@elabs-ai/components-editor 4.0.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 (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/dist/chunk-LBC5VJBD.js +3748 -0
  4. package/dist/chunk-LBC5VJBD.js.map +1 -0
  5. package/dist/index.css +233 -0
  6. package/dist/index.css.map +1 -0
  7. package/dist/index.d.ts +199 -0
  8. package/dist/index.js +362 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/lib/monaco-environment.d.ts +2 -0
  11. package/dist/lib/monaco-environment.js +44 -0
  12. package/dist/lib/monaco-environment.js.map +1 -0
  13. package/dist/markdown/frontmatter.d.ts +11 -0
  14. package/dist/markdown/frontmatter.js +33 -0
  15. package/dist/markdown/frontmatter.js.map +1 -0
  16. package/dist/markdown/index.css +233 -0
  17. package/dist/markdown/index.css.map +1 -0
  18. package/dist/markdown/index.d.ts +1486 -0
  19. package/dist/markdown/index.js +4882 -0
  20. package/dist/markdown/index.js.map +1 -0
  21. package/dist/markdown/parse.d.ts +25 -0
  22. package/dist/markdown/parse.js +14 -0
  23. package/dist/markdown/parse.js.map +1 -0
  24. package/dist/markdown-editor-DfBZibAn.d.ts +702 -0
  25. package/package.json +101 -0
  26. package/src/ai-objects/ai-objects-integration.test.tsx +128 -0
  27. package/src/ai-objects/ai-objects.stories.tsx +233 -0
  28. package/src/ai-objects/ai-objects.test.tsx +205 -0
  29. package/src/ai-objects/decision-card.tsx +178 -0
  30. package/src/ai-objects/directives.ts +136 -0
  31. package/src/ai-objects/entity.tsx +181 -0
  32. package/src/ai-objects/index.ts +34 -0
  33. package/src/ai-objects/knowledge-card.tsx +143 -0
  34. package/src/calc-block/calc-block.stories.tsx +297 -0
  35. package/src/calc-block/calc-block.test.tsx +164 -0
  36. package/src/calc-block/calc-block.tsx +398 -0
  37. package/src/calc-block/calc-editor-monaco.ts +214 -0
  38. package/src/calc-block/calc-editor-prose.ts +128 -0
  39. package/src/calc-block/calc-editor.css +80 -0
  40. package/src/calc-block/calc-editor.stories.tsx +272 -0
  41. package/src/calc-block/calc-editor.test.ts +228 -0
  42. package/src/calc-block/calc-editor.ts +270 -0
  43. package/src/calc-block/calc-inline.stories.tsx +44 -0
  44. package/src/calc-block/calc-inline.test.tsx +44 -0
  45. package/src/calc-block/calc-inline.tsx +75 -0
  46. package/src/calc-block/index.ts +20 -0
  47. package/src/calc-block/types.ts +164 -0
  48. package/src/code-editor/code-editor.stories.tsx +218 -0
  49. package/src/code-editor/code-editor.test.tsx +134 -0
  50. package/src/code-editor/code-editor.tsx +250 -0
  51. package/src/code-editor/index.ts +6 -0
  52. package/src/code-workspace/code-workspace.stories.tsx +51 -0
  53. package/src/code-workspace/code-workspace.test.tsx +146 -0
  54. package/src/code-workspace/code-workspace.tsx +253 -0
  55. package/src/code-workspace/index.ts +6 -0
  56. package/src/copy-button/copy-button.tsx +51 -0
  57. package/src/copy-button/index.ts +1 -0
  58. package/src/css.d.ts +3 -0
  59. package/src/diff-editor/diff-editor.stories.tsx +44 -0
  60. package/src/diff-editor/diff-editor.test.tsx +68 -0
  61. package/src/diff-editor/diff-editor.tsx +151 -0
  62. package/src/diff-editor/index.ts +1 -0
  63. package/src/editor-context-menu/editor-context-menu.tsx +120 -0
  64. package/src/editor-context-menu/index.ts +1 -0
  65. package/src/editor-toolbar/editor-toolbar.tsx +83 -0
  66. package/src/editor-toolbar/index.ts +1 -0
  67. package/src/index.ts +48 -0
  68. package/src/lib/editor-completions-monaco.test.ts +206 -0
  69. package/src/lib/editor-completions-monaco.ts +127 -0
  70. package/src/lib/editor-completions.test.ts +156 -0
  71. package/src/lib/editor-completions.ts +153 -0
  72. package/src/lib/editor-content-access-prose.test.ts +411 -0
  73. package/src/lib/editor-content-access-prose.ts +172 -0
  74. package/src/lib/editor-content-access.test.ts +182 -0
  75. package/src/lib/editor-content-access.ts +109 -0
  76. package/src/lib/languages.ts +32 -0
  77. package/src/lib/markdown/diff.test.ts +80 -0
  78. package/src/lib/markdown/diff.ts +194 -0
  79. package/src/lib/markdown/directives.ts +342 -0
  80. package/src/lib/markdown/frontmatter.ts +50 -0
  81. package/src/lib/markdown/markdown-scale.test.ts +65 -0
  82. package/src/lib/markdown/markdown-scale.ts +62 -0
  83. package/src/lib/markdown/merge.test.ts +64 -0
  84. package/src/lib/markdown/merge.ts +158 -0
  85. package/src/lib/markdown/slugify.test.ts +156 -0
  86. package/src/lib/markdown/slugify.ts +47 -0
  87. package/src/lib/monaco-environment.ts +103 -0
  88. package/src/lib/monaco-theme-bridge.ts +293 -0
  89. package/src/lib/use-data-theme.ts +61 -0
  90. package/src/markdown/frontmatter.ts +14 -0
  91. package/src/markdown/index.ts +323 -0
  92. package/src/markdown/parse.test.ts +60 -0
  93. package/src/markdown/parse.ts +38 -0
  94. package/src/markdown-academic/citations.test.ts +77 -0
  95. package/src/markdown-academic/citations.tsx +442 -0
  96. package/src/markdown-academic/footnotes.tsx +235 -0
  97. package/src/markdown-academic/index.ts +32 -0
  98. package/src/markdown-academic/math.tsx +163 -0
  99. package/src/markdown-academic/toc.tsx +88 -0
  100. package/src/markdown-editor/completions/completions-menu.tsx +96 -0
  101. package/src/markdown-editor/completions/completions-prose.test.ts +356 -0
  102. package/src/markdown-editor/completions/completions-prose.ts +313 -0
  103. package/src/markdown-editor/completions/completions-widget.tsx +75 -0
  104. package/src/markdown-editor/completions/index.ts +37 -0
  105. package/src/markdown-editor/directive-nodes.ts +151 -0
  106. package/src/markdown-editor/directive-views.test.tsx +189 -0
  107. package/src/markdown-editor/directive-views.tsx +795 -0
  108. package/src/markdown-editor/exit-keymap.test.ts +88 -0
  109. package/src/markdown-editor/exit-keymap.ts +87 -0
  110. package/src/markdown-editor/index.ts +6 -0
  111. package/src/markdown-editor/markdown-editor.css +250 -0
  112. package/src/markdown-editor/markdown-editor.directives.test.tsx +105 -0
  113. package/src/markdown-editor/markdown-editor.fill.test.ts +34 -0
  114. package/src/markdown-editor/markdown-editor.paste-embed.test.tsx +297 -0
  115. package/src/markdown-editor/markdown-editor.stories.tsx +560 -0
  116. package/src/markdown-editor/markdown-editor.strictmode.test.tsx +38 -0
  117. package/src/markdown-editor/markdown-editor.table.test.tsx +287 -0
  118. package/src/markdown-editor/markdown-editor.tsx +560 -0
  119. package/src/markdown-editor/milkdown-react/editor.tsx +33 -0
  120. package/src/markdown-editor/milkdown-react/index.ts +8 -0
  121. package/src/markdown-editor/milkdown-react/types.ts +27 -0
  122. package/src/markdown-editor/milkdown-react/use-editor.ts +27 -0
  123. package/src/markdown-editor/milkdown-react/use-get-editor.ts +56 -0
  124. package/src/markdown-editor/milkdown-react/use-instance.ts +23 -0
  125. package/src/markdown-editor/paste-embed.ts +355 -0
  126. package/src/markdown-editor/slash/brand-slash-commands.test.ts +194 -0
  127. package/src/markdown-editor/slash/brand-slash-commands.ts +324 -0
  128. package/src/markdown-editor/slash/brand-slash-plugin.test.ts +254 -0
  129. package/src/markdown-editor/slash/brand-slash-plugin.ts +385 -0
  130. package/src/markdown-editor/slash/index.ts +107 -0
  131. package/src/markdown-editor/slash/insert-directive.test.ts +263 -0
  132. package/src/markdown-editor/slash/insert-directive.ts +345 -0
  133. package/src/markdown-editor/slash/monaco-slash-menu.test.tsx +198 -0
  134. package/src/markdown-editor/slash/monaco-slash-menu.tsx +356 -0
  135. package/src/markdown-editor/slash/shortcut-monaco.ts +69 -0
  136. package/src/markdown-editor/slash/shortcut.test.ts +157 -0
  137. package/src/markdown-editor/slash/shortcut.ts +57 -0
  138. package/src/markdown-editor/slash/slash-menu.stories.tsx +139 -0
  139. package/src/markdown-editor/slash/slash-menu.tsx +115 -0
  140. package/src/markdown-editor/slash/slash-scroll.test.tsx +52 -0
  141. package/src/markdown-editor/slash/slash-widget.tsx +97 -0
  142. package/src/markdown-editor/slash/source-slash-trigger.test.ts +42 -0
  143. package/src/markdown-editor/slash/source-slash-trigger.ts +46 -0
  144. package/src/markdown-editor/table-view.tsx +249 -0
  145. package/src/markdown-iteration/directive.tsx +92 -0
  146. package/src/markdown-iteration/edit-context.ts +56 -0
  147. package/src/markdown-iteration/index.ts +24 -0
  148. package/src/markdown-iteration/iteration-block.test.tsx +138 -0
  149. package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +293 -0
  150. package/src/markdown-iteration/iteration-builder-dialog.tsx +312 -0
  151. package/src/markdown-iteration/iteration-builder.test.ts +252 -0
  152. package/src/markdown-iteration/iteration-builder.ts +303 -0
  153. package/src/markdown-iteration/iteration.test.ts +56 -0
  154. package/src/markdown-iteration/iteration.tsx +333 -0
  155. package/src/markdown-iteration/template-dialog.stories.tsx +63 -0
  156. package/src/markdown-iteration/template-dialog.test.tsx +104 -0
  157. package/src/markdown-iteration/template-dialog.tsx +116 -0
  158. package/src/markdown-outline/document-outline.stories.tsx +76 -0
  159. package/src/markdown-outline/document-outline.tsx +83 -0
  160. package/src/markdown-outline/index.ts +2 -0
  161. package/src/markdown-outline/markdown-outline.test.ts +49 -0
  162. package/src/markdown-outline/markdown-outline.ts +60 -0
  163. package/src/markdown-preview/code-fence.tsx +193 -0
  164. package/src/markdown-preview/index.ts +8 -0
  165. package/src/markdown-preview/markdown-preview-academic.stories.tsx +203 -0
  166. package/src/markdown-preview/markdown-preview-academic.test.tsx +257 -0
  167. package/src/markdown-preview/markdown-preview-iteration-calc.test.tsx +71 -0
  168. package/src/markdown-preview/markdown-preview-iteration-nested.test.tsx +58 -0
  169. package/src/markdown-preview/markdown-preview-iteration.stories.tsx +187 -0
  170. package/src/markdown-preview/markdown-preview-iteration.test.tsx +90 -0
  171. package/src/markdown-preview/markdown-preview-linking.test.tsx +61 -0
  172. package/src/markdown-preview/markdown-preview-transclusion.test.tsx +33 -0
  173. package/src/markdown-preview/markdown-preview.stories.tsx +108 -0
  174. package/src/markdown-preview/markdown-preview.test.tsx +411 -0
  175. package/src/markdown-preview/markdown-preview.tsx +1541 -0
  176. package/src/markdown-toolbar/index.ts +8 -0
  177. package/src/markdown-toolbar/markdown-commands.test.ts +84 -0
  178. package/src/markdown-toolbar/markdown-commands.ts +104 -0
  179. package/src/markdown-toolbar/markdown-toolbar.stories.tsx +56 -0
  180. package/src/markdown-toolbar/markdown-toolbar.tsx +263 -0
  181. package/src/markdown-workspace/focus-writing.test.ts +52 -0
  182. package/src/markdown-workspace/focus-writing.ts +38 -0
  183. package/src/markdown-workspace/index.ts +6 -0
  184. package/src/markdown-workspace/markdown-workspace.stories.tsx +688 -0
  185. package/src/markdown-workspace/markdown-workspace.test.tsx +326 -0
  186. package/src/markdown-workspace/markdown-workspace.tsx +787 -0
  187. package/src/mermaid-diagram/index.ts +1 -0
  188. package/src/mermaid-diagram/mermaid-diagram.stories.tsx +42 -0
  189. package/src/mermaid-diagram/mermaid-diagram.test.tsx +86 -0
  190. package/src/mermaid-diagram/mermaid-diagram.tsx +318 -0
  191. package/src/mermaid-diagram/mermaid-viewer.test.tsx +89 -0
  192. package/src/mermaid-diagram/mermaid-viewer.tsx +367 -0
  193. package/src/mermaid-diagram/real-parse.test.ts +42 -0
  194. package/src/mermaid-diagram/remediate.test.ts +60 -0
  195. package/src/mermaid-diagram/remediate.ts +124 -0
  196. package/src/mermaid-workspace/index.ts +1 -0
  197. package/src/mermaid-workspace/mermaid-workspace.stories.tsx +28 -0
  198. package/src/mermaid-workspace/mermaid-workspace.tsx +71 -0
  199. package/src/metric-block/index.ts +1 -0
  200. package/src/metric-block/metric-block.stories.tsx +40 -0
  201. package/src/metric-block/metric-block.tsx +14 -0
  202. package/src/monaco-workers.d.ts +10 -0
  203. package/src/prose/index.ts +14 -0
  204. package/src/prose/prose.stories.tsx +44 -0
  205. package/src/prose/prose.test.tsx +44 -0
  206. package/src/prose/prose.tsx +21 -0
  207. package/src/timeline/index.ts +18 -0
@@ -0,0 +1,398 @@
1
+ "use client";
2
+
3
+ /**
4
+ * CalcBlock — render a ```calc fence as a two-column "sheet" (#L1).
5
+ *
6
+ * The Soulver/Notes-Calculator model: expressions on the left, computed answers
7
+ * on the right (`tabular-nums`), a running-total footer. The library RENDERS and
8
+ * the consumer EVALUATES: the math engine is app/domain logic, so CalcBlock takes
9
+ * an `evaluate(source) => CalcSheet` hook (mirroring `MarkdownPreview`'s
10
+ * `resolveUrl`) and bundles no calculator. Per-line errors render inline; a bad
11
+ * block never throws or blanks the document. The mermaid fence is the precedent.
12
+ *
13
+ * Title: a leading `# Heading` becomes the block title (header bar); with none,
14
+ * the header shows the neutral `calc` code-label.
15
+ *
16
+ * Row presentation — a per-row `rule` divider (dotted / single / double) and a
17
+ * semantic `tint` wash — comes from two sources, both pure presentation:
18
+ * 1. the evaluator, as `rule`/`tint` fields on a `CalcLineResult` (app-driven); or
19
+ * 2. a trailing author **marker** the WRITER types in the calc text, which
20
+ * CalcBlock strips before calling `evaluate` (so the math engine never sees
21
+ * it) — the same way it strips `# ` off a heading. Tints: `@primary`,
22
+ * `@success`, `@warning`, `@danger`, `@info`, `@muted`/`@note`. Rules:
23
+ * `@line`, `@line2`/`@double`, `@dotted`. Multiple markers per line compose
24
+ * (`subtotal = a + b @success @line`); an evaluator field wins over a marker.
25
+ * Disable parsing with `markers={false}`. Unknown trailing `@words` are left
26
+ * as literal text (never silently dropped).
27
+ *
28
+ * The footer total defaults to `sheet.total` but can be overridden via the
29
+ * `total` / `totalLabel` props.
30
+ *
31
+ * Syntax highlighting uses the dedicated `--calc-*` tokens (#221) via `text-calc-*`;
32
+ * color is never the only signal (var-def = weight, unresolved = dotted underline),
33
+ * so roles stay distinct in the high-contrast theme.
34
+ */
35
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
36
+ import { TriangleAlert } from "lucide-react";
37
+ import { forwardRef, useId, useMemo, type HTMLAttributes, type ReactNode } from "react";
38
+
39
+ import type {
40
+ CalcLineResult,
41
+ CalcRule,
42
+ CalcTint,
43
+ CalcToken,
44
+ CalcTokenKind,
45
+ CalcValue,
46
+ EvaluateCalc,
47
+ } from "./types";
48
+ // Carry the `.brand-calc-tok--<role>` role colours, so the RENDERED block reads
49
+ // identically to the editor authoring surfaces (which import the same stylesheet).
50
+ import "./calc-editor.css";
51
+
52
+ export interface CalcBlockProps extends Omit<HTMLAttributes<HTMLElement>, "children" | "title"> {
53
+ /** The ```calc fence body. */
54
+ source: string;
55
+ /** Consumer-supplied evaluator (sync). The library bundles no math engine. */
56
+ evaluate: EvaluateCalc;
57
+ /**
58
+ * Block title shown in the header bar. By default a leading `# Heading` line in
59
+ * `source` is lifted here; with neither prop nor heading the header shows the
60
+ * neutral `calc` code-label. Pass `title` to override the displayed text.
61
+ */
62
+ title?: ReactNode;
63
+ /** Show the running-total footer. Default true. */
64
+ showTotal?: boolean;
65
+ /**
66
+ * Override the footer total. Defaults to `sheet.total`. Pass a `CalcValue`
67
+ * (rendered tabular via its `.display`) or any ReactNode to replace the
68
+ * computed total — e.g. a different aggregate the evaluator didn't surface.
69
+ */
70
+ total?: CalcValue | ReactNode;
71
+ /** Footer label. Default `Total`. */
72
+ totalLabel?: ReactNode;
73
+ /**
74
+ * Parse trailing author markers (`@success`, `@line`, …) out of each source
75
+ * line — applying the row's tint/rule and stripping the marker from the
76
+ * rendered text. Default `true`. Set `false` if your calc dialect uses a
77
+ * trailing `@token` for something else.
78
+ */
79
+ markers?: boolean;
80
+ /** Reserved for the editing variant (CalcWorkspace); preview is read-only. */
81
+ readOnly?: boolean;
82
+ }
83
+
84
+ /** Author marker keyword → semantic tint (friendly words; `@danger` = destructive). */
85
+ const TINT_MARKERS: Record<string, CalcTint> = {
86
+ primary: "primary",
87
+ success: "success",
88
+ warning: "warning",
89
+ danger: "destructive",
90
+ destructive: "destructive",
91
+ info: "info",
92
+ muted: "muted",
93
+ note: "muted",
94
+ };
95
+
96
+ /** Author marker keyword → rule (divider) style. */
97
+ const RULE_MARKERS: Record<string, CalcRule> = {
98
+ line: "single",
99
+ rule: "single",
100
+ line2: "double",
101
+ double: "double",
102
+ dotted: "dotted",
103
+ };
104
+
105
+ /** A line's parsed presentation, after author markers are stripped from the text. */
106
+ interface LineMarkers {
107
+ text: string;
108
+ tint?: CalcTint;
109
+ rule?: CalcRule;
110
+ }
111
+
112
+ /**
113
+ * Strip recognized trailing `@marker`s off a line (right-to-left), returning the
114
+ * cleaned text plus the tint/rule they request. An unrecognized trailing `@word`
115
+ * halts stripping and is kept as literal text — markers only ever bind at the end.
116
+ */
117
+ function parseMarkers(line: string): LineMarkers {
118
+ let text = line;
119
+ let tint: CalcTint | undefined;
120
+ let rule: CalcRule | undefined;
121
+ for (;;) {
122
+ const m = /\s+@([A-Za-z][A-Za-z0-9]*)\s*$/.exec(text);
123
+ if (!m) break;
124
+ const key = (m[1] ?? "").toLowerCase();
125
+ if (key in TINT_MARKERS) {
126
+ tint ??= TINT_MARKERS[key];
127
+ } else if (key in RULE_MARKERS) {
128
+ rule ??= RULE_MARKERS[key];
129
+ } else {
130
+ break; // unknown trailing @word — leave it (and everything before) as text
131
+ }
132
+ text = text.slice(0, m.index);
133
+ }
134
+ return { text, tint, rule };
135
+ }
136
+
137
+ /** Per-row rule (divider) presentation. `border-strong` is the sole same-surface cue. */
138
+ const RULE_CLASS: Record<CalcRule, string> = {
139
+ dotted: "border-b border-dotted border-border-strong pb-1",
140
+ single: "border-b border-border-strong pb-1",
141
+ double: "border-b-4 border-double border-border-strong pb-1",
142
+ };
143
+
144
+ /** Per-row background tint — semantic status washes (theme-safe), `muted` neutral. */
145
+ const TINT_CLASS: Record<CalcTint, string> = {
146
+ primary: "bg-primary/10",
147
+ success: "bg-success/10",
148
+ warning: "bg-warning/10",
149
+ destructive: "bg-destructive/10",
150
+ info: "bg-info/10",
151
+ muted: "bg-muted",
152
+ };
153
+
154
+ /** Narrow a `total` override to a `CalcValue` (vs a plain ReactNode like a string). */
155
+ function isCalcValue(x: unknown): x is CalcValue {
156
+ return typeof x === "object" && x !== null && !("$$typeof" in x) && "kind" in x && "display" in x;
157
+ }
158
+
159
+ /**
160
+ * Token kind → highlight class. Color comes from the dedicated `--calc-*` syntax
161
+ * tokens (#221) via the `text-calc-*` utilities; the `brand-calc-tok--<role>`
162
+ * class carries a role-specific, color-independent cue (weight / style / underline)
163
+ * in low-chroma themes (#226) — so in high-contrast, where every inline calc token
164
+ * collapses to one ink, number / unit / currency / var-ref / line-ref / unknown
165
+ * stay distinguishable. The SAME classes drive the editor decorations
166
+ * (calc-editor.css), so both surfaces read identically.
167
+ */
168
+ function tokenClass(kind: CalcTokenKind, resolved: boolean): string {
169
+ let base: string;
170
+ let role: string;
171
+ switch (kind) {
172
+ case "comment":
173
+ base = "text-calc-comment italic";
174
+ role = "brand-calc-tok--comment";
175
+ break;
176
+ case "operator":
177
+ base = "text-calc-operator";
178
+ role = "brand-calc-tok--operator";
179
+ break;
180
+ case "unit":
181
+ base = "text-calc-unit";
182
+ role = "brand-calc-tok--unit";
183
+ break;
184
+ case "currency":
185
+ base = "text-calc-currency";
186
+ role = "brand-calc-tok--currency";
187
+ break;
188
+ case "function":
189
+ base = "text-calc-function";
190
+ role = "brand-calc-tok--function";
191
+ break;
192
+ case "var-ref":
193
+ base = "text-calc-variable";
194
+ role = "brand-calc-tok--var-ref";
195
+ break;
196
+ case "line-ref":
197
+ base = "text-calc-reference tabular-nums";
198
+ role = "brand-calc-tok--line-ref";
199
+ break;
200
+ case "var-def":
201
+ base = "text-calc-variable font-medium";
202
+ role = "brand-calc-tok--var-def";
203
+ break;
204
+ case "unknown":
205
+ base = "text-calc-warning";
206
+ role = "brand-calc-tok--unknown";
207
+ break;
208
+ default: // number, constant
209
+ base = "text-calc-number";
210
+ role = "brand-calc-tok--number";
211
+ }
212
+ return cn(
213
+ "brand-calc-tok",
214
+ role,
215
+ base,
216
+ !resolved && "underline decoration-dotted decoration-1 underline-offset-2",
217
+ );
218
+ }
219
+
220
+ /** Paint one source line: tokenized spans with plain text in the gaps. */
221
+ function renderSource(text: string, tokens: CalcToken[]): ReactNode {
222
+ const out: ReactNode[] = [];
223
+ let cursor = 0;
224
+ for (const [i, t] of tokens.entries()) {
225
+ if (t.start > cursor) {
226
+ out.push(<span key={`gap-${String(i)}`}>{text.slice(cursor, t.start)}</span>);
227
+ }
228
+ out.push(
229
+ <span key={`tok-${String(i)}`} className={tokenClass(t.kind, t.resolved)}>
230
+ {text.slice(t.start, t.end)}
231
+ </span>,
232
+ );
233
+ cursor = t.end;
234
+ }
235
+ if (cursor < text.length) out.push(<span key="tail">{text.slice(cursor)}</span>);
236
+ if (out.length === 0) out.push(<span key="empty">{text || " "}</span>);
237
+ return out;
238
+ }
239
+
240
+ /** The right-hand cell: a value, a calm error marker, or nothing. */
241
+ function ResultCell({ result }: { result: CalcLineResult }): ReactNode {
242
+ if (result.error) {
243
+ // Native `title` (not a Radix Tooltip) so CalcBlock is self-contained — it
244
+ // renders inside MarkdownPreview / a story with no TooltipProvider ancestor.
245
+ return (
246
+ <span
247
+ className="inline-flex shrink-0 text-calc-warning"
248
+ title={result.error.message}
249
+ aria-label={`Error: ${result.error.message}`}
250
+ >
251
+ <TriangleAlert className="size-3.5" aria-hidden="true" />
252
+ </span>
253
+ );
254
+ }
255
+ if (result.value) {
256
+ return (
257
+ <div className="brand-calc-tok--result shrink-0 tabular-nums text-calc-result">
258
+ <span className="sr-only">equals </span>
259
+ <span>{result.value.display}</span>
260
+ </div>
261
+ );
262
+ }
263
+ return null;
264
+ }
265
+
266
+ export const CalcBlock = forwardRef<HTMLDivElement, CalcBlockProps>(function CalcBlock(
267
+ {
268
+ source,
269
+ evaluate,
270
+ title,
271
+ showTotal = true,
272
+ total,
273
+ totalLabel = "Total",
274
+ markers = true,
275
+ readOnly: _readOnly,
276
+ className,
277
+ ...props
278
+ },
279
+ ref,
280
+ ) {
281
+ // Author markers are stripped BEFORE evaluation: the math engine sees clean
282
+ // lines, and because markers are trailing, token columns stay aligned with the
283
+ // rendered (cleaned) text. `lines`/`evalSource` are the marker-free versions.
284
+ const { lines, evalSource, hints } = useMemo(() => {
285
+ const raw = source.split("\n");
286
+ if (!markers) return { lines: raw, evalSource: source, hints: [] as LineMarkers[] };
287
+ const parsed = raw.map(parseMarkers);
288
+ return {
289
+ lines: parsed.map((p) => p.text),
290
+ evalSource: parsed.map((p) => p.text).join("\n"),
291
+ hints: parsed,
292
+ };
293
+ }, [source, markers]);
294
+
295
+ const sheet = useMemo(() => evaluate(evalSource), [evaluate, evalSource]);
296
+ const empty = evalSource.trim() === "";
297
+ const titleId = useId();
298
+
299
+ // A leading `# Heading` is the block title (lifted to the header bar) and is not
300
+ // repeated in the body. Later `#` lines stay as in-body section headings.
301
+ const { titleLine, derivedTitle } = useMemo(() => {
302
+ const idx = lines.findIndex((l) => l.trim() !== "");
303
+ const first = (lines[idx] ?? "").trim();
304
+ return /^#+\s+/.test(first)
305
+ ? { titleLine: idx + 1, derivedTitle: first.replace(/^#+\s*/, "") }
306
+ : { titleLine: 0, derivedTitle: undefined as string | undefined };
307
+ }, [lines]);
308
+
309
+ const hasTitle = title != null || derivedTitle != null;
310
+ const resolvedTitle = title ?? derivedTitle ?? "calc";
311
+
312
+ const resolvedTotal = total ?? sheet.total;
313
+ const totalDisplay = isCalcValue(resolvedTotal) ? resolvedTotal.display : resolvedTotal;
314
+
315
+ const rows: ReactNode[] = [];
316
+ for (const result of sheet.results) {
317
+ if (result.line === titleLine) continue; // lifted to the header bar
318
+ const text = lines[result.line - 1] ?? "";
319
+ const key = `row-${String(result.line)}`;
320
+ if (text.trim() === "") {
321
+ rows.push(<div key={key} className="h-1.5" aria-hidden="true" />);
322
+ continue;
323
+ }
324
+ if (text.trim().startsWith("#")) {
325
+ rows.push(
326
+ <div key={key} className="pt-1 font-semibold text-foreground first:pt-0">
327
+ {text.replace(/^#+\s*/, "")}
328
+ </div>,
329
+ );
330
+ continue;
331
+ }
332
+ // An evaluator-set field wins over an author marker; otherwise the marker applies.
333
+ const hint = hints[result.line - 1];
334
+ const rule = result.rule ?? hint?.rule;
335
+ const tint = result.tint ?? hint?.tint;
336
+ rows.push(
337
+ <div
338
+ key={key}
339
+ data-rule={rule}
340
+ data-tint={tint}
341
+ className={cn(
342
+ "flex items-baseline justify-between gap-x-6",
343
+ rule && RULE_CLASS[rule],
344
+ tint != null && cn("-mx-2 rounded-sm px-2", TINT_CLASS[tint]),
345
+ )}
346
+ >
347
+ <div className="min-w-0 whitespace-pre-wrap break-words text-foreground">
348
+ {renderSource(text, result.tokens)}
349
+ </div>
350
+ <ResultCell result={result} />
351
+ </div>,
352
+ );
353
+ }
354
+
355
+ return (
356
+ <div
357
+ ref={ref}
358
+ data-testid="calc-block"
359
+ role="group"
360
+ aria-labelledby={titleId}
361
+ className={cn("my-4 overflow-hidden rounded-md border border-border bg-card", className)}
362
+ {...props}
363
+ >
364
+ <div className="border-b border-border px-4 py-1.5">
365
+ <span
366
+ id={titleId}
367
+ className={cn(
368
+ "text-meta",
369
+ hasTitle
370
+ ? "font-medium text-foreground"
371
+ : "font-mono uppercase tracking-wide text-muted-foreground",
372
+ )}
373
+ >
374
+ {resolvedTitle}
375
+ </span>
376
+ </div>
377
+
378
+ {empty ? (
379
+ <p className="px-4 py-6 text-body text-muted-foreground">Empty calc block.</p>
380
+ ) : (
381
+ <div className="flex flex-col gap-y-1 px-4 py-3 font-mono text-code leading-relaxed">
382
+ {rows}
383
+ </div>
384
+ )}
385
+
386
+ {showTotal && resolvedTotal != null && !empty ? (
387
+ <div className="flex items-center justify-between border-t border-border px-4 py-2">
388
+ <span className="text-meta font-medium uppercase tracking-wide text-muted-foreground">
389
+ {totalLabel}
390
+ </span>
391
+ <span className="font-mono text-code font-medium tabular-nums text-foreground">
392
+ {totalDisplay}
393
+ </span>
394
+ </div>
395
+ ) : null}
396
+ </div>
397
+ );
398
+ });
@@ -0,0 +1,214 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Monaco calc layer (#220) — live highlighting + autocomplete + result inlays for
5
+ * ```calc fences inside a markdown Monaco model.
6
+ *
7
+ * - HIGHLIGHT is a decoration pass (not a Monarch language): the model is markdown,
8
+ * so we tokenize each calc fence body via the consumer's `tokenize` hook and apply
9
+ * `inlineClassName` decorations colored from the `--calc-*` tokens (calc-editor.css).
10
+ * - COMPLETIONS come from a markdown `CompletionItemProvider` scoped to calc fences,
11
+ * delegating to the consumer's `complete` hook.
12
+ * - INLAYS are Monaco inlay hints (themed via the theme bridge's `editorInlayHint.*`
13
+ * colors, so they re-apply on theme change), built from the consumer's `evaluate`.
14
+ *
15
+ * The providers register ONCE per language and look the active hooks up per-model via
16
+ * a registry, so any number of markdown editors can be wired independently. All the
17
+ * column/position math lives in `calc-editor.ts` (engine-neutral + unit-tested);
18
+ * this module only maps those specs to Monaco objects and owns the lifecycle.
19
+ */
20
+ import * as monaco from "monaco-editor";
21
+
22
+ import "./calc-editor.css";
23
+
24
+ import {
25
+ calcDecorationSpecs,
26
+ calcInlaySpecs,
27
+ findCalcFences,
28
+ identifierPrefix,
29
+ type CalcFence,
30
+ } from "./calc-editor";
31
+ import type { CalcCompletionKind, CalcEditorHooks } from "./types";
32
+
33
+ /** Resolver of the latest hooks for a model (read through a ref so prop-identity churn is free). */
34
+ type HooksGetter = () => CalcEditorHooks | undefined;
35
+
36
+ /** Per-model hook registry the global providers consult. */
37
+ const REGISTRY = new Map<monaco.editor.ITextModel, HooksGetter>();
38
+
39
+ let providersRegistered = false;
40
+ let inlayEmitter: monaco.Emitter<void> | null = null;
41
+
42
+ /** Read a fence body's EOL-free line texts straight from the model (CRLF-safe). */
43
+ function bodyLineTexts(model: monaco.editor.ITextModel, fence: CalcFence): string[] {
44
+ const out: string[] = [];
45
+ for (let ln = fence.bodyStartLine; ln <= fence.bodyEndLine; ln++) {
46
+ out.push(model.getLineContent(ln));
47
+ }
48
+ return out;
49
+ }
50
+
51
+ /** All ```calc fences in the model, scanned from LF-normalized text. */
52
+ function modelFences(model: monaco.editor.ITextModel): CalcFence[] {
53
+ return findCalcFences(model.getValue(monaco.editor.EndOfLinePreference.LF));
54
+ }
55
+
56
+ /** Highlight decorations for every calc fence in the model. */
57
+ function buildDecorations(
58
+ model: monaco.editor.ITextModel,
59
+ hooks: CalcEditorHooks,
60
+ ): monaco.editor.IModelDeltaDecoration[] {
61
+ const decorations: monaco.editor.IModelDeltaDecoration[] = [];
62
+ for (const fence of modelFences(model)) {
63
+ if (fence.bodyEndLine < fence.bodyStartLine) continue;
64
+ const specs = calcDecorationSpecs(hooks, fence.bodyStartLine, bodyLineTexts(model, fence));
65
+ for (const s of specs) {
66
+ decorations.push({
67
+ range: new monaco.Range(s.lineNumber, s.startColumn, s.lineNumber, s.endColumn),
68
+ options: { inlineClassName: s.className },
69
+ });
70
+ }
71
+ }
72
+ return decorations;
73
+ }
74
+
75
+ const COMPLETION_KIND: Record<CalcCompletionKind, () => monaco.languages.CompletionItemKind> = {
76
+ variable: () => monaco.languages.CompletionItemKind.Variable,
77
+ function: () => monaco.languages.CompletionItemKind.Function,
78
+ unit: () => monaco.languages.CompletionItemKind.Unit,
79
+ currency: () => monaco.languages.CompletionItemKind.Unit,
80
+ constant: () => monaco.languages.CompletionItemKind.Constant,
81
+ reference: () => monaco.languages.CompletionItemKind.Reference,
82
+ keyword: () => monaco.languages.CompletionItemKind.Keyword,
83
+ snippet: () => monaco.languages.CompletionItemKind.Snippet,
84
+ };
85
+
86
+ function mapCompletionKind(kind?: CalcCompletionKind): monaco.languages.CompletionItemKind {
87
+ return (COMPLETION_KIND[kind ?? "variable"] ?? COMPLETION_KIND.variable)();
88
+ }
89
+
90
+ /** Register the per-language providers once (idempotent). */
91
+ function ensureProviders(): void {
92
+ if (providersRegistered) return;
93
+ providersRegistered = true;
94
+ inlayEmitter = new monaco.Emitter<void>();
95
+
96
+ monaco.languages.registerInlayHintsProvider("markdown", {
97
+ onDidChangeInlayHints: inlayEmitter.event,
98
+ provideInlayHints(model, range) {
99
+ const empty = { hints: [] as monaco.languages.InlayHint[], dispose() {} };
100
+ const hooks = REGISTRY.get(model)?.();
101
+ if (!hooks?.evaluate) return empty;
102
+ const hints: monaco.languages.InlayHint[] = [];
103
+ for (const fence of modelFences(model)) {
104
+ if (fence.bodyEndLine < fence.bodyStartLine) continue;
105
+ if (
106
+ fence.bodyEndLine < range.startLineNumber ||
107
+ fence.bodyStartLine > range.endLineNumber
108
+ ) {
109
+ continue;
110
+ }
111
+ for (const inlay of calcInlaySpecs(
112
+ hooks,
113
+ fence.bodyStartLine,
114
+ bodyLineTexts(model, fence),
115
+ )) {
116
+ hints.push({
117
+ position: { lineNumber: inlay.lineNumber, column: inlay.column },
118
+ label: inlay.text,
119
+ kind: monaco.languages.InlayHintKind.Type,
120
+ paddingLeft: true,
121
+ });
122
+ }
123
+ }
124
+ return { hints, dispose() {} };
125
+ },
126
+ });
127
+
128
+ monaco.languages.registerCompletionItemProvider("markdown", {
129
+ provideCompletionItems(model, position) {
130
+ const hooks = REGISTRY.get(model)?.();
131
+ if (!hooks?.complete) return { suggestions: [] };
132
+ const fence = modelFences(model).find(
133
+ (f) => position.lineNumber >= f.bodyStartLine && position.lineNumber <= f.bodyEndLine,
134
+ );
135
+ if (!fence) return { suggestions: [] };
136
+ const lines = bodyLineTexts(model, fence);
137
+ const line = lines[position.lineNumber - fence.bodyStartLine] ?? "";
138
+ const column = position.column - 1; // 0-based caret within the line
139
+ const prefix = identifierPrefix(line, column);
140
+ let completions;
141
+ try {
142
+ completions = hooks.complete({
143
+ source: lines.join("\n"),
144
+ line,
145
+ lineNumber: position.lineNumber - fence.bodyStartLine + 1,
146
+ column,
147
+ prefix,
148
+ });
149
+ } catch {
150
+ return { suggestions: [] };
151
+ }
152
+ const replace = new monaco.Range(
153
+ position.lineNumber,
154
+ position.column - prefix.length,
155
+ position.lineNumber,
156
+ position.column,
157
+ );
158
+ return {
159
+ suggestions: completions.map((c) => ({
160
+ label: c.label,
161
+ insertText: c.insert,
162
+ detail: c.detail,
163
+ kind: mapCompletionKind(c.kind),
164
+ range: replace,
165
+ })),
166
+ };
167
+ },
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Wire calc highlighting + completion + inlays onto a markdown Monaco editor.
173
+ * `getHooks` is read fresh on each update, so changing the `calc` prop's identity
174
+ * never forces a re-attach. Returns a disposer; call it on unmount / when `calc`
175
+ * is removed.
176
+ */
177
+ export function attachCalcMonaco(
178
+ editor: monaco.editor.IStandaloneCodeEditor,
179
+ getHooks: HooksGetter,
180
+ ): () => void {
181
+ ensureProviders();
182
+ const collection = editor.createDecorationsCollection();
183
+ const subs: monaco.IDisposable[] = [];
184
+ let model = editor.getModel();
185
+ if (model) REGISTRY.set(model, getHooks);
186
+
187
+ const refresh = () => {
188
+ const current = editor.getModel();
189
+ const hooks = getHooks();
190
+ if (!current || !hooks) {
191
+ collection.clear();
192
+ return;
193
+ }
194
+ REGISTRY.set(current, getHooks);
195
+ collection.set(buildDecorations(current, hooks));
196
+ inlayEmitter?.fire();
197
+ };
198
+
199
+ refresh();
200
+ subs.push(editor.onDidChangeModelContent(refresh));
201
+ subs.push(
202
+ editor.onDidChangeModel(() => {
203
+ if (model) REGISTRY.delete(model);
204
+ model = editor.getModel();
205
+ refresh();
206
+ }),
207
+ );
208
+
209
+ return () => {
210
+ for (const s of subs) s.dispose();
211
+ collection.clear();
212
+ if (model) REGISTRY.delete(model);
213
+ };
214
+ }