UNPKG

@portabletext/editor

Version:

Portable Text Editor made in React

610 lines (562 loc) 17.8 kB
/** * This plugin will make the editor support undo/redo on the local state only. * The undo/redo steps are rebased against incoming patches since the step occurred. */ import type {Patch} from '@portabletext/patches' import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, parsePatch, } from '@sanity/diff-match-patch' import type {PortableTextBlock} from '@sanity/types' import {flatten, isEqual} from 'lodash' import { Editor, Operation, Path, Transforms, type Descendant, type SelectionOperation, } from 'slate' import {debugWithName} from '../../internal-utils/debug' import {fromSlateValue} from '../../internal-utils/values' import {isChangingRemotely} from '../../internal-utils/withChanges' import { isRedoing, isUndoing, setIsRedoing, setIsUndoing, withRedoing, withUndoing, } from '../../internal-utils/withUndoRedo' import type {BehaviorOperationImplementation} from '../../operations/behavior.operations' import type {PortableTextSlateEditor} from '../../types/editor' import type {EditorActor} from '../editor-machine' import {getCurrentUndoStepId} from '../with-undo-step' const debug = debugWithName('plugin:withUndoRedo') const debugVerbose = debug.enabled && false const SAVING = new WeakMap<Editor, boolean | undefined>() const REMOTE_PATCHES = new WeakMap< Editor, { patch: Patch time: Date snapshot: PortableTextBlock[] | undefined previousSnapshot: PortableTextBlock[] | undefined }[] >() const UNDO_STEP_LIMIT = 1000 const isSaving = (editor: Editor): boolean | undefined => { const state = SAVING.get(editor) return state === undefined ? true : state } export interface Options { editorActor: EditorActor subscriptions: Array<() => () => void> } const getRemotePatches = (editor: Editor) => { if (!REMOTE_PATCHES.get(editor)) { REMOTE_PATCHES.set(editor, []) } return REMOTE_PATCHES.get(editor) || [] } export function createWithUndoRedo( options: Options, ): (editor: PortableTextSlateEditor) => PortableTextSlateEditor { const {editorActor} = options return (editor: PortableTextSlateEditor) => { let previousSnapshot: PortableTextBlock[] | undefined = fromSlateValue( editor.children, editorActor.getSnapshot().context.schema.block.name, ) const remotePatches = getRemotePatches(editor) let previousUndoStepId = getCurrentUndoStepId(editor) options.subscriptions.push(() => { debug('Subscribing to patches') const sub = editorActor.on('patches', ({patches, snapshot}) => { let reset = false patches.forEach((patch) => { if (!reset && patch.origin !== 'local' && remotePatches) { if (patch.type === 'unset' && patch.path.length === 0) { debug( 'Someone else cleared the content, resetting undo/redo history', ) editor.history = {undos: [], redos: []} remotePatches.splice(0, remotePatches.length) SAVING.set(editor, true) reset = true return } remotePatches.push({ patch, time: new Date(), snapshot, previousSnapshot, }) } }) previousSnapshot = snapshot }) return () => { debug('Unsubscribing to patches') sub.unsubscribe() } }) editor.history = {undos: [], redos: []} const {apply} = editor editor.apply = (op: Operation) => { if (editorActor.getSnapshot().matches({'edit mode': 'read only'})) { apply(op) return } /** * We don't want to run any side effects when the editor is processing * remote changes. */ if (isChangingRemotely(editor)) { apply(op) return } /** * We don't want to run any side effects when the editor is undoing or * redoing operations. */ if (isUndoing(editor) || isRedoing(editor)) { apply(op) return } const {operations, history} = editor const {undos} = history const step = undos[undos.length - 1] const lastOp = step && step.operations && step.operations[step.operations.length - 1] const overwrite = shouldOverwrite(op, lastOp) const save = isSaving(editor) const currentUndoStepId = getCurrentUndoStepId(editor) let merge = currentUndoStepId === previousUndoStepId if (save) { if (!step) { merge = false } else if (operations.length === 0) { merge = currentUndoStepId === undefined && previousUndoStepId === undefined ? shouldMerge(op, lastOp) || overwrite : merge } if (step && merge) { step.operations.push(op) } else { const newStep = { operations: [ ...(editor.selection === null ? [] : [createSelectOperation(editor)]), op, ], timestamp: new Date(), } undos.push(newStep) debug('Created new undo step', step) } while (undos.length > UNDO_STEP_LIMIT) { undos.shift() } if (shouldClear(op)) { history.redos = [] } } previousUndoStepId = currentUndoStepId apply(op) } // Plugin return return editor } } export const historyUndoOperationImplementation: BehaviorOperationImplementation< 'history.undo' > = ({operation}) => { const editor = operation.editor const {undos} = editor.history const remotePatches = getRemotePatches(editor) if (undos.length > 0) { const step = undos[undos.length - 1] debug('Undoing', step) if (step.operations.length > 0) { const otherPatches = remotePatches.filter( (item) => item.time >= step.timestamp, ) let transformedOperations = step.operations otherPatches.forEach((item) => { transformedOperations = flatten( transformedOperations.map((op) => transformOperation( editor, item.patch, op, item.snapshot, item.previousSnapshot, ), ), ) }) const reversedOperations = transformedOperations .map(Operation.inverse) .reverse() try { Editor.withoutNormalizing(editor, () => { withUndoing(editor, () => { withoutSaving(editor, () => { reversedOperations.forEach((op) => { editor.apply(op) }) }) }) }) } catch (err) { debug('Could not perform undo step', err) remotePatches.splice(0, remotePatches.length) Transforms.deselect(editor) editor.history = {undos: [], redos: []} SAVING.set(editor, true) setIsUndoing(editor, false) editor.onChange() return } editor.history.redos.push(step) editor.history.undos.pop() } } } export const historyRedoOperationImplementation: BehaviorOperationImplementation< 'history.redo' > = ({operation}) => { const editor = operation.editor const {redos} = editor.history const remotePatches = getRemotePatches(editor) if (redos.length > 0) { const step = redos[redos.length - 1] debug('Redoing', step) if (step.operations.length > 0) { const otherPatches = remotePatches.filter( (item) => item.time >= step.timestamp, ) let transformedOperations = step.operations otherPatches.forEach((item) => { transformedOperations = flatten( transformedOperations.map((op) => transformOperation( editor, item.patch, op, item.snapshot, item.previousSnapshot, ), ), ) }) try { Editor.withoutNormalizing(editor, () => { withRedoing(editor, () => { withoutSaving(editor, () => { transformedOperations.forEach((op) => { editor.apply(op) }) }) }) }) } catch (err) { debug('Could not perform redo step', err) remotePatches.splice(0, remotePatches.length) Transforms.deselect(editor) editor.history = {undos: [], redos: []} SAVING.set(editor, true) setIsRedoing(editor, false) editor.onChange() return } editor.history.undos.push(step) editor.history.redos.pop() } } } /** * This will adjust the operation paths and offsets according to the * remote patches by other editors since the step operations was performed. */ function transformOperation( editor: PortableTextSlateEditor, patch: Patch, operation: Operation, snapshot: PortableTextBlock[] | undefined, previousSnapshot: PortableTextBlock[] | undefined, ): Operation[] { if (debugVerbose) { debug( `Adjusting '${operation.type}' operation paths for '${patch.type}' patch`, ) debug(`Operation ${JSON.stringify(operation)}`) debug(`Patch ${JSON.stringify(patch)}`) } const transformedOperation = {...operation} if (patch.type === 'insert' && patch.path.length === 1) { const insertBlockIndex = (snapshot || []).findIndex((blk) => isEqual({_key: blk._key}, patch.path[0]), ) debug( `Adjusting block path (+${patch.items.length}) for '${transformedOperation.type}' operation and patch '${patch.type}'`, ) return [ adjustBlockPath( transformedOperation, patch.items.length, insertBlockIndex, ), ] } if (patch.type === 'unset' && patch.path.length === 1) { const unsetBlockIndex = (previousSnapshot || []).findIndex((blk) => isEqual({_key: blk._key}, patch.path[0]), ) // If this operation is targeting the same block that got removed, return empty if ( 'path' in transformedOperation && Array.isArray(transformedOperation.path) && transformedOperation.path[0] === unsetBlockIndex ) { debug('Skipping transformation that targeted removed block') return [] } if (debugVerbose) { debug(`Selection ${JSON.stringify(editor.selection)}`) debug( `Adjusting block path (-1) for '${transformedOperation.type}' operation and patch '${patch.type}'`, ) } return [adjustBlockPath(transformedOperation, -1, unsetBlockIndex)] } // Someone reset the whole value if (patch.type === 'unset' && patch.path.length === 0) { debug( `Adjusting selection for unset everything patch and ${operation.type} operation`, ) return [] } if (patch.type === 'diffMatchPatch') { const operationTargetBlock = findOperationTargetBlock( editor, transformedOperation, ) if ( !operationTargetBlock || !isEqual({_key: operationTargetBlock._key}, patch.path[0]) ) { return [transformedOperation] } const diffPatches = parsePatch(patch.value) diffPatches.forEach((diffPatch) => { let adjustOffsetBy = 0 let changedOffset = diffPatch.utf8Start1 const {diffs} = diffPatch diffs.forEach((diff, index) => { const [diffType, text] = diff if (diffType === DIFF_INSERT) { adjustOffsetBy += text.length changedOffset += text.length } else if (diffType === DIFF_DELETE) { adjustOffsetBy -= text.length changedOffset -= text.length } else if (diffType === DIFF_EQUAL) { // Only up to the point where there are no other changes if (!diffs.slice(index).every(([dType]) => dType === DIFF_EQUAL)) { changedOffset += text.length } } }) // Adjust accordingly if someone inserted text in the same node before us if (transformedOperation.type === 'insert_text') { if (changedOffset < transformedOperation.offset) { transformedOperation.offset += adjustOffsetBy } } // Adjust accordingly if someone removed text in the same node before us if (transformedOperation.type === 'remove_text') { if ( changedOffset <= transformedOperation.offset - transformedOperation.text.length ) { transformedOperation.offset += adjustOffsetBy } } // Adjust set_selection operation's points to new offset if (transformedOperation.type === 'set_selection') { const currentFocus = transformedOperation.properties?.focus ? {...transformedOperation.properties.focus} : undefined const currentAnchor = transformedOperation?.properties?.anchor ? {...transformedOperation.properties.anchor} : undefined const newFocus = transformedOperation?.newProperties?.focus ? {...transformedOperation.newProperties.focus} : undefined const newAnchor = transformedOperation?.newProperties?.anchor ? {...transformedOperation.newProperties.anchor} : undefined if ((currentFocus && currentAnchor) || (newFocus && newAnchor)) { const points = [currentFocus, currentAnchor, newFocus, newAnchor] points.forEach((point) => { if (point && changedOffset < point.offset) { point.offset += adjustOffsetBy } }) if (currentFocus && currentAnchor) { transformedOperation.properties = { focus: currentFocus, anchor: currentAnchor, } } if (newFocus && newAnchor) { transformedOperation.newProperties = { focus: newFocus, anchor: newAnchor, } } } } }) return [transformedOperation] } return [transformedOperation] } /** * Adjust the block path for a operation */ function adjustBlockPath( operation: Operation, level: number, blockIndex: number, ): Operation { const transformedOperation = {...operation} if ( blockIndex >= 0 && transformedOperation.type !== 'set_selection' && Array.isArray(transformedOperation.path) && transformedOperation.path[0] >= blockIndex + level && transformedOperation.path[0] + level > -1 ) { const newPath = [ transformedOperation.path[0] + level, ...transformedOperation.path.slice(1), ] transformedOperation.path = newPath } if (transformedOperation.type === 'set_selection') { const currentFocus = transformedOperation.properties?.focus ? {...transformedOperation.properties.focus} : undefined const currentAnchor = transformedOperation?.properties?.anchor ? {...transformedOperation.properties.anchor} : undefined const newFocus = transformedOperation?.newProperties?.focus ? {...transformedOperation.newProperties.focus} : undefined const newAnchor = transformedOperation?.newProperties?.anchor ? {...transformedOperation.newProperties.anchor} : undefined if ((currentFocus && currentAnchor) || (newFocus && newAnchor)) { const points = [currentFocus, currentAnchor, newFocus, newAnchor] points.forEach((point) => { if ( point && point.path[0] >= blockIndex + level && point.path[0] + level > -1 ) { point.path = [point.path[0] + level, ...point.path.slice(1)] } }) if (currentFocus && currentAnchor) { transformedOperation.properties = { focus: currentFocus, anchor: currentAnchor, } } if (newFocus && newAnchor) { transformedOperation.newProperties = { focus: newFocus, anchor: newAnchor, } } } } // // Assign fresh point objects (we don't want to mutate the original ones) return transformedOperation } // Helper functions for editor.apply above const shouldMerge = (op: Operation, prev: Operation | undefined): boolean => { if (op.type === 'set_selection') { return true } // Text input if ( prev && op.type === 'insert_text' && prev.type === 'insert_text' && op.offset === prev.offset + prev.text.length && Path.equals(op.path, prev.path) && op.text !== ' ' // Tokenize between words ) { return true } // Text deletion if ( prev && op.type === 'remove_text' && prev.type === 'remove_text' && op.offset + op.text.length === prev.offset && Path.equals(op.path, prev.path) ) { return true } // Don't merge return false } const shouldOverwrite = ( op: Operation, prev: Operation | undefined, ): boolean => { if (prev && op.type === 'set_selection' && prev.type === 'set_selection') { return true } return false } const shouldClear = (op: Operation): boolean => { if (op.type === 'set_selection') { return false } return true } export function withoutSaving(editor: Editor, fn: () => void): void { const prev = isSaving(editor) SAVING.set(editor, false) fn() SAVING.set(editor, prev) } function createSelectOperation(editor: Editor): SelectionOperation { return { type: 'set_selection', properties: {...editor.selection}, newProperties: {...editor.selection}, } } function findOperationTargetBlock( editor: PortableTextSlateEditor, operation: Operation, ): Descendant | undefined { let block: Descendant | undefined if (operation.type === 'set_selection' && editor.selection) { block = editor.children[editor.selection.focus.path[0]] } else if ('path' in operation) { block = editor.children[operation.path[0]] } return block }