596 lines
22 KiB
TypeScript
596 lines
22 KiB
TypeScript
import { Point } from '@flatten-js/core';
|
|
import { compact, round } from 'es-toolkit';
|
|
import { MouseButton } from '../App.types.ts';
|
|
import { describeCommand, matchCommandPrefixes } from '../commands/registry.ts';
|
|
import { logLine, runCommandInput } from '../commands/run-command.ts';
|
|
import { requestHostSave } from '../integration/aislo-drawing-bridge.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 { pickRadius } from '../helpers/pick-radius.ts';
|
|
import {
|
|
getActiveToolActor,
|
|
getAngleGuideOriginPoint,
|
|
getAngleStep,
|
|
getCanvas,
|
|
getHighlightedEntityIds,
|
|
getPickableEntities,
|
|
getGridEnabled,
|
|
getPanStartLocation,
|
|
getScreenCanvasDrawController,
|
|
getSnapEnabled,
|
|
isHostSave,
|
|
isViewOnly,
|
|
setAngleStep,
|
|
setGridEnabled,
|
|
setHighlightedEntityIds,
|
|
setPanStartLocation,
|
|
setSelectedEntityIds,
|
|
setShouldDrawCursor,
|
|
setSnapEnabled,
|
|
} from '../state.ts';
|
|
import { Tool } from '../tools.ts';
|
|
import { SelectState } from '../tools/select-tool.ts';
|
|
import { ActorEvent, type MouseClickEvent } from '../tools/tool.types.ts';
|
|
import { activeToolWantsFreeText, activeToolWantsIme, ImeSink, isImeKey, isPlainSpace } from './ime-sink.ts';
|
|
import { drawInputField, dynamicInputLine } from './input-field-draw.ts';
|
|
import { cadCtrlCommand, ctrlShortcut } from './input-shortcut.ts';
|
|
import { wheelZoom } from './input-wheel.ts';
|
|
import { isObjectSnap, noteShift, snapAt } from './ortho.ts';
|
|
import { isWaitingForSecondSelectPoint, selectingActor } from './selecting.ts';
|
|
import { openSnapMenu } from './snap-menu.ts';
|
|
import { isMiddleDouble, zoomExtents } from './zoom-prompt.ts';
|
|
import { attachTouch } from './input-touch.ts';
|
|
import { NUMBER_REGEXP, type PointTextEvent } from './input-patterns.ts';
|
|
import { commandCandidate, pushInput, recallInput } from './input-history.ts';
|
|
import {
|
|
angleLockHint,
|
|
getAngleLock,
|
|
getDynamicField,
|
|
getLockedLength,
|
|
releaseOnePoint,
|
|
tabDynamicInput,
|
|
} from './input-angle-lock.ts';
|
|
import {
|
|
getSnapOverride,
|
|
setSnapOverride,
|
|
snapOverrideHint,
|
|
snapOverridePoint,
|
|
} from './input-snap-override.ts';
|
|
import {
|
|
cancelActiveCommand,
|
|
closeSubPrompt,
|
|
currentNotice,
|
|
currentPrompt,
|
|
dynamicInputApplies,
|
|
isIdle,
|
|
notice,
|
|
redoCommand,
|
|
routeTyped,
|
|
sendPoint,
|
|
undoCommand,
|
|
} from './prompt-input.ts';
|
|
|
|
/** 이만큼 넘게 끌면 클릭이 아니라 드래그로 본다 (화면 픽셀) */
|
|
const DRAG_SELECT_MIN_PX = 4;
|
|
|
|
export class InputController {
|
|
private text = '';
|
|
/** ↑↓ · Tab 돌림 — 기록인가 후보인가 · 후보의 처음 친 글 · 몇째 */
|
|
private browsing: 'history' | 'candidates' | null = null;
|
|
private candidateBase = '';
|
|
private candidateIndex = 0;
|
|
/** 왼쪽 버튼을 누른 화면 좌표 — 놓을 때 끌었는지 판단한다 */
|
|
private leftPressLocation: Point | null = null;
|
|
public readonly ime = new ImeSink((data) => {
|
|
this.text += data;
|
|
});
|
|
|
|
constructor() {
|
|
if (typeof process === 'object' && process?.env?.NODE_ENV === 'test') {
|
|
return; // used during unit testing
|
|
}
|
|
this.ime.attach();
|
|
// 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();
|
|
});
|
|
// 가운데 단추 — 브라우저 자동 스크롤(페이지가 같이 움직임) · 붙여넣기를 막는다.
|
|
// 막는 자리는 mousedown(아래 handleMouseDown) — pointerdown 을 막으면 mousedown 이 안 와 팬이 끊긴다.
|
|
canvas?.addEventListener('auxclick', (evt: MouseEvent) => {
|
|
if (evt.button === MouseButton.Middle) evt.preventDefault();
|
|
});
|
|
// 캔버스 밖(리본·패널) 위에서의 트랙패드 핀치도 브라우저를 확대시키지 않는다.
|
|
// ctrl이 없는 휠은 그대로 둬서 패널 스크롤은 살린다.
|
|
document.addEventListener(
|
|
'wheel',
|
|
(evt: WheelEvent) => {
|
|
if (evt.ctrlKey) evt.preventDefault();
|
|
},
|
|
{ passive: false }
|
|
);
|
|
attachTouch(this); // 손가락(기기대응 4-1) — 마우스 경로는 그대로
|
|
}
|
|
|
|
public draw(drawController: ScreenCanvasDrawController) {
|
|
this.ime.sync(activeToolWantsIme());
|
|
const toolInstruction = currentPrompt().line;
|
|
const texts: string[] = [];
|
|
if (toolInstruction) {
|
|
// Draw tool instruction
|
|
texts.push(toolInstruction);
|
|
}
|
|
texts.push(...compact([currentNotice()])); // 거절 · 안내 — 둘째 줄
|
|
if (toolInstruction) {
|
|
const roundedX = round(drawController.getWorldMouseLocation().x, 2);
|
|
const roundedY = round(drawController.getWorldMouseLocation().y, 2);
|
|
texts.push(`${roundedX},${roundedY}`);
|
|
}
|
|
const base = getAngleGuideOriginPoint();
|
|
if (base && dynamicInputApplies()) {
|
|
texts.push(
|
|
dynamicInputLine(
|
|
base,
|
|
drawController.getWorldMouseLocation(),
|
|
getDynamicField(),
|
|
this.text,
|
|
getLockedLength(),
|
|
getAngleLock()
|
|
)
|
|
);
|
|
}
|
|
texts.push(...compact([angleLockHint(), snapOverrideHint()]));
|
|
// 입력 중인 문자로 시작하는 명령 후보. 예: C => CIRCLE (C), COPY (CO)
|
|
texts.push(...this.getCommandSuggestions());
|
|
// 입력 글(조합 중인 한글도 함께)
|
|
drawInputField(drawController, this.text + this.ime.composing, texts);
|
|
}
|
|
|
|
public submitText(value: string) {
|
|
// 명령 해석은 대소문자를 가리지 않으므로 원문을 그대로 둔다 (문자 주석의 대소문자 보존)
|
|
this.text = value.trim();
|
|
this.handleEnterKey();
|
|
}
|
|
|
|
public handleMouseUp(evt: MouseEvent) {
|
|
if (evt.button === MouseButton.Right) {
|
|
// 우클릭 = Enter(명령 중 확정 · 쉴 때 반복) · Shift+우클릭 = 점 물음에서 한 번 스냅 메뉴
|
|
evt.preventDefault();
|
|
evt.stopPropagation();
|
|
if (evt.shiftKey && currentPrompt().kind === 'point') {
|
|
openSnapMenu(evt.clientX, evt.clientY, (name) => this.submitText(name));
|
|
} else {
|
|
this.handleEnterKey(true);
|
|
}
|
|
}
|
|
|
|
// 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 && isViewOnly()) {
|
|
setPanStartLocation(null);
|
|
return;
|
|
}
|
|
if (evt.button === MouseButton.Left) {
|
|
const pressLocation = this.leftPressLocation;
|
|
if (pressLocation) {
|
|
// 고르기는 누를 때 첫 점을 이미 보냈다 — 끌었으면 놓는 자리로 상자를 닫거나 집은 그립을 놓음 ·
|
|
// 안 끌고 떼면 그대로(상자 클릭-클릭 · 뜨거운 그립은 다음 누름에 놓음 — H3)
|
|
this.leftPressLocation = null;
|
|
const releaseLocation = this.getCanvasPoint(evt);
|
|
const dragged =
|
|
Math.hypot(releaseLocation.x - pressLocation.x, releaseLocation.y - pressLocation.y) >
|
|
DRAG_SELECT_MIN_PX;
|
|
if (dragged && (this.isGripEditActive() || isWaitingForSecondSelectPoint())) {
|
|
this.sendMouseClick(evt);
|
|
}
|
|
return;
|
|
}
|
|
this.sendMouseClick(evt);
|
|
this.ime.sync(activeToolWantsIme()); // 클릭으로 문자 단계에 들면 바로 한글 받개로
|
|
}
|
|
}
|
|
|
|
/** 활성 도구가 선택 도구인가 */
|
|
private isSelectToolActive(): boolean {
|
|
return getActiveToolActor()?.getSnapshot()?.context?.type === Tool.SELECT;
|
|
}
|
|
|
|
/** 집은 그립을 옮기는 중인가 */
|
|
private isGripEditActive(): boolean {
|
|
return getActiveToolActor()?.getSnapshot()?.context?.type === Tool.GRIP_EDIT;
|
|
}
|
|
|
|
/** 스냅을 반영한 클릭 한 번을 활성 도구에 보낸다 — snapped = 객체 스냅에 잡힘(직교가 안 덮음) */
|
|
private sendMouseClick(evt: MouseEvent) {
|
|
const screenCanvasDrawController = getScreenCanvasDrawController();
|
|
const closestSnapPoint = snapAt(screenCanvasDrawController.getWorldMouseLocation());
|
|
const worldMouseLocationTemp = screenCanvasDrawController.targetToWorld(
|
|
this.getCanvasPoint(evt)
|
|
);
|
|
let worldMouseLocation = closestSnapPoint ? closestSnapPoint.point : worldMouseLocationTemp;
|
|
let snapped = isObjectSnap(closestSnapPoint);
|
|
if (getSnapOverride()) {
|
|
const overridden = snapOverridePoint(worldMouseLocationTemp, getAngleGuideOriginPoint());
|
|
if (!overridden) return; // 맞는 점 없음 — 다시 누르기를 기다림
|
|
worldMouseLocation = overridden;
|
|
snapped = true;
|
|
}
|
|
|
|
this.sendPointEvent({
|
|
type: ActorEvent.MOUSE_CLICK,
|
|
worldMouseLocation,
|
|
screenMouseLocation: screenCanvasDrawController.worldToTarget(worldMouseLocation),
|
|
holdingCtrl: evt.ctrlKey,
|
|
holdingShift: evt.shiftKey,
|
|
snapped,
|
|
} as MouseClickEvent);
|
|
}
|
|
|
|
public handleMouseEnter() {
|
|
setShouldDrawCursor(true);
|
|
}
|
|
|
|
public handleMouseMove(evt: MouseEvent) {
|
|
setShouldDrawCursor(true);
|
|
noteShift(evt);
|
|
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);
|
|
}
|
|
|
|
// 미리보기는 보기만 — 스냅 · 가까운 개체 강조를 하지 않는다.
|
|
if (isViewOnly()) return;
|
|
|
|
// 가이드 · 스냅 — 가이드(직교 · 극)는 늘 · 객체 스냅점은 F3 일 때만(안에서 가름 · H8)
|
|
calculateAngleGuidesAndSnapPoints();
|
|
|
|
// 스침 강조 — 쉴 때 · 명령 안 고르기 단계 모두(H5)
|
|
if (selectingActor()) {
|
|
const closestEntityInfo = findClosestEntity(
|
|
screenCanvasDrawController.targetToWorld(newScreenMouseLocation),
|
|
getPickableEntities()
|
|
);
|
|
if (closestEntityInfo.distance < pickRadius()) {
|
|
setHighlightedEntityIds([closestEntityInfo.entity.id]);
|
|
} else {
|
|
setHighlightedEntityIds([]);
|
|
}
|
|
} else if (getHighlightedEntityIds().length) {
|
|
setHighlightedEntityIds([]);
|
|
}
|
|
}
|
|
|
|
public handleMouseOut() {
|
|
setShouldDrawCursor(false);
|
|
}
|
|
|
|
/**
|
|
* Change the zoom level of screen space
|
|
* @param evt
|
|
*/
|
|
public handleMouseWheel(evt: WheelEvent) {
|
|
// 확대는 도면만 — 트랙패드 핀치(ctrl+휠)가 브라우저 전체를 키우던 것을 막는다.
|
|
evt.preventDefault();
|
|
const zoom = wheelZoom(evt);
|
|
if (zoom) getScreenCanvasDrawController().zoomScreen(zoom.delta, zoom.exponent);
|
|
}
|
|
|
|
public handleMouseDown(evt: MouseEvent) {
|
|
noteShift(evt);
|
|
if (evt.button === MouseButton.Middle) {
|
|
evt.preventDefault(); // 자동 스크롤로 부모 페이지가 같이 굴러가던 것
|
|
// 휠 두 번 = 범위 줌(오토캐드)
|
|
if (isMiddleDouble(this.getCanvasPoint(evt))) {
|
|
setPanStartLocation(null);
|
|
zoomExtents();
|
|
return;
|
|
}
|
|
setPanStartLocation(this.getCanvasPoint(evt));
|
|
return;
|
|
}
|
|
if (evt.button !== MouseButton.Left) return;
|
|
if ((evt.target as HTMLElement | null)?.closest('.controls')) return;
|
|
if (isViewOnly()) {
|
|
// 미리보기 — 왼쪽 끌기는 선택 상자 대신 화면 옮기기.
|
|
evt.preventDefault();
|
|
setPanStartLocation(this.getCanvasPoint(evt));
|
|
return;
|
|
}
|
|
// AutoCAD처럼 누른 자리에서 사각형이 시작되도록 고르기(쉼 · 명령 안 고르기 단계)에만 첫 점을 미리 보낸다.
|
|
// 다른 물음은 예전처럼 놓을 때 한 점을 받는다 (끌다가 점이 두 개 찍히지 않게).
|
|
if (!selectingActor()) 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) {
|
|
const target = evt.target as HTMLElement | null;
|
|
if (target !== this.ime.element && target?.closest('input, textarea, select')) {
|
|
return;
|
|
}
|
|
const shortcut = ctrlShortcut(evt);
|
|
const cadCommand = cadCtrlCommand(evt);
|
|
// 한글 조합 중 — 단축키가 먹지 않게 IME 에 넘김(Ctrl 단축키는 조합 중에도 먹음)
|
|
if (isImeKey(evt) && !shortcut && !cadCommand) return;
|
|
if (!['Shift', 'Control', 'Alt', 'Meta'].includes(evt.key)) notice(null); // 알림 줄은 다음 키까지
|
|
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') {
|
|
// 쉼 + 친 글 = 명령 후보 돌림 · 점 단계(기준점 있음) = 동적 입력 칸 넘김 · 그 밖 = 브라우저 초점 이동
|
|
if (isIdle() && this.text) {
|
|
evt.preventDefault();
|
|
this.cycleCandidate(1);
|
|
return;
|
|
}
|
|
if (!dynamicInputApplies()) return;
|
|
evt.preventDefault();
|
|
const isNumber = NUMBER_REGEXP.test(this.text);
|
|
if (this.text && !isNumber) return; // 숫자 아닌 글(@x,y 등)은 그대로 둠
|
|
tabDynamicInput(isNumber ? Number.parseFloat(this.text) : null);
|
|
this.text = '';
|
|
return;
|
|
}
|
|
if (cadCommand) {
|
|
// Ctrl+C · X · Shift+C · Shift+V · 1 — 오토캐드 클립보드 · 특성
|
|
evt.preventDefault();
|
|
runCommandInput(cadCommand);
|
|
return;
|
|
}
|
|
if (evt.ctrlKey && evt.code === 'KeyV') {
|
|
// 막지 않음 — 브라우저가 `paste` 를 쏘고 `integration/dxf-clip-paste.ts` 가 받음(한글 자판도 code 로)
|
|
return;
|
|
}
|
|
evt.preventDefault();
|
|
evt.stopPropagation();
|
|
if (shortcut === 'undo') {
|
|
// User wants to undo the last action
|
|
this.handleUndo(evt);
|
|
} else if (shortcut === 'redo') {
|
|
// User wants to redo the last action
|
|
this.handleRedo(evt);
|
|
} else if (shortcut === 'save') {
|
|
// 부모 페이지가 저장을 맡은 화면(M02 양식)만 — B07 은 예전처럼 아무 일 없음
|
|
if (isHostSave()) requestHostSave();
|
|
} else if (shortcut === 'selectAll') {
|
|
// 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);
|
|
this.browsing = null;
|
|
} 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(true);
|
|
this.ime.sync(activeToolWantsIme());
|
|
} else if (evt.key === ' ' && !activeToolWantsFreeText()) {
|
|
// Space = Enter(오토캐드) · 글 단계는 아래 띄어쓰기 · 수정키 · 길게 눌러 반복은 무시
|
|
if (isPlainSpace(evt)) {
|
|
this.handleEnterKey(true);
|
|
this.ime.sync(activeToolWantsIme());
|
|
}
|
|
} else if ((evt.key === 'ArrowUp' || evt.key === 'ArrowDown') && !evt.shiftKey) {
|
|
// ↑↓ = 기록 · 최근 값 · 후보(오토캐드) — 화면 옮김은 Shift+↑↓ · ← →
|
|
this.recall(evt.key === 'ArrowUp' ? -1 : 1);
|
|
} 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 && !evt.ctrlKey && !evt.altKey && !evt.metaKey) {
|
|
// +·-는 확대·축소 단축키로 쓰지 않는다 — 음수 좌표(-100,-50)의 첫 글자를
|
|
// 먹어 버렸다. 줌은 휠·뷰 막대·ZOOMIN/ZOOMOUT 명령으로 한다.
|
|
// User entered a single character => add to input field text
|
|
// 글 단계(TEXT·MTEXT)는 친 그대로 — 명령 입력일 때만 대문자로 바꾼다(보이는 글만 · 맞추기는 prompt.ts).
|
|
// Ctrl · Alt · ⌘ 와 함께 누른 글자는 쌓지 않음(Ctrl+C → 원이 되던 것).
|
|
this.text += activeToolWantsFreeText() ? evt.key : evt.key.toUpperCase();
|
|
this.browsing = null;
|
|
}
|
|
}
|
|
|
|
/** ↑↓ — 후보 보일 때 = 후보 돌림 · 아니면 기록(쉼 = 친 명령 · 명령 중 = 그 물음 최근 값) */
|
|
private recall(dir: -1 | 1) {
|
|
const candidatesShown = isIdle() && !!this.text && !!commandCandidate(this.text, 0);
|
|
if (this.browsing === 'candidates' || (this.browsing === null && candidatesShown)) {
|
|
this.cycleCandidate(dir);
|
|
return;
|
|
}
|
|
const value = recallInput(dir, this.text);
|
|
if (value === null) return;
|
|
this.text = value;
|
|
this.browsing = 'history';
|
|
}
|
|
|
|
/** 명령 후보 돌림 — 처음 친 글로 시작하는 명령 이름을 차례로 친 글 자리에 */
|
|
private cycleCandidate(dir: -1 | 1) {
|
|
if (this.browsing !== 'candidates') {
|
|
this.candidateBase = this.text;
|
|
this.candidateIndex = dir > 0 ? -1 : 0;
|
|
}
|
|
const name = commandCandidate(this.candidateBase, this.candidateIndex + dir);
|
|
if (!name) return;
|
|
this.candidateIndex += dir;
|
|
this.text = name;
|
|
this.browsing = 'candidates';
|
|
}
|
|
|
|
/** Esc 한 번 = 명령 끝(오토캐드) — 친 글 · 조합 · 각도 잠금 · 한 번 스냅 · 덧물음도 함께 풂 */
|
|
public handleEscapeKey() {
|
|
this.text = '';
|
|
this.browsing = null;
|
|
this.ime.composing = '';
|
|
const boxing =
|
|
this.isSelectToolActive() &&
|
|
getActiveToolActor()?.getSnapshot()?.value === SelectState.WAITING_FOR_SECOND_SELECT_POINT;
|
|
if (boxing) {
|
|
getActiveToolActor()?.send({ type: ActorEvent.ESC }); // 상자 취소
|
|
} else if (isIdle()) {
|
|
setSelectedEntityIds([]); // 쉴 때 = 고름 풂
|
|
} else {
|
|
cancelActiveCommand();
|
|
}
|
|
releaseOnePoint();
|
|
setSnapOverride(null);
|
|
closeSubPrompt();
|
|
logLine('*취소*');
|
|
}
|
|
|
|
/** 커서 옆에 띄울 명령 후보 (이름·별칭 접두사 일치) — 쉴 때만 */
|
|
private getCommandSuggestions(): string[] {
|
|
if (this.text === '' || !isIdle()) {
|
|
return [];
|
|
}
|
|
const picking = this.browsing === 'candidates';
|
|
const candidates = matchCommandPrefixes(picking ? this.candidateBase : this.text);
|
|
const picked = picking ? candidates.findIndex((command) => command.id === this.text) : -1;
|
|
return candidates
|
|
.slice(0, 6)
|
|
.map((command, index) => `${index === picked ? '▸ ' : ''}${describeCommand(command)}`);
|
|
}
|
|
|
|
/**
|
|
* 친 글 제출(Enter · Space · 우클릭 · 명령행) — 해석은 `prompt-input.routeTyped` 한 곳.
|
|
* @param _repeatIfIdle 옛 꼴 — 빈 입력 + 쉼이면 늘 마지막 명령 반복(우클릭 포함)
|
|
*/
|
|
public handleEnterKey(_repeatIfIdle = false) {
|
|
const text = this.text;
|
|
this.text = '';
|
|
this.browsing = null;
|
|
pushInput(text); // ↑↓ 기록(지금 물음 묶음 — 해석 전에)
|
|
routeTyped(text);
|
|
}
|
|
|
|
/** 점 하나를 도구에 보냄(덧물음 먼저) — 각도 고정 · 한 번 스냅은 이 한 점으로 끝 */
|
|
private sendPointEvent(event: PointTextEvent | MouseClickEvent) {
|
|
sendPoint(event);
|
|
}
|
|
|
|
public handleUndo(evt: KeyboardEvent) {
|
|
evt.preventDefault();
|
|
undoCommand();
|
|
}
|
|
|
|
public handleRedo(evt: KeyboardEvent) {
|
|
evt.preventDefault();
|
|
redoCommand();
|
|
}
|
|
}
|