Skip to content

Commit 6e640e8

Browse files
authored
Merge pull request #273 from beNative/tisi/enable-column-resizing-in-rich-editor
Add resizable columns to rich text tables
2 parents dc3dccb + 250ad49 commit 6e640e8

File tree

1 file changed

+257
-0
lines changed

1 file changed

+257
-0
lines changed

components/RichTextEditor.tsx

Lines changed: 257 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,259 @@ const LinkModal: React.FC<{
271274
);
272275
};
273276

277+
const ensureColGroupWithWidths = (
278+
tableElement: HTMLTableElement,
279+
preferredWidths: number[] = [],
280+
): HTMLTableColElement[] => {
281+
const firstRow = tableElement.rows[0];
282+
const columnCount = firstRow?.cells.length ?? 0;
283+
if (columnCount === 0) {
284+
return [];
285+
}
286+
287+
let colGroup = tableElement.querySelector('colgroup');
288+
if (!colGroup) {
289+
colGroup = document.createElement('colgroup');
290+
tableElement.insertBefore(colGroup, tableElement.firstChild);
291+
}
292+
293+
while (colGroup.children.length < columnCount) {
294+
const col = document.createElement('col');
295+
colGroup.appendChild(col);
296+
}
297+
298+
while (colGroup.children.length > columnCount) {
299+
colGroup.lastElementChild?.remove();
300+
}
301+
302+
const colElements = Array.from(colGroup.children) as HTMLTableColElement[];
303+
304+
if (preferredWidths.length === columnCount && preferredWidths.some(width => width > 0)) {
305+
colElements.forEach((col, index) => {
306+
const width = preferredWidths[index];
307+
if (Number.isFinite(width) && width > 0) {
308+
col.style.width = `${Math.max(MIN_COLUMN_WIDTH, width)}px`;
309+
}
310+
});
311+
} else {
312+
const existingWidths = colElements.map(col => parseFloat(col.style.width || ''));
313+
const needInitialization = existingWidths.some(width => Number.isNaN(width) || width <= 0);
314+
315+
if (needInitialization) {
316+
const columnWidths = Array.from(firstRow.cells).map(cell => cell.getBoundingClientRect().width || MIN_COLUMN_WIDTH);
317+
colElements.forEach((col, index) => {
318+
const width = Math.max(MIN_COLUMN_WIDTH, columnWidths[index] ?? MIN_COLUMN_WIDTH);
319+
col.style.width = `${width}px`;
320+
});
321+
}
322+
}
323+
324+
return colElements;
325+
};
326+
327+
const getColumnWidthsFromState = (editor: LexicalEditor, tableKey: string): number[] => {
328+
let widths: number[] = [];
329+
330+
editor.getEditorState().read(() => {
331+
const tableNode = $getNodeByKey<TableNode>(tableKey);
332+
if (!tableNode) {
333+
return;
334+
}
335+
336+
const firstRow = tableNode.getChildren<TableRowNode>()[0];
337+
if (!firstRow) {
338+
return;
339+
}
340+
341+
widths = firstRow
342+
.getChildren<TableCellNode>()
343+
.map(cell => cell.getWidth())
344+
.filter((width): width is number => Number.isFinite(width));
345+
});
346+
347+
return widths;
348+
};
349+
350+
const attachColumnResizeHandles = (
351+
tableElement: HTMLTableElement,
352+
editor: LexicalEditor,
353+
tableKey: string,
354+
): (() => void) => {
355+
const container = tableElement.parentElement ?? tableElement;
356+
const originalContainerPosition = container.style.position;
357+
const restoreContainerPosition = originalContainerPosition === '' && getComputedStyle(container).position === 'static';
358+
359+
if (restoreContainerPosition) {
360+
container.style.position = 'relative';
361+
}
362+
363+
tableElement.style.tableLayout = 'fixed';
364+
365+
const overlay = document.createElement('div');
366+
overlay.style.position = 'absolute';
367+
overlay.style.inset = '0';
368+
overlay.style.pointerEvents = 'none';
369+
overlay.style.zIndex = '10';
370+
container.appendChild(overlay);
371+
372+
const cleanupHandles: Array<() => void> = [];
373+
const resizeObserver = new ResizeObserver(() => renderHandles());
374+
375+
function renderHandles() {
376+
overlay.replaceChildren();
377+
378+
const firstRow = tableElement.rows[0];
379+
if (!firstRow) {
380+
return;
381+
}
382+
383+
const storedColumnWidths = getColumnWidthsFromState(editor, tableKey);
384+
const cols = ensureColGroupWithWidths(tableElement, storedColumnWidths);
385+
const containerRect = container.getBoundingClientRect();
386+
const cells = Array.from(firstRow.cells);
387+
388+
cells.forEach((cell, columnIndex) => {
389+
if (columnIndex === cells.length - 1) {
390+
return;
391+
}
392+
393+
const cellRect = cell.getBoundingClientRect();
394+
const handle = document.createElement('div');
395+
handle.setAttribute('role', 'presentation');
396+
handle.contentEditable = 'false';
397+
handle.style.position = 'absolute';
398+
handle.style.top = `${tableElement.offsetTop}px`;
399+
handle.style.left = `${cellRect.right - containerRect.left - 3}px`;
400+
handle.style.width = '6px';
401+
handle.style.height = `${tableElement.offsetHeight}px`;
402+
handle.style.cursor = 'col-resize';
403+
handle.style.pointerEvents = 'auto';
404+
handle.style.userSelect = 'none';
405+
406+
let startX = 0;
407+
let leftWidth = 0;
408+
let rightWidth = 0;
409+
410+
const handleMouseMove = (event: MouseEvent) => {
411+
const deltaX = event.clientX - startX;
412+
const nextLeftWidth = Math.max(MIN_COLUMN_WIDTH, leftWidth + deltaX);
413+
const nextRightWidth = Math.max(MIN_COLUMN_WIDTH, rightWidth - deltaX);
414+
415+
cols[columnIndex].style.width = `${nextLeftWidth}px`;
416+
cols[columnIndex + 1].style.width = `${nextRightWidth}px`;
417+
};
418+
419+
const handleMouseUp = () => {
420+
document.removeEventListener('mousemove', handleMouseMove);
421+
document.removeEventListener('mouseup', handleMouseUp);
422+
423+
const updatedWidths = cols.map(col => parseFloat(col.style.width || ''));
424+
editor.update(() => {
425+
const tableNode = $getNodeByKey<TableNode>(tableKey);
426+
if (!tableNode) {
427+
return;
428+
}
429+
430+
const rows = tableNode.getChildren<TableRowNode>();
431+
rows.forEach(row => {
432+
const cellsInRow = row.getChildren<TableCellNode>();
433+
cellsInRow.forEach((cellNode, cellIndex) => {
434+
const width = updatedWidths[cellIndex];
435+
if (Number.isFinite(width) && width > 0) {
436+
cellNode.setWidth(Math.max(MIN_COLUMN_WIDTH, width));
437+
}
438+
});
439+
});
440+
});
441+
};
442+
443+
const handleMouseDown = (event: MouseEvent) => {
444+
event.preventDefault();
445+
startX = event.clientX;
446+
leftWidth = parseFloat(cols[columnIndex].style.width || `${cell.offsetWidth}`);
447+
rightWidth = parseFloat(
448+
cols[columnIndex + 1].style.width || `${cells[columnIndex + 1]?.offsetWidth ?? MIN_COLUMN_WIDTH}`,
449+
);
450+
451+
document.addEventListener('mousemove', handleMouseMove);
452+
document.addEventListener('mouseup', handleMouseUp);
453+
};
454+
455+
handle.addEventListener('mousedown', handleMouseDown);
456+
cleanupHandles.push(() => handle.removeEventListener('mousedown', handleMouseDown));
457+
overlay.appendChild(handle);
458+
});
459+
}
460+
461+
resizeObserver.observe(tableElement);
462+
renderHandles();
463+
464+
return () => {
465+
cleanupHandles.forEach(cleanup => cleanup());
466+
resizeObserver.disconnect();
467+
overlay.remove();
468+
469+
if (restoreContainerPosition) {
470+
container.style.position = originalContainerPosition;
471+
}
472+
};
473+
};
474+
475+
const TableColumnResizePlugin: React.FC = () => {
476+
const [editor] = useLexicalComposerContext();
477+
478+
useEffect(() => {
479+
const cleanupMap = new Map<string, () => void>();
480+
481+
const cleanupTable = (key: string) => {
482+
const cleanup = cleanupMap.get(key);
483+
if (cleanup) {
484+
cleanup();
485+
cleanupMap.delete(key);
486+
}
487+
};
488+
489+
const initializeTable = (tableNode: TableNode) => {
490+
const tableKey = tableNode.getKey();
491+
const tableElement = editor.getElementByKey(tableKey);
492+
if (tableElement instanceof HTMLTableElement) {
493+
cleanupTable(tableKey);
494+
cleanupMap.set(tableKey, attachColumnResizeHandles(tableElement, editor, tableKey));
495+
}
496+
};
497+
498+
editor.getEditorState().read(() => {
499+
const tableNodes = $nodesOfType(TableNode);
500+
tableNodes.forEach(tableNode => {
501+
initializeTable(tableNode);
502+
});
503+
});
504+
505+
const unregisterMutationListener = editor.registerMutationListener(TableNode, mutations => {
506+
editor.getEditorState().read(() => {
507+
mutations.forEach((mutation, key) => {
508+
if (mutation === 'created') {
509+
const tableNode = $getNodeByKey<TableNode>(key);
510+
if (tableNode) {
511+
initializeTable(tableNode);
512+
}
513+
} else if (mutation === 'destroyed') {
514+
cleanupTable(key);
515+
}
516+
});
517+
});
518+
});
519+
520+
return () => {
521+
unregisterMutationListener();
522+
cleanupMap.forEach(cleanup => cleanup());
523+
cleanupMap.clear();
524+
};
525+
}, [editor]);
526+
527+
return null;
528+
};
529+
274530
const TableModal: React.FC<{
275531
isOpen: boolean;
276532
onClose: () => void;
@@ -1912,6 +2168,7 @@ const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
19122168
<HistoryPlugin />
19132169
{!readOnly && <AutoFocusPlugin />}
19142170
<TablePlugin hasCellMerge={true} hasCellBackgroundColor={true} hasTabHandler={true} />
2171+
{!readOnly && <TableColumnResizePlugin />}
19152172
<ListPlugin />
19162173
<LinkPlugin />
19172174
<ImagePlugin />

0 commit comments

Comments
 (0)