@barocss/math-editor 0.2.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/ADAPTERS.md +25 -14
  2. package/API-JAVASCRIPT.md +1 -1
  3. package/API-REACT.md +5 -1
  4. package/API-SESSION.md +178 -6
  5. package/API-SOLID.md +1 -1
  6. package/API-SVELTE.md +1 -1
  7. package/API-VUE.md +1 -1
  8. package/API-WEB-COMPONENT.md +3 -1
  9. package/CHANGELOG.md +106 -0
  10. package/EDITING-SCENARIOS.md +174 -0
  11. package/EMBEDDING.md +4 -0
  12. package/IMPLEMENTATION.md +122 -2
  13. package/JSON-MODEL.md +1 -0
  14. package/LATEX-GUIDE.md +30 -1
  15. package/LATEX-MODEL.md +12 -0
  16. package/LATEX-SCOPE.md +12 -3
  17. package/LICENSE +1 -3
  18. package/README.md +201 -39
  19. package/RELEASING.md +81 -10
  20. package/RENDERING-TESTS.md +79 -0
  21. package/ROADMAP.md +220 -11
  22. package/STYLING.md +157 -0
  23. package/SUPPORT.md +24 -12
  24. package/VALIDATION.md +444 -4
  25. package/dist/context-tools.d.ts +21 -0
  26. package/dist/context-tools.js +37 -0
  27. package/dist/core.d.ts +5 -1
  28. package/dist/core.js +5 -1
  29. package/dist/document-codec.d.ts +0 -1
  30. package/dist/document-codec.js +0 -1
  31. package/dist/dom/caret-geometry.d.ts +3 -0
  32. package/dist/dom/caret-geometry.js +41 -0
  33. package/dist/dom/context-keyboard.d.ts +2 -0
  34. package/dist/dom/context-keyboard.js +22 -0
  35. package/dist/dom/elements.d.ts +1 -2
  36. package/dist/dom/elements.js +2 -3
  37. package/dist/dom/latex-panel.d.ts +10 -0
  38. package/dist/dom/latex-panel.js +59 -0
  39. package/dist/dom/matrix-selection.d.ts +11 -0
  40. package/dist/dom/matrix-selection.js +30 -0
  41. package/dist/dom/menu-position.d.ts +17 -2
  42. package/dist/dom/menu-position.js +129 -16
  43. package/dist/dom/presentation-controls.d.ts +4 -0
  44. package/dist/dom/presentation-controls.js +56 -0
  45. package/dist/dom/quick-panel.d.ts +12 -0
  46. package/dist/dom/quick-panel.js +102 -0
  47. package/dist/dom/selection.d.ts +29 -0
  48. package/dist/dom/selection.js +53 -0
  49. package/dist/dom/symbol-panel.d.ts +9 -0
  50. package/dist/dom/symbol-panel.js +146 -0
  51. package/dist/dom/theme.d.ts +6 -0
  52. package/dist/dom/theme.js +68 -0
  53. package/dist/dom/toolbar-catalog.d.ts +10 -0
  54. package/dist/dom/toolbar-catalog.js +35 -0
  55. package/dist/dom/toolbar.d.ts +13 -2
  56. package/dist/dom/toolbar.js +295 -28
  57. package/dist/dom.d.ts +9 -1
  58. package/dist/dom.js +682 -151
  59. package/dist/editor-labels.d.ts +0 -1
  60. package/dist/editor-labels.js +0 -1
  61. package/dist/enter-policy.d.ts +0 -1
  62. package/dist/enter-policy.js +0 -1
  63. package/dist/fences.d.ts +0 -1
  64. package/dist/fences.js +0 -1
  65. package/dist/i18n.d.ts +0 -1
  66. package/dist/i18n.js +0 -1
  67. package/dist/index.d.ts +4 -1
  68. package/dist/index.js +4 -1
  69. package/dist/latex-insertion.d.ts +12 -0
  70. package/dist/latex-insertion.js +26 -0
  71. package/dist/latex-paste-panel.d.ts +3 -0
  72. package/dist/latex-paste-panel.js +18 -0
  73. package/dist/latex.d.ts +0 -1
  74. package/dist/latex.js +11 -1
  75. package/dist/lines.d.ts +0 -1
  76. package/dist/lines.js +0 -1
  77. package/dist/locales/en.js +39 -1
  78. package/dist/locales/en.json +39 -1
  79. package/dist/locales/ko.js +39 -1
  80. package/dist/locales/ko.json +39 -1
  81. package/dist/math-editor-toolbar.d.ts +4 -2
  82. package/dist/math-editor-toolbar.js +3 -4
  83. package/dist/math-editor.d.ts +7 -2
  84. package/dist/math-editor.js +661 -308
  85. package/dist/math-layout.d.ts +12 -0
  86. package/dist/math-layout.js +59 -0
  87. package/dist/math-spacing.d.ts +13 -0
  88. package/dist/math-spacing.js +87 -0
  89. package/dist/matrix-range.d.ts +40 -0
  90. package/dist/matrix-range.js +242 -0
  91. package/dist/matrix.d.ts +0 -1
  92. package/dist/matrix.js +0 -1
  93. package/dist/model.d.ts +4 -1
  94. package/dist/model.js +49 -5
  95. package/dist/outputs.d.ts +0 -1
  96. package/dist/outputs.js +0 -1
  97. package/dist/preferences.d.ts +24 -0
  98. package/dist/preferences.js +66 -0
  99. package/dist/presentation-controls.d.ts +10 -0
  100. package/dist/presentation-controls.js +10 -0
  101. package/dist/presentation.d.ts +13 -0
  102. package/dist/presentation.js +86 -0
  103. package/dist/quick-panel.d.ts +2 -0
  104. package/dist/quick-panel.js +17 -0
  105. package/dist/range.d.ts +2 -1
  106. package/dist/range.js +11 -1
  107. package/dist/react.d.ts +0 -1
  108. package/dist/react.js +0 -1
  109. package/dist/root-transform.d.ts +19 -0
  110. package/dist/root-transform.js +69 -0
  111. package/dist/session.d.ts +31 -2
  112. package/dist/session.js +108 -7
  113. package/dist/solid.d.ts +0 -1
  114. package/dist/solid.js +0 -1
  115. package/dist/suggestions.d.ts +13 -1
  116. package/dist/suggestions.js +153 -12
  117. package/dist/svelte.d.ts +0 -1
  118. package/dist/svelte.js +0 -1
  119. package/dist/symbol-browser.d.ts +0 -1
  120. package/dist/symbol-browser.js +0 -1
  121. package/dist/symbols.d.ts +0 -1
  122. package/dist/symbols.js +0 -1
  123. package/dist/templates.d.ts +0 -1
  124. package/dist/templates.js +0 -1
  125. package/dist/tokens.d.ts +0 -1
  126. package/dist/tokens.js +0 -1
  127. package/dist/vertical-navigation.d.ts +15 -0
  128. package/dist/vertical-navigation.js +145 -0
  129. package/dist/vue.d.ts +0 -1
  130. package/dist/vue.js +0 -1
  131. package/dist/web-component.d.ts +0 -1
  132. package/dist/web-component.js +9 -2
  133. package/package.json +11 -3
  134. package/src/fonts/KaTeX_Math-Italic.woff2 +0 -0
  135. package/src/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  136. package/src/fonts/README.md +18 -0
  137. package/src/shapes/README.md +21 -0
  138. package/src/shapes/parenthesis-bottom.svg +1 -0
  139. package/src/shapes/parenthesis-top.svg +1 -0
  140. package/src/shapes/parenthesis.svg +1 -0
  141. package/src/shapes/radical.svg +1 -0
  142. package/src/style.css +949 -173
  143. package/dist/core.d.ts.map +0 -1
  144. package/dist/core.js.map +0 -1
  145. package/dist/document-codec.d.ts.map +0 -1
  146. package/dist/document-codec.js.map +0 -1
  147. package/dist/dom/elements.d.ts.map +0 -1
  148. package/dist/dom/elements.js.map +0 -1
  149. package/dist/dom/menu-position.d.ts.map +0 -1
  150. package/dist/dom/menu-position.js.map +0 -1
  151. package/dist/dom/toolbar.d.ts.map +0 -1
  152. package/dist/dom/toolbar.js.map +0 -1
  153. package/dist/dom.d.ts.map +0 -1
  154. package/dist/dom.js.map +0 -1
  155. package/dist/editor-labels.d.ts.map +0 -1
  156. package/dist/editor-labels.js.map +0 -1
  157. package/dist/enter-policy.d.ts.map +0 -1
  158. package/dist/enter-policy.js.map +0 -1
  159. package/dist/fences.d.ts.map +0 -1
  160. package/dist/fences.js.map +0 -1
  161. package/dist/i18n.d.ts.map +0 -1
  162. package/dist/i18n.js.map +0 -1
  163. package/dist/index.d.ts.map +0 -1
  164. package/dist/index.js.map +0 -1
  165. package/dist/latex.d.ts.map +0 -1
  166. package/dist/latex.js.map +0 -1
  167. package/dist/lines.d.ts.map +0 -1
  168. package/dist/lines.js.map +0 -1
  169. package/dist/math-editor-toolbar.d.ts.map +0 -1
  170. package/dist/math-editor-toolbar.js.map +0 -1
  171. package/dist/math-editor.d.ts.map +0 -1
  172. package/dist/math-editor.js.map +0 -1
  173. package/dist/matrix.d.ts.map +0 -1
  174. package/dist/matrix.js.map +0 -1
  175. package/dist/model.d.ts.map +0 -1
  176. package/dist/model.js.map +0 -1
  177. package/dist/outputs.d.ts.map +0 -1
  178. package/dist/outputs.js.map +0 -1
  179. package/dist/range.d.ts.map +0 -1
  180. package/dist/range.js.map +0 -1
  181. package/dist/react.d.ts.map +0 -1
  182. package/dist/react.js.map +0 -1
  183. package/dist/session.d.ts.map +0 -1
  184. package/dist/session.js.map +0 -1
  185. package/dist/solid.d.ts.map +0 -1
  186. package/dist/solid.js.map +0 -1
  187. package/dist/suggestions.d.ts.map +0 -1
  188. package/dist/suggestions.js.map +0 -1
  189. package/dist/svelte.d.ts.map +0 -1
  190. package/dist/svelte.js.map +0 -1
  191. package/dist/symbol-browser.d.ts.map +0 -1
  192. package/dist/symbol-browser.js.map +0 -1
  193. package/dist/symbols.d.ts.map +0 -1
  194. package/dist/symbols.js.map +0 -1
  195. package/dist/templates.d.ts.map +0 -1
  196. package/dist/templates.js.map +0 -1
  197. package/dist/tokens.d.ts.map +0 -1
  198. package/dist/tokens.js.map +0 -1
  199. package/dist/vue.d.ts.map +0 -1
  200. package/dist/vue.js.map +0 -1
  201. package/dist/web-component.d.ts.map +0 -1
  202. package/dist/web-component.js.map +0 -1
@@ -0,0 +1,22 @@
1
+ /** Keep focus in the same document (including iframe hosts). Arrow keys only browse; Enter applies. */
2
+ export function focusContextAction(host, key) {
3
+ const buttons = Array.from(host.querySelectorAll('button:not(:disabled)'));
4
+ if (!buttons.length)
5
+ return false;
6
+ const index = buttons.indexOf(host.ownerDocument.activeElement);
7
+ let next;
8
+ if (key === 'F6')
9
+ next = buttons.find((button) => button.getAttribute('aria-pressed') === 'true') ?? buttons[0];
10
+ else if (key === 'ArrowRight')
11
+ next = buttons[(index + 1) % buttons.length];
12
+ else if (key === 'ArrowLeft')
13
+ next = buttons[(index - 1 + buttons.length) % buttons.length];
14
+ else if (key === 'Home')
15
+ next = buttons[0];
16
+ else if (key === 'End')
17
+ next = buttons[buttons.length - 1];
18
+ if (!next)
19
+ return false;
20
+ next.focus({ preventScroll: true });
21
+ return true;
22
+ }
@@ -1,3 +1,2 @@
1
1
  /** Shared native element factory. Text is assigned with textContent, never parsed as HTML. */
2
- export declare const element: <K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string) => HTMLElementTagNameMap[K];
3
- //# sourceMappingURL=elements.d.ts.map
2
+ export declare const elementFactory: (doc: Document) => <K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string) => HTMLElementTagNameMap[K];
@@ -1,9 +1,8 @@
1
1
  /** Shared native element factory. Text is assigned with textContent, never parsed as HTML. */
2
- export const element = (tag, className = '', text) => {
3
- const node = document.createElement(tag);
2
+ export const elementFactory = (doc) => (tag, className = '', text) => {
3
+ const node = doc.createElement(tag);
4
4
  node.className = className;
5
5
  if (text !== undefined)
6
6
  node.textContent = text;
7
7
  return node;
8
8
  };
9
- //# sourceMappingURL=elements.js.map
@@ -0,0 +1,10 @@
1
+ import { type MathLocale } from '../i18n.js';
2
+ import type { LatexParseResult } from '../latex.js';
3
+ /** Explicit, nonmodal paste UI. It never reads the OS clipboard without a user paste. */
4
+ export declare function mountLatexPanel(host: HTMLElement, options: {
5
+ locale: MathLocale;
6
+ onInsert(source: string): LatexParseResult;
7
+ onClose(): void;
8
+ }): {
9
+ destroy(): void;
10
+ };
@@ -0,0 +1,59 @@
1
+ import { elementFactory } from './elements.js';
2
+ import { translate } from '../i18n.js';
3
+ /** Explicit, nonmodal paste UI. It never reads the OS clipboard without a user paste. */
4
+ export function mountLatexPanel(host, options) {
5
+ const el = elementFactory(host.ownerDocument);
6
+ const t = (key) => translate(options.locale, key);
7
+ const panel = el('section', 'me-utility-panel');
8
+ panel.setAttribute('aria-label', t('clipboard.latexTitle'));
9
+ const label = el('label', '', t('clipboard.latexTitle'));
10
+ const source = el('textarea');
11
+ source.rows = 3;
12
+ source.placeholder = String.raw `\frac{a}{b} + x^2`;
13
+ source.spellcheck = false;
14
+ label.append(source);
15
+ const hint = el('p', '', t('clipboard.latexHint'));
16
+ const error = el('p', 'me-utility-error');
17
+ error.setAttribute('role', 'alert');
18
+ const actions = el('div', 'me-utility-actions');
19
+ const insert = el('button', '', t('clipboard.latexInsert'));
20
+ insert.type = 'button';
21
+ insert.onclick = () => {
22
+ const result = options.onInsert(source.value);
23
+ if (result.ok)
24
+ options.onClose();
25
+ else {
26
+ const diagnostic = result.diagnostics[0];
27
+ error.textContent = `${t('clipboard.latexError')} (${diagnostic.start + 1}): ${diagnostic.message}`;
28
+ source.focus();
29
+ source.setSelectionRange(diagnostic.start, diagnostic.end);
30
+ }
31
+ };
32
+ const close = el('button', '', t('common.close'));
33
+ close.type = 'button';
34
+ close.onclick = options.onClose;
35
+ actions.append(insert, close);
36
+ panel.append(label, hint, error, actions);
37
+ host.append(panel);
38
+ panel.addEventListener('keydown', (event) => {
39
+ event.stopPropagation();
40
+ if (event.isComposing)
41
+ return;
42
+ if (event.key === 'Escape') {
43
+ event.preventDefault();
44
+ options.onClose();
45
+ }
46
+ else if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
47
+ event.preventDefault();
48
+ insert.click();
49
+ }
50
+ });
51
+ for (const type of ['copy', 'cut', 'paste', 'input', 'beforeinput', 'keyup'])
52
+ panel.addEventListener(type, (event) => event.stopPropagation());
53
+ source.focus();
54
+ return {
55
+ destroy() {
56
+ panel.remove();
57
+ },
58
+ };
59
+ }
@@ -0,0 +1,11 @@
1
+ import type { MathMatrixRange } from '../matrix-range.js';
2
+ type CellRect = {
3
+ left: number;
4
+ right: number;
5
+ top: number;
6
+ bottom: number;
7
+ };
8
+ /** Grid tracks, rather than nearest glyphs, keep tall fractions from stealing adjacent cells. */
9
+ export declare function matrixCellAtPoint(columns: number, cells: CellRect[], x: number, y: number): number;
10
+ export declare function matrixPointerRange(surface: HTMLElement, range: MathMatrixRange, columns: number, x: number, y: number): MathMatrixRange;
11
+ export {};
@@ -0,0 +1,30 @@
1
+ /** Grid tracks, rather than nearest glyphs, keep tall fractions from stealing adjacent cells. */
2
+ export function matrixCellAtPoint(columns, cells, x, y) {
3
+ const nearest = (axis) => {
4
+ const count = axis === 'row' ? cells.length / columns : columns;
5
+ let chosen = 0, distance = Infinity;
6
+ for (let track = 0; track < count; track++) {
7
+ const group = cells.filter((_, i) => (axis === 'row' ? Math.floor(i / columns) : i % columns) === track);
8
+ const start = Math.min(...group.map((cell) => (axis === 'row' ? cell.top : cell.left)));
9
+ const end = Math.max(...group.map((cell) => (axis === 'row' ? cell.bottom : cell.right)));
10
+ const point = axis === 'row' ? y : x;
11
+ const next = Math.max(start - point, 0, point - end);
12
+ if (next < distance) {
13
+ chosen = track;
14
+ distance = next;
15
+ }
16
+ }
17
+ return chosen;
18
+ };
19
+ return nearest('row') * columns + nearest('column');
20
+ }
21
+ export function matrixPointerRange(surface, range, columns, x, y) {
22
+ const grid = [...surface.querySelectorAll('.me-matrix')].find((node) => node.dataset.matrixId === range.matrixId);
23
+ const cells = grid?.querySelectorAll(':scope > .me-matrix-grid > .me-matrix-cell');
24
+ if (!cells?.length)
25
+ return range;
26
+ return {
27
+ ...range,
28
+ focus: matrixCellAtPoint(columns, [...cells].map((cell) => cell.getBoundingClientRect()), x, y),
29
+ };
30
+ }
@@ -1,3 +1,18 @@
1
+ interface MenuRect {
2
+ left: number;
3
+ top: number;
4
+ right: number;
5
+ bottom: number;
6
+ }
7
+ /** Follow layout and visual viewport changes, including ancestor transforms that
8
+ * do not produce window resize or element ResizeObserver notifications. */
9
+ export declare function followMathMenuPosition(anchor: HTMLElement, position: () => void, ignoreScrollWithin?: HTMLElement): () => void;
10
+ /** Choose a scrollable vertical gap without covering host controls in the same column. */
11
+ export declare function mathMenuPlacement(bounds: MenuRect, anchor: MenuRect, width: number, desiredHeight: number, preferredHeight: number, avoid?: readonly MenuRect[]): {
12
+ left: number;
13
+ top: number;
14
+ height: number;
15
+ };
1
16
  /** Position a fixed portal in viewport coordinates, including transformed modal hosts. */
2
- export declare function positionMathMenu(menu: HTMLElement, anchor: DOMRect, maxHeight: number): void;
3
- //# sourceMappingURL=menu-position.d.ts.map
17
+ export declare function positionMathMenu(menu: HTMLElement, anchor: DOMRect, maxHeight: number, avoidElements?: readonly HTMLElement[]): void;
18
+ export {};
@@ -1,12 +1,126 @@
1
+ /** Follow layout and visual viewport changes, including ancestor transforms that
2
+ * do not produce window resize or element ResizeObserver notifications. */
3
+ export function followMathMenuPosition(anchor, position, ignoreScrollWithin) {
4
+ const win = anchor.ownerDocument.defaultView;
5
+ let frame = 0;
6
+ const schedule = () => {
7
+ if (!frame)
8
+ frame = win.requestAnimationFrame(() => {
9
+ frame = 0;
10
+ position();
11
+ });
12
+ };
13
+ const scroll = (event) => {
14
+ if (event.target &&
15
+ 'nodeType' in event.target &&
16
+ ignoreScrollWithin?.contains(event.target))
17
+ return;
18
+ schedule();
19
+ };
20
+ const changes = new MutationObserver(schedule);
21
+ for (let element = anchor; element; element = element.parentElement)
22
+ changes.observe(element, { attributes: true, attributeFilter: ['style', 'class'] });
23
+ const resize = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(schedule);
24
+ resize?.observe(anchor);
25
+ win.addEventListener('resize', schedule);
26
+ win.addEventListener('scroll', scroll, true);
27
+ win.visualViewport?.addEventListener('resize', schedule);
28
+ win.visualViewport?.addEventListener('scroll', schedule);
29
+ return () => {
30
+ changes.disconnect();
31
+ resize?.disconnect();
32
+ win.cancelAnimationFrame(frame);
33
+ win.removeEventListener('resize', schedule);
34
+ win.removeEventListener('scroll', scroll, true);
35
+ win.visualViewport?.removeEventListener('resize', schedule);
36
+ win.visualViewport?.removeEventListener('scroll', schedule);
37
+ };
38
+ }
39
+ /** Fixed menus escape ordinary scrolling text areas. Transforms and containment
40
+ * establish a local containing block, so overflow at that block and above still clips. */
41
+ function establishesFixedContainingBlock(style) {
42
+ const properties = [
43
+ 'transform',
44
+ 'translate',
45
+ 'rotate',
46
+ 'scale',
47
+ 'perspective',
48
+ 'filter',
49
+ 'backdrop-filter',
50
+ ];
51
+ return (properties.some((property) => {
52
+ const value = style.getPropertyValue(property);
53
+ return value !== '' && value !== 'none';
54
+ }) ||
55
+ /layout|paint|strict|content/.test(style.contain) ||
56
+ style.contentVisibility === 'auto' ||
57
+ style.willChange.split(',').some((property) => properties.includes(property.trim())));
58
+ }
59
+ /** Choose a scrollable vertical gap without covering host controls in the same column. */
60
+ export function mathMenuPlacement(bounds, anchor, width, desiredHeight, preferredHeight, avoid = []) {
61
+ const left = Math.max(bounds.left, Math.min(anchor.left, bounds.right - width));
62
+ const blockers = avoid
63
+ .filter((rect) => rect.left < left + width && rect.right > left)
64
+ .sort((a, b) => a.top - b.top);
65
+ const gaps = (top, bottom) => {
66
+ const result = [];
67
+ let start = top;
68
+ for (const rect of blockers) {
69
+ if (rect.bottom + 4 <= start || rect.top - 4 >= bottom)
70
+ continue;
71
+ if (rect.top - 4 > start)
72
+ result.push({ top: start, bottom: Math.min(bottom, rect.top - 4) });
73
+ start = Math.max(start, rect.bottom + 4);
74
+ if (start >= bottom)
75
+ break;
76
+ }
77
+ if (start < bottom)
78
+ result.push({ top: start, bottom });
79
+ return result;
80
+ };
81
+ const capacity = (gap) => gap.bottom - gap.top;
82
+ const below = gaps(Math.max(bounds.top, anchor.bottom + 4), bounds.bottom);
83
+ const above = gaps(bounds.top, Math.min(bounds.bottom, anchor.top - 4)).reverse();
84
+ const largest = (items) => Math.max(0, ...items.map(capacity));
85
+ // Retain direction while typing, independent of the current number of candidates.
86
+ const threshold = Math.min(preferredHeight, 160);
87
+ let placeBelow = largest(below) >= threshold || largest(below) >= largest(above);
88
+ const preferred = placeBelow ? below : above;
89
+ let gap = preferred.find((item) => capacity(item) >= threshold) ??
90
+ preferred.reduce((best, item) => (!best || capacity(item) > capacity(best) ? item : best), undefined);
91
+ if (!gap || capacity(gap) < Math.min(48, desiredHeight)) {
92
+ // A short clipped host can leave space only beside the anchor vertically.
93
+ // Overlap the expression if necessary before overlapping its action controls.
94
+ const full = gaps(bounds.top, bounds.bottom).sort((a, b) => capacity(b) - capacity(a))[0];
95
+ if (full && (!gap || capacity(full) > capacity(gap))) {
96
+ gap = full;
97
+ placeBelow = true;
98
+ }
99
+ }
100
+ // If controls occupy the entire clipping rectangle, no disjoint placement exists.
101
+ // Keep the menu scrollable within that rectangle rather than silently hiding it.
102
+ gap ??= { top: bounds.top, bottom: bounds.bottom };
103
+ const height = Math.max(0, Math.min(desiredHeight, capacity(gap)));
104
+ return { left, top: placeBelow ? gap.top : gap.bottom - height, height };
105
+ }
1
106
  /** Position a fixed portal in viewport coordinates, including transformed modal hosts. */
2
- export function positionMathMenu(menu, anchor, maxHeight) {
107
+ export function positionMathMenu(menu, anchor, maxHeight, avoidElements = []) {
3
108
  const win = menu.ownerDocument.defaultView;
4
- const bounds = { left: 8, top: 8, right: win.innerWidth - 8, bottom: win.innerHeight - 8 };
109
+ const viewport = win.visualViewport;
110
+ const bounds = {
111
+ left: (viewport?.offsetLeft ?? 0) + 8,
112
+ top: (viewport?.offsetTop ?? 0) + 8,
113
+ right: (viewport?.offsetLeft ?? 0) + (viewport?.width ?? win.innerWidth) - 8,
114
+ bottom: (viewport?.offsetTop ?? 0) + (viewport?.height ?? win.innerHeight) - 8,
115
+ };
116
+ let contained = false;
5
117
  for (let parent = menu.parentElement; parent; parent = parent.parentElement) {
6
118
  const style = win.getComputedStyle(parent);
119
+ contained ||= establishesFixedContainingBlock(style);
7
120
  const dialog = parent.matches('dialog, [role="dialog"]');
8
- const clipX = dialog || /auto|scroll|hidden|clip/.test(style.overflowX);
9
- const clipY = dialog || /auto|scroll|hidden|clip/.test(style.overflowY);
121
+ const paintClip = /paint|strict|content/.test(style.contain) || style.contentVisibility === 'auto';
122
+ const clipX = dialog || paintClip || (contained && /auto|scroll|hidden|clip/.test(style.overflowX));
123
+ const clipY = dialog || paintClip || (contained && /auto|scroll|hidden|clip/.test(style.overflowY));
10
124
  if (!clipX && !clipY)
11
125
  continue;
12
126
  const rect = parent.getBoundingClientRect();
@@ -23,22 +137,21 @@ export function positionMathMenu(menu, anchor, maxHeight) {
23
137
  // A dialog's transform/translate establishes a local origin and may also scale it.
24
138
  menu.style.left = '0px';
25
139
  menu.style.top = '0px';
140
+ menu.style.boxSizing = 'border-box';
26
141
  menu.style.maxWidth = '';
27
142
  menu.style.maxHeight = `${maxHeight}px`;
28
143
  const origin = menu.getBoundingClientRect();
29
144
  const scaleX = origin.width / menu.offsetWidth || 1;
30
145
  const scaleY = origin.height / menu.offsetHeight || 1;
31
146
  menu.style.maxWidth = `${Math.max(0, bounds.right - bounds.left) / scaleX}px`;
32
- const desiredHeight = Math.min(menu.scrollHeight * scaleY, maxHeight * scaleY);
33
- const below = Math.max(0, bounds.bottom - anchor.bottom - 4);
34
- const above = Math.max(0, anchor.top - bounds.top - 4);
35
- // Choose direction by available space, not candidate count, so typing does not flip the list.
36
- const placeBelow = below >= Math.min(maxHeight * scaleY, 160) || below >= above;
37
- menu.style.maxHeight = `${Math.min(desiredHeight, placeBelow ? below : above) / scaleY}px`;
38
- const size = menu.getBoundingClientRect();
39
- const left = Math.max(bounds.left, Math.min(anchor.left, bounds.right - size.width));
40
- const top = Math.max(bounds.top, Math.min(placeBelow ? anchor.bottom + 4 : anchor.top - size.height - 4, bounds.bottom - size.height));
41
- menu.style.left = `${(left - origin.left) / scaleX}px`;
42
- menu.style.top = `${(top - origin.top) / scaleY}px`;
147
+ const borderHeight = menu.offsetHeight - menu.clientHeight;
148
+ const desiredHeight = Math.min((menu.scrollHeight + borderHeight) * scaleY, maxHeight * scaleY);
149
+ const avoid = avoidElements
150
+ .filter((element) => element.ownerDocument === menu.ownerDocument && element.isConnected)
151
+ .map((element) => element.getBoundingClientRect())
152
+ .filter((rect) => rect.width > 0 && rect.height > 0);
153
+ const placement = mathMenuPlacement(bounds, anchor, menu.getBoundingClientRect().width, desiredHeight, maxHeight * scaleY, avoid);
154
+ menu.style.maxHeight = `${placement.height / scaleY}px`;
155
+ menu.style.left = `${(placement.left - origin.left) / scaleX}px`;
156
+ menu.style.top = `${(placement.top - origin.top) / scaleY}px`;
43
157
  }
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
+ };