Files
Aislo/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts
T

547 lines
17 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_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: <explanation>
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,
}
);
});
}
}