/* ============================================================================= * ui_template_loupe.ts * 돋보기 — 커서를 따라가는 원 안에 지금 화면을 크게 보여 줌(계획서 218-1) · 공용 진입(`A00_Common/main.ts`)이 B05~B09 에 붙임. * * 켜는 법 둘 — 오른쪽 단추를 0.4 초 넘게 누르고 있는 동안 · Z 키를 누르고 있는 동안 · 떼면 꺼짐. * 길게 누른 우클릭은 뗀 뒤 메뉴(우리 메뉴 · 브라우저 메뉴)를 안 띄움 · 짧게 누르면 지금 우클릭 그대로. * 그리기 — 켤 때 `#app` 을 한 번 떠서(DOM 복제 + 2D canvas 그림 복사 · SVG 그대로) 원 안에 놓고, 움직일 때 transform 만 바꿈. * 화면 밖 상자는 속을 비우고 크기만 남김 · 켜 있는 동안 화면이 바뀌면 0.5 초 뒤 다시 뜸 · 끄면 복제를 지움. * WebGL canvas 는 그림이 남아 있을 때만 옮겨짐 · 같은 출처 iframe(CAD)은 안의 canvas 만 옮김 — 못 옮기면 그 자리 빈칸. * ========================================================================== */ import "./ui_template_loupe.css"; import { keyLetter } from "../A00_Common/key_shortcut"; /** 원 지름(px) */ export const LOUPE_DIAMETER = 240; /** 배율 */ export const LOUPE_ZOOM = 2; /** 오른쪽 단추를 이만큼(ms) 넘게 누르면 켬 */ export const LOUPE_HOLD_MS = 400; /** 켜 있는 동안 화면이 바뀌면 이만큼(ms) 뒤 다시 뜸 */ export const LOUPE_REFRESH_MS = 500; /** 화면 끝과 원 사이 여백(px) — 테두리가 안 잘리게 */ export const LOUPE_EDGE = 4; /** 붙일 주소 — B05~B09 */ export const LOUPE_ROUTE = /^b0[5-9]/; const SVG_NS = "http://www.w3.org/2000/svg"; 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 & Partial>, ): 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()); } /** 원 왼 위 자리 — 커서 가운데 · 화면 밖으로 안 나감 */ 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; hide(): void; } /** `win` 에 두 켜는 법을 붙임 — `isOn()` 이 거짓이면(B05~B09 밖) 안 켬 */ 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 holdTimer = 0; let swallowMenu = false; // 길게 누른 뒤 따라오는 우클릭 메뉴 한 번을 먹음 const update = (): void => { const want = (press || key) && isOn(); if (want && !shown) view.show(x, y); else if (!want && shown) view.hide(); shown = want; }; const stopHold = (): void => { win.clearTimeout(holdTimer); holdTimer = 0; }; const on = (type: K, fn: (e: WindowEventMap[K]) => void): void => win.addEventListener(type, fn, true); on("mousemove", (e) => { x = e.clientX; y = e.clientY; if (!(e.buttons & 2) && (holdTimer || press)) { stopHold(); // 창 밖에서 뗌 — mouseup 이 안 옴 press = false; update(); } if (shown) view.move(x, y); }); on("mousedown", (e) => { if (e.button !== 2) return; swallowMenu = false; stopHold(); if (!isOn()) 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", (e) => { if (keyLetter(e) !== "z" || !key) return; key = false; update(); }); on("blur", (e) => { if (e.target !== win) return; // 칸 초점 빠짐은 무시 — 창이 초점을 잃을 때만(CAD iframe 으로 넘어감 포함) 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 observer: MutationObserver | null = null; let refreshTimer = 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); doc.body.append(root); }; const capture = (): void => { const source = sourceOf(); const t0 = win.performance?.now() ?? 0; stage!.replaceChildren(); if (source) 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 view: LoupeView = { show(x, y) { if (!root) build(); capture(); root!.hidden = false; view.move(x, y); 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", refresh, true); }, move(x, y) { if (!root || !stage) return; 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 - LOUPE_ZOOM * x}px, ${r - LOUPE_ZOOM * y}px) scale(${LOUPE_ZOOM})`; }, hide() { observer?.disconnect(); observer = null; win.clearTimeout(refreshTimer); refreshTimer = 0; win.removeEventListener("scroll", refresh, true); stage?.replaceChildren(); // 복제를 지움 if (root) root.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, number, number][] = []; 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; } } function walk( o: Element, c: Element, doc: Document, vw: number, vh: number, scrolls: [Element, number, number][], ): 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 === "IFRAME") return void c.replaceWith(frameShot(o as HTMLIFrameElement, doc)); if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return copyValue(o, c); const r = o.getBoundingClientRect(); if (!KEEP_INSIDE.has(tag) && (r.bottom < 0 || r.right < 0 || r.top > vh || r.left > vw)) { c.replaceChildren(); // 화면 밖 — 속을 비우고 크기만 Object.assign((c as HTMLElement).style, { boxSizing: "border-box", width: `${r.width}px`, height: `${r.height}px`, }); return; } if (o.scrollTop || o.scrollLeft) scrolls.push([c, o.scrollTop, o.scrollLeft]); 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 { // 값을 못 옮겨도 모양은 그대로 } } /** iframe 자리 — 같은 출처면 안의 canvas 를 한 장에 모아 그림 · 아니면 빈칸 */ function frameShot(frame: HTMLIFrameElement, doc: Document): HTMLCanvasElement { const r = frame.getBoundingClientRect(); const shot = doc.createElement("canvas"); shot.className = frame.className; shot.width = Math.max(1, Math.round(r.width)); shot.height = Math.max(1, Math.round(r.height)); Object.assign(shot.style, { display: "block", width: `${r.width}px`, height: `${r.height}px` }); try { const ctx = shot.getContext("2d"); for (const canvas of Array.from(frame.contentDocument?.querySelectorAll("canvas") ?? [])) { const cr = canvas.getBoundingClientRect(); if (canvas.width && canvas.height) ctx?.drawImage(canvas, cr.left, cr.top, cr.width, cr.height); } } catch { // 다른 출처 · 그림 없음 — 빈칸 } return shot; }