@barocss/math-editor 0.2.0 → 0.4.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.
- package/ADAPTERS.md +25 -14
- package/API-JAVASCRIPT.md +1 -1
- package/API-REACT.md +5 -1
- package/API-SESSION.md +168 -6
- package/API-SOLID.md +1 -1
- package/API-SVELTE.md +1 -1
- package/API-VUE.md +1 -1
- package/API-WEB-COMPONENT.md +1 -1
- package/CHANGELOG.md +51 -1
- package/EMBEDDING.md +4 -0
- package/IMPLEMENTATION.md +27 -2
- package/JSON-MODEL.md +1 -0
- package/LATEX-GUIDE.md +13 -0
- package/LATEX-SCOPE.md +2 -2
- package/LICENSE +1 -3
- package/README.md +150 -36
- package/ROADMAP.md +96 -10
- package/STYLING.md +157 -0
- package/SUPPORT.md +29 -11
- package/VALIDATION.md +117 -4
- package/dist/core.d.ts +5 -1
- package/dist/core.js +5 -1
- package/dist/document-codec.d.ts +0 -1
- package/dist/document-codec.js +0 -1
- package/dist/dom/caret-geometry.d.ts +3 -0
- package/dist/dom/caret-geometry.js +41 -0
- package/dist/dom/elements.d.ts +1 -2
- package/dist/dom/elements.js +2 -3
- package/dist/dom/latex-panel.d.ts +10 -0
- package/dist/dom/latex-panel.js +59 -0
- package/dist/dom/matrix-selection.d.ts +11 -0
- package/dist/dom/matrix-selection.js +30 -0
- package/dist/dom/menu-position.d.ts +14 -2
- package/dist/dom/menu-position.js +84 -15
- package/dist/dom/presentation-controls.d.ts +4 -0
- package/dist/dom/presentation-controls.js +56 -0
- package/dist/dom/quick-panel.d.ts +12 -0
- package/dist/dom/quick-panel.js +102 -0
- package/dist/dom/selection.d.ts +29 -0
- package/dist/dom/selection.js +53 -0
- package/dist/dom/symbol-panel.d.ts +9 -0
- package/dist/dom/symbol-panel.js +146 -0
- package/dist/dom/theme.d.ts +6 -0
- package/dist/dom/theme.js +68 -0
- package/dist/dom/toolbar-catalog.d.ts +10 -0
- package/dist/dom/toolbar-catalog.js +35 -0
- package/dist/dom/toolbar.d.ts +13 -2
- package/dist/dom/toolbar.js +295 -28
- package/dist/dom.d.ts +7 -1
- package/dist/dom.js +527 -126
- package/dist/editor-labels.d.ts +0 -1
- package/dist/editor-labels.js +0 -1
- package/dist/enter-policy.d.ts +0 -1
- package/dist/enter-policy.js +0 -1
- package/dist/fences.d.ts +0 -1
- package/dist/fences.js +0 -1
- package/dist/i18n.d.ts +0 -1
- package/dist/i18n.js +0 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +4 -1
- package/dist/latex-insertion.d.ts +12 -0
- package/dist/latex-insertion.js +26 -0
- package/dist/latex-paste-panel.d.ts +3 -0
- package/dist/latex-paste-panel.js +18 -0
- package/dist/latex.d.ts +0 -1
- package/dist/latex.js +0 -1
- package/dist/lines.d.ts +0 -1
- package/dist/lines.js +0 -1
- package/dist/locales/en.js +28 -2
- package/dist/locales/en.json +28 -2
- package/dist/locales/ko.js +28 -2
- package/dist/locales/ko.json +28 -2
- package/dist/math-editor-toolbar.d.ts +4 -2
- package/dist/math-editor-toolbar.js +3 -4
- package/dist/math-editor.d.ts +5 -2
- package/dist/math-editor.js +300 -44
- package/dist/matrix-range.d.ts +40 -0
- package/dist/matrix-range.js +242 -0
- package/dist/matrix.d.ts +0 -1
- package/dist/matrix.js +0 -1
- package/dist/model.d.ts +0 -1
- package/dist/model.js +0 -1
- package/dist/outputs.d.ts +0 -1
- package/dist/outputs.js +0 -1
- package/dist/preferences.d.ts +24 -0
- package/dist/preferences.js +66 -0
- package/dist/presentation-controls.d.ts +10 -0
- package/dist/presentation-controls.js +10 -0
- package/dist/presentation.d.ts +13 -0
- package/dist/presentation.js +86 -0
- package/dist/quick-panel.d.ts +2 -0
- package/dist/quick-panel.js +17 -0
- package/dist/range.d.ts +4 -1
- package/dist/range.js +57 -1
- package/dist/react.d.ts +0 -1
- package/dist/react.js +0 -1
- package/dist/session.d.ts +31 -2
- package/dist/session.js +108 -7
- package/dist/solid.d.ts +0 -1
- package/dist/solid.js +0 -1
- package/dist/suggestions.d.ts +4 -1
- package/dist/suggestions.js +103 -13
- package/dist/svelte.d.ts +0 -1
- package/dist/svelte.js +0 -1
- package/dist/symbol-browser.d.ts +0 -1
- package/dist/symbol-browser.js +0 -1
- package/dist/symbols.d.ts +0 -1
- package/dist/symbols.js +0 -1
- package/dist/templates.d.ts +0 -1
- package/dist/templates.js +0 -1
- package/dist/tokens.d.ts +0 -1
- package/dist/tokens.js +0 -1
- package/dist/vertical-navigation.d.ts +15 -0
- package/dist/vertical-navigation.js +145 -0
- package/dist/vue.d.ts +0 -1
- package/dist/vue.js +0 -1
- package/dist/web-component.d.ts +0 -1
- package/dist/web-component.js +0 -1
- package/package.json +1 -1
- package/src/style.css +254 -129
- package/dist/core.d.ts.map +0 -1
- package/dist/core.js.map +0 -1
- package/dist/document-codec.d.ts.map +0 -1
- package/dist/document-codec.js.map +0 -1
- package/dist/dom/elements.d.ts.map +0 -1
- package/dist/dom/elements.js.map +0 -1
- package/dist/dom/menu-position.d.ts.map +0 -1
- package/dist/dom/menu-position.js.map +0 -1
- package/dist/dom/toolbar.d.ts.map +0 -1
- package/dist/dom/toolbar.js.map +0 -1
- package/dist/dom.d.ts.map +0 -1
- package/dist/dom.js.map +0 -1
- package/dist/editor-labels.d.ts.map +0 -1
- package/dist/editor-labels.js.map +0 -1
- package/dist/enter-policy.d.ts.map +0 -1
- package/dist/enter-policy.js.map +0 -1
- package/dist/fences.d.ts.map +0 -1
- package/dist/fences.js.map +0 -1
- package/dist/i18n.d.ts.map +0 -1
- package/dist/i18n.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/latex.d.ts.map +0 -1
- package/dist/latex.js.map +0 -1
- package/dist/lines.d.ts.map +0 -1
- package/dist/lines.js.map +0 -1
- package/dist/math-editor-toolbar.d.ts.map +0 -1
- package/dist/math-editor-toolbar.js.map +0 -1
- package/dist/math-editor.d.ts.map +0 -1
- package/dist/math-editor.js.map +0 -1
- package/dist/matrix.d.ts.map +0 -1
- package/dist/matrix.js.map +0 -1
- package/dist/model.d.ts.map +0 -1
- package/dist/model.js.map +0 -1
- package/dist/outputs.d.ts.map +0 -1
- package/dist/outputs.js.map +0 -1
- package/dist/range.d.ts.map +0 -1
- package/dist/range.js.map +0 -1
- package/dist/react.d.ts.map +0 -1
- package/dist/react.js.map +0 -1
- package/dist/session.d.ts.map +0 -1
- package/dist/session.js.map +0 -1
- package/dist/solid.d.ts.map +0 -1
- package/dist/solid.js.map +0 -1
- package/dist/suggestions.d.ts.map +0 -1
- package/dist/suggestions.js.map +0 -1
- package/dist/svelte.d.ts.map +0 -1
- package/dist/svelte.js.map +0 -1
- package/dist/symbol-browser.d.ts.map +0 -1
- package/dist/symbol-browser.js.map +0 -1
- package/dist/symbols.d.ts.map +0 -1
- package/dist/symbols.js.map +0 -1
- package/dist/templates.d.ts.map +0 -1
- package/dist/templates.js.map +0 -1
- package/dist/tokens.d.ts.map +0 -1
- package/dist/tokens.js.map +0 -1
- package/dist/vue.d.ts.map +0 -1
- package/dist/vue.js.map +0 -1
- package/dist/web-component.d.ts.map +0 -1
- package/dist/web-component.js.map +0 -1
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
interface MenuRect {
|
|
2
|
+
left: number;
|
|
3
|
+
top: number;
|
|
4
|
+
right: number;
|
|
5
|
+
bottom: number;
|
|
6
|
+
}
|
|
7
|
+
/** Choose a scrollable vertical gap without covering host controls in the same column. */
|
|
8
|
+
export declare function mathMenuPlacement(bounds: MenuRect, anchor: MenuRect, width: number, desiredHeight: number, preferredHeight: number, avoid?: readonly MenuRect[]): {
|
|
9
|
+
left: number;
|
|
10
|
+
top: number;
|
|
11
|
+
height: number;
|
|
12
|
+
};
|
|
1
13
|
/** Position a fixed portal in viewport coordinates, including transformed modal hosts. */
|
|
2
|
-
export declare function positionMathMenu(menu: HTMLElement, anchor: DOMRect, maxHeight: number): void;
|
|
3
|
-
|
|
14
|
+
export declare function positionMathMenu(menu: HTMLElement, anchor: DOMRect, maxHeight: number, avoidElements?: readonly HTMLElement[]): void;
|
|
15
|
+
export {};
|
|
@@ -1,12 +1,82 @@
|
|
|
1
|
+
/** Fixed menus escape ordinary scrolling text areas. Transforms and containment
|
|
2
|
+
* establish a local containing block, so overflow at that block and above still clips. */
|
|
3
|
+
function establishesFixedContainingBlock(style) {
|
|
4
|
+
const properties = [
|
|
5
|
+
'transform',
|
|
6
|
+
'translate',
|
|
7
|
+
'rotate',
|
|
8
|
+
'scale',
|
|
9
|
+
'perspective',
|
|
10
|
+
'filter',
|
|
11
|
+
'backdrop-filter',
|
|
12
|
+
];
|
|
13
|
+
return (properties.some((property) => {
|
|
14
|
+
const value = style.getPropertyValue(property);
|
|
15
|
+
return value !== '' && value !== 'none';
|
|
16
|
+
}) ||
|
|
17
|
+
/layout|paint|strict|content/.test(style.contain) ||
|
|
18
|
+
style.contentVisibility === 'auto' ||
|
|
19
|
+
style.willChange.split(',').some((property) => properties.includes(property.trim())));
|
|
20
|
+
}
|
|
21
|
+
/** Choose a scrollable vertical gap without covering host controls in the same column. */
|
|
22
|
+
export function mathMenuPlacement(bounds, anchor, width, desiredHeight, preferredHeight, avoid = []) {
|
|
23
|
+
const left = Math.max(bounds.left, Math.min(anchor.left, bounds.right - width));
|
|
24
|
+
const blockers = avoid
|
|
25
|
+
.filter((rect) => rect.left < left + width && rect.right > left)
|
|
26
|
+
.sort((a, b) => a.top - b.top);
|
|
27
|
+
const gaps = (top, bottom) => {
|
|
28
|
+
const result = [];
|
|
29
|
+
let start = top;
|
|
30
|
+
for (const rect of blockers) {
|
|
31
|
+
if (rect.bottom + 4 <= start || rect.top - 4 >= bottom)
|
|
32
|
+
continue;
|
|
33
|
+
if (rect.top - 4 > start)
|
|
34
|
+
result.push({ top: start, bottom: Math.min(bottom, rect.top - 4) });
|
|
35
|
+
start = Math.max(start, rect.bottom + 4);
|
|
36
|
+
if (start >= bottom)
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
if (start < bottom)
|
|
40
|
+
result.push({ top: start, bottom });
|
|
41
|
+
return result;
|
|
42
|
+
};
|
|
43
|
+
const capacity = (gap) => gap.bottom - gap.top;
|
|
44
|
+
const below = gaps(Math.max(bounds.top, anchor.bottom + 4), bounds.bottom);
|
|
45
|
+
const above = gaps(bounds.top, Math.min(bounds.bottom, anchor.top - 4)).reverse();
|
|
46
|
+
const largest = (items) => Math.max(0, ...items.map(capacity));
|
|
47
|
+
// Retain direction while typing, independent of the current number of candidates.
|
|
48
|
+
const threshold = Math.min(preferredHeight, 160);
|
|
49
|
+
let placeBelow = largest(below) >= threshold || largest(below) >= largest(above);
|
|
50
|
+
const preferred = placeBelow ? below : above;
|
|
51
|
+
let gap = preferred.find((item) => capacity(item) >= threshold) ??
|
|
52
|
+
preferred.reduce((best, item) => (!best || capacity(item) > capacity(best) ? item : best), undefined);
|
|
53
|
+
if (!gap || capacity(gap) < Math.min(48, desiredHeight)) {
|
|
54
|
+
// A short clipped host can leave space only beside the anchor vertically.
|
|
55
|
+
// Overlap the expression if necessary before overlapping its action controls.
|
|
56
|
+
const full = gaps(bounds.top, bounds.bottom).sort((a, b) => capacity(b) - capacity(a))[0];
|
|
57
|
+
if (full && (!gap || capacity(full) > capacity(gap))) {
|
|
58
|
+
gap = full;
|
|
59
|
+
placeBelow = true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// If controls occupy the entire clipping rectangle, no disjoint placement exists.
|
|
63
|
+
// Keep the menu scrollable within that rectangle rather than silently hiding it.
|
|
64
|
+
gap ??= { top: bounds.top, bottom: bounds.bottom };
|
|
65
|
+
const height = Math.max(0, Math.min(desiredHeight, capacity(gap)));
|
|
66
|
+
return { left, top: placeBelow ? gap.top : gap.bottom - height, height };
|
|
67
|
+
}
|
|
1
68
|
/** Position a fixed portal in viewport coordinates, including transformed modal hosts. */
|
|
2
|
-
export function positionMathMenu(menu, anchor, maxHeight) {
|
|
69
|
+
export function positionMathMenu(menu, anchor, maxHeight, avoidElements = []) {
|
|
3
70
|
const win = menu.ownerDocument.defaultView;
|
|
4
71
|
const bounds = { left: 8, top: 8, right: win.innerWidth - 8, bottom: win.innerHeight - 8 };
|
|
72
|
+
let contained = false;
|
|
5
73
|
for (let parent = menu.parentElement; parent; parent = parent.parentElement) {
|
|
6
74
|
const style = win.getComputedStyle(parent);
|
|
75
|
+
contained ||= establishesFixedContainingBlock(style);
|
|
7
76
|
const dialog = parent.matches('dialog, [role="dialog"]');
|
|
8
|
-
const
|
|
9
|
-
const
|
|
77
|
+
const paintClip = /paint|strict|content/.test(style.contain) || style.contentVisibility === 'auto';
|
|
78
|
+
const clipX = dialog || paintClip || (contained && /auto|scroll|hidden|clip/.test(style.overflowX));
|
|
79
|
+
const clipY = dialog || paintClip || (contained && /auto|scroll|hidden|clip/.test(style.overflowY));
|
|
10
80
|
if (!clipX && !clipY)
|
|
11
81
|
continue;
|
|
12
82
|
const rect = parent.getBoundingClientRect();
|
|
@@ -23,22 +93,21 @@ export function positionMathMenu(menu, anchor, maxHeight) {
|
|
|
23
93
|
// A dialog's transform/translate establishes a local origin and may also scale it.
|
|
24
94
|
menu.style.left = '0px';
|
|
25
95
|
menu.style.top = '0px';
|
|
96
|
+
menu.style.boxSizing = 'border-box';
|
|
26
97
|
menu.style.maxWidth = '';
|
|
27
98
|
menu.style.maxHeight = `${maxHeight}px`;
|
|
28
99
|
const origin = menu.getBoundingClientRect();
|
|
29
100
|
const scaleX = origin.width / menu.offsetWidth || 1;
|
|
30
101
|
const scaleY = origin.height / menu.offsetHeight || 1;
|
|
31
102
|
menu.style.maxWidth = `${Math.max(0, bounds.right - bounds.left) / scaleX}px`;
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
menu.style.
|
|
42
|
-
menu.style.top = `${(top - origin.top) / scaleY}px`;
|
|
103
|
+
const borderHeight = menu.offsetHeight - menu.clientHeight;
|
|
104
|
+
const desiredHeight = Math.min((menu.scrollHeight + borderHeight) * scaleY, maxHeight * scaleY);
|
|
105
|
+
const avoid = avoidElements
|
|
106
|
+
.filter((element) => element.ownerDocument === menu.ownerDocument && element.isConnected)
|
|
107
|
+
.map((element) => element.getBoundingClientRect())
|
|
108
|
+
.filter((rect) => rect.width > 0 && rect.height > 0);
|
|
109
|
+
const placement = mathMenuPlacement(bounds, anchor, menu.getBoundingClientRect().width, desiredHeight, maxHeight * scaleY, avoid);
|
|
110
|
+
menu.style.maxHeight = `${placement.height / scaleY}px`;
|
|
111
|
+
menu.style.left = `${(placement.left - origin.left) / scaleX}px`;
|
|
112
|
+
menu.style.top = `${(placement.top - origin.top) / scaleY}px`;
|
|
43
113
|
}
|
|
44
|
-
//# sourceMappingURL=menu-position.js.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type MathPresentationChange } from '../presentation.js';
|
|
2
|
+
import { type MathState, type StructureKind } from '../model.js';
|
|
3
|
+
import { type MathLocale } from '../i18n.js';
|
|
4
|
+
export declare function drawPresentationControls(host: HTMLElement, state: MathState, locale: MathLocale, onChange: (id: string, change: MathPresentationChange) => void, disabled?: boolean, kinds?: readonly StructureKind[]): void;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { activePresentation, limitKinds } from '../presentation.js';
|
|
2
|
+
import { fencePairs, fences } from '../fences.js';
|
|
3
|
+
import { isGrid } from '../model.js';
|
|
4
|
+
import { translate } from '../i18n.js';
|
|
5
|
+
import { mathStructures } from '../suggestions.js';
|
|
6
|
+
import { elementFactory } from './elements.js';
|
|
7
|
+
export function drawPresentationControls(host, state, locale, onChange, disabled = false, kinds) {
|
|
8
|
+
host.replaceChildren();
|
|
9
|
+
const node = activePresentation(state);
|
|
10
|
+
host.hidden = !node;
|
|
11
|
+
if (!node || isGrid(node))
|
|
12
|
+
return;
|
|
13
|
+
const el = elementFactory(host.ownerDocument);
|
|
14
|
+
const t = (key) => translate(locale, key);
|
|
15
|
+
host.className = 'me-presentation-tools me-matrix-tools';
|
|
16
|
+
host.setAttribute('role', 'toolbar');
|
|
17
|
+
host.setAttribute('aria-label', t('presentation.title'));
|
|
18
|
+
const field = (key, values, value, change) => {
|
|
19
|
+
const label = el('label', '', t(key));
|
|
20
|
+
const select = el('select');
|
|
21
|
+
select.disabled = disabled;
|
|
22
|
+
select.setAttribute('aria-label', t(key));
|
|
23
|
+
select.dataset.presentation = key;
|
|
24
|
+
for (const item of values) {
|
|
25
|
+
const option = el('option', '', item.label);
|
|
26
|
+
option.value = item.value;
|
|
27
|
+
select.append(option);
|
|
28
|
+
}
|
|
29
|
+
select.value = value;
|
|
30
|
+
select.onchange = () => onChange(node.id, change(select.value));
|
|
31
|
+
label.append(select);
|
|
32
|
+
host.append(label);
|
|
33
|
+
};
|
|
34
|
+
if (fences(node))
|
|
35
|
+
field('presentation.fence', [
|
|
36
|
+
...(node.type === 'fenced'
|
|
37
|
+
? [{ value: 'fenced', label: `${fences(node)[0]} … ${fences(node)[1]}` }]
|
|
38
|
+
: []),
|
|
39
|
+
...Object.keys(fencePairs)
|
|
40
|
+
.filter((type) => !kinds || kinds.includes(type))
|
|
41
|
+
.map((type) => ({
|
|
42
|
+
value: type,
|
|
43
|
+
label: t(mathStructures.find((item) => item.kind === type).label),
|
|
44
|
+
})),
|
|
45
|
+
], node.type, (fence) => ({ fence }));
|
|
46
|
+
if (['fraction', 'binomial'].includes(node.type))
|
|
47
|
+
field('presentation.size', ['default', 'display', 'text'].map((value) => ({
|
|
48
|
+
value,
|
|
49
|
+
label: t(`presentation.${value === 'above-below' ? 'aboveBelow' : value}`),
|
|
50
|
+
})), node.mathStyle ?? 'default', (value) => ({ mathStyle: value }));
|
|
51
|
+
if (limitKinds.includes(node.type))
|
|
52
|
+
field('presentation.limits', ['default', 'above-below', 'side'].map((value) => ({
|
|
53
|
+
value,
|
|
54
|
+
label: t(`presentation.${value === 'above-below' ? 'aboveBelow' : value}`),
|
|
55
|
+
})), node.limits === undefined ? 'default' : node.limits ? 'above-below' : 'side', (value) => ({ limits: value }));
|
|
56
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type MathPreferences, type MathQuickItem } from '../preferences.js';
|
|
2
|
+
import { type MathLocale } from '../i18n.js';
|
|
3
|
+
/** Shared catalog UI for recent and pinned symbols/templates; no document storage. */
|
|
4
|
+
export declare function mountQuickPanel(host: HTMLElement, options: {
|
|
5
|
+
locale: MathLocale;
|
|
6
|
+
preferences: MathPreferences;
|
|
7
|
+
allowedTemplates?: readonly string[];
|
|
8
|
+
onInsert(item: MathQuickItem): boolean;
|
|
9
|
+
onClose(): void;
|
|
10
|
+
}): {
|
|
11
|
+
destroy(): void;
|
|
12
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { elementFactory } from './elements.js';
|
|
2
|
+
import { mathQuickItems } from '../preferences.js';
|
|
3
|
+
import { translate, mathLocaleAliases } from '../i18n.js';
|
|
4
|
+
/** Shared catalog UI for recent and pinned symbols/templates; no document storage. */
|
|
5
|
+
export function mountQuickPanel(host, options) {
|
|
6
|
+
const el = elementFactory(host.ownerDocument);
|
|
7
|
+
const t = (key) => translate(options.locale, key);
|
|
8
|
+
const panel = el('section', 'me-utility-panel me-quick-panel');
|
|
9
|
+
panel.setAttribute('aria-label', t('quick.title'));
|
|
10
|
+
const controls = el('div', 'me-utility-actions');
|
|
11
|
+
const filter = el('select');
|
|
12
|
+
filter.setAttribute('aria-label', t('quick.filter'));
|
|
13
|
+
for (const value of ['all', 'recent', 'favorites']) {
|
|
14
|
+
const option = el('option', '', t(`quick.${value}`));
|
|
15
|
+
option.value = value;
|
|
16
|
+
filter.append(option);
|
|
17
|
+
}
|
|
18
|
+
const search = el('input');
|
|
19
|
+
search.type = 'search';
|
|
20
|
+
search.setAttribute('aria-label', t('symbols.search'));
|
|
21
|
+
search.placeholder = t('symbols.search');
|
|
22
|
+
const close = el('button', '', t('common.close'));
|
|
23
|
+
close.type = 'button';
|
|
24
|
+
close.onclick = options.onClose;
|
|
25
|
+
controls.append(filter, search, close);
|
|
26
|
+
const grid = el('div', 'me-symbol-grid');
|
|
27
|
+
const empty = el('p', '', t('quick.empty'));
|
|
28
|
+
empty.setAttribute('role', 'status');
|
|
29
|
+
panel.append(controls, grid, empty);
|
|
30
|
+
host.append(panel);
|
|
31
|
+
const draw = () => {
|
|
32
|
+
const snapshot = options.preferences.getSnapshot();
|
|
33
|
+
const needle = search.value.trim().normalize('NFC').toLowerCase();
|
|
34
|
+
const selected = filter.value === 'recent'
|
|
35
|
+
? snapshot.recent
|
|
36
|
+
: filter.value === 'favorites'
|
|
37
|
+
? snapshot.favorites
|
|
38
|
+
: undefined;
|
|
39
|
+
const catalog = selected
|
|
40
|
+
? selected.map((id) => mathQuickItems.find((item) => item.id === id))
|
|
41
|
+
: mathQuickItems;
|
|
42
|
+
const items = catalog.filter((item) => (!item.template ||
|
|
43
|
+
!options.allowedTemplates ||
|
|
44
|
+
options.allowedTemplates.includes(item.template)) &&
|
|
45
|
+
[
|
|
46
|
+
item.glyph,
|
|
47
|
+
item.id,
|
|
48
|
+
...(item.aliases ?? []),
|
|
49
|
+
...mathLocaleAliases(options.locale, item.id),
|
|
50
|
+
translate(options.locale, item.label, item.labelParameters),
|
|
51
|
+
translate('en', item.label),
|
|
52
|
+
translate('ko', item.label),
|
|
53
|
+
].some((value) => value.normalize('NFC').toLowerCase().includes(needle)));
|
|
54
|
+
grid.replaceChildren();
|
|
55
|
+
empty.hidden = items.length > 0;
|
|
56
|
+
for (const item of items) {
|
|
57
|
+
const cell = el('div', 'me-quick-item');
|
|
58
|
+
const name = translate(options.locale, item.label, item.labelParameters);
|
|
59
|
+
const insert = el('button', '', `${item.glyph} ${name}`);
|
|
60
|
+
insert.type = 'button';
|
|
61
|
+
insert.dataset.quickItem = item.id;
|
|
62
|
+
insert.onclick = () => {
|
|
63
|
+
if (options.onInsert(item)) {
|
|
64
|
+
options.preferences.record(item.id);
|
|
65
|
+
options.onClose();
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const favorite = el('button', '', snapshot.favorites.includes(item.id) ? '★' : '☆');
|
|
69
|
+
favorite.type = 'button';
|
|
70
|
+
favorite.dataset.favorite = item.id;
|
|
71
|
+
favorite.setAttribute('aria-label', `${t('quick.favorite')}: ${name}`);
|
|
72
|
+
favorite.setAttribute('aria-pressed', String(snapshot.favorites.includes(item.id)));
|
|
73
|
+
favorite.onclick = () => {
|
|
74
|
+
options.preferences.toggleFavorite(item.id);
|
|
75
|
+
const next = [...grid.querySelectorAll('[data-favorite]')].find((button) => button.dataset.favorite === item.id);
|
|
76
|
+
(next ?? search).focus();
|
|
77
|
+
};
|
|
78
|
+
cell.append(insert, favorite);
|
|
79
|
+
grid.append(cell);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
search.oninput = draw;
|
|
83
|
+
filter.onchange = draw;
|
|
84
|
+
panel.addEventListener('keydown', (event) => {
|
|
85
|
+
event.stopPropagation();
|
|
86
|
+
if (event.key === 'Escape' && !event.isComposing) {
|
|
87
|
+
event.preventDefault();
|
|
88
|
+
options.onClose();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
for (const name of ['copy', 'cut', 'paste', 'input', 'beforeinput', 'keyup'])
|
|
92
|
+
panel.addEventListener(name, (event) => event.stopPropagation());
|
|
93
|
+
const unsubscribe = options.preferences.subscribe(draw);
|
|
94
|
+
draw();
|
|
95
|
+
search.focus();
|
|
96
|
+
return {
|
|
97
|
+
destroy() {
|
|
98
|
+
unsubscribe();
|
|
99
|
+
panel.remove();
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { MathPoint, MathRange } from '../range.js';
|
|
2
|
+
/** Dismissal survives repeated native select/keyup events, but not a new selection. */
|
|
3
|
+
export declare function createSelectionMenuState(): {
|
|
4
|
+
sync(range: MathRange | undefined): boolean;
|
|
5
|
+
dismiss(): boolean;
|
|
6
|
+
reset(): void;
|
|
7
|
+
};
|
|
8
|
+
export interface SelectionBounds {
|
|
9
|
+
start: number;
|
|
10
|
+
end: number;
|
|
11
|
+
}
|
|
12
|
+
/** Intersect a row selection with one text run without expanding partial endpoints. */
|
|
13
|
+
export declare function textSelectionBounds(length: number, rowOffset: number, selection: SelectionBounds | undefined, inherited?: boolean): SelectionBounds | undefined;
|
|
14
|
+
export interface MathPointerSelection {
|
|
15
|
+
point: MathPoint;
|
|
16
|
+
pointerId: number;
|
|
17
|
+
x: number;
|
|
18
|
+
y: number;
|
|
19
|
+
moved: boolean;
|
|
20
|
+
input?: HTMLInputElement;
|
|
21
|
+
}
|
|
22
|
+
/** Shift-drag extends the native anchor, which is the end of a backward selection. */
|
|
23
|
+
export declare function nativeSelectionAnchor(input: Pick<HTMLInputElement, 'selectionStart' | 'selectionEnd' | 'selectionDirection'>): number | null;
|
|
24
|
+
/** Native input selection owns its rectangle until the drag crosses its edge. */
|
|
25
|
+
export declare function extendsMathSelection(drag: Pick<MathPointerSelection, 'pointerId' | 'x' | 'y' | 'moved'>, pointer: {
|
|
26
|
+
pointerId: number;
|
|
27
|
+
clientX: number;
|
|
28
|
+
clientY: number;
|
|
29
|
+
}, inputBounds?: Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'>): boolean;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Dismissal survives repeated native select/keyup events, but not a new selection. */
|
|
2
|
+
export function createSelectionMenuState() {
|
|
3
|
+
let current = '';
|
|
4
|
+
let dismissed = false;
|
|
5
|
+
return {
|
|
6
|
+
sync(range) {
|
|
7
|
+
const key = range
|
|
8
|
+
? JSON.stringify([range.anchor.id, range.anchor.offset, range.focus.id, range.focus.offset])
|
|
9
|
+
: '';
|
|
10
|
+
if (key !== current)
|
|
11
|
+
dismissed = false;
|
|
12
|
+
current = key;
|
|
13
|
+
return !!key && !dismissed;
|
|
14
|
+
},
|
|
15
|
+
dismiss() {
|
|
16
|
+
if (!current || dismissed)
|
|
17
|
+
return false;
|
|
18
|
+
dismissed = true;
|
|
19
|
+
return true;
|
|
20
|
+
},
|
|
21
|
+
reset() {
|
|
22
|
+
dismissed = false;
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** Intersect a row selection with one text run without expanding partial endpoints. */
|
|
27
|
+
export function textSelectionBounds(length, rowOffset, selection, inherited = false) {
|
|
28
|
+
if (inherited)
|
|
29
|
+
return { start: 0, end: length };
|
|
30
|
+
if (!selection)
|
|
31
|
+
return;
|
|
32
|
+
const start = Math.max(0, selection.start - rowOffset);
|
|
33
|
+
const end = Math.min(length, selection.end - rowOffset);
|
|
34
|
+
return start < end ? { start, end } : undefined;
|
|
35
|
+
}
|
|
36
|
+
/** Shift-drag extends the native anchor, which is the end of a backward selection. */
|
|
37
|
+
export function nativeSelectionAnchor(input) {
|
|
38
|
+
return input.selectionDirection === 'backward' ? input.selectionEnd : input.selectionStart;
|
|
39
|
+
}
|
|
40
|
+
/** Native input selection owns its rectangle until the drag crosses its edge. */
|
|
41
|
+
export function extendsMathSelection(drag, pointer, inputBounds) {
|
|
42
|
+
if (drag.pointerId !== pointer.pointerId)
|
|
43
|
+
return false;
|
|
44
|
+
if (drag.moved)
|
|
45
|
+
return true;
|
|
46
|
+
if (Math.hypot(pointer.clientX - drag.x, pointer.clientY - drag.y) < 4)
|
|
47
|
+
return false;
|
|
48
|
+
return (!inputBounds ||
|
|
49
|
+
pointer.clientX < inputBounds.left ||
|
|
50
|
+
pointer.clientX > inputBounds.right ||
|
|
51
|
+
pointer.clientY < inputBounds.top ||
|
|
52
|
+
pointer.clientY > inputBounds.bottom);
|
|
53
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type MathLocale } from '../i18n.js';
|
|
2
|
+
/** Native, nonmodal symbol discovery. The panel stays inside its toolbar's containment boundary. */
|
|
3
|
+
export declare function mountSymbolPanel(host: HTMLElement, options: {
|
|
4
|
+
locale: MathLocale;
|
|
5
|
+
onInsert(glyph: string): void;
|
|
6
|
+
onClose(): void;
|
|
7
|
+
}): {
|
|
8
|
+
destroy(): void;
|
|
9
|
+
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { elementFactory } from './elements.js';
|
|
2
|
+
import { findToolbarSymbols } from './toolbar-catalog.js';
|
|
3
|
+
import { translate, mathLocaleDirection } from '../i18n.js';
|
|
4
|
+
import { mathSymbols } from '../symbols.js';
|
|
5
|
+
/** Native, nonmodal symbol discovery. The panel stays inside its toolbar's containment boundary. */
|
|
6
|
+
export function mountSymbolPanel(host, options) {
|
|
7
|
+
const doc = host.ownerDocument;
|
|
8
|
+
const element = elementFactory(doc);
|
|
9
|
+
const t = (key) => translate(options.locale, key);
|
|
10
|
+
const panel = element('section', 'me-symbol-browser me-dom-symbol-browser');
|
|
11
|
+
panel.setAttribute('aria-label', t('symbols.title'));
|
|
12
|
+
panel.dir = mathLocaleDirection(options.locale);
|
|
13
|
+
const heading = element('div', 'me-symbol-browser-heading');
|
|
14
|
+
const title = element('strong', '', t('symbols.title'));
|
|
15
|
+
const count = element('small');
|
|
16
|
+
count.setAttribute('role', 'status');
|
|
17
|
+
title.append(' ', count);
|
|
18
|
+
const close = element('button', '', t('common.close'));
|
|
19
|
+
close.type = 'button';
|
|
20
|
+
close.onclick = options.onClose;
|
|
21
|
+
heading.append(title, close);
|
|
22
|
+
const label = element('label', 'me-symbol-search', t('symbols.search'));
|
|
23
|
+
const search = element('input');
|
|
24
|
+
search.type = 'search';
|
|
25
|
+
search.placeholder = t('symbols.searchPlaceholder');
|
|
26
|
+
label.append(search);
|
|
27
|
+
const help = element('p', 'me-symbol-help', t('symbols.help'));
|
|
28
|
+
const grid = element('div', 'me-symbol-grid');
|
|
29
|
+
grid.setAttribute('role', 'group');
|
|
30
|
+
grid.setAttribute('aria-label', t('symbols.title'));
|
|
31
|
+
const empty = element('p', '', t('symbols.noMatches'));
|
|
32
|
+
empty.setAttribute('role', 'status');
|
|
33
|
+
panel.append(heading, label, help, grid, empty);
|
|
34
|
+
host.append(panel);
|
|
35
|
+
let composing = false;
|
|
36
|
+
let destroyed = false;
|
|
37
|
+
let active = 0;
|
|
38
|
+
let buttons = [];
|
|
39
|
+
const focusResult = (index) => {
|
|
40
|
+
if (!buttons.length)
|
|
41
|
+
return;
|
|
42
|
+
active = Math.max(0, Math.min(index, buttons.length - 1));
|
|
43
|
+
buttons.forEach((button, at) => {
|
|
44
|
+
button.tabIndex = at === active ? 0 : -1;
|
|
45
|
+
});
|
|
46
|
+
buttons[active].focus();
|
|
47
|
+
buttons[active].scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
48
|
+
};
|
|
49
|
+
const draw = () => {
|
|
50
|
+
const matches = findToolbarSymbols(search.value, options.locale);
|
|
51
|
+
active = 0;
|
|
52
|
+
buttons = matches.map(([glyph, key]) => {
|
|
53
|
+
const button = element('button');
|
|
54
|
+
button.type = 'button';
|
|
55
|
+
button.dataset.symbol = glyph;
|
|
56
|
+
button.setAttribute('aria-label', `${glyph} — ${t(key)}`);
|
|
57
|
+
button.title = `${translate('en', key)} / ${translate('ko', key)}`;
|
|
58
|
+
const symbol = element('span', 'me-symbol-glyph', glyph);
|
|
59
|
+
symbol.setAttribute('aria-hidden', 'true');
|
|
60
|
+
button.append(symbol, element('span', '', t(key)));
|
|
61
|
+
button.onclick = () => {
|
|
62
|
+
if (!composing && !destroyed)
|
|
63
|
+
options.onInsert(glyph);
|
|
64
|
+
};
|
|
65
|
+
button.onfocus = () => {
|
|
66
|
+
active = buttons.indexOf(button);
|
|
67
|
+
buttons.forEach((item, index) => {
|
|
68
|
+
item.tabIndex = index === active ? 0 : -1;
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
return button;
|
|
72
|
+
});
|
|
73
|
+
buttons.forEach((button, index) => {
|
|
74
|
+
button.tabIndex = index === 0 ? 0 : -1;
|
|
75
|
+
});
|
|
76
|
+
grid.replaceChildren(...buttons);
|
|
77
|
+
count.textContent = `${matches.length} / ${mathSymbols.length}`;
|
|
78
|
+
empty.hidden = matches.length !== 0;
|
|
79
|
+
};
|
|
80
|
+
search.oninput = draw;
|
|
81
|
+
search.addEventListener('compositionstart', () => {
|
|
82
|
+
composing = true;
|
|
83
|
+
});
|
|
84
|
+
search.addEventListener('compositionend', () => {
|
|
85
|
+
composing = false;
|
|
86
|
+
draw();
|
|
87
|
+
});
|
|
88
|
+
panel.addEventListener('keydown', (event) => {
|
|
89
|
+
event.stopPropagation();
|
|
90
|
+
if (event.isComposing || composing || event.ctrlKey || event.metaKey || event.altKey)
|
|
91
|
+
return;
|
|
92
|
+
if (event.key === 'Escape') {
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
options.onClose();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (event.target === search) {
|
|
98
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
focusResult(event.key === 'ArrowDown' ? 0 : buttons.length - 1);
|
|
101
|
+
}
|
|
102
|
+
else if (event.key === 'Enter' && buttons.length) {
|
|
103
|
+
event.preventDefault();
|
|
104
|
+
buttons[0].click();
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (!buttons.includes(event.target))
|
|
109
|
+
return;
|
|
110
|
+
const firstTop = buttons[0].getBoundingClientRect().top;
|
|
111
|
+
const nextRow = buttons.findIndex((button) => Math.abs(button.getBoundingClientRect().top - firstTop) > 2);
|
|
112
|
+
const columns = nextRow < 0 ? buttons.length : Math.max(1, nextRow);
|
|
113
|
+
const rtl = panel.dir === 'rtl';
|
|
114
|
+
const next = event.key === 'ArrowDown'
|
|
115
|
+
? active + columns
|
|
116
|
+
: event.key === 'ArrowUp'
|
|
117
|
+
? active - columns
|
|
118
|
+
: event.key === 'ArrowRight'
|
|
119
|
+
? active + (rtl ? -1 : 1)
|
|
120
|
+
: event.key === 'ArrowLeft'
|
|
121
|
+
? active + (rtl ? 1 : -1)
|
|
122
|
+
: event.key === 'Home'
|
|
123
|
+
? 0
|
|
124
|
+
: event.key === 'End'
|
|
125
|
+
? buttons.length - 1
|
|
126
|
+
: undefined;
|
|
127
|
+
if (next !== undefined) {
|
|
128
|
+
event.preventDefault();
|
|
129
|
+
if (event.key === 'ArrowUp' && next < 0)
|
|
130
|
+
search.focus();
|
|
131
|
+
else
|
|
132
|
+
focusResult(next);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
for (const name of ['keyup', 'beforeinput', 'input', 'copy', 'cut', 'paste']) {
|
|
136
|
+
panel.addEventListener(name, (event) => event.stopPropagation());
|
|
137
|
+
}
|
|
138
|
+
draw();
|
|
139
|
+
search.focus();
|
|
140
|
+
return {
|
|
141
|
+
destroy() {
|
|
142
|
+
destroyed = true;
|
|
143
|
+
panel.remove();
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Copy resolved values, including host var() references, without copying layout or content.
|
|
2
|
+
* Explicit `initial` blocks unrelated tokens inherited from the portal container. */
|
|
3
|
+
export declare function syncMathTheme(source: HTMLElement, target: HTMLElement): void;
|
|
4
|
+
/** Follow scoped class/style/theme switches without polling or observing formula mutations.
|
|
5
|
+
* The observer and frame belong to the editor's document, including iframe hosts. */
|
|
6
|
+
export declare function followMathTheme(source: HTMLElement, target: HTMLElement): () => void;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Public presentation tokens, copied to menus that live outside the editor's DOM tree.
|
|
2
|
+
* Keep this list aligned with STYLING.md. Internal slot/glyph variables stay local. */
|
|
3
|
+
const themeProperties = [
|
|
4
|
+
'--me-font-size',
|
|
5
|
+
'--me-ui-font-family',
|
|
6
|
+
'--me-text',
|
|
7
|
+
'--me-muted',
|
|
8
|
+
'--me-accent',
|
|
9
|
+
'--me-line',
|
|
10
|
+
'--me-background',
|
|
11
|
+
'--me-panel-background',
|
|
12
|
+
'--me-toolbar-background',
|
|
13
|
+
'--me-subtle-background',
|
|
14
|
+
'--me-hover-background',
|
|
15
|
+
'--me-active-background',
|
|
16
|
+
'--me-variable',
|
|
17
|
+
'--me-number',
|
|
18
|
+
'--me-symbol',
|
|
19
|
+
'--me-function',
|
|
20
|
+
'--me-input-background',
|
|
21
|
+
'--me-input-focus-background',
|
|
22
|
+
'--me-selection-background',
|
|
23
|
+
'--me-selection-color',
|
|
24
|
+
'--me-focus-color',
|
|
25
|
+
'--me-radius',
|
|
26
|
+
'--me-control-radius',
|
|
27
|
+
'--me-menu-shadow',
|
|
28
|
+
'--me-menu-z-index',
|
|
29
|
+
];
|
|
30
|
+
/** Copy resolved values, including host var() references, without copying layout or content.
|
|
31
|
+
* Explicit `initial` blocks unrelated tokens inherited from the portal container. */
|
|
32
|
+
export function syncMathTheme(source, target) {
|
|
33
|
+
const computed = source.ownerDocument.defaultView.getComputedStyle(source);
|
|
34
|
+
for (const property of themeProperties) {
|
|
35
|
+
const value = computed.getPropertyValue(property).trim() || 'initial';
|
|
36
|
+
if (target.style.getPropertyValue(property) !== value)
|
|
37
|
+
target.style.setProperty(property, value);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Follow scoped class/style/theme switches without polling or observing formula mutations.
|
|
41
|
+
* The observer and frame belong to the editor's document, including iframe hosts. */
|
|
42
|
+
export function followMathTheme(source, target) {
|
|
43
|
+
const win = source.ownerDocument.defaultView;
|
|
44
|
+
let frame;
|
|
45
|
+
const refresh = () => {
|
|
46
|
+
if (frame !== undefined)
|
|
47
|
+
return;
|
|
48
|
+
frame = win.requestAnimationFrame(() => {
|
|
49
|
+
frame = undefined;
|
|
50
|
+
syncMathTheme(source, target);
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
const observer = new win.MutationObserver(refresh);
|
|
54
|
+
for (let ancestor = source; ancestor; ancestor = ancestor.parentElement)
|
|
55
|
+
observer.observe(ancestor, { attributes: true });
|
|
56
|
+
// CSS media queries can change a theme without changing an ancestor attribute.
|
|
57
|
+
const colorScheme = win.matchMedia('(prefers-color-scheme: dark)');
|
|
58
|
+
colorScheme.addEventListener('change', refresh);
|
|
59
|
+
win.addEventListener('resize', refresh);
|
|
60
|
+
syncMathTheme(source, target);
|
|
61
|
+
return () => {
|
|
62
|
+
observer.disconnect();
|
|
63
|
+
colorScheme.removeEventListener('change', refresh);
|
|
64
|
+
win.removeEventListener('resize', refresh);
|
|
65
|
+
if (frame !== undefined)
|
|
66
|
+
win.cancelAnimationFrame(frame);
|
|
67
|
+
};
|
|
68
|
+
}
|