feat(device): 손가락 → 마우스 번역 — 길게 누름 = 우클릭 · 두 번 톡 = 더블클릭 · 벌림 = Ctrl+휠(touch_bridge) · 3D 두 손가락 확대 · 이동 · 공용 SVG 확대 두 손가락 · 당기면 확대 지도 벌림 방향 · M02 그림판 손가락 끌기 · 시트 ⋯ 칸 메뉴 · 그림 면 안내 (기기대응 3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
2026-10-13 13:28:09 +09:00
co-authored by Claude Opus 5.5
parent 32e3a6723a
commit 68a96e253f
15 changed files with 532 additions and 28 deletions
+32
View File
@@ -219,6 +219,38 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
{ passive: false },
);
}
// 손가락 둘 = 벌려 확대 · 같이 끌어 이동(기기대응 3-2) — 한 손가락은 그 화면의 고르기 · 목록 밀기 몫.
// 휠 줌 화면은 그림 면이 손가락을 다 받음 · 아니면(B06 카드 목록) 한 손가락 밀기는 브라우저에 둠.
svg.setAttribute?.("data-touch-own", "pinch"); // ?. = 노드 시험의 흉내 SVG
svg.style?.setProperty("touch-action", options.wheelZoom ? "none" : "pan-x pan-y");
const touches = new Map<number, { x: number; y: number }>();
const touchPair = (): { x: number; y: number; dist: number } => {
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) };
};
svg.addEventListener("pointerdown", (event) => {
if (event.pointerType === "touch")
touches.set(event.pointerId, { x: event.clientX, y: event.clientY });
});
svg.addEventListener("pointermove", (event) => {
if (event.pointerType !== "touch" || !touches.has(event.pointerId)) return;
const before = touches.size === 2 ? touchPair() : null;
touches.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (!before) return;
const after = touchPair();
const rect = svg.getBoundingClientRect();
const w = Math.max(rect.width, 1);
const h = Math.max(rect.height, 1);
tx += ((after.x - before.x) / w) * widthPx;
ty += ((after.y - before.y) / h) * heightPx;
moved = true;
const cx = ((after.x - rect.left) / w) * widthPx;
const cy = ((after.y - rect.top) / h) * heightPx;
zoomAt(before.dist > 0 && after.dist > 0 ? after.dist / before.dist : 1, cx, cy);
});
const liftTouch = (event: PointerEvent): void => void touches.delete(event.pointerId);
svg.addEventListener("pointerup", liftTouch);
svg.addEventListener("pointercancel", liftTouch);
const currentScale = (): number => scale;
const reset = (): void => {
({ scale, tx, ty } = home);
+1
View File
@@ -10,6 +10,7 @@
import "./random_uuid_polyfill";
import "./device_context"; // 맨 앞 — 그리기 전에 <html> 기기 표시(기기대응 2-1)
import "./touch_bridge"; // 손가락 → 마우스 번역(기기대응 3-1)
import "@ui/ui_template_theme.css";
import { injectBaseStyles } from "@ui/ui_template_elements";
import { blockNativeMenu, NATIVE_MENU_ROUTE } from "@ui/ui_template_native_menu";
@@ -15,6 +15,7 @@ export function setSpreadsheetLanguage(index: 0 | 1): void {
const TEXT = {
Undo: ["되돌리기", "Undo"],
CellMenu: ["칸 메뉴(우클릭과 같음)", "Cell menu (same as right-click)"],
Redo: ["다시", "Redo"],
FontName: ["글꼴", "Font"],
FontSize: ["크기", "Size"],
@@ -291,3 +291,12 @@
justify-content: flex-end;
gap: var(--spacing-8);
}
/* ⋯ 칸 메뉴(기기대응 3-5) — 터치 모드에서만 */
.ss-toolbar .ss-toolbar__more {
display: none;
}
html[data-touch="on"] .ss-toolbar .ss-toolbar__more {
display: inline-flex;
}
+25 -1
View File
@@ -148,6 +148,30 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
undoBtn.title = st("Undo");
const redoBtn = createButton({ label: "↷", variant: "ghost", onClick: () => ctx.redo() });
redoBtn.title = st("Redo");
// 손가락 — 우클릭 대신 활성 칸 메뉴를 여는 ⋯(기기대응 3-5 · 터치 모드에서만 보임)
const moreBtn = createButton({
label: "⋯",
variant: "ghost",
onClick: () => {
const { r, c } = ctx.selection.활성;
const { box } = ctx.grid.editorSlot(r, c);
const at = ctx.grid.root.getBoundingClientRect();
const x = at.left + box.x + Math.min(box.w / 2, 24);
const y = at.top + box.y + box.h / 2;
const target = document.elementFromPoint(x, y) ?? ctx.grid.root;
target.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
button: 2,
}),
);
},
});
moreBtn.classList.add("ss-toolbar__more");
moreBtn.title = st("CellMenu");
// ── 글꼴 · 크기 ────────────────────────────────────────────────────────
const fontInput = el("input", {
@@ -501,7 +525,7 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
});
root.append(
group(undoBtn, redoBtn),
group(undoBtn, redoBtn, moreBtn),
group(fontInput, sizeInput),
group(boldBtn, italicBtn, underlineBtn, strikeBtn),
group(textColor, textColorClear, fillColor, fillColorClear),
+226
View File
@@ -0,0 +1,226 @@
/* =============================================================================
* 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);
/** 「당기면 확대」 휠 화면(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 {
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") listen();
+78 -20
View File
@@ -225,10 +225,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
if (options.blocked?.() || !controls.enabled) return;
if (event.button === 1 && panPointerId === null) {
// 그랩 팬 시작 — 커서 아래 지형점을 잡고, 시선 수직 평면 위에서 따라오게 한다.
pickPivot(event);
panAnchor.copy(pivot);
getCamera().getWorldDirection(viewDirection);
panPlane.setFromNormalAndCoplanarPoint(viewDirection, panAnchor);
startGrabPan(event);
panPointerId = event.pointerId;
element.setPointerCapture?.(event.pointerId);
return;
@@ -251,11 +248,15 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
/** 휠 줌 — 커서가 가리키는 지점을 축으로 삼아 그 점이 화면에 고정된 채 멀어지고 가까워진다.
* 휠을 위로 올리면 멀어진다(사용자 지시). */
function onWheel(event: WheelEvent): void {
const camera = getCamera();
if (!controls.enabled || options.blocked?.()) return;
event.preventDefault();
pickPivot(event);
const factor = event.deltaY < 0 ? 1 / ZOOM_STEP : ZOOM_STEP;
zoomAround(event, event.deltaY < 0 ? 1 / ZOOM_STEP : ZOOM_STEP);
}
/** 그 자리를 축으로 멀어지고(factor > 1) 가까워짐 — 휠 · 두 손가락 벌림이 같이 씀 */
function zoomAround(at: { clientX: number; clientY: number }, factor: number): void {
const camera = getCamera();
pickPivot(at);
const cameraOffset = camera.position.clone().sub(pivot).multiplyScalar(factor);
const targetOffset = controls.target.clone().sub(pivot).multiplyScalar(factor);
// 축에 너무 가까워지면 시점이 뒤집히므로 최소 거리를 남긴다.
@@ -276,19 +277,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
function onPointerMove(event: PointerEvent): void {
const camera = getCamera();
if (panPointerId === event.pointerId) {
// 그랩 팬 — 잡은 점이 커서 아래에 계속 오도록 카메라·target을 평행 이동한다.
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
pointer.set(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1,
);
raycaster.setFromCamera(pointer, camera);
if (!raycaster.ray.intersectPlane(panPlane, panPoint)) return;
const delta = panAnchor.clone().sub(panPoint);
camera.position.add(delta);
controls.target.add(delta);
controls.update();
grabPanTo(event);
return;
}
if (pointerId !== event.pointerId) return;
@@ -351,6 +340,67 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
syncPivotMarker();
}
/** 그랩 팬 — 잡은 점이 커서(손가락 가운데) 아래에 계속 오도록 카메라·target을 평행 이동한다. */
function grabPanTo(at: { clientX: number; clientY: number }): void {
const camera = getCamera();
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
pointer.set(
((at.clientX - rect.left) / rect.width) * 2 - 1,
-((at.clientY - rect.top) / rect.height) * 2 + 1,
);
raycaster.setFromCamera(pointer, camera);
if (!raycaster.ray.intersectPlane(panPlane, panPoint)) return;
const delta = panAnchor.clone().sub(panPoint);
camera.position.add(delta);
controls.target.add(delta);
controls.update();
}
function startGrabPan(at: { clientX: number; clientY: number }): void {
pickPivot(at);
panAnchor.copy(pivot);
getCamera().getWorldDirection(viewDirection);
panPlane.setFromNormalAndCoplanarPoint(viewDirection, panAnchor);
}
// 손가락(기기대응 3-1) — 하나 = 회전(왼쪽 단추와 같음) · 둘 = 벌려 확대 · 같이 끌어 이동.
// 3D 는 자체 처리 — 손가락 번역(`touch_bridge`)은 이 요소를 건너뜀.
element.dataset.touchOwn = "3d";
element.style.touchAction = "none";
const touches = new Map<number, { clientX: number; clientY: number }>();
let pinchDist = 0;
const touchMid = (): { clientX: number; clientY: number; dist: number } => {
const [a, b] = [...touches.values()];
return {
clientX: (a.clientX + b.clientX) / 2,
clientY: (a.clientY + b.clientY) / 2,
dist: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY),
};
};
function onTouchDown(event: PointerEvent): void {
if (event.pointerType !== "touch" || options.blocked?.() || !controls.enabled) return;
touches.set(event.pointerId, { clientX: event.clientX, clientY: event.clientY });
if (touches.size !== 2) return;
stop(); // 한 손가락 회전을 멈추고 두 손가락으로
const m = touchMid();
pinchDist = m.dist;
startGrabPan(m);
}
function onTouchMove(event: PointerEvent): void {
if (event.pointerType !== "touch" || !touches.has(event.pointerId)) return;
touches.set(event.pointerId, { clientX: event.clientX, clientY: event.clientY });
if (touches.size !== 2) return;
const m = touchMid();
grabPanTo(m);
if (pinchDist > 0 && m.dist > 0) zoomAround(m, pinchDist / m.dist);
pinchDist = m.dist;
startGrabPan(m); // 확대 뒤 다시 잡음 — 다음 이동이 새 배율 기준
}
function onTouchUp(event: PointerEvent): void {
touches.delete(event.pointerId);
}
function stop(): void {
if (pointerId !== null && element.hasPointerCapture?.(pointerId)) {
element.releasePointerCapture(pointerId);
@@ -381,6 +431,10 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
element.addEventListener("pointercancel", onPointerEnd);
element.addEventListener("pointerleave", onPointerLeave);
element.addEventListener("wheel", onWheel, { passive: false });
element.addEventListener("pointerdown", onTouchDown);
element.addEventListener("pointermove", onTouchMove);
element.addEventListener("pointerup", onTouchUp);
element.addEventListener("pointercancel", onTouchUp);
return () => {
element.removeEventListener("pointerdown", onPointerDown);
@@ -389,6 +443,10 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
element.removeEventListener("pointercancel", onPointerEnd);
element.removeEventListener("pointerleave", onPointerLeave);
element.removeEventListener("wheel", onWheel);
element.removeEventListener("pointerdown", onTouchDown);
element.removeEventListener("pointermove", onTouchMove);
element.removeEventListener("pointerup", onTouchUp);
element.removeEventListener("pointercancel", onTouchUp);
if (pivotMarker) {
pivotMarker.removeFromParent();
pivotMarker.material.map?.dispose();
@@ -1,4 +1,5 @@
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { wheelZoomsIn } from "../A00_Common/touch_bridge";
import { createProgressCircle } from "@ui/ui_template_progress";
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
import {
@@ -546,7 +547,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
wheelWidth,
8,
);
scale = Math.min(maxScale, Math.max(0.5, scale * (event.deltaY > 0 ? 1.15 : 0.87)));
scale = Math.min(maxScale, Math.max(0.5, scale * (wheelZoomsIn(event) ? 1.15 : 0.87)));
// 마우스 커서 아래 지점이 줌 전후로 같은 화면 위치에 머물도록 offset 보정.
// screen = center + (base - center)·scale + offset 이므로,
// 커서 고정 조건을 풀면 offset' = (cursor - center)·(1 - r) + offset·r (r = scale'/scale).
@@ -8,6 +8,7 @@
* 그리기·유역 목록·마커 편집기가 같은 값을 보던 구조를 그대로 두기 위함이다.
* ========================================================================== */
import { wheelZoomsIn } from "../A00_Common/touch_bridge";
import {
computeMaxScale,
lonLatToScreen,
@@ -67,7 +68,7 @@ export function bindDrainageInteractions(params: DrainageInteractParams): void {
const maxScale = computeMaxScale(params.getMeta(), view.mapRect.width, view.width, 16);
// 하한은 노선 기준(계획서 281) — 도엽 전체까지 빠지지 않는다.
const scale = clampWheelScale(
prevScale * (event.deltaY > 0 ? 1.15 : 0.87),
prevScale * (wheelZoomsIn(event) ? 1.15 : 0.87),
prevScale,
params.getMinScale(),
maxScale,
@@ -13,6 +13,7 @@
* 있어 좌상단 기준으로 셈하면 확대할수록 커서 아래 지점이 밀린다(옛 모달의 버그).
* ========================================================================== */
import { wheelZoomsIn } from "../A00_Common/touch_bridge";
import { computeMaxScale, type ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { StationLabelBox } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
@@ -56,7 +57,7 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void
view.width,
MAX_SCALE_FALLBACK,
);
const factor = event.deltaY > 0 ? ZOOM_IN_FACTOR : ZOOM_OUT_FACTOR;
const factor = wheelZoomsIn(event) ? ZOOM_IN_FACTOR : ZOOM_OUT_FACTOR;
const scale = Math.min(maxScale, Math.max(MIN_SCALE, view.scale * factor));
const ratio = scale / view.scale;
const rect = canvas.getBoundingClientRect();
@@ -458,11 +458,13 @@ export function renderDiagram(
},
{ passive: false },
);
// 판 옮기기 = 가운데 단추 끌기(어디서나) · Space + 왼쪽 끌기 — 왼쪽 빈 곳 끌기는 아무것도 안 함
// 판 옮기기 = 가운데 단추 끌기(어디서나) · Space + 왼쪽 끌기 — 왼쪽 빈 곳 끌기는 아무것도 안 함(마우스)
view.addEventListener(
"pointerdown",
(ev) => {
if (ev.button === 1 || (ev.button === 0 && spaceDown)) {
// 손가락 빈 곳 끌기도 판 옮기기(기기대응 3-3)
const touchBlank = ev.pointerType === "touch" && (ev.target === view || ev.target === world);
if (ev.button === 1 || (ev.button === 0 && spaceDown) || touchBlank) {
ev.preventDefault();
ev.stopPropagation();
dragPan(ev);
@@ -312,3 +312,10 @@
.m02-labordg__card.is-material {
cursor: default;
}
/* 손가락 기기 — 판이 끌기 · 두 손가락 확대를 받음(브라우저 확대 · 밀기 안 함 · 기기대응 3-2) */
@media (any-pointer: coarse) {
.m02-labordg__viewport {
touch-action: none;
}
}
@@ -280,9 +280,10 @@ function mountShell(host: HTMLElement): State {
}),
viewport,
);
// 판 옮기기 — 가운데 단추 · Space + 왼쪽 끌기
// 판 옮기기 — 가운데 단추 · Space + 왼쪽 끌기 · 손가락 빈 곳 끌기(기기대응 3-3)
viewport.addEventListener("pointerdown", (e) => {
if (!(e.button === 1 || (e.button === 0 && st.space))) return;
const touchBlank = e.pointerType === "touch" && (e.target === viewport || e.target === world);
if (!(e.button === 1 || (e.button === 0 && st.space) || touchBlank)) return;
e.preventDefault();
try {
viewport.setPointerCapture(e.pointerId);
+136
View File
@@ -0,0 +1,136 @@
"""손가락 → 마우스 번역(`A00_Common/touch_bridge.ts` · PLAN_기기대응 3-1) — 실제 브라우저 터치.
sub1 화면 서버(5174) 첫 화면을 터치 기기로 열고 시험용 상자에 CDP 터치를 넣어 봄:
① 길게 누름 → contextmenu 한 번 · 뒤따르는 click 없음 ② 짧은 톡 → contextmenu 없음
③ 두 번 톡 → dblclick 한 번 ④ 두 손가락 벌림 → Ctrl+휠(deltaY < 0) · 오므림 → deltaY > 0
⑤ [data-touch-own] 상자는 번역 안 함 ⑥ 마우스 우클릭 · 더블클릭은 그대로(번역이 끼지 않음)
"""
import json
import urllib.request
import pytest
BASE = "http://localhost:5174"
def _up() -> bool:
try:
urllib.request.urlopen(BASE, timeout=2)
return True
except Exception:
return False
SETUP = """
() => {
window.__log = [];
for (const id of ['tb-box', 'tb-own']) {
const b = document.createElement('div');
b.id = id;
Object.assign(b.style, { position: 'fixed', left: id === 'tb-box' ? '20px' : '220px', top: '120px',
width: '180px', height: '180px', background: '#eee', zIndex: 99999, touchAction: 'none' });
if (id === 'tb-own') b.dataset.touchOwn = 'x';
for (const t of ['contextmenu', 'click', 'dblclick', 'wheel'])
b.addEventListener(t, (e) => {
window.__log.push({ id, t, ctrl: !!e.ctrlKey, dy: e.deltaY || 0, trusted: e.isTrusted });
if (t === 'contextmenu' || t === 'wheel') e.preventDefault();
}, { passive: false });
document.body.append(b);
}
}
"""
def _touch(cdp, kind, points):
cdp.send(
"Input.dispatchTouchEvent",
{
"type": kind,
"touchPoints": [{"x": x, "y": y, "id": i} for i, (x, y) in enumerate(points)],
},
)
def _log(page):
out = page.evaluate("() => window.__log.splice(0)")
return [(e["id"], e["t"]) for e in out], out
@pytest.mark.skipif(not _up(), reason="sub1 화면 서버(5174) 꺼짐")
def test_touch_bridge_translates_finger_gestures():
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(channel="msedge", headless=True)
ctx = browser.new_context(
viewport={"width": 390, "height": 844}, has_touch=True, is_mobile=True
)
page = ctx.new_page()
page.goto(f"{BASE}/#/a01-home")
page.wait_for_timeout(1500)
assert page.evaluate("() => document.documentElement.dataset.touch") == "on"
page.evaluate(SETUP)
cdp = ctx.new_cdp_session(page)
# ① 길게 누름
_touch(cdp, "touchStart", [(100, 200)])
page.wait_for_timeout(700)
_touch(cdp, "touchEnd", [])
page.wait_for_timeout(500)
names, _ = _log(page)
assert names.count(("tb-box", "contextmenu")) == 1, names
assert ("tb-box", "click") not in names, names
# ② 짧은 톡
page.wait_for_timeout(400)
_touch(cdp, "touchStart", [(100, 200)])
_touch(cdp, "touchEnd", [])
page.wait_for_timeout(700)
names, _ = _log(page)
assert ("tb-box", "contextmenu") not in names, names
assert ("tb-box", "click") in names, names
# ③ 두 번 톡
page.wait_for_timeout(400)
for _ in range(2):
_touch(cdp, "touchStart", [(100, 200)])
_touch(cdp, "touchEnd", [])
page.wait_for_timeout(80)
page.wait_for_timeout(600)
names, _ = _log(page)
assert names.count(("tb-box", "dblclick")) == 1, names
# ④ 두 손가락 벌림 · 오므림
def pinch(box_x, start, end):
_touch(cdp, "touchStart", [(box_x - start, 210), (box_x + start, 210)])
steps = 8
for i in range(1, steps + 1):
d = start + (end - start) * i / steps
_touch(cdp, "touchMove", [(box_x - d, 210), (box_x + d, 210)])
_touch(cdp, "touchEnd", [])
page.wait_for_timeout(200)
return _log(page)
names, raw = pinch(110, 20, 80)
wheels = [e for e in raw if e["t"] == "wheel" and e["id"] == "tb-box"]
assert wheels and all(e["ctrl"] and e["dy"] < 0 for e in wheels), raw
names, raw = pinch(110, 80, 20)
wheels = [e for e in raw if e["t"] == "wheel" and e["id"] == "tb-box"]
assert wheels and all(e["ctrl"] and e["dy"] > 0 for e in wheels), raw
# ⑤ 자체 처리 상자 — 길게 누름 · 벌림 번역 없음
page.wait_for_timeout(400)
_touch(cdp, "touchStart", [(300, 200)])
page.wait_for_timeout(700)
_touch(cdp, "touchEnd", [])
names, raw = pinch(310, 20, 70)
assert not [e for e in raw if not e["trusted"] and e["id"] == "tb-own"], raw
# ⑥ 마우스 — 우클릭 · 더블클릭은 브라우저 것 하나씩만
page.mouse.click(100, 200, button="right")
page.mouse.dblclick(100, 200)
page.wait_for_timeout(500)
_, raw = _log(page)
assert all(e["trusted"] for e in raw), json.dumps(raw)
browser.close()
+4
View File
@@ -105,6 +105,10 @@ export const ui_locales_common = {
App_TouchMode_Auto: ["자동", "Auto"],
App_TouchMode_On: ["켬", "On"],
App_TouchMode_Off: ["끔", "Off"],
App_Touch_LongPressHint: [
"그림 위를 길게 누르면 메뉴 · 두 손가락으로 확대",
"Long-press the drawing for the menu · pinch to zoom",
],
App_Footer_Copyright: [
"© 2026 임도설계 및 견적 자동화. All rights reserved.",
"© 2026 Forest Road Design & Estimation. All rights reserved.",