@zzish/math-rich-input 0.1.53 → 0.1.55
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 +130 -28
- package/dist/math-rich-input.css +1 -1
- package/dist/standalone.js +1 -1
- package/package.json +1 -1
- package/src/MathRichInput.css +24 -5
- package/src/MathRichInput.jsx +142 -30
- package/src/mathRichInputHelper.js +9 -0
- package/src/standalone.js +56 -17
package/package.json
CHANGED
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
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
getPlainTextFromEditableDiv,
|
|
34
34
|
globalOffsetToPlainTextOffset,
|
|
35
35
|
plainTextOffsetToGlobalOffset,
|
|
36
|
+
SELECTION_MARK,
|
|
36
37
|
} from "./mathRichInputHelper";
|
|
37
38
|
import {
|
|
38
39
|
debugMathRichInput,
|
|
@@ -2338,30 +2339,18 @@ export default class MathRichInput extends React.Component {
|
|
|
2338
2339
|
let pasteHtml = clipboardData.getData("text/html");
|
|
2339
2340
|
let pasteText = clipboardData.getData("text/plain");
|
|
2340
2341
|
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
// Read the caret BEFORE, so there is something to fall back on, and AGAIN after the delete.
|
|
2354
|
-
const paramsBeforeDelete = this._getRangeParams();
|
|
2355
|
-
|
|
2356
|
-
const selection = window.getSelection();
|
|
2357
|
-
if (selection && selection.rangeCount > 0) {
|
|
2358
|
-
selection.deleteFromDocument();
|
|
2359
|
-
}
|
|
2360
|
-
|
|
2361
|
-
// The caret AFTER the delete is the one that counts: `rangeParams` is a node index, and the delete
|
|
2362
|
-
// removed the very nodes it counts. The pre-delete value is kept only as a fallback — bailing out
|
|
2363
|
-
// here having already emptied the selection is how a paste turns into a deletion.
|
|
2364
|
-
const rangeParams = this._getRangeParams() || paramsBeforeDelete;
|
|
2342
|
+
/*
|
|
2343
|
+
* The caret is read BEFORE the selection is removed, and that order is deliberate.
|
|
2344
|
+
*
|
|
2345
|
+
* Reading it afterwards looks more correct — `deleteFromDocument()` removes the very nodes the
|
|
2346
|
+
* index counts — and it was tried, on the theory that a stale index was why pasting over a
|
|
2347
|
+
* selection emptied the field. It was not: the field emptied because React never wrote the
|
|
2348
|
+
* content back (see `reconcileEditableDomWithRender`), and the reordering was left in on a theory
|
|
2349
|
+
* that had already been disproved. In a host whose field holds unwrapped text rather than a `<p>`,
|
|
2350
|
+
* the emptied div yields a caret that `elementToMarkedRawText` cannot mark at all — no mark, so the
|
|
2351
|
+
* pasted text is inserted nowhere and the paste becomes a deletion.
|
|
2352
|
+
*/
|
|
2353
|
+
const rangeParams = this._getRangeParams();
|
|
2365
2354
|
if (!rangeParams) {
|
|
2366
2355
|
console.warn("Could not get range params for paste");
|
|
2367
2356
|
return;
|
|
@@ -2370,6 +2359,12 @@ export default class MathRichInput extends React.Component {
|
|
|
2370
2359
|
// Store old range params for cursor positioning (like equation editor)
|
|
2371
2360
|
this.setOldRangeParams(rangeParams);
|
|
2372
2361
|
|
|
2362
|
+
// Clear any existing selection
|
|
2363
|
+
const selection = window.getSelection();
|
|
2364
|
+
if (selection && selection.rangeCount > 0) {
|
|
2365
|
+
selection.deleteFromDocument();
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2373
2368
|
let finalText = "";
|
|
2374
2369
|
let finalMimeType = this.props.mimeType || "text/html";
|
|
2375
2370
|
let hasMathContent = false;
|
|
@@ -2478,19 +2473,84 @@ export default class MathRichInput extends React.Component {
|
|
|
2478
2473
|
|
|
2479
2474
|
// Use the component's insert mechanism to handle paste properly
|
|
2480
2475
|
if (finalText) {
|
|
2481
|
-
|
|
2482
|
-
|
|
2476
|
+
/*
|
|
2477
|
+
* WHERE TO PUT THE PASTED TEXT, found in the document rather than by index.
|
|
2478
|
+
*
|
|
2479
|
+
* `elementToMarkedRawText` marks ONE position — a node and an offset — and the selection has to
|
|
2480
|
+
* be gone from the document before that position means anything. It was, and then the position
|
|
2481
|
+
* was looked up by INDEX, which is where every version of this went wrong: an index read before
|
|
2482
|
+
* the delete counts nodes the delete then removes, and an index read after it does not resolve in
|
|
2483
|
+
* a field whose content is unwrapped text. Either way `_findNodeWithIndex` returns nothing, no
|
|
2484
|
+
* mark is placed, `insertCharacterBeforeMarks` warns "Start: -1" and returns the text UNCHANGED —
|
|
2485
|
+
* so the pasted words go nowhere, and what the teacher sees is their field emptied.
|
|
2486
|
+
*
|
|
2487
|
+
* The browser's own selection is the answer: after `deleteFromDocument()` it is left collapsed at
|
|
2488
|
+
* exactly the point where the pasted text belongs, in a node that exists by construction. The
|
|
2489
|
+
* index lookup stays as a fallback for the case where the selection has gone elsewhere.
|
|
2490
|
+
*/
|
|
2491
|
+
const live = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
|
2492
|
+
const liveNode =
|
|
2493
|
+
live && this.editableDiv.contains(live.startContainer) ? live.startContainer : null;
|
|
2494
|
+
const currentNode =
|
|
2495
|
+
liveNode !== null ? liveNode : this._findNodeWithIndex(rangeParams.startNodeIndex);
|
|
2496
|
+
const currentOffset = liveNode !== null ? live.startOffset : rangeParams.startOffset;
|
|
2497
|
+
|
|
2483
2498
|
const markedText = elementToMarkedRawText(
|
|
2484
2499
|
this.editableDiv,
|
|
2485
2500
|
currentNode,
|
|
2486
|
-
|
|
2501
|
+
currentOffset,
|
|
2487
2502
|
this.enableHtml()
|
|
2488
2503
|
);
|
|
2489
2504
|
|
|
2505
|
+
/*
|
|
2506
|
+
* A FIELD WITH NOTHING LEFT IN IT NEEDS NO MARK.
|
|
2507
|
+
*
|
|
2508
|
+
* Marking only works on a TEXT node, and an emptied field has none: the browser leaves the caret
|
|
2509
|
+
* in the element itself, so `elementToMarkedRawText` has nothing to mark and returns text with no
|
|
2510
|
+
* mark in it. `insertCharacterBeforeMarks` then warns and hands back its input UNCHANGED, which
|
|
2511
|
+
* reads as success — the value comes out valid with the pasted words missing, and the teacher
|
|
2512
|
+
* watches their field empty itself.
|
|
2513
|
+
*
|
|
2514
|
+
* But an empty field after the delete says something exact: the selection covered everything, so
|
|
2515
|
+
* the result of the paste IS the pasted text. Nothing needs to be worked out. This is the
|
|
2516
|
+
* select-all-and-paste that every report of this bug has been.
|
|
2517
|
+
*/
|
|
2518
|
+
const remainingText = this.editableDiv.textContent
|
|
2519
|
+
.split(SMALL_SPACE)
|
|
2520
|
+
.join("")
|
|
2521
|
+
.trim();
|
|
2522
|
+
const fieldIsEmpty =
|
|
2523
|
+
remainingText === "" && this.editableDiv.querySelector(".katex") === null;
|
|
2524
|
+
const marked = markedText.indexOf(SELECTION_MARK) !== -1;
|
|
2525
|
+
|
|
2526
|
+
/*
|
|
2527
|
+
* NOTHING IS BETTER THAN DESTRUCTION.
|
|
2528
|
+
*
|
|
2529
|
+
* No mark and content still standing means the caret cannot be located inside text that must be
|
|
2530
|
+
* kept. Carrying on would write a value with the pasted text missing over a field whose selection
|
|
2531
|
+
* has already been removed from the document — a paste that performs a deletion. Stopping leaves
|
|
2532
|
+
* `props.value` untouched and `reconcileEditableDomWithRender` puts the document back from it:
|
|
2533
|
+
* the paste does nothing, which is a disappointment rather than a loss.
|
|
2534
|
+
*/
|
|
2535
|
+
if (!marked && !fieldIsEmpty) {
|
|
2536
|
+
console.warn(
|
|
2537
|
+
"[math-rich-input] paste found nowhere to insert; the field is left as it was",
|
|
2538
|
+
{ rangeParams, hadLiveSelection: liveNode !== null }
|
|
2539
|
+
);
|
|
2540
|
+
queueMicrotask(() => {
|
|
2541
|
+
try {
|
|
2542
|
+
this.reconcileEditableDomWithRender(true);
|
|
2543
|
+
} catch (error) {
|
|
2544
|
+
console.error(error);
|
|
2545
|
+
}
|
|
2546
|
+
});
|
|
2547
|
+
return;
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2490
2550
|
// Insert the new content at the marked position
|
|
2491
|
-
let newRawText =
|
|
2492
|
-
insertCharacterBeforeMarks(markedText, finalText)
|
|
2493
|
-
|
|
2551
|
+
let newRawText = marked
|
|
2552
|
+
? removeMarks(insertCharacterBeforeMarks(markedText, finalText))
|
|
2553
|
+
: finalText;
|
|
2494
2554
|
// Convert any remaining \[...\] LaTeX expressions to <math>...</math> format
|
|
2495
2555
|
// This ensures consistency when paste adds <math> tags alongside existing \[...\] expressions
|
|
2496
2556
|
if (this.enableMath()) {
|
|
@@ -2666,6 +2726,24 @@ export default class MathRichInput extends React.Component {
|
|
|
2666
2726
|
return true;
|
|
2667
2727
|
}
|
|
2668
2728
|
|
|
2729
|
+
/**
|
|
2730
|
+
* Whether the field holds nothing a teacher would call content.
|
|
2731
|
+
*
|
|
2732
|
+
* NOT `:empty`, which is what the stylesheet asked and why the placeholder has never once been seen.
|
|
2733
|
+
* An empty field here is not an empty element: the value renders as a paragraph carrying a single
|
|
2734
|
+
* zero-width space, so the element always has a child and the CSS rule never matched. The hint was
|
|
2735
|
+
* written, translated, passed down through every field — and drawn nowhere.
|
|
2736
|
+
*
|
|
2737
|
+
* Markup is stripped rather than counted: `<p>` and `<br>` are the shape of emptiness, not content.
|
|
2738
|
+
* Maths and images are content even though stripping tags would leave nothing behind, so they are
|
|
2739
|
+
* asked about directly.
|
|
2740
|
+
*/
|
|
2741
|
+
isVisuallyEmpty() {
|
|
2742
|
+
const value = this.props.value || "";
|
|
2743
|
+
if (/<(math|img)\b/i.test(value)) return false;
|
|
2744
|
+
return value.replace(/<[^>]*>/g, "").split(SMALL_SPACE).join("").trim().length === 0;
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2669
2747
|
/**
|
|
2670
2748
|
* The browser's own reading of an HTML string.
|
|
2671
2749
|
*
|
|
@@ -3968,6 +4046,40 @@ export default class MathRichInput extends React.Component {
|
|
|
3968
4046
|
<>
|
|
3969
4047
|
<div className={useClassName}>
|
|
3970
4048
|
<div className="MathRichInput-scrollview">
|
|
4049
|
+
{/*
|
|
4050
|
+
THE HINT IS A REAL ELEMENT, and it is NOT inside the editable.
|
|
4051
|
+
|
|
4052
|
+
It was a `::before` on the editable span, shown by `:empty`. Two things were wrong with that
|
|
4053
|
+
and each alone was enough. `:empty` is never true here — an empty field still holds a
|
|
4054
|
+
paragraph with a zero-width space in it — and a pseudo-element on a `contenteditable` host is
|
|
4055
|
+
not something browsers draw dependably; it also sits in the same box a caret is moving
|
|
4056
|
+
through. The hint was written, translated and passed down through every field, and drawn in
|
|
4057
|
+
none of them.
|
|
4058
|
+
|
|
4059
|
+
Beside the editable and laid over it, it is ordinary content: it can be inspected, it cannot
|
|
4060
|
+
affect the caret, and it disappears the moment the field has something in it or the teacher
|
|
4061
|
+
starts typing.
|
|
4062
|
+
*/}
|
|
4063
|
+
{this.props.placeholder && this.isVisuallyEmpty() && !this.state.hasFocus && (
|
|
4064
|
+
/*
|
|
4065
|
+
* CARRIED BY ITS OWN ANCHOR, a box of no size at the start of the row.
|
|
4066
|
+
*
|
|
4067
|
+
* An absolutely positioned element hangs off the nearest positioned ancestor, and which one
|
|
4068
|
+
* that is belongs to whoever is embedding the field: a host may take the positioning off
|
|
4069
|
+
* this component's own boxes to anchor something else of its own — the toolbar, say — and
|
|
4070
|
+
* the hint then measures from the host's outer card instead of the field, landing on top of
|
|
4071
|
+
* whatever the host has put to the left of it.
|
|
4072
|
+
*
|
|
4073
|
+
* A wrapper of its own settles it. Zero-sized, so it displaces nothing, and positioned, so
|
|
4074
|
+
* it is what the hint measures from — which puts the words where the first character goes,
|
|
4075
|
+
* whatever the host has done around it.
|
|
4076
|
+
*/
|
|
4077
|
+
<div className="MathRichInput-hintlayer">
|
|
4078
|
+
<div className="MathRichInput-hint" aria-hidden="true">
|
|
4079
|
+
{this.props.placeholder}
|
|
4080
|
+
</div>
|
|
4081
|
+
</div>
|
|
4082
|
+
)}
|
|
3971
4083
|
{this.state.hasFocus && (
|
|
3972
4084
|
<Toolbar
|
|
3973
4085
|
className="Toolbar"
|
|
@@ -1582,3 +1582,12 @@ export function plainTextOffsetToGlobalOffset(editableDiv, plainTextOffset) {
|
|
|
1582
1582
|
let plainText = getPlainTextFromEditableDiv(editableDiv);
|
|
1583
1583
|
return Math.min(plainTextOffset, plainText.length);
|
|
1584
1584
|
}
|
|
1585
|
+
|
|
1586
|
+
/**
|
|
1587
|
+
* The character used to mark a position in text while content is being rewritten.
|
|
1588
|
+
*
|
|
1589
|
+
* Exported so a caller can tell whether a mark was actually placed. `insertCharacterBeforeMarks` warns
|
|
1590
|
+
* and returns its input UNCHANGED when there is no mark, which reads as success at the call site — the
|
|
1591
|
+
* value comes back looking valid with the inserted text silently missing.
|
|
1592
|
+
*/
|
|
1593
|
+
export const SELECTION_MARK = MARK;
|
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() {
|