Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
180 lines
7.4 KiB
TypeScript
180 lines
7.4 KiB
TypeScript
/* =============================================================================
|
|
* 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<string, typeof Event>)[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<HTMLElement>(".b05-profile__masshaul-overlay");
|
|
const massHead = massOverlay?.querySelector<HTMLElement>(".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<HTMLElement>(".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(),
|
|
});
|
|
}
|
|
}
|