@zzish/math-rich-input 0.1.56 → 0.1.58

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.
@@ -39,6 +39,10 @@ import {
39
39
  debugMathRichInput,
40
40
  getSelectionSnapshot,
41
41
  } from "./mathRichInputDebug";
42
+ import {
43
+ editableText,
44
+ transactionFromBeforeInput,
45
+ } from "./editTransaction";
42
46
 
43
47
  import "./MathRichInput.css";
44
48
  import AccentBar from "./AccentBar";
@@ -234,6 +238,14 @@ export default class MathRichInput extends React.Component {
234
238
  this.isMouseDownInEditable = false;
235
239
  this.lastRenderedEditableHtml = null;
236
240
  this.preserveNativeDomRangeParams = null;
241
+ // The `dangerouslySetInnerHTML` prop object, reused while the html is unchanged. React re-applies
242
+ // the markup on a commit when it is handed a fresh object, replacing the editable's nodes and the
243
+ // caret with them; the same object tells React there is nothing to write.
244
+ this.lastEditableHtmlProp = undefined;
245
+ this.lastEditableHtmlPropHtml = null;
246
+ // Which of the field's formulas the current mouse press started on. The node itself cannot be
247
+ // kept: focusing a field redraws it, so the element that was pressed is already gone at mouseup.
248
+ this.equationEditorMouseDownKatexIndex = -1;
237
249
 
238
250
  this.lastSetActiveButtons = null;
239
251
 
@@ -245,6 +257,9 @@ export default class MathRichInput extends React.Component {
245
257
  this.initialHistoryState = null;
246
258
  this.undoHistory = [];
247
259
  this.redoHistory = [];
260
+ this.historySequence = 0;
261
+ this.pendingTransaction = null;
262
+ this.compositionStart = null;
248
263
  this.accent = "";
249
264
 
250
265
  // keeps track of whether user is composing in an IME - see https://developer.mozilla.org/en-US/docs/Web/API/CompositionEvent
@@ -359,15 +374,29 @@ export default class MathRichInput extends React.Component {
359
374
  if (isExpertMode === null) isExpertMode = options.isExpertMode;
360
375
  if (selectedTab === null) selectedTab = options.selectedTab;
361
376
 
362
- // Add this to the history
363
- this.undoHistory.push({ value, mimeType, rangeParams });
364
- if (this.undoHistory.length > MAX_HISTORY_LENGTH) this.undoHistory.shift();
377
+ const transaction = applyOptions.transaction || null;
378
+ const historyState = { value, mimeType, rangeParams, transaction };
379
+ if (applyOptions.recordHistory !== false) {
380
+ // Add this to the history. The transaction is retained beside the rendered value so a host can
381
+ // restore annotation snapshots on undo/redo instead of interpreting an inverse spelling anew.
382
+ this.undoHistory.push(historyState);
383
+ if (this.undoHistory.length > MAX_HISTORY_LENGTH) this.undoHistory.shift();
384
+ }
365
385
 
366
386
  // Debug what's being passed to parent
367
387
  // console.log("APPLY CHANGES:", { value, mimeType });
368
388
 
369
389
  // Call the parent handler to apply the changes
370
- this.onChangeCallback({ value, mimeType }, { isExpertMode, selectedTab });
390
+ this.onChangeCallback(
391
+ {
392
+ value,
393
+ mimeType,
394
+ ...(transaction?.edit ? { edit: transaction.edit } : {}),
395
+ ...(transaction?.intent ? { intent: transaction.intent } : {}),
396
+ ...(transaction?.history ? { history: transaction.history } : {}),
397
+ },
398
+ { isExpertMode, selectedTab }
399
+ );
371
400
  if (applyOptions.skipControlledRender === true) {
372
401
  const pendingNativeValue = value;
373
402
  // Give a scheduled controlled echo one short render window. If the host
@@ -395,6 +424,7 @@ export default class MathRichInput extends React.Component {
395
424
  value: this.props.value,
396
425
  mimeType: this.props.mimeType,
397
426
  rangeParams: this._getRangeParams(),
427
+ transaction: null,
398
428
  };
399
429
  };
400
430
 
@@ -425,37 +455,62 @@ export default class MathRichInput extends React.Component {
425
455
  };
426
456
 
427
457
  undo = () => {
428
- const currentState = this.undoHistory.pop();
458
+ const currentState = this.undoHistory[this.undoHistory.length - 1];
459
+ if (!currentState) return;
460
+ this.undoHistory.pop();
429
461
 
430
- let previousState = this.undoHistory.pop();
431
- if (previousState === null || previousState === undefined) {
432
- previousState = this.initialHistoryState;
433
- }
434
-
435
- if (this.redoHistory.length > 0) {
436
- let redoLast = this.redoHistory[this.redoHistory.length - 1];
437
- if (!this.deepEqual(redoLast, currentState))
438
- this.redoHistory.push(currentState);
439
- } else this.redoHistory.push(currentState);
462
+ const previousState = this.undoHistory[this.undoHistory.length - 1] || this.initialHistoryState;
463
+ if (!previousState) return;
464
+ this.redoHistory.push(currentState);
440
465
 
466
+ const transaction = this.historyTransaction(currentState, "undo");
441
467
  this.applyChangesToComponent(
442
468
  previousState.value,
443
469
  previousState.mimeType,
444
- previousState.rangeParams
470
+ previousState.rangeParams,
471
+ null,
472
+ null,
473
+ { transaction, recordHistory: false }
445
474
  );
446
475
  };
447
476
 
448
477
  redo = () => {
449
478
  const redoState = this.redoHistory.pop();
450
- if ((redoState !== null) & (redoState !== undefined)) {
479
+ if (redoState !== null && redoState !== undefined) {
451
480
  this.applyChangesToComponent(
452
481
  redoState.value,
453
482
  redoState.mimeType,
454
- redoState.rangeParams
483
+ redoState.rangeParams,
484
+ null,
485
+ null,
486
+ { transaction: this.historyTransaction(redoState, "redo"), recordHistory: false }
455
487
  );
488
+ this.undoHistory.push(redoState);
456
489
  }
457
490
  };
458
491
 
492
+ /** Build an explicit forward/inverse transaction for one history entry. */
493
+ historyTransaction = (state, direction) => {
494
+ const original = state?.transaction;
495
+ if (!original?.history?.id || !original.edit) {
496
+ return { intent: direction, history: original?.history ? { id: original.history.id, direction } : undefined };
497
+ }
498
+ const edit = original.edit;
499
+ const after = edit.before.slice(0, edit.from) + edit.insert + edit.before.slice(edit.to);
500
+ const inverse = {
501
+ before: after,
502
+ from: edit.from,
503
+ to: edit.from + edit.insert.length,
504
+ insert: edit.before.slice(edit.from, edit.to),
505
+ ...(edit.boundary ? { boundary: edit.boundary } : {}),
506
+ };
507
+ return {
508
+ intent: direction,
509
+ history: { id: original.history.id, direction },
510
+ edit: direction === "undo" ? inverse : edit,
511
+ };
512
+ };
513
+
459
514
  setOldRangeParams(params) {
460
515
  this.oldRangeParams = params;
461
516
  }
@@ -598,6 +653,36 @@ export default class MathRichInput extends React.Component {
598
653
  };
599
654
  }
600
655
 
656
+ /** The caret's params, only when the selection is inside this field. */
657
+ selectionRangeParams() {
658
+ const selection = window.getSelection();
659
+ if (!selection || selection.rangeCount === 0) return null;
660
+ if (!this.editableDiv || !this.editableDiv.contains(selection.anchorNode)) {
661
+ return null;
662
+ }
663
+ return this.getRangeParamsPreservingInlinePlaceholder();
664
+ }
665
+
666
+ /**
667
+ * The `dangerouslySetInnerHTML` prop, REUSED while the html is unchanged.
668
+ *
669
+ * React re-applies `dangerouslySetInnerHTML` on a commit when it is handed a new prop object, even
670
+ * when the string in it is the one already in the DOM (measured with React 19: three writes of an
671
+ * identical-length string, each replacing the editable's children — and with them the caret, which
672
+ * then sat at the start of the field). The same object is what tells React there is nothing to
673
+ * write, and that is how the field keeps the browser's own DOM and selection.
674
+ */
675
+ editableHtmlProp(html) {
676
+ if (
677
+ this.lastEditableHtmlProp === undefined ||
678
+ this.lastEditableHtmlPropHtml !== html
679
+ ) {
680
+ this.lastEditableHtmlPropHtml = html;
681
+ this.lastEditableHtmlProp = { __html: html };
682
+ }
683
+ return this.lastEditableHtmlProp;
684
+ }
685
+
601
686
  getEditableHtmlForRender(html) {
602
687
  const currentRawText = this.editableDiv
603
688
  ? this.getCurrentRawTextForProps(this.props)
@@ -641,8 +726,14 @@ export default class MathRichInput extends React.Component {
641
726
  return liveEditableHtml;
642
727
  }
643
728
 
729
+ // React will write this string: it differs from the one it last rendered. Remember where the
730
+ // caret is so it can be put back if the write takes the selection with it. A click elsewhere
731
+ // blurs the field before the commit, so the restore below never overrides another control.
732
+ this.preserveNativeDomRangeParams =
733
+ this.state.hasFocus === true && html !== this.lastRenderedEditableHtml
734
+ ? this.selectionRangeParams()
735
+ : null;
644
736
  this.lastRenderedEditableHtml = html;
645
- this.preserveNativeDomRangeParams = null;
646
737
  return html;
647
738
  }
648
739
 
@@ -913,7 +1004,11 @@ export default class MathRichInput extends React.Component {
913
1004
  const rangeParams = this._getRangeParams();
914
1005
  if (rangeParams) {
915
1006
  this.setOldRangeParams(rangeParams);
916
- this.applyCurrentEditableDomToComponent(rangeParams);
1007
+ this.applyCurrentEditableDomToComponent(rangeParams, {
1008
+ edit: { before: editableText(this.editableDiv), from: 0, to: 0, insert: "" },
1009
+ intent: "format",
1010
+ history: { id: `mri-${++this.historySequence}`, direction: "forward" },
1011
+ });
917
1012
  }
918
1013
 
919
1014
  debugMathRichInput("moveTrailingWhitespaceOutsideInlineStyle:after", {
@@ -927,7 +1022,7 @@ export default class MathRichInput extends React.Component {
927
1022
  return true;
928
1023
  }
929
1024
 
930
- applyCurrentEditableDomToComponent(rangeParams = null) {
1025
+ applyCurrentEditableDomToComponent(rangeParams = null, transaction = null) {
931
1026
  if (!this.editableDiv) return;
932
1027
  if (rangeParams === null || rangeParams === undefined) {
933
1028
  rangeParams = this._getRangeParams();
@@ -960,7 +1055,7 @@ export default class MathRichInput extends React.Component {
960
1055
  rangeParams,
961
1056
  this.props.useExpertMode,
962
1057
  this.props.selectedTab,
963
- { skipControlledRender: true }
1058
+ { skipControlledRender: true, transaction }
964
1059
  );
965
1060
  }
966
1061
 
@@ -1028,6 +1123,7 @@ export default class MathRichInput extends React.Component {
1028
1123
  }
1029
1124
 
1030
1125
  handleKeyDownBackspace = (e) => {
1126
+ const beforeText = editableText(this.editableDiv);
1031
1127
  let rangeParams = this._getRangeParams();
1032
1128
  let start_node_index = rangeParams.startNodeIndex;
1033
1129
  let end_node_index = rangeParams.endNodeIndex;
@@ -1082,7 +1178,14 @@ export default class MathRichInput extends React.Component {
1082
1178
  this.getMimeType(false, false),
1083
1179
  new_rangeParams,
1084
1180
  this.props.useExpertMode,
1085
- this.props.selectedTab
1181
+ this.props.selectedTab,
1182
+ {
1183
+ transaction: {
1184
+ edit: { before: beforeText, from: 0, to: beforeText.length, insert: "" },
1185
+ intent: "cut",
1186
+ history: { id: `mri-${++this.historySequence}`, direction: "forward" },
1187
+ },
1188
+ }
1086
1189
  );
1087
1190
  e.preventDefault();
1088
1191
  return;
@@ -2141,6 +2244,12 @@ export default class MathRichInput extends React.Component {
2141
2244
  try {
2142
2245
  // console.log("handleInput")
2143
2246
  if (this.isComposing) return;
2247
+ // execCommand emits a synchronous input event; styleText records the
2248
+ // complete format transaction once after the command returns.
2249
+ if (this.formatCommandActive) return;
2250
+
2251
+ const transaction = this.pendingTransaction;
2252
+ this.pendingTransaction = null;
2144
2253
 
2145
2254
  debugMathRichInput("handleInput:start", {
2146
2255
  inputType: event && event.nativeEvent ? event.nativeEvent.inputType : null,
@@ -2202,7 +2311,7 @@ export default class MathRichInput extends React.Component {
2202
2311
  defaultParams,
2203
2312
  this.props.useExpertMode,
2204
2313
  this.props.selectedTab,
2205
- { skipControlledRender: true }
2314
+ { skipControlledRender: true, transaction }
2206
2315
  );
2207
2316
  return;
2208
2317
  } else if (
@@ -2252,7 +2361,7 @@ export default class MathRichInput extends React.Component {
2252
2361
  validParams,
2253
2362
  this.props.useExpertMode,
2254
2363
  this.props.selectedTab,
2255
- { skipControlledRender: true }
2364
+ { skipControlledRender: true, transaction }
2256
2365
  );
2257
2366
  debugMathRichInput("handleInput:after-apply", {
2258
2367
  raw_text,
@@ -2350,6 +2459,7 @@ export default class MathRichInput extends React.Component {
2350
2459
 
2351
2460
  handlePaste = (event) => {
2352
2461
  try {
2462
+ this.pendingTransaction = null;
2353
2463
  let clipboardData = event.clipboardData || window.clipboardData;
2354
2464
  if (!clipboardData) {
2355
2465
  return;
@@ -2375,6 +2485,8 @@ export default class MathRichInput extends React.Component {
2375
2485
  console.warn("Could not get range params for paste");
2376
2486
  return;
2377
2487
  }
2488
+ const beforePaste = editableText(this.editableDiv);
2489
+ const pasteSelection = this.captureSelectionOffsets();
2378
2490
 
2379
2491
  // Store old range params for cursor positioning (like equation editor)
2380
2492
  this.setOldRangeParams(rangeParams);
@@ -2647,12 +2759,32 @@ export default class MathRichInput extends React.Component {
2647
2759
  );
2648
2760
 
2649
2761
  // Apply the change using the component's standard flow
2762
+ const insertionText = (() => {
2763
+ if (!/<[^>]+>/.test(finalText)) return finalText;
2764
+ const probe = document.createElement("div");
2765
+ probe.innerHTML = finalText;
2766
+ return editableText(probe).replace(/\ufffc/g, "");
2767
+ })();
2768
+ const transaction = pasteSelection
2769
+ ? {
2770
+ edit: {
2771
+ before: beforePaste,
2772
+ from: pasteSelection.from,
2773
+ to: pasteSelection.to,
2774
+ insert: insertionText,
2775
+ },
2776
+ intent: "paste",
2777
+ history: { id: `mri-${++this.historySequence}`, direction: "forward" },
2778
+ }
2779
+ : { intent: "paste", history: { id: `mri-${++this.historySequence}`, direction: "forward" } };
2780
+
2650
2781
  this.applyChangesToComponent(
2651
2782
  cleanedRawText,
2652
2783
  actualMimeType,
2653
2784
  newRangeParams,
2654
2785
  this.props.useExpertMode,
2655
- this.props.selectedTab
2786
+ this.props.selectedTab,
2787
+ { transaction }
2656
2788
  );
2657
2789
 
2658
2790
  // A paste cannot rely on being re-rendered. It removed the selected content from the DOM itself,
@@ -2693,6 +2825,7 @@ export default class MathRichInput extends React.Component {
2693
2825
 
2694
2826
  this.editableDiv.addEventListener("keydown", this.handleKeyDown);
2695
2827
  this.editableDiv.addEventListener("keyup", this.handleKeyUp);
2828
+ this.editableDiv.addEventListener("beforeinput", this.handleBeforeInput);
2696
2829
  this.editableDiv.addEventListener("paste", this.handlePaste);
2697
2830
  this.editableDiv.addEventListener("copy", this.handleCopy);
2698
2831
  if (this.props.autofocus === true) this.editableDiv.focus();
@@ -2705,6 +2838,7 @@ export default class MathRichInput extends React.Component {
2705
2838
  try {
2706
2839
  this.editableDiv.removeEventListener("keydown", this.handleKeyDown);
2707
2840
  this.editableDiv.removeEventListener("keyup", this.handleKeyUp);
2841
+ this.editableDiv.removeEventListener("beforeinput", this.handleBeforeInput);
2708
2842
  this.editableDiv.removeEventListener("paste", this.handlePaste);
2709
2843
  this.editableDiv.removeEventListener("copy", this.handleCopy);
2710
2844
  } catch (error) {
@@ -2869,14 +3003,23 @@ export default class MathRichInput extends React.Component {
2869
3003
  ) {
2870
3004
  const rangeParams = this.preserveNativeDomRangeParams;
2871
3005
  this.preserveNativeDomRangeParams = null;
2872
- debugMathRichInput("componentDidUpdate:preserve-restore-before", {
2873
- rangeParams,
2874
- selection: getSelectionSnapshot(this.editableDiv),
2875
- });
2876
- this._setRangeParams(rangeParams);
2877
- debugMathRichInput("componentDidUpdate:preserve-restore-after", {
2878
- selection: getSelectionSnapshot(this.editableDiv),
2879
- });
3006
+ // Only when the commit really did take the caret. A selection still in the field is the
3007
+ // user's and is not ours to move; a click elsewhere has blurred the field, which is why the
3008
+ // focus check above owns that case.
3009
+ const liveRangeParams = this._getRangeParams();
3010
+ if (
3011
+ liveRangeParams === null ||
3012
+ this.rangeIsAtStart(liveRangeParams)
3013
+ ) {
3014
+ debugMathRichInput("componentDidUpdate:preserve-restore-before", {
3015
+ rangeParams,
3016
+ selection: getSelectionSnapshot(this.editableDiv),
3017
+ });
3018
+ this._setRangeParams(rangeParams);
3019
+ debugMathRichInput("componentDidUpdate:preserve-restore-after", {
3020
+ selection: getSelectionSnapshot(this.editableDiv),
3021
+ });
3022
+ }
2880
3023
  }
2881
3024
  } catch (error) {
2882
3025
  console.error(error);
@@ -2886,6 +3029,7 @@ export default class MathRichInput extends React.Component {
2886
3029
  showEquationEditor = (moveCursorOnInsert = false, node = null) => {
2887
3030
  try {
2888
3031
  let rangeParams = this._getRangeParams();
3032
+ const hasNode = node !== null && node !== undefined;
2889
3033
  debugMathRichInput("showEquationEditor:start", {
2890
3034
  moveCursorOnInsert,
2891
3035
  nodeProvided: Boolean(node),
@@ -2894,8 +3038,30 @@ export default class MathRichInput extends React.Component {
2894
3038
  innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
2895
3039
  selection: getSelectionSnapshot(this.editableDiv),
2896
3040
  });
2897
- if (rangeParams.startNodeIndex < 0) {
2898
- console.error("Could not find position of cursor in node list");
3041
+ // A node handed in by a click is enough to open the editor, even when the selection resolves to
3042
+ // nothing (-1) or is missing entirely: the node carries the latex and where it sits.
3043
+ if (
3044
+ !hasNode &&
3045
+ (rangeParams === null || rangeParams.startNodeIndex < 0) &&
3046
+ !this.isVisuallyEmpty()
3047
+ ) {
3048
+ // No usable caret in a field that has content. The empty-field fallback below must never run
3049
+ // here: its marks stand for the WHOLE value, so Insert would replace the answer with the formula.
3050
+ // Put the caret at the end of the field and resolve it again; if that still fails, do not open.
3051
+ const endOfField = document.createRange();
3052
+ endOfField.selectNodeContents(this.editableDiv);
3053
+ endOfField.collapse(false);
3054
+ this.setCaretToRange(endOfField);
3055
+ rangeParams = this._getRangeParams();
3056
+ if (rangeParams === null || rangeParams.startNodeIndex < 0) {
3057
+ console.error("Could not find position of cursor in node list");
3058
+ return;
3059
+ }
3060
+ }
3061
+
3062
+ if (!hasNode && (rangeParams === null || rangeParams.startNodeIndex < 0)) {
3063
+ // An empty field: there is nothing to keep, so the equation becomes its whole value.
3064
+ debugMathRichInput("showEquationEditor:empty-field", { rangeParams });
2899
3065
 
2900
3066
  this.setOldRangeParams({
2901
3067
  startNodeIndex: 2,
@@ -2904,6 +3070,9 @@ export default class MathRichInput extends React.Component {
2904
3070
  endOffset: 1,
2905
3071
  });
2906
3072
  this.markedRawText = MARK + " " + MARK;
3073
+ // A new equation: never the formula an earlier opening was editing.
3074
+ this.editingExistingEquation = false;
3075
+ this.editingNode = null;
2907
3076
  this.moveCursorOnInsert = moveCursorOnInsert;
2908
3077
  this.setState({
2909
3078
  showEquationEditor: true,
@@ -2915,9 +3084,23 @@ export default class MathRichInput extends React.Component {
2915
3084
  let latex = "";
2916
3085
  let offset = 0;
2917
3086
 
2918
- if (node === null)
3087
+ if (!hasNode)
2919
3088
  node = this._findNodeWithIndex(rangeParams.startNodeIndex);
2920
3089
 
3090
+ // The toolbar preserves a caret AFTER the formula, in the renderer's zero-width text node.
3091
+ // Resolve that boundary to the preceding formula so f(x) edits it instead of opening empty.
3092
+ // Ordinary text or a non-collapsed selection still starts a new equation.
3093
+ if (
3094
+ !hasNode &&
3095
+ node?.nodeType === Node.TEXT_NODE &&
3096
+ rangeParams.startNodeIndex === rangeParams.endNodeIndex &&
3097
+ rangeParams.startOffset === rangeParams.endOffset &&
3098
+ node.textContent.slice(0, rangeParams.startOffset).replace(/\u200b/g, "") === "" &&
3099
+ isKatexNode(node.previousSibling)
3100
+ ) {
3101
+ node = node.previousSibling;
3102
+ }
3103
+
2921
3104
  // console.log(rangeParams)
2922
3105
  // console.log(this.editableDiv.childNodes)
2923
3106
  // console.log(node)
@@ -2939,7 +3122,7 @@ export default class MathRichInput extends React.Component {
2939
3122
  this.editingNode = node;
2940
3123
  } else {
2941
3124
  latex = "";
2942
- offset = rangeParams.startOffset;
3125
+ offset = rangeParams ? rangeParams.startOffset : 0;
2943
3126
  this.editingExistingEquation = false;
2944
3127
  this.editingNode = null;
2945
3128
  }
@@ -2974,6 +3157,7 @@ export default class MathRichInput extends React.Component {
2974
3157
  this.isMouseDownInEditable = false;
2975
3158
  }, 0);
2976
3159
  this.showEquationEditorOnMouseUp = false;
3160
+ this.equationEditorMouseDownKatexIndex = -1;
2977
3161
  let node = null;
2978
3162
  try {
2979
3163
  node = event.target;
@@ -2991,6 +3175,12 @@ export default class MathRichInput extends React.Component {
2991
3175
  }
2992
3176
 
2993
3177
  this.showEquationEditorOnMouseUp = true;
3178
+ // The browser has no selection yet at mousedown and a click inside KaTeX may leave it inside the
3179
+ // rendered subtree or outside the field. The formula's POSITION is what survives until mouseup:
3180
+ // focusing the field redraws the editable and replaces every node with an identical one, so the
3181
+ // element pressed here is detached by the time the mouse is released.
3182
+ this.equationEditorMouseDownKatexIndex =
3183
+ this.katexNodesInField().indexOf(node);
2994
3184
  // Save current range parameters so that we can restore them later if we wish
2995
3185
  this.setOldRangeParams(this._getRangeParams());
2996
3186
 
@@ -3004,6 +3194,47 @@ export default class MathRichInput extends React.Component {
3004
3194
  }
3005
3195
  };
3006
3196
 
3197
+ /** The field's rendered formulas, in document order — the positions a click can land on. */
3198
+ katexNodesInField() {
3199
+ return [...this.editableDiv.querySelectorAll(".katex, .katex-error")];
3200
+ }
3201
+
3202
+ /**
3203
+ * Put the browser's selection at `range` and remember the position it resolves to.
3204
+ *
3205
+ * Used to place the caret where the component — not the browser — knows it belongs: after a formula
3206
+ * that was clicked, and at the end of a field that was focused without a click.
3207
+ */
3208
+ setCaretToRange(range) {
3209
+ try {
3210
+ const selection = window.getSelection();
3211
+ if (!selection) return;
3212
+ selection.removeAllRanges();
3213
+ selection.addRange(range);
3214
+ const params = this._getRangeParams();
3215
+ if (params) this.setOldRangeParams(params);
3216
+ } catch (error) {
3217
+ console.error(error);
3218
+ }
3219
+ }
3220
+
3221
+ /**
3222
+ * A range that starts immediately after `node`, in the text that follows it.
3223
+ *
3224
+ * `setStartAfter` alone reads back as a position INSIDE the KaTeX subtree — the last text node of the
3225
+ * formula is the nearest text — and the text model treats that as the position BEFORE the maths. The
3226
+ * renderer always leaves a zero-width text node after a formula, so the caret starts there instead:
3227
+ * after the maths, where typing continues.
3228
+ */
3229
+ caretRangeAfterNode(node) {
3230
+ const range = document.createRange();
3231
+ const next = node.nextSibling;
3232
+ if (next !== null && next !== undefined) range.setStart(next, 0);
3233
+ else range.setStartAfter(node);
3234
+ range.collapse(true);
3235
+ return range;
3236
+ }
3237
+
3007
3238
  handleMouseUp = (event) => {
3008
3239
  try {
3009
3240
  this.initialiseHistory();
@@ -3011,6 +3242,30 @@ export default class MathRichInput extends React.Component {
3011
3242
  // Clear selection anchor on mouse click
3012
3243
  this.selectionAnchor = null;
3013
3244
 
3245
+ const selection = window.getSelection();
3246
+ const collapsedClick =
3247
+ selection === null ||
3248
+ selection.rangeCount === 0 ||
3249
+ selection.isCollapsed;
3250
+
3251
+ // A click on a formula opens the equation editor for THAT formula. The selection is not used to
3252
+ // find it: clicking KaTeX leaves the selection inside the rendered subtree (which the text model
3253
+ // reads as the position before the maths) or outside the field, and the editor then opened empty
3254
+ // and the caret came back at the start. The caret is put after the formula, which is one logical
3255
+ // character, so typing and Insert continue to its right.
3256
+ if (collapsedClick && this.showEquationEditorOnMouseUp) {
3257
+ // Focus BEFORE the node is looked up: focusing redraws the field, and a node resolved from the
3258
+ // old DOM would be detached by the time the range is built.
3259
+ this.editableDiv.focus();
3260
+ const mathNode =
3261
+ this.katexNodesInField()[this.equationEditorMouseDownKatexIndex] || null;
3262
+ if (mathNode !== null) {
3263
+ this.setCaretToRange(this.caretRangeAfterNode(mathNode));
3264
+ this.showEquationEditor(false, mathNode);
3265
+ return;
3266
+ }
3267
+ }
3268
+
3014
3269
  // A click that lands with the selection outside this field — a toolbar button, another field —
3015
3270
  // gives no range at all. Dereferenced unguarded, this threw on every such click.
3016
3271
  let params = this._getRangeParams();
@@ -3024,16 +3279,6 @@ export default class MathRichInput extends React.Component {
3024
3279
  return;
3025
3280
  }
3026
3281
 
3027
- if (
3028
- params.startNodeIndex === params.endNodeIndex &&
3029
- params.startOffset === params.endOffset &&
3030
- this.showEquationEditorOnMouseUp
3031
- ) {
3032
- this.editableDiv.focus();
3033
- this.showEquationEditor(false, startNode);
3034
- return;
3035
- }
3036
-
3037
3282
  // If our range starts or ends inside a katex node, move it out
3038
3283
  let new_rangeParams = null;
3039
3284
  if (isKatexNode(startNode)) {
@@ -3057,6 +3302,10 @@ export default class MathRichInput extends React.Component {
3057
3302
  }
3058
3303
 
3059
3304
  if (new_rangeParams !== null) {
3305
+ // These node positions replace a selection inside KaTeX. Its old global offsets still point
3306
+ // before the atomic formula, so they must not override the corrected positions after it.
3307
+ new_rangeParams.startGlobalOffset = null;
3308
+ new_rangeParams.endGlobalOffset = null;
3060
3309
  this._setRangeParams(new_rangeParams);
3061
3310
  event.preventDefault();
3062
3311
  return;
@@ -3092,6 +3341,32 @@ export default class MathRichInput extends React.Component {
3092
3341
  selection: getSelectionSnapshot(this.editableDiv),
3093
3342
  });
3094
3343
 
3344
+ // Insert with nothing to write: a formula that was opened and not edited, or a new equation left
3345
+ // empty. Writing back would send the host a change event for a value it already holds, and hosts
3346
+ // show "unsaved changes" on any change event. Close as Escape does and put the caret back.
3347
+ if (
3348
+ (this.editingExistingEquation &&
3349
+ latex === this.state.equationEditorLatex) ||
3350
+ (!this.editingExistingEquation && latex === "")
3351
+ ) {
3352
+ const unchangedRangeParams = this.getOldRangeParams();
3353
+ this.setState({ showEquationEditor: false }, () => {
3354
+ try {
3355
+ this.editableDiv.focus();
3356
+ if (unchangedRangeParams !== null) {
3357
+ this._setRangeParams(unchangedRangeParams);
3358
+ this.restoreRangeAfterBrowserWork(
3359
+ unchangedRangeParams,
3360
+ "equation-unchanged"
3361
+ );
3362
+ }
3363
+ } catch (error) {
3364
+ console.error(error);
3365
+ }
3366
+ });
3367
+ return;
3368
+ }
3369
+
3095
3370
  let start_pos = this.markedRawText.indexOf(MARK);
3096
3371
  let end_pos = this.markedRawText.indexOf(MARK, start_pos + MARK.length);
3097
3372
  let oldRangeParams = this.getOldRangeParams();
@@ -3254,6 +3529,9 @@ export default class MathRichInput extends React.Component {
3254
3529
 
3255
3530
  styleText = (event, style) => {
3256
3531
  try {
3532
+ this.initialiseHistory();
3533
+ this.resetHistoryRedo();
3534
+ this.formatCommandActive = true;
3257
3535
  style = style.toLowerCase();
3258
3536
  const command = this.getStyleCommand(style);
3259
3537
  const activeBefore = this.getActiveButtonsFromSelection();
@@ -3264,6 +3542,7 @@ export default class MathRichInput extends React.Component {
3264
3542
  selection.getRangeAt(0).collapsed;
3265
3543
  let success = false;
3266
3544
  let insertedTypingStylePlaceholder = false;
3545
+ let publishedByWhitespaceMove = false;
3267
3546
 
3268
3547
  // This first part is a workaround whereby executing subscript or superscript
3269
3548
  // within an existing subscript or superscript makes the text even smaller instead
@@ -3274,6 +3553,7 @@ export default class MathRichInput extends React.Component {
3274
3553
  this.moveTrailingWhitespaceOutsideInlineStyle(style, command)
3275
3554
  ) {
3276
3555
  success = true;
3556
+ publishedByWhitespaceMove = true;
3277
3557
  } else if (
3278
3558
  isCollapsed &&
3279
3559
  !activeBefore[style] &&
@@ -3310,12 +3590,19 @@ export default class MathRichInput extends React.Component {
3310
3590
  }
3311
3591
 
3312
3592
  const rangeParams = this._getRangeParams();
3313
- const removedEmptyTags =
3314
- !insertedTypingStylePlaceholder &&
3315
- this.cleanupEmptyInlineFormattingElements();
3316
- if (removedEmptyTags && rangeParams !== null && rangeParams !== undefined) {
3593
+ if (!insertedTypingStylePlaceholder) this.cleanupEmptyInlineFormattingElements();
3594
+ if (!publishedByWhitespaceMove && rangeParams !== null && rangeParams !== undefined) {
3317
3595
  this._setRangeParams(rangeParams);
3318
- this.applyCurrentEditableDomToComponent(rangeParams);
3596
+ this.applyCurrentEditableDomToComponent(rangeParams, {
3597
+ edit: {
3598
+ before: editableText(this.editableDiv),
3599
+ from: 0,
3600
+ to: 0,
3601
+ insert: "",
3602
+ },
3603
+ intent: "format",
3604
+ history: { id: `mri-${++this.historySequence}`, direction: "forward" },
3605
+ });
3319
3606
  }
3320
3607
  this.setActiveButtonsIfChanged(
3321
3608
  this.getActiveButtonsFromSelection(rangeParams)
@@ -3323,6 +3610,8 @@ export default class MathRichInput extends React.Component {
3323
3610
  return success;
3324
3611
  } catch (error) {
3325
3612
  console.error(error);
3613
+ } finally {
3614
+ this.formatCommandActive = false;
3326
3615
  }
3327
3616
  };
3328
3617
 
@@ -3371,6 +3660,7 @@ export default class MathRichInput extends React.Component {
3371
3660
 
3372
3661
  insertRawText = (new_text, selectInsertedText = false) => {
3373
3662
  try {
3663
+ const beforeText = editableText(this.editableDiv);
3374
3664
  let rangeParams = this._getRangeParams();
3375
3665
  let node = this._findNodeWithIndex(rangeParams.endNodeIndex);
3376
3666
  let offset = rangeParams.endOffset;
@@ -3422,7 +3712,19 @@ export default class MathRichInput extends React.Component {
3422
3712
  this.props.mimeType,
3423
3713
  new_rangeParams,
3424
3714
  this.props.useExpertMode,
3425
- this.props.selectedTab
3715
+ this.props.selectedTab,
3716
+ {
3717
+ transaction: {
3718
+ edit: {
3719
+ before: beforeText,
3720
+ from: rangeParams.startGlobalOffset,
3721
+ to: rangeParams.endGlobalOffset,
3722
+ insert: new_text,
3723
+ },
3724
+ intent: "input",
3725
+ history: { id: `mri-${++this.historySequence}`, direction: "forward" },
3726
+ },
3727
+ }
3426
3728
  );
3427
3729
  } catch (error) {
3428
3730
  console.error(error);
@@ -3577,6 +3879,7 @@ export default class MathRichInput extends React.Component {
3577
3879
 
3578
3880
  removeNewLine = () => {
3579
3881
  try {
3882
+ const beforeText = editableText(this.editableDiv);
3580
3883
  let rangeParams = this._getRangeParams();
3581
3884
  let node = this._findNodeWithIndex(rangeParams.endNodeIndex);
3582
3885
  let prevNode = this._findNodeWithIndex(rangeParams.endNodeIndex - 1);
@@ -3613,7 +3916,19 @@ export default class MathRichInput extends React.Component {
3613
3916
  this.props.mimeType,
3614
3917
  new_rangeParams,
3615
3918
  this.props.useExpertMode,
3616
- this.props.selectedTab
3919
+ this.props.selectedTab,
3920
+ {
3921
+ transaction: {
3922
+ edit: {
3923
+ before: beforeText,
3924
+ from: Math.max(0, rangeParams.endGlobalOffset - 1),
3925
+ to: rangeParams.endGlobalOffset,
3926
+ insert: "",
3927
+ },
3928
+ intent: "input",
3929
+ history: { id: `mri-${++this.historySequence}`, direction: "forward" },
3930
+ },
3931
+ }
3617
3932
  );
3618
3933
  } catch (error) {
3619
3934
  console.error(error);
@@ -3622,6 +3937,7 @@ export default class MathRichInput extends React.Component {
3622
3937
 
3623
3938
  insertNewLine = () => {
3624
3939
  try {
3940
+ const beforeText = editableText(this.editableDiv);
3625
3941
  let rangeParams = this._getRangeParams();
3626
3942
  let node = this._findNodeWithIndex(rangeParams.endNodeIndex);
3627
3943
  let offset = rangeParams.endOffset;
@@ -3678,14 +3994,26 @@ export default class MathRichInput extends React.Component {
3678
3994
  this.props.mimeType,
3679
3995
  new_rangeParams,
3680
3996
  this.props.useExpertMode,
3681
- this.props.selectedTab
3997
+ this.props.selectedTab,
3998
+ {
3999
+ transaction: {
4000
+ edit: {
4001
+ before: beforeText,
4002
+ from: rangeParams.startGlobalOffset,
4003
+ to: rangeParams.endGlobalOffset,
4004
+ insert: "\n",
4005
+ },
4006
+ intent: "input",
4007
+ history: { id: `mri-${++this.historySequence}`, direction: "forward" },
4008
+ },
4009
+ }
3682
4010
  );
3683
4011
  } catch (error) {
3684
4012
  console.error(error);
3685
4013
  }
3686
4014
  };
3687
4015
 
3688
- insertAfterSmallSpace(new_char, rangeParams) {
4016
+ insertAfterSmallSpace(new_char, rangeParams) {
3689
4017
  try {
3690
4018
  if (rangeParams === null || rangeParams === undefined)
3691
4019
  rangeParams = this._getRangeParams();
@@ -3697,6 +4025,7 @@ export default class MathRichInput extends React.Component {
3697
4025
  " in insertAfterSmallSpace"
3698
4026
  );
3699
4027
 
4028
+ const beforeText = editableText(this.editableDiv);
3700
4029
  let node = this._findNodeWithIndex(rangeParams.startNodeIndex);
3701
4030
  if (node === null || node === undefined || node.nodeValue === null) {
3702
4031
  console.warn("Could not find small-space text node");
@@ -3736,14 +4065,21 @@ export default class MathRichInput extends React.Component {
3736
4065
  this.enableHtml()
3737
4066
  );
3738
4067
 
3739
- this.applyChangesToComponent(
4068
+ this.applyChangesToComponent(
3740
4069
  new_raw_text,
3741
4070
  this.props.mimeType,
3742
4071
  new_rangeParams,
3743
4072
  this.props.useExpertMode,
3744
4073
  this.props.selectedTab,
3745
- { skipControlledRender: true }
3746
- );
4074
+ {
4075
+ skipControlledRender: true,
4076
+ transaction: {
4077
+ edit: { before: beforeText, from: rangeParams.startGlobalOffset, to: rangeParams.startGlobalOffset, insert: new_char },
4078
+ intent: "input",
4079
+ history: { id: `mri-${++this.historySequence}`, direction: "forward" },
4080
+ },
4081
+ }
4082
+ );
3747
4083
  } catch (error) {
3748
4084
  console.error(error);
3749
4085
  }
@@ -3751,6 +4087,7 @@ export default class MathRichInput extends React.Component {
3751
4087
 
3752
4088
  insertAccentLetter = (event, old_char, new_char) => {
3753
4089
  try {
4090
+ const beforeText = editableText(this.editableDiv);
3754
4091
  let rangeParams = this._getRangeParams();
3755
4092
  let node = this._findNodeWithIndex(rangeParams.startNodeIndex);
3756
4093
  let offset = rangeParams.startOffset;
@@ -3797,7 +4134,19 @@ export default class MathRichInput extends React.Component {
3797
4134
  this.props.mimeType,
3798
4135
  new_rangeParams,
3799
4136
  this.props.useExpertMode,
3800
- this.props.selectedTab
4137
+ this.props.selectedTab,
4138
+ {
4139
+ transaction: {
4140
+ edit: {
4141
+ before: beforeText,
4142
+ from: old_char === null ? rangeParams.startGlobalOffset : Math.max(0, rangeParams.startGlobalOffset - 1),
4143
+ to: old_char === null ? rangeParams.startGlobalOffset : rangeParams.startGlobalOffset,
4144
+ insert: new_char,
4145
+ },
4146
+ intent: "input",
4147
+ history: { id: `mri-${++this.historySequence}`, direction: "forward" },
4148
+ },
4149
+ }
3801
4150
  );
3802
4151
  } catch (error) {
3803
4152
  console.error(error);
@@ -3849,16 +4198,30 @@ export default class MathRichInput extends React.Component {
3849
4198
  currentRangeParams = null;
3850
4199
  }
3851
4200
  const savedRangeParams = this.getOldRangeParams();
4201
+ // A missing selection at focus time is not "somewhere else": the browser has not placed a caret
4202
+ // yet, which reads as the START of the field. Treating null as "leave it alone" skipped both the
4203
+ // restore below and the move to the end, so the caret stayed where the browser had left it — 0.
4204
+ const currentIsAtStart =
4205
+ currentRangeParams === null || this.rangeIsAtStart(currentRangeParams);
3852
4206
  const shouldRestoreSavedRange =
3853
4207
  !this.isMouseDownInEditable &&
3854
4208
  this.props.value !== "" &&
3855
- this.rangeIsAtStart(currentRangeParams) &&
4209
+ currentIsAtStart &&
3856
4210
  this.rangeHasNonZeroPosition(savedRangeParams);
4211
+ // Focus without a click inside the field (a host focusing it, Tab, a click on the row around it)
4212
+ // carries no position of its own. Editing an existing answer means continuing at its end, so the
4213
+ // caret goes there; a click inside the field keeps the position the browser gave it.
4214
+ const shouldMoveToEnd =
4215
+ !this.isMouseDownInEditable &&
4216
+ this.props.value !== "" &&
4217
+ currentIsAtStart &&
4218
+ !this.rangeHasNonZeroPosition(savedRangeParams);
3857
4219
  debugMathRichInput("handleFocus:start", {
3858
4220
  propsValue: this.props.value,
3859
4221
  oldRangeParams: this.oldRangeParams,
3860
4222
  currentRangeParams,
3861
4223
  shouldRestoreSavedRange,
4224
+ shouldMoveToEnd,
3862
4225
  innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
3863
4226
  selection: getSelectionSnapshot(this.editableDiv),
3864
4227
  });
@@ -3870,6 +4233,25 @@ export default class MathRichInput extends React.Component {
3870
4233
  if (shouldRestoreSavedRange) {
3871
4234
  this._setRangeParams(savedRangeParams);
3872
4235
  this.restoreRangeAfterBrowserWork(savedRangeParams, "focus-restore");
4236
+ } else if (shouldMoveToEnd) {
4237
+ // One task later, not here: the browser places the caret as part of the focus default
4238
+ // action, and for a keyboard focus (Tab) that happens AFTER this callback — moving now was
4239
+ // simply overwritten. A task boundary is after both the pointer and the keyboard default.
4240
+ // The guard keeps a selection placed meanwhile (a host restoring its own range) untouched.
4241
+ window.setTimeout(() => {
4242
+ if (this.state.hasFocus !== true) return;
4243
+ const liveRangeParams = this._getRangeParams();
4244
+ if (
4245
+ liveRangeParams !== null &&
4246
+ !this.rangeIsAtStart(liveRangeParams)
4247
+ ) {
4248
+ return;
4249
+ }
4250
+ const endOfField = document.createRange();
4251
+ endOfField.selectNodeContents(this.editableDiv);
4252
+ endOfField.collapse(false);
4253
+ this.setCaretToRange(endOfField);
4254
+ }, 0);
3873
4255
  }
3874
4256
  debugMathRichInput("handleFocus:after-setState", {
3875
4257
  selection: getSelectionSnapshot(this.editableDiv),
@@ -3885,7 +4267,7 @@ export default class MathRichInput extends React.Component {
3885
4267
  let currentRangeParams = null;
3886
4268
  try {
3887
4269
  currentRangeParams = this._getRangeParams();
3888
- if (this.rangeHasNonZeroPosition(currentRangeParams)) {
4270
+ if (currentRangeParams !== null) {
3889
4271
  this.setOldRangeParams(currentRangeParams);
3890
4272
  }
3891
4273
  } catch (error) {
@@ -3898,6 +4280,10 @@ export default class MathRichInput extends React.Component {
3898
4280
  innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
3899
4281
  selection: getSelectionSnapshot(this.editableDiv),
3900
4282
  });
4283
+ // Keyboard focus on this editor's tools is still inside the editor. Keep both
4284
+ // the toolbar and the captured range until focus leaves the whole control.
4285
+ if (event.relatedTarget === this.editableDiv ||
4286
+ this.toolbar?.toolbar?.contains(event.relatedTarget)) return;
3901
4287
  if (onBlur != null) {
3902
4288
  onBlur();
3903
4289
  }
@@ -3913,11 +4299,63 @@ export default class MathRichInput extends React.Component {
3913
4299
  };
3914
4300
 
3915
4301
  handleCompositionStart = (event) => {
4302
+ const offsets = this.captureSelectionOffsets();
4303
+ this.compositionStart = {
4304
+ before: editableText(this.editableDiv),
4305
+ from: offsets?.from ?? 0,
4306
+ to: offsets?.to ?? 0,
4307
+ };
3916
4308
  this.isComposing = true;
3917
4309
  };
3918
4310
 
3919
4311
  handleCompositionEnd = (event) => {
3920
4312
  this.isComposing = false;
4313
+ this.compositionStart = null;
4314
+ };
4315
+
4316
+ captureSelectionOffsets = () => {
4317
+ try {
4318
+ const selection = window.getSelection();
4319
+ if (!selection || selection.rangeCount === 0) return null;
4320
+ const range = selection.getRangeAt(0);
4321
+ const before = editableText(this.editableDiv);
4322
+ const endpoint = (container, offset) => {
4323
+ const probe = document.createRange();
4324
+ probe.selectNodeContents(this.editableDiv);
4325
+ probe.setEnd(container, offset);
4326
+ const wrapper = document.createElement("span");
4327
+ wrapper.appendChild(probe.cloneContents());
4328
+ return editableText(wrapper).length;
4329
+ };
4330
+ const from = endpoint(range.startContainer, range.startOffset);
4331
+ const to = endpoint(range.endContainer, range.endOffset);
4332
+ return { before, from: Math.min(from, to), to: Math.max(from, to) };
4333
+ } catch (_error) {
4334
+ return null;
4335
+ }
4336
+ };
4337
+
4338
+ /** Capture an input intent before the browser mutates the contenteditable DOM. */
4339
+ handleBeforeInput = (event) => {
4340
+ try {
4341
+ const captured = transactionFromBeforeInput(
4342
+ this.editableDiv,
4343
+ event,
4344
+ this.compositionStart
4345
+ ? { before: this.compositionStart.before, from: this.compositionStart.from, to: this.compositionStart.to }
4346
+ : null
4347
+ );
4348
+ const id = `mri-${++this.historySequence}`;
4349
+ this.pendingTransaction = {
4350
+ edit: captured.edit || undefined,
4351
+ intent: captured.intent,
4352
+ history: { id, direction: "forward" },
4353
+ };
4354
+ } catch (_error) {
4355
+ this.pendingTransaction = {
4356
+ intent: "programmatic",
4357
+ };
4358
+ }
3921
4359
  };
3922
4360
 
3923
4361
  // Comprehensive LaTeX pattern detection and auto-wrapping in math tags
@@ -4112,6 +4550,16 @@ export default class MathRichInput extends React.Component {
4112
4550
  )}
4113
4551
  {this.state.hasFocus && (
4114
4552
  <Toolbar
4553
+ ref={(r) => (this.toolbar = r)}
4554
+ onBlur={this.handleBlur}
4555
+ beforeAction={() => {
4556
+ if (document.activeElement !== this.editableDiv) {
4557
+ const range = this.getOldRangeParams();
4558
+ this.editableDiv.focus({ preventScroll: true });
4559
+ if (range !== null) this._setRangeParams(range);
4560
+ }
4561
+ this.initialiseHistory();
4562
+ }}
4115
4563
  className="Toolbar"
4116
4564
  enableMath={this.enableMath()}
4117
4565
  enableFontStyling={this.enableFontStyling()}
@@ -4146,9 +4594,7 @@ export default class MathRichInput extends React.Component {
4146
4594
  onBlur={this.handleBlur}
4147
4595
  onCompositionStart={this.handleCompositionStart}
4148
4596
  onCompositionEnd={this.handleCompositionEnd}
4149
- dangerouslySetInnerHTML={{
4150
- __html: editableHtml,
4151
- }}
4597
+ dangerouslySetInnerHTML={this.editableHtmlProp(editableHtml)}
4152
4598
  data-placeholder={this.props.placeholder || ""}
4153
4599
  ></span>
4154
4600
  </div>