@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,293 @@
|
|
|
1
|
+
import { Button } from "@elabs-ai/components-ui";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { expect, userEvent, within } from "storybook/test";
|
|
5
|
+
|
|
6
|
+
import { IterationBuilderDialog } from "./iteration-builder-dialog";
|
|
7
|
+
import type { IterationBuilderValue } from "./iteration-builder";
|
|
8
|
+
import type { EvaluateCalc } from "../calc-block";
|
|
9
|
+
|
|
10
|
+
// A minimal stand-in calc engine for the story (the app brings its own): every
|
|
11
|
+
// non-empty line resolves to a fixed value, enough to show a ```calc cell
|
|
12
|
+
// RESOLVING into a CalcBlock inside the pivot rather than showing raw text.
|
|
13
|
+
const evaluateCalc: EvaluateCalc = (source) => ({
|
|
14
|
+
results: source
|
|
15
|
+
.split("\n")
|
|
16
|
+
.filter((line) => line.trim().length > 0)
|
|
17
|
+
.map((_, i) => ({
|
|
18
|
+
line: i + 1,
|
|
19
|
+
tokens: [],
|
|
20
|
+
value: { kind: "number" as const, raw: 42, display: "42" },
|
|
21
|
+
})),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Room between a focused child's border box and its scroll-container ancestor's
|
|
26
|
+
* SCROLLPORT — see `packages/ui/src/components/dialog/dialog-focus-ring-clearance.stories.tsx`
|
|
27
|
+
* for the full derivation. Reused here (not imported — `@elabs-ai/components-editor`
|
|
28
|
+
* doesn't depend on `@elabs-ai/components-ui`'s story files) to confirm #425's fix
|
|
29
|
+
* reaches this dialog now that its scroll region is a real `DialogBody`.
|
|
30
|
+
*/
|
|
31
|
+
const room = (child: HTMLElement, box: HTMLElement) => {
|
|
32
|
+
const c = child.getBoundingClientRect();
|
|
33
|
+
const b = box.getBoundingClientRect();
|
|
34
|
+
const l = b.left + box.clientLeft;
|
|
35
|
+
const t = b.top + box.clientTop;
|
|
36
|
+
return {
|
|
37
|
+
left: c.left - l,
|
|
38
|
+
right: l + box.clientWidth - c.right,
|
|
39
|
+
top: c.top - t,
|
|
40
|
+
bottom: t + box.clientHeight - c.bottom,
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const meta = {
|
|
45
|
+
title: "Editor/Iteration/BuilderDialog",
|
|
46
|
+
component: IterationBuilderDialog,
|
|
47
|
+
tags: ["autodocs"],
|
|
48
|
+
parameters: {
|
|
49
|
+
layout: "centered",
|
|
50
|
+
docs: {
|
|
51
|
+
description: {
|
|
52
|
+
component:
|
|
53
|
+
"The #223 GUIDED builder (A5) — collects the DATA (value list(s) + bind name + layout) " +
|
|
54
|
+
"as well as the per-cell template, shows a LIVE populated preview, and writes a " +
|
|
55
|
+
"fully-bound `:::iterate` / `:::pivot` directive whose value lists live in its " +
|
|
56
|
+
"attributes (rendered by the built-in `evaluateEmbedded`). Wire the node-view `⋯` " +
|
|
57
|
+
"re-edit to it with `IterationBuilderProvider` for a lossless round-trip.",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
} satisfies Meta<typeof IterationBuilderDialog>;
|
|
62
|
+
|
|
63
|
+
export default meta;
|
|
64
|
+
type Story = StoryObj<typeof meta>;
|
|
65
|
+
|
|
66
|
+
const PIVOT_SEED: IterationBuilderValue = {
|
|
67
|
+
kind: "pivot",
|
|
68
|
+
as: "item",
|
|
69
|
+
layout: "matrix",
|
|
70
|
+
values: ["Q1", "Q2", "Q3"],
|
|
71
|
+
cols: ["North", "South"],
|
|
72
|
+
template: "{{row}} · {{col}}",
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Pivot: two value lists → a populated matrix in the live preview. Opening this
|
|
77
|
+
* (the `⋯` re-edit scenario, seeded via `value`) shows the matrix filling in from
|
|
78
|
+
* the embedded row/column lists with no consumer data engine.
|
|
79
|
+
*/
|
|
80
|
+
export const PivotBuilder: Story = {
|
|
81
|
+
args: { open: false, onOpenChange: () => {}, onSave: () => {} },
|
|
82
|
+
render: function PivotBuilderStory() {
|
|
83
|
+
const [open, setOpen] = useState(false);
|
|
84
|
+
const [saved, setSaved] = useState<string | null>(null);
|
|
85
|
+
return (
|
|
86
|
+
<div className="flex flex-col items-center gap-3">
|
|
87
|
+
<Button onClick={() => setOpen(true)}>Edit pivot…</Button>
|
|
88
|
+
{saved ? (
|
|
89
|
+
<pre className="max-w-md rounded-md bg-surface-muted p-3 text-caption">{saved}</pre>
|
|
90
|
+
) : null}
|
|
91
|
+
<IterationBuilderDialog
|
|
92
|
+
open={open}
|
|
93
|
+
onOpenChange={setOpen}
|
|
94
|
+
value={PIVOT_SEED}
|
|
95
|
+
onSave={setSaved}
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
},
|
|
100
|
+
play: async ({ canvasElement }) => {
|
|
101
|
+
const canvas = within(canvasElement);
|
|
102
|
+
await userEvent.click(canvas.getByRole("button", { name: /Edit pivot/i }));
|
|
103
|
+
const dialog = await within(document.body).findByRole("dialog", {}, { timeout: 12000 });
|
|
104
|
+
const d = within(dialog);
|
|
105
|
+
// Each value string renders TWICE — once as a TagInput chip in the left
|
|
106
|
+
// column, once in the live preview — so scope queries to the preview region
|
|
107
|
+
// (the confirming pattern from PivotWithCalc / WithNestedCard below).
|
|
108
|
+
const preview = within(
|
|
109
|
+
await d.findByRole("region", { name: /live preview/i }, { timeout: 8000 }),
|
|
110
|
+
);
|
|
111
|
+
// The live preview renders a populated matrix from the embedded value lists:
|
|
112
|
+
// axis headers + an interpolated cell.
|
|
113
|
+
await expect(await preview.findByText("North", {}, { timeout: 8000 })).toBeVisible();
|
|
114
|
+
await expect(preview.getByText("Q1")).toBeVisible();
|
|
115
|
+
await expect(preview.getByText("Q1 · North")).toBeVisible();
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Iterate: one value list → one rendered cell per value (stacked).
|
|
121
|
+
*/
|
|
122
|
+
export const IterateBuilder: Story = {
|
|
123
|
+
args: { open: false, onOpenChange: () => {}, onSave: () => {} },
|
|
124
|
+
render: function IterateBuilderStory() {
|
|
125
|
+
const [open, setOpen] = useState(false);
|
|
126
|
+
const [saved, setSaved] = useState<string | null>(null);
|
|
127
|
+
return (
|
|
128
|
+
<div className="flex flex-col items-center gap-3">
|
|
129
|
+
<Button onClick={() => setOpen(true)}>Edit iteration…</Button>
|
|
130
|
+
{saved ? (
|
|
131
|
+
<pre className="max-w-md rounded-md bg-surface-muted p-3 text-caption">{saved}</pre>
|
|
132
|
+
) : null}
|
|
133
|
+
<IterationBuilderDialog
|
|
134
|
+
open={open}
|
|
135
|
+
onOpenChange={setOpen}
|
|
136
|
+
value={{
|
|
137
|
+
kind: "iterate",
|
|
138
|
+
as: "item",
|
|
139
|
+
layout: "stacked",
|
|
140
|
+
values: ["Alice", "Bob", "Charlie"],
|
|
141
|
+
template: "{{item.name}}",
|
|
142
|
+
}}
|
|
143
|
+
onSave={setSaved}
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
},
|
|
148
|
+
play: async ({ canvasElement }) => {
|
|
149
|
+
const canvas = within(canvasElement);
|
|
150
|
+
await userEvent.click(canvas.getByRole("button", { name: /Edit iteration/i }));
|
|
151
|
+
const dialog = await within(document.body).findByRole("dialog", {}, { timeout: 12000 });
|
|
152
|
+
// "Alice" renders TWICE — as a TagInput chip AND in the live preview — so
|
|
153
|
+
// scope to the preview region (mirrors the PivotBuilder fix above).
|
|
154
|
+
const preview = within(
|
|
155
|
+
await within(dialog).findByRole("region", { name: /live preview/i }, { timeout: 8000 }),
|
|
156
|
+
);
|
|
157
|
+
// The live preview renders one cell per embedded value.
|
|
158
|
+
await expect(await preview.findByText("Alice", {}, { timeout: 8000 })).toBeVisible();
|
|
159
|
+
|
|
160
|
+
// #425 acceptance criterion: this dialog's `Bind name` field sits directly
|
|
161
|
+
// against the (now real) `DialogBody` scrollport as its first child — the
|
|
162
|
+
// same worst case `FocusRingClearance` locks on `DialogBody` itself. Assert
|
|
163
|
+
// the fix reaches this consumer rather than assuming it from the swap alone.
|
|
164
|
+
const body = dialog.querySelector<HTMLElement>('[data-slot="dialog-body"]')!;
|
|
165
|
+
const bindNameInput = within(dialog).getByLabelText("Bind name");
|
|
166
|
+
bindNameInput.focus();
|
|
167
|
+
const r = room(bindNameInput, body);
|
|
168
|
+
expect(r.left).toBeGreaterThanOrEqual(3.5);
|
|
169
|
+
expect(r.right).toBeGreaterThanOrEqual(3.5);
|
|
170
|
+
expect(r.top).toBeGreaterThanOrEqual(3.5);
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Pivot with a ```calc cell: objects inside a cell RESOLVE in the live preview
|
|
176
|
+
* (not raw text). With a calc `evaluate` wired, each cell's fence renders a real
|
|
177
|
+
* CalcBlock — the same path the saved block renders through, in matrix and
|
|
178
|
+
* stacked alike.
|
|
179
|
+
*/
|
|
180
|
+
export const PivotWithCalc: Story = {
|
|
181
|
+
args: { open: false, onOpenChange: () => {}, onSave: () => {} },
|
|
182
|
+
render: function PivotWithCalcStory() {
|
|
183
|
+
const [open, setOpen] = useState(false);
|
|
184
|
+
return (
|
|
185
|
+
<div className="flex flex-col items-center gap-3">
|
|
186
|
+
<Button onClick={() => setOpen(true)}>Edit pivot with calc…</Button>
|
|
187
|
+
<IterationBuilderDialog
|
|
188
|
+
open={open}
|
|
189
|
+
onOpenChange={setOpen}
|
|
190
|
+
evaluate={evaluateCalc}
|
|
191
|
+
value={{
|
|
192
|
+
kind: "pivot",
|
|
193
|
+
as: "item",
|
|
194
|
+
layout: "matrix",
|
|
195
|
+
values: ["1", "2"],
|
|
196
|
+
cols: ["1", "2"],
|
|
197
|
+
template: "```calc\nitems = {{row}} + {{col}}\ntotal = items\n```",
|
|
198
|
+
}}
|
|
199
|
+
onSave={() => {}}
|
|
200
|
+
/>
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
},
|
|
204
|
+
play: async ({ canvasElement }) => {
|
|
205
|
+
const canvas = within(canvasElement);
|
|
206
|
+
await userEvent.click(canvas.getByRole("button", { name: /Edit pivot with calc/i }));
|
|
207
|
+
const dialog = await within(document.body).findByRole("dialog", {}, { timeout: 12000 });
|
|
208
|
+
// The calc fence resolved into real CalcBlock(s) inside the matrix cells.
|
|
209
|
+
await within(dialog).findAllByTestId("calc-block", {}, { timeout: 8000 });
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A nested `:::card` (with content AFTER it) inside the iteration template. The live
|
|
215
|
+
* preview renders a REAL Card per item — not truncated plain text — and keeps the
|
|
216
|
+
* trailing line, because the builder serializes the directive with a fence longer
|
|
217
|
+
* than the nested one (`outerDirectiveFence`). This is the #223 "other components
|
|
218
|
+
* inside iterations don't render" fix, shown in the dialog the author actually uses.
|
|
219
|
+
*/
|
|
220
|
+
export const WithNestedCard: Story = {
|
|
221
|
+
args: { open: false, onOpenChange: () => {}, onSave: () => {} },
|
|
222
|
+
render: function WithNestedCardStory() {
|
|
223
|
+
const [open, setOpen] = useState(false);
|
|
224
|
+
return (
|
|
225
|
+
<div className="flex flex-col items-center gap-3">
|
|
226
|
+
<Button onClick={() => setOpen(true)}>Edit iteration with card…</Button>
|
|
227
|
+
<IterationBuilderDialog
|
|
228
|
+
open={open}
|
|
229
|
+
onOpenChange={setOpen}
|
|
230
|
+
value={{
|
|
231
|
+
kind: "iterate",
|
|
232
|
+
as: "item",
|
|
233
|
+
layout: "stacked",
|
|
234
|
+
values: ["Ada", "Grace"],
|
|
235
|
+
template:
|
|
236
|
+
':::card{title="{{item.name}}"}\nLead engineer for {{item.name}}\n:::\n\nNotes for {{item.name}}',
|
|
237
|
+
}}
|
|
238
|
+
onSave={() => {}}
|
|
239
|
+
/>
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
},
|
|
243
|
+
play: async ({ canvasElement }) => {
|
|
244
|
+
const canvas = within(canvasElement);
|
|
245
|
+
await userEvent.click(canvas.getByRole("button", { name: /Edit iteration with card/i }));
|
|
246
|
+
const dialog = await within(document.body).findByRole("dialog", {}, { timeout: 12000 });
|
|
247
|
+
const d = within(dialog);
|
|
248
|
+
// The nested card rendered as a real component (title + body interpolated)...
|
|
249
|
+
await expect(await d.findByText("Lead engineer for Ada", {}, { timeout: 8000 })).toBeVisible();
|
|
250
|
+
// ...and the content AFTER it survived (dropped before the fence-collision fix).
|
|
251
|
+
await expect(d.getByText("Notes for Ada")).toBeVisible();
|
|
252
|
+
await expect(d.getByText("Notes for Grace")).toBeVisible();
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Bento layout: the values render as varied-size BentoGrid tiles in the live preview.
|
|
258
|
+
*/
|
|
259
|
+
export const BentoBuilder: Story = {
|
|
260
|
+
args: { open: false, onOpenChange: () => {}, onSave: () => {} },
|
|
261
|
+
render: function BentoBuilderStory() {
|
|
262
|
+
const [open, setOpen] = useState(false);
|
|
263
|
+
return (
|
|
264
|
+
<div className="flex flex-col items-center gap-3">
|
|
265
|
+
<Button onClick={() => setOpen(true)}>Edit bento…</Button>
|
|
266
|
+
<IterationBuilderDialog
|
|
267
|
+
open={open}
|
|
268
|
+
onOpenChange={setOpen}
|
|
269
|
+
value={{
|
|
270
|
+
kind: "iterate",
|
|
271
|
+
as: "item",
|
|
272
|
+
layout: "bento",
|
|
273
|
+
values: ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"],
|
|
274
|
+
template: "**{{item.name}}**",
|
|
275
|
+
}}
|
|
276
|
+
onSave={() => {}}
|
|
277
|
+
/>
|
|
278
|
+
</div>
|
|
279
|
+
);
|
|
280
|
+
},
|
|
281
|
+
play: async ({ canvasElement }) => {
|
|
282
|
+
const canvas = within(canvasElement);
|
|
283
|
+
await userEvent.click(canvas.getByRole("button", { name: /Edit bento/i }));
|
|
284
|
+
const dialog = await within(document.body).findByRole("dialog", {}, { timeout: 12000 });
|
|
285
|
+
// "Alpha"/"Epsilon" render TWICE — as TagInput chips AND in the live
|
|
286
|
+
// preview's BentoGrid tiles — so scope to the preview region.
|
|
287
|
+
const preview = within(
|
|
288
|
+
await within(dialog).findByRole("region", { name: /live preview/i }, { timeout: 8000 }),
|
|
289
|
+
);
|
|
290
|
+
await expect(await preview.findByText("Alpha", {}, { timeout: 8000 })).toBeVisible();
|
|
291
|
+
await expect(preview.getByText("Epsilon")).toBeVisible();
|
|
292
|
+
},
|
|
293
|
+
};
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* IterationBuilderDialog (A5) — GUIDED `:::iterate` / `:::pivot` authoring.
|
|
5
|
+
*
|
|
6
|
+
* Where `IterationTemplateDialog` edits only the per-cell template, the builder
|
|
7
|
+
* also collects the DATA — the value list (iterate) or the two value lists
|
|
8
|
+
* (pivot), the bind name, and the layout — and shows a LIVE preview of the
|
|
9
|
+
* populated block as you type. On save it writes a fully-bound directive
|
|
10
|
+
* (`serializeIterationDirective`) whose value lists live in its attributes, so the
|
|
11
|
+
* block renders populated via the built-in `evaluateEmbedded` and the `⋯` re-edit
|
|
12
|
+
* can reopen it losslessly (`parseIterationDirective` / `builderValueFromParts`).
|
|
13
|
+
*
|
|
14
|
+
* Controlled (`open` / `onOpenChange`); seed via `value` (re-edit) or `kind` +
|
|
15
|
+
* `initialValues` (fresh insert, e.g. a selection split to one value per line).
|
|
16
|
+
*/
|
|
17
|
+
import {
|
|
18
|
+
Button,
|
|
19
|
+
Dialog,
|
|
20
|
+
DialogBody,
|
|
21
|
+
DialogContent,
|
|
22
|
+
DialogDescription,
|
|
23
|
+
DialogFooter,
|
|
24
|
+
DialogHeader,
|
|
25
|
+
DialogTitle,
|
|
26
|
+
Input,
|
|
27
|
+
Label,
|
|
28
|
+
TagInput,
|
|
29
|
+
ToggleGroup,
|
|
30
|
+
ToggleGroupItem,
|
|
31
|
+
} from "@elabs-ai/components-ui";
|
|
32
|
+
import { useEffect, useId, useMemo, useState, type ReactNode } from "react";
|
|
33
|
+
|
|
34
|
+
import { type EvaluateCalc } from "../calc-block";
|
|
35
|
+
import { MarkdownPreview } from "../markdown-preview";
|
|
36
|
+
import { MarkdownWorkspace } from "../markdown-workspace";
|
|
37
|
+
import { IterationEditContext, type IterationEditRequest } from "./edit-context";
|
|
38
|
+
import { type InterpolateTemplate, type IterationLayout } from "./iteration";
|
|
39
|
+
import {
|
|
40
|
+
builderValueFromParts,
|
|
41
|
+
directivePartsFromValue,
|
|
42
|
+
emptyBuilderValue,
|
|
43
|
+
evaluateEmbedded,
|
|
44
|
+
ITERATION_LAYOUTS,
|
|
45
|
+
parseIterationDirective,
|
|
46
|
+
serializeIterationDirective,
|
|
47
|
+
type IterationBuilderValue,
|
|
48
|
+
} from "./iteration-builder";
|
|
49
|
+
|
|
50
|
+
export interface IterationBuilderDialogProps {
|
|
51
|
+
open: boolean;
|
|
52
|
+
onOpenChange: (open: boolean) => void;
|
|
53
|
+
/** Which block to author. Default `"iterate"`. Ignored when `value` is set. */
|
|
54
|
+
kind?: "iterate" | "pivot";
|
|
55
|
+
/** Seed the whole builder (the `⋯` re-edit path). */
|
|
56
|
+
value?: IterationBuilderValue;
|
|
57
|
+
/** Seed the value list of a fresh block (e.g. a selection split to lines). */
|
|
58
|
+
initialValues?: string[];
|
|
59
|
+
/** Receives the fully-bound directive markdown when the user saves. */
|
|
60
|
+
onSave: (directiveMarkdown: string) => void;
|
|
61
|
+
/**
|
|
62
|
+
* Resolve a ```calc fence to a `CalcSheet` so calc cells COMPUTE in the live
|
|
63
|
+
* preview (the same hook `MarkdownPreview` takes — the library renders, the app
|
|
64
|
+
* computes). Without it a calc cell still renders as a code block; with it, the
|
|
65
|
+
* preview matches production. Pass your app's calc engine.
|
|
66
|
+
*/
|
|
67
|
+
evaluate?: EvaluateCalc;
|
|
68
|
+
/** Fill a cell template with its context. Defaults to `{{path}}` substitution. */
|
|
69
|
+
interpolate?: InterpolateTemplate;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function IterationBuilderDialog({
|
|
73
|
+
open,
|
|
74
|
+
onOpenChange,
|
|
75
|
+
kind: kindProp = "iterate",
|
|
76
|
+
value,
|
|
77
|
+
initialValues,
|
|
78
|
+
onSave,
|
|
79
|
+
evaluate,
|
|
80
|
+
interpolate,
|
|
81
|
+
}: IterationBuilderDialogProps) {
|
|
82
|
+
const kind = value?.kind ?? kindProp;
|
|
83
|
+
const isPivot = kind === "pivot";
|
|
84
|
+
const ids = useId();
|
|
85
|
+
|
|
86
|
+
// Working draft. Value lists are arrays (chip entry via TagInput); the per-cell
|
|
87
|
+
// template stays raw text.
|
|
88
|
+
const [asName, setAsName] = useState("item");
|
|
89
|
+
const [layout, setLayout] = useState<IterationLayout>(ITERATION_LAYOUTS[kind][0] ?? "stacked");
|
|
90
|
+
const [values, setValues] = useState<string[]>([]);
|
|
91
|
+
const [cols, setCols] = useState<string[]>([]);
|
|
92
|
+
const [template, setTemplate] = useState("");
|
|
93
|
+
|
|
94
|
+
// (Re)seed whenever the dialog opens against a new value / kind.
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!open) return;
|
|
97
|
+
const seed = value ?? {
|
|
98
|
+
...emptyBuilderValue(kind),
|
|
99
|
+
values: initialValues ?? [],
|
|
100
|
+
};
|
|
101
|
+
setAsName(seed.as);
|
|
102
|
+
setLayout(seed.layout);
|
|
103
|
+
setValues(seed.values);
|
|
104
|
+
setCols(seed.cols ?? []);
|
|
105
|
+
setTemplate(seed.template);
|
|
106
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
107
|
+
}, [open]);
|
|
108
|
+
|
|
109
|
+
const draft: IterationBuilderValue = useMemo(
|
|
110
|
+
() => ({
|
|
111
|
+
kind,
|
|
112
|
+
as: asName.trim() || "item",
|
|
113
|
+
layout,
|
|
114
|
+
values,
|
|
115
|
+
cols: isPivot ? cols : undefined,
|
|
116
|
+
template,
|
|
117
|
+
}),
|
|
118
|
+
[kind, asName, layout, values, cols, template, isPivot],
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Live preview — render the SERIALIZED directive through MarkdownPreview, the
|
|
122
|
+
// exact path the saved block renders through. That resolves the iteration via
|
|
123
|
+
// the built-in `evaluateEmbedded` AND each cell's objects (a ```calc fence,
|
|
124
|
+
// nested directives, formatting) — in stacked, grid and matrix alike — instead
|
|
125
|
+
// of dumping the raw cell text.
|
|
126
|
+
const previewMarkdown = useMemo(() => serializeIterationDirective(draft), [draft]);
|
|
127
|
+
|
|
128
|
+
const save = () => {
|
|
129
|
+
onSave(serializeIterationDirective(draft));
|
|
130
|
+
onOpenChange(false);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const noun = isPivot ? "pivot" : "iteration";
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
137
|
+
<DialogContent className="flex max-h-[88vh] w-[min(56rem,94vw)] max-w-none flex-col">
|
|
138
|
+
<DialogHeader>
|
|
139
|
+
<DialogTitle>
|
|
140
|
+
{value ? "Edit" : "Insert"} {noun}
|
|
141
|
+
</DialogTitle>
|
|
142
|
+
<DialogDescription>
|
|
143
|
+
{isPivot
|
|
144
|
+
? "Pick the row and column values, then write the per-cell template. The matrix below fills in live."
|
|
145
|
+
: "Add the list values, then write the per-row template. The result below fills in live."}
|
|
146
|
+
</DialogDescription>
|
|
147
|
+
</DialogHeader>
|
|
148
|
+
|
|
149
|
+
<DialogBody className="grid gap-4 md:grid-cols-2">
|
|
150
|
+
{/* Left column — the DATA the block iterates over. */}
|
|
151
|
+
<div className="flex min-w-0 flex-col gap-4">
|
|
152
|
+
{!isPivot ? (
|
|
153
|
+
<div className="flex flex-col gap-1.5">
|
|
154
|
+
<Label htmlFor={`${ids}-as`}>Bind name</Label>
|
|
155
|
+
<Input
|
|
156
|
+
id={`${ids}-as`}
|
|
157
|
+
value={asName}
|
|
158
|
+
spellCheck={false}
|
|
159
|
+
autoComplete="off"
|
|
160
|
+
placeholder="item"
|
|
161
|
+
onChange={(e) => setAsName(e.target.value)}
|
|
162
|
+
/>
|
|
163
|
+
<p className="text-meta text-muted-foreground">
|
|
164
|
+
Use <code>{`{{${asName.trim() || "item"}.name}}`}</code> in the template.
|
|
165
|
+
</p>
|
|
166
|
+
</div>
|
|
167
|
+
) : null}
|
|
168
|
+
|
|
169
|
+
<div className="flex flex-col gap-1.5">
|
|
170
|
+
<Label htmlFor={`${ids}-values`}>{isPivot ? "Row values" : "Values"}</Label>
|
|
171
|
+
<TagInput
|
|
172
|
+
id={`${ids}-values`}
|
|
173
|
+
value={values}
|
|
174
|
+
onValueChange={setValues}
|
|
175
|
+
delimiter={[",", "\n"]}
|
|
176
|
+
placeholder="Type a value, press Enter…"
|
|
177
|
+
/>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{isPivot ? (
|
|
181
|
+
<div className="flex flex-col gap-1.5">
|
|
182
|
+
<Label htmlFor={`${ids}-cols`}>Column values</Label>
|
|
183
|
+
<TagInput
|
|
184
|
+
id={`${ids}-cols`}
|
|
185
|
+
value={cols}
|
|
186
|
+
onValueChange={setCols}
|
|
187
|
+
delimiter={[",", "\n"]}
|
|
188
|
+
placeholder="Type a value, press Enter…"
|
|
189
|
+
/>
|
|
190
|
+
</div>
|
|
191
|
+
) : null}
|
|
192
|
+
|
|
193
|
+
<div className="flex flex-col gap-1.5">
|
|
194
|
+
<Label id={`${ids}-layout`}>Layout</Label>
|
|
195
|
+
<ToggleGroup
|
|
196
|
+
type="single"
|
|
197
|
+
variant="segmented"
|
|
198
|
+
value={layout}
|
|
199
|
+
onValueChange={(next) => {
|
|
200
|
+
// Single-select: ignore the empty value Radix emits when the active
|
|
201
|
+
// item is re-pressed, so a layout is always selected.
|
|
202
|
+
if (next) setLayout(next as IterationLayout);
|
|
203
|
+
}}
|
|
204
|
+
aria-labelledby={`${ids}-layout`}
|
|
205
|
+
className="w-fit"
|
|
206
|
+
>
|
|
207
|
+
{ITERATION_LAYOUTS[kind].map((l) => (
|
|
208
|
+
<ToggleGroupItem key={l} value={l} className="capitalize">
|
|
209
|
+
{l}
|
|
210
|
+
</ToggleGroupItem>
|
|
211
|
+
))}
|
|
212
|
+
</ToggleGroup>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
{/* Right column — the per-cell TEMPLATE + the live populated preview. */}
|
|
217
|
+
<div className="flex min-h-0 min-w-0 flex-col gap-4">
|
|
218
|
+
<div className="flex min-h-0 flex-col gap-1.5">
|
|
219
|
+
<Label>Per-{isPivot ? "cell" : "row"} template</Label>
|
|
220
|
+
<div className="h-44 min-h-0 overflow-hidden rounded-md border border-border">
|
|
221
|
+
<MarkdownWorkspace
|
|
222
|
+
value={template}
|
|
223
|
+
onChange={setTemplate}
|
|
224
|
+
defaultMode="source"
|
|
225
|
+
className="h-full"
|
|
226
|
+
aria-label="Per-cell template"
|
|
227
|
+
/>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<div className="flex min-h-0 flex-col gap-1.5">
|
|
232
|
+
<Label>Live preview</Label>
|
|
233
|
+
<div
|
|
234
|
+
role="region"
|
|
235
|
+
aria-label="Live preview"
|
|
236
|
+
className="min-h-0 flex-1 overflow-auto rounded-md border border-border bg-card p-3"
|
|
237
|
+
>
|
|
238
|
+
<MarkdownPreview
|
|
239
|
+
evaluateIteration={evaluateEmbedded}
|
|
240
|
+
evaluate={evaluate}
|
|
241
|
+
interpolate={interpolate}
|
|
242
|
+
>
|
|
243
|
+
{previewMarkdown}
|
|
244
|
+
</MarkdownPreview>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
</DialogBody>
|
|
249
|
+
|
|
250
|
+
<DialogFooter>
|
|
251
|
+
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
|
252
|
+
Cancel
|
|
253
|
+
</Button>
|
|
254
|
+
<Button onClick={save}>{value ? "Save" : "Insert"}</Button>
|
|
255
|
+
</DialogFooter>
|
|
256
|
+
</DialogContent>
|
|
257
|
+
</Dialog>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* One-liner wiring for the node-view `⋯` re-edit using the GUIDED builder (A5).
|
|
263
|
+
* Drop it above a `MarkdownEditor` / `MarkdownWorkspace` to make the `⋯` on a
|
|
264
|
+
* `:::iterate` / `:::pivot` reopen the full builder seeded with the block's DATA
|
|
265
|
+
* (value lists + bind name + layout) and template, writing both back losslessly.
|
|
266
|
+
*
|
|
267
|
+
* Prefer this over {@link IterationTemplateProvider} when the consumer authors
|
|
268
|
+
* blocks with embedded value lists (the builder flow); the template-only provider
|
|
269
|
+
* remains for the lighter "edit just the template" affordance.
|
|
270
|
+
*/
|
|
271
|
+
export function IterationBuilderProvider({
|
|
272
|
+
children,
|
|
273
|
+
evaluate,
|
|
274
|
+
interpolate,
|
|
275
|
+
}: {
|
|
276
|
+
children: ReactNode;
|
|
277
|
+
/** Forwarded to the builder's live preview so calc cells COMPUTE on re-edit. */
|
|
278
|
+
evaluate?: EvaluateCalc;
|
|
279
|
+
/** Forwarded to the builder's live preview cell interpolation. */
|
|
280
|
+
interpolate?: InterpolateTemplate;
|
|
281
|
+
}) {
|
|
282
|
+
const [request, setRequest] = useState<IterationEditRequest | null>(null);
|
|
283
|
+
|
|
284
|
+
const seed = request
|
|
285
|
+
? builderValueFromParts(request.kind, request.attributes ?? {}, request.template)
|
|
286
|
+
: undefined;
|
|
287
|
+
|
|
288
|
+
return (
|
|
289
|
+
<IterationEditContext.Provider value={setRequest}>
|
|
290
|
+
{children}
|
|
291
|
+
<IterationBuilderDialog
|
|
292
|
+
open={request != null}
|
|
293
|
+
onOpenChange={(next) => {
|
|
294
|
+
if (!next) setRequest(null);
|
|
295
|
+
}}
|
|
296
|
+
kind={request?.kind ?? "iterate"}
|
|
297
|
+
value={seed}
|
|
298
|
+
evaluate={evaluate}
|
|
299
|
+
interpolate={interpolate}
|
|
300
|
+
onSave={(directiveMarkdown) => {
|
|
301
|
+
const parsed = parseIterationDirective(directiveMarkdown);
|
|
302
|
+
if (!parsed) return;
|
|
303
|
+
const { attributes, template } = directivePartsFromValue(parsed);
|
|
304
|
+
// Prefer the data-aware writer (rewrites attributes + body); fall back to
|
|
305
|
+
// the template-only writer for older node-views.
|
|
306
|
+
if (request?.onSaveData) request.onSaveData({ attributes, template });
|
|
307
|
+
else request?.onSave(template);
|
|
308
|
+
}}
|
|
309
|
+
/>
|
|
310
|
+
</IterationEditContext.Provider>
|
|
311
|
+
);
|
|
312
|
+
}
|