- 새 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>
238 lines
9.0 KiB
TypeScript
238 lines
9.0 KiB
TypeScript
/**
|
|
* 글자 더블클릭 = 그 자리 입력 칸(293-1) — 브라우저 창(prompt) 대신 글자 위에 겹친 칸에서 바로 고친다.
|
|
* 칸은 글자 크기 · 기울기 · 확대 배율을 매 화면 따라간다(휠 줌 · 가운데 단추 팬 중에도).
|
|
* Enter = 적용(여러 줄은 Ctrl+Enter · Enter 는 줄바꿈) · Esc = 취소 · 칸 밖 누름 = 적용 · 되돌리기 한 번.
|
|
*/
|
|
import { Point } from '@flatten-js/core';
|
|
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,
|
|
getLayers,
|
|
getScreenCanvasDrawController,
|
|
isDrawingReadOnly,
|
|
isViewOnly,
|
|
setEntities,
|
|
} from '../state.ts';
|
|
import { paintColor } from '../theme.ts';
|
|
import { cloneKeepingLook } from '../tools/move-tool.helpers.ts';
|
|
|
|
/**
|
|
* 수량 산출표 도면층 — 여기 글자는 앞 단계(B05·B06) 산출값이라 B07에서 고치지 않는다
|
|
* (2026-09-01 사용자 확정). 고치면 그림 글자만 바뀌고 저장되는 수량표는 그대로여서
|
|
* 화면과 저장값이 어긋났다.
|
|
*/
|
|
const QUANTITY_TABLE_LAYER_ID = 'b08-cross-table';
|
|
export { textLinesOf };
|
|
|
|
/** 새로 늘어난 줄 간격 — 여러 줄 문자 명령(text-tools)과 같은 배수 */
|
|
const LINE_GAP_RATIO = 1.35;
|
|
/** 너무 작게 줌아웃했을 때도 칠 수 있는 최소 글 크기(px) */
|
|
const MIN_EDITOR_FONT_PX = 10;
|
|
|
|
let closeOpenEditor: ((apply: boolean) => void) | null = null;
|
|
|
|
const basePointOf = (entity: TextEntity): Point => entity.getFirstPoint() as Point;
|
|
|
|
/** 칸 글을 도면에 적용한 새 목록 — 바뀐 게 없거나 빈 글이면 null. 고친 글자는 복제본으로 갈아 끼워 되돌리기가 옛 글을 살린다 */
|
|
export function applyTextEdit(
|
|
lines: TextEntity[],
|
|
value: string,
|
|
entities: Entity[]
|
|
): Entity[] | null {
|
|
const next =
|
|
lines.length > 1
|
|
? value
|
|
.split('\n')
|
|
.map((line) => line.trimEnd())
|
|
.filter((line) => line.trim().length > 0)
|
|
: [value];
|
|
if (!next.join('').trim()) return null;
|
|
if (next.join('\n') === lines.map((line) => line.getLabel()).join('\n')) return null;
|
|
const last = lines[lines.length - 1];
|
|
const gap =
|
|
lines.length > 1
|
|
? basePointOf(lines[0]).y - basePointOf(lines[1]).y
|
|
: last.getTextOptions().fontSize * LINE_GAP_RATIO;
|
|
const replaced = next.map((label, index) => {
|
|
const kept = index < lines.length;
|
|
const copy = cloneKeepingLook(kept ? lines[index] : last, kept) as TextEntity;
|
|
copy.setLabel(label);
|
|
if (!kept) {
|
|
copy.groupId = last.groupId;
|
|
copy.move(0, -gap * (index - lines.length + 1));
|
|
}
|
|
return copy;
|
|
});
|
|
const lineIndex = new Map(lines.map((line, index) => [line.id, index]));
|
|
const result: Entity[] = [];
|
|
for (const entity of entities) {
|
|
const index = lineIndex.get(entity.id);
|
|
if (index === undefined) result.push(entity);
|
|
else if (index < replaced.length) {
|
|
result.push(replaced[index]);
|
|
if (entity === last) result.push(...replaced.slice(lines.length));
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** 칸 안 키 — 적용 · 취소 · 그 밖(칸이 받음). 한글 조합 중 Enter 는 조합을 끝낼 뿐 */
|
|
export function editorKeyAction(
|
|
evt: Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'metaKey' | 'isComposing' | 'keyCode'>,
|
|
multiline: boolean
|
|
): 'apply' | 'cancel' | null {
|
|
if (isImeKey(evt as KeyboardEvent)) return null;
|
|
if (evt.key === 'Escape') return 'cancel';
|
|
if (evt.key === 'Enter' && (!multiline || evt.ctrlKey || evt.metaKey)) return 'apply';
|
|
return null;
|
|
}
|
|
|
|
/** 열린 칸이 있으면 닫는다(적용) — 도면을 새로 실을 때 등 */
|
|
export function closeTextEditor(apply = true): void {
|
|
closeOpenEditor?.(apply);
|
|
}
|
|
|
|
/** 글자 자리에 입력 칸을 띄운다 — 글 전체 골라진 채 바로 입력 */
|
|
export function openTextEditor(lines: TextEntity[], canvas: HTMLCanvasElement): void {
|
|
closeTextEditor();
|
|
const multiline = lines.length > 1;
|
|
const editor = document.createElement(multiline ? 'textarea' : 'input');
|
|
editor.className = 'cad-text-inplace';
|
|
editor.value = lines.map((line) => line.getLabel()).join('\n');
|
|
editor.setAttribute('spellcheck', 'false');
|
|
editor.setAttribute('autocomplete', 'off');
|
|
const options = lines[0].getTextOptions();
|
|
const angle = getAngleWithXAxis(
|
|
new Point(0, 0),
|
|
new Point(options.textDirection.x, -options.textDirection.y)
|
|
);
|
|
Object.assign(editor.style, {
|
|
position: 'fixed',
|
|
zIndex: '1000',
|
|
margin: '0',
|
|
padding: '0',
|
|
border: 'none',
|
|
outline: '1px dashed #4a90d9',
|
|
background: 'transparent',
|
|
color: paintColor(options.textColor),
|
|
textAlign: options.textAlign,
|
|
whiteSpace: 'pre',
|
|
overflow: 'hidden',
|
|
resize: 'none',
|
|
boxSizing: 'border-box',
|
|
transformOrigin: '0 0',
|
|
});
|
|
// 칸 아래 캔버스 글자를 감춘다 — 겹쳐 두 벌로 보이지 않게(닫을 때 되살림)
|
|
const previewLabels = lines.map((line) => line.previewLabel);
|
|
for (const line of lines) line.previewLabel = '';
|
|
bumpSceneVersion();
|
|
|
|
let frame = 0;
|
|
const place = () => {
|
|
const controller = getScreenCanvasDrawController();
|
|
// 칸 = 스침 테두리와 같은 셈(`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();
|
|
Object.assign(editor.style, {
|
|
left: `${bounds.left + anchor.x}px`,
|
|
top: `${bounds.bottom - anchor.y}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);
|
|
};
|
|
|
|
let closed = false;
|
|
const close = (apply: boolean) => {
|
|
if (closed) return;
|
|
closed = true;
|
|
closeOpenEditor = null;
|
|
cancelAnimationFrame(frame);
|
|
document.removeEventListener('pointerdown', onOutsidePointer, true);
|
|
lines.forEach((line, index) => (line.previewLabel = previewLabels[index]));
|
|
const value = editor.value;
|
|
editor.remove();
|
|
const next = apply ? applyTextEdit(lines, value, getEntities()) : null;
|
|
if (next)
|
|
setEntities(next, true); // 되돌리기 한 번 + DRAWING_CHANGED 알림
|
|
else bumpSceneVersion();
|
|
};
|
|
// 칸 밖 누름 = 적용 · 가운데 단추(팬)는 칸이 따라가므로 닫지 않음
|
|
const onOutsidePointer = (evt: PointerEvent) => {
|
|
if (evt.target !== editor && evt.button !== 1) close(true);
|
|
};
|
|
editor.addEventListener('keydown', (evt) => {
|
|
evt.stopPropagation(); // CAD 단축키 · 명령줄로 새지 않게
|
|
const action = editorKeyAction(evt as KeyboardEvent, multiline);
|
|
if (!action) return;
|
|
evt.preventDefault();
|
|
close(action === 'apply');
|
|
});
|
|
editor.addEventListener('blur', () => close(true));
|
|
document.addEventListener('pointerdown', onOutsidePointer, true);
|
|
document.body.appendChild(editor);
|
|
closeOpenEditor = close;
|
|
place();
|
|
editor.focus({ preventScroll: true });
|
|
editor.select();
|
|
}
|
|
|
|
/**
|
|
* 더블클릭한 위치의 글자(잠금 해제 도면층)를 찾아 그 자리 입력 칸을 띄운다.
|
|
* 잠금 도면층 · 수량 산출표는 제외.
|
|
*/
|
|
export function handleTextDoubleClick(event: MouseEvent, canvas: HTMLCanvasElement): void {
|
|
if (isViewOnly()) {
|
|
getScreenCanvasDrawController().zoomToFitScreen();
|
|
return;
|
|
}
|
|
if (isDrawingReadOnly()) {
|
|
toast.info('확정한 도면입니다. 고치려면 [수정]을 먼저 누르세요.');
|
|
return;
|
|
}
|
|
const drawController = getScreenCanvasDrawController();
|
|
const bounds = canvas.getBoundingClientRect();
|
|
const screenPoint = new Point(event.clientX - bounds.left, bounds.bottom - event.clientY);
|
|
const worldPoint = drawController.targetToWorld(screenPoint);
|
|
const lockedLayerIds = new Set(
|
|
getLayers()
|
|
.filter((layer) => layer.isLocked)
|
|
.map((layer) => layer.id)
|
|
);
|
|
let closest: TextEntity | null = null;
|
|
let closestDistance = Number.MAX_SAFE_INTEGER;
|
|
for (const entity of getEntities()) {
|
|
if (!(entity instanceof TextEntity) || lockedLayerIds.has(entity.layerId)) continue;
|
|
const distanceInfo = entity.distanceTo(worldPoint);
|
|
if (distanceInfo && distanceInfo[0] < closestDistance) {
|
|
closestDistance = distanceInfo[0];
|
|
closest = entity;
|
|
}
|
|
}
|
|
// 잡는 범위 = 글 테두리 + 집기 반경(스침 · 고르기와 같음 · 487)
|
|
if (!closest || closestDistance > pickRadius()) return;
|
|
if (closest.layerId === QUANTITY_TABLE_LAYER_ID) {
|
|
toast.info('수량표는 횡단설계(B06)에서 고칩니다. 고치고 돌아오면 반영됩니다.');
|
|
return;
|
|
}
|
|
openTextEditor(textLinesOf(closest, getEntities()), canvas);
|
|
}
|