Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1
This commit is contained in:
@@ -38,6 +38,7 @@ 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';
|
||||
@@ -168,10 +169,14 @@ export class InputController {
|
||||
|
||||
public handleMouseUp(evt: MouseEvent) {
|
||||
if (evt.button === MouseButton.Right) {
|
||||
// 우클릭 = Enter(명령 중 확정 · 쉴 때 반복)
|
||||
// 우클릭 = Enter(명령 중 확정 · 쉴 때 반복) · Shift+우클릭 = 점 물음에서 한 번 스냅 메뉴
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
this.handleEnterKey(true);
|
||||
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
|
||||
|
||||
@@ -63,6 +63,8 @@ export interface SubPrompt {
|
||||
onNumber?(n: number): SubResult;
|
||||
onOption?(key: string): SubResult;
|
||||
onEnter?(): SubResult;
|
||||
/** 꼴이 정해지지 않은 글(ZOOM 「2X」) — undefined = 안 먹음 · 사다리 그대로 */
|
||||
onText?(text: string): SubResult | undefined;
|
||||
}
|
||||
/** `{point}` = 닫고 그 점을 원래 물음에 ABSOLUTE_POINT_INPUT 로 */
|
||||
export type SubResult = 'stay' | 'done' | { point: Point };
|
||||
@@ -377,6 +379,8 @@ export function routeTyped(raw: string): void {
|
||||
return;
|
||||
}
|
||||
if (text.startsWith("'")) return routeTransparent(text.slice(1));
|
||||
const own = subPrompt()?.onText?.(text);
|
||||
if (own) return settle(own);
|
||||
if (prompt.kind === 'idle') {
|
||||
// ⑨ 이름 > 내 별칭 > 기본 별칭 딱 맞음만
|
||||
const command = resolveCommandInput(text);
|
||||
|
||||
@@ -6,6 +6,11 @@ import type { AnyActorRef } from 'xstate';
|
||||
import { getActiveToolActor } from '../state.ts';
|
||||
import { Tool } from '../tools.ts';
|
||||
import { SelectState } from '../tools/select-tool.ts';
|
||||
import { setKeywordNotice } from '../tools/select-tool.helpers.ts';
|
||||
import { notice } from './prompt-input.ts';
|
||||
|
||||
// 고르기 키워드(ALL · L · P) 「n개를 찾음」 → 알림 줄
|
||||
setKeywordNotice(notice);
|
||||
|
||||
const isSelect = (actor: AnyActorRef | undefined): boolean =>
|
||||
actor?.getSnapshot()?.context?.type === Tool.SELECT;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/* Shift+우클릭 한 번 스냅 메뉴(`snap-menu.ts`) — 커서 자리에 세로 목록 */
|
||||
.cad-snap-menu {
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 160px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.cad-snap-menu > button {
|
||||
padding: 4px 10px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cad-snap-menu > button:hover,
|
||||
.cad-snap-menu > button:focus-visible {
|
||||
background: var(--color-surface-hover, rgba(127, 127, 127, 0.15));
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Shift+우클릭 한 번 스냅 메뉴(337 마 · 오토캐드) — 점 물음일 때만 · 고르면 그 이름을 친 것과 같은 길(`submitText`).
|
||||
* 스냅 이름 · FROM · M2P 의 뜻은 친 글 사다리(`prompt-input.ts`) 몫 · 메뉴는 고르기만.
|
||||
*/
|
||||
import './snap-menu.css';
|
||||
|
||||
/** [친 글, 이름] — 오토캐드 객체 스냅 메뉴 차례 */
|
||||
export const SNAP_MENU: readonly [string, string][] = [
|
||||
['FROM', '시작점에서'],
|
||||
['M2P', '두 점 사이의 중간'],
|
||||
['END', '끝점'],
|
||||
['MID', '중간점'],
|
||||
['INT', '교차점'],
|
||||
['CEN', '중심'],
|
||||
['QUA', '사분점'],
|
||||
['PER', '수직'],
|
||||
['NOD', '노드'],
|
||||
['NEA', '근처점'],
|
||||
['NON', '없음'],
|
||||
];
|
||||
|
||||
let current: { onPick: (name: string) => void; element: HTMLElement | null } | null = null;
|
||||
|
||||
const onKey = (evt: KeyboardEvent) => {
|
||||
if (evt.key !== 'Escape') return;
|
||||
evt.preventDefault();
|
||||
evt.stopImmediatePropagation(); // Esc = 메뉴만 닫음(명령은 그대로)
|
||||
closeSnapMenu();
|
||||
};
|
||||
const onOutside = (evt: MouseEvent) => {
|
||||
if (!current?.element?.contains(evt.target as Node)) closeSnapMenu();
|
||||
};
|
||||
|
||||
export const isSnapMenuOpen = (): boolean => current !== null;
|
||||
|
||||
export function closeSnapMenu(): void {
|
||||
if (!current) return;
|
||||
current.element?.remove();
|
||||
current = null;
|
||||
if (typeof window === 'undefined') return;
|
||||
window.removeEventListener('keydown', onKey, true);
|
||||
window.removeEventListener('mousedown', onOutside, true);
|
||||
}
|
||||
|
||||
/** 메뉴에서 하나 고름 — 닫고 그 이름을 넘김 */
|
||||
export function pickSnapMenu(name: string): void {
|
||||
const picked = current;
|
||||
closeSnapMenu();
|
||||
picked?.onPick(name);
|
||||
}
|
||||
|
||||
/** 화면 (x, y) 에 메뉴를 띄움 — DOM 없는 시험에선 상태만 */
|
||||
export function openSnapMenu(x: number, y: number, onPick: (name: string) => void): void {
|
||||
closeSnapMenu();
|
||||
current = { onPick, element: null };
|
||||
if (typeof document === 'undefined') return;
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'cad-snap-menu controls';
|
||||
menu.setAttribute('role', 'menu');
|
||||
for (const [name, label] of SNAP_MENU) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.setAttribute('role', 'menuitem');
|
||||
item.textContent = `${label} (${name})`;
|
||||
item.addEventListener('click', () => pickSnapMenu(name));
|
||||
menu.append(item);
|
||||
}
|
||||
document.body.append(menu);
|
||||
// 화면 밖으로 안 나가게
|
||||
const box = menu.getBoundingClientRect();
|
||||
menu.style.left = `${Math.min(x, window.innerWidth - box.width - 4)}px`;
|
||||
menu.style.top = `${Math.min(y, window.innerHeight - box.height - 4)}px`;
|
||||
current.element = menu;
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
window.addEventListener('mousedown', onOutside, true);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
/**
|
||||
* 보기 바꿈(337 마) — 범위 줌 · 앞 화면(ZOOM 이전(P)) · 휠 두 번 = 범위.
|
||||
* 보기 바꿈(337 마) — 범위 줌 · 앞 화면(ZOOM 이전(P)) · 휠 두 번 = 범위 · ZOOM 덧물음.
|
||||
* 앞 화면 기억 = ZOOM · 휠 두 번 · ⛶ 로 바꾼 것만(휠 굴림 · 팬은 안 쌓음) · 오토캐드처럼 열 개.
|
||||
* ZOOM 은 덧물음 — 도구를 안 바꿔 명령 중 `'Z` 도 같은 길(투명).
|
||||
*/
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { Point } from '@flatten-js/core';
|
||||
import type { OptionSpec, PromptSpec } from '../commands/prompt.ts';
|
||||
import { getScreenCanvasDrawController } from '../state.ts';
|
||||
import { notice, pushSubPrompt, type SubPrompt, type SubResult } from './prompt-input.ts';
|
||||
|
||||
type View = { scale: number; offset: Point };
|
||||
|
||||
@@ -46,3 +49,110 @@ export function isMiddleDouble(point: { x: number; y: number }, time = Date.now(
|
||||
lastMiddle = double ? null : { time, x: point.x, y: point.y };
|
||||
return double;
|
||||
}
|
||||
|
||||
/** 배율만 바꿈 — 화면 가운데 자리를 붙잡음 */
|
||||
function setScaleAboutCenter(scale: number): void {
|
||||
const controller = getScreenCanvasDrawController();
|
||||
const size = controller.getCanvasSize();
|
||||
const offset = controller.getScreenOffset();
|
||||
const old = controller.getScreenScale();
|
||||
const cx = offset.x + size.x / 2 / old;
|
||||
const cy = offset.y + size.y / 2 / old;
|
||||
controller.setScreenScale(scale);
|
||||
controller.setScreenOffset(new Point(cx - size.x / 2 / scale, cy - size.y / 2 / scale));
|
||||
}
|
||||
|
||||
/** 윈도우 줌 — 두 구석 상자를 화면에 꽉(종횡비 유지) */
|
||||
export function zoomWindow(a: Point, b: Point): void {
|
||||
const controller = getScreenCanvasDrawController();
|
||||
const size = controller.getCanvasSize();
|
||||
const width = Math.abs(a.x - b.x);
|
||||
const height = Math.abs(a.y - b.y);
|
||||
const scale = Math.min(
|
||||
width > 0 ? size.x / width : Number.POSITIVE_INFINITY,
|
||||
height > 0 ? size.y / height : Number.POSITIVE_INFINITY
|
||||
);
|
||||
if (!Number.isFinite(scale)) return; // 같은 점 두 번 — 안 바꿈
|
||||
const cx = (a.x + b.x) / 2;
|
||||
const cy = (a.y + b.y) / 2;
|
||||
changeView(() => {
|
||||
controller.setScreenScale(scale);
|
||||
controller.setScreenOffset(new Point(cx - size.x / 2 / scale, cy - size.y / 2 / scale));
|
||||
});
|
||||
}
|
||||
|
||||
const zoomOption = (key: string, kw: string, label: string): OptionSpec => ({
|
||||
key,
|
||||
kw,
|
||||
en: key,
|
||||
label,
|
||||
});
|
||||
|
||||
/** 오토캐드 ZOOM 첫 줄 — 넣은 넷 · 뺀 넷 · <실시간> */
|
||||
const ZOOM_PROMPT: PromptSpec = {
|
||||
text: '윈도우 구석 지정, 축척 비율 (nX 또는 nXP) 입력',
|
||||
options: [
|
||||
zoomOption('ALL', 'A', '전체'),
|
||||
zoomOption('EXTENTS', 'E', '범위'),
|
||||
zoomOption('PREVIOUS', 'P', '이전'),
|
||||
zoomOption('WINDOW', 'W', '윈도우'),
|
||||
],
|
||||
absent: [
|
||||
zoomOption('CENTER', 'C', '중심'),
|
||||
zoomOption('DYNAMIC', 'D', '동적'),
|
||||
zoomOption('SCALE', 'S', '축척'),
|
||||
zoomOption('OBJECT', 'O', '객체'),
|
||||
],
|
||||
default: { kind: 'enter', label: '실시간' },
|
||||
};
|
||||
const FIRST_CORNER: PromptSpec = { text: '첫 번째 구석 지정' };
|
||||
const OTHER_CORNER: PromptSpec = { text: '반대 구석 지정' };
|
||||
const SCALE_REGEXP = /^(\d*\.?\d+)\s*XP?$/i;
|
||||
|
||||
/** ZOOM — 덧물음 하나가 단계(첫 줄 → 첫 구석 → 반대 구석)를 품음 */
|
||||
export function startZoomPrompt(): void {
|
||||
let stage: 'main' | 'first' | 'other' = 'main';
|
||||
let corner: Point | null = null;
|
||||
const sub: SubPrompt = {
|
||||
kind: 'point',
|
||||
get prompt() {
|
||||
return stage === 'main' ? ZOOM_PROMPT : stage === 'first' ? FIRST_CORNER : OTHER_CORNER;
|
||||
},
|
||||
onPoint(point): SubResult {
|
||||
if (stage === 'other' && corner) {
|
||||
zoomWindow(corner, point);
|
||||
return 'done';
|
||||
}
|
||||
corner = point; // 첫 줄에서 누름 = 윈도우 첫 구석(오토캐드)
|
||||
stage = 'other';
|
||||
return 'stay';
|
||||
},
|
||||
onOption(key): SubResult {
|
||||
if (key === 'WINDOW') {
|
||||
stage = 'first';
|
||||
return 'stay';
|
||||
}
|
||||
if (key === 'PREVIOUS') {
|
||||
if (!zoomPrevious()) notice('앞 화면이 없음.');
|
||||
return 'done';
|
||||
}
|
||||
zoomExtents(); // 전체(A) = 범위(이 CAD 엔 도면 한계 없음)
|
||||
return 'done';
|
||||
},
|
||||
onEnter(): SubResult {
|
||||
if (stage !== 'main') return 'stay';
|
||||
notice('실시간 줌은 휠로.');
|
||||
return 'done';
|
||||
},
|
||||
onText(text) {
|
||||
const match = stage === 'main' ? SCALE_REGEXP.exec(text) : null;
|
||||
if (!match) return undefined;
|
||||
const factor = Number.parseFloat(match[1]);
|
||||
if (factor <= 0) return undefined;
|
||||
const scale = getScreenCanvasDrawController().getScreenScale() * factor;
|
||||
changeView(() => setScaleAboutCenter(scale));
|
||||
return 'done';
|
||||
},
|
||||
};
|
||||
pushSubPrompt(sub);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user