Fix blank editor on book open and add home delete + auto-folder new book

- Auto-select the first chapter when a book opens so the editor is no
  longer left on the placeholder (was the main `all I see is the title`
  symptom).
- Stop markdownToNative from emitting text nodes with empty content and
  defensively strip them in setEditorDoc; ProseMirror rejects such nodes,
  which made editor.commands.setContent silently empty the whole editor.
- Home screen: delete a book (sent to trash, recoverable) and create a new
  book in a folder named after it under ~/Documents.
- Add self-test coverage for new book + delete.
This commit is contained in:
avi 2026-08-20 17:35:13 -05:00
commit 5ac7cdb593
9 changed files with 184 additions and 41 deletions

View file

@ -10,7 +10,7 @@ import {
loadBook, loadBook,
Recent, Recent,
} from "./project.js"; } from "./project.js";
import { listRecents, addRecent } from "./recents.js"; import { listRecents, addRecent, removeRecent } from "./recents.js";
import { buildMenu } from "./menu.js"; import { buildMenu } from "./menu.js";
import { import {
createChapter, createChapter,
@ -173,24 +173,59 @@ async function openBookAt(p: string): Promise<BookResult | ErrResult> {
} }
} }
ipcMain.handle("folio:newBook", async () => { function sanitizeName(name: string): string {
const { canceled, filePaths } = await showOpenDialog({ return name
properties: ["createDirectory", "openDirectory"], .replace(/[\\/:*?"<>|\x00-\x1f\x7f]/g, "")
title: "Choose where to save your new book", .replace(/\s+/g, " ")
}); .trim()
if (canceled || !filePaths.length) return { canceled: true } as const; .slice(0, 100);
const p = filePaths[0]; }
if (hasBook(p)) return { error: "A Folio book already exists at this location." };
const title = path.basename(p) || "Untitled"; // Create a new book as a dedicated folder <name>/ inside the user's Documents
const meta = createBook(p, title); // directory, then open it. The folder name is the sanitized book title; if a
if (process.env.FOLIO_SELF_TEST !== "1") { // folder with that name already exists we append a numeric suffix.
addRecent({ path: p, title: meta.title, lastOpened: new Date().toISOString() }); 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`); mainWindow?.setTitle(`${meta.title} — Folio`);
refreshMenu(); refreshMenu();
getWindow()?.webContents.send("folio:bookOpened", { path: p, title: meta.title }); getWindow()?.webContents.send("folio:bookOpened", { path: target, title: meta.title });
return { path: p, 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 () => { ipcMain.handle("folio:openBook", async () => {

View file

@ -221,7 +221,9 @@ function mergeText(nodes: FolioNode[]): FolioNode[] {
out.push(n); 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[] { 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) { for (const t of children) {
switch (t.type) { switch (t.type) {
case "text": case "text":
out.push({ type: "text", text: t.content, marks: topMarks() }); if (t.content) out.push({ type: "text", text: t.content, marks: topMarks() });
break; break;
case "softbreak": case "softbreak":
out.push({ type: "text", text: " ", marks: topMarks() }); out.push({ type: "text", text: " ", marks: topMarks() });
@ -243,6 +245,7 @@ function walkInline(children: Token[] | null, state: WalkState): FolioNode[] {
out.push({ type: "hard_break" }); out.push({ type: "hard_break" });
break; break;
case "code_inline": case "code_inline":
if (t.content)
out.push({ out.push({
type: "text", type: "text",
text: t.content, text: t.content,

View file

@ -2,7 +2,8 @@ import { contextBridge, ipcRenderer } from "electron";
contextBridge.exposeInMainWorld("folio", { contextBridge.exposeInMainWorld("folio", {
// book / project // 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"), openBook: () => ipcRenderer.invoke("folio:openBook"),
openBookPath: (p: string) => ipcRenderer.invoke("folio:openPath", p), openBookPath: (p: string) => ipcRenderer.invoke("folio:openPath", p),
revealInFolder: (p: string) => ipcRenderer.invoke("folio:reveal", p), revealInFolder: (p: string) => ipcRenderer.invoke("folio:reveal", p),

View file

@ -135,3 +135,10 @@ export function addRecent(filePath: string, recent: Recent): Recent[] {
fs.writeFileSync(filePath, JSON.stringify(recents, null, 2), "utf-8"); fs.writeFileSync(filePath, JSON.stringify(recents, null, 2), "utf-8");
return recents; 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;
}

View file

@ -5,6 +5,7 @@ import {
hasBook, hasBook,
listRecents as _listRecents, listRecents as _listRecents,
addRecent as _addRecent, addRecent as _addRecent,
removeRecent as _removeRecent,
} from "./project.js"; } from "./project.js";
import type { Recent } from "./project.js"; import type { Recent } from "./project.js";
@ -35,3 +36,7 @@ export function listRecents(): Recent[] {
export function addRecent(recent: Recent): Recent[] { export function addRecent(recent: Recent): Recent[] {
return _addRecent(recentsFilePath(), recent); return _addRecent(recentsFilePath(), recent);
} }
export function removeRecent(recentPath: string): Recent[] {
return _removeRecent(recentsFilePath(), recentPath);
}

View file

@ -18,7 +18,8 @@ import {
} from "./native-assembly.js"; } from "./native-assembly.js";
export type FolioAPI = { export type FolioAPI = {
newBook: () => Promise<any>; newBookNamed: (name: string) => Promise<any>;
deleteBook: (p: string) => Promise<any>;
openBook: () => Promise<any>; openBook: () => Promise<any>;
openBookPath: (p: string) => Promise<any>; openBookPath: (p: string) => Promise<any>;
revealInFolder: (p: string) => Promise<boolean>; revealInFolder: (p: string) => Promise<boolean>;
@ -749,9 +750,10 @@ export function initBook(api: FolioAPI) {
} }
setChapterButtons(!!meta); setChapterButtons(!!meta);
renderChapterList(); renderChapterList();
if (fullBookView.classList.contains("hidden")) { if (ordered.length) {
if (ordered.length) showPlaceholder(); await selectChapter(ordered[0].id);
else showPlaceholder(); } else {
showPlaceholder();
} }
await refreshFullBook(); await refreshFullBook();
} }
@ -1342,7 +1344,11 @@ export function initBook(api: FolioAPI) {
} }
async function startNewBook() { 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 handleResult(res);
await renderRecents(); await renderRecents();
} }
@ -1371,12 +1377,37 @@ export function initBook(api: FolioAPI) {
} }
for (const r of recents as Recent[]) { for (const r of recents as Recent[]) {
const li = document.createElement("li"); const li = document.createElement("li");
li.textContent = r.title || r.path; li.className = "recent-item";
li.title = r.path; const label = document.createElement("span");
li.addEventListener("mousedown", (e) => { label.className = "recent-title";
label.textContent = r.title || r.path;
label.title = r.path;
label.addEventListener("mousedown", (e) => {
e.preventDefault(); e.preventDefault();
void openPath(r.path); 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); recentList.appendChild(li);
} }
} }

View file

@ -401,21 +401,51 @@ html, body {
overflow-y: auto; overflow-y: auto;
} }
.recent-list li { .recent-list li.recent-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px; padding: 6px 8px;
border-radius: var(--radius); border-radius: var(--radius);
cursor: pointer; cursor: pointer;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--color-text); color: var(--color-text);
transition: background 0.12s ease, color 0.12s ease; 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; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
user-select: none;
} }
.recent-list li:hover { .recent-item .recent-delete {
background: var(--color-accent); flex: 0 0 auto;
color: var(--color-on-accent); 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 { .muted {

View file

@ -350,15 +350,34 @@ export function createTiptapEditor(
return editor; 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 { export function setEditorDoc(editor: Editor, doc: FolioNode): void {
// An empty document (no top-level blocks) leaves ProseMirror with nowhere to // 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 // 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 // until the user forces a block in. Seed one paragraph so the caret always
// has a home and paste works immediately. // 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 = const safe =
content && Array.isArray(content) && content.length > 0 content && Array.isArray(content) && content.length > 0
? doc ? clean
: ({ type: "doc", content: [{ type: "paragraph" }] } as unknown as FolioNode); : ({ type: "doc", content: [{ type: "paragraph" }] } as unknown as FolioNode);
editor.commands.setContent(safe as unknown as Record<string, unknown>, false); editor.commands.setContent(safe as unknown as Record<string, unknown>, false);
} }

View file

@ -92,10 +92,10 @@
// Manual save: typing marks the chapter Unsaved; the Save button (or // Manual save: typing marks the chapter Unsaved; the Save button (or
// Ctrl/Cmd+S) is required to persist. No auto-save should occur. // Ctrl/Cmd+S) is required to persist. No auto-save should occur.
const ed = window.__folioEditor; const edSave = window.__folioEditor;
if (ed) { if (edSave) {
ed.commands.focus("end"); edSave.commands.focus("end");
ed.commands.insertContent(" manual-save-check"); edSave.commands.insertContent(" manual-save-check");
await sleep(150); await sleep(150);
const unsaved = document.getElementById("saveState"); const unsaved = document.getElementById("saveState");
step("afterTypeUnsaved", unsaved ? unsaved.textContent : "n/a"); step("afterTypeUnsaved", unsaved ? unsaved.textContent : "n/a");
@ -170,6 +170,18 @@
} }
step("exports", ex); 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; out.ok = true;
} catch (e) { } catch (e) {
out.error = String((e && e.stack) || e); out.error = String((e && e.stack) || e);