import { Component, Input, Output, EventEmitter, inject, HostListener, ElementRef, OnChanges, SimpleChanges, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Block, BlockType } from '../../core/models/block.model';
import { DocumentService } from '../../services/document.service';
import { CodeThemeService } from '../../services/code-theme.service';
export interface MenuAction {
type: 'comment' | 'add' | 'convert' | 'background' | 'lineColor' | 'borderColor' | 'codeTheme' | 'codeLanguage' | 'copyCode' | 'toggleWrap' | 'toggleLineNumbers' | 'addCaption' | 'tableLayout' | 'copyTable' | 'filterTable' | 'importCSV' | 'tableHelp' | 'insertColumn' | 'imageAspectRatio' | 'imageAlignment' | 'imageDefaultSize' | 'imageReplace' | 'imageRotate' | 'imageSetPreview' | 'imageOCR' | 'imageDownload' | 'imageViewFull' | 'imageOpenTab' | 'imageInfo' | 'duplicate' | 'copy' | 'lock' | 'copyLink' | 'delete' | 'align' | 'indent';
payload?: any;
}
@Component({
selector: 'app-block-context-menu',
standalone: true,
imports: [CommonModule],
template: `
@if (block.type === 'image') {
} @else {
}
@if (block.type === 'image') {
Aspect
}
@if (convertOptions.length) {
}
@if (block.type === 'code') {
@for (lang of codeThemeService.getLanguages(); track lang) {
}
@for (theme of codeThemeService.getThemes(); track theme.id) {
}
}
@if (block.type === 'image') {
}
@if (block.type === 'table') {
}
`,
styles: [`
:host { display: contents; }
.ctx {
pointer-events: auto;
border-radius: 0.75rem;
box-shadow: 0 10px 30px rgba(0,0,0,.25);
background: var(--card, #ffffff);
border: 1px solid var(--border, #e5e7eb);
color: var(--text-main, var(--fg, #111827));
z-index: 2147483646;
max-height: calc(100vh - 16px);
overflow-y: auto;
overflow-x: hidden; /* submenus are fixed-positioned; avoid horizontal scrollbar */
animation: fadeIn .12s ease-out;
}
/* Stronger highlight on hover/focus for all buttons inside the menu (override utility classes) */
.ctx button:hover,
.ctx button:focus,
.ctx [data-submenu-panel] button:hover {
background: var(--menu-hover, rgba(0,0,0,0.16)) !important;
}
.ctx button:focus { outline: none; }
@keyframes fadeIn { from { opacity:0; transform: scale(.97);} to { opacity:1; transform: scale(1);} }
`]
})
export class BlockContextMenuComponent implements OnChanges {
@Input() block!: Block;
@Input() visible = false;
@Input() position = { x: 0, y: 0 };
@Output() action = new EventEmitter();
@Output() close = new EventEmitter();
private documentService = inject(DocumentService);
private elementRef = inject(ElementRef);
readonly codeThemeService = inject(CodeThemeService);
private clipboardData: Block | null = null;
@ViewChild('menu') menuRef?: ElementRef;
// viewport-safe coordinates
left = 0;
top = 0;
private repositionRaf: number | null = null;
@HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent): void {
const root = this.menuRef?.nativeElement;
if (this.visible && root && !root.contains(event.target as Node)) {
this.close.emit();
}
}
// Close on mousedown outside for immediate feedback
@HostListener('document:mousedown', ['$event'])
onDocumentMouseDown(event: MouseEvent): void {
const root = this.menuRef?.nativeElement;
if (this.visible && root && !root.contains(event.target as Node)) {
this.close.emit();
}
}
// Close when focus moves outside the menu (e.g., via Tab navigation)
@HostListener('document:focusin', ['$event'])
onDocumentFocusIn(event: FocusEvent): void {
const root = this.menuRef?.nativeElement;
if (this.visible && root && !root.contains(event.target as Node)) {
this.close.emit();
}
}
// Close when window loses focus (switching tabs/windows)
@HostListener('window:blur')
onWindowBlur() {
if (this.visible) {
this.close.emit();
}
}
@HostListener('window:resize') onResize() { if (this.visible) this.scheduleReposition(); }
@HostListener('window:scroll') onScroll() { if (this.visible) this.scheduleReposition(); }
// If hovering a non-submenu option within the main menu, close any open submenu
@HostListener('mouseover', ['$event'])
onMenuMouseOver(event: MouseEvent) {
if (!this.visible) return;
const root = this.menuRef?.nativeElement; if (!root) return;
const target = event.target as HTMLElement;
// Don't close if hovering over a submenu panel (they are fixed-positioned outside root)
const panel = this.showSubmenu ? document.querySelector(`[data-submenu-panel="${this.showSubmenu}"]`) as HTMLElement | null : null;
if (panel && panel.contains(target)) return;
if (!root.contains(target)) return;
const overAnchor = this._submenuAnchor ? (this._submenuAnchor === target || this._submenuAnchor.contains(target)) : false;
if (overAnchor) return;
const rowWithSubmenu = target.closest('[data-submenu]') as HTMLElement | null;
if (!rowWithSubmenu) {
this.closeSubmenu();
}
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['visible']) {
if (this.visible) {
this.left = this.position.x;
this.top = this.position.y;
this.scheduleReposition();
queueMicrotask(() => this.focusFirstItem());
}
}
if ((changes['position']) && this.visible) {
this.left = this.position.x;
this.top = this.position.y;
this.scheduleReposition();
}
}
private scheduleReposition() {
if (this.repositionRaf != null) cancelAnimationFrame(this.repositionRaf);
const el = this.menuRef?.nativeElement; if (el) el.style.visibility = 'hidden';
this.repositionRaf = requestAnimationFrame(() => { this.repositionRaf = null; this.reposition(); });
}
private reposition() {
const el = this.menuRef?.nativeElement; if (!el) return;
const rect = el.getBoundingClientRect();
const vw = window.innerWidth; const vh = window.innerHeight;
let left = this.left; let top = this.top;
// horizontal clamp
if (left + rect.width > vw - 8) left = Math.max(8, vw - rect.width - 8);
if (left < 8) left = 8;
// vertical: open upwards if overflow
if (top + rect.height > vh - 8) {
top = Math.max(8, top - rect.height);
}
if (top < 8) top = 8;
// if still too tall, rely on max-height + scroll
this.left = left; this.top = top; if (el) el.style.visibility = 'visible';
// also keep any open submenu in position relative to its anchor
if (this.showSubmenu && this._submenuAnchor) {
this.positionSubmenu(this.showSubmenu, this._submenuAnchor);
}
}
// Keyboard navigation
@HostListener('window:keydown', ['$event'])
onKey(e: KeyboardEvent) {
if (!this.visible) return;
if (e.key === 'Escape') { this.close.emit(); e.preventDefault(); return; }
const items = this.getFocusableItems(); if (!items.length) return;
const active = document.activeElement as HTMLElement | null;
let idx = Math.max(0, items.indexOf(active || items[0]));
if (e.key === 'ArrowDown') { idx = (idx + 1) % items.length; items[idx].focus(); items[idx].scrollIntoView({ block: 'nearest' }); this.maybeCloseSubmenuOnFocusChange(items[idx]); e.preventDefault(); }
else if (e.key === 'ArrowUp') { idx = (idx - 1 + items.length) % items.length; items[idx].focus(); items[idx].scrollIntoView({ block: 'nearest' }); this.maybeCloseSubmenuOnFocusChange(items[idx]); e.preventDefault(); }
else if (e.key === 'Enter') { (items[idx] as HTMLButtonElement).click(); e.preventDefault(); }
else if (e.key === 'ArrowRight') { this.tryOpenSubmenuFor(items[idx]); e.preventDefault(); }
else if (e.key === 'ArrowLeft') { this.showSubmenu = null; e.preventDefault(); }
}
private getFocusableItems(): HTMLElement[] {
const root = this.menuRef?.nativeElement; if (!root) return [];
const all = Array.from(root.querySelectorAll('button')) as HTMLElement[];
return all.filter(el => el.offsetParent !== null);
}
private focusFirstItem() {
const first = this.getFocusableItems()[0]; if (first) first.focus();
}
private tryOpenSubmenuFor(btn: HTMLElement) {
const id = btn.getAttribute('data-submenu');
if (id) {
this.onOpenSubmenu({ currentTarget: btn } as any, id as any);
// focus first item inside submenu when available
setTimeout(() => {
const panel = document.querySelector(`[data-submenu-panel="${id}"]`) as HTMLElement | null;
const first = panel?.querySelector('button') as HTMLElement | null;
if (first) first.focus();
}, 0);
}
}
showSubmenu: 'add' | 'convert' | 'background' | 'lineColor' | 'borderColor' | 'codeTheme' | 'codeLanguage' | 'tableLayout' | 'imageAspectRatio' | 'imageAlignment' | null = null;
submenuStyle: Record = {};
private _submenuAnchor: HTMLElement | null = null;
onOpenSubmenu(ev: Event, id: NonNullable) {
const anchor = (ev.currentTarget as HTMLElement) || null;
this.showSubmenu = id;
this._submenuAnchor = anchor;
// compute after render
requestAnimationFrame(() => this.positionSubmenu(id, anchor));
}
toggleSubmenu(ev: Event, id: NonNullable) {
if (this.showSubmenu === id) {
this.closeSubmenu();
} else {
this.onOpenSubmenu(ev, id);
}
}
keepSubmenuOpen(id: NonNullable) {
this.showSubmenu = id;
if (this._submenuAnchor) this.positionSubmenu(id, this._submenuAnchor);
}
closeSubmenu() {
this.showSubmenu = null;
this._submenuAnchor = null;
}
private positionSubmenu(id: NonNullable, anchor: HTMLElement | null) {
if (!anchor) return;
const panel = document.querySelector(`[data-submenu-panel="${id}"]`) as HTMLElement | null;
if (!panel) return;
const r = anchor.getBoundingClientRect();
const vw = window.innerWidth; const vh = window.innerHeight;
// ensure fixed positioning so it never affects the main menu scroll area
panel.style.position = 'fixed';
panel.style.maxHeight = Math.max(100, vh - 16) + 'px';
// First try opening to the right (small gap to allow mouse travel)
let left = r.right + 4;
// place top aligned with anchor top
let top = r.top;
// Measure panel size (after position temp offscreen)
panel.style.left = '-9999px'; panel.style.top = '-9999px';
const pw = panel.offsetWidth || 260; const ph = panel.offsetHeight || 200;
// Auto-invert horizontally if overflowing
if (left + pw > vw - 8) {
left = Math.max(8, r.left - pw - 2);
}
// Clamp vertical within viewport
if (top + ph > vh - 8) top = Math.max(8, vh - ph - 8);
if (top < 8) top = 8;
// Apply
this.submenuStyle[id] = { position: 'fixed', left: left + 'px', top: top + 'px' };
panel.style.left = left + 'px';
panel.style.top = top + 'px';
}
private maybeCloseSubmenuOnFocusChange(focused: HTMLElement) {
if (!this.showSubmenu) return;
const panel = document.querySelector(`[data-submenu-panel="${this.showSubmenu}"]`);
const isOnAnchorRow = focused.getAttribute('data-submenu') === this.showSubmenu;
const isInsidePanel = panel ? (panel as HTMLElement).contains(focused) : false;
if (!isOnAnchorRow && !isInsidePanel) {
this.closeSubmenu();
}
}
alignments = [
{ value: 'left', label: 'Align Left', lines: ['M3 6h12', 'M3 12h8', 'M3 18h12'] },
{ value: 'center', label: 'Align Center', lines: ['M6 6h12', 'M3 12h18', 'M6 18h12'] },
{ value: 'right', label: 'Align Right', lines: ['M9 6h12', 'M13 12h8', 'M9 18h12'] },
{ value: 'justify', label: 'Justify', lines: ['M3 6h18', 'M3 12h18', 'M3 18h18'] }
];
private previewState: {
kind: 'background' | 'borderColor' | 'lineColor' | null,
origBg?: string | undefined,
origBorder?: string | undefined,
origLine?: string | undefined,
confirmed?: boolean,
} = { kind: null };
onColorMenuEnter(kind: 'background' | 'borderColor' | 'lineColor') {
this.previewState = {
kind,
origBg: this.block?.meta?.bgColor,
origBorder: (this.block?.props as any)?.borderColor,
origLine: (this.block?.props as any)?.lineColor,
confirmed: false,
};
}
onColorHover(kind: 'background' | 'borderColor' | 'lineColor', value: string) {
const color = value === 'transparent' ? undefined : value;
if (kind === 'background') {
this.documentService.updateBlock(this.block.id, {
meta: { ...this.block.meta, bgColor: color }
} as any);
} else if (kind === 'borderColor') {
if (this.block.type === 'hint') {
this.documentService.updateBlockProps(this.block.id, {
...this.block.props,
borderColor: color
});
}
} else if (kind === 'lineColor') {
if (this.block.type === 'hint' || this.block.type === 'quote') {
this.documentService.updateBlockProps(this.block.id, {
...this.block.props,
lineColor: color
});
}
}
}
onColorConfirm(kind: 'background' | 'borderColor' | 'lineColor', value: string) {
// Mark as confirmed so we don't revert on leave
this.previewState.confirmed = true;
}
onColorMenuLeave(kind: 'background' | 'borderColor' | 'lineColor') {
if (this.previewState.kind !== kind) return;
if (this.previewState.confirmed) { this.previewState = { kind: null }; return; }
// Revert to original values
if (kind === 'background') {
this.documentService.updateBlock(this.block.id, {
meta: { ...this.block.meta, bgColor: this.previewState.origBg }
} as any);
} else if (kind === 'borderColor') {
if (this.block.type === 'hint') {
this.documentService.updateBlockProps(this.block.id, {
...this.block.props,
borderColor: this.previewState.origBorder
});
}
} else if (kind === 'lineColor') {
if (this.block.type === 'hint' || this.block.type === 'quote') {
this.documentService.updateBlockProps(this.block.id, {
...this.block.props,
lineColor: this.previewState.origLine
});
}
}
this.previewState = { kind: null };
}
get convertOptions() {
const base = [
{ type: 'list-item' as BlockType, preset: { kind: 'check', checked: false, text: '' }, icon: 'βοΈ', label: 'Checkbox list', shortcut: 'ctrl+shift+c' },
{ type: 'list-item' as BlockType, preset: { kind: 'numbered', number: 1, text: '' }, icon: '1.', label: 'Numbered list', shortcut: 'ctrl+shift+7' },
{ type: 'list-item' as BlockType, preset: { kind: 'bullet', text: '' }, icon: 'β’', label: 'Bullet list', shortcut: 'ctrl+shift+8' },
{ type: 'toggle' as BlockType, preset: null, icon: 'βΆοΈ', label: 'Toggle Block', shortcut: 'ctrl+alt+6' },
{ type: 'paragraph' as BlockType, preset: null, icon: 'ΒΆ', label: 'Paragraph', shortcut: 'ctrl+alt+7' },
{ type: 'steps' as BlockType, preset: null, icon: 'π', label: 'Steps', shortcut: '' },
{ type: 'heading' as BlockType, preset: { level: 1 }, icon: 'Hβ', label: 'Large Heading', shortcut: 'ctrl+alt+1' },
{ type: 'heading' as BlockType, preset: { level: 2 }, icon: 'Hβ', label: 'Medium Heading', shortcut: 'ctrl+alt+2' },
{ type: 'heading' as BlockType, preset: { level: 3 }, icon: 'Hβ', label: 'Small Heading', shortcut: 'ctrl+alt+3' },
{ type: 'code' as BlockType, preset: null, icon: '>', label: 'Code', shortcut: 'ctrl+alt+c' },
{ type: 'quote' as BlockType, preset: null, icon: 'β', label: 'Quote', shortcut: 'ctrl+alt+y' },
{ type: 'hint' as BlockType, preset: null, icon: 'βΉοΈ', label: 'Hint', shortcut: 'ctrl+alt+u' },
{ type: 'button' as BlockType, preset: null, icon: 'π', label: 'Button', shortcut: 'ctrl+alt+5' }
];
// Restrict for file/image per requirements
if (this.block?.type === 'file') {
// only when underlying file is an image kind
const props: any = this.block.props || {};
const meta = props.meta || {};
let kind = meta.kind as string | undefined;
if (!kind) {
const name = meta.name || props.name || '';
const ext = (meta.ext || name.split('.').pop() || '').toLowerCase();
if (['png','jpg','jpeg','gif','webp','bmp','svg'].includes(ext)) kind = 'image';
}
if (kind === 'image') {
return [{ type: 'image' as BlockType, preset: null, icon: 'πΌοΈ', label: 'Image', shortcut: '' }];
}
return [];
}
if (this.block?.type === 'image') {
return [{ type: 'file' as BlockType, preset: null, icon: 'π', label: 'File', shortcut: '' }];
}
return base;
}
backgroundColors = [
{ name: 'None', value: 'transparent' },
// row 1 (reds/pinks/purples)
{ name: 'Red 600', value: '#dc2626' },
{ name: 'Rose 500', value: '#f43f5e' },
{ name: 'Fuchsia 600', value: '#c026d3' },
{ name: 'Purple 600', value: '#9333ea' },
{ name: 'Indigo 600', value: '#4f46e5' },
// row 2 (blues/teals)
{ name: 'Blue 600', value: '#2563eb' },
{ name: 'Sky 500', value: '#0ea5e9' },
{ name: 'Cyan 500', value: '#06b6d4' },
{ name: 'Teal 600', value: '#0d9488' },
{ name: 'Emerald 600', value: '#059669' },
// row 3 (greens/yellows/oranges)
{ name: 'Green 600', value: '#16a34a' },
{ name: 'Lime 500', value: '#84cc16' },
{ name: 'Yellow 500', value: '#eab308' },
{ name: 'Amber 600', value: '#d97706' },
{ name: 'Orange 600', value: '#ea580c' },
// row 4 (browns/grays)
{ name: 'Stone 600', value: '#57534e' },
{ name: 'Neutral 600', value: '#525252' },
{ name: 'Slate 600', value: '#475569' },
{ name: 'Rose 300', value: '#fda4af' },
{ name: 'Sky 300', value: '#7dd3fc' }
];
onAction(type: MenuAction['type'], payload?: any): void {
if (type === 'copy') {
// Copy block to clipboard
this.copyBlockToClipboard();
} else {
// Emit action for parent to handle (including ratios/alignment payload)
this.action.emit({ type, payload });
}
this.close.emit();
}
onAlignImage(alignment: 'left' | 'center' | 'right'): void {
this.action.emit({ type: 'imageAlignment', payload: { alignment } });
this.close.emit();
}
onImageDefaultSize(): void {
this.action.emit({ type: 'imageDefaultSize' });
this.close.emit();
}
private copyBlockToClipboard(): void {
// Store in service for paste
this.clipboardData = JSON.parse(JSON.stringify(this.block));
// Also copy to system clipboard as JSON
const jsonStr = JSON.stringify(this.block, null, 2);
navigator.clipboard.writeText(jsonStr).then(() => {
console.log('Block copied to clipboard');
}).catch(err => {
console.error('Failed to copy:', err);
});
// Store in localStorage for cross-session paste
localStorage.setItem('copiedBlock', jsonStr);
}
onAlign(alignment: 'left'|'center'|'right'|'justify'): void {
// Emit action for parent to handle (works for both normal blocks and columns)
this.action.emit({ type: 'align', payload: { alignment } });
this.close.emit();
}
onIndent(delta: number): void {
// Emit action for parent to handle (works for both normal blocks and columns)
this.action.emit({ type: 'indent', payload: { delta } });
this.close.emit();
}
onConvert(type: BlockType, preset: any): void {
// Emit action with convert payload for parent to handle
this.action.emit({ type: 'convert', payload: { type, preset } });
this.close.emit();
}
onBackgroundColor(color: string): void {
// Emit action for parent to handle (works for both normal blocks and columns)
this.action.emit({ type: 'background', payload: { color } });
this.close.emit();
}
onLineColor(color: string): void {
// Emit action for parent to handle (Quote and Hint blocks)
this.action.emit({ type: 'lineColor', payload: { color } });
this.close.emit();
}
onBorderColor(color: string): void {
// Emit action for parent to handle (Hint blocks)
this.action.emit({ type: 'borderColor', payload: { color } });
this.close.emit();
}
isActiveBackgroundColor(value: string): boolean {
const current = (this.block.meta as any)?.bgColor;
return (current ?? 'transparent') === (value ?? 'transparent');
}
isActiveLineColor(value: string): boolean {
if (this.block.type === 'quote') {
const current = (this.block.props as any)?.lineColor;
return (current ?? '#3b82f6') === (value ?? '#3b82f6');
}
if (this.block.type === 'hint') {
const current = (this.block.props as any)?.lineColor;
const defaultColor = this.getDefaultHintLineColor();
return (current ?? defaultColor) === (value ?? defaultColor);
}
return false;
}
isActiveBorderColor(value: string): boolean {
if (this.block.type === 'hint') {
const current = (this.block.props as any)?.borderColor;
const defaultColor = this.getDefaultHintBorderColor();
return (current ?? defaultColor) === (value ?? defaultColor);
}
return false;
}
private getDefaultHintLineColor(): string {
const variant = (this.block.props as any)?.variant;
switch (variant) {
case 'info': return '#3b82f6';
case 'warning': return '#eab308';
case 'success': return '#22c55e';
case 'note': return '#a855f7';
default: return 'var(--border)';
}
}
private getDefaultHintBorderColor(): string {
const variant = (this.block.props as any)?.variant;
switch (variant) {
case 'info': return '#3b82f6';
case 'warning': return '#eab308';
case 'success': return '#22c55e';
case 'note': return '#a855f7';
default: return 'var(--border)';
}
}
// Code block specific methods
isActiveLanguage(lang: string): boolean {
if (this.block.type !== 'code') return false;
const current = (this.block.props as any)?.lang || '';
return current === lang;
}
isActiveTheme(themeId: string): boolean {
if (this.block.type !== 'code') return false;
const current = (this.block.props as any)?.theme || 'default';
return current === themeId;
}
onCodeLanguage(lang: string): void {
this.action.emit({ type: 'codeLanguage', payload: { lang } });
this.close.emit();
}
onCodeTheme(themeId: string): void {
this.action.emit({ type: 'codeTheme', payload: { themeId } });
this.close.emit();
}
getCodeWrapIcon(): string {
if (this.block.type !== 'code') return 'β¬';
return (this.block.props as any)?.enableWrap ? 'β
' : 'β¬';
}
getCodeLineNumbersIcon(): string {
if (this.block.type !== 'code') return 'β¬';
return (this.block.props as any)?.showLineNumbers ? 'β
' : 'β¬';
}
// Table block specific methods
hasCaption(): boolean {
if (this.block.type !== 'table') return false;
return !!(this.block.props as any)?.caption;
}
isActiveLayout(layout: string): boolean {
if (this.block.type !== 'table') return false;
const current = (this.block.props as any)?.layout || 'auto';
return current === layout;
}
onTableLayout(layout: 'auto' | 'fixed'): void {
this.action.emit({ type: 'tableLayout', payload: { layout } });
this.close.emit();
}
onInsertColumn(position: 'left' | 'center' | 'right'): void {
this.action.emit({ type: 'insertColumn', payload: { position } });
this.close.emit();
}
// Image block helpers
isActiveAspectRatio(r: string): boolean {
if (this.block.type !== 'image') return false;
const current = (this.block.props as any)?.aspectRatio || 'free';
return current === r;
}
isActiveImageAlignment(a: 'left' | 'center' | 'right' | 'full'): boolean {
if (this.block.type !== 'image') return false;
const current = (this.block.props as any)?.alignment || 'center';
return current === a;
}
}