"use client"; import { Folder as FolderIcon, X as XIcon } from "lucide-react"; import type { NodeEntry, TElement } from "platejs"; import type { PlateElementProps } from "platejs/react"; import { createPlatePlugin, ParagraphPlugin, Plate, PlateContent, usePlateEditor, } from "platejs/react"; import { createContext, type FC, forwardRef, useCallback, useContext, useImperativeHandle, useMemo, useRef, } from "react"; import { FOLDER_MENTION_DOCUMENT_TYPE } from "@/atoms/chat/mentioned-documents.atom"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { Document } from "@/contracts/types/document.types"; import { getMentionDocKey } from "@/lib/chat/mention-doc-key"; import { cn } from "@/lib/utils"; export type MentionKind = "doc" | "folder"; export interface MentionedDocument { id: number; title: string; document_type?: string; kind: MentionKind; } /** * Input shape for inserting a chip. ``kind`` defaults to ``"doc"`` * when omitted so legacy callers don't have to thread the * discriminator. Folder callers pass ``kind: "folder"`` and the * folder ``id`` and ``title``; ``document_type`` defaults to * ``FOLDER_MENTION_DOCUMENT_TYPE`` inside ``insertMentionChip`` so the * dedup key (`kind:document_type:id`) never collides with a doc chip * that happens to share an id. */ export type MentionChipInput = { id: number; title: string; document_type?: string; kind?: MentionKind; }; export interface InlineMentionEditorRef { focus: () => void; clear: () => void; setText: (text: string) => void; getText: () => string; getMentionedDocuments: () => MentionedDocument[]; insertMentionChip: (mention: MentionChipInput, options?: { removeTriggerText?: boolean }) => void; /** * @deprecated Use ``insertMentionChip``. Kept for one transition * cycle so we don't break ad-hoc callers; prefer the new name. */ insertDocumentChip: ( doc: Pick, options?: { removeTriggerText?: boolean } ) => void; removeDocumentChip: (docId: number, docType?: string) => void; setDocumentChipStatus: ( docId: number, docType: string | undefined, statusLabel: string | null, statusKind?: "pending" | "processing" | "ready" | "failed" ) => void; } interface InlineMentionEditorProps { placeholder?: string; onMentionTrigger?: (query: string) => void; onMentionClose?: () => void; onActionTrigger?: (query: string) => void; onActionClose?: () => void; onSubmit?: () => void; onChange?: (text: string, docs: MentionedDocument[]) => void; onDocumentRemove?: (docId: number, docType?: string) => void; onKeyDown?: (e: React.KeyboardEvent) => void; disabled?: boolean; className?: string; initialText?: string; } type MentionStatusKind = "pending" | "processing" | "ready" | "failed"; type ComposerTextNode = { text: string }; type MentionElementNode = { type: "mention"; id: number; title: string; document_type?: string; /** * Discriminator added so a folder chip and a doc chip with the * same id round-trip cleanly through ``getMentionedDocuments`` * and the persisted ``mentioned-documents`` content part. * Defaults to ``"doc"`` for nodes that predate this field. */ kind?: MentionKind; statusLabel?: string | null; statusKind?: MentionStatusKind; children: [{ text: "" }]; }; type ComposerNode = ComposerTextNode | MentionElementNode; type ComposerParagraph = { type: "p"; children: ComposerNode[] }; type ComposerValue = ComposerParagraph[]; const MENTION_TYPE = "mention"; const MENTION_CHIP_CLASSNAME = "group inline-flex h-5 items-center gap-1 mx-0.5 rounded bg-primary/10 px-1 text-xs font-bold text-primary/60 select-none align-middle leading-none"; const MENTION_CHIP_ICON_CLASSNAME = "flex items-center text-muted-foreground leading-none"; const MENTION_CHIP_TITLE_CLASSNAME = "max-w-[120px] truncate leading-none"; const COMPOSER_TEXT_METRICS_CLASSNAME = "text-sm leading-6"; const EMPTY_VALUE: ComposerValue = [{ type: "p", children: [{ text: "" }] }]; /** * Internal seam that lets ``MentionElement`` (a Plate render component * with no React props beyond ``element``) reach the editor's chip-removal * function. Mirrors the Backspace path in ``handleKeyDown`` so the X * button delegates to the exact same combined call site — no extra * state, no atom coupling leaking into the chip. */ type MentionEditorContextValue = { removeChip: (docId: number, docType: string | undefined) => void; }; const MentionEditorContext = createContext(null); const MentionElement: FC> = ({ attributes, children, element, }) => { const statusClass = element.statusKind === "failed" ? "text-destructive" : element.statusKind === "ready" ? "text-emerald-700" : "text-amber-700"; const isFolder = element.kind === "folder"; const ctx = useContext(MentionEditorContext); return ( {isFolder ? ( ) : ( getConnectorIcon(element.document_type ?? "UNKNOWN", "h-3 w-3") )} {ctx ? ( ) : null} {element.title} {element.statusLabel ? ( {element.statusLabel} ) : null} {children} ); }; const MentionPlugin = createPlatePlugin({ key: MENTION_TYPE, node: { isElement: true, isInline: true, isVoid: true, type: MENTION_TYPE, component: MentionElement, }, }); function isMentionNode(node: ComposerNode): node is MentionElementNode { return typeof node === "object" && "type" in node && node.type === MENTION_TYPE; } function getTextNode(node: ComposerNode): ComposerTextNode | null { if (typeof node === "object" && "text" in node && typeof node.text === "string") return node; return null; } function toValueFromText(text: string): ComposerValue { const lines = text.split("\n"); if (lines.length === 0) return EMPTY_VALUE; return lines.map((line) => ({ type: "p", children: [{ text: line }] })) as ComposerValue; } function getPlainText(value: ComposerValue): string { const lines = value.map((block) => block.children .map((node) => { if (isMentionNode(node)) return `@${node.title}`; return getTextNode(node)?.text ?? ""; }) .join("") ); return lines.join("\n").trim(); } function getMentionedDocuments(value: ComposerValue): MentionedDocument[] { const map = new Map(); for (const block of value) { for (const node of block.children) { if (!isMentionNode(node)) continue; const kind: MentionKind = node.kind ?? "doc"; const doc: MentionedDocument = { id: node.id, title: node.title, document_type: node.document_type, kind, }; map.set(getMentionDocKey(doc), doc); } } return Array.from(map.values()); } type EditorSelection = { anchor: { path: number[]; offset: number }; focus: { path: number[]; offset: number }; } | null; function getCursorTextContext(value: ComposerValue, selection: EditorSelection) { if (!selection || !selection.anchor || !selection.focus) return null; if ( selection.anchor.path.length < 2 || selection.focus.path.length < 2 || selection.anchor.path[0] !== selection.focus.path[0] || selection.anchor.path[1] !== selection.focus.path[1] ) { return null; } const block = value[selection.anchor.path[0]]; if (!block) return null; const child = block.children[selection.anchor.path[1]]; const textNode = getTextNode(child); if (!textNode) return null; return { blockIndex: selection.anchor.path[0], childIndex: selection.anchor.path[1], text: textNode.text, cursor: selection.anchor.offset, }; } function scanActiveTrigger(text: string, cursor: number) { let wordStart = 0; for (let i = cursor - 1; i >= 0; i--) { if (text[i] === " " || text[i] === "\n") { wordStart = i + 1; break; } } let triggerChar: "@" | "/" | null = null; let triggerIndex = -1; for (let i = wordStart; i < cursor; i++) { if (text[i] === "@" || text[i] === "/") { triggerChar = text[i] as "@" | "/"; triggerIndex = i; break; } } if (!triggerChar || triggerIndex === -1) return null; const query = text.slice(triggerIndex + 1, cursor); if (query.startsWith(" ")) return null; if ( triggerChar === "/" && triggerIndex > 0 && text[triggerIndex - 1] !== " " && text[triggerIndex - 1] !== "\n" ) { return null; } return { triggerChar, query }; } export const InlineMentionEditor = forwardRef( ( { placeholder = "Type @ to mention documents...", onMentionTrigger, onMentionClose, onActionTrigger, onActionClose, onSubmit, onChange, onDocumentRemove, onKeyDown, disabled = false, className, initialText, }, ref ) => { const editableRef = useRef(null); const editor = usePlateEditor({ readOnly: disabled, plugins: [ParagraphPlugin, MentionPlugin], value: initialText ? toValueFromText(initialText) : EMPTY_VALUE, }); // Move the caret to the end of the document and focus the editor. // Routes through Plate's transforms so ``editor.selection`` and // the DOM selection stay in sync — bypassing Plate (via raw // ``window.getSelection``) was the prior implementation and is // what made the caret disappear after every ``setValue``-based // mutation. Falls back to DOM focus if Plate's API throws (e.g. // during a transient unmount race). const focusAtEnd = useCallback(() => { try { editor.tf.select(editor.api.end([])); editor.tf.focus(); } catch { editableRef.current?.focus(); } }, [editor]); const getCurrentValue = useCallback( () => (editor.children as ComposerValue) ?? EMPTY_VALUE, [editor] ); const emitState = useCallback( (nextValue: ComposerValue) => { const text = getPlainText(nextValue); const docs = getMentionedDocuments(nextValue); onChange?.(text, docs); const cursorCtx = getCursorTextContext(nextValue, editor.selection); if (!cursorCtx) { onMentionClose?.(); onActionClose?.(); return; } const trigger = scanActiveTrigger(cursorCtx.text, cursorCtx.cursor); if (!trigger) { onMentionClose?.(); onActionClose?.(); return; } if (trigger.triggerChar === "@") { onMentionTrigger?.(trigger.query); onActionClose?.(); return; } onActionTrigger?.(trigger.query); onMentionClose?.(); }, [editor.selection, onActionClose, onActionTrigger, onChange, onMentionClose, onMentionTrigger] ); const setValue = useCallback( (nextValue: ComposerValue) => { const tf = editor.tf as { setValue: (value: ComposerValue) => void }; tf.setValue(nextValue); emitState(nextValue); }, [editor, emitState] ); // Insert a mention chip at the current caret. Uses Plate // transforms so Slate keeps the editor selection valid through // the edit. // // Critical detail: the chip is a void inline element. Inserting // it on its own with ``{ select: true }`` would land the caret // inside the void's empty ``children: [{ text: "" }]`` — a point // the browser can't render a caret on, which is what made the // cursor disappear or jump to the wrong side of the chip after // insertion. Inserting ``[mentionNode, { text: " " }]`` as a // single array means the *last* inserted node is a text node, so // ``{ select: true }`` resolves to that text node's end (offset // 1, after the trailing space) — a real, renderable text point. // The whole sequence stays inside ``withoutNormalizing`` so the // optional trigger-text delete and the chip insert show up as a // single undo step. const insertMentionChip = useCallback( (mention: MentionChipInput, options?: { removeTriggerText?: boolean }) => { if (typeof mention.id !== "number" || typeof mention.title !== "string") return; const removeTriggerText = options?.removeTriggerText ?? true; const kind: MentionKind = mention.kind ?? "doc"; const document_type = mention.document_type ?? (kind === "folder" ? FOLDER_MENTION_DOCUMENT_TYPE : undefined); const mentionNode: MentionElementNode = { type: MENTION_TYPE, id: mention.id, title: mention.title, document_type, kind, children: [{ text: "" }], }; editor.tf.withoutNormalizing(() => { const selection = editor.selection; // No active editor selection — typically because focus // moved to a picker/dropdown. Snap the caret to the end // of the document so the chip appends cleanly instead // of disappearing into a dead range. if (!selection) { editor.tf.select(editor.api.end([])); } else if (removeTriggerText) { // Delete the in-progress "@query" text so the chip // stands in for it. Mirrors the old splice but lets // Slate keep selection sane through the edit. const cursorCtx = getCursorTextContext(getCurrentValue(), selection); if (cursorCtx) { const text = cursorCtx.text; let triggerIndex = -1; for (let i = cursorCtx.cursor - 1; i >= 0; i--) { if (text[i] === "@") { triggerIndex = i; break; } if (text[i] === " " || text[i] === "\n") break; } if (triggerIndex >= 0 && triggerIndex < cursorCtx.cursor) { const path = [cursorCtx.blockIndex, cursorCtx.childIndex]; editor.tf.delete({ at: { anchor: { path, offset: triggerIndex }, focus: { path, offset: cursorCtx.cursor }, }, }); } } } editor.tf.insertNodes([mentionNode, { text: " " }] as unknown as TElement[], { select: true, }); }); editor.tf.focus(); }, [editor, getCurrentValue] ); // Backwards-compatible shim — pre-folder callers pass a doc-only // payload; we route them through ``insertMentionChip`` with // ``kind: "doc"``. const insertDocumentChip = useCallback( ( doc: Pick, options?: { removeTriggerText?: boolean } ) => { insertMentionChip({ ...doc, kind: "doc" }, options); }, [insertMentionChip] ); // Remove the chip(s) matching the given (id, document_type) pair. // Goes through ``tf.removeNodes`` so Slate keeps the surrounding // selection valid — the previous ``setValue``-based filter wiped // selection on every removal, which is why the caret vanished // when the X button was clicked. Iterates descending so removing // one entry doesn't invalidate the path of subsequent matches. // In practice chips are deduped by ``getMentionDocKey`` so this // loop runs at most once; the descending iteration is defense // against any future divergence. const removeDocumentChip = useCallback( (docId: number, docType?: string) => { const match = (n: unknown) => { if (!n || typeof n !== "object" || !("type" in n)) return false; const node = n as MentionElementNode; if (node.type !== MENTION_TYPE) return false; if (node.id !== docId) return false; return (node.document_type ?? "UNKNOWN") === (docType ?? "UNKNOWN"); }; const entries = Array.from(editor.api.nodes({ at: [], match })) as NodeEntry[]; if (entries.length === 0) return; editor.tf.withoutNormalizing(() => { for (const [, path] of entries.reverse()) { editor.tf.removeNodes({ at: path }); } }); }, [editor] ); // Combined "remove chip end-to-end" used by both the Backspace // keybinding and the in-chip X button. Keeping these two surfaces // pinned to a single helper guarantees they can never diverge — // e.g. one path forgetting to notify the parent atom. const removeChip = useCallback( (docId: number, docType: string | undefined) => { removeDocumentChip(docId, docType); onDocumentRemove?.(docId, docType); }, [onDocumentRemove, removeDocumentChip] ); // Update the streaming status on a chip in place. ``tf.setNodes`` // merges the partial props onto every node matching the // predicate without rebuilding the document, so the user's // selection stays put — important because status transitions // arrive as backend events while the user may be mid-typing. const setDocumentChipStatus = useCallback( ( docId: number, docType: string | undefined, statusLabel: string | null, statusKind: MentionStatusKind = "pending" ) => { const match = (n: unknown) => { if (!n || typeof n !== "object" || !("type" in n)) return false; const node = n as MentionElementNode; if (node.type !== MENTION_TYPE) return false; if (node.id !== docId) return false; return (node.document_type ?? "UNKNOWN") === (docType ?? "UNKNOWN"); }; editor.tf.setNodes( { statusLabel, statusKind: statusLabel ? statusKind : undefined, } as Partial, { at: [], match } ); }, [editor] ); const clear = useCallback(() => { setValue(EMPTY_VALUE); // ``tf.setValue`` (inside ``setValue``) wipes the editor's // selection — without this, after the user presses Enter to // submit, the composer is left with no caret and they would // have to click before typing again. requestAnimationFrame(focusAtEnd); }, [focusAtEnd, setValue]); const setText = useCallback( (text: string) => { setValue(toValueFromText(text)); requestAnimationFrame(focusAtEnd); }, [focusAtEnd, setValue] ); const getText = useCallback(() => getPlainText(getCurrentValue()), [getCurrentValue]); const getMentionedDocs = useCallback( () => getMentionedDocuments(getCurrentValue()), [getCurrentValue] ); useImperativeHandle( ref, () => ({ // If we already have a Plate selection (user was typing // before focus left), preserve it — just refocus. If we // don't (first mount, or focus was lost without a // surviving selection), seed a selection at end-of-doc // so the contentEditable shows a caret instead of an // invisible focus ring. focus: () => { try { if (!editor.selection) { editor.tf.select(editor.api.end([])); } editor.tf.focus(); } catch { editableRef.current?.focus(); } }, clear, setText, getText, getMentionedDocuments: getMentionedDocs, insertMentionChip, insertDocumentChip, removeDocumentChip, setDocumentChipStatus, }), [ clear, editor, getMentionedDocs, getText, insertMentionChip, insertDocumentChip, removeDocumentChip, setDocumentChipStatus, setText, ] ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { onKeyDown?.(e); if (e.defaultPrevented) return; if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onSubmit?.(); return; } if (e.key !== "Backspace") return; const selection = editor.selection; if (!selection || !selection.anchor || !selection.focus) return; if ( selection.anchor.path.length < 2 || selection.focus.path.length < 2 || selection.anchor.path[0] !== selection.focus.path[0] ) { return; } if (selection.anchor.offset !== 0 || selection.focus.offset !== 0) return; const value = getCurrentValue(); const block = value[selection.anchor.path[0]]; if (!block) return; const childIndex = selection.anchor.path[1]; if (childIndex <= 0) return; const prev = block.children[childIndex - 1]; if (!isMentionNode(prev)) return; e.preventDefault(); removeChip(prev.id, prev.document_type); }, [editor.selection, getCurrentValue, onKeyDown, onSubmit, removeChip] ); const editableProps = useMemo( () => ({ placeholder, onPaste: (e: React.ClipboardEvent) => { e.preventDefault(); const text = e.clipboardData.getData("text/plain"); const tf = editor.tf as { insertText: (value: string) => void }; tf.insertText(text); }, onKeyDown: handleKeyDown, }), [editor, handleKeyDown, placeholder] ); const mentionEditorContextValue = useMemo( () => ({ removeChip }), [removeChip] ); return (
{ emitState(value as ComposerValue); }} >
); } ); InlineMentionEditor.displayName = "InlineMentionEditor";