fix: restore native text context menu
This commit is contained in:
85
tests/e2e/context-menu.spec.ts
Normal file
85
tests/e2e/context-menu.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
120
tests/unit/context-menu.test.ts
Normal file
120
tests/unit/context-menu.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user