03a4410272
主要工作是共享同一套“画布内核与通用 UI 源码”,网站和 Tauri 只分别实现宿主适配层。 --------- Co-authored-by: 段舒康 <kdletters@qq.com> Co-authored-by: kdletters <kdletters@qq.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/136 Co-authored-by: menghao <mh18530625731@163.com> Co-committed-by: menghao <mh18530625731@163.com>
716 lines
20 KiB
TypeScript
716 lines
20 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;
|
|
resourceSignature: string;
|
|
};
|
|
|
|
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,
|
|
resourceSignature: string,
|
|
) {
|
|
return JSON.stringify([projectPath, projectId, mode, resourceSignature]);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 reconcileLayout(
|
|
source: ProjectResourceCanvasLayout,
|
|
resources: ResourceCanvasItem[],
|
|
rederiveAutomaticPositions: boolean,
|
|
) {
|
|
if (!rederiveAutomaticPositions) {
|
|
return reconcileResourceCanvasLayout(source, resources);
|
|
}
|
|
const manualSource = {
|
|
...source,
|
|
positions: source.positions.filter((position) => position.manuallyPlaced),
|
|
};
|
|
const reconciled = reconcileResourceCanvasLayout(manualSource, resources);
|
|
return {
|
|
layout: reconciled.layout,
|
|
changed: !positionsEqual(source.positions, reconciled.layout.positions),
|
|
};
|
|
}
|
|
|
|
export function useProjectResourceCanvasLayout({
|
|
projectPath,
|
|
projectId,
|
|
mode,
|
|
resources,
|
|
initializationReady = true,
|
|
renderFallbackWhileBlocked = false,
|
|
rederiveAutomaticPositions = false,
|
|
}: {
|
|
projectPath: string;
|
|
projectId: string;
|
|
mode: ProjectResourceCanvasLayoutMode;
|
|
resources: ResourceCanvasItem[];
|
|
initializationReady?: boolean;
|
|
renderFallbackWhileBlocked?: boolean;
|
|
rederiveAutomaticPositions?: boolean;
|
|
}) {
|
|
const resourceSignature = useMemo(
|
|
() => createResourceSignature(resources),
|
|
[resources],
|
|
);
|
|
const scopeKey = createScopeKey(
|
|
projectPath,
|
|
projectId,
|
|
mode,
|
|
resourceSignature,
|
|
);
|
|
const fallback = useMemo(() => {
|
|
const empty = createEmptyResourceCanvasLayout(projectId, mode);
|
|
return initializationReady || renderFallbackWhileBlocked
|
|
? reconcileLayout(empty, resources, rederiveAutomaticPositions).layout
|
|
: empty;
|
|
}, [
|
|
initializationReady,
|
|
mode,
|
|
projectId,
|
|
rederiveAutomaticPositions,
|
|
renderFallbackWhileBlocked,
|
|
resources,
|
|
]);
|
|
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 resourceSignatureRef = useRef(resourceSignature);
|
|
const initializedScopeEpochRef = useRef<number | null>(null);
|
|
const scopeRef = useRef<LayoutScope>({
|
|
key: scopeKey,
|
|
epoch: 0,
|
|
projectPath,
|
|
projectId,
|
|
mode,
|
|
resourceSignature,
|
|
});
|
|
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 = reconcileLayout(
|
|
persistedLayoutRef.current,
|
|
resourcesRef.current,
|
|
rederiveAutomaticPositions,
|
|
).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,
|
|
);
|
|
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 (
|
|
reconcileLayout(
|
|
result.layout,
|
|
resourcesRef.current,
|
|
rederiveAutomaticPositions,
|
|
).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,
|
|
).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,
|
|
resourceSignature,
|
|
};
|
|
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,
|
|
).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)) {
|
|
throw new Error('layout response revision is not a safe integer');
|
|
}
|
|
if (!layoutMatchesScope(loaded, scope)) {
|
|
return;
|
|
}
|
|
persistedLayoutRef.current = loaded;
|
|
initializedScopeEpochRef.current = epoch;
|
|
setReadyScopeKey(scopeKey);
|
|
if (
|
|
reconcileLayout(
|
|
loaded,
|
|
resourcesRef.current,
|
|
rederiveAutomaticPositions,
|
|
).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,
|
|
resourceSignature,
|
|
scopeKey,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const scope = scopeRef.current;
|
|
if (
|
|
scope.key !== scopeKey ||
|
|
!initializationReady ||
|
|
initializedScopeEpochRef.current !== scope.epoch
|
|
) {
|
|
return;
|
|
}
|
|
const reconciledCurrent = reconcileLayout(
|
|
layoutRef.current,
|
|
resourcesRef.current,
|
|
rederiveAutomaticPositions,
|
|
);
|
|
applyLayout(reconciledCurrent.layout);
|
|
if (
|
|
window.__TAURI__?.core?.invoke &&
|
|
reconcileLayout(
|
|
persistedLayoutRef.current,
|
|
resourcesRef.current,
|
|
rederiveAutomaticPositions,
|
|
).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: scopeMatches ? layout : fallback,
|
|
notice,
|
|
saving,
|
|
ready,
|
|
settled,
|
|
scopeIdentity: scopeKey,
|
|
commitPosition,
|
|
};
|
|
}
|