Skip to content

Commit 84a7860

Browse files
authored
feat: add expand/collapse functionality for graph nodes (#63)
1 parent d0885a2 commit 84a7860

1 file changed

Lines changed: 234 additions & 34 deletions

File tree

  • packages/devtools-vite/src/app/components/modules

packages/devtools-vite/src/app/components/modules/Graph.vue

Lines changed: 234 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ const props = defineProps<{
1515
interface Node {
1616
module: ModuleListItem
1717
import?: ModuleImport
18+
expanded?: boolean
19+
hasChildren: boolean
1820
}
1921
2022
type Link = HierarchyLink<Node> & {
@@ -38,6 +40,11 @@ const width = ref(window.innerWidth)
3840
const height = ref(window.innerHeight)
3941
const 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+
4148
const nodes = shallowRef<HierarchyNode<Node>[]>([])
4249
const links = shallowRef<Link[]>([])
4350
const 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+
181315
function generateLink(link: Link) {
182316
if (link.target.x! <= link.source.x!) {
183317
return createLinkVertical({
@@ -192,7 +326,7 @@ function generateLink(link: Link) {
192326
}
193327
194328
function getLinkColor(_link: Link) {
195-
return 'stroke-#8882'
329+
return 'stroke-#8885'
196330
}
197331
198332
function 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

Comments
 (0)