Skip to content

Commit

Permalink
feat(core): implement presence cursors (#6081)
Browse files Browse the repository at this point in the history
* 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 eca960e

* 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 <per.kristian.nordnes@gmail.com>
  • Loading branch information
2 people authored and rexxars committed Apr 26, 2024
1 parent 06ef31d commit 1522806
Show file tree
Hide file tree
Showing 17 changed files with 675 additions and 72 deletions.
14 changes: 7 additions & 7 deletions packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ export function Compositor(props: Omit<InputProps, 'schemaType' | 'arrayFunction
onPathFocus,
onToggleFullscreen,
path,
readOnly,
rangeDecorations,
readOnly,
renderAnnotation,
renderBlock,
renderBlockActions,
Expand Down Expand Up @@ -414,21 +414,21 @@ export function Compositor(props: Omit<InputProps, 'schemaType' | 'arrayFunction
// Keep only stable ones here!
[
ariaDescribedBy,
editorHotkeys,
editorRenderAnnotation,
editorRenderBlock,
editorRenderChild,
elementRef,
handleToggleFullscreen,
initialSelection,
editorHotkeys,
isActive,
isFullscreen,
onItemOpen,
onCopy,
onItemOpen,
onPaste,
handleToggleFullscreen,
path,
rangeDecorations,
readOnly,
editorRenderAnnotation,
editorRenderBlock,
editorRenderChild,
scrollElement,
],
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import {
type EditorChange,
type EditorSelection,
type InvalidValue,
type Patch as EditorPatch,
type Patch,
PortableTextEditor,
type RangeDecoration,
} from '@sanity/portable-text-editor'
import {useTelemetry} from '@sanity/telemetry/react'
import {isKeySegment, type Path, type PortableTextBlock} from '@sanity/types'
import {isKeySegment, type PortableTextBlock} from '@sanity/types'
import {Box, useToast} from '@sanity/ui'
import {
type MutableRefObject,
Expand All @@ -27,12 +29,17 @@ import {
} from '../../__telemetry__/form.telemetry'
import {SANITY_PATCH_TYPE} from '../../patch'
import {type ArrayOfObjectsItemMember, type ObjectFormNode} from '../../store'
import {immutableReconcile} from '../../store/utils/immutableReconcile'
import {type PortableTextInputProps} from '../../types'
import {Compositor, type PortableTextEditorElement} from './Compositor'
import {PortableTextMarkersProvider} from './contexts/PortableTextMarkers'
import {PortableTextMemberItemsProvider} from './contexts/PortableTextMembers'
import {usePortableTextMemberItemsFromProps} from './hooks/usePortableTextMembers'
import {InvalidValue as RespondToInvalidContent} from './InvalidValue'
import {
type PresenceCursorDecorationsHookProps,
usePresenceCursorDecorations,
} from './presence-cursors'
import {usePatches} from './usePatches'

/** @internal */
Expand Down Expand Up @@ -73,17 +80,26 @@ export function PortableTextInput(props: PortableTextInputProps): ReactNode {
onPathFocus,
path,
readOnly,
rangeDecorations,
rangeDecorations: rangeDecorationsProp,
renderBlockActions,
renderCustomMarkers,
schemaType,
value,
} = props

const {onBlur, onFocus, ref: elementRef} = elementProps
const {onBlur, ref: elementRef} = elementProps
const defaultEditorRef = useRef<PortableTextEditor | null>(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<InvalidValue | null>(null)
Expand Down Expand Up @@ -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<EditorSelection | null>(null)

// Handle editor changes
const handleEditorChange = useCallback(
Expand All @@ -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)
Expand All @@ -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':
Expand All @@ -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(() => {
Expand Down Expand Up @@ -251,8 +285,17 @@ export function PortableTextInput(props: PortableTextInputProps): ReactNode {
}
}, [editorRef, isActive])

const previousRangeDecorations = useRef<RangeDecoration[]>([])

const rangeDecorations = useMemo((): RangeDecoration[] => {
const result = [...(rangeDecorationsProp || []), ...presenceCursorDecorations]
const reconciled = immutableReconcile(previousRangeDecorations.current, result)
previousRangeDecorations.current = reconciled
return reconciled
}, [presenceCursorDecorations, rangeDecorationsProp])

return (
<Box ref={elementProps.ref}>
<Box>
{!ignoreValidationError && respondToInvalidContent}
{(!invalidValue || ignoreValidationError) && (
<PortableTextMarkersProvider markers={markers}>
Expand All @@ -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}
Expand Down

0 comments on commit 1522806

Please sign in to comment.