From a5f5cac73933b36c2cc2d879551f86a5b14a691d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:26:31 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(ui):=20=EB=8F=8B=EB=B3=B4=EA=B8=B0=20C?= =?UTF-8?q?AD=20=EC=98=81=EC=97=AD=20=EB=B9=BC=EA=B8=B0=20=E2=80=94=20CAD?= =?UTF-8?q?=20=EC=9C=84=20=EC=95=88=20=EC=BC=AC=20=C2=B7=20=EC=BC=9C?= =?UTF-8?q?=EC=A7=84=20=EC=B1=84=20=EB=93=A4=EC=96=B4=EA=B0=80=EB=A9=B4=20?= =?UTF-8?q?=EC=88=A8=EA=B9=80=20=C2=B7=20=EB=82=98=EC=98=A4=EB=A9=B4=20?= =?UTF-8?q?=EB=B0=B0=EC=9C=A8=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=C2=B7=20CAD?= =?UTF-8?q?=20=EC=9C=84=20=ED=9C=A0=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=C2=B7?= =?UTF-8?q?=20=EB=B3=B5=EC=A0=9C=EC=97=90=EC=84=9C=20CAD=20=EB=B9=88?= =?UTF-8?q?=EC=B9=B8=20(221-1)?= 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 | 111 ++++++++++++++++++++++- resources/tester/test_218_1_loupe.py | 16 +++- resources/tester/test_221_1_loupe_cad.py | 69 ++++++++++++++ ui_template/ui_template_loupe.ts | 109 +++++++++++++--------- 4 files changed, 254 insertions(+), 51 deletions(-) create mode 100644 resources/tester/test_221_1_loupe_cad.py diff --git a/resources/tester/helper_218_1_loupe.cjs b/resources/tester/helper_218_1_loupe.cjs index cfa125bf1..52ac768cf 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 휠 배율) 단위검증 헬퍼 — +/* 돋보기(`ui_template/ui_template_loupe.ts` · 계획서 218-1 · 220-1 휠 배율 · 221-1 CAD 빼기) 단위검증 헬퍼 — * 그 자리에서 트랜스파일해 Node 로 돌리고 값을 JSON 으로 냄. 가짜 창 · 가짜 시계 · 가짜 요소로 판정. */ const fs = require("fs"); const path = require("path"); @@ -271,6 +271,98 @@ r.origin = { bottomRight: m.loupeOrigin(995, 795, 1000, 800), }; +{ + // 221-1 CAD 영역 — 위에선 안 켬 · 켜진 채 들어가면 숨김 · 나오면 보임 · 휠은 CAD 로 + const cad = { tagName: "IFRAME", closest: (sel) => (sel === m.LOUPE_SKIP ? {} : null) }; + const plain = { tagName: "DIV", closest: () => null }; + const kinds = (v) => v.log.map((l) => l[0]); + r.cad = { + isCad: [m.isCadTarget(cad), m.isCadTarget(plain), m.isCadTarget(null), m.isCadTarget({})], + skip: m.LOUPE_SKIP, + }; + { + // Z 를 CAD 위에서 누름 — 안 켬 · 나오면 보임 · 떼면 끔 + const { win, view } = setup(); + win.fire("mouseover", { target: cad }); + win.fire("keydown", { code: "KeyZ", target: cad }); + const onCad = view.log.length; + const wheel = win.fire("wheel", { deltaY: -100, target: cad }); + win.fire("mouseover", { target: plain, clientX: 50, clientY: 60 }); + const out = view.log[0]; + win.fire("keyup", { code: "KeyZ" }); + r.cad.keyOnCad = { onCad, wheelPrevented: wheel.prevented, out, off: last(view) }; + } + { + // 켜진 채 CAD 위로(mouseover · mousemove 둘 다) — 숨김 · 휠 안 막음 · 나오면 배율 그대로 보임 + const { win, view } = setup(); + win.fire("keydown", { code: "KeyZ" }); + win.fire("wheel", { deltaY: -100 }); // 2.25 + win.fire("mouseover", { target: cad }); + const hidden = last(view); + const n = view.log.length; + const wheel = win.fire("wheel", { deltaY: -100, target: cad }); + win.fire("mousemove", { target: cad, clientX: 5, clientY: 5 }); + const quiet = view.log.length === n; // 숨긴 동안 move · 배율 안 부름 + win.fire("mouseover", { target: plain, clientX: 70, clientY: 80 }); + const back = view.log.slice(n); + win.fire("mousemove", { target: cad, clientX: 6, clientY: 6 }); + const moveHide = last(view); + win.fire("mousemove", { target: plain, clientX: 7, clientY: 7 }); + r.cad.enter = { + hidden, + wheel: { prevented: wheel.prevented, stopped: wheel.stopped }, + quiet, + back, + moveHide, + moveBack: kinds(view).slice(-3), + }; + } + { + // 우클릭 길게 — CAD 위면 안 켜고 메뉴 그대로 + const { win, view } = setup(); + win.fire("mousedown", { button: 2, buttons: 2, target: cad }); + win.tick(1000); + win.fire("mouseup", { button: 2, target: cad }); + const menu = win.fire("contextmenu", { target: cad }); + r.cad.press = { log: view.log.length, menuPrevented: menu.prevented }; + } + { + // 초점이 CAD iframe 으로 넘어감(cad_host) — 끄지 않고 숨김 · Z 떼기는 그쪽 창에서 + const { win, view } = setup(); + const frameCalls = []; + const frame = { + ...cad, + contentWindow: { addEventListener: (type, fn, cap) => frameCalls.push({ type, fn, cap }) }, + }; + win.document = { activeElement: frame }; + win.fire("keydown", { code: "KeyZ" }); + win.fire("blur", { target: win }); + const blurHidden = kinds(view); + win.document = { activeElement: plain }; + const up = frameCalls.find((c) => c.type === "keyup"); + up.fn({ code: "KeyZ" }); + win.fire("mouseover", { target: plain }); + const afterRelease = kinds(view); + // 우클릭 누른 채 들어감 — 나오면 다시 보임 + const b = setup(); + b.win.fire("mousedown", { button: 2, buttons: 2 }); + b.win.tick(400); + b.win.document = { activeElement: frame }; + b.win.fire("blur", { target: b.win }); + b.win.document = { activeElement: plain }; + b.win.fire("mousemove", { buttons: 2, target: plain }); + const pressBack = kinds(b.view); + b.win.fire("mouseup", { button: 2 }); + r.cad.blur = { + blurHidden, + frameKeyup: up && up.cap, + afterRelease, + pressBack, + pressOff: last(b.view), + }; + } +} + // ---- 가짜 요소 ---- const SVG_NS = "http://www.w3.org/2000/svg"; class El { @@ -299,6 +391,9 @@ class El { getBoundingClientRect() { return this.rect; } + get classList() { + return { contains: (k) => this.className.split(" ").includes(k) }; + } setAttribute(k, v) { this.attrs[k] = v; } @@ -370,8 +465,13 @@ function makePage() { contentDocument: frameDoc, rect: { left: 0, top: 0, right: 320, bottom: 240, width: 320, height: 240 }, }); + const cadBox = new El("DIV", { + rect: { left: 0, top: 300, right: 400, bottom: 600, width: 400, height: 300 }, + }); + cadBox.className = "cad-host"; + cadBox.append(new El("IFRAME"), new El("A")); scroller.append(canvas, blank, input, check); - app.append(scroller, offscreen, table, svg, frame); + app.append(scroller, offscreen, table, svg, frame, cadBox); return app; } function fakeDocument(app) { @@ -406,7 +506,7 @@ function fakeDocument(app) { const stage = root.children[0]; const page = stage.children[0]; const copy = page.children[0]; - const [scroller, offscreen, table, svg, frame] = copy.children; + const [scroller, offscreen, table, svg, frame, cadBox] = copy.children; const [canvas, blank, input, check] = scroller.children; r.capture = { rootClass: root.className, @@ -429,7 +529,10 @@ function fakeDocument(app) { svgKept: svg.children.length, frameTag: frame.tagName, frameDrawn: frame.drawn, - frameSize: [frame.width, frame.height], + frameSize: [frame.style.width, frame.style.height], + frameEmpty: frame.children.length, + cadBoxEmpty: cadBox.children.length, + cadBoxSize: [cadBox.style.width, cadBox.style.height], watching: observers[0].watching === app, observeOpts: observers[0].opts, scrollListen: win.calls.some((c) => c.type === "scroll" && c.capture), diff --git a/resources/tester/test_218_1_loupe.py b/resources/tester/test_218_1_loupe.py index d8f67f5b8..9568efe79 100644 --- a/resources/tester/test_218_1_loupe.py +++ b/resources/tester/test_218_1_loupe.py @@ -49,7 +49,16 @@ def test_크기_배율_상수(r): def test_창_캡처_단계로_들음(r): assert r["listeners"] == [ - [t, True] for t in ("mousemove", "mousedown", "mouseup", "contextmenu", "keydown", "keyup") + [t, True] + for t in ( + "mousemove", + "mouseover", + "mousedown", + "mouseup", + "contextmenu", + "keydown", + "keyup", + ) ] + [["wheel", {"capture": True, "passive": False}], ["blur", True]] @@ -139,8 +148,9 @@ def test_화면_뜨기(r): assert c["offscreenChildren"] == 0 and c["offscreenSize"] == ["100px", "100px"] # 보이는 영역만 assert c["rowKept"] == 1 # 표 줄은 칸 너비 때문에 그대로 assert c["svgKept"] == 1 - assert c["frameTag"] == "CANVAS" and c["frameDrawn"] == [["cad", 5, 6, 300, 200]] - assert c["frameSize"] == [320, 240] + # iframe(CAD) 은 복제하지 않음 — 같은 크기 빈 상자(221-1) + assert c["frameTag"] == "DIV" and c["frameDrawn"] == [] and c["frameEmpty"] == 0 + assert c["frameSize"] == ["320px", "240px"] assert c["watching"] and c["observeOpts"]["subtree"] and c["scrollListen"] diff --git a/resources/tester/test_221_1_loupe_cad.py b/resources/tester/test_221_1_loupe_cad.py new file mode 100644 index 000000000..b74b31959 --- /dev/null +++ b/resources/tester/test_221_1_loupe_cad.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +"""돋보기 CAD 영역 빼기(계획서 221-1) 회귀검증 — CAD(cad_host 상자 · iframe) 위에선 안 켬 · +켜진 채 들어가면 숨김 · 나오면 다시 보임(배율 그대로) · CAD 위 휠은 안 막음 · 복제에서 CAD 는 빈칸. + +218-1 헬퍼(`helper_218_1_loupe.cjs`)의 CAD 장면을 판정함. +""" + +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_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) + + +def test_CAD_판정(r): + c = r["cad"] + assert c["skip"] == ".cad-host, iframe" + assert c["isCad"] == [True, False, False, False] + + +def test_CAD_위_Z_안_켬_나오면_보임(r): + k = r["cad"]["keyOnCad"] + assert k["onCad"] == 0 and k["wheelPrevented"] is False + assert k["out"] == ["show", 50, 60] # 나온 자리에 뜸 + assert k["off"] == "hide" + + +def test_CAD_위_우클릭_길게_안_켬_메뉴_그대로(r): + assert r["cad"]["press"] == {"log": 0, "menuPrevented": False} + + +def test_켜진_채_CAD_위_숨김_휠_안_막음_나오면_배율_그대로(r): + e = r["cad"]["enter"] + assert e["hidden"] == "hide" + assert e["wheel"] == {"prevented": False, "stopped": False} # CAD 휠 줌 그대로 + assert e["quiet"] # 숨긴 동안 원을 안 움직임 + assert e["back"] == [["show", 70, 80], ["zoom", 2.25]] + assert e["moveHide"] == "hide" and e["moveBack"] == ["show", "zoom", "move"] + + +def test_초점이_CAD_로_넘어가도_끄지_않고_숨김(r): + b = r["cad"]["blur"] + assert b["blurHidden"] == ["show", "hide"] + assert b["frameKeyup"] is True # Z 떼기는 CAD 창에서 들음 + assert b["afterRelease"] == ["show", "hide"] # 뗀 뒤 나와도 안 뜸 + assert b["pressBack"] == ["show", "hide", "show", "move"] # 우클릭 누른 채 나오면 다시 + assert b["pressOff"] == "hide" + + +def test_복제에서_CAD_빈칸(r): + c = r["capture"] + assert c["frameTag"] == "DIV" and c["frameEmpty"] == 0 + assert c["frameSize"] == ["320px", "240px"] + assert c["cadBoxEmpty"] == 0 and c["cadBoxSize"] == ["400px", "300px"] diff --git a/ui_template/ui_template_loupe.ts b/ui_template/ui_template_loupe.ts index cdb4fe4c6..1703dc58a 100644 --- a/ui_template/ui_template_loupe.ts +++ b/ui_template/ui_template_loupe.ts @@ -7,7 +7,8 @@ * 길게 누른 우클릭은 뗀 뒤 메뉴(우리 메뉴 · 브라우저 메뉴)를 안 띄움 · 짧게 누르면 지금 우클릭 그대로. * 그리기 — 켤 때 `#app` 을 한 번 떠서(DOM 복제 + 2D canvas 그림 복사 · SVG 그대로) 원 안에 놓고, 움직일 때 transform 만 바꿈. * 화면 밖 상자는 속을 비우고 크기만 남김 · 켜 있는 동안 화면이 바뀌면 0.5 초 뒤 다시 뜸 · 끄면 복제를 지움. - * WebGL canvas 는 그림이 남아 있을 때만 옮겨짐 · 같은 출처 iframe(CAD)은 안의 canvas 만 옮김 — 못 옮기면 그 자리 빈칸. + * WebGL canvas 는 그림이 남아 있을 때만 옮겨짐. + * CAD 영역(cad_host 상자 · iframe · 221-1) — 커서가 그 위면 켜지 않고 · 켜진 채 들어가면 숨김(나오면 다시) · 휠도 CAD 로 · 복제는 빈칸. * ========================================================================== */ import "./ui_template_loupe.css"; @@ -33,6 +34,8 @@ export const LOUPE_EDGE = 4; export const LOUPE_ROUTE = /^(b0[1-9]|m0[12])-/; const SVG_NS = "http://www.w3.org/2000/svg"; +/** CAD 영역 — cad_host 가 끼운 상자와 iframe */ +export const LOUPE_SKIP = ".cad-host, iframe"; const NON_TEXT_INPUT = new Set([ "checkbox", "radio", @@ -77,6 +80,11 @@ export function isTypingTarget(target: unknown): boolean { return tag === "INPUT" && !NON_TEXT_INPUT.has((el.type || "text").toLowerCase()); } +/** CAD 영역 위인지 — 돋보기를 안 띄우는 자리(221-1) */ +export function isCadTarget(target: unknown): boolean { + return !!(target as Partial | null)?.closest?.(LOUPE_SKIP); +} + /** 휠 한 번 뒤 배율 — 위로 굴림(deltaY < 0) = 크게 · 한도 안 */ export function nextLoupeZoom(zoom: number, deltaY: number): number { const step = Math.sign(-deltaY) * LOUPE_ZOOM_STEP; @@ -115,18 +123,36 @@ export function initLoupe( let y = win.innerHeight / 2; let press = false; // 오른쪽 길게 누름 let key = false; // Z 누름 - let shown = false; + let shown = false; // 켜짐 + let seen = false; // 원이 보임 — 켜짐이어도 CAD 위면 숨김 + let overCad = false; let holdTimer = 0; let swallowMenu = false; // 길게 누른 뒤 따라오는 우클릭 메뉴 한 번을 먹음 let zoom = LOUPE_ZOOM; const update = (): void => { const want = (press || key) && isOn(); - if (want && !shown) { - zoom = LOUPE_ZOOM; // 켤 때마다 2배 + if (want && !shown) zoom = LOUPE_ZOOM; // 켤 때마다 2배 + const see = want && !overCad; + if (see && !seen) { view.show(x, y); - } else if (!want && shown) view.hide(); + if (zoom !== LOUPE_ZOOM) view.setZoom(zoom); // CAD 에서 나옴 — 배율 그대로 + } else if (!see && seen) view.hide(); shown = want; + seen = see; + }; + const track = (e: MouseEvent): void => { + x = e.clientX; + y = e.clientY; + const cad = isCadTarget(e.target); + if (cad === overCad) return; + overCad = cad; + update(); + }; + const releaseZ = (e: KeyboardEvent): void => { + if (keyLetter(e) !== "z" || !key) return; + key = false; + update(); }; const stopHold = (): void => { win.clearTimeout(holdTimer); @@ -136,20 +162,21 @@ export function initLoupe( win.addEventListener(type, fn, true); on("mousemove", (e) => { - x = e.clientX; - y = e.clientY; + track(e); if (!(e.buttons & 2) && (holdTimer || press)) { stopHold(); // 창 밖에서 뗌 — mouseup 이 안 옴 press = false; update(); } - if (shown) view.move(x, y); + if (seen) view.move(x, y); }); + on("mouseover", track); // iframe 위로 들어가면 mousemove 가 안 옴 — 들어갈 때 한 번 + on("mousedown", (e) => { if (e.button !== 2) return; swallowMenu = false; stopHold(); - if (!isOn()) return; + if (!isOn() || isCadTarget(e.target)) return; x = e.clientX; y = e.clientY; holdTimer = win.setTimeout(() => { @@ -177,16 +204,12 @@ export function initLoupe( key = true; update(); }); - on("keyup", (e) => { - if (keyLetter(e) !== "z" || !key) return; - key = false; - update(); - }); + on("keyup", releaseZ); // 켜 있는 동안만 휠을 잡음 — 잡기 단계 첫 자리 · passive:false 여야 스크롤을 막음 win.addEventListener( "wheel", (e) => { - if (!shown) return; + if (!seen) return; // CAD 위 — CAD 휠 줌 그대로 e.preventDefault(); e.stopImmediatePropagation(); // 표 · 그래프 휠 줌도 안 받음 zoom = nextLoupeZoom(zoom, e.deltaY); @@ -195,7 +218,19 @@ export function initLoupe( { capture: true, passive: false }, ); on("blur", (e) => { - if (e.target !== win) return; // 칸 초점 빠짐은 무시 — 창이 초점을 잃을 때만(CAD iframe 으로 넘어감 포함) + if (e.target !== win) return; // 칸 초점 빠짐은 무시 — 창이 초점을 잃을 때만 + const active = win.document?.activeElement as HTMLIFrameElement | null | undefined; + if (isCadTarget(active)) { + // 커서가 CAD 로 들어가 초점이 넘어감(cad_host) — 끄지 않고 숨김만 · Z 떼기는 그쪽 창에서 들음 + try { + active?.contentWindow?.addEventListener("keyup", releaseZ, true); + } catch { + // 다른 출처 — 나올 때 Z 를 다시 눌러야 꺼짐 + } + overCad = true; + update(); + return; + } stopHold(); press = key = false; update(); @@ -328,18 +363,15 @@ function walk( if (o.namespaceURI === SVG_NS) return; // SVG 는 복제 그대로 const tag = o.tagName.toUpperCase(); if (tag === "CANVAS") return drawCanvas(o as HTMLCanvasElement, c as HTMLCanvasElement); - if (tag === "IFRAME") return void c.replaceWith(frameShot(o as HTMLIFrameElement, doc)); if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return copyValue(o, c); const r = o.getBoundingClientRect(); - if (!KEEP_INSIDE.has(tag) && (r.bottom < 0 || r.right < 0 || r.top > vh || r.left > vw)) { - c.replaceChildren(); // 화면 밖 — 속을 비우고 크기만 - Object.assign((c as HTMLElement).style, { - boxSizing: "border-box", - width: `${r.width}px`, - height: `${r.height}px`, - }); - return; + if (tag === "IFRAME") { + const box = doc.createElement("div"); // 복제 iframe 은 CAD 를 또 띄움 — 빈 상자로 바꿈 + c.replaceWith(box); + return emptyBox(box, r); } + 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]); for (let i = 0; i < o.children.length; i++) walk(o.children[i], c.children[i], doc, vw, vh, scrolls); @@ -364,23 +396,12 @@ function copyValue(o: Element, c: Element): void { } } -/** iframe 자리 — 같은 출처면 안의 canvas 를 한 장에 모아 그림 · 아니면 빈칸 */ -function frameShot(frame: HTMLIFrameElement, doc: Document): HTMLCanvasElement { - const r = frame.getBoundingClientRect(); - const shot = doc.createElement("canvas"); - shot.className = frame.className; - shot.width = Math.max(1, Math.round(r.width)); - shot.height = Math.max(1, Math.round(r.height)); - Object.assign(shot.style, { display: "block", width: `${r.width}px`, height: `${r.height}px` }); - try { - const ctx = shot.getContext("2d"); - for (const canvas of Array.from(frame.contentDocument?.querySelectorAll("canvas") ?? [])) { - const cr = canvas.getBoundingClientRect(); - if (canvas.width && canvas.height) - ctx?.drawImage(canvas, cr.left, cr.top, cr.width, cr.height); - } - } catch { - // 다른 출처 · 그림 없음 — 빈칸 - } - return shot; +/** 속을 비우고 크기만 남김 — 화면 밖 · CAD 영역 */ +function emptyBox(c: Element, r: DOMRect): void { + c.replaceChildren(); + Object.assign((c as HTMLElement).style, { + boxSizing: "border-box", + width: `${r.width}px`, + height: `${r.height}px`, + }); } From 74c78154758c48d338e1b51eecb6d260ee39ff75 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:27:30 +0900 Subject: [PATCH 2/3] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=2097?= 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_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 3b42827fc..2a930d328 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -120,3 +120,4 @@ 94. 캐드 프로그램에서 스케일 기능은 잘되지만 추가로 오토캐드처럼 오리진을 지정하고 숫자로 스케일 값을 입력을 받게 추가 95. 캐드 프로그램에서 줌아웃을 많이 하면 브라우져의 속도가 많이 떨어지던데 개선방향은? (질문) 96. 특히 M01 GF000156과 같은 단가산출이 있는지 조사하고 개선 +97. 돋보기 기능이 페이지의 스크롤에 상황에 따라 화면이 안바뀜 개선 From 83798367487a1ca3d53fd01ac3d47f467dae3a1d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:29:34 +0900 Subject: [PATCH 3/3] =?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); }