Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1

This commit is contained in:
2026-10-10 16:30:30 +09:00
3 changed files with 137 additions and 15 deletions
+56 -2
View File
@@ -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));
@@ -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
+39 -13
View File
@@ -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);
}