@zzish/math-rich-input 0.1.52 → 0.1.53

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.53",
4
4
  "description": "React component for user to enter rich text with embedded math equations.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1972,6 +1972,10 @@ export default class MathRichInput extends React.Component {
1972
1972
 
1973
1973
  // Stop editing if within rendered katex node, if somehow the cursor ends up there (it shouldn't)
1974
1974
  let params = this._getRangeParams();
1975
+ // No range means no caret to protect — a field the browser has not placed a selection in yet. It
1976
+ // reads as null on the first keystroke after a click on a toolbar button, and dereferencing it threw
1977
+ // on every Cmd, every Cmd+A and every Cmd+V the teacher pressed.
1978
+ if (params === null || params === undefined) return;
1975
1979
  let startNode = this._findNodeWithIndex(params.startNodeIndex);
1976
1980
  let endNode = this._findNodeWithIndex(params.endNodeIndex);
1977
1981
  if (!this.editableDivIsPlainText()) {
@@ -2028,7 +2032,12 @@ export default class MathRichInput extends React.Component {
2028
2032
  // If the cursor is in a katex node, then move it to the next node (unless there is no
2029
2033
  // net node in which case move it to the previous node)
2030
2034
  if (e.key === "ArrowRight") {
2035
+ // `_getRangeParams` returns null whenever the document's selection is not inside this field —
2036
+ // which happens in ordinary use, not only in error: a toolbar button taking focus is enough. It
2037
+ // was dereferenced straight away, so the key handler THREW and everything after it in the same
2038
+ // press was skipped.
2031
2039
  let rangeParams = this._getRangeParams();
2040
+ if (!rangeParams) return;
2032
2041
  let index = rangeParams.startNodeIndex;
2033
2042
 
2034
2043
  if (index >= 0) {
@@ -2063,7 +2072,9 @@ export default class MathRichInput extends React.Component {
2063
2072
  }
2064
2073
 
2065
2074
  if (e.key === "ArrowLeft") {
2075
+ // Same guard, same reason — see ArrowRight above.
2066
2076
  let rangeParams = this._getRangeParams();
2077
+ if (!rangeParams) return;
2067
2078
  let index = rangeParams.startNodeIndex;
2068
2079
 
2069
2080
  // If the cursor is in a katex node, then move it to the previous node (unless there is no
@@ -2327,8 +2338,30 @@ export default class MathRichInput extends React.Component {
2327
2338
  let pasteHtml = clipboardData.getData("text/html");
2328
2339
  let pasteText = clipboardData.getData("text/plain");
2329
2340
 
2330
- // Get current cursor position
2331
- const rangeParams = this._getRangeParams();
2341
+ // Remove whatever was selected FIRST, then read the caret.
2342
+ //
2343
+ // The order used to be the other way round, and that is why pasting over a selection emptied the
2344
+ // field instead of replacing it. `rangeParams` is a NODE INDEX plus an offset, and
2345
+ // `deleteFromDocument()` removes the very nodes it counts: read before the delete, the index points
2346
+ // at something that no longer exists, so `_findNodeWithIndex` below finds nothing, the marked text
2347
+ // it builds carries no mark, and the pasted text is inserted nowhere. The value the component then
2348
+ // reports is correct while the editable div is left empty — which is exactly what a teacher sees.
2349
+ //
2350
+ // It only ever worked on an empty field because the canonical empty state is a single `<p>` holding
2351
+ // one small space: deleting that selection leaves the same node structure standing, so the stale
2352
+ // index still happens to resolve.
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;
2332
2365
  if (!rangeParams) {
2333
2366
  console.warn("Could not get range params for paste");
2334
2367
  return;
@@ -2337,12 +2370,6 @@ export default class MathRichInput extends React.Component {
2337
2370
  // Store old range params for cursor positioning (like equation editor)
2338
2371
  this.setOldRangeParams(rangeParams);
2339
2372
 
2340
- // Clear any existing selection
2341
- const selection = window.getSelection();
2342
- if (selection.rangeCount > 0) {
2343
- selection.deleteFromDocument();
2344
- }
2345
-
2346
2373
  let finalText = "";
2347
2374
  let finalMimeType = this.props.mimeType || "text/html";
2348
2375
  let hasMathContent = false;
@@ -2464,7 +2491,6 @@ export default class MathRichInput extends React.Component {
2464
2491
  let newRawText = removeMarks(
2465
2492
  insertCharacterBeforeMarks(markedText, finalText)
2466
2493
  );
2467
-
2468
2494
  // Convert any remaining \[...\] LaTeX expressions to <math>...</math> format
2469
2495
  // This ensures consistency when paste adds <math> tags alongside existing \[...\] expressions
2470
2496
  if (this.enableMath()) {
@@ -2474,35 +2500,46 @@ export default class MathRichInput extends React.Component {
2474
2500
  );
2475
2501
  }
2476
2502
 
2477
- // Calculate cursor position exactly like equation editor does
2503
+ /*
2504
+ * Where the caret lands after a paste: at the END of what was pasted.
2505
+ *
2506
+ * The node index walks to just past the last formula, and that part was always right. The OFFSET
2507
+ * within that node was not: it stopped at the boundary, so pasting "…multiply it by 3." left the
2508
+ * caret between the 3 and the full stop. Everything after the last `</math>` is plain text, and
2509
+ * plain text is the one thing whose rendered length is its written length — so the tail can
2510
+ * simply be stepped over. (Markup in the tail is not that, and keeps the old position rather than
2511
+ * a guessed one.)
2512
+ *
2513
+ * Deriving the position from scratch was tried twice and both attempts landed FURTHER away, each
2514
+ * needing an exact model of how the rendered document is measured and each getting a corner of it
2515
+ * wrong. Walking the structure that is already there needs no such model.
2516
+ */
2478
2517
  let newRangeParams = null;
2479
2518
 
2480
2519
  if (hasMathContent) {
2481
- // Use same approach as equation editor for math content
2482
2520
  const oldRangeParams = this.getOldRangeParams();
2483
2521
  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
- }
2522
+ const startNodeIndex =
2523
+ this.props.value === ""
2524
+ ? mathTagCount * 2
2525
+ : oldRangeParams.startNodeIndex + mathTagCount * 2;
2526
+ const lastMathEnd = finalText.toLowerCase().lastIndexOf("</math>");
2527
+ const tail =
2528
+ lastMathEnd === -1
2529
+ ? ""
2530
+ : finalText.substring(lastMathEnd + "</math>".length);
2531
+ const startOffset =
2532
+ tail.indexOf("<") === -1
2533
+ ? SMALL_SPACE_LENGTH + tail.length
2534
+ : SMALL_SPACE_LENGTH;
2535
+ newRangeParams = {
2536
+ startNodeIndex,
2537
+ startOffset,
2538
+ endNodeIndex: startNodeIndex,
2539
+ endOffset: startOffset,
2540
+ };
2502
2541
  } else {
2503
- // For text content, use global offset like before
2504
- const newGlobalOffset =
2505
- rangeParams.startGlobalOffset + finalText.length;
2542
+ const newGlobalOffset = rangeParams.startGlobalOffset + finalText.length;
2506
2543
  newRangeParams = {
2507
2544
  startGlobalOffset: newGlobalOffset,
2508
2545
  endGlobalOffset: newGlobalOffset,
@@ -2537,6 +2574,23 @@ export default class MathRichInput extends React.Component {
2537
2574
  this.props.useExpertMode,
2538
2575
  this.props.selectedTab
2539
2576
  );
2577
+
2578
+ // A paste cannot rely on being re-rendered. It removed the selected content from the DOM itself,
2579
+ // and if the host already holds the value being applied — the same words pasted back into the
2580
+ // field they came from — no prop changes, nothing re-renders, and the field is left empty while
2581
+ // the value is correct everywhere else. So: give a real render its chance, then put the content
2582
+ // back if none came.
2583
+ const rangeAfterPaste = newRangeParams;
2584
+ queueMicrotask(() => {
2585
+ try {
2586
+ if (this.reconcileEditableDomWithRender(true)) {
2587
+ this._setRangeParams(rangeAfterPaste);
2588
+ this.updateActiveButtons();
2589
+ }
2590
+ } catch (error) {
2591
+ console.error(error);
2592
+ }
2593
+ });
2540
2594
  }
2541
2595
  } catch (error) {
2542
2596
  console.error("Error in paste handler:", error);
@@ -2612,6 +2666,64 @@ export default class MathRichInput extends React.Component {
2612
2666
  return true;
2613
2667
  }
2614
2668
 
2669
+ /**
2670
+ * The browser's own reading of an HTML string.
2671
+ *
2672
+ * React renders `<p>&#8203;…</P>`; the DOM reports `<p>​…</p>` — same content, different bytes. So a
2673
+ * comparison against `innerHTML` has to be made in the DOM's spelling, or it never matches and the
2674
+ * reconciliation below would rewrite the field on every single update.
2675
+ */
2676
+ normaliseHtml(html) {
2677
+ const probe = document.createElement("span");
2678
+ probe.innerHTML = html;
2679
+ return probe.innerHTML;
2680
+ }
2681
+
2682
+ /**
2683
+ * Put back what React believes it rendered, when the live DOM has drifted away from it.
2684
+ *
2685
+ * WHY THIS IS NEEDED AT ALL. `dangerouslySetInnerHTML` writes only when the html STRING changes: React
2686
+ * compares the new `__html` with the previous one and, finding them equal, leaves the DOM alone. That is
2687
+ * sound as long as React is the only thing that touches the DOM — and here it is not. `handlePaste`
2688
+ * removes the selected content itself, through `selection.deleteFromDocument()`, so the field empties
2689
+ * behind React's back while React's record of it still says "full".
2690
+ *
2691
+ * Paste the same words back into the field they came from and the two mistakes meet: the DOM is empty,
2692
+ * the html React computes is identical to the html it rendered last time, so it writes nothing — and
2693
+ * the field stays blank while `value`, the command, and the row in the database are all correct. The
2694
+ * teacher sees their text vanish on paste; nothing anywhere reports an error.
2695
+ *
2696
+ * It writes back the SAME string React itself passed to `dangerouslySetInnerHTML` on this render, so
2697
+ * nothing reaches the DOM here that React was not already putting there — the trust boundary is the
2698
+ * one that existed before, not a new one.
2699
+ *
2700
+ * Only reconciled after a PROGRAMMATIC edit (`afterComponentUpdateData` is set), or when the paste
2701
+ * asks directly (`force`). Ordinary typing goes down the native-input path, where the browser has
2702
+ * already put the character in the right place and the caret with it, and rewriting the DOM there
2703
+ * would move the caret to the front on every keystroke.
2704
+ *
2705
+ * `force` exists because a re-render is not guaranteed to happen AT ALL. When the host already holds
2706
+ * the value being applied — paste the same words back into the field they came from — the prop never
2707
+ * changes, so nothing re-renders and `componentDidUpdate` never runs. `lastRenderedEditableHtml` is
2708
+ * then exactly right: it is the html for that unchanged value, which is what the DOM should hold.
2709
+ *
2710
+ * Returns whether it wrote, so the caller knows whether the caret needs putting back.
2711
+ */
2712
+ reconcileEditableDomWithRender(force = false) {
2713
+ if (!this.editableDiv) return false;
2714
+ if (!force && (this.afterComponentUpdateData === null || this.afterComponentUpdateData === undefined))
2715
+ return false;
2716
+ const html = force
2717
+ ? rawTextToHtml(katex, this.props.value || "", this.props.mimeType)
2718
+ : this.lastRenderedEditableHtml;
2719
+ if (html === null || html === undefined) return false;
2720
+ if (this.editableDiv.innerHTML === this.normaliseHtml(html)) return false;
2721
+ debugMathRichInput("reconcile", { force, liveInnerHTML: this.editableDiv.innerHTML, html });
2722
+ this.editableDiv.innerHTML = html;
2723
+ this.lastRenderedEditableHtml = html;
2724
+ return true;
2725
+ }
2726
+
2615
2727
  componentDidUpdate() {
2616
2728
  try {
2617
2729
  debugMathRichInput("componentDidUpdate:start", {
@@ -2623,6 +2735,9 @@ export default class MathRichInput extends React.Component {
2623
2735
  innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
2624
2736
  selection: getSelectionSnapshot(this.editableDiv),
2625
2737
  });
2738
+ // Before the caret is restored, not after: the offsets below are counted through the text, so
2739
+ // restoring them against an empty field puts the caret at 0 and loses the position as well.
2740
+ this.reconcileEditableDomWithRender();
2626
2741
  if (
2627
2742
  this.afterComponentUpdateData !== null &&
2628
2743
  this.afterComponentUpdateData !== undefined &&
@@ -2788,9 +2903,12 @@ export default class MathRichInput extends React.Component {
2788
2903
  // Clear selection anchor on mouse click
2789
2904
  this.selectionAnchor = null;
2790
2905
 
2791
- let params = this._getRangeParams();
2792
- this.setOldRangeParams(params);
2793
- let startNode = this._findNodeWithIndex(params.startNodeIndex);
2906
+ // A click that lands with the selection outside this field — a toolbar button, another field —
2907
+ // gives no range at all. Dereferenced unguarded, this threw on every such click.
2908
+ let params = this._getRangeParams();
2909
+ if (!params) return;
2910
+ this.setOldRangeParams(params);
2911
+ let startNode = this._findNodeWithIndex(params.startNodeIndex);
2794
2912
  let endNode = this._findNodeWithIndex(params.endNodeIndex);
2795
2913
 
2796
2914
  if (startNode === null || endNode === null) {