Commit 6be9711c by pangchong

feat(editor): 优化大纲视图和XML树视图滚动条交互体验

- 为大纲视图和XML树视图添加自定义虚拟列表滚动条,解决节点过多时滚动条拖拽困难问题
- 实现滚动条拖拽与点击轨道跳转功能,提升操作流畅度
- 使用ResizeObserver动态响应滚动区域尺寸变化,保证滚动条高度准确
- 隐藏原生滚动条,统一滚动条样式风格
- 优化大纲视图节点的样式和交互细节,增加图标和序号徽标的动态效果
- XML树视图节点支持展开折叠、批量管理模式下复选框显示
- 增加大纲视图和XML树视图滚动事件处理,确保滚动状态同步更新
parent 2b95d913
......@@ -60,3 +60,19 @@
.app-modal {
border-radius: 12px;
}
/* OverlayScrollbars 主题重写 - 解决虚拟列表节点过多时滑块极小难拖拽的问题 */
.os-theme-custom {
--os-size: 10px;
--os-padding-perpendicular: 1px;
--os-padding-axis: 2px;
--os-track-bg: rgba(128, 128, 128, 0.05);
--os-track-bg-hover: rgba(128, 128, 128, 0.12);
--os-track-bg-active: rgba(128, 128, 128, 0.18);
--os-track-border-radius: 6px;
--os-handle-bg: rgba(128, 128, 128, 0.4);
--os-handle-bg-hover: var(--primary-color, #705bf6);
--os-handle-bg-active: var(--primary-color, #705bf6);
--os-handle-border-radius: 6px;
--os-handle-min-size: 35px;
}
\ No newline at end of file
......@@ -13,3 +13,5 @@ export interface DocOutlineProps {
pattern: string
active: boolean
}
export const MIN_THUMB_SIZE = 36
import { useEditorStore } from '@/store/editor'
import type { DocOutlineProps, OutlineItem } from '../constants'
import { MIN_THUMB_SIZE } from '../constants'
export function useDocOutlineView(props: DocOutlineProps) {
const editorStore = useEditorStore()
......@@ -362,13 +363,105 @@ export function useDocOutlineView(props: DocOutlineProps) {
}
)
const handleContainerScroll = (e: Event) => {
if (outlineViewportRef.value) {
scrollTop.value = outlineViewportRef.value.scrollTop
viewportHeight.value = outlineViewportRef.value.clientHeight
}
handleOutlineScroll(e)
}
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
if (outlineViewportRef.value) {
viewportHeight.value = outlineViewportRef.value.clientHeight
resizeObserver = new ResizeObserver((entries) => {
if (entries[0]) {
viewportHeight.value = entries[0].contentRect.height
}
})
resizeObserver.observe(outlineViewportRef.value)
}
})
onUnmounted(() => {
resizeObserver?.disconnect()
resizeObserver = null
document.removeEventListener('mousemove', handleThumbDragging)
document.removeEventListener('mouseup', handleThumbDragEnd)
})
const showCustomScrollbar = computed(() => {
return totalHeight.value > viewportHeight.value && viewportHeight.value > 0
})
const thumbHeight = computed(() => {
if (!viewportHeight.value || !totalHeight.value) return MIN_THUMB_SIZE
const raw = (viewportHeight.value / totalHeight.value) * viewportHeight.value
return Math.max(MIN_THUMB_SIZE, Math.min(viewportHeight.value, Math.round(raw)))
})
const thumbTop = computed(() => {
const maxScroll = totalHeight.value - viewportHeight.value
const maxThumb = viewportHeight.value - thumbHeight.value
if (maxScroll <= 0 || maxThumb <= 0) return 0
return (scrollTop.value / maxScroll) * maxThumb
})
let isDragging = false
let startY = 0
let startScrollTop = 0
const handleThumbDragStart = (e: MouseEvent) => {
isDragging = true
startY = e.clientY
startScrollTop = outlineViewportRef.value?.scrollTop || 0
document.addEventListener('mousemove', handleThumbDragging)
document.addEventListener('mouseup', handleThumbDragEnd)
}
const handleThumbDragging = (e: MouseEvent) => {
if (!isDragging || !outlineViewportRef.value) return
const deltaY = e.clientY - startY
const maxScroll = totalHeight.value - viewportHeight.value
const maxThumb = viewportHeight.value - thumbHeight.value
if (maxThumb <= 0) return
const scrollDelta = (deltaY / maxThumb) * maxScroll
outlineViewportRef.value.scrollTop = startScrollTop + scrollDelta
}
const handleThumbDragEnd = () => {
isDragging = false
document.removeEventListener('mousemove', handleThumbDragging)
document.removeEventListener('mouseup', handleThumbDragEnd)
}
const handleTrackClick = (e: MouseEvent) => {
if (!outlineViewportRef.value) return
const trackRect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const clickY = e.clientY - trackRect.top
const maxScroll = totalHeight.value - viewportHeight.value
const maxThumb = viewportHeight.value - thumbHeight.value
if (maxThumb <= 0) return
const targetThumbTop = clickY - thumbHeight.value / 2
const clampedThumbTop = Math.max(0, Math.min(maxThumb, targetThumbTop))
const targetScrollTop = (clampedThumbTop / maxThumb) * maxScroll
outlineViewportRef.value.scrollTop = targetScrollTop
}
return {
editorStore,
outlineViewportRef,
filteredOutlineList,
activeOutlineId,
showCustomScrollbar,
thumbHeight,
thumbTop,
handleContainerScroll,
handleThumbDragStart,
handleTrackClick,
handleOutlineClick,
handleOutlineScroll,
totalHeight,
startOffset,
visibleOutlineItems
......
<template>
<div ref="outlineViewportRef" class="flex-1 overflow-y-auto p-2 pb-14 relative select-none" @scroll="handleScroll">
<div class="outline-wrapper flex-1 relative overflow-hidden flex flex-col min-h-0">
<div
ref="outlineViewportRef"
class="flex-1 overflow-y-auto p-2 pb-14 relative select-none outline-container custom-scrollbar-hide"
@scroll="handleContainerScroll"
>
<div v-if="filteredOutlineList.length > 0" :style="{ height: totalHeight + 'px', position: 'relative' }">
<div :style="{ transform: `translateY(${startOffset}px)` }" class="absolute top-0 left-0 right-0">
<div
......@@ -96,6 +101,16 @@
<n-empty description="暂无匹配的大纲项" />
</div>
</div>
<!-- 专属大纲虚拟列表滚动条 (保底 36px 拖拽块) -->
<div v-if="showCustomScrollbar" class="v-scrollbar-track" @mousedown="handleTrackClick">
<div
class="v-scrollbar-thumb"
:style="{ height: `${thumbHeight}px`, transform: `translateY(${thumbTop}px)` }"
@mousedown.stop.prevent="handleThumbDragStart"
></div>
</div>
</div>
</template>
<script setup lang="ts">
......@@ -122,18 +137,18 @@ const {
outlineViewportRef,
filteredOutlineList,
activeOutlineId,
showCustomScrollbar,
thumbHeight,
thumbTop,
handleContainerScroll,
handleThumbDragStart,
handleTrackClick,
handleOutlineClick,
handleOutlineScroll,
totalHeight,
startOffset,
visibleOutlineItems
} = useDocOutlineView(props)
const handleScroll = (e: Event) => {
handleOutlineScroll(e)
emit('scroll', e)
}
const getItemIcon = (tagName: string) => {
switch (tagName) {
case 'CEP':
......@@ -171,3 +186,53 @@ defineExpose({
outlineViewportRef
})
</script>
<style scoped>
.custom-scrollbar-hide {
scrollbar-width: none !important;
-ms-overflow-style: none !important;
}
.custom-scrollbar-hide::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
}
.v-scrollbar-track {
position: absolute;
top: 4px;
right: 2px;
bottom: 4px;
width: 10px;
background: transparent;
z-index: 40;
cursor: pointer;
border-radius: 5px;
transition: background 0.2s ease;
}
.v-scrollbar-track:hover {
background: rgba(128, 128, 128, 0.12);
}
.v-scrollbar-thumb {
position: absolute;
top: 0;
right: 0;
width: 8px;
background: rgba(128, 128, 128, 0.35);
border-radius: 4px;
cursor: grab;
transition:
background 0.15s ease,
width 0.15s ease;
}
.v-scrollbar-track:hover .v-scrollbar-thumb,
.v-scrollbar-thumb:hover {
width: 10px;
background: var(--primary-color, #705bf6);
}
.v-scrollbar-thumb:active {
cursor: grabbing;
width: 10px;
background: var(--primary-color, #705bf6);
}
</style>
......@@ -21,3 +21,5 @@ export interface XmlTreeViewProps {
hasCustomColor: (item: FlatNode) => boolean
getNodeStyle: (item: FlatNode) => any
}
export const MIN_THUMB_SIZE = 36
import { useEditorStore } from '@/store/editor'
import type { XmlTreeViewProps } from '../constants'
import { MIN_THUMB_SIZE } from '../constants'
export function useXmlTreeView(_props: XmlTreeViewProps) {
export function useXmlTreeView(props: XmlTreeViewProps, emit: any) {
const editorStore = useEditorStore()
const viewportRef = ref<HTMLElement | null>(null)
const scrollTop = ref(0)
const viewportHeight = ref(0)
const handleContainerScroll = (e: Event) => {
if (viewportRef.value) {
scrollTop.value = viewportRef.value.scrollTop
viewportHeight.value = viewportRef.value.clientHeight
}
emit('scroll', e)
}
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
if (viewportRef.value) {
viewportHeight.value = viewportRef.value.clientHeight
resizeObserver = new ResizeObserver((entries) => {
if (entries[0]) {
viewportHeight.value = entries[0].contentRect.height
}
})
resizeObserver.observe(viewportRef.value)
}
})
onUnmounted(() => {
resizeObserver?.disconnect()
resizeObserver = null
document.removeEventListener('mousemove', handleThumbDragging)
document.removeEventListener('mouseup', handleThumbDragEnd)
})
const showCustomScrollbar = computed(() => {
return props.totalHeight > viewportHeight.value && viewportHeight.value > 0
})
const thumbHeight = computed(() => {
if (!viewportHeight.value || !props.totalHeight) return MIN_THUMB_SIZE
const raw = (viewportHeight.value / props.totalHeight) * viewportHeight.value
return Math.max(MIN_THUMB_SIZE, Math.min(viewportHeight.value, Math.round(raw)))
})
const thumbTop = computed(() => {
const maxScroll = props.totalHeight - viewportHeight.value
const maxThumb = viewportHeight.value - thumbHeight.value
if (maxScroll <= 0 || maxThumb <= 0) return 0
return (scrollTop.value / maxScroll) * maxThumb
})
let isDragging = false
let startY = 0
let startScrollTop = 0
const handleThumbDragStart = (e: MouseEvent) => {
isDragging = true
startY = e.clientY
startScrollTop = viewportRef.value?.scrollTop || 0
document.addEventListener('mousemove', handleThumbDragging)
document.addEventListener('mouseup', handleThumbDragEnd)
}
const handleThumbDragging = (e: MouseEvent) => {
if (!isDragging || !viewportRef.value) return
const deltaY = e.clientY - startY
const maxScroll = props.totalHeight - viewportHeight.value
const maxThumb = viewportHeight.value - thumbHeight.value
if (maxThumb <= 0) return
const scrollDelta = (deltaY / maxThumb) * maxScroll
viewportRef.value.scrollTop = startScrollTop + scrollDelta
}
const handleThumbDragEnd = () => {
isDragging = false
document.removeEventListener('mousemove', handleThumbDragging)
document.removeEventListener('mouseup', handleThumbDragEnd)
}
const handleTrackClick = (e: MouseEvent) => {
if (!viewportRef.value) return
const trackRect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const clickY = e.clientY - trackRect.top
const maxScroll = props.totalHeight - viewportHeight.value
const maxThumb = viewportHeight.value - thumbHeight.value
if (maxThumb <= 0) return
const targetThumbTop = clickY - thumbHeight.value / 2
const clampedThumbTop = Math.max(0, Math.min(maxThumb, targetThumbTop))
const targetScrollTop = (clampedThumbTop / maxThumb) * maxScroll
viewportRef.value.scrollTop = targetScrollTop
}
const getGraphicKey = (node: any): string => {
let key = node.attributes?.KEY || node.attributes?.GRAPHICKEY || node.attributes?.ID || ''
if (!key && node.children) {
......@@ -56,6 +147,12 @@ export function useXmlTreeView(_props: XmlTreeViewProps) {
return {
editorStore,
viewportRef,
showCustomScrollbar,
thumbHeight,
thumbTop,
handleContainerScroll,
handleThumbDragStart,
handleTrackClick,
getGraphicKey,
getTreeItemRefNodes,
getTreeItemTargetId,
......
<template>
<div ref="viewportRef" class="flex-1 overflow-y-auto p-2 pb-14 relative select-none virtual-tree-container" @scroll="emit('scroll', $event)">
<div class="virtual-tree-wrapper flex-1 relative overflow-hidden flex flex-col min-h-0">
<div
ref="viewportRef"
class="flex-1 overflow-y-auto p-2 pb-14 relative select-none virtual-tree-container custom-scrollbar-hide"
@scroll="handleContainerScroll"
>
<div v-if="flatList.length > 0" :style="{ height: totalHeight + 'px', position: 'relative' }">
<!-- 背景连接线层 - 绘制连续的垂直虚线 -->
<div class="tree-lines-layer">
......@@ -90,7 +95,10 @@
<div v-else class="w-4 h-4 mr-1"></div>
<!-- Icon -->
<div class="mr-1.5 flex items-center justify-center shrink-0 z-10" :class="[effectiveSelectedId === item.id ? 'text-white' : '']">
<div
class="mr-1.5 flex items-center justify-center shrink-0 z-10"
:class="[effectiveSelectedId === item.id ? 'text-white' : '']"
>
<n-icon size="16">
<component :is="getNodeIcon(item)" />
</n-icon>
......@@ -164,6 +172,16 @@
<n-empty description="暂无节点数据" />
</div>
</div>
<!-- 专属虚拟列表滚动条 (保底 36px 拖拽块,解决节点过多微缩无法抓取) -->
<div v-if="showCustomScrollbar" class="v-scrollbar-track" @mousedown="handleTrackClick">
<div
class="v-scrollbar-thumb"
:style="{ height: `${thumbHeight}px`, transform: `translateY(${thumbTop}px)` }"
@mousedown.stop.prevent="handleThumbDragStart"
></div>
</div>
</div>
</template>
<script setup lang="ts">
......@@ -182,7 +200,19 @@ const emit = defineEmits<{
(e: 'toggleCheck', id: string, checked: boolean): void
}>()
const { editorStore, viewportRef, getTreeItemRefNodes, getTreeItemTargetId, handleTreeRefJump } = useXmlTreeView(props)
const {
editorStore,
viewportRef,
showCustomScrollbar,
thumbHeight,
thumbTop,
handleContainerScroll,
handleThumbDragStart,
handleTrackClick,
getTreeItemRefNodes,
getTreeItemTargetId,
handleTreeRefJump
} = useXmlTreeView(props, emit)
defineExpose({
viewportRef
......@@ -190,6 +220,56 @@ defineExpose({
</script>
<style scoped>
/* 隐匿原生 WebKit / Firefox 滚动条 */
.custom-scrollbar-hide {
scrollbar-width: none !important;
-ms-overflow-style: none !important;
}
.custom-scrollbar-hide::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
}
/* 专属虚拟列表滚动条轨道 */
.v-scrollbar-track {
position: absolute;
top: 4px;
right: 2px;
bottom: 4px;
width: 10px;
background: transparent;
z-index: 40;
cursor: pointer;
border-radius: 5px;
transition: background 0.2s ease;
}
.v-scrollbar-track:hover {
background: rgba(128, 128, 128, 0.12);
}
.v-scrollbar-thumb {
position: absolute;
top: 0;
right: 0;
width: 8px;
background: rgba(128, 128, 128, 0.35);
border-radius: 4px;
cursor: grab;
transition:
background 0.15s ease,
width 0.15s ease;
}
.v-scrollbar-track:hover .v-scrollbar-thumb,
.v-scrollbar-thumb:hover {
width: 10px;
background: var(--primary-color, #705bf6);
}
.v-scrollbar-thumb:active {
cursor: grabbing;
width: 10px;
background: var(--primary-color, #705bf6);
}
.virtual-tree-container {
position: relative;
overflow-y: auto;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment