flowbook 0.2.8 → 0.2.9
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/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useRef, useState, useId } from "react";
|
|
1
|
+
import { useEffect, useRef, useState, useId, useCallback } from "react";
|
|
2
2
|
import mermaid from "mermaid";
|
|
3
3
|
|
|
4
4
|
let initialized = false;
|
|
@@ -14,9 +14,9 @@ function ensureInit() {
|
|
|
14
14
|
darkMode: true,
|
|
15
15
|
background: "#131a2b",
|
|
16
16
|
mainBkg: "#1e2740",
|
|
17
|
-
primaryColor: "#
|
|
17
|
+
primaryColor: "#4f46e5",
|
|
18
18
|
primaryTextColor: "#e2e8f0",
|
|
19
|
-
primaryBorderColor: "#
|
|
19
|
+
primaryBorderColor: "#6366f1",
|
|
20
20
|
secondaryColor: "#1e293b",
|
|
21
21
|
secondaryTextColor: "#cbd5e1",
|
|
22
22
|
secondaryBorderColor: "#334155",
|
|
@@ -26,7 +26,7 @@ function ensureInit() {
|
|
|
26
26
|
lineColor: "#6366f1",
|
|
27
27
|
textColor: "#e2e8f0",
|
|
28
28
|
nodeTextColor: "#e2e8f0",
|
|
29
|
-
nodeBorder: "#
|
|
29
|
+
nodeBorder: "#6366f1",
|
|
30
30
|
clusterBkg: "#0f172a",
|
|
31
31
|
clusterBorder: "#1e293b",
|
|
32
32
|
edgeLabelBackground: "#131a2b",
|
|
@@ -35,6 +35,49 @@ function ensureInit() {
|
|
|
35
35
|
initialized = true;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
const MIN_ZOOM = 0.25;
|
|
39
|
+
const MAX_ZOOM = 4;
|
|
40
|
+
const ZOOM_SPEED = 0.002;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Post-process SVG to add CSS classes based on node shape type.
|
|
44
|
+
* - polygon → node-decision (diamond, hexagon, etc.)
|
|
45
|
+
* - 2+ rects → node-subroutine (double-bordered)
|
|
46
|
+
* - path with arc commands → node-database (cylinder)
|
|
47
|
+
* - circle → node-circle
|
|
48
|
+
*/
|
|
49
|
+
function classifyNodes(svgString: string): string {
|
|
50
|
+
try {
|
|
51
|
+
const parser = new DOMParser();
|
|
52
|
+
const doc = parser.parseFromString(svgString, "image/svg+xml");
|
|
53
|
+
const svg = doc.documentElement;
|
|
54
|
+
|
|
55
|
+
svg.querySelectorAll(".node").forEach((node) => {
|
|
56
|
+
if (node.querySelector("polygon")) {
|
|
57
|
+
node.classList.add("node-decision");
|
|
58
|
+
} else if (node.querySelectorAll("rect").length >= 2) {
|
|
59
|
+
node.classList.add("node-subroutine");
|
|
60
|
+
} else if (node.querySelector("circle")) {
|
|
61
|
+
node.classList.add("node-circle");
|
|
62
|
+
} else {
|
|
63
|
+
const paths = node.querySelectorAll("path");
|
|
64
|
+
let hasArcs = false;
|
|
65
|
+
paths.forEach((p) => {
|
|
66
|
+
const d = p.getAttribute("d") || "";
|
|
67
|
+
if (/[Aa]\s*[\d.]/.test(d)) hasArcs = true;
|
|
68
|
+
});
|
|
69
|
+
if (hasArcs) {
|
|
70
|
+
node.classList.add("node-database");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return new XMLSerializer().serializeToString(svg);
|
|
76
|
+
} catch {
|
|
77
|
+
return svgString;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
38
81
|
interface Props {
|
|
39
82
|
code: string;
|
|
40
83
|
className?: string;
|
|
@@ -47,16 +90,22 @@ export function MermaidRenderer({ code, className }: Props) {
|
|
|
47
90
|
const rawId = useId();
|
|
48
91
|
const safeId = "mermaid" + rawId.replace(/:/g, "-");
|
|
49
92
|
|
|
93
|
+
// Zoom & pan state
|
|
94
|
+
const [scale, setScale] = useState(1);
|
|
95
|
+
const [pos, setPos] = useState({ x: 0, y: 0 });
|
|
96
|
+
const dragging = useRef(false);
|
|
97
|
+
const dragOrigin = useRef({ x: 0, y: 0, px: 0, py: 0 });
|
|
98
|
+
|
|
99
|
+
// Render mermaid diagram
|
|
50
100
|
useEffect(() => {
|
|
51
101
|
ensureInit();
|
|
52
|
-
|
|
53
102
|
let cancelled = false;
|
|
54
103
|
|
|
55
104
|
mermaid
|
|
56
105
|
.render(safeId, code)
|
|
57
106
|
.then(({ svg: rendered }) => {
|
|
58
107
|
if (!cancelled) {
|
|
59
|
-
setSvg(rendered);
|
|
108
|
+
setSvg(classifyNodes(rendered));
|
|
60
109
|
setError("");
|
|
61
110
|
}
|
|
62
111
|
})
|
|
@@ -72,20 +121,98 @@ export function MermaidRenderer({ code, className }: Props) {
|
|
|
72
121
|
};
|
|
73
122
|
}, [code, safeId]);
|
|
74
123
|
|
|
124
|
+
// Wheel zoom (non-passive to allow preventDefault)
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
const el = containerRef.current;
|
|
127
|
+
if (!el || !svg) return;
|
|
128
|
+
|
|
129
|
+
const onWheel = (e: WheelEvent) => {
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
const rect = el.getBoundingClientRect();
|
|
132
|
+
const cx = e.clientX - rect.left;
|
|
133
|
+
const cy = e.clientY - rect.top;
|
|
134
|
+
|
|
135
|
+
setScale((prev) => {
|
|
136
|
+
const next = Math.min(
|
|
137
|
+
MAX_ZOOM,
|
|
138
|
+
Math.max(MIN_ZOOM, prev * (1 - e.deltaY * ZOOM_SPEED)),
|
|
139
|
+
);
|
|
140
|
+
const ratio = next / prev;
|
|
141
|
+
setPos((p) => ({
|
|
142
|
+
x: cx - (cx - p.x) * ratio,
|
|
143
|
+
y: cy - (cy - p.y) * ratio,
|
|
144
|
+
}));
|
|
145
|
+
return next;
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
el.addEventListener("wheel", onWheel, { passive: false });
|
|
150
|
+
return () => el.removeEventListener("wheel", onWheel);
|
|
151
|
+
}, [svg]);
|
|
152
|
+
|
|
153
|
+
// Mouse drag to pan
|
|
154
|
+
const onMouseDown = useCallback(
|
|
155
|
+
(e: React.MouseEvent) => {
|
|
156
|
+
if (e.button !== 0) return;
|
|
157
|
+
dragging.current = true;
|
|
158
|
+
dragOrigin.current = {
|
|
159
|
+
x: e.clientX,
|
|
160
|
+
y: e.clientY,
|
|
161
|
+
px: pos.x,
|
|
162
|
+
py: pos.y,
|
|
163
|
+
};
|
|
164
|
+
},
|
|
165
|
+
[pos],
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
const onMove = (e: MouseEvent) => {
|
|
170
|
+
if (!dragging.current) return;
|
|
171
|
+
setPos({
|
|
172
|
+
x: dragOrigin.current.px + (e.clientX - dragOrigin.current.x),
|
|
173
|
+
y: dragOrigin.current.py + (e.clientY - dragOrigin.current.y),
|
|
174
|
+
});
|
|
175
|
+
};
|
|
176
|
+
const onUp = () => {
|
|
177
|
+
dragging.current = false;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
window.addEventListener("mousemove", onMove);
|
|
181
|
+
window.addEventListener("mouseup", onUp);
|
|
182
|
+
return () => {
|
|
183
|
+
window.removeEventListener("mousemove", onMove);
|
|
184
|
+
window.removeEventListener("mouseup", onUp);
|
|
185
|
+
};
|
|
186
|
+
}, []);
|
|
187
|
+
|
|
188
|
+
// Double-click to reset
|
|
189
|
+
const onDoubleClick = useCallback(() => {
|
|
190
|
+
setScale(1);
|
|
191
|
+
setPos({ x: 0, y: 0 });
|
|
192
|
+
}, []);
|
|
193
|
+
|
|
75
194
|
if (error) {
|
|
76
195
|
return (
|
|
77
196
|
<div
|
|
78
197
|
className={`rounded-lg p-4 border ${className ?? ""}`}
|
|
79
198
|
style={{
|
|
80
|
-
background:
|
|
81
|
-
borderColor:
|
|
199
|
+
background: "rgba(239, 68, 68, 0.08)",
|
|
200
|
+
borderColor: "rgba(239, 68, 68, 0.2)",
|
|
82
201
|
}}
|
|
83
202
|
>
|
|
84
|
-
<p className="text-sm font-medium mb-2" style={{ color:
|
|
203
|
+
<p className="text-sm font-medium mb-2" style={{ color: "#f87171" }}>
|
|
85
204
|
Render error
|
|
86
205
|
</p>
|
|
87
|
-
<p
|
|
88
|
-
|
|
206
|
+
<p
|
|
207
|
+
className="text-xs font-mono mb-3"
|
|
208
|
+
style={{ color: "rgba(248, 113, 113, 0.7)" }}
|
|
209
|
+
>
|
|
210
|
+
{error}
|
|
211
|
+
</p>
|
|
212
|
+
<pre
|
|
213
|
+
className="text-xs overflow-x-auto whitespace-pre-wrap"
|
|
214
|
+
style={{ color: "var(--fb-text-muted)" }}
|
|
215
|
+
>
|
|
89
216
|
{code}
|
|
90
217
|
</pre>
|
|
91
218
|
</div>
|
|
@@ -97,7 +224,13 @@ export function MermaidRenderer({ code, className }: Props) {
|
|
|
97
224
|
<div
|
|
98
225
|
className={`flex items-center justify-center py-12 ${className ?? ""}`}
|
|
99
226
|
>
|
|
100
|
-
<div
|
|
227
|
+
<div
|
|
228
|
+
className="w-5 h-5 border-2 rounded-full animate-spin"
|
|
229
|
+
style={{
|
|
230
|
+
borderColor: "rgba(99, 102, 241, 0.3)",
|
|
231
|
+
borderTopColor: "#6366f1",
|
|
232
|
+
}}
|
|
233
|
+
/>
|
|
101
234
|
</div>
|
|
102
235
|
);
|
|
103
236
|
}
|
|
@@ -105,8 +238,18 @@ export function MermaidRenderer({ code, className }: Props) {
|
|
|
105
238
|
return (
|
|
106
239
|
<div
|
|
107
240
|
ref={containerRef}
|
|
108
|
-
className={`flowbook-
|
|
109
|
-
|
|
110
|
-
|
|
241
|
+
className={`flowbook-zoom-container ${className ?? ""}`}
|
|
242
|
+
onMouseDown={onMouseDown}
|
|
243
|
+
onDoubleClick={onDoubleClick}
|
|
244
|
+
>
|
|
245
|
+
<div
|
|
246
|
+
className="flowbook-mermaid"
|
|
247
|
+
style={{
|
|
248
|
+
transform: `translate(${pos.x}px, ${pos.y}px) scale(${scale})`,
|
|
249
|
+
transformOrigin: "0 0",
|
|
250
|
+
}}
|
|
251
|
+
dangerouslySetInnerHTML={{ __html: svg }}
|
|
252
|
+
/>
|
|
253
|
+
</div>
|
|
111
254
|
);
|
|
112
255
|
}
|
|
@@ -25,13 +25,63 @@ body {
|
|
|
25
25
|
background: var(--fb-bg-outer);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/*
|
|
28
|
+
/* ── Zoom container ── */
|
|
29
|
+
.flowbook-zoom-container {
|
|
30
|
+
overflow: hidden;
|
|
31
|
+
cursor: grab;
|
|
32
|
+
user-select: none;
|
|
33
|
+
-webkit-user-select: none;
|
|
34
|
+
width: 100%;
|
|
35
|
+
height: 100%;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.flowbook-zoom-container:active {
|
|
39
|
+
cursor: grabbing;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* Remove max-width limit so zoom works beyond container */
|
|
43
|
+
.flowbook-zoom-container .flowbook-mermaid svg {
|
|
44
|
+
max-width: none;
|
|
45
|
+
height: auto;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* ── Mermaid SVG sizing (non-zoom fallback) ── */
|
|
29
49
|
.flowbook-mermaid svg {
|
|
30
50
|
max-width: 100%;
|
|
31
51
|
height: auto;
|
|
32
52
|
}
|
|
33
53
|
|
|
34
|
-
/*
|
|
54
|
+
/* ── Per-node-type coloring ── */
|
|
55
|
+
|
|
56
|
+
/* Decision nodes: diamond, hexagon, etc. (polygon shapes) → dark rose */
|
|
57
|
+
.flowbook-mermaid .node-decision polygon {
|
|
58
|
+
fill: #6b2142 !important;
|
|
59
|
+
stroke: #8b3a5c !important;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* Database / cylinder nodes → dark teal-green */
|
|
63
|
+
.flowbook-mermaid .node-database path {
|
|
64
|
+
fill: #1a4731 !important;
|
|
65
|
+
stroke: #2d6b47 !important;
|
|
66
|
+
}
|
|
67
|
+
.flowbook-mermaid .node-database rect {
|
|
68
|
+
fill: #1a4731 !important;
|
|
69
|
+
stroke: #2d6b47 !important;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* Subroutine nodes (double-bordered rect) → dark slate */
|
|
73
|
+
.flowbook-mermaid .node-subroutine rect {
|
|
74
|
+
fill: #1e2740 !important;
|
|
75
|
+
stroke: #2d3456 !important;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* Circle nodes → indigo */
|
|
79
|
+
.flowbook-mermaid .node-circle circle {
|
|
80
|
+
fill: #4f46e5 !important;
|
|
81
|
+
stroke: #6366f1 !important;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* ── Scrollbar ── */
|
|
35
85
|
::-webkit-scrollbar {
|
|
36
86
|
width: 6px;
|
|
37
87
|
height: 6px;
|