Skip to content

Commit a6c9c48

Browse files
authored
fix: improve context menu prevention logic for specific elements (#609)
Refs: CO-2083 - update the listener to traverse `event.composedPath()` and check for allowed checks anywhere in the hierarchy
1 parent 6056b3a commit a6c9c48

File tree

2 files changed

+132
-15
lines changed

2 files changed

+132
-15
lines changed

src/index.test.tsx

+117
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2025 Zextras <https://www.zextras.com>
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only
5+
*/
6+
import '@testing-library/jest-dom';
7+
8+
jest.mock('react-dom/client', () => ({
9+
createRoot: jest.fn().mockImplementation(() => ({
10+
render: jest.fn(),
11+
unmount: jest.fn()
12+
}))
13+
}));
14+
15+
describe('index.tsx - Context Menu Behavior', () => {
16+
let originalGetSelection: typeof window.getSelection;
17+
18+
beforeAll(() => {
19+
originalGetSelection = window.getSelection;
20+
import('./index');
21+
});
22+
23+
beforeEach(() => {
24+
jest.clearAllMocks();
25+
window.getSelection = originalGetSelection;
26+
document.body.innerHTML = '';
27+
});
28+
29+
it('should block context menu for regular elements', () => {
30+
const target = document.body;
31+
const event = new MouseEvent('contextmenu', {
32+
bubbles: true,
33+
cancelable: true,
34+
composed: true
35+
});
36+
const preventDefaultSpy = jest.spyOn(event, 'preventDefault');
37+
38+
target.dispatchEvent(event);
39+
40+
expect(preventDefaultSpy).toHaveBeenCalled();
41+
});
42+
43+
it('should allow context menu for A tags', () => {
44+
const link = document.createElement('a');
45+
document.body.appendChild(link);
46+
const event = new MouseEvent('contextmenu', {
47+
bubbles: true,
48+
cancelable: true,
49+
composed: true
50+
});
51+
const preventDefaultSpy = jest.spyOn(event, 'preventDefault');
52+
53+
link.dispatchEvent(event);
54+
55+
expect(preventDefaultSpy).not.toHaveBeenCalled();
56+
});
57+
58+
it('should allow context menu for IMG tags', () => {
59+
const img = document.createElement('img');
60+
document.body.appendChild(img);
61+
const event = new MouseEvent('contextmenu', {
62+
bubbles: true,
63+
cancelable: true,
64+
composed: true
65+
});
66+
const preventDefaultSpy = jest.spyOn(event, 'preventDefault');
67+
68+
img.dispatchEvent(event);
69+
70+
expect(preventDefaultSpy).not.toHaveBeenCalled();
71+
});
72+
73+
it('should allow context menu for bypass-class elements', () => {
74+
const div = document.createElement('div');
75+
div.classList.add('carbonio-bypass-context-menu');
76+
document.body.appendChild(div);
77+
const event = new MouseEvent('contextmenu', {
78+
bubbles: true,
79+
cancelable: true,
80+
composed: true
81+
});
82+
const preventDefaultSpy = jest.spyOn(event, 'preventDefault');
83+
84+
div.dispatchEvent(event);
85+
86+
expect(preventDefaultSpy).not.toHaveBeenCalled();
87+
});
88+
89+
it('should allow context menu for text selections', () => {
90+
const range = document.createRange();
91+
const textNode = document.createTextNode('selectable text');
92+
document.body.appendChild(textNode);
93+
range.selectNode(textNode);
94+
95+
window.getSelection = jest.fn(
96+
() =>
97+
({
98+
type: 'Range',
99+
rangeCount: 1,
100+
getRangeAt: jest.fn(() => range)
101+
}) as never
102+
);
103+
104+
const event = new MouseEvent('contextmenu', {
105+
bubbles: true,
106+
cancelable: true,
107+
composed: true
108+
});
109+
const preventDefaultSpy = jest.spyOn(event, 'preventDefault');
110+
111+
textNode.dispatchEvent(event);
112+
113+
expect(preventDefaultSpy).not.toHaveBeenCalled();
114+
115+
document.body.childNodes.forEach((n) => n.nodeType === Node.TEXT_NODE && n.remove());
116+
});
117+
});

src/index.tsx

+15-15
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,21 @@ import '@fontsource/roboto/500.css';
1919
import '@fontsource/roboto/700.css';
2020

2121
window.addEventListener('contextmenu', (ev) => {
22-
if (
23-
!(
24-
['IMG', 'A'].find(
25-
(name) => ev?.target instanceof HTMLElement && ev.target.tagName === name
26-
) ||
27-
ev.view?.getSelection?.()?.type === 'Range' ||
28-
ev
29-
.composedPath()
30-
.find(
31-
(element) =>
32-
element instanceof HTMLElement &&
33-
element.classList.contains('carbonio-bypass-context-menu')
34-
)
35-
)
36-
) {
22+
const path = ev.composedPath?.() || [];
23+
24+
const isAllowedTarget = path.some(
25+
(element) => element instanceof HTMLElement && ['A', 'IMG'].includes(element.tagName)
26+
);
27+
28+
const selection = window.getSelection?.();
29+
const isTextSelection = selection?.type === 'Range';
30+
31+
const hasBypassClass = path.some(
32+
(element) =>
33+
element instanceof HTMLElement && element.classList.contains('carbonio-bypass-context-menu')
34+
);
35+
36+
if (!(isAllowedTarget || isTextSelection || hasBypassClass)) {
3737
ev.preventDefault();
3838
}
3939
});

0 commit comments

Comments
 (0)