refactor(canvas): 节点注册表 - 合并 PR #284 并收敛类型判据
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useEffect, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
|
||||
import { Button, Tooltip } from "antd";
|
||||
import { ArrowUp, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, UserRound, Wrench, X, XCircle } from "lucide-react";
|
||||
import { ArrowUp, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, Sparkles, UserRound, Wrench, X, XCircle } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import type { CanvasAgentOperationImpact } from "@/lib/canvas/canvas-agent-ops";
|
||||
import type { LocalUser } from "@/stores/use-user-store";
|
||||
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
|
||||
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import type { Skill } from "@/services/api/skills";
|
||||
|
||||
export type CanvasAgentChatAttachment = { id: string; name: string; url: string };
|
||||
export type CanvasAgentMode = "online" | "local";
|
||||
@@ -179,6 +182,9 @@ export function AgentChatComposer({
|
||||
onAddFiles,
|
||||
onRemoveAttachment,
|
||||
left,
|
||||
references = [],
|
||||
slashSkills,
|
||||
includeAssetLibrary,
|
||||
}: {
|
||||
prompt: string;
|
||||
attachments?: CanvasAgentChatAttachment[];
|
||||
@@ -191,9 +197,78 @@ export function AgentChatComposer({
|
||||
onAddFiles?: (files: FileList | File[] | null) => void | Promise<void>;
|
||||
onRemoveAttachment?: (id: string) => void;
|
||||
left?: ReactNode;
|
||||
/** 供「@」插入的画布节点/素材/技能引用候选(可选,默认空,缺省时退化为普通输入框) */
|
||||
references?: CanvasResourceReference[];
|
||||
/** 供「/」弹出的技能候选(可选) */
|
||||
slashSkills?: Skill[];
|
||||
/** 是否在「@」候选里包含素材库资源 */
|
||||
includeAssetLibrary?: boolean;
|
||||
}) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [slash, setSlash] = useState<{ start: number; query: string } | null>(null);
|
||||
const [slashIndex, setSlashIndex] = useState(0);
|
||||
const availableSlashSkills = slashSkills ?? [];
|
||||
const canSubmit = !disabled && !sending && Boolean(prompt.trim() || attachments.length);
|
||||
const activeSlashIndex = Math.min(Math.max(slashIndex, 0), Math.max(availableSlashSkills.length - 1, 0));
|
||||
|
||||
// 在输入值末尾检测「/关键词」打开技能候选;选择后替换为 @[skill:xxx] 引用 token(保持在 prompt 文本里)。
|
||||
const handlePromptChange = (value: string) => {
|
||||
onPromptChange(value);
|
||||
const match = /(^|\s)\/([^\s/]*)$/.exec(value);
|
||||
if (match && availableSlashSkills.length) {
|
||||
const next = { start: match.index + match[1].length, query: match[2] };
|
||||
setSlash((current) => (current && current.start === next.start && current.query === next.query ? current : next));
|
||||
setSlashIndex(0);
|
||||
} else if (slash) {
|
||||
setSlash(null);
|
||||
}
|
||||
};
|
||||
|
||||
const applySlashSkill = (skill: Skill) => {
|
||||
const token = `@[skill:${skill.skill_id}] `;
|
||||
const next = slash
|
||||
? `${prompt.slice(0, slash.start)}${token}${prompt.slice(slash.start + slash.query.length)}`
|
||||
: prompt
|
||||
? `${prompt.replace(/\s+$/u, "")} ${token}`
|
||||
: token;
|
||||
setSlash(null);
|
||||
setSlashIndex(0);
|
||||
onPromptChange(next);
|
||||
};
|
||||
|
||||
// slash 菜单的键盘控制在 capture 阶段拦截(contentEditable/textarea 内部先消费 Enter,外层冒泡拿不到)
|
||||
const handleSlashKeyCapture = (event: ReactKeyboardEvent) => {
|
||||
if (!slash || !availableSlashSkills.length) return;
|
||||
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setSlashIndex((index) => Math.min(index + 1, availableSlashSkills.length - 1));
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setSlashIndex((index) => Math.max(index - 1, 0));
|
||||
} else if (event.key === "Enter" || event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
applySlashSkill(availableSlashSkills[activeSlashIndex]);
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setSlash(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 保留粘贴图片成附件(contentEditable 模式内部会把粘贴转纯文本,capture 阶段先拦截图片)
|
||||
const handlePasteCapture = (event: ReactClipboardEvent) => {
|
||||
if (!onAddFiles) return;
|
||||
const images = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith("image/"));
|
||||
if (!images.length) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void onAddFiles(images);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-3 pb-3 pt-1" onWheelCapture={(event) => event.stopPropagation()}>
|
||||
<div className="rounded-lg border px-3 pb-2.5 pt-3 transition-[border-color,box-shadow] duration-150 focus-within:border-current" style={{ background: theme.node.fill, borderColor: theme.toolbar.border, color: theme.accent.primary, boxShadow: `0 10px 30px ${theme.spatial.shadow}` }}>
|
||||
@@ -211,25 +286,47 @@ export function AgentChatComposer({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => onPromptChange(event.target.value)}
|
||||
onPaste={(event) => {
|
||||
if (!onAddFiles) return;
|
||||
const images = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith("image/"));
|
||||
if (!images.length) return;
|
||||
event.preventDefault();
|
||||
void onAddFiles(images);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey) return;
|
||||
event.preventDefault();
|
||||
void onSubmit();
|
||||
}}
|
||||
className="thin-scrollbar max-h-40 min-h-[60px] w-full resize-none border-0 bg-transparent px-1 py-1 text-sm leading-5 outline-none placeholder:opacity-45"
|
||||
style={{ color: theme.node.text }}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<div className="relative" onKeyDownCapture={handleSlashKeyCapture} onPasteCapture={handlePasteCapture}>
|
||||
<div className="thin-scrollbar max-h-40 min-h-[60px] overflow-y-auto">
|
||||
<CanvasResourceMentionTextarea
|
||||
value={prompt}
|
||||
references={references}
|
||||
includeAssetLibrary={includeAssetLibrary}
|
||||
sendOnEnter
|
||||
disabled={disabled}
|
||||
onChange={handlePromptChange}
|
||||
onSubmit={onSubmit}
|
||||
className="w-full resize-none border-0 bg-transparent px-1 py-1 text-sm leading-5 outline-none placeholder:opacity-45"
|
||||
containerClassName="min-h-[60px]"
|
||||
style={{ color: theme.node.text }}
|
||||
placeholder={placeholder}
|
||||
aria-label="Agent 输入"
|
||||
/>
|
||||
</div>
|
||||
{slash && availableSlashSkills.length ? (
|
||||
<div
|
||||
data-agent-slash-menu
|
||||
className="absolute bottom-full left-0 z-[var(--z-toolbar)] mb-1.5 w-full max-w-xs overflow-hidden rounded-lg border p-1 shadow-lg"
|
||||
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border }}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
{availableSlashSkills.map((skill, index) => (
|
||||
<button
|
||||
key={skill.skill_id}
|
||||
type="button"
|
||||
className="flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs"
|
||||
style={{ background: index === activeSlashIndex ? theme.toolbar.itemHover : "transparent", color: theme.node.text }}
|
||||
onMouseEnter={() => setSlashIndex(index)}
|
||||
onClick={() => applySlashSkill(skill)}
|
||||
>
|
||||
<Sparkles className="size-3.5 shrink-0 opacity-70" />
|
||||
<span className="min-w-0 truncate font-medium">{skill.skill_name}</span>
|
||||
{skill.description ? <span className="min-w-0 flex-1 truncate opacity-50">{skill.description}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{onAddFiles ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Settings2, Square, Video } from "lucide-react";
|
||||
import { Button, Segmented, Select } from "antd";
|
||||
import { Button, InputNumber, Segmented, Select } from "antd";
|
||||
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { configuredModelMatchesCapability, defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
@@ -69,6 +69,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
const videoProfile = mode === "video" ? modelCapabilityConfigFor(config, config.model).video! : undefined;
|
||||
const operationOptions = videoProfile ? videoOperationOptions.filter((item) => videoProfile.operations.includes(item.value) || item.value === "concat") : videoOperationOptions;
|
||||
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
||||
const textCountValue = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(node.metadata?.textCount) || 1))));
|
||||
const priceChannel = resolveModelChannel(config, config.model);
|
||||
const credits = requestCreditCost({ channelMode: priceChannel.scope === "system" ? "remote" : "local", modelCosts: priceChannel.modelCosts, model: modelOptionName(config.model), count: mode === "image" ? count : 1, seconds: mode === "video" ? config.videoSeconds : 1 });
|
||||
const hasPrice = creditsEnabled && credits !== null;
|
||||
@@ -165,9 +166,14 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
{simpleMode ? (
|
||||
<div className="mb-2 rounded-lg px-2 py-2 text-[var(--fs-label)]" style={{ background: theme.node.fill, color: theme.node.muted }}>将使用当前默认模型与生成参数</div>
|
||||
) : (
|
||||
<div className={`mb-2 grid min-w-0 cursor-default items-center gap-2 ${mode === "image" || mode === "video" || mode === "audio" ? "grid-cols-[minmax(0,1fr)_148px]" : "grid-cols-1"}`} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<div className={`mb-2 grid min-w-0 cursor-default items-center gap-2 ${mode === "image" || mode === "video" || mode === "audio" || mode === "text" ? "grid-cols-[minmax(0,1fr)_148px]" : "grid-cols-1"}`} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<ModelPicker className="canvas-compact-control h-10" config={config} value={config.model} onChange={(model) => onConfigChange(node.id, mode === "image" ? { model, ...defaultImageParamsForModel(config, model) } : { model })} capability={mode} requirements={requirements} onMissingConfig={() => navigateToSettings({ continueCreation: true })} fullWidth showSelectedPrice={creditsEnabled} />
|
||||
{mode === "video" ? (
|
||||
{mode === "text" ? (
|
||||
<div className="flex h-10 min-w-0 cursor-default items-center justify-between gap-2 rounded-lg border px-2.5" style={{ borderColor: theme.node.stroke, background: theme.node.fill }} data-canvas-no-zoom onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<span className="inline-flex items-center gap-1 text-[var(--fs-tiny)] font-semibold" style={{ color: theme.node.muted }}><MessageSquare className="size-3.5" />文本份数</span>
|
||||
<InputNumber size="small" min={1} max={15} value={textCountValue} onChange={(value) => onConfigChange(node.id, { textCount: Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 1))) })} aria-label="文本生成份数" />
|
||||
</div>
|
||||
) : mode === "video" ? (
|
||||
<CanvasVideoSettingsPopover config={config} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
|
||||
) : mode === "image" ? (
|
||||
<CanvasImageSettingsPopover config={config} placement="topRight" autoAdjustOverflow={false} buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { App, Button, Segmented, Tooltip } from "antd";
|
||||
import copyToClipboard from "copy-to-clipboard";
|
||||
import { Copy, FolderOpen, History, LoaderCircle, MessageSquareText, PlugZap, Plus, RefreshCw, RotateCcw, Terminal, Trash2 } from "lucide-react";
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
type AgentThreadSummary,
|
||||
} from "@/stores/canvas/use-canvas-agent-store";
|
||||
import { previewCanvasAgentOps, summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
|
||||
import { buildCanvasResourceReferences } from "@/lib/canvas/canvas-resource-references";
|
||||
import { listAddedSkills, type Skill } from "@/services/api/skills";
|
||||
import { isProjectAgentReadTool, isProjectAgentToolName, runProjectAgentTool } from "@/services/api/project-agent-tools";
|
||||
import { AgentChatComposer, AgentChatMessage, AgentPanelTabs, AgentPendingToolCard, AgentWorkingMessage, type CanvasAgentChatAttachment } from "./canvas-agent-chat-ui";
|
||||
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
|
||||
@@ -103,6 +105,22 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
|
||||
} = useCanvasAgentStore();
|
||||
const [resizing, setResizing] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
// 供 Agent 输入框「@」插入的画布节点引用候选(active 标记为可用,供「@」菜单列出),与「/」弹出的已加入技能候选
|
||||
const composerReferences = useMemo(() => buildCanvasResourceReferences(snapshot.nodes, snapshot.connections).map((item) => ({ ...item, active: true })), [snapshot]);
|
||||
const [composerSkills, setComposerSkills] = useState<Skill[]>([]);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
listAddedSkills()
|
||||
.then((result) => {
|
||||
if (!cancelled) setComposerSkills(result?.skills ?? []);
|
||||
})
|
||||
.catch(() => {
|
||||
// 技能列表加载失败只影响「/」菜单,不影响输入主功能
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
const snapshotRef = useRef(snapshot);
|
||||
const confirmToolsRef = useRef(confirmTools);
|
||||
const pendingToolRef = useRef<AgentPendingToolCall | null>(null);
|
||||
@@ -637,6 +655,9 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
|
||||
sending={sending || waiting}
|
||||
placeholder="询问 Codex,或让它操作画布"
|
||||
theme={theme}
|
||||
references={composerReferences}
|
||||
slashSkills={composerSkills}
|
||||
includeAssetLibrary
|
||||
onPromptChange={(prompt) => setAgentState({ prompt })}
|
||||
onSubmit={sendPrompt}
|
||||
onAddFiles={addAttachments}
|
||||
|
||||
18
web/src/components/canvas/canvas-node-action-context.ts
Normal file
18
web/src/components/canvas/canvas-node-action-context.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import type { CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
// 批次子图操作条(下载/创建副本/删除)与主图位下载需要调用画布级动作,
|
||||
// 但画布节点经 CanvasProjectWorldLayers 渲染、不便逐个透传 handler,
|
||||
// 通过 Context 注入,避免改动 world-layers。无 Provider 时静默降级为 no-op。
|
||||
export type CanvasNodeActionContextValue = {
|
||||
download?: (node: CanvasNodeData) => void;
|
||||
duplicate?: (node: CanvasNodeData) => void;
|
||||
deleteNode?: (node: CanvasNodeData) => void;
|
||||
};
|
||||
|
||||
export const CanvasNodeActionContext = createContext<CanvasNodeActionContextValue>({});
|
||||
|
||||
export function useCanvasNodeActions() {
|
||||
return useContext(CanvasNodeActionContext);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
|
||||
import { ArrowUp, AtSign, Boxes, ChevronDown, FileText, ImageIcon, ImagePlus, Maximize2, Music2, Pencil, SlidersHorizontal, Square, UserRound, Video } from "lucide-react";
|
||||
import { Button, Image as AntImage, Modal, Tooltip } from "antd";
|
||||
import { Button, Image as AntImage, InputNumber, Modal, Tooltip } from "antd";
|
||||
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
@@ -261,7 +261,19 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-1">
|
||||
{mode === "image" ? (
|
||||
{mode === "text" ? (
|
||||
<Tooltip title={`文本生成份数(默认 1,可在生成配置中调整)`}>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={1}
|
||||
max={15}
|
||||
value={Math.max(1, Math.min(15, Math.floor(Math.abs(Number(node.metadata?.textCount) || 1))))}
|
||||
onChange={(value) => onConfigChange(node.id, { textCount: Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 1))) })}
|
||||
aria-label="文本生成份数"
|
||||
className="!w-14 !h-7 [&_.ant-input-number-input]:!text-[var(--fs-tiny)]"
|
||||
/>
|
||||
</Tooltip>
|
||||
) : mode === "image" ? (
|
||||
<CanvasImageSettingsPopover
|
||||
config={config}
|
||||
placement={expanded ? "topRight" : "topLeft"}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Input, Modal } from "antd";
|
||||
import { BookOpenText, FileText, Image, Pencil, Search, Video } from "lucide-react";
|
||||
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { getNodeListLabel } from "@/lib/canvas/node-registry";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
export function CanvasNodeSearchModal({ open, nodes, onClose, onFocus }: { open: boolean; nodes: CanvasNodeData[]; onClose: () => void; onFocus: (nodeId: string) => void }) {
|
||||
@@ -10,7 +11,7 @@ export function CanvasNodeSearchModal({ open, nodes, onClose, onFocus }: { open:
|
||||
const results = useMemo(() => {
|
||||
const keyword = query.trim().toLocaleLowerCase();
|
||||
if (!keyword) return nodes.slice(0, 40);
|
||||
return nodes.filter((node) => [node.title, node.type, nodeTypeLabel(node.type), node.metadata?.prompt, node.metadata?.composerContent, node.metadata?.model, node.metadata?.chapterTitle, node.metadata?.workflowTitle, node.metadata?.workflowDescription, typeof node.metadata?.shotIndex === "number" ? `镜头 ${node.metadata.shotIndex + 1}` : "", ...(node.metadata?.assetTags || [])]
|
||||
return nodes.filter((node) => [node.title, node.type, getNodeListLabel(node.type), node.metadata?.prompt, node.metadata?.composerContent, node.metadata?.model, node.metadata?.chapterTitle, node.metadata?.workflowTitle, node.metadata?.workflowDescription, typeof node.metadata?.shotIndex === "number" ? `镜头 ${node.metadata.shotIndex + 1}` : "", ...(node.metadata?.assetTags || [])]
|
||||
.some((value) => typeof value === "string" && value.toLocaleLowerCase().includes(keyword))).slice(0, 80);
|
||||
}, [nodes, query]);
|
||||
|
||||
@@ -44,19 +45,5 @@ export function CanvasNodeSearchModal({ open, nodes, onClose, onFocus }: { open:
|
||||
|
||||
function nodeContextLabel(node: CanvasNodeData) {
|
||||
const location = [node.metadata?.chapterTitle, typeof node.metadata?.shotIndex === "number" ? `镜头 ${node.metadata.shotIndex + 1}` : ""].filter(Boolean).join(" · ");
|
||||
return location || node.metadata?.prompt || node.metadata?.composerContent || node.metadata?.workflowDescription || nodeTypeLabel(node.type);
|
||||
}
|
||||
|
||||
function nodeTypeLabel(type: CanvasNodeType) {
|
||||
return ({
|
||||
[CanvasNodeType.Image]: "图片节点",
|
||||
[CanvasNodeType.Text]: "文本节点",
|
||||
[CanvasNodeType.Drawing]: "绘图节点",
|
||||
[CanvasNodeType.Script]: "分镜脚本节点",
|
||||
[CanvasNodeType.Skill]: "技能节点",
|
||||
[CanvasNodeType.Config]: "生成配置节点",
|
||||
[CanvasNodeType.Video]: "视频节点",
|
||||
[CanvasNodeType.Audio]: "音频节点",
|
||||
[CanvasNodeType.Frame]: "背板",
|
||||
} as Record<CanvasNodeType, string>)[type] || "未知节点";
|
||||
return location || node.metadata?.prompt || node.metadata?.composerContent || node.metadata?.workflowDescription || getNodeListLabel(node.type);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Image as ImageIcon, Lock, Maximize2, Music2, Pencil, Plus, Replace, Settings2, Star, Type, Upload, Video } from "lucide-react";
|
||||
import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Copy, Download, Image as ImageIcon, Lock, Maximize2, Music2, Pencil, Plus, RefreshCw, Replace, Settings2, Star, Trash2, Type, Upload, Video } from "lucide-react";
|
||||
|
||||
import { useCanvasNodeActions } from "./canvas-node-action-context";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { storyboardMinNodeHeight } from "@/lib/canvas/canvas-storyboard-layout";
|
||||
@@ -8,7 +10,7 @@ import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } f
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas";
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { MEDIA_NODE_MIN_SIZE } from "@/lib/canvas/canvas-node-size";
|
||||
import { getNodeMinSize, shouldKeepAspectRatio } from "@/lib/canvas/node-registry";
|
||||
import { CanvasNodeContent, CanvasNodeImageInfo } from "./canvas-node-content";
|
||||
|
||||
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
@@ -106,6 +108,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
const [isEditingContent, setIsEditingContent] = useState(false);
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState(data.title);
|
||||
const { download: downloadNode, duplicate: duplicateNode, deleteNode } = useCanvasNodeActions();
|
||||
const hasImageContent = data.type === CanvasNodeType.Image && Boolean(data.metadata?.content);
|
||||
const hasVideoContent = data.type === CanvasNodeType.Video && Boolean(data.metadata?.content);
|
||||
const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content);
|
||||
@@ -173,9 +176,10 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
|
||||
const dx = (event.clientX - resizeRef.current.startX) / scale;
|
||||
const dy = (event.clientY - resizeRef.current.startY) / scale;
|
||||
const isMediaNode = data.type === CanvasNodeType.Image || data.type === CanvasNodeType.Video;
|
||||
const minWidth = data.type === CanvasNodeType.Script ? 800 : isMediaNode ? MEDIA_NODE_MIN_SIZE.width : 220;
|
||||
const minHeight = scriptMinHeight || (isMediaNode ? MEDIA_NODE_MIN_SIZE.height : 160);
|
||||
const minSize = getNodeMinSize(data.type);
|
||||
const minWidth = minSize.width;
|
||||
// 分镜脚本的高度由表格内容动态撑开,覆盖注册表里的静态下限。
|
||||
const minHeight = scriptMinHeight || minSize.height;
|
||||
const startRight = resizeRef.current.startLeft + resizeRef.current.startWidth;
|
||||
const startBottom = resizeRef.current.startTop + resizeRef.current.startHeight;
|
||||
const fromLeft = resizeRef.current.corner.includes("left");
|
||||
@@ -227,7 +231,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
startTop: data.position.y,
|
||||
startWidth: data.width,
|
||||
startHeight: data.height,
|
||||
keepRatio: (data.type === CanvasNodeType.Image && !data.metadata?.freeResize) || data.type === CanvasNodeType.Video,
|
||||
keepRatio: shouldKeepAspectRatio(data),
|
||||
ratio: (data.metadata?.naturalWidth || data.width) / (data.metadata?.naturalHeight || data.height || 1),
|
||||
};
|
||||
window.addEventListener("mousemove", handleResizeMove);
|
||||
@@ -428,6 +432,30 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
{data.metadata?.locked ? <NodeLockBadge theme={theme} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{/* 批次子图操作条:成功子项提供下载/副本/设为主图,失败子项提供重试/删除 */}
|
||||
{isBatchChild && !readOnly && (hasImageContent || data.metadata?.status === "error") && (hovered || isSelected) ? (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-2 z-[var(--node-z-overlay)] flex justify-center"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-0.5 rounded-[var(--r-md)] border px-1 py-1 backdrop-blur-xl" style={{ background: `${theme.toolbar.panel}e6`, borderColor: theme.toolbar.border }}>
|
||||
{hasImageContent ? <BatchChildActionButton theme={theme} label="下载图片" icon={<Download className="size-3.5" />} onClick={() => downloadNode?.(data)} /> : null}
|
||||
{hasImageContent ? <BatchChildActionButton theme={theme} label="创建副本" icon={<Copy className="size-3.5" />} onClick={() => duplicateNode?.(data)} /> : null}
|
||||
{hasImageContent ? (
|
||||
<BatchChildActionButton theme={theme} label={batchPrimary ? "当前主图" : "设为主图"} icon={<Star className={`size-3.5 ${batchPrimary ? "fill-current" : ""}`} style={{ color: theme.accent.primary }} />} onClick={() => onSetBatchPrimary?.(data)} />
|
||||
) : null}
|
||||
{data.metadata?.status === "error" ? <BatchChildActionButton theme={theme} label="重试" icon={<RefreshCw className="size-3.5" />} onClick={() => onRetry?.(data)} /> : null}
|
||||
{data.metadata?.status === "error" ? <BatchChildActionButton theme={theme} label="删除" icon={<Trash2 className="size-3.5" />} danger onClick={() => deleteNode?.(data)} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{/* 批次主图位(折叠根节点封面)常驻下载按钮 */}
|
||||
{isBatchRoot && hasImageContent && !readOnly ? (
|
||||
<div className="absolute bottom-2 right-2 z-[var(--node-z-overlay)]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<BatchChildActionButton theme={theme} label="下载主图" icon={<Download className="size-3.5" />} onClick={() => downloadNode?.(data)} />
|
||||
</div>
|
||||
) : null}
|
||||
{assetTags.length || (showImageInfo && hasImageContent) ? (
|
||||
<div className="pointer-events-none absolute inset-x-3 bottom-3 z-[var(--node-z-overlay)] flex items-end justify-between gap-2">
|
||||
{assetTags.length ? <AssetTagBadges tags={assetTags} theme={theme} /> : null}
|
||||
@@ -539,6 +567,24 @@ function BatchPrimaryBadge({ visible, selected, theme, onSelect }: { visible: bo
|
||||
);
|
||||
}
|
||||
|
||||
function BatchChildActionButton({ theme, label, icon, onClick, danger = false }: { theme: CanvasTheme; label: string; icon: ReactNode; onClick: () => void; danger?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-canvas-no-zoom
|
||||
className="canvas-node-inline-action grid size-8 place-items-center rounded-[var(--r-sm)] p-0 backdrop-blur-xl transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||
style={{ color: danger ? theme.accent.danger : theme.node.text, outlineColor: theme.accent.primary }}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={(event) => { event.stopPropagation(); onClick(); }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetTagBadges({ tags, theme }: { tags: string[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-end gap-1">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme";
|
||||
import { applyCanvasLiveViewport, subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport";
|
||||
import { applyCanvasLiveViewport, canvasDotPx, subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { ViewportTransform } from "@/types/canvas";
|
||||
|
||||
@@ -382,7 +382,7 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "dots"
|
||||
"--canvas-grid-size": `${48 * viewport.k}px`,
|
||||
"--canvas-grid-x": `${viewport.x % (48 * viewport.k)}px`,
|
||||
"--canvas-grid-y": `${viewport.y % (48 * viewport.k)}px`,
|
||||
"--canvas-dot-size": viewport.k < 0.12 ? "0.8px" : "1.15px",
|
||||
"--canvas-dot-size": canvasDotPx(viewport.k),
|
||||
} as React.CSSProperties}
|
||||
onPointerDown={handlePointerDown}
|
||||
onDoubleClick={(event) => {
|
||||
@@ -426,7 +426,7 @@ function CanvasGrid({ mode }: { mode: CanvasBackgroundMode }) {
|
||||
backgroundImage,
|
||||
backgroundSize: "var(--canvas-grid-size) var(--canvas-grid-size)",
|
||||
transform: "translate3d(var(--canvas-grid-x), var(--canvas-grid-y), 0)",
|
||||
opacity: mode === "dots" ? 0.28 : 0.2,
|
||||
opacity: 0.55,
|
||||
willChange: "transform",
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user