perf(*): 创作工作台 - 优化首页按需加载与画布交互并修复历史迁移冲突

This commit is contained in:
ddCat
2026-09-05 13:24:10 +08:00
parent ca7ad1f9b2
commit 711d1b79eb
107 changed files with 3025 additions and 1089 deletions

View File

@@ -60,6 +60,7 @@ export function AssetPickerModal({ open, multiple = true, onInsert, onClose }: P
return (
<AssetLibraryPickerModal
remoteLibrary
open={open}
items={items}
categoryLabels={{ ...categoryLabels, ...externalAssetSources.categoryLabels }}

View File

@@ -1,15 +1,17 @@
import { Dropdown, Input } from "antd";
import { App, Dropdown, Input } from "antd";
import { Download, LoaderCircle, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import type { KeyboardEvent } from "react";
import { ProjectPreview } from "@/components/canvas/canvas-project-card";
import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
import { useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import type { CanvasLibrarySummary } from "@/services/api/user-data";
import { loadCanvasProjectForEditing, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store";
import { cn } from "@/lib/utils";
type CanvasFolderCardProps = {
project: CanvasProject;
project: CanvasLibrarySummary;
projectName?: string;
onClick: () => void;
onPrefetch?: () => void;
@@ -18,6 +20,7 @@ type CanvasFolderCardProps = {
/** 画布库中的文件夹封面:单一卡片表面承载预览和信息,避免相邻卡片互相侵入。 */
export function CanvasFolderCard({ project, projectName, onClick, onPrefetch, opening = false }: CanvasFolderCardProps) {
const { message } = App.useApp();
const renameProject = useCanvasStore((state) => state.renameProject);
const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds);
const editingId = useCanvasUiStore((state) => state.editingProjectId);
@@ -30,9 +33,21 @@ export function CanvasFolderCard({ project, projectName, onClick, onPrefetch, op
const editing = editingId === project.id;
const selected = selectedIds.includes(project.id);
const saveTitle = () => {
renameProject(project.id, editingTitle);
const saveTitle = async () => {
if (!editing) return;
stopEditing();
try {
await loadCanvasProjectForEditing(project.id);
renameProject(project.id, editingTitle);
await saveRemoteUserDataNow();
} catch (error) { message.error(error instanceof Error ? error.message : "重命名失败"); }
};
const exportProject = async () => {
try {
const fullProject = await loadCanvasProjectForEditing(project.id);
if (!fullProject) throw new Error("画布不存在");
await exportCanvasProjects([fullProject], project.title || "画布");
} catch (error) { message.error(error instanceof Error ? error.message : "导出失败"); }
};
const handleOpenKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
@@ -47,7 +62,7 @@ export function CanvasFolderCard({ project, projectName, onClick, onPrefetch, op
<article className={cn("canvas-folder-card", selected && "is-selected", editing && "is-editing", opening && "is-opening")} onPointerEnter={onPrefetch} onPointerDown={onPrefetch} onFocusCapture={onPrefetch}>
<div className="canvas-folder-open" role="button" tabIndex={0} aria-label={`打开画布 ${project.title}`} aria-busy={opening} onClick={() => !editing && !opening && onClick()} onKeyDown={handleOpenKeyDown}>
<div className="canvas-folder-preview" aria-hidden="true">
<ProjectPreview project={project} preferLatestImage />
<ProjectPreview project={{ id: project.id, nodes: project.previewNodes }} preferLatestImage />
{opening ? <div className="canvas-folder-opening"><LoaderCircle className="size-5 animate-spin" /><span>正在打开</span></div> : null}
</div>
<div className="canvas-folder-body">
@@ -72,7 +87,7 @@ export function CanvasFolderCard({ project, projectName, onClick, onPrefetch, op
<div className="canvas-folder-meta">
<span className="canvas-folder-meta-item">{projectName ? `所属项目:${projectName}` : "自由画布"}</span>
<span className="canvas-folder-meta-separator" aria-hidden="true">·</span>
<span className="canvas-folder-meta-item">{project.nodes.length} 节点</span>
<span className="canvas-folder-meta-item">{project.nodeCount} 节点</span>
</div>
<div className="canvas-folder-dates">
<span><small>创建时间</small><time dateTime={project.createdAt}>{formatCanvasDate(project.createdAt)}</time></span>
@@ -112,7 +127,7 @@ export function CanvasFolderCard({ project, projectName, onClick, onPrefetch, op
menu={{
onClick: ({ domEvent }) => domEvent.stopPropagation(),
items: [
{ key: "export", icon: <Download className="size-3.5" />, label: "导出画布", onClick: () => void exportCanvasProjects([project], project.title || "画布") },
{ key: "export", icon: <Download className="size-3.5" />, label: "导出画布", onClick: () => void exportProject() },
{ type: "divider" },
{ key: "delete", danger: true, icon: <Trash2 className="size-3.5" />, label: "删除", onClick: () => setDeleteIds([project.id]) },
],

View File

@@ -1,7 +1,8 @@
import type { ReactNode } from "react";
import { Brush, Camera, Copy, FileText, Grid2x2, Lock, LockOpen, Maximize2, PencilLine, Scissors, SlidersHorizontal, Smile, Sparkles, Upload, ZoomIn } from "lucide-react";
import { Brush, Camera, Copy, FileText, Grid2x2, Lock, LockOpen, Maximize2, PencilLine, Crop, SlidersHorizontal, Smile, Upload, Scaling } from "lucide-react";
import type { CanvasNodeData } from "@/types/canvas";
import type { NodeToolbarGroup } from "@/lib/canvas/tool-registry";
type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "annotation" | "maskEdit" | "emotion" | "portraitTexture" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "view";
@@ -26,92 +27,137 @@ type ImageToolDefinition = {
id: ImageNodeActionToolId;
label: string | ((node: CanvasNodeData) => string);
icon: (node: CanvasNodeData) => ReactNode;
group: NodeToolbarGroup;
order: number;
section?: string;
description?: string;
active?: (node: CanvasNodeData) => boolean;
run: (node: CanvasNodeData, handlers: ImageToolHandlers) => void;
};
const imageToolDefinitions: ImageToolDefinition[] = [
{
id: "copyPrompt",
section: "生成信息",
label: "复制提示词",
icon: () => <Copy className="size-3.5" />,
group: "more",
order: 50,
run: (node, handlers) => handlers.onCopyPrompt(node),
},
{
id: "reversePrompt",
section: "生成信息",
label: "反推提示词",
icon: () => <FileText className="size-3.5" />,
group: "more",
order: 60,
run: (node, handlers) => handlers.onReversePrompt(node),
},
{
id: "replace",
label: "替换图片",
icon: () => <Upload className="size-3.5" />,
group: "more",
section: "素材",
order: 45,
run: (node, handlers) => handlers.onUpload(node),
},
{
id: "resize",
label: (node) => (node.metadata?.freeResize ? "自由比例" : "锁比例"),
label: "锁定宽高比",
section: "节点管理",
active: (node) => !node.metadata?.freeResize,
icon: (node) => (node.metadata?.freeResize ? <LockOpen className="size-3.5" /> : <Lock className="size-3.5" />),
group: "more",
order: 70,
run: (node, handlers) => handlers.onToggleFreeResize(node),
},
{
id: "annotation",
section: "拆分与标记",
description: "添加标记,另存为新图片",
label: "标注",
icon: () => <PencilLine className="size-3.5" />,
group: "process",
order: 40,
run: (node, handlers) => handlers.onAnnotate(node),
},
{
id: "maskEdit",
label: "局部编辑",
label: "局部重绘",
description: "涂抹要修改的区域,生成新图片",
icon: () => <Brush className="size-3.5" />,
group: "primary",
order: 10,
run: (node, handlers) => handlers.onMaskEdit(node),
},
{
id: "emotion",
label: "情绪",
label: "表情调整",
section: "人像调整",
description: "调整人物表情,生成新图片",
icon: () => <Smile className="size-3.5" />,
group: "portrait",
order: 50,
run: (node, handlers) => handlers.onEmotion(node),
},
{
id: "portraitTexture",
label: "人物质感",
label: "质感调整",
section: "人像调整",
description: "设置肤质、光影与融合,再执行生成",
icon: () => <SlidersHorizontal className="size-3.5" />,
group: "portrait",
order: 60,
run: (node, handlers) => handlers.onPortraitTexture(node),
},
{
id: "crop",
label: "剪裁",
icon: () => <Scissors className="size-3.5" />,
label: "裁剪",
section: "构图与尺寸",
description: "保留所选区域,生成新图片",
icon: () => <Crop className="size-3.5" />,
group: "process",
order: 10,
run: (node, handlers) => handlers.onCrop(node),
},
{
id: "split",
label: "切图",
label: "宫格切分",
section: "拆分与标记",
description: "按行列拆成多个图片节点",
icon: () => <Grid2x2 className="size-3.5" />,
group: "process",
order: 30,
run: (node, handlers) => handlers.onSplit(node),
},
{
id: "upscale",
label: "放大",
icon: () => <ZoomIn className="size-3.5" />,
label: "调整尺寸",
section: "构图与尺寸",
description: "插值放大像素尺寸,不是 AI 超分",
icon: () => <Scaling className="size-3.5" />,
group: "process",
order: 20,
run: (node, handlers) => handlers.onUpscale(node),
},
{
id: "superResolve",
label: "超分",
icon: () => <Sparkles className="size-3.5" />,
run: (node, handlers) => handlers.onSuperResolve(node),
},
{
id: "angle",
label: "多视角",
label: "多角度",
section: "视角",
description: "调整摄影机角度,生成新视角",
icon: () => <Camera className="size-3.5" />,
group: "viewpoint",
order: 20,
run: (node, handlers) => handlers.onAngle(node),
},
{
id: "view",
label: "查看大图",
label: "预览",
icon: () => <Maximize2 className="size-3.5" />,
group: "utility",
order: 10,
run: (node, handlers) => handlers.onViewImage(node),
},
];
@@ -121,6 +167,11 @@ export function buildImageToolbarTools(node: CanvasNodeData, handlers: ImageTool
id: tool.id,
label: resolveToolText(tool.label, node),
icon: tool.icon(node),
group: tool.group,
order: tool.order,
section: tool.section,
description: tool.description,
active: tool.active?.(node),
onClick: () => tool.run(node, handlers),
}));
}

View File

@@ -324,7 +324,7 @@ function syncOverlayContent(scene: OverlayScene, props: CanvasLeaferGraphicsLaye
scene.selectionBounds.visible = Boolean(bounds && !selection);
if (bounds && !selection) {
syncSelectionBounds(scene.selectionBounds, bounds, viewportScale);
scene.selectionBounds.stroke = props.theme.accent.primary;
scene.selectionBounds.stroke = props.theme.node.label;
}
const connecting = props.connectingParams;
@@ -373,8 +373,9 @@ function syncSelection(rect: Rect, selection: SelectionBox, theme: CanvasTheme)
y: Math.min(selection.startWorldY, selection.currentWorldY),
width: Math.abs(selection.currentWorldX - selection.startWorldX),
height: Math.abs(selection.currentWorldY - selection.startWorldY),
fill: theme.canvas.selectionFill,
stroke: theme.accent.primary,
fill: "transparent",
stroke: theme.node.label,
opacity: selection.hitMode === "intersect" ? 0.82 : 0.68,
});
}
@@ -382,10 +383,18 @@ function syncViewport(viewport: ViewportTransform, width: number, height: number
const scale = Math.max(viewport.k, 0.05);
for (const scene of [underlay, overlay]) scene.world.set({ x: viewport.x, y: viewport.y, scaleX: scale, scaleY: scale });
overlay.selection.strokeWidth = 1 / scale;
overlay.selection.cornerRadius = 2 / scale;
overlay.selection.set({
strokeWidth: 1 / scale,
cornerRadius: 2 / scale,
dashPattern: [4 / scale, 4 / scale],
});
if (props.selectedNodeBounds) syncSelectionBounds(overlay.selectionBounds, props.selectedNodeBounds, scale);
overlay.selectionBounds.set({ strokeWidth: 1 / scale, cornerRadius: 12 / scale });
overlay.selectionBounds.set({
strokeWidth: 1 / scale,
cornerRadius: 2 / scale,
dashPattern: [4 / scale, 4 / scale],
opacity: 0.68,
});
overlay.draft.set({ strokeWidth: 1.4 / scale, dashPattern: [8 / scale, 8 / scale] });
overlay.guides.set({
visible: typeof props.alignmentGuides.vertical === "number" || typeof props.alignmentGuides.horizontal === "number",

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react";
import { AlertCircle, BookOpenCheck, Clock3, Download, FileText, Image as ImageIcon, LoaderCircle, Music2, Pencil, RefreshCw, Video } from "lucide-react";
import { AlertCircle, BookOpenCheck, Clock3, Download, FileText, Image as ImageIcon, LoaderCircle, Music2, Pencil, Play, RefreshCw, Video } from "lucide-react";
import { VideoPlayer } from "@/components/video-player";
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
@@ -14,7 +14,8 @@ import type { CanvasTheme } from "@/lib/canvas-theme";
import { formatBytes } from "@/lib/image-utils";
import { resourceIdFromStorageKey } from "@/services/api/resources";
import type { GenerationTask } from "@/services/api/task-center";
import { cacheResourceObjectUrl, getCachedResourceObjectUrl } from "@/services/resource-blob-cache";
import { cacheResourceObjectUrl, getCachedResourceObjectUrl, scheduleResourceBlobCache } from "@/services/resource-blob-cache";
import { resolveMediaUrl } from "@/services/file-storage";
import { hydrateCanvasVideoPreview } from "@/services/canvas-video-preview";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
@@ -56,6 +57,7 @@ export type CanvasNodeContentProps = {
onToggleBatch?: () => void;
reduceMediaEffects?: boolean;
mediaActive?: boolean;
onMediaPlayRequest?: (nodeId: string) => void;
};
export function CanvasNodeContent(props: CanvasNodeContentProps) {
@@ -419,11 +421,10 @@ function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded
return content;
}
function VideoNodeContent({ node, theme, reduceMediaEffects, mediaActive = false }: CanvasNodeContentProps) {
function VideoNodeContent({ node, theme, mediaActive = false, onMediaPlayRequest }: CanvasNodeContentProps) {
const playerBoxRef = useRef<HTMLDivElement>(null);
const { updateMediaNode } = useCanvasNodeActions();
const hasPassivePreview = Boolean(canvasNodeVideoPreviewUrl(node));
const { url, loading } = useNodeResourceUrl(node, mediaActive || !hasPassivePreview);
const { url, loading } = useVideoPlaybackUrl(node, mediaActive);
const subtitleEntries = node.metadata?.subtitleEntries || [];
const subtitleStyle = node.metadata?.subtitleStyle || createDefaultSubtitleStyle();
const [currentTimeMs, setCurrentTimeMs] = useState(0);
@@ -452,7 +453,7 @@ function VideoNodeContent({ node, theme, reduceMediaEffects, mediaActive = false
}, [node.id, node.metadata?.naturalHeight, node.metadata?.naturalWidth, subtitleEntries.length, updateMediaNode, url]);
if (!node.metadata?.content) return <EmptyMediaContent icon={<Video className="size-7 opacity-35" />} label="空视频节点" color={theme.node.placeholder} />;
if (!mediaActive) return <InactiveVideoPreview node={node} theme={theme} sourceUrl={url} sourceLoading={loading} />;
if (!mediaActive) return <InactiveVideoPreview node={node} theme={theme} onPlay={() => onMediaPlayRequest?.(node.id)} />;
if (!url) return <MediaLoadingState icon={<LoaderCircle className="size-5 animate-spin" />} label={loading ? "正在加载视频" : "视频资源不可用"} />;
const sourceRatio = (videoSize?.width || node.metadata?.naturalWidth || node.width) / Math.max(1, videoSize?.height || node.metadata?.naturalHeight || node.height);
@@ -464,7 +465,7 @@ function VideoNodeContent({ node, theme, reduceMediaEffects, mediaActive = false
return (
<div ref={playerBoxRef} className="relative flex h-full w-full items-center justify-center overflow-hidden rounded-[var(--node-radius)] bg-black">
<div className="relative" style={{ width: fitWidth, height: Math.round(fitHeight) }}>
<VideoPlayer src={url} mimeType={node.metadata?.mimeType} title={node.title || "视频"} hasAudio={inferVideoHasAudio(node.metadata)} autoPlay={mediaActive} preload={reduceMediaEffects ? "none" : "metadata"} brandColor={theme.accent.primary} className="h-full w-full rounded-[var(--node-radius)] bg-black" dataCanvasNoZoom compactControls />
<VideoPlayer src={url} mimeType={node.metadata?.mimeType} title={node.title || "视频"} hasAudio={inferVideoHasAudio(node.metadata)} autoPlay preload="metadata" brandColor={theme.accent.primary} className="h-full w-full rounded-[var(--node-radius)] bg-black" dataCanvasNoZoom compactControls onPlay={() => scheduleResourceBlobCache(node.metadata?.storageKey || "")} />
{activeEntry && activeEntry.text.trim() ? <CanvasSubtitleOverlay text={activeEntry.text} highlight={activeHighlight} style={subtitleStyle} /> : null}
</div>
</div>
@@ -487,18 +488,20 @@ function AudioNodeContent({ node, theme }: CanvasNodeContentProps) {
return <CanvasAudioPlayer node={node} theme={theme} />;
}
function InactiveVideoPreview({ node, theme, sourceUrl, sourceLoading }: Pick<CanvasNodeContentProps, "node" | "theme"> & { sourceUrl: string; sourceLoading: boolean }) {
function InactiveVideoPreview({ node, theme, onPlay }: Pick<CanvasNodeContentProps, "node" | "theme"> & { onPlay: () => void }) {
const previewRef = useRef<HTMLDivElement>(null);
const nearViewport = useNearViewport(previewRef);
const previewUrl = canvasNodeVideoPreviewUrl(node);
const { updateMetadata } = useCanvasNodeActions();
const updateMetadataRef = useRef(updateMetadata);
const [hydrating, setHydrating] = useState(() => !previewUrl && Boolean(node.metadata?.content));
const [hydrating, setHydrating] = useState(false);
useEffect(() => {
updateMetadataRef.current = updateMetadata;
}, [updateMetadata]);
useEffect(() => {
if (previewUrl || !node.metadata?.content || !updateMetadataRef.current) {
if (previewUrl || !nearViewport || !node.metadata?.content || !updateMetadataRef.current) {
setHydrating(false);
return;
}
@@ -513,32 +516,47 @@ function InactiveVideoPreview({ node, theme, sourceUrl, sourceLoading }: Pick<Ca
if (!controller.signal.aborted) setHydrating(false);
});
return () => controller.abort();
}, [node.id, node.metadata?.content, node.metadata?.storageKey, previewUrl]);
}, [nearViewport, node.id, node.metadata?.content, node.metadata?.storageKey, previewUrl]);
if (previewUrl) {
return <div className="relative size-full overflow-hidden rounded-[var(--node-radius)] bg-black"><img src={previewUrl} alt={`${node.title || "视频"} 静态预览`} loading="lazy" decoding="async" draggable={false} className="pointer-events-none size-full select-none object-contain" /></div>;
}
if (sourceUrl) {
return <div className="relative size-full overflow-hidden rounded-[var(--node-radius)] bg-black">
<video
src={sourceUrl}
aria-label={`${node.title || "视频"} 首帧预览`}
muted
playsInline
preload="auto"
draggable={false}
className="pointer-events-none size-full select-none object-contain"
onLoadedMetadata={(event) => primePassiveVideoFrame(event.currentTarget)}
onLoadedData={(event) => primePassiveVideoFrame(event.currentTarget)}
/>
{hydrating ? <span className="pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2 rounded-full bg-black/55 px-2 py-1 text-[var(--fs-tiny)] text-white/70 backdrop-blur">正在保存首帧</span> : null}
return <div ref={previewRef} className="group/video-preview relative size-full overflow-hidden rounded-[var(--node-radius)] bg-black">
<img src={previewUrl} alt={`${node.title || "视频"} 静态预览`} loading="lazy" decoding="async" draggable={false} className="pointer-events-none size-full select-none object-contain" />
<VideoPreviewPlayButton title={node.title || "视频"} onPlay={onPlay} />
</div>;
}
return <InactiveMediaCard icon={<Video className="size-7" />} title={node.title || "视频"} hint={sourceLoading || hydrating ? "正在加载首帧" : "视频资源暂不可用"} theme={theme} />;
return <div ref={previewRef} className="group/video-preview relative size-full">
<InactiveMediaCard icon={<Video className="size-7" />} title={node.title || "视频"} hint={hydrating ? "正在生成首帧" : nearViewport ? "点击播放视频" : "进入视口后加载首帧"} theme={theme} />
<VideoPreviewPlayButton title={node.title || "视频"} onPlay={onPlay} />
</div>;
}
function primePassiveVideoFrame(video: HTMLVideoElement) {
if (video.currentTime === 0 && Number.isFinite(video.duration) && video.duration > 0) video.currentTime = Math.min(0.001, video.duration);
function VideoPreviewPlayButton({ title, onPlay }: { title: string; onPlay: () => void }) {
return <button type="button" className="absolute left-1/2 top-1/2 z-[var(--node-z-overlay)] grid size-11 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-black/62 text-white shadow-lg backdrop-blur transition-[transform,background-color,opacity] hover:scale-105 hover:bg-black/78 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" aria-label={`播放 ${title}`} title="播放视频" onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onPlay(); }}><Play className="ml-0.5 size-5 fill-current" /></button>;
}
function useVideoPlaybackUrl(node: CanvasNodeData, active: boolean) {
const rawContent = node.metadata?.content || "";
const fallback = node.metadata?.importSource?.provider === "libtv" ? buildLibTVVideoSourceUrl(rawContent) : rawContent;
const storageKey = node.metadata?.storageKey || "";
const [url, setUrl] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
let cancelled = false;
if (!active) {
setUrl("");
setLoading(false);
return;
}
setLoading(true);
void resolveMediaUrl(storageKey, fallback)
.then((resolved) => { if (!cancelled) setUrl(resolved); })
.catch(() => { if (!cancelled) setUrl(fallback); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [active, fallback, storageKey]);
return { url, loading };
}
function InactiveMediaCard({ icon, title, hint, theme }: { icon: ReactNode; title: string; hint: string; theme: CanvasTheme }) {

View File

@@ -124,7 +124,7 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
<div className="flex min-h-[360px] flex-col gap-5">
<div>
<h2 className="text-xl font-semibold">局部遮罩编辑</h2>
<h2 className="text-xl font-semibold">局部重绘</h2>
<div className="mt-2 text-sm opacity-60">{image ? `${image.width} x ${image.height}px` : "读取中"}</div>
</div>

View File

@@ -1,5 +1,5 @@
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, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from "react";
import { ArrowUp, AtSign, ChevronDown, FileText, ImageIcon, ImagePlus, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react";
import { Button, Image as AntImage, InputNumber, Modal, Tooltip } from "antd";
import { ModelPicker } from "@/components/model-picker";
@@ -244,32 +244,29 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
<span className="grid size-3.5 shrink-0 place-items-center" style={{ color: monochromeAccent }}>
<GenerationModeIcon mode={mode} />
</span>
<span className="truncate text-[var(--fs-tiny)] font-medium">{modeDisplayName(mode)}创作</span>
<span className="truncate text-[var(--fs-tiny)] font-medium">{modeDisplayName(mode)}生成</span>
</div>
)}
{!simpleMode ? <CanvasPresetPicker mode={mode} skillReferences={skillReferences} open={expanded ? expandedPresetOpen : presetOpen} onOpenChange={expanded ? setExpandedPresetOpen : setPresetOpen} onSelect={applyPreset} dense /> : null}
<div className="ml-auto flex shrink-0 items-center justify-end gap-1">
{!simpleMode ? <CanvasPresetPicker mode={mode} skillReferences={skillReferences} open={expanded ? expandedPresetOpen : presetOpen} onOpenChange={expanded ? setExpandedPresetOpen : setPresetOpen} onSelect={applyPreset} dense appearance="quiet" /> : null}
{canOptimizePrompt ? (
<Tooltip title="用 AI 优化提示词">
<Tooltip title="用 AI 润色提示词">
<button
type="button"
className="canvas-node-composer-icon-button inline-flex h-6 shrink-0 items-center gap-1 rounded-md px-1.5 transition-[background-color,filter] hover:brightness-125 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 motion-reduce:hover:translate-y-0"
style={{ background: controlSurface, color: theme.node.text, outlineColor: monochromeAccent }}
className="canvas-node-composer-header-action inline-flex h-6 shrink-0 items-center gap-1 rounded-md px-1.5"
onClick={() => setPromptOptimizerOpen(true)}
aria-label="优化提示词"
aria-label="润色提示词"
>
<WandSparkles className="size-3" />
<span className="hidden text-[var(--fs-micro)] font-medium sm:inline">优化</span>
<span className="text-[var(--fs-tiny)] font-medium">润色</span>
</button>
</Tooltip>
) : null}
<div className="ml-auto flex shrink-0 items-center justify-end gap-1">
{activeReferenceCount ? <ComposerPill theme={theme} icon={<Boxes className="size-2.5" />} label={`参考 ${activeReferenceCount}`} /> : null}
{!expanded && canExpandPrompt ? (
<Tooltip title="放大编辑">
<button
type="button"
className="canvas-node-composer-icon-button grid size-6 shrink-0 place-items-center rounded-md transition-[background-color,filter] hover:brightness-125 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 motion-reduce:hover:translate-y-0"
style={{ background: controlSurface, color: theme.node.text, outlineColor: monochromeAccent }}
className="canvas-node-composer-header-action grid size-6 shrink-0 place-items-center rounded-md"
onClick={() => setExpandedPromptOpen(true)}
aria-label="放大编辑提示词"
>
@@ -281,8 +278,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
<Tooltip title="关闭">
<button
type="button"
className="canvas-node-composer-icon-button grid size-6 shrink-0 place-items-center rounded-md transition-[background-color,filter] hover:brightness-125 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 motion-reduce:hover:translate-y-0"
style={{ background: controlSurface, color: theme.node.text, outlineColor: monochromeAccent }}
className="canvas-node-composer-header-action grid size-6 shrink-0 place-items-center rounded-md"
onClick={onClose}
aria-label="关闭创作面板"
>
@@ -508,15 +504,6 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
);
}
function ComposerPill({ theme, icon, label }: { theme: CanvasTheme; icon: ReactNode; label: string }) {
return (
<span className="inline-flex h-6 shrink-0 items-center gap-1 rounded-[var(--r-sm)] px-1.5 text-[var(--fs-micro)] font-medium" style={{ background: "var(--canvas-composer-control-surface)", color: theme.node.activeStroke }}>
{icon}
{label}
</span>
);
}
function GenerationModeIcon({ mode }: { mode: CanvasNodeGenerationMode }) {
if (mode === "image") return <ImagePlus className="size-3" />;
if (mode === "video") return <Video className="size-3" />;
@@ -539,6 +526,9 @@ function ConnectedReferenceShelf({ references, theme, onInsert, onRemove }: { re
return (
<>
<div className="canvas-node-composer-references thin-scrollbar" role="group" aria-label="已连接素材">
<span className="canvas-node-composer-reference-heading">
{activeReferences.every((reference) => reference.kind === "image" || reference.kind === "character") ? "参考图" : "参考素材"} · {activeReferences.length}
</span>
{activeReferences.map((reference) => {
const canPreview = (reference.kind === "image" || reference.kind === "character") && Boolean(reference.previewUrl);
return (

View File

@@ -35,7 +35,7 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden>
<div className="space-y-5">
<div>
<h2 className="text-xl font-semibold">切分图片</h2>
<h2 className="text-xl font-semibold">宫格切分</h2>
<p className="mt-1 text-sm opacity-60">生成 {total} 个图片子节点,并按原图网格排列到画布右侧</p>
</div>
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_280px]">

View File

@@ -1,13 +1,13 @@
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
import { App, Button, Dropdown, Input, Modal, Tag } from "antd";
import { App, Button, Dropdown, Input, Modal, Tag, Tooltip } from "antd";
import type { MenuProps } from "antd";
import { ChevronDown, Ellipsis, Lock, Plus, Unlock } from "lucide-react";
import { Check, ChevronDown, Ellipsis, Images, Plus, SlidersHorizontal, UserRound } from "lucide-react";
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import { ASSET_CATEGORY_OPTIONS } from "@/lib/asset-category";
import { canvasThemes } from "@/lib/canvas-theme";
import { resolveToolbarTools, type ToolContext, type ToolbarHandlers } from "@/lib/canvas/tool-registry";
import { subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport";
import { resolveNodeToolbarPlacement, resolveToolbarTools, type NodeToolbarGroup, type ToolContext, type ToolbarHandlers } from "@/lib/canvas/tool-registry";
import { subscribeCanvasGraphicsViewportPreview } from "@/lib/canvas/canvas-live-viewport";
import { canvasNodeAssetCategory } from "@/lib/canvas/canvas-node-asset";
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
import { generationErrorMessage } from "@/lib/generation-error";
@@ -62,10 +62,14 @@ type CanvasAssetCategory = NonNullable<NonNullable<CanvasNodeData["metadata"]>["
const assetCategoryOptions: Array<{ value: CanvasAssetCategory; label: string }> = ASSET_CATEGORY_OPTIONS;
type ToolbarTool = {
section?: string;
description?: string;
id: string;
label: string;
icon: ReactNode;
onClick: () => void;
group: NodeToolbarGroup;
order: number;
active?: boolean;
danger?: boolean;
disabled?: boolean;
@@ -112,6 +116,7 @@ export function CanvasNodeToolbar({
workspaceMode = "professional",
}: CanvasNodeToolbarProps) {
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [containerWidth, setContainerWidth] = useState(1000);
const [anchor, setAnchor] = useState<{ left: number; top: number } | null>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const { message } = App.useApp();
@@ -135,37 +140,64 @@ export function CanvasNodeToolbar({
setAnchor(null);
return;
}
let disposed = false;
let queued = false;
let containerRect = container.getBoundingClientRect();
let toolbarWidth = toolbarRef.current?.offsetWidth || 0;
let toolbarHeight = toolbarRef.current?.offsetHeight || 44;
const update = () => {
const nodeRect = element.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const preferredLeft = nodeRect.left - containerRect.left + nodeRect.width / 2;
const toolbarWidth = toolbarRef.current?.offsetWidth || 0;
const halfToolbar = toolbarWidth / 2;
const canClamp = toolbarWidth > 0 && toolbarWidth <= containerRect.width - 20;
const left = canClamp ? Math.min(Math.max(preferredLeft, halfToolbar + 10), containerRect.width - halfToolbar - 10) : preferredLeft;
const top = nodeRect.top - containerRect.top - 30;
let left = canClamp ? Math.min(Math.max(preferredLeft, halfToolbar + 10), containerRect.width - halfToolbar - 10) : preferredLeft;
const above = nodeRect.top - containerRect.top - 30;
let top = Math.max(toolbarHeight + 8, Math.min(above, containerRect.height - 8));
for (const panel of container.querySelectorAll<HTMLElement>("[data-canvas-node-panel]")) {
const panelRect = panel.getBoundingClientRect();
const panelLeft = panelRect.left - containerRect.left;
const panelRight = panelRect.right - containerRect.left;
const panelTop = panelRect.top - containerRect.top;
const panelBottom = panelRect.bottom - containerRect.top;
if (left + halfToolbar <= panelLeft || left - halfToolbar >= panelRight || top <= panelTop || top - toolbarHeight >= panelBottom) continue;
if (panelLeft >= toolbarWidth + 18) left = panelLeft - halfToolbar - 8;
else if (containerRect.width - panelRight >= toolbarWidth + 18) left = panelRight + halfToolbar + 8;
else if (panelTop >= toolbarHeight + 16) top = panelTop - 8;
else if (containerRect.height - panelBottom >= toolbarHeight + 16) top = panelBottom + toolbarHeight + 8;
}
if (toolbarRef.current) {
toolbarRef.current.style.left = `${left}px`;
toolbarRef.current.style.top = `${top}px`;
toolbarRef.current.style.transform = `translate3d(${left}px, ${top}px, 0)`;
return;
}
setAnchor((current) => current?.left === left && current.top === top ? current : { left, top });
};
update();
const resizeObserver = new ResizeObserver(update);
const scheduleUpdate = () => {
if (queued || disposed) return;
queued = true;
queueMicrotask(() => {
queued = false;
if (!disposed) update();
});
};
const measure = () => {
containerRect = container.getBoundingClientRect();
toolbarWidth = toolbarRef.current?.offsetWidth || 0;
toolbarHeight = toolbarRef.current?.offsetHeight || 44;
setContainerWidth(containerRect.width);
scheduleUpdate();
};
measure();
const resizeObserver = new ResizeObserver(measure);
resizeObserver.observe(element);
resizeObserver.observe(container);
if (toolbarRef.current) resizeObserver.observe(toolbarRef.current);
const viewportLayer = element.parentElement;
const mutationObserver = new MutationObserver(update);
if (viewportLayer) mutationObserver.observe(viewportLayer, { attributes: true, attributeFilter: ["style"] });
const unsubscribeViewport = subscribeCanvasViewportPreview(container, update);
window.addEventListener("resize", update);
const unsubscribeViewport = subscribeCanvasGraphicsViewportPreview(container, scheduleUpdate);
window.addEventListener("resize", measure);
return () => {
disposed = true;
resizeObserver.disconnect();
mutationObserver.disconnect();
unsubscribeViewport();
window.removeEventListener("resize", update);
window.removeEventListener("resize", measure);
};
}, [anchor === null, containerRef, node, viewport.k, viewport.x, viewport.y]);
@@ -175,9 +207,6 @@ export function CanvasNodeToolbar({
const isVideo = node.type === CanvasNodeType.Video;
const isAudio = node.type === CanvasNodeType.Audio;
const hasImage = isImage && Boolean(node.metadata?.content);
const isText = node.type === CanvasNodeType.Text;
const isCharacterReference = isText && node.metadata?.workflowKind === "character" && Boolean(node.metadata.characterAssetId);
const isEditableText = isText && !isCharacterReference;
const copyImagePrompt = (target: CanvasNodeData) => {
const prompt = target.metadata?.prompt?.trim();
if (!prompt) {
@@ -219,52 +248,43 @@ export function CanvasNodeToolbar({
handlers: nodeHoverHandlers,
};
// 注册表只负责动作合同与适用性,Dock 的业务分组在此处唯一确定。
// 注册表统一提供动作合同、适用性和节点 Dock 层级。
const registryTools = resolveToolbarTools("node-hover", nodeHoverCtx, null);
// 锁定始终放在菜单末尾,避免与业务工具混排。
const otherRegistryTools = registryTools.filter((tool) => tool.id !== "node-lock");
// 转为 ToolbarTool 供组件内部逻辑使用
const otherTools: ToolbarTool[] = otherRegistryTools.map((tool) => ({
id: tool.id,
label: tool.displayLabel ? (typeof tool.displayLabel === "function" ? tool.displayLabel(nodeHoverCtx) : tool.displayLabel) : (typeof tool.label === "function" ? tool.label(nodeHoverCtx) : tool.label),
icon: typeof tool.icon === "function" ? tool.icon(nodeHoverCtx) : tool.icon,
active: tool.active?.(nodeHoverCtx),
danger: tool.danger,
disabled: tool.disabled?.(nodeHoverCtx),
onClick: () => tool.run(nodeHoverCtx),
}));
const allTools: ToolbarTool[] = hasImage && !simpleMode
? [...otherTools, ...imageTools.map((tool) => ({ id: tool.id, label: tool.label, icon: tool.icon, onClick: tool.onClick }))]
: otherTools;
const toolById = new Map(allTools.map((tool) => [tool.id, tool]));
const takeTools = (ids: string[]) => ids.map((id) => toolById.get(id)).filter((tool): tool is ToolbarTool => Boolean(tool));
const imageBaseTools = takeTools(hasImage ? ["delete", "download"] : ["delete", "uploadImage"]);
const imageEditTools = takeTools(["maskEdit", "crop", "split"]);
const imagePortraitTools = takeTools(["emotion", "portraitTexture"]).map((tool) => tool.id === "emotion" ? { ...tool, label: "人物情绪" } : tool);
const imageAngleTool = toolById.get("angle");
const videoTools = takeTools(["delete", "download", "subtitles", "timeline", "extractFrames", "extractAudio", "trimRegenerate", "uploadVideo"]).map((tool) => {
if (tool.id === "extractFrames") return { ...tool, label: "提取画面" };
if (tool.id === "trimRegenerate") return { ...tool, label: "截取片段" };
return tool;
const registryToolbarTools: ToolbarTool[] = registryTools.map((tool) => {
const placement = resolveNodeToolbarPlacement(tool, nodeHoverCtx);
return {
id: tool.id,
label: tool.displayLabel ? (typeof tool.displayLabel === "function" ? tool.displayLabel(nodeHoverCtx) : tool.displayLabel) : (typeof tool.label === "function" ? tool.label(nodeHoverCtx) : tool.label),
icon: typeof tool.icon === "function" ? tool.icon(nodeHoverCtx) : tool.icon,
group: placement.group,
order: placement.order,
section: tool.nodeToolbar?.section,
description: tool.nodeToolbar?.description,
active: tool.active?.(nodeHoverCtx),
danger: tool.danger,
disabled: tool.disabled?.(nodeHoverCtx),
onClick: () => tool.run(nodeHoverCtx),
};
});
const genericTools = takeTools(isAudio ? ["delete", "download", "timeline", "uploadAudio"] : isEditableText ? ["delete", "edit", "editText", "generateImage", "saveAsset"] : ["delete", "info", "config"]);
const visibleToolIds = new Set([
...(isImage ? [...imageBaseTools, ...imageEditTools, ...imagePortraitTools, ...(imageAngleTool ? [imageAngleTool] : [])] : isVideo ? videoTools : genericTools).map((tool) => tool.id),
]);
const overflowTools = allTools
.filter((tool) => !visibleToolIds.has(tool.id))
.map((tool) => tool.id === "edit" && (isImage || isVideo) ? { ...tool, label: "生成设置" } : tool);
const lockTool: ToolbarTool = {
id: "node-lock",
label: node.metadata?.locked ? "解锁" : "锁定",
icon: node.metadata?.locked ? <Unlock className="size-3.5" /> : <Lock className="size-3.5" />,
active: Boolean(node.metadata?.locked),
onClick: () => onToggleLocked(node),
};
const allTools: ToolbarTool[] = hasImage && !simpleMode
? [...registryToolbarTools, ...imageTools]
: registryToolbarTools;
const compact = containerWidth < 640;
const narrow = containerWidth < 420;
const inGroup = (group: NodeToolbarGroup) => allTools.filter((tool) => tool.group === group).sort(compareToolbarTools);
const primary = inGroup("primary");
const primaryTools = narrow ? primary.slice(0, 1) : primary;
const portraitTools = compact ? [] : inGroup("portrait");
const viewpointTools = compact ? [] : inGroup("viewpoint");
const processTools = compact ? [...inGroup("portrait"), ...inGroup("viewpoint"), ...inGroup("process")] : inGroup("process");
const workspaceTools = narrow ? [] : inGroup("workspace");
const utilityTools = inGroup("utility");
const moreTools = [...(narrow ? [...primary.slice(1), ...inGroup("workspace")] : []), ...inGroup("more")];
const processMenuLabel = compact ? "工具" : isVideo ? "提取素材" : isImage ? "图片工具" : isAudio ? "音频处理" : "文本调整";
const handleMenuOpenChange = (menuId: string, open: boolean) => {
setOpenMenuId((current) => open ? menuId : current === menuId ? null : current);
if (open) onKeep(node.id);
else onLeave();
else if (!toolbarRef.current?.contains(document.activeElement)) onLeave();
};
const dockStyle = canvasDockStyle(theme, theme.node.text);
@@ -272,66 +292,102 @@ export function CanvasNodeToolbar({
<div
ref={toolbarRef}
className="canvas-node-toolbar absolute z-[var(--z-node-toolbar)] -translate-x-1/2 -translate-y-full"
style={{ left: anchor.left, top: anchor.top, width: "max-content", maxWidth: "min(calc(100% - 20px), 960px)", color: theme.node.text }}
style={{ left: 0, top: 0, transform: `translate3d(${anchor.left}px, ${anchor.top}px, 0)`, width: "max-content", maxWidth: "calc(100% - 20px)", color: theme.node.text }}
onMouseEnter={() => onKeep(node.id)}
onMouseLeave={() => { if (!openMenuId) onLeave(); }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
data-canvas-no-zoom
onKeyDown={(event) => event.stopPropagation()}
onFocus={() => onKeep(node.id)}
onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget) && !openMenuId) onLeave(); }}
>
<div
role="toolbar"
aria-label="节点快捷工具"
className="thin-scrollbar flex h-11 max-w-full items-center gap-0.5 overflow-x-auto rounded-[var(--dock-radius-tight)] px-2 backdrop-blur-2xl"
className="flex h-11 max-w-full items-center gap-0.5 overflow-visible rounded-[var(--dock-radius-tight)] px-2 backdrop-blur-2xl"
style={{ ...dockStyle, border: 0 }}
>
{isImage ? (
<>
{imageBaseTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
{imageEditTools.length ? <NodeDockMenuButton menuId="image-edit" label="编辑" icon={imageEditTools[0].icon} tools={imageEditTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
{imagePortraitTools.length ? <NodeDockMenuButton menuId="image-portrait" label="人物调整" icon={imagePortraitTools[0].icon} tools={imagePortraitTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
{imageAngleTool ? <NodeDockToolButton tool={imageAngleTool} /> : null}
</>
) : isVideo ? videoTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />) : genericTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
<span aria-hidden className="aceternity-dock-separator mx-1.5 h-6 w-px shrink-0" />
<NodeDockToolButton tool={lockTool} />
{overflowTools.length ? (
<NodeDockMenuButton menuId="more" label="更多" icon={<Ellipsis className="size-3.5" />} tools={overflowTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} placement="topRight" />
{primaryTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
{portraitTools.length ? <NodeDockMenuButton menuId="portrait" label="人像调整" icon={<UserRound className="size-3.5" />} tools={portraitTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
{viewpointTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
{processTools.length ? <NodeDockMenuButton menuId="process" label={processMenuLabel} icon={isVideo ? <Images className="size-3.5" /> : <SlidersHorizontal className="size-3.5" />} tools={processTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
{workspaceTools.length ? <span aria-hidden className="aceternity-dock-separator mx-1 h-5 w-px shrink-0" /> : null}
{workspaceTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
{utilityTools.length || moreTools.length ? <span aria-hidden className="aceternity-dock-separator mx-1 h-5 w-px shrink-0" /> : null}
{utilityTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} iconOnly />)}
{moreTools.length ? (
<NodeDockMenuButton menuId="more" label="更多" icon={<Ellipsis className="size-3.5" />} tools={moreTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} placement="topRight" iconOnly />
) : null}
</div>
</div>
);
}
function NodeDockToolButton({ tool }: { tool: ToolbarTool }) {
function NodeDockToolButton({ tool, iconOnly = false }: { tool: ToolbarTool; iconOnly?: boolean }) {
return (
<Tooltip title={tool.description ? `${tool.label}:${tool.description}` : tool.label}>
<button
type="button"
className={`aceternity-dock-command is-labeled pointer-events-auto inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-[var(--dock-item-radius)] px-2.5 outline-none ${tool.active ? "is-active" : ""} ${tool.danger ? "is-danger" : ""}`}
aria-label={tool.label}
aria-pressed={tool.active || undefined}
aria-pressed={tool.active}
disabled={tool.disabled}
onClick={tool.onClick}
>
<span className="grid size-3.5 shrink-0 place-items-center">{tool.icon}</span>
<span className="inline-flex h-4 items-center whitespace-nowrap text-[var(--fs-label)] font-medium leading-none">{tool.label}</span>
{!iconOnly ? <span className="inline-flex h-4 items-center whitespace-nowrap text-[var(--fs-label)] font-medium leading-none">{tool.label}</span> : null}
</button>
</Tooltip>
);
}
function NodeDockMenuButton({ menuId, label, icon, tools, openMenuId, onOpenChange, placement = "top" }: { menuId: string; label: string; icon: ReactNode; tools: ToolbarTool[]; openMenuId: string | null; onOpenChange: (menuId: string, open: boolean) => void; placement?: "top" | "topRight" }) {
function compareToolbarTools(left: ToolbarTool, right: ToolbarTool) {
if (left.danger !== right.danger) return left.danger ? 1 : -1;
return left.order - right.order;
}
function NodeDockMenuButton({ menuId, label, icon, tools, openMenuId, onOpenChange, placement = "top", iconOnly = false }: { menuId: string; label: string; icon: ReactNode; tools: ToolbarTool[]; openMenuId: string | null; onOpenChange: (menuId: string, open: boolean) => void; placement?: "top" | "topRight"; iconOnly?: boolean }) {
const open = openMenuId === menuId;
const items: MenuProps["items"] = tools.map((tool) => ({ key: tool.id, icon: tool.icon, label: tool.label, disabled: tool.disabled, onClick: tool.onClick }));
const triggerRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const frame = requestAnimationFrame(() => triggerRef.current?.focus());
return () => cancelAnimationFrame(frame);
}, [open]);
const sections = new Map<string, ToolbarTool[]>();
for (const tool of tools) {
const section = tool.danger ? "危险操作" : tool.section || "常用操作";
sections.set(section, [...(sections.get(section) || []), tool]);
}
const items: MenuProps["items"] = [...sections].sort(([left], [right]) => Number(left === "危险操作") - Number(right === "危险操作")).map(([section, entries]) => ({
type: "group", key: section, label: section,
children: entries.map((tool) => ({ key: tool.id, icon: tool.icon, label: <div><span className="inline-flex items-center gap-2">{tool.label}{tool.active ? <Check className="size-3.5" /> : null}</span>{tool.description ? <div className="text-[var(--fs-tiny)] opacity-60">{tool.description}</div> : null}</div>, disabled: tool.disabled, danger: tool.danger, onClick: () => { onOpenChange(menuId, false); tool.onClick(); } })),
}));
return (
<Dropdown open={open} trigger={["click"]} placement={placement} onOpenChange={(nextOpen) => onOpenChange(menuId, nextOpen)} menu={{ items }}>
<Dropdown open={open} trigger={["click"]} placement={placement} onOpenChange={(nextOpen) => onOpenChange(menuId, nextOpen)} menu={{ items }} autoFocus popupRender={(menu) => <div className="canvas-node-toolbar-menu" data-canvas-no-zoom data-canvas-wheel-scroll onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()} onKeyDownCapture={(event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); triggerRef.current?.focus(); onOpenChange(menuId, false); } }} onKeyDown={(event) => event.stopPropagation()}>{menu}</div>}>
<button
ref={triggerRef}
type="button"
className={`aceternity-dock-command is-labeled pointer-events-auto inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-[var(--dock-item-radius)] px-2.5 outline-none ${open ? "is-active" : ""}`}
aria-label={label}
aria-expanded={open}
aria-haspopup="menu"
title={label}
onKeyDown={(event) => {
if (event.key === "ArrowDown" && open) {
event.preventDefault();
document.querySelector<HTMLElement>(".ant-dropdown:not(.ant-dropdown-hidden) .canvas-node-toolbar-menu [role='menuitem']:not([aria-disabled='true'])")?.focus();
}
if (event.key === "Escape" && open) {
event.preventDefault();
event.stopPropagation();
onOpenChange(menuId, false);
}
}}
>
<span className="grid size-3.5 shrink-0 place-items-center">{icon}</span>
<span className="inline-flex h-4 items-center whitespace-nowrap text-[var(--fs-label)] font-medium leading-none">{label}</span>
<ChevronDown className="size-3 shrink-0 opacity-55" />
{!iconOnly ? <><span className="inline-flex h-4 items-center whitespace-nowrap text-[var(--fs-label)] font-medium leading-none">{label}</span><ChevronDown className="size-3 shrink-0 opacity-55" /></> : null}
</button>
</Dropdown>
);

View File

@@ -8,7 +8,7 @@ import { MAX_UPSCALE_LONG_EDGE, resolveUpscaleSize, type ImageUpscaleAlgorithm,
export type CanvasImageUpscaleParams = ImageUpscaleParams;
const algorithms: Array<{ value: ImageUpscaleAlgorithm; title: string; description: string }> = [
{ value: "high", title: "高清插值", description: "适合照片和细节图" },
{ value: "high", title: "高质量插值", description: "适合照片和细节图" },
{ value: "bilinear", title: "双线性", description: "平滑、速度快" },
{ value: "nearest", title: "最近邻", description: "适合像素风格" },
];
@@ -53,7 +53,8 @@ export function CanvasNodeUpscaleDialog({ dataUrl, open, onClose, onConfirm }: {
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={820} centered destroyOnHidden>
<div className="space-y-5">
<div>
<h2 className="text-xl font-semibold">图片放大</h2>
<h2 className="text-xl font-semibold">调整尺寸</h2>
<p className="mt-2 text-sm opacity-60">通过插值放大像素尺寸,另存为新图片,保留原图。这不是 AI 超分,不会生成新的真实细节。</p>
</div>
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_360px]">
<div className="rounded-xl border p-4">

View File

@@ -61,6 +61,7 @@ type CanvasNodeProps = {
onOpenTextEditor?: (node: CanvasNodeData) => void;
onOpenDirector?: (node: CanvasNodeData) => void;
onOpenDrawing?: (node: CanvasNodeData) => void;
onMediaPlayRequest?: (nodeId: string) => void;
onContextMenu: (event: React.MouseEvent, nodeId: string) => void;
};
@@ -105,6 +106,7 @@ export const CanvasNode = React.memo(function CanvasNode({
onOpenTextEditor,
onOpenDirector,
onOpenDrawing,
onMediaPlayRequest,
onContextMenu,
}: CanvasNodeProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
@@ -375,6 +377,7 @@ export const CanvasNode = React.memo(function CanvasNode({
onToggleBatch={() => onToggleBatch?.(data.id)}
reduceMediaEffects={reduceMediaEffects}
mediaActive={mediaActive}
onMediaPlayRequest={onMediaPlayRequest}
/>
</div>

View File

@@ -28,7 +28,7 @@ export function CanvasPortraitTexturePopover({ value, placement = "topLeft", onC
<div className="w-[min(350px,calc(100vw-32px))] p-2.5" style={{ color: theme.node.text }}>
<div className="mb-2 flex items-center gap-1.5 px-0.5">
<SlidersHorizontal className="size-3.5" style={{ color: theme.accent.primary }} />
<span className="text-xs font-medium">人物质感调节</span>
<span className="text-xs font-medium">质感调整</span>
</div>
<div className="space-y-1">
{PORTRAIT_TEXTURE_GROUPS.map((group) => (
@@ -75,10 +75,10 @@ export function CanvasPortraitTexturePopover({ value, placement = "topLeft", onC
type="button"
className="flex h-6 min-w-0 items-center gap-1 rounded-md px-1.5 transition-colors hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 motion-reduce:transition-none"
style={{ background: theme.accent.primarySoft, color: theme.accent.primary, outlineColor: theme.accent.primary }}
aria-label="打开人物质感调节面板"
aria-label="打开质感调整面板"
>
<SlidersHorizontal className="size-3 shrink-0" />
<span className="truncate text-[var(--fs-tiny)] font-medium">人物质感调节</span>
<span className="truncate text-[var(--fs-tiny)] font-medium">质感调整</span>
</button>
</Popover>
);

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { Input, Popover } from "antd";
import { Search, WandSparkles } from "lucide-react";
import { LayoutTemplate, Search, WandSparkles } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
@@ -75,6 +75,7 @@ export function CanvasPresetPicker({
onSelect,
compact = false,
dense = false,
appearance = "default",
}: {
mode: CanvasGenerationMode;
skillReferences?: CanvasResourceReference[];
@@ -83,6 +84,7 @@ export function CanvasPresetPicker({
onSelect: (preset: CanvasPromptPreset) => void;
compact?: boolean;
dense?: boolean;
appearance?: "default" | "quiet";
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [internalOpen, setInternalOpen] = useState(false);
@@ -173,14 +175,14 @@ export function CanvasPresetPicker({
>
<button
type="button"
className={`canvas-preset-picker-trigger inline-flex shrink-0 items-center justify-center gap-1 rounded-lg transition hover:brightness-110 focus-visible:outline-none ${compact ? "size-6" : dense ? "h-6 px-1.5" : "h-7 px-2"}`}
style={{ background: theme.accent.primarySoft, color: theme.accent.primary }}
title="打开提示词预设"
aria-label="打开提示词预设"
className={`canvas-preset-picker-trigger ${appearance === "quiet" ? "canvas-node-composer-header-action" : ""} inline-flex shrink-0 items-center justify-center gap-1 rounded-lg transition focus-visible:outline-none ${compact ? "size-6" : dense ? "h-6 px-1.5" : "h-7 px-2"}`}
style={appearance === "quiet" ? undefined : { background: theme.accent.primarySoft, color: theme.accent.primary }}
title={appearance === "quiet" ? "提示词模板" : "打开提示词预设"}
aria-label={appearance === "quiet" ? "提示词模板" : "打开提示词预设"}
aria-expanded={actualOpen}
>
<WandSparkles className={dense ? "size-3" : "size-3.5"} />
{compact ? null : <span className="text-[var(--fs-tiny)] font-semibold">预设</span>}
{appearance === "quiet" ? <LayoutTemplate className="size-3" /> : <WandSparkles className={dense ? "size-3" : "size-3.5"} />}
{compact ? null : <span className="text-[var(--fs-tiny)] font-medium">{appearance === "quiet" ? "提示词模板" : "预设"}</span>}
</button>
</Popover>
);

View File

@@ -75,6 +75,7 @@ export function CanvasProjectAssetModal({
return (
<AssetLibraryPickerModal
remoteLibrary={!detail}
open={open}
items={pickerItems}
categoryLabels={{ ...categoryLabels, ...externalAssetSources.categoryLabels }}

View File

@@ -142,7 +142,7 @@ export function CanvasProjectCard({ project, projectName, variant = "library", r
);
}
export function ProjectPreview({ project, preferLatestImage = false }: { project: CanvasProject; preferLatestImage?: boolean }) {
export function ProjectPreview({ project, preferLatestImage = false }: { project: Pick<CanvasProject, "id" | "nodes">; preferLatestImage?: boolean }) {
const syncProgress = useSyncProgressStore((state) => state.syncingProjects[project.id]);
const isSyncing = Boolean(syncProgress && (syncProgress.phase === "uploading" || syncProgress.phase === "saving"));
const media = projectPreviewMedia(project.nodes, preferLatestImage);

View File

@@ -6,7 +6,7 @@ import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface";
import { useCanvasOverlayLayer } from "@/components/canvas/canvas-overlay-layer";
import { canvasThemes } from "@/lib/canvas-theme";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport";
import { subscribeCanvasGraphicsViewportPreview, subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData, type ConnectionHandle, type Position, type ViewportTransform } from "@/types/canvas";
@@ -88,14 +88,8 @@ export function CanvasSelectionToolbar({ anchorRef, containerRef, count, childre
);
}
type NodePanelPlacement = "above" | "below";
// 拖动时保留当前上下方向,并允许短暂越过安全边界,避免面板在临界位置反复翻转。
const NODE_PANEL_PLACEMENT_BUFFER = 32;
export function CanvasNodePanelOverlay({ node, viewport, containerRef, panelWidth, panelHeight = 190, dragOffset, isDragging = false, children }: { node: CanvasNodeData; viewport: ViewportTransform; containerRef: RefObject<HTMLDivElement | null>; panelWidth?: number; panelHeight?: number; dragOffset?: Position | null; isDragging?: boolean; children: ReactNode }) {
const panelRef = useRef<HTMLDivElement>(null);
const placementRef = useRef<NodePanelPlacement | null>(null);
const { bringToFront, zIndex } = useCanvasOverlayLayer(`node-panel:${node.id}`, "var(--z-modal-overlay)");
const initialWidth = resolveNodePanelWidth(node, viewport, panelWidth);
const initialPosition = getNodePanelPosition(node, viewport, { width: containerRef.current?.clientWidth || 0, height: containerRef.current?.clientHeight || 0 }, initialWidth, panelHeight, dragOffset);
@@ -108,36 +102,29 @@ export function CanvasNodePanelOverlay({ node, viewport, containerRef, panelWidt
const container = containerRef.current;
const panel = panelRef.current;
if (!container || !panel) return;
let liveViewport = viewport;
let viewportSize = { width: container.clientWidth, height: container.clientHeight };
const update = (nextViewport: ViewportTransform) => {
liveViewport = nextViewport;
const nextWidth = resolveNodePanelWidth(node, nextViewport, panelWidth);
const nextHeight = panel.offsetHeight || panelHeight;
panel.style.width = `${nextWidth}px`;
panel.style.removeProperty("height");
const position = getNodePanelPosition(
node,
nextViewport,
{ width: container.clientWidth, height: container.clientHeight },
viewportSize,
nextWidth,
nextHeight,
panelHeight,
dragOffset,
isDragging ? placementRef.current : null,
isDragging ? NODE_PANEL_PLACEMENT_BUFFER : 0,
);
if (isDragging && placementRef.current && placementRef.current !== position.placement) {
panel.classList.remove("canvas-node-panel-placement-change");
// 强制重排后重新加 class,确保每次上下翻转都能重新播放虚化动画。
void panel.offsetWidth;
panel.classList.add("canvas-node-panel-placement-change");
}
placementRef.current = isDragging ? position.placement : null;
panel.style.left = `${position.left}px`;
panel.style.top = `${position.top}px`;
panel.style.transform = `translate3d(${position.left}px, ${position.top}px, 0)`;
};
update(viewport);
const resizeObserver = new ResizeObserver(() => update(viewport));
const resizeObserver = new ResizeObserver(() => {
viewportSize = { width: container.clientWidth, height: container.clientHeight };
update(liveViewport);
});
resizeObserver.observe(container);
resizeObserver.observe(panel);
const unsubscribeViewport = subscribeCanvasViewportPreview(container, update);
const unsubscribeViewport = subscribeCanvasGraphicsViewportPreview(container, update);
return () => {
resizeObserver.disconnect();
unsubscribeViewport();
@@ -148,8 +135,9 @@ export function CanvasNodePanelOverlay({ node, viewport, containerRef, panelWidt
<div
ref={panelRef}
data-canvas-no-zoom
data-canvas-node-panel
className="thin-scrollbar absolute max-w-[calc(100%_-_24px)] overflow-y-auto"
style={{ left: initialPosition.left, top: initialPosition.top, width: initialWidth, maxHeight: "calc(100% - 84px)", zIndex }}
style={{ left: 0, top: 0, transform: `translate3d(${initialPosition.left}px, ${initialPosition.top}px, 0)`, width: initialWidth, maxHeight: "calc(100% - 84px)", zIndex }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDownCapture={bringToFront}
onFocusCapture={bringToFront}
@@ -257,33 +245,18 @@ function getConnectionMenuPosition(position: Position, viewport: ViewportTransfo
};
}
function getNodePanelPosition(node: CanvasNodeData, viewport: ViewportTransform, viewportSize: { width: number; height: number }, panelWidth: number, panelHeight: number, dragOffset?: Position | null, lockedPlacement?: NodePanelPlacement | null, placementBuffer = 0) {
export function getNodePanelPosition(node: CanvasNodeData, viewport: ViewportTransform, viewportSize: { width: number; height: number }, panelWidth: number, _panelHeight: number, dragOffset?: Position | null) {
const gap = 10;
const margin = 12;
const topBoundary = 72;
const offsetX = dragOffset?.x || 0;
const offsetY = dragOffset?.y || 0;
const nodeCenterX = viewport.x + (node.position.x + offsetX + node.width / 2) * viewport.k;
const nodeTop = viewport.y + (node.position.y + offsetY) * viewport.k;
const nodeBottom = viewport.y + (node.position.y + offsetY + node.height) * viewport.k;
const maxLeft = Math.max(margin, viewportSize.width - panelWidth - margin);
const left = clamp(nodeCenterX - panelWidth / 2, margin, maxLeft);
const belowTop = nodeBottom + gap;
const aboveTop = nodeTop - panelHeight - gap;
const canFitBelow = belowTop + panelHeight <= viewportSize.height - margin;
const canFitAbove = aboveTop >= topBoundary;
let placement: NodePanelPlacement;
if (lockedPlacement === "below") {
placement = !canFitBelow && belowTop + panelHeight > viewportSize.height - margin + placementBuffer && canFitAbove ? "above" : "below";
} else if (lockedPlacement === "above") {
placement = !canFitAbove && aboveTop < topBoundary - placementBuffer && canFitBelow ? "below" : "above";
} else {
placement = canFitBelow ? "below" : "above";
}
const preferredTop = placement === "below" ? belowTop : aboveTop;
return {
left,
top: clamp(preferredTop, topBoundary, Math.max(topBoundary, viewportSize.height - panelHeight - margin)),
placement,
top: nodeBottom + gap,
placement: "below" as const,
};
}

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { resolveCanvasAppearance, resolveCanvasGridColor, type CanvasAppearance } from "@/lib/canvas/canvas-appearance";
import { resolveCanvasPointerIntent } from "@/lib/canvas/canvas-selection";
import type { CanvasBackgroundMode } from "@/lib/canvas-theme";
import { applyCanvasLiveViewport, canvasDotGridPx, canvasDotPx, subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport";
import { useThemeStore } from "@/stores/use-theme-store";
@@ -64,6 +65,7 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
const interactingRef = useRef(false);
const touchPointsRef = useRef(new Map<number, TouchPoint>());
const pinchStateRef = useRef<PinchState>({ active: false, pointerIds: [-1, -1], initialDistance: 1, worldX: 0, worldY: 0, initialScale: viewport.k });
const spacePressedRef = useRef(false);
const [isSpacePressed, setIsSpacePressed] = useState(false);
const [isPanning, setIsPanning] = useState(false);
@@ -88,6 +90,7 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
if (frameRef.current) cancelAnimationFrame(frameRef.current);
if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
delete containerRef.current?.dataset.canvasViewportInteracting;
document.body.style.cursor = "";
},
[containerRef],
);
@@ -126,19 +129,30 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.code !== "Space") return;
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return;
if (event.target instanceof Element && event.target.closest("input,textarea,select,button,[contenteditable='true']")) return;
event.preventDefault();
spacePressedRef.current = true;
setIsSpacePressed(true);
};
const handleKeyUp = (event: KeyboardEvent) => {
if (event.code === "Space") setIsSpacePressed(false);
if (event.code !== "Space") return;
spacePressedRef.current = false;
setIsSpacePressed(false);
};
const handleBlur = () => {
spacePressedRef.current = false;
setIsSpacePressed(false);
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("blur", handleBlur);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("blur", handleBlur);
};
}, []);
@@ -200,8 +214,18 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
const isBackgroundClick = !target?.closest("[data-node-id],[data-connection-id]");
const isTouch = event.pointerType === "touch";
const hasSelectionModifier = event.shiftKey || event.ctrlKey || event.metaKey || event.altKey;
if (event.button === 0 && !isSpacePressed && !isTouch && isBackgroundClick && (hasSelectionModifier || boxSelectEnabled)) {
const pointerIntent = resolveCanvasPointerIntent({
altKey: event.altKey,
background: isBackgroundClick,
boxSelectEnabled,
button: event.button,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
pointerType: event.pointerType,
shiftKey: event.shiftKey,
spacePressed: spacePressedRef.current,
});
if (pointerIntent === "select") {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
onCanvasMouseDown?.(event);
@@ -252,7 +276,7 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
return;
}
if (isBackgroundClick && (event.button === 1 || event.button === 0)) {
if (pointerIntent === "pan") {
const current = viewportRef.current;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
@@ -322,7 +346,7 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
delete containerRef.current?.dataset.canvasViewportInteracting;
syncViewport();
setIsPanning(false);
document.body.style.cursor = "default";
document.body.style.cursor = "";
return;
}
@@ -339,7 +363,7 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
delete containerRef.current?.dataset.canvasViewportInteracting;
syncViewport();
setIsPanning(false);
document.body.style.cursor = "default";
document.body.style.cursor = "";
};
window.addEventListener("pointermove", handlePointerMove);
@@ -373,7 +397,8 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
return (
<div
ref={containerRef}
className={`relative h-full w-full select-none overflow-hidden touch-none ${isPanning ? "cursor-grabbing" : boxSelectEnabled ? "cursor-crosshair" : "cursor-grab"}`}
data-canvas-pan-state={isPanning ? "grabbing" : isSpacePressed || !boxSelectEnabled ? "grab" : undefined}
className={`relative h-full w-full select-none overflow-hidden touch-none ${isPanning ? "cursor-grabbing" : isSpacePressed || !boxSelectEnabled ? "cursor-grab" : "canvas-cursor-select"}`}
style={{
background: resolvedAppearance.background,
overscrollBehavior: "none",