From 54beb75afe197d157623bb7feec92796a196091a Mon Sep 17 00:00:00 2001 From: Lakatos Andrei Date: Tue, 23 Jun 2026 10:40:51 +0300 Subject: [PATCH 1/8] fix: math editor saves on close and focuses when switching node [PIE-695][PIE-697] --- .../src/extensions/__tests__/math.test.js | 25 ++++++++ .../src/extensions/math.js | 2 +- .../src/__tests__/editor-and-pad.test.js | 58 +++++++++++++++++++ packages/math-toolbar/src/editor-and-pad.jsx | 3 +- 4 files changed, 86 insertions(+), 2 deletions(-) diff --git a/packages/editable-html-tip-tap/src/extensions/__tests__/math.test.js b/packages/editable-html-tip-tap/src/extensions/__tests__/math.test.js index ac3d40393..a1a9d27e2 100644 --- a/packages/editable-html-tip-tap/src/extensions/__tests__/math.test.js +++ b/packages/editable-html-tip-tap/src/extensions/__tests__/math.test.js @@ -648,6 +648,31 @@ describe('MathNodeView', () => { }); }); + it('re-registers click listener when node changes', async () => { + const addEventListenerSpy = jest.spyOn(document, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener'); + const nodeA = { attrs: { latex: 'x^2' } }; + const nodeB = { attrs: { latex: 'y^2' } }; + + const { rerender } = render(); + + await waitFor(() => { + expect(addEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function)); + }); + + const initialCallCount = addEventListenerSpy.mock.calls.length; + + rerender(); + + await waitFor(() => { + expect(removeEventListenerSpy).toHaveBeenCalled(); + expect(addEventListenerSpy.mock.calls.length).toBeGreaterThan(initialCallCount); + }); + + addEventListenerSpy.mockRestore(); + removeEventListenerSpy.mockRestore(); + }); + it('does not close toolbar when clicking the math node preview', async () => { const { getByTestId, queryByTestId } = render(); diff --git a/packages/editable-html-tip-tap/src/extensions/math.js b/packages/editable-html-tip-tap/src/extensions/math.js index 5c100d560..3f79eeddb 100644 --- a/packages/editable-html-tip-tap/src/extensions/math.js +++ b/packages/editable-html-tip-tap/src/extensions/math.js @@ -298,7 +298,7 @@ export const MathNodeView = (props) => { } return () => document.removeEventListener('click', handleClickOutside); - }, [editor, showToolbar]); + }, [editor, showToolbar, node]); return ( { onBlur: jest.fn(), }; + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + it('renders with default props', () => { const { container } = render(); expect(container.firstChild).toBeInTheDocument(); }); + + describe('autoFocus', () => { + it('focuses input immediately via setTimeout when autoFocus is true', () => { + const mockFocus = jest.fn(); + const component = new EditorAndPad({ ...defaultProps, autoFocus: true }); + component.input = { focus: mockFocus }; + + component.componentDidMount(); + + expect(mockFocus).not.toHaveBeenCalled(); + + jest.runAllTimers(); + + expect(mockFocus).toHaveBeenCalledTimes(1); + }); + + it('does not focus input when autoFocus is false', () => { + const mockFocus = jest.fn(); + const component = new EditorAndPad({ ...defaultProps, autoFocus: false }); + component.input = { focus: mockFocus }; + + component.componentDidMount(); + jest.runAllTimers(); + + expect(mockFocus).not.toHaveBeenCalled(); + }); + + it('does not focus when input ref is not set', () => { + const component = new EditorAndPad({ ...defaultProps, autoFocus: true }); + component.input = null; + + expect(() => { + component.componentDidMount(); + jest.runAllTimers(); + }).not.toThrow(); + }); + + it('uses setTimeout with 0ms delay to defer focus', () => { + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + const mockFocus = jest.fn(); + const component = new EditorAndPad({ ...defaultProps, autoFocus: true }); + component.input = { focus: mockFocus }; + + component.componentDidMount(); + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 0); + + setTimeoutSpy.mockRestore(); + }); + }); }); diff --git a/packages/math-toolbar/src/editor-and-pad.jsx b/packages/math-toolbar/src/editor-and-pad.jsx index 56ce922f6..1dd4adc22 100644 --- a/packages/math-toolbar/src/editor-and-pad.jsx +++ b/packages/math-toolbar/src/editor-and-pad.jsx @@ -263,7 +263,8 @@ export class EditorAndPad extends React.Component { componentDidMount() { if (this.input && this.props.autoFocus) { - this.input.focus(); + // adding a timeout to wait for other stuff related to focus to be finished + setTimeout(() => this.input.focus(), 0); } } From 009cf1d56af3f5f436d2f09b33e4d6d0bfad8609 Mon Sep 17 00:00:00 2001 From: Patricia Romaniuc Date: Tue, 23 Jun 2026 14:09:36 +0300 Subject: [PATCH 2/8] fix(blank): prevent ResizeObserver feedback loops and re-measure dimensions on visibility --- packages/mask-markup/src/components/blank.jsx | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/mask-markup/src/components/blank.jsx b/packages/mask-markup/src/components/blank.jsx index 469c9971b..5413ded7c 100644 --- a/packages/mask-markup/src/components/blank.jsx +++ b/packages/mask-markup/src/components/blank.jsx @@ -106,6 +106,7 @@ function BlankContent({ const rootRef = useRef(null); const spanRef = useRef(null); const frozenRef = useRef(null); // to use during dragging to prevent flickering + const measuringRef = useRef(false); // guard against ResizeObserver feedback loops const [dimensions, setDimensions] = useState({ height: 0, width: 0 }); const handleImageLoad = () => { @@ -133,7 +134,8 @@ function BlankContent({ }; const updateDimensions = () => { - if (spanRef.current && rootRef.current) { + if (spanRef.current && rootRef.current && !measuringRef.current) { + measuringRef.current = true; // Temporarily set rootRef width to 'auto' for natural measurement rootRef.current.style.width = 'auto'; rootRef.current.style.height = 'auto'; @@ -164,8 +166,15 @@ function BlankContent({ height: adjustedHeight > responseAreaHeight ? adjustedHeight : prevState.height, })); - rootRef.current.style.width = `${adjustedWidth}px`; - rootRef.current.style.height = `${adjustedHeight}px`; + const nextWidth = `${adjustedWidth}px`; + const nextHeight = `${adjustedHeight}px`; + if (rootRef.current.style.width !== nextWidth) { + rootRef.current.style.width = nextWidth; + } + if (rootRef.current.style.height !== nextHeight) { + rootRef.current.style.height = nextHeight; + } + measuringRef.current = false; } }; @@ -191,6 +200,20 @@ function BlankContent({ handleElements(); }, []); + // Re-measure when the element first becomes visible — covers the tabbed-view case + // where the initial measurement happened while the tab was hidden (size 0). + useEffect(() => { + if (typeof IntersectionObserver === 'undefined' || !rootRef.current) return undefined; + const io = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) updateDimensions(); + }, + { threshold: 0 }, + ); + io.observe(rootRef.current); + return () => io.disconnect(); + }, []); + // Render math for the placeholder/preview when dragging over useEffect(() => { if (rootRef.current) { @@ -204,7 +227,7 @@ function BlankContent({ return; } handleElements(); - }, [choice]); + }, [choice?.value]); useEffect(() => { if (!isOver && !isDragging) { From f7402995deaf7687a4dd0f3d8f97b57a77c11d2b Mon Sep 17 00:00:00 2001 From: arimieandreea Date: Tue, 23 Jun 2026 17:19:29 +0300 Subject: [PATCH 3/8] fix: prevent toolbar from opening on text selection and Cmd+A PIE-699 --- .../editable-html-tip-tap/src/extensions/math.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/editable-html-tip-tap/src/extensions/math.js b/packages/editable-html-tip-tap/src/extensions/math.js index 5c100d560..97d297624 100644 --- a/packages/editable-html-tip-tap/src/extensions/math.js +++ b/packages/editable-html-tip-tap/src/extensions/math.js @@ -232,11 +232,19 @@ export const MathNodeView = (props) => { editor.commands.focus(); }; + // Only open the toolbar when this node is *explicitly* selected + // via a NodeSelection — not when it's merely included in a broader + // TextSelection or AllSelection (e.g. click-drag across math, or Cmd+A). useEffect(() => { - if (selected) { + if (!selected) return; + + const { selection } = editor.state; + const isNodeSelected = selection.node?.type?.name === 'math'; + + if (isNodeSelected) { setShowToolbar(true); } - }, [selected]); + }, [selected, editor]); useEffect(() => { setToolbarOpened(editor, selected || showToolbar); From dabe80923567799b4e81ca120426eae248bb9497 Mon Sep 17 00:00:00 2001 From: Patricia Romaniuc Date: Wed, 24 Jun 2026 14:03:37 +0300 Subject: [PATCH 4/8] fix(lockChoices): update logic to ensure instructor role does not lock choice order PIE-714 --- .../src/__tests__/persistence.test.js | 10 +++++----- packages/controller-utils/src/persistence.js | 19 ++++--------------- 2 files changed, 9 insertions(+), 20 deletions(-) diff --git a/packages/controller-utils/src/__tests__/persistence.test.js b/packages/controller-utils/src/__tests__/persistence.test.js index 2e9f992d0..574097c87 100644 --- a/packages/controller-utils/src/__tests__/persistence.test.js +++ b/packages/controller-utils/src/__tests__/persistence.test.js @@ -96,9 +96,9 @@ describe('lockChoices', () => { ${false} | ${undefined} | ${undefined} | ${false} ${undefined} | ${session()} | ${env(false)} | ${false} ${undefined} | ${session()} | ${env(undefined)} | ${false} - ${false} | ${session()} | ${env(false, 'instructor')} | ${true} - ${false} | ${session([0, 1])} | ${env(false, 'instructor')} | ${true} - ${false} | ${undefined} | ${env(false, 'instructor')} | ${true} + ${false} | ${session()} | ${env(false, 'instructor')} | ${false} + ${false} | ${session([0, 1])} | ${env(false, 'instructor')} | ${false} + ${false} | ${undefined} | ${env(false, 'instructor')} | ${false} `('1. model.lockChoiceOrder: $modelLock, $session, $env => $expected', ({ modelLock, session, env, expected }) => { const model = { lockChoiceOrder: modelLock }; const result = lockChoices(model, session, env); @@ -120,7 +120,7 @@ describe('lockChoices mod', () => { ${false} | ${session()} | ${env(false)} | ${false} ${undefined} | ${session()} | ${env(true)} | ${true} ${undefined} | ${session()} | ${env(undefined)} | ${false} - ${undefined} | ${session()} | ${env(undefined, 'instructor')} | ${true} + ${undefined} | ${session()} | ${env(undefined, 'instructor')} | ${false} `('2. model.lockChoiceOrder: $modelLock, $session, $env => $expected', ({ modelLock, session, env, expected }) => { const model = { lockChoiceOrder: modelLock }; const result = lockChoices(model, session, env); @@ -142,7 +142,7 @@ describe('lockChoices', () => { ${false} | ${session()} | ${env(false)} | ${false} ${undefined} | ${session()} | ${env(true)} | ${true} ${undefined} | ${session()} | ${env(undefined)} | ${false} - ${undefined} | ${session()} | ${env(undefined, 'instructor')} | ${true} + ${undefined} | ${session()} | ${env(undefined, 'instructor')} | ${false} `('3. model.lockChoiceOrder: $modelLock, $env => $expected', ({ modelLock, session, env, expected }) => { const model = { lockChoiceOrder: modelLock }; const result = lockChoices(model, session, env); diff --git a/packages/controller-utils/src/persistence.js b/packages/controller-utils/src/persistence.js index a52923784..234c7b99c 100644 --- a/packages/controller-utils/src/persistence.js +++ b/packages/controller-utils/src/persistence.js @@ -69,6 +69,10 @@ export const getShuffledChoices = (choices, session, updateSession, choiceKey) = * - true - that means that the order of the choices will be ordinal (as is created in the configure item) * - false - that means the getShuffledChoices above will be called and that in turn means that we either * return the shuffled values on the session (if any exists) or we shuffle the choices + * + * Note: the role (student/instructor) is intentionally not considered here — instructor mode + * will respect the same `lockChoiceOrder` value as students, instead of forcing the order to be locked. + * * @param model - model to check if we should lock order * @param session - session to check if we should lock order * @param env - env to check if we should lock order @@ -85,20 +89,5 @@ export const lockChoices = (model, session, env) => { return true; } - const role = get(env, 'role', 'student'); - - if (role === 'instructor') { - // TODO: .. in the future the instructor can toggle between ordinal and shuffled here, so keeping this code until then - /*const alreadyShuffled = hasShuffledValues(session); - - if (alreadyShuffled) { - return false; - } - - return true;*/ - return true; - } - - // here it's a student, so don't lock and it will shuffle if needs be return false; }; From af3b72cb60c2ec31f6c9a1a6063e25af3439fd3c Mon Sep 17 00:00:00 2001 From: arimieandreea Date: Wed, 24 Jun 2026 16:06:54 +0300 Subject: [PATCH 5/8] fix: add test for math node selection PIE-699 --- .../src/extensions/__tests__/math.test.js | 56 ++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/editable-html-tip-tap/src/extensions/__tests__/math.test.js b/packages/editable-html-tip-tap/src/extensions/__tests__/math.test.js index ac3d40393..a956c1b12 100644 --- a/packages/editable-html-tip-tap/src/extensions/__tests__/math.test.js +++ b/packages/editable-html-tip-tap/src/extensions/__tests__/math.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import { render, waitFor, fireEvent } from '@testing-library/react'; +import { render, waitFor, fireEvent, act } from '@testing-library/react'; import { EnsureTextAfterMathPlugin, MathNode, MathNodeView, ZeroWidthSpaceHandlingPlugin } from '../math'; import * as toolbarUtils from '../../utils/toolbar'; @@ -390,6 +390,7 @@ describe('MathNodeView', () => { selection: { from: 0, to: 1, + node: { type: { name: 'math' } }, }, tr: { setSelection: jest.fn().mockReturnThis(), @@ -724,6 +725,59 @@ describe('MathNodeView', () => { }); }); + describe('selection-based toolbar guard', () => { + it('opens toolbar when selected transitions to true and the editor has a NodeSelection on math', async () => { + // Start unselected (as the component always mounts in the real editor), + // then simulate TipTap delivering selected=true with a proper math NodeSelection. + const { queryByTestId, rerender } = render(); + expect(queryByTestId('math-toolbar')).not.toBeInTheDocument(); + + rerender(); + await waitFor(() => { + expect(queryByTestId('math-toolbar')).toBeInTheDocument(); + }); + }); + + it('does not open toolbar when selected briefly becomes true but editor selection has no node (Cmd+A / drag case)', async () => { + // Real-world timing: ProseMirror calls selectNode() (setting selected=true) + // while the editor's actual selection is already a TextSelection or AllSelection + // with no `.node`. The effect must notice this and keep the toolbar closed. + const editor = { + ...defaultProps.editor, + state: { + ...defaultProps.editor.state, + selection: { from: 0, to: 100 }, // no .node — TextSelection / AllSelection shape + }, + }; + + const { queryByTestId, rerender } = render( + , + ); + rerender(); + + await act(async () => {}); + expect(queryByTestId('math-toolbar')).not.toBeInTheDocument(); + }); + + it('does not open toolbar when selected briefly becomes true but NodeSelection targets a non-math node', async () => { + const editor = { + ...defaultProps.editor, + state: { + ...defaultProps.editor.state, + selection: { from: 0, to: 1, node: { type: { name: 'image' } } }, + }, + }; + + const { queryByTestId, rerender } = render( + , + ); + rerender(); + + await act(async () => {}); + expect(queryByTestId('math-toolbar')).not.toBeInTheDocument(); + }); + }); + it('does not close toolbar when clicking equation editor dropdown', async () => { const { queryByTestId } = render(); From cd4dc8939f7b8313a80290176806467ef9a928ec Mon Sep 17 00:00:00 2001 From: arimieandreea Date: Wed, 24 Jun 2026 16:08:25 +0300 Subject: [PATCH 6/8] fix: add test for math node selection PIE-699 --- .../src/extensions/__tests__/math.test.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/editable-html-tip-tap/src/extensions/__tests__/math.test.js b/packages/editable-html-tip-tap/src/extensions/__tests__/math.test.js index a956c1b12..35087bc40 100644 --- a/packages/editable-html-tip-tap/src/extensions/__tests__/math.test.js +++ b/packages/editable-html-tip-tap/src/extensions/__tests__/math.test.js @@ -727,8 +727,6 @@ describe('MathNodeView', () => { describe('selection-based toolbar guard', () => { it('opens toolbar when selected transitions to true and the editor has a NodeSelection on math', async () => { - // Start unselected (as the component always mounts in the real editor), - // then simulate TipTap delivering selected=true with a proper math NodeSelection. const { queryByTestId, rerender } = render(); expect(queryByTestId('math-toolbar')).not.toBeInTheDocument(); @@ -739,9 +737,6 @@ describe('MathNodeView', () => { }); it('does not open toolbar when selected briefly becomes true but editor selection has no node (Cmd+A / drag case)', async () => { - // Real-world timing: ProseMirror calls selectNode() (setting selected=true) - // while the editor's actual selection is already a TextSelection or AllSelection - // with no `.node`. The effect must notice this and keep the toolbar closed. const editor = { ...defaultProps.editor, state: { From 41be4e3f317e6fc23ddae7e3c8c65ff5a6253a0d Mon Sep 17 00:00:00 2001 From: Andrei Miron Date: Wed, 24 Jun 2026 16:37:12 +0300 Subject: [PATCH 7/8] fix(editable-html): preserve existing spans on load and prevent unnecessary updates PIE-665 --- .../src/components/EditableHtml.jsx | 2 +- .../src/extensions/css.js | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/editable-html-tip-tap/src/components/EditableHtml.jsx b/packages/editable-html-tip-tap/src/components/EditableHtml.jsx index d398d2735..129aa50a9 100644 --- a/packages/editable-html-tip-tap/src/components/EditableHtml.jsx +++ b/packages/editable-html-tip-tap/src/components/EditableHtml.jsx @@ -334,7 +334,7 @@ export const EditableHtml = (props) => { const nextMarkup = normalizeInitialMarkup(props.markup); if (nextMarkup !== editor.getHTML()) { - editor.commands.setContent(nextMarkup, false); + editor.commands.setContent(nextMarkup, { emitUpdate: false }); } }, [props.markup, editor]); diff --git a/packages/editable-html-tip-tap/src/extensions/css.js b/packages/editable-html-tip-tap/src/extensions/css.js index b2264d6c1..101204bc3 100644 --- a/packages/editable-html-tip-tap/src/extensions/css.js +++ b/packages/editable-html-tip-tap/src/extensions/css.js @@ -169,14 +169,27 @@ export const CSSMark = Mark.create({ }, parseHTML() { - // Any span with a class that matches one of allowed classes + // Any span with a class that matches one of allowed classes any span that carries a class attribute + // so that pre-existing spans are preserved when loading content return [ { tag: 'span[class]', getAttrs: (el) => { const cls = el.getAttribute('class') || ''; - const match = this.options.classes.find((name) => cls.includes(name)); - return match ? { class: match } : false; + + if (!cls) { + return false; + } + + const allowedClasses = (this.options && this.options.classes) || []; + + if (allowedClasses.length > 0) { + const match = this.options.classes.find((name) => cls.includes(name)); + + return match ? { class: match } : false; + } + + return { class: cls }; }, }, ]; From 0f43d5ae119d4e52263593180fb009821d9e5211 Mon Sep 17 00:00:00 2001 From: Lakatos Andrei Date: Thu, 25 Jun 2026 11:40:15 +0300 Subject: [PATCH 8/8] fix: place math toolbar inside viewport and move it accordingly when window resizes [PIE-656] --- .../src/extensions/__tests__/math.test.js | 81 ++++++++++++++-- .../src/extensions/math.js | 93 ++++++++++++++++--- 2 files changed, 153 insertions(+), 21 deletions(-) diff --git a/packages/editable-html-tip-tap/src/extensions/__tests__/math.test.js b/packages/editable-html-tip-tap/src/extensions/__tests__/math.test.js index ac3d40393..2891c6689 100644 --- a/packages/editable-html-tip-tap/src/extensions/__tests__/math.test.js +++ b/packages/editable-html-tip-tap/src/extensions/__tests__/math.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import { render, waitFor, fireEvent } from '@testing-library/react'; +import { render, waitFor, fireEvent, act } from '@testing-library/react'; import { EnsureTextAfterMathPlugin, MathNode, MathNodeView, ZeroWidthSpaceHandlingPlugin } from '../math'; import * as toolbarUtils from '../../utils/toolbar'; @@ -467,30 +467,31 @@ describe('MathNodeView', () => { }); describe('toolbar positioning', () => { - it('positions relative to the editor element using coordsAtPos', async () => { + it('positions relative to portal container using coordsAtPos', async () => { const { container } = render(); await waitFor(() => { const toolbar = container.querySelector('[data-toolbar-for]'); expect(toolbar).toBeInTheDocument(); - expect(toolbar.style.top).toBe('140px'); + expect(toolbar.style.top).toBe('100px'); expect(toolbar.style.left).toBe('50px'); }); }); - it('accounts for editor scroll offset when calculating toolbar position', async () => { - const editorElement = createEditorElement({ top: -200, left: 0, width: 600, height: 400 }); + it('offsets position by portal container getBoundingClientRect', async () => { + const containerEl = document.createElement('div'); + containerEl.getBoundingClientRect = jest.fn(() => ({ top: 100, left: 50, width: 600, height: 400 })); const editor = { ...defaultProps.editor, - options: { element: editorElement }, + _tiptapContainerEl: containerEl, }; const { container } = render(); await waitFor(() => { const toolbar = container.querySelector('[data-toolbar-for]'); expect(toolbar).toBeInTheDocument(); - expect(toolbar.style.top).toBe('340px'); - expect(toolbar.style.left).toBe('50px'); + expect(toolbar.style.top).toBe('0px'); + expect(toolbar.style.left).toBe('0px'); }); }); @@ -503,6 +504,15 @@ describe('MathNodeView', () => { }); }); + it('renders above other editor overlays with a high z-index', async () => { + const { container } = render(); + await waitFor(() => { + const toolbar = container.querySelector('[data-toolbar-for]'); + expect(toolbar).toBeInTheDocument(); + expect(toolbar.style.zIndex).toBe('1000'); + }); + }); + it('updates position from coordsAtPos when selection changes', async () => { const editor = { ...defaultProps.editor, @@ -517,11 +527,64 @@ describe('MathNodeView', () => { await waitFor(() => { const toolbar = container.querySelector('[data-toolbar-for]'); expect(toolbar).toBeInTheDocument(); - expect(toolbar.style.top).toBe('240px'); + expect(toolbar.style.top).toBe('200px'); expect(toolbar.style.left).toBe('150px'); }); }); + it('clamps toolbar position to viewport margins', async () => { + const originalInnerHeight = window.innerHeight; + const originalInnerWidth = window.innerWidth; + + Object.defineProperty(window, 'innerHeight', { configurable: true, writable: true, value: 200 }); + Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 300 }); + + const editor = { + ...defaultProps.editor, + view: { + ...defaultProps.editor.view, + coordsAtPos: jest.fn(() => ({ top: 190, left: 280, bottom: 195 })), + dispatch: jest.fn(), + }, + }; + + const { container } = render(); + + let toolbar; + await waitFor(() => { + toolbar = container.querySelector('[data-toolbar-for]'); + expect(toolbar).toBeInTheDocument(); + }); + + Object.defineProperty(toolbar, 'offsetHeight', { configurable: true, value: 100 }); + Object.defineProperty(toolbar, 'offsetWidth', { configurable: true, value: 150 }); + + await act(async () => { + window.dispatchEvent(new Event('resize')); + await new Promise((resolve) => requestAnimationFrame(resolve)); + }); + + await waitFor(() => { + expect(parseInt(toolbar.style.top, 10)).toBeLessThanOrEqual(200 - 100 - 8); + expect(parseInt(toolbar.style.left, 10)).toBeLessThanOrEqual(300 - 150 - 8); + expect(parseInt(toolbar.style.top, 10)).toBeGreaterThanOrEqual(8); + expect(parseInt(toolbar.style.left, 10)).toBeGreaterThanOrEqual(8); + }); + + Object.defineProperty(window, 'innerHeight', { configurable: true, writable: true, value: originalInnerHeight }); + Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: originalInnerWidth }); + }); + + it('attaches scroll and resize listeners while toolbar is open', async () => { + const addSpy = jest.spyOn(window, 'addEventListener'); + render(); + await waitFor(() => { + expect(addSpy).toHaveBeenCalledWith('scroll', expect.any(Function), true); + expect(addSpy).toHaveBeenCalledWith('resize', expect.any(Function)); + }); + addSpy.mockRestore(); + }); + it('portals toolbar into _tiptapContainerEl when available', async () => { const containerEl = document.createElement('div'); containerEl.getBoundingClientRect = jest.fn(() => ({ top: 0, left: 0, width: 600, height: 400 })); diff --git a/packages/editable-html-tip-tap/src/extensions/math.js b/packages/editable-html-tip-tap/src/extensions/math.js index 5c100d560..bb6e7347f 100644 --- a/packages/editable-html-tip-tap/src/extensions/math.js +++ b/packages/editable-html-tip-tap/src/extensions/math.js @@ -23,7 +23,9 @@ export const EnsureTextAfterMathPlugin = (mathNodeName) => key: ensureTextAfterMathPluginKey, appendTransaction: (transactions, oldState, newState) => { // Only act when the doc actually changed - if (!transactions.some((tr) => tr.docChanged)) return null; + if (!transactions.some((tr) => tr.docChanged)) { + return null; + } const tr = newState.tr; let changed = false; @@ -202,6 +204,7 @@ export const MathNodeView = (props) => { const { node, updateAttributes, editor, selected, options } = props; const [showToolbar, setShowToolbar] = useState(selected); const toolbarRef = useRef(null); + const nodeRef = useRef(null); const timestamp = useRef(Date.now()); const [position, setPosition] = useState({ top: 0, left: 0 }); const { math: mathOptions = {} } = options || {}; @@ -243,17 +246,83 @@ export const MathNodeView = (props) => { }, [editor, showToolbar, selected]); useEffect(() => { - // Calculate position relative to selection - const { from } = editor.state.selection; - const start = editor.view.coordsAtPos(from); - const editorDOM = editor.options.element; - const editorRect = editorDOM.getBoundingClientRect(); - - setPosition({ - top: start.top - editorRect.top + 40, // shift above - left: start.left - editorRect.left, + if (!editor || !showToolbar) { + setPosition({ top: 0, left: 0 }); + return; + } + + // Clamp in viewport coordinates, then convert to portal-container-relative values + // for position: absolute (toolbar is portaled into _tiptapContainerEl or document.body). + const updatePosition = () => { + if (!toolbarRef.current) { + return; + } + + const { from } = editor.state.selection; + const start = editor.view.coordsAtPos(from); + const nodeRect = nodeRef.current?.getBoundingClientRect?.(); + + // Anchor to the math node element when available; fall back to selection coords. + const anchorTop = nodeRect?.height ? nodeRect.top : start.top; + const anchorLeft = nodeRect?.width ? nodeRect.left : start.left; + const anchorBottom = nodeRect?.height ? nodeRect.bottom : (start.bottom ?? start.top); + + const toolbarHeight = toolbarRef.current.offsetHeight; + const toolbarWidth = toolbarRef.current.offsetWidth; + + const gap = 0; + const spaceBelow = window.innerHeight - (anchorBottom + gap); + + // Place the toolbar's top-left corner directly below the anchor; flip above when needed. + let top = spaceBelow >= toolbarHeight ? anchorBottom + gap : anchorTop - toolbarHeight - gap; + let left = anchorLeft; + + const margin = 8; + top = Math.max(margin, Math.min(top, window.innerHeight - toolbarHeight - margin)); + left = Math.max(margin, Math.min(left, window.innerWidth - toolbarWidth - margin)); + + const portalEl = editor._tiptapContainerEl || document.body; + const containerRect = portalEl.getBoundingClientRect(); + + setPosition({ + top: top - containerRect.top, + left: left - containerRect.left, + }); + }; + + updatePosition(); + + let frame = null; + const scheduleUpdate = () => { + if (frame !== null) { + return; + } + + frame = requestAnimationFrame(() => { + frame = null; + updatePosition(); + }); + }; + + frame = requestAnimationFrame(() => { + frame = null; + updatePosition(); }); + window.addEventListener('scroll', scheduleUpdate, true); + window.addEventListener('resize', scheduleUpdate); + + return () => { + if (frame !== null) { + cancelAnimationFrame(frame); + } + + window.removeEventListener('scroll', scheduleUpdate, true); + window.removeEventListener('resize', scheduleUpdate); + }; + }, [editor, showToolbar]); + + useEffect(() => { const handleClickOutside = (event) => { const target = event?.target; @@ -310,7 +379,7 @@ export const MathNodeView = (props) => { }} data-selected={selected} > -
setShowToolbar(true)} contentEditable={false}> +
setShowToolbar(true)} contentEditable={false}>
{showToolbar && @@ -322,7 +391,7 @@ export const MathNodeView = (props) => { position: 'absolute', top: `${position.top}px`, left: `${position.left}px`, - zIndex: 20, + zIndex: 1000, background: 'var(--editable-html-toolbar-bg, #efefef)', boxShadow: '0px 1px 5px 0px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 3px 1px -2px rgba(0, 0, 0, 0.12)',