From 68a96e253f058f81b4889abc1ce7048a0386a021 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 13:28:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(device):=20=EC=86=90=EA=B0=80=EB=9D=BD=20?= =?UTF-8?q?=E2=86=92=20=EB=A7=88=EC=9A=B0=EC=8A=A4=20=EB=B2=88=EC=97=AD=20?= =?UTF-8?q?=E2=80=94=20=EA=B8=B8=EA=B2=8C=20=EB=88=84=EB=A6=84=20=3D=20?= =?UTF-8?q?=EC=9A=B0=ED=81=B4=EB=A6=AD=20=C2=B7=20=EB=91=90=20=EB=B2=88=20?= =?UTF-8?q?=ED=86=A1=20=3D=20=EB=8D=94=EB=B8=94=ED=81=B4=EB=A6=AD=20=C2=B7?= =?UTF-8?q?=20=EB=B2=8C=EB=A6=BC=20=3D=20Ctrl+=ED=9C=A0(touch=5Fbridge)=20?= =?UTF-8?q?=C2=B7=203D=20=EB=91=90=20=EC=86=90=EA=B0=80=EB=9D=BD=20?= =?UTF-8?q?=ED=99=95=EB=8C=80=20=C2=B7=20=EC=9D=B4=EB=8F=99=20=C2=B7=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20SVG=20=ED=99=95=EB=8C=80=20=EB=91=90=20?= =?UTF-8?q?=EC=86=90=EA=B0=80=EB=9D=BD=20=C2=B7=20=EB=8B=B9=EA=B8=B0?= =?UTF-8?q?=EB=A9=B4=20=ED=99=95=EB=8C=80=20=EC=A7=80=EB=8F=84=20=EB=B2=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=B0=A9=ED=96=A5=20=C2=B7=20M02=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=BC=ED=8C=90=20=EC=86=90=EA=B0=80=EB=9D=BD=20=EB=81=8C?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=8B=9C=ED=8A=B8=20=E2=8B=AF=20=EC=B9=B8?= =?UTF-8?q?=20=EB=A9=94=EB=89=B4=20=C2=B7=20=EA=B7=B8=EB=A6=BC=20=EB=A9=B4?= =?UTF-8?q?=20=EC=95=88=EB=82=B4=20(=EA=B8=B0=EA=B8=B0=EB=8C=80=EC=9D=91?= =?UTF-8?q?=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- A00_Common/b_svg_zoom_pan.ts | 32 +++ A00_Common/main.ts | 1 + A00_Common/spreadsheet/spreadsheet_text.ts | 1 + .../spreadsheet/spreadsheet_toolbar.css | 9 + A00_Common/spreadsheet/spreadsheet_toolbar.ts | 26 +- A00_Common/touch_bridge.ts | 226 ++++++++++++++++++ B04_PreProcess/B04_PreProcess_UI_Camera.ts | 98 ++++++-- B04_PreProcess/B04_PreProcess_UI_MapViewer.ts | 3 +- .../B05_Profile_UI_Drainage_Interact.ts | 3 +- B05_Profile/B05_Profile_UI_RouteEdit_Input.ts | 3 +- M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts | 6 +- .../M02_MasterTemplete_Labor_Diagram.css | 7 + .../M02_MasterTemplete_Labor_Diagram.ts | 5 +- resources/tester/test_touch_bridge.py | 136 +++++++++++ ui_template/ui_template_locale_common.ts | 4 + 15 files changed, 532 insertions(+), 28 deletions(-) create mode 100644 A00_Common/touch_bridge.ts create mode 100644 resources/tester/test_touch_bridge.py diff --git a/A00_Common/b_svg_zoom_pan.ts b/A00_Common/b_svg_zoom_pan.ts index 72301e3d8..9590e576c 100644 --- a/A00_Common/b_svg_zoom_pan.ts +++ b/A00_Common/b_svg_zoom_pan.ts @@ -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(); + 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); diff --git a/A00_Common/main.ts b/A00_Common/main.ts index 2a84f9ce1..e7ea76d3e 100644 --- a/A00_Common/main.ts +++ b/A00_Common/main.ts @@ -10,6 +10,7 @@ import "./random_uuid_polyfill"; import "./device_context"; // 맨 앞 — 그리기 전에 기기 표시(기기대응 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"; diff --git a/A00_Common/spreadsheet/spreadsheet_text.ts b/A00_Common/spreadsheet/spreadsheet_text.ts index 0b097515f..f5cad79f0 100644 --- a/A00_Common/spreadsheet/spreadsheet_text.ts +++ b/A00_Common/spreadsheet/spreadsheet_text.ts @@ -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"], diff --git a/A00_Common/spreadsheet/spreadsheet_toolbar.css b/A00_Common/spreadsheet/spreadsheet_toolbar.css index 84bd546d5..3d9b08d9d 100644 --- a/A00_Common/spreadsheet/spreadsheet_toolbar.css +++ b/A00_Common/spreadsheet/spreadsheet_toolbar.css @@ -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; +} diff --git a/A00_Common/spreadsheet/spreadsheet_toolbar.ts b/A00_Common/spreadsheet/spreadsheet_toolbar.ts index d213b4a61..d9fccdb72 100644 --- a/A00_Common/spreadsheet/spreadsheet_toolbar.ts +++ b/A00_Common/spreadsheet/spreadsheet_toolbar.ts @@ -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), diff --git a/A00_Common/touch_bridge.ts b/A00_Common/touch_bridge.ts new file mode 100644 index 000000000..3b09d9a04 --- /dev/null +++ b/A00_Common/touch_bridge.ts @@ -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(); + +/** 손가락 벌림으로 만든 휠인지 — 「당기면 확대」 화면이 방향을 맞출 때 씀 */ +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(); +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 { + 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(); diff --git a/B04_PreProcess/B04_PreProcess_UI_Camera.ts b/B04_PreProcess/B04_PreProcess_UI_Camera.ts index b6c20751e..d5adca1d9 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Camera.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Camera.ts @@ -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(); + 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(); diff --git a/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts b/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts index 388ff9eca..9e4ab2d40 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts @@ -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). diff --git a/B05_Profile/B05_Profile_UI_Drainage_Interact.ts b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts index c3f1a57aa..22c3f6b8e 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Interact.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts @@ -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, diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts index d784e7422..2e3b6198d 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -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(); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts index 26dff53d5..76d4b028c 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts @@ -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); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.css b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.css index faf687b08..2d22d332f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.css @@ -312,3 +312,10 @@ .m02-labordg__card.is-material { cursor: default; } + +/* 손가락 기기 — 판이 끌기 · 두 손가락 확대를 받음(브라우저 확대 · 밀기 안 함 · 기기대응 3-2) */ +@media (any-pointer: coarse) { + .m02-labordg__viewport { + touch-action: none; + } +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts index c1194b438..b966ba85c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts @@ -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); diff --git a/resources/tester/test_touch_bridge.py b/resources/tester/test_touch_bridge.py new file mode 100644 index 000000000..ef4365988 --- /dev/null +++ b/resources/tester/test_touch_bridge.py @@ -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() diff --git a/ui_template/ui_template_locale_common.ts b/ui_template/ui_template_locale_common.ts index b78139c67..ed38a2035 100644 --- a/ui_template/ui_template_locale_common.ts +++ b/ui_template/ui_template_locale_common.ts @@ -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.",