@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,7 +1,12 @@
1
1
  import React from 'react';
2
2
  import { fireEvent, render, waitFor } from '@testing-library/react';
3
+ import { renderMath } from '@pie-lib/math-rendering';
3
4
  import InlineDropdown from '../respArea/InlineDropdown';
4
5
 
6
+ jest.mock('@pie-lib/math-rendering', () => ({
7
+ renderMath: jest.fn(),
8
+ }));
9
+
5
10
  jest.mock('@tiptap/react', () => ({
6
11
  NodeViewWrapper: ({ children, ...props }) => (
7
12
  <div data-testid="node-view-wrapper" {...props}>
@@ -10,15 +15,27 @@ jest.mock('@tiptap/react', () => ({
10
15
  ),
11
16
  }));
12
17
 
18
+ const mockCreatePortal = jest.fn((node) => node);
19
+
13
20
  jest.mock('react-dom', () => ({
14
21
  ...jest.requireActual('react-dom'),
15
- createPortal: (node) => node,
22
+ createPortal: (...args) => mockCreatePortal(...args),
16
23
  }));
17
24
 
18
25
  describe('InlineDropdown', () => {
19
26
  const buildMockEditor = (overrides = {}) => {
27
+ const mockNodeAt = jest.fn((pos) => (pos === 5 ? { nodeSize: 1 } : null));
28
+ const mockDoc = {
29
+ descendants: jest.fn(),
30
+ nodeAt: mockNodeAt,
31
+ };
20
32
  const mockTr = {
21
33
  delete: jest.fn(),
34
+ doc: { nodeAt: mockNodeAt },
35
+ setSelection: jest.fn(),
36
+ setMeta: jest.fn(function setMeta() {
37
+ return mockTr;
38
+ }),
22
39
  };
23
40
  return {
24
41
  state: {
@@ -27,6 +44,7 @@ describe('InlineDropdown', () => {
27
44
  to: 1,
28
45
  },
29
46
  tr: mockTr,
47
+ doc: mockDoc,
30
48
  },
31
49
  view: {
32
50
  coordsAtPos: jest.fn(() => ({ top: 100, left: 50 })),
@@ -66,6 +84,7 @@ describe('InlineDropdown', () => {
66
84
 
67
85
  beforeEach(() => {
68
86
  jest.clearAllMocks();
87
+ mockCreatePortal.mockClear();
69
88
  mockEditor = buildMockEditor();
70
89
  defaultProps.editor = mockEditor;
71
90
  Object.defineProperty(document.body, 'getBoundingClientRect', {
@@ -90,6 +109,32 @@ describe('InlineDropdown', () => {
90
109
  expect(valueDiv).toBeInTheDocument();
91
110
  });
92
111
 
112
+ it('renders math inside the value control on mount', () => {
113
+ const { container } = render(<InlineDropdown {...defaultProps} />);
114
+ const valueDiv = container.querySelector('div[style*="border"]');
115
+
116
+ expect(renderMath).toHaveBeenCalledWith(valueDiv);
117
+ });
118
+
119
+ it('re-renders math when the value changes', () => {
120
+ const updatedNode = {
121
+ ...mockNode,
122
+ attrs: {
123
+ ...mockNode.attrs,
124
+ value: '<span>Updated math</span>',
125
+ },
126
+ };
127
+
128
+ const { container, rerender } = render(<InlineDropdown {...defaultProps} />);
129
+
130
+ renderMath.mockClear();
131
+
132
+ rerender(<InlineDropdown {...defaultProps} node={updatedNode} />);
133
+
134
+ const valueDiv = container.querySelector('div[style*="border"]');
135
+ expect(renderMath).toHaveBeenCalledWith(valueDiv);
136
+ });
137
+
93
138
  it('uses 2px horizontal margin on the value control and no horizontal margin on the wrapper', () => {
94
139
  const { container, getByTestId } = render(<InlineDropdown {...defaultProps} />);
95
140
  const valueDiv = container.querySelector('div[style*="border"]');
@@ -171,6 +216,107 @@ describe('InlineDropdown', () => {
171
216
  });
172
217
  });
173
218
 
219
+ it('calls close callback when toolbar closes on outside click', async () => {
220
+ const onInlineDropdownToolbarClose = jest.fn();
221
+ const options = {
222
+ ...mockOptions,
223
+ onInlineDropdownToolbarClose,
224
+ };
225
+
226
+ const { queryByTestId } = render(<InlineDropdown {...defaultProps} options={options} selected={true} />);
227
+
228
+ await waitFor(() => {
229
+ expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument();
230
+ });
231
+
232
+ fireEvent.mouseDown(document.body);
233
+
234
+ await waitFor(() => {
235
+ expect(queryByTestId('inline-dropdown-toolbar')).not.toBeInTheDocument();
236
+ });
237
+
238
+ expect(onInlineDropdownToolbarClose).toHaveBeenCalledWith(mockEditor);
239
+ });
240
+
241
+ it('uses the current node when closing on outside click after the node prop changes', async () => {
242
+ const onToolbarCloseRequest = jest.fn((_tuple, _editor, onConfirm) => onConfirm());
243
+ const options = {
244
+ ...mockOptions,
245
+ onToolbarCloseRequest,
246
+ };
247
+ const updatedNode = {
248
+ attrs: { index: '1', value: 'Updated Option', error: false },
249
+ nodeSize: 1,
250
+ };
251
+
252
+ const { queryByTestId, rerender } = render(<InlineDropdown {...defaultProps} options={options} selected={true} />);
253
+
254
+ await waitFor(() => {
255
+ expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument();
256
+ });
257
+
258
+ rerender(<InlineDropdown {...defaultProps} options={options} node={updatedNode} selected={true} />);
259
+
260
+ fireEvent.mouseDown(document.body);
261
+
262
+ await waitFor(() => {
263
+ expect(onToolbarCloseRequest).toHaveBeenCalledWith(
264
+ [updatedNode, 5],
265
+ expect.anything(),
266
+ expect.any(Function),
267
+ expect.any(Function),
268
+ );
269
+ });
270
+ });
271
+
272
+ it('respects hold state for the current node after the node prop changes', async () => {
273
+ const updatedNode = {
274
+ attrs: { index: '1', value: 'Updated Option', error: false },
275
+ nodeSize: 1,
276
+ };
277
+
278
+ mockEditor._holdInlineDropdownToolbarIndex = '1';
279
+
280
+ const { queryByTestId, rerender } = render(<InlineDropdown {...defaultProps} selected={true} />);
281
+
282
+ await waitFor(() => {
283
+ expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument();
284
+ });
285
+
286
+ rerender(<InlineDropdown {...defaultProps} node={updatedNode} selected={true} />);
287
+
288
+ fireEvent.mouseDown(document.body);
289
+
290
+ await waitFor(() => {
291
+ expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument();
292
+ });
293
+ });
294
+
295
+ it('rebinds the outside-click listener when the node prop changes while the toolbar is open', async () => {
296
+ const addSpy = jest.spyOn(document, 'addEventListener');
297
+ const removeSpy = jest.spyOn(document, 'removeEventListener');
298
+ const updatedNode = { ...mockNode, attrs: { ...mockNode.attrs, value: 'Updated Option' } };
299
+
300
+ const { rerender } = render(<InlineDropdown {...defaultProps} selected={true} />);
301
+
302
+ await waitFor(() => {
303
+ expect(addSpy).toHaveBeenCalledWith('mousedown', expect.any(Function));
304
+ });
305
+
306
+ const removeCallsBeforeRerender = removeSpy.mock.calls.filter(([type]) => type === 'mousedown').length;
307
+
308
+ rerender(<InlineDropdown {...defaultProps} node={updatedNode} selected={true} />);
309
+
310
+ await waitFor(() => {
311
+ const removeCallsAfterRerender = removeSpy.mock.calls.filter(([type]) => type === 'mousedown').length;
312
+ expect(removeCallsAfterRerender).toBeGreaterThan(removeCallsBeforeRerender);
313
+ expect(addSpy).toHaveBeenCalledWith('mousedown', expect.any(Function));
314
+ });
315
+
316
+ addSpy.mockRestore();
317
+ removeSpy.mockRestore();
318
+ });
319
+
174
320
  it('has correct border styling', () => {
175
321
  const { container } = render(<InlineDropdown {...defaultProps} />);
176
322
  const dropdownDiv = container.querySelector('div[style*="border"]');
@@ -200,14 +346,29 @@ describe('InlineDropdown', () => {
200
346
  expect(textContainer).toHaveStyle({ textOverflow: 'ellipsis', whiteSpace: 'nowrap' });
201
347
  });
202
348
 
203
- it('renders toolbar with z-index', async () => {
204
- const { container } = render(<InlineDropdown {...defaultProps} selected={true} />);
349
+ it('portals toolbar into editor container when _tiptapContainerEl is set', async () => {
350
+ const containerEl = document.createElement('div');
351
+ const editor = buildMockEditor({ _tiptapContainerEl: containerEl });
352
+
353
+ render(<InlineDropdown {...defaultProps} editor={editor} selected={true} />);
354
+
355
+ await waitFor(() => {
356
+ expect(mockCreatePortal).toHaveBeenCalled();
357
+ });
358
+
359
+ expect(mockCreatePortal.mock.calls[0][1]).toBe(containerEl);
360
+ });
361
+
362
+ it('portals toolbar into document.body when _tiptapContainerEl is missing', async () => {
363
+ const editor = buildMockEditor({ _tiptapContainerEl: undefined });
364
+
365
+ render(<InlineDropdown {...defaultProps} editor={editor} selected={true} />);
366
+
205
367
  await waitFor(() => {
206
- const toolbarContainer = container.querySelector('div[style*="zIndex"]');
207
- if (toolbarContainer) {
208
- expect(toolbarContainer).toHaveStyle({ zIndex: '1' });
209
- }
368
+ expect(mockCreatePortal).toHaveBeenCalled();
210
369
  });
370
+
371
+ expect(mockCreatePortal.mock.calls[0][1]).toBe(document.body);
211
372
  });
212
373
 
213
374
  it('passes editorCallback to InlineDropdownToolbar', async () => {
@@ -356,6 +517,20 @@ describe('InlineDropdown', () => {
356
517
  expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument();
357
518
  });
358
519
 
520
+ it('does not close toolbar when clicking the page scrollbar (documentElement)', async () => {
521
+ const { queryByTestId } = render(<InlineDropdown {...defaultProps} selected={true} />);
522
+
523
+ await waitFor(() => {
524
+ expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument();
525
+ });
526
+
527
+ fireEvent.mouseDown(document.documentElement);
528
+
529
+ await waitFor(() => {
530
+ expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument();
531
+ });
532
+ });
533
+
359
534
  it('renders delete control on portaled custom toolbar when container el is set', async () => {
360
535
  const { findByLabelText } = render(<InlineDropdown {...defaultProps} selected />);
361
536
  expect(await findByLabelText('Delete')).toBeInTheDocument();
@@ -373,7 +548,9 @@ describe('InlineDropdown', () => {
373
548
  const { findByLabelText } = render(<InlineDropdown {...defaultProps} selected />);
374
549
  fireEvent.mouseDown(await findByLabelText('Delete'));
375
550
  expect(mockEditor.state.tr.delete).toHaveBeenCalledWith(5, 6);
376
- expect(mockEditor.view.dispatch).toHaveBeenCalledWith(mockEditor.state.tr);
551
+ expect(mockEditor.view.dispatch).toHaveBeenCalledTimes(2);
552
+ expect(mockEditor.view.dispatch).toHaveBeenNthCalledWith(1, mockEditor.state.tr);
553
+ expect(mockEditor.view.dispatch).toHaveBeenNthCalledWith(2, mockEditor.state.tr);
377
554
  expect(mockEditor._toolbarOpened).toBe(false);
378
555
  expect(mockEditor.commands.focus).toHaveBeenCalled();
379
556
  });
@@ -20,6 +20,7 @@ jest.mock('@tiptap/react', () => ({
20
20
  })),
21
21
  })),
22
22
  getAttributes: jest.fn(() => ({ border: '1' })),
23
+ commandManager: {},
23
24
  isFocused: true,
24
25
  state: {
25
26
  selection: {},
@@ -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
  },
@@ -3,6 +3,7 @@ import { NodeViewWrapper } from '@tiptap/react';
3
3
  import ReactDOM from 'react-dom';
4
4
  import PropTypes from 'prop-types';
5
5
  import CustomToolbarWrapper from '../../extensions/custom-toolbar-wrapper';
6
+ import { setToolbarOpened } from '../../utils/toolbar';
6
7
 
7
8
  const ExplicitConstructedResponse = (props) => {
8
9
  const { editor, node, getPos, options, selected } = props;
@@ -110,7 +111,7 @@ const ExplicitConstructedResponse = (props) => {
110
111
  tr.delete(pos, pos + node.nodeSize);
111
112
  // Prevent the debounced onBlur/onDone from firing into the
112
113
  // now-deleted node's stale position
113
- editor._toolbarOpened = false;
114
+ setToolbarOpened(editor, false);
114
115
  editor.view.dispatch(tr);
115
116
  setShowToolbar(false);
116
117
  editor.commands.focus();
@@ -1,23 +1,87 @@
1
1
  import React, { useEffect, useRef, useState } from 'react';
2
+ import ReactDOM from 'react-dom';
3
+ import { renderMath } from '@pie-lib/math-rendering';
2
4
  import PropTypes from 'prop-types';
3
5
  import { NodeViewWrapper } from '@tiptap/react';
6
+ import { NodeSelection } from 'prosemirror-state';
4
7
  import { Chevron } from '../icons/RespArea';
5
- import ReactDOM from 'react-dom';
6
8
  import CustomToolbarWrapper from '../../extensions/custom-toolbar-wrapper';
9
+ import { setToolbarOpened } from '../../utils/toolbar';
7
10
 
8
11
  const InlineDropdown = (props) => {
9
12
  const { editor, node, getPos, options, selected } = props;
10
13
  const { attrs: attributes } = node;
11
14
  const { value, error } = attributes;
12
- // TODO: Investigate
13
- // Needed because items with values inside have different positioning for some reason
14
15
  const html = value || '<div>&nbsp</div>';
15
16
  const pos = getPos();
16
17
  const toolbarRef = useRef(null);
17
18
  const toolbarEditor = useRef(null);
19
+ const pendingCloseRequest = useRef(false);
20
+ const elementRef = useRef(null);
21
+
22
+ const isHeld = () =>
23
+ editor._holdInlineDropdownToolbarIndex != null &&
24
+ String(editor._holdInlineDropdownToolbarIndex) === String(node.attrs.index);
25
+
18
26
  const [showToolbar, setShowToolbar] = useState(false);
19
27
  const [position, setPosition] = useState({ top: 0, left: 0 });
20
- const InlineDropdownToolbar = options.respAreaToolbar([node, pos], editor, () => {});
28
+
29
+ const closeToolbar = () => {
30
+ if (isHeld()) {
31
+ return;
32
+ }
33
+
34
+ setShowToolbar(false);
35
+ options.onInlineDropdownToolbarClose?.(editor);
36
+ };
37
+
38
+ const InlineDropdownToolbar = options.respAreaToolbar([node, pos], editor, closeToolbar);
39
+
40
+ const reselectNode = () => {
41
+ const { tr } = editor.state;
42
+ const nodeAtPos = tr.doc.nodeAt(pos);
43
+
44
+ if (!nodeAtPos) {
45
+ return;
46
+ }
47
+
48
+ const { selection } = tr;
49
+
50
+ if (selection.from === pos && selection.to === pos + nodeAtPos.nodeSize) {
51
+ return;
52
+ }
53
+
54
+ tr.setSelection(NodeSelection.create(tr.doc, pos));
55
+ editor.view.dispatch(tr);
56
+ };
57
+
58
+ const requestClose = () => {
59
+ if (pendingCloseRequest.current) {
60
+ return;
61
+ }
62
+
63
+ if (options.onToolbarCloseRequest) {
64
+ pendingCloseRequest.current = true;
65
+
66
+ options.onToolbarCloseRequest(
67
+ [node, pos],
68
+ editor,
69
+ () => {
70
+ pendingCloseRequest.current = false;
71
+ delete editor._holdInlineDropdownToolbarIndex;
72
+ closeToolbar();
73
+ },
74
+ () => {
75
+ pendingCloseRequest.current = false;
76
+ delete editor._holdInlineDropdownToolbarIndex;
77
+ setShowToolbar(true);
78
+ setTimeout(reselectNode, 0);
79
+ },
80
+ );
81
+ } else {
82
+ closeToolbar();
83
+ }
84
+ };
21
85
 
22
86
  useEffect(() => {
23
87
  const { selection } = editor.state;
@@ -25,13 +89,24 @@ const InlineDropdown = (props) => {
25
89
 
26
90
  if (selected) {
27
91
  if (onlyThisNodeSelected) {
28
- setShowToolbar(selected);
92
+ setShowToolbar(true);
29
93
  }
30
- } else {
31
- setShowToolbar(selected);
94
+ } else if (showToolbar) {
95
+ requestClose();
32
96
  }
33
97
  }, [editor, node, selected]);
34
98
 
99
+ const isScrollbarClicked = (event) =>
100
+ event.clientX > document.documentElement.clientWidth ||
101
+ event.clientY > document.documentElement.clientHeight ||
102
+ event.target === document.documentElement;
103
+
104
+ useEffect(() => {
105
+ if (elementRef.current && typeof renderMath === 'function') {
106
+ renderMath(elementRef.current);
107
+ }
108
+ }, [value]);
109
+
35
110
  useEffect(() => {
36
111
  // Calculate position relative to selection
37
112
  const bodyRect = document.body.getBoundingClientRect();
@@ -44,16 +119,21 @@ const InlineDropdown = (props) => {
44
119
  });
45
120
 
46
121
  const handleClickOutside = (event) => {
122
+ if (isScrollbarClicked(event)) {
123
+ return;
124
+ }
125
+
47
126
  const insideSomeEditor = event.target.closest('[data-toolbar-for]');
48
127
 
49
128
  if (
50
- (!insideSomeEditor || insideSomeEditor.dataset.toolbarFor !== toolbarEditor.current.instanceId) &&
129
+ !event.target.closest('[data-inline-dropdown-toolbar]') &&
130
+ (!insideSomeEditor || insideSomeEditor.dataset.toolbarFor !== toolbarEditor.current?.instanceId) &&
51
131
  !editor._toolbarOpened &&
52
132
  toolbarRef.current &&
53
133
  !toolbarRef.current.contains(event.target) &&
54
134
  !event.target.closest('[data-inline-node]')
55
135
  ) {
56
- setShowToolbar(false);
136
+ requestClose();
57
137
  }
58
138
  };
59
139
 
@@ -64,7 +144,7 @@ const InlineDropdown = (props) => {
64
144
  }
65
145
 
66
146
  return () => document.removeEventListener('mousedown', handleClickOutside);
67
- }, [showToolbar]);
147
+ }, [showToolbar, node]);
68
148
 
69
149
  return (
70
150
  <NodeViewWrapper
@@ -77,6 +157,7 @@ const InlineDropdown = (props) => {
77
157
  }}
78
158
  >
79
159
  <div
160
+ ref={elementRef}
80
161
  style={{
81
162
  display: 'inline-flex',
82
163
  minWidth: '178px',
@@ -105,31 +186,22 @@ const InlineDropdown = (props) => {
105
186
  display: 'inline-block',
106
187
  verticalAlign: 'middle',
107
188
  }}
108
- dangerouslySetInnerHTML={{
109
- __html: html,
110
- }}
189
+ dangerouslySetInnerHTML={{ __html: html }}
111
190
  />
112
191
  </div>
113
- <Chevron
114
- direction="down"
115
- style={{
116
- position: 'absolute',
117
- top: '5px',
118
- right: '5px',
119
- }}
120
- />
192
+ <Chevron direction="down" style={{ position: 'absolute', top: '5px', right: '5px' }} />
121
193
  </div>
122
194
  {showToolbar && (
123
195
  <React.Fragment>
124
196
  {ReactDOM.createPortal(
125
- <div ref={toolbarRef} style={{ zIndex: 1 }}>
197
+ <div ref={toolbarRef}>
126
198
  <InlineDropdownToolbar
127
199
  editorCallback={(instance) => {
128
200
  toolbarEditor.current = instance;
129
201
  }}
130
202
  />
131
203
  </div>,
132
- document.body,
204
+ editor?._tiptapContainerEl || document.body,
133
205
  )}
134
206
 
135
207
  {editor._tiptapContainerEl &&
@@ -144,7 +216,8 @@ const InlineDropdown = (props) => {
144
216
  tr.delete(pos, pos + node.nodeSize);
145
217
  // Prevent the debounced onBlur/onDone from firing into the
146
218
  // now-deleted node's stale position
147
- editor._toolbarOpened = false;
219
+ setToolbarOpened(editor, false);
220
+ delete editor._holdInlineDropdownToolbarIndex;
148
221
  editor.view.dispatch(tr);
149
222
  setShowToolbar(false);
150
223
  editor.commands.focus();
@@ -0,0 +1,79 @@
1
+ import { NodeSelection } from 'prosemirror-state';
2
+
3
+ export const HOLD_INLINE_DROPDOWN_TOOLBAR_INDEX = '_holdInlineDropdownToolbarIndex';
4
+
5
+ export const findInlineDropdownPos = (editor, index) => {
6
+ let foundPos = null;
7
+
8
+ editor.state.doc.descendants((n, p) => {
9
+ if (n.type?.name === 'inline_dropdown' && String(n.attrs?.index) === String(index)) {
10
+ foundPos = p;
11
+ return false;
12
+ }
13
+
14
+ return true;
15
+ });
16
+
17
+ return foundPos;
18
+ };
19
+
20
+ export const holdInlineDropdownToolbar = (editor, index) => {
21
+ editor[HOLD_INLINE_DROPDOWN_TOOLBAR_INDEX] = index;
22
+ };
23
+
24
+ export const releaseInlineDropdownToolbarHold = (editor) => {
25
+ delete editor[HOLD_INLINE_DROPDOWN_TOOLBAR_INDEX];
26
+ };
27
+
28
+ export const isInlineDropdownToolbarHeld = (editor, index) =>
29
+ editor[HOLD_INLINE_DROPDOWN_TOOLBAR_INDEX] != null &&
30
+ String(editor[HOLD_INLINE_DROPDOWN_TOOLBAR_INDEX]) === String(index);
31
+
32
+ export const selectInlineDropdownNode = (editor, index, fallbackPos) => {
33
+ const pos = findInlineDropdownPos(editor, index) ?? fallbackPos;
34
+
35
+ if (pos == null) {
36
+ return null;
37
+ }
38
+
39
+ const { tr } = editor.state;
40
+ const nodeAtPos = tr.doc.nodeAt(pos);
41
+
42
+ if (!nodeAtPos) {
43
+ return null;
44
+ }
45
+
46
+ const { selection } = tr;
47
+
48
+ if (selection.from === pos && selection.to === pos + nodeAtPos.nodeSize) {
49
+ return pos;
50
+ }
51
+
52
+ tr.setSelection(NodeSelection.create(tr.doc, pos));
53
+ editor.view.dispatch(tr);
54
+
55
+ return pos;
56
+ };
57
+
58
+ export const deleteInlineDropdownByIndex = (editor, index, fallbackPos) => {
59
+ const pos = findInlineDropdownPos(editor, index) ?? fallbackPos;
60
+
61
+ if (pos == null) {
62
+ releaseInlineDropdownToolbarHold(editor);
63
+ return false;
64
+ }
65
+
66
+ const { tr } = editor.state;
67
+ const nodeAtPos = tr.doc.nodeAt(pos);
68
+
69
+ if (!nodeAtPos) {
70
+ releaseInlineDropdownToolbarHold(editor);
71
+ return false;
72
+ }
73
+
74
+ tr.delete(pos, pos + nodeAtPos.nodeSize);
75
+ editor.view.dispatch(tr);
76
+ releaseInlineDropdownToolbarHold(editor);
77
+
78
+ return true;
79
+ };
@@ -39,6 +39,7 @@ describe('ImageComponent', () => {
39
39
 
40
40
  const createMockEditor = (selection = { from: 0, to: 1 }) => ({
41
41
  _tiptapContainerEl: document.body,
42
+ isEditable: true,
42
43
  commands: {
43
44
  updateAttributes: jest.fn(),
44
45
  focus: jest.fn(),