@pie-lib/editable-html-tip-tap 2.1.5 → 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' });
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
+ });
253
314
 
254
- expect(handled).toBe(true);
255
- expect(view.state.tr.delete).toHaveBeenCalledWith(0, 2);
256
- expect(view.dispatch).toHaveBeenCalledWith(view.state.tr);
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: {
@@ -320,6 +401,9 @@ describe('MathNodeView', () => {
320
401
  coordsAtPos: jest.fn(() => ({ top: 100, left: 50 })),
321
402
  dispatch: jest.fn(),
322
403
  },
404
+ options: {
405
+ element: createEditorElement(),
406
+ },
323
407
  commands: {
324
408
  focus: jest.fn(),
325
409
  },
@@ -335,13 +419,6 @@ describe('MathNodeView', () => {
335
419
 
336
420
  let defaultProps;
337
421
 
338
- beforeAll(() => {
339
- Object.defineProperty(document.body, 'getBoundingClientRect', {
340
- value: jest.fn(() => ({ top: 0, left: 0 })),
341
- configurable: true,
342
- });
343
- });
344
-
345
422
  beforeEach(() => {
346
423
  jest.clearAllMocks();
347
424
  mockCreatePortal.mockImplementation((node) => node);
@@ -390,30 +467,29 @@ describe('MathNodeView', () => {
390
467
  });
391
468
 
392
469
  describe('toolbar positioning', () => {
393
- it('uses a fixed top offset and horizontal position from coordsAtPos', async () => {
470
+ it('positions relative to the editor element using coordsAtPos', async () => {
394
471
  const { container } = render(<MathNodeView {...defaultProps} selected={true} />);
395
472
  await waitFor(() => {
396
473
  const toolbar = container.querySelector('[data-toolbar-for]');
397
474
  expect(toolbar).toBeInTheDocument();
398
- expect(toolbar.style.top).toBe('40px');
475
+ expect(toolbar.style.top).toBe('140px');
399
476
  expect(toolbar.style.left).toBe('50px');
400
477
  });
401
478
  });
402
479
 
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 }));
480
+ it('accounts for editor scroll offset when calculating toolbar position', async () => {
481
+ const editorElement = createEditorElement({ top: -200, left: 0, width: 600, height: 400 });
406
482
 
407
483
  const editor = {
408
484
  ...defaultProps.editor,
409
- _tiptapContainerEl: containerEl,
485
+ options: { element: editorElement },
410
486
  };
411
487
 
412
488
  const { container } = render(<MathNodeView {...defaultProps} editor={editor} selected={true} />);
413
489
  await waitFor(() => {
414
490
  const toolbar = container.querySelector('[data-toolbar-for]');
415
491
  expect(toolbar).toBeInTheDocument();
416
- expect(toolbar.style.top).toBe('40px');
492
+ expect(toolbar.style.top).toBe('340px');
417
493
  expect(toolbar.style.left).toBe('50px');
418
494
  });
419
495
  });
@@ -427,7 +503,7 @@ describe('MathNodeView', () => {
427
503
  });
428
504
  });
429
505
 
430
- it('updates horizontal position from coordsAtPos when selection changes', async () => {
506
+ it('updates position from coordsAtPos when selection changes', async () => {
431
507
  const editor = {
432
508
  ...defaultProps.editor,
433
509
  view: {
@@ -441,7 +517,7 @@ describe('MathNodeView', () => {
441
517
  await waitFor(() => {
442
518
  const toolbar = container.querySelector('[data-toolbar-for]');
443
519
  expect(toolbar).toBeInTheDocument();
444
- expect(toolbar.style.top).toBe('40px');
520
+ expect(toolbar.style.top).toBe('240px');
445
521
  expect(toolbar.style.left).toBe('150px');
446
522
  });
447
523
  });
@@ -515,18 +591,35 @@ describe('MathNodeView', () => {
515
591
  });
516
592
  });
517
593
 
518
- 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 () => {
519
612
  const { getByTestId } = render(<MathNodeView {...defaultProps} selected={true} />);
520
613
 
521
614
  await waitFor(() => {
522
615
  expect(getByTestId('done-button')).toBeInTheDocument();
616
+ expect(defaultProps.editor._toolbarOpened).toBe(true);
523
617
  });
524
618
 
525
- const doneButton = getByTestId('done-button');
526
- fireEvent.click(doneButton);
619
+ fireEvent.click(getByTestId('done-button'));
527
620
 
528
621
  await waitFor(() => {
529
- expect(defaultProps.editor._toolbarOpened).toBe(false);
622
+ expect(defaultProps.editor._toolbarOpened).toBe(true);
530
623
  });
531
624
  });
532
625
 
@@ -551,7 +644,7 @@ describe('MathNodeView', () => {
551
644
  expect(editor.state.tr.setSelection).toHaveBeenCalled();
552
645
  expect(editor.view.dispatch).toHaveBeenCalledWith(editor.state.tr);
553
646
  expect(editor.commands.focus).toHaveBeenCalled();
554
- expect(editor._toolbarOpened).toBe(false);
647
+ expect(editor._toolbarOpened).toBe(true);
555
648
  });
556
649
  });
557
650
 
@@ -608,11 +701,13 @@ describe('MathNodeView', () => {
608
701
 
609
702
  const { getAllByTestId, queryAllByTestId } = render(
610
703
  <>
611
- <MathNodeView {...defaultProps} editor={editorA} updateAttributes={updateAttributes} selected={true} />
704
+ <MathNodeView {...defaultProps} editor={editorA} updateAttributes={updateAttributes} selected={false} />
612
705
  <MathNodeView {...defaultProps} editor={editorB} node={{ attrs: { latex: 'y^2' } }} selected={false} />
613
706
  </>,
614
707
  );
615
708
 
709
+ fireEvent.click(getAllByTestId('math-preview')[0]);
710
+
616
711
  await waitFor(() => {
617
712
  expect(queryAllByTestId('math-toolbar')).toHaveLength(1);
618
713
  });
@@ -45,6 +45,26 @@ export const EnsureTextAfterMathPlugin = (mathNodeName) =>
45
45
  },
46
46
  });
47
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
+
48
68
  export const ZeroWidthSpaceHandlingPlugin = new Plugin({
49
69
  key: new PluginKey('zeroWidthSpaceHandling'),
50
70
  props: {
@@ -54,35 +74,38 @@ export const ZeroWidthSpaceHandlingPlugin = new Plugin({
54
74
  const { from, empty } = selection;
55
75
 
56
76
  if (empty && event.key === 'Backspace' && from > 0) {
57
- const prevChar = doc.textBetween(from - 1, from, '\uFFFC', '\uFFFC');
58
- if (prevChar === '\u200b') {
59
- const tr = state.tr.delete(from - 2, from);
60
- dispatch(tr);
61
- return true; // handled
77
+ const start = nodeBeforeZeroWidthSpace(doc, from);
78
+
79
+ if (start === -1) {
80
+ return false;
62
81
  }
82
+
83
+ const tr = state.tr.delete(start, from);
84
+ dispatch(tr);
85
+ return true; // handled
63
86
  }
64
87
 
65
88
  if (empty && event.key === 'ArrowLeft' && from > 0) {
66
- const prevChar = doc.textBetween(from - 1, from, '\uFFFC', '\uFFFC');
67
- // If the previous character is the zero-width space...
68
- if (prevChar === '\u200b') {
69
- const posBefore = from - 1;
70
- const resolved = state.doc.resolve(posBefore - 1); // look just before the zwsp
71
- const maybeNode = resolved.nodeAfter || resolved.nodeBefore;
72
-
73
- // Check if there's an inline selectable node (e.g., your math node)
74
- if (maybeNode) {
75
- const nodePos = posBefore - maybeNode.nodeSize;
76
- const nodeResolved = state.doc.resolve(nodePos);
77
- const tr = state.tr.setSelection(NodeSelection.create(state.doc, nodeResolved.pos));
78
- dispatch(tr);
79
- return true;
80
- } else {
81
- // Just move the text cursor before the zwsp
82
- const tr = state.tr.setSelection(TextSelection.create(state.doc, from - 2));
83
- dispatch(tr);
84
- return true;
85
- }
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;
86
109
  }
87
110
  }
88
111
 
@@ -152,14 +175,9 @@ export const MathNode = Node.create({
152
175
 
153
176
  dispatch(tr);
154
177
 
178
+ setToolbarOpened(editor, true);
155
179
  return true;
156
180
  },
157
- // insertMath: (latex = '') => ({ commands }) => {
158
- // return commands.insertContent({
159
- // type: this.name,
160
- // attrs: { latex },
161
- // });
162
- // },
163
181
  };
164
182
  },
165
183
 
@@ -221,16 +239,19 @@ export const MathNodeView = (props) => {
221
239
  }, [selected]);
222
240
 
223
241
  useEffect(() => {
224
- setToolbarOpened(editor, showToolbar);
225
- }, [editor, 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) => {