feat(B05·B06): 54-35 종단 하단 패널 [새 창] 분리

- 종단 도구줄 [배수유역도] 뒤 [새 창] 단추 — 종단도·표·유토곡선·배수유역도 묶음을 같은 출처 팝업으로 옮김 · 코드·자료·가게는 메인 창 그대로
- 스타일 시트·html 테마 속성 복사 · 팝업 크기 변화 시 팝업 ResizeObserver 로 다시 그림
- 팝업 키·포인터(keydown·keyup·pointerdown·pointerup·pointercancel)·blur 를 메인 문서로 넘김 — 대상 요소 유지
- 팝업 닫힘·[제자리로]·메인 창 떠남·페이지 정리 때 원래 자리로 되돌림 · 팝업 차단 시 알림 한 줄
- 우클릭 메뉴 화면 밖 보정을 요소 소속 창 기준으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
This commit is contained in:
2026-10-06 14:01:35 +09:00
co-authored by Claude Opus 5.5
parent 95b849b440
commit 2a5430f9b5
5 changed files with 124 additions and 4 deletions
+15
View File
@@ -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,
+101
View File
@@ -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<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(root);
next.addEventListener("pagehide", restore);
}
}
+4 -1
View File
@@ -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<K extends keyof RouteProfilePanelCallbacks>(
/** 종단도 한 벌 — 처음 부를 때 만들어 종단도 자리에 붙인다. */
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;
}
@@ -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")):
+3 -2
View File
@@ -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`;
},