mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-15 05:37:32 +00:00
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 { 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}
|
||||
|
||||
@@ -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": {
|
||||
"@affine-tools/utils": "workspace:*",
|
||||
"@affine/i18n": "workspace:*",
|
||||
"@affine/native": "workspace:*",
|
||||
"@affine/nbstore": "workspace:*",
|
||||
"@electron-forge/cli": "^7.6.0",
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 = () => {};
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../../../../tools/utils" },
|
||||
{ "path": "../../i18n" },
|
||||
{ "path": "../../native" },
|
||||
{ "path": "../../../common/nbstore" },
|
||||
{ "path": "../../../common/infra" }
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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.`
|
||||
*/
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user