잠금 필터가 공용 함수 없이 호출부마다 들어가 있어 넣은 곳은 막히고 안 넣은 곳은 샜다. Ctrl+A는 도각 103개·원지반 16개까지 전부 선택했고, 선택 삭제에는 잠금 검사가 없어 Ctrl+A → Delete 한 번에 도각이 사라졌다. 자르기와 트림·모따기 계열, 커서 강조도 도각을 집었다. state.ts에 getPickableEntities()(잠금 도면층 제외)를 두고 집기 경로가 그걸 쓴다. 선택은 setSelectedEntityIds() 한 곳에서 막아, 앞으로 생길 선택 경로도 자동으로 잠금 객체를 담지 못한다. 화면맞춤(zoomToFitScreen)도 같은 목록에 맞춘다 — 도각까지 넣으면 A1 한 장 전체가 잡혀 화면의 40%가 여백으로 갔다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
677 lines
23 KiB
TypeScript
677 lines
23 KiB
TypeScript
import { Point } from '@flatten-js/core';
|
|
import { compact, round } from 'es-toolkit';
|
|
import { Actor } from 'xstate';
|
|
import {
|
|
CANVAS_INPUT_FIELD_BACKGROUND_COLOR,
|
|
CANVAS_INPUT_FIELD_FONT_SIZE,
|
|
CANVAS_INPUT_FIELD_HEIGHT,
|
|
CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR,
|
|
CANVAS_INPUT_FIELD_MOUSE_OFFSET,
|
|
CANVAS_INPUT_FIELD_TEXT_COLOR,
|
|
CANVAS_INPUT_FIELD_WIDTH,
|
|
PINCH_ZOOM_EXPONENT,
|
|
SNAP_POINT_DISTANCE,
|
|
WHEEL_LINE_PX,
|
|
WHEEL_PAGE_PX,
|
|
WHEEL_ZOOM_EXPONENT,
|
|
} from '../App.consts.ts';
|
|
import { MouseButton } from '../App.types.ts';
|
|
import {
|
|
describeCommand,
|
|
matchCommandPrefixes,
|
|
resolveCommandInput,
|
|
} from '../commands/registry.ts';
|
|
import { runCommand } from '../commands/run-command.ts';
|
|
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController.ts';
|
|
import { calculateAngleGuidesAndSnapPoints } from '../helpers/calculate-angle-guides-and-snap-points.ts';
|
|
import { findClosestEntity } from '../helpers/find-closest-entity.ts';
|
|
import { getClosestSnapPointWithinRadius } from '../helpers/get-closest-snap-point.ts';
|
|
import { pickRadius } from '../helpers/pick-radius.ts';
|
|
import {
|
|
getActiveToolActor,
|
|
getAngleStep,
|
|
getCanvas,
|
|
getPickableEntities,
|
|
getGridEnabled,
|
|
getLastStateInstructions,
|
|
getPanStartLocation,
|
|
getScreenCanvasDrawController,
|
|
getSelectedEntities,
|
|
getSnapEnabled,
|
|
getSnapPoint,
|
|
getSnapPointOnAngleGuide,
|
|
redo,
|
|
setActiveToolActor,
|
|
setAngleStep,
|
|
setGhostHelperEntities,
|
|
setGridEnabled,
|
|
setHighlightedEntityIds,
|
|
setPanStartLocation,
|
|
setSelectedEntityIds,
|
|
setShouldDrawCursor,
|
|
setSnapEnabled,
|
|
undo,
|
|
} from '../state.ts';
|
|
import { Tool } from '../tools.ts';
|
|
import { SelectState, selectToolStateMachine } from '../tools/select-tool.ts';
|
|
import {
|
|
type AbsolutePointInputEvent,
|
|
ActorEvent,
|
|
type MouseClickEvent,
|
|
type NumberInputEvent,
|
|
type RelativePointInputEvent,
|
|
type TextInputEvent,
|
|
} from '../tools/tool.types.ts';
|
|
|
|
const NUMBER_REGEXP = /^[0-9]+([.][0-9]+)?$/;
|
|
/** 부호 있는 실수 한 개 (좌표는 음수가 될 수 있다) */
|
|
const SIGNED = '(-?[0-9]+(?:[.][0-9]+)?)';
|
|
const ABSOLUTE_POINT_REGEXP = new RegExp(`^${SIGNED}[ ]*,[ ]*${SIGNED}$`);
|
|
const RELATIVE_POINT_REGEXP = new RegExp(`^@${SIGNED}[ ]*,[ ]*${SIGNED}$`);
|
|
/** 극좌표 — 거리<각도(도). `@`가 붙으면 직전 점 기준 */
|
|
const ABSOLUTE_POLAR_REGEXP = new RegExp(`^${SIGNED}[ ]*<[ ]*${SIGNED}$`);
|
|
const RELATIVE_POLAR_REGEXP = new RegExp(`^@${SIGNED}[ ]*<[ ]*${SIGNED}$`);
|
|
|
|
/** 거리·각도(도)를 x·y 변위로 바꾼다 */
|
|
function polarToPoint(distance: number, degrees: number): Point {
|
|
const radians = (degrees * Math.PI) / 180;
|
|
return new Point(distance * Math.cos(radians), distance * Math.sin(radians));
|
|
}
|
|
|
|
/** 이만큼 넘게 끌면 클릭이 아니라 드래그로 본다 (화면 픽셀) */
|
|
const DRAG_SELECT_MIN_PX = 4;
|
|
|
|
export class InputController {
|
|
private text = '';
|
|
/** 왼쪽 버튼을 누른 화면 좌표 — 놓을 때 끌었는지 판단한다 */
|
|
private leftPressLocation: Point | null = null;
|
|
|
|
constructor() {
|
|
if (typeof process === 'object' && process?.env?.NODE_ENV === 'test') {
|
|
return; // used during unit testing
|
|
}
|
|
// Listen for keystrokes
|
|
document.addEventListener('keydown', (evt) => {
|
|
this.handleKeyStroke(evt);
|
|
});
|
|
// Listen for right mouse button click => perform the same action as ENTER
|
|
const canvas = getCanvas();
|
|
canvas?.addEventListener('mousedown', (evt: MouseEvent) => this.handleMouseDown(evt));
|
|
canvas?.addEventListener('mousemove', (evt: MouseEvent) => this.handleMouseMove(evt));
|
|
canvas?.addEventListener('mouseup', (evt: MouseEvent) => this.handleMouseUp(evt));
|
|
// passive:false — 핀치·휠의 브라우저 기본 확대/스크롤을 막아야 한다.
|
|
canvas?.addEventListener('wheel', (evt: WheelEvent) => this.handleMouseWheel(evt), {
|
|
passive: false,
|
|
});
|
|
canvas?.addEventListener('mouseout', () => this.handleMouseOut());
|
|
canvas?.addEventListener('mouseenter', () => this.handleMouseEnter());
|
|
// Stop the context menu from appearing when right-clicking
|
|
canvas?.addEventListener('contextmenu', (evt) => {
|
|
evt.preventDefault();
|
|
});
|
|
// 캔버스 밖(리본·패널) 위에서의 트랙패드 핀치도 브라우저를 확대시키지 않는다.
|
|
// ctrl이 없는 휠은 그대로 둬서 패널 스크롤은 살린다.
|
|
document.addEventListener(
|
|
'wheel',
|
|
(evt: WheelEvent) => {
|
|
if (evt.ctrlKey) evt.preventDefault();
|
|
},
|
|
{ passive: false }
|
|
);
|
|
}
|
|
|
|
public draw(drawController: ScreenCanvasDrawController) {
|
|
const screenMouseLocation = drawController.getScreenMouseLocation();
|
|
|
|
// draw input field
|
|
drawController.fillRectScreen(
|
|
screenMouseLocation.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET,
|
|
// fillRectScreen이 아래 변 기준으로 바로잡혔으므로, 칸이 있던 자리를 지키려고
|
|
// 높이만큼 내려 잡는다.
|
|
screenMouseLocation.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET - CANVAS_INPUT_FIELD_HEIGHT,
|
|
CANVAS_INPUT_FIELD_WIDTH,
|
|
CANVAS_INPUT_FIELD_HEIGHT,
|
|
CANVAS_INPUT_FIELD_BACKGROUND_COLOR
|
|
);
|
|
// Draw text in input field
|
|
if (this.text) {
|
|
drawController.drawTextScreen(
|
|
this.text,
|
|
new Point(
|
|
screenMouseLocation.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET + 2,
|
|
screenMouseLocation.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET - CANVAS_INPUT_FIELD_HEIGHT - 2
|
|
),
|
|
{
|
|
textAlign: 'left',
|
|
textColor: CANVAS_INPUT_FIELD_TEXT_COLOR,
|
|
fontSize: CANVAS_INPUT_FIELD_FONT_SIZE,
|
|
}
|
|
);
|
|
}
|
|
|
|
const matchingCommands = this.getCommandSuggestions();
|
|
const toolInstruction = getLastStateInstructions();
|
|
const texts: string[] = [];
|
|
if (toolInstruction) {
|
|
// Draw tool instruction
|
|
texts.push(toolInstruction);
|
|
const roundedX = round(drawController.getWorldMouseLocation().x, 2);
|
|
const roundedY = round(drawController.getWorldMouseLocation().y, 2);
|
|
texts.push(`${roundedX},${roundedY}`);
|
|
}
|
|
if (matchingCommands.length) {
|
|
// 입력 중인 문자로 시작하는 명령 후보. 예: C => CIRCLE (C), COPY (CO)
|
|
texts.push(...matchingCommands);
|
|
}
|
|
this.drawListBelowInputField(drawController, texts);
|
|
}
|
|
|
|
public submitText(value: string) {
|
|
// 명령 해석은 대소문자를 가리지 않으므로 원문을 그대로 둔다 (문자 주석의 대소문자 보존)
|
|
this.text = value.trim();
|
|
this.handleEnterKey();
|
|
}
|
|
|
|
public handleMouseUp(evt: MouseEvent) {
|
|
if (evt.button === MouseButton.Right) {
|
|
// Right click => confirm action (ENTER)
|
|
evt.preventDefault();
|
|
evt.stopPropagation();
|
|
this.handleEnterKey();
|
|
}
|
|
|
|
// If ancestor parent exist with class .controls => ignore clicks, since a button was clicked instead of the canvas
|
|
const controlsParent = (evt?.target as HTMLElement)?.closest('.controls');
|
|
if (controlsParent) {
|
|
return;
|
|
}
|
|
|
|
if (evt.button === MouseButton.Middle) {
|
|
setPanStartLocation(null);
|
|
}
|
|
if (evt.button === MouseButton.Left) {
|
|
if (this.isSelectToolActive()) {
|
|
// 선택 도구는 누를 때 첫 점을 이미 보냈다. 끌었으면 놓는 자리로 사각형을 닫는다.
|
|
const pressLocation = this.leftPressLocation;
|
|
this.leftPressLocation = null;
|
|
const releaseLocation = this.getCanvasPoint(evt);
|
|
const dragged =
|
|
!!pressLocation &&
|
|
Math.hypot(releaseLocation.x - pressLocation.x, releaseLocation.y - pressLocation.y) >
|
|
DRAG_SELECT_MIN_PX;
|
|
if (dragged && this.isWaitingForSecondSelectPoint()) {
|
|
this.sendMouseClick(evt);
|
|
}
|
|
return;
|
|
}
|
|
this.sendMouseClick(evt);
|
|
}
|
|
}
|
|
|
|
/** 활성 도구가 선택 도구인가 */
|
|
private isSelectToolActive(): boolean {
|
|
return getActiveToolActor()?.getSnapshot()?.context?.type === Tool.SELECT;
|
|
}
|
|
|
|
/** 선택 도구가 선택 사각형의 두 번째 점을 기다리는 중인가 */
|
|
private isWaitingForSecondSelectPoint(): boolean {
|
|
return (
|
|
getActiveToolActor()?.getSnapshot()?.value === SelectState.WAITING_FOR_SECOND_SELECT_POINT
|
|
);
|
|
}
|
|
|
|
/** 스냅을 반영한 클릭 한 번을 활성 도구에 보낸다 */
|
|
private sendMouseClick(evt: MouseEvent) {
|
|
const screenCanvasDrawController = getScreenCanvasDrawController();
|
|
const closestSnapPoint = getClosestSnapPointWithinRadius(
|
|
compact([getSnapPoint(), getSnapPointOnAngleGuide()]),
|
|
screenCanvasDrawController.getWorldMouseLocation(),
|
|
SNAP_POINT_DISTANCE / screenCanvasDrawController.getScreenScale()
|
|
);
|
|
const worldMouseLocationTemp = screenCanvasDrawController.targetToWorld(
|
|
this.getCanvasPoint(evt)
|
|
);
|
|
const worldMouseLocation = closestSnapPoint ? closestSnapPoint.point : worldMouseLocationTemp;
|
|
|
|
getActiveToolActor()?.send({
|
|
type: ActorEvent.MOUSE_CLICK,
|
|
worldMouseLocation,
|
|
screenMouseLocation: screenCanvasDrawController.worldToTarget(worldMouseLocation),
|
|
holdingCtrl: evt.ctrlKey,
|
|
holdingShift: evt.shiftKey,
|
|
} as MouseClickEvent);
|
|
}
|
|
|
|
public handleMouseEnter() {
|
|
setShouldDrawCursor(true);
|
|
}
|
|
|
|
public handleMouseMove(evt: MouseEvent) {
|
|
setShouldDrawCursor(true);
|
|
const screenCanvasDrawController = getScreenCanvasDrawController();
|
|
const newScreenMouseLocation = this.getCanvasPoint(evt);
|
|
screenCanvasDrawController.setScreenMouseLocation(newScreenMouseLocation);
|
|
|
|
// If the middle mouse button is pressed, pan the screen
|
|
const panStartLocation = getPanStartLocation();
|
|
if (panStartLocation) {
|
|
screenCanvasDrawController.panScreen(
|
|
newScreenMouseLocation.x - panStartLocation.x,
|
|
newScreenMouseLocation.y - panStartLocation.y
|
|
);
|
|
setPanStartLocation(newScreenMouseLocation);
|
|
}
|
|
|
|
// Calculate angle guides and snap points
|
|
if (getSnapEnabled()) {
|
|
calculateAngleGuidesAndSnapPoints();
|
|
}
|
|
|
|
// Highlight the entity closest to the mouse when the select tool is active
|
|
if (getActiveToolActor()?.getSnapshot()?.context.type === Tool.SELECT) {
|
|
const closestEntityInfo = findClosestEntity(
|
|
screenCanvasDrawController.targetToWorld(newScreenMouseLocation),
|
|
getPickableEntities()
|
|
);
|
|
if (closestEntityInfo.distance < pickRadius()) {
|
|
setHighlightedEntityIds([closestEntityInfo.entity.id]);
|
|
} else {
|
|
setHighlightedEntityIds([]);
|
|
}
|
|
}
|
|
}
|
|
|
|
public handleMouseOut() {
|
|
setShouldDrawCursor(false);
|
|
}
|
|
|
|
/**
|
|
* Change the zoom level of screen space
|
|
* @param evt
|
|
*/
|
|
public handleMouseWheel(evt: WheelEvent) {
|
|
// 확대는 도면만 — 트랙패드 핀치(ctrl+휠)가 브라우저 전체를 키우던 것을 막는다.
|
|
evt.preventDefault();
|
|
const drawController = getScreenCanvasDrawController();
|
|
// deltaY 단위를 픽셀로 맞춘다 — 브라우저에 따라 줄(1)·쪽(2) 단위로 오기도 한다.
|
|
const unitPx = evt.deltaMode === 1 ? WHEEL_LINE_PX : evt.deltaMode === 2 ? WHEEL_PAGE_PX : 1;
|
|
const deltaX = evt.deltaX * unitPx;
|
|
const deltaY = evt.deltaY * unitPx;
|
|
|
|
// 핀치(ctrl+휠)는 기기와 무관하게 확대.
|
|
if (evt.ctrlKey) {
|
|
if (deltaY !== 0) drawController.zoomScreen(-deltaY, PINCH_ZOOM_EXPONENT);
|
|
return;
|
|
}
|
|
|
|
// 트랙패드 두 손가락 이동은 팬 — 문서를 스크롤하듯 화면이 손가락 반대로 간다.
|
|
if (!isMouseWheel(evt)) {
|
|
drawController.panScreen(-deltaX, deltaY);
|
|
return;
|
|
}
|
|
|
|
if (deltaY === 0) {
|
|
return; // We can't zoom by zero delta
|
|
}
|
|
// Aislo: wheel direction is inverted on purpose - pulling the wheel zooms in, pushing
|
|
// zooms out, matching the B04/B05 maps and the B06 cross sections (2026-08-02).
|
|
drawController.zoomScreen(-deltaY, WHEEL_ZOOM_EXPONENT);
|
|
}
|
|
|
|
public handleMouseDown(evt: MouseEvent) {
|
|
if (evt.button === MouseButton.Middle) {
|
|
setPanStartLocation(this.getCanvasPoint(evt));
|
|
return;
|
|
}
|
|
if (evt.button !== MouseButton.Left) return;
|
|
if ((evt.target as HTMLElement | null)?.closest('.controls')) return;
|
|
// AutoCAD처럼 누른 자리에서 사각형이 시작되도록 선택 도구에만 첫 점을 미리 보낸다.
|
|
// 다른 도구는 예전처럼 놓을 때 한 점을 받는다 (끌다가 점이 두 개 찍히지 않게).
|
|
if (!this.isSelectToolActive()) return;
|
|
this.leftPressLocation = this.getCanvasPoint(evt);
|
|
this.sendMouseClick(evt);
|
|
}
|
|
|
|
private getCanvasPoint(evt: MouseEvent): Point {
|
|
const bounds = getCanvas()?.getBoundingClientRect();
|
|
return new Point(
|
|
evt.clientX - (bounds?.left ?? 0),
|
|
(bounds?.bottom ?? getScreenCanvasDrawController().getCanvasSize().y) - evt.clientY
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Returns a distance to pan the screen when a directional arrow is pressed
|
|
* Offset is based on shift key being pressed (larger offset)
|
|
* and zoom level
|
|
* @private
|
|
*/
|
|
private getScreenPanStep(
|
|
direction: 'up' | 'right' | 'down' | 'left',
|
|
shiftPressed: boolean
|
|
): Point {
|
|
const screenOffset = getScreenCanvasDrawController().getScreenOffset();
|
|
const screenZoom = getScreenCanvasDrawController().getScreenScale();
|
|
let step = 20;
|
|
if (shiftPressed) {
|
|
step = 100;
|
|
}
|
|
step *= screenZoom;
|
|
switch (direction) {
|
|
case 'up':
|
|
return new Point(screenOffset.x, screenOffset.y - step);
|
|
case 'right':
|
|
return new Point(screenOffset.x - step, screenOffset.y);
|
|
case 'down':
|
|
return new Point(screenOffset.x, screenOffset.y + step);
|
|
case 'left':
|
|
return new Point(screenOffset.x + step, screenOffset.y);
|
|
}
|
|
}
|
|
|
|
public handleKeyStroke(evt: KeyboardEvent) {
|
|
if ((evt.target as HTMLElement | null)?.closest('input, textarea, select')) {
|
|
return;
|
|
}
|
|
console.log(`key pressed: ${evt.key}`);
|
|
if (evt.key === 'F12') {
|
|
// F12 => open developer tools
|
|
return;
|
|
}
|
|
if (evt.key === 'F5') {
|
|
// F5 => reload the page
|
|
return;
|
|
}
|
|
if (evt.key === 'F11') {
|
|
// F11 => toggle fullscreen
|
|
// ponytail: AutoCAD는 F11이 객체 스냅 추적이지만 브라우저 전체화면이 우선이다.
|
|
// 스냅 추적은 상태막대 버튼으로 켜고 끈다.
|
|
return;
|
|
}
|
|
// 제도 보조 토글 (AutoCAD 상태막대 기능키)
|
|
if (evt.key === 'F3') {
|
|
evt.preventDefault();
|
|
setSnapEnabled(!getSnapEnabled());
|
|
return;
|
|
}
|
|
if (evt.key === 'F7') {
|
|
evt.preventDefault();
|
|
setGridEnabled(!getGridEnabled());
|
|
return;
|
|
}
|
|
if (evt.key === 'F8') {
|
|
evt.preventDefault();
|
|
setAngleStep(getAngleStep() === 90 ? 0 : 90);
|
|
return;
|
|
}
|
|
if (evt.key === 'F10') {
|
|
evt.preventDefault();
|
|
setAngleStep(getAngleStep() === 45 ? 0 : 45);
|
|
return;
|
|
}
|
|
if (evt.key === 'Tab') {
|
|
// Tab => move keyboard focus
|
|
return;
|
|
}
|
|
evt.preventDefault();
|
|
evt.stopPropagation();
|
|
if (evt.ctrlKey && evt.key === 'v') {
|
|
// User wants to paste the clipboard
|
|
} else if (evt.ctrlKey && !evt.shiftKey && evt.key === 'z') {
|
|
// User wants to undo the last action
|
|
this.handleUndo(evt);
|
|
} else if (evt.ctrlKey && evt.shiftKey && evt.key === 'z') {
|
|
// User wants to redo the last action
|
|
this.handleRedo(evt);
|
|
} else if (evt.ctrlKey && evt.key === 'y') {
|
|
// User wants to redo the last action
|
|
this.handleRedo(evt);
|
|
} else if (evt.ctrlKey && evt.key === 'a') {
|
|
// User wants to select everything
|
|
setSelectedEntityIds(getPickableEntities().map((entity) => entity.id));
|
|
} else if (evt.key === 'Backspace') {
|
|
// Remove the last character from the input field
|
|
evt.preventDefault();
|
|
this.text = this.text.slice(0, this.text.length - 1);
|
|
} else if (evt.key === 'Delete') {
|
|
// User wants to delete the current selection
|
|
evt.preventDefault();
|
|
getActiveToolActor()?.send({
|
|
type: ActorEvent.DELETE,
|
|
});
|
|
} else if (evt.key === 'Escape') {
|
|
// User wants to cancel the current action
|
|
this.handleEscapeKey();
|
|
} else if (evt.key === 'Enter') {
|
|
// User wants to submit the input or submit the action
|
|
this.handleEnterKey();
|
|
} else if (evt.key === 'ArrowDown') {
|
|
// Move the screen down
|
|
getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('down', evt.shiftKey));
|
|
} else if (evt.key === 'ArrowUp') {
|
|
// Move the screen up
|
|
getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('up', evt.shiftKey));
|
|
} else if (evt.key === 'ArrowLeft') {
|
|
// Move the screen left
|
|
getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('left', evt.shiftKey));
|
|
} else if (evt.key === 'ArrowRight') {
|
|
// Move the screen right
|
|
getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('right', evt.shiftKey));
|
|
} else if (evt.key?.length === 1) {
|
|
// +·-는 확대·축소 단축키로 쓰지 않는다 — 음수 좌표(-100,-50)의 첫 글자를
|
|
// 먹어 버렸다. 줌은 휠·뷰 막대·ZOOMIN/ZOOMOUT 명령으로 한다.
|
|
// User entered a single character => add to input field text
|
|
this.text += evt.key.toUpperCase();
|
|
}
|
|
}
|
|
|
|
public handleEscapeKey() {
|
|
if (getSelectedEntities().length > 0) {
|
|
// Deselect entities
|
|
setSelectedEntityIds([]);
|
|
} else if (this.text === '') {
|
|
// Cancel tool action
|
|
getActiveToolActor()?.send({
|
|
type: ActorEvent.ESC,
|
|
});
|
|
} else {
|
|
// clear the input field
|
|
this.text = '';
|
|
}
|
|
}
|
|
|
|
/** 커서 옆에 띄울 명령 후보 (이름·별칭 접두사 일치) */
|
|
private getCommandSuggestions(): string[] {
|
|
if (this.text === '') {
|
|
return [];
|
|
}
|
|
return matchCommandPrefixes(this.text).slice(0, 6).map(describeCommand);
|
|
}
|
|
|
|
public handleEnterKey() {
|
|
// submit the text as input to the active tool and clear the input field
|
|
const activeTool = getActiveToolActor();
|
|
const activeToolSnapshot = activeTool?.getSnapshot();
|
|
const activeToolState = activeToolSnapshot?.value;
|
|
const activeToolCanHandleTextInput =
|
|
!!activeToolSnapshot?.machine?.states?.[activeToolState]?.config?.on?.TEXT_INPUT;
|
|
|
|
if (this.text === '') {
|
|
console.log('ENTER: ', {
|
|
text: this.text,
|
|
activeTool: getActiveToolActor(),
|
|
});
|
|
const wasSelectTool = this.isSelectToolActive();
|
|
// Send the ENTER event to the active tool
|
|
getActiveToolActor()?.send({
|
|
type: ActorEvent.ENTER,
|
|
});
|
|
// 선택 도구는 ENTER(우클릭)로 최종 상태에 들어가 **멈춘다**. 다른 도구가 선택을
|
|
// 자식으로 불러 쓸 때는 그 끝남이 필요하지만(move·copy·rotate의 onDone),
|
|
// 선택이 그 자체로 활성 도구일 때 멈추면 커서 옆 안내가 사라지고 이후 클릭도
|
|
// 안 먹는다(2026-08-30 사용자 지적). 그 경우에만 새로 세워 준다.
|
|
if (wasSelectTool && getActiveToolActor()?.getSnapshot()?.status === 'done') {
|
|
setActiveToolActor(new Actor(selectToolStateMachine));
|
|
}
|
|
} else if (activeToolCanHandleTextInput) {
|
|
console.log('TEXT_INPUT: ', {
|
|
text: this.text,
|
|
activeTool: getActiveToolActor(),
|
|
});
|
|
// Send the text to the active tool
|
|
getActiveToolActor()?.send({
|
|
type: ActorEvent.TEXT_INPUT,
|
|
value: this.text,
|
|
} as TextInputEvent);
|
|
this.text = '';
|
|
} else if (resolveCommandInput(this.text)) {
|
|
// 명령 이름 또는 AutoCAD 별칭을 입력했다. 예: L, LINE, REC
|
|
const command = resolveCommandInput(this.text);
|
|
if (command) {
|
|
runCommand(command);
|
|
}
|
|
this.text = '';
|
|
} else if (NUMBER_REGEXP.test(this.text)) {
|
|
console.log(' NUMBER_INPUT: ', {
|
|
text: this.text,
|
|
activeTool: getActiveToolActor(),
|
|
});
|
|
// User entered a number. eg: 100
|
|
getActiveToolActor()?.send({
|
|
type: ActorEvent.NUMBER_INPUT,
|
|
value: Number.parseFloat(this.text),
|
|
worldMouseLocation:
|
|
getSnapPointOnAngleGuide()?.point ||
|
|
getSnapPoint()?.point ||
|
|
getScreenCanvasDrawController().getWorldMouseLocation(),
|
|
} as NumberInputEvent);
|
|
this.text = '';
|
|
} else if (ABSOLUTE_POINT_REGEXP.test(this.text)) {
|
|
console.log('ABSOLUTE_POINT_INPUT: ', {
|
|
text: this.text,
|
|
activeTool: getActiveToolActor(),
|
|
});
|
|
// User entered coordinates to an absolute point on the canvas. eg: 100, 200
|
|
const match = ABSOLUTE_POINT_REGEXP.exec(this.text);
|
|
if (!match) {
|
|
return;
|
|
}
|
|
const x = Number.parseFloat(match[1]);
|
|
const y = Number.parseFloat(match[2]);
|
|
getActiveToolActor()?.send({
|
|
type: ActorEvent.ABSOLUTE_POINT_INPUT,
|
|
value: new Point(x, y),
|
|
} as AbsolutePointInputEvent);
|
|
this.text = '';
|
|
} else if (RELATIVE_POINT_REGEXP.test(this.text)) {
|
|
console.log('RELATIVE_POINT_INPUT: ', {
|
|
text: this.text,
|
|
activeTool: getActiveToolActor(),
|
|
});
|
|
// User entered coordinates to a relative point on the canvas. eg: @100, 200
|
|
const match = RELATIVE_POINT_REGEXP.exec(this.text);
|
|
if (!match) {
|
|
return;
|
|
}
|
|
const x = Number.parseFloat(match[1]);
|
|
const y = Number.parseFloat(match[2]);
|
|
getActiveToolActor()?.send({
|
|
type: ActorEvent.RELATIVE_POINT_INPUT,
|
|
value: new Point(x, y),
|
|
} as RelativePointInputEvent);
|
|
this.text = '';
|
|
} else if (RELATIVE_POLAR_REGEXP.test(this.text)) {
|
|
// 직전 점에서 거리·각도로 이동. 예: @100<45
|
|
const match = RELATIVE_POLAR_REGEXP.exec(this.text);
|
|
if (!match) {
|
|
return;
|
|
}
|
|
getActiveToolActor()?.send({
|
|
type: ActorEvent.RELATIVE_POINT_INPUT,
|
|
value: polarToPoint(Number.parseFloat(match[1]), Number.parseFloat(match[2])),
|
|
} as RelativePointInputEvent);
|
|
this.text = '';
|
|
} else if (ABSOLUTE_POLAR_REGEXP.test(this.text)) {
|
|
// 원점에서 거리·각도로 지정한 점. 예: 100<45
|
|
const match = ABSOLUTE_POLAR_REGEXP.exec(this.text);
|
|
if (!match) {
|
|
return;
|
|
}
|
|
getActiveToolActor()?.send({
|
|
type: ActorEvent.ABSOLUTE_POINT_INPUT,
|
|
value: polarToPoint(Number.parseFloat(match[1]), Number.parseFloat(match[2])),
|
|
} as AbsolutePointInputEvent);
|
|
this.text = '';
|
|
} else {
|
|
console.log('TEXT_INPUT: ', {
|
|
text: this.text,
|
|
activeTool: getActiveToolActor(),
|
|
});
|
|
// Send the text to the active tool
|
|
getActiveToolActor()?.send({
|
|
type: ActorEvent.TEXT_INPUT,
|
|
value: this.text,
|
|
} as TextInputEvent);
|
|
this.text = '';
|
|
}
|
|
}
|
|
|
|
public handleUndo(evt: KeyboardEvent) {
|
|
evt.preventDefault();
|
|
undo();
|
|
setGhostHelperEntities([]);
|
|
setSelectedEntityIds([]);
|
|
getActiveToolActor()?.send({
|
|
type: ActorEvent.ESC,
|
|
});
|
|
}
|
|
|
|
public handleRedo(evt: KeyboardEvent) {
|
|
evt.preventDefault();
|
|
redo();
|
|
setGhostHelperEntities([]);
|
|
setSelectedEntityIds([]);
|
|
getActiveToolActor()?.send({
|
|
type: ActorEvent.ESC,
|
|
});
|
|
}
|
|
|
|
private drawListBelowInputField(
|
|
drawController: ScreenCanvasDrawController,
|
|
texts: string[]
|
|
): void {
|
|
const screenMouseLocation = drawController.worldToTarget(
|
|
drawController.getWorldMouseLocation()
|
|
);
|
|
const startY =
|
|
screenMouseLocation.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET - CANVAS_INPUT_FIELD_HEIGHT * 2 - 2;
|
|
const offsetY = CANVAS_INPUT_FIELD_HEIGHT;
|
|
texts.forEach((text, index) => {
|
|
drawController.drawTextScreen(
|
|
text,
|
|
new Point(
|
|
screenMouseLocation.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET + 2,
|
|
startY - index * offsetY
|
|
),
|
|
{
|
|
textAlign: 'left',
|
|
textColor: CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR,
|
|
fontSize: CANVAS_INPUT_FIELD_FONT_SIZE,
|
|
}
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 마우스 휠인지 트랙패드인지 가른다. 마우스 휠은 한 칸이 정해진 크기(Chrome 100px,
|
|
* 일부 브라우저 120px)로 딱 떨어지고 가로 델타가 없다. 트랙패드는 손가락이 움직인
|
|
* 만큼 잔 델타를 보내고 가로 델타도 함께 온다. 줄·쪽 단위(deltaMode≠0)는 휠뿐이다.
|
|
*/
|
|
function isMouseWheel(evt: WheelEvent): boolean {
|
|
if (evt.deltaMode !== 0) return true;
|
|
if (evt.deltaX !== 0) return false;
|
|
const step = Math.abs(evt.deltaY);
|
|
return step !== 0 && (step % 100 === 0 || step % 120 === 0);
|
|
}
|