Files
Aislo/ui_template/ui_template_help_edit.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

137 lines
5.4 KiB
TypeScript

/* =============================================================================
* 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<boolean> } {
let open: (() => Promise<void>) | null = null;
let pending: Promise<void> = 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<void> => {
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<void> => {
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<HTMLElement>(".ui-help__item");
if (!section || open || target.closest("textarea, .ui-help__mine")) return;
const block = target.closest<HTMLElement>("[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;
},
};
}