perf(*): 创作工作台 - 优化首页按需加载与画布交互并修复历史迁移冲突
This commit is contained in:
@@ -60,6 +60,7 @@ export function AssetPickerModal({ open, multiple = true, onInsert, onClose }: P
|
||||
|
||||
return (
|
||||
<AssetLibraryPickerModal
|
||||
remoteLibrary
|
||||
open={open}
|
||||
items={items}
|
||||
categoryLabels={{ ...categoryLabels, ...externalAssetSources.categoryLabels }}
|
||||
|
||||
@@ -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]) },
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -75,6 +75,7 @@ export function CanvasProjectAssetModal({
|
||||
|
||||
return (
|
||||
<AssetLibraryPickerModal
|
||||
remoteLibrary={!detail}
|
||||
open={open}
|
||||
items={pickerItems}
|
||||
categoryLabels={{ ...categoryLabels, ...externalAssetSources.categoryLabels }}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user