Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1

This commit is contained in:
2026-10-10 16:15:45 +09:00
6 changed files with 263 additions and 32 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ function bootstrap(): void {
// B · M 페이지는 브라우저 기본 오른쪽 클릭 메뉴를 막음 — 우리 메뉴는 그대로(계획서 39-9a)
blockNativeMenu(window, () => NATIVE_MENU_ROUTE.test(currentRoute()));
// B05~B09 돋보기 — 오른쪽 길게 누름 · Z 누름 동안(계획서 218-1)
// B01~B09 · M01 · M02 돋보기 — 오른쪽 길게 누름 · Z 누름 동안(계획서 218-1 · 220)
initLoupe(window, () => LOUPE_ROUTE.test(currentRoute()));
// 3. 셸 렌더 + 현재 라우트 렌더를 함께 수행하는 헬퍼
+81 -2
View File
@@ -1,4 +1,4 @@
/* 돋보기(`ui_template/ui_template_loupe.ts` · 계획서 218-1) 단위검증 헬퍼 —
/* 돋보기(`ui_template/ui_template_loupe.ts` · 계획서 218-1 · 220-1 휠 배율) 단위검증 헬퍼 —
* 그 자리에서 트랜스파일해 Node 로 돌리고 값을 JSON 으로 냄. 가짜 창 · 가짜 시계 · 가짜 요소로 판정. */
const fs = require("fs");
const path = require("path");
@@ -70,6 +70,7 @@ function fakeView() {
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"]),
};
}
@@ -86,6 +87,9 @@ 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,
};
@@ -93,6 +97,54 @@ r.consts = {
const { win } = setup();
r.listeners = win.calls.map((c) => [c.type, c.capture]);
}
{
// 220-1 휠 — 켜 있는 동안만 배율 · 스크롤 막음 · 켤 때마다 2배
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();
@@ -194,6 +246,12 @@ r.consts = {
}
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",
@@ -201,8 +259,10 @@ for (const route of [
"b08-quantity",
"b09-estimation",
"b10-payment",
"a01-home",
"b11-status",
"b11-loading",
"m01-master-data",
"m02-master-template",
])
r.routes[route] = m.LOUPE_ROUTE.test(route);
r.origin = {
@@ -342,6 +402,7 @@ function fakeDocument(app) {
const view = m.createLoupeView(win);
view.show(500, 400);
const root = doc.body.children[0];
const label = doc.body.children[1];
const stage = root.children[0];
const page = stage.children[0];
const copy = page.children[0];
@@ -373,6 +434,20 @@ function fakeDocument(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, stage.style.transform, 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();
@@ -396,7 +471,11 @@ function fakeDocument(app) {
observers[0].fn();
win.tick(1000);
r.hide.noRecaptureAfterHide = stage.children.length;
r.hide.labelHidden = label.hidden;
view.setZoom(4);
view.hide();
view.show(500, 400);
r.hide.reopenLabel = [label.textContent, label.hidden, stage.style.transform];
r.hide.reuseRoot = doc.body.children.length;
}
console.log(JSON.stringify(r));
+25 -14
View File
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""B05~B09 돋보기(계획서 218-1) 회귀검증 — 오른쪽 길게 누름 · Z 누름 동안 켬 · 크기 · 배율 · 복제 지움 · 페이지 다섯.
"""돋보기(계획서 218-1 · 220) 회귀검증 — 오른쪽 길게 누름 · Z 누름 동안 켬 · 크기 · 배율 · 복제 지움 · 붙는 페이지.
Node 헬퍼(`helper_218_1_loupe.cjs`)가 `ui_template/ui_template_loupe.ts` 를 트랜스파일해
가짜 창 · 가짜 시계 · 가짜 요소로 돌리고, 여기서 값을 판정함.
@@ -35,17 +35,22 @@ def _read(rel):
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)"
c = r["consts"]
assert (c["d"], c["zoom"], c["hold"], c["refresh"]) == (
360,
2,
400,
500,
) # 220-1 지름 240 → 360
assert r["capture"]["rootSize"] == ["360px", "360px"]
# 커서(500,400)가 원 가운데(180,180)에 2배로 옴
assert r["capture"]["stageTransform"] == "translate(-820px, -620px) scale(2)"
def test_창_캡처_단계로_들음(r):
assert r["listeners"] == [
[t, True]
for t in ("mousemove", "mousedown", "mouseup", "contextmenu", "keydown", "keyup", "blur")
]
[t, True] for t in ("mousemove", "mousedown", "mouseup", "contextmenu", "keydown", "keyup")
] + [["wheel", {"capture": True, "passive": False}], ["blur", True]]
def test_길게_누름_0_4초_넘어야_켜고_떼면_끔_메뉴는_안_뜸(r):
@@ -87,19 +92,25 @@ def test_입력_칸에선_안_켬(r):
assert t["checkbox"] and t["button"]
def test_B05_B09_밖이면_안_켬(r):
def test_붙일_주소_밖이면_안_켬(r):
assert r["off"] == {"log": 0, "menuStopped": False}
def test_페이지_다섯_붙음(r):
def test_붙는_페이지_B01_B09_M01_M02(r):
on = [k for k, v in r["routes"].items() if v]
assert on == [
"b01-account",
"b02-proj-register",
"b03-file-input",
"b04-preprocess",
"b05-profile",
"b06-section",
"b07-design-detail",
"b08-quantity",
"b09-estimation",
]
"m01-master-data",
"m02-master-template",
] # 220 — A 영역 · 로그인 · B10 · B11 은 뺌
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)
@@ -107,9 +118,9 @@ def test_페이지_다섯_붙음(r):
def test_화면_끝에서_안_잘림(r):
o = r["origin"]
assert o["mid"] == {"left": 380, "top": 280}
assert o["mid"] == {"left": 320, "top": 220}
assert o["topLeft"] == {"left": 4, "top": 4}
assert o["bottomRight"] == {"left": 1000 - 240 - 4, "top": 800 - 240 - 4}
assert o["bottomRight"] == {"left": 1000 - 360 - 4, "top": 800 - 360 - 4}
def test_화면_뜨기(r):
@@ -140,7 +151,7 @@ def test_바뀌면_0_5초_뒤_다시_뜸_끄면_복제_지움(r):
assert h["stageChildren"] == 0 # 복제 지움
assert h["hidden"] and h["disconnected"] and h["scrollListen"] is False
assert h["noRecaptureAfterHide"] == 0
assert h["reuseRoot"] == 1 # 원은 하나를 다시 씀
assert h["reuseRoot"] == 2 # 원 · 배율 글 하나씩을 다시 씀
def test_테두리_그림자_토큰():
@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
"""돋보기 휠 배율(계획서 220-1) 회귀검증 — 지름 360 · 켜진 동안 휠 = 배율 ± 0.25(1.5 ~ 6) ·
그동안 휠 기본 동작(스크롤 · 그래프 줌) 막음 · 꺼지면 그대로 · 다음 켤 때 2배 · 원 아래 배율 글.
218-1 헬퍼(`helper_218_1_loupe.cjs`)의 휠 · 배율 글 장면을 판정함.
"""
import json
import os
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 test_지름_360_배율_한도(r):
c = r["consts"]
assert c["d"] == 360 and r["capture"]["rootSize"] == ["360px", "360px"]
assert (c["zoom"], c["zoomMin"], c["zoomMax"], c["zoomStep"]) == (2, 1.5, 6, 0.25)
def test_휠_위_크게_아래_작게_한도(r):
w = r["wheel"]
assert w["up"]["zoom"] == 2.25 # 위로 한 칸
assert w["down"] == 1.75 # 아래로 두 칸
assert w["flat"] == 1.75 # 가로 굴림은 그대로
assert w["max"] == 6 and w["min"] == 1.5
assert w["pure"] == [2.25, 1.75, 6, 1.5]
def test_켜진_동안_휠_기본_동작_막음(r):
w = r["wheel"]
assert w["up"]["prevented"] and w["up"]["stopped"] # 스크롤 · 그래프 휠 줌 안 받음
assert w["press"] == {"prevented": True, "zoom": ["zoom", 2.25]} # 우클릭 길게 누름도
assert ["wheel", {"capture": True, "passive": False}] in r["listeners"]
def test_꺼지면_스크롤_그대로(r):
w = r["wheel"]
assert w["off"] == {"prevented": False, "stopped": False, "log": 0}
assert w["afterOff"] == {"prevented": False, "stopped": False, "zoomed": False}
def test_다음_켤_때_2배(r):
assert r["wheel"]["reopen"] == 2.25 # 1.5 에서 껐다 켜면 2배에서 한 칸
h = r["hide"]
assert h["reopenLabel"] == ["×2", False, "translate(-820px, -620px) scale(2)"]
def test_배율_글_원_아래(r):
lb = r["label"]
assert lb["cls"] == "ui-loupe__zoom" and lb["ariaHidden"] == "true" and not lb["hidden"]
assert lb["text"] == "×2"
# 원(320,220 · 지름 360) 아래 가운데 — 원 끝 580 + 4
assert lb["at"] == "translate(500px, 584px) translateX(-50%)"
assert lb["zoom3"] == ["×3", "translate(-1320px, -1020px) scale(3)", "translate(320px, 220px)"]
assert lb["bottom"] == "translate(500px, 778px) translateX(-50%)" # 화면 아래 끝에서 안 잘림
assert r["hide"]["labelHidden"]
with open(os.path.join(ROOT, "ui_template", "ui_template_loupe.css"), encoding="utf-8") as f:
css = f.read()
assert ".ui-loupe__zoom" in css and ".ui-loupe__zoom[hidden]" in css
+22 -1
View File
@@ -1,6 +1,6 @@
/* =============================================================================
* 돋보기 공용 모양(`ui_template_loupe.ts` · 계획서 218-1) — 화면에 하나 · fixed · 원 ·
* 테두리 · 그림자 = 테마 토큰(밝음 · 어두움 같이) · 마우스를 안 막음 · 크기 · 배율은 TS 상수
* 테두리 · 그림자 = 테마 토큰(밝음 · 어두움 같이) · 마우스를 안 막음 · 크기 · 배율은 TS 상수(220-1 지름 360)
* ========================================================================== */
.ui-loupe {
@@ -45,6 +45,27 @@
position: absolute;
}
/* 배율 글(220-1) — 원 아래 가운데 · 작게 · 자리는 TS 가 transform 으로 */
.ui-loupe__zoom {
position: fixed;
left: 0;
top: 0;
z-index: calc(var(--z-toast) + 10);
padding: 0 6px;
border-radius: 9px;
background: var(--color-bg);
color: var(--color-accent);
font-size: 11px;
line-height: 18px;
box-shadow: 0 0 0 1px var(--color-accent);
pointer-events: none;
white-space: nowrap;
}
.ui-loupe__zoom[hidden] {
display: none;
}
/* 가운데 십자 — 작게 */
.ui-loupe__cross::before,
.ui-loupe__cross::after {
+58 -14
View File
@@ -1,8 +1,9 @@
/* =============================================================================
* ui_template_loupe.ts
* 돋보기 — 커서를 따라가는 원 안에 지금 화면을 크게 보여 줌(계획서 218-1) · 공용 진입(`A00_Common/main.ts`)이 B05~B09 에 붙임.
* 돋보기 — 커서를 따라가는 원 안에 지금 화면을 크게 보여 줌(계획서 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 초 뒤 다시 뜸 · 끄면 복제를 지움.
@@ -13,17 +14,23 @@ import "./ui_template_loupe.css";
import { keyLetter } from "../A00_Common/key_shortcut";
/** 원 지름(px) */
export const LOUPE_DIAMETER = 240;
/** 배율 */
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;
/** 붙일 주소 — B05~B09 */
export const LOUPE_ROUTE = /^b0[5-9]/;
/** 붙일 주소 — 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";
const NON_TEXT_INPUT = new Set([
@@ -70,6 +77,12 @@ export function isTypingTarget(target: unknown): boolean {
return tag === "INPUT" && !NON_TEXT_INPUT.has((el.type || "text").toLowerCase());
}
/** 휠 한 번 뒤 배율 — 위로 굴림(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,
@@ -88,10 +101,11 @@ export function loupeOrigin(
export interface LoupeView {
show(x: number, y: number): void;
move(x: number, y: number): void;
setZoom(zoom: number): void;
hide(): void;
}
/** `win` 에 두 켜는 법을 붙임 — `isOn()` 이 거짓이면(B05~B09 밖) 안 켬 */
/** `win` 에 두 켜는 법을 붙임 — `isOn()` 이 거짓이면(붙일 주소 밖) 안 켬 */
export function initLoupe(
win: Window,
isOn: () => boolean,
@@ -104,11 +118,14 @@ export function initLoupe(
let shown = false;
let holdTimer = 0;
let swallowMenu = false; // 길게 누른 뒤 따라오는 우클릭 메뉴 한 번을 먹음
let zoom = LOUPE_ZOOM;
const update = (): void => {
const want = (press || key) && isOn();
if (want && !shown) view.show(x, y);
else if (!want && shown) view.hide();
if (want && !shown) {
zoom = LOUPE_ZOOM; // 켤 때마다 2배
view.show(x, y);
} else if (!want && shown) view.hide();
shown = want;
};
const stopHold = (): void => {
@@ -165,6 +182,18 @@ export function initLoupe(
key = false;
update();
});
// 켜 있는 동안만 휠을 잡음 — 잡기 단계 첫 자리 · passive:false 여야 스크롤을 막음
win.addEventListener(
"wheel",
(e) => {
if (!shown) return;
e.preventDefault();
e.stopImmediatePropagation(); // 표 · 그래프 휠 줌도 안 받음
zoom = nextLoupeZoom(zoom, e.deltaY);
view.setZoom(zoom);
},
{ capture: true, passive: false },
);
on("blur", (e) => {
if (e.target !== win) return; // 칸 초점 빠짐은 무시 — 창이 초점을 잃을 때만(CAD iframe 으로 넘어감 포함)
stopHold();
@@ -181,6 +210,9 @@ export function createLoupeView(
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;
@@ -195,7 +227,10 @@ export function createLoupeView(
const cross = doc.createElement("div");
cross.className = "ui-loupe__cross";
root.append(stage, cross);
doc.body.append(root);
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();
@@ -215,8 +250,9 @@ export function createLoupeView(
show(x, y) {
if (!root) build();
capture();
root!.hidden = false;
view.move(x, y);
root!.hidden = label!.hidden = false;
at = [x, y];
view.setZoom(LOUPE_ZOOM); // 켤 때마다 2배 · 자리도 놓음
const source = sourceOf();
const Observer = (win as Window & typeof globalThis).MutationObserver;
const watch = new Observer(refresh);
@@ -231,11 +267,19 @@ export function createLoupeView(
win.addEventListener("scroll", refresh, true);
},
move(x, y) {
if (!root || !stage) return;
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 - LOUPE_ZOOM * x}px, ${r - LOUPE_ZOOM * y}px) scale(${LOUPE_ZOOM})`;
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();
@@ -244,7 +288,7 @@ export function createLoupeView(
refreshTimer = 0;
win.removeEventListener("scroll", refresh, true);
stage?.replaceChildren(); // 복제를 지움
if (root) root.hidden = true;
if (root) root.hidden = label!.hidden = true;
},
};
return view;