@portabletext/editor
Version:
Portable Text Editor made in React
628 lines (567 loc) • 20.5 kB
text/typescript
/**
*
* This plugin will change Slate's default marks model (every prop is a mark) with the Portable Text model (marks is an array of strings on prop .marks).
*
*/
import {isPortableTextBlock, isPortableTextSpan} from '@portabletext/toolkit'
import type {PortableTextObject, PortableTextSpan} from '@sanity/types'
import {isEqual, uniq} from 'lodash'
import {Editor, Element, Node, Path, Range, Text, Transforms} from 'slate'
import {debugWithName} from '../../internal-utils/debug'
import {getNextSpan, getPreviousSpan} from '../../internal-utils/sibling-utils'
import {isChangingRemotely} from '../../internal-utils/withChanges'
import {isRedoing, isUndoing} from '../../internal-utils/withUndoRedo'
import type {BehaviorOperationImplementation} from '../../operations/behavior.operations'
import {getActiveDecorators} from '../../selectors/selector.get-active-decorators'
import {getMarkState} from '../../selectors/selector.get-mark-state'
import type {PortableTextSlateEditor} from '../../types/editor'
import type {EditorActor} from '../editor-machine'
import {getEditorSnapshot} from '../editor-selector'
const debug = debugWithName('plugin:withPortableTextMarkModel')
export function createWithPortableTextMarkModel(
editorActor: EditorActor,
): (editor: PortableTextSlateEditor) => PortableTextSlateEditor {
return function withPortableTextMarkModel(editor: PortableTextSlateEditor) {
const {apply, normalizeNode} = editor
const decorators = editorActor
.getSnapshot()
.context.schema.decorators.map((t) => t.name)
// Extend Slate's default normalization. Merge spans with same set of .marks when doing merge_node operations, and clean up markDefs / marks
editor.normalizeNode = (nodeEntry) => {
const [node, path] = nodeEntry
if (editor.isTextBlock(node)) {
const children = Node.children(editor, path)
for (const [child, childPath] of children) {
const nextNode = node.children[childPath[1] + 1]
if (
editor.isTextSpan(child) &&
editor.isTextSpan(nextNode) &&
child.marks?.every((mark) => nextNode.marks?.includes(mark)) &&
nextNode.marks?.every((mark) => child.marks?.includes(mark))
) {
debug(
'Merging spans',
JSON.stringify(child, null, 2),
JSON.stringify(nextNode, null, 2),
)
editorActor.send({type: 'normalizing'})
Transforms.mergeNodes(editor, {
at: [childPath[0], childPath[1] + 1],
voids: true,
})
editorActor.send({type: 'done normalizing'})
return
}
}
}
/**
* Add missing .markDefs to block nodes
*/
if (editor.isTextBlock(node) && !Array.isArray(node.markDefs)) {
debug('Adding .markDefs to block node')
editorActor.send({type: 'normalizing'})
Transforms.setNodes(editor, {markDefs: []}, {at: path})
editorActor.send({type: 'done normalizing'})
return
}
/**
* Add missing .marks to span nodes
*/
if (editor.isTextSpan(node) && !Array.isArray(node.marks)) {
debug('Adding .marks to span node')
editorActor.send({type: 'normalizing'})
Transforms.setNodes(editor, {marks: []}, {at: path})
editorActor.send({type: 'done normalizing'})
return
}
/**
* Remove annotations from empty spans
*/
if (editor.isTextSpan(node)) {
const blockPath = Path.parent(path)
const [block] = Editor.node(editor, blockPath)
const decorators = editorActor
.getSnapshot()
.context.schema.decorators.map((decorator) => decorator.name)
const annotations = node.marks?.filter(
(mark) => !decorators.includes(mark),
)
if (editor.isTextBlock(block)) {
if (node.text === '' && annotations && annotations.length > 0) {
debug('Removing annotations from empty span node')
editorActor.send({type: 'normalizing'})
Transforms.setNodes(
editor,
{marks: node.marks?.filter((mark) => decorators.includes(mark))},
{at: path},
)
editorActor.send({type: 'done normalizing'})
return
}
}
}
/**
* Remove orphaned annotations from child spans of block nodes
*/
if (editor.isTextBlock(node)) {
const decorators = editorActor
.getSnapshot()
.context.schema.decorators.map((decorator) => decorator.name)
for (const [child, childPath] of Node.children(editor, path)) {
if (editor.isTextSpan(child)) {
const marks = child.marks ?? []
const orphanedAnnotations = marks.filter((mark) => {
return (
!decorators.includes(mark) &&
!node.markDefs?.find((def) => def._key === mark)
)
})
if (orphanedAnnotations.length > 0) {
debug('Removing orphaned annotations from span node')
editorActor.send({type: 'normalizing'})
Transforms.setNodes(
editor,
{
marks: marks.filter(
(mark) => !orphanedAnnotations.includes(mark),
),
},
{at: childPath},
)
editorActor.send({type: 'done normalizing'})
return
}
}
}
}
/**
* Remove orphaned annotations from span nodes
*/
if (editor.isTextSpan(node)) {
const blockPath = Path.parent(path)
const [block] = Editor.node(editor, blockPath)
if (editor.isTextBlock(block)) {
const decorators = editorActor
.getSnapshot()
.context.schema.decorators.map((decorator) => decorator.name)
const marks = node.marks ?? []
const orphanedAnnotations = marks.filter((mark) => {
return (
!decorators.includes(mark) &&
!block.markDefs?.find((def) => def._key === mark)
)
})
if (orphanedAnnotations.length > 0) {
debug('Removing orphaned annotations from span node')
editorActor.send({type: 'normalizing'})
Transforms.setNodes(
editor,
{
marks: marks.filter(
(mark) => !orphanedAnnotations.includes(mark),
),
},
{at: path},
)
editorActor.send({type: 'done normalizing'})
return
}
}
}
// Remove duplicate markDefs
if (editor.isTextBlock(node)) {
const markDefs = node.markDefs ?? []
const markDefKeys = new Set<string>()
const newMarkDefs: Array<PortableTextObject> = []
for (const markDef of markDefs) {
if (!markDefKeys.has(markDef._key)) {
markDefKeys.add(markDef._key)
newMarkDefs.push(markDef)
}
}
if (markDefs.length !== newMarkDefs.length) {
debug('Removing duplicate markDefs')
editorActor.send({type: 'normalizing'})
Transforms.setNodes(editor, {markDefs: newMarkDefs}, {at: path})
editorActor.send({type: 'done normalizing'})
return
}
}
// Check consistency of markDefs (unless we are merging two nodes)
if (
editor.isTextBlock(node) &&
!editor.operations.some(
(op) =>
op.type === 'merge_node' &&
'markDefs' in op.properties &&
op.path.length === 1,
)
) {
const newMarkDefs = (node.markDefs || []).filter((def) => {
return node.children.find((child) => {
return (
Text.isText(child) &&
Array.isArray(child.marks) &&
child.marks.includes(def._key)
)
})
})
if (node.markDefs && !isEqual(newMarkDefs, node.markDefs)) {
debug('Removing markDef not in use')
editorActor.send({type: 'normalizing'})
Transforms.setNodes(
editor,
{
markDefs: newMarkDefs,
},
{at: path},
)
editorActor.send({type: 'done normalizing'})
return
}
}
normalizeNode(nodeEntry)
}
editor.apply = (op) => {
/**
* 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
}
if (op.type === 'set_selection') {
if (
op.properties &&
op.newProperties &&
op.properties.anchor &&
op.properties.focus &&
op.newProperties.anchor &&
op.newProperties.focus
) {
const previousSelectionIsCollapsed = Range.isCollapsed({
anchor: op.properties.anchor,
focus: op.properties.focus,
})
const newSelectionIsCollapsed = Range.isCollapsed({
anchor: op.newProperties.anchor,
focus: op.newProperties.focus,
})
if (previousSelectionIsCollapsed && newSelectionIsCollapsed) {
const focusSpan: PortableTextSpan | undefined = Array.from(
Editor.nodes(editor, {
mode: 'lowest',
at: op.properties.focus,
match: (n) => editor.isTextSpan(n),
voids: false,
}),
)[0]?.[0]
const newFocusSpan: PortableTextSpan | undefined = Array.from(
Editor.nodes(editor, {
mode: 'lowest',
at: op.newProperties.focus,
match: (n) => editor.isTextSpan(n),
voids: false,
}),
)[0]?.[0]
const movedToNextSpan =
focusSpan &&
newFocusSpan &&
op.newProperties.focus.path[0] === op.properties.focus.path[0] &&
op.newProperties.focus.path[1] ===
op.properties.focus.path[1] + 1 &&
focusSpan.text.length === op.properties.focus.offset &&
op.newProperties.focus.offset === 0
const movedToPreviousSpan =
focusSpan &&
newFocusSpan &&
op.newProperties.focus.path[0] === op.properties.focus.path[0] &&
op.newProperties.focus.path[1] ===
op.properties.focus.path[1] - 1 &&
op.properties.focus.offset === 0 &&
newFocusSpan.text.length === op.newProperties.focus.offset
// We only want to clear the decorator state if the caret is visually
// moving
if (!movedToNextSpan && !movedToPreviousSpan) {
editor.decoratorState = {}
}
}
}
}
if (op.type === 'insert_node') {
const {selection} = editor
if (selection) {
const [_block, blockPath] = Editor.node(editor, selection, {depth: 1})
const previousSpan = getPreviousSpan({
editor,
blockPath,
spanPath: op.path,
})
const previousSpanAnnotations = previousSpan
? previousSpan.marks?.filter((mark) => !decorators.includes(mark))
: []
const nextSpan = getNextSpan({
editor,
blockPath,
spanPath: [op.path[0], op.path[1] - 1],
})
const nextSpanAnnotations = nextSpan
? nextSpan.marks?.filter((mark) => !decorators.includes(mark))
: []
const annotationsEnding =
previousSpanAnnotations?.filter(
(annotation) => !nextSpanAnnotations?.includes(annotation),
) ?? []
const atTheEndOfAnnotation = annotationsEnding.length > 0
if (
atTheEndOfAnnotation &&
isPortableTextSpan(op.node) &&
op.node.marks?.some((mark) => annotationsEnding.includes(mark))
) {
Transforms.insertNodes(editor, {
...op.node,
_key: editorActor.getSnapshot().context.keyGenerator(),
marks:
op.node.marks?.filter(
(mark) => !annotationsEnding.includes(mark),
) ?? [],
})
return
}
const annotationsStarting =
nextSpanAnnotations?.filter(
(annotation) => !previousSpanAnnotations?.includes(annotation),
) ?? []
const atTheStartOfAnnotation = annotationsStarting.length > 0
if (
atTheStartOfAnnotation &&
isPortableTextSpan(op.node) &&
op.node.marks?.some((mark) => annotationsStarting.includes(mark))
) {
Transforms.insertNodes(editor, {
...op.node,
_key: editorActor.getSnapshot().context.keyGenerator(),
marks:
op.node.marks?.filter(
(mark) => !annotationsStarting.includes(mark),
) ?? [],
})
return
}
const nextSpanDecorators =
nextSpan?.marks?.filter((mark) => decorators.includes(mark)) ?? []
const decoratorStarting = nextSpanDecorators.length > 0
if (
decoratorStarting &&
atTheEndOfAnnotation &&
!atTheStartOfAnnotation &&
isPortableTextSpan(op.node) &&
op.node.marks?.length === 0
) {
Transforms.insertNodes(editor, {
...op.node,
_key: editorActor.getSnapshot().context.keyGenerator(),
marks: nextSpanDecorators,
})
return
}
}
}
if (op.type === 'insert_text') {
const snapshot = getEditorSnapshot({
editorActorSnapshot: editorActor.getSnapshot(),
slateEditorInstance: editor,
})
const markState = getMarkState(snapshot)
if (!markState) {
apply(op)
return
}
if (markState.state === 'unchanged') {
apply(op)
return
}
Transforms.insertNodes(editor, {
_type: 'span',
_key: editorActor.getSnapshot().context.keyGenerator(),
text: op.text,
marks: markState.marks,
})
return
}
if (op.type === 'remove_text') {
const {selection} = editor
if (selection && Range.isExpanded(selection)) {
const [block, blockPath] = Editor.node(editor, selection, {
depth: 1,
})
const [span, spanPath] =
Array.from(
Editor.nodes(editor, {
mode: 'lowest',
at: {path: op.path, offset: op.offset},
match: (n) => editor.isTextSpan(n),
voids: false,
}),
)[0] ?? ([undefined, undefined] as const)
if (span && block && isPortableTextBlock(block)) {
const markDefs = block.markDefs ?? []
const marks = span.marks ?? []
const spanHasAnnotations = marks.some((mark) =>
markDefs.find((markDef) => markDef._key === mark),
)
const deletingFromTheEnd =
op.offset + op.text.length === span.text.length
const deletingAllText = op.offset === 0 && deletingFromTheEnd
const previousSpan = getPreviousSpan({editor, blockPath, spanPath})
const nextSpan = getNextSpan({editor, blockPath, spanPath})
const previousSpanHasSameAnnotation = previousSpan
? previousSpan.marks?.some(
(mark) => !decorators.includes(mark) && marks.includes(mark),
)
: false
const nextSpanHasSameAnnotation = nextSpan
? nextSpan.marks?.some(
(mark) => !decorators.includes(mark) && marks.includes(mark),
)
: false
if (
spanHasAnnotations &&
deletingAllText &&
!previousSpanHasSameAnnotation &&
!nextSpanHasSameAnnotation
) {
const snapshot = getEditorSnapshot({
editorActorSnapshot: editorActor.getSnapshot(),
slateEditorInstance: editor,
})
Editor.withoutNormalizing(editor, () => {
apply(op)
Transforms.setNodes(
editor,
{marks: getActiveDecorators(snapshot)},
{at: op.path},
)
})
editor.onChange()
return
}
}
}
}
/**
* Copy over markDefs when merging blocks
*/
if (
op.type === 'merge_node' &&
op.path.length === 1 &&
'markDefs' in op.properties &&
op.properties._type ===
editorActor.getSnapshot().context.schema.block.name &&
Array.isArray(op.properties.markDefs) &&
op.properties.markDefs.length > 0 &&
op.path[0] - 1 >= 0
) {
const [targetBlock, targetPath] = Editor.node(editor, [op.path[0] - 1])
if (editor.isTextBlock(targetBlock)) {
const oldDefs =
(Array.isArray(targetBlock.markDefs) && targetBlock.markDefs) || []
const newMarkDefs = uniq([...oldDefs, ...op.properties.markDefs])
debug(`Copying markDefs over to merged block`, op)
Transforms.setNodes(
editor,
{markDefs: newMarkDefs},
{at: targetPath, voids: false},
)
apply(op)
return
}
}
apply(op)
}
return editor
}
}
export const removeDecoratorOperationImplementation: BehaviorOperationImplementation<
'decorator.remove'
> = ({operation}) => {
const editor = operation.editor
const mark = operation.decorator
const {selection} = editor
if (selection) {
if (Range.isExpanded(selection)) {
// Split if needed
Transforms.setNodes(
editor,
{},
{match: Text.isText, split: true, hanging: true},
)
if (editor.selection) {
const splitTextNodes = [
...Editor.nodes(editor, {
at: editor.selection,
match: Text.isText,
}),
]
splitTextNodes.forEach(([node, path]) => {
const block = editor.children[path[0]]
if (Element.isElement(block) && block.children.includes(node)) {
Transforms.setNodes(
editor,
{
marks: (Array.isArray(node.marks) ? node.marks : []).filter(
(eMark: string) => eMark !== mark,
),
_type: 'span',
},
{at: path},
)
}
})
}
} else {
const [block, blockPath] = Editor.node(editor, selection, {
depth: 1,
})
const lonelyEmptySpan =
editor.isTextBlock(block) &&
block.children.length === 1 &&
editor.isTextSpan(block.children[0]) &&
block.children[0].text === ''
? block.children[0]
: undefined
if (lonelyEmptySpan) {
const existingMarks = lonelyEmptySpan.marks ?? []
const existingMarksWithoutDecorator = existingMarks.filter(
(existingMark) => existingMark !== mark,
)
Transforms.setNodes(
editor,
{
marks: existingMarksWithoutDecorator,
},
{
at: blockPath,
match: (node) => editor.isTextSpan(node),
},
)
} else {
editor.decoratorState[mark] = false
}
}
if (editor.selection) {
// Reselect
const selection = editor.selection
editor.selection = {...selection}
}
}
}