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:
@@ -10,6 +10,7 @@ import { Suspense } from 'react';
|
|||||||
import { RouterProvider } from 'react-router-dom';
|
import { RouterProvider } from 'react-router-dom';
|
||||||
|
|
||||||
import { setupEffects } from './effects';
|
import { setupEffects } from './effects';
|
||||||
|
import { DesktopLanguageSync } from './language-sync';
|
||||||
import { DesktopThemeSync } from './theme-sync';
|
import { DesktopThemeSync } from './theme-sync';
|
||||||
|
|
||||||
const { frameworkProvider } = setupEffects();
|
const { frameworkProvider } = setupEffects();
|
||||||
@@ -46,6 +47,7 @@ export function App() {
|
|||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<AffineContext store={getCurrentStore()}>
|
<AffineContext store={getCurrentStore()}>
|
||||||
<DesktopThemeSync />
|
<DesktopThemeSync />
|
||||||
|
<DesktopLanguageSync />
|
||||||
<RouterProvider
|
<RouterProvider
|
||||||
fallbackElement={<AppContainer fallback />}
|
fallbackElement={<AppContainer fallback />}
|
||||||
router={router}
|
router={router}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@affine-tools/utils": "workspace:*",
|
"@affine-tools/utils": "workspace:*",
|
||||||
|
"@affine/i18n": "workspace:*",
|
||||||
"@affine/native": "workspace:*",
|
"@affine/native": "workspace:*",
|
||||||
"@affine/nbstore": "workspace:*",
|
"@affine/nbstore": "workspace:*",
|
||||||
"@electron-forge/cli": "^7.6.0",
|
"@electron-forge/cli": "^7.6.0",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { I18n } from '@affine/i18n';
|
||||||
import { ipcMain } from 'electron';
|
import { ipcMain } from 'electron';
|
||||||
|
|
||||||
import { AFFINE_API_CHANNEL_NAME } from '../shared/type';
|
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
|
// Note: all of these handlers will be the single-source-of-truth for the apis exposed to the renderer process
|
||||||
export const allHandlers = {
|
export const allHandlers = {
|
||||||
debug: debugHandlers,
|
debug: debugHandlers,
|
||||||
@@ -33,6 +40,7 @@ export const allHandlers = {
|
|||||||
worker: workerHandlers,
|
worker: workerHandlers,
|
||||||
recording: recordingHandlers,
|
recording: recordingHandlers,
|
||||||
popup: popupHandlers,
|
popup: popupHandlers,
|
||||||
|
i18n: i18nHandlers,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const registerHandlers = () => {
|
export const registerHandlers = () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { I18n } from '@affine/i18n';
|
||||||
import {
|
import {
|
||||||
app,
|
app,
|
||||||
BrowserWindow,
|
BrowserWindow,
|
||||||
@@ -822,42 +823,53 @@ export class WebContentViewsManager {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (spellCheckSettings.enabled) {
|
view.webContents.on('context-menu', (_event, params) => {
|
||||||
view.webContents.on('context-menu', (_event, params) => {
|
const menu = Menu.buildFromTemplate([
|
||||||
const shouldShow =
|
{
|
||||||
params.misspelledWord && params.dictionarySuggestions.length > 0;
|
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) {
|
// Add each spelling suggestion
|
||||||
return;
|
for (const suggestion of params.dictionarySuggestions) {
|
||||||
}
|
menu.append(
|
||||||
const menu = new Menu();
|
new MenuItem({
|
||||||
|
label: suggestion,
|
||||||
|
click: () => view.webContents.replaceMisspelling(suggestion),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Add each spelling suggestion
|
// Allow users to add the misspelled word to the dictionary
|
||||||
for (const suggestion of params.dictionarySuggestions) {
|
if (params.misspelledWord) {
|
||||||
menu.append(
|
menu.append(
|
||||||
new MenuItem({
|
new MenuItem({
|
||||||
label: suggestion,
|
label: 'Add to dictionary', // TODO: i18n
|
||||||
click: () => view.webContents.replaceMisspelling(suggestion),
|
click: () =>
|
||||||
})
|
view.webContents.session.addWordToSpellCheckerDictionary(
|
||||||
);
|
params.misspelledWord
|
||||||
}
|
),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Allow users to add the misspelled word to the dictionary
|
menu.popup();
|
||||||
if (params.misspelledWord) {
|
});
|
||||||
menu.append(
|
|
||||||
new MenuItem({
|
|
||||||
label: 'Add to dictionary', // TODO: i18n
|
|
||||||
click: () =>
|
|
||||||
view.webContents.session.addWordToSpellCheckerDictionary(
|
|
||||||
params.misspelledWord
|
|
||||||
),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
menu.popup();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.webViewsMap$.next(this.tabViewsMap.set(viewId, view));
|
this.webViewsMap$.next(this.tabViewsMap.set(viewId, view));
|
||||||
let unsub = () => {};
|
let unsub = () => {};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"include": ["./src"],
|
"include": ["./src"],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "../../../../tools/utils" },
|
{ "path": "../../../../tools/utils" },
|
||||||
|
{ "path": "../../i18n" },
|
||||||
{ "path": "../../native" },
|
{ "path": "../../native" },
|
||||||
{ "path": "../../../common/nbstore" },
|
{ "path": "../../../common/nbstore" },
|
||||||
{ "path": "../../../common/infra" }
|
{ "path": "../../../common/infra" }
|
||||||
|
|||||||
@@ -123,52 +123,57 @@ const WorkbenchView = ({
|
|||||||
[tabsHeaderService, workbench.id]
|
[tabsHeaderService, workbench.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onContextMenu = useAsyncCallback(async () => {
|
const onContextMenu = useAsyncCallback(
|
||||||
const action = await tabsHeaderService.showContextMenu?.(
|
async (e: React.MouseEvent) => {
|
||||||
workbench.id,
|
e.preventDefault();
|
||||||
viewIdx
|
e.stopPropagation();
|
||||||
);
|
const action = await tabsHeaderService.showContextMenu?.(
|
||||||
switch (action?.type) {
|
workbench.id,
|
||||||
case 'open-in-split-view': {
|
viewIdx
|
||||||
track.$.appTabsHeader.$.tabAction({
|
);
|
||||||
control: 'contextMenu',
|
switch (action?.type) {
|
||||||
action: 'openInSplitView',
|
case 'open-in-split-view': {
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'separate-view': {
|
|
||||||
track.$.appTabsHeader.$.tabAction({
|
|
||||||
control: 'contextMenu',
|
|
||||||
action: 'separateTabs',
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'pin-tab': {
|
|
||||||
if (action.payload.shouldPin) {
|
|
||||||
track.$.appTabsHeader.$.tabAction({
|
track.$.appTabsHeader.$.tabAction({
|
||||||
control: 'contextMenu',
|
control: 'contextMenu',
|
||||||
action: 'pin',
|
action: 'openInSplitView',
|
||||||
});
|
|
||||||
} else {
|
|
||||||
track.$.appTabsHeader.$.tabAction({
|
|
||||||
control: 'contextMenu',
|
|
||||||
action: 'unpin',
|
|
||||||
});
|
});
|
||||||
|
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': {
|
[tabsHeaderService, viewIdx, workbench.id]
|
||||||
track.$.appTabsHeader.$.tabAction({
|
);
|
||||||
control: 'contextMenu',
|
|
||||||
action: 'close',
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}, [tabsHeaderService, viewIdx, workbench.id]);
|
|
||||||
|
|
||||||
const contentNode = useMemo(() => {
|
const contentNode = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8286,6 +8286,18 @@ export function useAFFiNEI18N(): {
|
|||||||
* `Copy link`
|
* `Copy link`
|
||||||
*/
|
*/
|
||||||
["com.affine.comment.copy-link"](): string;
|
["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.`
|
* `An internal error occurred.`
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2079,6 +2079,9 @@
|
|||||||
"com.affine.comment.filter.only-current-mode": "Only current mode",
|
"com.affine.comment.filter.only-current-mode": "Only current mode",
|
||||||
"com.affine.comment.reply": "Reply",
|
"com.affine.comment.reply": "Reply",
|
||||||
"com.affine.comment.copy-link": "Copy link",
|
"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.INTERNAL_SERVER_ERROR": "An internal error occurred.",
|
||||||
"error.NETWORK_ERROR": "Network error.",
|
"error.NETWORK_ERROR": "Network error.",
|
||||||
"error.TOO_MANY_REQUEST": "Too many requests.",
|
"error.TOO_MANY_REQUEST": "Too many requests.",
|
||||||
|
|||||||
@@ -1257,6 +1257,7 @@ export const PackageList = [
|
|||||||
name: '@affine/electron',
|
name: '@affine/electron',
|
||||||
workspaceDependencies: [
|
workspaceDependencies: [
|
||||||
'tools/utils',
|
'tools/utils',
|
||||||
|
'packages/frontend/i18n',
|
||||||
'packages/frontend/native',
|
'packages/frontend/native',
|
||||||
'packages/common/nbstore',
|
'packages/common/nbstore',
|
||||||
'packages/common/infra',
|
'packages/common/infra',
|
||||||
|
|||||||
@@ -552,6 +552,7 @@ __metadata:
|
|||||||
resolution: "@affine/electron@workspace:packages/frontend/apps/electron"
|
resolution: "@affine/electron@workspace:packages/frontend/apps/electron"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@affine-tools/utils": "workspace:*"
|
"@affine-tools/utils": "workspace:*"
|
||||||
|
"@affine/i18n": "workspace:*"
|
||||||
"@affine/native": "workspace:*"
|
"@affine/native": "workspace:*"
|
||||||
"@affine/nbstore": "workspace:*"
|
"@affine/nbstore": "workspace:*"
|
||||||
"@electron-forge/cli": "npm:^7.6.0"
|
"@electron-forge/cli": "npm:^7.6.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user