@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
@@ -264,16 +264,16 @@ describe('ResponseAreaExtension', () => {
264
264
  jest.resetModules();
265
265
  });
266
266
 
267
- const buildInsertCommand = () => {
267
+ const buildInsertCommand = (type = 'inline-dropdown') => {
268
268
  const { ResponseAreaExtension } = require('../responseArea');
269
269
  const context = {
270
270
  options: {
271
- type: 'inline-dropdown',
271
+ type,
272
272
  maxResponseAreas: 5,
273
273
  },
274
274
  };
275
275
  const commands = ResponseAreaExtension.addCommands.call(context);
276
- return commands.insertResponseArea('inline-dropdown');
276
+ return commands.insertResponseArea(type);
277
277
  };
278
278
 
279
279
  const createDoc = (existingCount, typeName = 'inline_dropdown') => ({
@@ -391,6 +391,60 @@ describe('ResponseAreaExtension', () => {
391
391
  expect(create).not.toHaveBeenCalled();
392
392
  expect(mockTr.insert).not.toHaveBeenCalled();
393
393
  });
394
+
395
+ describe('selection after insert', () => {
396
+ const buildStateAndTr = (typeName, { withResolve = false } = {}) => {
397
+ const mockInlineNode = { nodeSize: 1 };
398
+ const create = jest.fn(() => mockInlineNode);
399
+ const mockDoc = {
400
+ descendants: jest.fn(),
401
+ content: { size: 50 },
402
+ ...(withResolve ? { resolve: jest.fn((pos) => ({ resolved: pos })) } : {}),
403
+ };
404
+ const mockTr = {
405
+ insert: jest.fn(),
406
+ doc: mockDoc,
407
+ setSelection: jest.fn(),
408
+ };
409
+ const state = {
410
+ schema: { nodes: { [typeName]: { create } } },
411
+ doc: mockDoc,
412
+ selection: { from: 5 },
413
+ };
414
+
415
+ return { mockTr, state };
416
+ };
417
+
418
+ it.each(['inline-dropdown', 'explicit-constructed-response'])(
419
+ 'sets a NodeSelection on the inserted node for %s',
420
+ (type) => {
421
+ const insert = buildInsertCommand(type);
422
+ const typeName = type.replace(/-/g, '_');
423
+ const { mockTr, state } = buildStateAndTr(typeName);
424
+ const { NodeSelection, TextSelection } = require('prosemirror-state');
425
+
426
+ insert({ tr: mockTr, state, dispatch: jest.fn(), commands: { focus: jest.fn() } });
427
+
428
+ expect(NodeSelection.create).toHaveBeenCalledWith(mockTr.doc, 5);
429
+ expect(TextSelection.near).not.toHaveBeenCalled();
430
+ expect(mockTr.setSelection).toHaveBeenCalledWith({ type: 'node', pos: 5 });
431
+ },
432
+ );
433
+
434
+ it('sets a TextSelection just after the inserted node for math-templated (no NodeSelection)', () => {
435
+ const insert = buildInsertCommand('math-templated');
436
+ const { mockTr, state } = buildStateAndTr('math_templated', { withResolve: true });
437
+ const { NodeSelection, TextSelection } = require('prosemirror-state');
438
+
439
+ insert({ tr: mockTr, state, dispatch: jest.fn(), commands: { focus: jest.fn() } });
440
+
441
+ expect(NodeSelection.create).not.toHaveBeenCalled();
442
+ // usedPos (5) + nodeSize (1) === 6
443
+ expect(mockTr.doc.resolve).toHaveBeenCalledWith(6);
444
+ expect(TextSelection.near).toHaveBeenCalledWith({ resolved: 6 }, 1);
445
+ expect(mockTr.setSelection).toHaveBeenCalledWith({ type: 'text', pos: { resolved: 6 }, dir: 1 });
446
+ });
447
+ });
394
448
  });
395
449
  });
396
450
  });
@@ -169,14 +169,27 @@ export const CSSMark = Mark.create({
169
169
  },
170
170
 
171
171
  parseHTML() {
172
- // Any span with a class that matches one of allowed classes
172
+ // Any span with a class that matches one of allowed classes any span that carries a class attribute
173
+ // so that pre-existing spans are preserved when loading content
173
174
  return [
174
175
  {
175
176
  tag: 'span[class]',
176
177
  getAttrs: (el) => {
177
178
  const cls = el.getAttribute('class') || '';
178
- const match = this.options.classes.find((name) => cls.includes(name));
179
- return match ? { class: match } : false;
179
+
180
+ if (!cls) {
181
+ return false;
182
+ }
183
+
184
+ const allowedClasses = (this.options && this.options.classes) || [];
185
+
186
+ if (allowedClasses.length > 0) {
187
+ const match = this.options.classes.find((name) => cls.includes(name));
188
+
189
+ return match ? { class: match } : false;
190
+ }
191
+
192
+ return { class: cls };
180
193
  },
181
194
  },
182
195
  ];
@@ -125,7 +125,7 @@ function ImageComponent(props) {
125
125
  );
126
126
 
127
127
  const applySizeData = useCallback(() => {
128
- if (!node.attrs.width || !imgRef.current) return;
128
+ if (!node.attrs.width || !imgRef.current || !imgRef.current.naturalWidth) return;
129
129
  const resizePercent = getPercentFromWidth(node.attrs.width);
130
130
  if (node.attrs.resizePercent === resizePercent) return;
131
131
  updateThisNode({ resizePercent });
@@ -138,7 +138,7 @@ function ImageComponent(props) {
138
138
 
139
139
  useEffect(() => {
140
140
  if (selected) {
141
- if (onlyThisNodeSelected) {
141
+ if (onlyThisNodeSelected && editor.isEditable) {
142
142
  // Only open the upload UI for a fresh placeholder. Remounting after tab switch
143
143
  // would otherwise call insertImageRequested again and reopen the file modal.
144
144
  const hasImageSrc = String(node.attrs?.src ?? '').trim();
@@ -156,15 +156,17 @@ function ImageComponent(props) {
156
156
  } else {
157
157
  setShowToolbar(selected);
158
158
  }
159
- }, [onlyThisNodeSelected, selected]);
159
+ }, [onlyThisNodeSelected, selected, editor.isEditable]);
160
160
 
161
161
  useEffect(() => {
162
162
  applySizeData();
163
163
 
164
- const resizeHandle = resizeRef.current;
165
- if (resizeHandle) {
164
+ const resizeHandle = resizeRef?.current;
165
+
166
+ if (resizeHandle && editor.isEditable) {
166
167
  resizeHandle.addEventListener('mousedown', initResize, false);
167
168
  }
169
+
168
170
  return () => {
169
171
  if (resizeHandle) {
170
172
  resizeHandle.removeEventListener('mousedown', initResize, false);
@@ -272,7 +274,7 @@ function ImageComponent(props) {
272
274
  onLoad={loadImage}
273
275
  alt={node.attrs.alt}
274
276
  />
275
- <StyledResize ref={resizeRef} className="resize" />
277
+ <StyledResize ref={resizeRef} className="resize" style={{ display: editor.isEditable ? undefined : 'none' }} />
276
278
  </StyledImageContainer>
277
279
  </StyledRoot>
278
280
 
@@ -5,6 +5,7 @@ import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
5
5
  import { NodeSelection, Plugin, PluginKey, TextSelection } from 'prosemirror-state';
6
6
  import { MathPreview, MathToolbar } from '@pie-lib/math-toolbar';
7
7
  import { wrapMath } from '@pie-lib/math-rendering';
8
+ import { setToolbarOpened } from '../utils/toolbar';
8
9
 
9
10
  const ensureTextAfterMathPluginKey = new PluginKey('ensureTextAfterMath');
10
11
 
@@ -22,7 +23,9 @@ export const EnsureTextAfterMathPlugin = (mathNodeName) =>
22
23
  key: ensureTextAfterMathPluginKey,
23
24
  appendTransaction: (transactions, oldState, newState) => {
24
25
  // Only act when the doc actually changed
25
- if (!transactions.some((tr) => tr.docChanged)) return null;
26
+ if (!transactions.some((tr) => tr.docChanged)) {
27
+ return null;
28
+ }
26
29
 
27
30
  const tr = newState.tr;
28
31
  let changed = false;
@@ -44,6 +47,26 @@ export const EnsureTextAfterMathPlugin = (mathNodeName) =>
44
47
  },
45
48
  });
46
49
 
50
+ const nodeBeforeZeroWidthSpace = (doc, from) => {
51
+ let i;
52
+
53
+ // finding if previous to the cursor there's a zero-width space
54
+ // and a non-text element, and deleting everything until the space
55
+ for (i = from; i > 0; i--) {
56
+ const currentDoc = doc.nodeAt(i);
57
+
58
+ if (currentDoc?.type?.name === 'text' && currentDoc.textContent !== '\u200b') {
59
+ return -1;
60
+ }
61
+
62
+ if (currentDoc && currentDoc?.type?.name !== 'text') {
63
+ break;
64
+ }
65
+ }
66
+
67
+ return i;
68
+ };
69
+
47
70
  export const ZeroWidthSpaceHandlingPlugin = new Plugin({
48
71
  key: new PluginKey('zeroWidthSpaceHandling'),
49
72
  props: {
@@ -53,35 +76,38 @@ export const ZeroWidthSpaceHandlingPlugin = new Plugin({
53
76
  const { from, empty } = selection;
54
77
 
55
78
  if (empty && event.key === 'Backspace' && from > 0) {
56
- const prevChar = doc.textBetween(from - 1, from, '\uFFFC', '\uFFFC');
57
- if (prevChar === '\u200b') {
58
- const tr = state.tr.delete(from - 2, from);
59
- dispatch(tr);
60
- return true; // handled
79
+ const start = nodeBeforeZeroWidthSpace(doc, from);
80
+
81
+ if (start === -1) {
82
+ return false;
61
83
  }
84
+
85
+ const tr = state.tr.delete(start, from);
86
+ dispatch(tr);
87
+ return true; // handled
62
88
  }
63
89
 
64
90
  if (empty && event.key === 'ArrowLeft' && from > 0) {
65
- const prevChar = doc.textBetween(from - 1, from, '\uFFFC', '\uFFFC');
66
- // If the previous character is the zero-width space...
67
- if (prevChar === '\u200b') {
68
- const posBefore = from - 1;
69
- const resolved = state.doc.resolve(posBefore - 1); // look just before the zwsp
70
- const maybeNode = resolved.nodeAfter || resolved.nodeBefore;
71
-
72
- // Check if there's an inline selectable node (e.g., your math node)
73
- if (maybeNode) {
74
- const nodePos = posBefore - maybeNode.nodeSize;
75
- const nodeResolved = state.doc.resolve(nodePos);
76
- const tr = state.tr.setSelection(NodeSelection.create(state.doc, nodeResolved.pos));
77
- dispatch(tr);
78
- return true;
79
- } else {
80
- // Just move the text cursor before the zwsp
81
- const tr = state.tr.setSelection(TextSelection.create(state.doc, from - 2));
82
- dispatch(tr);
83
- return true;
84
- }
91
+ const start = nodeBeforeZeroWidthSpace(doc, from);
92
+
93
+ if (start === -1) {
94
+ return false;
95
+ }
96
+
97
+ const resolved = state.doc.resolve(start);
98
+ const maybeNode = resolved.nodeAfter || resolved.nodeBefore;
99
+
100
+ // Check if there's an inline selectable node (e.g., your math node)
101
+ if (maybeNode) {
102
+ const nodeResolved = state.doc.resolve(start);
103
+ const tr = state.tr.setSelection(NodeSelection.create(state.doc, nodeResolved.pos));
104
+ dispatch(tr);
105
+ return true;
106
+ } else {
107
+ // Just move the text cursor before the zwsp
108
+ const tr = state.tr.setSelection(TextSelection.create(state.doc, from - 2));
109
+ dispatch(tr);
110
+ return true;
85
111
  }
86
112
  }
87
113
 
@@ -151,14 +177,9 @@ export const MathNode = Node.create({
151
177
 
152
178
  dispatch(tr);
153
179
 
180
+ setToolbarOpened(editor, true);
154
181
  return true;
155
182
  },
156
- // insertMath: (latex = '') => ({ commands }) => {
157
- // return commands.insertContent({
158
- // type: this.name,
159
- // attrs: { latex },
160
- // });
161
- // },
162
183
  };
163
184
  },
164
185
 
@@ -180,9 +201,11 @@ export const MathNode = Node.create({
180
201
  });
181
202
 
182
203
  export const MathNodeView = (props) => {
183
- const { node, updateAttributes, editor, selected, options } = props;
204
+ const { node, updateAttributes, editor, selected, options, getPos } = props;
184
205
  const [showToolbar, setShowToolbar] = useState(selected);
185
206
  const toolbarRef = useRef(null);
207
+ const nodeRef = useRef(null);
208
+ const timestamp = useRef(Date.now());
186
209
  const [position, setPosition] = useState({ top: 0, left: 0 });
187
210
  const { math: mathOptions = {} } = options || {};
188
211
  const {
@@ -195,27 +218,132 @@ export const MathNodeView = (props) => {
195
218
 
196
219
  const latex = node.attrs.latex || '';
197
220
 
221
+ const handleChange = (newLatex) => {
222
+ updateAttributes({ latex: newLatex });
223
+ };
224
+
225
+ // moveCursorAfterNode is set explicitly by the caller (not inferred from
226
+ // editor.state.selection: every keystroke while editing already calls
227
+ // updateAttributes, which replaces this node's content and — per a
228
+ // ProseMirror mapping quirk — collapses any NodeSelection on it into a
229
+ // plain TextSelection right away, so the live selection can't be trusted
230
+ // to still describe this node by the time handleDone runs).
231
+ //
232
+ // - Check icon: always move the cursor to just after this node.
233
+ // - Clicking elsewhere in the editable content while the toolbar was
234
+ // open (see handleClickOutside): leave the cursor where the user
235
+ // actually clicked instead of overriding it.
236
+ const handleDone = (newLatex, { moveCursorAfterNode = true } = {}) => {
237
+ updateAttributes({ latex: newLatex });
238
+ setShowToolbar(false);
239
+
240
+ if (moveCursorAfterNode && typeof getPos === 'function') {
241
+ const pos = getPos();
242
+ const { doc } = editor.state;
243
+ const sel = TextSelection.create(doc, pos + node.nodeSize);
244
+ const tr = editor.state.tr.setSelection(sel);
245
+ editor.view.dispatch(tr);
246
+ }
247
+
248
+ editor.commands.focus();
249
+ };
250
+
251
+ // Only open the toolbar when this node is *explicitly* selected
252
+ // via a NodeSelection — not when it's merely included in a broader
253
+ // TextSelection or AllSelection (e.g. click-drag across math, or Cmd+A).
198
254
  useEffect(() => {
199
- if (selected) {
255
+ if (!selected) return;
256
+
257
+ const { selection } = editor.state;
258
+ const isNodeSelected = selection.node?.type?.name === 'math';
259
+
260
+ if (isNodeSelected) {
200
261
  setShowToolbar(true);
201
262
  }
202
- }, [selected]);
263
+ }, [selected, editor]);
203
264
 
204
265
  useEffect(() => {
205
- editor._toolbarOpened = !!showToolbar;
206
- }, [showToolbar]);
266
+ setToolbarOpened(editor, selected || showToolbar);
267
+ }, [editor, showToolbar, selected]);
207
268
 
208
269
  useEffect(() => {
209
- // Calculate position relative to selection
210
- const container = editor?._tiptapContainerEl || document.body;
211
- const bodyRect = container.getBoundingClientRect();
212
- const { from } = editor.state.selection;
213
- const start = editor.view.coordsAtPos(from);
214
- setPosition({
215
- top: start.top + Math.abs(bodyRect.top) + 40, // shift above
216
- left: start.left,
270
+ if (!editor || !showToolbar) {
271
+ setPosition({ top: 0, left: 0 });
272
+ return;
273
+ }
274
+
275
+ // Clamp in viewport coordinates, then convert to portal-container-relative values
276
+ // for position: absolute (toolbar is portaled into _tiptapContainerEl or document.body).
277
+ const updatePosition = () => {
278
+ if (!toolbarRef.current) {
279
+ return;
280
+ }
281
+
282
+ const { from } = editor.state.selection;
283
+ const start = editor.view.coordsAtPos(from);
284
+ const nodeRect = nodeRef.current?.getBoundingClientRect?.();
285
+
286
+ // Anchor to the math node element when available; fall back to selection coords.
287
+ const anchorTop = nodeRect?.height ? nodeRect.top : start.top;
288
+ const anchorLeft = nodeRect?.width ? nodeRect.left : start.left;
289
+ const anchorBottom = nodeRect?.height ? nodeRect.bottom : (start.bottom ?? start.top);
290
+
291
+ const toolbarHeight = toolbarRef.current.offsetHeight;
292
+ const toolbarWidth = toolbarRef.current.offsetWidth;
293
+
294
+ const gap = 0;
295
+ const spaceBelow = window.innerHeight - (anchorBottom + gap);
296
+
297
+ // Place the toolbar's top-left corner directly below the anchor; flip above when needed.
298
+ let top = spaceBelow >= toolbarHeight ? anchorBottom + gap : anchorTop - toolbarHeight - gap;
299
+ let left = anchorLeft;
300
+
301
+ const margin = 8;
302
+ top = Math.max(margin, Math.min(top, window.innerHeight - toolbarHeight - margin));
303
+ left = Math.max(margin, Math.min(left, window.innerWidth - toolbarWidth - margin));
304
+
305
+ const portalEl = editor._tiptapContainerEl || document.body;
306
+ const containerRect = portalEl.getBoundingClientRect();
307
+
308
+ setPosition({
309
+ top: top - containerRect.top,
310
+ left: left - containerRect.left,
311
+ });
312
+ };
313
+
314
+ updatePosition();
315
+
316
+ let frame = null;
317
+ const scheduleUpdate = () => {
318
+ if (frame !== null) {
319
+ return;
320
+ }
321
+
322
+ frame = requestAnimationFrame(() => {
323
+ frame = null;
324
+ updatePosition();
325
+ });
326
+ };
327
+
328
+ frame = requestAnimationFrame(() => {
329
+ frame = null;
330
+ updatePosition();
217
331
  });
218
332
 
333
+ window.addEventListener('scroll', scheduleUpdate, true);
334
+ window.addEventListener('resize', scheduleUpdate);
335
+
336
+ return () => {
337
+ if (frame !== null) {
338
+ cancelAnimationFrame(frame);
339
+ }
340
+
341
+ window.removeEventListener('scroll', scheduleUpdate, true);
342
+ window.removeEventListener('resize', scheduleUpdate);
343
+ };
344
+ }, [editor, showToolbar]);
345
+
346
+ useEffect(() => {
219
347
  const handleClickOutside = (event) => {
220
348
  const target = event?.target;
221
349
 
@@ -236,7 +364,7 @@ export const MathNodeView = (props) => {
236
364
  // will keep/re-open the toolbar. Without this guard, closing and then
237
365
  // immediately clicking the math node would fire this listener in the same
238
366
  // event cycle and close the toolbar before it could open.
239
- const clickedMathNode = !!target?.closest?.('.math-node');
367
+ const clickedMathNode = !!target?.closest?.(`.math-node-${timestamp.current}`);
240
368
 
241
369
  if (
242
370
  toolbarRef.current &&
@@ -247,6 +375,16 @@ export const MathNodeView = (props) => {
247
375
  !clickedMathNode
248
376
  ) {
249
377
  setShowToolbar(false);
378
+
379
+ // If the click landed inside the editable content itself, respect
380
+ // it and leave the cursor where the user clicked. If it landed
381
+ // fully outside the editor (e.g. on other page UI), there's no
382
+ // click position to preserve — falling back to "leave selection
383
+ // untouched" there renders the browser's native NodeSelection
384
+ // fallback caret at the start of the node, so explicitly move the
385
+ // cursor after it instead, same as the check-icon path.
386
+ const clickedInsideEditableContent = !!editor?.view?.dom?.contains(target);
387
+ handleDone(node.attrs.latex, { moveCursorAfterNode: !clickedInsideEditableContent });
250
388
  }
251
389
  };
252
390
 
@@ -259,30 +397,11 @@ export const MathNodeView = (props) => {
259
397
  }
260
398
 
261
399
  return () => document.removeEventListener('click', handleClickOutside);
262
- }, [editor, showToolbar]);
263
-
264
- const handleChange = (newLatex) => {
265
- updateAttributes({ latex: newLatex });
266
- };
267
-
268
- const handleDone = (newLatex) => {
269
- updateAttributes({ latex: newLatex });
270
- setShowToolbar(false);
271
-
272
- editor._toolbarOpened = false;
273
-
274
- const { selection, tr, doc } = editor.state;
275
- const sel = TextSelection.create(doc, selection.from + 1);
276
-
277
- // Build a fresh transaction from the current state and set the selection
278
- tr.setSelection(sel);
279
- editor.view.dispatch(tr);
280
- editor.commands.focus();
281
- };
400
+ }, [editor, showToolbar, node]);
282
401
 
283
402
  return (
284
403
  <NodeViewWrapper
285
- className="math-node"
404
+ className={`math-node-${timestamp.current}`}
286
405
  style={{
287
406
  display: 'inline-flex',
288
407
  cursor: 'pointer',
@@ -290,7 +409,7 @@ export const MathNodeView = (props) => {
290
409
  }}
291
410
  data-selected={selected}
292
411
  >
293
- <div onClick={() => setShowToolbar(true)} contentEditable={false}>
412
+ <div ref={nodeRef} onClick={() => setShowToolbar(true)} contentEditable={false}>
294
413
  <MathPreview latex={latex} />
295
414
  </div>
296
415
  {showToolbar &&
@@ -302,7 +421,7 @@ export const MathNodeView = (props) => {
302
421
  position: 'absolute',
303
422
  top: `${position.top}px`,
304
423
  left: `${position.left}px`,
305
- zIndex: 20,
424
+ zIndex: 1000,
306
425
  background: 'var(--editable-html-toolbar-bg, #efefef)',
307
426
  boxShadow:
308
427
  '0px 1px 5px 0px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 3px 1px -2px rgba(0, 0, 0, 0.12)',
@@ -195,7 +195,13 @@ export const ResponseAreaExtension = Extension.create({
195
195
  // tr.setSelection(NodeSelection.create(tr.doc, usedPos))
196
196
 
197
197
  // --- Cursor move behavior for certain types (Slate: moveFocusTo next text) ---
198
- if (['math_templated', 'inline_dropdown', 'explicit_constructed_response'].includes(typeName)) {
198
+ // Only types whose node view opens its own toolbar on selection
199
+ // (inline_dropdown, explicit_constructed_response) benefit from a
200
+ // NodeSelection here — the toolbar is the visual feedback that it's
201
+ // selected. math_templated has no such UI, so a NodeSelection there
202
+ // just looks unfocused (a highlighted node, no blinking caret);
203
+ // moving the cursor to just after it gives a real, visible caret.
204
+ if (['inline_dropdown', 'explicit_constructed_response'].includes(typeName)) {
199
205
  tr.setSelection(NodeSelection.create(tr.doc, usedPos));
200
206
  } else {
201
207
  const after = usedPos + newInline.nodeSize;
package/src/index.jsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import StyledEditor, { EditableHtml } from './components/EditableHtml';
2
2
  import { ALL_PLUGINS, DEFAULT_PLUGINS } from './extensions';
3
-
4
- export { EditableHtml, ALL_PLUGINS, DEFAULT_PLUGINS };
3
+ import { deleteInlineDropdownByIndex } from './components/respArea/inlineDropdownUtils';
4
+ export { EditableHtml, ALL_PLUGINS, DEFAULT_PLUGINS, deleteInlineDropdownByIndex };
5
5
  export default StyledEditor;
@@ -0,0 +1,43 @@
1
+ import { setToolbarOpened, TOOLBAR_OPENED_META_KEY } from '../toolbar';
2
+
3
+ describe('setToolbarOpened', () => {
4
+ const createEditor = (toolbarOpened = false) => {
5
+ const tr = {
6
+ setMeta: jest.fn().mockReturnThis(),
7
+ };
8
+
9
+ return {
10
+ _toolbarOpened: toolbarOpened,
11
+ state: { tr },
12
+ view: {
13
+ dispatch: jest.fn(),
14
+ },
15
+ };
16
+ };
17
+
18
+ it('sets editor._toolbarOpened and dispatches a meta transaction', () => {
19
+ const editor = createEditor(false);
20
+
21
+ setToolbarOpened(editor, true);
22
+
23
+ expect(editor._toolbarOpened).toBe(true);
24
+ expect(editor.state.tr.setMeta).toHaveBeenCalledWith(TOOLBAR_OPENED_META_KEY, true);
25
+ expect(editor.view.dispatch).toHaveBeenCalledWith(editor.state.tr);
26
+ });
27
+
28
+ it('does nothing when the value is unchanged', () => {
29
+ const editor = createEditor(true);
30
+
31
+ setToolbarOpened(editor, true);
32
+
33
+ expect(editor.view.dispatch).not.toHaveBeenCalled();
34
+ });
35
+
36
+ it('coerces the opened value to a boolean', () => {
37
+ const editor = createEditor(false);
38
+
39
+ setToolbarOpened(editor, 1);
40
+
41
+ expect(editor._toolbarOpened).toBe(true);
42
+ });
43
+ });
@@ -0,0 +1,15 @@
1
+ export const TOOLBAR_OPENED_META_KEY = 'toolbarOpenedChanged';
2
+
3
+ export const setToolbarOpened = (editor, opened) => {
4
+ const next = !!opened;
5
+
6
+ if (editor._toolbarOpened === next) {
7
+ return;
8
+ }
9
+
10
+ editor._toolbarOpened = next;
11
+
12
+ if (editor?.view && editor?.state?.tr) {
13
+ editor.view.dispatch(editor.state.tr.setMeta(TOOLBAR_OPENED_META_KEY, true));
14
+ }
15
+ };