Skip to content

Commit 036c523

Browse files
fix(super-editor): keep context menu within the editor scroll area
The context menu opened at the raw click point with no clamping, so right-clicking near the right/bottom edge pushed it partly off-screen, and even a viewport clamp would render it under the scroll container scrollbar. Clamp the menu to the viewport intersected with the editor scroll container's content box (which excludes its scrollbar) after it renders.
1 parent 12a1fa5 commit 036c523

3 files changed

Lines changed: 160 additions & 0 deletions

File tree

packages/super-editor/src/editors/v1/components/context-menu/ContextMenu.vue

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { moveCursorToMouseEvent } from '../cursor-helpers.js';
99
import { getEditorSurfaceElement } from '../../core/helpers/editorSurface.js';
1010
import { getItems } from './menuItems.js';
1111
import { getEditorContext } from './utils.js';
12+
import { clampMenuPositionToBounds, resolveMenuBounds } from './menu-position.js';
1213
import { CONTEXT_MENU_HANDLED_FLAG } from './event-flags.js';
1314
import { isMacOS } from '../../core/utilities/isMacOS.js';
1415
@@ -583,6 +584,13 @@ onMounted(() => {
583584
searchQuery.value = '';
584585
selectedId.value = flattenedItems.value[0]?.id || null;
585586
isOpen.value = true;
587+
588+
await nextTick();
589+
const menuRect = menuRef.value?.getBoundingClientRect();
590+
if (menuRect?.width > 0 && menuRect.height > 0) {
591+
const bounds = resolveMenuBounds(getEditorSurfaceElement(props.editor) ?? menuRef.value, window);
592+
menuPosition.value = clampMenuPositionToBounds(menuPosition.value, menuRect, bounds);
593+
}
586594
};
587595
props.editor.on('contextMenu:open', contextMenuOpenHandler);
588596
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
const findScrollableAncestor = (element, view) => {
2+
let current = element;
3+
while (current) {
4+
const { overflowX, overflowY } = view.getComputedStyle(current);
5+
if (/(auto|scroll)/.test(overflowY) || /(auto|scroll)/.test(overflowX)) return current;
6+
current = current.parentElement;
7+
}
8+
return null;
9+
};
10+
11+
/**
12+
* Visible bounds (viewport coordinates) a fixed-position menu should stay within: the viewport
13+
* minus any window scrollbar, intersected with `anchorEl`'s nearest scroll container's content box.
14+
* Using the container's clientWidth/clientHeight excludes that container's scrollbar, so the menu
15+
* never renders under the right/bottom scrollbar.
16+
*
17+
* @param {Element|null} anchorEl - Element inside the scroll area (e.g. the editor surface).
18+
* @param {Window} view - Window used for measurements (injectable for tests).
19+
* @returns {{ left: number, top: number, right: number, bottom: number }}
20+
*/
21+
export const resolveMenuBounds = (anchorEl, view) => {
22+
const docEl = view.document.documentElement;
23+
const bounds = { left: 0, top: 0, right: docEl.clientWidth, bottom: docEl.clientHeight };
24+
25+
const scroller = anchorEl ? findScrollableAncestor(anchorEl, view) : null;
26+
if (scroller && scroller.getBoundingClientRect) {
27+
const rect = scroller.getBoundingClientRect();
28+
bounds.left = Math.max(bounds.left, rect.left);
29+
bounds.top = Math.max(bounds.top, rect.top);
30+
bounds.right = Math.min(bounds.right, rect.left + scroller.clientWidth);
31+
bounds.bottom = Math.min(bounds.bottom, rect.top + scroller.clientHeight);
32+
}
33+
return bounds;
34+
};
35+
36+
/**
37+
* Clamp a fixed-position menu back inside `bounds` using its rendered rect. Shifts by how far the
38+
* rect overflows each edge, so the result is correct regardless of the menu's containing block.
39+
*
40+
* @param {{ left: string, top: string }} position - Current CSS position (px strings).
41+
* @param {{ left: number, top: number, right: number, bottom: number }} rect - Rendered menu rect.
42+
* @param {{ left: number, top: number, right: number, bottom: number }} bounds - Allowed area.
43+
* @param {number} [gutter=8] - Minimum gap from each edge.
44+
* @returns {{ left: string, top: string }}
45+
*/
46+
export const clampMenuPositionToBounds = (position, rect, bounds, gutter = 8) => {
47+
let left = parseFloat(position.left) || 0;
48+
let top = parseFloat(position.top) || 0;
49+
50+
// Clamp an axis only when the menu fits; a larger menu renders as-is (shifting just trades edges).
51+
const fitsX = rect.right - rect.left <= bounds.right - bounds.left - 2 * gutter;
52+
const fitsY = rect.bottom - rect.top <= bounds.bottom - bounds.top - 2 * gutter;
53+
54+
if (fitsX) {
55+
if (rect.right > bounds.right - gutter) left -= rect.right - (bounds.right - gutter);
56+
else if (rect.left < bounds.left + gutter) left += bounds.left + gutter - rect.left;
57+
}
58+
59+
if (fitsY) {
60+
if (rect.bottom > bounds.bottom - gutter) top -= rect.bottom - (bounds.bottom - gutter);
61+
else if (rect.top < bounds.top + gutter) top += bounds.top + gutter - rect.top;
62+
}
63+
64+
return { left: `${left}px`, top: `${top}px` };
65+
};
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { clampMenuPositionToBounds, resolveMenuBounds } from '../menu-position.js';
3+
4+
const viewport = { left: 0, top: 0, right: 1000, bottom: 800 };
5+
6+
describe('clampMenuPositionToBounds', () => {
7+
it('shifts left when the menu overflows the right edge', () => {
8+
const rect = { left: 900, top: 100, right: 1100, bottom: 300 };
9+
expect(clampMenuPositionToBounds({ left: '900px', top: '100px' }, rect, viewport)).toEqual({
10+
left: '792px',
11+
top: '100px',
12+
});
13+
});
14+
15+
it('shifts up when the menu overflows the bottom edge', () => {
16+
const rect = { left: 100, top: 700, right: 300, bottom: 900 };
17+
expect(clampMenuPositionToBounds({ left: '100px', top: '700px' }, rect, viewport)).toEqual({
18+
left: '100px',
19+
top: '592px',
20+
});
21+
});
22+
23+
it('shifts back when the menu is off the left/top edge', () => {
24+
const rect = { left: -20, top: -10, right: 180, bottom: 190 };
25+
expect(clampMenuPositionToBounds({ left: '-20px', top: '-10px' }, rect, viewport)).toEqual({
26+
left: '8px',
27+
top: '8px',
28+
});
29+
});
30+
31+
it('leaves a fully in-bounds menu unchanged', () => {
32+
const rect = { left: 100, top: 100, right: 300, bottom: 300 };
33+
expect(clampMenuPositionToBounds({ left: '100px', top: '100px' }, rect, viewport)).toEqual({
34+
left: '100px',
35+
top: '100px',
36+
});
37+
});
38+
39+
it('clears the scroll container scrollbar (bounds narrower than viewport)', () => {
40+
// Bounds right 985 (15px scrollbar): a menu at right 990 shifts to bounds.right - gutter = 977.
41+
const bounds = { left: 0, top: 0, right: 985, bottom: 760 };
42+
const rect = { left: 810, top: 100, right: 990, bottom: 300 };
43+
expect(clampMenuPositionToBounds({ left: '810px', top: '100px' }, rect, bounds)).toEqual({
44+
left: '797px',
45+
top: '100px',
46+
});
47+
});
48+
49+
it('renders as-is on an axis where the menu is larger than the bounds', () => {
50+
// 200x320 menu cannot fit in 180x240 bounds; shifting would only trade edges, so leave it.
51+
const bounds = { left: 0, top: 0, right: 180, bottom: 240 };
52+
const rect = { left: 40, top: 30, right: 240, bottom: 350 };
53+
expect(clampMenuPositionToBounds({ left: '40px', top: '30px' }, rect, bounds)).toEqual({
54+
left: '40px',
55+
top: '30px',
56+
});
57+
});
58+
});
59+
60+
describe('resolveMenuBounds', () => {
61+
const makeView = (clientW, clientH, computed) => ({
62+
document: { documentElement: { clientWidth: clientW, clientHeight: clientH } },
63+
getComputedStyle: (el) => computed.get(el) ?? { overflowX: 'visible', overflowY: 'visible' },
64+
});
65+
66+
it('returns the viewport when there is no scrollable ancestor', () => {
67+
const el = { parentElement: null };
68+
const view = makeView(1000, 800, new Map());
69+
expect(resolveMenuBounds(el, view)).toEqual({ left: 0, top: 0, right: 1000, bottom: 800 });
70+
});
71+
72+
it('intersects with the scroll container content box (excludes its scrollbar)', () => {
73+
const scroller = {
74+
parentElement: null,
75+
clientWidth: 985, // 15px vertical scrollbar
76+
clientHeight: 445,
77+
getBoundingClientRect: () => ({ left: 0, top: 315 }),
78+
};
79+
const anchor = { parentElement: scroller };
80+
const computed = new Map([
81+
[anchor, { overflowX: 'visible', overflowY: 'visible' }],
82+
[scroller, { overflowX: 'hidden', overflowY: 'auto' }],
83+
]);
84+
const view = makeView(1000, 760, computed);
85+
expect(resolveMenuBounds(anchor, view)).toEqual({ left: 0, top: 315, right: 985, bottom: 760 });
86+
});
87+
});

0 commit comments

Comments
 (0)