Files
Aislo/A00_Common/touch_bridge.ts
T

233 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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();