拆分图片画布拖拽入画布流程
新增画布拖拽 drop workflow,承接素材库图片和本地文件拖入画布分流 补充拖拽入画布 hook 测试,覆盖遮罩、默认文件夹和无关拖拽不拦截 更新前端拆分文档和 TRACKING 浏览器回归记录
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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('-');
|
||||
});
|
||||
});
|
||||
131
src/components/image-editor/useImageCanvasCanvasDropWorkflow.ts
Normal file
131
src/components/image-editor/useImageCanvasCanvasDropWorkflow.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user