Replace unsupported window prompt/confirm/alert with an in-app modal dialog

This commit is contained in:
avi 2026-08-17 11:15:14 -05:00
commit 17a895dffe
5 changed files with 218 additions and 22 deletions

View file

@ -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<any>;
@ -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();
}

87
src/renderer/dialog.ts Normal file
View file

@ -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<string | null> {
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<string | null>((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<string | null> {
return open("prompt", title, "", value ?? "");
}
export function confirmMessage(title: string, body?: string): Promise<boolean> {
return open("confirm", title, body ?? "", "").then((v) => v !== null);
}
export function alertMessage(title: string, body?: string): Promise<void> {
return open("alert", title, body ?? "", "").then(() => undefined);
}

View file

@ -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;
}

View file

@ -65,6 +65,17 @@
</div>
</main>
</div>
<div class="modal-backdrop hidden" id="modalBackdrop">
<div class="modal" role="dialog" aria-modal="true">
<h3 class="modal-title" id="modalTitle"></h3>
<p class="modal-msg hidden" id="modalMsg"></p>
<input type="text" class="modal-input" id="modalInput" autocomplete="off" />
<div class="modal-actions">
<button class="button" id="modalCancel">Cancel</button>
<button class="button button-primary" id="modalOk">OK</button>
</div>
</div>
</div>
<script src="./renderer.js"></script>
</body>
</html>