@mathzetta/inlinemath 0.0.0-stage → 0.2.0

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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +326 -2
  3. package/dist/cas.external.js +2 -0
  4. package/dist/cas.js +2 -0
  5. package/dist/chunks/calculate-Cz87Lt0N.js +2 -0
  6. package/dist/chunks/draw-Bev17BJJ.js +2 -0
  7. package/dist/chunks/edit-C65xXzo8.js +2 -0
  8. package/dist/chunks/edit-M68aRA5o.js +2 -0
  9. package/dist/chunks/html-BimAqn7x.js +2 -0
  10. package/dist/chunks/html-CupluJSh.js +2 -0
  11. package/dist/chunks/lite-3Fb-OHbt.js +2 -0
  12. package/dist/fonts/GUST-FONT-LICENSE.txt +30 -0
  13. package/dist/fonts/fonts.css +17 -0
  14. package/dist/fonts/lmroman12-italic.woff2 +0 -0
  15. package/dist/fonts/lmroman12-regular.woff2 +0 -0
  16. package/dist/inlinemath-cas.external.global.min.js +2 -0
  17. package/dist/inlinemath-cas.global.min.js +2 -0
  18. package/dist/inlinemath-lite-calc.global.min.js +2 -0
  19. package/dist/inlinemath-lite.external.global.min.js +2 -0
  20. package/dist/inlinemath-lite.global.min.js +2 -0
  21. package/dist/inlinemath.css +158 -0
  22. package/dist/inlinemath.external.global.min.js +2 -0
  23. package/dist/inlinemath.external.js +2 -0
  24. package/dist/inlinemath.global.min.js +2 -0
  25. package/dist/inlinemath.js +2 -0
  26. package/dist/lite-calc.js +2 -0
  27. package/dist/lite.external.js +2 -0
  28. package/dist/lite.js +2 -0
  29. package/dist/markdown.external.js +2 -0
  30. package/dist/markdown.js +2 -0
  31. package/fonts/GUST-FONT-LICENSE.txt +30 -0
  32. package/fonts/fonts.css +17 -0
  33. package/fonts/lmroman12-italic.woff2 +0 -0
  34. package/fonts/lmroman12-regular.woff2 +0 -0
  35. package/inlinemath.css +158 -0
  36. package/package.json +95 -4
  37. package/src/calculator.js +6 -0
  38. package/src/cas.d.ts +3 -0
  39. package/src/cas.js +149 -0
  40. package/src/draw.js +72 -0
  41. package/src/edit.js +96 -0
  42. package/src/glyphs.js +40 -0
  43. package/src/html.js +32 -0
  44. package/src/index.d.ts +3 -0
  45. package/src/index.js +17 -0
  46. package/src/layout.js +649 -0
  47. package/src/lite-calc.d.ts +3 -0
  48. package/src/lite-calc.js +15 -0
  49. package/src/lite.d.ts +3 -0
  50. package/src/lite.js +18 -0
  51. package/src/markdown.d.ts +3 -0
  52. package/src/markdown.js +676 -0
  53. package/src/mathzetta.js +18 -0
  54. package/src/select.js +108 -0
  55. package/src/types.d.ts +204 -0
  56. package/src/variables.js +228 -0
  57. package/src/view.js +382 -0
package/src/view.js ADDED
@@ -0,0 +1,382 @@
1
+ /**
2
+ * A live expression on the page: draw it, point at parts of it, react to
3
+ * clicks, step it to the next expression with the unchanged parts gliding
4
+ * into place.
5
+ *
6
+ * const view = render(element, 'x^2 + 2x');
7
+ * view.highlight('2x'); // or an id, path, key, test
8
+ * view.color('x^2', 'crimson');
9
+ * view.on('click', ({ node }) => …);
10
+ * view.set('2x + 2', { animate: true }); // next step (instant for readers who ask for reduced motion)
11
+ * view.segments() // [{ text, id }] spoken pieces
12
+ *
13
+ * Once something listens for 'click', the drawing works from the keyboard too:
14
+ * Tab to it, arrow keys walk its parts (Down into a part, Up out of it,
15
+ * Left/Right between neighbours, Home or Escape back to the whole), Enter or
16
+ * Space fires the click handlers for the current part, and each part is spoken.
17
+ *
18
+ * view.editable({ toText }) (toText from 'mathzetta/text') lets the reader change it: double-click
19
+ * a part, or press F2 on the part the keyboard is on, and a text box opens over it; Enter puts
20
+ * the new text in its place, Escape leaves it. { onEdit } hears each change.
21
+ *
22
+ * With { announce: true }, each set() after the first reads the new expression
23
+ * to a screen reader through a polite live region placed after the element
24
+ * (or pass your own live-region element as `announce`).
25
+ */
26
+
27
+ import { toSpeechSegments, toAudioSpeech } from './mathzetta.js';
28
+ import { treeOf, layoutOptions } from './html.js';
29
+ import { layout } from './layout.js';
30
+ import { select, signature, pathOf, nodeAt } from './select.js';
31
+ import { openEditor, replaceAt } from './edit.js';
32
+
33
+ export class View {
34
+ /**
35
+ * @param {Element} element where to draw (its contents are replaced)
36
+ * @param {string|string[]|object} input
37
+ * @param {object} [options] as for toHTML
38
+ */
39
+ constructor(element, input, options = {}) {
40
+ this.element = element;
41
+ this.options = options;
42
+ this.p = options.prefix || 'mv';
43
+ this.handlers = [];
44
+ this.clicks = []; // the 'click' handlers, which Enter and Space call too
45
+ this.active = null; // the part the keyboard is on (null: the whole expression)
46
+ this.live = liveRegionFor(element, options.announce, this.p);
47
+ this.set(input);
48
+ }
49
+
50
+ /**
51
+ * Show a new expression. With { animate: true }, parts that appear in
52
+ * both move from their old place to their new one, and new parts fade in —
53
+ * unless the reader's system asks for reduced motion, when the new step
54
+ * simply appears. { animate: 'always' } animates regardless (a page whose
55
+ * point is the motion, started by the reader).
56
+ */
57
+ set(input, { animate = false, duration = 600, easing = 'ease-in-out' } = {}) {
58
+ const before = shouldAnimate(animate) && this.tree ? this.snapshot() : null;
59
+ const isStep = this.tree !== undefined;
60
+ this.tree = treeOf(input, this.options);
61
+ this.element.innerHTML = layout(this.tree, layoutOptions(this.tree, this.options)).html;
62
+ this.root = this.element.firstElementChild;
63
+ if (this.handlers.length) this.root.classList.add(`${this.p}-clickable`);
64
+ if (this.editing) this.root.classList.add(`${this.p}-editable`);
65
+ this.active = null;
66
+ if (this.clicks.length || this.editing) this.focusable();
67
+ if (before) this.morph(before, { duration, easing });
68
+ if (isStep && this.live) this.announce(this.spoken());
69
+ return this;
70
+ }
71
+
72
+ /** The expression as a listener hears it: its accessible name, else MathZetta's audio speech. */
73
+ spoken() {
74
+ const label = this.root && this.root.getAttribute && this.root.getAttribute('aria-label');
75
+ if (label) return label;
76
+ try { return toAudioSpeech(this.tree); } catch { return ''; }
77
+ }
78
+
79
+ /**
80
+ * Say something through the view's live region ({ announce } must be set): the new step is
81
+ * announced this way, and a page can add its own words ("Step 3: …"). The region is emptied
82
+ * first, so the same words twice are still read.
83
+ */
84
+ announce(text) {
85
+ return this.say(this.live, text);
86
+ }
87
+
88
+ say(live, text) {
89
+ if (!live) return this;
90
+ live.textContent = '';
91
+ const put = () => { live.textContent = text; };
92
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => requestAnimationFrame(put));
93
+ else setTimeout(put, 50);
94
+ return this;
95
+ }
96
+
97
+ /** The nodes a target picks out (see select.js for the ways to write one). */
98
+ nodes(target) {
99
+ return select(this.tree, target);
100
+ }
101
+
102
+ /** The elements drawing those nodes. */
103
+ elements(target) {
104
+ return this.nodes(target).map((n) => this.elementOf(n)).filter(Boolean);
105
+ }
106
+
107
+ elementOf(node) {
108
+ return node.id === undefined ? null : this.root.querySelector(`[data-node="${node.id}"]`);
109
+ }
110
+
111
+ /** Add a class (mv-hl by default) to the target's elements. */
112
+ highlight(target, className = `${this.p}-hl`) {
113
+ this.elements(target).forEach((e) => e.classList.add(className));
114
+ return this;
115
+ }
116
+
117
+ /** Remove a highlight class, from the target or everywhere. */
118
+ unhighlight(target = null, className = `${this.p}-hl`) {
119
+ const list = target === null ? this.root.querySelectorAll(`.${className}`) : this.elements(target);
120
+ list.forEach((e) => e.classList.remove(className));
121
+ return this;
122
+ }
123
+
124
+ /** Colour parts; pass null to clear. */
125
+ color(target, colour) {
126
+ this.elements(target).forEach((e) => { e.style.color = colour || ''; });
127
+ return this;
128
+ }
129
+
130
+ /**
131
+ * React to events on parts of the expression. `fn` gets
132
+ * { node, id, path, element, event } for the innermost node under the pointer.
133
+ */
134
+ on(type, fn) {
135
+ const listener = (event) => {
136
+ const element = event.target.closest?.('[data-node]');
137
+ if (!element || !this.root.contains(element)) return;
138
+ const id = Number(element.dataset.node);
139
+ const node = select(this.tree, id)[0];
140
+ fn({ node, id, path: pathOf(this.tree, id), element, event });
141
+ };
142
+ this.element.addEventListener(type, listener);
143
+ this.handlers.push([type, listener]);
144
+ this.root.classList.add(`${this.p}-clickable`);
145
+ if (type === 'click') { this.clicks.push(fn); this.keyboard(); }
146
+ return () => {
147
+ this.element.removeEventListener(type, listener);
148
+ this.clicks = this.clicks.filter((f) => f !== fn);
149
+ };
150
+ }
151
+
152
+ // ---- editing a part in place ---------------------------------------------------
153
+
154
+ /**
155
+ * Let the reader edit the expression a part at a time (double-click, or F2 from the keyboard).
156
+ * @param {object} how
157
+ * @param {(node: object) => string} how.toText MathZetta's toText ('mathzetta/text')
158
+ * @param {(change: { tree, text, path, part }) => void} [how.onEdit] after each change: the
159
+ * new expression (tree, and as text), where the edited part is and what was typed
160
+ */
161
+ editable({ toText, onEdit } = {}) {
162
+ if (typeof toText !== 'function') throw new Error("editable needs { toText } (import { toText } from 'mathzetta/text')");
163
+ this.editing = { toText, onEdit };
164
+ if (this.root && this.root.classList) this.root.classList.add(`${this.p}-editable`);
165
+ this.keyboard();
166
+ if (!this.editOn && this.element.addEventListener) {
167
+ this.editOn = true;
168
+ this.element.addEventListener('dblclick', (event) => {
169
+ const element = event.target.closest?.('[data-node]');
170
+ if (!element || !this.root.contains(element)) return;
171
+ event.preventDefault();
172
+ this.edit(select(this.tree, Number(element.dataset.node))[0]);
173
+ });
174
+ }
175
+ return this;
176
+ }
177
+
178
+ /** Open the text box over a part (a node, or any target select() takes). */
179
+ edit(target = this.tree, { returnFocus = false } = {}) {
180
+ if (!this.editing) throw new Error('call editable({ toText }) first');
181
+ const node = typeof target === 'object' && target && target.type ? target : this.nodes(target)[0];
182
+ if (!node) return null;
183
+ const path = pathOf(this.tree, node.id) || [];
184
+ const element = this.elementOf(node) || this.root;
185
+ let label = 'Edit';
186
+ try { label = `Edit ${toAudioSpeech(node)}`; } catch { /* the plain name will do */ }
187
+ const back = () => { if (returnFocus && this.root.focus) this.root.focus(); };
188
+ return openEditor(element, this.editing.toText(node), {
189
+ label,
190
+ parse: (text) => treeOf(text, this.options),
191
+ commit: (part, typed) => {
192
+ this.set(replaceAt(this.tree, path, part), { animate: true });
193
+ const inPlace = nodeAt(this.tree, path);
194
+ if (returnFocus && inPlace) this.focusPart(inPlace === this.tree ? null : inPlace);
195
+ back();
196
+ if (this.editing.onEdit) this.editing.onEdit({ tree: this.tree, text: this.editing.toText(this.tree), path, part: typed });
197
+ },
198
+ cancel: back,
199
+ });
200
+ }
201
+
202
+ // ---- the keyboard: what a click does, without a pointer -----------------------
203
+
204
+ /** Make the expression one tab stop, with the keys explained to a screen reader. */
205
+ focusable() {
206
+ if (!this.root || !this.root.setAttribute) return;
207
+ this.root.setAttribute('tabindex', '0');
208
+ // a focusable element needs a name: with { mathml: true } the drawing has none of its own (the MathML has it)
209
+ if (!this.root.getAttribute('aria-label')) { this.root.setAttribute('role', 'math'); this.root.setAttribute('aria-label', this.spoken()); }
210
+ this.root.setAttribute('aria-description', this.editing
211
+ ? 'Arrow keys move between parts, F2 edits one.'
212
+ : 'Arrow keys move between parts, Enter selects one.');
213
+ }
214
+
215
+ /** Listen for the keys once, on the element (it outlives every set()). */
216
+ keyboard() {
217
+ this.focusable();
218
+ if (this.keysOn || !this.element.addEventListener) return;
219
+ this.keysOn = true;
220
+ this.partsLive = this.live || liveRegionFor(this.element, true, this.p);
221
+ this.element.addEventListener('keydown', (event) => this.key(event));
222
+ }
223
+
224
+ key(event) {
225
+ if (!this.root || !this.root.contains(event.target)) return;
226
+ const at = this.active;
227
+ let next;
228
+ switch (event.key) {
229
+ case 'ArrowDown': next = at ? this.partsOf(at)[0] : this.partsOf(this.tree)[0]; break;
230
+ case 'ArrowUp': next = at ? this.outerOf(at) : undefined; break;
231
+ case 'ArrowRight': next = at ? this.neighbour(at, 1) : this.partsOf(this.tree)[0]; break;
232
+ case 'ArrowLeft': next = at ? this.neighbour(at, -1) : undefined; break;
233
+ case 'Home': case 'Escape': next = null; break;
234
+ case 'F2':
235
+ if (!this.editing) return;
236
+ event.preventDefault();
237
+ this.edit(at || this.tree, { returnFocus: true });
238
+ return;
239
+ case 'Enter': case ' ': {
240
+ event.preventDefault();
241
+ const node = at || this.tree;
242
+ const element = this.elementOf(node) || this.root;
243
+ const info = { node, id: node.id, path: pathOf(this.tree, node.id), element, event };
244
+ for (const fn of this.clicks) fn(info);
245
+ return;
246
+ }
247
+ default: return;
248
+ }
249
+ event.preventDefault();
250
+ if (next === undefined) return; // nowhere to go: stay put
251
+ this.focusPart(next);
252
+ }
253
+
254
+ /** Put the keyboard on a part (null: the whole expression) and say it. */
255
+ focusPart(node) {
256
+ const cls = `${this.p}-focus`;
257
+ this.root.querySelectorAll(`.${cls}`).forEach((e) => e.classList.remove(cls));
258
+ this.active = node;
259
+ const element = node && this.elementOf(node);
260
+ if (element) element.classList.add(cls);
261
+ let words = '';
262
+ try { words = node ? toAudioSpeech(node) : this.spoken(); } catch { words = ''; }
263
+ this.say(this.partsLive, words);
264
+ return this;
265
+ }
266
+
267
+ /** The parts directly inside a node that are drawn (an undrawn wrapper is looked through). */
268
+ partsOf(node) {
269
+ const out = [];
270
+ const visit = (n) => { if (this.elementOf(n)) out.push(n); else if (n.type === 'op') n.args.forEach(visit); };
271
+ if (node && node.type === 'op') node.args.forEach(visit);
272
+ return out;
273
+ }
274
+
275
+ /** The drawn part a node sits in, or null for the whole expression. */
276
+ outerOf(node) {
277
+ const path = pathOf(this.tree, node.id);
278
+ for (let k = path ? path.length - 1 : -1; k > 0; k--) {
279
+ const up = nodeAt(this.tree, path.slice(0, k));
280
+ if (up && this.elementOf(up)) return up;
281
+ }
282
+ return null;
283
+ }
284
+
285
+ /** The part beside this one (dir 1 next, -1 previous), or undefined at the end. */
286
+ neighbour(node, dir) {
287
+ const siblings = this.partsOf(this.outerOf(node) || this.tree);
288
+ const i = siblings.indexOf(node);
289
+ return i < 0 ? undefined : siblings[i + dir];
290
+ }
291
+
292
+ /** The expression read aloud in pieces, each tagged with the node it describes. */
293
+ segments(options = {}) {
294
+ return toSpeechSegments(this.tree, options);
295
+ }
296
+
297
+ /** Light up the node a spoken piece is about (for karaoke-style narration). */
298
+ showSegment(segment, className = `${this.p}-hl`) {
299
+ this.unhighlight(null, className);
300
+ if (segment && segment.id !== undefined) this.highlight(segment.id, className);
301
+ return this;
302
+ }
303
+
304
+ // ---- animation between steps --------------------------------------------
305
+
306
+ /** Where each drawn part is now, grouped by what it looks like. */
307
+ snapshot() {
308
+ const bySignature = new Map();
309
+ const visit = (n) => {
310
+ const e = this.elementOf(n);
311
+ if (e) {
312
+ const sig = signature(n);
313
+ if (!bySignature.has(sig)) bySignature.set(sig, []);
314
+ bySignature.get(sig).push(e.getBoundingClientRect());
315
+ }
316
+ if (n.type === 'op') n.args.forEach(visit);
317
+ };
318
+ visit(this.tree);
319
+ return bySignature;
320
+ }
321
+
322
+ /**
323
+ * Move each part of the new expression that was also in the old one from
324
+ * its old place (largest matching parts first), and fade in the rest.
325
+ */
326
+ morph(before, { duration, easing }) {
327
+ if (typeof Element === 'undefined' || !Element.prototype.animate) return;
328
+ const visit = (n) => {
329
+ const e = this.elementOf(n);
330
+ const old = e && before.get(signature(n));
331
+ if (old && old.length) {
332
+ const from = old.shift();
333
+ const to = e.getBoundingClientRect();
334
+ const dx = from.left - to.left;
335
+ const dy = from.top - to.top;
336
+ const sx = to.width ? from.width / to.width : 1;
337
+ const sy = to.height ? from.height / to.height : 1;
338
+ if (Math.abs(dx) + Math.abs(dy) > 0.5 || Math.abs(sx - 1) + Math.abs(sy - 1) > 0.01) {
339
+ e.animate([
340
+ { transformOrigin: '0 0', transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})` },
341
+ { transformOrigin: '0 0', transform: 'none' },
342
+ ], { duration, easing });
343
+ }
344
+ return;
345
+ }
346
+ if (n.type === 'op') n.args.forEach(visit);
347
+ else if (e) e.animate([{ opacity: 0 }, { opacity: 0 }, { opacity: 1 }], { duration, easing });
348
+ };
349
+ visit(this.tree);
350
+ }
351
+ }
352
+
353
+ /**
354
+ * The live region steps are announced through: the element given, or a visually hidden polite
355
+ * region created right after the view's element (its contents are replaced by every set()).
356
+ */
357
+ function liveRegionFor(element, announce, prefix) {
358
+ if (!announce) return null;
359
+ if (typeof announce === 'object' && announce.nodeType === 1) return announce;
360
+ if (typeof document === 'undefined' || !element.insertAdjacentElement) return null;
361
+ const live = document.createElement('span');
362
+ live.className = `${prefix}-sr-only`;
363
+ live.setAttribute('aria-live', 'polite');
364
+ live.setAttribute('aria-atomic', 'true');
365
+ element.insertAdjacentElement('afterend', live);
366
+ return live;
367
+ }
368
+
369
+ /**
370
+ * Should a step animate? true honours prefers-reduced-motion (no motion for a reader who asked
371
+ * for none), 'always' does not, false never animates.
372
+ */
373
+ export function shouldAnimate(animate) {
374
+ if (!animate) return false;
375
+ if (animate === 'always') return true;
376
+ return !(typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches);
377
+ }
378
+
379
+ /** Draw an expression into an element and return a View for it. */
380
+ export function render(element, input, options = {}) {
381
+ return new View(element, input, options);
382
+ }