/* 돋보기(`ui_template/ui_template_loupe.ts` · 계획서 218-1 · 220-1 휠 배율 · 221-1 CAD 빼기 · 221-2 스크롤) 단위검증 헬퍼 — * 그 자리에서 트랜스파일해 Node 로 돌리고 값을 JSON 으로 냄. 가짜 창 · 가짜 시계 · 가짜 요소로 판정. */ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); const ROOT = path.join(__dirname, "..", ".."); function load(file) { 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") ? {} : p.endsWith("key_shortcut") // 공용 키 판정(214-2)도 그 자리에서 트랜스파일 ? load(path.join(ROOT, "A00_Common", "key_shortcut.ts")) : p.endsWith("input_prefs") // 휠 방향(337 · Node = 기본 밂) ? load(path.join(ROOT, "A00_Common", "input_prefs.ts")) : require(p); new Function("exports", "module", "require", js)(box.exports, box, req); return box.exports; } const m = load(path.join(ROOT, "ui_template", "ui_template_loupe.ts")); // ---- 가짜 시계 · 가짜 창 ---- 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.requestAnimationFrame = (fn) => win.setTimeout(fn, 16); // 한 화면 틀 win.cancelAnimationFrame = win.clearTimeout; 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]), setZoom: (z) => log.push(["zoom", z]), 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, zoomMin: m.LOUPE_ZOOM_MIN, zoomMax: m.LOUPE_ZOOM_MAX, zoomStep: m.LOUPE_ZOOM_STEP, hold: m.LOUPE_HOLD_MS, refresh: m.LOUPE_REFRESH_MS, }; { const { win } = setup(); r.listeners = win.calls.map((c) => [c.type, c.capture]); } { // 220-1 휠 — 켜 있는 동안만 배율 · 스크롤 막음 · 켤 때마다 2배 · 확대 = 밂(deltaY < 0 · 337 기본) const { win, view } = setup(); const wheel = (deltaY) => win.fire("wheel", { deltaY }); const zoomLog = () => view.log.filter((l) => l[0] === "zoom").map((l) => l[1]); const offEv = wheel(-100); const offState = { prevented: offEv.prevented, stopped: offEv.stopped, log: view.log.length }; win.fire("keydown", { code: "KeyZ" }); const up = wheel(-100); const upState = { prevented: up.prevented, stopped: up.stopped, zoom: zoomLog().at(-1) }; wheel(100); wheel(100); const down = zoomLog().at(-1); wheel(0); // 가로 굴림 — 배율 그대로 · 스크롤은 막음 const flat = zoomLog().at(-1); for (let i = 0; i < 30; i++) wheel(-50); const max = zoomLog().at(-1); for (let i = 0; i < 30; i++) wheel(120); const min = zoomLog().at(-1); win.fire("keyup", { code: "KeyZ" }); const n = zoomLog().length; const afterOff = wheel(-100); const afterOffZoomed = zoomLog().length !== n; win.fire("keydown", { code: "KeyZ" }); wheel(-100); r.wheel = { off: offState, up: upState, down, flat, max, min, afterOff: { prevented: afterOff.prevented, stopped: afterOff.stopped, zoomed: afterOffZoomed }, reopen: zoomLog().at(-1), // 다시 켜면 2배에서 한 칸 pure: [ m.nextLoupeZoom(2, -1), m.nextLoupeZoom(2, 1), m.nextLoupeZoom(6, -1), m.nextLoupeZoom(1.5, 1), ], }; // 길게 누름으로 켠 동안도 같음 const b = setup(); b.win.fire("mousedown", { button: 2, buttons: 2 }); b.win.tick(400); const press = b.win.fire("wheel", { deltaY: -1 }); r.wheel.press = { prevented: press.prevented, zoom: b.view.log.at(-1) }; } { // 길게 누름 — 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 [ "a01-home", "a06-login", "a07-register", "b01-account", "b02-proj-register", "b03-file-input", "b04-preprocess", "b05-profile", "b06-section", "b07-design-detail", "b08-quantity", "b09-estimation", "b10-payment", "b11-status", "b11-loading", "m01-master-data", "m02-master-template", ]) 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), }; { // 221-1 CAD 영역 — 위에선 안 켬 · 켜진 채 들어가면 숨김 · 나오면 보임 · 휠은 CAD 로 const cad = { tagName: "IFRAME", closest: (sel) => (sel === m.LOUPE_SKIP ? {} : null) }; const plain = { tagName: "DIV", closest: () => null }; const kinds = (v) => v.log.map((l) => l[0]); r.cad = { isCad: [m.isCadTarget(cad), m.isCadTarget(plain), m.isCadTarget(null), m.isCadTarget({})], skip: m.LOUPE_SKIP, }; { // Z 를 CAD 위에서 누름 — 안 켬 · 나오면 보임 · 떼면 끔 const { win, view } = setup(); win.fire("mouseover", { target: cad }); win.fire("keydown", { code: "KeyZ", target: cad }); const onCad = view.log.length; const wheel = win.fire("wheel", { deltaY: -100, target: cad }); win.fire("mouseover", { target: plain, clientX: 50, clientY: 60 }); const out = view.log[0]; win.fire("keyup", { code: "KeyZ" }); r.cad.keyOnCad = { onCad, wheelPrevented: wheel.prevented, out, off: last(view) }; } { // 켜진 채 CAD 위로(mouseover · mousemove 둘 다) — 숨김 · 휠 안 막음 · 나오면 배율 그대로 보임 const { win, view } = setup(); win.fire("keydown", { code: "KeyZ" }); win.fire("wheel", { deltaY: -100 }); // 2.25 — 밂 = 확대(337 기본) win.fire("mouseover", { target: cad }); const hidden = last(view); const n = view.log.length; const wheel = win.fire("wheel", { deltaY: -100, target: cad }); win.fire("mousemove", { target: cad, clientX: 5, clientY: 5 }); const quiet = view.log.length === n; // 숨긴 동안 move · 배율 안 부름 win.fire("mouseover", { target: plain, clientX: 70, clientY: 80 }); const back = view.log.slice(n); win.fire("mousemove", { target: cad, clientX: 6, clientY: 6 }); const moveHide = last(view); win.fire("mousemove", { target: plain, clientX: 7, clientY: 7 }); r.cad.enter = { hidden, wheel: { prevented: wheel.prevented, stopped: wheel.stopped }, quiet, back, moveHide, moveBack: kinds(view).slice(-3), }; } { // 우클릭 길게 — CAD 위면 안 켜고 메뉴 그대로 const { win, view } = setup(); win.fire("mousedown", { button: 2, buttons: 2, target: cad }); win.tick(1000); win.fire("mouseup", { button: 2, target: cad }); const menu = win.fire("contextmenu", { target: cad }); r.cad.press = { log: view.log.length, menuPrevented: menu.prevented }; } { // 초점이 CAD iframe 으로 넘어감(cad_host) — 끄지 않고 숨김 · Z 떼기는 그쪽 창에서 const { win, view } = setup(); const frameCalls = []; const frame = { ...cad, contentWindow: { addEventListener: (type, fn, cap) => frameCalls.push({ type, fn, cap }) }, }; win.document = { activeElement: frame }; win.fire("keydown", { code: "KeyZ" }); win.fire("blur", { target: win }); const blurHidden = kinds(view); win.document = { activeElement: plain }; const up = frameCalls.find((c) => c.type === "keyup"); up.fn({ code: "KeyZ" }); win.fire("mouseover", { target: plain }); const afterRelease = kinds(view); // 우클릭 누른 채 들어감 — 나오면 다시 보임 const b = setup(); b.win.fire("mousedown", { button: 2, buttons: 2 }); b.win.tick(400); b.win.document = { activeElement: frame }; b.win.fire("blur", { target: b.win }); b.win.document = { activeElement: plain }; b.win.fire("mousemove", { buttons: 2, target: plain }); const pressBack = kinds(b.view); b.win.fire("mouseup", { button: 2 }); r.cad.blur = { blurHidden, frameKeyup: up && up.cap, afterRelease, pressBack, pressOff: last(b.view), }; } } // ---- 가짜 요소 ---- 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.st = 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; } // 실제 DOM 처럼 숨긴(hidden) 조상 아래에선 스크롤을 적어도 안 먹음 get scrollTop() { return this.st; } set scrollTop(v) { for (let e = this; e; e = e.parent) if (e.hidden) return; this.st = v; } get classList() { return { contains: (k) => this.className.split(" ").includes(k) }; } 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 }, }); const cadBox = new El("DIV", { rect: { left: 0, top: 300, right: 400, bottom: 600, width: 400, height: 300 }, }); cadBox.className = "cad-host"; cadBox.append(new El("IFRAME"), new El("A")); scroller.scrollHeight = 900; // 스크롤될 수 있는 칸 scroller.clientHeight = 300; scroller.append(canvas, blank, input, check); app.append(scroller, offscreen, table, svg, frame, cadBox); return app; } function fakeDocument(app) { // 374-1 뜨는 자리 = body(모달 포함) · 원 · 배율 글은 html 바로 아래 const doc = { body: app, documentElement: new El("HTML") }; 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.documentElement.children[0]; const label = doc.documentElement.children[1]; const stage = root.children[0]; const scaler = stage.children[0]; // 이동 = stage · 배율 = scaler const page = scaler.children[0]; const lens = () => `${stage.style.transform} ${scaler.style.transform}`; const copy = page.children[0]; const [scroller, offscreen, table, svg, frame, cadBox] = 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: lens(), stageMoveOnly: 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.style.width, frame.style.height], frameEmpty: frame.children.length, cadBoxEmpty: cadBox.children.length, cadBoxSize: [cadBox.style.width, cadBox.style.height], watching: observers[0].watching === app, observeOpts: observers[0].opts, scrollListen: win.calls.some((c) => c.type === "scroll" && c.capture), }; // 220-1 배율 글 · 배율 바꿈 r.label = { cls: label.className, ariaHidden: label.attrs["aria-hidden"], text: label.textContent, at: label.style.transform, hidden: label.hidden, }; view.setZoom(3); r.label.zoom3 = [label.textContent, lens(), root.style.transform]; view.move(500, 790); r.label.bottom = label.style.transform; view.setZoom(2); view.move(500, 400); // 화면이 바뀜 — 멈춘 뒤 0.5 초에 한 번만 다시 뜸 const firstCopy = copy; observers[0].fn(); observers[0].fn(); win.tick(499); const sameBefore = scaler.children[0].children[0] === firstCopy; win.tick(1); const recaptured = scaler.children[0].children[0] !== firstCopy && scaler.children.length === 1; // 374-1 움직이는 동안 안 뜸 — 바뀜 뒤 움직이면 처음부터 · 멈춘 뒤 0.5 초에 한 번 const moving = scaler.children[0].children[0]; observers[0].fn(); win.tick(300); view.move(520, 400); win.tick(300); view.move(540, 400); win.tick(499); const heldWhileMoving = scaler.children[0].children[0] === moving; win.tick(1); r.debounce = { heldWhileMoving, afterStop: scaler.children[0].children[0] !== moving }; view.move(500, 400); view.move(10, 10); const nearEdge = root.style.transform; view.hide(); r.hide = { sameBefore, recaptured, nearEdge, stageChildren: scaler.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 = scaler.children.length; r.hide.labelHidden = label.hidden; view.setZoom(4); view.hide(); view.show(500, 400); r.hide.reopenLabel = [label.textContent, label.hidden, lens()]; r.hide.reuseRoot = doc.documentElement.children.length; r.hide.bodyUntouched = app.children.length; // 원이 body 에 안 붙음 } { // 221-2 스크롤 — 스크롤한 뒤 켬 · 켠 채 스크롤(칸 · 문서) const app = makePage(); const scroller = app.children[0]; const doc = fakeDocument(app); const win = fakeWindow(doc); win.MutationObserver = class { observe() {} disconnect() {} }; const view = m.createLoupeView(win); const scaler = () => doc.documentElement.children[0].children[0].children[0]; const cloneScroller = () => scaler().children[0].children[0].children[0]; const page = () => scaler().children[0]; view.show(500, 400); view.hide(); scroller.scrollTop = 0; // 0 에서 켬 → 스크롤 칸으로 잡힘 view.show(500, 400); const zero = cloneScroller().scrollTop; view.hide(); scroller.scrollTop = 260; // 꺼진 동안 스크롤해 둠 → 다시 켬(숨긴 원을 다시 씀) view.show(500, 400); const before = cloneScroller().scrollTop; // 켠 채 스크롤 — 다음 화면 틀에 위치만 · 같은 복제 const first = cloneScroller(); scroller.scrollTop = 400; app.rect = { ...app.rect, top: -50, bottom: 750 }; // 문서도 50 내려감 const scroll = () => win.calls.filter((c) => c.type === "scroll").forEach((c) => c.fn({})); scroll(); scroll(); // 한 틀에 한 번으로 묶음 const rafs = win.timers.filter((t) => t.at === win.now + 16).length; win.tick(16); const during = { top: cloneScroller().scrollTop, same: cloneScroller() === first, pageTop: page().style.top, }; win.tick(484); // 0.5 초 뒤 다시 뜸 — 비워 둔 칸을 채움 · 스크롤은 그대로 const recaptured = { same: cloneScroller() === first, top: cloneScroller().scrollTop }; view.hide(); const n = win.timers.length; r.scroll = { zero, before, rafs, during, recaptured, timersAfterHide: n }; } console.log(JSON.stringify(r));