feat(books): rename book titles; click-to-select recents with Rename/Delete actions
This commit is contained in:
parent
6479656680
commit
19255e07c8
7 changed files with 91 additions and 8 deletions
|
|
@ -17,7 +17,7 @@ import {
|
|||
saveMeta,
|
||||
Recent,
|
||||
} from "./project.js";
|
||||
import { listRecents, addRecent, removeRecent, migrateLegacyRecents } from "./recents.js";
|
||||
import { listRecents, addRecent, removeRecent, renameRecent, migrateLegacyRecents } from "./recents.js";
|
||||
import { buildMenu } from "./menu.js";
|
||||
import {
|
||||
createChapter,
|
||||
|
|
@ -338,6 +338,29 @@ handleIpc("folio:pickBackupFolder", async () => {
|
|||
|
||||
handleIpc("folio:getRecents", (): Recent[] => listRecents());
|
||||
|
||||
// Rename a book's display title (folio.json + matching recents entry). The
|
||||
// folder on disk keeps its name: renaming the directory would invalidate
|
||||
// every other recents/menu path that points at it, so the title is the only
|
||||
// stable rename surface.
|
||||
handleIpc("folio:renameBook", (_e, p: string, rawTitle: string) => {
|
||||
const title = typeof rawTitle === "string" ? rawTitle.trim() : "";
|
||||
if (!title) return { error: "Book name cannot be empty." };
|
||||
if (!hasBook(p)) return { error: `Not a Folio book: ${p}` };
|
||||
try {
|
||||
const meta = loadBook(p);
|
||||
meta.title = title;
|
||||
saveMeta(p, meta);
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
renameRecent(p, title);
|
||||
if (bookPath === p) {
|
||||
mainWindow?.setTitle(`${title} — Folio`);
|
||||
refreshMenu();
|
||||
}
|
||||
return { ok: true, title };
|
||||
});
|
||||
|
||||
handleIpc("folio:closeBook", () => {
|
||||
bookPath = null;
|
||||
mainWindow?.setTitle("Folio");
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ contextBridge.exposeInMainWorld("folio", {
|
|||
// book / project
|
||||
newBookNamed: (name: string) => ipcRenderer.invoke("folio:newBookNamed", name),
|
||||
deleteBook: (p: string) => ipcRenderer.invoke("folio:deleteBook", p),
|
||||
renameBook: (p: string, title: string) => ipcRenderer.invoke("folio:renameBook", p, title),
|
||||
restoreBook: (p: string) => ipcRenderer.invoke("folio:restoreBook", p),
|
||||
openBook: () => ipcRenderer.invoke("folio:openBook"),
|
||||
openBookPath: (p: string) => ipcRenderer.invoke("folio:openPath", p),
|
||||
|
|
|
|||
|
|
@ -132,3 +132,11 @@ export function removeRecent(filePath: string, recentPath: string): Recent[] {
|
|||
writeJsonFile(filePath, recents);
|
||||
return recents;
|
||||
}
|
||||
|
||||
export function renameRecent(filePath: string, recentPath: string, title: string): Recent[] {
|
||||
const recents = listRecents(filePath).map((r) =>
|
||||
r.path === recentPath ? { ...r, title } : r
|
||||
);
|
||||
writeJsonFile(filePath, recents);
|
||||
return recents;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { APP_ID, hasBook } from "./project.js";
|
|||
import {
|
||||
addRecent as _addRecent,
|
||||
removeRecent as _removeRecent,
|
||||
renameRecent as _renameRecent,
|
||||
} from "./project.js";
|
||||
import { readJsonArray, writeJsonFile } from "./json-store.js";
|
||||
import type { Recent } from "./project.js";
|
||||
|
|
@ -80,3 +81,7 @@ export function addRecent(recent: Recent): Recent[] {
|
|||
export function removeRecent(recentPath: string): Recent[] {
|
||||
return _removeRecent(recentsFilePath(), recentPath);
|
||||
}
|
||||
|
||||
export function renameRecent(recentPath: string, title: string): Recent[] {
|
||||
return _renameRecent(recentsFilePath(), recentPath, title);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ export function initBook(api: FolioAPI) {
|
|||
api.onOpenRecent((p) => void welcome.openPath(p));
|
||||
api.onOpenMarkdownRequested(() => void welcome.openMarkdown());
|
||||
api.onCheckUpdateRequested(() => void checkForUpdates());
|
||||
$("welcomeUpdate")?.addEventListener("click", () => void checkForUpdates());
|
||||
hooks.checkForUpdates = () => void checkForUpdates();
|
||||
api.onRevealRequested(async (p) => {
|
||||
const ok = await api.revealInFolder(p);
|
||||
showToast(
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export type FolioAPI = {
|
|||
openBookPath: (p: string) => Promise<any>;
|
||||
revealInFolder: (p: string) => Promise<boolean>;
|
||||
getRecents: () => Promise<any[]>;
|
||||
renameBook: (p: string, title: string) => Promise<any>;
|
||||
closeBook: () => Promise<boolean>;
|
||||
getSpellcheck: () => Promise<boolean>;
|
||||
setSpellcheck: (on: boolean) => Promise<any>;
|
||||
|
|
@ -138,6 +139,8 @@ export interface BookHooks {
|
|||
isTypewriterEnabled(): boolean;
|
||||
// settings feature
|
||||
openSettings(): void;
|
||||
// update flow (orchestrator)
|
||||
checkForUpdates(): void;
|
||||
// session coordinators (orchestrator)
|
||||
refreshMeta(): Promise<void>;
|
||||
showPlaceholder(): void;
|
||||
|
|
|
|||
|
|
@ -195,20 +195,50 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
|
|||
for (const r of recents as (Recent & { missing?: boolean })[]) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "recent-item";
|
||||
li.tabIndex = 0;
|
||||
const label = document.createElement("span");
|
||||
label.className = "recent-title";
|
||||
label.textContent = (r.title || r.path) + (r.missing ? " (missing)" : "");
|
||||
label.title = r.path;
|
||||
label.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault();
|
||||
void openPath(r.path);
|
||||
// Single click selects the row (reveals Rename/Delete actions);
|
||||
// double-click or Enter opens the book. Replaces the old always-on
|
||||
// "×" button, which deleted on a mis-click.
|
||||
label.addEventListener("click", () => selectRecent(li));
|
||||
li.addEventListener("dblclick", () => void openPath(r.path));
|
||||
li.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") void openPath(r.path);
|
||||
else if (e.key === "Escape" && li.classList.contains("selected")) deselectRecent(li);
|
||||
});
|
||||
const actions = document.createElement("span");
|
||||
actions.className = "recent-actions";
|
||||
if (!r.missing) {
|
||||
const ren = document.createElement("button");
|
||||
ren.className = "recent-action icon-button";
|
||||
ren.type = "button";
|
||||
ren.title = "Rename book";
|
||||
ren.setAttribute("aria-label", "Rename book");
|
||||
ren.textContent = "✎";
|
||||
ren.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const name = await promptInput("Rename book", r.title || "");
|
||||
if (name === null) return;
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || trimmed === r.title) return;
|
||||
const res = await api.renameBook(r.path, trimmed);
|
||||
if (res && "error" in res) {
|
||||
await alertMessage("Could not rename", (res as { error: string }).error);
|
||||
return;
|
||||
}
|
||||
await renderRecents();
|
||||
});
|
||||
actions.appendChild(ren);
|
||||
}
|
||||
const del = document.createElement("button");
|
||||
del.className = "recent-delete icon-button";
|
||||
del.className = "recent-action icon-button recent-delete";
|
||||
del.type = "button";
|
||||
del.title = "Delete book";
|
||||
del.setAttribute("aria-label", "Delete book");
|
||||
del.textContent = "×";
|
||||
del.textContent = "Delete";
|
||||
del.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const res = await api.deleteBook(r.path);
|
||||
|
|
@ -230,8 +260,9 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
|
|||
await renderRecents();
|
||||
});
|
||||
});
|
||||
actions.appendChild(del);
|
||||
li.appendChild(label);
|
||||
li.appendChild(del);
|
||||
li.appendChild(actions);
|
||||
recentList.appendChild(li);
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -239,6 +270,18 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
|
|||
}
|
||||
}
|
||||
|
||||
// At most one recents row is selected at a time; selection reveals the
|
||||
// row's action buttons (see .recent-actions CSS).
|
||||
function deselectRecent(li: HTMLElement) {
|
||||
li.classList.remove("selected");
|
||||
}
|
||||
function selectRecent(li: HTMLElement) {
|
||||
recentList
|
||||
.querySelectorAll("li.recent-item.selected")
|
||||
.forEach((el) => el !== li && el.classList.remove("selected"));
|
||||
li.classList.add("selected");
|
||||
}
|
||||
|
||||
// ---- wiring (bind once at construction) ----
|
||||
$("welcomeNewBook").addEventListener("click", () => void startNewBook());
|
||||
$("welcomeOpenBook").addEventListener("click", () => void startOpenBook());
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue