Commit e87ac505 by pangchong

feat(editor): 新增编辑区最小宽度与横向滚动支持

- 偏好设置新增“编辑区内容最小宽度”配置,支持300~1600px调整
- 编辑区内容宽度不足时自动开启平滑横向滚动条,防止内容压缩变形
- 编辑区及相关组件最小宽度绑定配置值,确保布局稳定
- 大纲视图和XML树视图新增专属水平滚动条,优雅替代原生滚动条
- 相关状态管理及路由元信息进行了相应调整与完善
- 移除naive-ui全局Modal调用,优化全局API挂载逻辑
- 代码中多处样式微调,保证滚动条和内容显示一致性和兼容性
parent b616f3bd
......@@ -1157,7 +1157,7 @@ export const FAQ_DATABASE: FaqItem[] = [
'【明暗切换】:点击顶部工具栏右上角的【太阳/月亮】图标,即可瞬间在亮色与暗色模式间切换。',
'【偏好设置】:点击右上角【设置齿轮】图标打开抽屉:',
' · 【外观 Tab】:浅色/深色/系统主题模式、14 种预设品牌色与 HEX 取色器、字号步进调节(±)、色弱/灰色模式。',
' · 【编辑区 Tab】:插入节点自动展开、表格新增列默认宽度比、树菜单收缩阈值、默认展开宽度及最大宽度占比。',
' · 【编辑区 Tab】:插入节点自动展开、表格新增列默认宽度比、树菜单收缩阈值、默认展开宽度、最大宽度占比及编辑区内容最小宽度(不足时横向滚动)。',
' · 【快捷键 Tab】:常用快捷键速查。',
' · 【偏好导入/导出】:抽屉底部支持一键【复制】与【导入】偏好 JSON 配置。'
],
......@@ -1287,6 +1287,35 @@ export const FAQ_DATABASE: FaqItem[] = [
],
highlights: ['悬浮球靠右吸附时,气泡与提示语向左侧展开;靠左吸附时自动反转方向(头像在左、气泡向右),确保不遮挡主要编辑界面。'],
relatedQuestions: ['faq_theme_settings', 'faq_keyboard_shortcuts']
},
{
id: 'faq_editor_min_width',
category: 'settings',
categoryName: '设置与快捷键',
title: '什么是“编辑区内容最小宽度”?如何配置与使用横向滚动?',
keywords: [
'最小宽度',
'编辑区宽度',
'横向滚动',
'水平滚动条',
'minWidth',
'hscroll',
'zxkd',
'zuixiaokuandu',
'hxgd',
'hengxianggundong'
],
synonyms: ['怎么设置编辑区最小宽度', '编辑区横向滚动条', '编辑区太挤怎么横向滚', '内容最小宽度'],
answer: '为了防止在调整分栏或小屏幕下,复杂表格与排版被过度压缩挤压,系统支持设定编辑区内容最小宽度:',
steps: [
'点击右上角【设置】齿轮图标打开偏好设置抽屉。',
'切换到【编辑区】选项卡,找到“编辑区内容最小宽度”配置项(默认 800px,支持 300px~1600px 范围调节)。',
'当编辑区实际可用宽度小于设定值时,系统会自动保持内容最小宽度并激活平滑的横向滚动条,防止内容被挤压形变。',
'左侧节点树与 Word 大纲在层级较深或文本较长时,同样支持通过底部独立的专属水平滚动条或 Shift+滚轮自由横向滚动。'
],
highlights: ['该设置会自动持久化保存至本地偏好配置中,随时可根据个人显示器屏幕尺寸灵活调节。'],
actions: [{ label: '打开偏好设置', type: 'event', payload: 'trigger_open_settings' }],
relatedQuestions: ['faq_theme_settings', 'faq_splitter_resize', 'faq_node_tree_ops']
}
]
......
......@@ -50,6 +50,7 @@ export function useSettingsDrawer() {
appStore.treeCollapseThreshold = 20
appStore.treeDefaultWidth = 560
appStore.treeMaxRatio = 0.6
appStore.editorMinWidth = 800
customColor.value = '#165DFF'
applyAndSave()
window.$message.success('已重置为默认设置')
......@@ -67,7 +68,8 @@ export function useSettingsDrawer() {
defaultNewColWidth: appStore.defaultNewColWidth,
treeCollapseThreshold: appStore.treeCollapseThreshold,
treeDefaultWidth: appStore.treeDefaultWidth,
treeMaxRatio: appStore.treeMaxRatio
treeMaxRatio: appStore.treeMaxRatio,
editorMinWidth: appStore.editorMinWidth
},
null,
2
......@@ -100,6 +102,7 @@ export function useSettingsDrawer() {
if (typeof conf.treeCollapseThreshold === 'number') appStore.treeCollapseThreshold = conf.treeCollapseThreshold
if (typeof conf.treeDefaultWidth === 'number') appStore.treeDefaultWidth = conf.treeDefaultWidth
if (typeof conf.treeMaxRatio === 'number') appStore.treeMaxRatio = conf.treeMaxRatio
if (typeof conf.editorMinWidth === 'number') appStore.editorMinWidth = conf.editorMinWidth
applyAndSave()
window.$message.success('偏好设置已成功导入并应用')
......
......@@ -235,7 +235,7 @@
</div>
<!-- 树菜单最大宽度占比 -->
<div class="py-3" :style="{ borderColor: themeVars.dividerColor }">
<div class="py-3 border-b" :style="{ borderColor: themeVars.dividerColor }">
<div class="flex items-center justify-between mb-1.5">
<div>
<div class="text-sm" :style="{ color: themeVars.textColor1 }">树菜单最大宽度占比</div>
......@@ -254,6 +254,27 @@
</div>
</div>
</div>
<!-- 编辑区内容最小宽度 -->
<div class="py-3" :style="{ borderColor: themeVars.dividerColor }">
<div class="flex items-center justify-between mb-1.5">
<div>
<div class="text-sm" :style="{ color: themeVars.textColor1 }">编辑区内容最小宽度</div>
<div class="text-xs mt-0.5" :style="{ color: themeVars.textColor3 }">内容区域保持的最小宽度(px),不足时横向滚动</div>
</div>
<div class="w-32">
<CommonInputNumber
v-model:value="appStore.editorMinWidth"
:min="300"
:max="1600"
:step="20"
:strict="true"
size="small"
placeholder="默认 800"
/>
</div>
</div>
</div>
</div>
</n-tab-pane>
......
......@@ -25,7 +25,7 @@ const constantRoutes: Array<RouteRecordRaw> = [
path: '/editor',
name: 'editor',
component: () => import('@/views/editor/index.vue'),
meta: { title: '工卡 XML 编辑器', fullScreen: true }
meta: { title: '工卡 XML 编辑器' }
}
]
},
......
......@@ -18,7 +18,8 @@ export const useAppStore = defineStore('app', {
defaultNewColWidth: 0.2,
treeCollapseThreshold: 20,
treeDefaultWidth: 560,
treeMaxRatio: 0.6
treeMaxRatio: 0.6,
editorMinWidth: 800
}),
actions: {
applyTheme() {
......
......@@ -17,4 +17,5 @@ export interface AppState {
treeCollapseThreshold: number // 树分栏收缩阈值
treeDefaultWidth: number // 树分栏默认展开宽度
treeMaxRatio: number // 树分栏最大宽度比例
editorMinWidth: number // 编辑区内容最小宽度 (px)
}
......@@ -67,9 +67,6 @@ declare global {
$uploadModal: any
$previewModal: any
$downloadModal: any
$modal: {
open: (options: any) => import('naive-ui').ModalReactive
}
}
}
......
......@@ -34,28 +34,8 @@ export function setupNaiveDiscreteApi() {
window.$notification = useNotification()
window.$loadingBar = loadingBar
// 3. 挂载全局 Modal 离散 API
const { modal } = createDiscreteApi(['modal'], {
configProviderProps: computed(() => {
const appStore = useAppStore()
return {
theme: (appStore as any).isDark ? darkTheme : lightTheme
}
})
})
window.$modal = {
open: (options: any) => {
const { width, ...rest } = options
const modalOptions = {
preset: 'card',
style: width ? { width: typeof width === 'number' ? `${width}px` : width } : {},
...rest
}
return modal.create(modalOptions)
}
}
// 4. 挂载全局阻塞加载控制
// 3. 挂载全局阻塞加载控制
window.$loading = {
start: (text?: string) => {
const appStore = useAppStore()
......
......@@ -18,7 +18,7 @@
<span v-else class="text-color3 italic">未选择节点</span>
</div>
<div class="flex items-center space-x-2">
<div class="flex items-center space-x-2 shrink-0">
<!-- 查找替换按钮 -->
<CommonButton size="tiny" secondary @click="toggleFindReplace">
<template #icon>
......@@ -43,15 +43,29 @@
<!-- 文档编辑区(虚拟滚动容器) -->
<div
ref="viewportRef"
class="flex-1 overflow-y-auto min-h-0 leading-relaxed relative text-color1"
class="flex-1 overflow-auto min-h-0 leading-relaxed relative text-color1"
@scroll="handleScroll"
@contextmenu="handleContextMenu"
@mousedown="handleViewportMouseDown"
>
<!-- 占位撑高,模拟全量内容总高度 -->
<div :style="{ height: totalHeight + 'px', position: 'relative' }">
<!-- 占位撑高,模拟全量内容总高度,设置最小宽度保证内容不被过度压缩 -->
<div
:style="{
height: totalHeight + 'px',
minWidth: `${appStore.editorMinWidth || 800}px`,
width: '100%',
position: 'relative'
}"
>
<!-- 仅渲染可视区块,通过 translateY 定位 -->
<div :style="{ transform: `translateY(${startOffset}px)` }" class="absolute top-0 left-0 right-0">
<div
:style="{
transform: `translateY(${startOffset}px)`,
minWidth: `${appStore.editorMinWidth || 800}px`,
width: '100%'
}"
class="absolute top-0 left-0"
>
<div
v-for="block in visibleBlocks"
:key="block.id"
......@@ -95,6 +109,7 @@
<script setup lang="ts">
import { SettingsOutline, SearchOutline, ArrowUpOutline } from '@vicons/ionicons5'
import { useEditorStore } from '@/store/editor'
import { useAppStore } from '@/store/app'
import DocNodeRenderer from '../DocNodeRenderer/index.vue'
import FindReplacePanel from './components/FindReplacePanel/index.vue'
import EditAreaContextMenuModal from './components/EditAreaContextMenuModal/index.vue'
......@@ -103,6 +118,7 @@ import { useEditorPanel } from './functionals'
import { addNodeVisible, addNodeMode, addNodeTargetId, addNodeAllowedTags } from '../NodeTree/functionals'
const editorStore = useEditorStore()
const appStore = useAppStore()
const {
selectedNode,
......
......@@ -363,11 +363,22 @@ export function useDocOutlineView(props: DocOutlineProps) {
}
)
const handleContainerScroll = (e: Event) => {
const scrollLeft = ref(0)
const viewportWidth = ref(0)
const scrollWidth = ref(0)
const updateScrollMetrics = () => {
if (outlineViewportRef.value) {
scrollTop.value = outlineViewportRef.value.scrollTop
scrollLeft.value = outlineViewportRef.value.scrollLeft
viewportHeight.value = outlineViewportRef.value.clientHeight
viewportWidth.value = outlineViewportRef.value.clientWidth
scrollWidth.value = outlineViewportRef.value.scrollWidth
}
}
const handleContainerScroll = (e: Event) => {
updateScrollMetrics()
handleOutlineScroll(e)
}
......@@ -375,21 +386,30 @@ export function useDocOutlineView(props: DocOutlineProps) {
onMounted(() => {
if (outlineViewportRef.value) {
viewportHeight.value = outlineViewportRef.value.clientHeight
resizeObserver = new ResizeObserver((entries) => {
if (entries[0]) {
viewportHeight.value = entries[0].contentRect.height
}
updateScrollMetrics()
resizeObserver = new ResizeObserver(() => {
updateScrollMetrics()
})
resizeObserver.observe(outlineViewportRef.value)
}
})
watch(
() => [visibleOutlineItems.value, filteredOutlineList.value.length],
() => {
nextTick(() => {
updateScrollMetrics()
})
}
)
onUnmounted(() => {
resizeObserver?.disconnect()
resizeObserver = null
document.removeEventListener('mousemove', handleThumbDragging)
document.removeEventListener('mouseup', handleThumbDragEnd)
document.removeEventListener('mousemove', handleHThumbDragging)
document.removeEventListener('mouseup', handleHThumbDragEnd)
})
const showCustomScrollbar = computed(() => {
......@@ -450,6 +470,70 @@ export function useDocOutlineView(props: DocOutlineProps) {
outlineViewportRef.value.scrollTop = targetScrollTop
}
// ── 水平滚动条计算与事件 ────────────────────────────────────────────────────────
const showCustomHScrollbar = computed(() => {
return scrollWidth.value > viewportWidth.value + 4 && viewportWidth.value > 0
})
const hTrackWidth = computed(() => {
if (!viewportWidth.value) return 0
return Math.max(0, viewportWidth.value - (showCustomScrollbar.value ? 14 : 6) - 4)
})
const hThumbWidth = computed(() => {
if (!viewportWidth.value || !scrollWidth.value || !hTrackWidth.value) return MIN_THUMB_SIZE
const ratio = viewportWidth.value / scrollWidth.value
return Math.max(MIN_THUMB_SIZE, Math.min(hTrackWidth.value, Math.round(ratio * hTrackWidth.value)))
})
const hThumbLeft = computed(() => {
const maxScroll = scrollWidth.value - viewportWidth.value
const maxThumb = hTrackWidth.value - hThumbWidth.value
if (maxScroll <= 0 || maxThumb <= 0) return 0
return (scrollLeft.value / maxScroll) * maxThumb
})
let isHDragging = false
let startX = 0
let startScrollLeft = 0
const handleHThumbDragStart = (e: MouseEvent) => {
isHDragging = true
startX = e.clientX
startScrollLeft = outlineViewportRef.value?.scrollLeft || 0
document.addEventListener('mousemove', handleHThumbDragging)
document.addEventListener('mouseup', handleHThumbDragEnd)
}
const handleHThumbDragging = (e: MouseEvent) => {
if (!isHDragging || !outlineViewportRef.value) return
const deltaX = e.clientX - startX
const maxScroll = scrollWidth.value - viewportWidth.value
const maxThumb = hTrackWidth.value - hThumbWidth.value
if (maxThumb <= 0) return
const scrollDelta = (deltaX / maxThumb) * maxScroll
outlineViewportRef.value.scrollLeft = startScrollLeft + scrollDelta
}
const handleHThumbDragEnd = () => {
isHDragging = false
document.removeEventListener('mousemove', handleHThumbDragging)
document.removeEventListener('mouseup', handleHThumbDragEnd)
}
const handleHTrackClick = (e: MouseEvent) => {
if (!outlineViewportRef.value) return
const trackRect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const clickX = e.clientX - trackRect.left
const maxScroll = scrollWidth.value - viewportWidth.value
const maxThumb = hTrackWidth.value - hThumbWidth.value
if (maxThumb <= 0) return
const targetThumbLeft = clickX - hThumbWidth.value / 2
const clampedThumbLeft = Math.max(0, Math.min(maxThumb, targetThumbLeft))
const targetScrollLeft = (clampedThumbLeft / maxThumb) * maxScroll
outlineViewportRef.value.scrollLeft = targetScrollLeft
}
return {
editorStore,
outlineViewportRef,
......@@ -458,6 +542,11 @@ export function useDocOutlineView(props: DocOutlineProps) {
showCustomScrollbar,
thumbHeight,
thumbTop,
showCustomHScrollbar,
hThumbWidth,
hThumbLeft,
handleHThumbDragStart,
handleHTrackClick,
handleContainerScroll,
handleThumbDragStart,
handleTrackClick,
......
......@@ -2,16 +2,16 @@
<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"
class="flex-1 overflow-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 v-if="filteredOutlineList.length > 0" :style="{ height: totalHeight + 'px', position: 'relative' }" class="min-w-full w-max">
<div :style="{ transform: `translateY(${startOffset}px)` }" class="absolute top-0 left-0 min-w-full w-max">
<div
v-for="item in visibleOutlineItems"
:key="item.id"
: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"
class="relative h-[27px] px-2 rounded-md text-xs cursor-pointer flex items-center justify-between transition-all duration-150 group/outline min-w-full w-max"
:style="{ paddingLeft: getItemPaddingLeft(item.level) }"
:class="[
activeOutlineId === item.id
......@@ -30,7 +30,7 @@
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 whitespace-nowrap mr-1.5">
<!-- 大纲类型图标 -->
<n-icon
v-if="getItemIcon(item.tagName)"
......@@ -70,7 +70,7 @@
<!-- 标题与节点文本预览 -->
<span
class="truncate"
class="shrink-0"
:class="[
item.level === 0 ? 'font-bold text-xs tracking-wide text-color1 dark:text-white' : '',
item.level === 1 ? 'font-bold text-xs text-color1' : '',
......@@ -110,6 +110,15 @@
@mousedown.stop.prevent="handleThumbDragStart"
></div>
</div>
<!-- 专属水平滚动条(仅在内容横向溢出时呈现,优雅平替原生滚动条,杜绝重叠与占位白边) -->
<div v-if="showCustomHScrollbar" class="h-scrollbar-track" @mousedown="handleHTrackClick">
<div
class="h-scrollbar-thumb"
:style="{ width: `${hThumbWidth}px`, transform: `translateX(${hThumbLeft}px)` }"
@mousedown.stop.prevent="handleHThumbDragStart"
></div>
</div>
</div>
</template>
......@@ -140,6 +149,11 @@ const {
showCustomScrollbar,
thumbHeight,
thumbTop,
showCustomHScrollbar,
hThumbWidth,
hThumbLeft,
handleHThumbDragStart,
handleHTrackClick,
handleContainerScroll,
handleThumbDragStart,
handleTrackClick,
......@@ -188,6 +202,7 @@ defineExpose({
</script>
<style scoped>
/* 彻底隐匿原生 WebKit / Firefox 滚动条,防止原生滚动条与自定义滑块冲突重叠及右侧白边 */
.custom-scrollbar-hide {
scrollbar-width: none !important;
-ms-overflow-style: none !important;
......@@ -198,6 +213,13 @@ defineExpose({
height: 0 !important;
}
.outline-container {
position: relative;
overflow-y: auto;
overflow-x: auto;
}
/* 专属虚拟列表垂直滚动条轨道 */
.v-scrollbar-track {
position: absolute;
top: 4px;
......@@ -235,4 +257,43 @@ defineExpose({
width: 10px;
background: var(--primary-color, #705bf6);
}
/* 专属水平自定义滚动条轨道与滑块 */
.h-scrollbar-track {
position: absolute;
left: 4px;
right: 14px;
bottom: 2px;
height: 10px;
background: transparent;
z-index: 40;
cursor: pointer;
border-radius: 5px;
transition: background 0.2s ease;
}
.h-scrollbar-track:hover {
background: rgba(128, 128, 128, 0.12);
}
.h-scrollbar-thumb {
position: absolute;
left: 0;
bottom: 1px;
height: 8px;
background: rgba(128, 128, 128, 0.35);
border-radius: 4px;
cursor: grab;
transition:
background 0.15s ease,
height 0.15s ease;
}
.h-scrollbar-track:hover .h-scrollbar-thumb,
.h-scrollbar-thumb:hover {
height: 10px;
background: var(--primary-color, #705bf6);
}
.h-scrollbar-thumb:active {
cursor: grabbing;
height: 10px;
background: var(--primary-color, #705bf6);
}
</style>
......@@ -8,12 +8,22 @@ export function useXmlTreeView(props: XmlTreeViewProps, emit: any) {
const scrollTop = ref(0)
const viewportHeight = ref(0)
const scrollLeft = ref(0)
const viewportWidth = ref(0)
const scrollWidth = ref(0)
const handleContainerScroll = (e: Event) => {
const updateScrollMetrics = () => {
if (viewportRef.value) {
scrollTop.value = viewportRef.value.scrollTop
scrollLeft.value = viewportRef.value.scrollLeft
viewportHeight.value = viewportRef.value.clientHeight
viewportWidth.value = viewportRef.value.clientWidth
scrollWidth.value = viewportRef.value.scrollWidth
}
}
const handleContainerScroll = (e: Event) => {
updateScrollMetrics()
emit('scroll', e)
}
......@@ -21,21 +31,30 @@ export function useXmlTreeView(props: XmlTreeViewProps, emit: any) {
onMounted(() => {
if (viewportRef.value) {
viewportHeight.value = viewportRef.value.clientHeight
resizeObserver = new ResizeObserver((entries) => {
if (entries[0]) {
viewportHeight.value = entries[0].contentRect.height
}
updateScrollMetrics()
resizeObserver = new ResizeObserver(() => {
updateScrollMetrics()
})
resizeObserver.observe(viewportRef.value)
}
})
watch(
() => [props.visibleItems, props.flatList.length],
() => {
nextTick(() => {
updateScrollMetrics()
})
}
)
onUnmounted(() => {
resizeObserver?.disconnect()
resizeObserver = null
document.removeEventListener('mousemove', handleThumbDragging)
document.removeEventListener('mouseup', handleThumbDragEnd)
document.removeEventListener('mousemove', handleHThumbDragging)
document.removeEventListener('mouseup', handleHThumbDragEnd)
})
const showCustomScrollbar = computed(() => {
......@@ -96,6 +115,70 @@ export function useXmlTreeView(props: XmlTreeViewProps, emit: any) {
viewportRef.value.scrollTop = targetScrollTop
}
// ── 水平滚动条计算与事件 ────────────────────────────────────────────────────────
const showCustomHScrollbar = computed(() => {
return scrollWidth.value > viewportWidth.value + 4 && viewportWidth.value > 0
})
const hTrackWidth = computed(() => {
if (!viewportWidth.value) return 0
return Math.max(0, viewportWidth.value - (showCustomScrollbar.value ? 14 : 6) - 4)
})
const hThumbWidth = computed(() => {
if (!viewportWidth.value || !scrollWidth.value || !hTrackWidth.value) return MIN_THUMB_SIZE
const ratio = viewportWidth.value / scrollWidth.value
return Math.max(MIN_THUMB_SIZE, Math.min(hTrackWidth.value, Math.round(ratio * hTrackWidth.value)))
})
const hThumbLeft = computed(() => {
const maxScroll = scrollWidth.value - viewportWidth.value
const maxThumb = hTrackWidth.value - hThumbWidth.value
if (maxScroll <= 0 || maxThumb <= 0) return 0
return (scrollLeft.value / maxScroll) * maxThumb
})
let isHDragging = false
let startX = 0
let startScrollLeft = 0
const handleHThumbDragStart = (e: MouseEvent) => {
isHDragging = true
startX = e.clientX
startScrollLeft = viewportRef.value?.scrollLeft || 0
document.addEventListener('mousemove', handleHThumbDragging)
document.addEventListener('mouseup', handleHThumbDragEnd)
}
const handleHThumbDragging = (e: MouseEvent) => {
if (!isHDragging || !viewportRef.value) return
const deltaX = e.clientX - startX
const maxScroll = scrollWidth.value - viewportWidth.value
const maxThumb = hTrackWidth.value - hThumbWidth.value
if (maxThumb <= 0) return
const scrollDelta = (deltaX / maxThumb) * maxScroll
viewportRef.value.scrollLeft = startScrollLeft + scrollDelta
}
const handleHThumbDragEnd = () => {
isHDragging = false
document.removeEventListener('mousemove', handleHThumbDragging)
document.removeEventListener('mouseup', handleHThumbDragEnd)
}
const handleHTrackClick = (e: MouseEvent) => {
if (!viewportRef.value) return
const trackRect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const clickX = e.clientX - trackRect.left
const maxScroll = scrollWidth.value - viewportWidth.value
const maxThumb = hTrackWidth.value - hThumbWidth.value
if (maxThumb <= 0) return
const targetThumbLeft = clickX - hThumbWidth.value / 2
const clampedThumbLeft = Math.max(0, Math.min(maxThumb, targetThumbLeft))
const targetScrollLeft = (clampedThumbLeft / maxThumb) * maxScroll
viewportRef.value.scrollLeft = targetScrollLeft
}
const getGraphicKey = (node: any): string => {
let key = node.attributes?.KEY || node.attributes?.GRAPHICKEY || node.attributes?.ID || ''
if (!key && node.children) {
......@@ -150,6 +233,11 @@ export function useXmlTreeView(props: XmlTreeViewProps, emit: any) {
showCustomScrollbar,
thumbHeight,
thumbTop,
showCustomHScrollbar,
hThumbWidth,
hThumbLeft,
handleHThumbDragStart,
handleHTrackClick,
handleContainerScroll,
handleThumbDragStart,
handleTrackClick,
......
......@@ -2,10 +2,10 @@
<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"
class="flex-1 overflow-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 v-if="flatList.length > 0" :style="{ height: totalHeight + 'px', position: 'relative' }" class="min-w-full w-max">
<!-- 背景连接线层 - 绘制连续的垂直虚线 -->
<div class="tree-lines-layer">
<div
......@@ -21,11 +21,11 @@
</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 min-w-full w-max">
<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="flex items-center h-[32px] px-2 rounded cursor-pointer transition-colors group/row tree-node-content min-w-full w-max"
:class="[
effectiveSelectedId === item.id
? 'bg-primary text-white tree-node-selected'
......@@ -128,7 +128,7 @@
<!-- Label & Subtitle & AttrSummary -->
<div
class="flex-1 min-w-0 flex items-center space-x-1.5 z-10"
class="flex items-center space-x-1.5 z-10 whitespace-nowrap"
:title="
item.fullSubtitle || item.subtitle || item.attrSummary
? `${item.tagName} ${item.attrSummary ? '[' + item.attrSummary + '] ' : ''}${item.fullSubtitle || item.subtitle}`
......@@ -151,7 +151,7 @@
<!-- 节点文本内容预览高亮 -->
<span
v-if="item.subtitle"
class="text-xs truncate font-normal"
class="text-xs font-normal shrink-0"
:title="item.fullSubtitle || item.subtitle"
:class="[
effectiveSelectedId === item.id
......@@ -173,7 +173,7 @@
</div>
</div>
<!-- 专属虚拟列表滚动条 (保底 36px 拖拽块,解决节点过多微缩无法抓取) -->
<!-- 专属虚拟列表垂直滚动条 (保底 36px 拖拽块,解决节点过多微缩无法抓取) -->
<div v-if="showCustomScrollbar" class="v-scrollbar-track" @mousedown="handleTrackClick">
<div
class="v-scrollbar-thumb"
......@@ -181,6 +181,15 @@
@mousedown.stop.prevent="handleThumbDragStart"
></div>
</div>
<!-- 专属水平滚动条(仅在内容横向溢出时呈现,优雅平替原生滚动条,杜绝重叠与占位白边) -->
<div v-if="showCustomHScrollbar" class="h-scrollbar-track" @mousedown="handleHTrackClick">
<div
class="h-scrollbar-thumb"
:style="{ width: `${hThumbWidth}px`, transform: `translateX(${hThumbLeft}px)` }"
@mousedown.stop.prevent="handleHThumbDragStart"
></div>
</div>
</div>
</template>
......@@ -206,6 +215,11 @@ const {
showCustomScrollbar,
thumbHeight,
thumbTop,
showCustomHScrollbar,
hThumbWidth,
hThumbLeft,
handleHThumbDragStart,
handleHTrackClick,
handleContainerScroll,
handleThumbDragStart,
handleTrackClick,
......@@ -220,7 +234,7 @@ defineExpose({
</script>
<style scoped>
/* 隐匿原生 WebKit / Firefox 滚动条 */
/* 彻底隐匿原生 WebKit / Firefox 滚动条,防止原生滚动条与自定义滑块冲突重叠及右侧白边 */
.custom-scrollbar-hide {
scrollbar-width: none !important;
-ms-overflow-style: none !important;
......@@ -231,7 +245,7 @@ defineExpose({
height: 0 !important;
}
/* 专属虚拟列表滚动条轨道 */
/* 专属虚拟列表垂直滚动条轨道 */
.v-scrollbar-track {
position: absolute;
top: 4px;
......@@ -270,10 +284,49 @@ defineExpose({
background: var(--primary-color, #705bf6);
}
/* 专属水平自定义滚动条轨道与滑块 */
.h-scrollbar-track {
position: absolute;
left: 4px;
right: 14px;
bottom: 2px;
height: 10px;
background: transparent;
z-index: 40;
cursor: pointer;
border-radius: 5px;
transition: background 0.2s ease;
}
.h-scrollbar-track:hover {
background: rgba(128, 128, 128, 0.12);
}
.h-scrollbar-thumb {
position: absolute;
left: 0;
bottom: 1px;
height: 8px;
background: rgba(128, 128, 128, 0.35);
border-radius: 4px;
cursor: grab;
transition:
background 0.15s ease,
height 0.15s ease;
}
.h-scrollbar-track:hover .h-scrollbar-thumb,
.h-scrollbar-thumb:hover {
height: 10px;
background: var(--primary-color, #705bf6);
}
.h-scrollbar-thumb:active {
cursor: grabbing;
height: 10px;
background: var(--primary-color, #705bf6);
}
.virtual-tree-container {
position: relative;
overflow-y: auto;
overflow-x: hidden;
overflow-x: auto;
}
/* 树形连接线背景层 */
......
......@@ -207,8 +207,7 @@ export function useNodeTree(
}
const text = getFullText(node).trim()
if (isFull) return text
return text.length > 60 ? text.substring(0, 60) + '...' : text
return text
}
// 递归构建扁平列表
......@@ -499,7 +498,7 @@ export function useNodeTree(
list.push({
id: virtualId,
tagName: '#text',
subtitle: trimmed.length > 60 ? trimmed.substring(0, 60) + '...' : trimmed,
subtitle: trimmed,
fullSubtitle: trimmed,
depth: depth + 1,
hasChildren: false,
......
......@@ -32,7 +32,7 @@
/>
<!-- 右侧:编辑区 -->
<div class="flex-1 flex flex-col overflow-hidden" :style="{ background: themeVars.colorBg1 }">
<div class="flex-1 flex flex-col overflow-hidden min-w-0" :style="{ background: themeVars.colorBg1 }">
<EditorPanel />
</div>
</div>
......
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