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(); }
}