feat(487 CAD 글 테두리 · 리본 켜짐): 스침 테두리 = 더블클릭 입력 칸 한 벌(text-frame) · 잡는 범위 = 테두리 안 · 리본 켜짐 하나만

- 새 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>
This commit is contained in:
2026-10-19 22:25:55 +09:00
co-authored by Claude Opus 5.5
parent dce63ccc21
commit cdfd2c6663
11 changed files with 455 additions and 51 deletions
+194
View File
@@ -0,0 +1,194 @@
/**
* 글 테두리 한 벌(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);
});
});
@@ -135,6 +135,11 @@ describe("글자 더블클릭 = 그 자리 입력 칸", () => {
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
// 칸 = 스침 테두리(487) — 잰 폭 40 + 양옆 여유 0.75 × 10 · 높이 1.2 줄 · 가운데 정렬 = 반 폭 왼쪽
expect(editor.style.width).toBe("55px");
expect(editor.style.padding).toBe("0 7.5px");
expect(editor.style.height).toBe("12px");
expect(editor.style.transform).toMatch(/translate\(-27\.5px, -6px\)$/);
expect(text.previewLabel).toBe("");
});
+3 -2
View File
@@ -228,8 +228,9 @@ def test_CAD_머리줄_없음_탭줄_빈자리_변수():
css = (src / "App.css").read_text("utf-8")
assert '<span ref={slotRef} className="cad-ribbon-tabs__slot" />' in ribbon
assert ribbon.index("cad-ribbon-tabs__slot") < ribbon.index("<QuickAccessBar />")
assert "'--cad-tab-slot-left', `${Math.round(s.left)}px`" in ribbon
assert "'--cad-tab-slot-right', `${Math.round(window.innerWidth - s.right)}px`" in ribbon
flat = " ".join(ribbon.split()) # 줄바꿈 · 들여쓰기 무관
assert "'--cad-tab-slot-left', `${Math.round(s.left)}px`" in flat
assert "'--cad-tab-slot-right', `${Math.round(window.innerWidth - s.right)}px`" in flat
assert "hidden={QUICK_ACCESS_HIDDEN.includes(id)}" in quick and "cad-titlebar" not in quick
assert "QUICK_ACCESS_HIDDEN: string[] = ['QSAVE', 'EXPORT']" in (
src / "ribbon/ribbon.config.ts"