支持合同、报价与入库/分装/送(提)货单在富文本中拖拽调整列宽行高;单据侧加宽物料编号与名称、收窄规格单位、放大 logo、加宽条款区,并弱化调整手柄绿色阴影。 Co-authored-by: Cursor <cursoragent@cursor.com>
928 lines
32 KiB
TypeScript
928 lines
32 KiB
TypeScript
/**
|
||
* 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')
|
||
}
|
||
}
|
||
})
|
||
}
|