feat(설정 마우스): 화면 옮기기 버튼 고르기 — 왼 · 가운데 · 오른 · 지도 · CAD · 종단 · 그림 칸 모두 따름 (PLAN 588-4 · 요구 445 C3)

- ☰ [설정] 마우스 탭 = 휠 방향 + 옮기기 버튼 라디오 · 규칙 글 = 고른 버튼 끌기 옮김(가운데는 늘)
- 끌면 옮김 · 덜 끌고 떼면 그 버튼 제 몫(고름 · 메뉴 · CAD Enter) · 오른쪽 끌기 뒤 메뉴 안 뜸
- 왼쪽 옮김은 페이지가 잡은 누름(B05 노드 · 손잡이 · 마커 · 재기 · D03 겹 · 그림 안 도형)에선 비킴
- 자리: B05 · B06 · D03 노선 지도 · CAD · 공용 SVG 확대 · B04 지도 · 3D(오른쪽만) · B05 배수 · 종단 가로 · B08 이정표 · M02 흐름도 · 노임
- 시험: CAD 오른쪽 끌기 옮김 · 옮기기 자리 배선 대조 · 낡은 헬퍼 둘 새 import 대응

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 19:08:40 +09:00
co-authored by Claude Opus 5.5
parent 2e4316567c
commit 883a098cd6
17 changed files with 216 additions and 34 deletions
+11 -5
View File
@@ -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);
+26
View File
@@ -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;
+4 -2
View File
@@ -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;
@@ -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");
};
@@ -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();
// 끌었으면 지도 조작이지 고르기가 아니다.
+4 -1
View File
@@ -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;
+1 -1
View File
@@ -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,
});
+33 -14
View File
@@ -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");
};
@@ -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,
};
}
@@ -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()) {
@@ -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();
@@ -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);
@@ -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);
+37 -1
View File
@@ -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<string, string>();
@@ -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<typeof initApplication>) => {
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();
});
});
@@ -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,
@@ -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 }),
+21
View File
@@ -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