Commit 9312e5ad by pangchong

feat(editor): 增加Word风格文档大纲视图,解耦NodeTree组件三层架构并优化虚拟滚动与定位体验

parent e84a7c4c
......@@ -328,16 +328,25 @@ export function useEditorPanel() {
const containerCenter = containerRect.top + containerRect.height / 2
const diff = Math.abs(targetCenter - containerCenter)
// 若节点高度超过视口的 70%,采用顶端预留 24px 呼吸距离对齐
const isTall = rect.height >= containerRect.height * 0.7
// 已在可视范围内且非强制,不做处理
if (!force && rect.top >= containerRect.top + 20 && rect.bottom <= containerRect.bottom - 20) {
return
}
// 如果当前节点已基本居中 (偏差 <= 35px),不再重复触发 scrollIntoView,防止画面二次抖动与闪烁
// 如果当前节点已基本居中/靠顶 (偏差 <= 35px),不再重复触发 scrollIntoView,防止画面二次抖动与闪烁
if (diff <= 35 && rect.top >= containerRect.top && rect.bottom <= containerRect.bottom) {
return
}
targetEl.scrollIntoView({ behavior: 'auto', block: 'center' })
if (isTall) {
const currentScrollTop = viewportRef.value.scrollTop
const targetScrollTop = Math.max(0, currentScrollTop + (rect.top - containerRect.top) - 24)
viewportRef.value.scrollTo({ top: targetScrollTop, behavior: 'smooth' })
} else {
targetEl.scrollIntoView({ behavior: 'auto', block: 'center' })
}
return
}
......@@ -357,7 +366,12 @@ export function useEditorPanel() {
}
const estimatedTargetTop = pos.top + internalOffset
const targetScrollTop = Math.max(0, estimatedTargetTop - vHeight / 2)
const targetNode = editorStore.nodeMap.get(realId)?.node
const estimatedHeight = targetNode ? getNodeEstimatedHeight(targetNode) : 200
const isTall = estimatedHeight >= vHeight * 0.7
const targetScrollTop = isTall
? Math.max(0, estimatedTargetTop - 24)
: Math.max(0, estimatedTargetTop - vHeight / 2)
viewportRef.value.scrollTo({
top: targetScrollTop,
......@@ -383,7 +397,14 @@ export function useEditorPanel() {
const containerCenter = containerRect.top + containerRect.height / 2
if (Math.abs(targetCenter - containerCenter) > 45) {
targetEl.scrollIntoView({ behavior: 'auto', block: 'center' })
const isTallNode = rect.height >= containerRect.height * 0.7
if (isTallNode) {
const currentScrollTop = viewportRef.value.scrollTop
const targetScrollTop = Math.max(0, currentScrollTop + (rect.top - containerRect.top) - 24)
viewportRef.value.scrollTo({ top: targetScrollTop, behavior: 'smooth' })
} else {
targetEl.scrollIntoView({ behavior: 'auto', block: 'center' })
}
}
})
})
......
// 文档大纲项接口定义
export interface OutlineItem {
id: string
tagName: string
seqNum: string
text: string
fullText: string
level: number
}
// 文档大纲组件 Props 定义
export interface DocOutlineProps {
pattern: string
active: boolean
}
<template>
<div ref="outlineViewportRef" class="flex-1 overflow-y-auto p-2 pb-14 relative select-none" @scroll="handleScroll">
<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-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"
: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">
<!-- 大纲类型图标 -->
<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
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 v-else class="h-full flex items-center justify-center text-color3 py-8">
<n-empty description="暂无匹配的大纲项" />
</div>
</div>
</template>
<script setup lang="ts">
import {
DocumentTextOutline,
BookmarkOutline,
ListOutline,
ImageOutline,
GridOutline,
WarningOutline,
InformationCircleOutline,
NavigateOutline
} from '@vicons/ionicons5'
import type { DocOutlineProps } from './constants'
import { useDocOutlineView } from './functionals'
const props = defineProps<DocOutlineProps>()
const emit = defineEmits<{
(e: 'scroll', event: Event): void
}>()
const {
outlineViewportRef,
filteredOutlineList,
activeOutlineId,
handleOutlineClick,
handleOutlineScroll,
totalHeight,
startOffset,
visibleOutlineItems
} = useDocOutlineView(props)
const handleScroll = (e: Event) => {
handleOutlineScroll(e)
emit('scroll', e)
}
const getItemIcon = (tagName: string) => {
switch (tagName) {
case 'CEP':
case 'TASK':
case 'JC-TASK':
return DocumentTextOutline
case 'TOPIC':
case 'PRETOPIC':
case 'SECTION':
return BookmarkOutline
case 'SUBTASK':
case 'SUBSECT':
return ListOutline
case 'GRAPHIC':
return ImageOutline
case 'TABLE':
return GridOutline
case 'WARNING':
case 'CAUTION':
return WarningOutline
case 'NOTE':
return InformationCircleOutline
default:
return null
}
}
const getItemPaddingLeft = (level: number): string => {
if (level === 0) return '8px'
const pad = Math.max(8, Math.min((level - 1) * 10 + 8, 48))
return `${pad}px`
}
defineExpose({
outlineViewportRef
})
</script>
import type { FlatNode } from '@/views/editor/components/NodeTree/constants'
export type { FlatNode }
export interface XmlTreeViewProps {
pattern: string
isBatchMode: boolean
selectedKeys: Set<string>
translatingNodeId: string | null
isAnyModalVisible: boolean
flatList: FlatNode[]
totalHeight: number
visibleVerticalLines: any[]
startOffset: number
visibleItems: FlatNode[]
effectiveSelectedId: string | null
showDropdown: boolean
contextNodeId: string | null
getNodeIcon: (item: FlatNode) => any
highlightText: (text: string, pattern: string) => string
hasCustomColor: (item: FlatNode) => boolean
getNodeStyle: (item: FlatNode) => any
}
import { useEditorStore } from '@/store/editor'
import type { XmlTreeViewProps } from '../constants'
export function useXmlTreeView(_props: XmlTreeViewProps) {
const editorStore = useEditorStore()
const viewportRef = ref<HTMLElement | null>(null)
const getGraphicKey = (node: any): string => {
let key = node.attributes?.KEY || node.attributes?.GRAPHICKEY || node.attributes?.ID || ''
if (!key && node.children) {
const sheet = node.children.find((c: any) => c.tagName === 'SHEET')
if (sheet && sheet.attributes) {
key = sheet.attributes.GNBR || sheet.attributes.KEY || sheet.attributes.ID || ''
}
}
return key
}
const getTreeItemRefNodes = (node: any): any[] => {
const key = getGraphicKey(node)
if (!key || !editorStore.nodeMap) return []
const refs: any[] = []
for (const item of editorStore.nodeMap.values()) {
const childNode = item.node
const attrs = childNode.attributes || {}
if (attrs.REFID === key || attrs.GRAPHICKEY === key || attrs.STRUCTID === key || attrs.GNBR === key) {
refs.push(childNode)
}
}
return refs
}
const getTreeItemTargetId = (node: any): string | null => {
let refId = node.attributes?.REFID || node.attributes?.STRUCTID || node.attributes?.GRAPHICKEY || node.attributes?.GNBR
if (!refId && node.textContent) {
refId = node.textContent
.trim()
.replace(/^\(?(?:Ref:\s*|参考:\s*)?|\)?$/gi, '')
.replace(/\[Sh\.\d+\]/gi, '')
.trim()
}
if (refId) {
const target = editorStore.findNodeByRef(refId, 'GRAPHIC')
return target ? target.id : null
}
return null
}
const handleTreeRefJump = (node: any) => {
const refs = getTreeItemRefNodes(node)
if (refs.length > 0) {
editorStore.setSelectedNodeId(refs[0].id, true)
}
}
return {
editorStore,
viewportRef,
getGraphicKey,
getTreeItemRefNodes,
getTreeItemTargetId,
handleTreeRefJump
}
}
......@@ -72,3 +72,6 @@ export interface TranslationResponse {
fuzzy_score: number | null
reference_count: number
}
// 文档大纲项接口定义
export type { OutlineItem } from '../components/DocOutlineView/constants'
......@@ -693,10 +693,12 @@ export function useNodeTree(
const itemTop = idx * ITEM_HEIGHT
const vHeight = viewportRef.value.clientHeight
const targetTop = Math.max(0, itemTop - vHeight / 2)
viewportRef.value.scrollTo({
top: Math.max(0, itemTop - vHeight / 2),
top: targetTop,
behavior: 'auto'
})
scrollTop.value = targetTop
}
})
}
......@@ -1673,6 +1675,7 @@ export function useNodeTree(
handleContextMenu,
handleDropdownSelect,
handleScroll,
syncTreeSelection,
hasCustomColor,
getNodeStyle,
effectiveSelectedId,
......
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