diff --git a/A00_Common/b_svg_zoom_pan.ts b/A00_Common/b_svg_zoom_pan.ts index 256cbe96b..1fc026412 100644 --- a/A00_Common/b_svg_zoom_pan.ts +++ b/A00_Common/b_svg_zoom_pan.ts @@ -15,7 +15,7 @@ * 그리는 일은 안 한다 — 넘겨받은 `layer` 에 `transform` 만 적는다. * ========================================================================== */ -import { wheelEventIn } from "./input_prefs"; +import { isPanButton, swallowNextMenu, wheelEventIn } from "./input_prefs"; /** 줌·팬 상태 — 다시 그릴 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */ export interface ZoomPanState { @@ -75,7 +75,7 @@ export interface SvgZoomPanOptions { /** 휠을 줌에 묶을지(기본 안 묶음). 묶으면 **커서 아래 지점**을 붙잡고 확대한다. * `"ctrl"` = Ctrl+휠만 줌 · 맨 휠은 페이지 스크롤에 둠. */ wheelZoom?: boolean | "ctrl"; - /** 팬을 시작할 누름인지 — 안 주면 가운데 버튼만. 고르기가 없는 빈 곳 왼쪽 끌기를 더할 때 쓴다. */ + /** 팬을 시작할 누름인지 — 안 주면 ☰ [설정] 옮기기 버튼(가운데는 늘). 고르기가 없는 빈 곳 왼쪽 끌기를 더할 때 쓴다. */ panStart?: (event: PointerEvent) => boolean; } @@ -166,9 +166,14 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { svg.addEventListener("pointerdown", (event) => { // 누름마다 풂 — 가운데 단추 팬 뒤엔 click 이 안 와 `moved` 가 남아 왼쪽 누름을 삼켰음(259-1) moved = false; - // 팬은 **가운데 버튼**만(기본). 좌클릭은 그 화면의 고르기 몫이다. - if (!(options.panStart ? options.panStart(event) : event.button === 1)) return; - event.preventDefault(); + // 팬 = ☰ [설정] 옮기기 버튼(가운데는 늘 · 588-4) · 왼쪽이면 안 도형이 잡은 누름은 비킴 · 끈 뒤 click 은 아래서 막음 + const claimed = + event.button === 0 && + (event.defaultPrevented || + (event.target as Element | null)?.hasPointerCapture?.(event.pointerId)); + if (!(options.panStart ? options.panStart(event) : isPanButton(event.button) && !claimed)) + return; + if (event.button !== 0) event.preventDefault(); // 왼쪽은 안 도형의 mousedown 을 살림 panning = true; moved = false; lastX = event.clientX; @@ -191,6 +196,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { const endPan = (event: PointerEvent): void => { if (!panning) return; panning = false; + if (moved && event.button === 2) swallowNextMenu(); // 오른쪽 끌어 옮김 뒤 메뉴 안 뜸 svg.classList.remove("is-panning"); try { svg.releasePointerCapture(event.pointerId); diff --git a/A00_Common/input_prefs.ts b/A00_Common/input_prefs.ts index fee8b3e38..d80e7ea96 100644 --- a/A00_Common/input_prefs.ts +++ b/A00_Common/input_prefs.ts @@ -86,9 +86,35 @@ export const panButton = (): PanButton => inputPrefs().panButton; export const isPanButton = (button: number): boolean => button === 1 || button === PAN_BUTTONS.indexOf(panButton()); +/** 왼쪽 아닌 옮기기 버튼(가운데 · 오른쪽을 고른 때) — 왼쪽이 고르기 · 돌림 몫인 화면용 */ +export const isAuxPanButton = (button: number): boolean => button !== 0 && isPanButton(button); + /** 단추 번호 → `buttons` 비트(왼 1 · 오른 2 · 가운데 4) — 끄는 중 놓침 알아채기 */ export const buttonMask = (button: number): number => [1, 4, 2][button] ?? 0; +/** 이만큼(px) 끌어야 옮김 — 덜 움직이고 떼면 그 단추 제 몫(고름 · 메뉴 · Enter) */ +export const PAN_MOVE_PX = 4; + +/** 오른쪽 끌기로 옮긴 뒤 놓을 때 뜨는 메뉴를 한 번 막음 */ +export function swallowNextMenu(): void { + const stop = (event: Event): void => { + event.preventDefault(); + event.stopImmediatePropagation(); + }; + globalThis.addEventListener("contextmenu", stop, { capture: true, once: true }); + setTimeout(() => globalThis.removeEventListener("contextmenu", stop, true), 400); +} + +/** 오른쪽 누름으로 옮기기를 시작할 때 부름 — 끌고 놓으면 그 뒤 메뉴를 막음 */ +export function guardMenuAfterDrag(down: PointerEvent): void { + if (down.button !== 2) return; + const up = (event: PointerEvent): void => { + const moved = Math.hypot(event.clientX - down.clientX, event.clientY - down.clientY); + if (moved > PAN_MOVE_PX) swallowNextMenu(); + }; + globalThis.addEventListener("pointerup", up, { capture: true, once: true }); +} + /** 휠 한 번이 확대인가 — 설정 방향을 따름 */ export const wheelIn = (deltaY: number): boolean => inputPrefs().wheelZoom === "pull" ? deltaY > 0 : deltaY < 0; diff --git a/B04_PreProcess/B04_PreProcess_UI_Camera.ts b/B04_PreProcess/B04_PreProcess_UI_Camera.ts index 232f05569..1af9b1497 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Camera.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Camera.ts @@ -1,7 +1,7 @@ import * as THREE from "three"; import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import type { SurfaceBounds } from "./B04_PreProcess_Api_Fetch"; -import { wheelEventIn } from "../A00_Common/input_prefs"; +import { guardMenuAfterDrag, isAuxPanButton, wheelEventIn } from "../A00_Common/input_prefs"; export const SURFACE_CAMERA_FOV = 50; const LIGHT_VIEWER_BACKGROUND = 0xf5f7f9; @@ -224,7 +224,9 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void // 가운데 버튼은 브라우저 기본 동작(페이지 자동 스크롤)이 팬과 겹치므로 막는다. if (event.button === 1) event.preventDefault(); if (options.blocked?.() || !controls.enabled) return; - if (event.button === 1 && panPointerId === null) { + // 그랩 팬 = 가운데 · ☰ [설정] 옮기기 버튼이 오른쪽이면 오른쪽도(왼쪽은 늘 돌림 · 588-4) + if (isAuxPanButton(event.button) && panPointerId === null) { + guardMenuAfterDrag(event); // 그랩 팬 시작 — 커서 아래 지형점을 잡고, 시선 수직 평면 위에서 따라오게 한다. startGrabPan(event); panPointerId = event.pointerId; diff --git a/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts b/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts index b5321c224..742e62311 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts @@ -1,6 +1,7 @@ import { createButton } from "@ui/ui_template_elements"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { wheelZoomsIn } from "../A00_Common/touch_bridge"; +import { PAN_MOVE_PX, isPanButton, swallowNextMenu } from "../A00_Common/input_prefs"; import { createProgressCircle } from "@ui/ui_template_progress"; import { createToolbar } from "@ui/ui_template_toolbar"; import { @@ -611,8 +612,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { // 터치·펜은 팬도 겸하므로 여기서 반환하지 않고 기록만 남긴다 — 그래야 손가락으로도 마커를 // 고를 수 있다(끌었으면 이동량 판정에서 걸러진다). if (event.button === 0) clickStart = { x: event.clientX, y: event.clientY }; - if (event.pointerType === "mouse" && event.button !== 1) { - // 마우스 좌버튼은 선택 전용 — 팬을 시작하지 않는다. + if (event.pointerType === "mouse" && !isPanButton(event.button)) { + // 팬 = ☰ [설정] 옮기기 버튼(가운데는 늘 · 588-4) — 왼쪽이면 끌지 않고 뗀 것은 위 고르기 그대로. return; } dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY }; @@ -651,7 +652,12 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { offsetY = dragStart.offsetY + event.clientY - dragStart.y; scheduleDraw(); }); - const stopDragging = (): void => { + const stopDragging = (event?: PointerEvent): void => { + // 오른쪽 끌어 옮김 뒤 메뉴 안 뜸(588-4) + if (dragStart && event?.button === 2) { + const moved = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y); + if (moved > PAN_MOVE_PX) swallowNextMenu(); + } dragStart = null; viewport.style.removeProperty("cursor"); }; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Interact.ts b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts index 8818be515..f5bf796fe 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Interact.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts @@ -9,6 +9,7 @@ * ========================================================================== */ import { wheelZoomsIn } from "../A00_Common/touch_bridge"; +import { PAN_MOVE_PX, isPanButton, swallowNextMenu } from "../A00_Common/input_prefs"; import { computeMaxScale, lonLatToScreen, @@ -116,7 +117,8 @@ export function bindDrainageInteractions(params: DrainageInteractParams): void { // 팬은 가운데(휠) 버튼 전용이다. 좌버튼은 유역선 핸들·배관 마커를 고르고 끄는 데만 쓴다 // (좌버튼이 팬까지 겸하면 마커를 집으려다 지도가 딸려 움직인다 — 2026-08-01 사용자 지시). // 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 할 수 있게 둔다. - if (event.pointerType === "mouse" && event.button !== 1) return; + // 팬 = ☰ [설정] 옮기기 버튼(가운데는 늘 · 588-4) — 왼쪽이면 끌지 않고 뗀 것은 유역 고르기 그대로. + if (event.pointerType === "mouse" && !isPanButton(event.button)) return; const offset = params.getOffset(); dragStart = { x: event.clientX, y: event.clientY, offsetX: offset.x, offsetY: offset.y }; viewport.style.cursor = "grabbing"; @@ -169,6 +171,10 @@ export function bindDrainageInteractions(params: DrainageInteractParams): void { basinClickStart = null; const dragged = pipeClickStart !== null; pipeClickStart = null; + if (dragStart && event.button === 2) { + const moved = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y); + if (moved > PAN_MOVE_PX) swallowNextMenu(); // 오른쪽 끌어 옮김 뒤 메뉴 안 뜸(588-4) + } if (!dragged && start && event.button === 0) { const rect = viewport.getBoundingClientRect(); // 끌었으면 지도 조작이지 고르기가 아니다. diff --git a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts index cdbfef24f..65db81545 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts @@ -19,6 +19,7 @@ * ========================================================================== */ import { viewOf } from "../common_util/common_util_view_window"; +import { guardMenuAfterDrag, isPanButton } from "../A00_Common/input_prefs"; /** 프레임마다 남은 거리에서 좁히는 비율. 0.35면 60fps에서 약 0.13초에 도착한다 — * 눈으로는 이어져 보이면서, 멈춘 뒤 세로 눈금이 따라오는 시간도 예전만큼 짧다. */ @@ -170,8 +171,10 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void { } scroller.addEventListener("pointerdown", (event) => { - if (dragId !== null || (event.button !== 0 && event.button !== 1)) return; + // 왼쪽 · 가운데 · ☰ [설정] 옮기기 버튼(오른쪽이면 끈 뒤 메뉴 안 뜸 · 588-4) + if (dragId !== null || (event.button !== 0 && !isPanButton(event.button))) return; if ((event.target as HTMLElement | null)?.closest(DRAG_BLOCK_SELECTOR)) return; + guardMenuAfterDrag(event); // 끌기가 시작되면 휠 관성 이동은 접는다 — 둘이 같은 scrollLeft를 다투면 튄다. stop(); dragId = event.pointerId; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 990682ee0..fb6b23a88 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -576,7 +576,6 @@ export async function mountRouteEditor( }, }); - // 확대·이동은 배수유역도와 같은 동작으로 — 휠은 당기면 확대, 팬은 가운데 버튼 전용. bindRouteEditNavigation({ canvas, getView: () => view, @@ -587,6 +586,7 @@ export async function mountRouteEditor( // 돌린 지도에서는 손이 민 방향과 그림이 움직일 방향이 다르다 — 거꾸로 돌려 넘긴다. unrotateDelta: rotation.unrotateDelta, rotateWheel: rotation.wheel, // Ctrl+휠 = 5° 돌리기(390-3) + claimed: pointer.claimed, // 왼쪽 옮기기 = 노드 · 손잡이 · 마커 · 재기 아닌 누름만(588-4) draw, }); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts index 8966c080f..59e14f86a 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -6,15 +6,22 @@ * (2026-09-07 사용자 지시 ⑤). 한쪽만 고치면 두 지도가 서로 다르게 움직인다. * · 휠을 **당기면 확대**(`deltaY > 0` → 확대) · 계수 1.15 / 0.87 * · 배율 상한은 「화면 폭 16m」로 계산(`computeMaxScale`), 하한 0.5 - * · **팬은 가운데(휠) 버튼 전용** — 왼쪽 버튼이 팬까지 겸하면 노드를 집으려다 지도가 - * 딸려 움직인다. 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 한다. + * · **팬 = ☰ [설정] 화면 옮기기 버튼**(기본 가운데 · 588-4) — 왼쪽을 고르면 페이지가 안 잡은 누름만 + * (노드 · 마커 · 그리기는 그대로) · 오른쪽은 끈 뒤 메뉴 안 뜸 · 덜 끌고 떼면 그 버튼 제 몫. + * 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 한다. * * ⚠ 커서 고정은 **화면 중심 기준**으로 셈한다 — `affineOf` 가 `centerX*(1−scale)` 을 품고 * 있어 좌상단 기준으로 셈하면 확대할수록 커서 아래 지점이 밀린다(옛 모달의 버그). * ========================================================================== */ import { isPinchWheel, pickPx, wheelZoomsIn } from "../A00_Common/touch_bridge"; -import { isTrackpadPinch } from "../A00_Common/input_prefs"; +import { + PAN_MOVE_PX, + buttonMask, + isPanButton, + isTrackpadPinch, + swallowNextMenu, +} from "../A00_Common/input_prefs"; 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"; @@ -36,8 +43,10 @@ export interface RouteEditNavigationParams { /** 돌린 지도 보정 — 화면에서 민 만큼(dx, dy)을 **그림 좌표의 만큼**으로 바꾼다. * 안 주면 안 돌린 것으로 본다(배수유역도는 회전이 없다). */ unrotateDelta?: (dx: number, dy: number) => [number, number]; - /** 마우스 왼쪽 버튼으로도 팬 — 집을 노드가 없는 미리 보기 지도용. 편집 창은 가운데 버튼만. */ + /** 마우스 왼쪽 버튼으로도 팬 — 집을 노드가 없는 미리 보기 지도용. 편집 창은 설정 버튼만. */ leftPan?: boolean; + /** 왼쪽이 옮기기 버튼일 때 페이지가 그 누름을 잡았나(노드 · 마커) — 안 주면 포인터를 잡은 겹이 있으면 잡음 */ + claimed?: () => boolean; /** Ctrl+휠 = 돌리기(390-3) — 안 주면 Ctrl+휠도 확대(추천 지도). 손가락 · 트랙패드 벌림은 늘 확대. */ rotateWheel?: (sign: 1 | -1) => void; draw: () => void; @@ -49,7 +58,14 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void const unrotateDelta = params.unrotateDelta ?? ((dx: number, dy: number) => [dx, dy]); /** 팬 중인 포인터 — 직전 자리만 쥔다(401). 누를 때 offset 을 쥐고 「처음 + 민 만큼」으로 덮으면 * 끄는 중 휠 확대 · Ctrl+휠 회전이 바꾼 화면을 옛 값이 덮어 지도가 튄다 → 걸음마다 지금 화면에 더함. */ - let drag: { id: number; x: number; y: number; mask: number } | null = null; + let drag: { + id: number; + x: number; + y: number; + mask: number; + button: number; + moved: number; + } | null = null; canvas.addEventListener( "wheel", @@ -87,20 +103,21 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void canvas.addEventListener("pointerdown", (event) => { // 가운데 버튼 기본 동작(페이지 자동 스크롤)이 팬과 겹치지 않게 막는다. if (event.button === 1) event.preventDefault(); - // 마우스는 가운데 버튼만 팬이다 — 왼쪽 버튼은 노드·손잡이 조작 몫. - if ( - event.pointerType === "mouse" && - event.button !== 1 && - !(params.leftPan && event.button === 0) - ) - return; + // 마우스는 설정 버튼(가운데는 늘)만 팬 — 왼쪽이면 페이지(노드 · 손잡이 · 겹)가 안 잡은 누름만. + const mouse = event.pointerType === "mouse"; + const left = event.button === 0 && !!params.leftPan; + if (mouse && !left && !isPanButton(event.button)) return; + const claimed = params.claimed ?? (() => canvas.hasPointerCapture(event.pointerId)); + if (mouse && event.button === 0 && !left && claimed()) return; if (drag) return; // 둘째 손가락은 팬을 넘겨받지 않음(벌림 확대 몫) - // 쥔 단추(buttons 비트) — 가운데 4 · 왼쪽 1. 놓침(창 밖에서 놓고 들어옴)을 move 에서 알아챔. + // 쥔 단추(buttons 비트). 놓침(창 밖에서 놓고 들어옴)을 move 에서 알아챔. drag = { id: event.pointerId, x: event.clientX, y: event.clientY, - mask: event.button === 1 ? 4 : 1, + mask: mouse ? buttonMask(event.button) : 1, + button: mouse ? event.button : -1, + moved: 0, }; canvas.style.cursor = "grabbing"; canvas.setPointerCapture(event.pointerId); @@ -110,6 +127,7 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void if (!drag || event.pointerId !== drag.id) return; if (event.pointerType === "mouse" && !(event.buttons & drag.mask)) return stop(); const [dx, dy] = unrotateDelta(event.clientX - drag.x, event.clientY - drag.y); + drag.moved += Math.abs(event.clientX - drag.x) + Math.abs(event.clientY - drag.y); drag.x = event.clientX; drag.y = event.clientY; const view = getView(); @@ -119,6 +137,7 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void const stop = (event?: PointerEvent): void => { if (!drag || (event && event.pointerId !== drag.id)) return; + if (drag.button === 2 && drag.moved > PAN_MOVE_PX) swallowNextMenu(); // 끈 뒤 메뉴 안 뜸 drag = null; canvas.style.removeProperty("cursor"); }; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index c5c0c2e11..b0aed6601 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -116,6 +116,8 @@ export interface RouteEditPointerParams { export interface RouteEditPointer { /** 노드나 손잡이를 끌고 있나 — 끄는 동안 기울기 라벨을 숨긴다. */ dragging: () => boolean; + /** 마지막 왼쪽 누름을 잡았나(재기 · 마커 · 노드 · 손잡이) — 왼쪽 옮기기(588-4)가 비킴 */ + claimed: () => boolean; nodeAt: (px: number, py: number) => number; segmentAt: (px: number, py: number) => number; } @@ -213,6 +215,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP let dragOff = 0; /** 구조물 측점 마커(399) — 집기 차례 = 재기 다음 맨 앞(409) · 끌기 · 방향키 1 m. */ const marks = bindStructureMarks(params, () => dragNode >= 0 || dragHandle !== null); + let claimed = false; bindPlanStructureMenu(params, nodeAt); // 우클릭 = 종단 그래프와 같은 구조물 메뉴(513-1) /** 누름 · 맴돔 한 판정 — 노드 가운데 6 px = 노드 · 밖 = 마커 · 노드 · 손잡이 중 가까운 것(`pressWinner`). */ const chooseAt = (px: number, py: number) => { @@ -234,6 +237,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP canvas.addEventListener("pointerdown", (event) => { if (event.button !== 0) return; + claimed = true; // 왼쪽 옮기기(588-4)는 잡은 누름(재기 · 마커 · 노드 · 손잡이)에선 안 함 const rect = canvas.getBoundingClientRect(); const [px, py] = params.unrotate(event.clientX - rect.left, event.clientY - rect.top); if (event.shiftKey || params.measureMode()) { @@ -270,6 +274,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP dragOff = params.offStation(); dragHandle = choice.handle; dragMoved = false; + claimed = dragNode >= 0 || dragHandle !== null; if (hitNode >= 0) { params.setPicked(hitNode); // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다. params.measure.clear(); // 잰 창과 곡선 패널은 같이 뜨지 않는다(㉔). @@ -504,5 +509,10 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP canvas.addEventListener("pointercancel", endDrag); bindRouteEditKeys(params, () => dragNode >= 0 || dragHandle !== null); // 교각점 방향키(383-2) - return { dragging: () => dragNode >= 0 || dragHandle !== null, nodeAt, segmentAt }; + return { + dragging: () => dragNode >= 0 || dragHandle !== null, + claimed: () => claimed, + nodeAt, + segmentAt, + }; } diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts index afae89f51..105276e07 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts @@ -32,7 +32,13 @@ import { import { Tool } from '../tools.ts'; import { SelectState } from '../tools/select-tool.ts'; import { ActorEvent, type MouseClickEvent } from '../tools/tool.types.ts'; -import { activeToolWantsFreeText, activeToolWantsIme, ImeSink, isImeKey, isPlainSpace } from './ime-sink.ts'; +import { + activeToolWantsFreeText, + activeToolWantsIme, + ImeSink, + isImeKey, + isPlainSpace, +} from './ime-sink.ts'; import { drawInputField, dynamicInputLine } from './input-field-draw.ts'; import { CAD_KEY_COMMANDS, cadKeyAction } from './input-shortcut.ts'; import { wheelZoom } from './input-wheel.ts'; @@ -40,6 +46,7 @@ import { isObjectSnap, noteShift, snapAt } from './ortho.ts'; import { isWaitingForSecondSelectPoint, selectingActor } from './selecting.ts'; import { openSnapMenu } from './snap-menu.ts'; import { isMiddleDouble, zoomExtents } from './zoom-prompt.ts'; +import { isPanButton, PAN_MOVE_PX } from '../../../../A00_Common/input_prefs'; import { attachTouch } from './input-touch.ts'; import { NUMBER_REGEXP, type PointTextEvent } from './input-patterns.ts'; import { commandCandidate, pushInput, recallInput } from './input-history.ts'; @@ -82,6 +89,8 @@ export class InputController { private candidateIndex = 0; /** 왼쪽 버튼을 누른 화면 좌표 — 놓을 때 끌었는지 판단한다 */ private leftPressLocation: Point | null = null; + /** ☰ [설정] 옮기기 버튼이 왼쪽 · 오른쪽일 때 누른 자리(588-4) — 끌면 옮김 · 안 끌고 떼면 그 버튼 제 몫 */ + private panPress: { button: number; at: Point } | null = null; public readonly ime = new ImeSink((data) => { this.text += data; }); @@ -168,6 +177,21 @@ export class InputController { } public handleMouseUp(evt: MouseEvent) { + const pan = this.panPress; + if (pan && evt.button === pan.button) { + this.panPress = null; + setPanStartLocation(null); + const at = this.getCanvasPoint(evt); + if (Math.hypot(at.x - pan.at.x, at.y - pan.at.y) > PAN_MOVE_PX) { + evt.preventDefault(); // 끌어 옮김 — Enter · 클릭 안 보냄 + return; + } + if (evt.button === MouseButton.Left) { + this.sendMouseClick(evt); + this.ime.sync(activeToolWantsIme()); + return; + } + } if (evt.button === MouseButton.Right) { // 우클릭 = Enter(명령 중 확정 · 쉴 때 반복) · Shift+우클릭 = 점 물음에서 한 번 스냅 메뉴 evt.preventDefault(); @@ -319,6 +343,17 @@ export class InputController { setPanStartLocation(this.getCanvasPoint(evt)); return; } + if ( + isPanButton(evt.button) && + !isViewOnly() && + !(evt.target as HTMLElement | null)?.closest('.controls') + ) { + // 왼쪽 · 오른쪽 옮기기 — 끌면 옮김 · 떼는 자리에서 클릭(왼쪽) · Enter(오른쪽) + evt.preventDefault(); + this.panPress = { button: evt.button, at: this.getCanvasPoint(evt) }; + setPanStartLocation(this.panPress.at); + return; + } if (evt.button !== MouseButton.Left) return; if ((evt.target as HTMLElement | null)?.closest('.controls')) return; if (isViewOnly()) { diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index c2d933641..90af6bcab 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -22,6 +22,7 @@ import { createButton, createSelectField, el, showToast } from "@ui/ui_template_ import { createFloatPanel } from "@ui/ui_template_float_panel"; import { createToolbar } from "@ui/ui_template_toolbar"; import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; +import { isAuxPanButton } from "../A00_Common/input_prefs"; import { midKm, roadOf, @@ -476,7 +477,8 @@ export function createDiagram( onChange: (state) => (zoomState = state), wheelZoom: true, content: panContent(vw, vh), // 원배율에서도 팬(244-6 · 범위 없으면 1배는 안 움직임) - panStart, // 가운데 버튼 · 빈 곳 왼쪽 끌기 + // 가운데 버튼 · 빈 곳 왼쪽 끌기 · ☰ [설정] 옮기기 버튼(588-4) + panStart: (e) => panStart(e) || isAuxPanButton(e.button), }); }; refresh(); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts index 453ef5ff0..51c8363f0 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts @@ -8,6 +8,7 @@ * ========================================================================== */ import { wheelZoomsIn } from "../../A00_Common/touch_bridge"; +import { guardMenuAfterDrag, isAuxPanButton } from "../../A00_Common/input_prefs"; import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; import type { CfBox, CfKind, CfLogic, CfPill, CfPort } from "./M02_DF_Model"; import { @@ -467,7 +468,8 @@ export function renderDiagram( (ev) => { // 손가락 빈 곳 끌기도 판 옮기기(기기대응 3-3) const touchBlank = ev.pointerType === "touch" && (ev.target === view || ev.target === world); - if (ev.button === 1 || (ev.button === 0 && spaceDown) || touchBlank) { + if (isAuxPanButton(ev.button) || (ev.button === 0 && spaceDown) || touchBlank) { + guardMenuAfterDrag(ev); ev.preventDefault(); ev.stopPropagation(); dragPan(ev); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts index 1a749344e..064e2f191 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts @@ -8,6 +8,7 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { wheelZoomsIn } from "../A00_Common/touch_bridge"; +import { guardMenuAfterDrag, isAuxPanButton } from "../A00_Common/input_prefs"; import { createButton, el } from "@ui/ui_template_elements"; import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; import "./M02_MasterTemplete_Labor_Diagram.css"; @@ -282,10 +283,11 @@ function mountShell(host: HTMLElement): State { }), viewport, ); - // 판 옮기기 — 가운데 단추 · Space + 왼쪽 끌기 · 손가락 빈 곳 끌기(기기대응 3-3) + // 판 옮기기 — 가운데 · ☰ [설정] 옮기기 버튼(588-4) · Space + 왼쪽 끌기 · 손가락 빈 곳 끌기(기기대응 3-3) viewport.addEventListener("pointerdown", (e) => { const touchBlank = e.pointerType === "touch" && (e.target === viewport || e.target === world); - if (!(e.button === 1 || (e.button === 0 && st.space) || touchBlank)) return; + if (!(isAuxPanButton(e.button) || (e.button === 0 && st.space) || touchBlank)) return; + guardMenuAfterDrag(e); e.preventDefault(); try { viewport.setPointerCapture(e.pointerId); diff --git a/resources/tester/cad/input-prefs-cad.test.ts b/resources/tester/cad/input-prefs-cad.test.ts index 6ff3723a8..44f4bc110 100644 --- a/resources/tester/cad/input-prefs-cad.test.ts +++ b/resources/tester/cad/input-prefs-cad.test.ts @@ -3,6 +3,7 @@ * 휠 = 기본 밂(앞으로 굴리면 확대 · 오토캐드 · 337) · 당김 저장 뒤 뒤집힘 · 핀치는 늘 벌리면 확대. * 별칭 = 명령 이름 > 내 별칭 > 기본 별칭 · 저장 즉시 따름. * 바꾼 키(588-3) = 새 키만 먹음 · 옛 키는 안 먹음 · 한글 자판도 자판 자리로. + * 옮기기 버튼(588-4) = 오른쪽을 고르면 오른쪽 끌기로 화면 옮김 · 기본(가운데)이면 오른쪽 끌기는 안 옮김. * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad input-prefs-cad */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -12,7 +13,12 @@ import { matchCommandPrefixes, resolveCommandInput, } from "../../../B07_DesignDetail/openwebcad/src/commands/registry"; -import { getActiveToolActor, getSnapEnabled } from "../../../B07_DesignDetail/openwebcad/src/state"; +import { + getActiveToolActor, + getPanStartLocation, + getScreenCanvasDrawController, + getSnapEnabled, +} from "../../../B07_DesignDetail/openwebcad/src/state"; import { initApplication } from "../../../B07_DesignDetail/openwebcad/test/helpers/init-application"; const store = new Map(); @@ -128,3 +134,33 @@ describe("바꾼 키(588-3)", () => { expect(getSnapEnabled()).toBe(before); }); }); + +describe("옮기기 버튼(588-4)", () => { + const mouse = (button: number, x: number, y = 100) => + ({ + button, + clientX: x, + clientY: y, + shiftKey: false, + ctrlKey: false, + target: null, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }) as unknown as MouseEvent; + const rightDrag = (controller: ReturnType) => { + const before = getScreenCanvasDrawController().getScreenOffset().clone(); + controller.handleMouseDown(mouse(2, 100)); + controller.handleMouseMove(mouse(2, 160)); + controller.handleMouseUp(mouse(2, 160)); + const after = getScreenCanvasDrawController().getScreenOffset(); + return Math.hypot(after.x - before.x, after.y - before.y); + }; + + it("기본(가운데) = 오른쪽 끌기 안 옮김 · 오른쪽 고름 = 옮김 · 놓으면 끝", () => { + const controller = initApplication(); + expect(rightDrag(controller)).toBe(0); + savePrefs({ panButton: "right" }); + expect(rightDrag(controller)).toBeGreaterThan(0); + expect(getPanStartLocation()).toBeNull(); + }); +}); diff --git a/resources/tester/helper_230_1_popout_wheel.cjs b/resources/tester/helper_230_1_popout_wheel.cjs index b05bb981b..223e8c1f3 100644 --- a/resources/tester/helper_230_1_popout_wheel.cjs +++ b/resources/tester/helper_230_1_popout_wheel.cjs @@ -82,6 +82,7 @@ globalThis.ResizeObserver = class { const viewWindow = load(path.join(ROOT, "common_util", "common_util_view_window.ts")); const wheelMod = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Wheel.ts"), { "../common_util/common_util_view_window": viewWindow, + "../A00_Common/input_prefs": load(path.join(ROOT, "A00_Common", "input_prefs.ts")), }); const viewport = load(path.join(ROOT, "A00_Common", "design", "design_profile_viewport.ts"), { "../../B05_Profile/B05_Profile_UI_Profile_Wheel": wheelMod, diff --git a/resources/tester/helper_369_3_update_press.cjs b/resources/tester/helper_369_3_update_press.cjs index 41ecd61ea..4f8ba6d86 100644 --- a/resources/tester/helper_369_3_update_press.cjs +++ b/resources/tester/helper_369_3_update_press.cjs @@ -36,6 +36,11 @@ global.Element = Element; const stubs = { "../A00_Common/touch_bridge": { wheelZoomsIn: () => true }, + "../A00_Common/input_prefs": { + isPanButton: (b) => b === 1, + PAN_MOVE_PX: 4, + swallowNextMenu() {}, + }, "../B04_PreProcess/B04_PreProcess_UI_MapRender": { computeMaxScale: () => 10, lonLatToScreen: () => ({ x: 0, y: 0 }), diff --git a/resources/tester/test_588_key_registry.py b/resources/tester/test_588_key_registry.py index d580f86ea..34d1f6f1b 100644 --- a/resources/tester/test_588_key_registry.py +++ b/resources/tester/test_588_key_registry.py @@ -129,3 +129,24 @@ def test_바꿀_수_있는_줄은_배선됨(r): def test_손글_안내_지움_표는_등록부(): assert "App_Settings_Keys_" not in _read("ui_template/ui_template_locale_common.ts") assert "App_Settings_Keys_" not in _read("A00_Common/app_settings.ts") + + +# 588-4 — 화면 옮기기 자리는 모두 설정 버튼을 따름(가운데 고정 판정이 남지 않음) +PAN_SITES = [ + "B05_Profile/B05_Profile_UI_RouteEdit_Input.ts", # B05 · B06 겹침 평면 · D03 지도 + "B07_DesignDetail/openwebcad/src/inputController/input-controller.ts", + "A00_Common/b_svg_zoom_pan.ts", + "B04_PreProcess/B04_PreProcess_UI_MapViewer.ts", + "B04_PreProcess/B04_PreProcess_UI_Camera.ts", + "B05_Profile/B05_Profile_UI_Drainage_Interact.ts", + "B05_Profile/B05_Profile_UI_Profile_Wheel.ts", + "B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts", + "M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts", + "M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts", +] + + +@pytest.mark.parametrize("rel", PAN_SITES) +def test_옮기기_자리는_설정_버튼(rel): + src = _read(rel) + assert "isPanButton(" in src or "isAuxPanButton(" in src, rel