From 10472abc32a4402af1cc3dbdf6f693dae6c9762b Mon Sep 17 00:00:00 2001 From: Per-Kristian Nordnes Date: Wed, 21 Feb 2024 09:38:33 +0100 Subject: [PATCH 1/4] 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. --- .../src/editor/Editable.tsx | 192 ++++++++++++++---- .../portable-text-editor/src/types/editor.ts | 46 ++++- .../portable-text-editor/src/utils/ranges.ts | 18 +- 3 files changed, 211 insertions(+), 45 deletions(-) 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/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} +} From c26365adf45eea99739ca7d6a8dd4cf8f8c7d06e Mon Sep 17 00:00:00 2001 From: Per-Kristian Nordnes Date: Fri, 1 Mar 2024 14:38:22 +0100 Subject: [PATCH 2/4] test(portable-text-editor): add simple test for moveRangeByOperation --- .../src/utils/__tests__/ranges.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 packages/@sanity/portable-text-editor/src/utils/__tests__/ranges.test.ts 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}}) + }) +}) From 741c0ad1ea3607281381274084d81161da132c3e Mon Sep 17 00:00:00 2001 From: Per-Kristian Nordnes Date: Wed, 21 Feb 2024 09:39:15 +0100 Subject: [PATCH 3/4] feat(core/inputs): support rangeDecorations in the PT-Input --- .../form/inputs/PortableText/Compositor.tsx | 25 +++++++++++-------- .../core/form/inputs/PortableText/Editor.tsx | 5 ++++ .../inputs/PortableText/PortableTextInput.tsx | 2 ++ .../sanity/src/core/form/types/inputProps.ts | 5 ++++ 4 files changed, 27 insertions(+), 10 deletions(-) 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[] } /** From 4f19f51be8ccfe2e00744672cde49076f5ac2c42 Mon Sep 17 00:00:00 2001 From: Per-Kristian Nordnes Date: Tue, 27 Feb 2024 17:11:49 +0100 Subject: [PATCH 4/4] test(playwright-ct): add range decoration test for PT-input --- .../PortableText/RangeDecoration.spec.tsx | 65 +++++++++++++ .../PortableText/RangeDecorationStory.tsx | 91 +++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 packages/sanity/playwright-ct/tests/formBuilder/inputs/PortableText/RangeDecoration.spec.tsx create mode 100644 packages/sanity/playwright-ct/tests/formBuilder/inputs/PortableText/RangeDecorationStory.tsx 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 ( + + + + ) +}