Files
shanxia2.0/web-admin/src/utils/tinymce-table-excel-resize.ts
T
zlwuandCursor 3d5866aa14 feat(web-admin): TinyMCE 表格类 Excel 调宽调高,并优化出入库/分装/送货单排版
支持合同、报价与入库/分装/送(提)货单在富文本中拖拽调整列宽行高;单据侧加宽物料编号与名称、收窄规格单位、放大 logo、加宽条款区,并弱化调整手柄绿色阴影。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 18:02:22 +08:00

928 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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)
}
/**
* 入库单等模板用 `<col span="7" style="width:14.28%">`,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<HTMLElement>('[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')
}
}
})
}