Skip to content
File

Blob: src/client/components/editor/extensions/table/widths.ts

typescript227 lines
1import { TableMap } from "@tiptap/pm/tables";
2import type { EditorView } from "@tiptap/pm/view";
3import type { EditorState, Transaction } from "@tiptap/pm/state";
4import type { Node as PMNode } from "@tiptap/pm/model";
5import { TABLE_CELL_MIN_WIDTH } from "./constants";
6import { findTableElement } from "./dom";
7 
8export interface TableWidthSnapshot {
9 hasSome: boolean;
10 hasAll: boolean;
11}
12 
13export 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 
21export 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 
45export 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 
93export 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 
99export 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 
110export 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 
132export 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 
137export 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 
144export 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 
158export 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 
179function 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 
203function 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 
211function 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}