Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-resource-mention-textarea.tsx

1133 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;
onContentSizeChange?: (height: number) => void;
includeAssetLibrary?: boolean;
activeDropReferenceId?: string | null;
onReferenceFilesDrop?: (reference: CanvasResourceReference, files: File[]) => void;
autoLinkEnabled?: boolean;
};
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 = sendOnEnter ? !event.ctrlKey && !event.metaKey && !event.shiftKey : (event.ctrlKey || event.metaKey) && !event.shiftKey;
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 = event.key === "Enter" && (sendOnEnter ? !event.ctrlKey && !event.metaKey && !event.shiftKey : (event.ctrlKey || event.metaKey) && !event.shiftKey);
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);
}