@zzish/math-rich-input 0.1.56 → 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.56",
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",
@@ -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";
@@ -245,6 +249,9 @@ export default class MathRichInput extends React.Component {
245
249
  this.initialHistoryState = null;
246
250
  this.undoHistory = [];
247
251
  this.redoHistory = [];
252
+ this.historySequence = 0;
253
+ this.pendingTransaction = null;
254
+ this.compositionStart = null;
248
255
  this.accent = "";
249
256
 
250
257
  // keeps track of whether user is composing in an IME - see https://developer.mozilla.org/en-US/docs/Web/API/CompositionEvent
@@ -359,15 +366,29 @@ export default class MathRichInput extends React.Component {
359
366
  if (isExpertMode === null) isExpertMode = options.isExpertMode;
360
367
  if (selectedTab === null) selectedTab = options.selectedTab;
361
368
 
362
- // Add this to the history
363
- this.undoHistory.push({ value, mimeType, rangeParams });
364
- 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
+ }
365
377
 
366
378
  // Debug what's being passed to parent
367
379
  // console.log("APPLY CHANGES:", { value, mimeType });
368
380
 
369
381
  // Call the parent handler to apply the changes
370
- 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
+ );
371
392
  if (applyOptions.skipControlledRender === true) {
372
393
  const pendingNativeValue = value;
373
394
  // Give a scheduled controlled echo one short render window. If the host
@@ -395,6 +416,7 @@ export default class MathRichInput extends React.Component {
395
416
  value: this.props.value,
396
417
  mimeType: this.props.mimeType,
397
418
  rangeParams: this._getRangeParams(),
419
+ transaction: null,
398
420
  };
399
421
  };
400
422
 
@@ -425,37 +447,62 @@ export default class MathRichInput extends React.Component {
425
447
  };
426
448
 
427
449
  undo = () => {
428
- const currentState = this.undoHistory.pop();
429
-
430
- let previousState = this.undoHistory.pop();
431
- if (previousState === null || previousState === undefined) {
432
- previousState = this.initialHistoryState;
433
- }
450
+ const currentState = this.undoHistory[this.undoHistory.length - 1];
451
+ if (!currentState) return;
452
+ this.undoHistory.pop();
434
453
 
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);
454
+ const previousState = this.undoHistory[this.undoHistory.length - 1] || this.initialHistoryState;
455
+ if (!previousState) return;
456
+ this.redoHistory.push(currentState);
440
457
 
458
+ const transaction = this.historyTransaction(currentState, "undo");
441
459
  this.applyChangesToComponent(
442
460
  previousState.value,
443
461
  previousState.mimeType,
444
- previousState.rangeParams
462
+ previousState.rangeParams,
463
+ null,
464
+ null,
465
+ { transaction, recordHistory: false }
445
466
  );
446
467
  };
447
468
 
448
469
  redo = () => {
449
470
  const redoState = this.redoHistory.pop();
450
- if ((redoState !== null) & (redoState !== undefined)) {
471
+ if (redoState !== null && redoState !== undefined) {
451
472
  this.applyChangesToComponent(
452
473
  redoState.value,
453
474
  redoState.mimeType,
454
- redoState.rangeParams
475
+ redoState.rangeParams,
476
+ null,
477
+ null,
478
+ { transaction: this.historyTransaction(redoState, "redo"), recordHistory: false }
455
479
  );
480
+ this.undoHistory.push(redoState);
456
481
  }
457
482
  };
458
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
+
459
506
  setOldRangeParams(params) {
460
507
  this.oldRangeParams = params;
461
508
  }
@@ -913,7 +960,11 @@ export default class MathRichInput extends React.Component {
913
960
  const rangeParams = this._getRangeParams();
914
961
  if (rangeParams) {
915
962
  this.setOldRangeParams(rangeParams);
916
- 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
+ });
917
968
  }
918
969
 
919
970
  debugMathRichInput("moveTrailingWhitespaceOutsideInlineStyle:after", {
@@ -927,7 +978,7 @@ export default class MathRichInput extends React.Component {
927
978
  return true;
928
979
  }
929
980
 
930
- applyCurrentEditableDomToComponent(rangeParams = null) {
981
+ applyCurrentEditableDomToComponent(rangeParams = null, transaction = null) {
931
982
  if (!this.editableDiv) return;
932
983
  if (rangeParams === null || rangeParams === undefined) {
933
984
  rangeParams = this._getRangeParams();
@@ -960,7 +1011,7 @@ export default class MathRichInput extends React.Component {
960
1011
  rangeParams,
961
1012
  this.props.useExpertMode,
962
1013
  this.props.selectedTab,
963
- { skipControlledRender: true }
1014
+ { skipControlledRender: true, transaction }
964
1015
  );
965
1016
  }
966
1017
 
@@ -1028,6 +1079,7 @@ export default class MathRichInput extends React.Component {
1028
1079
  }
1029
1080
 
1030
1081
  handleKeyDownBackspace = (e) => {
1082
+ const beforeText = editableText(this.editableDiv);
1031
1083
  let rangeParams = this._getRangeParams();
1032
1084
  let start_node_index = rangeParams.startNodeIndex;
1033
1085
  let end_node_index = rangeParams.endNodeIndex;
@@ -1082,7 +1134,14 @@ export default class MathRichInput extends React.Component {
1082
1134
  this.getMimeType(false, false),
1083
1135
  new_rangeParams,
1084
1136
  this.props.useExpertMode,
1085
- 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
+ }
1086
1145
  );
1087
1146
  e.preventDefault();
1088
1147
  return;
@@ -2142,6 +2201,9 @@ export default class MathRichInput extends React.Component {
2142
2201
  // console.log("handleInput")
2143
2202
  if (this.isComposing) return;
2144
2203
 
2204
+ const transaction = this.pendingTransaction;
2205
+ this.pendingTransaction = null;
2206
+
2145
2207
  debugMathRichInput("handleInput:start", {
2146
2208
  inputType: event && event.nativeEvent ? event.nativeEvent.inputType : null,
2147
2209
  data: event && event.nativeEvent ? event.nativeEvent.data : null,
@@ -2202,7 +2264,7 @@ export default class MathRichInput extends React.Component {
2202
2264
  defaultParams,
2203
2265
  this.props.useExpertMode,
2204
2266
  this.props.selectedTab,
2205
- { skipControlledRender: true }
2267
+ { skipControlledRender: true, transaction }
2206
2268
  );
2207
2269
  return;
2208
2270
  } else if (
@@ -2252,7 +2314,7 @@ export default class MathRichInput extends React.Component {
2252
2314
  validParams,
2253
2315
  this.props.useExpertMode,
2254
2316
  this.props.selectedTab,
2255
- { skipControlledRender: true }
2317
+ { skipControlledRender: true, transaction }
2256
2318
  );
2257
2319
  debugMathRichInput("handleInput:after-apply", {
2258
2320
  raw_text,
@@ -2350,6 +2412,7 @@ export default class MathRichInput extends React.Component {
2350
2412
 
2351
2413
  handlePaste = (event) => {
2352
2414
  try {
2415
+ this.pendingTransaction = null;
2353
2416
  let clipboardData = event.clipboardData || window.clipboardData;
2354
2417
  if (!clipboardData) {
2355
2418
  return;
@@ -2375,6 +2438,8 @@ export default class MathRichInput extends React.Component {
2375
2438
  console.warn("Could not get range params for paste");
2376
2439
  return;
2377
2440
  }
2441
+ const beforePaste = editableText(this.editableDiv);
2442
+ const pasteSelection = this.captureSelectionOffsets();
2378
2443
 
2379
2444
  // Store old range params for cursor positioning (like equation editor)
2380
2445
  this.setOldRangeParams(rangeParams);
@@ -2647,12 +2712,32 @@ export default class MathRichInput extends React.Component {
2647
2712
  );
2648
2713
 
2649
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
+
2650
2734
  this.applyChangesToComponent(
2651
2735
  cleanedRawText,
2652
2736
  actualMimeType,
2653
2737
  newRangeParams,
2654
2738
  this.props.useExpertMode,
2655
- this.props.selectedTab
2739
+ this.props.selectedTab,
2740
+ { transaction }
2656
2741
  );
2657
2742
 
2658
2743
  // A paste cannot rely on being re-rendered. It removed the selected content from the DOM itself,
@@ -2693,6 +2778,7 @@ export default class MathRichInput extends React.Component {
2693
2778
 
2694
2779
  this.editableDiv.addEventListener("keydown", this.handleKeyDown);
2695
2780
  this.editableDiv.addEventListener("keyup", this.handleKeyUp);
2781
+ this.editableDiv.addEventListener("beforeinput", this.handleBeforeInput);
2696
2782
  this.editableDiv.addEventListener("paste", this.handlePaste);
2697
2783
  this.editableDiv.addEventListener("copy", this.handleCopy);
2698
2784
  if (this.props.autofocus === true) this.editableDiv.focus();
@@ -2705,6 +2791,7 @@ export default class MathRichInput extends React.Component {
2705
2791
  try {
2706
2792
  this.editableDiv.removeEventListener("keydown", this.handleKeyDown);
2707
2793
  this.editableDiv.removeEventListener("keyup", this.handleKeyUp);
2794
+ this.editableDiv.removeEventListener("beforeinput", this.handleBeforeInput);
2708
2795
  this.editableDiv.removeEventListener("paste", this.handlePaste);
2709
2796
  this.editableDiv.removeEventListener("copy", this.handleCopy);
2710
2797
  } catch (error) {
@@ -3310,12 +3397,19 @@ export default class MathRichInput extends React.Component {
3310
3397
  }
3311
3398
 
3312
3399
  const rangeParams = this._getRangeParams();
3313
- const removedEmptyTags =
3314
- !insertedTypingStylePlaceholder &&
3315
- this.cleanupEmptyInlineFormattingElements();
3316
- if (removedEmptyTags && rangeParams !== null && rangeParams !== undefined) {
3400
+ if (!insertedTypingStylePlaceholder) this.cleanupEmptyInlineFormattingElements();
3401
+ if (rangeParams !== null && rangeParams !== undefined) {
3317
3402
  this._setRangeParams(rangeParams);
3318
- 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
+ });
3319
3413
  }
3320
3414
  this.setActiveButtonsIfChanged(
3321
3415
  this.getActiveButtonsFromSelection(rangeParams)
@@ -3371,6 +3465,7 @@ export default class MathRichInput extends React.Component {
3371
3465
 
3372
3466
  insertRawText = (new_text, selectInsertedText = false) => {
3373
3467
  try {
3468
+ const beforeText = editableText(this.editableDiv);
3374
3469
  let rangeParams = this._getRangeParams();
3375
3470
  let node = this._findNodeWithIndex(rangeParams.endNodeIndex);
3376
3471
  let offset = rangeParams.endOffset;
@@ -3422,7 +3517,19 @@ export default class MathRichInput extends React.Component {
3422
3517
  this.props.mimeType,
3423
3518
  new_rangeParams,
3424
3519
  this.props.useExpertMode,
3425
- 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
+ }
3426
3533
  );
3427
3534
  } catch (error) {
3428
3535
  console.error(error);
@@ -3577,6 +3684,7 @@ export default class MathRichInput extends React.Component {
3577
3684
 
3578
3685
  removeNewLine = () => {
3579
3686
  try {
3687
+ const beforeText = editableText(this.editableDiv);
3580
3688
  let rangeParams = this._getRangeParams();
3581
3689
  let node = this._findNodeWithIndex(rangeParams.endNodeIndex);
3582
3690
  let prevNode = this._findNodeWithIndex(rangeParams.endNodeIndex - 1);
@@ -3613,7 +3721,19 @@ export default class MathRichInput extends React.Component {
3613
3721
  this.props.mimeType,
3614
3722
  new_rangeParams,
3615
3723
  this.props.useExpertMode,
3616
- 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
+ }
3617
3737
  );
3618
3738
  } catch (error) {
3619
3739
  console.error(error);
@@ -3622,6 +3742,7 @@ export default class MathRichInput extends React.Component {
3622
3742
 
3623
3743
  insertNewLine = () => {
3624
3744
  try {
3745
+ const beforeText = editableText(this.editableDiv);
3625
3746
  let rangeParams = this._getRangeParams();
3626
3747
  let node = this._findNodeWithIndex(rangeParams.endNodeIndex);
3627
3748
  let offset = rangeParams.endOffset;
@@ -3678,14 +3799,26 @@ export default class MathRichInput extends React.Component {
3678
3799
  this.props.mimeType,
3679
3800
  new_rangeParams,
3680
3801
  this.props.useExpertMode,
3681
- 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
+ }
3682
3815
  );
3683
3816
  } catch (error) {
3684
3817
  console.error(error);
3685
3818
  }
3686
3819
  };
3687
3820
 
3688
- insertAfterSmallSpace(new_char, rangeParams) {
3821
+ insertAfterSmallSpace(new_char, rangeParams) {
3689
3822
  try {
3690
3823
  if (rangeParams === null || rangeParams === undefined)
3691
3824
  rangeParams = this._getRangeParams();
@@ -3697,6 +3830,7 @@ export default class MathRichInput extends React.Component {
3697
3830
  " in insertAfterSmallSpace"
3698
3831
  );
3699
3832
 
3833
+ const beforeText = editableText(this.editableDiv);
3700
3834
  let node = this._findNodeWithIndex(rangeParams.startNodeIndex);
3701
3835
  if (node === null || node === undefined || node.nodeValue === null) {
3702
3836
  console.warn("Could not find small-space text node");
@@ -3736,14 +3870,21 @@ export default class MathRichInput extends React.Component {
3736
3870
  this.enableHtml()
3737
3871
  );
3738
3872
 
3739
- this.applyChangesToComponent(
3873
+ this.applyChangesToComponent(
3740
3874
  new_raw_text,
3741
3875
  this.props.mimeType,
3742
3876
  new_rangeParams,
3743
3877
  this.props.useExpertMode,
3744
3878
  this.props.selectedTab,
3745
- { skipControlledRender: true }
3746
- );
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
+ );
3747
3888
  } catch (error) {
3748
3889
  console.error(error);
3749
3890
  }
@@ -3751,6 +3892,7 @@ export default class MathRichInput extends React.Component {
3751
3892
 
3752
3893
  insertAccentLetter = (event, old_char, new_char) => {
3753
3894
  try {
3895
+ const beforeText = editableText(this.editableDiv);
3754
3896
  let rangeParams = this._getRangeParams();
3755
3897
  let node = this._findNodeWithIndex(rangeParams.startNodeIndex);
3756
3898
  let offset = rangeParams.startOffset;
@@ -3797,7 +3939,19 @@ export default class MathRichInput extends React.Component {
3797
3939
  this.props.mimeType,
3798
3940
  new_rangeParams,
3799
3941
  this.props.useExpertMode,
3800
- 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
+ }
3801
3955
  );
3802
3956
  } catch (error) {
3803
3957
  console.error(error);
@@ -3913,11 +4067,63 @@ export default class MathRichInput extends React.Component {
3913
4067
  };
3914
4068
 
3915
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
+ };
3916
4076
  this.isComposing = true;
3917
4077
  };
3918
4078
 
3919
4079
  handleCompositionEnd = (event) => {
3920
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
+ }
3921
4127
  };
3922
4128
 
3923
4129
  // Comprehensive LaTeX pattern detection and auto-wrapping in math tags