From 9f45641ccd228ab9fa3b75118a4b52a1247e9a32 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 17 Jun 2026 05:00:53 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=E5=9B=BE=E7=89=87=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E5=8E=86=E5=8F=B2=E4=B8=8E=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=E5=8D=8F=E8=B0=83=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增画布历史 hook 承接撤销重做快照逻辑 新增项目持久化 hook 承接加载资源创建与自动保存时序 补充 hook 单测并更新图片画布拆分跟踪文档 --- TRACKING.md | 2 + ...构】图片画布编辑器前端拆分计划-2026-06-17.md | 15 +- .../image-editor/ImageCanvasEditorView.tsx | 383 ++++-------------- .../image-editor/useCanvasHistory.test.tsx | 218 ++++++++++ .../image-editor/useCanvasHistory.ts | 169 ++++++++ .../useImageCanvasProjectPersistence.test.tsx | 172 ++++++++ .../useImageCanvasProjectPersistence.ts | 240 +++++++++++ 7 files changed, 894 insertions(+), 305 deletions(-) create mode 100644 src/components/image-editor/useCanvasHistory.test.tsx create mode 100644 src/components/image-editor/useCanvasHistory.ts create mode 100644 src/components/image-editor/useImageCanvasProjectPersistence.test.tsx create mode 100644 src/components/image-editor/useImageCanvasProjectPersistence.ts diff --git a/TRACKING.md b/TRACKING.md index 135297ca..4c10c893 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -121,3 +121,5 @@ - 2026-06-17 前端拆分第五阶段:新增 `ImageCanvasLayerCommandModel`,把右键图层目标解析、复制 / 粘贴 / 创建副本、层级移动、分组 / 解组、显隐、锁定、翻转和删除的数据规则从主视图抽出;主视图只保留历史、选中态、菜单关闭、元数据清理和导出下载副作用。验证命令:`npm run test -- src/components/image-editor/ImageCanvasLayerCommandModel.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasEditorPrimitives.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口`,背景入口打开完整 `画布背景设置` 面板;登录后上传素材成功,点击素材可加入画布,图片右键打开 `图片功能面板`,创建副本、水平翻转、锁定和隐藏均生效,`AI画布工具栏` 保持可见。 - 2026-06-17 前端拆分第六阶段:新增 `ImageCanvasInteractionModel`,把适合视图、中心缩放、普通滚轮纵向滚动、Ctrl / Cmd 滚轮缩放、坐标换算、框选命中、平移、生成占位框拖拽、图层拖拽吸附、小地图投影、小地图点击定位和小地图拖拽视图移动的纯规则从主视图抽出;主视图保留事件、pointer capture、history、生成对象回写、选中态和状态更新。验证命令:`npm run test -- src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasEditorPrimitives.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 登录态刷新后素材、画布图层、小地图和 `AI画布工具栏` 保持可见,Ctrl 滚轮从 110% 缩放到 121%,普通滚轮不改变缩放,浏览器控制台无 passive wheel 错误。 - 2026-06-17 新增素材持久化修正:素材库图片、上传到画布、生成图、修改图和图标素材加入画布时会先用当前图层快照更新本地画布,再在资源创建完成后立刻保存带真实 `resourceId` 的 layout,避免资源创建异步返回时把空 `layers` 写回工程。验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录弹出 `账号入口`,登录后上传素材、点击素材加入画布并刷新,画布图片和 `AI画布工具栏` 均保持可见。 +- 2026-06-17 前端拆分第七阶段:新增 `useCanvasHistory`,把画布历史快照、撤销、重做、历史栈长度限制和 `canUndo` / `canRedo` 派生状态从主视图抽出;主视图只在具体动作前捕获历史,并注入恢复快照后的菜单 / hover / 框选 / 拖拽清理。验证命令:`npm run test -- src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`。 +- 2026-06-17 前端拆分第八阶段:新增 `useImageCanvasProjectPersistence`,把项目加载、`projectId` 状态、未就绪资源队列、工程资源创建、资源创建后即时保存和 450ms 自动保存从主视图抽出;新增 hook 单测锁定新增图层资源创建后保存真实 `resourceId` 的 layout。验证命令:`npm run test -- src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index 62195d3f..cf24d07b 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -71,11 +71,24 @@ - 主视图继续负责 React 事件对象、pointer capture、history 快照、生成对象回写、选中态和 `setState`。 - 该模块用独立单测覆盖小地图灵敏度、吸附、多选拖拽和滚轮缩放等之前容易回退的交互规则。 +## 第七阶段模块 + +- `useCanvasHistory.ts` + - 承载画布历史栈:快照创建、快照恢复、撤销、重做、历史栈长度限制和 `canUndo` / `canRedo` 派生状态。 + - 主视图继续负责在具体用户动作前调用 `captureCanvasHistory`,并通过 hook 注入恢复快照后需要清理的 hover、元数据、框选、吸附、右键菜单和平移拖拽状态。 + - 该 hook 有独立测试覆盖图层、视口、active / archived 生成对话框和选中态的撤销 / 重做恢复,避免后续把 history 逻辑继续埋回主视图。 + +## 第八阶段模块 + +- `useImageCanvasProjectPersistence.ts` + - 承载图片画布工程持久化协调:项目加载、`projectId` 维护、未就绪资源队列、工程资源创建、资源创建后即时 layout 保存、450ms 自动保存和鉴权失败登录弹窗。 + - 该 hook 以“项目持久化协调器”整体抽出,避免把加载、保存和资源创建拆成多个小 hook 后打散 `projectIdRef`、`pendingProjectResourceLayersRef`、`isProjectReady` 和 `saveTimerRef` 的时序约束。 + - 主视图继续负责项目重命名 UI、素材库管理、上传流程和用户动作触发;新增图层仍通过 `appendCanvasLayersWithResources` 先写本地图层快照,再创建 project resource 并保存带真实 `resourceId` 的 layout。 + ## 后续阶段 - 生成状态机模型:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再从主视图抽出深层状态模型。 - 上传 / 素材状态模型:上传占位卡片、素材文件夹移动、账号级素材库和拖拽遮罩仍在主视图与侧栏之间协作,后续需要等上传错误恢复和批量操作规则稳定后再收口。 -- 资源持久化稳定性:新增图层时先使用当前画布图层快照更新本地状态,再等待工程资源创建并即时保存带真实 `resourceId` 的 layout。后续如果继续拆上传或生成状态机,必须保留这一时序,避免 React 状态刷新和异步资源返回交错时写回空图层。 ## 验证计划 diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 339af6fb..83885b19 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -17,7 +17,6 @@ import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/ import { createEditorAsset, createEditorAssetFolder, - createEditorProjectResource, deleteEditorAsset, deleteEditorAssetFolder, editEditorImage, @@ -28,10 +27,7 @@ import { generateEditorIconSpritesheet, generateEditorImage, loadEditorAssetLibrary, - loadEditorProject, - loadOrCreateRecentEditorProject, renameEditorProject, - saveEditorProjectLayout, updateEditorAsset, updateEditorAssetFolder, } from '../../services/image-editor/editorProjectClient'; @@ -80,7 +76,6 @@ import { DEFAULT_CANVAS_BACKGROUND_COLOR, DEFAULT_CANVAS_SIZE, EDITOR_ASSET_FOLDERS, - MAX_HISTORY_STEPS, TOOLBAR_HALF_WIDTH, clamp, createLayerFromAsset, @@ -88,7 +83,6 @@ import { formatImageSizeValue, getDraggedAssetId, hasDataTransferType, - hydrateLayer, isGeneratedLayer, isLayerLinkedToAsset, normalizeAssetLibrary, @@ -151,7 +145,6 @@ import type { CanvasContextMenuState, CanvasGenerationDialogState, CanvasGenerationInputs, - CanvasHistorySnapshot, CanvasLayer, CanvasMarqueeState, CanvasTool, @@ -169,6 +162,8 @@ import type { SpecGenerationType, UploadTarget, } from './ImageCanvasEditorTypes'; +import { useCanvasHistory } from './useCanvasHistory'; +import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence'; function isImageFile(file: File) { return file.type.startsWith('image/'); @@ -250,28 +245,15 @@ export function ImageCanvasEditorView() { const isShiftPressedRef = useRef(false); const layerCounterRef = useRef(0); const generationDialogCounterRef = useRef(0); - const saveTimerRef = useRef(null); - const undoStackRef = useRef([]); - const redoStackRef = useRef([]); const layersRef = useRef([]); const viewportRef = useRef({ x: -260, y: 70, scale: 0.82, }); - const projectIdRef = useRef(null); const specToolWrapRef = useRef(null); const characterSpecButtonRef = useRef(null); const iconSpecButtonRef = useRef(null); - const pendingProjectResourceLayersRef = useRef< - Array<{ - layer: CanvasLayer; - options: { - onCreated?: (resourceId: string) => void; - snapshotLayers?: CanvasLayer[]; - }; - }> - >([]); const selectedLayerIdRef = useRef(null); const selectedLayerIdsRef = useRef([]); const generateDialogRef = useRef(null); @@ -280,7 +262,6 @@ export function ImageCanvasEditorView() { () => {}, ); const suppressAssetClickRef = useRef(false); - const [projectId, setProjectId] = useState(null); const [projectTitle, setProjectTitle] = useState('未命名画布'); const [projectRenameValue, setProjectRenameValue] = useState('未命名画布'); const [isRenamingProject, setIsRenamingProject] = useState(false); @@ -288,7 +269,6 @@ export function ImageCanvasEditorView() { const [projectRenameError, setProjectRenameError] = useState( null, ); - const [isProjectReady, setIsProjectReady] = useState(false); const [assetExportStatus, setAssetExportStatus] = useState<{ tone: 'info' | 'success' | 'error'; message: string; @@ -375,7 +355,6 @@ export function ImageCanvasEditorView() { ); const [canvasClipboard, setCanvasClipboard] = useState(null); - const [historyVersion, setHistoryVersion] = useState(0); const [quickEditPanel, setQuickEditPanel] = useState(null); const [characterAnimationPanel, setCharacterAnimationPanel] = @@ -568,9 +547,55 @@ export function ImageCanvasEditorView() { const contextShouldUnlockLayer = contextTargetLayers.some( (layer) => layer.locked, ); - const canUndo = undoStackRef.current.length > 0; - const canRedo = redoStackRef.current.length > 0; - void historyVersion; + const canvasHistoryRefs = useMemo( + () => ({ + layersRef, + viewportRef, + generateDialogRef, + inactiveGenerateDialogsRef, + selectedLayerIdRef, + selectedLayerIdsRef, + }), + [], + ); + const canvasHistorySetters = useMemo( + () => ({ + setLayers, + setViewport, + setGenerateDialog, + setInactiveGenerateDialogs, + setSelectedLayerId, + setSelectedLayerIds, + }), + [], + ); + const clearHistoryDragState = useCallback(() => { + dragStateRef.current = null; + }, []); + const canvasHistoryResetters = useMemo( + () => ({ + setHoveredLayerId, + setMetadataLayer, + setCanvasMarquee, + setSnapGuide, + setImageContextMenu, + setContextMenu, + setIsPanning, + clearDragState: clearHistoryDragState, + }), + [clearHistoryDragState], + ); + const { + canUndo, + canRedo, + captureCanvasHistory, + undoCanvasChange, + redoCanvasChange, + } = useCanvasHistory({ + refs: canvasHistoryRefs, + setters: canvasHistorySetters, + resetters: canvasHistoryResetters, + }); const groupedAssets = useMemo( () => assetFolders.map((folder) => ({ @@ -688,110 +713,6 @@ export function ImageCanvasEditorView() { ); }; - const getCanvasHistorySnapshot = useCallback( - (): CanvasHistorySnapshot => ({ - layers: layersRef.current.map((layer) => ({ ...layer })), - viewport: { ...viewportRef.current }, - generateDialog: generateDialogRef.current - ? { - ...generateDialogRef.current, - placeholder: generateDialogRef.current.placeholder - ? { ...generateDialogRef.current.placeholder } - : undefined, - } - : null, - inactiveGenerateDialogs: inactiveGenerateDialogsRef.current.map( - (dialog) => ({ - ...dialog, - placeholder: dialog.placeholder - ? { ...dialog.placeholder } - : undefined, - }), - ), - selectedLayerId: selectedLayerIdRef.current, - selectedLayerIds: [...selectedLayerIdsRef.current], - }), - [], - ); - - const restoreCanvasHistorySnapshot = useCallback( - (snapshot: CanvasHistorySnapshot) => { - setLayers(snapshot.layers.map((layer) => ({ ...layer }))); - setViewport({ ...snapshot.viewport }); - setGenerateDialog( - snapshot.generateDialog - ? { - ...snapshot.generateDialog, - placeholder: snapshot.generateDialog.placeholder - ? { ...snapshot.generateDialog.placeholder } - : undefined, - } - : null, - ); - setInactiveGenerateDialogs( - snapshot.inactiveGenerateDialogs.map((dialog) => ({ - ...dialog, - placeholder: dialog.placeholder - ? { ...dialog.placeholder } - : undefined, - })), - ); - setSelectedLayerId(snapshot.selectedLayerId); - setSelectedLayerIds([...snapshot.selectedLayerIds]); - setHoveredLayerId(null); - setMetadataLayer(null); - setCanvasMarquee(null); - setSnapGuide(null); - setImageContextMenu(null); - setContextMenu(null); - setIsPanning(false); - dragStateRef.current = null; - }, - [], - ); - - const captureCanvasHistory = useCallback( - (options: { clearRedo?: boolean } = {}) => { - undoStackRef.current = [ - ...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)), - getCanvasHistorySnapshot(), - ]; - if (options.clearRedo !== false) { - redoStackRef.current = []; - } - setHistoryVersion((version) => version + 1); - }, - [getCanvasHistorySnapshot], - ); - - const undoCanvasChange = useCallback(() => { - const previousSnapshot = undoStackRef.current.at(-1); - if (!previousSnapshot) { - return; - } - undoStackRef.current = undoStackRef.current.slice(0, -1); - redoStackRef.current = [ - ...redoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)), - getCanvasHistorySnapshot(), - ]; - restoreCanvasHistorySnapshot(previousSnapshot); - setHistoryVersion((version) => version + 1); - }, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]); - - const redoCanvasChange = useCallback(() => { - const nextSnapshot = redoStackRef.current.at(-1); - if (!nextSnapshot) { - return; - } - redoStackRef.current = redoStackRef.current.slice(0, -1); - undoStackRef.current = [ - ...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)), - getCanvasHistorySnapshot(), - ]; - restoreCanvasHistorySnapshot(nextSnapshot); - setHistoryVersion((version) => version + 1); - }, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]); - const selectSingleLayer = useCallback((layerId: string | null) => { setSelectedLayerId(layerId); setSelectedLayerIds(layerId ? [layerId] : []); @@ -809,6 +730,34 @@ export function ImageCanvasEditorView() { ); } }, []); + const projectPersistenceRefs = useMemo( + () => ({ + layersRef, + viewportRef, + }), + [], + ); + const projectPersistenceSetters = useMemo( + () => ({ + setProjectTitle, + setProjectRenameValue, + setViewport, + setLayers, + selectSingleLayer, + setLayerCounter: (value: number) => { + layerCounterRef.current = value; + }, + }), + [selectSingleLayer], + ); + const { projectId, appendCanvasLayersWithResources } = + useImageCanvasProjectPersistence({ + refs: projectPersistenceRefs, + setters: projectPersistenceSetters, + layers, + viewport, + openEditorLoginModal, + }); const hideGeneratedLayerPanelAfterBlur = useCallback(() => { setGenerateDialog((currentDialog) => @@ -856,144 +805,11 @@ export function ImageCanvasEditorView() { [], ); - const createProjectResourceForLayer = useCallback( - ( - layer: CanvasLayer, - options: { - onCreated?: (resourceId: string) => void; - snapshotLayers?: CanvasLayer[]; - } = {}, - ) => { - const readyProjectId = projectIdRef.current; - if (!readyProjectId) { - pendingProjectResourceLayersRef.current.push({ layer, options }); - return; - } - createEditorProjectResource(readyProjectId, { - imageSrc: layer.src, - objectKey: layer.objectKey, - assetObjectId: layer.assetObjectId, - width: layer.originalWidth, - height: layer.originalHeight, - sourceType: layer.sourceType, - prompt: layer.prompt, - actualPrompt: layer.actualPrompt, - model: layer.model, - provider: layer.provider, - taskId: layer.taskId, - sourceResourceId: layer.sourceResourceId, - }) - .then((resource) => { - options.onCreated?.(resource.resourceId); - const layerWithResourceId = { - ...layer, - resourceId: resource.resourceId, - }; - const currentLayers = layersRef.current; - const nextLayers = currentLayers.some( - (currentLayer) => currentLayer.id === layer.id, - ) - ? currentLayers.map((currentLayer) => - currentLayer.id === layer.id - ? layerWithResourceId - : currentLayer, - ) - : options.snapshotLayers?.some( - (snapshotLayer) => snapshotLayer.id === layer.id, - ) - ? options.snapshotLayers.map((snapshotLayer) => - snapshotLayer.id === layer.id - ? layerWithResourceId - : snapshotLayer, - ) - : currentLayers; - layersRef.current = nextLayers; - setLayers(nextLayers); - if (nextLayers.length) { - void saveEditorProjectLayout(readyProjectId, { - viewport: viewportRef.current, - layers: nextLayers.map(serializeLayer), - }).catch((error: unknown) => { - if (isEditorAuthError(error)) { - openEditorLoginModal(); - } - }); - } - }) - .catch((error: unknown) => { - if (isEditorAuthError(error)) { - openEditorLoginModal(); - } - }); - }, - [openEditorLoginModal], - ); - const minimapModel = useMemo( () => createMinimapModel({ layers, viewport, canvasSize }), [canvasSize, layers, viewport], ); - useEffect(() => { - let cancelled = false; - const projectIdFromQuery = - typeof window === 'undefined' - ? null - : new URLSearchParams(window.location.search) - .get('projectid') - ?.trim() || null; - const loadProject = projectIdFromQuery - ? loadEditorProject(projectIdFromQuery) - : loadOrCreateRecentEditorProject(); - - loadProject - .then((project) => { - if (cancelled) { - return; - } - projectIdRef.current = project.projectId; - setProjectId(project.projectId); - const nextProjectTitle = project.title?.trim() || '未命名画布'; - setProjectTitle(nextProjectTitle); - setProjectRenameValue(nextProjectTitle); - const pendingLayers = pendingProjectResourceLayersRef.current.splice(0); - pendingLayers.forEach(({ layer, options }) => { - createProjectResourceForLayer(layer, options); - }); - setViewport(project.viewport); - const resourcesById = new Map( - project.resources.map((resource) => [ - resource.resourceId, - { imageSrc: resource.imageSrc }, - ]), - ); - const hydratedLayers = project.layers - .map((layer) => hydrateLayer(layer, resourcesById)) - .filter((layer): layer is CanvasLayer => Boolean(layer)); - if (hydratedLayers.length > 0) { - layerCounterRef.current = hydratedLayers.length; - setLayers(hydratedLayers); - selectSingleLayer(hydratedLayers[0]?.id ?? null); - } - setIsProjectReady(true); - }) - .catch((error: unknown) => { - if (cancelled) { - return; - } - setIsProjectReady(false); - if (isEditorAuthError(error)) { - openEditorLoginModal(() => { - window.location.reload(); - }); - } - }); - - return () => { - cancelled = true; - }; - }, [createProjectResourceForLayer, openEditorLoginModal, selectSingleLayer]); - useEffect(() => { let cancelled = false; loadEditorAssetLibrary() @@ -1245,32 +1061,6 @@ export function ImageCanvasEditorView() { }; }, []); - useEffect(() => { - if (!projectId || !isProjectReady) { - return undefined; - } - if (saveTimerRef.current) { - window.clearTimeout(saveTimerRef.current); - } - - saveTimerRef.current = window.setTimeout(() => { - saveEditorProjectLayout(projectId, { - viewport, - layers: layers.map(serializeLayer), - }).catch((error: unknown) => { - if (isEditorAuthError(error)) { - openEditorLoginModal(); - } - }); - }, 450); - - return () => { - if (saveTimerRef.current) { - window.clearTimeout(saveTimerRef.current); - } - }; - }, [isProjectReady, layers, openEditorLoginModal, projectId, viewport]); - const fitLayers = useCallback( (targetLayers: CanvasLayer[] = layers) => { const nextViewport = fitViewportToLayers({ @@ -1575,21 +1365,6 @@ export function ImageCanvasEditorView() { ); }; - const appendCanvasLayersWithResources = useCallback( - (nextLayers: CanvasLayer[]) => { - if (!nextLayers.length) { - return; - } - const snapshotLayers = [...layersRef.current, ...nextLayers]; - layersRef.current = snapshotLayers; - setLayers(snapshotLayers); - nextLayers.forEach((layer) => - createProjectResourceForLayer(layer, { snapshotLayers }), - ); - }, - [createProjectResourceForLayer], - ); - const addAssetLayer = ( asset: EditorAsset, position?: { x: number; y: number }, diff --git a/src/components/image-editor/useCanvasHistory.test.tsx b/src/components/image-editor/useCanvasHistory.test.tsx new file mode 100644 index 00000000..0e4da5f3 --- /dev/null +++ b/src/components/image-editor/useCanvasHistory.test.tsx @@ -0,0 +1,218 @@ +/* @vitest-environment jsdom */ + +import { act, render, screen } from '@testing-library/react'; +import { useRef, useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { + CanvasGenerationDialogState, + CanvasLayer, + CanvasViewport, + GenerateDialogState, +} from './ImageCanvasEditorTypes'; +import { useCanvasHistory } from './useCanvasHistory'; + +function createLayer(id: string, x: number): CanvasLayer { + return { + id, + resourceId: `resource-${id}`, + title: id, + src: `data:image/png;base64,${id}`, + x, + y: 20, + width: 100, + height: 80, + originalWidth: 100, + originalHeight: 80, + zIndex: 1, + sourceType: 'uploaded', + }; +} + +function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) { + const [layers, setLayers] = useState([ + createLayer('first', 10), + ]); + const [viewport, setViewport] = useState({ + x: 1, + y: 2, + scale: 1, + }); + const [generateDialog, setGenerateDialog] = + useState({ + id: 'dialog-active', + mode: 'generate', + prompt: 'active prompt', + status: 'idle', + placeholder: { + x: 10, + y: 20, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + }, + }); + const [inactiveGenerateDialogs, setInactiveGenerateDialogs] = useState< + CanvasGenerationDialogState[] + >([ + { + id: 'dialog-inactive', + mode: 'character', + prompt: 'archived prompt', + status: 'idle', + placeholder: { + x: 30, + y: 40, + width: 512, + height: 512, + originalWidth: 512, + originalHeight: 512, + }, + }, + ]); + const [selectedLayerId, setSelectedLayerId] = useState('first'); + const [selectedLayerIds, setSelectedLayerIds] = useState(['first']); + + const layersRef = useRef(layers); + const viewportRef = useRef(viewport); + const generateDialogRef = useRef(generateDialog); + const inactiveGenerateDialogsRef = useRef(inactiveGenerateDialogs); + const selectedLayerIdRef = useRef(selectedLayerId); + const selectedLayerIdsRef = useRef(selectedLayerIds); + + layersRef.current = layers; + viewportRef.current = viewport; + generateDialogRef.current = generateDialog; + inactiveGenerateDialogsRef.current = inactiveGenerateDialogs; + selectedLayerIdRef.current = selectedLayerId; + selectedLayerIdsRef.current = selectedLayerIds; + + const history = useCanvasHistory({ + refs: { + layersRef, + viewportRef, + generateDialogRef, + inactiveGenerateDialogsRef, + selectedLayerIdRef, + selectedLayerIdsRef, + }, + setters: { + setLayers, + setViewport, + setGenerateDialog, + setInactiveGenerateDialogs, + setSelectedLayerId, + setSelectedLayerIds, + }, + resetters: { + setHoveredLayerId: () => {}, + setMetadataLayer: () => {}, + setCanvasMarquee: () => {}, + setSnapGuide: () => {}, + setImageContextMenu: () => {}, + setContextMenu: () => {}, + setIsPanning: () => {}, + clearDragState: onClearDrag, + }, + }); + + return ( +
+ + {layers.map((layer) => `${layer.id}:${layer.x}`).join(',')} + + + {viewport.x},{viewport.y},{viewport.scale} + + {generateDialog?.prompt ?? '-'} + + {inactiveGenerateDialogs.map((dialog) => dialog.prompt).join(',')} + + {selectedLayerIds.join(',')} + {String(history.canUndo)} + {String(history.canRedo)} + + + + +
+ ); +} + +describe('useCanvasHistory', () => { + it('captures, restores, and replays canvas history snapshots', () => { + const clearDragState = vi.fn(); + render(); + + act(() => { + screen.getByRole('button', { name: 'capture' }).click(); + }); + expect(screen.getByTestId('can-undo').textContent).toBe('true'); + + act(() => { + screen.getByRole('button', { name: 'mutate' }).click(); + }); + expect(screen.getByTestId('layers').textContent).toBe('second:90'); + expect(screen.getByTestId('viewport').textContent).toBe('9,8,2'); + + act(() => { + screen.getByRole('button', { name: 'undo' }).click(); + }); + + expect(screen.getByTestId('layers').textContent).toBe('first:10'); + expect(screen.getByTestId('viewport').textContent).toBe('1,2,1'); + expect(screen.getByTestId('dialog').textContent).toBe('active prompt'); + expect(screen.getByTestId('inactive').textContent).toBe('archived prompt'); + expect(screen.getByTestId('selection').textContent).toBe('first'); + expect(screen.getByTestId('can-redo').textContent).toBe('true'); + expect(clearDragState).toHaveBeenCalledTimes(1); + + act(() => { + screen.getByRole('button', { name: 'redo' }).click(); + }); + + expect(screen.getByTestId('layers').textContent).toBe('second:90'); + expect(screen.getByTestId('viewport').textContent).toBe('9,8,2'); + expect(screen.getByTestId('dialog').textContent).toBe('next prompt'); + expect(screen.getByTestId('selection').textContent).toBe('second'); + }); +}); diff --git a/src/components/image-editor/useCanvasHistory.ts b/src/components/image-editor/useCanvasHistory.ts new file mode 100644 index 00000000..d5298ee5 --- /dev/null +++ b/src/components/image-editor/useCanvasHistory.ts @@ -0,0 +1,169 @@ +import { type RefObject, useCallback, useRef, useState } from 'react'; + +import { MAX_HISTORY_STEPS } from './ImageCanvasEditorModel'; +import type { + CanvasGenerationDialogState, + CanvasHistorySnapshot, + CanvasLayer, + CanvasMarqueeState, + CanvasContextMenuState, + CanvasViewport, + GenerateDialogState, + ImageContextMenuState, + SnapGuide, +} from './ImageCanvasEditorTypes'; + +type CanvasHistoryRefs = { + layersRef: RefObject; + viewportRef: RefObject; + generateDialogRef: RefObject; + inactiveGenerateDialogsRef: RefObject; + selectedLayerIdRef: RefObject; + selectedLayerIdsRef: RefObject; +}; + +type CanvasHistorySetters = { + setLayers: (layers: CanvasLayer[]) => void; + setViewport: (viewport: CanvasViewport) => void; + setGenerateDialog: (dialog: GenerateDialogState | null) => void; + setInactiveGenerateDialogs: ( + dialogs: CanvasGenerationDialogState[], + ) => void; + setSelectedLayerId: (layerId: string | null) => void; + setSelectedLayerIds: (layerIds: string[]) => void; +}; + +type CanvasHistoryResetters = { + setHoveredLayerId: (layerId: string | null) => void; + setMetadataLayer: (layer: CanvasLayer | null) => void; + setCanvasMarquee: (marquee: CanvasMarqueeState | null) => void; + setSnapGuide: (guide: SnapGuide | null) => void; + setImageContextMenu: (menu: ImageContextMenuState | null) => void; + setContextMenu: (menu: CanvasContextMenuState | null) => void; + setIsPanning: (isPanning: boolean) => void; + clearDragState: () => void; +}; + +function cloneGenerateDialog(dialog: GenerateDialogState): GenerateDialogState { + return { + ...dialog, + placeholder: dialog.placeholder ? { ...dialog.placeholder } : undefined, + }; +} + +function cloneCanvasGenerationDialog( + dialog: CanvasGenerationDialogState, +): CanvasGenerationDialogState { + return { + ...dialog, + placeholder: dialog.placeholder ? { ...dialog.placeholder } : undefined, + }; +} + +export function useCanvasHistory({ + refs, + setters, + resetters, +}: { + refs: CanvasHistoryRefs; + setters: CanvasHistorySetters; + resetters: CanvasHistoryResetters; +}) { + const undoStackRef = useRef([]); + const redoStackRef = useRef([]); + const [historyVersion, setHistoryVersion] = useState(0); + + const getCanvasHistorySnapshot = useCallback( + (): CanvasHistorySnapshot => ({ + layers: refs.layersRef.current.map((layer) => ({ ...layer })), + viewport: { ...refs.viewportRef.current }, + generateDialog: refs.generateDialogRef.current + ? cloneGenerateDialog(refs.generateDialogRef.current) + : null, + inactiveGenerateDialogs: + refs.inactiveGenerateDialogsRef.current.map(cloneCanvasGenerationDialog), + selectedLayerId: refs.selectedLayerIdRef.current, + selectedLayerIds: [...refs.selectedLayerIdsRef.current], + }), + [refs], + ); + + const restoreCanvasHistorySnapshot = useCallback( + (snapshot: CanvasHistorySnapshot) => { + setters.setLayers(snapshot.layers.map((layer) => ({ ...layer }))); + setters.setViewport({ ...snapshot.viewport }); + setters.setGenerateDialog( + snapshot.generateDialog + ? cloneGenerateDialog(snapshot.generateDialog) + : null, + ); + setters.setInactiveGenerateDialogs( + snapshot.inactiveGenerateDialogs.map(cloneCanvasGenerationDialog), + ); + setters.setSelectedLayerId(snapshot.selectedLayerId); + setters.setSelectedLayerIds([...snapshot.selectedLayerIds]); + resetters.setHoveredLayerId(null); + resetters.setMetadataLayer(null); + resetters.setCanvasMarquee(null); + resetters.setSnapGuide(null); + resetters.setImageContextMenu(null); + resetters.setContextMenu(null); + resetters.setIsPanning(false); + resetters.clearDragState(); + }, + [resetters, setters], + ); + + const captureCanvasHistory = useCallback( + (options: { clearRedo?: boolean } = {}) => { + undoStackRef.current = [ + ...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)), + getCanvasHistorySnapshot(), + ]; + if (options.clearRedo !== false) { + redoStackRef.current = []; + } + setHistoryVersion((version) => version + 1); + }, + [getCanvasHistorySnapshot], + ); + + const undoCanvasChange = useCallback(() => { + const previousSnapshot = undoStackRef.current.at(-1); + if (!previousSnapshot) { + return; + } + undoStackRef.current = undoStackRef.current.slice(0, -1); + redoStackRef.current = [ + ...redoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)), + getCanvasHistorySnapshot(), + ]; + restoreCanvasHistorySnapshot(previousSnapshot); + setHistoryVersion((version) => version + 1); + }, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]); + + const redoCanvasChange = useCallback(() => { + const nextSnapshot = redoStackRef.current.at(-1); + if (!nextSnapshot) { + return; + } + redoStackRef.current = redoStackRef.current.slice(0, -1); + undoStackRef.current = [ + ...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)), + getCanvasHistorySnapshot(), + ]; + restoreCanvasHistorySnapshot(nextSnapshot); + setHistoryVersion((version) => version + 1); + }, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]); + + return { + canUndo: undoStackRef.current.length > 0, + canRedo: redoStackRef.current.length > 0, + historyVersion, + getCanvasHistorySnapshot, + restoreCanvasHistorySnapshot, + captureCanvasHistory, + undoCanvasChange, + redoCanvasChange, + }; +} diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx new file mode 100644 index 00000000..035f6e32 --- /dev/null +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -0,0 +1,172 @@ +/* @vitest-environment jsdom */ + +import { act, render, screen, waitFor } from '@testing-library/react'; +import { useRef, useState } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes'; +import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence'; + +const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); +const loadEditorProjectMock = vi.hoisted(() => vi.fn()); +const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn()); +const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn()); + +vi.mock('../../services/image-editor/editorProjectClient', async () => { + const actual = await vi.importActual< + typeof import('../../services/image-editor/editorProjectClient') + >('../../services/image-editor/editorProjectClient'); + return { + ...actual, + createEditorProjectResource: createEditorProjectResourceMock, + loadEditorProject: loadEditorProjectMock, + loadOrCreateRecentEditorProject: loadOrCreateRecentEditorProjectMock, + saveEditorProjectLayout: saveEditorProjectLayoutMock, + }; +}); + +function createLayer(id: string): CanvasLayer { + return { + id, + resourceId: `local-${id}`, + title: '账号素材A', + src: 'data:image/png;base64,YQ==', + x: 10, + y: 20, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + zIndex: 3, + sourceType: 'uploaded', + sourceAssetId: 'asset-a', + }; +} + +function ProjectPersistenceHarness() { + const [layers, setLayers] = useState([]); + const [viewport, setViewport] = useState({ + x: 0, + y: 0, + scale: 1, + }); + const [projectTitle, setProjectTitle] = useState(''); + const [projectRenameValue, setProjectRenameValue] = useState(''); + const layersRef = useRef(layers); + const viewportRef = useRef(viewport); + const selectedLayerRef = useRef(null); + const layerCounterRef = useRef(0); + + layersRef.current = layers; + viewportRef.current = viewport; + + const persistence = useImageCanvasProjectPersistence({ + refs: { + layersRef, + viewportRef, + }, + setters: { + setProjectTitle, + setProjectRenameValue, + setViewport, + setLayers, + selectSingleLayer: (layerId) => { + selectedLayerRef.current = layerId; + }, + setLayerCounter: (value) => { + layerCounterRef.current = value; + }, + }, + layers, + viewport, + openEditorLoginModal: vi.fn(), + }); + + return ( +
+ {persistence.projectId ?? '-'} + {projectTitle} + {projectRenameValue} + + {layers.map((layer) => `${layer.id}:${layer.resourceId}`).join(',')} + + {selectedLayerRef.current ?? '-'} + {layerCounterRef.current} + +
+ ); +} + +describe('useImageCanvasProjectPersistence', () => { + beforeEach(() => { + vi.clearAllMocks(); + loadOrCreateRecentEditorProjectMock.mockResolvedValue({ + projectId: 'editor-project-default', + title: '空画布项目', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }); + createEditorProjectResourceMock.mockResolvedValue({ + resourceId: 'resource-added-asset-a', + projectId: 'editor-project-default', + imageSrc: 'data:image/png;base64,YQ==', + width: 320, + height: 240, + sourceType: 'uploaded', + }); + saveEditorProjectLayoutMock.mockResolvedValue({ + projectId: 'editor-project-default', + title: '空画布项目', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }); + }); + + it('saves appended layers with the server resource id immediately after resource creation', async () => { + render(); + + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + expect(screen.getByTestId('project-title').textContent).toBe('空画布项目'); + expect(screen.getByTestId('project-rename').textContent).toBe( + '空画布项目', + ); + + act(() => { + screen.getByRole('button', { name: 'append' }).click(); + }); + + expect(screen.getByTestId('layers').textContent).toBe( + 'layer-a:local-layer-a', + ); + await waitFor(() => { + expect(screen.getByTestId('layers').textContent).toBe( + 'layer-a:resource-added-asset-a', + ); + }); + await waitFor(() => { + expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ + layers: expect.arrayContaining([ + expect.objectContaining({ + layerId: 'layer-a', + resourceId: 'resource-added-asset-a', + sourceAssetId: 'asset-a', + }), + ]), + }), + ); + }); + }); +}); diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.ts b/src/components/image-editor/useImageCanvasProjectPersistence.ts new file mode 100644 index 00000000..d5144a19 --- /dev/null +++ b/src/components/image-editor/useImageCanvasProjectPersistence.ts @@ -0,0 +1,240 @@ +import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'; + +import { ApiClientError } from '../../services/apiClient'; +import { + createEditorProjectResource, + loadEditorProject, + loadOrCreateRecentEditorProject, + saveEditorProjectLayout, +} from '../../services/image-editor/editorProjectClient'; +import { hydrateLayer, serializeLayer } from './ImageCanvasEditorModel'; +import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes'; + +type ProjectResourceOptions = { + onCreated?: (resourceId: string) => void; + snapshotLayers?: CanvasLayer[]; +}; + +type PendingProjectResourceLayer = { + layer: CanvasLayer; + options: ProjectResourceOptions; +}; + +type ImageCanvasProjectPersistenceRefs = { + layersRef: RefObject; + viewportRef: RefObject; +}; + +type ImageCanvasProjectPersistenceSetters = { + setProjectTitle: (title: string) => void; + setProjectRenameValue: (title: string) => void; + setViewport: (viewport: CanvasViewport) => void; + setLayers: (layers: CanvasLayer[]) => void; + selectSingleLayer: (layerId: string | null) => void; + setLayerCounter: (value: number) => void; +}; + +function isEditorAuthError(error: unknown) { + return ( + error instanceof ApiClientError && + (error.status === 401 || error.status === 403) + ); +} + +export function useImageCanvasProjectPersistence({ + refs, + setters, + layers, + viewport, + openEditorLoginModal, +}: { + refs: ImageCanvasProjectPersistenceRefs; + setters: ImageCanvasProjectPersistenceSetters; + layers: CanvasLayer[]; + viewport: CanvasViewport; + openEditorLoginModal: (postLoginAction?: (() => void) | null) => void; +}) { + const projectIdRef = useRef(null); + const pendingProjectResourceLayersRef = useRef( + [], + ); + const saveTimerRef = useRef(null); + const [projectId, setProjectId] = useState(null); + const [isProjectReady, setIsProjectReady] = useState(false); + + const createProjectResourceForLayer = useCallback( + (layer: CanvasLayer, options: ProjectResourceOptions = {}) => { + const readyProjectId = projectIdRef.current; + if (!readyProjectId) { + pendingProjectResourceLayersRef.current.push({ layer, options }); + return; + } + createEditorProjectResource(readyProjectId, { + imageSrc: layer.src, + objectKey: layer.objectKey, + assetObjectId: layer.assetObjectId, + width: layer.originalWidth, + height: layer.originalHeight, + sourceType: layer.sourceType, + prompt: layer.prompt, + actualPrompt: layer.actualPrompt, + model: layer.model, + provider: layer.provider, + taskId: layer.taskId, + sourceResourceId: layer.sourceResourceId, + }) + .then((resource) => { + options.onCreated?.(resource.resourceId); + const layerWithResourceId = { + ...layer, + resourceId: resource.resourceId, + }; + const currentLayers = refs.layersRef.current; + const nextLayers = currentLayers.some( + (currentLayer) => currentLayer.id === layer.id, + ) + ? currentLayers.map((currentLayer) => + currentLayer.id === layer.id + ? layerWithResourceId + : currentLayer, + ) + : options.snapshotLayers?.some( + (snapshotLayer) => snapshotLayer.id === layer.id, + ) + ? options.snapshotLayers.map((snapshotLayer) => + snapshotLayer.id === layer.id + ? layerWithResourceId + : snapshotLayer, + ) + : currentLayers; + refs.layersRef.current = nextLayers; + setters.setLayers(nextLayers); + if (nextLayers.length) { + void saveEditorProjectLayout(readyProjectId, { + viewport: refs.viewportRef.current, + layers: nextLayers.map(serializeLayer), + }).catch((error: unknown) => { + if (isEditorAuthError(error)) { + openEditorLoginModal(); + } + }); + } + }) + .catch((error: unknown) => { + if (isEditorAuthError(error)) { + openEditorLoginModal(); + } + }); + }, + [openEditorLoginModal, refs, setters], + ); + + const appendCanvasLayersWithResources = useCallback( + (nextLayers: CanvasLayer[]) => { + if (!nextLayers.length) { + return; + } + const snapshotLayers = [...refs.layersRef.current, ...nextLayers]; + refs.layersRef.current = snapshotLayers; + setters.setLayers(snapshotLayers); + nextLayers.forEach((layer) => + createProjectResourceForLayer(layer, { snapshotLayers }), + ); + }, + [createProjectResourceForLayer, refs, setters], + ); + + useEffect(() => { + let cancelled = false; + const projectIdFromQuery = + typeof window === 'undefined' + ? null + : new URLSearchParams(window.location.search) + .get('projectid') + ?.trim() || null; + const loadProject = projectIdFromQuery + ? loadEditorProject(projectIdFromQuery) + : loadOrCreateRecentEditorProject(); + + loadProject + .then((project) => { + if (cancelled) { + return; + } + projectIdRef.current = project.projectId; + setProjectId(project.projectId); + const nextProjectTitle = project.title?.trim() || '未命名画布'; + setters.setProjectTitle(nextProjectTitle); + setters.setProjectRenameValue(nextProjectTitle); + const pendingLayers = pendingProjectResourceLayersRef.current.splice(0); + pendingLayers.forEach(({ layer, options }) => { + createProjectResourceForLayer(layer, options); + }); + setters.setViewport(project.viewport); + const resourcesById = new Map( + project.resources.map((resource) => [ + resource.resourceId, + { imageSrc: resource.imageSrc }, + ]), + ); + const hydratedLayers = project.layers + .map((layer) => hydrateLayer(layer, resourcesById)) + .filter((layer): layer is CanvasLayer => Boolean(layer)); + if (hydratedLayers.length > 0) { + setters.setLayerCounter(hydratedLayers.length); + setters.setLayers(hydratedLayers); + setters.selectSingleLayer(hydratedLayers[0]?.id ?? null); + } + setIsProjectReady(true); + }) + .catch((error: unknown) => { + if (cancelled) { + return; + } + setIsProjectReady(false); + if (isEditorAuthError(error)) { + openEditorLoginModal(() => { + window.location.reload(); + }); + } + }); + + return () => { + cancelled = true; + }; + }, [createProjectResourceForLayer, openEditorLoginModal, setters]); + + useEffect(() => { + if (!projectId || !isProjectReady) { + return undefined; + } + if (saveTimerRef.current) { + window.clearTimeout(saveTimerRef.current); + } + + saveTimerRef.current = window.setTimeout(() => { + saveEditorProjectLayout(projectId, { + viewport, + layers: layers.map(serializeLayer), + }).catch((error: unknown) => { + if (isEditorAuthError(error)) { + openEditorLoginModal(); + } + }); + }, 450); + + return () => { + if (saveTimerRef.current) { + window.clearTimeout(saveTimerRef.current); + } + }; + }, [isProjectReady, layers, openEditorLoginModal, projectId, viewport]); + + return { + projectId, + isProjectReady, + projectIdRef, + createProjectResourceForLayer, + appendCanvasLayersWithResources, + }; +}