From 83798367487a1ca3d53fd01ac3d47f467dae3a1d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:29:34 +0900 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=EB=8F=8B=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EB=94=B0=EB=9D=BC=EA=B0=90=20?= =?UTF-8?q?=E2=80=94=20=EB=8B=A4=EC=8B=9C=20=EC=BC=A4=20=EB=95=8C=20?= =?UTF-8?q?=EC=9B=90=EC=9D=84=20=EB=A8=BC=EC=A0=80=20=EB=B3=B4=EC=97=AC=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=98=AE=EA=B9=80=EC=9D=B4=20?= =?UTF-8?q?=EB=A8=B9=EA=B2=8C=20=C2=B7=20=EC=BC=A0=20=EC=B1=84=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=EC=9D=80=20rAF=20=ED=95=9C=20=EB=B2=88?= =?UTF-8?q?=EC=97=90=20=EC=B9=B8=20=C2=B7=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EB=A7=9E=EC=B6=A4=20=C2=B7=200.5=20?= =?UTF-8?q?=EC=B4=88=20=EB=92=A4=20=EB=8B=A4=EC=8B=9C=20=EB=9C=B8=20(221-2?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt --- resources/tester/helper_218_1_loupe.cjs | 58 ++++++++++++++++++++- resources/tester/test_221_2_loupe_scroll.py | 42 +++++++++++++++ ui_template/ui_template_loupe.ts | 52 +++++++++++++----- 3 files changed, 137 insertions(+), 15 deletions(-) create mode 100644 resources/tester/test_221_2_loupe_scroll.py diff --git a/resources/tester/helper_218_1_loupe.cjs b/resources/tester/helper_218_1_loupe.cjs index 52ac768cf..ac649dbf7 100644 --- a/resources/tester/helper_218_1_loupe.cjs +++ b/resources/tester/helper_218_1_loupe.cjs @@ -1,4 +1,4 @@ -/* 돋보기(`ui_template/ui_template_loupe.ts` · 계획서 218-1 · 220-1 휠 배율 · 221-1 CAD 빼기) 단위검증 헬퍼 — +/* 돋보기(`ui_template/ui_template_loupe.ts` · 계획서 218-1 · 220-1 휠 배율 · 221-1 CAD 빼기 · 221-2 스크롤) 단위검증 헬퍼 — * 그 자리에서 트랜스파일해 Node 로 돌리고 값을 JSON 으로 냄. 가짜 창 · 가짜 시계 · 가짜 요소로 판정. */ const fs = require("fs"); const path = require("path"); @@ -37,6 +37,8 @@ function fakeWindow(doc) { (win.calls = win.calls.filter((c) => c.type !== type || c.fn !== fn)); 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(fn, 16); // 한 화면 틀 + win.cancelAnimationFrame = win.clearTimeout; win.tick = (ms) => { win.now += ms; for (const t of win.timers.filter((t) => t.at <= win.now)) { @@ -374,7 +376,7 @@ class El { this.style = {}; this.dataset = {}; this.attrs = {}; - this.scrollTop = opts.scrollTop || 0; + this.st = opts.scrollTop || 0; this.scrollLeft = 0; this.width = opts.width || 0; this.height = opts.height || 0; @@ -391,6 +393,14 @@ class El { getBoundingClientRect() { return this.rect; } + // 실제 DOM 처럼 숨긴(hidden) 조상 아래에선 스크롤을 적어도 안 먹음 + get scrollTop() { + return this.st; + } + set scrollTop(v) { + for (let e = this; e; e = e.parent) if (e.hidden) return; + this.st = v; + } get classList() { return { contains: (k) => this.className.split(" ").includes(k) }; } @@ -470,6 +480,8 @@ function makePage() { }); cadBox.className = "cad-host"; cadBox.append(new El("IFRAME"), new El("A")); + scroller.scrollHeight = 900; // 스크롤될 수 있는 칸 + scroller.clientHeight = 300; scroller.append(canvas, blank, input, check); app.append(scroller, offscreen, table, svg, frame, cadBox); return app; @@ -581,4 +593,46 @@ function fakeDocument(app) { r.hide.reopenLabel = [label.textContent, label.hidden, stage.style.transform]; r.hide.reuseRoot = doc.body.children.length; } +{ + // 221-2 스크롤 — 스크롤한 뒤 켬 · 켠 채 스크롤(칸 · 문서) + const app = makePage(); + const scroller = app.children[0]; + const doc = fakeDocument(app); + const win = fakeWindow(doc); + win.MutationObserver = class { + observe() {} + disconnect() {} + }; + const view = m.createLoupeView(win); + const cloneScroller = () => doc.body.children[0].children[0].children[0].children[0].children[0]; + const page = () => doc.body.children[0].children[0].children[0]; + view.show(500, 400); + view.hide(); + scroller.scrollTop = 0; // 0 에서 켬 → 스크롤 칸으로 잡힘 + view.show(500, 400); + const zero = cloneScroller().scrollTop; + view.hide(); + scroller.scrollTop = 260; // 꺼진 동안 스크롤해 둠 → 다시 켬(숨긴 원을 다시 씀) + view.show(500, 400); + const before = cloneScroller().scrollTop; + // 켠 채 스크롤 — 다음 화면 틀에 위치만 · 같은 복제 + const first = cloneScroller(); + scroller.scrollTop = 400; + app.rect = { ...app.rect, top: -50, bottom: 750 }; // 문서도 50 내려감 + const scroll = () => win.calls.filter((c) => c.type === "scroll").forEach((c) => c.fn({})); + scroll(); + scroll(); // 한 틀에 한 번으로 묶음 + const rafs = win.timers.filter((t) => t.at === win.now + 16).length; + win.tick(16); + const during = { + top: cloneScroller().scrollTop, + same: cloneScroller() === first, + pageTop: page().style.top, + }; + win.tick(484); // 0.5 초 뒤 다시 뜸 — 비워 둔 칸을 채움 · 스크롤은 그대로 + const recaptured = { same: cloneScroller() === first, top: cloneScroller().scrollTop }; + view.hide(); + const n = win.timers.length; + r.scroll = { zero, before, rafs, during, recaptured, timersAfterHide: n }; +} console.log(JSON.stringify(r)); diff --git a/resources/tester/test_221_2_loupe_scroll.py b/resources/tester/test_221_2_loupe_scroll.py new file mode 100644 index 000000000..feec5162c --- /dev/null +++ b/resources/tester/test_221_2_loupe_scroll.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +"""돋보기 스크롤 따라감(계획서 221-2) 회귀검증 — 스크롤해 둔 뒤 켜도 원 = 지금 화면 · +켠 채 스크롤하면 다음 화면 틀(rAF 한 번)에 위치만 맞춤 · 0.5 초 뒤 다시 뜸 · 끄면 예약 없음. + +218-1 헬퍼(`helper_218_1_loupe.cjs`)의 스크롤 장면을 판정함(숨긴 원 안에선 스크롤이 안 먹는 실제 동작 흉내). +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def s(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_218_1_loupe.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout)["scroll"] + + +def test_스크롤한_뒤_켬(s): + assert s["zero"] == 0 + assert s["before"] == 260 # 다시 켤 때도 지금 스크롤 그대로 + + +def test_켠_채_스크롤_위치만_맞춤(s): + assert s["rafs"] == 1 # 스크롤 두 번 → 한 틀에 한 번 + assert s["during"] == {"top": 400, "same": True, "pageTop": "-50px"} # 칸 · 문서 스크롤 + + +def test_켠_채_스크롤_0_5초_뒤_다시_뜸(s): + assert s["recaptured"] == {"same": False, "top": 400} + assert s["timersAfterHide"] == 0 diff --git a/ui_template/ui_template_loupe.ts b/ui_template/ui_template_loupe.ts index 1703dc58a..16479963a 100644 --- a/ui_template/ui_template_loupe.ts +++ b/ui_template/ui_template_loupe.ts @@ -7,6 +7,7 @@ * 길게 누른 우클릭은 뗀 뒤 메뉴(우리 메뉴 · 브라우저 메뉴)를 안 띄움 · 짧게 누르면 지금 우클릭 그대로. * 그리기 — 켤 때 `#app` 을 한 번 떠서(DOM 복제 + 2D canvas 그림 복사 · SVG 그대로) 원 안에 놓고, 움직일 때 transform 만 바꿈. * 화면 밖 상자는 속을 비우고 크기만 남김 · 켜 있는 동안 화면이 바뀌면 0.5 초 뒤 다시 뜸 · 끄면 복제를 지움. + * 스크롤(221-2) — 뜰 때 스크롤 칸마다 scrollTop · Left 를 복제에 옮김 · 켜진 동안 어느 칸이든 스크롤하면 그 화면 틀(rAF 한 번)에 위치를 맞추고 0.5 초 뒤 다시 뜸(비워 둔 화면 밖 칸을 채움). * WebGL canvas 는 그림이 남아 있을 때만 옮겨짐. * CAD 영역(cad_host 상자 · iframe · 221-1) — 커서가 그 위면 켜지 않고 · 켜진 채 들어가면 숨김(나오면 다시) · 휠도 CAD 로 · 복제는 빈칸. * ========================================================================== */ @@ -250,6 +251,8 @@ export function createLoupeView( let at: [number, number] = [0, 0]; // 마지막 커서 — 배율만 바뀔 때 다시 놓음 let observer: MutationObserver | null = null; let refreshTimer = 0; + let sync = (): void => {}; // 스크롤 위치 맞춤 — 뜰 때마다 새로 + let syncFrame = 0; const build = (): void => { root = doc.createElement("div"); @@ -271,7 +274,7 @@ export function createLoupeView( const source = sourceOf(); const t0 = win.performance?.now() ?? 0; stage!.replaceChildren(); - if (source) captureInto(source, stage!, win); + if (source) sync = captureInto(source, stage!, win); root!.dataset.captureMs = String(Math.round((win.performance?.now() ?? 0) - t0)); }; const refresh = (): void => { @@ -281,11 +284,19 @@ export function createLoupeView( if (observer) capture(); }, LOUPE_REFRESH_MS); }; + const onScroll = (): void => { + if (!syncFrame) + syncFrame = win.requestAnimationFrame(() => { + syncFrame = 0; + sync(); + }); + refresh(); + }; const view: LoupeView = { show(x, y) { if (!root) build(); + root!.hidden = label!.hidden = false; // 숨긴 채 뜨면 복제의 스크롤이 안 먹음 — 먼저 보임 capture(); - root!.hidden = label!.hidden = false; at = [x, y]; view.setZoom(LOUPE_ZOOM); // 켤 때마다 2배 · 자리도 놓음 const source = sourceOf(); @@ -299,7 +310,7 @@ export function createLoupeView( characterData: true, }); observer = watch; - win.addEventListener("scroll", refresh, true); + win.addEventListener("scroll", onScroll, true); }, move(x, y) { if (!root || !stage || !label) return; @@ -321,7 +332,9 @@ export function createLoupeView( observer = null; win.clearTimeout(refreshTimer); refreshTimer = 0; - win.removeEventListener("scroll", refresh, true); + win.cancelAnimationFrame(syncFrame); + syncFrame = 0; + win.removeEventListener("scroll", onScroll, true); stage?.replaceChildren(); // 복제를 지움 if (root) root.hidden = label!.hidden = true; }, @@ -329,8 +342,8 @@ export function createLoupeView( return view; } -/** `source` 를 떠서 `stage` 에 화면 자리 그대로 놓음 */ -export function captureInto(source: Element, stage: HTMLElement, win: Window): void { +/** `source` 를 떠서 `stage` 에 화면 자리 그대로 놓음 — 돌려주는 함수 = 지금 스크롤 · 자리로 다시 맞춤 */ +export function captureInto(source: Element, stage: HTMLElement, win: Window): () => void { const doc = win.document; const rect = source.getBoundingClientRect(); const page = doc.createElement("div"); @@ -342,14 +355,21 @@ export function captureInto(source: Element, stage: HTMLElement, win: Window): v height: `${rect.height}px`, }); const copy = source.cloneNode(true) as Element; - const scrolls: [Element, number, number][] = []; + const scrolls: [Element, Element][] = []; // [원본, 복제] 스크롤 칸 walk(source, copy, doc, win.innerWidth, win.innerHeight, scrolls); page.append(copy); stage.append(page); - for (const [el, top, left] of scrolls) { - el.scrollTop = top; // 복제는 스크롤이 0 으로 돌아감 — 붙인 뒤 되돌림 - el.scrollLeft = left; - } + const sync = (): void => { + const now = source.getBoundingClientRect(); // 문서 스크롤 — 페이지 자리 + page.style.left = `${now.left}px`; + page.style.top = `${now.top}px`; + for (const [o, c] of scrolls) { + c.scrollTop = o.scrollTop; // 복제는 스크롤이 0 으로 돌아감 — 붙인 뒤 옮김 + c.scrollLeft = o.scrollLeft; + } + }; + sync(); + return sync; } function walk( @@ -358,7 +378,7 @@ function walk( doc: Document, vw: number, vh: number, - scrolls: [Element, number, number][], + scrolls: [Element, Element][], ): void { if (o.namespaceURI === SVG_NS) return; // SVG 는 복제 그대로 const tag = o.tagName.toUpperCase(); @@ -372,7 +392,13 @@ function walk( } const off = r.bottom < 0 || r.right < 0 || r.top > vh || r.left > vw; if (o.classList?.contains("cad-host") || (!KEEP_INSIDE.has(tag) && off)) return emptyBox(c, r); // CAD · 화면 밖 - if (o.scrollTop || o.scrollLeft) scrolls.push([c, o.scrollTop, o.scrollLeft]); + if ( + o.scrollTop || + o.scrollLeft || + o.scrollHeight > o.clientHeight || + o.scrollWidth > o.clientWidth + ) + scrolls.push([o, c]); // 지금 스크롤됐거나 스크롤될 수 있는 칸 for (let i = 0; i < o.children.length; i++) walk(o.children[i], c.children[i], doc, vw, vh, scrolls); }