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

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.
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
  import { render, waitFor, fireEvent } from '@testing-library/react';
3
3
  import { EnsureTextAfterMathPlugin, MathNode, MathNodeView, ZeroWidthSpaceHandlingPlugin } from '../math';
4
+ import * as toolbarUtils from '../../utils/toolbar';
4
5
 
5
6
  jest.mock('@tiptap/react', () => ({
6
7
  NodeViewWrapper: ({ children, ...props }) => (
@@ -147,6 +148,35 @@ describe('MathNode', () => {
147
148
  expect(commands).toHaveProperty('insertMath');
148
149
  expect(typeof commands.insertMath).toBe('function');
149
150
  });
151
+
152
+ it('insertMath opens the toolbar after inserting a math node', () => {
153
+ const setToolbarOpenedSpy = jest.spyOn(toolbarUtils, 'setToolbarOpened');
154
+ const mathNode = { type: { name: 'math' }, nodeSize: 1 };
155
+ const tr = {
156
+ insert: jest.fn().mockReturnThis(),
157
+ setSelection: jest.fn().mockReturnThis(),
158
+ doc: {},
159
+ };
160
+ const editor = {
161
+ view: {
162
+ state: {
163
+ schema: { nodes: { math: { create: jest.fn(() => mathNode) } } },
164
+ selection: { $from: { pos: 1 } },
165
+ },
166
+ },
167
+ };
168
+ const dispatch = jest.fn();
169
+ const insertMath = MathNode.addCommands().insertMath('x^2');
170
+
171
+ insertMath({ tr, editor, dispatch });
172
+
173
+ expect(tr.insert).toHaveBeenCalledWith(1, mathNode);
174
+ expect(tr.setSelection).toHaveBeenCalled();
175
+ expect(dispatch).toHaveBeenCalledWith(tr);
176
+ expect(setToolbarOpenedSpy).toHaveBeenCalledWith(editor, true);
177
+
178
+ setToolbarOpenedSpy.mockRestore();
179
+ });
150
180
  });
151
181
 
152
182
  describe('addNodeView', () => {
@@ -219,12 +249,32 @@ describe('EnsureTextAfterMathPlugin', () => {
219
249
  });
220
250
 
221
251
  describe('ZeroWidthSpaceHandlingPlugin', () => {
222
- const createDefaultDoc = () => ({
223
- textBetween: jest.fn(() => '\u200b'),
224
- resolve: jest.fn(() => ({
252
+ const createDocWithMathAndZwsp = (resolveOverrides = {}) => ({
253
+ resolve: jest.fn((pos) => ({
225
254
  nodeAfter: null,
226
255
  nodeBefore: null,
256
+ pos,
257
+ ...resolveOverrides[pos],
227
258
  })),
259
+ nodeAt: jest.fn((pos) => {
260
+ if (pos === 1) {
261
+ return { type: { name: 'text' }, textContent: '\u200b' };
262
+ }
263
+ if (pos === 0) {
264
+ return { type: { name: 'math' }, nodeSize: 1 };
265
+ }
266
+ return null;
267
+ }),
268
+ });
269
+
270
+ const createDocWithRegularTextBeforeCursor = () => ({
271
+ resolve: jest.fn((pos) => ({ nodeAfter: null, nodeBefore: null, pos })),
272
+ nodeAt: jest.fn((pos) => {
273
+ if (pos === 1) {
274
+ return { type: { name: 'text' }, textContent: 'a' };
275
+ }
276
+ return null;
277
+ }),
228
278
  });
229
279
 
230
280
  const createView = ({ state: stateOverrides = {} } = {}) => {
@@ -237,66 +287,88 @@ describe('ZeroWidthSpaceHandlingPlugin', () => {
237
287
  return {
238
288
  state: {
239
289
  selection: { from: 2, empty: true },
240
- doc: createDefaultDoc(),
290
+ doc: createDocWithMathAndZwsp(),
241
291
  tr,
242
292
  ...stateOverrides,
243
- doc: { ...createDefaultDoc(), ...stateOverrides.doc },
244
293
  },
245
294
  dispatch,
246
295
  };
247
296
  };
248
297
 
249
- it('deletes math and zero-width space on Backspace', () => {
250
- const view = createView();
251
- const event = { key: 'Backspace' };
252
- const handled = ZeroWidthSpaceHandlingPlugin.props.handleKeyDown(view, event);
298
+ describe('Backspace', () => {
299
+ it('deletes the inline node and zero-width space before the cursor', () => {
300
+ const view = createView();
301
+ const handled = ZeroWidthSpaceHandlingPlugin.props.handleKeyDown(view, { key: 'Backspace' });
253
302
 
254
- expect(handled).toBe(true);
255
- expect(view.state.tr.delete).toHaveBeenCalledWith(0, 2);
256
- expect(view.dispatch).toHaveBeenCalledWith(view.state.tr);
303
+ expect(handled).toBe(true);
304
+ expect(view.state.tr.delete).toHaveBeenCalledWith(0, 2);
305
+ expect(view.dispatch).toHaveBeenCalledWith(view.state.tr);
306
+ });
307
+
308
+ it('returns false when regular text precedes the cursor', () => {
309
+ const view = createView({
310
+ state: {
311
+ doc: createDocWithRegularTextBeforeCursor(),
312
+ },
313
+ });
314
+
315
+ const handled = ZeroWidthSpaceHandlingPlugin.props.handleKeyDown(view, { key: 'Backspace' });
316
+
317
+ expect(handled).toBe(false);
318
+ expect(view.state.tr.delete).not.toHaveBeenCalled();
319
+ expect(view.dispatch).not.toHaveBeenCalled();
320
+ });
257
321
  });
258
322
 
259
- it('selects the math node on ArrowLeft before a zero-width space', () => {
260
- const mathNode = { nodeSize: 3 };
261
- const view = createView({
262
- state: {
263
- doc: {
264
- resolve: jest
265
- .fn()
266
- .mockReturnValueOnce({ nodeAfter: mathNode, nodeBefore: null })
267
- .mockReturnValueOnce({ pos: 4 }),
323
+ describe('ArrowLeft', () => {
324
+ it('selects the inline node before a zero-width space', () => {
325
+ const mathNode = { nodeSize: 3 };
326
+ const view = createView({
327
+ state: {
328
+ doc: createDocWithMathAndZwsp({
329
+ 0: { nodeAfter: mathNode, nodeBefore: null, pos: 0 },
330
+ }),
268
331
  },
269
- },
332
+ });
333
+ const { NodeSelection } = require('prosemirror-state');
334
+
335
+ const handled = ZeroWidthSpaceHandlingPlugin.props.handleKeyDown(view, { key: 'ArrowLeft' });
336
+
337
+ expect(handled).toBe(true);
338
+ expect(view.state.doc.resolve).toHaveBeenCalledWith(0);
339
+ expect(NodeSelection.create).toHaveBeenCalledWith(view.state.doc, 0);
340
+ expect(view.dispatch).toHaveBeenCalled();
270
341
  });
271
- const { NodeSelection } = require('prosemirror-state');
272
342
 
273
- const handled = ZeroWidthSpaceHandlingPlugin.props.handleKeyDown(view, { key: 'ArrowLeft' });
343
+ it('moves the text cursor before the zero-width space when no inline node precedes it', () => {
344
+ const view = createView();
345
+ const { TextSelection } = require('prosemirror-state');
274
346
 
275
- expect(handled).toBe(true);
276
- expect(NodeSelection.create).toHaveBeenCalledWith(view.state.doc, 4);
277
- expect(view.dispatch).toHaveBeenCalled();
278
- });
347
+ const handled = ZeroWidthSpaceHandlingPlugin.props.handleKeyDown(view, { key: 'ArrowLeft' });
279
348
 
280
- it('moves the text cursor before the zero-width space when no inline node precedes it', () => {
281
- const view = createView();
282
- const { TextSelection } = require('prosemirror-state');
349
+ expect(handled).toBe(true);
350
+ expect(view.state.doc.resolve).toHaveBeenCalledWith(0);
351
+ expect(TextSelection.create).toHaveBeenCalledWith(view.state.doc, 0);
352
+ expect(view.dispatch).toHaveBeenCalled();
353
+ });
354
+
355
+ it('returns false when regular text precedes the cursor', () => {
356
+ const view = createView({
357
+ state: {
358
+ doc: createDocWithRegularTextBeforeCursor(),
359
+ },
360
+ });
283
361
 
284
- const handled = ZeroWidthSpaceHandlingPlugin.props.handleKeyDown(view, { key: 'ArrowLeft' });
362
+ const handled = ZeroWidthSpaceHandlingPlugin.props.handleKeyDown(view, { key: 'ArrowLeft' });
285
363
 
286
- expect(handled).toBe(true);
287
- expect(TextSelection.create).toHaveBeenCalledWith(view.state.doc, 0);
288
- expect(view.dispatch).toHaveBeenCalled();
364
+ expect(handled).toBe(false);
365
+ expect(view.state.tr.setSelection).not.toHaveBeenCalled();
366
+ expect(view.dispatch).not.toHaveBeenCalled();
367
+ });
289
368
  });
290
369
 
291
370
  it('returns false for unrelated keys', () => {
292
- const view = createView({
293
- state: {
294
- doc: {
295
- textBetween: jest.fn(() => 'a'),
296
- resolve: jest.fn(),
297
- },
298
- },
299
- });
371
+ const view = createView();
300
372
 
301
373
  const handled = ZeroWidthSpaceHandlingPlugin.props.handleKeyDown(view, { key: 'Enter' });
302
374
  expect(handled).toBe(false);
@@ -304,6 +376,15 @@ describe('ZeroWidthSpaceHandlingPlugin', () => {
304
376
  });
305
377
 
306
378
  describe('MathNodeView', () => {
379
+ const createEditorElement = (rect = { top: 0, left: 0, width: 600, height: 400 }) => {
380
+ const element = document.createElement('div');
381
+ Object.defineProperty(element, 'getBoundingClientRect', {
382
+ value: jest.fn(() => rect),
383
+ configurable: true,
384
+ });
385
+ return element;
386
+ };
387
+
307
388
  const createMockEditor = () => ({
308
389
  state: {
309
390
  selection: {
@@ -312,6 +393,7 @@ describe('MathNodeView', () => {
312
393
  },
313
394
  tr: {
314
395
  setSelection: jest.fn().mockReturnThis(),
396
+ setMeta: jest.fn().mockReturnThis(),
315
397
  },
316
398
  doc: {},
317
399
  },
@@ -319,6 +401,9 @@ describe('MathNodeView', () => {
319
401
  coordsAtPos: jest.fn(() => ({ top: 100, left: 50 })),
320
402
  dispatch: jest.fn(),
321
403
  },
404
+ options: {
405
+ element: createEditorElement(),
406
+ },
322
407
  commands: {
323
408
  focus: jest.fn(),
324
409
  },
@@ -334,13 +419,6 @@ describe('MathNodeView', () => {
334
419
 
335
420
  let defaultProps;
336
421
 
337
- beforeAll(() => {
338
- Object.defineProperty(document.body, 'getBoundingClientRect', {
339
- value: jest.fn(() => ({ top: 0, left: 0 })),
340
- configurable: true,
341
- });
342
- });
343
-
344
422
  beforeEach(() => {
345
423
  jest.clearAllMocks();
346
424
  mockCreatePortal.mockImplementation((node) => node);
@@ -389,30 +467,29 @@ describe('MathNodeView', () => {
389
467
  });
390
468
 
391
469
  describe('toolbar positioning', () => {
392
- it('uses a fixed top offset and horizontal position from coordsAtPos', async () => {
470
+ it('positions relative to the editor element using coordsAtPos', async () => {
393
471
  const { container } = render(<MathNodeView {...defaultProps} selected={true} />);
394
472
  await waitFor(() => {
395
473
  const toolbar = container.querySelector('[data-toolbar-for]');
396
474
  expect(toolbar).toBeInTheDocument();
397
- expect(toolbar.style.top).toBe('40px');
475
+ expect(toolbar.style.top).toBe('140px');
398
476
  expect(toolbar.style.left).toBe('50px');
399
477
  });
400
478
  });
401
479
 
402
- it('keeps the fixed top offset when the editor container is scrolled', async () => {
403
- const containerEl = document.createElement('div');
404
- containerEl.getBoundingClientRect = jest.fn(() => ({ top: -200, left: 0, width: 600, height: 400 }));
480
+ it('accounts for editor scroll offset when calculating toolbar position', async () => {
481
+ const editorElement = createEditorElement({ top: -200, left: 0, width: 600, height: 400 });
405
482
 
406
483
  const editor = {
407
484
  ...defaultProps.editor,
408
- _tiptapContainerEl: containerEl,
485
+ options: { element: editorElement },
409
486
  };
410
487
 
411
488
  const { container } = render(<MathNodeView {...defaultProps} editor={editor} selected={true} />);
412
489
  await waitFor(() => {
413
490
  const toolbar = container.querySelector('[data-toolbar-for]');
414
491
  expect(toolbar).toBeInTheDocument();
415
- expect(toolbar.style.top).toBe('40px');
492
+ expect(toolbar.style.top).toBe('340px');
416
493
  expect(toolbar.style.left).toBe('50px');
417
494
  });
418
495
  });
@@ -426,7 +503,7 @@ describe('MathNodeView', () => {
426
503
  });
427
504
  });
428
505
 
429
- it('updates horizontal position from coordsAtPos when selection changes', async () => {
506
+ it('updates position from coordsAtPos when selection changes', async () => {
430
507
  const editor = {
431
508
  ...defaultProps.editor,
432
509
  view: {
@@ -440,7 +517,7 @@ describe('MathNodeView', () => {
440
517
  await waitFor(() => {
441
518
  const toolbar = container.querySelector('[data-toolbar-for]');
442
519
  expect(toolbar).toBeInTheDocument();
443
- expect(toolbar.style.top).toBe('40px');
520
+ expect(toolbar.style.top).toBe('240px');
444
521
  expect(toolbar.style.left).toBe('150px');
445
522
  });
446
523
  });
@@ -514,18 +591,35 @@ describe('MathNodeView', () => {
514
591
  });
515
592
  });
516
593
 
517
- it('unsets editor._toolbarOpened when toolbar is closed', async () => {
594
+ it('unsets editor._toolbarOpened when toolbar is closed and node is not selected', async () => {
595
+ const { getByTestId } = render(<MathNodeView {...defaultProps} selected={false} />);
596
+
597
+ fireEvent.click(getByTestId('math-preview'));
598
+
599
+ await waitFor(() => {
600
+ expect(getByTestId('math-toolbar')).toBeInTheDocument();
601
+ expect(defaultProps.editor._toolbarOpened).toBe(true);
602
+ });
603
+
604
+ fireEvent.click(getByTestId('done-button'));
605
+
606
+ await waitFor(() => {
607
+ expect(defaultProps.editor._toolbarOpened).toBe(false);
608
+ });
609
+ });
610
+
611
+ it('keeps editor._toolbarOpened true while the math node remains selected', async () => {
518
612
  const { getByTestId } = render(<MathNodeView {...defaultProps} selected={true} />);
519
613
 
520
614
  await waitFor(() => {
521
615
  expect(getByTestId('done-button')).toBeInTheDocument();
616
+ expect(defaultProps.editor._toolbarOpened).toBe(true);
522
617
  });
523
618
 
524
- const doneButton = getByTestId('done-button');
525
- fireEvent.click(doneButton);
619
+ fireEvent.click(getByTestId('done-button'));
526
620
 
527
621
  await waitFor(() => {
528
- expect(defaultProps.editor._toolbarOpened).toBe(false);
622
+ expect(defaultProps.editor._toolbarOpened).toBe(true);
529
623
  });
530
624
  });
531
625
 
@@ -550,7 +644,7 @@ describe('MathNodeView', () => {
550
644
  expect(editor.state.tr.setSelection).toHaveBeenCalled();
551
645
  expect(editor.view.dispatch).toHaveBeenCalledWith(editor.state.tr);
552
646
  expect(editor.commands.focus).toHaveBeenCalled();
553
- expect(editor._toolbarOpened).toBe(false);
647
+ expect(editor._toolbarOpened).toBe(true);
554
648
  });
555
649
  });
556
650
 
@@ -568,6 +662,68 @@ describe('MathNodeView', () => {
568
662
  });
569
663
  });
570
664
 
665
+ describe('unique math-node class per instance', () => {
666
+ let dateNowSpy;
667
+
668
+ beforeEach(() => {
669
+ let timestamp = 1000;
670
+ dateNowSpy = jest.spyOn(Date, 'now').mockImplementation(() => timestamp++);
671
+ });
672
+
673
+ afterEach(() => {
674
+ dateNowSpy.mockRestore();
675
+ });
676
+
677
+ it('assigns a timestamp-based class to NodeViewWrapper', () => {
678
+ const { getByTestId } = render(<MathNodeView {...defaultProps} />);
679
+ const wrapper = getByTestId('node-view-wrapper');
680
+
681
+ expect(wrapper.className).toMatch(/^math-node-\d+$/);
682
+ });
683
+
684
+ it('assigns a different class to each MathNodeView instance', () => {
685
+ const { getAllByTestId } = render(
686
+ <>
687
+ <MathNodeView {...defaultProps} />
688
+ <MathNodeView {...defaultProps} node={{ attrs: { latex: 'y^2' } }} />
689
+ </>,
690
+ );
691
+
692
+ const wrappers = getAllByTestId('node-view-wrapper');
693
+ expect(wrappers[0].className).toBe('math-node-1000');
694
+ expect(wrappers[1].className).toBe('math-node-1001');
695
+ });
696
+
697
+ it('closes toolbar when clicking a different math node', async () => {
698
+ const updateAttributes = jest.fn();
699
+ const editorA = createMockEditor();
700
+ const editorB = createMockEditor();
701
+
702
+ const { getAllByTestId, queryAllByTestId } = render(
703
+ <>
704
+ <MathNodeView {...defaultProps} editor={editorA} updateAttributes={updateAttributes} selected={false} />
705
+ <MathNodeView {...defaultProps} editor={editorB} node={{ attrs: { latex: 'y^2' } }} selected={false} />
706
+ </>,
707
+ );
708
+
709
+ fireEvent.click(getAllByTestId('math-preview')[0]);
710
+
711
+ await waitFor(() => {
712
+ expect(queryAllByTestId('math-toolbar')).toHaveLength(1);
713
+ });
714
+
715
+ const secondPreview = getAllByTestId('math-preview')[1];
716
+ fireEvent.click(secondPreview);
717
+
718
+ await waitFor(() => {
719
+ expect(queryAllByTestId('math-toolbar')).toHaveLength(1);
720
+ expect(updateAttributes).toHaveBeenCalledWith({ latex: 'x^2' });
721
+ expect(editorA._toolbarOpened).toBe(false);
722
+ expect(getAllByTestId('math-input')[0]).toHaveValue('y^2');
723
+ });
724
+ });
725
+ });
726
+
571
727
  it('does not close toolbar when clicking equation editor dropdown', async () => {
572
728
  const { queryByTestId } = render(<MathNodeView {...defaultProps} selected={true} />);
573
729
 
@@ -5,6 +5,7 @@ import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
5
5
  import { NodeSelection, Plugin, PluginKey, TextSelection } from 'prosemirror-state';
6
6
  import { MathPreview, MathToolbar } from '@pie-lib/math-toolbar';
7
7
  import { wrapMath } from '@pie-lib/math-rendering';
8
+ import { setToolbarOpened } from '../utils/toolbar';
8
9
 
9
10
  const ensureTextAfterMathPluginKey = new PluginKey('ensureTextAfterMath');
10
11
 
@@ -44,6 +45,26 @@ export const EnsureTextAfterMathPlugin = (mathNodeName) =>
44
45
  },
45
46
  });
46
47
 
48
+ const nodeBeforeZeroWidthSpace = (doc, from) => {
49
+ let i;
50
+
51
+ // finding if previous to the cursor there's a zero-width space
52
+ // and a non-text element, and deleting everything until the space
53
+ for (i = from; i > 0; i--) {
54
+ const currentDoc = doc.nodeAt(i);
55
+
56
+ if (currentDoc?.type?.name === 'text' && currentDoc.textContent !== '\u200b') {
57
+ return -1;
58
+ }
59
+
60
+ if (currentDoc && currentDoc?.type?.name !== 'text') {
61
+ break;
62
+ }
63
+ }
64
+
65
+ return i;
66
+ };
67
+
47
68
  export const ZeroWidthSpaceHandlingPlugin = new Plugin({
48
69
  key: new PluginKey('zeroWidthSpaceHandling'),
49
70
  props: {
@@ -53,35 +74,38 @@ export const ZeroWidthSpaceHandlingPlugin = new Plugin({
53
74
  const { from, empty } = selection;
54
75
 
55
76
  if (empty && event.key === 'Backspace' && from > 0) {
56
- const prevChar = doc.textBetween(from - 1, from, '\uFFFC', '\uFFFC');
57
- if (prevChar === '\u200b') {
58
- const tr = state.tr.delete(from - 2, from);
59
- dispatch(tr);
60
- return true; // handled
77
+ const start = nodeBeforeZeroWidthSpace(doc, from);
78
+
79
+ if (start === -1) {
80
+ return false;
61
81
  }
82
+
83
+ const tr = state.tr.delete(start, from);
84
+ dispatch(tr);
85
+ return true; // handled
62
86
  }
63
87
 
64
88
  if (empty && event.key === 'ArrowLeft' && from > 0) {
65
- const prevChar = doc.textBetween(from - 1, from, '\uFFFC', '\uFFFC');
66
- // If the previous character is the zero-width space...
67
- if (prevChar === '\u200b') {
68
- const posBefore = from - 1;
69
- const resolved = state.doc.resolve(posBefore - 1); // look just before the zwsp
70
- const maybeNode = resolved.nodeAfter || resolved.nodeBefore;
71
-
72
- // Check if there's an inline selectable node (e.g., your math node)
73
- if (maybeNode) {
74
- const nodePos = posBefore - maybeNode.nodeSize;
75
- const nodeResolved = state.doc.resolve(nodePos);
76
- const tr = state.tr.setSelection(NodeSelection.create(state.doc, nodeResolved.pos));
77
- dispatch(tr);
78
- return true;
79
- } else {
80
- // Just move the text cursor before the zwsp
81
- const tr = state.tr.setSelection(TextSelection.create(state.doc, from - 2));
82
- dispatch(tr);
83
- return true;
84
- }
89
+ const start = nodeBeforeZeroWidthSpace(doc, from);
90
+
91
+ if (start === -1) {
92
+ return false;
93
+ }
94
+
95
+ const resolved = state.doc.resolve(start);
96
+ const maybeNode = resolved.nodeAfter || resolved.nodeBefore;
97
+
98
+ // Check if there's an inline selectable node (e.g., your math node)
99
+ if (maybeNode) {
100
+ const nodeResolved = state.doc.resolve(start);
101
+ const tr = state.tr.setSelection(NodeSelection.create(state.doc, nodeResolved.pos));
102
+ dispatch(tr);
103
+ return true;
104
+ } else {
105
+ // Just move the text cursor before the zwsp
106
+ const tr = state.tr.setSelection(TextSelection.create(state.doc, from - 2));
107
+ dispatch(tr);
108
+ return true;
85
109
  }
86
110
  }
87
111
 
@@ -151,14 +175,9 @@ export const MathNode = Node.create({
151
175
 
152
176
  dispatch(tr);
153
177
 
178
+ setToolbarOpened(editor, true);
154
179
  return true;
155
180
  },
156
- // insertMath: (latex = '') => ({ commands }) => {
157
- // return commands.insertContent({
158
- // type: this.name,
159
- // attrs: { latex },
160
- // });
161
- // },
162
181
  };
163
182
  },
164
183
 
@@ -183,6 +202,7 @@ export const MathNodeView = (props) => {
183
202
  const { node, updateAttributes, editor, selected, options } = props;
184
203
  const [showToolbar, setShowToolbar] = useState(selected);
185
204
  const toolbarRef = useRef(null);
205
+ const timestamp = useRef(Date.now());
186
206
  const [position, setPosition] = useState({ top: 0, left: 0 });
187
207
  const { math: mathOptions = {} } = options || {};
188
208
  const {
@@ -203,8 +223,6 @@ export const MathNodeView = (props) => {
203
223
  updateAttributes({ latex: newLatex });
204
224
  setShowToolbar(false);
205
225
 
206
- editor._toolbarOpened = false;
207
-
208
226
  const { selection, tr, doc } = editor.state;
209
227
  const sel = TextSelection.create(doc, selection.from + 1);
210
228
 
@@ -221,16 +239,19 @@ export const MathNodeView = (props) => {
221
239
  }, [selected]);
222
240
 
223
241
  useEffect(() => {
224
- editor._toolbarOpened = !!showToolbar;
225
- }, [showToolbar]);
242
+ setToolbarOpened(editor, selected || showToolbar);
243
+ }, [editor, showToolbar, selected]);
226
244
 
227
245
  useEffect(() => {
228
246
  // Calculate position relative to selection
229
247
  const { from } = editor.state.selection;
230
248
  const start = editor.view.coordsAtPos(from);
249
+ const editorDOM = editor.options.element;
250
+ const editorRect = editorDOM.getBoundingClientRect();
251
+
231
252
  setPosition({
232
- top: 40, // shift above
233
- left: start.left,
253
+ top: start.top - editorRect.top + 40, // shift above
254
+ left: start.left - editorRect.left,
234
255
  });
235
256
 
236
257
  const handleClickOutside = (event) => {
@@ -253,7 +274,7 @@ export const MathNodeView = (props) => {
253
274
  // will keep/re-open the toolbar. Without this guard, closing and then
254
275
  // immediately clicking the math node would fire this listener in the same
255
276
  // event cycle and close the toolbar before it could open.
256
- const clickedMathNode = !!target?.closest?.('.math-node');
277
+ const clickedMathNode = !!target?.closest?.(`.math-node-${timestamp.current}`);
257
278
 
258
279
  if (
259
280
  toolbarRef.current &&
@@ -281,7 +302,7 @@ export const MathNodeView = (props) => {
281
302
 
282
303
  return (
283
304
  <NodeViewWrapper
284
- className="math-node"
305
+ className={`math-node-${timestamp.current}`}
285
306
  style={{
286
307
  display: 'inline-flex',
287
308
  cursor: 'pointer',
@@ -0,0 +1,43 @@
1
+ import { setToolbarOpened, TOOLBAR_OPENED_META_KEY } from '../toolbar';
2
+
3
+ describe('setToolbarOpened', () => {
4
+ const createEditor = (toolbarOpened = false) => {
5
+ const tr = {
6
+ setMeta: jest.fn().mockReturnThis(),
7
+ };
8
+
9
+ return {
10
+ _toolbarOpened: toolbarOpened,
11
+ state: { tr },
12
+ view: {
13
+ dispatch: jest.fn(),
14
+ },
15
+ };
16
+ };
17
+
18
+ it('sets editor._toolbarOpened and dispatches a meta transaction', () => {
19
+ const editor = createEditor(false);
20
+
21
+ setToolbarOpened(editor, true);
22
+
23
+ expect(editor._toolbarOpened).toBe(true);
24
+ expect(editor.state.tr.setMeta).toHaveBeenCalledWith(TOOLBAR_OPENED_META_KEY, true);
25
+ expect(editor.view.dispatch).toHaveBeenCalledWith(editor.state.tr);
26
+ });
27
+
28
+ it('does nothing when the value is unchanged', () => {
29
+ const editor = createEditor(true);
30
+
31
+ setToolbarOpened(editor, true);
32
+
33
+ expect(editor.view.dispatch).not.toHaveBeenCalled();
34
+ });
35
+
36
+ it('coerces the opened value to a boolean', () => {
37
+ const editor = createEditor(false);
38
+
39
+ setToolbarOpened(editor, 1);
40
+
41
+ expect(editor._toolbarOpened).toBe(true);
42
+ });
43
+ });
@@ -0,0 +1,15 @@
1
+ export const TOOLBAR_OPENED_META_KEY = 'toolbarOpenedChanged';
2
+
3
+ export const setToolbarOpened = (editor, opened) => {
4
+ const next = !!opened;
5
+
6
+ if (editor._toolbarOpened === next) {
7
+ return;
8
+ }
9
+
10
+ editor._toolbarOpened = next;
11
+
12
+ if (editor?.view && editor?.state?.tr) {
13
+ editor.view.dispatch(editor.state.tr.setMeta(TOOLBAR_OPENED_META_KEY, true));
14
+ }
15
+ };