Files
Aislo/ui_template/ui_template_overlay.ts
T
eomsangdonandClaude Opus 5 9db84d9d20 fix(화면): 패널 높이·접힘이 탭을 새로 열 때마다 초기화되던 것
화면 취향까지 sessionStorage 에 있어 **탭을 새로 열거나 브라우저를 껐다 켜면** 패널 높이·
접힘·유토곡선 열림이 매번 초기화됐음. 계획서에는 「PC 를 바꾸면」으로 적혀 있었으나 실제
불편은 훨씬 잦았음(사용자 승인 뒤 조사에서 드러남).

- 저장소를 통(bucket)으로 가름 — **`pref` 만 localStorage**, 초안·결과는 세션 그대로.
  설계 데이터가 아니므로 CLAUDE.md 5장 「캐시=세션」에 걸리지 않음.
- 키만 들고 저장소를 직접 만지던 화면 공용 부품 셋(`ui_template_resizer`·`_overlay`·
  `_overlay_drag`)이 같은 판단(`storageOf`)을 쓰게 함 — 안 그러면 높이 값만 세션에 남아
  절반만 옮겨졌음(실측: 접힘 4개는 옮겨지고 높이 3개는 안 옮겨짐).
- 첫 로드 때 세션에 남은 옛 취향을 한 번 쓸어 옮김 — 이 변경으로 쓰던 크기가 초기화되지 않게.

실측(공용 브라우저) — 옮긴 뒤 취향 8개 전부 localStorage, 세션 0개, 높이 보존
(종단 438px · 표 204px). **새 탭에서도 8개 그대로**(같은 값). 검증 탭은 닫음.

시험 `tmp/tests/test_prefs_persist_across_tabs.py` 4건 — 취향만 갈리는지 · 초안·결과는
세션인지 · 공용 부품이 같은 판단을 쓰는지 · 옛 값을 한 번 옮기는지.

이 조각은 DB 없이 되는 몫임. 계정 저장(`018_user_ui_prefs`)은 다음 단계.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 13:51:58 +09:00

259 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { storageOf } 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 };
}
function readOpenState(key: string): boolean {
return storageOf(key).getItem(key) !== "false";
}
/**
* 사이드 패널 본문을 [스크롤 영역][고정 액션 영역] 두 칸으로 쪼갠다.
*
* 액션 줄(`.ui-sidebar-actions`)을 sticky로만 붙여 두면 본문 높이가 패널 높이를
* 넘나들 때마다 자리가 흔들린다(2026-08-18 사용자 보고). 액션 줄을 스크롤 대상에서
* 아예 빼고, 나머지 그룹만 마진·패딩 없는 래퍼(`.ui-sidebar-scroll`)에 담아 그쪽만
* 스크롤시킨다. 페이지는 지금처럼 본문 마지막에 액션 줄을 붙이기만 하면 된다.
*/
function splitSidebarActions(body: HTMLElement): void {
// 액션 줄은 보통 페이지 패널 루트(b05-route__panel 등) **안**에 있다 — body 직계가
// 아니므로 깊이 무관하게 찾고, 액션 줄의 실제 부모 기준으로 쪼갠다.
const actions = body.querySelector<HTMLElement>(".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): 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));
}
storageOf(storageKey).setItem(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));
// 진행단계 패널만 사용자가 자리를 옮긴다(제목 패널은 좌측 도킹 사이드바).
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,
};
}