@pie-lib/editable-html-tip-tap 1.2.0-next.9 → 2.0.1

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 (94) hide show
  1. package/CHANGELOG.md +176 -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/extensions/custom-toolbar-wrapper.js +3 -2
  24. package/lib/extensions/custom-toolbar-wrapper.js.map +1 -1
  25. package/lib/extensions/div-node.js +83 -0
  26. package/lib/extensions/div-node.js.map +1 -0
  27. package/lib/extensions/ensure-empty-root-div.js +48 -0
  28. package/lib/extensions/ensure-empty-root-div.js.map +1 -0
  29. package/lib/extensions/ensure-list-item-content-is-div.js +64 -0
  30. package/lib/extensions/ensure-list-item-content-is-div.js.map +1 -0
  31. package/lib/extensions/extended-list-item.js +15 -0
  32. package/lib/extensions/extended-list-item.js.map +1 -0
  33. package/lib/extensions/extended-table-cell.js +22 -0
  34. package/lib/extensions/extended-table-cell.js.map +1 -0
  35. package/lib/extensions/extended-table.js +50 -1
  36. package/lib/extensions/extended-table.js.map +1 -1
  37. package/lib/extensions/image-component.js +102 -51
  38. package/lib/extensions/image-component.js.map +1 -1
  39. package/lib/extensions/image.js +51 -2
  40. package/lib/extensions/image.js.map +1 -1
  41. package/lib/extensions/math.js +50 -9
  42. package/lib/extensions/math.js.map +1 -1
  43. package/lib/extensions/media.js +3 -1
  44. package/lib/extensions/media.js.map +1 -1
  45. package/lib/extensions/responseArea.js +12 -7
  46. package/lib/extensions/responseArea.js.map +1 -1
  47. package/lib/styles/editorContainerStyles.js +5 -4
  48. package/lib/styles/editorContainerStyles.js.map +1 -1
  49. package/lib/utils/helper.js +17 -0
  50. package/lib/utils/helper.js.map +1 -0
  51. package/package.json +8 -8
  52. package/src/__tests__/EditableHtml.test.jsx +90 -7
  53. package/src/__tests__/index.test.jsx +11 -3
  54. package/src/components/CharacterPicker.jsx +1 -0
  55. package/src/components/EditableHtml.jsx +91 -41
  56. package/src/components/MenuBar.jsx +57 -24
  57. package/src/components/TiptapContainer.jsx +10 -8
  58. package/src/components/__tests__/CharacterPicker.test.jsx +22 -0
  59. package/src/components/__tests__/ExplicitConstructedResponse.test.jsx +55 -12
  60. package/src/components/__tests__/InlineDropdown.test.jsx +203 -10
  61. package/src/components/__tests__/InsertImageHandler.test.js +28 -21
  62. package/src/components/__tests__/MenuBar.test.jsx +32 -0
  63. package/src/components/icons/TextAlign.jsx +1 -1
  64. package/src/components/image/InsertImageHandler.js +9 -13
  65. package/src/components/respArea/DragInTheBlank/DragInTheBlank.jsx +6 -1
  66. package/src/components/respArea/DragInTheBlank/choice.jsx +32 -4
  67. package/src/components/respArea/ExplicitConstructedResponse.jsx +33 -10
  68. package/src/components/respArea/InlineDropdown.jsx +45 -10
  69. package/src/extensions/__tests__/divNode.test.js +87 -0
  70. package/src/extensions/__tests__/ensure-empty-root-div.test.js +57 -0
  71. package/src/extensions/__tests__/ensure-list-item-content-is-div.test.js +44 -0
  72. package/src/extensions/__tests__/extended-list-item.test.js +13 -0
  73. package/src/extensions/__tests__/extended-table-cell.test.js +22 -0
  74. package/src/extensions/__tests__/extended-table.test.js +98 -1
  75. package/src/extensions/__tests__/image-component.test.jsx +105 -9
  76. package/src/extensions/__tests__/image.test.js +109 -8
  77. package/src/extensions/__tests__/math.test.js +348 -0
  78. package/src/extensions/__tests__/media-node-view.test.jsx +10 -8
  79. package/src/extensions/__tests__/responseArea.test.js +291 -0
  80. package/src/extensions/custom-toolbar-wrapper.jsx +2 -2
  81. package/src/extensions/div-node.js +86 -0
  82. package/src/extensions/ensure-empty-root-div.js +47 -0
  83. package/src/extensions/ensure-list-item-content-is-div.js +62 -0
  84. package/src/extensions/extended-list-item.js +10 -0
  85. package/src/extensions/extended-table-cell.js +19 -0
  86. package/src/extensions/extended-table.js +37 -1
  87. package/src/extensions/image-component.jsx +114 -69
  88. package/src/extensions/image.js +56 -1
  89. package/src/extensions/math.js +62 -10
  90. package/src/extensions/media.js +1 -1
  91. package/src/extensions/responseArea.js +13 -11
  92. package/src/styles/editorContainerStyles.js +5 -4
  93. package/src/utils/helper.js +17 -0
  94. /package/src/components/media/{MediaDialog.js → MediaDialog.jsx} +0 -0
@@ -1,10 +1,11 @@
1
- import React, { useCallback, useEffect, useRef, useState } from 'react';
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import PropTypes from 'prop-types';
3
3
  import isEqual from 'lodash-es/isEqual';
4
4
  import debug from 'debug';
5
5
  import LinearProgress from '@mui/material/LinearProgress';
6
6
  import { styled } from '@mui/material/styles';
7
7
  import { NodeViewWrapper } from '@tiptap/react';
8
+ import ReactDOM from 'react-dom';
8
9
  import InsertImageHandler from '../components/image/InsertImageHandler';
9
10
  import ImageToolbar from '../components/image/ImageToolbar';
10
11
  import CustomToolbarWrapper from './custom-toolbar-wrapper';
@@ -26,9 +27,8 @@ const StyledProgress = styled(LinearProgress, {
26
27
 
27
28
  const StyledRoot = styled('div', {
28
29
  shouldForwardProp: (prop) => !['active', 'loading', 'pendingDelete'].includes(prop),
29
- })(({ theme, active, loading, pendingDelete }) => ({
30
+ })(({ loading, pendingDelete }) => ({
30
31
  position: 'relative',
31
- border: active ? `solid 1px ${theme.palette.primary.main}` : `solid 1px ${theme.palette.common.white}`,
32
32
  display: 'flex',
33
33
  transition: 'opacity 200ms linear',
34
34
  ...(loading && {
@@ -49,6 +49,12 @@ const StyledImageContainer = styled('div')(({ theme }) => ({
49
49
  },
50
50
  }));
51
51
 
52
+ const StyledImage = styled('img', {
53
+ shouldForwardProp: (prop) => prop !== 'active',
54
+ })(({ theme, active }) => ({
55
+ border: active ? `solid 1px ${theme.palette.primary.main}` : 'solid 1px transparent',
56
+ }));
57
+
52
58
  const StyledResize = styled('div')(({ theme }) => ({
53
59
  backgroundColor: theme.palette.primary.main,
54
60
  cursor: 'col-resize',
@@ -68,15 +74,18 @@ function ImageComponent(props) {
68
74
  editor,
69
75
  attributes,
70
76
  onFocus,
77
+ getPos,
71
78
  selected,
72
79
  options,
73
80
  maxImageWidth = 700,
74
81
  maxImageHeight = 900,
75
- latex,
76
- handleChange,
77
- handleDone,
78
82
  } = props;
79
83
  const { alt } = node.attrs;
84
+ const pos = getPos();
85
+
86
+ const selFrom = editor.state.selection.from;
87
+ const selTo = editor.state.selection.to;
88
+ const onlyThisNodeSelected = useMemo(() => selFrom + node.nodeSize === selTo, [selFrom, selTo, node.nodeSize]);
80
89
 
81
90
  const [showToolbar, setShowToolbar] = useState(false);
82
91
 
@@ -90,39 +99,66 @@ function ImageComponent(props) {
90
99
  return parseInt(floored.toFixed(0) * 25, 10);
91
100
  }, []);
92
101
 
102
+ const findNodePos = useCallback(() => {
103
+ const key = latestNodeRef.current.attrs.nodeKey;
104
+ let found = null;
105
+ editor.state.doc.descendants((n, pos) => {
106
+ if (found !== null) return false;
107
+ if (n.type.name === 'imageUploadNode' && n.attrs.nodeKey === key) {
108
+ found = pos;
109
+ return false;
110
+ }
111
+ });
112
+ return found;
113
+ }, [editor]);
114
+
115
+ // dispatch an attribute update targeted precisely at this node by nodeKey.
116
+ const updateThisNode = useCallback(
117
+ (newAttrs) => {
118
+ const nodePos = findNodePos();
119
+ if (nodePos === null) return;
120
+ const currentNode = editor.state.doc.nodeAt(nodePos);
121
+ if (!currentNode) return;
122
+ editor.view.dispatch(editor.state.tr.setNodeMarkup(nodePos, undefined, { ...currentNode.attrs, ...newAttrs }));
123
+ },
124
+ [editor, findNodePos],
125
+ );
126
+
93
127
  const applySizeData = useCallback(() => {
94
128
  if (!node.attrs.width || !imgRef.current) return;
95
-
96
- const update = {
97
- ...node.attrs,
98
- resizePercent: getPercentFromWidth(node.attrs.width),
99
- };
100
-
101
- if (!isEqual(update, node.attrs)) {
102
- editor.commands.updateAttributes('imageUploadNode', update);
103
- }
104
- }, [editor, node.attrs, getPercentFromWidth]);
129
+ const resizePercent = getPercentFromWidth(node.attrs.width);
130
+ if (node.attrs.resizePercent === resizePercent) return;
131
+ updateThisNode({ resizePercent });
132
+ }, [node.attrs.width, node.attrs.resizePercent, getPercentFromWidth, updateThisNode]);
105
133
 
106
134
  // keep ref in sync with latest node
107
135
  useEffect(() => {
108
- latestNodeRef.current = node;
109
- }, [node]);
136
+ latestNodeRef.current = { ...node, pos };
137
+ }, [node, pos]);
110
138
 
111
139
  useEffect(() => {
112
- const { selection } = editor.state;
113
- const onlyThisNodeSelected = selection.from + node.nodeSize === selection.to;
114
-
115
140
  if (selected) {
116
141
  if (onlyThisNodeSelected) {
142
+ // Only open the upload UI for a fresh placeholder. Remounting after tab switch
143
+ // would otherwise call insertImageRequested again and reopen the file modal.
144
+ const hasImageSrc = String(node.attrs?.src ?? '').trim();
145
+
146
+ if (!hasImageSrc && options.imageHandling?.insertImageRequested) {
147
+ options.imageHandling.insertImageRequested(
148
+ editor,
149
+ [node, pos],
150
+ (finish) => new InsertImageHandler(editor, [node, pos], finish),
151
+ );
152
+ }
153
+
117
154
  setShowToolbar(selected);
118
155
  }
119
156
  } else {
120
157
  setShowToolbar(selected);
121
158
  }
122
- }, [editor, node, selected]);
159
+ }, [onlyThisNodeSelected, selected]);
123
160
 
124
161
  useEffect(() => {
125
- options.imageHandling.insertImageRequested(node, (finish) => new InsertImageHandler(editor, node, finish));
126
162
  applySizeData();
127
163
 
128
164
  const resizeHandle = resizeRef.current;
@@ -133,8 +169,6 @@ function ImageComponent(props) {
133
169
  if (resizeHandle) {
134
170
  resizeHandle.removeEventListener('mousedown', initResize, false);
135
171
  }
136
-
137
- options.imageHandling.onDelete(latestNodeRef.current);
138
172
  };
139
173
  }, []);
140
174
 
@@ -154,11 +188,11 @@ function ImageComponent(props) {
154
188
  box.style.height = `${h}px`;
155
189
 
156
190
  const update = { width: w, height: h };
157
- if (!isEqual(update, node.attrs)) {
158
- editor.commands.updateAttributes('imageUploadNode', update);
191
+ if (!isEqual(update, { width: node.attrs.width, height: node.attrs.height })) {
192
+ updateThisNode(update);
159
193
  }
160
194
  }
161
- }, [editor, node.attrs, maxImageWidth, maxImageHeight]);
195
+ }, [node.attrs.width, node.attrs.height, maxImageWidth, maxImageHeight, updateThisNode]);
162
196
 
163
197
  const updateAspect = (initial, next, keepAspect = true, resizeType) => {
164
198
  if (keepAspect) {
@@ -186,20 +220,17 @@ function ImageComponent(props) {
186
220
  box.style.width = `${next.width}px`;
187
221
  box.style.height = `${next.height}px`;
188
222
 
189
- const update = { width: next.width, height: next.height };
190
- if (!isEqual(update, node.attrs)) {
191
- editor.commands.updateAttributes('imageUploadNode', update);
192
- }
223
+ updateThisNode({ width: next.width, height: next.height });
193
224
  }
194
225
  },
195
- [editor, node.attrs],
226
+ [editor, updateThisNode],
196
227
  );
197
228
 
198
229
  const onChange = useCallback(
199
230
  (newValues) => {
200
- editor.commands.updateAttributes('imageUploadNode', newValues);
231
+ updateThisNode(newValues);
201
232
  },
202
- [editor],
233
+ [editor, updateThisNode],
203
234
  );
204
235
 
205
236
  const stopResize = useCallback(() => {
@@ -224,16 +255,17 @@ function ImageComponent(props) {
224
255
  <NodeViewWrapper>
225
256
  <StyledRoot
226
257
  onFocus={onFocus}
227
- active={selected}
228
258
  loading={!node.attrs.loaded}
229
259
  pendingDelete={node.attrs.deleteStatus === 'pending'}
230
260
  style={{ justifyContent: flexAlign }}
231
261
  >
232
262
  <StyledProgress mode="determinate" value={node.attrs.percent || 0} hideProgress={node.attrs.loaded} />
233
263
 
234
- <StyledImageContainer>
235
- <img
264
+ <StyledImageContainer onDragStart={(e) => e.preventDefault()}>
265
+ <StyledImage
236
266
  {...attributes}
267
+ active={selected && node.attrs.loaded}
268
+ draggable={false}
237
269
  ref={imgRef}
238
270
  src={node.attrs.src}
239
271
  style={style}
@@ -244,39 +276,52 @@ function ImageComponent(props) {
244
276
  </StyledImageContainer>
245
277
  </StyledRoot>
246
278
 
247
- {showToolbar && (
248
- <div
249
- ref={toolbarRef}
250
- style={{
251
- position: 'absolute',
252
- top: '100%',
253
- left: 0,
254
- zIndex: 20,
255
- background: 'var(--editable-html-toolbar-bg, #efefef)',
256
- boxShadow:
257
- '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)',
258
- width: '100%',
259
- }}
260
- >
261
- <CustomToolbarWrapper
262
- showDone
263
- {...options}
264
- onDone={() => {
265
- setShowToolbar(false);
266
- props.imageHandling?.onDone();
267
- props.editor.commands.focus('end');
279
+ {showToolbar &&
280
+ editor._tiptapContainerEl &&
281
+ ReactDOM.createPortal(
282
+ <div
283
+ ref={toolbarRef}
284
+ style={{
285
+ zIndex: 20,
286
+ background: 'var(--editable-html-toolbar-bg, #efefef)',
287
+ boxShadow:
288
+ '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)',
289
+ width: '100%',
268
290
  }}
269
291
  >
270
- <ImageToolbar
271
- disableImageAlignmentButtons={options.disableImageAlignmentButtons}
272
- alt={node.attrs.alt}
273
- imageLoaded={node.attrs.loaded}
274
- alignment={node.attrs.alignment || 'left'}
275
- onChange={onChange}
276
- />
277
- </CustomToolbarWrapper>
278
- </div>
279
- )}
292
+ <CustomToolbarWrapper
293
+ showDone
294
+ deletable
295
+ toolbarOpts={options.toolbarOpts || {}}
296
+ onDelete={() => {
297
+ const nodePos = findNodePos();
298
+ if (nodePos === null) return;
299
+
300
+ options.imageHandling?.onDelete?.(latestNodeRef.current);
301
+
302
+ editor.view.dispatch(
303
+ editor.state.tr.delete(nodePos, nodePos + editor.state.doc.nodeAt(nodePos).nodeSize),
304
+ );
305
+ setShowToolbar(false);
306
+ editor.commands.focus();
307
+ }}
308
+ onDone={() => {
309
+ setShowToolbar(false);
310
+ options.imageHandling?.onDone?.(editor);
311
+ editor.commands.focus('end');
312
+ }}
313
+ >
314
+ <ImageToolbar
315
+ disableImageAlignmentButtons={options.imageHandling?.disableImageAlignmentButtons}
316
+ alt={node.attrs.alt}
317
+ imageLoaded={node.attrs.loaded}
318
+ alignment={node.attrs.alignment || 'left'}
319
+ onChange={onChange}
320
+ />
321
+ </CustomToolbarWrapper>
322
+ </div>,
323
+ editor._tiptapContainerEl,
324
+ )}
280
325
  </NodeViewWrapper>
281
326
  );
282
327
  }
@@ -1,7 +1,9 @@
1
1
  import { mergeAttributes, Node } from '@tiptap/core';
2
2
  import { ReactNodeViewRenderer } from '@tiptap/react';
3
+ import { Plugin } from '@tiptap/pm/state';
3
4
  import React from 'react';
4
5
  import ImageComponent from './image-component';
6
+ import { node } from 'prop-types';
5
7
 
6
8
  export const ImageUploadNode = Node.create({
7
9
  name: 'imageUploadNode',
@@ -13,6 +15,7 @@ export const ImageUploadNode = Node.create({
13
15
 
14
16
  addAttributes() {
15
17
  return {
18
+ nodeKey: { default: null },
16
19
  loaded: { default: false },
17
20
  deleteStatus: { default: null },
18
21
  alignment: { default: null },
@@ -27,7 +30,7 @@ export const ImageUploadNode = Node.create({
27
30
  parseHTML() {
28
31
  return [
29
32
  {
30
- tag: 'div[data-type="image-upload-node"]',
33
+ tag: 'img[data-type="image-upload-node"]',
31
34
  },
32
35
  ];
33
36
  },
@@ -47,8 +50,60 @@ export const ImageUploadNode = Node.create({
47
50
  ({ commands }) => {
48
51
  return commands.insertContent({
49
52
  type: this.name,
53
+ // adding a unique nodeKey attribute to help identify this node instance later due to issues with multiple images
54
+ attrs: { nodeKey: `img-${Date.now()}-${Math.random().toString(36).slice(2)}` },
50
55
  });
51
56
  },
52
57
  };
53
58
  },
59
+
60
+ addProseMirrorPlugins() {
61
+ const editor = this.editor;
62
+
63
+ return [
64
+ new Plugin({
65
+ props: {
66
+ handlePaste(view, event) {
67
+ const items = Array.from(event.clipboardData?.items || []);
68
+
69
+ const imageItem = items.find((item) => item.kind === 'file' && item.type.startsWith('image/'));
70
+
71
+ if (!imageItem) {
72
+ return false;
73
+ }
74
+
75
+ const file = imageItem.getAsFile();
76
+
77
+ if (!file) {
78
+ return false;
79
+ }
80
+
81
+ // Example 1: insert as base64 immediately
82
+ const reader = new FileReader();
83
+
84
+ reader.onload = () => {
85
+ const src = reader.result;
86
+
87
+ if (typeof src !== 'string') {
88
+ return;
89
+ }
90
+
91
+ editor.commands.insertContent({
92
+ type: 'imageUploadNode',
93
+ attrs: {
94
+ src,
95
+ loaded: true,
96
+ nodeKey: `img-${Date.now()}-${Math.random().toString(36).slice(2)}`,
97
+ },
98
+ });
99
+ };
100
+
101
+ reader.readAsDataURL(file);
102
+
103
+ return true;
104
+ },
105
+ },
106
+ }),
107
+ ];
108
+ },
54
109
  });
@@ -8,6 +8,15 @@ import { wrapMath } from '@pie-lib/math-rendering';
8
8
 
9
9
  const ensureTextAfterMathPluginKey = new PluginKey('ensureTextAfterMath');
10
10
 
11
+ const generateAdditionalKeys = (keyData = []) => {
12
+ return keyData.map((key) => ({
13
+ name: key,
14
+ latex: key,
15
+ write: key,
16
+ label: key,
17
+ }));
18
+ };
19
+
11
20
  export const EnsureTextAfterMathPlugin = (mathNodeName) =>
12
21
  new Plugin({
13
22
  key: ensureTextAfterMathPluginKey,
@@ -175,6 +184,14 @@ export const MathNodeView = (props) => {
175
184
  const [showToolbar, setShowToolbar] = useState(selected);
176
185
  const toolbarRef = useRef(null);
177
186
  const [position, setPosition] = useState({ top: 0, left: 0 });
187
+ const { math: mathOptions = {} } = options || {};
188
+ const {
189
+ keypadMode,
190
+ controlledKeypadMode = true,
191
+ customKeys = [],
192
+ keyPadCharacterRef,
193
+ setKeypadInteraction,
194
+ } = mathOptions;
178
195
 
179
196
  const latex = node.attrs.latex || '';
180
197
 
@@ -199,22 +216,48 @@ export const MathNodeView = (props) => {
199
216
  });
200
217
 
201
218
  const handleClickOutside = (event) => {
219
+ const target = event?.target;
220
+
221
+ // MUI's `Select` renders its dropdown options in a portal attached to `document.body`.
222
+ // Those clicks should not dismiss the math toolbar.
223
+ const equationEditorListboxes =
224
+ document.querySelectorAll?.(
225
+ '[id^="equation-editor-select"][id*="listbox"], [aria-labelledby="equation-editor-label"][role="listbox"]',
226
+ ) || [];
227
+
228
+ const equationEditorPopoverOpen = equationEditorListboxes.length > 0;
229
+ const clickedEquationEditorSelect =
230
+ !!(target?.id && target.id.includes('equation-editor-select')) ||
231
+ !!target?.closest?.('[id*="equation-editor-select"]');
232
+
233
+ // If the click originated from the math node preview itself (the element
234
+ // that opens the toolbar), ignore it here — the node's own onClick handler
235
+ // will keep/re-open the toolbar. Without this guard, closing and then
236
+ // immediately clicking the math node would fire this listener in the same
237
+ // event cycle and close the toolbar before it could open.
238
+ const clickedMathNode = !!target?.closest?.('.math-node');
239
+
202
240
  if (
203
241
  toolbarRef.current &&
204
- !toolbarRef.current.contains(event.target) &&
205
- !event.target.closest('[data-inline-node]')
242
+ !toolbarRef.current.contains(target) &&
243
+ !target?.closest?.('[data-inline-node]') &&
244
+ !equationEditorPopoverOpen &&
245
+ !clickedEquationEditorSelect &&
246
+ !clickedMathNode
206
247
  ) {
207
248
  setShowToolbar(false);
208
249
  }
209
250
  };
210
251
 
211
252
  if (showToolbar) {
212
- document.addEventListener('mousedown', handleClickOutside);
253
+ // Use `click` (not `mousedown`) so interacting with browser UI like the scrollbar
254
+ // doesn't automatically dismiss the math toolbar.
255
+ document.addEventListener('click', handleClickOutside);
213
256
  } else {
214
- document.removeEventListener('mousedown', handleClickOutside);
257
+ document.removeEventListener('click', handleClickOutside);
215
258
  }
216
259
 
217
- return () => document.removeEventListener('mousedown', handleClickOutside);
260
+ return () => document.removeEventListener('click', handleClickOutside);
218
261
  }, [editor, showToolbar]);
219
262
 
220
263
  const handleChange = (newLatex) => {
@@ -253,19 +296,28 @@ export const MathNodeView = (props) => {
253
296
  ReactDOM.createPortal(
254
297
  <div
255
298
  ref={toolbarRef}
299
+ data-toolbar-for={editor.instanceId}
256
300
  style={{
257
- position: 'absolute',
258
- top: `${position.top}px`,
259
- left: `${position.left}px`,
301
+ marginTop: '6px',
260
302
  zIndex: 20,
261
303
  background: 'var(--editable-html-toolbar-bg, #efefef)',
262
304
  boxShadow:
263
305
  '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)',
264
306
  }}
265
307
  >
266
- <MathToolbar latex={latex} autoFocus onChange={handleChange} onDone={handleDone} keypadMode="basic" />
308
+ <MathToolbar
309
+ latex={latex}
310
+ autoFocus
311
+ onChange={handleChange}
312
+ onDone={handleDone}
313
+ keypadMode={keypadMode}
314
+ controlledKeypadMode={controlledKeypadMode}
315
+ additionalKeys={generateAdditionalKeys(customKeys)}
316
+ keyPadCharacterRef={keyPadCharacterRef}
317
+ setKeypadInteraction={setKeypadInteraction}
318
+ />
267
319
  </div>,
268
- document.body,
320
+ editor?._tiptapContainerEl || document.body,
269
321
  )}
270
322
  </NodeViewWrapper>
271
323
  );
@@ -179,7 +179,7 @@ export default function MediaNodeView({ editor, node, updateAttributes, deleteNo
179
179
  <source type="audio/mp3" src={src} />
180
180
  </audio>
181
181
  ) : (
182
- <iframe src={src} allowFullScreen frameBorder="0" />
182
+ <iframe src={src} allowFullScreen frameBorder="0" width={width} height={height} />
183
183
  )}
184
184
 
185
185
  <MediaToolbar onEdit={handleEdit} onRemove={deleteNode} />
@@ -142,10 +142,12 @@ export const ResponseAreaExtension = Extension.create({
142
142
  }
143
143
 
144
144
  // --- Slate: indexing logic (kept identical) ---
145
- if (lastIndexMap[typeName] === undefined) lastIndexMap[typeName] = 0;
145
+ if (lastIndexMap[typeName] === undefined) {
146
+ lastIndexMap[typeName] = 0;
147
+ }
146
148
 
147
149
  const prevIndex = lastIndexMap[typeName];
148
- const newIndex = prevIndex === 0 ? prevIndex : prevIndex + 1;
150
+ const newIndex = prevIndex + 1;
149
151
 
150
152
  // Slate increments map even if newIndex === 0
151
153
  lastIndexMap[typeName] += 1;
@@ -156,7 +158,9 @@ export const ResponseAreaExtension = Extension.create({
156
158
  index: newIndex,
157
159
  });
158
160
 
159
- if (!newInline) return false;
161
+ if (!newInline) {
162
+ return false;
163
+ }
160
164
 
161
165
  // --- Insert logic ---
162
166
  const { selection } = state;
@@ -182,20 +186,18 @@ export const ResponseAreaExtension = Extension.create({
182
186
  if (usedPos == null) {
183
187
  usedPos = tryInsertAt(tr.doc.content.size);
184
188
  }
185
- if (usedPos == null) return false;
189
+
190
+ if (usedPos == null) {
191
+ return false;
192
+ }
186
193
 
187
194
  // Optionally select the node you just inserted (like your original command)
188
195
  // tr.setSelection(NodeSelection.create(tr.doc, usedPos))
189
196
 
190
197
  // --- Cursor move behavior for certain types (Slate: moveFocusTo next text) ---
191
- if (
192
- ['math_templated', 'inline_dropdown', 'drag_in_the_blank', 'explicit_constructed_response'].includes(
193
- typeName,
194
- )
195
- ) {
198
+ if (['math_templated', 'inline_dropdown', 'explicit_constructed_response'].includes(typeName)) {
196
199
  tr.setSelection(NodeSelection.create(tr.doc, usedPos));
197
200
  } else {
198
- // Default: put cursor after inserted node
199
201
  const after = usedPos + newInline.nodeSize;
200
202
  tr.setSelection(selectionAfterPos(tr.doc, after));
201
203
  }
@@ -214,7 +216,7 @@ export const ResponseAreaExtension = Extension.create({
214
216
  const node = selection.$from.nodeAfter;
215
217
  const nodePos = selection.from;
216
218
 
217
- tr.setNodeMarkup(nodePos, undefined, { ...node.attrs, updated: `${Date.now()}` });
219
+ tr.setNodeMarkup(nodePos, undefined, { ...node?.attrs, updated: `${Date.now()}` });
218
220
  tr.setSelection(NodeSelection.create(tr.doc, nodePos));
219
221
 
220
222
  if (dispatch) {
@@ -68,7 +68,7 @@ const styles = (theme) => ({
68
68
  background: 'var(--black)',
69
69
  borderRadius: '0.5rem',
70
70
  color: 'var(--white)',
71
- fontFamily: "'JetBrainsMono', monospace",
71
+ fontFamily: '\'JetBrainsMono\', monospace',
72
72
  margin: '1.5rem 0',
73
73
  padding: '0.75rem 1rem',
74
74
 
@@ -81,9 +81,10 @@ const styles = (theme) => ({
81
81
  },
82
82
 
83
83
  '& blockquote': {
84
- borderLeft: '3px solid var(--gray-3)',
85
- margin: '1.5rem 0',
86
- paddingLeft: '1rem',
84
+ background: '#f9f9f9',
85
+ borderLeft: '5px solid #ccc',
86
+ margin: '1.5em 10px',
87
+ padding: '.5em 10px',
87
88
  },
88
89
 
89
90
  '& hr': {
@@ -0,0 +1,17 @@
1
+ const escapeHtml = (str) =>
2
+ String(str)
3
+ .replace(/&/g, '&amp;')
4
+ .replace(/</g, '&lt;')
5
+ .replace(/>/g, '&gt;')
6
+ .replace(/"/g, '&quot;')
7
+ .replace(/'/g, '&#39;');
8
+
9
+ export const normalizeInitialMarkup = (markup) => {
10
+ const trimmed = String(markup ?? '').trim();
11
+ if (!trimmed) return '<div></div>';
12
+
13
+ const looksLikeHtml = /<[^>]+>/.test(trimmed);
14
+ if (looksLikeHtml) return trimmed;
15
+
16
+ return `<div>${escapeHtml(trimmed)}</div>`;
17
+ };