UNPKG

@portabletext/editor

Version:

Portable Text Editor made in React

520 lines (519 loc) 16.9 kB
import { c } from "react-compiler-runtime"; import React, { useEffect } from "react"; import { useEditor } from "../_chunks-es/editor-provider.js"; import { jsx, jsxs, Fragment } from "react/jsx-runtime"; import { coreBehaviors, defineBehavior, execute, effect, raise } from "../_chunks-es/behavior.core.js"; import { useActorRef } from "@xstate/react"; import isEqual from "lodash/isEqual.js"; import { setup, fromCallback, assign } from "xstate"; import { getFocusTextBlock, getSelectionStartPoint, getPreviousInlineObject, isSelectionExpanded } from "../_chunks-es/selector.is-overlapping-selection.js"; import { spanSelectionPointToBlockOffset } from "../_chunks-es/util.slice-blocks.js"; import { blockOffsetsToSelection, childSelectionPointToBlockOffset } from "../_chunks-es/util.selection-point-to-block-offset.js"; import { getBlockTextBefore } from "../_chunks-es/selector.get-text-before.js"; import { useEffectEvent } from "use-effect-event"; import { createMarkdownBehaviors } from "../_chunks-es/behavior.markdown.js"; import { isTextBlock, mergeTextBlocks } from "../_chunks-es/util.merge-text-blocks.js"; function BehaviorPlugin(props) { const $ = c(4), editor = useEditor(); let t0, t1; return $[0] !== editor || $[1] !== props.behaviors ? (t0 = () => { const unregisterBehaviors = props.behaviors.map((behavior) => editor.registerBehavior({ behavior })); return () => { unregisterBehaviors.forEach(_temp); }; }, t1 = [editor, props.behaviors], $[0] = editor, $[1] = props.behaviors, $[2] = t0, $[3] = t1) : (t0 = $[2], t1 = $[3]), useEffect(t0, t1), null; } function _temp(unregister) { return unregister(); } function CoreBehaviorsPlugin() { const $ = c(1); let t0; return $[0] === Symbol.for("react.memo_cache_sentinel") ? (t0 = /* @__PURE__ */ jsx(BehaviorPlugin, { behaviors: coreBehaviors }), $[0] = t0) : t0 = $[0], t0; } function createPairRegex(char, amount) { const prePrefix = `(?<!\\${char})`, prefix = `\\${char}`.repeat(Math.max(amount, 1)), postPrefix = "(?!\\s)", content = `([^${char}\\n]+?)`, preSuffix = "(?<!\\s)", suffix = `\\${char}`.repeat(Math.max(amount, 1)), postSuffix = `(?!\\${char})`; return `${prePrefix}${prefix}${postPrefix}${content}${preSuffix}${suffix}${postSuffix}`; } function createDecoratorPairBehavior(config) { config.pair.amount < 1 && console.warn("The amount of characters in the pair should be greater than 0"); const pairRegex = createPairRegex(config.pair.char, config.pair.amount), regEx = new RegExp(`(${pairRegex})$`); return defineBehavior({ on: "insert.text", guard: ({ snapshot, event }) => { if (config.pair.amount < 1) return !1; const decorator = config.decorator({ schema: snapshot.context.schema }); if (decorator === void 0) return !1; const focusTextBlock = getFocusTextBlock(snapshot), selectionStartPoint = getSelectionStartPoint(snapshot), selectionStartOffset = selectionStartPoint ? spanSelectionPointToBlockOffset({ value: snapshot.context.value, selectionPoint: selectionStartPoint }) : void 0; if (!focusTextBlock || !selectionStartOffset) return !1; const newText = `${getBlockTextBefore(snapshot)}${event.text}`, textToDecorate = newText.match(regEx)?.at(0); if (textToDecorate === void 0) return !1; const prefixOffsets = { anchor: { path: focusTextBlock.path, // Example: "foo **bar**".length - "**bar**".length = 4 offset: newText.length - textToDecorate.length }, focus: { path: focusTextBlock.path, // Example: "foo **bar**".length - "**bar**".length + "*".length * 2 = 6 offset: newText.length - textToDecorate.length + config.pair.char.length * config.pair.amount } }, suffixOffsets = { anchor: { path: focusTextBlock.path, // Example: "foo **bar*|" (10) + "*".length - 2 = 9 offset: selectionStartOffset.offset + event.text.length - config.pair.char.length * config.pair.amount }, focus: { path: focusTextBlock.path, // Example: "foo **bar*|" (10) + "*".length = 11 offset: selectionStartOffset.offset + event.text.length } }; if (prefixOffsets.focus.offset - prefixOffsets.anchor.offset > 1) { const prefixSelection = blockOffsetsToSelection({ value: snapshot.context.value, offsets: prefixOffsets }), inlineObjectBeforePrefixFocus = getPreviousInlineObject({ context: { ...snapshot.context, selection: prefixSelection ? { anchor: prefixSelection.focus, focus: prefixSelection.focus } : null } }), inlineObjectBeforePrefixFocusOffset = inlineObjectBeforePrefixFocus ? childSelectionPointToBlockOffset({ value: snapshot.context.value, selectionPoint: { path: inlineObjectBeforePrefixFocus.path, offset: 0 } }) : void 0; if (inlineObjectBeforePrefixFocusOffset && inlineObjectBeforePrefixFocusOffset.offset > prefixOffsets.anchor.offset && inlineObjectBeforePrefixFocusOffset.offset < prefixOffsets.focus.offset) return !1; } if (suffixOffsets.focus.offset - suffixOffsets.anchor.offset > 1) { const previousInlineObject = getPreviousInlineObject(snapshot), previousInlineObjectOffset = previousInlineObject ? childSelectionPointToBlockOffset({ value: snapshot.context.value, selectionPoint: { path: previousInlineObject.path, offset: 0 } }) : void 0; if (previousInlineObjectOffset && previousInlineObjectOffset.offset > suffixOffsets.anchor.offset && previousInlineObjectOffset.offset < suffixOffsets.focus.offset) return !1; } return { prefixOffsets, suffixOffsets, decorator }; }, actions: [ // Insert the text as usual in its own undo step ({ event }) => [execute(event)], (_, { prefixOffsets, suffixOffsets, decorator }) => [ // Decorate the text between the prefix and suffix execute({ type: "decorator.add", decorator, at: { anchor: prefixOffsets.focus, focus: suffixOffsets.anchor } }), // Delete the suffix execute({ type: "delete.text", at: suffixOffsets }), // Delete the prefix execute({ type: "delete.text", at: prefixOffsets }), // Toggle the decorator off so the next inserted text isn't emphasized execute({ type: "decorator.remove", decorator }), effect(() => { config.onDecorate({ ...suffixOffsets.anchor, offset: suffixOffsets.anchor.offset - (prefixOffsets.focus.offset - prefixOffsets.anchor.offset) }); }) ] ] }); } function DecoratorShortcutPlugin(config) { const $ = c(4), editor = useEditor(); let t0; return $[0] !== config.decorator || $[1] !== config.pair || $[2] !== editor ? (t0 = { input: { editor, decorator: config.decorator, pair: config.pair } }, $[0] = config.decorator, $[1] = config.pair, $[2] = editor, $[3] = t0) : t0 = $[3], useActorRef(decoratorPairMachine, t0), null; } const emphasisListener = ({ sendBack, input }) => input.editor.registerBehavior({ behavior: createDecoratorPairBehavior({ decorator: input.decorator, pair: input.pair, onDecorate: (offset) => { sendBack({ type: "emphasis.add", blockOffset: offset }); } }) }), selectionListenerCallback = ({ sendBack, input }) => input.editor.registerBehavior({ behavior: defineBehavior({ on: "select", guard: ({ snapshot, event }) => { if (!event.at) return { blockOffsets: void 0 }; const anchor = spanSelectionPointToBlockOffset({ value: snapshot.context.value, selectionPoint: event.at.anchor }), focus = spanSelectionPointToBlockOffset({ value: snapshot.context.value, selectionPoint: event.at.focus }); return !anchor || !focus ? { blockOffsets: void 0 } : { blockOffsets: { anchor, focus } }; }, actions: [(_, { blockOffsets }) => [{ type: "effect", effect: () => { sendBack({ type: "selection", blockOffsets }); } }]] }) }), deleteBackwardListenerCallback = ({ sendBack, input }) => input.editor.registerBehavior({ behavior: defineBehavior({ on: "delete.backward", actions: [() => [execute({ type: "history.undo" }), effect(() => { sendBack({ type: "delete.backward" }); })]] }) }), decoratorPairMachine = setup({ types: { context: {}, input: {}, events: {} }, actors: { "emphasis listener": fromCallback(emphasisListener), "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: "emphasis listener", input: ({ context }) => ({ decorator: context.decorator, editor: context.editor, pair: context.pair }) }], on: { "emphasis.add": { target: "emphasis added", actions: assign({ offsetAfterEmphasis: ({ event }) => event.blockOffset }) } } }, "emphasis added": { exit: [assign({ offsetAfterEmphasis: void 0 })], 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 }) => !isEqual({ anchor: context.offsetAfterEmphasis, focus: context.offsetAfterEmphasis }, event.blockOffsets) }, "delete.backward": { target: "idle" } } } } }), EditorRefPlugin = React.forwardRef((_, ref) => { const $ = c(2), editor = useEditor(), portableTextEditorRef = React.useRef(editor); let t0, t1; return $[0] === Symbol.for("react.memo_cache_sentinel") ? (t0 = () => portableTextEditorRef.current, t1 = [], $[0] = t0, $[1] = t1) : (t0 = $[0], t1 = $[1]), React.useImperativeHandle(ref, t0, t1), null; }); EditorRefPlugin.displayName = "EditorRefPlugin"; function EventListenerPlugin(props) { const $ = c(5), editor = useEditor(), on = useEffectEvent(props.on); let t0; $[0] !== editor || $[1] !== on ? (t0 = () => { const subscription = editor.on("*", on); return () => { subscription.unsubscribe(); }; }, $[0] = editor, $[1] = on, $[2] = t0) : t0 = $[2]; let t1; return $[3] !== editor ? (t1 = [editor], $[3] = editor, $[4] = t1) : t1 = $[4], useEffect(t0, t1), null; } function MarkdownPlugin(props) { const $ = c(17), editor = useEditor(); let t0, t1; $[0] !== editor || $[1] !== props.config ? (t0 = () => { const unregisterBehaviors = createMarkdownBehaviors(props.config).map((behavior) => editor.registerBehavior({ behavior })); return () => { for (const unregisterBehavior of unregisterBehaviors) unregisterBehavior(); }; }, t1 = [editor, props.config], $[0] = editor, $[1] = props.config, $[2] = t0, $[3] = t1) : (t0 = $[2], t1 = $[3]), useEffect(t0, t1); let t2; $[4] !== props.config.boldDecorator ? (t2 = props.config.boldDecorator ? /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx(DecoratorShortcutPlugin, { decorator: props.config.boldDecorator, pair: { char: "*", amount: 2 } }), /* @__PURE__ */ jsx(DecoratorShortcutPlugin, { decorator: props.config.boldDecorator, pair: { char: "_", amount: 2 } }) ] }) : null, $[4] = props.config.boldDecorator, $[5] = t2) : t2 = $[5]; let t3; $[6] !== props.config.codeDecorator ? (t3 = props.config.codeDecorator ? /* @__PURE__ */ jsx(DecoratorShortcutPlugin, { decorator: props.config.codeDecorator, pair: { char: "`", amount: 1 } }) : null, $[6] = props.config.codeDecorator, $[7] = t3) : t3 = $[7]; let t4; $[8] !== props.config.italicDecorator ? (t4 = props.config.italicDecorator ? /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx(DecoratorShortcutPlugin, { decorator: props.config.italicDecorator, pair: { char: "*", amount: 1 } }), /* @__PURE__ */ jsx(DecoratorShortcutPlugin, { decorator: props.config.italicDecorator, pair: { char: "_", amount: 1 } }) ] }) : null, $[8] = props.config.italicDecorator, $[9] = t4) : t4 = $[9]; let t5; $[10] !== props.config.strikeThroughDecorator ? (t5 = props.config.strikeThroughDecorator ? /* @__PURE__ */ jsx(DecoratorShortcutPlugin, { decorator: props.config.strikeThroughDecorator, pair: { char: "~", amount: 2 } }) : null, $[10] = props.config.strikeThroughDecorator, $[11] = t5) : t5 = $[11]; let t6; return $[12] !== t2 || $[13] !== t3 || $[14] !== t4 || $[15] !== t5 ? (t6 = /* @__PURE__ */ jsxs(Fragment, { children: [ t2, t3, t4, t5 ] }), $[12] = t2, $[13] = t3, $[14] = t4, $[15] = t5, $[16] = t6) : t6 = $[16], t6; } const oneLineBehaviors = [ /** * Hitting Enter on an expanded selection should just delete that selection * without causing a line break. */ defineBehavior({ on: "insert.break", guard: ({ snapshot }) => snapshot.context.selection && isSelectionExpanded(snapshot) ? { selection: snapshot.context.selection } : !1, actions: [(_, { selection }) => [execute({ type: "delete", at: selection })]] }), /** * All other cases of `insert.break` should be aborted. */ defineBehavior({ on: "insert.break", actions: [() => [{ type: "noop" }]] }), /** * `split.block`s as well. */ defineBehavior({ on: "split.block", actions: [() => [{ type: "noop" }]] }), /** * `insert.block` `before` or `after` is not allowed in a one-line editor. */ defineBehavior({ on: "insert.block", guard: ({ event }) => event.placement === "before" || event.placement === "after", actions: [() => [{ type: "noop" }]] }), /** * An ordinary `insert.block` is acceptable if it's a text block. In that * case it will get merged into the existing text block. */ defineBehavior({ on: "insert.block", guard: ({ snapshot, event }) => !(!getFocusTextBlock(snapshot) || !isTextBlock(snapshot.context, event.block)), actions: [({ event }) => [execute({ type: "insert.block", block: event.block, placement: "auto", select: "end" })]] }), /** * Fallback Behavior to avoid `insert.block` in case the Behaviors above all * end up with a falsy guard. */ defineBehavior({ on: "insert.block", actions: [() => [{ type: "noop" }]] }), /** * If multiple blocks are inserted, then the non-text blocks are filtered out * and the text blocks are merged into one block */ defineBehavior({ on: "insert.blocks", guard: ({ snapshot, event }) => event.blocks.filter((block) => isTextBlock(snapshot.context, block)).reduce((targetBlock, incomingBlock) => mergeTextBlocks({ context: snapshot.context, targetBlock, incomingBlock })), actions: [ // `insert.block` is raised so the Behavior above can handle the // insertion (_, block) => [raise({ type: "insert.block", block, placement: "auto" })] ] }) ]; function OneLinePlugin() { const $ = c(1); let t0; return $[0] === Symbol.for("react.memo_cache_sentinel") ? (t0 = /* @__PURE__ */ jsx(BehaviorPlugin, { behaviors: oneLineBehaviors }), $[0] = t0) : t0 = $[0], t0; } export { BehaviorPlugin, CoreBehaviorsPlugin, DecoratorShortcutPlugin, EditorRefPlugin, EventListenerPlugin, MarkdownPlugin, OneLinePlugin }; //# sourceMappingURL=index.js.map