Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { moveCursorToMouseEvent } from '../cursor-helpers.js';
import { getEditorSurfaceElement } from '../../core/helpers/editorSurface.js';
import { getItems } from './menuItems.js';
import { getEditorContext } from './utils.js';
import { clampMenuPositionToBounds, resolveMenuBounds } from './menu-position.js';
import { CONTEXT_MENU_HANDLED_FLAG } from './event-flags.js';
import { isMacOS } from '../../core/utilities/isMacOS.js';

Expand Down Expand Up @@ -583,6 +584,13 @@ onMounted(() => {
searchQuery.value = '';
selectedId.value = flattenedItems.value[0]?.id || null;
isOpen.value = true;

await nextTick();
const menuRect = menuRef.value?.getBoundingClientRect();
if (menuRect?.width > 0 && menuRect.height > 0) {
const bounds = resolveMenuBounds(getEditorSurfaceElement(props.editor) ?? menuRef.value, window);
menuPosition.value = clampMenuPositionToBounds(menuPosition.value, menuRect, bounds);
}
};
props.editor.on('contextMenu:open', contextMenuOpenHandler);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
const findScrollableAncestor = (element, view) => {
let current = element;
while (current) {
const { overflowX, overflowY } = view.getComputedStyle(current);
if (/(auto|scroll)/.test(overflowY) || /(auto|scroll)/.test(overflowX)) return current;
current = current.parentElement;
}
return null;
};

/**
* Visible bounds (viewport coordinates) a fixed-position menu should stay within: the viewport
* minus any window scrollbar, intersected with `anchorEl`'s nearest scroll container's content box.
* Using the container's clientWidth/clientHeight excludes that container's scrollbar, so the menu
* never renders under the right/bottom scrollbar.
*
* @param {Element|null} anchorEl - Element inside the scroll area (e.g. the editor surface).
* @param {Window} view - Window used for measurements (injectable for tests).
* @returns {{ left: number, top: number, right: number, bottom: number }}
*/
export const resolveMenuBounds = (anchorEl, view) => {
const docEl = view.document.documentElement;
const bounds = { left: 0, top: 0, right: docEl.clientWidth, bottom: docEl.clientHeight };

const scroller = anchorEl ? findScrollableAncestor(anchorEl, view) : null;
if (scroller && scroller.getBoundingClientRect) {
const rect = scroller.getBoundingClientRect();
bounds.left = Math.max(bounds.left, rect.left);
bounds.top = Math.max(bounds.top, rect.top);
bounds.right = Math.min(bounds.right, rect.left + scroller.clientWidth);
bounds.bottom = Math.min(bounds.bottom, rect.top + scroller.clientHeight);
}
return bounds;
};

/**
* Clamp a fixed-position menu back inside `bounds` using its rendered rect. Shifts by how far the
* rect overflows each edge, so the result is correct regardless of the menu's containing block.
*
* @param {{ left: string, top: string }} position - Current CSS position (px strings).
* @param {{ left: number, top: number, right: number, bottom: number }} rect - Rendered menu rect.
* @param {{ left: number, top: number, right: number, bottom: number }} bounds - Allowed area.
* @param {number} [gutter=8] - Minimum gap from each edge.
* @returns {{ left: string, top: string }}
*/
export const clampMenuPositionToBounds = (position, rect, bounds, gutter = 8) => {
let left = parseFloat(position.left) || 0;
let top = parseFloat(position.top) || 0;

// Clamp an axis only when the menu fits; a larger menu renders as-is (shifting just trades edges).
const fitsX = rect.right - rect.left <= bounds.right - bounds.left - 2 * gutter;
const fitsY = rect.bottom - rect.top <= bounds.bottom - bounds.top - 2 * gutter;

if (fitsX) {
if (rect.right > bounds.right - gutter) left -= rect.right - (bounds.right - gutter);
else if (rect.left < bounds.left + gutter) left += bounds.left + gutter - rect.left;
}

if (fitsY) {
if (rect.bottom > bounds.bottom - gutter) top -= rect.bottom - (bounds.bottom - gutter);
else if (rect.top < bounds.top + gutter) top += bounds.top + gutter - rect.top;
}

return { left: `${left}px`, top: `${top}px` };
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import { clampMenuPositionToBounds, resolveMenuBounds } from '../menu-position.js';

const viewport = { left: 0, top: 0, right: 1000, bottom: 800 };

describe('clampMenuPositionToBounds', () => {
it('shifts left when the menu overflows the right edge', () => {
const rect = { left: 900, top: 100, right: 1100, bottom: 300 };
expect(clampMenuPositionToBounds({ left: '900px', top: '100px' }, rect, viewport)).toEqual({
left: '792px',
top: '100px',
});
});

it('shifts up when the menu overflows the bottom edge', () => {
const rect = { left: 100, top: 700, right: 300, bottom: 900 };
expect(clampMenuPositionToBounds({ left: '100px', top: '700px' }, rect, viewport)).toEqual({
left: '100px',
top: '592px',
});
});

it('shifts back when the menu is off the left/top edge', () => {
const rect = { left: -20, top: -10, right: 180, bottom: 190 };
expect(clampMenuPositionToBounds({ left: '-20px', top: '-10px' }, rect, viewport)).toEqual({
left: '8px',
top: '8px',
});
});

it('leaves a fully in-bounds menu unchanged', () => {
const rect = { left: 100, top: 100, right: 300, bottom: 300 };
expect(clampMenuPositionToBounds({ left: '100px', top: '100px' }, rect, viewport)).toEqual({
left: '100px',
top: '100px',
});
});

it('clears the scroll container scrollbar (bounds narrower than viewport)', () => {
// Bounds right 985 (15px scrollbar): a menu at right 990 shifts to bounds.right - gutter = 977.
const bounds = { left: 0, top: 0, right: 985, bottom: 760 };
const rect = { left: 810, top: 100, right: 990, bottom: 300 };
expect(clampMenuPositionToBounds({ left: '810px', top: '100px' }, rect, bounds)).toEqual({
left: '797px',
top: '100px',
});
});

it('renders as-is on an axis where the menu is larger than the bounds', () => {
// 200x320 menu cannot fit in 180x240 bounds; shifting would only trade edges, so leave it.
const bounds = { left: 0, top: 0, right: 180, bottom: 240 };
const rect = { left: 40, top: 30, right: 240, bottom: 350 };
expect(clampMenuPositionToBounds({ left: '40px', top: '30px' }, rect, bounds)).toEqual({
left: '40px',
top: '30px',
});
});
});

describe('resolveMenuBounds', () => {
const makeView = (clientW, clientH, computed) => ({
document: { documentElement: { clientWidth: clientW, clientHeight: clientH } },
getComputedStyle: (el) => computed.get(el) ?? { overflowX: 'visible', overflowY: 'visible' },
});

it('returns the viewport when there is no scrollable ancestor', () => {
const el = { parentElement: null };
const view = makeView(1000, 800, new Map());
expect(resolveMenuBounds(el, view)).toEqual({ left: 0, top: 0, right: 1000, bottom: 800 });
});

it('intersects with the scroll container content box (excludes its scrollbar)', () => {
const scroller = {
parentElement: null,
clientWidth: 985, // 15px vertical scrollbar
clientHeight: 445,
getBoundingClientRect: () => ({ left: 0, top: 315 }),
};
const anchor = { parentElement: scroller };
const computed = new Map([
[anchor, { overflowX: 'visible', overflowY: 'visible' }],
[scroller, { overflowX: 'hidden', overflowY: 'auto' }],
]);
const view = makeView(1000, 760, computed);
expect(resolveMenuBounds(anchor, view)).toEqual({ left: 0, top: 315, right: 985, bottom: 760 });
});
});
Loading