From 0fd0a06387787b5cc9709d9550d768c228c68e13 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 14 Jun 2026 19:20:13 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=BB=E5=B8=83=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=E4=BA=A4=E4=BA=92=E7=BC=BA=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一默认素材文件夹,避免侧栏拖拽上传重复生成图片 区分素材入库和画布拖拽上传,画布落点增加安全兜底 补齐画布 Shift 多选、框选渲染和多图层打组能力 调整生成器对话框隐藏逻辑,关闭按钮保留占位图 将缩放比例入口放入左下角面板并拦截编辑器内 Ctrl 滚轮缩放页面 补充素材上传、画布多选、图层打组和生成器隐藏回归测试 --- .../ImageCanvasEditorView.test.tsx | 177 ++++++- .../image-editor/ImageCanvasEditorView.tsx | 475 +++++++++++++----- src/index.css | 14 + 3 files changed, 527 insertions(+), 139 deletions(-) diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 4dc20e58..8eda8f37 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -200,7 +200,7 @@ describe('ImageCanvasEditorView', () => { const sidebar = screen.getByRole('complementary', { name: '图片资源栏' }); expect(within(sidebar).getByRole('region', { name: '项目素材' })).toBeTruthy(); - expect(within(sidebar).getByRole('region', { name: '参考素材' })).toBeTruthy(); + expect(within(sidebar).queryByRole('region', { name: '参考素材' })).toBeNull(); await user.click(screen.getByRole('button', { name: '重命名素材拼图素材' })); const renameInput = screen.getByLabelText('重命名素材拼图素材'); @@ -248,7 +248,6 @@ describe('ImageCanvasEditorView', () => { new File(['image'], '角色草图.png', { type: 'image/png' }), ); - await user.click(screen.getByRole('button', { name: '打开素材' })); const customFolder = screen.getByRole('region', { name: '角色上传' }); expect(within(customFolder).getByRole('button', { name: '添加角色草图.png' })).toBeTruthy(); expect(within(customFolder).getByRole('button', { name: '删除素材角色草图.png' })).toBeTruthy(); @@ -256,7 +255,7 @@ describe('ImageCanvasEditorView', () => { await user.click(within(customFolder).getByRole('button', { name: '删除素材角色草图.png' })); expect(screen.queryByRole('button', { name: '添加角色草图.png' })).toBeNull(); - expect(screen.getByAltText('画布图片:角色草图.png')).toBeTruthy(); + expect(screen.queryByAltText('画布图片:角色草图.png')).toBeNull(); }); it('renames and deletes asset folders through the persisted asset library API', async () => { @@ -302,21 +301,21 @@ describe('ImageCanvasEditorView', () => { expect(deleteEditorAssetFolderMock).toHaveBeenCalledWith('folder-role'); }); - it('uploads multiple files and persists them as account-level assets', async () => { + it('uploads multiple files as account-level assets without adding canvas layers', async () => { render(); - const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); - fireEvent.click(within(bottomToolbar).getByRole('button', { name: '上传工具' })); await userEvent.upload(screen.getByLabelText('上传图片文件'), [ new File(['image-a'], '第一张.png', { type: 'image/png' }), new File(['image-b'], '第二张.png', { type: 'image/png' }), ]); await waitFor(() => { - expect(screen.getByAltText('画布图片:第一张.png')).toBeTruthy(); - expect(screen.getByAltText('画布图片:第二张.png')).toBeTruthy(); + expect(screen.getByRole('button', { name: '添加第一张.png' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '添加第二张.png' })).toBeTruthy(); }); expect(createEditorAssetMock).toHaveBeenCalledTimes(2); + expect(screen.queryByAltText('画布图片:第一张.png')).toBeNull(); + expect(screen.queryByAltText('画布图片:第二张.png')).toBeNull(); }); it('supports asset selection mode and batch delete with shared toolbar', async () => { @@ -534,21 +533,21 @@ describe('ImageCanvasEditorView', () => { expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy(); }); - it('uploads an image file as a new canvas layer', async () => { + it('drops an image file on the canvas as a new canvas layer', async () => { render(); await waitFor(() => { expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); }); - const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); - - expect(within(bottomToolbar).queryByRole('button', { name: '局部修改工具' })).toBeNull(); - - fireEvent.click(within(bottomToolbar).getByRole('button', { name: '上传工具' })); - await userEvent.upload( - screen.getByLabelText('上传图片文件'), - new File(['image'], '测试上传.png', { type: 'image/png' }), - ); + const viewport = screen.getByLabelText('画布工作区'); + fireEvent.drop(viewport, { + clientX: 430, + clientY: 260, + dataTransfer: { + files: [new File(['image'], '测试上传.png', { type: 'image/png' })], + types: ['Files'], + }, + }); await waitFor(() => { expect(screen.getByAltText('画布图片:测试上传.png')).toBeTruthy(); @@ -562,6 +561,23 @@ describe('ImageCanvasEditorView', () => { expect(screen.getByRole('button', { name: '选择图层测试上传.png' })).toBeTruthy(); }); + it('drops files into the asset panel only once without creating canvas layers', async () => { + render(); + + fireEvent.drop(screen.getByRole('region', { name: '项目素材' }), { + dataTransfer: { + files: [new File(['image'], '素材拖拽.png', { type: 'image/png' })], + types: ['Files'], + }, + }); + + await waitFor(() => { + expect(screen.getByRole('button', { name: '添加素材拖拽.png' })).toBeTruthy(); + }); + expect(createEditorAssetMock).toHaveBeenCalledTimes(1); + expect(screen.queryByAltText('画布图片:素材拖拽.png')).toBeNull(); + }); + it('blocks the browser context menu inside the editor workspace', () => { render(); @@ -674,6 +690,64 @@ describe('ImageCanvasEditorView', () => { clientY: 280, }); expect(screen.getByRole('button', { name: '当前缩放比例 90%' })).toBeTruthy(); + + const ctrlWheelEvent = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + ctrlKey: true, + deltaY: -120, + clientX: 400, + clientY: 280, + }); + viewport.dispatchEvent(ctrlWheelEvent); + expect(ctrlWheelEvent.defaultPrevented).toBe(true); + }); + + it('selects multiple canvas layers with shift click', async () => { + render(); + + const firstLayer = screen.getByAltText('画布图片:拼图素材').closest('button')!; + const secondLayer = screen.getByAltText('画布图片:大鱼素材').closest('button')!; + + fireEvent.pointerDown(firstLayer, { + button: 0, + pointerId: 81, + clientX: 120, + clientY: 120, + }); + fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { + pointerId: 81, + clientX: 120, + clientY: 120, + }); + await waitFor(() => { + expect( + screen.getByAltText('画布图片:拼图素材').closest('button')?.className, + ).toContain('image-canvas-editor__layer--selected'); + }); + fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' }); + fireEvent.pointerDown(secondLayer, { + button: 0, + pointerId: 82, + clientX: 520, + clientY: 180, + shiftKey: true, + }); + fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { + pointerId: 82, + clientX: 520, + clientY: 180, + }); + fireEvent.keyUp(window, { key: 'Shift', code: 'ShiftLeft' }); + + await waitFor(() => { + expect( + screen.getByAltText('画布图片:拼图素材').closest('button')?.className, + ).toContain('image-canvas-editor__layer--selected'); + expect( + screen.getByAltText('画布图片:大鱼素材').closest('button')?.className, + ).toContain('image-canvas-editor__layer--selected'); + }); }); it('drags the minimap to move the canvas viewport', () => { @@ -707,10 +781,48 @@ describe('ImageCanvasEditorView', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开图层' })); + fireEvent.pointerDown(screen.getByAltText('画布图片:拼图素材').closest('button')!, { + button: 0, + pointerId: 90, + clientX: 120, + clientY: 120, + }); + fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { + pointerId: 90, + clientX: 120, + clientY: 120, + }); + await waitFor(() => { + expect( + screen.getByAltText('画布图片:拼图素材').closest('button')?.className, + ).toContain('image-canvas-editor__layer--selected'); + }); + fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' }); + fireEvent.pointerDown(screen.getByAltText('画布图片:大鱼素材').closest('button')!, { + button: 0, + pointerId: 91, + clientX: 520, + clientY: 180, + shiftKey: true, + }); + fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { + pointerId: 91, + clientX: 520, + clientY: 180, + }); + fireEvent.keyUp(window, { key: 'Shift', code: 'ShiftLeft' }); + await waitFor(() => { + expect( + screen.getByAltText('画布图片:拼图素材').closest('button')?.className, + ).toContain('image-canvas-editor__layer--selected'); + expect( + screen.getByAltText('画布图片:大鱼素材').closest('button')?.className, + ).toContain('image-canvas-editor__layer--selected'); + }); fireEvent.click(screen.getByRole('button', { name: '图层打组' })); await waitFor(() => { - expect(screen.getByText(/已打组/u)).toBeTruthy(); + expect(screen.getAllByText(/已打组/u)).toHaveLength(2); }); await waitFor(() => { expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( @@ -721,6 +833,10 @@ describe('ImageCanvasEditorView', () => { title: '拼图素材', groupId: expect.stringMatching(/^layer-group-/u), }), + expect.objectContaining({ + title: '大鱼素材', + groupId: expect.stringMatching(/^layer-group-/u), + }), ]), }), ); @@ -870,7 +986,7 @@ describe('ImageCanvasEditorView', () => { expect(Number.parseFloat((generatedLayer as HTMLElement).style.top)).toBeGreaterThan(180); }); - it('keeps the generation composer when selecting another image', () => { + it('hides the generation composer when selecting another image but keeps the placeholder', () => { render(); fireEvent.click(screen.getByRole('button', { name: '生成工具' })); @@ -883,8 +999,17 @@ describe('ImageCanvasEditorView', () => { clientY: 120, }); - expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); + expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); + + fireEvent.pointerDown(screen.getByLabelText('图像生成占位图'), { + button: 0, + pointerId: 64, + clientX: 300, + clientY: 180, + }); + + expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); }); it('keeps the generation composer when clicking the canvas outside generation controls', () => { @@ -904,6 +1029,16 @@ describe('ImageCanvasEditorView', () => { expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); }); + it('closes the generation composer without removing the placeholder frame', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: '生成工具' })); + fireEvent.click(screen.getByRole('button', { name: '关闭生成图片' })); + + expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); + expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); + }); + it('shows generation errors instead of falling back to mock images', async () => { generateEditorImageMock.mockRejectedValueOnce(new Error('VectorEngine 未配置')); render(); diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index c743798d..23b25f7c 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -148,6 +148,7 @@ type GenerateDialogState = { mode: 'generate' | 'edit'; prompt: string; status: 'idle' | 'generating' | 'failed'; + composerOpen?: boolean; sourceLayerId?: string; generatedLayerId?: string; errorMessage?: string; @@ -180,6 +181,14 @@ type AssetMarqueeState = { currentY: number; }; +type CanvasMarqueeState = { + pointerId: number; + startX: number; + startY: number; + currentX: number; + currentY: number; +}; + type DragState = | { kind: 'pan'; @@ -192,10 +201,12 @@ type DragState = kind: 'layer'; pointerId: number; layerId: string; + layerIds: string[]; startClientX: number; startClientY: number; startLayerX: number; startLayerY: number; + startLayers: Array<{ id: string; x: number; y: number }>; startScale: number; } | { @@ -241,7 +252,7 @@ const EDITOR_ASSETS: EditorAsset[] = [ src: '/creation-type-references/big-fish.webp', width: 720, height: 405, - folderId: 'references', + folderId: 'project', sourceKind: 'built-in', sourceType: 'uploaded', persisted: false, @@ -252,7 +263,7 @@ const EDITOR_ASSETS: EditorAsset[] = [ src: '/creation-type-references/bark-battle.webp', width: 640, height: 900, - folderId: 'references', + folderId: 'project', sourceKind: 'built-in', sourceType: 'uploaded', persisted: false, @@ -263,7 +274,7 @@ const EDITOR_ASSETS: EditorAsset[] = [ src: '/creation-type-references/visual-novel.webp', width: 720, height: 405, - folderId: 'references', + folderId: 'project', sourceKind: 'built-in', sourceType: 'uploaded', persisted: false, @@ -278,20 +289,6 @@ const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [ systemDefault: true, persisted: false, }, - { - id: 'references', - label: '参考素材', - collapsed: false, - systemDefault: false, - persisted: false, - }, - { - id: 'uploads', - label: '上传素材', - collapsed: false, - systemDefault: false, - persisted: false, - }, ]; const INITIAL_LAYERS: CanvasLayer[] = [ @@ -606,10 +603,12 @@ function resolveImageGenerationErrorMessage(error: unknown) { } export function ImageCanvasEditorView() { + const editorRootRef = useRef(null); const canvasViewportRef = useRef(null); const uploadInputRef = useRef(null); const assetListRef = useRef(null); const dragStateRef = useRef(null); + const isShiftPressedRef = useRef(false); const layerCounterRef = useRef(INITIAL_LAYERS.length); const saveTimerRef = useRef(null); const [projectId, setProjectId] = useState(null); @@ -636,7 +635,7 @@ export function ImageCanvasEditorView() { } | null>(null); const [creatingFolder, setCreatingFolder] = useState(false); const [newFolderName, setNewFolderName] = useState(''); - const [activeUploadFolderId, setActiveUploadFolderId] = useState('uploads'); + const [activeUploadFolderId, setActiveUploadFolderId] = useState('project'); const [isAssetSelectionMode, setIsAssetSelectionMode] = useState(false); const [selectedAssetIds, setSelectedAssetIds] = useState>( () => new Set(), @@ -644,6 +643,9 @@ export function ImageCanvasEditorView() { const [assetMarquee, setAssetMarquee] = useState( null, ); + const [canvasMarquee, setCanvasMarquee] = useState( + null, + ); const [selectedLayerId, setSelectedLayerId] = useState( INITIAL_LAYERS[0]?.id ?? null, ); @@ -681,7 +683,8 @@ export function ImageCanvasEditorView() { generateDialog?.mode === 'generate' ? (activeGenerationLayer ?? generateDialog.placeholder ?? null) : null; - const generationComposerStyle = generationAnchor + const generationComposerStyle = + generateDialog?.composerOpen !== false && generationAnchor ? { left: viewport.x + @@ -723,6 +726,16 @@ export function ImageCanvasEditorView() { const selectSingleLayer = (layerId: string | null) => { setSelectedLayerId(layerId); setSelectedLayerIds(layerId ? [layerId] : []); + if (layerId && generateDialog?.mode === 'generate') { + setGenerateDialog((currentDialog) => + currentDialog?.mode === 'generate' + ? { + ...currentDialog, + composerOpen: false, + } + : currentDialog, + ); + } }; const minimapModel = useMemo(() => { const layerBounds = getLayerBounds(layers); @@ -864,12 +877,22 @@ export function ImageCanvasEditorView() { useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Shift') { + isShiftPressedRef.current = true; + } if (event.key === 'Escape') { setActiveSidebarPanel(null); setIsZoomMenuOpen(false); setIsBackgroundMenuOpen(false); setGenerateDialog((currentDialog) => - currentDialog?.status === 'generating' ? currentDialog : null, + currentDialog?.status === 'generating' + ? currentDialog + : currentDialog?.mode === 'generate' + ? { + ...currentDialog, + composerOpen: false, + } + : null, ); return; } @@ -880,6 +903,9 @@ export function ImageCanvasEditorView() { setIsSpacePanning(true); }; const handleKeyUp = (event: KeyboardEvent) => { + if (event.key === 'Shift') { + isShiftPressedRef.current = false; + } if (event.code !== 'Space') { return; } @@ -895,6 +921,27 @@ export function ImageCanvasEditorView() { }; }, []); + useEffect(() => { + const blockBrowserZoom = (event: WheelEvent) => { + const editorElement = editorRootRef.current; + if ( + editorElement && + event.target instanceof Node && + editorElement.contains(event.target) && + (event.ctrlKey || event.metaKey) + ) { + event.preventDefault(); + } + }; + window.addEventListener('wheel', blockBrowserZoom, { + capture: true, + passive: false, + }); + return () => { + window.removeEventListener('wheel', blockBrowserZoom, { capture: true }); + }; + }, []); + useEffect(() => { if (!projectId || !isProjectReady) { return undefined; @@ -1364,6 +1411,7 @@ export function ImageCanvasEditorView() { folderId?: string; canvasPoint?: { x: number; y: number }; uploadIndex?: number; + addToCanvas?: boolean; } = {}, ) => { if (!file.type.startsWith('image/')) { @@ -1379,13 +1427,22 @@ export function ImageCanvasEditorView() { const uploadFolderId = assetFolders.some((folder) => folder.id === (options.folderId ?? activeUploadFolderId)) ? (options.folderId ?? activeUploadFolderId) - : 'uploads'; + : 'project'; const screenPoint = options.canvasPoint ?? { x: canvasSize.width / 2, y: canvasSize.height / 2, }; - const worldCenterX = (screenPoint.x - viewport.x) / viewport.scale; - const worldCenterY = (screenPoint.y - viewport.y) / viewport.scale; + const fallbackScreenPoint = { + x: canvasSize.width > 0 ? canvasSize.width / 2 : 640, + y: canvasSize.height > 0 ? canvasSize.height / 2 : 360, + }; + const normalizedScreenPoint = { + x: Number.isFinite(screenPoint.x) ? screenPoint.x : fallbackScreenPoint.x, + y: Number.isFinite(screenPoint.y) ? screenPoint.y : fallbackScreenPoint.y, + }; + const safeScale = viewport.scale > 0 ? viewport.scale : 1; + const worldCenterX = (normalizedScreenPoint.x - viewport.x) / safeScale; + const worldCenterY = (normalizedScreenPoint.y - viewport.y) / safeScale; const nextLayer: CanvasLayer = { id: `layer-upload-${uploadIndex}`, resourceId: `local-resource-upload-${uploadIndex}`, @@ -1412,7 +1469,9 @@ export function ImageCanvasEditorView() { persisted: false, }; - setLayers((currentLayers) => [...currentLayers, nextLayer]); + if (options.addToCanvas) { + setLayers((currentLayers) => [...currentLayers, nextLayer]); + } setAssets((currentAssets) => [...currentAssets, uploadedAsset]); setAssetFolders((currentFolders) => currentFolders.map((folder) => @@ -1424,8 +1483,10 @@ export function ImageCanvasEditorView() { : folder, ), ); - selectSingleLayer(nextLayer.id); - setActiveSidebarPanel('layers'); + if (options.addToCanvas) { + selectSingleLayer(nextLayer.id); + setActiveSidebarPanel('layers'); + } createEditorAsset({ folderId: uploadFolderId, @@ -1457,7 +1518,9 @@ export function ImageCanvasEditorView() { }) .catch(() => {}); - createProjectResourceForLayer(nextLayer); + if (options.addToCanvas) { + createProjectResourceForLayer(nextLayer); + } if (imageSrc) { const uploadedImage = new Image(); @@ -1468,21 +1531,23 @@ export function ImageCanvasEditorView() { const sizeRatio = longestSide > 0 ? Math.min(1, 420 / longestSide) : 1; const width = Math.round(originalWidth * sizeRatio); const height = Math.round(originalHeight * sizeRatio); - setLayers((currentLayers) => - currentLayers.map((layer) => - layer.id === nextLayer.id - ? { - ...layer, - width, - height, - originalWidth, - originalHeight, - x: worldCenterX - width / 2, - y: worldCenterY - height / 2, - } - : layer, - ), - ); + if (options.addToCanvas) { + setLayers((currentLayers) => + currentLayers.map((layer) => + layer.id === nextLayer.id + ? { + ...layer, + width, + height, + originalWidth, + originalHeight, + x: worldCenterX - width / 2, + y: worldCenterY - height / 2, + } + : layer, + ), + ); + } setAssets((currentAssets) => currentAssets.map((asset) => asset.id === uploadedAsset.id @@ -1501,13 +1566,18 @@ export function ImageCanvasEditorView() { const addUploadedFiles = ( files: FileList | File[], - options: { folderId?: string; canvasPoint?: { x: number; y: number } } = {}, + options: { + folderId?: string; + canvasPoint?: { x: number; y: number }; + addToCanvas?: boolean; + } = {}, ) => { Array.from(files).forEach((file, index) => { layerCounterRef.current += 1; const uploadIndex = layerCounterRef.current; void addUploadedLayer(file, { ...options, + addToCanvas: options.addToCanvas ?? false, uploadIndex, canvasPoint: options.canvasPoint ? { @@ -1546,6 +1616,7 @@ export function ImageCanvasEditorView() { mode: 'generate', prompt: '', status: 'idle', + composerOpen: true, placeholder: { x: worldCenterX - placeholderWidth / 2, y: worldCenterY - placeholderHeight / 2, @@ -1567,6 +1638,7 @@ export function ImageCanvasEditorView() { ? `${sourceLayer.prompt},在保持主体结构的基础上优化画面细节` : '', status: 'idle', + composerOpen: true, sourceLayerId: sourceLayer.id, }); setActiveTool('generate'); @@ -1625,10 +1697,11 @@ export function ImageCanvasEditorView() { setGenerateDialog((currentDialog) => currentDialog?.mode === 'generate' ? { - ...currentDialog, - status: 'idle', - generatedLayerId: nextLayer.id, - placeholder: undefined, + ...currentDialog, + status: 'idle', + composerOpen: true, + generatedLayerId: nextLayer.id, + placeholder: undefined, errorMessage: undefined, } : currentDialog, @@ -1648,6 +1721,7 @@ export function ImageCanvasEditorView() { ...dialog, prompt: normalizedPrompt, status: 'generating', + composerOpen: true, }); try { @@ -1669,12 +1743,13 @@ export function ImageCanvasEditorView() { addGeneratedResultLayer(generated, { frame: dialog.placeholder }); } } catch (error) { - setGenerateDialog({ - ...dialog, - prompt: normalizedPrompt, - status: 'failed', - errorMessage: resolveImageGenerationErrorMessage(error), - }); + setGenerateDialog({ + ...dialog, + prompt: normalizedPrompt, + status: 'failed', + composerOpen: true, + errorMessage: resolveImageGenerationErrorMessage(error), + }); } }; @@ -1739,6 +1814,27 @@ export function ImageCanvasEditorView() { if (button !== 0) { return; } + const target = event.target as HTMLElement; + if ( + effectiveTool === 'select' && + (event.target === event.currentTarget || + target.classList.contains('image-canvas-editor__world')) + ) { + event.preventDefault(); + const rect = canvasViewportRef.current?.getBoundingClientRect(); + const startX = event.clientX - (rect?.left ?? 0); + const startY = event.clientY - (rect?.top ?? 0); + canvasViewportRef.current?.setPointerCapture?.(event.pointerId); + setCanvasMarquee({ + pointerId: event.pointerId, + startX, + startY, + currentX: startX, + currentY: startY, + }); + selectSingleLayer(null); + return; + } selectSingleLayer(null); }; @@ -1770,6 +1866,7 @@ export function ImageCanvasEditorView() { addUploadedFiles(files, { folderId: defaultFolder?.id, canvasPoint, + addToCanvas: true, }); }; @@ -1791,15 +1888,51 @@ export function ImageCanvasEditorView() { event.stopPropagation(); const pointer = getPointerClient(event); canvasViewportRef.current?.setPointerCapture?.(event.pointerId); - selectSingleLayer(layer.id); + const isMultiSelectGesture = event.shiftKey || isShiftPressedRef.current; + const nextSelectedIds = isMultiSelectGesture + ? selectedLayerIds.includes(layer.id) + ? selectedLayerIds.length > 1 + ? selectedLayerIds.filter((layerId) => layerId !== layer.id) + : [layer.id] + : [...selectedLayerIds, layer.id] + : [layer.id]; + setSelectedLayerId(layer.id); + setSelectedLayerIds(nextSelectedIds); + setGenerateDialog((currentDialog) => { + if (currentDialog?.mode !== 'generate') { + return currentDialog; + } + if (currentDialog.generatedLayerId === layer.id) { + return { + ...currentDialog, + composerOpen: true, + }; + } + return { + ...currentDialog, + composerOpen: false, + }; + }); + const dragLayerIds = nextSelectedIds.includes(layer.id) + ? nextSelectedIds + : [layer.id]; + const startLayers = layers + .filter((currentLayer) => dragLayerIds.includes(currentLayer.id)) + .map((currentLayer) => ({ + id: currentLayer.id, + x: currentLayer.x, + y: currentLayer.y, + })); dragStateRef.current = { kind: 'layer', pointerId: getPointerId(event), layerId: layer.id, + layerIds: dragLayerIds, startClientX: pointer.x, startClientY: pointer.y, startLayerX: layer.x, startLayerY: layer.y, + startLayers, startScale: viewport.scale, }; }; @@ -1824,7 +1957,16 @@ export function ImageCanvasEditorView() { event.stopPropagation(); const pointer = getPointerClient(event); canvasViewportRef.current?.setPointerCapture?.(event.pointerId); - selectSingleLayer(null); + setSelectedLayerId(null); + setSelectedLayerIds([]); + setGenerateDialog((currentDialog) => + currentDialog?.mode === 'generate' + ? { + ...currentDialog, + composerOpen: true, + } + : currentDialog, + ); dragStateRef.current = { kind: 'generation-frame', pointerId: getPointerId(event), @@ -1875,6 +2017,43 @@ export function ImageCanvasEditorView() { }; const handlePointerMove = (event: ReactPointerEvent) => { + if (canvasMarquee && canvasMarquee.pointerId === event.pointerId) { + event.preventDefault(); + const rect = canvasViewportRef.current?.getBoundingClientRect(); + const currentX = event.clientX - (rect?.left ?? 0); + const currentY = event.clientY - (rect?.top ?? 0); + setCanvasMarquee((currentMarquee) => + currentMarquee + ? { + ...currentMarquee, + currentX, + currentY, + } + : null, + ); + const left = Math.min(canvasMarquee.startX, currentX); + const right = Math.max(canvasMarquee.startX, currentX); + const top = Math.min(canvasMarquee.startY, currentY); + const bottom = Math.max(canvasMarquee.startY, currentY); + const selectedIds = layers + .filter((layer) => { + const layerLeft = viewport.x + layer.x * viewport.scale; + const layerTop = viewport.y + layer.y * viewport.scale; + const layerRight = layerLeft + layer.width * viewport.scale; + const layerBottom = layerTop + layer.height * viewport.scale; + return ( + layerLeft <= right && + layerRight >= left && + layerTop <= bottom && + layerBottom >= top + ); + }) + .map((layer) => layer.id); + setSelectedLayerIds(selectedIds); + setSelectedLayerId(selectedIds[0] ?? null); + return; + } + const dragState = dragStateRef.current; const pointerId = getPointerId(event); if ( @@ -1936,18 +2115,42 @@ export function ImageCanvasEditorView() { setSnapGuide(snapped.guide); setLayers((currentLayers) => currentLayers.map((layer) => - layer.id === dragState.layerId - ? { - ...layer, - x: snapped.x, - y: snapped.y, - } + dragState.layerIds.includes(layer.id) + ? (() => { + const startLayer = dragState.startLayers.find( + (item) => item.id === layer.id, + ); + if (!startLayer) { + return layer; + } + if (layer.id === dragState.layerId) { + return { + ...layer, + x: snapped.x, + y: snapped.y, + }; + } + return { + ...layer, + x: startLayer.x + deltaX + (snapped.x - (dragState.startLayerX + deltaX)), + y: startLayer.y + deltaY + (snapped.y - (dragState.startLayerY + deltaY)), + }; + })() : layer, ), ); }; const finishDrag = (event: ReactPointerEvent) => { + if (canvasMarquee && canvasMarquee.pointerId === event.pointerId) { + event.preventDefault(); + setCanvasMarquee(null); + if (canvasViewportRef.current?.hasPointerCapture?.(event.pointerId)) { + canvasViewportRef.current.releasePointerCapture?.(event.pointerId); + } + return; + } + const dragState = dragStateRef.current; const pointerId = getPointerId(event); if ( @@ -2025,6 +2228,7 @@ export function ImageCanvasEditorView() { return (
event.preventDefault()} @@ -2039,7 +2243,7 @@ export function ImageCanvasEditorView() { onChange={(event) => { const files = event.currentTarget.files; if (files?.length) { - addUploadedFiles(files); + addUploadedFiles(files, { addToCanvas: activeTool === 'upload' }); } event.currentTarget.value = ''; }} @@ -2146,6 +2350,7 @@ export function ImageCanvasEditorView() { onDragOver={(event) => { if (event.dataTransfer.types.includes('Files')) { event.preventDefault(); + event.stopPropagation(); event.dataTransfer.dropEffect = 'copy'; } }} @@ -2154,6 +2359,7 @@ export function ImageCanvasEditorView() { return; } event.preventDefault(); + event.stopPropagation(); addUploadedFiles(event.dataTransfer.files, { folderId: folder.id }); }} > @@ -2339,6 +2545,7 @@ export function ImageCanvasEditorView() { onDragOver={(event) => { if (event.dataTransfer.types.includes('Files')) { event.preventDefault(); + event.stopPropagation(); event.dataTransfer.dropEffect = 'copy'; } }} @@ -2347,6 +2554,7 @@ export function ImageCanvasEditorView() { return; } event.preventDefault(); + event.stopPropagation(); addUploadedFiles(event.dataTransfer.files, { folderId: asset.folderId, }); @@ -2440,60 +2648,6 @@ export function ImageCanvasEditorView() {

图片编辑器

画布 -
- setIsZoomMenuOpen((open) => !open)} - > - {formatPercent(viewport.scale)} - - {isZoomMenuOpen ? ( - - { - updateScaleFromCenter(viewport.scale * 1.16); - setIsZoomMenuOpen(false); - }} - > - 放大 - - { - updateScaleFromCenter(viewport.scale * 0.86); - setIsZoomMenuOpen(false); - }} - > - 缩小 - - { - fitLayers(); - setIsZoomMenuOpen(false); - }} - > - 显示画布所有元素 - - {[0.5, 1, 2].map((scale) => ( - { - updateScaleFromCenter(scale); - setIsZoomMenuOpen(false); - }} - > - 缩放至{Math.round(scale * 100)}% - - ))} - - ) : null} -
left.zIndex - right.zIndex) .map((layer) => { - const isSelected = selectedLayerId === layer.id; + const isSelected = selectedLayerIds.includes(layer.id); const isHovered = hoveredLayerId === layer.id; return (