Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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(<MathNodeView {...defaultProps} selected={true} />);
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(<MathNodeView {...defaultProps} editor={editor} selected={true} />);
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');
});
});

Expand All @@ -503,6 +504,15 @@ describe('MathNodeView', () => {
});
});

it('renders above other editor overlays with a high z-index', async () => {
const { container } = render(<MathNodeView {...defaultProps} selected={true} />);
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,
Expand All @@ -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(<MathNodeView {...defaultProps} editor={editor} selected={true} />);

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(<MathNodeView {...defaultProps} selected={true} />);
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 }));
Expand Down
93 changes: 81 additions & 12 deletions packages/editable-html-tip-tap/src/extensions/math.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 || {};
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -310,7 +379,7 @@ export const MathNodeView = (props) => {
}}
data-selected={selected}
>
<div onClick={() => setShowToolbar(true)} contentEditable={false}>
<div ref={nodeRef} onClick={() => setShowToolbar(true)} contentEditable={false}>
<MathPreview latex={latex} />
</div>
{showToolbar &&
Expand All @@ -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)',
Expand Down
Loading