/* ============================================================================= * ui_template_help_edit.ts * [?] 사용자 가이드 바로 고침(538-3) — 시스템 관리자만(서버도 시스템 관리자만 받음). * 덩어리(제목 · 문단 · 목록 · 표 · 주의 상자 · 그림 줄) 더블클릭 → 그 덩어리 원문 마크다운 textarea 로 바꿔 끼움. * 바깥 누름 · Ctrl+Enter = 저장(안 바뀌었으면 요청 안 함) · Esc = 취소 · 한글 조합 중 Enter · Esc 무시 · * 고침이 열린 채 모달을 닫으면 저장(`flush`). * 저장 = `PUT /api/help/{page}/{key}` {version, start, end, text} → 그 항목만 다시 그림 · * 409 = 「다른 관리자가 먼저 고침」 → 다시 불러옴 + 내 글은 그 절 머리에 보여 둠. * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { el, showToast } from "./ui_template_elements"; import type { HelpGuide, HelpItem, HelpPage } from "./ui_template_help"; const TEXT = { conflict: "다른 관리자가 먼저 고침 — 다시 불러옴", failed: "저장 못 함 — 다시 불러옴", mine: "내가 쓴 글 — 저장 안 됨 · 복사해 다시 넣음", }; type Render = (page: HelpPage, item: HelpItem) => string; export function attachHelpEditing( article: HTMLElement, pages: HelpPage[], render: Render, ): { flush: () => Promise } { let open: (() => Promise) | null = null; let pending: Promise = Promise.resolve(); article.classList.add("is-editable"); const find = (section: HTMLElement): { page: HelpPage; item: HelpItem } | null => { const page = pages.find((p) => p.page === section.dataset.page); const item = page?.items.find((i) => i.key === section.dataset.key); return page && item ? { page, item } : null; }; /** 서버의 지금 글로 그 항목 · 그림 판을 바꿔 둠 */ const reload = async (page: HelpPage, item: HelpItem): Promise => { const response = await fetch(`${API_BASE_URL}/help`, { credentials: "include", cache: "no-store", }); if (!response.ok) return; const fresh = ((await response.json()) as HelpGuide).pages.find((p) => p.page === page.page); const freshItem = fresh?.items.find((i) => i.key === item.key); if (fresh) page.images = fresh.images; if (freshItem) Object.assign(item, freshItem); }; const keepMine = (section: HTMLElement, text: string): void => { const area = el("textarea", { className: "ui-help__edit", attrs: { readonly: "" } }); area.value = text; section.prepend(el("div", { className: "ui-help__mine", children: [TEXT.mine, area] })); }; const edit = (section: HTMLElement, block: HTMLElement): void => { const found = find(section); if (!found) return; const { page, item } = found; const [start, end] = (block.dataset.src ?? "").split("-").map(Number); const before = item.text .split(/\r?\n/) .slice(start, end + 1) .join("\n"); const area = el("textarea", { className: "ui-help__edit" }); area.value = before; const fit = (): void => { area.style.height = "auto"; area.style.height = `${area.scrollHeight + 2}px`; }; let done = false; const finish = async (keep: boolean): Promise => { if (done) return; done = true; open = null; const text = area.value; if (!keep || text === before) return void area.replaceWith(block); area.disabled = true; const response = await fetch( `${API_BASE_URL}/help/${page.page}/${encodeURIComponent(item.key)}`, { method: "PUT", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ version: item.version, start, end, text }), }, ).catch(() => null); if (response?.ok) { Object.assign(item, ((await response.json()) as { item: HelpItem }).item); section.innerHTML = render(page, item); return; } showToast(response?.status === 409 ? TEXT.conflict : TEXT.failed, "warning"); await reload(page, item).catch(() => undefined); section.innerHTML = render(page, item); keepMine(section, text); }; area.addEventListener("input", fit); area.addEventListener("keydown", (ev) => { if (ev.isComposing || ev.keyCode === 229) return; if (ev.key === "Escape") { ev.preventDefault(); // 모달 Esc 닫기도 막음 ev.stopPropagation(); void finish(false); } else if (ev.key === "Enter" && (ev.ctrlKey || ev.metaKey)) { ev.preventDefault(); pending = finish(true); } }); area.addEventListener("blur", () => (pending = finish(true))); open = () => (pending = finish(true)); block.replaceWith(area); fit(); area.focus(); }; article.addEventListener("dblclick", (ev) => { const target = ev.target as HTMLElement; const section = target.closest(".ui-help__item"); if (!section || open || target.closest("textarea, .ui-help__mine")) return; const block = target.closest("[data-src]"); if (!block || !section.contains(block)) return; ev.preventDefault(); window.getSelection()?.removeAllRanges(); edit(section, block); }); return { flush: async () => { if (open) await open(); await pending; return true; }, }; }