Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
257 changes: 257 additions & 0 deletions components/RichTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
$createRangeSelection,
$createTextNode,
$getNodeByKey,
$nodesOfType,
$setSelection,
} from 'lexical';
import {
Expand Down Expand Up @@ -198,6 +199,8 @@ const RICH_TEXT_THEME = {

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

const MIN_COLUMN_WIDTH = 72;

const normalizeUrl = (url: string): string => {
const trimmed = url.trim();
if (!trimmed) {
Expand Down Expand Up @@ -271,6 +274,259 @@ const LinkModal: React.FC<{
);
};

const ensureColGroupWithWidths = (
tableElement: HTMLTableElement,
preferredWidths: number[] = [],
): HTMLTableColElement[] => {
const firstRow = tableElement.rows[0];
const columnCount = firstRow?.cells.length ?? 0;
if (columnCount === 0) {
return [];
}

let colGroup = tableElement.querySelector('colgroup');
if (!colGroup) {
colGroup = document.createElement('colgroup');
tableElement.insertBefore(colGroup, tableElement.firstChild);
}

while (colGroup.children.length < columnCount) {
const col = document.createElement('col');
colGroup.appendChild(col);
}

while (colGroup.children.length > columnCount) {
colGroup.lastElementChild?.remove();
}

const colElements = Array.from(colGroup.children) as HTMLTableColElement[];

if (preferredWidths.length === columnCount && preferredWidths.some(width => width > 0)) {
colElements.forEach((col, index) => {
const width = preferredWidths[index];
if (Number.isFinite(width) && width > 0) {
col.style.width = `${Math.max(MIN_COLUMN_WIDTH, width)}px`;
}
});
} else {
const existingWidths = colElements.map(col => parseFloat(col.style.width || ''));
const needInitialization = existingWidths.some(width => Number.isNaN(width) || width <= 0);

if (needInitialization) {
const columnWidths = Array.from(firstRow.cells).map(cell => cell.getBoundingClientRect().width || MIN_COLUMN_WIDTH);
colElements.forEach((col, index) => {
const width = Math.max(MIN_COLUMN_WIDTH, columnWidths[index] ?? MIN_COLUMN_WIDTH);
col.style.width = `${width}px`;
});
}
}

return colElements;
};

const getColumnWidthsFromState = (editor: LexicalEditor, tableKey: string): number[] => {
let widths: number[] = [];

editor.getEditorState().read(() => {
const tableNode = $getNodeByKey<TableNode>(tableKey);
if (!tableNode) {
return;
}

const firstRow = tableNode.getChildren<TableRowNode>()[0];
if (!firstRow) {
return;
}

widths = firstRow
.getChildren<TableCellNode>()
.map(cell => cell.getWidth())
.filter((width): width is number => Number.isFinite(width));
});

return widths;
};

const attachColumnResizeHandles = (
tableElement: HTMLTableElement,
editor: LexicalEditor,
tableKey: string,
): (() => void) => {
const container = tableElement.parentElement ?? tableElement;
const originalContainerPosition = container.style.position;
const restoreContainerPosition = originalContainerPosition === '' && getComputedStyle(container).position === 'static';

if (restoreContainerPosition) {
container.style.position = 'relative';
}

tableElement.style.tableLayout = 'fixed';

const overlay = document.createElement('div');
overlay.style.position = 'absolute';
overlay.style.inset = '0';
overlay.style.pointerEvents = 'none';
overlay.style.zIndex = '10';
container.appendChild(overlay);

const cleanupHandles: Array<() => void> = [];
const resizeObserver = new ResizeObserver(() => renderHandles());

function renderHandles() {
overlay.replaceChildren();

const firstRow = tableElement.rows[0];
if (!firstRow) {
return;
}

const storedColumnWidths = getColumnWidthsFromState(editor, tableKey);
const cols = ensureColGroupWithWidths(tableElement, storedColumnWidths);
const containerRect = container.getBoundingClientRect();
const cells = Array.from(firstRow.cells);

cells.forEach((cell, columnIndex) => {
if (columnIndex === cells.length - 1) {
return;
}

const cellRect = cell.getBoundingClientRect();
const handle = document.createElement('div');
handle.setAttribute('role', 'presentation');
handle.contentEditable = 'false';
handle.style.position = 'absolute';
handle.style.top = `${tableElement.offsetTop}px`;
handle.style.left = `${cellRect.right - containerRect.left - 3}px`;
handle.style.width = '6px';
handle.style.height = `${tableElement.offsetHeight}px`;
handle.style.cursor = 'col-resize';
handle.style.pointerEvents = 'auto';
handle.style.userSelect = 'none';

let startX = 0;
let leftWidth = 0;
let rightWidth = 0;

const handleMouseMove = (event: MouseEvent) => {
const deltaX = event.clientX - startX;
const nextLeftWidth = Math.max(MIN_COLUMN_WIDTH, leftWidth + deltaX);
const nextRightWidth = Math.max(MIN_COLUMN_WIDTH, rightWidth - deltaX);

cols[columnIndex].style.width = `${nextLeftWidth}px`;
cols[columnIndex + 1].style.width = `${nextRightWidth}px`;
Comment on lines +410 to +416

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve resized column widths in saved HTML

Column resizing only updates the DOM widths on the existing <col> elements (cols[columnIndex].style.width = …, cols[columnIndex + 1].style.width = …) without writing any corresponding data into the Lexical editor state. Because serialization still relies on $generateHtmlFromNodes, any resized widths are dropped as soon as the document is saved or reloaded, so users lose their column adjustments after persisting content.

Useful? React with 👍 / 👎.

};

const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);

const updatedWidths = cols.map(col => parseFloat(col.style.width || ''));
editor.update(() => {
const tableNode = $getNodeByKey<TableNode>(tableKey);
if (!tableNode) {
return;
}

const rows = tableNode.getChildren<TableRowNode>();
rows.forEach(row => {
const cellsInRow = row.getChildren<TableCellNode>();
cellsInRow.forEach((cellNode, cellIndex) => {
const width = updatedWidths[cellIndex];
if (Number.isFinite(width) && width > 0) {
cellNode.setWidth(Math.max(MIN_COLUMN_WIDTH, width));
}
});
});
});
};

const handleMouseDown = (event: MouseEvent) => {
event.preventDefault();
startX = event.clientX;
leftWidth = parseFloat(cols[columnIndex].style.width || `${cell.offsetWidth}`);
rightWidth = parseFloat(
cols[columnIndex + 1].style.width || `${cells[columnIndex + 1]?.offsetWidth ?? MIN_COLUMN_WIDTH}`,
);

document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};

handle.addEventListener('mousedown', handleMouseDown);
cleanupHandles.push(() => handle.removeEventListener('mousedown', handleMouseDown));
overlay.appendChild(handle);
});
}

resizeObserver.observe(tableElement);
renderHandles();

return () => {
cleanupHandles.forEach(cleanup => cleanup());
resizeObserver.disconnect();
overlay.remove();

if (restoreContainerPosition) {
container.style.position = originalContainerPosition;
}
};
};

const TableColumnResizePlugin: React.FC = () => {
const [editor] = useLexicalComposerContext();

useEffect(() => {
const cleanupMap = new Map<string, () => void>();

const cleanupTable = (key: string) => {
const cleanup = cleanupMap.get(key);
if (cleanup) {
cleanup();
cleanupMap.delete(key);
}
};

const initializeTable = (tableNode: TableNode) => {
const tableKey = tableNode.getKey();
const tableElement = editor.getElementByKey(tableKey);
if (tableElement instanceof HTMLTableElement) {
cleanupTable(tableKey);
cleanupMap.set(tableKey, attachColumnResizeHandles(tableElement, editor, tableKey));
}
};

editor.getEditorState().read(() => {
const tableNodes = $nodesOfType(TableNode);
tableNodes.forEach(tableNode => {
initializeTable(tableNode);
});
});

const unregisterMutationListener = editor.registerMutationListener(TableNode, mutations => {
editor.getEditorState().read(() => {
mutations.forEach((mutation, key) => {
if (mutation === 'created') {
const tableNode = $getNodeByKey<TableNode>(key);
if (tableNode) {
initializeTable(tableNode);
}
} else if (mutation === 'destroyed') {
cleanupTable(key);
}
});
});
});

return () => {
unregisterMutationListener();
cleanupMap.forEach(cleanup => cleanup());
cleanupMap.clear();
};
}, [editor]);

return null;
};

const TableModal: React.FC<{
isOpen: boolean;
onClose: () => void;
Expand Down Expand Up @@ -1912,6 +2168,7 @@ const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
<HistoryPlugin />
{!readOnly && <AutoFocusPlugin />}
<TablePlugin hasCellMerge={true} hasCellBackgroundColor={true} hasTabHandler={true} />
{!readOnly && <TableColumnResizePlugin />}
<ListPlugin />
<LinkPlugin />
<ImagePlugin />
Expand Down