From 2f3c04fa5d106bf16c46a1a451789df68c7be048 Mon Sep 17 00:00:00 2001 From: Dmytro Harastovych Date: Tue, 21 Jul 2026 13:34:19 +0300 Subject: [PATCH 1/5] 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 }); + }); +}); From a5a4016dfa00f9e05befc323fc1917d56b066398 Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:14:58 -0300 Subject: [PATCH 2/5] fix(super-editor): handle nested context menu clipping --- .../components/context-menu/menu-position.js | 44 +++++++----- .../context-menu/tests/menu-position.test.js | 70 ++++++++++++++++++- 2 files changed, 94 insertions(+), 20 deletions(-) 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 index 49b16586f8..c6fe7b783f 100644 --- 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 @@ -1,18 +1,8 @@ -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; -}; +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 `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. + * 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). @@ -22,14 +12,30 @@ 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); + 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; }; 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 index 53e3f90876..d99f5960d1 100644 --- 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 @@ -63,7 +63,7 @@ describe('resolveMenuBounds', () => { getComputedStyle: (el) => computed.get(el) ?? { overflowX: 'visible', overflowY: 'visible' }, }); - it('returns the viewport when there is no scrollable ancestor', () => { + 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 }); @@ -74,6 +74,8 @@ describe('resolveMenuBounds', () => { parentElement: null, clientWidth: 985, // 15px vertical scrollbar clientHeight: 445, + clientLeft: 0, + clientTop: 0, getBoundingClientRect: () => ({ left: 0, top: 315 }), }; const anchor = { parentElement: scroller }; @@ -84,4 +86,70 @@ describe('resolveMenuBounds', () => { 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 }); + }); }); From b50b45fb2a46f1d81aa840a34c64956b92cf6233 Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:21:29 -0300 Subject: [PATCH 3/5] test(super-editor): cover context menu clamping --- .../context-menu/tests/ContextMenu.test.js | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) 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..85d8a4d661 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,45 @@ 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')) { + return { left: 512, top: 200, right: 692, 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('should not open menu when editor is read-only', async () => { mockEditor.isEditable = false; const wrapper = mount(ContextMenu, { props: mockProps }); From 9feb20b4d9969da08ac904ba6b463f8b28f5adcf Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:44:58 -0300 Subject: [PATCH 4/5] fix(super-editor): handle context menu edge cases --- .../components/context-menu/ContextMenu.vue | 26 +++++-- .../components/context-menu/menu-position.js | 19 +++-- .../context-menu/tests/ContextMenu.test.js | 70 ++++++++++++++++++- .../context-menu/tests/menu-position.test.js | 9 +++ 4 files changed, 111 insertions(+), 13 deletions(-) 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 7e9dd02504..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 @@ -37,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) => { @@ -202,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); @@ -210,6 +229,7 @@ const renderCustomItem = async (itemId) => { element.innerHTML = ''; element.appendChild(fallbackElement); element.hasCustomContent = true; + scheduleMenuReposition(); } }; @@ -586,11 +606,7 @@ onMounted(() => { 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); - } + 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 index c6fe7b783f..0d063189a2 100644 --- 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 @@ -53,18 +53,23 @@ 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; + 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 - gutter) left -= rect.right - (bounds.right - gutter); - else if (rect.left < bounds.left + gutter) left += bounds.left + gutter - rect.left; + 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 - gutter) top -= rect.bottom - (bounds.bottom - gutter); - else if (rect.top < bounds.top + gutter) top += bounds.top + gutter - rect.top; + 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 85d8a4d661..6fb000467b 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 @@ -202,7 +202,8 @@ describe('ContextMenu.vue', () => { 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')) { - return { left: 512, top: 200, right: 692, bottom: 306, width: 180, height: 106 }; + 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 }; @@ -226,6 +227,73 @@ describe('ContextMenu.vue', () => { } }); + 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 computedStyle = vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => ({ + overflowX: element.classList.contains('context-menu') ? 'hidden' : 'visible', + overflowY: element.classList.contains('context-menu') ? 'hidden' : 'visible', + })); + 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(); + computedStyle.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('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 index d99f5960d1..26aff3c4e7 100644 --- 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 @@ -55,6 +55,15 @@ describe('clampMenuPositionToBounds', () => { 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', () => { From b782a35a9db8932e48ba0c501cefa68b08f73517 Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:07:07 -0300 Subject: [PATCH 5/5] test(super-editor): cover search menu clamping --- .../context-menu/tests/ContextMenu.test.js | 45 ++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) 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 6fb000467b..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 @@ -231,10 +231,6 @@ describe('ContextMenu.vue', () => { surfaceElementMock = null; const viewportWidth = vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(1000); const viewportHeight = vi.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(760); - const computedStyle = vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => ({ - overflowX: element.classList.contains('context-menu') ? 'hidden' : 'visible', - overflowY: element.classList.contains('context-menu') ? 'hidden' : 'visible', - })); const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { if (this.classList.contains('context-menu')) { const left = Number.parseFloat(this.style.left) || 0; @@ -254,7 +250,6 @@ describe('ContextMenu.vue', () => { rect.mockRestore(); viewportWidth.mockRestore(); viewportHeight.mockRestore(); - computedStyle.mockRestore(); } }); @@ -294,6 +289,46 @@ describe('ContextMenu.vue', () => { } }); + 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 });