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,
|
||||
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<BookResult | ErrResult> {
|
|||
}
|
||||
}
|
||||
|
||||
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 <name>/ 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 () => {
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ import {
|
|||
} from "./native-assembly.js";
|
||||
|
||||
export type FolioAPI = {
|
||||
newBook: () => Promise<any>;
|
||||
newBookNamed: (name: string) => Promise<any>;
|
||||
deleteBook: (p: string) => Promise<any>;
|
||||
openBook: () => Promise<any>;
|
||||
openBookPath: (p: string) => Promise<any>;
|
||||
revealInFolder: (p: string) => Promise<boolean>;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>, false);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue