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[]
}
/**