ObsiViewer/src/app/editor/components/block/block-context-menu.component.ts
Bruno Charest 85d021b154 ```
feat: add file block with drag-and-drop support

- Implemented file upload block with preview support for images, videos, PDFs, text, code, and DOCX files
- Added drag-and-drop directive for files with visual drop indicators in root and column contexts
- Created file icon component with FontAwesome integration for 50+ file type icons
```
2025-11-12 22:41:43 -05:00

1374 lines
58 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: `
<div
*ngIf="visible"
#menu
class="ctx fixed min-w-[240px] py-2"
[style.left.px]="left"
[style.top.px]="top"
role="menu"
(mousedown)="$event.stopPropagation()"
(click)="$event.stopPropagation()"
(contextmenu)="$event.preventDefault()"
>
<!-- Alignment toolbar (image has dedicated icons + size buttons). Non-image keeps generic align + indent. -->
<div class="flex items-center gap-1 px-3 py-2 border-b border-border">
@if (block.type === 'image') {
<!-- Image alignment: Left / Center / Right (custom icons like in ref) -->
<button
class="p-2 rounded hover:bg-surface2 transition"
title="Align left"
(click)="onAlignImage('left')"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<rect x="3" y="5.5" width="18" height="13" rx="2" opacity="0.35"/>
<rect x="5" y="8" width="7" height="8" rx="1" fill="currentColor"/>
</svg>
</button>
<button
class="p-2 rounded hover:bg-surface2 transition"
title="Align center"
(click)="onAlignImage('center')"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<rect x="3" y="5.5" width="18" height="13" rx="2" opacity="0.35"/>
<rect x="8.5" y="8" width="7" height="8" rx="1" fill="currentColor"/>
</svg>
</button>
<button
class="p-2 rounded hover:bg-surface2 transition"
title="Align right"
(click)="onAlignImage('right')"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<rect x="3" y="5.5" width="18" height="13" rx="2" opacity="0.35"/>
<rect x="12" y="8" width="7" height="8" rx="1" fill="currentColor"/>
</svg>
</button>
<div class="w-px h-5 mx-1 bg-border"></div>
<!-- Default size and Full width -->
<button
class="p-2 rounded hover:bg-surface2 transition"
title="Default size"
(click)="onImageDefaultSize()"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<rect x="3" y="5.5" width="18" height="13" rx="2" opacity="0.35"/>
<rect x="9" y="8" width="6" height="8" rx="1" fill="currentColor"/>
</svg>
</button>
<button
class="p-2 rounded hover:bg-surface2 transition"
title="Full width"
(click)="onAction('imageAlignment', { alignment: 'full' })"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<rect x="3" y="5.5" width="18" height="13" rx="2" opacity="0.35"/>
<rect x="4.5" y="8" width="15" height="8" rx="1" fill="currentColor"/>
</svg>
</button>
} @else {
<button
*ngFor="let align of alignments"
class="p-2 rounded hover:bg-surface2 transition"
[title]="align.label"
(click)="onAlign(align.value)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path *ngFor="let line of align.lines" [attr.d]="line"/>
</svg>
</button>
<div class="w-px h-5 mx-1 bg-border"></div>
<button
class="p-2 rounded hover:bg-surface2 transition"
title="Increase indent"
(click)="onIndent(1)"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 6h13"/>
<path d="M8 12h13"/>
<path d="M8 18h13"/>
<path d="M3 8l4 4-4 4"/>
</svg>
</button>
<button
class="p-2 rounded hover:bg-surface2 transition"
title="Decrease indent"
(click)="onIndent(-1)"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 6h13"/>
<path d="M8 12h13"/>
<path d="M8 18h13"/>
<path d="M7 12H3"/>
</svg>
</button>
}
</div>
<!-- Image quick ratios row (top, only for image) -->
@if (block.type === 'image') {
<div class="flex items-center gap-2 px-3 py-2 border-b border-border">
<div class="text-xs text-text-muted mr-1">Aspect</div>
<button class="px-2 py-1 text-xs rounded border border-border hover:bg-surface2"
[class.bg-primary/10]="isActiveAspectRatio('free')"
(click)="onAction('imageAspectRatio', { ratio: 'free' })">Free</button>
<button class="px-2 py-1 text-xs rounded border border-border hover:bg-surface2"
[class.bg-primary/10]="isActiveAspectRatio('16:9')"
(click)="onAction('imageAspectRatio', { ratio: '16:9' })">16:9</button>
<button class="px-2 py-1 text-xs rounded border border-border hover:bg-surface2"
[class.bg-primary/10]="isActiveAspectRatio('4:3')"
(click)="onAction('imageAspectRatio', { ratio: '4:3' })">4:3</button>
<button class="px-2 py-1 text-xs rounded border border-border hover:bg-surface2"
[class.bg-primary/10]="isActiveAspectRatio('1:1')"
(click)="onAction('imageAspectRatio', { ratio: '1:1' })">1:1</button>
<button class="px-2 py-1 text-xs rounded border border-border hover:bg-surface2"
[class.bg-primary/10]="isActiveAspectRatio('3:2')"
(click)="onAction('imageAspectRatio', { ratio: '3:2' })">3:2</button>
</div>
}
<!-- Main menu items -->
<div class="py-1">
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
(click)="onAction('comment')"
>
<span class="text-base">💬</span>
<span>Comment</span>
</button>
<div class="relative">
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3 justify-between"
[attr.data-submenu]="'add'"
(mouseenter)="onOpenSubmenu($event, 'add')"
(click)="toggleSubmenu($event, 'add')"
>
<div class="flex items-center gap-3">
<span class="text-base"></span>
<span>Add block</span>
</div>
<span class="text-xs"></span>
</button>
<!-- Add block submenu -->
<div
*ngIf="showSubmenu === 'add'"
class="bg-surface1 border border-border rounded-lg shadow-xl p-2 w-[180px] z-50"
[attr.data-submenu-panel]="'add'"
[ngStyle]="submenuStyle['add']"
(mouseenter)="keepSubmenuOpen('add')"
(mouseleave)="closeSubmenu()"
>
<button class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm" (click)="onAction('add', { position: 'above' })">Above</button>
<button class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm" (click)="onAction('add', { position: 'below' })">Below</button>
<div class="h-px bg-border my-1"></div>
<button class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm" (click)="onAction('add', { position: 'left' })">Left</button>
<button class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm" (click)="onAction('add', { position: 'right' })">Right</button>
</div>
</div>
@if (convertOptions.length) {
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3 justify-between"
[attr.data-submenu]="'convert'"
(mouseenter)="onOpenSubmenu($event, 'convert')"
(click)="toggleSubmenu($event, 'convert')"
>
<div class="flex items-center gap-3">
<span class="text-base">🔄</span>
<span>Convert to</span>
</div>
<span class="text-xs"></span>
</button>
}
<div class="relative">
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3 justify-between"
[attr.data-submenu]="'background'"
(mouseenter)="onOpenSubmenu($event, 'background')"
(click)="toggleSubmenu($event, 'background')"
>
<div class="flex items-center gap-3">
<span class="text-base">🎨</span>
<span>Background color</span>
</div>
<span class="text-xs"></span>
</button>
<!-- Background color submenu (anchored to row, no gap) -->
<div
*ngIf="showSubmenu === 'background'"
class="bg-surface1 border border-border rounded-lg shadow-xl p-3 w-[240px] z-50"
[attr.data-submenu-panel]="'background'"
[ngStyle]="submenuStyle['background']"
(mouseenter)="onColorMenuEnter('background'); keepSubmenuOpen('background')"
(mouseleave)="onColorMenuLeave('background'); closeSubmenu()"
>
<div class="grid grid-cols-5 gap-3">
<button
*ngFor="let color of backgroundColors"
class="w-7 h-7 rounded-full ring-2 transition"
[style.backgroundColor]="color.value"
[attr.title]="color.name"
[ngClass]="{ 'ring-primary': isActiveBackgroundColor(color.value), 'ring-transparent hover:ring-primary': !isActiveBackgroundColor(color.value) }"
(mouseenter)="onColorHover('background', color.value)"
(click)="onBackgroundColor(color.value); onColorConfirm('background', color.value)"
></button>
</div>
</div>
</div>
<!-- Border color (Hint blocks only) -->
<div class="relative" *ngIf="block.type === 'hint'">
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3 justify-between"
(mouseenter)="onOpenSubmenu($event, 'borderColor')"
(click)="toggleSubmenu($event, 'borderColor')"
>
<div class="flex items-center gap-3">
<span class="text-base">🎨</span>
<span>Border color</span>
</div>
<span class="text-xs"></span>
</button>
<!-- Border color submenu -->
<div
*ngIf="showSubmenu === 'borderColor'"
class="bg-surface1 border border-border rounded-lg shadow-xl p-3 w-[240px] z-50"
[attr.data-submenu-panel]="'borderColor'"
[ngStyle]="submenuStyle['borderColor']"
(mouseenter)="onColorMenuEnter('borderColor'); keepSubmenuOpen('borderColor')"
(mouseleave)="onColorMenuLeave('borderColor'); closeSubmenu()"
>
<div class="grid grid-cols-5 gap-3">
<button
*ngFor="let color of backgroundColors"
class="w-7 h-7 rounded-full ring-2 transition"
[style.backgroundColor]="color.value"
[attr.title]="color.name"
[ngClass]="{ 'ring-primary': isActiveBorderColor(color.value), 'ring-transparent hover:ring-primary': !isActiveBorderColor(color.value) }"
(mouseenter)="onColorHover('borderColor', color.value)"
(click)="onBorderColor(color.value); onColorConfirm('borderColor', color.value)"
></button>
</div>
</div>
</div>
<!-- Line color (Quote and Hint blocks) -->
<div class="relative" *ngIf="block.type === 'quote' || block.type === 'hint'">
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3 justify-between"
(mouseenter)="onOpenSubmenu($event, 'lineColor')"
(click)="toggleSubmenu($event, 'lineColor')"
>
<div class="flex items-center gap-3">
<span class="text-base">🖌️</span>
<span>Line color</span>
</div>
<span class="text-xs"></span>
</button>
<!-- Line color submenu -->
<div
*ngIf="showSubmenu === 'lineColor'"
class="bg-surface1 border border-border rounded-lg shadow-xl p-3 w-[240px] z-50"
[attr.data-submenu-panel]="'lineColor'"
[ngStyle]="submenuStyle['lineColor']"
(mouseenter)="onColorMenuEnter('lineColor'); keepSubmenuOpen('lineColor')"
(mouseleave)="onColorMenuLeave('lineColor'); closeSubmenu()"
>
<div class="grid grid-cols-5 gap-3">
<button
*ngFor="let color of backgroundColors"
class="w-7 h-7 rounded-full ring-2 transition"
[style.backgroundColor]="color.value"
[attr.title]="color.name"
[ngClass]="{ 'ring-primary': isActiveLineColor(color.value), 'ring-transparent hover:ring-primary': !isActiveLineColor(color.value) }"
(mouseenter)="onColorHover('lineColor', color.value)"
(click)="onLineColor(color.value); onColorConfirm('lineColor', color.value)"
></button>
</div>
</div>
</div>
<!-- Code block specific options -->
@if (block.type === 'code') {
<!-- Language submenu -->
<div class="relative">
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3 justify-between"
[attr.data-submenu]="'codeLanguage'"
(mouseenter)="onOpenSubmenu($event, 'codeLanguage')"
(click)="toggleSubmenu($event, 'codeLanguage')"
>
<div class="flex items-center gap-3">
<span class="text-base">🔤</span>
<span>Language</span>
</div>
<span class="text-xs"></span>
</button>
<!-- Language submenu -->
<div
*ngIf="showSubmenu === 'codeLanguage'"
class="bg-surface1 border border-border rounded-lg shadow-xl p-2 w-[200px] max-h-[300px] overflow-y-auto z-50"
[attr.data-submenu-panel]="'codeLanguage'"
[ngStyle]="submenuStyle['codeLanguage']"
(mouseenter)="keepSubmenuOpen('codeLanguage')"
(mouseleave)="closeSubmenu()"
>
@for (lang of codeThemeService.getLanguages(); track lang) {
<button
class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm"
[class.bg-primary/10]="isActiveLanguage(lang)"
(click)="onCodeLanguage(lang)"
>
{{ codeThemeService.getLanguageDisplay(lang) }}
</button>
}
</div>
</div>
<!-- Theme submenu -->
<div class="relative">
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3 justify-between"
[attr.data-submenu]="'codeTheme'"
(mouseenter)="onOpenSubmenu($event, 'codeTheme')"
(click)="toggleSubmenu($event, 'codeTheme')"
>
<div class="flex items-center gap-3">
<span class="text-base">🎨</span>
<span>Theme</span>
</div>
<span class="text-xs"></span>
</button>
<!-- Theme submenu -->
<div
*ngIf="showSubmenu === 'codeTheme'"
class="bg-surface1 border border-border rounded-lg shadow-xl p-2 w-[180px] z-50"
[attr.data-submenu-panel]="'codeTheme'"
[ngStyle]="submenuStyle['codeTheme']"
(mouseenter)="keepSubmenuOpen('codeTheme')"
(mouseleave)="closeSubmenu()"
>
@for (theme of codeThemeService.getThemes(); track theme.id) {
<button
class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm"
[class.bg-primary/10]="isActiveTheme(theme.id)"
(click)="onCodeTheme(theme.id)"
>
{{ theme.name }}
</button>
}
</div>
</div>
<!-- Copy code -->
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
(click)="onAction('copyCode')"
>
<span class="text-base">📋</span>
<span>Copy code</span>
</button>
<!-- Toggle wrap -->
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
(click)="onAction('toggleWrap')"
>
<span class="text-base">{{ getCodeWrapIcon() }}</span>
<span>Enable wrap</span>
</button>
<!-- Toggle line numbers -->
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
(click)="onAction('toggleLineNumbers')"
>
<span class="text-base">{{ getCodeLineNumbersIcon() }}</span>
<span>Show line numbers</span>
</button>
}
<!-- Image block specific options -->
@if (block.type === 'image') {
<!-- Add caption -->
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
(click)="onAction('addCaption')"
>
<span class="text-base">📝</span>
<span>Add caption</span>
</button>
<!-- Aspect ratio submenu -->
<div class="relative">
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3 justify-between"
[attr.data-submenu]="'imageAspectRatio'"
(mouseenter)="onOpenSubmenu($event, 'imageAspectRatio')"
(click)="toggleSubmenu($event, 'imageAspectRatio')"
>
<div class="flex items-center gap-3">
<span class="text-base">📐</span>
<span>Aspect ratio</span>
</div>
<span class="text-xs"></span>
</button>
<div
*ngIf="showSubmenu === 'imageAspectRatio'"
class="bg-surface1 border border-border rounded-lg shadow-xl p-2 w-[180px] z-50"
[attr.data-submenu-panel]="'imageAspectRatio'"
[ngStyle]="submenuStyle['imageAspectRatio']"
(mouseenter)="keepSubmenuOpen('imageAspectRatio')"
(mouseleave)="closeSubmenu()"
>
<button class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm" [class.bg-primary/10]="isActiveAspectRatio('free')" (click)="onAction('imageAspectRatio', { ratio: 'free' })">Free</button>
<button class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm" [class.bg-primary/10]="isActiveAspectRatio('16:9')" (click)="onAction('imageAspectRatio', { ratio: '16:9' })">16:9</button>
<button class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm" [class.bg-primary/10]="isActiveAspectRatio('4:3')" (click)="onAction('imageAspectRatio', { ratio: '4:3' })">4:3</button>
<button class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm" [class.bg-primary/10]="isActiveAspectRatio('1:1')" (click)="onAction('imageAspectRatio', { ratio: '1:1' })">1:1</button>
<button class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm" [class.bg-primary/10]="isActiveAspectRatio('3:2')" (click)="onAction('imageAspectRatio', { ratio: '3:2' })">3:2</button>
</div>
</div>
<!-- Alignment submenu -->
<div class="relative">
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3 justify-between"
[attr.data-submenu]="'imageAlignment'"
(mouseenter)="onOpenSubmenu($event, 'imageAlignment')"
(click)="toggleSubmenu($event, 'imageAlignment')"
>
<div class="flex items-center gap-3">
<span class="text-base">↔️</span>
<span>Alignment</span>
</div>
<span class="text-xs"></span>
</button>
<div
*ngIf="showSubmenu === 'imageAlignment'"
class="bg-surface1 border border-border rounded-lg shadow-xl p-2 w-[180px] z-50"
[attr.data-submenu-panel]="'imageAlignment'"
[ngStyle]="submenuStyle['imageAlignment']"
(mouseenter)="keepSubmenuOpen('imageAlignment')"
(mouseleave)="closeSubmenu()"
>
<button class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm" [class.bg-primary/10]="isActiveImageAlignment('left')" (click)="onAction('imageAlignment', { alignment: 'left' })">Left</button>
<button class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm" [class.bg-primary/10]="isActiveImageAlignment('center')" (click)="onAction('imageAlignment', { alignment: 'center' })">Center</button>
<button class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm" [class.bg-primary/10]="isActiveImageAlignment('right')" (click)="onAction('imageAlignment', { alignment: 'right' })">Right</button>
<button class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm" [class.bg-primary/10]="isActiveImageAlignment('full')" (click)="onAction('imageAlignment', { alignment: 'full' })">Full width</button>
</div>
</div>
<!-- Replace image -->
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
(click)="onAction('imageReplace')"
>
<span class="text-base">🖼️</span>
<span>Replace</span>
</button>
<!-- Rotate image -->
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
(click)="onAction('imageRotate')"
>
<span class="text-base">🔄</span>
<span>Rotate 90°</span>
</button>
<!-- Set as preview -->
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
(click)="onAction('imageSetPreview')"
>
<span class="text-base">⭐</span>
<span>Set as preview</span>
</button>
<!-- OCR -->
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
(click)="onAction('imageOCR')"
>
<span class="text-base">🧠</span>
<span>Get text from image (OCR)</span>
</button>
<!-- Download -->
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
(click)="onAction('imageDownload')"
>
<span class="text-base">⬇️</span>
<span>Download</span>
</button>
<!-- View full size -->
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
(click)="onAction('imageViewFull')"
>
<span class="text-base">🔎</span>
<span>View full size</span>
</button>
<!-- Open in new tab -->
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
(click)="onAction('imageOpenTab')"
>
<span class="text-base">🪟</span>
<span>Open in new tab</span>
</button>
<!-- Image info -->
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
(click)="onAction('imageInfo')"
>
<span class="text-base"></span>
<span>Image info</span>
</button>
}
<!-- Table block specific options -->
@if (block.type === 'table') {
<!-- Add caption -->
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
(click)="onAction('addCaption')"
>
<span class="text-base">📝</span>
<span>{{ hasCaption() ? 'Edit caption' : 'Add caption' }}</span>
</button>
<!-- Table layout -->
<div class="relative">
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3 justify-between"
[attr.data-submenu]="'tableLayout'"
(mouseenter)="onOpenSubmenu($event, 'tableLayout')"
(click)="toggleSubmenu($event, 'tableLayout')"
>
<div class="flex items-center gap-3">
<span class="text-base">📐</span>
<span>Table layout</span>
</div>
<span class="text-xs"></span>
</button>
<!-- Layout submenu -->
<div
*ngIf="showSubmenu === 'tableLayout'"
class="bg-surface1 border border-border rounded-lg shadow-xl p-2 w-[180px] z-50"
[attr.data-submenu-panel]="'tableLayout'"
[ngStyle]="submenuStyle['tableLayout']"
(mouseenter)="keepSubmenuOpen('tableLayout')"
(mouseleave)="closeSubmenu()"
>
<button
class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm"
[class.bg-primary/10]="isActiveLayout('auto')"
(click)="onTableLayout('auto')"
>
Auto
</button>
<button
class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm"
[class.bg-primary/10]="isActiveLayout('fixed')"
(click)="onTableLayout('fixed')"
>
Fixed
</button>
</div>
</div>
<!-- Copy table -->
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
(click)="onAction('copyTable')"
>
<span class="text-base">📋</span>
<span>Copy table</span>
</button>
<!-- Filter -->
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
(click)="onAction('filterTable')"
>
<span class="text-base">🔍</span>
<span>Filter</span>
</button>
<!-- Import from CSV -->
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
(click)="onAction('importCSV')"
>
<span class="text-base">📥</span>
<span>Import from CSV</span>
</button>
<!-- Insert column (submenu inline avec 3 icônes) -->
<div class="px-4 py-2 border-t border-border dark:border-gray-700">
<div class="text-xs text-text-muted dark:text-neutral-500 mb-2">Insert column</div>
<div class="flex gap-2">
<button
class="flex-1 p-2 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition"
(click)="onInsertColumn('left')"
title="Insert left"
>
<svg class="w-4 h-4 mx-auto" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<rect x="4" y="4" width="6" height="16" rx="1"/>
<rect x="14" y="4" width="6" height="16" rx="1" opacity="0.3"/>
</svg>
</button>
<button
class="flex-1 p-2 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition"
(click)="onInsertColumn('center')"
title="Insert center"
>
<svg class="w-4 h-4 mx-auto" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<rect x="4" y="4" width="6" height="16" rx="1" opacity="0.3"/>
<rect x="9" y="4" width="6" height="16" rx="1"/>
<rect x="14" y="4" width="6" height="16" rx="1" opacity="0.3"/>
</svg>
</button>
<button
class="flex-1 p-2 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition"
(click)="onInsertColumn('right')"
title="Insert right"
>
<svg class="w-4 h-4 mx-auto" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<rect x="4" y="4" width="6" height="16" rx="1" opacity="0.3"/>
<rect x="14" y="4" width="6" height="16" rx="1"/>
</svg>
</button>
</div>
</div>
<!-- Help -->
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 dark:hover:bg-gray-700 transition flex items-center gap-3"
(click)="onAction('tableHelp')"
>
<span class="text-base">❓</span>
<span>Help</span>
</button>
}
<div class="h-px bg-border dark:bg-gray-700 my-1"></div>
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
(click)="onAction('duplicate')"
>
<span class="text-base">📋</span>
<span>Duplicate</span>
</button>
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
(click)="onAction('copy')"
>
<span class="text-base">📄</span>
<span>Copy block</span>
</button>
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
(click)="onAction('lock')"
>
<span class="text-base">🔒</span>
<span>Lock block</span>
</button>
<button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
(click)="onAction('copyLink')"
>
<span class="text-base">🔗</span>
<span>Copy Link</span>
</button>
<div class="h-px bg-border dark:bg-gray-700 my-1"></div>
<button
class="w-full text-left px-4 py-2 hover:bg-red-500/10 text-red-500 transition flex items-center gap-3"
(click)="onAction('delete')"
>
<span class="text-base">🗑️</span>
<span>Delete</span>
</button>
</div>
<!-- Convert to submenu -->
<div
*ngIf="showSubmenu === 'convert' && convertOptions.length"
class="bg-surface1 border border-border rounded-lg shadow-xl min-w-[280px] py-2"
[attr.data-submenu-panel]="'convert'"
[ngStyle]="submenuStyle['convert']"
(mouseenter)="keepSubmenuOpen('convert')"
(mouseleave)="closeSubmenu()"
(mousedown)="$event.stopPropagation()"
>
<button
*ngFor="let item of convertOptions"
class="w-full text-left px-4 py-2 hover:bg-surface2 dark:hover:bg-gray-700 transition flex items-center justify-between"
(mousedown)="onConvert(item.type, item.preset)"
(click)="$event.preventDefault()"
>
<div class="flex items-center gap-3">
<span class="text-base w-5 flex items-center justify-center">
@if (item.type === 'list-item' && item.preset?.kind === 'check') {
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="5" height="5" rx="1"/>
<path d="M10 6h10"/>
<rect x="3" y="10" width="5" height="5" rx="1"/>
<path d="M10 13h10"/>
<rect x="3" y="17" width="5" height="5" rx="1"/>
<path d="M10 20h10"/>
</svg>
} @else if (item.type === 'list-item' && item.preset?.kind === 'bullet') {
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="5.5" cy="5.5" r="1.5"/>
<path d="M10 6h10"/>
<circle cx="5.5" cy="12.5" r="1.5"/>
<path d="M10 13h10"/>
<circle cx="5.5" cy="19.5" r="1.5"/>
<path d="M10 20h10"/>
</svg>
} @else if (item.type === 'list-item' && item.preset?.kind === 'numbered') {
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<text x="4" y="7" font-size="5" text-anchor="middle" dominant-baseline="middle" fill="currentColor" stroke="none">1</text>
<path d="M10 7h10"/>
<text x="4" y="14" font-size="5" text-anchor="middle" dominant-baseline="middle" fill="currentColor" stroke="none">2</text>
<path d="M10 14h10"/>
<text x="4" y="21" font-size="5" text-anchor="middle" dominant-baseline="middle" fill="currentColor" stroke="none">3</text>
<path d="M10 21h10"/>
</svg>
} @else {
{{ item.icon }}
}
</span>
<span>{{ item.label }}</span>
</div>
<span class="text-xs text-text-muted">{{ item.shortcut }}</span>
</button>
</div>
<!-- Background submenu moved into the Background row above -->
</div>
`,
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<MenuAction>();
@Output() close = new EventEmitter<void>();
private documentService = inject(DocumentService);
private elementRef = inject(ElementRef);
readonly codeThemeService = inject(CodeThemeService);
private clipboardData: Block | null = null;
@ViewChild('menu') menuRef?: ElementRef<HTMLElement>;
// 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<string, any> = {};
private _submenuAnchor: HTMLElement | null = null;
onOpenSubmenu(ev: Event, id: NonNullable<typeof this.showSubmenu>) {
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<typeof this.showSubmenu>) {
if (this.showSubmenu === id) {
this.closeSubmenu();
} else {
this.onOpenSubmenu(ev, id);
}
}
keepSubmenuOpen(id: NonNullable<typeof this.showSubmenu>) {
this.showSubmenu = id;
if (this._submenuAnchor) this.positionSubmenu(id, this._submenuAnchor);
}
closeSubmenu() {
this.showSubmenu = null;
this._submenuAnchor = null;
}
private positionSubmenu(id: NonNullable<typeof this.showSubmenu>, 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;
}
}