import "./ui_template_overlay.css"; import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { t } from "./ui_template_locale"; import { makePanelDraggable } from "./ui_template_overlay_drag"; // 워크플로 상태는 공용 창구 하나로 받는다 — 화면마다 따로 부르면 진입에서 같은 답을 // 두 번 받는다(2026-09-06 실측). 그 창구가 짧은 시간 동안 캐시한다. import { fetchWorkflowState } from "../A00_Common/b_workflow_nav"; import { readByKey, writeByKey } from "../A00_Common/b_page_state"; const TITLE_OVERLAY_STATE_KEY = "frd_workflow_title_overlay_open"; const PROGRESS_OVERLAY_STATE_KEY = "frd_workflow_progress_overlay_open"; const PROGRESS_OVERLAY_POSITION_KEY = "frd_workflow_progress_overlay_pos"; export interface WorkflowOverlayOptions { title: string; progressContent: HTMLElement; optionsContent?: HTMLElement; showTitlePanel?: boolean; onOptionsOpenChange?: (isOpen: boolean) => void; } export interface WorkflowOverlayHandle { root: HTMLElement; setTitleOpen: (isOpen: boolean) => void; setProgressOpen: (isOpen: boolean) => void; } export interface WorkflowPanelHandle { root: HTMLButtonElement; setOpen: (isOpen: boolean) => void; } export function createWorkflowPanelHandle( placement: "side" | "bottom", /** * 패널이 접히면서 사라지는 쪽. 화살표가 항상 그 방향을 가리키게 만든다. * * 기본값 `"left"`는 왼쪽에 도킹해 왼쪽으로 접히는 패널(제목 카드)용이다. * 오른쪽에 도킹해 오른쪽으로 접히는 패널(B05 배수유역도)은 `"right"`를 넘겨야 * 화살표가 뒤집히지 않는다 — 도킹 방향이 반대인데 같은 회전을 쓰면 열기/닫기 * 표시가 정확히 거꾸로 보인다. * * `placement: "bottom"`은 위아래로 접히는 패널이다. 기본은 아래로 접히는 하단 도킹 * 패널(`"down"`)이고, **위로 접히는 상단 도킹 패널**(B06 종단·유토곡선 패널)은 `"up"`을 * 넘겨야 한다. 좌우와 같은 이유로, 도킹 방향이 반대인데 같은 회전을 쓰면 표시가 거꾸로 된다. */ collapseToward: "left" | "right" | "up" | "down" = "left", /** * 손잡이 옆에 붙일 짧은 이름표(선택, 2026-08-04 사용자 지시 — 무엇의 손잡이인지 보이게). * bottom 손잡이는 화살표 **오른쪽**에 가로로, side 손잡이는 화살표 **위쪽**에 90° 세로로 * 놓인다. 사이드바(제목 패널)처럼 이름이 필요 없는 곳은 생략하면 현행과 같다. */ label?: string, ): WorkflowPanelHandle { const root = document.createElement("button"); root.type = "button"; root.className = `ui-workflow-overlay__handle ui-workflow-overlay__handle--${placement}`; const icon = document.createElement("span"); icon.className = "ui-workflow-overlay__handle-icon"; if (label) { root.classList.add("is-labeled"); const text = document.createElement("span"); text.className = "ui-workflow-overlay__handle-label"; text.textContent = label; // side는 이름표가 화살표 위, bottom은 화살표 오른쪽 — DOM 순서로 자리를 정한다. if (placement === "side") root.append(text, icon); else root.append(icon, text); } else { root.append(icon); } function setOpen(isOpen: boolean): void { // 삼각형은 회전 0°에서 오른쪽(▶)을 가리킨다. 펼친 상태면 접히는 쪽을, // 접힌 상태면 펼쳐질 쪽을 가리키도록 90°/180°를 붙였다 뗀다. const pointsRight = isOpen === (collapseToward === "right"); const pointsUp = isOpen === (collapseToward === "up"); const rotation = placement === "side" ? (pointsRight ? 0 : 180) : pointsUp ? -90 : 90; icon.style.transform = `rotate(${rotation}deg)`; root.title = t(isOpen ? "Workflow_Overlay_Collapse" : "Workflow_Overlay_Expand"); root.setAttribute("aria-label", root.title); root.setAttribute("aria-expanded", String(isOpen)); } return { root, setOpen }; } /** 좌측 패널을 **접은 채로 시작**하는 폭 — `ui_template_overlay.css` 의 560 과 짝이다. */ const NARROW_START_COLLAPSED_MAX_PX = 560; /** * 처음 열 때 펼쳐 둘지. * * ⚠ **사용자가 정한 적이 있으면 그 뜻이 이긴다** — 저장값이 있으면 그대로 따른다. * 정한 적이 없고 화면이 아주 좁으면 **접은 채로 시작**한다. 그 폭에서는 패널이 본문 * 위에 겹쳐 떠서, 펼친 채로 열면 본문이 가려진 화면부터 보게 된다(2026-09-08). */ function readOpenState(key: string, collapseWhenNarrow = false): boolean { const raw = readByKey(key); if (raw !== null) return raw !== "false"; if (!collapseWhenNarrow) return true; return window.innerWidth > NARROW_START_COLLAPSED_MAX_PX; } /** * 사이드 패널 본문을 [스크롤 영역][고정 액션 영역] 두 칸으로 쪼갠다. * * 액션 줄(`.ui-sidebar-actions`)을 sticky로만 붙여 두면 본문 높이가 패널 높이를 * 넘나들 때마다 자리가 흔들린다(2026-08-18 사용자 보고). 액션 줄을 스크롤 대상에서 * 아예 빼고, 나머지 그룹만 마진·패딩 없는 래퍼(`.ui-sidebar-scroll`)에 담아 그쪽만 * 스크롤시킨다. 페이지는 지금처럼 본문 마지막에 액션 줄을 붙이기만 하면 된다. */ function splitSidebarActions(body: HTMLElement): void { // 액션 줄은 보통 페이지 패널 루트(b05-route__panel 등) **안**에 있다 — body 직계가 // 아니므로 깊이 무관하게 찾고, 액션 줄의 실제 부모 기준으로 쪼갠다. const actions = body.querySelector(".ui-sidebar-actions"); const container = actions?.parentElement; if (!actions || !container) return; const scroll = document.createElement("div"); scroll.className = "ui-sidebar-scroll"; // 액션 줄 앞의 형제만 옮긴다 — 뒤에 붙은 요소(없어야 정상)는 그대로 둔다. while (container.firstChild && container.firstChild !== actions) scroll.append(container.firstChild); container.prepend(scroll); body.classList.add("ui-sidebar-split"); // body → 부모 사이 중간 요소들이 높이를 이어주도록 표시한다(플렉스 세로 확장). for (let el = container; el && el !== body; el = el.parentElement as HTMLElement) { el.classList.add("ui-sidebar-fill"); } } /** * 제목 줄 오른쪽에 붙는 프로젝트 이름 (2026-09-04 사용자 지시). * * 화면이 들고 있는 것은 프로젝트 id 뿐이라 워크플로 상태 응답에서 이름을 받아 온다 — * 새로고침·주소 직접 입력으로 들어와도 따라온다. 이름이 없으면 칸을 비워 둔다. * 여기 한 곳에 두어 제목 패널을 쓰는 화면(B03~B08)이 모두 같은 값을 보인다. */ function createProjectNameTag(): HTMLElement { const tag = document.createElement("span"); tag.className = "ui-workflow-overlay__project"; const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); if (!projectId) return tag; void fetchWorkflowState(projectId) .then((state) => { const name = state.project_name ?? ""; tag.textContent = name; // 이름이 길면 말줄임으로 자르고 전체는 툴팁으로 본다. if (name) tag.title = name; }) .catch(() => { /* 조회 실패는 제목만 보이면 된다 — 빈 칸으로 둔다. */ }); return tag; } function createPanel( variant: "title" | "progress", titleText: string, body: HTMLElement, storageKey: string, onOpenChange?: (isOpen: boolean) => void, titleAside?: HTMLElement, ): { root: HTMLElement; setOpen: (isOpen: boolean) => void } { const root = document.createElement("aside"); root.className = `ui-workflow-overlay__panel ui-workflow-overlay__panel--${variant}`; // title 패널은 사이드바 도킹형 — 토글이 패널 우측 가장자리 세로 중앙 핸들로 나감 const isSidebar = variant === "title"; const header = document.createElement("div"); header.className = "ui-workflow-overlay__header"; const title = document.createElement("h2"); title.className = "ui-workflow-overlay__title"; title.textContent = titleText; const panelHandle = isSidebar ? createWorkflowPanelHandle("side") : null; const toggle = panelHandle?.root ?? document.createElement("button"); if (!panelHandle) { toggle.type = "button"; toggle.className = "ui-workflow-overlay__toggle"; } if (isSidebar) { header.append(title); // 제목은 왼쪽, 프로젝트 이름은 같은 행 오른쪽 끝 (2026-09-04 사용자 지시). if (titleAside) header.append(titleAside); root.append(header, toggle); } else { header.append(title, toggle); root.append(header); } if (body.childElementCount > 0) { body.classList.add("ui-workflow-overlay__body"); if (isSidebar) splitSidebarActions(body); root.append(body); } let dragHandle: { refresh: () => void } | null = null; function setOpen(isOpen: boolean, persist = true): void { root.classList.toggle("is-collapsed", !isOpen); if (panelHandle) panelHandle.setOpen(isOpen); else { toggle.textContent = isOpen ? "−" : "+"; toggle.title = t(isOpen ? "Workflow_Overlay_Collapse" : "Workflow_Overlay_Expand"); toggle.setAttribute("aria-label", toggle.title); toggle.setAttribute("aria-expanded", String(isOpen)); } // ⚠ 처음 앉힐 때는 저장하지 않는다 — 좁아서 접어 둔 것을 「사용자가 접었다」로 // 적으면 넓은 화면으로 돌아와도 접힌 채로 남는다. if (persist) writeByKey(storageKey, String(isOpen)); // 펼친 뒤에는 아래 공간이 모자랄 수 있다 — 열리는 방향을 다시 잡는다. if (dragHandle) requestAnimationFrame(() => dragHandle?.refresh()); onOpenChange?.(isOpen); } function toggleOpen(): void { setOpen(root.classList.contains("is-collapsed")); } title.tabIndex = 0; title.setAttribute("role", "button"); title.addEventListener("click", toggleOpen); title.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); toggleOpen(); } }); toggle.addEventListener("click", toggleOpen); setOpen(readOpenState(storageKey, isSidebar), false); // 진행단계 패널만 사용자가 자리를 옮긴다(제목 패널은 좌측 도킹 사이드바). if (variant === "progress") { dragHandle = makePanelDraggable(root, header, PROGRESS_OVERLAY_POSITION_KEY); } return { root, setOpen }; } export function createWorkflowOverlays(options: WorkflowOverlayOptions): WorkflowOverlayHandle { const root = document.createElement("div"); root.className = "ui-workflow-overlay"; const progressBody = document.createElement("div"); progressBody.append(options.progressContent); const progressPanel = createPanel( "progress", t("Workflow_Progress_Title"), progressBody, PROGRESS_OVERLAY_STATE_KEY, ); let setTitleOpen = (_isOpen: boolean): void => {}; if (options.showTitlePanel !== false) { const titleBody = document.createElement("div"); if (options.optionsContent) titleBody.append(options.optionsContent); const titlePanel = createPanel( "title", options.title, titleBody, TITLE_OVERLAY_STATE_KEY, options.onOptionsOpenChange, createProjectNameTag(), ); root.append(titlePanel.root); setTitleOpen = titlePanel.setOpen; } root.append(progressPanel.root); return { root, setTitleOpen, setProgressOpen: progressPanel.setOpen, }; }