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, );}
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,
);
}