fix(ui): 374-1 돋보기 = 지금 보이는 화면 전체(body · 모달 포함) · 움직이는 동안 안 뜸 · 이동 합성층

- 복제 원본 #app → document.body(모달 포함) · 원 · 배율 글은 html 바로 아래(자기 복제 · 관찰 안 걸림)
- 다시 뜨기 스로틀 → 디바운스(바뀜 · 스크롤 · 움직임마다 미룸 · 멈춘 뒤 0.5 초 한 번)
- 바깥 판 translate 만(will-change) · 안쪽 상자 scale
- 시험 helper_218_1 · test_218_1 고쳐 씀(디바운스 · 원 자리 · 원본)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
This commit is contained in:
2026-10-17 11:58:48 +09:00
co-authored by Claude Opus 5.5
parent 04dd53e5e6
commit 2a66130446
4 changed files with 84 additions and 28 deletions
+33 -15
View File
@@ -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 에서 켬 → 스크롤 칸으로 잡힘
+20
View File
@@ -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_테두리_그림자_토큰():
+10 -1
View File
@@ -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);
+21 -12
View File
@@ -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;
},
};