feat(desktop): rich context menu — copy link, image, spellcheck

Replaces the hardcoded Copy/Paste/Select All menu with one driven by
ContextMenuParams: link items (Open in Browser, Copy Link Address),
image items (Copy Image, Save Image As), spellcheck suggestions, and
proper Cut/Copy/Paste in editable inputs. Both the main window and
the browser webview menus go through the shared helper.

MarkdownLink (web branch) now wraps its Pressable+Text in a real
<a href> with `display: contents` so Electron exposes linkURL on
right-click. Native branch is untouched.
This commit is contained in:
Mohamed Boudra
2026-05-09 12:24:08 +07:00
parent 511580a47e
commit 650d3148e9
3 changed files with 101 additions and 19 deletions
+79 -7
View File
@@ -1,4 +1,14 @@
import { app, BrowserWindow, Menu, ipcMain, nativeTheme } from "electron";
import {
app,
BrowserWindow,
Menu,
type MenuItemConstructorOptions,
type WebContents,
clipboard,
ipcMain,
nativeTheme,
shell,
} from "electron";
export function readBadgeCount(input: unknown): number {
if (typeof input !== "number" || !Number.isSafeInteger(input) || input < 0) {
@@ -272,14 +282,76 @@ export function setupDarwinPaintRefresh(win: BrowserWindow): void {
});
}
export function buildStandardContextMenuItems(
contents: WebContents,
params: Electron.ContextMenuParams,
): MenuItemConstructorOptions[] {
const items: MenuItemConstructorOptions[] = [];
if (params.misspelledWord) {
if (params.dictionarySuggestions.length > 0) {
for (const suggestion of params.dictionarySuggestions) {
items.push({
label: suggestion,
click: () => contents.replaceMisspelling(suggestion),
});
}
} else {
items.push({ label: "No suggestions", enabled: false });
}
items.push({ type: "separator" });
items.push({
label: "Add to Dictionary",
click: () => contents.session.addWordToSpellCheckerDictionary(params.misspelledWord),
});
items.push({ type: "separator" });
}
if (params.linkURL && /^https?:/i.test(params.linkURL)) {
items.push({
label: "Open Link in Browser",
click: () => {
void shell.openExternal(params.linkURL);
},
});
items.push({
label: "Copy Link Address",
click: () => clipboard.writeText(params.linkURL),
});
items.push({ type: "separator" });
}
if (params.hasImageContents && params.srcURL) {
items.push({
label: "Copy Image",
click: () => contents.copyImageAt(params.x, params.y),
});
items.push({
label: "Save Image As…",
click: () => contents.downloadURL(params.srcURL),
});
items.push({ type: "separator" });
}
if (params.isEditable) {
items.push({ role: "cut", enabled: params.editFlags.canCut });
items.push({ role: "copy", enabled: params.editFlags.canCopy });
items.push({ role: "paste", enabled: params.editFlags.canPaste });
items.push({ type: "separator" });
items.push({ role: "selectAll" });
} else {
items.push({ role: "copy", enabled: params.selectionText.length > 0 });
items.push({ role: "paste" });
items.push({ type: "separator" });
items.push({ role: "selectAll" });
}
return items;
}
export function setupDefaultContextMenu(win: BrowserWindow): void {
win.webContents.on("context-menu", (_event, params) => {
const menu = Menu.buildFromTemplate([
{ role: "copy", enabled: params.selectionText.length > 0 },
{ role: "paste" },
{ type: "separator" },
{ role: "selectAll" },
]);
const menu = Menu.buildFromTemplate(buildStandardContextMenuItems(win.webContents, params));
menu.popup({ window: win });
});
}