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(); let mode: Mode = 'idle'; let start = { x: 0, y: 0 }; let last = { x: 0, y: 0 }; let pinchDist = 0; let timer: ReturnType | 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); }