diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts index 35c5f56e5..458c31246 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts @@ -54,6 +54,28 @@ type LayoutWriteIntent = ManualLayoutWriteIntent | ResourceLayoutWriteIntent; const MAX_RESOURCE_SYNC_CONFLICT_RETRIES = 2; +/** + * 自动坐标策略。`rederive` 在每次协调时丢弃全部自动坐标并按当前资源与拓扑重算, + * PRD 要求的「关系图首次就绪 / `dependencyDepth` / 拓扑身份签名变化后按最终拓扑 + * 重算」依赖它;`preserve` 只补新资源 ID,不重排任何已存在的坐标。 + */ +type AutomaticPositionPolicy = 'rederive' | 'preserve'; + +function automaticPositionPolicy( + rederiveAutomaticPositions: boolean, +): AutomaticPositionPolicy { + return rederiveAutomaticPositions ? 'rederive' : 'preserve'; +} + +/** + * 拖拽是一次纯手动写入:PRD 给自动重派生的触发条件是「关系图就绪 / 深度 / 拓扑签名 + * 变化」,没有要求「用户拖动本身」触发。所以拖拽写链固定用 `preserve`,不让其它自动卡 + * 补位到被拖走的空位。代价:把卡正好丢在另一张卡上时不再自动挪开它,直到下一次资源 + * 集合或拓扑变化再次触发重派生。 + */ +const MANUAL_WRITE_AUTOMATIC_POSITION_POLICY: AutomaticPositionPolicy = + 'preserve'; + function createScopeKey( projectPath: string, projectId: string, @@ -168,6 +190,33 @@ function positionsEqual( ); } +/** + * 坐标内容比较(忽略数组顺序)。手动写入会把被拖的卡固定在用户放下的坐标,只由排序归一 + * 带来的顺序变化不构成"还要再落盘一次"的变化;只有坐标真的动了、或有新资源需要落位时, + * 才继续排一次资源同步。手动卡被拖走后由它触发的补位正是从这条同步路径漏进来的。 + */ +function automaticCoordinatesMatch( + left: ProjectResourceCanvasLayout, + right: ProjectResourceCanvasLayout, +) { + if (left.positions.length !== right.positions.length) { + return false; + } + const rightByResourceId = new Map( + right.positions.map((position) => [position.resourceId, position]), + ); + return left.positions.every((position) => { + const other = rightByResourceId.get(position.resourceId); + return ( + other !== undefined && + other.section === position.section && + other.x === position.x && + other.y === position.y && + other.manuallyPlaced === position.manuallyPlaced + ); + }); +} + function layoutCoordinatesAreSafe(layout: ProjectResourceCanvasLayout) { return layout.positions.every( (position) => @@ -179,10 +228,10 @@ function layoutCoordinatesAreSafe(layout: ProjectResourceCanvasLayout) { function reconcileLayout( source: ProjectResourceCanvasLayout, resources: ResourceCanvasItem[], - rederiveAutomaticPositions: boolean, + policy: AutomaticPositionPolicy, topology: ResourceCanvasLayoutTopology | undefined, ) { - if (!rederiveAutomaticPositions) { + if (policy === 'preserve') { const reconciled = reconcileResourceCanvasLayout( source, resources, @@ -241,8 +290,12 @@ export function useProjectResourceCanvasLayout({ const fallback = useMemo(() => { const empty = createEmptyResourceCanvasLayout(projectId, mode); return initializationReady || renderFallbackWhileBlocked - ? reconcileLayout(empty, resources, rederiveAutomaticPositions, topology) - .layout + ? reconcileLayout( + empty, + resources, + automaticPositionPolicy(rederiveAutomaticPositions), + topology, + ).layout : empty; }, [ initializationReady, @@ -297,7 +350,7 @@ export function useProjectResourceCanvasLayout({ }, []); const rebuildOptimisticLayout = useCallback( - (scopeEpoch: number) => { + (scopeEpoch: number, policy: AutomaticPositionPolicy) => { const scope = scopeRef.current; if (scope.epoch !== scopeEpoch) { return; @@ -305,7 +358,7 @@ export function useProjectResourceCanvasLayout({ let next = reconcileLayout( persistedLayoutRef.current, resourcesRef.current, - rederiveAutomaticPositions, + policy, topologyRef.current, ).layout; for (const intent of writeQueueRef.current) { @@ -321,7 +374,7 @@ export function useProjectResourceCanvasLayout({ } applyLayout(next); }, - [applyLayout, rederiveAutomaticPositions], + [applyLayout], ); enqueueResourceSyncRef.current = (scopeEpoch, conflictRetries = 0) => { @@ -382,15 +435,19 @@ export function useProjectResourceCanvasLayout({ return; } + const writePolicy = + intent.kind === 'manual' + ? MANUAL_WRITE_AUTOMATIC_POSITION_POLICY + : automaticPositionPolicy(rederiveAutomaticPositions); const reconciled = reconcileLayout( persistedLayoutRef.current, resourcesRef.current, - rederiveAutomaticPositions, + writePolicy, topologyRef.current, ); if (intent.kind === 'resources' && !reconciled.changed) { removeWriteIntent(intent); - rebuildOptimisticLayout(scope.epoch); + rebuildOptimisticLayout(scope.epoch, writePolicy); void Promise.resolve().then(() => pumpWritesRef.current()); return; } @@ -403,7 +460,7 @@ export function useProjectResourceCanvasLayout({ ) ) { removeWriteIntent(intent); - rebuildOptimisticLayout(scope.epoch); + rebuildOptimisticLayout(scope.epoch, writePolicy); void Promise.resolve().then(() => pumpWritesRef.current()); return; } @@ -421,7 +478,7 @@ export function useProjectResourceCanvasLayout({ if (!invoke) { persistedLayoutRef.current = candidate; removeWriteIntent(intent); - rebuildOptimisticLayout(scope.epoch); + rebuildOptimisticLayout(scope.epoch, writePolicy); const hasQueuedWrite = writeQueueRef.current.some( (queued) => queued.scopeEpoch === scope.epoch, ); @@ -440,7 +497,7 @@ export function useProjectResourceCanvasLayout({ !layoutCoordinatesAreSafe(candidate) ) { removeWriteIntent(intent); - rebuildOptimisticLayout(scope.epoch); + rebuildOptimisticLayout(scope.epoch, writePolicy); if (intent.kind === 'manual') { redragRequiredScopeEpochRef.current = null; } @@ -485,19 +542,25 @@ export function useProjectResourceCanvasLayout({ } persistedLayoutRef.current = result.layout; removeWriteIntent(intent); + const reconciledAfterWrite = reconcileLayout( + result.layout, + resourcesRef.current, + writePolicy, + topologyRef.current, + ); + const needsResourceSync = + intent.kind === 'manual' + ? !automaticCoordinatesMatch( + result.layout, + reconciledAfterWrite.layout, + ) + : reconciledAfterWrite.changed; if (result.status === 'updated') { if (intent.kind === 'manual') { redragRequiredScopeEpochRef.current = null; setNotice('布局已保存'); } - if ( - reconcileLayout( - result.layout, - resourcesRef.current, - rederiveAutomaticPositions, - topologyRef.current, - ).changed - ) { + if (needsResourceSync) { enqueueResourceSyncRef.current(currentScope.epoch); } } else { @@ -511,12 +574,6 @@ export function useProjectResourceCanvasLayout({ queued.scopeEpoch !== currentScope.epoch || queued.kind !== 'manual', ); - const needsResourceSync = reconcileLayout( - result.layout, - resourcesRef.current, - rederiveAutomaticPositions, - topologyRef.current, - ).changed; const nextRetry = intent.kind === 'resources' ? intent.conflictRetries + 1 : 0; const willRetryResourceSync = @@ -534,7 +591,7 @@ export function useProjectResourceCanvasLayout({ setNotice('布局已在其他窗口更新'); } } - rebuildOptimisticLayout(currentScope.epoch); + rebuildOptimisticLayout(currentScope.epoch, writePolicy); }) .catch(() => { const currentScope = scopeRef.current; @@ -542,7 +599,7 @@ export function useProjectResourceCanvasLayout({ return; } removeWriteIntent(intent); - rebuildOptimisticLayout(currentScope.epoch); + rebuildOptimisticLayout(currentScope.epoch, writePolicy); if (intent.kind === 'manual') { redragRequiredScopeEpochRef.current = null; } @@ -614,7 +671,7 @@ export function useProjectResourceCanvasLayout({ const initialFallback = reconcileLayout( emptyLayout, resourcesRef.current, - rederiveAutomaticPositions, + automaticPositionPolicy(rederiveAutomaticPositions), topologyRef.current, ).layout; persistedLayoutRef.current = initialFallback; @@ -657,13 +714,16 @@ export function useProjectResourceCanvasLayout({ reconcileLayout( loaded, resourcesRef.current, - rederiveAutomaticPositions, + automaticPositionPolicy(rederiveAutomaticPositions), topologyRef.current, ).changed ) { enqueueResourceSyncRef.current(epoch); } - rebuildOptimisticLayout(epoch); + rebuildOptimisticLayout( + epoch, + automaticPositionPolicy(rederiveAutomaticPositions), + ); pumpWritesRef.current(); }) .catch(() => { @@ -673,7 +733,10 @@ export function useProjectResourceCanvasLayout({ persistedLayoutRef.current = initialFallback; initializedScopeEpochRef.current = epoch; setReadyScopeKey(scopeKey); - rebuildOptimisticLayout(epoch); + rebuildOptimisticLayout( + epoch, + automaticPositionPolicy(rederiveAutomaticPositions), + ); setNotice('布局读取失败,已使用当前会话布局'); pumpWritesRef.current(); }); @@ -703,7 +766,7 @@ export function useProjectResourceCanvasLayout({ const reconciledCurrent = reconcileLayout( layoutRef.current, resourcesRef.current, - rederiveAutomaticPositions, + automaticPositionPolicy(rederiveAutomaticPositions), topologyRef.current, ); applyLayout(reconciledCurrent.layout); @@ -712,7 +775,7 @@ export function useProjectResourceCanvasLayout({ reconcileLayout( persistedLayoutRef.current, resourcesRef.current, - rederiveAutomaticPositions, + automaticPositionPolicy(rederiveAutomaticPositions), topologyRef.current, ).changed ) { diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts index 90ba775d2..6e0afa5e8 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts @@ -1079,4 +1079,102 @@ describe('useProjectResourceCanvasLayout', () => { ), ).toBe(true); }); + + it('leaves other automatic cards in place when one card is dragged', async () => { + // 三张同深度自动卡按当前默认口径(层内 2 列网格)落盘:a(0,0) b(228,0) c(0,168)。 + const resources = [ + resource('resource-a'), + resource('resource-b'), + resource('resource-c'), + ]; + const updates: Array<{ + expectedRevision: number; + positions: ProjectResourceCanvasPosition[]; + }> = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_canvas_layout') { + return persistedLayout('dependency', 3, [ + automaticPosition('resource-a', 0, 0), + automaticPosition('resource-b', 228, 0), + automaticPosition('resource-c', 0, 168), + ]); + } + if (command === 'update_local_project_resource_canvas_layout') { + const input = args as { + expectedRevision: number; + positions: ProjectResourceCanvasPosition[]; + }; + updates.push(structuredClone(input)); + return { + status: 'updated', + layout: persistedLayout( + 'dependency', + input.expectedRevision + 1, + structuredClone(input.positions), + ), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'dependency', + resources, + rederiveAutomaticPositions: true, + }), + ); + await waitFor(() => + expect(result.current.layout.positions).toHaveLength(3), + ); + + act(() => + result.current.commitPosition('resource-a', 'document', 900, 640), + ); + + await waitFor(() => + expect( + result.current.layout.positions.find( + ({ resourceId }) => resourceId === 'resource-a', + ), + ).toMatchObject({ x: 900, y: 640, manuallyPlaced: true }), + ); + // 拖动只改被拖的那张:其余自动卡在所有写入里坐标逐项不变,也不会补位到空出的格子。 + expect(updates.length).toBeGreaterThan(0); + for (const write of updates) { + const written = new Map( + write.positions.map((candidate) => [candidate.resourceId, candidate]), + ); + expect(written.size).toBe(3); + expect(written.get('resource-a')).toMatchObject({ + x: 900, + y: 640, + manuallyPlaced: true, + }); + expect(written.get('resource-b')).toMatchObject({ + x: 228, + y: 0, + manuallyPlaced: false, + }); + expect(written.get('resource-c')).toMatchObject({ + x: 0, + y: 168, + manuallyPlaced: false, + }); + } + expect( + result.current.layout.positions.find( + ({ resourceId }) => resourceId === 'resource-b', + ), + ).toMatchObject({ x: 228, y: 0 }); + expect( + result.current.layout.positions.find( + ({ resourceId }) => resourceId === 'resource-c', + ), + ).toMatchObject({ x: 0, y: 168 }); + }); });