diff --git a/packages/super-editor/src/editors/v1/components/context-menu/ContextMenu.vue b/packages/super-editor/src/editors/v1/components/context-menu/ContextMenu.vue index c7b5daef28..7e9dd02504 100644 --- a/packages/super-editor/src/editors/v1/components/context-menu/ContextMenu.vue +++ b/packages/super-editor/src/editors/v1/components/context-menu/ContextMenu.vue @@ -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'; @@ -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); diff --git a/packages/super-editor/src/editors/v1/components/context-menu/menu-position.js b/packages/super-editor/src/editors/v1/components/context-menu/menu-position.js new file mode 100644 index 0000000000..49b16586f8 --- /dev/null +++ b/packages/super-editor/src/editors/v1/components/context-menu/menu-position.js @@ -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` }; +}; diff --git a/packages/super-editor/src/editors/v1/components/context-menu/tests/menu-position.test.js b/packages/super-editor/src/editors/v1/components/context-menu/tests/menu-position.test.js new file mode 100644 index 0000000000..53e3f90876 --- /dev/null +++ b/packages/super-editor/src/editors/v1/components/context-menu/tests/menu-position.test.js @@ -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 }); + }); +});