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..4dc37fa2c9 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'; @@ -36,6 +37,24 @@ const sections = ref([]); const selectedId = ref(null); const currentContext = ref(null); // Store context for action execution +const repositionMenu = () => { + const menuRect = menuRef.value?.getBoundingClientRect(); + if (!menuRect || menuRect.width <= 0 || menuRect.height <= 0) return; + + const bounds = resolveMenuBounds(getEditorSurfaceElement(props.editor), window); + menuPosition.value = clampMenuPositionToBounds(menuPosition.value, menuRect, bounds); +}; + +let repositionScheduled = false; +const scheduleMenuReposition = () => { + if (repositionScheduled) return; + repositionScheduled = true; + nextTick(() => { + repositionScheduled = false; + repositionMenu(); + }); +}; + const TABLE_SURFACE_SELECTOR = '.superdoc-table-fragment, .superdoc-table-cell'; const hasExpandedSelection = (selection) => { @@ -201,6 +220,7 @@ const renderCustomItem = async (itemId) => { element.innerHTML = ''; element.appendChild(customElement); element.hasCustomContent = true; + scheduleMenuReposition(); } } catch (error) { console.warn(`[ContextMenu] Error rendering custom item ${itemId}:`, error); @@ -209,6 +229,7 @@ const renderCustomItem = async (itemId) => { element.innerHTML = ''; element.appendChild(fallbackElement); element.hasCustomContent = true; + scheduleMenuReposition(); } }; @@ -583,6 +604,9 @@ onMounted(() => { searchQuery.value = ''; selectedId.value = flattenedItems.value[0]?.id || null; isOpen.value = true; + + await nextTick(); + repositionMenu(); }; 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..0d063189a2 --- /dev/null +++ b/packages/super-editor/src/editors/v1/components/context-menu/menu-position.js @@ -0,0 +1,76 @@ +const CLIPPING_OVERFLOW = new Set(['auto', 'scroll', 'hidden', 'clip']); + +/** + * Visible bounds (viewport coordinates) a fixed-position menu should stay within: the viewport + * minus any window scrollbar, intersected with every clipping ancestor's client box. + * + * @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 }; + + let current = anchorEl; + while (current) { + const { overflowX, overflowY } = view.getComputedStyle(current); + const clipsX = CLIPPING_OVERFLOW.has(overflowX); + const clipsY = CLIPPING_OVERFLOW.has(overflowY); + + if ((clipsX || clipsY) && current.getBoundingClientRect) { + const rect = current.getBoundingClientRect(); + const clientLeft = rect.left + current.clientLeft; + const clientTop = rect.top + current.clientTop; + + if (clipsX) { + bounds.left = Math.max(bounds.left, clientLeft); + bounds.right = Math.min(bounds.right, clientLeft + current.clientWidth); + } + if (clipsY) { + bounds.top = Math.max(bounds.top, clientTop); + bounds.bottom = Math.min(bounds.bottom, clientTop + current.clientHeight); + } + } + + current = current.parentElement; + } + + 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; + + const menuWidth = rect.right - rect.left; + const menuHeight = rect.bottom - rect.top; + const boundsWidth = bounds.right - bounds.left; + const boundsHeight = bounds.bottom - bounds.top; + const fitsX = menuWidth <= boundsWidth; + const fitsY = menuHeight <= boundsHeight; + const gutterX = Math.min(gutter, Math.max(0, (boundsWidth - menuWidth) / 2)); + const gutterY = Math.min(gutter, Math.max(0, (boundsHeight - menuHeight) / 2)); + + if (fitsX) { + if (rect.right > bounds.right - gutterX) left -= rect.right - (bounds.right - gutterX); + else if (rect.left < bounds.left + gutterX) left += bounds.left + gutterX - rect.left; + } + + if (fitsY) { + if (rect.bottom > bounds.bottom - gutterY) top -= rect.bottom - (bounds.bottom - gutterY); + else if (rect.top < bounds.top + gutterY) top += bounds.top + gutterY - rect.top; + } + + return { left: `${left}px`, top: `${top}px` }; +}; diff --git a/packages/super-editor/src/editors/v1/components/context-menu/tests/ContextMenu.test.js b/packages/super-editor/src/editors/v1/components/context-menu/tests/ContextMenu.test.js index 4581d1836f..7016f0f88f 100644 --- a/packages/super-editor/src/editors/v1/components/context-menu/tests/ContextMenu.test.js +++ b/packages/super-editor/src/editors/v1/components/context-menu/tests/ContextMenu.test.js @@ -187,6 +187,148 @@ describe('ContextMenu.vue', () => { expect(wrapper.find('.context-menu').element.style.top).toBe('200px'); }); + it('keeps the rendered menu inside a clipping ancestor', async () => { + const clipper = document.createElement('div'); + clipper.style.overflowX = 'hidden'; + clipper.style.overflowY = 'hidden'; + Object.defineProperties(clipper, { + clientWidth: { configurable: true, value: 600 }, + clientHeight: { configurable: true, value: 760 }, + }); + clipper.append(surfaceElementMock); + document.body.append(clipper); + + const viewportWidth = vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(1000); + const viewportHeight = vi.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(760); + const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { + if (this.classList.contains('context-menu')) { + const left = Number.parseFloat(this.style.left) || 0; + return { left, top: 200, right: left + 180, bottom: 306, width: 180, height: 106 }; + } + if (this === clipper) { + return { left: 0, top: 0, right: 600, bottom: 760, width: 600, height: 760 }; + } + return { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }; + }); + + const wrapper = mount(ContextMenu, { props: mockProps }); + try { + const onContextMenuOpen = mockEditor.on.mock.calls.find((call) => call[0] === 'contextMenu:open')[1]; + await onContextMenuOpen({ menuPosition: { left: '512px', top: '200px' } }); + + expect(wrapper.find('.context-menu').element.style.left).toBe('412px'); + expect(wrapper.find('.context-menu').element.style.top).toBe('200px'); + } finally { + wrapper.unmount(); + rect.mockRestore(); + viewportWidth.mockRestore(); + viewportHeight.mockRestore(); + clipper.remove(); + } + }); + + it('uses viewport bounds when the editor surface is unavailable', async () => { + surfaceElementMock = null; + const viewportWidth = vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(1000); + const viewportHeight = vi.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(760); + const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { + if (this.classList.contains('context-menu')) { + const left = Number.parseFloat(this.style.left) || 0; + return { left, top: 200, right: left + 180, bottom: 306, width: 180, height: 106 }; + } + return { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }; + }); + + const wrapper = mount(ContextMenu, { props: mockProps }); + try { + const onContextMenuOpen = mockEditor.on.mock.calls.find((call) => call[0] === 'contextMenu:open')[1]; + await onContextMenuOpen({ menuPosition: { left: '900px', top: '200px' } }); + + expect(wrapper.find('.context-menu').element.style.left).toBe('812px'); + } finally { + wrapper.unmount(); + rect.mockRestore(); + viewportWidth.mockRestore(); + viewportHeight.mockRestore(); + } + }); + + it('repositions after a custom item changes the menu height', async () => { + const customRenderItem = createMockRenderItem('custom-item'); + customRenderItem.render = () => { + const element = document.createElement('div'); + element.dataset.tallCustomItem = ''; + return element; + }; + mockGetItems.mockReturnValue([{ id: 'custom-section', items: [customRenderItem] }]); + + const viewportWidth = vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(1000); + const viewportHeight = vi.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(760); + const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { + if (this.classList.contains('context-menu')) { + const top = Number.parseFloat(this.style.top) || 0; + const height = this.querySelector('[data-tall-custom-item]') ? 200 : 100; + return { left: 100, top, right: 280, bottom: top + height, width: 180, height }; + } + return { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }; + }); + + const wrapper = mount(ContextMenu, { props: mockProps }); + try { + const onContextMenuOpen = mockEditor.on.mock.calls.find((call) => call[0] === 'contextMenu:open')[1]; + await onContextMenuOpen({ menuPosition: { left: '100px', top: '650px' } }); + await nextTick(); + await nextTick(); + + expect(wrapper.find('.context-menu').element.style.top).toBe('552px'); + } finally { + wrapper.unmount(); + rect.mockRestore(); + viewportWidth.mockRestore(); + viewportHeight.mockRestore(); + } + }); + + it('repositions when the search header grows a full menu', async () => { + mockGetItems.mockReturnValue( + createMockMenuItems( + 1, + Array.from({ length: 40 }, (_, index) => ({ + id: `item-${index}`, + label: `Item ${index}`, + showWhen: () => true, + })), + ), + ); + + const viewportWidth = vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(1000); + const viewportHeight = vi.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(760); + const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { + if (this.classList.contains('context-menu')) { + const top = Number.parseFloat(this.style.top) || 0; + const height = this.querySelector('.context-menu-search-header') ? 330 : 300; + return { left: 100, top, right: 280, bottom: top + height, width: 180, height }; + } + return { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }; + }); + + const wrapper = mount(ContextMenu, { props: mockProps }); + try { + const onContextMenuOpen = mockEditor.on.mock.calls.find((call) => call[0] === 'contextMenu:open')[1]; + await onContextMenuOpen({ menuPosition: { left: '100px', top: '452px' } }); + await wrapper.find('.context-menu-hidden-input').setValue('Item'); + await nextTick(); + await nextTick(); + + expect(wrapper.find('.context-menu').element.style.top).toBe('422px'); + } finally { + wrapper.unmount(); + rect.mockRestore(); + viewportWidth.mockRestore(); + viewportHeight.mockRestore(); + } + }); + it('should not open menu when editor is read-only', async () => { mockEditor.isEditable = false; const wrapper = mount(ContextMenu, { props: mockProps }); 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..26aff3c4e7 --- /dev/null +++ b/packages/super-editor/src/editors/v1/components/context-menu/tests/menu-position.test.js @@ -0,0 +1,164 @@ +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', + }); + }); + + it('uses a smaller gutter when the menu only fits within the full bounds', () => { + const bounds = { left: 0, top: 0, right: 200, bottom: 300 }; + const rect = { left: 20, top: 20, right: 205, bottom: 120 }; + expect(clampMenuPositionToBounds({ left: '20px', top: '20px' }, rect, bounds)).toEqual({ + left: '7.5px', + top: '20px', + }); + }); +}); + +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 clipping 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, + clientLeft: 0, + clientTop: 0, + 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 }); + }); + + it('intersects every clipping ancestor', () => { + const outer = { + parentElement: null, + clientWidth: 580, + clientHeight: 500, + clientLeft: 0, + clientTop: 0, + getBoundingClientRect: () => ({ left: 20, top: 100 }), + }; + const inner = { + parentElement: outer, + clientWidth: 700, + clientHeight: 430, + clientLeft: 0, + clientTop: 0, + getBoundingClientRect: () => ({ left: 80, top: 150 }), + }; + const anchor = { parentElement: inner }; + const computed = new Map([ + [anchor, { overflowX: 'visible', overflowY: 'visible' }], + [inner, { overflowX: 'auto', overflowY: 'auto' }], + [outer, { overflowX: 'hidden', overflowY: 'hidden' }], + ]); + const view = makeView(1000, 760, computed); + + expect(resolveMenuBounds(anchor, view)).toEqual({ left: 80, top: 150, right: 600, bottom: 580 }); + }); + + it('clips each axis independently', () => { + const clipX = { + parentElement: null, + clientWidth: 500, + clientHeight: 300, + clientLeft: 0, + clientTop: 0, + getBoundingClientRect: () => ({ left: 20, top: 100 }), + }; + const anchor = { parentElement: clipX }; + const computed = new Map([ + [anchor, { overflowX: 'visible', overflowY: 'visible' }], + [clipX, { overflowX: 'hidden', overflowY: 'visible' }], + ]); + const view = makeView(1000, 760, computed); + + expect(resolveMenuBounds(anchor, view)).toEqual({ left: 20, top: 0, right: 520, bottom: 760 }); + }); + + it('uses the clipping ancestor content box inside its border', () => { + const clipper = { + parentElement: null, + clientWidth: 500, + clientHeight: 300, + clientLeft: 4, + clientTop: 6, + getBoundingClientRect: () => ({ left: 20, top: 100 }), + }; + const anchor = { parentElement: clipper }; + const computed = new Map([ + [anchor, { overflowX: 'visible', overflowY: 'visible' }], + [clipper, { overflowX: 'clip', overflowY: 'clip' }], + ]); + const view = makeView(1000, 760, computed); + + expect(resolveMenuBounds(anchor, view)).toEqual({ left: 24, top: 106, right: 524, bottom: 406 }); + }); +});