From 9398a90ce5dbf0bcefe24ce13fb48a739c6a678f Mon Sep 17 00:00:00 2001 From: Sean Colsen Date: Tue, 17 Dec 2024 14:07:33 -0500 Subject: [PATCH 01/11] Create "New Item Highlighter" tool --- .../src/packages/new-item-highlighter/LICENSE | 21 ++++ .../packages/new-item-highlighter/README.md | 3 + .../new-item-highlighter/constants.ts | 12 +++ .../new-item-highlighter/highlight.ts | 74 ++++++++++++++ .../highlightNewItems.scss | 77 +++++++++++++++ .../new-item-highlighter/highlightNewItems.ts | 54 +++++++++++ .../src/packages/new-item-highlighter/hint.ts | 96 +++++++++++++++++++ .../packages/new-item-highlighter/index.ts | 1 + .../packages/new-item-highlighter/utils.ts | 55 +++++++++++ 9 files changed, 393 insertions(+) create mode 100644 mathesar_ui/src/packages/new-item-highlighter/LICENSE create mode 100644 mathesar_ui/src/packages/new-item-highlighter/README.md create mode 100644 mathesar_ui/src/packages/new-item-highlighter/constants.ts create mode 100644 mathesar_ui/src/packages/new-item-highlighter/highlight.ts create mode 100644 mathesar_ui/src/packages/new-item-highlighter/highlightNewItems.scss create mode 100644 mathesar_ui/src/packages/new-item-highlighter/highlightNewItems.ts create mode 100644 mathesar_ui/src/packages/new-item-highlighter/hint.ts create mode 100644 mathesar_ui/src/packages/new-item-highlighter/index.ts create mode 100644 mathesar_ui/src/packages/new-item-highlighter/utils.ts diff --git a/mathesar_ui/src/packages/new-item-highlighter/LICENSE b/mathesar_ui/src/packages/new-item-highlighter/LICENSE new file mode 100644 index 0000000000..ddf685f590 --- /dev/null +++ b/mathesar_ui/src/packages/new-item-highlighter/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Mathesar Foundation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/mathesar_ui/src/packages/new-item-highlighter/README.md b/mathesar_ui/src/packages/new-item-highlighter/README.md new file mode 100644 index 0000000000..f7a578014c --- /dev/null +++ b/mathesar_ui/src/packages/new-item-highlighter/README.md @@ -0,0 +1,3 @@ +# New Item Highlighter + +This is a Svelte action that highlights new items in a list. diff --git a/mathesar_ui/src/packages/new-item-highlighter/constants.ts b/mathesar_ui/src/packages/new-item-highlighter/constants.ts new file mode 100644 index 0000000000..6a48d1bd92 --- /dev/null +++ b/mathesar_ui/src/packages/new-item-highlighter/constants.ts @@ -0,0 +1,12 @@ +/** The transition time for the highlight effect, in milliseconds */ +export const HIGHLIGHT_TRANSITION_MS = 2 * 1000; // 2 seconds + +/** The amount of time in milliseconds before we begin fading out the hint. */ +export const HINT_EXPIRATION_START_MS = 10 * 1000; // 10 seconds + +/** The time it will take to fade out the hint. */ +export const HINT_EXPIRATION_TRANSITION_MS = 3 * 1000; // 3 seconds + +/** The time at which we can remove the hint DOM nodes. */ +export const HINT_EXPIRATION_END_MS = + HINT_EXPIRATION_START_MS + HINT_EXPIRATION_TRANSITION_MS; diff --git a/mathesar_ui/src/packages/new-item-highlighter/highlight.ts b/mathesar_ui/src/packages/new-item-highlighter/highlight.ts new file mode 100644 index 0000000000..26b6769d98 --- /dev/null +++ b/mathesar_ui/src/packages/new-item-highlighter/highlight.ts @@ -0,0 +1,74 @@ +import { HIGHLIGHT_TRANSITION_MS, HINT_EXPIRATION_END_MS } from './constants'; +import { displayHint } from './hint'; +import { getRectCssGeometry, onElementRemoved } from './utils'; + +function makeHighlighterElement(): HTMLElement { + const effect = document.createElement('div'); + effect.className = 'effect'; + + const highlight = document.createElement('div'); + highlight.className = 'new-item-highlighter'; + highlight.appendChild(effect); + + return highlight; +} + +function displayHighlight(target: HTMLElement): void { + const highlight = makeHighlighterElement(); + highlight.style.setProperty('--duration', `${HIGHLIGHT_TRANSITION_MS}ms`); + document.body.appendChild(highlight); + + function trackPosition() { + if (!target.isConnected) return; + const rect = target.getBoundingClientRect(); + Object.assign(highlight.style, getRectCssGeometry(rect)); + requestAnimationFrame(trackPosition); + } + trackPosition(); + + function cleanup() { + highlight.remove(); + } + + onElementRemoved(target, cleanup); + setTimeout(cleanup, HIGHLIGHT_TRANSITION_MS); +} + +export function setupHighlighter( + target: HTMLElement, + options: { + scrollHint?: string; + }, +): () => void { + let cleanupHint: (() => void) | undefined; + + const intersectionObserver = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (entry.isIntersecting && entry.intersectionRatio >= 0.5) { + displayHighlight(target); + // eslint-disable-next-line @typescript-eslint/no-use-before-define + cleanup(); + } else if (options.scrollHint && !cleanupHint) { + cleanupHint = displayHint(target, options.scrollHint); + } + }, + { threshold: [0.5] }, + ); + + function cleanup() { + intersectionObserver.disconnect(); + cleanupHint?.(); + } + + intersectionObserver.observe(target); + + onElementRemoved(target, cleanup); + + // If the user still hasn't seen the hint or the highlight (i.e. if it's + // scrolled out of view), then we give up and remove them. This means + // `displayHighlight` will never be called. + setTimeout(cleanup, HINT_EXPIRATION_END_MS); + + return cleanup; +} diff --git a/mathesar_ui/src/packages/new-item-highlighter/highlightNewItems.scss b/mathesar_ui/src/packages/new-item-highlighter/highlightNewItems.scss new file mode 100644 index 0000000000..010df65b9e --- /dev/null +++ b/mathesar_ui/src/packages/new-item-highlighter/highlightNewItems.scss @@ -0,0 +1,77 @@ +body > .new-item-highlighter { + position: absolute; + z-index: var(--new-item-highlighter-z-index, 1000); + pointer-events: none; + --easing: cubic-bezier(0.5, 0, 1, 0.5); + + .effect { + position: absolute; + inset: -3rem; + border-radius: 3rem; + background: transparent; + mix-blend-mode: darken; + transition: + background var(--duration) var(--easing), + border-radius var(--duration) var(--easing), + inset var(--duration) var(--easing); + pointer-events: none; + filter: blur(0.2rem); + } + + @starting-style { + .effect { + background: rgba(254, 221, 72, 0.3); + border-radius: 0.5rem; + inset: 0; + } + } +} + +body > .new-item-highlighter__hint { + position: absolute; + z-index: var(--new-item-highlighter-z-index, 1000); + --background: rgba(0, 0, 0, 0.7); + inset: 0px auto auto 0px; + display: flex; + flex-direction: column; + align-items: center; + opacity: 0; + + @starting-style { + opacity: 1; + } + + .message { + background-color: var(--background); + color: white; + padding: 0.5rem; + border-radius: 0.3rem; + max-width: 15rem; + text-align: center; + cursor: pointer; + } + + &:hover { + --background: black; + } + + svg { + height: 1.5rem; + width: 1.5rem; + margin-bottom: -1px; + cursor: pointer; + + path { + fill: var(--background); + } + } + + &.down { + flex-direction: column-reverse; + svg { + transform: rotate(180deg); + margin-bottom: 0; + margin-top: -1px; + } + } +} diff --git a/mathesar_ui/src/packages/new-item-highlighter/highlightNewItems.ts b/mathesar_ui/src/packages/new-item-highlighter/highlightNewItems.ts new file mode 100644 index 0000000000..0f716708a2 --- /dev/null +++ b/mathesar_ui/src/packages/new-item-highlighter/highlightNewItems.ts @@ -0,0 +1,54 @@ +import type { ActionReturn } from 'svelte/action'; + +import { setupHighlighter } from './highlight'; +import { getNewlyAddedItemsFromMutations } from './utils'; + +export function highlightNewItems( + container: HTMLElement, + options: { + /** + * The number of milliseconds to wait before setting up highlighting. + * + * This defaults to 2000 (i.e. 2 seconds) to give children time for the + * initial load if necessary, and because in most of the contexts where we + * want to use this, we don't expect the user to be able to perform data + * entry in under 2 seconds. + * + * Set this to 0 to start highlighting immediately. If the children are + * rendered synchronously, then highlighting will still be deferred to new + * items. + */ + wait?: number; + /** + * Pass a string to display a hint to the user when the new item is + * scrolled out of view. + */ + scrollHint?: string; + } = {}, +): ActionReturn { + const wait = options.wait ?? 2000; + + const cleanupFns: (() => void)[] = []; + + function init() { + const mutationObserver = new MutationObserver((mutations) => { + for (const item of getNewlyAddedItemsFromMutations(mutations)) { + cleanupFns.push(setupHighlighter(item, options)); + } + }); + cleanupFns.push(() => mutationObserver.disconnect()); + mutationObserver.observe(container, { childList: true }); + } + + if (wait) { + setTimeout(init, wait); + } else { + init(); + } + + return { + destroy() { + cleanupFns.forEach((fn) => fn()); + }, + }; +} diff --git a/mathesar_ui/src/packages/new-item-highlighter/hint.ts b/mathesar_ui/src/packages/new-item-highlighter/hint.ts new file mode 100644 index 0000000000..303158a998 --- /dev/null +++ b/mathesar_ui/src/packages/new-item-highlighter/hint.ts @@ -0,0 +1,96 @@ +import { + HINT_EXPIRATION_START_MS, + HINT_EXPIRATION_TRANSITION_MS, +} from './constants'; +import { + getNearestVerticallyScrollableAncestor, + onElementRemoved, +} from './utils'; + +function makeArrowElement(): SVGSVGElement { + const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + arrow.setAttribute('viewBox', '0 0 400 400'); + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute( + 'd', + 'm 358,179.5 c 3.8,-8.8 2,-19 -4.6,-26 L 217.4,9.5 C 212.9,4.7 206.6,2 200,2 193.4,2 187.1,4.7 182.6,9.5 l -136,144 c -6.6,7 -8.4,17.2 -4.6,26 3.8,8.8 12.4,14.5 22,14.5 h 72 V 400 H 264 V 194 h 72 c 9.6,0 18.2,-5.7 22,-14.5 z', + ); + arrow.appendChild(path); + return arrow; +} + +function makeHintElement( + message: string, + direction: 'up' | 'down', +): HTMLElement { + const hintElement = document.createElement('div'); + hintElement.classList.add('new-item-highlighter__hint', direction); + hintElement.style.setProperty( + 'transition', + `opacity ${HINT_EXPIRATION_TRANSITION_MS}ms ${HINT_EXPIRATION_START_MS}ms`, + ); + const messageElement = document.createElement('div'); + messageElement.textContent = message; + messageElement.className = 'message'; + hintElement.appendChild(makeArrowElement()); + hintElement.appendChild(messageElement); + return hintElement; +} + +export function displayHint(target: HTMLElement, message: string): () => void { + const container = getNearestVerticallyScrollableAncestor(target); + if (!container) return () => {}; + + const containerTop = container.getBoundingClientRect().top; + const targetTop = target.getBoundingClientRect().top; + const direction = targetTop > containerTop ? 'down' : 'up'; + + const hintElement = makeHintElement(message, direction); + document.body.appendChild(hintElement); + const hintRect = hintElement.getBoundingClientRect(); + + function positionHint() { + if (!container) return; + const containerRect = container.getBoundingClientRect(); + const top = + direction === 'up' + ? containerRect.top + : containerRect.bottom - hintRect.height; + const left = + containerRect.left + (containerRect.width - hintRect.width) / 2; + hintElement.style.top = `${top}px`; + hintElement.style.left = `${left}px`; + } + + const resizeObserver = new ResizeObserver(positionHint); + resizeObserver.observe(container); + resizeObserver.observe(document.body); + + function scrollContainerToTarget() { + if (!container) return; + const containerRect = container.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + const top = + direction === 'up' + ? targetRect.top - containerRect.top + : targetRect.bottom - containerRect.bottom; + container.scrollTo({ + top, + behavior: 'smooth', + }); + } + + hintElement.addEventListener('click', scrollContainerToTarget); + + function cleanup() { + hintElement.remove(); + resizeObserver.disconnect(); + document.body.removeEventListener('click', cleanup); + } + + onElementRemoved(target, cleanup); + onElementRemoved(container, cleanup); + setTimeout(() => document.body.addEventListener('click', cleanup), 50); + + return cleanup; +} diff --git a/mathesar_ui/src/packages/new-item-highlighter/index.ts b/mathesar_ui/src/packages/new-item-highlighter/index.ts new file mode 100644 index 0000000000..2c35b28ac5 --- /dev/null +++ b/mathesar_ui/src/packages/new-item-highlighter/index.ts @@ -0,0 +1 @@ +export { highlightNewItems } from './highlightNewItems'; diff --git a/mathesar_ui/src/packages/new-item-highlighter/utils.ts b/mathesar_ui/src/packages/new-item-highlighter/utils.ts new file mode 100644 index 0000000000..3923b1ddaa --- /dev/null +++ b/mathesar_ui/src/packages/new-item-highlighter/utils.ts @@ -0,0 +1,55 @@ +export function getRectCssGeometry( + rect: DOMRect, +): Partial { + return { + top: `${rect.top}px`, + left: `${rect.left}px`, + width: `${rect.width}px`, + height: `${rect.height}px`, + }; +} + +export function onElementRemoved(element: HTMLElement, fn: () => void): void { + const mutationObserver = new MutationObserver(() => { + if (element.isConnected) return; + fn(); + mutationObserver.disconnect(); + }); + mutationObserver.observe(document.body, { childList: true, subtree: true }); +} + +export function isVerticallyScrollable(element: HTMLElement): boolean { + const style = window.getComputedStyle(element); + const { overflowY } = style; + return ( + (overflowY === 'auto' || overflowY === 'scroll') && + element.scrollHeight > element.clientHeight + ); +} + +export function getNearestVerticallyScrollableAncestor( + element: HTMLElement, +): HTMLElement | undefined { + const parent = element.parentElement; + if (!parent) return undefined; + return isVerticallyScrollable(parent) + ? parent + : getNearestVerticallyScrollableAncestor(parent); +} + +export function* getNewlyAddedItemsFromMutations( + mutations: Iterable, +): Generator { + for (const mutation of mutations) { + if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { + for (const node of mutation.addedNodes) { + if ( + node.nodeType === Node.ELEMENT_NODE && + node instanceof HTMLElement + ) { + yield node; + } + } + } + } +} From ce19d53a2d2a32caab2b5e29502a076980a0dfcc Mon Sep 17 00:00:00 2001 From: Sean Colsen Date: Tue, 17 Dec 2024 14:17:54 -0500 Subject: [PATCH 02/11] Add new-item-highlighting in perms modal --- mathesar_ui/src/App.svelte | 4 +- mathesar_ui/src/i18n/languages/en/dict.json | 1 + .../permissions/overview/AccessControl.svelte | 59 ++++++++++--------- 3 files changed, 36 insertions(+), 28 deletions(-) diff --git a/mathesar_ui/src/App.svelte b/mathesar_ui/src/App.svelte index 5b59698d6d..fd4ac57138 100644 --- a/mathesar_ui/src/App.svelte +++ b/mathesar_ui/src/App.svelte @@ -35,6 +35,7 @@ -->