@zzish/math-rich-input 0.1.53 → 0.1.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zzish/math-rich-input",
3
- "version": "0.1.53",
3
+ "version": "0.1.54",
4
4
  "description": "React component for user to enter rich text with embedded math equations.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -33,6 +33,7 @@ import {
33
33
  getPlainTextFromEditableDiv,
34
34
  globalOffsetToPlainTextOffset,
35
35
  plainTextOffsetToGlobalOffset,
36
+ SELECTION_MARK,
36
37
  } from "./mathRichInputHelper";
37
38
  import {
38
39
  debugMathRichInput,
@@ -2338,30 +2339,18 @@ export default class MathRichInput extends React.Component {
2338
2339
  let pasteHtml = clipboardData.getData("text/html");
2339
2340
  let pasteText = clipboardData.getData("text/plain");
2340
2341
 
2341
- // Remove whatever was selected FIRST, then read the caret.
2342
- //
2343
- // The order used to be the other way round, and that is why pasting over a selection emptied the
2344
- // field instead of replacing it. `rangeParams` is a NODE INDEX plus an offset, and
2345
- // `deleteFromDocument()` removes the very nodes it counts: read before the delete, the index points
2346
- // at something that no longer exists, so `_findNodeWithIndex` below finds nothing, the marked text
2347
- // it builds carries no mark, and the pasted text is inserted nowhere. The value the component then
2348
- // reports is correct while the editable div is left empty which is exactly what a teacher sees.
2349
- //
2350
- // It only ever worked on an empty field because the canonical empty state is a single `<p>` holding
2351
- // one small space: deleting that selection leaves the same node structure standing, so the stale
2352
- // index still happens to resolve.
2353
- // Read the caret BEFORE, so there is something to fall back on, and AGAIN after the delete.
2354
- const paramsBeforeDelete = this._getRangeParams();
2355
-
2356
- const selection = window.getSelection();
2357
- if (selection && selection.rangeCount > 0) {
2358
- selection.deleteFromDocument();
2359
- }
2360
-
2361
- // The caret AFTER the delete is the one that counts: `rangeParams` is a node index, and the delete
2362
- // removed the very nodes it counts. The pre-delete value is kept only as a fallback — bailing out
2363
- // here having already emptied the selection is how a paste turns into a deletion.
2364
- const rangeParams = this._getRangeParams() || paramsBeforeDelete;
2342
+ /*
2343
+ * The caret is read BEFORE the selection is removed, and that order is deliberate.
2344
+ *
2345
+ * Reading it afterwards looks more correct — `deleteFromDocument()` removes the very nodes the
2346
+ * index counts and it was tried, on the theory that a stale index was why pasting over a
2347
+ * selection emptied the field. It was not: the field emptied because React never wrote the
2348
+ * content back (see `reconcileEditableDomWithRender`), and the reordering was left in on a theory
2349
+ * that had already been disproved. In a host whose field holds unwrapped text rather than a `<p>`,
2350
+ * the emptied div yields a caret that `elementToMarkedRawText` cannot mark at all — no mark, so the
2351
+ * pasted text is inserted nowhere and the paste becomes a deletion.
2352
+ */
2353
+ const rangeParams = this._getRangeParams();
2365
2354
  if (!rangeParams) {
2366
2355
  console.warn("Could not get range params for paste");
2367
2356
  return;
@@ -2370,6 +2359,12 @@ export default class MathRichInput extends React.Component {
2370
2359
  // Store old range params for cursor positioning (like equation editor)
2371
2360
  this.setOldRangeParams(rangeParams);
2372
2361
 
2362
+ // Clear any existing selection
2363
+ const selection = window.getSelection();
2364
+ if (selection && selection.rangeCount > 0) {
2365
+ selection.deleteFromDocument();
2366
+ }
2367
+
2373
2368
  let finalText = "";
2374
2369
  let finalMimeType = this.props.mimeType || "text/html";
2375
2370
  let hasMathContent = false;
@@ -2478,19 +2473,84 @@ export default class MathRichInput extends React.Component {
2478
2473
 
2479
2474
  // Use the component's insert mechanism to handle paste properly
2480
2475
  if (finalText) {
2481
- // Get current node and create marked text at cursor position
2482
- const currentNode = this._findNodeWithIndex(rangeParams.startNodeIndex);
2476
+ /*
2477
+ * WHERE TO PUT THE PASTED TEXT, found in the document rather than by index.
2478
+ *
2479
+ * `elementToMarkedRawText` marks ONE position — a node and an offset — and the selection has to
2480
+ * be gone from the document before that position means anything. It was, and then the position
2481
+ * was looked up by INDEX, which is where every version of this went wrong: an index read before
2482
+ * the delete counts nodes the delete then removes, and an index read after it does not resolve in
2483
+ * a field whose content is unwrapped text. Either way `_findNodeWithIndex` returns nothing, no
2484
+ * mark is placed, `insertCharacterBeforeMarks` warns "Start: -1" and returns the text UNCHANGED —
2485
+ * so the pasted words go nowhere, and what the teacher sees is their field emptied.
2486
+ *
2487
+ * The browser's own selection is the answer: after `deleteFromDocument()` it is left collapsed at
2488
+ * exactly the point where the pasted text belongs, in a node that exists by construction. The
2489
+ * index lookup stays as a fallback for the case where the selection has gone elsewhere.
2490
+ */
2491
+ const live = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
2492
+ const liveNode =
2493
+ live && this.editableDiv.contains(live.startContainer) ? live.startContainer : null;
2494
+ const currentNode =
2495
+ liveNode !== null ? liveNode : this._findNodeWithIndex(rangeParams.startNodeIndex);
2496
+ const currentOffset = liveNode !== null ? live.startOffset : rangeParams.startOffset;
2497
+
2483
2498
  const markedText = elementToMarkedRawText(
2484
2499
  this.editableDiv,
2485
2500
  currentNode,
2486
- rangeParams.startOffset,
2501
+ currentOffset,
2487
2502
  this.enableHtml()
2488
2503
  );
2489
2504
 
2505
+ /*
2506
+ * A FIELD WITH NOTHING LEFT IN IT NEEDS NO MARK.
2507
+ *
2508
+ * Marking only works on a TEXT node, and an emptied field has none: the browser leaves the caret
2509
+ * in the element itself, so `elementToMarkedRawText` has nothing to mark and returns text with no
2510
+ * mark in it. `insertCharacterBeforeMarks` then warns and hands back its input UNCHANGED, which
2511
+ * reads as success — the value comes out valid with the pasted words missing, and the teacher
2512
+ * watches their field empty itself.
2513
+ *
2514
+ * But an empty field after the delete says something exact: the selection covered everything, so
2515
+ * the result of the paste IS the pasted text. Nothing needs to be worked out. This is the
2516
+ * select-all-and-paste that every report of this bug has been.
2517
+ */
2518
+ const remainingText = this.editableDiv.textContent
2519
+ .split(SMALL_SPACE)
2520
+ .join("")
2521
+ .trim();
2522
+ const fieldIsEmpty =
2523
+ remainingText === "" && this.editableDiv.querySelector(".katex") === null;
2524
+ const marked = markedText.indexOf(SELECTION_MARK) !== -1;
2525
+
2526
+ /*
2527
+ * NOTHING IS BETTER THAN DESTRUCTION.
2528
+ *
2529
+ * No mark and content still standing means the caret cannot be located inside text that must be
2530
+ * kept. Carrying on would write a value with the pasted text missing over a field whose selection
2531
+ * has already been removed from the document — a paste that performs a deletion. Stopping leaves
2532
+ * `props.value` untouched and `reconcileEditableDomWithRender` puts the document back from it:
2533
+ * the paste does nothing, which is a disappointment rather than a loss.
2534
+ */
2535
+ if (!marked && !fieldIsEmpty) {
2536
+ console.warn(
2537
+ "[math-rich-input] paste found nowhere to insert; the field is left as it was",
2538
+ { rangeParams, hadLiveSelection: liveNode !== null }
2539
+ );
2540
+ queueMicrotask(() => {
2541
+ try {
2542
+ this.reconcileEditableDomWithRender(true);
2543
+ } catch (error) {
2544
+ console.error(error);
2545
+ }
2546
+ });
2547
+ return;
2548
+ }
2549
+
2490
2550
  // Insert the new content at the marked position
2491
- let newRawText = removeMarks(
2492
- insertCharacterBeforeMarks(markedText, finalText)
2493
- );
2551
+ let newRawText = marked
2552
+ ? removeMarks(insertCharacterBeforeMarks(markedText, finalText))
2553
+ : finalText;
2494
2554
  // Convert any remaining \[...\] LaTeX expressions to <math>...</math> format
2495
2555
  // This ensures consistency when paste adds <math> tags alongside existing \[...\] expressions
2496
2556
  if (this.enableMath()) {
@@ -1582,3 +1582,12 @@ export function plainTextOffsetToGlobalOffset(editableDiv, plainTextOffset) {
1582
1582
  let plainText = getPlainTextFromEditableDiv(editableDiv);
1583
1583
  return Math.min(plainTextOffset, plainText.length);
1584
1584
  }
1585
+
1586
+ /**
1587
+ * The character used to mark a position in text while content is being rewritten.
1588
+ *
1589
+ * Exported so a caller can tell whether a mark was actually placed. `insertCharacterBeforeMarks` warns
1590
+ * and returns its input UNCHANGED when there is no mark, which reads as success at the call site — the
1591
+ * value comes back looking valid with the inserted text silently missing.
1592
+ */
1593
+ export const SELECTION_MARK = MARK;