@pie-lib/editable-html-tip-tap 1.2.0-next.8 → 2.0.0

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 (98) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/lib/components/CharacterPicker.js +1 -0
  3. package/lib/components/CharacterPicker.js.map +1 -1
  4. package/lib/components/EditableHtml.js +84 -43
  5. package/lib/components/EditableHtml.js.map +1 -1
  6. package/lib/components/MenuBar.js +74 -43
  7. package/lib/components/MenuBar.js.map +1 -1
  8. package/lib/components/TiptapContainer.js +9 -8
  9. package/lib/components/TiptapContainer.js.map +1 -1
  10. package/lib/components/icons/TextAlign.js +2 -2
  11. package/lib/components/icons/TextAlign.js.map +1 -1
  12. package/lib/components/image/InsertImageHandler.js +10 -13
  13. package/lib/components/image/InsertImageHandler.js.map +1 -1
  14. package/lib/components/media/MediaDialog.js.map +1 -1
  15. package/lib/components/respArea/DragInTheBlank/DragInTheBlank.js +6 -1
  16. package/lib/components/respArea/DragInTheBlank/DragInTheBlank.js.map +1 -1
  17. package/lib/components/respArea/DragInTheBlank/choice.js +15 -7
  18. package/lib/components/respArea/DragInTheBlank/choice.js.map +1 -1
  19. package/lib/components/respArea/ExplicitConstructedResponse.js +29 -11
  20. package/lib/components/respArea/ExplicitConstructedResponse.js.map +1 -1
  21. package/lib/components/respArea/InlineDropdown.js +35 -6
  22. package/lib/components/respArea/InlineDropdown.js.map +1 -1
  23. package/lib/components/respArea/MathTemplated.js +130 -0
  24. package/lib/components/respArea/MathTemplated.js.map +1 -0
  25. package/lib/extensions/custom-toolbar-wrapper.js +3 -2
  26. package/lib/extensions/custom-toolbar-wrapper.js.map +1 -1
  27. package/lib/extensions/div-node.js +83 -0
  28. package/lib/extensions/div-node.js.map +1 -0
  29. package/lib/extensions/ensure-empty-root-div.js +48 -0
  30. package/lib/extensions/ensure-empty-root-div.js.map +1 -0
  31. package/lib/extensions/ensure-list-item-content-is-div.js +64 -0
  32. package/lib/extensions/ensure-list-item-content-is-div.js.map +1 -0
  33. package/lib/extensions/extended-list-item.js +15 -0
  34. package/lib/extensions/extended-list-item.js.map +1 -0
  35. package/lib/extensions/extended-table-cell.js +22 -0
  36. package/lib/extensions/extended-table-cell.js.map +1 -0
  37. package/lib/extensions/extended-table.js +50 -1
  38. package/lib/extensions/extended-table.js.map +1 -1
  39. package/lib/extensions/image-component.js +102 -51
  40. package/lib/extensions/image-component.js.map +1 -1
  41. package/lib/extensions/image.js +51 -2
  42. package/lib/extensions/image.js.map +1 -1
  43. package/lib/extensions/math.js +50 -9
  44. package/lib/extensions/math.js.map +1 -1
  45. package/lib/extensions/media.js +3 -1
  46. package/lib/extensions/media.js.map +1 -1
  47. package/lib/extensions/responseArea.js +22 -13
  48. package/lib/extensions/responseArea.js.map +1 -1
  49. package/lib/styles/editorContainerStyles.js +5 -4
  50. package/lib/styles/editorContainerStyles.js.map +1 -1
  51. package/lib/utils/helper.js +17 -0
  52. package/lib/utils/helper.js.map +1 -0
  53. package/package.json +8 -8
  54. package/src/__tests__/EditableHtml.test.jsx +93 -7
  55. package/src/__tests__/index.test.jsx +11 -3
  56. package/src/components/CharacterPicker.jsx +1 -0
  57. package/src/components/EditableHtml.jsx +93 -41
  58. package/src/components/MenuBar.jsx +57 -24
  59. package/src/components/TiptapContainer.jsx +10 -8
  60. package/src/components/__tests__/CharacterPicker.test.jsx +22 -0
  61. package/src/components/__tests__/ExplicitConstructedResponse.test.jsx +55 -12
  62. package/src/components/__tests__/InlineDropdown.test.jsx +203 -10
  63. package/src/components/__tests__/InsertImageHandler.test.js +28 -21
  64. package/src/components/__tests__/MenuBar.test.jsx +32 -0
  65. package/src/components/icons/TextAlign.jsx +1 -1
  66. package/src/components/image/InsertImageHandler.js +9 -13
  67. package/src/components/respArea/DragInTheBlank/DragInTheBlank.jsx +6 -1
  68. package/src/components/respArea/DragInTheBlank/choice.jsx +32 -4
  69. package/src/components/respArea/ExplicitConstructedResponse.jsx +33 -10
  70. package/src/components/respArea/InlineDropdown.jsx +45 -10
  71. package/src/components/respArea/MathTemplated.jsx +124 -0
  72. package/src/components/respArea/__tests__/MathTemplated.test.jsx +210 -0
  73. package/src/extensions/__tests__/divNode.test.js +87 -0
  74. package/src/extensions/__tests__/ensure-empty-root-div.test.js +57 -0
  75. package/src/extensions/__tests__/ensure-list-item-content-is-div.test.js +44 -0
  76. package/src/extensions/__tests__/extended-list-item.test.js +13 -0
  77. package/src/extensions/__tests__/extended-table-cell.test.js +22 -0
  78. package/src/extensions/__tests__/extended-table.test.js +98 -1
  79. package/src/extensions/__tests__/image-component.test.jsx +105 -9
  80. package/src/extensions/__tests__/image.test.js +109 -8
  81. package/src/extensions/__tests__/math.test.js +348 -0
  82. package/src/extensions/__tests__/media-node-view.test.jsx +10 -8
  83. package/src/extensions/__tests__/responseArea.test.js +291 -0
  84. package/src/extensions/custom-toolbar-wrapper.jsx +2 -2
  85. package/src/extensions/div-node.js +86 -0
  86. package/src/extensions/ensure-empty-root-div.js +47 -0
  87. package/src/extensions/ensure-list-item-content-is-div.js +62 -0
  88. package/src/extensions/extended-list-item.js +10 -0
  89. package/src/extensions/extended-table-cell.js +19 -0
  90. package/src/extensions/extended-table.js +37 -1
  91. package/src/extensions/image-component.jsx +114 -69
  92. package/src/extensions/image.js +56 -1
  93. package/src/extensions/math.js +62 -10
  94. package/src/extensions/media.js +1 -1
  95. package/src/extensions/responseArea.js +15 -12
  96. package/src/styles/editorContainerStyles.js +5 -4
  97. package/src/utils/helper.js +17 -0
  98. /package/src/components/media/{MediaDialog.js → MediaDialog.jsx} +0 -0
@@ -15,9 +15,11 @@ import Functions from '@mui/icons-material/Functions';
15
15
  import ImageIcon from '@mui/icons-material/Image';
16
16
  import Redo from '@mui/icons-material/Redo';
17
17
  import Undo from '@mui/icons-material/Undo';
18
+ import FormatQuote from '@mui/icons-material/FormatQuote';
18
19
  import TheatersIcon from '@mui/icons-material/Theaters';
19
20
  import VolumeUpIcon from '@mui/icons-material/VolumeUp';
20
21
  import BorderAll from '@mui/icons-material/BorderAll';
22
+ import Delete from '@mui/icons-material/Delete';
21
23
 
22
24
  import { useEditorState } from '@tiptap/react';
23
25
 
@@ -91,11 +93,15 @@ function MenuBar({
91
93
  const hideDefaultToolbar =
92
94
  ctx.editor?.isActive('math') ||
93
95
  ctx.editor?.isActive('explicit_constructed_response') ||
94
- ctx.editor?.isActive('imageUploadNode');
96
+ ctx.editor?.isActive('imageUploadNode') ||
97
+ ctx.editor?.isActive('drag_in_the_blank');
98
+
99
+ const hasTextSelectionInTable = selection && selection.empty === false && ctx.editor.isActive('table');
95
100
 
96
101
  return {
97
102
  currentNode,
98
103
  hideDefaultToolbar,
104
+ hasTextSelectionInTable,
99
105
  isFocused: ctx.editor?.isFocused,
100
106
  isBold: ctx.editor.isActive('bold') ?? false,
101
107
  canBold: ctx.editor.can().chain().toggleBold().run() ?? false,
@@ -136,7 +142,7 @@ function MenuBar({
136
142
  [classes.toolbarWithNoDone]: !hasDoneButton,
137
143
  [classes.toolbarTop]: toolbarOpts.position === 'top',
138
144
  [classes.toolbarRight]: toolbarOpts.alignment === 'right',
139
- [classes.focused]: toolbarOpts.alwaysVisible || (editorState.isFocused && !editor._toolbarOpened),
145
+ [classes.focused]: toolbarOpts.alwaysVisible || (editorState.isFocused && !editor._toolbarOpened && !editorState.hideDefaultToolbar),
140
146
  [classes.autoWidth]: autoWidth,
141
147
  [classes.fullWidth]: !autoWidth,
142
148
  [classes.hidden]: toolbarOpts.isHidden === true,
@@ -160,35 +166,35 @@ function MenuBar({
160
166
  {
161
167
  icon: <AddRow />,
162
168
  onClick: (editor) => editor.chain().focus().addRowAfter().run(),
163
- hidden: (state) => !state.isTable,
169
+ hidden: (state) => !(state.isTable && !state.hasTextSelectionInTable),
164
170
  isActive: (state) => state.isTable,
165
171
  isDisabled: (state) => !state.canTable,
166
172
  },
167
173
  {
168
174
  icon: <RemoveRow />,
169
175
  onClick: (editor) => editor.chain().focus().deleteRow().run(),
170
- hidden: (state) => !state.isTable,
176
+ hidden: (state) => !(state.isTable && !state.hasTextSelectionInTable),
171
177
  isActive: (state) => state.isTable,
172
178
  isDisabled: (state) => !state.canTable,
173
179
  },
174
180
  {
175
181
  icon: <AddColumn />,
176
182
  onClick: (editor) => editor.chain().focus().addColumnAfter().run(),
177
- hidden: (state) => !state.isTable,
183
+ hidden: (state) => !(state.isTable && !state.hasTextSelectionInTable),
178
184
  isActive: (state) => state.isTable,
179
185
  isDisabled: (state) => !state.canTable,
180
186
  },
181
187
  {
182
188
  icon: <RemoveColumn />,
183
189
  onClick: (editor) => editor.chain().focus().deleteColumn().run(),
184
- hidden: (state) => !state.isTable,
190
+ hidden: (state) => !(state.isTable && !state.hasTextSelectionInTable),
185
191
  isActive: (state) => state.isTable,
186
192
  isDisabled: (state) => !state.canTable,
187
193
  },
188
194
  {
189
195
  icon: <RemoveTable />,
190
196
  onClick: (editor) => editor.chain().focus().deleteTable().run(),
191
- hidden: (state) => !state.isTable,
197
+ hidden: (state) => !(state.isTable && !state.hasTextSelectionInTable),
192
198
  isActive: (state) => state.isTable,
193
199
  isDisabled: (state) => !state.canTable,
194
200
  },
@@ -204,54 +210,54 @@ function MenuBar({
204
210
 
205
211
  editor.commands.updateAttributes('table', update);
206
212
  },
207
- hidden: (state) => !state.isTable,
213
+ hidden: (state) => !(state.isTable && !state.hasTextSelectionInTable),
208
214
  isActive: (state) => state.tableHasBorder,
209
215
  isDisabled: (state) => !state.canTable,
210
216
  },
211
217
  {
212
218
  icon: <Bold />,
213
219
  onClick: (editor) => editor.chain().focus().toggleBold().run(),
214
- hidden: (state) => !activePlugins?.includes('bold') || state.isTable,
220
+ hidden: () => !activePlugins?.includes('bold'),
215
221
  isActive: (state) => state.isBold,
216
222
  isDisabled: (state) => !state.canBold,
217
223
  },
218
224
  {
219
225
  icon: <Italic />,
220
226
  onClick: (editor) => editor.chain().focus().toggleItalic().run(),
221
- hidden: (state) => !activePlugins?.includes('italic') || state.isTable,
227
+ hidden: () => !activePlugins?.includes('italic'),
222
228
  isActive: (state) => state.isItalic,
223
229
  isDisabled: (state) => !state.canItalic,
224
230
  },
225
231
  {
226
232
  icon: <Strikethrough />,
227
233
  onClick: (editor) => editor.chain().focus().toggleStrike().run(),
228
- hidden: (state) => !activePlugins?.includes('strikethrough') || state.isTable,
234
+ hidden: () => !activePlugins?.includes('strikethrough'),
229
235
  isActive: (state) => state.isStrike,
230
236
  isDisabled: (state) => !state.canStrike,
231
237
  },
232
238
  {
233
239
  icon: <Code />,
234
240
  onClick: (editor) => editor.chain().focus().toggleCode().run(),
235
- hidden: (state) => !activePlugins?.includes('code') || state.isTable,
241
+ hidden: () => !activePlugins?.includes('code'),
236
242
  isActive: (state) => state.isCode,
237
243
  isDisabled: (state) => !state.canCode,
238
244
  },
239
245
  {
240
246
  icon: <Underline />,
241
247
  onClick: (editor) => editor.chain().focus().toggleUnderline().run(),
242
- hidden: (state) => !activePlugins?.includes('underline') || state.isTable,
248
+ hidden: () => !activePlugins?.includes('underline'),
243
249
  isActive: (state) => state.isUnderline,
244
250
  },
245
251
  {
246
252
  icon: <SubscriptIcon />,
247
253
  onClick: (editor) => editor.chain().focus().toggleSubscript().run(),
248
- hidden: (state) => !activePlugins?.includes('subscript') || state.isTable,
254
+ hidden: () => !activePlugins?.includes('subscript'),
249
255
  isActive: (state) => state.isSubScript,
250
256
  },
251
257
  {
252
258
  icon: <SuperscriptIcon />,
253
259
  onClick: (editor) => editor.chain().focus().toggleSuperscript().run(),
254
- hidden: (state) => !activePlugins?.includes('superscript') || state.isTable,
260
+ hidden: () => !activePlugins?.includes('superscript'),
255
261
  isActive: (state) => state.isSuperScript,
256
262
  },
257
263
  {
@@ -261,22 +267,28 @@ function MenuBar({
261
267
  },
262
268
  {
263
269
  icon: <TheatersIcon />,
264
- hidden: (state) => !activePlugins?.includes('video') || state.isTable,
270
+ hidden: () => !activePlugins?.includes('video'),
265
271
  onClick: (editor) => editor.chain().focus().insertMedia({ type: 'video' }).run(),
266
272
  },
267
273
  {
268
274
  icon: <VolumeUpIcon />,
269
- hidden: (state) => !activePlugins?.includes('audio') || state.isTable,
275
+ hidden: () => !activePlugins?.includes('audio'),
270
276
  onClick: (editor) => editor.chain().focus().insertMedia({ type: 'audio', tag: 'audio' }).run(),
271
277
  },
272
278
  {
273
279
  icon: <CSSIcon />,
274
- hidden: (state) => !activePlugins?.includes('css') || state.isTable,
280
+ hidden: () => !activePlugins?.includes('css'),
275
281
  onClick: (editor) => editor.commands.openCSSClassDialog(),
276
282
  },
283
+ {
284
+ icon: <FormatQuote />,
285
+ hidden: () => !activePlugins?.includes('blockquote'),
286
+ onClick: (editor) => editor.chain().focus().toggleBlockquote().run(),
287
+ isActive: (state) => state.isBlockquote,
288
+ },
277
289
  {
278
290
  icon: <HeadingIcon />,
279
- hidden: (state) => !activePlugins?.includes('h3') || state.isTable,
291
+ hidden: () => !activePlugins?.includes('h3'),
280
292
  onClick: (editor) => editor.chain().focus().toggleHeading({ level: 3 }).run(),
281
293
  isActive: (state) => state.isHeading3,
282
294
  },
@@ -297,30 +309,30 @@ function MenuBar({
297
309
  },
298
310
  {
299
311
  icon: <TextAlignIcon editor={editor} />,
300
- hidden: (state) => !activePlugins?.includes('text-align') || state.isTable,
312
+ hidden: () => !activePlugins?.includes('text-align'),
301
313
  onClick: () => {},
302
314
  },
303
315
  {
304
316
  icon: <BulletedListIcon />,
305
- hidden: (state) => !activePlugins?.includes('bulleted-list') || state.isTable,
317
+ hidden: () => !activePlugins?.includes('bulleted-list'),
306
318
  onClick: (editor) => editor.chain().focus().toggleBulletList().run(),
307
319
  isActive: (state) => state.isBulletList,
308
320
  },
309
321
  {
310
322
  icon: <NumberedListIcon />,
311
- hidden: (state) => !activePlugins?.includes('numbered-list') || state.isTable,
323
+ hidden: () => !activePlugins?.includes('numbered-list'),
312
324
  onClick: (editor) => editor.chain().focus().toggleOrderedList().run(),
313
325
  isActive: (state) => state.isOrderedList,
314
326
  },
315
327
  {
316
328
  icon: <Undo />,
317
- hidden: (state) => !activePlugins?.includes('undo') || state.isTable,
329
+ hidden: () => !activePlugins?.includes('undo'),
318
330
  onClick: (editor) => editor.chain().focus().undo().run(),
319
331
  isDisabled: (state) => !state.canUndo,
320
332
  },
321
333
  {
322
334
  icon: <Redo />,
323
- hidden: (state) => !activePlugins?.includes('redo') || state.isTable,
335
+ hidden: () => !activePlugins?.includes('redo'),
324
336
  onClick: (editor) => editor.chain().focus().redo().run(),
325
337
  isDisabled: (state) => !state.canRedo,
326
338
  },
@@ -328,8 +340,29 @@ function MenuBar({
328
340
  [activePlugins, editor],
329
341
  );
330
342
 
343
+ const isDragInTheBlankSelected =
344
+ editorState.hideDefaultToolbar && editorState.currentNode?.type?.name === 'drag_in_the_blank';
345
+
331
346
  return (
332
347
  <div className={names} style={{ ...customStyles }} onMouseDown={handleMouseDown}>
348
+ {isDragInTheBlankSelected && (
349
+ <div className={classes.defaultToolbar} tabIndex="1">
350
+ <div className={classes.buttonsContainer}>
351
+ <button
352
+ type="button"
353
+ className={classes.button}
354
+ onClick={(e) => {
355
+ e.preventDefault();
356
+ editor.chain().focus().deleteSelection().run();
357
+ onChange?.(editor.getHTML());
358
+ }}
359
+ aria-label="Delete response area"
360
+ >
361
+ <Delete />
362
+ </button>
363
+ </div>
364
+ </div>
365
+ )}
333
366
  {!editorState.hideDefaultToolbar && (
334
367
  <div className={classes.defaultToolbar} tabIndex="1">
335
368
  <div className={classes.buttonsContainer}>
@@ -73,14 +73,10 @@ const StyledRoot = styled('div', {
73
73
  },
74
74
  },
75
75
  '& blockquote': {
76
- borderLeft: '3px solid var(--gray-3)',
77
- margin: '1.5rem 0',
78
- paddingLeft: '1rem',
79
- },
80
- '& hr': {
81
- border: 'none',
82
- borderTop: '1px solid var(--gray-2)',
83
- margin: '2rem 0',
76
+ background: '#f9f9f9',
77
+ borderLeft: '5px solid #ccc',
78
+ margin: '1.5em 10px',
79
+ padding: '.5em 10px',
84
80
  },
85
81
  '& p': {
86
82
  margin: '0',
@@ -152,6 +148,12 @@ function TiptapContainer(props) {
152
148
  ref,
153
149
  } = props;
154
150
 
151
+ useEffect(() => {
152
+ if (editor && rootRef.current) {
153
+ editor._tiptapContainerEl = rootRef.current;
154
+ }
155
+ }, [editor, rootRef.current]);
156
+
155
157
  useEffect(() => {
156
158
  if (editor && autoFocus) {
157
159
  Promise.resolve().then(() => {
@@ -194,4 +194,26 @@ describe('CharacterPicker', () => {
194
194
  const dialog = container.querySelector('.insert-character-dialog');
195
195
  expect(dialog).toHaveStyle({ position: 'absolute' });
196
196
  });
197
+
198
+ it('adds data-toolbar-for attribute with editor instanceId', () => {
199
+ const editorWithInstanceId = {
200
+ ...mockEditor,
201
+ instanceId: 'editor-123',
202
+ };
203
+ const opts = {
204
+ characters: [['á', 'é']],
205
+ };
206
+ const { container } = render(<CharacterPicker editor={editorWithInstanceId} opts={opts} onClose={jest.fn()} />);
207
+ const dialog = container.querySelector('.insert-character-dialog');
208
+ expect(dialog).toHaveAttribute('data-toolbar-for', 'editor-123');
209
+ });
210
+
211
+ it('renders without instanceId gracefully', () => {
212
+ const opts = {
213
+ characters: [['á', 'é']],
214
+ };
215
+ const { container } = render(<CharacterPicker editor={mockEditor} opts={opts} onClose={jest.fn()} />);
216
+ const dialog = container.querySelector('.insert-character-dialog');
217
+ expect(dialog).toBeInTheDocument();
218
+ });
197
219
  });
@@ -10,19 +10,37 @@ jest.mock('@tiptap/react', () => ({
10
10
  ),
11
11
  }));
12
12
 
13
+ jest.mock('react-dom', () => ({
14
+ ...jest.requireActual('react-dom'),
15
+ createPortal: (node) => node,
16
+ }));
17
+
13
18
  describe('ExplicitConstructedResponse', () => {
14
- const mockEditor = {
15
- state: {
16
- selection: {
17
- from: 0,
18
- to: 1,
19
+ const buildMockEditor = (overrides = {}) => {
20
+ const mockTr = {
21
+ delete: jest.fn(),
22
+ };
23
+ return {
24
+ state: {
25
+ selection: {
26
+ from: 0,
27
+ to: 1,
28
+ },
29
+ tr: mockTr,
19
30
  },
20
- },
21
- commands: {
22
- focus: jest.fn(),
23
- },
31
+ view: {
32
+ dispatch: jest.fn(),
33
+ },
34
+ commands: {
35
+ focus: jest.fn(),
36
+ },
37
+ _tiptapContainerEl: document.createElement('div'),
38
+ ...overrides,
39
+ };
24
40
  };
25
41
 
42
+ let mockEditor;
43
+
26
44
  const mockNode = {
27
45
  attrs: {
28
46
  index: '0',
@@ -46,6 +64,8 @@ describe('ExplicitConstructedResponse', () => {
46
64
 
47
65
  beforeEach(() => {
48
66
  jest.clearAllMocks();
67
+ mockEditor = buildMockEditor();
68
+ defaultProps.editor = mockEditor;
49
69
  });
50
70
 
51
71
  it('renders without crashing', () => {
@@ -97,8 +117,7 @@ describe('ExplicitConstructedResponse', () => {
97
117
  const clickableDiv = container.querySelector('div[style*="border"]');
98
118
  fireEvent.click(clickableDiv);
99
119
  await waitFor(() => {
100
- // After click, toolbar should be shown
101
- expect(container).toBeInTheDocument();
120
+ expect(queryByTestId('toolbar')).toBeInTheDocument();
102
121
  });
103
122
  });
104
123
 
@@ -124,7 +143,7 @@ describe('ExplicitConstructedResponse', () => {
124
143
 
125
144
  it('calls respAreaToolbar with correct params', () => {
126
145
  render(<ExplicitConstructedResponse {...defaultProps} />);
127
- expect(mockOptions.respAreaToolbar).toHaveBeenCalledWith(mockNode, mockEditor, expect.any(Function));
146
+ expect(mockOptions.respAreaToolbar).toHaveBeenCalledWith([mockNode, 5], mockEditor, expect.any(Function));
128
147
  });
129
148
 
130
149
  it('has cursor pointer style', () => {
@@ -158,4 +177,28 @@ describe('ExplicitConstructedResponse', () => {
158
177
  const contentDiv = container.querySelector('div[style*="padding"]');
159
178
  expect(contentDiv).toHaveStyle({ minWidth: '178px' });
160
179
  });
180
+
181
+ it('renders delete control on portaled custom toolbar when container el is set', async () => {
182
+ const { findByLabelText } = render(<ExplicitConstructedResponse {...defaultProps} selected />);
183
+ expect(await findByLabelText('Delete')).toBeInTheDocument();
184
+ });
185
+
186
+ it('does not render portaled delete control when _tiptapContainerEl is missing', async () => {
187
+ const editor = buildMockEditor({ _tiptapContainerEl: undefined });
188
+ const { queryByLabelText, findByTestId } = render(
189
+ <ExplicitConstructedResponse {...defaultProps} editor={editor} selected />,
190
+ );
191
+ expect(await findByTestId('toolbar')).toBeInTheDocument();
192
+ expect(queryByLabelText('Delete')).not.toBeInTheDocument();
193
+ });
194
+
195
+ it('delete clears toolbar flag, removes node range, dispatches, and focuses', async () => {
196
+ mockEditor._toolbarOpened = true;
197
+ const { findByLabelText } = render(<ExplicitConstructedResponse {...defaultProps} selected />);
198
+ fireEvent.mouseDown(await findByLabelText('Delete'));
199
+ expect(mockEditor.state.tr.delete).toHaveBeenCalledWith(5, 6);
200
+ expect(mockEditor.view.dispatch).toHaveBeenCalledWith(mockEditor.state.tr);
201
+ expect(mockEditor._toolbarOpened).toBe(false);
202
+ expect(mockEditor.commands.focus).toHaveBeenCalled();
203
+ });
161
204
  });
@@ -16,18 +16,33 @@ jest.mock('react-dom', () => ({
16
16
  }));
17
17
 
18
18
  describe('InlineDropdown', () => {
19
- const mockEditor = {
20
- state: {
21
- selection: {
22
- from: 0,
23
- to: 1,
19
+ const buildMockEditor = (overrides = {}) => {
20
+ const mockTr = {
21
+ delete: jest.fn(),
22
+ };
23
+ return {
24
+ state: {
25
+ selection: {
26
+ from: 0,
27
+ to: 1,
28
+ },
29
+ tr: mockTr,
24
30
  },
25
- },
26
- view: {
27
- coordsAtPos: jest.fn(() => ({ top: 100, left: 50 })),
28
- },
31
+ view: {
32
+ coordsAtPos: jest.fn(() => ({ top: 100, left: 50 })),
33
+ dispatch: jest.fn(),
34
+ },
35
+ commands: {
36
+ focus: jest.fn(),
37
+ },
38
+ _tiptapContainerEl: document.createElement('div'),
39
+ _toolbarOpened: false,
40
+ ...overrides,
41
+ };
29
42
  };
30
43
 
44
+ let mockEditor;
45
+
31
46
  const mockNode = {
32
47
  attrs: {
33
48
  index: '0',
@@ -51,6 +66,8 @@ describe('InlineDropdown', () => {
51
66
 
52
67
  beforeEach(() => {
53
68
  jest.clearAllMocks();
69
+ mockEditor = buildMockEditor();
70
+ defaultProps.editor = mockEditor;
54
71
  Object.defineProperty(document.body, 'getBoundingClientRect', {
55
72
  value: jest.fn(() => ({ top: 0, left: 0 })),
56
73
  configurable: true,
@@ -73,6 +90,14 @@ describe('InlineDropdown', () => {
73
90
  expect(valueDiv).toBeInTheDocument();
74
91
  });
75
92
 
93
+ it('uses 2px horizontal margin on the value control and no horizontal margin on the wrapper', () => {
94
+ const { container, getByTestId } = render(<InlineDropdown {...defaultProps} />);
95
+ const valueDiv = container.querySelector('div[style*="border"]');
96
+ expect(valueDiv).toHaveStyle({ margin: '0 2px' });
97
+ const wrapper = getByTestId('node-view-wrapper');
98
+ expect(wrapper.getAttribute('style') || '').not.toMatch(/margin/);
99
+ });
100
+
76
101
  it('renders chevron icon', () => {
77
102
  const { container } = render(<InlineDropdown {...defaultProps} />);
78
103
  const chevron = container.querySelector('svg');
@@ -132,7 +157,7 @@ describe('InlineDropdown', () => {
132
157
 
133
158
  it('calls respAreaToolbar with correct params', () => {
134
159
  render(<InlineDropdown {...defaultProps} />);
135
- expect(mockOptions.respAreaToolbar).toHaveBeenCalledWith(mockNode, mockEditor, expect.any(Function));
160
+ expect(mockOptions.respAreaToolbar).toHaveBeenCalledWith([mockNode, 5], mockEditor, expect.any(Function));
136
161
  });
137
162
 
138
163
  it('closes toolbar on outside click', async () => {
@@ -184,4 +209,172 @@ describe('InlineDropdown', () => {
184
209
  }
185
210
  });
186
211
  });
212
+
213
+ it('passes editorCallback to InlineDropdownToolbar', async () => {
214
+ const mockToolbarComponent = jest.fn(({ editorCallback }) => {
215
+ editorCallback?.({ instanceId: 'test-instance' });
216
+ return <div data-testid="inline-dropdown-toolbar">Toolbar</div>;
217
+ });
218
+
219
+ const mockOptionsWithCallback = {
220
+ respAreaToolbar: jest.fn(() => mockToolbarComponent),
221
+ };
222
+
223
+ const { queryByTestId } = render(
224
+ <InlineDropdown {...defaultProps} options={mockOptionsWithCallback} selected={true} />,
225
+ );
226
+
227
+ await waitFor(() => {
228
+ expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument();
229
+ });
230
+ });
231
+
232
+ it('stores toolbar editor instance in ref when editorCallback is called', async () => {
233
+ let capturedCallback;
234
+ const mockToolbarComponent = ({ editorCallback }) => {
235
+ capturedCallback = editorCallback;
236
+ return <div data-testid="inline-dropdown-toolbar">Toolbar</div>;
237
+ };
238
+
239
+ const mockOptionsWithCallback = {
240
+ respAreaToolbar: jest.fn(() => mockToolbarComponent),
241
+ };
242
+
243
+ const { queryByTestId } = render(
244
+ <InlineDropdown {...defaultProps} options={mockOptionsWithCallback} selected={true} />,
245
+ );
246
+
247
+ await waitFor(() => {
248
+ expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument();
249
+ });
250
+
251
+ // Verify callback exists
252
+ expect(capturedCallback).toBeDefined();
253
+ });
254
+
255
+ it('handles click outside logic with data-toolbar-for attribute', async () => {
256
+ const editorWithInstanceId = {
257
+ ...mockEditor,
258
+ instanceId: 'editor-123',
259
+ _toolbarOpened: false,
260
+ };
261
+
262
+ // Mock the toolbar callback to set the toolbar editor instance
263
+ let capturedCallback;
264
+ const mockToolbarComponent = ({ editorCallback }) => {
265
+ React.useEffect(() => {
266
+ capturedCallback = editorCallback;
267
+ if (editorCallback) {
268
+ editorCallback({ instanceId: 'editor-123' });
269
+ }
270
+ }, [editorCallback]);
271
+ return <div data-testid="inline-dropdown-toolbar">Toolbar</div>;
272
+ };
273
+
274
+ const mockOptionsWithCallback = {
275
+ respAreaToolbar: jest.fn(() => mockToolbarComponent),
276
+ };
277
+
278
+ const { container, queryByTestId } = render(
279
+ <InlineDropdown
280
+ {...defaultProps}
281
+ editor={editorWithInstanceId}
282
+ options={mockOptionsWithCallback}
283
+ selected={true}
284
+ />,
285
+ );
286
+
287
+ await waitFor(() => {
288
+ expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument();
289
+ });
290
+
291
+ // Create an element with data-toolbar-for attribute
292
+ const otherToolbar = document.createElement('div');
293
+ otherToolbar.setAttribute('data-toolbar-for', 'editor-456');
294
+ document.body.appendChild(otherToolbar);
295
+
296
+ await waitFor(() => {
297
+ fireEvent.mouseDown(otherToolbar);
298
+ });
299
+
300
+ // Cleanup
301
+ document.body.removeChild(otherToolbar);
302
+ expect(container).toBeInTheDocument();
303
+ });
304
+
305
+ it('does not close when clicking inside same editor toolbar', async () => {
306
+ const editorWithInstanceId = {
307
+ ...mockEditor,
308
+ instanceId: 'editor-123',
309
+ _toolbarOpened: false,
310
+ };
311
+
312
+ let toolbarEditorInstance;
313
+ const mockToolbarComponent = ({ editorCallback }) => {
314
+ React.useEffect(() => {
315
+ if (editorCallback) {
316
+ editorCallback({ instanceId: 'editor-123' });
317
+ toolbarEditorInstance = { instanceId: 'editor-123' };
318
+ }
319
+ }, [editorCallback]);
320
+ return <div data-testid="inline-dropdown-toolbar">Toolbar</div>;
321
+ };
322
+
323
+ const mockOptionsWithCallback = {
324
+ respAreaToolbar: jest.fn(() => mockToolbarComponent),
325
+ };
326
+
327
+ render(
328
+ <InlineDropdown
329
+ {...defaultProps}
330
+ editor={editorWithInstanceId}
331
+ options={mockOptionsWithCallback}
332
+ selected={true}
333
+ />,
334
+ );
335
+
336
+ await waitFor(() => {
337
+ expect(mockOptionsWithCallback.respAreaToolbar).toHaveBeenCalled();
338
+ });
339
+ });
340
+
341
+ it('checks editor._toolbarOpened in click outside handler', async () => {
342
+ const editorWithToolbarOpened = buildMockEditor({ _toolbarOpened: true });
343
+
344
+ const { queryByTestId } = render(
345
+ <InlineDropdown {...defaultProps} editor={editorWithToolbarOpened} selected={true} />,
346
+ );
347
+
348
+ await waitFor(() => {
349
+ expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument();
350
+ });
351
+
352
+ // When _toolbarOpened is true, clicking outside should not close
353
+ fireEvent.mouseDown(document.body);
354
+
355
+ // Toolbar should still be visible
356
+ expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument();
357
+ });
358
+
359
+ it('renders delete control on portaled custom toolbar when container el is set', async () => {
360
+ const { findByLabelText } = render(<InlineDropdown {...defaultProps} selected />);
361
+ expect(await findByLabelText('Delete')).toBeInTheDocument();
362
+ });
363
+
364
+ it('does not render portaled delete control when _tiptapContainerEl is missing', async () => {
365
+ const editor = buildMockEditor({ _tiptapContainerEl: undefined });
366
+ const { queryByLabelText, findByTestId } = render(<InlineDropdown {...defaultProps} editor={editor} selected />);
367
+ expect(await findByTestId('inline-dropdown-toolbar')).toBeInTheDocument();
368
+ expect(queryByLabelText('Delete')).not.toBeInTheDocument();
369
+ });
370
+
371
+ it('delete clears toolbar flag, removes node range, dispatches, and focuses', async () => {
372
+ mockEditor._toolbarOpened = true;
373
+ const { findByLabelText } = render(<InlineDropdown {...defaultProps} selected />);
374
+ fireEvent.mouseDown(await findByLabelText('Delete'));
375
+ expect(mockEditor.state.tr.delete).toHaveBeenCalledWith(5, 6);
376
+ expect(mockEditor.view.dispatch).toHaveBeenCalledWith(mockEditor.state.tr);
377
+ expect(mockEditor._toolbarOpened).toBe(false);
378
+ expect(mockEditor.commands.focus).toHaveBeenCalled();
379
+ });
187
380
  });