feat(canvas): 自由画布 - 增强空白起点、外观设置与节点连接体验 (#356)

This commit is contained in:
vv-get-ti
2026-08-31 06:49:43 +08:00
committed by GitHub
parent cc4aa736ff
commit 151e99f8d9
22 changed files with 925 additions and 113 deletions

View File

@@ -12,6 +12,7 @@ import { uploadImage } from "@/services/image-storage";
import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import copyToClipboard from "copy-to-clipboard";
import { nanoid } from "nanoid";
import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, normalizeCanvasAppearance, resolveCanvasAppearance, writeCanvasAppearanceDefault, type CanvasAppearance } from "@/lib/canvas/canvas-appearance";
import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme";
import { persistCanvasMediaPerformanceMode, readCanvasMediaPerformanceMode } from "@/lib/canvas/canvas-performance-mode";
import { summarizeCanvasContext } from "@/lib/canvas/canvas-context-summary";
@@ -55,6 +56,7 @@ import { InfiniteCanvas } from "@/components/canvas/infinite-canvas";
import { Minimap } from "@/components/canvas/canvas-mini-map";
import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import { CanvasToolbar } from "@/components/canvas/canvas-toolbar";
import { useCanvasCreateCommands } from "@/components/canvas/use-canvas-create-commands";
import { AssetPickerModal, type InsertAssetPayload } from "@/components/canvas/asset-picker-modal";
import { getProject } from "@/services/api/projects";
import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
@@ -71,6 +73,7 @@ import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay } from "@/components
import { CanvasOverlayLayerContainer, CanvasOverlayLayerProvider } from "@/components/canvas/canvas-overlay-layer";
import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-graphics-layer";
import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry";
import { resolveCanvasEmptyStateKind } from "@/lib/canvas/canvas-starter";
import { failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild } from "@/lib/canvas/canvas-image-batch-retry";
import { createCanvasNode, getInputSummary, isHiddenBatchChild, persistCanvasWorkspaceMode, readCanvasWorkspaceMode } from "@/lib/canvas/canvas-project-domain";
import { stampCanvasNodeChanges } from "@/lib/canvas/canvas-node-timestamps";
@@ -215,7 +218,9 @@ function InfiniteCanvasPage() {
const assets = useAssetStore((state) => state.assets);
const assetsHydrated = useAssetStore((state) => state.hydrated);
const cleanupAssetImages = useAssetStore((state) => state.cleanupImages);
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const colorTheme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const theme = canvasThemes[colorTheme];
const defaultDrawingEngine = useUserStore((state) => state.drawingEngine.defaultEngine);
const shortDramaEnabled = useUserStore((state) => state.features.shortDramaEnabled);
const directorOnboardingScope = useUserStore((state) => state.user?.id?.trim() || "");
@@ -244,6 +249,7 @@ function InfiniteCanvasPage() {
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [isMiniMapOpen, setIsMiniMapOpen] = useState(false);
const [canvasAppearance, setCanvasAppearance] = useState<CanvasAppearance>(() => canvasAppearanceForTheme(colorTheme));
const [backgroundMode, setBackgroundMode] = useState<CanvasBackgroundMode>("lines");
const [showImageInfo, setShowImageInfo] = useState(false);
const [canvasTool, setCanvasTool] = useState<CanvasToolMode>("move");
@@ -335,18 +341,32 @@ function InfiniteCanvasPage() {
});
}, [canvasStorageScope, projectId]);
const resolvedCanvasAppearance = useMemo(() => resolveCanvasAppearance(canvasAppearance, colorTheme), [canvasAppearance, colorTheme]);
const applyCanvasAppearance = useCallback((next: CanvasAppearance) => {
const fallback = canvasAppearanceBaseTheme(next, colorTheme);
const normalized = normalizeCanvasAppearance(next, fallback);
setCanvasAppearance(normalized);
setTheme(canvasAppearanceBaseTheme(normalized, fallback));
}, [colorTheme, setTheme]);
const saveCanvasAppearanceDefault = useCallback((next: CanvasAppearance) => {
writeCanvasAppearanceDefault({ appearance: next, backgroundMode });
message.success("已保存为当前账号在本机的新建画布默认外观");
}, [backgroundMode, message]);
const { getHistoryCleanupContext, historyPausedRef, historyState, redoCanvas, resetHistory, undoCanvas } = useCanvasHistory({
projectLoaded,
nodes,
connections,
chatSessions,
activeChatId,
canvasAppearance,
backgroundMode,
showImageInfo,
setNodes,
setConnections,
setChatSessions,
setActiveChatId,
applyCanvasAppearance,
setBackgroundMode,
setShowImageInfo,
setSelectedNodeIds,
@@ -368,6 +388,7 @@ function InfiniteCanvasPage() {
connections,
chatSessions,
activeChatId,
canvasAppearance,
backgroundMode,
showImageInfo,
viewport,
@@ -379,6 +400,7 @@ function InfiniteCanvasPage() {
setConnections,
setChatSessions,
setActiveChatId,
setCanvasAppearance,
setBackgroundMode,
setShowImageInfo,
setViewport,
@@ -1944,20 +1966,47 @@ function InfiniteCanvasPage() {
}
focusCanvasNode(styleNode.id);
}, [focusCanvasNode, message, nodesRef]);
const emptyCanvasState = nodes.length ? null : !shortDramaEnabled ? (
<CanvasFreeformEmptyState onUpload={() => handleUploadRequest()} onAddText={() => createNode(CanvasNodeType.Text)} />
) : currentProject?.projectId ? (
const freeformCreateCommands = useCanvasCreateCommands({
workspaceMode,
isProjectLinked: Boolean(shortDramaEnabled && currentProject?.projectId),
handlers: {
onAddText: () => createNode(CanvasNodeType.Text),
onAddImage: () => createNode(CanvasNodeType.Image),
onAddVideo: () => createNode(CanvasNodeType.Video),
onAddAudio: () => createNode(CanvasNodeType.Audio),
onAddScript: () => createNode(CanvasNodeType.Script),
onAddFrame: () => createNode(CanvasNodeType.Frame),
onAddFolder: createFolder,
onAddDrawing: () => createNode(CanvasNodeType.Drawing),
onAddWorkflow: () => createNode(CanvasNodeType.Config),
onAddExtensionNode: (type) => createNode(type),
onChooseStyle: () => setStylePickerOpen(true),
onOpenDirector: () => setDirectorTemplateRequest({}),
onUpload: () => handleUploadRequest(),
onOpenMyAssets: () => openCanvasAssetLibrary(),
onOpenProjectCharacters: () => openProjectAssets("character"),
},
});
const emptyStateKind = resolveCanvasEmptyStateKind({
nodeCount: nodes.length,
shortDramaEnabled,
isProjectLinked: Boolean(currentProject?.projectId),
starterMode: currentProject?.starterMode,
});
const emptyCanvasState = emptyStateKind === "freeform" ? (
<CanvasFreeformEmptyState commands={freeformCreateCommands} />
) : emptyStateKind === "linked" ? (
<CanvasLinkedProjectEmptyState
projectName={linkedProjectQuery.data?.project.name || currentProject.title}
projectName={linkedProjectQuery.data?.project.name || currentProject?.title || "项目画布"}
hasChapter={Boolean(linkedProjectQuery.data?.units.length)}
onAddFirstChapter={() => {
const first = linkedProjectQuery.data?.units.slice().sort((left, right) => left.position - right.position)[0];
if (first) void handleProjectChapterInsert({ id: first.id, projectId: currentProject.projectId!, title: first.title, position: first.position });
if (first) void handleProjectChapterInsert({ id: first.id, projectId: linkedProjectId, title: first.title, position: first.position });
}}
onOpenAssets={() => openProjectAssets()}
onAddText={() => createNode(CanvasNodeType.Text)}
/>
) : (
) : emptyStateKind === "guided" ? (
<CanvasShortDramaEmptyState
onCreatePipeline={createShortDramaPipeline}
onOpenAgent={() => {
@@ -1965,11 +2014,12 @@ function InfiniteCanvasPage() {
setAgentMode("online");
openAgent("online");
}}
onStartFreeform={() => updateProject(projectId, { starterMode: "freeform" })}
onUpload={() => handleUploadRequest()}
onAddText={() => createNode(CanvasNodeType.Text)}
onAddScript={() => createNode(CanvasNodeType.Script)}
/>
);
) : null;
if (!projectLoaded) return <CanvasRefreshShell />;
return (
@@ -1980,7 +2030,7 @@ function InfiniteCanvasPage() {
>
跳转到画布主内容
</a>
<main id="canvas-main" tabIndex={-1} className="flex h-full min-h-0 overflow-hidden outline-none" style={{ background: theme.canvas.background, color: theme.node.text }}>
<main id="canvas-main" tabIndex={-1} className="flex h-full min-h-0 overflow-hidden outline-none" style={{ background: resolvedCanvasAppearance.background, color: theme.node.text }}>
{!focusMode && shortDramaEnabled && currentProject?.projectId ? (
<CanvasProjectSidebar projectId={currentProject.projectId} detail={linkedProjectQuery.data} onAddChapter={handleProjectChapterInsert} onLocateStyle={locateProjectStyleNode} onOpenAssets={() => openProjectAssets()} />
) : null}
@@ -2078,6 +2128,7 @@ function InfiniteCanvasPage() {
<InfiniteCanvas
containerRef={containerRef}
viewport={viewport}
appearance={canvasAppearance}
backgroundMode={backgroundMode}
graphicsLayer={
<CanvasLeaferGraphicsLayer
@@ -2217,6 +2268,7 @@ function InfiniteCanvasPage() {
isProjectLinked={Boolean(shortDramaEnabled && currentProject?.projectId)}
canUndo={historyState.canUndo}
canRedo={historyState.canRedo}
appearance={canvasAppearance}
backgroundMode={backgroundMode}
showImageInfo={showImageInfo}
onAddImage={() => createNode(CanvasNodeType.Image)}
@@ -2237,6 +2289,8 @@ function InfiniteCanvasPage() {
onDelete={() => deleteNodes(new Set(selectedNodeIds))}
onClear={() => setClearConfirmOpen(true)}
onDeselect={deselectCanvas}
onAppearanceChange={applyCanvasAppearance}
onSaveAppearanceDefault={saveCanvasAppearanceDefault}
onBackgroundModeChange={setBackgroundMode}
onShowImageInfoChange={setShowImageInfo}
onOpenMyAssets={() => {

View File

@@ -13,6 +13,7 @@ import { InfiniteCanvas } from "@/components/canvas/infinite-canvas";
import { FullScreenLoader } from "@/components/ui/aceternity/full-screen-loader";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, normalizeCanvasAppearance, resolveCanvasAppearance, type CanvasAppearance } from "@/lib/canvas/canvas-appearance";
import { canvasThemes } from "@/lib/canvas-theme";
import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, isCanvasFolderNode, isFrameNode, isNodeHiddenByCollapsedFrame, resolveFrameConnection } from "@/lib/canvas/canvas-frame";
import { ensureMediaNodeMinimumSize } from "@/lib/canvas/canvas-node-size";
@@ -26,7 +27,8 @@ type DragState = { primaryId: string; nodeIds: string[]; startX: number; startY:
export default function SharedCanvasPage() {
const { token = "" } = useParams();
const { message } = App.useApp();
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const colorTheme = useThemeStore((state) => state.theme);
const theme = canvasThemes[colorTheme];
const containerRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<ViewportTransform>({ x: 0, y: 0, k: 1 });
const dragRef = useRef<DragState | null>(null);
@@ -34,6 +36,7 @@ export default function SharedCanvasPage() {
const [title, setTitle] = useState("共享画布");
const [nodes, setNodes] = useState<CanvasNodeData[]>([]);
const [connections, setConnections] = useState<Awaited<ReturnType<typeof getPublicCanvasShare>>["project"]["connections"]>([]);
const [appearance, setAppearance] = useState<CanvasAppearance>(() => canvasAppearanceForTheme(colorTheme));
const [backgroundMode, setBackgroundMode] = useState<"lines" | "dots" | "blank">("lines");
const [viewport, setViewport] = useState<ViewportTransform>({ x: 0, y: 0, k: 1 });
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
@@ -74,12 +77,22 @@ export default function SharedCanvasPage() {
useEffect(() => {
let active = true;
const themeBeforeShare = useThemeStore.getState().theme;
let appliedShareTheme: typeof themeBeforeShare | null = null;
let themeChangedAfterApply = false;
const unsubscribeTheme = useThemeStore.subscribe((state, previous) => {
if (appliedShareTheme && state.theme !== previous.theme && state.theme !== appliedShareTheme) themeChangedAfterApply = true;
});
setLoading(true);
getPublicCanvasShare(token).then(({ project }) => {
if (!active) return;
setTitle(project.title || "共享画布");
setNodes((project.nodes || []).map(ensureMediaNodeMinimumSize));
setConnections(project.connections || []);
const nextAppearance = project.appearance ? normalizeCanvasAppearance(project.appearance, themeBeforeShare) : canvasAppearanceForTheme(themeBeforeShare);
setAppearance(nextAppearance);
appliedShareTheme = canvasAppearanceBaseTheme(nextAppearance, themeBeforeShare);
useThemeStore.getState().setTheme(appliedShareTheme);
setBackgroundMode(project.backgroundMode || "lines");
const initial = project.viewport || { x: 0, y: 0, k: 1 };
viewportRef.current = initial;
@@ -89,7 +102,13 @@ export default function SharedCanvasPage() {
}).finally(() => {
if (active) setLoading(false);
});
return () => { active = false; };
return () => {
active = false;
unsubscribeTheme();
if (appliedShareTheme && !themeChangedAfterApply && useThemeStore.getState().theme === appliedShareTheme) {
useThemeStore.getState().setTheme(themeBeforeShare);
}
};
}, [token]);
useEffect(() => {
@@ -222,7 +241,7 @@ export default function SharedCanvasPage() {
if (loadError) return <div className="grid h-screen place-items-center px-5" style={{ background: theme.canvas.background }}><WorkspaceState icon="error" title="分享链接不可用" description={loadError} action={<Link to="/"><Button>返回首页</Button></Link>} /></div>;
return (
<main className="relative h-screen overflow-hidden" style={{ background: theme.canvas.background, color: theme.node.text }}>
<main className="relative h-screen overflow-hidden" style={{ background: resolveCanvasAppearance(appearance, colorTheme).background, color: theme.node.text }}>
<header className="pointer-events-none absolute inset-x-0 top-0 z-[var(--z-panel-floating)] flex h-16 items-center justify-between px-5">
<div className="pointer-events-auto flex min-w-0 items-center gap-3">
<Share2 className="size-4" style={{ color: theme.node.muted }} />
@@ -232,7 +251,7 @@ export default function SharedCanvasPage() {
<Link className="pointer-events-auto" to="/login"><Button type="text" icon={<LogIn className="size-4" />}>登录</Button></Link>
</header>
<InfiniteCanvas containerRef={containerRef} viewport={viewport} backgroundMode={backgroundMode} onViewportChange={onViewportChange} onViewportPreviewChange={(next) => { viewportRef.current = next; }} onCanvasDeselect={() => { setSelectedNodeId(null); setContextMenu(null); }} onContextMenu={(event) => openContextMenu(event)} onDrop={(event) => { event.preventDefault(); unauthorized(); }}>
<InfiniteCanvas containerRef={containerRef} viewport={viewport} appearance={appearance} backgroundMode={backgroundMode} onViewportChange={onViewportChange} onViewportPreviewChange={(next) => { viewportRef.current = next; }} onCanvasDeselect={() => { setSelectedNodeId(null); setContextMenu(null); }} onContextMenu={(event) => openContextMenu(event)} onDrop={(event) => { event.preventDefault(); unauthorized(); }}>
<svg className="absolute overflow-visible" viewBox={`${connectionBounds.left} ${connectionBounds.top} ${connectionBounds.width} ${connectionBounds.height}`} style={{ left: connectionBounds.left, top: connectionBounds.top, width: connectionBounds.width, height: connectionBounds.height, pointerEvents: "none", zIndex: 0 }}>
{visibleConnections.map(({ connection, from, to }) => <ConnectionPath key={connection.id} connection={connection} from={from} to={to} active={false} onSelect={() => setInfoNodeId(to.id)} />)}
</svg>

View File

@@ -5,6 +5,7 @@ import { nanoid } from "nanoid";
import type { PendingConnectionCreate } from "@/components/canvas/canvas-workspace-overlays";
import { getNodeSpec } from "@/constant/canvas";
import { batchSourceRestriction, buildBatchConnectionCreateRequest, hasBatchConnectionCandidate, planBatchConnections, type CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-connection";
import { connectedNodeCenterFromEdgeDrop } from "@/lib/canvas/canvas-connected-node-placement";
import { canvasConnectionError } from "@/lib/canvas/canvas-connection-policy";
import { attachNodeToStoryboardRow, createCanvasNode, getConnectionTargetAnchor, isHiddenBatchChild, normalizeConnection, storyboardHandleAtY, storyboardPromptTemplateMetadata, storyboardRowFromHandle } from "@/lib/canvas/canvas-project-domain";
import { createCanvasDrawingFromImage } from "@/lib/canvas/canvas-drawing-storage";
@@ -255,7 +256,9 @@ export function useCanvasConnectionController({
: sourceNodeForQuickCreate.position.x - 96 - spec.width / 2,
y: anchorY,
}
: pending.position;
: batchSourceNodeIds.length
? pending.position
: connectedNodeCenterFromEdgeDrop(pending.position, spec, pending.connection.handleType);
const newNode = createCanvasNode(nodeType, position, metadata);
if (nodeType === CanvasNodeType.Config && selectedWorkflowProvider) newNode.title = selectedWorkflowProvider === "runninghub" ? "RunningHub 工作流" : "ComfyUI Bridge";
if (storyboardRow) newNode.title = `镜头 ${storyboardRow.shotNumber} · 视频`;

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
import type { CanvasAppearance } from "@/lib/canvas/canvas-appearance";
import type { CanvasBackgroundMode } from "@/lib/canvas-theme";
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ContextMenuState } from "@/types/canvas";
@@ -8,6 +9,7 @@ export type CanvasHistorySnapshot = {
connections: CanvasConnection[];
chatSessions: CanvasAssistantSession[];
activeChatId: string | null;
canvasAppearance: CanvasAppearance;
backgroundMode: CanvasBackgroundMode;
showImageInfo: boolean;
};
@@ -34,6 +36,7 @@ type CanvasHistoryPatch = {
connections?: EntityPatch<CanvasConnection>;
chatSessions?: EntityPatch<CanvasAssistantSession>;
activeChatId?: ValuePatch<string | null>;
canvasAppearance?: ValuePatch<CanvasAppearance>;
backgroundMode?: ValuePatch<CanvasBackgroundMode>;
showImageInfo?: ValuePatch<boolean>;
};
@@ -44,6 +47,7 @@ type UseCanvasHistoryOptions = CanvasHistorySnapshot & {
setConnections: Dispatch<SetStateAction<CanvasConnection[]>>;
setChatSessions: Dispatch<SetStateAction<CanvasAssistantSession[]>>;
setActiveChatId: Dispatch<SetStateAction<string | null>>;
applyCanvasAppearance: (appearance: CanvasAppearance) => void;
setBackgroundMode: Dispatch<SetStateAction<CanvasBackgroundMode>>;
setShowImageInfo: Dispatch<SetStateAction<boolean>>;
setSelectedNodeIds: Dispatch<SetStateAction<Set<string>>>;
@@ -57,12 +61,14 @@ export function useCanvasHistory({
connections,
chatSessions,
activeChatId,
canvasAppearance,
backgroundMode,
showImageInfo,
setNodes,
setConnections,
setChatSessions,
setActiveChatId,
applyCanvasAppearance,
setBackgroundMode,
setShowImageInfo,
setSelectedNodeIds,
@@ -78,8 +84,8 @@ export function useCanvasHistory({
const [historyState, setHistoryState] = useState({ canUndo: false, canRedo: false });
const createHistorySnapshot = useCallback(
(): CanvasHistorySnapshot => ({ nodes, connections, chatSessions, activeChatId, backgroundMode, showImageInfo }),
[activeChatId, backgroundMode, chatSessions, connections, nodes, showImageInfo],
(): CanvasHistorySnapshot => ({ nodes, connections, chatSessions, activeChatId, canvasAppearance, backgroundMode, showImageInfo }),
[activeChatId, backgroundMode, canvasAppearance, chatSessions, connections, nodes, showImageInfo],
);
const clearCommitTimer = useCallback(() => {
@@ -109,6 +115,7 @@ export function useCanvasHistory({
setConnections(snapshot.connections);
setChatSessions(snapshot.chatSessions);
setActiveChatId(snapshot.activeChatId);
applyCanvasAppearance(snapshot.canvasAppearance);
setBackgroundMode(snapshot.backgroundMode);
setShowImageInfo(snapshot.showImageInfo);
setSelectedNodeIds(new Set());
@@ -120,7 +127,7 @@ export function useCanvasHistory({
applyTimerRef.current = null;
setHistoryState({ canUndo: historyRef.current.past.length > 0, canRedo: historyRef.current.future.length > 0 });
});
}, [clearCommitTimer, setActiveChatId, setBackgroundMode, setChatSessions, setConnections, setContextMenu, setNodes, setSelectedConnectionId, setSelectedNodeIds, setShowImageInfo]);
}, [applyCanvasAppearance, clearCommitTimer, setActiveChatId, setBackgroundMode, setChatSessions, setConnections, setContextMenu, setNodes, setSelectedConnectionId, setSelectedNodeIds, setShowImageInfo]);
const undoCanvas = useCallback(() => {
const patch = historyRef.current.past.pop();
@@ -176,6 +183,7 @@ function snapshotsShareReferences(before: CanvasHistorySnapshot, after: CanvasHi
&& before.connections === after.connections
&& before.chatSessions === after.chatSessions
&& before.activeChatId === after.activeChatId
&& before.canvasAppearance === after.canvasAppearance
&& before.backgroundMode === after.backgroundMode
&& before.showImageInfo === after.showImageInfo;
}
@@ -186,6 +194,7 @@ function createCanvasHistoryPatch(before: CanvasHistorySnapshot, after: CanvasHi
patch.connections = createEntityPatch(before.connections, after.connections);
patch.chatSessions = createEntityPatch(before.chatSessions, after.chatSessions);
if (before.activeChatId !== after.activeChatId) patch.activeChatId = { before: before.activeChatId, after: after.activeChatId };
if (before.canvasAppearance !== after.canvasAppearance) patch.canvasAppearance = { before: before.canvasAppearance, after: after.canvasAppearance };
if (before.backgroundMode !== after.backgroundMode) patch.backgroundMode = { before: before.backgroundMode, after: after.backgroundMode };
if (before.showImageInfo !== after.showImageInfo) patch.showImageInfo = { before: before.showImageInfo, after: after.showImageInfo };
return Object.values(patch).some(Boolean) ? patch : null;
@@ -219,6 +228,7 @@ function applyCanvasHistoryPatch(snapshot: CanvasHistorySnapshot, patch: CanvasH
connections: patch.connections ? applyEntityPatch(snapshot.connections, patch.connections, side) : snapshot.connections,
chatSessions: patch.chatSessions ? applyEntityPatch(snapshot.chatSessions, patch.chatSessions, side) : snapshot.chatSessions,
activeChatId: patch.activeChatId ? patch.activeChatId[side] : snapshot.activeChatId,
canvasAppearance: patch.canvasAppearance ? patch.canvasAppearance[side] : snapshot.canvasAppearance,
backgroundMode: patch.backgroundMode ? patch.backgroundMode[side] : snapshot.backgroundMode,
showImageInfo: patch.showImageInfo ? patch.showImageInfo[side] : snapshot.showImageInfo,
};

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState, type Dispatch, type MutableRe
import { App } from "antd";
import { useNavigate } from "react-router";
import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, normalizeCanvasAppearance, type CanvasAppearance } from "@/lib/canvas/canvas-appearance";
import type { CanvasBackgroundMode } from "@/lib/canvas-theme";
import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
import { normalizeCanvasNodeTimestamps } from "@/lib/canvas/canvas-node-timestamps";
@@ -9,6 +10,7 @@ import { hydrateAssistantImages, hydrateCanvasImages, resetInterruptedGeneration
import { listAddedSkills, type Skill } from "@/services/api/skills";
import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, CanvasNodeMetadata, ViewportTransform } from "@/types/canvas";
import type { CanvasHistorySnapshot } from "./use-canvas-history";
@@ -19,6 +21,7 @@ type UseCanvasProjectLifecycleOptions = {
connections: CanvasConnection[];
chatSessions: CanvasAssistantSession[];
activeChatId: string | null;
canvasAppearance: CanvasAppearance;
backgroundMode: CanvasBackgroundMode;
showImageInfo: boolean;
viewport: ViewportTransform;
@@ -30,6 +33,7 @@ type UseCanvasProjectLifecycleOptions = {
setConnections: Dispatch<SetStateAction<CanvasConnection[]>>;
setChatSessions: Dispatch<SetStateAction<CanvasAssistantSession[]>>;
setActiveChatId: Dispatch<SetStateAction<string | null>>;
setCanvasAppearance: Dispatch<SetStateAction<CanvasAppearance>>;
setBackgroundMode: Dispatch<SetStateAction<CanvasBackgroundMode>>;
setShowImageInfo: Dispatch<SetStateAction<boolean>>;
setViewport: Dispatch<SetStateAction<ViewportTransform>>;
@@ -46,6 +50,7 @@ export function useCanvasProjectLifecycle({
connections,
chatSessions,
activeChatId,
canvasAppearance,
backgroundMode,
showImageInfo,
viewport,
@@ -57,6 +62,7 @@ export function useCanvasProjectLifecycle({
setConnections,
setChatSessions,
setActiveChatId,
setCanvasAppearance,
setBackgroundMode,
setShowImageInfo,
setViewport,
@@ -87,11 +93,16 @@ export function useCanvasProjectLifecycle({
const applyRestoredProject = (restoredNodes: CanvasNodeData[], restoredSessions: CanvasAssistantSession[]) => {
if (cancelled) return;
const fallbackTheme = useThemeStore.getState().theme;
const restoredAppearance = project.appearance
? normalizeCanvasAppearance(project.appearance, fallbackTheme)
: canvasAppearanceForTheme(fallbackTheme);
const snapshot: CanvasHistorySnapshot = {
nodes: restoredNodes,
connections: project.connections,
chatSessions: restoredSessions,
activeChatId: project.activeChatId || null,
canvasAppearance: restoredAppearance,
backgroundMode: project.backgroundMode || "dots",
showImageInfo: project.showImageInfo || false,
};
@@ -102,6 +113,8 @@ export function useCanvasProjectLifecycle({
setConnections(snapshot.connections);
setChatSessions(snapshot.chatSessions);
setActiveChatId(snapshot.activeChatId);
setCanvasAppearance(snapshot.canvasAppearance);
useThemeStore.getState().setTheme(canvasAppearanceBaseTheme(snapshot.canvasAppearance, fallbackTheme));
setBackgroundMode(snapshot.backgroundMode);
setShowImageInfo(snapshot.showImageInfo);
setViewport(project.viewport);
@@ -128,7 +141,7 @@ export function useCanvasProjectLifecycle({
return () => {
cancelled = true;
};
}, [hydrated, message, navigate, openProject, projectId, resetHistory, setActiveChatId, setBackgroundMode, setChatSessions, setConnections, setNodes, setShowImageInfo, setViewport]);
}, [hydrated, message, navigate, openProject, projectId, resetHistory, setActiveChatId, setBackgroundMode, setCanvasAppearance, setChatSessions, setConnections, setNodes, setShowImageInfo, setViewport]);
useEffect(() => {
if (!projectLoaded) return;
@@ -147,8 +160,8 @@ export function useCanvasProjectLifecycle({
useEffect(() => {
if (!projectLoaded || historyPausedRef.current) return;
updateProject(projectId, { nodes, connections, chatSessions, activeChatId, backgroundMode, showImageInfo });
}, [activeChatId, backgroundMode, chatSessions, connections, historyPausedRef, nodes, projectId, projectLoaded, showImageInfo, updateProject]);
updateProject(projectId, { nodes, connections, chatSessions, activeChatId, appearance: canvasAppearance, backgroundMode, showImageInfo });
}, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connections, historyPausedRef, nodes, projectId, projectLoaded, showImageInfo, updateProject]);
useEffect(() => {
if (!projectLoaded) return;
@@ -202,6 +215,7 @@ export function useCanvasProjectLifecycle({
connections: connectionsRef.current,
chatSessions,
activeChatId,
appearance: canvasAppearance,
backgroundMode,
showImageInfo,
viewport: viewportRef.current,
@@ -220,7 +234,7 @@ export function useCanvasProjectLifecycle({
message.warning(`本地画布布局已保存,云端同步失败:${detail}`);
}
return true;
}, [activeChatId, backgroundMode, chatSessions, connectionsRef, currentProject?.directorScenes, message, nodesRef, projectId, showImageInfo, updateProject, viewportRef]);
}, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connectionsRef, currentProject?.directorScenes, message, nodesRef, projectId, showImageInfo, updateProject, viewportRef]);
const clearCanvasFiles = useCallback(() => {
cleanupCanvasFiles({ projectId, nodes: [], chatSessions: [] });