画布布局收口:整理只动当前栏目、多选拖动一笔提交与一次撤销
- 整理画布重排目标栏目内全部素材(含手动坐标与被筛选隐藏的卡),「所有资源」页覆盖全部栏目 - 栏外坐标逐值保留:不再把其他栏目的自动坐标丢给重算,避免被顺带规整成规范槽位 - 手动标记纳入历史快照与相等判定,撤销整理恢复原坐标与原手动标记 - 新增批量坐标写入入口:多选拖动与撤销都只排一笔 CAS、只压一条历史 - 多选拖动按同一位移整批跟随,取消 / 捕获丢失 / 失焦回滚,切项目丢弃进行中的位移 - 补测试:栏外自动坐标保留、all 页整理范围、批量撤销与取消边界
This commit is contained in:
+11
-8
@@ -2,8 +2,8 @@
|
||||
* 资源画布的组织操作历史:只记录资源卡的布局坐标,**不记录、也不回滚素材内容**。
|
||||
*
|
||||
* 素材不可变是硬约束,所以撤销/重做只作用于「画布上卡片怎么排」,绝不反向修改
|
||||
* manifest 条目或素材文件。持久化仍走现役的手动 CAS 写链(`commitPosition`),
|
||||
* 本模块只做纯函数的快照栈。
|
||||
* manifest 条目或素材文件。持久化仍走现役的手动 CAS 写链(`commitPositions` 的一笔批量
|
||||
* 写入),本模块只做纯函数的快照栈。
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -82,10 +82,9 @@ export function captureResourceCanvasSnapshot(
|
||||
/**
|
||||
* 两份快照是否算「同一版布局」。
|
||||
*
|
||||
* 只比 `section / x / y`:撤销写回走 `commitPosition(resourceId, section, x, y)`,
|
||||
* `manuallyPlaced` 不在写回入参里(`moveResourceCanvasPosition` 自己会置 true),
|
||||
* 所以它本来就不是可恢复的维度。把它算进相等判定,只会在「只有这个标记不同」时压进一条
|
||||
* 历史,而 `resolveResourceCanvasRestoreEntries` 按坐标过滤后返回空表 —— 撤销变成静默空操作。
|
||||
* 比 `section / x / y / manuallyPlaced` 四项。手动标记是**可恢复的维度**:「整理画布」
|
||||
* 会把目标栏目里连手动卡一起重排成自动坐标,撤销要恢复的正是"原坐标 + 原手动标记";
|
||||
* 只比坐标就会把"坐标恰好重合、标记不同"的那次操作判成没变过,撤销变成静默空操作。
|
||||
*/
|
||||
export function resourceCanvasSnapshotsEqual(
|
||||
left: ResourceCanvasLayoutSnapshot,
|
||||
@@ -101,7 +100,8 @@ export function resourceCanvasSnapshotsEqual(
|
||||
entry.resourceId === other.resourceId &&
|
||||
entry.section === other.section &&
|
||||
entry.x === other.x &&
|
||||
entry.y === other.y
|
||||
entry.y === other.y &&
|
||||
entry.manuallyPlaced === other.manuallyPlaced
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -214,6 +214,8 @@ export function canRedoResourceCanvasHistory(history: ResourceCanvasHistory) {
|
||||
|
||||
/**
|
||||
* 只回写和快照不一致的位置,避免撤销时把没动过的卡片也当成一次手动摆放写回后端。
|
||||
* `manuallyPlaced` 同样参与判定:「整理画布」之后这些卡的坐标可能恰好与整理前一致
|
||||
* (例如本来就已经整齐),但标记从手动变成了自动,撤销必须把标记恢复回去。
|
||||
*/
|
||||
export function resolveResourceCanvasRestoreEntries(
|
||||
snapshot: ResourceCanvasLayoutSnapshot,
|
||||
@@ -230,7 +232,8 @@ export function resolveResourceCanvasRestoreEntries(
|
||||
return (
|
||||
existing.section !== entry.section ||
|
||||
existing.x !== entry.x ||
|
||||
existing.y !== entry.y
|
||||
existing.y !== entry.y ||
|
||||
existing.manuallyPlaced !== entry.manuallyPlaced
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+65
-20
@@ -1334,31 +1334,76 @@ export function moveResourceCanvasPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
): ProjectResourceCanvasLayout {
|
||||
const finiteX = Number.isFinite(x) ? x : 0;
|
||||
const finiteY = Number.isFinite(y) ? y : 0;
|
||||
const normalizedX = Math.min(
|
||||
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
|
||||
Math.max(GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE, Math.round(finiteX)),
|
||||
);
|
||||
const normalizedY = Math.min(
|
||||
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
|
||||
Math.max(GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE, Math.round(finiteY)),
|
||||
);
|
||||
return applyResourceCanvasPositionWrites(layout, [
|
||||
{ resourceId, section, x, y, manuallyPlaced: true },
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一笔写回里的单张卡坐标。`manuallyPlaced` 由调用方显式给出,不从"是不是用户拖的"推断:
|
||||
* 多选拖动与撤销恢复都可能在一笔里同时出现手动卡与自动卡(撤销「整理画布」恢复的正是
|
||||
* 原来的**自动**卡标记),因此这个标记必须是入参,不能由写入本身改写。
|
||||
*/
|
||||
export type ResourceCanvasPositionWrite = {
|
||||
resourceId: string;
|
||||
section: ProjectResourceCanvasCategory;
|
||||
x: number;
|
||||
y: number;
|
||||
manuallyPlaced: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量坐标写入:一次把多张卡的坐标(含手动标记)落到同一个布局对象上。
|
||||
*
|
||||
* 与 `moveResourceCanvasPosition` 同一条归一化与匹配口径——只改写 `resourceId + section`
|
||||
* 都对得上的那一条,坐标夹到安全范围并取整;不新增、不删除、不重排分区,找不到的写入
|
||||
* 静默跳过(资源可能在拖动期间被删掉)。调用方据此把"多选拖动 / 撤销一次"变成**一笔**
|
||||
* 布局写入,而不是每张卡各排一笔。
|
||||
*/
|
||||
export function applyResourceCanvasPositionWrites(
|
||||
layout: ProjectResourceCanvasLayout,
|
||||
writes: readonly ResourceCanvasPositionWrite[],
|
||||
): ProjectResourceCanvasLayout {
|
||||
if (writes.length === 0) {
|
||||
return layout;
|
||||
}
|
||||
const writeByResourceId = new Map<
|
||||
string,
|
||||
Pick<ResourceCanvasPositionWrite, 'section' | 'x' | 'y' | 'manuallyPlaced'>
|
||||
>();
|
||||
for (const write of writes) {
|
||||
writeByResourceId.set(write.resourceId, {
|
||||
section: write.section,
|
||||
x: write.x,
|
||||
y: write.y,
|
||||
manuallyPlaced: write.manuallyPlaced,
|
||||
});
|
||||
}
|
||||
return {
|
||||
...layout,
|
||||
positions: layout.positions.map((position) =>
|
||||
position.resourceId === resourceId && position.section === section
|
||||
? {
|
||||
...position,
|
||||
x: normalizedX,
|
||||
y: normalizedY,
|
||||
manuallyPlaced: true,
|
||||
}
|
||||
: position,
|
||||
),
|
||||
positions: layout.positions.map((position) => {
|
||||
const write = writeByResourceId.get(position.resourceId);
|
||||
if (!write || write.section !== position.section) {
|
||||
return position;
|
||||
}
|
||||
return {
|
||||
...position,
|
||||
x: normalizedResourceCanvasCoordinate(write.x),
|
||||
y: normalizedResourceCanvasCoordinate(write.y),
|
||||
manuallyPlaced: write.manuallyPlaced,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedResourceCanvasCoordinate(value: number) {
|
||||
const finite = Number.isFinite(value) ? value : 0;
|
||||
return Math.min(
|
||||
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
|
||||
Math.max(GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE, Math.round(finite)),
|
||||
);
|
||||
}
|
||||
|
||||
export function resourceCanvasSectionExtent(
|
||||
positions: readonly ProjectResourceCanvasPosition[],
|
||||
cardSizeByResourceId?: ResourceCanvasCardSizeByResourceId,
|
||||
|
||||
+244
-75
@@ -12,11 +12,12 @@ import {
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { normalizeDiagnosticText } from '../../services/errorReporting';
|
||||
import {
|
||||
applyResourceCanvasPositionWrites,
|
||||
createEmptyResourceCanvasLayout,
|
||||
moveResourceCanvasPosition,
|
||||
reconcileResourceCanvasLayout,
|
||||
type ResourceCanvasItem,
|
||||
type ResourceCanvasLayoutTopology,
|
||||
type ResourceCanvasPositionWrite,
|
||||
} from './resourceCanvasLayoutModel';
|
||||
import { normalizeResourceCanvasPosition } from './resourceCanvasSectionMapping';
|
||||
|
||||
@@ -53,29 +54,46 @@ type LayoutScope = {
|
||||
mode: ProjectResourceCanvasLayoutMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* 一笔手动坐标写入:多选拖动、撤销/重做恢复都只排**一笔**,`positions` 里的每一张卡各自
|
||||
* 带自己的 `section` 与 `manuallyPlaced`。队列按 `resourceId + section` 合并同一张卡的重复
|
||||
* 写入,语义仍是"最后落点赢",不会因为一次多选拖动就生成 N 笔 CAS。
|
||||
*/
|
||||
type ManualLayoutWriteIntent = {
|
||||
kind: 'manual';
|
||||
scopeEpoch: number;
|
||||
resourceId: string;
|
||||
section: ProjectResourceCanvasCategory;
|
||||
x: number;
|
||||
y: number;
|
||||
positions: ResourceCanvasPositionWrite[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 自动坐标重派生请求。
|
||||
*
|
||||
* - `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` 策略丢掉全部自动坐标重算。
|
||||
* 显式「整理画布」/「关系图首次就绪」写意图:这一笔写回按 `rederive` 策略丢坐标重算。
|
||||
*
|
||||
* 仍然是"坐标真的变了才落盘"(沿用既有 `changed` 门):已经整齐的画布按一下不该产生
|
||||
* 一次无意义的 CAS / revision 推进,也不该在关系图被截断这类"重算结果同样可信但不能
|
||||
* 声称变过"的场景里凭空写一笔。自动(签名变化触发)的资源同步永远是 `false`,
|
||||
* 只补新卡、不动既有坐标。
|
||||
*/
|
||||
rederive: boolean;
|
||||
rederive: LayoutRederiveRequest | null;
|
||||
};
|
||||
|
||||
type LayoutWriteIntent = ManualLayoutWriteIntent | ResourceLayoutWriteIntent;
|
||||
@@ -365,8 +383,46 @@ function reconcileLayout(
|
||||
resources: ResourceCanvasItem[],
|
||||
policy: AutomaticPositionPolicy,
|
||||
topology: ResourceCanvasLayoutTopology | undefined,
|
||||
/**
|
||||
* 「整理画布」的目标栏目集合。给了(含 `null` = 全部栏目)就在重算前把这些栏目里的坐标
|
||||
* **连手动标记一起丢掉**,重算结果一律落成自动坐标;没给的栏目**逐值不动、连自动坐标也不
|
||||
* 重排**(详见下面的保留判据)。判定按 `normalizeResourceCanvasPosition` 归并后的现行分区
|
||||
* 做,磁盘上仍是旧栏目取值的坐标不会因为 `section` 字面量不同而漏出整理范围。
|
||||
*/
|
||||
organizeSections:
|
||||
| readonly ProjectResourceCanvasCategory[]
|
||||
| null
|
||||
| undefined = undefined,
|
||||
) {
|
||||
if (policy === 'preserve') {
|
||||
// `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,
|
||||
@@ -376,12 +432,27 @@ function reconcileLayout(
|
||||
? reconciled
|
||||
: { layout: source, changed: false };
|
||||
}
|
||||
const manualSource = {
|
||||
/**
|
||||
* 保留判据按「这一次重算的范围」分两种口径:
|
||||
* - **整理**(`organizeSections` 已给):只丢目标栏目的坐标,栏外坐标逐值保留,**包括自动
|
||||
* 坐标**。自动坐标不是「重算一次必然一样」的派生值:真实项目里它可能来自更早版本的排布,
|
||||
* 或素材被删、被拖走后留下的既有位置;顺手把它们丢给重算,就等于「整理当前栏目」时把别的
|
||||
* 栏目重新规整成规范槽位,与「其他栏目不变」相反。
|
||||
* - **全局重算**(`rederive`:关系图首次就绪那一次):丢全部自动坐标、手动卡原地不动,
|
||||
* 由重算把自动槽位补齐。
|
||||
*/
|
||||
const keepsPosition = (
|
||||
position: ProjectResourceCanvasLayout['positions'][number],
|
||||
) =>
|
||||
organizeSections !== undefined
|
||||
? !dropPosition(position)
|
||||
: policy === 'preserve' || position.manuallyPlaced;
|
||||
const nextSource = {
|
||||
...source,
|
||||
positions: source.positions.filter((position) => position.manuallyPlaced),
|
||||
positions: source.positions.filter(keepsPosition),
|
||||
};
|
||||
const reconciled = reconcileResourceCanvasLayout(
|
||||
manualSource,
|
||||
nextSource,
|
||||
resources,
|
||||
topology,
|
||||
);
|
||||
@@ -487,29 +558,29 @@ export function useProjectResourceCanvasLayout({
|
||||
}, []);
|
||||
|
||||
const rebuildOptimisticLayout = useCallback(
|
||||
(scopeEpoch: number, policy: AutomaticPositionPolicy) => {
|
||||
(
|
||||
scopeEpoch: number,
|
||||
policy: AutomaticPositionPolicy,
|
||||
organizeSections?: readonly ProjectResourceCanvasCategory[] | null,
|
||||
) => {
|
||||
const scope = scopeRef.current;
|
||||
if (scope.epoch !== scopeEpoch) {
|
||||
return;
|
||||
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 = moveResourceCanvasPosition(
|
||||
next,
|
||||
intent.resourceId,
|
||||
intent.section,
|
||||
intent.x,
|
||||
intent.y,
|
||||
);
|
||||
next = applyResourceCanvasPositionWrites(next, intent.positions);
|
||||
}
|
||||
}
|
||||
applyLayout(next);
|
||||
return next;
|
||||
},
|
||||
[applyLayout],
|
||||
);
|
||||
@@ -542,7 +613,7 @@ export function useProjectResourceCanvasLayout({
|
||||
scopeEpoch,
|
||||
resourceSignature: signature,
|
||||
conflictRetries,
|
||||
rederive: false,
|
||||
rederive: null,
|
||||
});
|
||||
}
|
||||
if (mountedRef.current) {
|
||||
@@ -573,6 +644,10 @@ export function useProjectResourceCanvasLayout({
|
||||
return;
|
||||
}
|
||||
|
||||
const organizeSections =
|
||||
intent.kind === 'resources' && intent.rederive?.kind === 'organize'
|
||||
? intent.rederive.sections
|
||||
: undefined;
|
||||
const writePolicy =
|
||||
intent.kind === 'manual'
|
||||
? MANUAL_WRITE_AUTOMATIC_POSITION_POLICY
|
||||
@@ -584,41 +659,38 @@ export function useProjectResourceCanvasLayout({
|
||||
resourcesRef.current,
|
||||
writePolicy,
|
||||
topologyRef.current,
|
||||
organizeSections,
|
||||
);
|
||||
if (intent.kind === 'resources' && !reconciled.changed) {
|
||||
removeWriteIntent(intent);
|
||||
rebuildOptimisticLayout(scope.epoch, writePolicy);
|
||||
void Promise.resolve().then(() => pumpWritesRef.current());
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* 坐标真的没变就不落盘:`changed` 门对整理/自动同步是老口径,这一条补给它的是手动
|
||||
* 一笔(多选拖动或撤销恢复)。判据含 `manuallyPlaced`——撤销整理恢复的正是"坐标相同、
|
||||
* 但必须从自动标记回到手动标记"的那一批卡,那种情况必须照写。
|
||||
*/
|
||||
const manualCandidate =
|
||||
intent.kind === 'manual'
|
||||
? applyResourceCanvasPositionWrites(
|
||||
reconciled.layout,
|
||||
intent.positions,
|
||||
)
|
||||
: null;
|
||||
const manualWritesAnything =
|
||||
manualCandidate !== null &&
|
||||
!automaticCoordinatesMatch(reconciled.layout, manualCandidate);
|
||||
if (
|
||||
intent.kind === 'manual' &&
|
||||
!reconciled.layout.positions.some(
|
||||
(position) =>
|
||||
position.resourceId === intent.resourceId &&
|
||||
position.section === intent.section,
|
||||
)
|
||||
(intent.kind === 'resources' && !reconciled.changed) ||
|
||||
(intent.kind === 'manual' && !manualWritesAnything)
|
||||
) {
|
||||
removeWriteIntent(intent);
|
||||
rebuildOptimisticLayout(scope.epoch, writePolicy);
|
||||
rebuildOptimisticLayout(scope.epoch, writePolicy, organizeSections);
|
||||
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 candidate = manualCandidate ?? reconciled.layout;
|
||||
const invoke = window.__TAURI__?.core?.invoke;
|
||||
if (!invoke) {
|
||||
persistedLayoutRef.current = candidate;
|
||||
removeWriteIntent(intent);
|
||||
rebuildOptimisticLayout(scope.epoch, writePolicy);
|
||||
rebuildOptimisticLayout(scope.epoch, writePolicy, organizeSections);
|
||||
const hasQueuedWrite = writeQueueRef.current.some(
|
||||
(queued) => queued.scopeEpoch === scope.epoch,
|
||||
);
|
||||
@@ -637,7 +709,7 @@ export function useProjectResourceCanvasLayout({
|
||||
!layoutCoordinatesAreSafe(candidate)
|
||||
) {
|
||||
removeWriteIntent(intent);
|
||||
rebuildOptimisticLayout(scope.epoch, writePolicy);
|
||||
rebuildOptimisticLayout(scope.epoch, writePolicy, organizeSections);
|
||||
if (intent.kind === 'manual') {
|
||||
redragRequiredScopeEpochRef.current = null;
|
||||
}
|
||||
@@ -700,7 +772,8 @@ export function useProjectResourceCanvasLayout({
|
||||
redragRequiredScopeEpochRef.current = null;
|
||||
setNotice('布局已保存');
|
||||
} else if (intent.rederive) {
|
||||
// 显式整理复用同一条提示:用户按了按钮,就必须看到"这次重算真的落盘了"。
|
||||
// 显式整理与关系图首次就绪复用同一条提示:用户按了按钮,就必须看到
|
||||
// "这次重算真的落盘了"。
|
||||
setNotice('布局已保存');
|
||||
}
|
||||
if (needsResourceSync) {
|
||||
@@ -734,7 +807,11 @@ export function useProjectResourceCanvasLayout({
|
||||
setNotice('布局已在其他窗口更新');
|
||||
}
|
||||
}
|
||||
rebuildOptimisticLayout(currentScope.epoch, writePolicy);
|
||||
rebuildOptimisticLayout(
|
||||
currentScope.epoch,
|
||||
writePolicy,
|
||||
organizeSections,
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const currentScope = scopeRef.current;
|
||||
@@ -747,7 +824,11 @@ export function useProjectResourceCanvasLayout({
|
||||
`[resource-canvas-layout] 布局保存失败:${describeResourceCanvasLayoutFailure(error)}`,
|
||||
);
|
||||
removeWriteIntent(intent);
|
||||
rebuildOptimisticLayout(currentScope.epoch, writePolicy);
|
||||
rebuildOptimisticLayout(
|
||||
currentScope.epoch,
|
||||
writePolicy,
|
||||
organizeSections,
|
||||
);
|
||||
if (intent.kind === 'manual') {
|
||||
redragRequiredScopeEpochRef.current = null;
|
||||
}
|
||||
@@ -969,14 +1050,19 @@ export function useProjectResourceCanvasLayout({
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [notice]);
|
||||
|
||||
const commitPosition = useCallback(
|
||||
(
|
||||
resourceId: string,
|
||||
section: ProjectResourceCanvasCategory,
|
||||
x: number,
|
||||
y: number,
|
||||
) => {
|
||||
/**
|
||||
* 手动坐标的批量写入口:多选拖动与撤销/重做都只调用一次,整批卡排成**一笔** CAS。
|
||||
*
|
||||
* 队列里同一张卡(`resourceId + section`)的重复写入就地覆盖——最后落点赢,同一次操作
|
||||
* 里的中间态不需要逐笔排队;在途那笔之后的新写入仍排在它后面(沿用既有顺序语义)。
|
||||
* 乐观视图按同一批写入立即更新,失败/冲突仍走既有 `notice` 与恢复分支。
|
||||
*/
|
||||
const commitPositions = useCallback(
|
||||
(writes: readonly ResourceCanvasPositionWrite[]) => {
|
||||
const scope = scopeRef.current;
|
||||
if (writes.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
scope.key !== scopeKey ||
|
||||
!initializationReady ||
|
||||
@@ -988,38 +1074,38 @@ export function useProjectResourceCanvasLayout({
|
||||
(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;
|
||||
mergeResourceCanvasPositionWrites(queuedIntent.positions, writes);
|
||||
} else {
|
||||
writeQueueRef.current.push({
|
||||
kind: 'manual',
|
||||
scopeEpoch: scope.epoch,
|
||||
resourceId,
|
||||
section,
|
||||
x,
|
||||
y,
|
||||
positions: [...writes],
|
||||
});
|
||||
}
|
||||
applyLayout(
|
||||
moveResourceCanvasPosition(
|
||||
layoutRef.current,
|
||||
resourceId,
|
||||
section,
|
||||
x,
|
||||
y,
|
||||
),
|
||||
);
|
||||
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` 门),画布本身先乐观按重算结果显示。
|
||||
@@ -1041,19 +1127,20 @@ export function useProjectResourceCanvasLayout({
|
||||
(intent): intent is ResourceLayoutWriteIntent =>
|
||||
intent.kind === 'resources' &&
|
||||
intent.scopeEpoch === scope.epoch &&
|
||||
intent.rederive &&
|
||||
intent.rederive !== null &&
|
||||
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: true,
|
||||
rederive: { kind: 'automatic' },
|
||||
});
|
||||
}
|
||||
// 乐观视图:立刻把重算结果显示出来,别让用户以为按钮没反应。
|
||||
@@ -1062,6 +1149,65 @@ export function useProjectResourceCanvasLayout({
|
||||
pumpWritesRef.current();
|
||||
}, [initializationReady, rebuildOptimisticLayout, scopeKey]);
|
||||
|
||||
/**
|
||||
* 显式「整理画布」:重排给定栏目内的**全部**素材(含手动摆放过的卡),结果一律落成自动
|
||||
* 坐标;`sections` 为 `null` 表示「所有资源」页——当前项目全部可展示素材。
|
||||
*
|
||||
* 与自动同步、关系图首次就绪共用同一条写队列与 CAS 写链,只是重算前的取舍不同;可见反馈
|
||||
* 继续由既有 `notice` / `saving` 承担。历史快照与撤销由调用方(工作台)在按下时先记一笔,
|
||||
* 本 hook 不新增业务状态。
|
||||
*
|
||||
* 返回 `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;
|
||||
}
|
||||
// 先按同一套重算看结果:与当前画面逐值相同(含手动标记)就什么都不做。
|
||||
const previous = layoutRef.current;
|
||||
const next = rebuildOptimisticLayout(scope.epoch, 'rederive', sections);
|
||||
if (!next || positionsEqual(previous.positions, next.positions)) {
|
||||
return false;
|
||||
}
|
||||
const queued = writeQueueRef.current.find(
|
||||
(intent): intent is ResourceLayoutWriteIntent =>
|
||||
intent.kind === 'resources' &&
|
||||
intent.scopeEpoch === scope.epoch &&
|
||||
intent.rederive !== null &&
|
||||
intent !== activeWriteIntentRef.current,
|
||||
);
|
||||
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 &&
|
||||
@@ -1093,6 +1239,29 @@ export function useProjectResourceCanvasLayout({
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
pushResourceCanvasHistory,
|
||||
redoResourceCanvasHistory,
|
||||
resolveResourceCanvasRestoreEntries,
|
||||
resourceCanvasSnapshotsEqual,
|
||||
undoResourceCanvasHistory,
|
||||
} from '../src/features/resource-canvas/resourceCanvasHistoryModel';
|
||||
|
||||
@@ -155,6 +156,54 @@ describe('resource canvas history model', () => {
|
||||
expect(restore.map((entry) => entry.resourceId)).toEqual(['asset:b']);
|
||||
});
|
||||
|
||||
it('坐标完全相同但手动标记不同,仍算两次不同的布局', () => {
|
||||
const manual = captureResourceCanvasSnapshot(
|
||||
positions([['asset:a', 10, 20, true]]),
|
||||
);
|
||||
const automatic = captureResourceCanvasSnapshot(
|
||||
positions([['asset:a', 10, 20, false]]),
|
||||
);
|
||||
|
||||
expect(resourceCanvasSnapshotsEqual(manual, automatic)).toBe(false);
|
||||
const history = pushResourceCanvasHistory(createResourceCanvasHistory(), {
|
||||
label: '整理画布',
|
||||
snapshot: manual,
|
||||
});
|
||||
expect(
|
||||
pushResourceCanvasHistory(history, {
|
||||
label: '整理画布',
|
||||
snapshot: automatic,
|
||||
}).undoStack,
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('坐标一致但手动标记不同时,撤销仍然回写这条标记', () => {
|
||||
const snapshot = captureResourceCanvasSnapshot(
|
||||
positions([
|
||||
['asset:a', 10, 20, true],
|
||||
['asset:b', 30, 40, false],
|
||||
]),
|
||||
);
|
||||
const restore = resolveResourceCanvasRestoreEntries(
|
||||
snapshot,
|
||||
positions([
|
||||
// 整理画布之后:坐标没变,但标记从手动变成了自动。
|
||||
['asset:a', 10, 20, false],
|
||||
['asset:b', 30, 40, false],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(restore).toEqual([
|
||||
{
|
||||
resourceId: 'asset:a',
|
||||
section: 'document',
|
||||
x: 10,
|
||||
y: 20,
|
||||
manuallyPlaced: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('快照里不存在的资源不会在撤销时被新建出来', () => {
|
||||
const snapshot = captureResourceCanvasSnapshot(
|
||||
positions([['asset:deleted', 10, 20]]),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1546,3 +1546,375 @@ describe('布局落盘 / 读盘失败的原因不再被吞掉', () => {
|
||||
expect(logged[0]).not.toContain('\n');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 「整理画布」与批量坐标写入的 hook 级口径。
|
||||
*
|
||||
* 整理只作用于目标栏目:栏内**全部**素材(含手动摆放过的卡)一起重算成自动坐标,其他栏目
|
||||
* 逐值不动;整次整理仍是一笔 CAS。手动写入支持一次多张卡(含手动标记),因此多选拖动与
|
||||
* 撤销恢复都不会退化成"每卡一笔"。
|
||||
*/
|
||||
describe('资源画布整理与批量坐标写入', () => {
|
||||
function characterResource(id: string): ResourceCanvasItem {
|
||||
return { ...resource(id), category: 'character' };
|
||||
}
|
||||
|
||||
function typeLayoutHarness(initialPositions: ProjectResourceCanvasPosition[]) {
|
||||
const updates: ProjectResourceCanvasPosition[][] = [];
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_local_project_resource_canvas_layout') {
|
||||
return persistedLayout('type', 7, structuredClone(initialPositions));
|
||||
}
|
||||
if (command === 'update_local_project_resource_canvas_layout') {
|
||||
const positions = structuredClone(
|
||||
args?.positions as ProjectResourceCanvasPosition[],
|
||||
);
|
||||
updates.push(positions);
|
||||
return {
|
||||
status: 'updated',
|
||||
layout: persistedLayout('type', 8, positions),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
return { invoke, updates };
|
||||
}
|
||||
|
||||
/**
|
||||
* 等读盘之后的协调写回彻底落地再清空记账:打开项目那次"归并 / 补位"写回与本次要验的
|
||||
* 整理、多选写入是两回事,混在一起数笔数会把既有口径算成本次行为。
|
||||
*/
|
||||
async function settleInitialWrites(
|
||||
result: { current: { settled: boolean } },
|
||||
updates: ProjectResourceCanvasPosition[][],
|
||||
) {
|
||||
await waitFor(() => expect(result.current.settled).toBe(true));
|
||||
updates.length = 0;
|
||||
}
|
||||
|
||||
it('整理栏目时连手动坐标一起重算,其他栏目逐值不动', async () => {
|
||||
const documentA = resource('resource-doc-a');
|
||||
const documentB = { ...resource('resource-doc-b'), dependencyDepth: 1 };
|
||||
const characterA = characterResource('resource-char-a');
|
||||
const { updates } = typeLayoutHarness([
|
||||
position('resource-doc-a', 600, 40),
|
||||
automaticPosition('resource-doc-b', 900, 900),
|
||||
{ ...position('resource-char-a', 777, 55), section: 'character' },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useProjectResourceCanvasLayout({
|
||||
projectPath,
|
||||
projectId,
|
||||
mode: 'type',
|
||||
resources: [documentA, documentB, characterA],
|
||||
}),
|
||||
);
|
||||
await settleInitialWrites(result, updates);
|
||||
|
||||
act(() => result.current.organizeNow(['document']));
|
||||
|
||||
await waitFor(() => expect(updates).toHaveLength(1));
|
||||
expect(updates[0]).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
resourceId: 'resource-doc-a',
|
||||
section: 'document',
|
||||
x: 0,
|
||||
y: 0,
|
||||
manuallyPlaced: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
resourceId: 'resource-doc-b',
|
||||
section: 'document',
|
||||
x: 196,
|
||||
y: 0,
|
||||
manuallyPlaced: false,
|
||||
}),
|
||||
// 其他栏目:手动坐标与手动标记原样保留。
|
||||
expect.objectContaining({
|
||||
resourceId: 'resource-char-a',
|
||||
section: 'character',
|
||||
x: 777,
|
||||
y: 55,
|
||||
manuallyPlaced: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('整理不重排其他栏目的自动坐标:栏外坐标逐值原样保留', async () => {
|
||||
const documentA = resource('resource-doc-a');
|
||||
/**
|
||||
* 其他栏目的自动卡故意放在「非规范槽位」上:这类坐标在真实项目里来自更早版本的排布、
|
||||
* 或素材被删后留下的既有位置。整理当前栏目只允许丢**目标栏目**的坐标,因此这些卡
|
||||
* 必须逐值保留,而不是被丢进重算后按空栏目规整回 (0,0)。
|
||||
*/
|
||||
const characterA = characterResource('resource-char-auto-a');
|
||||
const characterB = characterResource('resource-char-auto-b');
|
||||
const { updates } = typeLayoutHarness([
|
||||
position('resource-doc-a', 600, 40),
|
||||
{ ...automaticPosition('resource-char-auto-a', 900, 900), section: 'character' },
|
||||
{ ...automaticPosition('resource-char-auto-b', 1100, 940), section: 'character' },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useProjectResourceCanvasLayout({
|
||||
projectPath,
|
||||
projectId,
|
||||
mode: 'type',
|
||||
resources: [documentA, characterA, characterB],
|
||||
}),
|
||||
);
|
||||
await settleInitialWrites(result, updates);
|
||||
|
||||
act(() => result.current.organizeNow(['document']));
|
||||
|
||||
await waitFor(() => expect(updates).toHaveLength(1));
|
||||
expect(updates[0]).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
resourceId: 'resource-doc-a',
|
||||
x: 0,
|
||||
y: 0,
|
||||
manuallyPlaced: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
resourceId: 'resource-char-auto-a',
|
||||
section: 'character',
|
||||
x: 900,
|
||||
y: 900,
|
||||
manuallyPlaced: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
resourceId: 'resource-char-auto-b',
|
||||
section: 'character',
|
||||
x: 1100,
|
||||
y: 940,
|
||||
manuallyPlaced: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('「所有资源」范围(null)整理全部栏目,不留下手动标记', async () => {
|
||||
const documentA = resource('resource-doc-a');
|
||||
const characterA = characterResource('resource-char-a');
|
||||
const { updates } = typeLayoutHarness([
|
||||
position('resource-doc-a', 600, 40),
|
||||
{ ...automaticPosition('resource-char-a', 900, 900), section: 'character' },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useProjectResourceCanvasLayout({
|
||||
projectPath,
|
||||
projectId,
|
||||
mode: 'type',
|
||||
resources: [documentA, characterA],
|
||||
}),
|
||||
);
|
||||
await settleInitialWrites(result, updates);
|
||||
|
||||
act(() => result.current.organizeNow(null));
|
||||
|
||||
await waitFor(() => expect(updates).toHaveLength(1));
|
||||
expect(updates[0]).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
resourceId: 'resource-doc-a',
|
||||
section: 'document',
|
||||
x: 0,
|
||||
y: 0,
|
||||
manuallyPlaced: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
resourceId: 'resource-char-a',
|
||||
section: 'character',
|
||||
x: 0,
|
||||
y: 0,
|
||||
manuallyPlaced: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('没有可整理栏目时不产生任何写入', async () => {
|
||||
const documentA = resource('resource-doc-a');
|
||||
const { updates } = typeLayoutHarness([position('resource-doc-a', 600, 40)]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useProjectResourceCanvasLayout({
|
||||
projectPath,
|
||||
projectId,
|
||||
mode: 'type',
|
||||
resources: [documentA],
|
||||
}),
|
||||
);
|
||||
await settleInitialWrites(result, updates);
|
||||
|
||||
await act(async () => {
|
||||
result.current.organizeNow([]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(updates).toEqual([]);
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
|
||||
it('批量手动写入只排一笔 CAS,并按下发的标记落盘', async () => {
|
||||
const documentA = resource('resource-doc-a');
|
||||
const documentB = resource('resource-doc-b');
|
||||
const { invoke, updates } = typeLayoutHarness([
|
||||
automaticPosition('resource-doc-a', 10, 20),
|
||||
automaticPosition('resource-doc-b', 30, 40),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useProjectResourceCanvasLayout({
|
||||
projectPath,
|
||||
projectId,
|
||||
mode: 'type',
|
||||
resources: [documentA, documentB],
|
||||
}),
|
||||
);
|
||||
await settleInitialWrites(result, updates);
|
||||
|
||||
await act(async () => {
|
||||
result.current.commitPositions([
|
||||
{
|
||||
resourceId: 'resource-doc-a',
|
||||
section: 'document',
|
||||
x: 110,
|
||||
y: 30,
|
||||
manuallyPlaced: true,
|
||||
},
|
||||
{
|
||||
resourceId: 'resource-doc-b',
|
||||
section: 'document',
|
||||
x: 210,
|
||||
y: 60,
|
||||
manuallyPlaced: false,
|
||||
},
|
||||
]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(updates).toHaveLength(1));
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) =>
|
||||
command === 'update_local_project_resource_canvas_layout',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(updates[0]).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
resourceId: 'resource-doc-a',
|
||||
x: 110,
|
||||
y: 30,
|
||||
manuallyPlaced: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
resourceId: 'resource-doc-b',
|
||||
x: 210,
|
||||
y: 60,
|
||||
manuallyPlaced: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('只有手动标记变化(撤销整理)时仍然落盘,完全没变化时不写', async () => {
|
||||
const documentA = resource('resource-doc-a');
|
||||
const { updates } = typeLayoutHarness([
|
||||
automaticPosition('resource-doc-a', 10, 20),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useProjectResourceCanvasLayout({
|
||||
projectPath,
|
||||
projectId,
|
||||
mode: 'type',
|
||||
resources: [documentA],
|
||||
}),
|
||||
);
|
||||
await settleInitialWrites(result, updates);
|
||||
|
||||
await act(async () => {
|
||||
result.current.commitPositions([
|
||||
{
|
||||
resourceId: 'resource-doc-a',
|
||||
section: 'document',
|
||||
x: 10,
|
||||
y: 20,
|
||||
manuallyPlaced: false,
|
||||
},
|
||||
]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
// 坐标与标记都没变:不产生无意义的一笔。
|
||||
expect(updates).toEqual([]);
|
||||
|
||||
await act(async () => {
|
||||
result.current.commitPositions([
|
||||
{
|
||||
resourceId: 'resource-doc-a',
|
||||
section: 'document',
|
||||
x: 10,
|
||||
y: 20,
|
||||
manuallyPlaced: true,
|
||||
},
|
||||
]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await waitFor(() => expect(updates).toHaveLength(1));
|
||||
expect(updates[0]?.[0]).toMatchObject({
|
||||
resourceId: 'resource-doc-a',
|
||||
x: 10,
|
||||
y: 20,
|
||||
manuallyPlaced: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('整理写入失败时保留失败提示,不伪报保存成功', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
const documentA = resource('resource-doc-a');
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'read_local_project_resource_canvas_layout') {
|
||||
return persistedLayout('type', 7, [
|
||||
position('resource-doc-a', 600, 40),
|
||||
]);
|
||||
}
|
||||
if (command === 'update_local_project_resource_canvas_layout') {
|
||||
throw new Error('layout write rejected');
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
});
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useProjectResourceCanvasLayout({
|
||||
projectPath,
|
||||
projectId,
|
||||
mode: 'type',
|
||||
resources: [documentA],
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(result.current.ready).toBe(true));
|
||||
|
||||
act(() => result.current.organizeNow(['document']));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.notice).toBe('布局保存失败,已保留当前会话布局'),
|
||||
);
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) =>
|
||||
command === 'update_local_project_resource_canvas_layout',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user