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