Commit 4c7b7627 by pangchong

refactor(editor): 重构编辑器面板逻辑与功能封装

- 将编辑器状态和操作集中封装到 useEditorPanel 组合函数中
- 合并了查找替换、滚动、节点编辑、右键菜单等功能的逻辑,提高代码复用性
- 移除冗余的响应式状态变量,减少组件内状态定义
- 优化右键菜单通过判断是否处于表格内部来控制菜单显示
- 统一使用 appStore 和 editorStore,提升状态管理一致性
- 调整 constants 导出,增加对所有表格相关标签集合的支持
parent 9f354075
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
import { TRANSPARENT_TAGS, ALL_LIST_TAGS, CALS_TABLE_TAGS } from '@/configs/xmlTags' import { TRANSPARENT_TAGS, ALL_LIST_TAGS, CALS_TABLE_TAGS, CALS_TABLE_ALL_TAGS } from '@/configs/xmlTags'
// 容器节点:直接穿透,不独立成块,而是遍历其子节点,以实现细粒度原子级虚拟滚动分块 // 容器节点:直接穿透,不独立成块,而是遍历其子节点,以实现细粒度原子级虚拟滚动分块
export { TRANSPARENT_TAGS, ALL_LIST_TAGS, CALS_TABLE_TAGS } export { TRANSPARENT_TAGS, ALL_LIST_TAGS, CALS_TABLE_TAGS, CALS_TABLE_ALL_TAGS }
export const TRANSPARENT_TAGS_SET = new Set(TRANSPARENT_TAGS) export const TRANSPARENT_TAGS_SET = new Set(TRANSPARENT_TAGS)
export const CALS_TABLE_ALL_TAGS_SET = new Set(CALS_TABLE_ALL_TAGS)
/** /**
* 虚拟滚动:各类节点的默认估计高度(px) * 虚拟滚动:各类节点的默认估计高度(px)
......
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import { useAppStore } from '@/store/app'
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
import { TRANSPARENT_TAGS_SET, getNodeEstimatedHeight, type EditorBlock, type BlockPosition } from '../constants' import {
CALS_TABLE_ALL_TAGS_SET,
getNodeEstimatedHeight,
getEditorBlocks,
type EditorBlock,
type BlockPosition
} from '../constants'
import { addNodeVisible, addNodeMode, addNodeTargetId, addNodeAllowedTags } from '../../NodeTree/functionals'
const getRealNodeId = (id: string): string => { const getRealNodeId = (id: string): string => {
if (id && id.includes('-txt-')) { if (id && id.includes('-txt-')) {
...@@ -15,6 +23,7 @@ const getRealNodeId = (id: string): string => { ...@@ -15,6 +23,7 @@ const getRealNodeId = (id: string): string => {
*/ */
export function useEditorPanel() { export function useEditorPanel() {
const editorStore = useEditorStore() const editorStore = useEditorStore()
const appStore = useAppStore()
// ── 基础状态 ────────────────────────────────────────────────────────────── // ── 基础状态 ──────────────────────────────────────────────────────────────
...@@ -61,53 +70,6 @@ export function useEditorPanel() { ...@@ -61,53 +70,6 @@ export function useEditorPanel() {
// ── 节点扁平化 ──────────────────────────────────────────────────────────── // ── 节点扁平化 ────────────────────────────────────────────────────────────
/**
* 递归构建扁平化文档块列表
* JOBCARD / CEP / TASK 为透明容器,穿透递归其子节点
*/
const getEditorBlocks = (node: XmlNode): EditorBlock[] => {
const blocks: EditorBlock[] = []
const walk = (n: XmlNode) => {
if (n.tagName === 'CEP') {
const virtualHeaderNode: XmlNode = {
id: n.id,
tagName: 'CEP-HEADER',
attributes: n.attributes || {},
textContent: '',
children: [],
mixedContent: [],
parentId: n.parentId
}
blocks.push({ id: virtualHeaderNode.id, tagName: 'CEP-HEADER', rawNode: virtualHeaderNode })
n.children.forEach(walk)
} else if (n.tagName === 'TASK') {
// TASK 节点:创建虚拟 TASK-HEADER 展示任务编号,然后穿透子节点
const virtualTaskHeaderNode: XmlNode = {
id: n.id,
tagName: 'TASK-HEADER',
attributes: n.attributes || {},
textContent: '',
children: [],
mixedContent: [],
parentId: n.parentId
}
blocks.push({ id: virtualTaskHeaderNode.id, tagName: 'TASK-HEADER', rawNode: virtualTaskHeaderNode })
n.children.forEach(walk)
} else if (TRANSPARENT_TAGS_SET.has(n.tagName)) {
n.children.forEach(walk)
} else if (n.tagName === 'TOPIC') {
// 特殊拆分:TOPIC 节点自身仅作为标题 Block,其内容子节点(SUBTASK 等)则作为平级 Block 扁平化,以实现细粒度虚拟化
blocks.push({ id: n.id, tagName: 'TOPIC', rawNode: n })
const contentNodes = n.children.filter((c) => c.tagName !== 'TITLE' && c.tagName !== 'TITLEC')
contentNodes.forEach(walk)
} else {
blocks.push({ id: n.id, tagName: n.tagName, rawNode: n })
}
}
walk(node)
return blocks
}
const blocksList = computed<EditorBlock[]>(() => { const blocksList = computed<EditorBlock[]>(() => {
if (!editorStore.xmlTree) return [] if (!editorStore.xmlTree) return []
return getEditorBlocks(editorStore.xmlTree) return getEditorBlocks(editorStore.xmlTree)
...@@ -458,7 +420,107 @@ export function useEditorPanel() { ...@@ -458,7 +420,107 @@ export function useEditorPanel() {
() => syncEditorScroll(editorStore.selectedNodeId, true) () => syncEditorScroll(editorStore.selectedNodeId, true)
) )
// ── 回到顶部 ────────────────────────────────────────────────────────────
const scrollToTop = () => {
if (viewportRef.value) {
viewportRef.value.scrollTop = 0
}
}
// ── 查找与替换 ──────────────────────────────────────────────────────────
const findReplaceVisible = ref(false)
const toggleFindReplace = () => {
findReplaceVisible.value = !findReplaceVisible.value
}
// 查找快捷键监听 (Ctrl+F)
useKeyboardShortcuts({
onFind: () => {
findReplaceVisible.value = true
}
})
// ── 节点属性修改 ────────────────────────────────────────────────────────
const handleEditSelectedNode = () => {
if (!selectedNode.value) return
const targetId = editorStore.selectedNodeId || selectedNode.value.id
addNodeTargetId.value = targetId
addNodeMode.value = 'edit'
if (targetId.includes('-txt-')) {
addNodeAllowedTags.value = ['#text']
} else {
addNodeAllowedTags.value = [selectedNode.value.tagName]
}
addNodeVisible.value = true
}
// ── 编辑区右键上下文菜单 ──────────────────────────────────────────────────
const contextMenuVisible = ref(false)
const contextMenuNodeIds = ref<string[]>([])
const contextMenuX = ref(0)
const contextMenuY = ref(0)
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault()
const target = e.target as HTMLElement
const wrapper = target.closest('[data-node-id]')
if (!wrapper) return
const rawNodeId = wrapper.getAttribute('data-node-id')
if (!rawNodeId) return
const isVirtualText = rawNodeId.includes('-txt-')
const realId = isVirtualText ? rawNodeId.split('-txt-')[0] : rawNodeId
// 区分单文本节点与多文本节点(混合内容):仅当存在多个文本片段时,才保留 -txt- 虚拟文本节点
let shouldKeepVirtualText = false
if (isVirtualText) {
const mapped = editorStore.nodeMap.get(realId)
if (mapped?.node?.mixedContent) {
const textItems = mapped.node.mixedContent.filter((m) => m.type === 'text' && (m.text || '').trim())
if (textItems.length > 1) {
shouldKeepVirtualText = true
}
}
}
// 向上回溯构建祖先节点 ID 链
const ids: string[] = []
if (isVirtualText && shouldKeepVirtualText) {
ids.push(rawNodeId)
}
let currentId: string | null = realId
let isInsideTable = false
while (currentId) {
const item = editorStore.nodeMap.get(currentId)
if (item) {
if (CALS_TABLE_ALL_TAGS_SET.has(item.node.tagName)) {
isInsideTable = true
}
ids.push(currentId)
currentId = item.parent?.id || null
} else {
break
}
}
// 如果处于表格内部,采用表格专有的操作菜单,因此此处不弹出通用非 Table 菜单
if (isInsideTable) return
if (ids.length > 0) {
contextMenuX.value = e.clientX
contextMenuY.value = e.clientY
contextMenuNodeIds.value = ids
contextMenuVisible.value = true
}
}
return { return {
editorStore,
appStore,
selectedNode, selectedNode,
nodePath, nodePath,
editorTitle, editorTitle,
...@@ -470,6 +532,15 @@ export function useEditorPanel() { ...@@ -470,6 +532,15 @@ export function useEditorPanel() {
setBlockRef, setBlockRef,
syncEditorScroll, syncEditorScroll,
scrollTop, scrollTop,
handleViewportMouseDown handleViewportMouseDown,
scrollToTop,
findReplaceVisible,
toggleFindReplace,
handleEditSelectedNode,
contextMenuVisible,
contextMenuNodeIds,
contextMenuX,
contextMenuY,
handleContextMenu
} }
} }
...@@ -108,132 +108,36 @@ ...@@ -108,132 +108,36 @@
<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 { 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'
import SelectionToolbar from './components/SelectionToolbar/index.vue' import SelectionToolbar from './components/SelectionToolbar/index.vue'
import { useEditorPanel } from './functionals' import { useEditorPanel } from './functionals'
import { addNodeVisible, addNodeMode, addNodeTargetId, addNodeAllowedTags } from '../NodeTree/functionals'
const editorStore = useEditorStore()
const appStore = useAppStore()
const { const {
editorStore,
appStore,
selectedNode, selectedNode,
nodePath, nodePath,
editorTitle,
viewportRef, viewportRef,
totalHeight, totalHeight,
startOffset, startOffset,
visibleBlocks, visibleBlocks,
scrollTop,
handleScroll, handleScroll,
setBlockRef, setBlockRef,
syncEditorScroll, syncEditorScroll,
scrollTop, handleViewportMouseDown,
handleViewportMouseDown scrollToTop,
findReplaceVisible,
toggleFindReplace,
handleEditSelectedNode,
contextMenuVisible,
contextMenuNodeIds,
contextMenuX,
contextMenuY,
handleContextMenu
} = useEditorPanel() } = useEditorPanel()
const findReplaceVisible = ref(false)
const toggleFindReplace = () => {
findReplaceVisible.value = !findReplaceVisible.value
}
const scrollToTop = () => {
if (viewportRef.value) {
viewportRef.value.scrollTop = 0
}
}
// 查找快捷键监听 (Ctrl+F)
useKeyboardShortcuts({
onFind: () => {
findReplaceVisible.value = true
}
})
const handleEditSelectedNode = () => {
if (!selectedNode.value) return
const targetId = editorStore.selectedNodeId || selectedNode.value.id
addNodeTargetId.value = targetId
addNodeMode.value = 'edit'
if (targetId.includes('-txt-')) {
addNodeAllowedTags.value = ['#text']
} else {
addNodeAllowedTags.value = [selectedNode.value.tagName]
}
addNodeVisible.value = true
}
const contextMenuVisible = ref(false)
const contextMenuNodeIds = ref<string[]>([])
const contextMenuX = ref(0)
const contextMenuY = ref(0)
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault()
const target = e.target as HTMLElement
const wrapper = target.closest('[data-node-id]')
if (!wrapper) return
const rawNodeId = wrapper.getAttribute('data-node-id')
if (!rawNodeId) return
const isVirtualText = rawNodeId.includes('-txt-')
const realId = isVirtualText ? rawNodeId.split('-txt-')[0] : rawNodeId
// 区分单文本节点与多文本节点(混合内容):仅当存在多个文本片段时,才保留 -txt- 虚拟文本节点
let shouldKeepVirtualText = false
if (isVirtualText) {
const mapped = editorStore.nodeMap.get(realId)
if (mapped?.node?.mixedContent) {
const textItems = mapped.node.mixedContent.filter((m) => m.type === 'text' && (m.text || '').trim())
if (textItems.length > 1) {
shouldKeepVirtualText = true
}
}
}
// 向上回溯构建祖先节点 ID 链
const ids: string[] = []
if (isVirtualText && shouldKeepVirtualText) {
ids.push(rawNodeId)
}
let currentId: string | null = realId
let isInsideTable = false
while (currentId) {
const item = editorStore.nodeMap.get(currentId)
if (item) {
if (
item.node.tagName === 'TABLE' ||
item.node.tagName === 'ENTRY' ||
item.node.tagName === 'ROW' ||
item.node.tagName === 'TBODY' ||
item.node.tagName === 'TGROUP'
) {
isInsideTable = true
}
ids.push(currentId)
currentId = item.parent?.id || null
} else {
break
}
}
// 如果处于表格内部,采用表格专有的操作菜单,因此此处不弹出通用非 Table 菜单
if (isInsideTable) return
if (ids.length > 0) {
contextMenuX.value = e.clientX
contextMenuY.value = e.clientY
contextMenuNodeIds.value = ids
contextMenuVisible.value = true
}
}
</script> </script>
<style scoped> <style scoped>
......
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