Skip to content

Commit 35de54d

Browse files
authored
Merge pull request #2276 from pie-framework/fix/PIE-656
fix: place math toolbar inside viewport and move it accordingly when window resizes [PIE-656]
2 parents 01bd879 + 0f43d5a commit 35de54d

2 files changed

Lines changed: 152 additions & 20 deletions

File tree

packages/editable-html-tip-tap/src/extensions/__tests__/math.test.js

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -468,30 +468,31 @@ describe('MathNodeView', () => {
468468
});
469469

470470
describe('toolbar positioning', () => {
471-
it('positions relative to the editor element using coordsAtPos', async () => {
471+
it('positions relative to portal container using coordsAtPos', async () => {
472472
const { container } = render(<MathNodeView {...defaultProps} selected={true} />);
473473
await waitFor(() => {
474474
const toolbar = container.querySelector('[data-toolbar-for]');
475475
expect(toolbar).toBeInTheDocument();
476-
expect(toolbar.style.top).toBe('140px');
476+
expect(toolbar.style.top).toBe('100px');
477477
expect(toolbar.style.left).toBe('50px');
478478
});
479479
});
480480

481-
it('accounts for editor scroll offset when calculating toolbar position', async () => {
482-
const editorElement = createEditorElement({ top: -200, left: 0, width: 600, height: 400 });
481+
it('offsets position by portal container getBoundingClientRect', async () => {
482+
const containerEl = document.createElement('div');
483+
containerEl.getBoundingClientRect = jest.fn(() => ({ top: 100, left: 50, width: 600, height: 400 }));
483484

484485
const editor = {
485486
...defaultProps.editor,
486-
options: { element: editorElement },
487+
_tiptapContainerEl: containerEl,
487488
};
488489

489490
const { container } = render(<MathNodeView {...defaultProps} editor={editor} selected={true} />);
490491
await waitFor(() => {
491492
const toolbar = container.querySelector('[data-toolbar-for]');
492493
expect(toolbar).toBeInTheDocument();
493-
expect(toolbar.style.top).toBe('340px');
494-
expect(toolbar.style.left).toBe('50px');
494+
expect(toolbar.style.top).toBe('0px');
495+
expect(toolbar.style.left).toBe('0px');
495496
});
496497
});
497498

@@ -504,6 +505,15 @@ describe('MathNodeView', () => {
504505
});
505506
});
506507

508+
it('renders above other editor overlays with a high z-index', async () => {
509+
const { container } = render(<MathNodeView {...defaultProps} selected={true} />);
510+
await waitFor(() => {
511+
const toolbar = container.querySelector('[data-toolbar-for]');
512+
expect(toolbar).toBeInTheDocument();
513+
expect(toolbar.style.zIndex).toBe('1000');
514+
});
515+
});
516+
507517
it('updates position from coordsAtPos when selection changes', async () => {
508518
const editor = {
509519
...defaultProps.editor,
@@ -518,11 +528,64 @@ describe('MathNodeView', () => {
518528
await waitFor(() => {
519529
const toolbar = container.querySelector('[data-toolbar-for]');
520530
expect(toolbar).toBeInTheDocument();
521-
expect(toolbar.style.top).toBe('240px');
531+
expect(toolbar.style.top).toBe('200px');
522532
expect(toolbar.style.left).toBe('150px');
523533
});
524534
});
525535

536+
it('clamps toolbar position to viewport margins', async () => {
537+
const originalInnerHeight = window.innerHeight;
538+
const originalInnerWidth = window.innerWidth;
539+
540+
Object.defineProperty(window, 'innerHeight', { configurable: true, writable: true, value: 200 });
541+
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 300 });
542+
543+
const editor = {
544+
...defaultProps.editor,
545+
view: {
546+
...defaultProps.editor.view,
547+
coordsAtPos: jest.fn(() => ({ top: 190, left: 280, bottom: 195 })),
548+
dispatch: jest.fn(),
549+
},
550+
};
551+
552+
const { container } = render(<MathNodeView {...defaultProps} editor={editor} selected={true} />);
553+
554+
let toolbar;
555+
await waitFor(() => {
556+
toolbar = container.querySelector('[data-toolbar-for]');
557+
expect(toolbar).toBeInTheDocument();
558+
});
559+
560+
Object.defineProperty(toolbar, 'offsetHeight', { configurable: true, value: 100 });
561+
Object.defineProperty(toolbar, 'offsetWidth', { configurable: true, value: 150 });
562+
563+
await act(async () => {
564+
window.dispatchEvent(new Event('resize'));
565+
await new Promise((resolve) => requestAnimationFrame(resolve));
566+
});
567+
568+
await waitFor(() => {
569+
expect(parseInt(toolbar.style.top, 10)).toBeLessThanOrEqual(200 - 100 - 8);
570+
expect(parseInt(toolbar.style.left, 10)).toBeLessThanOrEqual(300 - 150 - 8);
571+
expect(parseInt(toolbar.style.top, 10)).toBeGreaterThanOrEqual(8);
572+
expect(parseInt(toolbar.style.left, 10)).toBeGreaterThanOrEqual(8);
573+
});
574+
575+
Object.defineProperty(window, 'innerHeight', { configurable: true, writable: true, value: originalInnerHeight });
576+
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: originalInnerWidth });
577+
});
578+
579+
it('attaches scroll and resize listeners while toolbar is open', async () => {
580+
const addSpy = jest.spyOn(window, 'addEventListener');
581+
render(<MathNodeView {...defaultProps} selected={true} />);
582+
await waitFor(() => {
583+
expect(addSpy).toHaveBeenCalledWith('scroll', expect.any(Function), true);
584+
expect(addSpy).toHaveBeenCalledWith('resize', expect.any(Function));
585+
});
586+
addSpy.mockRestore();
587+
});
588+
526589
it('portals toolbar into _tiptapContainerEl when available', async () => {
527590
const containerEl = document.createElement('div');
528591
containerEl.getBoundingClientRect = jest.fn(() => ({ top: 0, left: 0, width: 600, height: 400 }));

packages/editable-html-tip-tap/src/extensions/math.js

Lines changed: 81 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ export const EnsureTextAfterMathPlugin = (mathNodeName) =>
2323
key: ensureTextAfterMathPluginKey,
2424
appendTransaction: (transactions, oldState, newState) => {
2525
// Only act when the doc actually changed
26-
if (!transactions.some((tr) => tr.docChanged)) return null;
26+
if (!transactions.some((tr) => tr.docChanged)) {
27+
return null;
28+
}
2729

2830
const tr = newState.tr;
2931
let changed = false;
@@ -202,6 +204,7 @@ export const MathNodeView = (props) => {
202204
const { node, updateAttributes, editor, selected, options } = props;
203205
const [showToolbar, setShowToolbar] = useState(selected);
204206
const toolbarRef = useRef(null);
207+
const nodeRef = useRef(null);
205208
const timestamp = useRef(Date.now());
206209
const [position, setPosition] = useState({ top: 0, left: 0 });
207210
const { math: mathOptions = {} } = options || {};
@@ -251,17 +254,83 @@ export const MathNodeView = (props) => {
251254
}, [editor, showToolbar, selected]);
252255

253256
useEffect(() => {
254-
// Calculate position relative to selection
255-
const { from } = editor.state.selection;
256-
const start = editor.view.coordsAtPos(from);
257-
const editorDOM = editor.options.element;
258-
const editorRect = editorDOM.getBoundingClientRect();
259-
260-
setPosition({
261-
top: start.top - editorRect.top + 40, // shift above
262-
left: start.left - editorRect.left,
257+
if (!editor || !showToolbar) {
258+
setPosition({ top: 0, left: 0 });
259+
return;
260+
}
261+
262+
// Clamp in viewport coordinates, then convert to portal-container-relative values
263+
// for position: absolute (toolbar is portaled into _tiptapContainerEl or document.body).
264+
const updatePosition = () => {
265+
if (!toolbarRef.current) {
266+
return;
267+
}
268+
269+
const { from } = editor.state.selection;
270+
const start = editor.view.coordsAtPos(from);
271+
const nodeRect = nodeRef.current?.getBoundingClientRect?.();
272+
273+
// Anchor to the math node element when available; fall back to selection coords.
274+
const anchorTop = nodeRect?.height ? nodeRect.top : start.top;
275+
const anchorLeft = nodeRect?.width ? nodeRect.left : start.left;
276+
const anchorBottom = nodeRect?.height ? nodeRect.bottom : (start.bottom ?? start.top);
277+
278+
const toolbarHeight = toolbarRef.current.offsetHeight;
279+
const toolbarWidth = toolbarRef.current.offsetWidth;
280+
281+
const gap = 0;
282+
const spaceBelow = window.innerHeight - (anchorBottom + gap);
283+
284+
// Place the toolbar's top-left corner directly below the anchor; flip above when needed.
285+
let top = spaceBelow >= toolbarHeight ? anchorBottom + gap : anchorTop - toolbarHeight - gap;
286+
let left = anchorLeft;
287+
288+
const margin = 8;
289+
top = Math.max(margin, Math.min(top, window.innerHeight - toolbarHeight - margin));
290+
left = Math.max(margin, Math.min(left, window.innerWidth - toolbarWidth - margin));
291+
292+
const portalEl = editor._tiptapContainerEl || document.body;
293+
const containerRect = portalEl.getBoundingClientRect();
294+
295+
setPosition({
296+
top: top - containerRect.top,
297+
left: left - containerRect.left,
298+
});
299+
};
300+
301+
updatePosition();
302+
303+
let frame = null;
304+
const scheduleUpdate = () => {
305+
if (frame !== null) {
306+
return;
307+
}
308+
309+
frame = requestAnimationFrame(() => {
310+
frame = null;
311+
updatePosition();
312+
});
313+
};
314+
315+
frame = requestAnimationFrame(() => {
316+
frame = null;
317+
updatePosition();
263318
});
264319

320+
window.addEventListener('scroll', scheduleUpdate, true);
321+
window.addEventListener('resize', scheduleUpdate);
322+
323+
return () => {
324+
if (frame !== null) {
325+
cancelAnimationFrame(frame);
326+
}
327+
328+
window.removeEventListener('scroll', scheduleUpdate, true);
329+
window.removeEventListener('resize', scheduleUpdate);
330+
};
331+
}, [editor, showToolbar]);
332+
333+
useEffect(() => {
265334
const handleClickOutside = (event) => {
266335
const target = event?.target;
267336

@@ -318,7 +387,7 @@ export const MathNodeView = (props) => {
318387
}}
319388
data-selected={selected}
320389
>
321-
<div onClick={() => setShowToolbar(true)} contentEditable={false}>
390+
<div ref={nodeRef} onClick={() => setShowToolbar(true)} contentEditable={false}>
322391
<MathPreview latex={latex} />
323392
</div>
324393
{showToolbar &&
@@ -330,7 +399,7 @@ export const MathNodeView = (props) => {
330399
position: 'absolute',
331400
top: `${position.top}px`,
332401
left: `${position.left}px`,
333-
zIndex: 20,
402+
zIndex: 1000,
334403
background: 'var(--editable-html-toolbar-bg, #efefef)',
335404
boxShadow:
336405
'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)',

0 commit comments

Comments
 (0)