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_HEIGHT, CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR, CANVAS_INPUT_FIELD_MOUSE_OFFSET, CANVAS_INPUT_FIELD_TEXT_COLOR, CANVAS_INPUT_FIELD_WIDTH, HIGHLIGHT_ENTITY_DISTANCE, SNAP_POINT_DISTANCE, } from '../App.consts.ts'; import { MouseButton } from '../App.types.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 { getActiveToolActor, getCanvas, getEntities, getLastStateInstructions, getPanStartLocation, getSnapEnabled, getScreenCanvasDrawController, getSelectedEntities, getSnapPoint, getSnapPointOnAngleGuide, redo, setActiveToolActor, setGhostHelperEntities, setHighlightedEntityIds, setPanStartLocation, setSelectedEntityIds, setShouldDrawCursor, undo, } from '../state.ts'; import { Tool } from '../tools.ts'; import { TOOL_STATE_MACHINES } from '../tools/tool.consts.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 ABSOLUTE_POINT_REGEXP = /^([0-9]+([.][0-9]+)?)\s*,\s*([0-9]+([.][0-9]+)?)$/; const RELATIVE_POINT_REGEXP = /^@([0-9]+([.][0-9]+)?)\s*,\s*([0-9]+([.][0-9]+)?)$/; export class InputController { private text = ''; 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, screenMouseLocation.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET, 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: 18, } ); } const matchingToolNames = this.getToolNamesFromPrefixText(); 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 (matchingToolNames.length) { // Draw list of matching tools. eg: C => CIRCLE, COPY, ... texts.push(...matchingToolNames); } this.drawListBelowInputField(drawController, texts); } public submitText(value: string) { this.text = value.trim().toUpperCase(); 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) { const screenCanvasDrawController = getScreenCanvasDrawController(); const closestSnapPoint = getClosestSnapPointWithinRadius( compact([getSnapPoint(), getSnapPointOnAngleGuide()]), screenCanvasDrawController.getWorldMouseLocation(), SNAP_POINT_DISTANCE / screenCanvasDrawController.getScreenScale() ); const worldMouseLocationTemp = getScreenCanvasDrawController().targetToWorld( this.getCanvasPoint(evt) ); const worldMouseLocation = closestSnapPoint ? closestSnapPoint.point : worldMouseLocationTemp; const activeToolActor = getActiveToolActor(); activeToolActor?.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), getEntities() ); if (closestEntityInfo.distance < HIGHLIGHT_ENTITY_DISTANCE) { 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(); if (Math.abs(evt.deltaY) === 0) { return; // We can't zoom by zero delta } const drawController = getScreenCanvasDrawController(); // 휠 한 칸(deltaY 100, 줄 단위면 3)을 세기 1로 본다. 트랙패드는 잔 델타를 // 초당 수십 번 보내므로 세기를 델타에 비례시켜야 한 번에 튀지 않는다. const notch = evt.deltaMode === 0 ? 100 : 3; const strength = Math.min(1, Math.max(0.12, Math.abs(evt.deltaY) / notch)); // 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(-evt.deltaY, strength); } public handleMouseDown(evt: MouseEvent) { if (evt.button !== MouseButton.Middle) return; setPanStartLocation(this.getCanvasPoint(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 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(getEntities().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 === '+') { // Zoom in // TODO keep the center of the screen centered during zoom getScreenCanvasDrawController().setScreenScale( getScreenCanvasDrawController().getScreenScale() * 1.1 ); } else if (evt.key === '-') { // Zoom in // TODO keep the center of the screen centered during zoom getScreenCanvasDrawController().setScreenScale( getScreenCanvasDrawController().getScreenScale() * 0.9 ); } else if (evt.key?.length === 1) { // 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 getToolNamesFromPrefixText(): Tool[] { if (this.text === '') { return []; } return (Object.keys(TOOL_STATE_MACHINES).filter((cmd) => cmd.startsWith(this.text.toUpperCase()) ) || null) as Tool[]; } 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(), }); // Send the ENTER event to the active tool getActiveToolActor()?.send({ type: ActorEvent.ENTER, }); } 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 (this.getToolNamesFromPrefixText()[0]) { // User entered a command. eg: L or LINE const toolName = this.getToolNamesFromPrefixText()[0]; getActiveToolActor()?.stop(); const newToolActor = new Actor(TOOL_STATE_MACHINES[toolName]); setActiveToolActor(newToolActor); console.log('SWITCH TO TOOL: ', { toolName, // biome-ignore lint/suspicious/noExplicitAny: activeTool: (getActiveToolActor()?.src as any).config.context.type, }); 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[3]); 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[3]); getActiveToolActor()?.send({ type: ActorEvent.RELATIVE_POINT_INPUT, value: new Point(x, y), } as RelativePointInputEvent); 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: 18, } ); }); } }