@pie-lib/math-input-mathlive 1.1.5-beta.6 → 1.1.5-beta.8
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__/input-selection.test.js +103 -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__/selection.test.js +195 -0
- package/lib/__tests__/static-prompts.test.js +405 -0
- package/lib/__tests__/tmp-markup.test.js +26 -0
- package/lib/latex-bridge.js +17 -3
- package/lib/latex-bridge.js.map +1 -1
- package/lib/mf/input.js +9 -1
- package/lib/mf/input.js.map +1 -1
- package/lib/selection.js +148 -0
- package/lib/selection.js.map +1 -0
- 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__/input-selection.test.jsx +64 -0
- package/src/__tests__/keypad-label.test.jsx +283 -0
- package/src/__tests__/keypad-render.test.jsx +123 -0
- package/src/__tests__/latex-bridge.test.js +359 -0
- package/src/__tests__/mathlive-instance.test.js +418 -0
- package/src/__tests__/mount-race.test.jsx +142 -0
- package/src/__tests__/selection.test.js +212 -0
- package/src/__tests__/static-prompts.test.jsx +364 -0
- package/src/latex-bridge.js +17 -2
- package/src/mf/input.jsx +10 -1
- package/src/selection.js +146 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { expandRange, trackWholeAtomSelection } from '../selection';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Offset tables captured from MathLive 0.110.0 `getElementInfo(i)` in Chrome,
|
|
5
|
+
* as [depth, latex] per offset. Offset `i` is the atom ending at caret `i`.
|
|
6
|
+
*/
|
|
7
|
+
const table = (rows) => (i) => (rows[i] ? { depth: rows[i][0], latex: rows[i][1] } : undefined);
|
|
8
|
+
|
|
9
|
+
// 5x^2+2x+\frac{\pi}{2}-4
|
|
10
|
+
const POLY = table([
|
|
11
|
+
[0, ''], [0, '5'], [0, 'x'], [1, ''], [1, '2'], [0, '^2'], [0, '+'], [0, '2'], [0, 'x'], [0, '+'],
|
|
12
|
+
[1, ''], [1, '\\pi'], [1, ''], [1, '2'], [0, '\\frac{\\pi}{2}'], [0, '-'], [0, '4'],
|
|
13
|
+
]);
|
|
14
|
+
const POLY_LAST = 16;
|
|
15
|
+
|
|
16
|
+
// a+\sqrt{x+1}+b
|
|
17
|
+
const ROOT = table([[0, ''], [0, 'a'], [0, '+'], [1, ''], [1, 'x'], [1, '+'], [1, '1'], [0, '\\sqrt{x+1}'], [0, '+'], [0, 'b']]);
|
|
18
|
+
|
|
19
|
+
// \frac{\frac{a}{b}}{c}
|
|
20
|
+
const NESTED = table([
|
|
21
|
+
[0, ''], [1, ''], [2, ''], [2, 'a'], [2, ''], [2, 'b'], [1, '\\frac{a}{b}'], [1, ''], [1, 'c'], [0, '\\frac{\\frac{a}{b}}{c}'],
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
describe('expandRange', () => {
|
|
25
|
+
const poly = (a, b) => expandRange(a, b, POLY, POLY_LAST);
|
|
26
|
+
|
|
27
|
+
it('leaves a selection of whole sibling atoms alone', () => {
|
|
28
|
+
expect(poly(6, 7)).toEqual([6, 7]); // 2
|
|
29
|
+
expect(poly(6, 9)).toEqual([6, 9]); // 2x+
|
|
30
|
+
expect(poly(6, 15)).toEqual([6, 15]); // 2x+\frac{\pi}{2}-
|
|
31
|
+
expect(poly(0, 16)).toEqual([0, 16]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// The reported case: five Shift+Right presses from before `2x` gave `2x+\pi`.
|
|
35
|
+
it('takes the whole fraction when the selection reaches into it', () => {
|
|
36
|
+
expect(poly(6, 10)).toEqual([6, 14]);
|
|
37
|
+
expect(poly(6, 11)).toEqual([6, 14]);
|
|
38
|
+
expect(poly(6, 13)).toEqual([6, 14]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('takes the whole fraction when the selection leaves it', () => {
|
|
42
|
+
expect(poly(11, 15)).toEqual([9, 15]); // from the pi to after the minus
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('crossing from numerator to denominator selects the fraction', () => {
|
|
46
|
+
expect(poly(11, 13)).toEqual([9, 14]);
|
|
47
|
+
expect(poly(11, 12)).toEqual([9, 14]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('a selection inside one branch stays inside it', () => {
|
|
51
|
+
expect(poly(10, 11)).toEqual([10, 11]); // \pi
|
|
52
|
+
expect(poly(12, 13)).toEqual([12, 13]); // the 2 of the denominator
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// MathLive stores x^2 as `x` then a separate `^2` atom.
|
|
56
|
+
it('keeps a superscript with its base', () => {
|
|
57
|
+
expect(poly(3, 7)).toEqual([1, 7]); // x^2+2, not ^2+2
|
|
58
|
+
expect(poly(4, 6)).toEqual([1, 6]); // x^2+
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('accepts a reversed range', () => {
|
|
62
|
+
expect(poly(11, 6)).toEqual([6, 14]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('leaves a collapsed range alone', () => {
|
|
66
|
+
expect(poly(11, 11)).toEqual([11, 11]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('handles roots the same way', () => {
|
|
70
|
+
expect(expandRange(2, 4, ROOT, 9)).toEqual([2, 7]);
|
|
71
|
+
expect(expandRange(4, 6, ROOT, 9)).toEqual([4, 6]); // +1 inside the radicand
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('climbs as many levels as needed in nested fractions', () => {
|
|
75
|
+
expect(expandRange(2, 3, NESTED, 9)).toEqual([2, 3]); // a
|
|
76
|
+
expect(expandRange(3, 5, NESTED, 9)).toEqual([1, 6]); // a/b crossing -> inner fraction
|
|
77
|
+
expect(expandRange(6, 7, NESTED, 9)).toEqual([0, 9]); // inner -> outer denominator
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('tolerates missing info', () => {
|
|
81
|
+
expect(expandRange(1, 3, () => undefined, 5)).toEqual([1, 3]);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('trackWholeAtomSelection', () => {
|
|
86
|
+
const stubField = (range, direction = 'forward') => {
|
|
87
|
+
const listeners = {};
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
selection: { ranges: [range], direction },
|
|
91
|
+
get selectionIsCollapsed() {
|
|
92
|
+
return this.selection.ranges[0][0] === this.selection.ranges[0][1];
|
|
93
|
+
},
|
|
94
|
+
lastOffset: POLY_LAST,
|
|
95
|
+
getElementInfo: POLY,
|
|
96
|
+
sets: 0,
|
|
97
|
+
addEventListener(type, fn) {
|
|
98
|
+
listeners[type] = fn;
|
|
99
|
+
},
|
|
100
|
+
removeEventListener(type, fn) {
|
|
101
|
+
if (listeners[type] === fn) {
|
|
102
|
+
delete listeners[type];
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
fire(type) {
|
|
106
|
+
if (listeners[type]) {
|
|
107
|
+
listeners[type]();
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
has(type) {
|
|
111
|
+
return !!listeners[type];
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const track = (field) => {
|
|
117
|
+
// count assignments without breaking the getter
|
|
118
|
+
let current = field.selection;
|
|
119
|
+
|
|
120
|
+
Object.defineProperty(field, 'selection', {
|
|
121
|
+
get: () => current,
|
|
122
|
+
set: (v) => {
|
|
123
|
+
field.sets += 1;
|
|
124
|
+
current = v;
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return trackWholeAtomSelection(field);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
it('widens the selection on selection-change', () => {
|
|
132
|
+
const field = stubField([6, 11]);
|
|
133
|
+
|
|
134
|
+
track(field);
|
|
135
|
+
field.fire('selection-change');
|
|
136
|
+
|
|
137
|
+
expect(field.selection.ranges).toEqual([[6, 14]]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('preserves the direction, so Shift+Arrow keeps extending the same end', () => {
|
|
141
|
+
const field = stubField([11, 16], 'backward');
|
|
142
|
+
|
|
143
|
+
track(field);
|
|
144
|
+
field.fire('selection-change');
|
|
145
|
+
|
|
146
|
+
expect(field.selection).toEqual({ ranges: [[9, 16]], direction: 'backward' });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('does not touch a selection that is already whole', () => {
|
|
150
|
+
const field = stubField([6, 9]);
|
|
151
|
+
|
|
152
|
+
track(field);
|
|
153
|
+
field.fire('selection-change');
|
|
154
|
+
|
|
155
|
+
expect(field.sets).toBe(0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('ignores a collapsed caret', () => {
|
|
159
|
+
const field = stubField([11, 11]);
|
|
160
|
+
|
|
161
|
+
track(field);
|
|
162
|
+
field.fire('selection-change');
|
|
163
|
+
|
|
164
|
+
expect(field.sets).toBe(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('does not recurse when its own assignment fires selection-change', () => {
|
|
168
|
+
const field = stubField([6, 11]);
|
|
169
|
+
let current = field.selection;
|
|
170
|
+
|
|
171
|
+
Object.defineProperty(field, 'selection', {
|
|
172
|
+
get: () => current,
|
|
173
|
+
set: (v) => {
|
|
174
|
+
field.sets += 1;
|
|
175
|
+
current = v;
|
|
176
|
+
field.fire('selection-change');
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
trackWholeAtomSelection(field);
|
|
181
|
+
field.fire('selection-change');
|
|
182
|
+
|
|
183
|
+
expect(field.sets).toBe(1);
|
|
184
|
+
expect(field.selection.ranges).toEqual([[6, 14]]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('survives a field that throws', () => {
|
|
188
|
+
const field = stubField([6, 11]);
|
|
189
|
+
|
|
190
|
+
field.getElementInfo = () => {
|
|
191
|
+
throw new Error('boom');
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
track(field);
|
|
195
|
+
|
|
196
|
+
expect(() => field.fire('selection-change')).not.toThrow();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('tears down its listener', () => {
|
|
200
|
+
const field = stubField([6, 11]);
|
|
201
|
+
const untrack = track(field);
|
|
202
|
+
|
|
203
|
+
expect(field.has('selection-change')).toBe(true);
|
|
204
|
+
untrack();
|
|
205
|
+
expect(field.has('selection-change')).toBe(false);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('is a no-op without a field', () => {
|
|
209
|
+
expect(trackWholeAtomSelection(undefined)).toBeUndefined();
|
|
210
|
+
expect(trackWholeAtomSelection({})).toBeUndefined();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -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/latex-bridge.js
CHANGED
|
@@ -69,14 +69,28 @@ 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{}`). A bar is a thin accent, so its label is shorter
|
|
73
|
+
// than the ray and line keys' stretchy arrows. The keypad centres every label
|
|
74
|
+
// in its cell, so the bar's box ended up ~0.17em higher than theirs.
|
|
75
|
+
const EMPTY_OVERLINE_REGEX = /\\overline\{\s*\}/g;
|
|
76
|
+
const EMPTY_OVERLINE_STRUT = '\\rule{0em}{1.08em}';
|
|
72
77
|
// Empty delimiter pairs: `\abs{}` is converted to `\left|\right|`, and the
|
|
73
78
|
// parenthesis / bracket keys are `\left(\right)` / `\left[\right]`. None has a
|
|
74
79
|
// `{}` group for the rule above to catch, so they would render as bare fences.
|
|
75
|
-
const EMPTY_FENCE_REGEX = /\\left([(
|
|
80
|
+
const EMPTY_FENCE_REGEX = /\\left([([|])\s*\\right([)\]|])/g;
|
|
76
81
|
// The same muted fill as the other slots (`pie-empty`), but a tall rectangle
|
|
77
82
|
// rather than a square. It also sets the fences' height: `\left...\right` stretch
|
|
78
83
|
// to their content, so a taller slot gives taller parentheses.
|
|
79
|
-
|
|
84
|
+
//
|
|
85
|
+
// A `\rule` stands on the baseline, but the fences are centred on the math axis
|
|
86
|
+
// (0.25em above it), so an un-lifted box sat high between them. `[-0.25em]`
|
|
87
|
+
// lowers it by (axis - height / 2) = 0.25 - 1 / 2, centring it on the axis.
|
|
88
|
+
//
|
|
89
|
+
// Lowering it alone also shrinks the fences, which size themselves to their
|
|
90
|
+
// content (MathLive switches to short bars). The zero-width `\rule{0em}{1em}`
|
|
91
|
+
// strut keeps the content as tall as before, so the bars keep their height and
|
|
92
|
+
// only the box moves.
|
|
93
|
+
const EMPTY_FENCE_SLOT = '\\rule{0em}{1em}\\htmlData{pie-empty=1}{\\rule[-0.25em]{0.55em}{1em}}';
|
|
80
94
|
|
|
81
95
|
/**
|
|
82
96
|
* Make empty argument slots visible, for static rendering.
|
|
@@ -89,6 +103,7 @@ const EMPTY_FENCE_SLOT = '\\htmlData{pie-empty=1}{\\rule{0.55em}{1em}}';
|
|
|
89
103
|
*/
|
|
90
104
|
export const withVisibleEmptySlots = (latex) =>
|
|
91
105
|
(latex || '')
|
|
106
|
+
.replace(EMPTY_OVERLINE_REGEX, `\\overline{${EMPTY_OVERLINE_STRUT}${EMPTY_SLOT}}`)
|
|
92
107
|
.replace(EMPTY_GROUP_REGEX, `{${EMPTY_SLOT}}`)
|
|
93
108
|
.replace(EMPTY_FENCE_REGEX, (_m, open, close) => `\\left${open}${EMPTY_FENCE_SLOT}\\right${close}`);
|
|
94
109
|
|
package/src/mf/input.jsx
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
trackPlaceholderCaret,
|
|
11
11
|
} from '../mathlive-instance';
|
|
12
12
|
import { toMathLive, fromMathLive, keyToAction, KEYSTROKES } from '../latex-bridge';
|
|
13
|
+
import { trackWholeAtomSelection } from '../selection';
|
|
13
14
|
|
|
14
15
|
const log = debug('pie-lib:math-input-mathlive:input');
|
|
15
16
|
|
|
@@ -81,7 +82,7 @@ export class Input extends React.Component {
|
|
|
81
82
|
// defaults to an empty string.
|
|
82
83
|
this.mathField.mathModeSpace = MATH_MODE_SPACE;
|
|
83
84
|
// After the exponent key the cursor must stay in the exponent until it is moved
|
|
84
|
-
//
|
|
85
|
+
// MathLive's `smartSuperscript` (on by
|
|
85
86
|
// default) jumps out of an empty superscript after a single digit, which breaks
|
|
86
87
|
// typing x^23 or 10^6 and is inconsistent with non-digit exponents.
|
|
87
88
|
this.mathField.smartSuperscript = false;
|
|
@@ -93,6 +94,9 @@ export class Input extends React.Component {
|
|
|
93
94
|
// A selected `\placeholder{}` gets no MathLive caret; draw one.
|
|
94
95
|
this.untrackCaret = trackPlaceholderCaret(this.mathField);
|
|
95
96
|
|
|
97
|
+
// Shift+Arrow would otherwise select half a fraction; snap to whole atoms.
|
|
98
|
+
this.untrackSelection = trackWholeAtomSelection(this.mathField);
|
|
99
|
+
|
|
96
100
|
this.mathField.addEventListener('input', this.onInputEdit);
|
|
97
101
|
|
|
98
102
|
// Belt and braces: drop anything a previous mount may have left behind.
|
|
@@ -136,6 +140,11 @@ export class Input extends React.Component {
|
|
|
136
140
|
this.untrackCaret = undefined;
|
|
137
141
|
}
|
|
138
142
|
|
|
143
|
+
if (this.untrackSelection) {
|
|
144
|
+
this.untrackSelection();
|
|
145
|
+
this.untrackSelection = undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
139
148
|
if (this.mathField) {
|
|
140
149
|
this.mathField.removeEventListener('input', this.onInputEdit);
|
|
141
150
|
this.mathField.remove();
|