From 0cdd88c1e5c6f4a52e2d71651f5ee8a8422712c3 Mon Sep 17 00:00:00 2001 From: Herman Wikner Date: Fri, 26 Apr 2024 17:23:47 +0200 Subject: [PATCH] feat(core): implement presence cursors (#6081) * feat(portable-text-editor): improve range decorations perf by comparing prev values * test(portable-text-editor): add tests for range decorations rendering * fixup! feat(portable-text-editor): improve range decorations perf by comparing prev values * test(portable-text-editor): improve some minor test things * fix(core): update z-index on PTE activate overlay * feat(core): extend presence data model with `selection` * feat(structure): include `selection` in presence data * feat(core): implement presence cursors in PTE * test(core): add presence cursor workshop story * refactor(core): use `getTheme_v2` to access theme values * fix(core): introduce `OnPathFocusPayload` to improve typing * fix(form/inputs): perf optimization for PT-input decorators * refactor(form/inputs): also display user presence when user is selecting a range, but only the focus point of it * fix(core): render presence cursors inline instead of in a portal * fix(core): broken workshop story * feat(core): reset presence selection on blur in PTE * fix(structure): add accidentially removed setFocusPath (DocumentPaneProvider) This seems to have been lost in a rebase. * fix(core): use `useFormFieldPresence` in `usePresenceCursorDecorations` * fix(core): set `focusPath` when receiving a mutation event if there are pending patches * fix(form/inputs): break don't return * fix(form/inputs): remove ref that should not be set here This ref is supposed to be set elsewhere (on the PTE Editable component) See eca960e37be3307cbc7f4bbca15c69877001d44e * fix(form/inputs): sort hook deps list * fix(form/inputs): reconcile presence decorations for PT-Input Make sure these objects stays as stable as possible in order to not redraw any range decorations unnecessary. * fix(form/inputs): return early if focusPath is already selected in PT-Input * refactor(form/inputs): throttle reporting of focusPath and presence updates for PT-input * fix(core): import of `FormNodePresence` * fix(form/inputs): remove lastActive as dep for PT-presence range decorations uniqueness We are not using this value anyway, and it complicates the reconciliation of the presence decorators * test(core): add presence cursor workshop story * fix(core): remove unnecessary z-index in PTE activate overlay * fix(core): prevent presence cursor user name from being selected * fix(form/inputs): fix issue where perf opt on focusPath tracking broke tests This perf opt. broke some tests. It's not clear if it is a problem with the test or the func. so restoring the old behaviour for now. * test(core): update snapshots in `Studio.test` * refactor(core): move presence decorations according to user edits Use a own state for this. * refactor(core/form): remove complexity from PortableTextInput Handle debouncing in the DocumentProvider instead. Setting focusPath must be synchronous * refactor(structure): announce presence throttled When we introduced presence in the PortableTextInput, we risk calling the presence updates very often. There should be no reason for not doing this throttled, as long as we have leading true. * refactor(core): clean up presence cursors code + add comments --------- Co-authored-by: Per-Kristian Nordnes --- .../form/inputs/PortableText/Compositor.tsx | 14 +- .../inputs/PortableText/PortableTextInput.tsx | 119 ++++++---- .../__workshop__/PresenceInputStory.tsx | 209 ++++++++++++++++++ .../__workshop__/UserPresenceCursorStory.tsx | 61 +++++ .../inputs/PortableText/__workshop__/index.ts | 10 + .../PortableText/hooks/useTrackFocusPath.tsx | 17 +- .../presence-cursors/UserPresenceCursor.tsx | 157 +++++++++++++ .../PortableText/presence-cursors/index.ts | 1 + .../usePresenceCursorDecorations.tsx | 93 ++++++++ .../object/fields/ArrayOfObjectsField.tsx | 5 +- .../form/studio/contexts/FormCallbacks.tsx | 7 +- .../sanity/src/core/form/types/inputProps.ts | 10 +- packages/sanity/src/core/presence/types.ts | 2 + .../store/_legacy/presence/presence-store.ts | 1 + .../src/core/store/_legacy/presence/types.ts | 2 + .../sanity/src/core/studio/Studio.test.tsx | 4 +- .../panes/document/DocumentPaneProvider.tsx | 35 ++- 17 files changed, 675 insertions(+), 72 deletions(-) create mode 100644 packages/sanity/src/core/form/inputs/PortableText/__workshop__/PresenceInputStory.tsx create mode 100644 packages/sanity/src/core/form/inputs/PortableText/__workshop__/UserPresenceCursorStory.tsx create mode 100644 packages/sanity/src/core/form/inputs/PortableText/presence-cursors/UserPresenceCursor.tsx create mode 100644 packages/sanity/src/core/form/inputs/PortableText/presence-cursors/index.ts create mode 100644 packages/sanity/src/core/form/inputs/PortableText/presence-cursors/usePresenceCursorDecorations.tsx diff --git a/packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx b/packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx index ada4dd03aaf..7d7ef09b031 100644 --- a/packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx +++ b/packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx @@ -66,8 +66,8 @@ export function Compositor(props: Omit(null) const editorRef = editorRefProp || defaultEditorRef + const presenceCursorDecorations = usePresenceCursorDecorations( + useMemo( + (): PresenceCursorDecorationsHookProps => ({ + path: props.path, + }), + [props.path], + ), + ) + const {subscribe} = usePatches({path}) const [ignoreValidationError, setIgnoreValidationError] = useState(false) const [invalidValue, setInvalidValue] = useState(null) @@ -139,31 +155,41 @@ export function PortableTextInput(props: PortableTextInputProps): ReactNode { } }, [hasFocusWithin]) - // Report focus on spans with `.text` appended to the reported focusPath. - // This is done to support the Presentation tool which uses this kind of paths to refer to texts. - // The PT-input already supports these paths the other way around. - // It's a bit ugly right here, but it's a rather simple way to support the Presentation tool without - // having to change the PTE's internals. - const setFocusPathFromEditorSelection = useCallback( - (focusPath: Path) => { - // Test if the focusPath is pointing directly to a span - const isSpanPath = - focusPath.length === 3 && // A span path is always 3 segments long - focusPath[1] === 'children' && // Is a child of a block - isKeySegment(focusPath[2]) && // Contains the key of the child - !portableTextMemberItems.some( - (item) => isKeySegment(focusPath[2]) && item.member.key === focusPath[2]._key, - ) // Not an inline object (it would be a member in this list, where spans are not). By doing this check we avoid depending on the value. - if (isSpanPath) { - // Append `.text` to the focusPath - onPathFocus(focusPath.concat('text')) - } else { - // Call normally - onPathFocus(focusPath) - } - }, - [onPathFocus, portableTextMemberItems], - ) + const setFocusPathFromEditorSelection = useCallback(() => { + const selection = nextSelectionRef.current + const focusPath = selection?.focus.path + if (!focusPath) return + + // Report focus on spans with `.text` appended to the reported focusPath. + // This is done to support the Presentation tool which uses this kind of paths to refer to texts. + // The PT-input already supports these paths the other way around. + // It's a bit ugly right here, but it's a rather simple way to support the Presentation tool without + // having to change the PTE's internals. + const isSpanPath = + focusPath.length === 3 && // A span path is always 3 segments long + focusPath[1] === 'children' && // Is a child of a block + isKeySegment(focusPath[2]) && // Contains the key of the child + !portableTextMemberItems.some( + (item) => isKeySegment(focusPath[2]) && item.member.key === focusPath[2]._key, + ) + const nextFocusPath = isSpanPath ? focusPath.concat(['text']) : focusPath + + // Must called in a transition useTrackFocusPath hook + // will try to effectuate a focusPath that is different from what currently is the editor focusPath + startTransition(() => { + onPathFocus(nextFocusPath, { + selection, + }) + }) + }, [onPathFocus, portableTextMemberItems]) + + const resetSelectionPresence = useCallback(() => { + onPathFocus(props.path, { + selection: null, + }) + }, [onPathFocus, props.path]) + + const nextSelectionRef = useRef(null) // Handle editor changes const handleEditorChange = useCallback( @@ -180,13 +206,8 @@ export function PortableTextInput(props: PortableTextInputProps): ReactNode { } break case 'selection': - // This doesn't need to be immediate, - // call through startTransition - startTransition(() => { - if (change.selection) { - setFocusPathFromEditorSelection(change.selection.focus.path) - } - }) + nextSelectionRef.current = change.selection + setFocusPathFromEditorSelection() break case 'focus': setIsActive(true) @@ -195,6 +216,11 @@ export function PortableTextInput(props: PortableTextInputProps): ReactNode { case 'blur': onBlur(change.event) setHasFocusWithin(false) + + // When the editor blurs, we reset the presence selection + // in order to remove the presence cursor for the current user + // since they no longer have an active selection in the editor. + resetSelectionPresence() break case 'undo': case 'redo': @@ -215,7 +241,15 @@ export function PortableTextInput(props: PortableTextInputProps): ReactNode { onEditorChange(change, editorRef.current) } }, - [editorRef, onBlur, onChange, onEditorChange, setFocusPathFromEditorSelection, toast], + [ + editorRef, + onEditorChange, + onChange, + setFocusPathFromEditorSelection, + onBlur, + resetSelectionPresence, + toast, + ], ) useEffect(() => { @@ -251,8 +285,17 @@ export function PortableTextInput(props: PortableTextInputProps): ReactNode { } }, [editorRef, isActive]) + const previousRangeDecorations = useRef([]) + + const rangeDecorations = useMemo((): RangeDecoration[] => { + const result = [...(rangeDecorationsProp || []), ...presenceCursorDecorations] + const reconciled = immutableReconcile(previousRangeDecorations.current, result) + previousRangeDecorations.current = reconciled + return reconciled + }, [presenceCursorDecorations, rangeDecorationsProp]) + return ( - + {!ignoreValidationError && respondToInvalidContent} {(!invalidValue || ignoreValidationError) && ( @@ -274,9 +317,9 @@ export function PortableTextInput(props: PortableTextInputProps): ReactNode { isActive={isActive} isFullscreen={isFullscreen} onActivate={handleActivate} - onItemRemove={onItemRemove} onCopy={onCopy} onInsert={onInsert} + onItemRemove={onItemRemove} onPaste={onPaste} onToggleFullscreen={handleToggleFullscreen} rangeDecorations={rangeDecorations} diff --git a/packages/sanity/src/core/form/inputs/PortableText/__workshop__/PresenceInputStory.tsx b/packages/sanity/src/core/form/inputs/PortableText/__workshop__/PresenceInputStory.tsx new file mode 100644 index 00000000000..a685c7bc8c4 --- /dev/null +++ b/packages/sanity/src/core/form/inputs/PortableText/__workshop__/PresenceInputStory.tsx @@ -0,0 +1,209 @@ +import { + type EditorChange, + type EditorSelection, + PortableTextEditable, + PortableTextEditor, + type RenderBlockFunction, +} from '@sanity/portable-text-editor' +import {Schema} from '@sanity/schema/index' +import {type PortableTextBlock} from '@sanity/types' +import {Card, Container, Flex, Stack, Text} from '@sanity/ui' +import {useCallback, useMemo, useRef, useState} from 'react' +import {defineArrayMember, defineField, type FormNodePresence} from 'sanity' +import {css, styled} from 'styled-components' + +import {PresenceProvider} from '../../../studio/contexts/Presence' +import {usePresenceCursorDecorations} from '../presence-cursors' + +const renderBlock: RenderBlockFunction = (p) => ( +
+ {p.children} +
+) + +const EditorCard = styled(Card)(({theme}) => { + const color = theme.sanity.v2?.color.focusRing + + return css` + min-height: 150px; + + &:focus-within { + border: 1px solid ${color}; + } + ` +}) + +const INLINE_STYLE: React.CSSProperties = {outline: 'none'} + +const INITIAL_VALUE: PortableTextBlock[] = [ + { + _type: 'block', + _key: 'd2d43dfa67d0', + style: 'normal', + markDefs: [], + children: [ + { + _type: 'span', + _key: '997e2b7c72ba', + text: 'This story is used to debug presence cursors.', + marks: [], + }, + ], + }, +] + +const USER_1: FormNodePresence = { + lastActiveAt: new Date().toISOString(), + path: ['body', 'text'], + sessionId: 'id-1', + user: { + id: 'user-1', + displayName: 'User A', + }, +} + +const USER_2: FormNodePresence = { + lastActiveAt: new Date().toISOString(), + path: ['body', 'text'], + sessionId: 'id-2', + user: { + id: 'user-2', + displayName: 'User B', + }, +} + +const INITIAL_PRESENCE: FormNodePresence[] = [USER_1, USER_2] + +const blockType = defineField({ + type: 'block', + name: 'block', + styles: [{title: 'Normal', value: 'normal'}], +}) + +const portableTextType = defineArrayMember({ + type: 'array', + name: 'body', + of: [blockType], +}) + +const schema = Schema.compile({ + name: 'body', + types: [portableTextType], +}) + +export default function PresenceInputStory() { + const [value, setValue] = useState(INITIAL_VALUE) + const [presence, setPresence] = useState(INITIAL_PRESENCE) + + const handleSelectionChange = useCallback( + (nextSelection: EditorSelection, userId: 'user-1' | 'user-2') => { + setPresence((current) => { + return current.map((p) => { + if (p.user.id === userId) { + return { + ...p, + selection: nextSelection, + } + } + + return p + }) + }) + }, + [], + ) + + const presenceA = useMemo(() => presence.filter((v) => v.user.id !== 'user-1'), [presence]) + const presenceB = useMemo(() => presence.filter((v) => v.user.id !== 'user-2'), [presence]) + + return ( + + + {/* User A editor */} + + + User A + + + + handleSelectionChange(v, 'user-1')} + value={value} + /> + + + + + + + {/* User B editor */} + + + + User B + + + + handleSelectionChange(v, 'user-2')} + value={value} + /> + + + + + ) +} + +interface InputProps { + onChange: (value: PortableTextBlock[]) => void + onSelectionChange: (presence: EditorSelection) => void + value: PortableTextBlock[] +} + +function Input(props: InputProps) { + const {onChange, onSelectionChange, value} = props + const editorRef = useRef(null) + + const decorations = usePresenceCursorDecorations({ + path: ['body'], + }) + + const handleChange = useCallback( + (e: EditorChange) => { + if (e.type === 'patch' && editorRef.current) { + const nextValue = PortableTextEditor.getValue(editorRef.current) + + onChange(nextValue || []) + } + + if (e.type === 'selection') { + onSelectionChange(e.selection) + } + }, + [onChange, onSelectionChange], + ) + + return ( + + + + + + ) +} diff --git a/packages/sanity/src/core/form/inputs/PortableText/__workshop__/UserPresenceCursorStory.tsx b/packages/sanity/src/core/form/inputs/PortableText/__workshop__/UserPresenceCursorStory.tsx new file mode 100644 index 00000000000..a4cb009ed35 --- /dev/null +++ b/packages/sanity/src/core/form/inputs/PortableText/__workshop__/UserPresenceCursorStory.tsx @@ -0,0 +1,61 @@ +import {Card, Container, Flex, Heading, Stack, Text} from '@sanity/ui' + +import {HighlightSpan} from '../../../../comments' +import {UserPresenceCursor} from '../presence-cursors/UserPresenceCursor' + +const user1 = + +const user2 = + +const user3 = + +const user4 = + +const user5 = + +const user6 = + +const user7 = + +function Editor() { + return ( + + Introducing: User Presence C{user4}ursors + + + We are introducing a new feature t{user2}ha{user2}t allows you to see where other users are + currently editing in the document. This is a great way to avoid conflicts and collaborat + {user5}e in real-time. + + + + Keep an ey{user1}e out for the{' '} + + colored dot{user7}s + {' '} + and lines that indicate where other users are currently editing. Hover over the dots{user6}{' '} + to see who is editin{user3}g that part of the document. + + + ) +} + +export default function UserPresenceCursorStory() { + return ( + + + + + + + + + + + + + + + + ) +} diff --git a/packages/sanity/src/core/form/inputs/PortableText/__workshop__/index.ts b/packages/sanity/src/core/form/inputs/PortableText/__workshop__/index.ts index 69b10f5f5ff..73638d78463 100644 --- a/packages/sanity/src/core/form/inputs/PortableText/__workshop__/index.ts +++ b/packages/sanity/src/core/form/inputs/PortableText/__workshop__/index.ts @@ -28,5 +28,15 @@ export default defineScope({ title: 'Text blocks', component: lazy(() => import('./textBlocks')), }, + { + name: 'user-presence-cursor', + title: 'User presence cursor', + component: lazy(() => import('./UserPresenceCursorStory')), + }, + { + name: 'presence-input-story', + title: 'Presence input story', + component: lazy(() => import('./PresenceInputStory')), + }, ], }) diff --git a/packages/sanity/src/core/form/inputs/PortableText/hooks/useTrackFocusPath.tsx b/packages/sanity/src/core/form/inputs/PortableText/hooks/useTrackFocusPath.tsx index aec69dbf8d4..fa309877026 100644 --- a/packages/sanity/src/core/form/inputs/PortableText/hooks/useTrackFocusPath.tsx +++ b/packages/sanity/src/core/form/inputs/PortableText/hooks/useTrackFocusPath.tsx @@ -19,6 +19,7 @@ interface Props { // This hook will track the form focusPath and make sure editor content is visible (opened), scrolled to, and (potentially) focused accordingly. export function useTrackFocusPath(props: Props): void { const {focusPath, boundaryElement, onItemClose} = props + const portableTextMemberItems = usePortableTextMemberItems() const editor = usePortableTextEditor() const selection = usePortableTextEditorSelection() @@ -29,6 +30,14 @@ export function useTrackFocusPath(props: Props): void { return } + // Don't do anything if the editor selection focus path is already equal to the focusPath + if ( + selection?.focus.path && + isEqual(selection.focus.path, focusPath.slice(0, selection.focus.path.length)) + ) { + return + } + // Find the focused editor member item (if any) const focusedItem = portableTextMemberItems.find((m) => m.member.item.focused) @@ -39,14 +48,6 @@ export function useTrackFocusPath(props: Props): void { const relatedEditorItem = focusedItem || openItem if (relatedEditorItem && relatedEditorItem.elementRef?.current) { - // Don't do anything if the selection focus path is already equal to the focusPath - if ( - selection?.focus.path && - isEqual(selection.focus.path, focusPath.slice(0, selection.focus.path.length)) - ) { - return - } - if (boundaryElement) { // Scroll the boundary element into view (the scrollable element itself) scrollIntoView(boundaryElement, { diff --git a/packages/sanity/src/core/form/inputs/PortableText/presence-cursors/UserPresenceCursor.tsx b/packages/sanity/src/core/form/inputs/PortableText/presence-cursors/UserPresenceCursor.tsx new file mode 100644 index 00000000000..7edff7e64d1 --- /dev/null +++ b/packages/sanity/src/core/form/inputs/PortableText/presence-cursors/UserPresenceCursor.tsx @@ -0,0 +1,157 @@ +import {type ColorTints} from '@sanity/color' +import {type User} from '@sanity/types' +import {Box, Text} from '@sanity/ui' +import { + // eslint-disable-next-line camelcase + getTheme_v2, +} from '@sanity/ui/theme' +import {AnimatePresence, motion, type Transition, type Variants} from 'framer-motion' +import {useCallback, useState} from 'react' +import {css, styled} from 'styled-components' + +import {useUserColor} from '../../../../user-color/hooks' + +const DOT_SIZE = 6 + +const CONTENT_BOX_VARIANTS: Variants = { + animate: {opacity: 1, scaleX: 1, scaleY: 1}, + exit: {opacity: 0, scaleX: 0, scaleY: 0.5}, + initial: {opacity: 0, scaleX: 0, scaleY: 0.5}, +} + +const CONTENT_BOX_TRANSITION: Transition = { + duration: 0.3, + ease: 'easeInOut', + type: 'spring', + bounce: 0, +} + +const CONTENT_TEXT_VARIANTS: Variants = { + animate: {opacity: 1}, + exit: {opacity: 0}, + initial: {opacity: 0}, +} + +const CONTENT_TEXT_TRANSITION: Transition = { + duration: 0.2, + delay: 0.15, +} + +const CursorLine = styled.span<{$tints: ColorTints}>(({theme, $tints}) => { + const isDark = getTheme_v2(theme)?.color._dark + const bg = $tints[isDark ? 400 : 500].hex + const fg = $tints[isDark ? 900 : 50].hex + + return css` + --presence-cursor-bg: ${bg}; + --presence-cursor-fg: ${fg}; + + border-left: 1px solid transparent; + border-color: var(--presence-cursor-bg); + margin-left: -1px; + position: relative; + word-break: normal; + white-space: normal; + mix-blend-mode: unset; + pointer-events: none; + ` +}) + +const CursorDot = styled.div` + background-color: var(--presence-cursor-bg); + border-radius: 50%; + width: ${DOT_SIZE}px; + height: ${DOT_SIZE}px; + position: absolute; + top: -${DOT_SIZE - 1}px; + left: -0.5px; + transform: translateX(-50%); + mix-blend-mode: unset; + z-index: 0; + pointer-events: all; + + // Increase the hit area of the cursor dot + &:before { + content: ''; + position: absolute; + top: -${DOT_SIZE / 2}px; + left: 50%; + transform: translateX(-50%); + width: ${DOT_SIZE * 2}px; + height: ${DOT_SIZE * 3}px; + opacity: 0.5; + } +` + +const UserBox = styled(motion(Box))(({theme}) => { + const radius = getTheme_v2(theme)?.radius[4] + + return css` + position: absolute; + top: -${DOT_SIZE * 1.5}px; + left: -${DOT_SIZE * 0.75}px; + transform-origin: left; + white-space: nowrap; + padding: 3px 6px; + box-sizing: border-box; + border-radius: ${radius}px; + background-color: var(--presence-cursor-bg); + z-index: 1; + mix-blend-mode: unset; + user-select: none; + ` +}) + +const UserText = styled(motion(Text))` + color: var(--presence-cursor-fg); + mix-blend-mode: unset; +` + +interface UserPresenceCursorProps { + user: User +} + +export function UserPresenceCursor(props: UserPresenceCursorProps): JSX.Element { + const {user} = props + const {tints} = useUserColor(user.id) + const [hovered, setHovered] = useState(false) + + const handleMouseEnter = useCallback(() => setHovered(true), []) + const handleMouseLeave = useCallback(() => setHovered(false), []) + + return ( + + + {hovered && ( + + + {user.displayName} + + + )} + + + + + ) +} diff --git a/packages/sanity/src/core/form/inputs/PortableText/presence-cursors/index.ts b/packages/sanity/src/core/form/inputs/PortableText/presence-cursors/index.ts new file mode 100644 index 00000000000..65e977fae38 --- /dev/null +++ b/packages/sanity/src/core/form/inputs/PortableText/presence-cursors/index.ts @@ -0,0 +1 @@ +export * from './usePresenceCursorDecorations' diff --git a/packages/sanity/src/core/form/inputs/PortableText/presence-cursors/usePresenceCursorDecorations.tsx b/packages/sanity/src/core/form/inputs/PortableText/presence-cursors/usePresenceCursorDecorations.tsx new file mode 100644 index 00000000000..2de9fb711fb --- /dev/null +++ b/packages/sanity/src/core/form/inputs/PortableText/presence-cursors/usePresenceCursorDecorations.tsx @@ -0,0 +1,93 @@ +import { + type RangeDecoration, + type RangeDecorationOnMovedDetails, +} from '@sanity/portable-text-editor' +import {type Path} from '@sanity/types' +import {startsWith} from '@sanity/util/paths' +import {isEqual} from 'lodash' +import {useCallback, useEffect, useRef, useState} from 'react' +import {EMPTY_ARRAY} from 'sanity' + +import {type FormNodePresence} from '../../../../presence' +import {useFormFieldPresence} from '../../../studio/contexts/Presence' +import {UserPresenceCursor} from './UserPresenceCursor' + +export interface PresenceCursorDecorationsHookProps { + path: Path +} + +export function usePresenceCursorDecorations( + props: PresenceCursorDecorationsHookProps, +): RangeDecoration[] { + const {path} = props + const fieldPresence = useFormFieldPresence() + const [currentPresence, setCurrentPresence] = useState([]) + const [presenceCursorDecorations, setPresenceCursorDecorations] = useState([]) + const previousPresence = useRef(currentPresence) + + const handleRangeDecorationMoved = useCallback((details: RangeDecorationOnMovedDetails) => { + const {rangeDecoration, newSelection} = details + + // Update the range decoration with the new selection. + setPresenceCursorDecorations((prev) => { + // eslint-disable-next-line max-nested-callbacks + const next = prev.map((p) => { + if (p.payload?.sessionId === rangeDecoration.payload?.sessionId) { + const nextDecoration: RangeDecoration = { + ...rangeDecoration, + selection: newSelection, + } + return nextDecoration + } + return p + }) + + return next + }) + }, []) + + useEffect(() => { + const nextPresence = fieldPresence.filter( + (p) => startsWith(path, p.path) && !isEqual(path, p.path), + ) + + // Filter out the selection and sessionId from the next and previous presence + // since that is the only thing we are interested in comparing to see if we need to update. + const filteredNext = nextPresence.map((d) => ({...d.selection, sessionId: d.sessionId})) + const filteredPrevious = previousPresence.current.map((d) => ({ + ...d.selection, + sessionId: d.sessionId, + })) + + // Only update the current presence state it has changed. + if (!isEqual(filteredNext, filteredPrevious)) { + const value = nextPresence.length > 0 ? nextPresence : EMPTY_ARRAY + + setCurrentPresence(value) + // Store the previous presence to be able to compare it in the next render. + previousPresence.current = value + } + }, [fieldPresence, path]) + + useEffect(() => { + const decorations: RangeDecoration[] = currentPresence.map((presence) => { + if (!presence.selection) return null + + // Always use the focus point as the cursor point. This is important when + // the user has selected a range of text. In that case, we want to show the + // cursor at the start of the selection. + const cursorPoint = {focus: presence.selection.focus, anchor: presence.selection.focus} + + return { + component: () => , + selection: cursorPoint, + onMoved: handleRangeDecorationMoved, + payload: {sessionId: presence.sessionId}, + } + }) as RangeDecoration[] + + setPresenceCursorDecorations(decorations.filter(Boolean)) + }, [currentPresence, handleRangeDecorationMoved]) + + return presenceCursorDecorations +} diff --git a/packages/sanity/src/core/form/members/object/fields/ArrayOfObjectsField.tsx b/packages/sanity/src/core/form/members/object/fields/ArrayOfObjectsField.tsx index 2690c536b87..97b280db2fd 100644 --- a/packages/sanity/src/core/form/members/object/fields/ArrayOfObjectsField.tsx +++ b/packages/sanity/src/core/form/members/object/fields/ArrayOfObjectsField.tsx @@ -22,6 +22,7 @@ import { type ArrayInputMoveItemEvent, type ArrayOfObjectsInputProps, type ObjectItem, + type OnPathFocusPayload, type RenderAnnotationCallback, type RenderArrayOfObjectsItemCallback, type RenderBlockCallback, @@ -285,8 +286,8 @@ export function ArrayOfObjectsField(props: { ) const handleFocusChildPath = useCallback( - (path: Path) => { - onPathFocus(member.field.path.concat(path)) + (path: Path, payload?: OnPathFocusPayload) => { + onPathFocus(member.field.path.concat(path), payload) }, [member.field.path, onPathFocus], ) diff --git a/packages/sanity/src/core/form/studio/contexts/FormCallbacks.tsx b/packages/sanity/src/core/form/studio/contexts/FormCallbacks.tsx index 01a16aba10a..56cf1e27fe8 100644 --- a/packages/sanity/src/core/form/studio/contexts/FormCallbacks.tsx +++ b/packages/sanity/src/core/form/studio/contexts/FormCallbacks.tsx @@ -2,13 +2,14 @@ import {type Path} from '@sanity/types' import {createContext, memo, type ReactNode, useCallback, useContext, useMemo, useRef} from 'react' +import {type OnPathFocusPayload} from '../..' import {type FormPatch, type PatchEvent} from '../../patch' /** @internal */ export interface FormCallbacksValue { transformPatches?: (patches: FormPatch[]) => FormPatch[] onChange: (patchEvent: PatchEvent) => void - onPathFocus: (path: Path) => void + onPathFocus: (path: Path, payload?: OnPathFocusPayload) => void onPathBlur: (path: Path) => void onPathOpen: (path: Path) => void onSetPathCollapsed: (path: Path, collapsed: boolean) => void @@ -39,8 +40,8 @@ export const FormCallbacksProvider = memo(function FormCallbacksProvider( ref.current.onChange(patchEvent) }, []) - const onPathFocus = useCallback((path: Path) => { - ref.current.onPathFocus(path) + const onPathFocus = useCallback((path: Path, payload?: OnPathFocusPayload) => { + ref.current.onPathFocus(path, payload) }, []) const onPathBlur = useCallback((path: Path) => { ref.current.onPathBlur(path) diff --git a/packages/sanity/src/core/form/types/inputProps.ts b/packages/sanity/src/core/form/types/inputProps.ts index 5687ed2674b..c01ea706983 100644 --- a/packages/sanity/src/core/form/types/inputProps.ts +++ b/packages/sanity/src/core/form/types/inputProps.ts @@ -1,5 +1,6 @@ import { type EditorChange, + type EditorSelection, type HotkeyOptions, type OnCopyFn, type OnPasteFn, @@ -58,6 +59,13 @@ import { type RenderPreviewCallback, } from './renderCallback' +/** + * @hidden + * @beta */ +export interface OnPathFocusPayload { + selection?: EditorSelection +} + /** * @hidden * @public */ @@ -226,7 +234,7 @@ export interface ArrayOfObjectsInputProps< /** * @hidden * @beta */ - onPathFocus: (path: Path) => void + onPathFocus: (path: Path, payload?: OnPathFocusPayload) => void /** * for array inputs using expand/collapse semantics for items diff --git a/packages/sanity/src/core/presence/types.ts b/packages/sanity/src/core/presence/types.ts index c6b50f263c5..e4f81f0c7d4 100644 --- a/packages/sanity/src/core/presence/types.ts +++ b/packages/sanity/src/core/presence/types.ts @@ -1,3 +1,4 @@ +import {type EditorSelection} from '@sanity/portable-text-editor' import {type Path, type User} from '@sanity/types' import {type Session, type Status} from '../store/_legacy' @@ -47,6 +48,7 @@ export interface FormNodePresence { path: Path sessionId: string lastActiveAt: string + selection?: EditorSelection } /** @internal */ diff --git a/packages/sanity/src/core/store/_legacy/presence/presence-store.ts b/packages/sanity/src/core/store/_legacy/presence/presence-store.ts index 4d708e24473..8e338b09c42 100644 --- a/packages/sanity/src/core/store/_legacy/presence/presence-store.ts +++ b/packages/sanity/src/core/store/_legacy/presence/presence-store.ts @@ -309,6 +309,7 @@ export function __tmp_wrap_presenceStore(context: { lastActiveAt: userAndSession.session.lastActiveAt, path: location.path || [], sessionId: userAndSession.session.sessionId, + selection: location?.selection, })), ), toArray(), diff --git a/packages/sanity/src/core/store/_legacy/presence/types.ts b/packages/sanity/src/core/store/_legacy/presence/types.ts index 8d3041e696c..6d926bc0506 100644 --- a/packages/sanity/src/core/store/_legacy/presence/types.ts +++ b/packages/sanity/src/core/store/_legacy/presence/types.ts @@ -1,3 +1,4 @@ +import {type EditorSelection} from '@sanity/portable-text-editor' import {type Path, type User} from '@sanity/types' /** @internal */ @@ -19,6 +20,7 @@ export interface PresenceLocation { documentId: string lastActiveAt: string // iso date path: Path + selection?: EditorSelection } /** @internal */ diff --git a/packages/sanity/src/core/studio/Studio.test.tsx b/packages/sanity/src/core/studio/Studio.test.tsx index f55debcdad2..cd416c120b3 100644 --- a/packages/sanity/src/core/studio/Studio.test.tsx +++ b/packages/sanity/src/core/studio/Studio.test.tsx @@ -43,7 +43,7 @@ describe('Studio', () => { const html = renderToStaticMarkup(sheet.collectStyles()) expect(html).toMatchInlineSnapshot( - `"
"`, + `"
"`, ) } finally { sheet.seal() @@ -60,7 +60,7 @@ describe('Studio', () => { try { const html = renderToString(sheet.collectStyles()) expect(html).toMatchInlineSnapshot( - `"
"`, + `"
"`, ) } finally { sheet.seal() diff --git a/packages/sanity/src/structure/panes/document/DocumentPaneProvider.tsx b/packages/sanity/src/structure/panes/document/DocumentPaneProvider.tsx index 44610c07096..958546ef61d 100644 --- a/packages/sanity/src/structure/panes/document/DocumentPaneProvider.tsx +++ b/packages/sanity/src/structure/panes/document/DocumentPaneProvider.tsx @@ -8,7 +8,7 @@ import { } from '@sanity/types' import {useToast} from '@sanity/ui' import {fromString as pathFromString, resolveKeyedPath} from '@sanity/util/paths' -import {omit} from 'lodash' +import {omit, throttle} from 'lodash' import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react' import deepEquals from 'react-fast-compare' import { @@ -19,6 +19,7 @@ import { getDraftId, getExpandOperations, getPublishedId, + type OnPathFocusPayload, type PatchEvent, setAtPath, type StateTree, @@ -547,25 +548,37 @@ export const DocumentPaneProvider = memo((props: DocumentPaneProviderProps) => { [formStateRef], ) - const handleFocus = useCallback( - (nextFocusPath: Path) => { - setFocusPath(nextFocusPath) - if (!deepEquals(focusPathRef.current, nextFocusPath)) { - setOpenPath(nextFocusPath.slice(0, -1)) - focusPathRef.current = nextFocusPath - onFocusPath?.(nextFocusPath) - } - + const updatePresence = useCallback( + (nextFocusPath: Path, payload?: OnPathFocusPayload) => { presenceStore.setLocation([ { type: 'document', documentId, path: nextFocusPath, lastActiveAt: new Date().toISOString(), + selection: payload?.selection, }, ]) }, - [documentId, onFocusPath, presenceStore, setOpenPath], + [documentId, presenceStore], + ) + + const updatePresenceThrottled = useMemo( + () => throttle(updatePresence, 1000, {leading: true, trailing: true}), + [updatePresence], + ) + + const handleFocus = useCallback( + (nextFocusPath: Path, payload?: OnPathFocusPayload) => { + setFocusPath(nextFocusPath) + if (!deepEquals(focusPathRef.current, nextFocusPath)) { + setOpenPath(nextFocusPath.slice(0, -1)) + focusPathRef.current = nextFocusPath + onFocusPath?.(nextFocusPath) + } + updatePresenceThrottled(nextFocusPath, payload) + }, + [onFocusPath, setOpenPath, updatePresenceThrottled], ) const documentPane: DocumentPaneContextValue = useMemo(