@pie-lib/editable-html-tip-tap 2.1.1 → 2.1.2-next.102

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.
Files changed (52) hide show
  1. package/lib/components/CharacterPicker.js +51 -20
  2. package/lib/components/CharacterPicker.js.map +1 -1
  3. package/lib/components/EditableHtml.js +37 -24
  4. package/lib/components/EditableHtml.js.map +1 -1
  5. package/lib/components/MenuBar.js +15 -6
  6. package/lib/components/MenuBar.js.map +1 -1
  7. package/lib/components/TiptapContainer.js +9 -1
  8. package/lib/components/TiptapContainer.js.map +1 -1
  9. package/lib/components/common/toolbar-buttons.js +5 -0
  10. package/lib/components/common/toolbar-buttons.js.map +1 -1
  11. package/lib/components/respArea/ExplicitConstructedResponse.js +2 -1
  12. package/lib/components/respArea/ExplicitConstructedResponse.js.map +1 -1
  13. package/lib/components/respArea/InlineDropdown.js +74 -16
  14. package/lib/components/respArea/InlineDropdown.js.map +1 -1
  15. package/lib/components/respArea/inlineDropdownUtils.js +67 -0
  16. package/lib/components/respArea/inlineDropdownUtils.js.map +1 -0
  17. package/lib/extensions/css.js +17 -7
  18. package/lib/extensions/css.js.map +1 -1
  19. package/lib/extensions/image-component.js +9 -6
  20. package/lib/extensions/image-component.js.map +1 -1
  21. package/lib/extensions/math.js +179 -71
  22. package/lib/extensions/math.js.map +1 -1
  23. package/lib/extensions/responseArea.js +7 -1
  24. package/lib/extensions/responseArea.js.map +1 -1
  25. package/lib/index.js +7 -0
  26. package/lib/index.js.map +1 -1
  27. package/lib/utils/toolbar.js +19 -0
  28. package/lib/utils/toolbar.js.map +1 -0
  29. package/package.json +18 -18
  30. package/src/__tests__/EditableHtml.test.jsx +116 -2
  31. package/src/components/CharacterPicker.jsx +56 -19
  32. package/src/components/EditableHtml.jsx +43 -23
  33. package/src/components/MenuBar.jsx +15 -3
  34. package/src/components/TiptapContainer.jsx +18 -2
  35. package/src/components/__tests__/CharacterPicker.test.jsx +57 -6
  36. package/src/components/__tests__/ExplicitConstructedResponse.test.jsx +6 -1
  37. package/src/components/__tests__/InlineDropdown.test.jsx +185 -8
  38. package/src/components/__tests__/MenuBar.test.jsx +1 -0
  39. package/src/components/common/toolbar-buttons.jsx +5 -0
  40. package/src/components/respArea/ExplicitConstructedResponse.jsx +2 -1
  41. package/src/components/respArea/InlineDropdown.jsx +97 -24
  42. package/src/components/respArea/inlineDropdownUtils.js +79 -0
  43. package/src/extensions/__tests__/image-component.test.jsx +1 -0
  44. package/src/extensions/__tests__/math.test.js +481 -42
  45. package/src/extensions/__tests__/responseArea.test.js +57 -3
  46. package/src/extensions/css.js +16 -3
  47. package/src/extensions/image-component.jsx +8 -6
  48. package/src/extensions/math.js +188 -69
  49. package/src/extensions/responseArea.js +7 -1
  50. package/src/index.jsx +2 -2
  51. package/src/utils/__tests__/toolbar.test.js +43 -0
  52. package/src/utils/toolbar.js +15 -0
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
- import { render, waitFor, fireEvent } from '@testing-library/react';
3
- import { MathNode, MathNodeView } from '../math';
2
+ import { render, waitFor, fireEvent, act } from '@testing-library/react';
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', () => {
@@ -156,17 +186,215 @@ describe('MathNode', () => {
156
186
  expect(result).toBeDefined();
157
187
  });
158
188
  });
189
+
190
+ describe('addProseMirrorPlugins', () => {
191
+ it('registers ensure-text-after-math and zero-width-space plugins', () => {
192
+ const plugins = MathNode.addProseMirrorPlugins();
193
+
194
+ expect(plugins).toHaveLength(2);
195
+ expect(plugins[0].appendTransaction).toBeDefined();
196
+ expect(plugins[1].props.handleKeyDown).toBeDefined();
197
+ });
198
+ });
199
+ });
200
+
201
+ describe('EnsureTextAfterMathPlugin', () => {
202
+ it('inserts a zero-width space after a math node when no text follows', () => {
203
+ const plugin = EnsureTextAfterMathPlugin('math');
204
+ const textNode = { type: { name: 'text' } };
205
+ const mathNode = { type: { name: 'math' }, nodeSize: 3 };
206
+ const tr = { insert: jest.fn() };
207
+
208
+ const newState = {
209
+ schema: { text: jest.fn((value) => ({ type: textNode.type, text: value })) },
210
+ tr,
211
+ doc: {
212
+ descendants: (cb) => cb(mathNode, 5),
213
+ nodeAt: jest.fn(() => null),
214
+ },
215
+ };
216
+
217
+ const result = plugin.appendTransaction([{ docChanged: true }], {}, newState);
218
+
219
+ expect(tr.insert).toHaveBeenCalledWith(8, expect.anything());
220
+ expect(result).toBe(tr);
221
+ });
222
+
223
+ it('does not insert when text already follows the math node', () => {
224
+ const plugin = EnsureTextAfterMathPlugin('math');
225
+ const tr = { insert: jest.fn() };
226
+ const mathNode = { type: { name: 'math' }, nodeSize: 3 };
227
+
228
+ const newState = {
229
+ schema: { text: jest.fn() },
230
+ tr,
231
+ doc: {
232
+ descendants: (cb) => cb(mathNode, 5),
233
+ nodeAt: jest.fn(() => ({ type: { name: 'text' } })),
234
+ },
235
+ };
236
+
237
+ const result = plugin.appendTransaction([{ docChanged: true }], {}, newState);
238
+
239
+ expect(tr.insert).not.toHaveBeenCalled();
240
+ expect(result).toBeNull();
241
+ });
242
+
243
+ it('returns null when the document did not change', () => {
244
+ const plugin = EnsureTextAfterMathPlugin('math');
245
+
246
+ const result = plugin.appendTransaction([{ docChanged: false }], {}, {});
247
+ expect(result).toBeNull();
248
+ });
249
+ });
250
+
251
+ describe('ZeroWidthSpaceHandlingPlugin', () => {
252
+ const createDocWithMathAndZwsp = (resolveOverrides = {}) => ({
253
+ resolve: jest.fn((pos) => ({
254
+ nodeAfter: null,
255
+ nodeBefore: null,
256
+ pos,
257
+ ...resolveOverrides[pos],
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
+ }),
278
+ });
279
+
280
+ const createView = ({ state: stateOverrides = {} } = {}) => {
281
+ const dispatch = jest.fn();
282
+ const tr = {
283
+ delete: jest.fn().mockReturnThis(),
284
+ setSelection: jest.fn().mockReturnThis(),
285
+ };
286
+
287
+ return {
288
+ state: {
289
+ selection: { from: 2, empty: true },
290
+ doc: createDocWithMathAndZwsp(),
291
+ tr,
292
+ ...stateOverrides,
293
+ },
294
+ dispatch,
295
+ };
296
+ };
297
+
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' });
302
+
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
+ });
321
+ });
322
+
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
+ }),
331
+ },
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();
341
+ });
342
+
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');
346
+
347
+ const handled = ZeroWidthSpaceHandlingPlugin.props.handleKeyDown(view, { key: 'ArrowLeft' });
348
+
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
+ });
361
+
362
+ const handled = ZeroWidthSpaceHandlingPlugin.props.handleKeyDown(view, { key: 'ArrowLeft' });
363
+
364
+ expect(handled).toBe(false);
365
+ expect(view.state.tr.setSelection).not.toHaveBeenCalled();
366
+ expect(view.dispatch).not.toHaveBeenCalled();
367
+ });
368
+ });
369
+
370
+ it('returns false for unrelated keys', () => {
371
+ const view = createView();
372
+
373
+ const handled = ZeroWidthSpaceHandlingPlugin.props.handleKeyDown(view, { key: 'Enter' });
374
+ expect(handled).toBe(false);
375
+ });
159
376
  });
160
377
 
161
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
+
162
388
  const createMockEditor = () => ({
163
389
  state: {
164
390
  selection: {
165
391
  from: 0,
166
392
  to: 1,
393
+ node: { type: { name: 'math' } },
167
394
  },
168
395
  tr: {
169
396
  setSelection: jest.fn().mockReturnThis(),
397
+ setMeta: jest.fn().mockReturnThis(),
170
398
  },
171
399
  doc: {},
172
400
  },
@@ -174,6 +402,9 @@ describe('MathNodeView', () => {
174
402
  coordsAtPos: jest.fn(() => ({ top: 100, left: 50 })),
175
403
  dispatch: jest.fn(),
176
404
  },
405
+ options: {
406
+ element: createEditorElement(),
407
+ },
177
408
  commands: {
178
409
  focus: jest.fn(),
179
410
  },
@@ -185,17 +416,11 @@ describe('MathNodeView', () => {
185
416
  attrs: {
186
417
  latex: 'x^2',
187
418
  },
419
+ nodeSize: 1,
188
420
  };
189
421
 
190
422
  let defaultProps;
191
423
 
192
- beforeAll(() => {
193
- Object.defineProperty(document.body, 'getBoundingClientRect', {
194
- value: jest.fn(() => ({ top: 0, left: 0 })),
195
- configurable: true,
196
- });
197
- });
198
-
199
424
  beforeEach(() => {
200
425
  jest.clearAllMocks();
201
426
  mockCreatePortal.mockImplementation((node) => node);
@@ -205,6 +430,7 @@ describe('MathNodeView', () => {
205
430
  editor: createMockEditor(),
206
431
  selected: false,
207
432
  options: {},
433
+ getPos: jest.fn(() => 0),
208
434
  };
209
435
  });
210
436
 
@@ -244,38 +470,31 @@ describe('MathNodeView', () => {
244
470
  });
245
471
 
246
472
  describe('toolbar positioning', () => {
247
- it('uses _tiptapContainerEl for position calculation when available', async () => {
248
- const containerEl = document.createElement('div');
249
- containerEl.getBoundingClientRect = jest.fn(() => ({ top: -50, left: 20, width: 600, height: 400 }));
250
-
251
- const editor = {
252
- ...defaultProps.editor,
253
- _tiptapContainerEl: containerEl,
254
- };
255
-
256
- const { container } = render(<MathNodeView {...defaultProps} editor={editor} selected={true} />);
473
+ it('positions relative to portal container using coordsAtPos', async () => {
474
+ const { container } = render(<MathNodeView {...defaultProps} selected={true} />);
257
475
  await waitFor(() => {
258
476
  const toolbar = container.querySelector('[data-toolbar-for]');
259
477
  expect(toolbar).toBeInTheDocument();
260
- // top = coordsAtPos.top (100) + Math.abs(containerEl.top (-50)) + 40 = 190
261
- expect(toolbar.style.top).toBe('190px');
478
+ expect(toolbar.style.top).toBe('100px');
262
479
  expect(toolbar.style.left).toBe('50px');
263
480
  });
264
481
  });
265
482
 
266
- it('falls back to document.body when _tiptapContainerEl is not set', async () => {
483
+ it('offsets position by portal container getBoundingClientRect', async () => {
484
+ const containerEl = document.createElement('div');
485
+ containerEl.getBoundingClientRect = jest.fn(() => ({ top: 100, left: 50, width: 600, height: 400 }));
486
+
267
487
  const editor = {
268
488
  ...defaultProps.editor,
269
- _tiptapContainerEl: undefined,
489
+ _tiptapContainerEl: containerEl,
270
490
  };
271
491
 
272
492
  const { container } = render(<MathNodeView {...defaultProps} editor={editor} selected={true} />);
273
493
  await waitFor(() => {
274
494
  const toolbar = container.querySelector('[data-toolbar-for]');
275
495
  expect(toolbar).toBeInTheDocument();
276
- // top = coordsAtPos.top (100) + Math.abs(document.body.top (0)) + 40 = 140
277
- expect(toolbar.style.top).toBe('140px');
278
- expect(toolbar.style.left).toBe('50px');
496
+ expect(toolbar.style.top).toBe('0px');
497
+ expect(toolbar.style.left).toBe('0px');
279
498
  });
280
499
  });
281
500
 
@@ -288,7 +507,16 @@ describe('MathNodeView', () => {
288
507
  });
289
508
  });
290
509
 
291
- it('recalculates position when toolbar reopens', async () => {
510
+ it('renders above other editor overlays with a high z-index', async () => {
511
+ const { container } = render(<MathNodeView {...defaultProps} selected={true} />);
512
+ await waitFor(() => {
513
+ const toolbar = container.querySelector('[data-toolbar-for]');
514
+ expect(toolbar).toBeInTheDocument();
515
+ expect(toolbar.style.zIndex).toBe('1000');
516
+ });
517
+ });
518
+
519
+ it('updates position from coordsAtPos when selection changes', async () => {
292
520
  const editor = {
293
521
  ...defaultProps.editor,
294
522
  view: {
@@ -302,28 +530,62 @@ describe('MathNodeView', () => {
302
530
  await waitFor(() => {
303
531
  const toolbar = container.querySelector('[data-toolbar-for]');
304
532
  expect(toolbar).toBeInTheDocument();
305
- // top = 200 + Math.abs(0) + 40 = 240
306
- expect(toolbar.style.top).toBe('240px');
533
+ expect(toolbar.style.top).toBe('200px');
307
534
  expect(toolbar.style.left).toBe('150px');
308
535
  });
309
536
  });
310
537
 
311
- it('accounts for negative container top (scrolled container)', async () => {
312
- const containerEl = document.createElement('div');
313
- containerEl.getBoundingClientRect = jest.fn(() => ({ top: -200, left: 0, width: 600, height: 400 }));
538
+ it('clamps toolbar position to viewport margins', async () => {
539
+ const originalInnerHeight = window.innerHeight;
540
+ const originalInnerWidth = window.innerWidth;
541
+
542
+ Object.defineProperty(window, 'innerHeight', { configurable: true, writable: true, value: 200 });
543
+ Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 300 });
314
544
 
315
545
  const editor = {
316
546
  ...defaultProps.editor,
317
- _tiptapContainerEl: containerEl,
547
+ view: {
548
+ ...defaultProps.editor.view,
549
+ coordsAtPos: jest.fn(() => ({ top: 190, left: 280, bottom: 195 })),
550
+ dispatch: jest.fn(),
551
+ },
318
552
  };
319
553
 
320
554
  const { container } = render(<MathNodeView {...defaultProps} editor={editor} selected={true} />);
555
+
556
+ let toolbar;
321
557
  await waitFor(() => {
322
- const toolbar = container.querySelector('[data-toolbar-for]');
558
+ toolbar = container.querySelector('[data-toolbar-for]');
323
559
  expect(toolbar).toBeInTheDocument();
324
- // top = 100 + Math.abs(-200) + 40 = 340
325
- expect(toolbar.style.top).toBe('340px');
326
560
  });
561
+
562
+ Object.defineProperty(toolbar, 'offsetHeight', { configurable: true, value: 100 });
563
+ Object.defineProperty(toolbar, 'offsetWidth', { configurable: true, value: 150 });
564
+
565
+ await act(async () => {
566
+ window.dispatchEvent(new Event('resize'));
567
+ await new Promise((resolve) => requestAnimationFrame(resolve));
568
+ });
569
+
570
+ await waitFor(() => {
571
+ expect(parseInt(toolbar.style.top, 10)).toBeLessThanOrEqual(200 - 100 - 8);
572
+ expect(parseInt(toolbar.style.left, 10)).toBeLessThanOrEqual(300 - 150 - 8);
573
+ expect(parseInt(toolbar.style.top, 10)).toBeGreaterThanOrEqual(8);
574
+ expect(parseInt(toolbar.style.left, 10)).toBeGreaterThanOrEqual(8);
575
+ });
576
+
577
+ Object.defineProperty(window, 'innerHeight', { configurable: true, writable: true, value: originalInnerHeight });
578
+ Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: originalInnerWidth });
579
+ });
580
+
581
+ it('attaches scroll and resize listeners while toolbar is open', async () => {
582
+ const addSpy = jest.spyOn(window, 'addEventListener');
583
+ render(<MathNodeView {...defaultProps} selected={true} />);
584
+ await waitFor(() => {
585
+ expect(addSpy).toHaveBeenCalledWith('scroll', expect.any(Function), true);
586
+ expect(addSpy).toHaveBeenCalledWith('resize', expect.any(Function));
587
+ });
588
+ addSpy.mockRestore();
327
589
  });
328
590
 
329
591
  it('portals toolbar into _tiptapContainerEl when available', async () => {
@@ -395,23 +657,45 @@ describe('MathNodeView', () => {
395
657
  });
396
658
  });
397
659
 
398
- it('unsets editor._toolbarOpened when toolbar is closed', async () => {
660
+ it('unsets editor._toolbarOpened when toolbar is closed and node is not selected', async () => {
661
+ const { getByTestId } = render(<MathNodeView {...defaultProps} selected={false} />);
662
+
663
+ fireEvent.click(getByTestId('math-preview'));
664
+
665
+ await waitFor(() => {
666
+ expect(getByTestId('math-toolbar')).toBeInTheDocument();
667
+ expect(defaultProps.editor._toolbarOpened).toBe(true);
668
+ });
669
+
670
+ fireEvent.click(getByTestId('done-button'));
671
+
672
+ await waitFor(() => {
673
+ expect(defaultProps.editor._toolbarOpened).toBe(false);
674
+ });
675
+ });
676
+
677
+ it('keeps editor._toolbarOpened true while the math node remains selected', async () => {
399
678
  const { getByTestId } = render(<MathNodeView {...defaultProps} selected={true} />);
400
679
 
401
680
  await waitFor(() => {
402
681
  expect(getByTestId('done-button')).toBeInTheDocument();
682
+ expect(defaultProps.editor._toolbarOpened).toBe(true);
403
683
  });
404
684
 
405
- const doneButton = getByTestId('done-button');
406
- fireEvent.click(doneButton);
685
+ fireEvent.click(getByTestId('done-button'));
407
686
 
408
687
  await waitFor(() => {
409
- expect(defaultProps.editor._toolbarOpened).toBe(false);
688
+ expect(defaultProps.editor._toolbarOpened).toBe(true);
410
689
  });
411
690
  });
412
691
 
413
- it('closes toolbar on outside click', async () => {
414
- const { queryByTestId } = render(<MathNodeView {...defaultProps} selected={true} />);
692
+ it('closes toolbar on outside click and runs handleDone', async () => {
693
+ const updateAttributes = jest.fn();
694
+ const editor = createMockEditor();
695
+ const { TextSelection } = require('prosemirror-state');
696
+ const { queryByTestId } = render(
697
+ <MathNodeView {...defaultProps} editor={editor} updateAttributes={updateAttributes} selected={true} />,
698
+ );
415
699
 
416
700
  await waitFor(() => {
417
701
  expect(queryByTestId('math-toolbar')).toBeInTheDocument();
@@ -421,6 +705,161 @@ describe('MathNodeView', () => {
421
705
 
422
706
  await waitFor(() => {
423
707
  expect(queryByTestId('math-toolbar')).not.toBeInTheDocument();
708
+ expect(updateAttributes).toHaveBeenCalledWith({ latex: 'x^2' });
709
+ expect(TextSelection.create).toHaveBeenCalledWith(editor.state.doc, 1);
710
+ expect(editor.state.tr.setSelection).toHaveBeenCalled();
711
+ expect(editor.view.dispatch).toHaveBeenCalledWith(editor.state.tr);
712
+ expect(editor.commands.focus).toHaveBeenCalled();
713
+ expect(editor._toolbarOpened).toBe(true);
714
+ });
715
+ });
716
+
717
+ it('re-registers click listener when node changes', async () => {
718
+ const addEventListenerSpy = jest.spyOn(document, 'addEventListener');
719
+ const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener');
720
+ const nodeA = { attrs: { latex: 'x^2' } };
721
+ const nodeB = { attrs: { latex: 'y^2' } };
722
+
723
+ const { rerender } = render(<MathNodeView {...defaultProps} node={nodeA} selected={true} />);
724
+
725
+ await waitFor(() => {
726
+ expect(addEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function));
727
+ });
728
+
729
+ const initialCallCount = addEventListenerSpy.mock.calls.length;
730
+
731
+ rerender(<MathNodeView {...defaultProps} node={nodeB} selected={true} />);
732
+
733
+ await waitFor(() => {
734
+ expect(removeEventListenerSpy).toHaveBeenCalled();
735
+ expect(addEventListenerSpy.mock.calls.length).toBeGreaterThan(initialCallCount);
736
+ });
737
+
738
+ addEventListenerSpy.mockRestore();
739
+ removeEventListenerSpy.mockRestore();
740
+ });
741
+
742
+ it('does not close toolbar when clicking the math node preview', async () => {
743
+ const { getByTestId, queryByTestId } = render(<MathNodeView {...defaultProps} selected={true} />);
744
+
745
+ await waitFor(() => {
746
+ expect(queryByTestId('math-toolbar')).toBeInTheDocument();
747
+ });
748
+
749
+ fireEvent.click(getByTestId('math-preview'));
750
+
751
+ await waitFor(() => {
752
+ expect(queryByTestId('math-toolbar')).toBeInTheDocument();
753
+ });
754
+ });
755
+
756
+ describe('unique math-node class per instance', () => {
757
+ let dateNowSpy;
758
+
759
+ beforeEach(() => {
760
+ let timestamp = 1000;
761
+ dateNowSpy = jest.spyOn(Date, 'now').mockImplementation(() => timestamp++);
762
+ });
763
+
764
+ afterEach(() => {
765
+ dateNowSpy.mockRestore();
766
+ });
767
+
768
+ it('assigns a timestamp-based class to NodeViewWrapper', () => {
769
+ const { getByTestId } = render(<MathNodeView {...defaultProps} />);
770
+ const wrapper = getByTestId('node-view-wrapper');
771
+
772
+ expect(wrapper.className).toMatch(/^math-node-\d+$/);
773
+ });
774
+
775
+ it('assigns a different class to each MathNodeView instance', () => {
776
+ const { getAllByTestId } = render(
777
+ <>
778
+ <MathNodeView {...defaultProps} />
779
+ <MathNodeView {...defaultProps} node={{ attrs: { latex: 'y^2' } }} />
780
+ </>,
781
+ );
782
+
783
+ const wrappers = getAllByTestId('node-view-wrapper');
784
+ expect(wrappers[0].className).toBe('math-node-1000');
785
+ expect(wrappers[1].className).toBe('math-node-1001');
786
+ });
787
+
788
+ it('closes toolbar when clicking a different math node', async () => {
789
+ const updateAttributes = jest.fn();
790
+ const editorA = createMockEditor();
791
+ const editorB = createMockEditor();
792
+
793
+ const { getAllByTestId, queryAllByTestId } = render(
794
+ <>
795
+ <MathNodeView {...defaultProps} editor={editorA} updateAttributes={updateAttributes} selected={false} />
796
+ <MathNodeView {...defaultProps} editor={editorB} node={{ attrs: { latex: 'y^2' } }} selected={false} />
797
+ </>,
798
+ );
799
+
800
+ fireEvent.click(getAllByTestId('math-preview')[0]);
801
+
802
+ await waitFor(() => {
803
+ expect(queryAllByTestId('math-toolbar')).toHaveLength(1);
804
+ });
805
+
806
+ const secondPreview = getAllByTestId('math-preview')[1];
807
+ fireEvent.click(secondPreview);
808
+
809
+ await waitFor(() => {
810
+ expect(queryAllByTestId('math-toolbar')).toHaveLength(1);
811
+ expect(updateAttributes).toHaveBeenCalledWith({ latex: 'x^2' });
812
+ expect(editorA._toolbarOpened).toBe(false);
813
+ expect(getAllByTestId('math-input')[0]).toHaveValue('y^2');
814
+ });
815
+ });
816
+ });
817
+
818
+ describe('selection-based toolbar guard', () => {
819
+ it('opens toolbar when selected transitions to true and the editor has a NodeSelection on math', async () => {
820
+ const { queryByTestId, rerender } = render(<MathNodeView {...defaultProps} selected={false} />);
821
+ expect(queryByTestId('math-toolbar')).not.toBeInTheDocument();
822
+
823
+ rerender(<MathNodeView {...defaultProps} selected={true} />);
824
+ await waitFor(() => {
825
+ expect(queryByTestId('math-toolbar')).toBeInTheDocument();
826
+ });
827
+ });
828
+
829
+ it('does not open toolbar when selected briefly becomes true but editor selection has no node (Cmd+A / drag case)', async () => {
830
+ const editor = {
831
+ ...defaultProps.editor,
832
+ state: {
833
+ ...defaultProps.editor.state,
834
+ selection: { from: 0, to: 100 }, // no .node — TextSelection / AllSelection shape
835
+ },
836
+ };
837
+
838
+ const { queryByTestId, rerender } = render(
839
+ <MathNodeView {...defaultProps} editor={editor} selected={false} />,
840
+ );
841
+ rerender(<MathNodeView {...defaultProps} editor={editor} selected={true} />);
842
+
843
+ await act(async () => {});
844
+ expect(queryByTestId('math-toolbar')).not.toBeInTheDocument();
845
+ });
846
+
847
+ it('does not open toolbar when selected briefly becomes true but NodeSelection targets a non-math node', async () => {
848
+ const editor = {
849
+ ...defaultProps.editor,
850
+ state: {
851
+ ...defaultProps.editor.state,
852
+ selection: { from: 0, to: 1, node: { type: { name: 'image' } } },
853
+ },
854
+ };
855
+
856
+ const { queryByTestId, rerender } = render(
857
+ <MathNodeView {...defaultProps} editor={editor} selected={false} />,
858
+ );
859
+ rerender(<MathNodeView {...defaultProps} editor={editor} selected={true} />);
860
+
861
+ await act(async () => {});
862
+ expect(queryByTestId('math-toolbar')).not.toBeInTheDocument();
424
863
  });
425
864
  });
426
865