@@ -63,6 +63,7 @@ import {
6363 $createRangeSelection ,
6464 $createTextNode ,
6565 $getNodeByKey ,
66+ $nodesOfType ,
6667 $setSelection ,
6768} from 'lexical' ;
6869import {
@@ -198,6 +199,8 @@ const RICH_TEXT_THEME = {
198199
199200const Placeholder : React . FC = ( ) => null ;
200201
202+ const MIN_COLUMN_WIDTH = 72 ;
203+
201204const 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+
274530const 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