@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/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) =>
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
228
|
-
|
|
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) =>
|
|
238
|
-
onMouseDown={(e) =>
|
|
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
|
-
|
|
973
|
-
|
|
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
|
-
|
|
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
|
);
|