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'] ...@@ -219,3 +219,7 @@ export const CHINESE_FIRST_PARA_TAGS = ['PARAC', 'PARA']
// 默认翻译目标标签列表 // 默认翻译目标标签列表
export const TRANSLATE_TARGET_TAGS = ['PARAC', 'TITLEC'] 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 { XmlNode } from '@/types/xmlNode'
import type { EditorState } from './types' import type { EditorState } from './types'
import { useAppStore } from '@/store/app' 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 节点对象 * 辅助函数:根据标签名创建具有完整子结构的默认 XML 节点对象
...@@ -432,7 +431,7 @@ export const useEditorStore = defineStore('editor', { ...@@ -432,7 +431,7 @@ export const useEditorStore = defineStore('editor', {
this.nodeMap = markRaw(map) 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) { if (!this.xmlTree || !this.selectedNodeId) {
window.$message.warning('请先在树中选择一个目标节点!') window.$message.warning('请先在树中选择一个目标节点!')
return return
...@@ -456,10 +455,12 @@ export const useEditorStore = defineStore('editor', { ...@@ -456,10 +455,12 @@ export const useEditorStore = defineStore('editor', {
parentTag = selected.tagName parentTag = selected.tagName
} }
const existingCount = parentNode.children.filter((c) => c.tagName === tagName).length if (!skipDtdCheck) {
if (!canAddChild(parentTag, tagName, existingCount)) { const existingCount = parentNode.children.filter((c) => c.tagName === tagName).length
window.$message.warning(`DTD 校验失败: 节点 <${parentTag}> 无法接受子元素 <${tagName}>`) if (!canAddChild(parentTag, tagName, existingCount)) {
return window.$message.warning(`DTD 校验失败: 节点 <${parentTag}> 无法接受子元素 <${tagName}>`)
return
}
} }
const appStore = useAppStore() const appStore = useAppStore()
...@@ -553,7 +554,7 @@ export const useEditorStore = defineStore('editor', { ...@@ -553,7 +554,7 @@ export const useEditorStore = defineStore('editor', {
} }
// 3. 严格校验 DTD 规则约束 // 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) { for (const node of newNodes) {
const tagName = node.tagName const tagName = node.tagName
const currentCount = existingTags.filter((t) => t === tagName).length const currentCount = existingTags.filter((t) => t === tagName).length
...@@ -738,17 +739,7 @@ export const useEditorStore = defineStore('editor', { ...@@ -738,17 +739,7 @@ export const useEditorStore = defineStore('editor', {
} }
// 虚拟文本节点(#text):不高亮父节点外框,由文本 span 自身通过 selectedNodeId 比较来显示高亮 // 虚拟文本节点(#text):不高亮父节点外框,由文本 span 自身通过 selectedNodeId 比较来显示高亮
const oldRenderedId = this.renderedSelectedNodeId
this.renderedSelectedNodeId = renderedId 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 ...@@ -13,10 +13,10 @@ export function useDocNodeRenderer(props: { node: XmlNode; parent?: XmlNode | nu
const editorStore = useEditorStore() const editorStore = useEditorStore()
const { locale } = useI18n() const { locale } = useI18n()
const isSelected = ref(editorStore.renderedSelectedNodeId === props.node.id) const isSelected = computed(() => editorStore.renderedSelectedNodeId === props.node.id)
onMounted(() => { onMounted(() => {
nodeSelectedRefs.set(props.node.id, isSelected) nodeSelectedRefs.add(props.node.id)
// 自动溯源刷新选中:若发现当前选中节点的祖先链中包含当前节点,则触发状态重算,使新挂载的组件正确取得高亮 // 自动溯源刷新选中:若发现当前选中节点的祖先链中包含当前节点,则触发状态重算,使新挂载的组件正确取得高亮
if (editorStore.selectedNodeId) { if (editorStore.selectedNodeId) {
let realId = editorStore.selectedNodeId let realId = editorStore.selectedNodeId
......
...@@ -31,6 +31,36 @@ ...@@ -31,6 +31,36 @@
<!-- 0. 各类 HEADER 节点特殊处理 (不展示) --> <!-- 0. 各类 HEADER 节点特殊处理 (不展示) -->
<template v-if="isHeaderTag"></template> <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) --> <!-- 1. WARNING / CAUTION / NOTE 结构特殊处理 (仿照 PDF) -->
<template v-else-if="ALERT_BLOCK_SET.has(node.tagName)"> <template v-else-if="ALERT_BLOCK_SET.has(node.tagName)">
<div <div
...@@ -1446,4 +1476,17 @@ const { ...@@ -1446,4 +1476,17 @@ const {
:deep(.n-image img) { :deep(.n-image img) {
width: 100%; 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> </style>
...@@ -16,6 +16,7 @@ import { ...@@ -16,6 +16,7 @@ import {
copyNodeCache, copyNodeCache,
translatingNodeId translatingNodeId
} from '@/views/editor/components/NodeTree/functionals' } 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) { export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (event: 'update:visible', val: boolean) => void) {
const editorStore = useEditorStore() const editorStore = useEditorStore()
...@@ -226,6 +227,11 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e ...@@ -226,6 +227,11 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
return activeNodeId.value.includes('-txt-') || activeNode.value?.tagName === '#text' 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 canTranslateActive = computed(() => {
const node = activeNode.value const node = activeNode.value
...@@ -254,8 +260,10 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e ...@@ -254,8 +260,10 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
const realId = activeNodeId.value const realId = activeNodeId.value
const mapped = editorStore.nodeMap.get(realId) const mapped = editorStore.nodeMap.get(realId)
if (!mapped || !mapped.parent) return false 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) { if (existingTags.length > 0 && existingTags[existingTags.length - 1] === mapped.node.tagName) {
return true return true
...@@ -527,6 +535,7 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e ...@@ -527,6 +535,7 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
activeNodeName, activeNodeName,
sourceNodeName, sourceNodeName,
isTextNode, isTextNode,
isVirtualLayoutNode,
canTranslateActive, canTranslateActive,
canDeleteActive, canDeleteActive,
allowedChildrenList, allowedChildrenList,
......
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
checkRuleData, checkRuleData,
translatingNodeId translatingNodeId
} from '@/views/editor/components/NodeTree/functionals' } from '@/views/editor/components/NodeTree/functionals'
import { VIRTUAL_LAYOUT_TAGS } from '@/configs/xmlTags'
// ── 翻译相关的通用辅助函数 (逻辑与树节点及右键上下文菜单完全一致) ── // ── 翻译相关的通用辅助函数 (逻辑与树节点及右键上下文菜单完全一致) ──
...@@ -49,6 +50,9 @@ export function useSelectionToolbar() { ...@@ -49,6 +50,9 @@ export function useSelectionToolbar() {
const hasActiveNode = computed(() => !!activeNode.value) const hasActiveNode = computed(() => !!activeNode.value)
const isVirtualText = computed(() => !!activeNodeId.value && activeNodeId.value.includes('-txt-')) 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(() => { const hasParent = computed(() => {
if (isVirtualText.value) return true if (isVirtualText.value) return true
...@@ -63,9 +67,9 @@ export function useSelectionToolbar() { ...@@ -63,9 +67,9 @@ export function useSelectionToolbar() {
const canDeleteCurrent = computed(() => { const canDeleteCurrent = computed(() => {
if (!activeNode.value) return false 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 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) { if (existingTags.length > 0 && existingTags[existingTags.length - 1] === activeNode.value.tagName) {
return true return true
...@@ -383,7 +387,7 @@ export function useSelectionToolbar() { ...@@ -383,7 +387,7 @@ export function useSelectionToolbar() {
addNodeMode.value = 'before' addNodeMode.value = 'before'
addNodeAllowedTags.value = getInsertableChildren( addNodeAllowedTags.value = getInsertableChildren(
parentNode.value.tagName, 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 addNodeVisible.value = true
...@@ -402,7 +406,7 @@ export function useSelectionToolbar() { ...@@ -402,7 +406,7 @@ export function useSelectionToolbar() {
addNodeMode.value = 'after' addNodeMode.value = 'after'
addNodeAllowedTags.value = getInsertableChildren( addNodeAllowedTags.value = getInsertableChildren(
parentNode.value.tagName, 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 addNodeVisible.value = true
...@@ -442,6 +446,7 @@ export function useSelectionToolbar() { ...@@ -442,6 +446,7 @@ export function useSelectionToolbar() {
left, left,
hasActiveNode, hasActiveNode,
isVirtualText, isVirtualText,
isVirtualLayoutNode,
hasParent, hasParent,
hasAllowedChildren, hasAllowedChildren,
canDeleteCurrent, canDeleteCurrent,
......
...@@ -13,90 +13,92 @@ ...@@ -13,90 +13,92 @@
> >
<!-- 节点操作区域 --> <!-- 节点操作区域 -->
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
<!-- 编辑节点属性 --> <template v-if="!isVirtualLayoutNode">
<n-tooltip trigger="hover"> <!-- 编辑节点属性 -->
<template #trigger> <n-tooltip trigger="hover">
<CommonButton size="tiny" quaternary circle :disabled="!hasActiveNode" @click="handleEditNode"> <template #trigger>
<template #icon> <CommonButton size="tiny" quaternary circle :disabled="!hasActiveNode" @click="handleEditNode">
<n-icon><SettingsOutline /></n-icon> <template #icon>
</template> <n-icon><SettingsOutline /></n-icon>
</CommonButton> </template>
</template> </CommonButton>
编辑节点属性 </template>
</n-tooltip> 编辑节点属性
</n-tooltip>
<!-- 查看规则 --> <!-- 查看规则 -->
<n-tooltip trigger="hover"> <n-tooltip trigger="hover">
<template #trigger> <template #trigger>
<CommonButton size="tiny" quaternary circle :disabled="!hasActiveNode || isVirtualText" @click="handleViewRule"> <CommonButton size="tiny" quaternary circle :disabled="!hasActiveNode || isVirtualText" @click="handleViewRule">
<template #icon> <template #icon>
<n-icon><EyeOutline /></n-icon> <n-icon><EyeOutline /></n-icon>
</template> </template>
</CommonButton> </CommonButton>
</template> </template>
查看 DTD 规则 查看 DTD 规则
</n-tooltip> </n-tooltip>
<!-- 智能翻译 --> <!-- 智能翻译 -->
<n-tooltip trigger="hover"> <n-tooltip trigger="hover">
<template #trigger> <template #trigger>
<CommonButton <CommonButton
size="tiny" size="tiny"
quaternary quaternary
circle circle
:disabled="!hasActiveNode || !canTranslateActive || isTranslating" :disabled="!hasActiveNode || !canTranslateActive || isTranslating"
@click="handleTranslateNode" @click="handleTranslateNode"
> >
<template #icon> <template #icon>
<n-icon v-if="isTranslating" class="animate-spin"><SyncOutline /></n-icon> <n-icon v-if="isTranslating" class="animate-spin"><SyncOutline /></n-icon>
<n-icon v-else><LanguageOutline /></n-icon> <n-icon v-else><LanguageOutline /></n-icon>
</template> </template>
</CommonButton> </CommonButton>
</template> </template>
智能翻译 智能翻译
</n-tooltip> </n-tooltip>
<!-- 添加子节点 --> <!-- 添加子节点 -->
<n-tooltip trigger="hover"> <n-tooltip trigger="hover">
<template #trigger> <template #trigger>
<CommonButton <CommonButton
size="tiny" size="tiny"
quaternary quaternary
circle circle
:disabled="!hasActiveNode || isVirtualText || !hasAllowedChildren" :disabled="!hasActiveNode || isVirtualText || !hasAllowedChildren"
@click="handleAddChild" @click="handleAddChild"
> >
<template #icon> <template #icon>
<n-icon><AddCircleOutline /></n-icon> <n-icon><AddCircleOutline /></n-icon>
</template> </template>
</CommonButton> </CommonButton>
</template> </template>
添加子节点 添加子节点
</n-tooltip> </n-tooltip>
<!-- 插入到上方 --> <!-- 插入到上方 -->
<n-tooltip trigger="hover"> <n-tooltip trigger="hover">
<template #trigger> <template #trigger>
<CommonButton size="tiny" quaternary circle :disabled="!hasActiveNode || !hasParent" @click="handleInsertBefore"> <CommonButton size="tiny" quaternary circle :disabled="!hasActiveNode || !hasParent" @click="handleInsertBefore">
<template #icon> <template #icon>
<n-icon><ArrowUpOutline /></n-icon> <n-icon><ArrowUpOutline /></n-icon>
</template> </template>
</CommonButton> </CommonButton>
</template> </template>
插入到上方 插入到上方
</n-tooltip> </n-tooltip>
<!-- 插入到下方 --> <!-- 插入到下方 -->
<n-tooltip trigger="hover"> <n-tooltip trigger="hover">
<template #trigger> <template #trigger>
<CommonButton size="tiny" quaternary circle :disabled="!hasActiveNode || !hasParent" @click="handleInsertAfter"> <CommonButton size="tiny" quaternary circle :disabled="!hasActiveNode || !hasParent" @click="handleInsertAfter">
<template #icon> <template #icon>
<n-icon><ArrowDownOutline /></n-icon> <n-icon><ArrowDownOutline /></n-icon>
</template> </template>
</CommonButton> </CommonButton>
</template> </template>
插入到下方 插入到下方
</n-tooltip> </n-tooltip>
</template>
<!-- 删除节点 --> <!-- 删除节点 -->
<n-tooltip trigger="hover"> <n-tooltip trigger="hover">
...@@ -140,6 +142,7 @@ const { ...@@ -140,6 +142,7 @@ const {
left, left,
hasActiveNode, hasActiveNode,
isVirtualText, isVirtualText,
isVirtualLayoutNode,
hasParent, hasParent,
hasAllowedChildren, hasAllowedChildren,
canDeleteCurrent, canDeleteCurrent,
......
import { ImageOutline, GridOutline, DocumentTextOutline, CreateOutline, RemoveOutline, ListOutline } from '@vicons/ionicons5' import { ImageOutline, GridOutline, DocumentTextOutline, CreateOutline, RemoveOutline, ListOutline, ReturnDownForwardOutline } from '@vicons/ionicons5'
/** /**
* EditorToolbar 组件级静态常量 * EditorToolbar 组件级静态常量
...@@ -11,5 +11,6 @@ export const GREEN_BUTTONS: any[] = [ ...@@ -11,5 +11,6 @@ export const GREEN_BUTTONS: any[] = [
{ label: '插入模板', tag: 'TEMPLATE', icon: DocumentTextOutline }, { label: '插入模板', tag: 'TEMPLATE', icon: DocumentTextOutline },
{ label: '插入签字点', tag: 'SIGNOFF', icon: CreateOutline }, { label: '插入签字点', tag: 'SIGNOFF', icon: CreateOutline },
{ label: '插入选项组', tag: 'SELECTION', icon: ListOutline }, { 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) { ...@@ -28,6 +28,18 @@ export function useEditorToolbar(emit: any) {
return 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 const selected = editorStore.selectedNode
if (!selected) { if (!selected) {
window.$message.warning('请先在树中选择一个目标节点!') window.$message.warning('请先在树中选择一个目标节点!')
......
...@@ -211,6 +211,32 @@ ...@@ -211,6 +211,32 @@
</div> </div>
<div class="text-[10px] text-color3 mt-2 text-center leading-relaxed">插入下划线记录行,提供数据填报输入项。</div> <div class="text-[10px] text-color3 mt-2 text-center leading-relaxed">插入下划线记录行,提供数据填报输入项。</div>
</template> </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>
</div> </div>
</n-popover> </n-popover>
......
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
import type { BlockedNodeDetail, SafeNodeItem } from '../../../constants' import type { BlockedNodeDetail, SafeNodeItem } from '../../../constants'
import { VIRTUAL_LAYOUT_TAGS } from '@/configs/xmlTags'
export function useBatchDeleteConfirmModal(emit: (event: 'confirm') => void) { export function useBatchDeleteConfirmModal(emit: (event: 'confirm') => void) {
const editorStore = useEditorStore() const editorStore = useEditorStore()
...@@ -162,6 +163,12 @@ const partitionBatchDelete = (selectedNodeIds: string[], nodeMap: Map<string, { ...@@ -162,6 +163,12 @@ const partitionBatchDelete = (selectedNodeIds: string[], nodeMap: Map<string, {
}) })
continue continue
} }
// 分页符等虚拟排版节点不受 DTD 约束,直接允许删除
if (VIRTUAL_LAYOUT_TAGS.includes(item.node.tagName)) {
safeIds.push(nodeId)
confirmedSafeSet.add(nodeId)
continue
}
const parentId = item.parent.id const parentId = item.parent.id
...@@ -188,7 +195,7 @@ const partitionBatchDelete = (selectedNodeIds: string[], nodeMap: Map<string, { ...@@ -188,7 +195,7 @@ const partitionBatchDelete = (selectedNodeIds: string[], nodeMap: Map<string, {
if (!parentRemainingTagsMap.has(parentId)) { if (!parentRemainingTagsMap.has(parentId)) {
parentRemainingTagsMap.set( parentRemainingTagsMap.set(
parentId, 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)! const remaining = parentRemainingTagsMap.get(parentId)!
...@@ -198,7 +205,7 @@ const partitionBatchDelete = (selectedNodeIds: string[], nodeMap: Map<string, { ...@@ -198,7 +205,7 @@ const partitionBatchDelete = (selectedNodeIds: string[], nodeMap: Map<string, {
if (!(parentRemainingTagsMap as any).has(parentOriginalTagsKey)) { if (!(parentRemainingTagsMap as any).has(parentOriginalTagsKey)) {
;(parentRemainingTagsMap as any).set( ;(parentRemainingTagsMap as any).set(
parentOriginalTagsKey, 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)! const originalTags: string[] = (parentRemainingTagsMap as any).get(parentOriginalTagsKey)!
......
...@@ -22,7 +22,7 @@ import { useStashStore } from '@/store/stash' ...@@ -22,7 +22,7 @@ import { useStashStore } from '@/store/stash'
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
import type { CheckRuleData, InsertMode, FlatNode, TranslationResponse } from '../constants' import type { CheckRuleData, InsertMode, FlatNode, TranslationResponse } from '../constants'
import { DOCUMENT_LIKE_TAGS } 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( ...@@ -750,6 +750,24 @@ export function useNodeTree(
const options: DropdownOption[] = [] 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 // 查看XML
options.push({ options.push({
label: '查看XML', label: '查看XML',
...@@ -835,12 +853,12 @@ export function useNodeTree( ...@@ -835,12 +853,12 @@ export function useNodeTree(
children: pasteFragmentChildren children: pasteFragmentChildren
}) })
// 删除节点(受 DTD 约束,文本节点永远允许删除) // 删除节点(受 DTD 约束,文本节点和分页符节点永远允许删除)
if (parent) { if (parent) {
const deletable = isVirtual const deletable = (isVirtual || VIRTUAL_LAYOUT_TAGS.includes(node.tagName))
? true ? 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) { if (existingTags.length > 0 && existingTags[existingTags.length - 1] === node.tagName) {
return true return true
...@@ -874,7 +892,7 @@ export function useNodeTree( ...@@ -874,7 +892,7 @@ export function useNodeTree(
if (parent) { if (parent) {
const insertable = getInsertableChildren( const insertable = getInsertableChildren(
parent.tagName, 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) { if (insertable.length > 0) {
structureChildren.push({ structureChildren.push({
...@@ -1226,7 +1244,7 @@ export function useNodeTree( ...@@ -1226,7 +1244,7 @@ export function useNodeTree(
case 'addChildNode': { case 'addChildNode': {
let allowed = getInsertableChildren( let allowed = getInsertableChildren(
node.tagName, 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 (isMixedContentElement(node.tagName) || isTextOnlyElement(node.tagName)) {
if (!allowed.includes('#text')) { if (!allowed.includes('#text')) {
...@@ -1243,7 +1261,7 @@ export function useNodeTree( ...@@ -1243,7 +1261,7 @@ export function useNodeTree(
if (!parent) break if (!parent) break
let insertable = getInsertableChildren( let insertable = getInsertableChildren(
parent.tagName, 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 (isMixedContentElement(parent.tagName) || isTextOnlyElement(parent.tagName)) {
if (!insertable.includes('#text')) { if (!insertable.includes('#text')) {
...@@ -1260,7 +1278,7 @@ export function useNodeTree( ...@@ -1260,7 +1278,7 @@ export function useNodeTree(
if (!parent) break if (!parent) break
let insertable = getInsertableChildren( let insertable = getInsertableChildren(
parent.tagName, 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 (isMixedContentElement(parent.tagName) || isTextOnlyElement(parent.tagName)) {
if (!insertable.includes('#text')) { if (!insertable.includes('#text')) {
......
...@@ -68,7 +68,8 @@ ...@@ -68,7 +68,8 @@
? 'tree-node-context-active' ? 'tree-node-context-active'
: hasCustomColor(item) : hasCustomColor(item)
? 'hover:bg-fill-3' ? '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="[ :style="[
{ {
...@@ -212,6 +213,7 @@ ...@@ -212,6 +213,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { SearchOutline, ListOutline, TrashOutline, CloseOutline, SyncOutline, ArrowUpOutline } from '@vicons/ionicons5' import { SearchOutline, ListOutline, TrashOutline, CloseOutline, SyncOutline, ArrowUpOutline } from '@vicons/ionicons5'
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import { VIRTUAL_LAYOUT_TAGS } from '@/configs/xmlTags'
import { import {
useNodeTree, useNodeTree,
checkRuleVisible, checkRuleVisible,
...@@ -485,4 +487,21 @@ const batchDeleteConfirmModalRef = ref<any>(null) ...@@ -485,4 +487,21 @@ const batchDeleteConfirmModalRef = ref<any>(null)
opacity: 0; opacity: 0;
transform: translateY(10px) scale(0.9); 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> </style>
...@@ -2,6 +2,7 @@ import { useEditorStore } from '@/store/editor' ...@@ -2,6 +2,7 @@ import { useEditorStore } from '@/store/editor'
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
// 静态导入 DTD JSON // 静态导入 DTD JSON
import dtdJson from '@/assets/json/dtd.json' import dtdJson from '@/assets/json/dtd.json'
import { VIRTUAL_LAYOUT_TAGS } from '@/configs/xmlTags'
import { DEFAULT_XML_FILE } from '@/configs' import { DEFAULT_XML_FILE } from '@/configs'
// 导入通用及定制环境目录下的本地默认模板 XML 文本 // 导入通用及定制环境目录下的本地默认模板 XML 文本
const xmlModules = import.meta.glob(['../../../assets/file/*.xml', '../../../*/assets/file/*.xml'], { const xmlModules = import.meta.glob(['../../../assets/file/*.xml', '../../../*/assets/file/*.xml'], {
...@@ -91,6 +92,11 @@ export function useEditor() { ...@@ -91,6 +92,11 @@ export function useEditor() {
const warnings: string[] = [] const warnings: string[] = []
const validateNode = (node: XmlNode) => { const validateNode = (node: XmlNode) => {
// 虚拟辅助排版节点本身不需要 DTD 验证
if (VIRTUAL_LAYOUT_TAGS.includes(node.tagName)) {
return
}
const rule = getElementRule(node.tagName) const rule = getElementRule(node.tagName)
if (rule) { if (rule) {
for (const [attrName, attrDef] of Object.entries(rule.attributes)) { for (const [attrName, attrDef] of Object.entries(rule.attributes)) {
...@@ -99,6 +105,10 @@ export function useEditor() { ...@@ -99,6 +105,10 @@ export function useEditor() {
} }
} }
for (const child of node.children) { for (const child of node.children) {
// 分页符等虚拟节点不参与 DTD 子节点合法性校验
if (VIRTUAL_LAYOUT_TAGS.includes(child.tagName)) {
continue
}
if (!rule.allowedChildren.includes(child.tagName)) { if (!rule.allowedChildren.includes(child.tagName)) {
warnings.push(`节点 <${node.tagName}> 包含不允许的子节点 <${child.tagName}>`) warnings.push(`节点 <${node.tagName}> 包含不允许的子节点 <${child.tagName}>`)
} }
...@@ -257,6 +267,17 @@ export function useEditor() { ...@@ -257,6 +267,17 @@ export function useEditor() {
font-size: 12px !important; font-size: 12px !important;
line-height: 1.2 !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 */ /* 移除多余的块级 margin */
.print-preview-root .doc-node-wrapper.block { .print-preview-root .doc-node-wrapper.block {
margin-top: 0px !important; 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