@zzish/math-rich-input 0.1.55 → 0.1.57
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 +598 -39
- package/dist/standalone.js +1 -1
- package/package.json +4 -2
- package/src/MathRichInput.jsx +279 -43
- package/src/editTransaction.js +173 -0
- package/src/standalone.js +4 -1
|
@@ -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
|
+
}
|
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
|
);
|