diff --git a/B07_DesignDetail/openwebcad/src/App.css b/B07_DesignDetail/openwebcad/src/App.css index ce7c76163..1554c60cf 100644 --- a/B07_DesignDetail/openwebcad/src/App.css +++ b/B07_DesignDetail/openwebcad/src/App.css @@ -320,12 +320,15 @@ input[type="color"][data-mixed="true"]::-webkit-color-swatch { background: trans font-size: 15px; line-height: 1; } -.cad-tool:hover:not(:disabled), +/* 켜짐 = 테두리 + 바탕 + 밑줄 · 스침 = 바탕만(마우스 기기만) — 켜진 단추가 늘 하나로 보이게(487) */ +@media (hover: hover) { + .cad-tool:hover:not(:disabled) { + background: var(--cad-hover); + } +} .cad-tool[data-active="true"] { border-color: var(--cad-accent); background: var(--cad-hover); -} -.cad-tool[data-active="true"] { box-shadow: inset 0 -2px var(--cad-accent); } .cad-tool:disabled { diff --git a/B07_DesignDetail/openwebcad/src/components/QuickAccessBar.tsx b/B07_DesignDetail/openwebcad/src/components/QuickAccessBar.tsx index c99d8e61c..046c48930 100644 --- a/B07_DesignDetail/openwebcad/src/components/QuickAccessBar.tsx +++ b/B07_DesignDetail/openwebcad/src/components/QuickAccessBar.tsx @@ -19,7 +19,10 @@ export const QuickAccessBar: FC = () => { key={command.id} hidden={QUICK_ACCESS_HIDDEN.includes(id)} title={`${command.label} (${command.id})`} - onClick={() => runCommand(command)} + onClick={(event) => { + event.currentTarget.blur(); + runCommand(command); + }} > {command.glyph} diff --git a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts index d48309e2e..b33539c5b 100644 --- a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts +++ b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts @@ -526,9 +526,13 @@ export class ScreenCanvasDrawController implements DrawController { } /** 화면 글 폭(px) — drawTextScreen 과 같은 글꼴로 잼 */ - public measureTextScreen(label: string, fontSize: number): number { + public measureTextScreen( + label: string, + fontSize: number, + font: Partial<{ fontFamily: string; bold: boolean; italic: boolean }> = {} + ): number { this.context.save(); - this.context.font = `${fontSize}px ${DEFAULT_TEXT_OPTIONS.fontFamily}`; + this.context.font = `${font.italic ? 'italic ' : ''}${font.bold ? 'bold ' : ''}${fontSize}px ${font.fontFamily ?? DEFAULT_TEXT_OPTIONS.fontFamily}`; const width = this.context.measureText(label).width; this.context.restore(); return width; diff --git a/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts b/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts index 6479e1566..00ab77f88 100644 --- a/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts @@ -4,7 +4,19 @@ import type { Shape, SnapPoint } from '../App.types'; import { DEFAULT_TEXT_OPTIONS, type DrawController } from '../drawControllers/DrawController'; import { mirrorPointOverAxis } from '../helpers/mirror-point-over-axis.ts'; import { scalePoint } from '../helpers/scale-point.ts'; -import { getActiveLayerId, isEntityHighlighted, isEntitySelected } from '../state.ts'; +import { + textFrame, + textFrameCorners, + textFrameDistance, + textLinesOf, +} from '../helpers/text-frame.ts'; +import { + getActiveLayerId, + getEntities, + getScreenCanvasDrawController, + isEntityHighlighted, + isEntitySelected, +} from '../state.ts'; import { type Entity, EntityName, type JsonEntity } from './Entity'; import type { LineEntity } from './LineEntity.ts'; @@ -71,15 +83,14 @@ export class TextEntity implements Entity { drawController.drawText(this.previewLabel ?? this.label, this.basePoint, this.options); // 집었을 때만 회색 외곽선을 두른다 (2026-09-06 사용자 지시) — 글자는 선 모양이 // 바뀌어도 티가 안 나 무엇을 골랐는지 보이지 않았다. 출력·내보내기는 선택 상태가 - // 없어 이 선이 실리지 않는다. + // 없어 이 선이 실리지 않는다. 테두리 = 더블클릭 입력 칸과 같은 셈(`text-frame.ts` · 487) · + // 여러 줄은 묶음 하나(표시된 첫 줄이 그림). if (highlighted || selected) { - const box = this.getBoundingBox(); - const corners = [ - new Point(box.xmin, box.ymin), - new Point(box.xmax, box.ymin), - new Point(box.xmax, box.ymax), - new Point(box.xmin, box.ymax), - ]; + const lines = textLinesOf(this, getEntities()); + const marked = (line: TextEntity) => isEntityHighlighted(line) || isEntitySelected(line); + if (lines.length > 1 && lines.find(marked) !== this) return; + const corners = this.frameOf(lines)?.corners; + if (!corners) return; drawController.setLineStyles(false, false, TEXT_SELECTION_OUTLINE_COLOR, 1, [4, 4]); for (let index = 0; index < corners.length; index++) { drawController.drawLine(corners[index], corners[(index + 1) % corners.length]); @@ -201,8 +212,28 @@ export class TextEntity implements Entity { return this.basePoint; } + /** 잡는 범위 = 글 테두리(안 = 0 · 밖 = 테두리까지) · 화면이 없으면(시험 · 내보내기) 기준점 */ public distanceTo(shape: Shape): [number, Segment] | null { - return this.basePoint.distanceTo(shape); + // 점만(x · y) — instanceof 는 flatten 을 따로 실은 시험에서 갈림 + const frame = typeof (shape as Point).x === 'number' ? this.frameOf([this]) : null; + if (!frame) return this.basePoint.distanceTo(shape); + return textFrameDistance([this], frame.frame, frame.scale, shape as Point); + } + + /** 글 테두리(화면 기준 셈) — 화면 그리기 제어기가 없으면 null */ + private frameOf(lines: TextEntity[]) { + let controller: ReturnType; + try { + controller = getScreenCanvasDrawController(); + } catch { + return null; + } + const scale = controller.getScreenScale(); + const rows = lines.map((line) => line.previewLabel ?? line.label); + const frame = textFrame(lines, rows, scale, (row, px, font) => + controller.measureTextScreen(row, px, font) + ); + return { frame, scale, corners: textFrameCorners(lines, frame, scale) }; } public getSvgString(): string | null { diff --git a/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts b/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts index 465047359..f1e2a86c7 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts @@ -17,6 +17,8 @@ import { } from '../state'; import { Point } from '@flatten-js/core'; import { isEntityHidden } from './visibility'; +import type { TextEntity } from '../entities/TextEntity'; +import { textFrame, textFrameCorners, textLinesOf } from './text-frame'; export function registerCadDebugHook(): void { (window as unknown as Record).__aisloCad = { @@ -59,5 +61,21 @@ export function registerCadDebugHook(): void { const point = getScreenCanvasDrawController().worldToTarget(new Point(x, y)); return { x: point.x, y: point.y }; }, + // 글 스침 테두리 네 귀(화면 좌표 · 왼위부터) — 더블클릭 입력 칸과 픽셀로 견줌(487). + textFrame: (id: string) => { + const entity = getEntities().find((item) => item.id === id) as TextEntity | undefined; + if (!entity?.getTextOptions) return null; + const controller = getScreenCanvasDrawController(); + const lines = textLinesOf(entity, getEntities()); + const scale = controller.getScreenScale(); + const rows = lines.map((line) => line.previewLabel ?? line.getLabel()); + const frame = textFrame(lines, rows, scale, (row, px, font) => + controller.measureTextScreen(row, px, font) + ); + return textFrameCorners(lines, frame, scale).map((corner) => { + const point = controller.worldToTarget(corner); + return { x: point.x, y: point.y }; + }); + }, }; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/selection-style.ts b/B07_DesignDetail/openwebcad/src/helpers/selection-style.ts index 4ee9b4c82..fa9b60bcd 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/selection-style.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/selection-style.ts @@ -104,4 +104,5 @@ export function restyle(targets: Entity[], change: (entity: Entity) => void, tra export function repaint(copies: Entity[], change: (entity: Entity) => void): void { for (const copy of copies) applyDeep(copy, change); setEntities([...getEntities()], false); + notifyWindow(HtmlEvent.UPDATE_STATE); // 견본 색도 따라감 } diff --git a/B07_DesignDetail/openwebcad/src/helpers/text-frame.ts b/B07_DesignDetail/openwebcad/src/helpers/text-frame.ts new file mode 100644 index 000000000..039fe1280 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/text-frame.ts @@ -0,0 +1,150 @@ +/** + * 글 테두리 한 벌(487 · 요구 280-6) — 스침 · 고름 테두리(`TextEntity.draw`) · 더블클릭 입력 칸 + * (`integration/text-inplace-edit.ts`) · 잡는 범위(`TextEntity.distanceTo`)가 같은 셈을 씀. + * 테두리 = 글 방향 축(u 오른쪽 · v 위) · 첫 줄 기준점 = 0 · 단위 = 화면 px. + * 정렬(왼 · 가운데 · 오른) · 가운데 맞춤(textBaseline middle) · 실제 글꼴 폭 · 회전 · 여러 줄 묶음 · 도각 칸. + * `TextEntity.getBoundingBox` 는 안 씀(줄맞춤 · 그립 · 공간 색인 · 창 고르기 몫). + */ +import { Point, Segment } from '@flatten-js/core'; +import { type Entity, EntityName } from '../entities/Entity'; +import type { TextEntity, TextOptions } from '../entities/TextEntity'; + +/** 양옆 여유(× 글 크기) — 옛 입력 칸 폭 여유 1.5 를 반씩 */ +export const TEXT_FRAME_PAD = 0.75; +/** 한 줄 높이(× 글 크기) */ +export const TEXT_LINE_RATIO = 1.2; + +/** 화면 글 폭(px) — 그리기와 같은 글꼴(굵게 · 기울임 포함)로 잼 */ +export type MeasureText = ( + label: string, + fontPx: number, + font: Pick +) => number; + +export interface TextFrame { + left: number; + right: number; + top: number; + bottom: number; + /** 양옆 여유(px) — 입력 칸 안쪽 여백 */ + pad: number; + fontPx: number; + linePx: number; +} + +const basePointOf = (entity: TextEntity): Point => entity.getFirstPoint() as Point; + +let groupMemo: { entities: Entity[]; groups: Map } | null = null; +const groupOf = (entities: Entity[], groupId: string): Entity[] => { + if (groupMemo?.entities !== entities) { + const groups = new Map(); + for (const entity of entities) { + if (!entity.groupId) continue; + const members = groups.get(entity.groupId); + if (members) members.push(entity); + else groups.set(entity.groupId, [entity]); + } + groupMemo = { entities, groups }; + } + return groupMemo.groups.get(groupId) ?? []; +}; + +/** 여러 줄 문자(MTEXT) = 같은 묶음의 글자들이 같은 x 에 위아래로 놓인 것 · 위 줄부터. 아니면 그 글자 하나 */ +export function textLinesOf(target: TextEntity, entities: Entity[]): TextEntity[] { + if (!target.groupId) return [target]; + const group = groupOf(entities, target.groupId); + const x = basePointOf(target).x; + const isMultiline = + group.length > 1 && + group.every( + (entity) => + entity.getType() === EntityName.Text && + Math.abs(basePointOf(entity as TextEntity).x - x) < 1e-6 + ); + if (!isMultiline) return [target]; + return ([...group] as TextEntity[]).sort((a, b) => basePointOf(b).y - basePointOf(a).y); +} + +/** 글 묶음 테두리(px) — rows = 보일 글(입력 칸은 친 글) · scale = 화면 px / 월드 · minFontPx = 입력 칸 최소 글 크기 */ +export function textFrame( + lines: TextEntity[], + rows: string[], + scale: number, + measure: MeasureText, + minFontPx = 0 +): TextFrame { + const options = lines[0].getTextOptions(); + const fontPx = Math.max(options.fontSize * scale, minFontPx); + if (options.boxWidth && options.boxHeight) { + // 도각 자리표 = 칸이 곧 테두리(기준점 = 칸 한가운데) + const width = options.boxWidth * scale; + const height = options.boxHeight * scale; + return { + left: -width / 2, + right: width / 2, + top: height / 2, + bottom: -height / 2, + pad: 0, + fontPx, + linePx: height, + }; + } + const linePx = + lines.length > 1 + ? Math.max((basePointOf(lines[0]).y - basePointOf(lines[1]).y) * scale, fontPx) + : fontPx * TEXT_LINE_RATIO; + const widest = Math.max(0, ...rows.map((row) => measure(row, fontPx, options))); + const pad = fontPx * TEXT_FRAME_PAD; + const start = + options.textAlign === 'left' ? 0 : options.textAlign === 'right' ? -widest : -widest / 2; + return { + left: start - pad, + right: start + widest + pad, + top: linePx / 2, + bottom: linePx / 2 - linePx * Math.max(rows.length, 1), + pad, + fontPx, + linePx, + }; +} + +/** 글 방향 단위 벡터(월드 · 위 +y) */ +const axesOf = (entity: TextEntity): { ux: number; uy: number } => { + const { x, y } = entity.getTextOptions().textDirection; + const length = Math.hypot(x, y) || 1; + return { ux: x / length, uy: y / length }; +}; + +/** 테두리 네 귀(월드) — 왼위 · 오른위 · 오른아래 · 왼아래 */ +export function textFrameCorners(lines: TextEntity[], frame: TextFrame, scale: number): Point[] { + const base = basePointOf(lines[0]); + const { ux, uy } = axesOf(lines[0]); + const at = (u: number, v: number) => + new Point(base.x + (ux * u - uy * v) / scale, base.y + (uy * u + ux * v) / scale); + return [ + at(frame.left, frame.top), + at(frame.right, frame.top), + at(frame.right, frame.bottom), + at(frame.left, frame.bottom), + ]; +} + +/** 점(월드)과 테두리 거리(월드 · 안 = 0) · 테두리 위 가장 가까운 점까지 선분 */ +export function textFrameDistance( + lines: TextEntity[], + frame: TextFrame, + scale: number, + point: Point +): [number, Segment] { + const base = basePointOf(lines[0]); + const { ux, uy } = axesOf(lines[0]); + const dx = (point.x - base.x) * scale; + const dy = (point.y - base.y) * scale; + const u = Math.min(Math.max(dx * ux + dy * uy, frame.left), frame.right); + const v = Math.min(Math.max(-dx * uy + dy * ux, frame.bottom), frame.top); + const nearest = new Point(base.x + (ux * u - uy * v) / scale, base.y + (uy * u + ux * v) / scale); + return [ + Math.hypot(point.x - nearest.x, point.y - nearest.y), + new Segment(new Point(point.x, point.y), nearest), + ]; +} diff --git a/B07_DesignDetail/openwebcad/src/integration/text-inplace-edit.ts b/B07_DesignDetail/openwebcad/src/integration/text-inplace-edit.ts index 8b2161276..80d001ca5 100644 --- a/B07_DesignDetail/openwebcad/src/integration/text-inplace-edit.ts +++ b/B07_DesignDetail/openwebcad/src/integration/text-inplace-edit.ts @@ -8,7 +8,9 @@ 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 { pickRadius } from '../helpers/pick-radius.ts'; import { bumpSceneVersion } from '../helpers/scene-version.ts'; +import { textFrame, textLinesOf } from '../helpers/text-frame.ts'; import { isImeKey } from '../inputController/ime-sink.ts'; import { getEntities, @@ -27,6 +29,8 @@ import { cloneKeepingLook } from '../tools/move-tool.helpers.ts'; * 화면과 저장값이 어긋났다. */ const QUANTITY_TABLE_LAYER_ID = 'b08-cross-table'; +export { textLinesOf }; + /** 새로 늘어난 줄 간격 — 여러 줄 문자 명령(text-tools)과 같은 배수 */ const LINE_GAP_RATIO = 1.35; /** 너무 작게 줌아웃했을 때도 칠 수 있는 최소 글 크기(px) */ @@ -36,20 +40,6 @@ 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[], @@ -123,7 +113,6 @@ export function openTextEditor(lines: TextEntity[], canvas: HTMLCanvasElement): 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', @@ -137,6 +126,7 @@ export function openTextEditor(lines: TextEntity[], canvas: HTMLCanvasElement): whiteSpace: 'pre', overflow: 'hidden', resize: 'none', + boxSizing: 'border-box', transformOrigin: '0 0', }); // 칸 아래 캔버스 글자를 감춘다 — 겹쳐 두 벌로 보이지 않게(닫을 때 되살림) @@ -147,23 +137,25 @@ export function openTextEditor(lines: TextEntity[], canvas: HTMLCanvasElement): 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; + // 칸 = 스침 테두리와 같은 셈(`text-box.ts`) — 친 글 폭을 매 화면 다시 잼 + const box = textFrame( + lines, + editor.value.split('\n'), + controller.getScreenScale(), + (row, px, font) => controller.measureTextScreen(row, px, font), + MIN_EDITOR_FONT_PX + ); 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)`, + font: `${options.italic ? 'italic ' : ''}${options.bold ? 'bold ' : ''}${box.fontPx}px ${options.fontFamily}`, + lineHeight: `${box.linePx}px`, + padding: `0 ${box.pad}px`, + width: `${box.right - box.left}px`, + height: `${box.top - box.bottom}px`, + transform: `rotate(${angle}rad) translate(${box.left}px, ${-box.top}px)`, }); frame = requestAnimationFrame(place); }; @@ -235,10 +227,8 @@ export function handleTextDoubleClick(event: MouseEvent, canvas: HTMLCanvasEleme 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; + // 잡는 범위 = 글 테두리 + 집기 반경(스침 · 고르기와 같음 · 487) + if (!closest || closestDistance > pickRadius()) return; if (closest.layerId === QUANTITY_TABLE_LAYER_ID) { toast.info('수량표는 횡단설계(B06)에서 고칩니다. 고치고 돌아오면 반영됩니다.'); return; diff --git a/B07_DesignDetail/openwebcad/src/ribbon/Ribbon.tsx b/B07_DesignDetail/openwebcad/src/ribbon/Ribbon.tsx index 87ea40472..954404559 100644 --- a/B07_DesignDetail/openwebcad/src/ribbon/Ribbon.tsx +++ b/B07_DesignDetail/openwebcad/src/ribbon/Ribbon.tsx @@ -35,7 +35,11 @@ const CommandButton: FC = ({ command, size, activeTool, onRu title={`${command.label}${command.aliases?.length ? ` (${command.aliases[0]})` : ''}${ command.hint ? ` — ${command.hint}` : '' }`} - onClick={() => onRun(command)} + onClick={(event) => { + // 초점 테두리가 남아 「선택」과 둘이 켜진 듯 보이지 않게(487) + event.currentTarget.blur(); + onRun(command); + }} > {command.glyph} {command.label} @@ -135,7 +139,8 @@ const Panel: FC<{ type="button" key={command.id} title={command.hint} - onClick={() => { + onClick={(event) => { + event.currentTarget.blur(); setOverflowAt(null); onRun(command); }} diff --git a/resources/tester/cad/text-frame.test.ts b/resources/tester/cad/text-frame.test.ts new file mode 100644 index 000000000..cad6a8d27 --- /dev/null +++ b/resources/tester/cad/text-frame.test.ts @@ -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>()), + 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); + }); +}); diff --git a/resources/tester/cad/text-inplace-edit.test.ts b/resources/tester/cad/text-inplace-edit.test.ts index 8b870cd5e..3172888a5 100644 --- a/resources/tester/cad/text-inplace-edit.test.ts +++ b/resources/tester/cad/text-inplace-edit.test.ts @@ -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(""); }); diff --git a/resources/tester/test_cad_host_tabs.py b/resources/tester/test_cad_host_tabs.py index 92ea8e8a0..d5ba48fce 100644 --- a/resources/tester/test_cad_host_tabs.py +++ b/resources/tester/test_cad_host_tabs.py @@ -228,8 +228,9 @@ def test_CAD_머리줄_없음_탭줄_빈자리_변수(): css = (src / "App.css").read_text("utf-8") assert '' in ribbon assert ribbon.index("cad-ribbon-tabs__slot") < ribbon.index("") - 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"