UNPKG

@portabletext/editor

Version:

Portable Text Editor made in React

837 lines (836 loc) 27.1 kB
import { c } from "react-compiler-runtime"; import React, { useEffect } from "react"; import { useEditor } from "../_chunks-es/use-editor.js"; import { useActorRef } from "@xstate/react"; import isEqual from "lodash/isEqual.js"; import { setup, fromCallback, assign } from "xstate"; import { spanSelectionPointToBlockOffset, getTextBlockText, isTextBlock } from "../_chunks-es/selection-point.js"; import { blockOffsetsToSelection, childSelectionPointToBlockOffset } from "../_chunks-es/util.child-selection-point-to-block-offset.js"; import { getFocusTextBlock, getSelectionStartPoint, getPreviousInlineObject, isSelectionCollapsed, getFocusSpan, getFocusBlock, isSelectionExpanded } from "../_chunks-es/selector.is-selection-expanded.js"; import { getBlockTextBefore } from "../_chunks-es/selector.get-text-before.js"; import { defineBehavior, execute, effect, forward, raise } from "../behaviors/index.js"; import { useEffectEvent } from "use-effect-event"; import { jsxs, Fragment, jsx } from "react/jsx-runtime"; import { isTextBlock as isTextBlock$1, 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 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({ context: { schema: snapshot.context.schema, 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({ context: snapshot.context, offsets: prefixOffsets }), inlineObjectBeforePrefixFocus = getPreviousInlineObject({ context: { ...snapshot.context, selection: prefixSelection ? { anchor: prefixSelection.focus, focus: prefixSelection.focus } : null } }), inlineObjectBeforePrefixFocusOffset = inlineObjectBeforePrefixFocus ? childSelectionPointToBlockOffset({ context: { schema: snapshot.context.schema, 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({ context: { schema: snapshot.context.schema, 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({ context: snapshot.context, selectionPoint: event.at.anchor }), focus = spanSelectionPointToBlockOffset({ context: snapshot.context, selectionPoint: event.at.focus }); return !anchor || !focus ? { blockOffsets: void 0 } : { blockOffsets: { anchor, focus } }; }, actions: [({ event }, { blockOffsets }) => [{ type: "effect", effect: () => { sendBack({ type: "selection", blockOffsets }); } }, forward(event)]] }) }), 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 createMarkdownBehaviors(config) { const automaticBlockquoteOnSpace = defineBehavior({ on: "insert.text", guard: ({ snapshot, event }) => { if (event.text !== " ") return !1; const selectionCollapsed = isSelectionCollapsed(snapshot), focusTextBlock = getFocusTextBlock(snapshot), focusSpan = getFocusSpan(snapshot); if (!selectionCollapsed || !focusTextBlock || !focusSpan) return !1; const previousInlineObject = getPreviousInlineObject(snapshot), blockOffset = spanSelectionPointToBlockOffset({ context: snapshot.context, selectionPoint: { path: [{ _key: focusTextBlock.node._key }, "children", { _key: focusSpan.node._key }], offset: snapshot.context.selection?.focus.offset ?? 0 } }); if (previousInlineObject || !blockOffset) return !1; const blockText = getTextBlockText(focusTextBlock.node), caretAtTheEndOfQuote = blockOffset.offset === 1, looksLikeMarkdownQuote = /^>/.test(blockText), blockquoteStyle = config.blockquoteStyle?.(snapshot.context); return caretAtTheEndOfQuote && looksLikeMarkdownQuote && blockquoteStyle !== void 0 ? { focusTextBlock, style: blockquoteStyle } : !1; }, actions: [() => [execute({ type: "insert.text", text: " " })], (_, { focusTextBlock, style }) => [execute({ type: "block.unset", props: ["listItem", "level"], at: focusTextBlock.path }), execute({ type: "block.set", props: { style }, at: focusTextBlock.path }), execute({ type: "delete.text", at: { anchor: { path: focusTextBlock.path, offset: 0 }, focus: { path: focusTextBlock.path, offset: 2 } } })]] }), automaticHr = defineBehavior({ on: "insert.text", guard: ({ snapshot, event }) => { const hrCharacter = event.text === "-" ? "-" : event.text === "*" ? "*" : event.text === "_" ? "_" : void 0; if (hrCharacter === void 0) return !1; const hrObject = config.horizontalRuleObject?.(snapshot.context), focusBlock = getFocusTextBlock(snapshot), selectionCollapsed = isSelectionCollapsed(snapshot); if (!hrObject || !focusBlock || !selectionCollapsed) return !1; const previousInlineObject = getPreviousInlineObject(snapshot), textBefore = getBlockTextBefore(snapshot), hrBlockOffsets = { anchor: { path: focusBlock.path, offset: 0 }, focus: { path: focusBlock.path, offset: 3 } }; return !previousInlineObject && textBefore === `${hrCharacter}${hrCharacter}` ? { hrObject, focusBlock, hrCharacter, hrBlockOffsets } : !1; }, actions: [(_, { hrCharacter }) => [execute({ type: "insert.text", text: hrCharacter })], (_, { hrObject, hrBlockOffsets }) => [execute({ type: "insert.block", placement: "before", block: { _type: hrObject.name, ...hrObject.value ?? {} } }), execute({ type: "delete.text", at: hrBlockOffsets })]] }), automaticHrOnPaste = defineBehavior({ on: "clipboard.paste", guard: ({ snapshot, event }) => { const text = event.originEvent.dataTransfer.getData("text/plain"), hrRegExp = /^(---)$|(___)$|(\*\*\*)$/, hrCharacters = text.match(hrRegExp)?.[0], hrObject = config.horizontalRuleObject?.(snapshot.context), focusBlock = getFocusBlock(snapshot); return !hrCharacters || !hrObject || !focusBlock ? !1 : { hrCharacters, hrObject, focusBlock }; }, actions: [(_, { hrCharacters }) => [execute({ type: "insert.text", text: hrCharacters })], ({ snapshot }, { hrObject, focusBlock }) => isTextBlock(snapshot.context, focusBlock.node) ? [execute({ type: "insert.block", block: { _type: snapshot.context.schema.block.name, children: focusBlock.node.children }, placement: "after" }), execute({ type: "insert.block", block: { _type: hrObject.name, ...hrObject.value ?? {} }, placement: "after" }), execute({ type: "delete.block", at: focusBlock.path })] : [execute({ type: "insert.block", block: { _type: hrObject.name, ...hrObject.value ?? {} }, placement: "after" })]] }), automaticHeadingOnSpace = defineBehavior({ on: "insert.text", guard: ({ snapshot, event }) => { if (event.text !== " ") return !1; const selectionCollapsed = isSelectionCollapsed(snapshot), focusTextBlock = getFocusTextBlock(snapshot), focusSpan = getFocusSpan(snapshot); if (!selectionCollapsed || !focusTextBlock || !focusSpan) return !1; const blockOffset = spanSelectionPointToBlockOffset({ context: snapshot.context, selectionPoint: { path: [{ _key: focusTextBlock.node._key }, "children", { _key: focusSpan.node._key }], offset: snapshot.context.selection?.focus.offset ?? 0 } }); if (!blockOffset) return !1; const previousInlineObject = getPreviousInlineObject(snapshot), blockText = getTextBlockText(focusTextBlock.node), markdownHeadingSearch = /^#+/.exec(blockText), level = markdownHeadingSearch ? markdownHeadingSearch[0].length : void 0, caretAtTheEndOfHeading = blockOffset.offset === level; if (previousInlineObject || !caretAtTheEndOfHeading) return !1; const style = level !== void 0 ? config.headingStyle?.({ schema: snapshot.context.schema, level }) : void 0; return level !== void 0 && style !== void 0 ? { focusTextBlock, style, level } : !1; }, actions: [({ event }) => [execute(event)], (_, { focusTextBlock, style, level }) => [execute({ type: "block.unset", props: ["listItem", "level"], at: focusTextBlock.path }), execute({ type: "block.set", props: { style }, at: focusTextBlock.path }), execute({ type: "delete.text", at: { anchor: { path: focusTextBlock.path, offset: 0 }, focus: { path: focusTextBlock.path, offset: level + 1 } } })]] }), clearStyleOnBackspace = defineBehavior({ on: "delete.backward", guard: ({ snapshot }) => { const selectionCollapsed = isSelectionCollapsed(snapshot), focusTextBlock = getFocusTextBlock(snapshot), focusSpan = getFocusSpan(snapshot); if (!selectionCollapsed || !focusTextBlock || !focusSpan) return !1; const atTheBeginningOfBLock = focusTextBlock.node.children[0]._key === focusSpan.node._key && snapshot.context.selection?.focus.offset === 0, defaultStyle = config.defaultStyle?.(snapshot.context); return atTheBeginningOfBLock && defaultStyle && focusTextBlock.node.style !== defaultStyle ? { defaultStyle, focusTextBlock } : !1; }, actions: [(_, { defaultStyle, focusTextBlock }) => [execute({ type: "block.set", props: { style: defaultStyle }, at: focusTextBlock.path })]] }), automaticListOnSpace = defineBehavior({ on: "insert.text", guard: ({ snapshot, event }) => { if (event.text !== " ") return !1; const selectionCollapsed = isSelectionCollapsed(snapshot), focusTextBlock = getFocusTextBlock(snapshot), focusSpan = getFocusSpan(snapshot); if (!selectionCollapsed || !focusTextBlock || !focusSpan) return !1; const previousInlineObject = getPreviousInlineObject(snapshot), blockOffset = spanSelectionPointToBlockOffset({ context: snapshot.context, selectionPoint: { path: [{ _key: focusTextBlock.node._key }, "children", { _key: focusSpan.node._key }], offset: snapshot.context.selection?.focus.offset ?? 0 } }); if (previousInlineObject || !blockOffset) return !1; const blockText = getTextBlockText(focusTextBlock.node), defaultStyle = config.defaultStyle?.(snapshot.context), looksLikeUnorderedList = /^(-|\*)/.test(blockText), unorderedListStyle = config.unorderedListStyle?.(snapshot.context), caretAtTheEndOfUnorderedList = blockOffset.offset === 1; if (defaultStyle && caretAtTheEndOfUnorderedList && looksLikeUnorderedList && unorderedListStyle !== void 0) return { focusTextBlock, listItem: unorderedListStyle, listItemLength: 1, style: defaultStyle }; const looksLikeOrderedList = /^1\./.test(blockText), orderedListStyle = config.orderedListStyle?.(snapshot.context), caretAtTheEndOfOrderedList = blockOffset.offset === 2; return defaultStyle && caretAtTheEndOfOrderedList && looksLikeOrderedList && orderedListStyle !== void 0 ? { focusTextBlock, listItem: orderedListStyle, listItemLength: 2, style: defaultStyle } : !1; }, actions: [({ event }) => [execute(event)], (_, { focusTextBlock, style, listItem, listItemLength }) => [execute({ type: "block.set", props: { listItem, level: 1, style }, at: focusTextBlock.path }), execute({ type: "delete.text", at: { anchor: { path: focusTextBlock.path, offset: 0 }, focus: { path: focusTextBlock.path, offset: listItemLength + 1 } } })]] }); return [automaticBlockquoteOnSpace, automaticHeadingOnSpace, automaticHr, automaticHrOnPaste, clearStyleOnBackspace, automaticListOnSpace]; } 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: [] }), /** * `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: [] }), /** * 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$1(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: [] }), /** * 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 }) => { const textBlocks = event.blocks.filter((block) => isTextBlock$1(snapshot.context, block)); return textBlocks.length === 0 ? !1 : textBlocks.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" })] ] }), /** * Fallback Behavior to avoid `insert.blocks` in case the Behavior above * ends up with a falsy guard. */ defineBehavior({ on: "insert.blocks", actions: [] }) ]; 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, DecoratorShortcutPlugin, EditorRefPlugin, EventListenerPlugin, MarkdownPlugin, OneLinePlugin }; //# sourceMappingURL=index.js.map