diff --git a/src/main/index.ts b/src/main/index.ts index 8d31afc..faa86ec 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -10,7 +10,7 @@ import { loadBook, Recent, } from "./project.js"; -import { listRecents, addRecent } from "./recents.js"; +import { listRecents, addRecent, removeRecent } from "./recents.js"; import { buildMenu } from "./menu.js"; import { createChapter, @@ -173,24 +173,59 @@ async function openBookAt(p: string): Promise { } } -ipcMain.handle("folio:newBook", async () => { - const { canceled, filePaths } = await showOpenDialog({ - properties: ["createDirectory", "openDirectory"], - title: "Choose where to save your new book", - }); - if (canceled || !filePaths.length) return { canceled: true } as const; - const p = filePaths[0]; - if (hasBook(p)) return { error: "A Folio book already exists at this location." }; - const title = path.basename(p) || "Untitled"; - const meta = createBook(p, title); - if (process.env.FOLIO_SELF_TEST !== "1") { - addRecent({ path: p, title: meta.title, lastOpened: new Date().toISOString() }); +function sanitizeName(name: string): string { + return name + .replace(/[\\/:*?"<>|\x00-\x1f\x7f]/g, "") + .replace(/\s+/g, " ") + .trim() + .slice(0, 100); +} + +// Create a new book as a dedicated folder / inside the user's Documents +// directory, then open it. The folder name is the sanitized book title; if a +// folder with that name already exists we append a numeric suffix. +ipcMain.handle("folio:newBookNamed", async (_e, name: string) => { + const title = (name || "").trim(); + if (!title) return { error: "Please provide a book name." }; + const base = app.getPath("documents"); + const folder = sanitizeName(title) || "Untitled"; + let target = path.join(base, folder); + let n = 2; + while (hasBook(target) || fs.existsSync(target)) { + target = path.join(base, `${folder} ${n}`); + n++; } - bookPath = p; + const meta = createBook(target, title); + if (process.env.FOLIO_SELF_TEST !== "1") { + addRecent({ path: target, title: meta.title, lastOpened: new Date().toISOString() }); + } + bookPath = target; mainWindow?.setTitle(`${meta.title} — Folio`); refreshMenu(); - getWindow()?.webContents.send("folio:bookOpened", { path: p, title: meta.title }); - return { path: p, title: meta.title }; + getWindow()?.webContents.send("folio:bookOpened", { path: target, title: meta.title }); + return { path: target, title: meta.title }; +}); + +ipcMain.handle("folio:deleteBook", async (_e, p: string) => { + try { + if (!hasBook(p)) return { error: "Not a Folio book." }; + // Move to the system trash rather than permanently erasing, so an + // accidental delete can be undone from the desktop trash. + try { + await shell.trashItem(p); + } catch { + // Fall back to a permanent delete only when the system trash is + // unavailable (e.g. headless environments). On a normal desktop the + // book is moved to the trash and remains recoverable. + fs.rmSync(p, { recursive: true, force: true }); + } + removeRecent(p); + if (bookPath === p) bookPath = null; + refreshMenu(); + return { ok: true }; + } catch (e) { + return { error: (e as Error).message }; + } }); ipcMain.handle("folio:openBook", async () => { diff --git a/src/main/markdown-to-native.ts b/src/main/markdown-to-native.ts index b6f0273..adda769 100644 --- a/src/main/markdown-to-native.ts +++ b/src/main/markdown-to-native.ts @@ -221,7 +221,9 @@ function mergeText(nodes: FolioNode[]): FolioNode[] { out.push(n); } } - return out; + // ProseMirror forbids text nodes with empty content, so drop any that were + // produced (e.g. an emphasis marker wrapping nothing). + return out.filter((n) => !(n.type === "text" && !(n.text ?? "").length)); } function walkInline(children: Token[] | null, state: WalkState): FolioNode[] { @@ -234,7 +236,7 @@ function walkInline(children: Token[] | null, state: WalkState): FolioNode[] { for (const t of children) { switch (t.type) { case "text": - out.push({ type: "text", text: t.content, marks: topMarks() }); + if (t.content) out.push({ type: "text", text: t.content, marks: topMarks() }); break; case "softbreak": out.push({ type: "text", text: " ", marks: topMarks() }); @@ -243,11 +245,12 @@ function walkInline(children: Token[] | null, state: WalkState): FolioNode[] { out.push({ type: "hard_break" }); break; case "code_inline": - out.push({ - type: "text", - text: t.content, - marks: [...topMarks(), { type: "code" }], - }); + if (t.content) + out.push({ + type: "text", + text: t.content, + marks: [...topMarks(), { type: "code" }], + }); break; case "strong_open": markStack.push({ type: "bold" }); diff --git a/src/main/preload.ts b/src/main/preload.ts index 5303d68..08c8f19 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -2,7 +2,8 @@ import { contextBridge, ipcRenderer } from "electron"; contextBridge.exposeInMainWorld("folio", { // book / project - newBook: () => ipcRenderer.invoke("folio:newBook"), + newBookNamed: (name: string) => ipcRenderer.invoke("folio:newBookNamed", name), + deleteBook: (p: string) => ipcRenderer.invoke("folio:deleteBook", p), openBook: () => ipcRenderer.invoke("folio:openBook"), openBookPath: (p: string) => ipcRenderer.invoke("folio:openPath", p), revealInFolder: (p: string) => ipcRenderer.invoke("folio:reveal", p), diff --git a/src/main/project.ts b/src/main/project.ts index da00a68..afb9718 100644 --- a/src/main/project.ts +++ b/src/main/project.ts @@ -135,3 +135,10 @@ export function addRecent(filePath: string, recent: Recent): Recent[] { fs.writeFileSync(filePath, JSON.stringify(recents, null, 2), "utf-8"); return recents; } + +export function removeRecent(filePath: string, recentPath: string): Recent[] { + const recents = listRecents(filePath).filter((r) => r.path !== recentPath); + ensureParent(filePath); + fs.writeFileSync(filePath, JSON.stringify(recents, null, 2), "utf-8"); + return recents; +} diff --git a/src/main/recents.ts b/src/main/recents.ts index 0b2d56e..be5b1ee 100644 --- a/src/main/recents.ts +++ b/src/main/recents.ts @@ -5,6 +5,7 @@ import { hasBook, listRecents as _listRecents, addRecent as _addRecent, + removeRecent as _removeRecent, } from "./project.js"; import type { Recent } from "./project.js"; @@ -35,3 +36,7 @@ export function listRecents(): Recent[] { export function addRecent(recent: Recent): Recent[] { return _addRecent(recentsFilePath(), recent); } + +export function removeRecent(recentPath: string): Recent[] { + return _removeRecent(recentsFilePath(), recentPath); +} diff --git a/src/renderer/book.ts b/src/renderer/book.ts index b8f6e32..858e98f 100644 --- a/src/renderer/book.ts +++ b/src/renderer/book.ts @@ -18,7 +18,8 @@ import { } from "./native-assembly.js"; export type FolioAPI = { - newBook: () => Promise; + newBookNamed: (name: string) => Promise; + deleteBook: (p: string) => Promise; openBook: () => Promise; openBookPath: (p: string) => Promise; revealInFolder: (p: string) => Promise; @@ -749,9 +750,10 @@ export function initBook(api: FolioAPI) { } setChapterButtons(!!meta); renderChapterList(); - if (fullBookView.classList.contains("hidden")) { - if (ordered.length) showPlaceholder(); - else showPlaceholder(); + if (ordered.length) { + await selectChapter(ordered[0].id); + } else { + showPlaceholder(); } await refreshFullBook(); } @@ -1342,7 +1344,11 @@ export function initBook(api: FolioAPI) { } async function startNewBook() { - const res = await api.newBook(); + const name = await promptInput("New book", "My Book"); + if (name === null) return; + const trimmed = name.trim(); + if (!trimmed) return; + const res = await api.newBookNamed(trimmed); await handleResult(res); await renderRecents(); } @@ -1371,12 +1377,37 @@ export function initBook(api: FolioAPI) { } for (const r of recents as Recent[]) { const li = document.createElement("li"); - li.textContent = r.title || r.path; - li.title = r.path; - li.addEventListener("mousedown", (e) => { + li.className = "recent-item"; + const label = document.createElement("span"); + label.className = "recent-title"; + label.textContent = r.title || r.path; + label.title = r.path; + label.addEventListener("mousedown", (e) => { e.preventDefault(); void openPath(r.path); }); + const del = document.createElement("button"); + del.className = "recent-delete icon-button"; + del.type = "button"; + del.title = "Delete book"; + del.setAttribute("aria-label", "Delete book"); + del.textContent = "×"; + del.addEventListener("click", async (e) => { + e.stopPropagation(); + const ok = await confirmMessage( + "Delete book", + `Delete "${r.title || r.path}"? This permanently removes the book folder.` + ); + if (!ok) return; + const res = await api.deleteBook(r.path); + if (res && "error" in res) { + await alertMessage("Could not delete", (res as { error: string }).error); + return; + } + await renderRecents(); + }); + li.appendChild(label); + li.appendChild(del); recentList.appendChild(li); } } diff --git a/src/renderer/index.css b/src/renderer/index.css index 1ba6fa1..bc8716e 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -401,21 +401,51 @@ html, body { overflow-y: auto; } -.recent-list li { +.recent-list li.recent-item { + display: flex; + align-items: center; + gap: 6px; padding: 6px 8px; border-radius: var(--radius); cursor: pointer; font-size: 0.85rem; color: var(--color-text); transition: background 0.12s ease, color 0.12s ease; +} + +.recent-list li.recent-item:hover { + background: var(--color-accent); + color: var(--color-on-accent); +} + +.recent-item .recent-title { + flex: 1 1 auto; + min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + user-select: none; } -.recent-list li:hover { - background: var(--color-accent); - color: var(--color-on-accent); +.recent-item .recent-delete { + flex: 0 0 auto; + width: 20px; + height: 20px; + line-height: 1; + font-size: 1rem; + padding: 0; + border: none; + background: transparent; + color: inherit; + cursor: pointer; + border-radius: var(--radius); + opacity: 0.6; +} + +.recent-item .recent-delete:hover { + opacity: 1; + background: rgba(220, 53, 69, 0.18); + color: #ff6b6b; } .muted { diff --git a/src/renderer/tiptap-editor.ts b/src/renderer/tiptap-editor.ts index 1da3687..a897984 100644 --- a/src/renderer/tiptap-editor.ts +++ b/src/renderer/tiptap-editor.ts @@ -350,15 +350,34 @@ export function createTiptapEditor( return editor; } +// ProseMirror rejects text nodes with empty content, which makes +// editor.commands.setContent silently fail (the editor ends up empty) for any +// doc that contains one. Strip them before loading so chapter content always +// renders. +function dropEmptyTextNodes(node: FolioNode | null): FolioNode | null { + if (!node || typeof node !== "object") return node; + if (node.type === "text") { + if (!(node.text ?? "").length) return null; + return node; + } + if (Array.isArray(node.content)) { + node.content = node.content + .map(dropEmptyTextNodes) + .filter(Boolean) as FolioNode[]; + } + return node; +} + export function setEditorDoc(editor: Editor, doc: FolioNode): void { // An empty document (no top-level blocks) leaves ProseMirror with nowhere to // place the cursor, so brand-new/empty chapters can't be typed into or pasted // until the user forces a block in. Seed one paragraph so the caret always // has a home and paste works immediately. - const content = (doc as { content?: unknown[] } | null)?.content; + const clean = dropEmptyTextNodes(doc) as FolioNode; + const content = (clean as { content?: unknown[] } | null)?.content; const safe = content && Array.isArray(content) && content.length > 0 - ? doc + ? clean : ({ type: "doc", content: [{ type: "paragraph" }] } as unknown as FolioNode); editor.commands.setContent(safe as unknown as Record, false); } diff --git a/tests/selftest-scenario.js b/tests/selftest-scenario.js index 76cdbf2..dffc33b 100644 --- a/tests/selftest-scenario.js +++ b/tests/selftest-scenario.js @@ -92,10 +92,10 @@ // Manual save: typing marks the chapter Unsaved; the Save button (or // Ctrl/Cmd+S) is required to persist. No auto-save should occur. - const ed = window.__folioEditor; - if (ed) { - ed.commands.focus("end"); - ed.commands.insertContent(" manual-save-check"); + const edSave = window.__folioEditor; + if (edSave) { + edSave.commands.focus("end"); + edSave.commands.insertContent(" manual-save-check"); await sleep(150); const unsaved = document.getElementById("saveState"); step("afterTypeUnsaved", unsaved ? unsaved.textContent : "n/a"); @@ -170,6 +170,18 @@ } step("exports", ex); + // New book auto-creates a folder inside Documents; then delete it. + const nb = await api.newBookNamed("Folio Selftest Temp"); + step("newBookNamed", { + ok: !!(nb && nb.path && nb.title), + path: nb && nb.path, + title: nb && nb.title, + }); + if (nb && nb.path) { + const del = await api.deleteBook(nb.path); + step("deleteBook", del && del.ok ? "ok" : del && del.error); + } + out.ok = true; } catch (e) { out.error = String((e && e.stack) || e);