@portabletext/plugin-character-pair-decorator
Version:
Automatically match a pair of characters and decorate the text in between
240 lines (224 loc) • 5.63 kB
text/typescript
import type {BlockOffset, Editor, EditorSchema} from '@portabletext/editor'
import {useEditor} from '@portabletext/editor'
import {
defineBehavior,
effect,
execute,
forward,
} from '@portabletext/editor/behaviors'
import * as utils from '@portabletext/editor/utils'
import {useActorRef} from '@xstate/react'
import {isDeepEqual} from 'remeda'
import {
assign,
fromCallback,
setup,
type AnyEventObject,
type CallbackLogicFunction,
} from 'xstate'
import {createCharacterPairDecoratorBehavior} from './behavior.character-pair-decorator'
/**
* @beta
*/
export function CharacterPairDecoratorPlugin(config: {
decorator: ({schema}: {schema: EditorSchema}) => string | undefined
pair: {char: string; amount: number}
}) {
const editor = useEditor()
useActorRef(decoratorPairMachine, {
input: {
editor,
decorator: config.decorator,
pair: config.pair,
},
})
return null
}
type DecoratorPairEvent =
| {
type: 'decorator.add'
blockOffset: BlockOffset
}
| {
type: 'selection'
blockOffsets?: {
anchor: BlockOffset
focus: BlockOffset
}
}
| {
type: 'delete.backward'
}
const decorateListener: CallbackLogicFunction<
AnyEventObject,
DecoratorPairEvent,
{
decorator: ({schema}: {schema: EditorSchema}) => string | undefined
editor: Editor
pair: {char: string; amount: number}
}
> = ({sendBack, input}) => {
const unregister = input.editor.registerBehavior({
behavior: createCharacterPairDecoratorBehavior({
decorator: input.decorator,
pair: input.pair,
onDecorate: (offset) => {
sendBack({type: 'decorator.add', blockOffset: offset})
},
}),
})
return unregister
}
const selectionListenerCallback: CallbackLogicFunction<
AnyEventObject,
DecoratorPairEvent,
{editor: Editor}
> = ({sendBack, input}) => {
const unregister = input.editor.registerBehavior({
behavior: defineBehavior({
on: 'select',
guard: ({snapshot, event}) => {
if (!event.at) {
return {blockOffsets: undefined}
}
const anchor = utils.spanSelectionPointToBlockOffset({
context: snapshot.context,
selectionPoint: event.at.anchor,
})
const focus = utils.spanSelectionPointToBlockOffset({
context: snapshot.context,
selectionPoint: event.at.focus,
})
if (!anchor || !focus) {
return {blockOffsets: undefined}
}
return {
blockOffsets: {
anchor,
focus,
},
}
},
actions: [
({event}, {blockOffsets}) => [
{
type: 'effect',
effect: () => {
sendBack({type: 'selection', blockOffsets})
},
},
forward(event),
],
],
}),
})
return unregister
}
const deleteBackwardListenerCallback: CallbackLogicFunction<
AnyEventObject,
DecoratorPairEvent,
{editor: Editor}
> = ({sendBack, input}) => {
const unregister = input.editor.registerBehavior({
behavior: defineBehavior({
on: 'delete.backward',
actions: [
() => [
execute({
type: 'history.undo',
}),
effect(() => {
sendBack({type: 'delete.backward'})
}),
],
],
}),
})
return unregister
}
const decoratorPairMachine = setup({
types: {
context: {} as {
decorator: ({schema}: {schema: EditorSchema}) => string | undefined
editor: Editor
offsetAfterDecorator?: BlockOffset
pair: {char: string; amount: number}
},
input: {} as {
decorator: ({schema}: {schema: EditorSchema}) => string | undefined
editor: Editor
pair: {char: string; amount: number}
},
events: {} as DecoratorPairEvent,
},
actors: {
'decorate listener': fromCallback(decorateListener),
'delete.backward listener': fromCallback(deleteBackwardListenerCallback),
'selection listener': fromCallback(selectionListenerCallback),
},
}).createMachine({
id: 'decorator pair',
context: ({input}) => ({
decorator: input.decorator,
editor: input.editor,
pair: input.pair,
}),
initial: 'idle',
states: {
'idle': {
invoke: [
{
src: 'decorate listener',
input: ({context}) => ({
decorator: context.decorator,
editor: context.editor,
pair: context.pair,
}),
},
],
on: {
'decorator.add': {
target: 'decorator added',
actions: assign({
offsetAfterDecorator: ({event}) => event.blockOffset,
}),
},
},
},
'decorator added': {
exit: [
assign({
offsetAfterDecorator: undefined,
}),
],
invoke: [
{
src: 'selection listener',
input: ({context}) => ({editor: context.editor}),
},
{
src: 'delete.backward listener',
input: ({context}) => ({editor: context.editor}),
},
],
on: {
'selection': {
target: 'idle',
guard: ({context, event}) => {
const selectionChanged = !isDeepEqual(
{
anchor: context.offsetAfterDecorator,
focus: context.offsetAfterDecorator,
},
event.blockOffsets,
)
return selectionChanged
},
},
'delete.backward': {
target: 'idle',
},
},
},
},
})