@portabletext/editor
Version:
Portable Text Editor made in React
616 lines (594 loc) • 16.7 kB
text/typescript
import type {Patch} from '@portabletext/patches'
import type {PortableTextBlock} from '@sanity/types'
import type {FocusEvent} from 'react'
import {Transforms} from 'slate'
import {ReactEditor} from 'slate-react'
import {
assertEvent,
assign,
emit,
enqueueActions,
setup,
type ActorRefFrom,
} from 'xstate'
import {coreBehaviors} from '../behaviors/behavior.core'
import {performEvent} from '../behaviors/behavior.perform-event'
import type {Behavior} from '../behaviors/behavior.types.behavior'
import type {BehaviorEvent} from '../behaviors/behavior.types.event'
import type {Converter} from '../converters/converter.types'
import type {EventPosition} from '../internal-utils/event-position'
import type {NamespaceEvent} from '../type-utils'
import type {
EditorSelection,
InvalidValueResolution,
PortableTextMemberSchemaTypes,
PortableTextSlateEditor,
} from '../types/editor'
import type {EditorSchema} from './editor-schema'
import {createEditorSnapshot} from './editor-snapshot'
export * from 'xstate/guards'
/**
* @public
*/
export type PatchesEvent = {
type: 'patches'
patches: Array<Patch>
snapshot: Array<PortableTextBlock> | undefined
}
/**
* @public
*/
export type MutationEvent = {
type: 'mutation'
patches: Array<Patch>
/**
* @deprecated Use `value` instead
*/
snapshot: Array<PortableTextBlock> | undefined
value: Array<PortableTextBlock> | undefined
}
/**
* @public
*/
export type ExternalEditorEvent =
| {
type: 'add behavior'
behavior: Behavior
}
| {
type: 'remove behavior'
behavior: Behavior
}
| {
type: 'update readOnly'
readOnly: boolean
}
| {
type: 'update schema'
schema: EditorSchema
}
| {
type: 'update behaviors'
behaviors: Array<Behavior>
}
| {
type: 'update key generator'
keyGenerator: () => string
}
| {
type: 'update value'
value: Array<PortableTextBlock> | undefined
}
| {
type: 'update maxBlocks'
maxBlocks: number | undefined
}
| PatchesEvent
/**
* @public
*/
export type EditorEmittedEvent =
| {
type: 'blurred'
event: FocusEvent<HTMLDivElement, Element>
}
| {
type: 'done loading'
}
| {
type: 'editable'
}
| {
type: 'error'
name: string
description: string
data: unknown
}
| {
type: 'focused'
event: FocusEvent<HTMLDivElement, Element>
}
| {
type: 'invalid value'
resolution: InvalidValueResolution | null
value: Array<PortableTextBlock> | undefined
}
| {
type: 'loading'
}
| MutationEvent
| PatchEvent
| {
type: 'read only'
}
| {
type: 'ready'
}
| {
type: 'selection'
selection: EditorSelection
}
| {
type: 'value changed'
value: Array<PortableTextBlock> | undefined
}
type PatchEvent = {
type: 'patch'
patch: Patch
}
type InternalPatchEvent = NamespaceEvent<PatchEvent, 'internal'> & {
actionId?: string
value: Array<PortableTextBlock>
}
type UnsetEvent = {
type: 'unset'
previousValue: Array<PortableTextBlock>
}
/**
* @internal
*/
export type EditorActor = ActorRefFrom<typeof editorMachine>
export type HasTag = ReturnType<EditorActor['getSnapshot']>['hasTag']
/**
* @internal
*/
export type InternalEditorEvent =
| ExternalEditorEvent
| {
type: 'blur'
editor: PortableTextSlateEditor
}
| {
type: 'focus'
editor: PortableTextSlateEditor
}
| {
type: 'normalizing'
}
| {
type: 'done normalizing'
}
| {
type: 'done syncing initial value'
}
| {
type: 'behavior event'
behaviorEvent: BehaviorEvent
editor: PortableTextSlateEditor
nativeEvent?: {preventDefault: () => void}
}
| MutationEvent
| InternalPatchEvent
| NamespaceEvent<EditorEmittedEvent, 'notify'>
| NamespaceEvent<UnsetEvent, 'notify'>
| {
type: 'dragstart'
origin: Pick<EventPosition, 'selection'>
ghost?: HTMLElement
}
| {type: 'dragend'}
| {type: 'drop'}
/**
* @internal
*/
export type InternalEditorEmittedEvent =
| EditorEmittedEvent
| InternalPatchEvent
| PatchesEvent
| UnsetEvent
/**
* @internal
*/
export const editorMachine = setup({
types: {
context: {} as {
behaviors: Set<Behavior>
converters: Set<Converter>
getLegacySchema: () => PortableTextMemberSchemaTypes
keyGenerator: () => string
pendingEvents: Array<InternalPatchEvent | MutationEvent>
schema: EditorSchema
initialReadOnly: boolean
maxBlocks: number | undefined
selection: EditorSelection
incomingValue: Array<PortableTextBlock> | undefined
internalDrag?: {
ghost?: HTMLElement
origin: Pick<EventPosition, 'selection'>
}
slateEditor?: PortableTextSlateEditor
},
events: {} as InternalEditorEvent,
emitted: {} as InternalEditorEmittedEvent,
input: {} as {
behaviors?: Array<Behavior>
converters?: Array<Converter>
getLegacySchema: () => PortableTextMemberSchemaTypes
keyGenerator: () => string
maxBlocks?: number
readOnly?: boolean
schema: EditorSchema
initialValue?: Array<PortableTextBlock>
},
tags: {} as 'dragging internally',
},
actions: {
'add behavior to context': assign({
behaviors: ({context, event}) => {
assertEvent(event, 'add behavior')
return new Set([...context.behaviors, event.behavior])
},
}),
'remove behavior from context': assign({
behaviors: ({context, event}) => {
assertEvent(event, 'remove behavior')
context.behaviors.delete(event.behavior)
return new Set([...context.behaviors])
},
}),
'assign behaviors': assign({
behaviors: ({event}) => {
assertEvent(event, 'update behaviors')
return new Set([...event.behaviors])
},
}),
'assign schema': assign({
schema: ({event}) => {
assertEvent(event, 'update schema')
return event.schema
},
}),
'emit patch event': enqueueActions(({event, enqueue}) => {
assertEvent(event, 'internal.patch')
enqueue.emit(event)
enqueue.emit({type: 'patch', patch: event.patch})
}),
'emit mutation event': emit(({event}) => {
assertEvent(event, 'mutation')
return event
}),
'emit read only': emit({type: 'read only'}),
'emit editable': emit({type: 'editable'}),
'defer event': assign({
pendingEvents: ({context, event}) => {
assertEvent(event, ['internal.patch', 'mutation'])
return [...context.pendingEvents, event]
},
}),
'emit pending events': enqueueActions(({context, enqueue}) => {
for (const event of context.pendingEvents) {
if (event.type === 'internal.patch') {
enqueue.emit(event)
enqueue.emit({type: 'patch', patch: event.patch})
} else {
enqueue.emit(event)
}
}
}),
'emit ready': emit({type: 'ready'}),
'clear pending events': assign({
pendingEvents: [],
}),
'handle blur': ({event}) => {
assertEvent(event, 'blur')
try {
ReactEditor.blur(event.editor)
} catch (error) {
console.error(new Error(`Failed to blur editor: ${error.message}`))
}
},
'handle focus': ({context}) => {
if (!context.slateEditor) {
console.error('No Slate editor found to focus')
return
}
try {
const currentSelection = context.slateEditor.selection
ReactEditor.focus(context.slateEditor)
if (currentSelection) {
Transforms.select(context.slateEditor, currentSelection)
}
} catch (error) {
console.error(new Error(`Failed to focus editor: ${error.message}`))
}
},
'handle behavior event': ({context, event, self}) => {
assertEvent(event, ['behavior event'])
performEvent({
mode: 'raise',
behaviors: [...context.behaviors.values()],
event: event.behaviorEvent,
editor: event.editor,
keyGenerator: context.keyGenerator,
schema: context.schema,
getSnapshot: () =>
createEditorSnapshot({
converters: [...context.converters],
editor: event.editor,
keyGenerator: context.keyGenerator,
readOnly: self.getSnapshot().matches({'edit mode': 'read only'}),
schema: context.schema,
hasTag: (tag) => self.getSnapshot().hasTag(tag),
internalDrag: context.internalDrag,
}),
nativeEvent: event.nativeEvent,
})
},
},
guards: {
'slate is busy': ({context}) => {
if (!context.slateEditor) {
return false
}
return context.slateEditor.operations.length > 0
},
},
}).createMachine({
id: 'editor',
context: ({input}) => ({
behaviors: new Set([...(input.behaviors ?? coreBehaviors)]),
converters: new Set(input.converters ?? []),
getLegacySchema: input.getLegacySchema,
keyGenerator: input.keyGenerator,
pendingEvents: [],
schema: input.schema,
selection: null,
initialReadOnly: input.readOnly ?? false,
maxBlocks: input.maxBlocks,
incomingValue: input.initialValue,
}),
on: {
'notify.blurred': {
actions: emit(({event}) => ({...event, type: 'blurred'})),
},
'notify.done loading': {actions: emit({type: 'done loading'})},
'notify.error': {actions: emit(({event}) => ({...event, type: 'error'}))},
'notify.invalid value': {
actions: emit(({event}) => ({...event, type: 'invalid value'})),
},
'notify.focused': {
actions: emit(({event}) => ({...event, type: 'focused'})),
},
'notify.selection': {
actions: [
assign({selection: ({event}) => event.selection}),
emit(({event}) => ({...event, type: 'selection'})),
],
},
'notify.unset': {actions: emit(({event}) => ({...event, type: 'unset'}))},
'notify.loading': {actions: emit({type: 'loading'})},
'notify.value changed': {
actions: emit(({event}) => ({...event, type: 'value changed'})),
},
'add behavior': {actions: 'add behavior to context'},
'remove behavior': {actions: 'remove behavior from context'},
'patches': {actions: emit(({event}) => event)},
'update behaviors': {actions: 'assign behaviors'},
'update key generator': {
actions: assign({keyGenerator: ({event}) => event.keyGenerator}),
},
'update schema': {actions: 'assign schema'},
'update value': {
actions: assign({incomingValue: ({event}) => event.value}),
},
'update maxBlocks': {
actions: assign({maxBlocks: ({event}) => event.maxBlocks}),
},
},
type: 'parallel',
states: {
'edit mode': {
initial: 'read only',
states: {
'read only': {
initial: 'determine initial edit mode',
on: {
'behavior event': {
actions: 'handle behavior event',
guard: ({event}) =>
event.behaviorEvent.type === 'clipboard.copy' ||
event.behaviorEvent.type === 'mouse.click' ||
event.behaviorEvent.type === 'serialize' ||
event.behaviorEvent.type === 'serialization.failure' ||
event.behaviorEvent.type === 'serialization.success' ||
event.behaviorEvent.type === 'select',
},
},
states: {
'determine initial edit mode': {
on: {
'done syncing initial value': [
{
target: '#editor.edit mode.read only.read only',
guard: ({context}) => context.initialReadOnly,
},
{
target: '#editor.edit mode.editable',
},
],
},
},
'read only': {
on: {
'update readOnly': {
guard: ({event}) => !event.readOnly,
target: '#editor.edit mode.editable',
actions: ['emit editable'],
},
},
},
},
},
'editable': {
on: {
'update readOnly': {
guard: ({event}) => event.readOnly,
target: '#editor.edit mode.read only.read only',
actions: ['emit read only'],
},
'behavior event': {
actions: 'handle behavior event',
},
'blur': {
actions: 'handle blur',
},
'focus': {
target: '.focusing',
actions: [assign({slateEditor: ({event}) => event.editor})],
},
},
initial: 'idle',
states: {
'idle': {
on: {
dragstart: {
actions: [
assign({
internalDrag: ({event}) => ({
ghost: event.ghost,
origin: event.origin,
}),
}),
],
target: 'dragging internally',
},
},
},
'focusing': {
initial: 'checking if busy',
states: {
'checking if busy': {
always: [
{
guard: 'slate is busy',
target: 'busy',
},
{
target: '#editor.edit mode.editable.idle',
actions: ['handle focus'],
},
],
},
'busy': {
after: {
10: {
target: 'checking if busy',
},
},
},
},
},
'dragging internally': {
exit: [
({context}) => {
if (context.internalDrag?.ghost) {
try {
context.internalDrag.ghost.parentNode?.removeChild(
context.internalDrag.ghost,
)
} catch (error) {
console.error(
new Error(
`Removing the internal drag ghost failed due to: ${error.message}`,
),
)
}
}
},
assign({internalDrag: undefined}),
],
tags: ['dragging internally'],
on: {
dragend: {target: 'idle'},
drop: {target: 'idle'},
},
},
},
},
},
},
'setup': {
initial: 'setting up',
states: {
'setting up': {
exit: ['emit ready'],
on: {
'internal.patch': {
actions: 'defer event',
},
'mutation': {
actions: 'defer event',
},
'done syncing initial value': {
target: 'pristine',
},
},
},
'pristine': {
initial: 'idle',
states: {
idle: {
on: {
'normalizing': {
target: 'normalizing',
},
'internal.patch': {
actions: 'defer event',
target: '#editor.setup.dirty',
},
'mutation': {
actions: 'defer event',
target: '#editor.setup.dirty',
},
},
},
normalizing: {
on: {
'done normalizing': {
target: 'idle',
},
'internal.patch': {
actions: 'defer event',
},
'mutation': {
actions: 'defer event',
},
},
},
},
},
'dirty': {
entry: ['emit pending events', 'clear pending events'],
on: {
'internal.patch': {
actions: 'emit patch event',
},
'mutation': {
actions: 'emit mutation event',
},
},
},
},
},
},
})