fix: restore native text context menu

This commit is contained in:
2026-09-07 17:07:57 +08:00
parent 8c13ae76db
commit 813d1ca02e
6 changed files with 355 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
import type { ElectronApplication } from '@playwright/test';
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
type ContextMenuProbe = {
popupCount: number;
template: Array<{
label: string | null;
role: string | null;
type: string;
}>;
};
async function installContextMenuProbe(app: ElectronApplication): Promise<void> {
await app.evaluate(({ Menu }) => {
const probe: ContextMenuProbe = {
popupCount: 0,
template: [],
};
const globalWithProbe = globalThis as typeof globalThis & {
__makeloreContextMenuProbe?: ContextMenuProbe;
};
globalWithProbe.__makeloreContextMenuProbe = probe;
Menu.buildFromTemplate = ((template) => {
probe.template = template.map((item) => ({
label: item.label ?? null,
role: item.role ?? null,
type: item.type ?? 'normal',
}));
return {
popup: () => {
probe.popupCount += 1;
},
} as unknown as Electron.Menu;
}) as typeof Menu.buildFromTemplate;
});
}
async function readContextMenuProbe(app: ElectronApplication): Promise<ContextMenuProbe | null> {
return await app.evaluate(() => {
const globalWithProbe = globalThis as typeof globalThis & {
__makeloreContextMenuProbe?: ContextMenuProbe;
};
return globalWithProbe.__makeloreContextMenuProbe ?? null;
});
}
test.describe('Makelore text context menu', () => {
test('opens native editing actions for editable text', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
const page = await getStableWindow(app);
await installContextMenuProbe(app);
await page.evaluate(() => {
const input = document.createElement('input');
input.id = 'context-menu-e2e-input';
input.style.position = 'fixed';
input.style.inset = '80px auto auto 80px';
input.style.zIndex = '2147483647';
document.body.append(input);
});
const input = page.locator('#context-menu-e2e-input');
await input.fill('Makelore');
await input.selectText();
await input.click({ button: 'right' });
await expect.poll(async () => await readContextMenuProbe(app)).toEqual({
popupCount: 1,
template: [
{ label: '剪切', role: 'cut', type: 'normal' },
{ label: '复制', role: 'copy', type: 'normal' },
{ label: '粘贴', role: 'paste', type: 'normal' },
{ label: null, role: null, type: 'separator' },
{ label: '全选', role: 'selectAll', type: 'normal' },
],
});
} finally {
await closeElectronApp(app);
}
});
});

View File

@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest';
import {
createTextContextMenuTemplate,
type TextContextMenuContext,
} from '@electron/main/context-menu';
type ContextOverrides = {
isEditable?: boolean;
selectionText?: string;
editFlags?: Partial<Electron.EditFlags>;
};
function context(overrides: ContextOverrides = {}): TextContextMenuContext {
return {
isEditable: overrides.isEditable ?? false,
selectionText: overrides.selectionText ?? '',
editFlags: {
canUndo: false,
canRedo: false,
canCut: false,
canCopy: false,
canPaste: false,
canDelete: false,
canSelectAll: false,
canEditRichly: false,
...overrides.editFlags,
},
};
}
function enabledByRole(template: Electron.MenuItemConstructorOptions[] | null) {
return Object.fromEntries(
(template ?? [])
.filter((item) => item.role)
.map((item) => [item.role, item.enabled]),
);
}
describe('text context menu', () => {
it('does not create a menu for ordinary non-editable content', () => {
expect(createTextContextMenuTemplate(context())).toBeNull();
});
it('allows paste and select all in an editable control without a selection', () => {
const template = createTextContextMenuTemplate(context({
isEditable: true,
editFlags: {
canPaste: true,
canSelectAll: true,
},
}));
expect(template?.map((item) => item.label ?? item.type)).toEqual([
'剪切',
'复制',
'粘贴',
'separator',
'全选',
]);
expect(enabledByRole(template)).toEqual({
cut: false,
copy: false,
paste: true,
selectAll: true,
});
});
it('enables every requested action for an editable selection when supported', () => {
const template = createTextContextMenuTemplate(context({
isEditable: true,
selectionText: 'Makelore',
editFlags: {
canCut: true,
canCopy: true,
canPaste: true,
canSelectAll: true,
},
}));
expect(enabledByRole(template)).toEqual({
cut: true,
copy: true,
paste: true,
selectAll: true,
});
});
it('keeps cut and paste disabled for a read-only selection', () => {
const template = createTextContextMenuTemplate(context({
selectionText: 'Makelore',
editFlags: {
canCut: true,
canCopy: true,
canPaste: true,
canSelectAll: true,
},
}));
expect(enabledByRole(template)).toEqual({
cut: false,
copy: true,
paste: false,
selectAll: true,
});
});
it('respects disabled renderer editing capabilities', () => {
const template = createTextContextMenuTemplate(context({
isEditable: true,
selectionText: 'Makelore',
}));
expect(enabledByRole(template)).toEqual({
cut: false,
copy: false,
paste: false,
selectAll: false,
});
});
});