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

This commit is contained in:
2026-10-17 12:07:25 +09:00
24 changed files with 375 additions and 238 deletions
+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;
},
};
+3
View File
@@ -370,6 +370,9 @@
--color-primary-text: #16121f;
--color-accent: var(--color-lavender-wash);
--color-focus-ring: var(--color-lavender-wash);
--color-success: #3fbb6f;
--color-danger: #e8695a;
--panel-handle-border: #e0b64a;
--map-route-locked: #f472b6;
}