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:
parent
80898c2e2a
commit
5ac7cdb593
9 changed files with 184 additions and 41 deletions
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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,11 +245,12 @@ 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":
|
||||||
out.push({
|
if (t.content)
|
||||||
type: "text",
|
out.push({
|
||||||
text: t.content,
|
type: "text",
|
||||||
marks: [...topMarks(), { type: "code" }],
|
text: t.content,
|
||||||
});
|
marks: [...topMarks(), { type: "code" }],
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
case "strong_open":
|
case "strong_open":
|
||||||
markStack.push({ type: "bold" });
|
markStack.push({ type: "bold" });
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue