diff --git a/packages/root-cms/ui/components/DocEditor/DocEditor.css b/packages/root-cms/ui/components/DocEditor/DocEditor.css index 03f3484c5..895969003 100644 --- a/packages/root-cms/ui/components/DocEditor/DocEditor.css +++ b/packages/root-cms/ui/components/DocEditor/DocEditor.css @@ -27,6 +27,7 @@ } .DocEditor__statusBar__saveState, +.DocEditor__statusBar__viewers, .DocEditor__statusBar__statusBadges { margin-right: 16px; } @@ -105,12 +106,71 @@ } .DocEditor__field.deeplink-target { - padding: 16px; + padding: 8px; + /* Negative horizontal margins offset the padding so the field contents stay + * aligned with unfocused fields (no horizontal shift). */ + margin-left: -8px; + margin-right: -8px; background: linear-gradient(rgb(255, 248, 197), rgb(255, 248, 197)); border: 1px solid rgba(212, 167, 44, 0.4); border-radius: 4px; } +/* For drawer object fields, highlight the drawer element itself rather than + * shifting the whole field with padding + negative margins. */ +.DocEditor__field.deeplink-target:has( + > .DocEditor__field__input + > .DocEditor__ObjectFieldDrawer + > .DocEditor__ObjectFieldDrawer__drawer + ) { + padding: 0; + margin-left: 0; + margin-right: 0; + background: none; + border: none; + border-radius: 0; +} + +.DocEditor__field.deeplink-target + > .DocEditor__field__input + > .DocEditor__ObjectFieldDrawer + > .DocEditor__ObjectFieldDrawer__drawer { + background: linear-gradient(rgb(255, 248, 197), rgb(255, 248, 197)); +} + +/* + * Presence highlight: when another user has this field focused, outline the + * field's actual input element (textarea, text input, select, etc.) in that + * user's color (matching their avatar), à la Google Docs. An outline (drawn + * just outside the control's own border) is used rather than tinting the + * border itself, which renders poorly on controls with inner chrome (e.g. + * selects). Note: focusing a container field directly (e.g. an array item + * drawer's toggle) marks the container as the presence field, so the rule is + * limited to leaf fields (no nested `.DocEditor__field`) to avoid outlining + * every input inside the container at once. + */ +/* For composite controls (e.g. the multiselect field or the richtext editor), + * outline the outer wrapper rather than the inner input (`[type='search']` is + * excluded below for that reason). */ +.DocEditor__field--presence:not(:has(.DocEditor__field)) + > .DocEditor__field__input + :is( + textarea, + select, + .LexicalEditor, + .DocEditor__MultiSelectField, + input:not( + [type='checkbox'], + [type='radio'], + [type='file'], + [type='search'] + ) + ) { + outline: 1px solid var(--presence-color); + outline-offset: 1px; + transition: outline-color 0.2s ease; +} + .DocEditor__FieldHeader { position: relative; padding-right: 30px; @@ -123,20 +183,12 @@ gap: 8px; } -.DocEditor__FieldHeader__label__deeplink { - color: #ced4da; +.DocEditor__FieldHeader__label__link { + color: inherit; text-decoration: none; - opacity: 0; - transition: opacity 0.3s ease; } -.DocEditor__FieldHeader__label:focus-within - .DocEditor__FieldHeader__label__deeplink, -.DocEditor__FieldHeader__label:hover .DocEditor__FieldHeader__label__deeplink { - opacity: 1; -} - -.DocEditor__FieldHeader__label__deeplink:hover { +.DocEditor__FieldHeader__label__link:hover { text-decoration: underline; text-underline-offset: 2px; } @@ -147,10 +199,52 @@ margin-top: 2px; } -.DocEditor__FieldHeader__translate { +/* + * Right-aligned actions row (presence avatars + translate icon). Absolutely + * positioned so it never causes layout shift; the avatars stay flush with the + * right edge of the field even when the translate icon isn't rendered. + */ +.DocEditor__FieldHeader__actions { position: absolute; top: 0; right: 0; + /* Match the height of the translate ActionIcon (size="xs" = 18px) so the + * avatars' centers line up with it (the avatars are taller due to their + * border). */ + height: 18px; + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; +} + +.DocEditor__FieldHeader__translate { + display: inline-flex; + align-items: center; +} + +/* + * Presence avatars for other users focused on this field. + */ +.DocEditor__FieldHeader__viewers { + display: flex; + flex-direction: row; + align-items: center; + pointer-events: none; +} + +/* Overlap is applied to the direct flex children (which may be a Tooltip + * wrapper element, not the avatar itself) so the layout stays stable on hover. */ +.DocEditor__FieldHeader__viewers > * { + pointer-events: auto; +} + +.DocEditor__FieldHeader__viewers > * + * { + margin-left: -8px; +} + +.DocEditor__FieldHeader__viewers__avatar { + margin-left: 0; } .DocEditor__FieldHeader__translate__iconDisabled { diff --git a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx index 10a6c5a50..d5bb85f1a 100644 --- a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx +++ b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx @@ -45,6 +45,7 @@ import { IconTriangleFilled, } from '@tabler/icons-preact'; import {createContext, RefObject} from 'preact'; +import {CSSProperties} from 'preact/compat'; import { useContext, useEffect, @@ -125,7 +126,8 @@ import {useLocalizationModal} from '../LocalizationModal/LocalizationModal.js'; import {useLockPublishingModal} from '../LockPublishingModal/LockPublishingModal.js'; import {usePublishDocModal} from '../PublishDocModal/PublishDocModal.js'; import {Text} from '../Text/Text.js'; -import {Viewers} from '../Viewers/Viewers.js'; +import {UserAvatar} from '../UserAvatar/UserAvatar.js'; +import {useFieldViewers, Viewers, ViewersProvider} from '../Viewers/Viewers.js'; import {BooleanField} from './fields/BooleanField.js'; import {DateField} from './fields/DateField.js'; import {DateTimeField} from './fields/DateTimeField.js'; @@ -226,34 +228,36 @@ export function DocEditor(props: DocEditorProps) { - -
- - - {!loading && !props.hideStatusBar && ( - + +
+ - )} -
- {fields.map((field) => ( - + {!loading && !props.hideStatusBar && ( + - ))} + )} +
+ {fields.map((field) => ( + + ))} +
-
-
+ + ); } @@ -408,10 +412,10 @@ DocEditor.StatusBar = (props: StatusBarProps) => { return (
+
- {data?.sys && (
{ const targeted = deeplink.value === props.deepKey; const ref = useRef(null); + // Presence highlight: tint the field's input element (textarea, text input, + // etc.) to match the (first other) viewer focused on it, à la Google Docs. + // Applied declaratively (rather than via imperative classList/style + // mutations) so it survives re-renders such as the `deeplink-target` class + // toggling on/off. + const fieldViewers = useFieldViewers(props.deepKey); + const presenceColor = useMemo(() => { + const other = fieldViewers.find((viewer) => !viewer.isCurrentUser); + return (other || fieldViewers[0])?.color || ''; + }, [fieldViewers]); + const showFieldHeader = useMemo(() => { if (field.type === 'object') { // Default to the "drawer" variant. @@ -787,8 +802,14 @@ DocEditor.FieldBody = (props: FieldProps) => { className={joinClassNames( 'DocEditor__field', field.deprecated && 'DocEditor__field--deprecated', - targeted && 'deeplink-target' + targeted && 'deeplink-target', + presenceColor && 'DocEditor__field--presence' )} + style={ + presenceColor + ? ({'--presence-color': presenceColor} as CSSProperties) + : undefined + } data-type={field.type} data-level={level} id={props.deepKey} @@ -854,31 +875,69 @@ DocEditor.FieldHeader = (props: FieldProps & {className?: string}) => {
DEPRECATED: {label}
) : ( )} {field.help && (
{field.help}
)} - +
+ + +
+
+ ); +}; + +/** + * Renders miniature avatars of other viewers who currently have this field + * focused. Sized to match the translate icon so it never causes layout shift. + */ +DocEditor.FieldHeaderViewers = (props: {deepKey: string}) => { + const viewers = useFieldViewers(props.deepKey); + + if (viewers.length === 0) { + return null; + } + return ( +
+ {viewers.map((viewer) => ( + + ))}
); }; @@ -1047,7 +1106,17 @@ DocEditor.ObjectFieldDrawer = (props: FieldProps) => { const modalTheme = useModalTheme(); const deeplink = useDeeplink(); - const initialOpen = !collapsed || deeplink.value.includes(props.deepKey); + const isDeeplinkTarget = deeplink.value.includes(props.deepKey); + const [open, setOpen] = useState(!collapsed || isDeeplinkTarget); + + // Open (but never force-close) the drawer when it becomes the deeplink + // target, so that clearing the deeplink (e.g. pressing Esc) leaves the + // drawer's open state untouched. + useEffect(() => { + if (isDeeplinkTarget) { + setOpen(true); + } + }, [isDeeplinkTarget]); const copyToClipboard = () => { const data = draft.getValue(props.deepKey) || {}; @@ -1131,7 +1200,10 @@ DocEditor.ObjectFieldDrawer = (props: FieldProps) => { >
{ + setOpen((e.currentTarget as HTMLDetailsElement).open); + }} >
@@ -1979,6 +2051,17 @@ interface ArrayFieldItemProps { DocEditor.ArrayFieldItem = (props: ArrayFieldItemProps) => { const itemDeepKey = `${props.parentDeepKey}.${props.itemKey}`; + // The drawer is natively toggled by the user, so `initialOpen` only ever + // forces it open (e.g. newly-added items or deeplink targets) — never + // closed. Without this, clearing the deeplink would re-render with a falsy + // `initialOpen` and collapse a drawer the user had open. + const [open, setOpen] = useState(props.initialOpen); + useEffect(() => { + if (props.initialOpen) { + setOpen(true); + } + }, [props.initialOpen]); + return ( {
{ - if ((e.target as HTMLDetailsElement).open) { + const isOpen = (e.target as HTMLDetailsElement).open; + setOpen(isOpen); + if (isOpen) { requestHighlightNode(itemDeepKey, {scroll: true}); } else { requestHighlightNode(null); diff --git a/packages/root-cms/ui/components/UserAvatar/UserAvatar.css b/packages/root-cms/ui/components/UserAvatar/UserAvatar.css index 2f2708efb..9c34763d4 100644 --- a/packages/root-cms/ui/components/UserAvatar/UserAvatar.css +++ b/packages/root-cms/ui/components/UserAvatar/UserAvatar.css @@ -1,11 +1,36 @@ .UserAvatar { - border: 2px solid var(--mantine-color-body, #fff); + border: 1px solid var(--mantine-color-body, #fff); box-sizing: content-box; - transition: opacity 0.2s ease; + transition: filter 0.2s ease; } +/* Grey out inactive avatars: desaturate, then fade toward white via a 50% + * white overlay. An overlay is used instead of opacity on the avatar itself, + * which looks off when avatars overlap (you'd see the avatar behind it bleed + * through). */ .UserAvatar--inactive { + position: relative; + filter: grayscale(1); +} + +.UserAvatar--inactive::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + background: #fff; opacity: 0.5; + pointer-events: none; +} + +.UserAvatar--clickable { + cursor: pointer; +} + +/* When a color ring is drawn, the colored border should sit directly against + * the photo (no white border/gap in between). */ +.UserAvatar--colorRing { + border: none; } .UserAvatar__tooltip { diff --git a/packages/root-cms/ui/components/UserAvatar/UserAvatar.tsx b/packages/root-cms/ui/components/UserAvatar/UserAvatar.tsx index d8cd0c053..9aee27485 100644 --- a/packages/root-cms/ui/components/UserAvatar/UserAvatar.tsx +++ b/packages/root-cms/ui/components/UserAvatar/UserAvatar.tsx @@ -29,6 +29,23 @@ export interface UserAvatarProps { * inactive (e.g. disconnected from a viewing session). */ inactive?: boolean; + /** + * Optional click handler. When provided, the avatar becomes interactive + * (e.g. to deeplink to the field a viewer is focused on). + */ + onClick?: (e: MouseEvent) => void; + /** + * If true, draws a colored ring around the avatar using the user's + * deterministic color (à la Google Docs), so the same user is easy to + * recognize across the UI even when they have a profile photo. When a + * profile photo is shown, this is the only color cue. + */ + colorRing?: boolean; + /** + * Width (in px) of the colored ring drawn when {@link colorRing} is set. + * Defaults to 1. + */ + ringWidth?: number; } /** @@ -50,33 +67,61 @@ export function UserAvatar(props: UserAvatarProps) { const displayName = profile?.displayName || ''; const photoURL = profile?.photoURL || ''; const [imgError, setImgError] = useState(false); + const [imgLoaded, setImgLoaded] = useState(false); + + const hasPhoto = Boolean(photoURL) && !imgError; + const showInitials = !hasPhoto; + const avatarColor = getAvatarColor(email || displayName || '?'); + + // Color-codes users consistently across the UI (à la Google Docs): a ring in + // the user's color directly around the photo. Drawn with box-shadow so it + // never shifts layout. + const ringWidth = props.ringWidth ?? 1; + const ringStyle = props.colorRing + ? {boxShadow: `0 0 0 ${ringWidth}px ${avatarColor}`} + : undefined; const avatar = ( setImgLoaded(true), onError: () => setImgError(true), }} > - {!photoURL || imgError ? getUserInitials(email, displayName) : null} + {showInitials ? getUserInitials(email, displayName) : null} ); diff --git a/packages/root-cms/ui/components/Viewers/Viewers.css b/packages/root-cms/ui/components/Viewers/Viewers.css index b266da9bf..aaa404735 100644 --- a/packages/root-cms/ui/components/Viewers/Viewers.css +++ b/packages/root-cms/ui/components/Viewers/Viewers.css @@ -8,8 +8,26 @@ } .Viewers__avatar { - border: 2px solid var(--mantine-color-body, #fff); - box-sizing: content-box; + position: relative; + /* Active avatars stack above their overlapping neighbors. */ + z-index: 1; +} + +/* Keep inactive (disconnected) avatars below active ones so the active users + * are always visually on top. */ +.Viewers__avatar.UserAvatar--inactive { + z-index: 0; +} + +/* Avatars that can be clicked to follow/deeplink to a viewer's focused field. */ +.Viewers__avatar--follow { + cursor: pointer; +} + +.Viewers__avatar--follow:hover { + /* Raise above neighbors on hover without affecting layout (transforms and + * filters would nudge surrounding content / cause a visible shift). */ + z-index: 2; } .Viewers > *:first-child { diff --git a/packages/root-cms/ui/components/Viewers/Viewers.tsx b/packages/root-cms/ui/components/Viewers/Viewers.tsx index 28ae3faa5..ea368032f 100644 --- a/packages/root-cms/ui/components/Viewers/Viewers.tsx +++ b/packages/root-cms/ui/components/Viewers/Viewers.tsx @@ -9,12 +9,19 @@ import { setDoc, updateDoc, } from 'firebase/firestore'; -import {useEffect, useState} from 'preact/hooks'; +import {ComponentChildren, createContext} from 'preact'; +import {useContext, useEffect, useMemo, useState} from 'preact/hooks'; import {normalizeSlug} from '../../../shared/slug.js'; +import { + scrollToDeeplink, + useOptionalDeeplink, +} from '../../hooks/useDeeplink.js'; +import {debounce} from '../../utils/debounce.js'; import {EventListener} from '../../utils/events.js'; import {throttle} from '../../utils/throttle.js'; import {TIME_UNITS} from '../../utils/time.js'; import {Timer} from '../../utils/timer.js'; +import {getAvatarColor} from '../../utils/user-profile.js'; import {UserAvatar} from '../UserAvatar/UserAvatar.js'; import './Viewers.css'; @@ -29,6 +36,8 @@ interface Viewer { photoURL: string; lastViewedAt: Timestamp; disconnectedAt: Timestamp; + /** The `deepKey` of the field this viewer currently has focused, if any. */ + focusedField?: string; } export interface ViewersProps { @@ -44,6 +53,7 @@ class ViewersController extends EventListener { started = false; timer: Timer = new Timer(UPDATE_INTERVAL); idleTimer: Timer = new Timer(IDLE_TIMEOUT); + private focusedField = ''; constructor(id: string) { super(); @@ -118,6 +128,38 @@ class ViewersController extends EventListener { this.idleTimer.reset(); }, 5000); + /** Broadcasts the field (`deepKey`) the current user has focused. */ + setFocusedField(deepKey: string) { + const value = deepKey || ''; + if (this.focusedField === value) { + return; + } + this.focusedField = value; + this.writeFocusedField(); + } + + private writeFocusedField = debounce(async () => { + if (!this.started) { + return; + } + const user = window.firebase.user; + if (!user.email) { + return; + } + await setDoc( + this.docRef, + { + [user.email]: { + email: user.email, + photoURL: user.photoURL, + focusedField: this.focusedField, + lastViewedAt: serverTimestamp(), + }, + }, + {merge: true} + ); + }, 200); + stop() { if (!this.started) { return; @@ -149,17 +191,45 @@ class ViewersController extends EventListener { } } +function isViewerDisconnected(viewer: Viewer) { + if (!viewer.disconnectedAt) { + return false; + } + return viewer.disconnectedAt > viewer.lastViewedAt; +} + +interface ViewersContextValue { + viewers: Viewer[]; + controller: ViewersController | null; + /** The `deepKey` of the field the current user currently has focused. */ + currentFocusedField: string; +} + +const ViewersContext = createContext({ + viewers: [], + controller: null, + currentFocusedField: '', +}); + /** - * Displays avatars viewing the current page. + * Hook that wires up a {@link ViewersController} for the given page `id`. It + * keeps the list of other active viewers up to date and tracks which field the + * current user has focused so it can be broadcast to other viewers. */ -export function Viewers(props: ViewersProps) { - const id = normalizeSlug(props.id); +function useViewersController(id: string): ViewersContextValue { const [viewers, setViewers] = useState([]); + const [controller, setController] = useState(null); + const [currentFocusedField, setCurrentFocusedField] = useState(''); useEffect(() => { + if (!id) { + return; + } const controller = new ViewersController(id); + setController(controller); controller.on('change', (viewers: Viewer[]) => setViewers(viewers)); controller.start(); + const onVisibilityChange = () => { if (document.hidden || document.visibilityState !== 'visible') { controller.stop(); @@ -170,22 +240,140 @@ export function Viewers(props: ViewersProps) { } }; document.addEventListener('visibilitychange', onVisibilityChange); + + // Broadcast which field the current user has focused so other viewers can + // render a presence indicator next to it. + const getFocusedDeepKey = () => { + const active = document.activeElement; + if (!active) { + return ''; + } + const fieldEl = active.closest('.DocEditor__field'); + return fieldEl?.id || ''; + }; + const updateFocusedField = () => { + const deepKey = getFocusedDeepKey(); + controller.setFocusedField(deepKey); + setCurrentFocusedField(deepKey); + }; + const onFocusIn = () => updateFocusedField(); + const onFocusOut = () => { + // Defer so `document.activeElement` reflects the newly focused element. + window.setTimeout(() => { + updateFocusedField(); + }, 0); + }; + document.addEventListener('focusin', onFocusIn); + document.addEventListener('focusout', onFocusOut); + return () => { controller.dispose(); document.removeEventListener('visibilitychange', onVisibilityChange); + document.removeEventListener('focusin', onFocusIn); + document.removeEventListener('focusout', onFocusOut); + setController(null); + setCurrentFocusedField(''); }; }, [id]); + return {viewers, controller, currentFocusedField}; +} + +/** + * Provides viewer presence (including per-field focus) to descendants. Wrap the + * doc editor with this so individual fields can render presence indicators. + */ +export function ViewersProvider(props: { + id: string; + children: ComponentChildren; +}) { + const id = normalizeSlug(props.id); + const value = useViewersController(id); + return ( + + {props.children} + + ); +} + +export interface FieldViewer { + email: string; + /** Whether this viewer is the current (signed-in) user. */ + isCurrentUser: boolean; + /** Deterministic color for this user (used to color-code presence). */ + color: string; +} + +/** + * Returns the viewers who currently have the field identified by `deepKey` + * focused. The current user is only included when at least one *other* viewer + * is also focused on the same field (so we never show your own avatar when + * you're the only one on the field). + */ +export function useFieldViewers(deepKey: string): FieldViewer[] { + const {viewers, currentFocusedField} = useContext(ViewersContext); + return useMemo(() => { + if (!deepKey) { + return []; + } + const others = viewers.filter( + (viewer) => + viewer.focusedField === deepKey && !isViewerDisconnected(viewer) + ); + if (others.length === 0) { + return []; + } + const result: FieldViewer[] = others.map((viewer) => ({ + email: viewer.email, + isCurrentUser: false, + color: getAvatarColor(viewer.email), + })); + // Include the current user's avatar when they share this field with at + // least one other viewer. + if (currentFocusedField === deepKey) { + const currentEmail = window.firebase.user?.email || ''; + if (currentEmail) { + result.unshift({ + email: currentEmail, + isCurrentUser: true, + color: getAvatarColor(currentEmail), + }); + } + } + return result; + }, [viewers, deepKey, currentFocusedField]); +} + +/** + * Displays avatars viewing the current page. When rendered inside a + * {@link ViewersProvider} it reuses the shared controller; otherwise it spins + * up its own controller (e.g. for standalone usage). + */ +export function Viewers(props: ViewersProps) { + const id = normalizeSlug(props.id); + const context = useContext(ViewersContext); + const standalone = useViewersController(context.controller ? '' : id); + const viewers = context.controller ? context.viewers : standalone.viewers; + const deeplink = useOptionalDeeplink(); + if (viewers.length === 0) { return null; } - function isViewerDisconnected(viewer: Viewer) { - if (!viewer.disconnectedAt) { - return false; + // Deeplinks to the field a viewer currently has focused (à la Figma's + // "click an avatar to follow"). Only available inside the doc editor where a + // `` is present. + const followViewer = (deepKey: string) => { + if (!deeplink || !deepKey) { + return; } - return viewer.disconnectedAt > viewer.lastViewedAt; - } + // setValue keeps the `?deeplink=` URL param in sync. + deeplink.setValue(deepKey); + const element = document.getElementById(deepKey); + if (element) { + scrollToDeeplink(element, {behavior: 'smooth', force: true}); + } + }; // Use plain CSS instead of `AvatarsGroup` because that // component doesn't support using Tooltips as children. @@ -195,15 +383,28 @@ export function Viewers(props: ViewersProps) { return (
- {visibleViewers.map((viewer) => ( - - ))} + {visibleViewers.map((viewer) => { + const canFollow = Boolean( + deeplink && viewer.focusedField && !isViewerDisconnected(viewer) + ); + return ( + followViewer(viewer.focusedField!) : undefined + } + /> + ); + })} {overflow > 0 && ( +{overflow} diff --git a/packages/root-cms/ui/hooks/useDeeplink.tsx b/packages/root-cms/ui/hooks/useDeeplink.tsx index 5bace3417..c675c483d 100644 --- a/packages/root-cms/ui/hooks/useDeeplink.tsx +++ b/packages/root-cms/ui/hooks/useDeeplink.tsx @@ -1,5 +1,11 @@ import {ComponentChildren, createContext} from 'preact'; -import {useContext, useEffect, useState} from 'preact/hooks'; +import { + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'preact/hooks'; import {isScrollToDeeplinkMessage} from '../../shared/embed-protocol.js'; import {isAllowedOrigin} from '../utils/embed-bridge.js'; @@ -20,17 +26,68 @@ function getDeeplinkFromUrl(): string { return params.get('deeplink') || ''; } +/** Writes (or clears) the `deeplink` query param without touching history. */ +function syncDeeplinkUrl(deepKey: string) { + const url = new URL(window.location.href); + if (deepKey) { + url.searchParams.set('deeplink', deepKey); + } else { + url.searchParams.delete('deeplink'); + } + window.history.replaceState({}, '', url.toString()); +} + const DEEPLINK_CONTEXT = createContext(null); export function DeeplinkProvider(props: {children: ComponentChildren}) { - const deeplinkFromUrl = getDeeplinkFromUrl(); - const [value, setValue] = useState(deeplinkFromUrl); + const [value, setValueState] = useState(getDeeplinkFromUrl); + + // Live mirror of `value` so `setValue` can read the current value + // synchronously (without depending on a possibly-stale render closure). + const valueRef = useRef(value); + valueRef.current = value; + + // Tracks a field that was *just* cleared so we can ignore the preview + // iframe's `scrollToDeeplink` echo (clicking/focusing in the preview posts a + // message back, which would otherwise immediately re-add the deeplink and + // cause a visible "flash off then back on" in the URL). + const recentlyCleared = useRef<{deepKey: string; at: number} | null>(null); + + // Single source of truth: update React state AND keep the URL in sync. This + // avoids fragile "read the URL on every render" coupling that made the + // deeplink toggle unreliable. + const setValue = useCallback((next: string) => { + const prev = valueRef.current; + if (!next && prev) { + // Mark the cleared key synchronously so an echo arriving before React + // commits the new state is still suppressed. + recentlyCleared.current = {deepKey: prev, at: Date.now()}; + } else if (next) { + recentlyCleared.current = null; + } + valueRef.current = next; + setValueState(next); + syncDeeplinkUrl(next); + }, []); + const deeplinkCtx = {value, setValue}; - // When navigating between docs (URL changes), update the deeplink. + // Allow pressing "Esc" to unselect the currently highlighted field. useEffect(() => { - setValue(deeplinkFromUrl); - }, [deeplinkFromUrl]); + if (!value) { + return; + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented) { + return; + } + setValue(''); + }; + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [value, setValue]); // Enable posting messages from the preview frame to the DocEditor so that // fields can be focused. @@ -43,14 +100,21 @@ export function DeeplinkProvider(props: {children: ComponentChildren}) { } if (isScrollToDeeplinkMessage(event.data)) { const deepKey = event.data.scrollToDeeplink.deepKey; + // Ignore the preview's echo of a deeplink the user just toggled off. + // Match the exact key as well as parent/child keys, since the echo can + // reference a nested field of the one that was cleared. + const cleared = recentlyCleared.current; + if (cleared && Date.now() - cleared.at < 1000) { + const a = cleared.deepKey; + const b = deepKey; + if (a === b || a.startsWith(`${b}.`) || b.startsWith(`${a}.`)) { + return; + } + } const element = document.getElementById(deepKey); if (element) { setValue(deepKey); scrollToDeeplink(element, {behavior: 'smooth', force: true}); - // Update the URL without modifying the history. - const url = new URL(window.location.href); - url.searchParams.set('deeplink', deepKey); - window.history.replaceState({}, '', url.toString()); } } }; @@ -58,7 +122,7 @@ export function DeeplinkProvider(props: {children: ComponentChildren}) { return () => { window.removeEventListener('message', handleMessage); }; - }, []); + }, [setValue]); return ( @@ -77,6 +141,15 @@ export function useDeeplink(): DeeplinkContext { return ctxValue; } +/** + * Like {@link useDeeplink} but returns `null` instead of throwing when there is + * no surrounding ``. Useful for components that can be used + * both inside and outside the doc editor (e.g. {@link Viewers}). + */ +export function useOptionalDeeplink(): DeeplinkContext | null { + return useContext(DEEPLINK_CONTEXT); +} + /** Opens all the ancestor detail elements. */ function setAncestorsOpen(deeplinkEl: HTMLElement) { let current = deeplinkEl; diff --git a/packages/root-cms/ui/utils/user-profile.ts b/packages/root-cms/ui/utils/user-profile.ts index 631f23709..16665d403 100644 --- a/packages/root-cms/ui/utils/user-profile.ts +++ b/packages/root-cms/ui/utils/user-profile.ts @@ -216,19 +216,24 @@ export function isOrgEmail(email: string): boolean { /** * Returns a deterministic background color for an email so missing-photo * avatars are visually distinct but stable across renders. + * + * The palette is a bright, saturated, Figma-style spread of hues so different + * users are easy to tell apart at a glance. */ export function getAvatarColor(email: string): string { const palette = [ - '#1f6feb', - '#0e7490', - '#0f766e', - '#15803d', - '#9333ea', - '#c026d3', - '#db2777', - '#dc2626', - '#ea580c', - '#b45309', + '#1e88ff', // blue + '#00b8d9', // cyan + '#00c781', // green + '#36b37e', // teal-green + '#ffab00', // amber + '#ff8b00', // orange + '#ff5630', // red-orange + '#f5365c', // pink-red + '#e040fb', // magenta + '#9c5cff', // violet + '#6554ff', // indigo + '#ff7eb6', // pink ]; let hash = 0; for (let i = 0; i < email.length; i++) {