@pie-lib/editable-html-tip-tap 2.1.6 → 2.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -185,6 +185,85 @@ describe('InlineDropdown', () => {
185
185
  });
186
186
  });
187
187
 
188
+ it('uses the current node when closing on outside click after the node prop changes', async () => {
189
+ const onToolbarCloseRequest = jest.fn((_tuple, _editor, onConfirm) => onConfirm());
190
+ const options = {
191
+ ...mockOptions,
192
+ onToolbarCloseRequest,
193
+ };
194
+ const updatedNode = {
195
+ attrs: { index: '1', value: 'Updated Option', error: false },
196
+ nodeSize: 1,
197
+ };
198
+
199
+ const { queryByTestId, rerender } = render(<InlineDropdown {...defaultProps} options={options} selected={true} />);
200
+
201
+ await waitFor(() => {
202
+ expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument();
203
+ });
204
+
205
+ rerender(<InlineDropdown {...defaultProps} options={options} node={updatedNode} selected={true} />);
206
+
207
+ fireEvent.mouseDown(document.body);
208
+
209
+ await waitFor(() => {
210
+ expect(onToolbarCloseRequest).toHaveBeenCalledWith(
211
+ [updatedNode, 5],
212
+ expect.anything(),
213
+ expect.any(Function),
214
+ expect.any(Function),
215
+ );
216
+ });
217
+ });
218
+
219
+ it('respects hold state for the current node after the node prop changes', async () => {
220
+ const updatedNode = {
221
+ attrs: { index: '1', value: 'Updated Option', error: false },
222
+ nodeSize: 1,
223
+ };
224
+
225
+ mockEditor._holdInlineDropdownToolbarIndex = '1';
226
+
227
+ const { queryByTestId, rerender } = render(<InlineDropdown {...defaultProps} selected={true} />);
228
+
229
+ await waitFor(() => {
230
+ expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument();
231
+ });
232
+
233
+ rerender(<InlineDropdown {...defaultProps} node={updatedNode} selected={true} />);
234
+
235
+ fireEvent.mouseDown(document.body);
236
+
237
+ await waitFor(() => {
238
+ expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument();
239
+ });
240
+ });
241
+
242
+ it('rebinds the outside-click listener when the node prop changes while the toolbar is open', async () => {
243
+ const addSpy = jest.spyOn(document, 'addEventListener');
244
+ const removeSpy = jest.spyOn(document, 'removeEventListener');
245
+ const updatedNode = { ...mockNode, attrs: { ...mockNode.attrs, value: 'Updated Option' } };
246
+
247
+ const { rerender } = render(<InlineDropdown {...defaultProps} selected={true} />);
248
+
249
+ await waitFor(() => {
250
+ expect(addSpy).toHaveBeenCalledWith('mousedown', expect.any(Function));
251
+ });
252
+
253
+ const removeCallsBeforeRerender = removeSpy.mock.calls.filter(([type]) => type === 'mousedown').length;
254
+
255
+ rerender(<InlineDropdown {...defaultProps} node={updatedNode} selected={true} />);
256
+
257
+ await waitFor(() => {
258
+ const removeCallsAfterRerender = removeSpy.mock.calls.filter(([type]) => type === 'mousedown').length;
259
+ expect(removeCallsAfterRerender).toBeGreaterThan(removeCallsBeforeRerender);
260
+ expect(addSpy).toHaveBeenCalledWith('mousedown', expect.any(Function));
261
+ });
262
+
263
+ addSpy.mockRestore();
264
+ removeSpy.mockRestore();
265
+ });
266
+
188
267
  it('has correct border styling', () => {
189
268
  const { container } = render(<InlineDropdown {...defaultProps} />);
190
269
  const dropdownDiv = container.querySelector('div[style*="border"]');
@@ -126,7 +126,7 @@ const InlineDropdown = (props) => {
126
126
  }
127
127
 
128
128
  return () => document.removeEventListener('mousedown', handleClickOutside);
129
- }, [showToolbar]);
129
+ }, [showToolbar, node]);
130
130
 
131
131
  return (
132
132
  <NodeViewWrapper
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { render, waitFor, fireEvent } from '@testing-library/react';
2
+ import { render, waitFor, fireEvent, act } from '@testing-library/react';
3
3
  import { EnsureTextAfterMathPlugin, MathNode, MathNodeView, ZeroWidthSpaceHandlingPlugin } from '../math';
4
4
  import * as toolbarUtils from '../../utils/toolbar';
5
5
 
@@ -390,6 +390,7 @@ describe('MathNodeView', () => {
390
390
  selection: {
391
391
  from: 0,
392
392
  to: 1,
393
+ node: { type: { name: 'math' } },
393
394
  },
394
395
  tr: {
395
396
  setSelection: jest.fn().mockReturnThis(),
@@ -467,30 +468,31 @@ describe('MathNodeView', () => {
467
468
  });
468
469
 
469
470
  describe('toolbar positioning', () => {
470
- it('positions relative to the editor element using coordsAtPos', async () => {
471
+ it('positions relative to portal container using coordsAtPos', async () => {
471
472
  const { container } = render(<MathNodeView {...defaultProps} selected={true} />);
472
473
  await waitFor(() => {
473
474
  const toolbar = container.querySelector('[data-toolbar-for]');
474
475
  expect(toolbar).toBeInTheDocument();
475
- expect(toolbar.style.top).toBe('140px');
476
+ expect(toolbar.style.top).toBe('100px');
476
477
  expect(toolbar.style.left).toBe('50px');
477
478
  });
478
479
  });
479
480
 
480
- it('accounts for editor scroll offset when calculating toolbar position', async () => {
481
- 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 }));
482
484
 
483
485
  const editor = {
484
486
  ...defaultProps.editor,
485
- options: { element: editorElement },
487
+ _tiptapContainerEl: containerEl,
486
488
  };
487
489
 
488
490
  const { container } = render(<MathNodeView {...defaultProps} editor={editor} selected={true} />);
489
491
  await waitFor(() => {
490
492
  const toolbar = container.querySelector('[data-toolbar-for]');
491
493
  expect(toolbar).toBeInTheDocument();
492
- expect(toolbar.style.top).toBe('340px');
493
- expect(toolbar.style.left).toBe('50px');
494
+ expect(toolbar.style.top).toBe('0px');
495
+ expect(toolbar.style.left).toBe('0px');
494
496
  });
495
497
  });
496
498
 
@@ -503,6 +505,15 @@ describe('MathNodeView', () => {
503
505
  });
504
506
  });
505
507
 
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
+
506
517
  it('updates position from coordsAtPos when selection changes', async () => {
507
518
  const editor = {
508
519
  ...defaultProps.editor,
@@ -517,11 +528,64 @@ describe('MathNodeView', () => {
517
528
  await waitFor(() => {
518
529
  const toolbar = container.querySelector('[data-toolbar-for]');
519
530
  expect(toolbar).toBeInTheDocument();
520
- expect(toolbar.style.top).toBe('240px');
531
+ expect(toolbar.style.top).toBe('200px');
521
532
  expect(toolbar.style.left).toBe('150px');
522
533
  });
523
534
  });
524
535
 
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
+
525
589
  it('portals toolbar into _tiptapContainerEl when available', async () => {
526
590
  const containerEl = document.createElement('div');
527
591
  containerEl.getBoundingClientRect = jest.fn(() => ({ top: 0, left: 0, width: 600, height: 400 }));
@@ -648,6 +712,31 @@ describe('MathNodeView', () => {
648
712
  });
649
713
  });
650
714
 
715
+ it('re-registers click listener when node changes', async () => {
716
+ const addEventListenerSpy = jest.spyOn(document, 'addEventListener');
717
+ const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener');
718
+ const nodeA = { attrs: { latex: 'x^2' } };
719
+ const nodeB = { attrs: { latex: 'y^2' } };
720
+
721
+ const { rerender } = render(<MathNodeView {...defaultProps} node={nodeA} selected={true} />);
722
+
723
+ await waitFor(() => {
724
+ expect(addEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function));
725
+ });
726
+
727
+ const initialCallCount = addEventListenerSpy.mock.calls.length;
728
+
729
+ rerender(<MathNodeView {...defaultProps} node={nodeB} selected={true} />);
730
+
731
+ await waitFor(() => {
732
+ expect(removeEventListenerSpy).toHaveBeenCalled();
733
+ expect(addEventListenerSpy.mock.calls.length).toBeGreaterThan(initialCallCount);
734
+ });
735
+
736
+ addEventListenerSpy.mockRestore();
737
+ removeEventListenerSpy.mockRestore();
738
+ });
739
+
651
740
  it('does not close toolbar when clicking the math node preview', async () => {
652
741
  const { getByTestId, queryByTestId } = render(<MathNodeView {...defaultProps} selected={true} />);
653
742
 
@@ -724,6 +813,54 @@ describe('MathNodeView', () => {
724
813
  });
725
814
  });
726
815
 
816
+ describe('selection-based toolbar guard', () => {
817
+ it('opens toolbar when selected transitions to true and the editor has a NodeSelection on math', async () => {
818
+ const { queryByTestId, rerender } = render(<MathNodeView {...defaultProps} selected={false} />);
819
+ expect(queryByTestId('math-toolbar')).not.toBeInTheDocument();
820
+
821
+ rerender(<MathNodeView {...defaultProps} selected={true} />);
822
+ await waitFor(() => {
823
+ expect(queryByTestId('math-toolbar')).toBeInTheDocument();
824
+ });
825
+ });
826
+
827
+ it('does not open toolbar when selected briefly becomes true but editor selection has no node (Cmd+A / drag case)', async () => {
828
+ const editor = {
829
+ ...defaultProps.editor,
830
+ state: {
831
+ ...defaultProps.editor.state,
832
+ selection: { from: 0, to: 100 }, // no .node — TextSelection / AllSelection shape
833
+ },
834
+ };
835
+
836
+ const { queryByTestId, rerender } = render(
837
+ <MathNodeView {...defaultProps} editor={editor} selected={false} />,
838
+ );
839
+ rerender(<MathNodeView {...defaultProps} editor={editor} selected={true} />);
840
+
841
+ await act(async () => {});
842
+ expect(queryByTestId('math-toolbar')).not.toBeInTheDocument();
843
+ });
844
+
845
+ it('does not open toolbar when selected briefly becomes true but NodeSelection targets a non-math node', async () => {
846
+ const editor = {
847
+ ...defaultProps.editor,
848
+ state: {
849
+ ...defaultProps.editor.state,
850
+ selection: { from: 0, to: 1, node: { type: { name: 'image' } } },
851
+ },
852
+ };
853
+
854
+ const { queryByTestId, rerender } = render(
855
+ <MathNodeView {...defaultProps} editor={editor} selected={false} />,
856
+ );
857
+ rerender(<MathNodeView {...defaultProps} editor={editor} selected={true} />);
858
+
859
+ await act(async () => {});
860
+ expect(queryByTestId('math-toolbar')).not.toBeInTheDocument();
861
+ });
862
+ });
863
+
727
864
  it('does not close toolbar when clicking equation editor dropdown', async () => {
728
865
  const { queryByTestId } = render(<MathNodeView {...defaultProps} selected={true} />);
729
866
 
@@ -169,14 +169,27 @@ export const CSSMark = Mark.create({
169
169
  },
170
170
 
171
171
  parseHTML() {
172
- // Any span with a class that matches one of allowed classes
172
+ // Any span with a class that matches one of allowed classes any span that carries a class attribute
173
+ // so that pre-existing spans are preserved when loading content
173
174
  return [
174
175
  {
175
176
  tag: 'span[class]',
176
177
  getAttrs: (el) => {
177
178
  const cls = el.getAttribute('class') || '';
178
- const match = this.options.classes.find((name) => cls.includes(name));
179
- return match ? { class: match } : false;
179
+
180
+ if (!cls) {
181
+ return false;
182
+ }
183
+
184
+ const allowedClasses = (this.options && this.options.classes) || [];
185
+
186
+ if (allowedClasses.length > 0) {
187
+ const match = this.options.classes.find((name) => cls.includes(name));
188
+
189
+ return match ? { class: match } : false;
190
+ }
191
+
192
+ return { class: cls };
180
193
  },
181
194
  },
182
195
  ];
@@ -23,7 +23,9 @@ export const EnsureTextAfterMathPlugin = (mathNodeName) =>
23
23
  key: ensureTextAfterMathPluginKey,
24
24
  appendTransaction: (transactions, oldState, newState) => {
25
25
  // 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
+ }
27
29
 
28
30
  const tr = newState.tr;
29
31
  let changed = false;
@@ -202,6 +204,7 @@ export const MathNodeView = (props) => {
202
204
  const { node, updateAttributes, editor, selected, options } = props;
203
205
  const [showToolbar, setShowToolbar] = useState(selected);
204
206
  const toolbarRef = useRef(null);
207
+ const nodeRef = useRef(null);
205
208
  const timestamp = useRef(Date.now());
206
209
  const [position, setPosition] = useState({ top: 0, left: 0 });
207
210
  const { math: mathOptions = {} } = options || {};
@@ -232,28 +235,102 @@ export const MathNodeView = (props) => {
232
235
  editor.commands.focus();
233
236
  };
234
237
 
238
+ // Only open the toolbar when this node is *explicitly* selected
239
+ // via a NodeSelection — not when it's merely included in a broader
240
+ // TextSelection or AllSelection (e.g. click-drag across math, or Cmd+A).
235
241
  useEffect(() => {
236
- if (selected) {
242
+ if (!selected) return;
243
+
244
+ const { selection } = editor.state;
245
+ const isNodeSelected = selection.node?.type?.name === 'math';
246
+
247
+ if (isNodeSelected) {
237
248
  setShowToolbar(true);
238
249
  }
239
- }, [selected]);
250
+ }, [selected, editor]);
240
251
 
241
252
  useEffect(() => {
242
253
  setToolbarOpened(editor, selected || showToolbar);
243
254
  }, [editor, showToolbar, selected]);
244
255
 
245
256
  useEffect(() => {
246
- // Calculate position relative to selection
247
- const { from } = editor.state.selection;
248
- const start = editor.view.coordsAtPos(from);
249
- const editorDOM = editor.options.element;
250
- const editorRect = editorDOM.getBoundingClientRect();
251
-
252
- setPosition({
253
- top: start.top - editorRect.top + 40, // shift above
254
- 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();
255
318
  });
256
319
 
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(() => {
257
334
  const handleClickOutside = (event) => {
258
335
  const target = event?.target;
259
336
 
@@ -298,7 +375,7 @@ export const MathNodeView = (props) => {
298
375
  }
299
376
 
300
377
  return () => document.removeEventListener('click', handleClickOutside);
301
- }, [editor, showToolbar]);
378
+ }, [editor, showToolbar, node]);
302
379
 
303
380
  return (
304
381
  <NodeViewWrapper
@@ -310,7 +387,7 @@ export const MathNodeView = (props) => {
310
387
  }}
311
388
  data-selected={selected}
312
389
  >
313
- <div onClick={() => setShowToolbar(true)} contentEditable={false}>
390
+ <div ref={nodeRef} onClick={() => setShowToolbar(true)} contentEditable={false}>
314
391
  <MathPreview latex={latex} />
315
392
  </div>
316
393
  {showToolbar &&
@@ -322,7 +399,7 @@ export const MathNodeView = (props) => {
322
399
  position: 'absolute',
323
400
  top: `${position.top}px`,
324
401
  left: `${position.left}px`,
325
- zIndex: 20,
402
+ zIndex: 1000,
326
403
  background: 'var(--editable-html-toolbar-bg, #efefef)',
327
404
  boxShadow:
328
405
  '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)',