diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts index cedc8c175..029dc0a27 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts @@ -1,4 +1,3 @@ -import { Point } from '@flatten-js/core'; import { type DesignMeta, HtmlEvent } from '../App.types.ts'; import { ImageEntity } from '../entities/ImageEntity.ts'; import { TextEntity } from '../entities/TextEntity.ts'; @@ -11,11 +10,8 @@ import { getDesignMeta, getEntities, getFrameFields, - getLayers, getScreenCanvasDrawController, isDrawingDirty, - isDrawingReadOnly, - isViewOnly, resetUndoBaseline, setActiveLayerId, setDesignMeta, @@ -30,10 +26,10 @@ import { setBare, } from '../state.ts'; import { setInspectorCollapsed, setRibbonCollapsed } from '../components/ui-state.ts'; -import { toast } from 'react-toastify'; import { runCommandInput } from '../commands/run-command.ts'; import { setRecoveryScope } from '../helpers/autosave.ts'; import { registerBoxResizeDrag } from './box-resize-drag.ts'; +import { closeTextEditor, handleTextDoubleClick } from './text-inplace-edit.ts'; import { handleTableMessage } from './aislo-table-bridge.ts'; import { registerGuideDrag, setGuide, setGuideArea, setGuideVisible } from './guide-area.ts'; @@ -114,63 +110,11 @@ export function requestDrawingExport(fileFormat: 'dxf' | 'dwg') { notifyParent(AISLO_DRAWING_EXPORT_MESSAGE, { fileFormat }); } -/** - * 수량 산출표 도면층 — 여기 글자는 앞 단계(B05·B06) 산출값이라 B07에서 고치지 않는다 - * (2026-09-01 사용자 확정). 고치면 그림 글자만 바뀌고 저장되는 수량표는 그대로여서 - * 화면과 저장값이 어긋났다. - */ -const QUANTITY_TABLE_LAYER_ID = 'b08-cross-table'; - -/** - * 더블클릭한 위치의 텍스트(잠금 해제 레이어)를 찾아 내용을 즉시 편집한다. - * CAD 측점 테이블 값 셀 수정 수단 — 잠금 레이어와 수량 산출표는 제외. - */ +/** 글자 더블클릭 = 그 자리 입력 칸(`text-inplace-edit.ts` · 293-1). */ function registerTextDoubleClickEdit() { const canvas = getCanvas(); if (!canvas) return; - canvas.addEventListener('dblclick', (event: MouseEvent) => { - if (isViewOnly()) { - getScreenCanvasDrawController().zoomToFitScreen(); - return; - } - if (isDrawingReadOnly()) { - toast.info('확정한 도면입니다. 고치려면 [수정]을 먼저 누르세요.'); - return; - } - const drawController = getScreenCanvasDrawController(); - const bounds = canvas.getBoundingClientRect(); - const screenPoint = new Point(event.clientX - bounds.left, bounds.bottom - event.clientY); - const worldPoint = drawController.targetToWorld(screenPoint); - const lockedLayerIds = new Set( - getLayers() - .filter((layer) => layer.isLocked) - .map((layer) => layer.id) - ); - let closest: TextEntity | null = null; - let closestDistance = Number.MAX_SAFE_INTEGER; - let closestOnQuantityTable = false; - for (const entity of getEntities()) { - if (!(entity instanceof TextEntity) || lockedLayerIds.has(entity.layerId)) continue; - const distanceInfo = entity.distanceTo(worldPoint); - if (distanceInfo && distanceInfo[0] < closestDistance) { - closestDistance = distanceInfo[0]; - closest = entity; - closestOnQuantityTable = entity.layerId === QUANTITY_TABLE_LAYER_ID; - } - } - if (!closest) return; - const fontSize = closest.getTextOptions().fontSize; - const tolerance = Math.max((closest.getLabel().length + 2) * fontSize * 0.5, fontSize * 2); - if (closestDistance > tolerance) return; - if (closestOnQuantityTable) { - toast.info('수량표는 횡단설계(B06)에서 고칩니다. 고치고 돌아오면 반영됩니다.'); - return; - } - const nextLabel = window.prompt('값 수정', closest.getLabel()); - if (nextLabel === null || nextLabel === closest.getLabel()) return; - closest.setLabel(nextLabel); - setEntities([...getEntities()], true); // undo 스택 + DRAWING_CHANGED 통지 - }); + canvas.addEventListener('dblclick', (event: MouseEvent) => handleTextDoubleClick(event, canvas)); } /** @@ -224,6 +168,7 @@ export function registerAisloDrawingBridge() { } if (!isDrawingLoadMessage(event.data)) return; + closeTextEditor(false); // 옛 도면 글자 칸은 새 도면에 적용하지 않음 try { const drawing = await getEntitiesAndLayersFromJsonObject(event.data.drawing); setEntities(drawing.entities, false); diff --git a/B07_DesignDetail/openwebcad/src/integration/text-inplace-edit.ts b/B07_DesignDetail/openwebcad/src/integration/text-inplace-edit.ts new file mode 100644 index 000000000..8b2161276 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/integration/text-inplace-edit.ts @@ -0,0 +1,247 @@ +/** + * 글자 더블클릭 = 그 자리 입력 칸(293-1) — 브라우저 창(prompt) 대신 글자 위에 겹친 칸에서 바로 고친다. + * 칸은 글자 크기 · 기울기 · 확대 배율을 매 화면 따라간다(휠 줌 · 가운데 단추 팬 중에도). + * Enter = 적용(여러 줄은 Ctrl+Enter · Enter 는 줄바꿈) · Esc = 취소 · 칸 밖 누름 = 적용 · 되돌리기 한 번. + */ +import { Point } from '@flatten-js/core'; +import { toast } from 'react-toastify'; +import type { Entity } from '../entities/Entity'; +import { TextEntity } from '../entities/TextEntity'; +import { getAngleWithXAxis } from '../helpers/get-angle-with-x-axis.ts'; +import { bumpSceneVersion } from '../helpers/scene-version.ts'; +import { isImeKey } from '../inputController/ime-sink.ts'; +import { + getEntities, + getLayers, + getScreenCanvasDrawController, + isDrawingReadOnly, + isViewOnly, + setEntities, +} from '../state.ts'; +import { paintColor } from '../theme.ts'; +import { cloneKeepingLook } from '../tools/move-tool.helpers.ts'; + +/** + * 수량 산출표 도면층 — 여기 글자는 앞 단계(B05·B06) 산출값이라 B07에서 고치지 않는다 + * (2026-09-01 사용자 확정). 고치면 그림 글자만 바뀌고 저장되는 수량표는 그대로여서 + * 화면과 저장값이 어긋났다. + */ +const QUANTITY_TABLE_LAYER_ID = 'b08-cross-table'; +/** 새로 늘어난 줄 간격 — 여러 줄 문자 명령(text-tools)과 같은 배수 */ +const LINE_GAP_RATIO = 1.35; +/** 너무 작게 줌아웃했을 때도 칠 수 있는 최소 글 크기(px) */ +const MIN_EDITOR_FONT_PX = 10; + +let closeOpenEditor: ((apply: boolean) => void) | null = null; + +const basePointOf = (entity: TextEntity): Point => entity.getFirstPoint() as Point; + +/** 여러 줄 문자(MTEXT) = 같은 묶음의 글자들이 같은 x 에 위아래로 놓인 것 · 위 줄부터. 아니면 그 글자 하나 */ +export function textLinesOf(target: TextEntity, entities: Entity[]): TextEntity[] { + if (!target.groupId) return [target]; + const group = entities.filter((entity) => entity.groupId === target.groupId); + const x = basePointOf(target).x; + const isMultiline = + group.length > 1 && + group.every( + (entity) => entity instanceof TextEntity && Math.abs(basePointOf(entity).x - x) < 1e-6 + ); + if (!isMultiline) return [target]; + return (group as TextEntity[]).sort((a, b) => basePointOf(b).y - basePointOf(a).y); +} + +/** 칸 글을 도면에 적용한 새 목록 — 바뀐 게 없거나 빈 글이면 null. 고친 글자는 복제본으로 갈아 끼워 되돌리기가 옛 글을 살린다 */ +export function applyTextEdit( + lines: TextEntity[], + value: string, + entities: Entity[] +): Entity[] | null { + const next = + lines.length > 1 + ? value + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.trim().length > 0) + : [value]; + if (!next.join('').trim()) return null; + if (next.join('\n') === lines.map((line) => line.getLabel()).join('\n')) return null; + const last = lines[lines.length - 1]; + const gap = + lines.length > 1 + ? basePointOf(lines[0]).y - basePointOf(lines[1]).y + : last.getTextOptions().fontSize * LINE_GAP_RATIO; + const replaced = next.map((label, index) => { + const kept = index < lines.length; + const copy = cloneKeepingLook(kept ? lines[index] : last, kept) as TextEntity; + copy.setLabel(label); + if (!kept) { + copy.groupId = last.groupId; + copy.move(0, -gap * (index - lines.length + 1)); + } + return copy; + }); + const lineIndex = new Map(lines.map((line, index) => [line.id, index])); + const result: Entity[] = []; + for (const entity of entities) { + const index = lineIndex.get(entity.id); + if (index === undefined) result.push(entity); + else if (index < replaced.length) { + result.push(replaced[index]); + if (entity === last) result.push(...replaced.slice(lines.length)); + } + } + return result; +} + +/** 칸 안 키 — 적용 · 취소 · 그 밖(칸이 받음). 한글 조합 중 Enter 는 조합을 끝낼 뿐 */ +export function editorKeyAction( + evt: Pick, + multiline: boolean +): 'apply' | 'cancel' | null { + if (isImeKey(evt as KeyboardEvent)) return null; + if (evt.key === 'Escape') return 'cancel'; + if (evt.key === 'Enter' && (!multiline || evt.ctrlKey || evt.metaKey)) return 'apply'; + return null; +} + +/** 열린 칸이 있으면 닫는다(적용) — 도면을 새로 실을 때 등 */ +export function closeTextEditor(apply = true): void { + closeOpenEditor?.(apply); +} + +/** 글자 자리에 입력 칸을 띄운다 — 글 전체 골라진 채 바로 입력 */ +export function openTextEditor(lines: TextEntity[], canvas: HTMLCanvasElement): void { + closeTextEditor(); + const multiline = lines.length > 1; + const editor = document.createElement(multiline ? 'textarea' : 'input'); + editor.className = 'cad-text-inplace'; + editor.value = lines.map((line) => line.getLabel()).join('\n'); + editor.setAttribute('spellcheck', 'false'); + editor.setAttribute('autocomplete', 'off'); + const options = lines[0].getTextOptions(); + const angle = getAngleWithXAxis( + new Point(0, 0), + new Point(options.textDirection.x, -options.textDirection.y) + ); + const shiftX = { left: '0', center: '-50%', right: '-100%' }[options.textAlign] ?? '0'; + Object.assign(editor.style, { + position: 'fixed', + zIndex: '1000', + margin: '0', + padding: '0', + border: 'none', + outline: '1px dashed #4a90d9', + background: 'transparent', + color: paintColor(options.textColor), + textAlign: options.textAlign, + whiteSpace: 'pre', + overflow: 'hidden', + resize: 'none', + transformOrigin: '0 0', + }); + // 칸 아래 캔버스 글자를 감춘다 — 겹쳐 두 벌로 보이지 않게(닫을 때 되살림) + const previewLabels = lines.map((line) => line.previewLabel); + for (const line of lines) line.previewLabel = ''; + bumpSceneVersion(); + + let frame = 0; + const place = () => { + const controller = getScreenCanvasDrawController(); + const scale = controller.getScreenScale(); + const fontPx = Math.max(options.fontSize * scale, MIN_EDITOR_FONT_PX); + const linePx = multiline + ? Math.max((basePointOf(lines[0]).y - basePointOf(lines[1]).y) * scale, fontPx) + : fontPx * 1.2; + const anchor = controller.worldToTarget(basePointOf(lines[0])); + const bounds = canvas.getBoundingClientRect(); + const rows = editor.value.split('\n'); + const widest = Math.max(...rows.map((row) => controller.measureTextScreen(row, fontPx))); + Object.assign(editor.style, { + left: `${bounds.left + anchor.x}px`, + top: `${bounds.bottom - anchor.y}px`, + font: `${options.italic ? 'italic ' : ''}${options.bold ? 'bold ' : ''}${fontPx}px ${options.fontFamily}`, + lineHeight: `${linePx}px`, + width: `${widest + fontPx * 1.5}px`, + height: `${linePx * rows.length}px`, + transform: `rotate(${angle}rad) translate(${shiftX}, ${-linePx / 2}px)`, + }); + frame = requestAnimationFrame(place); + }; + + let closed = false; + const close = (apply: boolean) => { + if (closed) return; + closed = true; + closeOpenEditor = null; + cancelAnimationFrame(frame); + document.removeEventListener('pointerdown', onOutsidePointer, true); + lines.forEach((line, index) => (line.previewLabel = previewLabels[index])); + const value = editor.value; + editor.remove(); + const next = apply ? applyTextEdit(lines, value, getEntities()) : null; + if (next) + setEntities(next, true); // 되돌리기 한 번 + DRAWING_CHANGED 알림 + else bumpSceneVersion(); + }; + // 칸 밖 누름 = 적용 · 가운데 단추(팬)는 칸이 따라가므로 닫지 않음 + const onOutsidePointer = (evt: PointerEvent) => { + if (evt.target !== editor && evt.button !== 1) close(true); + }; + editor.addEventListener('keydown', (evt) => { + evt.stopPropagation(); // CAD 단축키 · 명령줄로 새지 않게 + const action = editorKeyAction(evt as KeyboardEvent, multiline); + if (!action) return; + evt.preventDefault(); + close(action === 'apply'); + }); + editor.addEventListener('blur', () => close(true)); + document.addEventListener('pointerdown', onOutsidePointer, true); + document.body.appendChild(editor); + closeOpenEditor = close; + place(); + editor.focus({ preventScroll: true }); + editor.select(); +} + +/** + * 더블클릭한 위치의 글자(잠금 해제 도면층)를 찾아 그 자리 입력 칸을 띄운다. + * 잠금 도면층 · 수량 산출표는 제외. + */ +export function handleTextDoubleClick(event: MouseEvent, canvas: HTMLCanvasElement): void { + if (isViewOnly()) { + getScreenCanvasDrawController().zoomToFitScreen(); + return; + } + if (isDrawingReadOnly()) { + toast.info('확정한 도면입니다. 고치려면 [수정]을 먼저 누르세요.'); + return; + } + const drawController = getScreenCanvasDrawController(); + const bounds = canvas.getBoundingClientRect(); + const screenPoint = new Point(event.clientX - bounds.left, bounds.bottom - event.clientY); + const worldPoint = drawController.targetToWorld(screenPoint); + const lockedLayerIds = new Set( + getLayers() + .filter((layer) => layer.isLocked) + .map((layer) => layer.id) + ); + let closest: TextEntity | null = null; + let closestDistance = Number.MAX_SAFE_INTEGER; + for (const entity of getEntities()) { + if (!(entity instanceof TextEntity) || lockedLayerIds.has(entity.layerId)) continue; + const distanceInfo = entity.distanceTo(worldPoint); + if (distanceInfo && distanceInfo[0] < closestDistance) { + closestDistance = distanceInfo[0]; + closest = entity; + } + } + if (!closest) return; + const fontSize = closest.getTextOptions().fontSize; + const tolerance = Math.max((closest.getLabel().length + 2) * fontSize * 0.5, fontSize * 2); + if (closestDistance > tolerance) return; + if (closest.layerId === QUANTITY_TABLE_LAYER_ID) { + toast.info('수량표는 횡단설계(B06)에서 고칩니다. 고치고 돌아오면 반영됩니다.'); + return; + } + openTextEditor(textLinesOf(closest, getEntities()), canvas); +} diff --git a/resources/tester/cad/text-inplace-edit.test.ts b/resources/tester/cad/text-inplace-edit.test.ts new file mode 100644 index 000000000..8b870cd5e --- /dev/null +++ b/resources/tester/cad/text-inplace-edit.test.ts @@ -0,0 +1,216 @@ +/** + * 글자 더블클릭 = 그 자리 입력 칸(PLAN 293-1) — prompt 대신 글자 위 입력 칸 · Enter 적용 · Esc 취소 · + * 칸 밖 누름 적용 · 되돌리기 한 번 · 여러 줄(MTEXT)은 Ctrl+Enter 적용 · 키가 CAD 로 새지 않음. + * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad text-inplace-edit + */ +import { Point } from "@flatten-js/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../../B07_DesignDetail/openwebcad/src/theme.ts", () => ({ + paintColor: (color: string) => color, +})); +vi.mock("../../../B07_DesignDetail/openwebcad/src/state.ts", async (importOriginal) => ({ + ...(await importOriginal>()), + getScreenCanvasDrawController: () => ({ + getScreenScale: () => 2, + worldToTarget: (point: Point) => point, + // 시험 쪽 Point 로 — 앱과 시험이 flatten 을 따로 실어 instanceof 가 갈림 + targetToWorld: (point: Point) => new Point(point.x, point.y), + measureTextScreen: () => 40, + zoomToFitScreen: () => {}, + }), +})); + +import { TextEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/TextEntity.ts"; +import { + closeTextEditor, + editorKeyAction, + handleTextDoubleClick, +} from "../../../B07_DesignDetail/openwebcad/src/integration/text-inplace-edit"; +import { + getEntities, + resetUndoBaseline, + setEntities, + undo, +} from "../../../B07_DesignDetail/openwebcad/src/state"; + +type Listener = (evt: Record) => void; +class FakeElement { + public value = ""; + public className = ""; + public style: Record = {}; + public selected = false; + public removed = false; + public listeners: Record = {}; + constructor(public tagName: string) {} + setAttribute() {} + addEventListener(type: string, listener: Listener) { + (this.listeners[type] ??= []).push(listener); + } + fire(type: string, evt: Record = {}) { + for (const listener of this.listeners[type] ?? []) listener(evt); + } + focus() {} + select() { + this.selected = true; + } + remove() { + this.removed = true; + this.fire("blur"); + } +} + +let created: FakeElement[] = []; +let documentListeners: Record = {}; +const prompt = vi.fn(); +const canvas = { + getBoundingClientRect: () => ({ left: 10, top: 20, bottom: 520, right: 810 }), +} as unknown as HTMLCanvasElement; + +beforeEach(() => { + created = []; + documentListeners = {}; + prompt.mockReset(); + vi.stubGlobal("window", { prompt, dispatchEvent: () => true }); + vi.stubGlobal("requestAnimationFrame", () => 1); + vi.stubGlobal("cancelAnimationFrame", () => {}); + vi.stubGlobal("document", { + createElement: (tag: string) => { + const element = new FakeElement(tag); + created.push(element); + return element; + }, + body: { appendChild: () => {} }, + addEventListener: (type: string, listener: Listener) => + (documentListeners[type] ??= []).push(listener), + removeEventListener: (type: string, listener: Listener) => + (documentListeners[type] = (documentListeners[type] ?? []).filter((l) => l !== listener)), + }); +}); +afterEach(() => { + closeTextEditor(false); + vi.unstubAllGlobals(); +}); + +/** 화면 좌표 = 월드 좌표(가짜 제어기) · 캔버스 아래 520 기준으로 y 를 뒤집음 */ +const dblclickAt = (x: number, y: number) => + handleTextDoubleClick({ clientX: 10 + x, clientY: 520 - y } as MouseEvent, canvas); +const key = (editor: FakeElement, k: string, extra: Record = {}) => { + const evt = { + key: k, + ctrlKey: false, + metaKey: false, + isComposing: false, + keyCode: 0, + stopped: false, + prevented: false, + stopPropagation() { + evt.stopped = true; + }, + preventDefault() { + evt.prevented = true; + }, + ...extra, + }; + editor.fire("keydown", evt); + return evt; +}; +const labels = () => getEntities().map((entity) => (entity as TextEntity).getLabel()); +const load = (...entities: TextEntity[]) => { + setEntities(entities, false); + resetUndoBaseline(); +}; + +describe("글자 더블클릭 = 그 자리 입력 칸", () => { + it("더블클릭 → 글자 자리에 입력 칸 · 글 전체 골라짐 · prompt 안 부름 · 캔버스 글자 감춤", () => { + const text = new TextEntity("0", "배수관", new Point(100, 200), { fontSize: 5 }); + load(text); + dblclickAt(101, 200); + expect(prompt).not.toHaveBeenCalled(); + expect(created).toHaveLength(1); + const [editor] = created; + expect(editor.tagName).toBe("input"); + expect(editor.value).toBe("배수관"); + expect(editor.selected).toBe(true); + expect(editor.style.left).toBe("110px"); // 캔버스 왼쪽 10 + 글자 x 100 + expect(editor.style.top).toBe("320px"); // 캔버스 아래 520 - 글자 y 200 + expect(editor.style.font).toContain("10px"); // 글 크기 5 × 배율 2 + expect(text.previewLabel).toBe(""); + }); + + it("Enter = 적용 · 되돌리기 한 번에 옛 글 · 키가 CAD 로 새지 않음", () => { + load(new TextEntity("0", "배수관", new Point(100, 200), { fontSize: 5 })); + dblclickAt(100, 200); + const [editor] = created; + const typed = key(editor, "a"); + expect(typed.stopped).toBe(true); + expect(typed.prevented).toBe(false); + editor.value = "횡단배수관"; + const enter = key(editor, "Enter"); + expect(enter.stopped).toBe(true); + expect(editor.removed).toBe(true); + expect(labels()).toEqual(["횡단배수관"]); + expect((getEntities()[0] as TextEntity).previewLabel).toBeNull(); + undo(); + expect(labels()).toEqual(["배수관"]); + }); + + it("Esc = 취소 · 글 그대로 · 감춘 글자 되살림", () => { + const text = new TextEntity("0", "배수관", new Point(100, 200), { fontSize: 5 }); + load(text); + dblclickAt(100, 200); + const [editor] = created; + editor.value = "바꿈"; + key(editor, "Escape"); + expect(editor.removed).toBe(true); + expect(labels()).toEqual(["배수관"]); + expect(getEntities()[0]).toBe(text); + expect(text.previewLabel).toBeNull(); + }); + + it("칸 밖 누름 = 적용 · 가운데 단추(팬)는 닫지 않음", () => { + load(new TextEntity("0", "배수관", new Point(100, 200), { fontSize: 5 })); + dblclickAt(100, 200); + const [editor] = created; + editor.value = "옹벽"; + for (const listener of documentListeners.pointerdown) listener({ target: {}, button: 1 }); + expect(editor.removed).toBe(false); + for (const listener of documentListeners.pointerdown) listener({ target: {}, button: 0 }); + expect(editor.removed).toBe(true); + expect(labels()).toEqual(["옹벽"]); + }); + + it("한글 조합 중 Enter 는 적용하지 않음", () => { + expect(editorKeyAction({ key: "Enter", isComposing: true } as KeyboardEvent, false)).toBeNull(); + expect(editorKeyAction({ key: "Enter", keyCode: 229 } as KeyboardEvent, false)).toBeNull(); + }); + + it("여러 줄(MTEXT) = 여러 줄 칸 · Enter 줄바꿈 · Ctrl+Enter 적용 · 줄 늘면 아래로 · 되돌리기 한 번", () => { + const first = new TextEntity("0", "첫 줄", new Point(100, 200), { fontSize: 5 }); + const second = new TextEntity("0", "둘째 줄", new Point(100, 193), { fontSize: 5 }); + first.groupId = second.groupId = "m1"; + load(second, first); + dblclickAt(100, 193); + const [editor] = created; + expect(editor.tagName).toBe("textarea"); + expect(editor.value).toBe("첫 줄\n둘째 줄"); + expect(key(editor, "Enter").prevented).toBe(false); + expect(editor.removed).toBe(false); + editor.value = "첫 줄\n둘째\n셋째"; + key(editor, "Enter", { ctrlKey: true }); + expect(editor.removed).toBe(true); + const lines = getEntities() as TextEntity[]; + expect(lines.map((line) => line.getLabel())).toEqual(["둘째", "셋째", "첫 줄"]); + expect(lines.every((line) => line.groupId === "m1")).toBe(true); + expect(lines[1].getFirstPoint()?.y).toBeCloseTo(186); + undo(); + expect(labels()).toEqual(["둘째 줄", "첫 줄"]); + }); + + it("수량표 글자는 칸을 띄우지 않음", () => { + load(new TextEntity("b08-cross-table", "12.5", new Point(100, 200), { fontSize: 5 })); + dblclickAt(100, 200); + expect(created).toHaveLength(0); + expect(prompt).not.toHaveBeenCalled(); + }); +});