From 0d560350bda21ef5b036688a72406621d0a8ace0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 15:39:03 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EA=B3=B5=EC=9A=A9):=20B05~B09=20=EB=8F=8B?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=E2=80=94=20=EC=98=A4=EB=A5=B8=EC=AA=BD=20?= =?UTF-8?q?=EA=B8=B8=EA=B2=8C=20=EB=88=84=EB=A6=84=200.4=20=EC=B4=88=20?= =?UTF-8?q?=C2=B7=20Z=20=EB=88=84=EB=A6=84=20=EB=8F=99=EC=95=88=20?= =?UTF-8?q?=EC=BB=A4=EC=84=9C=20=EB=94=B0=EB=9D=BC=EA=B0=90=20=C2=B7=20?= =?UTF-8?q?=EC=A7=80=EB=A6=84=20240=20px=20=C2=B7=202=EB=B0=B0(218-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt --- A00_Common/main.ts | 3 + resources/tester/helper_218_1_loupe.cjs | 394 ++++++++++++++++++++++++ resources/tester/test_218_1_loupe.py | 154 +++++++++ ui_template/ui_template_loupe.css | 68 ++++ ui_template/ui_template_loupe.ts | 340 ++++++++++++++++++++ 5 files changed, 959 insertions(+) create mode 100644 resources/tester/helper_218_1_loupe.cjs create mode 100644 resources/tester/test_218_1_loupe.py create mode 100644 ui_template/ui_template_loupe.css create mode 100644 ui_template/ui_template_loupe.ts diff --git a/A00_Common/main.ts b/A00_Common/main.ts index 2d94e6e8b..1d3e8b069 100644 --- a/A00_Common/main.ts +++ b/A00_Common/main.ts @@ -12,6 +12,7 @@ import "./random_uuid_polyfill"; import "@ui/ui_template_theme.css"; import { injectBaseStyles } from "@ui/ui_template_elements"; import { blockNativeMenu, NATIVE_MENU_ROUTE } from "@ui/ui_template_native_menu"; +import { initLoupe, LOUPE_ROUTE } from "@ui/ui_template_loupe"; import { ROUTES, type RoutePath } from "@config/config_frontend"; import { initThemeAndLang, injectShellStyles, renderShell } from "./app_shell"; import { @@ -54,6 +55,8 @@ function bootstrap(): void { // B · M 페이지는 브라우저 기본 오른쪽 클릭 메뉴를 막음 — 우리 메뉴는 그대로(계획서 39-9a) blockNativeMenu(window, () => NATIVE_MENU_ROUTE.test(currentRoute())); + // B05~B09 돋보기 — 오른쪽 길게 누름 · Z 누름 동안(계획서 218-1) + initLoupe(window, () => LOUPE_ROUTE.test(currentRoute())); // 3. 셸 렌더 + 현재 라우트 렌더를 함께 수행하는 헬퍼 let shown = ""; diff --git a/resources/tester/helper_218_1_loupe.cjs b/resources/tester/helper_218_1_loupe.cjs new file mode 100644 index 000000000..e09756ba8 --- /dev/null +++ b/resources/tester/helper_218_1_loupe.cjs @@ -0,0 +1,394 @@ +/* 돋보기(`ui_template/ui_template_loupe.ts` · 계획서 218-1) 단위검증 헬퍼 — + * 그 자리에서 트랜스파일해 Node 로 돌리고 값을 JSON 으로 냄. 가짜 창 · 가짜 시계 · 가짜 요소로 판정. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join(__dirname, "..", "..", "ui_template", "ui_template_loupe.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +const req = (p) => (p.endsWith(".css") ? {} : require(p)); +new Function("exports", "module", "require", js)(box.exports, box, req); +const m = box.exports; + +// ---- 가짜 시계 · 가짜 창 ---- +function fakeWindow(doc) { + const win = { + innerWidth: 1000, + innerHeight: 800, + document: doc, + now: 0, + timers: [], + seq: 0, + calls: [], + }; + win.addEventListener = (type, fn, capture) => win.calls.push({ type, fn, capture }); + win.removeEventListener = (type, fn) => + (win.calls = win.calls.filter((c) => c.type !== type || c.fn !== fn)); + win.setTimeout = (fn, ms) => (win.timers.push({ id: ++win.seq, at: win.now + ms, fn }), win.seq); + win.clearTimeout = (id) => (win.timers = win.timers.filter((t) => t.id !== id)); + win.tick = (ms) => { + win.now += ms; + for (const t of win.timers.filter((t) => t.at <= win.now)) { + win.clearTimeout(t.id); + t.fn(); + } + }; + win.fire = (type, props = {}) => { + const ev = { + type, + button: 0, + buttons: 0, + clientX: 0, + clientY: 0, + code: "", + target: { tagName: "DIV" }, + ...props, + }; + ev.prevented = false; + ev.stopped = false; + ev.preventDefault = () => (ev.prevented = true); + ev.stopImmediatePropagation = () => (ev.stopped = true); + win.calls.filter((c) => c.type === type).forEach((c) => c.fn(ev)); + return ev; + }; + return win; +} +function fakeView() { + const log = []; + return { + log, + show: (x, y) => log.push(["show", x, y]), + move: (x, y) => log.push(["move", x, y]), + hide: () => log.push(["hide"]), + }; +} +const last = (v) => (v.log.length ? v.log[v.log.length - 1][0] : null); + +// ---- 켜는 법 ---- +function setup(on = () => true) { + const win = fakeWindow(null); + const view = fakeView(); + m.initLoupe(win, on, view); + return { win, view }; +} +const r = {}; +r.consts = { + d: m.LOUPE_DIAMETER, + zoom: m.LOUPE_ZOOM, + hold: m.LOUPE_HOLD_MS, + refresh: m.LOUPE_REFRESH_MS, +}; +{ + const { win } = setup(); + r.listeners = win.calls.map((c) => [c.type, c.capture]); +} +{ + // 길게 누름 — 0.4 초 넘어야 켬 · 떼면 끔 · 뒤따르는 메뉴는 먹음 + const { win, view } = setup(); + win.fire("mousedown", { button: 2, buttons: 2, clientX: 300, clientY: 200 }); + win.tick(399); + const before = view.log.length; + win.tick(1); + const on = last(view); + win.fire("mousemove", { buttons: 2, clientX: 310, clientY: 205 }); + const moved = view.log[view.log.length - 1]; + win.fire("mouseup", { button: 2 }); + const off = last(view); + const menu = win.fire("contextmenu"); + const nextMenu = win.fire("contextmenu"); + r.long = { + before, + on, + show: view.log[0], + moved, + off, + menuPrevented: menu.prevented, + menuStopped: menu.stopped, + nextStopped: nextMenu.stopped, + }; +} +{ + // 짧게 누름 — 안 켬 · 메뉴 그대로 + const { win, view } = setup(); + win.fire("mousedown", { button: 2, buttons: 2 }); + win.tick(200); + win.fire("mouseup", { button: 2 }); + win.tick(1000); + const menu = win.fire("contextmenu"); + r.short = { log: view.log.length, menuPrevented: menu.prevented, menuStopped: menu.stopped }; +} +{ + // 왼쪽 단추 · 창 밖에서 뗌 + const { win, view } = setup(); + win.fire("mousedown", { button: 0, buttons: 1 }); + win.tick(1000); + const left = view.log.length; + win.fire("mousedown", { button: 2, buttons: 2 }); + win.fire("mousemove", { buttons: 0 }); + win.tick(1000); + const lostBeforeHold = view.log.length; + win.fire("mousedown", { button: 2, buttons: 2 }); + win.tick(500); + win.fire("mousemove", { buttons: 0 }); + r.mouseMisc = { left, lostBeforeHold, lostAfterHold: last(view) }; +} +{ + // Z 키 + const { win, view } = setup(); + const z = (props) => win.fire("keydown", { code: "KeyZ", ...props }); + z({ key: "ㅋ" }); + const on = last(view); + win.fire("keydown", { code: "KeyZ", repeat: true }); + const repeatLen = view.log.length; + win.fire("keyup", { code: "KeyZ" }); + const off = last(view); + const len = view.log.length; + z({ ctrlKey: true }); + z({ altKey: true }); + z({ metaKey: true }); + win.fire("keydown", { code: "KeyX" }); + const noCombo = view.log.length === len; + const typing = {}; + for (const [k, target] of Object.entries({ + input: { tagName: "INPUT", type: "text" }, + inputNoType: { tagName: "INPUT", type: "" }, + number: { tagName: "INPUT", type: "number" }, + textarea: { tagName: "TEXTAREA" }, + select: { tagName: "SELECT" }, + editable: { tagName: "SPAN", isContentEditable: true }, + checkbox: { tagName: "INPUT", type: "checkbox" }, + button: { tagName: "BUTTON" }, + })) { + const n = view.log.length; + z({ target }); + typing[k] = view.log.length > n; + win.fire("keyup", { code: "KeyZ" }); + } + z({ shiftKey: true }); + const shift = last(view); + win.fire("blur", { target: { tagName: "INPUT", type: "text" } }); + const fieldBlur = last(view); + win.fire("blur", { target: win }); + r.key = { on, repeatLen, off, noCombo, typing, shift, fieldBlur, blur: last(view) }; +} +{ + // B05~B09 밖이면 안 켬 + const { win, view } = setup(() => false); + win.fire("keydown", { code: "KeyZ" }); + win.fire("mousedown", { button: 2, buttons: 2 }); + win.tick(1000); + win.fire("mouseup", { button: 2 }); + const menu = win.fire("contextmenu"); + r.off = { log: view.log.length, menuStopped: menu.stopped }; +} +r.routes = {}; +for (const route of [ + "b04-preprocess", + "b05-profile", + "b06-section", + "b07-design-detail", + "b08-quantity", + "b09-estimation", + "b10-payment", + "a01-home", + "m01-master-data", +]) + r.routes[route] = m.LOUPE_ROUTE.test(route); +r.origin = { + mid: m.loupeOrigin(500, 400, 1000, 800), + topLeft: m.loupeOrigin(10, 10, 1000, 800), + bottomRight: m.loupeOrigin(995, 795, 1000, 800), +}; + +// ---- 가짜 요소 ---- +const SVG_NS = "http://www.w3.org/2000/svg"; +class El { + constructor(tagName, opts = {}) { + this.tagName = tagName; + this.namespaceURI = opts.ns || "http://www.w3.org/1999/xhtml"; + this.rect = opts.rect || { left: 0, top: 0, right: 100, bottom: 100, width: 100, height: 100 }; + this.children = []; + this.style = {}; + this.dataset = {}; + this.attrs = {}; + this.scrollTop = opts.scrollTop || 0; + this.scrollLeft = 0; + this.width = opts.width || 0; + this.height = opts.height || 0; + this.value = opts.value || ""; + this.checked = !!opts.checked; + this.type = opts.type || ""; + this.className = ""; + this.hidden = false; + this.contentDocument = opts.contentDocument || null; + this.drawn = []; + this.parent = null; + this.mark = opts.mark || ""; + } + getBoundingClientRect() { + return this.rect; + } + setAttribute(k, v) { + this.attrs[k] = v; + } + append(...nodes) { + for (const n of nodes) ((n.parent = this), this.children.push(n)); + } + replaceChildren() { + this.children = []; + } + replaceWith(n) { + const i = this.parent.children.indexOf(this); + n.parent = this.parent; + this.parent.children[i] = n; + } + cloneNode(deep) { + const c = new El(this.tagName, { + ns: this.namespaceURI, + rect: this.rect, + width: this.width, + height: this.height, + type: this.type, + mark: this.mark, + }); + c.cloned = true; // 값 · 스크롤 · 그림은 복제에 안 따라옴(실제 DOM 과 같게) + if (deep) c.append(...this.children.map((k) => k.cloneNode(true))); + return c; + } + getContext() { + return { drawImage: (src, x, y, w, h) => this.drawn.push([src.mark, x, y, w, h]) }; + } + querySelectorAll(sel) { + const out = []; + const visit = (e) => ( + e.tagName.toLowerCase() === sel && out.push(e), + e.children.forEach(visit) + ); + this.children.forEach(visit); + return out; + } +} +const far = { left: 0, top: 2000, right: 100, bottom: 2100, width: 100, height: 100 }; +function makePage() { + const app = new El("DIV", { + rect: { left: 0, top: 0, right: 1000, bottom: 800, width: 1000, height: 800 }, + }); + const scroller = new El("DIV", { scrollTop: 120, mark: "scroller" }); + const canvas = new El("CANVAS", { width: 50, height: 40, mark: "canvas2d" }); + const blank = new El("CANVAS", { mark: "blank" }); + const input = new El("INPUT", { value: "12.5", type: "text" }); + const check = new El("INPUT", { checked: true, type: "checkbox" }); + const offscreen = new El("SECTION", { rect: far, mark: "off" }); + offscreen.append(new El("P"), new El("P")); + const table = new El("TABLE"); + const row = new El("TR", { rect: far, mark: "row" }); + row.append(new El("TD", { rect: far })); + table.append(row); + const svg = new El("svg", { ns: SVG_NS }); + svg.append(new El("path", { ns: SVG_NS })); + const frameDoc = new El("HTML"); + frameDoc.append( + new El("CANVAS", { + width: 300, + height: 200, + mark: "cad", + rect: { left: 5, top: 6, width: 300, height: 200 }, + }), + ); + const frame = new El("IFRAME", { + contentDocument: frameDoc, + rect: { left: 0, top: 0, right: 320, bottom: 240, width: 320, height: 240 }, + }); + scroller.append(canvas, blank, input, check); + app.append(scroller, offscreen, table, svg, frame); + return app; +} +function fakeDocument(app) { + const doc = { body: new El("BODY") }; + doc.createElement = (tag) => new El(tag.toUpperCase()); + doc.getElementById = (id) => (id === "app" ? app : null); + return doc; +} +{ + const app = makePage(); + const doc = fakeDocument(app); + const win = fakeWindow(doc); + const observers = []; + win.MutationObserver = class { + constructor(fn) { + this.fn = fn; + this.watching = null; + observers.push(this); + } + observe(target, opts) { + this.watching = target; + this.opts = opts; + } + disconnect() { + this.watching = null; + } + }; + const view = m.createLoupeView(win); + view.show(500, 400); + const root = doc.body.children[0]; + const stage = root.children[0]; + const page = stage.children[0]; + const copy = page.children[0]; + const [scroller, offscreen, table, svg, frame] = copy.children; + const [canvas, blank, input, check] = scroller.children; + r.capture = { + rootClass: root.className, + rootSize: [root.style.width, root.style.height], + inert: root.inert, + ariaHidden: root.attrs["aria-hidden"], + hidden: root.hidden, + rootTransform: root.style.transform, + stageTransform: stage.style.transform, + pageBox: [page.style.left, page.style.top, page.style.width, page.style.height], + isClone: copy.cloned === true && copy !== app, + canvasDrawn: canvas.drawn.map((d) => d[0]), + blankDrawn: blank.drawn.length, + inputValue: input.value, + checkChecked: check.checked, + scrollTop: scroller.scrollTop, + offscreenChildren: offscreen.children.length, + offscreenSize: [offscreen.style.width, offscreen.style.height], + rowKept: table.children[0].children.length, + svgKept: svg.children.length, + frameTag: frame.tagName, + frameDrawn: frame.drawn, + frameSize: [frame.width, frame.height], + watching: observers[0].watching === app, + observeOpts: observers[0].opts, + scrollListen: win.calls.some((c) => c.type === "scroll" && c.capture), + }; + // 화면이 바뀜 — 0.5 초 뒤 한 번만 다시 뜸 + const firstCopy = copy; + observers[0].fn(); + observers[0].fn(); + win.tick(499); + const sameBefore = stage.children[0].children[0] === firstCopy; + win.tick(1); + const recaptured = stage.children[0].children[0] !== firstCopy && stage.children.length === 1; + view.move(10, 10); + const nearEdge = root.style.transform; + view.hide(); + r.hide = { + sameBefore, + recaptured, + nearEdge, + stageChildren: stage.children.length, + hidden: root.hidden, + disconnected: observers[0].watching === null, + scrollListen: win.calls.some((c) => c.type === "scroll"), + }; + observers[0].fn(); + win.tick(1000); + r.hide.noRecaptureAfterHide = stage.children.length; + view.show(500, 400); + r.hide.reuseRoot = doc.body.children.length; +} +console.log(JSON.stringify(r)); diff --git a/resources/tester/test_218_1_loupe.py b/resources/tester/test_218_1_loupe.py new file mode 100644 index 000000000..8cf99dd14 --- /dev/null +++ b/resources/tester/test_218_1_loupe.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- +"""B05~B09 돋보기(계획서 218-1) 회귀검증 — 오른쪽 길게 누름 · Z 누름 동안 켬 · 크기 · 배율 · 복제 지움 · 페이지 다섯. + +Node 헬퍼(`helper_218_1_loupe.cjs`)가 `ui_template/ui_template_loupe.ts` 를 트랜스파일해 +가짜 창 · 가짜 시계 · 가짜 요소로 돌리고, 여기서 값을 판정함. +""" + +import json +import os +import re +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_218_1_loupe.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _read(rel): + with open(os.path.join(ROOT, rel), encoding="utf-8") as f: + return f.read() + + +def test_크기_배율_상수(r): + assert r["consts"] == {"d": 240, "zoom": 2, "hold": 400, "refresh": 500} + assert r["capture"]["rootSize"] == ["240px", "240px"] + # 커서(500,400)가 원 가운데(120,120)에 2배로 옴 + assert r["capture"]["stageTransform"] == "translate(-880px, -680px) scale(2)" + + +def test_창_캡처_단계로_들음(r): + assert r["listeners"] == [ + [t, True] + for t in ("mousemove", "mousedown", "mouseup", "contextmenu", "keydown", "keyup", "blur") + ] + + +def test_길게_누름_0_4초_넘어야_켜고_떼면_끔_메뉴는_안_뜸(r): + lg = r["long"] + assert lg["before"] == 0 # 0.399 초 = 아직 + assert lg["on"] == "show" and lg["show"] == ["show", 300, 200] + assert lg["moved"] == ["move", 310, 205] # 커서 따라감 + assert lg["off"] == "hide" + assert lg["menuPrevented"] and lg["menuStopped"] # 브라우저 · 우리 메뉴 둘 다 안 뜸 + assert lg["nextStopped"] is False # 한 번만 먹음 + + +def test_짧게_누르면_지금_우클릭_그대로(r): + assert r["short"] == {"log": 0, "menuPrevented": False, "menuStopped": False} + + +def test_왼쪽_단추_무시_창_밖에서_떼면_끔(r): + mm = r["mouseMisc"] + assert mm["left"] == 0 + assert mm["lostBeforeHold"] == 0 # 0.4 초 전에 뗌 — 안 켬 + assert mm["lostAfterHold"] == "hide" + + +def test_Z_누름_켬_뗌_끔(r): + k = r["key"] + assert k["on"] == "show" # 한글 자판(ㅋ)도 code KeyZ + assert k["repeatLen"] == 1 # 누르고 있는 동안 다시 안 그림 + assert k["off"] == "hide" + assert k["noCombo"] # Ctrl · Alt · Meta 조합 · 다른 키는 안 켬 + assert k["shift"] == "show" + assert k["fieldBlur"] == "show" # 칸 초점 빠짐으론 안 끔 + assert k["blur"] == "hide" # 창이 초점을 잃으면 끔 + + +def test_입력_칸에선_안_켬(r): + t = r["key"]["typing"] + for name in ("input", "inputNoType", "number", "textarea", "select", "editable"): + assert t[name] is False, name + assert t["checkbox"] and t["button"] + + +def test_B05_B09_밖이면_안_켬(r): + assert r["off"] == {"log": 0, "menuStopped": False} + + +def test_페이지_다섯_붙음(r): + on = [k for k, v in r["routes"].items() if v] + assert on == [ + "b05-profile", + "b06-section", + "b07-design-detail", + "b08-quantity", + "b09-estimation", + ] + main = _read("A00_Common/main.ts") + assert 'from "@ui/ui_template_loupe"' in main + assert re.search(r"initLoupe\(window, \(\) => LOUPE_ROUTE\.test\(currentRoute\(\)\)\)", main) + + +def test_화면_끝에서_안_잘림(r): + o = r["origin"] + assert o["mid"] == {"left": 380, "top": 280} + assert o["topLeft"] == {"left": 4, "top": 4} + assert o["bottomRight"] == {"left": 1000 - 240 - 4, "top": 800 - 240 - 4} + + +def test_화면_뜨기(r): + c = r["capture"] + assert ( + c["rootClass"] == "ui-loupe" + and c["inert"] + and c["ariaHidden"] == "true" + and not c["hidden"] + ) + assert c["pageBox"] == ["0px", "0px", "1000px", "800px"] + assert c["isClone"] + assert c["canvasDrawn"] == ["canvas2d"] and c["blankDrawn"] == 0 + assert c["inputValue"] == "12.5" and c["checkChecked"] + assert c["scrollTop"] == 120 + assert c["offscreenChildren"] == 0 and c["offscreenSize"] == ["100px", "100px"] # 보이는 영역만 + assert c["rowKept"] == 1 # 표 줄은 칸 너비 때문에 그대로 + assert c["svgKept"] == 1 + assert c["frameTag"] == "CANVAS" and c["frameDrawn"] == [["cad", 5, 6, 300, 200]] + assert c["frameSize"] == [320, 240] + assert c["watching"] and c["observeOpts"]["subtree"] and c["scrollListen"] + + +def test_바뀌면_0_5초_뒤_다시_뜸_끄면_복제_지움(r): + h = r["hide"] + assert h["sameBefore"] and h["recaptured"] + assert h["nearEdge"] == "translate(4px, 4px)" + assert h["stageChildren"] == 0 # 복제 지움 + assert h["hidden"] and h["disconnected"] and h["scrollListen"] is False + assert h["noRecaptureAfterHide"] == 0 + assert h["reuseRoot"] == 1 # 원은 하나를 다시 씀 + + +def test_테두리_그림자_토큰(): + css = _read("ui_template/ui_template_loupe.css") + assert "--loupe-border: var(--color-accent)" in css + assert "--loupe-shadow: var(--shadow-lg)" in css + assert "border-radius: 50%" in css and "pointer-events: none" in css + assert ".ui-loupe__cross" in css + theme = _read("ui_template/ui_template_theme.css") + dark = theme[theme.index(':root[data-theme="dark"]') :] + assert "--color-accent" in dark and "--shadow-lg" in dark # 어두움에서도 토큰이 바뀜 diff --git a/ui_template/ui_template_loupe.css b/ui_template/ui_template_loupe.css new file mode 100644 index 000000000..bfc3cf912 --- /dev/null +++ b/ui_template/ui_template_loupe.css @@ -0,0 +1,68 @@ +/* ============================================================================= + * 돋보기 공용 모양(`ui_template_loupe.ts` · 계획서 218-1) — 화면에 하나 · fixed · 원 · + * 테두리 · 그림자 = 테마 토큰(밝음 · 어두움 같이) · 마우스를 안 막음 · 크기 · 배율은 TS 상수 + * ========================================================================== */ + +.ui-loupe { + --loupe-border: var(--color-accent); + --loupe-shadow: var(--shadow-lg); + position: fixed; + left: 0; + top: 0; + z-index: calc(var(--z-toast) + 10); + border-radius: 50%; + overflow: hidden; + background: var(--color-bg); + box-shadow: + 0 0 0 2px var(--loupe-border), + var(--loupe-shadow); + pointer-events: none; + will-change: transform; +} + +.ui-loupe[hidden] { + display: none; +} + +/* 복제를 놓는 판 — 화면(뷰포트) 크기 · fixed 자식의 기준 상자 */ +.ui-loupe__stage { + position: absolute; + left: 0; + top: 0; + width: 100vw; + height: 100vh; + transform-origin: 0 0; +} + +.ui-loupe__stage *, +.ui-loupe__stage *::before, +.ui-loupe__stage *::after { + animation: none !important; + transition: none !important; +} + +.ui-loupe__page { + position: absolute; +} + +/* 가운데 십자 — 작게 */ +.ui-loupe__cross::before, +.ui-loupe__cross::after { + content: ""; + position: absolute; + left: 50%; + top: 50%; + background: var(--loupe-border); +} + +.ui-loupe__cross::before { + width: 12px; + height: 1px; + transform: translate(-50%, -50%); +} + +.ui-loupe__cross::after { + width: 1px; + height: 12px; + transform: translate(-50%, -50%); +} diff --git a/ui_template/ui_template_loupe.ts b/ui_template/ui_template_loupe.ts new file mode 100644 index 000000000..46a82b369 --- /dev/null +++ b/ui_template/ui_template_loupe.ts @@ -0,0 +1,340 @@ +/* ============================================================================= + * 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"; + +/** 원 지름(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 키 판정 — 글자(`key`)가 아니라 자판 자리(`code`) · 한글 자판(「ㅋ」)도 같음(CAD `input-shortcut` 과 같은 원리) · Ctrl/Alt/Meta 조합은 아님 */ +export function isLoupeKey( + evt: Pick, +): boolean { + return evt.code === "KeyZ" && !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 (e.code !== "KeyZ" || !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; +}