flowbook 0.2.8 → 0.2.10

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,6 +1,6 @@
1
1
  {
2
2
  "name": "flowbook",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "flowbook": "dist/cli.js"
@@ -13,8 +13,8 @@
13
13
  "dev": "vite",
14
14
  "build": "tsup",
15
15
  "preview": "vite preview",
16
- "flowbook": "flowbook dev",
17
- "build-flowbook": "flowbook build"
16
+ "flowbook": "node dist/cli.js dev",
17
+ "build-flowbook": "node dist/cli.js build"
18
18
  },
19
19
  "dependencies": {
20
20
  "@tailwindcss/vite": "^4.0.0",
@@ -31,7 +31,6 @@
31
31
  "@types/node": "^25.3.3",
32
32
  "@types/react": "^19.0.0",
33
33
  "@types/react-dom": "^19.0.0",
34
- "flowbook": "^0.2.7",
35
34
  "tsup": "^8.0.0",
36
35
  "typescript": "^5.7.0"
37
36
  }
@@ -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: "#6366f1",
17
+ primaryColor: "#4f46e5",
18
18
  primaryTextColor: "#e2e8f0",
19
- primaryBorderColor: "#4f46e5",
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: "#4f46e5",
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: 'rgba(239, 68, 68, 0.08)',
81
- borderColor: 'rgba(239, 68, 68, 0.2)',
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: '#f87171' }}>
203
+ <p className="text-sm font-medium mb-2" style={{ color: "#f87171" }}>
85
204
  Render error
86
205
  </p>
87
- <p className="text-xs font-mono mb-3" style={{ color: 'rgba(248, 113, 113, 0.7)' }}>{error}</p>
88
- <pre className="text-xs overflow-x-auto whitespace-pre-wrap" style={{ color: 'var(--fb-text-muted)' }}>
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 className="w-5 h-5 border-2 rounded-full animate-spin" style={{ borderColor: 'rgba(99, 102, 241, 0.3)', borderTopColor: '#6366f1' }} />
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-mermaid overflow-x-auto ${className ?? ""}`}
109
- dangerouslySetInnerHTML={{ __html: svg }}
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
- /* Mermaid SVG sizing */
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
- /* Scrollbar */
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;