Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon";
export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files";
export const SET_THEME_CHANNEL = "desktop:set-theme";
export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
export const WINDOW_CONTEXT_MENU_CHANNEL = "desktop:window-context-menu";
export const WINDOW_CONTEXT_MENU_RESULT_CHANNEL = "desktop:window-context-menu-result";
export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings";
export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors";
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,19 @@ contextBridge.exposeInMainWorld("desktopBridge", {
items,
...(position === undefined ? {} : { position }),
}),
onWindowContextMenu: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => {
if (typeof request !== "object" || request === null) return;
listener(request as Parameters<typeof listener>[0]);
};

ipcRenderer.on(IpcChannels.WINDOW_CONTEXT_MENU_CHANNEL, wrappedListener);
return () => {
ipcRenderer.removeListener(IpcChannels.WINDOW_CONTEXT_MENU_CHANNEL, wrappedListener);
};
},
resolveWindowContextMenu: (requestId, itemId) =>
ipcRenderer.send(IpcChannels.WINDOW_CONTEXT_MENU_RESULT_CHANNEL, { requestId, itemId }),
receiveProviderAuthCallback: (url: string) =>
ipcRenderer.invoke(IpcChannels.RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL, url),
cancelProviderAuthCallback: (url: string) =>
Expand Down
93 changes: 48 additions & 45 deletions apps/desktop/src/window/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
SNAP_SHOT_EVENT_CHANNEL,
TRACKPAD_SCROLL_BEGIN_CHANNEL,
TRACKPAD_SCROLL_END_CHANNEL,
WINDOW_CONTEXT_MENU_CHANNEL,
WINDOW_CONTEXT_MENU_RESULT_CHANNEL,
WINDOW_FULLSCREEN_STATE_CHANNEL,
} from "../ipc/channels.ts";
import { PASSKEY_BRIDGE_ARGUMENT } from "../preview/GuestProtocol.ts";
Expand All @@ -36,6 +38,12 @@ import * as ElectronApp from "../electron/ElectronApp.ts";
import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";
import { makeQuitShortcutHandler } from "./QuitHold.ts";
import { copyContextMenuImage } from "./ContextMenuImage.ts";
import {
buildWindowContextMenu,
makeInPageContextMenus,
toNativeContextMenuTemplate,
type WindowContextMenuAction,
} from "./WindowContextMenu.ts";

const TITLEBAR_HEIGHT = 40;
// Match --workspace-topbar-height in apps/web/src/index.css; native macOS buttons stay 14 points at any zoom.
Expand Down Expand Up @@ -531,66 +539,61 @@ export const make = Effect.gen(function* () {
): void => {
if (contextMenuContents.has(contents)) return;
contextMenuContents.add(contents);
// Native menus ignore the app theme. macOS keeps them; elsewhere the app's own page draws
// the menu. Guests and popups have no app page to draw in, so they stay native.
const inPageMenus =
environment.platform !== "darwin" && contents === window.webContents
? makeInPageContextMenus((request) => {
if (!contents.isDestroyed()) contents.send(WINDOW_CONTEXT_MENU_CHANNEL, request);
})
: null;
if (inPageMenus) {
contents.ipc.on(WINDOW_CONTEXT_MENU_RESULT_CHANNEL, (_event, result: unknown) =>
inPageMenus.resolve(result),
);
}
contents.on("context-menu", (event, params) => {
event.preventDefault();
if (contents.isDestroyed() || ownerWindow.isDestroyed()) return;
// Right-clicking a guest may leave native editing roles targeting the host renderer.
contents.focus();

const menuTemplate: Electron.MenuItemConstructorOptions[] = [];

if (params.misspelledWord) {
for (const suggestion of params.dictionarySuggestions.slice(0, 5)) {
menuTemplate.push({
label: suggestion,
click: () => {
if (!contents.isDestroyed()) contents.replaceMisspelling(suggestion);
},
});
}
if (params.dictionarySuggestions.length === 0) {
menuTemplate.push({ label: "No suggestions", enabled: false });
}
menuTemplate.push({ type: "separator" });
}

if (Option.isSome(ElectronShell.parseSafeExternalUrl(params.linkURL))) {
menuTemplate.push(
{
label: "Copy Link",
click: () => {
void runPromise(electronShell.copyText(params.linkURL));
},
},
{ type: "separator" },
);
}

if (params.mediaType === "image") {
menuTemplate.push({
label: "Copy Image",
click: () => {
const entries = buildWindowContextMenu(
params,
Option.isSome(ElectronShell.parseSafeExternalUrl(params.linkURL)),
);
const run = (action: WindowContextMenuAction) => {
if (contents.isDestroyed()) return;
switch (action.type) {
case "replace-misspelling":
contents.replaceMisspelling(action.word);
return;
case "copy-link":
void runPromise(electronShell.copyText(params.linkURL));
return;
case "copy-image":
void runPromise(
Effect.tryPromise(() => copyContextMenuImage(contents, params)).pipe(
Effect.catch(() => logWindowWarning("failed to copy context-menu image")),
),
);
},
});
menuTemplate.push({ type: "separator" });
}

menuTemplate.push(
{ role: "cut", enabled: params.editFlags.canCut },
{ role: "copy", enabled: params.editFlags.canCopy },
{ role: "paste", enabled: params.editFlags.canPaste },
{ role: "selectAll", enabled: params.editFlags.canSelectAll },
);
return;
case "edit":
contents[action.role]();
return;
}
};

if (inPageMenus) {
// Electron reports window points; the page positions in CSS pixels.
const zoomFactor = contents.getZoomFactor();
inPageMenus.show(entries, { x: params.x / zoomFactor, y: params.y / zoomFactor }, run);
return;
}
void runPromise(
electronMenu.popupTemplate({
window: ownerWindow,
template: menuTemplate,
template: toNativeContextMenuTemplate(entries, run),
...(params.frame ? { frame: params.frame } : {}),
}),
);
Expand Down
78 changes: 78 additions & 0 deletions apps/desktop/src/window/WindowContextMenu.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { DesktopWindowContextMenuRequest } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import {
buildWindowContextMenu,
makeInPageContextMenus,
toNativeContextMenuTemplate,
type WindowContextMenuAction,
} from "./WindowContextMenu.ts";

const editFlags = {
canUndo: false,
canRedo: false,
canCut: false,
canCopy: true,
canPaste: false,
canDelete: false,
canSelectAll: true,
canEditRichly: false,
};

const misspelled = buildWindowContextMenu(
{
misspelledWord: "teh",
dictionarySuggestions: ["the", "ten"],
mediaType: "none",
editFlags,
},
true,
);

describe("WindowContextMenu", () => {
it("groups spelling, link, and editing entries with one separator between groups", () => {
expect(misspelled.map((entry) => [entry.label, entry.enabled, entry.separatorBefore])).toEqual([
["the", true, false],
["ten", true, false],
["Copy Link", true, true],
["Cut", false, true],
["Copy", true, false],
["Paste", false, false],
["Select all", true, false],
]);
expect(toNativeContextMenuTemplate(misspelled, () => {}).slice(2, 5)).toMatchObject([
{ type: "separator" },
{ label: "Copy Link" },
{ type: "separator" },
]);
});

it("runs the picked entry once, and ignores disabled, stale, and unknown answers", () => {
const sent: DesktopWindowContextMenuRequest[] = [];
const ran: WindowContextMenuAction[] = [];
const menus = makeInPageContextMenus((request) => sent.push(request));
const run = (action: WindowContextMenuAction) => ran.push(action);

menus.show(misspelled, { x: 10, y: 20 }, run);
menus.show(misspelled, { x: 10, y: 20 }, run);
const [stale, current] = sent;
expect(current?.items[3]).toEqual({
id: "3",
label: "Cut",
shortcut: "Ctrl+X",
disabled: true,
separatorBefore: true,
});

menus.resolve({ requestId: stale?.requestId, itemId: "0" });
menus.resolve({ requestId: current?.requestId, itemId: "99" });
expect(ran).toEqual([]);

menus.show(misspelled, { x: 10, y: 20 }, run);
menus.resolve({ requestId: sent[2]?.requestId, itemId: "3" });
menus.show(misspelled, { x: 10, y: 20 }, run);
menus.resolve({ requestId: sent[3]?.requestId, itemId: "0" });
menus.resolve({ requestId: sent[3]?.requestId, itemId: "0" });
expect(ran).toEqual([{ type: "replace-misspelling", word: "the" }]);
});
});
131 changes: 131 additions & 0 deletions apps/desktop/src/window/WindowContextMenu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import type { ContextMenuItem, DesktopWindowContextMenuRequest } from "@t3tools/contracts";
import type * as Electron from "electron";

type EditRole = "cut" | "copy" | "paste" | "selectAll";

export type WindowContextMenuAction =
| { readonly type: "replace-misspelling"; readonly word: string }
| { readonly type: "copy-link" }
| { readonly type: "copy-image" }
| { readonly type: "edit"; readonly role: EditRole };

export interface WindowContextMenuEntry {
readonly label: string;
readonly enabled: boolean;
readonly separatorBefore: boolean;
readonly action: WindowContextMenuAction | null;
}

type MenuParams = Pick<
Electron.ContextMenuParams,
"misspelledWord" | "dictionarySuggestions" | "mediaType" | "editFlags"
>;

const EDIT_ROLES = ["cut", "copy", "paste", "selectAll"] as const;
// Shortcuts are only drawn by the page, which happens on Windows and Linux, so they use Ctrl.
const EDIT_ITEMS = {
cut: { label: "Cut", flag: "canCut", shortcut: "Ctrl+X" },
copy: { label: "Copy", flag: "canCopy", shortcut: "Ctrl+C" },
paste: { label: "Paste", flag: "canPaste", shortcut: "Ctrl+V" },
selectAll: { label: "Select all", flag: "canSelectAll", shortcut: "Ctrl+A" },
} as const satisfies Record<
EditRole,
{ label: string; flag: keyof Electron.EditFlags; shortcut: string }
>;

/** The menu for a right-click no page handler claimed: spelling, link, image, then editing. */
export function buildWindowContextMenu(
params: MenuParams,
hasSafeLink: boolean,
): WindowContextMenuEntry[] {
const entries: WindowContextMenuEntry[] = [];
const addSection = (section: ReadonlyArray<Omit<WindowContextMenuEntry, "separatorBefore">>) => {
for (const [index, entry] of section.entries()) {
entries.push({ ...entry, separatorBefore: index === 0 && entries.length > 0 });
}
};

if (params.misspelledWord) {
const suggestions = params.dictionarySuggestions.slice(0, 5);
addSection(
suggestions.length > 0
? suggestions.map((word) => ({
label: word,
enabled: true,
action: { type: "replace-misspelling", word } as const,
}))
: [{ label: "No suggestions", enabled: false, action: null }],
);
}
if (hasSafeLink) {
addSection([{ label: "Copy Link", enabled: true, action: { type: "copy-link" } }]);
}
if (params.mediaType === "image") {
addSection([{ label: "Copy Image", enabled: true, action: { type: "copy-image" } }]);
}
addSection(
EDIT_ROLES.map((role) => ({
label: EDIT_ITEMS[role].label,
enabled: params.editFlags[EDIT_ITEMS[role].flag],
action: { type: "edit", role } as const,
})),
);
return entries;
}

/** Editing entries keep their native roles, so the OS supplies their labels and shortcuts. */
export function toNativeContextMenuTemplate(
entries: readonly WindowContextMenuEntry[],
run: (action: WindowContextMenuAction) => void,
): Electron.MenuItemConstructorOptions[] {
return entries.flatMap(({ label, enabled, separatorBefore, action }) => {
const item: Electron.MenuItemConstructorOptions =
action?.type === "edit"
? { role: action.role, enabled }
: { label, enabled, ...(action ? { click: () => run(action) } : {}) };
return separatorBefore ? [{ type: "separator" as const }, item] : [item];
});
}

/**
* Hands menus to the page to draw and runs the entry it picks. Each new menu replaces the
* previous one, so a late answer for an older menu does nothing.
*/
export function makeInPageContextMenus(send: (request: DesktopWindowContextMenuRequest) => void) {
let lastRequestId = 0;
let pending: {
readonly requestId: number;
readonly entries: readonly WindowContextMenuEntry[];
readonly run: (action: WindowContextMenuAction) => void;
} | null = null;

return {
show: (
entries: readonly WindowContextMenuEntry[],
position: DesktopWindowContextMenuRequest["position"],
run: (action: WindowContextMenuAction) => void,
) => {
lastRequestId += 1;
pending = { requestId: lastRequestId, entries, run };
const items: ContextMenuItem[] = entries.map((entry, index) => ({
id: String(index),
label: entry.label,
...(entry.action?.type === "edit"
? { shortcut: EDIT_ITEMS[entry.action.role].shortcut }
: {}),
...(entry.enabled ? {} : { disabled: true }),
...(entry.separatorBefore ? { separatorBefore: true } : {}),
}));
send({ requestId: lastRequestId, items, position });
},
resolve: (result: unknown) => {
if (typeof result !== "object" || result === null || pending === null) return;
const { requestId, itemId } = result as { requestId?: unknown; itemId?: unknown };
if (requestId !== pending.requestId) return;
const { entries, run } = pending;
pending = null;
const entry = entries.find((_, index) => String(index) === itemId);
if (entry?.enabled && entry.action) run(entry.action);
},
};
}
2 changes: 2 additions & 0 deletions apps/web/src/appInit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
import { syncBrowserChromeTheme } from "./hooks/useTheme";
import { startThreadVisitedMigration } from "./hooks/useThreadVisitedMigration";
import { installDesktopPasteAsText } from "./lib/desktopPasteAsText";
import { installDesktopWindowContextMenu } from "./lib/desktopWindowContextMenu";
import {
syncDocumentElectronPlatformClasses,
syncDocumentWindowControlsOverlayClass,
Expand Down Expand Up @@ -177,6 +178,7 @@ export function startAppServices(): AppRouter {
}

installDesktopPasteAsText(window.desktopBridge, window);
installDesktopWindowContextMenu(window.desktopBridge);

startClientAppearance();
startDocumentTitle();
Expand Down
Loading
Loading