@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.
- package/API-SESSION.md +35 -1
- package/API-WEB-COMPONENT.md +2 -0
- package/CHANGELOG.md +109 -0
- package/CLIPBOARD.md +54 -0
- package/EDITING-SCENARIOS.md +209 -0
- package/GETTING-STARTED.md +54 -0
- package/IMPLEMENTATION.md +105 -0
- package/KEYBOARD.md +41 -0
- package/LATEX-GUIDE.md +52 -2
- package/LATEX-MODEL.md +12 -0
- package/LATEX-SCOPE.md +10 -1
- package/README.md +119 -23
- package/RELEASING.md +95 -10
- package/RENDERING-TESTS.md +79 -0
- package/ROADMAP.md +238 -6
- package/STYLING.md +28 -1
- package/TEXT-EDITORS.md +157 -0
- package/VALIDATION.md +433 -0
- package/dist/context-tools.d.ts +21 -0
- package/dist/context-tools.js +37 -0
- package/dist/dom/caret-geometry.js +1 -1
- package/dist/dom/context-keyboard.d.ts +2 -0
- package/dist/dom/context-keyboard.js +22 -0
- package/dist/dom/help.d.ts +5 -0
- package/dist/dom/help.js +104 -0
- package/dist/dom/menu-position.d.ts +3 -0
- package/dist/dom/menu-position.js +49 -4
- package/dist/dom/readable-layout.d.ts +3 -0
- package/dist/dom/readable-layout.js +52 -0
- package/dist/dom/toolbar-catalog.d.ts +1 -1
- package/dist/dom/toolbar.d.ts +2 -0
- package/dist/dom/toolbar.js +8 -1
- package/dist/dom.d.ts +13 -1
- package/dist/dom.js +323 -51
- package/dist/enter-policy.js +1 -1
- package/dist/latex.js +11 -0
- package/dist/lines.d.ts +2 -0
- package/dist/lines.js +15 -0
- package/dist/locales/en.js +33 -3
- package/dist/locales/en.json +33 -3
- package/dist/locales/ko.js +33 -3
- package/dist/locales/ko.json +33 -3
- package/dist/math-editor-toolbar.js +2 -1
- package/dist/math-editor.d.ts +4 -1
- package/dist/math-editor.js +506 -296
- package/dist/math-layout.d.ts +12 -0
- package/dist/math-layout.js +59 -0
- package/dist/math-spacing.d.ts +13 -0
- package/dist/math-spacing.js +87 -0
- package/dist/model.d.ts +6 -0
- package/dist/model.js +72 -5
- package/dist/range.d.ts +10 -2
- package/dist/range.js +31 -6
- package/dist/root-transform.d.ts +19 -0
- package/dist/root-transform.js +69 -0
- package/dist/selection-shortcuts.d.ts +13 -0
- package/dist/selection-shortcuts.js +35 -0
- package/dist/session.d.ts +1 -0
- package/dist/session.js +1 -1
- package/dist/suggestions.d.ts +9 -0
- package/dist/suggestions.js +53 -0
- package/dist/symbols.d.ts +1 -1
- package/dist/symbols.js +1 -0
- package/dist/vertical-navigation.d.ts +5 -0
- package/dist/vertical-navigation.js +33 -0
- package/dist/web-component.js +9 -1
- package/package.json +11 -3
- package/src/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/src/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/src/fonts/README.md +18 -0
- package/src/shapes/README.md +21 -0
- package/src/shapes/parenthesis-bottom.svg +1 -0
- package/src/shapes/parenthesis-top.svg +1 -0
- package/src/shapes/parenthesis.svg +1 -0
- package/src/shapes/radical.svg +1 -0
- 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
|
-
'^': '\\
|
|
374
|
+
'^': '\\char"005E{}',
|
|
312
375
|
'%': '\\%',
|
|
313
376
|
$: '\\$',
|
|
314
377
|
'#': '\\#',
|
|
315
378
|
'&': '\\&',
|
|
316
|
-
'~': '\\
|
|
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
|
-
|
|
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
|
|
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[
|
|
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 {
|
|
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
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() {
|
package/dist/suggestions.d.ts
CHANGED
|
@@ -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;
|
package/dist/suggestions.js
CHANGED
|
@@ -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)
|