Files
paseo/packages/desktop/src/window/window-manager.ts
T
Mohamed Boudra 650d3148e9 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.
2026-05-09 12:24:08 +07:00

372 lines
10 KiB
TypeScript

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) {
return 0;
}
return input;
}
export type WindowTheme = "light" | "dark";
export interface WindowControlsOverlayUpdate {
height?: number;
backgroundColor?: string;
foregroundColor?: string;
}
export interface WindowControlsOverlayState {
height: number;
backgroundColor?: string;
foregroundColor?: string;
}
export function readWindowTheme(input: unknown): WindowTheme | null {
if (input === "light" || input === "dark") {
return input;
}
return null;
}
export function resolveSystemWindowTheme(): WindowTheme {
return nativeTheme.shouldUseDarkColors ? "dark" : "light";
}
export function getWindowBackgroundColor(theme: WindowTheme): string {
return theme === "dark" ? "#181B1A" : "#ffffff";
}
export function createWindowControlsOverlayState(theme: WindowTheme): WindowControlsOverlayState {
const overlay = getTitleBarOverlayOptions(theme);
return {
height: overlay.height ?? 29,
backgroundColor: overlay.color,
foregroundColor: overlay.symbolColor,
};
}
export function getTitleBarOverlayOptions(theme: WindowTheme): Electron.TitleBarOverlayOptions {
if (theme === "dark") {
return { color: "#181B1A", symbolColor: "#e4e4e7", height: 29 };
}
return { color: "#ffffff", symbolColor: "#09090b", height: 29 };
}
export function getMainWindowChromeOptions(input: {
platform: NodeJS.Platform;
theme: WindowTheme;
}): Pick<
Electron.BrowserWindowConstructorOptions,
"titleBarStyle" | "trafficLightPosition" | "frame" | "titleBarOverlay" | "autoHideMenuBar"
> {
if (input.platform === "darwin") {
return {
titleBarStyle: "hidden",
titleBarOverlay: true,
trafficLightPosition: { x: 16, y: 14 },
};
}
return {
titleBarStyle: "hidden",
frame: false,
titleBarOverlay: getTitleBarOverlayOptions(input.theme),
autoHideMenuBar: true,
};
}
function readFiniteOverlayHeight(input: unknown): number | null {
if (typeof input !== "number" || !Number.isFinite(input)) {
return null;
}
const rounded = Math.round(input);
return rounded >= 1 ? rounded : null;
}
function readOverlayColor(input: unknown): string | null {
if (typeof input !== "string") {
return null;
}
return input;
}
export function readWindowControlsOverlayUpdate(
input: unknown,
): WindowControlsOverlayUpdate | null {
if (!input || typeof input !== "object") {
return null;
}
const candidate = input as Record<string, unknown>;
const height = readFiniteOverlayHeight(candidate.height);
const backgroundColor = readOverlayColor(candidate.backgroundColor);
const foregroundColor = readOverlayColor(candidate.foregroundColor);
if (height === null && backgroundColor === null && foregroundColor === null) {
return null;
}
return {
...(height !== null ? { height } : {}),
...(backgroundColor !== null ? { backgroundColor } : {}),
...(foregroundColor !== null ? { foregroundColor } : {}),
};
}
export function resolveRuntimeTitleBarOverlayOptions(
state: WindowControlsOverlayState,
): Electron.TitleBarOverlayOptions {
return {
color: state.backgroundColor?.trim() === "" ? undefined : state.backgroundColor,
symbolColor: state.foregroundColor?.trim() === "" ? undefined : state.foregroundColor,
height: Math.max(0, state.height - 1),
};
}
export function applyWindowControlsOverlayUpdate(input: {
win: Pick<BrowserWindow, "setTitleBarOverlay">;
current: WindowControlsOverlayState;
update: WindowControlsOverlayUpdate;
}): WindowControlsOverlayState {
const next: WindowControlsOverlayState = {
height: input.update.height ?? input.current.height,
backgroundColor: input.update.backgroundColor ?? input.current.backgroundColor,
foregroundColor: input.update.foregroundColor ?? input.current.foregroundColor,
};
input.win.setTitleBarOverlay(resolveRuntimeTitleBarOverlayOptions(next));
return next;
}
export function registerWindowManager(): void {
const overlayStateByWindow = new WeakMap<BrowserWindow, WindowControlsOverlayState>();
ipcMain.handle("paseo:window:toggleMaximize", (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return;
if (win.isMaximized()) {
win.unmaximize();
} else {
win.maximize();
}
});
ipcMain.handle("paseo:window:isFullscreen", (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
return win?.isFullScreen() ?? false;
});
ipcMain.handle("paseo:window:setBadgeCount", (_event, count?: unknown) => {
if (process.platform === "darwin" || process.platform === "linux") {
const badgeCount = readBadgeCount(count);
try {
app.setBadgeCount(badgeCount);
} catch (error) {
console.warn("[window-manager] Failed to update badge count", {
count,
badgeCount,
error,
});
}
}
});
ipcMain.handle("paseo:window:updateWindowControls", (event, update?: unknown) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) {
return;
}
const nextUpdate = readWindowControlsOverlayUpdate(update);
if (!nextUpdate) {
return;
}
if (nextUpdate.backgroundColor) {
win.setBackgroundColor(nextUpdate.backgroundColor);
}
if (process.platform === "darwin") {
return;
}
const current =
overlayStateByWindow.get(win) ?? createWindowControlsOverlayState(resolveSystemWindowTheme());
const nextState = applyWindowControlsOverlayUpdate({
win,
current,
update: nextUpdate,
});
overlayStateByWindow.set(win, nextState);
});
}
export function setupWindowResizeEvents(win: BrowserWindow): void {
win.on("resize", () => {
win.webContents.send("paseo:window:resized", {});
});
win.on("enter-full-screen", () => {
win.webContents.send("paseo:window:resized", {});
});
win.on("leave-full-screen", () => {
win.webContents.send("paseo:window:resized", {});
});
}
function refreshChromiumSurface(win: BrowserWindow): void {
if (win.isDestroyed()) {
return;
}
win.webContents.invalidate();
if (win.isMaximized() || win.isFullScreen()) {
return;
}
const [width, height] = win.getSize();
win.setSize(width + 1, height);
setTimeout(() => {
if (!win.isDestroyed()) {
win.setSize(width, height);
}
}, 32);
}
export function setupDarwinPaintRefresh(win: BrowserWindow): void {
if (process.platform !== "darwin") {
return;
}
win.webContents.setBackgroundThrottling(false);
const requestSurfaceRefresh = () => {
if (!win.isDestroyed()) {
win.webContents.invalidate();
}
};
const handleChildProcessGone = (
_event: Electron.Event,
details: { type?: string; reason?: string },
) => {
if (details.type !== "GPU") {
return;
}
console.warn("[window] GPU process gone:", details.reason);
refreshChromiumSurface(win);
};
win.on("restore", requestSurfaceRefresh);
win.on("show", requestSurfaceRefresh);
app.on("child-process-gone", handleChildProcessGone);
win.once("closed", () => {
win.off("restore", requestSurfaceRefresh);
win.off("show", requestSurfaceRefresh);
app.off("child-process-gone", handleChildProcessGone);
});
}
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(buildStandardContextMenuItems(win.webContents, params));
menu.popup({ window: win });
});
}
/**
* Prevent Electron from navigating to files dragged onto the window.
* The renderer handles drag-drop via standard HTML5 APIs instead.
*/
export function setupDragDropPrevention(win: BrowserWindow): void {
win.webContents.on("will-navigate", (event, url) => {
// Allow normal navigation (e.g. dev server hot-reload) but block file:// URLs
// that result from dropping files onto the window.
if (url.startsWith("file://")) {
event.preventDefault();
}
});
}