@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/src/Toolbar.jsx CHANGED
@@ -53,6 +53,7 @@ export default class Toolbar extends React.Component {
53
53
  handleButtonClick = (event, buttonName) => {
54
54
  event.preventDefault();
55
55
  event.stopPropagation();
56
+ this.props.beforeAction?.();
56
57
  this.suppliedClickHandler(event, buttonName);
57
58
  };
58
59
 
@@ -147,70 +148,75 @@ export default class Toolbar extends React.Component {
147
148
  }
148
149
 
149
150
  return (
150
- <div className="Toolbar" style={style} ref={(r) => (this.toolbar = r)}>
151
+ <div className="Toolbar" style={style} ref={(r) => (this.toolbar = r)} onBlur={this.props.onBlur}>
151
152
  {enableFontStyling && (
152
153
  <>
153
154
  <button
155
+ type="button"
154
156
  name="Bold"
157
+ aria-label="Bold"
155
158
  className={
156
159
  "Toolbar-button TB-narrow" +
157
160
  (this.props.activeButtons.bold ? " TB-selected" : "")
158
161
  }
159
- onClick={(e) => e.preventDefault()}
162
+ onClick={(e) => this.handleButtonClick(e, "Bold")}
160
163
  onMouseDown={(e) => e.preventDefault()}
161
- onMouseUp={(e) => this.handleButtonClick(e, "Bold")}
162
164
  >
163
165
  <b>B</b>
164
166
  </button>
165
167
 
166
168
  <button
169
+ type="button"
167
170
  name="Italic"
171
+ aria-label="Italic"
168
172
  className={
169
173
  "Toolbar-button TB-narrow" +
170
174
  (this.props.activeButtons.italic ? " TB-selected" : "")
171
175
  }
172
- onClick={(e) => e.preventDefault()}
176
+ onClick={(e) => this.handleButtonClick(e, "Italic")}
173
177
  onMouseDown={(e) => e.preventDefault()}
174
- onMouseUp={(e) => this.handleButtonClick(e, "Italic")}
175
178
  >
176
179
  <i>I</i>
177
180
  </button>
178
181
 
179
182
  <button
183
+ type="button"
180
184
  name="Underline"
185
+ aria-label="Underline"
181
186
  className={
182
187
  "Toolbar-button TB-narrow" +
183
188
  (this.props.activeButtons.underline ? " TB-selected" : "")
184
189
  }
185
- onClick={(e) => e.preventDefault()}
190
+ onClick={(e) => this.handleButtonClick(e, "Underline")}
186
191
  onMouseDown={(e) => e.preventDefault()}
187
- onMouseUp={(e) => this.handleButtonClick(e, "Underline")}
188
192
  >
189
193
  <u>U</u>
190
194
  </button>
191
195
 
192
196
  <button
197
+ type="button"
193
198
  name="Superscript"
199
+ aria-label="Superscript"
194
200
  className={
195
201
  "Toolbar-button TB-narrow" +
196
202
  (this.props.activeButtons.superscript ? " TB-selected" : "")
197
203
  }
198
- onClick={(e) => e.preventDefault()}
204
+ onClick={(e) => this.handleButtonClick(e, "Superscript")}
199
205
  onMouseDown={(e) => e.preventDefault()}
200
- onMouseUp={(e) => this.handleButtonClick(e, "Superscript")}
201
206
  >
202
207
  a<sup>x</sup>
203
208
  </button>
204
209
 
205
210
  <button
211
+ type="button"
206
212
  name="Subscript"
213
+ aria-label="Subscript"
207
214
  className={
208
215
  "Toolbar-button TB-narrow" +
209
216
  (this.props.activeButtons.subscript ? " TB-selected" : "")
210
217
  }
211
- onClick={(e) => e.preventDefault()}
218
+ onClick={(e) => this.handleButtonClick(e, "Subscript")}
212
219
  onMouseDown={(e) => e.preventDefault()}
213
- onMouseUp={(e) => this.handleButtonClick(e, "Subscript")}
214
220
  >
215
221
  a<sub>x</sub>
216
222
  </button>
@@ -218,24 +224,32 @@ export default class Toolbar extends React.Component {
218
224
  )}
219
225
 
220
226
  <button
227
+ type="button"
221
228
  name="Accents"
229
+ aria-label="Accents"
230
+ aria-expanded={this.state.showAccents}
222
231
  className={
223
232
  "Toolbar-button TB-narrow" +
224
233
  (this.state.showAccents ? " TB-selected" : "")
225
234
  }
226
235
  onMouseDown={(e) => e.preventDefault()}
227
- onClick={(e) => e.preventDefault()}
228
- onMouseUp={(e) => this.showOrHideAccentBar(e)}
236
+ onClick={(e) => {
237
+ e.preventDefault(); e.stopPropagation();
238
+ this.props.beforeAction?.();
239
+ this.showOrHideAccentBar(e);
240
+ }}
229
241
  >
230
242
  á
231
243
  </button>
232
244
 
233
245
  {enableMath && (
234
246
  <button
247
+ type="button"
235
248
  name="Equation"
249
+ aria-label="Insert equation"
236
250
  className="Toolbar-button TB-wide"
237
- onClick={(e) => e.preventDefault()}
238
- onMouseDown={(e) => this.handleButtonClick(e, "Equation")}
251
+ onClick={(e) => this.handleButtonClick(e, "Equation")}
252
+ onMouseDown={(e) => e.preventDefault()}
239
253
  >
240
254
  <div dangerouslySetInnerHTML={{ __html: katexHTML }} />
241
255
  <span className="tooltiptext">
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Capture browser editing intent in the logical text coordinates used by MathRichInput.
3
+ *
4
+ * The component deliberately reports a measured replacement range instead of asking a host to diff the
5
+ * old and new strings. A host can therefore retain two equal names independently and reject a stale
6
+ * event before it changes protected content.
7
+ */
8
+
9
+ const ZERO_WIDTH_SPACE = "\u200b";
10
+ const BLOCK_TAGS = new Set([
11
+ "p", "div", "section", "article", "blockquote", "pre", "li", "ul", "ol",
12
+ "h1", "h2", "h3", "h4", "h5", "h6", "table", "thead", "tbody", "tfoot",
13
+ "tr", "td", "th", "hr",
14
+ ]);
15
+ const INERT_TAGS = new Set(["script", "style", "template", "head", "title"]);
16
+
17
+ /** Return whether a node is the rendered KaTeX island that cannot be edited character by character. */
18
+ function isMathNode(node) {
19
+ return node && node.nodeType === 1 &&
20
+ (node.classList?.contains("katex") || node.classList?.contains("katex-error"));
21
+ }
22
+
23
+ /** Return whether a tag contributes a logical line boundary to the source text. */
24
+ function isBlock(node) {
25
+ if (!node || node.nodeType !== 1) return false;
26
+ return BLOCK_TAGS.has(node.tagName.toLowerCase());
27
+ }
28
+
29
+ function isInert(node) {
30
+ if (!node || node.nodeType !== 1) return false;
31
+ return INERT_TAGS.has(node.tagName.toLowerCase());
32
+ }
33
+
34
+ function trimZeroWidth(value) {
35
+ return value.split(ZERO_WIDTH_SPACE).join("");
36
+ }
37
+
38
+ /** Build the text coordinate string without reading attributes or rendered math markup. */
39
+ export function editableText(root) {
40
+ if (!root) return "";
41
+ const pieces = [];
42
+ let pendingBoundary = false;
43
+ const walk = (node) => {
44
+ if (isInert(node)) return;
45
+ if (isMathNode(node)) {
46
+ if (pendingBoundary && pieces.length && pieces[pieces.length - 1] !== "\n") pieces.push("\n");
47
+ const latex = node.querySelector?.('annotation[encoding="application/x-tex"]')?.textContent || "";
48
+ pieces.push(latex);
49
+ pendingBoundary = false;
50
+ return;
51
+ }
52
+ if (node.nodeType === 3) {
53
+ const text = trimZeroWidth(node.nodeValue || "");
54
+ if (text && pendingBoundary && pieces.length && pieces[pieces.length - 1] !== "\n") pieces.push("\n");
55
+ if (text) pieces.push(text);
56
+ if (text) pendingBoundary = false;
57
+ return;
58
+ }
59
+ if (node.nodeType !== 1 && node.nodeType !== 11) return;
60
+ if (node.nodeType === 1 && node.tagName.toLowerCase() === "br") {
61
+ pieces.push("\n");
62
+ pendingBoundary = false;
63
+ return;
64
+ }
65
+ const block = isBlock(node);
66
+ if (block && pieces.length) pendingBoundary = true;
67
+ for (const child of node.childNodes || []) walk(child);
68
+ if (block) pendingBoundary = true;
69
+ };
70
+ for (const child of root.childNodes || []) walk(child);
71
+ return pieces.join("");
72
+ }
73
+
74
+ function textBeforeEndpoint(root, container, offset) {
75
+ if (!root || !container || !root.contains(container)) return null;
76
+ const range = document.createRange();
77
+ range.selectNodeContents(root);
78
+ try {
79
+ range.setEnd(container, offset);
80
+ } catch (_error) {
81
+ return null;
82
+ }
83
+ const clone = document.createElement("span");
84
+ clone.appendChild(range.cloneContents());
85
+ return editableText(clone).length;
86
+ }
87
+
88
+ /** Read a collapsed or selected DOM range in logical UTF-16 offsets. */
89
+ export function selectionOffsets(root, selection = window.getSelection?.()) {
90
+ if (!root || !selection || selection.rangeCount === 0) return null;
91
+ const range = selection.getRangeAt(0);
92
+ const from = textBeforeEndpoint(root, range.startContainer, range.startOffset);
93
+ const to = textBeforeEndpoint(root, range.endContainer, range.endOffset);
94
+ if (from === null || to === null) return null;
95
+ return from <= to ? { from, to } : { from: to, to: from };
96
+ }
97
+
98
+ function expandSurrogateBoundary(text, from, to) {
99
+ const before = text.charCodeAt(from - 1);
100
+ const after = text.charCodeAt(from);
101
+ if (before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff) {
102
+ return { from: from - 1, to: Math.max(to, from + 1) };
103
+ }
104
+ const at = text.charCodeAt(to - 1);
105
+ const next = text.charCodeAt(to);
106
+ if (at >= 0xd800 && at <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) {
107
+ return { from: Math.min(from, to - 1), to: to + 1 };
108
+ }
109
+ return { from, to };
110
+ }
111
+
112
+ function textFromTransfer(dataTransfer) {
113
+ if (!dataTransfer) return "";
114
+ const plain = dataTransfer.getData?.("text/plain") || "";
115
+ if (plain) return plain;
116
+ const html = dataTransfer.getData?.("text/html") || "";
117
+ if (!html || typeof document === "undefined") return "";
118
+ const probe = document.createElement("div");
119
+ probe.innerHTML = html;
120
+ return editableText(probe);
121
+ }
122
+
123
+ /**
124
+ * Convert a browser `beforeinput` event into an explicit replacement.
125
+ *
126
+ * A null return means the browser supplied no safe range or the operation is outside the supported text
127
+ * edits. The caller still emits the intent, without an edit, so a protected host can refuse it loudly.
128
+ */
129
+ export function transactionFromBeforeInput(root, event, compositionStart = null) {
130
+ const before = editableText(root);
131
+ const selection = selectionOffsets(root);
132
+ if (!selection) return { before, intent: intentForInputType(event?.inputType), edit: null };
133
+ const inputType = event?.inputType || "insertText";
134
+ const intent = intentForInputType(inputType);
135
+ let { from, to } = selection;
136
+ let insert = event?.data ?? "";
137
+
138
+ if (inputType === "insertFromPaste" || inputType === "insertFromDrop") {
139
+ insert = textFromTransfer(event?.dataTransfer);
140
+ } else if (inputType === "insertLineBreak" || inputType === "insertParagraph") {
141
+ insert = "\n";
142
+ } else if (inputType === "deleteContentBackward" && from === to) {
143
+ ({ from, to } = expandSurrogateBoundary(before, Math.max(0, from - 1), from));
144
+ } else if (inputType === "deleteContentForward" && from === to) {
145
+ ({ from, to } = expandSurrogateBoundary(before, from, Math.min(before.length, to + 1)));
146
+ } else if (inputType.startsWith("delete") && from === to) {
147
+ return { before, intent, edit: null };
148
+ }
149
+
150
+ const base = compositionStart && intent === "composition" ? compositionStart : { before, from, to };
151
+ return {
152
+ before: base.before,
153
+ intent,
154
+ edit: { before: base.before, from: base.from, to: base.to, insert },
155
+ };
156
+ }
157
+
158
+ /** Give hosts a stable vocabulary while preserving the browser's native inputType for diagnostics. */
159
+ export function intentForInputType(inputType) {
160
+ if (!inputType) return "input";
161
+ if (inputType.includes("Composition")) return "composition";
162
+ if (inputType.includes("Paste")) return "paste";
163
+ if (inputType.includes("Drop")) return "drop";
164
+ if (inputType.includes("Cut")) return "cut";
165
+ if (inputType === "historyUndo") return "undo";
166
+ if (inputType === "historyRedo") return "redo";
167
+ return "input";
168
+ }
169
+
170
+ /** Make a text replacement for component-owned operations such as a toolbar accent. */
171
+ export function explicitReplacement(before, from, to, insert, intent = "programmatic") {
172
+ return { before, from, to, insert, intent };
173
+ }
@@ -52,6 +52,8 @@ export function getMark() {
52
52
  }
53
53
 
54
54
  export function isKatexNode(node) {
55
+ // Callers probe siblings and parents that may not exist (the first node has no previousSibling).
56
+ if (node === null || node === undefined) return false;
55
57
  return node.className === "katex" || node.className === "katex-error";
56
58
  }
57
59
 
@@ -969,9 +971,11 @@ export function getRangeParams(editableDiv) {
969
971
 
970
972
  const selection = window.getSelection();
971
973
  if (selection.rangeCount === 0) {
972
- console.error(
973
- "Can't get range params, window.getSelection did not return any ranges"
974
- );
974
+ // No selection yet is ordinary, not an error. A mousedown reaches this before the browser has
975
+ // placed a caret, and a toolbar button takes the selection out of the field; every click used to
976
+ // print an error to the console for a state the callers handle. Return null, and say so only when
977
+ // debug logging is on.
978
+ debugMathRichInput("getRangeParams:no-selection");
975
979
  return null;
976
980
  }
977
981
 
package/src/standalone.js CHANGED
@@ -467,11 +467,14 @@ class MathRichInputElement extends HTMLElement {
467
467
  this.setAttribute("mimetype", newMimeType);
468
468
  }
469
469
 
470
- this.dispatchEvent(
470
+ this.dispatchEvent(
471
471
  new CustomEvent("change", {
472
472
  detail: {
473
473
  value: newValue,
474
474
  mimeType: newMimeType,
475
+ ...(data.edit ? { edit: data.edit } : {}),
476
+ ...(data.intent ? { intent: data.intent } : {}),
477
+ ...(data.history ? { history: data.history } : {}),
475
478
  },
476
479
  })
477
480
  );