@zzish/math-rich-input 0.1.52 → 0.1.54

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zzish/math-rich-input",
3
- "version": "0.1.52",
3
+ "version": "0.1.54",
4
4
  "description": "React component for user to enter rich text with embedded math equations.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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,
@@ -1972,6 +1973,10 @@ export default class MathRichInput extends React.Component {
1972
1973
 
1973
1974
  // Stop editing if within rendered katex node, if somehow the cursor ends up there (it shouldn't)
1974
1975
  let params = this._getRangeParams();
1976
+ // No range means no caret to protect — a field the browser has not placed a selection in yet. It
1977
+ // reads as null on the first keystroke after a click on a toolbar button, and dereferencing it threw
1978
+ // on every Cmd, every Cmd+A and every Cmd+V the teacher pressed.
1979
+ if (params === null || params === undefined) return;
1975
1980
  let startNode = this._findNodeWithIndex(params.startNodeIndex);
1976
1981
  let endNode = this._findNodeWithIndex(params.endNodeIndex);
1977
1982
  if (!this.editableDivIsPlainText()) {
@@ -2028,7 +2033,12 @@ export default class MathRichInput extends React.Component {
2028
2033
  // If the cursor is in a katex node, then move it to the next node (unless there is no
2029
2034
  // net node in which case move it to the previous node)
2030
2035
  if (e.key === "ArrowRight") {
2036
+ // `_getRangeParams` returns null whenever the document's selection is not inside this field —
2037
+ // which happens in ordinary use, not only in error: a toolbar button taking focus is enough. It
2038
+ // was dereferenced straight away, so the key handler THREW and everything after it in the same
2039
+ // press was skipped.
2031
2040
  let rangeParams = this._getRangeParams();
2041
+ if (!rangeParams) return;
2032
2042
  let index = rangeParams.startNodeIndex;
2033
2043
 
2034
2044
  if (index >= 0) {
@@ -2063,7 +2073,9 @@ export default class MathRichInput extends React.Component {
2063
2073
  }
2064
2074
 
2065
2075
  if (e.key === "ArrowLeft") {
2076
+ // Same guard, same reason — see ArrowRight above.
2066
2077
  let rangeParams = this._getRangeParams();
2078
+ if (!rangeParams) return;
2067
2079
  let index = rangeParams.startNodeIndex;
2068
2080
 
2069
2081
  // If the cursor is in a katex node, then move it to the previous node (unless there is no
@@ -2327,7 +2339,17 @@ export default class MathRichInput extends React.Component {
2327
2339
  let pasteHtml = clipboardData.getData("text/html");
2328
2340
  let pasteText = clipboardData.getData("text/plain");
2329
2341
 
2330
- // Get current cursor position
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
+ */
2331
2353
  const rangeParams = this._getRangeParams();
2332
2354
  if (!rangeParams) {
2333
2355
  console.warn("Could not get range params for paste");
@@ -2339,7 +2361,7 @@ export default class MathRichInput extends React.Component {
2339
2361
 
2340
2362
  // Clear any existing selection
2341
2363
  const selection = window.getSelection();
2342
- if (selection.rangeCount > 0) {
2364
+ if (selection && selection.rangeCount > 0) {
2343
2365
  selection.deleteFromDocument();
2344
2366
  }
2345
2367
 
@@ -2451,20 +2473,84 @@ export default class MathRichInput extends React.Component {
2451
2473
 
2452
2474
  // Use the component's insert mechanism to handle paste properly
2453
2475
  if (finalText) {
2454
- // Get current node and create marked text at cursor position
2455
- const currentNode = this._findNodeWithIndex(rangeParams.startNodeIndex);
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
+
2456
2498
  const markedText = elementToMarkedRawText(
2457
2499
  this.editableDiv,
2458
2500
  currentNode,
2459
- rangeParams.startOffset,
2501
+ currentOffset,
2460
2502
  this.enableHtml()
2461
2503
  );
2462
2504
 
2463
- // Insert the new content at the marked position
2464
- let newRawText = removeMarks(
2465
- insertCharacterBeforeMarks(markedText, finalText)
2466
- );
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
+ }
2467
2549
 
2550
+ // Insert the new content at the marked position
2551
+ let newRawText = marked
2552
+ ? removeMarks(insertCharacterBeforeMarks(markedText, finalText))
2553
+ : finalText;
2468
2554
  // Convert any remaining \[...\] LaTeX expressions to <math>...</math> format
2469
2555
  // This ensures consistency when paste adds <math> tags alongside existing \[...\] expressions
2470
2556
  if (this.enableMath()) {
@@ -2474,35 +2560,46 @@ export default class MathRichInput extends React.Component {
2474
2560
  );
2475
2561
  }
2476
2562
 
2477
- // Calculate cursor position exactly like equation editor does
2563
+ /*
2564
+ * Where the caret lands after a paste: at the END of what was pasted.
2565
+ *
2566
+ * The node index walks to just past the last formula, and that part was always right. The OFFSET
2567
+ * within that node was not: it stopped at the boundary, so pasting "…multiply it by 3." left the
2568
+ * caret between the 3 and the full stop. Everything after the last `</math>` is plain text, and
2569
+ * plain text is the one thing whose rendered length is its written length — so the tail can
2570
+ * simply be stepped over. (Markup in the tail is not that, and keeps the old position rather than
2571
+ * a guessed one.)
2572
+ *
2573
+ * Deriving the position from scratch was tried twice and both attempts landed FURTHER away, each
2574
+ * needing an exact model of how the rendered document is measured and each getting a corner of it
2575
+ * wrong. Walking the structure that is already there needs no such model.
2576
+ */
2478
2577
  let newRangeParams = null;
2479
2578
 
2480
2579
  if (hasMathContent) {
2481
- // Use same approach as equation editor for math content
2482
2580
  const oldRangeParams = this.getOldRangeParams();
2483
2581
  const mathTagCount = (finalText.match(/<math>/gi) || []).length;
2484
-
2485
- if (this.props.value === "") {
2486
- // Special case of previously empty text input (like equation editor)
2487
- newRangeParams = {
2488
- startNodeIndex: mathTagCount * 2,
2489
- startOffset: SMALL_SPACE_LENGTH,
2490
- endNodeIndex: mathTagCount * 2,
2491
- endOffset: SMALL_SPACE_LENGTH,
2492
- };
2493
- } else {
2494
- // Normal case - position cursor after inserted math content (like equation editor)
2495
- newRangeParams = {
2496
- startNodeIndex: oldRangeParams.startNodeIndex + mathTagCount * 2,
2497
- startOffset: SMALL_SPACE_LENGTH,
2498
- endNodeIndex: oldRangeParams.startNodeIndex + mathTagCount * 2,
2499
- endOffset: SMALL_SPACE_LENGTH,
2500
- };
2501
- }
2582
+ const startNodeIndex =
2583
+ this.props.value === ""
2584
+ ? mathTagCount * 2
2585
+ : oldRangeParams.startNodeIndex + mathTagCount * 2;
2586
+ const lastMathEnd = finalText.toLowerCase().lastIndexOf("</math>");
2587
+ const tail =
2588
+ lastMathEnd === -1
2589
+ ? ""
2590
+ : finalText.substring(lastMathEnd + "</math>".length);
2591
+ const startOffset =
2592
+ tail.indexOf("<") === -1
2593
+ ? SMALL_SPACE_LENGTH + tail.length
2594
+ : SMALL_SPACE_LENGTH;
2595
+ newRangeParams = {
2596
+ startNodeIndex,
2597
+ startOffset,
2598
+ endNodeIndex: startNodeIndex,
2599
+ endOffset: startOffset,
2600
+ };
2502
2601
  } else {
2503
- // For text content, use global offset like before
2504
- const newGlobalOffset =
2505
- rangeParams.startGlobalOffset + finalText.length;
2602
+ const newGlobalOffset = rangeParams.startGlobalOffset + finalText.length;
2506
2603
  newRangeParams = {
2507
2604
  startGlobalOffset: newGlobalOffset,
2508
2605
  endGlobalOffset: newGlobalOffset,
@@ -2537,6 +2634,23 @@ export default class MathRichInput extends React.Component {
2537
2634
  this.props.useExpertMode,
2538
2635
  this.props.selectedTab
2539
2636
  );
2637
+
2638
+ // A paste cannot rely on being re-rendered. It removed the selected content from the DOM itself,
2639
+ // and if the host already holds the value being applied — the same words pasted back into the
2640
+ // field they came from — no prop changes, nothing re-renders, and the field is left empty while
2641
+ // the value is correct everywhere else. So: give a real render its chance, then put the content
2642
+ // back if none came.
2643
+ const rangeAfterPaste = newRangeParams;
2644
+ queueMicrotask(() => {
2645
+ try {
2646
+ if (this.reconcileEditableDomWithRender(true)) {
2647
+ this._setRangeParams(rangeAfterPaste);
2648
+ this.updateActiveButtons();
2649
+ }
2650
+ } catch (error) {
2651
+ console.error(error);
2652
+ }
2653
+ });
2540
2654
  }
2541
2655
  } catch (error) {
2542
2656
  console.error("Error in paste handler:", error);
@@ -2612,6 +2726,64 @@ export default class MathRichInput extends React.Component {
2612
2726
  return true;
2613
2727
  }
2614
2728
 
2729
+ /**
2730
+ * The browser's own reading of an HTML string.
2731
+ *
2732
+ * React renders `<p>&#8203;…</P>`; the DOM reports `<p>​…</p>` — same content, different bytes. So a
2733
+ * comparison against `innerHTML` has to be made in the DOM's spelling, or it never matches and the
2734
+ * reconciliation below would rewrite the field on every single update.
2735
+ */
2736
+ normaliseHtml(html) {
2737
+ const probe = document.createElement("span");
2738
+ probe.innerHTML = html;
2739
+ return probe.innerHTML;
2740
+ }
2741
+
2742
+ /**
2743
+ * Put back what React believes it rendered, when the live DOM has drifted away from it.
2744
+ *
2745
+ * WHY THIS IS NEEDED AT ALL. `dangerouslySetInnerHTML` writes only when the html STRING changes: React
2746
+ * compares the new `__html` with the previous one and, finding them equal, leaves the DOM alone. That is
2747
+ * sound as long as React is the only thing that touches the DOM — and here it is not. `handlePaste`
2748
+ * removes the selected content itself, through `selection.deleteFromDocument()`, so the field empties
2749
+ * behind React's back while React's record of it still says "full".
2750
+ *
2751
+ * Paste the same words back into the field they came from and the two mistakes meet: the DOM is empty,
2752
+ * the html React computes is identical to the html it rendered last time, so it writes nothing — and
2753
+ * the field stays blank while `value`, the command, and the row in the database are all correct. The
2754
+ * teacher sees their text vanish on paste; nothing anywhere reports an error.
2755
+ *
2756
+ * It writes back the SAME string React itself passed to `dangerouslySetInnerHTML` on this render, so
2757
+ * nothing reaches the DOM here that React was not already putting there — the trust boundary is the
2758
+ * one that existed before, not a new one.
2759
+ *
2760
+ * Only reconciled after a PROGRAMMATIC edit (`afterComponentUpdateData` is set), or when the paste
2761
+ * asks directly (`force`). Ordinary typing goes down the native-input path, where the browser has
2762
+ * already put the character in the right place and the caret with it, and rewriting the DOM there
2763
+ * would move the caret to the front on every keystroke.
2764
+ *
2765
+ * `force` exists because a re-render is not guaranteed to happen AT ALL. When the host already holds
2766
+ * the value being applied — paste the same words back into the field they came from — the prop never
2767
+ * changes, so nothing re-renders and `componentDidUpdate` never runs. `lastRenderedEditableHtml` is
2768
+ * then exactly right: it is the html for that unchanged value, which is what the DOM should hold.
2769
+ *
2770
+ * Returns whether it wrote, so the caller knows whether the caret needs putting back.
2771
+ */
2772
+ reconcileEditableDomWithRender(force = false) {
2773
+ if (!this.editableDiv) return false;
2774
+ if (!force && (this.afterComponentUpdateData === null || this.afterComponentUpdateData === undefined))
2775
+ return false;
2776
+ const html = force
2777
+ ? rawTextToHtml(katex, this.props.value || "", this.props.mimeType)
2778
+ : this.lastRenderedEditableHtml;
2779
+ if (html === null || html === undefined) return false;
2780
+ if (this.editableDiv.innerHTML === this.normaliseHtml(html)) return false;
2781
+ debugMathRichInput("reconcile", { force, liveInnerHTML: this.editableDiv.innerHTML, html });
2782
+ this.editableDiv.innerHTML = html;
2783
+ this.lastRenderedEditableHtml = html;
2784
+ return true;
2785
+ }
2786
+
2615
2787
  componentDidUpdate() {
2616
2788
  try {
2617
2789
  debugMathRichInput("componentDidUpdate:start", {
@@ -2623,6 +2795,9 @@ export default class MathRichInput extends React.Component {
2623
2795
  innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
2624
2796
  selection: getSelectionSnapshot(this.editableDiv),
2625
2797
  });
2798
+ // Before the caret is restored, not after: the offsets below are counted through the text, so
2799
+ // restoring them against an empty field puts the caret at 0 and loses the position as well.
2800
+ this.reconcileEditableDomWithRender();
2626
2801
  if (
2627
2802
  this.afterComponentUpdateData !== null &&
2628
2803
  this.afterComponentUpdateData !== undefined &&
@@ -2788,9 +2963,12 @@ export default class MathRichInput extends React.Component {
2788
2963
  // Clear selection anchor on mouse click
2789
2964
  this.selectionAnchor = null;
2790
2965
 
2791
- let params = this._getRangeParams();
2792
- this.setOldRangeParams(params);
2793
- let startNode = this._findNodeWithIndex(params.startNodeIndex);
2966
+ // A click that lands with the selection outside this field — a toolbar button, another field —
2967
+ // gives no range at all. Dereferenced unguarded, this threw on every such click.
2968
+ let params = this._getRangeParams();
2969
+ if (!params) return;
2970
+ this.setOldRangeParams(params);
2971
+ let startNode = this._findNodeWithIndex(params.startNodeIndex);
2794
2972
  let endNode = this._findNodeWithIndex(params.endNodeIndex);
2795
2973
 
2796
2974
  if (startNode === null || endNode === null) {
@@ -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;