"use client"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CompositionEvent, type KeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode, type SyntheticEvent, } from "react"; import { createPortal } from "react-dom"; import { CheckSquareOutlined, CopyOutlined, FolderAddOutlined, ScissorOutlined, } from "@ant-design/icons"; export type ComposerPromptToken = | { type: "text"; text: string; } | { type: "reference"; id: string; mediaType: "image" | "video" | "audio"; label: string; sourceNodeId: string; url: string; thumbnailUrl?: string; duration?: number; }; export interface ComposerMentionReferenceItem { key: string; label: string; value: string; image?: string; sourceNodeId?: string; referenceType?: "image" | "video" | "audio"; url?: string; thumbnailUrl?: string; duration?: number; } interface ComposerMentionInputProps { value: string; readOnly?: boolean; placeholder?: string; minHeightPx: number; maxHeightPx: number; className?: string; onChange: (value: string, tokens: ComposerPromptToken[]) => void; onFocus?: () => void; onClick?: (element: HTMLDivElement) => void; onKeyUp?: (event: KeyboardEvent) => void; onKeyDown?: (event: KeyboardEvent) => void; onCompositionStart?: (event: CompositionEvent) => void; onCompositionEnd?: (event: CompositionEvent) => void; referenceItems?: ComposerMentionReferenceItem[]; referenceMenuLabel?: string; onReferenceSelect?: (item: ComposerMentionReferenceItem) => void; } type TextContextMenuAction = "copy" | "cut" | "paste" | "selectAll"; type TextContextMenuState = { position: { x: number; y: number }; canCopy: boolean; canCut: boolean; canPaste: boolean; }; type TextContextMenuItem = { key: TextContextMenuAction; label: string; icon: ReactNode; disabled?: boolean; }; function textToTokens(text: string): ComposerPromptToken[] { return text.length > 0 ? [{ type: "text", text }] : []; } function createReferenceToken(item: ComposerMentionReferenceItem): ComposerPromptToken | null { if (!item.sourceNodeId || !item.referenceType || !item.url) return null; return { type: "reference", id: item.key, mediaType: item.referenceType, label: item.value.trim(), sourceNodeId: item.sourceNodeId, url: item.url, ...(item.thumbnailUrl ? { thumbnailUrl: item.thumbnailUrl } : {}), ...(typeof item.duration === "number" && item.duration > 0 ? { duration: item.duration } : {}), }; } function getPromptTextFromTokens(tokens: ComposerPromptToken[]): string { return tokens.map((token) => token.type === "reference" ? token.label : token.text).join(""); } function getReferenceItemLabel(item: ComposerMentionReferenceItem): string { return item.value.trim(); } function appendTextToken(tokens: ComposerPromptToken[], text: string): void { if (!text) return; const previous = tokens[tokens.length - 1]; if (previous?.type === "text") { previous.text += text; return; } tokens.push({ type: "text", text }); } function parseReferenceTag(element: HTMLElement): ComposerPromptToken | null { const mediaType = element.dataset.referenceMediaType; const label = element.dataset.referenceLabel; const sourceNodeId = element.dataset.referenceSourceNodeId; const url = element.dataset.referenceUrl; if ( mediaType !== "image" && mediaType !== "video" && mediaType !== "audio" ) { return null; } if (!label || !sourceNodeId || !url) return null; const duration = Number(element.dataset.referenceDuration); return { type: "reference", id: element.dataset.referenceId || sourceNodeId, mediaType, label, sourceNodeId, url, ...(element.dataset.referenceThumbnailUrl ? { thumbnailUrl: element.dataset.referenceThumbnailUrl } : {}), ...(Number.isFinite(duration) && duration > 0 ? { duration } : {}), }; } function parseTokensFromDom(element: HTMLDivElement): ComposerPromptToken[] { const tokens: ComposerPromptToken[] = []; element.childNodes.forEach((child) => { if (child.nodeType === Node.TEXT_NODE) { appendTextToken(tokens, child.textContent ?? ""); return; } if (!(child instanceof HTMLElement)) { appendTextToken(tokens, child.textContent ?? ""); return; } if (child.dataset.composerReferenceTag === "true") { const referenceToken = parseReferenceTag(child); if (referenceToken) { tokens.push(referenceToken); return; } } appendTextToken(tokens, child.textContent ?? ""); }); return tokens; } function createReferenceTagElement(item: ComposerMentionReferenceItem): HTMLElement | null { const token = createReferenceToken(item); if (!token || token.type !== "reference") return null; const tag = document.createElement("span"); tag.contentEditable = "false"; tag.setAttribute("contenteditable", "false"); tag.dataset.composerReferenceTag = "true"; tag.dataset.referenceId = token.id; tag.dataset.referenceMediaType = token.mediaType; tag.dataset.referenceLabel = token.label; tag.dataset.referenceSourceNodeId = token.sourceNodeId; tag.dataset.referenceUrl = token.url; if (token.thumbnailUrl) tag.dataset.referenceThumbnailUrl = token.thumbnailUrl; if (token.duration) tag.dataset.referenceDuration = String(token.duration); tag.className = "mx-0.5 inline-flex h-5 max-w-[12rem] select-none items-center gap-1 rounded-md border border-[var(--border-subtle)] bg-[var(--surface-1)] px-1.5 align-middle text-xs leading-none text-[var(--text-primary)]"; if (token.thumbnailUrl) { const thumbnail = document.createElement("img"); thumbnail.src = token.thumbnailUrl; thumbnail.alt = ""; thumbnail.draggable = false; thumbnail.className = "h-4 w-4 shrink-0 rounded-[3px] object-cover"; tag.appendChild(thumbnail); } const label = document.createElement("span"); label.contentEditable = "false"; label.setAttribute("contenteditable", "false"); label.className = "min-w-0 truncate"; label.textContent = token.label; tag.appendChild(label); return tag; } function buildPromptDomNodes( value: string, referenceItems: ComposerMentionReferenceItem[] ): Node[] { const nodes: Node[] = []; const sortedReferenceItems = referenceItems .filter((item) => getReferenceItemLabel(item).length > 0) .sort((a, b) => getReferenceItemLabel(b).length - getReferenceItemLabel(a).length); let cursor = 0; while (cursor < value.length) { let matchedItem: ComposerMentionReferenceItem | null = null; for (const item of sortedReferenceItems) { const label = getReferenceItemLabel(item); if (value.startsWith(label, cursor)) { matchedItem = item; break; } } if (matchedItem) { const tag = createReferenceTagElement(matchedItem); if (tag) { nodes.push(tag); cursor += getReferenceItemLabel(matchedItem).length; continue; } } const nextMatchIndex = sortedReferenceItems.reduce((nextIndex, item) => { const index = value.indexOf(getReferenceItemLabel(item), cursor + 1); if (index === -1) return nextIndex; return nextIndex === -1 ? index : Math.min(nextIndex, index); }, -1); const end = nextMatchIndex === -1 ? value.length : nextMatchIndex; nodes.push(document.createTextNode(value.slice(cursor, end))); cursor = end; } return nodes.length > 0 ? nodes : [document.createTextNode("")]; } function renderPromptValue( element: HTMLDivElement, value: string, referenceItems: ComposerMentionReferenceItem[] ): void { element.replaceChildren(...buildPromptDomNodes(value, referenceItems)); } function isReferenceTagNode(node: Node | null): node is HTMLElement { return node instanceof HTMLElement && node.dataset.composerReferenceTag === "true"; } function getChildNodeIndex(element: HTMLDivElement, node: Node): number { return Array.prototype.indexOf.call(element.childNodes, node); } function setCaretAtRemovedNodePosition(element: HTMLDivElement, removedNodeIndex: number): void { const selection = window.getSelection(); if (!selection) return; const nextNode = element.childNodes[removedNodeIndex] ?? null; const previousNode = element.childNodes[removedNodeIndex - 1] ?? null; const nextRange = document.createRange(); if (nextNode?.nodeType === Node.TEXT_NODE) { nextRange.setStart(nextNode, 0); } else if (previousNode?.nodeType === Node.TEXT_NODE) { nextRange.setStart(previousNode, previousNode.textContent?.length ?? 0); } else { nextRange.setStart(element, Math.min(removedNodeIndex, element.childNodes.length)); } nextRange.collapse(true); selection.removeAllRanges(); selection.addRange(nextRange); } function removeAdjacentReferenceTag(element: HTMLDivElement, direction: "backward" | "forward"): boolean { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0 || !selection.isCollapsed) return false; const range = selection.getRangeAt(0); if (!element.contains(range.startContainer)) return false; let candidate: Node | null = null; if (isReferenceTagNode(range.startContainer)) { candidate = range.startContainer; } else if (range.startContainer.nodeType === Node.TEXT_NODE) { const textNode = range.startContainer; if (direction === "backward" && range.startOffset === 0) { candidate = textNode.previousSibling; } if (direction === "forward" && range.startOffset === (textNode.textContent?.length ?? 0)) { candidate = textNode.nextSibling; } } else if (range.startContainer === element) { candidate = direction === "backward" ? element.childNodes[range.startOffset - 1] ?? null : element.childNodes[range.startOffset] ?? null; } if (!isReferenceTagNode(candidate)) return false; const candidateIndex = getChildNodeIndex(element, candidate); if (candidateIndex < 0) return false; candidate.remove(); setCaretAtRemovedNodePosition(element, candidateIndex); return true; } function getContentEditableCaretOffset(element: HTMLDivElement): number { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return element.textContent?.length ?? 0; const range = selection.getRangeAt(0); if (!element.contains(range.endContainer)) return element.textContent?.length ?? 0; const beforeRange = range.cloneRange(); beforeRange.selectNodeContents(element); beforeRange.setEnd(range.endContainer, range.endOffset); return beforeRange.toString().length; } function setContentEditableCaretOffset(element: HTMLDivElement, offset: number): void { const selection = window.getSelection(); if (!selection) return; const safeOffset = Math.max(0, Math.min(offset, element.textContent?.length ?? 0)); const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); let remaining = safeOffset; let current = walker.nextNode(); const range = document.createRange(); while (current) { const textLength = current.textContent?.length ?? 0; if (remaining <= textLength) { range.setStart(current, remaining); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); return; } remaining -= textLength; current = walker.nextNode(); } range.setStart(element, element.childNodes.length); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); } function selectionBelongsToElement(selection: Selection | null, element: HTMLDivElement): boolean { if (!selection || selection.rangeCount === 0) return false; const range = selection.getRangeAt(0); return element.contains(range.startContainer) && element.contains(range.endContainer); } function getSelectedTextInElement(element: HTMLDivElement): string { const selection = window.getSelection(); if (!selection || !selectionBelongsToElement(selection, element) || selection.isCollapsed) return ""; return selection.toString(); } function selectElementContents(element: HTMLDivElement): void { const selection = window.getSelection(); if (!selection) return; const range = document.createRange(); range.selectNodeContents(element); selection.removeAllRanges(); selection.addRange(range); } function insertPlainTextAtSelection(element: HTMLDivElement, text: string): void { const selection = window.getSelection(); if (!selection || !selectionBelongsToElement(selection, element)) { element.focus(); setContentEditableCaretOffset(element, element.textContent?.length ?? 0); } const activeSelection = window.getSelection(); if (!activeSelection || activeSelection.rangeCount === 0) return; const range = activeSelection.getRangeAt(0); range.deleteContents(); const textNode = document.createTextNode(text); range.insertNode(textNode); range.setStartAfter(textNode); range.collapse(true); activeSelection.removeAllRanges(); activeSelection.addRange(range); } function isReferenceNavigationKey(key: string): boolean { return key === "ArrowDown" || key === "ArrowUp" || key === "Enter" || key === "Escape"; } export const ComposerMentionInput = forwardRef( function ComposerMentionInput({ value, readOnly = false, placeholder, minHeightPx, maxHeightPx, className, onChange, onFocus, onClick, onKeyUp, onKeyDown, onCompositionStart, onCompositionEnd, referenceItems = [], referenceMenuLabel, onReferenceSelect, }, forwardedRef) { const inputRef = useRef(null); const isComposingRef = useRef(false); const mentionTriggerIndexRef = useRef(null); const [menuPosition, setMenuPosition] = useState<{ left: number; top: number } | null>(null); const [textContextMenu, setTextContextMenu] = useState(null); const [activeReferenceIndex, setActiveReferenceIndex] = useState(0); const isReferenceMenuOpen = Boolean(menuPosition) && referenceItems.length > 0; useImperativeHandle(forwardedRef, () => inputRef.current as HTMLDivElement); const stopCanvasInteraction = useCallback((event: SyntheticEvent) => { event.stopPropagation(); }, []); const closeReferenceMenu = useCallback(() => { mentionTriggerIndexRef.current = null; setMenuPosition(null); setActiveReferenceIndex(0); }, []); const closeTextContextMenu = useCallback(() => { setTextContextMenu(null); }, []); const emitTokens = useCallback((tokens: ComposerPromptToken[]) => { onChange(getPromptTextFromTokens(tokens), tokens); }, [onChange]); const emitDomValue = useCallback((element: HTMLDivElement) => { const tokens = parseTokensFromDom(element); emitTokens(tokens.length > 0 ? tokens : textToTokens(element.textContent ?? "")); }, [emitTokens]); const emitValue = useCallback((nextValue: string) => { onChange(nextValue, textToTokens(nextValue)); }, [onChange]); const handleTextContextMenu = useCallback((event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); closeReferenceMenu(); const element = event.currentTarget; const selectedText = getSelectedTextInElement(element); setTextContextMenu({ position: { x: event.clientX, y: event.clientY }, canCopy: selectedText.length > 0, canCut: !readOnly && selectedText.length > 0, canPaste: !readOnly, }); }, [closeReferenceMenu, readOnly]); const handleTextContextMenuAction = useCallback((action: TextContextMenuAction) => { const element = inputRef.current; if (!element) return; const run = async () => { if (action === "selectAll") { element.focus(); selectElementContents(element); return; } const selectedText = getSelectedTextInElement(element); if (action === "copy") { if (!selectedText) return; await navigator.clipboard?.writeText(selectedText); return; } if (action === "cut") { if (readOnly || !selectedText) return; await navigator.clipboard?.writeText(selectedText); window.getSelection()?.deleteFromDocument(); emitDomValue(element); return; } if (action === "paste") { if (readOnly) return; const text = await navigator.clipboard?.readText(); if (!text) return; insertPlainTextAtSelection(element, text); emitDomValue(element); } }; closeTextContextMenu(); void run().catch(() => { if (action === "copy") document.execCommand("copy"); if (action === "cut" && !readOnly) { document.execCommand("cut"); emitDomValue(element); } if (action === "paste" && !readOnly) { void navigator.clipboard?.readText().then((text) => { insertPlainTextAtSelection(element, text); emitDomValue(element); }); } }); }, [closeTextContextMenu, emitDomValue, readOnly]); const syncReferenceMenuFromCursor = useCallback((element: HTMLDivElement) => { if (readOnly || referenceItems.length === 0) { closeReferenceMenu(); return; } const cursor = getContentEditableCaretOffset(element); const inputValue = element.textContent ?? ""; const previousChar = cursor > 0 ? inputValue[cursor - 1] : ""; if (previousChar !== "@") { closeReferenceMenu(); return; } const selection = window.getSelection(); const selectionRange = selection?.rangeCount ? selection.getRangeAt(0) : null; const selectionRect = selectionRange && "getBoundingClientRect" in selectionRange ? selectionRange.getBoundingClientRect() : null; const inputRect = element.getBoundingClientRect(); mentionTriggerIndexRef.current = cursor - 1; setMenuPosition({ left: Math.max(8, Math.min((selectionRect?.left || inputRect.left) + 16, window.innerWidth - 296)), top: Math.max(8, (selectionRect?.top || inputRect.top) - 8), }); setActiveReferenceIndex(0); }, [closeReferenceMenu, readOnly, referenceItems.length]); const insertReference = useCallback((item: ComposerMentionReferenceItem) => { const element = inputRef.current; if (!element) return; const tag = createReferenceTagElement(item); if (!tag) { const currentValue = getPromptTextFromTokens(parseTokensFromDom(element)); const fallbackIndex = currentValue.endsWith("@") ? currentValue.length - 1 : currentValue.length; const triggerIndex = mentionTriggerIndexRef.current ?? fallbackIndex; const before = currentValue.slice(0, triggerIndex); const after = currentValue[triggerIndex] === "@" ? currentValue.slice(triggerIndex + 1) : currentValue.slice(triggerIndex); const nextValue = `${before}${item.value}${after}`; renderPromptValue(element, nextValue, referenceItems); emitValue(nextValue); closeReferenceMenu(); return; } const currentValue = getPromptTextFromTokens(parseTokensFromDom(element)); const fallbackIndex = currentValue.endsWith("@") ? currentValue.length - 1 : currentValue.length; const triggerIndex = mentionTriggerIndexRef.current ?? fallbackIndex; const before = currentValue.slice(0, triggerIndex); const after = currentValue[triggerIndex] === "@" ? currentValue.slice(triggerIndex + 1) : currentValue.slice(triggerIndex); const nextValue = `${before}${item.value}${after}`; renderPromptValue(element, nextValue, referenceItems); emitDomValue(element); onReferenceSelect?.(item); closeReferenceMenu(); window.setTimeout(() => { if (!element.isConnected) return; element.focus(); setContentEditableCaretOffset(element, before.length + item.value.length); }, 0); }, [closeReferenceMenu, emitDomValue, emitValue, onReferenceSelect, referenceItems]); const referenceMenu = useMemo(() => { if (!isReferenceMenuOpen || !menuPosition) return null; return createPortal(
event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()} > {referenceMenuLabel ? (
{referenceMenuLabel}
) : null}
{referenceItems.map((item, itemIndex) => { const isActive = itemIndex === activeReferenceIndex; return ( ); })}
, document.body ); }, [ activeReferenceIndex, insertReference, isReferenceMenuOpen, menuPosition, referenceItems, referenceMenuLabel, ]); const textContextMenuElement = useMemo(() => { if (!textContextMenu) return null; const items: TextContextMenuItem[] = [ { key: "copy", label: "复制", icon: , disabled: !textContextMenu.canCopy }, { key: "cut", label: "剪切", icon: , disabled: !textContextMenu.canCut }, { key: "paste", label: "粘贴", icon: , disabled: !textContextMenu.canPaste }, { key: "selectAll", label: "全选", icon: }, ]; const viewportPadding = 8; const menuWidth = 116; const menuHeight = items.length * 34 + 8; const left = Math.min(textContextMenu.position.x, window.innerWidth - menuWidth - viewportPadding); const top = Math.min(textContextMenu.position.y, window.innerHeight - menuHeight - viewportPadding); return createPortal(
event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onContextMenu={(event) => event.preventDefault()} > {items.map((item) => ( ))}
, document.body ); }, [handleTextContextMenuAction, textContextMenu]); useEffect(() => { if (!textContextMenu) return; const close = () => closeTextContextMenu(); const handleKeyDown = (event: globalThis.KeyboardEvent) => { if (event.key === "Escape") closeTextContextMenu(); }; window.addEventListener("pointerdown", close); window.addEventListener("wheel", close, { passive: true }); window.addEventListener("keydown", handleKeyDown); return () => { window.removeEventListener("pointerdown", close); window.removeEventListener("wheel", close); window.removeEventListener("keydown", handleKeyDown); }; }, [closeTextContextMenu, textContextMenu]); useEffect(() => { const element = inputRef.current; if (!element) return; const tokens = parseTokensFromDom(element); const currentValue = getPromptTextFromTokens(tokens); const hasReferenceTags = tokens.some((token) => token.type === "reference"); const shouldHaveReferenceTags = referenceItems.some((item) => value.includes(getReferenceItemLabel(item))); if (currentValue === value && hasReferenceTags === shouldHaveReferenceTags) return; if (document.activeElement === element && currentValue === value) return; const shouldRestoreCaret = document.activeElement === element; const caretOffset = shouldRestoreCaret ? getContentEditableCaretOffset(element) : null; renderPromptValue(element, value, referenceItems); if (caretOffset !== null) { setContentEditableCaretOffset(element, caretOffset); window.requestAnimationFrame(() => { if (!element.isConnected || document.activeElement !== element) return; setContentEditableCaretOffset(element, caretOffset); }); } }, [referenceItems, value]); useEffect(() => { if (readOnly || referenceItems.length === 0) closeReferenceMenu(); }, [closeReferenceMenu, readOnly, referenceItems.length]); return (
{referenceMenu} {textContextMenuElement} {value.length === 0 && !readOnly && placeholder ? (
{placeholder}
) : null}
{ if (isComposingRef.current) return; emitDomValue(event.currentTarget); syncReferenceMenuFromCursor(event.currentTarget); }} onFocus={onFocus} onClick={(event) => { event.stopPropagation(); onClick?.(event.currentTarget); syncReferenceMenuFromCursor(event.currentTarget); }} onKeyUp={(event) => { onKeyUp?.(event); if (isReferenceMenuOpen && isReferenceNavigationKey(event.key)) return; syncReferenceMenuFromCursor(event.currentTarget); }} onKeyDown={(event) => { if (isReferenceMenuOpen) { if (event.key === "ArrowDown") { event.preventDefault(); setActiveReferenceIndex((current) => (current + 1) % referenceItems.length); return; } if (event.key === "ArrowUp") { event.preventDefault(); setActiveReferenceIndex((current) => (current - 1 + referenceItems.length) % referenceItems.length); return; } if (event.key === "Enter") { event.preventDefault(); insertReference(referenceItems[activeReferenceIndex] ?? referenceItems[0]); return; } if (event.key === "Escape") { event.preventDefault(); closeReferenceMenu(); return; } if (event.key === " " || event.key === "Tab") { closeReferenceMenu(); } } if (event.key === "Backspace" || event.key === "Delete") { const removed = removeAdjacentReferenceTag( event.currentTarget, event.key === "Backspace" ? "backward" : "forward" ); if (removed) { event.preventDefault(); emitDomValue(event.currentTarget); return; } } if (event.key === "Enter") closeReferenceMenu(); onKeyDown?.(event); }} onPaste={(event) => { event.preventDefault(); const text = event.clipboardData.getData("text/plain"); document.execCommand("insertText", false, text); }} onContextMenu={handleTextContextMenu} onCompositionStart={(event) => { isComposingRef.current = true; closeReferenceMenu(); onCompositionStart?.(event); }} onCompositionEnd={(event) => { isComposingRef.current = false; emitDomValue(event.currentTarget); onCompositionEnd?.(event); }} onPointerDownCapture={stopCanvasInteraction} onMouseDownCapture={stopCanvasInteraction} onWheelCapture={stopCanvasInteraction} style={{ minHeight: minHeightPx, maxHeight: maxHeightPx, whiteSpace: "pre-wrap", }} className={className} />
); } );