UNPKG

@portabletext/editor

Version:

Portable Text Editor made in React

872 lines (833 loc) 24.9 kB
import type {Patch} from '@portabletext/patches' import type {PortableTextBlock} from '@sanity/types' import {isEqual} from 'lodash' import {Editor, Text, Transforms, type Descendant, type Node} from 'slate' import { and, assertEvent, assign, emit, fromCallback, not, raise, setup, type AnyEventObject, type CallbackLogicFunction, } from 'xstate' import type {ActorRefFrom} from 'xstate' import {debugWithName} from '../internal-utils/debug' import {validateValue} from '../internal-utils/validateValue' import {toSlateValue, VOID_CHILD_KEY} from '../internal-utils/values' import { isChangingRemotely, withRemoteChanges, } from '../internal-utils/withChanges' import {withoutPatching} from '../internal-utils/withoutPatching' import type {PickFromUnion} from '../type-utils' import type { InvalidValueResolution, PortableTextSlateEditor, } from '../types/editor' import type {EditorSchema} from './editor-schema' import {withoutSaving} from './plugins/createWithUndoRedo' const debug = debugWithName('sync machine') type SyncValueEvent = | { type: 'patch' patch: Patch } | { type: 'invalid value' resolution: InvalidValueResolution | null value: Array<PortableTextBlock> | undefined } | { type: 'value changed' value: Array<PortableTextBlock> | undefined } | { type: 'done syncing' value: Array<PortableTextBlock> | undefined } const syncValueCallback: CallbackLogicFunction< AnyEventObject, SyncValueEvent, { context: { keyGenerator: () => string previousValue: Array<PortableTextBlock> | undefined readOnly: boolean schema: EditorSchema } slateEditor: PortableTextSlateEditor streamBlocks: boolean value: Array<PortableTextBlock> | undefined } > = ({sendBack, input}) => { updateValue({ context: input.context, sendBack, slateEditor: input.slateEditor, value: input.value, streamBlocks: input.streamBlocks, }) } const syncValueLogic = fromCallback(syncValueCallback) export type SyncActor = ActorRefFrom<typeof syncMachine> /** * Sync value with the editor state * * Normally nothing here should apply, and the editor and the real world are perfectly aligned. * * Inconsistencies could happen though, so we need to check the editor state when the value changes. * * For performance reasons, it makes sense to also do the content validation here, as we already * iterate over the value and can validate only the new content that is actually changed. * * @internal */ export const syncMachine = setup({ types: { context: {} as { initialValue: Array<PortableTextBlock> | undefined initialValueSynced: boolean isProcessingLocalChanges: boolean keyGenerator: () => string schema: EditorSchema readOnly: boolean slateEditor: PortableTextSlateEditor pendingValue: Array<PortableTextBlock> | undefined previousValue: Array<PortableTextBlock> | undefined }, input: {} as { initialValue: Array<PortableTextBlock> | undefined keyGenerator: () => string schema: EditorSchema readOnly: boolean slateEditor: PortableTextSlateEditor }, events: {} as | { type: 'has pending mutations' } | { type: 'mutation' } | { type: 'update value' value: Array<PortableTextBlock> | undefined } | { type: 'update readOnly' readOnly: boolean } | SyncValueEvent, emitted: {} as | PickFromUnion< SyncValueEvent, 'type', 'invalid value' | 'patch' | 'value changed' > | {type: 'done syncing value'} | {type: 'syncing value'}, }, actions: { 'assign initial value synced': assign({ initialValueSynced: true, }), 'assign readOnly': assign({ readOnly: ({event}) => { assertEvent(event, 'update readOnly') return event.readOnly }, }), 'assign pending value': assign({ pendingValue: ({event}) => { assertEvent(event, 'update value') return event.value }, }), 'clear pending value': assign({ pendingValue: undefined, }), 'assign previous value': assign({ previousValue: ({event}) => { assertEvent(event, 'done syncing') return event.value }, }), 'emit done syncing value': emit({ type: 'done syncing value', }), 'emit syncing value': emit({ type: 'syncing value', }), }, guards: { 'initial value synced': ({context}) => context.initialValueSynced, 'is busy': ({context}) => { const editable = !context.readOnly const isProcessingLocalChanges = context.isProcessingLocalChanges const isChanging = isChangingRemotely(context.slateEditor) ?? false const isBusy = editable && (isProcessingLocalChanges || isChanging) debug('isBusy', {isBusy, editable, isProcessingLocalChanges, isChanging}) return isBusy }, 'is empty value': ({event}) => { return event.type === 'update value' && event.value === undefined }, 'is empty array': ({event}) => { return ( event.type === 'update value' && Array.isArray(event.value) && event.value.length === 0 ) }, 'is new value': ({context, event}) => { return ( event.type === 'update value' && context.previousValue !== event.value ) }, 'value changed while syncing': ({context, event}) => { assertEvent(event, 'done syncing') return context.pendingValue !== event.value }, 'pending value equals previous value': ({context}) => { return isEqual(context.pendingValue, context.previousValue) }, }, actors: { 'sync value': syncValueLogic, }, }).createMachine({ id: 'sync', context: ({input}) => ({ initialValue: input.initialValue, initialValueSynced: false, isProcessingLocalChanges: false, keyGenerator: input.keyGenerator, schema: input.schema, readOnly: input.readOnly, slateEditor: input.slateEditor, pendingValue: undefined, previousValue: undefined, }), entry: [ raise(({context}) => { return {type: 'update value', value: context.initialValue} }), ], on: { 'has pending mutations': { actions: assign({ isProcessingLocalChanges: true, }), }, 'mutation': { actions: assign({ isProcessingLocalChanges: false, }), }, 'update readOnly': { actions: ['assign readOnly'], }, }, initial: 'idle', states: { idle: { entry: [ () => { debug('entry: syncing->idle') }, ], exit: [ () => { debug('exit: syncing->idle') }, ], on: { 'update value': [ { guard: and(['is empty value', not('initial value synced')]), actions: ['assign initial value synced', 'emit done syncing value'], }, { guard: and(['is empty array', not('initial value synced')]), actions: [ 'assign initial value synced', emit({type: 'value changed', value: []}), 'emit done syncing value', ], }, { guard: and(['is busy', 'is new value']), target: 'busy', actions: ['assign pending value'], }, { guard: 'is new value', target: 'syncing', actions: ['assign pending value'], }, { guard: not('initial value synced'), actions: [ () => { debug('no new value – setting initial value as synced') }, 'assign initial value synced', 'emit done syncing value', ], }, { actions: [ () => { debug('no new value and initial value already synced') }, ], }, ], }, }, busy: { entry: [ () => { debug('entry: syncing->busy') }, ], exit: [ () => { debug('exit: syncing->busy') }, ], after: { 1000: [ { guard: 'is busy', target: '.', reenter: true, actions: [ () => { debug('reenter: syncing->busy') }, ], }, { target: 'syncing', }, ], }, on: { 'update value': [ { guard: 'is new value', actions: ['assign pending value'], }, ], }, }, syncing: { entry: [ () => { debug('entry: syncing->syncing') }, 'emit syncing value', ], exit: [ () => { debug('exit: syncing->syncing') }, 'emit done syncing value', ], invoke: { src: 'sync value', id: 'sync value', input: ({context}) => { return { context: { keyGenerator: context.keyGenerator, previousValue: context.previousValue, readOnly: context.readOnly, schema: context.schema, }, slateEditor: context.slateEditor, streamBlocks: !context.initialValueSynced, value: context.pendingValue, } }, }, on: { 'update value': { guard: 'is new value', actions: ['assign pending value'], }, 'patch': { actions: [emit(({event}) => event)], }, 'invalid value': { actions: [emit(({event}) => event)], }, 'value changed': { actions: [emit(({event}) => event)], }, 'done syncing': [ { guard: 'value changed while syncing', actions: ['assign previous value', 'assign initial value synced'], target: 'syncing', reenter: true, }, { target: 'idle', actions: [ 'clear pending value', 'assign previous value', 'assign initial value synced', ], }, ], }, }, }, }) async function updateValue({ context, sendBack, slateEditor, streamBlocks, value, }: { context: { keyGenerator: () => string previousValue: Array<PortableTextBlock> | undefined readOnly: boolean schema: EditorSchema } sendBack: (event: SyncValueEvent) => void slateEditor: PortableTextSlateEditor streamBlocks: boolean value: PortableTextBlock[] | undefined }) { let doneSyncing = false let isChanged = false let isValid = true const hadSelection = !!slateEditor.selection // If empty value, remove everything in the editor and insert a placeholder block if (!value || value.length === 0) { debug('Value is empty') Editor.withoutNormalizing(slateEditor, () => { withoutSaving(slateEditor, () => { withRemoteChanges(slateEditor, () => { withoutPatching(slateEditor, () => { if (doneSyncing) { return } if (hadSelection) { Transforms.deselect(slateEditor) } const childrenLength = slateEditor.children.length slateEditor.children.forEach((_, index) => { Transforms.removeNodes(slateEditor, { at: [childrenLength - 1 - index], }) }) Transforms.insertNodes( slateEditor, slateEditor.pteCreateTextBlock({decorators: []}), {at: [0]}, ) // Add a new selection in the top of the document if (hadSelection) { Transforms.select(slateEditor, [0, 0]) } }) }) }) }) isChanged = true } // Remove, replace or add nodes according to what is changed. if (value && value.length > 0) { const slateValueFromProps = toSlateValue(value, { schemaTypes: context.schema, }) if (streamBlocks) { await new Promise<void>((resolve) => { Editor.withoutNormalizing(slateEditor, () => { withRemoteChanges(slateEditor, () => { withoutPatching(slateEditor, () => { if (doneSyncing) { resolve() return } isChanged = removeExtraBlocks({ slateEditor, slateValueFromProps, }) const processBlocks = async () => { for await (const [ currentBlock, currentBlockIndex, ] of getStreamedBlocks({ slateValue: slateValueFromProps, })) { const {blockChanged, blockValid} = syncBlock({ context, sendBack, block: currentBlock, index: currentBlockIndex, slateEditor, value, }) isChanged = blockChanged || isChanged isValid = isValid && blockValid } resolve() } processBlocks() }) }) }) }) } else { Editor.withoutNormalizing(slateEditor, () => { withRemoteChanges(slateEditor, () => { withoutPatching(slateEditor, () => { if (doneSyncing) { return } isChanged = removeExtraBlocks({ slateEditor, slateValueFromProps, }) let index = 0 for (const currentBlock of slateValueFromProps) { const {blockChanged, blockValid} = syncBlock({ context, sendBack, block: currentBlock, index, slateEditor, value, }) isChanged = blockChanged || isChanged isValid = isValid && blockValid index++ } }) }) }) } } if (!isValid) { debug('Invalid value, returning') doneSyncing = true sendBack({type: 'done syncing', value}) return } if (isChanged) { debug('Server value changed, syncing editor') try { slateEditor.onChange() } catch (err) { console.error(err) sendBack({ type: 'invalid value', resolution: null, value, }) doneSyncing = true sendBack({type: 'done syncing', value}) return } if (hadSelection && !slateEditor.selection) { Transforms.select(slateEditor, { anchor: {path: [0, 0], offset: 0}, focus: {path: [0, 0], offset: 0}, }) slateEditor.onChange() } sendBack({type: 'value changed', value}) } else { debug('Server value and editor value is equal, no need to sync.') } doneSyncing = true sendBack({type: 'done syncing', value}) } function removeExtraBlocks({ slateEditor, slateValueFromProps, }: { slateEditor: PortableTextSlateEditor slateValueFromProps: Array<Descendant> }) { let isChanged = false const childrenLength = slateEditor.children.length // Remove blocks that have become superfluous if (slateValueFromProps.length < childrenLength) { for (let i = childrenLength - 1; i > slateValueFromProps.length - 1; i--) { Transforms.removeNodes(slateEditor, { at: [i], }) } isChanged = true } return isChanged } async function* getStreamedBlocks({ slateValue, }: { slateValue: Array<Descendant> }) { let index = 0 for await (const block of slateValue) { if (index % 10 === 0) { await new Promise<void>((resolve) => setTimeout(resolve, 0)) } yield [block, index] as const index++ } } function syncBlock({ context, sendBack, block, index, slateEditor, value, }: { context: { keyGenerator: () => string previousValue: Array<PortableTextBlock> | undefined readOnly: boolean schema: EditorSchema } sendBack: (event: SyncValueEvent) => void block: Descendant index: number slateEditor: PortableTextSlateEditor value: Array<PortableTextBlock> }) { let blockChanged = false let blockValid = true const currentBlock = block const currentBlockIndex = index const oldBlock = slateEditor.children[currentBlockIndex] const hasChanges = oldBlock && !isEqual(currentBlock, oldBlock) Editor.withoutNormalizing(slateEditor, () => { withRemoteChanges(slateEditor, () => { withoutPatching(slateEditor, () => { if (hasChanges && blockValid) { const validationValue = [value[currentBlockIndex]] const validation = validateValue( validationValue, context.schema, context.keyGenerator, ) // Resolve validations that can be resolved automatically, without involving the user (but only if the value was changed) if ( !validation.valid && validation.resolution?.autoResolve && validation.resolution?.patches.length > 0 ) { // Only apply auto resolution if the value has been populated before and is different from the last one. if ( !context.readOnly && context.previousValue && context.previousValue !== value ) { // Give a console warning about the fact that it did an auto resolution console.warn( `${validation.resolution.action} for block with _key '${validationValue[0]._key}'. ${validation.resolution?.description}`, ) validation.resolution.patches.forEach((patch) => { sendBack({type: 'patch', patch}) }) } } if (validation.valid || validation.resolution?.autoResolve) { if (oldBlock._key === currentBlock._key) { if (debug.enabled) debug('Updating block', oldBlock, currentBlock) _updateBlock( slateEditor, currentBlock, oldBlock, currentBlockIndex, ) } else { if (debug.enabled) debug('Replacing block', oldBlock, currentBlock) _replaceBlock(slateEditor, currentBlock, currentBlockIndex) } blockChanged = true } else { sendBack({ type: 'invalid value', resolution: validation.resolution, value, }) blockValid = false } } if (!oldBlock && blockValid) { const validationValue = [value[currentBlockIndex]] const validation = validateValue( validationValue, context.schema, context.keyGenerator, ) if (debug.enabled) debug( 'Validating and inserting new block in the end of the value', currentBlock, ) if (validation.valid || validation.resolution?.autoResolve) { Transforms.insertNodes(slateEditor, currentBlock, { at: [currentBlockIndex], }) } else { debug('Invalid', validation) sendBack({ type: 'invalid value', resolution: validation.resolution, value, }) blockValid = false } } }) }) }) return {blockChanged, blockValid} } /** * This code is moved out of the above algorithm to keep complexity down. * @internal */ function _replaceBlock( slateEditor: PortableTextSlateEditor, currentBlock: Descendant, currentBlockIndex: number, ) { // While replacing the block and the current selection focus is on the replaced block, // temporarily deselect the editor then optimistically try to restore the selection afterwards. const currentSelection = slateEditor.selection const selectionFocusOnBlock = currentSelection && currentSelection.focus.path[0] === currentBlockIndex if (selectionFocusOnBlock) { Transforms.deselect(slateEditor) } Transforms.removeNodes(slateEditor, {at: [currentBlockIndex]}) Transforms.insertNodes(slateEditor, currentBlock, {at: [currentBlockIndex]}) slateEditor.onChange() if (selectionFocusOnBlock) { Transforms.select(slateEditor, currentSelection) } } /** * This code is moved out of the above algorithm to keep complexity down. * @internal */ function _updateBlock( slateEditor: PortableTextSlateEditor, currentBlock: Descendant, oldBlock: Descendant, currentBlockIndex: number, ) { // Update the root props on the block Transforms.setNodes(slateEditor, currentBlock as Partial<Node>, { at: [currentBlockIndex], }) // Text block's need to have their children updated as well (setNode does not target a node's children) if ( slateEditor.isTextBlock(currentBlock) && slateEditor.isTextBlock(oldBlock) ) { const oldBlockChildrenLength = oldBlock.children.length if (currentBlock.children.length < oldBlockChildrenLength) { // Remove any children that have become superfluous Array.from( Array(oldBlockChildrenLength - currentBlock.children.length), ).forEach((_, index) => { const childIndex = oldBlockChildrenLength - 1 - index if (childIndex > 0) { debug('Removing child') Transforms.removeNodes(slateEditor, { at: [currentBlockIndex, childIndex], }) } }) } currentBlock.children.forEach( (currentBlockChild, currentBlockChildIndex) => { const oldBlockChild = oldBlock.children[currentBlockChildIndex] const isChildChanged = !isEqual(currentBlockChild, oldBlockChild) const isTextChanged = !isEqual( currentBlockChild.text, oldBlockChild?.text, ) const path = [currentBlockIndex, currentBlockChildIndex] if (isChildChanged) { // Update if this is the same child if (currentBlockChild._key === oldBlockChild?._key) { debug('Updating changed child', currentBlockChild, oldBlockChild) Transforms.setNodes( slateEditor, currentBlockChild as Partial<Node>, { at: path, }, ) const isSpanNode = Text.isText(currentBlockChild) && currentBlockChild._type === 'span' && Text.isText(oldBlockChild) && oldBlockChild._type === 'span' if (isSpanNode && isTextChanged) { if (oldBlockChild.text.length > 0) { Transforms.delete(slateEditor, { at: { focus: {path, offset: 0}, anchor: {path, offset: oldBlockChild.text.length}, }, }) } Transforms.insertText(slateEditor, currentBlockChild.text, { at: path, }) slateEditor.onChange() } else if (!isSpanNode) { // If it's a inline block, also update the void text node key debug('Updating changed inline object child', currentBlockChild) Transforms.setNodes( slateEditor, {_key: VOID_CHILD_KEY}, { at: [...path, 0], voids: true, }, ) } // Replace the child if _key's are different } else if (oldBlockChild) { debug('Replacing child', currentBlockChild) Transforms.removeNodes(slateEditor, { at: [currentBlockIndex, currentBlockChildIndex], }) Transforms.insertNodes(slateEditor, currentBlockChild as Node, { at: [currentBlockIndex, currentBlockChildIndex], }) slateEditor.onChange() // Insert it if it didn't exist before } else if (!oldBlockChild) { debug('Inserting new child', currentBlockChild) Transforms.insertNodes(slateEditor, currentBlockChild as Node, { at: [currentBlockIndex, currentBlockChildIndex], }) slateEditor.onChange() } } }, ) } }