@zzish/math-rich-input 0.1.54 → 0.1.56
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/dist/index.js +71 -5
- package/dist/math-rich-input.css +1 -1
- package/dist/standalone.js +1 -1
- package/package.json +4 -2
- package/src/MathRichInput.css +24 -5
- package/src/MathRichInput.jsx +87 -5
- package/src/standalone.js +56 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zzish/math-rich-input",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.56",
|
|
4
4
|
"description": "React component for user to enter rich text with embedded math equations.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
},
|
|
14
14
|
"main": "dist/index.js",
|
|
15
15
|
"scripts": {
|
|
16
|
+
"test": "playwright test tests/caret-regression.spec.js --workers=1",
|
|
16
17
|
"build": "rollup --config && webpack --config webpack.standalone.config.js",
|
|
17
18
|
"build:legacy": "rollup --config",
|
|
18
19
|
"build:standalone": "webpack --config webpack.standalone.config.js",
|
|
19
|
-
"prepublishOnly": "npm run build"
|
|
20
|
+
"prepublishOnly": "npm test && npm run build"
|
|
20
21
|
},
|
|
21
22
|
"dependencies": {
|
|
22
23
|
"katex": "^0.12.0",
|
|
@@ -36,6 +37,7 @@
|
|
|
36
37
|
"@babel/preset-env": "^7.24.5",
|
|
37
38
|
"@babel/preset-react": "^7.24.1",
|
|
38
39
|
"@babel/register": "^7.13.8",
|
|
40
|
+
"@playwright/test": "^1.62.1",
|
|
39
41
|
"@rollup/plugin-babel": "^5.3.0",
|
|
40
42
|
"@rollup/plugin-commonjs": "^17.1.0",
|
|
41
43
|
"@rollup/plugin-node-resolve": "^11.2.0",
|
package/src/MathRichInput.css
CHANGED
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
.MathRichInput {
|
|
26
|
+
/* The hint below is laid over this box rather than inside its flow — see `.is-empty::before`. */
|
|
27
|
+
position: relative;
|
|
26
28
|
font-size: 18px;
|
|
27
29
|
padding: 10px;
|
|
28
30
|
width: 100%;
|
|
@@ -32,13 +34,30 @@
|
|
|
32
34
|
overflow: overlay;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
/*
|
|
38
|
+
* The hint shown in a field with nothing in it.
|
|
39
|
+
*
|
|
40
|
+
* Laid over the editable, never inside it: a caret shares that box, and a pseudo-element in a
|
|
41
|
+
* `contenteditable` host is not drawn dependably. Placed at the field's own padding so the words start
|
|
42
|
+
* exactly where the first character will.
|
|
43
|
+
*/
|
|
44
|
+
.MathRichInput-hintlayer {
|
|
45
|
+
position: relative;
|
|
46
|
+
width: 0;
|
|
47
|
+
height: 0;
|
|
48
|
+
flex: 0 0 auto;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.MathRichInput-hint {
|
|
52
|
+
position: absolute;
|
|
53
|
+
top: 10px;
|
|
54
|
+
left: 10px;
|
|
37
55
|
color: #acb6c0;
|
|
38
56
|
font-size: 15px;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
57
|
+
/* The field underneath owns every click. */
|
|
58
|
+
pointer-events: none;
|
|
59
|
+
user-select: none;
|
|
60
|
+
white-space: nowrap;
|
|
42
61
|
}
|
|
43
62
|
|
|
44
63
|
.MathRichInput:focus {
|
package/src/MathRichInput.jsx
CHANGED
|
@@ -84,6 +84,7 @@ const EMPTY_INLINE_FORMATTING_REG_EXP =
|
|
|
84
84
|
// const LATEX_MARKER_END = "</annotation>"
|
|
85
85
|
|
|
86
86
|
const MAX_HISTORY_LENGTH = 100;
|
|
87
|
+
const CONTROLLED_VALUE_ECHO_TIMEOUT_MS = 50;
|
|
87
88
|
|
|
88
89
|
const DEFAULT_OPTIONS = {
|
|
89
90
|
useExpertMode: false,
|
|
@@ -367,6 +368,21 @@ export default class MathRichInput extends React.Component {
|
|
|
367
368
|
|
|
368
369
|
// Call the parent handler to apply the changes
|
|
369
370
|
this.onChangeCallback({ value, mimeType }, { isExpertMode, selectedTab });
|
|
371
|
+
if (applyOptions.skipControlledRender === true) {
|
|
372
|
+
const pendingNativeValue = value;
|
|
373
|
+
// Give a scheduled controlled echo one short render window. If the host
|
|
374
|
+
// never echoes the value, release the gate rather than preserving a
|
|
375
|
+
// rejected native edit indefinitely.
|
|
376
|
+
setTimeout(() => {
|
|
377
|
+
if (
|
|
378
|
+
this.skipNextControlledValueRender === true &&
|
|
379
|
+
this.skipNextControlledValue === pendingNativeValue
|
|
380
|
+
) {
|
|
381
|
+
this.skipNextControlledValueRender = false;
|
|
382
|
+
this.skipNextControlledValue = null;
|
|
383
|
+
}
|
|
384
|
+
}, CONTROLLED_VALUE_ECHO_TIMEOUT_MS);
|
|
385
|
+
}
|
|
370
386
|
debugMathRichInput("applyChangesToComponent:after-onChange", {
|
|
371
387
|
afterComponentUpdateData: this.afterComponentUpdateData,
|
|
372
388
|
selection: getSelectionSnapshot(this.editableDiv),
|
|
@@ -587,6 +603,9 @@ export default class MathRichInput extends React.Component {
|
|
|
587
603
|
? this.getCurrentRawTextForProps(this.props)
|
|
588
604
|
: null;
|
|
589
605
|
const liveEditableHtml = this.editableDiv ? this.editableDiv.innerHTML : null;
|
|
606
|
+
const pendingNativeInputMatchesLiveDom =
|
|
607
|
+
this.skipNextControlledValueRender === true &&
|
|
608
|
+
currentRawText === this.skipNextControlledValue;
|
|
590
609
|
const canPreserveNativeDom =
|
|
591
610
|
this.state.hasFocus === true &&
|
|
592
611
|
this.editableDiv !== null &&
|
|
@@ -594,7 +613,7 @@ export default class MathRichInput extends React.Component {
|
|
|
594
613
|
(this.afterComponentUpdateData === null ||
|
|
595
614
|
this.afterComponentUpdateData === undefined) &&
|
|
596
615
|
this.lastRenderedEditableHtml !== null &&
|
|
597
|
-
currentRawText === this.props.value;
|
|
616
|
+
(currentRawText === this.props.value || pendingNativeInputMatchesLiveDom);
|
|
598
617
|
const preserveRangeParams = canPreserveNativeDom
|
|
599
618
|
? this.getRangeParamsPreservingInlinePlaceholder()
|
|
600
619
|
: null;
|
|
@@ -609,6 +628,7 @@ export default class MathRichInput extends React.Component {
|
|
|
609
628
|
liveEditableHtml,
|
|
610
629
|
lastRenderedEditableHtml: this.lastRenderedEditableHtml,
|
|
611
630
|
currentRawText,
|
|
631
|
+
pendingNativeInputMatchesLiveDom,
|
|
612
632
|
preserveRangeParams,
|
|
613
633
|
afterComponentUpdateData: this.afterComponentUpdateData,
|
|
614
634
|
hasFocus: this.state.hasFocus,
|
|
@@ -2694,22 +2714,32 @@ export default class MathRichInput extends React.Component {
|
|
|
2694
2714
|
|
|
2695
2715
|
shouldComponentUpdate(nextProps, nextState) {
|
|
2696
2716
|
if (this.skipNextControlledValueRender === true) {
|
|
2717
|
+
const controlledValueArrived =
|
|
2718
|
+
nextProps.value === this.skipNextControlledValue;
|
|
2697
2719
|
const canSkip =
|
|
2698
|
-
|
|
2699
|
-
nextState === this.state;
|
|
2720
|
+
controlledValueArrived && nextState === this.state;
|
|
2700
2721
|
|
|
2701
2722
|
debugMathRichInput("shouldComponentUpdate:native-input-gate", {
|
|
2702
2723
|
canSkip,
|
|
2703
2724
|
nextValue: nextProps.value,
|
|
2704
2725
|
skipNextControlledValue: this.skipNextControlledValue,
|
|
2726
|
+
controlledValueArrived,
|
|
2705
2727
|
currentPropsValue: this.props.value,
|
|
2706
2728
|
stateChanged: nextState !== this.state,
|
|
2707
2729
|
innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
|
|
2708
2730
|
selection: getSelectionSnapshot(this.editableDiv),
|
|
2709
2731
|
});
|
|
2710
2732
|
|
|
2711
|
-
|
|
2712
|
-
this.
|
|
2733
|
+
// A controlled host may commit an unrelated render before it echoes the
|
|
2734
|
+
// value from this native input event. Keep the gate armed through that
|
|
2735
|
+
// stale render so getEditableHtmlForRender can preserve the browser DOM
|
|
2736
|
+
// and its selection. The time-bounded fallback armed by
|
|
2737
|
+
// applyChangesToComponent keeps a host that rejects the value from
|
|
2738
|
+
// leaving the gate armed indefinitely.
|
|
2739
|
+
if (controlledValueArrived) {
|
|
2740
|
+
this.skipNextControlledValueRender = false;
|
|
2741
|
+
this.skipNextControlledValue = null;
|
|
2742
|
+
}
|
|
2713
2743
|
|
|
2714
2744
|
if (canSkip) return false;
|
|
2715
2745
|
}
|
|
@@ -2726,6 +2756,24 @@ export default class MathRichInput extends React.Component {
|
|
|
2726
2756
|
return true;
|
|
2727
2757
|
}
|
|
2728
2758
|
|
|
2759
|
+
/**
|
|
2760
|
+
* Whether the field holds nothing a teacher would call content.
|
|
2761
|
+
*
|
|
2762
|
+
* NOT `:empty`, which is what the stylesheet asked and why the placeholder has never once been seen.
|
|
2763
|
+
* An empty field here is not an empty element: the value renders as a paragraph carrying a single
|
|
2764
|
+
* zero-width space, so the element always has a child and the CSS rule never matched. The hint was
|
|
2765
|
+
* written, translated, passed down through every field — and drawn nowhere.
|
|
2766
|
+
*
|
|
2767
|
+
* Markup is stripped rather than counted: `<p>` and `<br>` are the shape of emptiness, not content.
|
|
2768
|
+
* Maths and images are content even though stripping tags would leave nothing behind, so they are
|
|
2769
|
+
* asked about directly.
|
|
2770
|
+
*/
|
|
2771
|
+
isVisuallyEmpty() {
|
|
2772
|
+
const value = this.props.value || "";
|
|
2773
|
+
if (/<(math|img)\b/i.test(value)) return false;
|
|
2774
|
+
return value.replace(/<[^>]*>/g, "").split(SMALL_SPACE).join("").trim().length === 0;
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2729
2777
|
/**
|
|
2730
2778
|
* The browser's own reading of an HTML string.
|
|
2731
2779
|
*
|
|
@@ -4028,6 +4076,40 @@ export default class MathRichInput extends React.Component {
|
|
|
4028
4076
|
<>
|
|
4029
4077
|
<div className={useClassName}>
|
|
4030
4078
|
<div className="MathRichInput-scrollview">
|
|
4079
|
+
{/*
|
|
4080
|
+
THE HINT IS A REAL ELEMENT, and it is NOT inside the editable.
|
|
4081
|
+
|
|
4082
|
+
It was a `::before` on the editable span, shown by `:empty`. Two things were wrong with that
|
|
4083
|
+
and each alone was enough. `:empty` is never true here — an empty field still holds a
|
|
4084
|
+
paragraph with a zero-width space in it — and a pseudo-element on a `contenteditable` host is
|
|
4085
|
+
not something browsers draw dependably; it also sits in the same box a caret is moving
|
|
4086
|
+
through. The hint was written, translated and passed down through every field, and drawn in
|
|
4087
|
+
none of them.
|
|
4088
|
+
|
|
4089
|
+
Beside the editable and laid over it, it is ordinary content: it can be inspected, it cannot
|
|
4090
|
+
affect the caret, and it disappears the moment the field has something in it or the teacher
|
|
4091
|
+
starts typing.
|
|
4092
|
+
*/}
|
|
4093
|
+
{this.props.placeholder && this.isVisuallyEmpty() && !this.state.hasFocus && (
|
|
4094
|
+
/*
|
|
4095
|
+
* CARRIED BY ITS OWN ANCHOR, a box of no size at the start of the row.
|
|
4096
|
+
*
|
|
4097
|
+
* An absolutely positioned element hangs off the nearest positioned ancestor, and which one
|
|
4098
|
+
* that is belongs to whoever is embedding the field: a host may take the positioning off
|
|
4099
|
+
* this component's own boxes to anchor something else of its own — the toolbar, say — and
|
|
4100
|
+
* the hint then measures from the host's outer card instead of the field, landing on top of
|
|
4101
|
+
* whatever the host has put to the left of it.
|
|
4102
|
+
*
|
|
4103
|
+
* A wrapper of its own settles it. Zero-sized, so it displaces nothing, and positioned, so
|
|
4104
|
+
* it is what the hint measures from — which puts the words where the first character goes,
|
|
4105
|
+
* whatever the host has done around it.
|
|
4106
|
+
*/
|
|
4107
|
+
<div className="MathRichInput-hintlayer">
|
|
4108
|
+
<div className="MathRichInput-hint" aria-hidden="true">
|
|
4109
|
+
{this.props.placeholder}
|
|
4110
|
+
</div>
|
|
4111
|
+
</div>
|
|
4112
|
+
)}
|
|
4031
4113
|
{this.state.hasFocus && (
|
|
4032
4114
|
<Toolbar
|
|
4033
4115
|
className="Toolbar"
|
package/src/standalone.js
CHANGED
|
@@ -31,12 +31,32 @@ class MathRichInputElement extends HTMLElement {
|
|
|
31
31
|
this.lastRenderTime = 0;
|
|
32
32
|
this.renderThrottleMs = 16; // ~60fps throttle
|
|
33
33
|
this.isDuringDrag = false;
|
|
34
|
+
/** Pending teardown, cancelled when a disconnect turns out to have been a move. */
|
|
35
|
+
this.teardownTimeout = null;
|
|
34
36
|
|
|
35
37
|
// Setup global drag event listeners once
|
|
36
38
|
this.setupDragListeners();
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
connectedCallback() {
|
|
42
|
+
/*
|
|
43
|
+
* A MOVE IS A DISCONNECT FOLLOWED BY A CONNECT, and it must not cost anything.
|
|
44
|
+
*
|
|
45
|
+
* Reordering a list moves the row, and moving a row takes this element out of the document and puts
|
|
46
|
+
* it back. Torn down in between, the field empties: the row loses its height, everything below it
|
|
47
|
+
* jumps up, and it all lands again when the field has re-rendered. In a drag that happens on every
|
|
48
|
+
* crossing, which reads as the list bouncing.
|
|
49
|
+
*
|
|
50
|
+
* The teardown is scheduled rather than done, so a return within the same turn simply cancels it and
|
|
51
|
+
* the field never notices it was moved. A real removal has no such return, and tears down as before.
|
|
52
|
+
*/
|
|
53
|
+
if (this.teardownTimeout) {
|
|
54
|
+
clearTimeout(this.teardownTimeout);
|
|
55
|
+
this.teardownTimeout = null;
|
|
56
|
+
globalDragState.disconnectedElements.delete(this);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
40
60
|
// Handle reconnection after disconnect (common in SortableJS)
|
|
41
61
|
this.handleReconnection();
|
|
42
62
|
|
|
@@ -51,26 +71,45 @@ class MathRichInputElement extends HTMLElement {
|
|
|
51
71
|
// Track this element as disconnected (for potential reconnection)
|
|
52
72
|
globalDragState.disconnectedElements.add(this);
|
|
53
73
|
|
|
54
|
-
//
|
|
55
|
-
if
|
|
56
|
-
|
|
57
|
-
this.
|
|
58
|
-
|
|
74
|
+
// Nothing is torn down yet — see `connectedCallback`. If this is a move, the element is back before
|
|
75
|
+
// this runs and cancels it; if it is a real removal, it runs and everything goes as it always did.
|
|
76
|
+
this.teardownTimeout = setTimeout(() => {
|
|
77
|
+
this.teardownTimeout = null;
|
|
78
|
+
|
|
79
|
+
/*
|
|
80
|
+
* ASK THE DOCUMENT, do not trust the timing.
|
|
81
|
+
*
|
|
82
|
+
* Cancelling on reconnect covers a move that finishes in the same turn. A framework is free to take
|
|
83
|
+
* the element out and put it back a turn later — which is still a move, and tearing down in between
|
|
84
|
+
* empties the field, collapses the row and jumps everything below it. The element itself knows
|
|
85
|
+
* whether it ended up back in the document, so that is what decides.
|
|
86
|
+
*/
|
|
87
|
+
if (this.isConnected) return;
|
|
88
|
+
|
|
89
|
+
// Clear any pending render timeouts
|
|
90
|
+
if (this.renderTimeout) {
|
|
91
|
+
clearTimeout(this.renderTimeout);
|
|
92
|
+
this.renderTimeout = null;
|
|
93
|
+
}
|
|
59
94
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
95
|
+
// Reset state flags
|
|
96
|
+
this.isRendering = false;
|
|
97
|
+
this.isDuringDrag = false;
|
|
63
98
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
99
|
+
// Unmount React root
|
|
100
|
+
if (this.root) {
|
|
101
|
+
try {
|
|
102
|
+
this.root.unmount();
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.error("❌ MathRichInput: Error unmounting React root:", error);
|
|
105
|
+
} finally {
|
|
106
|
+
this.root = null;
|
|
107
|
+
}
|
|
72
108
|
}
|
|
73
|
-
|
|
109
|
+
// Long enough for a framework to finish a move it makes in more than one step — a reorder can
|
|
110
|
+
// remove the row and put it back in separate turns, and a teardown landing between the two rebuilds
|
|
111
|
+
// the field for nothing. A genuine removal simply tears down a fraction of a second later.
|
|
112
|
+
}, 60);
|
|
74
113
|
}
|
|
75
114
|
|
|
76
115
|
static get observedAttributes() {
|