Commit 6be9711c by pangchong

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

- 为大纲视图和XML树视图添加自定义虚拟列表滚动条,解决节点过多时滚动条拖拽困难问题
- 实现滚动条拖拽与点击轨道跳转功能,提升操作流畅度
- 使用ResizeObserver动态响应滚动区域尺寸变化,保证滚动条高度准确
- 隐藏原生滚动条,统一滚动条样式风格
- 优化大纲视图节点的样式和交互细节,增加图标和序号徽标的动态效果
- XML树视图节点支持展开折叠、批量管理模式下复选框显示
- 增加大纲视图和XML树视图滚动事件处理,确保滚动状态同步更新
parent 2b95d913
...@@ -59,4 +59,20 @@ ...@@ -59,4 +59,20 @@
/* 全局弹框组件样式 */ /* 全局弹框组件样式 */
.app-modal { .app-modal {
border-radius: 12px; 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 { ...@@ -13,3 +13,5 @@ export interface DocOutlineProps {
pattern: string pattern: string
active: boolean active: boolean
} }
export const MIN_THUMB_SIZE = 36
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import type { DocOutlineProps, OutlineItem } from '../constants' import type { DocOutlineProps, OutlineItem } from '../constants'
import { MIN_THUMB_SIZE } from '../constants'
export function useDocOutlineView(props: DocOutlineProps) { export function useDocOutlineView(props: DocOutlineProps) {
const editorStore = useEditorStore() const editorStore = useEditorStore()
...@@ -362,13 +363,105 @@ export function useDocOutlineView(props: DocOutlineProps) { ...@@ -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 { return {
editorStore, editorStore,
outlineViewportRef, outlineViewportRef,
filteredOutlineList, filteredOutlineList,
activeOutlineId, activeOutlineId,
showCustomScrollbar,
thumbHeight,
thumbTop,
handleContainerScroll,
handleThumbDragStart,
handleTrackClick,
handleOutlineClick, handleOutlineClick,
handleOutlineScroll,
totalHeight, totalHeight,
startOffset, startOffset,
visibleOutlineItems visibleOutlineItems
......
<template> <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 v-if="filteredOutlineList.length > 0" :style="{ height: totalHeight + 'px', position: 'relative' }"> <div
<div :style="{ transform: `translateY(${startOffset}px)` }" class="absolute top-0 left-0 right-0"> ref="outlineViewportRef"
<div class="flex-1 overflow-y-auto p-2 pb-14 relative select-none outline-container custom-scrollbar-hide"
v-for="item in visibleOutlineItems" @scroll="handleContainerScroll"
:key="item.id" >
:data-outline-id="item.id" <div v-if="filteredOutlineList.length > 0" :style="{ height: totalHeight + 'px', position: 'relative' }">
class="relative h-[27px] px-2 rounded-md text-xs cursor-pointer flex items-center justify-between transition-all duration-150 group/outline" <div :style="{ transform: `translateY(${startOffset}px)` }" class="absolute top-0 left-0 right-0">
:style="{ paddingLeft: getItemPaddingLeft(item.level) }"
:class="[
activeOutlineId === item.id
? 'bg-primary text-white font-semibold shadow-sm'
: item.level === 0
? 'bg-primary/10 dark:bg-primary/20 border border-primary/30 text-color1 dark:text-white hover:bg-primary/15'
: item.level === 1
? 'hover:bg-fill-3 text-color1 font-bold'
: 'hover:bg-fill-3 text-color2 font-normal'
]"
@click="handleOutlineClick(item.id)"
>
<!-- 顶层主任务/文档卡片左侧修饰条 -->
<div <div
v-if="item.level === 0 && activeOutlineId !== item.id" v-for="item in visibleOutlineItems"
class="absolute left-0 top-1.5 bottom-1.5 w-[3px] bg-primary rounded-r" :key="item.id"
></div> :data-outline-id="item.id"
class="relative h-[27px] px-2 rounded-md text-xs cursor-pointer flex items-center justify-between transition-all duration-150 group/outline"
:style="{ paddingLeft: getItemPaddingLeft(item.level) }"
:class="[
activeOutlineId === item.id
? 'bg-primary text-white font-semibold shadow-sm'
: item.level === 0
? 'bg-primary/10 dark:bg-primary/20 border border-primary/30 text-color1 dark:text-white hover:bg-primary/15'
: item.level === 1
? 'hover:bg-fill-3 text-color1 font-bold'
: 'hover:bg-fill-3 text-color2 font-normal'
]"
@click="handleOutlineClick(item.id)"
>
<!-- 顶层主任务/文档卡片左侧修饰条 -->
<div
v-if="item.level === 0 && activeOutlineId !== item.id"
class="absolute left-0 top-1.5 bottom-1.5 w-[3px] bg-primary rounded-r"
></div>
<div class="flex items-center space-x-1.5 min-w-0 flex-1 mr-1.5"> <div class="flex items-center space-x-1.5 min-w-0 flex-1 mr-1.5">
<!-- 大纲类型图标 --> <!-- 大纲类型图标 -->
<n-icon
v-if="getItemIcon(item.tagName)"
size="14"
class="shrink-0 transition-transform group-hover/outline:scale-110"
:class="[
activeOutlineId === item.id
? 'text-white'
: item.level === 0
? 'text-primary dark:text-primary-hover font-bold'
: item.tagName === 'WARNING' || item.tagName === 'CAUTION'
? 'text-amber-500 dark:text-amber-400'
: item.tagName === 'GRAPHIC'
? 'text-indigo-500 dark:text-indigo-400'
: item.tagName === 'TABLE'
? 'text-emerald-500 dark:text-emerald-400'
: 'text-primary/70'
]"
>
<component :is="getItemIcon(item.tagName)" />
</n-icon>
<!-- 序号徽标 (如 3., A., (1), (2)) -->
<span
v-if="
item.seqNum &&
!item.seqNum.includes('🖼️') &&
!item.seqNum.includes('📊') &&
!item.seqNum.includes('⚡') &&
!item.seqNum.includes('📝')
"
class="font-mono font-bold shrink-0 text-[11px]"
:class="[activeOutlineId === item.id ? 'text-white' : 'text-primary dark:text-primary-hover']"
>
{{ item.seqNum }}
</span>
<!-- 标题与节点文本预览 -->
<span
class="truncate"
:class="[
item.level === 0 ? 'font-bold text-xs tracking-wide text-color1 dark:text-white' : '',
item.level === 1 ? 'font-bold text-xs text-color1' : '',
item.tagName === 'SUBTASK' ? 'font-semibold text-color1' : ''
]"
:title="item.fullText"
>
{{ item.text }}
</span>
</div>
<!-- 定位指示 / 悬浮跳转图标 -->
<n-icon <n-icon
v-if="getItemIcon(item.tagName)" size="13"
size="14" class="shrink-0 transition-all duration-150"
class="shrink-0 transition-transform group-hover/outline:scale-110"
:class="[ :class="[
activeOutlineId === item.id activeOutlineId === item.id
? 'text-white' ? 'text-white opacity-100 translate-x-0'
: item.level === 0 : 'opacity-0 -translate-x-1 group-hover/outline:opacity-80 group-hover/outline:translate-x-0 text-primary'
? 'text-primary dark:text-primary-hover font-bold'
: item.tagName === 'WARNING' || item.tagName === 'CAUTION'
? 'text-amber-500 dark:text-amber-400'
: item.tagName === 'GRAPHIC'
? 'text-indigo-500 dark:text-indigo-400'
: item.tagName === 'TABLE'
? 'text-emerald-500 dark:text-emerald-400'
: 'text-primary/70'
]" ]"
> >
<component :is="getItemIcon(item.tagName)" /> <NavigateOutline />
</n-icon> </n-icon>
<!-- 序号徽标 (如 3., A., (1), (2)) -->
<span
v-if="
item.seqNum &&
!item.seqNum.includes('🖼️') &&
!item.seqNum.includes('📊') &&
!item.seqNum.includes('⚡') &&
!item.seqNum.includes('📝')
"
class="font-mono font-bold shrink-0 text-[11px]"
:class="[activeOutlineId === item.id ? 'text-white' : 'text-primary dark:text-primary-hover']"
>
{{ item.seqNum }}
</span>
<!-- 标题与节点文本预览 -->
<span
class="truncate"
:class="[
item.level === 0 ? 'font-bold text-xs tracking-wide text-color1 dark:text-white' : '',
item.level === 1 ? 'font-bold text-xs text-color1' : '',
item.tagName === 'SUBTASK' ? 'font-semibold text-color1' : ''
]"
:title="item.fullText"
>
{{ item.text }}
</span>
</div> </div>
<!-- 定位指示 / 悬浮跳转图标 -->
<n-icon
size="13"
class="shrink-0 transition-all duration-150"
:class="[
activeOutlineId === item.id
? 'text-white opacity-100 translate-x-0'
: 'opacity-0 -translate-x-1 group-hover/outline:opacity-80 group-hover/outline:translate-x-0 text-primary'
]"
>
<NavigateOutline />
</n-icon>
</div> </div>
</div> </div>
<div v-else class="h-full flex items-center justify-center text-color3 py-8">
<n-empty description="暂无匹配的大纲项" />
</div>
</div> </div>
<div v-else class="h-full flex items-center justify-center text-color3 py-8">
<n-empty description="暂无匹配的大纲项" /> <!-- 专属大纲虚拟列表滚动条 (保底 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>
</div> </div>
</template> </template>
...@@ -122,18 +137,18 @@ const { ...@@ -122,18 +137,18 @@ const {
outlineViewportRef, outlineViewportRef,
filteredOutlineList, filteredOutlineList,
activeOutlineId, activeOutlineId,
showCustomScrollbar,
thumbHeight,
thumbTop,
handleContainerScroll,
handleThumbDragStart,
handleTrackClick,
handleOutlineClick, handleOutlineClick,
handleOutlineScroll,
totalHeight, totalHeight,
startOffset, startOffset,
visibleOutlineItems visibleOutlineItems
} = useDocOutlineView(props) } = useDocOutlineView(props)
const handleScroll = (e: Event) => {
handleOutlineScroll(e)
emit('scroll', e)
}
const getItemIcon = (tagName: string) => { const getItemIcon = (tagName: string) => {
switch (tagName) { switch (tagName) {
case 'CEP': case 'CEP':
...@@ -171,3 +186,53 @@ defineExpose({ ...@@ -171,3 +186,53 @@ defineExpose({
outlineViewportRef outlineViewportRef
}) })
</script> </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 { ...@@ -21,3 +21,5 @@ export interface XmlTreeViewProps {
hasCustomColor: (item: FlatNode) => boolean hasCustomColor: (item: FlatNode) => boolean
getNodeStyle: (item: FlatNode) => any getNodeStyle: (item: FlatNode) => any
} }
export const MIN_THUMB_SIZE = 36
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import type { XmlTreeViewProps } from '../constants' 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 editorStore = useEditorStore()
const viewportRef = ref<HTMLElement | null>(null) 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 => { const getGraphicKey = (node: any): string => {
let key = node.attributes?.KEY || node.attributes?.GRAPHICKEY || node.attributes?.ID || '' let key = node.attributes?.KEY || node.attributes?.GRAPHICKEY || node.attributes?.ID || ''
if (!key && node.children) { if (!key && node.children) {
...@@ -56,6 +147,12 @@ export function useXmlTreeView(_props: XmlTreeViewProps) { ...@@ -56,6 +147,12 @@ export function useXmlTreeView(_props: XmlTreeViewProps) {
return { return {
editorStore, editorStore,
viewportRef, viewportRef,
showCustomScrollbar,
thumbHeight,
thumbTop,
handleContainerScroll,
handleThumbDragStart,
handleTrackClick,
getGraphicKey, getGraphicKey,
getTreeItemRefNodes, getTreeItemRefNodes,
getTreeItemTargetId, getTreeItemTargetId,
......
<template> <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 v-if="flatList.length > 0" :style="{ height: totalHeight + 'px', position: 'relative' }"> <div
<!-- 背景连接线层 - 绘制连续的垂直虚线 --> ref="viewportRef"
<div class="tree-lines-layer"> class="flex-1 overflow-y-auto p-2 pb-14 relative select-none virtual-tree-container custom-scrollbar-hide"
<div @scroll="handleContainerScroll"
v-for="line in visibleVerticalLines" >
:key="line.key" <div v-if="flatList.length > 0" :style="{ height: totalHeight + 'px', position: 'relative' }">
class="tree-vertical-line" <!-- 背景连接线层 - 绘制连续的垂直虚线 -->
:style="{ <div class="tree-lines-layer">
left: `${line.left}px`, <div
top: `${line.top}px`, v-for="line in visibleVerticalLines"
height: `${line.height}px` :key="line.key"
}" class="tree-vertical-line"
></div> :style="{
</div> left: `${line.left}px`,
top: `${line.top}px`,
height: `${line.height}px`
}"
></div>
</div>
<!-- 可见列表节点 --> <!-- 可见列表节点 -->
<div :style="{ transform: `translateY(${startOffset}px)` }" class="absolute top-0 left-0 right-0"> <div :style="{ transform: `translateY(${startOffset}px)` }" class="absolute top-0 left-0 right-0">
<div
v-for="item in visibleItems"
:key="item.id"
class="flex items-center h-[32px] px-2 rounded cursor-pointer transition-colors group/row tree-node-content"
:class="[
effectiveSelectedId === item.id
? 'bg-primary text-white tree-node-selected'
: (showDropdown || isAnyModalVisible) && contextNodeId === item.id
? 'tree-node-context-active'
: hasCustomColor(item)
? 'hover:bg-fill-3'
: 'hover:bg-fill-3 text-color2',
VIRTUAL_LAYOUT_TAGS.includes(item.tagName) ? 'tree-node-pagebreak' : ''
]"
:style="[
{
paddingLeft: item.depth * 20 + 8 + (isBatchMode ? 24 : 0) + 'px',
'--tree-level': item.depth,
'--batch-shift': isBatchMode ? '24px' : '0px'
},
getNodeStyle(item)
]"
@click="emit('select', item.id)"
@contextmenu.prevent="(e) => emit('contextmenu', e, item)"
>
<!-- 局部翻译加载状态 -->
<Transition name="translate-loading">
<div v-if="translatingNodeId === item.id" class="translate-loading-mask">
<div class="translate-loading-inner">
<n-icon size="13" class="translate-loading-icon">
<SyncOutline />
</n-icon>
<span class="translate-loading-text">
正在智能翻译
<span class="translate-dots">
<span>.</span>
<span>.</span>
<span>.</span>
</span>
</span>
</div>
</div>
</Transition>
<!-- Checkbox (批量管理模式) -->
<CommonCheckboxSingle
v-if="isBatchMode"
:checked="selectedKeys.has(item.id)"
class="absolute left-2.5 shrink-0 z-10"
@update:checked="(val: boolean) => emit('toggleCheck', item.id, val)"
@click.stop
/>
<!-- Switcher (展开/折叠 减号/加号) -->
<div <div
v-if="item.hasChildren" v-for="item in visibleItems"
class="w-4 h-4 flex items-center justify-center mr-1 text-color3 hover:text-color1 cursor-pointer transition-colors z-10" :key="item.id"
:class="[effectiveSelectedId === item.id ? 'text-white/80 hover:text-white' : 'text-primary']" class="flex items-center h-[32px] px-2 rounded cursor-pointer transition-colors group/row tree-node-content"
@click.stop="emit('toggleExpand', item.id)" :class="[
effectiveSelectedId === item.id
? 'bg-primary text-white tree-node-selected'
: (showDropdown || isAnyModalVisible) && contextNodeId === item.id
? 'tree-node-context-active'
: hasCustomColor(item)
? 'hover:bg-fill-3'
: 'hover:bg-fill-3 text-color2',
VIRTUAL_LAYOUT_TAGS.includes(item.tagName) ? 'tree-node-pagebreak' : ''
]"
:style="[
{
paddingLeft: item.depth * 20 + 8 + (isBatchMode ? 24 : 0) + 'px',
'--tree-level': item.depth,
'--batch-shift': isBatchMode ? '24px' : '0px'
},
getNodeStyle(item)
]"
@click="emit('select', item.id)"
@contextmenu.prevent="(e) => emit('contextmenu', e, item)"
> >
<!-- 展开状态:减号 --> <!-- 局部翻译加载状态 -->
<svg v-if="item.isExpanded" class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor"> <Transition name="translate-loading">
<path d="M3 8h10v1H3V8z" /> <div v-if="translatingNodeId === item.id" class="translate-loading-mask">
</svg> <div class="translate-loading-inner">
<!-- 折叠状态:加号 --> <n-icon size="13" class="translate-loading-icon">
<svg v-else class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor"> <SyncOutline />
<path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" /> </n-icon>
</svg> <span class="translate-loading-text">
</div> 正在智能翻译
<span class="translate-dots">
<span>.</span>
<span>.</span>
<span>.</span>
</span>
</span>
</div>
</div>
</Transition>
<!-- Checkbox (批量管理模式) -->
<CommonCheckboxSingle
v-if="isBatchMode"
:checked="selectedKeys.has(item.id)"
class="absolute left-2.5 shrink-0 z-10"
@update:checked="(val: boolean) => emit('toggleCheck', item.id, val)"
@click.stop
/>
<!-- 占位符 (无子节点时填充宽度以便对齐) --> <!-- Switcher (展开/折叠 减号/加号) -->
<div v-else class="w-4 h-4 mr-1"></div> <div
v-if="item.hasChildren"
class="w-4 h-4 flex items-center justify-center mr-1 text-color3 hover:text-color1 cursor-pointer transition-colors z-10"
:class="[effectiveSelectedId === item.id ? 'text-white/80 hover:text-white' : 'text-primary']"
@click.stop="emit('toggleExpand', item.id)"
>
<!-- 展开状态:减号 -->
<svg v-if="item.isExpanded" class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M3 8h10v1H3V8z" />
</svg>
<!-- 折叠状态:加号 -->
<svg v-else class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" />
</svg>
</div>
<!-- Icon --> <!-- 占位符 (无子节点时填充宽度以便对齐) -->
<div class="mr-1.5 flex items-center justify-center shrink-0 z-10" :class="[effectiveSelectedId === item.id ? 'text-white' : '']"> <div v-else class="w-4 h-4 mr-1"></div>
<n-icon size="16">
<component :is="getNodeIcon(item)" />
</n-icon>
</div>
<!-- 快捷定位引用 / 定位目标按钮 (在图标右侧直观展示) --> <!-- Icon -->
<template v-if="item.tagName === 'GRAPHIC' && getTreeItemRefNodes(item.rawNode).length > 0">
<div <div
class="w-5 h-5 rounded hover:bg-primary/30 flex items-center justify-center mr-1 text-primary cursor-pointer transition-transform hover:scale-110 z-20 shrink-0 border border-primary/30" class="mr-1.5 flex items-center justify-center shrink-0 z-10"
:class="[effectiveSelectedId === item.id ? 'text-white border-white/50 bg-white/20' : 'bg-primary/10']" :class="[effectiveSelectedId === item.id ? 'text-white' : '']"
title="点击直接定位至引用当前插图的位置"
@click.stop="handleTreeRefJump(item.rawNode)"
> >
<n-icon size="12"><NavigateOutline /></n-icon> <n-icon size="16">
<component :is="getNodeIcon(item)" />
</n-icon>
</div> </div>
</template>
<template v-else-if="(item.tagName === 'GRPHCREF' || item.tagName === 'REFINT') && getTreeItemTargetId(item.rawNode)"> <!-- 快捷定位引用 / 定位目标按钮 (在图标右侧直观展示) -->
<template v-if="item.tagName === 'GRAPHIC' && getTreeItemRefNodes(item.rawNode).length > 0">
<div
class="w-5 h-5 rounded hover:bg-primary/30 flex items-center justify-center mr-1 text-primary cursor-pointer transition-transform hover:scale-110 z-20 shrink-0 border border-primary/30"
:class="[effectiveSelectedId === item.id ? 'text-white border-white/50 bg-white/20' : 'bg-primary/10']"
title="点击直接定位至引用当前插图的位置"
@click.stop="handleTreeRefJump(item.rawNode)"
>
<n-icon size="12"><NavigateOutline /></n-icon>
</div>
</template>
<template v-else-if="(item.tagName === 'GRPHCREF' || item.tagName === 'REFINT') && getTreeItemTargetId(item.rawNode)">
<div
class="w-5 h-5 rounded hover:bg-primary/30 flex items-center justify-center mr-1 text-primary cursor-pointer transition-transform hover:scale-110 z-20 shrink-0 border border-primary/30"
:class="[effectiveSelectedId === item.id ? 'text-white border-white/50 bg-white/20' : 'bg-primary/10']"
title="点击直接定位至关联的插图/目标节点"
@click.stop="editorStore.setSelectedNodeId(getTreeItemTargetId(item.rawNode)!, true)"
>
<n-icon size="12"><NavigateOutline /></n-icon>
</div>
</template>
<!-- Label & Subtitle & AttrSummary -->
<div <div
class="w-5 h-5 rounded hover:bg-primary/30 flex items-center justify-center mr-1 text-primary cursor-pointer transition-transform hover:scale-110 z-20 shrink-0 border border-primary/30" class="flex-1 min-w-0 flex items-center space-x-1.5 z-10"
:class="[effectiveSelectedId === item.id ? 'text-white border-white/50 bg-white/20' : 'bg-primary/10']" :title="
title="点击直接定位至关联的插图/目标节点" item.fullSubtitle || item.subtitle || item.attrSummary
@click.stop="editorStore.setSelectedNodeId(getTreeItemTargetId(item.rawNode)!, true)" ? `${item.tagName} ${item.attrSummary ? '[' + item.attrSummary + '] ' : ''}${item.fullSubtitle || item.subtitle}`
: item.tagName
"
> >
<n-icon size="12"><NavigateOutline /></n-icon> <!-- 节点名称高亮 -->
<span class="font-bold text-sm flex-shrink-0" v-html="highlightText(item.tagName, pattern)"></span>
<!-- 属性摘要 Badge (包含淡色背景与框线,与文本预览彻底区分) -->
<span
v-if="item.attrSummary"
class="text-[10px] font-mono px-1.5 py-0.5 rounded shrink-0 leading-none font-medium border"
:class="[
effectiveSelectedId === item.id
? 'bg-white/20 text-white border-white/30'
: 'bg-primary/10 text-primary border-primary/20 dark:bg-primary/20 dark:border-primary/30'
]"
v-html="highlightText(item.attrSummary, pattern)"
></span>
<!-- 节点文本内容预览高亮 -->
<span
v-if="item.subtitle"
class="text-xs truncate font-normal"
:title="item.fullSubtitle || item.subtitle"
:class="[
effectiveSelectedId === item.id
? 'text-white/70'
: (showDropdown || isAnyModalVisible) && contextNodeId === item.id
? 'tree-node-context-active-subtitle'
: hasCustomColor(item)
? 'opacity-80'
: 'text-color3'
]"
v-html="highlightText(item.subtitle, pattern)"
></span>
</div> </div>
</template>
<!-- Label & Subtitle & AttrSummary -->
<div
class="flex-1 min-w-0 flex items-center space-x-1.5 z-10"
:title="
item.fullSubtitle || item.subtitle || item.attrSummary
? `${item.tagName} ${item.attrSummary ? '[' + item.attrSummary + '] ' : ''}${item.fullSubtitle || item.subtitle}`
: item.tagName
"
>
<!-- 节点名称高亮 -->
<span class="font-bold text-sm flex-shrink-0" v-html="highlightText(item.tagName, pattern)"></span>
<!-- 属性摘要 Badge (包含淡色背景与框线,与文本预览彻底区分) -->
<span
v-if="item.attrSummary"
class="text-[10px] font-mono px-1.5 py-0.5 rounded shrink-0 leading-none font-medium border"
:class="[
effectiveSelectedId === item.id
? 'bg-white/20 text-white border-white/30'
: 'bg-primary/10 text-primary border-primary/20 dark:bg-primary/20 dark:border-primary/30'
]"
v-html="highlightText(item.attrSummary, pattern)"
></span>
<!-- 节点文本内容预览高亮 -->
<span
v-if="item.subtitle"
class="text-xs truncate font-normal"
:title="item.fullSubtitle || item.subtitle"
:class="[
effectiveSelectedId === item.id
? 'text-white/70'
: (showDropdown || isAnyModalVisible) && contextNodeId === item.id
? 'tree-node-context-active-subtitle'
: hasCustomColor(item)
? 'opacity-80'
: 'text-color3'
]"
v-html="highlightText(item.subtitle, pattern)"
></span>
</div> </div>
</div> </div>
</div> </div>
<div v-else class="h-full flex items-center justify-center text-color3">
<n-empty description="暂无节点数据" />
</div>
</div> </div>
<div v-else class="h-full flex items-center justify-center text-color3">
<n-empty description="暂无节点数据" /> <!-- 专属虚拟列表滚动条 (保底 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>
</div> </div>
</template> </template>
...@@ -182,7 +200,19 @@ const emit = defineEmits<{ ...@@ -182,7 +200,19 @@ const emit = defineEmits<{
(e: 'toggleCheck', id: string, checked: boolean): void (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({ defineExpose({
viewportRef viewportRef
...@@ -190,6 +220,56 @@ defineExpose({ ...@@ -190,6 +220,56 @@ defineExpose({
</script> </script>
<style scoped> <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 { .virtual-tree-container {
position: relative; position: relative;
overflow-y: auto; 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