拆分图片画布历史与持久化协调器
新增画布历史 hook 承接撤销重做快照逻辑 新增项目持久化 hook 承接加载资源创建与自动保存时序 补充 hook 单测并更新图片画布拆分跟踪文档
This commit is contained in:
@@ -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 前端拆分第五阶段:新增 `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 前端拆分第六阶段:新增 `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 新增素材持久化修正:素材库图片、上传到画布、生成图、修改图和图标素材加入画布时会先用当前图层快照更新本地画布,再在资源创建完成后立刻保存带真实 `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`。
|
||||||
|
|||||||
@@ -71,11 +71,24 @@
|
|||||||
- 主视图继续负责 React 事件对象、pointer capture、history 快照、生成对象回写、选中态和 `setState`。
|
- 主视图继续负责 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 规则进一步稳定后,再从主视图抽出深层状态模型。
|
- 生成状态机模型:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再从主视图抽出深层状态模型。
|
||||||
- 上传 / 素材状态模型:上传占位卡片、素材文件夹移动、账号级素材库和拖拽遮罩仍在主视图与侧栏之间协作,后续需要等上传错误恢复和批量操作规则稳定后再收口。
|
- 上传 / 素材状态模型:上传占位卡片、素材文件夹移动、账号级素材库和拖拽遮罩仍在主视图与侧栏之间协作,后续需要等上传错误恢复和批量操作规则稳定后再收口。
|
||||||
- 资源持久化稳定性:新增图层时先使用当前画布图层快照更新本地状态,再等待工程资源创建并即时保存带真实 `resourceId` 的 layout。后续如果继续拆上传或生成状态机,必须保留这一时序,避免 React 状态刷新和异步资源返回交错时写回空图层。
|
|
||||||
|
|
||||||
## 验证计划
|
## 验证计划
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/
|
|||||||
import {
|
import {
|
||||||
createEditorAsset,
|
createEditorAsset,
|
||||||
createEditorAssetFolder,
|
createEditorAssetFolder,
|
||||||
createEditorProjectResource,
|
|
||||||
deleteEditorAsset,
|
deleteEditorAsset,
|
||||||
deleteEditorAssetFolder,
|
deleteEditorAssetFolder,
|
||||||
editEditorImage,
|
editEditorImage,
|
||||||
@@ -28,10 +27,7 @@ import {
|
|||||||
generateEditorIconSpritesheet,
|
generateEditorIconSpritesheet,
|
||||||
generateEditorImage,
|
generateEditorImage,
|
||||||
loadEditorAssetLibrary,
|
loadEditorAssetLibrary,
|
||||||
loadEditorProject,
|
|
||||||
loadOrCreateRecentEditorProject,
|
|
||||||
renameEditorProject,
|
renameEditorProject,
|
||||||
saveEditorProjectLayout,
|
|
||||||
updateEditorAsset,
|
updateEditorAsset,
|
||||||
updateEditorAssetFolder,
|
updateEditorAssetFolder,
|
||||||
} from '../../services/image-editor/editorProjectClient';
|
} from '../../services/image-editor/editorProjectClient';
|
||||||
@@ -80,7 +76,6 @@ import {
|
|||||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||||
DEFAULT_CANVAS_SIZE,
|
DEFAULT_CANVAS_SIZE,
|
||||||
EDITOR_ASSET_FOLDERS,
|
EDITOR_ASSET_FOLDERS,
|
||||||
MAX_HISTORY_STEPS,
|
|
||||||
TOOLBAR_HALF_WIDTH,
|
TOOLBAR_HALF_WIDTH,
|
||||||
clamp,
|
clamp,
|
||||||
createLayerFromAsset,
|
createLayerFromAsset,
|
||||||
@@ -88,7 +83,6 @@ import {
|
|||||||
formatImageSizeValue,
|
formatImageSizeValue,
|
||||||
getDraggedAssetId,
|
getDraggedAssetId,
|
||||||
hasDataTransferType,
|
hasDataTransferType,
|
||||||
hydrateLayer,
|
|
||||||
isGeneratedLayer,
|
isGeneratedLayer,
|
||||||
isLayerLinkedToAsset,
|
isLayerLinkedToAsset,
|
||||||
normalizeAssetLibrary,
|
normalizeAssetLibrary,
|
||||||
@@ -151,7 +145,6 @@ import type {
|
|||||||
CanvasContextMenuState,
|
CanvasContextMenuState,
|
||||||
CanvasGenerationDialogState,
|
CanvasGenerationDialogState,
|
||||||
CanvasGenerationInputs,
|
CanvasGenerationInputs,
|
||||||
CanvasHistorySnapshot,
|
|
||||||
CanvasLayer,
|
CanvasLayer,
|
||||||
CanvasMarqueeState,
|
CanvasMarqueeState,
|
||||||
CanvasTool,
|
CanvasTool,
|
||||||
@@ -169,6 +162,8 @@ import type {
|
|||||||
SpecGenerationType,
|
SpecGenerationType,
|
||||||
UploadTarget,
|
UploadTarget,
|
||||||
} from './ImageCanvasEditorTypes';
|
} from './ImageCanvasEditorTypes';
|
||||||
|
import { useCanvasHistory } from './useCanvasHistory';
|
||||||
|
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
||||||
|
|
||||||
function isImageFile(file: File) {
|
function isImageFile(file: File) {
|
||||||
return file.type.startsWith('image/');
|
return file.type.startsWith('image/');
|
||||||
@@ -250,28 +245,15 @@ export function ImageCanvasEditorView() {
|
|||||||
const isShiftPressedRef = useRef(false);
|
const isShiftPressedRef = useRef(false);
|
||||||
const layerCounterRef = useRef(0);
|
const layerCounterRef = useRef(0);
|
||||||
const generationDialogCounterRef = useRef(0);
|
const generationDialogCounterRef = useRef(0);
|
||||||
const saveTimerRef = useRef<number | null>(null);
|
|
||||||
const undoStackRef = useRef<CanvasHistorySnapshot[]>([]);
|
|
||||||
const redoStackRef = useRef<CanvasHistorySnapshot[]>([]);
|
|
||||||
const layersRef = useRef<CanvasLayer[]>([]);
|
const layersRef = useRef<CanvasLayer[]>([]);
|
||||||
const viewportRef = useRef<CanvasViewport>({
|
const viewportRef = useRef<CanvasViewport>({
|
||||||
x: -260,
|
x: -260,
|
||||||
y: 70,
|
y: 70,
|
||||||
scale: 0.82,
|
scale: 0.82,
|
||||||
});
|
});
|
||||||
const projectIdRef = useRef<string | null>(null);
|
|
||||||
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||||||
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const pendingProjectResourceLayersRef = useRef<
|
|
||||||
Array<{
|
|
||||||
layer: CanvasLayer;
|
|
||||||
options: {
|
|
||||||
onCreated?: (resourceId: string) => void;
|
|
||||||
snapshotLayers?: CanvasLayer[];
|
|
||||||
};
|
|
||||||
}>
|
|
||||||
>([]);
|
|
||||||
const selectedLayerIdRef = useRef<string | null>(null);
|
const selectedLayerIdRef = useRef<string | null>(null);
|
||||||
const selectedLayerIdsRef = useRef<string[]>([]);
|
const selectedLayerIdsRef = useRef<string[]>([]);
|
||||||
const generateDialogRef = useRef<GenerateDialogState | null>(null);
|
const generateDialogRef = useRef<GenerateDialogState | null>(null);
|
||||||
@@ -280,7 +262,6 @@ export function ImageCanvasEditorView() {
|
|||||||
() => {},
|
() => {},
|
||||||
);
|
);
|
||||||
const suppressAssetClickRef = useRef(false);
|
const suppressAssetClickRef = useRef(false);
|
||||||
const [projectId, setProjectId] = useState<string | null>(null);
|
|
||||||
const [projectTitle, setProjectTitle] = useState('未命名画布');
|
const [projectTitle, setProjectTitle] = useState('未命名画布');
|
||||||
const [projectRenameValue, setProjectRenameValue] = useState('未命名画布');
|
const [projectRenameValue, setProjectRenameValue] = useState('未命名画布');
|
||||||
const [isRenamingProject, setIsRenamingProject] = useState(false);
|
const [isRenamingProject, setIsRenamingProject] = useState(false);
|
||||||
@@ -288,7 +269,6 @@ export function ImageCanvasEditorView() {
|
|||||||
const [projectRenameError, setProjectRenameError] = useState<string | null>(
|
const [projectRenameError, setProjectRenameError] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [isProjectReady, setIsProjectReady] = useState(false);
|
|
||||||
const [assetExportStatus, setAssetExportStatus] = useState<{
|
const [assetExportStatus, setAssetExportStatus] = useState<{
|
||||||
tone: 'info' | 'success' | 'error';
|
tone: 'info' | 'success' | 'error';
|
||||||
message: string;
|
message: string;
|
||||||
@@ -375,7 +355,6 @@ export function ImageCanvasEditorView() {
|
|||||||
);
|
);
|
||||||
const [canvasClipboard, setCanvasClipboard] =
|
const [canvasClipboard, setCanvasClipboard] =
|
||||||
useState<CanvasClipboard | null>(null);
|
useState<CanvasClipboard | null>(null);
|
||||||
const [historyVersion, setHistoryVersion] = useState(0);
|
|
||||||
const [quickEditPanel, setQuickEditPanel] =
|
const [quickEditPanel, setQuickEditPanel] =
|
||||||
useState<QuickEditPanelState | null>(null);
|
useState<QuickEditPanelState | null>(null);
|
||||||
const [characterAnimationPanel, setCharacterAnimationPanel] =
|
const [characterAnimationPanel, setCharacterAnimationPanel] =
|
||||||
@@ -568,9 +547,55 @@ export function ImageCanvasEditorView() {
|
|||||||
const contextShouldUnlockLayer = contextTargetLayers.some(
|
const contextShouldUnlockLayer = contextTargetLayers.some(
|
||||||
(layer) => layer.locked,
|
(layer) => layer.locked,
|
||||||
);
|
);
|
||||||
const canUndo = undoStackRef.current.length > 0;
|
const canvasHistoryRefs = useMemo(
|
||||||
const canRedo = redoStackRef.current.length > 0;
|
() => ({
|
||||||
void historyVersion;
|
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(
|
const groupedAssets = useMemo(
|
||||||
() =>
|
() =>
|
||||||
assetFolders.map((folder) => ({
|
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) => {
|
const selectSingleLayer = useCallback((layerId: string | null) => {
|
||||||
setSelectedLayerId(layerId);
|
setSelectedLayerId(layerId);
|
||||||
setSelectedLayerIds(layerId ? [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(() => {
|
const hideGeneratedLayerPanelAfterBlur = useCallback(() => {
|
||||||
setGenerateDialog((currentDialog) =>
|
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(
|
const minimapModel = useMemo(
|
||||||
() => createMinimapModel({ layers, viewport, canvasSize }),
|
() => createMinimapModel({ layers, viewport, canvasSize }),
|
||||||
[canvasSize, layers, viewport],
|
[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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
loadEditorAssetLibrary()
|
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(
|
const fitLayers = useCallback(
|
||||||
(targetLayers: CanvasLayer[] = layers) => {
|
(targetLayers: CanvasLayer[] = layers) => {
|
||||||
const nextViewport = fitViewportToLayers({
|
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 = (
|
const addAssetLayer = (
|
||||||
asset: EditorAsset,
|
asset: EditorAsset,
|
||||||
position?: { x: number; y: number },
|
position?: { x: number; y: number },
|
||||||
|
|||||||
218
src/components/image-editor/useCanvasHistory.test.tsx
Normal file
218
src/components/image-editor/useCanvasHistory.test.tsx
Normal file
@@ -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<CanvasLayer[]>([
|
||||||
|
createLayer('first', 10),
|
||||||
|
]);
|
||||||
|
const [viewport, setViewport] = useState<CanvasViewport>({
|
||||||
|
x: 1,
|
||||||
|
y: 2,
|
||||||
|
scale: 1,
|
||||||
|
});
|
||||||
|
const [generateDialog, setGenerateDialog] =
|
||||||
|
useState<GenerateDialogState | null>({
|
||||||
|
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<string | null>('first');
|
||||||
|
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>(['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 (
|
||||||
|
<div>
|
||||||
|
<span data-testid="layers">
|
||||||
|
{layers.map((layer) => `${layer.id}:${layer.x}`).join(',')}
|
||||||
|
</span>
|
||||||
|
<span data-testid="viewport">
|
||||||
|
{viewport.x},{viewport.y},{viewport.scale}
|
||||||
|
</span>
|
||||||
|
<span data-testid="dialog">{generateDialog?.prompt ?? '-'}</span>
|
||||||
|
<span data-testid="inactive">
|
||||||
|
{inactiveGenerateDialogs.map((dialog) => dialog.prompt).join(',')}
|
||||||
|
</span>
|
||||||
|
<span data-testid="selection">{selectedLayerIds.join(',')}</span>
|
||||||
|
<span data-testid="can-undo">{String(history.canUndo)}</span>
|
||||||
|
<span data-testid="can-redo">{String(history.canRedo)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
history.captureCanvasHistory();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
capture
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setLayers([createLayer('second', 90)]);
|
||||||
|
setViewport({ x: 9, y: 8, scale: 2 });
|
||||||
|
setGenerateDialog({
|
||||||
|
id: 'dialog-next',
|
||||||
|
mode: 'spec',
|
||||||
|
prompt: 'next prompt',
|
||||||
|
status: 'idle',
|
||||||
|
});
|
||||||
|
setInactiveGenerateDialogs([]);
|
||||||
|
setSelectedLayerId('second');
|
||||||
|
setSelectedLayerIds(['second']);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
mutate
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
history.undoCanvasChange();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
undo
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
history.redoCanvasChange();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
redo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useCanvasHistory', () => {
|
||||||
|
it('captures, restores, and replays canvas history snapshots', () => {
|
||||||
|
const clearDragState = vi.fn();
|
||||||
|
render(<HistoryHarness onClearDrag={clearDragState} />);
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
169
src/components/image-editor/useCanvasHistory.ts
Normal file
169
src/components/image-editor/useCanvasHistory.ts
Normal file
@@ -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<CanvasLayer[]>;
|
||||||
|
viewportRef: RefObject<CanvasViewport>;
|
||||||
|
generateDialogRef: RefObject<GenerateDialogState | null>;
|
||||||
|
inactiveGenerateDialogsRef: RefObject<CanvasGenerationDialogState[]>;
|
||||||
|
selectedLayerIdRef: RefObject<string | null>;
|
||||||
|
selectedLayerIdsRef: RefObject<string[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<CanvasHistorySnapshot[]>([]);
|
||||||
|
const redoStackRef = useRef<CanvasHistorySnapshot[]>([]);
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<CanvasLayer[]>([]);
|
||||||
|
const [viewport, setViewport] = useState<CanvasViewport>({
|
||||||
|
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<string | null>(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 (
|
||||||
|
<div>
|
||||||
|
<span data-testid="project-id">{persistence.projectId ?? '-'}</span>
|
||||||
|
<span data-testid="project-title">{projectTitle}</span>
|
||||||
|
<span data-testid="project-rename">{projectRenameValue}</span>
|
||||||
|
<span data-testid="layers">
|
||||||
|
{layers.map((layer) => `${layer.id}:${layer.resourceId}`).join(',')}
|
||||||
|
</span>
|
||||||
|
<span data-testid="selected">{selectedLayerRef.current ?? '-'}</span>
|
||||||
|
<span data-testid="counter">{layerCounterRef.current}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
persistence.appendCanvasLayersWithResources([createLayer('layer-a')]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
append
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(<ProjectPersistenceHarness />);
|
||||||
|
|
||||||
|
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',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
240
src/components/image-editor/useImageCanvasProjectPersistence.ts
Normal file
240
src/components/image-editor/useImageCanvasProjectPersistence.ts
Normal file
@@ -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<CanvasLayer[]>;
|
||||||
|
viewportRef: RefObject<CanvasViewport>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string | null>(null);
|
||||||
|
const pendingProjectResourceLayersRef = useRef<PendingProjectResourceLayer[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const saveTimerRef = useRef<number | null>(null);
|
||||||
|
const [projectId, setProjectId] = useState<string | null>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user