Commit 318eff57 by pangchong

feat(table-editor): 添加合并单元格保留内容功能

- 新增 TableMergeModal 组件,实现合并单元格时选择保留内容
- 在 TableEditor 中集成 TableMergeModal,实现弹框操作和确认合并逻辑
- 增加 useTableEditor hook 中的 executeMergeAction 方法处理合并并保留内容
- 修改合并单元格逻辑,支持根据用户选择保留指定内容合并至左上角单元格
- 支持快捷预设操作,如仅保留主单元格、保留非空内容、全选/全不选、反选等
- 提供合并后预览功能,展示合并后单元格内内容顺序拼接效果
- 优化单元格调整列宽手柄显示条件,避免超出列范围调整
- 重构相关工具函数,包含节点克隆、表格结构解析、节点查找等辅助功能
parent 9b6e3bc1
import type { XmlNode } from '@/types/xmlNode'
import type { TableStructureModel } from '@/views/editor/components/TableEditor/constants'
export interface MergeContentItem {
id: string
cellId: string
rowIdx: number
colIdx: number
tagName: string
text: string
xmlNode?: XmlNode
selected: boolean
}
export interface MergeCellGroup {
cellId: string
rowIdx: number
colIdx: number
isTopLeft: boolean
items: MergeContentItem[]
}
export interface TableMergeModalOpenParams {
tableNode: XmlNode
cellIds: string[]
minCol: number
maxCol: number
minRow: number
maxRow: number
isThead: boolean
structure: TableStructureModel
}
export type ConfirmMergeEmit = (event: 'confirm', retainedItems: MergeContentItem[], params: TableMergeModalOpenParams) => void
export interface MergePresetAction {
key: string
label: string | (() => string)
type?: 'default' | 'primary' | 'info' | 'success' | 'warning' | 'error'
handler: () => void
}
import type { XmlNode } from '@/types/xmlNode'
import type { TableMergeModalOpenParams, MergeCellGroup, MergeContentItem, ConfirmMergeEmit, MergePresetAction } from '../constants'
import { INLINE_ELEMENTS } from '@/configs/xmlTags'
const INLINE_ELEMENTS_SET = new Set(INLINE_ELEMENTS)
const getDeepText = (node: XmlNode): string => {
if (node.tagName === 'TABLE') {
const tgroup = node.children?.find((c) => c.tagName === 'TGROUP')
const cols = tgroup?.attributes?.COLS || tgroup?.attributes?.cols
let rowCount = 0
if (tgroup && tgroup.children) {
tgroup.children.forEach((sec) => {
if (sec.tagName === 'THEAD' || sec.tagName === 'TBODY') {
if (sec.children) {
rowCount += sec.children.filter((r) => r.tagName === 'ROW').length
}
}
})
}
if (cols && rowCount > 0) {
return `嵌套表格 (${rowCount}${cols} 列)`
}
return '嵌套表格结构'
}
if (node.tagName === 'UNLIST' || node.tagName === 'ORDERLIST' || node.tagName === 'RANDLIST') {
return `列表容器 (<${node.tagName}>)`
}
if (node.tagName === 'GRAPHIC') {
return '图片节点 (<GRAPHIC>)'
}
if (node.mixedContent && node.mixedContent.length > 0) {
return node.mixedContent
.map((item) => {
if (item.type === 'text') return item.text || ''
if (item.type === 'element' && item.nodeId) {
const child = node.children.find((c) => c.id === item.nodeId)
if (!child) return ''
const childText = getDeepText(child)
if (INLINE_ELEMENTS_SET.has(child.tagName)) {
return childText ? ` [${child.tagName}: ${childText}] ` : ` [${child.tagName}] `
}
return childText ? ` ${childText} ` : ''
}
return ''
})
.join('')
.replace(/\s+/g, ' ')
.trim()
}
if (node.children && node.children.length > 0) {
return node.children
.map((c) => getDeepText(c))
.filter(Boolean)
.join(' ')
.replace(/\s+/g, ' ')
.trim()
}
return node.textContent || ''
}
const findTgroup = (node: XmlNode): XmlNode | null => {
if (node.tagName === 'TGROUP') return node
if (node.tagName === 'TABLE') {
const tgroup = node.children.find((c) => c.tagName === 'TGROUP')
return tgroup || null
}
return null
}
const findXmlNodeById = (node: XmlNode, id: string): XmlNode | null => {
if (node.id === id) return node
if (node.children) {
for (const child of node.children) {
const found = findXmlNodeById(child, id)
if (found) return found
}
}
return null
}
const cloneXmlNode = (node: XmlNode, newParentId?: string | null): XmlNode => {
const idMap = new Map<string, string>()
const buildIdMap = (n: XmlNode) => {
const newId = crypto.randomUUID()
idMap.set(n.id, newId)
if (n.children && n.children.length > 0) {
n.children.forEach(buildIdMap)
}
}
buildIdMap(node)
const copyNode = (n: XmlNode, parentIdVal: string | null): XmlNode => {
const newId = idMap.get(n.id)!
const clonedChildren = (n.children || []).map((child) => copyNode(child, newId))
const clonedMixedContent = (n.mixedContent || []).map((item) => {
if (item.type === 'element' && item.nodeId) {
const mappedNodeId = idMap.get(item.nodeId) || item.nodeId
return { ...item, nodeId: mappedNodeId }
}
return { ...item }
})
return {
...n,
id: newId,
parentId: parentIdVal,
attributes: { ...n.attributes },
children: clonedChildren,
mixedContent: clonedMixedContent
}
}
return copyNode(node, newParentId ?? node.parentId)
}
export function useTableMergeModal(emit: ConfirmMergeEmit) {
const show = ref(false)
const activeParams = ref<TableMergeModalOpenParams | null>(null)
const cellGroups = ref<MergeCellGroup[]>([])
const open = (params: TableMergeModalOpenParams) => {
activeParams.value = params
const tgroup = findTgroup(params.tableNode)
const allCells = params.isThead ? params.structure.theadRows.flatMap((r) => r.cells) : params.structure.tbodyRows.flatMap((r) => r.cells)
const targetCells = allCells
.filter((c) => params.cellIds.includes(c.id))
.sort((a, b) => {
const rA = a.rowIdx ?? 0
const rB = b.rowIdx ?? 0
if (rA !== rB) return rA - rB
return (a.colIdx ?? 0) - (b.colIdx ?? 0)
})
const groups: MergeCellGroup[] = []
targetCells.forEach((cell) => {
const rowIdx = cell.rowIdx ?? 0
const colIdx = cell.colIdx ?? 0
const isTopLeft = rowIdx === params.minRow && colIdx === params.minCol
const items: MergeContentItem[] = []
let cellXml: XmlNode | null = null
if (tgroup) {
cellXml = findXmlNodeById(tgroup, cell.id)
}
if (cellXml && cellXml.children && cellXml.children.length > 0) {
cellXml.children.forEach((c) => {
items.push({
id: c.id,
cellId: cell.id,
rowIdx,
colIdx,
tagName: c.tagName,
text: getDeepText(c) || `<${c.tagName}>`,
xmlNode: c,
selected: true
})
})
} else if (cell.paragraphs && cell.paragraphs.length > 0) {
cell.paragraphs.forEach((p) => {
items.push({
id: p.id,
cellId: cell.id,
rowIdx,
colIdx,
tagName: p.tagName !== 'ENTRY' ? p.tagName : 'PARAC',
text: p.text || '',
xmlNode: undefined,
selected: true
})
})
} else {
const textVal = cellXml?.textContent || ''
items.push({
id: cell.id + '-text',
cellId: cell.id,
rowIdx,
colIdx,
tagName: 'PARAC',
text: textVal,
xmlNode: undefined,
selected: true
})
}
groups.push({
cellId: cell.id,
rowIdx,
colIdx,
isTopLeft,
items
})
})
cellGroups.value = groups
show.value = true
}
const presetActions: MergePresetAction[] = [
{
key: 'onlyTopLeft',
label: '仅保留主单元格',
handler: () => {
cellGroups.value.forEach((group) => {
group.items.forEach((item) => {
item.selected = group.isTopLeft
})
})
}
},
{
key: 'nonEmpty',
label: '保留非空内容',
handler: () => {
cellGroups.value.forEach((group) => {
group.items.forEach((item) => {
item.selected = item.text.trim().length > 0
})
})
}
},
{
key: 'toggleAll',
label: () => {
const allItems = cellGroups.value.flatMap((g) => g.items)
return allItems.length > 0 && allItems.every((i) => i.selected) ? '全不选' : '全选'
},
handler: () => {
const allItems = cellGroups.value.flatMap((g) => g.items)
const shouldSelectAll = !allItems.every((i) => i.selected)
allItems.forEach((item) => {
item.selected = shouldSelectAll
})
}
},
{
key: 'invert',
label: '反选',
handler: () => {
cellGroups.value.forEach((group) => {
group.items.forEach((item) => {
item.selected = !item.selected
})
})
}
},
{
key: 'keepParac',
label: '仅 PARAC',
type: 'warning',
handler: () => {
cellGroups.value.forEach((group) => {
group.items.forEach((item) => {
item.selected = item.tagName === 'PARAC'
})
})
}
},
{
key: 'keepPara',
label: '仅 PARA',
type: 'info',
handler: () => {
cellGroups.value.forEach((group) => {
group.items.forEach((item) => {
item.selected = item.tagName === 'PARA'
})
})
}
}
]
const isGroupSelected = (group: MergeCellGroup): boolean => {
return group.items.length > 0 && group.items.every((i) => i.selected)
}
const isGroupIndeterminate = (group: MergeCellGroup): boolean => {
const selectedCount = group.items.filter((i) => i.selected).length
return selectedCount > 0 && selectedCount < group.items.length
}
const toggleGroup = (group: MergeCellGroup, checked: boolean) => {
group.items.forEach((i) => {
i.selected = checked
})
}
const previewCellNode = computed<XmlNode | null>(() => {
if (!activeParams.value) return null
const selectedItems: MergeContentItem[] = []
cellGroups.value.forEach((g) => {
g.items.forEach((i) => {
if (i.selected) selectedItems.push(i)
})
})
const newChildren: XmlNode[] = []
selectedItems.forEach((item) => {
if (item.xmlNode) {
newChildren.push(cloneXmlNode(item.xmlNode, 'preview-cell'))
} else if (item.text && item.text.trim()) {
const defaultTag = item.tagName && item.tagName !== 'ENTRY' ? item.tagName : 'PARAC'
newChildren.push({
id: crypto.randomUUID(),
tagName: defaultTag,
attributes: {},
children: [],
textContent: item.text,
mixedContent: [{ type: 'text', text: item.text }],
parentId: 'preview-cell'
})
}
})
return {
id: 'preview-cell',
tagName: 'ENTRY',
attributes: {},
children: newChildren,
textContent: '',
mixedContent: [],
parentId: null
}
})
const handleConfirm = () => {
if (!activeParams.value) return
const retained: MergeContentItem[] = []
cellGroups.value.forEach((g) => {
g.items.forEach((i) => {
if (i.selected) {
retained.push(i)
}
})
})
emit('confirm', retained, activeParams.value)
show.value = false
}
return {
show,
cellGroups,
open,
presetActions,
isGroupSelected,
isGroupIndeterminate,
toggleGroup,
previewCellNode,
handleConfirm
}
}
<template>
<CommonModal v-model="show" title="合并单元格 - 选择保留内容" :width="960" confirm-text="确认合并" @confirm="handleConfirm">
<div class="flex flex-col gap-3 py-1">
<!-- 说明提示 -->
<div class="text-xs text-color3 bg-fill-2 p-2.5 rounded border border-divider">
<span>请勾选需要保留的单元格内容,每条内容下方展示实际渲染效果,保留的内容将按顺序合并至左上角主单元格中。</span>
</div>
<!-- 左右双栏布局 -->
<div class="flex gap-4 items-stretch" style="min-height: 420px">
<!-- 左栏:单元格内容勾选列表 -->
<div class="flex-1 flex flex-col gap-2.5 min-w-0">
<!-- 快捷预设按钮栏 -->
<div class="flex items-center gap-2 shrink-0 overflow-x-auto">
<span class="text-xs text-color3 font-bold shrink-0">快捷选项:</span>
<CommonButton
v-for="action in presetActions"
:key="action.key"
size="tiny"
secondary
:type="action.type ?? 'default'"
@click="action.handler()"
>
{{ typeof action.label === 'function' ? action.label() : action.label }}
</CommonButton>
</div>
<!-- 单元格内容分组列表 -->
<div class="flex-1 max-h-[400px] overflow-y-auto space-y-3 pr-1.5">
<div
v-for="group in cellGroups"
:key="group.cellId"
class="border rounded-lg overflow-hidden transition-all"
:class="group.isTopLeft ? 'border-primary/50' : 'border-divider'"
>
<!-- 单元格组头部 -->
<div class="flex items-center justify-between px-3 py-2" :class="group.isTopLeft ? 'bg-primary/8' : 'bg-fill-2'">
<CommonCheckboxSingle
:checked="isGroupSelected(group)"
:indeterminate="isGroupIndeterminate(group)"
@update:checked="(val: boolean) => toggleGroup(group, val)"
>
<span class="font-bold text-xs text-color1">{{ group.rowIdx + 1 }} 行 · 第 {{ group.colIdx + 1 }}</span>
</CommonCheckboxSingle>
<CommonTag v-if="group.isTopLeft" type="primary" size="small">主单元格</CommonTag>
<CommonTag v-else type="default" size="small">合并单元格</CommonTag>
</div>
<!-- 段落/节点列表,每条内嵌 DocNodeRenderer 预览 -->
<div v-if="group.items.length > 0" class="divide-y divide-divider">
<div
v-for="item in group.items"
:key="item.id"
class="flex items-start gap-3 px-3 py-2 transition-all cursor-pointer"
:class="item.selected ? 'bg-fill-1' : 'bg-fill-3 opacity-60'"
@click.stop="item.selected = !item.selected"
>
<!-- 勾选框 -->
<CommonCheckboxSingle v-model:checked="item.selected" class="mt-0.5 shrink-0" @click.stop />
<!-- 内容预览区 -->
<div class="flex-1 min-w-0">
<!-- 若有 xmlNode,直接用 DocNodeRenderer 渲染实际效果 -->
<template v-if="item.xmlNode">
<div
class="p-2 rounded border text-sm leading-relaxed transition-all overflow-hidden"
:class="item.selected ? 'border-divider bg-card' : 'border-divider/40 bg-fill-3'"
style="max-height: 120px; overflow-y: auto"
>
<DocNodeRenderer :node="item.xmlNode" :parent="null" />
</div>
</template>
<!-- 无 xmlNode(纯文本段落),展示文本内容 -->
<template v-else>
<div
class="p-2 rounded border text-sm leading-relaxed transition-all"
:class="[
item.selected ? 'border-divider bg-card' : 'border-divider/40 bg-fill-3',
!item.selected ? 'line-through text-color3' : 'text-color1'
]"
>
{{ item.text || '(空内容)' }}
</div>
</template>
<!-- 标签名角标 -->
<span
class="inline-block mt-1 text-[10px] font-mono px-1.5 py-0.5 rounded"
:class="item.tagName === 'PARAC' ? 'bg-warning/15 text-warning' : 'bg-fill-3 text-color3'"
>
&lt;{{ item.tagName }}&gt;
</span>
</div>
</div>
</div>
<div v-else class="px-3 py-2 text-xs text-color3 italic bg-fill-1">(空单元格)</div>
</div>
</div>
</div>
<!-- 右栏:合并后效果预览面板 -->
<div class="w-[360px] shrink-0 bg-fill-2 p-3.5 rounded-lg border border-divider flex flex-col gap-2">
<div class="text-xs font-bold text-color3 flex items-center justify-between pb-2 border-b border-divider shrink-0">
<span>合并后效果预览</span>
<CommonTag size="tiny" type="primary">按顺序拼接</CommonTag>
</div>
<div class="flex-1 max-h-[400px] min-h-[300px] overflow-y-auto p-3 bg-card rounded border border-divider">
<template v-if="previewCellNode && previewCellNode.children.length > 0">
<DocNodeRenderer v-for="child in previewCellNode.children" :key="child.id" :node="child" :parent="previewCellNode" />
</template>
<div v-else class="h-full flex items-center justify-center text-xs text-color3 italic py-8 text-center">
(未选择任何内容,
<br />
合并后单元格将为空)
</div>
</div>
</div>
</div>
</div>
</CommonModal>
</template>
<script setup lang="ts">
import { useTableMergeModal } from './functionals'
import type { TableMergeModalOpenParams, MergeContentItem } from './constants'
import CommonCheckboxSingle from '@/components/CommonCheckboxSingle.vue'
import DocNodeRenderer from '@/views/editor/components/DocNodeRenderer/index.vue'
// 注入只读模式,禁用内部编辑事件
provide('diffMode', true)
const emit = defineEmits<{
(e: 'confirm', retainedItems: MergeContentItem[], params: TableMergeModalOpenParams): void
}>()
const {
show,
cellGroups,
open,
presetActions,
isGroupSelected,
isGroupIndeterminate,
toggleGroup,
previewCellNode,
handleConfirm
} = useTableMergeModal(emit)
defineExpose({ open })
</script>
...@@ -2,6 +2,7 @@ import { useEditorStore } from '@/store/editor' ...@@ -2,6 +2,7 @@ import { useEditorStore } from '@/store/editor'
import { useAppStore } from '@/store/app' import { useAppStore } from '@/store/app'
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
import type { TableCellModel, TableRowModel, TableStructureModel, BatchModalRef, BatchActionMeta, CellParagraphModel } from '../constants' import type { TableCellModel, TableRowModel, TableStructureModel, BatchModalRef, BatchActionMeta, CellParagraphModel } from '../constants'
import type { MergeContentItem, TableMergeModalOpenParams } from '../components/TableMergeModal/constants'
import { ACTION_META } from '../constants' import { ACTION_META } from '../constants'
import { COMPLEX_ENTRY_TAGS, PARA_TAGS, CHINESE_FIRST_PARA_TAGS } from '@/configs/xmlTags' import { COMPLEX_ENTRY_TAGS, PARA_TAGS, CHINESE_FIRST_PARA_TAGS } from '@/configs/xmlTags'
...@@ -39,10 +40,49 @@ const setDeepText = (node: XmlNode, text: string): void => { ...@@ -39,10 +40,49 @@ const setDeepText = (node: XmlNode, text: string): void => {
} }
} }
import type { Ref } from 'vue'
const cloneXmlNode = (node: XmlNode, newParentId?: string | null): XmlNode => {
const idMap = new Map<string, string>()
const buildIdMap = (n: XmlNode) => {
const newId = crypto.randomUUID()
idMap.set(n.id, newId)
if (n.children && n.children.length > 0) {
n.children.forEach(buildIdMap)
}
}
buildIdMap(node)
const copyNode = (n: XmlNode, parentIdVal: string | null): XmlNode => {
const newId = idMap.get(n.id)!
const clonedChildren = (n.children || []).map((child) => copyNode(child, newId))
const clonedMixedContent = (n.mixedContent || []).map((item) => {
if (item.type === 'element' && item.nodeId) {
const mappedNodeId = idMap.get(item.nodeId) || item.nodeId
return { ...item, nodeId: mappedNodeId }
}
return { ...item }
})
return {
...n,
id: newId,
parentId: parentIdVal,
attributes: { ...n.attributes },
children: clonedChildren,
mixedContent: clonedMixedContent
}
}
return copyNode(node, newParentId ?? node.parentId)
}
/** /**
* CALS 表格编辑器 (TableEditor) 组件专用 Hook 逻辑 * CALS 表格编辑器 (TableEditor) 组件专用 Hook 逻辑
*/ */
export function useTableEditor(props: { node: XmlNode }) { export function useTableEditor(props: { node: XmlNode }, options?: { mergeModalRef?: Ref<any> }) {
const store = useEditorStore() const store = useEditorStore()
const appStore = useAppStore() const appStore = useAppStore()
const isDiffModeRaw = inject<any>('diffMode', false) const isDiffModeRaw = inject<any>('diffMode', false)
...@@ -939,6 +979,124 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -939,6 +979,124 @@ export function useTableEditor(props: { node: XmlNode }) {
store.rebuildNodeMap() store.rebuildNodeMap()
} }
const mergeMultipleCellsWithRetainedContent = (
tableNode: XmlNode,
retainedItems: MergeContentItem[],
params: TableMergeModalOpenParams
): void => {
const { cellIds, minCol, maxCol, minRow, maxRow, isThead } = params
const tgroup = findTgroup(tableNode)
if (!tgroup) return
store.saveSnapshot()
const colspan = maxCol - minCol + 1
const rowspan = maxRow - minRow + 1
const colSpecs = tgroup.children.filter((c) => c.tagName === 'COLSPEC')
const ensureColName = (idx: number): string => {
let spec = colSpecs[idx]
if (!spec) {
spec = {
id: crypto.randomUUID(),
tagName: 'COLSPEC',
attributes: {
COLNAME: `col${idx + 1}`,
COLNUM: (idx + 1).toString()
},
children: [],
textContent: '',
mixedContent: [],
parentId: tgroup.id
}
const lastColSpecIdx = tgroup.children.reduce((acc, curr, curIdx) => {
return curr.tagName === 'COLSPEC' ? curIdx : acc
}, -1)
tgroup.children.splice(lastColSpecIdx + 1, 0, spec)
colSpecs.push(spec)
}
if (!spec.attributes.COLNAME) {
spec.attributes.COLNAME = `col${idx + 1}`
}
return spec.attributes.COLNAME
}
const startColName = ensureColName(minCol)
const endColName = ensureColName(maxCol)
const currentStructure = parseTable(tableNode)
const targetRows = isThead ? currentStructure.theadRows : currentStructure.tbodyRows
let topLeftCellId: string | null = null
for (const row of targetRows) {
const found = row.cells.find((c) => c.colIdx === minCol && c.rowIdx === minRow)
if (found) {
topLeftCellId = found.id
break
}
}
if (!topLeftCellId) return
const topLeftCellXml = findXmlNodeById(tgroup, topLeftCellId)
if (!topLeftCellXml) return
if (colspan > 1) {
topLeftCellXml.attributes.NAMEST = startColName
topLeftCellXml.attributes.NAMEEND = endColName
} else {
delete topLeftCellXml.attributes.NAMEST
delete topLeftCellXml.attributes.NAMEEND
}
if (rowspan > 1) {
topLeftCellXml.attributes.MOREROWS = (rowspan - 1).toString()
} else {
delete topLeftCellXml.attributes.MOREROWS
}
const newChildren: XmlNode[] = []
retainedItems.forEach((item) => {
if (item.xmlNode) {
const cloned = cloneXmlNode(item.xmlNode, topLeftCellXml.id)
newChildren.push(cloned)
} else if (item.text && item.text.trim()) {
const defaultTag = item.tagName && item.tagName !== 'ENTRY' ? item.tagName : (CHINESE_FIRST_PARA_TAGS[0] || 'PARAC')
const newParaNode: XmlNode = {
id: crypto.randomUUID(),
tagName: defaultTag,
attributes: {},
children: [],
textContent: item.text,
mixedContent: [{ type: 'text', text: item.text }],
parentId: topLeftCellXml.id
}
newChildren.push(newParaNode)
}
})
topLeftCellXml.children = newChildren
topLeftCellXml.textContent = ''
topLeftCellXml.mixedContent = []
// 删除其余被合并吞噬的真实 ENTRY 节点
const otherCellIds = cellIds.filter((id) => id !== topLeftCellId)
otherCellIds.forEach((id) => {
const cellXml = findXmlNodeById(tgroup, id)
if (cellXml && cellXml.parentId) {
const parentRow = findXmlNodeById(tgroup, cellXml.parentId)
if (parentRow) {
const idx = parentRow.children.findIndex((c) => c.id === id)
if (idx !== -1) {
parentRow.children.splice(idx, 1)
}
}
}
})
store.rebuildNodeMap()
}
const splitCell = (tableNode: XmlNode, cellId: string): void => { const splitCell = (tableNode: XmlNode, cellId: string): void => {
const tgroup = findTgroup(tableNode) const tgroup = findTgroup(tableNode)
if (!tgroup) return if (!tgroup) return
...@@ -1224,10 +1382,22 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -1224,10 +1382,22 @@ export function useTableEditor(props: { node: XmlNode }) {
if (rIdx + rSpan - 1 > maxRow) maxRow = rIdx + rSpan - 1 if (rIdx + rSpan - 1 > maxRow) maxRow = rIdx + rSpan - 1
}) })
// 在更新 selectedCellIds 之前先快照所有被选格 ID,
// 避免提前重置后 mergeMultipleCells 拿到的 cellIds 只剩左上角一个
const allSelectedIds = cells.map((c) => c.id) const allSelectedIds = cells.map((c) => c.id)
if (options?.mergeModalRef?.value) {
options.mergeModalRef.value.open({
tableNode: props.node,
cellIds: allSelectedIds,
minCol,
maxCol,
minRow,
maxRow,
isThead,
structure: structure.value
})
return
}
const targetRows = isThead ? structure.value.theadRows : structure.value.tbodyRows const targetRows = isThead ? structure.value.theadRows : structure.value.tbodyRows
const topLeftCell = targetRows[minRow]?.cells.find((c) => c.colIdx === minCol) const topLeftCell = targetRows[minRow]?.cells.find((c) => c.colIdx === minCol)
...@@ -1239,6 +1409,35 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -1239,6 +1409,35 @@ export function useTableEditor(props: { node: XmlNode }) {
mergeMultipleCells(props.node, allSelectedIds, minCol, maxCol, minRow, maxRow, isThead) mergeMultipleCells(props.node, allSelectedIds, minCol, maxCol, minRow, maxRow, isThead)
} }
const executeMergeAction = (retainedItems: MergeContentItem[], params: TableMergeModalOpenParams) => {
mergeMultipleCellsWithRetainedContent(props.node, retainedItems, params)
const { minCol, minRow, isThead } = params
const targetRows = isThead ? structure.value.theadRows : structure.value.tbodyRows
const topLeftCell = targetRows[minRow]?.cells.find((c) => c.colIdx === minCol)
if (topLeftCell) {
selectedCellIds.value = [topLeftCell.id]
store.setSelectedNodeId(topLeftCell.id)
const tgroup = findTgroup(props.node)
if (tgroup) {
const cellXml = findXmlNodeById(tgroup, topLeftCell.id)
if (cellXml) {
const collectAllKeys = (node: XmlNode): string[] => {
const keys: string[] = [node.id]
if (node.children && node.children.length > 0) {
node.children.forEach((c) => {
keys.push(...collectAllKeys(c))
})
}
return keys
}
store.expandNodes(collectAllKeys(cellXml))
}
}
}
}
const handleSplitSelected = () => { const handleSplitSelected = () => {
if (selectedCellIds.value.length !== 1) return if (selectedCellIds.value.length !== 1) return
const cellId = selectedCellIds.value[0] const cellId = selectedCellIds.value[0]
...@@ -1797,7 +1996,7 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -1797,7 +1996,7 @@ export function useTableEditor(props: { node: XmlNode }) {
const handleResizeStart = (cell: TableCellModel, e: MouseEvent) => { const handleResizeStart = (cell: TableCellModel, e: MouseEvent) => {
const colIdx = (cell.colIdx ?? 0) + (cell.colspan ?? 1) - 1 const colIdx = (cell.colIdx ?? 0) + (cell.colspan ?? 1) - 1
if (colIdx < 0 || colIdx >= structure.value.cols) return if (colIdx < 0 || colIdx >= structure.value.cols - 1) return
const tableEl = (e.target as HTMLElement).closest('table') const tableEl = (e.target as HTMLElement).closest('table')
if (!tableEl) return if (!tableEl) return
...@@ -1905,6 +2104,7 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -1905,6 +2104,7 @@ export function useTableEditor(props: { node: XmlNode }) {
handleRowAddSelect, handleRowAddSelect,
handleColAddSelect, handleColAddSelect,
handleMergeMultiple, handleMergeMultiple,
executeMergeAction,
handleSplitSelected, handleSplitSelected,
handleRowDelete, handleRowDelete,
handleColumnDelete, handleColumnDelete,
...@@ -1928,6 +2128,7 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -1928,6 +2128,7 @@ export function useTableEditor(props: { node: XmlNode }) {
addColumn, addColumn,
deleteColumn, deleteColumn,
mergeMultipleCells, mergeMultipleCells,
mergeMultipleCellsWithRetainedContent,
splitCell, splitCell,
insertedRowIds, insertedRowIds,
insertedCellIds, insertedCellIds,
......
...@@ -63,7 +63,7 @@ ...@@ -63,7 +63,7 @@
<tr <tr
:data-node-id="row.id" :data-node-id="row.id"
data-tag-name="ROW" data-tag-name="ROW"
class="border-b border-divider group transition-colors transition-all" class="group transition-colors transition-all"
:class="[ :class="[
!isDiffMode ? 'hover:bg-fill-3 cursor-pointer' : 'cursor-default', !isDiffMode ? 'hover:bg-fill-3 cursor-pointer' : 'cursor-default',
isNodeSelected(row.id) ? 'outline outline-2 outline-primary outline-offset-[-2px] bg-primary/10' : '', isNodeSelected(row.id) ? 'outline outline-2 outline-primary outline-offset-[-2px] bg-primary/10' : '',
...@@ -164,7 +164,7 @@ ...@@ -164,7 +164,7 @@
</template> </template>
<!-- 拖拽调整列宽手柄 --> <!-- 拖拽调整列宽手柄 -->
<div <div
v-if="!isDiffMode" v-if="!isDiffMode && ((cell.colIdx ?? 0) + (cell.colspan ?? 1)) < structure.cols"
class="absolute -right-1 top-0 bottom-0 w-2 cursor-col-resize hover:bg-primary/40 active:bg-primary/60 transition-colors z-20 select-none" class="absolute -right-1 top-0 bottom-0 w-2 cursor-col-resize hover:bg-primary/40 active:bg-primary/60 transition-colors z-20 select-none"
@mousedown.stop.prevent="handleResizeStart(cell, $event)" @mousedown.stop.prevent="handleResizeStart(cell, $event)"
></div> ></div>
...@@ -209,7 +209,7 @@ ...@@ -209,7 +209,7 @@
<tr <tr
:data-node-id="row.id" :data-node-id="row.id"
data-tag-name="ROW" data-tag-name="ROW"
class="border-b border-divider group transition-colors transition-all" class="group transition-colors transition-all"
:class="[ :class="[
!isDiffMode ? 'hover:bg-fill-3 cursor-pointer' : 'cursor-default', !isDiffMode ? 'hover:bg-fill-3 cursor-pointer' : 'cursor-default',
isNodeSelected(row.id) ? 'outline outline-2 outline-primary outline-offset-[-2px] bg-primary/10' : '', isNodeSelected(row.id) ? 'outline outline-2 outline-primary outline-offset-[-2px] bg-primary/10' : '',
...@@ -310,7 +310,7 @@ ...@@ -310,7 +310,7 @@
</template> </template>
<!-- 拖拽调整列宽手柄 --> <!-- 拖拽调整列宽手柄 -->
<div <div
v-if="!isDiffMode" v-if="!isDiffMode && ((cell.colIdx ?? 0) + (cell.colspan ?? 1)) < structure.cols"
class="absolute -right-1 top-0 bottom-0 w-2 cursor-col-resize hover:bg-primary/40 active:bg-primary/60 transition-colors z-20 select-none" class="absolute -right-1 top-0 bottom-0 w-2 cursor-col-resize hover:bg-primary/40 active:bg-primary/60 transition-colors z-20 select-none"
@mousedown.stop.prevent="handleResizeStart(cell, $event)" @mousedown.stop.prevent="handleResizeStart(cell, $event)"
></div> ></div>
...@@ -365,6 +365,8 @@ ...@@ -365,6 +365,8 @@
<!-- 批量操作弹框 --> <!-- 批量操作弹框 -->
<TableBatchModal ref="batchModalRef" @confirm="executeBatchAction" /> <TableBatchModal ref="batchModalRef" @confirm="executeBatchAction" />
<!-- 合并单元格保留内容弹框 -->
<TableMergeModal ref="mergeModalRef" @confirm="executeMergeAction" />
</div> </div>
</template> </template>
...@@ -377,6 +379,7 @@ import { CELL_EDIT_TIP } from './constants' ...@@ -377,6 +379,7 @@ import { CELL_EDIT_TIP } from './constants'
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import DocNodeRenderer from '../DocNodeRenderer/index.vue' import DocNodeRenderer from '../DocNodeRenderer/index.vue'
import TableBatchModal from './components/TableBatchModal/index.vue' import TableBatchModal from './components/TableBatchModal/index.vue'
import TableMergeModal from './components/TableMergeModal/index.vue'
const props = defineProps<{ const props = defineProps<{
node: XmlNode node: XmlNode
...@@ -386,6 +389,10 @@ const editorStore = useEditorStore() ...@@ -386,6 +389,10 @@ const editorStore = useEditorStore()
const injectedDiffMode = inject<boolean | Ref<boolean>>('diffMode', false) const injectedDiffMode = inject<boolean | Ref<boolean>>('diffMode', false)
const isDiffMode = computed(() => (typeof injectedDiffMode === 'boolean' ? injectedDiffMode : injectedDiffMode.value)) const isDiffMode = computed(() => (typeof injectedDiffMode === 'boolean' ? injectedDiffMode : injectedDiffMode.value))
/** TableMergeModal 与 TableBatchModal 组件实例引用 */
const mergeModalRef = ref<InstanceType<typeof TableMergeModal>>()
const batchModalRef = ref<InstanceType<typeof TableBatchModal>>()
const { const {
structure, structure,
selectedCellIds, selectedCellIds,
...@@ -414,8 +421,9 @@ const { ...@@ -414,8 +421,9 @@ const {
colWidthStyles, colWidthStyles,
tableFrameClass, tableFrameClass,
getCellStyle, getCellStyle,
handleResizeStart handleResizeStart,
} = useTableEditor(props) executeMergeAction
} = useTableEditor(props, { mergeModalRef })
const isNodeSelected = (id: string) => { const isNodeSelected = (id: string) => {
if (isDiffMode.value) return false if (isDiffMode.value) return false
...@@ -427,9 +435,6 @@ const isCellSelected = (cell: any, colIdx: number) => { ...@@ -427,9 +435,6 @@ const isCellSelected = (cell: any, colIdx: number) => {
return rawIsCellSelected(cell, colIdx) return rawIsCellSelected(cell, colIdx)
} }
/** TableBatchModal 组件实例引用 */
const batchModalRef = ref<InstanceType<typeof TableBatchModal>>()
const { onContextMenuSelect, executeBatchAction } = useTableBatchActions({ const { onContextMenuSelect, executeBatchAction } = useTableBatchActions({
batchModalRef, batchModalRef,
handleContextMenuSelect, handleContextMenuSelect,
......
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