@pie-lib/editable-html-tip-tap 2.1.9 → 2.1.11

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.
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  import { render, waitFor } from '@testing-library/react';
3
3
  import { useEditor } from '@tiptap/react';
4
4
  import { EditableHtml } from '../components/EditableHtml';
5
+ import { InlineDropdownNode } from '../extensions/responseArea';
5
6
 
6
7
  // Mock TipTap dependencies
7
8
  jest.mock('@tiptap/react', () => ({
@@ -210,6 +211,39 @@ describe('EditableHtml', () => {
210
211
  expect(container).toBeInTheDocument();
211
212
  });
212
213
 
214
+ it('passes blur done behavior to inline dropdown toolbar close', async () => {
215
+ const onChange = jest.fn();
216
+ const onDone = jest.fn();
217
+ const html = '<p>from inline dropdown close</p>';
218
+
219
+ render(
220
+ <EditableHtml
221
+ {...defaultProps}
222
+ markup="<p>Hello World</p>"
223
+ onChange={onChange}
224
+ onDone={onDone}
225
+ toolbarOpts={{ doneOn: 'blur' }}
226
+ responseAreaProps={{ type: 'inline-dropdown' }}
227
+ />,
228
+ );
229
+
230
+ await waitFor(() => {
231
+ expect(InlineDropdownNode.configure).toHaveBeenCalled();
232
+ });
233
+
234
+ const configureCall = InlineDropdownNode.configure.mock.calls[InlineDropdownNode.configure.mock.calls.length - 1];
235
+ const inlineDropdownOptions = configureCall[0];
236
+ const editor = {
237
+ getHTML: jest.fn(() => html),
238
+ };
239
+
240
+ inlineDropdownOptions.onInlineDropdownToolbarClose(editor);
241
+
242
+ expect(editor.getHTML).toHaveBeenCalledTimes(1);
243
+ expect(onChange).toHaveBeenCalledWith(html);
244
+ expect(onDone).toHaveBeenCalledWith(html);
245
+ });
246
+
213
247
  it('accepts size props', () => {
214
248
  const sizeProps = {
215
249
  width: 500,
@@ -120,6 +120,30 @@ export const EditableHtml = (props) => {
120
120
  ...toolbarOpts,
121
121
  };
122
122
 
123
+ const commitEditorContent = useCallback(
124
+ (editor) => {
125
+ const html = editor.getHTML();
126
+
127
+ if (props.markup !== html) {
128
+ props.onChange?.(html);
129
+ }
130
+
131
+ if (toolbarOptsToUse.doneOn === 'blur') {
132
+ props.onDone?.(html);
133
+ }
134
+ },
135
+ [props.markup, props.onChange, props.onDone, toolbarOptsToUse.doneOn],
136
+ );
137
+
138
+ const responseAreaPropsToUse = useMemo(
139
+ () => ({
140
+ ...defaultResponseAreaProps,
141
+ ...props.responseAreaProps,
142
+ onInlineDropdownToolbarClose: commitEditorContent,
143
+ }),
144
+ [props.responseAreaProps, commitEditorContent],
145
+ );
146
+
123
147
  const activePluginsToUse = useMemo(() => {
124
148
  let { customPlugins, ...otherPluginProps } = props.pluginProps || {};
125
149
 
@@ -143,14 +167,14 @@ export const EditableHtml = (props) => {
143
167
  toolbar: {},
144
168
  table: {},
145
169
  responseArea: {
146
- type: props.responseAreaProps?.type,
170
+ type: responseAreaPropsToUse.type,
147
171
  },
148
172
  languageCharacters: props.languageCharactersProps,
149
173
  keyPadCharacterRef: {},
150
174
  setKeypadInteraction: {},
151
175
  media: {},
152
176
  });
153
- }, [props]);
177
+ }, [props, responseAreaPropsToUse.type]);
154
178
 
155
179
  const extensions = [
156
180
  TextAlign.configure({
@@ -183,11 +207,11 @@ export const EditableHtml = (props) => {
183
207
  TableRow,
184
208
  ExtendedTableHeader,
185
209
  ExtendedTableCell,
186
- ResponseAreaExtension.configure(props.responseAreaProps),
187
- ExplicitConstructedResponseNode.configure(props.responseAreaProps),
188
- DragInTheBlankNode.configure(props.responseAreaProps),
189
- InlineDropdownNode.configure(props.responseAreaProps),
190
- MathTemplatedNode.configure(props.responseAreaProps),
210
+ ResponseAreaExtension.configure(responseAreaPropsToUse),
211
+ ExplicitConstructedResponseNode.configure(responseAreaPropsToUse),
212
+ DragInTheBlankNode.configure(responseAreaPropsToUse),
213
+ InlineDropdownNode.configure(responseAreaPropsToUse),
214
+ MathTemplatedNode.configure(responseAreaPropsToUse),
191
215
  MathNode.configure({
192
216
  toolbarOpts: toolbarOptsToUse,
193
217
  math: props.pluginProps?.math || {},
@@ -219,7 +243,7 @@ export const EditableHtml = (props) => {
219
243
  let cb;
220
244
 
221
245
  if (scheduled && result) {
222
- // finish editing only on success
246
+ // finish editing only on success
223
247
  cb = props.onChange;
224
248
  }
225
249
 
@@ -303,15 +327,7 @@ export const EditableHtml = (props) => {
303
327
  return;
304
328
  }
305
329
 
306
- const html = editor.getHTML();
307
-
308
- if (props.markup !== html) {
309
- props.onChange?.(html);
310
- }
311
-
312
- if (toolbarOptsToUse.doneOn === 'blur') {
313
- props.onDone?.(html);
314
- }
330
+ commitEditorContent(editor);
315
331
  },
316
332
  },
317
333
  [props.charactersLimit],
@@ -408,10 +424,12 @@ const StyledEditorContent = styled(EditorContent, {
408
424
  },
409
425
 
410
426
  // Out of flow so the caret stays at the start of the block; in-flow ::before pushes the caret after the hint text.
411
- '& p.is-editor-empty, & div.is-editor-empty': {
427
+ // :only-child ensures the placeholder is hidden whenever the editor has other content (images, upload nodes, etc.)
428
+ // and covers the type+backspace edge case where Tiptap only adds is-empty (not is-editor-empty).
429
+ '& p[data-placeholder].is-empty:only-child, & div[data-placeholder].is-empty:only-child': {
412
430
  position: 'relative',
413
431
  },
414
- '& p.is-editor-empty::before, & div.is-editor-empty::before': {
432
+ '& p[data-placeholder].is-empty:only-child::before, & div[data-placeholder].is-empty:only-child::before': {
415
433
  content: 'attr(data-placeholder)',
416
434
  position: 'absolute',
417
435
  left: 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}>
@@ -104,6 +109,32 @@ describe('InlineDropdown', () => {
104
109
  expect(valueDiv).toBeInTheDocument();
105
110
  });
106
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
+
107
138
  it('uses 2px horizontal margin on the value control and no horizontal margin on the wrapper', () => {
108
139
  const { container, getByTestId } = render(<InlineDropdown {...defaultProps} />);
109
140
  const valueDiv = container.querySelector('div[style*="border"]');
@@ -185,6 +216,28 @@ describe('InlineDropdown', () => {
185
216
  });
186
217
  });
187
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
+
188
241
  it('uses the current node when closing on outside click after the node prop changes', async () => {
189
242
  const onToolbarCloseRequest = jest.fn((_tuple, _editor, onConfirm) => onConfirm());
190
243
  const options = {
@@ -476,7 +529,7 @@ describe('InlineDropdown', () => {
476
529
  await waitFor(() => {
477
530
  expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument();
478
531
  });
479
- });
532
+ });
480
533
 
481
534
  it('renders delete control on portaled custom toolbar when container el is set', async () => {
482
535
  const { findByLabelText } = render(<InlineDropdown {...defaultProps} selected />);
@@ -0,0 +1,72 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+ import DragDropChoice from '../choice';
4
+
5
+ // Mock @dnd-kit hooks to avoid DndContext requirement
6
+ jest.mock('@dnd-kit/core', () => ({
7
+ useDraggable: jest.fn(() => ({
8
+ attributes: {},
9
+ listeners: {},
10
+ setNodeRef: jest.fn(),
11
+ isDragging: false,
12
+ })),
13
+ useDroppable: jest.fn(() => ({
14
+ setNodeRef: jest.fn(),
15
+ isOver: false,
16
+ active: null,
17
+ })),
18
+ }));
19
+
20
+ jest.mock('@pie-lib/math-rendering', () => ({
21
+ renderMath: jest.fn(),
22
+ }));
23
+
24
+ // Collect the CSS rules emotion/MUI injected into the document (jsdom uses insertRule).
25
+ const collectEmotionRules = () => {
26
+ const rules = [];
27
+ for (const sheet of Array.from(document.styleSheets)) {
28
+ try {
29
+ for (const rule of Array.from(sheet.cssRules)) {
30
+ rules.push(rule.cssText);
31
+ }
32
+ } catch (e) {
33
+ /* inaccessible stylesheet */
34
+ }
35
+ }
36
+ return rules;
37
+ };
38
+
39
+ describe('DragInTheBlank choice', () => {
40
+ const defaultProps = {
41
+ value: { id: '1', value: '<math>1/2</math>' },
42
+ disabled: false,
43
+ instanceId: 'test-instance',
44
+ n: { index: 0 },
45
+ onChange: jest.fn(),
46
+ removeResponse: jest.fn(),
47
+ duplicates: false,
48
+ };
49
+
50
+ it('renders without crashing', () => {
51
+ const { container } = render(<DragDropChoice {...defaultProps} />);
52
+ expect(container.firstChild).toBeInTheDocument();
53
+ });
54
+
55
+ describe('fraction math styling', () => {
56
+ it('enlarges numerator/denominator digits adjacent to a fraction to 120%', () => {
57
+ render(<DragDropChoice {...defaultProps} />);
58
+ const rule = collectEmotionRules().find((r) => r.includes('mjx-mn') && r.includes('mjx-mfrac'));
59
+ expect(rule).toBeDefined();
60
+ expect(rule).toMatch(/mjx-mn:has\(~\s*mjx-mfrac\)/);
61
+ expect(rule).toMatch(/mjx-mfrac\s*~\s*mjx-mn/);
62
+ expect(rule).toMatch(/font-size:\s*120%\s*!important/i);
63
+ });
64
+
65
+ it('keeps the existing mjx-frac 120% rule', () => {
66
+ render(<DragDropChoice {...defaultProps} />);
67
+ const rule = collectEmotionRules().find((r) => /(^|[^-])mjx-frac/.test(r) && !r.includes('mjx-mfrac'));
68
+ expect(rule).toBeDefined();
69
+ expect(rule).toMatch(/font-size:\s*120%\s*!important/i);
70
+ });
71
+ });
72
+ });
@@ -10,6 +10,9 @@ import { GripIcon } from '../../icons/RespArea';
10
10
 
11
11
  const StyledContent = styled('span')(({ theme }) => ({
12
12
  border: `solid 0px ${theme.palette.primary.main}`,
13
+ '& mjx-mn:has(~ mjx-mfrac), mjx-mfrac ~ mjx-mn': {
14
+ fontSize: '120% !important',
15
+ },
13
16
  '& mjx-frac': {
14
17
  fontSize: '120% !important',
15
18
  },
@@ -1,9 +1,10 @@
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';
4
6
  import { NodeSelection } from 'prosemirror-state';
5
7
  import { Chevron } from '../icons/RespArea';
6
- import ReactDOM from 'react-dom';
7
8
  import CustomToolbarWrapper from '../../extensions/custom-toolbar-wrapper';
8
9
  import { setToolbarOpened } from '../../utils/toolbar';
9
10
 
@@ -16,6 +17,7 @@ const InlineDropdown = (props) => {
16
17
  const toolbarRef = useRef(null);
17
18
  const toolbarEditor = useRef(null);
18
19
  const pendingCloseRequest = useRef(false);
20
+ const elementRef = useRef(null);
19
21
 
20
22
  const isHeld = () =>
21
23
  editor._holdInlineDropdownToolbarIndex != null &&
@@ -30,6 +32,7 @@ const InlineDropdown = (props) => {
30
32
  }
31
33
 
32
34
  setShowToolbar(false);
35
+ options.onInlineDropdownToolbarClose?.(editor);
33
36
  };
34
37
 
35
38
  const InlineDropdownToolbar = options.respAreaToolbar([node, pos], editor, closeToolbar);
@@ -93,13 +96,16 @@ const InlineDropdown = (props) => {
93
96
  }
94
97
  }, [editor, node, selected]);
95
98
 
96
-
97
-
98
99
  const isScrollbarClicked = (event) =>
99
100
  event.clientX > document.documentElement.clientWidth ||
100
101
  event.clientY > document.documentElement.clientHeight ||
101
102
  event.target === document.documentElement;
102
-
103
+
104
+ useEffect(() => {
105
+ if (elementRef.current && typeof renderMath === 'function') {
106
+ renderMath(elementRef.current);
107
+ }
108
+ }, [value]);
103
109
 
104
110
  useEffect(() => {
105
111
  // Calculate position relative to selection
@@ -113,10 +119,10 @@ const InlineDropdown = (props) => {
113
119
  });
114
120
 
115
121
  const handleClickOutside = (event) => {
116
-
117
- if( isScrollbarClicked(event) ) {
122
+ if (isScrollbarClicked(event)) {
118
123
  return;
119
124
  }
125
+
120
126
  const insideSomeEditor = event.target.closest('[data-toolbar-for]');
121
127
 
122
128
  if (
@@ -151,6 +157,7 @@ const InlineDropdown = (props) => {
151
157
  }}
152
158
  >
153
159
  <div
160
+ ref={elementRef}
154
161
  style={{
155
162
  display: 'inline-flex',
156
163
  minWidth: '178px',
@@ -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(),
@@ -416,6 +416,7 @@ describe('MathNodeView', () => {
416
416
  attrs: {
417
417
  latex: 'x^2',
418
418
  },
419
+ nodeSize: 1,
419
420
  };
420
421
 
421
422
  let defaultProps;
@@ -429,6 +430,7 @@ describe('MathNodeView', () => {
429
430
  editor: createMockEditor(),
430
431
  selected: false,
431
432
  options: {},
433
+ getPos: jest.fn(() => 0),
432
434
  };
433
435
  });
434
436
 
@@ -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
  });
@@ -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
 
@@ -201,7 +201,7 @@ export const MathNode = Node.create({
201
201
  });
202
202
 
203
203
  export const MathNodeView = (props) => {
204
- const { node, updateAttributes, editor, selected, options } = props;
204
+ const { node, updateAttributes, editor, selected, options, getPos } = props;
205
205
  const [showToolbar, setShowToolbar] = useState(selected);
206
206
  const toolbarRef = useRef(null);
207
207
  const nodeRef = useRef(null);
@@ -222,16 +222,29 @@ export const MathNodeView = (props) => {
222
222
  updateAttributes({ latex: newLatex });
223
223
  };
224
224
 
225
- const handleDone = (newLatex) => {
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 } = {}) => {
226
237
  updateAttributes({ latex: newLatex });
227
238
  setShowToolbar(false);
228
239
 
229
- const { selection, tr, doc } = editor.state;
230
- const sel = TextSelection.create(doc, selection.from + 1);
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
+ }
231
247
 
232
- // Build a fresh transaction from the current state and set the selection
233
- tr.setSelection(sel);
234
- editor.view.dispatch(tr);
235
248
  editor.commands.focus();
236
249
  };
237
250
 
@@ -362,7 +375,16 @@ export const MathNodeView = (props) => {
362
375
  !clickedMathNode
363
376
  ) {
364
377
  setShowToolbar(false);
365
- handleDone(node.attrs.latex);
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 });
366
388
  }
367
389
  };
368
390
 
@@ -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;