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:
@@ -1,8 +1,18 @@
|
|||||||
|
import type { Layer } from '../App.types';
|
||||||
|
|
||||||
|
/** 도면층 목록 + 현재 층 — LAYMRG · LAYDEL 이 객체 변경과 함께 담는다 */
|
||||||
|
export interface LayerSnapshot {
|
||||||
|
layers: Layer[];
|
||||||
|
activeLayerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UndoState {
|
export interface UndoState {
|
||||||
variable: StateVariable;
|
variable: StateVariable;
|
||||||
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: TODO list all undo state types
|
// biome-ignore lint/suspicious/noExplicitAny: TODO list all undo state types
|
||||||
value: any;
|
value: any;
|
||||||
|
/** 이 단계가 도면층도 바꿨다면 전 · 후 — 되돌리면 before · 다시 하면 after 를 복원 */
|
||||||
|
layerChange?: { before: LayerSnapshot; after: LayerSnapshot };
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum StateVariable {
|
export enum StateVariable {
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ import {
|
|||||||
import type { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawController';
|
import type { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawController';
|
||||||
import type { Entity } from './entities/Entity';
|
import type { Entity } from './entities/Entity';
|
||||||
import { bumpSceneVersion } from './helpers/scene-version';
|
import { bumpSceneVersion } from './helpers/scene-version';
|
||||||
import { StateVariable, type UndoState, createStack } from './helpers/undo-stack';
|
import {
|
||||||
|
type LayerSnapshot,
|
||||||
|
StateVariable,
|
||||||
|
type UndoState,
|
||||||
|
createStack,
|
||||||
|
} from './helpers/undo-stack';
|
||||||
import type { InputController } from './inputController/input-controller.ts'; // state variables
|
import type { InputController } from './inputController/input-controller.ts'; // state variables
|
||||||
import { type PromptMeta, renderPrompt, rememberPoint, resolvePrompt } from './commands/prompt';
|
import { type PromptMeta, renderPrompt, rememberPoint, resolvePrompt } from './commands/prompt';
|
||||||
|
|
||||||
@@ -626,20 +631,43 @@ export function resetUndoBaseline() {
|
|||||||
drawingDirty = false;
|
drawingDirty = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getLayerSnapshot = (): LayerSnapshot => ({ layers, activeLayerId });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 방금 쌓은 객체 되돌리기 단계에 도면층 전 · 후를 붙인다(LAYMRG · LAYDEL) —
|
||||||
|
* Ctrl+Z 한 번에 객체와 도면층이 함께 돌아오고 Ctrl+Y 도 짝으로 간다.
|
||||||
|
* 맨 위 단계가 지금 객체 목록이 아니면(쌓이지 않음) 붙이지 않는다.
|
||||||
|
*/
|
||||||
|
export function attachLayerChangeToUndo(before: LayerSnapshot, after: LayerSnapshot) {
|
||||||
|
const top = undoStack.peek();
|
||||||
|
if (top?.variable === StateVariable.entities && top.value === entities) {
|
||||||
|
top.layerChange = { before, after };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLayerSnapshot(snapshot: LayerSnapshot) {
|
||||||
|
setLayers(snapshot.layers);
|
||||||
|
setActiveLayerId(snapshot.activeLayerId);
|
||||||
|
}
|
||||||
|
|
||||||
export function undo() {
|
export function undo() {
|
||||||
|
const undone = undoStack.peek();
|
||||||
const undoState = undoStack.undo();
|
const undoState = undoStack.undo();
|
||||||
if (!undoState) return;
|
if (!undoState) return;
|
||||||
|
|
||||||
updateStates(undoState);
|
updateStates(undoState);
|
||||||
|
if (undone !== undoState && undone?.layerChange) applyLayerSnapshot(undone.layerChange.before);
|
||||||
drawingDirty = true; // 되돌려도 저장본과는 다를 수 있다 — 미저장 경고 대상이다
|
drawingDirty = true; // 되돌려도 저장본과는 다를 수 있다 — 미저장 경고 대상이다
|
||||||
notifyWindow(HtmlEvent.DRAWING_CHANGED);
|
notifyWindow(HtmlEvent.DRAWING_CHANGED);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function redo() {
|
export function redo() {
|
||||||
|
const current = undoStack.peek();
|
||||||
const redoState = undoStack.redo();
|
const redoState = undoStack.redo();
|
||||||
if (!redoState) return;
|
if (!redoState) return;
|
||||||
|
|
||||||
updateStates(redoState);
|
updateStates(redoState);
|
||||||
|
if (current !== redoState && redoState.layerChange) applyLayerSnapshot(redoState.layerChange.after);
|
||||||
drawingDirty = true;
|
drawingDirty = true;
|
||||||
notifyWindow(HtmlEvent.DRAWING_CHANGED);
|
notifyWindow(HtmlEvent.DRAWING_CHANGED);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
saveLayerState,
|
||||||
} from '../../helpers/layer-history';
|
} from '../../helpers/layer-history';
|
||||||
import {
|
import {
|
||||||
|
attachLayerChangeToUndo,
|
||||||
deleteEntities,
|
deleteEntities,
|
||||||
getActiveLayerId,
|
getActiveLayerId,
|
||||||
getEntities,
|
getEntities,
|
||||||
|
getLayerSnapshot,
|
||||||
getLayers,
|
getLayers,
|
||||||
setActiveLayerId,
|
setActiveLayerId,
|
||||||
setLayers,
|
setLayers,
|
||||||
@@ -29,6 +31,13 @@ function updateLayers(mutate: (layers: Layer[]) => Layer[]): void {
|
|||||||
setLayers(mutate(current.map((layer) => ({ ...layer }))));
|
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 =>
|
const layerNameOf = (layerId: string): string =>
|
||||||
getLayers().find((layer) => layer.id === layerId)?.name ?? layerId;
|
getLayers().find((layer) => layer.id === layerId)?.name ?? layerId;
|
||||||
|
|
||||||
@@ -222,15 +231,17 @@ export const layerMergeToolStateMachine = createSequenceTool({
|
|||||||
toast.warn('같은 레이어입니다.');
|
toast.warn('같은 레이어입니다.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 사본으로 갈아 끼움(제자리 고침 = 되돌리기 칸이 같은 참조 → Ctrl+Z 해도 그대로) · 층 목록 = LAYERP
|
// 사본으로 갈아 끼움(제자리 고침 = 되돌리기 칸이 같은 참조 → Ctrl+Z 해도 그대로)
|
||||||
restyle(
|
withLayerUndo(() => {
|
||||||
getEntities().filter((entity) => entity.layerId === sourceId),
|
restyle(
|
||||||
(entity) => {
|
getEntities().filter((entity) => entity.layerId === sourceId),
|
||||||
entity.layerId = targetId;
|
(entity) => {
|
||||||
}
|
entity.layerId = targetId;
|
||||||
);
|
}
|
||||||
updateLayers((layers) => layers.filter((layer) => layer.id !== sourceId));
|
);
|
||||||
if (getActiveLayerId() === sourceId) setActiveLayerId(targetId);
|
updateLayers((layers) => layers.filter((layer) => layer.id !== sourceId));
|
||||||
|
if (getActiveLayerId() === sourceId) setActiveLayerId(targetId);
|
||||||
|
});
|
||||||
toast.success(`레이어를 ${layerNameOf(targetId)}(으)로 병합했습니다.`);
|
toast.success(`레이어를 ${layerNameOf(targetId)}(으)로 병합했습니다.`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -246,9 +257,11 @@ export const layerDeleteToolStateMachine = createSequenceTool({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const doomed: Entity[] = getEntities().filter((entity) => entity.layerId === layerId);
|
const doomed: Entity[] = getEntities().filter((entity) => entity.layerId === layerId);
|
||||||
deleteEntities(doomed, true);
|
withLayerUndo(() => {
|
||||||
updateLayers((layers) => layers.filter((layer) => layer.id !== layerId));
|
deleteEntities(doomed, true);
|
||||||
if (getActiveLayerId() === layerId) setActiveLayerId(getLayers()[0].id);
|
updateLayers((layers) => layers.filter((layer) => layer.id !== layerId));
|
||||||
|
if (getActiveLayerId() === layerId) setActiveLayerId(getLayers()[0].id);
|
||||||
|
});
|
||||||
toast.success(`레이어와 객체 ${doomed.length}개를 삭제했습니다.`);
|
toast.success(`레이어와 객체 ${doomed.length}개를 삭제했습니다.`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user