Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
434 lines
15 KiB
TypeScript
434 lines
15 KiB
TypeScript
/* =============================================================================
|
||
* ui_template_loupe.ts
|
||
* 돋보기 — 커서를 따라가는 원 안에 지금 화면을 크게 보여 줌(계획서 218-1) · 공용 진입(`A00_Common/main.ts`)이 B01~B09 · M01 · M02 에 붙임(220).
|
||
*
|
||
* 켜는 법 둘 — 오른쪽 단추를 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 초 뒤 다시 뜸(비워 둔 화면 밖 칸을 채움).
|
||
* WebGL canvas 는 그림이 남아 있을 때만 옮겨짐.
|
||
* CAD 영역(cad_host 상자 · iframe · 221-1) — 커서가 그 위면 켜지 않고 · 켜진 채 들어가면 숨김(나오면 다시) · 휠도 CAD 로 · 복제는 빈칸.
|
||
* ========================================================================== */
|
||
|
||
import "./ui_template_loupe.css";
|
||
import { keyLetter } from "../A00_Common/key_shortcut";
|
||
|
||
/** 원 지름(px) */
|
||
export const LOUPE_DIAMETER = 360;
|
||
/** 켤 때 배율 */
|
||
export const LOUPE_ZOOM = 2;
|
||
/** 휠 배율 한도 · 한 칸 */
|
||
export const LOUPE_ZOOM_MIN = 1.5;
|
||
export const LOUPE_ZOOM_MAX = 6;
|
||
export const LOUPE_ZOOM_STEP = 0.25;
|
||
/** 배율 글 높이(px) — 화면 아래 끝에서 안 잘리게 */
|
||
const LOUPE_LABEL_H = 18;
|
||
/** 오른쪽 단추를 이만큼(ms) 넘게 누르면 켬 */
|
||
export const LOUPE_HOLD_MS = 400;
|
||
/** 켜 있는 동안 화면이 바뀌면 이만큼(ms) 뒤 다시 뜸 */
|
||
export const LOUPE_REFRESH_MS = 500;
|
||
/** 화면 끝과 원 사이 여백(px) — 테두리가 안 잘리게 */
|
||
export const LOUPE_EDGE = 4;
|
||
/** 붙일 주소 — B01~B09 · M01 · M02(A 영역 · 로그인 · B10 · B11 은 뺌) */
|
||
export const LOUPE_ROUTE = /^(b0[1-9]|m0[12])-/;
|
||
|
||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||
/** CAD 영역 — cad_host 가 끼운 상자와 iframe */
|
||
export const LOUPE_SKIP = ".cad-host, iframe";
|
||
const NON_TEXT_INPUT = new Set([
|
||
"checkbox",
|
||
"radio",
|
||
"button",
|
||
"submit",
|
||
"reset",
|
||
"range",
|
||
"color",
|
||
"file",
|
||
"image",
|
||
"hidden",
|
||
]);
|
||
/** 화면 밖이어도 속을 안 비움 — 표 칸 너비가 다른 줄 내용에 따라 정해짐 */
|
||
const KEEP_INSIDE = new Set([
|
||
"TABLE",
|
||
"THEAD",
|
||
"TBODY",
|
||
"TFOOT",
|
||
"TR",
|
||
"TD",
|
||
"TH",
|
||
"COLGROUP",
|
||
"COL",
|
||
"CAPTION",
|
||
]);
|
||
|
||
/** Z 키 판정 — 공용 `keyLetter`(214-2 · 한글 자판 「ㅋ」 = 자판 자리 KeyZ) · Ctrl/Alt/Meta 조합은 아님 */
|
||
export function isLoupeKey(
|
||
evt: Pick<KeyboardEvent, "code" | "ctrlKey" | "altKey" | "metaKey"> &
|
||
Partial<Pick<KeyboardEvent, "key">>,
|
||
): boolean {
|
||
return keyLetter(evt) === "z" && !evt.ctrlKey && !evt.altKey && !evt.metaKey;
|
||
}
|
||
|
||
/** 글을 치는 자리 — 글 칸 · 고르기 칸 · contenteditable */
|
||
export function isTypingTarget(target: unknown): boolean {
|
||
const el = target as { tagName?: string; type?: string; isContentEditable?: boolean } | null;
|
||
if (!el?.tagName) return false;
|
||
if (el.isContentEditable) return true;
|
||
const tag = el.tagName.toUpperCase();
|
||
if (tag === "TEXTAREA" || tag === "SELECT") return true;
|
||
return tag === "INPUT" && !NON_TEXT_INPUT.has((el.type || "text").toLowerCase());
|
||
}
|
||
|
||
/** CAD 영역 위인지 — 돋보기를 안 띄우는 자리(221-1) */
|
||
export function isCadTarget(target: unknown): boolean {
|
||
return !!(target as Partial<Element> | null)?.closest?.(LOUPE_SKIP);
|
||
}
|
||
|
||
/** 휠 한 번 뒤 배율 — 위로 굴림(deltaY < 0) = 크게 · 한도 안 */
|
||
export function nextLoupeZoom(zoom: number, deltaY: number): number {
|
||
const step = Math.sign(-deltaY) * LOUPE_ZOOM_STEP;
|
||
return Math.max(LOUPE_ZOOM_MIN, Math.min(LOUPE_ZOOM_MAX, zoom + step));
|
||
}
|
||
|
||
/** 원 왼 위 자리 — 커서 가운데 · 화면 밖으로 안 나감 */
|
||
export function loupeOrigin(
|
||
x: number,
|
||
y: number,
|
||
vw: number,
|
||
vh: number,
|
||
): { left: number; top: number } {
|
||
const clamp = (v: number, max: number): number => Math.max(LOUPE_EDGE, Math.min(v, max));
|
||
const r = LOUPE_DIAMETER / 2;
|
||
return {
|
||
left: clamp(x - r, vw - LOUPE_DIAMETER - LOUPE_EDGE),
|
||
top: clamp(y - r, vh - LOUPE_DIAMETER - LOUPE_EDGE),
|
||
};
|
||
}
|
||
|
||
export interface LoupeView {
|
||
show(x: number, y: number): void;
|
||
move(x: number, y: number): void;
|
||
setZoom(zoom: number): void;
|
||
hide(): void;
|
||
}
|
||
|
||
/** `win` 에 두 켜는 법을 붙임 — `isOn()` 이 거짓이면(붙일 주소 밖) 안 켬 */
|
||
export function initLoupe(
|
||
win: Window,
|
||
isOn: () => boolean,
|
||
view: LoupeView = createLoupeView(win),
|
||
): void {
|
||
let x = win.innerWidth / 2;
|
||
let y = win.innerHeight / 2;
|
||
let press = false; // 오른쪽 길게 누름
|
||
let key = false; // Z 누름
|
||
let shown = false; // 켜짐
|
||
let seen = false; // 원이 보임 — 켜짐이어도 CAD 위면 숨김
|
||
let overCad = false;
|
||
let holdTimer = 0;
|
||
let swallowMenu = false; // 길게 누른 뒤 따라오는 우클릭 메뉴 한 번을 먹음
|
||
let zoom = LOUPE_ZOOM;
|
||
|
||
const update = (): void => {
|
||
const want = (press || key) && isOn();
|
||
if (want && !shown) zoom = LOUPE_ZOOM; // 켤 때마다 2배
|
||
const see = want && !overCad;
|
||
if (see && !seen) {
|
||
view.show(x, y);
|
||
if (zoom !== LOUPE_ZOOM) view.setZoom(zoom); // CAD 에서 나옴 — 배율 그대로
|
||
} else if (!see && seen) view.hide();
|
||
shown = want;
|
||
seen = see;
|
||
};
|
||
const track = (e: MouseEvent): void => {
|
||
x = e.clientX;
|
||
y = e.clientY;
|
||
const cad = isCadTarget(e.target);
|
||
if (cad === overCad) return;
|
||
overCad = cad;
|
||
update();
|
||
};
|
||
const releaseZ = (e: KeyboardEvent): void => {
|
||
if (keyLetter(e) !== "z" || !key) return;
|
||
key = false;
|
||
update();
|
||
};
|
||
const stopHold = (): void => {
|
||
win.clearTimeout(holdTimer);
|
||
holdTimer = 0;
|
||
};
|
||
const on = <K extends keyof WindowEventMap>(type: K, fn: (e: WindowEventMap[K]) => void): void =>
|
||
win.addEventListener(type, fn, true);
|
||
|
||
on("mousemove", (e) => {
|
||
track(e);
|
||
if (!(e.buttons & 2) && (holdTimer || press)) {
|
||
stopHold(); // 창 밖에서 뗌 — mouseup 이 안 옴
|
||
press = false;
|
||
update();
|
||
}
|
||
if (seen) view.move(x, y);
|
||
});
|
||
on("mouseover", track); // iframe 위로 들어가면 mousemove 가 안 옴 — 들어갈 때 한 번
|
||
|
||
on("mousedown", (e) => {
|
||
if (e.button !== 2) return;
|
||
swallowMenu = false;
|
||
stopHold();
|
||
if (!isOn() || isCadTarget(e.target)) return;
|
||
x = e.clientX;
|
||
y = e.clientY;
|
||
holdTimer = win.setTimeout(() => {
|
||
holdTimer = 0;
|
||
press = true;
|
||
swallowMenu = true;
|
||
update();
|
||
}, LOUPE_HOLD_MS);
|
||
});
|
||
on("mouseup", (e) => {
|
||
if (e.button !== 2) return;
|
||
stopHold();
|
||
if (!press) return;
|
||
press = false;
|
||
update();
|
||
});
|
||
on("contextmenu", (e) => {
|
||
if (!swallowMenu) return;
|
||
swallowMenu = false;
|
||
e.preventDefault();
|
||
e.stopImmediatePropagation(); // 우리 메뉴도 안 뜸
|
||
});
|
||
on("keydown", (e) => {
|
||
if (!isLoupeKey(e) || isTypingTarget(e.target)) return;
|
||
key = true;
|
||
update();
|
||
});
|
||
on("keyup", releaseZ);
|
||
// 켜 있는 동안만 휠을 잡음 — 잡기 단계 첫 자리 · passive:false 여야 스크롤을 막음
|
||
win.addEventListener(
|
||
"wheel",
|
||
(e) => {
|
||
if (!seen) return; // CAD 위 — CAD 휠 줌 그대로
|
||
e.preventDefault();
|
||
e.stopImmediatePropagation(); // 표 · 그래프 휠 줌도 안 받음
|
||
zoom = nextLoupeZoom(zoom, e.deltaY);
|
||
view.setZoom(zoom);
|
||
},
|
||
{ capture: true, passive: false },
|
||
);
|
||
on("blur", (e) => {
|
||
if (e.target !== win) return; // 칸 초점 빠짐은 무시 — 창이 초점을 잃을 때만
|
||
const active = win.document?.activeElement as HTMLIFrameElement | null | undefined;
|
||
if (isCadTarget(active)) {
|
||
// 커서가 CAD 로 들어가 초점이 넘어감(cad_host) — 끄지 않고 숨김만 · Z 떼기는 그쪽 창에서 들음
|
||
try {
|
||
active?.contentWindow?.addEventListener("keyup", releaseZ, true);
|
||
} catch {
|
||
// 다른 출처 — 나올 때 Z 를 다시 눌러야 꺼짐
|
||
}
|
||
overCad = true;
|
||
update();
|
||
return;
|
||
}
|
||
stopHold();
|
||
press = key = false;
|
||
update();
|
||
});
|
||
}
|
||
|
||
/** 원 · 복제 · 다시 뜨기를 맡음 — `sourceOf()` 가 뜰 영역 */
|
||
export function createLoupeView(
|
||
win: Window,
|
||
sourceOf: () => Element | null = () => win.document.getElementById("app"),
|
||
): LoupeView {
|
||
const doc = win.document;
|
||
let root: HTMLElement | null = null;
|
||
let stage: HTMLElement | null = null;
|
||
let label: HTMLElement | null = null;
|
||
let zoom = LOUPE_ZOOM;
|
||
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");
|
||
root.className = "ui-loupe";
|
||
root.setAttribute("aria-hidden", "true");
|
||
root.inert = true;
|
||
root.style.width = root.style.height = `${LOUPE_DIAMETER}px`;
|
||
stage = doc.createElement("div");
|
||
stage.className = "ui-loupe__stage";
|
||
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);
|
||
};
|
||
const capture = (): void => {
|
||
const source = sourceOf();
|
||
const t0 = win.performance?.now() ?? 0;
|
||
stage!.replaceChildren();
|
||
if (source) sync = captureInto(source, stage!, win);
|
||
root!.dataset.captureMs = String(Math.round((win.performance?.now() ?? 0) - t0));
|
||
};
|
||
const refresh = (): void => {
|
||
if (refreshTimer) return;
|
||
refreshTimer = win.setTimeout(() => {
|
||
refreshTimer = 0;
|
||
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();
|
||
at = [x, y];
|
||
view.setZoom(LOUPE_ZOOM); // 켤 때마다 2배 · 자리도 놓음
|
||
const source = sourceOf();
|
||
const Observer = (win as Window & typeof globalThis).MutationObserver;
|
||
const watch = new Observer(refresh);
|
||
if (source)
|
||
watch.observe(source, {
|
||
subtree: true,
|
||
childList: true,
|
||
attributes: true,
|
||
characterData: true,
|
||
});
|
||
observer = watch;
|
||
win.addEventListener("scroll", onScroll, true);
|
||
},
|
||
move(x, y) {
|
||
if (!root || !stage || !label) return;
|
||
at = [x, y];
|
||
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})`;
|
||
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}`;
|
||
view.move(...at);
|
||
},
|
||
hide() {
|
||
observer?.disconnect();
|
||
observer = null;
|
||
win.clearTimeout(refreshTimer);
|
||
refreshTimer = 0;
|
||
win.cancelAnimationFrame(syncFrame);
|
||
syncFrame = 0;
|
||
win.removeEventListener("scroll", onScroll, true);
|
||
stage?.replaceChildren(); // 복제를 지움
|
||
if (root) root.hidden = label!.hidden = true;
|
||
},
|
||
};
|
||
return view;
|
||
}
|
||
|
||
/** `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");
|
||
page.className = "ui-loupe__page";
|
||
Object.assign(page.style, {
|
||
left: `${rect.left}px`,
|
||
top: `${rect.top}px`,
|
||
width: `${rect.width}px`,
|
||
height: `${rect.height}px`,
|
||
});
|
||
const copy = source.cloneNode(true) as Element;
|
||
const scrolls: [Element, Element][] = []; // [원본, 복제] 스크롤 칸
|
||
walk(source, copy, doc, win.innerWidth, win.innerHeight, scrolls);
|
||
page.append(copy);
|
||
stage.append(page);
|
||
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(
|
||
o: Element,
|
||
c: Element,
|
||
doc: Document,
|
||
vw: number,
|
||
vh: number,
|
||
scrolls: [Element, Element][],
|
||
): void {
|
||
if (o.namespaceURI === SVG_NS) return; // SVG 는 복제 그대로
|
||
const tag = o.tagName.toUpperCase();
|
||
if (tag === "CANVAS") return drawCanvas(o as HTMLCanvasElement, c as HTMLCanvasElement);
|
||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return copyValue(o, c);
|
||
const r = o.getBoundingClientRect();
|
||
if (tag === "IFRAME") {
|
||
const box = doc.createElement("div"); // 복제 iframe 은 CAD 를 또 띄움 — 빈 상자로 바꿈
|
||
c.replaceWith(box);
|
||
return emptyBox(box, r);
|
||
}
|
||
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 ||
|
||
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);
|
||
}
|
||
|
||
function drawCanvas(o: HTMLCanvasElement, c: HTMLCanvasElement): void {
|
||
try {
|
||
if (o.width && o.height) c.getContext("2d")?.drawImage(o, 0, 0);
|
||
} catch {
|
||
// 못 옮기면 빈칸
|
||
}
|
||
}
|
||
|
||
function copyValue(o: Element, c: Element): void {
|
||
const from = o as HTMLInputElement;
|
||
const to = c as HTMLInputElement;
|
||
try {
|
||
if (from.type !== "file") to.value = from.value;
|
||
to.checked = from.checked;
|
||
} catch {
|
||
// 값을 못 옮겨도 모양은 그대로
|
||
}
|
||
}
|
||
|
||
/** 속을 비우고 크기만 남김 — 화면 밖 · CAD 영역 */
|
||
function emptyBox(c: Element, r: DOMRect): void {
|
||
c.replaceChildren();
|
||
Object.assign((c as HTMLElement).style, {
|
||
boxSizing: "border-box",
|
||
width: `${r.width}px`,
|
||
height: `${r.height}px`,
|
||
});
|
||
}
|