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 { getActiveLayerId, 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 ) { 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 사용자 지시) — 글자는 선 모양이 // 바뀌어도 티가 안 나 무엇을 골랐는지 보이지 않았다. 출력·내보내기는 선택 상태가 // 없어 이 선이 실리지 않는다. 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), ]; 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>): 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; } public distanceTo(shape: Shape): [number, Segment] | null { return this.basePoint.distanceTo(shape); } 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 | 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): Promise { 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; }; }