Replace unsupported window prompt/confirm/alert with an in-app modal dialog
This commit is contained in:
parent
1d705ac607
commit
17a895dffe
5 changed files with 218 additions and 22 deletions
|
|
@ -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
87
src/renderer/dialog.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue