docs: remove outdated implementation documentation files - Deleted AI_TOOLS_IMPLEMENTATION.md (296 lines) - outdated AI tools integration guide - Deleted ALIGN_INDENT_COLUMNS_FIX.md (557 lines) - obsolete column alignment fix documentation - Deleted BLOCK_COMMENTS_IMPLEMENTATION.md (400 lines) - superseded block comments implementation notes - Deleted DRAG_DROP_COLUMNS_IMPLEMENTATION.md (500 lines) - outdated drag-and-drop columns guide - Deleted INLINE_TOOLBAR_IMPLEMENTATION.md (350 lines) - obsol
1115 lines
47 KiB
TypeScript
1115 lines
47 KiB
TypeScript
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';
|
|
|
|
/**
|
|
* 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,
|
|
OverlayModule,
|
|
PortalModule
|
|
],
|
|
template: `
|
|
<div
|
|
class="block-wrapper group relative"
|
|
[attr.data-block-id]="block.id"
|
|
[attr.data-block-index]="index"
|
|
[class.active]="isActive()"
|
|
[class.locked]="block.meta?.locked"
|
|
[style.background-color]="(block.type === 'list-item' || block.type === 'file' || block.type === 'paragraph' || block.type === 'list' || block.type === 'heading' || block.type === 'link') ? null : block.meta?.bgColor"
|
|
[ngStyle]="blockStyles()"
|
|
(click)="onBlockClick($event)"
|
|
>
|
|
<!-- Light target highlight for UX clarity -->
|
|
@if (dragDrop.dragging() && shouldHighlight()) {
|
|
<div class="absolute inset-0 rounded-md bg-cyan-400/5 ring-1 ring-cyan-400/20 pointer-events-none"></div>
|
|
}
|
|
<!-- Ellipsis menu handle (hidden for columns block as it has its own per-block buttons) -->
|
|
@if (block.type !== 'columns') {
|
|
<button
|
|
type="button"
|
|
class="menu-handle opacity-0 group-hover: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"
|
|
title="Click to open menu"
|
|
(click)="onMenuClick($event)"
|
|
(mousedown)="onDragStart($event)"
|
|
>
|
|
<svg class="w-2 h-2 text-gray-300" fill="currentColor" viewBox="0 0 16 16">
|
|
<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>
|
|
}
|
|
|
|
<!-- Block content (extra right padding so the comment icon sits on the dark background, not on top of the pill) -->
|
|
<div class="block-content pr-8" [class.locked]="block.meta?.locked">
|
|
@switch (block.type) {
|
|
@case ('paragraph') {
|
|
<div class="flex items-center gap-2">
|
|
<div class="flex-1">
|
|
<app-paragraph-block
|
|
[block]="block"
|
|
(update)="onBlockUpdate($event)"
|
|
(metaChange)="onMetaChange($event)"
|
|
(createBlock)="onCreateBlockBelow()"
|
|
(deleteBlock)="onDeleteBlock()"
|
|
/>
|
|
</div>
|
|
</div>
|
|
}
|
|
@case ('heading') {
|
|
<app-heading-block
|
|
[block]="block"
|
|
(update)="onBlockUpdate($event)"
|
|
(metaChange)="onMetaChange($event)"
|
|
(createBlock)="onCreateBlockBelow()"
|
|
(deleteBlock)="onDeleteBlock()"
|
|
/>
|
|
}
|
|
@case ('list') {
|
|
<app-list-block [block]="block" (update)="onBlockUpdate($event)" />
|
|
}
|
|
@case ('list-item') {
|
|
<app-list-item-block [block]="block" (update)="onBlockUpdate($event)" />
|
|
}
|
|
@case ('code') {
|
|
<app-code-block [block]="block" (update)="onBlockUpdate($event)" />
|
|
}
|
|
@case ('quote') {
|
|
<app-quote-block [block]="block" (update)="onBlockUpdate($event)" />
|
|
}
|
|
@case ('table') {
|
|
<app-table-block [block]="block" (update)="onBlockUpdate($event)" />
|
|
}
|
|
@case ('image') {
|
|
<app-image-block [block]="block" (update)="onBlockUpdate($event)" (requestMenu)="openMenuAt($event)" (insertImagesBelow)="onInsertImagesBelow($event)" />
|
|
}
|
|
@case ('file') {
|
|
<app-file-block [block]="block" (update)="onBlockUpdate($event)" />
|
|
}
|
|
@case ('button') {
|
|
<app-button-block [block]="block" (update)="onBlockUpdate($event)" />
|
|
}
|
|
@case ('link') {
|
|
<app-link-block [block]="block" (update)="onBlockUpdate($event)" />
|
|
}
|
|
@case ('hint') {
|
|
<app-hint-block [block]="block" (update)="onBlockUpdate($event)" />
|
|
}
|
|
@case ('toggle') {
|
|
<app-toggle-block
|
|
[block]="block"
|
|
(update)="onBlockUpdate($event)"
|
|
(createBlock)="onCreateToggleBelow()"
|
|
(createParagraph)="onCreateBlockBelow()"
|
|
/>
|
|
}
|
|
@case ('dropdown') {
|
|
<app-dropdown-block [block]="block" (update)="onBlockUpdate($event)" />
|
|
}
|
|
@case ('steps') {
|
|
<app-steps-block [block]="block" (update)="onBlockUpdate($event)" />
|
|
}
|
|
@case ('progress') {
|
|
<app-progress-block [block]="block" (update)="onBlockUpdate($event)" />
|
|
}
|
|
@case ('kanban') {
|
|
<app-kanban-block [block]="block" (update)="onBlockUpdate($event)" />
|
|
}
|
|
@case ('embed') {
|
|
<app-embed-block [block]="block" (update)="onBlockUpdate($event)" />
|
|
}
|
|
@case ('outline') {
|
|
<app-outline-block [block]="block" />
|
|
}
|
|
@case ('line') {
|
|
<app-line-block [block]="block" />
|
|
}
|
|
@case ('columns') {
|
|
<app-columns-block [block]="block" (update)="onBlockUpdate($event)" />
|
|
}
|
|
@case ('collapsible') {
|
|
<app-collapsible-block [block]="block" (update)="onBlockUpdate($event)" />
|
|
}
|
|
}
|
|
</div>
|
|
<ng-container *ngIf="block.type !== 'table' && block.type !== 'columns'">
|
|
<!-- Filled comment icon with count -->
|
|
<button *ngIf="totalComments() > 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()">
|
|
<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">{{ totalComments() }}</span>
|
|
</button>
|
|
<!-- Outline comment icon when there are no comments -->
|
|
<button *ngIf="totalComments() === 0" class="absolute top-1/2 -translate-y-1/2 right-2 w-8 h-8 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center z-20"
|
|
title="Add a comment" (click)="openComments()">
|
|
<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>
|
|
</div>
|
|
|
|
<!-- Context Menu -->
|
|
<app-block-context-menu
|
|
[block]="block"
|
|
[visible]="menuVisible()"
|
|
[position]="menuPosition()"
|
|
(action)="onMenuAction($event)"
|
|
(close)="closeMenu()"
|
|
/>
|
|
`,
|
|
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<BlockMenuAction>();
|
|
|
|
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<HTMLElement>);
|
|
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 '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(); }
|
|
}
|