feat(508-3a CAD 레이어 합치기 되돌리기): LAYMRG · LAYDEL 뒤 Ctrl+Z 한 번에 도형 레이어 · 레이어 목록 · 현재 층이 함께 돌아오고 Ctrl+Y 도 짝 · 되돌리기 단계에 도면층 전후 담음(state attachLayerChangeToUndo) · vitest layer-tools.test · 요구 325-1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 17:08:49 +09:00
co-authored by Claude Sonnet 5.5
parent 80020c26c5
commit 18fa773a2c
4 changed files with 152 additions and 13 deletions
@@ -0,0 +1,88 @@
import { Point } from '@flatten-js/core';
import { beforeAll, describe, expect, it } from 'vitest';
import { createActor } from 'xstate';
import type { Layer } from '../../App.types';
import { LineEntity } from '../../entities/LineEntity.ts';
import {
getActiveLayerId,
getEntities,
getLayers,
redo,
resetUndoBaseline,
setActiveLayerId,
setEntities,
setLayers,
setScreenCanvasDrawController,
undo,
} from '../../state.ts';
import { ActorEvent } from '../tool.types.ts';
import { layerDeleteToolStateMachine, layerMergeToolStateMachine } from './layer-tools.ts';
const layer = (id: string): Layer => ({ id, name: id, isVisible: true, isLocked: false });
/** 선 A(레이어 A · y=0) · 선 B(레이어 B · y=100) — 활성층은 A */
function setup() {
const a = new LineEntity('A', new Point(0, 0), new Point(10, 0));
const b = new LineEntity('B', new Point(0, 100), new Point(10, 100));
setLayers([layer('A'), layer('B')]);
setActiveLayerId('A');
setEntities([a, b]);
resetUndoBaseline();
}
const click = (actor: ReturnType<typeof createActor>, y: number) =>
actor.send({
type: ActorEvent.MOUSE_CLICK,
worldMouseLocation: new Point(5, y),
screenMouseLocation: new Point(0, 0),
holdingCtrl: false,
holdingShift: false,
});
const layerOfEntities = () => getEntities().map((entity) => entity.layerId);
const layerIds = () => getLayers().map((l) => l.id);
describe('LAYMRG · LAYDEL 되돌리기 (508-3a)', () => {
beforeAll(() => {
(globalThis as { window?: unknown }).window = { dispatchEvent: () => true };
// 집기 반경만 필요 — 화면 배율 1
setScreenCanvasDrawController({ getScreenScale: () => 1, getWorldMouseLocation: () => new Point(0, 0) } as never);
});
it('LAYMRG A→B: Ctrl+Z 한 번에 도형 레이어 · 레이어 목록 · 현재 층이 처음대로, Ctrl+Y 로 다시 합쳐짐', () => {
setup();
const actor = createActor(layerMergeToolStateMachine).start();
click(actor, 0);
click(actor, 100);
expect(layerOfEntities()).toEqual(['B', 'B']);
expect(layerIds()).toEqual(['B']);
expect(getActiveLayerId()).toBe('B');
undo();
expect(layerOfEntities()).toEqual(['A', 'B']);
expect(layerIds()).toEqual(['A', 'B']);
expect(getActiveLayerId()).toBe('A');
redo();
expect(layerOfEntities()).toEqual(['B', 'B']);
expect(layerIds()).toEqual(['B']);
expect(getActiveLayerId()).toBe('B');
});
it('LAYDEL A: Ctrl+Z 한 번에 지운 도형 · 레이어 A 가 돌아오고, Ctrl+Y 로 다시 지워짐', () => {
setup();
const actor = createActor(layerDeleteToolStateMachine).start();
click(actor, 0);
expect(layerOfEntities()).toEqual(['B']);
expect(layerIds()).toEqual(['B']);
undo();
expect(layerOfEntities()).toEqual(['A', 'B']);
expect(layerIds()).toEqual(['A', 'B']);
expect(getActiveLayerId()).toBe('A');
redo();
expect(layerOfEntities()).toEqual(['B']);
expect(layerIds()).toEqual(['B']);
});
});
@@ -11,9 +11,11 @@ import {
saveLayerState,
} from '../../helpers/layer-history';
import {
attachLayerChangeToUndo,
deleteEntities,
getActiveLayerId,
getEntities,
getLayerSnapshot,
getLayers,
setActiveLayerId,
setLayers,
@@ -29,6 +31,13 @@ function updateLayers(mutate: (layers: Layer[]) => Layer[]): void {
setLayers(mutate(current.map((layer) => ({ ...layer }))));
}
/** 객체 변경 + 도면층 변경을 되돌리기 한 단계로 묶는다(Ctrl+Z 한 번에 함께 · Ctrl+Y 도 짝) */
function withLayerUndo(change: () => void): void {
const before = getLayerSnapshot();
change();
attachLayerChangeToUndo(before, getLayerSnapshot());
}
const layerNameOf = (layerId: string): string =>
getLayers().find((layer) => layer.id === layerId)?.name ?? layerId;
@@ -222,15 +231,17 @@ export const layerMergeToolStateMachine = createSequenceTool({
toast.warn('같은 레이어입니다.');
return;
}
// 사본으로 갈아 끼움(제자리 고침 = 되돌리기 칸이 같은 참조 → Ctrl+Z 해도 그대로) · 층 목록 = LAYERP
restyle(
getEntities().filter((entity) => entity.layerId === sourceId),
(entity) => {
entity.layerId = targetId;
}
);
updateLayers((layers) => layers.filter((layer) => layer.id !== sourceId));
if (getActiveLayerId() === sourceId) setActiveLayerId(targetId);
// 사본으로 갈아 끼움(제자리 고침 = 되돌리기 칸이 같은 참조 → Ctrl+Z 해도 그대로)
withLayerUndo(() => {
restyle(
getEntities().filter((entity) => entity.layerId === sourceId),
(entity) => {
entity.layerId = targetId;
}
);
updateLayers((layers) => layers.filter((layer) => layer.id !== sourceId));
if (getActiveLayerId() === sourceId) setActiveLayerId(targetId);
});
toast.success(`레이어를 ${layerNameOf(targetId)}(으)로 병합했습니다.`);
},
});
@@ -246,9 +257,11 @@ export const layerDeleteToolStateMachine = createSequenceTool({
return;
}
const doomed: Entity[] = getEntities().filter((entity) => entity.layerId === layerId);
deleteEntities(doomed, true);
updateLayers((layers) => layers.filter((layer) => layer.id !== layerId));
if (getActiveLayerId() === layerId) setActiveLayerId(getLayers()[0].id);
withLayerUndo(() => {
deleteEntities(doomed, true);
updateLayers((layers) => layers.filter((layer) => layer.id !== layerId));
if (getActiveLayerId() === layerId) setActiveLayerId(getLayers()[0].id);
});
toast.success(`레이어와 객체 ${doomed.length}개를 삭제했습니다.`);
},
});