import { Component, Input, Output, EventEmitter, inject, ViewChild, signal, effect, ElementRef, HostListener, AfterViewInit, OnDestroy } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Block, ColumnsProps, ColumnItem } from '../../../core/models/block.model'; import { DragDropService } from '../../../services/drag-drop.service'; import { DocumentService } from '../../../services/document.service'; import { SelectionService } from '../../../services/selection.service'; import { CommentStoreService } from '../../../services/comment-store.service'; import { Overlay, OverlayRef } from '@angular/cdk/overlay'; import { ComponentPortal } from '@angular/cdk/portal'; // Import ALL block components for full support import { ParagraphBlockComponent } from './paragraph-block.component'; import { HeadingBlockComponent } from './heading-block.component'; import { ListItemBlockComponent } from './list-item-block.component'; import { CodeBlockComponent } from './code-block.component'; import { QuoteBlockComponent } from './quote-block.component'; import { ToggleBlockComponent } from './toggle-block.component'; import { CollapsibleBlockComponent } from './collapsible-block.component'; import { HintBlockComponent } from './hint-block.component'; import { ButtonBlockComponent } from './button-block.component'; import { ImageBlockComponent } from './image-block.component'; import { FileBlockComponent } from './file-block.component'; import { TableBlockComponent } from './table-block.component'; import { StepsBlockComponent } from './steps-block.component'; import { LineBlockComponent } from './line-block.component'; import { DropdownBlockComponent } from './dropdown-block.component'; import { ProgressBlockComponent } from './progress-block.component'; import { KanbanBlockComponent } from './kanban-block.component'; import { EmbedBlockComponent } from './embed-block.component'; import { OutlineBlockComponent } from './outline-block.component'; import { ListBlockComponent } from './list-block.component'; import { BlockCommentComposerComponent } from '../../comment/block-comment-composer.component'; import { BlockContextMenuComponent } from '../block-context-menu.component'; import { DragDropFilesDirective } from '../../../../blocks/file/directives/drag-drop-files.directive'; import { PaletteItem } from '../../../core/constants/palette-items'; @Component({ selector: 'app-columns-block', standalone: true, imports: [ CommonModule, ParagraphBlockComponent, HeadingBlockComponent, ListItemBlockComponent, CodeBlockComponent, QuoteBlockComponent, ToggleBlockComponent, CollapsibleBlockComponent, HintBlockComponent, ButtonBlockComponent, ImageBlockComponent, FileBlockComponent, TableBlockComponent, StepsBlockComponent, LineBlockComponent, DropdownBlockComponent, ProgressBlockComponent, KanbanBlockComponent, EmbedBlockComponent, OutlineBlockComponent, ListBlockComponent, BlockContextMenuComponent, DragDropFilesDirective ], template: `
@for (column of props.columns; track column.id; let colIndex = $index) {
@for (block of column.blocks; track block.id; let blockIndex = $index) {
@switch (block.type) { @case ('heading') { } @case ('paragraph') { } @case ('list-item') { } @case ('code') { } @case ('quote') { } @case ('toggle') { } @case ('collapsible') { } @case ('hint') { } @case ('button') { } @case ('image') { } @case ('file') { } @case ('table') { } @case ('steps') { } @case ('line') { } @case ('dropdown') { } @case ('progress') { } @case ('kanban') { } @case ('embed') { } @case ('outline') { } @case ('list') { } @case ('columns') {
⚠️ Nested columns are not supported. Convert this block to full width.
} @default {
Type: {{ block.type }} (not yet supported in columns)
} }
} @empty {
Drop blocks here
}
} @if (props.columns.length > 1) { @for (i of resizerIndexes; track i) {
} }
`, styles: [` :host { display: block; width: 100%; } /* Placeholder for empty contenteditable */ [contenteditable][data-placeholder]:empty:before { content: attr(data-placeholder); color: rgb(107, 114, 128); opacity: 0.6; pointer-events: none; } /* Focus outline */ [contenteditable]:focus { outline: none; } .col-resizer { /* visually subtle hit zone */ transform: translateX(-4px); } .col-resizer:hover { background: rgba(56, 189, 248, 0.15); } `] }) export class ColumnsBlockComponent implements AfterViewInit, OnDestroy { private readonly dragDrop = inject(DragDropService); private readonly commentsStore = inject(CommentStoreService); private readonly documentService = inject(DocumentService); private readonly selectionService = inject(SelectionService); private readonly overlay = inject(Overlay); @Input({ required: true }) block!: Block; @Output() update = new EventEmitter(); @ViewChild('columnsContainer', { static: true }) columnsContainerRef!: ElementRef; // Menu state selectedBlock = signal(null); menuVisible = signal(false); menuPosition = signal({ x: 0, y: 0 }); // Drag state private draggedBlock: { block: Block; columnIndex: number; blockIndex: number } | null = null; private dropIndicator = signal<{ columnIndex: number; blockIndex: number } | null>(null); // Resize state private readonly MIN_COL_WIDTH = 10; // percent private resizeState: { active: boolean; index: number; startX: number; containerWidth: number; leftStart: number; rightStart: number } | null = null; resizerPositions = signal([]); // Comments popover state private commentRef?: OverlayRef; private commentSub: { unsubscribe(): void } | null = null; get props(): ColumnsProps { return this.block.props; } getBlockCommentCount(blockId: string): number { try { return this.commentsStore.count(blockId); } catch { return 0; } } onConvertRequested(item: PaletteItem, blockId: string): void { // Map palette selection to block type and optional preset let newType = item.type as any; let preset: any = undefined; // Headings levels if (item.id === 'heading-1') preset = { level: 1 }; else if (item.id === 'heading-2') preset = { level: 2 }; else if (item.id === 'heading-3') preset = { level: 3 }; // Lists -> list-item presets if (item.id === 'checkbox-list') { newType = 'list-item'; preset = { kind: 'check', checked: false }; } else if (item.id === 'numbered-list') { newType = 'list-item'; preset = { kind: 'numbered', number: 1 }; } else if (item.id === 'bullet-list') { newType = 'list-item'; preset = { kind: 'bullet' }; } // Collapsible variants if (item.id === 'collapsible-large') { newType = 'collapsible'; preset = { level: 1 }; } else if (item.id === 'collapsible-medium') { newType = 'collapsible'; preset = { level: 2 }; } else if (item.id === 'collapsible-small') { newType = 'collapsible'; preset = { level: 3 }; } // Apply conversion within columns this.convertBlockInColumns(blockId, newType, preset); } openComments(blockId: string): void { this.closeComments(); const container = this.columnsContainerRef?.nativeElement; const anchor = (container?.querySelector(`[data-block-id="${blockId}"]`) as HTMLElement) || container; if (!anchor) return; const pos = this.overlay.position().flexibleConnectedTo(anchor).withPositions([ { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 8 }, { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -8 } ]); this.commentRef = this.overlay.create({ hasBackdrop: true, backdropClass: 'cdk-overlay-transparent-backdrop', positionStrategy: pos, panelClass: 'nimbus-menu-panel' }); const portal = new ComponentPortal(BlockCommentComposerComponent); const ref = this.commentRef.attach(portal); ref.instance.blockId = blockId; this.commentSub = ref.instance.close.subscribe(() => this.closeComments()); this.commentRef.backdropClick().subscribe(() => this.closeComments()); this.commentRef.keydownEvents().subscribe((e) => { if ((e as KeyboardEvent).key === 'Escape') this.closeComments(); }); } private closeComments(): void { if (this.commentSub) { try { this.commentSub.unsubscribe(); } catch {} this.commentSub = null; } if (this.commentRef) { this.commentRef.dispose(); this.commentRef = undefined; } } onBlockMetaChange(metaChanges: any, blockId: string): void { // Update meta for a specific block within columns const updatedColumns = this.props.columns.map(column => ({ ...column, blocks: column.blocks.map(b => { if (b.id === blockId) { return { ...b, meta: { ...b.meta, ...metaChanges } }; } return b; }) })); this.update.emit({ columns: updatedColumns }); } onBlockCreateBelow(blockId: string, columnIndex: number, blockIndex: number): void { // Create a new paragraph block after the specified block in the same column const updatedColumns = this.props.columns.map((column, colIdx) => { if (colIdx === columnIndex) { const newBlock = { id: this.generateId(), type: 'paragraph' as any, props: { text: '' }, children: [] }; const newBlocks = [...column.blocks]; newBlocks.splice(blockIndex + 1, 0, newBlock); return { ...column, blocks: newBlocks }; } return column; }); this.update.emit({ columns: updatedColumns }); // Focus the new block after a brief delay setTimeout(() => { const newElement = document.querySelector(`[data-block-id="${updatedColumns[columnIndex].blocks[blockIndex + 1].id}"] [contenteditable]`) as HTMLElement; if (newElement) { newElement.focus(); } }, 50); } onListItemCreateBelow(blockId: string, columnIndex: number, blockIndex: number): void { // Insert a new list-item block directly after the given list-item within the same column const updatedColumns = this.props.columns.map((column, colIdx) => { if (colIdx !== columnIndex) return column; const blocks = [...column.blocks]; const existing = blocks[blockIndex]; if (!existing || existing.type !== 'list-item') { return column; } const props: any = existing.props || {}; const newProps: any = { kind: props.kind, text: '', checked: props.kind === 'check' ? false : undefined, indent: props.indent || 0, align: props.align }; if (props.kind === 'numbered' && typeof props.number === 'number') { newProps.number = props.number + 1; } const newBlock = this.documentService.createBlock('list-item' as any, newProps); const newBlocks = [...blocks]; newBlocks.splice(blockIndex + 1, 0, newBlock); return { ...column, blocks: newBlocks }; }); this.update.emit({ columns: updatedColumns }); // Focus the new list-item input once it is rendered const newId = updatedColumns[columnIndex]?.blocks[blockIndex + 1]?.id; if (!newId) return; setTimeout(() => { const input = document.querySelector( `[data-block-id="${newId}"] input[type="text"]` ) as HTMLInputElement | null; if (input) { input.focus(); const len = input.value.length; input.setSelectionRange(len, len); } }, 50); } onBlockDelete(blockId: string): void { // Delete a specific block from columns const updatedColumns = this.props.columns.map(column => ({ ...column, blocks: column.blocks.filter(b => b.id !== blockId) })); this.update.emit({ columns: updatedColumns }); } onInsertImagesBelowInColumn(urls: string[], columnIndex: number, blockIndex: number): void { if (!urls || !urls.length) return; const updatedColumns = this.props.columns.map((column, idx) => { if (idx !== columnIndex) return column; const newBlocks = [...column.blocks]; let insertAt = blockIndex + 1; for (const url of urls) { const newBlock = this.documentService.createBlock('image', { src: url, alt: '' }); newBlocks.splice(insertAt, 0, newBlock); insertAt++; } return { ...column, blocks: newBlocks }; }); this.update.emit({ columns: updatedColumns }); } openMenu(block: Block, event: MouseEvent): void { event.stopPropagation(); const rect = (event.target as HTMLElement).getBoundingClientRect(); this.selectedBlock.set(block); this.menuVisible.set(true); this.menuPosition.set({ x: rect.left, y: rect.bottom + 5 }); } closeMenu(): void { this.menuVisible.set(false); this.selectedBlock.set(null); } createDummyBlock(): Block { // Return a dummy block when selectedBlock is null (to satisfy type requirements) return { id: '', type: 'paragraph', props: { text: '' }, children: [] }; } onMenuAction(action: any): void { const block = this.selectedBlock(); if (!block) return; // Handle comment action if (action.type === 'comment') { this.openComments(block.id); } // Handle align action if (action.type === 'align') { const { alignment } = action.payload || {}; if (alignment) { this.alignBlockInColumns(block.id, alignment); } } // Handle indent action if (action.type === 'indent') { const { delta } = action.payload || {}; if (delta !== undefined) { this.indentBlockInColumns(block.id, delta); } } // Handle background action if (action.type === 'background') { const { color } = action.payload || {}; this.backgroundColorBlockInColumns(block.id, color); } // Handle convert action if (action.type === 'convert') { // Convert the block type within the columns const { type, preset } = action.payload || {}; if (type) { this.convertBlockInColumns(block.id, type, preset); } } // Handle delete action if (action.type === 'delete') { this.deleteBlockFromColumns(block.id); } // Handle duplicate action if (action.type === 'duplicate') { this.duplicateBlockInColumns(block.id); } // Handle add action inside columns if (action.type === 'add') { const pos = (action.payload || {}).position as 'above' | 'below' | 'left' | 'right' | undefined; if (pos) { if (pos === 'above' || pos === 'below') { // Insert paragraph in same column before/after the selected block const updatedColumns = this.props.columns.map((column) => { const idx = column.blocks.findIndex(b => b.id === block.id); if (idx === -1) return column; const newBlock = this.documentService.createBlock('paragraph', { text: '' }); const newBlocks = [...column.blocks]; const insertAt = pos === 'above' ? idx : idx + 1; newBlocks.splice(insertAt, 0, newBlock); return { ...column, blocks: newBlocks }; }); this.update.emit({ columns: updatedColumns }); this.closeMenu(); return; } if (pos === 'left' || pos === 'right') { // Add a new column on left/right with a new paragraph const newParagraph = this.documentService.createBlock('paragraph', { text: '' }); const cols = [...this.props.columns]; const newWidth = 100 / (cols.length + 1); const resized = cols.map(col => ({ ...col, width: newWidth })); const insertion = { id: this.generateId(), blocks: [newParagraph], width: newWidth } as any; const targetColIndex = resized.findIndex(c => c.blocks.some(b => b.id === block.id)); if (targetColIndex >= 0) { if (pos === 'left') resized.splice(targetColIndex, 0, insertion); else resized.splice(targetColIndex + 1, 0, insertion); this.update.emit({ columns: resized }); this.closeMenu(); return; } } } } this.closeMenu(); } private alignBlockInColumns(blockId: string, alignment: string): void { const updatedColumns = this.props.columns.map(column => ({ ...column, blocks: column.blocks.map(b => { if (b.id === blockId) { // For list-item blocks, update props.align if (b.type === 'list-item') { return { ...b, props: { ...b.props, align: alignment as any } }; } else { // For other blocks, update meta.align const current = b.meta || {}; return { ...b, meta: { ...current, align: alignment as any } }; } } return b; }) })); this.update.emit({ columns: updatedColumns }); } private indentBlockInColumns(blockId: string, delta: number): void { const updatedColumns = this.props.columns.map(column => ({ ...column, blocks: column.blocks.map(b => { if (b.id === blockId) { // For list-item blocks, update props.indent if (b.type === 'list-item') { const cur = Number((b.props as any).indent || 0); const next = Math.max(0, Math.min(7, cur + delta)); return { ...b, props: { ...b.props, indent: next } }; } else { // For other blocks, update meta.indent const current = (b.meta as any) || {}; const cur = Number(current.indent || 0); const next = Math.max(0, Math.min(8, cur + delta)); return { ...b, meta: { ...current, indent: next } }; } } return b; }) })); this.update.emit({ columns: updatedColumns }); } private backgroundColorBlockInColumns(blockId: string, color: string): void { const updatedColumns = this.props.columns.map(column => ({ ...column, blocks: column.blocks.map(b => { if (b.id === blockId) { return { ...b, meta: { ...b.meta, bgColor: color === 'transparent' ? undefined : color } }; } return b; }) })); this.update.emit({ columns: updatedColumns }); } private convertBlockInColumns(blockId: string, newType: string, preset: any): void { const updatedColumns = this.props.columns.map(column => ({ ...column, blocks: column.blocks.map(b => { if (b.id === blockId) { // Convert block type while preserving text content const text = this.getBlockText(b); let newProps: any = { text }; // Apply preset if provided if (preset) { newProps = { ...newProps, ...preset }; } return { ...b, type: newType as any, props: newProps }; } return b; }) })); this.update.emit({ columns: updatedColumns }); } private deleteBlockFromColumns(blockId: string): void { let updatedColumns = this.props.columns.map(column => ({ ...column, blocks: column.blocks.filter(b => b.id !== blockId) })); // Remove empty columns updatedColumns = updatedColumns.filter(col => col.blocks.length > 0); // If only one column remains, we could convert back to normal blocks // But for now, we'll keep the columns structure and redistribute widths // Redistribute widths equally if (updatedColumns.length > 0) { const newWidth = 100 / updatedColumns.length; updatedColumns = updatedColumns.map(col => ({ ...col, width: newWidth })); } this.update.emit({ columns: updatedColumns }); } private duplicateBlockInColumns(blockId: string): void { const updatedColumns = this.props.columns.map(column => { const blockIndex = column.blocks.findIndex(b => b.id === blockId); if (blockIndex >= 0) { const originalBlock = column.blocks[blockIndex]; const duplicatedBlock = { ...JSON.parse(JSON.stringify(originalBlock)), id: this.generateId() }; const newBlocks = [...column.blocks]; newBlocks.splice(blockIndex + 1, 0, duplicatedBlock); return { ...column, blocks: newBlocks }; } return column; }); this.update.emit({ columns: updatedColumns }); } private getBlockText(block: Block): string { if ('text' in block.props) { return (block.props as any).text || ''; } return ''; } getBlockBgColor(block: Block): string | undefined { // Paragraph, heading and list(-item) blocks in columns should not have a full-width // background; their inner editable/input pill handles the colored capsule. if (block.type === 'paragraph' || block.type === 'heading' || block.type === 'list' || block.type === 'list-item') { return undefined; } const bgColor = (block.meta as any)?.bgColor; return bgColor && bgColor !== 'transparent' ? bgColor : undefined; } getBlockStyles(block: Block): {[key: string]: any} { const meta: any = block.meta || {}; const props: any = block.props || {}; // For list-item blocks, check props.align and props.indent // For other blocks, check meta.align and meta.indent const align = block.type === 'list-item' ? (props.align || 'left') : (meta.align || 'left'); const indent = block.type === 'list-item' ? Math.max(0, Math.min(7, Number(props.indent || 0))) : Math.max(0, Math.min(8, Number(meta.indent || 0))); return { textAlign: align, marginLeft: `${indent * 16}px` }; } private generateId(): string { return Math.random().toString(36).substring(2, 11); } ngAfterViewInit(): void { setTimeout(() => this.computeResizerPositions(), 0); } ngOnDestroy(): void { this.closeComments(); } @HostListener('window:resize') onWindowResize(): void { this.computeResizerPositions(); } onDragStart(block: Block, columnIndex: number, blockIndex: number, event: MouseEvent): void { event.stopPropagation(); // Store drag source info this.draggedBlock = { block, columnIndex, blockIndex }; // Use DragDropService for unified drag system // We use a virtual index based on position in the columns structure const virtualIndex = this.getVirtualIndex(columnIndex, blockIndex); this.dragDrop.beginDrag(block.id, virtualIndex, event.clientY); const onMove = (e: MouseEvent) => { // Update DragDropService pointer for visual indicators this.dragDrop.updatePointer(e.clientY, e.clientX); }; const onUp = (e: MouseEvent) => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); const { moved } = this.dragDrop.endDrag(); if (!moved || !this.draggedBlock) { this.draggedBlock = null; return; } // Determine drop target const target = document.elementFromPoint(e.clientX, e.clientY); if (!target) { this.draggedBlock = null; return; } // Check if dropping on another block in columns const blockEl = target.closest('[data-block-id]'); if (blockEl) { const targetColIndex = parseInt(blockEl.getAttribute('data-column-index') || '0'); const targetBlockIndex = parseInt(blockEl.getAttribute('data-block-index') || '0'); // Move within columns this.moveBlock( this.draggedBlock.columnIndex, this.draggedBlock.blockIndex, targetColIndex, targetBlockIndex ); } else { // Check if dropping outside columns (convert to full-width block) const isOutsideColumns = !target.closest('[data-column-id]'); if (isOutsideColumns) { this.convertToFullWidth(this.draggedBlock.columnIndex, this.draggedBlock.blockIndex); } } this.draggedBlock = null; }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); } private getVirtualIndex(colIndex: number, blockIndex: number): number { // Calculate a virtual index for DragDropService // This helps with visual indicator positioning let count = 0; const props = this.block.props as ColumnsProps; for (let i = 0; i < colIndex; i++) { count += props.columns[i]?.blocks.length || 0; } return count + blockIndex; } private convertToFullWidth(colIndex: number, blockIndex: number): void { const props = this.block.props as ColumnsProps; const column = props.columns[colIndex]; if (!column) return; const blockToMove = column.blocks[blockIndex]; if (!blockToMove) return; // Insert block as full-width after the columns block const blockCopy = JSON.parse(JSON.stringify(blockToMove)); this.documentService.insertBlock(this.block.id, blockCopy); // Remove from column const updatedColumns = [...props.columns]; updatedColumns[colIndex] = { ...column, blocks: column.blocks.filter((_, i) => i !== blockIndex) }; // Remove empty columns and redistribute widths const nonEmptyColumns = updatedColumns.filter(col => col.blocks.length > 0); if (nonEmptyColumns.length === 0) { // Delete the entire columns block if no blocks left this.documentService.deleteBlock(this.block.id); } else if (nonEmptyColumns.length === 1) { // Convert single column back to full-width blocks const remainingBlocks = nonEmptyColumns[0].blocks; remainingBlocks.forEach(b => { const copy = JSON.parse(JSON.stringify(b)); this.documentService.insertBlock(this.block.id, copy); }); this.documentService.deleteBlock(this.block.id); } else { // Update columns with redistributed widths const newWidth = 100 / nonEmptyColumns.length; const redistributed = nonEmptyColumns.map(col => ({ ...col, width: newWidth })); this.update.emit({ columns: redistributed }); } // Select the moved block this.selectionService.setActive(blockCopy.id); } private moveBlock(fromCol: number, fromBlock: number, toCol: number, toBlock: number): void { if (fromCol === toCol && fromBlock === toBlock) return; const columns = [...this.props.columns]; // Get the block to move const blockToMove = columns[fromCol].blocks[fromBlock]; if (!blockToMove) return; // Remove from source columns[fromCol] = { ...columns[fromCol], blocks: columns[fromCol].blocks.filter((_, i) => i !== fromBlock) }; // Adjust target index if moving within same column let actualToBlock = toBlock; if (fromCol === toCol && fromBlock < toBlock) { actualToBlock--; } // Insert at target const newBlocks = [...columns[toCol].blocks]; newBlocks.splice(actualToBlock, 0, blockToMove); columns[toCol] = { ...columns[toCol], blocks: newBlocks }; // Remove empty columns and redistribute widths const nonEmptyColumns = columns.filter(col => col.blocks.length > 0); if (nonEmptyColumns.length > 0) { const newWidth = 100 / nonEmptyColumns.length; const redistributed = nonEmptyColumns.map(col => ({ ...col, width: newWidth })); this.update.emit({ columns: redistributed }); } } onBlockUpdate(updatedProps: any, blockId: string): void { // Find the block in columns and update it const updatedColumns = this.props.columns.map(column => ({ ...column, blocks: column.blocks.map(b => b.id === blockId ? { ...b, props: { ...b.props, ...updatedProps } } : b ) })); // Emit the updated columns this.update.emit({ columns: updatedColumns }); } // Resizer helpers get resizerIndexes(): number[] { const n = (this.props.columns?.length || 0) - 1; if (n <= 0) return []; return Array.from({ length: n }, (_, i) => i); } private computeResizerPositions(): void { try { const container = this.columnsContainerRef?.nativeElement; if (!container) return; const cols = Array.from(container.querySelectorAll('[data-column-index]')); const crect = container.getBoundingClientRect(); const positions: number[] = []; for (let i = 0; i < cols.length - 1; i++) { const r = cols[i].getBoundingClientRect(); positions.push(r.right - crect.left); } this.resizerPositions.set(positions); } catch {} } onResizerDown(i: number, event: MouseEvent): void { event.preventDefault(); event.stopPropagation(); const container = this.columnsContainerRef?.nativeElement; if (!container) return; const crect = container.getBoundingClientRect(); const cols = this.props.columns || []; const left = cols[i]; const right = cols[i + 1]; const leftStart = Number(left?.width ?? (100 / cols.length)); const rightStart = Number(right?.width ?? (100 / cols.length)); this.resizeState = { active: true, index: i, startX: event.clientX, containerWidth: Math.max(1, crect.width), leftStart, rightStart }; const onMove = (e: MouseEvent) => { if (!this.resizeState) return; const dx = e.clientX - this.resizeState.startX; const dxPct = (dx / this.resizeState.containerWidth) * 100; const sum = this.resizeState.leftStart + this.resizeState.rightStart; let newLeft = this.resizeState.leftStart + dxPct; // Clamp with min constraints const min = this.MIN_COL_WIDTH; newLeft = Math.max(min, Math.min(sum - min, newLeft)); const newRight = sum - newLeft; const updated = (this.props.columns || []).map((col, idx) => { if (idx === i) return { ...col, width: newLeft } as ColumnItem; if (idx === i + 1) return { ...col, width: newRight } as ColumnItem; return col; }); this.update.emit({ columns: updated }); // Recompute positions on next tick to reflect DOM changes setTimeout(() => this.computeResizerPositions(), 0); }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); this.resizeState = null; this.computeResizerPositions(); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp, { once: true }); } }