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 type { BookMeta } from "../main/project.js";
|
||||||
import markdownIt from "markdown-it";
|
import markdownIt from "markdown-it";
|
||||||
|
import { alertMessage, confirmMessage, promptInput } from "./dialog.js";
|
||||||
|
|
||||||
export type FolioAPI = {
|
export type FolioAPI = {
|
||||||
newBook: () => Promise<any>;
|
newBook: () => Promise<any>;
|
||||||
|
|
@ -77,6 +78,11 @@ export function initBook(api: FolioAPI) {
|
||||||
const addChapterBtn = $("addChapterBtn") as HTMLButtonElement;
|
const addChapterBtn = $("addChapterBtn") as HTMLButtonElement;
|
||||||
const projectBar = $("projectBar");
|
const projectBar = $("projectBar");
|
||||||
|
|
||||||
|
function setChapterButtons(enabled: boolean) {
|
||||||
|
addChapterBtn.disabled = !enabled;
|
||||||
|
newChapterBtn.disabled = !enabled;
|
||||||
|
}
|
||||||
|
|
||||||
const md = markdownIt({ html: false, linkify: true, typographer: true });
|
const md = markdownIt({ html: false, linkify: true, typographer: true });
|
||||||
let dirty = false;
|
let dirty = false;
|
||||||
let loadedContent = "";
|
let loadedContent = "";
|
||||||
|
|
@ -97,6 +103,7 @@ export function initBook(api: FolioAPI) {
|
||||||
meta = null;
|
meta = null;
|
||||||
ordered = [];
|
ordered = [];
|
||||||
selectedId = null;
|
selectedId = null;
|
||||||
|
setChapterButtons(false);
|
||||||
renderChapterList();
|
renderChapterList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,7 +126,7 @@ export function initBook(api: FolioAPI) {
|
||||||
async function loadMeta() {
|
async function loadMeta() {
|
||||||
const res = await api.getBookMeta();
|
const res = await api.getBookMeta();
|
||||||
if (res && typeof res === "object" && "error" in res) {
|
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;
|
meta = null;
|
||||||
} else {
|
} else {
|
||||||
meta = res as BookMeta;
|
meta = res as BookMeta;
|
||||||
|
|
@ -127,6 +134,7 @@ export function initBook(api: FolioAPI) {
|
||||||
.map((id) => meta!.chapters[id])
|
.map((id) => meta!.chapters[id])
|
||||||
.filter(Boolean) as ChapterEntry[];
|
.filter(Boolean) as ChapterEntry[];
|
||||||
}
|
}
|
||||||
|
setChapterButtons(!!meta);
|
||||||
renderChapterList();
|
renderChapterList();
|
||||||
if (ordered.length) showPlaceholder();
|
if (ordered.length) showPlaceholder();
|
||||||
else showPlaceholder();
|
else showPlaceholder();
|
||||||
|
|
@ -300,7 +308,7 @@ export function initBook(api: FolioAPI) {
|
||||||
async function exportMarkdown() {
|
async function exportMarkdown() {
|
||||||
if (!currentBook) return;
|
if (!currentBook) return;
|
||||||
const res = await api.exportMarkdown();
|
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 && "filePath" in res) status(`Exported to ${basename(res.filePath)}`);
|
||||||
else if (res && "canceled" in res) status("Export canceled");
|
else if (res && "canceled" in res) status("Export canceled");
|
||||||
}
|
}
|
||||||
|
|
@ -308,7 +316,7 @@ export function initBook(api: FolioAPI) {
|
||||||
async function exportZip() {
|
async function exportZip() {
|
||||||
if (!currentBook) return;
|
if (!currentBook) return;
|
||||||
const res = await api.exportZip();
|
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 && "filePath" in res) status(`Bundle saved to ${basename(res.filePath)}`);
|
||||||
else if (res && "canceled" in res) status("Export canceled");
|
else if (res && "canceled" in res) status("Export canceled");
|
||||||
}
|
}
|
||||||
|
|
@ -326,10 +334,10 @@ export function initBook(api: FolioAPI) {
|
||||||
async function create() {
|
async function create() {
|
||||||
if (!meta) return;
|
if (!meta) return;
|
||||||
const count = meta.chapterOrder.length + 1;
|
const count = meta.chapterOrder.length + 1;
|
||||||
const title = prompt("Chapter title", `Chapter ${count}`);
|
const title = await promptInput("New chapter", `Chapter ${count}`);
|
||||||
if (!title || !title.trim()) return;
|
if (!title) return;
|
||||||
const res = await api.createChapter(title.trim());
|
const res = await api.createChapter(title);
|
||||||
if (res && "error" in res) alert((res as { error: string }).error);
|
if (res && "error" in res) await alertMessage("Could not create chapter", (res as { error: string }).error);
|
||||||
else {
|
else {
|
||||||
await refreshMeta();
|
await refreshMeta();
|
||||||
if (res && "id" in res) void selectChapter((res as ChapterEntry).id);
|
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) {
|
async function rename(id: string) {
|
||||||
if (!meta || !meta.chapters[id]) return;
|
if (!meta || !meta.chapters[id]) return;
|
||||||
const entry = meta.chapters[id];
|
const entry = meta.chapters[id];
|
||||||
const title = prompt("Rename chapter", entry.title);
|
const title = await promptInput("Rename chapter", entry.title);
|
||||||
if (!title || !title.trim()) return;
|
if (!title) return;
|
||||||
const res = await api.renameChapter(id, title.trim());
|
const res = await api.renameChapter(id, title);
|
||||||
if (res && "error" in res) alert((res as { error: string }).error);
|
if (res && "error" in res) await alertMessage("Could not rename chapter", (res as { error: string }).error);
|
||||||
else {
|
else {
|
||||||
await refreshMeta();
|
await refreshMeta();
|
||||||
if (selectedId === id && meta && meta.chapters[id]) {
|
if (selectedId === id && meta && meta.chapters[id]) {
|
||||||
|
|
@ -354,9 +362,10 @@ export function initBook(api: FolioAPI) {
|
||||||
async function del(id: string) {
|
async function del(id: string) {
|
||||||
if (!meta || !meta.chapters[id]) return;
|
if (!meta || !meta.chapters[id]) return;
|
||||||
const entry = meta.chapters[id];
|
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);
|
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 {
|
else {
|
||||||
if (selectedId === id) { selectedId = null; showPlaceholder(); }
|
if (selectedId === id) { selectedId = null; showPlaceholder(); }
|
||||||
await refreshMeta();
|
await refreshMeta();
|
||||||
|
|
@ -366,10 +375,10 @@ export function initBook(api: FolioAPI) {
|
||||||
async function duplicate(id: string) {
|
async function duplicate(id: string) {
|
||||||
if (!meta || !meta.chapters[id]) return;
|
if (!meta || !meta.chapters[id]) return;
|
||||||
const entry = meta.chapters[id];
|
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;
|
if (title === null) return;
|
||||||
const res = await api.duplicateChapter(id, title.trim() || undefined);
|
const res = await api.duplicateChapter(id, title || undefined);
|
||||||
if (res && "error" in res) alert((res as { error: string }).error);
|
if (res && "error" in res) await alertMessage("Could not duplicate chapter", (res as { error: string }).error);
|
||||||
else if (res && "id" in res) {
|
else if (res && "id" in res) {
|
||||||
await refreshMeta();
|
await refreshMeta();
|
||||||
void selectChapter((res as ChapterEntry).id);
|
void selectChapter((res as ChapterEntry).id);
|
||||||
|
|
@ -380,7 +389,7 @@ export function initBook(api: FolioAPI) {
|
||||||
if (!meta) return;
|
if (!meta) return;
|
||||||
meta.chapterOrder = ordered.map((c) => c.id);
|
meta.chapterOrder = ordered.map((c) => c.id);
|
||||||
const res = await api.reorderChapters(meta.chapterOrder);
|
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();
|
else renderChapterList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -404,28 +413,28 @@ export function initBook(api: FolioAPI) {
|
||||||
void persistOrder();
|
void persistOrder();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleResult(res: any) {
|
async function handleResult(res: any) {
|
||||||
if (!res) return;
|
if (!res) return;
|
||||||
if ("canceled" in 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 });
|
if ("path" in res) setCurrentBook({ path: res.path, title: res.title });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startNewBook() {
|
async function startNewBook() {
|
||||||
const res = await api.newBook();
|
const res = await api.newBook();
|
||||||
handleResult(res);
|
await handleResult(res);
|
||||||
await renderRecents();
|
await renderRecents();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startOpenBook() {
|
async function startOpenBook() {
|
||||||
const res = await api.openBook();
|
const res = await api.openBook();
|
||||||
handleResult(res);
|
await handleResult(res);
|
||||||
await renderRecents();
|
await renderRecents();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openPath(p: string) {
|
async function openPath(p: string) {
|
||||||
const res = await api.openBookPath(p);
|
const res = await api.openBookPath(p);
|
||||||
handleResult(res);
|
await handleResult(res);
|
||||||
await renderRecents();
|
await renderRecents();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -559,5 +568,6 @@ export function initBook(api: FolioAPI) {
|
||||||
api.onExportMarkdownRequested(() => void exportMarkdown());
|
api.onExportMarkdownRequested(() => void exportMarkdown());
|
||||||
api.onExportZipRequested(() => void exportZip());
|
api.onExportZipRequested(() => void exportZip());
|
||||||
|
|
||||||
|
setChapterButtons(false);
|
||||||
void renderRecents();
|
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);
|
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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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>
|
<script src="./renderer.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,19 @@ const BOOK_DIR = __BOOKDIR_VALUE__;
|
||||||
out.orderAfterKb = orderKb;
|
out.orderAfterKb = orderKb;
|
||||||
out.kbMoved = orderKb[0] === orderAfter[1] && orderKb[1] === orderAfter[0];
|
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;
|
out.all = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
out.error = String((e && e.message) || e);
|
out.error = String((e && e.message) || e);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue