@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,75 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CompletionWidget — the React widget the completions plugin mounts at the
|
|
5
|
+
* caret (the `slash-widget.tsx` precedent). Reads the live plugin state,
|
|
6
|
+
* renders the branded `<CompletionMenu>` floating below the caret, and inserts
|
|
7
|
+
* the chosen candidate through the SAME path as the plugin's own Enter/Tab
|
|
8
|
+
* handler (`insertCompletionItem`), so mouse and keyboard behave identically.
|
|
9
|
+
*/
|
|
10
|
+
import type { ReactWidgetViewComponent } from "@prosemirror-adapter/react";
|
|
11
|
+
import { useWidgetViewContext } from "@prosemirror-adapter/react";
|
|
12
|
+
import { useLayoutEffect, useRef } from "react";
|
|
13
|
+
|
|
14
|
+
import { CompletionMenu, completionOptionId } from "./completions-menu";
|
|
15
|
+
import {
|
|
16
|
+
completionsPluginKey,
|
|
17
|
+
insertCompletionItem,
|
|
18
|
+
type CompletionPluginState,
|
|
19
|
+
} from "./completions-prose";
|
|
20
|
+
|
|
21
|
+
const ID_PREFIX = "brand-completions";
|
|
22
|
+
|
|
23
|
+
/** Build the widget component (no external state — everything reads off the view). */
|
|
24
|
+
export function createCompletionWidget(): ReactWidgetViewComponent {
|
|
25
|
+
function CompletionWidget() {
|
|
26
|
+
const { view } = useWidgetViewContext();
|
|
27
|
+
const wrapperRef = useRef<HTMLSpanElement>(null);
|
|
28
|
+
|
|
29
|
+
const state = completionsPluginKey.getState(view.state) as CompletionPluginState | undefined;
|
|
30
|
+
const items = state?.items ?? [];
|
|
31
|
+
const activeIndex = state ? Math.min(state.index, Math.max(0, items.length - 1)) : 0;
|
|
32
|
+
const activeId = items.length > 0 ? completionOptionId(ID_PREFIX, activeIndex) : undefined;
|
|
33
|
+
|
|
34
|
+
// Mirror the active option onto the editor's `textbox` for AT, same wiring
|
|
35
|
+
// as the slash widget.
|
|
36
|
+
useLayoutEffect(() => {
|
|
37
|
+
const dom = view.dom as HTMLElement;
|
|
38
|
+
const listEl = wrapperRef.current?.querySelector<HTMLElement>('[role="listbox"]');
|
|
39
|
+
if (listEl && !listEl.id) listEl.id = `${ID_PREFIX}-listbox`;
|
|
40
|
+
dom.setAttribute("aria-expanded", "true");
|
|
41
|
+
if (listEl) dom.setAttribute("aria-controls", listEl.id);
|
|
42
|
+
if (activeId) dom.setAttribute("aria-activedescendant", activeId);
|
|
43
|
+
else dom.removeAttribute("aria-activedescendant");
|
|
44
|
+
return () => {
|
|
45
|
+
dom.removeAttribute("aria-expanded");
|
|
46
|
+
dom.removeAttribute("aria-controls");
|
|
47
|
+
dom.removeAttribute("aria-activedescendant");
|
|
48
|
+
};
|
|
49
|
+
}, [view, activeId]);
|
|
50
|
+
|
|
51
|
+
const onSelect = (index: number) => {
|
|
52
|
+
if (!state) return;
|
|
53
|
+
const item = items[index];
|
|
54
|
+
if (item) insertCompletionItem(view, state, item);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<span
|
|
59
|
+
ref={wrapperRef}
|
|
60
|
+
contentEditable={false}
|
|
61
|
+
className="brand-completions-anchor relative inline-block h-0 w-0 align-baseline"
|
|
62
|
+
>
|
|
63
|
+
<span className="absolute left-0 top-1 z-50 block">
|
|
64
|
+
<CompletionMenu
|
|
65
|
+
items={items}
|
|
66
|
+
activeIndex={activeIndex}
|
|
67
|
+
onSelect={onSelect}
|
|
68
|
+
idPrefix={ID_PREFIX}
|
|
69
|
+
/>
|
|
70
|
+
</span>
|
|
71
|
+
</span>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return CompletionWidget;
|
|
75
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WYSIWYG completions mirror — public surface for the Milkdown `MarkdownEditor`
|
|
5
|
+
* (the `../slash/index.ts` precedent). See `completions-prose.ts` for what this
|
|
6
|
+
* mirror does and does NOT cover relative to the Monaco path.
|
|
7
|
+
*/
|
|
8
|
+
import type { MilkdownPlugin } from "@milkdown/kit/ctx";
|
|
9
|
+
|
|
10
|
+
import type { EditorCompletionProvider } from "../../lib/editor-completions";
|
|
11
|
+
import { completionsProsePlugin, type CompletionWidgetFactory } from "./completions-prose";
|
|
12
|
+
import { createCompletionWidget } from "./completions-widget";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build the `.use()`-ready plugin array for the WYSIWYG completions mirror.
|
|
16
|
+
* Call with the widget factory from `useWidgetViewFactory()` (must run inside a
|
|
17
|
+
* `<ProsemirrorAdapterProvider>`), mirroring `brandSlashViewPlugins`.
|
|
18
|
+
*/
|
|
19
|
+
export function completionsViewPlugins(
|
|
20
|
+
widgetFactory: CompletionWidgetFactory,
|
|
21
|
+
getProviders: () => EditorCompletionProvider[] | undefined,
|
|
22
|
+
): MilkdownPlugin[] {
|
|
23
|
+
const widgetComponent = createCompletionWidget();
|
|
24
|
+
return [completionsProsePlugin({ widgetFactory, widgetComponent, getProviders })];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
CLOSED,
|
|
29
|
+
completionsPluginKey,
|
|
30
|
+
nextCompletionState,
|
|
31
|
+
buildCompletionContext,
|
|
32
|
+
completionReplaceRange,
|
|
33
|
+
insertCompletionItem,
|
|
34
|
+
type CompletionPluginState,
|
|
35
|
+
type CompletionWidgetFactory,
|
|
36
|
+
} from "./completions-prose";
|
|
37
|
+
export { CompletionMenu, completionOptionId, type CompletionMenuProps } from "./completions-menu";
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WYSIWYG support for the brand `:::` directives inside the Milkdown editor.
|
|
3
|
+
*
|
|
4
|
+
* Adds remark-directive to Milkdown's pipeline (so `:::card` / `:::callout` /
|
|
5
|
+
* `:::timeline` / `::metric` PARSE and SERIALIZE — lossless round-trip), plus two
|
|
6
|
+
* generic ProseMirror node schemas:
|
|
7
|
+
* - a CONTAINER node (editable body) for `:::` block directives
|
|
8
|
+
* - a LEAF node (atomic) for `::` directives like `::metric`
|
|
9
|
+
*
|
|
10
|
+
* Rendering is ProseMirror-native `toDOM` chrome styled from semantic tokens
|
|
11
|
+
* (see markdown-editor.css) — deliberately NOT a React node-view adapter, so the
|
|
12
|
+
* editor has zero extra runtime deps and the chrome stays token-driven. The
|
|
13
|
+
* branded PREVIEW still renders the real @brand React components; this gives the
|
|
14
|
+
* WYSIWYG surface a matching, editable representation.
|
|
15
|
+
*/
|
|
16
|
+
import type { MilkdownPlugin } from "@milkdown/kit/ctx";
|
|
17
|
+
import { $nodeSchema, $remark } from "@milkdown/kit/utils";
|
|
18
|
+
import remarkDirective from "remark-directive";
|
|
19
|
+
|
|
20
|
+
interface DirectiveMdast {
|
|
21
|
+
type: string;
|
|
22
|
+
name?: string;
|
|
23
|
+
attributes?: Record<string, string> | null;
|
|
24
|
+
children?: unknown[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Add remark-directive to Milkdown's unified processor (parse + stringify). */
|
|
28
|
+
export const directiveRemark = $remark("brandDirective", () => remarkDirective);
|
|
29
|
+
|
|
30
|
+
/** `:::name` block directives → an editable container node with branded chrome. */
|
|
31
|
+
export const containerDirectiveSchema = $nodeSchema("brand_container_directive", () => ({
|
|
32
|
+
content: "block+",
|
|
33
|
+
group: "block",
|
|
34
|
+
defining: true,
|
|
35
|
+
attrs: {
|
|
36
|
+
name: { default: "card" },
|
|
37
|
+
attributes: { default: {} as Record<string, string> },
|
|
38
|
+
},
|
|
39
|
+
parseDOM: [
|
|
40
|
+
{
|
|
41
|
+
tag: "div[data-brand-directive]",
|
|
42
|
+
getAttrs: (dom: HTMLElement | string) => {
|
|
43
|
+
if (typeof dom === "string") return false;
|
|
44
|
+
return {
|
|
45
|
+
name: dom.getAttribute("data-brand-directive") ?? "card",
|
|
46
|
+
attributes: JSON.parse(dom.getAttribute("data-brand-attrs") ?? "{}"),
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
toDOM: (node) => {
|
|
52
|
+
const name = String(node.attrs.name);
|
|
53
|
+
const attrs = (node.attrs.attributes ?? {}) as Record<string, string>;
|
|
54
|
+
const heading = attrs.title || (name === "callout" ? (attrs.type ?? "note") : "");
|
|
55
|
+
const chrome: unknown[] = [
|
|
56
|
+
"div",
|
|
57
|
+
{
|
|
58
|
+
"data-brand-directive": name,
|
|
59
|
+
"data-brand-attrs": JSON.stringify(attrs),
|
|
60
|
+
class: `brand-directive brand-directive--${name}`,
|
|
61
|
+
"data-callout-type": name === "callout" ? (attrs.type ?? "note") : null,
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
if (heading)
|
|
65
|
+
chrome.push(["div", { class: "brand-directive__title", contenteditable: "false" }, heading]);
|
|
66
|
+
chrome.push(["div", { class: "brand-directive__body" }, 0]);
|
|
67
|
+
return chrome as never;
|
|
68
|
+
},
|
|
69
|
+
parseMarkdown: {
|
|
70
|
+
match: (node) => (node as DirectiveMdast).type === "containerDirective",
|
|
71
|
+
runner: (state, node, type) => {
|
|
72
|
+
const d = node as DirectiveMdast;
|
|
73
|
+
state.openNode(type, { name: d.name ?? "card", attributes: d.attributes ?? {} });
|
|
74
|
+
state.next((d.children ?? []) as never);
|
|
75
|
+
state.closeNode();
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
toMarkdown: {
|
|
79
|
+
match: (node) => node.type.name === "brand_container_directive",
|
|
80
|
+
runner: (state, node) => {
|
|
81
|
+
state.openNode("containerDirective", undefined, {
|
|
82
|
+
name: node.attrs.name,
|
|
83
|
+
attributes: node.attrs.attributes,
|
|
84
|
+
});
|
|
85
|
+
state.next(node.content);
|
|
86
|
+
state.closeNode();
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
/** `::name` leaf directives (e.g. `::metric`) → an atomic node with branded chrome. */
|
|
92
|
+
export const leafDirectiveSchema = $nodeSchema("brand_leaf_directive", () => ({
|
|
93
|
+
group: "block",
|
|
94
|
+
atom: true,
|
|
95
|
+
isolating: true,
|
|
96
|
+
attrs: {
|
|
97
|
+
name: { default: "metric" },
|
|
98
|
+
attributes: { default: {} as Record<string, string> },
|
|
99
|
+
},
|
|
100
|
+
parseDOM: [
|
|
101
|
+
{
|
|
102
|
+
tag: "div[data-brand-leaf]",
|
|
103
|
+
getAttrs: (dom: HTMLElement | string) => {
|
|
104
|
+
if (typeof dom === "string") return false;
|
|
105
|
+
return {
|
|
106
|
+
name: dom.getAttribute("data-brand-leaf") ?? "metric",
|
|
107
|
+
attributes: JSON.parse(dom.getAttribute("data-brand-attrs") ?? "{}"),
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
toDOM: (node) => {
|
|
113
|
+
const name = String(node.attrs.name);
|
|
114
|
+
const a = (node.attrs.attributes ?? {}) as Record<string, string>;
|
|
115
|
+
return [
|
|
116
|
+
"div",
|
|
117
|
+
{
|
|
118
|
+
"data-brand-leaf": name,
|
|
119
|
+
"data-brand-attrs": JSON.stringify(a),
|
|
120
|
+
class: `brand-directive brand-directive--leaf brand-directive--${name}`,
|
|
121
|
+
contenteditable: "false",
|
|
122
|
+
},
|
|
123
|
+
["div", { class: "brand-metric__label" }, a.label ?? name],
|
|
124
|
+
["div", { class: "brand-metric__value" }, a.value ?? ""],
|
|
125
|
+
...(a.description ? [["div", { class: "brand-metric__desc" }, a.description]] : []),
|
|
126
|
+
] as never;
|
|
127
|
+
},
|
|
128
|
+
parseMarkdown: {
|
|
129
|
+
match: (node) => (node as DirectiveMdast).type === "leafDirective",
|
|
130
|
+
runner: (state, node, type) => {
|
|
131
|
+
const d = node as DirectiveMdast;
|
|
132
|
+
state.addNode(type, { name: d.name ?? "metric", attributes: d.attributes ?? {} });
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
toMarkdown: {
|
|
136
|
+
match: (node) => node.type.name === "brand_leaf_directive",
|
|
137
|
+
runner: (state, node) => {
|
|
138
|
+
state.addNode("leafDirective", undefined, undefined, {
|
|
139
|
+
name: node.attrs.name,
|
|
140
|
+
attributes: node.attrs.attributes,
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
}));
|
|
145
|
+
|
|
146
|
+
/** All plugins needed to parse, render and serialize brand directives in the editor. */
|
|
147
|
+
export const directivePlugins: MilkdownPlugin[] = [
|
|
148
|
+
directiveRemark,
|
|
149
|
+
containerDirectiveSchema,
|
|
150
|
+
leafDirectiveSchema,
|
|
151
|
+
].flat() as MilkdownPlugin[];
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for the iteration node-view's `⋯` / right-click menu
|
|
3
|
+
* (directive-views.tsx, #223). Milkdown mounts fine in jsdom for these
|
|
4
|
+
* interaction paths (the `markdown-editor.directives.test.tsx` precedent) —
|
|
5
|
+
* only the canvas/Monaco-backed surfaces need a browser.
|
|
6
|
+
*/
|
|
7
|
+
import { createRef, type ReactNode } from "react";
|
|
8
|
+
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
9
|
+
import userEvent from "@testing-library/user-event";
|
|
10
|
+
import { afterEach, describe, expect, test } from "vitest";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
IterationEditContext,
|
|
14
|
+
type IterationEditRequest,
|
|
15
|
+
} from "../markdown-iteration/edit-context";
|
|
16
|
+
import { MarkdownEditor, type MarkdownEditorHandle } from "./markdown-editor";
|
|
17
|
+
|
|
18
|
+
afterEach(cleanup);
|
|
19
|
+
|
|
20
|
+
/** A no-op handler is enough to make the ⋯ / right-click menu render at all. */
|
|
21
|
+
function withEditHandler(children: ReactNode) {
|
|
22
|
+
return <IterationEditContext.Provider value={() => {}}>{children}</IterationEditContext.Provider>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const PIVOT_WITH_CONSUMER_ATTRS = `:::pivot{source="sales" region="EU" rows="Q1, Q2" cols="North, South"}
|
|
26
|
+
{{row}} · {{col}}
|
|
27
|
+
:::
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
const ITERATE_CONSUMER_EVALUATED = `:::iterate{as="repo" source="repos" layout="grid"}
|
|
31
|
+
{{repo.name}}
|
|
32
|
+
:::
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
const PIVOT_CONSUMER_EVALUATED = `:::pivot{source="sales" layout="matrix"}
|
|
36
|
+
{{row}} · {{col}}
|
|
37
|
+
:::
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
async function openNodeMenu(): Promise<HTMLElement> {
|
|
41
|
+
const user = userEvent.setup();
|
|
42
|
+
const menuButton = await screen.findByRole("button", { name: "Iteration actions" });
|
|
43
|
+
await user.click(menuButton);
|
|
44
|
+
return screen.findByRole("menu");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("IterationDirectiveView — Edit iteration… merges instead of replacing attributes", () => {
|
|
48
|
+
test("onSaveData merges the guided builder's write-back WITHOUT dropping unrelated consumer attributes", async () => {
|
|
49
|
+
const user = userEvent.setup();
|
|
50
|
+
const ref = createRef<MarkdownEditorHandle>();
|
|
51
|
+
let captured: IterationEditRequest | null = null;
|
|
52
|
+
render(
|
|
53
|
+
<IterationEditContext.Provider
|
|
54
|
+
value={(request) => {
|
|
55
|
+
captured = request;
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
<MarkdownEditor ref={ref} defaultValue={PIVOT_WITH_CONSUMER_ATTRS} />
|
|
59
|
+
</IterationEditContext.Provider>,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const menu = await openNodeMenu();
|
|
63
|
+
await user.click(within(menu).getByText("Edit iteration…"));
|
|
64
|
+
await waitFor(() => expect(captured).not.toBeNull());
|
|
65
|
+
|
|
66
|
+
// Simulate the guided builder dialog saving — its model only knows
|
|
67
|
+
// `layout`/`rows`/`cols`, so its write-back must NOT drop `source`/`region`.
|
|
68
|
+
captured!.onSaveData!({
|
|
69
|
+
attributes: { layout: "matrix", rows: "Q1, Q2, Q3", cols: "North, South" },
|
|
70
|
+
template: "{{row}} · {{col}}",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await waitFor(() => {
|
|
74
|
+
const md = ref.current!.getMarkdown();
|
|
75
|
+
expect(md).toContain('rows="Q1, Q2, Q3"');
|
|
76
|
+
expect(md).toContain('cols="North, South"');
|
|
77
|
+
// The consumer's own attributes (its `evaluate`'s data-source reference)
|
|
78
|
+
// must survive the save — they are NOT part of the builder's model.
|
|
79
|
+
expect(md).toContain('source="sales"');
|
|
80
|
+
expect(md).toContain('region="EU"');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("IterationDirectiveView — Transpose merges instead of replacing attributes", () => {
|
|
86
|
+
test("Transpose swaps rows/cols WITHOUT dropping unrelated consumer attributes", async () => {
|
|
87
|
+
const user = userEvent.setup();
|
|
88
|
+
const ref = createRef<MarkdownEditorHandle>();
|
|
89
|
+
render(withEditHandler(<MarkdownEditor ref={ref} defaultValue={PIVOT_WITH_CONSUMER_ATTRS} />));
|
|
90
|
+
|
|
91
|
+
const menu = await openNodeMenu();
|
|
92
|
+
await user.click(within(menu).getByText("Transpose"));
|
|
93
|
+
|
|
94
|
+
await waitFor(() => {
|
|
95
|
+
const md = ref.current!.getMarkdown();
|
|
96
|
+
expect(md).toContain('rows="North, South"');
|
|
97
|
+
expect(md).toContain('cols="Q1, Q2"');
|
|
98
|
+
// The consumer's own attributes (its `evaluate`'s data-source reference)
|
|
99
|
+
// must survive the transpose — they are NOT part of the transpose model.
|
|
100
|
+
expect(md).toContain('source="sales"');
|
|
101
|
+
expect(md).toContain('region="EU"');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("IterationDirectiveView — Transpose is disabled with no embedded pivot axes", () => {
|
|
107
|
+
test("a pivot with embedded rows/cols is NOT disabled", async () => {
|
|
108
|
+
render(withEditHandler(<MarkdownEditor defaultValue={PIVOT_WITH_CONSUMER_ATTRS} />));
|
|
109
|
+
const menu = await openNodeMenu();
|
|
110
|
+
const item = within(menu).getByText("Transpose").closest('[role="menuitem"]')!;
|
|
111
|
+
expect(item).not.toHaveAttribute("aria-disabled", "true");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("a pivot resolved by the consumer's evaluate (no rows/cols) is disabled, its reason is VISIBLE, and it's inert", async () => {
|
|
115
|
+
const user = userEvent.setup();
|
|
116
|
+
const ref = createRef<MarkdownEditorHandle>();
|
|
117
|
+
render(withEditHandler(<MarkdownEditor ref={ref} defaultValue={PIVOT_CONSUMER_EVALUATED} />));
|
|
118
|
+
|
|
119
|
+
const menu = await openNodeMenu();
|
|
120
|
+
// The disabled reason is folded into the item's own visible label (NOT a
|
|
121
|
+
// `title` attribute — a disabled Radix item is `pointer-events-none`, so a
|
|
122
|
+
// hover tooltip could never fire, and it's skipped by keyboard focus).
|
|
123
|
+
const label = within(menu).getByText("Transpose — needs embedded values");
|
|
124
|
+
const item = label.closest('[role="menuitem"]')!;
|
|
125
|
+
expect(item).toHaveAttribute("aria-disabled", "true");
|
|
126
|
+
|
|
127
|
+
const before = ref.current!.getMarkdown();
|
|
128
|
+
await user.click(label);
|
|
129
|
+
// A disabled Radix item never fires `onSelect` — the block is byte-identical
|
|
130
|
+
// afterwards (previously Transpose silently wrote bare `rows`/`cols` FLAGS —
|
|
131
|
+
// `mdast-util-directive`'s serialization of an empty-string attribute value —
|
|
132
|
+
// as a visual no-op that still dirtied the document).
|
|
133
|
+
expect(ref.current!.getMarkdown()).toBe(before);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("IterationDirectiveView — Convert to static is disabled with no embedded data", () => {
|
|
138
|
+
test("a block with embedded values is NOT disabled", async () => {
|
|
139
|
+
render(withEditHandler(<MarkdownEditor defaultValue={PIVOT_WITH_CONSUMER_ATTRS} />));
|
|
140
|
+
const menu = await openNodeMenu();
|
|
141
|
+
const item = within(menu).getByText("Convert to static").closest('[role="menuitem"]')!;
|
|
142
|
+
expect(item).not.toHaveAttribute("aria-disabled", "true");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("a block resolved by the consumer's evaluate (no embedded values) is disabled, its reason is VISIBLE, and it's inert", async () => {
|
|
146
|
+
const user = userEvent.setup();
|
|
147
|
+
const ref = createRef<MarkdownEditorHandle>();
|
|
148
|
+
render(withEditHandler(<MarkdownEditor ref={ref} defaultValue={ITERATE_CONSUMER_EVALUATED} />));
|
|
149
|
+
|
|
150
|
+
const menu = await openNodeMenu();
|
|
151
|
+
// Same rationale as Transpose above: the reason lives in the visible label,
|
|
152
|
+
// not an unreachable `title`.
|
|
153
|
+
const label = within(menu).getByText("Convert to static — needs embedded values");
|
|
154
|
+
const item = label.closest('[role="menuitem"]')!;
|
|
155
|
+
expect(item).toHaveAttribute("aria-disabled", "true");
|
|
156
|
+
|
|
157
|
+
const before = ref.current!.getMarkdown();
|
|
158
|
+
await user.click(label);
|
|
159
|
+
// A disabled Radix item never fires `onSelect` — the document is untouched
|
|
160
|
+
// (previously this silently replaced the block with an empty string / no-op
|
|
161
|
+
// with zero user feedback).
|
|
162
|
+
expect(ref.current!.getMarkdown()).toBe(before);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("IterationDirectiveView — context menu is scoped to the header chrome", () => {
|
|
167
|
+
test("right-click on the header row opens the node menu", async () => {
|
|
168
|
+
render(withEditHandler(<MarkdownEditor defaultValue={PIVOT_WITH_CONSUMER_ATTRS} />));
|
|
169
|
+
const label = await screen.findByText("Pivot");
|
|
170
|
+
fireEvent.contextMenu(label);
|
|
171
|
+
expect(await screen.findByRole("menu")).toBeInTheDocument();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("right-click INSIDE the editable template body does not open it (native menu stays reachable)", async () => {
|
|
175
|
+
render(withEditHandler(<MarkdownEditor defaultValue={PIVOT_WITH_CONSUMER_ATTRS} />));
|
|
176
|
+
// Wait for the frame to mount before querying its body.
|
|
177
|
+
await screen.findByText("Pivot");
|
|
178
|
+
const body = document.querySelector(".brand-directive__body");
|
|
179
|
+
expect(body).toBeTruthy();
|
|
180
|
+
|
|
181
|
+
fireEvent.contextMenu(body!);
|
|
182
|
+
// Let any (non-)handling settle, then confirm no Radix menu opened — a
|
|
183
|
+
// right-click that hits the ProseMirror content must fall through to the
|
|
184
|
+
// browser's native context menu (spellcheck, Paste, Look Up, Emoji), which
|
|
185
|
+
// jsdom doesn't render but which our own menu must not have hijacked.
|
|
186
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
187
|
+
expect(screen.queryByRole("menu")).toBeNull();
|
|
188
|
+
});
|
|
189
|
+
});
|