- 덩어리(제목 · 문단 · 목록 · 표 · 주의 상자 · 그림 줄) 더블클릭 → 그 원문 줄 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>
373 lines
15 KiB
TypeScript
373 lines
15 KiB
TypeScript
/* =============================================================================
|
||
* 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);
|
||
}
|