拆分图片画布图层命令工作流
新增图层命令 hook 和独立单测 主视图改为通过 hook 处理复制、剪切、粘贴、层级、分组、显隐、锁定、翻转、删除和导出委托 更新图片画布前端拆分文档和 TRACKING 回归记录
This commit is contained in:
@@ -128,3 +128,4 @@
|
|||||||
- 2026-06-17 前端拆分第十一阶段:新增 `ImageCanvasFileModel` 和 `useImageCanvasUploadWorkflow`,把隐藏上传 input、上传目标分发、未登录续传、上传占位卡片、素材落库、拖到画布建层、生成参考图上传从主视图抽出;主视图保留画布 drop 外层判断和项目资源持久化注入。验证命令:`npm run test -- src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx 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` 未登录刷新弹出 `账号入口`,登录临时开发账号后 `画布背景设置` 面板点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,点击 `生成工具` 后显示 `Image Generator` 占位框和 `生成图片` 对话框且 `AI画布工具栏` 保持可见;上传图片后素材数增加,点击素材加入画布,切换 `图层` 面板可看到 2 个图层,登录后控制台无前端 error。
|
- 2026-06-17 前端拆分第十一阶段:新增 `ImageCanvasFileModel` 和 `useImageCanvasUploadWorkflow`,把隐藏上传 input、上传目标分发、未登录续传、上传占位卡片、素材落库、拖到画布建层、生成参考图上传从主视图抽出;主视图保留画布 drop 外层判断和项目资源持久化注入。验证命令:`npm run test -- src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx 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` 未登录刷新弹出 `账号入口`,登录临时开发账号后 `画布背景设置` 面板点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,点击 `生成工具` 后显示 `Image Generator` 占位框和 `生成图片` 对话框且 `AI画布工具栏` 保持可见;上传图片后素材数增加,点击素材加入画布,切换 `图层` 面板可看到 2 个图层,登录后控制台无前端 error。
|
||||||
- 2026-06-17 前端拆分第十二阶段:新增 `ImageCanvasGenerationLayerModel`,把普通生图、修改图片、快速编辑和图标素材批量生成结果落画布的图层 id、临时 resourceId、标题、位置、原始分辨率尺寸、zIndex、source metadata、源图关联和 `generationInputs` 纯规则从主视图抽出;主视图继续负责 API 提交、生成对象状态、资源持久化、选中态、侧栏和适合视图副作用。验证命令:`npm run test -- src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts 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` 清空会话后未登录刷新弹出 `账号入口`,登录临时开发账号后 `画布背景设置` 面板保留色相 / 自定义颜色 / 预设 / HEX / 恢复默认,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,点击 `生成工具` 后显示 `Image Generator` 占位框和 `生成图片` 对话框且 `AI画布工具栏` 保持可见;真实上传图片后素材数从 2 增至 3,登录后控制台无前端 error。
|
- 2026-06-17 前端拆分第十二阶段:新增 `ImageCanvasGenerationLayerModel`,把普通生图、修改图片、快速编辑和图标素材批量生成结果落画布的图层 id、临时 resourceId、标题、位置、原始分辨率尺寸、zIndex、source metadata、源图关联和 `generationInputs` 纯规则从主视图抽出;主视图继续负责 API 提交、生成对象状态、资源持久化、选中态、侧栏和适合视图副作用。验证命令:`npm run test -- src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts 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` 清空会话后未登录刷新弹出 `账号入口`,登录临时开发账号后 `画布背景设置` 面板保留色相 / 自定义颜色 / 预设 / HEX / 恢复默认,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,点击 `生成工具` 后显示 `Image Generator` 占位框和 `生成图片` 对话框且 `AI画布工具栏` 保持可见;真实上传图片后素材数从 2 增至 3,登录后控制台无前端 error。
|
||||||
- 2026-06-17 前端拆分第十三阶段:新增 `useImageCanvasAssetExportWorkflow`,把画布素材导出状态、单图右键导出、整包 ZIP 组包、图片去重、读取失败记录、metadata / manifest 和下载链接副作用从主视图抽出;主视图保留右键目标解析和状态提示渲染。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx 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` 清空会话后未登录刷新弹出 `账号入口`,登录临时开发账号后下载按钮启用,点击后触发真实下载 `未命名画布-画布素材-20260617.zip` 并显示导出状态;背景设置点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,点击 `生成工具` 后 `生成图片` 对话框出现且 `AI画布工具栏` 保持可见,登录后控制台无前端 error。
|
- 2026-06-17 前端拆分第十三阶段:新增 `useImageCanvasAssetExportWorkflow`,把画布素材导出状态、单图右键导出、整包 ZIP 组包、图片去重、读取失败记录、metadata / manifest 和下载链接副作用从主视图抽出;主视图保留右键目标解析和状态提示渲染。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx 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` 清空会话后未登录刷新弹出 `账号入口`,登录临时开发账号后下载按钮启用,点击后触发真实下载 `未命名画布-画布素材-20260617.zip` 并显示导出状态;背景设置点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,点击 `生成工具` 后 `生成图片` 对话框出现且 `AI画布工具栏` 保持可见,登录后控制台无前端 error。
|
||||||
|
- 2026-06-17 前端拆分第十四阶段:新增 `useImageCanvasLayerCommands`,把画布剪贴板、右键目标解析、复制 / 剪切 / 粘贴、创建副本、层级移动、分组 / 解组、显隐、锁定、翻转、删除选中图层、按 id 删除和单图导出委托从主视图抽出;主视图保留菜单定位、画布事件、生成、上传、项目持久化和实际导出下载。验证命令:`npm run test -- src/components/image-editor/useImageCanvasLayerCommands.test.tsx 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` 清空会话后未登录刷新弹出 `账号入口`,关闭后 `画布背景色` 打开完整 `画布背景设置`,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,点击 `生成工具` 后 `Image Generator` 占位框、`生成图片` 对话框和 `AI画布工具栏` 均可见;登录临时开发账号后新标签素材、画布图层、返回项目入口、小地图和底部工具栏可见,控制台无前端 error。
|
||||||
|
|||||||
@@ -124,6 +124,13 @@
|
|||||||
- 主视图继续负责右键菜单目标解析、下载按钮渲染和状态提示展示;导出 hook 不接管图层选择、右键菜单生命周期或画布状态。
|
- 主视图继续负责右键菜单目标解析、下载按钮渲染和状态提示展示;导出 hook 不接管图层选择、右键菜单生命周期或画布状态。
|
||||||
- 该 hook 用独立单测覆盖 ZIP 内容、重复图层复用同一图片文件、失败图片 metadata、manifest 失败数量、空画布提示和单图文件名清理。
|
- 该 hook 用独立单测覆盖 ZIP 内容、重复图层复用同一图片文件、失败图片 metadata、manifest 失败数量、空画布提示和单图文件名清理。
|
||||||
|
|
||||||
|
## 第十四阶段模块
|
||||||
|
|
||||||
|
- `useImageCanvasLayerCommands.ts`
|
||||||
|
- 承载图层命令工作流:画布剪贴板、右键目标解析、复制 / 剪切 / 粘贴、创建副本、层级移动、分组 / 解组、显示 / 隐藏、锁定 / 解锁、翻转、删除选中图层、按 id 删除图层和单图导出委托。
|
||||||
|
- 主视图继续负责右键菜单定位、画布事件、生成提交、素材上传、项目持久化和实际下载实现;hook 只接收必要 setter 与副作用回调,不反向读取 DOM 或路由。
|
||||||
|
- 该 hook 用独立单测覆盖菜单关闭、历史捕获、选中态更新、删除副作用、剪贴板和导出委托,避免主视图后续继续堆积右键菜单 orchestration。
|
||||||
|
|
||||||
## 后续阶段
|
## 后续阶段
|
||||||
|
|
||||||
- 生成工作流 hook:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再抽出 `useImageCanvasGenerationWorkflow` 这类深 hook;它应整体承接打开入口、提交状态、API 调用、错误映射和结果落图协调,而不是把主视图拆成大量 setter 透传。
|
- 生成工作流 hook:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再抽出 `useImageCanvasGenerationWorkflow` 这类深 hook;它应整体承接打开入口、提交状态、API 调用、错误映射和结果落图协调,而不是把主视图拆成大量 setter 透传。
|
||||||
@@ -131,7 +138,7 @@
|
|||||||
|
|
||||||
## 验证计划
|
## 验证计划
|
||||||
|
|
||||||
- `npm run test -- src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`
|
- `npm run test -- src/components/image-editor/useImageCanvasLayerCommands.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`
|
||||||
- `npm run typecheck`
|
- `npm run typecheck`
|
||||||
- `npm run check:encoding`
|
- `npm run check:encoding`
|
||||||
- `git diff --check`
|
- `git diff --check`
|
||||||
|
|||||||
@@ -46,20 +46,8 @@ import {
|
|||||||
zoomViewportFromWheel,
|
zoomViewportFromWheel,
|
||||||
} from './ImageCanvasInteractionModel';
|
} from './ImageCanvasInteractionModel';
|
||||||
import {
|
import {
|
||||||
createCanvasLayerClipboard,
|
|
||||||
duplicateCanvasLayers,
|
|
||||||
flipCanvasLayers,
|
|
||||||
getCanvasLayersByIds,
|
getCanvasLayersByIds,
|
||||||
groupCanvasLayers,
|
|
||||||
moveCanvasLayers,
|
|
||||||
removeCanvasLayers,
|
|
||||||
resolveContextTargetLayerIds,
|
resolveContextTargetLayerIds,
|
||||||
toggleCanvasLayersLock,
|
|
||||||
toggleCanvasLayersVisibility,
|
|
||||||
ungroupCanvasLayers,
|
|
||||||
updateCanvasLayersByIds,
|
|
||||||
type CanvasLayerFlipAxis,
|
|
||||||
type CanvasLayerMoveMode,
|
|
||||||
} from './ImageCanvasLayerCommandModel';
|
} from './ImageCanvasLayerCommandModel';
|
||||||
import { ImageCanvasSidebarView } from './ImageCanvasSidebarView';
|
import { ImageCanvasSidebarView } from './ImageCanvasSidebarView';
|
||||||
import { ImageCanvasStageView } from './ImageCanvasStageView';
|
import { ImageCanvasStageView } from './ImageCanvasStageView';
|
||||||
@@ -122,7 +110,6 @@ import {
|
|||||||
} from './ImageCanvasGenerationModel';
|
} from './ImageCanvasGenerationModel';
|
||||||
import type {
|
import type {
|
||||||
AssetPointerDragState,
|
AssetPointerDragState,
|
||||||
CanvasClipboard,
|
|
||||||
CanvasContextMenuState,
|
CanvasContextMenuState,
|
||||||
CanvasGenerationDialogState,
|
CanvasGenerationDialogState,
|
||||||
CanvasGenerationInputs,
|
CanvasGenerationInputs,
|
||||||
@@ -145,6 +132,7 @@ import { useCanvasHistory } from './useCanvasHistory';
|
|||||||
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
|
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
|
||||||
import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary';
|
import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary';
|
||||||
import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow';
|
import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow';
|
||||||
|
import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands';
|
||||||
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
||||||
import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow';
|
import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow';
|
||||||
|
|
||||||
@@ -288,8 +276,6 @@ export function ImageCanvasEditorView() {
|
|||||||
const [contextMenu, setContextMenu] = useState<CanvasContextMenuState | null>(
|
const [contextMenu, setContextMenu] = useState<CanvasContextMenuState | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [canvasClipboard, setCanvasClipboard] =
|
|
||||||
useState<CanvasClipboard | null>(null);
|
|
||||||
const [quickEditPanel, setQuickEditPanel] =
|
const [quickEditPanel, setQuickEditPanel] =
|
||||||
useState<QuickEditPanelState | null>(null);
|
useState<QuickEditPanelState | null>(null);
|
||||||
const [characterAnimationPanel, setCharacterAnimationPanel] =
|
const [characterAnimationPanel, setCharacterAnimationPanel] =
|
||||||
@@ -681,6 +667,58 @@ export function ImageCanvasEditorView() {
|
|||||||
projectId,
|
projectId,
|
||||||
projectTitle,
|
projectTitle,
|
||||||
});
|
});
|
||||||
|
const handleDeletedLayerSideEffects = useCallback(
|
||||||
|
(targetLayerId: string) => {
|
||||||
|
setQuickEditPanel((currentPanel) =>
|
||||||
|
currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel,
|
||||||
|
);
|
||||||
|
setCharacterAnimationPanel((currentPanel) =>
|
||||||
|
currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel,
|
||||||
|
);
|
||||||
|
setGenerateDialog((currentDialog) =>
|
||||||
|
currentDialog?.mode === 'edit' &&
|
||||||
|
currentDialog.sourceLayerId === targetLayerId
|
||||||
|
? null
|
||||||
|
: currentDialog,
|
||||||
|
);
|
||||||
|
removeCanvasGenerationDialogsByLayerId(targetLayerId);
|
||||||
|
},
|
||||||
|
[removeCanvasGenerationDialogsByLayerId, setGenerateDialog],
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
canvasClipboard,
|
||||||
|
pasteCanvasClipboard,
|
||||||
|
copyContextLayers,
|
||||||
|
duplicateContextLayers,
|
||||||
|
moveContextLayers,
|
||||||
|
groupContextLayers,
|
||||||
|
ungroupContextLayers,
|
||||||
|
toggleContextLayerVisibility,
|
||||||
|
toggleContextLayerLock,
|
||||||
|
flipContextLayers,
|
||||||
|
deleteContextLayers,
|
||||||
|
exportContextLayer,
|
||||||
|
deleteLayerById,
|
||||||
|
deleteSelectedLayer,
|
||||||
|
groupSelectedLayers,
|
||||||
|
} = useImageCanvasLayerCommands({
|
||||||
|
layers,
|
||||||
|
contextMenu,
|
||||||
|
selectedLayerId,
|
||||||
|
selectedLayerIds,
|
||||||
|
setLayers,
|
||||||
|
setSelectedLayerId,
|
||||||
|
setSelectedLayerIds,
|
||||||
|
setHoveredLayerId,
|
||||||
|
setMetadataLayer,
|
||||||
|
setContextMenu,
|
||||||
|
setImageContextMenu,
|
||||||
|
setActiveTool,
|
||||||
|
captureCanvasHistory,
|
||||||
|
selectSingleLayer,
|
||||||
|
onDeleteLayerSideEffects: handleDeletedLayerSideEffects,
|
||||||
|
exportLayerImage,
|
||||||
|
});
|
||||||
const {
|
const {
|
||||||
uploadInputRef,
|
uploadInputRef,
|
||||||
setUploadTarget,
|
setUploadTarget,
|
||||||
@@ -1024,187 +1062,6 @@ export function ImageCanvasEditorView() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const duplicateLayersToPoint = (
|
|
||||||
sourceLayers: CanvasLayer[],
|
|
||||||
canvasPoint?: { x: number; y: number },
|
|
||||||
options: { renameCopies?: boolean } = {},
|
|
||||||
) =>
|
|
||||||
duplicateCanvasLayers({
|
|
||||||
sourceLayers,
|
|
||||||
allLayers: layersRef.current,
|
|
||||||
canvasPoint,
|
|
||||||
renameCopies: options.renameCopies !== false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const pasteCanvasClipboard = (canvasPoint?: { x: number; y: number }) => {
|
|
||||||
if (!canvasClipboard?.layers.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nextLayers = duplicateLayersToPoint(
|
|
||||||
canvasClipboard.layers,
|
|
||||||
canvasPoint,
|
|
||||||
{
|
|
||||||
renameCopies: canvasClipboard.mode !== 'cut',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!nextLayers.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
captureCanvasHistory();
|
|
||||||
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
|
|
||||||
setSelectedLayerIds(nextLayers.map((layer) => layer.id));
|
|
||||||
setSelectedLayerId(nextLayers[0]?.id ?? null);
|
|
||||||
setActiveTool('select');
|
|
||||||
setContextMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyContextLayers = (options: { cut?: boolean } = {}) => {
|
|
||||||
const targetIds = getContextTargetLayerIds();
|
|
||||||
const clipboard = createCanvasLayerClipboard(
|
|
||||||
layers,
|
|
||||||
targetIds,
|
|
||||||
options.cut ? 'cut' : 'copy',
|
|
||||||
);
|
|
||||||
if (!clipboard) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCanvasClipboard(clipboard);
|
|
||||||
if (options.cut) {
|
|
||||||
captureCanvasHistory();
|
|
||||||
setLayers((currentLayers) =>
|
|
||||||
removeCanvasLayers(currentLayers, targetIds),
|
|
||||||
);
|
|
||||||
selectSingleLayer(null);
|
|
||||||
setMetadataLayer((currentLayer) =>
|
|
||||||
currentLayer && targetIds.includes(currentLayer.id)
|
|
||||||
? null
|
|
||||||
: currentLayer,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setContextMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const duplicateContextLayers = () => {
|
|
||||||
const targetIds = getContextTargetLayerIds();
|
|
||||||
const targetLayers = getCanvasLayersByIds(layers, targetIds);
|
|
||||||
const nextLayers = duplicateLayersToPoint(targetLayers);
|
|
||||||
if (!nextLayers.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
captureCanvasHistory();
|
|
||||||
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
|
|
||||||
setSelectedLayerIds(nextLayers.map((layer) => layer.id));
|
|
||||||
setSelectedLayerId(nextLayers[0]?.id ?? null);
|
|
||||||
setContextMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateContextLayers = (
|
|
||||||
updater: (layer: CanvasLayer, targetIds: string[]) => CanvasLayer,
|
|
||||||
) => {
|
|
||||||
const targetIds = getContextTargetLayerIds();
|
|
||||||
if (!targetIds.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
captureCanvasHistory();
|
|
||||||
setLayers((currentLayers) =>
|
|
||||||
updateCanvasLayersByIds(currentLayers, targetIds, updater),
|
|
||||||
);
|
|
||||||
setContextMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const moveContextLayers = (mode: CanvasLayerMoveMode) => {
|
|
||||||
const targetIds = getContextTargetLayerIds();
|
|
||||||
if (!targetIds.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
captureCanvasHistory();
|
|
||||||
setLayers((currentLayers) =>
|
|
||||||
moveCanvasLayers(currentLayers, targetIds, mode),
|
|
||||||
);
|
|
||||||
setContextMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const groupContextLayers = () => {
|
|
||||||
const targetIds = getContextTargetLayerIds();
|
|
||||||
if (!targetIds.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const groupId = `layer-group-${Date.now()}`;
|
|
||||||
captureCanvasHistory();
|
|
||||||
setLayers((currentLayers) =>
|
|
||||||
groupCanvasLayers(currentLayers, targetIds, groupId),
|
|
||||||
);
|
|
||||||
setContextMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ungroupContextLayers = () => {
|
|
||||||
const targetIds = getContextTargetLayerIds();
|
|
||||||
if (!targetIds.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
captureCanvasHistory();
|
|
||||||
setLayers((currentLayers) => ungroupCanvasLayers(currentLayers, targetIds));
|
|
||||||
setContextMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleContextLayerVisibility = () => {
|
|
||||||
const targetIds = getContextTargetLayerIds();
|
|
||||||
if (!targetIds.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
captureCanvasHistory();
|
|
||||||
setLayers((currentLayers) =>
|
|
||||||
toggleCanvasLayersVisibility(currentLayers, targetIds),
|
|
||||||
);
|
|
||||||
setContextMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleContextLayerLock = () => {
|
|
||||||
const targetIds = getContextTargetLayerIds();
|
|
||||||
if (!targetIds.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
captureCanvasHistory();
|
|
||||||
setLayers((currentLayers) =>
|
|
||||||
toggleCanvasLayersLock(currentLayers, targetIds),
|
|
||||||
);
|
|
||||||
setContextMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const flipContextLayers = (axis: CanvasLayerFlipAxis) => {
|
|
||||||
const targetIds = getContextTargetLayerIds();
|
|
||||||
if (!targetIds.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
captureCanvasHistory();
|
|
||||||
setLayers((currentLayers) =>
|
|
||||||
flipCanvasLayers(currentLayers, targetIds, axis),
|
|
||||||
);
|
|
||||||
setContextMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteContextLayers = () => {
|
|
||||||
const targetIds = getContextTargetLayerIds();
|
|
||||||
if (!targetIds.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
captureCanvasHistory();
|
|
||||||
setLayers((currentLayers) => removeCanvasLayers(currentLayers, targetIds));
|
|
||||||
selectSingleLayer(null);
|
|
||||||
setHoveredLayerId(null);
|
|
||||||
setMetadataLayer((currentLayer) =>
|
|
||||||
currentLayer && targetIds.includes(currentLayer.id) ? null : currentLayer,
|
|
||||||
);
|
|
||||||
setContextMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportContextLayer = () => {
|
|
||||||
const targetIds = getContextTargetLayerIds();
|
|
||||||
const targetLayer = layers.find((layer) => targetIds.includes(layer.id));
|
|
||||||
exportLayerImage(targetLayer ?? null);
|
|
||||||
setContextMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addAssetLayer = (
|
const addAssetLayer = (
|
||||||
asset: EditorAsset,
|
asset: EditorAsset,
|
||||||
position?: { x: number; y: number },
|
position?: { x: number; y: number },
|
||||||
@@ -1318,84 +1175,8 @@ export function ImageCanvasEditorView() {
|
|||||||
setImageContextMenu(null);
|
setImageContextMenu(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteLayerById = (targetLayerId: string | null) => {
|
|
||||||
if (!targetLayerId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setImageContextMenu(null);
|
|
||||||
setContextMenu(null);
|
|
||||||
captureCanvasHistory();
|
|
||||||
setLayers((currentLayers) => {
|
|
||||||
const nextLayers = currentLayers.filter(
|
|
||||||
(layer) => layer.id !== targetLayerId,
|
|
||||||
);
|
|
||||||
const nextSelectedLayer = nextLayers
|
|
||||||
.slice()
|
|
||||||
.sort((left, right) => right.zIndex - left.zIndex)[0];
|
|
||||||
selectSingleLayer(nextSelectedLayer?.id ?? null);
|
|
||||||
return nextLayers;
|
|
||||||
});
|
|
||||||
setHoveredLayerId(null);
|
|
||||||
setMetadataLayer((currentLayer) =>
|
|
||||||
currentLayer?.id === targetLayerId ? null : currentLayer,
|
|
||||||
);
|
|
||||||
setQuickEditPanel((currentPanel) =>
|
|
||||||
currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel,
|
|
||||||
);
|
|
||||||
setCharacterAnimationPanel((currentPanel) =>
|
|
||||||
currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel,
|
|
||||||
);
|
|
||||||
setGenerateDialog((currentDialog) =>
|
|
||||||
currentDialog?.mode === 'edit' &&
|
|
||||||
currentDialog.sourceLayerId === targetLayerId
|
|
||||||
? null
|
|
||||||
: currentDialog,
|
|
||||||
);
|
|
||||||
removeCanvasGenerationDialogsByLayerId(targetLayerId);
|
|
||||||
};
|
|
||||||
|
|
||||||
deleteLayerByIdRef.current = deleteLayerById;
|
deleteLayerByIdRef.current = deleteLayerById;
|
||||||
|
|
||||||
const deleteSelectedLayer = () => {
|
|
||||||
const targetIds = selectedLayerIds.length
|
|
||||||
? selectedLayerIds
|
|
||||||
: selectedLayerId
|
|
||||||
? [selectedLayerId]
|
|
||||||
: [];
|
|
||||||
if (targetIds.length <= 1) {
|
|
||||||
deleteLayerById(targetIds[0] ?? null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
captureCanvasHistory();
|
|
||||||
setImageContextMenu(null);
|
|
||||||
setContextMenu(null);
|
|
||||||
setLayers((currentLayers) => {
|
|
||||||
const nextLayers = currentLayers.filter(
|
|
||||||
(layer) => !targetIds.includes(layer.id),
|
|
||||||
);
|
|
||||||
const nextSelectedLayer = nextLayers
|
|
||||||
.slice()
|
|
||||||
.sort((left, right) => right.zIndex - left.zIndex)[0];
|
|
||||||
selectSingleLayer(nextSelectedLayer?.id ?? null);
|
|
||||||
return nextLayers;
|
|
||||||
});
|
|
||||||
setHoveredLayerId(null);
|
|
||||||
setMetadataLayer((currentLayer) =>
|
|
||||||
currentLayer && targetIds.includes(currentLayer.id) ? null : currentLayer,
|
|
||||||
);
|
|
||||||
setQuickEditPanel((currentPanel) =>
|
|
||||||
currentPanel && targetIds.includes(currentPanel.sourceLayerId)
|
|
||||||
? null
|
|
||||||
: currentPanel,
|
|
||||||
);
|
|
||||||
setCharacterAnimationPanel((currentPanel) =>
|
|
||||||
currentPanel && targetIds.includes(currentPanel.sourceLayerId)
|
|
||||||
? null
|
|
||||||
: currentPanel,
|
|
||||||
);
|
|
||||||
targetIds.forEach(removeCanvasGenerationDialogsByLayerId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openGenerateDialog = () => {
|
const openGenerateDialog = () => {
|
||||||
const placeholderWidth = 420;
|
const placeholderWidth = 420;
|
||||||
const placeholderHeight = 420;
|
const placeholderHeight = 420;
|
||||||
@@ -2460,29 +2241,6 @@ export function ImageCanvasEditorView() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const groupSelectedLayers = () => {
|
|
||||||
const targetIds = selectedLayerIds.length
|
|
||||||
? selectedLayerIds
|
|
||||||
: selectedLayerId
|
|
||||||
? [selectedLayerId]
|
|
||||||
: [];
|
|
||||||
if (!targetIds.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const groupId = `layer-group-${Date.now()}`;
|
|
||||||
captureCanvasHistory();
|
|
||||||
setLayers((currentLayers) =>
|
|
||||||
currentLayers.map((layer) =>
|
|
||||||
targetIds.includes(layer.id)
|
|
||||||
? {
|
|
||||||
...layer,
|
|
||||||
groupId,
|
|
||||||
}
|
|
||||||
: layer,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateSpecFormValue = (key: keyof SpecFormValues, value: string) => {
|
const updateSpecFormValue = (key: keyof SpecFormValues, value: string) => {
|
||||||
setGenerateDialog((currentDialog) => {
|
setGenerateDialog((currentDialog) => {
|
||||||
if (currentDialog?.mode !== 'spec') {
|
if (currentDialog?.mode !== 'spec') {
|
||||||
|
|||||||
279
src/components/image-editor/useImageCanvasLayerCommands.test.tsx
Normal file
279
src/components/image-editor/useImageCanvasLayerCommands.test.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CanvasContextMenuState,
|
||||||
|
CanvasLayer,
|
||||||
|
} from './ImageCanvasEditorTypes';
|
||||||
|
import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands';
|
||||||
|
|
||||||
|
function createLayer(id: string, x: number, zIndex: 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,
|
||||||
|
sourceType: 'uploaded',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function LayerCommandsHarness({
|
||||||
|
exportLayerImage = vi.fn(),
|
||||||
|
onDeleteLayerSideEffects = vi.fn(),
|
||||||
|
}: {
|
||||||
|
exportLayerImage?: (layer: CanvasLayer | null) => void;
|
||||||
|
onDeleteLayerSideEffects?: (layerId: string) => void;
|
||||||
|
}) {
|
||||||
|
const [layers, setLayers] = useState<CanvasLayer[]>([
|
||||||
|
createLayer('first', 10, 1),
|
||||||
|
createLayer('second', 160, 2),
|
||||||
|
createLayer('third', 310, 3),
|
||||||
|
]);
|
||||||
|
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(
|
||||||
|
'first',
|
||||||
|
);
|
||||||
|
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([
|
||||||
|
'first',
|
||||||
|
'second',
|
||||||
|
]);
|
||||||
|
const [metadataLayer, setMetadataLayer] = useState<CanvasLayer | null>(
|
||||||
|
layers[0] ?? null,
|
||||||
|
);
|
||||||
|
const [hoveredLayerId, setHoveredLayerId] = useState<string | null>('first');
|
||||||
|
const [contextMenu, setContextMenu] = useState<CanvasContextMenuState | null>(
|
||||||
|
{
|
||||||
|
kind: 'layer',
|
||||||
|
layerId: 'first',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
canvasPoint: { x: 0, y: 0 },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const [historyCount, setHistoryCount] = useState(0);
|
||||||
|
const [imageContextClosedCount, setImageContextClosedCount] = useState(0);
|
||||||
|
const [activeTool, setActiveTool] = useState('shape');
|
||||||
|
|
||||||
|
const selectSingleLayer = (layerId: string | null) => {
|
||||||
|
setSelectedLayerId(layerId);
|
||||||
|
setSelectedLayerIds(layerId ? [layerId] : []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commands = useImageCanvasLayerCommands({
|
||||||
|
layers,
|
||||||
|
contextMenu,
|
||||||
|
selectedLayerId,
|
||||||
|
selectedLayerIds,
|
||||||
|
setLayers,
|
||||||
|
setSelectedLayerId,
|
||||||
|
setSelectedLayerIds,
|
||||||
|
setHoveredLayerId,
|
||||||
|
setMetadataLayer,
|
||||||
|
setContextMenu,
|
||||||
|
setImageContextMenu: () =>
|
||||||
|
setImageContextClosedCount((currentCount) => currentCount + 1),
|
||||||
|
setActiveTool,
|
||||||
|
captureCanvasHistory: () =>
|
||||||
|
setHistoryCount((currentCount) => currentCount + 1),
|
||||||
|
selectSingleLayer,
|
||||||
|
onDeleteLayerSideEffects,
|
||||||
|
exportLayerImage,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span data-testid="layers">
|
||||||
|
{layers
|
||||||
|
.map(
|
||||||
|
(layer) =>
|
||||||
|
`${layer.id}:${layer.title}:${layer.x}:${layer.zIndex}:${layer.groupId ?? '-'}:${layer.hidden ? 'hidden' : 'shown'}:${layer.locked ? 'locked' : 'open'}:${layer.flipX ? 'flipX' : '-'}:${layer.flipY ? 'flipY' : '-'}`,
|
||||||
|
)
|
||||||
|
.join('|')}
|
||||||
|
</span>
|
||||||
|
<span data-testid="selection">
|
||||||
|
{selectedLayerId ?? '-'}:{selectedLayerIds.join(',')}
|
||||||
|
</span>
|
||||||
|
<span data-testid="metadata">{metadataLayer?.id ?? '-'}</span>
|
||||||
|
<span data-testid="hovered">{hoveredLayerId ?? '-'}</span>
|
||||||
|
<span data-testid="context">{contextMenu ? 'open' : 'closed'}</span>
|
||||||
|
<span data-testid="history">{historyCount}</span>
|
||||||
|
<span data-testid="image-context-closed">
|
||||||
|
{imageContextClosedCount}
|
||||||
|
</span>
|
||||||
|
<span data-testid="tool">{activeTool}</span>
|
||||||
|
<span data-testid="clipboard">
|
||||||
|
{commands.canvasClipboard
|
||||||
|
? `${commands.canvasClipboard.mode}:${commands.canvasClipboard.layers.length}`
|
||||||
|
: '-'}
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={() => commands.copyContextLayers()}>
|
||||||
|
复制
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => commands.copyContextLayers({ cut: true })}
|
||||||
|
>
|
||||||
|
剪切
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => commands.pasteCanvasClipboard({ x: 500, y: 600 })}
|
||||||
|
>
|
||||||
|
粘贴
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => commands.duplicateContextLayers()}>
|
||||||
|
创建副本
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => commands.moveContextLayers('top')}>
|
||||||
|
置顶
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => commands.groupContextLayers()}>
|
||||||
|
创建组
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => commands.ungroupContextLayers()}>
|
||||||
|
解除组
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => commands.toggleContextLayerVisibility()}
|
||||||
|
>
|
||||||
|
显隐
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => commands.toggleContextLayerLock()}>
|
||||||
|
锁定
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => commands.flipContextLayers('x')}>
|
||||||
|
翻转
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => commands.deleteContextLayers()}>
|
||||||
|
删除右键目标
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => commands.deleteSelectedLayer()}>
|
||||||
|
删除选中
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => commands.deleteLayerById('first')}>
|
||||||
|
删除单图
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => commands.groupSelectedLayers()}>
|
||||||
|
选中打组
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => commands.exportContextLayer()}>
|
||||||
|
导出
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setContextMenu({
|
||||||
|
kind: 'layer',
|
||||||
|
layerId: 'third',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
canvasPoint: { x: 0, y: 0 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
右键第三层
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedLayerId('third');
|
||||||
|
setSelectedLayerIds(['third']);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
只选第三层
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useImageCanvasLayerCommands', () => {
|
||||||
|
it('copies, cuts, and pastes context layers with history and selection updates', () => {
|
||||||
|
render(<LayerCommandsHarness />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '复制' }));
|
||||||
|
expect(screen.getByTestId('clipboard').textContent).toBe('copy:2');
|
||||||
|
expect(screen.getByTestId('context').textContent).toBe('closed');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '粘贴' }));
|
||||||
|
expect(screen.getByTestId('layers').textContent).toContain(
|
||||||
|
'first 副本:500:4',
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('layers').textContent).toContain(
|
||||||
|
'second 副本:650:5',
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('selection').textContent).toContain(
|
||||||
|
'layer-copy-',
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('tool').textContent).toBe('select');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '右键第三层' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '只选第三层' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '剪切' }));
|
||||||
|
|
||||||
|
expect(screen.getByTestId('clipboard').textContent).toBe('cut:1');
|
||||||
|
expect(screen.getByTestId('layers').textContent).not.toContain('third');
|
||||||
|
expect(screen.getByTestId('selection').textContent).toBe('-:');
|
||||||
|
expect(Number(screen.getByTestId('history').textContent)).toBeGreaterThan(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies layer commands and clears menus without owning menu positioning', () => {
|
||||||
|
const exportLayerImage = vi.fn();
|
||||||
|
render(<LayerCommandsHarness exportLayerImage={exportLayerImage} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '创建组' }));
|
||||||
|
expect(screen.getByTestId('layers').textContent).toContain('layer-group-');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '右键第三层' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '显隐' }));
|
||||||
|
expect(screen.getByTestId('layers').textContent).toContain(':hidden:');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '右键第三层' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '锁定' }));
|
||||||
|
expect(screen.getByTestId('layers').textContent).toContain(':locked:');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '右键第三层' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '翻转' }));
|
||||||
|
expect(screen.getByTestId('layers').textContent).toContain(':flipX:');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '右键第三层' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '导出' }));
|
||||||
|
expect(exportLayerImage).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: 'third' }),
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('context').textContent).toBe('closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes selected and direct layers while running delete side effects', () => {
|
||||||
|
const onDeleteLayerSideEffects = vi.fn();
|
||||||
|
render(
|
||||||
|
<LayerCommandsHarness
|
||||||
|
onDeleteLayerSideEffects={onDeleteLayerSideEffects}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '删除选中' }));
|
||||||
|
|
||||||
|
expect(screen.getByTestId('layers').textContent).not.toContain('first');
|
||||||
|
expect(screen.getByTestId('layers').textContent).not.toContain('second');
|
||||||
|
expect(screen.getByTestId('selection').textContent).toBe('third:third');
|
||||||
|
expect(screen.getByTestId('metadata').textContent).toBe('-');
|
||||||
|
expect(screen.getByTestId('image-context-closed').textContent).toBe('1');
|
||||||
|
expect(onDeleteLayerSideEffects).toHaveBeenCalledWith('first');
|
||||||
|
expect(onDeleteLayerSideEffects).toHaveBeenCalledWith('second');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '删除单图' }));
|
||||||
|
expect(screen.getByTestId('image-context-closed').textContent).toBe('2');
|
||||||
|
});
|
||||||
|
});
|
||||||
422
src/components/image-editor/useImageCanvasLayerCommands.ts
Normal file
422
src/components/image-editor/useImageCanvasLayerCommands.ts
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useState,
|
||||||
|
type Dispatch,
|
||||||
|
type SetStateAction,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CanvasClipboard,
|
||||||
|
CanvasContextMenuState,
|
||||||
|
CanvasLayer,
|
||||||
|
} from './ImageCanvasEditorTypes';
|
||||||
|
import {
|
||||||
|
createCanvasLayerClipboard,
|
||||||
|
duplicateCanvasLayers,
|
||||||
|
flipCanvasLayers,
|
||||||
|
getCanvasLayersByIds,
|
||||||
|
groupCanvasLayers,
|
||||||
|
moveCanvasLayers,
|
||||||
|
removeCanvasLayers,
|
||||||
|
resolveContextTargetLayerIds,
|
||||||
|
toggleCanvasLayersLock,
|
||||||
|
toggleCanvasLayersVisibility,
|
||||||
|
ungroupCanvasLayers,
|
||||||
|
updateCanvasLayersByIds,
|
||||||
|
type CanvasLayerFlipAxis,
|
||||||
|
type CanvasLayerMoveMode,
|
||||||
|
} from './ImageCanvasLayerCommandModel';
|
||||||
|
|
||||||
|
type LayerCommandsOptions = {
|
||||||
|
layers: CanvasLayer[];
|
||||||
|
contextMenu: CanvasContextMenuState | null;
|
||||||
|
selectedLayerId: string | null;
|
||||||
|
selectedLayerIds: string[];
|
||||||
|
setLayers: Dispatch<SetStateAction<CanvasLayer[]>>;
|
||||||
|
setSelectedLayerId: Dispatch<SetStateAction<string | null>>;
|
||||||
|
setSelectedLayerIds: Dispatch<SetStateAction<string[]>>;
|
||||||
|
setHoveredLayerId: Dispatch<SetStateAction<string | null>>;
|
||||||
|
setMetadataLayer: Dispatch<SetStateAction<CanvasLayer | null>>;
|
||||||
|
setContextMenu: Dispatch<SetStateAction<CanvasContextMenuState | null>>;
|
||||||
|
setImageContextMenu: (menu: null) => void;
|
||||||
|
setActiveTool: (tool: 'select') => void;
|
||||||
|
captureCanvasHistory: () => void;
|
||||||
|
selectSingleLayer: (layerId: string | null) => void;
|
||||||
|
onDeleteLayerSideEffects: (targetLayerId: string) => void;
|
||||||
|
exportLayerImage: (layer: CanvasLayer | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createGroupId() {
|
||||||
|
return `layer-group-${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useImageCanvasLayerCommands({
|
||||||
|
layers,
|
||||||
|
contextMenu,
|
||||||
|
selectedLayerId,
|
||||||
|
selectedLayerIds,
|
||||||
|
setLayers,
|
||||||
|
setSelectedLayerId,
|
||||||
|
setSelectedLayerIds,
|
||||||
|
setHoveredLayerId,
|
||||||
|
setMetadataLayer,
|
||||||
|
setContextMenu,
|
||||||
|
setImageContextMenu,
|
||||||
|
setActiveTool,
|
||||||
|
captureCanvasHistory,
|
||||||
|
selectSingleLayer,
|
||||||
|
onDeleteLayerSideEffects,
|
||||||
|
exportLayerImage,
|
||||||
|
}: LayerCommandsOptions) {
|
||||||
|
const [canvasClipboard, setCanvasClipboard] =
|
||||||
|
useState<CanvasClipboard | null>(null);
|
||||||
|
|
||||||
|
const getContextTargetLayerIds = useCallback(
|
||||||
|
(menu: CanvasContextMenuState | null = contextMenu) =>
|
||||||
|
resolveContextTargetLayerIds(menu, selectedLayerIds),
|
||||||
|
[contextMenu, selectedLayerIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const duplicateLayersToPoint = useCallback(
|
||||||
|
(
|
||||||
|
sourceLayers: CanvasLayer[],
|
||||||
|
canvasPoint?: { x: number; y: number },
|
||||||
|
options: { renameCopies?: boolean } = {},
|
||||||
|
) =>
|
||||||
|
duplicateCanvasLayers({
|
||||||
|
sourceLayers,
|
||||||
|
allLayers: layers,
|
||||||
|
canvasPoint,
|
||||||
|
renameCopies: options.renameCopies !== false,
|
||||||
|
}),
|
||||||
|
[layers],
|
||||||
|
);
|
||||||
|
|
||||||
|
const pasteCanvasClipboard = useCallback(
|
||||||
|
(canvasPoint?: { x: number; y: number }) => {
|
||||||
|
if (!canvasClipboard?.layers.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextLayers = duplicateLayersToPoint(
|
||||||
|
canvasClipboard.layers,
|
||||||
|
canvasPoint,
|
||||||
|
{
|
||||||
|
renameCopies: canvasClipboard.mode !== 'cut',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!nextLayers.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
captureCanvasHistory();
|
||||||
|
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
|
||||||
|
setSelectedLayerIds(nextLayers.map((layer) => layer.id));
|
||||||
|
setSelectedLayerId(nextLayers[0]?.id ?? null);
|
||||||
|
setActiveTool('select');
|
||||||
|
setContextMenu(null);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
canvasClipboard,
|
||||||
|
captureCanvasHistory,
|
||||||
|
duplicateLayersToPoint,
|
||||||
|
setActiveTool,
|
||||||
|
setContextMenu,
|
||||||
|
setLayers,
|
||||||
|
setSelectedLayerId,
|
||||||
|
setSelectedLayerIds,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const copyContextLayers = useCallback(
|
||||||
|
(options: { cut?: boolean } = {}) => {
|
||||||
|
const targetIds = getContextTargetLayerIds();
|
||||||
|
const clipboard = createCanvasLayerClipboard(
|
||||||
|
layers,
|
||||||
|
targetIds,
|
||||||
|
options.cut ? 'cut' : 'copy',
|
||||||
|
);
|
||||||
|
if (!clipboard) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCanvasClipboard(clipboard);
|
||||||
|
if (options.cut) {
|
||||||
|
captureCanvasHistory();
|
||||||
|
setLayers((currentLayers) =>
|
||||||
|
removeCanvasLayers(currentLayers, targetIds),
|
||||||
|
);
|
||||||
|
selectSingleLayer(null);
|
||||||
|
setMetadataLayer((currentLayer) =>
|
||||||
|
currentLayer && targetIds.includes(currentLayer.id)
|
||||||
|
? null
|
||||||
|
: currentLayer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setContextMenu(null);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
captureCanvasHistory,
|
||||||
|
getContextTargetLayerIds,
|
||||||
|
layers,
|
||||||
|
selectSingleLayer,
|
||||||
|
setContextMenu,
|
||||||
|
setLayers,
|
||||||
|
setMetadataLayer,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const duplicateContextLayers = useCallback(() => {
|
||||||
|
const targetIds = getContextTargetLayerIds();
|
||||||
|
const targetLayers = getCanvasLayersByIds(layers, targetIds);
|
||||||
|
const nextLayers = duplicateLayersToPoint(targetLayers);
|
||||||
|
if (!nextLayers.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
captureCanvasHistory();
|
||||||
|
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
|
||||||
|
setSelectedLayerIds(nextLayers.map((layer) => layer.id));
|
||||||
|
setSelectedLayerId(nextLayers[0]?.id ?? null);
|
||||||
|
setContextMenu(null);
|
||||||
|
}, [
|
||||||
|
captureCanvasHistory,
|
||||||
|
duplicateLayersToPoint,
|
||||||
|
getContextTargetLayerIds,
|
||||||
|
layers,
|
||||||
|
setContextMenu,
|
||||||
|
setLayers,
|
||||||
|
setSelectedLayerId,
|
||||||
|
setSelectedLayerIds,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const updateContextLayers = useCallback(
|
||||||
|
(updater: (layer: CanvasLayer, targetIds: string[]) => CanvasLayer) => {
|
||||||
|
const targetIds = getContextTargetLayerIds();
|
||||||
|
if (!targetIds.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
captureCanvasHistory();
|
||||||
|
setLayers((currentLayers) =>
|
||||||
|
updateCanvasLayersByIds(currentLayers, targetIds, updater),
|
||||||
|
);
|
||||||
|
setContextMenu(null);
|
||||||
|
},
|
||||||
|
[captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers],
|
||||||
|
);
|
||||||
|
|
||||||
|
const moveContextLayers = useCallback(
|
||||||
|
(mode: CanvasLayerMoveMode) => {
|
||||||
|
const targetIds = getContextTargetLayerIds();
|
||||||
|
if (!targetIds.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
captureCanvasHistory();
|
||||||
|
setLayers((currentLayers) =>
|
||||||
|
moveCanvasLayers(currentLayers, targetIds, mode),
|
||||||
|
);
|
||||||
|
setContextMenu(null);
|
||||||
|
},
|
||||||
|
[captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers],
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupContextLayers = useCallback(() => {
|
||||||
|
const targetIds = getContextTargetLayerIds();
|
||||||
|
if (!targetIds.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
captureCanvasHistory();
|
||||||
|
setLayers((currentLayers) =>
|
||||||
|
groupCanvasLayers(currentLayers, targetIds, createGroupId()),
|
||||||
|
);
|
||||||
|
setContextMenu(null);
|
||||||
|
}, [captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers]);
|
||||||
|
|
||||||
|
const ungroupContextLayers = useCallback(() => {
|
||||||
|
const targetIds = getContextTargetLayerIds();
|
||||||
|
if (!targetIds.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
captureCanvasHistory();
|
||||||
|
setLayers((currentLayers) => ungroupCanvasLayers(currentLayers, targetIds));
|
||||||
|
setContextMenu(null);
|
||||||
|
}, [captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers]);
|
||||||
|
|
||||||
|
const toggleContextLayerVisibility = useCallback(() => {
|
||||||
|
const targetIds = getContextTargetLayerIds();
|
||||||
|
if (!targetIds.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
captureCanvasHistory();
|
||||||
|
setLayers((currentLayers) =>
|
||||||
|
toggleCanvasLayersVisibility(currentLayers, targetIds),
|
||||||
|
);
|
||||||
|
setContextMenu(null);
|
||||||
|
}, [captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers]);
|
||||||
|
|
||||||
|
const toggleContextLayerLock = useCallback(() => {
|
||||||
|
const targetIds = getContextTargetLayerIds();
|
||||||
|
if (!targetIds.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
captureCanvasHistory();
|
||||||
|
setLayers((currentLayers) =>
|
||||||
|
toggleCanvasLayersLock(currentLayers, targetIds),
|
||||||
|
);
|
||||||
|
setContextMenu(null);
|
||||||
|
}, [captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers]);
|
||||||
|
|
||||||
|
const flipContextLayers = useCallback(
|
||||||
|
(axis: CanvasLayerFlipAxis) => {
|
||||||
|
const targetIds = getContextTargetLayerIds();
|
||||||
|
if (!targetIds.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
captureCanvasHistory();
|
||||||
|
setLayers((currentLayers) =>
|
||||||
|
flipCanvasLayers(currentLayers, targetIds, axis),
|
||||||
|
);
|
||||||
|
setContextMenu(null);
|
||||||
|
},
|
||||||
|
[captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteContextLayers = useCallback(() => {
|
||||||
|
const targetIds = getContextTargetLayerIds();
|
||||||
|
if (!targetIds.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
captureCanvasHistory();
|
||||||
|
setLayers((currentLayers) => removeCanvasLayers(currentLayers, targetIds));
|
||||||
|
selectSingleLayer(null);
|
||||||
|
setHoveredLayerId(null);
|
||||||
|
setMetadataLayer((currentLayer) =>
|
||||||
|
currentLayer && targetIds.includes(currentLayer.id) ? null : currentLayer,
|
||||||
|
);
|
||||||
|
setContextMenu(null);
|
||||||
|
}, [
|
||||||
|
captureCanvasHistory,
|
||||||
|
getContextTargetLayerIds,
|
||||||
|
selectSingleLayer,
|
||||||
|
setContextMenu,
|
||||||
|
setHoveredLayerId,
|
||||||
|
setLayers,
|
||||||
|
setMetadataLayer,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const exportContextLayer = useCallback(() => {
|
||||||
|
const targetIds = getContextTargetLayerIds();
|
||||||
|
const targetLayer = layers.find((layer) => targetIds.includes(layer.id));
|
||||||
|
exportLayerImage(targetLayer ?? null);
|
||||||
|
setContextMenu(null);
|
||||||
|
}, [exportLayerImage, getContextTargetLayerIds, layers, setContextMenu]);
|
||||||
|
|
||||||
|
const deleteLayerById = useCallback(
|
||||||
|
(targetLayerId: string | null) => {
|
||||||
|
if (!targetLayerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setImageContextMenu(null);
|
||||||
|
setContextMenu(null);
|
||||||
|
captureCanvasHistory();
|
||||||
|
setLayers((currentLayers) => {
|
||||||
|
const nextLayers = currentLayers.filter(
|
||||||
|
(layer) => layer.id !== targetLayerId,
|
||||||
|
);
|
||||||
|
const nextSelectedLayer = nextLayers
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => right.zIndex - left.zIndex)[0];
|
||||||
|
selectSingleLayer(nextSelectedLayer?.id ?? null);
|
||||||
|
return nextLayers;
|
||||||
|
});
|
||||||
|
setHoveredLayerId(null);
|
||||||
|
setMetadataLayer((currentLayer) =>
|
||||||
|
currentLayer?.id === targetLayerId ? null : currentLayer,
|
||||||
|
);
|
||||||
|
onDeleteLayerSideEffects(targetLayerId);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
captureCanvasHistory,
|
||||||
|
onDeleteLayerSideEffects,
|
||||||
|
selectSingleLayer,
|
||||||
|
setContextMenu,
|
||||||
|
setHoveredLayerId,
|
||||||
|
setImageContextMenu,
|
||||||
|
setLayers,
|
||||||
|
setMetadataLayer,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteSelectedLayer = useCallback(() => {
|
||||||
|
const targetIds = selectedLayerIds.length
|
||||||
|
? selectedLayerIds
|
||||||
|
: selectedLayerId
|
||||||
|
? [selectedLayerId]
|
||||||
|
: [];
|
||||||
|
if (targetIds.length <= 1) {
|
||||||
|
deleteLayerById(targetIds[0] ?? null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
captureCanvasHistory();
|
||||||
|
setImageContextMenu(null);
|
||||||
|
setContextMenu(null);
|
||||||
|
setLayers((currentLayers) => {
|
||||||
|
const nextLayers = currentLayers.filter(
|
||||||
|
(layer) => !targetIds.includes(layer.id),
|
||||||
|
);
|
||||||
|
const nextSelectedLayer = nextLayers
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => right.zIndex - left.zIndex)[0];
|
||||||
|
selectSingleLayer(nextSelectedLayer?.id ?? null);
|
||||||
|
return nextLayers;
|
||||||
|
});
|
||||||
|
setHoveredLayerId(null);
|
||||||
|
setMetadataLayer((currentLayer) =>
|
||||||
|
currentLayer && targetIds.includes(currentLayer.id) ? null : currentLayer,
|
||||||
|
);
|
||||||
|
targetIds.forEach((targetId) => onDeleteLayerSideEffects(targetId));
|
||||||
|
}, [
|
||||||
|
captureCanvasHistory,
|
||||||
|
deleteLayerById,
|
||||||
|
onDeleteLayerSideEffects,
|
||||||
|
selectSingleLayer,
|
||||||
|
selectedLayerId,
|
||||||
|
selectedLayerIds,
|
||||||
|
setContextMenu,
|
||||||
|
setHoveredLayerId,
|
||||||
|
setImageContextMenu,
|
||||||
|
setLayers,
|
||||||
|
setMetadataLayer,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const groupSelectedLayers = useCallback(() => {
|
||||||
|
const targetIds = selectedLayerIds.length
|
||||||
|
? selectedLayerIds
|
||||||
|
: selectedLayerId
|
||||||
|
? [selectedLayerId]
|
||||||
|
: [];
|
||||||
|
if (!targetIds.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
captureCanvasHistory();
|
||||||
|
setLayers((currentLayers) =>
|
||||||
|
groupCanvasLayers(currentLayers, targetIds, createGroupId()),
|
||||||
|
);
|
||||||
|
}, [captureCanvasHistory, selectedLayerId, selectedLayerIds, setLayers]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
canvasClipboard,
|
||||||
|
getContextTargetLayerIds,
|
||||||
|
pasteCanvasClipboard,
|
||||||
|
copyContextLayers,
|
||||||
|
duplicateContextLayers,
|
||||||
|
updateContextLayers,
|
||||||
|
moveContextLayers,
|
||||||
|
groupContextLayers,
|
||||||
|
ungroupContextLayers,
|
||||||
|
toggleContextLayerVisibility,
|
||||||
|
toggleContextLayerLock,
|
||||||
|
flipContextLayers,
|
||||||
|
deleteContextLayers,
|
||||||
|
exportContextLayer,
|
||||||
|
deleteLayerById,
|
||||||
|
deleteSelectedLayer,
|
||||||
|
groupSelectedLayers,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user