@zzish/math-rich-input 0.1.51 → 0.1.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,6 +34,10 @@ import {
34
34
  globalOffsetToPlainTextOffset,
35
35
  plainTextOffsetToGlobalOffset,
36
36
  } from "./mathRichInputHelper";
37
+ import {
38
+ debugMathRichInput,
39
+ getSelectionSnapshot,
40
+ } from "./mathRichInputDebug";
37
41
 
38
42
  import "./MathRichInput.css";
39
43
  import AccentBar from "./AccentBar";
@@ -50,6 +54,30 @@ const SMALL_SPACE_LENGTH = SMALL_SPACE.length;
50
54
 
51
55
  const MARK = getMark();
52
56
  const MARK_REG_EXP = new RegExp(MARK, "g");
57
+ const INLINE_STYLE_COMMANDS = {
58
+ bold: "bold",
59
+ italic: "italic",
60
+ underline: "underline",
61
+ subscript: "subscript",
62
+ superscript: "superscript",
63
+ };
64
+ const INLINE_STYLE_TAGS = {
65
+ bold: ["b", "strong"],
66
+ italic: ["i", "em"],
67
+ underline: ["u"],
68
+ subscript: ["sub"],
69
+ superscript: ["sup"],
70
+ };
71
+ const INLINE_STYLE_TAG_NAMES = ["b", "strong", "i", "em", "u", "sub", "sup"];
72
+ const INLINE_STYLE_WRAPPER_TAGS = {
73
+ bold: "strong",
74
+ italic: "em",
75
+ underline: "u",
76
+ subscript: "sub",
77
+ superscript: "sup",
78
+ };
79
+ const EMPTY_INLINE_FORMATTING_REG_EXP =
80
+ /<(B|STRONG|I|EM|U|SUB|SUP)>(?:\s|&nbsp;|\u00a0|\u200b|\ufeff)*<\/\1>/gi;
53
81
 
54
82
  // const LATEX_MARKER_START = "<annotation encoding=\"application/x-tex\">"
55
83
  // const LATEX_MARKER_END = "</annotation>"
@@ -190,17 +218,20 @@ export default class MathRichInput extends React.Component {
190
218
 
191
219
  // Elements that need to be set before the equation editor is shown
192
220
  this.markedRawText = null;
193
- this.oldRangeParams = {
194
- startNodeIndex: 0,
195
- startOffset: 0,
196
- endNodeIndex: 0,
197
- endOffset: 0,
198
- };
221
+ // No selection has been captured yet. Do not default this to the start of
222
+ // the editor: React 19 focus/click ordering makes handleFocus restore that
223
+ // synthetic 0,0 range and moves the caret to the beginning on every click.
224
+ this.oldRangeParams = null;
199
225
  this.moveCursorOnInsert = false;
200
226
 
201
227
  // Elements defining the current state of range
202
228
  this.afterComponentUpdateData = null; // Set this to update the selection after rendering
203
- this.lastSetRangeParmas = null; // This is set automatically each time the range params are set with setRangeParams
229
+ this.lastSetRangeParams = null; // This is set automatically each time the range params are set with setRangeParams
230
+ this.skipNextControlledValueRender = false;
231
+ this.skipNextControlledValue = null;
232
+ this.isMouseDownInEditable = false;
233
+ this.lastRenderedEditableHtml = null;
234
+ this.preserveNativeDomRangeParams = null;
204
235
 
205
236
  this.lastSetActiveButtons = null;
206
237
 
@@ -292,12 +323,35 @@ export default class MathRichInput extends React.Component {
292
323
  mimeType,
293
324
  rangeParams,
294
325
  isExpertMode,
295
- selectedTab
326
+ selectedTab,
327
+ applyOptions = {}
296
328
  ) => {
297
329
  // rangeParams, isExpertMode and selectedTab can be null, in which case we will set them
298
330
  if (rangeParams === null) rangeParams = this.lastSetRangeParams;
299
331
 
300
- this.afterComponentUpdateData = { rangeParams, value };
332
+ debugMathRichInput("applyChangesToComponent:start", {
333
+ value,
334
+ mimeType,
335
+ rangeParams,
336
+ propsValue: this.props.value,
337
+ propsMimeType: this.props.mimeType,
338
+ innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
339
+ selection: getSelectionSnapshot(this.editableDiv),
340
+ skipControlledRender: applyOptions.skipControlledRender === true,
341
+ });
342
+
343
+ if (applyOptions.skipControlledRender === true) {
344
+ // Native contenteditable input has already mutated the DOM and kept the
345
+ // browser selection in the right place. Re-rendering the same value via
346
+ // dangerouslySetInnerHTML can replace text nodes under React 19 and move
347
+ // the caret to the beginning. Programmatic edits still use the normal
348
+ // render-and-restore path.
349
+ this.skipNextControlledValueRender = true;
350
+ this.skipNextControlledValue = value;
351
+ this.afterComponentUpdateData = null;
352
+ } else {
353
+ this.afterComponentUpdateData = { rangeParams, value };
354
+ }
301
355
 
302
356
  let options = this.props.options || DEFAULT_OPTIONS;
303
357
  if (isExpertMode === null) isExpertMode = options.isExpertMode;
@@ -312,6 +366,10 @@ export default class MathRichInput extends React.Component {
312
366
 
313
367
  // Call the parent handler to apply the changes
314
368
  this.onChangeCallback({ value, mimeType }, { isExpertMode, selectedTab });
369
+ debugMathRichInput("applyChangesToComponent:after-onChange", {
370
+ afterComponentUpdateData: this.afterComponentUpdateData,
371
+ selection: getSelectionSnapshot(this.editableDiv),
372
+ });
315
373
  };
316
374
 
317
375
  initialiseHistory = () => {
@@ -392,23 +450,550 @@ export default class MathRichInput extends React.Component {
392
450
  return params;
393
451
  }
394
452
 
453
+ rangeIsAtStart(params) {
454
+ if (params === null || params === undefined) return false;
455
+ if (
456
+ params.startGlobalOffset !== null &&
457
+ params.startGlobalOffset !== undefined &&
458
+ params.endGlobalOffset !== null &&
459
+ params.endGlobalOffset !== undefined
460
+ ) {
461
+ return params.startGlobalOffset === 0 && params.endGlobalOffset === 0;
462
+ }
463
+ return (
464
+ params.startNodeIndex === 0 &&
465
+ params.endNodeIndex === 0 &&
466
+ params.startOffset === 0 &&
467
+ params.endOffset === 0
468
+ );
469
+ }
470
+
471
+ rangeHasNonZeroPosition(params) {
472
+ if (params === null || params === undefined) return false;
473
+ if (
474
+ params.startGlobalOffset !== null &&
475
+ params.startGlobalOffset !== undefined
476
+ ) {
477
+ return params.startGlobalOffset > 0;
478
+ }
479
+ return (
480
+ params.startNodeIndex > 0 ||
481
+ params.endNodeIndex > 0 ||
482
+ params.startOffset > 0 ||
483
+ params.endOffset > 0
484
+ );
485
+ }
486
+
487
+ getCurrentRawTextForProps(nextProps) {
488
+ if (!this.editableDiv) return null;
489
+ try {
490
+ let rawText = elementToMarkedRawText(
491
+ this.editableDiv,
492
+ null,
493
+ 0,
494
+ this.enableHtml()
495
+ );
496
+ rawText = this.stripEmptyInlineFormattingTags(rawText);
497
+ return removeEncodingIfPlainText(
498
+ rawText,
499
+ nextProps.mimeType,
500
+ this.enableHtml()
501
+ );
502
+ } catch (error) {
503
+ debugMathRichInput("getCurrentRawTextForProps:error", {
504
+ error,
505
+ selection: getSelectionSnapshot(this.editableDiv),
506
+ });
507
+ return null;
508
+ }
509
+ }
510
+
511
+ shouldSkipRedundantFocusedRender(nextProps, nextState) {
512
+ if (!this.editableDiv) return false;
513
+ if (nextState !== this.state) return false;
514
+ if (nextState.hasFocus !== true) return false;
515
+ if (
516
+ this.afterComponentUpdateData !== null &&
517
+ this.afterComponentUpdateData !== undefined
518
+ ) {
519
+ return false;
520
+ }
521
+
522
+ const currentRawText = this.getCurrentRawTextForProps(nextProps);
523
+ const shouldSkip = currentRawText === nextProps.value;
524
+ debugMathRichInput("shouldComponentUpdate:redundant-focused-check", {
525
+ shouldSkip,
526
+ currentRawText,
527
+ nextValue: nextProps.value,
528
+ currentPropsValue: this.props.value,
529
+ nextMimeType: nextProps.mimeType,
530
+ currentMimeType: this.props.mimeType,
531
+ selection: getSelectionSnapshot(this.editableDiv),
532
+ });
533
+
534
+ if (shouldSkip) {
535
+ const currentRangeParams = this.getRangeParamsPreservingInlinePlaceholder();
536
+ if (this.rangeHasNonZeroPosition(currentRangeParams)) {
537
+ this.setOldRangeParams(currentRangeParams);
538
+ }
539
+ }
540
+
541
+ return shouldSkip;
542
+ }
543
+
544
+ isSelectionInsideInlinePlaceholder() {
545
+ if (typeof document === "undefined") return false;
546
+
547
+ const selection = document.getSelection ? document.getSelection() : null;
548
+ if (!selection || selection.rangeCount === 0) return false;
549
+
550
+ const range = selection.getRangeAt(0);
551
+ if (!range.collapsed) return false;
552
+
553
+ const node = range.startContainer;
554
+ if (!node || node.nodeType !== 3) return false;
555
+ if (node.nodeValue !== SMALL_SPACE) return false;
556
+ if (range.startOffset !== SMALL_SPACE_LENGTH) return false;
557
+
558
+ const parent = node.parentNode;
559
+ if (!parent || parent.nodeType !== 1) return false;
560
+
561
+ const tagName = parent.nodeName.toLowerCase();
562
+ if (!INLINE_STYLE_TAG_NAMES.includes(tagName)) return false;
563
+
564
+ return parent.textContent === SMALL_SPACE;
565
+ }
566
+
567
+ getRangeParamsPreservingInlinePlaceholder() {
568
+ const selectionInsideInlinePlaceholder =
569
+ this.isSelectionInsideInlinePlaceholder();
570
+ const rangeParams = this._getRangeParams();
571
+
572
+ if (!selectionInsideInlinePlaceholder || !rangeParams) {
573
+ return rangeParams;
574
+ }
575
+
576
+ // Global offsets ignore the zero-width placeholder; node index preserves it.
577
+ return {
578
+ ...rangeParams,
579
+ startGlobalOffset: null,
580
+ endGlobalOffset: null,
581
+ };
582
+ }
583
+
584
+ getEditableHtmlForRender(html) {
585
+ const currentRawText = this.editableDiv
586
+ ? this.getCurrentRawTextForProps(this.props)
587
+ : null;
588
+ const liveEditableHtml = this.editableDiv ? this.editableDiv.innerHTML : null;
589
+ const canPreserveNativeDom =
590
+ this.state.hasFocus === true &&
591
+ this.editableDiv !== null &&
592
+ this.editableDiv !== undefined &&
593
+ (this.afterComponentUpdateData === null ||
594
+ this.afterComponentUpdateData === undefined) &&
595
+ this.lastRenderedEditableHtml !== null &&
596
+ currentRawText === this.props.value;
597
+ const preserveRangeParams = canPreserveNativeDom
598
+ ? this.getRangeParamsPreservingInlinePlaceholder()
599
+ : null;
600
+
601
+ debugMathRichInput("render:editable-html", {
602
+ canPreserveNativeDom,
603
+ selectionInsideInlinePlaceholder:
604
+ this.isSelectionInsideInlinePlaceholder(),
605
+ propsValue: this.props.value,
606
+ generatedHtml: html,
607
+ renderedHtml: canPreserveNativeDom ? liveEditableHtml : html,
608
+ liveEditableHtml,
609
+ lastRenderedEditableHtml: this.lastRenderedEditableHtml,
610
+ currentRawText,
611
+ preserveRangeParams,
612
+ afterComponentUpdateData: this.afterComponentUpdateData,
613
+ hasFocus: this.state.hasFocus,
614
+ selection: getSelectionSnapshot(this.editableDiv),
615
+ });
616
+
617
+ if (canPreserveNativeDom) {
618
+ this.lastRenderedEditableHtml = liveEditableHtml;
619
+ this.preserveNativeDomRangeParams = preserveRangeParams;
620
+ return liveEditableHtml;
621
+ }
622
+
623
+ this.lastRenderedEditableHtml = html;
624
+ this.preserveNativeDomRangeParams = null;
625
+ return html;
626
+ }
627
+
628
+ stripEmptyInlineFormattingTags(rawText) {
629
+ if (rawText === null || rawText === undefined) return rawText;
630
+ let cleaned = rawText;
631
+ let previous = null;
632
+ while (previous !== cleaned) {
633
+ previous = cleaned;
634
+ cleaned = cleaned.replace(EMPTY_INLINE_FORMATTING_REG_EXP, "");
635
+ }
636
+ return cleaned;
637
+ }
638
+
639
+ cleanupEmptyInlineFormattingElements() {
640
+ if (!this.editableDiv) return false;
641
+
642
+ let removed = false;
643
+ this.editableDiv
644
+ .querySelectorAll("b,strong,i,em,u,sub,sup")
645
+ .forEach((node) => {
646
+ const text = node.textContent || "";
647
+ const hasMeaningfulText = text.replace(/[\s\u00a0\u200b\ufeff]/g, "") !== "";
648
+ const hasProtectedContent =
649
+ node.querySelector("br,img,math,.katex") !== null;
650
+
651
+ if (!hasMeaningfulText && !hasProtectedContent) {
652
+ node.remove();
653
+ removed = true;
654
+ }
655
+ });
656
+
657
+ if (removed) {
658
+ this.editableDiv.normalize();
659
+ }
660
+
661
+ return removed;
662
+ }
663
+
664
+ getStyleCommand(style) {
665
+ return INLINE_STYLE_COMMANDS[style] || style;
666
+ }
667
+
668
+ insertInlineStylePlaceholder(style) {
669
+ const wrapperTag = INLINE_STYLE_WRAPPER_TAGS[style] || "span";
670
+ const selection = document.getSelection();
671
+ if (!selection || selection.rangeCount === 0) return false;
672
+
673
+ const range = selection.getRangeAt(0);
674
+ if (!range.collapsed) return false;
675
+
676
+ range.deleteContents();
677
+
678
+ const wrapper = document.createElement(wrapperTag);
679
+ const textNode = document.createTextNode(SMALL_SPACE);
680
+ wrapper.appendChild(textNode);
681
+ range.insertNode(wrapper);
682
+
683
+ const nextRange = document.createRange();
684
+ nextRange.setStart(textNode, SMALL_SPACE_LENGTH);
685
+ nextRange.collapse(true);
686
+ selection.removeAllRanges();
687
+ selection.addRange(nextRange);
688
+
689
+ const rangeParams = this.getRangeParamsPreservingInlinePlaceholder();
690
+ if (rangeParams) this.setOldRangeParams(rangeParams);
691
+
692
+ debugMathRichInput("insertInlineStylePlaceholder:after", {
693
+ style,
694
+ wrapperTag,
695
+ innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
696
+ rangeParams,
697
+ selection: getSelectionSnapshot(this.editableDiv),
698
+ });
699
+
700
+ return true;
701
+ }
702
+
703
+ getActiveButtonsFromCommandState() {
704
+ const query = (command) => {
705
+ try {
706
+ return document.queryCommandState(command);
707
+ } catch (error) {
708
+ return false;
709
+ }
710
+ };
711
+
712
+ return {
713
+ bold: query("bold"),
714
+ italic: query("italic"),
715
+ underline: query("underline"),
716
+ subscript: query("subscript"),
717
+ superscript: query("superscript"),
718
+ };
719
+ }
720
+
721
+ isSelectionCollapsed() {
722
+ const selection = document.getSelection();
723
+ if (!selection || selection.rangeCount === 0) return true;
724
+ return selection.getRangeAt(0).collapsed;
725
+ }
726
+
727
+ getActiveButtonsFromAncestorState(rangeParams = null) {
728
+ if (rangeParams === null || rangeParams === undefined) {
729
+ rangeParams = this._getRangeParams();
730
+ }
731
+
732
+ let activeButtons = {
733
+ bold: false,
734
+ italic: false,
735
+ underline: false,
736
+ subscript: false,
737
+ superscript: false,
738
+ };
739
+
740
+ if (
741
+ rangeParams === null ||
742
+ rangeParams === undefined ||
743
+ rangeParams.startNodeIndex < 0
744
+ ) {
745
+ return activeButtons;
746
+ }
747
+
748
+ let node = this._findNodeWithIndex(rangeParams.startNodeIndex);
749
+ if (node === null || node === undefined) return activeButtons;
750
+
751
+ do {
752
+ if (node.nodeType === 1) {
753
+ let nodeName = node.nodeName.toLowerCase();
754
+ if (nodeName === "b" || nodeName === "strong") activeButtons.bold = true;
755
+ else if (nodeName === "i" || nodeName === "em")
756
+ activeButtons.italic = true;
757
+ else if (nodeName === "u") activeButtons.underline = true;
758
+ else if (nodeName === "sub") activeButtons.subscript = true;
759
+ else if (nodeName === "sup") activeButtons.superscript = true;
760
+ } else if (
761
+ node.classList !== null &&
762
+ node.classList !== undefined &&
763
+ node.classList.contains("MathRichInput")
764
+ )
765
+ break;
766
+ node = node.parentNode;
767
+ } while (node !== null);
768
+
769
+ return activeButtons;
770
+ }
771
+
772
+ getActiveButtonsFromSelection(rangeParams = null) {
773
+ const commandState = this.getActiveButtonsFromCommandState();
774
+ const ancestorState = this.getActiveButtonsFromAncestorState(rangeParams);
775
+ return {
776
+ bold: commandState.bold || ancestorState.bold,
777
+ italic: commandState.italic || ancestorState.italic,
778
+ underline: commandState.underline || ancestorState.underline,
779
+ subscript: commandState.subscript || ancestorState.subscript,
780
+ superscript: commandState.superscript || ancestorState.superscript,
781
+ };
782
+ }
783
+
784
+ setActiveButtonsIfChanged(activeButtons) {
785
+ let update = true;
786
+ if (this.lastSetActiveButtons !== null) {
787
+ update = false;
788
+ for (var style in activeButtons) {
789
+ if (activeButtons[style] !== this.lastSetActiveButtons[style]) {
790
+ update = true;
791
+ break;
792
+ }
793
+ }
794
+ }
795
+
796
+ if (update) {
797
+ this.lastSetActiveButtons = activeButtons;
798
+ this.setState({ activeButtons: activeButtons });
799
+ }
800
+ }
801
+
802
+ getInlineStyleAncestor(style) {
803
+ const tags = INLINE_STYLE_TAGS[style];
804
+ if (!tags) return null;
805
+
806
+ const selection = document.getSelection();
807
+ if (!selection || selection.rangeCount === 0) return null;
808
+
809
+ let node = selection.getRangeAt(0).startContainer;
810
+ if (node.nodeType === Node.TEXT_NODE) node = node.parentNode;
811
+
812
+ while (node && node !== this.editableDiv) {
813
+ if (
814
+ node.nodeType === Node.ELEMENT_NODE &&
815
+ tags.includes(node.nodeName.toLowerCase())
816
+ ) {
817
+ return node;
818
+ }
819
+ node = node.parentNode;
820
+ }
821
+
822
+ return null;
823
+ }
824
+
825
+ isRangeAtEndOfNode(range, node) {
826
+ try {
827
+ const afterRange = document.createRange();
828
+ afterRange.setStart(range.startContainer, range.startOffset);
829
+ afterRange.setEnd(node, node.childNodes.length);
830
+ const textAfterCaret = afterRange
831
+ .toString()
832
+ .replace(/[\s\u00a0\u200b\ufeff]/g, "");
833
+ if (textAfterCaret === "") return true;
834
+ } catch (error) {
835
+ // Fall back to boundary comparison below if the browser rejects the range.
836
+ }
837
+
838
+ const nodeEndRange = document.createRange();
839
+ nodeEndRange.selectNodeContents(node);
840
+ nodeEndRange.collapse(false);
841
+ return range.compareBoundaryPoints(Range.START_TO_START, nodeEndRange) === 0;
842
+ }
843
+
844
+ moveTrailingWhitespaceOutsideInlineStyle(style, command) {
845
+ const selection = document.getSelection();
846
+ if (!selection || selection.rangeCount === 0) return false;
847
+
848
+ const range = selection.getRangeAt(0);
849
+ if (!range.collapsed) return false;
850
+
851
+ const ancestor = this.getInlineStyleAncestor(style);
852
+ if (!ancestor || !this.isRangeAtEndOfNode(range, ancestor)) return false;
853
+ if (range.startContainer.nodeType !== Node.TEXT_NODE) return false;
854
+
855
+ const textNode = range.startContainer;
856
+ const text = textNode.nodeValue || "";
857
+ let moveStart = range.startOffset;
858
+ while (moveStart > 0) {
859
+ const char = text.charAt(moveStart - 1);
860
+ if (char !== " " && char !== "\u00a0") break;
861
+ moveStart -= 1;
862
+ }
863
+
864
+ if (moveStart === range.startOffset) return false;
865
+
866
+ const movedText = text.substring(moveStart, range.startOffset);
867
+ textNode.nodeValue =
868
+ text.substring(0, moveStart) + text.substring(range.startOffset);
869
+
870
+ let outsideNode = ancestor.nextSibling;
871
+ if (!outsideNode || outsideNode.nodeType !== Node.TEXT_NODE) {
872
+ outsideNode = document.createTextNode(movedText);
873
+ ancestor.parentNode.insertBefore(outsideNode, ancestor.nextSibling);
874
+ } else {
875
+ outsideNode.nodeValue = movedText + outsideNode.nodeValue;
876
+ }
877
+
878
+ const nextRange = document.createRange();
879
+ nextRange.setStart(outsideNode, movedText.length);
880
+ nextRange.collapse(true);
881
+ selection.removeAllRanges();
882
+ selection.addRange(nextRange);
883
+
884
+ try {
885
+ if (document.queryCommandState(command)) {
886
+ document.execCommand(command, false, null);
887
+ }
888
+ } catch (error) {
889
+ // DOM position is the source of truth here; command state is best effort.
890
+ }
891
+
892
+ const rangeParams = this._getRangeParams();
893
+ if (rangeParams) {
894
+ this.setOldRangeParams(rangeParams);
895
+ this.applyCurrentEditableDomToComponent(rangeParams);
896
+ }
897
+
898
+ debugMathRichInput("moveTrailingWhitespaceOutsideInlineStyle:after", {
899
+ style,
900
+ command,
901
+ movedText,
902
+ innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
903
+ rangeParams,
904
+ selection: getSelectionSnapshot(this.editableDiv),
905
+ });
906
+ return true;
907
+ }
908
+
909
+ applyCurrentEditableDomToComponent(rangeParams = null) {
910
+ if (!this.editableDiv) return;
911
+ if (rangeParams === null || rangeParams === undefined) {
912
+ rangeParams = this._getRangeParams();
913
+ }
914
+ if (rangeParams === null || rangeParams === undefined) return;
915
+
916
+ let rawText = elementToMarkedRawText(
917
+ this.editableDiv,
918
+ null,
919
+ 0,
920
+ this.enableHtml()
921
+ );
922
+ rawText = this.stripEmptyInlineFormattingTags(rawText);
923
+
924
+ const nodeCounts = this._countNodeTypes();
925
+ const hasMath = /<math>/i.test(rawText) || nodeCounts.math > 0;
926
+ const hasHtml = nodeCounts.html > 0 || /<[^>]+>/i.test(rawText);
927
+ const mimeType = this.getMimeType(hasHtml, hasMath);
928
+
929
+ rawText = removeEncodingIfPlainText(
930
+ rawText,
931
+ mimeType,
932
+ this.enableHtml()
933
+ );
934
+ rawText = this.stripEmptyInlineFormattingTags(rawText);
935
+
936
+ this.applyChangesToComponent(
937
+ rawText,
938
+ mimeType,
939
+ rangeParams,
940
+ this.props.useExpertMode,
941
+ this.props.selectedTab,
942
+ { skipControlledRender: true }
943
+ );
944
+ }
945
+
395
946
  _getRangeParams(editableDiv = null) {
396
947
  if (editableDiv === null) editableDiv = this.editableDiv;
397
948
  let rangeParams = getRangeParams(editableDiv);
949
+ debugMathRichInput("MathRichInput:_getRangeParams", {
950
+ rangeParams,
951
+ selection: getSelectionSnapshot(editableDiv),
952
+ });
398
953
  return rangeParams;
399
954
  }
400
955
 
401
956
  _setRangeParams = (params) => {
402
957
  try {
958
+ debugMathRichInput("MathRichInput:_setRangeParams:start", {
959
+ params,
960
+ selectionBefore: getSelectionSnapshot(this.editableDiv),
961
+ });
403
962
  setRangeParams(this.editableDiv, params);
404
963
  this.setOldRangeParams(params);
405
- this.lastSetRangeParmas = params;
964
+ this.lastSetRangeParams = params;
965
+ debugMathRichInput("MathRichInput:_setRangeParams:after", {
966
+ params,
967
+ oldRangeParams: this.oldRangeParams,
968
+ selectionAfter: getSelectionSnapshot(this.editableDiv),
969
+ });
406
970
  } catch (error) {
407
971
  console.error(error);
408
972
  // console.log(this.editableDiv.childNodes);
409
973
  }
410
974
  };
411
975
 
976
+ restoreRangeAfterBrowserWork = (params, reason) => {
977
+ if (params === null || params === undefined) return;
978
+ const restore = (delay) => {
979
+ window.setTimeout(() => {
980
+ if (this.state.showEquationEditor) return;
981
+ if (!this.state.hasFocus && document.activeElement !== this.editableDiv) {
982
+ return;
983
+ }
984
+ debugMathRichInput("restoreRangeAfterBrowserWork", {
985
+ reason,
986
+ delay,
987
+ params,
988
+ selectionBefore: getSelectionSnapshot(this.editableDiv),
989
+ });
990
+ this._setRangeParams(params);
991
+ }, delay);
992
+ };
993
+ restore(0);
994
+ restore(50);
995
+ };
996
+
412
997
  _findNodeWithIndex(index) {
413
998
  return findNodeWithIndex(this.editableDiv, index);
414
999
  }
@@ -596,7 +1181,11 @@ export default class MathRichInput extends React.Component {
596
1181
  // Check if word navigation modifier is pressed
597
1182
  isWordModifier = (e) => {
598
1183
  const { isMac } = this.getPlatformModifiers();
599
- return isMac ? e.altKey : e.ctrlKey;
1184
+ // Borough production is pinned to math-rich-input 0.1.48, where Option +
1185
+ // Arrow did not get custom word navigation. Keep Mac Option + Arrow as
1186
+ // normal character movement to preserve the editor's existing UX; Command +
1187
+ // Arrow is handled by isLineModifier below.
1188
+ return isMac ? false : e.ctrlKey;
600
1189
  };
601
1190
 
602
1191
  // Check if line navigation modifier is pressed
@@ -1160,6 +1749,17 @@ export default class MathRichInput extends React.Component {
1160
1749
 
1161
1750
  handleKeyDown = (e) => {
1162
1751
  try {
1752
+ debugMathRichInput("handleKeyDown:start", {
1753
+ key: e.key,
1754
+ altKey: e.altKey,
1755
+ metaKey: e.metaKey,
1756
+ ctrlKey: e.ctrlKey,
1757
+ shiftKey: e.shiftKey,
1758
+ hasFocus: this.state.hasFocus,
1759
+ propsValue: this.props.value,
1760
+ innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
1761
+ selection: getSelectionSnapshot(this.editableDiv),
1762
+ });
1163
1763
  // Lets set up or update the undo redo history
1164
1764
  this.initialiseHistory();
1165
1765
 
@@ -1246,7 +1846,8 @@ export default class MathRichInput extends React.Component {
1246
1846
  }
1247
1847
  }
1248
1848
 
1249
- if (e.altKey && e.key === "Alt") {
1849
+ const { isMac } = this.getPlatformModifiers();
1850
+ if (!isMac && e.altKey && e.key === "Alt") {
1250
1851
  let key = this.fetchAccentLetter();
1251
1852
  if (
1252
1853
  key === "a" ||
@@ -1262,7 +1863,9 @@ export default class MathRichInput extends React.Component {
1262
1863
  key === "!"
1263
1864
  ) {
1264
1865
  // console.log("showing/hiding accent bar");
1265
- this.setState({ showAccentBar: !this.state.showAccentBar });
1866
+ this.setState({
1867
+ showAccentBar: !this.state.showAccentBar,
1868
+ });
1266
1869
  } else {
1267
1870
  // console.log("hiding accent bar");
1268
1871
  this.setState({ showAccentBar: false });
@@ -1272,7 +1875,9 @@ export default class MathRichInput extends React.Component {
1272
1875
  }
1273
1876
 
1274
1877
  this.keyPressed = e.key; // store for use in methods
1275
- this.setState({ keyPressed: e.key });
1878
+ if (this.state.showAccentBar) {
1879
+ this.setState({ keyPressed: e.key });
1880
+ }
1276
1881
 
1277
1882
  // Hide the accent bar if a key is typed except when it is an automatic key repeat press
1278
1883
  // If it is automatic key repeat, then keyPressStartTime will be greater than 0
@@ -1291,10 +1896,16 @@ export default class MathRichInput extends React.Component {
1291
1896
  }
1292
1897
  if (e.key === "ArrowLeft") {
1293
1898
  this.handleKeyDownArrowLeft(e);
1899
+ debugMathRichInput("handleKeyDown:after-arrow-left", {
1900
+ selection: getSelectionSnapshot(this.editableDiv),
1901
+ });
1294
1902
  return;
1295
1903
  }
1296
1904
  if (e.key === "ArrowRight") {
1297
1905
  this.handleKeyDownArrowRight(e);
1906
+ debugMathRichInput("handleKeyDown:after-arrow-right", {
1907
+ selection: getSelectionSnapshot(this.editableDiv),
1908
+ });
1298
1909
  return;
1299
1910
  }
1300
1911
  if (e.key === "ArrowUp") {
@@ -1361,6 +1972,10 @@ export default class MathRichInput extends React.Component {
1361
1972
 
1362
1973
  // Stop editing if within rendered katex node, if somehow the cursor ends up there (it shouldn't)
1363
1974
  let params = this._getRangeParams();
1975
+ // No range means no caret to protect — a field the browser has not placed a selection in yet. It
1976
+ // reads as null on the first keystroke after a click on a toolbar button, and dereferencing it threw
1977
+ // on every Cmd, every Cmd+A and every Cmd+V the teacher pressed.
1978
+ if (params === null || params === undefined) return;
1364
1979
  let startNode = this._findNodeWithIndex(params.startNodeIndex);
1365
1980
  let endNode = this._findNodeWithIndex(params.endNodeIndex);
1366
1981
  if (!this.editableDivIsPlainText()) {
@@ -1403,13 +2018,26 @@ export default class MathRichInput extends React.Component {
1403
2018
 
1404
2019
  handleKeyUp = (e) => {
1405
2020
  try {
2021
+ debugMathRichInput("handleKeyUp:start", {
2022
+ key: e.key,
2023
+ altKey: e.altKey,
2024
+ metaKey: e.metaKey,
2025
+ ctrlKey: e.ctrlKey,
2026
+ shiftKey: e.shiftKey,
2027
+ selection: getSelectionSnapshot(this.editableDiv),
2028
+ });
1406
2029
  this.keyPressed = e.key;
1407
2030
  this.keyPressStartTime = -1;
1408
2031
 
1409
2032
  // If the cursor is in a katex node, then move it to the next node (unless there is no
1410
2033
  // net node in which case move it to the previous node)
1411
2034
  if (e.key === "ArrowRight") {
2035
+ // `_getRangeParams` returns null whenever the document's selection is not inside this field —
2036
+ // which happens in ordinary use, not only in error: a toolbar button taking focus is enough. It
2037
+ // was dereferenced straight away, so the key handler THREW and everything after it in the same
2038
+ // press was skipped.
1412
2039
  let rangeParams = this._getRangeParams();
2040
+ if (!rangeParams) return;
1413
2041
  let index = rangeParams.startNodeIndex;
1414
2042
 
1415
2043
  if (index >= 0) {
@@ -1444,7 +2072,9 @@ export default class MathRichInput extends React.Component {
1444
2072
  }
1445
2073
 
1446
2074
  if (e.key === "ArrowLeft") {
2075
+ // Same guard, same reason — see ArrowRight above.
1447
2076
  let rangeParams = this._getRangeParams();
2077
+ if (!rangeParams) return;
1448
2078
  let index = rangeParams.startNodeIndex;
1449
2079
 
1450
2080
  // If the cursor is in a katex node, then move it to the previous node (unless there is no
@@ -1491,6 +2121,17 @@ export default class MathRichInput extends React.Component {
1491
2121
  // console.log("handleInput")
1492
2122
  if (this.isComposing) return;
1493
2123
 
2124
+ debugMathRichInput("handleInput:start", {
2125
+ inputType: event && event.nativeEvent ? event.nativeEvent.inputType : null,
2126
+ data: event && event.nativeEvent ? event.nativeEvent.data : null,
2127
+ propsValue: this.props.value,
2128
+ propsMimeType: this.props.mimeType,
2129
+ innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
2130
+ selection: getSelectionSnapshot(this.editableDiv),
2131
+ });
2132
+
2133
+ this.cleanupEmptyInlineFormattingElements();
2134
+
1494
2135
  let raw_text = elementToMarkedRawText(
1495
2136
  this.editableDiv,
1496
2137
  null,
@@ -1498,6 +2139,11 @@ export default class MathRichInput extends React.Component {
1498
2139
  this.enableHtml()
1499
2140
  );
1500
2141
  const params = this._getRangeParams();
2142
+ debugMathRichInput("handleInput:after-range", {
2143
+ raw_text,
2144
+ params,
2145
+ selection: getSelectionSnapshot(this.editableDiv),
2146
+ });
1501
2147
  if (params === null || params === undefined) {
1502
2148
  // console.log(
1503
2149
  // "handleInput: Empty content detected, using default range params"
@@ -1528,12 +2174,14 @@ export default class MathRichInput extends React.Component {
1528
2174
  mimeType,
1529
2175
  this.enableHtml()
1530
2176
  );
2177
+ raw_text = this.stripEmptyInlineFormattingTags(raw_text);
1531
2178
  this.applyChangesToComponent(
1532
2179
  raw_text,
1533
2180
  mimeType,
1534
2181
  defaultParams,
1535
2182
  this.props.useExpertMode,
1536
- this.props.selectedTab
2183
+ this.props.selectedTab,
2184
+ { skipControlledRender: true }
1537
2185
  );
1538
2186
  return;
1539
2187
  } else if (
@@ -1564,23 +2212,33 @@ export default class MathRichInput extends React.Component {
1564
2212
  this.enableHtml()
1565
2213
  );
1566
2214
 
1567
- // Use valid params or fallback to defaults
1568
- const validParams = params || {
1569
- startNodeIndex: 0,
1570
- startOffset: 0,
1571
- endNodeIndex: 0,
1572
- endOffset: 0,
1573
- startGlobalOffset: 0,
1574
- endGlobalOffset: 0,
1575
- };
2215
+ raw_text = this.stripEmptyInlineFormattingTags(raw_text);
1576
2216
 
1577
- this.applyChangesToComponent(
1578
- raw_text,
1579
- mimeType,
2217
+ // Use valid params or fallback to defaults
2218
+ const validParams = params || {
2219
+ startNodeIndex: 0,
2220
+ startOffset: 0,
2221
+ endNodeIndex: 0,
2222
+ endOffset: 0,
2223
+ startGlobalOffset: 0,
2224
+ endGlobalOffset: 0,
2225
+ };
2226
+ this.setOldRangeParams(validParams);
2227
+
2228
+ this.applyChangesToComponent(
2229
+ raw_text,
2230
+ mimeType,
1580
2231
  validParams,
1581
2232
  this.props.useExpertMode,
1582
- this.props.selectedTab
2233
+ this.props.selectedTab,
2234
+ { skipControlledRender: true }
1583
2235
  );
2236
+ debugMathRichInput("handleInput:after-apply", {
2237
+ raw_text,
2238
+ mimeType,
2239
+ validParams,
2240
+ selection: getSelectionSnapshot(this.editableDiv),
2241
+ });
1584
2242
  } catch (error) {
1585
2243
  console.error(error);
1586
2244
  }
@@ -1622,46 +2280,8 @@ export default class MathRichInput extends React.Component {
1622
2280
  this.setState({ activeButtons: activeButtons });
1623
2281
  return;
1624
2282
  }
1625
- let node = this._findNodeWithIndex(rangeParams.startNodeIndex);
1626
- let bold = false;
1627
- let italic = false;
1628
- let underline = false;
1629
- let superscript = false;
1630
- let subscript = false;
1631
- do {
1632
- if (node.nodeType === 1) {
1633
- let nodeName = node.nodeName.toLowerCase();
1634
- if (nodeName === "b" || nodeName === "strong") bold = true;
1635
- else if (nodeName === "i" || nodeName === "em") italic = true;
1636
- else if (nodeName === "u") underline = true;
1637
- else if (nodeName === "sub") subscript = true;
1638
- else if (nodeName === "sup") superscript = true;
1639
- } else if (
1640
- node.classList !== null &&
1641
- node.classList !== undefined &&
1642
- node.classList.contains("MathRichInput")
1643
- )
1644
- break;
1645
- node = node.parentNode;
1646
- } while (node !== null);
1647
-
1648
- let activeButtons = { bold, italic, underline, subscript, superscript };
1649
- // console.log(activeButtons)
1650
-
1651
- let update = true;
1652
- if (this.lastSetActiveButtons !== null) {
1653
- update = false;
1654
- for (var style in activeButtons) {
1655
- if (activeButtons[style] !== this.lastSetActiveButtons[style]) {
1656
- update = true;
1657
- break;
1658
- }
1659
- }
1660
- }
1661
- if (update) {
1662
- this.lastSetActiveButtons = activeButtons;
1663
- this.setState({ activeButtons: activeButtons });
1664
- }
2283
+ let activeButtons = this.getActiveButtonsFromSelection(rangeParams);
2284
+ this.setActiveButtonsIfChanged(activeButtons);
1665
2285
  } catch (error) {
1666
2286
  console.error(error);
1667
2287
  }
@@ -1718,8 +2338,30 @@ export default class MathRichInput extends React.Component {
1718
2338
  let pasteHtml = clipboardData.getData("text/html");
1719
2339
  let pasteText = clipboardData.getData("text/plain");
1720
2340
 
1721
- // Get current cursor position
1722
- const rangeParams = this._getRangeParams();
2341
+ // Remove whatever was selected FIRST, then read the caret.
2342
+ //
2343
+ // The order used to be the other way round, and that is why pasting over a selection emptied the
2344
+ // field instead of replacing it. `rangeParams` is a NODE INDEX plus an offset, and
2345
+ // `deleteFromDocument()` removes the very nodes it counts: read before the delete, the index points
2346
+ // at something that no longer exists, so `_findNodeWithIndex` below finds nothing, the marked text
2347
+ // it builds carries no mark, and the pasted text is inserted nowhere. The value the component then
2348
+ // reports is correct while the editable div is left empty — which is exactly what a teacher sees.
2349
+ //
2350
+ // It only ever worked on an empty field because the canonical empty state is a single `<p>` holding
2351
+ // one small space: deleting that selection leaves the same node structure standing, so the stale
2352
+ // index still happens to resolve.
2353
+ // Read the caret BEFORE, so there is something to fall back on, and AGAIN after the delete.
2354
+ const paramsBeforeDelete = this._getRangeParams();
2355
+
2356
+ const selection = window.getSelection();
2357
+ if (selection && selection.rangeCount > 0) {
2358
+ selection.deleteFromDocument();
2359
+ }
2360
+
2361
+ // The caret AFTER the delete is the one that counts: `rangeParams` is a node index, and the delete
2362
+ // removed the very nodes it counts. The pre-delete value is kept only as a fallback — bailing out
2363
+ // here having already emptied the selection is how a paste turns into a deletion.
2364
+ const rangeParams = this._getRangeParams() || paramsBeforeDelete;
1723
2365
  if (!rangeParams) {
1724
2366
  console.warn("Could not get range params for paste");
1725
2367
  return;
@@ -1728,12 +2370,6 @@ export default class MathRichInput extends React.Component {
1728
2370
  // Store old range params for cursor positioning (like equation editor)
1729
2371
  this.setOldRangeParams(rangeParams);
1730
2372
 
1731
- // Clear any existing selection
1732
- const selection = window.getSelection();
1733
- if (selection.rangeCount > 0) {
1734
- selection.deleteFromDocument();
1735
- }
1736
-
1737
2373
  let finalText = "";
1738
2374
  let finalMimeType = this.props.mimeType || "text/html";
1739
2375
  let hasMathContent = false;
@@ -1855,7 +2491,6 @@ export default class MathRichInput extends React.Component {
1855
2491
  let newRawText = removeMarks(
1856
2492
  insertCharacterBeforeMarks(markedText, finalText)
1857
2493
  );
1858
-
1859
2494
  // Convert any remaining \[...\] LaTeX expressions to <math>...</math> format
1860
2495
  // This ensures consistency when paste adds <math> tags alongside existing \[...\] expressions
1861
2496
  if (this.enableMath()) {
@@ -1865,35 +2500,46 @@ export default class MathRichInput extends React.Component {
1865
2500
  );
1866
2501
  }
1867
2502
 
1868
- // Calculate cursor position exactly like equation editor does
2503
+ /*
2504
+ * Where the caret lands after a paste: at the END of what was pasted.
2505
+ *
2506
+ * The node index walks to just past the last formula, and that part was always right. The OFFSET
2507
+ * within that node was not: it stopped at the boundary, so pasting "…multiply it by 3." left the
2508
+ * caret between the 3 and the full stop. Everything after the last `</math>` is plain text, and
2509
+ * plain text is the one thing whose rendered length is its written length — so the tail can
2510
+ * simply be stepped over. (Markup in the tail is not that, and keeps the old position rather than
2511
+ * a guessed one.)
2512
+ *
2513
+ * Deriving the position from scratch was tried twice and both attempts landed FURTHER away, each
2514
+ * needing an exact model of how the rendered document is measured and each getting a corner of it
2515
+ * wrong. Walking the structure that is already there needs no such model.
2516
+ */
1869
2517
  let newRangeParams = null;
1870
2518
 
1871
2519
  if (hasMathContent) {
1872
- // Use same approach as equation editor for math content
1873
2520
  const oldRangeParams = this.getOldRangeParams();
1874
2521
  const mathTagCount = (finalText.match(/<math>/gi) || []).length;
1875
-
1876
- if (this.props.value === "") {
1877
- // Special case of previously empty text input (like equation editor)
1878
- newRangeParams = {
1879
- startNodeIndex: mathTagCount * 2,
1880
- startOffset: SMALL_SPACE_LENGTH,
1881
- endNodeIndex: mathTagCount * 2,
1882
- endOffset: SMALL_SPACE_LENGTH,
1883
- };
1884
- } else {
1885
- // Normal case - position cursor after inserted math content (like equation editor)
1886
- newRangeParams = {
1887
- startNodeIndex: oldRangeParams.startNodeIndex + mathTagCount * 2,
1888
- startOffset: SMALL_SPACE_LENGTH,
1889
- endNodeIndex: oldRangeParams.startNodeIndex + mathTagCount * 2,
1890
- endOffset: SMALL_SPACE_LENGTH,
1891
- };
1892
- }
2522
+ const startNodeIndex =
2523
+ this.props.value === ""
2524
+ ? mathTagCount * 2
2525
+ : oldRangeParams.startNodeIndex + mathTagCount * 2;
2526
+ const lastMathEnd = finalText.toLowerCase().lastIndexOf("</math>");
2527
+ const tail =
2528
+ lastMathEnd === -1
2529
+ ? ""
2530
+ : finalText.substring(lastMathEnd + "</math>".length);
2531
+ const startOffset =
2532
+ tail.indexOf("<") === -1
2533
+ ? SMALL_SPACE_LENGTH + tail.length
2534
+ : SMALL_SPACE_LENGTH;
2535
+ newRangeParams = {
2536
+ startNodeIndex,
2537
+ startOffset,
2538
+ endNodeIndex: startNodeIndex,
2539
+ endOffset: startOffset,
2540
+ };
1893
2541
  } else {
1894
- // For text content, use global offset like before
1895
- const newGlobalOffset =
1896
- rangeParams.startGlobalOffset + finalText.length;
2542
+ const newGlobalOffset = rangeParams.startGlobalOffset + finalText.length;
1897
2543
  newRangeParams = {
1898
2544
  startGlobalOffset: newGlobalOffset,
1899
2545
  endGlobalOffset: newGlobalOffset,
@@ -1928,6 +2574,23 @@ export default class MathRichInput extends React.Component {
1928
2574
  this.props.useExpertMode,
1929
2575
  this.props.selectedTab
1930
2576
  );
2577
+
2578
+ // A paste cannot rely on being re-rendered. It removed the selected content from the DOM itself,
2579
+ // and if the host already holds the value being applied — the same words pasted back into the
2580
+ // field they came from — no prop changes, nothing re-renders, and the field is left empty while
2581
+ // the value is correct everywhere else. So: give a real render its chance, then put the content
2582
+ // back if none came.
2583
+ const rangeAfterPaste = newRangeParams;
2584
+ queueMicrotask(() => {
2585
+ try {
2586
+ if (this.reconcileEditableDomWithRender(true)) {
2587
+ this._setRangeParams(rangeAfterPaste);
2588
+ this.updateActiveButtons();
2589
+ }
2590
+ } catch (error) {
2591
+ console.error(error);
2592
+ }
2593
+ });
1931
2594
  }
1932
2595
  } catch (error) {
1933
2596
  console.error("Error in paste handler:", error);
@@ -1939,6 +2602,14 @@ export default class MathRichInput extends React.Component {
1939
2602
  componentDidMount() {
1940
2603
  try {
1941
2604
  setupKatex();
2605
+ debugMathRichInput("componentDidMount", {
2606
+ propsValue: this.props.value,
2607
+ propsMimeType: this.props.mimeType,
2608
+ autofocus: this.props.autofocus,
2609
+ autoFocus: this.props.autoFocus,
2610
+ innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
2611
+ selection: getSelectionSnapshot(this.editableDiv),
2612
+ });
1942
2613
 
1943
2614
  this.editableDiv.addEventListener("keydown", this.handleKeyDown);
1944
2615
  this.editableDiv.addEventListener("keyup", this.handleKeyUp);
@@ -1961,16 +2632,143 @@ export default class MathRichInput extends React.Component {
1961
2632
  }
1962
2633
  }
1963
2634
 
2635
+ shouldComponentUpdate(nextProps, nextState) {
2636
+ if (this.skipNextControlledValueRender === true) {
2637
+ const canSkip =
2638
+ nextProps.value === this.skipNextControlledValue &&
2639
+ nextState === this.state;
2640
+
2641
+ debugMathRichInput("shouldComponentUpdate:native-input-gate", {
2642
+ canSkip,
2643
+ nextValue: nextProps.value,
2644
+ skipNextControlledValue: this.skipNextControlledValue,
2645
+ currentPropsValue: this.props.value,
2646
+ stateChanged: nextState !== this.state,
2647
+ innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
2648
+ selection: getSelectionSnapshot(this.editableDiv),
2649
+ });
2650
+
2651
+ this.skipNextControlledValueRender = false;
2652
+ this.skipNextControlledValue = null;
2653
+
2654
+ if (canSkip) return false;
2655
+ }
2656
+
2657
+ if (this.shouldSkipRedundantFocusedRender(nextProps, nextState)) {
2658
+ debugMathRichInput("shouldComponentUpdate:skip-redundant-focused-render", {
2659
+ nextValue: nextProps.value,
2660
+ currentPropsValue: this.props.value,
2661
+ selection: getSelectionSnapshot(this.editableDiv),
2662
+ });
2663
+ return false;
2664
+ }
2665
+
2666
+ return true;
2667
+ }
2668
+
2669
+ /**
2670
+ * The browser's own reading of an HTML string.
2671
+ *
2672
+ * React renders `<p>&#8203;…</P>`; the DOM reports `<p>​…</p>` — same content, different bytes. So a
2673
+ * comparison against `innerHTML` has to be made in the DOM's spelling, or it never matches and the
2674
+ * reconciliation below would rewrite the field on every single update.
2675
+ */
2676
+ normaliseHtml(html) {
2677
+ const probe = document.createElement("span");
2678
+ probe.innerHTML = html;
2679
+ return probe.innerHTML;
2680
+ }
2681
+
2682
+ /**
2683
+ * Put back what React believes it rendered, when the live DOM has drifted away from it.
2684
+ *
2685
+ * WHY THIS IS NEEDED AT ALL. `dangerouslySetInnerHTML` writes only when the html STRING changes: React
2686
+ * compares the new `__html` with the previous one and, finding them equal, leaves the DOM alone. That is
2687
+ * sound as long as React is the only thing that touches the DOM — and here it is not. `handlePaste`
2688
+ * removes the selected content itself, through `selection.deleteFromDocument()`, so the field empties
2689
+ * behind React's back while React's record of it still says "full".
2690
+ *
2691
+ * Paste the same words back into the field they came from and the two mistakes meet: the DOM is empty,
2692
+ * the html React computes is identical to the html it rendered last time, so it writes nothing — and
2693
+ * the field stays blank while `value`, the command, and the row in the database are all correct. The
2694
+ * teacher sees their text vanish on paste; nothing anywhere reports an error.
2695
+ *
2696
+ * It writes back the SAME string React itself passed to `dangerouslySetInnerHTML` on this render, so
2697
+ * nothing reaches the DOM here that React was not already putting there — the trust boundary is the
2698
+ * one that existed before, not a new one.
2699
+ *
2700
+ * Only reconciled after a PROGRAMMATIC edit (`afterComponentUpdateData` is set), or when the paste
2701
+ * asks directly (`force`). Ordinary typing goes down the native-input path, where the browser has
2702
+ * already put the character in the right place and the caret with it, and rewriting the DOM there
2703
+ * would move the caret to the front on every keystroke.
2704
+ *
2705
+ * `force` exists because a re-render is not guaranteed to happen AT ALL. When the host already holds
2706
+ * the value being applied — paste the same words back into the field they came from — the prop never
2707
+ * changes, so nothing re-renders and `componentDidUpdate` never runs. `lastRenderedEditableHtml` is
2708
+ * then exactly right: it is the html for that unchanged value, which is what the DOM should hold.
2709
+ *
2710
+ * Returns whether it wrote, so the caller knows whether the caret needs putting back.
2711
+ */
2712
+ reconcileEditableDomWithRender(force = false) {
2713
+ if (!this.editableDiv) return false;
2714
+ if (!force && (this.afterComponentUpdateData === null || this.afterComponentUpdateData === undefined))
2715
+ return false;
2716
+ const html = force
2717
+ ? rawTextToHtml(katex, this.props.value || "", this.props.mimeType)
2718
+ : this.lastRenderedEditableHtml;
2719
+ if (html === null || html === undefined) return false;
2720
+ if (this.editableDiv.innerHTML === this.normaliseHtml(html)) return false;
2721
+ debugMathRichInput("reconcile", { force, liveInnerHTML: this.editableDiv.innerHTML, html });
2722
+ this.editableDiv.innerHTML = html;
2723
+ this.lastRenderedEditableHtml = html;
2724
+ return true;
2725
+ }
2726
+
1964
2727
  componentDidUpdate() {
1965
2728
  try {
2729
+ debugMathRichInput("componentDidUpdate:start", {
2730
+ propsValue: this.props.value,
2731
+ propsMimeType: this.props.mimeType,
2732
+ afterComponentUpdateData: this.afterComponentUpdateData,
2733
+ hasFocus: this.state.hasFocus,
2734
+ showEquationEditor: this.state.showEquationEditor,
2735
+ innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
2736
+ selection: getSelectionSnapshot(this.editableDiv),
2737
+ });
2738
+ // Before the caret is restored, not after: the offsets below are counted through the text, so
2739
+ // restoring them against an empty field puts the caret at 0 and loses the position as well.
2740
+ this.reconcileEditableDomWithRender();
1966
2741
  if (
1967
2742
  this.afterComponentUpdateData !== null &&
1968
2743
  this.afterComponentUpdateData !== undefined &&
1969
2744
  this.afterComponentUpdateData.value === this.props.value
1970
2745
  ) {
2746
+ debugMathRichInput("componentDidUpdate:restore-before", {
2747
+ rangeParams: this.afterComponentUpdateData.rangeParams,
2748
+ selection: getSelectionSnapshot(this.editableDiv),
2749
+ });
1971
2750
  this._setRangeParams(this.afterComponentUpdateData.rangeParams);
1972
2751
  this.afterComponentUpdateData = null;
1973
2752
  this.updateActiveButtons();
2753
+ debugMathRichInput("componentDidUpdate:restore-after", {
2754
+ selection: getSelectionSnapshot(this.editableDiv),
2755
+ });
2756
+ }
2757
+ if (
2758
+ this.preserveNativeDomRangeParams !== null &&
2759
+ this.preserveNativeDomRangeParams !== undefined &&
2760
+ this.state.hasFocus === true
2761
+ ) {
2762
+ const rangeParams = this.preserveNativeDomRangeParams;
2763
+ this.preserveNativeDomRangeParams = null;
2764
+ debugMathRichInput("componentDidUpdate:preserve-restore-before", {
2765
+ rangeParams,
2766
+ selection: getSelectionSnapshot(this.editableDiv),
2767
+ });
2768
+ this._setRangeParams(rangeParams);
2769
+ debugMathRichInput("componentDidUpdate:preserve-restore-after", {
2770
+ selection: getSelectionSnapshot(this.editableDiv),
2771
+ });
1974
2772
  }
1975
2773
  } catch (error) {
1976
2774
  console.error(error);
@@ -1980,6 +2778,14 @@ export default class MathRichInput extends React.Component {
1980
2778
  showEquationEditor = (moveCursorOnInsert = false, node = null) => {
1981
2779
  try {
1982
2780
  let rangeParams = this._getRangeParams();
2781
+ debugMathRichInput("showEquationEditor:start", {
2782
+ moveCursorOnInsert,
2783
+ nodeProvided: Boolean(node),
2784
+ rangeParams,
2785
+ oldRangeParams: this.oldRangeParams,
2786
+ innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
2787
+ selection: getSelectionSnapshot(this.editableDiv),
2788
+ });
1983
2789
  if (rangeParams.startNodeIndex < 0) {
1984
2790
  console.error("Could not find position of cursor in node list");
1985
2791
 
@@ -2030,6 +2836,7 @@ export default class MathRichInput extends React.Component {
2030
2836
  this.editingNode = null;
2031
2837
  }
2032
2838
 
2839
+ this.setOldRangeParams(rangeParams);
2033
2840
  this.markedRawText = elementToMarkedRawText(
2034
2841
  this.editableDiv,
2035
2842
  node,
@@ -2042,12 +2849,22 @@ export default class MathRichInput extends React.Component {
2042
2849
  showEquationEditor: true,
2043
2850
  equationEditorLatex: latex,
2044
2851
  });
2852
+ debugMathRichInput("showEquationEditor:after-setState", {
2853
+ rangeParams,
2854
+ markedRawText: this.markedRawText,
2855
+ latex,
2856
+ selection: getSelectionSnapshot(this.editableDiv),
2857
+ });
2045
2858
  } catch (error) {
2046
2859
  console.error(error);
2047
2860
  }
2048
2861
  };
2049
2862
 
2050
2863
  handleMouseDown = (event) => {
2864
+ this.isMouseDownInEditable = true;
2865
+ window.setTimeout(() => {
2866
+ this.isMouseDownInEditable = false;
2867
+ }, 0);
2051
2868
  this.showEquationEditorOnMouseUp = false;
2052
2869
  let node = null;
2053
2870
  try {
@@ -2086,7 +2903,11 @@ export default class MathRichInput extends React.Component {
2086
2903
  // Clear selection anchor on mouse click
2087
2904
  this.selectionAnchor = null;
2088
2905
 
2906
+ // A click that lands with the selection outside this field — a toolbar button, another field —
2907
+ // gives no range at all. Dereferenced unguarded, this threw on every such click.
2089
2908
  let params = this._getRangeParams();
2909
+ if (!params) return;
2910
+ this.setOldRangeParams(params);
2090
2911
  let startNode = this._findNodeWithIndex(params.startNodeIndex);
2091
2912
  let endNode = this._findNodeWithIndex(params.endNodeIndex);
2092
2913
 
@@ -2153,6 +2974,15 @@ export default class MathRichInput extends React.Component {
2153
2974
  handleEquationEditorInsert = (latex) => {
2154
2975
  try {
2155
2976
  // console.log("Inserting latex: "+latex)
2977
+ debugMathRichInput("handleEquationEditorInsert:start", {
2978
+ latex,
2979
+ markedRawText: this.markedRawText,
2980
+ oldRangeParams: this.getOldRangeParams(),
2981
+ moveCursorOnInsert: this.moveCursorOnInsert,
2982
+ propsValue: this.props.value,
2983
+ innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
2984
+ selection: getSelectionSnapshot(this.editableDiv),
2985
+ });
2156
2986
 
2157
2987
  let start_pos = this.markedRawText.indexOf(MARK);
2158
2988
  let end_pos = this.markedRawText.indexOf(MARK, start_pos + MARK.length);
@@ -2205,15 +3035,19 @@ export default class MathRichInput extends React.Component {
2205
3035
  this.enableHtml()
2206
3036
  );
2207
3037
 
2208
- this.applyChangesToComponent(
2209
- new_raw_text,
2210
- mimeType,
2211
- new_rangeParams,
2212
- this.props.useExpertMode,
2213
- this.props.selectedTab
2214
- );
2215
- this.setState({ showEquationEditor: false });
2216
- return;
3038
+ this.applyChangesToComponent(
3039
+ new_raw_text,
3040
+ mimeType,
3041
+ new_rangeParams,
3042
+ this.props.useExpertMode,
3043
+ this.props.selectedTab
3044
+ );
3045
+ this.setState({ showEquationEditor: false }, () => {
3046
+ this.editableDiv.focus();
3047
+ this._setRangeParams(new_rangeParams);
3048
+ this.restoreRangeAfterBrowserWork(new_rangeParams, "equation-empty");
3049
+ });
3050
+ return;
2217
3051
  }
2218
3052
 
2219
3053
  // Handle case of latex returned, in which case we update the katex node
@@ -2235,25 +3069,42 @@ export default class MathRichInput extends React.Component {
2235
3069
  endOffset: SMALL_SPACE_LENGTH,
2236
3070
  };
2237
3071
  } else {
2238
- // Position cursor after the inserted math element using globalOffset
2239
- // Find the position right after the last math tag in the content
2240
- const mathTagMatches = [
2241
- ...new_raw_text.matchAll(/<math>[^<]*<\/math>/gi),
2242
- ];
2243
- if (mathTagMatches.length > 0) {
2244
- const lastMatch = mathTagMatches[mathTagMatches.length - 1];
2245
- const afterMathOffset = lastMatch.index + lastMatch[0].length;
2246
-
3072
+ // In this editor a rendered equation counts as one logical character
3073
+ // in global-offset space. Put the caret immediately after that math
3074
+ // character instead of guessing rendered child-node indexes.
3075
+ const oldRangeParams = this.getOldRangeParams();
3076
+ if (
3077
+ oldRangeParams !== null &&
3078
+ oldRangeParams.startGlobalOffset !== null &&
3079
+ oldRangeParams.startGlobalOffset !== undefined
3080
+ ) {
3081
+ const afterInsertedMathOffset = oldRangeParams.startGlobalOffset + 1;
2247
3082
  new_rangeParams = {
2248
- startGlobalOffset: afterMathOffset,
2249
- endGlobalOffset: afterMathOffset,
3083
+ startGlobalOffset: afterInsertedMathOffset,
3084
+ endGlobalOffset: afterInsertedMathOffset,
3085
+ };
3086
+ } else if (
3087
+ oldRangeParams !== null &&
3088
+ oldRangeParams.startNodeIndex !== null &&
3089
+ oldRangeParams.startNodeIndex !== undefined
3090
+ ) {
3091
+ new_rangeParams = {
3092
+ startNodeIndex: oldRangeParams.startNodeIndex + 2,
3093
+ startOffset: SMALL_SPACE_LENGTH,
3094
+ endNodeIndex: oldRangeParams.startNodeIndex + 2,
3095
+ endOffset: SMALL_SPACE_LENGTH,
2250
3096
  };
2251
3097
  }
2252
- // If no math tags found, keep old range params
3098
+ // If old range params are unavailable, keep the current fallback.
2253
3099
  }
2254
3100
  }
2255
3101
  // console.log("New cursor pos: "+new_rangeParams.startNodeIndex+"->"+new_rangeParams.startOffset)
2256
3102
  this.setOldRangeParams(new_rangeParams);
3103
+ debugMathRichInput("handleEquationEditorInsert:before-apply", {
3104
+ new_raw_text,
3105
+ new_rangeParams,
3106
+ selection: getSelectionSnapshot(this.editableDiv),
3107
+ });
2257
3108
 
2258
3109
  let nodeCounts = this._countNodeTypes();
2259
3110
  let mimeType = this.getMimeType(nodeCounts.html > 0, true);
@@ -2270,7 +3121,15 @@ export default class MathRichInput extends React.Component {
2270
3121
  this.props.useExpertMode,
2271
3122
  this.props.selectedTab
2272
3123
  );
2273
- this.setState({ showEquationEditor: false });
3124
+ this.setState({ showEquationEditor: false }, () => {
3125
+ this.editableDiv.focus();
3126
+ this._setRangeParams(new_rangeParams);
3127
+ this.restoreRangeAfterBrowserWork(new_rangeParams, "equation-insert");
3128
+ debugMathRichInput("handleEquationEditorInsert:after-close-restore", {
3129
+ new_rangeParams,
3130
+ selection: getSelectionSnapshot(this.editableDiv),
3131
+ });
3132
+ });
2274
3133
  } catch (error) {
2275
3134
  console.error(error);
2276
3135
  }
@@ -2288,16 +3147,37 @@ export default class MathRichInput extends React.Component {
2288
3147
  styleText = (event, style) => {
2289
3148
  try {
2290
3149
  style = style.toLowerCase();
3150
+ const command = this.getStyleCommand(style);
3151
+ const activeBefore = this.getActiveButtonsFromSelection();
3152
+ const selection = document.getSelection();
3153
+ const isCollapsed =
3154
+ selection === null ||
3155
+ selection.rangeCount === 0 ||
3156
+ selection.getRangeAt(0).collapsed;
2291
3157
  let success = false;
3158
+ let insertedTypingStylePlaceholder = false;
2292
3159
 
2293
3160
  // This first part is a workaround whereby executing subscript or superscript
2294
3161
  // within an existing subscript or superscript makes the text even smaller instead
2295
3162
  // of toggling it off. Thus we manually toggle it off with some jigerry pokery!
2296
3163
  if (
2297
- (style === "subscript" && this.state.activeButtons.subscript) ||
2298
- (style === "superscript" && this.state.activeButtons.superscript)
3164
+ isCollapsed &&
3165
+ activeBefore[style] &&
3166
+ this.moveTrailingWhitespaceOutsideInlineStyle(style, command)
3167
+ ) {
3168
+ success = true;
3169
+ } else if (
3170
+ isCollapsed &&
3171
+ !activeBefore[style] &&
3172
+ selection &&
3173
+ selection.rangeCount > 0
3174
+ ) {
3175
+ success = this.insertInlineStylePlaceholder(style);
3176
+ insertedTypingStylePlaceholder = success;
3177
+ } else if (
3178
+ (style === "subscript" && activeBefore.subscript) ||
3179
+ (style === "superscript" && activeBefore.superscript)
2299
3180
  ) {
2300
- const selection = document.getSelection();
2301
3181
  let range = selection.getRangeAt(0);
2302
3182
  if (
2303
3183
  range.endContainer === range.startContainer &&
@@ -2309,90 +3189,29 @@ export default class MathRichInput extends React.Component {
2309
3189
  selection.addRange(range);
2310
3190
  }
2311
3191
  success = document.execCommand("removeFormat", false, null);
2312
- if (this.state.activeButtons.bold)
3192
+ if (activeBefore.bold)
2313
3193
  success = document.execCommand("bold", false, null);
2314
- if (this.state.activeButtons.italic)
3194
+ if (activeBefore.italic)
2315
3195
  success = document.execCommand("italic", false, null);
2316
- if (this.state.activeButtons.underline)
3196
+ if (activeBefore.underline)
2317
3197
  success = document.execCommand("underline", false, null);
2318
3198
  } else {
2319
- const selection = document.getSelection();
2320
3199
  if (selection && selection.rangeCount > 0) {
2321
- const range = selection.getRangeAt(0);
2322
-
2323
- if (range.collapsed) {
2324
- // For cursor position (no selection), use insertHTML with styled wrapper
2325
- // This creates a formatted element that will maintain styling for future typing
2326
- const isToggleOff = this.state.activeButtons[style];
2327
-
2328
- if (!isToggleOff) {
2329
- // Turning style ON - insert a styled wrapper element
2330
- let wrapperTag;
2331
- switch (style) {
2332
- case "bold":
2333
- wrapperTag = "strong";
2334
- break;
2335
- case "italic":
2336
- wrapperTag = "em";
2337
- break;
2338
- case "underline":
2339
- wrapperTag = "u";
2340
- break;
2341
- case "subscript":
2342
- wrapperTag = "sub";
2343
- break;
2344
- case "superscript":
2345
- wrapperTag = "sup";
2346
- break;
2347
- default:
2348
- wrapperTag = "span";
2349
- break;
2350
- }
2351
-
2352
- // Insert an invisible character wrapped in the styled element
2353
- // This provides a "landing zone" for future typing
2354
- const styledElement = `<${wrapperTag}>\u200B</${wrapperTag}>`;
2355
- success = document.execCommand(
2356
- "insertHTML",
2357
- false,
2358
- styledElement
2359
- );
2360
-
2361
- // Move cursor inside the styled element
2362
- setTimeout(() => {
2363
- const newSelection = document.getSelection();
2364
- if (newSelection && newSelection.rangeCount > 0) {
2365
- const newRange = newSelection.getRangeAt(0);
2366
- const styledNode = newRange.startContainer.parentElement;
2367
- if (
2368
- styledNode &&
2369
- styledNode.tagName.toLowerCase() === wrapperTag
2370
- ) {
2371
- newRange.setStart(styledNode, 0);
2372
- newRange.setEnd(styledNode, 0);
2373
- newSelection.removeAllRanges();
2374
- newSelection.addRange(newRange);
2375
- }
2376
- }
2377
- }, 0);
2378
- } else {
2379
- // Turning style OFF - just apply execCommand normally
2380
- success = document.execCommand(style, false, null);
2381
- }
2382
- } else {
2383
- // For text selection, use regular execCommand
2384
- success = document.execCommand(style, false, null);
2385
- }
3200
+ success = document.execCommand(command, false, null);
2386
3201
  }
2387
3202
  }
2388
3203
 
2389
- let buttonState = this.state.activeButtons[style];
2390
- if (buttonState === null || buttonState === undefined) return success;
2391
-
2392
- buttonState = !buttonState;
2393
- let new_activeButtons = { ...this.state.activeButtons };
2394
- new_activeButtons[style] = buttonState;
2395
- this.setState({ activeButtons: new_activeButtons });
3204
+ const rangeParams = this._getRangeParams();
3205
+ const removedEmptyTags =
3206
+ !insertedTypingStylePlaceholder &&
3207
+ this.cleanupEmptyInlineFormattingElements();
3208
+ if (removedEmptyTags && rangeParams !== null && rangeParams !== undefined) {
3209
+ this._setRangeParams(rangeParams);
3210
+ this.applyCurrentEditableDomToComponent(rangeParams);
3211
+ }
3212
+ this.setActiveButtonsIfChanged(
3213
+ this.getActiveButtonsFromSelection(rangeParams)
3214
+ );
2396
3215
  return success;
2397
3216
  } catch (error) {
2398
3217
  console.error(error);
@@ -2401,8 +3220,15 @@ export default class MathRichInput extends React.Component {
2401
3220
 
2402
3221
  handleToolbarButtonClick = (event, buttonName) => {
2403
3222
  try {
3223
+ debugMathRichInput("handleToolbarButtonClick:start", {
3224
+ buttonName,
3225
+ eventType: event ? event.type : null,
3226
+ selection: getSelectionSnapshot(this.editableDiv),
3227
+ oldRangeParams: this.oldRangeParams,
3228
+ });
2404
3229
  if (buttonName === "Equation") {
2405
3230
  this.showEquationEditor(true);
3231
+ return;
2406
3232
  }
2407
3233
  let style = buttonName;
2408
3234
  let success = this.styleText(event, style);
@@ -2751,10 +3577,10 @@ export default class MathRichInput extends React.Component {
2751
3577
  }
2752
3578
  };
2753
3579
 
2754
- insertAfterSmallSpace(new_char, rangeParams) {
2755
- try {
2756
- if (rangeParams === null || rangeParams === undefined)
2757
- rangeParams = this._getRangeParams();
3580
+ insertAfterSmallSpace(new_char, rangeParams) {
3581
+ try {
3582
+ if (rangeParams === null || rangeParams === undefined)
3583
+ rangeParams = this._getRangeParams();
2758
3584
 
2759
3585
  if (rangeParams.startOffset !== 0)
2760
3586
  console.warn(
@@ -2763,29 +3589,41 @@ export default class MathRichInput extends React.Component {
2763
3589
  " in insertAfterSmallSpace"
2764
3590
  );
2765
3591
 
2766
- let node = this._findNodeWithIndex(rangeParams.startNodeIndex);
2767
- let raw_text = elementToMarkedRawText(
2768
- this.editableDiv,
2769
- node,
2770
- 1, // after small space
2771
- this.enableHtml()
2772
- );
2773
-
2774
- let new_raw_text = removeMarks(
2775
- insertCharacterBeforeMarks(raw_text, new_char)
2776
- );
2777
-
2778
- let new_rangeParams = {
2779
- startNodeIndex: rangeParams.startNodeIndex,
2780
- startNodeOffset: 1 + new_char.length,
2781
- endNodeIndex: rangeParams.startNodeIndex,
2782
- endNodeOffset: 1 + new_char.length,
2783
- startGlobalOffset: rangeParams.startGlobalOffset + new_char.length,
2784
- endGlobalOffset: rangeParams.endGlobalOffset + new_char.length,
2785
- };
2786
-
2787
- new_raw_text = removeEncodingIfPlainText(
2788
- new_raw_text,
3592
+ let node = this._findNodeWithIndex(rangeParams.startNodeIndex);
3593
+ if (node === null || node === undefined || node.nodeValue === null) {
3594
+ console.warn("Could not find small-space text node");
3595
+ return;
3596
+ }
3597
+
3598
+ // The initial empty editor contains a hidden zero-width space. Handling
3599
+ // the first printable key by re-rendering through React replaces the
3600
+ // text node and drops Chrome's selection to the start under React 19.
3601
+ // Mutate the already-focused DOM node, then skip the matching controlled
3602
+ // render exactly like the normal contenteditable input path.
3603
+ node.nodeValue =
3604
+ node.nodeValue.substring(0, 1) +
3605
+ new_char +
3606
+ node.nodeValue.substring(1);
3607
+
3608
+ let new_rangeParams = {
3609
+ startNodeIndex: rangeParams.startNodeIndex,
3610
+ startOffset: 1 + new_char.length,
3611
+ endNodeIndex: rangeParams.startNodeIndex,
3612
+ endOffset: 1 + new_char.length,
3613
+ startGlobalOffset: rangeParams.startGlobalOffset + new_char.length,
3614
+ endGlobalOffset: rangeParams.endGlobalOffset + new_char.length,
3615
+ };
3616
+ this._setRangeParams(new_rangeParams);
3617
+
3618
+ let new_raw_text = elementToMarkedRawText(
3619
+ this.editableDiv,
3620
+ null,
3621
+ 0,
3622
+ this.enableHtml()
3623
+ );
3624
+
3625
+ new_raw_text = removeEncodingIfPlainText(
3626
+ new_raw_text,
2789
3627
  this.props.mimeType,
2790
3628
  this.enableHtml()
2791
3629
  );
@@ -2793,12 +3631,13 @@ export default class MathRichInput extends React.Component {
2793
3631
  this.applyChangesToComponent(
2794
3632
  new_raw_text,
2795
3633
  this.props.mimeType,
2796
- new_rangeParams,
2797
- this.props.useExpertMode,
2798
- this.props.selectedTab
2799
- );
2800
- } catch (error) {
2801
- console.error(error);
3634
+ new_rangeParams,
3635
+ this.props.useExpertMode,
3636
+ this.props.selectedTab,
3637
+ { skipControlledRender: true }
3638
+ );
3639
+ } catch (error) {
3640
+ console.error(error);
2802
3641
  }
2803
3642
  }
2804
3643
 
@@ -2895,12 +3734,39 @@ export default class MathRichInput extends React.Component {
2895
3734
 
2896
3735
  handleFocus = (event) => {
2897
3736
  const { onFocus } = this.props;
3737
+ let currentRangeParams = null;
3738
+ try {
3739
+ currentRangeParams = this._getRangeParams();
3740
+ } catch (error) {
3741
+ currentRangeParams = null;
3742
+ }
3743
+ const savedRangeParams = this.getOldRangeParams();
3744
+ const shouldRestoreSavedRange =
3745
+ !this.isMouseDownInEditable &&
3746
+ this.props.value !== "" &&
3747
+ this.rangeIsAtStart(currentRangeParams) &&
3748
+ this.rangeHasNonZeroPosition(savedRangeParams);
3749
+ debugMathRichInput("handleFocus:start", {
3750
+ propsValue: this.props.value,
3751
+ oldRangeParams: this.oldRangeParams,
3752
+ currentRangeParams,
3753
+ shouldRestoreSavedRange,
3754
+ innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
3755
+ selection: getSelectionSnapshot(this.editableDiv),
3756
+ });
2898
3757
  if (onFocus != null) {
2899
3758
  onFocus();
2900
3759
  }
2901
3760
  try {
2902
- this._setRangeParams(this.getOldRangeParams());
2903
- this.setState({ hasFocus: true });
3761
+ this.setState({ hasFocus: true }, () => {
3762
+ if (shouldRestoreSavedRange) {
3763
+ this._setRangeParams(savedRangeParams);
3764
+ this.restoreRangeAfterBrowserWork(savedRangeParams, "focus-restore");
3765
+ }
3766
+ debugMathRichInput("handleFocus:after-setState", {
3767
+ selection: getSelectionSnapshot(this.editableDiv),
3768
+ });
3769
+ });
2904
3770
  } catch (error) {
2905
3771
  console.error(error);
2906
3772
  }
@@ -2908,12 +3774,31 @@ export default class MathRichInput extends React.Component {
2908
3774
 
2909
3775
  handleBlur = (event) => {
2910
3776
  const { onBlur } = this.props;
3777
+ let currentRangeParams = null;
3778
+ try {
3779
+ currentRangeParams = this._getRangeParams();
3780
+ if (this.rangeHasNonZeroPosition(currentRangeParams)) {
3781
+ this.setOldRangeParams(currentRangeParams);
3782
+ }
3783
+ } catch (error) {
3784
+ currentRangeParams = null;
3785
+ }
3786
+ debugMathRichInput("handleBlur:start", {
3787
+ propsValue: this.props.value,
3788
+ oldRangeParams: this.oldRangeParams,
3789
+ currentRangeParams,
3790
+ innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
3791
+ selection: getSelectionSnapshot(this.editableDiv),
3792
+ });
2911
3793
  if (onBlur != null) {
2912
3794
  onBlur();
2913
3795
  }
2914
3796
  try {
2915
3797
  // console.log("Hiding accent bar 4");
2916
3798
  this.setState({ hasFocus: false, showAccentBar: false });
3799
+ debugMathRichInput("handleBlur:after-setState", {
3800
+ selection: getSelectionSnapshot(this.editableDiv),
3801
+ });
2917
3802
  } catch (error) {
2918
3803
  console.error(error);
2919
3804
  }
@@ -3039,6 +3924,23 @@ export default class MathRichInput extends React.Component {
3039
3924
  this.props.value || "",
3040
3925
  this.props.mimeType
3041
3926
  );
3927
+ const editableHtml = this.getEditableHtmlForRender(html);
3928
+ debugMathRichInput("render", {
3929
+ propsValue: this.props.value,
3930
+ propsMimeType: this.props.mimeType,
3931
+ html,
3932
+ editableHtml,
3933
+ state: {
3934
+ hasFocus: this.state.hasFocus,
3935
+ showEquationEditor: this.state.showEquationEditor,
3936
+ showAccentBar: this.state.showAccentBar,
3937
+ keyPressed: this.state.keyPressed,
3938
+ },
3939
+ afterComponentUpdateData: this.afterComponentUpdateData,
3940
+ oldRangeParams: this.oldRangeParams,
3941
+ editableInnerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
3942
+ selection: getSelectionSnapshot(this.editableDiv),
3943
+ });
3042
3944
 
3043
3945
  // Debug render math
3044
3946
  // if (this.props.value && this.props.value.includes("<math>")) {
@@ -3103,7 +4005,7 @@ export default class MathRichInput extends React.Component {
3103
4005
  onCompositionStart={this.handleCompositionStart}
3104
4006
  onCompositionEnd={this.handleCompositionEnd}
3105
4007
  dangerouslySetInnerHTML={{
3106
- __html: html,
4008
+ __html: editableHtml,
3107
4009
  }}
3108
4010
  data-placeholder={this.props.placeholder || ""}
3109
4011
  ></span>