1- import { useRef } from "preact/hooks" ;
1+ import { useRef , useEffect } from "preact/hooks" ;
22import { Signal , computed , useComputed , useSignal } from "@preact/signals" ;
33import {
44 Divider ,
@@ -10,16 +10,48 @@ import {
1010} from "../types" ;
1111import { updatesStore } from "../models/UpdatesModel" ;
1212
13+ const copyToClipboard = ( text : string ) => {
14+ const copyEl = document . createElement ( "textarea" ) ;
15+ try {
16+ copyEl . value = text ;
17+ document . body . append ( copyEl ) ;
18+ copyEl . select ( ) ;
19+ document . execCommand ( "copy" ) ;
20+ } finally {
21+ copyEl . remove ( ) ;
22+ }
23+ } ;
24+
1325export function GraphVisualization ( ) {
1426 const updates = updatesStore . updates ;
1527 const svgRef = useRef < SVGSVGElement > ( null ) ;
1628 const containerRef = useRef < HTMLDivElement > ( null ) ;
29+ const exportMenuRef = useRef < HTMLDivElement > ( null ) ;
1730
1831 // Pan and zoom state using signals
1932 const panOffset = useSignal ( { x : 0 , y : 0 } ) ;
2033 const zoom = useSignal ( 1 ) ;
2134 const isPanning = useSignal ( false ) ;
2235 const startPan = useSignal ( { x : 0 , y : 0 } ) ;
36+ const showExportMenu = useSignal ( false ) ;
37+ const toastText = useSignal < string > ( ) ;
38+
39+ useEffect ( ( ) => {
40+ const handleClickOutside = ( e : MouseEvent ) => {
41+ if (
42+ showExportMenu . value &&
43+ exportMenuRef . current &&
44+ ! exportMenuRef . current . contains ( e . target as Node )
45+ ) {
46+ showExportMenu . value = false ;
47+ }
48+ } ;
49+
50+ document . addEventListener ( "mousedown" , handleClickOutside ) ;
51+ return ( ) => {
52+ document . removeEventListener ( "mousedown" , handleClickOutside ) ;
53+ } ;
54+ } , [ ] ) ;
2355
2456 // Build graph data from updates signal using a computed
2557 const graphData = useComputed < GraphData > ( ( ) => {
@@ -181,6 +213,62 @@ export function GraphVisualization() {
181213 zoom . value = 1 ;
182214 } ;
183215
216+ const toggleExportMenu = ( ) => {
217+ showExportMenu . value = ! showExportMenu . value ;
218+ } ;
219+
220+ const mermaidIdPattern = / [ ^ a - z A - Z 0 - 9 ] / g;
221+ const computeMermaidId = ( id : string ) => id . replace ( mermaidIdPattern , "_" ) ;
222+
223+ const showToast = ( text : string ) => {
224+ toastText . value = text ;
225+ setTimeout ( ( ) => {
226+ toastText . value = undefined ;
227+ } , 2000 ) ;
228+ } ;
229+
230+ const handleExportMermaid = async ( ) => {
231+ showExportMenu . value = false ;
232+
233+ const lines : string [ ] = [ "graph LR" ] ;
234+
235+ graphData . value . nodes . forEach ( node => {
236+ const id = computeMermaidId ( node . id ) ;
237+ const name = node . name ;
238+
239+ switch ( node . type ) {
240+ case "signal" :
241+ lines . push ( ` ${ id } ((${ name } ))` ) ;
242+ break ;
243+ case "computed" :
244+ lines . push ( ` ${ id } (${ name } )` ) ;
245+ break ;
246+ case "effect" :
247+ lines . push ( ` ${ id } ([${ name } ])` ) ;
248+ break ;
249+ case "component" :
250+ lines . push ( ` ${ id } [${ name } ]` ) ;
251+ break ;
252+ }
253+ } ) ;
254+
255+ for ( const link of graphData . value . links ) {
256+ const sourceId = computeMermaidId ( link . source ) ;
257+ const targetId = computeMermaidId ( link . target ) ;
258+ lines . push ( ` ${ sourceId } --> ${ targetId } ` ) ;
259+ }
260+
261+ copyToClipboard ( lines . join ( "\n" ) ) ;
262+ showToast ( "Copied to clipboard!" ) ;
263+ } ;
264+
265+ const handleExportJSON = async ( ) => {
266+ showExportMenu . value = false ;
267+ const value = JSON . stringify ( graphData . value , null , 2 ) ;
268+ copyToClipboard ( value ) ;
269+ showToast ( "Copied to clipboard!" ) ;
270+ } ;
271+
184272 if ( graphData . value . nodes . length === 0 ) {
185273 return (
186274 < div className = "graph-empty" >
@@ -326,14 +414,47 @@ export function GraphVisualization() {
326414 </ g >
327415 </ svg >
328416
329- { /* Reset view button */ }
330- < button
331- className = "graph-reset-button"
332- onClick = { resetView }
333- title = "Reset view"
334- >
335- ⟲ Reset View
336- </ button >
417+ { /* Control buttons */ }
418+ < div className = "graph-controls" >
419+ < button
420+ className = "graph-reset-button"
421+ onClick = { resetView }
422+ title = "Reset view"
423+ >
424+ ⟲ Reset View
425+ </ button >
426+
427+ < div ref = { exportMenuRef } className = "graph-export-container" >
428+ < button
429+ className = "graph-export-button"
430+ onClick = { toggleExportMenu }
431+ title = "Export graph"
432+ >
433+ ↓ Export
434+ </ button >
435+ { showExportMenu . value && (
436+ < div className = "graph-export-menu" >
437+ < button
438+ className = "graph-export-menu-item"
439+ onClick = { handleExportMermaid }
440+ >
441+ Mermaid
442+ </ button >
443+ < button
444+ className = "graph-export-menu-item"
445+ onClick = { handleExportJSON }
446+ >
447+ JSON
448+ </ button >
449+ </ div >
450+ ) }
451+ </ div >
452+ </ div >
453+
454+ { /* Toast notification */ }
455+ { toastText . value && (
456+ < div className = "graph-toast" > { toastText . value } </ div >
457+ ) }
337458
338459 { /* Legend */ }
339460 < div className = "graph-legend" >
0 commit comments