* feat(canvas): 面板层级管理优化 - 五层Elevation+渐进式三区+进度增强 #98 #94 基于 Wayfinder 地图 #98 决议执行面板层级管理优化: 1. 五层 Elevation 体系(决策1) - 画布组件 z-index 硬编码值全部迁移到 token - L0 画布层 → L1 工具层 → L2 面板层 → L3 对话框层 → L4 通知层 - 涉及 14 个 .tsx 文件,0 处 z-[N] 硬编码残留 2. prompt 面板渐进式三区(决策2) - B区参数区改为手风琴折叠模式(默认折叠) - 新增 SlidersHorizontal + ChevronDown 图标切换 - 点击展开 video 工具参数,收起后释放视觉空间 - A区核心区(prompt+生成)始终展开 3. 任务进度条增强(决策3) - 进度条高度 1px→1.5px,视觉更醒目 - 新增 shimmer 扫描动画(running 态,1.4s/cycle) - 新增百分比数字显示(tabular-nums 等宽数字) - 新增 indeterminate 模式(无百分比时循环扫描) - 尊重 prefers-reduced-motion(reducedMotion 时禁用动画) 4. CSS 动画 - 新增 @keyframes canvas-task-progress-shimmer - .canvas-task-progress-shimmer 类 Closes #98 * refactor(canvas): z-index 全量迁移到五层Elevation token #98 #94 补全 #98 五层 Elevation 体系的剩余硬编码 z-index 清理: - 三个设置弹层(图/音/视频)zIndex:1200 → var(--z-popover) - model-setup-guide z-[900] → var(--z-toast) - 项目侧栏 z-30 → var(--z-panel),章节预览 z-50 → var(--z-panel-floating) - shared 页头/缩放控件/访客提示 z-[80]/z-[70] → var(--z-panel-floating),右键菜单 z-[90] → var(--z-modal) - project 缩放控件容器 z-50 → var(--z-panel),绘图加载遮罩 z-[500] → var(--z-toast) - 反馈面板 z-[90] → var(--z-panel-floating) 共 16 处全局浮层 z-index 迁移到 token,消除层级冲突隐患。 --------- Co-authored-by: sikongyue <3155157671@qq.com>
471 lines
22 KiB
TypeScript
471 lines
22 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import type { CSSProperties, KeyboardEvent, MouseEvent, PointerEvent } from "react";
|
||
import { Button, Image } from "antd";
|
||
import { FileText, Image as ImageIcon, Music2, Pencil, Sparkles, Video, X } from "lucide-react";
|
||
|
||
import { canvasThemes } from "@/lib/canvas-theme";
|
||
import { useThemeStore } from "@/stores/use-theme-store";
|
||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||
import type { NodeGenerationInput } from "./canvas-node-generation";
|
||
import { CanvasVideoPromptTools } from "./canvas-video-prompt-tools";
|
||
import { CanvasPresetPicker, type CanvasPromptPreset } from "./canvas-preset-picker";
|
||
import type { CanvasGenerationMode, CanvasNodeMetadata, CanvasWorkspaceMode } from "@/types/canvas";
|
||
|
||
type CanvasConfigComposerProps = {
|
||
value: string;
|
||
inputs: NodeGenerationInput[];
|
||
skillReferences?: CanvasResourceReference[];
|
||
generationMode?: CanvasGenerationMode;
|
||
metadata?: CanvasNodeMetadata;
|
||
onChange: (value: string) => void;
|
||
onMetadataChange?: (patch: Partial<CanvasNodeMetadata>) => void;
|
||
onClose: () => void;
|
||
workspaceMode?: CanvasWorkspaceMode;
|
||
};
|
||
|
||
type Token =
|
||
| { type: "text"; value: string }
|
||
| { type: "reference"; nodeId: string };
|
||
|
||
type MentionState = {
|
||
query: string;
|
||
};
|
||
|
||
type ComposerCandidate =
|
||
| {
|
||
kind: "input";
|
||
input: NodeGenerationInput;
|
||
}
|
||
| {
|
||
kind: "skill";
|
||
reference: CanvasResourceReference;
|
||
};
|
||
|
||
export const CONFIG_REFERENCE_PATTERN = /@\[node:([^\]]+)\]/g;
|
||
|
||
export function CanvasConfigComposer({ value, inputs, skillReferences = [], generationMode, metadata, onChange, onMetadataChange, onClose, workspaceMode = "professional" }: CanvasConfigComposerProps) {
|
||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||
const editorRef = useRef<HTMLDivElement>(null);
|
||
const composingRef = useRef(false);
|
||
const [mention, setMention] = useState<MentionState | null>(null);
|
||
const [activeIndex, setActiveIndex] = useState(0);
|
||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||
const [presetOpen, setPresetOpen] = useState(false);
|
||
const simpleMode = workspaceMode === "simple";
|
||
const tokens = useMemo(() => parseComposerTokens(value), [value]);
|
||
const referenceById = useMemo(() => new Map(inputs.map((input) => [input.nodeId, input])), [inputs]);
|
||
const videoFrameOptions = useMemo(
|
||
() =>
|
||
inputs
|
||
.filter((input) => input.type === "image" && input.image)
|
||
.map((input) => ({ nodeId: input.nodeId, label: resourceLabel(input, inputs), title: input.title, previewUrl: input.image?.dataUrl })),
|
||
[inputs],
|
||
);
|
||
const candidates = useMemo(() => {
|
||
if (!mention) return [];
|
||
const query = (mention.query || "").trim().toLowerCase();
|
||
const items: ComposerCandidate[] = [...skillReferences.map((reference) => ({ kind: "skill" as const, reference })), ...inputs.map((input) => ({ kind: "input" as const, input }))];
|
||
if (!query) return items;
|
||
return items.filter((item) => candidateSearchText(item, inputs).toLowerCase().includes(query));
|
||
}, [inputs, mention, skillReferences]);
|
||
|
||
useEffect(() => {
|
||
if (document.activeElement === editorRef.current) return;
|
||
const editor = editorRef.current;
|
||
if (!editor) return;
|
||
editor.textContent = "";
|
||
tokens.forEach((token) => {
|
||
if (token.type === "text") {
|
||
editor.append(document.createTextNode(token.value));
|
||
return;
|
||
}
|
||
const input = referenceById.get(token.nodeId);
|
||
if (input) editor.append(createReferenceChip(input, inputs, theme, setImagePreview));
|
||
});
|
||
}, [inputs, referenceById, theme, tokens]);
|
||
|
||
const syncFromEditor = () => {
|
||
const editor = editorRef.current;
|
||
if (!editor) return;
|
||
const next = serializeEditor(editor);
|
||
onChange(next);
|
||
syncMention();
|
||
};
|
||
|
||
const syncMention = () => {
|
||
const text = textBeforeCaret();
|
||
const match = /@([^\s@]*)$/.exec(text);
|
||
if (!match || (!inputs.length && !skillReferences.length)) {
|
||
closeMention();
|
||
return;
|
||
}
|
||
const nextQuery = match[1] || "";
|
||
if (mention?.query !== nextQuery) {
|
||
setMention({ query: nextQuery });
|
||
setActiveIndex(0);
|
||
}
|
||
};
|
||
|
||
const closeMention = () => {
|
||
setMention(null);
|
||
setActiveIndex(0);
|
||
};
|
||
|
||
const insertCandidate = (candidate: ComposerCandidate) => {
|
||
const editor = editorRef.current;
|
||
if (!editor) return;
|
||
removeActiveMention();
|
||
const space = document.createTextNode(" ");
|
||
const node = candidate.kind === "skill" ? document.createTextNode(`@${candidate.reference.label}`) : createReferenceChip(candidate.input, inputs, theme, setImagePreview);
|
||
const selection = window.getSelection();
|
||
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
|
||
if (range) {
|
||
range.insertNode(space);
|
||
range.insertNode(node);
|
||
range.setStartAfter(space);
|
||
range.collapse(true);
|
||
selection?.removeAllRanges();
|
||
selection?.addRange(range);
|
||
} else {
|
||
editor.append(node, space);
|
||
placeCaretAtEnd(editor);
|
||
}
|
||
closeMention();
|
||
onChange(serializeEditor(editor));
|
||
};
|
||
|
||
const stopCanvasInteraction = (event: PointerEvent | MouseEvent) => event.stopPropagation();
|
||
|
||
const insertPreset = (preset: CanvasPromptPreset) => {
|
||
const editor = editorRef.current;
|
||
if (!editor) return;
|
||
removeActiveSlash(editor);
|
||
const selection = window.getSelection();
|
||
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
|
||
const text = document.createTextNode(`${preset.prompt} `);
|
||
if (range && editor.contains(range.commonAncestorContainer)) {
|
||
range.insertNode(text);
|
||
range.setStartAfter(text);
|
||
range.collapse(true);
|
||
selection?.removeAllRanges();
|
||
selection?.addRange(range);
|
||
} else {
|
||
editor.append(text);
|
||
placeCaretAtEnd(editor);
|
||
}
|
||
onChange(serializeEditor(editor));
|
||
};
|
||
|
||
return (
|
||
<div
|
||
data-canvas-no-zoom
|
||
className="aceternity-floating-panel rounded-lg border p-3 backdrop-blur-2xl"
|
||
style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }}
|
||
onMouseDown={stopCanvasInteraction}
|
||
onPointerDown={stopCanvasInteraction}
|
||
onWheel={(event) => event.stopPropagation()}
|
||
>
|
||
<div className="mb-2 flex items-center justify-between gap-2">
|
||
<div className="flex min-w-0 items-baseline gap-2">
|
||
<div className="shrink-0 text-xs font-semibold">{simpleMode ? "快速生成" : "组装提示词"}</div>
|
||
<div className="truncate text-[var(--fs-label)] opacity-55">{simpleMode ? "已连接素材会自动带入" : "@ 引用已连接素材或已激活技能,发送前自动组装"}</div>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-1">
|
||
{simpleMode ? null : <CanvasPresetPicker mode={generationMode || "image"} skillReferences={skillReferences} open={presetOpen} onOpenChange={setPresetOpen} onSelect={insertPreset} />}
|
||
<Button size="small" type="text" className="!h-7 !w-7 !min-w-7 !p-0" icon={<X className="size-3.5" />} onClick={onClose} />
|
||
</div>
|
||
</div>
|
||
{generationMode === "video" && onMetadataChange && !simpleMode ? (
|
||
<div className="mb-2 border-y px-1 py-1.5" style={{ borderColor: theme.node.stroke }}>
|
||
<CanvasVideoPromptTools metadata={metadata} frameOptions={videoFrameOptions} onMetadataChange={onMetadataChange} />
|
||
</div>
|
||
) : null}
|
||
<div className="relative rounded-lg border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
|
||
{!value.trim() ? <div className="pointer-events-none absolute left-3 top-2 text-sm leading-7" style={{ color: theme.node.placeholder }}>输入提示词,按 @ 引用连接素材或技能</div> : null}
|
||
<div
|
||
ref={editorRef}
|
||
contentEditable
|
||
suppressContentEditableWarning
|
||
className="thin-scrollbar min-h-28 max-h-[min(42vh,360px)] w-full overflow-y-auto whitespace-pre-wrap break-words px-3 py-2 text-sm leading-7 outline-none"
|
||
style={{ color: theme.node.text }}
|
||
onInput={() => {
|
||
if (!composingRef.current) syncFromEditor();
|
||
}}
|
||
onCompositionStart={() => {
|
||
composingRef.current = true;
|
||
}}
|
||
onCompositionEnd={() => {
|
||
composingRef.current = false;
|
||
syncFromEditor();
|
||
}}
|
||
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
|
||
event.stopPropagation();
|
||
if (event.key === "/" && !mention) window.setTimeout(() => setPresetOpen(true));
|
||
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();
|
||
insertCandidate(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;
|
||
}
|
||
requestAnimationFrame(syncMention);
|
||
}}
|
||
onBlur={() => window.setTimeout(closeMention, 120)}
|
||
/>
|
||
{mention && candidates.length ? <MentionMenu candidates={candidates} allInputs={inputs} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertCandidate} /> : null}
|
||
</div>
|
||
{imagePreview ? <Image src={imagePreview} alt="引用图片预览" style={{ display: "none" }} preview={{ visible: true, src: imagePreview, onVisibleChange: (visible) => !visible && setImagePreview(null) }} /> : null}
|
||
</div>
|
||
);
|
||
|
||
}
|
||
|
||
function removeActiveSlash(editor: HTMLDivElement) {
|
||
const selection = window.getSelection();
|
||
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
|
||
if (!range || !editor.contains(range.commonAncestorContainer)) return;
|
||
const node = range.startContainer;
|
||
if (node.nodeType !== Node.TEXT_NODE) return;
|
||
const value = node.textContent || "";
|
||
const before = value.slice(0, range.startOffset);
|
||
const match = /(^|\s)\/[\p{L}\p{N}_-]*$/u.exec(before);
|
||
if (!match) return;
|
||
node.textContent = `${before.slice(0, match.index)}${match[1]}${value.slice(range.startOffset)}`;
|
||
range.setStart(node, match.index + match[1].length);
|
||
range.collapse(true);
|
||
selection?.removeAllRanges();
|
||
selection?.addRange(range);
|
||
}
|
||
|
||
function MentionMenu({ candidates, allInputs, activeIndex, theme, onSelect }: { candidates: ComposerCandidate[]; allInputs: NodeGenerationInput[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (candidate: ComposerCandidate) => void }) {
|
||
const selectedRef = useRef(false);
|
||
const activeItemRef = useRef<HTMLButtonElement | null>(null);
|
||
|
||
useEffect(() => {
|
||
activeItemRef.current?.scrollIntoView({ block: "nearest" });
|
||
}, [activeIndex, candidates]);
|
||
|
||
const selectInput = (candidate: ComposerCandidate) => {
|
||
if (selectedRef.current) return;
|
||
selectedRef.current = true;
|
||
onSelect(candidate);
|
||
};
|
||
|
||
return (
|
||
<div className="aceternity-floating-panel absolute left-2 top-[calc(100%+6px)] z-[var(--z-modal)] max-h-56 w-64 overflow-y-auto rounded-lg border p-1" style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border }}>
|
||
{candidates.map((candidate, index) => (
|
||
<button
|
||
key={candidate.kind === "skill" ? candidate.reference.id : candidate.input.nodeId}
|
||
ref={index === activeIndex ? activeItemRef : undefined}
|
||
type="button"
|
||
className="flex w-full min-w-0 items-center gap-2 rounded-md 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 }}
|
||
onMouseDown={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
selectInput(candidate);
|
||
}}
|
||
>
|
||
<ResourcePreview candidate={candidate} />
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block font-medium">{candidate.kind === "skill" ? candidate.reference.label : resourceLabel(candidate.input, allInputs)}</span>
|
||
{candidate.kind !== "skill" ? <span className="block truncate opacity-65">{candidate.input.text || candidate.input.title}</span> : null}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ResourcePreview({ candidate }: { candidate: ComposerCandidate }) {
|
||
if (candidate.kind === "skill") {
|
||
return (
|
||
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-cyan-500/12 text-cyan-600 dark:text-cyan-200">
|
||
<Sparkles className="size-4" />
|
||
</span>
|
||
);
|
||
}
|
||
const input = candidate.input;
|
||
if (input.sourceKind === "drawing") {
|
||
return (
|
||
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-black/10">
|
||
<Pencil className="size-4" />
|
||
</span>
|
||
);
|
||
}
|
||
if (input.type === "image" && input.image) return <img src={input.image.dataUrl} alt="" className="size-9 rounded-md object-cover" />;
|
||
if (input.type === "video" && input.video) return <video src={input.video.url} className="size-9 rounded-md bg-black object-cover" muted preload="metadata" />;
|
||
const Icon = input.type === "audio" ? Music2 : input.type === "video" ? Video : input.type === "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 candidateSearchText(candidate: ComposerCandidate, inputs: NodeGenerationInput[]) {
|
||
if (candidate.kind === "skill") return `技能 ${candidate.reference.label} ${candidate.reference.title} ${candidate.reference.text || ""}`;
|
||
return `${resourceLabel(candidate.input, inputs)} ${candidate.input.title} ${candidate.input.text || ""}`;
|
||
}
|
||
|
||
function createReferenceChip(input: NodeGenerationInput, inputs: NodeGenerationInput[], theme: (typeof canvasThemes)[keyof typeof canvasThemes], onImagePreview: (url: string) => void) {
|
||
const wrapper = document.createElement("span");
|
||
wrapper.contentEditable = "false";
|
||
wrapper.dataset.referenceNodeId = input.nodeId;
|
||
wrapper.className = "mx-px inline-flex h-7 max-w-40 items-center justify-center overflow-hidden rounded-md border px-1 text-xs leading-none align-middle";
|
||
Object.assign(wrapper.style, chipStyle(theme));
|
||
if (input.type === "image" && input.image && input.sourceKind !== "drawing") {
|
||
const image = document.createElement("img");
|
||
image.src = input.image.dataUrl;
|
||
image.alt = input.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(input.image?.dataUrl || "");
|
||
});
|
||
} else {
|
||
wrapper.title = input.sourceKind === "drawing" ? resourceLabel(input, inputs) : input.text || input.title;
|
||
const text = document.createElement("span");
|
||
text.className = "block truncate";
|
||
text.textContent = input.sourceKind === "drawing" ? resourceLabel(input, inputs) : input.type === "text" ? input.text || input.title : input.title;
|
||
wrapper.appendChild(text);
|
||
}
|
||
return wrapper;
|
||
}
|
||
|
||
function serializeEditor(editor: HTMLElement) {
|
||
return serializeNodes(editor.childNodes).replace(/\uFEFF/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 nodeId = node.dataset.referenceNodeId;
|
||
if (nodeId) result += `@[node:${nodeId}]`;
|
||
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();
|
||
}
|
||
|
||
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.referenceNodeId ? 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 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);
|
||
}
|
||
|
||
function parseComposerTokens(value: string): Token[] {
|
||
const tokens: Token[] = [];
|
||
let lastIndex = 0;
|
||
for (const match of value.matchAll(CONFIG_REFERENCE_PATTERN)) {
|
||
if (match.index === undefined) continue;
|
||
if (match.index > lastIndex) tokens.push({ type: "text", value: value.slice(lastIndex, match.index) });
|
||
tokens.push({ type: "reference", nodeId: match[1] });
|
||
lastIndex = match.index + match[0].length;
|
||
}
|
||
if (lastIndex < value.length) tokens.push({ type: "text", value: value.slice(lastIndex) });
|
||
return tokens;
|
||
}
|
||
|
||
function resourceLabel(input: NodeGenerationInput, inputs: NodeGenerationInput[]) {
|
||
const sameTypeInputs = inputs.filter((item) => item.type === input.type && item.sourceKind === input.sourceKind);
|
||
const index = Math.max(0, sameTypeInputs.findIndex((item) => item.nodeId === input.nodeId));
|
||
if (input.sourceKind === "drawing") return `绘图${index + 1}`;
|
||
if (input.type === "image") return `图片${index + 1}`;
|
||
if (input.type === "video") return `视频${index + 1}`;
|
||
if (input.type === "audio") return `音频${index + 1}`;
|
||
return `文本${index + 1}`;
|
||
}
|
||
|
||
function chipStyle(theme: (typeof canvasThemes)[keyof typeof canvasThemes]): CSSProperties {
|
||
return { background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text };
|
||
}
|