From dd483a8fbde2ff5826ddcb45e68b1ff9239cd9a3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 17:42:52 +0900 Subject: [PATCH] =?UTF-8?q?fix(B05):=20=EC=A2=85=EB=8B=A8=20=EC=83=88=20?= =?UTF-8?q?=EC=B0=BD=20=EC=B5=9C=EB=8C=80=ED=99=94=20=EB=95=8C=20=ED=9C=A0?= =?UTF-8?q?=20=EC=9D=B4=EB=8F=99=20=E2=80=94=20=EC=8A=A4=ED=81=AC=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=EA=B0=80=20=EB=86=93=EC=9D=B8=20=EC=B0=BD=EC=9D=98=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=ED=8B=80=20=C2=B7=20=EC=8B=9C=EA=B3=84=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9(230-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메인 창 requestAnimationFrame 은 새 창 최대화로 메인 창이 가려지면 멈춤 · 휠은 막히고 이동 0. 휠 이동 · 끌기 뒤 클릭 삼킴 · 글자 선택 풀기 · 스크롤 뒤 다시 그리기 시계를 ownerDocument.defaultView 로. 새 시험 test_230_1_popout_wheel(가려진 메인 창 · 새 창 보통 · 최대화 · 원래 창). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- A00_Common/design/design_profile_viewport.ts | 7 +- B05_Profile/B05_Profile_UI_Profile_Wheel.ts | 20 ++- .../tester/helper_230_1_popout_wheel.cjs | 158 ++++++++++++++++++ resources/tester/test_230_1_popout_wheel.py | 62 +++++++ 4 files changed, 240 insertions(+), 7 deletions(-) create mode 100644 resources/tester/helper_230_1_popout_wheel.cjs create mode 100644 resources/tester/test_230_1_popout_wheel.py diff --git a/A00_Common/design/design_profile_viewport.ts b/A00_Common/design/design_profile_viewport.ts index ea193f514..7a9b7bb3a 100644 --- a/A00_Common/design/design_profile_viewport.ts +++ b/A00_Common/design/design_profile_viewport.ts @@ -29,15 +29,18 @@ export function bindProfileViewport(deps: ProfileViewportDeps): () => void { /** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */ let settledScrollLeft = 0; let scrollSettleTimer = 0; + /** 시계를 건 창 — 새 창으로 빼면 그 창 시계를 쓴다(가려진 메인 창 시계는 늦게 돎 · 230-1). */ + let timerView: Window = window; // 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의 // 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만 // 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다. body.addEventListener("scroll", () => { if (deps.editing()) return; const fitted = deps.fitElevation(); - window.clearTimeout(scrollSettleTimer); + timerView.clearTimeout(scrollSettleTimer); if (!needsFullRedraw(fitted)) return; - scrollSettleTimer = window.setTimeout(() => { + timerView = body.ownerDocument.defaultView ?? window; + scrollSettleTimer = timerView.setTimeout(() => { if (deps.editing() || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return; settledScrollLeft = body.scrollLeft; deps.draw(); diff --git a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts index d174fc835..1f6aa5ed4 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts @@ -37,16 +37,25 @@ const BASE_FRAME_MS = 1000 / 60; * 쓰면 한 번에 목표까지 튄다. */ const MAX_FRAME_MS = 64; +/** 스크롤러가 지금 놓인 창 — 종단을 새 창으로 빼면 그 창이다(230-1). + * 메인 창 전역 `requestAnimationFrame` 을 쓰면, 새 창을 최대화해 메인 창이 가려질 때 + * 브라우저가 메인 창 프레임을 멈춰 휠 이동이 한 걸음도 안 나갔다. */ +function viewOf(scroller: HTMLElement): Window { + return scroller.ownerDocument.defaultView ?? window; +} + export function attachWheelHorizontalScroll(scroller: HTMLElement): void { let target = -1; /** 우리가 마지막으로 써 넣은 값 — 남이 자리를 옮겼는지 가리는 데 쓴다. */ let applied = -1; let frame = 0; + /** `frame` 을 받아 둔 창 — 걷을 때도 같은 창에서 걷는다. */ + let frameView: Window = window; /** 직전 프레임 시각 — 좁히는 양을 **흐른 시간**에 맞추는 데 쓴다. */ let lastFrameAt = 0; function stop(): void { - if (frame) cancelAnimationFrame(frame); + if (frame) frameView.cancelAnimationFrame(frame); frame = 0; target = -1; applied = -1; @@ -72,7 +81,7 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void { const ratio = 1 - Math.pow(1 - EASE, elapsed / BASE_FRAME_MS); scroller.scrollLeft = scroller.scrollLeft + rest * ratio; applied = scroller.scrollLeft; - frame = requestAnimationFrame(step); + frame = frameView.requestAnimationFrame(step); } scroller.addEventListener( @@ -89,7 +98,8 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void { target = next; if (!frame) { applied = scroller.scrollLeft; - frame = requestAnimationFrame(step); + frameView = viewOf(scroller); + frame = frameView.requestAnimationFrame(step); } event.preventDefault(); }, @@ -128,7 +138,7 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void { }; scroller.addEventListener("click", swallow, { capture: true, once: true }); // 클릭이 안 오는 경우(휠 버튼 등)를 대비해 다음 프레임에 걷어낸다. - requestAnimationFrame(() => + viewOf(scroller).requestAnimationFrame(() => scroller.removeEventListener("click", swallow, { capture: true }), ); } @@ -164,7 +174,7 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void { // 끄는 동안 글자가 파랗게 잡히지 않게 한다. 포인터 잡기를 미룬 뒤로 브라우저가 // 기본 글자 선택을 시작했다(2026-09-06 사용자 지적). 이미 잡힌 것도 푼다. scroller.style.userSelect = "none"; - window.getSelection()?.removeAllRanges(); + viewOf(scroller).getSelection()?.removeAllRanges(); } scroller.scrollLeft = dragLeft - dx; scroller.scrollTop = dragTop - dy; diff --git a/resources/tester/helper_230_1_popout_wheel.cjs b/resources/tester/helper_230_1_popout_wheel.cjs new file mode 100644 index 000000000..57b2cb4fe --- /dev/null +++ b/resources/tester/helper_230_1_popout_wheel.cjs @@ -0,0 +1,158 @@ +/* 종단 새 창 + 최대화 휠(계획서 230-1) 단위검증 헬퍼 — 휠 이동(`B05_Profile_UI_Profile_Wheel.ts`) · + * 스크롤 뒤 다시 그리기 시계(`design_profile_viewport.ts`)를 그 자리에서 트랜스파일해 돌리고 JSON 으로 냄. + * 메인 창은 「가려짐」 — 화면 틀 · 시계가 돌지 않음(새 창 최대화가 메인 창을 덮은 꼴). */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +function load(file, mods = {}) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + new Function("exports", "module", "require", js)(box.exports, box, (p) => mods[p] ?? require(p)); + return box.exports; +} + +// ---- 가짜 창 — frozen 이면 화면 틀 · 시계가 안 돎 ---- +function fakeWindow(name, frozen) { + const win = { name, frozen, now: 0, timers: [], seq: 0, frames: 0 }; + win.setTimeout = (fn, ms) => (win.timers.push({ id: ++win.seq, at: win.now + ms, fn }), win.seq); + win.clearTimeout = (id) => (win.timers = win.timers.filter((t) => t.id !== id)); + win.requestAnimationFrame = (fn) => win.setTimeout(() => (win.frames++, fn(win.now)), 16); + win.cancelAnimationFrame = win.clearTimeout; + win.getSelection = () => ({ removeAllRanges: () => (win.cleared = true) }); + win.tick = (ms) => { + if (win.frozen) return; + const end = win.now + ms; + for (;;) { + const next = win.timers.filter((t) => t.at <= end).sort((a, b) => a.at - b.at)[0]; + if (!next) break; + win.now = next.at; + win.clearTimeout(next.id); + next.fn(); + } + win.now = end; + }; + return win; +} + +function fakeScroller(view, { width, scrollWidth }) { + const calls = {}; + let left = 0; + const el = { + ownerDocument: { defaultView: view }, + clientWidth: width, + clientHeight: 400, + scrollWidth, + scrollTop: 0, + style: {}, + addEventListener: (type, fn) => (calls[type] ??= []).push(fn), + removeEventListener: () => {}, + hasPointerCapture: () => false, + setPointerCapture: () => {}, + releasePointerCapture: () => {}, + fire: (type, ev = {}) => { + const e = { prevented: false, preventDefault: () => (e.prevented = true), ...ev }; + (calls[type] ?? []).forEach((fn) => fn(e)); + return e; + }, + }; + Object.defineProperty(el, "scrollLeft", { + get: () => left, + set: (v) => (left = Math.min(Math.max(0, v), el.scrollWidth - el.clientWidth)), + }); + return el; +} + +const main = fakeWindow("main", true); +globalThis.window = main; +globalThis.requestAnimationFrame = main.requestAnimationFrame; // 옛 코드가 쓰던 전역 = 메인 창 +globalThis.cancelAnimationFrame = main.cancelAnimationFrame; +const roCallbacks = []; +globalThis.ResizeObserver = class { + constructor(fn) { + roCallbacks.push(fn); + } + observe() {} + disconnect() {} +}; + +const wheelMod = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Wheel.ts")); +const viewport = load(path.join(ROOT, "A00_Common", "design", "design_profile_viewport.ts"), { + "../../B05_Profile/B05_Profile_UI_Profile_Wheel": wheelMod, + "@util/common_util_chart_ywindow": { needsFullRedraw: () => true }, +}); + +function wheelDown(scroller, view, notches) { + const prevented = []; + for (let i = 0; i < notches; i++) + prevented.push(scroller.fire("wheel", { deltaY: 120 }).prevented); + view.tick(1000); + return { left: Math.round(scroller.scrollLeft), prevented }; +} + +const out = {}; + +// ① 새 창 보통 크기(1400) — 메인 창은 가려짐 +const popup = fakeWindow("popup", false); +const s1 = fakeScroller(popup, { width: 1400, scrollWidth: 6000 }); +wheelMod.attachWheelHorizontalScroll(s1); +out.popupNormal = { + ...wheelDown(s1, popup, 3), + popupFrames: popup.frames, + mainTimers: main.timers.length, +}; + +// ② 새 창 최대화(1400 → 1920) 뒤 휠 — 크기 바뀜 듣개가 돈 뒤에도 이동 +const popup2 = fakeWindow("popup2", false); +const s2 = fakeScroller(popup2, { width: 1400, scrollWidth: 6000 }); +let draws = 0; +let lightSyncs = 0; +const last = { width: 1400, height: 400 }; +const unbind = viewport.bindProfileViewport({ + body: s2, + editing: () => false, + fitElevation: () => ({}), + draw: () => draws++, + lastSize: () => last, + scheduleLightSync: () => lightSyncs++, + scheduleDraw: () => {}, +}); +s2.clientWidth = 1920; +s2.clientHeight = 1000; +roCallbacks.at(-1)(); +const before = main.timers.length; +const moved = wheelDown(s2, popup2, 2); +s2.fire("scroll"); +popup2.tick(200); +out.popupMax = { + ...moved, + limit: s2.scrollWidth - s2.clientWidth, + lightSyncs, + draws, + mainTimersAdded: main.timers.length - before, +}; +unbind(); + +// ③ 끝까지 굴림 — 한도에서 멈추고 더는 막지 않음(기본 스크롤로 넘김) +const s3 = fakeScroller(popup, { width: 1920, scrollWidth: 2200 }); +wheelMod.attachWheelHorizontalScroll(s3); +const atEnd = wheelDown(s3, popup, 5); +out.popupEnd = { ...atEnd, after: s3.fire("wheel", { deltaY: 120 }).prevented }; + +// ④ 원래 창(가려지지 않음) — 자기 창 화면 틀로 그대로 이동 +main.frozen = false; +const s4 = fakeScroller(main, { width: 1400, scrollWidth: 6000 }); +wheelMod.attachWheelHorizontalScroll(s4); +out.mainWindow = wheelDown(s4, main, 3); + +// ⑤ 끌기 — 글자 선택은 스크롤러가 놓인 창에서 풂 +const s5 = fakeScroller(popup, { width: 1400, scrollWidth: 6000 }); +wheelMod.attachWheelHorizontalScroll(s5); +s5.fire("pointerdown", { button: 0, pointerId: 1, clientX: 500, clientY: 100, target: null }); +s5.fire("pointermove", { pointerId: 1, clientX: 400, clientY: 100 }); +out.drag = { left: s5.scrollLeft, popupCleared: !!popup.cleared, mainCleared: !!main.cleared }; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_230_1_popout_wheel.py b/resources/tester/test_230_1_popout_wheel.py new file mode 100644 index 000000000..26e4ef6e5 --- /dev/null +++ b/resources/tester/test_230_1_popout_wheel.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +"""종단 새 창 + 최대화 휠(계획서 230-1) 회귀검증. + +까닭 — 휠 이동 · 스크롤 뒤 다시 그리기가 메인 창 전역 화면 틀 · 시계를 썼음. 새 창을 최대화하면 +메인 창이 가려져 브라우저가 그 틀을 멈춤 → 휠은 막히고(preventDefault) 이동은 0. +고침 — 스크롤러가 놓인 창(`ownerDocument.defaultView`)의 틀 · 시계를 씀. + +`helper_230_1_popout_wheel.cjs` 가 메인 창을 「가려짐」(틀 · 시계 멈춤)으로 두고 판정함. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_230_1_popout_wheel.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_새_창_보통_휠_이동_메인_틀_안_씀(r): + p = r["popupNormal"] + assert p["left"] == 360 # 세 칸 × 120 + assert p["prevented"] == [True, True, True] + assert p["popupFrames"] > 0 and p["mainTimers"] == 0 + + +def test_새_창_최대화_뒤_휠_이동(r): + p = r["popupMax"] + assert p["limit"] == 4080 # 6000 − 1920 — 최대화해도 스크롤할 거리 남음 + assert p["lightSyncs"] == 1 # 크기 바뀜 듣개가 돎 + assert p["left"] == 240 # 두 칸 × 120 + assert p["draws"] == 1 # 스크롤 뒤 다시 그리기도 새 창 시계로 + assert p["mainTimersAdded"] == 0 + + +def test_끝에서_멈추고_기본_스크롤로_넘김(r): + p = r["popupEnd"] + assert p["left"] == 280 # 한도 2200 − 1920 + assert p["prevented"][-1] is False and p["after"] is False + + +def test_원래_창_휠_그대로(r): + assert r["mainWindow"] == {"left": 360, "prevented": [True, True, True]} + + +def test_끌기_글자_선택은_새_창에서_풂(r): + d = r["drag"] + assert d["left"] == 100 + assert d["popupCleared"] is True and d["mainCleared"] is False