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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user