import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ProjectResourceCanvasCategory, ProjectResourceCanvasLayout, ProjectResourceCanvasLayoutMode, UpdateProjectResourceCanvasLayoutResult, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { isSafeProjectResourceCanvasCoordinate, isSafeProjectResourceCanvasLayoutRevision, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { createEmptyResourceCanvasLayout, moveResourceCanvasPosition, reconcileResourceCanvasLayout, type ResourceCanvasItem, type ResourceCanvasLayoutTopology, } from './resourceCanvasLayoutModel'; type LayoutNotice = | '' | '布局已保存' | '布局读取失败,已使用当前会话布局' | '布局保存失败,已保留当前会话布局' | '布局保存失败,已恢复上次布局' | '布局已在其他窗口更新'; type LayoutScope = { key: string; epoch: number; projectPath: string; projectId: string; mode: ProjectResourceCanvasLayoutMode; }; type ManualLayoutWriteIntent = { kind: 'manual'; scopeEpoch: number; resourceId: string; section: ProjectResourceCanvasCategory; x: number; y: number; }; type ResourceLayoutWriteIntent = { kind: 'resources'; scopeEpoch: number; resourceSignature: string; conflictRetries: number; }; type LayoutWriteIntent = ManualLayoutWriteIntent | ResourceLayoutWriteIntent; const MAX_RESOURCE_SYNC_CONFLICT_RETRIES = 2; function createScopeKey( projectPath: string, projectId: string, mode: ProjectResourceCanvasLayoutMode, ) { return JSON.stringify([projectPath, projectId, mode]); } export function createResourceSignature(resources: ResourceCanvasItem[]) { return resources .map((resource) => JSON.stringify([ resource.id, resource.category, resource.subtype, resource.label, resource.dependencyDepth, resource.mediaType, resource.cardSize?.width ?? null, resource.cardSize?.height ?? null, ]), ) .sort() .join('\n'); } /** * A bounded, identity-only input to dependency automatic placement. Display * labels deliberately stay out: the resource list's coordination signature * already covers them, while topology changes must be detectable even when * every depth and resource count is unchanged. */ export function createResourceTopologySignature( topology: ResourceCanvasLayoutTopology | undefined, ) { if (!topology) { return ''; } const entries = [ ...topology.referenceEdges .map( (edge) => `r:${JSON.stringify([edge.sourceResourceId, edge.targetResourceId])}`, ) .sort(compareStableText), ...topology.taskFlows .map( (flow) => `f:${JSON.stringify([ [...new Set(flow.sourceResourceIds)].sort(compareStableText), [...new Set(flow.targetResourceIds)].sort(compareStableText), ])}`, ) .sort(compareStableText), ]; return stableTopologyDigest([...new Set(entries)].sort(compareStableText)); } function compareStableText(left: string, right: string) { return left < right ? -1 : left > right ? 1 : 0; } /** * Keeps the sidecar coordination input fixed-size even for a large graph. * The canonical entries retain all stable endpoint and flow-member identities * while being digested; labels and browser geometry never participate. */ function stableTopologyDigest(entries: readonly string[]) { let forward = 0x811c9dc5; let reverse = 0x9e3779b9; for (const entry of entries) { for (let index = 0; index < entry.length; index += 1) { forward = Math.imul(forward ^ entry.charCodeAt(index), 0x01000193); reverse = Math.imul( reverse ^ entry.charCodeAt(entry.length - index - 1), 0x85ebca6b, ); } forward = Math.imul(forward ^ 10, 0x01000193); reverse = Math.imul(reverse ^ 10, 0x85ebca6b); } return `${(entries.length >>> 0).toString(16).padStart(8, '0')}:${( forward >>> 0 ) .toString(16) .padStart(8, '0')}:${(reverse >>> 0).toString(16).padStart(8, '0')}`; } function layoutMatchesScope( layout: ProjectResourceCanvasLayout, scope: LayoutScope, ) { return layout.projectId === scope.projectId && layout.mode === scope.mode; } function positionsEqual( left: ProjectResourceCanvasLayout['positions'], right: ProjectResourceCanvasLayout['positions'], ) { return ( left.length === right.length && left.every((position, index) => { const other = right[index]; return ( other?.resourceId === position.resourceId && 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) => isSafeProjectResourceCanvasCoordinate(position.x) && isSafeProjectResourceCanvasCoordinate(position.y), ); } function reconcileLayout( source: ProjectResourceCanvasLayout, resources: ResourceCanvasItem[], rederiveAutomaticPositions: boolean, topology: ResourceCanvasLayoutTopology | undefined, ) { if (!rederiveAutomaticPositions) { const reconciled = reconcileResourceCanvasLayout( source, resources, topology, ); return layoutCoordinatesAreSafe(reconciled.layout) ? reconciled : { layout: source, changed: false }; } const manualSource = { ...source, positions: source.positions.filter((position) => position.manuallyPlaced), }; const reconciled = reconcileResourceCanvasLayout( manualSource, resources, topology, ); const result = { layout: reconciled.layout, changed: !positionsEqual(source.positions, reconciled.layout.positions), }; return layoutCoordinatesAreSafe(result.layout) ? result : { layout: source, changed: false }; } export function useProjectResourceCanvasLayout({ projectPath, projectId, mode, resources, topology, initializationReady = true, renderFallbackWhileBlocked = false, rederiveAutomaticPositions = false, }: { projectPath: string; projectId: string; mode: ProjectResourceCanvasLayoutMode; resources: ResourceCanvasItem[]; topology?: ResourceCanvasLayoutTopology; initializationReady?: boolean; renderFallbackWhileBlocked?: boolean; rederiveAutomaticPositions?: boolean; }) { const topologySignature = useMemo( () => createResourceTopologySignature(topology), [topology], ); const resourceSignature = useMemo( () => [createResourceSignature(resources), topologySignature].join('\n'), [resources, topologySignature], ); const scopeKey = createScopeKey(projectPath, projectId, mode); const fallback = useMemo(() => { const empty = createEmptyResourceCanvasLayout(projectId, mode); return initializationReady || renderFallbackWhileBlocked ? reconcileLayout(empty, resources, rederiveAutomaticPositions, topology) .layout : empty; }, [ initializationReady, mode, projectId, rederiveAutomaticPositions, renderFallbackWhileBlocked, resources, topology, ]); const [layout, setLayout] = useState(fallback); const [notice, setNotice] = useState(''); const [saving, setSaving] = useState(false); const [readyScopeKey, setReadyScopeKey] = useState(null); const mountedRef = useRef(true); const layoutRef = useRef(fallback); const persistedLayoutRef = useRef(fallback); const resourcesRef = useRef(resources); const topologyRef = useRef(topology); const resourceSignatureRef = useRef(resourceSignature); const initializedScopeEpochRef = useRef(null); const scopeRef = useRef({ key: scopeKey, epoch: 0, projectPath, projectId, mode, }); const writeQueueRef = useRef([]); const activeWriteIntentRef = useRef(null); const redragRequiredScopeEpochRef = useRef(null); const pumpWritesRef = useRef<() => void>(() => undefined); const enqueueResourceSyncRef = useRef< (scopeEpoch: number, conflictRetries?: number) => void >(() => undefined); resourcesRef.current = resources; topologyRef.current = topology; resourceSignatureRef.current = resourceSignature; const applyLayout = useCallback((next: ProjectResourceCanvasLayout) => { layoutRef.current = next; if (mountedRef.current) { setLayout(next); } }, []); const removeWriteIntent = useCallback((intent: LayoutWriteIntent) => { writeQueueRef.current = writeQueueRef.current.filter( (queued) => queued !== intent, ); }, []); const rebuildOptimisticLayout = useCallback( (scopeEpoch: number) => { const scope = scopeRef.current; if (scope.epoch !== scopeEpoch) { return; } let next = reconcileLayout( persistedLayoutRef.current, resourcesRef.current, rederiveAutomaticPositions, topologyRef.current, ).layout; for (const intent of writeQueueRef.current) { if (intent.scopeEpoch === scopeEpoch && intent.kind === 'manual') { next = moveResourceCanvasPosition( next, intent.resourceId, intent.section, intent.x, intent.y, ); } } applyLayout(next); }, [applyLayout, rederiveAutomaticPositions], ); enqueueResourceSyncRef.current = (scopeEpoch, conflictRetries = 0) => { const scope = scopeRef.current; if (scope.epoch !== scopeEpoch) { return; } const signature = resourceSignatureRef.current; const queued = writeQueueRef.current.find( (intent): intent is ResourceLayoutWriteIntent => intent.kind === 'resources' && intent.scopeEpoch === scopeEpoch && intent !== activeWriteIntentRef.current, ); if (queued) { if (queued.resourceSignature !== signature) { queued.resourceSignature = signature; queued.conflictRetries = 0; } else { queued.conflictRetries = Math.min( queued.conflictRetries, conflictRetries, ); } } else { writeQueueRef.current.push({ kind: 'resources', scopeEpoch, resourceSignature: signature, conflictRetries, }); } if (mountedRef.current) { setSaving(true); } pumpWritesRef.current(); }; pumpWritesRef.current = () => { if (activeWriteIntentRef.current) { return; } const scope = scopeRef.current; writeQueueRef.current = writeQueueRef.current.filter( (intent) => intent.scopeEpoch === scope.epoch, ); const intent = writeQueueRef.current[0]; if (!intent) { if (mountedRef.current) { setSaving(false); } return; } if (initializedScopeEpochRef.current !== scope.epoch) { if (mountedRef.current) { setSaving(true); } return; } const reconciled = reconcileLayout( persistedLayoutRef.current, resourcesRef.current, rederiveAutomaticPositions, topologyRef.current, ); if (intent.kind === 'resources' && !reconciled.changed) { removeWriteIntent(intent); rebuildOptimisticLayout(scope.epoch); void Promise.resolve().then(() => pumpWritesRef.current()); return; } if ( intent.kind === 'manual' && !reconciled.layout.positions.some( (position) => position.resourceId === intent.resourceId && position.section === intent.section, ) ) { removeWriteIntent(intent); rebuildOptimisticLayout(scope.epoch); void Promise.resolve().then(() => pumpWritesRef.current()); return; } const candidate = intent.kind === 'manual' ? moveResourceCanvasPosition( reconciled.layout, intent.resourceId, intent.section, intent.x, intent.y, ) : reconciled.layout; const invoke = window.__TAURI__?.core?.invoke; if (!invoke) { persistedLayoutRef.current = candidate; removeWriteIntent(intent); rebuildOptimisticLayout(scope.epoch); const hasQueuedWrite = writeQueueRef.current.some( (queued) => queued.scopeEpoch === scope.epoch, ); if (mountedRef.current) { setSaving(hasQueuedWrite); } if (hasQueuedWrite) { void Promise.resolve().then(() => pumpWritesRef.current()); } return; } const expectedRevision = persistedLayoutRef.current.revision; if ( !isSafeProjectResourceCanvasLayoutRevision(expectedRevision) || !layoutCoordinatesAreSafe(candidate) ) { removeWriteIntent(intent); rebuildOptimisticLayout(scope.epoch); if (intent.kind === 'manual') { redragRequiredScopeEpochRef.current = null; } setNotice( intent.kind === 'resources' ? '布局保存失败,已保留当前会话布局' : '布局保存失败,已恢复上次布局', ); void Promise.resolve().then(() => pumpWritesRef.current()); return; } activeWriteIntentRef.current = intent; if (mountedRef.current) { setSaving(true); } void invoke( 'update_local_project_resource_canvas_layout', { projectPath: scope.projectPath, expectedProjectId: scope.projectId, mode: scope.mode, expectedRevision, positions: candidate.positions, }, ) .then((result) => { const currentScope = scopeRef.current; if (currentScope.epoch !== intent.scopeEpoch) { return; } if ( !isSafeProjectResourceCanvasLayoutRevision(result.layout.revision) || !layoutCoordinatesAreSafe(result.layout) ) { throw new Error( 'layout response revision or coordinates are invalid', ); } if (!layoutMatchesScope(result.layout, currentScope)) { throw new Error('layout response scope mismatch'); } persistedLayoutRef.current = result.layout; removeWriteIntent(intent); if (result.status === 'updated') { if (intent.kind === 'manual') { redragRequiredScopeEpochRef.current = null; setNotice('布局已保存'); } if ( reconcileLayout( result.layout, resourcesRef.current, rederiveAutomaticPositions, topologyRef.current, ).changed ) { enqueueResourceSyncRef.current(currentScope.epoch); } } else { const discardedQueuedManualIntent = writeQueueRef.current.some( (queued) => queued.scopeEpoch === currentScope.epoch && queued.kind === 'manual', ); writeQueueRef.current = writeQueueRef.current.filter( (queued) => 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 = needsResourceSync && nextRetry <= MAX_RESOURCE_SYNC_CONFLICT_RETRIES; const redragRequired = intent.kind === 'manual' || discardedQueuedManualIntent; if (redragRequired) { redragRequiredScopeEpochRef.current = currentScope.epoch; } if (willRetryResourceSync) { enqueueResourceSyncRef.current(currentScope.epoch, nextRetry); } if (redragRequired || !willRetryResourceSync) { setNotice('布局已在其他窗口更新'); } } rebuildOptimisticLayout(currentScope.epoch); }) .catch(() => { const currentScope = scopeRef.current; if (currentScope.epoch !== intent.scopeEpoch) { return; } removeWriteIntent(intent); rebuildOptimisticLayout(currentScope.epoch); if (intent.kind === 'manual') { redragRequiredScopeEpochRef.current = null; } if ( intent.kind !== 'resources' || redragRequiredScopeEpochRef.current !== currentScope.epoch ) { setNotice( intent.kind === 'resources' ? '布局保存失败,已保留当前会话布局' : '布局保存失败,已恢复上次布局', ); } }) .finally(() => { if (activeWriteIntentRef.current === intent) { activeWriteIntentRef.current = null; } const currentScope = scopeRef.current; if (mountedRef.current && currentScope.epoch === intent.scopeEpoch) { setSaving( writeQueueRef.current.some( (queued) => queued.scopeEpoch === currentScope.epoch, ), ); } pumpWritesRef.current(); }); }; useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; scopeRef.current = { ...scopeRef.current, epoch: scopeRef.current.epoch + 1, }; initializedScopeEpochRef.current = null; writeQueueRef.current = []; activeWriteIntentRef.current = null; redragRequiredScopeEpochRef.current = null; }; }, []); useEffect(() => { const epoch = scopeRef.current.epoch + 1; const scope: LayoutScope = { key: scopeKey, epoch, projectPath, projectId, mode, }; scopeRef.current = scope; initializedScopeEpochRef.current = null; writeQueueRef.current = []; activeWriteIntentRef.current = null; redragRequiredScopeEpochRef.current = null; setReadyScopeKey(null); const emptyLayout = createEmptyResourceCanvasLayout(projectId, mode); if (!initializationReady) { persistedLayoutRef.current = emptyLayout; applyLayout(emptyLayout); setNotice(''); setSaving(false); return undefined; } const initialFallback = reconcileLayout( emptyLayout, resourcesRef.current, rederiveAutomaticPositions, topologyRef.current, ).layout; persistedLayoutRef.current = initialFallback; applyLayout(initialFallback); setNotice(''); setSaving(false); const invoke = window.__TAURI__?.core?.invoke; if (!invoke) { initializedScopeEpochRef.current = epoch; setReadyScopeKey(scopeKey); pumpWritesRef.current(); return undefined; } let cancelled = false; void invoke( 'read_local_project_resource_canvas_layout', { projectPath, mode }, ) .then((loaded) => { if (cancelled || scopeRef.current.epoch !== epoch) { return; } if ( !isSafeProjectResourceCanvasLayoutRevision(loaded.revision) || !layoutCoordinatesAreSafe(loaded) ) { throw new Error( 'layout response revision or coordinates are invalid', ); } if (!layoutMatchesScope(loaded, scope)) { return; } persistedLayoutRef.current = loaded; initializedScopeEpochRef.current = epoch; setReadyScopeKey(scopeKey); if ( reconcileLayout( loaded, resourcesRef.current, rederiveAutomaticPositions, topologyRef.current, ).changed ) { enqueueResourceSyncRef.current(epoch); } rebuildOptimisticLayout(epoch); pumpWritesRef.current(); }) .catch(() => { if (cancelled || scopeRef.current.epoch !== epoch) { return; } persistedLayoutRef.current = initialFallback; initializedScopeEpochRef.current = epoch; setReadyScopeKey(scopeKey); rebuildOptimisticLayout(epoch); setNotice('布局读取失败,已使用当前会话布局'); pumpWritesRef.current(); }); return () => { cancelled = true; }; }, [ applyLayout, initializationReady, mode, projectId, projectPath, rebuildOptimisticLayout, rederiveAutomaticPositions, scopeKey, ]); useEffect(() => { const scope = scopeRef.current; if ( scope.key !== scopeKey || !initializationReady || initializedScopeEpochRef.current !== scope.epoch ) { return; } const reconciledCurrent = reconcileLayout( layoutRef.current, resourcesRef.current, rederiveAutomaticPositions, topologyRef.current, ); applyLayout(reconciledCurrent.layout); if ( window.__TAURI__?.core?.invoke && reconcileLayout( persistedLayoutRef.current, resourcesRef.current, rederiveAutomaticPositions, topologyRef.current, ).changed ) { enqueueResourceSyncRef.current(scope.epoch); } }, [ applyLayout, initializationReady, rederiveAutomaticPositions, resourceSignature, scopeKey, ]); useEffect(() => { if (!notice) { return undefined; } if ( notice === '布局已在其他窗口更新' && redragRequiredScopeEpochRef.current === scopeRef.current.epoch ) { return undefined; } const timeout = window.setTimeout(() => setNotice(''), 2400); return () => window.clearTimeout(timeout); }, [notice]); const commitPosition = useCallback( ( resourceId: string, section: ProjectResourceCanvasCategory, x: number, y: number, ) => { const scope = scopeRef.current; if ( scope.key !== scopeKey || !initializationReady || initializedScopeEpochRef.current !== scope.epoch ) { return; } const queuedIntent = writeQueueRef.current.find( (intent): intent is ManualLayoutWriteIntent => intent.kind === 'manual' && intent.scopeEpoch === scope.epoch && intent.resourceId === resourceId && intent.section === section && intent !== activeWriteIntentRef.current, ); if (queuedIntent) { queuedIntent.x = x; queuedIntent.y = y; } else { writeQueueRef.current.push({ kind: 'manual', scopeEpoch: scope.epoch, resourceId, section, x, y, }); } applyLayout( moveResourceCanvasPosition( layoutRef.current, resourceId, section, x, y, ), ); setSaving(true); pumpWritesRef.current(); }, [applyLayout, initializationReady, scopeKey], ); const scopeMatches = initializationReady && scopeRef.current.key === scopeKey && layout.projectId === projectId && layout.mode === mode; const ready = scopeMatches && readyScopeKey === scopeKey; const allResourcesPositioned = resources.every((resource) => layout.positions.some( (position) => position.resourceId === resource.id && position.section === resource.category, ), ); const settled = ready && !saving && activeWriteIntentRef.current === null && !writeQueueRef.current.some( (intent) => intent.scopeEpoch === scopeRef.current.epoch, ) && allResourcesPositioned; return { layout: ready ? layout : fallback, notice, saving, ready, settled, scopeIdentity: scopeKey, commitPosition, }; }