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'; export interface TextOptions { textDirection: Vector; textAlign: 'left' | 'center' | 'right'; textColor: string; fontSize: number; fontFamily: string; /** 굵게·기울임 (문자 편집기 기본 서식). 밑줄은 캔버스에 없어 넣지 않았다 */ bold?: boolean; italic?: boolean; } 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; 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 { drawController.setLineStyles( parentHighlighted ?? isEntityHighlighted(this), parentSelected ?? isEntitySelected(this), this.lineColor, this.lineWidth, this.lineDash ); drawController.drawText(this.label, this.basePoint, this.options); } 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 { return new TextEntity( getActiveLayerId(), this.label, this.basePoint.clone(), cloneDeep(this.options) ); } public intersectsWithBox(box: Box): boolean { return box.contains(this.basePoint); } public isContainedInBox(box: Box): boolean { return box.contains(this.basePoint); } public getBoundingBox(): Box { // 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 ); } 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, }, }, }; } 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, } ); 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; }; }