diff --git a/packages/@sanity/portable-text-editor/src/editor/Editable.tsx b/packages/@sanity/portable-text-editor/src/editor/Editable.tsx index a4703741a9b..a16fc70c051 100644 --- a/packages/@sanity/portable-text-editor/src/editor/Editable.tsx +++ b/packages/@sanity/portable-text-editor/src/editor/Editable.tsx @@ -15,7 +15,15 @@ import { useMemo, useState, } from 'react' -import {type BaseRange, Editor, type Text, Transforms} from 'slate' +import { + type BaseRange, + Editor, + type NodeEntry, + type Operation, + Range as SlateRange, + type Text, + Transforms, +} from 'slate' import { Editable as SlateEditable, ReactEditor, @@ -30,6 +38,7 @@ import { type OnCopyFn, type OnPasteFn, type OnPasteResult, + type RangeDecoration, type RenderAnnotationFunction, type RenderBlockFunction, type RenderChildFunction, @@ -40,7 +49,7 @@ import { } from '../types/editor' import {type HotkeyOptions} from '../types/options' import {debugWithName} from '../utils/debug' -import {toPortableTextRange, toSlateRange} from '../utils/ranges' +import {moveRangeByOperation, toPortableTextRange, toSlateRange} from '../utils/ranges' import {normalizeSelection} from '../utils/selection' import {fromSlateValue, isEqualToEmptyEditor, toSlateValue} from '../utils/values' import {Element} from './components/Element' @@ -62,7 +71,11 @@ const PLACEHOLDER_STYLE: CSSProperties = { right: 0, } -const EMPTY_DECORATORS: BaseRange[] = [] +interface BaseRangeWithDecoration extends BaseRange { + rangeDecoration: RangeDecoration +} + +const EMPTY_DECORATORS: BaseRangeWithDecoration[] = [] /** * @public @@ -75,6 +88,7 @@ export type PortableTextEditableProps = Omit< onBeforeInput?: (event: InputEvent) => void onPaste?: OnPasteFn onCopy?: OnCopyFn + rangeDecorations?: RangeDecoration[] renderAnnotation?: RenderAnnotationFunction renderBlock?: RenderBlockFunction renderChild?: RenderChildFunction @@ -102,6 +116,7 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable( onBeforeInput, onPaste, onCopy, + rangeDecorations, renderAnnotation, renderBlock, renderChild, @@ -121,6 +136,8 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable( const ref = useForwardedRef(forwardedRef) const [editableElement, setEditableElement] = useState(null) const [hasInvalidValue, setHasInvalidValue] = useState(false) + const [rangeDecorationState, setRangeDecorationsState] = + useState(EMPTY_DECORATORS) const {change$, schemaTypes} = portableTextEditor const slateEditor = useSlate() @@ -166,28 +183,39 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable( ) const renderLeaf = useCallback( - (lProps: RenderLeafProps & {leaf: Text & {placeholder?: boolean}}) => { - const rendered = ( - - ) - if (renderPlaceholder && lProps.leaf.placeholder && lProps.text.text === '') { - return ( - <> - - {renderPlaceholder()} - - {rendered} - + ( + lProps: RenderLeafProps & { + leaf: Text & {placeholder?: boolean; rangeDecoration?: RangeDecoration} + }, + ) => { + if (lProps.leaf._type === 'span') { + let rendered = ( + ) + if (renderPlaceholder && lProps.leaf.placeholder && lProps.text.text === '') { + return ( + <> + + {renderPlaceholder()} + + {rendered} + + ) + } + const decoration = lProps.leaf.rangeDecoration + if (decoration) { + rendered = decoration.component({children: rendered}) + } + return rendered } - return rendered + return lProps.children }, [readOnly, renderAnnotation, renderChild, renderDecorator, renderPlaceholder, schemaTypes], ) @@ -215,9 +243,58 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable( } }, [propsSelection, slateEditor, blockTypeName, change$]) + const syncRangeDecorations = useCallback( + (operation?: Operation) => { + if (rangeDecorations && rangeDecorations.length > 0) { + const newSlateRanges: BaseRangeWithDecoration[] = [] + rangeDecorations.forEach((rangeDecorationItem) => { + const slateRange = toSlateRange(rangeDecorationItem.selection, slateEditor) + if (!SlateRange.isRange(slateRange) || !SlateRange.isExpanded(slateRange)) { + if (rangeDecorationItem.onMoved) { + rangeDecorationItem.onMoved({ + newSelection: null, + rangeDecoration: rangeDecorationItem, + origin: 'local', + }) + } + return + } + let newRange: BaseRange | null | undefined + if (operation) { + newRange = moveRangeByOperation(slateRange, operation) + if ((newRange && newRange !== slateRange) || (newRange === null && slateRange)) { + const value = PortableTextEditor.getValue(portableTextEditor) + const newRangeSelection = toPortableTextRange(value, newRange, schemaTypes) + if (rangeDecorationItem.onMoved) { + rangeDecorationItem.onMoved({ + newSelection: newRangeSelection, + rangeDecoration: rangeDecorationItem, + origin: 'local', + }) + } + // Temporarily set the range decoration to the new range (it will however be overwritten by props at any moment) + rangeDecorationItem.selection = newRangeSelection + } + } + // If the newRange is null, it means that the range is not valid anymore and should be removed + // If it's undefined, it means that the slateRange is still valid and should be kept + if (newRange !== null) { + newSlateRanges.push({...(newRange || slateRange), rangeDecoration: rangeDecorationItem}) + } + }) + if (newSlateRanges.length > 0) { + setRangeDecorationsState(newSlateRanges) + return + } + } + setRangeDecorationsState(EMPTY_DECORATORS) + }, + [portableTextEditor, rangeDecorations, schemaTypes, slateEditor], + ) + // Subscribe to change$ and restore selection from props when the editor has been initialized properly with it's value useEffect(() => { - debug('Subscribing to editor changes$') + // debug('Subscribing to editor changes$') const sub = change$.subscribe((next: EditorChange): void => { switch (next.type) { case 'ready': @@ -233,10 +310,10 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable( } }) return () => { - debug('Unsubscribing to changes$') + // debug('Unsubscribing to changes$') sub.unsubscribe() } - }, [change$, restoreSelectionFromProps]) + }, [change$, restoreSelectionFromProps, syncRangeDecorations]) // Restore selection from props when it changes useEffect(() => { @@ -245,6 +322,26 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable( } }, [hasInvalidValue, propsSelection, restoreSelectionFromProps]) + // Store reference to original apply function (see below for usage in useEffect) + const originalApply = useMemo(() => slateEditor.apply, [slateEditor]) + + useEffect(() => { + syncRangeDecorations() + }, [rangeDecorations, syncRangeDecorations]) + + // Sync range decorations before an operation is applied + useEffect(() => { + slateEditor.apply = (op: Operation) => { + originalApply(op) + if (op.type !== 'set_selection') { + syncRangeDecorations(op) + } + } + return () => { + slateEditor.apply = originalApply + } + }, [originalApply, slateEditor, syncRangeDecorations]) + // Handle from props onCopy function const handleCopy = useCallback( (event: ClipboardEvent): void | ReactEditor => { @@ -460,24 +557,33 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable( } }, [portableTextEditor, scrollSelectionIntoView]) - const decorate = useCallback(() => { - if (isEqualToEmptyEditor(slateEditor.children, schemaTypes)) { - return [ - { - anchor: { - path: [0, 0], - offset: 0, - }, - focus: { - path: [0, 0], - offset: 0, + const decorate: (entry: NodeEntry) => BaseRange[] = useCallback( + ([, path]) => { + if (isEqualToEmptyEditor(slateEditor.children, schemaTypes)) { + return [ + { + anchor: { + path: [0, 0], + offset: 0, + }, + focus: { + path: [0, 0], + offset: 0, + }, + placeholder: true, }, - placeholder: true, - }, - ] - } - return EMPTY_DECORATORS - }, [schemaTypes, slateEditor]) + ] + } + const result = rangeDecorationState.filter( + (item) => path.length > 1 && SlateRange.includes(item, path), + ) + if (result.length > 0) { + return result + } + return EMPTY_DECORATORS + }, + [slateEditor, schemaTypes, rangeDecorationState], + ) // Set the forwarded ref to be the Slate editable DOM element // Also set the editable element in a state so that the MutationObserver diff --git a/packages/@sanity/portable-text-editor/src/types/editor.ts b/packages/@sanity/portable-text-editor/src/types/editor.ts index aa927bf42b1..b794ebe32a9 100644 --- a/packages/@sanity/portable-text-editor/src/types/editor.ts +++ b/packages/@sanity/portable-text-editor/src/types/editor.ts @@ -19,12 +19,14 @@ import { type ClipboardEvent, type FocusEvent, type KeyboardEvent, + type PropsWithChildren, type ReactElement, type RefObject, } from 'react' import {type Observable, type Subject} from 'rxjs' import {type Descendant, type Node as SlateNode, type Operation as SlateOperation} from 'slate' import {type ReactEditor} from 'slate-react' +import {type DOMNode} from 'slate-react/dist/utils/dom' import {type PortableTextEditor} from '../editor/PortableTextEditor' import {type Patch} from '../types/patch' @@ -44,7 +46,7 @@ export interface EditableAPI { blur: () => void delete: (selection: EditorSelection, options?: EditableAPIDeleteOptions) => void findByPath: (path: Path) => [PortableTextBlock | PortableTextChild | undefined, Path | undefined] - findDOMNode: (element: PortableTextBlock | PortableTextChild) => Node | undefined + findDOMNode: (element: PortableTextBlock | PortableTextChild) => DOMNode | undefined focus: () => void focusBlock: () => PortableTextBlock | undefined focusChild: () => PortableTextChild | undefined @@ -507,6 +509,48 @@ export type ScrollSelectionIntoViewFunction = ( domRange: globalThis.Range, ) => void +/** + * Parameters for the callback that will be called for a RangeDecoration's onMoved. + * @alpha */ +export interface RangeDecorationOnMovedDetails { + rangeDecoration: RangeDecoration + newSelection: EditorSelection + origin: 'remote' | 'local' +} +/** + * A range decoration is a UI affordance that wraps a given selection range in the editor + * with a custom component. This can be used to highlight search results, + * mark validation errors on specific words, draw user presence and similar. + * @alpha */ +export interface RangeDecoration { + /** + * A component for rendering the range decoration. + * The component will receive the children (text) of the range decoration as its children. + * + * @example + * ```ts + * (rangeComponentProps: PropsWithChildren) => ( + * + * {rangeComponentProps.children} + * + * ) + * ``` + */ + component: (props: PropsWithChildren) => ReactElement + /** + * The editor content selection range + */ + selection: EditorSelection + /** + * A optional callback that will be called when the range decoration potentially moves according to user edits. + */ + onMoved?: (details: RangeDecorationOnMovedDetails) => void + /** + * A custom payload that can be set on the range decoration + */ + payload?: Record +} + /** @internal */ export type PortableTextMemberSchemaTypes = { annotations: (ObjectSchemaType & {i18nTitleKey?: string})[] diff --git a/packages/@sanity/portable-text-editor/src/utils/__tests__/ranges.test.ts b/packages/@sanity/portable-text-editor/src/utils/__tests__/ranges.test.ts new file mode 100644 index 00000000000..486b1fa3d49 --- /dev/null +++ b/packages/@sanity/portable-text-editor/src/utils/__tests__/ranges.test.ts @@ -0,0 +1,18 @@ +import {describe, expect, it} from '@jest/globals' +import {type InsertTextOperation, type Range} from 'slate' + +import {moveRangeByOperation} from '../ranges' + +describe('moveRangeByOperation', () => { + it('should move range when inserting text in front of it', () => { + const range: Range = {anchor: {path: [0, 0], offset: 1}, focus: {path: [0, 0], offset: 3}} + const operation: InsertTextOperation = { + type: 'insert_text', + path: [0, 0], + offset: 0, + text: 'foo', + } + const newRange = moveRangeByOperation(range, operation) + expect(newRange).toEqual({anchor: {path: [0, 0], offset: 4}, focus: {path: [0, 0], offset: 6}}) + }) +}) diff --git a/packages/@sanity/portable-text-editor/src/utils/ranges.ts b/packages/@sanity/portable-text-editor/src/utils/ranges.ts index 7de984dd753..d68b0508e38 100644 --- a/packages/@sanity/portable-text-editor/src/utils/ranges.ts +++ b/packages/@sanity/portable-text-editor/src/utils/ranges.ts @@ -1,4 +1,5 @@ -import {type BaseRange, type Editor, Range} from 'slate' +/* eslint-disable complexity */ +import {type BaseRange, type Editor, type Operation, Point, Range} from 'slate' import { type EditorSelection, @@ -59,3 +60,18 @@ export function toSlateRange(selection: EditorSelection, editor: Editor): Range const range = anchor && focus ? {anchor, focus} : null return range } + +export function moveRangeByOperation(range: Range, operation: Operation): Range | null { + const anchor = Point.transform(range.anchor, operation) + const focus = Point.transform(range.focus, operation) + + if (anchor === null || focus === null) { + return null + } + + if (Point.equals(anchor, range.anchor) && Point.equals(focus, range.focus)) { + return range + } + + return {anchor, focus} +} diff --git a/packages/sanity/playwright-ct/tests/formBuilder/inputs/PortableText/RangeDecoration.spec.tsx b/packages/sanity/playwright-ct/tests/formBuilder/inputs/PortableText/RangeDecoration.spec.tsx new file mode 100644 index 00000000000..dbc8afcef20 --- /dev/null +++ b/packages/sanity/playwright-ct/tests/formBuilder/inputs/PortableText/RangeDecoration.spec.tsx @@ -0,0 +1,65 @@ +import {expect, test} from '@playwright/experimental-ct-react' +import {type SanityDocument} from 'sanity' + +import {testHelpers} from '../../../utils/testHelpers' +import {type DecorationData, RangeDecorationStory} from './RangeDecorationStory' + +const document: SanityDocument = { + _id: '123', + _type: 'test', + _createdAt: new Date().toISOString(), + _updatedAt: new Date().toISOString(), + _rev: '123', + body: [ + { + _type: 'block', + _key: 'a', + children: [{_type: 'span', _key: 'a1', text: 'Hello there world'}], + markDefs: [], + }, + { + _type: 'block', + _key: 'b', + children: [{_type: 'span', _key: 'b1', text: "It's a beautiful day on planet earth"}], + markDefs: [], + }, + ], +} + +// Since we can't pass React components to our story, we'll just pass the selection data, +// and use a test component inside the Story to render the range decoration. +const decorationData: DecorationData[] = [ + { + word: 'there', + selection: { + anchor: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 6}, + focus: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 11}, + }, + }, +] + +test.describe('Portable Text Input', () => { + test.describe('Range Decoration', () => { + // test.only('Manual testing can be performed with this test', async ({mount, page}) => { + // await mount() + // await page.waitForTimeout(360000) + // }) + test(`Draws range decoration around our selection`, async ({mount, page}) => { + await mount() + await expect(page.getByTestId('range-decoration')).toHaveText('there') + }) + + test(`Let's us move the range according to our edits`, async ({mount, page}) => { + const {getFocusedPortableTextEditor, insertPortableText} = testHelpers({page}) + + await mount() + + const $pte = await getFocusedPortableTextEditor('field-body') + + await insertPortableText('123 ', $pte) + await expect($pte).toHaveText("123 Hello there worldIt's a beautiful day on planet earth") + // Assert that the same word is decorated after the edit + await expect(page.getByTestId('range-decoration')).toHaveText('there') + }) + }) +}) diff --git a/packages/sanity/playwright-ct/tests/formBuilder/inputs/PortableText/RangeDecorationStory.tsx b/packages/sanity/playwright-ct/tests/formBuilder/inputs/PortableText/RangeDecorationStory.tsx new file mode 100644 index 00000000000..f06f486692c --- /dev/null +++ b/packages/sanity/playwright-ct/tests/formBuilder/inputs/PortableText/RangeDecorationStory.tsx @@ -0,0 +1,91 @@ +/* eslint-disable max-nested-callbacks */ +import {type EditorSelection, type RangeDecoration} from '@sanity/portable-text-editor' +import {defineArrayMember, defineField, defineType, type SanityDocument} from '@sanity/types' +import {type PropsWithChildren, useEffect, useMemo, useState} from 'react' +import {type InputProps, PortableTextInput, type PortableTextInputProps} from 'sanity' + +import {TestForm} from '../../utils/TestForm' +import {TestWrapper} from '../../utils/TestWrapper' + +export type DecorationData = {selection: EditorSelection; word: string} + +const RangeDecorationTestComponent = (props: PropsWithChildren) => { + return ( + + {props.children} + + ) +} + +const CustomPortableTextInput = ( + props: PortableTextInputProps & {decorationData?: DecorationData[]}, +) => { + const {decorationData} = props + const [rangeDecorationsState, setRangeDecorationsState] = useState([]) + + useEffect(() => { + setRangeDecorationsState( + (decorationData?.map((data) => ({ + component: RangeDecorationTestComponent, + selection: data.selection, + onMoved: (movedProps) => { + const {newSelection, rangeDecoration} = movedProps + setRangeDecorationsState((prev) => + prev.map((decoration) => + data.selection === rangeDecoration.selection + ? {...decoration, selection: newSelection} + : decoration, + ), + ) + }, + payload: {word: data.word}, + })) || []) as RangeDecoration[], + ) + }, [decorationData]) + + return +} + +export function RangeDecorationStory({ + document, + decorationData, +}: { + document?: SanityDocument + decorationData?: DecorationData[] +}) { + const schemaTypes = useMemo( + () => [ + defineType({ + type: 'document', + name: 'test', + title: 'Test', + fields: [ + defineField({ + type: 'array', + name: 'body', + of: [ + defineArrayMember({ + type: 'block', + }), + ], + components: { + input: (props: InputProps) => ( + + ), + }, + }), + ], + }), + ], + [decorationData], + ) + + return ( + + + + ) +} diff --git a/packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx b/packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx index 1e76475e749..38d5ee89619 100644 --- a/packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx +++ b/packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx @@ -6,6 +6,7 @@ import { type HotkeyOptions, type OnCopyFn, type OnPasteFn, + type RangeDecoration, usePortableTextEditor, } from '@sanity/portable-text-editor' import {type Path, type PortableTextBlock, type PortableTextTextBlock} from '@sanity/types' @@ -36,6 +37,7 @@ interface InputProps extends ArrayOfObjectsInputProps { onPaste?: OnPasteFn onToggleFullscreen: () => void path: Path + rangeDecorations?: RangeDecoration[] renderBlockActions?: RenderBlockActionsCallback renderCustomMarkers?: RenderCustomMarkers } @@ -63,6 +65,7 @@ export function Compositor(props: Omit void path: Path readOnly?: boolean + rangeDecorations?: RangeDecoration[] renderAnnotation: RenderAnnotationFunction renderBlock: RenderBlockFunction renderChild: RenderChildFunction @@ -95,6 +97,7 @@ export function Editor(props: EditorProps) { onToggleFullscreen, path, readOnly, + rangeDecorations, renderAnnotation, renderBlock, renderChild, @@ -145,6 +148,7 @@ export function Editor(props: EditorProps) { onCopy={onCopy} onPaste={onPaste} ref={editableRef} + rangeDecorations={rangeDecorations} renderAnnotation={renderAnnotation} renderBlock={renderBlock} renderChild={renderChild} @@ -164,6 +168,7 @@ export function Editor(props: EditorProps) { initialSelection, onCopy, onPaste, + rangeDecorations, renderAnnotation, renderBlock, renderChild, diff --git a/packages/sanity/src/core/form/inputs/PortableText/PortableTextInput.tsx b/packages/sanity/src/core/form/inputs/PortableText/PortableTextInput.tsx index f43d1b79a5a..bd6da0eb11f 100644 --- a/packages/sanity/src/core/form/inputs/PortableText/PortableTextInput.tsx +++ b/packages/sanity/src/core/form/inputs/PortableText/PortableTextInput.tsx @@ -74,6 +74,7 @@ export function PortableTextInput(props: PortableTextInputProps) { onPathFocus, path, readOnly, + rangeDecorations, renderBlockActions, renderCustomMarkers, schemaType, @@ -291,6 +292,7 @@ export function PortableTextInput(props: PortableTextInputProps) { onInsert={onInsert} onPaste={onPaste} onToggleFullscreen={handleToggleFullscreen} + rangeDecorations={rangeDecorations} renderBlockActions={renderBlockActions} renderCustomMarkers={renderCustomMarkers} /> diff --git a/packages/sanity/src/core/form/types/inputProps.ts b/packages/sanity/src/core/form/types/inputProps.ts index da1b1909561..e815eb7fa8a 100644 --- a/packages/sanity/src/core/form/types/inputProps.ts +++ b/packages/sanity/src/core/form/types/inputProps.ts @@ -4,6 +4,7 @@ import { type OnCopyFn, type OnPasteFn, type PortableTextEditor, + type RangeDecoration, } from '@sanity/portable-text-editor' import { type ArraySchemaType, @@ -538,6 +539,10 @@ export interface PortableTextInputProps * Use the `renderBlock` interface instead. */ renderCustomMarkers?: RenderCustomMarkers + /** + * Array of {@link RangeDecoration} that can be used to decorate the content. + */ + rangeDecorations?: RangeDecoration[] } /**