@barocss/math-editor 0.4.0 → 0.6.1

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 (76) hide show
  1. package/API-SESSION.md +35 -1
  2. package/API-WEB-COMPONENT.md +2 -0
  3. package/CHANGELOG.md +109 -0
  4. package/CLIPBOARD.md +54 -0
  5. package/EDITING-SCENARIOS.md +209 -0
  6. package/GETTING-STARTED.md +54 -0
  7. package/IMPLEMENTATION.md +105 -0
  8. package/KEYBOARD.md +41 -0
  9. package/LATEX-GUIDE.md +52 -2
  10. package/LATEX-MODEL.md +12 -0
  11. package/LATEX-SCOPE.md +10 -1
  12. package/README.md +119 -23
  13. package/RELEASING.md +95 -10
  14. package/RENDERING-TESTS.md +79 -0
  15. package/ROADMAP.md +238 -6
  16. package/STYLING.md +28 -1
  17. package/TEXT-EDITORS.md +157 -0
  18. package/VALIDATION.md +433 -0
  19. package/dist/context-tools.d.ts +21 -0
  20. package/dist/context-tools.js +37 -0
  21. package/dist/dom/caret-geometry.js +1 -1
  22. package/dist/dom/context-keyboard.d.ts +2 -0
  23. package/dist/dom/context-keyboard.js +22 -0
  24. package/dist/dom/help.d.ts +5 -0
  25. package/dist/dom/help.js +104 -0
  26. package/dist/dom/menu-position.d.ts +3 -0
  27. package/dist/dom/menu-position.js +49 -4
  28. package/dist/dom/readable-layout.d.ts +3 -0
  29. package/dist/dom/readable-layout.js +52 -0
  30. package/dist/dom/toolbar-catalog.d.ts +1 -1
  31. package/dist/dom/toolbar.d.ts +2 -0
  32. package/dist/dom/toolbar.js +8 -1
  33. package/dist/dom.d.ts +13 -1
  34. package/dist/dom.js +323 -51
  35. package/dist/enter-policy.js +1 -1
  36. package/dist/latex.js +11 -0
  37. package/dist/lines.d.ts +2 -0
  38. package/dist/lines.js +15 -0
  39. package/dist/locales/en.js +33 -3
  40. package/dist/locales/en.json +33 -3
  41. package/dist/locales/ko.js +33 -3
  42. package/dist/locales/ko.json +33 -3
  43. package/dist/math-editor-toolbar.js +2 -1
  44. package/dist/math-editor.d.ts +4 -1
  45. package/dist/math-editor.js +506 -296
  46. package/dist/math-layout.d.ts +12 -0
  47. package/dist/math-layout.js +59 -0
  48. package/dist/math-spacing.d.ts +13 -0
  49. package/dist/math-spacing.js +87 -0
  50. package/dist/model.d.ts +6 -0
  51. package/dist/model.js +72 -5
  52. package/dist/range.d.ts +10 -2
  53. package/dist/range.js +31 -6
  54. package/dist/root-transform.d.ts +19 -0
  55. package/dist/root-transform.js +69 -0
  56. package/dist/selection-shortcuts.d.ts +13 -0
  57. package/dist/selection-shortcuts.js +35 -0
  58. package/dist/session.d.ts +1 -0
  59. package/dist/session.js +1 -1
  60. package/dist/suggestions.d.ts +9 -0
  61. package/dist/suggestions.js +53 -0
  62. package/dist/symbols.d.ts +1 -1
  63. package/dist/symbols.js +1 -0
  64. package/dist/vertical-navigation.d.ts +5 -0
  65. package/dist/vertical-navigation.js +33 -0
  66. package/dist/web-component.js +9 -1
  67. package/package.json +11 -3
  68. package/src/fonts/KaTeX_Math-Italic.woff2 +0 -0
  69. package/src/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  70. package/src/fonts/README.md +18 -0
  71. package/src/shapes/README.md +21 -0
  72. package/src/shapes/parenthesis-bottom.svg +1 -0
  73. package/src/shapes/parenthesis-top.svg +1 -0
  74. package/src/shapes/parenthesis.svg +1 -0
  75. package/src/shapes/radical.svg +1 -0
  76. package/src/style.css +882 -61
@@ -0,0 +1,12 @@
1
+ import { type MathDocument } from './model.js';
2
+ export type MathLayoutStyle = 'display' | 'text' | 'script' | 'scriptscript';
3
+ /** Resolve mathematical style from model slots, independently of DOM ancestry.
4
+ * These values control operator presentation only; they are not saved in JSON.
5
+ */
6
+ export declare function mathLayout(document: MathDocument, mode?: 'block' | 'inline'): {
7
+ rows: Map<string, MathLayoutStyle>;
8
+ operators: Map<string, {
9
+ style: MathLayoutStyle;
10
+ placement: "side" | "stacked";
11
+ }>;
12
+ };
@@ -0,0 +1,59 @@
1
+ import { documentRows, isGrid, } from './model.js';
2
+ const scriptStyle = (style) => style === 'display' || style === 'text' ? 'script' : 'scriptscript';
3
+ const large = new Set([
4
+ 'sum',
5
+ 'product',
6
+ 'integral',
7
+ 'doubleIntegral',
8
+ 'tripleIntegral',
9
+ 'contourIntegral',
10
+ ]);
11
+ const limits = new Set(['limit', 'limsup', 'liminf']);
12
+ /** Resolve mathematical style from model slots, independently of DOM ancestry.
13
+ * These values control operator presentation only; they are not saved in JSON.
14
+ */
15
+ export function mathLayout(document, mode = 'block') {
16
+ const rows = new Map();
17
+ const operators = new Map();
18
+ const visit = (row, style) => {
19
+ rows.set(row.id, style);
20
+ for (const node of row.children) {
21
+ if (node.type === 'text')
22
+ continue;
23
+ const own = !isGrid(node) && node.mathStyle ? node.mathStyle : style;
24
+ if (large.has(node.type) || limits.has(node.type)) {
25
+ const integral = node.type.toLowerCase().includes('integral');
26
+ const explicit = !isGrid(node) ? node.limits : undefined;
27
+ operators.set(node.id, {
28
+ style: own,
29
+ placement: explicit === true || (explicit !== false && own === 'display' && !integral)
30
+ ? 'stacked'
31
+ : 'side',
32
+ });
33
+ }
34
+ node.slots.forEach((slot, index) => visit(slot, slotStyle(node, index, own)));
35
+ }
36
+ };
37
+ documentRows(document).forEach((row) => visit(row, mode === 'inline' ? 'text' : 'display'));
38
+ return { rows, operators };
39
+ }
40
+ function slotStyle(node, index, style) {
41
+ if (node.type === 'fraction' || node.type === 'binomial')
42
+ return style === 'display' ? 'text' : scriptStyle(style);
43
+ if (['superscript', 'subscript', 'scripts'].includes(node.type))
44
+ return index === 0 ? style : scriptStyle(style);
45
+ if (node.type === 'indexedRoot' && index === 0)
46
+ return 'scriptscript';
47
+ if (large.has(node.type))
48
+ return index < 2 ? scriptStyle(style) : style;
49
+ if (limits.has(node.type))
50
+ return index === 0 ? scriptStyle(style) : style;
51
+ if (['overset', 'underset', 'overbrace', 'underbrace', 'xrightarrow', 'xleftarrow'].includes(node.type))
52
+ return node.type.startsWith('x') || index === 0 ? scriptStyle(style) : style;
53
+ // AMS aligned cells explicitly enter display style, even inside a fraction.
54
+ if (node.type === 'aligned')
55
+ return 'display';
56
+ if (node.type === 'matrix' || node.type === 'cases')
57
+ return 'text';
58
+ return style;
59
+ }
@@ -0,0 +1,13 @@
1
+ import type { MathRow } from './model.js';
2
+ export interface RunSpacing {
3
+ before: number;
4
+ after: number;
5
+ tightBefore?: number;
6
+ tightAfter?: number;
7
+ }
8
+ /** Text-run spacing in math units (18 mu = 1 em).
9
+ * Empty caret boundaries do not participate. Named functions and fractions
10
+ * share run spacing. Other structures still own their outer spacing.
11
+ * Literal text rows must not use these values.
12
+ */
13
+ export declare function mathRunSpacing(row: MathRow): Map<string, RunSpacing>;
@@ -0,0 +1,87 @@
1
+ import { tokenizeMathText } from './tokens.js';
2
+ const binary = new Set(Array.from('+−-±∓×·÷∗∪∩∖⊕⊗∧∨'));
3
+ const relation = new Set(Array.from('=<>≤≥⩽⩾≠≈≃≅∼≡∝∈∉∋⊂⊃⊆⊇⊈⊥∥←→↔↑↓↕⇐⇒⇔⇑⇓⇕↦'));
4
+ function atom(text) {
5
+ if (binary.has(text))
6
+ return 'binary';
7
+ if (relation.has(text) || /^[\u2190-\u21ff\u27f0-\u27ff\u2900-\u297f]$/u.test(text))
8
+ return 'relation';
9
+ if ('([{'.includes(text) && text.length === 1)
10
+ return 'open';
11
+ if (')]}'.includes(text) && text.length === 1)
12
+ return 'close';
13
+ if (text === ',' || text === ';')
14
+ return 'punctuation';
15
+ return 'ordinary';
16
+ }
17
+ /** Text-run spacing in math units (18 mu = 1 em).
18
+ * Empty caret boundaries do not participate. Named functions and fractions
19
+ * share run spacing. Other structures still own their outer spacing.
20
+ * Literal text rows must not use these values.
21
+ */
22
+ export function mathRunSpacing(row) {
23
+ const entries = row.children.flatMap((node) => node.type === 'text'
24
+ ? tokenizeMathText(node.text)
25
+ .filter((token) => token.text && token.kind !== 'space')
26
+ .map((token) => ({ key: `${node.id}:${token.start}`, atom: atom(token.text) }))
27
+ : [
28
+ {
29
+ key: ['operatorName', 'fraction'].includes(node.type) ? node.id : '',
30
+ atom: (node.type === 'operatorName' ? 'operator' : 'ordinary'),
31
+ },
32
+ ]);
33
+ // A leading/trailing binary sign is unary. The same is true after an open
34
+ // fence, relation or operator, and before a closing fence or punctuation.
35
+ for (let index = 0; index < entries.length; index++) {
36
+ const entry = entries[index];
37
+ if (entry.atom !== 'binary')
38
+ continue;
39
+ const left = entries[index - 1]?.atom;
40
+ const right = entries[index + 1]?.atom;
41
+ if (!left ||
42
+ !right ||
43
+ ['binary', 'relation', 'open', 'punctuation', 'operator'].includes(left) ||
44
+ ['relation', 'close', 'punctuation'].includes(right))
45
+ entry.atom = 'ordinary';
46
+ }
47
+ const result = new Map();
48
+ entries.forEach((entry) => {
49
+ if (entry.key)
50
+ result.set(entry.key, { before: 0, after: 0 });
51
+ });
52
+ for (let index = 1; index < entries.length; index++) {
53
+ const left = entries[index - 1];
54
+ const right = entries[index];
55
+ let gap = 0;
56
+ if (left.atom !== 'open') {
57
+ if (right.atom === 'relation' &&
58
+ ['ordinary', 'close', 'punctuation', 'operator'].includes(left.atom))
59
+ gap = 5;
60
+ else if (left.atom === 'relation' && ['ordinary', 'open', 'operator'].includes(right.atom))
61
+ gap = 5;
62
+ else if (right.atom === 'binary' && ['ordinary', 'close'].includes(left.atom))
63
+ gap = 4;
64
+ else if (left.atom === 'binary' && ['ordinary', 'open', 'operator'].includes(right.atom))
65
+ gap = 4;
66
+ else if (left.atom === 'punctuation')
67
+ gap = 3;
68
+ else if ((right.atom === 'operator' && ['ordinary', 'close', 'operator'].includes(left.atom)) ||
69
+ (left.atom === 'operator' && right.atom === 'ordinary'))
70
+ gap = 3;
71
+ }
72
+ const tight = gap === 3 &&
73
+ (left.atom === 'operator' || right.atom === 'operator') &&
74
+ left.atom !== 'punctuation';
75
+ if (right.key) {
76
+ result.get(right.key).before = gap;
77
+ if (tight)
78
+ result.get(right.key).tightBefore = gap;
79
+ }
80
+ else if (left.key) {
81
+ result.get(left.key).after = gap;
82
+ if (tight)
83
+ result.get(left.key).tightAfter = gap;
84
+ }
85
+ }
86
+ return result;
87
+ }
package/dist/model.d.ts CHANGED
@@ -64,8 +64,14 @@ export declare const namedFunctions: readonly ["sin", "cos", "tan", "cot", "sec"
64
64
  /** Wrap the selection, or the preceding simple operand; never swallow a preceding + or =. */
65
65
  export declare function insertStructure(state: MathState, kind: StructureKind, consumeOperand?: boolean, mathStyle?: 'display' | 'text'): MathState;
66
66
  export declare function moveCaret(state: MathState, direction: -1 | 1): MathState;
67
+ /** Remove the wrapper of an empty slot while retaining its other slots' content.
68
+ * Grid cells keep their shape; row/column deletion has its own explicit commands.
69
+ */
70
+ export declare function unwrapEmptySlot(state: MathState): MathState;
67
71
  /** At the right edge of a structure, unwrap it instead of silently deleting its contents. */
68
72
  export declare function unwrapPrevious(state: MathState): MathState;
73
+ /** Delete before a structure unwraps it and keeps the caret before its retained contents. */
74
+ export declare function unwrapNext(state: MathState): MathState;
69
75
  export declare function toLatex(document: MathDocument): string;
70
76
  export interface MathHistory {
71
77
  past: MathState[];
package/dist/model.js CHANGED
@@ -81,7 +81,7 @@ const escapeLiteral = (text) => text.replace(/[\\{}_%$#&^~]/g, (char) => ({
81
81
  '\\': '\\textbackslash{}',
82
82
  '^': '\\textasciicircum{}',
83
83
  '~': '\\textasciitilde{}',
84
- }[char] ?? `\\${char}`));
84
+ })[char] ?? `\\${char}`);
85
85
  /** Wrap the selection, or the preceding simple operand; never swallow a preceding + or =. */
86
86
  export function insertStructure(state, kind, consumeOperand = false, mathStyle) {
87
87
  if (isLiteralText(state.document, state.caret.id))
@@ -233,6 +233,47 @@ export function moveCaret(state, direction) {
233
233
  const offset = direction < 0 ? next.text.length : 0;
234
234
  return { ...state, caret: { id: next.id, start: offset, end: offset } };
235
235
  }
236
+ /** Remove the wrapper of an empty slot while retaining its other slots' content.
237
+ * Grid cells keep their shape; row/column deletion has its own explicit commands.
238
+ */
239
+ export function unwrapEmptySlot(state) {
240
+ if (state.caret.start !== 0 || state.caret.end !== 0)
241
+ return state;
242
+ const visit = (line) => {
243
+ for (let index = 0; index < line.children.length; index++) {
244
+ const node = line.children[index];
245
+ if (node.type === 'text')
246
+ continue;
247
+ const emptySlot = node.slots.findIndex((slot) => slot.children.length === 1 &&
248
+ slot.children[0].id === state.caret.id &&
249
+ slot.children[0].type === 'text' &&
250
+ slot.children[0].text === '');
251
+ if (emptySlot >= 0) {
252
+ if (isGrid(node))
253
+ return state;
254
+ // Preserve the remaining script or radicand instead of flattening it.
255
+ if ((node.type === 'indexedRoot' && emptySlot === 0) ||
256
+ (node.type === 'scripts' && emptySlot > 0))
257
+ return unwrapPrevious(state);
258
+ const after = line.children[index + 1];
259
+ if (after?.type !== 'text')
260
+ return state;
261
+ return unwrapPrevious({ ...state, caret: { id: after.id, start: 0, end: 0 } });
262
+ }
263
+ for (const slot of node.slots) {
264
+ const next = visit(slot);
265
+ if (next)
266
+ return next;
267
+ }
268
+ }
269
+ };
270
+ for (const line of documentRows(state.document)) {
271
+ const next = visit(line);
272
+ if (next)
273
+ return next;
274
+ }
275
+ return state;
276
+ }
236
277
  /** At the right edge of a structure, unwrap it instead of silently deleting its contents. */
237
278
  export function unwrapPrevious(state) {
238
279
  if (state.caret.start !== 0 || state.caret.end !== 0)
@@ -298,6 +339,28 @@ export function unwrapPrevious(state) {
298
339
  const offset = target.text.length - current.text.length;
299
340
  return { document, caret: { id: target.id, start: offset, end: offset } };
300
341
  }
342
+ /** Delete before a structure unwraps it and keeps the caret before its retained contents. */
343
+ export function unwrapNext(state) {
344
+ if (state.caret.start !== state.caret.end)
345
+ return state;
346
+ const at = documentRows(state.document)
347
+ .map((line) => locate(line, state.caret.id))
348
+ .find(Boolean);
349
+ if (!at)
350
+ return state;
351
+ const current = at.row.children[at.index];
352
+ const structure = at.row.children[at.index + 1];
353
+ const after = at.row.children[at.index + 2];
354
+ if (current.type !== 'text' ||
355
+ state.caret.end !== current.text.length ||
356
+ !structure ||
357
+ structure.type === 'text' ||
358
+ isGrid(structure) ||
359
+ after?.type !== 'text')
360
+ return state;
361
+ const next = unwrapPrevious({ ...state, caret: { id: after.id, start: 0, end: 0 } });
362
+ return next.document === state.document ? state : { ...next, caret: { ...state.caret } };
363
+ }
301
364
  const symbols = Object.fromEntries(mathSymbols
302
365
  .filter(([value, , , , latex]) => value !== latex)
303
366
  .map(([value, , , , latex]) => [value, `${latex} `]));
@@ -308,12 +371,12 @@ const escapeText = (text) => Array.from(text)
308
371
  '{': '\\{',
309
372
  '}': '\\}',
310
373
  _: '\\_',
311
- '^': '\\textasciicircum{}',
374
+ '^': '\\char"005E{}',
312
375
  '%': '\\%',
313
376
  $: '\\$',
314
377
  '#': '\\#',
315
378
  '&': '\\&',
316
- '~': '\\textasciitilde{}',
379
+ '~': '\\char"007E{}',
317
380
  ' ': '\\ ',
318
381
  }[c] ??
319
382
  c)
@@ -364,8 +427,12 @@ export function toLatex(document) {
364
427
  return `\\${n.mathStyle === 'display' ? 'dbinom' : n.mathStyle === 'text' ? 'tbinom' : 'binom'}{${a}}{${b}}`;
365
428
  case 'fraction':
366
429
  return `\\${n.mathStyle === 'display' ? 'dfrac' : n.mathStyle === 'text' ? 'tfrac' : 'frac'}{${a}}{${b}}`;
367
- case 'indexedRoot':
368
- return `\\sqrt[${a}]{${b}}`;
430
+ case 'indexedRoot': {
431
+ // Group complex optional arguments: nested brackets and leading
432
+ // script groups otherwise terminate KaTeX's root-index scan early.
433
+ const index = /[{}\[\]]/.test(a) ? `{${a}}` : a;
434
+ return `\\sqrt[${index}]{${b}}`;
435
+ }
369
436
  case 'vec':
370
437
  case 'hat':
371
438
  case 'tilde':
package/dist/range.d.ts CHANGED
@@ -28,6 +28,14 @@ export declare function parseFragment(source: string): MathFragment | undefined;
28
28
  export declare const wrappingKinds: readonly ["bold", "calligraphic", "blackboard", "fraction", "binomial", "root", "vec", "hat", "tilde", "bar", "dot", "ddot", "widehat", "widetilde", "overline", "indexedRoot", "norm", "braces", "angle", "openClosed", "closedOpen", "overbrace", "underbrace", "overset", "underset", "superscript", "subscript", "parentheses", "brackets", "absolute"];
29
29
  export type WrappingKind = (typeof wrappingKinds)[number];
30
30
  /** Wrap one balanced row. Cross-slot ranges expand to their common structure. */
31
- export declare function wrapRange(state: MathState, range: MathRange, kind: WrappingKind): MathState;
31
+ export declare function wrapRange(state: MathState, range: MathRange, kind: WrappingKind, fractionSlot?: 'numerator' | 'denominator'): MathState;
32
32
  /** Keyboard ranges share drag-selection normalization. Cross a structure as one atom. */
33
- export declare function extendKeyboardRange(state: MathState, range: MathRange | undefined, key: string): MathRange;
33
+ export declare function extendKeyboardRange(state: MathState, range: MathRange | undefined, key: string, unit?: 'character' | 'token'): MathRange;
34
+ /** Match host-platform word movement without taking AltGr or OS shortcuts. */
35
+ export declare function isTokenNavigationKey(event: {
36
+ key: string;
37
+ altKey: boolean;
38
+ ctrlKey: boolean;
39
+ metaKey: boolean;
40
+ isComposing?: boolean;
41
+ }, platform: string): boolean;
package/dist/range.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { tokenizeMathText } from './tokens.js';
1
2
  import { fenceCharacters } from './fences.js';
2
3
  import { documentRows, isLiteralText, row, textNode, textNodes, } from './model.js';
3
4
  const size = (node) => (node.type === 'text' ? node.text.length : 1);
@@ -353,7 +354,7 @@ export const wrappingKinds = [
353
354
  'absolute',
354
355
  ];
355
356
  /** Wrap one balanced row. Cross-slot ranges expand to their common structure. */
356
- export function wrapRange(state, range, kind) {
357
+ export function wrapRange(state, range, kind, fractionSlot = 'numerator') {
357
358
  if (isLiteralText(state.document, range.anchor.id))
358
359
  return state;
359
360
  const fragment = copyRange(state.document, range);
@@ -367,7 +368,8 @@ export function wrapRange(state, range, kind) {
367
368
  : ['parentheses', 'brackets', 'absolute', 'root', 'matrix'].includes(meaningful[0].type));
368
369
  if (kind === 'superscript' && !grouped)
369
370
  content = normalize([{ type: 'parentheses', id: row().id, slots: [content] }]);
370
- const slots = ['indexedRoot', 'overset', 'underset', 'overbrace', 'underbrace'].includes(kind)
371
+ const denominator = kind === 'fraction' && fractionSlot === 'denominator';
372
+ const slots = denominator || ['indexedRoot', 'overset', 'underset', 'overbrace', 'underbrace'].includes(kind)
371
373
  ? [row(), content]
372
374
  : ['fraction', 'binomial', 'superscript', 'subscript'].includes(kind)
373
375
  ? [content, row()]
@@ -390,11 +392,14 @@ export function wrapRange(state, range, kind) {
390
392
  const wrapper = location?.row.children[location.index - 1];
391
393
  if (!wrapper || wrapper.type === 'text')
392
394
  return next;
393
- const target = wrapper.slots[['indexedRoot', 'overset', 'underset', 'overbrace', 'underbrace'].includes(kind) ? 0 : 1].children[0];
395
+ const target = wrapper.slots[denominator ||
396
+ ['indexedRoot', 'overset', 'underset', 'overbrace', 'underbrace'].includes(kind)
397
+ ? 0
398
+ : 1].children[0];
394
399
  return { ...next, caret: { id: target.id, start: 0, end: 0 } };
395
400
  }
396
401
  /** Keyboard ranges share drag-selection normalization. Cross a structure as one atom. */
397
- export function extendKeyboardRange(state, range, key) {
402
+ export function extendKeyboardRange(state, range, key, unit = 'character') {
398
403
  const anchor = range?.anchor ?? { id: state.caret.id, offset: state.caret.start };
399
404
  const point = range?.focus ?? { id: state.caret.id, offset: state.caret.end };
400
405
  const at = address(state.document, point);
@@ -414,11 +419,20 @@ export function extendKeyboardRange(state, range, key) {
414
419
  const node = local.row.children[local.index];
415
420
  if (node.type !== 'text')
416
421
  return { anchor, focus: point };
422
+ if (unit === 'token') {
423
+ const tokens = tokenizeMathText(node.text).filter((token) => token.kind !== 'space');
424
+ const token = direction < 0
425
+ ? tokens.reverse().find((token) => token.start < point.offset)
426
+ : tokens.find((token) => token.end > point.offset);
427
+ if (token)
428
+ return { anchor, focus: { id: point.id, offset: direction < 0 ? token.start : token.end } };
429
+ // Skip remaining whitespace before crossing a structure or leaving this slot.
430
+ }
417
431
  const chars = direction < 0
418
432
  ? Array.from(node.text.slice(0, point.offset))
419
433
  : Array.from(node.text.slice(point.offset));
420
434
  const char = direction < 0 ? chars.at(-1) : chars[0];
421
- if (char)
435
+ if (char && unit === 'character')
422
436
  return { anchor, focus: { id: point.id, offset: point.offset + direction * char.length } };
423
437
  // A sibling structure is skipped intact; leaving a nested slot selects its enclosing structure.
424
438
  for (let depth = at.path.length - 1; depth >= 0; depth--) {
@@ -434,8 +448,19 @@ export function extendKeyboardRange(state, range, key) {
434
448
  }
435
449
  const line = documentRows(state.document)[at.line + direction];
436
450
  if (!line)
437
- return { anchor, focus: point };
451
+ return {
452
+ anchor,
453
+ focus: unit === 'token' ? { id: point.id, offset: direction < 0 ? 0 : node.text.length } : point,
454
+ };
438
455
  const nodes = textNodes({ version: 1, root: line });
439
456
  const target = direction < 0 ? nodes.at(-1) : nodes[0];
440
457
  return { anchor, focus: { id: target.id, offset: direction < 0 ? target.text.length : 0 } };
441
458
  }
459
+ /** Match host-platform word movement without taking AltGr or OS shortcuts. */
460
+ export function isTokenNavigationKey(event, platform) {
461
+ if (event.isComposing || event.metaKey || !['ArrowLeft', 'ArrowRight'].includes(event.key))
462
+ return false;
463
+ return /Mac|iPhone|iPad|iPod/.test(platform)
464
+ ? event.altKey && !event.ctrlKey
465
+ : event.ctrlKey && !event.altKey;
466
+ }
@@ -0,0 +1,19 @@
1
+ import { type MathState, type MathStructure } from './model.js';
2
+ /** Use the nearest enclosing radical, including from one of its nested slots. */
3
+ export declare function activeRoot(state: MathState): MathStructure | undefined;
4
+ /** An explicit non-square index cannot be discarded by a presentation change. */
5
+ export declare function canUseSquareRoot(root: MathStructure): boolean;
6
+ /** Preserve the radicand's entire tree and IDs, and focus the newly editable index. */
7
+ export declare function transformRoot(state: MathState, targetId: string, kind: 'root' | 'indexedRoot'): MathState;
8
+ /** Footer context is independent of the typed query and suggestion dismissal. */
9
+ export declare function rootEditingContext(state: MathState): {
10
+ id: string;
11
+ kind: "root" | "indexedRoot";
12
+ target: "root" | "indexedRoot";
13
+ canTransform: boolean;
14
+ indexCaret: {
15
+ id: string;
16
+ start: number;
17
+ end: number;
18
+ } | undefined;
19
+ } | undefined;
@@ -0,0 +1,69 @@
1
+ import { documentRows, isLiteralText, row, textNodes, } from './model.js';
2
+ /** Use the nearest enclosing radical, including from one of its nested slots. */
3
+ export function activeRoot(state) {
4
+ if (isLiteralText(state.document, state.caret.id))
5
+ return;
6
+ const visit = (line, parent) => {
7
+ for (const node of line.children) {
8
+ if (node.type === 'text') {
9
+ if (node.id === state.caret.id)
10
+ return parent;
11
+ }
12
+ else {
13
+ for (const slot of node.slots) {
14
+ const found = visit(slot, ['root', 'indexedRoot'].includes(node.type) ? node : parent);
15
+ if (found)
16
+ return found;
17
+ }
18
+ }
19
+ }
20
+ };
21
+ for (const line of documentRows(state.document)) {
22
+ const found = visit(line);
23
+ if (found)
24
+ return found;
25
+ }
26
+ }
27
+ /** An explicit non-square index cannot be discarded by a presentation change. */
28
+ export function canUseSquareRoot(root) {
29
+ if (root.type !== 'indexedRoot')
30
+ return false;
31
+ const index = root.slots[0];
32
+ if (index.children.some((node) => node.type !== 'text'))
33
+ return false;
34
+ const value = index.children
35
+ .map((node) => (node.type === 'text' ? node.text : ''))
36
+ .join('')
37
+ .trim();
38
+ return value === '' || value === '2';
39
+ }
40
+ /** Preserve the radicand's entire tree and IDs, and focus the newly editable index. */
41
+ export function transformRoot(state, targetId, kind) {
42
+ const current = activeRoot(state);
43
+ if (!current || current.id !== targetId || current.type === kind)
44
+ return state;
45
+ if (kind === 'root' && !canUseSquareRoot(current))
46
+ return state;
47
+ const next = { ...state, document: structuredClone(state.document) };
48
+ const root = activeRoot(next);
49
+ const radicand = root.slots[root.type === 'root' ? 0 : 1];
50
+ root.type = kind;
51
+ // Start at 2 to retain the formula's meaning; select it so typing n replaces it.
52
+ root.slots = kind === 'indexedRoot' ? [row('2'), radicand] : [radicand];
53
+ const first = textNodes({ version: 1, root: root.slots[0] })[0];
54
+ return { ...next, caret: { id: first.id, start: 0, end: kind === 'indexedRoot' ? 1 : 0 } };
55
+ }
56
+ /** Footer context is independent of the typed query and suggestion dismissal. */
57
+ export function rootEditingContext(state) {
58
+ const root = activeRoot(state);
59
+ if (!root)
60
+ return;
61
+ const index = root.type === 'indexedRoot' ? textNodes({ version: 1, root: root.slots[0] })[0] : undefined;
62
+ return {
63
+ id: root.id,
64
+ kind: root.type,
65
+ target: root.type === 'root' ? 'indexedRoot' : 'root',
66
+ canTransform: root.type === 'root' || canUseSquareRoot(root),
67
+ indexCaret: index ? { id: index.id, start: 0, end: index.text.length } : undefined,
68
+ };
69
+ }
@@ -0,0 +1,13 @@
1
+ import type { MathState } from './model.js';
2
+ import { type MathRange } from './range.js';
3
+ /** A typed opening symbol wraps a selection before ordinary replacement runs.
4
+ * Shift is allowed because several symbols require it on common keyboards.
5
+ * Returning the original state consumes unsupported multi-line wrapping safely.
6
+ */
7
+ export declare function wrapSelectionShortcut(state: MathState, range: MathRange | undefined, event: {
8
+ key: string;
9
+ ctrlKey?: boolean;
10
+ metaKey?: boolean;
11
+ altKey?: boolean;
12
+ isComposing?: boolean;
13
+ }): MathState | undefined;
@@ -0,0 +1,35 @@
1
+ import { isLiteralText } from './model.js';
2
+ import { wrapRange } from './range.js';
3
+ const wrappers = {
4
+ '(': 'parentheses',
5
+ '[': 'brackets',
6
+ '{': 'braces',
7
+ '|': 'absolute',
8
+ '/': 'fraction',
9
+ '^': 'superscript',
10
+ _: 'subscript',
11
+ };
12
+ /** A typed opening symbol wraps a selection before ordinary replacement runs.
13
+ * Shift is allowed because several symbols require it on common keyboards.
14
+ * Returning the original state consumes unsupported multi-line wrapping safely.
15
+ */
16
+ export function wrapSelectionShortcut(state, range, event) {
17
+ if (event.ctrlKey || event.metaKey || event.altKey || event.isComposing)
18
+ return;
19
+ const kind = wrappers[event.key];
20
+ if (!kind)
21
+ return;
22
+ const selection = range ??
23
+ (state.caret.start !== state.caret.end
24
+ ? {
25
+ anchor: { id: state.caret.id, offset: state.caret.start },
26
+ focus: { id: state.caret.id, offset: state.caret.end },
27
+ }
28
+ : undefined);
29
+ if (!selection ||
30
+ (selection.anchor.id === selection.focus.id &&
31
+ selection.anchor.offset === selection.focus.offset) ||
32
+ isLiteralText(state.document, selection.anchor.id))
33
+ return;
34
+ return wrapRange(state, selection, kind);
35
+ }
package/dist/session.d.ts CHANGED
@@ -24,6 +24,7 @@ export type MathCommand = {
24
24
  } | {
25
25
  type: 'structure';
26
26
  kind: StructureKind;
27
+ fractionSlot?: 'numerator' | 'denominator';
27
28
  } | {
28
29
  type: 'template';
29
30
  id: string;
package/dist/session.js CHANGED
@@ -215,7 +215,7 @@ export function createMathSession(options = {}) {
215
215
  return mode === 'inline' ? false : apply(splitLine(state));
216
216
  if (range)
217
217
  return (wrappingKinds.includes(command.kind) &&
218
- apply(wrapRange(state, range, command.kind)));
218
+ apply(wrapRange(state, range, command.kind, command.fractionSlot)));
219
219
  return apply(insertStructure(state, command.kind, ['fraction', 'binomial', 'superscript', 'subscript'].includes(command.kind)));
220
220
  },
221
221
  destroy() {
@@ -1,6 +1,10 @@
1
1
  import { type MathLocale, type MathMessageParameters } from './i18n.js';
2
2
  import { type MathState, type StructureKind } from './model.js';
3
3
  export interface MathSuggestion {
4
+ /** Convert this enclosing structure without consuming the typed query. */
5
+ transformRootId?: string;
6
+ /** Change the current fence without inserting a wrapper or consuming text. */
7
+ transformFenceId?: string;
4
8
  /** Preserve the query as an operand instead of replacing a command name. */
5
9
  wrapOperand?: boolean;
6
10
  operatorName?: string;
@@ -31,4 +35,9 @@ export declare function findSuggestions(text: string, caret: number, locale?: Ma
31
35
  query: string;
32
36
  candidates: MathSuggestion[];
33
37
  };
38
+ /** Combine text search with changes available for the current formula structure. */
39
+ export declare function findStateSuggestions(state: MathState, locale?: MathLocale, caret?: number): {
40
+ query: string;
41
+ candidates: MathSuggestion[];
42
+ };
34
43
  export declare function acceptSuggestion(state: MathState, query: string, candidate: MathSuggestion): MathState;
@@ -1,3 +1,6 @@
1
+ import { transformRoot } from './root-transform.js';
2
+ import { structureEditingContext, changeContextFence } from './context-tools.js';
3
+ import { fencePairs } from './fences.js';
1
4
  import { mathLocaleAliases, translate, } from './i18n.js';
2
5
  import { insertTemplate, mathTemplates } from './templates.js';
3
6
  import { activeMatrix, insertIdentityMatrix, insertMatrix, MATRIX_MAX_SIZE } from './matrix.js';
@@ -284,7 +287,57 @@ export function findSuggestions(text, caret, locale = 'ko') {
284
287
  })),
285
288
  };
286
289
  }
290
+ /** Combine text search with changes available for the current formula structure. */
291
+ export function findStateSuggestions(state, locale = 'ko', caret = state.caret.start) {
292
+ const text = textNodes(state.document).find((node) => node.id === state.caret.id)?.text ?? '';
293
+ const result = findSuggestions(text, caret, locale);
294
+ const context = structureEditingContext(state);
295
+ const root = context?.root;
296
+ const kind = root?.canTransform ? root.target : undefined;
297
+ if (root && kind) {
298
+ const item = mathStructures.find((entry) => entry.kind === kind);
299
+ result.candidates.push({
300
+ ...item,
301
+ id: `transform-${kind}`,
302
+ transformRootId: root.id,
303
+ label: translate(locale, 'suggestion.transform', {
304
+ kind: translate(locale, `context.${kind}`),
305
+ }),
306
+ detail: translate(locale, 'suggestion.transformRootDetail'),
307
+ });
308
+ }
309
+ if (context?.fence) {
310
+ const fence = context.fence;
311
+ const changes = Object.keys(fencePairs)
312
+ .filter((kind) => kind !== fence.type)
313
+ .map((kind) => {
314
+ const item = mathStructures.find((entry) => entry.kind === kind);
315
+ return {
316
+ ...item,
317
+ id: `transform-fence-${kind}`,
318
+ transformFenceId: fence.id,
319
+ label: translate(locale, 'suggestion.transformFence', {
320
+ kind: translate(locale, item.label),
321
+ }),
322
+ detail: translate(locale, 'suggestion.transformFenceDetail'),
323
+ };
324
+ });
325
+ // Explicit symbol/command matches stay first. Changes precede new wrapping actions.
326
+ const wrapping = result.candidates.findIndex((item) => item.wrapOperand);
327
+ result.candidates.splice(wrapping < 0 ? result.candidates.length : wrapping, 0, ...changes);
328
+ }
329
+ return result;
330
+ }
287
331
  export function acceptSuggestion(state, query, candidate) {
332
+ if (candidate.transformFenceId)
333
+ return candidate.kind
334
+ ? changeContextFence(state, candidate.transformFenceId, candidate.kind)
335
+ : state;
336
+ if (candidate.transformRootId) {
337
+ return candidate.kind === 'root' || candidate.kind === 'indexedRoot'
338
+ ? transformRoot(state, candidate.transformRootId, candidate.kind)
339
+ : state;
340
+ }
288
341
  const node = textNodes(state.document).find((n) => n.id === state.caret.id);
289
342
  const start = state.caret.start - query.length;
290
343
  if (!node || start < 0 || node.text.slice(start, state.caret.start) !== query)