画布修复:重算意图各占队列槽、撤销不跨排序模式、整理判据与落盘同源
- 显式整理与「关系图首次就绪」各占一个队列槽,系统重算不再改写用户已按下的那笔整理 - 资源签名同步覆盖排队中的每一笔资源意图,先落盘的那笔不会把后一笔留成过期签名 - 撤销栈按「项目 + 排序模式」隔离:切模式后不会把另一份 sidecar 的坐标写过去 - 整理的「有没有变化」判据与落盘同源,目标栏目还有在途/排队手动落点时也算一次真实变化 - 删除本次改动退役且已无调用方的 resourceBookAllBandLocalPoint 与布局 dragX/dragY - 补回归:排队中整理不被覆盖、切模式撤销不跨写、在途拖动后整理照常生效、分类变更后拖动按新栏目落盘
This commit is contained in:
@@ -179,6 +179,7 @@ import {
|
||||
pushResourceCanvasHistory,
|
||||
redoResourceCanvasHistory,
|
||||
resolveResourceCanvasRestoreEntries,
|
||||
type ResourceCanvasHistory,
|
||||
type ResourceCanvasLayoutSnapshot,
|
||||
undoResourceCanvasHistory,
|
||||
} from '../../features/resource-canvas/resourceCanvasHistoryModel';
|
||||
@@ -419,6 +420,14 @@ export function resolveResourceCardDragMoves({
|
||||
return moves.length > 0 ? moves : [moveFor(resource)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销栈作用域不匹配时对外呈现的空栈。
|
||||
*
|
||||
* 单独放一个共享常量,而不是每次渲染新建:`canUndo / canRedo` 与撤销回调都按历史对象做依赖,
|
||||
* 每帧换一个空对象会让它们无意义地反复重建。
|
||||
*/
|
||||
const EMPTY_RESOURCE_CANVAS_HISTORY = createResourceCanvasHistory();
|
||||
|
||||
function isPlatformAuthenticationRequired(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
@@ -1367,8 +1376,6 @@ function ResourceBookScene({
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
dragX: number;
|
||||
dragY: number;
|
||||
},
|
||||
) => ReactNode;
|
||||
/** 渲染在画本世界坐标系里的覆盖层(框选矩形等)。 */
|
||||
@@ -1678,9 +1685,49 @@ export default function ProjectDevelopmentView({
|
||||
const [resourceInfoPanelOpen, setResourceInfoPanelOpen] = useState(false);
|
||||
const [resourceCanvasMarquee, setResourceCanvasMarquee] =
|
||||
useState<CanvasMarqueeState | null>(null);
|
||||
/** 资源卡组织操作历史:只回滚布局坐标,不回滚素材。 */
|
||||
const [resourceCanvasHistory, setResourceCanvasHistory] = useState(
|
||||
createResourceCanvasHistory,
|
||||
/**
|
||||
* 资源卡组织操作历史:只回滚布局坐标,不回滚素材。
|
||||
*
|
||||
* 撤销栈按「项目 + 排序模式」隔离:两种排序各有一份独立的布局 sidecar,同一份历史套用到
|
||||
* 另一份 sidecar 上就是把坐标写进别人的文件。作用域记在状态里、读的时候先比对——切模式后
|
||||
* 当前历史按空栈看待,但那一段历史仍然留在状态里(切回来还能继续撤销),也不会被当成
|
||||
* 当前作用域的历史被撤销消费掉。
|
||||
*/
|
||||
const resourceCanvasHistoryScopeKey = JSON.stringify([
|
||||
projectPath,
|
||||
manifest.projectId,
|
||||
sortMode,
|
||||
]);
|
||||
const [resourceCanvasHistoryState, setResourceCanvasHistoryState] = useState<{
|
||||
scopeKey: string;
|
||||
history: ResourceCanvasHistory;
|
||||
}>(() => ({
|
||||
scopeKey: resourceCanvasHistoryScopeKey,
|
||||
history: createResourceCanvasHistory(),
|
||||
}));
|
||||
const resourceCanvasHistory =
|
||||
resourceCanvasHistoryState.scopeKey === resourceCanvasHistoryScopeKey
|
||||
? resourceCanvasHistoryState.history
|
||||
: EMPTY_RESOURCE_CANVAS_HISTORY;
|
||||
const setResourceCanvasHistory = useCallback(
|
||||
(
|
||||
update:
|
||||
| ResourceCanvasHistory
|
||||
| ((history: ResourceCanvasHistory) => ResourceCanvasHistory),
|
||||
) => {
|
||||
setResourceCanvasHistoryState((current) => ({
|
||||
scopeKey: resourceCanvasHistoryScopeKey,
|
||||
history:
|
||||
typeof update === 'function'
|
||||
? update(
|
||||
current.scopeKey === resourceCanvasHistoryScopeKey
|
||||
? current.history
|
||||
: createResourceCanvasHistory(),
|
||||
)
|
||||
: update,
|
||||
}));
|
||||
},
|
||||
[resourceCanvasHistoryScopeKey],
|
||||
);
|
||||
const [resourcePanelOpen, setResourcePanelOpen] = useState(false);
|
||||
const [resourceDocumentPreviewIdentity, setResourceDocumentPreviewIdentity] =
|
||||
@@ -6447,8 +6494,6 @@ export default function ProjectDevelopmentView({
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
dragX: number;
|
||||
dragY: number;
|
||||
},
|
||||
) => {
|
||||
const previewIdentity = resourceCardPreviews.identityByResourceId.get(
|
||||
|
||||
@@ -34,8 +34,6 @@ export type ResourceBookCardLayout = {
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
dragX: number;
|
||||
dragY: number;
|
||||
rotation: number;
|
||||
};
|
||||
|
||||
@@ -265,33 +263,6 @@ export function resolveResourceBookAllLayout(
|
||||
return { key, layout: buildResourceBookAllLayout(input) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 展开态的世界坐标 → 栏目内局部坐标(**落盘前必须走的唯一换算**)。
|
||||
*
|
||||
* 展开态按分带铺卡片,卡片画的是"局部坐标 + 带原点";而布局 sidecar 只认栏目内局部坐标
|
||||
* (`resource-layouts/{dependency,type}.json` 的 `x / y`,`section` 是资源当前分类)。
|
||||
* 因此拖动提交前减掉带原点:写回的是**这张卡在它自己栏目里的同一个槽位**,栏目页立刻看到
|
||||
* 同一位置,不存在第二份空间。带不存在(栏目页、或该栏目此刻没有分带)时减 0,
|
||||
* 与栏目页本来就有的口径完全一致。
|
||||
*/
|
||||
export function resourceBookAllBandLocalPoint({
|
||||
layout,
|
||||
category,
|
||||
x,
|
||||
y,
|
||||
}: {
|
||||
layout: ResourceBookAllLayout | null;
|
||||
category: ResourceBookCategory;
|
||||
x: number;
|
||||
y: number;
|
||||
}) {
|
||||
const band = layout?.bandByCategory.get(category) ?? null;
|
||||
return {
|
||||
x: x - (band?.originX ?? 0),
|
||||
y: y - (band?.originY ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function groupResourceBookResourcesByCategory(
|
||||
resources: readonly ProjectResource[],
|
||||
) {
|
||||
@@ -373,8 +344,6 @@ export function resourceBookOverviewCardLayout({
|
||||
y: (rect?.top ?? 0) + 58 + Math.min(stackIndex, 2) * 5,
|
||||
width: 92,
|
||||
height: 64,
|
||||
dragX: 0,
|
||||
dragY: 0,
|
||||
rotation: overviewStackRotations[rotationIndex] ?? 0,
|
||||
};
|
||||
}
|
||||
@@ -396,8 +365,6 @@ export function resourceBookChildCardLayout({
|
||||
y,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
dragX: x,
|
||||
dragY: y,
|
||||
rotation: 0,
|
||||
};
|
||||
}
|
||||
@@ -502,8 +469,8 @@ export function buildResourceBookScenePlan({
|
||||
? (allLayout?.bandByCategory.get(category) ?? null)
|
||||
: null;
|
||||
/**
|
||||
* 子画布布局 + 可选带偏移。偏移同时写进 `x / y` 与 `dragX / dragY`:后者是拖动起点,
|
||||
* 必须与画出来的世界坐标同系;提交时再减回带原点(`resourceBookAllBandLocalPoint`)。
|
||||
* 子画布布局 + 可选带偏移。偏移写进 `x / y`:卡片画出来的就是世界坐标,拖动按同一份世界
|
||||
* 位移换算成栏目内局部坐标落盘(位移在带原点冻结的一次拖动内逐卡拉平)。
|
||||
*/
|
||||
const childLayoutFor = (
|
||||
category: ResourceBookTarget,
|
||||
@@ -519,7 +486,7 @@ export function buildResourceBookScenePlan({
|
||||
}
|
||||
const x = layout.x + band.originX;
|
||||
const y = layout.y + band.originY;
|
||||
return { ...layout, x, y, dragX: x, dragY: y };
|
||||
return { ...layout, x, y };
|
||||
};
|
||||
return visibleCategoryOrder.map((category) => {
|
||||
const presentation = resourceBookCategoryCardPresentation(state, category);
|
||||
|
||||
+50
-17
@@ -66,7 +66,8 @@ type ManualLayoutWriteIntent = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 自动坐标重派生请求。
|
||||
* 自动坐标重派生请求。两种重算**各占一个队列槽**(按 `kind` 区分):显式整理是用户动作、
|
||||
* 「关系图首次就绪」是系统动作,后者不得就地改写前者已经按下、还没落盘的那一笔。
|
||||
*
|
||||
* - `automatic`:只丢自动坐标,手动卡原地不动(关系图首次就绪那一次)。
|
||||
* - `organize`:用户显式「整理画布」。目标栏目内的坐标**连手动一起丢**再重算,结果一律是
|
||||
@@ -591,21 +592,28 @@ export function useProjectResourceCanvasLayout({
|
||||
return;
|
||||
}
|
||||
const signature = resourceSignatureRef.current;
|
||||
const queued = writeQueueRef.current.find(
|
||||
/**
|
||||
* 队列里可能同时排着两笔重算(显式整理与关系图首次就绪各占一个槽):资源签名是作用域级
|
||||
* 的事实,排队中的每一笔都要跟上,否则先落盘的那笔会把后一笔的签名留成过期值、凭空多排
|
||||
* 一次资源同步。
|
||||
*/
|
||||
const queued = writeQueueRef.current.filter(
|
||||
(intent): intent is ResourceLayoutWriteIntent =>
|
||||
intent.kind === 'resources' &&
|
||||
intent.scopeEpoch === scopeEpoch &&
|
||||
intent !== activeWriteIntentRef.current,
|
||||
);
|
||||
if (queued) {
|
||||
if (queued.resourceSignature !== signature) {
|
||||
queued.resourceSignature = signature;
|
||||
queued.conflictRetries = 0;
|
||||
} else {
|
||||
queued.conflictRetries = Math.min(
|
||||
queued.conflictRetries,
|
||||
conflictRetries,
|
||||
);
|
||||
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({
|
||||
@@ -1127,7 +1135,7 @@ export function useProjectResourceCanvasLayout({
|
||||
(intent): intent is ResourceLayoutWriteIntent =>
|
||||
intent.kind === 'resources' &&
|
||||
intent.scopeEpoch === scope.epoch &&
|
||||
intent.rederive !== null &&
|
||||
intent.rederive?.kind === 'automatic' &&
|
||||
intent !== activeWriteIntentRef.current,
|
||||
);
|
||||
if (queued) {
|
||||
@@ -1174,17 +1182,42 @@ export function useProjectResourceCanvasLayout({
|
||||
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)) {
|
||||
/**
|
||||
* 「这一按到底会不会改变布局」按落盘口径判,而不是看乐观视图:
|
||||
*
|
||||
* - 主判据:整理结果 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;
|
||||
}
|
||||
const queued = writeQueueRef.current.find(
|
||||
(intent): intent is ResourceLayoutWriteIntent =>
|
||||
intent.kind === 'resources' &&
|
||||
intent.scopeEpoch === scope.epoch &&
|
||||
intent.rederive !== null &&
|
||||
intent.rederive?.kind === 'organize' &&
|
||||
intent !== activeWriteIntentRef.current,
|
||||
);
|
||||
if (queued) {
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
resolveResourceBookAllLayout,
|
||||
RESOURCE_BOOK_ALL_BAND_GAP,
|
||||
RESOURCE_BOOK_OVERVIEW_STACK_LIMIT,
|
||||
type ResourceBookAllBand,
|
||||
resourceBookAllBandLocalPoint,
|
||||
type ResourceBookAllLayout,
|
||||
resourceBookAllLayoutKey,
|
||||
resourceBookOverviewCardLayout,
|
||||
@@ -270,8 +268,6 @@ describe('buildResourceBookScenePlan', () => {
|
||||
y: 0,
|
||||
width: 180,
|
||||
height: 128,
|
||||
dragX: 0,
|
||||
dragY: 0,
|
||||
rotation: 0,
|
||||
},
|
||||
},
|
||||
@@ -288,8 +284,6 @@ describe('buildResourceBookScenePlan', () => {
|
||||
y: 0,
|
||||
width: 180,
|
||||
height: 128,
|
||||
dragX: 0,
|
||||
dragY: 0,
|
||||
rotation: 0,
|
||||
},
|
||||
},
|
||||
@@ -464,12 +458,11 @@ describe('buildResourceBookScenePlan', () => {
|
||||
// 世界原点叠成一摞(用户报的正是这个)。
|
||||
expect(artGroup.titlebar).toBe(false);
|
||||
expect(artGroup.titlebarActive).toBe(false);
|
||||
// 画出来的是"栏目内局部坐标 + 带原点",拖动起点与它同系(提交时再减回带原点)。
|
||||
// 画出来的是"栏目内局部坐标 + 带原点":拖动按同一份世界位移换算成栏目内局部坐标落盘,
|
||||
// 所以画出来的世界坐标必须等于"sidecar 里的局部坐标 + 带原点"。
|
||||
const first = artGroup.cards[0]!;
|
||||
expect(first.layout.x).toBe(positions.get('art-0')!.x + band.originX);
|
||||
expect(first.layout.y).toBe(positions.get('art-0')!.y + band.originY);
|
||||
expect(first.layout.dragX).toBe(first.layout.x);
|
||||
expect(first.layout.dragY).toBe(first.layout.y);
|
||||
|
||||
// 各栏目带之间不共享原点:不同栏目的卡不会被画到同一个位置。
|
||||
const sceneCard = plan
|
||||
@@ -643,56 +636,6 @@ describe('buildResourceBookAllLayout', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resourceBookAllBandLocalPoint', () => {
|
||||
const band: ResourceBookAllBand = {
|
||||
category: 'document',
|
||||
originX: 40,
|
||||
originY: 100,
|
||||
width: 620,
|
||||
height: 200,
|
||||
};
|
||||
const layout: ResourceBookAllLayout = {
|
||||
bands: [band],
|
||||
bandByCategory: new Map([['document', band]]),
|
||||
bounds: { x: 0, y: 0, width: 620, height: 300 },
|
||||
};
|
||||
|
||||
/**
|
||||
* 这是本方案唯一会落到布局 sidecar 上的换算:展开态画的是"局部坐标 + 带原点",
|
||||
* 落盘只认栏目内局部坐标。换算方向写错 = 把卡写到别处,所以单独钉住。
|
||||
*/
|
||||
it('subtracts the band origin so the world point lands back on the column-local point', () => {
|
||||
expect(
|
||||
resourceBookAllBandLocalPoint({
|
||||
layout,
|
||||
category: 'document',
|
||||
x: 140,
|
||||
y: 260,
|
||||
}),
|
||||
).toEqual({ x: 100, y: 160 });
|
||||
});
|
||||
|
||||
it('passes the point through for the column page and for a category without a band', () => {
|
||||
// 栏目页没有分带(`layout` 传 null),减 0:与既有"局部坐标直接落盘"逐字等价。
|
||||
expect(
|
||||
resourceBookAllBandLocalPoint({
|
||||
layout: null,
|
||||
category: 'document',
|
||||
x: 140,
|
||||
y: 260,
|
||||
}),
|
||||
).toEqual({ x: 140, y: 260 });
|
||||
expect(
|
||||
resourceBookAllBandLocalPoint({
|
||||
layout,
|
||||
category: 'audio',
|
||||
x: 7,
|
||||
y: 9,
|
||||
}),
|
||||
).toEqual({ x: 7, y: 9 });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 「所有资源」展开态里文档卡与图片卡一开始自动重叠的回归夹具(PR #316 反馈)。
|
||||
*
|
||||
|
||||
@@ -1824,4 +1824,89 @@ describe('资源画布多选拖动与整理范围', () => {
|
||||
).toEqual([]);
|
||||
await waitFor(() => expect(selectedResourceIdsInDom()).toEqual([]));
|
||||
});
|
||||
|
||||
/**
|
||||
* 撤销栈按「项目 + 排序模式」隔离:两种排序各有自己的一份布局 sidecar。
|
||||
*
|
||||
* 在「按类型」里拖过卡之后切到「按依赖」,那笔历史不属于这一份 sidecar —— 撤销不能把
|
||||
* type 侧的坐标套用(写)进依赖侧,否则用户只是切了个 tab,保存下来的却是一份串了模式的
|
||||
* 布局。
|
||||
*/
|
||||
it('撤销不跨排序模式:切到另一份 sidecar 后不会把 type 侧坐标写过去', async () => {
|
||||
const { manager, tauri } = await mountPointerWorkbench('character', {
|
||||
layoutByMode: {
|
||||
type: [
|
||||
{
|
||||
resourceId: 'asset:pointer-a',
|
||||
section: 'character',
|
||||
x: 0,
|
||||
y: 0,
|
||||
manuallyPlaced: false,
|
||||
},
|
||||
{
|
||||
resourceId: 'asset:pointer-b',
|
||||
section: 'character',
|
||||
x: 400,
|
||||
y: 300,
|
||||
manuallyPlaced: false,
|
||||
},
|
||||
],
|
||||
dependency: [
|
||||
{
|
||||
resourceId: 'asset:pointer-a',
|
||||
section: 'character',
|
||||
x: 700,
|
||||
y: 700,
|
||||
manuallyPlaced: false,
|
||||
},
|
||||
{
|
||||
resourceId: 'asset:pointer-b',
|
||||
section: 'character',
|
||||
x: 900,
|
||||
y: 900,
|
||||
manuallyPlaced: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
|
||||
await waitFor(() =>
|
||||
expect(cardIn(manager, 'asset:pointer-a')).not.toBeNull(),
|
||||
);
|
||||
await settleFocusChain();
|
||||
|
||||
// 「按类型」里拖动一张卡:记一笔历史 + 一笔 type 侧写入。
|
||||
const cardA = cardIn(manager, 'asset:pointer-a');
|
||||
fireEvent.pointerDown(cardA, {
|
||||
pointerId: 61,
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
});
|
||||
fireEvent.pointerMove(cardA, {
|
||||
pointerId: 61,
|
||||
buttons: 1,
|
||||
clientX: 180,
|
||||
clientY: 140,
|
||||
});
|
||||
fireEvent.pointerUp(cardA, {
|
||||
pointerId: 61,
|
||||
button: 0,
|
||||
clientX: 180,
|
||||
clientY: 140,
|
||||
});
|
||||
await waitFor(() => expect(typeWrites(tauri).length).toBeGreaterThan(0));
|
||||
await settleFocusChain();
|
||||
|
||||
// 切到「按依赖」:另一份 sidecar,坐标与 type 侧不同。
|
||||
fireEvent.click(screen.getByRole('button', { name: '按依赖' }));
|
||||
await settleFocusChain();
|
||||
const dependencyWritesBefore = dependencyWrites(tauri).length;
|
||||
|
||||
fireEvent.keyDown(window, { key: 'z', ctrlKey: true });
|
||||
await settleFocusChain();
|
||||
|
||||
// 依赖侧不多出一笔:撤销在另一份 sidecar 上是空操作,不跨写。
|
||||
expect(dependencyWrites(tauri)).toHaveLength(dependencyWritesBefore);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1595,6 +1595,53 @@ describe('资源画布整理与批量坐标写入', () => {
|
||||
updates.length = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可挂起的 type 侧 harness:`holdNextWrite()` 之后的那一笔 update 会停在途上,直到
|
||||
* `releaseHeldWrite()` 放行。用来复现"上一笔还没落盘、用户又按了整理"的真实窗口。
|
||||
*/
|
||||
function gatedTypeLayoutHarness(
|
||||
initialPositions: ProjectResourceCanvasPosition[],
|
||||
) {
|
||||
const updates: ProjectResourceCanvasPosition[][] = [];
|
||||
let heldWrite: (() => void) | null = null;
|
||||
let holdNext = false;
|
||||
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') {
|
||||
if (holdNext) {
|
||||
holdNext = false;
|
||||
await new Promise<void>((resolve) => {
|
||||
heldWrite = resolve;
|
||||
});
|
||||
}
|
||||
const positions = structuredClone(
|
||||
args?.positions as ProjectResourceCanvasPosition[],
|
||||
);
|
||||
updates.push(positions);
|
||||
return {
|
||||
status: 'updated',
|
||||
layout: persistedLayout('type', 8 + updates.length, positions),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
return {
|
||||
invoke,
|
||||
updates,
|
||||
holdNextWrite: () => {
|
||||
holdNext = true;
|
||||
},
|
||||
releaseHeldWrite: () => {
|
||||
heldWrite?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('整理栏目时连手动坐标一起重算,其他栏目逐值不动', async () => {
|
||||
const documentA = resource('resource-doc-a');
|
||||
const documentB = { ...resource('resource-doc-b'), dependencyDepth: 1 };
|
||||
@@ -1741,6 +1788,222 @@ describe('资源画布整理与批量坐标写入', () => {
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 排队中的「整理画布」不能被「关系图首次就绪」那一次重算覆盖。
|
||||
*
|
||||
* 两种重算共用同一条写队列:显式整理是用户动作、首次就绪是系统动作,各自占一个队列槽。
|
||||
* 系统请求若就地改写用户已经按下、还没落盘的那一笔,用户会看到整理生效、落盘却把手动卡
|
||||
* 留在原地(整理被悄悄降级成"只丢自动坐标")。
|
||||
*/
|
||||
it('排队中的整理不会被「关系图首次就绪」覆盖:两者各占一个队列槽', async () => {
|
||||
const documentA = resource('resource-doc-a');
|
||||
const documentB = resource('resource-doc-b');
|
||||
const { updates, holdNextWrite, releaseHeldWrite } =
|
||||
gatedTypeLayoutHarness([
|
||||
position('resource-doc-a', 600, 40),
|
||||
automaticPosition('resource-doc-b', 900, 900),
|
||||
]);
|
||||
const { result } = renderHook(() =>
|
||||
useProjectResourceCanvasLayout({
|
||||
projectPath,
|
||||
projectId,
|
||||
mode: 'type',
|
||||
resources: [documentA, documentB],
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(result.current.settled).toBe(true));
|
||||
updates.length = 0;
|
||||
|
||||
// 让一笔手动写入停在途上:后面的整理只能排队——正是「整理已按下、还没落盘」的窗口。
|
||||
holdNextWrite();
|
||||
act(() => {
|
||||
result.current.commitPosition('resource-doc-a', 'document', 111, 222);
|
||||
});
|
||||
act(() => {
|
||||
expect(result.current.organizeNow(['document'])).toBe(true);
|
||||
});
|
||||
// 关系图首次就绪的重算在这个窗口里进来。
|
||||
act(() => {
|
||||
result.current.rederiveNow();
|
||||
});
|
||||
await act(async () => {
|
||||
releaseHeldWrite();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(updates.length).toBeGreaterThan(1));
|
||||
// 整理那一笔照原样落盘:栏内两张卡都重算成自动坐标,手动坐标不残留。
|
||||
expect(updates[1]).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
resourceId: 'resource-doc-a',
|
||||
x: 0,
|
||||
y: 0,
|
||||
manuallyPlaced: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
resourceId: 'resource-doc-b',
|
||||
x: 196,
|
||||
y: 0,
|
||||
manuallyPlaced: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
// 首次就绪那一笔退化成空操作:整理后已经是自动坐标,不再多写一次 CAS。
|
||||
expect(updates).toHaveLength(2);
|
||||
});
|
||||
|
||||
/**
|
||||
* 「刚拖完、落点还没写回」时按整理:整理必须照常排进队列并最终生效。
|
||||
*
|
||||
* 判据不能拿乐观视图(已经把那一笔排队中的手动落点叠上去了)去比:整理结果与它逐值相同
|
||||
* 就会判成"什么都没变",用户的点击被静默吞掉,随后落盘的手动结果(卡在原地)反客为主。
|
||||
* 与落盘那一步同源地用"整理结果 vs 已落盘布局"判定,两处才不会再打架。
|
||||
*/
|
||||
it('拖动落点还在途时按整理:整理照常排队,最终把这张卡重排回自动槽位', async () => {
|
||||
const documentA = resource('resource-doc-a');
|
||||
const documentB = resource('resource-doc-b');
|
||||
const { updates, holdNextWrite, releaseHeldWrite } =
|
||||
gatedTypeLayoutHarness([
|
||||
automaticPosition('resource-doc-a', 0, 0),
|
||||
automaticPosition('resource-doc-b', 196, 0),
|
||||
]);
|
||||
const { result } = renderHook(() =>
|
||||
useProjectResourceCanvasLayout({
|
||||
projectPath,
|
||||
projectId,
|
||||
mode: 'type',
|
||||
resources: [documentA, documentB],
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(result.current.settled).toBe(true));
|
||||
updates.length = 0;
|
||||
|
||||
// 拖动落点已经在画面上(乐观视图),但这一笔还没写回。
|
||||
holdNextWrite();
|
||||
act(() => {
|
||||
result.current.commitPosition('resource-doc-a', 'document', 500, 600);
|
||||
});
|
||||
|
||||
// 用户紧接着按整理:这一按必须真的排进队列。
|
||||
let organized = false;
|
||||
act(() => {
|
||||
organized = result.current.organizeNow(['document']);
|
||||
});
|
||||
expect(organized).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
releaseHeldWrite();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(updates.length).toBeGreaterThan(1));
|
||||
// 整理排在手动落点之后落盘:这张卡被重排回自动槽位,手动落点不留在最终布局里。
|
||||
expect(updates.at(-1)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
resourceId: 'resource-doc-a',
|
||||
x: 0,
|
||||
y: 0,
|
||||
manuallyPlaced: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
resourceId: 'resource-doc-b',
|
||||
x: 196,
|
||||
y: 0,
|
||||
manuallyPlaced: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 分类刚变更、同步写还没落盘时拖动这张卡:落点必须按**新栏目**写回,不能被静默跳过。
|
||||
*
|
||||
* 顺序上的依据:资源签名变化那一次 effect 先 `reconcileLayout(layoutRef.current, resources)`
|
||||
* 再 `applyLayout`,而 `resources` 已经是新分类——所以拖动开始时内存布局里这条坐标的
|
||||
* `section` 已经是新栏目,`resourceId + section` 的匹配不会落空。这条用例把这个顺序钉住:
|
||||
* 谁把顺序改回去(例如先写后 reconcile),这里就会先红。
|
||||
*/
|
||||
it('分类刚变更、同步写还没落盘时拖动:落点按新栏目写回,不被静默跳过', async () => {
|
||||
const documentResource = resource('resource-shift');
|
||||
const sceneResource: ResourceCanvasItem = {
|
||||
...resource('resource-shift'),
|
||||
category: 'scene',
|
||||
};
|
||||
const { updates, holdNextWrite, releaseHeldWrite } =
|
||||
gatedTypeLayoutHarness([
|
||||
{ ...automaticPosition('resource-shift', 100, 100), section: 'document' },
|
||||
]);
|
||||
const { result, rerender } = renderHook(
|
||||
(props: {
|
||||
resources: ResourceCanvasItem[];
|
||||
}) =>
|
||||
useProjectResourceCanvasLayout({
|
||||
projectPath,
|
||||
projectId,
|
||||
mode: 'type',
|
||||
resources: props.resources,
|
||||
}),
|
||||
{ initialProps: { resources: [documentResource] } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.settled).toBe(true));
|
||||
updates.length = 0;
|
||||
|
||||
// 分类变更:挂住随之而来的同步写,模拟"变更已生效、sidecar 还没对齐"的窗口。
|
||||
holdNextWrite();
|
||||
act(() => {
|
||||
rerender({ resources: [sceneResource] });
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
// 内存布局先按新分类归并:这条坐标的 section 已经是 scene。
|
||||
expect(
|
||||
result.current.layout.positions.find(
|
||||
(position) => position.resourceId === 'resource-shift',
|
||||
),
|
||||
).toMatchObject({ section: 'scene', x: 100, y: 100 });
|
||||
|
||||
// 窗口内拖动这张卡:乐观布局必须立刻跟上(匹配落空的话这里会停在 100,100)。
|
||||
act(() => {
|
||||
result.current.commitPosition('resource-shift', 'scene', 300, 400);
|
||||
});
|
||||
expect(
|
||||
result.current.layout.positions.find(
|
||||
(position) => position.resourceId === 'resource-shift',
|
||||
),
|
||||
).toMatchObject({
|
||||
section: 'scene',
|
||||
x: 300,
|
||||
y: 400,
|
||||
manuallyPlaced: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
releaseHeldWrite();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// 落盘也是一笔带新栏目与手动标记的坐标:没有"被跳过、还没提示"的静默路径。
|
||||
await waitFor(() => expect(result.current.settled).toBe(true));
|
||||
const manualWrites = updates.filter((positions) =>
|
||||
positions.some(
|
||||
(position) =>
|
||||
position.resourceId === 'resource-shift' &&
|
||||
position.x === 300 &&
|
||||
position.y === 400 &&
|
||||
position.manuallyPlaced,
|
||||
),
|
||||
);
|
||||
expect(manualWrites).toHaveLength(1);
|
||||
expect(
|
||||
manualWrites[0]!.find(
|
||||
(position) => position.resourceId === 'resource-shift',
|
||||
),
|
||||
).toMatchObject({ section: 'scene', x: 300, y: 400 });
|
||||
});
|
||||
|
||||
it('没有可整理栏目时不产生任何写入', async () => {
|
||||
const documentA = resource('resource-doc-a');
|
||||
const { updates } = typeLayoutHarness([position('resource-doc-a', 600, 40)]);
|
||||
|
||||
Reference in New Issue
Block a user