From 036c52360998481e70e7a5319694bd518f6de8db Mon Sep 17 00:00:00 2001 From: Dmytro Harastovych Date: Tue, 21 Jul 2026 13:34:19 +0300 Subject: [PATCH] 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. --- .../components/context-menu/ContextMenu.vue | 8 ++ .../components/context-menu/menu-position.js | 65 ++++++++++++++ .../context-menu/tests/menu-position.test.js | 87 +++++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 packages/super-editor/src/editors/v1/components/context-menu/menu-position.js create mode 100644 packages/super-editor/src/editors/v1/components/context-menu/tests/menu-position.test.js 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 }); + }); +});