@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,795 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* React node-views for the brand `:::` directives.
|
|
5
|
+
*
|
|
6
|
+
* Upgrades the Milkdown (ProseMirror) WYSIWYG surface from token-styled `toDOM`
|
|
7
|
+
* chrome (see directive-nodes.ts) to the ACTUAL @brand React components, rendered
|
|
8
|
+
* live INSIDE the editor via @prosemirror-adapter/react:
|
|
9
|
+
*
|
|
10
|
+
* :::card → real <Card> (title editable inline; body = editable content)
|
|
11
|
+
* :::callout → real <Alert> (title editable inline; body = editable content)
|
|
12
|
+
* ::metric → real <MetricBlock> (label + value editable inline; atomic)
|
|
13
|
+
* :::timeline → branded frame around the editable list (kept editable, not a
|
|
14
|
+
* derived <Timeline>, so steps round-trip as real markdown list items)
|
|
15
|
+
*
|
|
16
|
+
* Inline edits write through `setAttrs` to the directive node's `attributes`, which
|
|
17
|
+
* the existing `toMarkdown` runner serializes back to `:::name{key="value"}` — so the
|
|
18
|
+
* round-trip stays lossless (the editor and the Streamdown preview share one dialect).
|
|
19
|
+
*
|
|
20
|
+
* The `toDOM` definitions in directive-nodes.ts remain as the schema's serialization
|
|
21
|
+
* fallback (clipboard / no-adapter); when these node-views are registered, ProseMirror
|
|
22
|
+
* renders them instead.
|
|
23
|
+
*/
|
|
24
|
+
import {
|
|
25
|
+
Alert,
|
|
26
|
+
AlertDescription,
|
|
27
|
+
Card,
|
|
28
|
+
CardContent,
|
|
29
|
+
CardHeader,
|
|
30
|
+
CardTitle,
|
|
31
|
+
ContextMenu,
|
|
32
|
+
ContextMenuContent,
|
|
33
|
+
ContextMenuItem,
|
|
34
|
+
ContextMenuRadioGroup,
|
|
35
|
+
ContextMenuRadioItem,
|
|
36
|
+
ContextMenuSub,
|
|
37
|
+
ContextMenuSubContent,
|
|
38
|
+
ContextMenuSubTrigger,
|
|
39
|
+
ContextMenuTrigger,
|
|
40
|
+
DropdownMenu,
|
|
41
|
+
DropdownMenuContent,
|
|
42
|
+
DropdownMenuItem,
|
|
43
|
+
DropdownMenuRadioGroup,
|
|
44
|
+
DropdownMenuRadioItem,
|
|
45
|
+
DropdownMenuSub,
|
|
46
|
+
DropdownMenuSubContent,
|
|
47
|
+
DropdownMenuSubTrigger,
|
|
48
|
+
DropdownMenuTrigger,
|
|
49
|
+
} from "@elabs-ai/components-ui";
|
|
50
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
51
|
+
import { editorViewCtx, parserCtx, serializerCtx } from "@milkdown/kit/core";
|
|
52
|
+
import type { MilkdownPlugin } from "@milkdown/kit/ctx";
|
|
53
|
+
import type { Node as ProseNode } from "@milkdown/kit/prose/model";
|
|
54
|
+
import { $view } from "@milkdown/kit/utils";
|
|
55
|
+
import { useNodeViewContext, type useNodeViewFactory } from "@prosemirror-adapter/react";
|
|
56
|
+
import {
|
|
57
|
+
ArrowLeftRight,
|
|
58
|
+
FileText,
|
|
59
|
+
Grid3x3,
|
|
60
|
+
LayoutGrid,
|
|
61
|
+
MoreHorizontal,
|
|
62
|
+
Pencil,
|
|
63
|
+
Repeat2,
|
|
64
|
+
} from "lucide-react";
|
|
65
|
+
import { useContext, useEffect, useRef, type KeyboardEvent, type ReactNode } from "react";
|
|
66
|
+
|
|
67
|
+
import { IterationEditContext } from "../markdown-iteration/edit-context";
|
|
68
|
+
import type { IterationLayout } from "../markdown-iteration/iteration";
|
|
69
|
+
import {
|
|
70
|
+
builderValueFromParts,
|
|
71
|
+
directivePartsFromValue,
|
|
72
|
+
evaluateEmbedded,
|
|
73
|
+
ITERATION_LAYOUTS,
|
|
74
|
+
staticMarkdownFromValue,
|
|
75
|
+
transposeIterationValue,
|
|
76
|
+
} from "../markdown-iteration/iteration-builder";
|
|
77
|
+
import { MetricBlock } from "../metric-block";
|
|
78
|
+
import { containerDirectiveSchema, leafDirectiveSchema } from "./directive-nodes";
|
|
79
|
+
import { useInstance } from "./milkdown-react";
|
|
80
|
+
|
|
81
|
+
type Attrs = Record<string, string>;
|
|
82
|
+
|
|
83
|
+
/** Map a callout `type` to an @elabs-ai/components-ui Alert variant (mirrors the preview). */
|
|
84
|
+
const CALLOUT_VARIANT: Record<string, "default" | "info" | "success" | "warning" | "destructive"> =
|
|
85
|
+
{
|
|
86
|
+
info: "info",
|
|
87
|
+
note: "info",
|
|
88
|
+
tip: "success",
|
|
89
|
+
success: "success",
|
|
90
|
+
warning: "warning",
|
|
91
|
+
caution: "warning",
|
|
92
|
+
danger: "destructive",
|
|
93
|
+
error: "destructive",
|
|
94
|
+
destructive: "destructive",
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
function capitalize(s: string): string {
|
|
98
|
+
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Read the directive node's `name` + `attributes`, with a writer that round-trips. */
|
|
102
|
+
function useDirectiveAttrs() {
|
|
103
|
+
const { node, setAttrs } = useNodeViewContext();
|
|
104
|
+
const name = String(node.attrs.name);
|
|
105
|
+
const attributes = (node.attrs.attributes ?? {}) as Attrs;
|
|
106
|
+
const update = (key: string, value: string) => {
|
|
107
|
+
const next: Attrs = { ...attributes };
|
|
108
|
+
if (value === "") delete next[key];
|
|
109
|
+
else next[key] = value;
|
|
110
|
+
setAttrs({ attributes: next });
|
|
111
|
+
};
|
|
112
|
+
return { name, attributes, update };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface InlineEditProps {
|
|
116
|
+
value: string;
|
|
117
|
+
onCommit: (value: string) => void;
|
|
118
|
+
ariaLabel: string;
|
|
119
|
+
placeholder?: string;
|
|
120
|
+
className?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A seamless inline editor for a single directive attribute.
|
|
125
|
+
*
|
|
126
|
+
* Uncontrolled `contentEditable` (so the caret never jumps mid-type), syncing the
|
|
127
|
+
* DOM text from `value` only while NOT focused, and committing on blur / Enter
|
|
128
|
+
* (Escape reverts). Marked `data-directive-chrome` so the node-view's `stopEvent`
|
|
129
|
+
* routes its keystrokes to the browser, not ProseMirror.
|
|
130
|
+
*/
|
|
131
|
+
function InlineEdit({ value, onCommit, ariaLabel, placeholder, className }: InlineEditProps) {
|
|
132
|
+
const ref = useRef<HTMLSpanElement>(null);
|
|
133
|
+
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
const el = ref.current;
|
|
136
|
+
if (!el) return;
|
|
137
|
+
// Don't clobber the user's text while they're editing this field.
|
|
138
|
+
if (el === el.ownerDocument.activeElement) return;
|
|
139
|
+
if (el.textContent !== value) el.textContent = value;
|
|
140
|
+
}, [value]);
|
|
141
|
+
|
|
142
|
+
const commit = () => {
|
|
143
|
+
const next = (ref.current?.textContent ?? "").trim();
|
|
144
|
+
if (next !== value) onCommit(next);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const onKeyDown = (e: KeyboardEvent<HTMLSpanElement>) => {
|
|
148
|
+
if (e.key === "Enter") {
|
|
149
|
+
e.preventDefault();
|
|
150
|
+
e.currentTarget.blur();
|
|
151
|
+
} else if (e.key === "Escape") {
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
if (ref.current) ref.current.textContent = value;
|
|
154
|
+
e.currentTarget.blur();
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<span
|
|
160
|
+
ref={ref}
|
|
161
|
+
role="textbox"
|
|
162
|
+
aria-label={ariaLabel}
|
|
163
|
+
// Single-line field: Enter commits (it never inserts a newline).
|
|
164
|
+
aria-multiline={false}
|
|
165
|
+
data-directive-chrome=""
|
|
166
|
+
data-placeholder={placeholder}
|
|
167
|
+
contentEditable
|
|
168
|
+
suppressContentEditableWarning
|
|
169
|
+
tabIndex={0}
|
|
170
|
+
spellCheck={false}
|
|
171
|
+
onBlur={commit}
|
|
172
|
+
onKeyDown={onKeyDown}
|
|
173
|
+
className={cn(
|
|
174
|
+
"brand-inline-edit rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
175
|
+
className,
|
|
176
|
+
)}
|
|
177
|
+
/>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** `:::name` block directives → live @brand component with an editable body. */
|
|
182
|
+
function ContainerDirectiveView() {
|
|
183
|
+
const { contentRef } = useNodeViewContext();
|
|
184
|
+
const { name, attributes, update } = useDirectiveAttrs();
|
|
185
|
+
|
|
186
|
+
// The editable ProseMirror content (the directive body) mounts here.
|
|
187
|
+
const body = <div className="brand-directive__body" ref={contentRef} />;
|
|
188
|
+
|
|
189
|
+
if (name === "card") {
|
|
190
|
+
return (
|
|
191
|
+
<Card className="brand-directive brand-directive--card" data-brand-directive="card">
|
|
192
|
+
<CardHeader className="pb-3">
|
|
193
|
+
<CardTitle>
|
|
194
|
+
<InlineEdit
|
|
195
|
+
ariaLabel="Card title"
|
|
196
|
+
placeholder="Card title"
|
|
197
|
+
value={attributes.title ?? ""}
|
|
198
|
+
onCommit={(v) => update("title", v)}
|
|
199
|
+
/>
|
|
200
|
+
</CardTitle>
|
|
201
|
+
</CardHeader>
|
|
202
|
+
<CardContent>{body}</CardContent>
|
|
203
|
+
</Card>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (name === "callout") {
|
|
208
|
+
const variant = CALLOUT_VARIANT[attributes.type ?? ""] ?? "default";
|
|
209
|
+
return (
|
|
210
|
+
<Alert
|
|
211
|
+
variant={variant}
|
|
212
|
+
className="brand-directive brand-directive--callout"
|
|
213
|
+
data-brand-directive="callout"
|
|
214
|
+
>
|
|
215
|
+
{/* Non-heading title (matches the preview): a callout sits inside content
|
|
216
|
+
flow, so its label must not join the document heading outline (see #21). */}
|
|
217
|
+
<div className="mb-1 font-medium leading-none tracking-tight">
|
|
218
|
+
<InlineEdit
|
|
219
|
+
ariaLabel="Callout title"
|
|
220
|
+
placeholder={capitalize(attributes.type ?? "note")}
|
|
221
|
+
value={attributes.title ?? ""}
|
|
222
|
+
onCommit={(v) => update("title", v)}
|
|
223
|
+
/>
|
|
224
|
+
</div>
|
|
225
|
+
<AlertDescription>{body}</AlertDescription>
|
|
226
|
+
</Alert>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (name === "timeline") {
|
|
231
|
+
// Kept as an editable list inside a branded frame — a derived <Timeline> can't
|
|
232
|
+
// be edited in place, and the steps must serialize back as markdown list items.
|
|
233
|
+
return (
|
|
234
|
+
<div className="brand-directive brand-directive--timeline" data-brand-directive="timeline">
|
|
235
|
+
{body}
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (name === "iterate" || name === "pivot") {
|
|
241
|
+
// Owns hooks (dialog state + editor instance) → its own component.
|
|
242
|
+
return <IterationDirectiveView />;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Unknown container directive — surface it, but never drop the body content.
|
|
246
|
+
// `role="note"` (not Alert's default assertive `role="alert"`): this is a
|
|
247
|
+
// PERSISTENT block that re-renders on every re-parse, so an assertive live region
|
|
248
|
+
// would re-announce on each keystroke (#37). The title is a label, not a heading.
|
|
249
|
+
return (
|
|
250
|
+
<Alert
|
|
251
|
+
role="note"
|
|
252
|
+
variant="destructive"
|
|
253
|
+
className="brand-directive brand-directive--unknown"
|
|
254
|
+
data-brand-directive={name}
|
|
255
|
+
>
|
|
256
|
+
<div className="mb-1 font-medium leading-none tracking-tight">Unknown block: {name}</div>
|
|
257
|
+
<AlertDescription>{body}</AlertDescription>
|
|
258
|
+
</Alert>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/* ------------------------------------------------------------------ */
|
|
263
|
+
/* Iteration node-view (`:::iterate` / `:::pivot`) + the ⋯ re-edit modal */
|
|
264
|
+
/* ------------------------------------------------------------------ */
|
|
265
|
+
|
|
266
|
+
type GetEditor = () =>
|
|
267
|
+
| { ctx: { get: (token: unknown) => unknown }; action: <T>(fn: (ctx: unknown) => T) => T }
|
|
268
|
+
| undefined;
|
|
269
|
+
|
|
270
|
+
/** Serialize a directive node's BODY content back to markdown (the template). */
|
|
271
|
+
function readBodyMarkdown(getInstance: GetEditor, node: ProseNode): string {
|
|
272
|
+
try {
|
|
273
|
+
const editor = getInstance();
|
|
274
|
+
if (!editor) return node.textContent;
|
|
275
|
+
return editor
|
|
276
|
+
.action((ctx) => {
|
|
277
|
+
const serialize = (ctx as { get: (t: unknown) => (n: ProseNode) => string }).get(
|
|
278
|
+
serializerCtx,
|
|
279
|
+
);
|
|
280
|
+
const doc = node.type.schema.topNodeType.create(null, node.content);
|
|
281
|
+
return serialize(doc);
|
|
282
|
+
})
|
|
283
|
+
.trim();
|
|
284
|
+
} catch {
|
|
285
|
+
// Serializer unavailable (e.g. SSR/edge) — fall back to the plain text body.
|
|
286
|
+
return node.textContent;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Replace a directive node's BODY with markdown parsed back into PM content. */
|
|
291
|
+
function writeBodyMarkdown(
|
|
292
|
+
getInstance: GetEditor,
|
|
293
|
+
getPos: () => number | undefined,
|
|
294
|
+
template: string,
|
|
295
|
+
): void {
|
|
296
|
+
try {
|
|
297
|
+
const editor = getInstance();
|
|
298
|
+
const pos = getPos();
|
|
299
|
+
if (!editor || pos == null) return;
|
|
300
|
+
editor.action((ctx) => {
|
|
301
|
+
const parse = (ctx as { get: (t: unknown) => (md: string) => ProseNode | null }).get(
|
|
302
|
+
parserCtx,
|
|
303
|
+
);
|
|
304
|
+
const parsed = parse(template);
|
|
305
|
+
if (!parsed) return;
|
|
306
|
+
const view = (
|
|
307
|
+
ctx as {
|
|
308
|
+
get: (t: unknown) => {
|
|
309
|
+
state: { doc: ProseNode; tr: unknown };
|
|
310
|
+
dispatch: (tr: unknown) => void;
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
).get(editorViewCtx);
|
|
314
|
+
const node = view.state.doc.nodeAt(pos);
|
|
315
|
+
if (!node) return;
|
|
316
|
+
const start = pos + 1;
|
|
317
|
+
const end = start + node.content.size;
|
|
318
|
+
const tr = (
|
|
319
|
+
view.state.tr as { replaceWith: (from: number, to: number, content: unknown) => unknown }
|
|
320
|
+
).replaceWith(start, end, parsed.content);
|
|
321
|
+
view.dispatch(tr);
|
|
322
|
+
});
|
|
323
|
+
} catch {
|
|
324
|
+
// Parser unavailable / position stale — leave the inline body as the source.
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Merge `next` into `attrs`, OMITTING any key whose value is an empty string
|
|
330
|
+
* rather than writing it. `directivePartsFromValue` always computes `rows`/
|
|
331
|
+
* `cols`/`values` as a joined string — `""` when the list is empty — and an
|
|
332
|
+
* empty-string attribute value serializes as a BARE flag (`rows` with no
|
|
333
|
+
* `="…"`) via mdast-util-directive, which a consumer's `evaluate` then sees as
|
|
334
|
+
* `attributes.rows === ""` instead of `undefined` (a real behaviour-flip risk
|
|
335
|
+
* for any consumer that branches on attribute presence). Every writer that
|
|
336
|
+
* rewrites the builder-known keys (`as`/`layout`/`values`/`rows`/`cols`) with a
|
|
337
|
+
* possibly-empty computed value routes through this instead of a raw spread —
|
|
338
|
+
* that's also what keeps a consumer's OWN attributes (e.g. `source`/`region`,
|
|
339
|
+
* unknown to the builder model) intact: only the keys `next` actually names
|
|
340
|
+
* are touched, everything else in `attrs` passes through untouched.
|
|
341
|
+
*/
|
|
342
|
+
function mergeAttrsOmittingEmpty(
|
|
343
|
+
attrs: Record<string, string>,
|
|
344
|
+
next: Record<string, string>,
|
|
345
|
+
): Record<string, string> {
|
|
346
|
+
const merged: Record<string, string> = { ...attrs };
|
|
347
|
+
for (const [key, value] of Object.entries(next)) {
|
|
348
|
+
if (value === "") delete merged[key];
|
|
349
|
+
else merged[key] = value;
|
|
350
|
+
}
|
|
351
|
+
return merged;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Replace the ENTIRE directive node (not just its body) with parsed markdown —
|
|
356
|
+
* the "Convert to static" node-menu action's writer. A no-op on blank markdown
|
|
357
|
+
* (nothing resolved yet — e.g. no values entered) so the block is never
|
|
358
|
+
* clobbered with empty content.
|
|
359
|
+
*/
|
|
360
|
+
function replaceNodeWithMarkdown(
|
|
361
|
+
getInstance: GetEditor,
|
|
362
|
+
getPos: () => number | undefined,
|
|
363
|
+
markdown: string,
|
|
364
|
+
): void {
|
|
365
|
+
if (!markdown.trim()) return;
|
|
366
|
+
try {
|
|
367
|
+
const editor = getInstance();
|
|
368
|
+
const pos = getPos();
|
|
369
|
+
if (!editor || pos == null) return;
|
|
370
|
+
editor.action((ctx) => {
|
|
371
|
+
const parse = (ctx as { get: (t: unknown) => (md: string) => ProseNode | null }).get(
|
|
372
|
+
parserCtx,
|
|
373
|
+
);
|
|
374
|
+
const parsed = parse(markdown);
|
|
375
|
+
if (!parsed) return;
|
|
376
|
+
const view = (
|
|
377
|
+
ctx as {
|
|
378
|
+
get: (t: unknown) => {
|
|
379
|
+
state: { doc: ProseNode; tr: unknown };
|
|
380
|
+
dispatch: (tr: unknown) => void;
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
).get(editorViewCtx);
|
|
384
|
+
const node = view.state.doc.nodeAt(pos);
|
|
385
|
+
if (!node) return;
|
|
386
|
+
const tr = (
|
|
387
|
+
view.state.tr as { replaceWith: (from: number, to: number, content: unknown) => unknown }
|
|
388
|
+
).replaceWith(pos, pos + node.nodeSize, parsed.content);
|
|
389
|
+
view.dispatch(tr);
|
|
390
|
+
});
|
|
391
|
+
} catch {
|
|
392
|
+
// Parser/position unavailable — leave the node as a live directive.
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/* -------------------------------------------------------------------- */
|
|
397
|
+
/* The iteration node MENU (⋯ dropdown AND right-click context menu) */
|
|
398
|
+
/* -------------------------------------------------------------------- */
|
|
399
|
+
|
|
400
|
+
interface IterationMenuAction {
|
|
401
|
+
type: "item";
|
|
402
|
+
id: string;
|
|
403
|
+
label: string;
|
|
404
|
+
icon: ReactNode;
|
|
405
|
+
onSelect: () => void;
|
|
406
|
+
/**
|
|
407
|
+
* Disable the item (e.g. "Transpose"/"Convert to static" with no resolvable
|
|
408
|
+
* data yet). The WHY must be folded into `label` itself — a disabled Radix
|
|
409
|
+
* menu item is `pointer-events-none` (a `title` tooltip can never fire) and
|
|
410
|
+
* skipped by keyboard roving-focus, so `title` is not a viable explanation
|
|
411
|
+
* channel for any input modality.
|
|
412
|
+
*/
|
|
413
|
+
disabled?: boolean;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
interface IterationMenuLayoutGroup {
|
|
417
|
+
type: "layout";
|
|
418
|
+
id: "layout";
|
|
419
|
+
label: string;
|
|
420
|
+
icon: ReactNode;
|
|
421
|
+
value: IterationLayout;
|
|
422
|
+
options: IterationLayout[];
|
|
423
|
+
onChange: (layout: IterationLayout) => void;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
type IterationMenuEntry = IterationMenuAction | IterationMenuLayoutGroup;
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Render one shared item list into EITHER the `⋯` dropdown or the right-click
|
|
430
|
+
* context menu — the two surfaces the AC requires to expose the SAME actions
|
|
431
|
+
* (#223). Taking `entries` as data (rather than duplicating JSX per menu type)
|
|
432
|
+
* means the two menus can never diverge: a new action is added once, here.
|
|
433
|
+
*/
|
|
434
|
+
function IterationMenuItems({
|
|
435
|
+
kind,
|
|
436
|
+
entries,
|
|
437
|
+
}: {
|
|
438
|
+
kind: "dropdown" | "context";
|
|
439
|
+
entries: IterationMenuEntry[];
|
|
440
|
+
}) {
|
|
441
|
+
const isDropdown = kind === "dropdown";
|
|
442
|
+
const Item = isDropdown ? DropdownMenuItem : ContextMenuItem;
|
|
443
|
+
const Sub = isDropdown ? DropdownMenuSub : ContextMenuSub;
|
|
444
|
+
const SubTrigger = isDropdown ? DropdownMenuSubTrigger : ContextMenuSubTrigger;
|
|
445
|
+
const SubContent = isDropdown ? DropdownMenuSubContent : ContextMenuSubContent;
|
|
446
|
+
const RadioGroup = isDropdown ? DropdownMenuRadioGroup : ContextMenuRadioGroup;
|
|
447
|
+
const RadioItem = isDropdown ? DropdownMenuRadioItem : ContextMenuRadioItem;
|
|
448
|
+
|
|
449
|
+
return (
|
|
450
|
+
<>
|
|
451
|
+
{entries.map((entry) => {
|
|
452
|
+
if (entry.type === "layout") {
|
|
453
|
+
return (
|
|
454
|
+
<Sub key={entry.id}>
|
|
455
|
+
<SubTrigger className="gap-2">
|
|
456
|
+
{entry.icon}
|
|
457
|
+
{entry.label}
|
|
458
|
+
</SubTrigger>
|
|
459
|
+
<SubContent>
|
|
460
|
+
<RadioGroup
|
|
461
|
+
value={entry.value}
|
|
462
|
+
onValueChange={(next) => entry.onChange(next as IterationLayout)}
|
|
463
|
+
>
|
|
464
|
+
{entry.options.map((option) => (
|
|
465
|
+
<RadioItem key={option} value={option} className="capitalize">
|
|
466
|
+
{option}
|
|
467
|
+
</RadioItem>
|
|
468
|
+
))}
|
|
469
|
+
</RadioGroup>
|
|
470
|
+
</SubContent>
|
|
471
|
+
</Sub>
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
return (
|
|
475
|
+
<Item key={entry.id} onSelect={entry.onSelect} disabled={entry.disabled}>
|
|
476
|
+
{entry.icon}
|
|
477
|
+
{entry.label}
|
|
478
|
+
</Item>
|
|
479
|
+
);
|
|
480
|
+
})}
|
|
481
|
+
</>
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* `:::iterate` / `:::pivot` node-view: the body IS the per-cell TEMPLATE (with
|
|
487
|
+
* `{{tokens}}`), kept editable inline in a quiet labelled frame (accent rail, no
|
|
488
|
+
* fill). When the consumer provides an {@link IterationEditContext} handler, a
|
|
489
|
+
* `⋯` button AND a right-click both open the SAME node menu (#223):
|
|
490
|
+
* - "Edit iteration…" — the existing guided re-edit (`requestEdit`).
|
|
491
|
+
* - "Change layout" — rewrites the `layout` attribute directly (no dialog).
|
|
492
|
+
* - "Transpose" (pivot only) — swaps the rows/cols value lists.
|
|
493
|
+
* - "Convert to static" — replaces the directive with its populated markdown.
|
|
494
|
+
* With no handler wired, neither menu renders and the body stays editable
|
|
495
|
+
* inline (today's behaviour, unchanged).
|
|
496
|
+
*/
|
|
497
|
+
function IterationDirectiveView() {
|
|
498
|
+
const { contentRef, node, getPos, setAttrs } = useNodeViewContext();
|
|
499
|
+
const { name, attributes } = useDirectiveAttrs();
|
|
500
|
+
const [, getInstance] = useInstance();
|
|
501
|
+
const onEdit = useContext(IterationEditContext);
|
|
502
|
+
|
|
503
|
+
const isPivot = name === "pivot";
|
|
504
|
+
const kind: "iterate" | "pivot" = isPivot ? "pivot" : "iterate";
|
|
505
|
+
const Icon = isPivot ? Grid3x3 : Repeat2;
|
|
506
|
+
|
|
507
|
+
const requestEdit = () => {
|
|
508
|
+
onEdit?.({
|
|
509
|
+
kind,
|
|
510
|
+
template: readBodyMarkdown(getInstance as GetEditor, node),
|
|
511
|
+
// A5: hand the current attributes (value lists, bind name, layout) to the
|
|
512
|
+
// handler so the GUIDED builder can reopen with its data — and a writer that
|
|
513
|
+
// round-trips BOTH the attributes and the body, not just the template.
|
|
514
|
+
attributes: { ...(attributes as Record<string, string>) },
|
|
515
|
+
onSave: (template) => writeBodyMarkdown(getInstance as GetEditor, getPos, template),
|
|
516
|
+
// MERGE the guided builder's write-back into the EXISTING attributes rather
|
|
517
|
+
// than replacing the whole record — `directivePartsFromValue` only knows
|
|
518
|
+
// about `as`/`layout`/`values`/`rows`/`cols`, so a naive
|
|
519
|
+
// `setAttrs({ attributes: nextAttrs })` would silently drop every other
|
|
520
|
+
// attribute the directive carries (e.g. a consumer's `source`/`region`
|
|
521
|
+
// reference — `containerDirectiveSchema.attrs.attributes` is a free-form
|
|
522
|
+
// record, and those keys are load-bearing for the consumer's `evaluate`).
|
|
523
|
+
// Mirrors the `transpose()` fix below.
|
|
524
|
+
onSaveData: ({ attributes: nextAttrs, template }) => {
|
|
525
|
+
setAttrs({
|
|
526
|
+
attributes: mergeAttrsOmittingEmpty(attributes as Record<string, string>, nextAttrs),
|
|
527
|
+
});
|
|
528
|
+
writeBodyMarkdown(getInstance as GetEditor, getPos, template);
|
|
529
|
+
},
|
|
530
|
+
onSetAttributes: (nextAttrs) => setAttrs({ attributes: nextAttrs }),
|
|
531
|
+
onReplaceWithMarkdown: (markdown) =>
|
|
532
|
+
replaceNodeWithMarkdown(getInstance as GetEditor, getPos, markdown),
|
|
533
|
+
});
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
const setLayout = (layout: IterationLayout) => {
|
|
537
|
+
setAttrs({ attributes: { ...(attributes as Record<string, string>), layout } });
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Swap a pivot's rows/cols in place — a no-op template read (transpose never
|
|
542
|
+
* touches the body), so it doesn't need the (async-ish) `readBodyMarkdown`.
|
|
543
|
+
*
|
|
544
|
+
* MERGES the transposed `rows`/`cols` into the EXISTING attributes rather than
|
|
545
|
+
* replacing the whole record (via `mergeAttrsOmittingEmpty`) — so a consumer's
|
|
546
|
+
* OTHER attributes (e.g. `source`/`region`) survive, AND an empty transposed
|
|
547
|
+
* axis is OMITTED rather than written as `""` (which mdast-util-directive would
|
|
548
|
+
* serialize as a bare `rows`/`cols` FLAG, not a genuinely-absent attribute). The
|
|
549
|
+
* menu gates this action on `hasEmbeddedData` below, so in practice both axes
|
|
550
|
+
* are always non-empty here — this stays defensive rather than load-bearing.
|
|
551
|
+
*/
|
|
552
|
+
const transpose = () => {
|
|
553
|
+
const seed = builderValueFromParts(kind, attributes as Record<string, string>, "");
|
|
554
|
+
const { attributes: transposed } = directivePartsFromValue(transposeIterationValue(seed));
|
|
555
|
+
setAttrs({
|
|
556
|
+
attributes: mergeAttrsOmittingEmpty(attributes as Record<string, string>, {
|
|
557
|
+
rows: transposed.rows ?? "",
|
|
558
|
+
cols: transposed.cols ?? "",
|
|
559
|
+
}),
|
|
560
|
+
});
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
// "Transpose" (pivot) and "Convert to static" only have anything to act on
|
|
564
|
+
// when the block's data is EMBEDDED in its own attributes (`values=` /
|
|
565
|
+
// `rows=`×`cols=`) — the built-in `evaluateEmbedded` resolver. A block whose
|
|
566
|
+
// cells come from the consumer's `evaluate` instead (e.g. `source="repos"`)
|
|
567
|
+
// resolves to zero cells here — Transpose would be a pure visual no-op that
|
|
568
|
+
// still DIRTIES the document (writing bare `rows`/`cols` flags), and Convert
|
|
569
|
+
// to static has nothing to flatten — so both are disabled, with the reason
|
|
570
|
+
// folded into the visible LABEL (not `title`): a disabled Radix menu item is
|
|
571
|
+
// both `pointer-events-none` (no hover tooltip can ever fire) and skipped by
|
|
572
|
+
// keyboard roving-focus, so `title` is unreachable by any input modality —
|
|
573
|
+
// the rendered text is the only place a reason can actually be read.
|
|
574
|
+
const hasEmbeddedData =
|
|
575
|
+
evaluateEmbedded({
|
|
576
|
+
kind,
|
|
577
|
+
layout: (attributes.layout as IterationLayout) || ITERATION_LAYOUTS[kind][0]!,
|
|
578
|
+
template: "",
|
|
579
|
+
as: attributes.as || "item",
|
|
580
|
+
attributes: attributes as Record<string, string>,
|
|
581
|
+
}).cells.length > 0;
|
|
582
|
+
|
|
583
|
+
const disabledHint = "— needs embedded values";
|
|
584
|
+
|
|
585
|
+
const convertToStatic = () => {
|
|
586
|
+
const template = readBodyMarkdown(getInstance as GetEditor, node);
|
|
587
|
+
const value = builderValueFromParts(kind, attributes as Record<string, string>, template);
|
|
588
|
+
replaceNodeWithMarkdown(getInstance as GetEditor, getPos, staticMarkdownFromValue(value));
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
const menuEntries: IterationMenuEntry[] = [
|
|
592
|
+
{
|
|
593
|
+
type: "item",
|
|
594
|
+
id: "edit",
|
|
595
|
+
label: "Edit iteration…",
|
|
596
|
+
icon: <Pencil className="size-4" aria-hidden="true" />,
|
|
597
|
+
onSelect: requestEdit,
|
|
598
|
+
},
|
|
599
|
+
{
|
|
600
|
+
type: "layout",
|
|
601
|
+
id: "layout",
|
|
602
|
+
label: "Change layout",
|
|
603
|
+
icon: <LayoutGrid className="size-4" aria-hidden="true" />,
|
|
604
|
+
value: (attributes.layout as IterationLayout) || ITERATION_LAYOUTS[kind][0]!,
|
|
605
|
+
options: ITERATION_LAYOUTS[kind],
|
|
606
|
+
onChange: setLayout,
|
|
607
|
+
},
|
|
608
|
+
...(isPivot
|
|
609
|
+
? [
|
|
610
|
+
{
|
|
611
|
+
type: "item",
|
|
612
|
+
id: "transpose",
|
|
613
|
+
label: hasEmbeddedData ? "Transpose" : `Transpose ${disabledHint}`,
|
|
614
|
+
icon: <ArrowLeftRight className="size-4" aria-hidden="true" />,
|
|
615
|
+
onSelect: transpose,
|
|
616
|
+
disabled: !hasEmbeddedData,
|
|
617
|
+
} satisfies IterationMenuAction,
|
|
618
|
+
]
|
|
619
|
+
: []),
|
|
620
|
+
{
|
|
621
|
+
type: "item",
|
|
622
|
+
id: "convert-to-static",
|
|
623
|
+
label: hasEmbeddedData ? "Convert to static" : `Convert to static ${disabledHint}`,
|
|
624
|
+
icon: <FileText className="size-4" aria-hidden="true" />,
|
|
625
|
+
onSelect: convertToStatic,
|
|
626
|
+
disabled: !hasEmbeddedData,
|
|
627
|
+
},
|
|
628
|
+
];
|
|
629
|
+
|
|
630
|
+
const header = (
|
|
631
|
+
<div className="mb-1.5 flex items-center gap-1.5 text-meta font-medium text-info-text">
|
|
632
|
+
<Icon className="size-3.5 shrink-0" aria-hidden="true" />
|
|
633
|
+
<span>{isPivot ? "Pivot" : "Iterate"}</span>
|
|
634
|
+
{!isPivot && attributes.as ? (
|
|
635
|
+
<span className="font-normal text-muted-foreground">· per {attributes.as}</span>
|
|
636
|
+
) : null}
|
|
637
|
+
<span className="font-normal text-muted-foreground">— template</span>
|
|
638
|
+
{onEdit ? (
|
|
639
|
+
<DropdownMenu>
|
|
640
|
+
<DropdownMenuTrigger asChild>
|
|
641
|
+
<button
|
|
642
|
+
type="button"
|
|
643
|
+
// `data-directive-chrome` routes the click to the browser, not ProseMirror.
|
|
644
|
+
data-directive-chrome=""
|
|
645
|
+
aria-label="Iteration actions"
|
|
646
|
+
title="Iteration actions…"
|
|
647
|
+
className="ms-auto inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
648
|
+
>
|
|
649
|
+
<MoreHorizontal className="size-4" aria-hidden="true" />
|
|
650
|
+
</button>
|
|
651
|
+
</DropdownMenuTrigger>
|
|
652
|
+
<DropdownMenuContent align="end">
|
|
653
|
+
<IterationMenuItems kind="dropdown" entries={menuEntries} />
|
|
654
|
+
</DropdownMenuContent>
|
|
655
|
+
</DropdownMenu>
|
|
656
|
+
) : null}
|
|
657
|
+
</div>
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
const body = (
|
|
661
|
+
// The editable template body (inline ProseMirror content).
|
|
662
|
+
<div className="brand-directive__body" ref={contentRef} />
|
|
663
|
+
);
|
|
664
|
+
|
|
665
|
+
if (!onEdit) {
|
|
666
|
+
// No consumer handler wired — no re-edit / node-menu affordance (today's
|
|
667
|
+
// behaviour); the template stays editable inline only.
|
|
668
|
+
return (
|
|
669
|
+
<div
|
|
670
|
+
className="brand-directive brand-directive--iterate border-s-2 border-s-info ps-3"
|
|
671
|
+
data-brand-directive={name}
|
|
672
|
+
>
|
|
673
|
+
{header}
|
|
674
|
+
{body}
|
|
675
|
+
</div>
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
return (
|
|
680
|
+
<ContextMenu>
|
|
681
|
+
<div
|
|
682
|
+
className="brand-directive brand-directive--iterate border-s-2 border-s-info ps-3"
|
|
683
|
+
data-brand-directive={name}
|
|
684
|
+
>
|
|
685
|
+
{/*
|
|
686
|
+
* Scoped to the HEADER chrome only — NOT the editable template body.
|
|
687
|
+
* Radix's ContextMenuTrigger calls `event.preventDefault()` on every
|
|
688
|
+
* `contextmenu` inside its child, so wrapping the whole frame (header +
|
|
689
|
+
* body) hijacked the browser's native context menu (spellcheck
|
|
690
|
+
* suggestions, Paste, Look Up, Emoji) for right-clicks inside the
|
|
691
|
+
* editable template text. Right-click still opens this SAME menu from
|
|
692
|
+
* the header row; the body keeps its native menu.
|
|
693
|
+
*/}
|
|
694
|
+
<ContextMenuTrigger asChild>{header}</ContextMenuTrigger>
|
|
695
|
+
{body}
|
|
696
|
+
</div>
|
|
697
|
+
<ContextMenuContent>
|
|
698
|
+
<IterationMenuItems kind="context" entries={menuEntries} />
|
|
699
|
+
</ContextMenuContent>
|
|
700
|
+
</ContextMenu>
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** `::name` leaf directives (e.g. `::metric`) → live, atomic @brand component. */
|
|
705
|
+
function LeafDirectiveView() {
|
|
706
|
+
const { name, attributes, update } = useDirectiveAttrs();
|
|
707
|
+
|
|
708
|
+
if (name !== "metric") {
|
|
709
|
+
return (
|
|
710
|
+
<div
|
|
711
|
+
className="brand-directive brand-directive--leaf brand-directive--unknown rounded-md border border-destructive/40 bg-surface-muted p-3 text-sm text-muted-foreground"
|
|
712
|
+
data-brand-leaf={name}
|
|
713
|
+
>
|
|
714
|
+
Unknown inline block: <code>::{name}</code>
|
|
715
|
+
</div>
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const delta = attributes.delta;
|
|
720
|
+
return (
|
|
721
|
+
<MetricBlock
|
|
722
|
+
className="brand-directive brand-directive--leaf brand-directive--metric"
|
|
723
|
+
data-brand-leaf="metric"
|
|
724
|
+
label={
|
|
725
|
+
<InlineEdit
|
|
726
|
+
ariaLabel="Metric label"
|
|
727
|
+
placeholder="Label"
|
|
728
|
+
value={attributes.label ?? ""}
|
|
729
|
+
onCommit={(v) => update("label", v)}
|
|
730
|
+
/>
|
|
731
|
+
}
|
|
732
|
+
value={
|
|
733
|
+
<InlineEdit
|
|
734
|
+
ariaLabel="Metric value"
|
|
735
|
+
placeholder="0"
|
|
736
|
+
value={attributes.value ?? ""}
|
|
737
|
+
onCommit={(v) => update("value", v)}
|
|
738
|
+
className="min-w-[1ch]"
|
|
739
|
+
/>
|
|
740
|
+
}
|
|
741
|
+
description={attributes.description}
|
|
742
|
+
delta={delta}
|
|
743
|
+
deltaDirection={delta?.startsWith("+") ? "up" : delta?.startsWith("-") ? "down" : "neutral"}
|
|
744
|
+
/>
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Keep keystrokes inside an editable attribute field out of ProseMirror's hands —
|
|
750
|
+
* but ONLY for chrome that belongs to THIS node-view, not an ancestor's. ProseMirror
|
|
751
|
+
* asks the node-view whose `dom` contains the event, and `@prosemirror-adapter` marks
|
|
752
|
+
* every node-view root with `data-node-view-root`. So we walk up from the event target
|
|
753
|
+
* and stop ONLY if we reach a `[data-directive-chrome]` element WITHOUT first crossing
|
|
754
|
+
* a node-view-root boundary. This bounds the capture to the current node-view — a
|
|
755
|
+
* nested directive can't swallow events meant for its parent's chrome, and vice versa
|
|
756
|
+
* (#37). A shared, unbounded `closest()` could reach an ancestor's chrome.
|
|
757
|
+
*/
|
|
758
|
+
function directiveStopEvent(event: Event): boolean {
|
|
759
|
+
let node = event.target;
|
|
760
|
+
while (node instanceof HTMLElement) {
|
|
761
|
+
if (node.hasAttribute("data-directive-chrome")) return true;
|
|
762
|
+
// Reached this node-view's own root without finding chrome → any match above
|
|
763
|
+
// belongs to an ancestor node-view; don't capture for it.
|
|
764
|
+
if (node.hasAttribute("data-node-view-root")) return false;
|
|
765
|
+
node = node.parentElement;
|
|
766
|
+
}
|
|
767
|
+
return false;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Build the `$view` plugins that bind the directive schemas to their React
|
|
772
|
+
* node-views. Call with the factory from `useNodeViewFactory()` (so it must run
|
|
773
|
+
* inside a `<ProsemirrorAdapterProvider>`), then `.use()` the result on the editor.
|
|
774
|
+
*/
|
|
775
|
+
export function directiveViewPlugins(
|
|
776
|
+
nodeViewFactory: ReturnType<typeof useNodeViewFactory>,
|
|
777
|
+
): MilkdownPlugin[] {
|
|
778
|
+
return [
|
|
779
|
+
$view(containerDirectiveSchema.node, () =>
|
|
780
|
+
nodeViewFactory({
|
|
781
|
+
component: ContainerDirectiveView,
|
|
782
|
+
as: "div",
|
|
783
|
+
contentAs: "div",
|
|
784
|
+
stopEvent: directiveStopEvent,
|
|
785
|
+
}),
|
|
786
|
+
),
|
|
787
|
+
$view(leafDirectiveSchema.node, () =>
|
|
788
|
+
nodeViewFactory({
|
|
789
|
+
component: LeafDirectiveView,
|
|
790
|
+
as: "div",
|
|
791
|
+
stopEvent: directiveStopEvent,
|
|
792
|
+
}),
|
|
793
|
+
),
|
|
794
|
+
].flat() as MilkdownPlugin[];
|
|
795
|
+
}
|