/** * 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') } } }) }