import { Component, Input, Output, EventEmitter, inject, signal, HostListener, ElementRef, OnDestroy } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Block } from '../../core/models/block.model'; import { SelectionService } from '../../services/selection.service'; import { DocumentService } from '../../services/document.service'; import { BlockContextMenuComponent, MenuAction } from './block-context-menu.component'; import { DragDropService } from '../../services/drag-drop.service'; import { CommentStoreService } from '../../services/comment-store.service'; import { Overlay, OverlayModule, OverlayRef } from '@angular/cdk/overlay'; import { ComponentPortal, PortalModule } from '@angular/cdk/portal'; import { BlockCommentComposerComponent } from '../comment/block-comment-composer.component'; import { ImageInfoModalComponent } from '../image/image-info-modal.component'; import { ImageCaptionModalComponent } from '../image/image-caption-modal.component'; import { BlockMenuAction } from './block-initial-menu.component'; // Import block components import { ParagraphBlockComponent } from './blocks/paragraph-block.component'; import { HeadingBlockComponent } from './blocks/heading-block.component'; import { ListBlockComponent } from './blocks/list-block.component'; import { ListItemBlockComponent } from './blocks/list-item-block.component'; import { CodeBlockComponent } from './blocks/code-block.component'; import { QuoteBlockComponent } from './blocks/quote-block.component'; import { TableBlockComponent } from './blocks/table-block.component'; import { ImageBlockComponent } from './blocks/image-block.component'; import { FileBlockComponent } from './blocks/file-block.component'; import { ButtonBlockComponent } from './blocks/button-block.component'; import { LinkBlockComponent } from './blocks/link-block.component'; import { HintBlockComponent } from './blocks/hint-block.component'; import { ToggleBlockComponent } from './blocks/toggle-block.component'; import { DropdownBlockComponent } from './blocks/dropdown-block.component'; import { StepsBlockComponent } from './blocks/steps-block.component'; import { ProgressBlockComponent } from './blocks/progress-block.component'; import { KanbanBlockComponent } from './blocks/kanban-block.component'; import { EmbedBlockComponent } from './blocks/embed-block.component'; import { OutlineBlockComponent } from './blocks/outline-block.component'; import { LineBlockComponent } from './blocks/line-block.component'; import { ColumnsBlockComponent } from './blocks/columns-block.component'; import { CollapsibleBlockComponent } from './blocks/collapsible-block.component'; import { BookmarkBlockComponent } from './blocks/bookmark-block.component'; /** * Block host component - routes to specific block type */ @Component({ selector: 'app-block-host', standalone: true, imports: [ CommonModule, BlockContextMenuComponent, ParagraphBlockComponent, HeadingBlockComponent, ListBlockComponent, ListItemBlockComponent, CodeBlockComponent, QuoteBlockComponent, TableBlockComponent, ImageBlockComponent, FileBlockComponent, ButtonBlockComponent, LinkBlockComponent, HintBlockComponent, ToggleBlockComponent, DropdownBlockComponent, StepsBlockComponent, ProgressBlockComponent, KanbanBlockComponent, EmbedBlockComponent, OutlineBlockComponent, LineBlockComponent, ColumnsBlockComponent, CollapsibleBlockComponent, BookmarkBlockComponent, OverlayModule, PortalModule ], template: `
@if (dragDrop.dragging() && shouldHighlight()) {
} @if (block.type !== 'columns') { }
@switch (block.type) { @case ('paragraph') {
} @case ('heading') { } @case ('list') { } @case ('list-item') { } @case ('code') { } @case ('quote') { } @case ('table') { } @case ('image') { } @case ('file') { } @case ('button') { } @case ('link') { } @case ('hint') { } @case ('toggle') { } @case ('dropdown') { } @case ('steps') { } @case ('progress') { } @case ('kanban') { } @case ('embed') { } @case ('outline') { } @case ('line') { } @case ('columns') { } @case ('collapsible') { } @case ('bookmark') { } }
`, styles: [` .block-wrapper { @apply relative py-0.5 px-3 rounded-md transition-all; /* No fixed min-height; let content define height */ } /* No hover/active visuals; block should blend with background */ .block-wrapper:hover { } .block-wrapper.active { } .block-wrapper.locked { @apply opacity-60 cursor-not-allowed; } .block-content.locked { pointer-events: none; } .menu-handle { @apply flex items-center justify-center cursor-pointer; } .menu-handle:active { @apply cursor-grabbing; } `] }) export class BlockHostComponent implements OnDestroy { @Input({ required: true }) block!: Block; @Input() index: number = 0; @Input() showInlineMenu = false; @Output() inlineMenuAction = new EventEmitter(); private readonly selectionService = inject(SelectionService); private readonly documentService = inject(DocumentService); readonly dragDrop = inject(DragDropService); private readonly comments = inject(CommentStoreService); private readonly overlay = inject(Overlay); private readonly host = inject(ElementRef); private commentRef?: OverlayRef; private commentSub?: OverlayRef | { unsubscribe: () => void } | null = null; private imageInfoRef?: OverlayRef; private imageCaptionRef?: OverlayRef; readonly isActive = signal(false); readonly menuVisible = signal(false); readonly menuPosition = signal({ x: 0, y: 0 }); ngOnInit(): void { // Update active state when selection changes this.isActive.set(this.selectionService.isActive(this.block.id)); } // Whether to show a subtle highlight on this block while dragging shouldHighlight(): boolean { if (!this.dragDrop.dragging()) return false; const mode = this.dragDrop.dropMode(); const over = this.dragDrop.overIndex(); if (mode === 'line') { // Highlight the two blocks around the insertion line to aid targeting return over === this.index || over === this.index + 1; } // For column creation (left/right), highlight the target block only return over === this.index; } onBlockClick(event: MouseEvent): void { if (!this.block.meta?.locked) { this.selectionService.setActive(this.block.id); this.isActive.set(true); event.stopPropagation(); } } onInsertImagesBelow(urls: string[]): void { if (!urls || !urls.length) return; let afterId = this.block.id; for (const url of urls) { const newBlock = this.documentService.createBlock('image', { src: url, alt: '' }); this.documentService.insertBlock(afterId, newBlock); afterId = newBlock.id; } } onMenuClick(event: MouseEvent): void { event.stopPropagation(); event.preventDefault(); const rect = (event.target as HTMLElement).getBoundingClientRect(); this.menuPosition.set({ x: rect.right + 8, y: rect.top }); this.menuVisible.set(true); } openMenuAt(pos: { x: number; y: number }): void { this.menuPosition.set({ x: pos.x, y: pos.y }); this.menuVisible.set(true); } onInlineMenuAction(action: BlockMenuAction): void { this.inlineMenuAction.emit(action); } onDragStart(event: MouseEvent): void { if (this.block.meta?.locked) return; const target = event.currentTarget as HTMLElement; const y = event.clientY; this.dragDrop.beginDrag(this.block.id, this.index, y); const onMove = (e: MouseEvent) => { this.dragDrop.updatePointer(e.clientY, e.clientX); }; const onUp = (e: MouseEvent) => { const { from, to, moved, mode } = this.dragDrop.endDrag(); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); if (!moved) return; if (to < 0) return; if (from < 0) return; // Cancel if dropped outside the editor container const crect = this.dragDrop.getContainerRect(); if (crect && (e.clientX < crect.left || e.clientX > crect.right || e.clientY < crect.top || e.clientY > crect.bottom)) { return; } // Check if dropping into or between columns const target = document.elementFromPoint(e.clientX, e.clientY); if (target) { const columnsBlockEl = target.closest('.block-wrapper[data-block-id]'); const columnsBlockId = columnsBlockEl?.getAttribute('data-block-id'); if (columnsBlockId) { const blocks = this.documentService.blocks(); const columnsBlock = blocks.find(b => b.id === columnsBlockId); if (columnsBlock && columnsBlock.type === 'columns') { const columnEl = target.closest('[data-column-id]'); // If indicator is a gap between columns, create a new column deterministically if (mode === 'column-gap') { // Determine the columns container and compute gap index by pointer X let columnsContainerEl = (columnsBlockEl.querySelector('[data-column-id]') as HTMLElement | null); columnsContainerEl = (columnsContainerEl?.parentElement as HTMLElement) || columnsContainerEl; if (!columnsContainerEl) columnsContainerEl = columnsBlockEl as HTMLElement; if (columnsContainerEl) { const containerRect = columnsContainerEl.getBoundingClientRect(); const props = columnsBlock.props as any; const columns = [...(props.columns || [])]; const relativeX = e.clientX - containerRect.left; const columnWidth = containerRect.width / columns.length; let insertIndex = Math.floor(relativeX / columnWidth); const gapThreshold = 60; const posInColumn = (relativeX % columnWidth); // if near right border of current column, insert after if (posInColumn > (columnWidth - gapThreshold)) insertIndex += 1; const draggedCopy = JSON.parse(JSON.stringify(this.block)); const newColumn = { id: this.generateId(), blocks: [draggedCopy], width: 100 / (columns.length + 1) }; const updatedColumns = columns.map((col: any) => ({ ...col, width: 100 / (columns.length + 1) })); const clampedIndex = Math.max(0, Math.min(updatedColumns.length, insertIndex)); updatedColumns.splice(clampedIndex, 0, newColumn); this.documentService.updateBlockProps(columnsBlockId, { columns: updatedColumns }); this.documentService.deleteBlock(this.block.id); this.selectionService.setActive(draggedCopy.id); return; } } else if (columnEl) { // Dropping over an existing column: if near left/right border, create a new column; else insert into the column const colIndex = parseInt(columnEl.getAttribute('data-column-index') || '0'); const props = columnsBlock.props as any; const columns = [...(props.columns || [])]; const cRect = (columnEl as HTMLElement).getBoundingClientRect(); const gapThreshold = 60; const distLeft = e.clientX - cRect.left; const distRight = cRect.right - e.clientX; if (distLeft < gapThreshold || distRight < gapThreshold) { // Create a new column before/after this one const draggedCopy = JSON.parse(JSON.stringify(this.block)); const newColumn = { id: this.generateId(), blocks: [draggedCopy], width: 100 / (columns.length + 1) }; const updatedColumns = columns.map((col: any) => ({ ...col, width: 100 / (columns.length + 1) })); const insertAt = distLeft < distRight ? colIndex : colIndex + 1; const clamped = Math.max(0, Math.min(updatedColumns.length, insertAt)); updatedColumns.splice(clamped, 0, newColumn); this.documentService.updateBlockProps(columnsBlockId, { columns: updatedColumns }); this.documentService.deleteBlock(this.block.id); this.selectionService.setActive(draggedCopy.id); return; } else { // Insert INTO the column (at a precise block index if hovered one) const blockCopy = JSON.parse(JSON.stringify(this.block)); const blockEl = target.closest('[data-block-id]'); let insertIndex = columns[colIndex]?.blocks?.length || 0; if (blockEl && blockEl.getAttribute('data-block-id') !== columnsBlockId) { insertIndex = parseInt(blockEl.getAttribute('data-block-index') || '0'); } columns[colIndex] = { ...columns[colIndex], blocks: [ ...columns[colIndex].blocks.slice(0, insertIndex), blockCopy, ...columns[colIndex].blocks.slice(insertIndex) ] }; this.documentService.updateBlockProps(columnsBlockId, { columns }); this.documentService.deleteBlock(this.block.id); this.selectionService.setActive(blockCopy.id); return; } } else { // Fallback gap detection (legacy) - insert as new column let columnsContainerEl = columnsBlockEl.querySelector('[data-column-id]') as HTMLElement | null; columnsContainerEl = (columnsContainerEl?.parentElement as HTMLElement) || columnsContainerEl; if (!columnsContainerEl) { // Fallback: use the block element itself columnsContainerEl = columnsBlockEl as HTMLElement; } if (columnsContainerEl) { const containerRect = (columnsContainerEl as HTMLElement).getBoundingClientRect(); const props = columnsBlock.props as any; const columns = [...(props.columns || [])]; // Calculate which gap we're in based on X position const relativeX = e.clientX - containerRect.left; const columnWidth = containerRect.width / columns.length; let insertIndex = Math.floor(relativeX / columnWidth); // Check if we're in the gap (not on a column) - increased threshold for easier detection const gapThreshold = 60; // pixels (increased from 20 for better detection) const posInColumn = (relativeX % columnWidth); const isInGap = posInColumn > (columnWidth - gapThreshold) || posInColumn < gapThreshold; if (isInGap) { // Insert as new column if (posInColumn > (columnWidth - gapThreshold)) { insertIndex += 1; // Insert after this column } const blockCopy = JSON.parse(JSON.stringify(this.block)); const newColumn = { id: this.generateId(), blocks: [blockCopy], width: 100 / (columns.length + 1) }; // Recalculate existing column widths const updatedColumns = columns.map((col: any) => ({ ...col, width: 100 / (columns.length + 1) })); updatedColumns.splice(insertIndex, 0, newColumn); // Update columns block this.documentService.updateBlockProps(columnsBlockId, { columns: updatedColumns }); // Delete original block this.documentService.deleteBlock(this.block.id); this.selectionService.setActive(blockCopy.id); return; } } } } else { // Not a columns block: deterministically create a 2-column layout using dropMode and overIndex const modeNow = mode; // from endDrag() if (modeNow === 'column-left' || modeNow === 'column-right') { const all = this.documentService.blocks(); const tgtIdx = Math.max(0, Math.min(all.length - 1, to >= all.length ? all.length - 1 : to)); const targetBlock = all[tgtIdx]; if (targetBlock && targetBlock.id !== this.block.id) { const draggedCopy = JSON.parse(JSON.stringify(this.block)); const columns = (modeNow === 'column-left') ? [ { id: this.generateId(), blocks: [draggedCopy], width: 50 }, { id: this.generateId(), blocks: [JSON.parse(JSON.stringify(targetBlock))], width: 50 } ] : [ { id: this.generateId(), blocks: [JSON.parse(JSON.stringify(targetBlock))], width: 50 }, { id: this.generateId(), blocks: [draggedCopy], width: 50 } ]; const newColumnsBlock = this.documentService.createBlock('columns', { columns }); const beforeId = tgtIdx > 0 ? all[tgtIdx - 1].id : null; // Insert new columns before target, delete original two blocks this.documentService.insertBlock(beforeId, newColumnsBlock); this.documentService.deleteBlock(targetBlock.id); this.documentService.deleteBlock(this.block.id); this.selectionService.setActive(draggedCopy.id); return; } } } } } // Handle regular line move const blocks = this.documentService.blocks(); let toIndex = to; if (toIndex < 0) toIndex = 0; if (toIndex > blocks.length) toIndex = blocks.length; // allow end if (toIndex === from) return; // exact same slot this.documentService.moveBlock(this.block.id, toIndex); this.selectionService.setActive(this.block.id); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp, { once: true }); event.stopPropagation(); } private generateId(): string { return Math.random().toString(36).substring(2, 11); } // Simple CSV line parser supporting quotes and escaped quotes private parseCsvLine(line: string): string[] { const out: string[] = []; let cur = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (inQuotes) { if (ch === '"') { if (i + 1 < line.length && line[i + 1] === '"') { cur += '"'; i++; } else { inQuotes = false; } } else { cur += ch; } } else { if (ch === ',') { out.push(cur); cur = ''; } else if (ch === '"') { inQuotes = true; } else { cur += ch; } } } out.push(cur); return out; } closeMenu(): void { this.menuVisible.set(false); } @HostListener('document:click') onDocumentClick(): void { this.closeMenu(); } onMenuAction(action: MenuAction): void { switch (action.type) { case 'align': const { alignment } = action.payload || {}; if (alignment) { // For list-item blocks, update props.align if (this.block.type === 'list-item') { this.documentService.updateBlockProps(this.block.id, { ...this.block.props, align: alignment }); } else { // For other blocks, update meta.align const current = this.block.meta || {} as any; this.documentService.updateBlock(this.block.id, { meta: { ...current, align: alignment } } as any); } } break; case 'bookmarkViewMode': if (this.block.type === 'bookmark') { const mode = (action.payload || {}).viewMode as 'card' | 'tile' | 'cover' | undefined; if (mode === 'card' || mode === 'tile' || mode === 'cover') { this.documentService.updateBlockProps(this.block.id, { viewMode: mode }); } } break; case 'indent': const { delta } = action.payload || {}; if (delta !== undefined) { // For list-item blocks, update props.indent if (this.block.type === 'list-item') { const cur = Number((this.block.props as any).indent || 0); const next = Math.max(0, Math.min(7, cur + delta)); this.documentService.updateBlockProps(this.block.id, { ...this.block.props, indent: next }); } else { // For other blocks, update meta.indent const current = (this.block.meta as any) || {}; const cur = Number(current.indent || 0); const next = Math.max(0, Math.min(8, cur + delta)); this.documentService.updateBlock(this.block.id, { meta: { ...current, indent: next } } as any); } } break; case 'background': const { color } = action.payload || {}; this.documentService.updateBlock(this.block.id, { meta: { ...this.block.meta, bgColor: color === 'transparent' ? undefined : color } }); break; case 'lineColor': // For Quote and Hint blocks - update line color if (this.block.type === 'quote' || this.block.type === 'hint') { const { color: lineColor } = action.payload || {}; this.documentService.updateBlockProps(this.block.id, { ...this.block.props, lineColor: lineColor === 'transparent' ? undefined : lineColor }); } break; case 'borderColor': // For Hint blocks - update border color if (this.block.type === 'hint') { const { color: borderColor } = action.payload || {}; this.documentService.updateBlockProps(this.block.id, { ...this.block.props, borderColor: borderColor === 'transparent' ? undefined : borderColor }); } break; case 'convert': // Handle block conversion const { type, preset } = action.payload || {}; if (type) { this.documentService.convertBlock(this.block.id, type, preset); } break; case 'add': { const position = (action.payload || {}).position as 'above' | 'below' | 'left' | 'right' | undefined; if (!position) break; if (position === 'above' || position === 'below') { const newBlock = this.documentService.createBlock('paragraph', { text: '' }); const blocks = this.documentService.blocks(); const idx = blocks.findIndex(b => b.id === this.block.id); if (position === 'above') { const afterId = idx > 0 ? blocks[idx - 1].id : null; this.documentService.insertBlock(afterId, newBlock); } else { this.documentService.insertBlock(this.block.id, newBlock); } this.selectionService.setActive(newBlock.id); break; } if (position === 'left' || position === 'right') { // If current block is a columns block, add a new column at start/end if (this.block.type === 'columns') { const props: any = this.block.props || {}; const currentColumns = [...(props.columns || [])]; const newParagraph = this.documentService.createBlock('paragraph', { text: '' }); const newWidth = 100 / (currentColumns.length + 1); const updated = currentColumns.map((col: any) => ({ ...col, width: newWidth })); const newCol = { id: this.generateId(), blocks: [newParagraph], width: newWidth }; if (position === 'left') updated.unshift(newCol); else updated.push(newCol); this.documentService.updateBlockProps(this.block.id, { columns: updated }); this.selectionService.setActive(newParagraph.id); break; } // Otherwise, wrap current block and new paragraph into a two-column layout const blocks = this.documentService.blocks(); const targetIndex = blocks.findIndex(b => b.id === this.block.id); const blockCopy = JSON.parse(JSON.stringify(this.block)); const newParagraph = this.documentService.createBlock('paragraph', { text: '' }); const columns = position === 'left' ? [ { id: this.generateId(), blocks: [newParagraph], width: 50 }, { id: this.generateId(), blocks: [blockCopy], width: 50 } ] : [ { id: this.generateId(), blocks: [blockCopy], width: 50 }, { id: this.generateId(), blocks: [newParagraph], width: 50 } ]; const newColumnsBlock = this.documentService.createBlock('columns', { columns }); // Replace current block with columns block this.documentService.deleteBlock(this.block.id); if (targetIndex > 0) { const beforeBlockId = this.documentService.blocks()[targetIndex - 1]?.id || null; this.documentService.insertBlock(beforeBlockId, newColumnsBlock); } else { this.documentService.insertBlock(null, newColumnsBlock); } this.selectionService.setActive(newParagraph.id); } } break; case 'duplicate': this.documentService.duplicateBlock(this.block.id); break; case 'delete': this.closeMenu(); this.documentService.deleteBlock(this.block.id); break; case 'lock': this.documentService.updateBlock(this.block.id, { meta: { ...this.block.meta, locked: !this.block.meta?.locked } }); break; case 'copy': // TODO: Copy to clipboard console.log('Copy block:', this.block); break; case 'copyLink': // TODO: Copy link to clipboard console.log('Copy link:', this.block.id); break; case 'codeLanguage': // For Code blocks - update language if (this.block.type === 'code') { const { lang } = action.payload || {}; this.documentService.updateBlockProps(this.block.id, { ...this.block.props, lang }); } break; case 'codeTheme': // For Code blocks - update theme if (this.block.type === 'code') { const { themeId } = action.payload || {}; this.documentService.updateBlockProps(this.block.id, { ...this.block.props, theme: themeId }); } break; case 'copyCode': // For Code blocks - copy code to clipboard if (this.block.type === 'code') { const code = (this.block.props as any)?.code || ''; navigator.clipboard.writeText(code).then(() => { console.log('Code copied to clipboard'); }); } break; case 'toggleWrap': // For Code blocks - toggle word wrap if (this.block.type === 'code') { const current = (this.block.props as any)?.enableWrap || false; this.documentService.updateBlockProps(this.block.id, { ...this.block.props, enableWrap: !current }); } break; case 'toggleLineNumbers': // For Code blocks - toggle line numbers if (this.block.type === 'code') { const current = (this.block.props as any)?.showLineNumbers || false; this.documentService.updateBlockProps(this.block.id, { ...this.block.props, showLineNumbers: !current }); } break; case 'addCaption': if (this.block.type === 'table' || this.block.type === 'image') { this.openCaptionModal(); } break; case 'tableLayout': // For Table blocks - update layout if (this.block.type === 'table') { const { layout } = action.payload || {}; this.documentService.updateBlockProps(this.block.id, { ...this.block.props, layout }); } break; case 'copyTable': // For Table blocks - copy as markdown if (this.block.type === 'table') { const props = this.block.props as any; const rows = props.rows || []; let markdown = ''; rows.forEach((row: any, idx: number) => { const cells = row.cells || []; markdown += '| ' + cells.map((c: any) => c.text).join(' | ') + ' |\n'; if (idx === 0 && props.header) { markdown += '| ' + cells.map(() => '---').join(' | ') + ' |\n'; } }); navigator.clipboard.writeText(markdown).then(() => { console.log('Table copied as markdown'); }); } break; case 'filterTable': if (this.block.type === 'table') { const current = ((this.block.props as any)?.filter || '').trim(); const next = prompt('Filter rows (contains):', current) ?? null; if (next !== null) { const filter = next.trim(); this.documentService.updateBlockProps(this.block.id, { ...this.block.props, filter: filter || undefined } as any); } } break; case 'importCSV': if (this.block.type === 'table') { const pasted = prompt('Paste CSV data (comma-separated):'); if (pasted && pasted.trim()) { const lines = pasted.replace(/\r\n/g, '\n').split('\n').filter(l => l.length > 0); const rows = lines.map((line, ri) => { const cells = this.parseCsvLine(line).map((t, ci) => ({ id: `cell-${ri}-${ci}-${Date.now()}`, text: t })); return { id: `row-${ri}-${Date.now()}`, cells }; }); this.documentService.updateBlockProps(this.block.id, { ...this.block.props, rows } as any); } } break; case 'insertColumn': // For Table blocks - insert column if (this.block.type === 'table') { const { position } = action.payload || {}; const props = this.block.props as any; const rows = [...(props.rows || [])]; rows.forEach((row: any) => { const cells = [...row.cells]; const newCell = { id: `cell-${Date.now()}-${Math.random()}`, text: '' }; if (position === 'left') { cells.unshift(newCell); } else if (position === 'right') { cells.push(newCell); } else { const middle = Math.floor(cells.length / 2); cells.splice(middle, 0, newCell); } row.cells = cells; }); this.documentService.updateBlockProps(this.block.id, { ...this.block.props, rows }); } break; case 'tableHelp': // For Table blocks - open help if (this.block.type === 'table') { window.open('https://docs.example.com/tables', '_blank'); } break; case 'imageAspectRatio': if (this.block.type === 'image') { const { ratio } = action.payload || {}; const patch: any = { ...this.block.props, aspectRatio: ratio }; if (ratio && ratio !== 'free') { patch.height = undefined; // let CSS aspect-ratio compute height from width } this.documentService.updateBlockProps(this.block.id, patch); } break; case 'imageAlignment': if (this.block.type === 'image') { const { alignment } = action.payload || {}; const patch: any = { ...this.block.props, alignment }; if (alignment === 'full') { patch.width = undefined; patch.height = undefined; } this.documentService.updateBlockProps(this.block.id, patch); } break; case 'imageDefaultSize': if (this.block.type === 'image') { this.documentService.updateBlockProps(this.block.id, { ...this.block.props, width: undefined, height: undefined, aspectRatio: 'free' } as any); } break; case 'imageReplace': if (this.block.type === 'image') { const currentSrc = (this.block.props as any)?.src || ''; const src = prompt('Enter new image URL:', currentSrc); if (src !== null && src.trim()) { this.documentService.updateBlockProps(this.block.id, { ...this.block.props, src: src.trim() }); } } break; case 'imageRotate': if (this.block.type === 'image') { const cur = Number((this.block.props as any)?.rotation || 0); const next = (cur + 90) % 360; this.documentService.updateBlockProps(this.block.id, { ...this.block.props, rotation: next }); } break; case 'imageSetPreview': if (this.block.type === 'image') { const src = (this.block.props as any)?.src || ''; if (src) { try { (this.documentService as any).updateDocumentMeta ? (this.documentService as any).updateDocumentMeta({ coverImage: src }) : alert('Set as preview coming soon!'); } catch { alert('Set as preview coming soon!'); } } } break; case 'imageOCR': if (this.block.type === 'image') { console.log('OCR (to be implemented)'); alert('OCR feature coming soon!'); } break; case 'imageDownload': if (this.block.type === 'image') { const src = (this.block.props as any)?.src || ''; if (src) { const a = document.createElement('a'); a.href = src; a.download = src.split('/').pop() || 'image'; document.body.appendChild(a); a.click(); document.body.removeChild(a); } } break; case 'imageViewFull': if (this.block.type === 'image') { const src = (this.block.props as any)?.src || ''; if (src) window.open(src, '_blank', 'noopener'); } break; case 'imageOpenTab': if (this.block.type === 'image') { const src = (this.block.props as any)?.src || ''; if (src) window.open(src, '_blank'); } break; case 'imageInfo': if (this.block.type === 'image') { this.openImageInfo(); } break; case 'comment': this.openComments(); break; } } onBlockUpdate(props: any): void { this.documentService.updateBlockProps(this.block.id, props); } onMetaChange(metaChanges: any): void { // Update block meta (for indent, align, etc.) this.documentService.updateBlock(this.block.id, { meta: { ...this.block.meta, ...metaChanges } }); } onCreateBlockBelow(): void { // Create new paragraph block with empty text after current block const newBlock = this.documentService.createBlock('paragraph', { text: '' }); this.documentService.insertBlock(this.block.id, newBlock); // Focus the new block after a brief delay setTimeout(() => { const newElement = document.querySelector(`[data-block-id="${newBlock.id}"] [contenteditable]`) as HTMLElement; if (newElement) { newElement.focus(); } }, 50); } onCreateToggleBelow(): void { // Create a new Toggle block right below current block const preset = this.documentService.getDefaultProps('toggle'); const newBlock = this.documentService.createBlock('toggle', preset); this.documentService.insertBlock(this.block.id, newBlock); setTimeout(() => { const el = document.querySelector(`[data-block-id="${newBlock.id}"] [contenteditable]`) as HTMLElement | null; el?.focus(); }, 50); } onDeleteBlock(): void { // Delete current block this.documentService.deleteBlock(this.block.id); } // Compute per-block dynamic styles (alignment and indentation) blockStyles(): {[key: string]: any} { const meta: any = this.block.meta || {}; const align = meta.align || 'left'; const indent = Math.max(0, Math.min(8, Number(meta.indent || 0))); return { textAlign: align, marginLeft: `${indent * 16}px` }; } // Comments bubble helpers totalComments(): number { try { return this.comments.count(this.block.id); } catch { return 0; } } openComments(): void { this.closeComments(); const anchor = this.host.nativeElement.querySelector(`[data-block-id="${this.block.id}"]`) as HTMLElement || this.host.nativeElement; // For non-table blocks: place popover under the block, aligned to left 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 = this.block.id; 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(); }); } closeComments(): void { if (this.commentSub) { try { (this.commentSub as any).unsubscribe?.(); } catch {} this.commentSub = null; } if (this.commentRef) { this.commentRef.dispose(); this.commentRef = undefined; } } private openImageInfo(): void { this.closeImageInfo(); const anchor = this.host.nativeElement.querySelector(`[data-block-id="${this.block.id}"]`) as HTMLElement || this.host.nativeElement; const pos = this.overlay.position().flexibleConnectedTo(anchor).withPositions([ { originX: 'center', originY: 'center', overlayX: 'center', overlayY: 'center' } ]); this.imageInfoRef = this.overlay.create({ hasBackdrop: true, backdropClass: 'cdk-overlay-dark-backdrop', positionStrategy: pos, panelClass: 'nimbus-menu-panel' }); const portal = new ComponentPortal(ImageInfoModalComponent); const ref = this.imageInfoRef.attach(portal); const p: any = this.block.props || {}; ref.instance.src = p.src || ''; ref.instance.width = p.width; ref.instance.height = p.height; ref.instance.aspect = p.aspectRatio; ref.instance.alignment = p.alignment; ref.instance.rotation = p.rotation; const sub = ref.instance.close.subscribe(() => this.closeImageInfo()); this.imageInfoRef.backdropClick().subscribe(() => this.closeImageInfo()); this.imageInfoRef.keydownEvents().subscribe((e) => { if ((e as KeyboardEvent).key === 'Escape') this.closeImageInfo(); }); this.commentSub = { unsubscribe: () => { try { sub.unsubscribe(); } catch {} } } as any; } private closeImageInfo(): void { if (this.imageInfoRef) { this.imageInfoRef.dispose(); this.imageInfoRef = undefined; } } private openCaptionModal(): void { this.closeCaptionModal(); const anchor = this.host.nativeElement.querySelector(`[data-block-id="${this.block.id}"]`) as HTMLElement || this.host.nativeElement; const pos = this.overlay.position().flexibleConnectedTo(anchor).withPositions([ { originX: 'center', originY: 'center', overlayX: 'center', overlayY: 'center' } ]); this.imageCaptionRef = this.overlay.create({ hasBackdrop: true, backdropClass: 'cdk-overlay-dark-backdrop', positionStrategy: pos, panelClass: 'nimbus-menu-panel' }); const portal = new ComponentPortal(ImageCaptionModalComponent); const ref = this.imageCaptionRef.attach(portal); const currentCaption = (this.block.props as any)?.caption || ''; ref.instance.caption = currentCaption; ref.instance.title = this.block.type === 'table' ? 'Table caption' : 'Image caption'; const onSave = ref.instance.save.subscribe((caption: string) => { this.documentService.updateBlockProps(this.block.id, { ...this.block.props, caption: (caption || '').trim() || undefined } as any); this.closeCaptionModal(); }); const onCancel = ref.instance.cancel.subscribe(() => this.closeCaptionModal()); this.imageCaptionRef.backdropClick().subscribe(() => this.closeCaptionModal()); this.imageCaptionRef.keydownEvents().subscribe((e) => { if ((e as KeyboardEvent).key === 'Escape') this.closeCaptionModal(); }); this.commentSub = { unsubscribe: () => { try { onSave.unsubscribe(); onCancel.unsubscribe(); } catch {} } } as any; } private closeCaptionModal(): void { if (this.imageCaptionRef) { this.imageCaptionRef.dispose(); this.imageCaptionRef = undefined; } } ngOnDestroy(): void { this.closeComments(); } }