From 5d1ed227becb59e4c6bd1713819939c868d80209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gr=C3=B8ngaard?= Date: Wed, 18 Dec 2024 16:37:36 +0100 Subject: [PATCH] refactor(core): replace `PortableTextEditor` with `EditorProvider` (#8040) --- .../inputs/PortableText/PortableTextInput.tsx | 199 +++++++++++++++--- 1 file changed, 168 insertions(+), 31 deletions(-) diff --git a/packages/sanity/src/core/form/inputs/PortableText/PortableTextInput.tsx b/packages/sanity/src/core/form/inputs/PortableText/PortableTextInput.tsx index 6f504675967..d048caea4c9 100644 --- a/packages/sanity/src/core/form/inputs/PortableText/PortableTextInput.tsx +++ b/packages/sanity/src/core/form/inputs/PortableText/PortableTextInput.tsx @@ -1,30 +1,35 @@ import { type EditorChange, + type EditorEmittedEvent, + EditorEventListener, + EditorProvider, type EditorSelection, type InvalidValue, type OnPasteFn, - type Patch as EditorPatch, type Patch, type PortableTextEditableProps, PortableTextEditor, type RangeDecoration, type RenderEditableFunction, + useEditor, + usePortableTextEditor, } from '@portabletext/editor' import {useTelemetry} from '@sanity/telemetry/react' -import {isKeySegment, type PortableTextBlock} from '@sanity/types' +import {isKeySegment, type Path, type PortableTextBlock} from '@sanity/types' import {Box, Flex, Text, useToast} from '@sanity/ui' import {randomKey} from '@sanity/util/content' import {sortBy} from 'lodash' import { + forwardRef, type ReactNode, startTransition, useCallback, useEffect, + useImperativeHandle, useMemo, useRef, useState, } from 'react' -import {Subject} from 'rxjs' import {useTranslation} from '../../../i18n' import {EMPTY_ARRAY} from '../../../util' @@ -59,6 +64,21 @@ function keyGenerator() { return randomKey(12) } +/** + * `EditorProvider` doesn't have a `ref` prop. This custom PTE plugin takes + * care of imperatively forwarding that ref. + */ +const EditorRefPlugin = forwardRef((_, ref) => { + const portableTextEditor = usePortableTextEditor() + + const portableTextEditorRef = useRef(portableTextEditor) + + useImperativeHandle(ref, () => portableTextEditorRef.current, []) + + return null +}) +EditorRefPlugin.displayName = 'EditorRefPlugin' + /** @internal */ export interface PortableTextMemberItem { kind: 'annotation' | 'textBlock' | 'objectBlock' | 'inlineObject' @@ -125,7 +145,6 @@ export function PortableTextInput(props: PortableTextInputProps): ReactNode { ), ) - const {subscribe} = usePatches({path}) const {t} = useTranslation() const [ignoreValidationError, setIgnoreValidationError] = useState(false) const [invalidValue, setInvalidValue] = useState(null) @@ -137,16 +156,6 @@ export function PortableTextInput(props: PortableTextInputProps): ReactNode { const toast = useToast() - // Memoized patch stream - const [patchSubject] = useState( - () => - new Subject<{ - patches: EditorPatch[] - snapshot: PortableTextBlock[] | undefined - }>(), - ) - const patches$ = useMemo(() => patchSubject.asObservable(), [patchSubject]) - const handleToggleFullscreen = useCallback(() => { setIsFullscreen((v) => { const next = !v @@ -168,13 +177,6 @@ export function PortableTextInput(props: PortableTextInputProps): ReactNode { } }, [invalidValue, value]) - // Subscribe to patches - useEffect(() => { - return subscribe(({patches, snapshot}): void => { - patchSubject.next({patches, snapshot}) - }) - }, [patchSubject, subscribe]) - const portableTextMemberItems = usePortableTextMemberItemsFromProps(props) // Set active if focused within the editor @@ -380,16 +382,19 @@ export function PortableTextInput(props: PortableTextInputProps): ReactNode { {(!invalidValue || ignoreValidationError) && ( - + + + + + - + )} @@ -417,6 +422,138 @@ export function PortableTextInput(props: PortableTextInputProps): ReactNode { ) } +/** + * Custom PTE plugin that translates `EditorEmittedEvent`s to `EditorChange`s + */ +function EditorChangePlugin(props: {onChange: (change: EditorChange) => void}) { + const handleEditorEvent = useCallback( + (event: EditorEmittedEvent) => { + switch (event.type) { + case 'blurred': + props.onChange({ + type: 'blur', + event: event.event, + }) + break + case 'error': + props.onChange({ + type: 'error', + name: event.name, + level: 'warning', + description: event.description, + }) + break + case 'focused': + props.onChange({ + type: 'focus', + event: event.event, + }) + break + case 'loading': + props.onChange({ + type: 'loading', + isLoading: true, + }) + break + case 'done loading': + props.onChange({ + type: 'loading', + isLoading: false, + }) + break + case 'invalid value': + props.onChange({ + type: 'invalidValue', + resolution: event.resolution, + value: event.value, + }) + break + case 'mutation': + props.onChange(event) + break + case 'patch': { + props.onChange(event) + break + } + case 'ready': + props.onChange(event) + break + case 'selection': { + props.onChange(event) + break + } + case 'value changed': + props.onChange({ + type: 'value', + value: event.value, + }) + break + default: + } + }, + [props], + ) + + return +} + +/** + * Custom PTE plugin that sets up a patch subscription and sends patches to the + * editor. + */ +function PatchesPlugin(props: {path: Path}) { + const editor = useEditor() + const {subscribe} = usePatches({path: props.path}) + + useEffect(() => { + const unsubscribe = subscribe(({patches, snapshot}): void => { + editor.send({type: 'patches', patches, snapshot}) + }) + + return () => { + return unsubscribe() + } + }, [editor, subscribe]) + + return null +} + +/** + * `EditorProvider` doesn't have a `value` prop. Instead, this custom PTE + * plugin listens for the prop change and sends an `update value` event to the + * editor. + */ +function UpdateValuePlugin(props: {value: Array | undefined}) { + const editor = useEditor() + + useEffect(() => { + editor.send({ + type: 'update value', + value: props.value, + }) + }, [editor, props.value]) + + return null +} + +/** + * `EditorProvider` doesn't have a `readOnly` prop. Instead, this custom PTE + * plugin listens for the prop change and sends a `toggle readOnly` event to + * the editor. + */ +function UpdateReadOnlyPlugin(props: {readOnly: boolean}) { + const editor = useEditor() + + useEffect(() => { + editor.send({ + type: 'update readOnly', + readOnly: props.readOnly, + }) + }, [editor, props.readOnly]) + + return null +} + function toFormPatches(patches: any) { return patches.map((p: Patch) => ({...p, patchType: SANITY_PATCH_TYPE})) }