@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.
- package/CHANGELOG.md +17 -0
- package/lib/components/EditableHtml.js +29 -19
- package/lib/components/EditableHtml.js.map +1 -1
- package/lib/components/respArea/DragInTheBlank/choice.js +3 -0
- package/lib/components/respArea/DragInTheBlank/choice.js.map +1 -1
- package/lib/components/respArea/InlineDropdown.js +11 -1
- package/lib/components/respArea/InlineDropdown.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 +37 -11
- package/lib/extensions/math.js.map +1 -1
- package/lib/extensions/responseArea.js +7 -1
- package/lib/extensions/responseArea.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/EditableHtml.test.jsx +34 -0
- package/src/components/EditableHtml.jsx +37 -19
- package/src/components/__tests__/InlineDropdown.test.jsx +54 -1
- package/src/components/respArea/DragInTheBlank/__tests__/choice.test.jsx +72 -0
- package/src/components/respArea/DragInTheBlank/choice.jsx +3 -0
- package/src/components/respArea/InlineDropdown.jsx +13 -6
- package/src/extensions/__tests__/image-component.test.jsx +1 -0
- package/src/extensions/__tests__/math.test.js +2 -0
- package/src/extensions/__tests__/responseArea.test.js +57 -3
- package/src/extensions/image-component.jsx +8 -6
- package/src/extensions/math.js +30 -8
- package/src/extensions/responseArea.js +7 -1
|
@@ -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:
|
|
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(
|
|
187
|
-
ExplicitConstructedResponseNode.configure(
|
|
188
|
-
DragInTheBlankNode.configure(
|
|
189
|
-
InlineDropdownNode.configure(
|
|
190
|
-
MathTemplatedNode.configure(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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',
|
|
@@ -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
|
|
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
|
});
|
|
@@ -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
|
@@ -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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|