- 새 단추 꼴 danger-ghost(테두리 · 글 빨강 · 올리면 채움) · [초기화]만 - B05 [<] 흐림 · 주소 바뀔 때마다 [<] [>] 다시 맞춤 - 단계 판 B07~B09 · B06 [>] = 확정 뒤 그 주소 · 실패 · 자료 서기 전 = 머묾 - 노선 바뀐 채 확정 = 저장 뒤 물음 [머묾] / [B07 로](새 노선으로 확정 뒤 이동) - 막힘 = NOT_STARTED 만(STALE 은 색만) · B05·B06 화면 2 · 3 · 4 칸 늘 켬 · [저장] 뒤 단계 색 다시 읽음 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
216 lines
8.4 KiB
TypeScript
216 lines
8.4 KiB
TypeScript
/* =============================================================================
|
|
* ui_template_help.ts
|
|
* [?] 설명서(54-2) — 좌측 패널 아래 단추 묶음 맨 왼쪽 단추 + 모달(왼쪽 차례 · 오른쪽 글). B02~B09 공용.
|
|
* 글 = `resources/help/<페이지>/*.md` — 서버가 열 때마다 읽음(파일만 고치면 다음에 열 때 바뀜).
|
|
* 페이지 = 주소(#/b05-…)의 B0N · 먼저 여는 항목 = 좌측 패널에서 보이는 `[data-help-topic]` 중 마지막
|
|
* (지금 연 상자 · 구조물 종류) · 없으면 첫 파일(00_설명서).
|
|
* 단추 자리 = `.ui-sidebar-actions` 안 `[data-help-row]`(없으면 그 줄 자신)의 맨 앞.
|
|
* [<] [>](요구 280-2) = [?] 바로 뒤 · B05~B09 다섯 주소만 · 누르면 `ui-step-nav` 를 올려 보냄 →
|
|
* 진행 단계 틀(`createWorkflowLayout`)이 이웃 단계 단추를 누름(저장 · 막힘은 각 페이지 `onStepClick`).
|
|
* ========================================================================== */
|
|
|
|
import "./ui_template_help.css";
|
|
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
|
|
import { createButton, el } from "./ui_template_elements";
|
|
import { openModal } from "./ui_template_modal";
|
|
|
|
interface HelpItem {
|
|
key: string;
|
|
title: string;
|
|
text: string;
|
|
}
|
|
|
|
const TEXT = { button: "?", tip: "설명서", title: "설명서", close: "닫기", empty: "설명 글 없음" };
|
|
const STEP_TEXT = { prev: "<", next: ">", prevTip: "이전 단계", nextTip: "다음 단계" };
|
|
|
|
/** [<] [>] 를 붙이는 주소 — 목록으로 거름(b09-print · B03 · B04 · M01 · M02 뺌) */
|
|
const STEP_NAV_ROUTES = [
|
|
ROUTES.B05_PROFILE,
|
|
ROUTES.B06_SECTION,
|
|
ROUTES.B07_DESIGN_DETAIL,
|
|
ROUTES.B08_QUANTITY,
|
|
ROUTES.B09_ESTIMATION,
|
|
] as const;
|
|
|
|
function currentPage(): string {
|
|
const n = /^#\/b0(\d)/i.exec(location.hash)?.[1];
|
|
return n ? `B0${n}` : "";
|
|
}
|
|
|
|
function escapeHtml(text: string): string {
|
|
return text.replace(
|
|
/[&<>"]/g,
|
|
(c) => `&${{ "&": "amp", "<": "lt", ">": "gt", '"': "quot" }[c]};`,
|
|
);
|
|
}
|
|
|
|
function inline(text: string): string {
|
|
return escapeHtml(text)
|
|
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
|
.replace(/`(.+?)`/g, "<code>$1</code>");
|
|
}
|
|
|
|
/** 메모장 글에 쓸 만큼만 — 제목(#) · 목록(- · 1.) · 문단 · **굵게** · `코드` */
|
|
export function renderHelpMarkdown(text: string): string {
|
|
const out: string[] = [];
|
|
let list: "ul" | "ol" | null = null;
|
|
let para: string[] = [];
|
|
const lines = text.split(/\r?\n/);
|
|
const cells = (line: string): string[] =>
|
|
line
|
|
.trim()
|
|
.replace(/^\||\|$/g, "")
|
|
.split("|")
|
|
.map((cell) => cell.trim());
|
|
const separator = (line: string): boolean =>
|
|
cells(line).every((cell) => /^:?-{3,}:?$/.test(cell));
|
|
const flush = (): void => {
|
|
if (para.length) out.push(`<p>${para.map(inline).join("<br>")}</p>`);
|
|
para = [];
|
|
if (list) out.push(`</${list}>`);
|
|
list = null;
|
|
};
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const raw = lines[index];
|
|
const line = raw.trim();
|
|
if (line.includes("|") && lines[index + 1]?.includes("|") && separator(lines[index + 1])) {
|
|
flush();
|
|
const headers = cells(line);
|
|
const rows: string[][] = [];
|
|
index += 2;
|
|
while (index < lines.length && lines[index].includes("|")) rows.push(cells(lines[index++]));
|
|
index -= 1;
|
|
out.push(
|
|
`<div class="ui-markdown-table"><table><thead><tr>${headers.map((cell) => `<th>${inline(cell)}</th>`).join("")}</tr></thead><tbody>${rows.map((row) => `<tr>${headers.map((_, cell) => `<td>${inline(row[cell] ?? "")}</td>`).join("")}</tr>`).join("")}</tbody></table></div>`,
|
|
);
|
|
continue;
|
|
}
|
|
const head = /^(#{1,4})\s+(.*)$/.exec(line);
|
|
const item = /^(?:([-*])|\d+\.)\s+(.*)$/.exec(line);
|
|
if (!line) flush();
|
|
else if (head) {
|
|
flush();
|
|
out.push(`<h${head[1].length + 1}>${inline(head[2])}</h${head[1].length + 1}>`);
|
|
} else if (item) {
|
|
const kind = item[1] ? "ul" : "ol";
|
|
if (para.length || list !== kind) flush();
|
|
if (!list) out.push(`<${kind}>`);
|
|
list = kind;
|
|
out.push(`<li>${inline(item[2])}</li>`);
|
|
} else {
|
|
if (list) flush();
|
|
para.push(line);
|
|
}
|
|
}
|
|
flush();
|
|
return out.join("");
|
|
}
|
|
|
|
function visibleTopics(): string[] {
|
|
const panel = document.querySelector(".ui-workflow-overlay__panel--title") ?? document;
|
|
return [...panel.querySelectorAll<HTMLElement>("[data-help-topic]")]
|
|
.filter((node) => node.offsetParent !== null)
|
|
.map((node) => node.dataset.helpTopic ?? "");
|
|
}
|
|
|
|
async function fetchItems(page: string): Promise<HelpItem[]> {
|
|
const response = await fetch(`${API_BASE_URL}/help/${page}`, {
|
|
credentials: "include",
|
|
cache: "no-store",
|
|
});
|
|
return response.ok ? ((await response.json()) as { items: HelpItem[] }).items : [];
|
|
}
|
|
|
|
/** B05 · B06 = 한 페이지(구조물 폼 공유) — 제 폴더 뒤에 짝 폴더 항목(짝의 설명서는 뺌)도 보임 */
|
|
const PAIR: Record<string, string> = { B05: "B06", B06: "B05" };
|
|
|
|
async function openHelp(): Promise<void> {
|
|
const page = currentPage();
|
|
const pair = PAIR[page];
|
|
const [own, other] = await Promise.all([fetchItems(page), pair ? fetchItems(pair) : []]);
|
|
const items = [...own, ...other.filter((item) => !own.some((o) => o.key === item.key))];
|
|
const topics = visibleTopics();
|
|
const first = [...topics].reverse().find((key) => items.some((item) => item.key === key));
|
|
openModal({
|
|
title: `${TEXT.title} · ${page}`,
|
|
closeLabel: TEXT.close,
|
|
dialogClass: "ui-help",
|
|
mount: (body) => {
|
|
const article = el("article", { className: "ui-help__text" });
|
|
const toc = el("nav", { className: "ui-help__toc" });
|
|
const show = (key: string): void => {
|
|
const item = items.find((entry) => entry.key === key);
|
|
article.innerHTML = item ? renderHelpMarkdown(item.text) : `<p>${TEXT.empty}</p>`;
|
|
article.dataset.helpKey = key;
|
|
toc
|
|
.querySelectorAll("button")
|
|
.forEach((b) => b.classList.toggle("is-active", b.dataset.helpKey === key));
|
|
};
|
|
for (const item of items) {
|
|
const link = el("button", {
|
|
className: "ui-help__link",
|
|
text: item.title,
|
|
attrs: { type: "button" },
|
|
});
|
|
link.dataset.helpKey = item.key;
|
|
link.addEventListener("click", () => show(item.key));
|
|
toc.append(link);
|
|
}
|
|
body.append(el("div", { className: "ui-help__grid", children: [toc, article] }));
|
|
show(first ?? items[0]?.key ?? "");
|
|
},
|
|
});
|
|
}
|
|
|
|
export function createHelpButton(): HTMLButtonElement {
|
|
const button = createButton({
|
|
label: TEXT.button,
|
|
variant: "ghost",
|
|
onClick: () => void openHelp(),
|
|
});
|
|
button.classList.add("ui-help-btn");
|
|
button.title = button.ariaLabel = TEXT.tip;
|
|
return button;
|
|
}
|
|
|
|
function createStepNavButton(step: -1 | 1): HTMLButtonElement {
|
|
const button = createButton({
|
|
label: step < 0 ? STEP_TEXT.prev : STEP_TEXT.next,
|
|
variant: "ghost",
|
|
onClick: () =>
|
|
button.dispatchEvent(new CustomEvent("ui-step-nav", { bubbles: true, detail: step })),
|
|
});
|
|
button.classList.add("ui-step-nav-btn");
|
|
button.dataset.stepNav = String(step);
|
|
button.title = button.ariaLabel = step < 0 ? STEP_TEXT.prevTip : STEP_TEXT.nextTip;
|
|
return button;
|
|
}
|
|
|
|
/** 지금 주소의 [<] [>] 차례 — 목록 밖이면 -1 */
|
|
function stepNavIndex(): number {
|
|
const route = location.hash.replace(/^#\/?/, "").split("?")[0];
|
|
return (STEP_NAV_ROUTES as readonly string[]).indexOf(route);
|
|
}
|
|
|
|
/** 아래 단추 묶음에 [?] 를 한 번 — `[data-help-row]` 가 있으면 그 줄 · 없으면 묶음 자신의 맨 앞 */
|
|
export function placeHelpButton(actions: HTMLElement): void {
|
|
if (actions.querySelector(".ui-help-btn")) return;
|
|
const row = actions.querySelector<HTMLElement>("[data-help-row]") ?? actions;
|
|
if (stepNavIndex() < 0) return void row.prepend(createHelpButton());
|
|
const prev = createStepNavButton(-1);
|
|
const next = createStepNavButton(1);
|
|
// 첫 단계(B05) [<] · 마지막 단계(B09) [>] 흐림 — B05 ↔ B06 은 페이지를 다시 안 세우므로 주소마다 다시 맞춤
|
|
const sync = (): void => {
|
|
const index = stepNavIndex();
|
|
prev.disabled = index <= 0;
|
|
next.disabled = index === STEP_NAV_ROUTES.length - 1;
|
|
};
|
|
const onHash = (): void => {
|
|
if (prev.isConnected) sync();
|
|
else window.removeEventListener("hashchange", onHash); // 화면에서 빠짐 — 풂
|
|
};
|
|
sync();
|
|
window.addEventListener("hashchange", onHash);
|
|
row.prepend(createHelpButton(), prev, next);
|
|
}
|