@@ -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,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+
274470const 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