拆分图片画布拖拽入画布流程

新增画布拖拽 drop workflow,承接素材库图片和本地文件拖入画布分流

补充拖拽入画布 hook 测试,覆盖遮罩、默认文件夹和无关拖拽不拦截

更新前端拆分文档和 TRACKING 浏览器回归记录
This commit is contained in:
2026-06-17 10:17:07 +08:00
parent 31da3b2fa2
commit 53d1283083
5 changed files with 395 additions and 59 deletions

View File

@@ -134,3 +134,4 @@
- 2026-06-17 前端拆分第十六阶段:新增 `useImageCanvasEditorChrome`,把项目标题 / 重命名、侧栏开关、当前工具、缩放菜单、背景设置、小地图和背景 HEX 状态从主视图抽出;主视图继续保留上传 / 生成 / 键盘 Escape 的跨工作流编排。新增 hook 单测覆盖重命名、鉴权登录、背景色输入、面板开关和工具状态;主视图从 2039 行降至 1966 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck`
- 2026-06-17 前端拆分第十七阶段:新增 `useImageCanvasViewportControls`,把视口状态、画布尺寸、小地图投影、适合视图、中心缩放、滚轮语义、坐标换算和小地图移动从主视图抽出;主视图继续保留图层拖拽、框选、生成占位拖拽、上传 drop 和历史触发时机。验证命令:`npm run test -- src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.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` 未登录刷新弹出 `账号入口`,登录后素材 / 画布 / 小地图和底部工具栏可见普通滚轮不改变缩放Ctrl 滚轮从 `100%``110%`;背景设置点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none`;点击 `生成工具``Image Generator``生成图片` 对话框和 `AI画布工具栏` 均可见,登录后控制台无前端 error。
- 2026-06-17 前端拆分第十八阶段:新增 `useImageCanvasStageInteractions`,把画布舞台 pointer 状态机、选择 / 框选、多选拖拽、生成占位拖拽、抓手 / Space 临时抓手 / 中键平移、小地图 click / drag 分流和吸附线状态从主视图抽出;主视图继续保留上传 drop、右键菜单、生成提交、项目持久化和工具栏动作分流。新增 hook 单测覆盖多选拖拽、框选、临时抓手、生成占位和小地图分流;主视图从 1802 行降至 1452 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.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`普通滚轮不改变缩放Ctrl 滚轮从 `146%``161%`;抓手 / 文字 / 选择工具可连续切换;点击 `生成工具``Image Generator``生成图片` 对话框和 `AI画布工具栏` 均可见,关闭对话框后占位图保留,登录后控制台无前端 error。
- 2026-06-17 前端拆分第十九阶段:新增 `useImageCanvasCanvasDropWorkflow`,把画布区域 drag over / drag leave / drop 分流从主视图抽出覆盖素材库图片拖入画布、本地文件拖入画布、无关拖拽不拦截、默认文件夹选择和画布遮罩清理主视图继续注入素材建层、文件上传、drop 点换算和素材移动高亮清理。新增 hook 单测覆盖拖拽入画布细节,主视图从 1452 行降至 1405 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.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` 登录态刷新后素材 / 画布 / 小地图和底部工具栏可见,真实鼠标拖拽素材库图片到画布时出现 `添加到画布` 遮罩,松手后画布图层数量从 4 增至 5`画布背景设置` 点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none`;抓手工具可切回选择工具,登录后控制台无前端 error。

View File

@@ -160,14 +160,21 @@
- 主视图继续保留原生文件 / 素材 drop、右键菜单定位、上传工作流、生成提交、项目持久化和工具栏动作分流舞台 hook 只接收这些能力需要的回调不反向读取路由、API 或素材库状态。
- 该 hook 用独立单测覆盖多选拖拽、框选、临时抓手、生成占位拖拽和小地图 click / drag 分流;主视图 DOM 测试继续覆盖真实组件路径和历史上容易回退的浏览器级交互。
## 第十九阶段模块
- `useImageCanvasCanvasDropWorkflow.ts`
- 承载画布区域 drag over / leave / drop 分流:识别素材库拖拽 MIME、本地文件拖拽、画布遮罩状态、默认文件夹选择、素材入画布和文件上传到画布参数组装。
- 主视图继续提供已有能力注入:账号级素材列表、默认素材文件夹、屏幕点转画布 drop 点、素材建层、文件上传、素材移动高亮清理drop hook 不直接创建资源、不访问 API也不读取项目持久化状态。
- 该 hook 用独立单测覆盖素材库图片拖入画布、文件拖入画布、无关拖拽不拦截和 drag leave 清理遮罩;主视图集成测试继续覆盖真实 DOM 中的“素材库拖到画布”和“文件拖到画布”路径。
## 后续阶段
- 后续可继续选择更高内聚的交互 workflow 或持久化边界,不再把生成链路继续拆成浅层 wrapper。
- 素材入画布、原生文件 drop、右键菜单定位、工程资源持久化和历史捕获仍在主视图编排,拆分前需要先确认不会破坏多生成对象同时存在、完成时读取最新占位框、素材拖拽上传位置和角色动画优先传 `objectKey` 的历史保护规则。
- 右键菜单定位、工程资源持久化和历史捕获仍在主视图编排,拆分前需要先确认不会破坏多生成对象同时存在、完成时读取最新占位框、素材拖拽上传位置和角色动画优先传 `objectKey` 的历史保护规则。
## 验证计划
- `npm run test -- src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`
- `npm run test -- src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`
- `npm run typecheck`
- `npm run check:encoding`
- `git diff --check`

View File

@@ -1,7 +1,6 @@
import { Check, ChevronLeft, Download, Pencil, X } from 'lucide-react';
import {
type CSSProperties,
type DragEvent as ReactDragEvent,
type MouseEvent as ReactMouseEvent,
useCallback,
useEffect,
@@ -23,12 +22,9 @@ import {
import { ImageCanvasSidebarView } from './ImageCanvasSidebarView';
import { ImageCanvasStageView } from './ImageCanvasStageView';
import {
ASSET_DRAG_MIME_TYPE,
TOOLBAR_HALF_WIDTH,
clamp,
createLayerFromAsset,
getDraggedAssetId,
hasDataTransferType,
isGeneratedLayer,
isLayerLinkedToAsset,
resolveContextMenuPosition,
@@ -56,6 +52,7 @@ import { useCanvasHistory } from './useCanvasHistory';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary';
import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow';
import { useImageCanvasCanvasDropWorkflow } from './useImageCanvasCanvasDropWorkflow';
import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome';
import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow';
import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands';
@@ -904,60 +901,16 @@ export function ImageCanvasEditorView() {
deleteLayerByIdRef.current = deleteLayerById;
const handleCanvasDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
if (hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE)) {
event.preventDefault();
setUploadDropTarget('canvas');
event.dataTransfer.dropEffect = 'copy';
return;
}
if (hasDataTransferType(event.dataTransfer, 'Files')) {
event.preventDefault();
setUploadDropTarget('canvas');
event.dataTransfer.dropEffect = 'copy';
}
};
const handleCanvasDragLeave = (event: ReactDragEvent<HTMLDivElement>) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setUploadDropTarget((currentTarget) =>
currentTarget === 'canvas' ? null : currentTarget,
);
}
};
const handleCanvasDrop = (event: ReactDragEvent<HTMLDivElement>) => {
const draggedAssetId = getDraggedAssetId(event.dataTransfer);
if (draggedAssetId) {
const draggedAsset = assets.find((asset) => asset.id === draggedAssetId);
if (!draggedAsset) {
return;
}
event.preventDefault();
setUploadDropTarget(null);
updateAssetMoveDropFolder(null);
addAssetLayer(
draggedAsset,
getCanvasDropPoint(event.clientX, event.clientY),
);
return;
}
const files = event.dataTransfer.files;
if (!files.length) {
return;
}
event.preventDefault();
setUploadDropTarget(null);
updateAssetMoveDropFolder(null);
const canvasPoint = getCanvasDropPoint(event.clientX, event.clientY);
const defaultFolder =
assetFolders.find((folder) => folder.systemDefault) ?? assetFolders[0];
addUploadedFiles(files, {
folderId: defaultFolder?.id,
canvasPoint,
addToCanvas: true,
const { handleCanvasDragOver, handleCanvasDragLeave, handleCanvasDrop } =
useImageCanvasCanvasDropWorkflow({
assets,
assetFolders,
setUploadDropTarget,
updateAssetMoveDropFolder,
getCanvasDropPoint,
addAssetLayer,
addUploadedFiles,
});
};
const handleCanvasContextMenu = (event: ReactMouseEvent<HTMLDivElement>) => {
event.preventDefault();

View File

@@ -0,0 +1,244 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { ASSET_DRAG_MIME_TYPE } from './ImageCanvasEditorModel';
import type { EditorAsset, EditorAssetFolder } from './ImageCanvasEditorTypes';
import { useImageCanvasCanvasDropWorkflow } from './useImageCanvasCanvasDropWorkflow';
function createDataTransferStub({
files = [],
}: {
files?: File[];
} = {}) {
const store = new Map<string, string>();
return {
files,
types: files.length ? ['Files'] : ([] as string[]),
dropEffect: 'none',
setData(type: string, value: string) {
store.set(type, value);
if (!this.types.includes(type)) {
this.types.push(type);
}
},
getData(type: string) {
return store.get(type) ?? '';
},
};
}
function dispatchDragEvent(
target: Element,
type: string,
options: {
dataTransfer: ReturnType<typeof createDataTransferStub>;
clientX?: number;
clientY?: number;
relatedTarget?: EventTarget | null;
},
) {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperty(event, 'dataTransfer', {
configurable: true,
value: options.dataTransfer,
});
Object.defineProperty(event, 'clientX', {
configurable: true,
value: options.clientX ?? 0,
});
Object.defineProperty(event, 'clientY', {
configurable: true,
value: options.clientY ?? 0,
});
Object.defineProperty(event, 'relatedTarget', {
configurable: true,
value: options.relatedTarget ?? null,
});
fireEvent(target, event);
}
function DropWorkflowHarness({
assets = [
{
id: 'asset-1',
label: '素材一',
src: 'data:image/png;base64,asset',
width: 100,
height: 80,
folderId: 'project',
sourceKind: 'uploaded',
sourceType: 'uploaded',
persisted: true,
},
],
folders = [
{
id: 'project',
label: '项目素材',
collapsed: false,
systemDefault: true,
persisted: true,
},
],
addAssetLayer = vi.fn(),
addUploadedFiles = vi.fn(),
updateAssetMoveDropFolder = vi.fn(),
getCanvasDropPoint = vi.fn((clientX: number, clientY: number) => ({
x: clientX - 10,
y: clientY - 20,
})),
}: {
assets?: EditorAsset[];
folders?: EditorAssetFolder[];
addAssetLayer?: (asset: EditorAsset, position?: { x: number; y: number }) => void;
addUploadedFiles?: (
files: FileList | File[],
options: {
folderId?: string;
canvasPoint: { x: number; y: number };
addToCanvas: true;
},
) => void;
updateAssetMoveDropFolder?: (folderId: string | null) => void;
getCanvasDropPoint?: (clientX: number, clientY: number) => {
x: number;
y: number;
};
}) {
const [uploadDropTarget, setUploadDropTarget] = useState<
'canvas' | 'assets' | null
>(null);
const workflow = useImageCanvasCanvasDropWorkflow({
assets,
assetFolders: folders,
setUploadDropTarget,
updateAssetMoveDropFolder,
getCanvasDropPoint,
addAssetLayer,
addUploadedFiles,
});
return (
<div>
<div
data-testid="outside"
onDragEnter={() => setUploadDropTarget('assets')}
/>
<div
data-testid="canvas"
onDragOver={workflow.handleCanvasDragOver}
onDragLeave={workflow.handleCanvasDragLeave}
onDrop={workflow.handleCanvasDrop}
>
canvas
<span data-testid="drop-target">{uploadDropTarget ?? '-'}</span>
<span data-testid="inner">inner</span>
</div>
</div>
);
}
describe('useImageCanvasCanvasDropWorkflow', () => {
it('shows a canvas drop target for dragged asset library images', () => {
render(<DropWorkflowHarness />);
const dataTransfer = createDataTransferStub();
dataTransfer.setData(ASSET_DRAG_MIME_TYPE, 'asset-1');
dispatchDragEvent(screen.getByTestId('canvas'), 'dragover', {
dataTransfer,
});
expect(dataTransfer.dropEffect).toBe('copy');
expect(screen.getByTestId('drop-target').textContent).toBe('canvas');
});
it('adds an existing asset to the canvas at the drop point', () => {
const addAssetLayer = vi.fn();
const updateAssetMoveDropFolder = vi.fn();
render(
<DropWorkflowHarness
addAssetLayer={addAssetLayer}
updateAssetMoveDropFolder={updateAssetMoveDropFolder}
/>,
);
const dataTransfer = createDataTransferStub();
dataTransfer.setData(ASSET_DRAG_MIME_TYPE, 'asset-1');
dispatchDragEvent(screen.getByTestId('canvas'), 'drop', {
clientX: 120,
clientY: 90,
dataTransfer,
});
expect(addAssetLayer).toHaveBeenCalledWith(
expect.objectContaining({ id: 'asset-1' }),
{ x: 110, y: 70 },
);
expect(updateAssetMoveDropFolder).toHaveBeenCalledWith(null);
expect(screen.getByTestId('drop-target').textContent).toBe('-');
});
it('uploads dropped files to the default folder and adds them to the canvas', () => {
const addUploadedFiles = vi.fn();
render(<DropWorkflowHarness addUploadedFiles={addUploadedFiles} />);
const imageFile = new File(['image'], '画布上传.png', {
type: 'image/png',
});
const dataTransfer = createDataTransferStub({ files: [imageFile] });
dispatchDragEvent(screen.getByTestId('canvas'), 'dragover', {
dataTransfer,
});
expect(screen.getByTestId('drop-target').textContent).toBe('canvas');
dispatchDragEvent(screen.getByTestId('canvas'), 'drop', {
clientX: 300,
clientY: 220,
dataTransfer,
});
expect(addUploadedFiles).toHaveBeenCalledWith([imageFile], {
folderId: 'project',
canvasPoint: { x: 290, y: 200 },
addToCanvas: true,
});
expect(screen.getByTestId('drop-target').textContent).toBe('-');
});
it('keeps unrelated drags untouched and clears only canvas overlays on leave', () => {
render(<DropWorkflowHarness />);
const dataTransfer = createDataTransferStub();
dispatchDragEvent(screen.getByTestId('canvas'), 'dragover', {
dataTransfer,
});
expect(dataTransfer.dropEffect).toBe('none');
expect(screen.getByTestId('drop-target').textContent).toBe('-');
fireEvent.dragEnter(screen.getByTestId('outside'));
expect(screen.getByTestId('drop-target').textContent).toBe('assets');
dispatchDragEvent(screen.getByTestId('canvas'), 'dragover', {
dataTransfer: createDataTransferStub({
files: [new File(['image'], '画布上传.png', { type: 'image/png' })],
}),
});
expect(screen.getByTestId('drop-target').textContent).toBe('canvas');
dispatchDragEvent(screen.getByTestId('canvas'), 'dragleave', {
dataTransfer: createDataTransferStub(),
relatedTarget: screen.getByTestId('inner'),
});
expect(screen.getByTestId('drop-target').textContent).toBe('canvas');
dispatchDragEvent(screen.getByTestId('canvas'), 'dragleave', {
dataTransfer: createDataTransferStub(),
relatedTarget: screen.getByTestId('outside'),
});
expect(screen.getByTestId('drop-target').textContent).toBe('-');
});
});

View File

@@ -0,0 +1,131 @@
import {
type DragEvent as ReactDragEvent,
type Dispatch,
type SetStateAction,
useCallback,
useMemo,
} from 'react';
import {
ASSET_DRAG_MIME_TYPE,
getDraggedAssetId,
hasDataTransferType,
} from './ImageCanvasEditorModel';
import type { EditorAsset, EditorAssetFolder } from './ImageCanvasEditorTypes';
type UploadFilesToCanvasOptions = {
folderId?: string;
canvasPoint: { x: number; y: number };
addToCanvas: true;
};
type UseImageCanvasCanvasDropWorkflowOptions = {
assets: EditorAsset[];
assetFolders: EditorAssetFolder[];
setUploadDropTarget: Dispatch<SetStateAction<'canvas' | 'assets' | null>>;
updateAssetMoveDropFolder: (folderId: string | null) => void;
getCanvasDropPoint: (clientX: number, clientY: number) => {
x: number;
y: number;
};
addAssetLayer: (
asset: EditorAsset,
position?: { x: number; y: number },
) => void;
addUploadedFiles: (
files: FileList | File[],
options: UploadFilesToCanvasOptions,
) => void;
};
export function useImageCanvasCanvasDropWorkflow({
assets,
assetFolders,
setUploadDropTarget,
updateAssetMoveDropFolder,
getCanvasDropPoint,
addAssetLayer,
addUploadedFiles,
}: UseImageCanvasCanvasDropWorkflowOptions) {
const handleCanvasDragOver = useCallback(
(event: ReactDragEvent<HTMLDivElement>) => {
if (hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE)) {
event.preventDefault();
setUploadDropTarget('canvas');
event.dataTransfer.dropEffect = 'copy';
return;
}
if (hasDataTransferType(event.dataTransfer, 'Files')) {
event.preventDefault();
setUploadDropTarget('canvas');
event.dataTransfer.dropEffect = 'copy';
}
},
[setUploadDropTarget],
);
const handleCanvasDragLeave = useCallback(
(event: ReactDragEvent<HTMLDivElement>) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setUploadDropTarget((currentTarget) =>
currentTarget === 'canvas' ? null : currentTarget,
);
}
},
[setUploadDropTarget],
);
const handleCanvasDrop = useCallback(
(event: ReactDragEvent<HTMLDivElement>) => {
const draggedAssetId = getDraggedAssetId(event.dataTransfer);
if (draggedAssetId) {
const draggedAsset = assets.find((asset) => asset.id === draggedAssetId);
if (!draggedAsset) {
return;
}
event.preventDefault();
setUploadDropTarget(null);
updateAssetMoveDropFolder(null);
addAssetLayer(
draggedAsset,
getCanvasDropPoint(event.clientX, event.clientY),
);
return;
}
const files = event.dataTransfer.files;
if (!files.length) {
return;
}
event.preventDefault();
setUploadDropTarget(null);
updateAssetMoveDropFolder(null);
const canvasPoint = getCanvasDropPoint(event.clientX, event.clientY);
const defaultFolder =
assetFolders.find((folder) => folder.systemDefault) ?? assetFolders[0];
addUploadedFiles(files, {
folderId: defaultFolder?.id,
canvasPoint,
addToCanvas: true,
});
},
[
addAssetLayer,
addUploadedFiles,
assetFolders,
assets,
getCanvasDropPoint,
setUploadDropTarget,
updateAssetMoveDropFolder,
],
);
return useMemo(
() => ({
handleCanvasDragOver,
handleCanvasDragLeave,
handleCanvasDrop,
}),
[handleCanvasDragLeave, handleCanvasDragOver, handleCanvasDrop],
);
}