diff --git a/src/renderer/book.ts b/src/renderer/book.ts index 393d6cf..713e175 100644 --- a/src/renderer/book.ts +++ b/src/renderer/book.ts @@ -1,5 +1,6 @@ import type { BookMeta } from "../main/project.js"; import markdownIt from "markdown-it"; +import { alertMessage, confirmMessage, promptInput } from "./dialog.js"; export type FolioAPI = { newBook: () => Promise; @@ -77,6 +78,11 @@ export function initBook(api: FolioAPI) { const addChapterBtn = $("addChapterBtn") as HTMLButtonElement; const projectBar = $("projectBar"); + function setChapterButtons(enabled: boolean) { + addChapterBtn.disabled = !enabled; + newChapterBtn.disabled = !enabled; + } + const md = markdownIt({ html: false, linkify: true, typographer: true }); let dirty = false; let loadedContent = ""; @@ -97,6 +103,7 @@ export function initBook(api: FolioAPI) { meta = null; ordered = []; selectedId = null; + setChapterButtons(false); renderChapterList(); } @@ -119,7 +126,7 @@ export function initBook(api: FolioAPI) { async function loadMeta() { const res = await api.getBookMeta(); if (res && typeof res === "object" && "error" in res) { - alert((res as { error: string }).error); + await alertMessage("Could not load book", (res as { error: string }).error); meta = null; } else { meta = res as BookMeta; @@ -127,6 +134,7 @@ export function initBook(api: FolioAPI) { .map((id) => meta!.chapters[id]) .filter(Boolean) as ChapterEntry[]; } + setChapterButtons(!!meta); renderChapterList(); if (ordered.length) showPlaceholder(); else showPlaceholder(); @@ -300,7 +308,7 @@ export function initBook(api: FolioAPI) { async function exportMarkdown() { if (!currentBook) return; const res = await api.exportMarkdown(); - if (res && "error" in res) alert((res as { error: string }).error); + if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error); else if (res && "filePath" in res) status(`Exported to ${basename(res.filePath)}`); else if (res && "canceled" in res) status("Export canceled"); } @@ -308,7 +316,7 @@ export function initBook(api: FolioAPI) { async function exportZip() { if (!currentBook) return; const res = await api.exportZip(); - if (res && "error" in res) alert((res as { error: string }).error); + if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error); else if (res && "filePath" in res) status(`Bundle saved to ${basename(res.filePath)}`); else if (res && "canceled" in res) status("Export canceled"); } @@ -326,10 +334,10 @@ export function initBook(api: FolioAPI) { async function create() { if (!meta) return; const count = meta.chapterOrder.length + 1; - const title = prompt("Chapter title", `Chapter ${count}`); - if (!title || !title.trim()) return; - const res = await api.createChapter(title.trim()); - if (res && "error" in res) alert((res as { error: string }).error); + const title = await promptInput("New chapter", `Chapter ${count}`); + if (!title) return; + const res = await api.createChapter(title); + if (res && "error" in res) await alertMessage("Could not create chapter", (res as { error: string }).error); else { await refreshMeta(); if (res && "id" in res) void selectChapter((res as ChapterEntry).id); @@ -339,10 +347,10 @@ export function initBook(api: FolioAPI) { async function rename(id: string) { if (!meta || !meta.chapters[id]) return; const entry = meta.chapters[id]; - const title = prompt("Rename chapter", entry.title); - if (!title || !title.trim()) return; - const res = await api.renameChapter(id, title.trim()); - if (res && "error" in res) alert((res as { error: string }).error); + const title = await promptInput("Rename chapter", entry.title); + if (!title) return; + const res = await api.renameChapter(id, title); + if (res && "error" in res) await alertMessage("Could not rename chapter", (res as { error: string }).error); else { await refreshMeta(); if (selectedId === id && meta && meta.chapters[id]) { @@ -354,9 +362,10 @@ export function initBook(api: FolioAPI) { async function del(id: string) { if (!meta || !meta.chapters[id]) return; const entry = meta.chapters[id]; - if (!confirm(`Delete "${entry.title}"? This cannot be undone.`)) return; + const confirmed = await confirmMessage(`Delete "${entry.title}"?`, "This cannot be undone."); + if (!confirmed) return; const res = await api.deleteChapter(id); - if (res && "error" in res) alert((res as { error: string }).error); + if (res && "error" in res) await alertMessage("Could not delete chapter", (res as { error: string }).error); else { if (selectedId === id) { selectedId = null; showPlaceholder(); } await refreshMeta(); @@ -366,10 +375,10 @@ export function initBook(api: FolioAPI) { async function duplicate(id: string) { if (!meta || !meta.chapters[id]) return; const entry = meta.chapters[id]; - const title = prompt("Duplicate as:", `${entry.title} (copy)`); + const title = await promptInput("Duplicate as", `${entry.title} (copy)`); if (title === null) return; - const res = await api.duplicateChapter(id, title.trim() || undefined); - if (res && "error" in res) alert((res as { error: string }).error); + const res = await api.duplicateChapter(id, title || undefined); + if (res && "error" in res) await alertMessage("Could not duplicate chapter", (res as { error: string }).error); else if (res && "id" in res) { await refreshMeta(); void selectChapter((res as ChapterEntry).id); @@ -380,7 +389,7 @@ export function initBook(api: FolioAPI) { if (!meta) return; meta.chapterOrder = ordered.map((c) => c.id); const res = await api.reorderChapters(meta.chapterOrder); - if (res && "error" in res) alert((res as { error: string }).error); + if (res && "error" in res) await alertMessage("Could not reorder chapters", (res as { error: string }).error); else renderChapterList(); } @@ -404,28 +413,28 @@ export function initBook(api: FolioAPI) { void persistOrder(); } - function handleResult(res: any) { + async function handleResult(res: any) { if (!res) return; if ("canceled" in res) return; - if ("error" in res) { alert((res as { error: string }).error); return; } + if ("error" in res) { await alertMessage("Error", (res as { error: string }).error); return; } if ("path" in res) setCurrentBook({ path: res.path, title: res.title }); } async function startNewBook() { const res = await api.newBook(); - handleResult(res); + await handleResult(res); await renderRecents(); } async function startOpenBook() { const res = await api.openBook(); - handleResult(res); + await handleResult(res); await renderRecents(); } async function openPath(p: string) { const res = await api.openBookPath(p); - handleResult(res); + await handleResult(res); await renderRecents(); } @@ -559,5 +568,6 @@ export function initBook(api: FolioAPI) { api.onExportMarkdownRequested(() => void exportMarkdown()); api.onExportZipRequested(() => void exportZip()); + setChapterButtons(false); void renderRecents(); } diff --git a/src/renderer/dialog.ts b/src/renderer/dialog.ts new file mode 100644 index 0000000..f3cb155 --- /dev/null +++ b/src/renderer/dialog.ts @@ -0,0 +1,87 @@ +// In-app modal dialog used in place of window.prompt/confirm/alert, +// which Electron does not support (prompt() throws). +type Mode = "prompt" | "confirm" | "alert"; + +function el(id: string): HTMLElement { + return document.getElementById(id) as HTMLElement; +} + +const backdrop = el("modalBackdrop"); +const titleEl = el("modalTitle"); +const msgEl = el("modalMsg"); +const inputEl = el("modalInput") as HTMLInputElement; +const okBtn = el("modalOk") as HTMLButtonElement; +const cancelBtn = el("modalCancel") as HTMLButtonElement; + +let currentMode: Mode = "prompt"; +let resolver: ((value: string | null) => void) | null = null; + +function open(mode: Mode, title: string, body: string, value: string): Promise { + currentMode = mode; + titleEl.textContent = title; + msgEl.textContent = body; + inputEl.value = value; + inputEl.classList.toggle("hidden", mode !== "prompt"); + msgEl.classList.toggle("hidden", mode === "prompt"); + cancelBtn.classList.toggle("hidden", mode === "alert"); + okBtn.textContent = mode === "confirm" ? "Delete" : "OK"; + backdrop.classList.remove("hidden"); + if (mode === "prompt") { + inputEl.focus(); + inputEl.select(); + } else { + okBtn.focus(); + } + return new Promise((res) => { + resolver = res; + }); +} + +function close(value: string | null): void { + backdrop.classList.add("hidden"); + if (resolver) { + const r = resolver; + resolver = null; + r(value); + } +} + +okBtn.addEventListener("click", () => { + if (currentMode === "prompt") close(inputEl.value.trim()); + else close(inputEl.value); +}); + +cancelBtn.addEventListener("click", () => close(null)); + +backdrop.addEventListener("mousedown", (e) => { + if (e.target === backdrop) close(null); +}); + +inputEl.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + close(inputEl.value.trim()); + } else if (e.key === "Escape") { + e.preventDefault(); + close(null); + } +}); + +document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && !backdrop.classList.contains("hidden")) { + e.preventDefault(); + close(null); + } +}); + +export function promptInput(title: string, value?: string): Promise { + return open("prompt", title, "", value ?? ""); +} + +export function confirmMessage(title: string, body?: string): Promise { + return open("confirm", title, body ?? "", "").then((v) => v !== null); +} + +export function alertMessage(title: string, body?: string): Promise { + return open("alert", title, body ?? "", "").then(() => undefined); +} \ No newline at end of file diff --git a/src/renderer/index.css b/src/renderer/index.css index 6ad764d..0cb1b55 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -549,3 +549,78 @@ html, body { color: var(--color-text); } + +/* ---- modal dialog ---- */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.modal { + background: var(--color-panel); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: var(--gap); + width: min(420px, calc(100vw - 48px)); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3); + display: flex; + flex-direction: column; + gap: 12px; +} + +.modal-title { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--color-text); +} + +.modal-msg { + margin: 0; + font-size: 0.9rem; + color: var(--color-muted); + white-space: pre-wrap; +} + +.modal-input { + width: 100%; + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 8px 10px; + font-size: 0.9rem; + font-family: var(--font-sans); + background: var(--color-bg); + color: var(--color-text); + outline: none; +} + +.modal-input:focus { + border-color: var(--color-accent); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.button-primary { + background: var(--color-accent); + border-color: var(--color-accent); + color: #fff; +} + +.button-primary:hover { + background: var(--color-accent-hover); + border-color: var(--color-accent-hover); +} + +.button:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/src/renderer/index.html b/src/renderer/index.html index f8713d7..35cb8eb 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -65,6 +65,17 @@ + diff --git a/tests/selftest-scenario.js b/tests/selftest-scenario.js index 550fa7f..77cf0d6 100644 --- a/tests/selftest-scenario.js +++ b/tests/selftest-scenario.js @@ -69,6 +69,19 @@ const BOOK_DIR = __BOOKDIR_VALUE__; out.orderAfterKb = orderKb; out.kbMoved = orderKb[0] === orderAfter[1] && orderKb[1] === orderAfter[0]; + // UI-driven chapter creation via the in-app modal (window.prompt is unsupported) + const countBefore = (await window.folio.getBookMeta()).chapterOrder.length; + document.getElementById("addChapterBtn").click(); + await new Promise((r) => setTimeout(r, 80)); + const modalInput = document.getElementById("modalInput"); + out.modalShown = !!modalInput && !document.getElementById("modalBackdrop").classList.contains("hidden"); + modalInput.value = "Via Modal"; + document.getElementById("modalOk").click(); + await new Promise((r) => setTimeout(r, 300)); + const metaAfterUi = await window.folio.getBookMeta(); + out.uiCreated = metaAfterUi.chapterOrder.length === countBefore + 1 && + Object.values(metaAfterUi.chapters).some((c) => c.title === "Via Modal"); + out.all = true; } catch (e) { out.error = String((e && e.message) || e);