mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-04 16:14:22 +08:00
426 lines
19 KiB
TypeScript
426 lines
19 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import type { CSSProperties, KeyboardEvent, MouseEvent, PointerEvent } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import { Image } from "antd";
|
||
import { FileText, Image as ImageIcon, Music2, Video } from "lucide-react";
|
||
|
||
import { canvasThemes } from "@/lib/canvas-theme";
|
||
import { isImeComposing, isPlainEnterKey } from "@/lib/keyboard-event";
|
||
import { useThemeStore } from "@/stores/use-theme-store";
|
||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||
|
||
type Props = {
|
||
value: string;
|
||
references: CanvasResourceReference[];
|
||
onChange: (value: string) => void;
|
||
onSubmit?: () => void;
|
||
className?: string;
|
||
style?: CSSProperties;
|
||
placeholder?: string;
|
||
};
|
||
|
||
type MentionState = {
|
||
query: string;
|
||
rect: DOMRect | null;
|
||
};
|
||
|
||
type Token =
|
||
| { type: "text"; value: string }
|
||
| { type: "reference"; label: string };
|
||
|
||
// 提示词面板专用的 contentEditable 输入框:@ 引用图片时直接内嵌真实缩略图 chip,而不是「图片1」文字。
|
||
// 序列化时 chip → 引用 label 文本(如「图片1」),保证发给生成的 value 语义与旧 textarea 版一致。
|
||
export function CanvasPromptChipInput({ value, references, onChange, onSubmit, className, style, placeholder }: Props) {
|
||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||
const editorRef = useRef<HTMLDivElement>(null);
|
||
const composingRef = useRef(false);
|
||
// 记录我们最近一次向父级 emit 的 value。聚焦时若 value 与它一致,说明是本组件输入的回声,
|
||
// 跳过重建以免打断光标 / IME;若不一致(如发送后父级把 prompt 清空、或从提示词库插入),即使聚焦也要重建。
|
||
const lastEmittedRef = useRef(value);
|
||
const [mention, setMention] = useState<MentionState | null>(null);
|
||
const [activeIndex, setActiveIndex] = useState(0);
|
||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||
|
||
const activeReferences = useMemo(() => references.filter((item) => item.active), [references]);
|
||
const referenceByLabel = useMemo(() => new Map(activeReferences.map((item) => [item.label, item])), [activeReferences]);
|
||
// 长 label 优先匹配,避免「图片1」把「图片10」切坏。
|
||
const activeLabels = useMemo(() => Array.from(new Set(activeReferences.map((item) => item.label))).sort((a, b) => b.length - a.length), [activeReferences]);
|
||
const tokens = useMemo(() => parseTokens(value, activeLabels), [value, activeLabels]);
|
||
|
||
const candidates = useMemo(() => {
|
||
if (!mention) return [];
|
||
const query = mention.query.trim().toLowerCase();
|
||
if (!query) return activeReferences;
|
||
return activeReferences.filter((item) => `${item.label} ${item.title} ${item.kind} ${item.text || ""}`.toLowerCase().includes(query));
|
||
}, [mention, activeReferences]);
|
||
|
||
// DOM ← value:未聚焦时按 value 重建;聚焦时仅当 value 是外部改动(非本组件回声)才重建。
|
||
useEffect(() => {
|
||
const editor = editorRef.current;
|
||
if (!editor) return;
|
||
if (document.activeElement === editor && value === lastEmittedRef.current) return;
|
||
editor.textContent = "";
|
||
tokens.forEach((token) => {
|
||
if (token.type === "text") {
|
||
editor.append(document.createTextNode(token.value));
|
||
return;
|
||
}
|
||
const reference = referenceByLabel.get(token.label);
|
||
if (reference) editor.append(createReferenceChip(reference, theme, setImagePreview));
|
||
else editor.append(document.createTextNode(token.label));
|
||
});
|
||
lastEmittedRef.current = value;
|
||
}, [tokens, referenceByLabel, theme, value]);
|
||
|
||
const emit = (next: string) => {
|
||
lastEmittedRef.current = next;
|
||
onChange(next);
|
||
};
|
||
|
||
const syncFromEditor = () => {
|
||
const editor = editorRef.current;
|
||
if (!editor) return;
|
||
emit(serializeEditor(editor));
|
||
syncMention();
|
||
};
|
||
|
||
const syncMention = () => {
|
||
const text = textBeforeCaret();
|
||
const match = /@([^\s@]*)$/.exec(text);
|
||
if (!match || !activeReferences.length) {
|
||
closeMention();
|
||
return;
|
||
}
|
||
setMention({ query: match[1] || "", rect: caretRect() });
|
||
setActiveIndex(0);
|
||
};
|
||
|
||
const closeMention = () => {
|
||
setMention(null);
|
||
setActiveIndex(0);
|
||
};
|
||
|
||
const insertReference = (reference: CanvasResourceReference) => {
|
||
const editor = editorRef.current;
|
||
if (!editor) return;
|
||
removeActiveMention();
|
||
const chip = createReferenceChip(reference, theme, setImagePreview);
|
||
const space = document.createTextNode(" ");
|
||
const selection = window.getSelection();
|
||
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
|
||
if (range) {
|
||
range.insertNode(space);
|
||
range.insertNode(chip);
|
||
range.setStartAfter(space);
|
||
range.collapse(true);
|
||
selection?.removeAllRanges();
|
||
selection?.addRange(range);
|
||
} else {
|
||
editor.append(chip, space);
|
||
placeCaretAtEnd(editor);
|
||
}
|
||
closeMention();
|
||
emit(serializeEditor(editor));
|
||
};
|
||
|
||
const showPlaceholder = !value.trim();
|
||
|
||
return (
|
||
<div className="relative w-full">
|
||
{showPlaceholder && placeholder ? (
|
||
<div className="pointer-events-none absolute left-3 top-2 text-sm leading-5" style={{ color: theme.node.placeholder }}>
|
||
{placeholder}
|
||
</div>
|
||
) : null}
|
||
<div
|
||
ref={editorRef}
|
||
contentEditable
|
||
suppressContentEditableWarning
|
||
role="textbox"
|
||
aria-multiline="true"
|
||
className={`${className || ""} overflow-y-auto whitespace-pre-wrap break-words outline-none`}
|
||
style={{ ...style, cursor: "text" }}
|
||
onInput={() => {
|
||
if (!composingRef.current) syncFromEditor();
|
||
}}
|
||
onCompositionStart={() => {
|
||
composingRef.current = true;
|
||
}}
|
||
onCompositionEnd={() => {
|
||
composingRef.current = false;
|
||
syncFromEditor();
|
||
}}
|
||
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
|
||
event.stopPropagation();
|
||
if (isImeComposing(event)) return;
|
||
if (mention && candidates.length) {
|
||
if (event.key === "ArrowDown") {
|
||
event.preventDefault();
|
||
setActiveIndex((index) => (index + 1) % candidates.length);
|
||
return;
|
||
}
|
||
if (event.key === "ArrowUp") {
|
||
event.preventDefault();
|
||
setActiveIndex((index) => (index - 1 + candidates.length) % candidates.length);
|
||
return;
|
||
}
|
||
if (event.key === "Enter") {
|
||
event.preventDefault();
|
||
insertReference(candidates[Math.min(activeIndex, candidates.length - 1)]);
|
||
return;
|
||
}
|
||
if (event.key === "Escape") {
|
||
event.preventDefault();
|
||
closeMention();
|
||
return;
|
||
}
|
||
}
|
||
if ((event.key === "Backspace" || event.key === "Delete") && deleteAdjacentReference(event.key)) {
|
||
event.preventDefault();
|
||
requestAnimationFrame(syncFromEditor);
|
||
return;
|
||
}
|
||
if (isPlainEnterKey(event) && onSubmit) {
|
||
event.preventDefault();
|
||
onSubmit();
|
||
return;
|
||
}
|
||
requestAnimationFrame(syncMention);
|
||
}}
|
||
onBlur={() => window.setTimeout(closeMention, 120)}
|
||
/>
|
||
{mention && candidates.length ? (
|
||
<MentionMenu rect={mention.rect} references={candidates} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} />
|
||
) : null}
|
||
{imagePreview ? <Image src={imagePreview} alt="引用图片预览" style={{ display: "none" }} preview={{ visible: true, src: imagePreview, onVisibleChange: (visible) => !visible && setImagePreview(null) }} /> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MentionMenu({ rect, references, activeIndex, theme, onSelect }: { rect: DOMRect | null; references: CanvasResourceReference[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (reference: CanvasResourceReference) => void }) {
|
||
const selectedRef = useRef(false);
|
||
const activeItemRef = useRef<HTMLButtonElement | null>(null);
|
||
|
||
useEffect(() => {
|
||
activeItemRef.current?.scrollIntoView({ block: "nearest" });
|
||
}, [activeIndex, references]);
|
||
|
||
const selectReference = (reference: CanvasResourceReference) => {
|
||
if (selectedRef.current) return;
|
||
selectedRef.current = true;
|
||
onSelect(reference);
|
||
};
|
||
|
||
const stopCanvasInteraction = (event: PointerEvent | MouseEvent) => event.stopPropagation();
|
||
|
||
const menuWidth = 256;
|
||
const maxMenuHeight = 224;
|
||
const gap = 6;
|
||
const anchor = rect || new DOMRect(16, 16, 0, 0);
|
||
const left = clamp(anchor.left, 8, window.innerWidth - menuWidth - 8);
|
||
const showAbove = anchor.bottom + gap + maxMenuHeight > window.innerHeight && anchor.top - gap - maxMenuHeight >= 0;
|
||
const top = showAbove ? anchor.top - gap - maxMenuHeight : anchor.bottom + gap;
|
||
|
||
return createPortal(
|
||
<div
|
||
data-canvas-resource-mention-menu="true"
|
||
className="fixed z-[120] max-h-56 w-64 overflow-y-auto rounded-xl border p-1 shadow-2xl backdrop-blur-md"
|
||
style={{ left, top, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
|
||
onPointerDown={stopCanvasInteraction}
|
||
onMouseDown={stopCanvasInteraction}
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
{references.map((reference, index) => (
|
||
<button
|
||
key={reference.id}
|
||
ref={index === activeIndex ? activeItemRef : undefined}
|
||
type="button"
|
||
className="flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs transition"
|
||
style={{ background: index === activeIndex ? theme.toolbar.activeBg : "transparent", color: index === activeIndex ? theme.toolbar.activeText : theme.node.text }}
|
||
onPointerDown={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
selectReference(reference);
|
||
}}
|
||
onClick={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
selectReference(reference);
|
||
}}
|
||
>
|
||
<ReferencePreview reference={reference} />
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block font-medium">{reference.label}</span>
|
||
<span className="block truncate opacity-65">{reference.text || reference.title}</span>
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>,
|
||
document.body,
|
||
);
|
||
}
|
||
|
||
function ReferencePreview({ reference }: { reference: CanvasResourceReference }) {
|
||
if (reference.kind === "image" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="size-9 rounded-md object-cover" />;
|
||
if (reference.kind === "video" && reference.previewUrl) return <video src={reference.previewUrl} className="size-9 rounded-md bg-black object-cover" muted preload="metadata" />;
|
||
const Icon = reference.kind === "audio" ? Music2 : reference.kind === "video" ? Video : reference.kind === "image" ? ImageIcon : FileText;
|
||
return (
|
||
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-black/10">
|
||
<Icon className="size-4" />
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function createReferenceChip(reference: CanvasResourceReference, theme: (typeof canvasThemes)[keyof typeof canvasThemes], onImagePreview: (url: string) => void) {
|
||
const wrapper = document.createElement("span");
|
||
wrapper.contentEditable = "false";
|
||
wrapper.dataset.refLabel = reference.label;
|
||
if (reference.kind === "image" && reference.previewUrl) {
|
||
const image = document.createElement("img");
|
||
image.src = reference.previewUrl;
|
||
image.alt = reference.title;
|
||
image.className = "size-6 rounded object-cover";
|
||
wrapper.className = "mx-px inline-flex size-6 items-center justify-center overflow-hidden rounded align-middle";
|
||
wrapper.appendChild(image);
|
||
wrapper.addEventListener("click", (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
onImagePreview(reference.previewUrl || "");
|
||
});
|
||
} else {
|
||
wrapper.className = "mx-px inline-flex h-6 max-w-40 items-center justify-center overflow-hidden rounded-md border px-1 text-xs leading-none align-middle";
|
||
Object.assign(wrapper.style, { background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text } as CSSProperties);
|
||
wrapper.title = reference.text || reference.title;
|
||
const text = document.createElement("span");
|
||
text.className = "block truncate";
|
||
text.textContent = reference.kind === "text" ? reference.text || reference.title : reference.label;
|
||
wrapper.appendChild(text);
|
||
}
|
||
return wrapper;
|
||
}
|
||
|
||
function serializeEditor(editor: HTMLElement) {
|
||
return serializeNodes(editor.childNodes).replace(//g, "");
|
||
}
|
||
|
||
function serializeNodes(nodes: NodeListOf<ChildNode>) {
|
||
let result = "";
|
||
nodes.forEach((node) => {
|
||
if (node.nodeType === Node.TEXT_NODE) result += node.textContent || "";
|
||
if (!(node instanceof HTMLElement)) return;
|
||
const label = node.dataset.refLabel;
|
||
if (label) result += label;
|
||
else if (node.tagName === "BR") result += "\n";
|
||
else result += serializeNodes(node.childNodes);
|
||
});
|
||
return result;
|
||
}
|
||
|
||
function removeActiveMention() {
|
||
const selection = window.getSelection();
|
||
if (!selection?.rangeCount) return;
|
||
const range = selection.getRangeAt(0);
|
||
const text = textBeforeCaret();
|
||
const match = /@([^\s@]*)$/.exec(text);
|
||
if (!match) return;
|
||
range.setStart(range.startContainer, Math.max(0, range.startOffset - (match[1] || "").length - 1));
|
||
range.deleteContents();
|
||
}
|
||
|
||
// chip 是 contentEditable="false" 的原子块,光标紧邻它按 Backspace/Delete 时整块删除。
|
||
function deleteAdjacentReference(key: string) {
|
||
const selection = window.getSelection();
|
||
if (!selection?.rangeCount || !selection.isCollapsed) return false;
|
||
const range = selection.getRangeAt(0);
|
||
const target = adjacentReferenceNode(range, key);
|
||
if (!target) return false;
|
||
const nextCaretNode = document.createTextNode("");
|
||
target.replaceWith(nextCaretNode);
|
||
range.setStart(nextCaretNode, 0);
|
||
range.collapse(true);
|
||
selection.removeAllRanges();
|
||
selection.addRange(range);
|
||
return true;
|
||
}
|
||
|
||
function adjacentReferenceNode(range: Range, key: string) {
|
||
const container = range.startContainer;
|
||
const offset = range.startOffset;
|
||
const previous = key === "Backspace";
|
||
if (container.nodeType === Node.TEXT_NODE) {
|
||
const text = container.textContent || "";
|
||
if ((previous && offset > 0) || (!previous && offset < text.length)) return null;
|
||
return findReferenceSibling(container, previous);
|
||
}
|
||
const children = Array.from(container.childNodes);
|
||
return findReferenceSibling(children[previous ? offset - 1 : offset] || container, previous, true);
|
||
}
|
||
|
||
function findReferenceSibling(node: Node, previous: boolean, includeSelf = false): HTMLElement | null {
|
||
let current: Node | null = includeSelf ? node : previous ? node.previousSibling : node.nextSibling;
|
||
while (current && current.nodeType === Node.TEXT_NODE && !(current.textContent || "").trim()) current = previous ? current.previousSibling : current.nextSibling;
|
||
return current instanceof HTMLElement && current.dataset.refLabel ? current : null;
|
||
}
|
||
|
||
function textBeforeCaret() {
|
||
const selection = window.getSelection();
|
||
if (!selection?.rangeCount) return "";
|
||
const range = selection.getRangeAt(0).cloneRange();
|
||
const editor = closestEditor(range.startContainer);
|
||
if (!editor) return "";
|
||
range.setStart(editor, 0);
|
||
return range.toString();
|
||
}
|
||
|
||
function caretRect(): DOMRect | null {
|
||
const selection = window.getSelection();
|
||
if (!selection?.rangeCount) return null;
|
||
const range = selection.getRangeAt(0).cloneRange();
|
||
range.collapse(true);
|
||
const rect = range.getBoundingClientRect();
|
||
if (rect.width || rect.height || rect.left || rect.top) return rect;
|
||
// 空行/空编辑器时 range 无尺寸,退回到编辑器盒子。
|
||
const editor = closestEditor(range.startContainer);
|
||
return editor ? editor.getBoundingClientRect() : null;
|
||
}
|
||
|
||
function closestEditor(node: Node) {
|
||
const element = node instanceof Element ? node : node.parentElement;
|
||
return element?.closest("[contenteditable='true']") || null;
|
||
}
|
||
|
||
function placeCaretAtEnd(element: HTMLElement) {
|
||
const range = document.createRange();
|
||
range.selectNodeContents(element);
|
||
range.collapse(false);
|
||
const selection = window.getSelection();
|
||
selection?.removeAllRanges();
|
||
selection?.addRange(range);
|
||
}
|
||
|
||
// 按 active label(已按长度降序)把 value 文本切成「文本片段 + 命中的引用 label」。
|
||
function parseTokens(value: string, labels: string[]): Token[] {
|
||
if (!labels.length) return value ? [{ type: "text", value }] : [];
|
||
const escaped = labels.map(escapeRegExp).join("|");
|
||
const pattern = new RegExp(`(${escaped})`, "g");
|
||
const tokens: Token[] = [];
|
||
let lastIndex = 0;
|
||
for (const match of value.matchAll(pattern)) {
|
||
if (match.index === undefined) continue;
|
||
if (match.index > lastIndex) tokens.push({ type: "text", value: value.slice(lastIndex, match.index) });
|
||
tokens.push({ type: "reference", label: match[0] });
|
||
lastIndex = match.index + match[0].length;
|
||
}
|
||
if (lastIndex < value.length) tokens.push({ type: "text", value: value.slice(lastIndex) });
|
||
return tokens;
|
||
}
|
||
|
||
function escapeRegExp(value: string) {
|
||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
}
|
||
|
||
function clamp(value: number, min: number, max: number) {
|
||
if (max < min) return min;
|
||
return Math.min(Math.max(value, min), max);
|
||
}
|