Skip to content

Commit

Permalink
refactor(core): replace PortableTextEditor with EditorProvider
Browse files Browse the repository at this point in the history
`EditorProvider` is the new way of setting up the Portable Text Editor. It's
going to be the way we document how to use standalone PTE and it's the way
Create already uses PTE.

It exposes all the new APIs that have been under development, and for now it's
also 100% backwards compatible by the fact that:

1. No types or interfaces have been removed.
2. The old `usePortableTextEditor()` and `usePortableTextEditorSelection()`
   hooks are still exposed.
3. Static methods like `PortableTextEditor.addAnnotation(...)` still work (even
   though the `PortableTextEditor` React class isn't used to instantiate the
   editor anymore.)
3. You can hand-roll custom "plugins" to achieve backwards compatible
   behaviours like listening for a `value` prop change or forwarding a ref to
   the `PortableTextEditor` instance.

This means that this change is a pure refactor that introduces no changes in
functionality and no downstream breaking changes for customers configuring PTE.
  • Loading branch information
christianhg committed Dec 18, 2024
1 parent a6d5320 commit d7c4945
Showing 1 changed file with 168 additions and 31 deletions.
199 changes: 168 additions & 31 deletions packages/sanity/src/core/form/inputs/PortableText/PortableTextInput.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<PortableTextEditor | null>((_, 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'
Expand Down Expand Up @@ -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<InvalidValue | null>(null)
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -380,16 +382,19 @@ export function PortableTextInput(props: PortableTextInputProps): ReactNode {
{(!invalidValue || ignoreValidationError) && (
<PortableTextMarkersProvider markers={markers}>
<PortableTextMemberItemsProvider memberItems={portableTextMemberItems}>
<PortableTextEditor
patches$={patches$}
keyGenerator={keyGenerator}
onChange={handleEditorChange}
maxBlocks={undefined} // TODO: from schema?
ref={editorRef}
readOnly={readOnly || !ready}
schemaType={schemaType}
value={value}
<EditorProvider
initialConfig={{
initialValue: value,
readOnly: readOnly || !ready,
keyGenerator,
schema: schemaType,
}}
>
<EditorChangePlugin onChange={handleEditorChange} />
<EditorRefPlugin ref={editorRef} />
<PatchesPlugin path={path} />
<UpdateReadOnlyPlugin readOnly={readOnly || !ready} />
<UpdateValuePlugin value={value} />
<Compositor
{...props}
elementRef={elementRef}
Expand All @@ -409,14 +414,146 @@ export function PortableTextInput(props: PortableTextInputProps): ReactNode {
renderCustomMarkers={renderCustomMarkers}
renderEditable={renderEditable}
/>
</PortableTextEditor>
</EditorProvider>
</PortableTextMemberItemsProvider>
</PortableTextMarkersProvider>
)}
</Box>
)
}

/**
* 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 <EditorEventListener on={handleEditorEvent} />
}

/**
* 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<PortableTextBlock> | 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}))
}

0 comments on commit d7c4945

Please sign in to comment.