Commit e87ac505 by pangchong

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

- 偏好设置新增“编辑区内容最小宽度”配置,支持300~1600px调整
- 编辑区内容宽度不足时自动开启平滑横向滚动条,防止内容压缩变形
- 编辑区及相关组件最小宽度绑定配置值,确保布局稳定
- 大纲视图和XML树视图新增专属水平滚动条,优雅替代原生滚动条
- 相关状态管理及路由元信息进行了相应调整与完善
- 移除naive-ui全局Modal调用,优化全局API挂载逻辑
- 代码中多处样式微调,保证滚动条和内容显示一致性和兼容性
parent b616f3bd
...@@ -1157,7 +1157,7 @@ export const FAQ_DATABASE: FaqItem[] = [ ...@@ -1157,7 +1157,7 @@ export const FAQ_DATABASE: FaqItem[] = [
'【明暗切换】:点击顶部工具栏右上角的【太阳/月亮】图标,即可瞬间在亮色与暗色模式间切换。', '【明暗切换】:点击顶部工具栏右上角的【太阳/月亮】图标,即可瞬间在亮色与暗色模式间切换。',
'【偏好设置】:点击右上角【设置齿轮】图标打开抽屉:', '【偏好设置】:点击右上角【设置齿轮】图标打开抽屉:',
' · 【外观 Tab】:浅色/深色/系统主题模式、14 种预设品牌色与 HEX 取色器、字号步进调节(±)、色弱/灰色模式。', ' · 【外观 Tab】:浅色/深色/系统主题模式、14 种预设品牌色与 HEX 取色器、字号步进调节(±)、色弱/灰色模式。',
' · 【编辑区 Tab】:插入节点自动展开、表格新增列默认宽度比、树菜单收缩阈值、默认展开宽度及最大宽度占比。', ' · 【编辑区 Tab】:插入节点自动展开、表格新增列默认宽度比、树菜单收缩阈值、默认展开宽度、最大宽度占比及编辑区内容最小宽度(不足时横向滚动)。',
' · 【快捷键 Tab】:常用快捷键速查。', ' · 【快捷键 Tab】:常用快捷键速查。',
' · 【偏好导入/导出】:抽屉底部支持一键【复制】与【导入】偏好 JSON 配置。' ' · 【偏好导入/导出】:抽屉底部支持一键【复制】与【导入】偏好 JSON 配置。'
], ],
...@@ -1287,6 +1287,35 @@ export const FAQ_DATABASE: FaqItem[] = [ ...@@ -1287,6 +1287,35 @@ export const FAQ_DATABASE: FaqItem[] = [
], ],
highlights: ['悬浮球靠右吸附时,气泡与提示语向左侧展开;靠左吸附时自动反转方向(头像在左、气泡向右),确保不遮挡主要编辑界面。'], highlights: ['悬浮球靠右吸附时,气泡与提示语向左侧展开;靠左吸附时自动反转方向(头像在左、气泡向右),确保不遮挡主要编辑界面。'],
relatedQuestions: ['faq_theme_settings', 'faq_keyboard_shortcuts'] 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() { ...@@ -50,6 +50,7 @@ export function useSettingsDrawer() {
appStore.treeCollapseThreshold = 20 appStore.treeCollapseThreshold = 20
appStore.treeDefaultWidth = 560 appStore.treeDefaultWidth = 560
appStore.treeMaxRatio = 0.6 appStore.treeMaxRatio = 0.6
appStore.editorMinWidth = 800
customColor.value = '#165DFF' customColor.value = '#165DFF'
applyAndSave() applyAndSave()
window.$message.success('已重置为默认设置') window.$message.success('已重置为默认设置')
...@@ -67,7 +68,8 @@ export function useSettingsDrawer() { ...@@ -67,7 +68,8 @@ export function useSettingsDrawer() {
defaultNewColWidth: appStore.defaultNewColWidth, defaultNewColWidth: appStore.defaultNewColWidth,
treeCollapseThreshold: appStore.treeCollapseThreshold, treeCollapseThreshold: appStore.treeCollapseThreshold,
treeDefaultWidth: appStore.treeDefaultWidth, treeDefaultWidth: appStore.treeDefaultWidth,
treeMaxRatio: appStore.treeMaxRatio treeMaxRatio: appStore.treeMaxRatio,
editorMinWidth: appStore.editorMinWidth
}, },
null, null,
2 2
...@@ -100,6 +102,7 @@ export function useSettingsDrawer() { ...@@ -100,6 +102,7 @@ export function useSettingsDrawer() {
if (typeof conf.treeCollapseThreshold === 'number') appStore.treeCollapseThreshold = conf.treeCollapseThreshold if (typeof conf.treeCollapseThreshold === 'number') appStore.treeCollapseThreshold = conf.treeCollapseThreshold
if (typeof conf.treeDefaultWidth === 'number') appStore.treeDefaultWidth = conf.treeDefaultWidth if (typeof conf.treeDefaultWidth === 'number') appStore.treeDefaultWidth = conf.treeDefaultWidth
if (typeof conf.treeMaxRatio === 'number') appStore.treeMaxRatio = conf.treeMaxRatio if (typeof conf.treeMaxRatio === 'number') appStore.treeMaxRatio = conf.treeMaxRatio
if (typeof conf.editorMinWidth === 'number') appStore.editorMinWidth = conf.editorMinWidth
applyAndSave() applyAndSave()
window.$message.success('偏好设置已成功导入并应用') window.$message.success('偏好设置已成功导入并应用')
......
...@@ -235,7 +235,7 @@ ...@@ -235,7 +235,7 @@
</div> </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 class="flex items-center justify-between mb-1.5">
<div> <div>
<div class="text-sm" :style="{ color: themeVars.textColor1 }">树菜单最大宽度占比</div> <div class="text-sm" :style="{ color: themeVars.textColor1 }">树菜单最大宽度占比</div>
...@@ -254,6 +254,27 @@ ...@@ -254,6 +254,27 @@
</div> </div>
</div> </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> </div>
</n-tab-pane> </n-tab-pane>
......
...@@ -25,7 +25,7 @@ const constantRoutes: Array<RouteRecordRaw> = [ ...@@ -25,7 +25,7 @@ const constantRoutes: Array<RouteRecordRaw> = [
path: '/editor', path: '/editor',
name: 'editor', name: 'editor',
component: () => import('@/views/editor/index.vue'), component: () => import('@/views/editor/index.vue'),
meta: { title: '工卡 XML 编辑器', fullScreen: true } meta: { title: '工卡 XML 编辑器' }
} }
] ]
}, },
......
...@@ -18,7 +18,8 @@ export const useAppStore = defineStore('app', { ...@@ -18,7 +18,8 @@ export const useAppStore = defineStore('app', {
defaultNewColWidth: 0.2, defaultNewColWidth: 0.2,
treeCollapseThreshold: 20, treeCollapseThreshold: 20,
treeDefaultWidth: 560, treeDefaultWidth: 560,
treeMaxRatio: 0.6 treeMaxRatio: 0.6,
editorMinWidth: 800
}), }),
actions: { actions: {
applyTheme() { applyTheme() {
......
...@@ -17,4 +17,5 @@ export interface AppState { ...@@ -17,4 +17,5 @@ export interface AppState {
treeCollapseThreshold: number // 树分栏收缩阈值 treeCollapseThreshold: number // 树分栏收缩阈值
treeDefaultWidth: number // 树分栏默认展开宽度 treeDefaultWidth: number // 树分栏默认展开宽度
treeMaxRatio: number // 树分栏最大宽度比例 treeMaxRatio: number // 树分栏最大宽度比例
editorMinWidth: number // 编辑区内容最小宽度 (px)
} }
...@@ -67,9 +67,6 @@ declare global { ...@@ -67,9 +67,6 @@ declare global {
$uploadModal: any $uploadModal: any
$previewModal: any $previewModal: any
$downloadModal: any $downloadModal: any
$modal: {
open: (options: any) => import('naive-ui').ModalReactive
}
} }
} }
......
...@@ -34,28 +34,8 @@ export function setupNaiveDiscreteApi() { ...@@ -34,28 +34,8 @@ export function setupNaiveDiscreteApi() {
window.$notification = useNotification() window.$notification = useNotification()
window.$loadingBar = loadingBar 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 = { window.$loading = {
start: (text?: string) => { start: (text?: string) => {
const appStore = useAppStore() const appStore = useAppStore()
......
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
<span v-else class="text-color3 italic">未选择节点</span> <span v-else class="text-color3 italic">未选择节点</span>
</div> </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"> <CommonButton size="tiny" secondary @click="toggleFindReplace">
<template #icon> <template #icon>
...@@ -43,15 +43,29 @@ ...@@ -43,15 +43,29 @@
<!-- 文档编辑区(虚拟滚动容器) --> <!-- 文档编辑区(虚拟滚动容器) -->
<div <div
ref="viewportRef" 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" @scroll="handleScroll"
@contextmenu="handleContextMenu" @contextmenu="handleContextMenu"
@mousedown="handleViewportMouseDown" @mousedown="handleViewportMouseDown"
> >
<!-- 占位撑高,模拟全量内容总高度 --> <!-- 占位撑高,模拟全量内容总高度,设置最小宽度保证内容不被过度压缩 -->
<div :style="{ height: totalHeight + 'px', position: 'relative' }"> <div
:style="{
height: totalHeight + 'px',
minWidth: `${appStore.editorMinWidth || 800}px`,
width: '100%',
position: 'relative'
}"
>
<!-- 仅渲染可视区块,通过 translateY 定位 --> <!-- 仅渲染可视区块,通过 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 <div
v-for="block in visibleBlocks" v-for="block in visibleBlocks"
:key="block.id" :key="block.id"
...@@ -95,6 +109,7 @@ ...@@ -95,6 +109,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { SettingsOutline, SearchOutline, ArrowUpOutline } from '@vicons/ionicons5' import { SettingsOutline, SearchOutline, ArrowUpOutline } from '@vicons/ionicons5'
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import { useAppStore } from '@/store/app'
import DocNodeRenderer from '../DocNodeRenderer/index.vue' import DocNodeRenderer from '../DocNodeRenderer/index.vue'
import FindReplacePanel from './components/FindReplacePanel/index.vue' import FindReplacePanel from './components/FindReplacePanel/index.vue'
import EditAreaContextMenuModal from './components/EditAreaContextMenuModal/index.vue' import EditAreaContextMenuModal from './components/EditAreaContextMenuModal/index.vue'
...@@ -103,6 +118,7 @@ import { useEditorPanel } from './functionals' ...@@ -103,6 +118,7 @@ import { useEditorPanel } from './functionals'
import { addNodeVisible, addNodeMode, addNodeTargetId, addNodeAllowedTags } from '../NodeTree/functionals' import { addNodeVisible, addNodeMode, addNodeTargetId, addNodeAllowedTags } from '../NodeTree/functionals'
const editorStore = useEditorStore() const editorStore = useEditorStore()
const appStore = useAppStore()
const { const {
selectedNode, selectedNode,
......
...@@ -363,11 +363,22 @@ export function useDocOutlineView(props: DocOutlineProps) { ...@@ -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) { if (outlineViewportRef.value) {
scrollTop.value = outlineViewportRef.value.scrollTop scrollTop.value = outlineViewportRef.value.scrollTop
scrollLeft.value = outlineViewportRef.value.scrollLeft
viewportHeight.value = outlineViewportRef.value.clientHeight viewportHeight.value = outlineViewportRef.value.clientHeight
viewportWidth.value = outlineViewportRef.value.clientWidth
scrollWidth.value = outlineViewportRef.value.scrollWidth
}
} }
const handleContainerScroll = (e: Event) => {
updateScrollMetrics()
handleOutlineScroll(e) handleOutlineScroll(e)
} }
...@@ -375,21 +386,30 @@ export function useDocOutlineView(props: DocOutlineProps) { ...@@ -375,21 +386,30 @@ export function useDocOutlineView(props: DocOutlineProps) {
onMounted(() => { onMounted(() => {
if (outlineViewportRef.value) { if (outlineViewportRef.value) {
viewportHeight.value = outlineViewportRef.value.clientHeight updateScrollMetrics()
resizeObserver = new ResizeObserver((entries) => { resizeObserver = new ResizeObserver(() => {
if (entries[0]) { updateScrollMetrics()
viewportHeight.value = entries[0].contentRect.height
}
}) })
resizeObserver.observe(outlineViewportRef.value) resizeObserver.observe(outlineViewportRef.value)
} }
}) })
watch(
() => [visibleOutlineItems.value, filteredOutlineList.value.length],
() => {
nextTick(() => {
updateScrollMetrics()
})
}
)
onUnmounted(() => { onUnmounted(() => {
resizeObserver?.disconnect() resizeObserver?.disconnect()
resizeObserver = null resizeObserver = null
document.removeEventListener('mousemove', handleThumbDragging) document.removeEventListener('mousemove', handleThumbDragging)
document.removeEventListener('mouseup', handleThumbDragEnd) document.removeEventListener('mouseup', handleThumbDragEnd)
document.removeEventListener('mousemove', handleHThumbDragging)
document.removeEventListener('mouseup', handleHThumbDragEnd)
}) })
const showCustomScrollbar = computed(() => { const showCustomScrollbar = computed(() => {
...@@ -450,6 +470,70 @@ export function useDocOutlineView(props: DocOutlineProps) { ...@@ -450,6 +470,70 @@ export function useDocOutlineView(props: DocOutlineProps) {
outlineViewportRef.value.scrollTop = targetScrollTop 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 { return {
editorStore, editorStore,
outlineViewportRef, outlineViewportRef,
...@@ -458,6 +542,11 @@ export function useDocOutlineView(props: DocOutlineProps) { ...@@ -458,6 +542,11 @@ export function useDocOutlineView(props: DocOutlineProps) {
showCustomScrollbar, showCustomScrollbar,
thumbHeight, thumbHeight,
thumbTop, thumbTop,
showCustomHScrollbar,
hThumbWidth,
hThumbLeft,
handleHThumbDragStart,
handleHTrackClick,
handleContainerScroll, handleContainerScroll,
handleThumbDragStart, handleThumbDragStart,
handleTrackClick, handleTrackClick,
......
...@@ -2,16 +2,16 @@ ...@@ -2,16 +2,16 @@
<div class="outline-wrapper flex-1 relative overflow-hidden flex flex-col min-h-0"> <div class="outline-wrapper flex-1 relative overflow-hidden flex flex-col min-h-0">
<div <div
ref="outlineViewportRef" 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" @scroll="handleContainerScroll"
> >
<div v-if="filteredOutlineList.length > 0" :style="{ height: totalHeight + 'px', position: 'relative' }"> <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 right-0"> <div :style="{ transform: `translateY(${startOffset}px)` }" class="absolute top-0 left-0 min-w-full w-max">
<div <div
v-for="item in visibleOutlineItems" v-for="item in visibleOutlineItems"
:key="item.id" :key="item.id"
:data-outline-id="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) }" :style="{ paddingLeft: getItemPaddingLeft(item.level) }"
:class="[ :class="[
activeOutlineId === item.id activeOutlineId === item.id
...@@ -30,7 +30,7 @@ ...@@ -30,7 +30,7 @@
class="absolute left-0 top-1.5 bottom-1.5 w-[3px] bg-primary rounded-r" class="absolute left-0 top-1.5 bottom-1.5 w-[3px] bg-primary rounded-r"
></div> ></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 <n-icon
v-if="getItemIcon(item.tagName)" v-if="getItemIcon(item.tagName)"
...@@ -70,7 +70,7 @@ ...@@ -70,7 +70,7 @@
<!-- 标题与节点文本预览 --> <!-- 标题与节点文本预览 -->
<span <span
class="truncate" class="shrink-0"
:class="[ :class="[
item.level === 0 ? 'font-bold text-xs tracking-wide text-color1 dark:text-white' : '', item.level === 0 ? 'font-bold text-xs tracking-wide text-color1 dark:text-white' : '',
item.level === 1 ? 'font-bold text-xs text-color1' : '', item.level === 1 ? 'font-bold text-xs text-color1' : '',
...@@ -110,6 +110,15 @@ ...@@ -110,6 +110,15 @@
@mousedown.stop.prevent="handleThumbDragStart" @mousedown.stop.prevent="handleThumbDragStart"
></div> ></div>
</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> </div>
</template> </template>
...@@ -140,6 +149,11 @@ const { ...@@ -140,6 +149,11 @@ const {
showCustomScrollbar, showCustomScrollbar,
thumbHeight, thumbHeight,
thumbTop, thumbTop,
showCustomHScrollbar,
hThumbWidth,
hThumbLeft,
handleHThumbDragStart,
handleHTrackClick,
handleContainerScroll, handleContainerScroll,
handleThumbDragStart, handleThumbDragStart,
handleTrackClick, handleTrackClick,
...@@ -188,6 +202,7 @@ defineExpose({ ...@@ -188,6 +202,7 @@ defineExpose({
</script> </script>
<style scoped> <style scoped>
/* 彻底隐匿原生 WebKit / Firefox 滚动条,防止原生滚动条与自定义滑块冲突重叠及右侧白边 */
.custom-scrollbar-hide { .custom-scrollbar-hide {
scrollbar-width: none !important; scrollbar-width: none !important;
-ms-overflow-style: none !important; -ms-overflow-style: none !important;
...@@ -198,6 +213,13 @@ defineExpose({ ...@@ -198,6 +213,13 @@ defineExpose({
height: 0 !important; height: 0 !important;
} }
.outline-container {
position: relative;
overflow-y: auto;
overflow-x: auto;
}
/* 专属虚拟列表垂直滚动条轨道 */
.v-scrollbar-track { .v-scrollbar-track {
position: absolute; position: absolute;
top: 4px; top: 4px;
...@@ -235,4 +257,43 @@ defineExpose({ ...@@ -235,4 +257,43 @@ defineExpose({
width: 10px; width: 10px;
background: var(--primary-color, #705bf6); 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> </style>
...@@ -8,12 +8,22 @@ export function useXmlTreeView(props: XmlTreeViewProps, emit: any) { ...@@ -8,12 +8,22 @@ export function useXmlTreeView(props: XmlTreeViewProps, emit: any) {
const scrollTop = ref(0) const scrollTop = ref(0)
const viewportHeight = 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) { if (viewportRef.value) {
scrollTop.value = viewportRef.value.scrollTop scrollTop.value = viewportRef.value.scrollTop
scrollLeft.value = viewportRef.value.scrollLeft
viewportHeight.value = viewportRef.value.clientHeight viewportHeight.value = viewportRef.value.clientHeight
viewportWidth.value = viewportRef.value.clientWidth
scrollWidth.value = viewportRef.value.scrollWidth
}
} }
const handleContainerScroll = (e: Event) => {
updateScrollMetrics()
emit('scroll', e) emit('scroll', e)
} }
...@@ -21,21 +31,30 @@ export function useXmlTreeView(props: XmlTreeViewProps, emit: any) { ...@@ -21,21 +31,30 @@ export function useXmlTreeView(props: XmlTreeViewProps, emit: any) {
onMounted(() => { onMounted(() => {
if (viewportRef.value) { if (viewportRef.value) {
viewportHeight.value = viewportRef.value.clientHeight updateScrollMetrics()
resizeObserver = new ResizeObserver((entries) => { resizeObserver = new ResizeObserver(() => {
if (entries[0]) { updateScrollMetrics()
viewportHeight.value = entries[0].contentRect.height
}
}) })
resizeObserver.observe(viewportRef.value) resizeObserver.observe(viewportRef.value)
} }
}) })
watch(
() => [props.visibleItems, props.flatList.length],
() => {
nextTick(() => {
updateScrollMetrics()
})
}
)
onUnmounted(() => { onUnmounted(() => {
resizeObserver?.disconnect() resizeObserver?.disconnect()
resizeObserver = null resizeObserver = null
document.removeEventListener('mousemove', handleThumbDragging) document.removeEventListener('mousemove', handleThumbDragging)
document.removeEventListener('mouseup', handleThumbDragEnd) document.removeEventListener('mouseup', handleThumbDragEnd)
document.removeEventListener('mousemove', handleHThumbDragging)
document.removeEventListener('mouseup', handleHThumbDragEnd)
}) })
const showCustomScrollbar = computed(() => { const showCustomScrollbar = computed(() => {
...@@ -96,6 +115,70 @@ export function useXmlTreeView(props: XmlTreeViewProps, emit: any) { ...@@ -96,6 +115,70 @@ export function useXmlTreeView(props: XmlTreeViewProps, emit: any) {
viewportRef.value.scrollTop = targetScrollTop 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 => { 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) {
...@@ -150,6 +233,11 @@ export function useXmlTreeView(props: XmlTreeViewProps, emit: any) { ...@@ -150,6 +233,11 @@ export function useXmlTreeView(props: XmlTreeViewProps, emit: any) {
showCustomScrollbar, showCustomScrollbar,
thumbHeight, thumbHeight,
thumbTop, thumbTop,
showCustomHScrollbar,
hThumbWidth,
hThumbLeft,
handleHThumbDragStart,
handleHTrackClick,
handleContainerScroll, handleContainerScroll,
handleThumbDragStart, handleThumbDragStart,
handleTrackClick, handleTrackClick,
......
...@@ -2,10 +2,10 @@ ...@@ -2,10 +2,10 @@
<div class="virtual-tree-wrapper flex-1 relative overflow-hidden flex flex-col min-h-0"> <div class="virtual-tree-wrapper flex-1 relative overflow-hidden flex flex-col min-h-0">
<div <div
ref="viewportRef" 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" @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 class="tree-lines-layer">
<div <div
...@@ -21,11 +21,11 @@ ...@@ -21,11 +21,11 @@
</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 min-w-full w-max">
<div <div
v-for="item in visibleItems" v-for="item in visibleItems"
:key="item.id" :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="[ :class="[
effectiveSelectedId === item.id effectiveSelectedId === item.id
? 'bg-primary text-white tree-node-selected' ? 'bg-primary text-white tree-node-selected'
...@@ -128,7 +128,7 @@ ...@@ -128,7 +128,7 @@
<!-- Label & Subtitle & AttrSummary --> <!-- Label & Subtitle & AttrSummary -->
<div <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=" :title="
item.fullSubtitle || item.subtitle || item.attrSummary item.fullSubtitle || item.subtitle || item.attrSummary
? `${item.tagName} ${item.attrSummary ? '[' + item.attrSummary + '] ' : ''}${item.fullSubtitle || item.subtitle}` ? `${item.tagName} ${item.attrSummary ? '[' + item.attrSummary + '] ' : ''}${item.fullSubtitle || item.subtitle}`
...@@ -151,7 +151,7 @@ ...@@ -151,7 +151,7 @@
<!-- 节点文本内容预览高亮 --> <!-- 节点文本内容预览高亮 -->
<span <span
v-if="item.subtitle" v-if="item.subtitle"
class="text-xs truncate font-normal" class="text-xs font-normal shrink-0"
:title="item.fullSubtitle || item.subtitle" :title="item.fullSubtitle || item.subtitle"
:class="[ :class="[
effectiveSelectedId === item.id effectiveSelectedId === item.id
...@@ -173,7 +173,7 @@ ...@@ -173,7 +173,7 @@
</div> </div>
</div> </div>
<!-- 专属虚拟列表滚动条 (保底 36px 拖拽块,解决节点过多微缩无法抓取) --> <!-- 专属虚拟列表垂直滚动条 (保底 36px 拖拽块,解决节点过多微缩无法抓取) -->
<div v-if="showCustomScrollbar" class="v-scrollbar-track" @mousedown="handleTrackClick"> <div v-if="showCustomScrollbar" class="v-scrollbar-track" @mousedown="handleTrackClick">
<div <div
class="v-scrollbar-thumb" class="v-scrollbar-thumb"
...@@ -181,6 +181,15 @@ ...@@ -181,6 +181,15 @@
@mousedown.stop.prevent="handleThumbDragStart" @mousedown.stop.prevent="handleThumbDragStart"
></div> ></div>
</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> </div>
</template> </template>
...@@ -206,6 +215,11 @@ const { ...@@ -206,6 +215,11 @@ const {
showCustomScrollbar, showCustomScrollbar,
thumbHeight, thumbHeight,
thumbTop, thumbTop,
showCustomHScrollbar,
hThumbWidth,
hThumbLeft,
handleHThumbDragStart,
handleHTrackClick,
handleContainerScroll, handleContainerScroll,
handleThumbDragStart, handleThumbDragStart,
handleTrackClick, handleTrackClick,
...@@ -220,7 +234,7 @@ defineExpose({ ...@@ -220,7 +234,7 @@ defineExpose({
</script> </script>
<style scoped> <style scoped>
/* 隐匿原生 WebKit / Firefox 滚动条 */ /* 彻底隐匿原生 WebKit / Firefox 滚动条,防止原生滚动条与自定义滑块冲突重叠及右侧白边 */
.custom-scrollbar-hide { .custom-scrollbar-hide {
scrollbar-width: none !important; scrollbar-width: none !important;
-ms-overflow-style: none !important; -ms-overflow-style: none !important;
...@@ -231,7 +245,7 @@ defineExpose({ ...@@ -231,7 +245,7 @@ defineExpose({
height: 0 !important; height: 0 !important;
} }
/* 专属虚拟列表滚动条轨道 */ /* 专属虚拟列表垂直滚动条轨道 */
.v-scrollbar-track { .v-scrollbar-track {
position: absolute; position: absolute;
top: 4px; top: 4px;
...@@ -270,10 +284,49 @@ defineExpose({ ...@@ -270,10 +284,49 @@ defineExpose({
background: var(--primary-color, #705bf6); 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 { .virtual-tree-container {
position: relative; position: relative;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: auto;
} }
/* 树形连接线背景层 */ /* 树形连接线背景层 */
......
...@@ -207,8 +207,7 @@ export function useNodeTree( ...@@ -207,8 +207,7 @@ export function useNodeTree(
} }
const text = getFullText(node).trim() const text = getFullText(node).trim()
if (isFull) return text return text
return text.length > 60 ? text.substring(0, 60) + '...' : text
} }
// 递归构建扁平列表 // 递归构建扁平列表
...@@ -499,7 +498,7 @@ export function useNodeTree( ...@@ -499,7 +498,7 @@ export function useNodeTree(
list.push({ list.push({
id: virtualId, id: virtualId,
tagName: '#text', tagName: '#text',
subtitle: trimmed.length > 60 ? trimmed.substring(0, 60) + '...' : trimmed, subtitle: trimmed,
fullSubtitle: trimmed, fullSubtitle: trimmed,
depth: depth + 1, depth: depth + 1,
hasChildren: false, hasChildren: false,
......
...@@ -32,7 +32,7 @@ ...@@ -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 /> <EditorPanel />
</div> </div>
</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