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:
@@ -318,12 +318,15 @@ body > canvas[data-id="canvas"] {
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
}
|
||||
.cad-tool:hover:not(:disabled),
|
||||
/* 켜짐 = 테두리 + 바탕 + 밑줄 · 스침 = 바탕만(마우스 기기만) — 켜진 단추가 늘 하나로 보이게(487) */
|
||||
@media (hover: hover) {
|
||||
.cad-tool:hover:not(:disabled) {
|
||||
background: var(--cad-hover);
|
||||
}
|
||||
}
|
||||
.cad-tool[data-active="true"] {
|
||||
border-color: var(--cad-accent);
|
||||
background: var(--cad-hover);
|
||||
}
|
||||
.cad-tool[data-active="true"] {
|
||||
box-shadow: inset 0 -2px var(--cad-accent);
|
||||
}
|
||||
.cad-tool:disabled {
|
||||
|
||||
@@ -19,7 +19,10 @@ export const QuickAccessBar: FC = () => {
|
||||
key={command.id}
|
||||
hidden={QUICK_ACCESS_HIDDEN.includes(id)}
|
||||
title={`${command.label} (${command.id})`}
|
||||
onClick={() => runCommand(command)}
|
||||
onClick={(event) => {
|
||||
event.currentTarget.blur();
|
||||
runCommand(command);
|
||||
}}
|
||||
>
|
||||
<span aria-hidden>{command.glyph}</span>
|
||||
</button>
|
||||
|
||||
@@ -526,9 +526,13 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
}
|
||||
|
||||
/** 화면 글 폭(px) — drawTextScreen 과 같은 글꼴로 잼 */
|
||||
public measureTextScreen(label: string, fontSize: number): number {
|
||||
public measureTextScreen(
|
||||
label: string,
|
||||
fontSize: number,
|
||||
font: Partial<{ fontFamily: string; bold: boolean; italic: boolean }> = {}
|
||||
): number {
|
||||
this.context.save();
|
||||
this.context.font = `${fontSize}px ${DEFAULT_TEXT_OPTIONS.fontFamily}`;
|
||||
this.context.font = `${font.italic ? 'italic ' : ''}${font.bold ? 'bold ' : ''}${fontSize}px ${font.fontFamily ?? DEFAULT_TEXT_OPTIONS.fontFamily}`;
|
||||
const width = this.context.measureText(label).width;
|
||||
this.context.restore();
|
||||
return width;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
} from '../state';
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { isEntityHidden } from './visibility';
|
||||
import type { TextEntity } from '../entities/TextEntity';
|
||||
import { textFrame, textFrameCorners, textLinesOf } from './text-frame';
|
||||
|
||||
export function registerCadDebugHook(): void {
|
||||
(window as unknown as Record<string, unknown>).__aisloCad = {
|
||||
@@ -59,5 +61,21 @@ export function registerCadDebugHook(): void {
|
||||
const point = getScreenCanvasDrawController().worldToTarget(new Point(x, y));
|
||||
return { x: point.x, y: point.y };
|
||||
},
|
||||
// 글 스침 테두리 네 귀(화면 좌표 · 왼위부터) — 더블클릭 입력 칸과 픽셀로 견줌(487).
|
||||
textFrame: (id: string) => {
|
||||
const entity = getEntities().find((item) => item.id === id) as TextEntity | undefined;
|
||||
if (!entity?.getTextOptions) return null;
|
||||
const controller = getScreenCanvasDrawController();
|
||||
const lines = textLinesOf(entity, getEntities());
|
||||
const scale = controller.getScreenScale();
|
||||
const rows = lines.map((line) => line.previewLabel ?? line.getLabel());
|
||||
const frame = textFrame(lines, rows, scale, (row, px, font) =>
|
||||
controller.measureTextScreen(row, px, font)
|
||||
);
|
||||
return textFrameCorners(lines, frame, scale).map((corner) => {
|
||||
const point = controller.worldToTarget(corner);
|
||||
return { x: point.x, y: point.y };
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 글 테두리 한 벌(487 · 요구 280-6) — 스침 · 고름 테두리(`TextEntity.draw`) · 더블클릭 입력 칸
|
||||
* (`integration/text-inplace-edit.ts`) · 잡는 범위(`TextEntity.distanceTo`)가 같은 셈을 씀.
|
||||
* 테두리 = 글 방향 축(u 오른쪽 · v 위) · 첫 줄 기준점 = 0 · 단위 = 화면 px.
|
||||
* 정렬(왼 · 가운데 · 오른) · 가운데 맞춤(textBaseline middle) · 실제 글꼴 폭 · 회전 · 여러 줄 묶음 · 도각 칸.
|
||||
* `TextEntity.getBoundingBox` 는 안 씀(줄맞춤 · 그립 · 공간 색인 · 창 고르기 몫).
|
||||
*/
|
||||
import { Point, Segment } from '@flatten-js/core';
|
||||
import { type Entity, EntityName } from '../entities/Entity';
|
||||
import type { TextEntity, TextOptions } from '../entities/TextEntity';
|
||||
|
||||
/** 양옆 여유(× 글 크기) — 옛 입력 칸 폭 여유 1.5 를 반씩 */
|
||||
export const TEXT_FRAME_PAD = 0.75;
|
||||
/** 한 줄 높이(× 글 크기) */
|
||||
export const TEXT_LINE_RATIO = 1.2;
|
||||
|
||||
/** 화면 글 폭(px) — 그리기와 같은 글꼴(굵게 · 기울임 포함)로 잼 */
|
||||
export type MeasureText = (
|
||||
label: string,
|
||||
fontPx: number,
|
||||
font: Pick<TextOptions, 'fontFamily' | 'bold' | 'italic'>
|
||||
) => number;
|
||||
|
||||
export interface TextFrame {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
/** 양옆 여유(px) — 입력 칸 안쪽 여백 */
|
||||
pad: number;
|
||||
fontPx: number;
|
||||
linePx: number;
|
||||
}
|
||||
|
||||
const basePointOf = (entity: TextEntity): Point => entity.getFirstPoint() as Point;
|
||||
|
||||
let groupMemo: { entities: Entity[]; groups: Map<string, Entity[]> } | null = null;
|
||||
const groupOf = (entities: Entity[], groupId: string): Entity[] => {
|
||||
if (groupMemo?.entities !== entities) {
|
||||
const groups = new Map<string, Entity[]>();
|
||||
for (const entity of entities) {
|
||||
if (!entity.groupId) continue;
|
||||
const members = groups.get(entity.groupId);
|
||||
if (members) members.push(entity);
|
||||
else groups.set(entity.groupId, [entity]);
|
||||
}
|
||||
groupMemo = { entities, groups };
|
||||
}
|
||||
return groupMemo.groups.get(groupId) ?? [];
|
||||
};
|
||||
|
||||
/** 여러 줄 문자(MTEXT) = 같은 묶음의 글자들이 같은 x 에 위아래로 놓인 것 · 위 줄부터. 아니면 그 글자 하나 */
|
||||
export function textLinesOf(target: TextEntity, entities: Entity[]): TextEntity[] {
|
||||
if (!target.groupId) return [target];
|
||||
const group = groupOf(entities, target.groupId);
|
||||
const x = basePointOf(target).x;
|
||||
const isMultiline =
|
||||
group.length > 1 &&
|
||||
group.every(
|
||||
(entity) =>
|
||||
entity.getType() === EntityName.Text &&
|
||||
Math.abs(basePointOf(entity as TextEntity).x - x) < 1e-6
|
||||
);
|
||||
if (!isMultiline) return [target];
|
||||
return ([...group] as TextEntity[]).sort((a, b) => basePointOf(b).y - basePointOf(a).y);
|
||||
}
|
||||
|
||||
/** 글 묶음 테두리(px) — rows = 보일 글(입력 칸은 친 글) · scale = 화면 px / 월드 · minFontPx = 입력 칸 최소 글 크기 */
|
||||
export function textFrame(
|
||||
lines: TextEntity[],
|
||||
rows: string[],
|
||||
scale: number,
|
||||
measure: MeasureText,
|
||||
minFontPx = 0
|
||||
): TextFrame {
|
||||
const options = lines[0].getTextOptions();
|
||||
const fontPx = Math.max(options.fontSize * scale, minFontPx);
|
||||
if (options.boxWidth && options.boxHeight) {
|
||||
// 도각 자리표 = 칸이 곧 테두리(기준점 = 칸 한가운데)
|
||||
const width = options.boxWidth * scale;
|
||||
const height = options.boxHeight * scale;
|
||||
return {
|
||||
left: -width / 2,
|
||||
right: width / 2,
|
||||
top: height / 2,
|
||||
bottom: -height / 2,
|
||||
pad: 0,
|
||||
fontPx,
|
||||
linePx: height,
|
||||
};
|
||||
}
|
||||
const linePx =
|
||||
lines.length > 1
|
||||
? Math.max((basePointOf(lines[0]).y - basePointOf(lines[1]).y) * scale, fontPx)
|
||||
: fontPx * TEXT_LINE_RATIO;
|
||||
const widest = Math.max(0, ...rows.map((row) => measure(row, fontPx, options)));
|
||||
const pad = fontPx * TEXT_FRAME_PAD;
|
||||
const start =
|
||||
options.textAlign === 'left' ? 0 : options.textAlign === 'right' ? -widest : -widest / 2;
|
||||
return {
|
||||
left: start - pad,
|
||||
right: start + widest + pad,
|
||||
top: linePx / 2,
|
||||
bottom: linePx / 2 - linePx * Math.max(rows.length, 1),
|
||||
pad,
|
||||
fontPx,
|
||||
linePx,
|
||||
};
|
||||
}
|
||||
|
||||
/** 글 방향 단위 벡터(월드 · 위 +y) */
|
||||
const axesOf = (entity: TextEntity): { ux: number; uy: number } => {
|
||||
const { x, y } = entity.getTextOptions().textDirection;
|
||||
const length = Math.hypot(x, y) || 1;
|
||||
return { ux: x / length, uy: y / length };
|
||||
};
|
||||
|
||||
/** 테두리 네 귀(월드) — 왼위 · 오른위 · 오른아래 · 왼아래 */
|
||||
export function textFrameCorners(lines: TextEntity[], frame: TextFrame, scale: number): Point[] {
|
||||
const base = basePointOf(lines[0]);
|
||||
const { ux, uy } = axesOf(lines[0]);
|
||||
const at = (u: number, v: number) =>
|
||||
new Point(base.x + (ux * u - uy * v) / scale, base.y + (uy * u + ux * v) / scale);
|
||||
return [
|
||||
at(frame.left, frame.top),
|
||||
at(frame.right, frame.top),
|
||||
at(frame.right, frame.bottom),
|
||||
at(frame.left, frame.bottom),
|
||||
];
|
||||
}
|
||||
|
||||
/** 점(월드)과 테두리 거리(월드 · 안 = 0) · 테두리 위 가장 가까운 점까지 선분 */
|
||||
export function textFrameDistance(
|
||||
lines: TextEntity[],
|
||||
frame: TextFrame,
|
||||
scale: number,
|
||||
point: Point
|
||||
): [number, Segment] {
|
||||
const base = basePointOf(lines[0]);
|
||||
const { ux, uy } = axesOf(lines[0]);
|
||||
const dx = (point.x - base.x) * scale;
|
||||
const dy = (point.y - base.y) * scale;
|
||||
const u = Math.min(Math.max(dx * ux + dy * uy, frame.left), frame.right);
|
||||
const v = Math.min(Math.max(-dx * uy + dy * ux, frame.bottom), frame.top);
|
||||
const nearest = new Point(base.x + (ux * u - uy * v) / scale, base.y + (uy * u + ux * v) / scale);
|
||||
return [
|
||||
Math.hypot(point.x - nearest.x, point.y - nearest.y),
|
||||
new Segment(new Point(point.x, point.y), nearest),
|
||||
];
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import { toast } from 'react-toastify';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { TextEntity } from '../entities/TextEntity';
|
||||
import { getAngleWithXAxis } from '../helpers/get-angle-with-x-axis.ts';
|
||||
import { pickRadius } from '../helpers/pick-radius.ts';
|
||||
import { bumpSceneVersion } from '../helpers/scene-version.ts';
|
||||
import { textFrame, textLinesOf } from '../helpers/text-frame.ts';
|
||||
import { isImeKey } from '../inputController/ime-sink.ts';
|
||||
import {
|
||||
getEntities,
|
||||
@@ -27,6 +29,8 @@ import { cloneKeepingLook } from '../tools/move-tool.helpers.ts';
|
||||
* 화면과 저장값이 어긋났다.
|
||||
*/
|
||||
const QUANTITY_TABLE_LAYER_ID = 'b08-cross-table';
|
||||
export { textLinesOf };
|
||||
|
||||
/** 새로 늘어난 줄 간격 — 여러 줄 문자 명령(text-tools)과 같은 배수 */
|
||||
const LINE_GAP_RATIO = 1.35;
|
||||
/** 너무 작게 줌아웃했을 때도 칠 수 있는 최소 글 크기(px) */
|
||||
@@ -36,20 +40,6 @@ let closeOpenEditor: ((apply: boolean) => void) | null = null;
|
||||
|
||||
const basePointOf = (entity: TextEntity): Point => entity.getFirstPoint() as Point;
|
||||
|
||||
/** 여러 줄 문자(MTEXT) = 같은 묶음의 글자들이 같은 x 에 위아래로 놓인 것 · 위 줄부터. 아니면 그 글자 하나 */
|
||||
export function textLinesOf(target: TextEntity, entities: Entity[]): TextEntity[] {
|
||||
if (!target.groupId) return [target];
|
||||
const group = entities.filter((entity) => entity.groupId === target.groupId);
|
||||
const x = basePointOf(target).x;
|
||||
const isMultiline =
|
||||
group.length > 1 &&
|
||||
group.every(
|
||||
(entity) => entity instanceof TextEntity && Math.abs(basePointOf(entity).x - x) < 1e-6
|
||||
);
|
||||
if (!isMultiline) return [target];
|
||||
return (group as TextEntity[]).sort((a, b) => basePointOf(b).y - basePointOf(a).y);
|
||||
}
|
||||
|
||||
/** 칸 글을 도면에 적용한 새 목록 — 바뀐 게 없거나 빈 글이면 null. 고친 글자는 복제본으로 갈아 끼워 되돌리기가 옛 글을 살린다 */
|
||||
export function applyTextEdit(
|
||||
lines: TextEntity[],
|
||||
@@ -123,7 +113,6 @@ export function openTextEditor(lines: TextEntity[], canvas: HTMLCanvasElement):
|
||||
new Point(0, 0),
|
||||
new Point(options.textDirection.x, -options.textDirection.y)
|
||||
);
|
||||
const shiftX = { left: '0', center: '-50%', right: '-100%' }[options.textAlign] ?? '0';
|
||||
Object.assign(editor.style, {
|
||||
position: 'fixed',
|
||||
zIndex: '1000',
|
||||
@@ -137,6 +126,7 @@ export function openTextEditor(lines: TextEntity[], canvas: HTMLCanvasElement):
|
||||
whiteSpace: 'pre',
|
||||
overflow: 'hidden',
|
||||
resize: 'none',
|
||||
boxSizing: 'border-box',
|
||||
transformOrigin: '0 0',
|
||||
});
|
||||
// 칸 아래 캔버스 글자를 감춘다 — 겹쳐 두 벌로 보이지 않게(닫을 때 되살림)
|
||||
@@ -147,23 +137,25 @@ export function openTextEditor(lines: TextEntity[], canvas: HTMLCanvasElement):
|
||||
let frame = 0;
|
||||
const place = () => {
|
||||
const controller = getScreenCanvasDrawController();
|
||||
const scale = controller.getScreenScale();
|
||||
const fontPx = Math.max(options.fontSize * scale, MIN_EDITOR_FONT_PX);
|
||||
const linePx = multiline
|
||||
? Math.max((basePointOf(lines[0]).y - basePointOf(lines[1]).y) * scale, fontPx)
|
||||
: fontPx * 1.2;
|
||||
// 칸 = 스침 테두리와 같은 셈(`text-box.ts`) — 친 글 폭을 매 화면 다시 잼
|
||||
const box = textFrame(
|
||||
lines,
|
||||
editor.value.split('\n'),
|
||||
controller.getScreenScale(),
|
||||
(row, px, font) => controller.measureTextScreen(row, px, font),
|
||||
MIN_EDITOR_FONT_PX
|
||||
);
|
||||
const anchor = controller.worldToTarget(basePointOf(lines[0]));
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
const rows = editor.value.split('\n');
|
||||
const widest = Math.max(...rows.map((row) => controller.measureTextScreen(row, fontPx)));
|
||||
Object.assign(editor.style, {
|
||||
left: `${bounds.left + anchor.x}px`,
|
||||
top: `${bounds.bottom - anchor.y}px`,
|
||||
font: `${options.italic ? 'italic ' : ''}${options.bold ? 'bold ' : ''}${fontPx}px ${options.fontFamily}`,
|
||||
lineHeight: `${linePx}px`,
|
||||
width: `${widest + fontPx * 1.5}px`,
|
||||
height: `${linePx * rows.length}px`,
|
||||
transform: `rotate(${angle}rad) translate(${shiftX}, ${-linePx / 2}px)`,
|
||||
font: `${options.italic ? 'italic ' : ''}${options.bold ? 'bold ' : ''}${box.fontPx}px ${options.fontFamily}`,
|
||||
lineHeight: `${box.linePx}px`,
|
||||
padding: `0 ${box.pad}px`,
|
||||
width: `${box.right - box.left}px`,
|
||||
height: `${box.top - box.bottom}px`,
|
||||
transform: `rotate(${angle}rad) translate(${box.left}px, ${-box.top}px)`,
|
||||
});
|
||||
frame = requestAnimationFrame(place);
|
||||
};
|
||||
@@ -235,10 +227,8 @@ export function handleTextDoubleClick(event: MouseEvent, canvas: HTMLCanvasEleme
|
||||
closest = entity;
|
||||
}
|
||||
}
|
||||
if (!closest) return;
|
||||
const fontSize = closest.getTextOptions().fontSize;
|
||||
const tolerance = Math.max((closest.getLabel().length + 2) * fontSize * 0.5, fontSize * 2);
|
||||
if (closestDistance > tolerance) return;
|
||||
// 잡는 범위 = 글 테두리 + 집기 반경(스침 · 고르기와 같음 · 487)
|
||||
if (!closest || closestDistance > pickRadius()) return;
|
||||
if (closest.layerId === QUANTITY_TABLE_LAYER_ID) {
|
||||
toast.info('수량표는 횡단설계(B06)에서 고칩니다. 고치고 돌아오면 반영됩니다.');
|
||||
return;
|
||||
|
||||
@@ -35,7 +35,11 @@ const CommandButton: FC<CommandButtonProps> = ({ command, size, activeTool, onRu
|
||||
title={`${command.label}${command.aliases?.length ? ` (${command.aliases[0]})` : ''}${
|
||||
command.hint ? ` — ${command.hint}` : ''
|
||||
}`}
|
||||
onClick={() => onRun(command)}
|
||||
onClick={(event) => {
|
||||
// 초점 테두리가 남아 「선택」과 둘이 켜진 듯 보이지 않게(487)
|
||||
event.currentTarget.blur();
|
||||
onRun(command);
|
||||
}}
|
||||
>
|
||||
<span className="cad-tool__glyph">{command.glyph}</span>
|
||||
<span className="cad-tool__label">{command.label}</span>
|
||||
@@ -135,7 +139,8 @@ const Panel: FC<{
|
||||
type="button"
|
||||
key={command.id}
|
||||
title={command.hint}
|
||||
onClick={() => {
|
||||
onClick={(event) => {
|
||||
event.currentTarget.blur();
|
||||
setOverflowAt(null);
|
||||
onRun(command);
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 글 테두리 한 벌(PLAN 487 · `helpers/text-frame.ts`) — 스침 · 고름 테두리 네 귀 = 더블클릭 입력 칸 네 귀
|
||||
* (왼 · 가운데 · 오른 · 90° 돈 글 · 굵게 · 여러 줄 묶음 · 도각 칸) · 잡는 범위 = 테두리 안.
|
||||
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad text-frame
|
||||
*/
|
||||
import { Point, Vector } from "@flatten-js/core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const SCALE = 2;
|
||||
const fonts: string[] = [];
|
||||
vi.mock("../../../B07_DesignDetail/openwebcad/src/theme.ts", () => ({
|
||||
paintColor: (color: string) => color,
|
||||
}));
|
||||
vi.mock("../../../B07_DesignDetail/openwebcad/src/state.ts", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
getScreenCanvasDrawController: () => ({
|
||||
getScreenScale: () => SCALE,
|
||||
worldToTarget: (point: Point) => new Point(point.x * SCALE, point.y * SCALE),
|
||||
targetToWorld: (point: Point) => new Point(point.x / SCALE, point.y / SCALE),
|
||||
// 글자당 0.5 em · 굵게는 1.2 배 — 굵게가 폭 재기에 들어가는지 봄
|
||||
measureTextScreen: (label: string, px: number, font: { bold?: boolean } = {}) => {
|
||||
fonts.push(font.bold ? "bold" : "normal");
|
||||
return label.length * px * 0.5 * (font.bold ? 1.2 : 1);
|
||||
},
|
||||
zoomToFitScreen: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { TextEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/TextEntity.ts";
|
||||
import {
|
||||
closeTextEditor,
|
||||
handleTextDoubleClick,
|
||||
openTextEditor,
|
||||
} from "../../../B07_DesignDetail/openwebcad/src/integration/text-inplace-edit";
|
||||
import {
|
||||
resetUndoBaseline,
|
||||
setEntities,
|
||||
setHighlightedEntityIds,
|
||||
} from "../../../B07_DesignDetail/openwebcad/src/state";
|
||||
|
||||
class FakeElement {
|
||||
public value = "";
|
||||
public className = "";
|
||||
public style: Record<string, string> = {};
|
||||
constructor(public tagName: string) {}
|
||||
setAttribute() {}
|
||||
addEventListener() {}
|
||||
focus() {}
|
||||
select() {}
|
||||
remove() {}
|
||||
}
|
||||
let created: FakeElement[] = [];
|
||||
const BOUNDS = { left: 10, top: 20, bottom: 520, right: 810 };
|
||||
const canvas = { getBoundingClientRect: () => BOUNDS } as unknown as HTMLCanvasElement;
|
||||
|
||||
beforeEach(() => {
|
||||
created = [];
|
||||
fonts.length = 0;
|
||||
vi.stubGlobal("window", { dispatchEvent: () => true });
|
||||
vi.stubGlobal("requestAnimationFrame", () => 1);
|
||||
vi.stubGlobal("cancelAnimationFrame", () => {});
|
||||
vi.stubGlobal("document", {
|
||||
createElement: (tag: string) => {
|
||||
const element = new FakeElement(tag);
|
||||
created.push(element);
|
||||
return element;
|
||||
},
|
||||
body: { appendChild: () => {} },
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
closeTextEditor(false);
|
||||
setHighlightedEntityIds([]);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
type Xy = [number, number];
|
||||
/** 월드 → 화면(px) — 캔버스 왼쪽 · 아래 기준 · y 뒤집음 */
|
||||
const toScreen = (p: Point): Xy => [BOUNDS.left + p.x * SCALE, BOUNDS.bottom - p.y * SCALE];
|
||||
|
||||
/** 스침 테두리 네 귀(화면) — 글을 강조한 채 그려 dash 선 네 개의 시작점 */
|
||||
function outlineCorners(entities: TextEntity[]): Xy[] {
|
||||
setEntities(entities, false);
|
||||
resetUndoBaseline();
|
||||
setHighlightedEntityIds(entities.map((entity) => entity.id));
|
||||
const starts: Point[] = [];
|
||||
const controller = {
|
||||
setLineStyles: () => {},
|
||||
drawText: () => {},
|
||||
drawLine: (start: Point) => starts.push(start),
|
||||
};
|
||||
for (const entity of entities) entity.draw(controller as never);
|
||||
return starts.map(toScreen);
|
||||
}
|
||||
|
||||
/** 입력 칸 네 귀(화면) — left · top + rotate · translate · 폭 · 높이 */
|
||||
function editorCorners(editor: FakeElement): Xy[] {
|
||||
const px = (value: string) => Number.parseFloat(value);
|
||||
const m = /rotate\(([-\d.e]+)rad\) translate\(([-\d.e]+)px, ([-\d.e]+)px\)/.exec(
|
||||
editor.style.transform,
|
||||
);
|
||||
if (!m) throw new Error(editor.style.transform);
|
||||
const [angle, tx, ty] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
||||
const [w, h] = [px(editor.style.width), px(editor.style.height)];
|
||||
const [ox, oy] = [px(editor.style.left), px(editor.style.top)];
|
||||
const at = (x: number, y: number): Xy => [
|
||||
ox + x * Math.cos(angle) - y * Math.sin(angle),
|
||||
oy + x * Math.sin(angle) + y * Math.cos(angle),
|
||||
];
|
||||
return [at(tx, ty), at(tx + w, ty), at(tx + w, ty + h), at(tx, ty + h)];
|
||||
}
|
||||
|
||||
const expectSame = (a: Xy[], b: Xy[]) => {
|
||||
expect(a).toHaveLength(4);
|
||||
a.forEach(([x, y], i) => {
|
||||
expect(Math.abs(x - b[i][0])).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(y - b[i][1])).toBeLessThanOrEqual(1);
|
||||
});
|
||||
};
|
||||
|
||||
const text = (label: string, options: Record<string, unknown> = {}, at = new Point(100, 200)) =>
|
||||
new TextEntity("0", label, at, { fontSize: 5, ...options });
|
||||
|
||||
describe("스침 테두리 = 더블클릭 입력 칸", () => {
|
||||
it.each([
|
||||
["왼", { textAlign: "left" }],
|
||||
["가운데", { textAlign: "center" }],
|
||||
["오른", { textAlign: "right" }],
|
||||
["90° 돈 글", { textDirection: new Vector(0, 1) }],
|
||||
["30° 돈 오른 정렬", { textAlign: "right", textDirection: new Vector(Math.sqrt(3), 1) }],
|
||||
["굵게", { bold: true }],
|
||||
])("%s — 네 귀 차 ≤ 1 px", (_name, options) => {
|
||||
const entity = text("STA. 0+000", options);
|
||||
const outline = outlineCorners([entity]);
|
||||
openTextEditor([entity], canvas);
|
||||
expectSame(outline, editorCorners(created[0]));
|
||||
});
|
||||
|
||||
it("굵게 = 굵은 글꼴로 폭을 잼 · 테두리 폭이 넓어짐", () => {
|
||||
const plain = outlineCorners([text("배수관")]);
|
||||
const bold = outlineCorners([text("배수관", { bold: true })]);
|
||||
expect(fonts).toContain("bold");
|
||||
expect(bold[1][0] - bold[0][0]).toBeGreaterThan(plain[1][0] - plain[0][0]);
|
||||
});
|
||||
|
||||
it("가운데 정렬 = 기준점이 테두리 한가운데 · 위아래 같음", () => {
|
||||
const [lt, rt, , lb] = outlineCorners([text("배수관")]);
|
||||
const [bx, by] = toScreen(new Point(100, 200));
|
||||
expect((lt[0] + rt[0]) / 2).toBeCloseTo(bx);
|
||||
expect((lt[1] + lb[1]) / 2).toBeCloseTo(by);
|
||||
});
|
||||
|
||||
it("여러 줄 = 묶음 테두리 하나(첫 줄이 그림) = 여러 줄 칸", () => {
|
||||
const first = text("첫 줄", {}, new Point(100, 200));
|
||||
const second = text("둘째 줄 길게", {}, new Point(100, 193));
|
||||
first.groupId = second.groupId = "m1";
|
||||
const outline = outlineCorners([second, first]);
|
||||
openTextEditor([first, second], canvas);
|
||||
expect(created[0].tagName).toBe("textarea");
|
||||
expectSame(outline, editorCorners(created[0]));
|
||||
});
|
||||
|
||||
it("도각 칸 글 = 칸이 테두리 · 입력 칸도 칸 크기", () => {
|
||||
const entity = text("{{공사명}}", { boxWidth: 60, boxHeight: 8 });
|
||||
const outline = outlineCorners([entity]);
|
||||
expect(outline[1][0] - outline[0][0]).toBeCloseTo(60 * SCALE);
|
||||
openTextEditor([entity], canvas);
|
||||
expectSame(outline, editorCorners(created[0]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("잡는 범위 = 테두리 안", () => {
|
||||
it("테두리 안 = 0 · 밖 = 테두리까지 거리", () => {
|
||||
const entity = text("STA. 0+000", { textAlign: "left" });
|
||||
// 폭 = 10 자 × 10 px × 0.5 = 50 px = 25 월드 · 여유 7.5 px = 3.75 월드
|
||||
expect(entity.distanceTo(new Point(124, 201))?.[0]).toBe(0);
|
||||
expect(entity.distanceTo(new Point(130, 200))?.[0]).toBeCloseTo(130 - (125 + 3.75));
|
||||
});
|
||||
|
||||
it("끝 글자 더블클릭도 칸을 띄움 · 테두리 + 집기 반경 밖은 안 띄움", () => {
|
||||
setEntities([text("STA. 0+000", { textAlign: "left" })], false);
|
||||
const dblclickAt = (x: number, y: number) =>
|
||||
handleTextDoubleClick(
|
||||
{ clientX: BOUNDS.left + x * SCALE, clientY: BOUNDS.bottom - y * SCALE } as MouseEvent,
|
||||
canvas,
|
||||
);
|
||||
dblclickAt(140, 200); // 테두리 오른끝 128.75 + 반경 5 밖
|
||||
expect(created).toHaveLength(0);
|
||||
dblclickAt(124, 200); // 끝 글자
|
||||
expect(created).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -135,6 +135,11 @@ describe("글자 더블클릭 = 그 자리 입력 칸", () => {
|
||||
expect(editor.style.left).toBe("110px"); // 캔버스 왼쪽 10 + 글자 x 100
|
||||
expect(editor.style.top).toBe("320px"); // 캔버스 아래 520 - 글자 y 200
|
||||
expect(editor.style.font).toContain("10px"); // 글 크기 5 × 배율 2
|
||||
// 칸 = 스침 테두리(487) — 잰 폭 40 + 양옆 여유 0.75 × 10 · 높이 1.2 줄 · 가운데 정렬 = 반 폭 왼쪽
|
||||
expect(editor.style.width).toBe("55px");
|
||||
expect(editor.style.padding).toBe("0 7.5px");
|
||||
expect(editor.style.height).toBe("12px");
|
||||
expect(editor.style.transform).toMatch(/translate\(-27\.5px, -6px\)$/);
|
||||
expect(text.previewLabel).toBe("");
|
||||
});
|
||||
|
||||
|
||||
@@ -228,8 +228,9 @@ def test_CAD_머리줄_없음_탭줄_빈자리_변수():
|
||||
css = (src / "App.css").read_text("utf-8")
|
||||
assert '<span ref={slotRef} className="cad-ribbon-tabs__slot" />' in ribbon
|
||||
assert ribbon.index("cad-ribbon-tabs__slot") < ribbon.index("<QuickAccessBar />")
|
||||
assert "'--cad-tab-slot-left', `${Math.round(s.left)}px`" in ribbon
|
||||
assert "'--cad-tab-slot-right', `${Math.round(window.innerWidth - s.right)}px`" in ribbon
|
||||
flat = " ".join(ribbon.split()) # 줄바꿈 · 들여쓰기 무관
|
||||
assert "'--cad-tab-slot-left', `${Math.round(s.left)}px`" in flat
|
||||
assert "'--cad-tab-slot-right', `${Math.round(window.innerWidth - s.right)}px`" in flat
|
||||
assert "hidden={QUICK_ACCESS_HIDDEN.includes(id)}" in quick and "cad-titlebar" not in quick
|
||||
assert "QUICK_ACCESS_HIDDEN: string[] = ['QSAVE', 'EXPORT']" in (
|
||||
src / "ribbon/ribbon.config.ts"
|
||||
|
||||
Reference in New Issue
Block a user