import { Component, Input, Output, EventEmitter, inject, ViewChild, signal, effect } 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 { CommentService } from '../../../services/comment.service'; import { DocumentService } from '../../../services/document.service'; import { SelectionService } from '../../../services/selection.service'; // 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 { 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 { CommentsPanelComponent } from '../../comments/comments-panel.component'; import { BlockContextMenuComponent } from '../block-context-menu.component'; @Component({ selector: 'app-columns-block', standalone: true, imports: [ CommonModule, ParagraphBlockComponent, HeadingBlockComponent, ListItemBlockComponent, CodeBlockComponent, QuoteBlockComponent, ToggleBlockComponent, HintBlockComponent, ButtonBlockComponent, ImageBlockComponent, FileBlockComponent, TableBlockComponent, StepsBlockComponent, LineBlockComponent, DropdownBlockComponent, ProgressBlockComponent, KanbanBlockComponent, EmbedBlockComponent, OutlineBlockComponent, ListBlockComponent, CommentsPanelComponent, BlockContextMenuComponent ], 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 ('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
}
}
`, 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; } `] }) export class ColumnsBlockComponent { private readonly dragDrop = inject(DragDropService); private readonly commentService = inject(CommentService); private readonly documentService = inject(DocumentService); private readonly selectionService = inject(SelectionService); @Input({ required: true }) block!: Block; @Output() update = new EventEmitter(); @ViewChild('commentsPanel') commentsPanel?: CommentsPanelComponent; // 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); get props(): ColumnsProps { return this.block.props; } getBlockCommentCount(blockId: string): number { return this.commentService.getCommentCount(blockId); } openComments(blockId: string): void { this.commentsPanel?.open(blockId); } 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); } 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); } 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 { 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); } 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 }); } }