@lalalic/markcut 2.9.0 → 3.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/B] +2 -0
- package/package.json +1 -1
- package/skills/markcut/docs/components.md +45 -2
- package/skills/markcut/docs/markdown-descriptive.md +5 -2
- package/src/components/Markdown.tsx +138 -24
- package/src/components/Mermaid.tsx +223 -22
- package/src/context/EventContext.tsx +3 -0
- package/src/descriptive/compiler.ts +64 -29
- package/src/descriptive/markdown.ts +18 -0
- package/src/player/bundle/player.js +303 -44
- package/src/player/pipeline.mjs +66 -19
- package/src/schema/index.ts +2 -1
- package/src/types/Component.tsx +27 -1
- package/src/types/Effect.tsx +13 -6
- package/src/types/Folder.tsx +1 -1
- package/src/utils/index.ts +14 -2
- package/tests/fixtures/md/animate-diagrams.md +40 -0
- package/tests/fixtures/md/electricity-grow.md +130 -0
- package/tests/tmp/vision-1785081637127-video/videos/.normalized/segments/test-clip_0to3_seg_1100to3000.mp4 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/.normalized/test-clip_0to3.mp4 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/.normalized/test-clip_audio.mp3 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/metadata.json +9 -0
- package/tests/tmp/vision-1785081637127-video/videos/test-clip.mp4 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/test-clip.vtt +5 -0
package/B]
ADDED
package/package.json
CHANGED
|
@@ -9,6 +9,13 @@ These components are available directly in `jsx:".."` fields or importable from
|
|
|
9
9
|
- Uses `react-markdown` + `remark-gfm` (tables, strikethrough, task lists)
|
|
10
10
|
- Supports `plugins` and `components` props: define custom renderers in imports block and pass them in
|
|
11
11
|
- ` ```mermaid ` code fences inside markdown are automatically rendered as Mermaid diagrams
|
|
12
|
+
- **`highlight`** — array of 0-based list item indices to highlight (e.g., `highlight={[0,2]}`).
|
|
13
|
+
Matching `<li>` elements receive CSS class `highlight-list-item` with a default golden
|
|
14
|
+
left-border and background tint. Works for both ordered and unordered lists.
|
|
15
|
+
```md
|
|
16
|
+
<Markdown highlight={[0,2]}
|
|
17
|
+
source={"1. First item\n2. Second item\n3. Third item\n4. Fourth item"} />
|
|
18
|
+
```
|
|
12
19
|
- Importable: `import {Markdown} from "@lalalic/markcut/components"`
|
|
13
20
|
|
|
14
21
|
### `<Mermaid />` — render Mermaid diagrams as SVG
|
|
@@ -16,7 +23,42 @@ These components are available directly in `jsx:".."` fields or importable from
|
|
|
16
23
|
- component jsx:"<Mermaid source='graph TD; A-->B; A-->C; B-->D;' />"
|
|
17
24
|
```
|
|
18
25
|
- Theme prop: `theme="default" | "dark" | "forest" | "neutral"` (default: `dark`)
|
|
19
|
-
-
|
|
26
|
+
- **`highlight`** — node name(s) to highlight. String or array of strings.
|
|
27
|
+
Toggles CSS class `highlight` on matching SVG elements. The diagram source
|
|
28
|
+
must define the class via `classDef`:
|
|
29
|
+
```md
|
|
30
|
+
classDef highlight fill:#ffd700,stroke:#ff6600,stroke-width:3px,color:#000
|
|
31
|
+
```
|
|
32
|
+
- **`animateEdges`** — edge(s) to animate with a flowing dash effect.
|
|
33
|
+
`true` = animate all edges. `string[]` = specific edges by source→target alias:
|
|
34
|
+
```md
|
|
35
|
+
animateEdges={["A->B","D->F","F->G"]}
|
|
36
|
+
```
|
|
37
|
+
Animates matching edge `<path>` elements via CSS `stroke-dasharray` + `stroke-dashoffset` keyframes.
|
|
38
|
+
- Source from code fences: use `~~~mermaid` to define the diagram source in a
|
|
39
|
+
separate block, then reference it as `source={mermaid}` in the JSX:
|
|
40
|
+
```md
|
|
41
|
+
- component id:flowChart duration:12
|
|
42
|
+
~~~jsx
|
|
43
|
+
<Mermaid highlight={highlight} animateEdges={animateEdges}
|
|
44
|
+
theme='dark' source={mermaid}/>
|
|
45
|
+
~~~
|
|
46
|
+
~~~mermaid
|
|
47
|
+
graph TD
|
|
48
|
+
A["Receive Request"] --> B["Validate Input"]
|
|
49
|
+
B --> C{"Valid?"}
|
|
50
|
+
classDef highlight fill:#ffd700,stroke:#ff6600,stroke-width:3px
|
|
51
|
+
~~~
|
|
52
|
+
highlight:"A"
|
|
53
|
+
animateEdges:true
|
|
54
|
+
```
|
|
55
|
+
- Dynamic highlight/edges via events: define `on` specs that mutate the
|
|
56
|
+
component's registered id:
|
|
57
|
+
```md
|
|
58
|
+
- event duration:3 start:3 on:(start, flowChart.highlight="B")
|
|
59
|
+
- event duration:3 start:6 on:(start, flowChart.highlight="C")
|
|
60
|
+
- event duration:3 start:9 on:(start, flowChart.highlight=["D","G"])
|
|
61
|
+
```
|
|
20
62
|
- Errors shown inline in the output
|
|
21
63
|
- Importable: `import {Mermaid} from "@lalalic/markcut/components"`
|
|
22
64
|
|
|
@@ -43,4 +85,5 @@ export function SuperMarkdown({ source }) {
|
|
|
43
85
|
- `@remotion/shapes` — render shapes like arrows, circles, rectangles, etc
|
|
44
86
|
- `@remotion/starburst` — render starburst animations
|
|
45
87
|
- `react-webcam-pro` — render webcam video
|
|
46
|
-
- `react-chartjs-2` — render charts with Chart.js at https://react-chartjs-2.js.org/components
|
|
88
|
+
- `react-chartjs-2` — render charts with Chart.js at `https://react-chartjs-2.js.org/components`
|
|
89
|
+
- `@xyflow/react` - render diagrams with `https://reactflow.dev/api-reference`
|
|
@@ -69,6 +69,7 @@ Where:
|
|
|
69
69
|
| `audio` | `audio` | Voiceover, BGM, SFX | `src` + (`duration` or `endAt`) |
|
|
70
70
|
| `script "..."` | `audio` | Narration/TTS (shorthand for audio with script) | `script` text (the `"..."` is the primary content) |
|
|
71
71
|
| `component` | `component` | JSX React component | `jsx` (inline or code fence) + `duration` (unless `isBackground`) |
|
|
72
|
+
| `event` | `component` (stub) | **Event-only** — renders nothing, fires JS on registered components at a specific frame | `on:(when, state)` + `start` + `duration` |
|
|
72
73
|
| `rhythm` | `rhythm` | Beat-synced audio with timed children | `src`, `spots`, `children` |
|
|
73
74
|
| `map` | `map` | Animated route visualization | `duration`, `waypoints` |
|
|
74
75
|
| `include` | `include` | Embed external `.md` sub-video | `src` (path to another `.md` file) |
|
|
@@ -112,10 +113,13 @@ Fire JS expression at a specific frame to mutate component state.
|
|
|
112
113
|
```
|
|
113
114
|
- script "Narration" on:(start, slide.current=0)
|
|
114
115
|
- script "Beat" on:(50%, slide.current++)
|
|
116
|
+
- event duration:3 start:6 on:(start, slide.current=1)
|
|
115
117
|
```
|
|
116
118
|
|
|
117
119
|
`when`: `start`/`end`/`50%` (percent) / `2.5s` (seconds value). `state`: any JS expression.
|
|
118
120
|
|
|
121
|
+
Use the `event` type token to create an **event-only stub** — a node that renders nothing and exists solely to fire an event at a specific time. This avoids boilerplate like `- component duration:3 jsx:"<></>" start:6 on:(...)`.
|
|
122
|
+
|
|
119
123
|
#### Lists — `spots:[n,n,n]`, `start:n`
|
|
120
124
|
|
|
121
125
|
```
|
|
@@ -160,8 +164,7 @@ When a property value is too long for a single line (JSX, prompts, scripts, mark
|
|
|
160
164
|
| `~~~jsx jsx` or `~~~jsx` | `jsx` | Component JSX expression |
|
|
161
165
|
| `~~~prompt prompt` | `prompt` | TTI/TTV generation prompt |
|
|
162
166
|
| `~~~script script` or `~~~script` | `script` | Narration text on audio nodes |
|
|
163
|
-
| `~~~css stylesheet` | `stylesheet` | Global CSS (only valid at root level) |
|
|
164
|
-
| `~~~md <key>` | arbitrary | Markdown content for a specific key (e.g., `source` for `react-markdown`) |
|
|
167
|
+
| `~~~css stylesheet` | `stylesheet` | Global CSS (only valid at root level) || `~~~mermaid` | `mermaid` | Mermaid diagram source (referenced as `{mermaid}` in JSX) || `~~~md <key>` | arbitrary | Markdown content for a specific key (e.g., `source` for `react-markdown`) |
|
|
165
168
|
|
|
166
169
|
### 7. Scene Metadata Block
|
|
167
170
|
|
|
@@ -11,39 +11,153 @@ import { Mermaid } from "./Mermaid";
|
|
|
11
11
|
*
|
|
12
12
|
* The `source` prop is the markdown string to render.
|
|
13
13
|
*
|
|
14
|
+
* `highlight` is an array of 0-based list item indices to highlight.
|
|
15
|
+
* Works for both ordered (`ol`) and unordered (`ul`) lists. Matching
|
|
16
|
+
* `<li>` elements get CSS class `highlight-list-item` (intended for use
|
|
17
|
+
* with a `classDef` or a custom stylesheet).
|
|
18
|
+
*
|
|
14
19
|
* Built-in — no imports or frontmatter registration needed.
|
|
15
20
|
*/
|
|
16
21
|
export interface MarkdownProps {
|
|
17
|
-
|
|
22
|
+
children?: string;
|
|
18
23
|
source?: string;
|
|
19
24
|
className?: string;
|
|
20
25
|
plugins?: any[];
|
|
21
26
|
components?: any;
|
|
27
|
+
/** 0-based indices of list items to highlight. */
|
|
28
|
+
highlight?: number[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Context to pass highlightList down to list renderers
|
|
32
|
+
const ListCtx = React.createContext<number[]>([]);
|
|
33
|
+
|
|
34
|
+
function OrderedList({
|
|
35
|
+
children,
|
|
36
|
+
...props
|
|
37
|
+
}: React.ComponentPropsWithoutRef<"ol">) {
|
|
38
|
+
const hl = React.useContext(ListCtx);
|
|
39
|
+
const items = React.Children.toArray(children).filter(
|
|
40
|
+
(c): c is React.ReactElement => React.isValidElement(c) && c.type === "li",
|
|
41
|
+
);
|
|
42
|
+
return (
|
|
43
|
+
<ol {...props}>
|
|
44
|
+
{items.map((child, i) => {
|
|
45
|
+
if (hl.includes(i)) {
|
|
46
|
+
return React.cloneElement(child, { className: "highlight-list-item", key: (child as any).key } as any);
|
|
47
|
+
}
|
|
48
|
+
return child;
|
|
49
|
+
})}
|
|
50
|
+
</ol>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function UnorderedList({
|
|
55
|
+
children,
|
|
56
|
+
...props
|
|
57
|
+
}: React.ComponentPropsWithoutRef<"ul">) {
|
|
58
|
+
const hl = React.useContext(ListCtx);
|
|
59
|
+
const items = React.Children.toArray(children).filter(
|
|
60
|
+
(c): c is React.ReactElement => React.isValidElement(c) && c.type === "li",
|
|
61
|
+
);
|
|
62
|
+
return (
|
|
63
|
+
<ul {...props}>
|
|
64
|
+
{items.map((child, i) => {
|
|
65
|
+
if (hl.includes(i)) {
|
|
66
|
+
return React.cloneElement(child, { className: "highlight-list-item", key: (child as any).key } as any);
|
|
67
|
+
}
|
|
68
|
+
return child;
|
|
69
|
+
})}
|
|
70
|
+
</ul>
|
|
71
|
+
);
|
|
22
72
|
}
|
|
23
73
|
|
|
24
|
-
export function Markdown({
|
|
74
|
+
export function Markdown({
|
|
75
|
+
children,
|
|
76
|
+
source = children,
|
|
77
|
+
className,
|
|
78
|
+
plugins,
|
|
79
|
+
components: propComponents,
|
|
80
|
+
highlight,
|
|
81
|
+
}: MarkdownProps) {
|
|
82
|
+
const hl = React.useMemo(() => highlight ?? [], [highlight]);
|
|
83
|
+
|
|
84
|
+
// Inject default styles once
|
|
85
|
+
React.useEffect(() => {
|
|
86
|
+
const id = "markcut-markdown-defaults";
|
|
87
|
+
if (document.getElementById(id)) return;
|
|
88
|
+
const el = document.createElement("style");
|
|
89
|
+
el.id = id;
|
|
90
|
+
el.textContent = `
|
|
91
|
+
.highlight-list-item {
|
|
92
|
+
background: rgba(255, 215, 0, 0.15);
|
|
93
|
+
border-left: 3px solid #ffd700;
|
|
94
|
+
padding-left: 8px;
|
|
95
|
+
border-radius: 0 4px 4px 0;
|
|
96
|
+
}
|
|
97
|
+
.slide {
|
|
98
|
+
display: flex;
|
|
99
|
+
flex-direction: column;
|
|
100
|
+
align-items: center;
|
|
101
|
+
justify-content: center;
|
|
102
|
+
padding: 24px;
|
|
103
|
+
text-align: center;
|
|
104
|
+
}
|
|
105
|
+
.slide h1 { font-size: 2em; margin: 0.4em 0; font-weight: 700; }
|
|
106
|
+
.slide h2 { font-size: 1.6em; margin: 0.35em 0; font-weight: 600; }
|
|
107
|
+
.slide h3 { font-size: 1.3em; margin: 0.3em 0; font-weight: 600; }
|
|
108
|
+
.slide p { margin: 0.6em 0; line-height: 1.6; }
|
|
109
|
+
.slide ul, .slide ol { margin: 0.5em 0; padding-left: 1.5em; text-align: left; }
|
|
110
|
+
.slide li { margin: 0.3em 0; }
|
|
111
|
+
.slide blockquote {
|
|
112
|
+
margin: 0.6em 0;
|
|
113
|
+
padding: 0.4em 1em;
|
|
114
|
+
border-left: 3px solid rgba(255,255,255,.3);
|
|
115
|
+
font-style: italic;
|
|
116
|
+
opacity: .85;
|
|
117
|
+
}
|
|
118
|
+
.slide code {
|
|
119
|
+
background: rgba(255,255,255,.08);
|
|
120
|
+
padding: 0.15em 0.4em;
|
|
121
|
+
border-radius: 4px;
|
|
122
|
+
font-size: 0.9em;
|
|
123
|
+
}
|
|
124
|
+
.slide pre { margin: 0.6em 0; text-align: left; width: 100%; }
|
|
125
|
+
.slide a { color: #4a9eff; text-decoration: none; }
|
|
126
|
+
.slide a:hover { text-decoration: underline; }
|
|
127
|
+
`;
|
|
128
|
+
document.head.appendChild(el);
|
|
129
|
+
return () => { document.getElementById(id)?.remove(); };
|
|
130
|
+
}, []);
|
|
131
|
+
|
|
132
|
+
const mergedComponents = React.useMemo(
|
|
133
|
+
() => ({
|
|
134
|
+
...propComponents,
|
|
135
|
+
ol: OrderedList,
|
|
136
|
+
ul: UnorderedList,
|
|
137
|
+
pre: ({ children: preChildren }: { children: React.ReactNode }) => {
|
|
138
|
+
const code = React.Children.toArray(preChildren)[0] as React.ReactElement<any>;
|
|
139
|
+
if (code?.props?.className === "language-mermaid") {
|
|
140
|
+
return <Mermaid source={String(code.props.children)} />;
|
|
141
|
+
}
|
|
142
|
+
if (propComponents?.pre) {
|
|
143
|
+
return (propComponents.pre as any)({ children: preChildren });
|
|
144
|
+
}
|
|
145
|
+
return <pre>{preChildren}</pre>;
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
148
|
+
[propComponents],
|
|
149
|
+
);
|
|
150
|
+
|
|
25
151
|
return (
|
|
26
|
-
<
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
} else if (components.pre) {
|
|
37
|
-
return components.pre({ children });
|
|
38
|
-
}
|
|
39
|
-
return <pre>{children}</pre>;
|
|
40
|
-
},
|
|
41
|
-
}),
|
|
42
|
-
[components]
|
|
43
|
-
)}
|
|
44
|
-
>
|
|
45
|
-
{source}
|
|
46
|
-
</ReactMarkdown>
|
|
47
|
-
</div>
|
|
152
|
+
<ListCtx.Provider value={hl}>
|
|
153
|
+
<div className={className}>
|
|
154
|
+
<ReactMarkdown
|
|
155
|
+
remarkPlugins={React.useMemo(() => [remarkGfm, ...(plugins || [])], [plugins])}
|
|
156
|
+
components={mergedComponents}
|
|
157
|
+
>
|
|
158
|
+
{source}
|
|
159
|
+
</ReactMarkdown>
|
|
160
|
+
</div>
|
|
161
|
+
</ListCtx.Provider>
|
|
48
162
|
);
|
|
49
163
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
import { delayRender, continueRender } from "remotion";
|
|
3
2
|
import mermaid from "mermaid";
|
|
4
3
|
|
|
5
4
|
/**
|
|
@@ -10,13 +9,23 @@ import mermaid from "mermaid";
|
|
|
10
9
|
* <Mermaid source={diagram} theme="dark" />
|
|
11
10
|
*
|
|
12
11
|
* Props:
|
|
13
|
-
* source
|
|
14
|
-
* theme
|
|
15
|
-
*
|
|
12
|
+
* source — Mermaid diagram definition string
|
|
13
|
+
* theme — Mermaid theme: "default" | "dark" | "forest" | "neutral" (default: "dark")
|
|
14
|
+
* className — Optional container className
|
|
15
|
+
* style — Container inline style
|
|
16
|
+
* highlight — Node name(s) to highlight. String or array of strings.
|
|
17
|
+
* Toggles CSS class `highlight` on matching SVG elements.
|
|
18
|
+
* The diagram source should define the class, e.g.:
|
|
19
|
+
* classDef highlight fill:#ffd700,stroke:#ff6600,stroke-width:3px
|
|
20
|
+
* animateEdges — Edge(s) to animate with a flowing dash effect.
|
|
21
|
+
* true = animate all edges.
|
|
22
|
+
* string[] = animate specific edges by source->target alias,
|
|
23
|
+
* e.g. ["A->B", "D->C"]. Edge paths are found via Mermaid's
|
|
24
|
+
* SVG id pattern: {prefix}-L_{source}_{target}_{index}.
|
|
25
|
+
* Adds CSS class `edge-animated` with a stroke-dashoffset
|
|
26
|
+
* keyframe animation.
|
|
16
27
|
*
|
|
17
28
|
* The diagram is rendered asynchronously via the mermaid library.
|
|
18
|
-
* Uses delayRender/continueRender to ensure the SVG is ready before
|
|
19
|
-
* the frame is captured by Remotion.
|
|
20
29
|
*
|
|
21
30
|
* Built-in — no imports or frontmatter registration needed.
|
|
22
31
|
*/
|
|
@@ -24,20 +33,147 @@ export interface MermaidProps {
|
|
|
24
33
|
source?: string;
|
|
25
34
|
children?: string;
|
|
26
35
|
theme?: "default" | "dark" | "forest" | "neutral";
|
|
27
|
-
|
|
36
|
+
className?: string;
|
|
37
|
+
style?: React.CSSProperties;
|
|
38
|
+
highlight?: string | string[];
|
|
39
|
+
animateEdges?: boolean | string[];
|
|
28
40
|
}
|
|
29
41
|
|
|
30
42
|
let initialized = false;
|
|
31
43
|
|
|
32
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Recursively extract plain text from JsxParser children.
|
|
46
|
+
* JsxParser may wrap template literals in nested React elements.
|
|
47
|
+
*/
|
|
48
|
+
function extractText(x: unknown): string {
|
|
49
|
+
if (typeof x === "string") return x;
|
|
50
|
+
if (Array.isArray(x)) return x.map(extractText).join("");
|
|
51
|
+
if (x && typeof x === "object" && "props" in (x as any)) {
|
|
52
|
+
return extractText((x as any).props?.children);
|
|
53
|
+
}
|
|
54
|
+
return "";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Find the SVG element representing a named node.
|
|
59
|
+
* Strategy: search <text> + <title> elements by textContent,
|
|
60
|
+
* walk up to the containing <g> cluster.
|
|
61
|
+
*/
|
|
62
|
+
function findNodeGroup(svg: SVGSVGElement, name: string): Element | null {
|
|
63
|
+
// Strategy 1: SVG id containing the alias (e.g. `flowchart-A-1234` for "A")
|
|
64
|
+
// Mermaid embeds node aliases in auto-generated IDs.
|
|
65
|
+
const byId = svg.querySelector(`[id*="-${CSS.escape(name)}-"]`);
|
|
66
|
+
if (byId) {
|
|
67
|
+
let el: Element | null = byId;
|
|
68
|
+
while (el && el.tagName !== "g") el = el.parentElement;
|
|
69
|
+
return el || byId;
|
|
70
|
+
}
|
|
71
|
+
// Strategy 2: <text> elements whose text starts with the name
|
|
72
|
+
for (const t of svg.querySelectorAll<SVGTextElement>("text")) {
|
|
73
|
+
const text = t.textContent?.trim() ?? "";
|
|
74
|
+
if (text.startsWith(name)) {
|
|
75
|
+
let el: Element | null = t;
|
|
76
|
+
while (el && el.tagName !== "g") el = el.parentElement;
|
|
77
|
+
return el || t;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Strategy 3: <title> elements (class diagram titles, state labels)
|
|
81
|
+
for (const t of svg.querySelectorAll("title")) {
|
|
82
|
+
if ((t.textContent?.trim() ?? "").startsWith(name) && t.parentElement) {
|
|
83
|
+
return t.parentElement;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Apply edge animation to matching paths in the rendered Mermaid SVG.
|
|
91
|
+
* Mermaid edge paths have IDs like `{prefix}-L_{source}_{target}_{index}`.
|
|
92
|
+
*
|
|
93
|
+
* @param svg - The rendered SVG element
|
|
94
|
+
* @param spec - true = animate all edges, string[] = specific edges by "A->B" pattern
|
|
95
|
+
*/
|
|
96
|
+
function applyEdgeAnimation(svg: SVGSVGElement, spec: boolean | string[] | undefined): void {
|
|
97
|
+
if (!spec) return;
|
|
98
|
+
|
|
99
|
+
const allEdges = Array.from(svg.querySelectorAll<SVGPathElement>(
|
|
100
|
+
'path[id*="-L_"]',
|
|
101
|
+
));
|
|
102
|
+
|
|
103
|
+
if (spec === true) {
|
|
104
|
+
// Animate all edges
|
|
105
|
+
for (const path of allEdges) {
|
|
106
|
+
path.classList.add("edge-animated");
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// spec is string[] — parse patterns like "A->B"
|
|
112
|
+
for (const pattern of spec) {
|
|
113
|
+
const match = pattern.match(/^(\w+)\s*->\s*(\w+)$/);
|
|
114
|
+
if (!match) continue;
|
|
115
|
+
const source = match[1]!;
|
|
116
|
+
const target = match[2]!;
|
|
117
|
+
|
|
118
|
+
// Find matching path by ID pattern: *L_{source}_{target}_*
|
|
119
|
+
const suffix = `L_${source}_${target}_`;
|
|
120
|
+
for (const path of allEdges) {
|
|
121
|
+
if (path.id.includes(suffix)) {
|
|
122
|
+
path.classList.add("edge-animated");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function Mermaid({
|
|
129
|
+
children,
|
|
130
|
+
source: sourceProp,
|
|
131
|
+
theme = "dark",
|
|
132
|
+
className,
|
|
133
|
+
style,
|
|
134
|
+
highlight,
|
|
135
|
+
animateEdges,
|
|
136
|
+
}: MermaidProps) {
|
|
33
137
|
const ref = React.useRef<HTMLDivElement>(null);
|
|
34
|
-
const
|
|
138
|
+
const renderedRef = React.useRef(false);
|
|
139
|
+
const styleRef = React.useRef<HTMLStyleElement | null>(null);
|
|
35
140
|
|
|
141
|
+
// Inject edge animation CSS once
|
|
36
142
|
React.useEffect(() => {
|
|
143
|
+
if (!styleRef.current) {
|
|
144
|
+
const el = document.createElement("style");
|
|
145
|
+
el.textContent = `
|
|
146
|
+
@keyframes mermaid-edge-flow {
|
|
147
|
+
to { stroke-dashoffset: -24; }
|
|
148
|
+
}
|
|
149
|
+
.edge-animated {
|
|
150
|
+
stroke-dasharray: 8 6 !important;
|
|
151
|
+
animation: mermaid-edge-flow 0.5s linear infinite !important;
|
|
152
|
+
}
|
|
153
|
+
`;
|
|
154
|
+
document.head.appendChild(el);
|
|
155
|
+
styleRef.current = el;
|
|
156
|
+
}
|
|
157
|
+
return () => {
|
|
158
|
+
if (styleRef.current) {
|
|
159
|
+
styleRef.current.remove();
|
|
160
|
+
styleRef.current = null;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
}, []);
|
|
164
|
+
|
|
165
|
+
// Resolve source string: children from JsxParser may be nested React
|
|
166
|
+
// elements wrapping template literals.
|
|
167
|
+
const source = React.useMemo(
|
|
168
|
+
() => extractText(sourceProp ?? children),
|
|
169
|
+
[sourceProp, children],
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// Render effect — runs once when source/theme changes
|
|
173
|
+
React.useEffect(() => {
|
|
174
|
+
let cancelled = false;
|
|
37
175
|
if (!source || !ref.current) return;
|
|
38
176
|
|
|
39
|
-
// Initialize once — mermaid.initialize is idempotent but we guard to avoid
|
|
40
|
-
// redundant config writes on re-renders.
|
|
41
177
|
if (!initialized) {
|
|
42
178
|
mermaid.initialize({
|
|
43
179
|
startOnLoad: false,
|
|
@@ -52,25 +188,90 @@ export function Mermaid({ children, source=children, theme = "dark", className}:
|
|
|
52
188
|
mermaid
|
|
53
189
|
.render(id, source)
|
|
54
190
|
.then((result) => {
|
|
55
|
-
if (ref.current)
|
|
56
|
-
|
|
191
|
+
if (cancelled || !ref.current) return;
|
|
192
|
+
ref.current.innerHTML = result.svg;
|
|
193
|
+
const svg = ref.current.querySelector<SVGSVGElement>("svg");
|
|
194
|
+
if (svg) {
|
|
195
|
+
svg.removeAttribute("width");
|
|
196
|
+
svg.removeAttribute("height");
|
|
197
|
+
svg.style.width = "100%";
|
|
198
|
+
svg.style.height = "100%";
|
|
199
|
+
|
|
200
|
+
// Apply initial highlight now that SVG exists.
|
|
201
|
+
// The highlight effect below only fires on prop changes, so the
|
|
202
|
+
// initial highlight would be missed if SVG wasn't ready yet.
|
|
203
|
+
if (highlight) {
|
|
204
|
+
const names = Array.isArray(highlight) ? highlight : [highlight];
|
|
205
|
+
for (const name of names) {
|
|
206
|
+
const node = findNodeGroup(svg, name);
|
|
207
|
+
if (node) node.classList.add("highlight");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Apply edge animation now that SVG exists.
|
|
212
|
+
applyEdgeAnimation(svg, animateEdges);
|
|
213
|
+
}
|
|
214
|
+
renderedRef.current = true;
|
|
57
215
|
})
|
|
58
216
|
.catch((err) => {
|
|
217
|
+
if (cancelled) return;
|
|
59
218
|
console.error("Mermaid error:", err);
|
|
60
|
-
// Show error inline so the user can diagnose diagram syntax
|
|
61
219
|
if (ref.current) {
|
|
62
220
|
ref.current.innerHTML = `<div style="color:#f87171;padding:1em;border:2px dashed #f87171;border-radius:8px;font-family:monospace;font-size:14px;">
|
|
63
221
|
<strong>⚠ Mermaid Error</strong><br/>${String(err).replace(/</g, "<").replace(/>/g, ">")}
|
|
64
222
|
</div>`;
|
|
65
223
|
}
|
|
66
|
-
continueRender(handle);
|
|
67
224
|
});
|
|
68
|
-
}, [source, theme, handle]);
|
|
69
225
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
226
|
+
return () => { cancelled = true; };
|
|
227
|
+
}, [source, theme]);
|
|
228
|
+
|
|
229
|
+
// Highlight effect — toggles CSS class `highlight` on matching nodes.
|
|
230
|
+
// The diagram source must define this class with desired styles, e.g.:
|
|
231
|
+
// classDef highlight fill:#ffd700,stroke:#ff6600,stroke-width:3px
|
|
232
|
+
React.useEffect(() => {
|
|
233
|
+
const svg = ref.current?.querySelector<SVGSVGElement>("svg");
|
|
234
|
+
if (!svg) return;
|
|
235
|
+
|
|
236
|
+
// Remove class from all nodes
|
|
237
|
+
svg.querySelectorAll(".highlight").forEach((el) => {
|
|
238
|
+
el.classList.remove("highlight");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Apply class to current highlight target(s)
|
|
242
|
+
const names = Array.isArray(highlight) ? highlight : (highlight ? [highlight] : []);
|
|
243
|
+
for (const name of names) {
|
|
244
|
+
const node = findNodeGroup(svg, name);
|
|
245
|
+
if (node) {
|
|
246
|
+
node.classList.add("highlight");
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}, [highlight]);
|
|
250
|
+
|
|
251
|
+
// AnimateEdges effect — re-applies edge animation on prop changes.
|
|
252
|
+
// Runs in addition to the initial application in the render callback.
|
|
253
|
+
React.useEffect(() => {
|
|
254
|
+
const svg = ref.current?.querySelector<SVGSVGElement>("svg");
|
|
255
|
+
if (!svg) return;
|
|
256
|
+
|
|
257
|
+
// Remove animated class from all edges first
|
|
258
|
+
svg.querySelectorAll(".edge-animated").forEach((el) => {
|
|
259
|
+
el.classList.remove("edge-animated");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// Re-apply to current spec
|
|
263
|
+
applyEdgeAnimation(svg, animateEdges);
|
|
264
|
+
}, [animateEdges]);
|
|
265
|
+
|
|
266
|
+
// Default: center the SVG in the container. User style overrides individual properties.
|
|
267
|
+
const containerStyle: React.CSSProperties = {
|
|
268
|
+
display: "flex",
|
|
269
|
+
justifyContent: "center",
|
|
270
|
+
alignItems: "center",
|
|
271
|
+
width: "100%",
|
|
272
|
+
height: "100%",
|
|
273
|
+
...style,
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
return <div ref={ref} className={className} style={containerStyle} />;
|
|
76
277
|
}
|
|
@@ -74,6 +74,9 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
|
|
|
74
74
|
const keys = Object.keys(scope);
|
|
75
75
|
const vals = Object.values(scope);
|
|
76
76
|
try {
|
|
77
|
+
// Debug: log the exact code string
|
|
78
|
+
const codeChars = Array.from(code).map((c: string) => c.charCodeAt(0));
|
|
79
|
+
console.log(`EventContext.debug: keys=${JSON.stringify(keys)}, codeLen=${code.length}, codeChars=${JSON.stringify(codeChars)}, codeStr=${JSON.stringify(code)}`);
|
|
77
80
|
const fn = new Function(...keys, code);
|
|
78
81
|
fn(...vals);
|
|
79
82
|
console.info(`Event evaluation succeeded: "${code}"`);
|