From 2a661304461a21aaeee5427c5ad6b8ec48370419 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 11:58:48 +0900 Subject: [PATCH] =?UTF-8?q?fix(ui):=20374-1=20=EB=8F=8B=EB=B3=B4=EA=B8=B0?= =?UTF-8?q?=20=3D=20=EC=A7=80=EA=B8=88=20=EB=B3=B4=EC=9D=B4=EB=8A=94=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=A0=84=EC=B2=B4(body=20=C2=B7=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=ED=8F=AC=ED=95=A8)=20=C2=B7=20=EC=9B=80?= =?UTF-8?q?=EC=A7=81=EC=9D=B4=EB=8A=94=20=EB=8F=99=EC=95=88=20=EC=95=88=20?= =?UTF-8?q?=EB=9C=B8=20=C2=B7=20=EC=9D=B4=EB=8F=99=20=ED=95=A9=EC=84=B1?= =?UTF-8?q?=EC=B8=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 복제 원본 #app → document.body(모달 포함) · 원 · 배율 글은 html 바로 아래(자기 복제 · 관찰 안 걸림) - 다시 뜨기 스로틀 → 디바운스(바뀜 · 스크롤 · 움직임마다 미룸 · 멈춘 뒤 0.5 초 한 번) - 바깥 판 translate 만(will-change) · 안쪽 상자 scale - 시험 helper_218_1 · test_218_1 고쳐 씀(디바운스 · 원 자리 · 원본) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- resources/tester/helper_218_1_loupe.cjs | 48 +++++++++++++++++-------- resources/tester/test_218_1_loupe.py | 20 +++++++++++ ui_template/ui_template_loupe.css | 11 +++++- ui_template/ui_template_loupe.ts | 33 ++++++++++------- 4 files changed, 84 insertions(+), 28 deletions(-) diff --git a/resources/tester/helper_218_1_loupe.cjs b/resources/tester/helper_218_1_loupe.cjs index 1d8e2030c..be3b7ea74 100644 --- a/resources/tester/helper_218_1_loupe.cjs +++ b/resources/tester/helper_218_1_loupe.cjs @@ -489,7 +489,8 @@ function makePage() { return app; } function fakeDocument(app) { - const doc = { body: new El("BODY") }; + // 374-1 뜨는 자리 = body(모달 포함) · 원 · 배율 글은 html 바로 아래 + const doc = { body: app, documentElement: new El("HTML") }; doc.createElement = (tag) => new El(tag.toUpperCase()); doc.getElementById = (id) => (id === "app" ? app : null); return doc; @@ -515,10 +516,12 @@ function fakeDocument(app) { }; const view = m.createLoupeView(win); view.show(500, 400); - const root = doc.body.children[0]; - const label = doc.body.children[1]; + const root = doc.documentElement.children[0]; + const label = doc.documentElement.children[1]; const stage = root.children[0]; - const page = stage.children[0]; + const scaler = stage.children[0]; // 이동 = stage · 배율 = scaler + const page = scaler.children[0]; + const lens = () => `${stage.style.transform} ${scaler.style.transform}`; const copy = page.children[0]; const [scroller, offscreen, table, svg, frame, cadBox] = copy.children; const [canvas, blank, input, check] = scroller.children; @@ -529,7 +532,8 @@ function fakeDocument(app) { ariaHidden: root.attrs["aria-hidden"], hidden: root.hidden, rootTransform: root.style.transform, - stageTransform: stage.style.transform, + stageTransform: lens(), + stageMoveOnly: stage.style.transform, pageBox: [page.style.left, page.style.top, page.style.width, page.style.height], isClone: copy.cloned === true && copy !== app, canvasDrawn: canvas.drawn.map((d) => d[0]), @@ -560,19 +564,31 @@ function fakeDocument(app) { hidden: label.hidden, }; view.setZoom(3); - r.label.zoom3 = [label.textContent, stage.style.transform, root.style.transform]; + r.label.zoom3 = [label.textContent, lens(), root.style.transform]; view.move(500, 790); r.label.bottom = label.style.transform; view.setZoom(2); view.move(500, 400); - // 화면이 바뀜 — 0.5 초 뒤 한 번만 다시 뜸 + // 화면이 바뀜 — 멈춘 뒤 0.5 초에 한 번만 다시 뜸 const firstCopy = copy; observers[0].fn(); observers[0].fn(); win.tick(499); - const sameBefore = stage.children[0].children[0] === firstCopy; + const sameBefore = scaler.children[0].children[0] === firstCopy; win.tick(1); - const recaptured = stage.children[0].children[0] !== firstCopy && stage.children.length === 1; + const recaptured = scaler.children[0].children[0] !== firstCopy && scaler.children.length === 1; + // 374-1 움직이는 동안 안 뜸 — 바뀜 뒤 움직이면 처음부터 · 멈춘 뒤 0.5 초에 한 번 + const moving = scaler.children[0].children[0]; + observers[0].fn(); + win.tick(300); + view.move(520, 400); + win.tick(300); + view.move(540, 400); + win.tick(499); + const heldWhileMoving = scaler.children[0].children[0] === moving; + win.tick(1); + r.debounce = { heldWhileMoving, afterStop: scaler.children[0].children[0] !== moving }; + view.move(500, 400); view.move(10, 10); const nearEdge = root.style.transform; view.hide(); @@ -580,20 +596,21 @@ function fakeDocument(app) { sameBefore, recaptured, nearEdge, - stageChildren: stage.children.length, + stageChildren: scaler.children.length, hidden: root.hidden, disconnected: observers[0].watching === null, scrollListen: win.calls.some((c) => c.type === "scroll"), }; observers[0].fn(); win.tick(1000); - r.hide.noRecaptureAfterHide = stage.children.length; + r.hide.noRecaptureAfterHide = scaler.children.length; r.hide.labelHidden = label.hidden; view.setZoom(4); view.hide(); view.show(500, 400); - r.hide.reopenLabel = [label.textContent, label.hidden, stage.style.transform]; - r.hide.reuseRoot = doc.body.children.length; + r.hide.reopenLabel = [label.textContent, label.hidden, lens()]; + r.hide.reuseRoot = doc.documentElement.children.length; + r.hide.bodyUntouched = app.children.length; // 원이 body 에 안 붙음 } { // 221-2 스크롤 — 스크롤한 뒤 켬 · 켠 채 스크롤(칸 · 문서) @@ -606,8 +623,9 @@ function fakeDocument(app) { 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]; + const scaler = () => doc.documentElement.children[0].children[0].children[0]; + const cloneScroller = () => scaler().children[0].children[0].children[0]; + const page = () => scaler().children[0]; view.show(500, 400); view.hide(); scroller.scrollTop = 0; // 0 에서 켬 → 스크롤 칸으로 잡힘 diff --git a/resources/tester/test_218_1_loupe.py b/resources/tester/test_218_1_loupe.py index 9568efe79..c3f5f20bd 100644 --- a/resources/tester/test_218_1_loupe.py +++ b/resources/tester/test_218_1_loupe.py @@ -162,6 +162,26 @@ def test_바뀌면_0_5초_뒤_다시_뜸_끄면_복제_지움(r): assert h["hidden"] and h["disconnected"] and h["scrollListen"] is False assert h["noRecaptureAfterHide"] == 0 assert h["reuseRoot"] == 2 # 원 · 배율 글 하나씩을 다시 씀 + assert h["bodyUntouched"] == 6 # 원 · 글은 html 바로 아래 — body 복제 · 관찰에 안 걸림(374-1) + + +def test_움직이는_동안_안_뜸_멈춘_뒤_한_번(r): + # 374-1 디바운스 — 바뀜 뒤 계속 움직이면 안 뜸 · 마지막 움직임 0.5 초 뒤 한 번 + assert r["debounce"] == {"heldWhileMoving": True, "afterStop": True} + # 이동 = 바깥 판 translate 만(합성층) · 배율 = 안쪽 상자 + assert r["capture"]["stageMoveOnly"] == "translate(-820px, -620px)" + css = _read("ui_template/ui_template_loupe.css") + stage = css[css.index(".ui-loupe__stage {") :] + assert "will-change: transform;" in stage[: stage.index("}")] + scale = css[css.index(".ui-loupe__scale {") :] + assert "transform-origin: 0 0;" in scale[: scale.index("}")] + + +def test_뜨는_자리_화면_전체_모달_포함(): + src = _read("ui_template/ui_template_loupe.ts") + assert "sourceOf: () => Element | null = () => win.document.body," in src + assert "doc.documentElement.append(root, label);" in src + assert 'getElementById("app")' not in src def test_테두리_그림자_토큰(): diff --git a/ui_template/ui_template_loupe.css b/ui_template/ui_template_loupe.css index 5961245a9..c3471fca1 100644 --- a/ui_template/ui_template_loupe.css +++ b/ui_template/ui_template_loupe.css @@ -24,13 +24,20 @@ display: none; } -/* 복제를 놓는 판 — 화면(뷰포트) 크기 · fixed 자식의 기준 상자 */ +/* 복제를 놓는 판 — 화면(뷰포트) 크기 · 움직임은 translate 만(합성층 · 374-1) */ .ui-loupe__stage { position: absolute; left: 0; top: 0; width: 100vw; height: 100dvh; + will-change: transform; +} + +/* 배율 상자 — 배율 바꿀 때만 scale · fixed 자식의 기준 상자 */ +.ui-loupe__scale { + position: absolute; + inset: 0; transform-origin: 0 0; } @@ -55,6 +62,8 @@ border-radius: 9px; background: var(--color-bg); color: var(--color-accent); + /* html 바로 아래라 body 글꼴을 안 물려받음 */ + font-family: var(--font-body); font-size: 11px; line-height: 18px; box-shadow: 0 0 0 1px var(--color-accent); diff --git a/ui_template/ui_template_loupe.ts b/ui_template/ui_template_loupe.ts index 68a0cfee4..a7768fb54 100644 --- a/ui_template/ui_template_loupe.ts +++ b/ui_template/ui_template_loupe.ts @@ -5,9 +5,10 @@ * 켜는 법 둘 — 오른쪽 단추를 0.4 초 넘게 누르고 있는 동안 · Z 키를 누르고 있는 동안 · 떼면 꺼짐. * 켜 있는 동안 휠 = 배율(220-1 · 1.5 ~ 6배 · 한 칸 0.25 · 켤 때마다 2배) · 화면 스크롤 · 그래프 휠 줌은 막음. * 길게 누른 우클릭은 뗀 뒤 메뉴(우리 메뉴 · 브라우저 메뉴)를 안 띄움 · 짧게 누르면 지금 우클릭 그대로. - * 그리기 — 켤 때 `#app` 을 한 번 떠서(DOM 복제 + 2D canvas 그림 복사 · SVG 그대로) 원 안에 놓고, 움직일 때 transform 만 바꿈. - * 화면 밖 상자는 속을 비우고 크기만 남김 · 켜 있는 동안 화면이 바뀌면 0.5 초 뒤 다시 뜸 · 끄면 복제를 지움. - * 스크롤(221-2) — 뜰 때 스크롤 칸마다 scrollTop · Left 를 복제에 옮김 · 켜진 동안 어느 칸이든 스크롤하면 그 화면 틀(rAF 한 번)에 위치를 맞추고 0.5 초 뒤 다시 뜸(비워 둔 화면 밖 칸을 채움). + * 그리기 — 켤 때 지금 보이는 화면 전체(`body` · 모달 포함 · 374-1)를 한 번 떠서(DOM 복제 + 2D canvas 그림 복사 · SVG 그대로) 원 안에 놓고, + * 움직일 때 바깥 판(합성층) translate 만 바꿈 · 배율은 안쪽 상자 scale. 원 · 배율 글은 `html` 바로 아래(복제 · 관찰에 안 걸림). + * 화면 밖 상자는 속을 비우고 크기만 남김 · 켜 있는 동안 화면이 바뀌면 멈춘 뒤 0.5 초에 한 번 다시 뜸(움직이는 동안 안 뜸) · 끄면 복제를 지움. + * 스크롤(221-2) — 뜰 때 스크롤 칸마다 scrollTop · Left 를 복제에 옮김 · 켜진 동안 어느 칸이든 스크롤하면 그 화면 틀(rAF 한 번)에 위치를 맞추고 멈춘 뒤 0.5 초에 다시 뜸(비워 둔 화면 밖 칸을 채움). * WebGL canvas 는 그림이 남아 있을 때만 옮겨짐. * CAD 영역(cad_host 상자 · iframe · 221-1) — 커서가 그 위면 켜지 않고 · 켜진 채 들어가면 숨김(나오면 다시) · 휠도 CAD 로 · 복제는 빈칸. * ========================================================================== */ @@ -28,7 +29,7 @@ export const LOUPE_ZOOM_STEP = 0.25; const LOUPE_LABEL_H = 18; /** 오른쪽 단추를 이만큼(ms) 넘게 누르면 켬 */ export const LOUPE_HOLD_MS = 400; -/** 켜 있는 동안 화면이 바뀌면 이만큼(ms) 뒤 다시 뜸 */ +/** 켜 있는 동안 화면이 바뀌면 멈춘 뒤 이만큼(ms) 지나 다시 뜸 */ export const LOUPE_REFRESH_MS = 500; /** 화면 끝과 원 사이 여백(px) — 테두리가 안 잘리게 */ export const LOUPE_EDGE = 4; @@ -242,11 +243,12 @@ export function initLoupe( /** 원 · 복제 · 다시 뜨기를 맡음 — `sourceOf()` 가 뜰 영역 */ export function createLoupeView( win: Window, - sourceOf: () => Element | null = () => win.document.getElementById("app"), + sourceOf: () => Element | null = () => win.document.body, ): LoupeView { const doc = win.document; let root: HTMLElement | null = null; - let stage: HTMLElement | null = null; + let stage: HTMLElement | null = null; // 이동만(합성층) + let scaler: HTMLElement | null = null; // 배율만 · 복제를 담음 let label: HTMLElement | null = null; let zoom = LOUPE_ZOOM; let at: [number, number] = [0, 0]; // 마지막 커서 — 배율만 바뀔 때 다시 놓음 @@ -263,23 +265,28 @@ export function createLoupeView( root.style.width = root.style.height = `${LOUPE_DIAMETER}px`; stage = doc.createElement("div"); stage.className = "ui-loupe__stage"; + scaler = doc.createElement("div"); + scaler.className = "ui-loupe__scale"; + stage.append(scaler); const cross = doc.createElement("div"); cross.className = "ui-loupe__cross"; root.append(stage, cross); label = doc.createElement("div"); label.className = "ui-loupe__zoom"; label.setAttribute("aria-hidden", "true"); - doc.body.append(root, label); + // body 밖 — body 를 뜨고 지켜보므로 원이 자기 복제 · 자기 움직임 관찰에 안 걸림 + doc.documentElement.append(root, label); }; const capture = (): void => { const source = sourceOf(); const t0 = win.performance?.now() ?? 0; - stage!.replaceChildren(); - if (source) sync = captureInto(source, stage!, win); + scaler!.replaceChildren(); + if (source) sync = captureInto(source, scaler!, win); root!.dataset.captureMs = String(Math.round((win.performance?.now() ?? 0) - t0)); }; + /** 다시 뜨기 미룸 — 바뀜 · 스크롤 · 움직임마다 처음부터(멈춘 뒤 한 번 · 374-1) */ const refresh = (): void => { - if (refreshTimer) return; + win.clearTimeout(refreshTimer); refreshTimer = win.setTimeout(() => { refreshTimer = 0; if (observer) capture(); @@ -316,16 +323,18 @@ export function createLoupeView( move(x, y) { if (!root || !stage || !label) return; at = [x, y]; + if (refreshTimer) refresh(); // 움직이는 동안 안 뜸 const { left, top } = loupeOrigin(x, y, win.innerWidth, win.innerHeight); const r = LOUPE_DIAMETER / 2; root.style.transform = `translate(${left}px, ${top}px)`; - stage.style.transform = `translate(${r - zoom * x}px, ${r - zoom * y}px) scale(${zoom})`; + stage.style.transform = `translate(${r - zoom * x}px, ${r - zoom * y}px)`; const labelTop = Math.min(top + LOUPE_DIAMETER + 4, win.innerHeight - LOUPE_LABEL_H - 4); label.style.transform = `translate(${left + r}px, ${labelTop}px) translateX(-50%)`; }, setZoom(z) { zoom = z; if (label) label.textContent = `×${z}`; + if (scaler) scaler.style.transform = `scale(${z})`; view.move(...at); }, hide() { @@ -336,7 +345,7 @@ export function createLoupeView( win.cancelAnimationFrame(syncFrame); syncFrame = 0; win.removeEventListener("scroll", onScroll, true); - stage?.replaceChildren(); // 복제를 지움 + scaler?.replaceChildren(); // 복제를 지움 if (root) root.hidden = label!.hidden = true; }, };