fix(ui): 돋보기 스크롤 따라감 — 다시 켤 때 원을 먼저 보여 스크롤 옮김이 먹게 · 켠 채 스크롤은 rAF 한 번에 칸 · 페이지 위치 맞춤 · 0.5 초 뒤 다시 뜸 (221-2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
This commit is contained in:
2026-10-10 16:29:34 +09:00
co-authored by Claude Opus 5.5
parent a5f5cac739
commit 8379836748
3 changed files with 137 additions and 15 deletions
+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);
}