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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user