diff --git a/packages/devtools-vite/src/app/components/modules/Graph.vue b/packages/devtools-vite/src/app/components/modules/Graph.vue index 2494581e..3766dd9f 100644 --- a/packages/devtools-vite/src/app/components/modules/Graph.vue +++ b/packages/devtools-vite/src/app/components/modules/Graph.vue @@ -15,6 +15,8 @@ const props = defineProps<{ interface Node { module: ModuleListItem import?: ModuleImport + expanded?: boolean + hasChildren: boolean } type Link = HierarchyLink & { @@ -38,6 +40,11 @@ const width = ref(window.innerWidth) const height = ref(window.innerHeight) const nodesRefMap = shallowReactive(new Map()) +const isUpdating = ref(false) +const isFirstCalculateGraph = ref(true) +const collapsedNodes = shallowReactive(new Set()) +const childToParentMap = shallowReactive(new Map()) + const nodes = shallowRef[]>([]) const links = shallowRef([]) const nodesMap = shallowReactive(new Map>()) @@ -82,7 +89,7 @@ const createLinkVertical = linkVertical() .x(d => d[0]) .y(d => d[1]) -function calculateGraph() { +function calculateGraph(focusOnFirstRooeNode = true) { // Unset the canvas size, and recalculate again after nodes are rendered width.value = window.innerWidth height.value = window.innerHeight @@ -92,9 +99,24 @@ function calculateGraph() { { module: { id: '~root' } } as any, (parent) => { if (parent.module.id === '~root') { - rootModules.value.forEach(x => seen.add(x)) - return rootModules.value.map(x => ({ module: x })) + rootModules.value.forEach((x) => { + seen.add(x) + + if (isFirstCalculateGraph.value) { + childToParentMap.set(x.id, '~root') + } + }) + return rootModules.value.map(x => ({ + module: x, + expanded: !collapsedNodes.has(x.id), + hasChildren: false, + })) } + + if (collapsedNodes.has(parent.module.id)) { + return [] + } + const modules = parent.module.imports .map((x): Node | undefined => { const module = modulesMap.value.get(x.module_id) @@ -103,26 +125,49 @@ function calculateGraph() { if (seen.has(module)) return undefined + // Check if the module is a child of the current parent + if (childToParentMap.has(module.id) && childToParentMap.get(module.id) !== parent.module.id) + return undefined + seen.add(module) + + if (isFirstCalculateGraph.value) { + childToParentMap.set(module.id, parent.module.id) + } + return { module, import: x, + expanded: !collapsedNodes.has(module.id), + hasChildren: false, } }) .filter(x => x !== undefined) + return modules }, ) + if (isFirstCalculateGraph.value) { + isFirstCalculateGraph.value = false + } + // Calculate the layout const layout = tree() .nodeSize([SPACING.height, SPACING.width + SPACING.gap]) layout(root) - // Rotate the graph from top-down to left-right const _nodes = root.descendants() + for (const node of _nodes) { + // Rotate the graph from top-down to left-right [node.x, node.y] = [node.y! - SPACING.width, node.x!] + + if (node.data.module.imports) { + node.data.hasChildren = node.data.module.imports + ?.filter(subNode => childToParentMap.get(subNode.module_id) === node.data.module.id) + .length > 0 + } } // Offset the graph and adding margin @@ -163,8 +208,10 @@ function calculateGraph() { width.value = (container.value!.scrollWidth / scale.value + SPACING.margin) height.value = (container.value!.scrollHeight / scale.value + SPACING.margin) const moduleId = rootModules.value?.[0]?.id - if (moduleId) { - focusOn(moduleId, false) + if (focusOnFirstRooeNode && moduleId) { + nextTick(() => { + focusOn(moduleId, false) + }) } }) } @@ -178,6 +225,93 @@ function focusOn(id: string, animated = true) { }) } +function adjustScrollPositionAfterToggle(id: string, beforePosition: { x: number, y: number }) { + // Ensure this runs after the nextTick inside calculateGraph completes (width and height are computed) + nextTick(() => { + nextTick(() => { + const newNode = nodesRefMap.get(id) + + if (newNode && beforePosition && container.value) { + const containerRect = container.value.getBoundingClientRect() + const newRect = newNode.getBoundingClientRect() + + const viewportDiffX = newRect.left - containerRect.left - beforePosition.x + const viewportDiffY = newRect.top - containerRect.top - beforePosition.y + + container.value.scrollLeft += viewportDiffX + container.value.scrollTop += viewportDiffY + } + }) + }) +} + +function toggleNode(id: string) { + if (isUpdating.value) + return + isUpdating.value = true + + const node = nodesRefMap.get(id) + let beforePosition: null | { x: number, y: number } = null + + // Record position relative to the scroll container to avoid drift after reflow + if (node && container.value) { + const containerRect = container.value.getBoundingClientRect() + const rect = node.getBoundingClientRect() + beforePosition = { + x: rect.left - containerRect.left, + y: rect.top - containerRect.top, + } + } + + if (collapsedNodes.has(id)) { + collapsedNodes.delete(id) + } + else { + collapsedNodes.add(id) + } + + calculateGraph(false) + + // Adjust scroll position after layout changes + if (beforePosition) { + adjustScrollPositionAfterToggle(id, beforePosition) + } + + isUpdating.value = false +} + +function expandAll() { + if (isUpdating.value) + return + + isUpdating.value = true + + collapsedNodes.clear() + calculateGraph() + + setTimeout(() => { + isUpdating.value = false + }, 300) +} + +function collapseAll() { + if (isUpdating.value) + return + + isUpdating.value = true + + props.modules.forEach((module) => { + if (module.imports.length > 0) { + collapsedNodes.add(module.id) + } + }) + calculateGraph() + + setTimeout(() => { + isUpdating.value = false + }, 300) +} + function generateLink(link: Link) { if (link.target.x! <= link.source.x!) { return createLinkVertical({ @@ -192,7 +326,7 @@ function generateLink(link: Link) { } function getLinkColor(_link: Link) { - return 'stroke-#8882' + return 'stroke-#8885' } function handleDraggingScroll() { @@ -205,6 +339,7 @@ function handleDraggingScroll() { const rect = container.value!.getBoundingClientRect() const distRight = rect.right - e.clientX const distBottom = rect.bottom - e.clientY + if (distRight <= SCROLLBAR_THICKNESS || distBottom <= SCROLLBAR_THICKNESS) { return } @@ -213,6 +348,7 @@ function handleDraggingScroll() { x = container.value!.scrollLeft + e.pageX y = container.value!.scrollTop + e.pageY }) + useEventListener(container, 'contextmenu', e => e.preventDefault()) useEventListener('mouseleave', () => isGrabbing.value = false) useEventListener('mouseup', () => isGrabbing.value = false) useEventListener('mousemove', (e) => { @@ -228,10 +364,20 @@ onMounted(() => { handleDraggingScroll() watch( - () => [props.modules, graphRender.value], - calculateGraph, + () => props.modules, + () => { + isFirstCalculateGraph.value = true + collapsedNodes.clear() + childToParentMap.clear() + calculateGraph() + }, { immediate: true }, ) + + watch( + () => graphRender.value, + () => calculateGraph(), + ) }) @@ -272,40 +418,69 @@ onMounted(() => { /> - @@ -317,7 +492,32 @@ onMounted(() => { -
+
+ + + +
+