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: ` `, 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; } }