/**
* TinyMCE 表格「类 Excel」拖拽
*
* 在表格列/行边界叠加可拖调整条(不依赖 snooker、不依赖边线像素命中),
* 初始不改模板 HTML;仅开始拖拽时把该表当前视觉尺寸快照为像素。
*/
const HANDLE_THICKNESS = 5
const HANDLE_ROW_THICKNESS = 5
const MIN_COL_PX = 28
const MIN_ROW_PX = 20
const HANDLE_CLASS = 'sx-excel-resize-handle'
const HANDLE_COL = 'sx-excel-resize-col'
const HANDLE_ROW = 'sx-excel-resize-row'
/** 强制撑开表格行高的占位(float 撑高,不依赖 tr/td height 是否被浏览器忽略) */
const ROW_SPACER_CLASS = 'sx-excel-row-spacer'
/** 单据外层表 / 联次小表 / 信息页脚签字表:不参与列行拖拽 */
const EXCLUDE_TABLE_CLASS_RE =
/(?:^|\s)(?:[\w-]+-)?(?:slip-outer-table|slip-side-labels|side-labels|slip-info-table|slip-footer-table|slip-sign-table)(?:\s|$)/i
type ResizeKind = 'col' | 'row'
interface ResizeState {
kind: ResizeKind
table: HTMLTableElement
index: number
/** 列拖拽时右邻列索引(边界在 index 与 neighborIndex 之间) */
neighborIndex?: number
startPos: number
startSizes: number[]
/** 锁定表格总宽,拖列时不得改变 */
lockedTableWidth?: number
}
const isExcelResizeExcludedTable = (table: HTMLTableElement): boolean => {
const cls = typeof table.className === 'string' ? table.className : String(table.className || '')
return EXCLUDE_TABLE_CLASS_RE.test(cls)
}
/** 优先选「单列单元格最多」的行作度量行(入库单布局表首行多为 colspan,物料表头才是真列) */
const findMetricRow = (table: HTMLTableElement): HTMLTableRowElement | null => {
let best: HTMLTableRowElement | null = null
let bestSpan1 = -1
Array.from(table.rows).forEach((row) => {
let span1 = 0
let total = 0
Array.from(row.cells).forEach((cell) => {
const span = cell.colSpan || 1
total += span
if (span === 1) span1 += 1
})
if (span1 > bestSpan1) {
bestSpan1 = span1
best = row
}
if (span1 > 0 && span1 === total) {
best = row
bestSpan1 = span1
}
})
return best || table.rows[0] || null
}
const columnCount = (table: HTMLTableElement): number => {
const row = findMetricRow(table)
if (!row) return 0
let n = 0
for (const cell of Array.from(row.cells)) n += cell.colSpan || 1
return n
}
const getColumnCells = (table: HTMLTableElement, colIndex: number): HTMLTableCellElement[] => {
const cells: HTMLTableCellElement[] = []
Array.from(table.rows).forEach((row) => {
let idx = 0
for (const cell of Array.from(row.cells)) {
const span = cell.colSpan || 1
if (colIndex >= idx && colIndex < idx + span) {
// 跨列表头/合计行不能拿来当单列宽度
if (span === 1) cells.push(cell)
break
}
idx += span
}
})
return cells
}
/** 只按「非跨列」单元格量列宽;优先度量行(物料表头) */
const measureColWidth = (table: HTMLTableElement, colIndex: number): number => {
const row = findMetricRow(table)
if (row) {
let idx = 0
for (const cell of Array.from(row.cells)) {
const span = cell.colSpan || 1
if (colIndex >= idx && colIndex < idx + span) {
const w = cell.getBoundingClientRect().width
return Math.max(MIN_COL_PX, span > 1 ? w / span : w)
}
idx += span
}
}
const cells = getColumnCells(table, colIndex)
if (!cells.length) return MIN_COL_PX
return Math.max(MIN_COL_PX, ...cells.map((c) => c.getBoundingClientRect().width))
}
const measureRowHeight = (row: HTMLTableRowElement): number => row.getBoundingClientRect().height
const isGapSpacerRow = (row: HTMLTableRowElement): boolean => {
const cls = typeof row.className === 'string' ? row.className : ''
if (/gap-before-(product|sign)/i.test(cls)) return true
return !!row.querySelector(
'[class*="gap-before-product-cell"],[class*="gap-before-sign-cell"]'
)
}
/** 物料明细网格单元格(入库/送货/分装单);有此类名的表仅允许在物料区拖宽高 */
const isProductGridCell = (el: Element | null): boolean => {
if (!el || !(el instanceof Element)) return false
const cell = el.closest('td,th')
if (!cell) return false
const cls = typeof cell.className === 'string' ? cell.className : String(cell.className || '')
return /product-grid-cell/i.test(cls)
}
const isProductGridRow = (row: HTMLTableRowElement): boolean =>
Array.from(row.cells).some((cell) => isProductGridCell(cell))
const tableHasProductGrid = (table: HTMLTableElement): boolean =>
!!table.querySelector('td.product-grid-cell, th.product-grid-cell, [class*="product-grid-cell"]')
/** 方案A 独立物料表:整表都是物料区,悬停任意处即可出调整条 */
const isProductOnlyTable = (table: HTMLTableElement): boolean =>
/slip-product-table/i.test(
typeof table.className === 'string' ? table.className : String(table.className || '')
)
const getProductGridRows = (table: HTMLTableElement): HTMLTableRowElement[] =>
Array.from(table.rows).filter((row) => isProductGridRow(row))
/** 物料区整体包围盒(相对 viewport) */
const getProductGridRect = (table: HTMLTableElement): DOMRect | null => {
const rows = getProductGridRows(table)
if (!rows.length) return null
let left = Infinity
let top = Infinity
let right = -Infinity
let bottom = -Infinity
rows.forEach((row) => {
const r = row.getBoundingClientRect()
left = Math.min(left, r.left)
top = Math.min(top, r.top)
right = Math.max(right, r.right)
bottom = Math.max(bottom, r.bottom)
})
if (!Number.isFinite(left) || right - left < 4 || bottom - top < 4) return null
return new DOMRect(left, top, right - left, bottom - top)
}
/**
* 入库单等模板用 `
`,fixed 布局下会盖掉 td 像素宽。
* 拖拽时必须改写成「每列一个 col + 像素宽」,否则视觉上拖不动。
*/
const syncColgroupPixelWidths = (
editor: any,
table: HTMLTableElement,
widths: number[]
): void => {
const doc = table.ownerDocument
table.querySelectorAll('colgroup').forEach((cg) => cg.remove())
const colgroup = doc.createElement('colgroup')
widths.forEach((w) => {
const col = doc.createElement('col')
editor.dom.setStyle(col, 'width', `${Math.round(w)}px`)
colgroup.appendChild(col)
})
const first = table.firstElementChild
if (first && first.tagName === 'TBODY') {
table.insertBefore(colgroup, first)
} else {
table.insertBefore(colgroup, table.firstChild)
}
}
const resolveResizeTargetTable = (from: Element | null): HTMLTableElement | null => {
if (!from) return null
const product = from.closest('table[class*="slip-product-table"]') as HTMLTableElement | null
if (product) return product
const outer = from.closest('table[class*="slip-outer-table"]') as HTMLTableElement | null
if (outer) {
const blocked = from.closest(
'table[class*="slip-info-table"],table[class*="slip-footer-table"],table[class*="slip-sign-table"]'
)
if (blocked) return null
const innerProduct = outer.querySelector(
'table[class*="slip-product-table"]'
) as HTMLTableElement | null
// 点在外层空白/联次时不误挂整表调整条
if (!from.closest('table[class*="slip-product-table"]')) return null
if (innerProduct) return innerProduct
}
const table = from.closest('table') as HTMLTableElement | null
if (!table || isExcelResizeExcludedTable(table)) return null
return table
}
const getEditorContentWidth = (editor: any, body: HTMLElement): number => {
const style = editor.getDoc?.()?.defaultView?.getComputedStyle?.(body)
const padL = style ? parseFloat(style.paddingLeft) || 0 : 0
const padR = style ? parseFloat(style.paddingRight) || 0 : 0
return Math.max(0, body.clientWidth - padL - padR)
}
/** 相对编辑器 body 的定位(body 为 position:relative) */
const relativeToBody = (body: HTMLElement, rect: DOMRect) => {
const br = body.getBoundingClientRect()
return {
left: rect.left - br.left + body.scrollLeft,
top: rect.top - br.top + body.scrollTop
}
}
/**
* 将当前视觉列宽快照为像素,并钳制在编辑区宽度内(等比缩放,不改变相对比例)。
* 返回写入后的列宽与表宽,供拖拽直接使用(避免再次测量造成二次放大)。
*/
const snapshotTableVisualToPixels = (
editor: any,
table: HTMLTableElement
): { widths: number[]; tableWidth: number } | null => {
const body = editor.getBody() as HTMLElement | null
if (!body) return null
const cols = columnCount(table)
if (cols <= 0) return null
// 先统一 border-box,再测量,保证「量到的宽 = 写回去的宽」
Array.from(table.querySelectorAll('td,th')).forEach((cell) => {
editor.dom.setStyle(cell, 'box-sizing', 'border-box')
})
const raw: number[] = []
for (let i = 0; i < cols; i++) {
raw.push(Math.max(MIN_COL_PX, Math.round(measureColWidth(table, i))))
}
const rawSum = raw.reduce((a, b) => a + b, 0) || cols
// 以首行单元格宽度之和为准更稳;再与表宽、编辑区取最小,防止撑破弹窗
const visualW = Math.round(table.getBoundingClientRect().width) || rawSum
const contentW = getEditorContentWidth(editor, body)
const targetW = Math.max(cols * MIN_COL_PX, Math.min(rawSum, visualW, contentW || visualW))
let used = 0
const widths = raw.map((w, i) => {
if (i === cols - 1) return Math.max(MIN_COL_PX, targetW - used)
const next = Math.max(MIN_COL_PX, Math.round((w / rawSum) * targetW))
used += next
return next
})
editor.dom.setStyle(table, 'table-layout', 'fixed')
editor.dom.setStyle(table, 'width', `${targetW}px`)
editor.dom.setStyle(table, 'max-width', '100%')
editor.dom.setStyle(table, 'box-sizing', 'border-box')
editor.dom.setAttrib(table, 'width', '')
const applyWidthToCol = (colIndex: number, widthPx: number) => {
getColumnCells(table, colIndex).forEach((cell) => {
editor.dom.setStyle(cell, 'box-sizing', 'border-box')
editor.dom.setStyle(cell, 'width', `${widthPx}px`)
editor.dom.setAttrib(cell, 'width', '')
})
const head = findMetricRow(table)
if (!head) return
let idx = 0
for (const cell of Array.from(head.cells)) {
const span = cell.colSpan || 1
if (colIndex >= idx && colIndex < idx + span && span === 1) {
editor.dom.setStyle(cell, 'box-sizing', 'border-box')
editor.dom.setStyle(cell, 'width', `${widthPx}px`)
break
}
idx += span
}
}
for (let i = 0; i < cols; i++) {
applyWidthToCol(i, widths[i])
}
syncColgroupPixelWidths(editor, table, widths)
return { widths, tableWidth: targetW }
}
const injectHandleStyles = (doc: Document) => {
// 版本号:样式规则变更后强制重注入
const styleId = 'sx-excel-resize-style-v7'
;[
'sx-excel-resize-style',
'sx-excel-resize-style-v3',
'sx-excel-resize-style-v4',
'sx-excel-resize-style-v5',
'sx-excel-resize-style-v6'
].forEach((id) => doc.getElementById(id)?.remove())
if (doc.getElementById(styleId)) return
const style = doc.createElement('style')
style.id = styleId
style.textContent = `
.${HANDLE_CLASS} {
position: absolute;
z-index: 100001;
background: transparent;
box-sizing: border-box;
pointer-events: auto !important;
}
/* 可视:黑线 + 极淡绿阴影(能辨认即可) */
.${HANDLE_COL}::before {
content: '';
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1px;
transform: translateX(-50%);
background: #000;
box-shadow:
-1px 0 1px rgba(16, 185, 129, 0.35),
1px 0 1px rgba(16, 185, 129, 0.35);
pointer-events: none;
}
.${HANDLE_ROW}::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
transform: translateY(-50%);
background: #000;
box-shadow:
0 -1px 1px rgba(16, 185, 129, 0.35),
0 1px 1px rgba(16, 185, 129, 0.35);
pointer-events: none;
}
.${HANDLE_CLASS}:hover::before,
.${HANDLE_CLASS}.is-dragging::before {
box-shadow:
-1px 0 2px rgba(16, 185, 129, 0.5),
1px 0 2px rgba(16, 185, 129, 0.5);
}
.${HANDLE_ROW}:hover::before,
.${HANDLE_ROW}.is-dragging::before {
box-shadow:
0 -1px 2px rgba(16, 185, 129, 0.5),
0 1px 2px rgba(16, 185, 129, 0.5);
}
.${HANDLE_COL} { cursor: col-resize; z-index: 100001; }
.${HANDLE_ROW} { cursor: row-resize; z-index: 100002; }
.${ROW_SPACER_CLASS} {
display: block !important;
float: left !important;
width: 0 !important;
max-width: 0 !important;
margin: 0 !important;
padding: 0 !important;
border: 0 !important;
overflow: hidden !important;
visibility: hidden !important;
pointer-events: none !important;
line-height: 0 !important;
font-size: 0 !important;
}
`
doc.head.appendChild(style)
}
export const setupExcelLikeTableResize = (editor: any) => {
let state: ResizeState | null = null
let activeTable: HTMLTableElement | null = null
let handlesRoot: HTMLElement | null = null
let hideTimer: number | null = null
const setColWidth = (table: HTMLTableElement, colIndex: number, widthPx: number) => {
const w = Math.max(MIN_COL_PX, Math.round(widthPx))
getColumnCells(table, colIndex).forEach((cell) => {
editor.dom.setStyle(cell, 'box-sizing', 'border-box')
editor.dom.setStyle(cell, 'width', `${w}px`)
})
const head = findMetricRow(table)
if (head) {
let idx = 0
for (const cell of Array.from(head.cells)) {
const span = cell.colSpan || 1
if (colIndex >= idx && colIndex < idx + span && span === 1) {
editor.dom.setStyle(cell, 'box-sizing', 'border-box')
editor.dom.setStyle(cell, 'width', `${w}px`)
break
}
idx += span
}
}
}
/** 按完整列宽数组重写,并锁定表宽(Excel:拖边界只改两列,总宽不变) */
const applyColumnWidthsLocked = (
table: HTMLTableElement,
widths: number[],
lockedTableWidth: number
) => {
editor.dom.setStyle(table, 'table-layout', 'fixed')
editor.dom.setStyle(table, 'box-sizing', 'border-box')
editor.dom.setStyle(table, 'width', `${Math.round(lockedTableWidth)}px`)
editor.dom.setStyle(table, 'max-width', '100%')
for (let i = 0; i < widths.length; i++) {
setColWidth(table, i, widths[i])
}
syncColgroupPixelWidths(editor, table, widths)
}
const canResizeRow = (table: HTMLTableElement, row: HTMLTableRowElement): boolean => {
if (isGapSpacerRow(row)) return false
// 合同/报价等普通表:任意行
if (!tableHasProductGrid(table) && !isProductOnlyTable(table)) return true
// 物料网格行(表头/明细)
if (isProductGridRow(row)) return true
// 独立物料表:备注、条款等合并行也允许拖行高(用户要求)
if (isProductOnlyTable(table)) return true
return false
}
const ensureRowSpacer = (cell: HTMLTableCellElement, heightPx: number) => {
const doc = cell.ownerDocument
let spacer = cell.querySelector(`:scope > .${ROW_SPACER_CLASS}`) as HTMLElement | null
if (!spacer) {
spacer = doc.createElement('div')
spacer.className = ROW_SPACER_CLASS
spacer.setAttribute('contenteditable', 'false')
spacer.setAttribute('data-mce-contenteditable', 'false')
// 避免被清空:放一个零宽字符
spacer.textContent = '\u200b'
cell.insertBefore(spacer, cell.firstChild)
}
spacer.style.setProperty('height', `${heightPx}px`, 'important')
spacer.style.setProperty('min-height', `${heightPx}px`, 'important')
}
const setRowHeight = (table: HTMLTableElement, rowIndex: number, heightPx: number) => {
const h = Math.max(MIN_ROW_PX, Math.round(heightPx))
const row = table.rows[rowIndex]
if (!row || !canResizeRow(table, row)) return
/**
* 合同/报价里 TinyMCE 写 tr height 通常够用。
* 单据表在部分浏览器 + border-collapse/fixed 下 tr/td height 会被忽略,
* 因此用每个单元格内的 float 占位条强制撑高(对布局最稳)。
*/
row.style.setProperty('height', `${h}px`, 'important')
row.setAttribute('height', String(h))
Array.from(row.cells).forEach((cell) => {
ensureRowSpacer(cell, h)
cell.style.setProperty('height', `${h}px`, 'important')
cell.style.setProperty('min-height', `${h}px`, 'important')
cell.setAttribute('height', String(h))
})
}
const clearHandles = () => {
handlesRoot?.querySelectorAll(`.${HANDLE_CLASS}`).forEach((el) => el.remove())
}
const ensureHandlesRoot = (body: HTMLElement) => {
if (handlesRoot && handlesRoot.isConnected) {
// setContent 后可能被挤到前面,保持置顶才能盖住表格命中
if (handlesRoot.parentElement === body && body.lastElementChild !== handlesRoot) {
body.appendChild(handlesRoot)
}
return handlesRoot
}
handlesRoot = body.ownerDocument.createElement('div')
handlesRoot.id = 'sx-excel-resize-layer'
// 铺满编辑区:父级 0×0 时部分浏览器子元素无法命中;层本身不拦截,仅把手可点
handlesRoot.style.cssText =
'position:absolute;left:0;top:0;right:0;bottom:0;width:auto;height:auto;overflow:visible;z-index:100000;pointer-events:none;'
body.appendChild(handlesRoot)
return handlesRoot
}
const renderHandles = (table: HTMLTableElement, _preferProductGrid = false) => {
const body = editor.getBody() as HTMLElement | null
const doc = editor.getDoc() as Document | null
if (!body || !doc || editor.mode?.isReadOnly?.() || editor.readonly) return
injectHandleStyles(doc)
const root = ensureHandlesRoot(body)
clearHandles()
activeTable = table
const tableRect = table.getBoundingClientRect()
if (tableRect.width < 8 || tableRect.height < 8) return
const hasGrid = tableHasProductGrid(table)
// 物料表含备注/条款时:调整条只覆盖表头+明细网格;量不到时回退整表,避免功能“消失”
const productRect = hasGrid ? getProductGridRect(table) : null
const bandRect = productRect || tableRect
const bandPos = relativeToBody(body, bandRect)
const cols = columnCount(table)
if (cols < 2 && table.rows.length < 1) return
// 只在列与列之间放竖条;单据表仅覆盖物料明细网格高度
for (let i = 0; i < cols - 1; i++) {
const cells = getColumnCells(table, i)
const sample =
cells.find((c) => isProductGridCell(c)) ||
cells[0] ||
table.rows[0]?.cells?.[i]
if (!sample) continue
const cr = sample.getBoundingClientRect()
const pos = relativeToBody(body, cr)
const handle = doc.createElement('div')
handle.className = `${HANDLE_CLASS} ${HANDLE_COL}`
handle.dataset.kind = 'col'
handle.dataset.index = String(i)
handle.dataset.neighbor = String(i + 1)
handle.style.left = `${pos.left + cr.width - HANDLE_THICKNESS / 2}px`
handle.style.top = `${bandPos.top}px`
handle.style.width = `${HANDLE_THICKNESS}px`
handle.style.height = `${bandRect.height}px`
handle.style.pointerEvents = 'auto'
root.appendChild(handle)
}
// 行底边:物料网格行(或独立物料表的非合并行)
Array.from(table.rows).forEach((row, rowIndex) => {
if (!canResizeRow(table, row)) return
const rr = row.getBoundingClientRect()
const pos = relativeToBody(body, rr)
const handle = doc.createElement('div')
handle.className = `${HANDLE_CLASS} ${HANDLE_ROW}`
handle.dataset.kind = 'row'
handle.dataset.index = String(rowIndex)
handle.style.left = `${bandPos.left}px`
handle.style.top = `${pos.top + rr.height - HANDLE_ROW_THICKNESS / 2}px`
handle.style.width = `${bandRect.width}px`
handle.style.height = `${HANDLE_ROW_THICKNESS}px`
handle.style.pointerEvents = 'auto'
root.appendChild(handle)
})
}
const scheduleHideHandles = () => {
if (hideTimer) window.clearTimeout(hideTimer)
hideTimer = window.setTimeout(() => {
if (!state) {
clearHandles()
activeTable = null
}
}, 250)
}
const cancelHideHandles = () => {
if (hideTimer) {
window.clearTimeout(hideTimer)
hideTimer = null
}
}
const onHandleMouseDown = (e: MouseEvent) => {
const target = e.target as HTMLElement | null
if (!target?.classList?.contains(HANDLE_CLASS)) return
if (!activeTable) return
if (editor.mode?.isReadOnly?.() || editor.readonly) return
e.preventDefault()
e.stopPropagation()
cancelHideHandles()
target.classList.add('is-dragging')
const kind = (target.dataset.kind || '') as ResizeKind
const index = Number(target.dataset.index)
if ((kind !== 'col' && kind !== 'row') || Number.isNaN(index)) return
editor.focus()
if (kind === 'col') {
const cols = columnCount(activeTable)
const neighborIndex = Number(target.dataset.neighbor)
if (Number.isNaN(neighborIndex) || neighborIndex <= index || neighborIndex >= cols) return
const snap = snapshotTableVisualToPixels(editor, activeTable)
if (!snap) return
state = {
kind: 'col',
table: activeTable,
index,
neighborIndex,
startPos: e.clientX,
startSizes: snap.widths,
lockedTableWidth: snap.tableWidth
}
} else {
const row = activeTable.rows[index]
if (!row || !canResizeRow(activeTable, row)) return
// 注意:行拖不要走列宽快照(会改写单元格 style,易冲掉行高)
state = {
kind: 'row',
table: activeTable,
index,
startPos: e.clientY,
startSizes: [measureRowHeight(row)]
}
}
}
const onDocMouseMove = (e: MouseEvent) => {
if (!state) return
e.preventDefault()
if (state.kind === 'col') {
const neighborIndex = state.neighborIndex
const locked = state.lockedTableWidth
if (neighborIndex == null || locked == null) return
const delta = e.clientX - state.startPos
const leftStart = state.startSizes[state.index]
const rightStart = state.startSizes[neighborIndex]
const maxGrow = rightStart - MIN_COL_PX
const maxShrink = leftStart - MIN_COL_PX
const appliedDelta = Math.min(Math.max(delta, -maxShrink), maxGrow)
const leftW = leftStart + appliedDelta
const rightW = rightStart - appliedDelta
const widths = state.startSizes.slice()
widths[state.index] = leftW
widths[neighborIndex] = rightW
applyColumnWidthsLocked(state.table, widths, locked)
} else {
const delta = e.clientY - state.startPos
setRowHeight(state.table, state.index, state.startSizes[0] + delta)
}
}
const onDocMouseUp = () => {
if (!state) return
const table = state.table
state = null
handlesRoot?.querySelectorAll('.is-dragging').forEach((el) => el.classList.remove('is-dragging'))
renderHandles(table, tableHasProductGrid(table))
editor.nodeChanged()
editor.undoManager?.add?.()
editor.fire('change')
}
const onEditorMouseMove = (e: MouseEvent) => {
if (state) return
const t = e.target as Node | null
if (t && (t as HTMLElement).classList?.contains?.(HANDLE_CLASS)) {
cancelHideHandles()
return
}
const el = t instanceof Element ? t : t?.parentElement
const table = resolveResizeTargetTable(el || null)
if (table && editor.getBody()?.contains(table) && !isExcelResizeExcludedTable(table)) {
const cls =
typeof table.className === 'string' ? table.className : String(table.className || '')
if (/slip-(info|footer|sign)-table/i.test(cls)) {
scheduleHideHandles()
return
}
// 独立物料表:悬停表内任意处(含备注/条款)都可出调整条,但条只画在明细网格上
if (isProductOnlyTable(table)) {
cancelHideHandles()
if (activeTable !== table || !handlesRoot?.querySelector(`.${HANDLE_CLASS}`)) {
renderHandles(table, true)
}
return
}
if (tableHasProductGrid(table) && !isProductGridCell(el)) {
scheduleHideHandles()
return
}
cancelHideHandles()
if (activeTable !== table || !handlesRoot?.querySelector(`.${HANDLE_CLASS}`)) {
renderHandles(table, true)
}
} else {
scheduleHideHandles()
}
}
const onBodyMouseMove = (e: MouseEvent) => {
onEditorMouseMove(e)
}
const bind = () => {
const body = editor.getBody() as HTMLElement | null
const doc = editor.getDoc() as Document | null
if (!body || !doc) return
if (body.getAttribute('data-sx-excel-resize') === '1') {
ensureHandlesRoot(body)
return
}
injectHandleStyles(doc)
ensureHandlesRoot(body)
// 捕获阶段:保证点到调整条 / 悬停检测不依赖 TinyMCE 事件封装
body.addEventListener('mousedown', onHandleMouseDown, true)
body.addEventListener('mousemove', onBodyMouseMove, true)
doc.addEventListener('mousemove', onDocMouseMove, true)
doc.addEventListener('mouseup', onDocMouseUp, true)
body.setAttribute('data-sx-excel-resize', '1')
}
const unbind = () => {
const body = editor.getBody?.() as HTMLElement | null
const doc = editor.getDoc?.() as Document | null
body?.removeEventListener('mousedown', onHandleMouseDown, true)
body?.removeEventListener('mousemove', onBodyMouseMove, true)
doc?.removeEventListener('mousemove', onDocMouseMove, true)
doc?.removeEventListener('mouseup', onDocMouseUp, true)
body?.removeAttribute('data-sx-excel-resize')
clearHandles()
handlesRoot?.remove()
handlesRoot = null
activeTable = null
}
editor.on('init', () => {
// 下一帧再绑,确保 iframe body 可交互
requestAnimationFrame(() => bind())
})
// 载入/切换单据 HTML 后重挂调整层(setContent 会清掉 body 子节点)
editor.on('SetContent', () => {
requestAnimationFrame(() => {
handlesRoot = null
const body = editor.getBody() as HTMLElement | null
if (body?.getAttribute('data-sx-excel-resize') !== '1') {
bind()
} else {
ensureHandlesRoot(body)
}
})
})
editor.on('ScrollContent ScrollWindow', () => {
if (activeTable && !state) renderHandles(activeTable, tableHasProductGrid(activeTable))
})
editor.on('remove', unbind)
}
const parseCssLengthPx = (raw: string | null | undefined): number | null => {
if (!raw) return null
const s = String(raw).trim()
if (!s) return null
if (s.endsWith('%')) return null
const px = s.match(/^([\d.]+)px$/i)
if (px) return parseFloat(px[1])
const num = s.match(/^([\d.]+)$/)
if (num) return parseFloat(num[1])
return null
}
/** 读取声明列宽(col / 单元格 style|attr),避免被 max-width:100% 的视觉宽度误导 */
const getDeclaredColWidthPx = (table: HTMLTableElement, colIndex: number): number | null => {
const cols = table.querySelectorAll('colgroup col')
const col = cols[colIndex] as HTMLElement | undefined
if (col) {
const fromCol =
parseCssLengthPx(col.style.width) ?? parseCssLengthPx(col.getAttribute('width'))
if (fromCol && fromCol > 0) return fromCol
}
const row = table.rows[0]
if (!row) return null
let idx = 0
for (const cell of Array.from(row.cells)) {
const span = cell.colSpan || 1
if (colIndex >= idx && colIndex < idx + span) {
const fromStyle =
parseCssLengthPx(cell.style.width) ?? parseCssLengthPx(cell.getAttribute('width'))
if (fromStyle && fromStyle > 0) return span > 1 ? fromStyle / span : fromStyle
return null
}
idx += span
}
return null
}
/**
* 将富文本内表格强制适配到 maxWidthPx(用于 PDF/打印容器)。
*
* 根因:编辑器 content_style 有 max-width:100%,拖宽后视觉已钳制,但 HTML 仍保留更大的
* 单元格/col 像素宽。PDF 临时 DOM 无该样式时,声明宽会撑破 A4,html2canvas 裁切右侧。
* 因此必须以「声明列宽之和」为准重写,不能只看 getBoundingClientRect。
* 仅改临时 DOM,不回写业务 HTML。
*/
export function fitRichTextTablesToMaxWidth(root: HTMLElement, maxWidthPx: number): void {
if (!root || maxWidthPx <= 0) return
const targetW = Math.floor(maxWidthPx)
// 外层模版常带 max-width:794 + padding,叠在 PDF 容器 padding 上会再挤窄;统一吃满内容区
root.querySelectorAll('[style*="max-width"]').forEach((el) => {
if (el.tagName === 'TABLE' || el.tagName === 'IMG') return
el.style.maxWidth = '100%'
el.style.width = '100%'
el.style.boxSizing = 'border-box'
})
root.querySelectorAll('table').forEach((tableEl) => {
const table = tableEl as HTMLTableElement
Array.from(table.querySelectorAll('td, th')).forEach((cell) => {
const el = cell as HTMLTableCellElement
el.style.boxSizing = 'border-box'
el.style.minWidth = '0'
el.removeAttribute('width')
el.style.wordBreak = 'break-word'
el.style.overflowWrap = 'anywhere'
})
table.removeAttribute('width')
const cols = columnCount(table)
if (cols <= 0) {
table.style.maxWidth = '100%'
table.style.width = '100%'
table.style.tableLayout = 'fixed'
table.style.boxSizing = 'border-box'
return
}
const raw: number[] = []
for (let i = 0; i < cols; i++) {
const declared = getDeclaredColWidthPx(table, i)
if (declared && declared > 0) {
raw.push(Math.max(MIN_COL_PX, Math.round(declared)))
} else {
raw.push(Math.max(MIN_COL_PX, Math.round(measureColWidth(table, i))))
}
}
const rawSum = raw.reduce((a, b) => a + b, 0) || cols
const tableDeclared =
parseCssLengthPx(table.style.width) ?? parseCssLengthPx(table.getAttribute('width'))
// 任一声明总宽超过目标,或列宽之和超过目标,都必须重写(不能因视觉已钳制而跳过)
const needsFit = (tableDeclared != null && tableDeclared > targetW + 1) || rawSum > targetW + 1
table.style.maxWidth = '100%'
table.style.width = '100%'
table.style.tableLayout = 'fixed'
table.style.boxSizing = 'border-box'
if (!needsFit && rawSum <= targetW + 1) {
// 声明已在范围内:仍统一成百分比,避免 PDF 环境差异
let usedPct = 0
for (let i = 0; i < cols; i++) {
const pct =
i === cols - 1
? Math.max(1, 100 - usedPct)
: Math.max(1, Math.round((raw[i] / rawSum) * 100))
if (i < cols - 1) usedPct += pct
const pctStr = `${pct}%`
getColumnCells(table, i).forEach((cell) => {
cell.style.width = pctStr
})
const head = table.rows[0]
if (head) {
let idx = 0
for (const cell of Array.from(head.cells)) {
const span = cell.colSpan || 1
if (i >= idx && i < idx + span && span === 1) {
cell.style.width = pctStr
break
}
idx += span
}
}
const colEl = table.querySelectorAll('colgroup col')[i] as HTMLElement | undefined
if (colEl) {
colEl.style.width = pctStr
colEl.removeAttribute('width')
}
}
return
}
let used = 0
const widths = raw.map((w, i) => {
if (i === cols - 1) return Math.max(MIN_COL_PX, targetW - used)
const next = Math.max(MIN_COL_PX, Math.round((w / rawSum) * targetW))
used += next
return next
})
let usedPct = 0
for (let i = 0; i < cols; i++) {
const pct =
i === cols - 1
? Math.max(1, 100 - usedPct)
: Math.max(1, Math.round((widths[i] / targetW) * 100))
if (i < cols - 1) usedPct += pct
const pctStr = `${pct}%`
getColumnCells(table, i).forEach((cell) => {
cell.style.width = pctStr
})
const head = table.rows[0]
if (head) {
let idx = 0
for (const cell of Array.from(head.cells)) {
const span = cell.colSpan || 1
if (i >= idx && i < idx + span && span === 1) {
cell.style.width = pctStr
break
}
idx += span
}
}
const colEl = table.querySelectorAll('colgroup col')[i] as HTMLElement | undefined
if (colEl) {
colEl.style.width = pctStr
colEl.removeAttribute('width')
}
}
})
}