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

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.
@@ -10,9 +10,11 @@ jest.mock('@tiptap/react', () => ({
10
10
  ),
11
11
  }));
12
12
 
13
+ const mockCreatePortal = jest.fn((node) => node);
14
+
13
15
  jest.mock('react-dom', () => ({
14
16
  ...jest.requireActual('react-dom'),
15
- createPortal: (node) => node,
17
+ createPortal: (...args) => mockCreatePortal(...args),
16
18
  }));
17
19
 
18
20
  describe('InlineDropdown', () => {
@@ -77,6 +79,7 @@ describe('InlineDropdown', () => {
77
79
 
78
80
  beforeEach(() => {
79
81
  jest.clearAllMocks();
82
+ mockCreatePortal.mockClear();
80
83
  mockEditor = buildMockEditor();
81
84
  defaultProps.editor = mockEditor;
82
85
  Object.defineProperty(document.body, 'getBoundingClientRect', {
@@ -182,6 +185,85 @@ describe('InlineDropdown', () => {
182
185
  });
183
186
  });
184
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
+
185
267
  it('has correct border styling', () => {
186
268
  const { container } = render(<InlineDropdown {...defaultProps} />);
187
269
  const dropdownDiv = container.querySelector('div[style*="border"]');
@@ -211,14 +293,29 @@ describe('InlineDropdown', () => {
211
293
  expect(textContainer).toHaveStyle({ textOverflow: 'ellipsis', whiteSpace: 'nowrap' });
212
294
  });
213
295
 
214
- it('renders toolbar with z-index', async () => {
215
- const { container } = render(<InlineDropdown {...defaultProps} selected={true} />);
296
+ it('portals toolbar into editor container when _tiptapContainerEl is set', async () => {
297
+ const containerEl = document.createElement('div');
298
+ const editor = buildMockEditor({ _tiptapContainerEl: containerEl });
299
+
300
+ render(<InlineDropdown {...defaultProps} editor={editor} selected={true} />);
301
+
216
302
  await waitFor(() => {
217
- const toolbarContainer = container.querySelector('div[style*="zIndex"]');
218
- if (toolbarContainer) {
219
- expect(toolbarContainer).toHaveStyle({ zIndex: '1' });
220
- }
303
+ expect(mockCreatePortal).toHaveBeenCalled();
221
304
  });
305
+
306
+ expect(mockCreatePortal.mock.calls[0][1]).toBe(containerEl);
307
+ });
308
+
309
+ it('portals toolbar into document.body when _tiptapContainerEl is missing', async () => {
310
+ const editor = buildMockEditor({ _tiptapContainerEl: undefined });
311
+
312
+ render(<InlineDropdown {...defaultProps} editor={editor} selected={true} />);
313
+
314
+ await waitFor(() => {
315
+ expect(mockCreatePortal).toHaveBeenCalled();
316
+ });
317
+
318
+ expect(mockCreatePortal.mock.calls[0][1]).toBe(document.body);
222
319
  });
223
320
 
224
321
  it('passes editorCallback to InlineDropdownToolbar', async () => {
@@ -12,6 +12,11 @@ const StyledButton = styled('button', {
12
12
  background: 'none',
13
13
  border: 'none',
14
14
  cursor: disabled ? 'not-allowed' : 'pointer',
15
+ // previously we had implicit 24×24 icon rendering for mui svg icons, but now we need to explicitly set the size to 24×24 to match the previous behavior
16
+ '& svg': {
17
+ width: '24px',
18
+ height: '24px',
19
+ },
15
20
  '&:hover': {
16
21
  color: disabled ? 'grey' : 'black',
17
22
  },
@@ -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
@@ -175,14 +175,14 @@ const InlineDropdown = (props) => {
175
175
  {showToolbar && (
176
176
  <React.Fragment>
177
177
  {ReactDOM.createPortal(
178
- <div ref={toolbarRef} style={{ zIndex: 1 }}>
178
+ <div ref={toolbarRef}>
179
179
  <InlineDropdownToolbar
180
180
  editorCallback={(instance) => {
181
181
  toolbarEditor.current = instance;
182
182
  }}
183
183
  />
184
184
  </div>,
185
- document.body,
185
+ editor?._tiptapContainerEl || document.body,
186
186
  )}
187
187
 
188
188
  {editor._tiptapContainerEl &&
@@ -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) => {