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
@@ -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<typeof getScreenCanvasDrawController>;
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 {