Skip to content

Commit fa330a0

Browse files
authoredMar 1, 2024
feat(portable-text-editor): range decorations (#5871)
* feat(portable-text-editor): range decorations This will add support for decorating selections inside the Portable Text Editor with custom components. This can be used for search highlighting, validation, and similar. We will temporarily move the decorations according to use edits, but it's the consumers responsiblitly to permanently moved them. This can be done by using the 'onMoved' callback on the decorator, which will contain the new selection. Why don't they move on their own? This is because they come from props and are state managed by the consumer. * test(portable-text-editor): add simple test for moveRangeByOperation * feat(core/inputs): support rangeDecorations in the PT-Input * test(playwright-ct): add range decoration test for PT-input
1 parent 4926b78 commit fa330a0

File tree

10 files changed

+412
-55
lines changed

10 files changed

+412
-55
lines changed
 

‎packages/@sanity/portable-text-editor/src/editor/Editable.tsx

+149-43
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,15 @@ import {
1515
useMemo,
1616
useState,
1717
} from 'react'
18-
import {type BaseRange, Editor, type Text, Transforms} from 'slate'
18+
import {
19+
type BaseRange,
20+
Editor,
21+
type NodeEntry,
22+
type Operation,
23+
Range as SlateRange,
24+
type Text,
25+
Transforms,
26+
} from 'slate'
1927
import {
2028
Editable as SlateEditable,
2129
ReactEditor,
@@ -30,6 +38,7 @@ import {
3038
type OnCopyFn,
3139
type OnPasteFn,
3240
type OnPasteResult,
41+
type RangeDecoration,
3342
type RenderAnnotationFunction,
3443
type RenderBlockFunction,
3544
type RenderChildFunction,
@@ -40,7 +49,7 @@ import {
4049
} from '../types/editor'
4150
import {type HotkeyOptions} from '../types/options'
4251
import {debugWithName} from '../utils/debug'
43-
import {toPortableTextRange, toSlateRange} from '../utils/ranges'
52+
import {moveRangeByOperation, toPortableTextRange, toSlateRange} from '../utils/ranges'
4453
import {normalizeSelection} from '../utils/selection'
4554
import {fromSlateValue, isEqualToEmptyEditor, toSlateValue} from '../utils/values'
4655
import {Element} from './components/Element'
@@ -62,7 +71,11 @@ const PLACEHOLDER_STYLE: CSSProperties = {
6271
right: 0,
6372
}
6473

65-
const EMPTY_DECORATORS: BaseRange[] = []
74+
interface BaseRangeWithDecoration extends BaseRange {
75+
rangeDecoration: RangeDecoration
76+
}
77+
78+
const EMPTY_DECORATORS: BaseRangeWithDecoration[] = []
6679

6780
/**
6881
* @public
@@ -75,6 +88,7 @@ export type PortableTextEditableProps = Omit<
7588
onBeforeInput?: (event: InputEvent) => void
7689
onPaste?: OnPasteFn
7790
onCopy?: OnCopyFn
91+
rangeDecorations?: RangeDecoration[]
7892
renderAnnotation?: RenderAnnotationFunction
7993
renderBlock?: RenderBlockFunction
8094
renderChild?: RenderChildFunction
@@ -102,6 +116,7 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable(
102116
onBeforeInput,
103117
onPaste,
104118
onCopy,
119+
rangeDecorations,
105120
renderAnnotation,
106121
renderBlock,
107122
renderChild,
@@ -121,6 +136,8 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable(
121136
const ref = useForwardedRef(forwardedRef)
122137
const [editableElement, setEditableElement] = useState<HTMLDivElement | null>(null)
123138
const [hasInvalidValue, setHasInvalidValue] = useState(false)
139+
const [rangeDecorationState, setRangeDecorationsState] =
140+
useState<BaseRangeWithDecoration[]>(EMPTY_DECORATORS)
124141

125142
const {change$, schemaTypes} = portableTextEditor
126143
const slateEditor = useSlate()
@@ -166,28 +183,39 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable(
166183
)
167184

168185
const renderLeaf = useCallback(
169-
(lProps: RenderLeafProps & {leaf: Text & {placeholder?: boolean}}) => {
170-
const rendered = (
171-
<Leaf
172-
{...lProps}
173-
schemaTypes={schemaTypes}
174-
renderAnnotation={renderAnnotation}
175-
renderChild={renderChild}
176-
renderDecorator={renderDecorator}
177-
readOnly={readOnly}
178-
/>
179-
)
180-
if (renderPlaceholder && lProps.leaf.placeholder && lProps.text.text === '') {
181-
return (
182-
<>
183-
<span style={PLACEHOLDER_STYLE} contentEditable={false}>
184-
{renderPlaceholder()}
185-
</span>
186-
{rendered}
187-
</>
186+
(
187+
lProps: RenderLeafProps & {
188+
leaf: Text & {placeholder?: boolean; rangeDecoration?: RangeDecoration}
189+
},
190+
) => {
191+
if (lProps.leaf._type === 'span') {
192+
let rendered = (
193+
<Leaf
194+
{...lProps}
195+
schemaTypes={schemaTypes}
196+
renderAnnotation={renderAnnotation}
197+
renderChild={renderChild}
198+
renderDecorator={renderDecorator}
199+
readOnly={readOnly}
200+
/>
188201
)
202+
if (renderPlaceholder && lProps.leaf.placeholder && lProps.text.text === '') {
203+
return (
204+
<>
205+
<span style={PLACEHOLDER_STYLE} contentEditable={false}>
206+
{renderPlaceholder()}
207+
</span>
208+
{rendered}
209+
</>
210+
)
211+
}
212+
const decoration = lProps.leaf.rangeDecoration
213+
if (decoration) {
214+
rendered = decoration.component({children: rendered})
215+
}
216+
return rendered
189217
}
190-
return rendered
218+
return lProps.children
191219
},
192220
[readOnly, renderAnnotation, renderChild, renderDecorator, renderPlaceholder, schemaTypes],
193221
)
@@ -215,9 +243,58 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable(
215243
}
216244
}, [propsSelection, slateEditor, blockTypeName, change$])
217245

246+
const syncRangeDecorations = useCallback(
247+
(operation?: Operation) => {
248+
if (rangeDecorations && rangeDecorations.length > 0) {
249+
const newSlateRanges: BaseRangeWithDecoration[] = []
250+
rangeDecorations.forEach((rangeDecorationItem) => {
251+
const slateRange = toSlateRange(rangeDecorationItem.selection, slateEditor)
252+
if (!SlateRange.isRange(slateRange) || !SlateRange.isExpanded(slateRange)) {
253+
if (rangeDecorationItem.onMoved) {
254+
rangeDecorationItem.onMoved({
255+
newSelection: null,
256+
rangeDecoration: rangeDecorationItem,
257+
origin: 'local',
258+
})
259+
}
260+
return
261+
}
262+
let newRange: BaseRange | null | undefined
263+
if (operation) {
264+
newRange = moveRangeByOperation(slateRange, operation)
265+
if ((newRange && newRange !== slateRange) || (newRange === null && slateRange)) {
266+
const value = PortableTextEditor.getValue(portableTextEditor)
267+
const newRangeSelection = toPortableTextRange(value, newRange, schemaTypes)
268+
if (rangeDecorationItem.onMoved) {
269+
rangeDecorationItem.onMoved({
270+
newSelection: newRangeSelection,
271+
rangeDecoration: rangeDecorationItem,
272+
origin: 'local',
273+
})
274+
}
275+
// Temporarily set the range decoration to the new range (it will however be overwritten by props at any moment)
276+
rangeDecorationItem.selection = newRangeSelection
277+
}
278+
}
279+
// If the newRange is null, it means that the range is not valid anymore and should be removed
280+
// If it's undefined, it means that the slateRange is still valid and should be kept
281+
if (newRange !== null) {
282+
newSlateRanges.push({...(newRange || slateRange), rangeDecoration: rangeDecorationItem})
283+
}
284+
})
285+
if (newSlateRanges.length > 0) {
286+
setRangeDecorationsState(newSlateRanges)
287+
return
288+
}
289+
}
290+
setRangeDecorationsState(EMPTY_DECORATORS)
291+
},
292+
[portableTextEditor, rangeDecorations, schemaTypes, slateEditor],
293+
)
294+
218295
// Subscribe to change$ and restore selection from props when the editor has been initialized properly with it's value
219296
useEffect(() => {
220-
debug('Subscribing to editor changes$')
297+
// debug('Subscribing to editor changes$')
221298
const sub = change$.subscribe((next: EditorChange): void => {
222299
switch (next.type) {
223300
case 'ready':
@@ -233,10 +310,10 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable(
233310
}
234311
})
235312
return () => {
236-
debug('Unsubscribing to changes$')
313+
// debug('Unsubscribing to changes$')
237314
sub.unsubscribe()
238315
}
239-
}, [change$, restoreSelectionFromProps])
316+
}, [change$, restoreSelectionFromProps, syncRangeDecorations])
240317

241318
// Restore selection from props when it changes
242319
useEffect(() => {
@@ -245,6 +322,26 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable(
245322
}
246323
}, [hasInvalidValue, propsSelection, restoreSelectionFromProps])
247324

325+
// Store reference to original apply function (see below for usage in useEffect)
326+
const originalApply = useMemo(() => slateEditor.apply, [slateEditor])
327+
328+
useEffect(() => {
329+
syncRangeDecorations()
330+
}, [rangeDecorations, syncRangeDecorations])
331+
332+
// Sync range decorations before an operation is applied
333+
useEffect(() => {
334+
slateEditor.apply = (op: Operation) => {
335+
originalApply(op)
336+
if (op.type !== 'set_selection') {
337+
syncRangeDecorations(op)
338+
}
339+
}
340+
return () => {
341+
slateEditor.apply = originalApply
342+
}
343+
}, [originalApply, slateEditor, syncRangeDecorations])
344+
248345
// Handle from props onCopy function
249346
const handleCopy = useCallback(
250347
(event: ClipboardEvent<HTMLDivElement>): void | ReactEditor => {
@@ -460,24 +557,33 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable(
460557
}
461558
}, [portableTextEditor, scrollSelectionIntoView])
462559

463-
const decorate = useCallback(() => {
464-
if (isEqualToEmptyEditor(slateEditor.children, schemaTypes)) {
465-
return [
466-
{
467-
anchor: {
468-
path: [0, 0],
469-
offset: 0,
470-
},
471-
focus: {
472-
path: [0, 0],
473-
offset: 0,
560+
const decorate: (entry: NodeEntry) => BaseRange[] = useCallback(
561+
([, path]) => {
562+
if (isEqualToEmptyEditor(slateEditor.children, schemaTypes)) {
563+
return [
564+
{
565+
anchor: {
566+
path: [0, 0],
567+
offset: 0,
568+
},
569+
focus: {
570+
path: [0, 0],
571+
offset: 0,
572+
},
573+
placeholder: true,
474574
},
475-
placeholder: true,
476-
},
477-
]
478-
}
479-
return EMPTY_DECORATORS
480-
}, [schemaTypes, slateEditor])
575+
]
576+
}
577+
const result = rangeDecorationState.filter(
578+
(item) => path.length > 1 && SlateRange.includes(item, path),
579+
)
580+
if (result.length > 0) {
581+
return result
582+
}
583+
return EMPTY_DECORATORS
584+
},
585+
[slateEditor, schemaTypes, rangeDecorationState],
586+
)
481587

482588
// Set the forwarded ref to be the Slate editable DOM element
483589
// Also set the editable element in a state so that the MutationObserver

‎packages/@sanity/portable-text-editor/src/types/editor.ts

+45-1
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@ import {
1919
type ClipboardEvent,
2020
type FocusEvent,
2121
type KeyboardEvent,
22+
type PropsWithChildren,
2223
type ReactElement,
2324
type RefObject,
2425
} from 'react'
2526
import {type Observable, type Subject} from 'rxjs'
2627
import {type Descendant, type Node as SlateNode, type Operation as SlateOperation} from 'slate'
2728
import {type ReactEditor} from 'slate-react'
29+
import {type DOMNode} from 'slate-react/dist/utils/dom'
2830

2931
import {type PortableTextEditor} from '../editor/PortableTextEditor'
3032
import {type Patch} from '../types/patch'
@@ -44,7 +46,7 @@ export interface EditableAPI {
4446
blur: () => void
4547
delete: (selection: EditorSelection, options?: EditableAPIDeleteOptions) => void
4648
findByPath: (path: Path) => [PortableTextBlock | PortableTextChild | undefined, Path | undefined]
47-
findDOMNode: (element: PortableTextBlock | PortableTextChild) => Node | undefined
49+
findDOMNode: (element: PortableTextBlock | PortableTextChild) => DOMNode | undefined
4850
focus: () => void
4951
focusBlock: () => PortableTextBlock | undefined
5052
focusChild: () => PortableTextChild | undefined
@@ -507,6 +509,48 @@ export type ScrollSelectionIntoViewFunction = (
507509
domRange: globalThis.Range,
508510
) => void
509511

512+
/**
513+
* Parameters for the callback that will be called for a RangeDecoration's onMoved.
514+
* @alpha */
515+
export interface RangeDecorationOnMovedDetails {
516+
rangeDecoration: RangeDecoration
517+
newSelection: EditorSelection
518+
origin: 'remote' | 'local'
519+
}
520+
/**
521+
* A range decoration is a UI affordance that wraps a given selection range in the editor
522+
* with a custom component. This can be used to highlight search results,
523+
* mark validation errors on specific words, draw user presence and similar.
524+
* @alpha */
525+
export interface RangeDecoration {
526+
/**
527+
* A component for rendering the range decoration.
528+
* The component will receive the children (text) of the range decoration as its children.
529+
*
530+
* @example
531+
* ```ts
532+
* (rangeComponentProps: PropsWithChildren) => (
533+
* <SearchResultHighlight>
534+
* {rangeComponentProps.children}
535+
* </SearchResultHighlight>
536+
* )
537+
* ```
538+
*/
539+
component: (props: PropsWithChildren) => ReactElement
540+
/**
541+
* The editor content selection range
542+
*/
543+
selection: EditorSelection
544+
/**
545+
* A optional callback that will be called when the range decoration potentially moves according to user edits.
546+
*/
547+
onMoved?: (details: RangeDecorationOnMovedDetails) => void
548+
/**
549+
* A custom payload that can be set on the range decoration
550+
*/
551+
payload?: Record<string, unknown>
552+
}
553+
510554
/** @internal */
511555
export type PortableTextMemberSchemaTypes = {
512556
annotations: (ObjectSchemaType & {i18nTitleKey?: string})[]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import {describe, expect, it} from '@jest/globals'
2+
import {type InsertTextOperation, type Range} from 'slate'
3+
4+
import {moveRangeByOperation} from '../ranges'
5+
6+
describe('moveRangeByOperation', () => {
7+
it('should move range when inserting text in front of it', () => {
8+
const range: Range = {anchor: {path: [0, 0], offset: 1}, focus: {path: [0, 0], offset: 3}}
9+
const operation: InsertTextOperation = {
10+
type: 'insert_text',
11+
path: [0, 0],
12+
offset: 0,
13+
text: 'foo',
14+
}
15+
const newRange = moveRangeByOperation(range, operation)
16+
expect(newRange).toEqual({anchor: {path: [0, 0], offset: 4}, focus: {path: [0, 0], offset: 6}})
17+
})
18+
})

‎packages/@sanity/portable-text-editor/src/utils/ranges.ts

+17-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import {type BaseRange, type Editor, Range} from 'slate'
1+
/* eslint-disable complexity */
2+
import {type BaseRange, type Editor, type Operation, Point, Range} from 'slate'
23

34
import {
45
type EditorSelection,
@@ -59,3 +60,18 @@ export function toSlateRange(selection: EditorSelection, editor: Editor): Range
5960
const range = anchor && focus ? {anchor, focus} : null
6061
return range
6162
}
63+
64+
export function moveRangeByOperation(range: Range, operation: Operation): Range | null {
65+
const anchor = Point.transform(range.anchor, operation)
66+
const focus = Point.transform(range.focus, operation)
67+
68+
if (anchor === null || focus === null) {
69+
return null
70+
}
71+
72+
if (Point.equals(anchor, range.anchor) && Point.equals(focus, range.focus)) {
73+
return range
74+
}
75+
76+
return {anchor, focus}
77+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import {expect, test} from '@playwright/experimental-ct-react'
2+
import {type SanityDocument} from 'sanity'
3+
4+
import {testHelpers} from '../../../utils/testHelpers'
5+
import {type DecorationData, RangeDecorationStory} from './RangeDecorationStory'
6+
7+
const document: SanityDocument = {
8+
_id: '123',
9+
_type: 'test',
10+
_createdAt: new Date().toISOString(),
11+
_updatedAt: new Date().toISOString(),
12+
_rev: '123',
13+
body: [
14+
{
15+
_type: 'block',
16+
_key: 'a',
17+
children: [{_type: 'span', _key: 'a1', text: 'Hello there world'}],
18+
markDefs: [],
19+
},
20+
{
21+
_type: 'block',
22+
_key: 'b',
23+
children: [{_type: 'span', _key: 'b1', text: "It's a beautiful day on planet earth"}],
24+
markDefs: [],
25+
},
26+
],
27+
}
28+
29+
// Since we can't pass React components to our story, we'll just pass the selection data,
30+
// and use a test component inside the Story to render the range decoration.
31+
const decorationData: DecorationData[] = [
32+
{
33+
word: 'there',
34+
selection: {
35+
anchor: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 6},
36+
focus: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 11},
37+
},
38+
},
39+
]
40+
41+
test.describe('Portable Text Input', () => {
42+
test.describe('Range Decoration', () => {
43+
// test.only('Manual testing can be performed with this test', async ({mount, page}) => {
44+
// await mount(<RangeDecorationStory document={document} decorationData={decorationData} />)
45+
// await page.waitForTimeout(360000)
46+
// })
47+
test(`Draws range decoration around our selection`, async ({mount, page}) => {
48+
await mount(<RangeDecorationStory document={document} decorationData={decorationData} />)
49+
await expect(page.getByTestId('range-decoration')).toHaveText('there')
50+
})
51+
52+
test(`Let's us move the range according to our edits`, async ({mount, page}) => {
53+
const {getFocusedPortableTextEditor, insertPortableText} = testHelpers({page})
54+
55+
await mount(<RangeDecorationStory document={document} decorationData={decorationData} />)
56+
57+
const $pte = await getFocusedPortableTextEditor('field-body')
58+
59+
await insertPortableText('123 ', $pte)
60+
await expect($pte).toHaveText("123 Hello there worldIt's a beautiful day on planet earth")
61+
// Assert that the same word is decorated after the edit
62+
await expect(page.getByTestId('range-decoration')).toHaveText('there')
63+
})
64+
})
65+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/* eslint-disable max-nested-callbacks */
2+
import {type EditorSelection, type RangeDecoration} from '@sanity/portable-text-editor'
3+
import {defineArrayMember, defineField, defineType, type SanityDocument} from '@sanity/types'
4+
import {type PropsWithChildren, useEffect, useMemo, useState} from 'react'
5+
import {type InputProps, PortableTextInput, type PortableTextInputProps} from 'sanity'
6+
7+
import {TestForm} from '../../utils/TestForm'
8+
import {TestWrapper} from '../../utils/TestWrapper'
9+
10+
export type DecorationData = {selection: EditorSelection; word: string}
11+
12+
const RangeDecorationTestComponent = (props: PropsWithChildren) => {
13+
return (
14+
<span style={{backgroundColor: 'yellow'}} data-testid="range-decoration">
15+
{props.children}
16+
</span>
17+
)
18+
}
19+
20+
const CustomPortableTextInput = (
21+
props: PortableTextInputProps & {decorationData?: DecorationData[]},
22+
) => {
23+
const {decorationData} = props
24+
const [rangeDecorationsState, setRangeDecorationsState] = useState<RangeDecoration[]>([])
25+
26+
useEffect(() => {
27+
setRangeDecorationsState(
28+
(decorationData?.map((data) => ({
29+
component: RangeDecorationTestComponent,
30+
selection: data.selection,
31+
onMoved: (movedProps) => {
32+
const {newSelection, rangeDecoration} = movedProps
33+
setRangeDecorationsState((prev) =>
34+
prev.map((decoration) =>
35+
data.selection === rangeDecoration.selection
36+
? {...decoration, selection: newSelection}
37+
: decoration,
38+
),
39+
)
40+
},
41+
payload: {word: data.word},
42+
})) || []) as RangeDecoration[],
43+
)
44+
}, [decorationData])
45+
46+
return <PortableTextInput {...props} rangeDecorations={rangeDecorationsState} />
47+
}
48+
49+
export function RangeDecorationStory({
50+
document,
51+
decorationData,
52+
}: {
53+
document?: SanityDocument
54+
decorationData?: DecorationData[]
55+
}) {
56+
const schemaTypes = useMemo(
57+
() => [
58+
defineType({
59+
type: 'document',
60+
name: 'test',
61+
title: 'Test',
62+
fields: [
63+
defineField({
64+
type: 'array',
65+
name: 'body',
66+
of: [
67+
defineArrayMember({
68+
type: 'block',
69+
}),
70+
],
71+
components: {
72+
input: (props: InputProps) => (
73+
<CustomPortableTextInput
74+
{...(props as PortableTextInputProps)}
75+
decorationData={decorationData}
76+
/>
77+
),
78+
},
79+
}),
80+
],
81+
}),
82+
],
83+
[decorationData],
84+
)
85+
86+
return (
87+
<TestWrapper schemaTypes={schemaTypes}>
88+
<TestForm document={document} />
89+
</TestWrapper>
90+
)
91+
}

‎packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx

+15-10
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type HotkeyOptions,
77
type OnCopyFn,
88
type OnPasteFn,
9+
type RangeDecoration,
910
usePortableTextEditor,
1011
} from '@sanity/portable-text-editor'
1112
import {type Path, type PortableTextBlock, type PortableTextTextBlock} from '@sanity/types'
@@ -36,6 +37,7 @@ interface InputProps extends ArrayOfObjectsInputProps<PortableTextBlock> {
3637
onPaste?: OnPasteFn
3738
onToggleFullscreen: () => void
3839
path: Path
40+
rangeDecorations?: RangeDecoration[]
3941
renderBlockActions?: RenderBlockActionsCallback
4042
renderCustomMarkers?: RenderCustomMarkers
4143
}
@@ -63,6 +65,7 @@ export function Compositor(props: Omit<InputProps, 'schemaType' | 'arrayFunction
6365
onToggleFullscreen,
6466
path,
6567
readOnly,
68+
rangeDecorations,
6669
renderAnnotation,
6770
renderBlock,
6871
renderBlockActions,
@@ -146,13 +149,12 @@ export function Compositor(props: Omit<InputProps, 'schemaType' | 'arrayFunction
146149
[
147150
_renderBlockActions,
148151
_renderCustomMarkers,
149-
scrollElement,
152+
boundaryElement,
150153
isFullscreen,
151154
onItemClose,
152155
onItemOpen,
153156
onItemRemove,
154157
onPathFocus,
155-
boundaryElement,
156158
path,
157159
readOnly,
158160
renderAnnotation,
@@ -162,6 +164,7 @@ export function Compositor(props: Omit<InputProps, 'schemaType' | 'arrayFunction
162164
renderInput,
163165
renderItem,
164166
renderPreview,
167+
scrollElement,
165168
],
166169
)
167170

@@ -278,14 +281,14 @@ export function Compositor(props: Omit<InputProps, 'schemaType' | 'arrayFunction
278281
)
279282
},
280283
[
281-
boundaryElement,
282-
scrollElement,
283284
editor.schemaTypes.span.name,
285+
boundaryElement,
284286
onItemClose,
285287
onItemOpen,
286288
onPathFocus,
287289
path,
288290
readOnly,
291+
scrollElement,
289292
renderAnnotation,
290293
renderBlock,
291294
renderCustomMarkers,
@@ -394,6 +397,7 @@ export function Compositor(props: Omit<InputProps, 'schemaType' | 'arrayFunction
394397
onPaste={onPaste}
395398
onToggleFullscreen={handleToggleFullscreen}
396399
path={path}
400+
rangeDecorations={rangeDecorations}
397401
readOnly={readOnly}
398402
renderAnnotation={editorRenderAnnotation}
399403
renderBlock={editorRenderBlock}
@@ -408,18 +412,19 @@ export function Compositor(props: Omit<InputProps, 'schemaType' | 'arrayFunction
408412
[
409413
ariaDescribedBy,
410414
editorHotkeys,
411-
editorRenderAnnotation,
412-
editorRenderBlock,
413-
editorRenderChild,
414-
handleToggleFullscreen,
415-
initialSelection,
416415
isActive,
417416
isFullscreen,
418-
onCopy,
419417
onItemOpen,
418+
onCopy,
420419
onPaste,
420+
handleToggleFullscreen,
421421
path,
422+
rangeDecorations,
422423
readOnly,
424+
editorRenderAnnotation,
425+
editorRenderBlock,
426+
editorRenderChild,
427+
initialSelection,
423428
scrollElement,
424429
],
425430
)

‎packages/sanity/src/core/form/inputs/PortableText/Editor.tsx

+5
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
type OnCopyFn,
55
type OnPasteFn,
66
PortableTextEditable,
7+
type RangeDecoration,
78
type RenderAnnotationFunction,
89
type RenderBlockFunction,
910
type RenderChildFunction,
@@ -60,6 +61,7 @@ interface EditorProps {
6061
onToggleFullscreen: () => void
6162
path: Path
6263
readOnly?: boolean
64+
rangeDecorations?: RangeDecoration[]
6365
renderAnnotation: RenderAnnotationFunction
6466
renderBlock: RenderBlockFunction
6567
renderChild: RenderChildFunction
@@ -95,6 +97,7 @@ export function Editor(props: EditorProps) {
9597
onToggleFullscreen,
9698
path,
9799
readOnly,
100+
rangeDecorations,
98101
renderAnnotation,
99102
renderBlock,
100103
renderChild,
@@ -145,6 +148,7 @@ export function Editor(props: EditorProps) {
145148
onCopy={onCopy}
146149
onPaste={onPaste}
147150
ref={editableRef}
151+
rangeDecorations={rangeDecorations}
148152
renderAnnotation={renderAnnotation}
149153
renderBlock={renderBlock}
150154
renderChild={renderChild}
@@ -164,6 +168,7 @@ export function Editor(props: EditorProps) {
164168
initialSelection,
165169
onCopy,
166170
onPaste,
171+
rangeDecorations,
167172
renderAnnotation,
168173
renderBlock,
169174
renderChild,

‎packages/sanity/src/core/form/inputs/PortableText/PortableTextInput.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export function PortableTextInput(props: PortableTextInputProps) {
7474
onPathFocus,
7575
path,
7676
readOnly,
77+
rangeDecorations,
7778
renderBlockActions,
7879
renderCustomMarkers,
7980
schemaType,
@@ -291,6 +292,7 @@ export function PortableTextInput(props: PortableTextInputProps) {
291292
onInsert={onInsert}
292293
onPaste={onPaste}
293294
onToggleFullscreen={handleToggleFullscreen}
295+
rangeDecorations={rangeDecorations}
294296
renderBlockActions={renderBlockActions}
295297
renderCustomMarkers={renderCustomMarkers}
296298
/>

‎packages/sanity/src/core/form/types/inputProps.ts

+5
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
type OnCopyFn,
55
type OnPasteFn,
66
type PortableTextEditor,
7+
type RangeDecoration,
78
} from '@sanity/portable-text-editor'
89
import {
910
type ArraySchemaType,
@@ -538,6 +539,10 @@ export interface PortableTextInputProps
538539
* Use the `renderBlock` interface instead.
539540
*/
540541
renderCustomMarkers?: RenderCustomMarkers
542+
/**
543+
* Array of {@link RangeDecoration} that can be used to decorate the content.
544+
*/
545+
rangeDecorations?: RangeDecoration[]
541546
}
542547

543548
/**

0 commit comments

Comments
 (0)
Please sign in to comment.