@pie-lib/math-input-mathlive 1.0.9-next.6637 → 1.0.10
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/README.md +14 -5
- package/lib/__tests__/composites.test.js +255 -0
- package/lib/__tests__/composites.test.js.map +1 -0
- package/lib/__tests__/drop-in-compat.test.js +133 -0
- package/lib/__tests__/drop-in-compat.test.js.map +1 -0
- package/lib/__tests__/index.test.js +118 -0
- package/lib/__tests__/index.test.js.map +1 -0
- package/lib/__tests__/input-imperative.test.js +155 -0
- package/lib/__tests__/input-imperative.test.js.map +1 -0
- package/lib/__tests__/keypad-label.test.js +118 -0
- package/lib/__tests__/keypad-label.test.js.map +1 -0
- package/lib/__tests__/keypad-render.test.js +206 -0
- package/lib/__tests__/keypad-render.test.js.map +1 -0
- package/lib/__tests__/latex-bridge.test.js +280 -0
- package/lib/__tests__/latex-bridge.test.js.map +1 -0
- package/lib/__tests__/mathlive-instance.test.js +279 -0
- package/lib/__tests__/mathlive-instance.test.js.map +1 -0
- package/lib/__tests__/mount-race.test.js +195 -0
- package/lib/__tests__/mount-race.test.js.map +1 -0
- package/lib/__tests__/static-prompts.test.js +282 -0
- package/lib/__tests__/static-prompts.test.js.map +1 -0
- package/lib/keypad/index.js +11 -13
- package/lib/keypad/index.js.map +1 -1
- package/lib/keys/geometry.js +7 -4
- package/lib/keys/geometry.js.map +1 -1
- package/lib/latex-bridge.js +18 -3
- package/lib/latex-bridge.js.map +1 -1
- package/lib/mathlive-instance.js +103 -11
- package/lib/mathlive-instance.js.map +1 -1
- package/lib/mf/common-styles.js +33 -4
- package/lib/mf/common-styles.js.map +1 -1
- package/lib/mf/input.js +22 -5
- package/lib/mf/input.js.map +1 -1
- package/lib/mf/static.js +21 -4
- package/lib/mf/static.js.map +1 -1
- package/package.json +3 -4
- package/src/__tests__/composites.test.jsx +220 -0
- package/src/__tests__/keypad-label.test.jsx +81 -14
- package/src/__tests__/keypad-render.test.jsx +123 -0
- package/src/__tests__/latex-bridge.test.js +21 -0
- package/src/__tests__/mathlive-instance.test.js +87 -0
- package/src/__tests__/mount-race.test.jsx +130 -0
- package/src/__tests__/static-prompts.test.jsx +251 -0
- package/src/keypad/index.jsx +11 -13
- package/src/keys/geometry.js +7 -2
- package/src/latex-bridge.js +18 -3
- package/src/mathlive-instance.js +111 -11
- package/src/mf/common-styles.js +33 -4
- package/src/mf/input.jsx +23 -5
- package/src/mf/static.jsx +24 -4
- package/LICENSE.md +0 -5
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MathLive loads asynchronously, so `componentDidMount` resumes after an await
|
|
3
|
+
* before appending a <math-field>. Two orderings must both hold:
|
|
4
|
+
*
|
|
5
|
+
* - StrictMode dev double-mount (mount -> unmount -> mount on the SAME
|
|
6
|
+
* instance) must end up with exactly ONE field. A boolean "unmounted" flag
|
|
7
|
+
* latches here and blocks the remount entirely - which is why this uses a
|
|
8
|
+
* generation counter.
|
|
9
|
+
* - A genuine unmount while the load is in flight must append nothing.
|
|
10
|
+
*
|
|
11
|
+
* MathLive cannot load under jsdom, so it is mocked with a minimal stub in
|
|
12
|
+
* order to actually count appended fields.
|
|
13
|
+
*/
|
|
14
|
+
jest.mock('../mathlive-instance', () => {
|
|
15
|
+
// Must be a real DOM node: the component uses replaceChildren(), which
|
|
16
|
+
// rejects anything that is not a Node. Returning an element from the
|
|
17
|
+
// constructor replaces `this`, so callers get a genuine <math-field>.
|
|
18
|
+
class FakeMathfieldElement {
|
|
19
|
+
constructor() {
|
|
20
|
+
// `global` rather than `document`: jest forbids out-of-scope variables in
|
|
21
|
+
// a mock factory, and `global` is on its allow-list.
|
|
22
|
+
const el = global.document.createElement('math-field');
|
|
23
|
+
|
|
24
|
+
el.value = '';
|
|
25
|
+
el.getValue = () => el.value;
|
|
26
|
+
|
|
27
|
+
return el;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
loadMathLive: () => Promise.resolve({ MathfieldElement: FakeMathfieldElement }),
|
|
33
|
+
getMacros: () => ({}),
|
|
34
|
+
// must mirror the real export, otherwise the component sets `undefined`
|
|
35
|
+
MATH_MODE_SPACE: '\\ ',
|
|
36
|
+
applyShadowStyles: () => {},
|
|
37
|
+
latexToMarkup: (l) => `<span>${l}</span>`,
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// eslint-disable-next-line import/first
|
|
42
|
+
import Input from '../mf/input';
|
|
43
|
+
|
|
44
|
+
const holderFor = (cmp) => {
|
|
45
|
+
const el = document.createElement('span');
|
|
46
|
+
|
|
47
|
+
cmp.holderRef = { current: el };
|
|
48
|
+
|
|
49
|
+
return el;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const fieldCount = (el) => Array.from(el.childNodes).filter((n) => n.tagName === 'MATH-FIELD').length;
|
|
53
|
+
|
|
54
|
+
describe('async mount race', () => {
|
|
55
|
+
it('StrictMode mount -> unmount -> mount yields exactly one field', async () => {
|
|
56
|
+
const input = new Input({});
|
|
57
|
+
const el = holderFor(input);
|
|
58
|
+
|
|
59
|
+
const first = input.componentDidMount();
|
|
60
|
+
|
|
61
|
+
input.componentWillUnmount();
|
|
62
|
+
|
|
63
|
+
const second = input.componentDidMount();
|
|
64
|
+
|
|
65
|
+
await Promise.all([first, second]);
|
|
66
|
+
|
|
67
|
+
expect(fieldCount(el)).toBe(1);
|
|
68
|
+
expect(input.mathField).toBeDefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('appends nothing when unmounted while the load is in flight', async () => {
|
|
72
|
+
const input = new Input({});
|
|
73
|
+
const el = holderFor(input);
|
|
74
|
+
|
|
75
|
+
const pending = input.componentDidMount();
|
|
76
|
+
|
|
77
|
+
input.componentWillUnmount();
|
|
78
|
+
|
|
79
|
+
await pending;
|
|
80
|
+
|
|
81
|
+
expect(fieldCount(el)).toBe(0);
|
|
82
|
+
expect(input.mathField).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('a normal single mount creates the field', async () => {
|
|
86
|
+
const input = new Input({});
|
|
87
|
+
const el = holderFor(input);
|
|
88
|
+
|
|
89
|
+
await input.componentDidMount();
|
|
90
|
+
|
|
91
|
+
expect(fieldCount(el)).toBe(1);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('repeated mounts never accumulate fields', async () => {
|
|
95
|
+
const input = new Input({});
|
|
96
|
+
const el = holderFor(input);
|
|
97
|
+
|
|
98
|
+
await input.componentDidMount();
|
|
99
|
+
await input.componentDidMount();
|
|
100
|
+
await input.componentDidMount();
|
|
101
|
+
|
|
102
|
+
expect(fieldCount(el)).toBe(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// MathLive's mathModeSpace defaults to '', which makes the spacebar a no-op.
|
|
106
|
+
it('configures the spacebar so a space can be typed', async () => {
|
|
107
|
+
const input = new Input({});
|
|
108
|
+
|
|
109
|
+
holderFor(input);
|
|
110
|
+
await input.componentDidMount();
|
|
111
|
+
|
|
112
|
+
// a LaTeX control space - what MathQuill produced, and MathJax understands
|
|
113
|
+
expect(input.mathField.mathModeSpace).toEqual('\\ ');
|
|
114
|
+
expect(input.mathField.mathModeSpace).not.toEqual('');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('reports itself through innerRef on mount and clears it on unmount', async () => {
|
|
118
|
+
const innerRef = jest.fn();
|
|
119
|
+
const input = new Input({ innerRef });
|
|
120
|
+
|
|
121
|
+
holderFor(input);
|
|
122
|
+
await input.componentDidMount();
|
|
123
|
+
|
|
124
|
+
expect(innerRef).toHaveBeenCalledWith(input);
|
|
125
|
+
|
|
126
|
+
input.componentWillUnmount();
|
|
127
|
+
|
|
128
|
+
expect(innerRef).toHaveBeenLastCalledWith(null);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import Static from '../mf/static';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The answer-block contract: `\MathQuillMathField[id]{}` in stored latex becomes
|
|
5
|
+
* a MathLive prompt, and sub-field values/focus are surfaced through the same
|
|
6
|
+
* callbacks @pie-lib/math-input exposed (`onSubFieldChange`, `onSubFieldFocus`,
|
|
7
|
+
* `getFieldName`, `setInput`).
|
|
8
|
+
*
|
|
9
|
+
* Driven against a stub mathfield: a real <math-field> needs a browser, and what
|
|
10
|
+
* matters here is the mapping, not MathLive's rendering.
|
|
11
|
+
*/
|
|
12
|
+
const stubField = (values = {}, selection = null) => ({
|
|
13
|
+
values: { ...values },
|
|
14
|
+
getPrompts() {
|
|
15
|
+
return Object.keys(this.values);
|
|
16
|
+
},
|
|
17
|
+
getPromptValue(id) {
|
|
18
|
+
return this.values[id];
|
|
19
|
+
},
|
|
20
|
+
setPromptValue(id, v) {
|
|
21
|
+
this.values[id] = v;
|
|
22
|
+
},
|
|
23
|
+
getPromptRange(id) {
|
|
24
|
+
const i = Object.keys(this.values).indexOf(id);
|
|
25
|
+
|
|
26
|
+
return [i * 10, i * 10 + 5];
|
|
27
|
+
},
|
|
28
|
+
selection,
|
|
29
|
+
addEventListener() {},
|
|
30
|
+
removeEventListener() {},
|
|
31
|
+
focus() {
|
|
32
|
+
this.focused = true;
|
|
33
|
+
},
|
|
34
|
+
blur() {
|
|
35
|
+
this.blurred = true;
|
|
36
|
+
},
|
|
37
|
+
remove() {},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const make = (props = {}) => new Static({ latex: 'x=\\MathQuillMathField[r1]{}', getFieldName: () => {}, ...props });
|
|
41
|
+
|
|
42
|
+
describe('Static: answer blocks / prompts', () => {
|
|
43
|
+
describe('isInteractive', () => {
|
|
44
|
+
it('is interactive when the latex has answer blocks', () => {
|
|
45
|
+
expect(make({ latex: 'x=\\MathQuillMathField[r1]{}' }).isInteractive()).toBe(true);
|
|
46
|
+
expect(make({ latex: 'x=\\placeholder[r1]{}' }).isInteractive()).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('is display-only for plain latex', () => {
|
|
50
|
+
expect(make({ latex: '\\frac{1}{2}' }).isInteractive()).toBe(false);
|
|
51
|
+
expect(make({ latex: '' }).isInteractive()).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('values', () => {
|
|
56
|
+
it('returns {} before the field exists', () => {
|
|
57
|
+
expect(make().values()).toEqual({});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('maps each prompt id to its stored-form latex', () => {
|
|
61
|
+
const s = make();
|
|
62
|
+
|
|
63
|
+
s.mathField = stubField({ r1: '7', r2: '\\placeholder{}' });
|
|
64
|
+
|
|
65
|
+
// \placeholder must not leak into stored values
|
|
66
|
+
expect(s.values()).toEqual({ r1: '7', r2: '' });
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('promptHandle / innerFields', () => {
|
|
71
|
+
it('exposes a MathQuill-field-like handle with get/set latex', () => {
|
|
72
|
+
const s = make();
|
|
73
|
+
|
|
74
|
+
s.mathField = stubField({ r1: '7' });
|
|
75
|
+
|
|
76
|
+
const h = s.promptHandle('r1');
|
|
77
|
+
|
|
78
|
+
expect(h.id).toEqual('r1');
|
|
79
|
+
expect(h.latex()).toEqual('7');
|
|
80
|
+
|
|
81
|
+
h.latex('9');
|
|
82
|
+
expect(s.mathField.values.r1).toEqual('9');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('handle latex() is safe with no field', () => {
|
|
86
|
+
expect(make().promptHandle('r1').latex()).toEqual('');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('innerFields returns one handle per prompt', () => {
|
|
90
|
+
const s = make();
|
|
91
|
+
|
|
92
|
+
s.mathField = stubField({ r1: '1', r2: '2' });
|
|
93
|
+
|
|
94
|
+
expect(s.innerFields.map((f) => f.id)).toEqual(['r1', 'r2']);
|
|
95
|
+
expect(make().innerFields).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('onSubFieldChange', () => {
|
|
100
|
+
it('fires only for prompts whose value changed', () => {
|
|
101
|
+
const onSubFieldChange = jest.fn();
|
|
102
|
+
const s = make({ onSubFieldChange });
|
|
103
|
+
|
|
104
|
+
s.mathField = stubField({ r1: '1', r2: '2' });
|
|
105
|
+
s.promptValues = { r1: '1', r2: '2' };
|
|
106
|
+
|
|
107
|
+
s.mathField.values.r2 = '5';
|
|
108
|
+
s.onPromptInput();
|
|
109
|
+
|
|
110
|
+
expect(onSubFieldChange).toHaveBeenCalledTimes(1);
|
|
111
|
+
expect(onSubFieldChange).toHaveBeenCalledWith('r2', '5');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('uses getFieldName when it returns a name', () => {
|
|
115
|
+
const onSubFieldChange = jest.fn();
|
|
116
|
+
const s = make({ onSubFieldChange, getFieldName: (handle) => 'named-' + handle.id });
|
|
117
|
+
|
|
118
|
+
s.mathField = stubField({ r1: '1' });
|
|
119
|
+
s.promptValues = {};
|
|
120
|
+
|
|
121
|
+
s.onPromptInput();
|
|
122
|
+
|
|
123
|
+
expect(onSubFieldChange).toHaveBeenCalledWith('named-r1', '1');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('still tracks values when no callback is supplied', () => {
|
|
127
|
+
const s = make();
|
|
128
|
+
|
|
129
|
+
s.mathField = stubField({ r1: '3' });
|
|
130
|
+
s.onPromptInput();
|
|
131
|
+
|
|
132
|
+
expect(s.promptValues).toEqual({ r1: '3' });
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('onSubFieldFocus', () => {
|
|
137
|
+
it('reports the prompt containing the selection, plus setInput', () => {
|
|
138
|
+
const onSubFieldFocus = jest.fn();
|
|
139
|
+
const setInput = jest.fn();
|
|
140
|
+
const s = make({ onSubFieldFocus, setInput });
|
|
141
|
+
|
|
142
|
+
// r2 occupies offsets 10..15
|
|
143
|
+
s.mathField = stubField({ r1: '', r2: '' }, { ranges: [[11, 11]] });
|
|
144
|
+
s.onPromptFocus();
|
|
145
|
+
|
|
146
|
+
expect(onSubFieldFocus).toHaveBeenCalledTimes(1);
|
|
147
|
+
expect(onSubFieldFocus.mock.calls[0][0]).toEqual('r2');
|
|
148
|
+
expect(onSubFieldFocus.mock.calls[0][1].id).toEqual('r2');
|
|
149
|
+
expect(setInput).toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('does nothing without a selection', () => {
|
|
153
|
+
const onSubFieldFocus = jest.fn();
|
|
154
|
+
const s = make({ onSubFieldFocus });
|
|
155
|
+
|
|
156
|
+
s.mathField = stubField({ r1: '' }, null);
|
|
157
|
+
s.onPromptFocus();
|
|
158
|
+
|
|
159
|
+
expect(onSubFieldFocus).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('does not throw when the field is missing', () => {
|
|
163
|
+
const s = make({ onSubFieldFocus: jest.fn() });
|
|
164
|
+
|
|
165
|
+
expect(() => s.onPromptFocus()).not.toThrow();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('accessibility announcements', () => {
|
|
170
|
+
afterEach(() => {
|
|
171
|
+
document.body.innerHTML = '';
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('creates a polite off-screen live region', () => {
|
|
175
|
+
const s = make();
|
|
176
|
+
|
|
177
|
+
s.createLiveRegion();
|
|
178
|
+
|
|
179
|
+
expect(s.liveRegion.getAttribute('aria-live')).toEqual('polite');
|
|
180
|
+
expect(s.liveRegion.getAttribute('aria-atomic')).toEqual('true');
|
|
181
|
+
expect(document.body.contains(s.liveRegion)).toBe(true);
|
|
182
|
+
|
|
183
|
+
s.removeLiveRegion();
|
|
184
|
+
expect(s.liveRegion).toBeUndefined();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('does not create a second region', () => {
|
|
188
|
+
const s = make();
|
|
189
|
+
|
|
190
|
+
s.createLiveRegion();
|
|
191
|
+
const first = s.liveRegion;
|
|
192
|
+
|
|
193
|
+
s.createLiveRegion();
|
|
194
|
+
expect(s.liveRegion).toBe(first);
|
|
195
|
+
s.removeLiveRegion();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('announces when typed input expands into a command', () => {
|
|
199
|
+
const s = make();
|
|
200
|
+
|
|
201
|
+
s.createLiveRegion();
|
|
202
|
+
s.promptValues = { r1: 'x' };
|
|
203
|
+
// grew by more than one char AND gained a backslash => a conversion
|
|
204
|
+
s.announceConversion({ r1: 'x\\frac{}{}' });
|
|
205
|
+
|
|
206
|
+
expect(s.liveRegion.textContent).toEqual('Converted to math symbol');
|
|
207
|
+
s.removeLiveRegion();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('does not announce ordinary typing', () => {
|
|
211
|
+
const s = make();
|
|
212
|
+
|
|
213
|
+
s.createLiveRegion();
|
|
214
|
+
s.promptValues = { r1: 'x' };
|
|
215
|
+
s.announceConversion({ r1: 'xy' });
|
|
216
|
+
|
|
217
|
+
expect(s.liveRegion.textContent).toEqual('');
|
|
218
|
+
s.removeLiveRegion();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('does not announce a deletion', () => {
|
|
222
|
+
const s = make();
|
|
223
|
+
|
|
224
|
+
s.createLiveRegion();
|
|
225
|
+
s.promptValues = { r1: 'x\\frac{}{}' };
|
|
226
|
+
s.onKeyDown({ key: 'Backspace' });
|
|
227
|
+
s.announceConversion({ r1: 'x' });
|
|
228
|
+
|
|
229
|
+
expect(s.liveRegion.textContent).toEqual('');
|
|
230
|
+
s.removeLiveRegion();
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe('focus / blur', () => {
|
|
235
|
+
it('delegates to the field and is safe without one', () => {
|
|
236
|
+
const s = make();
|
|
237
|
+
|
|
238
|
+
expect(() => {
|
|
239
|
+
s.focus();
|
|
240
|
+
s.blur();
|
|
241
|
+
}).not.toThrow();
|
|
242
|
+
|
|
243
|
+
s.mathField = stubField();
|
|
244
|
+
s.focus();
|
|
245
|
+
s.blur();
|
|
246
|
+
|
|
247
|
+
expect(s.mathField.focused).toBe(true);
|
|
248
|
+
expect(s.mathField.blurred).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
});
|
package/src/keypad/index.jsx
CHANGED
|
@@ -29,22 +29,20 @@ const LabelHolder = styled('span')(() => ({
|
|
|
29
29
|
pointerEvents: 'none',
|
|
30
30
|
textTransform: 'none',
|
|
31
31
|
color: color.text(),
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
// Deliberately inline-block, NOT inline-flex: MathLive's markup relies on
|
|
33
|
+
// normal inline flow. Making its spans flex items collapses their width, and
|
|
34
|
+
// stretchy accents render an <svg width="100%" preserveAspectRatio="none">
|
|
35
|
+
// that then squeezes into a spike instead of spanning the content.
|
|
36
|
+
display: 'inline-block',
|
|
37
|
+
textAlign: 'center',
|
|
35
38
|
lineHeight: 1,
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
// the
|
|
41
|
-
// label's min-content contribution, so the grid can't blow out either way.
|
|
39
|
+
// Keep a label from sizing its grid column. `overflow: hidden` also collapses
|
|
40
|
+
// the label's min-content contribution, so a wide accent cannot drag the
|
|
41
|
+
// column across the screen. The svg's own positioning is left to
|
|
42
|
+
// mathlive-static.css (injected by the package) - duplicating it here broke
|
|
43
|
+
// the accent's containing block.
|
|
42
44
|
maxWidth: '100%',
|
|
43
45
|
overflow: 'hidden',
|
|
44
|
-
'& svg': {
|
|
45
|
-
position: 'absolute',
|
|
46
|
-
width: '100%',
|
|
47
|
-
},
|
|
48
46
|
// Answer-block / empty-slot boxes inside a label.
|
|
49
47
|
'& .ML__placeholder': {
|
|
50
48
|
backgroundColor: color.keypadEmptyPlaceholder ? color.keypadEmptyPlaceholder() : undefined,
|
package/src/keys/geometry.js
CHANGED
|
@@ -14,7 +14,10 @@ export const overRightArrow = set({
|
|
|
14
14
|
|
|
15
15
|
export const overLeftRightArrow = set({
|
|
16
16
|
name: 'Segment',
|
|
17
|
-
|
|
17
|
+
// MathQuill needed a nested \overline{} to draw the empty box; MathLive
|
|
18
|
+
// renders that as a real line, so the label showed a spurious bar under the
|
|
19
|
+
// arrows. `withVisibleEmptySlots` supplies the box for `{}` instead.
|
|
20
|
+
latex: '\\overleftrightarrow{}',
|
|
18
21
|
symbol: 'AB',
|
|
19
22
|
command: '\\overleftrightarrow',
|
|
20
23
|
});
|
|
@@ -51,7 +54,9 @@ export const angle = set({
|
|
|
51
54
|
});
|
|
52
55
|
export const overArc = set({
|
|
53
56
|
name: 'Over arc',
|
|
54
|
-
|
|
57
|
+
// See the note on Segment above: the nested \overline{} drew a straight line
|
|
58
|
+
// instead of leaving the arc visible.
|
|
59
|
+
latex: '\\overarc{}',
|
|
55
60
|
command: '\\overarc',
|
|
56
61
|
});
|
|
57
62
|
export const measureOfAngle = set({
|
package/src/latex-bridge.js
CHANGED
|
@@ -55,11 +55,19 @@ export const DEFAULT_FIELD_ID = 'r1';
|
|
|
55
55
|
*
|
|
56
56
|
* `\placeholder{}` is not usable here: in static markup it renders as a
|
|
57
57
|
* non-breaking space, because its editable box only exists inside a live
|
|
58
|
-
* mathfield. So
|
|
58
|
+
* mathfield. So something with real width is required.
|
|
59
|
+
*
|
|
60
|
+
* It must be a `\rule`, not a unicode glyph. A glyph's width comes from font
|
|
61
|
+
* metrics, and U+25AB is not in the KaTeX fonts - the browser substitutes a
|
|
62
|
+
* wider fallback while MathLive still sizes surrounding accents from its own
|
|
63
|
+
* metrics. That left stretchy accents (`\overarc`, `\overleftrightarrow`)
|
|
64
|
+
* visibly narrower than the box they were meant to span. A rule carries an
|
|
65
|
+
* explicit em width, so accents size correctly - and a filled box is closer to
|
|
66
|
+
* MathQuill's `.mq-empty` anyway.
|
|
59
67
|
*
|
|
60
68
|
* Live mathfields need none of this - MathLive manages empty slots itself.
|
|
61
69
|
*/
|
|
62
|
-
const EMPTY_SLOT = '\\htmlData{pie-empty=1}{\\
|
|
70
|
+
const EMPTY_SLOT = '\\htmlData{pie-empty=1}{\\rule{0.55em}{0.55em}}';
|
|
63
71
|
const EMPTY_GROUP_REGEX = /\{\s*\}/g;
|
|
64
72
|
|
|
65
73
|
/**
|
|
@@ -240,8 +248,15 @@ export const fromMathLive = (latex) => {
|
|
|
240
248
|
* placeholders in the inserted latex.
|
|
241
249
|
*/
|
|
242
250
|
const COMMANDS_WITH_ARGS = {
|
|
251
|
+
// An explicit "blank over blank" key: both slots start empty.
|
|
243
252
|
'\\frac': '\\frac{#?}{#?}',
|
|
244
|
-
'
|
|
253
|
+
// Division, which must behave like MathQuill's `/`: whatever precedes the
|
|
254
|
+
// cursor becomes the numerator and the caret lands in the denominator. `#@`
|
|
255
|
+
// is MathLive's token for "the selection, or the item before the cursor" -
|
|
256
|
+
// this is exactly the template MathLive binds to the `/` key itself. Using
|
|
257
|
+
// `#?` here instead produced an empty fraction and left the typed number
|
|
258
|
+
// stranded outside it.
|
|
259
|
+
'/': '\\frac{#@}{#?}',
|
|
245
260
|
'\\sqrt': '\\sqrt{#?}',
|
|
246
261
|
'\\nthroot': '\\sqrt[#?]{#?}',
|
|
247
262
|
'\\overline': '\\overline{#?}',
|
package/src/mathlive-instance.js
CHANGED
|
@@ -46,26 +46,63 @@ const absoluteFontsDirectory = (dir) => {
|
|
|
46
46
|
}
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Version-pinned CDN fallback for the KaTeX fonts.
|
|
51
|
+
*
|
|
52
|
+
* Some symbols are composed from Private Use Area glyphs that exist only in
|
|
53
|
+
* those fonts: `\\neq` is U+E020 (a negation slash) overlapped on `=`, and
|
|
54
|
+
* `\\nsim` / `\\ncong` work the same way. With no fonts the browser substitutes
|
|
55
|
+
* an arbitrary glyph for U+E020, so those keys render as garbage rather than
|
|
56
|
+
* simply falling back to a plainer face.
|
|
57
|
+
*
|
|
58
|
+
* The version comes from MathLive itself, so the fonts always match the engine.
|
|
59
|
+
* Hosts that would rather not depend on a third party should serve the fonts and
|
|
60
|
+
* call `configureFonts('/their/path')`, or pass `null` to opt out.
|
|
61
|
+
*/
|
|
62
|
+
const FALLBACK_MATHLIVE_VERSION = '0.110.0';
|
|
63
|
+
|
|
64
|
+
const cdnFontsDirectory = () => {
|
|
65
|
+
const version = (mathlive && mathlive.version && mathlive.version.mathlive) || FALLBACK_MATHLIVE_VERSION;
|
|
66
|
+
|
|
67
|
+
// NOTE: `/fonts`, not `/dist/fonts` - the published package root holds them.
|
|
68
|
+
return `https://unpkg.com/mathlive@${version}/fonts`;
|
|
69
|
+
};
|
|
70
|
+
|
|
49
71
|
/**
|
|
50
72
|
* Tell the package where the MathLive fonts are served from.
|
|
51
73
|
*
|
|
52
|
-
* Call before `loadMathLive()`.
|
|
53
|
-
*
|
|
54
|
-
*
|
|
74
|
+
* Call before `loadMathLive()`.
|
|
75
|
+
*
|
|
76
|
+
* - a path/URL: served by you, no third-party request
|
|
77
|
+
* - `null`: skip font loading; note `\\neq`, `\\nsim` and `\\ncong` then
|
|
78
|
+
* render incorrectly, being built from Private Use Area glyphs
|
|
79
|
+
* - not called: falls back to a version-pinned CDN
|
|
55
80
|
*
|
|
56
81
|
* The fonts ship inside the `mathlive` package (`node_modules/mathlive/fonts`)
|
|
57
|
-
*
|
|
82
|
+
* ready to copy into whatever the host app serves statically.
|
|
58
83
|
*
|
|
59
|
-
* @param {string|null} dir
|
|
84
|
+
* @param {string|null} [dir]
|
|
60
85
|
*/
|
|
61
86
|
export const configureFonts = (dir) => {
|
|
62
87
|
fontsDirectory = dir;
|
|
63
88
|
|
|
64
89
|
if (mathlive) {
|
|
65
|
-
mathlive.MathfieldElement.fontsDirectory = absoluteFontsDirectory(dir);
|
|
90
|
+
mathlive.MathfieldElement.fontsDirectory = dir === undefined ? cdnFontsDirectory() : absoluteFontsDirectory(dir);
|
|
66
91
|
}
|
|
67
92
|
};
|
|
68
93
|
|
|
94
|
+
/**
|
|
95
|
+
* What the spacebar inserts in math mode.
|
|
96
|
+
*
|
|
97
|
+
* MathLive's `mathModeSpace` defaults to an empty string, which makes the
|
|
98
|
+
* spacebar a no-op - users cannot type a space at all. `\\ ` (a LaTeX control
|
|
99
|
+
* space) is what MathQuill serialised spaces as, so existing content and the
|
|
100
|
+
* MathJax renderer in @pie-lib/math-rendering both already understand it.
|
|
101
|
+
*
|
|
102
|
+
* A literal " " is not usable: LaTeX skips raw whitespace.
|
|
103
|
+
*/
|
|
104
|
+
export const MATH_MODE_SPACE = '\\ ';
|
|
105
|
+
|
|
69
106
|
/**
|
|
70
107
|
* Custom macros for the pie-specific commands that the MathQuill fork
|
|
71
108
|
* understood but stock LaTeX does not.
|
|
@@ -126,21 +163,84 @@ let loadWarned = false;
|
|
|
126
163
|
/** Apply post-load configuration exactly once. */
|
|
127
164
|
const configureLoaded = () => {
|
|
128
165
|
// Always replace MathLive's relative `'./fonts/'` default: it throws
|
|
129
|
-
// "Invalid base URL" when bundled.
|
|
130
|
-
//
|
|
166
|
+
// "Invalid base URL" when bundled. Use the host's copy if one was configured,
|
|
167
|
+
// otherwise the version-pinned CDN.
|
|
131
168
|
if (fontsDirectory === undefined) {
|
|
132
169
|
log(
|
|
133
|
-
'no fontsDirectory configured -
|
|
134
|
-
'
|
|
170
|
+
'no fontsDirectory configured - falling back to the CDN (%s). ' +
|
|
171
|
+
'Serve the fonts yourself and call configureFonts("/path/to/fonts") to avoid the network request, ' +
|
|
172
|
+
'or configureFonts(null) to opt out.',
|
|
173
|
+
cdnFontsDirectory(),
|
|
135
174
|
);
|
|
136
175
|
}
|
|
137
176
|
|
|
138
177
|
mathlive.MathfieldElement.fontsDirectory =
|
|
139
|
-
fontsDirectory === undefined ?
|
|
178
|
+
fontsDirectory === undefined ? cdnFontsDirectory() : absoluteFontsDirectory(fontsDirectory);
|
|
140
179
|
|
|
141
180
|
injectCoreStylesheet();
|
|
142
181
|
};
|
|
143
182
|
|
|
183
|
+
/**
|
|
184
|
+
* CSS that has to live *inside* a mathfield's shadow root.
|
|
185
|
+
*
|
|
186
|
+
* A live `<math-field>` renders in shadow DOM (`attachShadow({mode:'open'})`),
|
|
187
|
+
* so nothing in the host page's stylesheets reaches it. The stretchy-accent
|
|
188
|
+
* problem therefore needs fixing twice: once for static markup (see
|
|
189
|
+
* `placeholderStyles`) and once here for the editor.
|
|
190
|
+
*
|
|
191
|
+
* MathLive centres an accent with `margin-left: <half the content width>` as an
|
|
192
|
+
* inline style. For a stretchy accent that also shrinks its containing block
|
|
193
|
+
* (`.ML__center` is position:relative), so the arc ends up half-width and offset
|
|
194
|
+
* - visibly failing to span the text underneath.
|
|
195
|
+
*
|
|
196
|
+
* `:has(.ML__stretchy)` keeps the fix to stretchy accents; single-glyph accents
|
|
197
|
+
* (`\hat{x}`, `\vec{x}`) still need their centring offset.
|
|
198
|
+
*/
|
|
199
|
+
const SHADOW_CSS = '.ML__latex .ML__center:has(.ML__stretchy){margin-left:0 !important}';
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Apply {@link SHADOW_CSS} inside a mathfield's shadow root.
|
|
203
|
+
*
|
|
204
|
+
* Uses `adoptedStyleSheets` when available (which is how MathLive loads its own
|
|
205
|
+
* stylesheets) and falls back to appending a <style> node.
|
|
206
|
+
*
|
|
207
|
+
* @param {HTMLElement} mathField
|
|
208
|
+
*/
|
|
209
|
+
export const applyShadowStyles = (mathField) => {
|
|
210
|
+
const root = mathField && mathField.shadowRoot;
|
|
211
|
+
|
|
212
|
+
if (!root) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
if ('adoptedStyleSheets' in root && typeof CSSStyleSheet !== 'undefined') {
|
|
218
|
+
// Do not re-add on a re-render.
|
|
219
|
+
if (root.__pieAccentSheet) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const sheet = new CSSStyleSheet();
|
|
224
|
+
|
|
225
|
+
sheet.replaceSync(SHADOW_CSS);
|
|
226
|
+
root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
|
|
227
|
+
root.__pieAccentSheet = sheet;
|
|
228
|
+
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!root.querySelector('style[data-pie-accent]')) {
|
|
233
|
+
const style = document.createElement('style');
|
|
234
|
+
|
|
235
|
+
style.setAttribute('data-pie-accent', '');
|
|
236
|
+
style.textContent = SHADOW_CSS;
|
|
237
|
+
root.appendChild(style);
|
|
238
|
+
}
|
|
239
|
+
} catch (e) {
|
|
240
|
+
log('could not apply shadow styles to the mathfield: %s', e && e.message);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
144
244
|
/**
|
|
145
245
|
* Ensure MathLive's "core" stylesheet is in the document.
|
|
146
246
|
*
|
package/src/mf/common-styles.js
CHANGED
|
@@ -57,13 +57,42 @@ export const placeholderStyles = {
|
|
|
57
57
|
'& [data-pie-empty]': {
|
|
58
58
|
opacity: 0.5,
|
|
59
59
|
},
|
|
60
|
-
// Stretchy accents
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
60
|
+
// Stretchy accents (\overarc, \overleftrightarrow, \overrightarrow) render an
|
|
61
|
+
// <svg width="100%" preserveAspectRatio="none"> inside a `.ML__stretchy`. That
|
|
62
|
+
// span is absolutely positioned, so its immediate wrapper computes to 0px and
|
|
63
|
+
// the width has to resolve against a *positioned* ancestor. Pin that to
|
|
64
|
+
// `.ML__base`, whose width already equals the accent's content (measured:
|
|
65
|
+
// 9.85px for a 0.56em slot), so the arc spans exactly its content.
|
|
66
|
+
//
|
|
67
|
+
// Do NOT write a bare `& svg` rule instead: MathLive's own rule is
|
|
68
|
+
// descendant-scoped (`.ML__latex .ML__stretchy svg`), and a global one
|
|
69
|
+
// re-parents every svg in the subtree.
|
|
70
|
+
'& .ML__base': {
|
|
71
|
+
position: 'relative',
|
|
72
|
+
},
|
|
73
|
+
'& .ML__stretchy': {
|
|
64
74
|
position: 'absolute',
|
|
75
|
+
left: 0,
|
|
65
76
|
width: '100%',
|
|
66
77
|
},
|
|
78
|
+
// MathLive centres an accent by shifting it right half the content width -
|
|
79
|
+
// `margin-left: 0.28em` for a 0.56em slot - which assumes a glyph with its own
|
|
80
|
+
// side bearings. Over a synthetic empty slot it just pushes the arc right.
|
|
81
|
+
//
|
|
82
|
+
// `!important` is required: MathLive writes this as an INLINE style
|
|
83
|
+
// (`style="top:-3.4em;margin-left:0.28em"`), which a class cannot override.
|
|
84
|
+
//
|
|
85
|
+
// It also fixes the width. `.ML__center` is the stretchy's containing block
|
|
86
|
+
// (MathLive gives `.ML__vlist > span` position:relative), and its width is the
|
|
87
|
+
// base minus that margin - measured 9.85px - 4.93px = 4.93px, exactly half,
|
|
88
|
+
// which is why the arc came out half-width AND offset right.
|
|
89
|
+
//
|
|
90
|
+
// Scoped with `:has(.ML__stretchy)`: only stretchy accents are affected.
|
|
91
|
+
// A single-glyph accent (`\hat{x}`, `\vec{x}`) genuinely needs its centring
|
|
92
|
+
// offset, and must keep it.
|
|
93
|
+
'& .ML__center:has(.ML__stretchy)': {
|
|
94
|
+
marginLeft: '0 !important',
|
|
95
|
+
},
|
|
67
96
|
};
|
|
68
97
|
|
|
69
98
|
/**
|