@portabletext/editor
Version:
Portable Text Editor made in React
457 lines (431 loc) • 12.4 kB
text/typescript
import {
applyAll,
type DiffMatchPatch,
type InsertPatch,
type Patch,
type SetPatch,
type UnsetPatch,
} from '@portabletext/patches'
import {
cleanupEfficiency,
DIFF_DELETE,
DIFF_EQUAL,
DIFF_INSERT,
applyPatches as diffMatchPatchApplyPatches,
makeDiff,
parsePatch,
} from '@sanity/diff-match-patch'
import type {
KeyedSegment,
Path,
PathSegment,
PortableTextBlock,
PortableTextChild,
} from '@sanity/types'
import {
Element,
Text,
Transforms,
type Descendant,
type Node,
type Path as SlatePath,
} from 'slate'
import type {EditorSchema} from '../editor/editor-schema'
import type {PortableTextSlateEditor} from '../types/editor'
import {debugWithName} from './debug'
import {toSlateValue} from './values'
import {KEY_TO_SLATE_ELEMENT} from './weakMaps'
const debug = debugWithName('applyPatches')
const debugVerbose = debug.enabled && true
/**
* Creates a function that can apply a patch onto a PortableTextSlateEditor.
*/
export function createApplyPatch(
schema: EditorSchema,
): (editor: PortableTextSlateEditor, patch: Patch) => boolean {
return (editor: PortableTextSlateEditor, patch: Patch): boolean => {
let changed = false
// Save some CPU cycles by not stringifying unless enabled
if (debugVerbose) {
debug(
'\n\nNEW PATCH =============================================================',
)
debug(JSON.stringify(patch, null, 2))
}
try {
switch (patch.type) {
case 'insert':
changed = insertPatch(editor, patch, schema)
break
case 'unset':
changed = unsetPatch(editor, patch)
break
case 'set':
changed = setPatch(editor, patch)
break
case 'diffMatchPatch':
changed = diffMatchPatch(editor, patch)
break
default:
debug('Unhandled patch', patch.type)
}
} catch (err) {
console.error(err)
}
return changed
}
}
/**
* Apply a remote diff match patch to the current PTE instance.
* Note meant for external consumption, only exported for testing purposes.
*
* @param editor - Portable text slate editor instance
* @param patch - The PTE diff match patch operation to apply
* @returns true if the patch was applied, false otherwise
* @internal
*/
export function diffMatchPatch(
editor: Pick<
PortableTextSlateEditor,
'children' | 'isTextBlock' | 'apply' | 'selection' | 'onChange'
>,
patch: DiffMatchPatch,
): boolean {
const {block, child, childPath} = findBlockAndChildFromPath(
editor,
patch.path,
)
if (!block) {
debug('Block not found')
return false
}
if (!child || !childPath) {
debug('Child not found')
return false
}
const isSpanTextDiffMatchPatch =
block &&
editor.isTextBlock(block) &&
patch.path.length === 4 &&
patch.path[1] === 'children' &&
patch.path[3] === 'text'
if (!isSpanTextDiffMatchPatch || !Text.isText(child)) {
return false
}
const patches = parsePatch(patch.value)
const [newValue] = diffMatchPatchApplyPatches(patches, child.text, {
allowExceedingIndices: true,
})
const diff = cleanupEfficiency(makeDiff(child.text, newValue), 5)
debugState(editor, 'before')
let offset = 0
for (const [op, text] of diff) {
if (op === DIFF_INSERT) {
editor.apply({type: 'insert_text', path: childPath, offset, text})
offset += text.length
} else if (op === DIFF_DELETE) {
editor.apply({type: 'remove_text', path: childPath, offset: offset, text})
} else if (op === DIFF_EQUAL) {
offset += text.length
}
}
debugState(editor, 'after')
return true
}
function insertPatch(
editor: PortableTextSlateEditor,
patch: InsertPatch,
schema: EditorSchema,
) {
const {
block: targetBlock,
child: targetChild,
blockPath: targetBlockPath,
childPath: targetChildPath,
} = findBlockAndChildFromPath(editor, patch.path)
if (!targetBlock || !targetBlockPath) {
debug('Block not found')
return false
}
if (patch.path.length > 1 && patch.path[1] !== 'children') {
debug('Ignoring patch targeting void value')
return false
}
// Insert blocks
if (patch.path.length === 1) {
const {items, position} = patch
const blocksToInsert = toSlateValue(
items as PortableTextBlock[],
{schemaTypes: schema},
KEY_TO_SLATE_ELEMENT.get(editor),
) as Descendant[]
const targetBlockIndex = targetBlockPath[0]
const normalizedIdx =
position === 'after' ? targetBlockIndex + 1 : targetBlockIndex
debug(`Inserting blocks at path [${normalizedIdx}]`)
debugState(editor, 'before')
Transforms.insertNodes(editor, blocksToInsert, {at: [normalizedIdx]})
debugState(editor, 'after')
return true
}
// Insert children
const {items, position} = patch
if (!targetChild || !targetChildPath) {
debug('Child not found')
return false
}
const childrenToInsert =
targetBlock &&
toSlateValue(
[{...targetBlock, children: items as PortableTextChild[]}],
{schemaTypes: schema},
KEY_TO_SLATE_ELEMENT.get(editor),
)
const targetChildIndex = targetChildPath[1]
const normalizedIdx =
position === 'after' ? targetChildIndex + 1 : targetChildIndex
const childInsertPath = [targetChildPath[0], normalizedIdx]
debug(`Inserting children at path ${childInsertPath}`)
debugState(editor, 'before')
if (childrenToInsert && Element.isElement(childrenToInsert[0])) {
Transforms.insertNodes(editor, childrenToInsert[0].children, {
at: childInsertPath,
})
}
debugState(editor, 'after')
return true
}
function setPatch(editor: PortableTextSlateEditor, patch: SetPatch) {
let value = patch.value
if (typeof patch.path[3] === 'string') {
value = {}
value[patch.path[3]] = patch.value
}
const {block, blockPath, child, childPath} = findBlockAndChildFromPath(
editor,
patch.path,
)
if (!block) {
debug('Block not found')
return false
}
const isTextBlock = editor.isTextBlock(block)
// Ignore patches targeting nested void data, like 'markDefs'
if (isTextBlock && patch.path.length > 1 && patch.path[1] !== 'children') {
debug('Ignoring setting void value')
return false
}
debugState(editor, 'before')
// If this is targeting a text block child
if (isTextBlock && child && childPath) {
if (Text.isText(value) && Text.isText(child)) {
const newText = child.text
const oldText = value.text
if (oldText !== newText) {
debug('Setting text property')
editor.apply({
type: 'remove_text',
path: childPath,
offset: 0,
text: newText,
})
editor.apply({
type: 'insert_text',
path: childPath,
offset: 0,
text: value.text,
})
// call OnChange here to emit the new selection
// the user's selection might be interfering with
editor.onChange()
}
} else {
debug('Setting non-text property')
editor.apply({
type: 'set_node',
path: childPath,
properties: {},
newProperties: value as Partial<Node>,
})
}
return true
} else if (Element.isElement(block) && patch.path.length === 1 && blockPath) {
debug('Setting block property')
const {children, ...nextRest} = value as unknown as PortableTextBlock
const {children: prevChildren, ...prevRest} = block || {children: undefined}
// Set any block properties
editor.apply({
type: 'set_node',
path: blockPath,
properties: {...prevRest},
newProperties: nextRest,
})
// Replace the children in the block
// Note that children must be explicitly inserted, and can't be set with set_node
debug('Setting children')
block.children.forEach((c, cIndex) => {
editor.apply({
type: 'remove_node',
path: blockPath.concat(block.children.length - 1 - cIndex),
node: c,
})
})
if (Array.isArray(children)) {
children.forEach((c, cIndex) => {
editor.apply({
type: 'insert_node',
path: blockPath.concat(cIndex),
node: c,
})
})
}
} else if (block && 'value' in block) {
if (patch.path.length > 1 && patch.path[1] !== 'children') {
const newVal = applyAll(block.value, [
{
...patch,
path: patch.path.slice(1),
},
])
Transforms.setNodes(editor, {...block, value: newVal}, {at: blockPath})
} else {
return false
}
}
debugState(editor, 'after')
return true
}
function unsetPatch(editor: PortableTextSlateEditor, patch: UnsetPatch) {
// Value
if (patch.path.length === 0) {
debug('Removing everything')
debugState(editor, 'before')
const previousSelection = editor.selection
Transforms.deselect(editor)
editor.children.forEach((_child, i) => {
Transforms.removeNodes(editor, {at: [i]})
})
Transforms.insertNodes(editor, editor.pteCreateTextBlock({decorators: []}))
if (previousSelection) {
Transforms.select(editor, {
anchor: {path: [0, 0], offset: 0},
focus: {path: [0, 0], offset: 0},
})
}
// call OnChange here to emit the new selection
editor.onChange()
debugState(editor, 'after')
return true
}
const {block, blockPath, child, childPath} = findBlockAndChildFromPath(
editor,
patch.path,
)
// Single blocks
if (patch.path.length === 1) {
if (!block || !blockPath) {
debug('Block not found')
return false
}
const blockIndex = blockPath[0]
debug(`Removing block at path [${blockIndex}]`)
debugState(editor, 'before')
Transforms.removeNodes(editor, {at: [blockIndex]})
debugState(editor, 'after')
return true
}
// Unset on text block children
if (
editor.isTextBlock(block) &&
patch.path[1] === 'children' &&
patch.path.length === 3
) {
if (!child || !childPath) {
debug('Child not found')
return false
}
debug(`Unsetting child at path ${JSON.stringify(childPath)}`)
debugState(editor, 'before')
if (debugVerbose) {
debug(`Removing child at path ${JSON.stringify(childPath)}`)
}
Transforms.removeNodes(editor, {at: childPath})
debugState(editor, 'after')
return true
}
return false
}
function isKeyedSegment(segment: PathSegment): segment is KeyedSegment {
return typeof segment === 'object' && '_key' in segment
}
function debugState(
editor: Pick<
PortableTextSlateEditor,
'children' | 'isTextBlock' | 'apply' | 'selection'
>,
stateName: string,
) {
if (!debugVerbose) {
return
}
debug(`Children ${stateName}:`, JSON.stringify(editor.children, null, 2))
debug(`Selection ${stateName}: `, JSON.stringify(editor.selection, null, 2))
}
function findBlockFromPath(
editor: Pick<
PortableTextSlateEditor,
'children' | 'isTextBlock' | 'apply' | 'selection' | 'onChange'
>,
path: Path,
): {block?: Descendant; path?: SlatePath} {
let blockIndex = -1
const block = editor.children.find((node: Descendant, index: number) => {
const isMatch = isKeyedSegment(path[0])
? node._key === path[0]._key
: index === path[0]
if (isMatch) {
blockIndex = index
}
return isMatch
})
if (!block) {
return {}
}
return {block, path: [blockIndex] as SlatePath}
}
function findBlockAndChildFromPath(
editor: Pick<
PortableTextSlateEditor,
'children' | 'isTextBlock' | 'apply' | 'selection' | 'onChange'
>,
path: Path,
): {
child?: Descendant
childPath?: SlatePath
block?: Descendant
blockPath?: SlatePath
} {
const {block, path: blockPath} = findBlockFromPath(editor, path)
if (!(Element.isElement(block) && path[1] === 'children')) {
return {block, blockPath, child: undefined, childPath: undefined}
}
let childIndex = -1
const child = block.children.find((node, index: number) => {
const isMatch = isKeyedSegment(path[2])
? node._key === path[2]._key
: index === path[2]
if (isMatch) {
childIndex = index
}
return isMatch
})
if (!child) {
return {block, blockPath, child: undefined, childPath: undefined}
}
return {
block,
child,
blockPath,
childPath: blockPath?.concat(childIndex) as SlatePath,
}
}