Skip to content

Commit 7eaccae

Browse files
committed
feat: minimap
1 parent 567c15d commit 7eaccae

10 files changed

Lines changed: 543 additions & 10 deletions

src/views/ProcessView.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ const {
9595
toggleFollowLast,
9696
stopFollowOnScrollUp,
9797
scrollToNode,
98+
safeScrollToItem,
9899
nodeNavSearchText,
99100
normalizedNodeNavSearchText,
100101
nodeNavFailedOnly,
@@ -227,6 +228,7 @@ void fileInputRef
227228
:on-toggle-node-nav-failed-only="toggleNodeNavFailedOnly"
228229
:on-select-node-nav="scrollToNode"
229230
:on-manual-scroll-up="stopFollowOnScrollUp"
231+
:safe-scroll-to-item="safeScrollToItem"
230232
/>
231233

232234
<primary-log-selection-modal

src/views/process/components/NodeTimelineList.vue

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
<script setup lang="ts">
2+
import { ref } from 'vue'
23
import { NEmpty } from 'naive-ui'
34
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'
45
import type { NodeInfo } from '../../../types'
56
import NodeCard from '../../../components/NodeCard.vue'
7+
import NodeTimelineMinimap from './NodeTimelineMinimap.vue'
68
79
type NodeTimelineItem = NodeInfo & {
810
_uniqueKey: string
@@ -19,6 +21,8 @@ const props = withDefaults(defineProps<{
1921
scrollerStyle?: string
2022
wrapperStyle?: string
2123
captureWheelUp?: boolean
24+
selectedNodeId?: number | null
25+
safeScrollToItem?: (index: number) => Promise<boolean>
2226
}>(), {
2327
selectedTaskKey: null,
2428
isVscodeLaunchEmbed: false,
@@ -28,6 +32,8 @@ const props = withDefaults(defineProps<{
2832
scrollerStyle: 'height: 100%',
2933
wrapperStyle: 'height: 100%; display: flex; flex-direction: column; position: relative',
3034
captureWheelUp: false,
35+
selectedNodeId: null,
36+
safeScrollToItem: undefined,
3137
})
3238
3339
const emit = defineEmits<{
@@ -39,16 +45,19 @@ const emit = defineEmits<{
3945
'scroller-mounted': [scroller: InstanceType<typeof DynamicScroller> | null]
4046
}>()
4147
42-
const setDynamicScrollerRef = (value: Element | object | null) => {
43-
emit('scroller-mounted', value as InstanceType<typeof DynamicScroller> | null)
44-
}
45-
4648
const handleWheel = (event: WheelEvent) => {
4749
if (!props.captureWheelUp) return
4850
if (event.deltaY < 0) {
4951
emit('manual-scroll-up')
5052
}
5153
}
54+
55+
const localScrollerRef = ref<InstanceType<typeof DynamicScroller> | null>(null)
56+
57+
const setLocalScrollerRef = (value: Element | object | null) => {
58+
localScrollerRef.value = value as InstanceType<typeof DynamicScroller> | null
59+
emit('scroller-mounted', value as InstanceType<typeof DynamicScroller> | null)
60+
}
5261
</script>
5362

5463
<template>
@@ -58,7 +67,7 @@ const handleWheel = (event: WheelEvent) => {
5867
</div>
5968
<DynamicScroller
6069
v-else
61-
:ref="setDynamicScrollerRef"
70+
:ref="setLocalScrollerRef"
6271
:key="selectedTaskKey ?? undefined"
6372
:items="nodes"
6473
:min-item-size="40"
@@ -99,13 +108,28 @@ const handleWheel = (event: WheelEvent) => {
99108
<div class="virtual-scroller-overscroll-padding" style="height: 100vh; pointer-events: none; opacity: 0;"></div>
100109
</template>
101110
</DynamicScroller>
111+
<node-timeline-minimap
112+
v-if="safeScrollToItem"
113+
:nodes="nodes"
114+
:scroller-ref="localScrollerRef"
115+
:selected-node-id="selectedNodeId ?? null"
116+
:safe-scroll-to-item="safeScrollToItem"
117+
/>
102118
</div>
103119
</template>
104120

105121
<style scoped>
106122
.virtual-scroller {
107123
/* 禁用浏览器默认的滚动锚定,防止虚拟列表在内部元素高频收起/展开时自动乱滚位置 */
108124
overflow-anchor: none;
125+
/* 为右侧的 minimap 预留空间,避免与卡片内容重叠 */
126+
padding-right: 16px;
127+
}
128+
/* 隐藏原生滚动条,让 minimap 完美充当滚动条 */
129+
.virtual-scroller::-webkit-scrollbar {
130+
width: 0;
131+
height: 0;
132+
display: none;
109133
}
110134
/* 确保动态条目也避免锚定争夺 */
111135
.virtual-scroller :deep(*) {
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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>

src/views/process/components/ProcessContentSection.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ const props = defineProps<{
7171
onToggleNodeNavFailedOnly: () => void
7272
onSelectNodeNav: (index: number) => void
7373
onManualScrollUp: () => void
74+
safeScrollToItem?: (index: number) => Promise<boolean>
7475
}>()
7576
</script>
7677

@@ -141,6 +142,7 @@ const props = defineProps<{
141142
:bridge-reveal-task="props.bridgeRevealTask"
142143
:set-task-list-panel-ref="props.setTaskListPanelRef"
143144
:set-node-nav-panel-ref="props.setNodeNavPanelRef"
145+
:safe-scroll-to-item="props.safeScrollToItem"
144146
@update:task-list-size="props.onUpdateTaskListSize"
145147
@update:node-nav-size="props.onUpdateNodeNavSize"
146148
@toggle-task-list="props.onToggleTaskList"

src/views/process/components/ProcessDesktopPane.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const props = defineProps<{
4747
bridgeRevealTask?: ((task: string) => Promise<void>) | null
4848
setTaskListPanelRef?: (instance: unknown | null) => void
4949
setNodeNavPanelRef?: (instance: unknown | null) => void
50+
safeScrollToItem?: (index: number) => Promise<boolean>
5051
}>()
5152
5253
const emit = defineEmits<{
@@ -146,6 +147,8 @@ const handleSelectNodeNavItem = (item: NodeNavViewItem) => {
146147
:is-vscode-launch-embed="isVscodeLaunchEmbed"
147148
:bridge-request-task-doc="bridgeRequestTaskDoc"
148149
:bridge-reveal-task="bridgeRevealTask"
150+
:selected-node-id="selectedNodeId ?? null"
151+
:safe-scroll-to-item="safeScrollToItem"
149152
@manual-scroll-up="emit('manual-scroll-up')"
150153
@scroller-mounted="emit('scroller-mounted', $event)"
151154
@select-node="emit('select-node', $event)"

src/views/process/components/ProcessTimelineListPane.vue

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script setup lang="ts">
22
import NodeTimelineList from './NodeTimelineList.vue'
33
import type { NodeInfo } from '../../../types'
4+
import type { DynamicScroller } from 'vue-virtual-scroller'
45
56
type NodeTimelineItem = NodeInfo & { _uniqueKey: string }
67
@@ -15,6 +16,8 @@ const props = withDefaults(defineProps<{
1516
scrollerStyle?: string
1617
wrapperStyle?: string
1718
captureWheelUp?: boolean
19+
selectedNodeId?: number | null
20+
safeScrollToItem?: (index: number) => Promise<boolean>
1821
}>(), {
1922
selectedTaskKey: null,
2023
isVscodeLaunchEmbed: false,
@@ -24,10 +27,12 @@ const props = withDefaults(defineProps<{
2427
scrollerStyle: 'height: 100%',
2528
wrapperStyle: 'height: 100%; display: flex; flex-direction: column; position: relative',
2629
captureWheelUp: true,
30+
selectedNodeId: null,
31+
safeScrollToItem: undefined,
2732
})
2833
2934
const emit = defineEmits<{
30-
'scroller-mounted': [scroller: object | null]
35+
'scroller-mounted': [scroller: InstanceType<typeof DynamicScroller> | null]
3136
'manual-scroll-up': []
3237
'select-node': [node: NodeInfo]
3338
'select-action': [node: NodeInfo]
@@ -48,6 +53,8 @@ const emit = defineEmits<{
4853
:scroller-style="props.scrollerStyle"
4954
:wrapper-style="props.wrapperStyle"
5055
:capture-wheel-up="props.captureWheelUp"
56+
:selected-node-id="props.selectedNodeId ?? null"
57+
:safe-scroll-to-item="props.safeScrollToItem"
5158
@scroller-mounted="emit('scroller-mounted', $event)"
5259
@manual-scroll-up="emit('manual-scroll-up')"
5360
@select-node="emit('select-node', $event)"

src/views/process/composables/processView/followNav.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const useProcessFollowNav = (
2323
toggleFollowLast,
2424
stopFollowOnScrollUp,
2525
scrollToNode,
26+
safeScrollToItem,
2627
} = useRealtimeFollow({
2728
tasks: computed(() => options.props.tasks),
2829
selectedTask: computed(() => options.props.selectedTask),
@@ -63,6 +64,7 @@ export const useProcessFollowNav = (
6364
toggleFollowLast,
6465
stopFollowOnScrollUp,
6566
scrollToNode,
67+
safeScrollToItem,
6668
nodeNavSearchText,
6769
normalizedNodeNavSearchText,
6870
nodeNavFailedOnly,

0 commit comments

Comments
 (0)