fix(M02): CAD 표 놓기 Esc 로 그만두면 면적 쪽 저장 안 한 고침 기다림 풂(104-3)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
This commit is contained in:
2026-10-08 16:53:23 +09:00
co-authored by Claude Sonnet 5.5
parent d669c0824e
commit c99a9a0ad4
9 changed files with 86 additions and 6 deletions
@@ -1,9 +1,17 @@
import { Point } from '@flatten-js/core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Actor } from 'xstate';
import { TableEntity } from '../entities/TableEntity.ts';
import { ActorEvent } from '../tools/tool.types.ts';
import { getEntities, isDrawingDirty, setEntities, setLayers } from '../state.ts';
import { getPendingTable, setPendingTable, tableAt } from '../tools/annotate/place-table-tool.ts';
import {
getPendingTable,
placeTableToolStateMachine,
setPendingTable,
tableAt,
} from '../tools/annotate/place-table-tool.ts';
import {
AISLO_PLACE_CANCELLED_MESSAGE,
AISLO_PLACE_TABLE_MESSAGE,
AISLO_TABLE_UPDATED_MESSAGE,
AISLO_UPDATE_TABLE_MESSAGE,
@@ -67,6 +75,21 @@ describe('표 놓기(PLACETABLE)', () => {
expect(notify).not.toHaveBeenCalled();
});
it('놓지 않고 Esc = place-cancelled 를 한 번 부모에 알리고 맡긴 표를 비움', async () => {
const notify = vi.fn();
await handleTableMessage(
{ type: AISLO_PLACE_TABLE_MESSAGE, entity: await table().toJson(), link: 'T3' },
notify
);
const actor = new Actor(placeTableToolStateMachine);
actor.start();
actor.send({ type: ActorEvent.ESC });
actor.send({ type: ActorEvent.ESC });
expect(notify).toHaveBeenCalledTimes(1);
expect(notify).toHaveBeenCalledWith(AISLO_PLACE_CANCELLED_MESSAGE, {});
expect(getPendingTable()).toBeNull();
});
it('옮기기 · 복사(clone) · JSON 왕복에도 style.연결이 남음', async () => {
const linked = table('T1');
expect(linked.clone().getStyle().연결).toBe('T1');
@@ -3,6 +3,7 @@
* place-table{entity, link} 표 JSON(TableEntity 꼴) → `style.연결 = link` 로 표 놓기 도구(PLACETABLE) 시작
* update-table{link, cells} `style.연결 === link` 인 표의 칸만 갈아 끼움(자리 · 크기 그대로 · 되돌리기 한 걸음)
* → 부모에 table-updated{link, count}
* place-cancelled{} (CAD → 부모) 표를 놓지 않고 Esc 로 끝남
* 놓기 · 갈기 모두 `setEntities(…, true)` 를 거쳐 drawing-changed 가 간다. 확정 · 보기 전용이면 안 함.
*/
import { toast } from 'react-toastify';
@@ -11,11 +12,12 @@ import type { JsonEntity } from '../entities/Entity.ts';
import { TableEntity } from '../entities/TableEntity.ts';
import type { TableCells } from '../helpers/table-geometry.ts';
import { getEntities, isDrawingReadOnly, isViewOnly, setEntities } from '../state.ts';
import { setPendingTable } from '../tools/annotate/place-table-tool.ts';
import { setPendingTable, setPlaceCancelHandler } from '../tools/annotate/place-table-tool.ts';
export const AISLO_PLACE_TABLE_MESSAGE = 'aislo:b08:place-table';
export const AISLO_UPDATE_TABLE_MESSAGE = 'aislo:b08:update-table';
export const AISLO_TABLE_UPDATED_MESSAGE = 'aislo:b08:table-updated';
export const AISLO_PLACE_CANCELLED_MESSAGE = 'aislo:b08:place-cancelled';
/** 두 메시지를 한 꼴로 받음 — place = entity · link · update = link · cells */
interface TableMessage {
@@ -68,7 +70,10 @@ export async function handleTableMessage(
const message = data as TableMessage | null;
if (!message || typeof message !== 'object') return false;
if (message.type === AISLO_PLACE_TABLE_MESSAGE && message.entity && message.link) {
if (!blocked()) await startPlaceTable(message.entity, message.link);
if (!blocked()) {
setPlaceCancelHandler(() => notify(AISLO_PLACE_CANCELLED_MESSAGE, {}));
await startPlaceTable(message.entity, message.link);
}
return true;
}
if (message.type === AISLO_UPDATE_TABLE_MESSAGE && message.link && message.cells) {
@@ -16,6 +16,13 @@ export function setPendingTable(table: TableEntity | null): void {
pending = table;
}
let onCancelled: (() => void) | null = null;
/** 놓지 않고 Esc 로 끝났을 때 한 번 부를 갈고리(부모에 알림) — 놓으면 버려진다 */
export function setPlaceCancelHandler(handler: (() => void) | null): void {
onCancelled = handler;
}
export function getPendingTable(): TableEntity | null {
return pending;
}
@@ -40,10 +47,18 @@ export const placeTableToolStateMachine = createSequenceTool({
steps: [{ kind: 'point', instructions: '표를 놓을 자리(왼쪽 위)를 누르십시오 · Esc 그만.' }],
helpers: true,
restart: false,
onCancel: () => {
if (!pending) return;
pending = null;
const notify = onCancelled;
onCancelled = null;
notify?.();
},
preview: (input) => (pending ? [tableAt(pending, input.cursor, getActiveLayerId())] : []),
commit: (input) => {
if (!pending) return;
addEntities([tableAt(pending, input.point(0), getActiveLayerId())], true);
pending = null;
onCancelled = null;
},
});
@@ -80,6 +80,8 @@ export interface SequenceToolConfig {
chainFromLastPoint?: boolean;
/** 점 입력 중 스냅·각도 가이드 사용 여부 (기본 true) */
helpers?: boolean;
/** ESC 로 확정 없이 그만둘 때 */
onCancel?: () => void;
}
export interface SequenceContext extends ToolContext {
@@ -246,6 +248,7 @@ export function createSequenceTool(config: SequenceToolConfig) {
});
const cancel = assign(({ context }: { context: SequenceContext }) => {
config.onCancel?.();
setGhostHelperEntities([]);
setAngleGuideOriginPoint(null);
if (context.values.length === 0) {
@@ -210,6 +210,8 @@ export interface AreaToDrawing {
waiting: () => boolean;
/** 떠나기로 함(물음 [확인]) — 기다리던 표 버림 */
drop: () => void;
/** 도면에서 Esc 로 놓지 않고 그만둠 — 기다림을 풀고 그 보내기 기록도 뺌 */
cancelPlace: () => void;
/** 도면 저장 문서 — 맨 위 `연결표` 를 도면의 표에 맞춤 */
withLinks: (doc: StructureDoc) => StructureDoc;
}
@@ -224,6 +226,7 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing {
let shown: string | null = null;
/** 보낸 표가 도면에 놓이길 기다림 — 떠날 때 「저장 안 한 고침」 물음(`waiting`) */
let waiting = false;
let waitingId = "";
/** 글자 크기 작은 창 — 보내기면 크기 · 닫으면 null */
const askSize = (spec: { 범위: string }): Promise<number | null> =>
@@ -338,6 +341,10 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing {
select: (s) => (range = { 시트: s.시트, 범위: s.범위 }),
waiting: () => waiting,
drop: () => (waiting = false),
cancelPlace: () => {
waiting = false;
fresh.delete(waitingId);
},
onArea: (at, readOnly, slot) => {
waiting = false;
// 범위는 그대로 — 시트가 처음 고름(A1)을 이 갈고리보다 먼저 알림
@@ -367,6 +374,7 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing {
const style = { ...(표.shapeData.style ?? {}), [LINK]: id };
fresh.set(id, { 연결: id, 시트, 범위, 이름, 글자크기: give.글자크기, 값 });
handle.placeTable({ ...표, shapeData: { ...표.shapeData, style } }, id);
waitingId = id;
waiting = true; // 놓기 전 떠나도 물음(놓으면 도면 고침 · [저장] 때 풂)
showToast(TEXT.place, "info");
// 보낸 범위 이름을 면적 시트에 — 행열 넣기 · 지우기를 따라가게(104-1) · 실패해도 놓기는 그대로
@@ -58,6 +58,8 @@ export interface DrawingTemplateOptions {
frameNumber?: string;
/** 탭 줄(82-1) — 그림 칸 바로 위에 놓음 */
tabs?: HTMLElement;
/** 보낸 표를 놓지 않고 Esc 로 그만둠(41장) */
onPlaceCancel?: () => void;
/** 여러 장 탭 줄은 고르기만(105-3 카드 · 면적 도면 패널) — 추가 · 이름 · 지우기 · 끌기 없음 */
pickTabs?: boolean;
}
@@ -254,6 +256,7 @@ export function mountDrawingTemplate(
title: "도면 양식",
onChanged: (dirty) => options.onChanged?.(dirty),
onHostSave: hostSave,
onPlaceCancel: () => options.onPlaceCancel?.(),
});
let loadSeq = 0;
/** `after` = 싣기를 보낸 바로 뒤(이 싣기가 마지막일 때만) — 표 놓기를 「열린 뒤」로 미룰 때 */
@@ -495,6 +498,10 @@ export function mountStructureSheets(
sheet: "",
frameNumber: doc.도번,
tabs: tabs.element,
onPlaceCancel: () => {
placing = null;
options.onPlaceCancel?.();
},
onChanged: (dirty) => {
cadDirty = dirty;
if (dirty && !readOnly) tabs.markDirty();
@@ -453,6 +453,7 @@ export function createMain(
layer: at.layer,
projectId: at.projectId,
onChanged: (d: boolean) => (dirty = d && !readOnly),
onPlaceCancel: () => toDrawing.cancelPlace(),
headerSlot: slot,
originMarker: at.kind === "structure",
noArea: at.kind === "structure",
+16 -3
View File
@@ -122,6 +122,15 @@ globalThis.fetch = async (url, init) => {
ad.onDrawing(handle, { ...area, kind: "structure" }, multi);
const multiId = placed.at(-1)[1];
const multiSaved = ad.withLinks({ ...multi, 연결표: [old, { ...old, 연결: "T2" }] }).연결표.map((r) => [r.연결, r.장]);
// 도면에서 Esc(놓지 않고 그만둠) — 기다림 풀림 · 그 보내기 기록은 저장 때 안 올라감
ad.select({ 시트: "산출", 칸: "A1", 범위: "A1" });
await slot.kids[0].on.click();
await new Promise((r) => setTimeout(r, 20));
ad.onDrawing(handle, { ...area, kind: "structure" }, drawing([]));
const escId = placed.at(-1)[1];
const escWaiting = ad.waiting();
ad.cancelPlace();
const esc = { before: escWaiting, after: ad.waiting(), rows: (ad.withLinks(drawing([table([["z"]], { 연결: escId })], [old])).연결표 ?? []).map((r) => r.연결) };
// 빈 칸만 고른 범위 — 크기를 묻기 전에 막고 알림 · 도면 안 엶
now = [["", " "]];
const openedBefore = opened.length;
@@ -129,7 +138,7 @@ globalThis.fetch = async (url, init) => {
await slot.kids[0].on.click();
await new Promise((r) => setTimeout(r, 20));
const empty = { opened: opened.length - openedBefore, toast: toasts.at(-1) };
console.log(JSON.stringify({ ro, buttons: slot.kids.length, sent, placed, notice: notice.map((x) => (typeof x === "string" ? x : x.label)), saved: saved.연결표, multiId, multiSaved, plain: "연결표" in plain, other, waitPlaced, waitDropped, waitSaved: ad.waiting(), empty }));
console.log(JSON.stringify({ esc, ro, buttons: slot.kids.length, sent, placed, notice: notice.map((x) => (typeof x === "string" ? x : x.label)), saved: saved.연결표, multiId, multiSaved, plain: "연결표" in plain, other, waitPlaced, waitDropped, waitSaved: ad.waiting(), empty }));
})();
"""
@@ -196,8 +205,8 @@ def test_다른_열_도면에는_안_놓음(got):
def test_편집_칸은_갈고리만():
main = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts").read_text("utf-8")
# select · onArea · onDrawing · withLinks · waiting(저장 안 한 고침) · drop(떠나기 확인)
assert main.count("toDrawing.") == 6
# select · onArea · onDrawing · withLinks · waiting(저장 안 한 고침) · drop(떠나기 확인) · cancelPlace(Esc)
assert main.count("toDrawing.") == 7
assert "|| toDrawing.waiting()" in main.split("const busy = ")[1].splitlines()[0]
@@ -207,3 +216,7 @@ def test_놓기_기다림은_저장_안_한_고침(got):
def test_빈_범위는_막고_알림(got):
assert got["empty"] == {"opened": 0, "toast": ["error", "보낼 칸에 내용이 없음"]}
def test_Esc로_놓지_않고_그만두면_기다림이_풀리고_기록도_뺌(got):
assert got["esc"] == {"before": True, "after": False, "rows": ["T1"]}
+5
View File
@@ -32,6 +32,7 @@ const CAD_TOAST_ACTION_MESSAGE = "aislo:b08:toast-action";
const CAD_PLACE_TABLE_MESSAGE = "aislo:b08:place-table";
const CAD_UPDATE_TABLE_MESSAGE = "aislo:b08:update-table";
const CAD_TABLE_UPDATED_MESSAGE = "aislo:b08:table-updated";
const CAD_PLACE_CANCELLED_MESSAGE = "aislo:b08:place-cancelled";
/** CAD 가 주고받는 도면 JSON — 도형·층 밖의 칸은 페이지마다 다르다. */
export interface CadHostDrawing {
@@ -56,6 +57,8 @@ export interface CadHostOptions {
onExport?: (fileFormat: "dxf" | "dwg") => void;
/** CAD 안 💾 · Ctrl+S — `hostSave` 로 실은 화면(M02 양식)만 온다. */
onHostSave?: () => void;
/** 표 놓기를 놓지 않고 Esc 로 그만둠(41장). */
onPlaceCancel?: () => void;
}
/** 싣기 곁값 — 수량 패널(meta) 없이 싣는 M02 양식이 쓴다. */
@@ -289,6 +292,8 @@ export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown
const [first, ...rest] = tableWaits.get(message.link) ?? [];
tableWaits.set(message.link, rest);
first?.(message.count ?? 0);
} else if (message.type === CAD_PLACE_CANCELLED_MESSAGE) {
options.onPlaceCancel?.();
} else if (message.type === CAD_ERROR_MESSAGE) {
stopLoadWatchdog();
showFailure(message.detail ?? "CAD 도면을 표시하지 못했습니다.");