/* ============================================================================= * ui_template_help.ts * [?] 사용자 가이드(54-2 · 538) — 좌측 패널 아래 단추 묶음 맨 왼쪽 단추 + 넓은 모달(왼쪽 차례 · 오른쪽 가이드 통째). * 글 = `A05_EduDetail/guide/<페이지>/*.md` — 서버가 열 때마다 읽음(`GET /api/help` 한 번에 모든 페이지). * 차례 = 묶음(임도 설계 B → 안정성평가 E → 예정노선 D) → 페이지 → 기능 · 지금 페이지 펼침. * 처음 자리 = 좌측 패널에서 보이는 `[data-help-topic]` 중 마지막 절(제 페이지 먼저 · 없으면 모든 페이지) · * 없으면 지금 페이지(#/b05-… → B05) 절 · 그것도 없으면 맨 앞. * 시스템 관리자 = 더블클릭 바로 고침(`ui_template_help_edit.ts`). * 단추 자리 = `.ui-sidebar-actions` 안 `[data-help-row]`(없으면 그 줄 자신)의 맨 앞. * [<] [>](요구 280-2) = [?] 바로 뒤 · 단계 묶음(B05~B09 · D02~D04 · E02~E05 · 529-1) 주소만 · 누르면 `ui-step-nav` 를 올려 보냄 → * 진행 단계 틀(`createWorkflowLayout`)이 이웃 단계 단추를 누름(저장 · 막힘은 각 페이지 `onStepClick`). * ========================================================================== */ import "./ui_template_help.css"; import { API_BASE_URL, PLAN_STEP_ROUTES, ROUTES, STAB_STEP_ROUTES } from "@config/config_frontend"; import { createButton, el } from "./ui_template_elements"; import { attachHelpEditing } from "./ui_template_help_edit"; import { openModal } from "./ui_template_modal"; export interface HelpItem { key: string; title: string; text: string; version: string; } export interface HelpPage { page: string; title: string; items: HelpItem[]; /** 그림 이름 → 판(고친 시각) */ images: Record; } export interface HelpGuide { pages: HelpPage[]; admin: boolean; } const TEXT = { button: "?", tip: "사용자 가이드", title: "사용자 가이드", close: "닫기", empty: "가이드 글 없음", imgMissing: "그림 없음 — 더블클릭해 지정", imgPending: "그림 준비 중", }; const STEP_TEXT = { prev: "<", next: ">", prevTip: "이전 단계", nextTip: "다음 단계" }; /** 차례 묶음 — 페이지 머리 글자 */ const GROUPS = [ { label: "임도 설계", prefix: "B" }, { label: "안정성평가", prefix: "E" }, { label: "예정노선", prefix: "D" }, ]; const IMG_RE = /^[A-Za-z0-9_-]+\.(webp|png|jpg)$/; /** [<] [>] 를 붙이는 주소 묶음 — 묶음 안에서만 오감(b09-print · B03 · B04 · M01 · M02 · D01 · E01 뺌) */ const STEP_NAV_GROUPS: ReadonlyArray = [ [ ROUTES.B05_PROFILE, ROUTES.B06_SECTION, ROUTES.B07_DESIGN_DETAIL, ROUTES.B08_QUANTITY, ROUTES.B09_ESTIMATION, ], PLAN_STEP_ROUTES, STAB_STEP_ROUTES, ]; /** 가이드 폴더 이름 = 주소 머리(#/b05-… → B05 · #/d02-… → D02) */ function currentPage(): string { const head = /^#\/([bde]0\d)/i.exec(location.hash)?.[1]; return head ? head.toUpperCase() : ""; } function escapeHtml(text: string): string { return text.replace( /[&<>"]/g, (c) => `&${{ "&": "amp", "<": "lt", ">": "gt", '"': "quot" }[c]};`, ); } /** 가이드 안 링크 `[글](B06/00_설명서)` · `[글](B06)` — 파일 이름 속 괄호 한 겹까지 */ const GOTO_RE = /\[([^\]]+)\]\(([BDE]\d{2}(?:\/(?:[^()]|\([^()]*\))+)?)\)/g; function inline(text: string, links = false): string { const html = escapeHtml(text) .replace(/\*\*(.+?)\*\*/g, "$1") .replace(/`(.+?)`/g, "$1"); return links ? html.replace(GOTO_RE, '$1') : html; } /** 페이지를 넘긴 때만 — 덩어리 원문 줄(`data-src`) · 그림 · 주의/참고 상자 · 가이드 안 링크 */ export interface HelpRenderContext { page: string; images: Record; admin: boolean; } function figure(alt: string, name: string, ctx: HelpRenderContext): string { const version = ctx.images[name]; const img = version ? `${escapeHtml(alt)}` : `
${ctx.admin ? TEXT.imgMissing : TEXT.imgPending}
`; const caption = alt ? `
${inline(alt)}
` : ""; return `
${img}${caption}
`; } function note(lines: string[], src: string): string { const body = lines.map((line) => line.replace(/^>\s?/, "")); const head = /^(주의|참고)\s*[::]\s*(.*)$/.exec(body[0]); if (head) body[0] = head[2]; const kind = head?.[1] === "주의" ? "warn" : "info"; return ``; } /** 메모장 글에 쓸 만큼만 — 제목(#) · 목록(- · 1.) · 표 · 문단 · **굵게** · `코드` (+ ctx 때 그림 · 상자 · 링크) */ export function renderHelpMarkdown(text: string, ctx?: HelpRenderContext): string { const out: string[] = []; let list: "ul" | "ol" | null = null; let para: string[] = []; let start = 0; let end = 0; let listAt = 0; let listFrom = 0; const lines = text.split(/\r?\n/); const src = (from: number, to: number): string => (ctx ? ` data-src="${from}-${to}"` : ""); 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(`${para.map((l) => inline(l, !!ctx)).join("
")}

`); para = []; if (list) out.push(``); 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 from = index; 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( `
${headers.map((cell) => ``).join("")}${rows.map((row) => `${headers.map((_, cell) => ``).join("")}`).join("")}
${inline(cell, !!ctx)}
${inline(row[cell] ?? "", !!ctx)}
`, ); continue; } const head = /^(#{1,4})\s+(.*)$/.exec(line); const item = /^(?:([-*])|\d+\.)\s+(.*)$/.exec(line); const image = ctx && /^!\[([^\]]*)\]\(([^)\s]+)\)$/.exec(line); if (!line) flush(); else if (ctx && line.startsWith(">")) { flush(); const from = index; while (lines[index + 1]?.trim().startsWith(">")) index += 1; const quote = lines.slice(from, index + 1).map((l) => l.trim()); out.push(note(quote, src(from, index))); } else if (image && IMG_RE.test(image[2])) { flush(); out.push(figure(image[1], image[2], ctx).replace("${inline(head[2], !!ctx)}`); } else if (item) { const kind = item[1] ? "ul" : "ol"; if (para.length || list !== kind) flush(); if (!list) { listAt = out.length; listFrom = index; out.push(""); } list = kind; out.push(`
  • ${inline(item[2], !!ctx)}
  • `); out[listAt] = `<${kind}${src(listFrom, index)}>`; } else { if (list) flush(); if (!para.length) start = index; end = index; para.push(line); } } flush(); return out.join(""); } function visibleTopics(): string[] { const panel = document.querySelector(".ui-workflow-overlay__panel--title") ?? document; return [...panel.querySelectorAll("[data-help-topic]")] .filter((node) => node.offsetParent !== null) .map((node) => node.dataset.helpTopic ?? ""); } async function fetchGuide(): Promise { const response = await fetch(`${API_BASE_URL}/help`, { credentials: "include", cache: "no-store", }); return response.ok ? ((await response.json()) as HelpGuide) : { pages: [], admin: false }; } /** 한 기능 절 — 자리 이름 `B05/00_설명서` */ export function renderHelpItem(page: HelpPage, item: HelpItem, admin: boolean): string { const ctx = { page: page.page, images: page.images, admin }; return renderHelpMarkdown(item.text, ctx); } function anchorOf(page: string, key?: string): string { return key ? `${page}/${key}` : page; } /** 처음 자리 — 보이는 주제(제 페이지 먼저 · 없으면 모든 페이지) → 지금 페이지 → 맨 앞 */ function firstAnchor(pages: HelpPage[]): string { const page = currentPage(); const topics = visibleTopics().reverse(); const own = pages.find((p) => p.page === page); for (const scope of own ? [[own], pages] : [pages]) for (const key of topics) { const hit = scope.find((p) => p.items.some((item) => item.key === key)); if (hit) return anchorOf(hit.page, key); } return own ? page : (pages[0]?.page ?? ""); } async function openHelp(): Promise { const guide = await fetchGuide(); const pages = GROUPS.flatMap((g) => guide.pages.filter((p) => p.page.startsWith(g.prefix))); const start = firstAnchor(pages); let flush = async (): Promise => true; openModal({ title: TEXT.title, closeLabel: TEXT.close, dialogClass: "ui-help", wide: true, beforeClose: () => flush(), mount: (body) => { const article = el("article", { className: "ui-help__text" }); const toc = el("nav", { className: "ui-help__toc" }); const tocPage = new Map(); const goTo = (anchor: string): void => { const page = anchor.split("/")[0]; const target = article.querySelector( `[data-help-anchor="${CSS.escape(anchor)}"]`, ); target?.scrollIntoView({ block: "start" }); tocPage.forEach((box, key) => box.classList.toggle("is-open", key === page)); toc .querySelectorAll("[data-help-goto]") .forEach((b) => b.classList.toggle("is-active", b.dataset.helpGoto === anchor)); }; const link = (text: string, anchor: string, className: string): HTMLButtonElement => { const button = el("button", { className, text, attrs: { type: "button" } }); button.dataset.helpGoto = anchor; button.addEventListener("click", () => goTo(anchor)); return button; }; for (const group of GROUPS) { const own = pages.filter((p) => p.page.startsWith(group.prefix)); if (!own.length) continue; toc.append(el("p", { className: "ui-help__group", text: group.label })); for (const page of own) { const subs = page.items .filter((item) => item.key !== "00_설명서") .map((item) => link(item.title, anchorOf(page.page, item.key), "ui-help__link--sub")); const box = el("div", { className: "ui-help__toc-page", children: [ link(page.title, page.page, "ui-help__link"), el("div", { className: "ui-help__subs", children: subs }), ], }); tocPage.set(page.page, box); toc.append(box); } } for (const page of pages) { const section = el("section", { className: "ui-help__page" }); section.dataset.helpAnchor = page.page; for (const item of page.items) { const part = el("section", { className: "ui-help__item" }); part.dataset.helpAnchor = anchorOf(page.page, item.key); part.dataset.page = page.page; part.dataset.key = item.key; part.innerHTML = renderHelpItem(page, item, guide.admin); section.append(part); } article.append(section); } if (!pages.length) article.append(el("p", { text: TEXT.empty })); article.addEventListener("click", (ev) => { const goto = (ev.target as HTMLElement).closest("a.ui-help-goto"); if (!goto) return; ev.preventDefault(); goTo(goto.dataset.helpGoto ?? ""); }); if (guide.admin) flush = attachHelpEditing(article, pages, (page, item) => renderHelpItem(page, item, true), ).flush; body.append(el("div", { className: "ui-help__grid", children: [toc, article] })); // 모달이 붙은 뒤 이동 — mount 는 붙기 전 requestAnimationFrame(() => goTo(start)); }, }); } 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(): { index: number; last: number } { const route = location.hash.replace(/^#\/?/, "").split("?")[0]; const group = STEP_NAV_GROUPS.find((routes) => routes.includes(route)) ?? []; return { index: group.indexOf(route), last: group.length - 1 }; } /** 아래 단추 묶음에 [?] 를 한 번 — `[data-help-row]` 가 있으면 그 줄 · 없으면 묶음 자신의 맨 앞 */ export function placeHelpButton(actions: HTMLElement): void { if (actions.querySelector(".ui-help-btn")) return; const row = actions.querySelector("[data-help-row]") ?? actions; if (stepNavIndex().index < 0) return void row.prepend(createHelpButton()); const prev = createStepNavButton(-1); const next = createStepNavButton(1); // 첫 단계(B05) [<] · 마지막 단계(B09) [>] 흐림 — B05 ↔ B06 은 페이지를 다시 안 세우므로 주소마다 다시 맞춤 const sync = (): void => { const { index, last } = stepNavIndex(); prev.disabled = index <= 0; next.disabled = index === last; }; const onHash = (): void => { if (prev.isConnected) sync(); else window.removeEventListener("hashchange", onHash); // 화면에서 빠짐 — 풂 }; sync(); window.addEventListener("hashchange", onHash); row.prepend(createHelpButton(), prev, next); }