From 64d5921b946e056c5a1b8aadcb6ec4c94f1a1032 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:14:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=EB=8F=8B=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EC=A7=80=EB=A6=84=20360=20=C2=B7=20=EC=BC=9C=EC=A7=84=20?= =?UTF-8?q?=EB=8F=99=EC=95=88=20=ED=9C=A0=20=EB=B0=B0=EC=9C=A8(1.5~6=20?= =?UTF-8?q?=C2=B7=200.25)=20=C2=B7=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20?= =?UTF-8?q?=EB=A7=89=EC=9D=8C=20=C2=B7=20=EB=B0=B0=EC=9C=A8=20=EA=B8=80=20?= =?UTF-8?q?=C2=B7=20=EB=B6=99=EB=8A=94=20=ED=8E=98=EC=9D=B4=EC=A7=80=20B01?= =?UTF-8?q?~B09=20=C2=B7=20M01=20=C2=B7=20M02=20(220-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 | 2 +- resources/tester/helper_218_1_loupe.cjs | 83 +++++++++++++++++++++- resources/tester/test_218_1_loupe.py | 39 ++++++---- resources/tester/test_220_1_loupe_wheel.py | 76 ++++++++++++++++++++ ui_template/ui_template_loupe.css | 23 +++++- ui_template/ui_template_loupe.ts | 72 +++++++++++++++---- 6 files changed, 263 insertions(+), 32 deletions(-) create mode 100644 resources/tester/test_220_1_loupe_wheel.py diff --git a/A00_Common/main.ts b/A00_Common/main.ts index 1d3e8b069..56ae1a236 100644 --- a/A00_Common/main.ts +++ b/A00_Common/main.ts @@ -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. 셸 렌더 + 현재 라우트 렌더를 함께 수행하는 헬퍼 diff --git a/resources/tester/helper_218_1_loupe.cjs b/resources/tester/helper_218_1_loupe.cjs index 32e247990..cfa125bf1 100644 --- a/resources/tester/helper_218_1_loupe.cjs +++ b/resources/tester/helper_218_1_loupe.cjs @@ -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)); diff --git a/resources/tester/test_218_1_loupe.py b/resources/tester/test_218_1_loupe.py index 8cf99dd14..d8f67f5b8 100644 --- a/resources/tester/test_218_1_loupe.py +++ b/resources/tester/test_218_1_loupe.py @@ -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_테두리_그림자_토큰(): diff --git a/resources/tester/test_220_1_loupe_wheel.py b/resources/tester/test_220_1_loupe_wheel.py new file mode 100644 index 000000000..a1c5bda7d --- /dev/null +++ b/resources/tester/test_220_1_loupe_wheel.py @@ -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 diff --git a/ui_template/ui_template_loupe.css b/ui_template/ui_template_loupe.css index bfc3cf912..74bbaa9e0 100644 --- a/ui_template/ui_template_loupe.css +++ b/ui_template/ui_template_loupe.css @@ -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 { diff --git a/ui_template/ui_template_loupe.ts b/ui_template/ui_template_loupe.ts index e4c40b572..cdb4fe4c6 100644 --- a/ui_template/ui_template_loupe.ts +++ b/ui_template/ui_template_loupe.ts @@ -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;