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