refactor: improve block UI consistency and comment icon styling - Wrapped heading blocks in rounded pill containers matching paragraph block styling - Unified comment icon design across all block types (full-width and columns) with consistent SVG speech bubble - Reduced block wrapper vertical padding (py-1 → py-0.5) and inline toolbar padding for more compact layout - Added right padding to block content (pr-8) to prevent comment icon overlap with colored backgrounds - Excluded paragraph,
1054 lines
39 KiB
TypeScript
1054 lines
39 KiB
TypeScript
import { Component, Input, Output, EventEmitter, inject, ViewChild, signal, effect, ElementRef, HostListener, AfterViewInit, OnDestroy } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { Block, ColumnsProps, ColumnItem } from '../../../core/models/block.model';
|
|
import { DragDropService } from '../../../services/drag-drop.service';
|
|
import { DocumentService } from '../../../services/document.service';
|
|
import { SelectionService } from '../../../services/selection.service';
|
|
import { CommentStoreService } from '../../../services/comment-store.service';
|
|
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
|
|
import { ComponentPortal } from '@angular/cdk/portal';
|
|
|
|
// Import ALL block components for full support
|
|
import { ParagraphBlockComponent } from './paragraph-block.component';
|
|
import { HeadingBlockComponent } from './heading-block.component';
|
|
import { ListItemBlockComponent } from './list-item-block.component';
|
|
import { CodeBlockComponent } from './code-block.component';
|
|
import { QuoteBlockComponent } from './quote-block.component';
|
|
import { ToggleBlockComponent } from './toggle-block.component';
|
|
import { CollapsibleBlockComponent } from './collapsible-block.component';
|
|
import { HintBlockComponent } from './hint-block.component';
|
|
import { ButtonBlockComponent } from './button-block.component';
|
|
import { ImageBlockComponent } from './image-block.component';
|
|
import { FileBlockComponent } from './file-block.component';
|
|
import { TableBlockComponent } from './table-block.component';
|
|
import { StepsBlockComponent } from './steps-block.component';
|
|
import { LineBlockComponent } from './line-block.component';
|
|
import { DropdownBlockComponent } from './dropdown-block.component';
|
|
import { ProgressBlockComponent } from './progress-block.component';
|
|
import { KanbanBlockComponent } from './kanban-block.component';
|
|
import { EmbedBlockComponent } from './embed-block.component';
|
|
import { OutlineBlockComponent } from './outline-block.component';
|
|
import { ListBlockComponent } from './list-block.component';
|
|
import { BlockCommentComposerComponent } from '../../comment/block-comment-composer.component';
|
|
import { BlockContextMenuComponent } from '../block-context-menu.component';
|
|
import { DragDropFilesDirective } from '../../../../blocks/file/directives/drag-drop-files.directive';
|
|
import { PaletteItem } from '../../../core/constants/palette-items';
|
|
|
|
@Component({
|
|
selector: 'app-columns-block',
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
ParagraphBlockComponent,
|
|
HeadingBlockComponent,
|
|
ListItemBlockComponent,
|
|
CodeBlockComponent,
|
|
QuoteBlockComponent,
|
|
ToggleBlockComponent,
|
|
CollapsibleBlockComponent,
|
|
HintBlockComponent,
|
|
ButtonBlockComponent,
|
|
ImageBlockComponent,
|
|
FileBlockComponent,
|
|
TableBlockComponent,
|
|
StepsBlockComponent,
|
|
LineBlockComponent,
|
|
DropdownBlockComponent,
|
|
ProgressBlockComponent,
|
|
KanbanBlockComponent,
|
|
EmbedBlockComponent,
|
|
OutlineBlockComponent,
|
|
ListBlockComponent,
|
|
BlockContextMenuComponent,
|
|
DragDropFilesDirective
|
|
],
|
|
template: `
|
|
<div class="flex gap-6 w-full relative" #columnsContainer [appDragDropFiles]="{ type: 'columns', columnsBlockId: block.id }">
|
|
@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 opacity-0 group-hover/block:opacity-100 transition-opacity absolute -left-5 top-1/2 -translate-y-1/2 w-5 h-5 flex items-center justify-center rounded-full bg-surface2/80 hover:bg-surface2 dark:bg-gray-700/80 dark:hover:bg-gray-700 shadow-sm z-10"
|
|
title="Drag to move or click for menu"
|
|
(click)="openMenu(block, $event)"
|
|
(mousedown)="onDragStart(block, colIndex, blockIndex, $event)"
|
|
>
|
|
<svg class="w-2 h-2 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>
|
|
|
|
|
|
|
|
<!-- Render block with background color support -->
|
|
<div
|
|
class="relative px-1.5 py-1 pr-8 rounded transition-colors"
|
|
[style.background-color]="getBlockBgColor(block)"
|
|
[ngStyle]="getBlockStyles(block)"
|
|
>
|
|
<!-- Comment icon inside the block, aligned to the right (same size/position as full-width blocks) -->
|
|
<ng-container>
|
|
<!-- Filled comment icon with count -->
|
|
<button *ngIf="getBlockCommentCount(block.id) > 0" class="absolute top-1/2 -translate-y-1/2 right-2 w-8 h-8 flex items-center justify-center z-20"
|
|
title="View comments" (click)="openComments(block.id)">
|
|
<svg viewBox="0 0 64 64" class="w-5 h-5 text-gray-100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path
|
|
d="M32 8C19.85 8 10 17.85 10 30c0 4.2 1.2 8.3 3.3 11.7L10 56l13.1-4.4C25.9 53.2 28.9 54 32 54c12.15 0 22-9.85 22-22S44.15 8 32 8Z"
|
|
stroke="currentColor"
|
|
stroke-width="4"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
/>
|
|
<circle cx="24" cy="30" r="3" fill="currentColor" />
|
|
<circle cx="32" cy="30" r="3" fill="currentColor" />
|
|
<circle cx="40" cy="30" r="3" fill="currentColor" />
|
|
</svg>
|
|
<span class="absolute text-[11px] font-semibold text-black">{{ getBlockCommentCount(block.id) }}</span>
|
|
</button>
|
|
<!-- Outline comment icon when there are no comments -->
|
|
<button *ngIf="getBlockCommentCount(block.id) === 0" class="absolute top-1/2 -translate-y-1/2 right-2 w-8 h-8 opacity-0 group-hover/block:opacity-100 transition-opacity flex items-center justify-center z-20"
|
|
title="Add a comment" (click)="openComments(block.id)">
|
|
<svg viewBox="0 0 64 64" class="w-5 h-5 text-gray-100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path
|
|
d="M32 8C19.85 8 10 17.85 10 30c0 4.2 1.2 8.3 3.3 11.7L10 56l13.1-4.4C25.9 53.2 28.9 54 32 54c12.15 0 22-9.85 22-22S44.15 8 32 8Z"
|
|
stroke="currentColor"
|
|
stroke-width="4"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
/>
|
|
<circle cx="24" cy="30" r="3" fill="currentColor" />
|
|
<circle cx="32" cy="30" r="3" fill="currentColor" />
|
|
<circle cx="40" cy="30" r="3" fill="currentColor" />
|
|
</svg>
|
|
</button>
|
|
</ng-container>
|
|
@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"
|
|
[inColumn]="true"
|
|
(update)="onBlockUpdate($event, block.id)"
|
|
(metaChange)="onBlockMetaChange($event, block.id)"
|
|
(createBlock)="onBlockCreateBelow(block.id, colIndex, blockIndex)"
|
|
(deleteBlock)="onBlockDelete(block.id)"
|
|
(convertRequested)="onConvertRequested($event, block.id)"
|
|
/>
|
|
}
|
|
@case ('list-item') {
|
|
<app-list-item-block
|
|
[block]="block"
|
|
(update)="onBlockUpdate($event, block.id)"
|
|
(createBelow)="onListItemCreateBelow(block.id, colIndex, blockIndex)"
|
|
/>
|
|
}
|
|
@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 ('collapsible') {
|
|
<app-collapsible-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>
|
|
}
|
|
@if (props.columns.length > 1) {
|
|
@for (i of resizerIndexes; track i) {
|
|
<div
|
|
class="col-resizer absolute top-0 bottom-0 w-2 cursor-col-resize"
|
|
[style.left.px]="resizerPositions()[i]"
|
|
(mousedown)="onResizerDown(i, $event)"
|
|
></div>
|
|
}
|
|
}
|
|
</div>
|
|
|
|
<!-- 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;
|
|
}
|
|
|
|
.col-resizer {
|
|
/* visually subtle hit zone */
|
|
transform: translateX(-4px);
|
|
}
|
|
.col-resizer:hover {
|
|
background: rgba(56, 189, 248, 0.15);
|
|
}
|
|
`]
|
|
})
|
|
export class ColumnsBlockComponent implements AfterViewInit, OnDestroy {
|
|
private readonly dragDrop = inject(DragDropService);
|
|
private readonly commentsStore = inject(CommentStoreService);
|
|
private readonly documentService = inject(DocumentService);
|
|
private readonly selectionService = inject(SelectionService);
|
|
private readonly overlay = inject(Overlay);
|
|
|
|
@Input({ required: true }) block!: Block<ColumnsProps>;
|
|
@Output() update = new EventEmitter<ColumnsProps>();
|
|
@ViewChild('columnsContainer', { static: true }) columnsContainerRef!: ElementRef<HTMLElement>;
|
|
|
|
// 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);
|
|
|
|
// Resize state
|
|
private readonly MIN_COL_WIDTH = 10; // percent
|
|
private resizeState: { active: boolean; index: number; startX: number; containerWidth: number; leftStart: number; rightStart: number } | null = null;
|
|
resizerPositions = signal<number[]>([]);
|
|
|
|
// Comments popover state
|
|
private commentRef?: OverlayRef;
|
|
private commentSub: { unsubscribe(): void } | null = null;
|
|
|
|
get props(): ColumnsProps {
|
|
return this.block.props;
|
|
}
|
|
|
|
getBlockCommentCount(blockId: string): number {
|
|
try {
|
|
return this.commentsStore.count(blockId);
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
onConvertRequested(item: PaletteItem, blockId: string): void {
|
|
// Map palette selection to block type and optional preset
|
|
let newType = item.type as any;
|
|
let preset: any = undefined;
|
|
// Headings levels
|
|
if (item.id === 'heading-1') preset = { level: 1 };
|
|
else if (item.id === 'heading-2') preset = { level: 2 };
|
|
else if (item.id === 'heading-3') preset = { level: 3 };
|
|
|
|
// Lists -> list-item presets
|
|
if (item.id === 'checkbox-list') { newType = 'list-item'; preset = { kind: 'check', checked: false }; }
|
|
else if (item.id === 'numbered-list') { newType = 'list-item'; preset = { kind: 'numbered', number: 1 }; }
|
|
else if (item.id === 'bullet-list') { newType = 'list-item'; preset = { kind: 'bullet' }; }
|
|
|
|
// Collapsible variants
|
|
if (item.id === 'collapsible-large') { newType = 'collapsible'; preset = { level: 1 }; }
|
|
else if (item.id === 'collapsible-medium') { newType = 'collapsible'; preset = { level: 2 }; }
|
|
else if (item.id === 'collapsible-small') { newType = 'collapsible'; preset = { level: 3 }; }
|
|
|
|
// Apply conversion within columns
|
|
this.convertBlockInColumns(blockId, newType, preset);
|
|
}
|
|
|
|
openComments(blockId: string): void {
|
|
this.closeComments();
|
|
const container = this.columnsContainerRef?.nativeElement;
|
|
const anchor = (container?.querySelector(`[data-block-id="${blockId}"]`) as HTMLElement) || container;
|
|
if (!anchor) return;
|
|
|
|
const pos = this.overlay.position().flexibleConnectedTo(anchor).withPositions([
|
|
{ originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 8 },
|
|
{ originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -8 }
|
|
]);
|
|
|
|
this.commentRef = this.overlay.create({
|
|
hasBackdrop: true,
|
|
backdropClass: 'cdk-overlay-transparent-backdrop',
|
|
positionStrategy: pos,
|
|
panelClass: 'nimbus-menu-panel'
|
|
});
|
|
|
|
const portal = new ComponentPortal(BlockCommentComposerComponent);
|
|
const ref = this.commentRef.attach(portal);
|
|
ref.instance.blockId = blockId;
|
|
this.commentSub = ref.instance.close.subscribe(() => this.closeComments());
|
|
this.commentRef.backdropClick().subscribe(() => this.closeComments());
|
|
this.commentRef.keydownEvents().subscribe((e) => { if ((e as KeyboardEvent).key === 'Escape') this.closeComments(); });
|
|
}
|
|
|
|
private closeComments(): void {
|
|
if (this.commentSub) {
|
|
try { this.commentSub.unsubscribe(); } catch {}
|
|
this.commentSub = null;
|
|
}
|
|
if (this.commentRef) {
|
|
this.commentRef.dispose();
|
|
this.commentRef = undefined;
|
|
}
|
|
}
|
|
|
|
onBlockMetaChange(metaChanges: any, blockId: string): void {
|
|
// Update meta for a specific block within columns
|
|
const updatedColumns = this.props.columns.map(column => ({
|
|
...column,
|
|
blocks: column.blocks.map(b => {
|
|
if (b.id === blockId) {
|
|
return { ...b, meta: { ...b.meta, ...metaChanges } };
|
|
}
|
|
return b;
|
|
})
|
|
}));
|
|
|
|
this.update.emit({ columns: updatedColumns });
|
|
}
|
|
|
|
onBlockCreateBelow(blockId: string, columnIndex: number, blockIndex: number): void {
|
|
// Create a new paragraph block after the specified block in the same column
|
|
const updatedColumns = this.props.columns.map((column, colIdx) => {
|
|
if (colIdx === columnIndex) {
|
|
const newBlock = {
|
|
id: this.generateId(),
|
|
type: 'paragraph' as any,
|
|
props: { text: '' },
|
|
children: []
|
|
};
|
|
|
|
const newBlocks = [...column.blocks];
|
|
newBlocks.splice(blockIndex + 1, 0, newBlock);
|
|
|
|
return { ...column, blocks: newBlocks };
|
|
}
|
|
return column;
|
|
});
|
|
|
|
this.update.emit({ columns: updatedColumns });
|
|
|
|
// Focus the new block after a brief delay
|
|
setTimeout(() => {
|
|
const newElement = document.querySelector(`[data-block-id="${updatedColumns[columnIndex].blocks[blockIndex + 1].id}"] [contenteditable]`) as HTMLElement;
|
|
if (newElement) {
|
|
newElement.focus();
|
|
}
|
|
}, 50);
|
|
}
|
|
|
|
onListItemCreateBelow(blockId: string, columnIndex: number, blockIndex: number): void {
|
|
// Insert a new list-item block directly after the given list-item within the same column
|
|
const updatedColumns = this.props.columns.map((column, colIdx) => {
|
|
if (colIdx !== columnIndex) return column;
|
|
|
|
const blocks = [...column.blocks];
|
|
const existing = blocks[blockIndex];
|
|
if (!existing || existing.type !== 'list-item') {
|
|
return column;
|
|
}
|
|
|
|
const props: any = existing.props || {};
|
|
const newProps: any = {
|
|
kind: props.kind,
|
|
text: '',
|
|
checked: props.kind === 'check' ? false : undefined,
|
|
indent: props.indent || 0,
|
|
align: props.align
|
|
};
|
|
|
|
if (props.kind === 'numbered' && typeof props.number === 'number') {
|
|
newProps.number = props.number + 1;
|
|
}
|
|
|
|
const newBlock = this.documentService.createBlock('list-item' as any, newProps);
|
|
const newBlocks = [...blocks];
|
|
newBlocks.splice(blockIndex + 1, 0, newBlock);
|
|
|
|
return { ...column, blocks: newBlocks };
|
|
});
|
|
|
|
this.update.emit({ columns: updatedColumns });
|
|
|
|
// Focus the new list-item input once it is rendered
|
|
const newId = updatedColumns[columnIndex]?.blocks[blockIndex + 1]?.id;
|
|
if (!newId) return;
|
|
setTimeout(() => {
|
|
const input = document.querySelector(
|
|
`[data-block-id="${newId}"] input[type="text"]`
|
|
) as HTMLInputElement | null;
|
|
if (input) {
|
|
input.focus();
|
|
const len = input.value.length;
|
|
input.setSelectionRange(len, len);
|
|
}
|
|
}, 50);
|
|
}
|
|
|
|
onBlockDelete(blockId: string): void {
|
|
// Delete a specific block from columns
|
|
const updatedColumns = this.props.columns.map(column => ({
|
|
...column,
|
|
blocks: column.blocks.filter(b => b.id !== blockId)
|
|
}));
|
|
|
|
this.update.emit({ columns: updatedColumns });
|
|
}
|
|
|
|
onInsertImagesBelowInColumn(urls: string[], columnIndex: number, blockIndex: number): void {
|
|
if (!urls || !urls.length) return;
|
|
const updatedColumns = this.props.columns.map((column, idx) => {
|
|
if (idx !== columnIndex) return column;
|
|
const newBlocks = [...column.blocks];
|
|
let insertAt = blockIndex + 1;
|
|
for (const url of urls) {
|
|
const newBlock = this.documentService.createBlock('image', { src: url, alt: '' });
|
|
newBlocks.splice(insertAt, 0, newBlock);
|
|
insertAt++;
|
|
}
|
|
return { ...column, blocks: newBlocks };
|
|
});
|
|
this.update.emit({ columns: updatedColumns });
|
|
}
|
|
|
|
openMenu(block: Block, event: MouseEvent): void {
|
|
event.stopPropagation();
|
|
const rect = (event.target as HTMLElement).getBoundingClientRect();
|
|
this.selectedBlock.set(block);
|
|
this.menuVisible.set(true);
|
|
this.menuPosition.set({
|
|
x: rect.left,
|
|
y: rect.bottom + 5
|
|
});
|
|
}
|
|
|
|
closeMenu(): void {
|
|
this.menuVisible.set(false);
|
|
this.selectedBlock.set(null);
|
|
}
|
|
|
|
createDummyBlock(): Block {
|
|
// Return a dummy block when selectedBlock is null (to satisfy type requirements)
|
|
return {
|
|
id: '',
|
|
type: 'paragraph',
|
|
props: { text: '' },
|
|
children: []
|
|
};
|
|
}
|
|
|
|
onMenuAction(action: any): void {
|
|
const block = this.selectedBlock();
|
|
if (!block) return;
|
|
|
|
// Handle comment action
|
|
if (action.type === 'comment') {
|
|
this.openComments(block.id);
|
|
}
|
|
|
|
// Handle align action
|
|
if (action.type === 'align') {
|
|
const { alignment } = action.payload || {};
|
|
if (alignment) {
|
|
this.alignBlockInColumns(block.id, alignment);
|
|
}
|
|
}
|
|
|
|
// Handle indent action
|
|
if (action.type === 'indent') {
|
|
const { delta } = action.payload || {};
|
|
if (delta !== undefined) {
|
|
this.indentBlockInColumns(block.id, delta);
|
|
}
|
|
}
|
|
|
|
// Handle background action
|
|
if (action.type === 'background') {
|
|
const { color } = action.payload || {};
|
|
this.backgroundColorBlockInColumns(block.id, color);
|
|
}
|
|
|
|
// Handle convert action
|
|
if (action.type === 'convert') {
|
|
// Convert the block type within the columns
|
|
const { type, preset } = action.payload || {};
|
|
if (type) {
|
|
this.convertBlockInColumns(block.id, type, preset);
|
|
}
|
|
}
|
|
|
|
// Handle delete action
|
|
if (action.type === 'delete') {
|
|
this.deleteBlockFromColumns(block.id);
|
|
}
|
|
|
|
// Handle duplicate action
|
|
if (action.type === 'duplicate') {
|
|
this.duplicateBlockInColumns(block.id);
|
|
}
|
|
|
|
// Handle add action inside columns
|
|
if (action.type === 'add') {
|
|
const pos = (action.payload || {}).position as 'above' | 'below' | 'left' | 'right' | undefined;
|
|
if (pos) {
|
|
if (pos === 'above' || pos === 'below') {
|
|
// Insert paragraph in same column before/after the selected block
|
|
const updatedColumns = this.props.columns.map((column) => {
|
|
const idx = column.blocks.findIndex(b => b.id === block.id);
|
|
if (idx === -1) return column;
|
|
const newBlock = this.documentService.createBlock('paragraph', { text: '' });
|
|
const newBlocks = [...column.blocks];
|
|
const insertAt = pos === 'above' ? idx : idx + 1;
|
|
newBlocks.splice(insertAt, 0, newBlock);
|
|
return { ...column, blocks: newBlocks };
|
|
});
|
|
this.update.emit({ columns: updatedColumns });
|
|
this.closeMenu();
|
|
return;
|
|
}
|
|
if (pos === 'left' || pos === 'right') {
|
|
// Add a new column on left/right with a new paragraph
|
|
const newParagraph = this.documentService.createBlock('paragraph', { text: '' });
|
|
const cols = [...this.props.columns];
|
|
const newWidth = 100 / (cols.length + 1);
|
|
const resized = cols.map(col => ({ ...col, width: newWidth }));
|
|
const insertion = { id: this.generateId(), blocks: [newParagraph], width: newWidth } as any;
|
|
const targetColIndex = resized.findIndex(c => c.blocks.some(b => b.id === block.id));
|
|
if (targetColIndex >= 0) {
|
|
if (pos === 'left') resized.splice(targetColIndex, 0, insertion);
|
|
else resized.splice(targetColIndex + 1, 0, insertion);
|
|
this.update.emit({ columns: resized });
|
|
this.closeMenu();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
this.closeMenu();
|
|
}
|
|
|
|
private alignBlockInColumns(blockId: string, alignment: string): void {
|
|
const updatedColumns = this.props.columns.map(column => ({
|
|
...column,
|
|
blocks: column.blocks.map(b => {
|
|
if (b.id === blockId) {
|
|
// For list-item blocks, update props.align
|
|
if (b.type === 'list-item') {
|
|
return { ...b, props: { ...b.props, align: alignment as any } };
|
|
} else {
|
|
// For other blocks, update meta.align
|
|
const current = b.meta || {};
|
|
return { ...b, meta: { ...current, align: alignment as any } };
|
|
}
|
|
}
|
|
return b;
|
|
})
|
|
}));
|
|
|
|
this.update.emit({ columns: updatedColumns });
|
|
}
|
|
|
|
private indentBlockInColumns(blockId: string, delta: number): void {
|
|
const updatedColumns = this.props.columns.map(column => ({
|
|
...column,
|
|
blocks: column.blocks.map(b => {
|
|
if (b.id === blockId) {
|
|
// For list-item blocks, update props.indent
|
|
if (b.type === 'list-item') {
|
|
const cur = Number((b.props as any).indent || 0);
|
|
const next = Math.max(0, Math.min(7, cur + delta));
|
|
return { ...b, props: { ...b.props, indent: next } };
|
|
} else {
|
|
// For other blocks, update meta.indent
|
|
const current = (b.meta as any) || {};
|
|
const cur = Number(current.indent || 0);
|
|
const next = Math.max(0, Math.min(8, cur + delta));
|
|
return { ...b, meta: { ...current, indent: next } };
|
|
}
|
|
}
|
|
return b;
|
|
})
|
|
}));
|
|
|
|
this.update.emit({ columns: updatedColumns });
|
|
}
|
|
|
|
private backgroundColorBlockInColumns(blockId: string, color: string): void {
|
|
const updatedColumns = this.props.columns.map(column => ({
|
|
...column,
|
|
blocks: column.blocks.map(b => {
|
|
if (b.id === blockId) {
|
|
return {
|
|
...b,
|
|
meta: {
|
|
...b.meta,
|
|
bgColor: color === 'transparent' ? undefined : color
|
|
}
|
|
};
|
|
}
|
|
return b;
|
|
})
|
|
}));
|
|
|
|
this.update.emit({ columns: updatedColumns });
|
|
}
|
|
|
|
private convertBlockInColumns(blockId: string, newType: string, preset: any): void {
|
|
const updatedColumns = this.props.columns.map(column => ({
|
|
...column,
|
|
blocks: column.blocks.map(b => {
|
|
if (b.id === blockId) {
|
|
// Convert block type while preserving text content
|
|
const text = this.getBlockText(b);
|
|
let newProps: any = { text };
|
|
|
|
// Apply preset if provided
|
|
if (preset) {
|
|
newProps = { ...newProps, ...preset };
|
|
}
|
|
|
|
return { ...b, type: newType as any, props: newProps };
|
|
}
|
|
return b;
|
|
})
|
|
}));
|
|
|
|
this.update.emit({ columns: updatedColumns });
|
|
}
|
|
|
|
private deleteBlockFromColumns(blockId: string): void {
|
|
let updatedColumns = this.props.columns.map(column => ({
|
|
...column,
|
|
blocks: column.blocks.filter(b => b.id !== blockId)
|
|
}));
|
|
|
|
// Remove empty columns
|
|
updatedColumns = updatedColumns.filter(col => col.blocks.length > 0);
|
|
|
|
// If only one column remains, we could convert back to normal blocks
|
|
// But for now, we'll keep the columns structure and redistribute widths
|
|
|
|
// Redistribute widths equally
|
|
if (updatedColumns.length > 0) {
|
|
const newWidth = 100 / updatedColumns.length;
|
|
updatedColumns = updatedColumns.map(col => ({
|
|
...col,
|
|
width: newWidth
|
|
}));
|
|
}
|
|
|
|
this.update.emit({ columns: updatedColumns });
|
|
}
|
|
|
|
private duplicateBlockInColumns(blockId: string): void {
|
|
const updatedColumns = this.props.columns.map(column => {
|
|
const blockIndex = column.blocks.findIndex(b => b.id === blockId);
|
|
if (blockIndex >= 0) {
|
|
const originalBlock = column.blocks[blockIndex];
|
|
const duplicatedBlock = {
|
|
...JSON.parse(JSON.stringify(originalBlock)),
|
|
id: this.generateId()
|
|
};
|
|
|
|
const newBlocks = [...column.blocks];
|
|
newBlocks.splice(blockIndex + 1, 0, duplicatedBlock);
|
|
|
|
return { ...column, blocks: newBlocks };
|
|
}
|
|
return column;
|
|
});
|
|
|
|
this.update.emit({ columns: updatedColumns });
|
|
}
|
|
|
|
private getBlockText(block: Block): string {
|
|
if ('text' in block.props) {
|
|
return (block.props as any).text || '';
|
|
}
|
|
return '';
|
|
}
|
|
|
|
getBlockBgColor(block: Block): string | undefined {
|
|
// Paragraph, heading and list(-item) blocks in columns should not have a full-width
|
|
// background; their inner editable/input pill handles the colored capsule.
|
|
if (block.type === 'paragraph' || block.type === 'heading' || block.type === 'list' || block.type === 'list-item') {
|
|
return undefined;
|
|
}
|
|
const bgColor = (block.meta as any)?.bgColor;
|
|
return bgColor && bgColor !== 'transparent' ? bgColor : undefined;
|
|
}
|
|
|
|
getBlockStyles(block: Block): {[key: string]: any} {
|
|
const meta: any = block.meta || {};
|
|
const props: any = block.props || {};
|
|
|
|
// For list-item blocks, check props.align and props.indent
|
|
// For other blocks, check meta.align and meta.indent
|
|
const align = block.type === 'list-item' ? (props.align || 'left') : (meta.align || 'left');
|
|
const indent = block.type === 'list-item'
|
|
? Math.max(0, Math.min(7, Number(props.indent || 0)))
|
|
: Math.max(0, Math.min(8, Number(meta.indent || 0)));
|
|
|
|
return {
|
|
textAlign: align,
|
|
marginLeft: `${indent * 16}px`
|
|
};
|
|
}
|
|
|
|
private generateId(): string {
|
|
return Math.random().toString(36).substring(2, 11);
|
|
}
|
|
|
|
ngAfterViewInit(): void {
|
|
setTimeout(() => this.computeResizerPositions(), 0);
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
this.closeComments();
|
|
}
|
|
|
|
@HostListener('window:resize')
|
|
onWindowResize(): void {
|
|
this.computeResizerPositions();
|
|
}
|
|
|
|
onDragStart(block: Block, columnIndex: number, blockIndex: number, event: MouseEvent): void {
|
|
event.stopPropagation();
|
|
|
|
// Store drag source info
|
|
this.draggedBlock = { block, columnIndex, blockIndex };
|
|
|
|
// Use DragDropService for unified drag system
|
|
// We use a virtual index based on position in the columns structure
|
|
const virtualIndex = this.getVirtualIndex(columnIndex, blockIndex);
|
|
this.dragDrop.beginDrag(block.id, virtualIndex, event.clientY);
|
|
|
|
const onMove = (e: MouseEvent) => {
|
|
// Update DragDropService pointer for visual indicators
|
|
this.dragDrop.updatePointer(e.clientY, e.clientX);
|
|
};
|
|
|
|
const onUp = (e: MouseEvent) => {
|
|
document.removeEventListener('mousemove', onMove);
|
|
document.removeEventListener('mouseup', onUp);
|
|
|
|
const { moved } = this.dragDrop.endDrag();
|
|
|
|
if (!moved || !this.draggedBlock) {
|
|
this.draggedBlock = null;
|
|
return;
|
|
}
|
|
|
|
// Determine drop target
|
|
const target = document.elementFromPoint(e.clientX, e.clientY);
|
|
if (!target) {
|
|
this.draggedBlock = null;
|
|
return;
|
|
}
|
|
|
|
// Check if dropping on another block in columns
|
|
const blockEl = target.closest('[data-block-id]');
|
|
if (blockEl) {
|
|
const targetColIndex = parseInt(blockEl.getAttribute('data-column-index') || '0');
|
|
const targetBlockIndex = parseInt(blockEl.getAttribute('data-block-index') || '0');
|
|
|
|
// Move within columns
|
|
this.moveBlock(
|
|
this.draggedBlock.columnIndex,
|
|
this.draggedBlock.blockIndex,
|
|
targetColIndex,
|
|
targetBlockIndex
|
|
);
|
|
} else {
|
|
// Check if dropping outside columns (convert to full-width block)
|
|
const isOutsideColumns = !target.closest('[data-column-id]');
|
|
if (isOutsideColumns) {
|
|
this.convertToFullWidth(this.draggedBlock.columnIndex, this.draggedBlock.blockIndex);
|
|
}
|
|
}
|
|
|
|
this.draggedBlock = null;
|
|
};
|
|
|
|
document.addEventListener('mousemove', onMove);
|
|
document.addEventListener('mouseup', onUp);
|
|
}
|
|
|
|
private getVirtualIndex(colIndex: number, blockIndex: number): number {
|
|
// Calculate a virtual index for DragDropService
|
|
// This helps with visual indicator positioning
|
|
let count = 0;
|
|
const props = this.block.props as ColumnsProps;
|
|
for (let i = 0; i < colIndex; i++) {
|
|
count += props.columns[i]?.blocks.length || 0;
|
|
}
|
|
return count + blockIndex;
|
|
}
|
|
|
|
private convertToFullWidth(colIndex: number, blockIndex: number): void {
|
|
const props = this.block.props as ColumnsProps;
|
|
const column = props.columns[colIndex];
|
|
if (!column) return;
|
|
|
|
const blockToMove = column.blocks[blockIndex];
|
|
if (!blockToMove) return;
|
|
|
|
// Insert block as full-width after the columns block
|
|
const blockCopy = JSON.parse(JSON.stringify(blockToMove));
|
|
this.documentService.insertBlock(this.block.id, blockCopy);
|
|
|
|
// Remove from column
|
|
const updatedColumns = [...props.columns];
|
|
updatedColumns[colIndex] = {
|
|
...column,
|
|
blocks: column.blocks.filter((_, i) => i !== blockIndex)
|
|
};
|
|
|
|
// Remove empty columns and redistribute widths
|
|
const nonEmptyColumns = updatedColumns.filter(col => col.blocks.length > 0);
|
|
|
|
if (nonEmptyColumns.length === 0) {
|
|
// Delete the entire columns block if no blocks left
|
|
this.documentService.deleteBlock(this.block.id);
|
|
} else if (nonEmptyColumns.length === 1) {
|
|
// Convert single column back to full-width blocks
|
|
const remainingBlocks = nonEmptyColumns[0].blocks;
|
|
remainingBlocks.forEach(b => {
|
|
const copy = JSON.parse(JSON.stringify(b));
|
|
this.documentService.insertBlock(this.block.id, copy);
|
|
});
|
|
this.documentService.deleteBlock(this.block.id);
|
|
} else {
|
|
// Update columns with redistributed widths
|
|
const newWidth = 100 / nonEmptyColumns.length;
|
|
const redistributed = nonEmptyColumns.map(col => ({ ...col, width: newWidth }));
|
|
this.update.emit({ columns: redistributed });
|
|
}
|
|
|
|
// Select the moved block
|
|
this.selectionService.setActive(blockCopy.id);
|
|
}
|
|
|
|
private moveBlock(fromCol: number, fromBlock: number, toCol: number, toBlock: number): void {
|
|
if (fromCol === toCol && fromBlock === toBlock) return;
|
|
|
|
const columns = [...this.props.columns];
|
|
|
|
// Get the block to move
|
|
const blockToMove = columns[fromCol].blocks[fromBlock];
|
|
if (!blockToMove) return;
|
|
|
|
// Remove from source
|
|
columns[fromCol] = {
|
|
...columns[fromCol],
|
|
blocks: columns[fromCol].blocks.filter((_, i) => i !== fromBlock)
|
|
};
|
|
|
|
// Adjust target index if moving within same column
|
|
let actualToBlock = toBlock;
|
|
if (fromCol === toCol && fromBlock < toBlock) {
|
|
actualToBlock--;
|
|
}
|
|
|
|
// Insert at target
|
|
const newBlocks = [...columns[toCol].blocks];
|
|
newBlocks.splice(actualToBlock, 0, blockToMove);
|
|
columns[toCol] = {
|
|
...columns[toCol],
|
|
blocks: newBlocks
|
|
};
|
|
|
|
// Remove empty columns and redistribute widths
|
|
const nonEmptyColumns = columns.filter(col => col.blocks.length > 0);
|
|
if (nonEmptyColumns.length > 0) {
|
|
const newWidth = 100 / nonEmptyColumns.length;
|
|
const redistributed = nonEmptyColumns.map(col => ({
|
|
...col,
|
|
width: newWidth
|
|
}));
|
|
|
|
this.update.emit({ columns: redistributed });
|
|
}
|
|
}
|
|
|
|
onBlockUpdate(updatedProps: any, blockId: string): void {
|
|
// Find the block in columns and update it
|
|
const updatedColumns = this.props.columns.map(column => ({
|
|
...column,
|
|
blocks: column.blocks.map(b =>
|
|
b.id === blockId ? { ...b, props: { ...b.props, ...updatedProps } } : b
|
|
)
|
|
}));
|
|
|
|
// Emit the updated columns
|
|
this.update.emit({ columns: updatedColumns });
|
|
}
|
|
|
|
// Resizer helpers
|
|
get resizerIndexes(): number[] {
|
|
const n = (this.props.columns?.length || 0) - 1;
|
|
if (n <= 0) return [];
|
|
return Array.from({ length: n }, (_, i) => i);
|
|
}
|
|
|
|
private computeResizerPositions(): void {
|
|
try {
|
|
const container = this.columnsContainerRef?.nativeElement;
|
|
if (!container) return;
|
|
const cols = Array.from(container.querySelectorAll<HTMLElement>('[data-column-index]'));
|
|
const crect = container.getBoundingClientRect();
|
|
const positions: number[] = [];
|
|
for (let i = 0; i < cols.length - 1; i++) {
|
|
const r = cols[i].getBoundingClientRect();
|
|
positions.push(r.right - crect.left);
|
|
}
|
|
this.resizerPositions.set(positions);
|
|
} catch {}
|
|
}
|
|
|
|
onResizerDown(i: number, event: MouseEvent): void {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const container = this.columnsContainerRef?.nativeElement;
|
|
if (!container) return;
|
|
const crect = container.getBoundingClientRect();
|
|
const cols = this.props.columns || [];
|
|
const left = cols[i];
|
|
const right = cols[i + 1];
|
|
const leftStart = Number(left?.width ?? (100 / cols.length));
|
|
const rightStart = Number(right?.width ?? (100 / cols.length));
|
|
this.resizeState = {
|
|
active: true,
|
|
index: i,
|
|
startX: event.clientX,
|
|
containerWidth: Math.max(1, crect.width),
|
|
leftStart,
|
|
rightStart
|
|
};
|
|
|
|
const onMove = (e: MouseEvent) => {
|
|
if (!this.resizeState) return;
|
|
const dx = e.clientX - this.resizeState.startX;
|
|
const dxPct = (dx / this.resizeState.containerWidth) * 100;
|
|
const sum = this.resizeState.leftStart + this.resizeState.rightStart;
|
|
let newLeft = this.resizeState.leftStart + dxPct;
|
|
// Clamp with min constraints
|
|
const min = this.MIN_COL_WIDTH;
|
|
newLeft = Math.max(min, Math.min(sum - min, newLeft));
|
|
const newRight = sum - newLeft;
|
|
const updated = (this.props.columns || []).map((col, idx) => {
|
|
if (idx === i) return { ...col, width: newLeft } as ColumnItem;
|
|
if (idx === i + 1) return { ...col, width: newRight } as ColumnItem;
|
|
return col;
|
|
});
|
|
this.update.emit({ columns: updated });
|
|
// Recompute positions on next tick to reflect DOM changes
|
|
setTimeout(() => this.computeResizerPositions(), 0);
|
|
};
|
|
|
|
const onUp = () => {
|
|
document.removeEventListener('mousemove', onMove);
|
|
document.removeEventListener('mouseup', onUp);
|
|
this.resizeState = null;
|
|
this.computeResizerPositions();
|
|
};
|
|
|
|
document.addEventListener('mousemove', onMove);
|
|
document.addEventListener('mouseup', onUp, { once: true });
|
|
}
|
|
}
|