@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.
- package/lib/components/CharacterPicker.js +51 -20
- package/lib/components/CharacterPicker.js.map +1 -1
- package/lib/components/EditableHtml.js +37 -24
- package/lib/components/EditableHtml.js.map +1 -1
- package/lib/components/MenuBar.js +15 -6
- package/lib/components/MenuBar.js.map +1 -1
- package/lib/components/TiptapContainer.js +9 -1
- package/lib/components/TiptapContainer.js.map +1 -1
- package/lib/components/common/toolbar-buttons.js +5 -0
- package/lib/components/common/toolbar-buttons.js.map +1 -1
- package/lib/components/respArea/ExplicitConstructedResponse.js +2 -1
- package/lib/components/respArea/ExplicitConstructedResponse.js.map +1 -1
- package/lib/components/respArea/InlineDropdown.js +74 -16
- package/lib/components/respArea/InlineDropdown.js.map +1 -1
- package/lib/components/respArea/inlineDropdownUtils.js +67 -0
- package/lib/components/respArea/inlineDropdownUtils.js.map +1 -0
- package/lib/extensions/css.js +17 -7
- package/lib/extensions/css.js.map +1 -1
- package/lib/extensions/image-component.js +9 -6
- package/lib/extensions/image-component.js.map +1 -1
- package/lib/extensions/math.js +179 -71
- package/lib/extensions/math.js.map +1 -1
- package/lib/extensions/responseArea.js +7 -1
- package/lib/extensions/responseArea.js.map +1 -1
- package/lib/index.js +7 -0
- package/lib/index.js.map +1 -1
- package/lib/utils/toolbar.js +19 -0
- package/lib/utils/toolbar.js.map +1 -0
- package/package.json +18 -18
- package/src/__tests__/EditableHtml.test.jsx +116 -2
- package/src/components/CharacterPicker.jsx +56 -19
- package/src/components/EditableHtml.jsx +43 -23
- package/src/components/MenuBar.jsx +15 -3
- package/src/components/TiptapContainer.jsx +18 -2
- package/src/components/__tests__/CharacterPicker.test.jsx +57 -6
- package/src/components/__tests__/ExplicitConstructedResponse.test.jsx +6 -1
- package/src/components/__tests__/InlineDropdown.test.jsx +185 -8
- package/src/components/__tests__/MenuBar.test.jsx +1 -0
- package/src/components/common/toolbar-buttons.jsx +5 -0
- package/src/components/respArea/ExplicitConstructedResponse.jsx +2 -1
- package/src/components/respArea/InlineDropdown.jsx +97 -24
- package/src/components/respArea/inlineDropdownUtils.js +79 -0
- package/src/extensions/__tests__/image-component.test.jsx +1 -0
- package/src/extensions/__tests__/math.test.js +481 -42
- package/src/extensions/__tests__/responseArea.test.js +57 -3
- package/src/extensions/css.js +16 -3
- package/src/extensions/image-component.jsx +8 -6
- package/src/extensions/math.js +188 -69
- package/src/extensions/responseArea.js +7 -1
- package/src/index.jsx +2 -2
- package/src/utils/__tests__/toolbar.test.js +43 -0
- 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
|
|
271
|
+
type,
|
|
272
272
|
maxResponseAreas: 5,
|
|
273
273
|
},
|
|
274
274
|
};
|
|
275
275
|
const commands = ResponseAreaExtension.addCommands.call(context);
|
|
276
|
-
return commands.insertResponseArea(
|
|
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
|
});
|
package/src/extensions/css.js
CHANGED
|
@@ -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
|
-
|
|
179
|
-
|
|
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
|
|
165
|
-
|
|
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
|
|
package/src/extensions/math.js
CHANGED
|
@@ -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))
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
206
|
-
}, [showToolbar]);
|
|
266
|
+
setToolbarOpened(editor, selected || showToolbar);
|
|
267
|
+
}, [editor, showToolbar, selected]);
|
|
207
268
|
|
|
208
269
|
useEffect(() => {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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?.(
|
|
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=
|
|
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:
|
|
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
|
-
|
|
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
|
+
};
|