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

145 lines
5.5 KiB
TypeScript

import { Point } from '@flatten-js/core';
import { setTouchPickPx } from '../helpers/pick-radius.ts';
import { getCanvas, getScreenCanvasDrawController } from '../state.ts';
import type { InputController } from './input-controller.ts';
import './input-touch.css';
/**
* 손가락 CAD(기기대응 4-1) — 마우스 코드는 그대로 두고 손가락만 여기서 번역한다.
*
* · 톡 = 그 자리 왼쪽 클릭(스냅 그대로)
* · 한 손가락 끌기 = 화면 옮기기 · 두 손가락 = 벌려 확대 + 같이 끌어 옮기기
* · 길게 누름(450ms) = 정밀 고르기 — 손가락 위 70px 에 십자선 · 끌어 맞추고 떼면 그 자리 클릭
* · 고름 반경 = 접촉 크기(최소 24px) · 마우스로 누르면 원래 반경
* · 자판 없이 쓰는 명령 단추(Esc · Enter · 지움 · 되돌리기 · 다시 · 스냅) — 손가락 기기에서만 보임(App.css)
*/
const LONG_MS = 450;
const SLOP_PX = 10;
const LIFT_PX = 70;
const MIN_PICK_PX = 24;
type Mode = 'idle' | 'pending' | 'pan' | 'precise' | 'pinch' | 'done';
export function attachTouch(ctl: InputController): void {
const canvas = getCanvas();
if (!canvas) return;
canvas.style.touchAction = 'none';
const touches = new Map<number, { x: number; y: number }>();
let mode: Mode = 'idle';
let start = { x: 0, y: 0 };
let last = { x: 0, y: 0 };
let pinchDist = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
const mouse = (type: string, x: number, y: number) =>
new MouseEvent(type, {
clientX: x,
clientY: y,
button: 0,
buttons: type === 'mousedown' ? 1 : 0,
});
/** 화면(캔버스 아래 원점) 좌표 — 컨트롤러와 같은 꼴 */
const canvasPoint = (x: number, y: number): Point => {
const b = canvas.getBoundingClientRect();
return new Point(x - b.left, b.bottom - y);
};
const clickAt = (x: number, y: number): void => {
ctl.handleMouseMove(mouse('mousemove', x, y));
ctl.handleMouseDown(mouse('mousedown', x, y));
ctl.handleMouseUp(mouse('mouseup', x, y));
};
const pair = () => {
const [a, b] = [...touches.values()];
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, dist: Math.hypot(a.x - b.x, a.y - b.y) };
};
canvas.addEventListener('pointerdown', (e) => {
if (e.pointerType !== 'touch') {
setTouchPickPx(0); // 마우스 · 펜 = 원래 반경
return;
}
e.preventDefault(); // 호환 마우스 이벤트를 막음 — 여기서 다 처리
setTouchPickPx(Math.max(MIN_PICK_PX, e.width || 0, e.height || 0));
touches.set(e.pointerId, { x: e.clientX, y: e.clientY });
clearTimeout(timer);
if (touches.size === 1) {
mode = 'pending';
start = last = { x: e.clientX, y: e.clientY };
timer = setTimeout(() => {
if (mode !== 'pending') return;
mode = 'precise';
ctl.handleMouseMove(mouse('mousemove', last.x, last.y - LIFT_PX));
}, LONG_MS);
} else if (touches.size === 2) {
mode = 'pinch';
const m = pair();
pinchDist = m.dist;
last = { x: m.x, y: m.y };
}
});
canvas.addEventListener('pointermove', (e) => {
if (e.pointerType !== 'touch' || !touches.has(e.pointerId)) return;
touches.set(e.pointerId, { x: e.clientX, y: e.clientY });
const view = getScreenCanvasDrawController();
if (mode === 'pending' && Math.hypot(e.clientX - start.x, e.clientY - start.y) > SLOP_PX) {
clearTimeout(timer);
mode = 'pan';
}
if (mode === 'pan') {
// 캔버스 좌표는 y 가 위로 — 끈 만큼 도면이 따라옴
view.panScreen(e.clientX - last.x, last.y - e.clientY);
last = { x: e.clientX, y: e.clientY };
} else if (mode === 'precise') {
last = { x: e.clientX, y: e.clientY };
ctl.handleMouseMove(mouse('mousemove', e.clientX, e.clientY - LIFT_PX));
} else if (mode === 'pinch' && touches.size === 2) {
const m = pair();
view.panScreen(m.x - last.x, last.y - m.y);
view.setScreenMouseLocation(canvasPoint(m.x, m.y));
if (pinchDist > 0 && m.dist > 0) view.zoomScreen(-Math.log(m.dist / pinchDist), 1);
pinchDist = m.dist;
last = { x: m.x, y: m.y };
}
});
const lift = (e: PointerEvent, cancelled: boolean): void => {
if (e.pointerType !== 'touch' || !touches.delete(e.pointerId)) return;
clearTimeout(timer);
if (!cancelled && touches.size === 0) {
if (mode === 'pending') clickAt(e.clientX, e.clientY);
else if (mode === 'precise') clickAt(e.clientX, e.clientY - LIFT_PX);
}
// 두 손가락에서 하나를 떼면 남은 손가락은 다 뗄 때까지 아무 일도 안 함
mode = touches.size === 0 ? 'idle' : 'done';
};
canvas.addEventListener('pointerup', (e) => lift(e, false));
canvas.addEventListener('pointercancel', (e) => lift(e, true));
mountTouchBar(ctl);
}
/** 자판 없는 명령 단추 — 단축키와 같은 길(`handleKeyStroke`)로 보낸다 */
function mountTouchBar(ctl: InputController): void {
const bar = document.createElement('div');
bar.className = 'cad-touch-bar';
const keys: [string, string, KeyboardEventInit][] = [
['Esc', '취소(Esc)', { key: 'Escape' }],
['↵', '확정(Enter)', { key: 'Enter' }],
['🗑', '지움(Delete)', { key: 'Delete' }],
['↶', '되돌리기(Ctrl+Z)', { key: 'z', code: 'KeyZ', ctrlKey: true }],
['↷', '다시(Ctrl+Y)', { key: 'y', code: 'KeyY', ctrlKey: true }],
['⌖', '스냅 켬/끔(F3)', { key: 'F3' }],
];
for (const [label, title, init] of keys) {
const b = document.createElement('button');
b.type = 'button';
b.textContent = label;
b.title = title;
b.setAttribute('aria-label', title);
b.addEventListener('click', () => ctl.handleKeyStroke(new KeyboardEvent('keydown', init)));
bar.append(b);
}
document.body.append(bar);
}