@portabletext/editor
Version:
Portable Text Editor made in React
490 lines (467 loc) • 14.8 kB
text/typescript
import {
diffMatchPatch,
insert,
set,
setIfMissing,
unset,
type InsertPosition,
type Patch,
} from '@portabletext/patches'
import type {Path, PortableTextSpan, PortableTextTextBlock} from '@sanity/types'
import {get, isUndefined, omitBy} from 'lodash'
import {
Text,
type Descendant,
type InsertNodeOperation,
type InsertTextOperation,
type MergeNodeOperation,
type MoveNodeOperation,
type RemoveNodeOperation,
type RemoveTextOperation,
type SetNodeOperation,
type SplitNodeOperation,
} from 'slate'
import type {EditorActor} from '../editor/editor-machine'
import type {PatchFunctions} from '../editor/plugins/createWithPatches'
import type {PortableTextSlateEditor} from '../types/editor'
import {debugWithName} from './debug'
import {fromSlateValue} from './values'
const debug = debugWithName('operationToPatches')
export function createOperationToPatches(
editorActor: EditorActor,
): PatchFunctions {
const textBlockName = editorActor.getSnapshot().context.schema.block.name
function insertTextPatch(
editor: PortableTextSlateEditor,
operation: InsertTextOperation,
beforeValue: Descendant[],
) {
if (debug.enabled) {
debug('Operation', JSON.stringify(operation, null, 2))
}
const block =
editor.isTextBlock(editor.children[operation.path[0]]) &&
editor.children[operation.path[0]]
if (!block) {
throw new Error('Could not find block')
}
const textChild =
editor.isTextBlock(block) &&
editor.isTextSpan(block.children[operation.path[1]]) &&
(block.children[operation.path[1]] as PortableTextSpan)
if (!textChild) {
throw new Error('Could not find child')
}
const path: Path = [
{_key: block._key},
'children',
{_key: textChild._key},
'text',
]
const prevBlock = beforeValue[operation.path[0]]
const prevChild =
editor.isTextBlock(prevBlock) && prevBlock.children[operation.path[1]]
const prevText = editor.isTextSpan(prevChild) ? prevChild.text : ''
const patch = diffMatchPatch(prevText, textChild.text, path)
return patch.value.length ? [patch] : []
}
function removeTextPatch(
editor: PortableTextSlateEditor,
operation: RemoveTextOperation,
beforeValue: Descendant[],
) {
const block = editor && editor.children[operation.path[0]]
if (!block) {
throw new Error('Could not find block')
}
const child =
(editor.isTextBlock(block) && block.children[operation.path[1]]) ||
undefined
const textChild: PortableTextSpan | undefined = editor.isTextSpan(child)
? child
: undefined
if (child && !textChild) {
throw new Error('Expected span')
}
if (!textChild) {
throw new Error('Could not find child')
}
const path: Path = [
{_key: block._key},
'children',
{_key: textChild._key},
'text',
]
const beforeBlock = beforeValue[operation.path[0]]
const prevTextChild =
editor.isTextBlock(beforeBlock) && beforeBlock.children[operation.path[1]]
const prevText = editor.isTextSpan(prevTextChild) && prevTextChild.text
const patch = diffMatchPatch(prevText || '', textChild.text, path)
return patch.value ? [patch] : []
}
function setNodePatch(
editor: PortableTextSlateEditor,
operation: SetNodeOperation,
) {
if (operation.path.length === 1) {
const block = editor.children[operation.path[0]]
if (typeof block._key !== 'string') {
throw new Error('Expected block to have a _key')
}
const setNode = omitBy(
{...editor.children[operation.path[0]], ...operation.newProperties},
isUndefined,
) as unknown as Descendant
return [
set(fromSlateValue([setNode], textBlockName)[0], [{_key: block._key}]),
]
} else if (operation.path.length === 2) {
const block = editor.children[operation.path[0]]
if (editor.isTextBlock(block)) {
const child = block.children[operation.path[1]]
if (child) {
const blockKey = block._key
const childKey = child._key
const patches: Patch[] = []
const keys = Object.keys(operation.newProperties)
keys.forEach((keyName) => {
// Special case for setting _key on a child. We have to target it by index and not the _key.
if (keys.length === 1 && keyName === '_key') {
const val = get(operation.newProperties, keyName)
patches.push(
set(val, [
{_key: blockKey},
'children',
block.children.indexOf(child),
keyName,
]),
)
} else {
const val = get(operation.newProperties, keyName)
patches.push(
set(val, [
{_key: blockKey},
'children',
{_key: childKey},
keyName,
]),
)
}
})
return patches
}
throw new Error('Could not find a valid child')
}
throw new Error('Could not find a valid block')
} else {
throw new Error(
`Unexpected path encountered: ${JSON.stringify(operation.path)}`,
)
}
}
function insertNodePatch(
editor: PortableTextSlateEditor,
operation: InsertNodeOperation,
beforeValue: Descendant[],
): Patch[] {
const block = beforeValue[operation.path[0]]
const isTextBlock = editor.isTextBlock(block)
if (operation.path.length === 1) {
const position = operation.path[0] === 0 ? 'before' : 'after'
const beforeBlock = beforeValue[operation.path[0] - 1]
const targetKey =
operation.path[0] === 0 ? block?._key : beforeBlock?._key
if (targetKey) {
return [
insert(
[fromSlateValue([operation.node as Descendant], textBlockName)[0]],
position,
[{_key: targetKey}],
),
]
}
return [
setIfMissing(beforeValue, []),
insert(
[fromSlateValue([operation.node as Descendant], textBlockName)[0]],
'before',
[operation.path[0]],
),
]
} else if (
isTextBlock &&
operation.path.length === 2 &&
editor.children[operation.path[0]]
) {
const position =
block.children.length === 0 || !block.children[operation.path[1] - 1]
? 'before'
: 'after'
const node = {...operation.node} as Descendant
if (!node._type && Text.isText(node)) {
node._type = 'span'
node.marks = []
}
const blk = fromSlateValue(
[
{
_key: 'bogus',
_type: textBlockName,
children: [node],
},
],
textBlockName,
)[0] as PortableTextTextBlock
const child = blk.children[0]
return [
insert([child], position, [
{_key: block._key},
'children',
block.children.length <= 1 || !block.children[operation.path[1] - 1]
? 0
: {_key: block.children[operation.path[1] - 1]._key},
]),
]
}
debug(
'Something was inserted into a void block. Not producing editor patches.',
)
return []
}
function splitNodePatch(
editor: PortableTextSlateEditor,
operation: SplitNodeOperation,
beforeValue: Descendant[],
) {
const patches: Patch[] = []
const splitBlock = editor.children[operation.path[0]]
if (!editor.isTextBlock(splitBlock)) {
throw new Error(
`Block with path ${JSON.stringify(
operation.path[0],
)} is not a text block and can't be split`,
)
}
if (operation.path.length === 1) {
const oldBlock = beforeValue[operation.path[0]]
if (editor.isTextBlock(oldBlock)) {
const targetValue = fromSlateValue(
[editor.children[operation.path[0] + 1]],
textBlockName,
)[0]
if (targetValue) {
patches.push(
insert([targetValue], 'after', [{_key: splitBlock._key}]),
)
const spansToUnset = oldBlock.children.slice(operation.position)
spansToUnset.forEach((span) => {
const path = [{_key: oldBlock._key}, 'children', {_key: span._key}]
patches.push(unset(path))
})
}
}
return patches
}
if (operation.path.length === 2) {
const splitSpan = splitBlock.children[operation.path[1]]
if (editor.isTextSpan(splitSpan)) {
const targetSpans = (
fromSlateValue(
[
{
...splitBlock,
children: splitBlock.children.slice(
operation.path[1] + 1,
operation.path[1] + 2,
),
} as Descendant,
],
textBlockName,
)[0] as PortableTextTextBlock
).children
patches.push(
insert(targetSpans, 'after', [
{_key: splitBlock._key},
'children',
{_key: splitSpan._key},
]),
)
patches.push(
set(splitSpan.text, [
{_key: splitBlock._key},
'children',
{_key: splitSpan._key},
'text',
]),
)
}
return patches
}
return patches
}
function removeNodePatch(
editor: PortableTextSlateEditor,
operation: RemoveNodeOperation,
beforeValue: Descendant[],
) {
const block = beforeValue[operation.path[0]]
if (operation.path.length === 1) {
// Remove a single block
if (block && block._key) {
return [unset([{_key: block._key}])]
}
throw new Error('Block not found')
} else if (editor.isTextBlock(block) && operation.path.length === 2) {
const spanToRemove = block.children[operation.path[1]]
if (spanToRemove) {
const spansMatchingKey = block.children.filter(
(span) => span._key === operation.node._key,
)
if (spansMatchingKey.length > 1) {
console.warn(
`Multiple spans have \`_key\` ${operation.node._key}. It's ambiguous which one to remove.`,
JSON.stringify(block, null, 2),
)
return []
}
return [
unset([{_key: block._key}, 'children', {_key: spanToRemove._key}]),
]
}
debug('Span not found in editor trying to remove node')
return []
} else {
debug('Not creating patch inside object block')
return []
}
}
function mergeNodePatch(
editor: PortableTextSlateEditor,
operation: MergeNodeOperation,
beforeValue: Descendant[],
) {
const patches: Patch[] = []
const block = beforeValue[operation.path[0]]
const updatedBlock = editor.children[operation.path[0]]
if (operation.path.length === 1) {
if (block?._key) {
const newBlock = fromSlateValue(
[editor.children[operation.path[0] - 1]],
textBlockName,
)[0]
patches.push(set(newBlock, [{_key: newBlock._key}]))
patches.push(unset([{_key: block._key}]))
} else {
throw new Error('Target key not found!')
}
} else if (
editor.isTextBlock(block) &&
editor.isTextBlock(updatedBlock) &&
operation.path.length === 2
) {
const updatedSpan =
updatedBlock.children[operation.path[1] - 1] &&
editor.isTextSpan(updatedBlock.children[operation.path[1] - 1])
? updatedBlock.children[operation.path[1] - 1]
: undefined
const removedSpan =
block.children[operation.path[1]] &&
editor.isTextSpan(block.children[operation.path[1]])
? block.children[operation.path[1]]
: undefined
if (updatedSpan) {
const spansMatchingKey = block.children.filter(
(span) => span._key === updatedSpan._key,
)
if (spansMatchingKey.length === 1) {
patches.push(
set(updatedSpan.text, [
{_key: block._key},
'children',
{_key: updatedSpan._key},
'text',
]),
)
} else {
console.warn(
`Multiple spans have \`_key\` ${updatedSpan._key}. It's ambiguous which one to update.`,
JSON.stringify(block, null, 2),
)
}
}
if (removedSpan) {
const spansMatchingKey = block.children.filter(
(span) => span._key === removedSpan._key,
)
if (spansMatchingKey.length === 1) {
patches.push(
unset([{_key: block._key}, 'children', {_key: removedSpan._key}]),
)
} else {
console.warn(
`Multiple spans have \`_key\` ${removedSpan._key}. It's ambiguous which one to remove.`,
JSON.stringify(block, null, 2),
)
}
}
} else {
debug("Void nodes can't be merged, not creating any patches")
}
return patches
}
function moveNodePatch(
editor: PortableTextSlateEditor,
operation: MoveNodeOperation,
beforeValue: Descendant[],
) {
const patches: Patch[] = []
const block = beforeValue[operation.path[0]]
const targetBlock = beforeValue[operation.newPath[0]]
if (!targetBlock) {
return patches
}
if (operation.path.length === 1) {
const position: InsertPosition =
operation.path[0] > operation.newPath[0] ? 'before' : 'after'
patches.push(unset([{_key: block._key}]))
patches.push(
insert([fromSlateValue([block], textBlockName)[0]], position, [
{_key: targetBlock._key},
]),
)
} else if (
operation.path.length === 2 &&
editor.isTextBlock(block) &&
editor.isTextBlock(targetBlock)
) {
const child = block.children[operation.path[1]]
const targetChild = targetBlock.children[operation.newPath[1]]
const position =
operation.newPath[1] === targetBlock.children.length
? 'after'
: 'before'
const childToInsert = (
fromSlateValue([block], textBlockName)[0] as PortableTextTextBlock
).children[operation.path[1]]
patches.push(unset([{_key: block._key}, 'children', {_key: child._key}]))
patches.push(
insert([childToInsert], position, [
{_key: targetBlock._key},
'children',
{_key: targetChild._key},
]),
)
}
return patches
}
return {
insertNodePatch,
insertTextPatch,
mergeNodePatch,
moveNodePatch,
removeNodePatch,
removeTextPatch,
setNodePatch,
splitNodePatch,
}
}