@pie-lib/math-input-mathlive 1.1.5-beta.7 → 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__/input-selection.test.js +103 -0
- package/lib/__tests__/keypad-label.test.js +4 -2
- package/lib/__tests__/selection.test.js +195 -0
- package/lib/latex-bridge.js +6 -1
- package/lib/latex-bridge.js.map +1 -1
- package/lib/mf/input.js +8 -0
- 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__/input-selection.test.jsx +64 -0
- package/src/__tests__/keypad-label.test.jsx +12 -0
- package/src/__tests__/selection.test.js +212 -0
- package/src/latex-bridge.js +6 -0
- package/src/mf/input.jsx +9 -0
- 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
|
+
});
|
package/src/latex-bridge.js
CHANGED
|
@@ -69,6 +69,11 @@ 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.
|
|
@@ -98,6 +103,7 @@ const EMPTY_FENCE_SLOT = '\\rule{0em}{1em}\\htmlData{pie-empty=1}{\\rule[-0.25em
|
|
|
98
103
|
*/
|
|
99
104
|
export const withVisibleEmptySlots = (latex) =>
|
|
100
105
|
(latex || '')
|
|
106
|
+
.replace(EMPTY_OVERLINE_REGEX, `\\overline{${EMPTY_OVERLINE_STRUT}${EMPTY_SLOT}}`)
|
|
101
107
|
.replace(EMPTY_GROUP_REGEX, `{${EMPTY_SLOT}}`)
|
|
102
108
|
.replace(EMPTY_FENCE_REGEX, (_m, open, close) => `\\left${open}${EMPTY_FENCE_SLOT}\\right${close}`);
|
|
103
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
|
|
|
@@ -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();
|
package/src/selection.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import debug from 'debug';
|
|
2
|
+
|
|
3
|
+
const log = debug('pie-lib:math-input-mathlive:selection');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Widen a selection so it covers whole atoms of a single parent.
|
|
7
|
+
*
|
|
8
|
+
* MathLive extends a selection over *flat offsets*, so Shift+Arrow walks into
|
|
9
|
+
* a fraction instead of over it: from before `2x` in `5x^2+2x+\frac{\pi}{2}-4`,
|
|
10
|
+
* five presses select `2x+` plus the `\pi` of the numerator, and copying gives
|
|
11
|
+
* `2x+\pi`. MathQuill treated a fraction (root, superscript...) as one unit,
|
|
12
|
+
* which is what authors expect.
|
|
13
|
+
*
|
|
14
|
+
* The tree is recovered from MathLive's public `getElementInfo(offset)`, where
|
|
15
|
+
* offset `i` describes the atom ending at caret position `i`:
|
|
16
|
+
*
|
|
17
|
+
* - a composite atom (fraction, root, script) owns the offset right *after*
|
|
18
|
+
* its children; the children are one level deeper;
|
|
19
|
+
* - each branch (numerator, denominator, radicand) opens with an empty-latex
|
|
20
|
+
* "start" atom.
|
|
21
|
+
*
|
|
22
|
+
* The rule: find the shallowest depth the selection touches, counting the caret
|
|
23
|
+
* it starts from. If the range crosses into a sibling branch at that depth, the
|
|
24
|
+
* real common parent is one level up. Then push both ends out of anything
|
|
25
|
+
* deeper. A script atom (`^2`) is kept with its base, since MathLive stores
|
|
26
|
+
* `x^2` as `x` followed by a separate `^2` atom.
|
|
27
|
+
*
|
|
28
|
+
* Pure, so it can be tested without a browser.
|
|
29
|
+
*
|
|
30
|
+
* @param {number} start
|
|
31
|
+
* @param {number} end
|
|
32
|
+
* @param {function(number): {depth?: number, latex?: string}} info
|
|
33
|
+
* @param {number} lastOffset
|
|
34
|
+
* @returns {[number, number]} the widened range, start <= end
|
|
35
|
+
*/
|
|
36
|
+
export const expandRange = (start, end, info, lastOffset) => {
|
|
37
|
+
const at = (i) => info(i) || {};
|
|
38
|
+
const depth = (i) => at(i).depth || 0;
|
|
39
|
+
const isBranchStart = (i) => depth(i) > 0 && at(i).latex === '';
|
|
40
|
+
|
|
41
|
+
if (end < start) {
|
|
42
|
+
[start, end] = [end, start];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (end - start < 1) {
|
|
46
|
+
return [start, end];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let target = Infinity;
|
|
50
|
+
|
|
51
|
+
for (let i = start; i <= end; i++) {
|
|
52
|
+
target = Math.min(target, depth(i));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (let i = start + 1; i <= end; i++) {
|
|
56
|
+
if (depth(i) === target && isBranchStart(i)) {
|
|
57
|
+
target -= 1;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
target = Math.max(target, 0);
|
|
63
|
+
|
|
64
|
+
while (start > 0 && depth(start) > target) {
|
|
65
|
+
start -= 1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
while (end < lastOffset && depth(end) > target) {
|
|
69
|
+
end += 1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Keep a superscript / subscript with its base.
|
|
73
|
+
let first = start + 1;
|
|
74
|
+
|
|
75
|
+
while (first <= end && depth(first) > target) {
|
|
76
|
+
first += 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (start > 0 && first <= end && /^[\^_]/.test(at(first).latex || '') && depth(start) === target) {
|
|
80
|
+
start -= 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return [start, end];
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Keep a mathfield's selection snapped to whole atoms (see {@link expandRange}).
|
|
88
|
+
*
|
|
89
|
+
* This fixes both what the user sees highlighted and what copy/cut put on the
|
|
90
|
+
* clipboard, since both come from the selection. The selection direction is
|
|
91
|
+
* preserved, so further Shift+Arrow presses keep extending the same end.
|
|
92
|
+
*
|
|
93
|
+
* @param {HTMLElement} mathField a MathLive `<math-field>`
|
|
94
|
+
* @returns {function|undefined} teardown, to call on unmount
|
|
95
|
+
*/
|
|
96
|
+
export const trackWholeAtomSelection = (mathField) => {
|
|
97
|
+
if (!mathField || typeof mathField.addEventListener !== 'function') {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let applying = false;
|
|
102
|
+
|
|
103
|
+
const update = () => {
|
|
104
|
+
if (applying) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
if (mathField.selectionIsCollapsed || typeof mathField.getElementInfo !== 'function') {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const selection = mathField.selection;
|
|
114
|
+
const range = selection && selection.ranges && selection.ranges[0];
|
|
115
|
+
|
|
116
|
+
if (!range) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const [start, end] = range;
|
|
121
|
+
const lo = Math.min(start, end);
|
|
122
|
+
const hi = Math.max(start, end);
|
|
123
|
+
const [nextStart, nextEnd] = expandRange(
|
|
124
|
+
lo,
|
|
125
|
+
hi,
|
|
126
|
+
(i) => mathField.getElementInfo(i),
|
|
127
|
+
mathField.lastOffset,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
if (nextStart === lo && nextEnd === hi) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
applying = true;
|
|
135
|
+
mathField.selection = { ranges: [[nextStart, nextEnd]], direction: selection.direction };
|
|
136
|
+
} catch (e) {
|
|
137
|
+
log('could not normalise the selection: %s', e && e.message);
|
|
138
|
+
} finally {
|
|
139
|
+
applying = false;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
mathField.addEventListener('selection-change', update);
|
|
144
|
+
|
|
145
|
+
return () => mathField.removeEventListener('selection-change', update);
|
|
146
|
+
};
|