feat(electron): add global context menu (#13218)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added automatic synchronization of language settings between the
desktop app and the system environment.
* Context menu actions (Cut, Copy, Paste) in the desktop app are now
localized according to the selected language.

* **Improvements**
* Context menu is always available with standard editing actions,
regardless of spell check settings.

* **Localization**
* Added translations for "Cut", "Copy", and "Paste" in the context menu.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
EYHN
2025-07-16 12:37:38 +08:00
committed by GitHub
parent 45b05f06b3
commit d44771dfe9
11 changed files with 138 additions and 74 deletions

View File

@@ -10,6 +10,7 @@ import { Suspense } from 'react';
import { RouterProvider } from 'react-router-dom';
import { setupEffects } from './effects';
import { DesktopLanguageSync } from './language-sync';
import { DesktopThemeSync } from './theme-sync';
const { frameworkProvider } = setupEffects();
@@ -46,6 +47,7 @@ export function App() {
<I18nProvider>
<AffineContext store={getCurrentStore()}>
<DesktopThemeSync />
<DesktopLanguageSync />
<RouterProvider
fallbackElement={<AppContainer fallback />}
router={router}

View File

@@ -0,0 +1,18 @@
import { DesktopApiService } from '@affine/core/modules/desktop-api';
import { I18nService } from '@affine/core/modules/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect } from 'react';
export const DesktopLanguageSync = () => {
const i18nService = useService(I18nService);
const currentLanguage = useLiveData(i18nService.i18n.currentLanguageKey$);
const handler = useService(DesktopApiService).api.handler;
useEffect(() => {
handler.i18n.changeLanguage(currentLanguage ?? 'en').catch(err => {
console.error(err);
});
}, [currentLanguage, handler]);
return null;
};

View File

@@ -33,6 +33,7 @@
},
"devDependencies": {
"@affine-tools/utils": "workspace:*",
"@affine/i18n": "workspace:*",
"@affine/native": "workspace:*",
"@affine/nbstore": "workspace:*",
"@electron-forge/cli": "^7.6.0",

View File

@@ -1,3 +1,4 @@
import { I18n } from '@affine/i18n';
import { ipcMain } from 'electron';
import { AFFINE_API_CHANNEL_NAME } from '../shared/type';
@@ -21,6 +22,12 @@ export const debugHandlers = {
},
};
export const i18nHandlers = {
changeLanguage: async (_: Electron.IpcMainInvokeEvent, language: string) => {
return I18n.changeLanguage(language);
},
};
// Note: all of these handlers will be the single-source-of-truth for the apis exposed to the renderer process
export const allHandlers = {
debug: debugHandlers,
@@ -33,6 +40,7 @@ export const allHandlers = {
worker: workerHandlers,
recording: recordingHandlers,
popup: popupHandlers,
i18n: i18nHandlers,
};
export const registerHandlers = () => {

View File

@@ -1,5 +1,6 @@
import { join } from 'node:path';
import { I18n } from '@affine/i18n';
import {
app,
BrowserWindow,
@@ -822,42 +823,53 @@ export class WebContentViewsManager {
},
});
if (spellCheckSettings.enabled) {
view.webContents.on('context-menu', (_event, params) => {
const shouldShow =
params.misspelledWord && params.dictionarySuggestions.length > 0;
view.webContents.on('context-menu', (_event, params) => {
const menu = Menu.buildFromTemplate([
{
id: 'cut',
label: I18n['com.affine.context-menu.cut'](),
role: 'cut',
enabled: params.editFlags.canCut,
},
{
id: 'copy',
label: I18n['com.affine.context-menu.copy'](),
role: 'copy',
enabled: params.editFlags.canCopy,
},
{
id: 'paste',
label: I18n['com.affine.context-menu.paste'](),
role: 'paste',
enabled: params.editFlags.canPaste,
},
]);
if (!shouldShow) {
return;
}
const menu = new Menu();
// Add each spelling suggestion
for (const suggestion of params.dictionarySuggestions) {
menu.append(
new MenuItem({
label: suggestion,
click: () => view.webContents.replaceMisspelling(suggestion),
})
);
}
// Add each spelling suggestion
for (const suggestion of params.dictionarySuggestions) {
menu.append(
new MenuItem({
label: suggestion,
click: () => view.webContents.replaceMisspelling(suggestion),
})
);
}
// Allow users to add the misspelled word to the dictionary
if (params.misspelledWord) {
menu.append(
new MenuItem({
label: 'Add to dictionary', // TODO: i18n
click: () =>
view.webContents.session.addWordToSpellCheckerDictionary(
params.misspelledWord
),
})
);
}
// Allow users to add the misspelled word to the dictionary
if (params.misspelledWord) {
menu.append(
new MenuItem({
label: 'Add to dictionary', // TODO: i18n
click: () =>
view.webContents.session.addWordToSpellCheckerDictionary(
params.misspelledWord
),
})
);
}
menu.popup();
});
}
menu.popup();
});
this.webViewsMap$.next(this.tabViewsMap.set(viewId, view));
let unsub = () => {};

View File

@@ -8,6 +8,7 @@
"include": ["./src"],
"references": [
{ "path": "../../../../tools/utils" },
{ "path": "../../i18n" },
{ "path": "../../native" },
{ "path": "../../../common/nbstore" },
{ "path": "../../../common/infra" }

View File

@@ -123,52 +123,57 @@ const WorkbenchView = ({
[tabsHeaderService, workbench.id]
);
const onContextMenu = useAsyncCallback(async () => {
const action = await tabsHeaderService.showContextMenu?.(
workbench.id,
viewIdx
);
switch (action?.type) {
case 'open-in-split-view': {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'openInSplitView',
});
break;
}
case 'separate-view': {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'separateTabs',
});
break;
}
case 'pin-tab': {
if (action.payload.shouldPin) {
const onContextMenu = useAsyncCallback(
async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const action = await tabsHeaderService.showContextMenu?.(
workbench.id,
viewIdx
);
switch (action?.type) {
case 'open-in-split-view': {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'pin',
});
} else {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'unpin',
action: 'openInSplitView',
});
break;
}
break;
case 'separate-view': {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'separateTabs',
});
break;
}
case 'pin-tab': {
if (action.payload.shouldPin) {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'pin',
});
} else {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'unpin',
});
}
break;
}
// fixme: when close tab the view may already be gc'ed
case 'close-tab': {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'close',
});
break;
}
default:
break;
}
// fixme: when close tab the view may already be gc'ed
case 'close-tab': {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'close',
});
break;
}
default:
break;
}
}, [tabsHeaderService, viewIdx, workbench.id]);
},
[tabsHeaderService, viewIdx, workbench.id]
);
const contentNode = useMemo(() => {
return (

View File

@@ -8286,6 +8286,18 @@ export function useAFFiNEI18N(): {
* `Copy link`
*/
["com.affine.comment.copy-link"](): string;
/**
* `Copy`
*/
["com.affine.context-menu.copy"](): string;
/**
* `Paste`
*/
["com.affine.context-menu.paste"](): string;
/**
* `Cut`
*/
["com.affine.context-menu.cut"](): string;
/**
* `An internal error occurred.`
*/

View File

@@ -2079,6 +2079,9 @@
"com.affine.comment.filter.only-current-mode": "Only current mode",
"com.affine.comment.reply": "Reply",
"com.affine.comment.copy-link": "Copy link",
"com.affine.context-menu.copy": "Copy",
"com.affine.context-menu.paste": "Paste",
"com.affine.context-menu.cut": "Cut",
"error.INTERNAL_SERVER_ERROR": "An internal error occurred.",
"error.NETWORK_ERROR": "Network error.",
"error.TOO_MANY_REQUEST": "Too many requests.",