From 12b25bd7195869b5f074f3a92548f2c5b2d0d77f Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 13 Jan 2026 11:55:19 -0300 Subject: [PATCH 1/4] fix: popover menu renders at correct position in presentation mode --- packages/super-editor/src/extensions/index.js | 1 + .../popover-plugin/popover-plugin.js | 84 +++++++++++++++++-- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/packages/super-editor/src/extensions/index.js b/packages/super-editor/src/extensions/index.js index 820dbbfb0a..9b28f1c496 100644 --- a/packages/super-editor/src/extensions/index.js +++ b/packages/super-editor/src/extensions/index.js @@ -179,6 +179,7 @@ const getStarterExtensions = () => { Image, BookmarkStart, BookmarkEnd, + PopoverPlugin, Mention, Collaboration, CollaborationCursor, diff --git a/packages/super-editor/src/extensions/popover-plugin/popover-plugin.js b/packages/super-editor/src/extensions/popover-plugin/popover-plugin.js index 02a2494206..6dec9140da 100644 --- a/packages/super-editor/src/extensions/popover-plugin/popover-plugin.js +++ b/packages/super-editor/src/extensions/popover-plugin/popover-plugin.js @@ -127,11 +127,13 @@ class Popover { users: this.editor.users, mention: atMention, inserMention: (user) => { - const { $from } = this.state.selection; + // Use fresh state from the view, not the stale captured state + const currentState = this.editor.view.state; + const { $from } = currentState.selection; const length = atMention.length; const attributes = { ...user }; const mentionNode = this.editor.schema.nodes.mention.create(attributes); - const tr = this.state.tr.replaceWith($from.pos - length, $from.pos, mentionNode); + const tr = currentState.tr.replaceWith($from.pos - length, $from.pos, mentionNode); this.editor.view.dispatch(tr); this.editor.view.focus(); }, @@ -148,19 +150,87 @@ class Popover { } showPopoverAtPosition(pos) { - const end = this.view.coordsAtPos(pos); + let left = 0; + let top = 0; + let source = 'fallback'; + + // In presentation mode, find position using DOM elements in painterHost + const presentationEditor = this.editor.presentationEditor; + if (presentationEditor) { + const result = this.getViewportCoordsFromPainterHost(presentationEditor, pos); + if (result) { + left = result.left; + top = result.bottom; + source = 'painterHost DOM'; + } + } + + // Fallback to view.coordsAtPos for non-presentation mode + if (source === 'fallback') { + const coords = this.view.coordsAtPos(pos); + left = coords.left; + top = coords.bottom; + } + this.popoverRect = { width: 0, height: 0, - top: end.bottom, - left: end.left, - bottom: end.bottom, - right: end.left, + top: top, + left: left, + bottom: top, + right: left, }; this.tippyInstance.show(); } + /** + * Get viewport coordinates by finding the DOM element in the painted content. + * This works in presentation mode where the actual DOM is off-screen but + * painted elements exist in the painterHost. + */ + getViewportCoordsFromPainterHost(presentationEditor, pos) { + // Access painterHost through the DOM - it's a private field but we can find it by class + const visibleHost = presentationEditor.element; + if (!visibleHost) return null; + + // painterHost has class 'presentation-editor__pages' + const painterHost = visibleHost.querySelector('.presentation-editor__pages'); + if (!painterHost) return null; + + // Find all page elements + const pageEls = painterHost.querySelectorAll('.superdoc-page[data-page-index]'); + if (!pageEls.length) return null; + + // Search through pages for a span containing this position + for (const pageEl of pageEls) { + const spanEls = pageEl.querySelectorAll('span[data-pm-start][data-pm-end]'); + for (const spanEl of spanEls) { + const pmStart = Number(spanEl.dataset.pmStart); + const pmEnd = Number(spanEl.dataset.pmEnd); + + if (pos >= pmStart && pos <= pmEnd && spanEl.firstChild?.nodeType === Node.TEXT_NODE) { + const textNode = spanEl.firstChild; + const charIndex = Math.min(pos - pmStart, textNode.length); + + const range = document.createRange(); + range.setStart(textNode, charIndex); + range.setEnd(textNode, charIndex); + + const rect = range.getBoundingClientRect(); + + return { + left: rect.left, + top: rect.top, + bottom: rect.bottom, + }; + } + } + } + + return null; + } + getMentionText(from) { const maxLookBehind = 20; const startPos = Math.max(0, from - maxLookBehind); From 6acfc2f5cfe62d158c09c448a571364b194d869b Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sun, 1 Mar 2026 08:19:25 -0300 Subject: [PATCH 2/4] refactor: use editor.coordsAtPos() instead of manual DOM traversal Replace getViewportCoordsFromPainterHost with editor.coordsAtPos() which delegates to PresentationEditor.coordsAtPos() in presentation mode. This handles all node types, zoom, and scroll via the layout engine instead of fragile CSS class-based DOM scraping. --- .../popover-plugin/popover-plugin.js | 78 ++----------------- 1 file changed, 6 insertions(+), 72 deletions(-) diff --git a/packages/super-editor/src/extensions/popover-plugin/popover-plugin.js b/packages/super-editor/src/extensions/popover-plugin/popover-plugin.js index 6dec9140da..0e2f9bab0d 100644 --- a/packages/super-editor/src/extensions/popover-plugin/popover-plugin.js +++ b/packages/super-editor/src/extensions/popover-plugin/popover-plugin.js @@ -150,87 +150,21 @@ class Popover { } showPopoverAtPosition(pos) { - let left = 0; - let top = 0; - let source = 'fallback'; - - // In presentation mode, find position using DOM elements in painterHost - const presentationEditor = this.editor.presentationEditor; - if (presentationEditor) { - const result = this.getViewportCoordsFromPainterHost(presentationEditor, pos); - if (result) { - left = result.left; - top = result.bottom; - source = 'painterHost DOM'; - } - } - - // Fallback to view.coordsAtPos for non-presentation mode - if (source === 'fallback') { - const coords = this.view.coordsAtPos(pos); - left = coords.left; - top = coords.bottom; - } + const coords = this.editor.coordsAtPos(pos); + if (!coords) return; this.popoverRect = { width: 0, height: 0, - top: top, - left: left, - bottom: top, - right: left, + top: coords.bottom, + left: coords.left, + bottom: coords.bottom, + right: coords.left, }; this.tippyInstance.show(); } - /** - * Get viewport coordinates by finding the DOM element in the painted content. - * This works in presentation mode where the actual DOM is off-screen but - * painted elements exist in the painterHost. - */ - getViewportCoordsFromPainterHost(presentationEditor, pos) { - // Access painterHost through the DOM - it's a private field but we can find it by class - const visibleHost = presentationEditor.element; - if (!visibleHost) return null; - - // painterHost has class 'presentation-editor__pages' - const painterHost = visibleHost.querySelector('.presentation-editor__pages'); - if (!painterHost) return null; - - // Find all page elements - const pageEls = painterHost.querySelectorAll('.superdoc-page[data-page-index]'); - if (!pageEls.length) return null; - - // Search through pages for a span containing this position - for (const pageEl of pageEls) { - const spanEls = pageEl.querySelectorAll('span[data-pm-start][data-pm-end]'); - for (const spanEl of spanEls) { - const pmStart = Number(spanEl.dataset.pmStart); - const pmEnd = Number(spanEl.dataset.pmEnd); - - if (pos >= pmStart && pos <= pmEnd && spanEl.firstChild?.nodeType === Node.TEXT_NODE) { - const textNode = spanEl.firstChild; - const charIndex = Math.min(pos - pmStart, textNode.length); - - const range = document.createRange(); - range.setStart(textNode, charIndex); - range.setEnd(textNode, charIndex); - - const rect = range.getBoundingClientRect(); - - return { - left: rect.left, - top: rect.top, - bottom: rect.bottom, - }; - } - } - } - - return null; - } - getMentionText(from) { const maxLookBehind = 20; const startPos = Math.max(0, from - maxLookBehind); From c4bbc37578591a8c29b05402141c8eed7239f631 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 20 Mar 2026 08:23:36 -0300 Subject: [PATCH 3/4] fix: hide popover on null coords and fix inserMention typo Hide tippy when coordsAtPos returns null to prevent stale popover position. Rename inserMention to insertMention across popover-plugin and Mentions.vue. --- packages/super-editor/src/components/popovers/Mentions.vue | 6 +++--- .../src/extensions/popover-plugin/popover-plugin.js | 7 +++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/super-editor/src/components/popovers/Mentions.vue b/packages/super-editor/src/components/popovers/Mentions.vue index 6dc272bdff..5126a84698 100644 --- a/packages/super-editor/src/components/popovers/Mentions.vue +++ b/packages/super-editor/src/components/popovers/Mentions.vue @@ -10,7 +10,7 @@ const props = defineProps({ type: String, default: '', }, - inserMention: { + insertMention: { type: Function, required: true, }, @@ -33,7 +33,7 @@ const getFilteredUsers = computed(() => { }); const handleClick = (user) => { - props.inserMention(user); + props.insertMention(user); }; const handleKeydown = (event) => { @@ -50,7 +50,7 @@ const handleKeydown = (event) => { } else if (event.key === 'Enter') { const user = getFilteredUsers.value[activeUserIndex.value]; if (user) { - props.inserMention(user); + props.insertMention(user); } } }; diff --git a/packages/super-editor/src/extensions/popover-plugin/popover-plugin.js b/packages/super-editor/src/extensions/popover-plugin/popover-plugin.js index 0e2f9bab0d..77b75cb434 100644 --- a/packages/super-editor/src/extensions/popover-plugin/popover-plugin.js +++ b/packages/super-editor/src/extensions/popover-plugin/popover-plugin.js @@ -126,7 +126,7 @@ class Popover { props: { users: this.editor.users, mention: atMention, - inserMention: (user) => { + insertMention: (user) => { // Use fresh state from the view, not the stale captured state const currentState = this.editor.view.state; const { $from } = currentState.selection; @@ -151,7 +151,10 @@ class Popover { showPopoverAtPosition(pos) { const coords = this.editor.coordsAtPos(pos); - if (!coords) return; + if (!coords) { + this.tippyInstance.hide(); + return; + } this.popoverRect = { width: 0, From 8b0f5a9079fa3b075775428e5f3f109ba6da9090 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 20 Mar 2026 08:58:44 -0300 Subject: [PATCH 4/4] test: add unit tests for null-coords tippy hide and insertMention prop name --- .../popover-plugin/popover-plugin.test.js | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/packages/super-editor/src/extensions/popover-plugin/popover-plugin.test.js b/packages/super-editor/src/extensions/popover-plugin/popover-plugin.test.js index 6c0c5b283a..2b385f5f63 100644 --- a/packages/super-editor/src/extensions/popover-plugin/popover-plugin.test.js +++ b/packages/super-editor/src/extensions/popover-plugin/popover-plugin.test.js @@ -88,4 +88,54 @@ describe('popover plugin basics', () => { pluginView.destroy(); expect(tippyInstance.destroy).toHaveBeenCalled(); }); + + it('hides tippy when coordsAtPos returns null', () => { + const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, schema.text('Hello @'))]); + const state = EditorState.create({ schema, doc, plugins: [plugin] }); + const view = { state, dom: document.createElement('div') }; + + const pluginView = plugin.spec.view(view); + + // Mock coordsAtPos to return null (presentation mode collapsed selection) + editor.coordsAtPos = vi.fn().mockReturnValue(null); + + // Build a new state where selection is at end (after @) + const endPos = state.doc.content.size - 1; + const tr = state.tr.setSelection(state.selection.constructor.near(state.doc.resolve(endPos))); + const newState = state.apply(tr); + + pluginView.update({ ...view, state: newState }, state); + + expect(tippyInstance.hide).toHaveBeenCalled(); + pluginView.destroy(); + }); + + it('uses insertMention as the prop name (not inserMention)', () => { + const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, schema.text('Hello @'))]); + const state = EditorState.create({ schema, doc, plugins: [plugin] }); + const view = { state, dom: document.createElement('div') }; + + const pluginView = plugin.spec.view(view); + + hoisted.createAppMock.mockClear(); + hoisted.createAppMock.mockReturnValue({ mount: vi.fn(), unmount: vi.fn() }); + + // Mock coordsAtPos to return valid coords so the popover renders + editor.coordsAtPos = vi.fn().mockReturnValue({ top: 100, bottom: 120, left: 50, right: 50 }); + + const endPos = state.doc.content.size - 1; + const tr = state.tr.setSelection(state.selection.constructor.near(state.doc.resolve(endPos))); + const newState = state.apply(tr); + + pluginView.update({ ...view, state: newState }, state); + + const calls = hoisted.createAppMock.mock.calls; + if (calls.length > 0) { + const [, props] = calls[calls.length - 1]; + expect(props).toHaveProperty('insertMention'); + expect(props).not.toHaveProperty('inserMention'); + } + + pluginView.destroy(); + }); });