diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts index 741cf8246..f62855427 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts @@ -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'); diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.ts index 4965308e9..3ed2f4bd2 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.ts @@ -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) { diff --git a/B07_DesignDetail/openwebcad/src/tools/annotate/place-table-tool.ts b/B07_DesignDetail/openwebcad/src/tools/annotate/place-table-tool.ts index ce5331048..f8d1a2f5a 100644 --- a/B07_DesignDetail/openwebcad/src/tools/annotate/place-table-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/annotate/place-table-tool.ts @@ -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; }, }); diff --git a/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts b/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts index 435f50c37..c4c7859b6 100644 --- a/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts @@ -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) { diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts index 455284717..b0c7bfd02 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts @@ -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 => @@ -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) · 실패해도 놓기는 그대로 diff --git a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts index 537438d55..5f902b05e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts @@ -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(); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 7c21a388c..7154d1020 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -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", diff --git a/resources/tester/test_m02_area_to_drawing.py b/resources/tester/test_m02_area_to_drawing.py index 045772108..2b1003213 100644 --- a/resources/tester/test_m02_area_to_drawing.py +++ b/resources/tester/test_m02_area_to_drawing.py @@ -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"]} diff --git a/ui_template/cad_host/cad_host.ts b/ui_template/cad_host/cad_host.ts index db2f74a87..dba23860c 100644 --- a/ui_template/cad_host/cad_host.ts +++ b/ui_template/cad_host/cad_host.ts @@ -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