Commit 84c44c7d by pangchong

feat(editor): 支持分页符虚拟辅助排版节点功能

- 在配置中新增虚拟辅助排版标签 PAGEBREAK,区别于普通节点不参与DTD规则约束
- 编辑器内插入分页符时跳过DTD校验,确保允许强制插入
- 右键菜单区分分页符节点,只提供删除和查看XML操作
- 节点树中分页符节点高亮显示,视觉样式区分虚拟排版节点
- 编辑区和工具栏中添加分页符节点的展示和插入控件
- 预览与打印时分页符隐藏视觉标记,仅保留分页效果
- 调整相关逻辑过滤分页符节点,避免计入DTD规则校验统计
- 优化节点选中状态管理,改用Set集合简化逻辑 handling
parent 770f4ed3
......@@ -219,3 +219,7 @@ export const CHINESE_FIRST_PARA_TAGS = ['PARAC', 'PARA']
// 默认翻译目标标签列表
export const TRANSLATE_TARGET_TAGS = ['PARAC', 'TITLEC']
// 虚拟辅助排版标签集(不参与 DTD 规则约束,如打印换页符)
export const VIRTUAL_LAYOUT_TAGS = ['PAGEBREAK']
import type { Ref } from 'vue'
import type { XmlNode } from '@/types/xmlNode'
import type { EditorState } from './types'
import { useAppStore } from '@/store/app'
import { CHINESE_FIRST_PARA_TAGS } from '@/configs/xmlTags'
import { CHINESE_FIRST_PARA_TAGS, VIRTUAL_LAYOUT_TAGS } from '@/configs/xmlTags'
export const nodeSelectedRefs = new Map<string, Ref<boolean>>()
export const nodeSelectedRefs = new Set<string>()
/**
* 辅助函数:根据标签名创建具有完整子结构的默认 XML 节点对象
......@@ -432,7 +431,7 @@ export const useEditorStore = defineStore('editor', {
this.nodeMap = markRaw(map)
},
insertNode(tagName: string, insertBelow: boolean, customNode?: XmlNode) {
insertNode(tagName: string, insertBelow: boolean, customNode?: XmlNode, skipDtdCheck?: boolean) {
if (!this.xmlTree || !this.selectedNodeId) {
window.$message.warning('请先在树中选择一个目标节点!')
return
......@@ -456,10 +455,12 @@ export const useEditorStore = defineStore('editor', {
parentTag = selected.tagName
}
const existingCount = parentNode.children.filter((c) => c.tagName === tagName).length
if (!canAddChild(parentTag, tagName, existingCount)) {
window.$message.warning(`DTD 校验失败: 节点 <${parentTag}> 无法接受子元素 <${tagName}>`)
return
if (!skipDtdCheck) {
const existingCount = parentNode.children.filter((c) => c.tagName === tagName).length
if (!canAddChild(parentTag, tagName, existingCount)) {
window.$message.warning(`DTD 校验失败: 节点 <${parentTag}> 无法接受子元素 <${tagName}>`)
return
}
}
const appStore = useAppStore()
......@@ -553,7 +554,7 @@ export const useEditorStore = defineStore('editor', {
}
// 3. 严格校验 DTD 规则约束
const existingTags = destParentNode.children.map((c) => c.tagName)
const existingTags = destParentNode.children.filter((c) => !VIRTUAL_LAYOUT_TAGS.includes(c.tagName)).map((c) => c.tagName)
for (const node of newNodes) {
const tagName = node.tagName
const currentCount = existingTags.filter((t) => t === tagName).length
......@@ -738,17 +739,7 @@ export const useEditorStore = defineStore('editor', {
}
// 虚拟文本节点(#text):不高亮父节点外框,由文本 span 自身通过 selectedNodeId 比较来显示高亮
const oldRenderedId = this.renderedSelectedNodeId
this.renderedSelectedNodeId = renderedId
if (oldRenderedId && nodeSelectedRefs.has(oldRenderedId)) {
const r = nodeSelectedRefs.get(oldRenderedId)
if (r) r.value = false
}
if (renderedId && nodeSelectedRefs.has(renderedId)) {
const r = nodeSelectedRefs.get(renderedId)
if (r) r.value = true
}
},
/**
......
......@@ -13,10 +13,10 @@ export function useDocNodeRenderer(props: { node: XmlNode; parent?: XmlNode | nu
const editorStore = useEditorStore()
const { locale } = useI18n()
const isSelected = ref(editorStore.renderedSelectedNodeId === props.node.id)
const isSelected = computed(() => editorStore.renderedSelectedNodeId === props.node.id)
onMounted(() => {
nodeSelectedRefs.set(props.node.id, isSelected)
nodeSelectedRefs.add(props.node.id)
// 自动溯源刷新选中:若发现当前选中节点的祖先链中包含当前节点,则触发状态重算,使新挂载的组件正确取得高亮
if (editorStore.selectedNodeId) {
let realId = editorStore.selectedNodeId
......
......@@ -31,6 +31,36 @@
<!-- 0. 各类 HEADER 节点特殊处理 (不展示) -->
<template v-if="isHeaderTag"></template>
<!-- 0b. PAGEBREAK 分页符:编辑区显示虚线提示,打印时触发真实分页 -->
<template v-else-if="node.tagName === 'PAGEBREAK'">
<div class="pagebreak-marker relative flex items-center my-3 select-none" style="page-break-after: always; break-after: page">
<div class="flex-1 border-t border-dashed" style="border-color: color-mix(in srgb, var(--primary-color) 40%, transparent)"></div>
<span
class="mx-2 text-xs font-medium px-2 py-0.5 rounded flex items-center gap-1 flex-shrink-0"
style="
color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 8%, var(--card-color, #ffffff));
border: 1px dashed color-mix(in srgb, var(--primary-color) 40%, transparent);
"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 512 512"
fill="currentColor"
style="display: inline; vertical-align: middle"
>
<path
d="M384 80H128c-26.51 0-48 21.49-48 48v256c0 26.51 21.49 48 48 48h256c26.51 0 48-21.49 48-48V128c0-26.51-21.49-48-48-48zm-32 272H160v-32h192v32zm0-64H160v-32h192v32zm0-64H160v-32h192v32z"
/>
</svg>
分页符 [Page Break]
</span>
<div class="flex-1 border-t border-dashed" style="border-color: color-mix(in srgb, var(--primary-color) 40%, transparent)"></div>
</div>
</template>
<!-- 1. WARNING / CAUTION / NOTE 结构特殊处理 (仿照 PDF) -->
<template v-else-if="ALERT_BLOCK_SET.has(node.tagName)">
<div
......@@ -1446,4 +1476,17 @@ const {
:deep(.n-image img) {
width: 100%;
}
/* 打印时隐藏分页符的视觉标签,仅保留分页效果 */
@media print {
.pagebreak-marker {
height: 0 !important;
margin: 0 !important;
padding: 0 !important;
overflow: hidden !important;
border: none !important;
}
.pagebreak-marker > * {
display: none !important;
}
}
</style>
......@@ -16,6 +16,7 @@ import {
copyNodeCache,
translatingNodeId
} from '@/views/editor/components/NodeTree/functionals'
import { VIRTUAL_LAYOUT_TAGS } from '@/configs/xmlTags'
export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (event: 'update:visible', val: boolean) => void) {
const editorStore = useEditorStore()
......@@ -226,6 +227,11 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
return activeNodeId.value.includes('-txt-') || activeNode.value?.tagName === '#text'
})
// 是否是虚拟排版节点(如分页符)
const isVirtualLayoutNode = computed(() => {
return activeNode.value ? VIRTUAL_LAYOUT_TAGS.includes(activeNode.value.tagName) : false
})
// 智能翻译是否可用(非纯结构节点)
const canTranslateActive = computed(() => {
const node = activeNode.value
......@@ -254,8 +260,10 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
const realId = activeNodeId.value
const mapped = editorStore.nodeMap.get(realId)
if (!mapped || !mapped.parent) return false
// 分页符节点不受 DTD 约束,允许直接删除
if (VIRTUAL_LAYOUT_TAGS.includes(mapped.node.tagName)) return true
const existingTags = mapped.parent.children.map((c) => c.tagName)
const existingTags = mapped.parent.children.filter((c) => !VIRTUAL_LAYOUT_TAGS.includes(c.tagName)).map((c) => c.tagName)
// 位置特许规则:父级中最后一个子节点(按位置)允许删除
if (existingTags.length > 0 && existingTags[existingTags.length - 1] === mapped.node.tagName) {
return true
......@@ -527,6 +535,7 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
activeNodeName,
sourceNodeName,
isTextNode,
isVirtualLayoutNode,
canTranslateActive,
canDeleteActive,
allowedChildrenList,
......
......@@ -27,106 +27,126 @@
<div>
<div class="text-xs text-color3 mb-2 font-medium">可用操作:</div>
<div class="space-y-2">
<div class="grid grid-cols-3 gap-2">
<!-- 1. 编辑节点 -->
<CommonButton type="success" secondary block @click="runAction('editNode')">
<div v-if="isVirtualLayoutNode" class="grid grid-cols-2 gap-2">
<!-- 删除节点 -->
<CommonButton type="error" secondary block :disabled="!canDeleteActive" @click="runAction('deleteNode')">
<template #icon>
<n-icon><create-outline /></n-icon>
<n-icon><trash-outline /></n-icon>
</template>
编辑节点
删除节点
</CommonButton>
<!-- 2. 复制节点 -->
<CommonButton type="primary" secondary block @click="runAction('copyNode')">
<!-- 查看 XML 片段 -->
<CommonButton type="info" secondary block @click="runAction('viewXml')">
<template #icon>
<n-icon><copy-outline /></n-icon>
<n-icon><code-working-outline /></n-icon>
</template>
复制节点
查看 XML
</CommonButton>
</div>
<!-- 3. 粘贴 XML -->
<n-dropdown trigger="click" :options="pasteXmlOptions" :disabled="!hasCopyCache" @select="runAction">
<CommonButton type="success" secondary block :disabled="!hasCopyCache">
<template v-else>
<div class="grid grid-cols-3 gap-2">
<!-- 1. 编辑节点 -->
<CommonButton type="success" secondary block @click="runAction('editNode')">
<template #icon>
<n-icon><clipboard-outline /></n-icon>
<n-icon><create-outline /></n-icon>
</template>
粘贴XML
编辑节点
</CommonButton>
</n-dropdown>
</div>
<div class="grid grid-cols-3 gap-2">
<!-- 4. 删除节点 -->
<CommonButton type="error" secondary block :disabled="!canDeleteActive" @click="runAction('deleteNode')">
<template #icon>
<n-icon><trash-outline /></n-icon>
</template>
删除节点
</CommonButton>
<!-- 5. 编辑结构 -->
<n-dropdown trigger="click" :options="editStructureOptions" :disabled="isTextNode" @select="runAction">
<CommonButton type="warning" secondary block :disabled="isTextNode">
<!-- 2. 复制节点 -->
<CommonButton type="primary" secondary block @click="runAction('copyNode')">
<template #icon>
<n-icon><build-outline /></n-icon>
<n-icon><copy-outline /></n-icon>
</template>
编辑结构
复制节点
</CommonButton>
</n-dropdown>
<!-- 6. 粘贴节点 (DTD) -->
<n-dropdown trigger="click" :options="pasteNodeOptions" :disabled="!hasCopyCache" @select="runAction">
<CommonButton type="warning" secondary block :disabled="!hasCopyCache">
<!-- 3. 粘贴 XML -->
<n-dropdown trigger="click" :options="pasteXmlOptions" :disabled="!hasCopyCache" @select="runAction">
<CommonButton type="success" secondary block :disabled="!hasCopyCache">
<template #icon>
<n-icon><clipboard-outline /></n-icon>
</template>
粘贴XML
</CommonButton>
</n-dropdown>
</div>
<div class="grid grid-cols-3 gap-2">
<!-- 4. 删除节点 -->
<CommonButton type="error" secondary block :disabled="!canDeleteActive" @click="runAction('deleteNode')">
<template #icon>
<n-icon><clipboard-outline /></n-icon>
<n-icon><trash-outline /></n-icon>
</template>
粘贴节点
删除节点
</CommonButton>
</n-dropdown>
</div>
<div class="grid grid-cols-3 gap-2">
<!-- 7. 智能翻译 -->
<CommonButton
type="info"
secondary
block
:disabled="!canTranslateActive || isTranslating(activeNodeId)"
@click="runAction('translateNode')"
>
<template #icon>
<n-icon v-if="isTranslating(activeNodeId)" class="animate-spin"><sync-outline /></n-icon>
<n-icon v-else><language-outline /></n-icon>
</template>
智能翻译
</CommonButton>
<!-- 5. 编辑结构 -->
<n-dropdown trigger="click" :options="editStructureOptions" :disabled="isTextNode" @select="runAction">
<CommonButton type="warning" secondary block :disabled="isTextNode">
<template #icon>
<n-icon><build-outline /></n-icon>
</template>
编辑结构
</CommonButton>
</n-dropdown>
<!-- 6. 粘贴节点 (DTD) -->
<n-dropdown trigger="click" :options="pasteNodeOptions" :disabled="!hasCopyCache" @select="runAction">
<CommonButton type="warning" secondary block :disabled="!hasCopyCache">
<template #icon>
<n-icon><clipboard-outline /></n-icon>
</template>
粘贴节点
</CommonButton>
</n-dropdown>
</div>
<!-- 8. 查看 XML 片段 -->
<CommonButton type="info" secondary block @click="runAction('viewXml')">
<template #icon>
<n-icon><code-working-outline /></n-icon>
</template>
查看 XML
</CommonButton>
<div class="grid grid-cols-3 gap-2">
<!-- 7. 智能翻译 -->
<CommonButton
type="info"
secondary
block
:disabled="!canTranslateActive || isTranslating(activeNodeId)"
@click="runAction('translateNode')"
>
<template #icon>
<n-icon v-if="isTranslating(activeNodeId)" class="animate-spin"><sync-outline /></n-icon>
<n-icon v-else><language-outline /></n-icon>
</template>
智能翻译
</CommonButton>
<!-- 9. 查看 DTD 规则 -->
<CommonButton type="info" secondary block :disabled="isTextNode" @click="runAction('checkRule')">
<template #icon>
<n-icon><eye-outline /></n-icon>
</template>
查看规则
</CommonButton>
</div>
<!-- 8. 查看 XML 片段 -->
<CommonButton type="info" secondary block @click="runAction('viewXml')">
<template #icon>
<n-icon><code-working-outline /></n-icon>
</template>
查看 XML
</CommonButton>
<!-- 保存为模板 -->
<div class="grid grid-cols-3 gap-2">
<CommonButton type="primary" secondary block class="col-span-3" @click="runAction('saveTemplate')">
<template #icon>
<n-icon><save-outline /></n-icon>
</template>
保存为模板
</CommonButton>
</div>
<!-- 9. 查看 DTD 规则 -->
<CommonButton type="info" secondary block :disabled="isTextNode" @click="runAction('checkRule')">
<template #icon>
<n-icon><eye-outline /></n-icon>
</template>
查看规则
</CommonButton>
</div>
<!-- 保存为模板 -->
<div class="grid grid-cols-3 gap-2">
<CommonButton type="primary" secondary block class="col-span-3" @click="runAction('saveTemplate')">
<template #icon>
<n-icon><save-outline /></n-icon>
</template>
保存为模板
</CommonButton>
</div>
</template>
</div>
</div>
</div>
......@@ -164,6 +184,7 @@ const {
activeNodeName,
sourceNodeName,
isTextNode,
isVirtualLayoutNode,
canTranslateActive,
canDeleteActive,
hasCopyCache,
......
......@@ -9,6 +9,7 @@ import {
checkRuleData,
translatingNodeId
} from '@/views/editor/components/NodeTree/functionals'
import { VIRTUAL_LAYOUT_TAGS } from '@/configs/xmlTags'
// ── 翻译相关的通用辅助函数 (逻辑与树节点及右键上下文菜单完全一致) ──
......@@ -49,6 +50,9 @@ export function useSelectionToolbar() {
const hasActiveNode = computed(() => !!activeNode.value)
const isVirtualText = computed(() => !!activeNodeId.value && activeNodeId.value.includes('-txt-'))
const isVirtualLayoutNode = computed(() => {
return activeNode.value ? VIRTUAL_LAYOUT_TAGS.includes(activeNode.value.tagName) : false
})
const hasParent = computed(() => {
if (isVirtualText.value) return true
......@@ -63,9 +67,9 @@ export function useSelectionToolbar() {
const canDeleteCurrent = computed(() => {
if (!activeNode.value) return false
if (isVirtualText.value) return true
if (isVirtualText.value || VIRTUAL_LAYOUT_TAGS.includes(activeNode.value.tagName)) return true
if (!parentNode.value) return false
const existingTags = parentNode.value.children.map((c) => c.tagName)
const existingTags = parentNode.value.children.filter((c) => !VIRTUAL_LAYOUT_TAGS.includes(c.tagName)).map((c) => c.tagName)
// 位置特许规则:父级中最后一个子节点(按位置)允许删除
if (existingTags.length > 0 && existingTags[existingTags.length - 1] === activeNode.value.tagName) {
return true
......@@ -383,7 +387,7 @@ export function useSelectionToolbar() {
addNodeMode.value = 'before'
addNodeAllowedTags.value = getInsertableChildren(
parentNode.value.tagName,
parentNode.value.children.map((c) => c.tagName)
parentNode.value.children.filter((c) => !VIRTUAL_LAYOUT_TAGS.includes(c.tagName)).map((c) => c.tagName)
)
}
addNodeVisible.value = true
......@@ -402,7 +406,7 @@ export function useSelectionToolbar() {
addNodeMode.value = 'after'
addNodeAllowedTags.value = getInsertableChildren(
parentNode.value.tagName,
parentNode.value.children.map((c) => c.tagName)
parentNode.value.children.filter((c) => !VIRTUAL_LAYOUT_TAGS.includes(c.tagName)).map((c) => c.tagName)
)
}
addNodeVisible.value = true
......@@ -442,6 +446,7 @@ export function useSelectionToolbar() {
left,
hasActiveNode,
isVirtualText,
isVirtualLayoutNode,
hasParent,
hasAllowedChildren,
canDeleteCurrent,
......
......@@ -13,90 +13,92 @@
>
<!-- 节点操作区域 -->
<div class="flex items-center space-x-1">
<!-- 编辑节点属性 -->
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton size="tiny" quaternary circle :disabled="!hasActiveNode" @click="handleEditNode">
<template #icon>
<n-icon><SettingsOutline /></n-icon>
</template>
</CommonButton>
</template>
编辑节点属性
</n-tooltip>
<template v-if="!isVirtualLayoutNode">
<!-- 编辑节点属性 -->
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton size="tiny" quaternary circle :disabled="!hasActiveNode" @click="handleEditNode">
<template #icon>
<n-icon><SettingsOutline /></n-icon>
</template>
</CommonButton>
</template>
编辑节点属性
</n-tooltip>
<!-- 查看规则 -->
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton size="tiny" quaternary circle :disabled="!hasActiveNode || isVirtualText" @click="handleViewRule">
<template #icon>
<n-icon><EyeOutline /></n-icon>
</template>
</CommonButton>
</template>
查看 DTD 规则
</n-tooltip>
<!-- 查看规则 -->
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton size="tiny" quaternary circle :disabled="!hasActiveNode || isVirtualText" @click="handleViewRule">
<template #icon>
<n-icon><EyeOutline /></n-icon>
</template>
</CommonButton>
</template>
查看 DTD 规则
</n-tooltip>
<!-- 智能翻译 -->
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton
size="tiny"
quaternary
circle
:disabled="!hasActiveNode || !canTranslateActive || isTranslating"
@click="handleTranslateNode"
>
<template #icon>
<n-icon v-if="isTranslating" class="animate-spin"><SyncOutline /></n-icon>
<n-icon v-else><LanguageOutline /></n-icon>
</template>
</CommonButton>
</template>
智能翻译
</n-tooltip>
<!-- 智能翻译 -->
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton
size="tiny"
quaternary
circle
:disabled="!hasActiveNode || !canTranslateActive || isTranslating"
@click="handleTranslateNode"
>
<template #icon>
<n-icon v-if="isTranslating" class="animate-spin"><SyncOutline /></n-icon>
<n-icon v-else><LanguageOutline /></n-icon>
</template>
</CommonButton>
</template>
智能翻译
</n-tooltip>
<!-- 添加子节点 -->
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton
size="tiny"
quaternary
circle
:disabled="!hasActiveNode || isVirtualText || !hasAllowedChildren"
@click="handleAddChild"
>
<template #icon>
<n-icon><AddCircleOutline /></n-icon>
</template>
</CommonButton>
</template>
添加子节点
</n-tooltip>
<!-- 添加子节点 -->
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton
size="tiny"
quaternary
circle
:disabled="!hasActiveNode || isVirtualText || !hasAllowedChildren"
@click="handleAddChild"
>
<template #icon>
<n-icon><AddCircleOutline /></n-icon>
</template>
</CommonButton>
</template>
添加子节点
</n-tooltip>
<!-- 插入到上方 -->
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton size="tiny" quaternary circle :disabled="!hasActiveNode || !hasParent" @click="handleInsertBefore">
<template #icon>
<n-icon><ArrowUpOutline /></n-icon>
</template>
</CommonButton>
</template>
插入到上方
</n-tooltip>
<!-- 插入到上方 -->
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton size="tiny" quaternary circle :disabled="!hasActiveNode || !hasParent" @click="handleInsertBefore">
<template #icon>
<n-icon><ArrowUpOutline /></n-icon>
</template>
</CommonButton>
</template>
插入到上方
</n-tooltip>
<!-- 插入到下方 -->
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton size="tiny" quaternary circle :disabled="!hasActiveNode || !hasParent" @click="handleInsertAfter">
<template #icon>
<n-icon><ArrowDownOutline /></n-icon>
</template>
</CommonButton>
</template>
插入到下方
</n-tooltip>
<!-- 插入到下方 -->
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton size="tiny" quaternary circle :disabled="!hasActiveNode || !hasParent" @click="handleInsertAfter">
<template #icon>
<n-icon><ArrowDownOutline /></n-icon>
</template>
</CommonButton>
</template>
插入到下方
</n-tooltip>
</template>
<!-- 删除节点 -->
<n-tooltip trigger="hover">
......@@ -140,6 +142,7 @@ const {
left,
hasActiveNode,
isVirtualText,
isVirtualLayoutNode,
hasParent,
hasAllowedChildren,
canDeleteCurrent,
......
import { ImageOutline, GridOutline, DocumentTextOutline, CreateOutline, RemoveOutline, ListOutline } from '@vicons/ionicons5'
import { ImageOutline, GridOutline, DocumentTextOutline, CreateOutline, RemoveOutline, ListOutline, ReturnDownForwardOutline } from '@vicons/ionicons5'
/**
* EditorToolbar 组件级静态常量
......@@ -11,5 +11,6 @@ export const GREEN_BUTTONS: any[] = [
{ label: '插入模板', tag: 'TEMPLATE', icon: DocumentTextOutline },
{ label: '插入签字点', tag: 'SIGNOFF', icon: CreateOutline },
{ label: '插入选项组', tag: 'SELECTION', icon: ListOutline },
{ label: '插入下划线', tag: 'RECORD-LINE', icon: RemoveOutline }
{ label: '插入下划线', tag: 'RECORD-LINE', icon: RemoveOutline },
{ label: '插入分页符', tag: 'PAGEBREAK', icon: ReturnDownForwardOutline }
]
......@@ -28,6 +28,18 @@ export function useEditorToolbar(emit: any) {
return
}
// PAGEBREAK 特殊处理:不受 DTD 约束,强制插入到当前选中节点下方
if (tag === 'PAGEBREAK') {
const selected = editorStore.selectedNode
if (!selected) {
window.$message.warning('请先在树中选择一个目标节点!')
return
}
// 分页符始终插入到选中节点下方(作为兄弟节点),跳过 DTD 校验
editorStore.insertNode('PAGEBREAK', true, undefined, true)
return
}
const selected = editorStore.selectedNode
if (!selected) {
window.$message.warning('请先在树中选择一个目标节点!')
......
......@@ -211,6 +211,32 @@
</div>
<div class="text-[10px] text-color3 mt-2 text-center leading-relaxed">插入下划线记录行,提供数据填报输入项。</div>
</template>
<template v-else-if="btn.tag === 'PAGEBREAK'">
<div class="border border-divider rounded-lg bg-fill-2 p-2 flex flex-col gap-1.5">
<div class="w-full flex items-center justify-between pb-1 border-b border-divider mb-1.5">
<span class="text-[9px] font-bold text-color2 flex items-center gap-1">
<n-icon class="text-[10px]"><component :is="btn.icon" /></n-icon>
<span>打印强制分页符</span>
</span>
<span class="text-[8px] bg-primary-1 text-primary px-1 rounded font-bold scale-[0.9]">PAGEBREAK</span>
</div>
<div class="flex items-center select-none w-full scale-[0.9] origin-left my-1.5">
<div class="flex-1 border-t border-dashed" style="border-color: color-mix(in srgb, var(--primary-color) 40%, transparent)"></div>
<span
class="mx-1 text-[8px] px-1 py-0.5 rounded border border-dashed flex-shrink-0"
style="
color: var(--primary-color);
border-color: color-mix(in srgb, var(--primary-color) 40%, transparent);
background: color-mix(in srgb, var(--primary-color) 8%, var(--card-color, #ffffff));
"
>
分页符 [Page Break]
</span>
<div class="flex-1 border-t border-dashed" style="border-color: color-mix(in srgb, var(--primary-color) 40%, transparent)"></div>
</div>
</div>
<div class="text-[10px] text-color3 mt-2 text-center leading-relaxed">在选定节点下方插入分页标记,导出和打印预览时在该处换页。</div>
</template>
</div>
</div>
</n-popover>
......
import { useEditorStore } from '@/store/editor'
import type { XmlNode } from '@/types/xmlNode'
import type { BlockedNodeDetail, SafeNodeItem } from '../../../constants'
import { VIRTUAL_LAYOUT_TAGS } from '@/configs/xmlTags'
export function useBatchDeleteConfirmModal(emit: (event: 'confirm') => void) {
const editorStore = useEditorStore()
......@@ -162,6 +163,12 @@ const partitionBatchDelete = (selectedNodeIds: string[], nodeMap: Map<string, {
})
continue
}
// 分页符等虚拟排版节点不受 DTD 约束,直接允许删除
if (VIRTUAL_LAYOUT_TAGS.includes(item.node.tagName)) {
safeIds.push(nodeId)
confirmedSafeSet.add(nodeId)
continue
}
const parentId = item.parent.id
......@@ -188,7 +195,7 @@ const partitionBatchDelete = (selectedNodeIds: string[], nodeMap: Map<string, {
if (!parentRemainingTagsMap.has(parentId)) {
parentRemainingTagsMap.set(
parentId,
item.parent.children.map((c) => c.tagName)
item.parent.children.filter((c) => !VIRTUAL_LAYOUT_TAGS.includes(c.tagName)).map((c) => c.tagName)
)
}
const remaining = parentRemainingTagsMap.get(parentId)!
......@@ -198,7 +205,7 @@ const partitionBatchDelete = (selectedNodeIds: string[], nodeMap: Map<string, {
if (!(parentRemainingTagsMap as any).has(parentOriginalTagsKey)) {
;(parentRemainingTagsMap as any).set(
parentOriginalTagsKey,
item.parent.children.map((c) => c.tagName)
item.parent.children.filter((c) => !VIRTUAL_LAYOUT_TAGS.includes(c.tagName)).map((c) => c.tagName)
)
}
const originalTags: string[] = (parentRemainingTagsMap as any).get(parentOriginalTagsKey)!
......
......@@ -22,7 +22,7 @@ import { useStashStore } from '@/store/stash'
import type { XmlNode } from '@/types/xmlNode'
import type { CheckRuleData, InsertMode, FlatNode, TranslationResponse } from '../constants'
import { DOCUMENT_LIKE_TAGS } from '../constants'
import { STRUCTURAL_TAGS, WARNING_LIKE_TAGS, NOTE_AND_REF_TAGS, COLORED_TAGS } from '@/configs/xmlTags'
import { STRUCTURAL_TAGS, WARNING_LIKE_TAGS, NOTE_AND_REF_TAGS, COLORED_TAGS, VIRTUAL_LAYOUT_TAGS } from '@/configs/xmlTags'
// ══════════════════════════════════════════════════════════
// 全局共享状态:查看规则弹窗
......@@ -750,6 +750,24 @@ export function useNodeTree(
const options: DropdownOption[] = []
// 如果是虚拟辅助排版节点(如分页符),只提供“查看XML”与“删除节点”操作
if (VIRTUAL_LAYOUT_TAGS.includes(node.tagName)) {
options.push({
label: '查看XML',
key: 'viewXml',
icon: icon(CodeWorkingOutline)
})
if (parent) {
options.push({
label: '删除节点',
key: 'deleteNode',
icon: icon(TrashOutline),
disabled: false
})
}
return options
}
// 查看XML
options.push({
label: '查看XML',
......@@ -835,12 +853,12 @@ export function useNodeTree(
children: pasteFragmentChildren
})
// 删除节点(受 DTD 约束,文本节点永远允许删除)
// 删除节点(受 DTD 约束,文本节点和分页符节点永远允许删除)
if (parent) {
const deletable = isVirtual
const deletable = (isVirtual || VIRTUAL_LAYOUT_TAGS.includes(node.tagName))
? true
: (() => {
const existingTags = parent.children.map((c) => c.tagName)
const existingTags = parent.children.filter((c) => !VIRTUAL_LAYOUT_TAGS.includes(c.tagName)).map((c) => c.tagName)
// 位置特许规则:父级中最后一个子节点(按位置)允许删除
if (existingTags.length > 0 && existingTags[existingTags.length - 1] === node.tagName) {
return true
......@@ -874,7 +892,7 @@ export function useNodeTree(
if (parent) {
const insertable = getInsertableChildren(
parent.tagName,
parent.children.map((c) => c.tagName)
parent.children.filter((c) => !VIRTUAL_LAYOUT_TAGS.includes(c.tagName)).map((c) => c.tagName)
)
if (insertable.length > 0) {
structureChildren.push({
......@@ -1226,7 +1244,7 @@ export function useNodeTree(
case 'addChildNode': {
let allowed = getInsertableChildren(
node.tagName,
node.children.map((c) => c.tagName)
node.children.filter((c) => !VIRTUAL_LAYOUT_TAGS.includes(c.tagName)).map((c) => c.tagName)
)
if (isMixedContentElement(node.tagName) || isTextOnlyElement(node.tagName)) {
if (!allowed.includes('#text')) {
......@@ -1243,7 +1261,7 @@ export function useNodeTree(
if (!parent) break
let insertable = getInsertableChildren(
parent.tagName,
parent.children.map((c) => c.tagName)
parent.children.filter((c) => !VIRTUAL_LAYOUT_TAGS.includes(c.tagName)).map((c) => c.tagName)
)
if (isMixedContentElement(parent.tagName) || isTextOnlyElement(parent.tagName)) {
if (!insertable.includes('#text')) {
......@@ -1260,7 +1278,7 @@ export function useNodeTree(
if (!parent) break
let insertable = getInsertableChildren(
parent.tagName,
parent.children.map((c) => c.tagName)
parent.children.filter((c) => !VIRTUAL_LAYOUT_TAGS.includes(c.tagName)).map((c) => c.tagName)
)
if (isMixedContentElement(parent.tagName) || isTextOnlyElement(parent.tagName)) {
if (!insertable.includes('#text')) {
......
......@@ -68,7 +68,8 @@
? 'tree-node-context-active'
: hasCustomColor(item)
? 'hover:bg-fill-3'
: 'hover:bg-fill-3 text-color2'
: 'hover:bg-fill-3 text-color2',
VIRTUAL_LAYOUT_TAGS.includes(item.tagName) ? 'tree-node-pagebreak' : ''
]"
:style="[
{
......@@ -212,6 +213,7 @@
<script setup lang="ts">
import { SearchOutline, ListOutline, TrashOutline, CloseOutline, SyncOutline, ArrowUpOutline } from '@vicons/ionicons5'
import { useEditorStore } from '@/store/editor'
import { VIRTUAL_LAYOUT_TAGS } from '@/configs/xmlTags'
import {
useNodeTree,
checkRuleVisible,
......@@ -485,4 +487,21 @@ const batchDeleteConfirmModalRef = ref<any>(null)
opacity: 0;
transform: translateY(10px) scale(0.9);
}
/* ── 虚拟排版辅助节点(如 PAGEBREAK)在树中的凸显样式 ── */
.tree-node-pagebreak {
color: var(--primary-color) !important;
background-color: color-mix(in srgb, var(--primary-color) 6%, transparent) !important;
border: 1px dashed color-mix(in srgb, var(--primary-color) 30%, transparent) !important;
margin: 2px 0;
border-radius: 4px;
}
.tree-node-pagebreak:hover {
background-color: color-mix(in srgb, var(--primary-color) 12%, transparent) !important;
}
.tree-node-selected.tree-node-pagebreak {
background-color: var(--primary-color) !important;
color: #ffffff !important;
border: 1px dashed color-mix(in srgb, var(--card-color, #ffffff) 40%, transparent) !important;
}
</style>
......@@ -2,6 +2,7 @@ import { useEditorStore } from '@/store/editor'
import type { XmlNode } from '@/types/xmlNode'
// 静态导入 DTD JSON
import dtdJson from '@/assets/json/dtd.json'
import { VIRTUAL_LAYOUT_TAGS } from '@/configs/xmlTags'
import { DEFAULT_XML_FILE } from '@/configs'
// 导入通用及定制环境目录下的本地默认模板 XML 文本
const xmlModules = import.meta.glob(['../../../assets/file/*.xml', '../../../*/assets/file/*.xml'], {
......@@ -91,6 +92,11 @@ export function useEditor() {
const warnings: string[] = []
const validateNode = (node: XmlNode) => {
// 虚拟辅助排版节点本身不需要 DTD 验证
if (VIRTUAL_LAYOUT_TAGS.includes(node.tagName)) {
return
}
const rule = getElementRule(node.tagName)
if (rule) {
for (const [attrName, attrDef] of Object.entries(rule.attributes)) {
......@@ -99,6 +105,10 @@ export function useEditor() {
}
}
for (const child of node.children) {
// 分页符等虚拟节点不参与 DTD 子节点合法性校验
if (VIRTUAL_LAYOUT_TAGS.includes(child.tagName)) {
continue
}
if (!rule.allowedChildren.includes(child.tagName)) {
warnings.push(`节点 <${node.tagName}> 包含不允许的子节点 <${child.tagName}>`)
}
......@@ -257,6 +267,17 @@ export function useEditor() {
font-size: 12px !important;
line-height: 1.2 !important;
}
/* 预览和打印时隐藏分页符的虚线及视觉文字标签,仅保留物理分页效果 */
.print-preview-root .pagebreak-marker {
height: 0 !important;
margin: 0 !important;
padding: 0 !important;
overflow: hidden !important;
border: none !important;
}
.print-preview-root .pagebreak-marker > * {
display: none !important;
}
/* 移除多余的块级 margin */
.print-preview-root .doc-node-wrapper.block {
margin-top: 0px !important;
......
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