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.
1042 lines
38 KiB
1042 lines
38 KiB
"use client";
|
|
|
|
import {
|
|
forwardRef,
|
|
useCallback,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type CompositionEvent,
|
|
type KeyboardEvent,
|
|
type MouseEvent as ReactMouseEvent,
|
|
type PointerEvent as ReactPointerEvent,
|
|
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 createReferenceTagLeadingVisual(
|
|
token: Extract<ComposerPromptToken, { type: "reference" }>
|
|
): HTMLElement | null {
|
|
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";
|
|
return thumbnail;
|
|
}
|
|
if (token.mediaType === "audio" || token.mediaType === "video") {
|
|
const icon = document.createElement("span");
|
|
icon.contentEditable = "false";
|
|
icon.setAttribute("contenteditable", "false");
|
|
icon.className = "flex h-4 w-4 shrink-0 items-center justify-center rounded-[3px] bg-[var(--surface-active)] text-[var(--text-muted)]";
|
|
icon.innerHTML = token.mediaType === "audio"
|
|
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-3 w-3"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>'
|
|
: '<svg viewBox="0 0 24 24" fill="currentColor" class="h-3 w-3"><path d="M8 5v14l11-7z"/></svg>';
|
|
return icon;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
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] cursor-grab 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)] active:cursor-grabbing";
|
|
|
|
const leadingVisual = createReferenceTagLeadingVisual(token);
|
|
if (leadingVisual) tag.appendChild(leadingVisual);
|
|
|
|
const label = document.createElement("span");
|
|
label.contentEditable = "false";
|
|
label.setAttribute("contenteditable", "false");
|
|
label.className = "min-w-0 truncate";
|
|
// Hide the leading "@" visually while keeping it in the DOM text, so caret/offset math
|
|
// (which counts rendered text via Range.toString) stays aligned with the prompt string
|
|
// that includes "@". Stripping it from textContent shifts every downstream offset.
|
|
const atMatch = token.label.match(/^(@+)([\s\S]*)$/);
|
|
if (atMatch) {
|
|
const hiddenAt = document.createElement("span");
|
|
hiddenAt.textContent = atMatch[1];
|
|
hiddenAt.style.display = "none";
|
|
label.appendChild(hiddenAt);
|
|
label.appendChild(document.createTextNode(atMatch[2]));
|
|
} else {
|
|
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";
|
|
}
|
|
|
|
const REFERENCE_TAG_DRAG_THRESHOLD_PX = 4;
|
|
|
|
function getReferenceTagAncestor(node: Node | null, root: HTMLElement): HTMLElement | null {
|
|
let current: Node | null = node;
|
|
while (current && current !== root) {
|
|
if (isReferenceTagNode(current)) return current;
|
|
current = current.parentNode;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function caretRangeFromPoint(x: number, y: number): Range | null {
|
|
const doc = document as Document & {
|
|
caretRangeFromPoint?: (x: number, y: number) => Range | null;
|
|
caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null;
|
|
};
|
|
if (typeof doc.caretRangeFromPoint === "function") {
|
|
return doc.caretRangeFromPoint(x, y);
|
|
}
|
|
if (typeof doc.caretPositionFromPoint === "function") {
|
|
const position = doc.caretPositionFromPoint(x, y);
|
|
if (!position) return null;
|
|
const range = document.createRange();
|
|
range.setStart(position.offsetNode, position.offset);
|
|
range.collapse(true);
|
|
return range;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Resolve the collapsed caret range for a drop point, snapping outside any reference tag
|
|
* so a dragged tag is never inserted inside another tag.
|
|
*/
|
|
function resolveReferenceDrop(
|
|
element: HTMLDivElement,
|
|
draggedTag: HTMLElement,
|
|
clientX: number,
|
|
clientY: number
|
|
): Range | null {
|
|
const range = caretRangeFromPoint(clientX, clientY);
|
|
if (!range || !element.contains(range.startContainer)) return null;
|
|
|
|
const tagAncestor = getReferenceTagAncestor(range.startContainer, element);
|
|
if (tagAncestor === draggedTag) return null;
|
|
if (tagAncestor) {
|
|
const tagRect = tagAncestor.getBoundingClientRect();
|
|
if (clientX > tagRect.left + tagRect.width / 2) {
|
|
range.setStartAfter(tagAncestor);
|
|
} else {
|
|
range.setStartBefore(tagAncestor);
|
|
}
|
|
range.collapse(true);
|
|
}
|
|
return range;
|
|
}
|
|
|
|
/**
|
|
* Floating drag preview that follows the pointer — a clone of the whole tag
|
|
* (cover image + label), so the user drags a visual copy of the chip itself.
|
|
*/
|
|
function createReferenceDragPreview(tag: HTMLElement): HTMLElement {
|
|
const preview = document.createElement("div");
|
|
preview.style.position = "fixed";
|
|
preview.style.left = "0";
|
|
preview.style.top = "0";
|
|
preview.style.zIndex = "10090";
|
|
preview.style.pointerEvents = "none";
|
|
preview.style.opacity = "0.9";
|
|
preview.style.willChange = "transform";
|
|
|
|
const clone = tag.cloneNode(true) as HTMLElement;
|
|
clone.style.opacity = "1";
|
|
clone.style.margin = "0";
|
|
clone.style.boxShadow = "0 8px 24px rgba(0, 0, 0, 0.4)";
|
|
preview.appendChild(clone);
|
|
|
|
document.body.appendChild(preview);
|
|
return preview;
|
|
}
|
|
|
|
function positionReferenceDragPreview(preview: HTMLElement, clientX: number, clientY: number): void {
|
|
const width = preview.offsetWidth;
|
|
const height = preview.offsetHeight;
|
|
preview.style.transform = `translate(${clientX - width / 2}px, ${clientY - height - 2}px)`;
|
|
}
|
|
|
|
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 pointerDragCleanupRef = useRef<(() => void) | 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 handleReferenceTagPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
|
// Preserve the canvas-interaction guard for every pointer down on the editor.
|
|
event.stopPropagation();
|
|
if (readOnly || event.button !== 0 || !event.isPrimary) return;
|
|
const editor = inputRef.current;
|
|
if (!editor) return;
|
|
const tag = getReferenceTagAncestor(event.target as Node, editor);
|
|
if (!tag) return;
|
|
|
|
// Take over from contentEditable so the chip drags instead of placing a caret.
|
|
event.preventDefault();
|
|
closeReferenceMenu();
|
|
const pointerId = event.pointerId;
|
|
const startX = event.clientX;
|
|
const startY = event.clientY;
|
|
let dragging = false;
|
|
let preview: HTMLElement | null = null;
|
|
let handleMove: (moveEvent: PointerEvent) => void;
|
|
let handleUp: (upEvent: PointerEvent) => void;
|
|
let handleCancel: (cancelEvent: PointerEvent) => void;
|
|
|
|
const cleanup = () => {
|
|
window.removeEventListener("pointermove", handleMove);
|
|
window.removeEventListener("pointerup", handleUp);
|
|
window.removeEventListener("pointercancel", handleCancel);
|
|
tag.style.opacity = "";
|
|
if (preview) {
|
|
preview.remove();
|
|
preview = null;
|
|
}
|
|
pointerDragCleanupRef.current = null;
|
|
};
|
|
|
|
handleMove = (moveEvent) => {
|
|
if (moveEvent.pointerId !== pointerId) return;
|
|
if (!dragging) {
|
|
if (Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY) < REFERENCE_TAG_DRAG_THRESHOLD_PX) return;
|
|
dragging = true;
|
|
editor.focus({ preventScroll: true });
|
|
tag.style.opacity = "0.35";
|
|
preview = createReferenceDragPreview(tag);
|
|
}
|
|
moveEvent.preventDefault();
|
|
if (preview) positionReferenceDragPreview(preview, moveEvent.clientX, moveEvent.clientY);
|
|
const dropRange = resolveReferenceDrop(editor, tag, moveEvent.clientX, moveEvent.clientY);
|
|
if (dropRange) {
|
|
// Move the real input caret to the drop point so it visibly follows the pointer.
|
|
const selection = window.getSelection();
|
|
if (selection) {
|
|
selection.removeAllRanges();
|
|
selection.addRange(dropRange.cloneRange());
|
|
}
|
|
}
|
|
};
|
|
|
|
handleUp = (upEvent) => {
|
|
if (upEvent.pointerId !== pointerId) return;
|
|
const wasDragging = dragging;
|
|
const dropX = upEvent.clientX;
|
|
const dropY = upEvent.clientY;
|
|
cleanup();
|
|
if (!wasDragging || !editor.contains(tag)) return;
|
|
const dropRange = resolveReferenceDrop(editor, tag, dropX, dropY);
|
|
if (!dropRange) return;
|
|
dropRange.insertNode(tag);
|
|
editor.normalize();
|
|
emitDomValue(editor);
|
|
window.requestAnimationFrame(() => {
|
|
if (!tag.isConnected) return;
|
|
const caretRange = document.createRange();
|
|
caretRange.setStartAfter(tag);
|
|
caretRange.collapse(true);
|
|
const selection = window.getSelection();
|
|
selection?.removeAllRanges();
|
|
selection?.addRange(caretRange);
|
|
});
|
|
};
|
|
|
|
handleCancel = (cancelEvent) => {
|
|
if (cancelEvent.pointerId !== pointerId) return;
|
|
cleanup();
|
|
};
|
|
|
|
pointerDragCleanupRef.current = cleanup;
|
|
window.addEventListener("pointermove", handleMove);
|
|
window.addEventListener("pointerup", handleUp);
|
|
window.addEventListener("pointercancel", handleCancel);
|
|
}, [closeReferenceMenu, emitDomValue, readOnly]);
|
|
|
|
useEffect(() => () => {
|
|
pointerDragCleanupRef.current?.();
|
|
}, []);
|
|
|
|
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={handleReferenceTagPointerDown}
|
|
onMouseDownCapture={stopCanvasInteraction}
|
|
onWheelCapture={stopCanvasInteraction}
|
|
style={{
|
|
minHeight: minHeightPx,
|
|
maxHeight: maxHeightPx,
|
|
whiteSpace: "pre-wrap",
|
|
}}
|
|
className={className}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|