@preact/signals-devtools-ui 0.1.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.
@@ -0,0 +1,496 @@
1
+ import { useRef, useEffect } from "preact/hooks";
2
+ import { useComputed, useSignal } from "@preact/signals";
3
+ import type { GraphData, GraphLink, GraphNode } from "../types";
4
+ import type { SignalUpdate } from "../context";
5
+ import { getContext } from "../context";
6
+
7
+ const copyToClipboard = (text: string) => {
8
+ const copyEl = document.createElement("textarea");
9
+ try {
10
+ copyEl.value = text;
11
+ document.body.append(copyEl);
12
+ copyEl.select();
13
+ document.execCommand("copy");
14
+ } finally {
15
+ copyEl.remove();
16
+ }
17
+ };
18
+
19
+ export function GraphVisualization() {
20
+ const { updatesStore, settingsStore } = getContext();
21
+ const updates = updatesStore.updates;
22
+ const disposedSignalIds = updatesStore.disposedSignalIds;
23
+ const svgRef = useRef<SVGSVGElement>(null);
24
+ const containerRef = useRef<HTMLDivElement>(null);
25
+ const exportMenuRef = useRef<HTMLDivElement>(null);
26
+
27
+ // Pan and zoom state using signals
28
+ const panOffset = useSignal({ x: 0, y: 0 });
29
+ const zoom = useSignal(1);
30
+ const isPanning = useSignal(false);
31
+ const startPan = useSignal({ x: 0, y: 0 });
32
+ const showExportMenu = useSignal(false);
33
+ const toastText = useSignal<string>();
34
+ const hoveredNode = useSignal<GraphNode | null>(null);
35
+ const tooltipPos = useSignal({ x: 0, y: 0 });
36
+
37
+ useEffect(() => {
38
+ const handleClickOutside = (e: MouseEvent) => {
39
+ if (
40
+ showExportMenu.value &&
41
+ exportMenuRef.current &&
42
+ !exportMenuRef.current.contains(e.target as Node)
43
+ ) {
44
+ showExportMenu.value = false;
45
+ }
46
+ };
47
+
48
+ document.addEventListener("mousedown", handleClickOutside);
49
+ return () => {
50
+ document.removeEventListener("mousedown", handleClickOutside);
51
+ };
52
+ }, []);
53
+
54
+ // Build graph data from updates signal using a computed
55
+ const graphData = useComputed<GraphData>(() => {
56
+ const rawUpdates = updates.value;
57
+ const disposed = disposedSignalIds.value;
58
+ const showDisposed = settingsStore.showDisposedSignals;
59
+
60
+ if (!rawUpdates || rawUpdates.length === 0) return { nodes: [], links: [] };
61
+
62
+ const nodes = new Map<string, GraphNode>();
63
+ const links = new Map<string, GraphLink>();
64
+
65
+ // Process updates to build graph structure
66
+ const signalUpdates = rawUpdates.filter(
67
+ update => update.type !== "divider"
68
+ ) as SignalUpdate[];
69
+
70
+ for (const update of signalUpdates) {
71
+ if (!update.signalId) continue;
72
+
73
+ // Skip disposed signals unless showDisposed is enabled
74
+ if (!showDisposed && disposed.has(update.signalId)) continue;
75
+
76
+ const type: "signal" | "computed" | "effect" = update.signalType;
77
+ const currentDepth = update.depth || 0;
78
+
79
+ if (!nodes.has(update.signalId)) {
80
+ nodes.set(update.signalId, {
81
+ id: update.signalId,
82
+ name: update.signalName,
83
+ type,
84
+ x: 0,
85
+ y: 0,
86
+ depth: currentDepth,
87
+ });
88
+ }
89
+
90
+ if (update.subscribedTo) {
91
+ // Also skip links to/from disposed signals
92
+ const sourceDisposed =
93
+ !showDisposed && disposed.has(update.subscribedTo);
94
+ if (sourceDisposed) continue;
95
+
96
+ const linkKey = `${update.subscribedTo}->${update.signalId}`;
97
+ if (!links.has(linkKey)) {
98
+ links.set(linkKey, {
99
+ source: update.subscribedTo,
100
+ target: update.signalId,
101
+ });
102
+ }
103
+ }
104
+ }
105
+
106
+ // Simple depth-based layout
107
+ const allNodes = Array.from(nodes.values());
108
+ const nodeSpacing = 120;
109
+ const depthSpacing = 250;
110
+ const startX = 100;
111
+ const startY = 80;
112
+
113
+ // Group nodes by depth
114
+ const nodesByDepth = new Map<number, GraphNode[]>();
115
+ allNodes.forEach(node => {
116
+ if (!nodesByDepth.has(node.depth)) {
117
+ nodesByDepth.set(node.depth, []);
118
+ }
119
+ nodesByDepth.get(node.depth)!.push(node);
120
+ });
121
+
122
+ // Layout nodes by depth, centering each depth level vertically
123
+ const maxDepth = Math.max(...allNodes.map(n => n.depth));
124
+ nodesByDepth.forEach((depthNodes, depth) => {
125
+ const depthHeight = (depthNodes.length - 1) * nodeSpacing;
126
+ const depthStartY = startY + maxDepth * 100 - depthHeight / 2;
127
+
128
+ depthNodes.forEach((node, index) => {
129
+ node.x = startX + depth * depthSpacing;
130
+ node.y = depthStartY + index * nodeSpacing;
131
+ });
132
+ });
133
+
134
+ return {
135
+ nodes: allNodes,
136
+ links: Array.from(links.values()),
137
+ };
138
+ });
139
+
140
+ // Mouse event handlers for panning
141
+ const handleMouseDown = (e: MouseEvent) => {
142
+ if (e.button !== 0) return;
143
+ isPanning.value = true;
144
+ startPan.value = {
145
+ x: e.clientX - panOffset.value.x,
146
+ y: e.clientY - panOffset.value.y,
147
+ };
148
+ };
149
+
150
+ const handleMouseMove = (e: MouseEvent) => {
151
+ if (!isPanning.value) return;
152
+ panOffset.value = {
153
+ x: e.clientX - startPan.value.x,
154
+ y: e.clientY - startPan.value.y,
155
+ };
156
+ };
157
+
158
+ const handleMouseUp = () => {
159
+ isPanning.value = false;
160
+ };
161
+
162
+ const handleWheel = (e: WheelEvent) => {
163
+ e.preventDefault();
164
+
165
+ const container = containerRef.current;
166
+ if (!container) return;
167
+
168
+ const rect = container.getBoundingClientRect();
169
+ const mouseX = e.clientX - rect.left;
170
+ const mouseY = e.clientY - rect.top;
171
+
172
+ // Smoother zoom: smaller delta for less aggressive scrolling
173
+ const delta = e.deltaY > 0 ? 0.96 : 1.04;
174
+ const newZoom = Math.min(Math.max(0.1, zoom.value * delta), 5);
175
+
176
+ const zoomRatio = newZoom / zoom.value;
177
+ panOffset.value = {
178
+ x: mouseX - (mouseX - panOffset.value.x) * zoomRatio,
179
+ y: mouseY - (mouseY - panOffset.value.y) * zoomRatio,
180
+ };
181
+
182
+ zoom.value = newZoom;
183
+ };
184
+
185
+ const resetView = () => {
186
+ panOffset.value = { x: 0, y: 0 };
187
+ zoom.value = 1;
188
+ };
189
+
190
+ const toggleExportMenu = () => {
191
+ showExportMenu.value = !showExportMenu.value;
192
+ };
193
+
194
+ const mermaidIdPattern = /[^a-zA-Z0-9]/g;
195
+ const computeMermaidId = (id: string) => id.replace(mermaidIdPattern, "_");
196
+
197
+ // Calculate node radius based on name length
198
+ const getNodeRadius = (node: GraphNode) => {
199
+ const baseRadius = 30;
200
+ const charWidth = 6.5; // Approximate width per character
201
+ const padding = 16;
202
+ const textWidth = node.name.length * charWidth + padding;
203
+ return Math.max(baseRadius, Math.min(textWidth / 2, 70));
204
+ };
205
+
206
+ // Handle node hover for tooltip
207
+ const handleNodeMouseEnter = (node: GraphNode, e: MouseEvent) => {
208
+ hoveredNode.value = node;
209
+ const container = containerRef.current;
210
+ if (container) {
211
+ const rect = container.getBoundingClientRect();
212
+ tooltipPos.value = {
213
+ x: e.clientX - rect.left,
214
+ y: e.clientY - rect.top,
215
+ };
216
+ }
217
+ };
218
+
219
+ const handleNodeMouseMove = (e: MouseEvent) => {
220
+ const container = containerRef.current;
221
+ if (container && hoveredNode.value) {
222
+ const rect = container.getBoundingClientRect();
223
+ tooltipPos.value = {
224
+ x: e.clientX - rect.left,
225
+ y: e.clientY - rect.top,
226
+ };
227
+ }
228
+ };
229
+
230
+ const handleNodeMouseLeave = () => {
231
+ hoveredNode.value = null;
232
+ };
233
+
234
+ const showToast = (text: string) => {
235
+ toastText.value = text;
236
+ setTimeout(() => {
237
+ toastText.value = undefined;
238
+ }, 2000);
239
+ };
240
+
241
+ const handleExportMermaid = async () => {
242
+ showExportMenu.value = false;
243
+
244
+ const lines: string[] = ["graph LR"];
245
+
246
+ graphData.value.nodes.forEach(node => {
247
+ const id = computeMermaidId(node.id);
248
+ const name = node.name;
249
+
250
+ switch (node.type) {
251
+ case "signal":
252
+ lines.push(` ${id}((${name}))`);
253
+ break;
254
+ case "computed":
255
+ lines.push(` ${id}(${name})`);
256
+ break;
257
+ case "effect":
258
+ lines.push(` ${id}([${name}])`);
259
+ break;
260
+ }
261
+ });
262
+
263
+ for (const link of graphData.value.links) {
264
+ const sourceId = computeMermaidId(link.source);
265
+ const targetId = computeMermaidId(link.target);
266
+ lines.push(` ${sourceId} --> ${targetId}`);
267
+ }
268
+
269
+ copyToClipboard(lines.join("\n"));
270
+ showToast("Copied to clipboard!");
271
+ };
272
+
273
+ const handleExportJSON = async () => {
274
+ showExportMenu.value = false;
275
+ const value = JSON.stringify(graphData.value, null, 2);
276
+ copyToClipboard(value);
277
+ showToast("Copied to clipboard!");
278
+ };
279
+
280
+ if (graphData.value.nodes.length === 0) {
281
+ return (
282
+ <div className="graph-empty">
283
+ <div>
284
+ <h3>No Signal Dependencies</h3>
285
+ <p>
286
+ Create some signals with dependencies to see the graph
287
+ visualization.
288
+ </p>
289
+ </div>
290
+ </div>
291
+ );
292
+ }
293
+
294
+ const svgWidth = Math.max(800, ...graphData.value.nodes.map(n => n.x + 100));
295
+ const svgHeight = Math.max(600, ...graphData.value.nodes.map(n => n.y + 100));
296
+
297
+ return (
298
+ <div className="graph-container">
299
+ <div
300
+ ref={containerRef}
301
+ className="graph-content"
302
+ onMouseDown={handleMouseDown}
303
+ onMouseMove={handleMouseMove}
304
+ onMouseUp={handleMouseUp}
305
+ onMouseLeave={handleMouseUp}
306
+ onWheel={handleWheel}
307
+ style={{ cursor: isPanning.value ? "grabbing" : "grab" }}
308
+ >
309
+ <svg
310
+ ref={svgRef}
311
+ className="graph-svg"
312
+ width={svgWidth}
313
+ height={svgHeight}
314
+ viewBox={`0 0 ${svgWidth} ${svgHeight}`}
315
+ >
316
+ <defs>
317
+ <marker
318
+ id="arrowhead"
319
+ markerWidth="8"
320
+ markerHeight="6"
321
+ refX="7"
322
+ refY="3"
323
+ orient="auto"
324
+ >
325
+ <polygon points="0 0, 8 3, 0 6" fill="#94a3b8" />
326
+ </marker>
327
+ </defs>
328
+
329
+ <g
330
+ transform={`translate(${panOffset.value.x}, ${panOffset.value.y}) scale(${zoom.value})`}
331
+ >
332
+ <g className="links">
333
+ {graphData.value.links.map((link, index) => {
334
+ const sourceNode = graphData.value.nodes.find(
335
+ n => n.id === link.source
336
+ );
337
+ const targetNode = graphData.value.nodes.find(
338
+ n => n.id === link.target
339
+ );
340
+
341
+ if (!sourceNode || !targetNode) return null;
342
+
343
+ const sourceRadius = getNodeRadius(sourceNode);
344
+ const targetRadius = getNodeRadius(targetNode);
345
+ const sourceX = sourceNode.x + sourceRadius;
346
+ const sourceY = sourceNode.y;
347
+ const targetX = targetNode.x - targetRadius - 8; // Extra space for arrow
348
+ const targetY = targetNode.y;
349
+
350
+ const midX = sourceX + (targetX - sourceX) * 0.5;
351
+ const pathData = `M ${sourceX} ${sourceY} C ${midX} ${sourceY}, ${midX} ${targetY}, ${targetX} ${targetY}`;
352
+
353
+ return (
354
+ <path
355
+ key={`link-${index}`}
356
+ className="graph-link"
357
+ d={pathData}
358
+ fill="none"
359
+ markerEnd="url(#arrowhead)"
360
+ />
361
+ );
362
+ })}
363
+ </g>
364
+
365
+ <g className="nodes">
366
+ {graphData.value.nodes.map(node => {
367
+ const radius = getNodeRadius(node);
368
+ // Calculate max chars based on radius
369
+ const maxChars = Math.floor((radius * 2 - 16) / 6.5);
370
+ const displayName =
371
+ node.name.length > maxChars
372
+ ? node.name.slice(0, maxChars - 1) + "…"
373
+ : node.name;
374
+ const isHovered = hoveredNode.value?.id === node.id;
375
+
376
+ return (
377
+ <g
378
+ key={node.id}
379
+ className={`graph-node-group ${isHovered ? "hovered" : ""}`}
380
+ onMouseEnter={(e: MouseEvent) =>
381
+ handleNodeMouseEnter(node, e)
382
+ }
383
+ onMouseMove={handleNodeMouseMove}
384
+ onMouseLeave={handleNodeMouseLeave}
385
+ >
386
+ <circle
387
+ className={`graph-node ${node.type}`}
388
+ cx={node.x}
389
+ cy={node.y}
390
+ r={radius}
391
+ />
392
+ <text
393
+ className="graph-text"
394
+ x={node.x}
395
+ y={node.y}
396
+ textAnchor="middle"
397
+ dominantBaseline="central"
398
+ >
399
+ {displayName}
400
+ </text>
401
+ </g>
402
+ );
403
+ })}
404
+ </g>
405
+ </g>
406
+ </svg>
407
+
408
+ <div className="graph-controls">
409
+ <button
410
+ className="graph-reset-button"
411
+ onClick={resetView}
412
+ title="Reset view"
413
+ >
414
+ ⟲ Reset View
415
+ </button>
416
+
417
+ <div ref={exportMenuRef} className="graph-export-container">
418
+ <button
419
+ className="graph-export-button"
420
+ onClick={toggleExportMenu}
421
+ title="Export graph"
422
+ >
423
+ ↓ Export
424
+ </button>
425
+ {showExportMenu.value && (
426
+ <div className="graph-export-menu">
427
+ <button
428
+ className="graph-export-menu-item"
429
+ onClick={handleExportMermaid}
430
+ >
431
+ Mermaid
432
+ </button>
433
+ <button
434
+ className="graph-export-menu-item"
435
+ onClick={handleExportJSON}
436
+ >
437
+ JSON
438
+ </button>
439
+ </div>
440
+ )}
441
+ </div>
442
+
443
+ <div className="graph-zoom-indicator" title="Zoom level">
444
+ {Math.round(zoom.value * 100)}%
445
+ </div>
446
+ </div>
447
+
448
+ {toastText.value && (
449
+ <div className="graph-toast">{toastText.value}</div>
450
+ )}
451
+
452
+ {hoveredNode.value && (
453
+ <div
454
+ className="graph-tooltip"
455
+ style={{
456
+ left: tooltipPos.value.x + 12,
457
+ top: tooltipPos.value.y - 8,
458
+ }}
459
+ >
460
+ <div className="tooltip-header">
461
+ <span className={`tooltip-type ${hoveredNode.value.type}`}>
462
+ {hoveredNode.value.type}
463
+ </span>
464
+ </div>
465
+ <div className="tooltip-name">{hoveredNode.value.name}</div>
466
+ <div className="tooltip-id">ID: {hoveredNode.value.id}</div>
467
+ </div>
468
+ )}
469
+
470
+ <div className="graph-legend">
471
+ <div className="legend-item">
472
+ <div
473
+ className="legend-color"
474
+ style={{ backgroundColor: "#2196f3" }}
475
+ ></div>
476
+ <span>Signal</span>
477
+ </div>
478
+ <div className="legend-item">
479
+ <div
480
+ className="legend-color"
481
+ style={{ backgroundColor: "#ff9800" }}
482
+ ></div>
483
+ <span>Computed</span>
484
+ </div>
485
+ <div className="legend-item">
486
+ <div
487
+ className="legend-color"
488
+ style={{ backgroundColor: "#4caf50" }}
489
+ ></div>
490
+ <span>Effect</span>
491
+ </div>
492
+ </div>
493
+ </div>
494
+ </div>
495
+ );
496
+ }
@@ -0,0 +1,39 @@
1
+ import { StatusIndicator } from "./StatusIndicator";
2
+ import { Button } from "./Button";
3
+ import { getContext } from "../context";
4
+
5
+ export function Header() {
6
+ const { connectionStore, updatesStore, settingsStore } = getContext();
7
+
8
+ const onToggleSettings = settingsStore.toggleSettings;
9
+ const onTogglePause = () => {
10
+ updatesStore.isPaused.value = !updatesStore.isPaused.value;
11
+ };
12
+
13
+ const onClear = () => {
14
+ updatesStore.clearUpdates();
15
+ };
16
+
17
+ return (
18
+ <header className="header">
19
+ <div className="header-title">
20
+ <h1>Signals</h1>
21
+ <StatusIndicator
22
+ status={connectionStore.status}
23
+ message={connectionStore.message}
24
+ />
25
+ </div>
26
+ <div className="header-controls">
27
+ {onClear && <Button onClick={onClear}>Clear</Button>}
28
+ {onTogglePause && (
29
+ <Button onClick={onTogglePause} active={updatesStore.isPaused.value}>
30
+ {updatesStore.isPaused.value ? "Resume" : "Pause"}
31
+ </Button>
32
+ )}
33
+ {onToggleSettings && (
34
+ <Button onClick={onToggleSettings}>Settings</Button>
35
+ )}
36
+ </div>
37
+ </header>
38
+ );
39
+ }
@@ -0,0 +1,129 @@
1
+ import { useSignal, useSignalEffect } from "@preact/signals";
2
+ import { Button } from "./Button";
3
+ import type { Settings } from "@preact/signals-devtools-adapter";
4
+ import { getContext } from "../context";
5
+
6
+ export function SettingsPanel() {
7
+ const { settingsStore } = getContext();
8
+
9
+ const onCancel = settingsStore.hideSettings;
10
+ const onApply = settingsStore.applySettings;
11
+ const settings = settingsStore.settings;
12
+ const isVisible = settingsStore.showSettings;
13
+
14
+ const localSettings = useSignal<Settings>(settings);
15
+
16
+ useSignalEffect(() => {
17
+ localSettings.value = settingsStore.settings;
18
+ });
19
+
20
+ const handleApply = () => {
21
+ onApply(localSettings.value);
22
+ };
23
+
24
+ if (!isVisible) {
25
+ return null;
26
+ }
27
+
28
+ return (
29
+ <div className="settings-panel">
30
+ <div className="settings-content">
31
+ <h3>Debug Configuration</h3>
32
+
33
+ <div className="setting-group">
34
+ <label>
35
+ <input
36
+ type="checkbox"
37
+ checked={localSettings.value.enabled}
38
+ onChange={e =>
39
+ (localSettings.value = {
40
+ ...localSettings.value,
41
+ enabled: (e.target as HTMLInputElement).checked,
42
+ })
43
+ }
44
+ />
45
+ Enable debug updates
46
+ </label>
47
+ </div>
48
+
49
+ <div className="setting-group">
50
+ <label>
51
+ <input
52
+ type="checkbox"
53
+ checked={localSettings.value.grouped}
54
+ onChange={e =>
55
+ (localSettings.value = {
56
+ ...localSettings.value,
57
+ grouped: (e.target as HTMLInputElement).checked,
58
+ })
59
+ }
60
+ />
61
+ Group related updates
62
+ </label>
63
+ </div>
64
+
65
+ <div className="setting-group">
66
+ <label htmlFor="maxUpdatesInput">Max updates per second:</label>
67
+ <input
68
+ type="number"
69
+ id="maxUpdatesInput"
70
+ value={localSettings.value.maxUpdatesPerSecond}
71
+ min="1"
72
+ max="1000"
73
+ onChange={e =>
74
+ (localSettings.value = {
75
+ ...localSettings.value,
76
+ maxUpdatesPerSecond:
77
+ parseInt((e.target as HTMLInputElement).value) || 60,
78
+ })
79
+ }
80
+ />
81
+ </div>
82
+
83
+ <div className="setting-group">
84
+ <label htmlFor="filterPatternsInput">
85
+ Filter patterns (one per line):
86
+ </label>
87
+ <textarea
88
+ id="filterPatternsInput"
89
+ placeholder="user.*&#10;.*State$&#10;global"
90
+ value={localSettings.value.filterPatterns.join("\n")}
91
+ onChange={e =>
92
+ (localSettings.value = {
93
+ ...localSettings.value,
94
+ filterPatterns: (e.target as HTMLTextAreaElement).value
95
+ .split("\n")
96
+ .map(pattern => pattern.trim())
97
+ .filter(pattern => pattern.length > 0),
98
+ })
99
+ }
100
+ />
101
+ </div>
102
+
103
+ <h3>Graph Settings</h3>
104
+
105
+ <div className="setting-group">
106
+ <label>
107
+ <input
108
+ type="checkbox"
109
+ checked={settingsStore.showDisposedSignals}
110
+ onChange={() => settingsStore.toggleShowDisposedSignals()}
111
+ />
112
+ Show disposed signals in graph
113
+ </label>
114
+ <p className="setting-description">
115
+ When enabled, signals and effects that have been disposed will still
116
+ be shown in the graph view.
117
+ </p>
118
+ </div>
119
+
120
+ <div className="settings-actions">
121
+ <Button onClick={handleApply} variant="primary">
122
+ Apply
123
+ </Button>
124
+ <Button onClick={onCancel}>Cancel</Button>
125
+ </div>
126
+ </div>
127
+ </div>
128
+ );
129
+ }
@@ -0,0 +1,22 @@
1
+ import type { ConnectionStatusType } from "@preact/signals-devtools-adapter";
2
+
3
+ interface StatusIndicatorProps {
4
+ status: ConnectionStatusType;
5
+ message: string;
6
+ showIndicator?: boolean;
7
+ className?: string;
8
+ }
9
+
10
+ export function StatusIndicator({
11
+ status,
12
+ message,
13
+ showIndicator = true,
14
+ className = "",
15
+ }: StatusIndicatorProps) {
16
+ return (
17
+ <div className={`connection-status ${status} ${className}`}>
18
+ {showIndicator && <span className={`status-indicator ${status}`}></span>}
19
+ <span className="status-text">{message}</span>
20
+ </div>
21
+ );
22
+ }