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,
|
saveMeta,
|
||||||
Recent,
|
Recent,
|
||||||
} from "./project.js";
|
} 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 { buildMenu } from "./menu.js";
|
||||||
import {
|
import {
|
||||||
createChapter,
|
createChapter,
|
||||||
|
|
@ -338,6 +338,29 @@ handleIpc("folio:pickBackupFolder", async () => {
|
||||||
|
|
||||||
handleIpc("folio:getRecents", (): Recent[] => listRecents());
|
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", () => {
|
handleIpc("folio:closeBook", () => {
|
||||||
bookPath = null;
|
bookPath = null;
|
||||||
mainWindow?.setTitle("Folio");
|
mainWindow?.setTitle("Folio");
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ contextBridge.exposeInMainWorld("folio", {
|
||||||
// book / project
|
// book / project
|
||||||
newBookNamed: (name: string) => ipcRenderer.invoke("folio:newBookNamed", name),
|
newBookNamed: (name: string) => ipcRenderer.invoke("folio:newBookNamed", name),
|
||||||
deleteBook: (p: string) => ipcRenderer.invoke("folio:deleteBook", p),
|
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),
|
restoreBook: (p: string) => ipcRenderer.invoke("folio:restoreBook", 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),
|
||||||
|
|
|
||||||
|
|
@ -132,3 +132,11 @@ export function removeRecent(filePath: string, recentPath: string): Recent[] {
|
||||||
writeJsonFile(filePath, recents);
|
writeJsonFile(filePath, recents);
|
||||||
return 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 {
|
import {
|
||||||
addRecent as _addRecent,
|
addRecent as _addRecent,
|
||||||
removeRecent as _removeRecent,
|
removeRecent as _removeRecent,
|
||||||
|
renameRecent as _renameRecent,
|
||||||
} from "./project.js";
|
} from "./project.js";
|
||||||
import { readJsonArray, writeJsonFile } from "./json-store.js";
|
import { readJsonArray, writeJsonFile } from "./json-store.js";
|
||||||
import type { Recent } from "./project.js";
|
import type { Recent } from "./project.js";
|
||||||
|
|
@ -80,3 +81,7 @@ export function addRecent(recent: Recent): Recent[] {
|
||||||
export function removeRecent(recentPath: string): Recent[] {
|
export function removeRecent(recentPath: string): Recent[] {
|
||||||
return _removeRecent(recentsFilePath(), recentPath);
|
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.onOpenRecent((p) => void welcome.openPath(p));
|
||||||
api.onOpenMarkdownRequested(() => void welcome.openMarkdown());
|
api.onOpenMarkdownRequested(() => void welcome.openMarkdown());
|
||||||
api.onCheckUpdateRequested(() => void checkForUpdates());
|
api.onCheckUpdateRequested(() => void checkForUpdates());
|
||||||
$("welcomeUpdate")?.addEventListener("click", () => void checkForUpdates());
|
hooks.checkForUpdates = () => void checkForUpdates();
|
||||||
api.onRevealRequested(async (p) => {
|
api.onRevealRequested(async (p) => {
|
||||||
const ok = await api.revealInFolder(p);
|
const ok = await api.revealInFolder(p);
|
||||||
showToast(
|
showToast(
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ export type FolioAPI = {
|
||||||
openBookPath: (p: string) => Promise<any>;
|
openBookPath: (p: string) => Promise<any>;
|
||||||
revealInFolder: (p: string) => Promise<boolean>;
|
revealInFolder: (p: string) => Promise<boolean>;
|
||||||
getRecents: () => Promise<any[]>;
|
getRecents: () => Promise<any[]>;
|
||||||
|
renameBook: (p: string, title: string) => Promise<any>;
|
||||||
closeBook: () => Promise<boolean>;
|
closeBook: () => Promise<boolean>;
|
||||||
getSpellcheck: () => Promise<boolean>;
|
getSpellcheck: () => Promise<boolean>;
|
||||||
setSpellcheck: (on: boolean) => Promise<any>;
|
setSpellcheck: (on: boolean) => Promise<any>;
|
||||||
|
|
@ -138,6 +139,8 @@ export interface BookHooks {
|
||||||
isTypewriterEnabled(): boolean;
|
isTypewriterEnabled(): boolean;
|
||||||
// settings feature
|
// settings feature
|
||||||
openSettings(): void;
|
openSettings(): void;
|
||||||
|
// update flow (orchestrator)
|
||||||
|
checkForUpdates(): void;
|
||||||
// session coordinators (orchestrator)
|
// session coordinators (orchestrator)
|
||||||
refreshMeta(): Promise<void>;
|
refreshMeta(): Promise<void>;
|
||||||
showPlaceholder(): void;
|
showPlaceholder(): void;
|
||||||
|
|
|
||||||
|
|
@ -195,20 +195,50 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
|
||||||
for (const r of recents as (Recent & { missing?: boolean })[]) {
|
for (const r of recents as (Recent & { missing?: boolean })[]) {
|
||||||
const li = document.createElement("li");
|
const li = document.createElement("li");
|
||||||
li.className = "recent-item";
|
li.className = "recent-item";
|
||||||
|
li.tabIndex = 0;
|
||||||
const label = document.createElement("span");
|
const label = document.createElement("span");
|
||||||
label.className = "recent-title";
|
label.className = "recent-title";
|
||||||
label.textContent = (r.title || r.path) + (r.missing ? " (missing)" : "");
|
label.textContent = (r.title || r.path) + (r.missing ? " (missing)" : "");
|
||||||
label.title = r.path;
|
label.title = r.path;
|
||||||
label.addEventListener("mousedown", (e) => {
|
// Single click selects the row (reveals Rename/Delete actions);
|
||||||
e.preventDefault();
|
// double-click or Enter opens the book. Replaces the old always-on
|
||||||
void openPath(r.path);
|
// "×" 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");
|
const del = document.createElement("button");
|
||||||
del.className = "recent-delete icon-button";
|
del.className = "recent-action icon-button recent-delete";
|
||||||
del.type = "button";
|
del.type = "button";
|
||||||
del.title = "Delete book";
|
del.title = "Delete book";
|
||||||
del.setAttribute("aria-label", "Delete book");
|
del.setAttribute("aria-label", "Delete book");
|
||||||
del.textContent = "×";
|
del.textContent = "Delete";
|
||||||
del.addEventListener("click", async (e) => {
|
del.addEventListener("click", async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const res = await api.deleteBook(r.path);
|
const res = await api.deleteBook(r.path);
|
||||||
|
|
@ -230,8 +260,9 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
|
||||||
await renderRecents();
|
await renderRecents();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
actions.appendChild(del);
|
||||||
li.appendChild(label);
|
li.appendChild(label);
|
||||||
li.appendChild(del);
|
li.appendChild(actions);
|
||||||
recentList.appendChild(li);
|
recentList.appendChild(li);
|
||||||
}
|
}
|
||||||
} catch {
|
} 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) ----
|
// ---- wiring (bind once at construction) ----
|
||||||
$("welcomeNewBook").addEventListener("click", () => void startNewBook());
|
$("welcomeNewBook").addEventListener("click", () => void startNewBook());
|
||||||
$("welcomeOpenBook").addEventListener("click", () => void startOpenBook());
|
$("welcomeOpenBook").addEventListener("click", () => void startOpenBook());
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue