@@ -15,6 +15,8 @@ const props = defineProps<{
1515interface Node {
1616 module: ModuleListItem
1717 import? : ModuleImport
18+ expanded? : boolean
19+ hasChildren: boolean
1820}
1921
2022type Link = HierarchyLink <Node > & {
@@ -38,6 +40,11 @@ const width = ref(window.innerWidth)
3840const height = ref (window .innerHeight )
3941const nodesRefMap = shallowReactive (new Map <string , HTMLDivElement >())
4042
43+ const isUpdating = ref (false )
44+ const isFirstCalculateGraph = ref (true )
45+ const collapsedNodes = shallowReactive (new Set <string >())
46+ const childToParentMap = shallowReactive (new Map <string , string >())
47+
4148const nodes = shallowRef <HierarchyNode <Node >[]>([])
4249const links = shallowRef <Link []>([])
4350const nodesMap = shallowReactive (new Map <string , HierarchyNode <Node >>())
@@ -82,7 +89,7 @@ const createLinkVertical = linkVertical()
8289 .x (d => d [0 ])
8390 .y (d => d [1 ])
8491
85- function calculateGraph() {
92+ function calculateGraph(focusOnFirstRooeNode = true ) {
8693 // Unset the canvas size, and recalculate again after nodes are rendered
8794 width .value = window .innerWidth
8895 height .value = window .innerHeight
@@ -92,9 +99,24 @@ function calculateGraph() {
9299 { module: { id: ' ~root' } } as any ,
93100 (parent ) => {
94101 if (parent .module .id === ' ~root' ) {
95- rootModules .value .forEach (x => seen .add (x ))
96- return rootModules .value .map (x => ({ module: x }))
102+ rootModules .value .forEach ((x ) => {
103+ seen .add (x )
104+
105+ if (isFirstCalculateGraph .value ) {
106+ childToParentMap .set (x .id , ' ~root' )
107+ }
108+ })
109+ return rootModules .value .map (x => ({
110+ module: x ,
111+ expanded: ! collapsedNodes .has (x .id ),
112+ hasChildren: false ,
113+ }))
97114 }
115+
116+ if (collapsedNodes .has (parent .module .id )) {
117+ return []
118+ }
119+
98120 const modules = parent .module .imports
99121 .map ((x ): Node | undefined => {
100122 const module = modulesMap .value .get (x .module_id )
@@ -103,26 +125,49 @@ function calculateGraph() {
103125 if (seen .has (module ))
104126 return undefined
105127
128+ // Check if the module is a child of the current parent
129+ if (childToParentMap .has (module .id ) && childToParentMap .get (module .id ) !== parent .module .id )
130+ return undefined
131+
106132 seen .add (module )
133+
134+ if (isFirstCalculateGraph .value ) {
135+ childToParentMap .set (module .id , parent .module .id )
136+ }
137+
107138 return {
108139 module ,
109140 import: x ,
141+ expanded: ! collapsedNodes .has (module .id ),
142+ hasChildren: false ,
110143 }
111144 })
112145 .filter (x => x !== undefined )
146+
113147 return modules
114148 },
115149 )
116150
151+ if (isFirstCalculateGraph .value ) {
152+ isFirstCalculateGraph .value = false
153+ }
154+
117155 // Calculate the layout
118156 const layout = tree <Node >()
119157 .nodeSize ([SPACING .height , SPACING .width + SPACING .gap ])
120158 layout (root )
121159
122- // Rotate the graph from top-down to left-right
123160 const _nodes = root .descendants ()
161+
124162 for (const node of _nodes ) {
163+ // Rotate the graph from top-down to left-right
125164 [node .x , node .y ] = [node .y ! - SPACING .width , node .x ! ]
165+
166+ if (node .data .module .imports ) {
167+ node .data .hasChildren = node .data .module .imports
168+ ?.filter (subNode => childToParentMap .get (subNode .module_id ) === node .data .module .id )
169+ .length > 0
170+ }
126171 }
127172
128173 // Offset the graph and adding margin
@@ -163,8 +208,10 @@ function calculateGraph() {
163208 width .value = (container .value ! .scrollWidth / scale .value + SPACING .margin )
164209 height .value = (container .value ! .scrollHeight / scale .value + SPACING .margin )
165210 const moduleId = rootModules .value ?.[0 ]?.id
166- if (moduleId ) {
167- focusOn (moduleId , false )
211+ if (focusOnFirstRooeNode && moduleId ) {
212+ nextTick (() => {
213+ focusOn (moduleId , false )
214+ })
168215 }
169216 })
170217}
@@ -178,6 +225,93 @@ function focusOn(id: string, animated = true) {
178225 })
179226}
180227
228+ function adjustScrollPositionAfterToggle(id : string , beforePosition : { x: number , y: number }) {
229+ // Ensure this runs after the nextTick inside calculateGraph completes (width and height are computed)
230+ nextTick (() => {
231+ nextTick (() => {
232+ const newNode = nodesRefMap .get (id )
233+
234+ if (newNode && beforePosition && container .value ) {
235+ const containerRect = container .value .getBoundingClientRect ()
236+ const newRect = newNode .getBoundingClientRect ()
237+
238+ const viewportDiffX = newRect .left - containerRect .left - beforePosition .x
239+ const viewportDiffY = newRect .top - containerRect .top - beforePosition .y
240+
241+ container .value .scrollLeft += viewportDiffX
242+ container .value .scrollTop += viewportDiffY
243+ }
244+ })
245+ })
246+ }
247+
248+ function toggleNode(id : string ) {
249+ if (isUpdating .value )
250+ return
251+ isUpdating .value = true
252+
253+ const node = nodesRefMap .get (id )
254+ let beforePosition: null | { x: number , y: number } = null
255+
256+ // Record position relative to the scroll container to avoid drift after reflow
257+ if (node && container .value ) {
258+ const containerRect = container .value .getBoundingClientRect ()
259+ const rect = node .getBoundingClientRect ()
260+ beforePosition = {
261+ x: rect .left - containerRect .left ,
262+ y: rect .top - containerRect .top ,
263+ }
264+ }
265+
266+ if (collapsedNodes .has (id )) {
267+ collapsedNodes .delete (id )
268+ }
269+ else {
270+ collapsedNodes .add (id )
271+ }
272+
273+ calculateGraph (false )
274+
275+ // Adjust scroll position after layout changes
276+ if (beforePosition ) {
277+ adjustScrollPositionAfterToggle (id , beforePosition )
278+ }
279+
280+ isUpdating .value = false
281+ }
282+
283+ function expandAll() {
284+ if (isUpdating .value )
285+ return
286+
287+ isUpdating .value = true
288+
289+ collapsedNodes .clear ()
290+ calculateGraph ()
291+
292+ setTimeout (() => {
293+ isUpdating .value = false
294+ }, 300 )
295+ }
296+
297+ function collapseAll() {
298+ if (isUpdating .value )
299+ return
300+
301+ isUpdating .value = true
302+
303+ props .modules .forEach ((module ) => {
304+ if (module .imports .length > 0 ) {
305+ collapsedNodes .add (module .id )
306+ }
307+ })
308+ calculateGraph ()
309+
310+ setTimeout (() => {
311+ isUpdating .value = false
312+ }, 300 )
313+ }
314+
181315function generateLink(link : Link ) {
182316 if (link .target .x ! <= link .source .x ! ) {
183317 return createLinkVertical ({
@@ -192,7 +326,7 @@ function generateLink(link: Link) {
192326}
193327
194328function getLinkColor(_link : Link ) {
195- return ' stroke-#8882 '
329+ return ' stroke-#8885 '
196330}
197331
198332function handleDraggingScroll() {
@@ -205,6 +339,7 @@ function handleDraggingScroll() {
205339 const rect = container .value ! .getBoundingClientRect ()
206340 const distRight = rect .right - e .clientX
207341 const distBottom = rect .bottom - e .clientY
342+
208343 if (distRight <= SCROLLBAR_THICKNESS || distBottom <= SCROLLBAR_THICKNESS ) {
209344 return
210345 }
@@ -213,6 +348,7 @@ function handleDraggingScroll() {
213348 x = container .value ! .scrollLeft + e .pageX
214349 y = container .value ! .scrollTop + e .pageY
215350 })
351+ useEventListener (container , ' contextmenu' , e => e .preventDefault ())
216352 useEventListener (' mouseleave' , () => isGrabbing .value = false )
217353 useEventListener (' mouseup' , () => isGrabbing .value = false )
218354 useEventListener (' mousemove' , (e ) => {
@@ -228,10 +364,20 @@ onMounted(() => {
228364 handleDraggingScroll ()
229365
230366 watch (
231- () => [props .modules , graphRender .value ],
232- calculateGraph ,
367+ () => props .modules ,
368+ () => {
369+ isFirstCalculateGraph .value = true
370+ collapsedNodes .clear ()
371+ childToParentMap .clear ()
372+ calculateGraph ()
373+ },
233374 { immediate: true },
234375 )
376+
377+ watch (
378+ () => graphRender .value ,
379+ () => calculateGraph (),
380+ )
235381})
236382 </script >
237383
@@ -272,40 +418,69 @@ onMounted(() => {
272418 />
273419 </g >
274420 </svg >
275- <!-- <svg pointer-events-none absolute left-0 top-0 z-graph-link-active :width="width" :height="height">
276- <g>
277- <path
278- v-for="link of links"
279- :key="link.id"
280- :d="generateLink(link)!"
281- fill="none"
282- class="stroke-primary:75"
283- />
284- </g>
285- </svg> -->
286421 <template
287422 v-for =" node of nodes "
288423 :key =" node .data .module .id "
289424 >
290425 <template v-if =" node .data .module .id !== ' ~root' " >
291- <DisplayModuleId
292- :id =" node.data.module.id"
293- :ref =" (el: any) => nodesRefMap.set(node.data.module.id, el?.$el)"
294- absolute hover =" bg-active" block px2 p1 bg-glass z-graph-node
295- border =" ~ base rounded"
296- :link =" true"
297- :session =" session"
298- :minimal =" true"
426+ <div
427+ absolute
428+ class =" group z-graph-node flex gap-1 items-center"
299429 :style =" {
300430 left: `${node.x}px`,
301431 top: `${node.y}px`,
302- minWidth: graphRender === 'normal' ? `${SPACING.width}px` : undefined,
303432 transform: 'translate(-50%, -50%)',
304- maxWidth: '400px',
305- maxHeight: '50px',
306- overflow: 'hidden',
307433 }"
308- />
434+ >
435+ <div
436+ flex =" ~ items-center gap-1"
437+ bg-glass
438+ border =" ~ base rounded"
439+ class =" group-hover:bg-active block px2 p1"
440+ :style =" {
441+ minWidth: graphRender === 'normal' ? `${SPACING.width}px` : undefined,
442+ maxWidth: '400px',
443+ maxHeight: '50px',
444+ overflow: 'hidden',
445+ transition: 'all 0.3s ease',
446+ }"
447+ >
448+ <DisplayModuleId
449+ :id =" node.data.module.id"
450+ :ref =" (el: any) => nodesRefMap.set(node.data.module.id, el?.$el)"
451+ :link =" true"
452+ :session =" session"
453+ :minimal =" true"
454+ flex =" 1"
455+ />
456+ </div >
457+
458+ <!-- Expand/Collapse Button -->
459+ <div class =" w-4" >
460+ <button
461+ v-if =" node.data.hasChildren"
462+ w-4
463+ h-4
464+ rounded-full
465+ flex =" items-center justify-center"
466+ text-xs
467+ border =" ~ active"
468+ class =" flex cursor-pointer z-graph-node-active bg-base"
469+ :disabled =" isUpdating"
470+ :class =" { 'cursor-not-allowed': isUpdating, 'hover:bg-active': !isUpdating }"
471+ :title =" node.data.expanded ? 'Collapse' : 'Expand'"
472+ @click.stop =" toggleNode(node.data.module.id)"
473+ >
474+ <div
475+ class =" text-primary"
476+ :class =" [
477+ node.data.expanded ? 'i-ph-minus' : 'i-ph-plus',
478+ ]"
479+ transition =" transform duration-200"
480+ />
481+ </button >
482+ </div >
483+ </div >
309484 </template >
310485 </template >
311486 </div >
@@ -317,7 +492,32 @@ onMounted(() => {
317492 <DisplayTimeoutView :content =" `${Math.round(scale * 100)}%`" class =" text-sm" />
318493 </div >
319494
320- <div bg-glass rounded-full border border-base shadow >
495+ <div bg-glass rounded-full border border-base shadow flex =" ~ col gap-1 p1" >
496+ <button
497+ v-tooltip.left =" 'Expand All'"
498+ w-10 h-10 rounded-full hover:bg-active op-fade
499+ hover:op100 flex =" ~ items-center justify-center"
500+ :disabled =" isUpdating"
501+ :class =" { 'op50 cursor-not-allowed': isUpdating, 'hover:bg-active': !isUpdating }"
502+ title =" Expand All"
503+ @click =" expandAll()"
504+ >
505+ <div class =" i-carbon:expand-categories" />
506+ </button >
507+ <button
508+ v-tooltip.left =" 'Collapse All'"
509+ w-10 h-10 rounded-full hover:bg-active op-fade
510+ hover:op100 flex =" ~ items-center justify-center"
511+ :disabled =" isUpdating"
512+ :class =" { 'op50 cursor-not-allowed': isUpdating, 'hover:bg-active': !isUpdating }"
513+ title =" Collapse All"
514+ @click =" collapseAll()"
515+ >
516+ <div class =" i-carbon:collapse-categories" />
517+ </button >
518+
519+ <div border =" t base" my1 />
520+
321521 <button
322522 v-tooltip.left =" 'Zoom In (Ctrl + =)'"
323523 :disabled =" scale >= ZOOM_MAX"
0 commit comments