Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1
This commit is contained in:
@@ -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 = "";
|
||||
|
||||
@@ -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));
|
||||
@@ -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 # 어두움에서도 토큰이 바뀜
|
||||
@@ -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%);
|
||||
}
|
||||
@@ -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<KeyboardEvent, "code" | "ctrlKey" | "altKey" | "metaKey">,
|
||||
): 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 = <K extends keyof WindowEventMap>(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;
|
||||
}
|
||||
Reference in New Issue
Block a user