@portabletext/editor
Version:
Portable Text Editor made in React
415 lines (378 loc) • 11.4 kB
text/typescript
import {isEqual} from 'lodash'
import {
Element,
Path,
Range,
type BaseRange,
type NodeEntry,
type Operation,
} from 'slate'
import {
and,
assign,
fromCallback,
setup,
type ActorRefFrom,
type AnyEventObject,
type CallbackLogicFunction,
} from 'xstate'
import {moveRangeByOperation, toSlateRange} from '../internal-utils/ranges'
import {slateRangeToSelection} from '../internal-utils/slate-utils'
import {isEqualToEmptyEditor} from '../internal-utils/values'
import type {PortableTextSlateEditor, RangeDecoration} from '../types/editor'
import type {EditorSchema} from './editor-schema'
const slateOperationCallback: CallbackLogicFunction<
AnyEventObject,
{type: 'slate operation'; operation: Operation},
{slateEditor: PortableTextSlateEditor}
> = ({input, sendBack}) => {
const originalApply = input.slateEditor.apply
input.slateEditor.apply = (op) => {
if (op.type !== 'set_selection') {
sendBack({type: 'slate operation', operation: op})
}
originalApply(op)
}
return () => {
input.slateEditor.apply = originalApply
}
}
type DecoratedRange = BaseRange & {rangeDecoration: RangeDecoration}
export const rangeDecorationsMachine = setup({
types: {
context: {} as {
decoratedRanges: Array<DecoratedRange>
pendingRangeDecorations: Array<RangeDecoration>
skipSetup: boolean
readOnly: boolean
schema: EditorSchema
slateEditor: PortableTextSlateEditor
updateCount: number
},
input: {} as {
rangeDecorations: Array<RangeDecoration>
readOnly: boolean
schema: EditorSchema
skipSetup: boolean
slateEditor: PortableTextSlateEditor
},
events: {} as
| {
type: 'ready'
}
| {
type: 'range decorations updated'
rangeDecorations: Array<RangeDecoration>
}
| {
type: 'slate operation'
operation: Operation
}
| {
type: 'update read only'
readOnly: boolean
},
},
actions: {
'update pending range decorations': assign({
pendingRangeDecorations: ({context, event}) => {
if (event.type !== 'range decorations updated') {
return context.pendingRangeDecorations
}
return event.rangeDecorations
},
}),
'set up initial range decorations': assign({
decoratedRanges: ({context}) => {
const rangeDecorationState: Array<DecoratedRange> = []
for (const rangeDecoration of context.pendingRangeDecorations) {
const slateRange = toSlateRange(
rangeDecoration.selection,
context.slateEditor,
)
if (!Range.isRange(slateRange)) {
rangeDecoration.onMoved?.({
newSelection: null,
rangeDecoration,
origin: 'local',
})
continue
}
rangeDecorationState.push({
rangeDecoration,
...slateRange,
})
}
return rangeDecorationState
},
}),
'update range decorations': assign({
decoratedRanges: ({context, event}) => {
if (event.type !== 'range decorations updated') {
return context.decoratedRanges
}
const rangeDecorationState: Array<DecoratedRange> = []
for (const rangeDecoration of event.rangeDecorations) {
const slateRange = toSlateRange(
rangeDecoration.selection,
context.slateEditor,
)
if (!Range.isRange(slateRange)) {
rangeDecoration.onMoved?.({
newSelection: null,
rangeDecoration,
origin: 'local',
})
continue
}
rangeDecorationState.push({
rangeDecoration,
...slateRange,
})
}
return rangeDecorationState
},
}),
'move range decorations': assign({
decoratedRanges: ({context, event}) => {
if (event.type !== 'slate operation') {
return context.decoratedRanges
}
const rangeDecorationState: Array<DecoratedRange> = []
for (const decoratedRange of context.decoratedRanges) {
const slateRange = toSlateRange(
decoratedRange.rangeDecoration.selection,
context.slateEditor,
)
if (!Range.isRange(slateRange)) {
decoratedRange.rangeDecoration.onMoved?.({
newSelection: null,
rangeDecoration: decoratedRange.rangeDecoration,
origin: 'local',
})
continue
}
let newRange: BaseRange | null | undefined
newRange = moveRangeByOperation(slateRange, event.operation)
if (
(newRange && newRange !== slateRange) ||
(newRange === null && slateRange)
) {
const newRangeSelection = newRange
? slateRangeToSelection({
schema: context.schema,
editor: context.slateEditor,
range: newRange,
})
: null
decoratedRange.rangeDecoration.onMoved?.({
newSelection: newRangeSelection,
rangeDecoration: decoratedRange.rangeDecoration,
origin: 'local',
})
}
// If the newRange is null, it means that the range is not valid anymore and should be removed
// If it's undefined, it means that the slateRange is still valid and should be kept
if (newRange !== null) {
rangeDecorationState.push({
...(newRange || slateRange),
rangeDecoration: {
...decoratedRange.rangeDecoration,
selection: slateRangeToSelection({
schema: context.schema,
editor: context.slateEditor,
range: newRange,
}),
},
})
}
}
return rangeDecorationState
},
}),
'assign readOnly': assign({
readOnly: ({context, event}) => {
if (event.type !== 'update read only') {
return context.readOnly
}
return event.readOnly
},
}),
'increment update count': assign({
updateCount: ({context}) => {
return context.updateCount + 1
},
}),
},
actors: {
'slate operation listener': fromCallback(slateOperationCallback),
},
guards: {
'has pending range decorations': ({context}) =>
context.pendingRangeDecorations.length > 0,
'has range decorations': ({context}) => context.decoratedRanges.length > 0,
'has different decorations': ({context, event}) => {
if (event.type !== 'range decorations updated') {
return false
}
const existingRangeDecorations = context.decoratedRanges.map(
(decoratedRange) => ({
anchor: decoratedRange.rangeDecoration.selection?.anchor,
focus: decoratedRange.rangeDecoration.selection?.focus,
}),
)
const newRangeDecorations = event.rangeDecorations.map(
(rangeDecoration) => ({
anchor: rangeDecoration.selection?.anchor,
focus: rangeDecoration.selection?.focus,
}),
)
const different = !isEqual(existingRangeDecorations, newRangeDecorations)
return different
},
'not read only': ({context}) => !context.readOnly,
'should skip setup': ({context}) => context.skipSetup,
},
}).createMachine({
id: 'range decorations',
context: ({input}) => ({
readOnly: input.readOnly,
pendingRangeDecorations: input.rangeDecorations,
decoratedRanges: [],
skipSetup: input.skipSetup,
schema: input.schema,
slateEditor: input.slateEditor,
updateCount: 0,
}),
invoke: {
src: 'slate operation listener',
input: ({context}) => ({slateEditor: context.slateEditor}),
},
on: {
'update read only': {
actions: ['assign readOnly'],
},
},
initial: 'setting up',
states: {
'setting up': {
always: [
{
guard: and(['should skip setup', 'has pending range decorations']),
target: 'ready',
actions: [
'set up initial range decorations',
'increment update count',
],
},
{
guard: 'should skip setup',
target: 'ready',
},
],
on: {
'range decorations updated': {
actions: ['update pending range decorations'],
},
'ready': [
{
target: 'ready',
guard: 'has pending range decorations',
actions: [
'set up initial range decorations',
'increment update count',
],
},
{
target: 'ready',
},
],
},
},
'ready': {
initial: 'idle',
on: {
'range decorations updated': {
target: '.idle',
guard: 'has different decorations',
actions: ['update range decorations', 'increment update count'],
},
},
states: {
'idle': {
on: {
'slate operation': {
target: 'moving range decorations',
guard: and(['has range decorations', 'not read only']),
},
},
},
'moving range decorations': {
entry: ['move range decorations'],
always: {
target: 'idle',
},
},
},
},
},
})
export function createDecorate(
rangeDecorationActor: ActorRefFrom<typeof rangeDecorationsMachine>,
) {
return function decorate([node, path]: NodeEntry): Array<BaseRange> {
if (
isEqualToEmptyEditor(
rangeDecorationActor.getSnapshot().context.slateEditor.children,
rangeDecorationActor.getSnapshot().context.schema,
)
) {
return [
{
anchor: {
path: [0, 0],
offset: 0,
},
focus: {
path: [0, 0],
offset: 0,
},
placeholder: true,
} as BaseRange,
]
}
// Editor node has a path length of 0 (should never be decorated)
if (path.length === 0) {
return []
}
if (!Element.isElement(node) || node.children.length === 0) {
return []
}
const blockIndex = path.at(0)
if (blockIndex === undefined) {
return []
}
return rangeDecorationActor
.getSnapshot()
.context.decoratedRanges.filter((decoratedRange) => {
// Special case in order to only return one decoration for collapsed ranges
if (Range.isCollapsed(decoratedRange)) {
// Collapsed ranges should only be decorated if they are on a block child level (length 2)
return node.children.some(
(_, childIndex) =>
Path.equals(decoratedRange.anchor.path, [
blockIndex,
childIndex,
]) &&
Path.equals(decoratedRange.focus.path, [blockIndex, childIndex]),
)
}
return (
Range.intersection(decoratedRange, {
anchor: {path, offset: 0},
focus: {path, offset: 0},
}) || Range.includes(decoratedRange, path)
)
})
}
}