- 새 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>
328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
import { Box, Point, type Segment, Vector } from '@flatten-js/core';
|
|
import { cloneDeep } from 'es-toolkit/compat';
|
|
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 {
|
|
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';
|
|
|
|
/** 글자를 집었을 때 두르는 외곽선 색 — 도면 선과 헷갈리지 않게 회색. */
|
|
const TEXT_SELECTION_OUTLINE_COLOR = '#9aa0a6';
|
|
|
|
export interface TextOptions {
|
|
textDirection: Vector;
|
|
textAlign: 'left' | 'center' | 'right';
|
|
textColor: string;
|
|
fontSize: number;
|
|
fontFamily: string;
|
|
/** 굵게·기울임 (문자 편집기 기본 서식). 밑줄은 캔버스에 없어 넣지 않았다 */
|
|
bold?: boolean;
|
|
italic?: boolean;
|
|
/**
|
|
* 도각 자리표의 칸 크기(mm). 있으면 `basePoint` 가 **칸의 한가운데**이고 글자는
|
|
* 가로·세로 가운데 맞춤으로 그려진다 (2026-09-06 사용자 지시). 없으면 예전처럼
|
|
* 글자 하나로만 산다.
|
|
*/
|
|
boxWidth?: number;
|
|
boxHeight?: number;
|
|
}
|
|
|
|
export class TextEntity implements Entity {
|
|
public id: string = crypto.randomUUID();
|
|
public lineColor = '#fff';
|
|
public lineWidth = 1;
|
|
public lineDash: number[] = [];
|
|
public layerId: string;
|
|
/** 0~1 객체 투명도 (Entity 인터페이스의 선택 항목) */
|
|
public opacity?: number;
|
|
/** GROUP으로 묶인 객체가 공유하는 식별자 */
|
|
public groupId?: string;
|
|
/**
|
|
* 도각 편집에서만 쓰는 **보여 주기용 값** (2026-09-06 사용자 지시). 자리표
|
|
* `{{공사명}}` 대신 실제 공사명을 그려 사용자가 어디에 무엇이 들어가는지 알게 한다.
|
|
* 저장값(`label`)은 토큰 그대로다 — 값이 아니라 연결을 저장한다.
|
|
*/
|
|
public previewLabel: string | null = null;
|
|
private readonly options: TextOptions;
|
|
|
|
constructor(
|
|
layerId: string,
|
|
private label: string,
|
|
private basePoint: Point,
|
|
options?: Partial<TextOptions>
|
|
) {
|
|
this.layerId = layerId;
|
|
this.options = {
|
|
...DEFAULT_TEXT_OPTIONS,
|
|
...options,
|
|
};
|
|
}
|
|
|
|
public draw(
|
|
drawController: DrawController,
|
|
parentHighlighted?: boolean,
|
|
parentSelected?: boolean
|
|
): void {
|
|
const highlighted = parentHighlighted ?? isEntityHighlighted(this);
|
|
const selected = parentSelected ?? isEntitySelected(this);
|
|
drawController.setLineStyles(highlighted, selected, this.lineColor, this.lineWidth, this.lineDash);
|
|
drawController.drawText(this.previewLabel ?? this.label, this.basePoint, this.options);
|
|
// 집었을 때만 회색 외곽선을 두른다 (2026-09-06 사용자 지시) — 글자는 선 모양이
|
|
// 바뀌어도 티가 안 나 무엇을 골랐는지 보이지 않았다. 출력·내보내기는 선택 상태가
|
|
// 없어 이 선이 실리지 않는다. 테두리 = 더블클릭 입력 칸과 같은 셈(`text-frame.ts` · 487) ·
|
|
// 여러 줄은 묶음 하나(표시된 첫 줄이 그림).
|
|
if (highlighted || selected) {
|
|
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]);
|
|
}
|
|
}
|
|
}
|
|
|
|
public move(x: number, y: number) {
|
|
this.basePoint = this.basePoint.translate(x, y);
|
|
}
|
|
|
|
public scale(scaleOrigin: Point, scaleFactor: number) {
|
|
this.basePoint = scalePoint(this.basePoint, scaleOrigin, scaleFactor);
|
|
this.options.fontSize = this.options.fontSize * scaleFactor; // TODO discuss if text should scale or not?
|
|
}
|
|
|
|
public rotate(rotateOrigin: Point, angle: number) {
|
|
this.basePoint = this.basePoint.rotate(angle, rotateOrigin);
|
|
this.options.textDirection = this.options.textDirection.rotate(angle);
|
|
}
|
|
|
|
public mirror(mirrorAxis: LineEntity) {
|
|
this.basePoint = mirrorPointOverAxis(this.basePoint, mirrorAxis);
|
|
this.options.textDirection = new Vector(
|
|
new Point(0, 0),
|
|
new Point(this.options.textDirection.x, this.options.textDirection.y)
|
|
);
|
|
}
|
|
|
|
public clone(): TextEntity {
|
|
const copy = new TextEntity(
|
|
getActiveLayerId(),
|
|
this.label,
|
|
this.basePoint.clone(),
|
|
cloneDeep(this.options)
|
|
);
|
|
// 보여 주기용 값도 함께 옮긴다 — 안 옮기면 그립을 옮긴 순간 자리표가 다시
|
|
// `{{도면명}}` 으로 보인다(2026-09-06 실측).
|
|
copy.previewLabel = this.previewLabel;
|
|
return copy;
|
|
}
|
|
|
|
public intersectsWithBox(box: Box): boolean {
|
|
return box.contains(this.basePoint);
|
|
}
|
|
|
|
public isContainedInBox(box: Box): boolean {
|
|
return box.contains(this.basePoint);
|
|
}
|
|
|
|
public getBoundingBox(): Box {
|
|
const { boxWidth, boxHeight } = this.options;
|
|
if (boxWidth && boxHeight) {
|
|
// 자리표는 칸이 곧 경계다 — basePoint 가 칸 한가운데다.
|
|
return new Box(
|
|
this.basePoint.x - boxWidth / 2,
|
|
this.basePoint.y - boxHeight / 2,
|
|
this.basePoint.x + boxWidth / 2,
|
|
this.basePoint.y + boxHeight / 2
|
|
);
|
|
}
|
|
// TODO find better way of determining the text bounding box
|
|
return new Box(
|
|
this.basePoint.x,
|
|
this.basePoint.y,
|
|
this.basePoint.x + this.options.fontSize * this.label.length,
|
|
this.basePoint.y + this.options.fontSize
|
|
);
|
|
}
|
|
|
|
/** 자리표 칸 크기(mm)를 바꾼다. 글자 크기는 그대로 둔다. */
|
|
public setBoxSize(width: number, height: number): void {
|
|
this.options.boxWidth = Math.max(width, 1);
|
|
this.options.boxHeight = Math.max(height, 1);
|
|
}
|
|
|
|
/** 마주 보는 두 모서리로 칸을 다시 잡는다 — 마우스로 끌어 크기를 바꿀 때 쓴다. */
|
|
public setBoxFromCorners(a: Point, b: Point): void {
|
|
this.setBoxSize(Math.abs(b.x - a.x), Math.abs(b.y - a.y));
|
|
this.basePoint = new Point((a.x + b.x) / 2, (a.y + b.y) / 2);
|
|
}
|
|
|
|
/** 자리표 칸이 있는가 — 칸이 있으면 basePoint 가 칸 한가운데다. */
|
|
public hasBox(): boolean {
|
|
return Boolean(this.options.boxWidth && this.options.boxHeight);
|
|
}
|
|
|
|
public getTextOptions(): TextOptions {
|
|
return this.options;
|
|
}
|
|
|
|
public setTextOptions(newOptions: Partial<Omit<TextOptions, 'textDirection'>>): void {
|
|
Object.assign(this.options, newOptions);
|
|
}
|
|
|
|
public getLabel(): string {
|
|
return this.label;
|
|
}
|
|
|
|
/** 테이블 값 셀 등 기존 텍스트 내용을 직접 수정한다 (aislo 더블클릭 편집용). */
|
|
public setLabel(newLabel: string): void {
|
|
this.label = newLabel;
|
|
}
|
|
|
|
public getShape(): Shape | null {
|
|
return null; // TODO see why we need to get the shape out of an entity
|
|
}
|
|
|
|
public getSnapPoints(): SnapPoint[] {
|
|
return [];
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
public getIntersections(_entity: Entity): Point[] {
|
|
return [];
|
|
}
|
|
|
|
public getFirstPoint(): Point | null {
|
|
return this.basePoint;
|
|
}
|
|
|
|
/** 잡는 범위 = 글 테두리(안 = 0 · 밖 = 테두리까지) · 화면이 없으면(시험 · 내보내기) 기준점 */
|
|
public distanceTo(shape: Shape): [number, Segment] | null {
|
|
// 점만(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 {
|
|
return null;
|
|
}
|
|
|
|
public getType(): EntityName {
|
|
return EntityName.Text;
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
public containsPointOnShape(_point: Point): boolean {
|
|
return false;
|
|
}
|
|
|
|
public async toJson(): Promise<JsonEntity<TextJsonData> | null> {
|
|
return {
|
|
id: this.id,
|
|
type: EntityName.Text,
|
|
lineColor: this.lineColor,
|
|
lineWidth: this.lineWidth,
|
|
lineDash: this.lineDash,
|
|
layerId: this.layerId,
|
|
shapeData: {
|
|
label: this.label,
|
|
basePoint: { x: this.basePoint.x, y: this.basePoint.y },
|
|
options: {
|
|
textDirection: {
|
|
x: this.options.textDirection.x,
|
|
y: this.options.textDirection.y,
|
|
},
|
|
textAlign: this.options.textAlign,
|
|
textColor: this.options.textColor,
|
|
fontSize: this.options.fontSize,
|
|
fontFamily: this.options.fontFamily,
|
|
bold: this.options.bold,
|
|
italic: this.options.italic,
|
|
boxWidth: this.options.boxWidth,
|
|
boxHeight: this.options.boxHeight,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
public static async fromJson(jsonEntity: JsonEntity<TextJsonData>): Promise<TextEntity> {
|
|
if (!jsonEntity.shapeData) {
|
|
throw new Error('Invalid JSON entity of type Text: missing shapeData');
|
|
}
|
|
const textEntity = new TextEntity(
|
|
jsonEntity.layerId || getActiveLayerId(),
|
|
jsonEntity.shapeData.label,
|
|
new Point(jsonEntity.shapeData.basePoint.x, jsonEntity.shapeData.basePoint.y),
|
|
{
|
|
textDirection: new Vector(
|
|
jsonEntity.shapeData.options.textDirection.x,
|
|
jsonEntity.shapeData.options.textDirection.y
|
|
),
|
|
textAlign: jsonEntity.shapeData.options.textAlign,
|
|
textColor: jsonEntity.shapeData.options.textColor,
|
|
fontSize: jsonEntity.shapeData.options.fontSize,
|
|
fontFamily: jsonEntity.shapeData.options.fontFamily,
|
|
bold: jsonEntity.shapeData.options.bold,
|
|
italic: jsonEntity.shapeData.options.italic,
|
|
boxWidth: jsonEntity.shapeData.options.boxWidth,
|
|
boxHeight: jsonEntity.shapeData.options.boxHeight,
|
|
}
|
|
);
|
|
textEntity.id = jsonEntity.id;
|
|
textEntity.lineColor = jsonEntity.lineColor;
|
|
textEntity.lineWidth = jsonEntity.lineWidth;
|
|
textEntity.lineDash = jsonEntity.lineDash ?? [];
|
|
return textEntity;
|
|
}
|
|
}
|
|
|
|
export interface TextJsonData {
|
|
label: string;
|
|
basePoint: { x: number; y: number };
|
|
options: {
|
|
textDirection: { x: number; y: number };
|
|
textAlign: 'left' | 'center' | 'right';
|
|
textColor: string;
|
|
fontSize: number;
|
|
fontFamily: string;
|
|
bold?: boolean;
|
|
italic?: boolean;
|
|
/** 도각 자리표 칸 크기(mm) — basePoint 가 칸 한가운데다. */
|
|
boxWidth?: number;
|
|
boxHeight?: number;
|
|
};
|
|
}
|