Files
Aislo/ui_template/ui_template_help.ts
T
eomsangdonandClaude Opus 5.5 73bdb9bc5b feat(가이드): 538-3 글 더블클릭 바로 고침 — 시스템 관리자만 · 판 409 · 원자 쓰기
- 덩어리(제목 · 문단 · 목록 · 표 · 주의 상자 · 그림 줄) 더블클릭 → 그 원문 줄 textarea(높이 자동)
  · 바깥 누름 · Ctrl+Enter 저장(안 바뀌면 요청 없음) · Esc 취소 · 한글 조합 중 Enter · Esc 무시
  · 고침 열린 채 모달 닫으면 저장 · 저장 뒤 그 항목만 다시 그림
- PUT /api/help/{page}/{key} {version, start, end, text} — require_system_admin(403) · 판 다르면 409 ·
  그 줄만 갈아 임시 파일 → os.replace · 줄바꿈(CRLF) · BOM 그대로 · 409 면 다시 불러오고 내 글은 절 머리에 보여 둠
- 모달 Esc: 몸이 막은 Esc · 한글 조합 중 Esc 는 안 닫음
- 시험: 줄 바꿈 · 빈 글 지움 · CRLF/BOM · 409 · 403 · 나쁜 범위/항목 · 원자 쓰기 실패 때 원본 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 08:53:34 +09:00

373 lines
15 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.
/* =============================================================================
* 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<string, string>;
}
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<readonly string[]> = [
[
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, "<strong>$1</strong>")
.replace(/`(.+?)`/g, "<code>$1</code>");
return links
? html.replace(GOTO_RE, '<a href="#" class="ui-help-goto" data-help-goto="$2">$1</a>')
: html;
}
/** 페이지를 넘긴 때만 — 덩어리 원문 줄(`data-src`) · 그림 · 주의/참고 상자 · 가이드 안 링크 */
export interface HelpRenderContext {
page: string;
images: Record<string, string>;
admin: boolean;
}
function figure(alt: string, name: string, ctx: HelpRenderContext): string {
const version = ctx.images[name];
const img = version
? `<img src="${API_BASE_URL}/help/${ctx.page}/img/${name}?v=${version}" alt="${escapeHtml(alt)}" loading="lazy">`
: `<div class="ui-help-figure__empty">${ctx.admin ? TEXT.imgMissing : TEXT.imgPending}</div>`;
const caption = alt ? `<figcaption>${inline(alt)}</figcaption>` : "";
return `<figure class="ui-help-figure" data-img="${name}">${img}${caption}</figure>`;
}
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 `<aside class="ui-help-note ui-help-note--${kind}"${src}><strong>${head?.[1] ?? "참고"}</strong> ${body.map((line) => inline(line, true)).join("<br>")}</aside>`;
}
/** 메모장 글에 쓸 만큼만 — 제목(#) · 목록(- · 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(`<p${src(start, end)}>${para.map((l) => inline(l, !!ctx)).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 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(
`<div class="ui-markdown-table"${src(from, index)}><table><thead><tr>${headers.map((cell) => `<th>${inline(cell, !!ctx)}</th>`).join("")}</tr></thead><tbody>${rows.map((row) => `<tr>${headers.map((_, cell) => `<td>${inline(row[cell] ?? "", !!ctx)}</td>`).join("")}</tr>`).join("")}</tbody></table></div>`,
);
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("<figure", `<figure${src(index, index)}`));
} else if (head) {
flush();
const level = head[1].length + 1;
out.push(`<h${level}${src(index, index)}>${inline(head[2], !!ctx)}</h${level}>`);
} 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(`<li>${inline(item[2], !!ctx)}</li>`);
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<HTMLElement>("[data-help-topic]")]
.filter((node) => node.offsetParent !== null)
.map((node) => node.dataset.helpTopic ?? "");
}
async function fetchGuide(): Promise<HelpGuide> {
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<void> {
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<boolean> => 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<string, HTMLElement>();
const goTo = (anchor: string): void => {
const page = anchor.split("/")[0];
const target = article.querySelector<HTMLElement>(
`[data-help-anchor="${CSS.escape(anchor)}"]`,
);
target?.scrollIntoView({ block: "start" });
tocPage.forEach((box, key) => box.classList.toggle("is-open", key === page));
toc
.querySelectorAll<HTMLElement>("[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<HTMLElement>("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<HTMLElement>("[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);
}