From 2a5430f9b5aa5f776114c399ac1980653ce0723b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 14:01:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05=C2=B7B06):=2054-35=20=EC=A2=85?= =?UTF-8?q?=EB=8B=A8=20=ED=95=98=EB=8B=A8=20=ED=8C=A8=EB=84=90=20[?= =?UTF-8?q?=EC=83=88=20=EC=B0=BD]=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 종단 도구줄 [배수유역도] 뒤 [새 창] 단추 — 종단도·표·유토곡선·배수유역도 묶음을 같은 출처 팝업으로 옮김 · 코드·자료·가게는 메인 창 그대로 - 스타일 시트·html 테마 속성 복사 · 팝업 크기 변화 시 팝업 ResizeObserver 로 다시 그림 - 팝업 키·포인터(keydown·keyup·pointerdown·pointerup·pointercancel)·blur 를 메인 문서로 넘김 — 대상 요소 유지 - 팝업 닫힘·[제자리로]·메인 창 떠남·페이지 정리 때 원래 자리로 되돌림 · 팝업 차단 시 알림 한 줄 - 우클릭 메뉴 화면 밖 보정을 요소 소속 창 기준으로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- A00_Common/design/design_page.css | 15 +++ A00_Common/design/design_profile_popout.ts | 101 ++++++++++++++++++ A00_Common/design/design_shared.ts | 5 +- .../tester/test_design_profile_single.py | 2 +- ui_template/ui_template_context_menu.ts | 5 +- 5 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 A00_Common/design/design_profile_popout.ts diff --git a/A00_Common/design/design_page.css b/A00_Common/design/design_page.css index 0aa0832d6..3ad5e29d2 100644 --- a/A00_Common/design/design_page.css +++ b/A00_Common/design/design_page.css @@ -40,6 +40,21 @@ flex: none; } +/* 새 창으로 뺀 종단 하단 패널(계획서 54-35) — 창 전체를 채움 · 접기 손잡이 · 높이 끌기는 감춤. */ +.b05-route-profile.is-popout { + position: relative; + height: 100dvh; +} + +.b05-route-profile.is-popout > :not(.b05-route-profile__content) { + display: none; +} + +.b05-route-profile__trailing > .b05-route-profile__popout { + padding: 1px var(--spacing-8); + font-size: var(--text-caption); +} + /* 횡단 조정(카드 조정창 사본 · 폼 칸에 옮겨 붙은 행)은 횡단 보기 몫 — 종단 보기에서는 한 벌 폼 안에서 가린다(패널 뿌리 클래스가 보기를 따라 바뀐다). */ .b05-route__panel .b06-adjust-dock, diff --git a/A00_Common/design/design_profile_popout.ts b/A00_Common/design/design_profile_popout.ts new file mode 100644 index 000000000..ddf5b743d --- /dev/null +++ b/A00_Common/design/design_profile_popout.ts @@ -0,0 +1,101 @@ +/* ============================================================================= + * A00_Common/design/design_profile_popout.ts + * 종단 하단 패널(종단도 · 표 · 유토곡선 · 배수유역도)을 **브라우저 새 창**으로 뺀다(계획서 54-35). + * + * 화면 조각(DOM)만 옮긴다 — 코드 · 자료 · 가게는 메인 창 그대로, 같은 함수가 그린다. + * 창 전역에 걸린 듣기(방향키 · 포인터 놓기 · 메뉴 닫기)는 메인 창에만 있으므로 새 창에서 난 + * 키 · 포인터를 메인 문서로 넘긴다. 새 창을 닫거나 메인 창이 떠나면 조각을 제자리로 돌린다. + * ========================================================================== */ + +import { showToast } from "@ui/ui_template_elements"; +import type { DesignProfile } from "./design_profile"; + +/** 메인 창 문서 · 창에 걸린 듣기가 받아야 할 새 창 이벤트. */ +const FORWARD_TYPES = ["keydown", "keyup", "pointerdown", "pointerup", "pointercancel"]; + +/** 종단 도구줄 [배수유역도] 단추 뒤에 [새 창] 단추를 붙인다 — 두 보기가 같은 한 벌을 쓴다. */ +export function attachProfilePopout( + profile: DesignProfile, + slot: HTMLElement, + sideTool: HTMLElement | null, +): void { + const { root } = profile; + let popup: Window | null = null; + + const button = document.createElement("button"); + button.type = "button"; + button.className = "ui-btn ui-btn--ghost b05-route-profile__popout"; + button.textContent = "새 창"; + button.title = "종단 하단 패널을 브라우저 새 창으로 띄움"; + button.addEventListener("click", () => (popup && !popup.closed ? closePopup() : open())); + sideTool?.after(button); + + const restore = (): void => { + if (!root.classList.contains("is-popout")) return; + root.classList.remove("is-popout"); + button.textContent = "새 창"; + slot.append(root); + popup = null; + requestAnimationFrame(profile.redraw); + }; + const closePopup = (): void => { + popup?.close(); + restore(); + }; + window.addEventListener("pagehide", closePopup); + const dispose = profile.dispose; + profile.dispose = () => { + window.removeEventListener("pagehide", closePopup); + closePopup(); + dispose(); + }; + + function open(): void { + const next = window.open("", "aislo-profile-popout", "popup,width=1400,height=720"); + if (!next) { + showToast("새 창이 막힘 — 주소창의 팝업 허용을 켠 뒤 다시 누름", "error"); + return; + } + popup = next; + const doc = next.document; + doc.title = "종단 — Aislo"; + // 스타일 · 테마 — 메인 창 시트 · html 속성을 그대로 옮겨 같은 색 · 글꼴로 그린다. + doc.head.replaceChildren( + ...Array.from(document.querySelectorAll('link[rel="stylesheet"], style'), (node) => + doc.importNode(node, true), + ), + ); + Array.from(document.documentElement.attributes).forEach((attr) => + doc.documentElement.setAttribute(attr.name, attr.value), + ); + doc.body.className = document.body.className; + doc.body.style.cssText = `margin:0;height:100dvh;overflow:hidden;background:${getComputedStyle(document.body).backgroundColor}`; + root.classList.add("is-popout"); + button.textContent = "제자리로"; + doc.body.replaceChildren(root); + + // 새 창 키 · 포인터를 메인 문서로 — 대상은 새 창 요소 그대로(입력칸 판정 · 메뉴 안 판정). + FORWARD_TYPES.forEach((type) => + next.addEventListener( + type, + (event) => { + const Ctor = (window as unknown as Record)[event.constructor.name]; + const copy = new (Ctor ?? Event)(type, event); + Object.defineProperty(copy, "target", { value: event.target }); + document.dispatchEvent(copy); + if (copy.defaultPrevented) event.preventDefault(); + }, + true, + ), + ); + // 새 창이 초점을 잃으면 길게 누르기를 끊는다(메인 창 blur 듣기). + next.addEventListener("blur", () => window.dispatchEvent(new FocusEvent("blur"))); + // 새 창 크기 기준으로 다시 맞춘다. + let pending = 0; + new (next as Window & typeof globalThis).ResizeObserver(() => { + next.cancelAnimationFrame(pending); + pending = next.requestAnimationFrame(profile.redraw); + }).observe(root); + next.addEventListener("pagehide", restore); + } +} diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 36c04ff22..5800f0416 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -22,6 +22,7 @@ import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_U import { drainageSide, drainageSideTool } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool"; import { createDesignPanel, type DesignPanel } from "./design_panel"; import { createDesignProfile, type DesignProfile } from "./design_profile"; +import { attachProfilePopout } from "./design_profile_popout"; import { saveDesign, type DesignSaveStep } from "./design_save"; import { resetDesign } from "./design_reset"; import type { DesignView } from "./design_dock"; @@ -147,11 +148,12 @@ function toAll( /** 종단도 한 벌 — 처음 부를 때 만들어 종단도 자리에 붙인다. */ export function sharedProfile(shared: DesignPageShared): DesignProfile { if (shared.profile) return shared.profile; + let sideTool: HTMLElement | null = null; const profile = createDesignProfile({ projectId: shared.projectId, view: shared.view(), side: drainageSide, - sideTool: drainageSideTool, + sideTool: (board) => (sideTool = drainageSideTool(board)), onSelectStation: (stationId) => bound(shared).profile?.onSelectStation(stationId), onResetAll: () => bound(shared).profile?.onResetAll?.(), callbacks: { @@ -173,6 +175,7 @@ export function sharedProfile(shared: DesignPageShared): DesignProfile { }, }); shared.profileSlot.append(profile.root); + attachProfilePopout(profile, shared.profileSlot, sideTool); shared.profile = profile; return profile; } diff --git a/resources/tester/test_design_profile_single.py b/resources/tester/test_design_profile_single.py index c6eba38b1..20177abb3 100644 --- a/resources/tester/test_design_profile_single.py +++ b/resources/tester/test_design_profile_single.py @@ -39,7 +39,7 @@ def test_두_보기가_같은_종단도와_같은_판(): """종단도는 한 페이지 한 벌(계획서 24-3b) — 두 보기는 같은 노드에 제 알림만 건다.""" shared = _read("A00_Common/design/design_shared.ts") assert shared.count("createDesignProfile(") == 1 - assert "side: drainageSide" in shared and "sideTool: drainageSideTool" in shared + assert "side: drainageSide" in shared and "drainageSideTool(board)" in shared b05 = _read("B05_Profile/B05_Profile_UI_Profile_Panel.ts") b06 = _read("B06_Section/B06_Section_UI_Section_View_Profile.ts") for source, view in ((b05, "profile"), (b06, "cross")): diff --git a/ui_template/ui_template_context_menu.ts b/ui_template/ui_template_context_menu.ts index 9b7ece1b4..4861ceab8 100644 --- a/ui_template/ui_template_context_menu.ts +++ b/ui_template/ui_template_context_menu.ts @@ -147,8 +147,9 @@ export function createMapContextMenu(blockClass: string): MapContextMenu { element.hidden = false; // 화면 밖으로 나가면 안쪽으로 당김(오른쪽 · 아래 끝에서 연 메뉴) const box = element.getBoundingClientRect(); - const dx = Math.max(0, box.right - window.innerWidth + 4); - const dy = Math.max(0, box.bottom - window.innerHeight + 4); + const view = element.ownerDocument.defaultView ?? window; // 새 창에 옮겨진 패널(54-35) + const dx = Math.max(0, box.right - view.innerWidth + 4); + const dy = Math.max(0, box.bottom - view.innerHeight + 4); if (dx) element.style.left = `${Math.max(x - dx, x - box.left)}px`; if (dy) element.style.top = `${Math.max(y - dy, y - box.top)}px`; },