From 3def4f18f00e8c370ad7e2abd8115e034172d6b8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 11:31:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EC=A2=85=EB=8B=A8=20=EA=B7=B8?= =?UTF-8?q?=EB=9E=98=ED=94=84=C2=B7=EC=9C=A0=ED=86=A0=EA=B3=A1=EC=84=A0?= =?UTF-8?q?=C2=B7=EC=B8=A1=EC=A0=90=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EB=81=8C=EC=96=B4=EC=84=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-09-06 사용자 지시. 좌클릭·휠 버튼으로 그림 위에서 바로 잡아 끈다. 공용 스크롤러 한 곳에 넣어 세 곳이 함께 얻음. 4px 문턱을 넘긴 뒤에만 이동으로 보고 그 경우에만 뒤따르는 click 을 한 번 삼켜, 측점 선택·선택 해제는 그대로 둠. 계획고 편집 버튼·리사이저 위에서 시작한 끌기는 제외(구조물 마크는 자기 pointerdown 에서 전파를 끊어 이미 제외됨). 끌기 시작 시 휠 관성 이동은 중단. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Profile_Wheel.ts | 77 +++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts index 5ee726db..4d989b9b 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts @@ -12,6 +12,10 @@ * 거리의 일정 비율씩 좁힌다. 목표가 늘어나도 속도가 이어지므로 굴릴수록 뒤처지지 않는다. * * Shift+휠은 브라우저 기본 가로 스크롤이라 건드리지 않는다. + * + * **끌어서 이동**(2026-09-06 사용자 지시) — 스크롤바를 잡지 않고 그림 위에서 바로 끌어 + * 옮긴다. 좌클릭·휠 버튼 둘 다 되며, 여기 한 곳만 고치면 이것을 쓰는 세 곳(종단 그래프· + * 유토곡선·측점 테이블)이 함께 얻는다. * ========================================================================== */ /** 프레임마다 남은 거리에서 좁히는 비율. 0.35면 60fps에서 약 0.13초에 도착한다 — @@ -19,6 +23,12 @@ const EASE = 0.35; /** 이보다 가까우면 도착으로 보고 딱 맞춘다(px). */ const SNAP_PX = 0.5; +/** 이만큼 넘게 움직여야 "끌기"로 본다(px). 그 아래는 클릭(측점 선택)으로 흘려보낸다. */ +const DRAG_THRESHOLD_PX = 4; +/** 끌기를 시작하면 안 되는 조작 부품 — 이 위에서 누른 것은 그 부품의 몫이다. + * 구조물 마크는 자기 `pointerdown`에서 전파를 끊으므로 여기 적지 않아도 된다. */ +const DRAG_BLOCK_SELECTOR = + "button, input, select, textarea, a, .ui-resizer, .b05-profile-edit__btn"; /** EASE의 기준 프레임 간격(ms) — 60Hz 한 칸. */ const BASE_FRAME_MS = 1000 / 60; /** 한 프레임으로 인정하는 최대 간격(ms). 탭이 잠들었다 깨면 간격이 수백 ms라 그대로 @@ -83,4 +93,71 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void { }, { passive: false }, ); + + /* ── 끌어서 이동 ───────────────────────────────────────────────────────── + * 좌클릭(0)·휠 버튼(1)으로 잡아 끈다. 문턱(4px)을 넘긴 뒤에야 이동으로 보고, 넘긴 + * 경우에만 뒤따르는 click을 한 번 삼킨다 — 그래야 "그림을 조금 움직였는데 측점 선택이 + * 풀리는" 일이 없다. 창 밖에서 손을 떼도 풀리도록 포인터를 잡아 둔다. + */ + let dragId: number | null = null; + let dragX = 0; + let dragY = 0; + let dragLeft = 0; + let dragTop = 0; + let dragMoved = false; + + function endDrag(): void { + if (dragId === null) return; + if (scroller.hasPointerCapture(dragId)) scroller.releasePointerCapture(dragId); + dragId = null; + scroller.style.cursor = ""; + // 끈 뒤의 click 한 번만 삼킨다(캡처 단계라 아래 리스너보다 먼저 잡는다). + if (dragMoved) { + const swallow = (event: MouseEvent): void => { + event.stopPropagation(); + event.preventDefault(); + }; + scroller.addEventListener("click", swallow, { capture: true, once: true }); + // 클릭이 안 오는 경우(휠 버튼 등)를 대비해 다음 프레임에 걷어낸다. + requestAnimationFrame(() => + scroller.removeEventListener("click", swallow, { capture: true }), + ); + } + dragMoved = false; + } + + scroller.addEventListener("pointerdown", (event) => { + if (dragId !== null || (event.button !== 0 && event.button !== 1)) return; + if ((event.target as HTMLElement | null)?.closest(DRAG_BLOCK_SELECTOR)) return; + // 끌기가 시작되면 휠 관성 이동은 접는다 — 둘이 같은 scrollLeft를 다투면 튄다. + stop(); + dragId = event.pointerId; + dragX = event.clientX; + dragY = event.clientY; + dragLeft = scroller.scrollLeft; + dragTop = scroller.scrollTop; + dragMoved = false; + scroller.setPointerCapture(event.pointerId); + // 휠 버튼은 브라우저 자동 스크롤이 뜨므로 막는다. + if (event.button === 1) event.preventDefault(); + }); + + scroller.addEventListener("pointermove", (event) => { + if (dragId !== event.pointerId) return; + const dx = event.clientX - dragX; + const dy = event.clientY - dragY; + if (!dragMoved && Math.abs(dx) < DRAG_THRESHOLD_PX && Math.abs(dy) < DRAG_THRESHOLD_PX) return; + if (!dragMoved) { + dragMoved = true; + scroller.style.cursor = "grabbing"; + } + scroller.scrollLeft = dragLeft - dx; + scroller.scrollTop = dragTop - dy; + event.preventDefault(); + }); + + scroller.addEventListener("pointerup", endDrag); + scroller.addEventListener("pointercancel", endDrag); + // 잡아 둔 포인터를 잃으면(창 전환 등) 끌기도 함께 끝낸다. + scroller.addEventListener("lostpointercapture", endDrag); }