perf(*): 创作工作台 - 优化首页按需加载与画布交互并修复历史迁移冲突
This commit is contained in:
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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user