desktop: show native menus where possible

This commit is contained in:
Abdullah Atta
2025-05-02 10:10:13 +05:00
parent 916b6a17aa
commit 716bb67b96
8 changed files with 7153 additions and 888 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,8 @@
"dependencies": {
"@lingui/core": "5.1.2",
"@notesnook/intl": "file:../../packages/intl",
"@notesnook/ui": "file:../../packages/ui",
"@resvg/resvg-js": "^2.6.2",
"@trpc/client": "10.45.2",
"@trpc/server": "10.45.2",
"better-sqlite3-multiple-ciphers": "11.9.1",
@@ -63,7 +65,7 @@
"staging": "node scripts/build.mjs --run",
"release": "node scripts/build.mjs",
"build": "node ../../scripts/build.mjs",
"bundle": "esbuild electron=./src/main.ts ./src/preload.ts --external:electron --external:fsevents --external:better-sqlite3-multiple-ciphers --external:sodium-native --bundle --outdir=./build --platform=node --tsconfig=tsconfig.json --define:MAC_APP_STORE=false --define:RELEASE=true",
"bundle": "esbuild electron=./src/main.ts ./src/preload.ts --external:electron --external:fsevents --external:better-sqlite3-multiple-ciphers --external:sodium-native --external:@resvg/resvg-js --bundle --outdir=./build --platform=node --tsconfig=tsconfig.json --define:MAC_APP_STORE=false --define:RELEASE=true",
"bundle:mas": "esbuild electron=./src/main.ts ./src/preload.ts --minify --external:electron --external:fsevents --bundle --outdir=./build --platform=node --tsconfig=tsconfig.json --define:MAC_APP_STORE=true --define:RELEASE=true",
"postinstall": "patch-package",
"test": "vitest run"

View File

@@ -19,7 +19,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { initTRPC } from "@trpc/server";
import { z } from "zod";
import { app, dialog, nativeTheme, Notification, shell } from "electron";
import {
app,
dialog,
Menu,
MenuItem,
nativeImage,
nativeTheme,
Notification,
shell
} from "electron";
import { AutoLaunch } from "../utils/autolaunch";
import { config, DesktopIntegration } from "../utils/config";
import { bringToFront } from "../utils/bring-to-front";
@@ -33,6 +42,8 @@ import { isFlatpak, isSnap } from "../utils";
import { setupDesktopIntegration } from "../utils/desktop-integration";
import { rm } from "fs/promises";
import { disableCustomDns, enableCustomDns } from "../utils/custom-dns";
import type { MenuItem as NNMenuItem } from "@notesnook/ui";
import { Resvg } from "@resvg/resvg-js";
const t = initTRPC.create();
@@ -225,5 +236,99 @@ export const osIntegrationRouter = t.router({
nativeTheme.off("updated", updated);
};
})
),
showMenu: t.procedure
.input(
z.object({
menuItems: z.array(z.any()),
menuIconColor: z.string()
})
)
.subscription(({ input: { menuItems, menuIconColor } }) =>
observable<string[]>((emit) => {
const items = menuItems as NNMenuItem[];
const menu = new Menu();
for (const item of items) {
const menuItem = toMenuItem(
item,
(id) => emit.next(id),
menuIconColor
);
if (menuItem) menu.append(menuItem);
}
if (menu.items.length > 0) menu.popup();
return () => {
menu.removeAllListeners();
menu.closePopup();
};
})
)
});
function toMenuItem(
item: NNMenuItem,
onClick: (id: string[]) => void,
menuIconColor: string,
parentKey?: string
): MenuItem | undefined {
switch (item.type) {
case "lazy-loader":
return undefined;
case "separator":
return new MenuItem({ type: "separator" });
case "button": {
const submenu = item.menu ? new Menu() : undefined;
if (submenu && item.menu) {
for (const subitem of item.menu.items) {
const subMenuItem = toMenuItem(
subitem,
onClick,
menuIconColor,
item.key
);
if (subMenuItem) submenu.append(subMenuItem);
}
}
return new MenuItem({
label: item.title,
enabled: !item.isDisabled,
visible: !item.isHidden,
toolTip: item.tooltip,
sublabel: item.tooltip,
checked: item.isChecked,
type: submenu ? "submenu" : item.isChecked ? "checkbox" : "normal",
id: item.key,
icon: item.icon
? svgPathToPng(
item.icon,
(item.styles?.icon?.color as string | undefined) || menuIconColor
)
: undefined,
submenu,
click: () => onClick(parentKey ? [parentKey, item.key] : [item.key]),
accelerator: item.modifier?.replace("Mod", "CommandOrControl")
});
}
}
}
function svgPathToPng(path: string, color?: string) {
const svg = Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="presentation" class="icon" style="stroke-width: 0px; stroke: ${
color || config.windowControlsIconColor
}; width: 14px; height: 14px;"><path d="${path}" style="fill: ${
color || config.windowControlsIconColor
};"></path></svg>`
);
const resvg = new Resvg(svg, {
fitTo: { mode: "width", value: 14 },
logLevel: "error",
font: {
loadSystemFonts: false
}
});
const pngData = resvg.render();
return nativeImage.createFromBuffer(pngData.asPng());
}

View File

@@ -143,16 +143,44 @@ function setupMenu() {
})
);
if (params.isEditable)
if (params.isEditable) {
menu.append(
new MenuItem({
label: strings.paste(),
role: "pasteAndMatchStyle",
role: "paste",
enabled: clipboard.readText("clipboard").length > 0,
accelerator: "CommandOrControl+V"
})
);
menu.append(
new MenuItem({
label:
process.platform === "darwin"
? strings.pasteAndMatchStyle()
: strings.pasteWithoutFormatting(),
role: "pasteAndMatchStyle",
enabled: clipboard.readText("clipboard").length > 0,
accelerator:
process.platform === "darwin"
? "Option+Shift+Command+V"
: "Shift+CommandOrControl+V"
})
);
menu.append(
new MenuItem({
type: "separator"
})
);
menu.append(
new MenuItem({
label: strings.spellCheck(),
role: "toggleSpellChecker"
})
);
}
if (menu.items.length > 0) menu.popup();
});
}

View File

@@ -20,7 +20,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { create } from "zustand";
import { shallow } from "zustand/shallow";
import { MenuItem, PositionOptions } from "@notesnook/ui";
// import { isUserPremium } from "./use-is-user-premium";
import { desktop } from "../common/desktop-bridge";
import { useThemeEngineStore } from "@notesnook/theme";
type MenuOptions = {
position?: PositionOptions;
@@ -43,7 +44,28 @@ const useMenuStore = create<MenuStore>((set) => ({
options: {
blocking: false
},
open: (items, options) => set(() => ({ isOpen: true, items, options })),
open: async (items, options) => {
if (IS_DESKTOP_APP && canShowNativeMenu(items)) {
const serializedItems = await resolveMenuItems(items);
const scopes = useThemeEngineStore.getState().theme.scopes;
const menuIconColor =
scopes.contextMenu?.primary?.icon || scopes.base?.primary?.icon;
desktop?.integration.showMenu.subscribe(
{
menuItems: JSON.parse(JSON.stringify(serializedItems)),
menuIconColor
},
{
onData(ids) {
findAndCallAction(serializedItems, ids);
}
}
);
set(() => ({ options }));
} else {
set(() => ({ isOpen: true, items, options }));
}
},
close: () =>
set(() => ({
isOpen: false,
@@ -84,3 +106,35 @@ export function useMenu() {
]);
return { items, options };
}
async function resolveMenuItems(items: MenuItem[]): Promise<MenuItem[]> {
const serialized = [];
for (const item of items) {
if (item.type === "lazy-loader")
serialized.push(...(await resolveMenuItems(await item.items())));
else if (item.type === "button") {
if (item.menu) item.menu.items = await resolveMenuItems(item.menu.items);
serialized.push(item);
} else serialized.push(item);
}
return serialized;
}
function findAndCallAction(items: MenuItem[], ids: string[]) {
let _items: MenuItem[] = items;
const actionId = ids.at(-1);
for (const id of ids) {
const item = _items.find((item) => item.key === id);
if (!item || item?.type !== "button") continue;
console.log(item);
if (id === actionId) {
item?.onClick?.();
} else {
_items = item.menu?.items || [];
}
}
}
function canShowNativeMenu(items: MenuItem[]) {
return items.every((item) => item.type !== "popup");
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2231,6 +2231,8 @@ Use this if changes from other devices are not appearing on this device. This wi
copyLinkText: () => t`Copy link text`,
copyImage: () => t`Copy image`,
paste: () => t`Paste`,
pasteAndMatchStyle: () => t`Paste and match style`,
pasteWithoutFormatting: () => t`Paste without formatting`,
configure: () => t`Configure`,
usingOfficialInstance: () => t`Using official Notesnook instance`,
usingInstance: (instance: string, version: string) =>