ObsiViewer/src/app/editor/components/block/block-context-menu.component.ts
Bruno Charest ba86bd4b91 ```
refactor: compact block context menu styling and improve visual density

- Reduced menu width from 240px to 220px and decreased padding throughout for more compact layout
- Scaled down button sizes (p-2 → p-1.5), gaps (gap-3 → gap-2.5), and font sizes (text-base → text-sm)
- Shrunk color picker swatches from 7x7 to 5x5 with thinner ring borders for cleaner appearance
- Tightened spacing in toolbars, submenus, and all menu sections while maintaining usability
- Added opacity property binding
2025-11-15 18:13:24 -05:00

1402 lines
57 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';
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: `
<div
*ngIf="visible"
#menu
class="ctx fixed min-w-[220px] py-1" [style.opacity]="opacity"
[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-0.5 px-2 py-1.5 border-b border-border">
@if (block.type === 'image') {
<!-- Image alignment: Left / Center / Right (custom icons like in ref) -->
<button
class="p-1.5 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-1.5 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-1.5 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-1.5 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-1.5 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-1.5 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-1.5 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-1.5 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-1.5 px-2 py-1.5 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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('comment')"
>
<span class="text-base">💬</span>
<span>Comment</span>
</button>
<div class="relative">
<button
class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 justify-between text-sm"
[attr.data-submenu]="'add'"
(mouseenter)="onOpenSubmenu($event, 'add')"
(click)="toggleSubmenu($event, 'add')"
>
<div class="flex items-center gap-2.5">
<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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 justify-between text-sm"
[attr.data-submenu]="'convert'"
(mouseenter)="onOpenSubmenu($event, 'convert')"
(click)="toggleSubmenu($event, 'convert')"
>
<div class="flex items-center gap-2.5">
<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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 justify-between text-sm"
[attr.data-submenu]="'background'"
(mouseenter)="onOpenSubmenu($event, 'background')"
(click)="toggleSubmenu($event, 'background')"
>
<div class="flex items-center gap-2.5">
<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-2 w-[200px] 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-2">
<button
*ngFor="let color of backgroundColors"
class="w-5 h-5 rounded-full ring-1 transition hover:ring-2"
[style.backgroundColor]="color.value"
[attr.title]="color.name"
[ngClass]="{ 'ring-primary ring-2': isActiveBackgroundColor(color.value), 'ring-border 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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 justify-between text-sm"
(mouseenter)="onOpenSubmenu($event, 'borderColor')"
(click)="toggleSubmenu($event, 'borderColor')"
>
<div class="flex items-center gap-2.5">
<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-2 w-[200px] 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-2">
<button
*ngFor="let color of backgroundColors"
class="w-5 h-5 rounded-full ring-1 transition hover:ring-2"
[style.backgroundColor]="color.value"
[attr.title]="color.name"
[ngClass]="{ 'ring-primary ring-2': isActiveBorderColor(color.value), 'ring-border 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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 justify-between text-sm"
(mouseenter)="onOpenSubmenu($event, 'lineColor')"
(click)="toggleSubmenu($event, 'lineColor')"
>
<div class="flex items-center gap-2.5">
<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-2 w-[200px] 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-2">
<button
*ngFor="let color of backgroundColors"
class="w-5 h-5 rounded-full ring-1 transition hover:ring-2"
[style.backgroundColor]="color.value"
[attr.title]="color.name"
[ngClass]="{ 'ring-primary ring-2': isActiveLineColor(color.value), 'ring-border 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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 justify-between text-sm"
[attr.data-submenu]="'codeLanguage'"
(mouseenter)="onOpenSubmenu($event, 'codeLanguage')"
(click)="toggleSubmenu($event, 'codeLanguage')"
>
<div class="flex items-center gap-2.5">
<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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 justify-between text-sm"
[attr.data-submenu]="'codeTheme'"
(mouseenter)="onOpenSubmenu($event, 'codeTheme')"
(click)="toggleSubmenu($event, 'codeTheme')"
>
<div class="flex items-center gap-2.5">
<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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('copyCode')"
>
<span class="text-base">📋</span>
<span>Copy code</span>
</button>
<!-- Toggle wrap -->
<button
class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('toggleWrap')"
>
<span class="text-base">{{ getCodeWrapIcon() }}</span>
<span>Enable wrap</span>
</button>
<!-- Toggle line numbers -->
<button
class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 justify-between text-sm"
[attr.data-submenu]="'imageAspectRatio'"
(mouseenter)="onOpenSubmenu($event, 'imageAspectRatio')"
(click)="toggleSubmenu($event, 'imageAspectRatio')"
>
<div class="flex items-center gap-2.5">
<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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 justify-between text-sm"
[attr.data-submenu]="'imageAlignment'"
(mouseenter)="onOpenSubmenu($event, 'imageAlignment')"
(click)="toggleSubmenu($event, 'imageAlignment')"
>
<div class="flex items-center gap-2.5">
<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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('imageReplace')"
>
<span class="text-base">🖼️</span>
<span>Replace</span>
</button>
<!-- Rotate image -->
<button
class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('imageRotate')"
>
<span class="text-base">🔄</span>
<span>Rotate 90°</span>
</button>
<!-- Set as preview -->
<button
class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('imageSetPreview')"
>
<span class="text-base">⭐</span>
<span>Set as preview</span>
</button>
<!-- OCR -->
<button
class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('imageOCR')"
>
<span class="text-base">🧠</span>
<span>Get text from image (OCR)</span>
</button>
<!-- Download -->
<button
class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('imageDownload')"
>
<span class="text-base">⬇️</span>
<span>Download</span>
</button>
<!-- View full size -->
<button
class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('imageOpenTab')"
>
<span class="text-base">🪟</span>
<span>Open in new tab</span>
</button>
<!-- Image info -->
<button
class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 justify-between text-sm"
[attr.data-submenu]="'tableLayout'"
(mouseenter)="onOpenSubmenu($event, 'tableLayout')"
(click)="toggleSubmenu($event, 'tableLayout')"
>
<div class="flex items-center gap-2.5">
<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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('copyTable')"
>
<span class="text-base">📋</span>
<span>Copy table</span>
</button>
<!-- Filter -->
<button
class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('filterTable')"
>
<span class="text-base">🔍</span>
<span>Filter</span>
</button>
<!-- Import from CSV -->
<button
class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(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-3 py-1.5 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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('duplicate')"
>
<span class="text-base">📋</span>
<span>Duplicate</span>
</button>
<button
class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('copy')"
>
<span class="text-base">📄</span>
<span>Copy block</span>
</button>
<button
class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('lock')"
>
<span class="text-base">🔒</span>
<span>Lock block</span>
</button>
<button
class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(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-3 py-1.5 hover:bg-red-500/10 text-red-500 transition flex items-center gap-2.5 text-sm"
(click)="onAction('delete')"
>
<span class="text-base">🗑️</span>
<span>Delete</span>
</button>
</div>
<!-- Convert to submenu -->
<div
*ngIf="showSubmenu === 'convert' && convertOptions.length"
class="submenu-pro bg-surface1 border border-border rounded-lg shadow-xl min-w-[260px] max-h-[420px] overflow-y-auto p-1.5"
[attr.data-submenu-panel]="'convert'"
[ngStyle]="submenuStyle['convert']"
(mouseenter)="keepSubmenuOpen('convert')"
(mouseleave)="closeSubmenu()"
(mousedown)="$event.stopPropagation()"
>
@for (group of groupedConvertOptions | keyvalue; track group.key) {
<div class="mb-1.5 last:mb-0">
<div class="text-[10px] font-semibold tracking-wide text-text-muted uppercase px-3 pt-2 pb-1">{{ group.key }}</div>
@for (item of group.value; track item.label) {
<button
class="w-full text-left px-3 py-1 rounded-md hover:bg-surface-hover transition flex items-center justify-between text-[13px] leading-tight"
(mousedown)="onConvert(item.type, item.preset)"
(click)="$event.preventDefault()"
>
<div class="flex items-center gap-2.5">
<span class="text-sm w-5 h-5 flex items-center justify-center text-text-muted">{{ item.icon }}</span>
<span class="text-text-main truncate">{{ item.label }}</span>
</div>
<span class="shortcut-key">{{ item.shortcut }}</span>
</button>
}
</div>
}
</div>
<!-- Background submenu moved into the Background row above -->
</div>
`,
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<MenuAction>();
@Output() close = new EventEmitter<void>();
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<HTMLElement>;
// 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<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;
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;
}
}