@pie-lib/math-input-mathlive 1.1.5-beta.1 → 1.1.5-beta.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/lib/__tests__/composites.test.js +255 -0
- package/lib/__tests__/drop-in-compat.test.js +133 -0
- package/lib/__tests__/embedded-fonts.test.js +121 -0
- package/lib/__tests__/index.test.js +118 -0
- package/lib/__tests__/input-imperative.test.js +155 -0
- package/lib/__tests__/keypad-label.test.js +231 -0
- package/lib/__tests__/keypad-render.test.js +206 -0
- package/lib/__tests__/latex-bridge.test.js +386 -0
- package/lib/__tests__/mathlive-instance.test.js +419 -0
- package/lib/__tests__/mount-race.test.js +218 -0
- package/lib/__tests__/static-prompts.test.js +405 -0
- package/lib/__tests__/tmp-markup.test.js +26 -0
- package/lib/keypad/index.js +36 -12
- package/lib/keypad/index.js.map +1 -1
- package/lib/latex-bridge.js +159 -4
- package/lib/latex-bridge.js.map +1 -1
- package/lib/mathlive-instance.js +21 -28
- package/lib/mathlive-instance.js.map +1 -1
- package/lib/mf/common-styles.js +17 -2
- package/lib/mf/common-styles.js.map +1 -1
- package/lib/mf/index.js +6 -0
- package/lib/mf/index.js.map +1 -1
- package/lib/mf/input.js +5 -0
- package/lib/mf/input.js.map +1 -1
- package/lib/mf/static.js +4 -2
- package/lib/mf/static.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/composites.test.jsx +220 -0
- package/src/__tests__/drop-in-compat.test.jsx +112 -0
- package/src/__tests__/index.test.jsx +109 -0
- package/src/__tests__/input-imperative.test.jsx +144 -0
- package/src/__tests__/keypad-label.test.jsx +284 -0
- package/src/__tests__/keypad-render.test.jsx +123 -0
- package/src/__tests__/latex-bridge.test.js +389 -0
- package/src/__tests__/mathlive-instance.test.js +418 -0
- package/src/__tests__/mount-race.test.jsx +142 -0
- package/src/__tests__/static-prompts.test.jsx +364 -0
- package/src/keypad/index.jsx +18 -3
- package/src/latex-bridge.js +171 -4
- package/src/mathlive-instance.js +27 -29
- package/src/mf/common-styles.js +19 -0
- package/src/mf/index.js +2 -0
- package/src/mf/input.jsx +5 -0
- package/src/mf/static.jsx +4 -1
- package/lib/fonts/embedded.js +0 -120
- package/lib/fonts/index.js +0 -81
- package/lib/fonts/index.js.map +0 -1
- package/scripts/generate-embedded-fonts.js +0 -94
- package/src/fonts/embedded.js +0 -116
- package/src/fonts/index.js +0 -71
|
@@ -0,0 +1,364 @@
|
|
|
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
|
+
// Caret bookkeeping: MathLive exposes the caret offset as `position`, and any
|
|
30
|
+
// executeCommand triggers the render that reveals the caret.
|
|
31
|
+
position: undefined,
|
|
32
|
+
commands: [],
|
|
33
|
+
executeCommand(name) {
|
|
34
|
+
this.commands.push(name);
|
|
35
|
+
},
|
|
36
|
+
addEventListener() {},
|
|
37
|
+
removeEventListener() {},
|
|
38
|
+
focus() {
|
|
39
|
+
this.focused = true;
|
|
40
|
+
},
|
|
41
|
+
blur() {
|
|
42
|
+
this.blurred = true;
|
|
43
|
+
},
|
|
44
|
+
remove() {},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const make = (props = {}) => new Static({ latex: 'x=\\MathQuillMathField[r1]{}', getFieldName: () => {}, ...props });
|
|
48
|
+
|
|
49
|
+
describe('Static: answer blocks / prompts', () => {
|
|
50
|
+
describe('isInteractive', () => {
|
|
51
|
+
it('is interactive when the latex has answer blocks', () => {
|
|
52
|
+
expect(make({ latex: 'x=\\MathQuillMathField[r1]{}' }).isInteractive()).toBe(true);
|
|
53
|
+
expect(make({ latex: 'x=\\placeholder[r1]{}' }).isInteractive()).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('is display-only for plain latex', () => {
|
|
57
|
+
expect(make({ latex: '\\frac{1}{2}' }).isInteractive()).toBe(false);
|
|
58
|
+
expect(make({ latex: '' }).isInteractive()).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('values', () => {
|
|
63
|
+
it('returns {} before the field exists', () => {
|
|
64
|
+
expect(make().values()).toEqual({});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('maps each prompt id to its stored-form latex', () => {
|
|
68
|
+
const s = make();
|
|
69
|
+
|
|
70
|
+
s.mathField = stubField({ r1: '7', r2: '\\placeholder{}' });
|
|
71
|
+
|
|
72
|
+
// \placeholder must not leak into stored values
|
|
73
|
+
expect(s.values()).toEqual({ r1: '7', r2: '' });
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('promptHandle / innerFields', () => {
|
|
78
|
+
it('exposes a MathQuill-field-like handle with get/set latex', () => {
|
|
79
|
+
const s = make();
|
|
80
|
+
|
|
81
|
+
s.mathField = stubField({ r1: '7' });
|
|
82
|
+
|
|
83
|
+
const h = s.promptHandle('r1');
|
|
84
|
+
|
|
85
|
+
expect(h.id).toEqual('r1');
|
|
86
|
+
expect(h.latex()).toEqual('7');
|
|
87
|
+
|
|
88
|
+
h.latex('9');
|
|
89
|
+
expect(s.mathField.values.r1).toEqual('9');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('handle latex() is safe with no field', () => {
|
|
93
|
+
expect(make().promptHandle('r1').latex()).toEqual('');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('innerFields returns one handle per prompt', () => {
|
|
97
|
+
const s = make();
|
|
98
|
+
|
|
99
|
+
s.mathField = stubField({ r1: '1', r2: '2' });
|
|
100
|
+
|
|
101
|
+
expect(s.innerFields.map((f) => f.id)).toEqual(['r1', 'r2']);
|
|
102
|
+
expect(make().innerFields).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('onSubFieldChange', () => {
|
|
107
|
+
it('fires only for prompts whose value changed', () => {
|
|
108
|
+
const onSubFieldChange = jest.fn();
|
|
109
|
+
const s = make({ onSubFieldChange });
|
|
110
|
+
|
|
111
|
+
s.mathField = stubField({ r1: '1', r2: '2' });
|
|
112
|
+
s.promptValues = { r1: '1', r2: '2' };
|
|
113
|
+
|
|
114
|
+
s.mathField.values.r2 = '5';
|
|
115
|
+
s.onPromptInput();
|
|
116
|
+
|
|
117
|
+
expect(onSubFieldChange).toHaveBeenCalledTimes(1);
|
|
118
|
+
expect(onSubFieldChange).toHaveBeenCalledWith('r2', '5');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('uses getFieldName when it returns a name', () => {
|
|
122
|
+
const onSubFieldChange = jest.fn();
|
|
123
|
+
const s = make({ onSubFieldChange, getFieldName: (handle) => 'named-' + handle.id });
|
|
124
|
+
|
|
125
|
+
s.mathField = stubField({ r1: '1' });
|
|
126
|
+
s.promptValues = {};
|
|
127
|
+
|
|
128
|
+
s.onPromptInput();
|
|
129
|
+
|
|
130
|
+
expect(onSubFieldChange).toHaveBeenCalledWith('named-r1', '1');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('still tracks values when no callback is supplied', () => {
|
|
134
|
+
const s = make();
|
|
135
|
+
|
|
136
|
+
s.mathField = stubField({ r1: '3' });
|
|
137
|
+
s.onPromptInput();
|
|
138
|
+
|
|
139
|
+
expect(s.promptValues).toEqual({ r1: '3' });
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('onSubFieldFocus', () => {
|
|
144
|
+
it('reports the prompt containing the selection, plus setInput', () => {
|
|
145
|
+
const onSubFieldFocus = jest.fn();
|
|
146
|
+
const setInput = jest.fn();
|
|
147
|
+
const s = make({ onSubFieldFocus, setInput });
|
|
148
|
+
|
|
149
|
+
// r2 occupies offsets 10..15
|
|
150
|
+
s.mathField = stubField({ r1: '', r2: '' }, { ranges: [[11, 11]] });
|
|
151
|
+
s.onPromptFocus();
|
|
152
|
+
|
|
153
|
+
expect(onSubFieldFocus).toHaveBeenCalledTimes(1);
|
|
154
|
+
expect(onSubFieldFocus.mock.calls[0][0]).toEqual('r2');
|
|
155
|
+
expect(onSubFieldFocus.mock.calls[0][1].id).toEqual('r2');
|
|
156
|
+
expect(setInput).toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('does nothing without a selection', () => {
|
|
160
|
+
const onSubFieldFocus = jest.fn();
|
|
161
|
+
const s = make({ onSubFieldFocus });
|
|
162
|
+
|
|
163
|
+
s.mathField = stubField({ r1: '' }, null);
|
|
164
|
+
s.onPromptFocus();
|
|
165
|
+
|
|
166
|
+
expect(onSubFieldFocus).not.toHaveBeenCalled();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('does not throw when the field is missing', () => {
|
|
170
|
+
const s = make({ onSubFieldFocus: jest.fn() });
|
|
171
|
+
|
|
172
|
+
expect(() => s.onPromptFocus()).not.toThrow();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('accessibility announcements', () => {
|
|
177
|
+
afterEach(() => {
|
|
178
|
+
document.body.innerHTML = '';
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('creates a polite off-screen live region', () => {
|
|
182
|
+
const s = make();
|
|
183
|
+
|
|
184
|
+
s.createLiveRegion();
|
|
185
|
+
|
|
186
|
+
expect(s.liveRegion.getAttribute('aria-live')).toEqual('polite');
|
|
187
|
+
expect(s.liveRegion.getAttribute('aria-atomic')).toEqual('true');
|
|
188
|
+
expect(document.body.contains(s.liveRegion)).toBe(true);
|
|
189
|
+
|
|
190
|
+
s.removeLiveRegion();
|
|
191
|
+
expect(s.liveRegion).toBeUndefined();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('does not create a second region', () => {
|
|
195
|
+
const s = make();
|
|
196
|
+
|
|
197
|
+
s.createLiveRegion();
|
|
198
|
+
const first = s.liveRegion;
|
|
199
|
+
|
|
200
|
+
s.createLiveRegion();
|
|
201
|
+
expect(s.liveRegion).toBe(first);
|
|
202
|
+
s.removeLiveRegion();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('announces when typed input expands into a command', () => {
|
|
206
|
+
const s = make();
|
|
207
|
+
|
|
208
|
+
s.createLiveRegion();
|
|
209
|
+
s.promptValues = { r1: 'x' };
|
|
210
|
+
// grew by more than one char AND gained a backslash => a conversion
|
|
211
|
+
s.announceConversion({ r1: 'x\\frac{}{}' });
|
|
212
|
+
|
|
213
|
+
expect(s.liveRegion.textContent).toEqual('Converted to math symbol');
|
|
214
|
+
s.removeLiveRegion();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('does not announce ordinary typing', () => {
|
|
218
|
+
const s = make();
|
|
219
|
+
|
|
220
|
+
s.createLiveRegion();
|
|
221
|
+
s.promptValues = { r1: 'x' };
|
|
222
|
+
s.announceConversion({ r1: 'xy' });
|
|
223
|
+
|
|
224
|
+
expect(s.liveRegion.textContent).toEqual('');
|
|
225
|
+
s.removeLiveRegion();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('does not announce a deletion', () => {
|
|
229
|
+
const s = make();
|
|
230
|
+
|
|
231
|
+
s.createLiveRegion();
|
|
232
|
+
s.promptValues = { r1: 'x\\frac{}{}' };
|
|
233
|
+
s.onKeyDown({ key: 'Backspace' });
|
|
234
|
+
s.announceConversion({ r1: 'x' });
|
|
235
|
+
|
|
236
|
+
expect(s.liveRegion.textContent).toEqual('');
|
|
237
|
+
s.removeLiveRegion();
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('focus / blur', () => {
|
|
242
|
+
it('delegates to the field and is safe without one', () => {
|
|
243
|
+
const s = make();
|
|
244
|
+
|
|
245
|
+
expect(() => {
|
|
246
|
+
s.focus();
|
|
247
|
+
s.blur();
|
|
248
|
+
}).not.toThrow();
|
|
249
|
+
|
|
250
|
+
s.mathField = stubField();
|
|
251
|
+
s.focus();
|
|
252
|
+
s.blur();
|
|
253
|
+
|
|
254
|
+
expect(s.mathField.focused).toBe(true);
|
|
255
|
+
expect(s.mathField.blurred).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* MathLive shows the caret only while the field carries `ML__focused`, which
|
|
261
|
+
* it adds during a render gated on `isSelectionEditable && hasFocus()`. In a
|
|
262
|
+
* read-only field only prompt interiors are editable, so after a bare
|
|
263
|
+
* `focus()` - whose selection sits outside every prompt - the answer block
|
|
264
|
+
* looked focused but had no blinking cursor. Verified in Chromium: the caret
|
|
265
|
+
* span goes from visibility:hidden to visible once the selection is inside a
|
|
266
|
+
* prompt AND a render has run.
|
|
267
|
+
*/
|
|
268
|
+
describe('caret placement', () => {
|
|
269
|
+
it('puts a collapsed caret in the first prompt on focus', () => {
|
|
270
|
+
const s = make();
|
|
271
|
+
|
|
272
|
+
s.mathField = stubField({ r1: '', r2: '' });
|
|
273
|
+
s.focus();
|
|
274
|
+
|
|
275
|
+
// stub ranges are [0,5] for r1, [10,15] for r2
|
|
276
|
+
expect(s.mathField.selection).toEqual({ ranges: [[0, 0]] });
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('forces the render that makes the caret visible', () => {
|
|
280
|
+
const s = make();
|
|
281
|
+
|
|
282
|
+
s.mathField = stubField({ r1: '' });
|
|
283
|
+
s.focus();
|
|
284
|
+
|
|
285
|
+
// assigning `selection` alone updates the model without re-rendering, so
|
|
286
|
+
// ML__focused would never be added
|
|
287
|
+
expect(s.mathField.commands).toEqual(['scrollIntoView']);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('does not move a caret that is already inside a prompt', () => {
|
|
291
|
+
const s = make();
|
|
292
|
+
|
|
293
|
+
s.mathField = stubField({ r1: '', r2: '' });
|
|
294
|
+
s.mathField.position = 12; // inside r2
|
|
295
|
+
s.focus();
|
|
296
|
+
|
|
297
|
+
expect(s.mathField.selection).toBe(null);
|
|
298
|
+
expect(s.mathField.commands).toEqual([]);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('targets a specific prompt', () => {
|
|
302
|
+
const s = make();
|
|
303
|
+
|
|
304
|
+
s.mathField = stubField({ r1: '', r2: '' });
|
|
305
|
+
s.focusPrompt('r2');
|
|
306
|
+
|
|
307
|
+
expect(s.mathField.selection).toEqual({ ranges: [[10, 10]] });
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('falls back to the first prompt for an unknown id', () => {
|
|
311
|
+
const s = make();
|
|
312
|
+
|
|
313
|
+
s.mathField = stubField({ r1: '', r2: '' });
|
|
314
|
+
s.focusPrompt('nope');
|
|
315
|
+
|
|
316
|
+
expect(s.mathField.selection).toEqual({ ranges: [[0, 0]] });
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('is a no-op when there are no prompts', () => {
|
|
320
|
+
const s = make();
|
|
321
|
+
|
|
322
|
+
s.mathField = stubField({});
|
|
323
|
+
|
|
324
|
+
expect(() => s.focusPrompt()).not.toThrow();
|
|
325
|
+
expect(s.mathField.selection).toBe(null);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('is safe without a field', () => {
|
|
329
|
+
expect(() => make().focusPrompt('r1')).not.toThrow();
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe('promptContaining', () => {
|
|
333
|
+
it('finds the prompt whose range holds the offset, inclusive', () => {
|
|
334
|
+
const s = make();
|
|
335
|
+
|
|
336
|
+
s.mathField = stubField({ r1: '', r2: '' });
|
|
337
|
+
|
|
338
|
+
expect(s.promptContaining(0)).toEqual('r1');
|
|
339
|
+
expect(s.promptContaining(5)).toEqual('r1');
|
|
340
|
+
expect(s.promptContaining(10)).toEqual('r2');
|
|
341
|
+
expect(s.promptContaining(7)).toBeUndefined();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('treats a missing position as outside', () => {
|
|
345
|
+
const s = make();
|
|
346
|
+
|
|
347
|
+
s.mathField = stubField({ r1: '' });
|
|
348
|
+
|
|
349
|
+
expect(s.promptContaining(undefined)).toBeUndefined();
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// MathQuill's inner fields could be focused directly.
|
|
354
|
+
it('promptHandle exposes focus() for its own prompt', () => {
|
|
355
|
+
const s = make();
|
|
356
|
+
|
|
357
|
+
s.mathField = stubField({ r1: '', r2: '' });
|
|
358
|
+
s.promptHandle('r2').focus();
|
|
359
|
+
|
|
360
|
+
expect(s.mathField.focused).toBe(true);
|
|
361
|
+
expect(s.mathField.selection).toEqual({ ranges: [[10, 10]] });
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
});
|
package/src/keypad/index.jsx
CHANGED
|
@@ -22,10 +22,25 @@ const log = debug('pie-lib:math-input-mathlive:keypad');
|
|
|
22
22
|
* `convertLatexToMarkup`, so a 40-key keypad no longer creates 40 live math
|
|
23
23
|
* instances.
|
|
24
24
|
*/
|
|
25
|
+
// Labels that the legacy keypad drew slanted. MathQuill styled `\parallel` as
|
|
26
|
+
// italic, which slants the two bars; MathLive draws it upright. A skew is used
|
|
27
|
+
// rather than `font-style: italic` so the slant does not depend on the font
|
|
28
|
+
// having an italic face for this glyph.
|
|
29
|
+
const SLANTED_LABELS = ['\\parallel'];
|
|
30
|
+
const SLANT = 'skewX(-15deg)';
|
|
31
|
+
// MathLive's `∥` has hairline bars next to the radical sign and the other keys, so
|
|
32
|
+
// they are thickened with a text stroke and stretched a little taller.
|
|
33
|
+
const SLANTED_STROKE = '0.03em currentColor';
|
|
34
|
+
const SLANTED_SCALE_Y = 1.25;
|
|
35
|
+
|
|
25
36
|
// The `color.*()` helpers must be called lazily, inside the styled callback.
|
|
26
37
|
// Evaluating them in a plain object literal runs them at module load, which
|
|
27
38
|
// breaks any consumer that mocks a subset of @pie-lib/render-ui.
|
|
28
|
-
const LabelHolder = styled('span')(() => ({
|
|
39
|
+
const LabelHolder = styled('span', { shouldForwardProp: (prop) => prop !== 'slanted' })(({ slanted }) => ({
|
|
40
|
+
...(slanted && {
|
|
41
|
+
transform: `${SLANT} scaleY(${SLANTED_SCALE_Y})`,
|
|
42
|
+
'& *': { WebkitTextStroke: SLANTED_STROKE },
|
|
43
|
+
}),
|
|
29
44
|
pointerEvents: 'none',
|
|
30
45
|
textTransform: 'none',
|
|
31
46
|
color: color.text(),
|
|
@@ -112,9 +127,9 @@ export class LatexLabel extends React.Component {
|
|
|
112
127
|
// Fall back to the raw latex so a button is never blank.
|
|
113
128
|
return markup ? (
|
|
114
129
|
// eslint-disable-next-line react/no-danger
|
|
115
|
-
<LabelHolder dangerouslySetInnerHTML={{ __html: markup }} />
|
|
130
|
+
<LabelHolder slanted={SLANTED_LABELS.includes(this.props.latex)} dangerouslySetInnerHTML={{ __html: markup }} />
|
|
116
131
|
) : (
|
|
117
|
-
<LabelHolder>{this.props.latex}</LabelHolder>
|
|
132
|
+
<LabelHolder slanted={SLANTED_LABELS.includes(this.props.latex)}>{this.props.latex}</LabelHolder>
|
|
118
133
|
);
|
|
119
134
|
}
|
|
120
135
|
}
|
package/src/latex-bridge.js
CHANGED
|
@@ -69,6 +69,35 @@ export const DEFAULT_FIELD_ID = 'r1';
|
|
|
69
69
|
*/
|
|
70
70
|
const EMPTY_SLOT = '\\htmlData{pie-empty=1}{\\rule{0.55em}{0.55em}}';
|
|
71
71
|
const EMPTY_GROUP_REGEX = /\{\s*\}/g;
|
|
72
|
+
// The segment key (`\overline{}`). The keypad centres every label in its cell, and
|
|
73
|
+
// a thin bar is a much shorter label than the ray and line keys' stretchy arrows,
|
|
74
|
+
// so by default both its bar and its box sit off from theirs: the bar is lower
|
|
75
|
+
// than the arrows' shafts, and the box higher than theirs. Two zero-width struts
|
|
76
|
+
// fix that, found by rendering the three keys at 14-28px and measuring:
|
|
77
|
+
// - inside the bar, above the box: pushes the bar up to the height of the
|
|
78
|
+
// arrows' shafts (the bar-to-box distance then matches theirs);
|
|
79
|
+
// - before the bar, from the baseline: raises the label's top, which moves the
|
|
80
|
+
// whole label down so the box lands where theirs does.
|
|
81
|
+
const EMPTY_OVERLINE_REGEX = /\\overline\{\s*\}/g;
|
|
82
|
+
const EMPTY_OVERLINE_BAR_STRUT = '\\rule{0em}{0.87em}';
|
|
83
|
+
const EMPTY_OVERLINE_LABEL_STRUT = '\\rule{0em}{1.3em}';
|
|
84
|
+
// Empty delimiter pairs: `\abs{}` is converted to `\left|\right|`, and the
|
|
85
|
+
// parenthesis / bracket keys are `\left(\right)` / `\left[\right]`. None has a
|
|
86
|
+
// `{}` group for the rule above to catch, so they would render as bare fences.
|
|
87
|
+
const EMPTY_FENCE_REGEX = /\\left([([|])\s*\\right([)\]|])/g;
|
|
88
|
+
// The same muted fill as the other slots (`pie-empty`), but a tall rectangle
|
|
89
|
+
// rather than a square. It also sets the fences' height: `\left...\right` stretch
|
|
90
|
+
// to their content, so a taller slot gives taller parentheses.
|
|
91
|
+
//
|
|
92
|
+
// A `\rule` stands on the baseline, but the fences are centred on the math axis
|
|
93
|
+
// (0.25em above it), so an un-lifted box sat high between them. `[-0.25em]`
|
|
94
|
+
// lowers it by (axis - height / 2) = 0.25 - 1 / 2, centring it on the axis.
|
|
95
|
+
//
|
|
96
|
+
// Lowering it alone also shrinks the fences, which size themselves to their
|
|
97
|
+
// content (MathLive switches to short bars). The zero-width `\rule{0em}{1em}`
|
|
98
|
+
// strut keeps the content as tall as before, so the bars keep their height and
|
|
99
|
+
// only the box moves.
|
|
100
|
+
const EMPTY_FENCE_SLOT = '\\rule{0em}{1em}\\htmlData{pie-empty=1}{\\rule[-0.25em]{0.55em}{1em}}';
|
|
72
101
|
|
|
73
102
|
/**
|
|
74
103
|
* Make empty argument slots visible, for static rendering.
|
|
@@ -79,7 +108,11 @@ const EMPTY_GROUP_REGEX = /\{\s*\}/g;
|
|
|
79
108
|
* @param {string} latex
|
|
80
109
|
* @returns {string}
|
|
81
110
|
*/
|
|
82
|
-
export const withVisibleEmptySlots = (latex) =>
|
|
111
|
+
export const withVisibleEmptySlots = (latex) =>
|
|
112
|
+
(latex || '')
|
|
113
|
+
.replace(EMPTY_OVERLINE_REGEX, `${EMPTY_OVERLINE_LABEL_STRUT}\\overline{${EMPTY_OVERLINE_BAR_STRUT}${EMPTY_SLOT}}`)
|
|
114
|
+
.replace(EMPTY_GROUP_REGEX, `{${EMPTY_SLOT}}`)
|
|
115
|
+
.replace(EMPTY_FENCE_REGEX, (_m, open, close) => `\\left${open}${EMPTY_FENCE_SLOT}\\right${close}`);
|
|
83
116
|
|
|
84
117
|
/**
|
|
85
118
|
* Pie commands that take an argument, and their native MathLive equivalents.
|
|
@@ -236,6 +269,138 @@ export const toMathLive = (latex) => {
|
|
|
236
269
|
return out;
|
|
237
270
|
};
|
|
238
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Keypad command names that MathQuill treated as ALIASES of a different command.
|
|
274
|
+
*
|
|
275
|
+
* MathQuill registered `\\divide` as another name for `\\div` (and
|
|
276
|
+
* `\\perpendicular` for `\\perp`), so pressing the key stored the real command.
|
|
277
|
+
* MathLive only knows these names through `PIE_MACROS`, and serialises a macro
|
|
278
|
+
* under its own name - so `\\divide` reached storage verbatim, where MathJax
|
|
279
|
+
* (@pie-lib/math-rendering) has no such command and renders it as red text.
|
|
280
|
+
*
|
|
281
|
+
* Every other keypad command already matches what MathQuill stored (`\\degree`,
|
|
282
|
+
* `\\nsim`, `\\ncong`, `\\nparallel`, `\\square`, `\\napprox`,
|
|
283
|
+
* `\\parallelogram`), and MathJax understands those.
|
|
284
|
+
*/
|
|
285
|
+
const MATHQUILL_ALIASES = {
|
|
286
|
+
'\\divide': '\\div',
|
|
287
|
+
'\\perpendicular': '\\perp',
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
// Whole command names only: `\\divideontimes` is a real AMS symbol.
|
|
291
|
+
const MATHQUILL_ALIAS_REGEX = /\\(divide|perpendicular)(?![a-zA-Z])/g;
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Primes. MathLive turns a prime that follows a base into a superscript atom and
|
|
295
|
+
* serialises it as `^{\\prime}`, or `^{\\doubleprime}` for the keypad's double
|
|
296
|
+
* prime key (`5''` comes back out as `5^{\\doubleprime}`). MathJax has no
|
|
297
|
+
* `\\doubleprime`, and two adjacent `^{\\prime}` groups - what typing `'` twice
|
|
298
|
+
* produces - are a "double exponent" error. MathQuill stored plain apostrophes,
|
|
299
|
+
* which both MathJax and MathLive read as primes, so store those.
|
|
300
|
+
*
|
|
301
|
+
* Only a superscript made of primes alone is rewritten: `x^{\\prime 2}` keeps
|
|
302
|
+
* its group, since it holds more than primes.
|
|
303
|
+
*/
|
|
304
|
+
const PRIME_SUPERSCRIPT_REGEX = /\^\{((?:\s*\\(?:prime|doubleprime)(?![a-zA-Z]))+)\s*\}/g;
|
|
305
|
+
const BARE_DOUBLEPRIME_REGEX = /\\doubleprime(?![a-zA-Z])/g;
|
|
306
|
+
|
|
307
|
+
const primesToApostrophes = (primes) =>
|
|
308
|
+
// eslint-disable-next-line quotes
|
|
309
|
+
"'".repeat(
|
|
310
|
+
(primes.match(/\\prime(?![a-zA-Z])/g) || []).length + 2 * (primes.match(BARE_DOUBLEPRIME_REGEX) || []).length,
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
const BARE_PRIME_COMMAND_REGEX = /^\\(doubleprime|prime)(?![a-zA-Z])/;
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Primes that are *not* in a superscript.
|
|
317
|
+
*
|
|
318
|
+
* MathLive emits bare `\prime` / `\doubleprime` for a `′` or `″` typed or pasted
|
|
319
|
+
* as a character (`5′6″` serialises as `5\prime6\doubleprime`). A bare `\prime`
|
|
320
|
+
* is a full-size glyph in MathJax, while `''` is a superscript, so storing
|
|
321
|
+
* `5\prime6''` drew the single prime visibly larger than each stroke of the
|
|
322
|
+
* double prime. Both become apostrophes, the superscript primes MathQuill
|
|
323
|
+
* stored, so they always match.
|
|
324
|
+
*
|
|
325
|
+
* Inside a script (`x^{\prime 2}`, `x^\prime`) a prime is already a
|
|
326
|
+
* superscript, and an apostrophe there would mean something else, so
|
|
327
|
+
* `\prime` is kept and `\doubleprime` (unknown to MathJax) becomes
|
|
328
|
+
* `\prime\prime`.
|
|
329
|
+
*
|
|
330
|
+
* @param {string} latex
|
|
331
|
+
* @returns {string}
|
|
332
|
+
*/
|
|
333
|
+
const barePrimesToApostrophes = (latex) => {
|
|
334
|
+
if (latex.indexOf('prime') === -1) {
|
|
335
|
+
return latex;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// One entry per open brace: is that group a super/subscript?
|
|
339
|
+
const groups = [];
|
|
340
|
+
let out = '';
|
|
341
|
+
let i = 0;
|
|
342
|
+
|
|
343
|
+
while (i < latex.length) {
|
|
344
|
+
const ch = latex[i];
|
|
345
|
+
|
|
346
|
+
if (ch === '\\') {
|
|
347
|
+
const match = latex.slice(i).match(BARE_PRIME_COMMAND_REGEX);
|
|
348
|
+
|
|
349
|
+
if (match) {
|
|
350
|
+
const isDouble = match[1] === 'doubleprime';
|
|
351
|
+
// `x^\prime`: the command is itself the script.
|
|
352
|
+
const isScriptArgument = /[\^_]\s*$/.test(out);
|
|
353
|
+
const inScript = isScriptArgument || groups.indexOf(true) !== -1;
|
|
354
|
+
|
|
355
|
+
if (inScript) {
|
|
356
|
+
out += isDouble ? '\\prime\\prime' : '\\prime';
|
|
357
|
+
} else {
|
|
358
|
+
// eslint-disable-next-line quotes
|
|
359
|
+
out += isDouble ? "''" : "'";
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
i += match[0].length;
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Copy any other command, or an escaped character such as `\{`, whole.
|
|
367
|
+
const command = latex.slice(i).match(/^\\(?:[a-zA-Z]+|.)/);
|
|
368
|
+
const text = command ? command[0] : ch;
|
|
369
|
+
|
|
370
|
+
out += text;
|
|
371
|
+
i += text.length;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (ch === '{') {
|
|
376
|
+
groups.push(/[\^_]\s*$/.test(out));
|
|
377
|
+
} else if (ch === '}') {
|
|
378
|
+
groups.pop();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
out += ch;
|
|
382
|
+
i += 1;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return out;
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Rewrite what MathLive serialises into the form MathQuill stored: alias
|
|
390
|
+
* command names, and primes.
|
|
391
|
+
*
|
|
392
|
+
* @param {string} latex
|
|
393
|
+
* @returns {string}
|
|
394
|
+
*/
|
|
395
|
+
export const toCanonicalCommands = (latex) =>
|
|
396
|
+
typeof latex === 'string'
|
|
397
|
+
? barePrimesToApostrophes(
|
|
398
|
+
latex
|
|
399
|
+
.replace(MATHQUILL_ALIAS_REGEX, (m) => MATHQUILL_ALIASES[m])
|
|
400
|
+
.replace(PRIME_SUPERSCRIPT_REGEX, (_m, primes) => primesToApostrophes(primes)),
|
|
401
|
+
)
|
|
402
|
+
: latex;
|
|
403
|
+
|
|
239
404
|
/**
|
|
240
405
|
* Convert latex out of MathLive back into the stored form, so items authored
|
|
241
406
|
* with the MathLive editor remain readable by @pie-lib/math-rendering and by
|
|
@@ -273,7 +438,9 @@ export const fromMathLive = (latex) => {
|
|
|
273
438
|
out = out.replace(BARE_PLACEHOLDER_REGEX, (_m, content) => content);
|
|
274
439
|
out = out.replace(BARE_PLACEHOLDER_NO_ARG_REGEX, '');
|
|
275
440
|
|
|
276
|
-
|
|
441
|
+
// Store the command MathQuill stored, not the alias - also heals latex that was
|
|
442
|
+
// saved with `\\divide` before the keypad resolved it.
|
|
443
|
+
return toCanonicalCommands(out);
|
|
277
444
|
};
|
|
278
445
|
|
|
279
446
|
/**
|
|
@@ -347,7 +514,7 @@ export const keyToAction = (key) => {
|
|
|
347
514
|
// applied them in sequence; MathLive inserts one latex string, so expand
|
|
348
515
|
// each part and concatenate.
|
|
349
516
|
const parts = Array.isArray(key.command) ? key.command : [key.command];
|
|
350
|
-
const value = parts.map((c) => COMMANDS_WITH_ARGS[c] || c).join('');
|
|
517
|
+
const value = parts.map((c) => COMMANDS_WITH_ARGS[c] || MATHQUILL_ALIASES[c] || c).join('');
|
|
351
518
|
|
|
352
519
|
return { type: 'insert', value };
|
|
353
520
|
}
|
|
@@ -361,4 +528,4 @@ export const keyToAction = (key) => {
|
|
|
361
528
|
return undefined;
|
|
362
529
|
};
|
|
363
530
|
|
|
364
|
-
export { COMMANDS_WITH_ARGS, KEYSTROKES };
|
|
531
|
+
export { COMMANDS_WITH_ARGS, KEYSTROKES, MATHQUILL_ALIASES };
|