Skip to content

Commit 9d6f586

Browse files
committed
Add resizable columns to rich text tables
1 parent dc3dccb commit 9d6f586

1 file changed

Lines changed: 197 additions & 0 deletions

File tree

components/RichTextEditor.tsx

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import {
6363
$createRangeSelection,
6464
$createTextNode,
6565
$getNodeByKey,
66+
$nodesOfType,
6667
$setSelection,
6768
} from 'lexical';
6869
import {
@@ -198,6 +199,8 @@ const RICH_TEXT_THEME = {
198199

199200
const Placeholder: React.FC = () => null;
200201

202+
const MIN_COLUMN_WIDTH = 72;
203+
201204
const normalizeUrl = (url: string): string => {
202205
const trimmed = url.trim();
203206
if (!trimmed) {
@@ -271,6 +274,199 @@ const LinkModal: React.FC<{
271274
);
272275
};
273276

277+
const ensureColGroupWithWidths = (tableElement: HTMLTableElement): HTMLTableColElement[] => {
278+
const firstRow = tableElement.rows[0];
279+
const columnCount = firstRow?.cells.length ?? 0;
280+
if (columnCount === 0) {
281+
return [];
282+
}
283+
284+
let colGroup = tableElement.querySelector('colgroup');
285+
if (!colGroup) {
286+
colGroup = document.createElement('colgroup');
287+
tableElement.insertBefore(colGroup, tableElement.firstChild);
288+
}
289+
290+
while (colGroup.children.length < columnCount) {
291+
const col = document.createElement('col');
292+
colGroup.appendChild(col);
293+
}
294+
295+
while (colGroup.children.length > columnCount) {
296+
colGroup.lastElementChild?.remove();
297+
}
298+
299+
const colElements = Array.from(colGroup.children) as HTMLTableColElement[];
300+
const existingWidths = colElements.map(col => parseFloat(col.style.width || ''));
301+
const needInitialization = existingWidths.some(width => Number.isNaN(width) || width <= 0);
302+
303+
if (needInitialization) {
304+
const columnWidths = Array.from(firstRow.cells).map(cell => cell.getBoundingClientRect().width || MIN_COLUMN_WIDTH);
305+
colElements.forEach((col, index) => {
306+
const width = Math.max(MIN_COLUMN_WIDTH, columnWidths[index] ?? MIN_COLUMN_WIDTH);
307+
col.style.width = `${width}px`;
308+
});
309+
}
310+
311+
return colElements;
312+
};
313+
314+
const attachColumnResizeHandles = (tableElement: HTMLTableElement): (() => void) => {
315+
const container = tableElement.parentElement ?? tableElement;
316+
const originalContainerPosition = container.style.position;
317+
const restoreContainerPosition = originalContainerPosition === '' && getComputedStyle(container).position === 'static';
318+
319+
if (restoreContainerPosition) {
320+
container.style.position = 'relative';
321+
}
322+
323+
tableElement.style.tableLayout = 'fixed';
324+
325+
const overlay = document.createElement('div');
326+
overlay.style.position = 'absolute';
327+
overlay.style.inset = '0';
328+
overlay.style.pointerEvents = 'none';
329+
overlay.style.zIndex = '10';
330+
container.appendChild(overlay);
331+
332+
const cleanupHandles: Array<() => void> = [];
333+
const resizeObserver = new ResizeObserver(() => renderHandles());
334+
335+
function renderHandles() {
336+
overlay.replaceChildren();
337+
338+
const firstRow = tableElement.rows[0];
339+
if (!firstRow) {
340+
return;
341+
}
342+
343+
const cols = ensureColGroupWithWidths(tableElement);
344+
const containerRect = container.getBoundingClientRect();
345+
const cells = Array.from(firstRow.cells);
346+
347+
cells.forEach((cell, columnIndex) => {
348+
if (columnIndex === cells.length - 1) {
349+
return;
350+
}
351+
352+
const cellRect = cell.getBoundingClientRect();
353+
const handle = document.createElement('div');
354+
handle.setAttribute('role', 'presentation');
355+
handle.contentEditable = 'false';
356+
handle.style.position = 'absolute';
357+
handle.style.top = `${tableElement.offsetTop}px`;
358+
handle.style.left = `${cellRect.right - containerRect.left - 3}px`;
359+
handle.style.width = '6px';
360+
handle.style.height = `${tableElement.offsetHeight}px`;
361+
handle.style.cursor = 'col-resize';
362+
handle.style.pointerEvents = 'auto';
363+
handle.style.userSelect = 'none';
364+
365+
let startX = 0;
366+
let leftWidth = 0;
367+
let rightWidth = 0;
368+
369+
const handleMouseMove = (event: MouseEvent) => {
370+
const deltaX = event.clientX - startX;
371+
const nextLeftWidth = Math.max(MIN_COLUMN_WIDTH, leftWidth + deltaX);
372+
const nextRightWidth = Math.max(MIN_COLUMN_WIDTH, rightWidth - deltaX);
373+
374+
cols[columnIndex].style.width = `${nextLeftWidth}px`;
375+
cols[columnIndex + 1].style.width = `${nextRightWidth}px`;
376+
};
377+
378+
const handleMouseUp = () => {
379+
document.removeEventListener('mousemove', handleMouseMove);
380+
document.removeEventListener('mouseup', handleMouseUp);
381+
};
382+
383+
const handleMouseDown = (event: MouseEvent) => {
384+
event.preventDefault();
385+
startX = event.clientX;
386+
leftWidth = parseFloat(cols[columnIndex].style.width || `${cell.offsetWidth}`);
387+
rightWidth = parseFloat(
388+
cols[columnIndex + 1].style.width || `${cells[columnIndex + 1]?.offsetWidth ?? MIN_COLUMN_WIDTH}`,
389+
);
390+
391+
document.addEventListener('mousemove', handleMouseMove);
392+
document.addEventListener('mouseup', handleMouseUp);
393+
};
394+
395+
handle.addEventListener('mousedown', handleMouseDown);
396+
cleanupHandles.push(() => handle.removeEventListener('mousedown', handleMouseDown));
397+
overlay.appendChild(handle);
398+
});
399+
}
400+
401+
resizeObserver.observe(tableElement);
402+
renderHandles();
403+
404+
return () => {
405+
cleanupHandles.forEach(cleanup => cleanup());
406+
resizeObserver.disconnect();
407+
overlay.remove();
408+
409+
if (restoreContainerPosition) {
410+
container.style.position = originalContainerPosition;
411+
}
412+
};
413+
};
414+
415+
const TableColumnResizePlugin: React.FC = () => {
416+
const [editor] = useLexicalComposerContext();
417+
418+
useEffect(() => {
419+
const cleanupMap = new Map<string, () => void>();
420+
421+
const cleanupTable = (key: string) => {
422+
const cleanup = cleanupMap.get(key);
423+
if (cleanup) {
424+
cleanup();
425+
cleanupMap.delete(key);
426+
}
427+
};
428+
429+
const initializeTable = (tableNode: TableNode) => {
430+
const tableKey = tableNode.getKey();
431+
const tableElement = editor.getElementByKey(tableKey);
432+
if (tableElement instanceof HTMLTableElement) {
433+
cleanupTable(tableKey);
434+
cleanupMap.set(tableKey, attachColumnResizeHandles(tableElement));
435+
}
436+
};
437+
438+
editor.getEditorState().read(() => {
439+
const tableNodes = $nodesOfType(TableNode);
440+
tableNodes.forEach(tableNode => {
441+
initializeTable(tableNode);
442+
});
443+
});
444+
445+
const unregisterMutationListener = editor.registerMutationListener(TableNode, mutations => {
446+
editor.getEditorState().read(() => {
447+
mutations.forEach((mutation, key) => {
448+
if (mutation === 'created') {
449+
const tableNode = $getNodeByKey<TableNode>(key);
450+
if (tableNode) {
451+
initializeTable(tableNode);
452+
}
453+
} else if (mutation === 'destroyed') {
454+
cleanupTable(key);
455+
}
456+
});
457+
});
458+
});
459+
460+
return () => {
461+
unregisterMutationListener();
462+
cleanupMap.forEach(cleanup => cleanup());
463+
cleanupMap.clear();
464+
};
465+
}, [editor]);
466+
467+
return null;
468+
};
469+
274470
const TableModal: React.FC<{
275471
isOpen: boolean;
276472
onClose: () => void;
@@ -1912,6 +2108,7 @@ const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
19122108
<HistoryPlugin />
19132109
{!readOnly && <AutoFocusPlugin />}
19142110
<TablePlugin hasCellMerge={true} hasCellBackgroundColor={true} hasTabHandler={true} />
2111+
{!readOnly && <TableColumnResizePlugin />}
19152112
<ListPlugin />
19162113
<LinkPlugin />
19172114
<ImagePlugin />

0 commit comments

Comments
 (0)