Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
233 lines
7.7 KiB
TypeScript
233 lines
7.7 KiB
TypeScript
/* =============================================================================
|
||
* 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<Event>();
|
||
|
||
/** 손가락 벌림으로 만든 휠인지 — 「당기면 확대」 화면이 방향을 맞출 때 씀 */
|
||
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<number, Pt>();
|
||
let press: (Pt & { id: number; target: Element; timer: ReturnType<typeof setTimeout> }) | 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();
|