/* ============================================================================= * A00_Common/design/design_profile_popout.ts * 종단 하단 패널(종단도 · 표 · 유토곡선 · 배수유역도)을 **브라우저 새 창**으로 뺀다(계획서 54-35). * 유토곡선 · 배수유역 판은 따로 또 뗀다(계획서 325-3) — 같은 몸통(`attachPopout`), 창 이름만 다름. * * 화면 조각(DOM)만 옮긴다 — 코드 · 자료 · 가게는 메인 창 그대로, 같은 함수가 그린다. * 창 전역에 걸린 듣기(방향키 · 포인터 놓기 · 메뉴 닫기)는 메인 창에만 있으므로 새 창에서 난 * 키 · 포인터를 메인 문서로 넘긴다. 새 창을 닫거나 메인 창이 떠나면 조각을 제자리로 돌린다. * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; import type { DesignProfile } from "./design_profile"; /** 메인 창 문서 · 창에 걸린 듣기가 받아야 할 새 창 이벤트. */ const FORWARD_TYPES = ["keydown", "keyup", "pointerdown", "pointerup", "pointercancel"]; /** 요소가 놓인 창(문서에 안 붙었으면 메인 창). */ const viewOfNode = (node: Node): Window => node.ownerDocument?.defaultView ?? window; /** 조각 하나를 새 창으로 떼는 단추 — 창 이름은 조각마다 달라야 함(같으면 앞 창 body 를 덮어 조각이 사라짐). */ interface PopoutSpec { piece: HTMLElement; button: HTMLButtonElement; name: string; features: string; docTitle: string; /** 조각을 제자리로 돌림. */ home: () => void; /** 뗐다 · 붙였다(조각이 옮겨 간 뒤). */ onMove?: (popped: boolean) => void; } /** 조각을 새 창으로 떼고 붙이는 몸통 — 새 창은 단추가 놓인 창에서 엶(종단 새 창 안에서 다시 떼기). */ function attachPopout(profile: DesignProfile, spec: PopoutSpec): void { const { piece, button } = spec; let popup: Window | null = null; button.type = "button"; button.textContent = "창 분할"; button.addEventListener("click", () => (popup && !popup.closed ? closePopup() : open())); const restore = (): void => { if (!piece.classList.contains("is-popout")) return; piece.classList.remove("is-popout"); button.textContent = "창 분할"; spec.home(); popup = null; spec.onMove?.(false); viewOfNode(piece).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 opener = viewOfNode(button); const next = opener.open("", spec.name, spec.features); if (!next) { showToast("새 창이 막힘 — 주소창의 팝업 허용을 켠 뒤 다시 누름", "error"); return; } popup = next; const source = opener.document; const doc = next.document; doc.title = spec.docTitle; // 스타일 · 테마 — 단추가 놓인 창의 시트 · html 속성을 그대로 옮겨 같은 색 · 글꼴로 그린다. doc.head.replaceChildren( ...Array.from(source.querySelectorAll('link[rel="stylesheet"], style'), (node) => doc.importNode(node, true), ), ); Array.from(source.documentElement.attributes).forEach((attr) => doc.documentElement.setAttribute(attr.name, attr.value), ); doc.body.className = source.body.className; doc.body.style.cssText = `margin:0;height:100dvh;overflow:hidden;background:${opener.getComputedStyle(source.body).backgroundColor}`; piece.classList.add("is-popout"); button.textContent = "창 결합"; doc.body.replaceChildren(piece); spec.onMove?.(true); // 새 창 키 · 포인터를 메인 문서로 — 대상은 새 창 요소 그대로(입력칸 판정 · 메뉴 안 판정). 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(piece); next.addEventListener("pagehide", restore); } } /** 종단 도구줄 [초기화] 왼쪽 단추(`toolLead`)를 [창 분할] 단추로 꾸민다 — 두 보기가 같은 한 벌을 쓴다. */ export function attachProfilePopout( profile: DesignProfile, slot: HTMLElement, button: HTMLButtonElement, ): void { button.className = "ui-btn ui-btn--ghost b05-route-profile__popout"; button.title = "종단 하단 패널을 별도 창으로 분할 · 다시 누르면 결합"; attachPopout(profile, { piece: profile.root, button, name: "aislo-profile-popout", features: "popup,width=1400,height=720", docTitle: "종단 — Aislo", home: () => slot.append(profile.root), }); } /** 유토곡선 · 배수유역 판에 [창 분할] 단추를 단다 — 두 판 머리줄 맨 앞(계획서 325-3 · 354-1). */ export function attachSubPanelPopouts(profile: DesignProfile): void { const subButton = (title: string): HTMLButtonElement => { const button = document.createElement("button"); button.className = "ui-btn ui-btn--ghost b05-subpanel__popout"; button.title = title; return button; }; const massOverlay = profile.root.querySelector(".b05-profile__masshaul-overlay"); const massHead = massOverlay?.querySelector(".b05-profile__masshaul-head"); if (massOverlay && massHead) { const button = subButton("유토곡선 창 분할"); // 판 머리줄 맨 앞 — 접어도 남아 닿는다(354-1). massHead.prepend(button); const column = massOverlay.parentElement; attachPopout(profile, { piece: massOverlay, button, name: "aislo-masshaul-popout", features: "popup,width=1400,height=420", docTitle: "유토곡선 — Aislo", // 제자리 = 종단 열 끝(종단 칸 아래 자기 판). home: () => column?.append(massOverlay), }); } const drainage = profile.side; const header = drainage?.root.querySelector(".b05-drainage__header"); if (drainage && header) { const button = subButton("배수유역 창 분할"); header.prepend(button); const content = drainage.root.parentElement; attachPopout(profile, { piece: drainage.root, button, name: "aislo-drainage-popout", features: "popup,width=1000,height=720", docTitle: "배수유역 — Aislo", // 제자리 = 종단 content 끝(종단 본문 오른쪽). home: () => content?.append(drainage.root), // 지도 크기 관찰을 놓인 창 것으로(325-1). onMove: () => drainage.rehome(), }); } }