feat(canvas): 自由画布 - 增强空白起点、外观设置与节点连接体验 (#356)
This commit is contained in:
@@ -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={() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} · 视频`;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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: [] });
|
||||
|
||||
Reference in New Issue
Block a user