UNPKG

@portabletext/editor

Version:

Portable Text Editor made in React

391 lines (390 loc) 11.9 kB
import { defineBehavior, raise, isHotkey, execute, effect, noop } from "../_chunks-es/behavior.core.js"; import { coreBehaviors } from "../_chunks-es/behavior.core.js"; import { getFirstBlock, getSelectedBlocks, getLastBlock, getFocusTextBlock, isSelectionCollapsed, getFocusSpan } from "../_chunks-es/selector.is-overlapping-selection.js"; import { createActor, setup, assign, assertEvent } from "xstate"; import { getBlockTextBefore } from "../_chunks-es/selector.get-text-before.js"; import { createMarkdownBehaviors } from "../_chunks-es/behavior.markdown.js"; function createCodeEditorBehaviors(config) { return [defineBehavior({ on: "keyboard.keydown", guard: ({ snapshot, event }) => { const isMoveUpShortcut = isHotkey(config.moveBlockUpShortcut, event.originEvent), firstBlock = getFirstBlock(snapshot), selectedBlocks = getSelectedBlocks(snapshot), blocksAbove = firstBlock?.node._key !== selectedBlocks[0]?.node._key; return !isMoveUpShortcut || !blocksAbove ? !1 : { paths: selectedBlocks.map((block) => block.path) }; }, actions: [(_, { paths }) => paths.map((at) => raise({ type: "move.block up", at }))] }), defineBehavior({ on: "keyboard.keydown", guard: ({ snapshot, event }) => { const isMoveDownShortcut = isHotkey(config.moveBlockDownShortcut, event.originEvent), lastBlock = getLastBlock(snapshot), selectedBlocks = getSelectedBlocks(snapshot), blocksBelow = lastBlock?.node._key !== selectedBlocks[selectedBlocks.length - 1]?.node._key; return !isMoveDownShortcut || !blocksBelow ? !1 : { paths: selectedBlocks.map((block) => block.path).reverse() }; }, actions: [(_, { paths }) => paths.map((at) => raise({ type: "move.block down", at }))] })]; } const emojiCharRegEx = /^[a-zA-Z-_0-9]{1}$/, incompleteEmojiRegEx = /:([a-zA-Z-_0-9]+)$/, emojiRegEx = /:([a-zA-Z-_0-9]+):$/; function createEmojiPickerBehaviors(config) { const emojiPickerActor = createActor(createEmojiPickerMachine()); return emojiPickerActor.start(), emojiPickerActor.subscribe((state) => { config.onMatchesChanged({ matches: state.context.matches }), config.onSelectedIndexChanged({ selectedIndex: state.context.selectedIndex }); }), [defineBehavior({ on: "insert.text", guard: ({ snapshot, event }) => { if (event.text === ":") return !1; if (!emojiCharRegEx.test(event.text)) return { emojis: [] }; const focusBlock = getFocusTextBlock(snapshot), emojiKeyword = `${getBlockTextBefore(snapshot)}${event.text}`.match(incompleteEmojiRegEx)?.[1]; return !focusBlock || emojiKeyword === void 0 ? { emojis: [] } : { emojis: config.matchEmojis({ keyword: emojiKeyword }) }; }, actions: [(_, params) => [{ type: "effect", effect: () => { emojiPickerActor.send({ type: "emojis found", matches: params.emojis }); } }]] }), defineBehavior({ on: "insert.text", guard: ({ snapshot, event }) => { if (event.text !== ":") return !1; const matches = emojiPickerActor.getSnapshot().context.matches, selectedIndex = emojiPickerActor.getSnapshot().context.selectedIndex, emoji = matches[selectedIndex] ? config.parseMatch({ match: matches[selectedIndex] }) : void 0, focusBlock = getFocusTextBlock(snapshot), textBefore = getBlockTextBefore(snapshot), emojiKeyword = `${textBefore}:`.match(emojiRegEx)?.[1]; if (!focusBlock || emojiKeyword === void 0) return !1; const emojiStringLength = emojiKeyword.length + 2; return emoji ? { focusBlock, emoji, emojiStringLength, textBeforeLength: textBefore.length + 1 } : !1; }, actions: [() => [execute({ type: "insert.text", text: ":" })], (_, params) => [effect(() => { emojiPickerActor.send({ type: "select" }); }), execute({ type: "delete.text", at: { anchor: { path: params.focusBlock.path, offset: params.textBeforeLength - params.emojiStringLength }, focus: { path: params.focusBlock.path, offset: params.textBeforeLength } } }), execute({ type: "insert.text", text: params.emoji })]] }), defineBehavior({ on: "keyboard.keydown", guard: ({ snapshot, event }) => { const matches = emojiPickerActor.getSnapshot().context.matches; if (matches.length === 0) return !1; if (isHotkey("Escape", event.originEvent)) return { action: "reset" }; const isEnter = isHotkey("Enter", event.originEvent), isTab = isHotkey("Tab", event.originEvent); if (isEnter || isTab) { const selectedIndex = emojiPickerActor.getSnapshot().context.selectedIndex, emoji = matches[selectedIndex] ? config.parseMatch({ match: matches[selectedIndex] }) : void 0; if (!emoji) return !1; const focusBlock = getFocusTextBlock(snapshot), textBefore = getBlockTextBefore(snapshot), emojiKeyword = textBefore.match(incompleteEmojiRegEx)?.[1]; if (!focusBlock || emojiKeyword === void 0) return !1; const emojiStringLength = emojiKeyword.length + 1; return emoji ? { action: "select", focusBlock, emoji, emojiStringLength, textBeforeLength: textBefore.length } : !1; } const isArrowDown = isHotkey("ArrowDown", event.originEvent), isArrowUp = isHotkey("ArrowUp", event.originEvent); return isArrowDown && matches.length > 0 ? { action: "navigate down" } : isArrowUp && matches.length > 0 ? { action: "navigate up" } : !1; }, actions: [(_, params) => params.action === "select" ? [effect(() => { emojiPickerActor.send({ type: "select" }); }), execute({ type: "delete.text", at: { anchor: { path: params.focusBlock.path, offset: params.textBeforeLength - params.emojiStringLength }, focus: { path: params.focusBlock.path, offset: params.textBeforeLength } } }), execute({ type: "insert.text", text: params.emoji })] : params.action === "navigate up" ? [ // If we are navigating then we want to hijack the key event and // turn it into a noop. noop(), effect(() => { emojiPickerActor.send({ type: "navigate up" }); }) ] : params.action === "navigate down" ? [ // If we are navigating then we want to hijack the key event and // turn it into a noop. noop(), effect(() => { emojiPickerActor.send({ type: "navigate down" }); }) ] : [effect(() => { emojiPickerActor.send({ type: "reset" }); })]] }), defineBehavior({ on: "delete.backward", guard: ({ snapshot, event }) => { if (event.unit !== "character" || emojiPickerActor.getSnapshot().context.matches.length === 0) return !1; const focusBlock = getFocusTextBlock(snapshot), textBefore = getBlockTextBefore(snapshot), emojiKeyword = textBefore.slice(0, textBefore.length - 1).match(incompleteEmojiRegEx)?.[1]; return !focusBlock || emojiKeyword === void 0 ? { emojis: [] } : { emojis: config.matchEmojis({ keyword: emojiKeyword }) }; }, actions: [(_, params) => [{ type: "effect", effect: () => { emojiPickerActor.send({ type: "emojis found", matches: params.emojis }); } }]] })]; } function createEmojiPickerMachine() { return setup({ types: { context: {}, events: {} }, actions: { "assign matches": assign({ matches: ({ event }) => (assertEvent(event, "emojis found"), event.matches) }), "reset matches": assign({ matches: [] }), "reset selected index": assign({ selectedIndex: 0 }), "increment selected index": assign({ selectedIndex: ({ context }) => context.selectedIndex === context.matches.length - 1 ? 0 : context.selectedIndex + 1 }), "decrement selected index": assign({ selectedIndex: ({ context }) => context.selectedIndex === 0 ? context.matches.length - 1 : context.selectedIndex - 1 }) }, guards: { "no matches": ({ context }) => context.matches.length === 0 } }).createMachine({ id: "emoji picker", context: { matches: [], selectedIndex: 0 }, initial: "idle", states: { idle: { on: { "emojis found": { actions: "assign matches", target: "showing matches" } } }, "showing matches": { always: { guard: "no matches", target: "idle" }, exit: ["reset selected index"], on: { "emojis found": { actions: "assign matches" }, "navigate down": { actions: "increment selected index" }, "navigate up": { actions: "decrement selected index" }, reset: { target: "idle", actions: ["reset selected index", "reset matches"] }, select: { target: "idle", actions: ["reset selected index", "reset matches"] } } } } }); } function looksLikeUrl(text) { let looksLikeUrl2 = !1; try { const url = new URL(text); if (!sensibleProtocols.includes(url.protocol)) return !1; looksLikeUrl2 = !0; } catch { } return looksLikeUrl2; } const sensibleProtocols = ["http:", "https:", "mailto:", "tel:"]; function createLinkBehaviors(config) { const pasteLinkOnSelection = defineBehavior({ on: "clipboard.paste", guard: ({ snapshot, event }) => { const selectionCollapsed = isSelectionCollapsed(snapshot), text = event.originEvent.dataTransfer.getData("text/plain"), url = looksLikeUrl(text) ? text : void 0, annotation = url !== void 0 ? config.linkAnnotation?.({ url, schema: snapshot.context.schema }) : void 0; return annotation && !selectionCollapsed ? { annotation } : !1; }, actions: [(_, { annotation }) => [execute({ type: "annotation.add", annotation })]] }), pasteLinkAtCaret = defineBehavior({ on: "clipboard.paste", guard: ({ snapshot, event }) => { const focusSpan = getFocusSpan(snapshot), selectionCollapsed = isSelectionCollapsed(snapshot); if (!focusSpan || !selectionCollapsed) return !1; const text = event.originEvent.dataTransfer.getData("text/plain"), url = looksLikeUrl(text) ? text : void 0, annotation = url !== void 0 ? config.linkAnnotation?.({ url, schema: snapshot.context.schema }) : void 0; return url && annotation && selectionCollapsed ? { focusSpan, annotation, url } : !1; }, actions: [(_, { annotation, url }) => [execute({ type: "insert.span", text: url, annotations: [annotation] })]] }); return [pasteLinkOnSelection, pasteLinkAtCaret]; } export { coreBehaviors, createCodeEditorBehaviors, createEmojiPickerBehaviors, createLinkBehaviors, createMarkdownBehaviors, defineBehavior, effect, execute, noop, raise }; //# sourceMappingURL=index.js.map