Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-resource-mention-textarea.tsx
T
QAyong 5537fae73b feat(canvas): Agent 面板 - 支持回车发送并让技能菜单接受中文顿号
画布 Agent 面板的输入框此前有两个中文用户的体验问题:Enter 不发送(只换行),
且技能候选菜单只认 "/" 触发,中文标点状态下按 "/" 打出的是 "、",必须先切回英文。

回车发送:

- canvas-resource-mention-textarea.tsx:sendOnEnter 由 boolean 扩展为 boolean | "both",
  新增 shouldSubmitOnEnter() 统一判定;原来 contentEditable 与 textarea 两处各写一份表达式。
  false 仅 ⌘/Ctrl+Enter 提交(原行为),"both" 两者都提交,Shift+Enter 始终换行。默认仍为 true,
  首页智能创作、批量创作表、短剧工作台三个调用方键位不变。
- canvas-cloud-agent-chat-ui.tsx:改传 sendOnEnter={canSubmit ? "both" : false},
  运行中 Enter 保持换行、不误发;提示文案改为「Enter 发送 · Shift+Enter 换行」,发送按钮 tooltip 同步。
- canvas-cloud-agent.css:快捷键提示原本在 @container (max-width: 520px) 下整条 display: none,
  而默认面板宽度 448px 正落在该区间,等于开箱即用时看不到任何发送键说明;
  改为 .agent-composer-send-hint-full / -compact 两段,窄态降级为短文案「Enter 发送」。

中文顿号触发技能菜单:

- canvas-cloud-agent-chat-ui.tsx:handlePromptChange 的检测正则由 /(^|\s)\/([^\s/]*)$/
  改为 /(^|\s)[/、]([^\s/、]*)$/。保留「行首或紧跟空白」前置条件,
  中文正文里的顿号(如「苹果、香蕉」)不会误触发;applySlashSkill 的替换长度仍为 1,两个触发符都是单字符。
- canvas-cloud-agent-panel.tsx:placeholder 改为「用 / 或 、 引用 Skills」,让两种触发方式可发现。
- canvas-resource-mention-editor.test.ts:同步源码断言(键位、两段提示类名、触发正则、placeholder)。

验证:

- cd web && bun run typecheck 通过。
- bun run lint(改动文件)通过;改动文件按 prettier 校验通过(其余四个文件基线即未格式化,CI 会跳过)。
- bun test test/canvas-resource-mention-editor.test.ts:10 通过 / 1 失败。失败项是同文件既有断言
  expect(css).not.toContain(".agent-tool-row:hover"),而 main 上 canvas-cloud-agent.css 本就含该选择器,
  属于基线既有失败,与本次改动无关(未在本 PR 内顺手修改,避免扩大范围)。
- 触发正则边界用独立脚本实测:、夜景 / 、夜景(前置空白)/ abc /夜 / a\n、x / " @节点 /夜景" 均正确给出
  start 与 query;苹果、香蕉 / abc、夜景 / 、a、 不触发。
- 未启动开发服务,浏览器键位与实际菜单行为待人工验收。
2026-09-19 03:31:41 +08:00

1142 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, MouseEvent, PointerEvent, TextareaHTMLAttributes } from "react";
import { createPortal } from "react-dom";
import { ArrowLeft, ChevronRight, FileText, Folder, Image as ImageIcon, Music2, Pencil, Search, UserRound, Video, Workflow } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { ASSET_CATEGORY_LABELS } from "@/lib/asset-category";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import { buildAssetMentionReferences, canvasResourceMentionToken, findCanvasResourceAutoLinkMatch, type CanvasResourceAutoLinkMatch, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { useAssetStore, type AssetCategory } from "@/stores/use-asset-store";
import { CanvasNodeType } from "@/types/canvas";
import { useResolvedCanvasResourceReferences } from "./use-resolved-canvas-resource-references";
type MentionState = {
start: number;
end: number;
query: string;
};
type EditableSelection = {
start: number;
end: number;
};
type MentionTextPart =
| {
type: "text";
text: string;
}
| {
type: "mention";
token: string;
reference: CanvasResourceReference;
};
type Props = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "onChange" | "value"> & {
value: string;
references: CanvasResourceReference[];
onSelectReference?: (reference: CanvasResourceReference) => CanvasResourceReference | undefined;
onChange: (value: string) => void;
onSubmit?: () => void;
containerClassName?: string;
highlightLabels?: boolean;
mentionMenuWidth?: number;
sendOnEnter?: boolean | "both";
onContentSizeChange?: (height: number) => void;
includeAssetLibrary?: boolean;
activeDropReferenceId?: string | null;
onReferenceFilesDrop?: (reference: CanvasResourceReference, files: File[]) => void;
autoLinkEnabled?: boolean;
};
// 回车提交语义由调用方决定:false 只在 ⌘/Ctrl+Enter 提交,"both" 两种都提交;Shift+Enter 始终换行。
function shouldSubmitOnEnter(event: { key: string; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }, sendOnEnter: boolean | "both") {
if (event.key !== "Enter" || event.shiftKey) return false;
const modifier = event.ctrlKey || event.metaKey;
if (sendOnEnter === false) return modifier;
if (sendOnEnter === "both") return true;
return !modifier;
}
export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Props>(function CanvasResourceMentionTextarea(
{ value, references, onSelectReference, onChange, onSubmit, onKeyDown, className, containerClassName, style, highlightLabels = true, mentionMenuWidth = 320, sendOnEnter = true, onContentSizeChange, includeAssetLibrary = false, activeDropReferenceId, onReferenceFilesDrop, autoLinkEnabled = false, ...props },
forwardedRef,
) {
const rawTheme = useActiveTheme();
const assets = useAssetStore((state) => state.assets);
const theme = canvasThemes[rawTheme as keyof typeof canvasThemes] ?? canvasThemes.dark;
const containerRef = useRef<HTMLDivElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const editorRef = useRef<HTMLDivElement | null>(null);
const composingRef = useRef(false);
const pendingSelectionRef = useRef<number | null>(null);
const pendingScrollTopRef = useRef<number | null>(null);
const lastRenderedValueRef = useRef("");
const [mention, setMention] = useState<MentionState | null>(null);
const [activeIndex, setActiveIndex] = useState(-1);
const [autoLinkCursor, setAutoLinkCursor] = useState<number | null>(null);
const autoLinkSuggestionRef = useRef<HTMLButtonElement | null>(null);
const [autoLinkPosition, setAutoLinkPosition] = useState<{ left: number; top: number } | null>(null);
const [nativeDropReferenceId, setNativeDropReferenceId] = useState<string | null>(null);
const [previewReference, setPreviewReference] = useState<CanvasResourceReference | null>(null);
const canvasReferences = useResolvedCanvasResourceReferences(references);
const rawAssetReferences = useMemo(() => includeAssetLibrary ? buildAssetMentionReferences(assets) : [], [assets, includeAssetLibrary]);
const assetReferences = useResolvedCanvasResourceReferences(rawAssetReferences);
const activeCanvasReferences = useMemo(() => canvasReferences.filter((item) => item.active), [canvasReferences]);
const availableReferences = useMemo(() => [...(onSelectReference ? canvasReferences : activeCanvasReferences), ...assetReferences], [onSelectReference, canvasReferences, activeCanvasReferences, assetReferences]);
const candidates = useMemo(() => {
if (!mention) return [];
const query = mention.query.trim().toLowerCase();
if (!query) return onSelectReference ? canvasReferences : activeCanvasReferences;
return availableReferences.filter((item) => `${item.label} ${item.title} ${item.kind} ${item.category || ""} ${item.text || ""}`.toLowerCase().includes(query));
}, [onSelectReference, canvasReferences, activeCanvasReferences, availableReferences, mention]);
const activeReferences = useMemo(() => {
if (!highlightLabels) return [];
return [...activeCanvasReferences, ...assetReferences.filter((item) => value.includes(canvasResourceMentionToken(item)))];
}, [activeCanvasReferences, assetReferences, highlightLabels, value]);
const useRichEditor = Boolean(activeReferences.length);
const autoLinkMatch = useMemo<CanvasResourceAutoLinkMatch | null>(() => autoLinkEnabled && autoLinkCursor !== null ? findCanvasResourceAutoLinkMatch(value, autoLinkCursor, activeCanvasReferences) : null, [activeCanvasReferences, autoLinkCursor, autoLinkEnabled, value]);
const reportContentSize = useCallback((element: HTMLElement | null) => {
if (!element || !onContentSizeChange) return;
const previous = { height: element.style.height, minHeight: element.style.minHeight, maxHeight: element.style.maxHeight, overflow: element.style.overflow };
element.style.height = "1px";
element.style.minHeight = "0";
element.style.maxHeight = "none";
element.style.overflow = "hidden";
const height = Math.ceil(element.scrollHeight);
element.style.height = previous.height;
element.style.minHeight = previous.minHeight;
element.style.maxHeight = previous.maxHeight;
element.style.overflow = previous.overflow;
onContentSizeChange(height);
}, [onContentSizeChange]);
useLayoutEffect(() => {
if (!useRichEditor) pendingScrollTopRef.current = null;
}, [useRichEditor, value]);
useLayoutEffect(() => {
if (!useRichEditor) return;
const editor = editorRef.current;
if (!editor || composingRef.current) return;
const isFocused = document.activeElement === editor;
const currentValue = serializeEditableValue(editor);
if (currentValue === value && lastRenderedValueRef.current === value) {
syncInlineMentionPreviews(editor, activeReferences);
pendingSelectionRef.current = null;
return;
}
const selection = pendingSelectionRef.current ?? (isFocused ? getEditableSelection(editor)?.start ?? null : null);
const scrollTop = pendingScrollTopRef.current;
renderEditableContent(editor, value, activeReferences);
lastRenderedValueRef.current = value;
if (isFocused && selection !== null) setEditableSelection(editor, selection);
pendingSelectionRef.current = null;
reportContentSize(editor);
if (scrollTop !== null) editor.scrollTop = scrollTop;
pendingScrollTopRef.current = null;
}, [activeReferences, reportContentSize, useRichEditor, value]);
useLayoutEffect(() => {
const anchor = useRichEditor ? editorRef.current : textareaRef.current;
const suggestion = autoLinkSuggestionRef.current;
if (!autoLinkMatch || !anchor || !suggestion) {
setAutoLinkPosition(null);
return;
}
const updatePosition = () => {
const caret = mentionCaretRect(anchor, autoLinkMatch.end);
const bounds = suggestion.getBoundingClientRect();
setAutoLinkPosition({
left: clamp(caret.right + 8, 12, window.innerWidth - bounds.width - 12),
top: clamp(caret.bottom + 4, 12, window.innerHeight - bounds.height - 12),
});
};
updatePosition();
const observer = new ResizeObserver(updatePosition);
observer.observe(anchor);
observer.observe(suggestion);
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
return () => {
observer.disconnect();
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
};
}, [autoLinkMatch, useRichEditor, value]);
useLayoutEffect(() => {
const editor = editorRef.current;
if (!editor) return;
const dropReferenceId = nativeDropReferenceId || activeDropReferenceId || "";
editor.querySelectorAll<HTMLElement>("[data-mention-reference-id]").forEach((chip) => {
chip.classList.toggle("is-replace-target", Boolean(dropReferenceId) && chip.dataset.mentionReferenceId === dropReferenceId);
});
}, [activeDropReferenceId, nativeDropReferenceId, useRichEditor, value]);
useLayoutEffect(() => {
const element = useRichEditor ? editorRef.current : textareaRef.current;
const container = containerRef.current;
if (!element || !container || !onContentSizeChange) return;
reportContentSize(element);
let width = container.clientWidth;
const observer = new ResizeObserver(() => {
const nextWidth = container.clientWidth;
if (nextWidth === width) return;
width = nextWidth;
reportContentSize(element);
});
observer.observe(container);
return () => observer.disconnect();
}, [onContentSizeChange, reportContentSize, useRichEditor, value]);
const focusEditor = (selectionStart?: number) => {
requestAnimationFrame(() => {
const editor = editorRef.current;
if (editor) {
const scrollTop = editor.scrollTop;
editor.focus({ preventScroll: true });
if (typeof selectionStart === "number") setEditableSelection(editor, selectionStart);
editor.scrollTop = scrollTop;
return;
}
const textarea = textareaRef.current;
if (!textarea) return;
const scrollTop = textarea.scrollTop;
textarea.focus({ preventScroll: true });
if (typeof selectionStart === "number") textarea.setSelectionRange(selectionStart, selectionStart);
textarea.scrollTop = scrollTop;
});
};
const updateValue = (next: string, selectionStart?: number) => {
if (typeof selectionStart === "number") pendingSelectionRef.current = selectionStart;
const editor = editorRef.current ?? textareaRef.current;
pendingScrollTopRef.current = editor?.scrollTop ?? null;
onChange(next);
if (typeof selectionStart === "number") focusEditor(selectionStart);
};
const closeMention = () => {
setMention(null);
setActiveIndex(-1);
};
const syncMention = (nextValue: string, cursor: number) => {
setAutoLinkCursor(cursor);
const prefix = nextValue.slice(0, cursor);
const match = /@([^\s@,.;:!?,。;:!?、)\]}】)]*)$/.exec(prefix);
if (!match || !availableReferences.length) {
closeMention();
return;
}
const nextMention = { start: match.index, end: cursor, query: match[1] };
const isSameMention = mention?.start === nextMention.start && mention.end === nextMention.end && mention.query === nextMention.query;
if (!isSameMention) {
setMention(nextMention);
setActiveIndex(-1);
}
};
const insertReference = (reference: CanvasResourceReference) => {
if (!mention) return;
const selected = onSelectReference ? onSelectReference(reference) : reference;
if (!selected) return;
const insertText = `${canvasResourceMentionToken(selected)} `;
const next = `${value.slice(0, mention.start)}${insertText}${value.slice(mention.end)}`;
closeMention();
updateValue(next, mention.start + insertText.length);
};
const replaceEditableSelection = (insertText: string) => {
const currentValue = editorRef.current ? serializeEditableValue(editorRef.current) : value;
const textarea = textareaRef.current;
const selection = getEditableSelection(editorRef.current)
|| (textarea ? { start: textarea.selectionStart, end: textarea.selectionEnd } : null)
|| { start: currentValue.length, end: currentValue.length };
const next = `${currentValue.slice(0, selection.start)}${insertText}${currentValue.slice(selection.end)}`;
const cursor = selection.start + insertText.length;
updateValue(next, cursor);
syncMention(next, cursor);
};
const insertAutoLink = (match: CanvasResourceAutoLinkMatch) => {
const currentValue = editorRef.current ? serializeEditableValue(editorRef.current) : value;
const insertText = `${canvasResourceMentionToken(match.reference)} `;
const next = `${currentValue.slice(0, match.start)}${insertText}${currentValue.slice(match.end)}`;
updateValue(next, match.start + insertText.length);
setAutoLinkCursor(match.start + insertText.length);
};
const autoLinkSuggestion = autoLinkMatch ? createPortal(
<button
ref={autoLinkSuggestionRef}
type="button"
data-canvas-no-zoom
className="fixed z-[var(--z-tooltip)] inline-flex max-w-[min(360px,calc(100vw-24px))] items-center gap-1.5 rounded-md border border-current/15 px-2 py-1 text-[var(--fs-micro)] shadow-sm"
style={{ ...autoLinkPosition, visibility: autoLinkPosition ? "visible" : "hidden", background: theme.node.panel, color: theme.node.text }}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => { event.preventDefault(); event.stopPropagation(); }}
onClick={(event) => { event.stopPropagation(); insertAutoLink(autoLinkMatch); }}
aria-label={`引用${autoLinkMatch.reference.label}`}
>
<span className="truncate">引用「{autoLinkMatch.query}」→ @{autoLinkMatch.reference.label}</span>
<kbd className="shrink-0 rounded border border-current/20 px-1 font-mono text-[var(--fs-micro)]">Tab</kbd>
</button>,
document.body,
) : null;
const syncEditableValue = () => {
if (composingRef.current) return;
const editor = editorRef.current;
if (!editor) return;
const next = serializeEditableValue(editor);
const cursor = getEditableSelection(editor)?.start ?? next.length;
pendingSelectionRef.current = cursor;
lastRenderedValueRef.current = next;
onChange(next);
syncMention(next, cursor);
reportContentSize(editor);
};
const syncEditableMentionFromSelection = () => {
const editor = editorRef.current;
if (!editor) return;
const selection = getEditableSelection(editor);
if (!selection || selection.start !== selection.end) {
setAutoLinkCursor(null);
return;
}
syncMention(serializeEditableValue(editor), selection.start);
};
const referenceForDropTarget = (target: EventTarget | null) => {
const element = target instanceof Element ? target.closest<HTMLElement>("[data-mention-reference-id]") : null;
const referenceId = element?.dataset.mentionReferenceId;
return referenceId ? activeReferences.find((reference) => reference.id === referenceId) : undefined;
};
const imageFilesFromTransfer = (event: DragEvent<HTMLDivElement>) => Array.from(event.dataTransfer.files).filter((file) => file.type.startsWith("image/"));
const mergedStyle = {
...(style || {}),
caretColor: style?.color || theme.node.text,
} as CSSProperties;
const menuAnchor = useRichEditor ? editorRef.current : textareaRef.current;
const menu = mention && availableReferences.length && menuAnchor ? (
<MentionMenu
anchor={menuAnchor}
connectedReferences={activeCanvasReferences}
assetReferences={assetReferences}
filteredReferences={candidates}
query={mention.query}
cursorOffset={mention.end}
activeReferenceId={activeIndex >= 0 ? candidates[Math.min(activeIndex, candidates.length - 1)]?.id : undefined}
preferredWidth={mentionMenuWidth}
onQueryChange={(query) => setMention((current) => current ? { ...current, query } : current)}
onClose={closeMention}
onSelect={insertReference}
/>
) : null;
if (useRichEditor) {
return (
<div ref={containerRef} data-canvas-no-zoom className={`relative w-full min-h-0 overflow-hidden ${containerClassName || "h-full"}`}>
{!value && props.placeholder ? (
<div aria-hidden className={`${className || ""} pointer-events-none absolute inset-0 z-0`} style={{ ...style, color: style?.color || theme.node.text, opacity: 0.4 }}>
{props.placeholder}
</div>
) : null}
<div
ref={editorRef}
role="textbox"
aria-multiline="true"
aria-label={props["aria-label"]}
aria-disabled={props.disabled}
contentEditable={!props.disabled && !props.readOnly}
suppressContentEditableWarning
spellCheck={props.spellCheck}
tabIndex={props.tabIndex}
className={`${className || ""} relative z-10 cursor-text select-text whitespace-pre-wrap break-words`}
style={{
...mergedStyle,
color: style?.color || theme.node.text,
height: "100%",
minHeight: 0,
maxHeight: "100%",
overflowY: "auto",
overflowX: "hidden",
}}
onInput={syncEditableValue}
onCompositionStart={(event) => {
composingRef.current = true;
props.onCompositionStart?.(event as unknown as React.CompositionEvent<HTMLTextAreaElement>);
}}
onCompositionEnd={(event) => {
composingRef.current = false;
syncEditableValue();
props.onCompositionEnd?.(event as unknown as React.CompositionEvent<HTMLTextAreaElement>);
}}
onPaste={(event: ClipboardEvent<HTMLDivElement>) => {
event.preventDefault();
replaceEditableSelection(event.clipboardData.getData("text/plain"));
}}
onDragOver={(event: DragEvent<HTMLDivElement>) => {
const reference = referenceForDropTarget(event.target);
const hasImageFile = Array.from(event.dataTransfer.items).some((item) => item.kind === "file" && (!item.type || item.type.startsWith("image/")))
|| Array.from(event.dataTransfer.files).some((file) => file.type.startsWith("image/"));
if (!onReferenceFilesDrop || reference?.kind !== "image" || !hasImageFile) {
setNativeDropReferenceId(null);
props.onDragOver?.(event as unknown as React.DragEvent<HTMLTextAreaElement>);
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
setNativeDropReferenceId(reference.id);
props.onDragOver?.(event as unknown as React.DragEvent<HTMLTextAreaElement>);
}}
onDragLeave={(event: DragEvent<HTMLDivElement>) => {
const bounds = event.currentTarget.getBoundingClientRect();
if (event.clientX <= bounds.left || event.clientX >= bounds.right || event.clientY <= bounds.top || event.clientY >= bounds.bottom) setNativeDropReferenceId(null);
props.onDragLeave?.(event as unknown as React.DragEvent<HTMLTextAreaElement>);
}}
onDrop={(event: DragEvent<HTMLDivElement>) => {
const reference = referenceForDropTarget(event.target);
const files = imageFilesFromTransfer(event);
setNativeDropReferenceId(null);
if (onReferenceFilesDrop && reference?.kind === "image" && files.length) {
event.preventDefault();
onReferenceFilesDrop(reference, files);
}
props.onDrop?.(event as unknown as React.DragEvent<HTMLTextAreaElement>);
}}
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.current || event.keyCode === 229)) return;
if (autoLinkMatch && event.key === "Tab" && !event.shiftKey && !event.nativeEvent.isComposing && !composingRef.current) {
event.preventDefault();
insertAutoLink(autoLinkMatch);
return;
}
if (mention && candidates.length) {
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveIndex((index) => index < 0 ? 0 : (index + 1) % candidates.length);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex((index) => index < 0 ? candidates.length - 1 : (index - 1 + candidates.length) % candidates.length);
return;
}
if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
insertReference(candidates[activeIndex < 0 ? 0 : Math.min(activeIndex, candidates.length - 1)]);
return;
}
if (event.key === "Escape") {
event.preventDefault();
closeMention();
return;
}
}
if (event.key === "Enter") {
event.preventDefault();
const shouldSubmit = shouldSubmitOnEnter(event, sendOnEnter);
if (onSubmit && shouldSubmit) {
onSubmit();
return;
}
replaceEditableSelection("\n");
return;
}
onKeyDown?.(event as unknown as React.KeyboardEvent<HTMLTextAreaElement>);
}}
onKeyUp={(event) => {
syncEditableMentionFromSelection();
props.onKeyUp?.(event as unknown as React.KeyboardEvent<HTMLTextAreaElement>);
}}
onMouseDown={(event) => props.onMouseDown?.(event as unknown as React.MouseEvent<HTMLTextAreaElement>)}
onPointerDown={(event) => props.onPointerDown?.(event as unknown as React.PointerEvent<HTMLTextAreaElement>)}
onDoubleClick={(event) => {
const chip = event.target instanceof Element ? event.target.closest<HTMLElement>("[data-mention-reference-id]") : null;
const reference = chip ? availableReferences.find((item) => item.id === chip.dataset.mentionReferenceId) : undefined;
if (!reference || !referencePreviewUrl(reference)) return;
event.preventDefault();
event.stopPropagation();
setPreviewReference(reference);
}}
onPointerUp={(event) => {
syncEditableMentionFromSelection();
props.onPointerUp?.(event as unknown as React.PointerEvent<HTMLTextAreaElement>);
}}
onSelect={(event) => props.onSelect?.(event as unknown as React.SyntheticEvent<HTMLTextAreaElement>)}
onWheel={(event) => {
event.stopPropagation();
props.onWheel?.(event as unknown as React.WheelEvent<HTMLTextAreaElement>);
}}
onScroll={(event) => props.onScroll?.(event as unknown as React.UIEvent<HTMLTextAreaElement>)}
onFocus={(event) => props.onFocus?.(event as unknown as React.FocusEvent<HTMLTextAreaElement>)}
onBlur={(event) => {
setAutoLinkCursor(null);
if (event.relatedTarget instanceof Element && event.relatedTarget.closest("[data-canvas-resource-mention-menu]")) return;
window.setTimeout(() => {
if (document.activeElement?.closest("[data-canvas-resource-mention-menu]")) return;
closeMention();
}, 120);
props.onBlur?.(event as unknown as React.FocusEvent<HTMLTextAreaElement>);
}}
>
</div>
{autoLinkSuggestion}
{menu}
{previewReference ? <InlineReferencePreview reference={previewReference} onClose={() => setPreviewReference(null)} /> : null}
</div>
);
}
return (
<div ref={containerRef} data-canvas-no-zoom className={`relative w-full min-h-0 overflow-hidden ${containerClassName || "h-full"}`}>
<textarea
{...props}
ref={(node) => {
textareaRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
}}
value={value}
className={`${className || ""} relative z-10`}
style={{ ...mergedStyle, height: "100%", minHeight: 0, maxHeight: "100%", overflowY: "auto", overflowX: "hidden" }}
onChange={(event) => {
const next = event.target.value;
onChange(next);
syncMention(next, event.target.selectionStart);
reportContentSize(event.currentTarget);
}}
onCompositionStart={(event) => {
composingRef.current = true;
props.onCompositionStart?.(event);
}}
onCompositionEnd={(event) => {
composingRef.current = false;
syncMention(event.currentTarget.value, event.currentTarget.selectionStart);
props.onCompositionEnd?.(event);
}}
onSelect={(event) => {
const textarea = event.currentTarget;
setAutoLinkCursor(textarea.selectionStart === textarea.selectionEnd ? textarea.selectionStart : null);
props.onSelect?.(event);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.current || event.keyCode === 229)) return;
if (autoLinkMatch && event.key === "Tab" && !event.shiftKey && !event.nativeEvent.isComposing && !composingRef.current) {
event.preventDefault();
insertAutoLink(autoLinkMatch);
return;
}
if (mention && candidates.length) {
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveIndex((index) => index < 0 ? 0 : (index + 1) % candidates.length);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex((index) => index < 0 ? candidates.length - 1 : (index - 1 + candidates.length) % candidates.length);
return;
}
if (event.key === "Enter") {
event.preventDefault();
insertReference(candidates[activeIndex < 0 ? 0 : Math.min(activeIndex, candidates.length - 1)]);
return;
}
if (event.key === "Escape") {
event.preventDefault();
closeMention();
return;
}
}
const shouldSubmit = shouldSubmitOnEnter(event, sendOnEnter);
if (shouldSubmit && onSubmit) {
event.preventDefault();
onSubmit();
return;
}
onKeyDown?.(event);
}}
onWheel={(event) => {
event.stopPropagation();
const textarea = event.currentTarget;
const deltaY = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaMode === 2 ? event.deltaY * textarea.clientHeight : event.deltaY;
if (deltaY) {
const previousTop = textarea.scrollTop;
textarea.scrollTop += deltaY;
if (textarea.scrollTop !== previousTop) event.preventDefault();
}
props.onWheel?.(event);
}}
onBlur={(event) => {
setAutoLinkCursor(null);
if (event.relatedTarget instanceof Element && event.relatedTarget.closest("[data-canvas-resource-mention-menu]")) return;
window.setTimeout(() => {
if (document.activeElement?.closest("[data-canvas-resource-mention-menu]")) return;
closeMention();
}, 120);
props.onBlur?.(event);
}}
/>
{autoLinkSuggestion}
{menu}
</div>
);
});
function createInlineMentionChip(reference: CanvasResourceReference, token: string) {
const chip = document.createElement("span");
chip.contentEditable = "false";
chip.dataset.mentionToken = token;
chip.dataset.mentionReferenceId = reference.id;
chip.className = `canvas-resource-inline-mention ${reference.kind === "skill" ? "is-skill" : ""}`;
chip.title = "双击放大预览";
if (reference.kind === "skill") chip.style.setProperty("--canvas-skill-mention-color", skillMentionColor(reference));
const prefix = document.createElement("span");
prefix.className = reference.kind === "skill" ? "canvas-resource-inline-skill-icon" : "canvas-resource-inline-at";
// “/” is an input command, not part of the selected Skill name. Keep the
// command token in the serialized value, but render the chip as a normal
// icon + label so it remains readable after selection and submission.
prefix.textContent = reference.kind === "skill" ? "✦" : "@";
chip.appendChild(prefix);
// Skill chip 的前缀已经承担图标职责,不再追加 fallback preview,避免出现两个星标。
if (reference.kind !== "skill") chip.appendChild(createInlinePreview(reference));
const label = document.createElement("span");
label.className = "canvas-resource-inline-label";
label.textContent = reference.label;
chip.appendChild(label);
return chip;
}
const SKILL_MENTION_COLORS = ["#8b5cf6", "#0ea5e9", "#14b8a6", "#f59e0b", "#ec4899", "#84cc16", "#f97316", "#06b6d4"];
function skillMentionColor(reference: CanvasResourceReference) {
const key = reference.skill?.skillId || reference.id;
let hash = 0;
for (const character of key) hash = (hash * 31 + character.charCodeAt(0)) | 0;
return SKILL_MENTION_COLORS[Math.abs(hash) % SKILL_MENTION_COLORS.length];
}
function referencePreviewUrl(reference: CanvasResourceReference) {
return reference.previewUrl || (reference.kind === "video" ? reference.mediaUrl : "") || "";
}
function InlineReferencePreview({ reference, onClose }: { reference: CanvasResourceReference; onClose: () => void }) {
const url = referencePreviewUrl(reference);
if (!url) return null;
return createPortal(
<div className="fixed inset-0 z-[var(--z-dialog-popover)] grid place-items-center bg-black/80 p-6" role="dialog" aria-label={`预览${reference.label}`} onClick={onClose}>
<div className="relative max-h-[92vh] max-w-[92vw]" onClick={(event) => event.stopPropagation()}>
<img src={url} alt={reference.label} className="max-h-[88vh] max-w-[88vw] rounded-xl object-contain shadow-2xl" />
<button type="button" className="absolute -right-3 -top-3 rounded-full bg-black/75 p-2 text-white shadow-lg" onClick={onClose} aria-label="关闭图片预览">×</button>
</div>
</div>,
document.body,
);
}
function createInlinePreview(reference: CanvasResourceReference) {
if ((reference.kind === "image" || reference.kind === "video" || reference.kind === "character") && reference.previewUrl) {
const media = document.createElement("img");
media.className = `canvas-resource-inline-preview is-${reference.kind}`;
media.setAttribute("src", reference.previewUrl);
media.setAttribute("alt", "");
return media;
}
if (reference.kind === "video" && reference.mediaUrl) {
const media = document.createElement("video");
media.className = "canvas-resource-inline-preview is-video";
media.setAttribute("src", reference.mediaUrl);
media.setAttribute("aria-hidden", "true");
media.muted = true;
media.playsInline = true;
media.preload = "metadata";
media.onloadedmetadata = () => primeVideoPreviewFrame(media);
return media;
}
const fallback = document.createElement("span");
fallback.className = "canvas-resource-inline-preview is-fallback";
fallback.textContent = reference.sourceType === CanvasNodeType.Drawing ? "✎" : reference.kind === "audio" ? "♪" : reference.kind === "video" ? "▶" : reference.kind === "image" ? "□" : reference.kind === "skill" ? "✦" : "";
return fallback;
}
/** Resource URLs resolve independently of prompt text; keep chips fresh without replacing the editable selection. */
function syncInlineMentionPreviews(editor: HTMLElement, references: CanvasResourceReference[]) {
const byId = new Map(references.map((reference) => [reference.id, reference]));
editor.querySelectorAll<HTMLElement>("[data-mention-reference-id]").forEach((chip) => {
const reference = byId.get(chip.dataset.mentionReferenceId || "");
if (!reference) return;
const preview = chip.querySelector(".canvas-resource-inline-preview");
const hasImage = ["image", "video", "character"].includes(reference.kind) && Boolean(reference.previewUrl);
const hasVideo = !hasImage && reference.kind === "video" && Boolean(reference.mediaUrl);
const tag = hasImage ? "IMG" : hasVideo ? "VIDEO" : "SPAN";
const className = `canvas-resource-inline-preview is-${hasImage || hasVideo ? reference.kind : "fallback"}`;
const src = hasImage ? reference.previewUrl : hasVideo ? reference.mediaUrl : null;
if (preview && (preview.tagName !== tag || preview.className !== className || preview.getAttribute("src") !== src)) {
preview.replaceWith(createInlinePreview(reference));
}
const label = chip.querySelector(".canvas-resource-inline-label");
if (label && label.textContent !== reference.label) label.textContent = reference.label;
});
}
function MentionMenu({ anchor, connectedReferences, assetReferences, filteredReferences, query, cursorOffset, activeReferenceId, preferredWidth, onQueryChange, onClose, onSelect }: {
anchor: HTMLElement;
connectedReferences: CanvasResourceReference[];
assetReferences: CanvasResourceReference[];
filteredReferences: CanvasResourceReference[];
query: string;
cursorOffset: number;
activeReferenceId?: string;
preferredWidth: number;
onQueryChange: (query: string) => void;
onClose: () => void;
onSelect: (reference: CanvasResourceReference) => void;
}) {
const menuRef = useRef<HTMLDivElement | null>(null);
const selectedRef = useRef(false);
const [category, setCategory] = useState<AssetCategory | null>(null);
const [position, setPosition] = useState(() => mentionMenuPosition(anchor, cursorOffset, preferredWidth));
useLayoutEffect(() => {
let frame = 0;
const updatePosition = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => setPosition(mentionMenuPosition(anchor, cursorOffset, preferredWidth)));
};
updatePosition();
const observer = new ResizeObserver(updatePosition);
observer.observe(anchor);
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
return () => {
cancelAnimationFrame(frame);
observer.disconnect();
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
};
}, [anchor, cursorOffset, preferredWidth]);
const stopCanvasInteraction = (event: PointerEvent | MouseEvent) => {
event.stopPropagation();
};
const selectReference = (reference: CanvasResourceReference) => {
if (selectedRef.current) return;
selectedRef.current = true;
onSelect(reference);
};
const categoryItems = Object.entries(ASSET_CATEGORY_LABELS)
.map(([value, label]) => ({ value: value as AssetCategory, label, count: assetReferences.filter((item) => item.category === value).length }))
.filter((item) => item.count > 0);
const connectedNodes = connectedReferences.filter((item) => item.kind !== "skill");
const skillReferences = connectedReferences.filter((item) => item.kind === "skill");
const visibleReferences = query
? filteredReferences
: category
? assetReferences.filter((item) => item.category === category)
: [];
useLayoutEffect(() => {
const closeOnOutsidePointer = (event: globalThis.PointerEvent) => {
const target = event.target;
if (!(target instanceof Node) || menuRef.current?.contains(target) || anchor.contains(target)) return;
onClose();
};
window.addEventListener("pointerdown", closeOnOutsidePointer, true);
return () => window.removeEventListener("pointerdown", closeOnOutsidePointer, true);
}, [anchor, onClose]);
return createPortal(
<div
ref={menuRef}
data-canvas-resource-mention-menu="true"
className="canvas-resource-mention-menu fixed z-[var(--z-tooltip)]"
data-placement={position.showAbove ? "top" : "bottom"}
style={{ left: position.left, top: position.top, width: position.width, maxHeight: position.maxHeight, transform: position.showAbove ? "translateY(-100%)" : undefined }}
onPointerDown={stopCanvasInteraction}
onMouseDown={stopCanvasInteraction}
onClick={(event) => event.stopPropagation()}
>
<div className="canvas-resource-mention-search">
<Search aria-hidden />
<input
value={query}
placeholder="搜索节点、素材或技能"
aria-label="搜索引用素材"
onChange={(event) => onQueryChange(event.target.value)}
onPointerDown={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
anchor.focus();
return;
}
if (event.key === "Enter" && filteredReferences.length) {
event.preventDefault();
selectReference(filteredReferences[0]);
}
}}
/>
</div>
<div className="canvas-resource-mention-scroll thin-scrollbar">
{query ? (
<MentionReferenceList references={visibleReferences} activeReferenceId={activeReferenceId} onSelect={selectReference} />
) : category ? (
<>
<button type="button" className="canvas-resource-mention-back" onClick={() => setCategory(null)}>
<ArrowLeft aria-hidden />
<span>{ASSET_CATEGORY_LABELS[category]}</span>
<small>{visibleReferences.length}</small>
</button>
<MentionReferenceList references={visibleReferences} activeReferenceId={activeReferenceId} onSelect={selectReference} />
</>
) : (
<>
{connectedNodes.length ? (
<section className="canvas-resource-mention-section">
<h4><span>画布节点</span><small>{connectedNodes.length}</small></h4>
<MentionReferenceList references={connectedNodes} activeReferenceId={activeReferenceId} onSelect={selectReference} />
</section>
) : null}
{skillReferences.length ? (
<section className="canvas-resource-mention-section">
<h4><span>技能库</span><small>{skillReferences.length}</small></h4>
<MentionReferenceList references={skillReferences} activeReferenceId={activeReferenceId} onSelect={selectReference} />
</section>
) : null}
{categoryItems.length ? (
<section className="canvas-resource-mention-section">
<h4><span>素材库</span><small>{assetReferences.length}</small></h4>
{categoryItems.map((item) => (
<button key={item.value} type="button" className="canvas-resource-mention-folder" onClick={() => setCategory(item.value)}>
<Folder aria-hidden />
<span>{item.label}</span>
<small>{item.count}</small>
<ChevronRight aria-hidden />
</button>
))}
</section>
) : null}
</>
)}
</div>
</div>,
document.body,
);
}
function MentionReferenceList({ references, activeReferenceId, onSelect }: { references: CanvasResourceReference[]; activeReferenceId?: string; onSelect: (reference: CanvasResourceReference) => void }) {
if (!references.length) return <div className="canvas-resource-mention-empty">没有匹配的引用</div>;
return references.map((reference) => (
<button
key={reference.id}
type="button"
className={`canvas-resource-mention-item ${reference.kind === "skill" ? "is-skill" : ""} ${reference.id === activeReferenceId ? "is-active" : ""}`}
aria-selected={reference.id === activeReferenceId}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
onSelect(reference);
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onSelect(reference);
}}
>
<ReferencePreview reference={reference} />
<span className="canvas-resource-mention-copy">
<span className="canvas-resource-mention-title-row"><strong title={reference.label}>{reference.label}</strong>{reference.kind === "skill" ? <em>技能</em> : null}</span>
{reference.kind === "skill" ? (
<span className="canvas-resource-mention-meta"><span>{reference.skill?.description || reference.text || "工作流技能"}</span><small>{reference.skill?.version ? `v${reference.skill.version}` : ""}{reference.skill?.fileCount ? ` · ${reference.skill.fileCount} 文件` : ""}</small></span>
) : reference.text && reference.text !== reference.title ? <span className="canvas-resource-mention-meta"><span>{reference.text}</span></span> : null}
</span>
</button>
));
}
function ReferencePreview({ reference }: { reference: CanvasResourceReference }) {
if (reference.kind === "image" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="canvas-resource-mention-preview is-image" />;
if (reference.kind === "video" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="canvas-resource-mention-preview is-video" loading="lazy" decoding="async" />;
if (reference.kind === "video" && reference.mediaUrl) {
return <video src={reference.mediaUrl} aria-hidden="true" muted playsInline preload="metadata" className="canvas-resource-mention-preview is-video" onLoadedMetadata={(event) => primeVideoPreviewFrame(event.currentTarget)} />;
}
if (reference.kind === "character" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="canvas-resource-mention-preview is-character" />;
if (reference.kind === "skill") {
return (
<span className="canvas-resource-mention-preview is-skill">
<Workflow aria-hidden />
</span>
);
}
const Icon = reference.sourceType === CanvasNodeType.Drawing ? Pencil : reference.kind === "character" ? UserRound : reference.kind === "audio" ? Music2 : reference.kind === "video" ? Video : reference.kind === "image" ? ImageIcon : FileText;
return (
<span className="canvas-resource-mention-preview is-fallback">
<Icon aria-hidden />
</span>
);
}
function primeVideoPreviewFrame(video: HTMLVideoElement) {
if (video.currentTime !== 0 || !Number.isFinite(video.duration) || video.duration <= 0) return;
try {
// Metadata-only loading does not paint a frame consistently across browsers.
// Seeking a tiny amount keeps this preview passive while forcing first-frame decode.
video.currentTime = Math.min(0.001, video.duration);
} catch {
// A transient media error should leave the fallback element usable.
}
}
function splitMentionText(value: string, references: CanvasResourceReference[]) {
if (!references.length || !value) return value ? [{ type: "text", text: value } as MentionTextPart] : [];
const referenceByToken = new Map<string, { reference: CanvasResourceReference; serializedToken: string }>();
references.forEach((reference) => {
const serializedToken = canvasResourceMentionToken(reference);
referenceByToken.set(serializedToken, { reference, serializedToken });
referenceByToken.set(`@${reference.label}`, { reference, serializedToken });
if (reference.nodeId && !reference.assetId) referenceByToken.set(`@[node:${reference.nodeId}]`, { reference, serializedToken });
});
const tokens = [...referenceByToken.keys()].sort((a, b) => b.length - a.length);
const parts: MentionTextPart[] = [];
let index = 0;
while (index < value.length) {
const token = tokens.find((item) => value.startsWith(item, index) && hasMentionBoundary(value, index + item.length));
if (!token) {
const nextTokenIndex = findNextMentionIndex(value, tokens, index + 1);
const end = nextTokenIndex < 0 ? value.length : nextTokenIndex;
parts.push({ type: "text", text: value.slice(index, end) });
index = end;
continue;
}
const matched = referenceByToken.get(token)!;
parts.push({ type: "mention", token: matched.serializedToken, reference: matched.reference });
index += token.length;
}
return parts;
}
function renderEditableContent(editor: HTMLElement, value: string, references: CanvasResourceReference[]) {
const parts = splitMentionText(value, references);
const nodes = parts.map((part) => (part.type === "mention" ? createInlineMentionChip(part.reference, part.token) : document.createTextNode(part.text)));
editor.replaceChildren(...nodes);
}
function findNextMentionIndex(value: string, tokens: string[], fromIndex: number) {
let next = -1;
tokens.forEach((token) => {
const index = value.indexOf(token, fromIndex);
if (index >= 0 && hasMentionBoundary(value, index + token.length) && (next < 0 || index < next)) next = index;
});
return next;
}
function hasMentionBoundary(value: string, index: number) {
const char = value[index];
return !char || /\s|[,.!?;:,。!?;:、)\]}】)]/.test(char);
}
function serializeEditableValue(root: HTMLElement) {
return serializeNodeList(root.childNodes).replace(/\u00a0/g, " ");
}
function serializeNodeList(nodes: NodeListOf<ChildNode> | ChildNode[]) {
let text = "";
nodes.forEach((node) => {
text += serializeNode(node);
});
return text;
}
function serializeNode(node: ChildNode): string {
if (node.nodeType === Node.TEXT_NODE) return node.textContent || "";
if (!(node instanceof HTMLElement)) return "";
const token = node.dataset.mentionToken;
if (token) return token;
if (node.tagName === "BR") return "\n";
return serializeNodeList(node.childNodes);
}
function getEditableSelection(root: HTMLElement | null): EditableSelection | null {
if (!root) return null;
const selection = window.getSelection();
if (!selection || !selection.rangeCount) return null;
const range = selection.getRangeAt(0);
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return null;
const start = offsetForPoint(root, range.startContainer, range.startOffset);
const end = offsetForPoint(root, range.endContainer, range.endOffset);
return start <= end ? { start, end } : { start: end, end: start };
}
function offsetForPoint(root: Node, target: Node, targetOffset: number): number {
if (root === target) {
if (root.nodeType === Node.TEXT_NODE) return targetOffset;
return Array.from(root.childNodes)
.slice(0, targetOffset)
.reduce((offset, node) => offset + plainTextLength(node), 0);
}
let offset = 0;
for (const child of Array.from(root.childNodes)) {
if (child === target || child.contains(target)) return offset + offsetForPoint(child, target, targetOffset);
offset += plainTextLength(child);
}
return offset;
}
function setEditableSelection(root: HTMLElement, offset: number) {
const range = document.createRange();
const point = pointForOffset(root, Math.max(0, offset));
range.setStart(point.node, point.offset);
range.collapse(true);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
function pointForOffset(root: Node, offset: number): { node: Node; offset: number } {
if (root.nodeType === Node.TEXT_NODE) return { node: root, offset: Math.min(offset, root.textContent?.length || 0) };
let remaining = offset;
const children = Array.from(root.childNodes);
for (let index = 0; index < children.length; index += 1) {
const child = children[index];
const length = plainTextLength(child);
if (remaining > length) {
remaining -= length;
continue;
}
if (isMentionElement(child)) return { node: root, offset: remaining <= length / 2 ? index : index + 1 };
return pointForOffset(child, remaining);
}
return { node: root, offset: children.length };
}
function plainTextLength(node: Node): number {
if (node.nodeType === Node.TEXT_NODE) return node.textContent?.length || 0;
if (node instanceof HTMLElement) {
const token = node.dataset.mentionToken;
if (token) return token.length;
if (node.tagName === "BR") return 1;
}
return Array.from(node.childNodes).reduce((total, child) => total + plainTextLength(child), 0);
}
function isMentionElement(node: Node): node is HTMLElement {
return node instanceof HTMLElement && Boolean(node.dataset.mentionToken);
}
type MentionAnchorRect = Pick<DOMRect, "left" | "right" | "top" | "bottom" | "width" | "height">;
function mentionMenuPosition(anchor: HTMLElement, cursorOffset: number, preferredWidth: number) {
const caret = mentionCaretRect(anchor, cursorOffset);
const boundary = anchor.closest(".ant-modal-container")?.getBoundingClientRect() || { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight };
const inset = 8;
const gap = 8;
const availableWidth = Math.max(0, boundary.right - boundary.left - inset * 2);
const width = Math.min(preferredWidth, availableWidth);
const left = clamp(caret.left, boundary.left + inset, boundary.right - width - inset);
const spaceAbove = Math.max(0, caret.top - boundary.top - gap - inset);
const spaceBelow = Math.max(0, boundary.bottom - caret.bottom - gap - inset);
const showAbove = spaceBelow < 220 && spaceAbove > spaceBelow;
const maxHeight = Math.min(320, showAbove ? spaceAbove : spaceBelow);
const top = showAbove ? caret.top - gap : caret.bottom + gap;
return { left, top, width, maxHeight, showAbove };
}
function mentionCaretRect(anchor: HTMLElement, cursorOffset: number): MentionAnchorRect {
if (anchor instanceof HTMLTextAreaElement) return textareaCaretRect(anchor, cursorOffset);
const point = pointForOffset(anchor, cursorOffset);
const range = document.createRange();
range.setStart(point.node, point.offset);
range.collapse(true);
const rangeRect = range.getClientRects()[0] || range.getBoundingClientRect();
if (rangeRect && (rangeRect.height || rangeRect.width)) return rangeRect;
return fallbackCaretRect(anchor);
}
function textareaCaretRect(textarea: HTMLTextAreaElement, cursorOffset: number): MentionAnchorRect {
const rect = textarea.getBoundingClientRect();
const computed = window.getComputedStyle(textarea);
const mirror = document.createElement("div");
const marker = document.createElement("span");
const copiedProperties = [
"boxSizing",
"borderTopWidth",
"borderRightWidth",
"borderBottomWidth",
"borderLeftWidth",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
"fontFamily",
"fontSize",
"fontStyle",
"fontVariant",
"fontWeight",
"fontStretch",
"lineHeight",
"letterSpacing",
"textAlign",
"textIndent",
"textTransform",
"wordSpacing",
"tabSize",
"wordBreak",
"overflowWrap",
] as const;
copiedProperties.forEach((property) => {
mirror.style[property] = computed[property];
});
Object.assign(mirror.style, {
position: "fixed",
left: `${rect.left}px`,
top: `${rect.top}px`,
width: `${rect.width}px`,
height: "auto",
minHeight: "0",
maxHeight: "none",
overflow: "hidden",
visibility: "hidden",
pointerEvents: "none",
whiteSpace: "pre-wrap",
zIndex: "-1",
});
mirror.append(document.createTextNode(textarea.value.slice(0, Math.max(0, cursorOffset))));
marker.textContent = "\u200b";
mirror.append(marker);
document.body.append(mirror);
const markerRect = marker.getBoundingClientRect();
mirror.remove();
const lineHeight = Number.parseFloat(computed.lineHeight) || Number.parseFloat(computed.fontSize) * 1.4 || 20;
const top = clamp(markerRect.top - textarea.scrollTop, rect.top, rect.bottom - lineHeight);
const left = clamp(markerRect.left - textarea.scrollLeft, rect.left, rect.right);
return { left, right: left, top, bottom: top + lineHeight, width: 0, height: lineHeight };
}
function fallbackCaretRect(anchor: HTMLElement): MentionAnchorRect {
const rect = anchor.getBoundingClientRect();
const computed = window.getComputedStyle(anchor);
const lineHeight = Number.parseFloat(computed.lineHeight) || Number.parseFloat(computed.fontSize) * 1.4 || 20;
const left = rect.left + Number.parseFloat(computed.paddingLeft || "0");
const top = rect.top + Number.parseFloat(computed.paddingTop || "0");
return { left, right: left, top, bottom: top + lineHeight, width: 0, height: lineHeight };
}
function clamp(value: number, min: number, max: number) {
if (max < min) return min;
return Math.min(Math.max(value, min), max);
}