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

This commit is contained in:
ddCat committed 2026-09-05 13:24:10 +08:00
1 parent 165b4443ab
commit 58f01d1eb6
107 files changed
+3025 -1089

No files matched your search

@@ -1,11 +1,10 @@
import { memo, useMemo, type CSSProperties, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject } from "react";
import { memo, useEffect, useMemo, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject } from "react";
import { Link2 } from "lucide-react";
import { ConnectionPath } from "@/components/canvas/canvas-connections";
import { CanvasFrameNode } from "@/components/canvas/canvas-frame-node";
import { CanvasNode } from "@/components/canvas/canvas-node";
import type { CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-connection";
import { resolveActiveCanvasMediaNodeId } from "@/lib/canvas/canvas-performance-mode";
import { sortCanvasNodesByStackOrder, type CanvasNodeStackOrder } from "@/lib/canvas/canvas-node-stack-order";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { isFrameNode } from "@/lib/canvas/canvas-frame";
@@ -83,7 +82,10 @@ const EMPTY_CANVAS_NODES: CanvasNodeData[] = [];
export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
const { viewportScale } = props;
const activeMediaNodeId = resolveActiveCanvasMediaNodeId(props.selectedNodeIds, props.nodeById);
const [activeMediaNodeId, setActiveMediaNodeId] = useState<string | null>(null);
useEffect(() => {
if (activeMediaNodeId && !props.nodeById.has(activeMediaNodeId)) setActiveMediaNodeId(null);
}, [activeMediaNodeId, props.nodeById]);
const orderedVisibleNodes = useMemo(() => [
...props.visibleNodes.filter(isFrameNode),
...sortCanvasNodesByStackOrder(props.visibleNodes.filter((node) => !isFrameNode(node)), props.nodeStackOrder),
@@ -146,6 +148,7 @@ export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(p
scale={viewportScale}
isSelected={props.selectedNodeIds.has(node.id)}
mediaActive={activeMediaNodeId === node.id}
onMediaPlayRequest={setActiveMediaNodeId}
isRelated={props.relatedNodeIds.has(node.id)}
isFocusRelated={props.activeNodeId === node.id}
isConnectionTarget={props.connectionTargetNodeId === node.id || props.batchConnectionPreview?.targetNodeId === node.id}
+63 -20
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { useQuery } from "@tanstack/react-query";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { App, Button, Dropdown, Modal, Select } from "antd";
import { ArrowDownAZ, Clock3, Download, FileUp, History, ListFilter, MoreHorizontal, Plus, Search, SlidersHorizontal, Trash2, X } from "lucide-react";
@@ -19,7 +19,9 @@ import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use
import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store";
import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
import { saveCanvasDrawing, type CanvasDrawingRenderDraft } from "@/lib/canvas/canvas-drawing-storage";
import { createCanvasProjectWithRemoteSync, hasRemoteUserDataSyncSession, saveRemoteUserDataNow, scheduleRemoteUserDataSync } from "@/services/user-data-sync";
import { createCanvasProjectWithRemoteSync, hasRemoteUserDataSyncSession, loadCanvasProjectForEditing, saveRemoteUserDataNow, scheduleRemoteUserDataSync } from "@/services/user-data-sync";
import { listRemoteCanvasProjectsPage, type CanvasLibrarySummary } from "@/services/api/user-data";
import { useUserStore } from "@/stores/use-user-store";
import { listProjects } from "@/services/api/projects";
import { loadCanvasProjectPage } from "@/lib/workspace-route-modules";
import { resourceFileUrl, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
@@ -28,6 +30,8 @@ import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import { useAppearanceStore } from "@/stores/use-appearance-store";
const CanvasDeleteProjectsDialog = lazy(() => import("@/components/canvas/canvas-delete-projects-dialog").then((module) => ({ default: module.CanvasDeleteProjectsDialog })));
export default function CanvasPage() {
const { message } = App.useApp();
const brandName = useAppearanceStore((state) => state.appearance.brandName);
@@ -43,9 +47,28 @@ export default function CanvasPage() {
const [openingProjectId, setOpeningProjectId] = useState("");
const openingProjectIdRef = useRef("");
const hydrated = useCanvasStore((state) => state.hydrated);
const projects = useCanvasStore((state) => state.projects);
const localProjects = useCanvasStore((state) => state.projects);
const userId = useUserStore((state) => state.user?.id);
const sessionHydrated = useUserStore((state) => state.hydrated);
const [debouncedKeyword, setDebouncedKeyword] = useState("");
useEffect(() => {
const timer = window.setTimeout(() => setDebouncedKeyword(keyword.trim()), 250);
return () => window.clearTimeout(timer);
}, [keyword]);
const libraryQuery = useInfiniteQuery({
queryKey: ["canvas-library", userId, projectFilter, sort, debouncedKeyword],
queryFn: ({ pageParam, signal }) => listRemoteCanvasProjectsPage({ page: pageParam, pageSize: 40, projectId: projectFilter, sort, query: debouncedKeyword, signal }),
initialPageParam: 1,
getNextPageParam: (last) => last.hasMore ? last.page + 1 : undefined,
enabled: Boolean(userId) && sessionHydrated,
});
const projects = useMemo<CanvasLibrarySummary[]>(() => userId
? libraryQuery.data?.pages.flatMap((page) => page.projects) || []
: localProjects.map((project) => ({ ...project, nodeCount: project.nodes.length, previewNodes: project.nodes.slice(0, 4) })), [libraryQuery.data, localProjects, userId]);
const totalProjects = userId ? libraryQuery.data?.pages[0]?.total || 0 : projects.length;
const importProject = useCanvasStore((state) => state.importProject);
const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds);
const deleteDialogOpen = useCanvasUiStore((state) => state.deleteProjectIds.length > 0);
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
const updateProject = useCanvasStore((state) => state.updateProject);
const [historyOpen, setHistoryOpen] = useState(false);
@@ -77,14 +100,16 @@ export default function CanvasPage() {
});
};
const filteredProjects = useMemo(() => {
if (userId) return projects;
const query = keyword.trim().toLowerCase();
const scoped = projects.filter((project) => projectFilter === "all" || (projectFilter === "independent" ? !project.projectId : project.projectId === projectFilter));
const values = query ? scoped.filter((project) => project.title.toLowerCase().includes(query)) : [...scoped];
values.sort((a, b) => (sort === "name" ? a.title.localeCompare(b.title, "zh-CN") : sort === "nodes" ? b.nodes.length - a.nodes.length : new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()));
values.sort((a, b) => (sort === "name" ? a.title.localeCompare(b.title, "zh-CN") : sort === "nodes" ? b.nodeCount - a.nodeCount : new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()));
return values;
}, [keyword, projectFilter, projects, sort]);
}, [keyword, projectFilter, projects, sort, userId]);
const projectNames = useMemo(() => new Map((projectQuery.data?.projects || []).map(({ project }) => [project.id, project.name])), [projectQuery.data]);
const visibleProjects = filteredProjects.slice(0, loadedProjectCount);
const visibleProjects = userId ? filteredProjects : filteredProjects.slice(0, loadedProjectCount);
const hasMore = userId ? libraryQuery.hasNextPage : visibleProjects.length < filteredProjects.length;
const showCreateCard = !keyword.trim() && projectFilter === "all";
const selectedProjects = projects.filter((project) => selectedIds.includes(project.id));
const projectFilterLabel = projectFilter === "all" ? "全部画布" : projectFilter === "independent" ? "自由画布" : projectNames.get(projectFilter) || "项目画布";
@@ -100,20 +125,24 @@ export default function CanvasPage() {
}, [keyword, projectFilter, sort]);
useEffect(() => {
const node = loadMoreRef.current;
if (!node || visibleProjects.length >= filteredProjects.length) return;
if (!node || !hasMore) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) setLoadedProjectCount((count) => Math.min(count + 50, filteredProjects.length));
if (!entry?.isIntersecting) return;
if (userId) {
if (!libraryQuery.isFetchingNextPage && !libraryQuery.isFetchNextPageError) void libraryQuery.fetchNextPage();
} else setLoadedProjectCount((count) => Math.min(count + 50, filteredProjects.length));
},
{ rootMargin: "600px" },
);
observer.observe(node);
return () => observer.disconnect();
}, [filteredProjects.length, visibleProjects.length]);
}, [filteredProjects.length, visibleProjects.length, hasMore, userId, libraryQuery.fetchNextPage, libraryQuery.isFetchingNextPage, libraryQuery.isFetchNextPageError]);
const associateSelected = async (nextProjectId = associationProjectId) => {
const projectId = nextProjectId || undefined;
selectedIds.forEach((id) => updateProject(id, { projectId }));
try {
for (const id of selectedIds) await loadCanvasProjectForEditing(id);
selectedIds.forEach((id) => updateProject(id, { projectId }));
await saveRemoteUserDataNow();
message.success(projectId ? "已加入项目" : "已移出项目,画布仍保留");
setAssociationOpen(false);
@@ -121,6 +150,17 @@ export default function CanvasPage() {
message.error(error instanceof Error ? `画布关系保存失败:${error.message}` : "画布关系保存失败");
}
};
const exportSelected = async () => {
try {
const selected = [];
for (const id of selectedIds) {
const project = await loadCanvasProjectForEditing(id);
if (!project) throw new Error("画布不存在,无法导出");
selected.push(project);
}
await exportCanvasProjects(selected, `${brandName}画布-${selected.length}个画布`);
} catch (error) { message.error(error instanceof Error ? error.message : "导出失败"); }
};
const importCanvas = async (file?: File) => {
if (!file) return;
const hideLoading = message.loading({ content: "正在解压并准备导入画布...", duration: 0 });
@@ -346,7 +386,7 @@ export default function CanvasPage() {
};
useEffect(() => {
if (!hydrated || autoOpenRef.current || (mode !== "new" && mode !== "recent" && mode !== "handoff")) return;
if (!hydrated || !sessionHydrated || (userId && !libraryQuery.isSuccess) || autoOpenRef.current || (mode !== "new" && mode !== "recent" && mode !== "handoff")) return;
autoOpenRef.current = true;
if (mode === "recent" && projects[0]?.id) {
enterProject(projects[0].id);
@@ -356,9 +396,9 @@ export default function CanvasPage() {
if (syncError) message.warning(syncError instanceof Error ? `画布已在本地创建,云端同步失败:${syncError.message}` : "画布已在本地创建,云端同步失败");
enterProject(id);
});
}, [hydrated, message, mode, projects]);
}, [hydrated, message, mode, projects, sessionHydrated, userId, libraryQuery.isSuccess]);
if (hydrated && (mode === "new" || mode === "recent" || mode === "handoff")) return <main className="flex h-full items-center justify-center bg-background text-sm text-stone-500">正在打开画布...</main>;
if (hydrated && !libraryQuery.isError && (mode === "new" || mode === "recent" || mode === "handoff")) return <main className="flex h-full items-center justify-center bg-background text-sm text-stone-500">正在打开画布...</main>;
return (
<WorkspacePage grid className="canvas-library-page">
@@ -366,7 +406,7 @@ export default function CanvasPage() {
<PageHeader
title="画布"
description="把镜头、素材和想法留在同一张画布里。"
meta={<span className="app-projects-header-meta">{projects.length} 个</span>}
meta={<span className="app-projects-header-meta">{totalProjects} 个</span>}
actions={
<div className="canvas-library-header-actions">
<Button className="canvas-library-header-action is-primary library-primary-action" type="primary" disabled={!hydrated} icon={<Plus className="size-3.5" />} onClick={createAndEnter}>
@@ -376,7 +416,7 @@ export default function CanvasPage() {
<Dropdown
menu={{
classNames: { root: "canvas-library-actions-menu", item: "canvas-library-actions-menu-item" },
items: [{ key: "delete-all", danger: true, icon: <Trash2 className="size-3.5" />, label: "删除全部画布", onClick: () => setDeleteIds(projects.map((project) => project.id)) }],
items: [{ key: "delete-loaded", danger: true, icon: <Trash2 className="size-3.5" />, label: "删除当前已加载画布", onClick: () => setDeleteIds(projects.map((project) => project.id)) }],
}}
openClassName="is-open"
placement="bottomRight"
@@ -468,7 +508,7 @@ export default function CanvasPage() {
</div>
<span className="canvas-library-count">
<strong>{String(filteredProjects.length).padStart(2, "0")}</strong>
<span>/ {String(projects.length).padStart(2, "0")} 画布</span>
<span>/ {String(totalProjects).padStart(2, "0")} 画布</span>
</span>
</section>
</div>
@@ -499,7 +539,7 @@ export default function CanvasPage() {
移出项目
</Button>
) : null}
<Button size="small" disabled={!hydrated} icon={<Download className="size-3.5" />} onClick={() => void exportCanvasProjects(selectedProjects, `${brandName}画布-${selectedIds.length}个画布`)}>
<Button size="small" disabled={!hydrated} icon={<Download className="size-3.5" />} onClick={() => void exportSelected()}>
导出
</Button>
<Button size="small" danger disabled={!hydrated} onClick={() => setDeleteIds(selectedIds)}>
@@ -508,7 +548,9 @@ export default function CanvasPage() {
</div>
) : null}
{!hydrated ? (
{userId && libraryQuery.isError ? (
<div role="alert">画布列表读取失败<Button onClick={() => void libraryQuery.refetch()}>重试</Button></div>
) : !hydrated || (userId && libraryQuery.isPending) ? (
<WorkspaceLoadingState label="正在恢复画布" detail="读取本地缓存与账号同步状态" />
) : showCreateCard || visibleProjects.length ? (
<CollectionGrid className="canvas-library-grid">
@@ -529,7 +571,7 @@ export default function CanvasPage() {
)}
{hydrated && visibleProjects.length ? (
<div ref={loadMoreRef} className="library-load-more" aria-live="polite">
{visibleProjects.length < filteredProjects.length ? `继续下滑加载更多(每页 50 条)` : `已加载全部 ${filteredProjects.length} 个画布`}
{libraryQuery.isFetchNextPageError ? <Button onClick={() => void libraryQuery.fetchNextPage()}>加载失败,重试</Button> : hasMore ? "继续下滑加载更多" : `已加载全部 ${filteredProjects.length} 个画布`}
</div>
) : null}
</div>
@@ -554,6 +596,7 @@ export default function CanvasPage() {
/>
</Modal>
<CanvasHistoryDrawer open={historyOpen} onClose={() => setHistoryOpen(false)} />
{deleteDialogOpen ? <Suspense fallback={null}><CanvasDeleteProjectsDialog /></Suspense> : null}
</WorkspacePage>
);
}
+12 -5
View File
@@ -1,7 +1,9 @@
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { Dispatch, MouseEvent as ReactMouseEvent, SetStateAction } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams, useSearchParams } from "react-router";
import { Link, useParams, useSearchParams } from "react-router";
import { loadAssetsForUse } from "@/services/user-data-sync";
import { canvasAssetHandoffIds } from "@/lib/canvas/canvas-asset-handoff";
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { uploadMediaFile } from "@/services/file-storage";
import { readLocalRuntimeBootstrapState } from "@/services/local-runtime-bootstrap";
@@ -23,7 +25,7 @@ import { flushCanvasStorePersistence } from "@/stores/canvas/use-canvas-store";
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { App, Modal } from "antd";
import { App, Button, Modal } from "antd";
import { getNodeSpec } from "@/constant/canvas";
import { CanvasConfigComposer } from "@/components/canvas/canvas-config-composer";
import { CanvasConfigNodePanel } from "@/components/canvas/canvas-config-node-panel";
@@ -260,7 +262,7 @@ function InfiniteCanvasPage() {
const [canvasAppearance, setCanvasAppearance] = useState<CanvasAppearance>(() => canvasAppearanceForTheme(colorTheme));
const [backgroundMode, setBackgroundMode] = useState<CanvasBackgroundMode>(DEFAULT_CANVAS_BACKGROUND_MODE);
const [showImageInfo, setShowImageInfo] = useState(false);
const [canvasTool, setCanvasTool] = useState<CanvasToolMode>("move");
const [canvasTool, setCanvasTool] = useState<CanvasToolMode>("box-select");
const [mediaPerformanceMode, setMediaPerformanceMode] = useState<CanvasMediaPerformanceMode>(readCanvasMediaPerformanceMode);
const [projectLoaded, setProjectLoaded] = useState(false);
const [workspaceMode, setWorkspaceMode] = useState<CanvasWorkspaceMode>(readCanvasWorkspaceMode);
@@ -399,7 +401,7 @@ function InfiniteCanvasPage() {
[cleanupAssetImages, getHistoryCleanupContext],
);
const { addedSkills, clearCanvasFiles, createAndOpenProject, currentProject, deleteCurrentProject, renameCurrentProject, saveCanvasProject, updateProject } = useCanvasProjectLifecycle({
const { loadError, retryLoad, addedSkills, clearCanvasFiles, createAndOpenProject, currentProject, deleteCurrentProject, renameCurrentProject, saveCanvasProject, updateProject } = useCanvasProjectLifecycle({
projectId,
projectLoaded,
nodes,
@@ -701,6 +703,11 @@ function InfiniteCanvasPage() {
});
const replaceCanvasNodeMedia = useCallback((node: CanvasNodeData) => handleUploadRequest(node.id), [handleUploadRequest]);
useEffect(() => {
if (!projectLoaded || searchParams.get("mode") !== "handoff") return;
void loadAssetsForUse(canvasAssetHandoffIds(searchParams)).catch((error) => message.error(error instanceof Error ? error.message : "转入素材读取失败"));
}, [projectLoaded, searchParams, message]);
useEffect(() => {
if (!projectLoaded || !assetsHydrated || searchParams.get("mode") !== "handoff") return;
const attempt = canvasAssetHandoffAttempt(assets, searchParams);
@@ -1090,7 +1097,6 @@ function InfiniteCanvasPage() {
onBatchConnectionTarget: handleBatchConnectionTargetClick,
onLinkedFolderDrop: archiveNodesToLinkedFolder,
onDeselect: handleCanvasDeselect,
onSelectionBoxEnd: () => setCanvasTool((tool) => (tool === "box-select" ? "move" : tool)),
});
const keepNodeToolbar = useCallback(
@@ -2133,6 +2139,7 @@ function InfiniteCanvasPage() {
onAddScript={() => createNode(CanvasNodeType.Script)}
/>
) : null;
if (!projectLoaded && loadError) return <main className="flex h-full flex-col items-center justify-center gap-4"><p role="alert">{loadError}</p><Button onClick={retryLoad}>重新加载</Button><Link to="/canvas">返回画布库</Link></main>;
if (!projectLoaded) return <CanvasRefreshShell />;
return (
@@ -4,7 +4,7 @@ import { App } from "antd";
import { applyGenerationTaskResultToNodes, generationTaskCanReloadResource, generationTaskNodeId } from "@/lib/canvas/canvas-generation-task-sync";
import { applyCanvasGenerationTaskNodeEffect, isCanvasGenerationDurableAckError } from "@/services/canvas-generation-consumer";
import { consumeGenerationTaskNode, ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import { consumeGenerationTaskNode, ensureCanvasNodeAsset, retryCanvasAssetSyncAfterRateLimit } from "@/services/project-asset-sync";
import { listGenerationTasks, listTaskLogs, queryGenerationTask, subscribeGenerationTasks, type GenerationTask, type TaskLog } from "@/services/api/task-center";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
@@ -273,7 +273,7 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
const saveGeneratedAsset = useCallback(
async (node: CanvasNodeData, taskId: string, signal?: AbortSignal) => {
const result = await ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId, node, source: "canvas-generation", taskId, signal });
const result = await retryCanvasAssetSyncAfterRateLimit(() => ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId, node, source: "canvas-generation", taskId, signal }), { signal });
setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, assetId: result.assetId } } : item)));
if (domainProjectId) await queryClient.invalidateQueries({ queryKey: ["project", domainProjectId] });
},
@@ -86,6 +86,7 @@ export function useCanvasKeyboard({
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest(".canvas-node-toolbar, .canvas-node-toolbar-menu")) return;
const key = event.key.toLowerCase();
const isModifierShortcut = event.metaKey || event.ctrlKey;
const isTextEditingTarget = event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement || Boolean(target?.closest("[contenteditable='true']"));
@@ -197,7 +197,7 @@ export function useCanvasMediaTools({
const image = await uploadImage(cropped);
const size = fitNodeSize(image.width, image.height, node.width, node.height);
const childId = nanoid();
const child: CanvasNodeData = { id: childId, type: CanvasNodeType.Image, title: "Cropped Image", position: { x: node.position.x + node.width + 96, y: node.position.y }, width: size.width, height: size.height, metadata: { ...imageMetadata(image), prompt: node.metadata?.prompt } };
const child: CanvasNodeData = { id: childId, type: CanvasNodeType.Image, title: `${node.title || "图片"} · 裁剪`, position: { x: node.position.x + node.width + 96, y: node.position.y }, width: size.width, height: size.height, metadata: { ...imageMetadata(image), prompt: node.metadata?.prompt } };
setNodes((current) => [...current, child]);
setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]);
setSelectedNodeIds(new Set([childId]));
@@ -210,7 +210,7 @@ export function useCanvasMediaTools({
const image = await uploadImage(dataUrl);
const size = fitNodeSize(image.width, image.height, node.width, node.height);
const childId = nanoid();
const child: CanvasNodeData = { id: childId, type: CanvasNodeType.Image, title: `标注 · ${node.title || "图片"}`, position: { x: node.position.x + node.width + 96, y: node.position.y }, width: size.width, height: size.height, metadata: { ...imageMetadata(image), prompt: node.metadata?.prompt } };
const child: CanvasNodeData = { id: childId, type: CanvasNodeType.Image, title: `${node.title || "图片"} · 标注`, position: { x: node.position.x + node.width + 96, y: node.position.y }, width: size.width, height: size.height, metadata: { ...imageMetadata(image), prompt: node.metadata?.prompt } };
setNodes((current) => [...current, child]);
setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]);
setSelectedNodeIds(new Set([childId]));
@@ -331,7 +331,7 @@ export function useCanvasMediaTools({
{ x: node.position.x + node.width + 96 + spec.width / 2, y: node.position.y + node.height / 2 },
{ ...audioMetadata(uploaded), prompt: `从「${node.title || "视频"}」提取的声音`, status: NODE_STATUS_SUCCESS },
);
audioNode.title = `声音 · ${node.title || "视频"}`;
audioNode.title = `${node.title || "视频"} · 音频`;
const audioNodeId = audioNode.id;
setNodes((current) => [...current, audioNode]);
setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: audioNodeId }]);
@@ -397,7 +397,7 @@ export function useCanvasMediaTools({
const segmentNode: CanvasNodeData = {
id: segmentId,
type: CanvasNodeType.Video,
title: `片段 ${index + 1} · ${sourceNode?.title || node.title || "视频"}`,
title: `${sourceNode?.title || node.title || "视频"} · 片段 ${index + 1}`,
position: { x: baseX, y: baseY + index * (Math.max(size.height, spec.height) + 24) },
width: size.width,
height: size.height,
@@ -550,7 +550,7 @@ export function useCanvasMediaTools({
return {
id: nanoid(),
type: CanvasNodeType.Image,
title: `${node.title || "图片"} ${piece.row + 1}-${piece.column + 1}`,
title: `${node.title || "图片"} · 宫格 ${piece.row + 1}-${piece.column + 1}`,
position: { x: startX + piece.column * (cellWidth + gap), y: node.position.y + piece.row * (cellHeight + gap) },
width: cellWidth,
height: cellHeight,
@@ -619,7 +619,7 @@ export function useCanvasMediaTools({
const image = await uploadImage(upscaled);
const size = fitNodeSize(image.width, image.height);
const childId = nanoid();
const child: CanvasNodeData = { id: childId, type: CanvasNodeType.Image, title: "Upscaled Image", position: { x: node.position.x + node.width + 96, y: node.position.y }, width: size.width, height: size.height, metadata: { ...imageMetadata(image), prompt: node.metadata?.prompt } };
const child: CanvasNodeData = { id: childId, type: CanvasNodeType.Image, title: `${node.title || "图片"} · 放大`, position: { x: node.position.x + node.width + 96, y: node.position.y }, width: size.width, height: size.height, metadata: { ...imageMetadata(image), prompt: node.metadata?.prompt } };
setNodes((current) => [...current, child]);
setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]);
setSelectedNodeIds(new Set([childId]));
@@ -8,9 +8,10 @@ import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
import { normalizeCanvasNodeTimestamps } from "@/lib/canvas/canvas-node-timestamps";
import { hydrateAssistantImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation";
import { listAddedSkills, type Skill } from "@/services/api/skills";
import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, loadCanvasProjectForEditing, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas";
import type { CanvasHistorySnapshot } from "./use-canvas-history";
@@ -74,18 +75,24 @@ export function useCanvasProjectLifecycle({
const { message } = App.useApp();
const navigate = useNavigate();
const hydrated = useCanvasStore((state) => state.hydrated);
const sessionHydrated = useUserStore((state) => state.hydrated);
const openProject = useCanvasStore((state) => state.openProject);
const updateProject = useCanvasStore((state) => state.updateProject);
const renameProject = useCanvasStore((state) => state.renameProject);
const currentProject = useCanvasStore((state) => state.projects.find((project) => project.id === projectId));
const [addedSkills, setAddedSkills] = useState<Skill[]>([]);
const [loadError, setLoadError] = useState("");
const [loadAttempt, setLoadAttempt] = useState(0);
const viewportSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!hydrated) return;
if (!hydrated || !sessionHydrated) return;
let cancelled = false;
setProjectLoaded(false);
const project = openProject(projectId);
setLoadError("");
const load = async () => {
const project = await loadCanvasProjectForEditing(projectId);
if (cancelled) return;
if (!project) {
navigate("/canvas", { replace: true });
return;
@@ -140,11 +147,15 @@ export function useCanvasProjectLifecycle({
if (!cancelled) message.warning("部分助手会话素材恢复失败,已使用项目记录继续打开");
});
};
void restore();
await restore();
};
void load().catch((error) => {
if (!cancelled) setLoadError(error instanceof Error ? error.message : "读取画布失败,请重试");
});
return () => {
cancelled = true;
};
}, [hydrated, message, navigate, openProject, projectId, resetHistory, setActiveChatId, setBackgroundMode, setCanvasAppearance, setChatSessions, setConnections, setNodes, setShowImageInfo, setViewport]);
}, [hydrated, sessionHydrated, loadAttempt, message, navigate, openProject, projectId, resetHistory, setActiveChatId, setBackgroundMode, setCanvasAppearance, setChatSessions, setConnections, setNodes, setShowImageInfo, setViewport]);
useEffect(() => {
if (!projectLoaded) return;
@@ -244,6 +255,8 @@ export function useCanvasProjectLifecycle({
}, [cleanupCanvasFiles, projectId]);
return {
loadError,
retryLoad: () => setLoadAttempt((attempt) => attempt + 1),
addedSkills,
clearCanvasFiles,
createAndOpenProject,
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type RefObject, type SetStateAction } from "react";
import { applyCanvasNodeDragPreview, applyCanvasSelectionPreview } from "@/lib/canvas/canvas-live-viewport";
import { applyCanvasNodeDragPreview, applyCanvasNodeSelectionPreview, applyCanvasSelectionPreview } from "@/lib/canvas/canvas-live-viewport";
import { calculateNodeAlignment, createNodeAlignmentContext, sameStringSet, type NodeAlignmentContext } from "@/lib/canvas/canvas-project-domain";
import { applyFrameDrop, buildCanvasFrameDropIndex, findFrameDropTargetFromIndex, getFrameChildIds, isFrameNode } from "@/lib/canvas/canvas-frame";
import { buildCanvasSpatialIndex, canvasNodeBounds, type CanvasSpatialIndex } from "@/lib/canvas/canvas-spatial-index";
import { applyCanvasSelectionStrategy, canvasSelectionHitsBounds, createCanvasSelectionBounds, createCanvasSelectionSpatialIndexCache, resolveCanvasSelectionHitMode, resolveCanvasSelectionPreviewDelta, resolveCanvasSelectionStrategy } from "@/lib/canvas/canvas-selection";
import { canvasNodeBounds } from "@/lib/canvas/canvas-spatial-index";
import type { CanvasNodeData, Position, SelectionBox, ViewportTransform } from "@/types/canvas";
type UseCanvasSelectionControllerOptions = {
@@ -25,7 +26,6 @@ type UseCanvasSelectionControllerOptions = {
onBatchConnectionTarget?: (event: ReactMouseEvent | ReactPointerEvent, nodeId: string) => boolean;
onLinkedFolderDrop?: (folder: CanvasNodeData, nodes: CanvasNodeData[]) => void;
onDeselect: () => void;
onSelectionBoxEnd?: () => void;
};
type DragState = {
@@ -40,6 +40,10 @@ type DragState = {
initialSelectedNodes: Array<{ id: string; x: number; y: number }>;
};
type SelectionGestureState =
| { phase: "idle" }
| { phase: "pending" | "selecting"; initialSelection: Set<string>; selection: SelectionBox };
const EMPTY_DRAG_STATE: DragState = {
isDraggingNode: false,
hasMoved: false,
@@ -71,7 +75,6 @@ export function useCanvasSelectionController({
onBatchConnectionTarget,
onLinkedFolderDrop,
onDeselect,
onSelectionBoxEnd,
}: UseCanvasSelectionControllerOptions) {
const dragFrameRef = useRef<number | null>(null);
const pendingNodeDragRef = useRef<Position>({ x: 0, y: 0 });
@@ -80,10 +83,9 @@ export function useCanvasSelectionController({
const lastFrameDropCheckRef = useRef(0);
const selectionFrameRef = useRef<number | null>(null);
const selectionBoundsElementRef = useRef<HTMLDivElement>(null);
const selectionSpatialIndexRef = useRef<CanvasSpatialIndex<CanvasNodeData>>(buildCanvasSpatialIndex([]));
const selectionSpatialIndexCacheRef = useRef(createCanvasSelectionSpatialIndexCache());
const pendingSelectionPointRef = useRef<Position | null>(null);
const selectionActivatedRef = useRef(false);
const selectionBoxRef = useRef<SelectionBox | null>(null);
const selectionGestureRef = useRef<SelectionGestureState>({ phase: "idle" });
const nodeDraggingRef = useRef(false);
const dragRef = useRef<DragState>({ ...EMPTY_DRAG_STATE });
const frameDropIndexRef = useRef(buildCanvasFrameDropIndex([]));
@@ -94,14 +96,25 @@ export function useCanvasSelectionController({
const [dragPreview, setDragPreview] = useState<{ x: number; y: number; nodeIds: Set<string> } | null>(null);
const [alignmentGuides, setAlignmentGuides] = useState<{ vertical?: number; horizontal?: number }>({});
const cancelSelectionBox = useCallback(() => {
selectionBoxRef.current = null;
const resetSelectionBox = useCallback(() => {
selectionGestureRef.current = { phase: "idle" };
pendingSelectionPointRef.current = null;
selectionActivatedRef.current = false;
if (selectionFrameRef.current) cancelAnimationFrame(selectionFrameRef.current);
selectionFrameRef.current = null;
applyCanvasNodeSelectionPreview(containerRef.current, null);
setSelectionBox(null);
}, []);
}, [containerRef]);
const cancelSelectionBox = useCallback(() => {
const gesture = selectionGestureRef.current;
const initialSelection = gesture.phase === "selecting" ? gesture.initialSelection : null;
resetSelectionBox();
if (!initialSelection) return;
const restoredSelection = new Set(initialSelection);
if (sameStringSet(restoredSelection, selectedNodeIdsRef.current)) return;
selectedNodeIdsRef.current = restoredSelection;
setSelectedNodeIds(restoredSelection);
}, [resetSelectionBox, selectedNodeIdsRef, setSelectedNodeIds]);
const deselectCanvas = useCallback(() => {
cancelPendingConnectionCreate();
@@ -118,29 +131,19 @@ export function useCanvasSelectionController({
onCanvasSelectionStart();
if (event.button !== 0) return;
const world = screenToCanvas(event.clientX, event.clientY);
const subtractive = event.altKey;
const additive = !subtractive && (event.shiftKey || event.ctrlKey || event.metaKey);
const strategy = resolveCanvasSelectionStrategy(event);
const initialSelection = new Set(selectedNodeIdsRef.current);
const nextSelectionBox: SelectionBox = {
startWorldX: world.x,
startWorldY: world.y,
currentWorldX: world.x,
currentWorldY: world.y,
additive,
subtractive,
initialSelectedNodeIds: additive || subtractive ? Array.from(selectedNodeIdsRef.current) : [],
strategy,
hitMode: "contain",
initialSelectedNodeIds: Array.from(initialSelection),
};
selectionBoxRef.current = nextSelectionBox;
selectionActivatedRef.current = false;
const currentNodes = nodesRef.current;
const nodeById = new Map(currentNodes.map((node) => [node.id, node]));
const hiddenBatchChildIds = new Set(currentNodes.flatMap((node) => {
const rootId = node.metadata?.batchRootId;
const root = rootId ? nodeById.get(rootId) : undefined;
return root && !root.metadata?.imageBatchExpanded ? [node.id] : [];
}));
selectionSpatialIndexRef.current = buildCanvasSpatialIndex(currentNodes
.filter((node) => !hiddenBatchChildIds.has(node.id) && !(node.parentId && nodeById.get(node.parentId)?.metadata?.frame?.collapsed))
.map((node) => ({ id: node.id, bounds: canvasNodeBounds(node), value: node })));
selectionGestureRef.current = { phase: "pending", initialSelection, selection: nextSelectionBox };
selectionSpatialIndexCacheRef.current.get(nodesRef.current);
setSelectedConnectionId(null);
}, [cancelPendingConnectionCreate, nodesRef, onCanvasSelectionStart, screenToCanvas, selectedNodeIdsRef, setSelectedConnectionId]);
@@ -282,70 +285,79 @@ export function useCanvasSelectionController({
});
}, [containerRef, viewportRef]);
const updateSelectionPreview = useCallback((world: Position, commit: boolean) => {
let gesture = selectionGestureRef.current;
if (gesture.phase === "idle") return false;
let selection = gesture.selection;
if (gesture.phase === "pending") {
const threshold = 4 / viewportRef.current.k;
if (Math.hypot(world.x - selection.startWorldX, world.y - selection.startWorldY) < threshold) return false;
selection = { ...selection, currentWorldX: world.x, currentWorldY: world.y, hitMode: resolveCanvasSelectionHitMode(selection.startWorldX, world.x) };
gesture = { phase: "selecting", initialSelection: gesture.initialSelection, selection };
selectionGestureRef.current = gesture;
// React only learns that a gesture exists. All subsequent geometry
// and node feedback stays outside React until pointer-up.
setSelectionBox(selection);
}
const bounds = createCanvasSelectionBounds(selection.startWorldX, selection.startWorldY, world.x, world.y);
selection = { ...selection, currentWorldX: world.x, currentWorldY: world.y, hitMode: resolveCanvasSelectionHitMode(selection.startWorldX, world.x) };
selectionGestureRef.current = { phase: "selecting", initialSelection: gesture.initialSelection, selection };
applyCanvasSelectionPreview(containerRef.current, selection);
const queryBounds = { ...bounds, right: Math.max(bounds.right, bounds.left + 0.01), bottom: Math.max(bounds.bottom, bounds.top + 0.01) };
const hitNodeIds = new Set(selectionSpatialIndexCacheRef.current
.get(nodesRef.current)
.query(queryBounds)
.filter((node) => canvasSelectionHitsBounds(queryBounds, canvasNodeBounds(node), selection.hitMode))
.map((node) => node.id));
applyCanvasNodeSelectionPreview(containerRef.current, resolveCanvasSelectionPreviewDelta(gesture.initialSelection, hitNodeIds, selection.strategy));
if (!commit) return true;
const nextSelected = applyCanvasSelectionStrategy(gesture.initialSelection, hitNodeIds, selection.strategy);
if (!sameStringSet(nextSelected, selectedNodeIdsRef.current)) {
selectedNodeIdsRef.current = nextSelected;
setSelectedNodeIds(nextSelected);
}
return true;
}, [containerRef, nodesRef, selectedNodeIdsRef, setSelectedNodeIds, viewportRef]);
const handlePointerMove = useCallback((event: PointerEvent) => {
if (dragRef.current.isDraggingNode) {
handleNodeDragMove(event);
return;
}
const currentSelection = selectionBoxRef.current;
if (!currentSelection) return;
if (event.buttons === 0) {
cancelSelectionBox();
return;
}
const currentGesture = selectionGestureRef.current;
if (currentGesture.phase === "idle") return;
pendingSelectionPointRef.current = screenToCanvas(event.clientX, event.clientY);
if (selectionFrameRef.current) return;
selectionFrameRef.current = requestAnimationFrame(() => {
selectionFrameRef.current = null;
let selection = selectionBoxRef.current;
const world = pendingSelectionPointRef.current;
if (!selection || !world) return;
if (!selectionActivatedRef.current) {
const threshold = 4 / viewportRef.current.k;
if (Math.hypot(world.x - selection.startWorldX, world.y - selection.startWorldY) < threshold) return;
selectionActivatedRef.current = true;
selection = { ...selection, currentWorldX: world.x, currentWorldY: world.y };
selectionBoxRef.current = selection;
setSelectionBox(selection);
if (!selection.additive) {
const emptySelection = new Set<string>();
selectedNodeIdsRef.current = emptySelection;
setSelectedNodeIds(emptySelection);
}
}
const rectX = Math.min(selection.startWorldX, world.x);
const rectY = Math.min(selection.startWorldY, world.y);
const rectW = Math.abs(world.x - selection.startWorldX);
const rectH = Math.abs(world.y - selection.startWorldY);
selection = { ...selection, currentWorldX: world.x, currentWorldY: world.y };
selectionBoxRef.current = selection;
applyCanvasSelectionPreview(containerRef.current, selection);
const nextSelected = new Set<string>(selection.additive || selection.subtractive ? selection.initialSelectedNodeIds : []);
selectionSpatialIndexRef.current.query({ left: rectX, top: rectY, right: rectX + Math.max(rectW, 0.01), bottom: rectY + Math.max(rectH, 0.01) }).forEach((node) => {
if (selection.subtractive) nextSelected.delete(node.id);
else nextSelected.add(node.id);
});
if (sameStringSet(nextSelected, selectedNodeIdsRef.current)) return;
selectedNodeIdsRef.current = nextSelected;
setSelectedNodeIds(nextSelected);
if (world) updateSelectionPreview(world, false);
});
}, [cancelSelectionBox, containerRef, handleNodeDragMove, screenToCanvas, selectedNodeIdsRef, setSelectedNodeIds, viewportRef]);
}, [handleNodeDragMove, screenToCanvas, updateSelectionPreview]);
const finishSelection = useCallback(() => {
const hadPendingSelection = Boolean(selectionBoxRef.current);
const wasSelection = selectionActivatedRef.current;
cancelSelectionBox();
if (hadPendingSelection && !wasSelection) deselectCanvas();
if (hadPendingSelection) onSelectionBoxEnd?.();
}, [cancelSelectionBox, deselectCanvas, onSelectionBoxEnd]);
const finishSelection = useCallback((clientX: number, clientY: number) => {
const gesture = selectionGestureRef.current;
const hadPendingSelection = gesture.phase !== "idle";
const strategy = gesture.phase === "idle" ? null : gesture.selection.strategy;
if (selectionFrameRef.current) cancelAnimationFrame(selectionFrameRef.current);
selectionFrameRef.current = null;
const wasSelection = hadPendingSelection && updateSelectionPreview(screenToCanvas(clientX, clientY), true);
resetSelectionBox();
if (hadPendingSelection && !wasSelection && strategy === "replace") deselectCanvas();
}, [deselectCanvas, resetSelectionBox, screenToCanvas, updateSelectionPreview]);
useEffect(() => {
const handleMouseUp = (event: MouseEvent) => {
finishNodeDrag(event.clientX, event.clientY);
finishSelection();
};
const handlePointerUp = (event: PointerEvent) => finishNodeDrag(event.clientX, event.clientY);
const cancel = () => finishNodeDrag();
const handlePointerUp = (event: PointerEvent) => {
finishNodeDrag(event.clientX, event.clientY);
finishSelection(event.clientX, event.clientY);
};
const cancel = () => {
finishNodeDrag();
cancelSelectionBox();
};
window.addEventListener("mousemove", handleNodeDragMove);
window.addEventListener("mouseup", handleMouseUp);
window.addEventListener("pointermove", handlePointerMove);
@@ -363,7 +375,7 @@ export function useCanvasSelectionController({
window.removeEventListener("pointercancel", cancel);
window.removeEventListener("blur", cancel);
};
}, [finishNodeDrag, finishSelection, handleNodeDragMove, handlePointerMove]);
}, [cancelSelectionBox, finishNodeDrag, finishSelection, handleNodeDragMove, handlePointerMove]);
return {
alignmentGuides,