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'; import { BlockMenuStylingService } from '../../services/block-menu-styling.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: ` `, styles: [` :host { position: fixed; inset: 0; pointer-events: none; z-index: 2147483646; } .ctx { pointer-events: auto; border-radius: 0.5rem; box-shadow: 0 16px 40px rgba(0,0,0,.32); background: var(--card, #0b1120); border: 1px solid var(--border, rgba(148,163,184,0.5)); color: var(--text-main, var(--fg, #e5e7eb)); max-height: calc(100vh - 16px); overflow-y: auto; overflow-x: hidden; font-size: 0.875rem; animation: fadeIn .12s ease-out; } .ctx button:hover, .ctx button:focus, .ctx [data-submenu-panel] button:hover { background: var(--menu-hover, rgba(0,0,0,0.16)) !important; } .submenu-pro { scrollbar-width: thin; } .submenu-pro::-webkit-scrollbar { width: 6px; } .submenu-pro::-webkit-scrollbar-track { background: transparent; } .submenu-pro::-webkit-scrollbar-thumb { background: rgba(148,163,184,0.6); border-radius: 999px; } .submenu-pro .shortcut-key { background-color: var(--surface2, #0f172a); color: var(--text-muted, #9ca3af); padding: 2px 8px; border-radius: 999px; font-size: 0.7rem; border: 1px solid var(--border, rgba(148,163,184,0.7)); white-space: nowrap; } .dark .submenu-pro .shortcut-key { background-color: var(--surface2, #020617); color: var(--text-muted, #e5e7eb); border-color: var(--border, rgba(148,163,184,0.9)); } .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 blockMenuStylingService = inject(BlockMenuStylingService); private clipboardData: Block | null = null; @ViewChild('menu') menuRef?: ElementRef; // viewport-safe coordinates left = -9999; top = -9999; opacity = 0; constructor() { try { const el = this.elementRef.nativeElement as HTMLElement; if (el && el.parentElement !== document.body) { document.body.appendChild(el); } } catch {} } @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.reposition(); } @HostListener('window:scroll') onScroll() { if (this.visible) this.reposition(); } // 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'] && this.visible) { this.open(); } else if (changes['visible'] && !this.visible) { this.opacity = 0; this.left = -9999; this.top = -9999; } if (changes['position'] && this.visible) { this.open(); } } 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.position.x; let top = this.position.y; // 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; this.left = left; this.top = top; this.opacity = 1; // also keep any open submenu in position relative to its anchor if (this.showSubmenu && this._submenuAnchor) { this.positionSubmenu(this.showSubmenu, this._submenuAnchor); } } // Keyboard navigation private open() { this.left = this.position.x; this.top = this.position.y; this.opacity = 0; // Keep it hidden until repositioned // Wait for the DOM to update with the new position setTimeout(() => { this.reposition(); queueMicrotask(() => this.focusFirstItem()); }, 0); } @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; const gap = 4; // Gap between main menu and submenu // Temporarily position off-screen to measure dimensions panel.style.visibility = 'hidden'; panel.style.position = 'fixed'; panel.style.left = '-9999px'; panel.style.top = '-9999px'; panel.style.maxHeight = `${vh - 16}px`; const pw = panel.offsetWidth; const ph = panel.offsetHeight; let left = r.right + gap; let top = r.top; // Adjust horizontal position if (left + pw > vw - 8) { left = r.left - pw - gap; } if (left < 8) { left = 8; } // Adjust vertical position if (top + ph > vh - 8) { top = vh - ph - 8; } if (top < 8) { top = 8; } // Apply final position and make visible this.submenuStyle[id] = { position: 'fixed', left: `${left}px`, top: `${top}px` }; panel.style.left = `${left}px`; panel.style.top = `${top}px`; panel.style.visibility = 'visible'; } 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() { if (this.block?.type === 'file') { 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: '', category: 'MEDIA' }]; } return []; } if (this.block?.type === 'image') { return [{ type: 'file' as BlockType, preset: null, icon: 'πŸ“Ž', label: 'File', shortcut: '', category: 'MEDIA' }]; } return this.blockMenuStylingService.getConvertOptions(); } get groupedConvertOptions() { return this.blockMenuStylingService.getGroupedConvertOptions(); } 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; } }