From 14cad71688ae5144c0f5302fa696f6734b0685a7 Mon Sep 17 00:00:00 2001 From: avi Date: Fri, 2 Oct 2026 15:06:27 -0500 Subject: [PATCH] =?UTF-8?q?feat(recents):=20right-click=20(two-finger=20ta?= =?UTF-8?q?p)=20on=20a=20recents=20row=20opens=20a=20book=20menu=20?= =?UTF-8?q?=E2=80=94=20Open,=20Rename,=20Show=20in=20Folder,=20Hide/Unhide?= =?UTF-8?q?,=20Delete=20(with=20confirm);=20renderer=20claims=20the=20even?= =?UTF-8?q?t=20synchronously=20so=20the=20generic=20spelling/edit=20popup?= =?UTF-8?q?=20is=20skipped=20for=20recents=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 70 ++++++++++++++++++++++++++++++++ src/main/preload.ts | 14 +++++++ src/renderer/book/types.ts | 11 +++++ src/renderer/book/welcome.ts | 79 +++++++++++++++++++++++++++++++++++- 4 files changed, 173 insertions(+), 1 deletion(-) diff --git a/src/main/index.ts b/src/main/index.ts index fd42374..727e998 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -340,6 +340,70 @@ handleIpc("folio:pickBackupFolder", async () => { handleIpc("folio:getRecents", (): Recent[] => listRecents()); +// ---- recents right-click menu ---- +// The renderer claims a right-click on a recents row synchronously +// (ipcRenderer.sendSync) before the main-process context-menu event fires, +// so the generic spelling/edit popup is skipped for that target and a +// dedicated book menu is shown instead. Menu actions are sent back to the +// renderer (folio:recents-menu) and executed by the same handlers the row +// buttons use — one code path, consistent behavior. +interface RecentsMenuEntry { + path: string; + title: string; + hidden?: boolean; + missing?: boolean; +} +let recentsMenuClaimedAt = 0; + +ipcMain.on( + "folio:recents-contextmenu-sync", + (event, entry: RecentsMenuEntry, vx: number, vy: number) => { + if (!entry || typeof entry.path !== "string") { + event.returnValue = false; + return; + } + const win = getWindow(); + if (!win) { + event.returnValue = false; + return; + } + recentsMenuClaimedAt = Date.now(); + // Convert viewport coords to screen coords for the popup. + const [ox, oy] = win.getContentBounds + ? [win.getContentBounds().x, win.getContentBounds().y] + : [0, 0]; + const x = Number.isFinite(vx) ? ox + Math.round(vx) : undefined; + const y = Number.isFinite(vy) ? oy + Math.round(vy) : undefined; + const exists = hasBook(entry.path); + const send = (action: string) => + win.webContents.send("folio:recents-menu", { action, entry }); + const items: Electron.MenuItemConstructorOptions[] = []; + if (exists) { + items.push({ label: "Open", click: () => send("open") }); + items.push({ label: "Rename…", click: () => send("rename") }); + items.push({ label: "Show in Folder", click: () => send("reveal") }); + items.push({ type: "separator" }); + } + items.push( + entry.hidden + ? { label: "Unhide in Recents", click: () => send("unhide") } + : { + label: "Hide from Recents", + toolTip: + "Removes the entry from the list only — the book on disk is untouched", + click: () => send("hide"), + } + ); + items.push({ type: "separator" }); + items.push({ + label: exists ? "Delete Book (to Trash)…" : "Remove from List", + click: () => send(exists ? "delete" : "remove"), + }); + Menu.buildFromTemplate(items).popup({ window: win, x, y }); + event.returnValue = true; + } +); + // 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 @@ -795,6 +859,12 @@ function wireContextMenu(win: BrowserWindow) { win.webContents.on("context-menu", (_e, params) => { const target = getWindow(); if (!target) return; + // A recents row claimed this right-click (see the + // folio:recents-contextmenu-sync handler): its dedicated book menu was + // already popped up, so skip the generic spelling/edit popup. The claim + // is only honored within a short window so it can never suppress later + // right-clicks elsewhere in the app. + if (Date.now() - recentsMenuClaimedAt < 250) return; const items: Electron.MenuItemConstructorOptions[] = []; if (params.misspelledWord) { const sugs = (params.dictionarySuggestions || []).slice(0, 5); diff --git a/src/main/preload.ts b/src/main/preload.ts index e926b6b..1eba3e0 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -7,6 +7,20 @@ contextBridge.exposeInMainWorld("folio", { renameBook: (p: string, title: string) => ipcRenderer.invoke("folio:renameBook", p, title), hideRecent: (p: string) => ipcRenderer.invoke("folio:hideRecent", p), unhideRecent: (p: string) => ipcRenderer.invoke("folio:unhideRecent", p), + // Synchronously claim a right-click on a recents row so the main process + // pops the dedicated book menu instead of the generic edit menu. + claimRecentsContext: ( + p: string, + title: string, + hidden?: boolean, + missing?: boolean, + x?: number, + y?: number + ): boolean => + ipcRenderer.sendSync("folio:recents-contextmenu-sync", { path: p, title, hidden, missing }, x, y) === true, + onRecentsMenu: ( + cb: (msg: { action: string; entry: { path: string; title: string; hidden?: boolean; missing?: boolean } }) => void + ) => ipcRenderer.on("folio:recents-menu", (_e, msg) => cb(msg)), restoreBook: (p: string) => ipcRenderer.invoke("folio:restoreBook", p), openBook: () => ipcRenderer.invoke("folio:openBook"), openBookPath: (p: string) => ipcRenderer.invoke("folio:openPath", p), diff --git a/src/renderer/book/types.ts b/src/renderer/book/types.ts index 95d1c00..29eb99a 100644 --- a/src/renderer/book/types.ts +++ b/src/renderer/book/types.ts @@ -43,6 +43,17 @@ export type FolioAPI = { renameBook: (p: string, title: string) => Promise; hideRecent: (p: string) => Promise; unhideRecent: (p: string) => Promise; + claimRecentsContext: ( + p: string, + title: string, + hidden?: boolean, + missing?: boolean, + x?: number, + y?: number + ) => boolean; + onRecentsMenu: ( + cb: (msg: { action: string; entry: { path: string; title: string; hidden?: boolean; missing?: boolean } }) => void + ) => void; closeBook: () => Promise; getSpellcheck: () => Promise; setSpellcheck: (on: boolean) => Promise; diff --git a/src/renderer/book/welcome.ts b/src/renderer/book/welcome.ts index 4939609..a5baa0c 100644 --- a/src/renderer/book/welcome.ts +++ b/src/renderer/book/welcome.ts @@ -9,7 +9,7 @@ import type { BookCtx } from "./types.js"; import type { ChapterEntry, Recent } from "./types.js"; import type { BookMeta } from "../../main/project.js"; -import { alertMessage, promptInput } from "../dialog.js"; +import { alertMessage, confirmMessage, promptInput } from "../dialog.js"; import { $, showUndoToast, showView } from "./ui.js"; export interface WelcomeFeature { @@ -221,6 +221,28 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature { // double-click or Enter opens the book. Replaces the old always-on // "×" button, which deleted on a mis-click. label.addEventListener("click", () => selectRecent(li)); + // Right-click (two-finger tap) on a recents row opens a dedicated + // context menu (Open / Rename / Hide / Delete / Show in Folder). The + // renderer claims the event synchronously (sendSync) so the main + // process skips the generic spelling/edit menu for this target; if the + // claim ever fails, fall back to selecting the row inline. + li.addEventListener("contextmenu", (e) => { + e.preventDefault(); + try { + const claimed = api.claimRecentsContext( + r.path, + r.title || r.path, + !!r.hidden, + !!r.missing, + e.clientX, + e.clientY + ); + if (claimed) return; + } catch { + /* fall through to inline selection */ + } + selectRecent(li); + }); li.addEventListener("dblclick", () => void openPath(r.path)); li.addEventListener("keydown", (e) => { if (e.key === "Enter") void openPath(r.path); @@ -363,6 +385,61 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature { if (state.currentBook) void api.revealInFolder(state.currentBook.path); }); + // Right-click menu actions (popped by the main process; see + // folio:recents-contextmenu-sync). Runs the same operations as the row + // buttons so behavior is identical from either surface. + api.onRecentsMenu(async ({ action, entry }) => { + switch (action) { + case "open": + await openPath(entry.path); + return; + case "rename": { + const name = await promptInput("Rename book", entry.title || ""); + if (name === null) return; + const trimmed = name.trim(); + if (!trimmed || trimmed === entry.title) return; + const res = await api.renameBook(entry.path, trimmed); + if (res && "error" in res) { + await alertMessage("Could not rename", (res as { error: string }).error); + return; + } + await renderRecents(); + return; + } + case "reveal": + await api.revealInFolder(entry.path); + return; + case "hide": + await api.hideRecent(entry.path); + await renderRecents(); + return; + case "unhide": + await api.unhideRecent(entry.path); + await renderRecents(); + return; + case "remove": + // Stale entry (book gone): no confirmation needed, nothing to lose. + await api.deleteBook(entry.path); + await renderRecents(); + return; + case "delete": { + const yes = await confirmMessage( + `Delete "${entry.title}"?`, + "The whole book folder moves to the system trash. Hide from Recents removes it from the list without touching the disk.", + "Move to Trash" + ); + if (!yes) return; + const res = await api.deleteBook(entry.path); + if (res && "error" in res) { + await alertMessage("Could not delete", (res as { error: string }).error); + return; + } + await renderRecents(); + return; + } + } + }); + return { showWelcome, showPlaceholder,