diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasHistoryModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasHistoryModel.ts index 9ee3d1ee4..4a625a4ae 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasHistoryModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasHistoryModel.ts @@ -2,8 +2,8 @@ * 资源画布的组织操作历史:只记录资源卡的布局坐标,**不记录、也不回滚素材内容**。 * * 素材不可变是硬约束,所以撤销/重做只作用于「画布上卡片怎么排」,绝不反向修改 - * manifest 条目或素材文件。持久化仍走现役的手动 CAS 写链(`commitPosition`), - * 本模块只做纯函数的快照栈。 + * manifest 条目或素材文件。持久化仍走现役的手动 CAS 写链(`commitPositions` 的一笔批量 + * 写入),本模块只做纯函数的快照栈。 */ import { @@ -82,10 +82,9 @@ export function captureResourceCanvasSnapshot( /** * 两份快照是否算「同一版布局」。 * - * 只比 `section / x / y`:撤销写回走 `commitPosition(resourceId, section, x, y)`, - * `manuallyPlaced` 不在写回入参里(`moveResourceCanvasPosition` 自己会置 true), - * 所以它本来就不是可恢复的维度。把它算进相等判定,只会在「只有这个标记不同」时压进一条 - * 历史,而 `resolveResourceCanvasRestoreEntries` 按坐标过滤后返回空表 —— 撤销变成静默空操作。 + * 比 `section / x / y / manuallyPlaced` 四项。手动标记是**可恢复的维度**:「整理画布」 + * 会把目标栏目里连手动卡一起重排成自动坐标,撤销要恢复的正是"原坐标 + 原手动标记"; + * 只比坐标就会把"坐标恰好重合、标记不同"的那次操作判成没变过,撤销变成静默空操作。 */ export function resourceCanvasSnapshotsEqual( left: ResourceCanvasLayoutSnapshot, @@ -101,7 +100,8 @@ export function resourceCanvasSnapshotsEqual( entry.resourceId === other.resourceId && entry.section === other.section && entry.x === other.x && - entry.y === other.y + entry.y === other.y && + entry.manuallyPlaced === other.manuallyPlaced ); }); } @@ -214,6 +214,8 @@ export function canRedoResourceCanvasHistory(history: ResourceCanvasHistory) { /** * 只回写和快照不一致的位置,避免撤销时把没动过的卡片也当成一次手动摆放写回后端。 + * `manuallyPlaced` 同样参与判定:「整理画布」之后这些卡的坐标可能恰好与整理前一致 + * (例如本来就已经整齐),但标记从手动变成了自动,撤销必须把标记恢复回去。 */ export function resolveResourceCanvasRestoreEntries( snapshot: ResourceCanvasLayoutSnapshot, @@ -230,7 +232,8 @@ export function resolveResourceCanvasRestoreEntries( return ( existing.section !== entry.section || existing.x !== entry.x || - existing.y !== entry.y + existing.y !== entry.y || + existing.manuallyPlaced !== entry.manuallyPlaced ); }); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index d4a9a3219..27b8867ad 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -238,7 +238,6 @@ import { import { buildResourceBookScenePlan, resolveResourceBookAllLayout, - resourceBookAllBandLocalPoint, type ResourceBookAllLayoutCache, type ResourceBookOverviewRect, resourceBookSceneCardKey, @@ -280,6 +279,7 @@ import { createResourceCanvasCardSizeByResourceId, fitResourceCanvasViewportToContent, normalizeInfiniteResourceCanvasViewport, + type ResourceCanvasPositionWrite, RESOURCE_CANVAS_CARD_HEIGHT, RESOURCE_CANVAS_CARD_WIDTH, RESOURCE_CANVAS_DRAG_THRESHOLD, @@ -369,6 +369,56 @@ type ResourceSortMode = ProjectResourceCanvasLayoutMode; type WorkbenchMode = 'resources' | 'run'; type OrchestrationMode = 'single-supervisor' | 'professional-dag'; +/** + * 一次资源卡拖动参与位移的卡集合与它们的起始局部坐标。 + * + * 判据只认**当前画布可见的选中集**:按住已选中的卡时整批跟随,按住未选中的卡时只有它自己 + * (保持既有单选语义)。集合按 `visibleResourceIds`(筛选后可见)与当前栏目页的栏目双重 + * 过滤——被筛选藏起来的、以及停留在其他栏目里的残留选中都不参与位移,也不会被写回坐标。 + * 起始坐标取拖动开始那一刻的布局局部坐标(`positions`),不随拖动过程中的重算漂移。 + */ +export function resolveResourceCardDragMoves({ + resource, + resources, + selectedResourceIds, + visibleResourceIds, + positions, + opensAllResources, + activePageCategory, +}: { + resource: ProjectResource; + resources: readonly ProjectResource[]; + selectedResourceIds: readonly string[]; + visibleResourceIds: ReadonlySet; + positions: ReadonlyMap; + opensAllResources: boolean; + activePageCategory: ResourceCategory | null; +}) { + const moveFor = (candidate: ProjectResource) => { + const position = positions.get(candidate.id); + return { + resourceId: candidate.id, + section: candidate.category, + startX: position?.x ?? 0, + startY: position?.y ?? 0, + }; + }; + const selected = new Set(selectedResourceIds); + if (!selected.has(resource.id)) { + return [moveFor(resource)]; + } + const movable = resources.filter( + (candidate) => + selected.has(candidate.id) && + visibleResourceIds.has(candidate.id) && + (opensAllResources || candidate.category === activePageCategory), + ); + const moves = movable.map(moveFor); + // 被选中的卡一定在画布里渲染得出来;真出现过滤后为空(例如集合已过期)时退回单选, + // 至少让用户按住的那张卡跟着指针走。 + return moves.length > 0 ? moves : [moveFor(resource)]; +} + function isPlatformAuthenticationRequired(error: unknown) { const message = error instanceof Error ? error.message : String(error); return ( @@ -1300,6 +1350,7 @@ function ResourceBookScene({ onPointerMove, onPointerUp, onPointerCancel, + onLostPointerCapture, }: { plan: ResourceBookSceneCategoryPlan[]; resourceBookState: ResourceBookState; @@ -1329,6 +1380,11 @@ function ResourceBookScene({ onPointerMove: (event: ReactPointerEvent) => void; onPointerUp: (event: ReactPointerEvent) => void; onPointerCancel: (event: ReactPointerEvent) => void; + /** + * 指针捕获丢失时的收口。缺省沿用 `onPointerCancel`(只负责画布平移/框选),资源卡拖动 + * 需要在这一刻按取消回滚,因此工作台会传一个同时终止两者的处理器。 + */ + onLostPointerCapture?: (event: ReactPointerEvent) => void; }) { const sceneViewport = normalizeResourceBookViewport( resourceBookState.view === 'main' ? mainViewport : viewport, @@ -1382,7 +1438,7 @@ function ResourceBookScene({ onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerCancel} - onLostPointerCapture={onPointerCancel} + onLostPointerCapture={onLostPointerCapture ?? onPointerCancel} >
; resourceId: string; section: ResourceCategory; startClientX: number; startClientY: number; startScale: number; - startX: number; - startY: number; + /** 拖动开始时的项目作用域:切项目后松手不得把位移写进新项目。 */ + scopeKey: string; changed: boolean; } | null>(null); const [resourceCardDragPreview, setResourceCardDragPreview] = useState<{ resourceId: string; - x: number; - y: number; + /** 整次选择集共用的位移(世界坐标单位,已按 scale 换算)。 */ + deltaX: number; + deltaY: number; + movedResourceIds: readonly string[]; } | null>(null); const resourceDependencyOverlayRef = useRef(null); @@ -2614,6 +2684,9 @@ export default function ProjectDevelopmentView({ ), [resourceLayout.positions], ); + /** 拖动起点坐标的读取口:`pointerdown` 与渲染同源,避免两处各取一份布局快照。 */ + const resourceLayoutPositionsRef = useRef(resourcePositionById); + resourceLayoutPositionsRef.current = resourcePositionById; const selectedVersionBindingResourceIds = useMemo(() => { const selectedVersion = resources.find( (resource) => resource.id === selectedResourceId, @@ -3508,11 +3581,13 @@ export default function ProjectDevelopmentView({ } return signatures; }, [resourceBookScenePlan, resourceBookOverviewRects]); - // 拖拽中的卡片只跟指针走,不参与布局 FLIP。 + // 拖拽中的卡片只跟指针走,不参与布局 FLIP;多选时整批移动的卡一起冻结。 const resourceBookFrozenMotionKeys = useMemo(() => { const keys = new Set(); if (resourceCardDragPreview) { - keys.add(resourceBookSceneCardKey(resourceCardDragPreview.resourceId)); + for (const resourceId of resourceCardDragPreview.movedResourceIds) { + keys.add(resourceBookSceneCardKey(resourceId)); + } } return keys; }, [resourceCardDragPreview]); @@ -4515,17 +4590,27 @@ export default function ProjectDevelopmentView({ const applyResourceCanvasLayoutSnapshot = useCallback( (snapshot: ResourceCanvasLayoutSnapshot) => { - resolveResourceCanvasRestoreEntries( + /** + * 撤销/重做一次只写一笔:整份快照里与当前布局不同的位置合成一次批量写入(含 + * `manuallyPlaced`),不再每张卡排一笔 CAS。写回顺序、失败提示与冲突恢复都沿用 + * 既有写队列。 + */ + const entries = resolveResourceCanvasRestoreEntries( snapshot, activeResourceLayout.layout.positions, - ).forEach((entry) => { - activeResourceLayout.commitPosition( - entry.resourceId, - entry.section, - entry.x, - entry.y, - ); - }); + ); + if (entries.length === 0) { + return; + } + activeResourceLayout.commitPositions( + entries.map((entry) => ({ + resourceId: entry.resourceId, + section: entry.section, + x: entry.x, + y: entry.y, + manuallyPlaced: entry.manuallyPlaced, + })), + ); }, [activeResourceLayout], ); @@ -4569,6 +4654,52 @@ export default function ProjectDevelopmentView({ resourceCanvasHistory, ); + /** + * 「整理画布」的作用范围。 + * + * - 栏目页:当前栏目这一栏,栏内**全部**素材(含手动摆放过的卡,筛选也不缩小集合); + * - 「所有资源」页:当前项目所有可展示素材,即全部栏目; + * - 没有当前栏目(空项目)时范围为空,按钮不产生任何写入。 + * + * 其他栏目不在范围内:既有的自动坐标、手动坐标都逐值不动。 + */ + const resourceOrganizeSections = useMemo< + readonly ResourceCategory[] | null + >(() => { + if (resourceBookOpensAllResources) { + return null; + } + return activePageCategory ? [activePageCategory] : []; + }, [activePageCategory, resourceBookOpensAllResources]); + + /** + * 整理是一次可撤销操作:先按"整理前"的坐标与手动标记记一笔历史,再交给布局写链按 + * 目标栏目重算并落盘。失败/冲突仍由布局 hook 的既有提示与恢复分支承担。 + * + * 已经整齐、这一按不会改变任何坐标与标记时不记历史、不落盘:撤销栈里不该出现 + * "按了却什么也没做"的一步。 + */ + const organizeResourceCanvasLayout = useCallback(() => { + if ( + resourceOrganizeSections !== null && + resourceOrganizeSections.length === 0 + ) { + return; + } + const snapshot = captureResourceCanvasSnapshot( + activeResourceLayout.layout.positions, + ); + if (!activeResourceLayout.organizeNow(resourceOrganizeSections)) { + return; + } + setResourceCanvasHistory((history) => + pushResourceCanvasHistory(history, { + label: '整理画布', + snapshot, + }), + ); + }, [activeResourceLayout, resourceOrganizeSections]); + useEffect(() => { const handleHistoryShortcut = (event: KeyboardEvent) => { if (!(event.ctrlKey || event.metaKey) || event.altKey) { @@ -4792,6 +4923,8 @@ export default function ProjectDevelopmentView({ const handleBlur = () => { resourceCanvasSpacePanRef.current = false; cancelResourceCanvasPan(); + // 窗口失焦=这次拖动不会再有可信的 pointerup:按取消处理,回滚到拖动前坐标。 + cancelResourceCardDrag(); setResourceCanvasMarquee(null); }; window.addEventListener('keydown', handleKeyDown); @@ -4802,7 +4935,7 @@ export default function ProjectDevelopmentView({ window.removeEventListener('keyup', handleKeyUp); window.removeEventListener('blur', handleBlur); }; - }, [cancelResourceCanvasPan]); + }, [cancelResourceCanvasPan, cancelResourceCardDrag]); const handleResourceBookWheel = useCallback( (event: ReactWheelEvent | WheelEvent) => { @@ -4959,8 +5092,6 @@ export default function ProjectDevelopmentView({ ( event: ReactPointerEvent, resource: ProjectResource, - x: number, - y: number, ) => { if (event.button !== 0) { return; @@ -5000,20 +5131,32 @@ export default function ProjectDevelopmentView({ resourceCardDragRef.current = { pointerId: event.pointerId, captureTarget: event.currentTarget, + moves: resolveResourceCardDragMoves({ + resource, + resources: canvasResources, + selectedResourceIds, + visibleResourceIds, + positions: resourceLayoutPositionsRef.current, + opensAllResources: resourceBookOpensAllResources, + activePageCategory, + }), resourceId: resource.id, section: resource.category, startClientX: event.clientX, startClientY: event.clientY, startScale: scale, - startX: x, - startY: y, + scopeKey: resourceCategoryScopeKey, changed: false, }; }, [ activePageCategory, + canvasResources, + resourceCategoryScopeKey, resourceBookOpensAllResources, resourceReplacementPickMode, + selectedResourceIds, + visibleResourceIds, ], ); @@ -5035,15 +5178,22 @@ export default function ProjectDevelopmentView({ skipNextResourceCardClickRef.current = drag.resourceId; } drag.changed = true; - const coordinate = (start: number, delta: number) => - start + delta / drag.startScale; - const preview = { + // 位移只有一份:整张选择集按同一个世界坐标位移走,渲染时每张卡各自加上它(见 + // `renderResourceBookCard`),落盘时再按同一尺度换成栏目内局部坐标。 + const scaledDeltaX = deltaX / drag.startScale; + const scaledDeltaY = deltaY / drag.startScale; + setResourceCardDragPreview({ resourceId: drag.resourceId, - x: coordinate(drag.startX, deltaX), - y: coordinate(drag.startY, deltaY), - }; - setResourceCardDragPreview(preview); - resourceDependencyOverlayRef.current?.updateDragPreview(preview); + deltaX: scaledDeltaX, + deltaY: scaledDeltaY, + movedResourceIds: drag.moves.map((move) => move.resourceId), + }); + const primary = drag.moves[0]; + resourceDependencyOverlayRef.current?.updateDragPreview({ + resourceId: drag.resourceId, + x: (primary?.startX ?? 0) + scaledDeltaX, + y: (primary?.startY ?? 0) + scaledDeltaY, + }); }, [], ); @@ -5071,55 +5221,50 @@ export default function ProjectDevelopmentView({ } else { deferSkippedResourceCardClickCleanup(drag.resourceId); } - const preview = drag.changed - ? { - resourceId: drag.resourceId, - x: - drag.startX + - (event.clientX - drag.startClientX) / drag.startScale, - y: - drag.startY + - (event.clientY - drag.startClientY) / drag.startScale, - } - : null; setResourceCardDragPreview(null); - if (commit && drag.changed && preview?.resourceId === drag.resourceId) { - // 先落"拖动前"的快照,撤销才有回退点;它只含布局坐标。 - setResourceCanvasHistory((history) => - pushResourceCanvasHistory(history, { - label: '移动资源卡', - snapshot: captureResourceCanvasSnapshot( - activeResourceLayout.layout.positions, - ), - }), - ); - /** - * 拖动算出来的 `preview` 与画面同系:栏目页就是栏目内局部坐标,展开态则是 - * "局部坐标 + 带原点"。落盘只认栏目内局部坐标(`section` 就是资源当前分类, - * 没有 `all` 这个分区),所以展开态提交前必须减掉带原点——写回去的正是这张卡 - * 在它自己栏目里的同一个槽位,栏目页立刻看到同一位置,不存在第二份空间。 - * 栏目页 `layout` 传 null,减 0,与原行为逐字等价。 - */ - const local = resourceBookAllBandLocalPoint({ - layout: resourceBookOpensAllResources ? resourceBookAllLayout : null, - category: drag.section, - x: preview.x, - y: preview.y, - }); - activeResourceLayout.commitPosition( - drag.resourceId, - drag.section, - local.x, - local.y, - ); + // 取消手势(pointercancel / 捕获丢失 / 窗口失焦):拖动期间从未改写坐标,丢掉预览即 + // 回到拖动前的位置;已选中的多选保持不动,不在这里清选择。 + if (!commit || !drag.changed) { + return; } + // 拖动开始后切了项目:位移只属于原项目,松手不得写进新项目。 + if (drag.scopeKey !== resourceCategoryScopeKey) { + return; + } + const deltaX = (event.clientX - drag.startClientX) / drag.startScale; + const deltaY = (event.clientY - drag.startClientY) / drag.startScale; + /** + * 每张卡各自的位移与整批相同:卡片画的是"栏目内局部坐标 + 该栏目的带原点"(展开态), + * 带原点在一次拖动期间冻结,同一个世界坐标位移换成局部位移后逐卡拉平——于是展开态 + * 与栏目页共用同一条换算,不需要按卡再减一次带原点,也不存在第二份空间。 + */ + const writes: ResourceCanvasPositionWrite[] = drag.moves.map((move) => ({ + resourceId: move.resourceId, + section: move.section, + x: move.startX + deltaX, + y: move.startY + deltaY, + manuallyPlaced: true, + })); + if (writes.length === 0) { + return; + } + // 先落"拖动前"的快照,撤销才有回退点;它只含布局坐标与手动标记。 + setResourceCanvasHistory((history) => + pushResourceCanvasHistory(history, { + label: writes.length > 1 ? '移动所选资源卡' : '移动资源卡', + snapshot: captureResourceCanvasSnapshot( + activeResourceLayout.layout.positions, + ), + }), + ); + // 整批一次提交:一张卡与多张卡走的都是这一次批量写入,撤销也只回滚这一次。 + activeResourceLayout.commitPositions(writes); }, [ activeResourceLayout, clearSkippedResourceCardClick, deferSkippedResourceCardClickCleanup, - resourceBookAllLayout, - resourceBookOpensAllResources, + resourceCategoryScopeKey, ], ); @@ -5269,6 +5414,22 @@ export default function ProjectDevelopmentView({ [resourceCanvasMarquee], ); + /** + * 场景根上的「指针捕获丢失」总收口。 + * + * 资源卡把指针捕获在**自己**身上,`lostpointercapture` 因此先在卡片上触发、再冒泡到这里; + * 这条路径既要终止画布平移/框选,也要按取消结束资源卡拖动——否则捕获一丢,拖动预览再也 + * 收不到 pointerup,卡片会一直挂在指针上。两个处理器各自按 `pointerId` 判据过滤, + * 与本次指针无关的事件不会误伤另一个手势。 + */ + const handleResourceCanvasLostPointerCapture = useCallback( + (event: ReactPointerEvent) => { + stopResourceCanvasPan(event); + handleResourceCardPointerCancel(event); + }, + [handleResourceCardPointerCancel, stopResourceCanvasPan], + ); + function showResourceSortMode(nextMode: ResourceSortMode) { if (nextMode === sortMode) { return; @@ -6297,11 +6458,12 @@ export default function ProjectDevelopmentView({ return null; } const dragPreview = - resourceCardDragPreview?.resourceId === resource.id + resourceCardDragPreview?.movedResourceIds.includes(resource.id) ? resourceCardDragPreview : null; - const previewX = dragPreview ? dragPreview.x : undefined; - const previewY = dragPreview ? dragPreview.y : undefined; + // 整张选择集按同一个位移走:每张卡各自加上同一份位移,相对位置保持原样。 + const previewX = dragPreview ? layout.x + dragPreview.deltaX : undefined; + const previewY = dragPreview ? layout.y + dragPreview.deltaY : undefined; return ( - handleResourceCardPointerDown( - event, - resource, - layout.dragX, - layout.dragY, - ) + handleResourceCardPointerDown(event, resource) } onPointerMove={handleResourceCardPointerMove} onPointerUp={handleResourceCardPointerUp} @@ -7818,16 +7975,21 @@ export default function ProjectDevelopmentView({ 样式(有边圆角 + secondary 填充),与分段 pill 的模式切换一眼可分;因此不 新增任何 CSS。**不要放到这一行的行尾**:行尾会被读成“针对整个工具条”的动作。 - 画布不再自动重排,整张整理只由这一次显式动作发起:丢掉全部自动坐标、按当前 - 资源与拓扑重算(手动拖过的卡原地不动)。进行中的保存/重算仍然由既有的 - 「保存中 / 布局已保存」状态位反馈。 + 画布不再自动重排,整张整理只由这一次显式动作发起:目标栏目(「所有资源」页 + 为全部栏目)内**全部**素材连手动坐标一起丢掉、按当前资源与拓扑重算成自动 + 坐标,其他栏目一格不动;筛选不缩小整理集合。一次整理=一笔可撤销操作, + 进行中的保存/重算仍然由既有的「保存中 / 布局已保存」状态位反馈。 */}