拆分图片画布图层命令工作流
新增图层命令 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 前端拆分第十二阶段:新增 `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 前端拆分第十四阶段:新增 `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 用独立单测覆盖 ZIP 内容、重复图层复用同一图片文件、失败图片 metadata、manifest 失败数量、空画布提示和单图文件名清理。
|
||||
|
||||
## 第十四阶段模块
|
||||
|
||||
- `useImageCanvasLayerCommands.ts`
|
||||
- 承载图层命令工作流:画布剪贴板、右键目标解析、复制 / 剪切 / 粘贴、创建副本、层级移动、分组 / 解组、显示 / 隐藏、锁定 / 解锁、翻转、删除选中图层、按 id 删除图层和单图导出委托。
|
||||
- 主视图继续负责右键菜单定位、画布事件、生成提交、素材上传、项目持久化和实际下载实现;hook 只接收必要 setter 与副作用回调,不反向读取 DOM 或路由。
|
||||
- 该 hook 用独立单测覆盖菜单关闭、历史捕获、选中态更新、删除副作用、剪贴板和导出委托,避免主视图后续继续堆积右键菜单 orchestration。
|
||||
|
||||
## 后续阶段
|
||||
|
||||
- 生成工作流 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 check:encoding`
|
||||
- `git diff --check`
|
||||
|
||||
@@ -46,20 +46,8 @@ import {
|
||||
zoomViewportFromWheel,
|
||||
} from './ImageCanvasInteractionModel';
|
||||
import {
|
||||
createCanvasLayerClipboard,
|
||||
duplicateCanvasLayers,
|
||||
flipCanvasLayers,
|
||||
getCanvasLayersByIds,
|
||||
groupCanvasLayers,
|
||||
moveCanvasLayers,
|
||||
removeCanvasLayers,
|
||||
resolveContextTargetLayerIds,
|
||||
toggleCanvasLayersLock,
|
||||
toggleCanvasLayersVisibility,
|
||||
ungroupCanvasLayers,
|
||||
updateCanvasLayersByIds,
|
||||
type CanvasLayerFlipAxis,
|
||||
type CanvasLayerMoveMode,
|
||||
} from './ImageCanvasLayerCommandModel';
|
||||
import { ImageCanvasSidebarView } from './ImageCanvasSidebarView';
|
||||
import { ImageCanvasStageView } from './ImageCanvasStageView';
|
||||
@@ -122,7 +110,6 @@ import {
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import type {
|
||||
AssetPointerDragState,
|
||||
CanvasClipboard,
|
||||
CanvasContextMenuState,
|
||||
CanvasGenerationDialogState,
|
||||
CanvasGenerationInputs,
|
||||
@@ -145,6 +132,7 @@ import { useCanvasHistory } from './useCanvasHistory';
|
||||
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
|
||||
import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary';
|
||||
import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow';
|
||||
import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands';
|
||||
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
||||
import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow';
|
||||
|
||||
@@ -288,8 +276,6 @@ export function ImageCanvasEditorView() {
|
||||
const [contextMenu, setContextMenu] = useState<CanvasContextMenuState | null>(
|
||||
null,
|
||||
);
|
||||
const [canvasClipboard, setCanvasClipboard] =
|
||||
useState<CanvasClipboard | null>(null);
|
||||
const [quickEditPanel, setQuickEditPanel] =
|
||||
useState<QuickEditPanelState | null>(null);
|
||||
const [characterAnimationPanel, setCharacterAnimationPanel] =
|
||||
@@ -681,6 +667,58 @@ export function ImageCanvasEditorView() {
|
||||
projectId,
|
||||
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 {
|
||||
uploadInputRef,
|
||||
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 = (
|
||||
asset: EditorAsset,
|
||||
position?: { x: number; y: number },
|
||||
@@ -1318,84 +1175,8 @@ export function ImageCanvasEditorView() {
|
||||
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;
|
||||
|
||||
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 placeholderWidth = 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) => {
|
||||
setGenerateDialog((currentDialog) => {
|
||||
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