From f4131c0421928bd1204694af14d655a5cf3b98f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 7 Aug 2026 13:17:33 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=B4=A0=E6=9D=90=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E6=A8=A1=E5=BC=8F=E4=BA=A4=E4=BA=92=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 阻断选择模式下的素材移动与外部文件投放并清理拖拽状态 固定隐藏素材删除确认目标并在选择范围漂移时关闭弹窗 收敛选择交互契约与素材行标签分支 修正短视口侧栏高度并补充定向回归测试和文档 --- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- .../ImageCanvasAssetFolderSectionView.tsx | 23 ++++++ .../ImageCanvasAssetLibraryPanelView.tsx | 62 +++++++++++++--- .../ImageCanvasAssetRowView.test.tsx | 57 +++++++++------ .../image-editor/ImageCanvasAssetRowView.tsx | 64 ++++++++++------ ...mageCanvasEditorAssetsIntegration.test.tsx | 73 ++++++++++++++++++- .../image-editor/ImageCanvasEditorView.tsx | 2 + .../image-editor/ImageCanvasSidebarView.tsx | 2 +- .../useImageCanvasAssetLibrary.test.tsx | 12 +-- .../useImageCanvasAssetLibrary.ts | 49 +++++++++---- .../useImageCanvasAssetSelection.ts | 5 -- src/index.css | 4 +- src/index.test.ts | 18 +++++ 13 files changed, 283 insertions(+), 90 deletions(-) diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 782eab873..6294904d4 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -200,7 +200,7 @@ - `生成角色动作` 的完成结果按序列帧素材处理:`assetKind` 固定为 `character-animation`,图层主 `src` 使用首帧,前端内部据 `assetKind` 派生 `CanvasMediaType="image-sequence"` 选择播放器。`previewVideoPath` 只作为上游来源,不作为画布主媒体。下载和 ZIP 导出必须得到序列帧 ZIP / frames 目录,不能回退为预览视频或首帧 PNG;后端抽帧后逐帧保存带绿幕源图,上传时消费并释放原帧字节,再用该对象的 600 秒 OSS URL 完成透明化并落盘透明帧素材。 - 素材库和项目资源 API 不返回独立 `mediaType`;前端只按 `assetKind` 映射渲染器:`character-animation → image-sequence`、`video → video`、`audio/sound-effect/background-music → audio`、其余 → image,不按扩展名猜类型。最终动作 asset / resource 平铺返回 `imageSequenceFrames/imageSequenceDurationMs`;正式帧对象不含 `frameIndex`,数组位置是唯一播放顺序,帧数取数组长度,FPS 按帧数与毫秒时长即时推导。只有至少两帧且播放时长大于 0 的角色动作素材才能进入序列播放器,否则按损坏素材失败关闭。预览视频作为独立 `assetKind=video` 资源保存,最终动作以 `sourceResourceId` 保留来源关系,不重复保存路径。从素材库点击、HTML5 拖放或指针拖放到画布时必须复制完整字段并先创建正式 project resource;新 layout 只保存 resource 引用和几何,不保存帧集或预览视频,刷新后从 project resource 恢复循环播放。历史 `local-*` 自包含动作布局仍可读取。 - 素材面板支持按素材名、文件夹名、生成信息、用户可见模型、任务和媒体类型搜索,并支持选择模式框选。选择模式的选中集合以全部上传完成且媒体地址有效的可选素材为有效性边界,不因搜索、折叠或展开变化而收缩;只有素材被删除、进入上传中 / 失败态或媒体地址失效时才剔除对应选择及范围锚点。素材缩略图、标题、元数据和卡片空白区统一作为整行选择命中区:鼠标、键盘、触摸和笔输入单击都只切换当前素材,不替换其它已选素材;`Shift + 点击` 按当前可见顺序把连续区间增量加入现有选择,锚点当前不可见时退化为切换目标素材并建立新锚点。当前搜索结果的全选 / 取消全选只增量增删当前已展开的可见素材并保留其它选择,同时清空上次单项选择的范围锚点。触摸素材卡仍可切换选中态,但触摸列表空白区必须继续滚动,不能捕获为框选;鼠标 / 笔框选坐标必须包含列表滚动偏移,`pointerup` 提交最后坐标,`pointercancel` 只取消。内置、上传未完成、上传失败或无可读来源的素材行不得暴露可用的选择按钮。退出选择模式、关闭素材栏或切换到图层栏时必须同时清空选择锚点、选中集合和框选状态,非选择模式不得残留选中高亮。 -- 素材选择模式的批量工具栏是素材列表滚动区之外的固定底栏,始终显示跨搜索与折叠状态保留的全部已选数量、当前可见素材全选 / 取消全选、下载、删除和取消;下载和删除作用于完整选中集合,不得只消费当前可见 ID。删除集合中存在当前未显示素材时,必须先用危险确认弹窗明确展示全部删除数量和未显示数量,确认前不得执行删除。工具栏自身禁止横向溢出和滚动,空间不足时按钮在工具栏内部换行。不得把工具栏作为滚动列表末项或依赖 `position: sticky` 的自然位置。移动端进入素材选择模式时必须提高素材侧栏高度并允许素材列表纵向滚动,为固定底栏之上至少保留一张素材卡的可操作空间,不得沿用普通模式 `14rem` 上限压扁列表。选择模式隐藏单素材下载 / 重命名动作并禁用素材拖拽和右键菜单,避免批量意图回退成单素材操作。Provider 与 `isEditorInternalProcessingModel(...)` 识别的内部处理模型不得进入搜索索引,避免出现隐藏字段可命中或命中但无可见匹配字段;原始审计元数据只保留给服务端和后台管理读取,普通用户(包括素材 owner)不得通过响应取得。 +- 素材选择模式的批量工具栏是素材列表滚动区之外的固定底栏,始终显示跨搜索与折叠状态保留的全部已选数量、当前可见素材全选 / 取消全选、下载、删除和取消;下载和删除作用于完整选中集合,不得只消费当前可见 ID。删除集合中存在当前未显示素材时,必须先用危险确认弹窗明确展示全部删除数量和未显示数量,确认目标使用打开弹窗时的素材 ID 快照;选择集合、可见集合或选择模式漂移后关闭旧确认弹窗,确认前不得执行删除。工具栏自身禁止横向溢出和滚动,空间不足时按钮在工具栏内部换行。不得把工具栏作为滚动列表末项或依赖 `position: sticky` 的自然位置。移动端进入素材选择模式时必须提高素材侧栏高度并允许素材列表纵向滚动,为固定底栏之上至少保留一张素材卡的可操作空间,同时受当前视口高度上限约束,不得在横屏或嵌入式短视口中挤占画布。选择模式隐藏单素材下载 / 重命名动作,进入时清理已有拖拽 / 投放状态,并同时阻断素材行、文件夹空白区的内部素材移动和外部文件投放以及右键菜单,避免批量意图回退成单素材操作。Provider 与 `isEditorInternalProcessingModel(...)` 识别的内部处理模型不得进入搜索索引,避免出现隐藏字段可命中或命中但无可见匹配字段;原始审计元数据只保留给服务端和后台管理读取,普通用户(包括素材 owner)不得通过响应取得。 - 图层面板支持按图层名、生成信息、用户可见模型、任务和媒体类型搜索;Provider 与内部处理模型同样不得进入搜索索引。普通用户图层快照也不得携带这些内部字段。支持选择多个图层后创建图层组,组名和 groupId 随画布布局保存。 - 小地图支持拖拽视口框,拖动时画布 viewport 跟随移动;`pointermove` 更新必须通过 `requestAnimationFrame` 合帧,结束拖拽时 flush 最后一帧,避免高频 pointermove 直接压垮 React 渲染和项目持久化链路。 - 鼠标滚轮默认按设备原始 `deltaX / deltaY` 二维平移画布视口;当按住 Shift 且设备上报 `deltaX = 0` 时,视口输入适配层把 `deltaY` 映射为横向位移并将纵向位移置零,核心平移模型仍只消费归一化后的 X / Y。按住 Ctrl / Cmd 滚轮才缩放画布,并阻止浏览器页面缩放。缩放比例显示保持现有换算口径,最低可缩小到 `5%`。 diff --git a/src/components/image-editor/ImageCanvasAssetFolderSectionView.tsx b/src/components/image-editor/ImageCanvasAssetFolderSectionView.tsx index cfd562459..392b8900f 100644 --- a/src/components/image-editor/ImageCanvasAssetFolderSectionView.tsx +++ b/src/components/image-editor/ImageCanvasAssetFolderSectionView.tsx @@ -117,6 +117,17 @@ export function ImageCanvasAssetFolderSectionView({ aria-label={folder.label} data-asset-folder-id={folder.id} onDragOver={(event) => { + if (isAssetSelectionMode) { + if ( + hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE) || + hasDataTransferType(event.dataTransfer, 'Files') + ) { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'none'; + } + return; + } if (hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE)) { event.preventDefault(); event.stopPropagation(); @@ -133,6 +144,18 @@ export function ImageCanvasAssetFolderSectionView({ } }} onDrop={(event) => { + if (isAssetSelectionMode) { + if ( + getDraggedAssetId(event.dataTransfer) || + event.dataTransfer.files.length > 0 + ) { + event.preventDefault(); + event.stopPropagation(); + setUploadDropTarget(null); + updateAssetMoveDropFolder(null); + } + return; + } const movingAssetId = getDraggedAssetId(event.dataTransfer); if (movingAssetId) { event.preventDefault(); diff --git a/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx b/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx index f0cfc07de..ac8d673b8 100644 --- a/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx +++ b/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx @@ -13,7 +13,7 @@ import type { RefObject, SetStateAction, } from 'react'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar'; @@ -110,7 +110,7 @@ export type ImageCanvasAssetLibraryPanelViewProps = { onDownloadAsset: (asset: EditorAsset) => void; toggleAllAssetsSelected: (visibleAssetIds: string[]) => void; downloadSelectedAssets: () => void | Promise; - deleteSelectedAssets: () => void; + deleteSelectedAssets: (assetIds?: ReadonlySet) => void; closeAssetSelectionMode: () => void; }; @@ -165,10 +165,12 @@ export function ImageCanvasAssetLibraryPanelView({ deleteSelectedAssets, closeAssetSelectionMode, }: ImageCanvasAssetLibraryPanelViewProps) { - const [ - hiddenSelectionDeleteConfirmOpen, - setHiddenSelectionDeleteConfirmOpen, - ] = useState(false); + const [hiddenSelectionDeleteSnapshot, setHiddenSelectionDeleteSnapshot] = + useState<{ + assetIds: Set; + visibleAssetIds: string[]; + hiddenAssetCount: number; + } | null>(null); let selectionToggleActionText = '全选'; if (allVisibleAssetsSelected) { selectionToggleActionText = '取消全选'; @@ -182,9 +184,42 @@ export function ImageCanvasAssetLibraryPanelView({ visibleAssetIds, ); + useEffect(() => { + if (!hiddenSelectionDeleteSnapshot) { + return; + } + const selectedIdsUnchanged = + hiddenSelectionDeleteSnapshot.assetIds.size === selectedAssetIds.size && + [...hiddenSelectionDeleteSnapshot.assetIds].every((assetId) => + selectedAssetIds.has(assetId), + ); + const visibleIdsUnchanged = + hiddenSelectionDeleteSnapshot.visibleAssetIds.length === + visibleAssetIds.length && + hiddenSelectionDeleteSnapshot.visibleAssetIds.every( + (assetId, index) => assetId === visibleAssetIds[index], + ); + if ( + !isAssetSelectionMode || + !selectedIdsUnchanged || + !visibleIdsUnchanged + ) { + setHiddenSelectionDeleteSnapshot(null); + } + }, [ + hiddenSelectionDeleteSnapshot, + isAssetSelectionMode, + selectedAssetIds, + visibleAssetIds, + ]); + const requestDeleteSelectedAssets = () => { if (hiddenSelectedAssetCount > 0) { - setHiddenSelectionDeleteConfirmOpen(true); + setHiddenSelectionDeleteSnapshot({ + assetIds: new Set(selectedAssetIds), + visibleAssetIds: [...visibleAssetIds], + hiddenAssetCount: hiddenSelectedAssetCount, + }); return; } deleteSelectedAssets(); @@ -351,14 +386,17 @@ export function ImageCanvasAssetLibraryPanelView({ ) : null} setHiddenSelectionDeleteConfirmOpen(false)} + onClose={() => setHiddenSelectionDeleteSnapshot(null)} onConfirm={() => { - setHiddenSelectionDeleteConfirmOpen(false); - deleteSelectedAssets(); + const targetAssetIds = hiddenSelectionDeleteSnapshot?.assetIds; + setHiddenSelectionDeleteSnapshot(null); + if (targetAssetIds) { + deleteSelectedAssets(targetAssetIds); + } }} > 删除后不可恢复。 diff --git a/src/components/image-editor/ImageCanvasAssetRowView.test.tsx b/src/components/image-editor/ImageCanvasAssetRowView.test.tsx index a75982c49..cb5244150 100644 --- a/src/components/image-editor/ImageCanvasAssetRowView.test.tsx +++ b/src/components/image-editor/ImageCanvasAssetRowView.test.tsx @@ -220,9 +220,6 @@ describe('ImageCanvasAssetRowView', () => { expect(toggleAssetSelected).toHaveBeenCalledWith('asset-1', { visibleAssetIds: ['asset-1'], shiftKey: false, - ctrlKey: false, - metaKey: false, - pointerKind: 'keyboard', }); expect(addAssetLayer).not.toHaveBeenCalled(); expect( @@ -246,7 +243,7 @@ describe('ImageCanvasAssetRowView', () => { expect(toggleAssetSelected).not.toHaveBeenCalled(); }); - it('forwards modifier keys to selection', () => { + it('forwards only the shift modifier used by selection semantics', () => { const toggleAssetSelected = vi.fn(); renderAssetRow({ isAssetSelectionMode: true, toggleAssetSelected }); fireEvent.click(screen.getByText('账号素材A'), { @@ -254,30 +251,42 @@ describe('ImageCanvasAssetRowView', () => { shiftKey: true, }); - expect(toggleAssetSelected).toHaveBeenCalledWith( - 'asset-1', - expect.objectContaining({ - ctrlKey: true, - shiftKey: true, - pointerKind: 'keyboard', - }), - ); + expect(toggleAssetSelected).toHaveBeenCalledWith('asset-1', { + visibleAssetIds: ['asset-1'], + shiftKey: true, + }); }); - it('uses click detail to recognize keyboard selection after a stale touch gesture', () => { - const toggleAssetSelected = vi.fn(); - renderAssetRow({ isAssetSelectionMode: true, toggleAssetSelected }); - const primaryButton = screen.getByRole('button', { - name: '选择素材账号素材A', + it('blocks internal moves and external file drops in selection mode', () => { + const { container, props } = renderAssetRow({ + isAssetSelectionMode: true, }); - - fireEvent.pointerDown(primaryButton, { pointerType: 'touch' }); - fireEvent.click(primaryButton, { detail: 0 }); - - expect(toggleAssetSelected).toHaveBeenCalledWith( - 'asset-1', - expect.objectContaining({ pointerKind: 'keyboard' }), + const row = container.querySelector( + '[data-asset-id="asset-1"]', ); + if (!row) { + throw new Error('asset row should exist'); + } + const fileTransfer = { + types: ['Files'], + files: [new File(['image'], '新增素材.png', { type: 'image/png' })], + dropEffect: 'copy', + getData: vi.fn(() => ''), + }; + fireEvent.dragOver(row, { dataTransfer: fileTransfer }); + fireEvent.drop(row, { dataTransfer: fileTransfer }); + + const assetTransfer = { + types: ['application/x-genarrative-editor-asset'], + files: [], + dropEffect: 'move', + getData: vi.fn(() => 'asset-other'), + }; + fireEvent.dragOver(row, { dataTransfer: assetTransfer }); + fireEvent.drop(row, { dataTransfer: assetTransfer }); + + expect(props.addUploadedFiles).not.toHaveBeenCalled(); + expect(props.moveAssetToFolder).not.toHaveBeenCalled(); }); it('renders rename input and commits with Enter', () => { diff --git a/src/components/image-editor/ImageCanvasAssetRowView.tsx b/src/components/image-editor/ImageCanvasAssetRowView.tsx index a636a630e..77441bcf0 100644 --- a/src/components/image-editor/ImageCanvasAssetRowView.tsx +++ b/src/components/image-editor/ImageCanvasAssetRowView.tsx @@ -90,8 +90,6 @@ export function ImageCanvasAssetRowView({ onDownloadAsset, }: ImageCanvasAssetRowViewProps) { const rowRef = useRef(null); - const selectionPointerKindRef = - useRef('keyboard'); const [menuPosition, setMenuPosition] = useState<{ x: number; y: number; @@ -118,9 +116,6 @@ export function ImageCanvasAssetRowView({ const selectAssetFromEvent = useCallback( (event: ReactMouseEvent) => { - const pointerKind = - event.detail === 0 ? 'keyboard' : selectionPointerKindRef.current; - selectionPointerKindRef.current = 'keyboard'; if ( !isAssetSelectionMode || !canSelectAsset || @@ -132,9 +127,6 @@ export function ImageCanvasAssetRowView({ toggleAssetSelected(asset.id, { visibleAssetIds, shiftKey: event.shiftKey, - ctrlKey: event.ctrlKey, - metaKey: event.metaKey, - pointerKind, }); }, [ @@ -211,6 +203,16 @@ export function ImageCanvasAssetRowView({