Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts
T
suzmii 532c6d51fd
Project CI / Repository checks (push) Successful in 4m5s
Project CI / Frontend tests (push) Successful in 4m48s
Project CI / Backend tests (push) Successful in 6m6s
Project CI / Native shell tests (push) Successful in 15m24s
完善 Game Agent 资源画布与图片精修生成事务 (#181)
## 背景

本合并请求整合 Game Agent 资源管理的栏目分页自由画布,以及图片持续精修、候选生成和本地恢复事务。

当前远端比较基线:

- base:`master` @ `b00a3f80576949567bf3320f0c6c92b4ed0a649d`
- head:`codex/game-agent-resource-section-pages` @ `5795d2f9b8797c342dcf2920ccefec2a3ff3f472`
- 相对 base:28 commits、33 files(`+3746 / -1333`)

## 主要改动

### 资源管理分页自由画布

- 固定展示“设计文档 → 美术资源 → 音乐音效 → 游戏代码 → 项目版本”五个栏目;空栏目仍可从悬浮 Dock 打开空画布。
- `按依赖` 与 `按类型` 共用栏目分页画布;每个“排序模式 + 栏目”组合独立保存 viewport。
- 普通滚轮使用有界意图队列切换栏目;Ctrl/Meta + 滚轮以指针为锚点缩放;空白拖拽可沿 x/y 无限平移,不以资源 extent 作为导航边界。
- 资源卡支持拖拽布局与一次 CAS 持久化;切页、排序切换和卸载会统一取消拖拽及 pointer capture。
- 资源详情为非模态独立卡片:打开、切换或关闭详情不卸载背景画布、资源卡或依赖连线,也不重置 viewport、搜索或排序状态。
- 顶部已移除“生成视频 / 生成音效 / 生成背景音乐 / 新增 UI 设计”的手动入口;保留播放、未完成编辑恢复、排序和画布复位,以及资源详情中的既有编辑动作。

### 图片持续精修与生成事务

- 图片资源可进入唯一活动精修草稿,支持原生批量导入、图片下方快速编辑卡、候选图层、任务侧栏和“设为最终图”。
- 生成成功只合并候选图层和 generation 权威事实,不以全量 hydrate 覆盖生成期间的本地编辑。
- 候选媒体、图层和公开 generation 记录写入并回读成功后,才推进私有 `candidate-ready`。
- 候选确认接入 Surface 保存 FIFO 与重新打开 hydrate;revision-sensitive 操作等待确认屏障。
- 失败归档和正式图提交采用可恢复中间态,避免中断后出现幽灵任务、重复 revision 或不一致的正式资产。

### 合同与文档

- `acknowledgeCandidateLayers`、`importLocalImages`、`archiveFailedGeneration` 为必选 Host Port;不支持的宿主返回结构化 `unsupported-capability`。
- 同步更新 TypeScript / Rust 合同、Tauri command 可达性、PRD、技术方案与项目共享记忆。
- 明确资源栏目无限画布合同:普通平移不夹取;资源 extent、图片测量、布局变更和 resize 不得重置用户 viewport;仅首次可测量布局与显式复位按真实卡片包围盒适配内容。

## 验证

CI run [#1290](https://git.genarrative.world/git/GenarrativeAI/Genarrative/actions/runs/1290) 针对当前 head 已完成且四项均成功:

- [x] Repository checks
- [x] Frontend tests(235 test files、3166 tests passed;App Surface 410 tests)
- [x] Backend tests
- [x] Native shell tests

另有本地定向检查:

- [x] `npm run agc:typecheck`
- [x] 资源画布、App Surface、Asset Canvas 与资源实时集成定向回归
- [x] Rust 候选确认、候选中断恢复、失败归档与恢复定向测试
- [x] `cargo fmt --check`
- [x] `npm run check:encoding`
- [x] `git diff --check`

## 评审 provenance

- review #247、#248、#252 都针对旧 head,当前在 Gitea 中均标记为 stale;不将其写作“已处理 #247”或当前 head 的评审结论。
- review #253 针对旧 `5456a1d…` head,提出 PRD 退役入口、共享 pitfalls 的 extent 旧合同和 PR 正文事实更新三项 P2;对应文档已在 `5795d2f…` head 修复并推送。
- 早期由 PR #176 引入的候选持久化、归档、Host Port 与快速编辑卡问题,当前已在 base/head 中具备对应修复,不作为本轮重复阻断项。
- 当前 head 仍需新的、针对最新提交的审批;历史 stale review 不能替代当前 head approval。

## 影响范围

- 修改 AI 游戏创作 App 的资源管理、图片精修、Tauri 本地持久化与恢复语义,以及共享合同和权威文档。
- 会调整本地资源布局、精修 draft 和私有 generation ledger 的状态机。
- 不修改 SpacetimeDB schema,不新增或变更 `/api/external/v1` 路由。

---------

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/181
Co-authored-by: suzmii <suzmii@qq.com>
Co-committed-by: suzmii <suzmii@qq.com>
2026-08-24 20:42:58 +08:00

826 lines
24 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
ProjectResourceCanvasLayout,
ProjectResourceCanvasLayoutMode,
ProjectResourceCanvasSection,
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: ProjectResourceCanvasSection;
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<ProjectResourceCanvasLayout>(fallback);
const [notice, setNotice] = useState<LayoutNotice>('');
const [saving, setSaving] = useState(false);
const [readyScopeKey, setReadyScopeKey] = useState<string | null>(null);
const mountedRef = useRef(true);
const layoutRef = useRef<ProjectResourceCanvasLayout>(fallback);
const persistedLayoutRef = useRef<ProjectResourceCanvasLayout>(fallback);
const resourcesRef = useRef(resources);
const topologyRef = useRef(topology);
const resourceSignatureRef = useRef(resourceSignature);
const initializedScopeEpochRef = useRef<number | null>(null);
const scopeRef = useRef<LayoutScope>({
key: scopeKey,
epoch: 0,
projectPath,
projectId,
mode,
});
const writeQueueRef = useRef<LayoutWriteIntent[]>([]);
const activeWriteIntentRef = useRef<LayoutWriteIntent | null>(null);
const redragRequiredScopeEpochRef = useRef<number | null>(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<UpdateProjectResourceCanvasLayoutResult>(
'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<ProjectResourceCanvasLayout>(
'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: ProjectResourceCanvasSection,
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,
};
}