Skip to content

Commit 53fef75

Browse files
committed
feat: add wheel zoom to module graph
1 parent e4422ea commit 53fef75

File tree

3 files changed

+210
-48
lines changed

3 files changed

+210
-48
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<script setup lang="ts">
2+
import { ref, watch } from 'vue'
3+
4+
defineOptions({
5+
inheritAttrs: false,
6+
})
7+
8+
const { content, hideInterval = 1500 } = defineProps<{
9+
content: string
10+
hideInterval?: number
11+
}>()
12+
13+
const isVisible = ref(false)
14+
15+
let timeoutId: ReturnType<typeof setTimeout> | null = null
16+
17+
watch(() => content, () => {
18+
if (timeoutId)
19+
clearTimeout(timeoutId)
20+
21+
isVisible.value = true
22+
23+
timeoutId = setTimeout(() => {
24+
isVisible.value = false
25+
}, hideInterval)
26+
})
27+
</script>
28+
29+
<template>
30+
<transition
31+
enter-active-class="transition-opacity duration-200 ease-in"
32+
leave-active-class="transition-opacity duration-500 ease-out"
33+
enter-from-class="opacity-0"
34+
leave-to-class="opacity-0"
35+
>
36+
<div v-if="isVisible" v-bind="$attrs">
37+
{{ content }}
38+
</div>
39+
</transition>
40+
</template>

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

Lines changed: 105 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
<script setup lang="ts">
2+
import type { ModuleImport, ModuleListItem, SessionContext } from '~/shared/types'
23
import type { HierarchyLink, HierarchyNode } from 'd3-hierarchy'
3-
import type { ModuleImport, ModuleListItem, SessionContext } from '~~/shared/types'
4-
import { useEventListener } from '@vueuse/core'
4+
import { onKeyPressed, useEventListener, useMagicKeys } from '@vueuse/core'
55
import { hierarchy, tree } from 'd3-hierarchy'
66
import { linkHorizontal, linkVertical } from 'd3-shape'
77
import { computed, nextTick, onMounted, reactive, ref, shallowReactive, shallowRef, useTemplateRef, watch } from 'vue'
8+
import { useZoomElement } from '~/composables/zoomElement'
89
910
const props = defineProps<{
1011
session: SessionContext
@@ -35,14 +36,32 @@ const container = useTemplateRef<HTMLDivElement>('container')
3536
const isGrabbing = ref(false)
3637
const width = ref(window.innerWidth)
3738
const height = ref(window.innerHeight)
38-
const scale = ref(1)
3939
const nodesRefMap = shallowReactive(new Map<string, HTMLDivElement>())
4040
4141
const nodes = shallowRef<HierarchyNode<Node>[]>([])
4242
const links = shallowRef<Link[]>([])
4343
const nodesMap = shallowReactive(new Map<string, HierarchyNode<Node>>())
4444
const linksMap = shallowReactive(new Map<string, Link>())
4545
46+
const ZOOM_MIN = 0.4
47+
const ZOOM_MAX = 2
48+
const { control } = useMagicKeys()
49+
const { scale, zoomIn, zoomOut } = useZoomElement(container, {
50+
wheel: control,
51+
minScale: ZOOM_MIN,
52+
maxScale: ZOOM_MAX,
53+
})
54+
55+
onKeyPressed(['-', '_'], (e) => {
56+
if (e.ctrlKey)
57+
zoomOut()
58+
})
59+
60+
onKeyPressed(['=', '+'], (e) => {
61+
if (e.ctrlKey)
62+
zoomIn()
63+
})
64+
4665
const modulesMap = computed(() => {
4766
const map = new Map<string, ModuleListItem>()
4867
for (const module of props.modules) {
@@ -220,26 +239,30 @@ onMounted(() => {
220239
:class="isGrabbing ? 'cursor-grabbing' : ''"
221240
>
222241
<div
223-
absolute left-0 top-0
224-
:style="{
225-
width: `${width}px`,
226-
height: `${height}px`,
227-
}"
228-
class="bg-dots"
229-
/>
230-
<svg pointer-events-none absolute left-0 top-0 z-graph-link :width="width" :height="height">
231-
<g>
232-
<path
233-
v-for="link of links"
234-
:key="link.id"
235-
:d="generateLink(link)!"
236-
:class="getLinkColor(link)"
237-
:stroke-dasharray="link.import?.kind === 'dynamic-import' ? '3 6' : undefined"
238-
fill="none"
239-
/>
240-
</g>
241-
</svg>
242-
<!-- <svg pointer-events-none absolute left-0 top-0 z-graph-link-active :width="width" :height="height">
242+
flex="~ items-center justify-center"
243+
:style="{ transform: `scale(${scale})`, transformOrigin: '0 0' }"
244+
>
245+
<div
246+
absolute left-0 top-0
247+
:style="{
248+
width: `${width}px`,
249+
height: `${height}px`,
250+
}"
251+
class="bg-dots"
252+
/>
253+
<svg pointer-events-none absolute left-0 top-0 z-graph-link :width="width" :height="height">
254+
<g>
255+
<path
256+
v-for="link of links"
257+
:key="link.id"
258+
:d="generateLink(link)!"
259+
:class="getLinkColor(link)"
260+
:stroke-dasharray="link.import?.kind === 'dynamic-import' ? '3 6' : undefined"
261+
fill="none"
262+
/>
263+
</g>
264+
</svg>
265+
<!-- <svg pointer-events-none absolute left-0 top-0 z-graph-link-active :width="width" :height="height">
243266
<g>
244267
<path
245268
v-for="link of links"
@@ -250,31 +273,65 @@ onMounted(() => {
250273
/>
251274
</g>
252275
</svg> -->
253-
<template
254-
v-for="node of nodes"
255-
:key="node.data.module.id"
256-
>
257-
<template v-if="node.data.module.id !== '~root'">
258-
<DisplayModuleId
259-
:id="node.data.module.id"
260-
:ref="(el: any) => nodesRefMap.set(node.data.module.id, el?.$el)"
261-
absolute hover="bg-active" block px2 p1 bg-glass z-graph-node
262-
border="~ base rounded"
263-
:link="true"
264-
:session="session"
265-
:pkg="node.data.module"
266-
:minimal="true"
267-
:style="{
268-
left: `${node.x}px`,
269-
top: `${node.y}px`,
270-
minWidth: graphRender === 'normal' ? `${SPACING.width}px` : undefined,
271-
transform: 'translate(-50%, -50%)',
272-
maxWidth: '400px',
273-
maxHeight: '50px',
274-
overflow: 'hidden',
275-
}"
276-
/>
276+
<template
277+
v-for="node of nodes"
278+
:key="node.data.module.id"
279+
>
280+
<template v-if="node.data.module.id !== '~root'">
281+
<DisplayModuleId
282+
:id="node.data.module.id"
283+
:ref="(el: any) => nodesRefMap.set(node.data.module.id, el?.$el)"
284+
absolute hover="bg-active" block px2 p1 bg-glass z-graph-node
285+
border="~ base rounded"
286+
:link="true"
287+
:session="session"
288+
:pkg="node.data.module"
289+
:minimal="true"
290+
:style="{
291+
left: `${node.x}px`,
292+
top: `${node.y}px`,
293+
minWidth: graphRender === 'normal' ? `${SPACING.width}px` : undefined,
294+
transform: 'translate(-50%, -50%)',
295+
maxWidth: '400px',
296+
maxHeight: '50px',
297+
overflow: 'hidden',
298+
}"
299+
/>
300+
</template>
277301
</template>
278-
</template>
302+
</div>
303+
<div
304+
fixed right-6 bottom-6 z-panel-nav flex="~ col gap-2 items-center"
305+
>
306+
<div w-10 flex="~ items-center justify-center">
307+
<DisplayTimeoutView :content="`${Math.round(scale * 100)}%`" class="text-sm" />
308+
</div>
309+
310+
<div bg-glass rounded-full border border-base shadow>
311+
<button
312+
v-tooltip.left="'Zoom In (Ctrl + =)'"
313+
:disabled="scale >= ZOOM_MAX"
314+
w-10 h-10 rounded-full hover:bg-active op-fade
315+
hover:op100 disabled:op20 disabled:bg-none
316+
disabled:cursor-not-allowed
317+
flex="~ items-center justify-center"
318+
title="Zoom In (Ctrl + =)"
319+
@click="zoomIn()"
320+
>
321+
<div i-ph-magnifying-glass-plus-duotone />
322+
</button>
323+
<button
324+
v-tooltip.left="'Zoom Out (Ctrl + -)'"
325+
:disabled="scale <= ZOOM_MIN"
326+
w-10 h-10 rounded-full hover:bg-active op-fade hover:op100
327+
disabled:op20 disabled:bg-none disabled:cursor-not-allowed
328+
flex="~ items-center justify-center"
329+
title="Zoom Out (Ctrl + -)"
330+
@click="zoomOut()"
331+
>
332+
<div i-ph-magnifying-glass-minus-duotone />
333+
</button>
334+
</div>
335+
</div>
279336
</div>
280337
</template>
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { MaybeElementRef } from '@vueuse/core'
2+
import type { MaybeRef } from 'vue'
3+
import { useEventListener } from '@vueuse/core'
4+
import { ref, toValue } from 'vue'
5+
6+
export function useZoomElement(
7+
target: MaybeElementRef<HTMLElement | null>,
8+
{
9+
wheel = true,
10+
minScale = 0.5,
11+
maxScale = 2,
12+
}: {
13+
wheel?: MaybeRef<boolean>
14+
minScale?: number
15+
maxScale?: number
16+
} = {},
17+
) {
18+
const scale = ref(1)
19+
20+
function zoom(factor: number, clientX?: number, clientY?: number) {
21+
const el = toValue(target)
22+
if (!el)
23+
return
24+
25+
const { left, top, width, height } = el.getBoundingClientRect()
26+
27+
// default to center
28+
const x = clientX ?? (left + width / 2)
29+
const y = clientY ?? (top + height / 2)
30+
31+
const offsetX = x - left
32+
const offsetY = y - top
33+
const oldScale = scale.value
34+
35+
scale.value = Math.max(minScale, Math.min(maxScale, oldScale + factor))
36+
37+
const ratio = scale.value / oldScale
38+
39+
// Adjust scroll so that the zoom center is kept in place
40+
el.scrollLeft = (el.scrollLeft + offsetX) * ratio - offsetX
41+
el.scrollTop = (el.scrollTop + offsetY) * ratio - offsetY
42+
}
43+
44+
function handleWheel(event: WheelEvent) {
45+
if (!toValue(wheel))
46+
return
47+
48+
event.preventDefault()
49+
50+
const zoomFactor = 0.2
51+
zoom(event.deltaY < 0 ? zoomFactor : zoomFactor * -1, event.clientX, event.clientY)
52+
}
53+
54+
function zoomIn(factor = 0.2) {
55+
zoom(factor)
56+
}
57+
58+
function zoomOut(factor = 0.2) {
59+
zoom(factor * -1)
60+
}
61+
62+
useEventListener(target, 'wheel', handleWheel)
63+
64+
return { scale, zoom, zoomIn, zoomOut }
65+
}

0 commit comments

Comments
 (0)