feat(b07): CAD 글자 더블클릭 = 그 자리 입력 칸 — prompt 걷음 · Enter 적용 · Esc 취소 · 여러 줄 Ctrl+Enter (293-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { type DesignMeta, HtmlEvent } from '../App.types.ts';
|
||||
import { ImageEntity } from '../entities/ImageEntity.ts';
|
||||
import { TextEntity } from '../entities/TextEntity.ts';
|
||||
@@ -11,11 +10,8 @@ import {
|
||||
getDesignMeta,
|
||||
getEntities,
|
||||
getFrameFields,
|
||||
getLayers,
|
||||
getScreenCanvasDrawController,
|
||||
isDrawingDirty,
|
||||
isDrawingReadOnly,
|
||||
isViewOnly,
|
||||
resetUndoBaseline,
|
||||
setActiveLayerId,
|
||||
setDesignMeta,
|
||||
@@ -30,10 +26,10 @@ import {
|
||||
setBare,
|
||||
} from '../state.ts';
|
||||
import { setInspectorCollapsed, setRibbonCollapsed } from '../components/ui-state.ts';
|
||||
import { toast } from 'react-toastify';
|
||||
import { runCommandInput } from '../commands/run-command.ts';
|
||||
import { setRecoveryScope } from '../helpers/autosave.ts';
|
||||
import { registerBoxResizeDrag } from './box-resize-drag.ts';
|
||||
import { closeTextEditor, handleTextDoubleClick } from './text-inplace-edit.ts';
|
||||
import { handleTableMessage } from './aislo-table-bridge.ts';
|
||||
import { registerGuideDrag, setGuide, setGuideArea, setGuideVisible } from './guide-area.ts';
|
||||
|
||||
@@ -114,63 +110,11 @@ export function requestDrawingExport(fileFormat: 'dxf' | 'dwg') {
|
||||
notifyParent(AISLO_DRAWING_EXPORT_MESSAGE, { fileFormat });
|
||||
}
|
||||
|
||||
/**
|
||||
* 수량 산출표 도면층 — 여기 글자는 앞 단계(B05·B06) 산출값이라 B07에서 고치지 않는다
|
||||
* (2026-09-01 사용자 확정). 고치면 그림 글자만 바뀌고 저장되는 수량표는 그대로여서
|
||||
* 화면과 저장값이 어긋났다.
|
||||
*/
|
||||
const QUANTITY_TABLE_LAYER_ID = 'b08-cross-table';
|
||||
|
||||
/**
|
||||
* 더블클릭한 위치의 텍스트(잠금 해제 레이어)를 찾아 내용을 즉시 편집한다.
|
||||
* CAD 측점 테이블 값 셀 수정 수단 — 잠금 레이어와 수량 산출표는 제외.
|
||||
*/
|
||||
/** 글자 더블클릭 = 그 자리 입력 칸(`text-inplace-edit.ts` · 293-1). */
|
||||
function registerTextDoubleClickEdit() {
|
||||
const canvas = getCanvas();
|
||||
if (!canvas) return;
|
||||
canvas.addEventListener('dblclick', (event: MouseEvent) => {
|
||||
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;
|
||||
let closestOnQuantityTable = false;
|
||||
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;
|
||||
closestOnQuantityTable = entity.layerId === QUANTITY_TABLE_LAYER_ID;
|
||||
}
|
||||
}
|
||||
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;
|
||||
if (closestOnQuantityTable) {
|
||||
toast.info('수량표는 횡단설계(B06)에서 고칩니다. 고치고 돌아오면 반영됩니다.');
|
||||
return;
|
||||
}
|
||||
const nextLabel = window.prompt('값 수정', closest.getLabel());
|
||||
if (nextLabel === null || nextLabel === closest.getLabel()) return;
|
||||
closest.setLabel(nextLabel);
|
||||
setEntities([...getEntities()], true); // undo 스택 + DRAWING_CHANGED 통지
|
||||
});
|
||||
canvas.addEventListener('dblclick', (event: MouseEvent) => handleTextDoubleClick(event, canvas));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,6 +168,7 @@ export function registerAisloDrawingBridge() {
|
||||
}
|
||||
if (!isDrawingLoadMessage(event.data)) return;
|
||||
|
||||
closeTextEditor(false); // 옛 도면 글자 칸은 새 도면에 적용하지 않음
|
||||
try {
|
||||
const drawing = await getEntitiesAndLayersFromJsonObject(event.data.drawing);
|
||||
setEntities(drawing.entities, false);
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* 글자 더블클릭 = 그 자리 입력 칸(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 { bumpSceneVersion } from '../helpers/scene-version.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';
|
||||
/** 새로 늘어난 줄 간격 — 여러 줄 문자 명령(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;
|
||||
|
||||
/** 여러 줄 문자(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[],
|
||||
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)
|
||||
);
|
||||
const shiftX = { left: '0', center: '-50%', right: '-100%' }[options.textAlign] ?? '0';
|
||||
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',
|
||||
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();
|
||||
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;
|
||||
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)`,
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
if (closest.layerId === QUANTITY_TABLE_LAYER_ID) {
|
||||
toast.info('수량표는 횡단설계(B06)에서 고칩니다. 고치고 돌아오면 반영됩니다.');
|
||||
return;
|
||||
}
|
||||
openTextEditor(textLinesOf(closest, getEntities()), canvas);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* 글자 더블클릭 = 그 자리 입력 칸(PLAN 293-1) — prompt 대신 글자 위 입력 칸 · Enter 적용 · Esc 취소 ·
|
||||
* 칸 밖 누름 적용 · 되돌리기 한 번 · 여러 줄(MTEXT)은 Ctrl+Enter 적용 · 키가 CAD 로 새지 않음.
|
||||
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad text-inplace-edit
|
||||
*/
|
||||
import { Point } from "@flatten-js/core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
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: () => 2,
|
||||
worldToTarget: (point: Point) => point,
|
||||
// 시험 쪽 Point 로 — 앱과 시험이 flatten 을 따로 실어 instanceof 가 갈림
|
||||
targetToWorld: (point: Point) => new Point(point.x, point.y),
|
||||
measureTextScreen: () => 40,
|
||||
zoomToFitScreen: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { TextEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/TextEntity.ts";
|
||||
import {
|
||||
closeTextEditor,
|
||||
editorKeyAction,
|
||||
handleTextDoubleClick,
|
||||
} from "../../../B07_DesignDetail/openwebcad/src/integration/text-inplace-edit";
|
||||
import {
|
||||
getEntities,
|
||||
resetUndoBaseline,
|
||||
setEntities,
|
||||
undo,
|
||||
} from "../../../B07_DesignDetail/openwebcad/src/state";
|
||||
|
||||
type Listener = (evt: Record<string, unknown>) => void;
|
||||
class FakeElement {
|
||||
public value = "";
|
||||
public className = "";
|
||||
public style: Record<string, string> = {};
|
||||
public selected = false;
|
||||
public removed = false;
|
||||
public listeners: Record<string, Listener[]> = {};
|
||||
constructor(public tagName: string) {}
|
||||
setAttribute() {}
|
||||
addEventListener(type: string, listener: Listener) {
|
||||
(this.listeners[type] ??= []).push(listener);
|
||||
}
|
||||
fire(type: string, evt: Record<string, unknown> = {}) {
|
||||
for (const listener of this.listeners[type] ?? []) listener(evt);
|
||||
}
|
||||
focus() {}
|
||||
select() {
|
||||
this.selected = true;
|
||||
}
|
||||
remove() {
|
||||
this.removed = true;
|
||||
this.fire("blur");
|
||||
}
|
||||
}
|
||||
|
||||
let created: FakeElement[] = [];
|
||||
let documentListeners: Record<string, Listener[]> = {};
|
||||
const prompt = vi.fn();
|
||||
const canvas = {
|
||||
getBoundingClientRect: () => ({ left: 10, top: 20, bottom: 520, right: 810 }),
|
||||
} as unknown as HTMLCanvasElement;
|
||||
|
||||
beforeEach(() => {
|
||||
created = [];
|
||||
documentListeners = {};
|
||||
prompt.mockReset();
|
||||
vi.stubGlobal("window", { prompt, 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: (type: string, listener: Listener) =>
|
||||
(documentListeners[type] ??= []).push(listener),
|
||||
removeEventListener: (type: string, listener: Listener) =>
|
||||
(documentListeners[type] = (documentListeners[type] ?? []).filter((l) => l !== listener)),
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
closeTextEditor(false);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/** 화면 좌표 = 월드 좌표(가짜 제어기) · 캔버스 아래 520 기준으로 y 를 뒤집음 */
|
||||
const dblclickAt = (x: number, y: number) =>
|
||||
handleTextDoubleClick({ clientX: 10 + x, clientY: 520 - y } as MouseEvent, canvas);
|
||||
const key = (editor: FakeElement, k: string, extra: Record<string, unknown> = {}) => {
|
||||
const evt = {
|
||||
key: k,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
isComposing: false,
|
||||
keyCode: 0,
|
||||
stopped: false,
|
||||
prevented: false,
|
||||
stopPropagation() {
|
||||
evt.stopped = true;
|
||||
},
|
||||
preventDefault() {
|
||||
evt.prevented = true;
|
||||
},
|
||||
...extra,
|
||||
};
|
||||
editor.fire("keydown", evt);
|
||||
return evt;
|
||||
};
|
||||
const labels = () => getEntities().map((entity) => (entity as TextEntity).getLabel());
|
||||
const load = (...entities: TextEntity[]) => {
|
||||
setEntities(entities, false);
|
||||
resetUndoBaseline();
|
||||
};
|
||||
|
||||
describe("글자 더블클릭 = 그 자리 입력 칸", () => {
|
||||
it("더블클릭 → 글자 자리에 입력 칸 · 글 전체 골라짐 · prompt 안 부름 · 캔버스 글자 감춤", () => {
|
||||
const text = new TextEntity("0", "배수관", new Point(100, 200), { fontSize: 5 });
|
||||
load(text);
|
||||
dblclickAt(101, 200);
|
||||
expect(prompt).not.toHaveBeenCalled();
|
||||
expect(created).toHaveLength(1);
|
||||
const [editor] = created;
|
||||
expect(editor.tagName).toBe("input");
|
||||
expect(editor.value).toBe("배수관");
|
||||
expect(editor.selected).toBe(true);
|
||||
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
|
||||
expect(text.previewLabel).toBe("");
|
||||
});
|
||||
|
||||
it("Enter = 적용 · 되돌리기 한 번에 옛 글 · 키가 CAD 로 새지 않음", () => {
|
||||
load(new TextEntity("0", "배수관", new Point(100, 200), { fontSize: 5 }));
|
||||
dblclickAt(100, 200);
|
||||
const [editor] = created;
|
||||
const typed = key(editor, "a");
|
||||
expect(typed.stopped).toBe(true);
|
||||
expect(typed.prevented).toBe(false);
|
||||
editor.value = "횡단배수관";
|
||||
const enter = key(editor, "Enter");
|
||||
expect(enter.stopped).toBe(true);
|
||||
expect(editor.removed).toBe(true);
|
||||
expect(labels()).toEqual(["횡단배수관"]);
|
||||
expect((getEntities()[0] as TextEntity).previewLabel).toBeNull();
|
||||
undo();
|
||||
expect(labels()).toEqual(["배수관"]);
|
||||
});
|
||||
|
||||
it("Esc = 취소 · 글 그대로 · 감춘 글자 되살림", () => {
|
||||
const text = new TextEntity("0", "배수관", new Point(100, 200), { fontSize: 5 });
|
||||
load(text);
|
||||
dblclickAt(100, 200);
|
||||
const [editor] = created;
|
||||
editor.value = "바꿈";
|
||||
key(editor, "Escape");
|
||||
expect(editor.removed).toBe(true);
|
||||
expect(labels()).toEqual(["배수관"]);
|
||||
expect(getEntities()[0]).toBe(text);
|
||||
expect(text.previewLabel).toBeNull();
|
||||
});
|
||||
|
||||
it("칸 밖 누름 = 적용 · 가운데 단추(팬)는 닫지 않음", () => {
|
||||
load(new TextEntity("0", "배수관", new Point(100, 200), { fontSize: 5 }));
|
||||
dblclickAt(100, 200);
|
||||
const [editor] = created;
|
||||
editor.value = "옹벽";
|
||||
for (const listener of documentListeners.pointerdown) listener({ target: {}, button: 1 });
|
||||
expect(editor.removed).toBe(false);
|
||||
for (const listener of documentListeners.pointerdown) listener({ target: {}, button: 0 });
|
||||
expect(editor.removed).toBe(true);
|
||||
expect(labels()).toEqual(["옹벽"]);
|
||||
});
|
||||
|
||||
it("한글 조합 중 Enter 는 적용하지 않음", () => {
|
||||
expect(editorKeyAction({ key: "Enter", isComposing: true } as KeyboardEvent, false)).toBeNull();
|
||||
expect(editorKeyAction({ key: "Enter", keyCode: 229 } as KeyboardEvent, false)).toBeNull();
|
||||
});
|
||||
|
||||
it("여러 줄(MTEXT) = 여러 줄 칸 · Enter 줄바꿈 · Ctrl+Enter 적용 · 줄 늘면 아래로 · 되돌리기 한 번", () => {
|
||||
const first = new TextEntity("0", "첫 줄", new Point(100, 200), { fontSize: 5 });
|
||||
const second = new TextEntity("0", "둘째 줄", new Point(100, 193), { fontSize: 5 });
|
||||
first.groupId = second.groupId = "m1";
|
||||
load(second, first);
|
||||
dblclickAt(100, 193);
|
||||
const [editor] = created;
|
||||
expect(editor.tagName).toBe("textarea");
|
||||
expect(editor.value).toBe("첫 줄\n둘째 줄");
|
||||
expect(key(editor, "Enter").prevented).toBe(false);
|
||||
expect(editor.removed).toBe(false);
|
||||
editor.value = "첫 줄\n둘째\n셋째";
|
||||
key(editor, "Enter", { ctrlKey: true });
|
||||
expect(editor.removed).toBe(true);
|
||||
const lines = getEntities() as TextEntity[];
|
||||
expect(lines.map((line) => line.getLabel())).toEqual(["둘째", "셋째", "첫 줄"]);
|
||||
expect(lines.every((line) => line.groupId === "m1")).toBe(true);
|
||||
expect(lines[1].getFirstPoint()?.y).toBeCloseTo(186);
|
||||
undo();
|
||||
expect(labels()).toEqual(["둘째 줄", "첫 줄"]);
|
||||
});
|
||||
|
||||
it("수량표 글자는 칸을 띄우지 않음", () => {
|
||||
load(new TextEntity("b08-cross-table", "12.5", new Point(100, 200), { fontSize: 5 }));
|
||||
dblclickAt(100, 200);
|
||||
expect(created).toHaveLength(0);
|
||||
expect(prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user