|
| 1 | +<script setup lang="ts"> |
| 2 | +import { ref, watch, onMounted, onBeforeUnmount } from 'vue' |
| 3 | +import type { DynamicScroller } from 'vue-virtual-scroller' |
| 4 | +import type { NodeInfo } from '../../../types' |
| 5 | +import { MINIMAP_CONFIG } from '../utils/minimapColors' |
| 6 | +import { createMinimapInteraction } from '../composables/useMinimapInteraction' |
| 7 | +
|
| 8 | +type NodeTimelineItem = NodeInfo & { _uniqueKey: string } |
| 9 | +
|
| 10 | +const props = defineProps<{ |
| 11 | + nodes: NodeTimelineItem[] |
| 12 | + scrollerRef: InstanceType<typeof DynamicScroller> | null |
| 13 | + selectedNodeId: number | null |
| 14 | + safeScrollToItem: (index: number) => Promise<boolean> |
| 15 | +}>() |
| 16 | +
|
| 17 | +const canvasRef = ref<HTMLCanvasElement | null>(null) |
| 18 | +const containerRef = ref<HTMLDivElement | null>(null) |
| 19 | +const scrollerRefLocal = ref<InstanceType<typeof DynamicScroller> | null>(null) |
| 20 | +const nodesRef = ref<NodeTimelineItem[]>([]) |
| 21 | +const selectedNodeIdRef = ref<number | null>(null) |
| 22 | +
|
| 23 | +watch(() => props.scrollerRef, (v) => { scrollerRefLocal.value = v }) |
| 24 | +watch(() => props.nodes, (v) => { nodesRef.value = v }, { immediate: true }) |
| 25 | +watch(() => props.selectedNodeId, (v) => { selectedNodeIdRef.value = v }, { immediate: true }) |
| 26 | +
|
| 27 | +const { |
| 28 | + handleClick, |
| 29 | + handleMouseDown, |
| 30 | + redraw, |
| 31 | + updateViewport, |
| 32 | + handleResize, |
| 33 | +} = createMinimapInteraction({ |
| 34 | + canvasRef, |
| 35 | + scrollerRef: scrollerRefLocal, |
| 36 | + nodes: nodesRef, |
| 37 | + selectedNodeId: selectedNodeIdRef, |
| 38 | + safeScrollToItem: (index: number) => props.safeScrollToItem(index), |
| 39 | +}) |
| 40 | +
|
| 41 | +let resizeObserver: ResizeObserver | null = null |
| 42 | +let scrollerEl: HTMLElement | null = null |
| 43 | +let scrollRafId: number | null = null |
| 44 | +let redrawRafId: number | null = null |
| 45 | +
|
| 46 | +const scheduleRedraw = () => { |
| 47 | + if (redrawRafId != null) return |
| 48 | + redrawRafId = requestAnimationFrame(() => { |
| 49 | + redrawRafId = null |
| 50 | + redraw() |
| 51 | + }) |
| 52 | +} |
| 53 | +
|
| 54 | +const onScrollerScroll = () => { |
| 55 | + if (scrollRafId != null) return |
| 56 | + scrollRafId = requestAnimationFrame(() => { |
| 57 | + scrollRafId = null |
| 58 | + updateViewport() |
| 59 | + }) |
| 60 | +} |
| 61 | +
|
| 62 | +const observeScroller = () => { |
| 63 | + const scroller = props.scrollerRef as unknown |
| 64 | + if (!scroller) return |
| 65 | + const rootCandidate = (scroller as { $el?: unknown }).$el ?? scroller |
| 66 | + if (!(rootCandidate instanceof HTMLElement)) return |
| 67 | + const nested = rootCandidate.querySelector('.vue-recycle-scroller') as HTMLElement | null |
| 68 | + const el = nested ?? rootCandidate |
| 69 | +
|
| 70 | + if (scrollerEl === el) return |
| 71 | + detachScroller() |
| 72 | + scrollerEl = el |
| 73 | + el.addEventListener('scroll', onScrollerScroll, { passive: true }) |
| 74 | +} |
| 75 | +
|
| 76 | +const detachScroller = () => { |
| 77 | + if (scrollerEl) { |
| 78 | + scrollerEl.removeEventListener('scroll', onScrollerScroll) |
| 79 | + scrollerEl = null |
| 80 | + } |
| 81 | + if (scrollRafId != null) { |
| 82 | + cancelAnimationFrame(scrollRafId) |
| 83 | + scrollRafId = null |
| 84 | + } |
| 85 | +} |
| 86 | +
|
| 87 | +const visible = ref(false) |
| 88 | +
|
| 89 | +watch(() => props.nodes.length, () => { |
| 90 | + visible.value = props.nodes.length >= MINIMAP_CONFIG.minNodesToShow |
| 91 | +}, { immediate: true }) |
| 92 | +
|
| 93 | +watch(() => props.nodes, () => { |
| 94 | + scheduleRedraw() |
| 95 | +}, { flush: 'post' }) |
| 96 | +
|
| 97 | +watch(() => props.selectedNodeId, () => { |
| 98 | + scheduleRedraw() |
| 99 | +}, { flush: 'post' }) |
| 100 | +
|
| 101 | +watch(() => props.scrollerRef, () => { |
| 102 | + observeScroller() |
| 103 | + scheduleRedraw() |
| 104 | +}, { flush: 'post' }) |
| 105 | +
|
| 106 | +onMounted(() => { |
| 107 | + if (containerRef.value) { |
| 108 | + resizeObserver = new ResizeObserver((entries) => { |
| 109 | + handleResize(entries) |
| 110 | + }) |
| 111 | + resizeObserver.observe(containerRef.value) |
| 112 | + } |
| 113 | + observeScroller() |
| 114 | + scheduleRedraw() |
| 115 | +}) |
| 116 | +
|
| 117 | +onBeforeUnmount(() => { |
| 118 | + if (resizeObserver) { |
| 119 | + resizeObserver.disconnect() |
| 120 | + resizeObserver = null |
| 121 | + } |
| 122 | + detachScroller() |
| 123 | + if (redrawRafId != null) { |
| 124 | + cancelAnimationFrame(redrawRafId) |
| 125 | + redrawRafId = null |
| 126 | + } |
| 127 | +}) |
| 128 | +</script> |
| 129 | + |
| 130 | +<template> |
| 131 | + <div |
| 132 | + v-if="visible" |
| 133 | + ref="containerRef" |
| 134 | + class="node-timeline-minimap" |
| 135 | + :style="{ width: `${MINIMAP_CONFIG.width}px` }" |
| 136 | + > |
| 137 | + <canvas |
| 138 | + ref="canvasRef" |
| 139 | + :width="MINIMAP_CONFIG.width" |
| 140 | + :height="100" |
| 141 | + class="minimap-canvas" |
| 142 | + @click="handleClick" |
| 143 | + @mousedown="handleMouseDown" |
| 144 | + /> |
| 145 | + </div> |
| 146 | +</template> |
| 147 | + |
| 148 | +<style scoped> |
| 149 | +.node-timeline-minimap { |
| 150 | + position: absolute; |
| 151 | + right: 0; |
| 152 | + top: 0; |
| 153 | + bottom: 0; |
| 154 | + z-index: 10; |
| 155 | + pointer-events: auto; |
| 156 | + cursor: pointer; |
| 157 | + user-select: none; |
| 158 | + background: var(--vscode-scrollbarSlider-background, rgba(128, 128, 128, 0.05)); |
| 159 | + border-left: 1px solid var(--vscode-panel-border, rgba(128, 128, 128, 0.1)); |
| 160 | +} |
| 161 | +
|
| 162 | +.minimap-canvas { |
| 163 | + display: block; |
| 164 | + width: 100%; |
| 165 | + height: 100%; |
| 166 | +} |
| 167 | +</style> |
0 commit comments