@mathzetta/inlinemath 0.1.0 → 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 (43) hide show
  1. package/README.md +64 -20
  2. package/dist/cas.external.js +2 -2
  3. package/dist/cas.js +2 -2
  4. package/dist/chunks/calculate-Cz87Lt0N.js +2 -0
  5. package/dist/chunks/draw-Bev17BJJ.js +2 -0
  6. package/dist/chunks/edit-C65xXzo8.js +2 -0
  7. package/dist/chunks/edit-M68aRA5o.js +2 -0
  8. package/dist/chunks/{html-CvuSyhzk.js → html-BimAqn7x.js} +2 -2
  9. package/dist/chunks/html-CupluJSh.js +2 -0
  10. package/dist/chunks/lite-3Fb-OHbt.js +2 -0
  11. package/dist/inlinemath-cas.external.global.min.js +2 -2
  12. package/dist/inlinemath-cas.global.min.js +2 -2
  13. package/dist/inlinemath-lite-calc.global.min.js +2 -2
  14. package/dist/inlinemath-lite.external.global.min.js +2 -2
  15. package/dist/inlinemath-lite.global.min.js +2 -2
  16. package/dist/inlinemath.css +32 -1
  17. package/dist/inlinemath.external.global.min.js +2 -2
  18. package/dist/inlinemath.external.js +2 -2
  19. package/dist/inlinemath.global.min.js +2 -2
  20. package/dist/inlinemath.js +2 -2
  21. package/dist/lite-calc.js +2 -2
  22. package/dist/lite.external.js +2 -2
  23. package/dist/lite.js +2 -2
  24. package/dist/markdown.external.js +2 -2
  25. package/dist/markdown.js +2 -2
  26. package/inlinemath.css +32 -1
  27. package/package.json +3 -3
  28. package/src/cas.js +25 -15
  29. package/src/edit.js +96 -0
  30. package/src/glyphs.js +2 -0
  31. package/src/index.d.ts +2 -2
  32. package/src/index.js +1 -1
  33. package/src/layout.js +55 -11
  34. package/src/markdown.d.ts +2 -2
  35. package/src/markdown.js +354 -39
  36. package/src/mathzetta.js +2 -1
  37. package/src/types.d.ts +77 -6
  38. package/src/variables.js +228 -0
  39. package/src/view.js +226 -6
  40. package/dist/chunks/calculate-BX9lJ7Ff.js +0 -2
  41. package/dist/chunks/draw-Cm4exbg7.js +0 -2
  42. package/dist/chunks/html-DWtfytjA.js +0 -2
  43. package/dist/chunks/lite-Bj_L6w2g.js +0 -2
package/src/markdown.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  // '@mathzetta/inlinemath/markdown' — Markdown with maths. See types.d.ts.
2
- export type { MarkdownOptions } from './types.js';
3
- export { markdownToHTML, renderMarkdownInto, extractMath, renderMarkdown } from './types.js';
2
+ export type { MarkdownOptions, VariableDefinition } from './types.js';
3
+ export { markdownToHTML, renderMarkdownInto, extractMath, renderMarkdown, createVariables, variables, Variables } from './types.js';
package/src/markdown.js CHANGED
@@ -7,8 +7,15 @@
7
7
  * Maths: $…$ inline, $$…$$ (or a ```math fence) on its own, \(…\) and \[…\] too.
8
8
  * Each is read by MathZetta (LaTeX, plain text or code all work) and drawn by
9
9
  * Inline Math; {{…}} inside is worked out first (MathZetta's calculator, or the CAS:
10
- * { blocks: casBlocks() } from '@mathzetta/inlinemath/cas'). A definition such as $r = 3$ or
11
- * $g := 9.81$ is remembered for the {{…}} after it ({ define: false } turns that off).
10
+ * { blocks: casBlocks() } from '@mathzetta/inlinemath/cas'). A definition such as $r = 3$ is
11
+ * remembered, as a number, for the {{…}} after it ({ define: false } turns that off).
12
+ *
13
+ * Saved variables: $A := 2+2$ saves A (and draws A ≔ 2 + 2); $_A := 2+2$ saves it without drawing.
14
+ * After it, A in any maths is drawn as its expression and {{A}} is its value: $A = {{A}}$ draws
15
+ * 2 + 2 = 4. $A := [[2+2]]$ is a textbox that sets A, [[2+2 | x^2]] a dropdown, [[1..10]] a slider
16
+ * ([[0..1 step 0.1 = 0.5]]). renderMarkdownInto keeps every part of a page in one store
17
+ * (`variables`), so a variable saved in one part is used in the others, and changing an input
18
+ * redraws everything that uses it.
12
19
  *
13
20
  * A $ is maths only when it looks like it (Pandoc's rule): the opening $ is not
14
21
  * followed by a space, the closing $ is not after a space or before a digit, so
@@ -21,8 +28,14 @@
21
28
  * the maths is taken out first and put back into its output.
22
29
  */
23
30
 
24
- import { toHTML } from './html.js';
31
+ import { toHTML, treeOf } from './html.js';
25
32
  import { calculate } from './calculator.js';
33
+ import { nameOf, bare, substitute, unexpand, inputOf, EXPRESSIONS, variables as pageVariables } from './variables.js';
34
+ import { toPostfix } from './mathzetta.js';
35
+ import { numberTree, select, pathOf, nodeAt } from './select.js';
36
+ import { openEditor, replaceAt } from './edit.js';
37
+
38
+ export { createVariables, variables, Variables } from './variables.js';
26
39
 
27
40
  // ---- finding the maths --------------------------------------------------------------------
28
41
 
@@ -34,7 +47,7 @@ const PLACEHOLDER = new RegExp(`${OPEN}(\\d+)${CLOSE}`, 'g');
34
47
  /**
35
48
  * The maths in a Markdown text, and the text with each piece replaced by a
36
49
  * placeholder (private-use characters, which no Markdown renderer touches).
37
- * @returns {{ text: string, math: Array<{ source, display, raw }> }}
50
+ * @returns {{ text: string, math: Array<{ source, display, raw, start, end }> }} (start, end: where the raw text is)
38
51
  */
39
52
  export function extractMath(markdown, { delimiters = ['$$', '$', '\\[', '\\('] } = {}) {
40
53
  const math = [];
@@ -43,7 +56,7 @@ export function extractMath(markdown, { delimiters = ['$$', '$', '\\[', '\\('] }
43
56
  const s = String(markdown);
44
57
  const atLineStart = (k) => k === 0 || s[k - 1] === '\n';
45
58
  const keep = (source, display, raw) => {
46
- math.push({ source, display, raw });
59
+ math.push({ source, display, raw, start: i, end: i + raw.length });
47
60
  return placeholder(math.length - 1);
48
61
  };
49
62
  while (i < s.length) {
@@ -132,30 +145,194 @@ function closingDollar(s, from) {
132
145
 
133
146
  const escape = (t) => String(t).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
134
147
 
135
- /** A definition: r = 3, g := 9.81 (a single name on the left). */
136
- const DEFINITION = /^\s*([A-Za-zͰ-Ͽ](?:_\{?\w+\}?)?)\s*(?::=|=)\s*([^=<>≤≥]+)$/;
148
+ /** A definition: r = 3, g = 9.81 (a single name on the left) — remembered as a number for {{…}}. */
149
+ const DEFINITION = /^\s*([A-Za-z\u0370-\u03ff](?:_\{?\w+\}?)?)\s*=\s*([^=<>≤≥]+)$/;
137
150
 
138
- function drawMath(item, options, scope) {
139
- const { source, display } = item;
140
- try {
141
- const html = toHTML(source, { ...options.math, display, blocks: options.blocks, scope });
142
- if (options.define !== false) define(source, scope);
143
- return display ? `<span class="mv-md-display">${html}</span>` : `<span class="mv-md-math">${html}</span>`;
144
- } catch (error) {
145
- return `<span class="mv-md-error" title="${escape(error.message)}"><code>${escape(item.raw)}</code></span>`;
146
- }
151
+ /**
152
+ * Maths that saves a variable: `A := 2+2` (drawn), `_A := 2+2` or `_A = 2+2` (not drawn),
153
+ * `A := [[…]]` (an input). Else null; `{ error }` for a `$_…$` that saves nothing.
154
+ */
155
+ function savingOf(source) {
156
+ let s = source.trim();
157
+ const hidden = s.startsWith('_');
158
+ if (hidden) s = s.slice(1).trimStart();
159
+ let at = s.indexOf(':=');
160
+ let width = 2;
161
+ if (at < 0 && hidden) {
162
+ at = s.search(/(?<![<>!≤≥=])=(?!=)/);
163
+ width = 1;
164
+ }
165
+ const nothing = hidden ? { error: 'a hidden $_…$ saves a variable: $_A := 2+2$' } : null;
166
+ if (at <= 0) return nothing;
167
+ const lhs = s.slice(0, at).trim();
168
+ const rhs = s.slice(at + width).trim();
169
+ let name = null;
170
+ try {
171
+ name = /\{\{/.test(lhs) ? null : nameOf(treeOf(lhs));
172
+ } catch {
173
+ // not a name
174
+ }
175
+ if (name === null || !rhs) return nothing;
176
+ return { hidden, lhs, name, rhs, input: inputOf(rhs) };
147
177
  }
148
178
 
149
- /** $r = 3$: remember r (exactly, as the calculator's text) for later {{…}}. */
150
- function define(source, scope) {
151
- const m = DEFINITION.exec(source);
152
- if (!m || /\{\{/.test(m[2])) return;
153
- try {
154
- const r = calculate(m[2], scope);
155
- if (typeof r.value === 'number' || typeof r.value === 'bigint') scope[m[1].replace(/[{}]/g, '')] = r.exact ? r.text : r.value;
156
- } catch {
157
- // not a number (x = y + 1): nothing to remember
158
- }
179
+ /** `A = …` drawn as `A ≔ …`: the first relation is the definition's. */
180
+ function asDefinition(html) {
181
+ return html
182
+ .replace('<span class="mv-rel">=</span>', '<span class="mv-rel mv-rel-def">≔</span>')
183
+ .replace(/(aria-label="[^"]*?) equals /, '$1 is defined as ')
184
+ .replace('<mo>=</mo>', '<mo>≔</mo>');
185
+ }
186
+
187
+ /** The input a definition's [[…]] asks for, holding `text`. */
188
+ function controlHTML(def, text) {
189
+ const { input, name } = def;
190
+ const attrs = `class="mv-md-input mv-md-${input.kind}" data-mv-var="${escape(name)}" aria-label="${escape(name)}"`;
191
+ if (input.kind === 'dropdown') {
192
+ const options = input.options.map((o) => `<option value="${escape(o)}"${o === text ? ' selected' : ''}>${escape(o)}</option>`);
193
+ return `<select ${attrs}>${options.join('')}</select>`;
194
+ }
195
+ if (input.kind === 'slider') {
196
+ return `<input type="range" ${attrs} min="${input.min}" max="${input.max}" step="${input.step}" value="${escape(text)}">`
197
+ + `<output class="mv-md-output" data-mv-out="${escape(name)}">${escape(text)}</output>`;
198
+ }
199
+ return `<input type="text" ${attrs} value="${escape(text)}" size="${Math.max(4, text.length + 1)}" spellcheck="false" autocomplete="off">`;
200
+ }
201
+
202
+ /**
203
+ * Every piece of maths drawn, in reading order: a variable is known to the maths after it, and
204
+ * the variables already in `store` (the other parts of the page) to all of it.
205
+ */
206
+ function drawAll(markdown, options, store, owner) {
207
+ const { text, math } = extractMath(markdown, options);
208
+ const defs = store ? store.definitions(owner) : new Map();
209
+ const textOf = (def) => (store ? store.textOf(def) : def.text);
210
+ const numbers = { ...(options.scope ?? {}) }; // options.scope and $r = 3$
211
+ const scope = {}; // what {{…}} sees: those and the saved variables' values
212
+ const mine = new Map();
213
+ const inputs = new Set();
214
+ const trees = []; // each piece's drawn tree, for finding what a double-click landed on
215
+ // the saved variables' values, for {{…}}: worked out until nothing more can be (B := 2A before A := 3)
216
+ const refresh = () => {
217
+ for (const k of Object.keys(scope)) delete scope[k];
218
+ Object.assign(scope, numbers);
219
+ for (const name of defs.keys()) delete scope[name]; // x^2 has no value: {{A}} draws A, drawn as x^2
220
+ let pending = [...defs.values()];
221
+ while (pending.length) {
222
+ const left = pending.filter((def) => {
223
+ try {
224
+ const r = calculate(textOf(def), scope);
225
+ // a number, or a matrix (or list): {{M^2}} works it out
226
+ if (typeof r.value === 'number' || typeof r.value === 'bigint' || Array.isArray(r.value)) {
227
+ scope[def.name] = r.exact ? r.text : r.value;
228
+ return false;
229
+ }
230
+ } catch {
231
+ // not (yet) a number
232
+ }
233
+ return true;
234
+ });
235
+ if (left.length === pending.length) break;
236
+ pending = left;
237
+ }
238
+ // and their expressions, for a CAS block: {{diff(f, x)}} differentiates what f stands for
239
+ const expressions = {};
240
+ const plain = lookupFor({});
241
+ for (const name of defs.keys()) {
242
+ const tree = plain(name);
243
+ if (!tree) continue;
244
+ try {
245
+ expressions[name] = toPostfix(substitute(tree, (n) => (n === name ? null : plain(n))).tree);
246
+ } catch {
247
+ // an expression that can't be written as postfix: the CAS sees the name
248
+ }
249
+ }
250
+ scope[EXPRESSIONS] = expressions;
251
+ };
252
+ const lookupFor = (drawOpts) => {
253
+ const trees = new Map();
254
+ return (name) => {
255
+ const def = defs.get(name);
256
+ if (!def) return null;
257
+ if (!trees.has(name)) {
258
+ let tree = null;
259
+ try {
260
+ tree = treeOf(textOf(def), drawOpts);
261
+ } catch {
262
+ // an input holding something that doesn't read: A is drawn as A
263
+ }
264
+ trees.set(name, tree);
265
+ }
266
+ return trees.get(name);
267
+ };
268
+ };
269
+
270
+ refresh();
271
+
272
+ const drawItem = (item, i) => {
273
+ const { source, display } = item;
274
+ const cls = display ? 'mv-md-display' : 'mv-md-math';
275
+ const wrap = (html, extra = '') => `<span class="${cls}${extra}" data-mv-md="${i}">${html}</span>`;
276
+ try {
277
+ const drawOpts = { ...options.math, display, blocks: options.blocks, scope };
278
+ const save = savingOf(source);
279
+ if (save && save.error) throw new Error(save.error);
280
+ if (save) {
281
+ const { name, input, hidden } = save;
282
+ const def = input
283
+ ? { name, text: input.default, input, key: `input:${JSON.stringify(input)}` }
284
+ : { name, text: save.rhs, input: null, key: `expr:${save.rhs}`, start: item.start, end: item.end };
285
+ let html = '';
286
+ if (input) {
287
+ const control = controlHTML(def, textOf(def));
288
+ html = hidden ? control : `${toHTML(save.lhs, drawOpts)}<span class="mv-md-assign" aria-hidden="true">≔</span>${control}`;
289
+ inputs.add(i);
290
+ } else {
291
+ const rhs = substitute(treeOf(save.rhs, drawOpts), lookupFor(drawOpts)).tree;
292
+ if (!hidden) {
293
+ const tree = numberTree({ type: 'op', op: '=', args: [bare(treeOf(save.lhs)), rhs.id === undefined ? rhs : bare(rhs)] });
294
+ trees[i] = tree;
295
+ html = asDefinition(toHTML(tree, drawOpts));
296
+ }
297
+ }
298
+ defs.delete(name);
299
+ defs.set(name, def);
300
+ mine.set(name, def);
301
+ refresh();
302
+ if (hidden && !input) return '';
303
+ return wrap(html, input ? ' mv-md-input-def' : '');
304
+ }
305
+ const { tree, changed } = substitute(treeOf(source, drawOpts), lookupFor(drawOpts));
306
+ if (changed) numberTree(tree);
307
+ trees[i] = tree;
308
+ const html = toHTML(tree, drawOpts);
309
+ if (!changed && options.define !== false && define(source, numbers, defs)) refresh();
310
+ return wrap(html);
311
+ } catch (error) {
312
+ return `<span class="mv-md-error" data-mv-md="${i}" title="${escape(error.message)}"><code>${escape(item.raw)}</code></span>`;
313
+ }
314
+ };
315
+ const drawn = math.map(drawItem);
316
+ return { text, math, drawn, defs: mine, inputs, trees };
317
+ }
318
+
319
+ /** $r = 3$ (or $A = \\begin{pmatrix}…$): remember r (exactly, as the calculator's text) for later {{…}} — unless r is a saved variable. */
320
+ function define(source, numbers, saved) {
321
+ const m = DEFINITION.exec(source);
322
+ if (!m || /\{\{/.test(m[2])) return false;
323
+ const name = m[1].replace(/[{}]/g, '');
324
+ if (saved.has(name)) return false;
325
+ try {
326
+ const r = calculate(m[2], numbers);
327
+ // a number, or a matrix (or list), as for saved variables
328
+ if (typeof r.value === 'number' || typeof r.value === 'bigint' || Array.isArray(r.value)) {
329
+ numbers[name] = r.exact ? r.text : r.value;
330
+ return true;
331
+ }
332
+ } catch {
333
+ // not a number (x = y + 1): nothing to remember
334
+ }
335
+ return false;
159
336
  }
160
337
 
161
338
  // ---- the Markdown renderer ----------------------------------------------------------------
@@ -329,6 +506,18 @@ export function renderMarkdown(markdown, options = {}, sourceOf = (t) => t) {
329
506
 
330
507
  // ---- together ---------------------------------------------------------------------------
331
508
 
509
+ /** The Markdown around the drawn maths. */
510
+ function assemble({ text, math, drawn }, options) {
511
+ const sourceOf = (t) => t.replace(PLACEHOLDER, (_, n) => math[Number(n)].raw);
512
+ const html = options.render ? options.render(text) : renderMarkdown(text, options, sourceOf);
513
+ return html
514
+ // display maths alone in a paragraph is a block of its own
515
+ .replace(new RegExp(`<p>\\s*${OPEN}(\\d+)${CLOSE}\\s*</p>`, 'g'), (whole, n) => (math[Number(n)].display && drawn[Number(n)] ? `<div class="mv-md-block">${drawn[Number(n)]}</div>` : whole))
516
+ .replace(PLACEHOLDER, (_, n) => drawn[Number(n)])
517
+ // a paragraph that only saved variables ($_A := 2+2$) leaves nothing behind
518
+ .replace(/<p>\s*<\/p>\n?/g, '');
519
+ }
520
+
332
521
  /**
333
522
  * Markdown with maths → HTML.
334
523
  * @param {string} markdown
@@ -337,25 +526,151 @@ export function renderMarkdown(markdown, options = {}, sourceOf = (t) => t) {
337
526
  * @param {Function} [options.blocks] the {{…}} handler (default the calculator; casBlocks() for the CAS)
338
527
  * @param {object} [options.scope] values for {{…}}: { r: 3 }
339
528
  * @param {boolean} [options.define] remember $r = 3$ for later {{…}} (default true)
529
+ * @param {object} [options.variables] a store (createVariables()) whose variables the maths may use
340
530
  * @param {boolean} [options.html] let raw HTML through (default false: it is shown as text)
341
531
  * @param {(markdown: string) => string} [options.render] another Markdown renderer
342
532
  * @param {string[]} [options.delimiters] which of '$$', '$', '\\[', '\\(' mark maths (default all)
343
533
  */
344
534
  export function markdownToHTML(markdown, options = {}) {
345
- const { text, math } = extractMath(markdown, options);
346
- const scope = { ...(options.scope ?? {}) };
347
- // drawn in reading order, so a definition is known to the maths after it
348
- const drawn = math.map((item) => drawMath(item, options, scope));
349
- const sourceOf = (t) => t.replace(PLACEHOLDER, (_, n) => math[Number(n)].raw);
350
- const html = options.render ? options.render(text) : renderMarkdown(text, options, sourceOf);
351
- return html
352
- // display maths alone in a paragraph is a block of its own
353
- .replace(new RegExp(`<p>\\s*${OPEN}(\\d+)${CLOSE}\\s*</p>`, 'g'), (whole, n) => (math[Number(n)].display ? `<div class="mv-md-block">${drawn[Number(n)]}</div>` : whole))
354
- .replace(PLACEHOLDER, (_, n) => drawn[Number(n)]);
535
+ return assemble(drawAll(markdown, options, options.variables || null), options);
355
536
  }
356
537
 
357
- /** Draw Markdown with maths into an element. */
538
+ const bindings = new WeakMap();
539
+ const live = new Set(); // every bound element's binding, to notice the ones taken off the page
540
+
541
+ /** Parts taken off the page take their variables with them. */
542
+ function sweep() {
543
+ for (const b of live) if (b.connected() && !b.element.isConnected) b.stop(true);
544
+ }
545
+
546
+ /**
547
+ * Draw Markdown with maths into an element, live: its variables join the page's store
548
+ * (options.variables, default `variables`; false for none), its inputs set them, and its maths is
549
+ * drawn again whenever a variable it can see changes. Drawing into the element again replaces it all.
550
+ * Double-clicking a part drawn from a textbox variable edits it ({ editable: { toText } }, toText
551
+ * from 'mathzetta/text'); any other part fires 'inlinemath:select' with where its maths is in the
552
+ * Markdown ({ index, start, end, raw }).
553
+ */
358
554
  export function renderMarkdownInto(element, markdown, options = {}) {
359
- element.innerHTML = markdownToHTML(markdown, options);
360
- return element;
555
+ const store = options.variables === undefined ? pageVariables : options.variables || null;
556
+ sweep();
557
+ const old = bindings.get(element);
558
+ if (old) old.stop(old.store !== store);
559
+ let result = drawAll(markdown, options, store, element);
560
+ element.innerHTML = assemble(result, options);
561
+ if (!store) {
562
+ bindings.delete(element);
563
+ return element;
564
+ }
565
+ store.own(element, result.defs);
566
+
567
+ let connected = element.isConnected;
568
+ const redraw = () => {
569
+ const next = drawAll(markdown, options, store, element);
570
+ next.drawn.forEach((html, i) => {
571
+ if (html === result.drawn[i] || next.inputs.has(i)) return;
572
+ const at = element.querySelector(`[data-mv-md="${i}"]`);
573
+ if (at) at.outerHTML = html;
574
+ });
575
+ // inputs for the same variable agree; a slider shows its value
576
+ for (const el of element.querySelectorAll('[data-mv-var], [data-mv-out]')) {
577
+ const def = next.defs.get(el.dataset.mvVar ?? el.dataset.mvOut);
578
+ if (!def) continue;
579
+ const text = store.textOf(def);
580
+ if (el.dataset.mvOut !== undefined) el.textContent = text;
581
+ else if (el.value !== text && el !== el.ownerDocument?.activeElement) el.value = text;
582
+ }
583
+ result = next;
584
+ };
585
+ const onInput = (event) => {
586
+ const el = event.target;
587
+ const name = el && el.dataset && el.dataset.mvVar;
588
+ if (name === undefined || !element.contains(el)) return;
589
+ if (el.type === 'text') {
590
+ try {
591
+ treeOf(el.value, { blocks: options.blocks, scope: {} });
592
+ el.removeAttribute('aria-invalid');
593
+ } catch {
594
+ el.setAttribute('aria-invalid', 'true'); // keeps what it held last
595
+ return;
596
+ }
597
+ }
598
+ store.set(name, el.value);
599
+ };
600
+ // double-click a part: a part that came from a textbox variable is edited there (in place,
601
+ // with options.editable = { toText }; else the textbox is focused); anything else tells the
602
+ // page where that maths is in the Markdown, as an 'inlinemath:select' event
603
+ const onDoubleClick = (event) => {
604
+ const part = event.target.closest?.('[data-node]');
605
+ const piece = part && part.closest('[data-mv-md]');
606
+ if (!piece || !element.contains(piece)) return;
607
+ const index = Number(piece.dataset.mvMd);
608
+ const tree = result.trees[index];
609
+ if (!tree) return;
610
+ const id = Number(part.dataset.node);
611
+ const path = pathOf(tree, id) || [];
612
+ // the innermost variable the part sits in
613
+ let at = null;
614
+ for (let k = path.length; k >= 0 && !at; k--) {
615
+ const n = nodeAt(tree, path.slice(0, k));
616
+ if (n && n.from !== undefined) at = { node: n, depth: k };
617
+ }
618
+ const def = at && store.definitions().get(at.node.from);
619
+ if (def && def.input && def.input.kind === 'textbox') {
620
+ event.preventDefault();
621
+ const name = def.name;
622
+ const box = element.ownerDocument.querySelector(`[data-mv-var="${CSS.escape(name)}"]`);
623
+ const toText = options.editable && options.editable.toText;
624
+ if (!toText) {
625
+ if (box) { box.focus(); box.select(); }
626
+ return;
627
+ }
628
+ const named = (n) => treeOf(n);
629
+ const node = nodeAt(tree, path);
630
+ openEditor(part, toText(unexpand(node, named)), {
631
+ label: `Edit ${name}`,
632
+ parse: (text) => treeOf(text, { blocks: options.blocks }),
633
+ commit: (typed) => {
634
+ const whole = replaceAt(unexpand(at.node, named), path.slice(at.depth), typed);
635
+ store.set(name, toText(whole));
636
+ },
637
+ });
638
+ return;
639
+ }
640
+ // a saved variable defined here: point at its definition rather than where it is used
641
+ const here = def && result.defs.get(def.name);
642
+ const item = here && here.key === def.key && here.start !== undefined
643
+ ? { ...result.math[index], start: here.start, end: here.end, raw: markdown.slice(here.start, here.end) }
644
+ : result.math[index];
645
+ element.dispatchEvent(new CustomEvent('inlinemath:select', {
646
+ bubbles: true,
647
+ detail: { index, start: item.start, end: item.end, raw: item.raw, source: item.source, id, path },
648
+ }));
649
+ };
650
+ element.addEventListener('input', onInput);
651
+ element.addEventListener('change', onInput);
652
+ element.addEventListener('dblclick', onDoubleClick);
653
+ const unsubscribe = store.subscribe((from) => {
654
+ if (from === element) return;
655
+ if (!element.isConnected) {
656
+ if (connected) stop(true); // taken off the page: its variables go with it
657
+ return;
658
+ }
659
+ connected = true;
660
+ sweep();
661
+ redraw();
662
+ });
663
+ function stop(drop) {
664
+ unsubscribe();
665
+ element.removeEventListener('input', onInput);
666
+ element.removeEventListener('change', onInput);
667
+ element.removeEventListener('dblclick', onDoubleClick);
668
+ live.delete(binding);
669
+ bindings.delete(element);
670
+ if (drop) store.own(element, null);
671
+ }
672
+ const binding = { element, store, stop, connected: () => connected };
673
+ live.add(binding);
674
+ bindings.set(element, binding);
675
+ return element;
361
676
  }
package/src/mathzetta.js CHANGED
@@ -13,5 +13,6 @@ export {
13
13
  operator, precedenceOf, isCustomFunction, VARIADIC,
14
14
  GREEK, needsBrackets, unaryNeedsBrackets, isMinusOneTimes, isImplicitProduct, isVariable,
15
15
  firstLeaf, customName, derivativeOrder, bracketedSides, bracketOp,
16
+ MATRICES, matrixRows,
16
17
  } from 'mathzetta';
17
- export { toSpeech, toSpeechSegments } from 'mathzetta/speech';
18
+ export { toSpeech, toSpeechSegments, toAudioSpeech } from 'mathzetta/speech';
package/src/types.d.ts CHANGED
@@ -30,6 +30,9 @@ export interface LayoutOptions {
30
30
  cases?: boolean;
31
31
  /** The accessible name: true = the spoken form, a string, a function of the tree, or false for none. */
32
32
  label?: boolean | string | ((tree: Node) => string);
33
+ /** MathZetta's toMathML (from 'mathzetta/mathml'): also write the expression as visually hidden MathML, which
34
+ * screen readers can step through and show in math braille; it takes the place of the label. Off by default. */
35
+ mathml?: ((input: Node) => string) | null;
33
36
  /** Extra attributes for a node's element. */
34
37
  attributes?: ((node: Node) => Record<string, string>) | null;
35
38
  /** Inline style for a node's element. */
@@ -47,6 +50,13 @@ export interface DrawOptions extends LayoutOptions {
47
50
  blocks?: BlockHandler;
48
51
  }
49
52
 
53
+ /** Options for a live view: everything toHTML takes, plus how steps reach a screen reader. */
54
+ export interface ViewOptions extends DrawOptions {
55
+ /** Read each new step (every set() after the first) to a screen reader: true places a polite live
56
+ * region after the element; or pass your own live-region element. Off by default. */
57
+ announce?: boolean | Element;
58
+ }
59
+
50
60
  export const DEFAULTS: Required<LayoutOptions>;
51
61
 
52
62
  /** Anything MathZetta reads, as HTML (a string; works in Node). Needs inlinemath.css on the page. */
@@ -71,11 +81,16 @@ export interface ClickInfo { node: Node; id: number; path: number[] | null; elem
71
81
 
72
82
  /** A live drawing: highlight, colour, listen, step with animation. */
73
83
  export class View {
74
- constructor(element: Element, input: Input, options?: DrawOptions);
84
+ constructor(element: Element, input: Input, options?: ViewOptions);
75
85
  readonly element: Element;
76
86
  readonly tree: Node;
77
- /** Show a new expression; with animate, matching parts glide over and new ones fade in. */
78
- set(input: Input, options?: { animate?: boolean; duration?: number; easing?: string }): this;
87
+ /** Show a new expression; with animate, matching parts glide over and new ones fade in. `true` is instant
88
+ * when the reader's system asks for reduced motion; `'always'` animates regardless. */
89
+ set(input: Input, options?: { animate?: boolean | 'always'; duration?: number; easing?: string }): this;
90
+ /** The expression as a listener hears it (its accessible name, else MathZetta's audio speech). */
91
+ spoken(): string;
92
+ /** Say something through the live region ({ announce } must be set); steps are announced this way. */
93
+ announce(text: string): this;
79
94
  nodes(target: Target): Node[];
80
95
  elements(target: Target): Element[];
81
96
  elementOf(node: Node): Element | null;
@@ -83,13 +98,30 @@ export class View {
83
98
  unhighlight(target?: Target | null, className?: string): this;
84
99
  color(target: Target, colour: string): this;
85
100
  /** Listen for an event on a part; returns a function that stops listening. */
101
+ /** Listen for an event on the parts. A 'click' listener also makes the drawing work from the keyboard:
102
+ * one tab stop, arrow keys walk the parts (each spoken), Enter or Space calls the listener for the
103
+ * current part — `event` is then the KeyboardEvent. */
86
104
  on(type: string, fn: (info: ClickInfo) => void): () => void;
105
+ /**
106
+ * Let the reader edit a part at a time: double-click it (or F2 on the keyboard's part) and a
107
+ * text box opens over it; Enter puts the new text in its place, Escape leaves it.
108
+ * toText is MathZetta's (import { toText } from 'mathzetta/text'; InlineMath.toText in the <script> build).
109
+ */
110
+ editable(how: { toText: (input: Input) => string; onEdit?: (change: { tree: Node; text: string; path: number[]; part: string }) => void }): this;
111
+ /** Open the editing box over a part (editable() first). */
112
+ edit(target?: unknown, options?: { returnFocus?: boolean }): HTMLInputElement | null;
113
+ /** The part the arrow keys are on (null: the whole expression). */
114
+ readonly active: Node | null;
115
+ /** Put the keyboard on a part (null: the whole expression) and say it. */
116
+ focusPart(node: Node | null): this;
87
117
  /** The expression read aloud in pieces, each tagged with the node it describes. */
88
118
  segments(options?: SpeechOptions): { text: string; id?: number }[];
89
119
  showSegment(segment: { id?: number } | null, className?: string): this;
90
120
  }
91
121
  /** Draw into an element and return the live view. */
92
- export function render(element: Element, input: Input, options?: DrawOptions): View;
122
+ export function render(element: Element, input: Input, options?: ViewOptions): View;
123
+ /** Should a step animate: true honours prefers-reduced-motion, 'always' does not, false never. */
124
+ export function shouldAnimate(animate?: boolean | 'always'): boolean;
93
125
 
94
126
  // ---- inlinemath/markdown --------------------------------------------------------------------
95
127
 
@@ -108,10 +140,48 @@ export interface MarkdownOptions {
108
140
  render?: (markdown: string) => string;
109
141
  /** Which of '$$', '$', '\\[', '\\(' mark maths (default all). */
110
142
  delimiters?: string[];
143
+ /**
144
+ * The saved variables ($A := 2+2$) the maths may use. markdownToHTML: none by default;
145
+ * renderMarkdownInto: the page's `variables` by default, false for none.
146
+ */
147
+ variables?: Variables | false | null;
148
+ /**
149
+ * renderMarkdownInto: double-clicking a part drawn from a textbox variable edits it in place
150
+ * (toText from 'mathzetta/text'); without it the textbox is focused. Any other part fires
151
+ * 'inlinemath:select' (detail { index, start, end, raw, source, id, path }) on the element.
152
+ */
153
+ editable?: { toText: (input: Input) => string };
154
+ }
155
+ /** What a saved variable is: its expression, or the input that sets it. */
156
+ export interface VariableDefinition {
157
+ name: string;
158
+ text: string;
159
+ input: null | { kind: 'textbox'; default: string } | { kind: 'dropdown'; options: string[]; default: string } | { kind: 'slider'; min: number; max: number; step: number; default: string };
160
+ key: string;
161
+ }
162
+ /** A page's saved variables: every part drawn with renderMarkdownInto sees the others'. */
163
+ export class Variables {
164
+ /** A variable's expression now (what its input holds, for an input), or undefined. */
165
+ get(name: string): string | undefined;
166
+ /** Set a variable: what its input holds, or (with no input) a definition for the whole page. */
167
+ set(name: string, text: string | number): this;
168
+ /** Forget a variable set with set(), or everything. */
169
+ clear(name?: string): this;
170
+ /** The saved names. */
171
+ names(): string[];
172
+ /** Called whenever a variable changes; returns a function that stops it. */
173
+ subscribe(fn: (owner: unknown, name?: string) => void): () => void;
174
+ definitions(except?: unknown): Map<string, VariableDefinition>;
175
+ textOf(def: VariableDefinition): string;
176
+ own(owner: unknown, defs: Map<string, VariableDefinition> | null): void;
111
177
  }
178
+ /** A store of its own, for parts of a page that shouldn't share variables. */
179
+ export function createVariables(): Variables;
180
+ /** The page's store, renderMarkdownInto's default. */
181
+ export const variables: Variables;
112
182
  export function markdownToHTML(markdown: string, options?: MarkdownOptions): string;
113
183
  export function renderMarkdownInto<E extends Element>(element: E, markdown: string, options?: MarkdownOptions): E;
114
- export function extractMath(markdown: string, options?: { delimiters?: string[] }): { text: string; math: Array<{ source: string; display: boolean; raw: string }> };
184
+ export function extractMath(markdown: string, options?: { delimiters?: string[] }): { text: string; math: Array<{ source: string; display: boolean; raw: string; start: number; end: number }> };
115
185
  export function renderMarkdown(markdown: string, options?: MarkdownOptions): string;
116
186
 
117
187
  // ---- inlinemath/cas (needs @mathzetta/cas) --------------------------------------------------------
@@ -122,7 +192,8 @@ export interface CASStep { id: string; name: string; from: string; to: string; p
122
192
  export type CAS = Record<string, any>;
123
193
  export function mathCAS(options?: { rules?: unknown[]; names?: string[]; complex?: boolean; [option: string]: unknown }): CAS;
124
194
  /** A `{{…}}` handler: toHTML(input, { blocks: casBlocks() }). `last` holds the latest CAS result. */
125
- export function casBlocks(options?: { cas?: CAS }): BlockHandler & { last?: unknown };
195
+ /** steps: how much working each call keeps (true: every step, 'brief': the ones that carry the idea, false: none). */
196
+ export function casBlocks(options?: { cas?: CAS; steps?: boolean | 'brief' | Record<string, unknown> }): BlockHandler & { last?: { steps?: CASStep[]; text?: string; [k: string]: unknown } };
126
197
  /** Steps as an ordered list, each rewrite highlighted, with its explanation. */
127
198
  export function stepsHTML(steps: CASStep[], options?: DrawOptions): string;
128
199
  /** A CAS node → MathZetta postfix tokens. */