@pie-lib/editable-html-tip-tap 2.1.3 → 2.1.5

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,6 @@
1
1
  import React from 'react';
2
2
  import { render, waitFor, fireEvent } from '@testing-library/react';
3
- import { MathNode, MathNodeView } from '../math';
3
+ import { EnsureTextAfterMathPlugin, MathNode, MathNodeView, ZeroWidthSpaceHandlingPlugin } from '../math';
4
4
 
5
5
  jest.mock('@tiptap/react', () => ({
6
6
  NodeViewWrapper: ({ children, ...props }) => (
@@ -156,6 +156,151 @@ describe('MathNode', () => {
156
156
  expect(result).toBeDefined();
157
157
  });
158
158
  });
159
+
160
+ describe('addProseMirrorPlugins', () => {
161
+ it('registers ensure-text-after-math and zero-width-space plugins', () => {
162
+ const plugins = MathNode.addProseMirrorPlugins();
163
+
164
+ expect(plugins).toHaveLength(2);
165
+ expect(plugins[0].appendTransaction).toBeDefined();
166
+ expect(plugins[1].props.handleKeyDown).toBeDefined();
167
+ });
168
+ });
169
+ });
170
+
171
+ describe('EnsureTextAfterMathPlugin', () => {
172
+ it('inserts a zero-width space after a math node when no text follows', () => {
173
+ const plugin = EnsureTextAfterMathPlugin('math');
174
+ const textNode = { type: { name: 'text' } };
175
+ const mathNode = { type: { name: 'math' }, nodeSize: 3 };
176
+ const tr = { insert: jest.fn() };
177
+
178
+ const newState = {
179
+ schema: { text: jest.fn((value) => ({ type: textNode.type, text: value })) },
180
+ tr,
181
+ doc: {
182
+ descendants: (cb) => cb(mathNode, 5),
183
+ nodeAt: jest.fn(() => null),
184
+ },
185
+ };
186
+
187
+ const result = plugin.appendTransaction([{ docChanged: true }], {}, newState);
188
+
189
+ expect(tr.insert).toHaveBeenCalledWith(8, expect.anything());
190
+ expect(result).toBe(tr);
191
+ });
192
+
193
+ it('does not insert when text already follows the math node', () => {
194
+ const plugin = EnsureTextAfterMathPlugin('math');
195
+ const tr = { insert: jest.fn() };
196
+ const mathNode = { type: { name: 'math' }, nodeSize: 3 };
197
+
198
+ const newState = {
199
+ schema: { text: jest.fn() },
200
+ tr,
201
+ doc: {
202
+ descendants: (cb) => cb(mathNode, 5),
203
+ nodeAt: jest.fn(() => ({ type: { name: 'text' } })),
204
+ },
205
+ };
206
+
207
+ const result = plugin.appendTransaction([{ docChanged: true }], {}, newState);
208
+
209
+ expect(tr.insert).not.toHaveBeenCalled();
210
+ expect(result).toBeNull();
211
+ });
212
+
213
+ it('returns null when the document did not change', () => {
214
+ const plugin = EnsureTextAfterMathPlugin('math');
215
+
216
+ const result = plugin.appendTransaction([{ docChanged: false }], {}, {});
217
+ expect(result).toBeNull();
218
+ });
219
+ });
220
+
221
+ describe('ZeroWidthSpaceHandlingPlugin', () => {
222
+ const createDefaultDoc = () => ({
223
+ textBetween: jest.fn(() => '\u200b'),
224
+ resolve: jest.fn(() => ({
225
+ nodeAfter: null,
226
+ nodeBefore: null,
227
+ })),
228
+ });
229
+
230
+ const createView = ({ state: stateOverrides = {} } = {}) => {
231
+ const dispatch = jest.fn();
232
+ const tr = {
233
+ delete: jest.fn().mockReturnThis(),
234
+ setSelection: jest.fn().mockReturnThis(),
235
+ };
236
+
237
+ return {
238
+ state: {
239
+ selection: { from: 2, empty: true },
240
+ doc: createDefaultDoc(),
241
+ tr,
242
+ ...stateOverrides,
243
+ doc: { ...createDefaultDoc(), ...stateOverrides.doc },
244
+ },
245
+ dispatch,
246
+ };
247
+ };
248
+
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);
253
+
254
+ expect(handled).toBe(true);
255
+ expect(view.state.tr.delete).toHaveBeenCalledWith(0, 2);
256
+ expect(view.dispatch).toHaveBeenCalledWith(view.state.tr);
257
+ });
258
+
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 }),
268
+ },
269
+ },
270
+ });
271
+ const { NodeSelection } = require('prosemirror-state');
272
+
273
+ const handled = ZeroWidthSpaceHandlingPlugin.props.handleKeyDown(view, { key: 'ArrowLeft' });
274
+
275
+ expect(handled).toBe(true);
276
+ expect(NodeSelection.create).toHaveBeenCalledWith(view.state.doc, 4);
277
+ expect(view.dispatch).toHaveBeenCalled();
278
+ });
279
+
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');
283
+
284
+ const handled = ZeroWidthSpaceHandlingPlugin.props.handleKeyDown(view, { key: 'ArrowLeft' });
285
+
286
+ expect(handled).toBe(true);
287
+ expect(TextSelection.create).toHaveBeenCalledWith(view.state.doc, 0);
288
+ expect(view.dispatch).toHaveBeenCalled();
289
+ });
290
+
291
+ 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
+ });
300
+
301
+ const handled = ZeroWidthSpaceHandlingPlugin.props.handleKeyDown(view, { key: 'Enter' });
302
+ expect(handled).toBe(false);
303
+ });
159
304
  });
160
305
 
161
306
  describe('MathNodeView', () => {
@@ -167,6 +312,7 @@ describe('MathNodeView', () => {
167
312
  },
168
313
  tr: {
169
314
  setSelection: jest.fn().mockReturnThis(),
315
+ setMeta: jest.fn().mockReturnThis(),
170
316
  },
171
317
  doc: {},
172
318
  },
@@ -244,37 +390,30 @@ describe('MathNodeView', () => {
244
390
  });
245
391
 
246
392
  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} />);
393
+ it('uses a fixed top offset and horizontal position from coordsAtPos', async () => {
394
+ const { container } = render(<MathNodeView {...defaultProps} selected={true} />);
257
395
  await waitFor(() => {
258
396
  const toolbar = container.querySelector('[data-toolbar-for]');
259
397
  expect(toolbar).toBeInTheDocument();
260
- // top = coordsAtPos.top (100) + Math.abs(containerEl.top (-50)) + 40 = 190
261
- expect(toolbar.style.top).toBe('190px');
398
+ expect(toolbar.style.top).toBe('40px');
262
399
  expect(toolbar.style.left).toBe('50px');
263
400
  });
264
401
  });
265
402
 
266
- it('falls back to document.body when _tiptapContainerEl is not set', async () => {
403
+ it('keeps the fixed top offset when the editor container is scrolled', async () => {
404
+ const containerEl = document.createElement('div');
405
+ containerEl.getBoundingClientRect = jest.fn(() => ({ top: -200, left: 0, width: 600, height: 400 }));
406
+
267
407
  const editor = {
268
408
  ...defaultProps.editor,
269
- _tiptapContainerEl: undefined,
409
+ _tiptapContainerEl: containerEl,
270
410
  };
271
411
 
272
412
  const { container } = render(<MathNodeView {...defaultProps} editor={editor} selected={true} />);
273
413
  await waitFor(() => {
274
414
  const toolbar = container.querySelector('[data-toolbar-for]');
275
415
  expect(toolbar).toBeInTheDocument();
276
- // top = coordsAtPos.top (100) + Math.abs(document.body.top (0)) + 40 = 140
277
- expect(toolbar.style.top).toBe('140px');
416
+ expect(toolbar.style.top).toBe('40px');
278
417
  expect(toolbar.style.left).toBe('50px');
279
418
  });
280
419
  });
@@ -288,7 +427,7 @@ describe('MathNodeView', () => {
288
427
  });
289
428
  });
290
429
 
291
- it('recalculates position when toolbar reopens', async () => {
430
+ it('updates horizontal position from coordsAtPos when selection changes', async () => {
292
431
  const editor = {
293
432
  ...defaultProps.editor,
294
433
  view: {
@@ -302,30 +441,11 @@ describe('MathNodeView', () => {
302
441
  await waitFor(() => {
303
442
  const toolbar = container.querySelector('[data-toolbar-for]');
304
443
  expect(toolbar).toBeInTheDocument();
305
- // top = 200 + Math.abs(0) + 40 = 240
306
- expect(toolbar.style.top).toBe('240px');
444
+ expect(toolbar.style.top).toBe('40px');
307
445
  expect(toolbar.style.left).toBe('150px');
308
446
  });
309
447
  });
310
448
 
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 }));
314
-
315
- const editor = {
316
- ...defaultProps.editor,
317
- _tiptapContainerEl: containerEl,
318
- };
319
-
320
- const { container } = render(<MathNodeView {...defaultProps} editor={editor} selected={true} />);
321
- await waitFor(() => {
322
- const toolbar = container.querySelector('[data-toolbar-for]');
323
- expect(toolbar).toBeInTheDocument();
324
- // top = 100 + Math.abs(-200) + 40 = 340
325
- expect(toolbar.style.top).toBe('340px');
326
- });
327
- });
328
-
329
449
  it('portals toolbar into _tiptapContainerEl when available', async () => {
330
450
  const containerEl = document.createElement('div');
331
451
  containerEl.getBoundingClientRect = jest.fn(() => ({ top: 0, left: 0, width: 600, height: 400 }));
@@ -410,8 +530,13 @@ describe('MathNodeView', () => {
410
530
  });
411
531
  });
412
532
 
413
- it('closes toolbar on outside click', async () => {
414
- const { queryByTestId } = render(<MathNodeView {...defaultProps} selected={true} />);
533
+ it('closes toolbar on outside click and runs handleDone', async () => {
534
+ const updateAttributes = jest.fn();
535
+ const editor = createMockEditor();
536
+ const { TextSelection } = require('prosemirror-state');
537
+ const { queryByTestId } = render(
538
+ <MathNodeView {...defaultProps} editor={editor} updateAttributes={updateAttributes} selected={true} />,
539
+ );
415
540
 
416
541
  await waitFor(() => {
417
542
  expect(queryByTestId('math-toolbar')).toBeInTheDocument();
@@ -421,6 +546,86 @@ describe('MathNodeView', () => {
421
546
 
422
547
  await waitFor(() => {
423
548
  expect(queryByTestId('math-toolbar')).not.toBeInTheDocument();
549
+ expect(updateAttributes).toHaveBeenCalledWith({ latex: 'x^2' });
550
+ expect(TextSelection.create).toHaveBeenCalledWith(editor.state.doc, 1);
551
+ expect(editor.state.tr.setSelection).toHaveBeenCalled();
552
+ expect(editor.view.dispatch).toHaveBeenCalledWith(editor.state.tr);
553
+ expect(editor.commands.focus).toHaveBeenCalled();
554
+ expect(editor._toolbarOpened).toBe(false);
555
+ });
556
+ });
557
+
558
+ it('does not close toolbar when clicking the math node preview', async () => {
559
+ const { getByTestId, queryByTestId } = render(<MathNodeView {...defaultProps} selected={true} />);
560
+
561
+ await waitFor(() => {
562
+ expect(queryByTestId('math-toolbar')).toBeInTheDocument();
563
+ });
564
+
565
+ fireEvent.click(getByTestId('math-preview'));
566
+
567
+ await waitFor(() => {
568
+ expect(queryByTestId('math-toolbar')).toBeInTheDocument();
569
+ });
570
+ });
571
+
572
+ describe('unique math-node class per instance', () => {
573
+ let dateNowSpy;
574
+
575
+ beforeEach(() => {
576
+ let timestamp = 1000;
577
+ dateNowSpy = jest.spyOn(Date, 'now').mockImplementation(() => timestamp++);
578
+ });
579
+
580
+ afterEach(() => {
581
+ dateNowSpy.mockRestore();
582
+ });
583
+
584
+ it('assigns a timestamp-based class to NodeViewWrapper', () => {
585
+ const { getByTestId } = render(<MathNodeView {...defaultProps} />);
586
+ const wrapper = getByTestId('node-view-wrapper');
587
+
588
+ expect(wrapper.className).toMatch(/^math-node-\d+$/);
589
+ });
590
+
591
+ it('assigns a different class to each MathNodeView instance', () => {
592
+ const { getAllByTestId } = render(
593
+ <>
594
+ <MathNodeView {...defaultProps} />
595
+ <MathNodeView {...defaultProps} node={{ attrs: { latex: 'y^2' } }} />
596
+ </>,
597
+ );
598
+
599
+ const wrappers = getAllByTestId('node-view-wrapper');
600
+ expect(wrappers[0].className).toBe('math-node-1000');
601
+ expect(wrappers[1].className).toBe('math-node-1001');
602
+ });
603
+
604
+ it('closes toolbar when clicking a different math node', async () => {
605
+ const updateAttributes = jest.fn();
606
+ const editorA = createMockEditor();
607
+ const editorB = createMockEditor();
608
+
609
+ const { getAllByTestId, queryAllByTestId } = render(
610
+ <>
611
+ <MathNodeView {...defaultProps} editor={editorA} updateAttributes={updateAttributes} selected={true} />
612
+ <MathNodeView {...defaultProps} editor={editorB} node={{ attrs: { latex: 'y^2' } }} selected={false} />
613
+ </>,
614
+ );
615
+
616
+ await waitFor(() => {
617
+ expect(queryAllByTestId('math-toolbar')).toHaveLength(1);
618
+ });
619
+
620
+ const secondPreview = getAllByTestId('math-preview')[1];
621
+ fireEvent.click(secondPreview);
622
+
623
+ await waitFor(() => {
624
+ expect(queryAllByTestId('math-toolbar')).toHaveLength(1);
625
+ expect(updateAttributes).toHaveBeenCalledWith({ latex: 'x^2' });
626
+ expect(editorA._toolbarOpened).toBe(false);
627
+ expect(getAllByTestId('math-input')[0]).toHaveValue('y^2');
628
+ });
424
629
  });
425
630
  });
426
631
 
@@ -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
 
@@ -183,6 +184,7 @@ export const MathNodeView = (props) => {
183
184
  const { node, updateAttributes, editor, selected, options } = props;
184
185
  const [showToolbar, setShowToolbar] = useState(selected);
185
186
  const toolbarRef = useRef(null);
187
+ const timestamp = useRef(Date.now());
186
188
  const [position, setPosition] = useState({ top: 0, left: 0 });
187
189
  const { math: mathOptions = {} } = options || {};
188
190
  const {
@@ -195,6 +197,23 @@ export const MathNodeView = (props) => {
195
197
 
196
198
  const latex = node.attrs.latex || '';
197
199
 
200
+ const handleChange = (newLatex) => {
201
+ updateAttributes({ latex: newLatex });
202
+ };
203
+
204
+ const handleDone = (newLatex) => {
205
+ updateAttributes({ latex: newLatex });
206
+ setShowToolbar(false);
207
+
208
+ const { selection, tr, doc } = editor.state;
209
+ const sel = TextSelection.create(doc, selection.from + 1);
210
+
211
+ // Build a fresh transaction from the current state and set the selection
212
+ tr.setSelection(sel);
213
+ editor.view.dispatch(tr);
214
+ editor.commands.focus();
215
+ };
216
+
198
217
  useEffect(() => {
199
218
  if (selected) {
200
219
  setShowToolbar(true);
@@ -202,17 +221,15 @@ export const MathNodeView = (props) => {
202
221
  }, [selected]);
203
222
 
204
223
  useEffect(() => {
205
- editor._toolbarOpened = !!showToolbar;
206
- }, [showToolbar]);
224
+ setToolbarOpened(editor, showToolbar);
225
+ }, [editor, showToolbar]);
207
226
 
208
227
  useEffect(() => {
209
228
  // Calculate position relative to selection
210
- const container = editor?._tiptapContainerEl || document.body;
211
- const bodyRect = container.getBoundingClientRect();
212
229
  const { from } = editor.state.selection;
213
230
  const start = editor.view.coordsAtPos(from);
214
231
  setPosition({
215
- top: start.top + Math.abs(bodyRect.top) + 40, // shift above
232
+ top: 40, // shift above
216
233
  left: start.left,
217
234
  });
218
235
 
@@ -236,7 +253,7 @@ export const MathNodeView = (props) => {
236
253
  // will keep/re-open the toolbar. Without this guard, closing and then
237
254
  // immediately clicking the math node would fire this listener in the same
238
255
  // event cycle and close the toolbar before it could open.
239
- const clickedMathNode = !!target?.closest?.('.math-node');
256
+ const clickedMathNode = !!target?.closest?.(`.math-node-${timestamp.current}`);
240
257
 
241
258
  if (
242
259
  toolbarRef.current &&
@@ -247,6 +264,7 @@ export const MathNodeView = (props) => {
247
264
  !clickedMathNode
248
265
  ) {
249
266
  setShowToolbar(false);
267
+ handleDone(node.attrs.latex);
250
268
  }
251
269
  };
252
270
 
@@ -261,28 +279,9 @@ export const MathNodeView = (props) => {
261
279
  return () => document.removeEventListener('click', handleClickOutside);
262
280
  }, [editor, showToolbar]);
263
281
 
264
- const handleChange = (newLatex) => {
265
- updateAttributes({ latex: newLatex });
266
- };
267
-
268
- const handleDone = (newLatex) => {
269
- updateAttributes({ latex: newLatex });
270
- setShowToolbar(false);
271
-
272
- editor._toolbarOpened = false;
273
-
274
- const { selection, tr, doc } = editor.state;
275
- const sel = TextSelection.create(doc, selection.from + 1);
276
-
277
- // Build a fresh transaction from the current state and set the selection
278
- tr.setSelection(sel);
279
- editor.view.dispatch(tr);
280
- editor.commands.focus();
281
- };
282
-
283
282
  return (
284
283
  <NodeViewWrapper
285
- className="math-node"
284
+ className={`math-node-${timestamp.current}`}
286
285
  style={{
287
286
  display: 'inline-flex',
288
287
  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
+ };