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
+20 -8
View File
@@ -908,17 +908,29 @@ function MarkdownLink({
}
return (
<Pressable
accessibilityRole="link"
onPress={handlePress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
>
<Text style={hoveredTextStyle}>{children}</Text>
</Pressable>
<a href={href} onClick={preventAnchorNavigation} style={MARKDOWN_LINK_ANCHOR_STYLE}>
<Pressable
accessibilityRole="link"
onPress={handlePress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
>
<Text style={hoveredTextStyle}>{children}</Text>
</Pressable>
</a>
);
}
const MARKDOWN_LINK_ANCHOR_STYLE: React.CSSProperties = {
display: "contents",
color: "inherit",
textDecoration: "none",
};
function preventAnchorNavigation(event: React.MouseEvent<HTMLAnchorElement>): void {
event.preventDefault();
}
function getInlineCodeAutoLinkUrl(
markdownParser: ReturnType<typeof MarkdownIt>,
content: string,
+2 -4
View File
@@ -23,6 +23,7 @@ import {
setupWindowResizeEvents,
setupDefaultContextMenu,
setupDragDropPrevention,
buildStandardContextMenuItems,
} from "./window/window-manager.js";
import { registerDialogHandlers } from "./features/dialogs.js";
import {
@@ -150,10 +151,7 @@ function showBrowserWebviewContextMenu(
params: Electron.ContextMenuParams,
): void {
const menu = Menu.buildFromTemplate([
{ role: "copy", enabled: params.selectionText.length > 0 },
{ role: "paste" },
{ type: "separator" },
{ role: "selectAll" },
...buildStandardContextMenuItems(contents, params),
...(app.isPackaged
? []
: [
+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 });
});
}