/** * 글자 더블클릭 = 그 자리 입력 칸(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, 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); }