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'; import { normalizeResourceCanvasPosition } from './resourceCanvasSectionMapping'; 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; /** * 自动坐标策略。`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, mode: ProjectResourceCanvasLayoutMode, ) { return JSON.stringify([projectPath, projectId, mode]); } /** 项目作用域身份(不含排序模式):两个排序模式的提示要归并成同一次。 */ function createProjectScopeKey(projectPath: string, projectId: string) { return JSON.stringify([projectPath, projectId]); } 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 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) => isSafeProjectResourceCanvasCoordinate(position.x) && isSafeProjectResourceCanvasCoordinate(position.y), ); } /** * 读盘后按现行分区归并 / 丢弃坐标的只读统计。 * * 读时归并(旧 `art` / `code` 分区 → 资源当前分区)会让协调路径判定 `changed`, * 于是第一次打开项目就会把整份布局写回一次 sidecar;无法归并的坐标则被丢弃, * 并且这次丢弃同样由那次写回固化。改前这两件事都完全静默,所以这里把它们数出来, * 交给调用方给用户一次可见反馈。 */ export type ProjectResourceCanvasLayoutReadReport = { /** 项目作用域身份(不含排序模式):同一个项目的两份 sidecar 只提示一次。 */ projectScopeKey: string; mode: ProjectResourceCanvasLayoutMode; /** 旧栏目坐标被读时归并到资源当前分区的条数。 */ normalizedSections: number; /** 因 `resourceId` 在当前资源集合里查不到而被丢弃的坐标条数。 */ droppedMissingResource: number; /** 因持久化分区与资源当前分类不匹配而被丢弃的坐标条数(资源仍在,会按现行分区重新落位)。 */ droppedSectionMismatch: number; /** 上面两项之和:读盘时被丢弃的坐标总数。 */ dropped: number; }; export type ProjectResourceCanvasLayoutReadCounts = Omit< ProjectResourceCanvasLayoutReadReport, 'projectScopeKey' | 'mode' >; /** * 读盘统计。判据直接复用 `normalizeResourceCanvasPosition`——也就是 * `reconcileResourceCanvasLayout` 丢失坐标用的同一个函数,所以三种计数与真正落盘的 * 结果逐条一致;这里只统计,判定与写回语义都不动。 */ export function inspectProjectResourceCanvasLayoutRead( layout: ProjectResourceCanvasLayout, resources: ResourceCanvasItem[], ): ProjectResourceCanvasLayoutReadCounts { const categoryByResourceId = new Map( resources.map((resource) => [resource.id, resource.category]), ); let normalizedSections = 0; let droppedMissingResource = 0; let droppedSectionMismatch = 0; for (const position of layout.positions) { const normalized = normalizeResourceCanvasPosition( position, categoryByResourceId, ); if (normalized) { if (normalized.changed) { normalizedSections += 1; } continue; } if (categoryByResourceId.has(position.resourceId)) { droppedSectionMismatch += 1; } else { droppedMissingResource += 1; } } return { normalizedSections, droppedMissingResource, droppedSectionMismatch, dropped: droppedMissingResource + droppedSectionMismatch, }; } /** * 读盘统计的用户可见文案。只描述已经发生的事:归并会当场写回一次 sidecar、坐标位置不变; * 无法归并的坐标会被跳过。这里不写长期规则,也不承诺后续行为。 */ export function describeProjectResourceCanvasLayoutRead( report: ProjectResourceCanvasLayoutReadCounts, ): string { const sentences: string[] = []; if (report.normalizedSections > 0) { sentences.push( `已把 ${report.normalizedSections} 条旧分区坐标对齐到新分区并写回,坐标位置未变`, ); } if (report.dropped > 0) { const reasons: string[] = []; if (report.droppedMissingResource > 0) { reasons.push(`${report.droppedMissingResource} 条资源已不在项目中`); } if (report.droppedSectionMismatch > 0) { reasons.push(`${report.droppedSectionMismatch} 条分区与资源分类不匹配`); } sentences.push( `${report.normalizedSections > 0 ? '另有 ' : '打开项目时有 '}${report.dropped} 条坐标无法对齐已跳过(${reasons.join(',')})`, ); } return sentences.join(';'); } function reconcileLayout( source: ProjectResourceCanvasLayout, resources: ResourceCanvasItem[], policy: AutomaticPositionPolicy, topology: ResourceCanvasLayoutTopology | undefined, ) { if (policy === 'preserve') { 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, automaticPositionPolicy(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 [readReport, setReadReport] = useState(null); 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, policy: AutomaticPositionPolicy) => { const scope = scopeRef.current; if (scope.epoch !== scopeEpoch) { return; } let next = reconcileLayout( persistedLayoutRef.current, resourcesRef.current, policy, 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], ); 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 writePolicy = intent.kind === 'manual' ? MANUAL_WRITE_AUTOMATIC_POSITION_POLICY : automaticPositionPolicy(rederiveAutomaticPositions); const reconciled = reconcileLayout( persistedLayoutRef.current, resourcesRef.current, writePolicy, topologyRef.current, ); if (intent.kind === 'resources' && !reconciled.changed) { removeWriteIntent(intent); rebuildOptimisticLayout(scope.epoch, writePolicy); 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, writePolicy); 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, writePolicy); 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, writePolicy); 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); 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 (needsResourceSync) { 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 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, writePolicy); }) .catch(() => { const currentScope = scopeRef.current; if (currentScope.epoch !== intent.scopeEpoch) { return; } removeWriteIntent(intent); rebuildOptimisticLayout(currentScope.epoch, writePolicy); 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); setReadReport(null); const emptyLayout = createEmptyResourceCanvasLayout(projectId, mode); if (!initializationReady) { persistedLayoutRef.current = emptyLayout; applyLayout(emptyLayout); setNotice(''); setSaving(false); return undefined; } const initialFallback = reconcileLayout( emptyLayout, resourcesRef.current, automaticPositionPolicy(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); const readCounts = inspectProjectResourceCanvasLayoutRead( loaded, resourcesRef.current, ); if ( reconcileLayout( loaded, resourcesRef.current, automaticPositionPolicy(rederiveAutomaticPositions), topologyRef.current, ).changed ) { enqueueResourceSyncRef.current(epoch); // 有归并或丢弃才会有这次写回;单纯补新资源落位的写回不报读时统计, // 否则提示会声称「已对齐旧分区」而实际上一条都没对齐。 if (readCounts.normalizedSections > 0 || readCounts.dropped > 0) { setReadReport({ ...readCounts, projectScopeKey: createProjectScopeKey(projectPath, projectId), mode, }); } } rebuildOptimisticLayout( epoch, automaticPositionPolicy(rederiveAutomaticPositions), ); pumpWritesRef.current(); }) .catch(() => { if (cancelled || scopeRef.current.epoch !== epoch) { return; } persistedLayoutRef.current = initialFallback; initializedScopeEpochRef.current = epoch; setReadyScopeKey(scopeKey); rebuildOptimisticLayout( epoch, automaticPositionPolicy(rederiveAutomaticPositions), ); 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, automaticPositionPolicy(rederiveAutomaticPositions), topologyRef.current, ); applyLayout(reconciledCurrent.layout); if ( window.__TAURI__?.core?.invoke && reconcileLayout( persistedLayoutRef.current, resourcesRef.current, automaticPositionPolicy(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, readReport, scopeIdentity: scopeKey, commitPosition, }; }