@zzish/math-rich-input 0.1.55 → 0.1.57

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.55",
3
+ "version": "0.1.57",
4
4
  "description": "React component for user to enter rich text with embedded math equations.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -13,10 +13,11 @@
13
13
  },
14
14
  "main": "dist/index.js",
15
15
  "scripts": {
16
+ "test": "playwright test tests/caret-regression.spec.js --workers=1",
16
17
  "build": "rollup --config && webpack --config webpack.standalone.config.js",
17
18
  "build:legacy": "rollup --config",
18
19
  "build:standalone": "webpack --config webpack.standalone.config.js",
19
- "prepublishOnly": "npm run build"
20
+ "prepublishOnly": "npm test && npm run build"
20
21
  },
21
22
  "dependencies": {
22
23
  "katex": "^0.12.0",
@@ -36,6 +37,7 @@
36
37
  "@babel/preset-env": "^7.24.5",
37
38
  "@babel/preset-react": "^7.24.1",
38
39
  "@babel/register": "^7.13.8",
40
+ "@playwright/test": "^1.62.1",
39
41
  "@rollup/plugin-babel": "^5.3.0",
40
42
  "@rollup/plugin-commonjs": "^17.1.0",
41
43
  "@rollup/plugin-node-resolve": "^11.2.0",
@@ -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";
@@ -84,6 +88,7 @@ const EMPTY_INLINE_FORMATTING_REG_EXP =
84
88
  // const LATEX_MARKER_END = "</annotation>"
85
89
 
86
90
  const MAX_HISTORY_LENGTH = 100;
91
+ const CONTROLLED_VALUE_ECHO_TIMEOUT_MS = 50;
87
92
 
88
93
  const DEFAULT_OPTIONS = {
89
94
  useExpertMode: false,
@@ -244,6 +249,9 @@ export default class MathRichInput extends React.Component {
244
249
  this.initialHistoryState = null;
245
250
  this.undoHistory = [];
246
251
  this.redoHistory = [];
252
+ this.historySequence = 0;
253
+ this.pendingTransaction = null;
254
+ this.compositionStart = null;
247
255
  this.accent = "";
248
256
 
249
257
  // keeps track of whether user is composing in an IME - see https://developer.mozilla.org/en-US/docs/Web/API/CompositionEvent
@@ -358,15 +366,44 @@ export default class MathRichInput extends React.Component {
358
366
  if (isExpertMode === null) isExpertMode = options.isExpertMode;
359
367
  if (selectedTab === null) selectedTab = options.selectedTab;
360
368
 
361
- // Add this to the history
362
- this.undoHistory.push({ value, mimeType, rangeParams });
363
- if (this.undoHistory.length > MAX_HISTORY_LENGTH) this.undoHistory.shift();
369
+ const transaction = applyOptions.transaction || null;
370
+ const historyState = { value, mimeType, rangeParams, transaction };
371
+ if (applyOptions.recordHistory !== false) {
372
+ // Add this to the history. The transaction is retained beside the rendered value so a host can
373
+ // restore annotation snapshots on undo/redo instead of interpreting an inverse spelling anew.
374
+ this.undoHistory.push(historyState);
375
+ if (this.undoHistory.length > MAX_HISTORY_LENGTH) this.undoHistory.shift();
376
+ }
364
377
 
365
378
  // Debug what's being passed to parent
366
379
  // console.log("APPLY CHANGES:", { value, mimeType });
367
380
 
368
381
  // Call the parent handler to apply the changes
369
- this.onChangeCallback({ value, mimeType }, { isExpertMode, selectedTab });
382
+ this.onChangeCallback(
383
+ {
384
+ value,
385
+ mimeType,
386
+ ...(transaction?.edit ? { edit: transaction.edit } : {}),
387
+ ...(transaction?.intent ? { intent: transaction.intent } : {}),
388
+ ...(transaction?.history ? { history: transaction.history } : {}),
389
+ },
390
+ { isExpertMode, selectedTab }
391
+ );
392
+ if (applyOptions.skipControlledRender === true) {
393
+ const pendingNativeValue = value;
394
+ // Give a scheduled controlled echo one short render window. If the host
395
+ // never echoes the value, release the gate rather than preserving a
396
+ // rejected native edit indefinitely.
397
+ setTimeout(() => {
398
+ if (
399
+ this.skipNextControlledValueRender === true &&
400
+ this.skipNextControlledValue === pendingNativeValue
401
+ ) {
402
+ this.skipNextControlledValueRender = false;
403
+ this.skipNextControlledValue = null;
404
+ }
405
+ }, CONTROLLED_VALUE_ECHO_TIMEOUT_MS);
406
+ }
370
407
  debugMathRichInput("applyChangesToComponent:after-onChange", {
371
408
  afterComponentUpdateData: this.afterComponentUpdateData,
372
409
  selection: getSelectionSnapshot(this.editableDiv),
@@ -379,6 +416,7 @@ export default class MathRichInput extends React.Component {
379
416
  value: this.props.value,
380
417
  mimeType: this.props.mimeType,
381
418
  rangeParams: this._getRangeParams(),
419
+ transaction: null,
382
420
  };
383
421
  };
384
422
 
@@ -409,37 +447,62 @@ export default class MathRichInput extends React.Component {
409
447
  };
410
448
 
411
449
  undo = () => {
412
- const currentState = this.undoHistory.pop();
413
-
414
- let previousState = this.undoHistory.pop();
415
- if (previousState === null || previousState === undefined) {
416
- previousState = this.initialHistoryState;
417
- }
450
+ const currentState = this.undoHistory[this.undoHistory.length - 1];
451
+ if (!currentState) return;
452
+ this.undoHistory.pop();
418
453
 
419
- if (this.redoHistory.length > 0) {
420
- let redoLast = this.redoHistory[this.redoHistory.length - 1];
421
- if (!this.deepEqual(redoLast, currentState))
422
- this.redoHistory.push(currentState);
423
- } else this.redoHistory.push(currentState);
454
+ const previousState = this.undoHistory[this.undoHistory.length - 1] || this.initialHistoryState;
455
+ if (!previousState) return;
456
+ this.redoHistory.push(currentState);
424
457
 
458
+ const transaction = this.historyTransaction(currentState, "undo");
425
459
  this.applyChangesToComponent(
426
460
  previousState.value,
427
461
  previousState.mimeType,
428
- previousState.rangeParams
462
+ previousState.rangeParams,
463
+ null,
464
+ null,
465
+ { transaction, recordHistory: false }
429
466
  );
430
467
  };
431
468
 
432
469
  redo = () => {
433
470
  const redoState = this.redoHistory.pop();
434
- if ((redoState !== null) & (redoState !== undefined)) {
471
+ if (redoState !== null && redoState !== undefined) {
435
472
  this.applyChangesToComponent(
436
473
  redoState.value,
437
474
  redoState.mimeType,
438
- redoState.rangeParams
475
+ redoState.rangeParams,
476
+ null,
477
+ null,
478
+ { transaction: this.historyTransaction(redoState, "redo"), recordHistory: false }
439
479
  );
480
+ this.undoHistory.push(redoState);
440
481
  }
441
482
  };
442
483
 
484
+ /** Build an explicit forward/inverse transaction for one history entry. */
485
+ historyTransaction = (state, direction) => {
486
+ const original = state?.transaction;
487
+ if (!original?.history?.id || !original.edit) {
488
+ return { intent: direction, history: original?.history ? { id: original.history.id, direction } : undefined };
489
+ }
490
+ const edit = original.edit;
491
+ const after = edit.before.slice(0, edit.from) + edit.insert + edit.before.slice(edit.to);
492
+ const inverse = {
493
+ before: after,
494
+ from: edit.from,
495
+ to: edit.from + edit.insert.length,
496
+ insert: edit.before.slice(edit.from, edit.to),
497
+ ...(edit.boundary ? { boundary: edit.boundary } : {}),
498
+ };
499
+ return {
500
+ intent: direction,
501
+ history: { id: original.history.id, direction },
502
+ edit: direction === "undo" ? inverse : edit,
503
+ };
504
+ };
505
+
443
506
  setOldRangeParams(params) {
444
507
  this.oldRangeParams = params;
445
508
  }
@@ -587,6 +650,9 @@ export default class MathRichInput extends React.Component {
587
650
  ? this.getCurrentRawTextForProps(this.props)
588
651
  : null;
589
652
  const liveEditableHtml = this.editableDiv ? this.editableDiv.innerHTML : null;
653
+ const pendingNativeInputMatchesLiveDom =
654
+ this.skipNextControlledValueRender === true &&
655
+ currentRawText === this.skipNextControlledValue;
590
656
  const canPreserveNativeDom =
591
657
  this.state.hasFocus === true &&
592
658
  this.editableDiv !== null &&
@@ -594,7 +660,7 @@ export default class MathRichInput extends React.Component {
594
660
  (this.afterComponentUpdateData === null ||
595
661
  this.afterComponentUpdateData === undefined) &&
596
662
  this.lastRenderedEditableHtml !== null &&
597
- currentRawText === this.props.value;
663
+ (currentRawText === this.props.value || pendingNativeInputMatchesLiveDom);
598
664
  const preserveRangeParams = canPreserveNativeDom
599
665
  ? this.getRangeParamsPreservingInlinePlaceholder()
600
666
  : null;
@@ -609,6 +675,7 @@ export default class MathRichInput extends React.Component {
609
675
  liveEditableHtml,
610
676
  lastRenderedEditableHtml: this.lastRenderedEditableHtml,
611
677
  currentRawText,
678
+ pendingNativeInputMatchesLiveDom,
612
679
  preserveRangeParams,
613
680
  afterComponentUpdateData: this.afterComponentUpdateData,
614
681
  hasFocus: this.state.hasFocus,
@@ -893,7 +960,11 @@ export default class MathRichInput extends React.Component {
893
960
  const rangeParams = this._getRangeParams();
894
961
  if (rangeParams) {
895
962
  this.setOldRangeParams(rangeParams);
896
- this.applyCurrentEditableDomToComponent(rangeParams);
963
+ this.applyCurrentEditableDomToComponent(rangeParams, {
964
+ edit: { before: editableText(this.editableDiv), from: 0, to: 0, insert: "" },
965
+ intent: "format",
966
+ history: { id: `mri-${++this.historySequence}`, direction: "forward" },
967
+ });
897
968
  }
898
969
 
899
970
  debugMathRichInput("moveTrailingWhitespaceOutsideInlineStyle:after", {
@@ -907,7 +978,7 @@ export default class MathRichInput extends React.Component {
907
978
  return true;
908
979
  }
909
980
 
910
- applyCurrentEditableDomToComponent(rangeParams = null) {
981
+ applyCurrentEditableDomToComponent(rangeParams = null, transaction = null) {
911
982
  if (!this.editableDiv) return;
912
983
  if (rangeParams === null || rangeParams === undefined) {
913
984
  rangeParams = this._getRangeParams();
@@ -940,7 +1011,7 @@ export default class MathRichInput extends React.Component {
940
1011
  rangeParams,
941
1012
  this.props.useExpertMode,
942
1013
  this.props.selectedTab,
943
- { skipControlledRender: true }
1014
+ { skipControlledRender: true, transaction }
944
1015
  );
945
1016
  }
946
1017
 
@@ -1008,6 +1079,7 @@ export default class MathRichInput extends React.Component {
1008
1079
  }
1009
1080
 
1010
1081
  handleKeyDownBackspace = (e) => {
1082
+ const beforeText = editableText(this.editableDiv);
1011
1083
  let rangeParams = this._getRangeParams();
1012
1084
  let start_node_index = rangeParams.startNodeIndex;
1013
1085
  let end_node_index = rangeParams.endNodeIndex;
@@ -1062,7 +1134,14 @@ export default class MathRichInput extends React.Component {
1062
1134
  this.getMimeType(false, false),
1063
1135
  new_rangeParams,
1064
1136
  this.props.useExpertMode,
1065
- this.props.selectedTab
1137
+ this.props.selectedTab,
1138
+ {
1139
+ transaction: {
1140
+ edit: { before: beforeText, from: 0, to: beforeText.length, insert: "" },
1141
+ intent: "cut",
1142
+ history: { id: `mri-${++this.historySequence}`, direction: "forward" },
1143
+ },
1144
+ }
1066
1145
  );
1067
1146
  e.preventDefault();
1068
1147
  return;
@@ -2122,6 +2201,9 @@ export default class MathRichInput extends React.Component {
2122
2201
  // console.log("handleInput")
2123
2202
  if (this.isComposing) return;
2124
2203
 
2204
+ const transaction = this.pendingTransaction;
2205
+ this.pendingTransaction = null;
2206
+
2125
2207
  debugMathRichInput("handleInput:start", {
2126
2208
  inputType: event && event.nativeEvent ? event.nativeEvent.inputType : null,
2127
2209
  data: event && event.nativeEvent ? event.nativeEvent.data : null,
@@ -2182,7 +2264,7 @@ export default class MathRichInput extends React.Component {
2182
2264
  defaultParams,
2183
2265
  this.props.useExpertMode,
2184
2266
  this.props.selectedTab,
2185
- { skipControlledRender: true }
2267
+ { skipControlledRender: true, transaction }
2186
2268
  );
2187
2269
  return;
2188
2270
  } else if (
@@ -2232,7 +2314,7 @@ export default class MathRichInput extends React.Component {
2232
2314
  validParams,
2233
2315
  this.props.useExpertMode,
2234
2316
  this.props.selectedTab,
2235
- { skipControlledRender: true }
2317
+ { skipControlledRender: true, transaction }
2236
2318
  );
2237
2319
  debugMathRichInput("handleInput:after-apply", {
2238
2320
  raw_text,
@@ -2330,6 +2412,7 @@ export default class MathRichInput extends React.Component {
2330
2412
 
2331
2413
  handlePaste = (event) => {
2332
2414
  try {
2415
+ this.pendingTransaction = null;
2333
2416
  let clipboardData = event.clipboardData || window.clipboardData;
2334
2417
  if (!clipboardData) {
2335
2418
  return;
@@ -2355,6 +2438,8 @@ export default class MathRichInput extends React.Component {
2355
2438
  console.warn("Could not get range params for paste");
2356
2439
  return;
2357
2440
  }
2441
+ const beforePaste = editableText(this.editableDiv);
2442
+ const pasteSelection = this.captureSelectionOffsets();
2358
2443
 
2359
2444
  // Store old range params for cursor positioning (like equation editor)
2360
2445
  this.setOldRangeParams(rangeParams);
@@ -2627,12 +2712,32 @@ export default class MathRichInput extends React.Component {
2627
2712
  );
2628
2713
 
2629
2714
  // Apply the change using the component's standard flow
2715
+ const insertionText = (() => {
2716
+ if (!/<[^>]+>/.test(finalText)) return finalText;
2717
+ const probe = document.createElement("div");
2718
+ probe.innerHTML = finalText;
2719
+ return editableText(probe).replace(/\ufffc/g, "");
2720
+ })();
2721
+ const transaction = pasteSelection
2722
+ ? {
2723
+ edit: {
2724
+ before: beforePaste,
2725
+ from: pasteSelection.from,
2726
+ to: pasteSelection.to,
2727
+ insert: insertionText,
2728
+ },
2729
+ intent: "paste",
2730
+ history: { id: `mri-${++this.historySequence}`, direction: "forward" },
2731
+ }
2732
+ : { intent: "paste", history: { id: `mri-${++this.historySequence}`, direction: "forward" } };
2733
+
2630
2734
  this.applyChangesToComponent(
2631
2735
  cleanedRawText,
2632
2736
  actualMimeType,
2633
2737
  newRangeParams,
2634
2738
  this.props.useExpertMode,
2635
- this.props.selectedTab
2739
+ this.props.selectedTab,
2740
+ { transaction }
2636
2741
  );
2637
2742
 
2638
2743
  // A paste cannot rely on being re-rendered. It removed the selected content from the DOM itself,
@@ -2673,6 +2778,7 @@ export default class MathRichInput extends React.Component {
2673
2778
 
2674
2779
  this.editableDiv.addEventListener("keydown", this.handleKeyDown);
2675
2780
  this.editableDiv.addEventListener("keyup", this.handleKeyUp);
2781
+ this.editableDiv.addEventListener("beforeinput", this.handleBeforeInput);
2676
2782
  this.editableDiv.addEventListener("paste", this.handlePaste);
2677
2783
  this.editableDiv.addEventListener("copy", this.handleCopy);
2678
2784
  if (this.props.autofocus === true) this.editableDiv.focus();
@@ -2685,6 +2791,7 @@ export default class MathRichInput extends React.Component {
2685
2791
  try {
2686
2792
  this.editableDiv.removeEventListener("keydown", this.handleKeyDown);
2687
2793
  this.editableDiv.removeEventListener("keyup", this.handleKeyUp);
2794
+ this.editableDiv.removeEventListener("beforeinput", this.handleBeforeInput);
2688
2795
  this.editableDiv.removeEventListener("paste", this.handlePaste);
2689
2796
  this.editableDiv.removeEventListener("copy", this.handleCopy);
2690
2797
  } catch (error) {
@@ -2694,22 +2801,32 @@ export default class MathRichInput extends React.Component {
2694
2801
 
2695
2802
  shouldComponentUpdate(nextProps, nextState) {
2696
2803
  if (this.skipNextControlledValueRender === true) {
2804
+ const controlledValueArrived =
2805
+ nextProps.value === this.skipNextControlledValue;
2697
2806
  const canSkip =
2698
- nextProps.value === this.skipNextControlledValue &&
2699
- nextState === this.state;
2807
+ controlledValueArrived && nextState === this.state;
2700
2808
 
2701
2809
  debugMathRichInput("shouldComponentUpdate:native-input-gate", {
2702
2810
  canSkip,
2703
2811
  nextValue: nextProps.value,
2704
2812
  skipNextControlledValue: this.skipNextControlledValue,
2813
+ controlledValueArrived,
2705
2814
  currentPropsValue: this.props.value,
2706
2815
  stateChanged: nextState !== this.state,
2707
2816
  innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
2708
2817
  selection: getSelectionSnapshot(this.editableDiv),
2709
2818
  });
2710
2819
 
2711
- this.skipNextControlledValueRender = false;
2712
- this.skipNextControlledValue = null;
2820
+ // A controlled host may commit an unrelated render before it echoes the
2821
+ // value from this native input event. Keep the gate armed through that
2822
+ // stale render so getEditableHtmlForRender can preserve the browser DOM
2823
+ // and its selection. The time-bounded fallback armed by
2824
+ // applyChangesToComponent keeps a host that rejects the value from
2825
+ // leaving the gate armed indefinitely.
2826
+ if (controlledValueArrived) {
2827
+ this.skipNextControlledValueRender = false;
2828
+ this.skipNextControlledValue = null;
2829
+ }
2713
2830
 
2714
2831
  if (canSkip) return false;
2715
2832
  }
@@ -3280,12 +3397,19 @@ export default class MathRichInput extends React.Component {
3280
3397
  }
3281
3398
 
3282
3399
  const rangeParams = this._getRangeParams();
3283
- const removedEmptyTags =
3284
- !insertedTypingStylePlaceholder &&
3285
- this.cleanupEmptyInlineFormattingElements();
3286
- if (removedEmptyTags && rangeParams !== null && rangeParams !== undefined) {
3400
+ if (!insertedTypingStylePlaceholder) this.cleanupEmptyInlineFormattingElements();
3401
+ if (rangeParams !== null && rangeParams !== undefined) {
3287
3402
  this._setRangeParams(rangeParams);
3288
- this.applyCurrentEditableDomToComponent(rangeParams);
3403
+ this.applyCurrentEditableDomToComponent(rangeParams, {
3404
+ edit: {
3405
+ before: editableText(this.editableDiv),
3406
+ from: 0,
3407
+ to: 0,
3408
+ insert: "",
3409
+ },
3410
+ intent: "format",
3411
+ history: { id: `mri-${++this.historySequence}`, direction: "forward" },
3412
+ });
3289
3413
  }
3290
3414
  this.setActiveButtonsIfChanged(
3291
3415
  this.getActiveButtonsFromSelection(rangeParams)
@@ -3341,6 +3465,7 @@ export default class MathRichInput extends React.Component {
3341
3465
 
3342
3466
  insertRawText = (new_text, selectInsertedText = false) => {
3343
3467
  try {
3468
+ const beforeText = editableText(this.editableDiv);
3344
3469
  let rangeParams = this._getRangeParams();
3345
3470
  let node = this._findNodeWithIndex(rangeParams.endNodeIndex);
3346
3471
  let offset = rangeParams.endOffset;
@@ -3392,7 +3517,19 @@ export default class MathRichInput extends React.Component {
3392
3517
  this.props.mimeType,
3393
3518
  new_rangeParams,
3394
3519
  this.props.useExpertMode,
3395
- this.props.selectedTab
3520
+ this.props.selectedTab,
3521
+ {
3522
+ transaction: {
3523
+ edit: {
3524
+ before: beforeText,
3525
+ from: rangeParams.startGlobalOffset,
3526
+ to: rangeParams.endGlobalOffset,
3527
+ insert: new_text,
3528
+ },
3529
+ intent: "input",
3530
+ history: { id: `mri-${++this.historySequence}`, direction: "forward" },
3531
+ },
3532
+ }
3396
3533
  );
3397
3534
  } catch (error) {
3398
3535
  console.error(error);
@@ -3547,6 +3684,7 @@ export default class MathRichInput extends React.Component {
3547
3684
 
3548
3685
  removeNewLine = () => {
3549
3686
  try {
3687
+ const beforeText = editableText(this.editableDiv);
3550
3688
  let rangeParams = this._getRangeParams();
3551
3689
  let node = this._findNodeWithIndex(rangeParams.endNodeIndex);
3552
3690
  let prevNode = this._findNodeWithIndex(rangeParams.endNodeIndex - 1);
@@ -3583,7 +3721,19 @@ export default class MathRichInput extends React.Component {
3583
3721
  this.props.mimeType,
3584
3722
  new_rangeParams,
3585
3723
  this.props.useExpertMode,
3586
- this.props.selectedTab
3724
+ this.props.selectedTab,
3725
+ {
3726
+ transaction: {
3727
+ edit: {
3728
+ before: beforeText,
3729
+ from: Math.max(0, rangeParams.endGlobalOffset - 1),
3730
+ to: rangeParams.endGlobalOffset,
3731
+ insert: "",
3732
+ },
3733
+ intent: "input",
3734
+ history: { id: `mri-${++this.historySequence}`, direction: "forward" },
3735
+ },
3736
+ }
3587
3737
  );
3588
3738
  } catch (error) {
3589
3739
  console.error(error);
@@ -3592,6 +3742,7 @@ export default class MathRichInput extends React.Component {
3592
3742
 
3593
3743
  insertNewLine = () => {
3594
3744
  try {
3745
+ const beforeText = editableText(this.editableDiv);
3595
3746
  let rangeParams = this._getRangeParams();
3596
3747
  let node = this._findNodeWithIndex(rangeParams.endNodeIndex);
3597
3748
  let offset = rangeParams.endOffset;
@@ -3648,14 +3799,26 @@ export default class MathRichInput extends React.Component {
3648
3799
  this.props.mimeType,
3649
3800
  new_rangeParams,
3650
3801
  this.props.useExpertMode,
3651
- this.props.selectedTab
3802
+ this.props.selectedTab,
3803
+ {
3804
+ transaction: {
3805
+ edit: {
3806
+ before: beforeText,
3807
+ from: rangeParams.startGlobalOffset,
3808
+ to: rangeParams.endGlobalOffset,
3809
+ insert: "\n",
3810
+ },
3811
+ intent: "input",
3812
+ history: { id: `mri-${++this.historySequence}`, direction: "forward" },
3813
+ },
3814
+ }
3652
3815
  );
3653
3816
  } catch (error) {
3654
3817
  console.error(error);
3655
3818
  }
3656
3819
  };
3657
3820
 
3658
- insertAfterSmallSpace(new_char, rangeParams) {
3821
+ insertAfterSmallSpace(new_char, rangeParams) {
3659
3822
  try {
3660
3823
  if (rangeParams === null || rangeParams === undefined)
3661
3824
  rangeParams = this._getRangeParams();
@@ -3667,6 +3830,7 @@ export default class MathRichInput extends React.Component {
3667
3830
  " in insertAfterSmallSpace"
3668
3831
  );
3669
3832
 
3833
+ const beforeText = editableText(this.editableDiv);
3670
3834
  let node = this._findNodeWithIndex(rangeParams.startNodeIndex);
3671
3835
  if (node === null || node === undefined || node.nodeValue === null) {
3672
3836
  console.warn("Could not find small-space text node");
@@ -3706,14 +3870,21 @@ export default class MathRichInput extends React.Component {
3706
3870
  this.enableHtml()
3707
3871
  );
3708
3872
 
3709
- this.applyChangesToComponent(
3873
+ this.applyChangesToComponent(
3710
3874
  new_raw_text,
3711
3875
  this.props.mimeType,
3712
3876
  new_rangeParams,
3713
3877
  this.props.useExpertMode,
3714
3878
  this.props.selectedTab,
3715
- { skipControlledRender: true }
3716
- );
3879
+ {
3880
+ skipControlledRender: true,
3881
+ transaction: {
3882
+ edit: { before: beforeText, from: rangeParams.startGlobalOffset, to: rangeParams.startGlobalOffset, insert: new_char },
3883
+ intent: "input",
3884
+ history: { id: `mri-${++this.historySequence}`, direction: "forward" },
3885
+ },
3886
+ }
3887
+ );
3717
3888
  } catch (error) {
3718
3889
  console.error(error);
3719
3890
  }
@@ -3721,6 +3892,7 @@ export default class MathRichInput extends React.Component {
3721
3892
 
3722
3893
  insertAccentLetter = (event, old_char, new_char) => {
3723
3894
  try {
3895
+ const beforeText = editableText(this.editableDiv);
3724
3896
  let rangeParams = this._getRangeParams();
3725
3897
  let node = this._findNodeWithIndex(rangeParams.startNodeIndex);
3726
3898
  let offset = rangeParams.startOffset;
@@ -3767,7 +3939,19 @@ export default class MathRichInput extends React.Component {
3767
3939
  this.props.mimeType,
3768
3940
  new_rangeParams,
3769
3941
  this.props.useExpertMode,
3770
- this.props.selectedTab
3942
+ this.props.selectedTab,
3943
+ {
3944
+ transaction: {
3945
+ edit: {
3946
+ before: beforeText,
3947
+ from: old_char === null ? rangeParams.startGlobalOffset : Math.max(0, rangeParams.startGlobalOffset - 1),
3948
+ to: old_char === null ? rangeParams.startGlobalOffset : rangeParams.startGlobalOffset,
3949
+ insert: new_char,
3950
+ },
3951
+ intent: "input",
3952
+ history: { id: `mri-${++this.historySequence}`, direction: "forward" },
3953
+ },
3954
+ }
3771
3955
  );
3772
3956
  } catch (error) {
3773
3957
  console.error(error);
@@ -3883,11 +4067,63 @@ export default class MathRichInput extends React.Component {
3883
4067
  };
3884
4068
 
3885
4069
  handleCompositionStart = (event) => {
4070
+ const offsets = this.captureSelectionOffsets();
4071
+ this.compositionStart = {
4072
+ before: editableText(this.editableDiv),
4073
+ from: offsets?.from ?? 0,
4074
+ to: offsets?.to ?? 0,
4075
+ };
3886
4076
  this.isComposing = true;
3887
4077
  };
3888
4078
 
3889
4079
  handleCompositionEnd = (event) => {
3890
4080
  this.isComposing = false;
4081
+ this.compositionStart = null;
4082
+ };
4083
+
4084
+ captureSelectionOffsets = () => {
4085
+ try {
4086
+ const selection = window.getSelection();
4087
+ if (!selection || selection.rangeCount === 0) return null;
4088
+ const range = selection.getRangeAt(0);
4089
+ const before = editableText(this.editableDiv);
4090
+ const endpoint = (container, offset) => {
4091
+ const probe = document.createRange();
4092
+ probe.selectNodeContents(this.editableDiv);
4093
+ probe.setEnd(container, offset);
4094
+ const wrapper = document.createElement("span");
4095
+ wrapper.appendChild(probe.cloneContents());
4096
+ return editableText(wrapper).length;
4097
+ };
4098
+ const from = endpoint(range.startContainer, range.startOffset);
4099
+ const to = endpoint(range.endContainer, range.endOffset);
4100
+ return { before, from: Math.min(from, to), to: Math.max(from, to) };
4101
+ } catch (_error) {
4102
+ return null;
4103
+ }
4104
+ };
4105
+
4106
+ /** Capture an input intent before the browser mutates the contenteditable DOM. */
4107
+ handleBeforeInput = (event) => {
4108
+ try {
4109
+ const captured = transactionFromBeforeInput(
4110
+ this.editableDiv,
4111
+ event,
4112
+ this.compositionStart
4113
+ ? { before: this.compositionStart.before, from: this.compositionStart.from, to: this.compositionStart.to }
4114
+ : null
4115
+ );
4116
+ const id = `mri-${++this.historySequence}`;
4117
+ this.pendingTransaction = {
4118
+ edit: captured.edit || undefined,
4119
+ intent: captured.intent,
4120
+ history: { id, direction: "forward" },
4121
+ };
4122
+ } catch (_error) {
4123
+ this.pendingTransaction = {
4124
+ intent: "programmatic",
4125
+ };
4126
+ }
3891
4127
  };
3892
4128
 
3893
4129
  // Comprehensive LaTeX pattern detection and auto-wrapping in math tags