@zzish/math-rich-input 0.1.51 → 0.1.52

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") {
@@ -1403,6 +2014,14 @@ export default class MathRichInput extends React.Component {
1403
2014
 
1404
2015
  handleKeyUp = (e) => {
1405
2016
  try {
2017
+ debugMathRichInput("handleKeyUp:start", {
2018
+ key: e.key,
2019
+ altKey: e.altKey,
2020
+ metaKey: e.metaKey,
2021
+ ctrlKey: e.ctrlKey,
2022
+ shiftKey: e.shiftKey,
2023
+ selection: getSelectionSnapshot(this.editableDiv),
2024
+ });
1406
2025
  this.keyPressed = e.key;
1407
2026
  this.keyPressStartTime = -1;
1408
2027
 
@@ -1491,6 +2110,17 @@ export default class MathRichInput extends React.Component {
1491
2110
  // console.log("handleInput")
1492
2111
  if (this.isComposing) return;
1493
2112
 
2113
+ debugMathRichInput("handleInput:start", {
2114
+ inputType: event && event.nativeEvent ? event.nativeEvent.inputType : null,
2115
+ data: event && event.nativeEvent ? event.nativeEvent.data : null,
2116
+ propsValue: this.props.value,
2117
+ propsMimeType: this.props.mimeType,
2118
+ innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
2119
+ selection: getSelectionSnapshot(this.editableDiv),
2120
+ });
2121
+
2122
+ this.cleanupEmptyInlineFormattingElements();
2123
+
1494
2124
  let raw_text = elementToMarkedRawText(
1495
2125
  this.editableDiv,
1496
2126
  null,
@@ -1498,6 +2128,11 @@ export default class MathRichInput extends React.Component {
1498
2128
  this.enableHtml()
1499
2129
  );
1500
2130
  const params = this._getRangeParams();
2131
+ debugMathRichInput("handleInput:after-range", {
2132
+ raw_text,
2133
+ params,
2134
+ selection: getSelectionSnapshot(this.editableDiv),
2135
+ });
1501
2136
  if (params === null || params === undefined) {
1502
2137
  // console.log(
1503
2138
  // "handleInput: Empty content detected, using default range params"
@@ -1528,12 +2163,14 @@ export default class MathRichInput extends React.Component {
1528
2163
  mimeType,
1529
2164
  this.enableHtml()
1530
2165
  );
2166
+ raw_text = this.stripEmptyInlineFormattingTags(raw_text);
1531
2167
  this.applyChangesToComponent(
1532
2168
  raw_text,
1533
2169
  mimeType,
1534
2170
  defaultParams,
1535
2171
  this.props.useExpertMode,
1536
- this.props.selectedTab
2172
+ this.props.selectedTab,
2173
+ { skipControlledRender: true }
1537
2174
  );
1538
2175
  return;
1539
2176
  } else if (
@@ -1564,23 +2201,33 @@ export default class MathRichInput extends React.Component {
1564
2201
  this.enableHtml()
1565
2202
  );
1566
2203
 
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
- };
2204
+ raw_text = this.stripEmptyInlineFormattingTags(raw_text);
1576
2205
 
1577
- this.applyChangesToComponent(
1578
- raw_text,
1579
- mimeType,
2206
+ // Use valid params or fallback to defaults
2207
+ const validParams = params || {
2208
+ startNodeIndex: 0,
2209
+ startOffset: 0,
2210
+ endNodeIndex: 0,
2211
+ endOffset: 0,
2212
+ startGlobalOffset: 0,
2213
+ endGlobalOffset: 0,
2214
+ };
2215
+ this.setOldRangeParams(validParams);
2216
+
2217
+ this.applyChangesToComponent(
2218
+ raw_text,
2219
+ mimeType,
1580
2220
  validParams,
1581
2221
  this.props.useExpertMode,
1582
- this.props.selectedTab
2222
+ this.props.selectedTab,
2223
+ { skipControlledRender: true }
1583
2224
  );
2225
+ debugMathRichInput("handleInput:after-apply", {
2226
+ raw_text,
2227
+ mimeType,
2228
+ validParams,
2229
+ selection: getSelectionSnapshot(this.editableDiv),
2230
+ });
1584
2231
  } catch (error) {
1585
2232
  console.error(error);
1586
2233
  }
@@ -1622,46 +2269,8 @@ export default class MathRichInput extends React.Component {
1622
2269
  this.setState({ activeButtons: activeButtons });
1623
2270
  return;
1624
2271
  }
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
- }
2272
+ let activeButtons = this.getActiveButtonsFromSelection(rangeParams);
2273
+ this.setActiveButtonsIfChanged(activeButtons);
1665
2274
  } catch (error) {
1666
2275
  console.error(error);
1667
2276
  }
@@ -1939,6 +2548,14 @@ export default class MathRichInput extends React.Component {
1939
2548
  componentDidMount() {
1940
2549
  try {
1941
2550
  setupKatex();
2551
+ debugMathRichInput("componentDidMount", {
2552
+ propsValue: this.props.value,
2553
+ propsMimeType: this.props.mimeType,
2554
+ autofocus: this.props.autofocus,
2555
+ autoFocus: this.props.autoFocus,
2556
+ innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
2557
+ selection: getSelectionSnapshot(this.editableDiv),
2558
+ });
1942
2559
 
1943
2560
  this.editableDiv.addEventListener("keydown", this.handleKeyDown);
1944
2561
  this.editableDiv.addEventListener("keyup", this.handleKeyUp);
@@ -1961,16 +2578,82 @@ export default class MathRichInput extends React.Component {
1961
2578
  }
1962
2579
  }
1963
2580
 
2581
+ shouldComponentUpdate(nextProps, nextState) {
2582
+ if (this.skipNextControlledValueRender === true) {
2583
+ const canSkip =
2584
+ nextProps.value === this.skipNextControlledValue &&
2585
+ nextState === this.state;
2586
+
2587
+ debugMathRichInput("shouldComponentUpdate:native-input-gate", {
2588
+ canSkip,
2589
+ nextValue: nextProps.value,
2590
+ skipNextControlledValue: this.skipNextControlledValue,
2591
+ currentPropsValue: this.props.value,
2592
+ stateChanged: nextState !== this.state,
2593
+ innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
2594
+ selection: getSelectionSnapshot(this.editableDiv),
2595
+ });
2596
+
2597
+ this.skipNextControlledValueRender = false;
2598
+ this.skipNextControlledValue = null;
2599
+
2600
+ if (canSkip) return false;
2601
+ }
2602
+
2603
+ if (this.shouldSkipRedundantFocusedRender(nextProps, nextState)) {
2604
+ debugMathRichInput("shouldComponentUpdate:skip-redundant-focused-render", {
2605
+ nextValue: nextProps.value,
2606
+ currentPropsValue: this.props.value,
2607
+ selection: getSelectionSnapshot(this.editableDiv),
2608
+ });
2609
+ return false;
2610
+ }
2611
+
2612
+ return true;
2613
+ }
2614
+
1964
2615
  componentDidUpdate() {
1965
2616
  try {
2617
+ debugMathRichInput("componentDidUpdate:start", {
2618
+ propsValue: this.props.value,
2619
+ propsMimeType: this.props.mimeType,
2620
+ afterComponentUpdateData: this.afterComponentUpdateData,
2621
+ hasFocus: this.state.hasFocus,
2622
+ showEquationEditor: this.state.showEquationEditor,
2623
+ innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
2624
+ selection: getSelectionSnapshot(this.editableDiv),
2625
+ });
1966
2626
  if (
1967
2627
  this.afterComponentUpdateData !== null &&
1968
2628
  this.afterComponentUpdateData !== undefined &&
1969
2629
  this.afterComponentUpdateData.value === this.props.value
1970
2630
  ) {
2631
+ debugMathRichInput("componentDidUpdate:restore-before", {
2632
+ rangeParams: this.afterComponentUpdateData.rangeParams,
2633
+ selection: getSelectionSnapshot(this.editableDiv),
2634
+ });
1971
2635
  this._setRangeParams(this.afterComponentUpdateData.rangeParams);
1972
2636
  this.afterComponentUpdateData = null;
1973
2637
  this.updateActiveButtons();
2638
+ debugMathRichInput("componentDidUpdate:restore-after", {
2639
+ selection: getSelectionSnapshot(this.editableDiv),
2640
+ });
2641
+ }
2642
+ if (
2643
+ this.preserveNativeDomRangeParams !== null &&
2644
+ this.preserveNativeDomRangeParams !== undefined &&
2645
+ this.state.hasFocus === true
2646
+ ) {
2647
+ const rangeParams = this.preserveNativeDomRangeParams;
2648
+ this.preserveNativeDomRangeParams = null;
2649
+ debugMathRichInput("componentDidUpdate:preserve-restore-before", {
2650
+ rangeParams,
2651
+ selection: getSelectionSnapshot(this.editableDiv),
2652
+ });
2653
+ this._setRangeParams(rangeParams);
2654
+ debugMathRichInput("componentDidUpdate:preserve-restore-after", {
2655
+ selection: getSelectionSnapshot(this.editableDiv),
2656
+ });
1974
2657
  }
1975
2658
  } catch (error) {
1976
2659
  console.error(error);
@@ -1980,6 +2663,14 @@ export default class MathRichInput extends React.Component {
1980
2663
  showEquationEditor = (moveCursorOnInsert = false, node = null) => {
1981
2664
  try {
1982
2665
  let rangeParams = this._getRangeParams();
2666
+ debugMathRichInput("showEquationEditor:start", {
2667
+ moveCursorOnInsert,
2668
+ nodeProvided: Boolean(node),
2669
+ rangeParams,
2670
+ oldRangeParams: this.oldRangeParams,
2671
+ innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
2672
+ selection: getSelectionSnapshot(this.editableDiv),
2673
+ });
1983
2674
  if (rangeParams.startNodeIndex < 0) {
1984
2675
  console.error("Could not find position of cursor in node list");
1985
2676
 
@@ -2030,6 +2721,7 @@ export default class MathRichInput extends React.Component {
2030
2721
  this.editingNode = null;
2031
2722
  }
2032
2723
 
2724
+ this.setOldRangeParams(rangeParams);
2033
2725
  this.markedRawText = elementToMarkedRawText(
2034
2726
  this.editableDiv,
2035
2727
  node,
@@ -2042,12 +2734,22 @@ export default class MathRichInput extends React.Component {
2042
2734
  showEquationEditor: true,
2043
2735
  equationEditorLatex: latex,
2044
2736
  });
2737
+ debugMathRichInput("showEquationEditor:after-setState", {
2738
+ rangeParams,
2739
+ markedRawText: this.markedRawText,
2740
+ latex,
2741
+ selection: getSelectionSnapshot(this.editableDiv),
2742
+ });
2045
2743
  } catch (error) {
2046
2744
  console.error(error);
2047
2745
  }
2048
2746
  };
2049
2747
 
2050
2748
  handleMouseDown = (event) => {
2749
+ this.isMouseDownInEditable = true;
2750
+ window.setTimeout(() => {
2751
+ this.isMouseDownInEditable = false;
2752
+ }, 0);
2051
2753
  this.showEquationEditorOnMouseUp = false;
2052
2754
  let node = null;
2053
2755
  try {
@@ -2086,8 +2788,9 @@ export default class MathRichInput extends React.Component {
2086
2788
  // Clear selection anchor on mouse click
2087
2789
  this.selectionAnchor = null;
2088
2790
 
2089
- let params = this._getRangeParams();
2090
- let startNode = this._findNodeWithIndex(params.startNodeIndex);
2791
+ let params = this._getRangeParams();
2792
+ this.setOldRangeParams(params);
2793
+ let startNode = this._findNodeWithIndex(params.startNodeIndex);
2091
2794
  let endNode = this._findNodeWithIndex(params.endNodeIndex);
2092
2795
 
2093
2796
  if (startNode === null || endNode === null) {
@@ -2153,6 +2856,15 @@ export default class MathRichInput extends React.Component {
2153
2856
  handleEquationEditorInsert = (latex) => {
2154
2857
  try {
2155
2858
  // console.log("Inserting latex: "+latex)
2859
+ debugMathRichInput("handleEquationEditorInsert:start", {
2860
+ latex,
2861
+ markedRawText: this.markedRawText,
2862
+ oldRangeParams: this.getOldRangeParams(),
2863
+ moveCursorOnInsert: this.moveCursorOnInsert,
2864
+ propsValue: this.props.value,
2865
+ innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
2866
+ selection: getSelectionSnapshot(this.editableDiv),
2867
+ });
2156
2868
 
2157
2869
  let start_pos = this.markedRawText.indexOf(MARK);
2158
2870
  let end_pos = this.markedRawText.indexOf(MARK, start_pos + MARK.length);
@@ -2205,15 +2917,19 @@ export default class MathRichInput extends React.Component {
2205
2917
  this.enableHtml()
2206
2918
  );
2207
2919
 
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;
2920
+ this.applyChangesToComponent(
2921
+ new_raw_text,
2922
+ mimeType,
2923
+ new_rangeParams,
2924
+ this.props.useExpertMode,
2925
+ this.props.selectedTab
2926
+ );
2927
+ this.setState({ showEquationEditor: false }, () => {
2928
+ this.editableDiv.focus();
2929
+ this._setRangeParams(new_rangeParams);
2930
+ this.restoreRangeAfterBrowserWork(new_rangeParams, "equation-empty");
2931
+ });
2932
+ return;
2217
2933
  }
2218
2934
 
2219
2935
  // Handle case of latex returned, in which case we update the katex node
@@ -2235,25 +2951,42 @@ export default class MathRichInput extends React.Component {
2235
2951
  endOffset: SMALL_SPACE_LENGTH,
2236
2952
  };
2237
2953
  } 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
-
2954
+ // In this editor a rendered equation counts as one logical character
2955
+ // in global-offset space. Put the caret immediately after that math
2956
+ // character instead of guessing rendered child-node indexes.
2957
+ const oldRangeParams = this.getOldRangeParams();
2958
+ if (
2959
+ oldRangeParams !== null &&
2960
+ oldRangeParams.startGlobalOffset !== null &&
2961
+ oldRangeParams.startGlobalOffset !== undefined
2962
+ ) {
2963
+ const afterInsertedMathOffset = oldRangeParams.startGlobalOffset + 1;
2247
2964
  new_rangeParams = {
2248
- startGlobalOffset: afterMathOffset,
2249
- endGlobalOffset: afterMathOffset,
2965
+ startGlobalOffset: afterInsertedMathOffset,
2966
+ endGlobalOffset: afterInsertedMathOffset,
2967
+ };
2968
+ } else if (
2969
+ oldRangeParams !== null &&
2970
+ oldRangeParams.startNodeIndex !== null &&
2971
+ oldRangeParams.startNodeIndex !== undefined
2972
+ ) {
2973
+ new_rangeParams = {
2974
+ startNodeIndex: oldRangeParams.startNodeIndex + 2,
2975
+ startOffset: SMALL_SPACE_LENGTH,
2976
+ endNodeIndex: oldRangeParams.startNodeIndex + 2,
2977
+ endOffset: SMALL_SPACE_LENGTH,
2250
2978
  };
2251
2979
  }
2252
- // If no math tags found, keep old range params
2980
+ // If old range params are unavailable, keep the current fallback.
2253
2981
  }
2254
2982
  }
2255
2983
  // console.log("New cursor pos: "+new_rangeParams.startNodeIndex+"->"+new_rangeParams.startOffset)
2256
2984
  this.setOldRangeParams(new_rangeParams);
2985
+ debugMathRichInput("handleEquationEditorInsert:before-apply", {
2986
+ new_raw_text,
2987
+ new_rangeParams,
2988
+ selection: getSelectionSnapshot(this.editableDiv),
2989
+ });
2257
2990
 
2258
2991
  let nodeCounts = this._countNodeTypes();
2259
2992
  let mimeType = this.getMimeType(nodeCounts.html > 0, true);
@@ -2270,7 +3003,15 @@ export default class MathRichInput extends React.Component {
2270
3003
  this.props.useExpertMode,
2271
3004
  this.props.selectedTab
2272
3005
  );
2273
- this.setState({ showEquationEditor: false });
3006
+ this.setState({ showEquationEditor: false }, () => {
3007
+ this.editableDiv.focus();
3008
+ this._setRangeParams(new_rangeParams);
3009
+ this.restoreRangeAfterBrowserWork(new_rangeParams, "equation-insert");
3010
+ debugMathRichInput("handleEquationEditorInsert:after-close-restore", {
3011
+ new_rangeParams,
3012
+ selection: getSelectionSnapshot(this.editableDiv),
3013
+ });
3014
+ });
2274
3015
  } catch (error) {
2275
3016
  console.error(error);
2276
3017
  }
@@ -2288,16 +3029,37 @@ export default class MathRichInput extends React.Component {
2288
3029
  styleText = (event, style) => {
2289
3030
  try {
2290
3031
  style = style.toLowerCase();
3032
+ const command = this.getStyleCommand(style);
3033
+ const activeBefore = this.getActiveButtonsFromSelection();
3034
+ const selection = document.getSelection();
3035
+ const isCollapsed =
3036
+ selection === null ||
3037
+ selection.rangeCount === 0 ||
3038
+ selection.getRangeAt(0).collapsed;
2291
3039
  let success = false;
3040
+ let insertedTypingStylePlaceholder = false;
2292
3041
 
2293
3042
  // This first part is a workaround whereby executing subscript or superscript
2294
3043
  // within an existing subscript or superscript makes the text even smaller instead
2295
3044
  // of toggling it off. Thus we manually toggle it off with some jigerry pokery!
2296
3045
  if (
2297
- (style === "subscript" && this.state.activeButtons.subscript) ||
2298
- (style === "superscript" && this.state.activeButtons.superscript)
3046
+ isCollapsed &&
3047
+ activeBefore[style] &&
3048
+ this.moveTrailingWhitespaceOutsideInlineStyle(style, command)
3049
+ ) {
3050
+ success = true;
3051
+ } else if (
3052
+ isCollapsed &&
3053
+ !activeBefore[style] &&
3054
+ selection &&
3055
+ selection.rangeCount > 0
3056
+ ) {
3057
+ success = this.insertInlineStylePlaceholder(style);
3058
+ insertedTypingStylePlaceholder = success;
3059
+ } else if (
3060
+ (style === "subscript" && activeBefore.subscript) ||
3061
+ (style === "superscript" && activeBefore.superscript)
2299
3062
  ) {
2300
- const selection = document.getSelection();
2301
3063
  let range = selection.getRangeAt(0);
2302
3064
  if (
2303
3065
  range.endContainer === range.startContainer &&
@@ -2309,90 +3071,29 @@ export default class MathRichInput extends React.Component {
2309
3071
  selection.addRange(range);
2310
3072
  }
2311
3073
  success = document.execCommand("removeFormat", false, null);
2312
- if (this.state.activeButtons.bold)
3074
+ if (activeBefore.bold)
2313
3075
  success = document.execCommand("bold", false, null);
2314
- if (this.state.activeButtons.italic)
3076
+ if (activeBefore.italic)
2315
3077
  success = document.execCommand("italic", false, null);
2316
- if (this.state.activeButtons.underline)
3078
+ if (activeBefore.underline)
2317
3079
  success = document.execCommand("underline", false, null);
2318
3080
  } else {
2319
- const selection = document.getSelection();
2320
3081
  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
- }
3082
+ success = document.execCommand(command, false, null);
2386
3083
  }
2387
3084
  }
2388
3085
 
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 });
3086
+ const rangeParams = this._getRangeParams();
3087
+ const removedEmptyTags =
3088
+ !insertedTypingStylePlaceholder &&
3089
+ this.cleanupEmptyInlineFormattingElements();
3090
+ if (removedEmptyTags && rangeParams !== null && rangeParams !== undefined) {
3091
+ this._setRangeParams(rangeParams);
3092
+ this.applyCurrentEditableDomToComponent(rangeParams);
3093
+ }
3094
+ this.setActiveButtonsIfChanged(
3095
+ this.getActiveButtonsFromSelection(rangeParams)
3096
+ );
2396
3097
  return success;
2397
3098
  } catch (error) {
2398
3099
  console.error(error);
@@ -2401,8 +3102,15 @@ export default class MathRichInput extends React.Component {
2401
3102
 
2402
3103
  handleToolbarButtonClick = (event, buttonName) => {
2403
3104
  try {
3105
+ debugMathRichInput("handleToolbarButtonClick:start", {
3106
+ buttonName,
3107
+ eventType: event ? event.type : null,
3108
+ selection: getSelectionSnapshot(this.editableDiv),
3109
+ oldRangeParams: this.oldRangeParams,
3110
+ });
2404
3111
  if (buttonName === "Equation") {
2405
3112
  this.showEquationEditor(true);
3113
+ return;
2406
3114
  }
2407
3115
  let style = buttonName;
2408
3116
  let success = this.styleText(event, style);
@@ -2751,10 +3459,10 @@ export default class MathRichInput extends React.Component {
2751
3459
  }
2752
3460
  };
2753
3461
 
2754
- insertAfterSmallSpace(new_char, rangeParams) {
2755
- try {
2756
- if (rangeParams === null || rangeParams === undefined)
2757
- rangeParams = this._getRangeParams();
3462
+ insertAfterSmallSpace(new_char, rangeParams) {
3463
+ try {
3464
+ if (rangeParams === null || rangeParams === undefined)
3465
+ rangeParams = this._getRangeParams();
2758
3466
 
2759
3467
  if (rangeParams.startOffset !== 0)
2760
3468
  console.warn(
@@ -2763,29 +3471,41 @@ export default class MathRichInput extends React.Component {
2763
3471
  " in insertAfterSmallSpace"
2764
3472
  );
2765
3473
 
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,
3474
+ let node = this._findNodeWithIndex(rangeParams.startNodeIndex);
3475
+ if (node === null || node === undefined || node.nodeValue === null) {
3476
+ console.warn("Could not find small-space text node");
3477
+ return;
3478
+ }
3479
+
3480
+ // The initial empty editor contains a hidden zero-width space. Handling
3481
+ // the first printable key by re-rendering through React replaces the
3482
+ // text node and drops Chrome's selection to the start under React 19.
3483
+ // Mutate the already-focused DOM node, then skip the matching controlled
3484
+ // render exactly like the normal contenteditable input path.
3485
+ node.nodeValue =
3486
+ node.nodeValue.substring(0, 1) +
3487
+ new_char +
3488
+ node.nodeValue.substring(1);
3489
+
3490
+ let new_rangeParams = {
3491
+ startNodeIndex: rangeParams.startNodeIndex,
3492
+ startOffset: 1 + new_char.length,
3493
+ endNodeIndex: rangeParams.startNodeIndex,
3494
+ endOffset: 1 + new_char.length,
3495
+ startGlobalOffset: rangeParams.startGlobalOffset + new_char.length,
3496
+ endGlobalOffset: rangeParams.endGlobalOffset + new_char.length,
3497
+ };
3498
+ this._setRangeParams(new_rangeParams);
3499
+
3500
+ let new_raw_text = elementToMarkedRawText(
3501
+ this.editableDiv,
3502
+ null,
3503
+ 0,
3504
+ this.enableHtml()
3505
+ );
3506
+
3507
+ new_raw_text = removeEncodingIfPlainText(
3508
+ new_raw_text,
2789
3509
  this.props.mimeType,
2790
3510
  this.enableHtml()
2791
3511
  );
@@ -2793,12 +3513,13 @@ export default class MathRichInput extends React.Component {
2793
3513
  this.applyChangesToComponent(
2794
3514
  new_raw_text,
2795
3515
  this.props.mimeType,
2796
- new_rangeParams,
2797
- this.props.useExpertMode,
2798
- this.props.selectedTab
2799
- );
2800
- } catch (error) {
2801
- console.error(error);
3516
+ new_rangeParams,
3517
+ this.props.useExpertMode,
3518
+ this.props.selectedTab,
3519
+ { skipControlledRender: true }
3520
+ );
3521
+ } catch (error) {
3522
+ console.error(error);
2802
3523
  }
2803
3524
  }
2804
3525
 
@@ -2895,12 +3616,39 @@ export default class MathRichInput extends React.Component {
2895
3616
 
2896
3617
  handleFocus = (event) => {
2897
3618
  const { onFocus } = this.props;
3619
+ let currentRangeParams = null;
3620
+ try {
3621
+ currentRangeParams = this._getRangeParams();
3622
+ } catch (error) {
3623
+ currentRangeParams = null;
3624
+ }
3625
+ const savedRangeParams = this.getOldRangeParams();
3626
+ const shouldRestoreSavedRange =
3627
+ !this.isMouseDownInEditable &&
3628
+ this.props.value !== "" &&
3629
+ this.rangeIsAtStart(currentRangeParams) &&
3630
+ this.rangeHasNonZeroPosition(savedRangeParams);
3631
+ debugMathRichInput("handleFocus:start", {
3632
+ propsValue: this.props.value,
3633
+ oldRangeParams: this.oldRangeParams,
3634
+ currentRangeParams,
3635
+ shouldRestoreSavedRange,
3636
+ innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
3637
+ selection: getSelectionSnapshot(this.editableDiv),
3638
+ });
2898
3639
  if (onFocus != null) {
2899
3640
  onFocus();
2900
3641
  }
2901
3642
  try {
2902
- this._setRangeParams(this.getOldRangeParams());
2903
- this.setState({ hasFocus: true });
3643
+ this.setState({ hasFocus: true }, () => {
3644
+ if (shouldRestoreSavedRange) {
3645
+ this._setRangeParams(savedRangeParams);
3646
+ this.restoreRangeAfterBrowserWork(savedRangeParams, "focus-restore");
3647
+ }
3648
+ debugMathRichInput("handleFocus:after-setState", {
3649
+ selection: getSelectionSnapshot(this.editableDiv),
3650
+ });
3651
+ });
2904
3652
  } catch (error) {
2905
3653
  console.error(error);
2906
3654
  }
@@ -2908,12 +3656,31 @@ export default class MathRichInput extends React.Component {
2908
3656
 
2909
3657
  handleBlur = (event) => {
2910
3658
  const { onBlur } = this.props;
3659
+ let currentRangeParams = null;
3660
+ try {
3661
+ currentRangeParams = this._getRangeParams();
3662
+ if (this.rangeHasNonZeroPosition(currentRangeParams)) {
3663
+ this.setOldRangeParams(currentRangeParams);
3664
+ }
3665
+ } catch (error) {
3666
+ currentRangeParams = null;
3667
+ }
3668
+ debugMathRichInput("handleBlur:start", {
3669
+ propsValue: this.props.value,
3670
+ oldRangeParams: this.oldRangeParams,
3671
+ currentRangeParams,
3672
+ innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
3673
+ selection: getSelectionSnapshot(this.editableDiv),
3674
+ });
2911
3675
  if (onBlur != null) {
2912
3676
  onBlur();
2913
3677
  }
2914
3678
  try {
2915
3679
  // console.log("Hiding accent bar 4");
2916
3680
  this.setState({ hasFocus: false, showAccentBar: false });
3681
+ debugMathRichInput("handleBlur:after-setState", {
3682
+ selection: getSelectionSnapshot(this.editableDiv),
3683
+ });
2917
3684
  } catch (error) {
2918
3685
  console.error(error);
2919
3686
  }
@@ -3039,6 +3806,23 @@ export default class MathRichInput extends React.Component {
3039
3806
  this.props.value || "",
3040
3807
  this.props.mimeType
3041
3808
  );
3809
+ const editableHtml = this.getEditableHtmlForRender(html);
3810
+ debugMathRichInput("render", {
3811
+ propsValue: this.props.value,
3812
+ propsMimeType: this.props.mimeType,
3813
+ html,
3814
+ editableHtml,
3815
+ state: {
3816
+ hasFocus: this.state.hasFocus,
3817
+ showEquationEditor: this.state.showEquationEditor,
3818
+ showAccentBar: this.state.showAccentBar,
3819
+ keyPressed: this.state.keyPressed,
3820
+ },
3821
+ afterComponentUpdateData: this.afterComponentUpdateData,
3822
+ oldRangeParams: this.oldRangeParams,
3823
+ editableInnerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
3824
+ selection: getSelectionSnapshot(this.editableDiv),
3825
+ });
3042
3826
 
3043
3827
  // Debug render math
3044
3828
  // if (this.props.value && this.props.value.includes("<math>")) {
@@ -3103,7 +3887,7 @@ export default class MathRichInput extends React.Component {
3103
3887
  onCompositionStart={this.handleCompositionStart}
3104
3888
  onCompositionEnd={this.handleCompositionEnd}
3105
3889
  dangerouslySetInnerHTML={{
3106
- __html: html,
3890
+ __html: editableHtml,
3107
3891
  }}
3108
3892
  data-placeholder={this.props.placeholder || ""}
3109
3893
  ></span>