diff --git a/B07_DesignDetail/openwebcad/src/helpers/undo-stack.ts b/B07_DesignDetail/openwebcad/src/helpers/undo-stack.ts index dbbe75cd1..c2240f779 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/undo-stack.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/undo-stack.ts @@ -1,8 +1,18 @@ +import type { Layer } from '../App.types'; + +/** 도면층 목록 + 현재 층 — LAYMRG · LAYDEL 이 객체 변경과 함께 담는다 */ +export interface LayerSnapshot { + layers: Layer[]; + activeLayerId: string; +} + export interface UndoState { variable: StateVariable; // biome-ignore lint/suspicious/noExplicitAny: TODO list all undo state types value: any; + /** 이 단계가 도면층도 바꿨다면 전 · 후 — 되돌리면 before · 다시 하면 after 를 복원 */ + layerChange?: { before: LayerSnapshot; after: LayerSnapshot }; } export enum StateVariable { diff --git a/B07_DesignDetail/openwebcad/src/state.ts b/B07_DesignDetail/openwebcad/src/state.ts index ad64ff267..57e005795 100644 --- a/B07_DesignDetail/openwebcad/src/state.ts +++ b/B07_DesignDetail/openwebcad/src/state.ts @@ -12,7 +12,12 @@ import { import type { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawController'; import type { Entity } from './entities/Entity'; 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 PromptMeta, renderPrompt, rememberPoint, resolvePrompt } from './commands/prompt'; @@ -626,20 +631,43 @@ export function resetUndoBaseline() { 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() { + const undone = undoStack.peek(); const undoState = undoStack.undo(); if (!undoState) return; updateStates(undoState); + if (undone !== undoState && undone?.layerChange) applyLayerSnapshot(undone.layerChange.before); drawingDirty = true; // 되돌려도 저장본과는 다를 수 있다 — 미저장 경고 대상이다 notifyWindow(HtmlEvent.DRAWING_CHANGED); } export function redo() { + const current = undoStack.peek(); const redoState = undoStack.redo(); if (!redoState) return; updateStates(redoState); + if (current !== redoState && redoState.layerChange) applyLayerSnapshot(redoState.layerChange.after); drawingDirty = true; notifyWindow(HtmlEvent.DRAWING_CHANGED); } diff --git a/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.test.ts b/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.test.ts new file mode 100644 index 000000000..75d042686 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.test.ts @@ -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, 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']); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.ts b/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.ts index e80a22ae0..6fd8c7b81 100644 --- a/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.ts @@ -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}개를 삭제했습니다.`); }, });