- 새 helpers/text-frame.ts — 정렬 · 가운데 맞춤 · 실제 글꼴 폭(굵게 · 기울임) · 회전 · 여러 줄 묶음 · 도각 칸 = 테두리 한 셈 - TextEntity 스침 · 고름 테두리 = 그 네 귀(여러 줄은 묶음 하나) · distanceTo = 테두리 안 0 · getBoundingBox 그대로 - 입력 칸 자리 · 폭 · 높이 = 같은 셈(여유 = 안쪽 여백) · 더블클릭 범위 = 테두리 + 집기 반경 - measureTextScreen 글꼴 받음 · 디버그 창구 textFrame(화면 네 귀) - 리본 스침 = 바탕만(hover 기기만) · 켜짐 = 테두리 + 밑줄 · 누른 뒤 blur(초점 테두리 남음 막음) - 시험 text-frame.test.ts 새로 · text-inplace-edit.test.ts 칸 폭 · 여백 · test_cad_host_tabs 줄바꿈 무관 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
195 lines
7.6 KiB
TypeScript
195 lines
7.6 KiB
TypeScript
/**
|
||
* 글 테두리 한 벌(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<Record<string, unknown>>()),
|
||
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<string, string> = {};
|
||
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<string, unknown> = {}, 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);
|
||
});
|
||
});
|