216407d93e
冻结资源画布布局数据与 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>
598 lines
17 KiB
TypeScript
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,
|
|
};
|
|
}
|