画布布局收口:整理只动当前栏目、多选拖动一笔提交与一次撤销

- 整理画布重排目标栏目内全部素材(含手动坐标与被筛选隐藏的卡),「所有资源」页覆盖全部栏目
- 栏外坐标逐值保留:不再把其他栏目的自动坐标丢给重算,避免被顺带规整成规范槽位
- 手动标记纳入历史快照与相等判定,撤销整理恢复原坐标与原手动标记
- 新增批量坐标写入入口:多选拖动与撤销都只排一笔 CAS、只压一条历史
- 多选拖动按同一位移整批跟随,取消 / 捕获丢失 / 失焦回滚,切项目丢弃进行中的位移
- 补测试:栏外自动坐标保留、all 页整理范围、批量撤销与取消边界
This commit is contained in:
2026-09-17 17:45:53 +08:00
parent 5c31ae91ca
commit 0a1f0e0b26
7 changed files with 1598 additions and 197 deletions
@@ -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
@@ -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,
@@ -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);
});
});