70271b408e
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m19s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m35s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m34s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m39s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m13s
Project CI / AI game creator shell Rust crates (push) Successful in 3m18s
Project CI / Backend tests (push) Successful in 9m16s
Project CI / Repository checks (push) Successful in 8m11s
Project CI / Native shell tests (push) Successful in 11m56s
Project CI / Frontend tests (push) Successful in 11m49s
Project CI / AI game creator shell web tests (push) Successful in 6m37s
Reviewed-on: #355 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
1349 lines
48 KiB
TypeScript
1349 lines
48 KiB
TypeScript
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 { normalizeDiagnosticText } from '../../services/errorReporting';
|
||
import {
|
||
applyResourceCanvasPositionWrites,
|
||
createEmptyResourceCanvasLayout,
|
||
reconcileResourceCanvasLayout,
|
||
type ResourceCanvasItem,
|
||
type ResourceCanvasLayoutTopology,
|
||
type ResourceCanvasPositionWrite,
|
||
} from './resourceCanvasLayoutModel';
|
||
import { normalizeResourceCanvasPosition } from './resourceCanvasSectionMapping';
|
||
|
||
/**
|
||
* 布局落盘 / 读盘失败时保留一份**脱敏后的原因**。
|
||
*
|
||
* 这两个 `.catch` 此前写成 `() => { ... }` —— 没有 message 参数,失败原因被整个吞掉:
|
||
* 用户只看到"布局保存失败",排障端也拿不到为什么(revision 越界?IPC 拒绝?权限?)。
|
||
* 这里把 `error.message` 压成一行并复用既有的 `normalizeDiagnosticText`(路径 / token /
|
||
* URL / 长 id 一律打码),再截断,避免多行堆栈糊进日志;日志由客户端已安装的
|
||
* webview→application.log 桥带进诊断日志,遇到"某个窗口保存一直失败"能直接查。
|
||
*/
|
||
function describeResourceCanvasLayoutFailure(error: unknown) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
return normalizeDiagnosticText(message.replace(/\s+/gu, ' ').trim()).slice(
|
||
0,
|
||
400,
|
||
);
|
||
}
|
||
|
||
type LayoutNotice =
|
||
| ''
|
||
| '布局已保存'
|
||
| '布局读取失败,已使用当前会话布局'
|
||
| '布局保存失败,已保留当前会话布局'
|
||
| '布局保存失败,已恢复上次布局'
|
||
| '布局已在其他窗口更新';
|
||
|
||
type LayoutScope = {
|
||
key: string;
|
||
epoch: number;
|
||
projectPath: string;
|
||
projectId: string;
|
||
mode: ProjectResourceCanvasLayoutMode;
|
||
};
|
||
|
||
/**
|
||
* 一笔手动坐标写入:多选拖动、撤销/重做恢复都只排**一笔**,`positions` 里的每一张卡各自
|
||
* 带自己的 `section` 与 `manuallyPlaced`。队列按 `resourceId + section` 合并同一张卡的重复
|
||
* 写入,语义仍是"最后落点赢",不会因为一次多选拖动就生成 N 笔 CAS。
|
||
*/
|
||
type ManualLayoutWriteIntent = {
|
||
kind: 'manual';
|
||
scopeEpoch: number;
|
||
positions: ResourceCanvasPositionWrite[];
|
||
};
|
||
|
||
/**
|
||
* 自动坐标重派生请求。两种重算**各占一个队列槽**(按 `kind` 区分):显式整理是用户动作、
|
||
* 「关系图首次就绪」是系统动作,后者不得就地改写前者已经按下、还没落盘的那一笔。
|
||
*
|
||
* - `automatic`:只丢自动坐标,手动卡原地不动(关系图首次就绪那一次)。
|
||
* - `organize`:用户显式「整理画布」。目标栏目内的坐标**连手动一起丢**再重算,结果一律是
|
||
* 自动坐标;`sections` 为 `null` 表示「所有资源」页=当前项目全部可展示素材。
|
||
* 保存仍走同一条写队列与 CAS,只是重算前的取舍不同。
|
||
*/
|
||
type LayoutRederiveRequest =
|
||
| { kind: 'automatic' }
|
||
| {
|
||
kind: 'organize';
|
||
sections: readonly ProjectResourceCanvasCategory[] | null;
|
||
};
|
||
|
||
type ResourceLayoutWriteIntent = {
|
||
kind: 'resources';
|
||
scopeEpoch: number;
|
||
resourceSignature: string;
|
||
conflictRetries: number;
|
||
/**
|
||
* 显式「整理画布」/「关系图首次就绪」写意图:这一笔写回按 `rederive` 策略丢坐标重算。
|
||
*
|
||
* 仍然是"坐标真的变了才落盘"(沿用既有 `changed` 门):已经整齐的画布按一下不该产生
|
||
* 一次无意义的 CAS / revision 推进,也不该在关系图被截断这类"重算结果同样可信但不能
|
||
* 声称变过"的场景里凭空写一笔。自动(签名变化触发)的资源同步永远是 `false`,
|
||
* 只补新卡、不动既有坐标。
|
||
*/
|
||
rederive: LayoutRederiveRequest | null;
|
||
};
|
||
|
||
type LayoutWriteIntent = ManualLayoutWriteIntent | ResourceLayoutWriteIntent;
|
||
|
||
const MAX_RESOURCE_SYNC_CONFLICT_RETRIES = 2;
|
||
|
||
/**
|
||
* 自动坐标策略。`rederive` 丢掉全部自动坐标、按当前资源与拓扑重算(手动坐标原样保留);
|
||
* 它现在只由显式动作触发——用户的「整理画布」按钮,以及「关系图首次就绪」那一次
|
||
* `rederiveNow()`。`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';
|
||
|
||
/**
|
||
* 一次「整理画布」的目标范围键:`null` 是「所有资源」页=全部栏目,否则是有序栏目集合。
|
||
*
|
||
* 队列按这个键判断"是不是同一次整理",而不是按 `sections` 的数组身份——调用方每次渲染都会
|
||
* 传一份新数组。**不同范围必须是两笔独立意图**:栏目 A 与栏目 B 是两次用户动作,各对应一次
|
||
* 撤销、各一笔 CAS;用后一次覆盖前一次会把前一栏的整理从落盘里抹掉(画面上它已经被乐观视图
|
||
* 重排过,磁盘上却是"只整理了后一栏")。同范围才就地合并,合并时只刷新签名与重试计数,
|
||
* 保持它在队列里的位置,顺序语义不变。
|
||
*/
|
||
function organizeSectionsKey(
|
||
sections: readonly ProjectResourceCanvasCategory[] | null,
|
||
) {
|
||
return sections === null
|
||
? 'all'
|
||
: JSON.stringify([...new Set(sections)].sort());
|
||
}
|
||
|
||
/**
|
||
* 这一按可以就地合并的那一笔待处理整理:**必须在队尾**,且作用范围相同。
|
||
*
|
||
* "只管队尾"不是省事,是本条语义的必需:队列是 FIFO,合并进更早的同范围整理,这一按就落到
|
||
* 它**之后**排进来、还没落盘的写入之前——`整理A → 手动移动A → 再整理A` 会复用第一次那个槽位,
|
||
* 最终落盘的是中间那次手动坐标,用户最后按下的整理被静默吞掉。落在队尾时合并没有这个风险
|
||
* (后面没有更新的意图),所以连续按同一栏仍然只写一笔 CAS。
|
||
*/
|
||
function queuedOrganizeIntentAtTail(
|
||
queue: readonly LayoutWriteIntent[],
|
||
activeIntent: LayoutWriteIntent | null,
|
||
scopeEpoch: number,
|
||
sectionsKey: string,
|
||
): ResourceLayoutWriteIntent | null {
|
||
const tail = queue[queue.length - 1];
|
||
if (!tail || tail === activeIntent) {
|
||
return null;
|
||
}
|
||
if (tail.kind !== 'resources') {
|
||
return null;
|
||
}
|
||
if (tail.scopeEpoch !== scopeEpoch) {
|
||
return null;
|
||
}
|
||
const rederive = tail.rederive;
|
||
if (!rederive || rederive.kind !== 'organize') {
|
||
return null;
|
||
}
|
||
return organizeSectionsKey(rederive.sections) === sectionsKey ? tail : null;
|
||
}
|
||
|
||
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;
|
||
/**
|
||
* 因分区取值**确实无从归并**而被丢弃的坐标条数(资源仍在,会按现行分区重新落位)。
|
||
*
|
||
* 只有两种取值会落到这里:无法识别的分区值,以及旧栏目归并目标的集合不含资源当前分类
|
||
* (如旧 `code` 遇到已归为 `document` 的资源)。**现行栏目值与资源当前分类不一致不再丢弃**:
|
||
* 那种情况下 `section` 被归并到资源当前分类、x / y 原样保留,计入 `normalizedSections`。
|
||
*/
|
||
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,
|
||
/**
|
||
* 「整理画布」的目标栏目集合。给了(含 `null` = 全部栏目)就在重算前把这些栏目里的坐标
|
||
* **连手动标记一起丢掉**,重算结果一律落成自动坐标;没给的栏目**逐值不动、连自动坐标也不
|
||
* 重排**(详见下面的保留判据)。判定按 `normalizeResourceCanvasPosition` 归并后的现行分区
|
||
* 做,磁盘上仍是旧栏目取值的坐标不会因为 `section` 字面量不同而漏出整理范围。
|
||
*/
|
||
organizeSections:
|
||
readonly ProjectResourceCanvasCategory[] | null | undefined = undefined,
|
||
) {
|
||
// `undefined` = 不是整理;`null` = 整理全部栏目;否则按集合判定。
|
||
const organizedSections =
|
||
organizeSections === undefined
|
||
? undefined
|
||
: organizeSections === null
|
||
? null
|
||
: new Set(organizeSections);
|
||
const categoryByResourceId = new Map(
|
||
resources.map((resource) => [resource.id, resource.category]),
|
||
);
|
||
const dropPosition = (
|
||
position: ProjectResourceCanvasLayout['positions'][number],
|
||
) => {
|
||
if (organizedSections === undefined) {
|
||
return false;
|
||
}
|
||
const normalized = normalizeResourceCanvasPosition(
|
||
position,
|
||
categoryByResourceId,
|
||
);
|
||
if (!normalized) {
|
||
return false;
|
||
}
|
||
return (
|
||
organizedSections === null ||
|
||
organizedSections.has(normalized.position.section)
|
||
);
|
||
};
|
||
if (policy === 'preserve' && organizeSections === undefined) {
|
||
const reconciled = reconcileResourceCanvasLayout(
|
||
source,
|
||
resources,
|
||
topology,
|
||
);
|
||
return layoutCoordinatesAreSafe(reconciled.layout)
|
||
? reconciled
|
||
: { layout: source, changed: false };
|
||
}
|
||
/**
|
||
* 保留判据按「这一次重算的范围」分两种口径:
|
||
* - **整理**(`organizeSections` 已给):只丢目标栏目的坐标,栏外坐标逐值保留,**包括自动
|
||
* 坐标**。自动坐标不是「重算一次必然一样」的派生值:真实项目里它可能来自更早版本的排布,
|
||
* 或素材被删、被拖走后留下的既有位置;顺手把它们丢给重算,就等于「整理当前栏目」时把别的
|
||
* 栏目重新规整成规范槽位,与「其他栏目不变」相反。
|
||
* - **全局重算**(`rederive`:关系图首次就绪那一次):丢全部自动坐标、手动卡原地不动,
|
||
* 由重算把自动槽位补齐。
|
||
*/
|
||
const keepsPosition = (
|
||
position: ProjectResourceCanvasLayout['positions'][number],
|
||
) =>
|
||
organizeSections !== undefined
|
||
? !dropPosition(position)
|
||
: policy === 'preserve' || position.manuallyPlaced;
|
||
const nextSource = {
|
||
...source,
|
||
positions: source.positions.filter(keepsPosition),
|
||
};
|
||
const reconciled = reconcileResourceCanvasLayout(
|
||
nextSource,
|
||
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<ProjectResourceCanvasLayout>(fallback);
|
||
const [notice, setNotice] = useState<LayoutNotice>('');
|
||
const [saving, setSaving] = useState(false);
|
||
const [readReport, setReadReport] =
|
||
useState<ProjectResourceCanvasLayoutReadReport | null>(null);
|
||
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,
|
||
policy: AutomaticPositionPolicy,
|
||
organizeSections?: readonly ProjectResourceCanvasCategory[] | null,
|
||
) => {
|
||
const scope = scopeRef.current;
|
||
if (scope.epoch !== scopeEpoch) {
|
||
return null;
|
||
}
|
||
let next = reconcileLayout(
|
||
persistedLayoutRef.current,
|
||
resourcesRef.current,
|
||
policy,
|
||
topologyRef.current,
|
||
organizeSections,
|
||
).layout;
|
||
for (const intent of writeQueueRef.current) {
|
||
if (intent.scopeEpoch === scopeEpoch && intent.kind === 'manual') {
|
||
next = applyResourceCanvasPositionWrites(next, intent.positions);
|
||
}
|
||
}
|
||
applyLayout(next);
|
||
return next;
|
||
},
|
||
[applyLayout],
|
||
);
|
||
|
||
enqueueResourceSyncRef.current = (scopeEpoch, conflictRetries = 0) => {
|
||
const scope = scopeRef.current;
|
||
if (scope.epoch !== scopeEpoch) {
|
||
return;
|
||
}
|
||
const signature = resourceSignatureRef.current;
|
||
/**
|
||
* 队列里可能同时排着两笔重算(显式整理与关系图首次就绪各占一个槽):资源签名是作用域级
|
||
* 的事实,排队中的每一笔都要跟上,否则先落盘的那笔会把后一笔的签名留成过期值、凭空多排
|
||
* 一次资源同步。
|
||
*/
|
||
const queued = writeQueueRef.current.filter(
|
||
(intent): intent is ResourceLayoutWriteIntent =>
|
||
intent.kind === 'resources' &&
|
||
intent.scopeEpoch === scopeEpoch &&
|
||
intent !== activeWriteIntentRef.current,
|
||
);
|
||
if (queued.length > 0) {
|
||
for (const intent of queued) {
|
||
if (intent.resourceSignature !== signature) {
|
||
intent.resourceSignature = signature;
|
||
intent.conflictRetries = 0;
|
||
} else {
|
||
intent.conflictRetries = Math.min(
|
||
intent.conflictRetries,
|
||
conflictRetries,
|
||
);
|
||
}
|
||
}
|
||
} else {
|
||
writeQueueRef.current.push({
|
||
kind: 'resources',
|
||
scopeEpoch,
|
||
resourceSignature: signature,
|
||
conflictRetries,
|
||
rederive: null,
|
||
});
|
||
}
|
||
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 organizeSections =
|
||
intent.kind === 'resources' && intent.rederive?.kind === 'organize'
|
||
? intent.rederive.sections
|
||
: undefined;
|
||
const writePolicy =
|
||
intent.kind === 'manual'
|
||
? MANUAL_WRITE_AUTOMATIC_POSITION_POLICY
|
||
: intent.rederive
|
||
? 'rederive'
|
||
: automaticPositionPolicy(rederiveAutomaticPositions);
|
||
const reconciled = reconcileLayout(
|
||
persistedLayoutRef.current,
|
||
resourcesRef.current,
|
||
writePolicy,
|
||
topologyRef.current,
|
||
organizeSections,
|
||
);
|
||
/**
|
||
* 坐标真的没变就不落盘:`changed` 门对整理/自动同步是老口径,这一条补给它的是手动
|
||
* 一笔(多选拖动或撤销恢复)。判据含 `manuallyPlaced`——撤销整理恢复的正是"坐标相同、
|
||
* 但必须从自动标记回到手动标记"的那一批卡,那种情况必须照写。
|
||
*/
|
||
const manualCandidate =
|
||
intent.kind === 'manual'
|
||
? applyResourceCanvasPositionWrites(reconciled.layout, intent.positions)
|
||
: null;
|
||
const manualWritesAnything =
|
||
manualCandidate !== null &&
|
||
!automaticCoordinatesMatch(reconciled.layout, manualCandidate);
|
||
if (
|
||
(intent.kind === 'resources' && !reconciled.changed) ||
|
||
(intent.kind === 'manual' && !manualWritesAnything)
|
||
) {
|
||
removeWriteIntent(intent);
|
||
rebuildOptimisticLayout(scope.epoch, writePolicy, organizeSections);
|
||
void Promise.resolve().then(() => pumpWritesRef.current());
|
||
return;
|
||
}
|
||
const candidate = manualCandidate ?? reconciled.layout;
|
||
const invoke = window.__TAURI__?.core?.invoke;
|
||
if (!invoke) {
|
||
persistedLayoutRef.current = candidate;
|
||
removeWriteIntent(intent);
|
||
rebuildOptimisticLayout(scope.epoch, writePolicy, organizeSections);
|
||
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, organizeSections);
|
||
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);
|
||
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('布局已保存');
|
||
} else if (intent.rederive) {
|
||
// 显式整理与关系图首次就绪复用同一条提示:用户按了按钮,就必须看到
|
||
// "这次重算真的落盘了"。
|
||
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,
|
||
organizeSections,
|
||
);
|
||
})
|
||
.catch((error: unknown) => {
|
||
const currentScope = scopeRef.current;
|
||
if (currentScope.epoch !== intent.scopeEpoch) {
|
||
return;
|
||
}
|
||
// 原因先落一行脱敏日志:下面那条提示在部分分支里是刻意不显示的,
|
||
// 若不记日志,这种"用户看不到、排障也看不到"的失败就完全消失。
|
||
console.warn(
|
||
`[resource-canvas-layout] 布局保存失败:${describeResourceCanvasLayoutFailure(error)}`,
|
||
);
|
||
removeWriteIntent(intent);
|
||
rebuildOptimisticLayout(
|
||
currentScope.epoch,
|
||
writePolicy,
|
||
organizeSections,
|
||
);
|
||
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<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);
|
||
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((error: unknown) => {
|
||
if (cancelled || scopeRef.current.epoch !== epoch) {
|
||
return;
|
||
}
|
||
// 读盘失败同样是"只报一句、不报原因",这里保留脱敏后的一行原因。
|
||
console.warn(
|
||
`[resource-canvas-layout] 布局读取失败:${describeResourceCanvasLayoutFailure(error)}`,
|
||
);
|
||
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]);
|
||
|
||
/**
|
||
* 手动坐标的批量写入口:多选拖动与撤销/重做都只调用一次,整批卡排成**一笔** CAS。
|
||
*
|
||
* 队列里同一张卡(`resourceId + section`)的重复写入就地覆盖——最后落点赢,同一次操作
|
||
* 里的中间态不需要逐笔排队;在途那笔之后的新写入仍排在它后面(沿用既有顺序语义)。
|
||
* 乐观视图按同一批写入立即更新,失败/冲突仍走既有 `notice` 与恢复分支。
|
||
*/
|
||
const commitPositions = useCallback(
|
||
(writes: readonly ResourceCanvasPositionWrite[]) => {
|
||
const scope = scopeRef.current;
|
||
if (writes.length === 0) {
|
||
return;
|
||
}
|
||
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 !== activeWriteIntentRef.current,
|
||
);
|
||
if (queuedIntent) {
|
||
mergeResourceCanvasPositionWrites(queuedIntent.positions, writes);
|
||
} else {
|
||
writeQueueRef.current.push({
|
||
kind: 'manual',
|
||
scopeEpoch: scope.epoch,
|
||
positions: [...writes],
|
||
});
|
||
}
|
||
applyLayout(applyResourceCanvasPositionWrites(layoutRef.current, writes));
|
||
setSaving(true);
|
||
pumpWritesRef.current();
|
||
},
|
||
[applyLayout, initializationReady, scopeKey],
|
||
);
|
||
|
||
/** 单卡手动落点的既有入口:等价于一笔只含这张卡、且标记手动摆放的批量写入。 */
|
||
const commitPosition = useCallback(
|
||
(
|
||
resourceId: string,
|
||
section: ProjectResourceCanvasCategory,
|
||
x: number,
|
||
y: number,
|
||
) => commitPositions([{ resourceId, section, x, y, manuallyPlaced: true }]),
|
||
[commitPositions],
|
||
);
|
||
|
||
/**
|
||
* 显式「整理画布」:丢掉全部自动坐标、按当前资源与拓扑重算一次;坐标确有变化时写回
|
||
* sidecar(沿用既有 `changed` 门),画布本身先乐观按重算结果显示。
|
||
*
|
||
* 这是整张画布重排的唯一入口——画布不再因为资源协调签名变化自动重派生。走的是与自动
|
||
* 同步同一条写队列与写回链路,只把策略换成 `rederive`;用户可见反馈仍由既有的
|
||
* `notice` / `saving` 状态位承担,不另造一套状态。
|
||
*/
|
||
const rederiveNow = useCallback(() => {
|
||
const scope = scopeRef.current;
|
||
if (
|
||
scope.key !== scopeKey ||
|
||
!initializationReady ||
|
||
initializedScopeEpochRef.current !== scope.epoch
|
||
) {
|
||
return;
|
||
}
|
||
const queued = writeQueueRef.current.find(
|
||
(intent): intent is ResourceLayoutWriteIntent =>
|
||
intent.kind === 'resources' &&
|
||
intent.scopeEpoch === scope.epoch &&
|
||
intent.rederive?.kind === 'automatic' &&
|
||
intent !== activeWriteIntentRef.current,
|
||
);
|
||
if (queued) {
|
||
queued.resourceSignature = resourceSignatureRef.current;
|
||
queued.conflictRetries = 0;
|
||
queued.rederive = { kind: 'automatic' };
|
||
} else {
|
||
writeQueueRef.current.push({
|
||
kind: 'resources',
|
||
scopeEpoch: scope.epoch,
|
||
resourceSignature: resourceSignatureRef.current,
|
||
conflictRetries: 0,
|
||
rederive: { kind: 'automatic' },
|
||
});
|
||
}
|
||
// 乐观视图:立刻把重算结果显示出来,别让用户以为按钮没反应。
|
||
rebuildOptimisticLayout(scope.epoch, 'rederive');
|
||
setSaving(true);
|
||
pumpWritesRef.current();
|
||
}, [initializationReady, rebuildOptimisticLayout, scopeKey]);
|
||
|
||
/**
|
||
* 显式「整理画布」:重排给定栏目内的**全部**素材(含手动摆放过的卡),结果一律落成自动
|
||
* 坐标;`sections` 为 `null` 表示「所有资源」页——当前项目全部可展示素材。
|
||
*
|
||
* 与自动同步、关系图首次就绪共用同一条写队列与 CAS 写链,只是重算前的取舍不同;可见反馈
|
||
* 继续由既有 `notice` / `saving` 承担。历史快照与撤销由调用方(工作台)在按下时先记一笔,
|
||
* 本 hook 不新增业务状态。
|
||
*
|
||
* 排队粒度是**作用范围**:不同 `sections` 各占一个队列槽、按按压顺序先后落盘(栏目 A 的
|
||
* 整理不会被随后按下的栏目 B 改写掉),同一个 `sections` 才就地合并成一笔。
|
||
*
|
||
* 返回 `false` 表示这一按在画面上什么都不会变(已经整齐、也没有需要转成自动的手动标记):
|
||
* 既不排队写、也不给调用方记历史的理由——否则"连按两下"会压进一条什么都不会做的撤销点,
|
||
* 用户按一次撤销看起来毫无反应。
|
||
*/
|
||
const organizeNow = useCallback(
|
||
(sections: readonly ProjectResourceCanvasCategory[] | null) => {
|
||
const scope = scopeRef.current;
|
||
if (
|
||
scope.key !== scopeKey ||
|
||
!initializationReady ||
|
||
initializedScopeEpochRef.current !== scope.epoch
|
||
) {
|
||
return false;
|
||
}
|
||
if (sections !== null && sections.length === 0) {
|
||
return false;
|
||
}
|
||
/**
|
||
* 「这一按到底会不会改变布局」按落盘口径判,而不是看乐观视图:
|
||
*
|
||
* - 主判据:整理结果 vs 已落盘布局(含手动标记)。与落盘那一步同源,两处判据不再打架。
|
||
* - 补判据:目标栏目里还有排队中/在途的手动落点。这些落点会先落盘、随后被这次整理
|
||
* 覆盖掉(整理丢的就是目标栏目的**全部**坐标,含手动),所以它本身就是一次真实变化。
|
||
* 少了这一条,「刚拖完就按整理」会判成"什么都没变":用户的点击被静默吞掉,接下来
|
||
* 落盘的手动结果反客为主。基准取乐观视图同样不行——那里叠着这批落点,等于自己抵消。
|
||
*/
|
||
const organized = reconcileLayout(
|
||
persistedLayoutRef.current,
|
||
resourcesRef.current,
|
||
'rederive',
|
||
topologyRef.current,
|
||
sections,
|
||
);
|
||
const hasPendingManualDropInScope = writeQueueRef.current.some(
|
||
(intent) =>
|
||
intent.kind === 'manual' &&
|
||
intent.scopeEpoch === scope.epoch &&
|
||
intent.positions.some(
|
||
(write) => sections === null || sections.includes(write.section),
|
||
),
|
||
);
|
||
if (!organized.changed && !hasPendingManualDropInScope) {
|
||
return false;
|
||
}
|
||
// 乐观视图按同一套重算落到画布上;排队中的手动落点仍在它之后落盘。
|
||
if (!rebuildOptimisticLayout(scope.epoch, 'rederive', sections)) {
|
||
return false;
|
||
}
|
||
/**
|
||
* 只在**队尾且同作用范围**时才就地合并(判据与理由见 `queuedOrganizeIntentAtTail`):
|
||
* 不同栏目各占一个队列槽、按按压顺序先后落盘,谁也不覆盖谁;命中时只刷新签名与重试
|
||
* 计数,不重排队列,先按下的那一笔仍然先落盘。
|
||
*/
|
||
const queued = queuedOrganizeIntentAtTail(
|
||
writeQueueRef.current,
|
||
activeWriteIntentRef.current,
|
||
scope.epoch,
|
||
organizeSectionsKey(sections),
|
||
);
|
||
if (queued) {
|
||
queued.resourceSignature = resourceSignatureRef.current;
|
||
queued.conflictRetries = 0;
|
||
queued.rederive = { kind: 'organize', sections };
|
||
} else {
|
||
writeQueueRef.current.push({
|
||
kind: 'resources',
|
||
scopeEpoch: scope.epoch,
|
||
resourceSignature: resourceSignatureRef.current,
|
||
conflictRetries: 0,
|
||
rederive: { kind: 'organize', sections },
|
||
});
|
||
}
|
||
// 乐观视图已经在上面的重算里落到画布上(筛选、可见性都不参与,重算的是全量资源)。
|
||
setSaving(true);
|
||
pumpWritesRef.current();
|
||
return true;
|
||
},
|
||
[initializationReady, rebuildOptimisticLayout, 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,
|
||
commitPositions,
|
||
rederiveNow,
|
||
organizeNow,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 合并同一张卡的重复手动写入:`resourceId + section` 是队列内的合并键,后来的写入覆盖先前
|
||
* 的坐标与手动标记。不同卡各自保留,因此一次多选拖动始终只是**一笔**待写。
|
||
*/
|
||
function mergeResourceCanvasPositionWrites(
|
||
target: ResourceCanvasPositionWrite[],
|
||
writes: readonly ResourceCanvasPositionWrite[],
|
||
) {
|
||
for (const write of writes) {
|
||
const index = target.findIndex(
|
||
(entry) =>
|
||
entry.resourceId === write.resourceId &&
|
||
entry.section === write.section,
|
||
);
|
||
if (index >= 0) {
|
||
target[index] = write;
|
||
} else {
|
||
target.push(write);
|
||
}
|
||
}
|
||
}
|