조사표 8~13절 검토에서 "기본 기능"으로 고른 것을 반영한다. - 좌표 입력: 절대·상대 좌표가 양수만 받아 `@-100,50`을 거부했다. 부호를 허용하고 극좌표 `@거리<각도`·`거리<각도`를 더했다. `-`·`+`가 확대·축소 단축키로 먼저 잡혀 음수의 첫 글자를 먹고 있어 그 두 단축키를 뺐다(줌은 휠·뷰 막대·명령). - 그립 편집: 선택 객체에 그립을 그리고 집어서 옮긴다. 선 끝점·중점, 폴리선 정점, 사각형 모서리, 원 중심·반지름, 문자·점 기준점. 폴리선은 세그먼트 중점을 끌면 정점이 늘고 정점 위 Ctrl+클릭이면 준다. 형상 필드가 private이라 공개 생성자로 다시 만들어 바꿔 끼우고 id·도면층·색·그룹을 물려받는다. - 자동 백업·복구: 5초 디바운스로 복구 전용 키에 저장하고, 시작할 때 백업이 있으면 눌러서 되살리는 안내를 띄운다. 저장(QSAVE)에 성공하면 백업을 지운다. - 선택 순환: 같은 자리를 다시 클릭하면 겹친 후보를 차례로 돌린다. - 상태막대: `극좌표 추적` 버튼이 `직교`와 같은 onClick이라 같은 일을 하고 있었다. 각각 45°·90°를 켜고 끄도록 고치고, 스냅 추적 토글과 F3·F7·F8·F10을 붙였다. - 문자 굵게·기울임을 캔버스·SVG·JSON·스타일 패널에 연결했다. - 조사표: 이미 되어 있던 5건의 표기를 정정하고, 출력·내보내기(9절)는 결재창 이후 PDF·DXF·DWG로 반영할 것이라 보류(P)로 구분해 사유를 남겼다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
231 lines
6.4 KiB
TypeScript
231 lines
6.4 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 { 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<TextOptions>
|
|
) {
|
|
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<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;
|
|
}
|
|
|
|
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<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,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
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,
|
|
}
|
|
);
|
|
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;
|
|
};
|
|
}
|