/* ============================================================================= * touch_bridge.ts * 손가락 → 마우스 번역 한 곳(요구 193~195 · PLAN_기기대응 3-1) — 페이지 코드는 마우스 그대로. * * - 길게 누름(500ms · 10px 안) → 그 자리에 `contextmenu` · 뒤따르는 click 막음 * (안드로이드처럼 브라우저가 먼저 내면 그것 하나만) * - 두 번 톡 → 브라우저가 `dblclick` 을 안 낼 때만 대신 * - 두 손가락 벌림 → 가운데 자리에 Ctrl+휠(deltaY = −100·ln(배율) · 배율 10% 마다 한 번 · 트랙패드 핀치와 같은 꼴) * 받는 면이 touch-action 으로 브라우저 확대를 막은 곳에서만 끝까지 옴 * - `[data-touch-own]` 요소(3D · 공용 SVG 확대)는 건너뜀 — 자체 손가락 처리 · 입력 칸 길게 누름도 브라우저 몫 * 마우스 · 펜 이벤트는 건드리지 않음. * ========================================================================== */ const LONG_MS = 500; const SLOP_PX = 10; const DOUBLE_MS = 300; const DOUBLE_PX = 25; /** 브라우저 dblclick 을 기다리는 틈 */ const NATIVE_WAIT_MS = 350; /** 길게 누름 뒤 따라오는 click · 원래 contextmenu 를 막는 틈 */ const AFTER_LONG_MS = 800; const PINCH_STEP = Math.log(1.1); const synthetic = new WeakSet(); /** 손가락 벌림으로 만든 휠인지 — 「당기면 확대」 화면이 방향을 맞출 때 씀 */ export const isPinchWheel = (e: Event): boolean => synthetic.has(e); let contactPx = 0; /** 고름 반경(px) — 손가락으로 눌렀으면 접촉 크기만큼(최소 24px) · 마우스 · 펜은 그대로(기기대응 4-4) */ export const pickPx = (px: number): number => (contactPx ? Math.max(px, contactPx) : px); /** 「당기면 확대」 휠 화면(B04 · B05 지도)의 확대 판정 — 손가락 벌림은 거꾸로라 뒤집음 */ export const wheelZoomsIn = (e: WheelEvent): boolean => isPinchWheel(e) ? e.deltaY < 0 : e.deltaY > 0; const ownsTouch = (t: EventTarget | null): boolean => t instanceof Element && !!t.closest("[data-touch-own]"); const isEditable = (t: EventTarget | null): boolean => t instanceof Element && !!t.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]'); interface Pt { x: number; y: number; } const touches = new Map(); let press: (Pt & { id: number; target: Element; timer: ReturnType }) | null = null; let longAt = 0; let nativeMenuAt = 0; let nativeDblAt = 0; let lastTap: (Pt & { t: number }) | null = null; let pinch: { dist: number; acc: number } | null = null; const at = (p: Pt, fallback: Element): Element => document.elementFromPoint(p.x, p.y) ?? fallback; function fire(type: "contextmenu" | "dblclick", target: Element, p: Pt): void { const ev = new MouseEvent(type, { bubbles: true, cancelable: true, composed: true, view: window, clientX: p.x, clientY: p.y, screenX: p.x, screenY: p.y, button: type === "contextmenu" ? 2 : 0, detail: type === "dblclick" ? 2 : 1, }); synthetic.add(ev); target.dispatchEvent(ev); } function fireWheel(target: Element, p: Pt, deltaY: number): void { const ev = new WheelEvent("wheel", { bubbles: true, cancelable: true, composed: true, view: window, clientX: p.x, clientY: p.y, ctrlKey: true, deltaY, deltaMode: 0, }); synthetic.add(ev); target.dispatchEvent(ev); } function cancelPress(): void { if (press) clearTimeout(press.timer); press = null; } const pair = (): { mid: Pt; dist: number } => { const [a, b] = [...touches.values()]; return { mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, dist: Math.hypot(a.x - b.x, a.y - b.y), }; }; /** 그림 면(SVG · 캔버스)을 처음 만지면 한 번 — 「길게 누르면 메뉴」(3-5 · 이 탭에서 한 번) */ const HINT_KEY = "frd_touch_hint_shown"; function hintOnce(target: Element): void { if (!target.closest("svg, canvas")) return; try { if (sessionStorage.getItem(HINT_KEY)) return; sessionStorage.setItem(HINT_KEY, "1"); } catch { return; } void Promise.all([import("@ui/ui_template_elements"), import("@ui/ui_template_locale")]).then( ([ui, loc]) => ui.showToast(loc.t("App_Touch_LongPressHint"), "info", 4000), ); } function onDown(e: PointerEvent): void { contactPx = e.pointerType === "touch" ? Math.max(24, e.width / 2, e.height / 2) : 0; if (e.pointerType !== "touch") return; const p = { x: e.clientX, y: e.clientY }; touches.set(e.pointerId, p); const target = e.target as Element; hintOnce(target); if (touches.size === 1) { if (ownsTouch(target) || isEditable(target)) return; press = { ...p, id: e.pointerId, target, timer: setTimeout(() => { const was = press; press = null; if (!was || Date.now() - nativeMenuAt < LONG_MS) return; longAt = Date.now(); fire("contextmenu", at(was, was.target), was); }, LONG_MS), }; return; } cancelPress(); lastTap = null; pinch = touches.size === 2 && !ownsTouch(target) ? { dist: pair().dist, acc: 0 } : null; } function onMove(e: PointerEvent): void { if (e.pointerType !== "touch" || !touches.has(e.pointerId)) return; const p = { x: e.clientX, y: e.clientY }; touches.set(e.pointerId, p); if (press && press.id === e.pointerId && Math.hypot(p.x - press.x, p.y - press.y) > SLOP_PX) { cancelPress(); lastTap = null; } if (!pinch || touches.size !== 2) return; const { mid, dist } = pair(); if (pinch.dist > 0 && dist > 0) pinch.acc += Math.log(dist / pinch.dist); pinch.dist = dist; if (Math.abs(pinch.acc) < PINCH_STEP) return; fireWheel(at(mid, e.target as Element), mid, -100 * pinch.acc); pinch.acc = 0; } function onUp(e: PointerEvent): void { if (e.pointerType !== "touch") return; touches.delete(e.pointerId); if (touches.size < 2) pinch = null; if (!press || press.id !== e.pointerId) return; const tap = press; cancelPress(); const now = Date.now(); if ( lastTap && now - lastTap.t < DOUBLE_MS && Math.hypot(tap.x - lastTap.x, tap.y - lastTap.y) < DOUBLE_PX ) { lastTap = null; setTimeout(() => { if (nativeDblAt < now) fire("dblclick", at(tap, tap.target), tap); }, NATIVE_WAIT_MS); return; } lastTap = { x: tap.x, y: tap.y, t: now }; } function onCancel(e: PointerEvent): void { if (e.pointerType !== "touch") return; touches.delete(e.pointerId); cancelPress(); pinch = null; } function onNativeMenu(e: Event): void { if (synthetic.has(e)) return; if (Date.now() - longAt < AFTER_LONG_MS) { // 우리가 이미 냄 — 브라우저 것은 하나 더라 버림 e.preventDefault(); e.stopImmediatePropagation(); } else if (press) { nativeMenuAt = Date.now(); // 브라우저가 먼저 냄 — 우리 것은 안 냄 cancelPress(); } } function onClick(e: Event): void { if (synthetic.has(e) || Date.now() - longAt >= AFTER_LONG_MS) return; longAt = 0; e.preventDefault(); e.stopImmediatePropagation(); } function listen(): void { const opts = { capture: true, passive: true } as const; window.addEventListener("pointerdown", onDown, opts); window.addEventListener("pointermove", onMove, opts); window.addEventListener("pointerup", onUp, opts); window.addEventListener("pointercancel", onCancel, opts); window.addEventListener("contextmenu", onNativeMenu, true); window.addEventListener("click", onClick, true); window.addEventListener( "dblclick", (e) => { if (!synthetic.has(e)) nativeDblAt = Date.now(); }, true, ); } // 브라우저에서만(노드 시험이 이 모듈을 거쳐 불러도 됨) if (typeof window !== "undefined" && typeof window.addEventListener === "function") listen();