Files
Aislo/ui_template/ui_template_loupe.ts
T

434 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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`,
});
}