|
|
@@ -0,0 +1,350 @@
|
|
|
+/**
|
|
|
+ * 富文本表格只在两条边上拖拽:
|
|
|
+ * 第一行右边界改列宽(整表变宽,邻列不动);
|
|
|
+ * 每一行从第 1 列开始的格子下边界改行高。
|
|
|
+ * 打开编辑器时不写表格尺寸,只有按下拖动才写入。
|
|
|
+ */
|
|
|
+
|
|
|
+export const MIN_COL_PX = 28
|
|
|
+export const MIN_ROW_PX = 20
|
|
|
+
|
|
|
+const EDGE_PX = 8
|
|
|
+const HANDLE_PX = 8
|
|
|
+const LAYER_ID = 'sx-edge-resize-layer'
|
|
|
+const HANDLE_CLASS = 'sx-edge-resize-handle'
|
|
|
+
|
|
|
+export interface ColumnResizeResult {
|
|
|
+ widths: number[]
|
|
|
+ tableWidth: number
|
|
|
+}
|
|
|
+
|
|
|
+export const applyColumnWidthDelta = (
|
|
|
+ widths: number[],
|
|
|
+ index: number,
|
|
|
+ delta: number,
|
|
|
+ minPx = MIN_COL_PX
|
|
|
+): ColumnResizeResult => {
|
|
|
+ const next = widths.slice()
|
|
|
+ const current = next[index] ?? minPx
|
|
|
+ next[index] = Math.max(minPx, Math.round(current + delta))
|
|
|
+ const tableWidth = next.reduce((sum, width) => sum + width, 0)
|
|
|
+ return { widths: next, tableWidth }
|
|
|
+}
|
|
|
+
|
|
|
+export const applyRowHeightDelta = (height: number, delta: number, minPx = MIN_ROW_PX): number =>
|
|
|
+ Math.max(minPx, Math.round(height + delta))
|
|
|
+
|
|
|
+const parsePx = (value: string | null | undefined): number | null => {
|
|
|
+ if (!value) return null
|
|
|
+ const matched = String(value).match(/([\d.]+)\s*px/i)
|
|
|
+ if (!matched) return null
|
|
|
+ const parsed = Number(matched[1])
|
|
|
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null
|
|
|
+}
|
|
|
+
|
|
|
+const isReadonly = (editor: any): boolean => Boolean(editor.mode?.isReadOnly?.() || editor.readonly)
|
|
|
+
|
|
|
+/** 格子在表格中的起始列。上一行 rowspan 占住的列位会跳过。 */
|
|
|
+const columnOrigin = (table: HTMLTableElement, cell: HTMLTableCellElement): number => {
|
|
|
+ const grid: Array<Array<HTMLTableCellElement | undefined>> = []
|
|
|
+ for (let r = 0; r < table.rows.length; r += 1) {
|
|
|
+ grid[r] = grid[r] || []
|
|
|
+ let col = 0
|
|
|
+ for (const source of Array.from(table.rows[r].cells)) {
|
|
|
+ while (grid[r][col]) col += 1
|
|
|
+ const colSpan = source.colSpan || 1
|
|
|
+ const rowSpan = source.rowSpan || 1
|
|
|
+ for (let rr = 0; rr < rowSpan; rr += 1) {
|
|
|
+ const line = grid[r + rr] || (grid[r + rr] = [])
|
|
|
+ for (let cc = 0; cc < colSpan; cc += 1) line[col + cc] = source
|
|
|
+ }
|
|
|
+ if (source === cell) return col
|
|
|
+ col += colSpan
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return -1
|
|
|
+}
|
|
|
+
|
|
|
+const readColumnWidths = (table: HTMLTableElement): number[] => {
|
|
|
+ const header = table.rows[0]
|
|
|
+ if (!header) return []
|
|
|
+ const count = Array.from(header.cells).reduce((sum, cell) => sum + (cell.colSpan || 1), 0)
|
|
|
+ if (count < 1) return []
|
|
|
+ const cols = Array.from(table.querySelectorAll('col'))
|
|
|
+ if (cols.length === count) {
|
|
|
+ const declared = cols.map((col) => parsePx(col.style.width) ?? parsePx(col.getAttribute('width')))
|
|
|
+ if (declared.every((width): width is number => width != null)) return declared
|
|
|
+ }
|
|
|
+ const headerCells = Array.from(header.cells)
|
|
|
+ if (headerCells.every((cell) => (cell.colSpan || 1) === 1)) {
|
|
|
+ const declared = headerCells.map((cell) => parsePx(cell.style.width))
|
|
|
+ if (declared.every((width): width is number => width != null)) return declared
|
|
|
+ }
|
|
|
+ const measured: number[] = []
|
|
|
+ headerCells.forEach((cell) => {
|
|
|
+ const span = cell.colSpan || 1
|
|
|
+ const each = cell.getBoundingClientRect().width / span
|
|
|
+ for (let i = 0; i < span; i += 1) measured.push(Math.max(MIN_COL_PX, Math.round(each)))
|
|
|
+ })
|
|
|
+ return measured
|
|
|
+}
|
|
|
+
|
|
|
+const writeColumnWidths = (table: HTMLTableElement, widths: number[]): void => {
|
|
|
+ const doc = table.ownerDocument
|
|
|
+ const sum = widths.reduce((total, width) => total + width, 0)
|
|
|
+ table.style.tableLayout = 'fixed'
|
|
|
+ table.style.boxSizing = 'border-box'
|
|
|
+ table.style.width = `${sum}px`
|
|
|
+ table.style.maxWidth = `${sum}px`
|
|
|
+
|
|
|
+ let colgroup = table.querySelector('colgroup')
|
|
|
+ if (!colgroup) {
|
|
|
+ colgroup = doc.createElement('colgroup')
|
|
|
+ table.insertBefore(colgroup, table.firstChild)
|
|
|
+ }
|
|
|
+ const cols = Array.from(colgroup.querySelectorAll('col'))
|
|
|
+ if (cols.length !== widths.length) {
|
|
|
+ colgroup.replaceChildren()
|
|
|
+ widths.forEach((width) => {
|
|
|
+ const col = doc.createElement('col')
|
|
|
+ col.style.width = `${Math.round(width)}px`
|
|
|
+ colgroup?.appendChild(col)
|
|
|
+ })
|
|
|
+ } else {
|
|
|
+ cols.forEach((col, index) => {
|
|
|
+ col.style.width = `${Math.round(widths[index])}px`
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ const header = table.rows[0]
|
|
|
+ if (!header) return
|
|
|
+ let index = 0
|
|
|
+ Array.from(header.cells).forEach((cell) => {
|
|
|
+ const span = cell.colSpan || 1
|
|
|
+ if (span === 1 && widths[index] != null) {
|
|
|
+ cell.style.boxSizing = 'border-box'
|
|
|
+ cell.style.width = `${Math.round(widths[index])}px`
|
|
|
+ }
|
|
|
+ index += span
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+const writeRowHeight = (row: HTMLTableRowElement, cell: HTMLTableCellElement, height: number): void => {
|
|
|
+ const px = `${Math.round(height)}px`
|
|
|
+ row.style.height = px
|
|
|
+ cell.style.height = px
|
|
|
+}
|
|
|
+
|
|
|
+type DragState =
|
|
|
+ | {
|
|
|
+ kind: 'col'
|
|
|
+ table: HTMLTableElement
|
|
|
+ index: number
|
|
|
+ startX: number
|
|
|
+ startWidths: number[]
|
|
|
+ }
|
|
|
+ | {
|
|
|
+ kind: 'row'
|
|
|
+ table: HTMLTableElement
|
|
|
+ row: HTMLTableRowElement
|
|
|
+ cell: HTMLTableCellElement
|
|
|
+ startY: number
|
|
|
+ startHeight: number
|
|
|
+ }
|
|
|
+
|
|
|
+const handleMeta = new WeakMap<
|
|
|
+ HTMLElement,
|
|
|
+ { cell: HTMLTableCellElement; kind: 'col' | 'row'; index: number }
|
|
|
+>()
|
|
|
+
|
|
|
+export const setupTableEdgeResize = (editor: any): void => {
|
|
|
+ let state: DragState | null = null
|
|
|
+ let layer: HTMLElement | null = null
|
|
|
+
|
|
|
+ const docOf = (): Document | null => (editor.getDoc?.() as Document | null) ?? null
|
|
|
+ const bodyOf = (): HTMLElement | null => (editor.getBody?.() as HTMLElement | null) ?? null
|
|
|
+
|
|
|
+ const clearHandle = () => {
|
|
|
+ layer?.replaceChildren()
|
|
|
+ }
|
|
|
+
|
|
|
+ const ensureLayer = (body: HTMLElement): HTMLElement => {
|
|
|
+ const doc = body.ownerDocument
|
|
|
+ if (layer && layer.isConnected) {
|
|
|
+ if (body.lastElementChild !== layer) body.appendChild(layer)
|
|
|
+ return layer
|
|
|
+ }
|
|
|
+ const existing = doc.getElementById(LAYER_ID)
|
|
|
+ if (existing) {
|
|
|
+ layer = existing
|
|
|
+ return existing
|
|
|
+ }
|
|
|
+ layer = doc.createElement('div')
|
|
|
+ layer.id = LAYER_ID
|
|
|
+ layer.setAttribute('data-mce-bogus', 'all')
|
|
|
+ layer.setAttribute('contenteditable', 'false')
|
|
|
+ layer.style.cssText =
|
|
|
+ 'position:absolute;left:0;top:0;right:0;bottom:0;width:auto;height:auto;overflow:visible;z-index:30;pointer-events:none;'
|
|
|
+ body.appendChild(layer)
|
|
|
+ return layer
|
|
|
+ }
|
|
|
+
|
|
|
+ const placeHandle = (cell: HTMLTableCellElement, kind: 'col' | 'row') => {
|
|
|
+ const body = bodyOf()
|
|
|
+ const doc = docOf()
|
|
|
+ if (!body || !doc || isReadonly(editor)) return
|
|
|
+ const root = ensureLayer(body)
|
|
|
+ root.replaceChildren()
|
|
|
+ const bodyRect = body.getBoundingClientRect()
|
|
|
+ const cellRect = cell.getBoundingClientRect()
|
|
|
+ const handle = doc.createElement('div')
|
|
|
+ handle.className = HANDLE_CLASS
|
|
|
+ handle.dataset.kind = kind
|
|
|
+ handle.setAttribute('data-mce-bogus', 'all')
|
|
|
+ handle.setAttribute('contenteditable', 'false')
|
|
|
+ const table = cell.closest('table') as HTMLTableElement
|
|
|
+ handleMeta.set(handle, { cell, kind, index: columnOrigin(table, cell) })
|
|
|
+ const top = cellRect.top - bodyRect.top + body.scrollTop
|
|
|
+ const left = cellRect.left - bodyRect.left + body.scrollLeft
|
|
|
+ if (kind === 'col') {
|
|
|
+ handle.style.cssText = `position:absolute;left:${left + cellRect.width - HANDLE_PX / 2}px;top:${top}px;width:${HANDLE_PX}px;height:${cellRect.height}px;background:#10b981;cursor:col-resize;pointer-events:auto;z-index:31;`
|
|
|
+ } else {
|
|
|
+ handle.style.cssText = `position:absolute;left:${left}px;top:${top + cellRect.height - HANDLE_PX / 2}px;width:${cellRect.width}px;height:${HANDLE_PX}px;background:#10b981;cursor:row-resize;pointer-events:auto;z-index:31;`
|
|
|
+ }
|
|
|
+ root.appendChild(handle)
|
|
|
+ }
|
|
|
+
|
|
|
+ const edgeCell = (event: MouseEvent): { cell: HTMLTableCellElement; kind: 'col' | 'row' } | null => {
|
|
|
+ const target = event.target as HTMLElement | null
|
|
|
+ if (target?.classList?.contains(HANDLE_CLASS)) return null
|
|
|
+ const cell = target?.closest?.('td,th') as HTMLTableCellElement | null
|
|
|
+ if (!cell) return null
|
|
|
+ const table = cell.closest('table') as HTMLTableElement | null
|
|
|
+ const row = cell.parentElement as HTMLTableRowElement | null
|
|
|
+ if (!table || !row || !editor.getBody?.()?.contains(table)) return null
|
|
|
+ const rect = cell.getBoundingClientRect()
|
|
|
+ const fromRight = Math.abs(event.clientX - rect.right)
|
|
|
+ const fromBottom = Math.abs(event.clientY - rect.bottom)
|
|
|
+ const insideY = event.clientY >= rect.top - EDGE_PX && event.clientY <= rect.bottom + EDGE_PX
|
|
|
+ const insideX = event.clientX >= rect.left - EDGE_PX && event.clientX <= rect.right + EDGE_PX
|
|
|
+ const canCol = row.rowIndex === 0 && (cell.colSpan || 1) === 1 && fromRight <= EDGE_PX && insideY
|
|
|
+ const canRow = columnOrigin(table, cell) === 0 && fromBottom <= EDGE_PX && insideX
|
|
|
+ if (canCol && canRow) return { cell, kind: fromRight <= fromBottom ? 'col' : 'row' }
|
|
|
+ if (canCol) return { cell, kind: 'col' }
|
|
|
+ if (canRow) return { cell, kind: 'row' }
|
|
|
+ return null
|
|
|
+ }
|
|
|
+
|
|
|
+ const onMouseMove = (event: MouseEvent) => {
|
|
|
+ if (isReadonly(editor)) {
|
|
|
+ clearHandle()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (state) {
|
|
|
+ if (state.kind === 'col') {
|
|
|
+ const delta = event.clientX - state.startX
|
|
|
+ const result = applyColumnWidthDelta(state.startWidths, state.index, delta)
|
|
|
+ editor.undoManager?.ignore?.(() => writeColumnWidths(state.table, result.widths))
|
|
|
+ const headerCell = columnCellAt(state.table.rows[0], state.index)
|
|
|
+ if (headerCell) placeHandle(headerCell, 'col')
|
|
|
+ } else {
|
|
|
+ const height = applyRowHeightDelta(state.startHeight, event.clientY - state.startY)
|
|
|
+ editor.undoManager?.ignore?.(() => writeRowHeight(state.row, state.cell, height))
|
|
|
+ placeHandle(state.cell, 'row')
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const target = event.target as HTMLElement | null
|
|
|
+ if (target?.classList?.contains(HANDLE_CLASS)) return
|
|
|
+ const edge = edgeCell(event)
|
|
|
+ if (!edge) {
|
|
|
+ clearHandle()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ placeHandle(edge.cell, edge.kind)
|
|
|
+ }
|
|
|
+
|
|
|
+ const onMouseDown = (event: MouseEvent) => {
|
|
|
+ const target = event.target as HTMLElement | null
|
|
|
+ if (!target?.classList?.contains(HANDLE_CLASS) || isReadonly(editor)) return
|
|
|
+ const body = bodyOf()
|
|
|
+ if (!body) return
|
|
|
+ const meta = handleMeta.get(target)
|
|
|
+ const cell = meta?.cell
|
|
|
+ const table = cell?.closest('table') as HTMLTableElement | null
|
|
|
+ const row = cell?.parentElement as HTMLTableRowElement | null
|
|
|
+ if (!meta || !cell || !table || !row || !cell.isConnected) return
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ editor.undoManager?.add?.()
|
|
|
+ if (meta.kind === 'col') {
|
|
|
+ if (meta.index < 0 || (cell.colSpan || 1) !== 1) return
|
|
|
+ state = {
|
|
|
+ kind: 'col',
|
|
|
+ table,
|
|
|
+ index: meta.index,
|
|
|
+ startX: event.clientX,
|
|
|
+ startWidths: readColumnWidths(table)
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ state = {
|
|
|
+ kind: 'row',
|
|
|
+ table,
|
|
|
+ row,
|
|
|
+ cell,
|
|
|
+ startY: event.clientY,
|
|
|
+ startHeight: row.getBoundingClientRect().height
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const onMouseUp = () => {
|
|
|
+ if (!state) return
|
|
|
+ state = null
|
|
|
+ editor.undoManager?.add?.()
|
|
|
+ editor.nodeChanged?.()
|
|
|
+ editor.fire?.('change')
|
|
|
+ }
|
|
|
+
|
|
|
+ const bind = () => {
|
|
|
+ const doc = docOf()
|
|
|
+ const body = bodyOf()
|
|
|
+ if (!doc || !body) return
|
|
|
+ doc.addEventListener('mousemove', onMouseMove)
|
|
|
+ doc.addEventListener('mousedown', onMouseDown, true)
|
|
|
+ doc.addEventListener('mouseup', onMouseUp)
|
|
|
+ doc.defaultView?.addEventListener('mouseup', onMouseUp)
|
|
|
+ }
|
|
|
+
|
|
|
+ const unbind = () => {
|
|
|
+ const doc = docOf()
|
|
|
+ doc?.removeEventListener('mousemove', onMouseMove)
|
|
|
+ doc?.removeEventListener('mousedown', onMouseDown, true)
|
|
|
+ doc?.removeEventListener('mouseup', onMouseUp)
|
|
|
+ doc?.defaultView?.removeEventListener('mouseup', onMouseUp)
|
|
|
+ layer?.remove()
|
|
|
+ layer = null
|
|
|
+ state = null
|
|
|
+ }
|
|
|
+
|
|
|
+ editor.on('BeforeGetContent', () => {
|
|
|
+ layer?.remove()
|
|
|
+ })
|
|
|
+ editor.on('GetContent', () => {
|
|
|
+ const body = bodyOf()
|
|
|
+ if (body && layer && !layer.isConnected) body.appendChild(layer)
|
|
|
+ })
|
|
|
+ editor.on('init', bind)
|
|
|
+ editor.on('remove', unbind)
|
|
|
+}
|
|
|
+
|
|
|
+const columnCellAt = (row: HTMLTableRowElement | undefined, index: number): HTMLTableCellElement | null => {
|
|
|
+ if (!row) return null
|
|
|
+ let cursor = 0
|
|
|
+ for (const cell of Array.from(row.cells)) {
|
|
|
+ const span = cell.colSpan || 1
|
|
|
+ if (cursor === index && span === 1) return cell
|
|
|
+ cursor += span
|
|
|
+ }
|
|
|
+ return null
|
|
|
+}
|