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:
@@ -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("");
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user