ObsiViewer/src/app/editor/components/block/blocks/columns-block.component.ts
Bruno Charest ee3085ce38 feat: add Nimbus Editor with Unsplash integration
- Integrated Unsplash API for image search functionality with environment configuration
- Added new Nimbus Editor page component with navigation from sidebar and mobile drawer
- Enhanced TOC with highlight animation for editor heading navigation
- Improved CDK overlay z-index hierarchy for proper menu layering
- Removed obsolete logging validation script
2025-11-11 11:38:27 -05:00

761 lines
27 KiB
TypeScript

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: `
<div class="flex gap-2 w-full relative" #columnsContainer>
@for (column of props.columns; track column.id; let colIndex = $index) {
<div
class="flex-1 min-w-0"
[style.flex-basis.%]="column.width || (100 / props.columns.length)"
[attr.data-column-id]="column.id"
[attr.data-column-index]="colIndex"
>
@for (block of column.blocks; track block.id; let blockIndex = $index) {
<div
class="mb-1 block-in-column group/block relative"
[attr.data-block-id]="block.id"
[attr.data-column-index]="colIndex"
[attr.data-block-index]="blockIndex"
>
<!-- Menu button (3 dots) - Outside left, centered vertically -->
<button
type="button"
class="menu-handle absolute -left-9 top-1/2 -translate-y-1/2 w-7 h-7 flex items-center justify-center opacity-0 group-hover/block:opacity-100 transition-opacity bg-gray-700 hover:bg-gray-600 rounded-md z-10"
title="Drag to move or click for menu"
(click)="openMenu(block, $event)"
(mousedown)="onDragStart(block, colIndex, blockIndex, $event)"
>
<svg class="w-4 h-4 text-gray-300" viewBox="0 0 16 16" fill="currentColor">
<circle cx="3" cy="8" r="1.5"/>
<circle cx="8" cy="8" r="1.5"/>
<circle cx="13" cy="8" r="1.5"/>
</svg>
</button>
<!-- Comment button - Outside right, centered vertically -->
<button
type="button"
class="absolute -right-9 top-1/2 -translate-y-1/2 w-7 h-7 flex items-center justify-center opacity-0 group-hover/block:opacity-100 transition-opacity bg-gray-700 hover:bg-gray-600 rounded-md z-10"
[class.!opacity-100]="getBlockCommentCount(block.id) > 0"
[class.bg-blue-600]="getBlockCommentCount(block.id) > 0"
[class.hover:bg-blue-500]="getBlockCommentCount(block.id) > 0"
title="Comments"
(click)="openComments(block.id)"
>
@if (getBlockCommentCount(block.id) > 0) {
<span class="text-[10px] font-semibold text-white">
{{ getBlockCommentCount(block.id) }}
</span>
} @else {
<svg class="w-4 h-4 text-gray-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
}
</button>
<!-- Render block with background color support -->
<div
class="relative px-1.5 py-0.5 rounded transition-colors"
[style.background-color]="getBlockBgColor(block)"
[ngStyle]="getBlockStyles(block)"
>
@switch (block.type) {
@case ('heading') {
<app-heading-block
[block]="block"
(update)="onBlockUpdate($event, block.id)"
(metaChange)="onBlockMetaChange($event, block.id)"
(createBlock)="onBlockCreateBelow(block.id, colIndex, blockIndex)"
(deleteBlock)="onBlockDelete(block.id)"
/>
}
@case ('paragraph') {
<app-paragraph-block
[block]="block"
(update)="onBlockUpdate($event, block.id)"
(metaChange)="onBlockMetaChange($event, block.id)"
(createBlock)="onBlockCreateBelow(block.id, colIndex, blockIndex)"
(deleteBlock)="onBlockDelete(block.id)"
/>
}
@case ('list-item') {
<app-list-item-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('code') {
<app-code-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('quote') {
<app-quote-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('toggle') {
<app-toggle-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('hint') {
<app-hint-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('button') {
<app-button-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('image') {
<app-image-block [block]="block" (update)="onBlockUpdate($event, block.id)" (insertImagesBelow)="onInsertImagesBelowInColumn($event, colIndex, blockIndex)" />
}
@case ('file') {
<app-file-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('table') {
<app-table-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('steps') {
<app-steps-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('line') {
<app-line-block [block]="block" />
}
@case ('dropdown') {
<app-dropdown-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('progress') {
<app-progress-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('kanban') {
<app-kanban-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('embed') {
<app-embed-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('outline') {
<app-outline-block [block]="block" />
}
@case ('list') {
<app-list-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('columns') {
<div class="text-orange-400 px-3 py-2 rounded bg-orange-900/20 border border-orange-700/30 text-sm">
⚠️ Nested columns are not supported. Convert this block to full width.
</div>
}
@default {
<div class="text-gray-300 px-2 py-1 rounded bg-gray-700/30 text-sm">
Type: {{ block.type }} (not yet supported in columns)
</div>
}
}
</div>
</div>
} @empty {
<div class="text-center py-4 text-gray-500 text-xs">
Drop blocks here
</div>
}
</div>
}
</div>
<!-- Comments Panel -->
<app-comments-panel #commentsPanel />
<!-- Block Context Menu -->
<app-block-context-menu
[block]="selectedBlock() || createDummyBlock()"
[visible]="menuVisible()"
[position]="menuPosition()"
(action)="onMenuAction($event)"
(close)="closeMenu()"
/>
`,
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<ColumnsProps>;
@Output() update = new EventEmitter<ColumnsProps>();
@ViewChild('commentsPanel') commentsPanel?: CommentsPanelComponent;
// Menu state
selectedBlock = signal<Block | null>(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 });
}
}