import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; import { $createLineBreakNode, $createTextNode, $getRoot, $getSelection, $isRangeSelection, COMMAND_PRIORITY_HIGH, PASTE_COMMAND, type LexicalEditor, type LexicalNode, } from "lexical"; interface ComposerInlineTokenPasteOptions { createMentionNode: (path: string) => LexicalNode; getExpandedAbsoluteOffsetForPoint: (node: LexicalNode, pointOffset: number) => number; } export function registerComposerInlineTokenPaste( editor: LexicalEditor, options: ComposerInlineTokenPasteOptions, ): () => void { return editor.registerCommand( PASTE_COMMAND, (event) => { if (!(event instanceof ClipboardEvent) || event.clipboardData === null) { return false; } if (event.clipboardData.files.length > 0) { return false; } const text = event.clipboardData.getData("text/plain"); if (text.length === 0) { return false; } // Token grammar requires trailing whitespace; a virtual newline lets a // mention at the very end of the pasted text still parse. const mentions = collectComposerInlineTokens(`${text}\n`).filter( (token) => token.type === "mention" && token.end <= text.length, ); if (mentions.length === 0) { return false; } // Lexical command listeners already run inside an editor update. Starting // a nested update here queues the mention insertion until after this // listener returns, which lets the plain-text paste handler run as well. const selection = $getSelection(); if (!$isRangeSelection(selection)) { return false; } const nodes: LexicalNode[] = []; const appendText = (value: string) => { const lines = value.split("\n"); for (let index = 0; index < lines.length; index += 1) { const line = lines[index] ?? ""; if (line.length > 0) { nodes.push($createTextNode(line)); } if (index < lines.length - 1) { nodes.push($createLineBreakNode()); } } }; const firstMention = mentions[0]; if (firstMention && firstMention.start === 0) { const startPoint = selection.isBackward() ? selection.focus : selection.anchor; const insertionOffset = options.getExpandedAbsoluteOffsetForPoint( startPoint.getNode(), startPoint.offset, ); const precedingChar = $getRoot() .getTextContent() .slice(insertionOffset - 1, insertionOffset); if (precedingChar.length > 0 && !/\s/.test(precedingChar)) { nodes.push($createTextNode(" ")); } } let cursor = 0; for (const mention of mentions) { if (mention.start < cursor) { continue; } if (mention.start > cursor) { appendText(text.slice(cursor, mention.start)); } nodes.push(options.createMentionNode(mention.value)); cursor = mention.end; } if (cursor < text.length) { appendText(text.slice(cursor)); } else { // Keep the serialized prompt valid: mention tokens need trailing // whitespace, so a paste ending in a mention gets the same // trailing space the autocomplete inserts. nodes.push($createTextNode(" ")); } selection.insertNodes(nodes); event.preventDefault(); return true; }, COMMAND_PRIORITY_HIGH, ); }