/** * 글 테두리 한 벌(PLAN 487 · `helpers/text-frame.ts`) — 스침 · 고름 테두리 네 귀 = 더블클릭 입력 칸 네 귀 * (왼 · 가운데 · 오른 · 90° 돈 글 · 굵게 · 여러 줄 묶음 · 도각 칸) · 잡는 범위 = 테두리 안. * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad text-frame */ import { Point, Vector } from "@flatten-js/core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const SCALE = 2; const fonts: string[] = []; 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: () => SCALE, worldToTarget: (point: Point) => new Point(point.x * SCALE, point.y * SCALE), targetToWorld: (point: Point) => new Point(point.x / SCALE, point.y / SCALE), // 글자당 0.5 em · 굵게는 1.2 배 — 굵게가 폭 재기에 들어가는지 봄 measureTextScreen: (label: string, px: number, font: { bold?: boolean } = {}) => { fonts.push(font.bold ? "bold" : "normal"); return label.length * px * 0.5 * (font.bold ? 1.2 : 1); }, zoomToFitScreen: () => {}, }), })); import { TextEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/TextEntity.ts"; import { closeTextEditor, handleTextDoubleClick, openTextEditor, } from "../../../B07_DesignDetail/openwebcad/src/integration/text-inplace-edit"; import { resetUndoBaseline, setEntities, setHighlightedEntityIds, } from "../../../B07_DesignDetail/openwebcad/src/state"; class FakeElement { public value = ""; public className = ""; public style: Record = {}; constructor(public tagName: string) {} setAttribute() {} addEventListener() {} focus() {} select() {} remove() {} } let created: FakeElement[] = []; const BOUNDS = { left: 10, top: 20, bottom: 520, right: 810 }; const canvas = { getBoundingClientRect: () => BOUNDS } as unknown as HTMLCanvasElement; beforeEach(() => { created = []; fonts.length = 0; vi.stubGlobal("window", { 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: () => {}, removeEventListener: () => {}, }); }); afterEach(() => { closeTextEditor(false); setHighlightedEntityIds([]); vi.unstubAllGlobals(); }); type Xy = [number, number]; /** 월드 → 화면(px) — 캔버스 왼쪽 · 아래 기준 · y 뒤집음 */ const toScreen = (p: Point): Xy => [BOUNDS.left + p.x * SCALE, BOUNDS.bottom - p.y * SCALE]; /** 스침 테두리 네 귀(화면) — 글을 강조한 채 그려 dash 선 네 개의 시작점 */ function outlineCorners(entities: TextEntity[]): Xy[] { setEntities(entities, false); resetUndoBaseline(); setHighlightedEntityIds(entities.map((entity) => entity.id)); const starts: Point[] = []; const controller = { setLineStyles: () => {}, drawText: () => {}, drawLine: (start: Point) => starts.push(start), }; for (const entity of entities) entity.draw(controller as never); return starts.map(toScreen); } /** 입력 칸 네 귀(화면) — left · top + rotate · translate · 폭 · 높이 */ function editorCorners(editor: FakeElement): Xy[] { const px = (value: string) => Number.parseFloat(value); const m = /rotate\(([-\d.e]+)rad\) translate\(([-\d.e]+)px, ([-\d.e]+)px\)/.exec( editor.style.transform, ); if (!m) throw new Error(editor.style.transform); const [angle, tx, ty] = [Number(m[1]), Number(m[2]), Number(m[3])]; const [w, h] = [px(editor.style.width), px(editor.style.height)]; const [ox, oy] = [px(editor.style.left), px(editor.style.top)]; const at = (x: number, y: number): Xy => [ ox + x * Math.cos(angle) - y * Math.sin(angle), oy + x * Math.sin(angle) + y * Math.cos(angle), ]; return [at(tx, ty), at(tx + w, ty), at(tx + w, ty + h), at(tx, ty + h)]; } const expectSame = (a: Xy[], b: Xy[]) => { expect(a).toHaveLength(4); a.forEach(([x, y], i) => { expect(Math.abs(x - b[i][0])).toBeLessThanOrEqual(1); expect(Math.abs(y - b[i][1])).toBeLessThanOrEqual(1); }); }; const text = (label: string, options: Record = {}, at = new Point(100, 200)) => new TextEntity("0", label, at, { fontSize: 5, ...options }); describe("스침 테두리 = 더블클릭 입력 칸", () => { it.each([ ["왼", { textAlign: "left" }], ["가운데", { textAlign: "center" }], ["오른", { textAlign: "right" }], ["90° 돈 글", { textDirection: new Vector(0, 1) }], ["30° 돈 오른 정렬", { textAlign: "right", textDirection: new Vector(Math.sqrt(3), 1) }], ["굵게", { bold: true }], ])("%s — 네 귀 차 ≤ 1 px", (_name, options) => { const entity = text("STA. 0+000", options); const outline = outlineCorners([entity]); openTextEditor([entity], canvas); expectSame(outline, editorCorners(created[0])); }); it("굵게 = 굵은 글꼴로 폭을 잼 · 테두리 폭이 넓어짐", () => { const plain = outlineCorners([text("배수관")]); const bold = outlineCorners([text("배수관", { bold: true })]); expect(fonts).toContain("bold"); expect(bold[1][0] - bold[0][0]).toBeGreaterThan(plain[1][0] - plain[0][0]); }); it("가운데 정렬 = 기준점이 테두리 한가운데 · 위아래 같음", () => { const [lt, rt, , lb] = outlineCorners([text("배수관")]); const [bx, by] = toScreen(new Point(100, 200)); expect((lt[0] + rt[0]) / 2).toBeCloseTo(bx); expect((lt[1] + lb[1]) / 2).toBeCloseTo(by); }); it("여러 줄 = 묶음 테두리 하나(첫 줄이 그림) = 여러 줄 칸", () => { const first = text("첫 줄", {}, new Point(100, 200)); const second = text("둘째 줄 길게", {}, new Point(100, 193)); first.groupId = second.groupId = "m1"; const outline = outlineCorners([second, first]); openTextEditor([first, second], canvas); expect(created[0].tagName).toBe("textarea"); expectSame(outline, editorCorners(created[0])); }); it("도각 칸 글 = 칸이 테두리 · 입력 칸도 칸 크기", () => { const entity = text("{{공사명}}", { boxWidth: 60, boxHeight: 8 }); const outline = outlineCorners([entity]); expect(outline[1][0] - outline[0][0]).toBeCloseTo(60 * SCALE); openTextEditor([entity], canvas); expectSame(outline, editorCorners(created[0])); }); }); describe("잡는 범위 = 테두리 안", () => { it("테두리 안 = 0 · 밖 = 테두리까지 거리", () => { const entity = text("STA. 0+000", { textAlign: "left" }); // 폭 = 10 자 × 10 px × 0.5 = 50 px = 25 월드 · 여유 7.5 px = 3.75 월드 expect(entity.distanceTo(new Point(124, 201))?.[0]).toBe(0); expect(entity.distanceTo(new Point(130, 200))?.[0]).toBeCloseTo(130 - (125 + 3.75)); }); it("끝 글자 더블클릭도 칸을 띄움 · 테두리 + 집기 반경 밖은 안 띄움", () => { setEntities([text("STA. 0+000", { textAlign: "left" })], false); const dblclickAt = (x: number, y: number) => handleTextDoubleClick( { clientX: BOUNDS.left + x * SCALE, clientY: BOUNDS.bottom - y * SCALE } as MouseEvent, canvas, ); dblclickAt(140, 200); // 테두리 오른끝 128.75 + 반경 5 밖 expect(created).toHaveLength(0); dblclickAt(124, 200); // 끝 글자 expect(created).toHaveLength(1); }); });