Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts
T
menghao 216407d93e
Project CI / Frontend tests (push) Failing after 20s
Project CI / Repository checks (push) Successful in 1m2s
Project CI / Backend tests (push) Successful in 2m59s
Project CI / Native shell tests (push) Successful in 11m58s
实现资源画布布局持久化 (#116)
冻结资源画布布局数据与 CAS 合同
实现双模式本地 sidecar 安全读写
接入二维拖动、默认排版和跨重启恢复
补齐并发冲突、安全边界和界面测试
同步技术文档与共享决策

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/116
Reviewed-by: 段舒康 <kdletters@qq.com>
Co-authored-by: menghao <mh18530625731@163.com>
Co-committed-by: menghao <mh18530625731@163.com>
2026-07-31 12:00:48 +08:00

598 lines
17 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
ProjectResourceCanvasLayout,
ProjectResourceCanvasLayoutMode,
ProjectResourceCanvasSection,
UpdateProjectResourceCanvasLayoutResult,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { isSafeProjectResourceCanvasLayoutRevision } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
createEmptyResourceCanvasLayout,
moveResourceCanvasPosition,
reconcileResourceCanvasLayout,
type ResourceCanvasItem,
} 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,
]),
)
.sort()
.join('\n');
}
function layoutMatchesScope(
layout: ProjectResourceCanvasLayout,
scope: LayoutScope,
) {
return layout.projectId === scope.projectId && layout.mode === scope.mode;
}
export function useProjectResourceCanvasLayout({
projectPath,
projectId,
mode,
resources,
}: {
projectPath: string;
projectId: string;
mode: ProjectResourceCanvasLayoutMode;
resources: ResourceCanvasItem[];
}) {
const scopeKey = createScopeKey(projectPath, projectId, mode);
const resourceSignature = useMemo(
() => createResourceSignature(resources),
[resources],
);
const fallback = useMemo(
() =>
reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout(projectId, mode),
resources,
).layout,
[mode, projectId, resources],
);
const [layout, setLayout] = useState<ProjectResourceCanvasLayout>(fallback);
const [notice, setNotice] = useState<LayoutNotice>('');
const [saving, setSaving] = useState(false);
const mountedRef = useRef(true);
const layoutRef = useRef<ProjectResourceCanvasLayout>(fallback);
const persistedLayoutRef = useRef<ProjectResourceCanvasLayout>(fallback);
const resourcesRef = useRef(resources);
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;
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 = reconcileResourceCanvasLayout(
persistedLayoutRef.current,
resourcesRef.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 reconciled = reconcileResourceCanvasLayout(
persistedLayoutRef.current,
resourcesRef.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)) {
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)
) {
throw new Error('layout response revision is not a safe integer');
}
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 (
reconcileResourceCanvasLayout(result.layout, resourcesRef.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 = reconcileResourceCanvasLayout(
result.layout,
resourcesRef.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;
const initialFallback = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout(projectId, mode),
resourcesRef.current,
).layout;
persistedLayoutRef.current = initialFallback;
applyLayout(initialFallback);
setNotice('');
setSaving(false);
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
initializedScopeEpochRef.current = epoch;
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)) {
throw new Error('layout response revision is not a safe integer');
}
if (!layoutMatchesScope(loaded, scope)) {
return;
}
persistedLayoutRef.current = loaded;
initializedScopeEpochRef.current = epoch;
if (
reconcileResourceCanvasLayout(loaded, resourcesRef.current).changed
) {
enqueueResourceSyncRef.current(epoch);
}
rebuildOptimisticLayout(epoch);
pumpWritesRef.current();
})
.catch(() => {
if (cancelled || scopeRef.current.epoch !== epoch) {
return;
}
persistedLayoutRef.current = initialFallback;
initializedScopeEpochRef.current = epoch;
rebuildOptimisticLayout(epoch);
setNotice('布局读取失败,已使用当前会话布局');
pumpWritesRef.current();
});
return () => {
cancelled = true;
};
}, [
applyLayout,
mode,
projectId,
projectPath,
rebuildOptimisticLayout,
scopeKey,
]);
useEffect(() => {
const scope = scopeRef.current;
if (
scope.key !== scopeKey ||
initializedScopeEpochRef.current !== scope.epoch
) {
return;
}
const reconciledCurrent = reconcileResourceCanvasLayout(
layoutRef.current,
resourcesRef.current,
);
applyLayout(reconciledCurrent.layout);
if (
window.__TAURI__?.core?.invoke &&
reconcileResourceCanvasLayout(
persistedLayoutRef.current,
resourcesRef.current,
).changed
) {
enqueueResourceSyncRef.current(scope.epoch);
}
}, [applyLayout, 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) {
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, scopeKey],
);
const scopeMatches =
scopeRef.current.key === scopeKey &&
layout.projectId === projectId &&
layout.mode === mode;
return {
layout: scopeMatches ? layout : fallback,
notice,
saving,
commitPosition,
};
}