File
Blob: src/client/components/editor/extensions/table/widths.ts
| 1 | import { TableMap } from "@tiptap/pm/tables"; |
| 2 | import type { EditorView } from "@tiptap/pm/view"; |
| 3 | import type { EditorState, Transaction } from "@tiptap/pm/state"; |
| 4 | import type { Node as PMNode } from "@tiptap/pm/model"; |
| 5 | import { TABLE_CELL_MIN_WIDTH } from "./constants"; |
| 6 | import { findTableElement } from "./dom"; |
| 7 | |
| 8 | export interface TableWidthSnapshot { |
| 9 | hasSome: boolean; |
| 10 | hasAll: boolean; |
| 11 | } |
| 12 | |
| 13 | export function hasExplicitColumnWidths(table: PMNode): boolean { |
| 14 | let hasWidths = false; |
| 15 | table.firstChild?.forEach((cell) => { |
| 16 | if (cell.attrs.colwidth != null) hasWidths = true; |
| 17 | }); |
| 18 | return hasWidths; |
| 19 | } |
| 20 | |
| 21 | export function snapshotTableWidths(table: PMNode): TableWidthSnapshot { |
| 22 | const map = TableMap.get(table); |
| 23 | const coverage = new Array(map.width).fill(false); |
| 24 | let hasSome = false; |
| 25 | let col = 0; |
| 26 | |
| 27 | table.firstChild?.forEach((cell) => { |
| 28 | const colspan = (cell.attrs.colspan as number) ?? 1; |
| 29 | const colwidth = cell.attrs.colwidth as number[] | null; |
| 30 | if (colwidth) { |
| 31 | for (let index = 0; index < colspan; index++) { |
| 32 | const width = colwidth[index]; |
| 33 | if (typeof width === "number" && width > 0) { |
| 34 | coverage[col + index] = true; |
| 35 | hasSome = true; |
| 36 | } |
| 37 | } |
| 38 | } |
| 39 | col += colspan; |
| 40 | }); |
| 41 | |
| 42 | return { hasSome, hasAll: coverage.every(Boolean) }; |
| 43 | } |
| 44 | |
| 45 | export function measureAutoColumnWidths(view: EditorView, tablePos: number): number[] | null { |
| 46 | const tableEl = findTableElement(view, tablePos); |
| 47 | if (!tableEl) return null; |
| 48 | |
| 49 | const host = document.createElement("div"); |
| 50 | host.className = "tiptap"; |
| 51 | host.style.position = "absolute"; |
| 52 | host.style.left = "-99999px"; |
| 53 | host.style.top = "0"; |
| 54 | host.style.visibility = "hidden"; |
| 55 | host.style.pointerEvents = "none"; |
| 56 | host.style.width = "max-content"; |
| 57 | host.style.maxWidth = "none"; |
| 58 | host.style.whiteSpace = "nowrap"; |
| 59 | |
| 60 | const clone = tableEl.cloneNode(true) as HTMLTableElement; |
| 61 | clone.querySelector("colgroup")?.remove(); |
| 62 | clone.style.width = "max-content"; |
| 63 | clone.style.minWidth = "0"; |
| 64 | clone.style.maxWidth = "none"; |
| 65 | clone.style.tableLayout = "auto"; |
| 66 | clone.querySelectorAll<HTMLElement>("td, th").forEach((cell) => { |
| 67 | cell.style.width = "auto"; |
| 68 | cell.style.minWidth = "0"; |
| 69 | cell.style.maxWidth = "none"; |
| 70 | cell.style.whiteSpace = "nowrap"; |
| 71 | }); |
| 72 | |
| 73 | host.appendChild(clone); |
| 74 | document.body.appendChild(host); |
| 75 | |
| 76 | try { |
| 77 | const firstRow = clone.querySelector("tr"); |
| 78 | if (!(firstRow instanceof HTMLTableRowElement)) return null; |
| 79 | |
| 80 | const widths: number[] = []; |
| 81 | for (const cell of Array.from(firstRow.cells)) { |
| 82 | const colspan = Math.max(1, cell.colSpan || 1); |
| 83 | const width = Math.max(TABLE_CELL_MIN_WIDTH, Math.ceil(cell.getBoundingClientRect().width / colspan)); |
| 84 | for (let index = 0; index < colspan; index++) widths.push(width); |
| 85 | } |
| 86 | |
| 87 | return widths.length > 0 ? widths : null; |
| 88 | } finally { |
| 89 | host.remove(); |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | export function measureRenderedTableWidth(view: EditorView, tablePos: number): number | null { |
| 94 | const tableEl = findTableElement(view, tablePos); |
| 95 | if (!tableEl) return null; |
| 96 | return Math.max(TABLE_CELL_MIN_WIDTH, Math.round(tableEl.getBoundingClientRect().width)); |
| 97 | } |
| 98 | |
| 99 | export function measureWrapperContentWidth(view: EditorView, tablePos: number): number | null { |
| 100 | const tableEl = findTableElement(view, tablePos); |
| 101 | const wrapper = tableEl?.closest(".tableWrapper"); |
| 102 | if (!(wrapper instanceof HTMLElement)) return null; |
| 103 | |
| 104 | const styles = getComputedStyle(wrapper); |
| 105 | const paddingLeft = parseFloat(styles.paddingLeft) || 0; |
| 106 | const paddingRight = parseFloat(styles.paddingRight) || 0; |
| 107 | return Math.max(0, wrapper.clientWidth - paddingLeft - paddingRight - 1); |
| 108 | } |
| 109 | |
| 110 | export function deriveCanonicalColumnWidths(doc: PMNode, table: PMNode, tablePos: number): number[] | null { |
| 111 | const map = TableMap.get(table); |
| 112 | const widths = new Array<number | null>(map.width).fill(null); |
| 113 | let hasWidths = false; |
| 114 | |
| 115 | visitTableCells(doc, table, tablePos, (node, _nodePos, col, colspan) => { |
| 116 | const colwidth = node.attrs.colwidth as number[] | null | undefined; |
| 117 | if (!colwidth) return; |
| 118 | |
| 119 | for (let index = 0; index < colspan; index++) { |
| 120 | const width = colwidth[index]; |
| 121 | if (typeof width !== "number" || width <= 0) continue; |
| 122 | if (widths[col + index] != null) continue; |
| 123 | widths[col + index] = Math.max(TABLE_CELL_MIN_WIDTH, Math.round(width)); |
| 124 | hasWidths = true; |
| 125 | } |
| 126 | }); |
| 127 | |
| 128 | if (!hasWidths) return null; |
| 129 | return widths.map((width, index) => width ?? nearestExplicitWidth(widths, index)); |
| 130 | } |
| 131 | |
| 132 | export function measureCanonicalTableWidth(doc: PMNode, table: PMNode, tablePos: number): number | null { |
| 133 | const widths = deriveCanonicalColumnWidths(doc, table, tablePos); |
| 134 | return widths ? widths.reduce((sum, width) => sum + width, 0) : null; |
| 135 | } |
| 136 | |
| 137 | export function buildEvenWidths(columnCount: number, totalWidth: number): number[] { |
| 138 | const clampedTotal = Math.max(TABLE_CELL_MIN_WIDTH * columnCount, Math.round(totalWidth)); |
| 139 | const base = Math.floor(clampedTotal / columnCount); |
| 140 | const remainder = clampedTotal - base * columnCount; |
| 141 | return Array.from({ length: columnCount }, (_, index) => base + (index < remainder ? 1 : 0)); |
| 142 | } |
| 143 | |
| 144 | export function clearExplicitColumnWidths(state: EditorState, tr: Transaction, tablePos: number): boolean { |
| 145 | const table = state.doc.nodeAt(tablePos); |
| 146 | if (!table || table.type.spec.tableRole !== "table") return false; |
| 147 | |
| 148 | let mutated = false; |
| 149 | visitTableCells(state.doc, table, tablePos, (node, nodePos) => { |
| 150 | if (node.attrs.colwidth == null) return; |
| 151 | tr.setNodeMarkup(nodePos, undefined, { ...node.attrs, colwidth: null }); |
| 152 | mutated = true; |
| 153 | }); |
| 154 | |
| 155 | return mutated; |
| 156 | } |
| 157 | |
| 158 | export function applyExplicitColumnWidths( |
| 159 | state: EditorState, |
| 160 | tr: Transaction, |
| 161 | tablePos: number, |
| 162 | widths: number[], |
| 163 | ): boolean { |
| 164 | const table = state.doc.nodeAt(tablePos); |
| 165 | if (!table || table.type.spec.tableRole !== "table") return false; |
| 166 | |
| 167 | let mutated = false; |
| 168 | visitTableCells(state.doc, table, tablePos, (node, nodePos, col, colspan) => { |
| 169 | const nextColwidth = Array.from({ length: colspan }, (_, index) => widths[col + index] ?? TABLE_CELL_MIN_WIDTH); |
| 170 | const current = node.attrs.colwidth as number[] | null | undefined; |
| 171 | if (equalWidths(current, nextColwidth)) return; |
| 172 | tr.setNodeMarkup(nodePos, undefined, { ...node.attrs, colwidth: nextColwidth }); |
| 173 | mutated = true; |
| 174 | }); |
| 175 | |
| 176 | return mutated; |
| 177 | } |
| 178 | |
| 179 | function visitTableCells( |
| 180 | doc: PMNode, |
| 181 | table: PMNode, |
| 182 | tablePos: number, |
| 183 | visit: (node: PMNode, nodePos: number, col: number, colspan: number) => void, |
| 184 | ) { |
| 185 | const map = TableMap.get(table); |
| 186 | const start = tablePos + 1; |
| 187 | const end = tablePos + table.nodeSize - 1; |
| 188 | |
| 189 | doc.nodesBetween(start, end, (node, nodePos) => { |
| 190 | const role = node.type.spec.tableRole; |
| 191 | if (role !== "cell" && role !== "header_cell") return true; |
| 192 | |
| 193 | const colspan = (node.attrs.colspan as number) ?? 1; |
| 194 | const cellStartInTable = nodePos - start; |
| 195 | const idx = map.map.indexOf(cellStartInTable); |
| 196 | if (idx >= 0) { |
| 197 | visit(node, nodePos, idx % map.width, colspan); |
| 198 | } |
| 199 | return false; |
| 200 | }); |
| 201 | } |
| 202 | |
| 203 | function equalWidths(a: number[] | null | undefined, b: number[]): boolean { |
| 204 | if (!a || a.length !== b.length) return false; |
| 205 | for (let index = 0; index < a.length; index++) { |
| 206 | if (a[index] !== b[index]) return false; |
| 207 | } |
| 208 | return true; |
| 209 | } |
| 210 | |
| 211 | function nearestExplicitWidth(widths: Array<number | null>, index: number): number { |
| 212 | let leftIndex = index - 1; |
| 213 | while (leftIndex >= 0 && widths[leftIndex] == null) leftIndex -= 1; |
| 214 | |
| 215 | let rightIndex = index + 1; |
| 216 | while (rightIndex < widths.length && widths[rightIndex] == null) rightIndex += 1; |
| 217 | |
| 218 | const leftWidth = leftIndex >= 0 ? widths[leftIndex] : null; |
| 219 | const rightWidth = rightIndex < widths.length ? widths[rightIndex] : null; |
| 220 | if (leftWidth != null && rightWidth != null) { |
| 221 | return index - leftIndex <= rightIndex - index ? leftWidth : rightWidth; |
| 222 | } |
| 223 | if (leftWidth != null) return leftWidth; |
| 224 | if (rightWidth != null) return rightWidth; |
| 225 | return TABLE_CELL_MIN_WIDTH; |
| 226 | } |