@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.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/dist/chunk-LBC5VJBD.js +3748 -0
- package/dist/chunk-LBC5VJBD.js.map +1 -0
- package/dist/index.css +233 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +199 -0
- package/dist/index.js +362 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/monaco-environment.d.ts +2 -0
- package/dist/lib/monaco-environment.js +44 -0
- package/dist/lib/monaco-environment.js.map +1 -0
- package/dist/markdown/frontmatter.d.ts +11 -0
- package/dist/markdown/frontmatter.js +33 -0
- package/dist/markdown/frontmatter.js.map +1 -0
- package/dist/markdown/index.css +233 -0
- package/dist/markdown/index.css.map +1 -0
- package/dist/markdown/index.d.ts +1486 -0
- package/dist/markdown/index.js +4882 -0
- package/dist/markdown/index.js.map +1 -0
- package/dist/markdown/parse.d.ts +25 -0
- package/dist/markdown/parse.js +14 -0
- package/dist/markdown/parse.js.map +1 -0
- package/dist/markdown-editor-DfBZibAn.d.ts +702 -0
- package/package.json +101 -0
- package/src/ai-objects/ai-objects-integration.test.tsx +128 -0
- package/src/ai-objects/ai-objects.stories.tsx +233 -0
- package/src/ai-objects/ai-objects.test.tsx +205 -0
- package/src/ai-objects/decision-card.tsx +178 -0
- package/src/ai-objects/directives.ts +136 -0
- package/src/ai-objects/entity.tsx +181 -0
- package/src/ai-objects/index.ts +34 -0
- package/src/ai-objects/knowledge-card.tsx +143 -0
- package/src/calc-block/calc-block.stories.tsx +297 -0
- package/src/calc-block/calc-block.test.tsx +164 -0
- package/src/calc-block/calc-block.tsx +398 -0
- package/src/calc-block/calc-editor-monaco.ts +214 -0
- package/src/calc-block/calc-editor-prose.ts +128 -0
- package/src/calc-block/calc-editor.css +80 -0
- package/src/calc-block/calc-editor.stories.tsx +272 -0
- package/src/calc-block/calc-editor.test.ts +228 -0
- package/src/calc-block/calc-editor.ts +270 -0
- package/src/calc-block/calc-inline.stories.tsx +44 -0
- package/src/calc-block/calc-inline.test.tsx +44 -0
- package/src/calc-block/calc-inline.tsx +75 -0
- package/src/calc-block/index.ts +20 -0
- package/src/calc-block/types.ts +164 -0
- package/src/code-editor/code-editor.stories.tsx +218 -0
- package/src/code-editor/code-editor.test.tsx +134 -0
- package/src/code-editor/code-editor.tsx +250 -0
- package/src/code-editor/index.ts +6 -0
- package/src/code-workspace/code-workspace.stories.tsx +51 -0
- package/src/code-workspace/code-workspace.test.tsx +146 -0
- package/src/code-workspace/code-workspace.tsx +253 -0
- package/src/code-workspace/index.ts +6 -0
- package/src/copy-button/copy-button.tsx +51 -0
- package/src/copy-button/index.ts +1 -0
- package/src/css.d.ts +3 -0
- package/src/diff-editor/diff-editor.stories.tsx +44 -0
- package/src/diff-editor/diff-editor.test.tsx +68 -0
- package/src/diff-editor/diff-editor.tsx +151 -0
- package/src/diff-editor/index.ts +1 -0
- package/src/editor-context-menu/editor-context-menu.tsx +120 -0
- package/src/editor-context-menu/index.ts +1 -0
- package/src/editor-toolbar/editor-toolbar.tsx +83 -0
- package/src/editor-toolbar/index.ts +1 -0
- package/src/index.ts +48 -0
- package/src/lib/editor-completions-monaco.test.ts +206 -0
- package/src/lib/editor-completions-monaco.ts +127 -0
- package/src/lib/editor-completions.test.ts +156 -0
- package/src/lib/editor-completions.ts +153 -0
- package/src/lib/editor-content-access-prose.test.ts +411 -0
- package/src/lib/editor-content-access-prose.ts +172 -0
- package/src/lib/editor-content-access.test.ts +182 -0
- package/src/lib/editor-content-access.ts +109 -0
- package/src/lib/languages.ts +32 -0
- package/src/lib/markdown/diff.test.ts +80 -0
- package/src/lib/markdown/diff.ts +194 -0
- package/src/lib/markdown/directives.ts +342 -0
- package/src/lib/markdown/frontmatter.ts +50 -0
- package/src/lib/markdown/markdown-scale.test.ts +65 -0
- package/src/lib/markdown/markdown-scale.ts +62 -0
- package/src/lib/markdown/merge.test.ts +64 -0
- package/src/lib/markdown/merge.ts +158 -0
- package/src/lib/markdown/slugify.test.ts +156 -0
- package/src/lib/markdown/slugify.ts +47 -0
- package/src/lib/monaco-environment.ts +103 -0
- package/src/lib/monaco-theme-bridge.ts +293 -0
- package/src/lib/use-data-theme.ts +61 -0
- package/src/markdown/frontmatter.ts +14 -0
- package/src/markdown/index.ts +323 -0
- package/src/markdown/parse.test.ts +60 -0
- package/src/markdown/parse.ts +38 -0
- package/src/markdown-academic/citations.test.ts +77 -0
- package/src/markdown-academic/citations.tsx +442 -0
- package/src/markdown-academic/footnotes.tsx +235 -0
- package/src/markdown-academic/index.ts +32 -0
- package/src/markdown-academic/math.tsx +163 -0
- package/src/markdown-academic/toc.tsx +88 -0
- package/src/markdown-editor/completions/completions-menu.tsx +96 -0
- package/src/markdown-editor/completions/completions-prose.test.ts +356 -0
- package/src/markdown-editor/completions/completions-prose.ts +313 -0
- package/src/markdown-editor/completions/completions-widget.tsx +75 -0
- package/src/markdown-editor/completions/index.ts +37 -0
- package/src/markdown-editor/directive-nodes.ts +151 -0
- package/src/markdown-editor/directive-views.test.tsx +189 -0
- package/src/markdown-editor/directive-views.tsx +795 -0
- package/src/markdown-editor/exit-keymap.test.ts +88 -0
- package/src/markdown-editor/exit-keymap.ts +87 -0
- package/src/markdown-editor/index.ts +6 -0
- package/src/markdown-editor/markdown-editor.css +250 -0
- package/src/markdown-editor/markdown-editor.directives.test.tsx +105 -0
- package/src/markdown-editor/markdown-editor.fill.test.ts +34 -0
- package/src/markdown-editor/markdown-editor.paste-embed.test.tsx +297 -0
- package/src/markdown-editor/markdown-editor.stories.tsx +560 -0
- package/src/markdown-editor/markdown-editor.strictmode.test.tsx +38 -0
- package/src/markdown-editor/markdown-editor.table.test.tsx +287 -0
- package/src/markdown-editor/markdown-editor.tsx +560 -0
- package/src/markdown-editor/milkdown-react/editor.tsx +33 -0
- package/src/markdown-editor/milkdown-react/index.ts +8 -0
- package/src/markdown-editor/milkdown-react/types.ts +27 -0
- package/src/markdown-editor/milkdown-react/use-editor.ts +27 -0
- package/src/markdown-editor/milkdown-react/use-get-editor.ts +56 -0
- package/src/markdown-editor/milkdown-react/use-instance.ts +23 -0
- package/src/markdown-editor/paste-embed.ts +355 -0
- package/src/markdown-editor/slash/brand-slash-commands.test.ts +194 -0
- package/src/markdown-editor/slash/brand-slash-commands.ts +324 -0
- package/src/markdown-editor/slash/brand-slash-plugin.test.ts +254 -0
- package/src/markdown-editor/slash/brand-slash-plugin.ts +385 -0
- package/src/markdown-editor/slash/index.ts +107 -0
- package/src/markdown-editor/slash/insert-directive.test.ts +263 -0
- package/src/markdown-editor/slash/insert-directive.ts +345 -0
- package/src/markdown-editor/slash/monaco-slash-menu.test.tsx +198 -0
- package/src/markdown-editor/slash/monaco-slash-menu.tsx +356 -0
- package/src/markdown-editor/slash/shortcut-monaco.ts +69 -0
- package/src/markdown-editor/slash/shortcut.test.ts +157 -0
- package/src/markdown-editor/slash/shortcut.ts +57 -0
- package/src/markdown-editor/slash/slash-menu.stories.tsx +139 -0
- package/src/markdown-editor/slash/slash-menu.tsx +115 -0
- package/src/markdown-editor/slash/slash-scroll.test.tsx +52 -0
- package/src/markdown-editor/slash/slash-widget.tsx +97 -0
- package/src/markdown-editor/slash/source-slash-trigger.test.ts +42 -0
- package/src/markdown-editor/slash/source-slash-trigger.ts +46 -0
- package/src/markdown-editor/table-view.tsx +249 -0
- package/src/markdown-iteration/directive.tsx +92 -0
- package/src/markdown-iteration/edit-context.ts +56 -0
- package/src/markdown-iteration/index.ts +24 -0
- package/src/markdown-iteration/iteration-block.test.tsx +138 -0
- package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +293 -0
- package/src/markdown-iteration/iteration-builder-dialog.tsx +312 -0
- package/src/markdown-iteration/iteration-builder.test.ts +252 -0
- package/src/markdown-iteration/iteration-builder.ts +303 -0
- package/src/markdown-iteration/iteration.test.ts +56 -0
- package/src/markdown-iteration/iteration.tsx +333 -0
- package/src/markdown-iteration/template-dialog.stories.tsx +63 -0
- package/src/markdown-iteration/template-dialog.test.tsx +104 -0
- package/src/markdown-iteration/template-dialog.tsx +116 -0
- package/src/markdown-outline/document-outline.stories.tsx +76 -0
- package/src/markdown-outline/document-outline.tsx +83 -0
- package/src/markdown-outline/index.ts +2 -0
- package/src/markdown-outline/markdown-outline.test.ts +49 -0
- package/src/markdown-outline/markdown-outline.ts +60 -0
- package/src/markdown-preview/code-fence.tsx +193 -0
- package/src/markdown-preview/index.ts +8 -0
- package/src/markdown-preview/markdown-preview-academic.stories.tsx +203 -0
- package/src/markdown-preview/markdown-preview-academic.test.tsx +257 -0
- package/src/markdown-preview/markdown-preview-iteration-calc.test.tsx +71 -0
- package/src/markdown-preview/markdown-preview-iteration-nested.test.tsx +58 -0
- package/src/markdown-preview/markdown-preview-iteration.stories.tsx +187 -0
- package/src/markdown-preview/markdown-preview-iteration.test.tsx +90 -0
- package/src/markdown-preview/markdown-preview-linking.test.tsx +61 -0
- package/src/markdown-preview/markdown-preview-transclusion.test.tsx +33 -0
- package/src/markdown-preview/markdown-preview.stories.tsx +108 -0
- package/src/markdown-preview/markdown-preview.test.tsx +411 -0
- package/src/markdown-preview/markdown-preview.tsx +1541 -0
- package/src/markdown-toolbar/index.ts +8 -0
- package/src/markdown-toolbar/markdown-commands.test.ts +84 -0
- package/src/markdown-toolbar/markdown-commands.ts +104 -0
- package/src/markdown-toolbar/markdown-toolbar.stories.tsx +56 -0
- package/src/markdown-toolbar/markdown-toolbar.tsx +263 -0
- package/src/markdown-workspace/focus-writing.test.ts +52 -0
- package/src/markdown-workspace/focus-writing.ts +38 -0
- package/src/markdown-workspace/index.ts +6 -0
- package/src/markdown-workspace/markdown-workspace.stories.tsx +688 -0
- package/src/markdown-workspace/markdown-workspace.test.tsx +326 -0
- package/src/markdown-workspace/markdown-workspace.tsx +787 -0
- package/src/mermaid-diagram/index.ts +1 -0
- package/src/mermaid-diagram/mermaid-diagram.stories.tsx +42 -0
- package/src/mermaid-diagram/mermaid-diagram.test.tsx +86 -0
- package/src/mermaid-diagram/mermaid-diagram.tsx +318 -0
- package/src/mermaid-diagram/mermaid-viewer.test.tsx +89 -0
- package/src/mermaid-diagram/mermaid-viewer.tsx +367 -0
- package/src/mermaid-diagram/real-parse.test.ts +42 -0
- package/src/mermaid-diagram/remediate.test.ts +60 -0
- package/src/mermaid-diagram/remediate.ts +124 -0
- package/src/mermaid-workspace/index.ts +1 -0
- package/src/mermaid-workspace/mermaid-workspace.stories.tsx +28 -0
- package/src/mermaid-workspace/mermaid-workspace.tsx +71 -0
- package/src/metric-block/index.ts +1 -0
- package/src/metric-block/metric-block.stories.tsx +40 -0
- package/src/metric-block/metric-block.tsx +14 -0
- package/src/monaco-workers.d.ts +10 -0
- package/src/prose/index.ts +14 -0
- package/src/prose/prose.stories.tsx +44 -0
- package/src/prose/prose.test.tsx +44 -0
- package/src/prose/prose.tsx +21 -0
- 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
|
+
}
|