From a5f5cac73933b36c2cc2d879551f86a5b14a691d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:26:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=EB=8F=8B=EB=B3=B4=EA=B8=B0=20CAD?= =?UTF-8?q?=20=EC=98=81=EC=97=AD=20=EB=B9=BC=EA=B8=B0=20=E2=80=94=20CAD=20?= =?UTF-8?q?=EC=9C=84=20=EC=95=88=20=EC=BC=AC=20=C2=B7=20=EC=BC=9C=EC=A7=84?= =?UTF-8?q?=20=EC=B1=84=20=EB=93=A4=EC=96=B4=EA=B0=80=EB=A9=B4=20=EC=88=A8?= =?UTF-8?q?=EA=B9=80=20=C2=B7=20=EB=82=98=EC=98=A4=EB=A9=B4=20=EB=B0=B0?= =?UTF-8?q?=EC=9C=A8=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=C2=B7=20CAD=20?= =?UTF-8?q?=EC=9C=84=20=ED=9C=A0=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=C2=B7=20?= =?UTF-8?q?=EB=B3=B5=EC=A0=9C=EC=97=90=EC=84=9C=20CAD=20=EB=B9=88=EC=B9=B8?= =?UTF-8?q?=20(221-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 --- resources/tester/helper_218_1_loupe.cjs | 111 ++++++++++++++++++++++- resources/tester/test_218_1_loupe.py | 16 +++- resources/tester/test_221_1_loupe_cad.py | 69 ++++++++++++++ ui_template/ui_template_loupe.ts | 109 +++++++++++++--------- 4 files changed, 254 insertions(+), 51 deletions(-) create mode 100644 resources/tester/test_221_1_loupe_cad.py diff --git a/resources/tester/helper_218_1_loupe.cjs b/resources/tester/helper_218_1_loupe.cjs index cfa125bf1..52ac768cf 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 · 220-1 휠 배율) 단위검증 헬퍼 — +/* 돋보기(`ui_template/ui_template_loupe.ts` · 계획서 218-1 · 220-1 휠 배율 · 221-1 CAD 빼기) 단위검증 헬퍼 — * 그 자리에서 트랜스파일해 Node 로 돌리고 값을 JSON 으로 냄. 가짜 창 · 가짜 시계 · 가짜 요소로 판정. */ const fs = require("fs"); const path = require("path"); @@ -271,6 +271,98 @@ r.origin = { 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 + 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 { @@ -299,6 +391,9 @@ class El { getBoundingClientRect() { return this.rect; } + get classList() { + return { contains: (k) => this.className.split(" ").includes(k) }; + } setAttribute(k, v) { this.attrs[k] = v; } @@ -370,8 +465,13 @@ function makePage() { 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.append(canvas, blank, input, check); - app.append(scroller, offscreen, table, svg, frame); + app.append(scroller, offscreen, table, svg, frame, cadBox); return app; } function fakeDocument(app) { @@ -406,7 +506,7 @@ function fakeDocument(app) { const stage = root.children[0]; const page = stage.children[0]; const copy = page.children[0]; - const [scroller, offscreen, table, svg, frame] = copy.children; + const [scroller, offscreen, table, svg, frame, cadBox] = copy.children; const [canvas, blank, input, check] = scroller.children; r.capture = { rootClass: root.className, @@ -429,7 +529,10 @@ function fakeDocument(app) { svgKept: svg.children.length, frameTag: frame.tagName, frameDrawn: frame.drawn, - frameSize: [frame.width, frame.height], + 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), diff --git a/resources/tester/test_218_1_loupe.py b/resources/tester/test_218_1_loupe.py index d8f67f5b8..9568efe79 100644 --- a/resources/tester/test_218_1_loupe.py +++ b/resources/tester/test_218_1_loupe.py @@ -49,7 +49,16 @@ def test_크기_배율_상수(r): def test_창_캡처_단계로_들음(r): assert r["listeners"] == [ - [t, True] for t in ("mousemove", "mousedown", "mouseup", "contextmenu", "keydown", "keyup") + [t, True] + for t in ( + "mousemove", + "mouseover", + "mousedown", + "mouseup", + "contextmenu", + "keydown", + "keyup", + ) ] + [["wheel", {"capture": True, "passive": False}], ["blur", True]] @@ -139,8 +148,9 @@ def test_화면_뜨기(r): 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] + # iframe(CAD) 은 복제하지 않음 — 같은 크기 빈 상자(221-1) + assert c["frameTag"] == "DIV" and c["frameDrawn"] == [] and c["frameEmpty"] == 0 + assert c["frameSize"] == ["320px", "240px"] assert c["watching"] and c["observeOpts"]["subtree"] and c["scrollListen"] diff --git a/resources/tester/test_221_1_loupe_cad.py b/resources/tester/test_221_1_loupe_cad.py new file mode 100644 index 000000000..b74b31959 --- /dev/null +++ b/resources/tester/test_221_1_loupe_cad.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +"""돋보기 CAD 영역 빼기(계획서 221-1) 회귀검증 — CAD(cad_host 상자 · iframe) 위에선 안 켬 · +켜진 채 들어가면 숨김 · 나오면 다시 보임(배율 그대로) · CAD 위 휠은 안 막음 · 복제에서 CAD 는 빈칸. + +218-1 헬퍼(`helper_218_1_loupe.cjs`)의 CAD 장면을 판정함. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@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_CAD_판정(r): + c = r["cad"] + assert c["skip"] == ".cad-host, iframe" + assert c["isCad"] == [True, False, False, False] + + +def test_CAD_위_Z_안_켬_나오면_보임(r): + k = r["cad"]["keyOnCad"] + assert k["onCad"] == 0 and k["wheelPrevented"] is False + assert k["out"] == ["show", 50, 60] # 나온 자리에 뜸 + assert k["off"] == "hide" + + +def test_CAD_위_우클릭_길게_안_켬_메뉴_그대로(r): + assert r["cad"]["press"] == {"log": 0, "menuPrevented": False} + + +def test_켜진_채_CAD_위_숨김_휠_안_막음_나오면_배율_그대로(r): + e = r["cad"]["enter"] + assert e["hidden"] == "hide" + assert e["wheel"] == {"prevented": False, "stopped": False} # CAD 휠 줌 그대로 + assert e["quiet"] # 숨긴 동안 원을 안 움직임 + assert e["back"] == [["show", 70, 80], ["zoom", 2.25]] + assert e["moveHide"] == "hide" and e["moveBack"] == ["show", "zoom", "move"] + + +def test_초점이_CAD_로_넘어가도_끄지_않고_숨김(r): + b = r["cad"]["blur"] + assert b["blurHidden"] == ["show", "hide"] + assert b["frameKeyup"] is True # Z 떼기는 CAD 창에서 들음 + assert b["afterRelease"] == ["show", "hide"] # 뗀 뒤 나와도 안 뜸 + assert b["pressBack"] == ["show", "hide", "show", "move"] # 우클릭 누른 채 나오면 다시 + assert b["pressOff"] == "hide" + + +def test_복제에서_CAD_빈칸(r): + c = r["capture"] + assert c["frameTag"] == "DIV" and c["frameEmpty"] == 0 + assert c["frameSize"] == ["320px", "240px"] + assert c["cadBoxEmpty"] == 0 and c["cadBoxSize"] == ["400px", "300px"] diff --git a/ui_template/ui_template_loupe.ts b/ui_template/ui_template_loupe.ts index cdb4fe4c6..1703dc58a 100644 --- a/ui_template/ui_template_loupe.ts +++ b/ui_template/ui_template_loupe.ts @@ -7,7 +7,8 @@ * 길게 누른 우클릭은 뗀 뒤 메뉴(우리 메뉴 · 브라우저 메뉴)를 안 띄움 · 짧게 누르면 지금 우클릭 그대로. * 그리기 — 켤 때 `#app` 을 한 번 떠서(DOM 복제 + 2D canvas 그림 복사 · SVG 그대로) 원 안에 놓고, 움직일 때 transform 만 바꿈. * 화면 밖 상자는 속을 비우고 크기만 남김 · 켜 있는 동안 화면이 바뀌면 0.5 초 뒤 다시 뜸 · 끄면 복제를 지움. - * WebGL canvas 는 그림이 남아 있을 때만 옮겨짐 · 같은 출처 iframe(CAD)은 안의 canvas 만 옮김 — 못 옮기면 그 자리 빈칸. + * WebGL canvas 는 그림이 남아 있을 때만 옮겨짐. + * CAD 영역(cad_host 상자 · iframe · 221-1) — 커서가 그 위면 켜지 않고 · 켜진 채 들어가면 숨김(나오면 다시) · 휠도 CAD 로 · 복제는 빈칸. * ========================================================================== */ import "./ui_template_loupe.css"; @@ -33,6 +34,8 @@ export const LOUPE_EDGE = 4; export const LOUPE_ROUTE = /^(b0[1-9]|m0[12])-/; const SVG_NS = "http://www.w3.org/2000/svg"; +/** CAD 영역 — cad_host 가 끼운 상자와 iframe */ +export const LOUPE_SKIP = ".cad-host, iframe"; const NON_TEXT_INPUT = new Set([ "checkbox", "radio", @@ -77,6 +80,11 @@ export function isTypingTarget(target: unknown): boolean { return tag === "INPUT" && !NON_TEXT_INPUT.has((el.type || "text").toLowerCase()); } +/** CAD 영역 위인지 — 돋보기를 안 띄우는 자리(221-1) */ +export function isCadTarget(target: unknown): boolean { + return !!(target as Partial | null)?.closest?.(LOUPE_SKIP); +} + /** 휠 한 번 뒤 배율 — 위로 굴림(deltaY < 0) = 크게 · 한도 안 */ export function nextLoupeZoom(zoom: number, deltaY: number): number { const step = Math.sign(-deltaY) * LOUPE_ZOOM_STEP; @@ -115,18 +123,36 @@ export function initLoupe( let y = win.innerHeight / 2; let press = false; // 오른쪽 길게 누름 let key = false; // Z 누름 - let shown = false; + let shown = false; // 켜짐 + let seen = false; // 원이 보임 — 켜짐이어도 CAD 위면 숨김 + let overCad = false; let holdTimer = 0; let swallowMenu = false; // 길게 누른 뒤 따라오는 우클릭 메뉴 한 번을 먹음 let zoom = LOUPE_ZOOM; const update = (): void => { const want = (press || key) && isOn(); - if (want && !shown) { - zoom = LOUPE_ZOOM; // 켤 때마다 2배 + if (want && !shown) zoom = LOUPE_ZOOM; // 켤 때마다 2배 + const see = want && !overCad; + if (see && !seen) { view.show(x, y); - } else if (!want && shown) view.hide(); + if (zoom !== LOUPE_ZOOM) view.setZoom(zoom); // CAD 에서 나옴 — 배율 그대로 + } else if (!see && seen) view.hide(); shown = want; + seen = see; + }; + const track = (e: MouseEvent): void => { + x = e.clientX; + y = e.clientY; + const cad = isCadTarget(e.target); + if (cad === overCad) return; + overCad = cad; + update(); + }; + const releaseZ = (e: KeyboardEvent): void => { + if (keyLetter(e) !== "z" || !key) return; + key = false; + update(); }; const stopHold = (): void => { win.clearTimeout(holdTimer); @@ -136,20 +162,21 @@ export function initLoupe( win.addEventListener(type, fn, true); on("mousemove", (e) => { - x = e.clientX; - y = e.clientY; + track(e); if (!(e.buttons & 2) && (holdTimer || press)) { stopHold(); // 창 밖에서 뗌 — mouseup 이 안 옴 press = false; update(); } - if (shown) view.move(x, y); + if (seen) view.move(x, y); }); + on("mouseover", track); // iframe 위로 들어가면 mousemove 가 안 옴 — 들어갈 때 한 번 + on("mousedown", (e) => { if (e.button !== 2) return; swallowMenu = false; stopHold(); - if (!isOn()) return; + if (!isOn() || isCadTarget(e.target)) return; x = e.clientX; y = e.clientY; holdTimer = win.setTimeout(() => { @@ -177,16 +204,12 @@ export function initLoupe( key = true; update(); }); - on("keyup", (e) => { - if (keyLetter(e) !== "z" || !key) return; - key = false; - update(); - }); + on("keyup", releaseZ); // 켜 있는 동안만 휠을 잡음 — 잡기 단계 첫 자리 · passive:false 여야 스크롤을 막음 win.addEventListener( "wheel", (e) => { - if (!shown) return; + if (!seen) return; // CAD 위 — CAD 휠 줌 그대로 e.preventDefault(); e.stopImmediatePropagation(); // 표 · 그래프 휠 줌도 안 받음 zoom = nextLoupeZoom(zoom, e.deltaY); @@ -195,7 +218,19 @@ export function initLoupe( { capture: true, passive: false }, ); on("blur", (e) => { - if (e.target !== win) return; // 칸 초점 빠짐은 무시 — 창이 초점을 잃을 때만(CAD iframe 으로 넘어감 포함) + if (e.target !== win) return; // 칸 초점 빠짐은 무시 — 창이 초점을 잃을 때만 + const active = win.document?.activeElement as HTMLIFrameElement | null | undefined; + if (isCadTarget(active)) { + // 커서가 CAD 로 들어가 초점이 넘어감(cad_host) — 끄지 않고 숨김만 · Z 떼기는 그쪽 창에서 들음 + try { + active?.contentWindow?.addEventListener("keyup", releaseZ, true); + } catch { + // 다른 출처 — 나올 때 Z 를 다시 눌러야 꺼짐 + } + overCad = true; + update(); + return; + } stopHold(); press = key = false; update(); @@ -328,18 +363,15 @@ function walk( 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 (tag === "IFRAME") { + const box = doc.createElement("div"); // 복제 iframe 은 CAD 를 또 띄움 — 빈 상자로 바꿈 + c.replaceWith(box); + return emptyBox(box, r); } + const off = r.bottom < 0 || r.right < 0 || r.top > vh || r.left > vw; + if (o.classList?.contains("cad-host") || (!KEEP_INSIDE.has(tag) && off)) return emptyBox(c, r); // CAD · 화면 밖 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); @@ -364,23 +396,12 @@ function copyValue(o: Element, c: Element): void { } } -/** 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; +/** 속을 비우고 크기만 남김 — 화면 밖 · CAD 영역 */ +function emptyBox(c: Element, r: DOMRect): void { + c.replaceChildren(); + Object.assign((c as HTMLElement).style, { + boxSizing: "border-box", + width: `${r.width}px`, + height: `${r.height}px`, + }); }