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:
@@ -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 에서 켬 → 스크롤 칸으로 잡힘
|
||||
|
||||
@@ -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_테두리_그림자_토큰():
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user