diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 443492ada..4bba5e130 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -10,7 +10,6 @@ import { dialog, ipcMain, Menu, - nativeImage, nativeTheme, protocol, shell, @@ -24,7 +23,6 @@ import type { } from "@t3tools/contracts"; import { autoUpdater } from "electron-updater"; -import type { ContextMenuItem } from "@t3tools/contracts"; import { NetService } from "@t3tools/shared/Net"; import { RotatingFileSink } from "@t3tools/shared/logging"; import { showDesktopConfirmDialog } from "./confirmDialog"; @@ -49,7 +47,6 @@ fixPath(); const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; const SET_THEME_CHANNEL = "desktop:set-theme"; -const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; const UPDATE_STATE_CHANNEL = "desktop:update-state"; @@ -92,7 +89,6 @@ let desktopLogSink: RotatingFileSink | null = null; let backendLogSink: RotatingFileSink | null = null; let restoreStdIoCapture: (() => void) | null = null; -let destructiveMenuIconCache: Electron.NativeImage | null | undefined; const desktopRuntimeInfo = resolveDesktopRuntimeInfo({ platform: process.platform, processArch: process.arch, @@ -248,29 +244,6 @@ function captureBackendOutput(child: ChildProcess.ChildProcess): void { } initializePackagedLogging(); - -function getDestructiveMenuIcon(): Electron.NativeImage | undefined { - if (process.platform !== "darwin") return undefined; - if (destructiveMenuIconCache !== undefined) { - return destructiveMenuIconCache ?? undefined; - } - try { - const icon = nativeImage.createFromNamedImage("trash").resize({ - width: 14, - height: 14, - }); - if (icon.isEmpty()) { - destructiveMenuIconCache = null; - return undefined; - } - icon.setTemplateImage(true); - destructiveMenuIconCache = icon; - return icon; - } catch { - destructiveMenuIconCache = null; - return undefined; - } -} let updatePollTimer: ReturnType | null = null; let updateStartupTimer: ReturnType | null = null; let updateCheckInFlight = false; @@ -1084,67 +1057,6 @@ function registerIpcHandlers(): void { nativeTheme.themeSource = theme; }); - ipcMain.removeHandler(CONTEXT_MENU_CHANNEL); - ipcMain.handle( - CONTEXT_MENU_CHANNEL, - async (_event, items: ContextMenuItem[], position?: { x: number; y: number }) => { - const normalizedItems = items - .filter((item) => typeof item.id === "string" && typeof item.label === "string") - .map((item) => ({ - id: item.id, - label: item.label, - destructive: item.destructive === true, - })); - if (normalizedItems.length === 0) { - return null; - } - - const popupPosition = - position && - Number.isFinite(position.x) && - Number.isFinite(position.y) && - position.x >= 0 && - position.y >= 0 - ? { - x: Math.floor(position.x), - y: Math.floor(position.y), - } - : null; - - const window = BrowserWindow.getFocusedWindow() ?? mainWindow; - if (!window) return null; - - return new Promise((resolve) => { - const template: MenuItemConstructorOptions[] = []; - let hasInsertedDestructiveSeparator = false; - for (const item of normalizedItems) { - if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { - template.push({ type: "separator" }); - hasInsertedDestructiveSeparator = true; - } - const itemOption: MenuItemConstructorOptions = { - label: item.label, - click: () => resolve(item.id), - }; - if (item.destructive) { - const destructiveIcon = getDestructiveMenuIcon(); - if (destructiveIcon) { - itemOption.icon = destructiveIcon; - } - } - template.push(itemOption); - } - - const menu = Menu.buildFromTemplate(template); - menu.popup({ - window, - ...popupPosition, - callback: () => resolve(null), - }); - }); - }, - ); - ipcMain.removeHandler(OPEN_EXTERNAL_CHANNEL); ipcMain.handle(OPEN_EXTERNAL_CHANNEL, async (_event, rawUrl: unknown) => { const externalUrl = getSafeExternalUrl(rawUrl); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 1e1bb3bd8..72ba4b69e 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -4,7 +4,6 @@ import type { DesktopBridge } from "@t3tools/contracts"; const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; const SET_THEME_CHANNEL = "desktop:set-theme"; -const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; const UPDATE_STATE_CHANNEL = "desktop:update-state"; @@ -18,7 +17,6 @@ contextBridge.exposeInMainWorld("desktopBridge", { pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), - showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position), openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5bb0b84f7..89ae8e161 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1439,6 +1439,7 @@ export default function Sidebar() { onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)} onContextMenu={(event) => { event.preventDefault(); + event.stopPropagation(); void handleProjectContextMenu(project.id, { x: event.clientX, y: event.clientY, @@ -1545,6 +1546,7 @@ export default function Sidebar() { }} onContextMenu={(event) => { event.preventDefault(); + event.stopPropagation(); if ( selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id) diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 63cdef848..010c46ba6 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -1,7 +1,7 @@ import type { ContextMenuItem } from "@t3tools/contracts"; /** - * Imperative DOM-based context menu for non-Electron environments. + * Imperative DOM-based context menu used for browser and desktop renderer surfaces. * Shows a positioned dropdown and returns a promise that resolves * with the clicked item id, or null if dismissed. */ @@ -10,21 +10,22 @@ export function showContextMenuFallback( position?: { x: number; y: number }, ): Promise { return new Promise((resolve) => { - const overlay = document.createElement("div"); - overlay.style.cssText = "position:fixed;inset:0;z-index:9999"; - const menu = document.createElement("div"); menu.className = - "fixed z-[10000] min-w-[140px] rounded-md border border-border bg-popover py-1 shadow-xl animate-in fade-in zoom-in-95"; + "fixed z-[10000] min-w-32 rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg/5"; + menu.style.visibility = "hidden"; const x = position?.x ?? 0; const y = position?.y ?? 0; menu.style.top = `${y}px`; menu.style.left = `${x}px`; + let outsidePressEnabled = false; + let enableOutsidePressFrame = 0; function cleanup(result: T | null) { document.removeEventListener("keydown", onKeyDown); - overlay.remove(); + document.removeEventListener("pointerdown", onPointerDown, true); + cancelAnimationFrame(enableOutsidePressFrame); menu.remove(); resolve(result); } @@ -36,26 +37,51 @@ export function showContextMenuFallback( } } - overlay.addEventListener("mousedown", () => cleanup(null)); + function onPointerDown(event: PointerEvent) { + if (!outsidePressEnabled) return; + const target = event.target; + if (target instanceof Node && menu.contains(target)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + cleanup(null); + } + document.addEventListener("keydown", onKeyDown); + document.addEventListener("pointerdown", onPointerDown, true); + let hasInsertedDestructiveSeparator = false; for (const item of items) { + const isDestructiveAction = item.destructive === true || item.id === "delete"; + if (isDestructiveAction && !hasInsertedDestructiveSeparator && menu.childElementCount > 0) { + const separator = document.createElement("div"); + separator.className = "mx-2 my-1 h-px bg-border"; + menu.appendChild(separator); + hasInsertedDestructiveSeparator = true; + } + const btn = document.createElement("button"); btn.type = "button"; btn.textContent = item.label; - const isDestructiveAction = item.destructive === true || item.id === "delete"; + btn.style.appearance = "none"; + btn.style.setProperty("-webkit-appearance", "none"); + btn.style.border = "0"; + btn.style.background = "transparent"; + btn.style.boxShadow = "none"; + btn.style.outline = "none"; + btn.style.font = "inherit"; + btn.style.margin = "0"; btn.className = isDestructiveAction - ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default" - : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default"; + ? "flex min-h-8 w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1 text-left text-base text-destructive outline-none hover:bg-accent hover:text-accent-foreground sm:min-h-7 sm:text-sm" + : "flex min-h-8 w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1 text-left text-base text-foreground outline-none hover:bg-accent hover:text-accent-foreground sm:min-h-7 sm:text-sm"; btn.addEventListener("click", () => cleanup(item.id)); menu.appendChild(btn); } - document.body.appendChild(overlay); document.body.appendChild(menu); - - // Adjust if menu overflows viewport - requestAnimationFrame(() => { + // Position the menu before revealing it so edge clamping does not cause a visible jump. + enableOutsidePressFrame = requestAnimationFrame(() => { const rect = menu.getBoundingClientRect(); if (rect.right > window.innerWidth) { menu.style.left = `${window.innerWidth - rect.width - 4}px`; @@ -63,6 +89,9 @@ export function showContextMenuFallback( if (rect.bottom > window.innerHeight) { menu.style.top = `${window.innerHeight - rect.height - 4}px`; } + menu.classList.add("animate-in", "fade-in", "zoom-in-95"); + menu.style.visibility = "visible"; + outsidePressEnabled = true; }); }); } diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 2323380da..89e8078db 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -336,14 +336,12 @@ describe("wsNativeApi", () => { }); }); - it("forwards context menu metadata to desktop bridge", async () => { - const showContextMenu = vi.fn().mockResolvedValue("delete"); + it("uses fallback context menu even when desktop bridge is available", async () => { + showContextMenuFallbackMock.mockResolvedValue("delete"); Object.defineProperty(getWindowForTest(), "desktopBridge", { configurable: true, writable: true, - value: { - showContextMenu, - }, + value: {}, }); const { createWsNativeApi } = await import("./wsNativeApi"); @@ -356,7 +354,7 @@ describe("wsNativeApi", () => { { x: 200, y: 300 }, ); - expect(showContextMenu).toHaveBeenCalledWith( + expect(showContextMenuFallbackMock).toHaveBeenCalledWith( [ { id: "rename", label: "Rename thread" }, { id: "delete", label: "Delete", destructive: true }, diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index ddfffbde6..314f60806 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -151,9 +151,9 @@ export function createWsNativeApi(): NativeApi { items: readonly ContextMenuItem[], position?: { x: number; y: number }, ): Promise => { - if (window.desktopBridge) { - return window.desktopBridge.showContextMenu(items, position) as Promise; - } + // Use the in-app menu on desktop too. Native Electron menus render an + // additional composited surface in the frameless window that clashes + // with the app UI and cannot be styled away from the renderer. return showContextMenuFallback(items, position); }, }, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b9127fb17..245f1ca90 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -99,10 +99,6 @@ export interface DesktopBridge { pickFolder: () => Promise; confirm: (message: string) => Promise; setTheme: (theme: DesktopTheme) => Promise; - showContextMenu: ( - items: readonly ContextMenuItem[], - position?: { x: number; y: number }, - ) => Promise; openExternal: (url: string) => Promise; onMenuAction: (listener: (action: string) => void) => () => void; getUpdateState: () => Promise;