You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

830 lines
30 KiB

"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<HTMLDivElement>) => void;
onKeyDown?: (event: KeyboardEvent<HTMLDivElement>) => void;
onCompositionStart?: (event: CompositionEvent<HTMLDivElement>) => void;
onCompositionEnd?: (event: CompositionEvent<HTMLDivElement>) => 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<HTMLDivElement, ComposerMentionInputProps>(
function ComposerMentionInput({
value,
readOnly = false,
placeholder,
minHeightPx,
maxHeightPx,
className,
onChange,
onFocus,
onClick,
onKeyUp,
onKeyDown,
onCompositionStart,
onCompositionEnd,
referenceItems = [],
referenceMenuLabel,
onReferenceSelect,
}, forwardedRef) {
const inputRef = useRef<HTMLDivElement>(null);
const isComposingRef = useRef(false);
const mentionTriggerIndexRef = useRef<number | null>(null);
const [menuPosition, setMenuPosition] = useState<{ left: number; top: number } | null>(null);
const [textContextMenu, setTextContextMenu] = useState<TextContextMenuState | null>(null);
const [activeReferenceIndex, setActiveReferenceIndex] = useState(0);
const isReferenceMenuOpen = Boolean(menuPosition) && referenceItems.length > 0;
useImperativeHandle(forwardedRef, () => inputRef.current as HTMLDivElement);
const stopCanvasInteraction = useCallback((event: SyntheticEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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(
<div
className="nodrag nopan nowheel fixed z-[10080] w-48 overflow-hidden rounded-lg border border-[var(--border-subtle)] bg-[#1f1f21] p-1.5 shadow-[0_12px_32px_rgba(0,0,0,0.36)]"
style={{ left: menuPosition.left, top: menuPosition.top }}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
>
{referenceMenuLabel ? (
<div className="sr-only">
{referenceMenuLabel}
</div>
) : null}
<div className="space-y-1">
{referenceItems.map((item, itemIndex) => {
const isActive = itemIndex === activeReferenceIndex;
return (
<button
key={item.key}
type="button"
onMouseEnter={() => setActiveReferenceIndex(itemIndex)}
onClick={() => insertReference(item)}
className={`flex h-11 w-full items-center gap-2 rounded-md px-2 text-left text-xs font-medium transition-colors ${
isActive
? "bg-[#3a3a3d] text-white"
: "bg-[#303033] text-[var(--text-secondary)] hover:bg-[#3a3a3d] hover:text-white"
}`}
>
{item.image ? <img src={item.image} alt="" className="h-8 w-8 shrink-0 rounded object-cover" /> : null}
<span className="min-w-0 flex-1 truncate">{item.label}</span>
</button>
);
})}
</div>
</div>,
document.body
);
}, [
activeReferenceIndex,
insertReference,
isReferenceMenuOpen,
menuPosition,
referenceItems,
referenceMenuLabel,
]);
const textContextMenuElement = useMemo(() => {
if (!textContextMenu) return null;
const items: TextContextMenuItem[] = [
{ key: "copy", label: "复制", icon: <CopyOutlined />, disabled: !textContextMenu.canCopy },
{ key: "cut", label: "剪切", icon: <ScissorOutlined />, disabled: !textContextMenu.canCut },
{ key: "paste", label: "粘贴", icon: <FolderAddOutlined />, disabled: !textContextMenu.canPaste },
{ key: "selectAll", label: "全选", icon: <CheckSquareOutlined /> },
];
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(
<div
className="fixed z-[10090] min-w-[116px] rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-1)] p-1 text-[13px] text-[var(--text-primary)] shadow-[var(--shadow-panel)]"
style={{ left, top, width: menuWidth }}
role="menu"
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onContextMenu={(event) => event.preventDefault()}
>
{items.map((item) => (
<button
key={item.key}
type="button"
role="menuitem"
aria-label={item.label}
disabled={item.disabled}
onClick={() => handleTextContextMenuAction(item.key)}
className={[
"flex h-[34px] w-full items-center gap-2 rounded-md px-2 text-left transition-colors hover:bg-[var(--surface-hover)]",
item.disabled ? "cursor-not-allowed opacity-40 hover:bg-transparent" : "",
].join(" ")}
>
<span className="flex w-4 shrink-0 items-center justify-center text-[14px]">{item.icon}</span>
<span className="min-w-0 flex-1 truncate">{item.label}</span>
</button>
))}
</div>,
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 (
<div className="relative">
{referenceMenu}
{textContextMenuElement}
{value.length === 0 && !readOnly && placeholder ? (
<div className="pointer-events-none absolute left-0 top-0 pr-12 text-sm leading-6 text-[var(--text-muted)]">
{placeholder}
</div>
) : null}
<div
ref={inputRef}
role="textbox"
aria-label={placeholder}
aria-multiline="true"
contentEditable={!readOnly}
suppressContentEditableWarning
spellCheck={false}
tabIndex={readOnly ? -1 : 0}
data-placeholder={placeholder}
onInput={(event) => {
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}
/>
</div>
);
}
);