diff --git a/packages/@sanity/portable-text-editor/src/editor/Editable.tsx b/packages/@sanity/portable-text-editor/src/editor/Editable.tsx index ea3b7f5801f..b90f1017d67 100644 --- a/packages/@sanity/portable-text-editor/src/editor/Editable.tsx +++ b/packages/@sanity/portable-text-editor/src/editor/Editable.tsx @@ -1,5 +1,12 @@ -import {BaseRange, Transforms, Text} from 'slate' -import React, {useCallback, useMemo, useEffect, forwardRef, useState, KeyboardEvent} from 'react' +import {BaseRange, Transforms, Text, NodeEntry, Range as SlateRange} from 'slate' +import React, { + forwardRef, + KeyboardEvent, + useCallback, + useEffect, + useMemo, + useState, +} from 'react' import { Editable as SlateEditable, ReactEditor, @@ -7,7 +14,7 @@ import { RenderLeafProps, useSlate, } from 'slate-react' -import {noop} from 'lodash' +import {flatten, noop} from 'lodash' import {PortableTextBlock} from '@sanity/types' import { EditorChange, @@ -15,6 +22,8 @@ import { OnCopyFn, OnPasteFn, OnPasteResult, + PortableTextSlateEditor, + RangeDecoration, RenderAnnotationFunction, RenderBlockFunction, RenderChildFunction, @@ -59,6 +68,7 @@ export type PortableTextEditableProps = Omit< onBeforeInput?: (event: InputEvent) => void onPaste?: OnPasteFn onCopy?: OnCopyFn + rangeDecorations?: RangeDecoration[] renderAnnotation?: RenderAnnotationFunction renderBlock?: RenderBlockFunction renderChild?: RenderChildFunction @@ -86,6 +96,7 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable( onBeforeInput, onPaste, onCopy, + rangeDecorations, renderAnnotation, renderBlock, renderChild, @@ -149,28 +160,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], ) @@ -393,24 +415,34 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable( } }, [portableTextEditor, scrollSelectionIntoView]) - const decorate = useCallback(() => { - if (isEqualToEmptyEditor(slateEditor.children, schemaTypes)) { - return [ - { - anchor: { - path: [0, 0], - offset: 0, + const decorate: (entry: NodeEntry) => BaseRange[] = useCallback( + ([node, path]) => { + if (isEqualToEmptyEditor(slateEditor.children, schemaTypes)) { + return [ + { + anchor: { + path: [0, 0], + offset: 0, + }, + focus: { + path: [0, 0], + offset: 0, + }, + placeholder: true, }, - focus: { - path: [0, 0], - offset: 0, - }, - placeholder: true, - }, - ] - } - return EMPTY_DECORATORS - }, [schemaTypes, slateEditor]) + ] + } + return rangeDecorations && rangeDecorations.length + ? getChildNodeToRangeDecorations({ + slateEditor, + portableTextEditor, + rangeDecorations, + nodeEntry: [node, path], + }) + : EMPTY_DECORATORS + }, + [slateEditor, schemaTypes, portableTextEditor, rangeDecorations], + ) // Set the forwarded ref to be the Slate editable DOM element useEffect(() => { @@ -442,3 +474,32 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable( /> ) }) + +const getChildNodeToRangeDecorations = ({ + rangeDecorations = [], + nodeEntry, + slateEditor, + portableTextEditor, +}: { + rangeDecorations: RangeDecoration[] + nodeEntry: NodeEntry + slateEditor: PortableTextSlateEditor + portableTextEditor: PortableTextEditor +}): SlateRange[] => { + if (rangeDecorations.length === 0) { + return EMPTY_DECORATORS + } + const [, path] = nodeEntry + return flatten( + rangeDecorations.map((decoration) => { + const slateRange = toSlateRange(decoration.selection, slateEditor) + if (decoration.isRangeInvalid(portableTextEditor)) { + return EMPTY_DECORATORS + } + if (slateRange && SlateRange.includes(slateRange, path) && path.length > 0) { + return {...slateRange, rangeDecoration: decoration} + } + return EMPTY_DECORATORS + }), + ) +} diff --git a/packages/@sanity/portable-text-editor/src/types/editor.ts b/packages/@sanity/portable-text-editor/src/types/editor.ts index 52b2c9d0e6c..8dc8a1e0369 100644 --- a/packages/@sanity/portable-text-editor/src/types/editor.ts +++ b/packages/@sanity/portable-text-editor/src/types/editor.ts @@ -37,7 +37,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 @@ -96,6 +96,7 @@ export interface PortableTextSlateEditor extends ReactEditor { isTextSpan: (value: unknown) => value is PortableTextSpan isListBlock: (value: unknown) => value is PortableTextListBlock subscriptions: (() => () => void)[] + nodeToRangeDecorations?: Map /** * Increments selected list items levels, or decrements them if `reverse` is true. @@ -481,6 +482,38 @@ export type ScrollSelectionIntoViewFunction = ( domRange: globalThis.Range, ) => void +/** + * 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. + * This component takes only children, and you could render + * your own component with own props by wrapping those children. + * + * @example + * ```ts + * (rangeComponentProps: PropsWithChildren) => ( + * + * {rangeComponentProps.children} + * + * ) + * ``` + */ + component: (props: PropsWithChildren) => ReactElement + /** + * A function that will can tell if the range has become invalid. + * The range will not be rendered when you return `true` from this function. + */ + isRangeInvalid: (editor: PortableTextEditor) => boolean + /** + * The editor content selection range + */ + selection: EditorSelection +} + /** @internal */ export type PortableTextMemberSchemaTypes = { annotations: ObjectSchemaType[] diff --git a/packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx b/packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx index 3a7fe14eb29..392dfc16d4e 100644 --- a/packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx +++ b/packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx @@ -8,6 +8,7 @@ import { BlockChildRenderProps as EditorChildRenderProps, BlockAnnotationRenderProps, EditorSelection, + RangeDecoration, } from '@sanity/portable-text-editor' import {Path, PortableTextBlock, PortableTextTextBlock} from '@sanity/types' import {Box, Portal, PortalProvider, useBoundaryElement, usePortal} from '@sanity/ui' @@ -35,6 +36,7 @@ interface InputProps extends ArrayOfObjectsInputProps { onPaste?: OnPasteFn onToggleFullscreen: () => void path: Path + rangeDecorations?: RangeDecoration[] renderBlockActions?: RenderBlockActionsCallback renderCustomMarkers?: RenderCustomMarkers } @@ -62,6 +64,7 @@ export function Compositor(props: Omit void path: Path readOnly?: boolean + rangeDecorations?: RangeDecoration[] renderAnnotation: RenderAnnotationFunction renderBlock: RenderBlockFunction renderChild: RenderChildFunction @@ -77,6 +79,7 @@ export function Editor(props: EditorProps) { onToggleFullscreen, path, readOnly, + rangeDecorations, renderAnnotation, renderBlock, renderChild, @@ -120,6 +123,7 @@ export function Editor(props: EditorProps) { onCopy={onCopy} onPaste={onPaste} ref={editableRef} + rangeDecorations={rangeDecorations} renderAnnotation={renderAnnotation} renderBlock={renderBlock} renderChild={renderChild} @@ -139,6 +143,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 69bf1ab68cc..c9c1b11e4fa 100644 --- a/packages/sanity/src/core/form/inputs/PortableText/PortableTextInput.tsx +++ b/packages/sanity/src/core/form/inputs/PortableText/PortableTextInput.tsx @@ -64,12 +64,14 @@ export function PortableTextInput(props: PortableTextInputProps) { members, onChange, onCopy, + onEditorChange, onItemRemove, onInsert, onPaste, onPathFocus, path, readOnly, + rangeDecorations, renderBlockActions, renderCustomMarkers, schemaType, @@ -340,6 +342,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 068c026e9d6..7292aee13b4 100644 --- a/packages/sanity/src/core/form/types/inputProps.ts +++ b/packages/sanity/src/core/form/types/inputProps.ts @@ -15,7 +15,13 @@ import { StringSchemaType, } from '@sanity/types' import React, {ComponentType, FocusEventHandler, FormEventHandler} from 'react' -import {HotkeyOptions, OnCopyFn, OnPasteFn} from '@sanity/portable-text-editor' +import { + EditorChange, + HotkeyOptions, + OnCopyFn, + OnPasteFn, + RangeDecoration, +} from '@sanity/portable-text-editor' import {FormPatch, PatchEvent} from '../patch' import { ArrayOfObjectsFormNode, @@ -488,6 +494,10 @@ export interface PortableTextInputProps * Use the `renderBlock` interface instead. */ markers?: PortableTextMarker[] + /** + * Returns changes from the underlying editor + */ + onEditorChange?: (change: EditorChange) => void /** * Custom copy function */ @@ -508,6 +518,7 @@ export interface PortableTextInputProps * Use the `renderBlock` interface instead. */ renderCustomMarkers?: RenderCustomMarkers + rangeDecorations?: RangeDecoration[] } /**