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
1402 lines
57 KiB
TypeScript
1402 lines
57 KiB
TypeScript
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;
|
||
}
|
||
}
|