@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,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed-`/` trigger detection for the Monaco source pane (the conflict-free way
|
|
3
|
+
* to open the brand block menu — no keybinding, mirrors the WYSIWYG `/` trigger).
|
|
4
|
+
*
|
|
5
|
+
* Pure + Monaco-free so it unit-tests without the engine. The workspace feeds it
|
|
6
|
+
* the just-edited line + the column the `/` landed on (from a Monaco content
|
|
7
|
+
* change) and, when it's a valid trigger spot, gets back the model range of that
|
|
8
|
+
* `/` so the menu can replace it with the inserted block (or delete it on cancel).
|
|
9
|
+
*
|
|
10
|
+
* A `/` is a valid trigger only at a textblock start (column 1) or right after
|
|
11
|
+
* whitespace — so a `/` inside a word, a URL, or a path (`a/b`, `http://`) never
|
|
12
|
+
* hijacks typing, matching the WYSIWYG `triggerAllowed` rule.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** A Monaco `IRange`-shaped span (kept structural so this module imports no monaco). */
|
|
16
|
+
export interface SlashTriggerRange {
|
|
17
|
+
startLineNumber: number;
|
|
18
|
+
startColumn: number;
|
|
19
|
+
endLineNumber: number;
|
|
20
|
+
endColumn: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Given the 1-based `line` number, the FULL line content AFTER the `/` was typed,
|
|
25
|
+
* and the 1-based `slashColumn` the `/` now occupies, return the range covering
|
|
26
|
+
* that `/` when it is a valid trigger — otherwise `null`.
|
|
27
|
+
*/
|
|
28
|
+
export function slashTriggerRange(
|
|
29
|
+
line: number,
|
|
30
|
+
lineContent: string,
|
|
31
|
+
slashColumn: number,
|
|
32
|
+
): SlashTriggerRange | null {
|
|
33
|
+
// Sanity: the character at the reported column must actually be the `/`.
|
|
34
|
+
if (lineContent.charAt(slashColumn - 1) !== "/") return null;
|
|
35
|
+
// Allowed at the very start of the line, or immediately after whitespace.
|
|
36
|
+
if (slashColumn > 1) {
|
|
37
|
+
const before = lineContent.charAt(slashColumn - 2);
|
|
38
|
+
if (!/\s/.test(before)) return null;
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
startLineNumber: line,
|
|
42
|
+
startColumn: slashColumn,
|
|
43
|
+
endLineNumber: line,
|
|
44
|
+
endColumn: slashColumn + 1,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Table control overlay for the Milkdown WYSIWYG editor.
|
|
5
|
+
*
|
|
6
|
+
* Mechanism: a **contextual toolbar** rendered as a ProseMirror plugin view
|
|
7
|
+
* (via `@prosemirror-adapter/react`'s `usePluginViewFactory`). When the
|
|
8
|
+
* selection is inside a GFM table, a token-styled button bar appears at the
|
|
9
|
+
* bottom of the editor's wrapper offering add/remove row and column ops.
|
|
10
|
+
*
|
|
11
|
+
* Design choice — why NOT a `$view` node-view:
|
|
12
|
+
* A content-replacing `$view` for the GFM table conflicts with gfm's
|
|
13
|
+
* `columnResizingPlugin` and `tableEditingPlugin` decorations (both rewrite
|
|
14
|
+
* the DOM directly), breaking cell selection highlighting and column handles.
|
|
15
|
+
* The contextual toolbar avoids all decoration conflicts while delivering the
|
|
16
|
+
* full feature surface (add/remove row+col, Tab nav, lossless round-trip,
|
|
17
|
+
* token look, keyboard a11y). GFM's `tableKeymap` already binds Tab →
|
|
18
|
+
* NextCell and Shift+Tab → PrevCell so no extra keymap plugin is needed.
|
|
19
|
+
*
|
|
20
|
+
* Focus safety:
|
|
21
|
+
* Plugin views mount OUTSIDE `contentEditable` (ProseMirror appends them to
|
|
22
|
+
* the editor wrapper). Buttons use `onMouseDown={preventDefault}` so clicks
|
|
23
|
+
* never steal focus from the editing surface.
|
|
24
|
+
*
|
|
25
|
+
* Command wiring:
|
|
26
|
+
* All ops call the existing gfm commands (addRowAfterCommand, etc.) via
|
|
27
|
+
* `commandsCtx.call()`. The GFM schema + serializer are never replaced, so
|
|
28
|
+
* GFM round-trip fidelity is structurally guaranteed.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { CmdKey } from "@milkdown/kit/core";
|
|
32
|
+
import type { Ctx, MilkdownPlugin } from "@milkdown/kit/ctx";
|
|
33
|
+
import { commandsCtx } from "@milkdown/kit/core";
|
|
34
|
+
import {
|
|
35
|
+
addColAfterCommand,
|
|
36
|
+
addColBeforeCommand,
|
|
37
|
+
addRowAfterCommand,
|
|
38
|
+
addRowBeforeCommand,
|
|
39
|
+
deleteSelectedCellsCommand,
|
|
40
|
+
} from "@milkdown/kit/preset/gfm";
|
|
41
|
+
import { Plugin, PluginKey } from "@milkdown/kit/prose/state";
|
|
42
|
+
import type { EditorView } from "@milkdown/kit/prose/view";
|
|
43
|
+
import { $prose } from "@milkdown/kit/utils";
|
|
44
|
+
import { isInTable } from "@milkdown/kit/prose/tables";
|
|
45
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
46
|
+
import { usePluginViewContext } from "@prosemirror-adapter/react";
|
|
47
|
+
import type { usePluginViewFactory } from "@prosemirror-adapter/react";
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Ctx bridge
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Per-instance Ctx registry. The `$prose` plugin's `view(editorView)` records
|
|
55
|
+
* THIS editor's live Milkdown `Ctx` keyed by its ProseMirror view; the React
|
|
56
|
+
* toolbar reads it back via the view it gets from `usePluginViewContext`. Keyed
|
|
57
|
+
* by the view (NOT a module singleton) so multiple `MarkdownEditor`s on one page
|
|
58
|
+
* — a Storybook autodocs page, a split workspace — each dispatch to the correct
|
|
59
|
+
* instance. Entries are released with their view (WeakMap GC).
|
|
60
|
+
*/
|
|
61
|
+
const ctxByView = new WeakMap<EditorView, Ctx>();
|
|
62
|
+
|
|
63
|
+
/** Call a gfm command key on the editor that owns `view`. */
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
65
|
+
function dispatchCommand(view: EditorView, key: CmdKey<any>): void {
|
|
66
|
+
const ctx = ctxByView.get(view);
|
|
67
|
+
if (!ctx) return;
|
|
68
|
+
try {
|
|
69
|
+
ctx.get(commandsCtx).call(key);
|
|
70
|
+
} catch {
|
|
71
|
+
// Editor may be mid-destroy; swallow gracefully.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Plugin key
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
const tableControlsKey = new PluginKey<boolean>("brand-table-controls");
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Toolbar React component
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
interface ToolbarButtonProps {
|
|
86
|
+
onClick: () => void;
|
|
87
|
+
ariaLabel: string;
|
|
88
|
+
title: string;
|
|
89
|
+
children: React.ReactNode;
|
|
90
|
+
variant?: "default" | "destructive";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function ToolbarButton({
|
|
94
|
+
onClick,
|
|
95
|
+
ariaLabel,
|
|
96
|
+
title,
|
|
97
|
+
children,
|
|
98
|
+
variant = "default",
|
|
99
|
+
}: ToolbarButtonProps) {
|
|
100
|
+
return (
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
aria-label={ariaLabel}
|
|
104
|
+
title={title}
|
|
105
|
+
// Keep focus in the editor — a mousedown without preventDefault would
|
|
106
|
+
// blur the ProseMirror view before the click fires.
|
|
107
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
108
|
+
onClick={onClick}
|
|
109
|
+
className={cn(
|
|
110
|
+
"inline-flex h-7 items-center gap-1 rounded px-2 text-caption font-medium",
|
|
111
|
+
"border border-border-strong",
|
|
112
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
113
|
+
"transition-colors duration-fast ease-standard motion-reduce:transition-none",
|
|
114
|
+
variant === "destructive"
|
|
115
|
+
? "bg-background text-destructive hover:bg-destructive/10"
|
|
116
|
+
: "bg-background text-foreground hover:bg-surface-muted",
|
|
117
|
+
)}
|
|
118
|
+
>
|
|
119
|
+
{children}
|
|
120
|
+
</button>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Thin visual separator between button groups. */
|
|
125
|
+
function ToolbarDivider() {
|
|
126
|
+
return <span aria-hidden="true" className="mx-0.5 h-4 w-px bg-border-strong" />;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The table controls toolbar.
|
|
131
|
+
*
|
|
132
|
+
* Rendered as a ProseMirror plugin view: ProseMirror mounts it into the
|
|
133
|
+
* editor's wrapper DOM (`view.dom.parentElement`) and calls `update()` on
|
|
134
|
+
* every state change — `usePluginViewContext` re-renders the React tree.
|
|
135
|
+
* Hidden when the cursor is not inside a table.
|
|
136
|
+
*/
|
|
137
|
+
function TableControlsView() {
|
|
138
|
+
const { view } = usePluginViewContext();
|
|
139
|
+
const inTable = isInTable(view.state);
|
|
140
|
+
const run = (key: Parameters<typeof dispatchCommand>[1]) => () => dispatchCommand(view, key);
|
|
141
|
+
|
|
142
|
+
if (!inTable) return null;
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<div
|
|
146
|
+
role="toolbar"
|
|
147
|
+
aria-label="Table controls"
|
|
148
|
+
className={cn(
|
|
149
|
+
"flex flex-wrap items-center gap-1 px-2 py-1.5",
|
|
150
|
+
"border-t border-border-strong bg-surface-muted",
|
|
151
|
+
)}
|
|
152
|
+
>
|
|
153
|
+
{/* Row group */}
|
|
154
|
+
<span className="mr-1 select-none text-caption text-muted-foreground">Row</span>
|
|
155
|
+
<ToolbarButton
|
|
156
|
+
ariaLabel="Add row above"
|
|
157
|
+
title="Add row above"
|
|
158
|
+
onClick={run(addRowBeforeCommand.key)}
|
|
159
|
+
>
|
|
160
|
+
↑+
|
|
161
|
+
</ToolbarButton>
|
|
162
|
+
<ToolbarButton
|
|
163
|
+
ariaLabel="Add row below"
|
|
164
|
+
title="Add row below"
|
|
165
|
+
onClick={run(addRowAfterCommand.key)}
|
|
166
|
+
>
|
|
167
|
+
↓+
|
|
168
|
+
</ToolbarButton>
|
|
169
|
+
<ToolbarButton
|
|
170
|
+
ariaLabel="Delete row"
|
|
171
|
+
title="Delete row"
|
|
172
|
+
variant="destructive"
|
|
173
|
+
onClick={run(deleteSelectedCellsCommand.key)}
|
|
174
|
+
>
|
|
175
|
+
×row
|
|
176
|
+
</ToolbarButton>
|
|
177
|
+
|
|
178
|
+
<ToolbarDivider />
|
|
179
|
+
|
|
180
|
+
{/* Column group */}
|
|
181
|
+
<span className="mr-1 select-none text-caption text-muted-foreground">Col</span>
|
|
182
|
+
<ToolbarButton
|
|
183
|
+
ariaLabel="Add column left"
|
|
184
|
+
title="Add column left"
|
|
185
|
+
onClick={run(addColBeforeCommand.key)}
|
|
186
|
+
>
|
|
187
|
+
←+
|
|
188
|
+
</ToolbarButton>
|
|
189
|
+
<ToolbarButton
|
|
190
|
+
ariaLabel="Add column right"
|
|
191
|
+
title="Add column right"
|
|
192
|
+
onClick={run(addColAfterCommand.key)}
|
|
193
|
+
>
|
|
194
|
+
→+
|
|
195
|
+
</ToolbarButton>
|
|
196
|
+
<ToolbarButton
|
|
197
|
+
ariaLabel="Delete column"
|
|
198
|
+
title="Delete column"
|
|
199
|
+
variant="destructive"
|
|
200
|
+
onClick={run(deleteSelectedCellsCommand.key)}
|
|
201
|
+
>
|
|
202
|
+
×col
|
|
203
|
+
</ToolbarButton>
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Exported factory
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Build the Milkdown plugin array for table controls.
|
|
214
|
+
*
|
|
215
|
+
* Call with `usePluginViewFactory()` from inside `<ProsemirrorAdapterProvider>`,
|
|
216
|
+
* then `.use(tableViewPlugins(pluginViewFactory))` on the editor chain.
|
|
217
|
+
*
|
|
218
|
+
* The signature matches the `directiveViewPlugins` convention: factory
|
|
219
|
+
* captured from the React adapter hook, plugin array returned for `.use()`.
|
|
220
|
+
*/
|
|
221
|
+
export function tableViewPlugins(
|
|
222
|
+
pluginViewFactory: ReturnType<typeof usePluginViewFactory>,
|
|
223
|
+
): MilkdownPlugin[] {
|
|
224
|
+
return [
|
|
225
|
+
$prose((ctx) => {
|
|
226
|
+
// Build the ProseMirror PluginViewSpec from the adapter factory. This
|
|
227
|
+
// returns a `view(editorView) => PluginView` that ProseMirror calls to
|
|
228
|
+
// mount/update/destroy the React tree.
|
|
229
|
+
const makePluginView = pluginViewFactory({ component: TableControlsView });
|
|
230
|
+
|
|
231
|
+
return new Plugin({
|
|
232
|
+
key: tableControlsKey,
|
|
233
|
+
state: {
|
|
234
|
+
// Track whether the cursor is in a table (bool) as plugin state so
|
|
235
|
+
// ProseMirror knows when to trigger an `update()` on the plugin view.
|
|
236
|
+
init: (_cfg, state) => isInTable(state),
|
|
237
|
+
apply: (_tr, _prev, _old, state) => isInTable(state),
|
|
238
|
+
},
|
|
239
|
+
// Bind THIS editor's Ctx to THIS editor's view, so the toolbar's
|
|
240
|
+
// commands dispatch to the right instance when several editors share
|
|
241
|
+
// this module (autodocs page, split workspace).
|
|
242
|
+
view: (editorView) => {
|
|
243
|
+
ctxByView.set(editorView, ctx);
|
|
244
|
+
return makePluginView(editorView);
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
}) as unknown as MilkdownPlugin,
|
|
248
|
+
];
|
|
249
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The `:::iterate` / `:::pivot` directive bridge — turns a directive context
|
|
5
|
+
* (attributes + the captured raw body template) into an `IterationSpec`, and
|
|
6
|
+
* renders an `IterationBlock` with a recursion guard so a self-referential
|
|
7
|
+
* template can't loop forever (the transclusion depth-cap precedent).
|
|
8
|
+
*/
|
|
9
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
IterationBlock,
|
|
13
|
+
type EvaluateIteration,
|
|
14
|
+
type InterpolateTemplate,
|
|
15
|
+
type IterationLayout,
|
|
16
|
+
type IterationSpec,
|
|
17
|
+
} from "./iteration";
|
|
18
|
+
|
|
19
|
+
/** Max nesting depth for `:::iterate` inside an iterated cell. */
|
|
20
|
+
export const MAX_ITERATION_DEPTH = 3;
|
|
21
|
+
|
|
22
|
+
/** Current iteration nesting depth; 0 = top-level document. */
|
|
23
|
+
const IterationDepthContext = createContext(0);
|
|
24
|
+
|
|
25
|
+
const DEFAULT_LAYOUT: Record<"iterate" | "pivot", IterationLayout> = {
|
|
26
|
+
iterate: "stacked",
|
|
27
|
+
pivot: "matrix",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const LAYOUTS = new Set<IterationLayout>(["stacked", "grid", "matrix", "bento"]);
|
|
31
|
+
|
|
32
|
+
/** Build an `IterationSpec` from a directive's name + attributes + raw body. */
|
|
33
|
+
export function specFromDirective(
|
|
34
|
+
name: "iterate" | "pivot",
|
|
35
|
+
attributes: Record<string, string>,
|
|
36
|
+
rawBody: string | undefined,
|
|
37
|
+
): IterationSpec {
|
|
38
|
+
const kind = name;
|
|
39
|
+
const layoutAttr = attributes.layout as IterationLayout | undefined;
|
|
40
|
+
const layout = layoutAttr && LAYOUTS.has(layoutAttr) ? layoutAttr : DEFAULT_LAYOUT[kind];
|
|
41
|
+
const columns = attributes.columns ? Number(attributes.columns) || undefined : undefined;
|
|
42
|
+
return {
|
|
43
|
+
kind,
|
|
44
|
+
layout,
|
|
45
|
+
template: rawBody ?? "",
|
|
46
|
+
as: attributes.as?.trim() || "item",
|
|
47
|
+
source: attributes.source,
|
|
48
|
+
rows: attributes.rows,
|
|
49
|
+
cols: attributes.cols,
|
|
50
|
+
columns,
|
|
51
|
+
attributes,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface IterationDirectiveProps {
|
|
56
|
+
spec: IterationSpec;
|
|
57
|
+
evaluate: EvaluateIteration;
|
|
58
|
+
interpolate?: InterpolateTemplate;
|
|
59
|
+
/** Render one cell's resolved markdown → node (a nested `MarkdownPreview`). */
|
|
60
|
+
renderCell: (markdown: string) => ReactNode;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Renders an `IterationBlock` one nesting level deeper, refusing to recurse past
|
|
65
|
+
* {@link MAX_ITERATION_DEPTH}. Cells (which mount nested `MarkdownPreview`s) read
|
|
66
|
+
* the incremented depth, so a nested `:::iterate` is bounded.
|
|
67
|
+
*/
|
|
68
|
+
export function IterationDirective({
|
|
69
|
+
spec,
|
|
70
|
+
evaluate,
|
|
71
|
+
interpolate,
|
|
72
|
+
renderCell,
|
|
73
|
+
}: IterationDirectiveProps) {
|
|
74
|
+
const depth = useContext(IterationDepthContext);
|
|
75
|
+
if (depth >= MAX_ITERATION_DEPTH) {
|
|
76
|
+
return (
|
|
77
|
+
<div className="my-4 text-meta text-muted-foreground italic" data-iteration-too-deep="">
|
|
78
|
+
Iteration nested too deep — skipped.
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return (
|
|
83
|
+
<IterationDepthContext.Provider value={depth + 1}>
|
|
84
|
+
<IterationBlock
|
|
85
|
+
spec={spec}
|
|
86
|
+
evaluate={evaluate}
|
|
87
|
+
interpolate={interpolate}
|
|
88
|
+
render={renderCell}
|
|
89
|
+
/>
|
|
90
|
+
</IterationDepthContext.Provider>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The seam between an iteration node-view's `⋯` "Edit template…" affordance and
|
|
5
|
+
* the {@link IterationTemplateDialog} that fulfils it.
|
|
6
|
+
*
|
|
7
|
+
* Decoupled ON PURPOSE: the node-view (inside `MarkdownEditor`) must NOT import the
|
|
8
|
+
* dialog (which embeds a whole `MarkdownWorkspace` — a heavy, cyclic dependency).
|
|
9
|
+
* Instead the node-view emits an edit REQUEST through this context; the consumer
|
|
10
|
+
* (above the editor) renders the dialog and writes the result back via `onSave`.
|
|
11
|
+
* When no handler is provided, the `⋯` button is hidden and the template stays
|
|
12
|
+
* editable inline. This keeps `@elabs-ai/components-editor` a presentation layer — the app owns
|
|
13
|
+
* how/where the modal mounts.
|
|
14
|
+
*/
|
|
15
|
+
import { createContext } from "react";
|
|
16
|
+
|
|
17
|
+
export interface IterationEditRequest {
|
|
18
|
+
/** Which directive asked to be edited. */
|
|
19
|
+
kind: "iterate" | "pivot";
|
|
20
|
+
/** The current template markdown (the node body, serialized). */
|
|
21
|
+
template: string;
|
|
22
|
+
/** Apply the edited template back to the node. */
|
|
23
|
+
onSave: (template: string) => void;
|
|
24
|
+
/**
|
|
25
|
+
* The directive's current attributes (value lists, bind name, layout) — present
|
|
26
|
+
* when the node-view supports GUIDED re-editing (A5). The
|
|
27
|
+
* `IterationBuilderProvider` reads these to reopen the builder with its data;
|
|
28
|
+
* the template-only `IterationTemplateProvider` ignores them.
|
|
29
|
+
*/
|
|
30
|
+
attributes?: Record<string, string>;
|
|
31
|
+
/**
|
|
32
|
+
* Write back BOTH the attributes and the template (the guided builder path). When
|
|
33
|
+
* present it is preferred over {@link onSave} (which only rewrites the body).
|
|
34
|
+
*/
|
|
35
|
+
onSaveData?: (next: { attributes: Record<string, string>; template: string }) => void;
|
|
36
|
+
/**
|
|
37
|
+
* Directly rewrite the node's attributes (e.g. the node-menu's "Change layout" /
|
|
38
|
+
* "Transpose" actions) without touching the template body. Additive (#223) —
|
|
39
|
+
* present alongside {@link onSave}/{@link onSaveData} for back-compat; a
|
|
40
|
+
* consumer-side surface (a future dialog action) can reach the same write the
|
|
41
|
+
* node-view's own menu uses.
|
|
42
|
+
*/
|
|
43
|
+
onSetAttributes?: (attributes: Record<string, string>) => void;
|
|
44
|
+
/**
|
|
45
|
+
* Replace the ENTIRE directive node with plain markdown — e.g. the node-menu's
|
|
46
|
+
* "Convert to static" action (the evaluated, populated result). After this the
|
|
47
|
+
* node stops being a `:::iterate`/`:::pivot` directive. Additive (#223).
|
|
48
|
+
*/
|
|
49
|
+
onReplaceWithMarkdown?: (markdown: string) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A consumer handler that opens the template editor for a request. */
|
|
53
|
+
export type IterationEditHandler = (request: IterationEditRequest) => void;
|
|
54
|
+
|
|
55
|
+
/** Provide a handler to enable the node-view `⋯` re-edit affordance. */
|
|
56
|
+
export const IterationEditContext = createContext<IterationEditHandler | null>(null);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `markdown-iteration` — the `:::iterate` / `:::pivot` repeater for
|
|
3
|
+
* `MarkdownPreview`. Enabled by the `evaluateIteration` prop (the calc/citation
|
|
4
|
+
* precedent — *the library renders, the app computes*); the standalone
|
|
5
|
+
* `IterationBlock` + the public contract types are exported for direct use.
|
|
6
|
+
*/
|
|
7
|
+
export {
|
|
8
|
+
IterationBlock,
|
|
9
|
+
defaultInterpolate,
|
|
10
|
+
type EvaluateIteration,
|
|
11
|
+
type InterpolateTemplate,
|
|
12
|
+
type IterationBlockProps,
|
|
13
|
+
type IterationCell,
|
|
14
|
+
type IterationData,
|
|
15
|
+
type IterationLayout,
|
|
16
|
+
type IterationSpec,
|
|
17
|
+
} from "./iteration";
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
IterationDirective,
|
|
21
|
+
specFromDirective,
|
|
22
|
+
MAX_ITERATION_DEPTH,
|
|
23
|
+
type IterationDirectiveProps,
|
|
24
|
+
} from "./directive";
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { IterationBlock, type IterationData, type IterationSpec } from "./iteration";
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
// A plain cell renderer (NOT a nested MarkdownPreview) keeps these tests
|
|
9
|
+
// deterministic + light — they exercise layout + interpolation, not markdown.
|
|
10
|
+
const plainRender = (md: string) => <span data-cell="">{md}</span>;
|
|
11
|
+
|
|
12
|
+
const spec = (over: Partial<IterationSpec> = {}): IterationSpec => ({
|
|
13
|
+
kind: "iterate",
|
|
14
|
+
layout: "stacked",
|
|
15
|
+
template: "{{name}}",
|
|
16
|
+
as: "item",
|
|
17
|
+
attributes: {},
|
|
18
|
+
...over,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("IterationBlock", () => {
|
|
22
|
+
it("stacked: renders one interpolated cell per item, in order", () => {
|
|
23
|
+
const data: IterationData = {
|
|
24
|
+
cells: [{ context: { name: "Ada" } }, { context: { name: "Bo" } }],
|
|
25
|
+
};
|
|
26
|
+
render(<IterationBlock spec={spec()} evaluate={() => data} render={plainRender} />);
|
|
27
|
+
const cells = screen.getAllByText(/Ada|Bo/);
|
|
28
|
+
expect(cells.map((c) => c.textContent)).toEqual(["Ada", "Bo"]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("grid: lays cells into a table with the given column headers", () => {
|
|
32
|
+
const data: IterationData = {
|
|
33
|
+
columns: ["A", "B"],
|
|
34
|
+
cells: [{ context: { n: 1 } }, { context: { n: 2 } }, { context: { n: 3 } }],
|
|
35
|
+
};
|
|
36
|
+
render(
|
|
37
|
+
<IterationBlock
|
|
38
|
+
spec={spec({ layout: "grid", template: "{{n}}" })}
|
|
39
|
+
evaluate={() => data}
|
|
40
|
+
render={plainRender}
|
|
41
|
+
/>,
|
|
42
|
+
);
|
|
43
|
+
expect(screen.getByRole("columnheader", { name: "A" })).toBeInTheDocument();
|
|
44
|
+
// 3 cells + 2 columns → 2 rows (row-major).
|
|
45
|
+
expect(screen.getAllByRole("row")).toHaveLength(3); // header + 2 body rows
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("bento: renders one BentoGrid tile (bg-card surface) per cell", () => {
|
|
49
|
+
const data: IterationData = {
|
|
50
|
+
cells: [{ context: { n: 1 } }, { context: { n: 2 } }, { context: { n: 3 } }],
|
|
51
|
+
};
|
|
52
|
+
const { container } = render(
|
|
53
|
+
<IterationBlock
|
|
54
|
+
spec={spec({ layout: "bento", template: "{{n}}" })}
|
|
55
|
+
evaluate={() => data}
|
|
56
|
+
render={plainRender}
|
|
57
|
+
/>,
|
|
58
|
+
);
|
|
59
|
+
expect(container.querySelector('[data-iteration-layout="bento"]')).toBeInTheDocument();
|
|
60
|
+
// Each tile composes Card (bg-card) → 3 cells = 3 tiles.
|
|
61
|
+
expect(container.querySelectorAll(".bg-card")).toHaveLength(3);
|
|
62
|
+
expect(screen.getByText("1")).toBeInTheDocument();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("matrix: builds a pivot table with row + column headers and cells at intersections", () => {
|
|
66
|
+
const data: IterationData = {
|
|
67
|
+
rowHeaders: ["Q1", "Q2"],
|
|
68
|
+
colHeaders: ["EU", "US"],
|
|
69
|
+
cells: ["Q1", "Q2"].flatMap((r) =>
|
|
70
|
+
["EU", "US"].map((c) => ({ row: r, col: c, context: { v: `${r}/${c}` } })),
|
|
71
|
+
),
|
|
72
|
+
};
|
|
73
|
+
render(
|
|
74
|
+
<IterationBlock
|
|
75
|
+
spec={spec({ kind: "pivot", layout: "matrix", template: "{{v}}" })}
|
|
76
|
+
evaluate={() => data}
|
|
77
|
+
render={plainRender}
|
|
78
|
+
/>,
|
|
79
|
+
);
|
|
80
|
+
expect(screen.getByRole("columnheader", { name: "EU" })).toBeInTheDocument();
|
|
81
|
+
expect(screen.getByRole("rowheader", { name: "Q1" })).toBeInTheDocument();
|
|
82
|
+
expect(screen.getByText("Q1/US")).toBeInTheDocument();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("matrix without both axes falls back to a grid (no crash)", () => {
|
|
86
|
+
const data: IterationData = { cells: [{ context: { v: "x" } }] };
|
|
87
|
+
render(
|
|
88
|
+
<IterationBlock
|
|
89
|
+
spec={spec({ kind: "pivot", layout: "matrix", template: "{{v}}" })}
|
|
90
|
+
evaluate={() => data}
|
|
91
|
+
render={plainRender}
|
|
92
|
+
/>,
|
|
93
|
+
);
|
|
94
|
+
expect(screen.getByText("x")).toBeInTheDocument();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("renders a quiet empty state when there is no data", () => {
|
|
98
|
+
render(<IterationBlock spec={spec()} evaluate={() => null} render={plainRender} />);
|
|
99
|
+
expect(screen.getByRole("group", { name: "Iteration" })).toHaveTextContent(
|
|
100
|
+
/Nothing to iterate/i,
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("degrades a throwing evaluate to the empty state (never crashes)", () => {
|
|
105
|
+
const evaluate = vi.fn(() => {
|
|
106
|
+
throw new Error("boom");
|
|
107
|
+
});
|
|
108
|
+
render(<IterationBlock spec={spec()} evaluate={evaluate} render={plainRender} />);
|
|
109
|
+
expect(screen.getByRole("group", { name: "Iteration" })).toBeInTheDocument();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("uses a cell's pre-rendered markdown, skipping interpolate", () => {
|
|
113
|
+
const interpolate = vi.fn(() => "INTERPOLATED");
|
|
114
|
+
const data: IterationData = { cells: [{ context: {}, markdown: "PRERENDERED" }] };
|
|
115
|
+
render(
|
|
116
|
+
<IterationBlock
|
|
117
|
+
spec={spec()}
|
|
118
|
+
evaluate={() => data}
|
|
119
|
+
interpolate={interpolate}
|
|
120
|
+
render={plainRender}
|
|
121
|
+
/>,
|
|
122
|
+
);
|
|
123
|
+
expect(screen.getByText("PRERENDERED")).toBeInTheDocument();
|
|
124
|
+
expect(interpolate).not.toHaveBeenCalled();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("exposes the loop variable + fields to the template scope", () => {
|
|
128
|
+
const data: IterationData = { cells: [{ context: { name: "Ada" } }] };
|
|
129
|
+
render(
|
|
130
|
+
<IterationBlock
|
|
131
|
+
spec={spec({ as: "p", template: "{{p.name}}={{name}}" })}
|
|
132
|
+
evaluate={() => data}
|
|
133
|
+
render={plainRender}
|
|
134
|
+
/>,
|
|
135
|
+
);
|
|
136
|
+
expect(screen.getByText("Ada=Ada")).toBeInTheDocument();
|
|
137
|
+
});
|
|
138
|
+
});
|