feat: add file block with drag-and-drop support - Implemented file upload block with preview support for images, videos, PDFs, text, code, and DOCX files - Added drag-and-drop directive for files with visual drop indicators in root and column contexts - Created file icon component with FontAwesome integration for 50+ file type icons ```
1374 lines
58 KiB
TypeScript
1374 lines
58 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';
|
||
|
||
export interface MenuAction {
|
||
type: 'comment' | 'add' | 'convert' | 'background' | 'lineColor' | 'borderColor' | 'codeTheme' | 'codeLanguage' | 'copyCode' | 'toggleWrap' | 'toggleLineNumbers' | 'addCaption' | 'tableLayout' | 'copyTable' | 'filterTable' | 'importCSV' | 'tableHelp' | 'insertColumn' | 'imageAspectRatio' | 'imageAlignment' | 'imageDefaultSize' | 'imageReplace' | 'imageRotate' | 'imageSetPreview' | 'imageOCR' | 'imageDownload' | 'imageViewFull' | 'imageOpenTab' | 'imageInfo' | 'duplicate' | 'copy' | 'lock' | 'copyLink' | 'delete' | 'align' | 'indent';
|
||
payload?: any;
|
||
}
|
||
|
||
@Component({
|
||
selector: 'app-block-context-menu',
|
||
standalone: true,
|
||
imports: [CommonModule],
|
||
template: `
|
||
<div
|
||
*ngIf="visible"
|
||
#menu
|
||
class="ctx fixed min-w-[240px] py-2"
|
||
[style.left.px]="left"
|
||
[style.top.px]="top"
|
||
role="menu"
|
||
(mousedown)="$event.stopPropagation()"
|
||
(click)="$event.stopPropagation()"
|
||
(contextmenu)="$event.preventDefault()"
|
||
>
|
||
<!-- Alignment toolbar (image has dedicated icons + size buttons). Non-image keeps generic align + indent. -->
|
||
<div class="flex items-center gap-1 px-3 py-2 border-b border-border">
|
||
@if (block.type === 'image') {
|
||
<!-- Image alignment: Left / Center / Right (custom icons like in ref) -->
|
||
<button
|
||
class="p-2 rounded hover:bg-surface2 transition"
|
||
title="Align left"
|
||
(click)="onAlignImage('left')"
|
||
>
|
||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||
<rect x="3" y="5.5" width="18" height="13" rx="2" opacity="0.35"/>
|
||
<rect x="5" y="8" width="7" height="8" rx="1" fill="currentColor"/>
|
||
</svg>
|
||
</button>
|
||
<button
|
||
class="p-2 rounded hover:bg-surface2 transition"
|
||
title="Align center"
|
||
(click)="onAlignImage('center')"
|
||
>
|
||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||
<rect x="3" y="5.5" width="18" height="13" rx="2" opacity="0.35"/>
|
||
<rect x="8.5" y="8" width="7" height="8" rx="1" fill="currentColor"/>
|
||
</svg>
|
||
</button>
|
||
<button
|
||
class="p-2 rounded hover:bg-surface2 transition"
|
||
title="Align right"
|
||
(click)="onAlignImage('right')"
|
||
>
|
||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||
<rect x="3" y="5.5" width="18" height="13" rx="2" opacity="0.35"/>
|
||
<rect x="12" y="8" width="7" height="8" rx="1" fill="currentColor"/>
|
||
</svg>
|
||
</button>
|
||
<div class="w-px h-5 mx-1 bg-border"></div>
|
||
<!-- Default size and Full width -->
|
||
<button
|
||
class="p-2 rounded hover:bg-surface2 transition"
|
||
title="Default size"
|
||
(click)="onImageDefaultSize()"
|
||
>
|
||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||
<rect x="3" y="5.5" width="18" height="13" rx="2" opacity="0.35"/>
|
||
<rect x="9" y="8" width="6" height="8" rx="1" fill="currentColor"/>
|
||
</svg>
|
||
</button>
|
||
<button
|
||
class="p-2 rounded hover:bg-surface2 transition"
|
||
title="Full width"
|
||
(click)="onAction('imageAlignment', { alignment: 'full' })"
|
||
>
|
||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||
<rect x="3" y="5.5" width="18" height="13" rx="2" opacity="0.35"/>
|
||
<rect x="4.5" y="8" width="15" height="8" rx="1" fill="currentColor"/>
|
||
</svg>
|
||
</button>
|
||
} @else {
|
||
<button
|
||
*ngFor="let align of alignments"
|
||
class="p-2 rounded hover:bg-surface2 transition"
|
||
[title]="align.label"
|
||
(click)="onAlign(align.value)"
|
||
>
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||
<path *ngFor="let line of align.lines" [attr.d]="line"/>
|
||
</svg>
|
||
</button>
|
||
<div class="w-px h-5 mx-1 bg-border"></div>
|
||
<button
|
||
class="p-2 rounded hover:bg-surface2 transition"
|
||
title="Increase indent"
|
||
(click)="onIndent(1)"
|
||
>
|
||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M8 6h13"/>
|
||
<path d="M8 12h13"/>
|
||
<path d="M8 18h13"/>
|
||
<path d="M3 8l4 4-4 4"/>
|
||
</svg>
|
||
</button>
|
||
<button
|
||
class="p-2 rounded hover:bg-surface2 transition"
|
||
title="Decrease indent"
|
||
(click)="onIndent(-1)"
|
||
>
|
||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M8 6h13"/>
|
||
<path d="M8 12h13"/>
|
||
<path d="M8 18h13"/>
|
||
<path d="M7 12H3"/>
|
||
</svg>
|
||
</button>
|
||
}
|
||
</div>
|
||
|
||
<!-- Image quick ratios row (top, only for image) -->
|
||
@if (block.type === 'image') {
|
||
<div class="flex items-center gap-2 px-3 py-2 border-b border-border">
|
||
<div class="text-xs text-text-muted mr-1">Aspect</div>
|
||
<button class="px-2 py-1 text-xs rounded border border-border hover:bg-surface2"
|
||
[class.bg-primary/10]="isActiveAspectRatio('free')"
|
||
(click)="onAction('imageAspectRatio', { ratio: 'free' })">Free</button>
|
||
<button class="px-2 py-1 text-xs rounded border border-border hover:bg-surface2"
|
||
[class.bg-primary/10]="isActiveAspectRatio('16:9')"
|
||
(click)="onAction('imageAspectRatio', { ratio: '16:9' })">16:9</button>
|
||
<button class="px-2 py-1 text-xs rounded border border-border hover:bg-surface2"
|
||
[class.bg-primary/10]="isActiveAspectRatio('4:3')"
|
||
(click)="onAction('imageAspectRatio', { ratio: '4:3' })">4:3</button>
|
||
<button class="px-2 py-1 text-xs rounded border border-border hover:bg-surface2"
|
||
[class.bg-primary/10]="isActiveAspectRatio('1:1')"
|
||
(click)="onAction('imageAspectRatio', { ratio: '1:1' })">1:1</button>
|
||
<button class="px-2 py-1 text-xs rounded border border-border hover:bg-surface2"
|
||
[class.bg-primary/10]="isActiveAspectRatio('3:2')"
|
||
(click)="onAction('imageAspectRatio', { ratio: '3:2' })">3:2</button>
|
||
</div>
|
||
}
|
||
|
||
<!-- Main menu items -->
|
||
<div class="py-1">
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
|
||
(click)="onAction('comment')"
|
||
>
|
||
<span class="text-base">💬</span>
|
||
<span>Comment</span>
|
||
</button>
|
||
|
||
<div class="relative">
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3 justify-between"
|
||
[attr.data-submenu]="'add'"
|
||
(mouseenter)="onOpenSubmenu($event, 'add')"
|
||
(click)="toggleSubmenu($event, 'add')"
|
||
>
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-base">➕</span>
|
||
<span>Add block</span>
|
||
</div>
|
||
<span class="text-xs">›</span>
|
||
</button>
|
||
|
||
<!-- Add block submenu -->
|
||
<div
|
||
*ngIf="showSubmenu === 'add'"
|
||
class="bg-surface1 border border-border rounded-lg shadow-xl p-2 w-[180px] z-50"
|
||
[attr.data-submenu-panel]="'add'"
|
||
[ngStyle]="submenuStyle['add']"
|
||
(mouseenter)="keepSubmenuOpen('add')"
|
||
(mouseleave)="closeSubmenu()"
|
||
>
|
||
<button class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm" (click)="onAction('add', { position: 'above' })">Above</button>
|
||
<button class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm" (click)="onAction('add', { position: 'below' })">Below</button>
|
||
<div class="h-px bg-border my-1"></div>
|
||
<button class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm" (click)="onAction('add', { position: 'left' })">Left</button>
|
||
<button class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm" (click)="onAction('add', { position: 'right' })">Right</button>
|
||
</div>
|
||
</div>
|
||
|
||
@if (convertOptions.length) {
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3 justify-between"
|
||
[attr.data-submenu]="'convert'"
|
||
(mouseenter)="onOpenSubmenu($event, 'convert')"
|
||
(click)="toggleSubmenu($event, 'convert')"
|
||
>
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-base">🔄</span>
|
||
<span>Convert to</span>
|
||
</div>
|
||
<span class="text-xs">›</span>
|
||
</button>
|
||
}
|
||
|
||
<div class="relative">
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3 justify-between"
|
||
[attr.data-submenu]="'background'"
|
||
(mouseenter)="onOpenSubmenu($event, 'background')"
|
||
(click)="toggleSubmenu($event, 'background')"
|
||
>
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-base">🎨</span>
|
||
<span>Background color</span>
|
||
</div>
|
||
<span class="text-xs">›</span>
|
||
</button>
|
||
|
||
<!-- Background color submenu (anchored to row, no gap) -->
|
||
<div
|
||
*ngIf="showSubmenu === 'background'"
|
||
class="bg-surface1 border border-border rounded-lg shadow-xl p-3 w-[240px] z-50"
|
||
[attr.data-submenu-panel]="'background'"
|
||
[ngStyle]="submenuStyle['background']"
|
||
(mouseenter)="onColorMenuEnter('background'); keepSubmenuOpen('background')"
|
||
(mouseleave)="onColorMenuLeave('background'); closeSubmenu()"
|
||
>
|
||
<div class="grid grid-cols-5 gap-3">
|
||
<button
|
||
*ngFor="let color of backgroundColors"
|
||
class="w-7 h-7 rounded-full ring-2 transition"
|
||
[style.backgroundColor]="color.value"
|
||
[attr.title]="color.name"
|
||
[ngClass]="{ 'ring-primary': isActiveBackgroundColor(color.value), 'ring-transparent hover:ring-primary': !isActiveBackgroundColor(color.value) }"
|
||
(mouseenter)="onColorHover('background', color.value)"
|
||
(click)="onBackgroundColor(color.value); onColorConfirm('background', color.value)"
|
||
></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Border color (Hint blocks only) -->
|
||
<div class="relative" *ngIf="block.type === 'hint'">
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3 justify-between"
|
||
(mouseenter)="onOpenSubmenu($event, 'borderColor')"
|
||
(click)="toggleSubmenu($event, 'borderColor')"
|
||
>
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-base">🎨</span>
|
||
<span>Border color</span>
|
||
</div>
|
||
<span class="text-xs">›</span>
|
||
</button>
|
||
|
||
<!-- Border color submenu -->
|
||
<div
|
||
*ngIf="showSubmenu === 'borderColor'"
|
||
class="bg-surface1 border border-border rounded-lg shadow-xl p-3 w-[240px] z-50"
|
||
[attr.data-submenu-panel]="'borderColor'"
|
||
[ngStyle]="submenuStyle['borderColor']"
|
||
(mouseenter)="onColorMenuEnter('borderColor'); keepSubmenuOpen('borderColor')"
|
||
(mouseleave)="onColorMenuLeave('borderColor'); closeSubmenu()"
|
||
>
|
||
<div class="grid grid-cols-5 gap-3">
|
||
<button
|
||
*ngFor="let color of backgroundColors"
|
||
class="w-7 h-7 rounded-full ring-2 transition"
|
||
[style.backgroundColor]="color.value"
|
||
[attr.title]="color.name"
|
||
[ngClass]="{ 'ring-primary': isActiveBorderColor(color.value), 'ring-transparent hover:ring-primary': !isActiveBorderColor(color.value) }"
|
||
(mouseenter)="onColorHover('borderColor', color.value)"
|
||
(click)="onBorderColor(color.value); onColorConfirm('borderColor', color.value)"
|
||
></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Line color (Quote and Hint blocks) -->
|
||
<div class="relative" *ngIf="block.type === 'quote' || block.type === 'hint'">
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3 justify-between"
|
||
(mouseenter)="onOpenSubmenu($event, 'lineColor')"
|
||
(click)="toggleSubmenu($event, 'lineColor')"
|
||
>
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-base">🖌️</span>
|
||
<span>Line color</span>
|
||
</div>
|
||
<span class="text-xs">›</span>
|
||
</button>
|
||
|
||
<!-- Line color submenu -->
|
||
<div
|
||
*ngIf="showSubmenu === 'lineColor'"
|
||
class="bg-surface1 border border-border rounded-lg shadow-xl p-3 w-[240px] z-50"
|
||
[attr.data-submenu-panel]="'lineColor'"
|
||
[ngStyle]="submenuStyle['lineColor']"
|
||
(mouseenter)="onColorMenuEnter('lineColor'); keepSubmenuOpen('lineColor')"
|
||
(mouseleave)="onColorMenuLeave('lineColor'); closeSubmenu()"
|
||
>
|
||
<div class="grid grid-cols-5 gap-3">
|
||
<button
|
||
*ngFor="let color of backgroundColors"
|
||
class="w-7 h-7 rounded-full ring-2 transition"
|
||
[style.backgroundColor]="color.value"
|
||
[attr.title]="color.name"
|
||
[ngClass]="{ 'ring-primary': isActiveLineColor(color.value), 'ring-transparent hover:ring-primary': !isActiveLineColor(color.value) }"
|
||
(mouseenter)="onColorHover('lineColor', color.value)"
|
||
(click)="onLineColor(color.value); onColorConfirm('lineColor', color.value)"
|
||
></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Code block specific options -->
|
||
@if (block.type === 'code') {
|
||
<!-- Language submenu -->
|
||
<div class="relative">
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3 justify-between"
|
||
[attr.data-submenu]="'codeLanguage'"
|
||
(mouseenter)="onOpenSubmenu($event, 'codeLanguage')"
|
||
(click)="toggleSubmenu($event, 'codeLanguage')"
|
||
>
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-base">🔤</span>
|
||
<span>Language</span>
|
||
</div>
|
||
<span class="text-xs">›</span>
|
||
</button>
|
||
|
||
<!-- Language submenu -->
|
||
<div
|
||
*ngIf="showSubmenu === 'codeLanguage'"
|
||
class="bg-surface1 border border-border rounded-lg shadow-xl p-2 w-[200px] max-h-[300px] overflow-y-auto z-50"
|
||
[attr.data-submenu-panel]="'codeLanguage'"
|
||
[ngStyle]="submenuStyle['codeLanguage']"
|
||
(mouseenter)="keepSubmenuOpen('codeLanguage')"
|
||
(mouseleave)="closeSubmenu()"
|
||
>
|
||
@for (lang of codeThemeService.getLanguages(); track lang) {
|
||
<button
|
||
class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm"
|
||
[class.bg-primary/10]="isActiveLanguage(lang)"
|
||
(click)="onCodeLanguage(lang)"
|
||
>
|
||
{{ codeThemeService.getLanguageDisplay(lang) }}
|
||
</button>
|
||
}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Theme submenu -->
|
||
<div class="relative">
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3 justify-between"
|
||
[attr.data-submenu]="'codeTheme'"
|
||
(mouseenter)="onOpenSubmenu($event, 'codeTheme')"
|
||
(click)="toggleSubmenu($event, 'codeTheme')"
|
||
>
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-base">🎨</span>
|
||
<span>Theme</span>
|
||
</div>
|
||
<span class="text-xs">›</span>
|
||
</button>
|
||
|
||
<!-- Theme submenu -->
|
||
<div
|
||
*ngIf="showSubmenu === 'codeTheme'"
|
||
class="bg-surface1 border border-border rounded-lg shadow-xl p-2 w-[180px] z-50"
|
||
[attr.data-submenu-panel]="'codeTheme'"
|
||
[ngStyle]="submenuStyle['codeTheme']"
|
||
(mouseenter)="keepSubmenuOpen('codeTheme')"
|
||
(mouseleave)="closeSubmenu()"
|
||
>
|
||
@for (theme of codeThemeService.getThemes(); track theme.id) {
|
||
<button
|
||
class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm"
|
||
[class.bg-primary/10]="isActiveTheme(theme.id)"
|
||
(click)="onCodeTheme(theme.id)"
|
||
>
|
||
{{ theme.name }}
|
||
</button>
|
||
}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Copy code -->
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
|
||
(click)="onAction('copyCode')"
|
||
>
|
||
<span class="text-base">📋</span>
|
||
<span>Copy code</span>
|
||
</button>
|
||
|
||
<!-- Toggle wrap -->
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
|
||
(click)="onAction('toggleWrap')"
|
||
>
|
||
<span class="text-base">{{ getCodeWrapIcon() }}</span>
|
||
<span>Enable wrap</span>
|
||
</button>
|
||
|
||
<!-- Toggle line numbers -->
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
|
||
(click)="onAction('toggleLineNumbers')"
|
||
>
|
||
<span class="text-base">{{ getCodeLineNumbersIcon() }}</span>
|
||
<span>Show line numbers</span>
|
||
</button>
|
||
}
|
||
|
||
<!-- Image block specific options -->
|
||
@if (block.type === 'image') {
|
||
<!-- Add caption -->
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
|
||
(click)="onAction('addCaption')"
|
||
>
|
||
<span class="text-base">📝</span>
|
||
<span>Add caption</span>
|
||
</button>
|
||
|
||
<!-- Aspect ratio submenu -->
|
||
<div class="relative">
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3 justify-between"
|
||
[attr.data-submenu]="'imageAspectRatio'"
|
||
(mouseenter)="onOpenSubmenu($event, 'imageAspectRatio')"
|
||
(click)="toggleSubmenu($event, 'imageAspectRatio')"
|
||
>
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-base">📐</span>
|
||
<span>Aspect ratio</span>
|
||
</div>
|
||
<span class="text-xs">›</span>
|
||
</button>
|
||
|
||
<div
|
||
*ngIf="showSubmenu === 'imageAspectRatio'"
|
||
class="bg-surface1 border border-border rounded-lg shadow-xl p-2 w-[180px] z-50"
|
||
[attr.data-submenu-panel]="'imageAspectRatio'"
|
||
[ngStyle]="submenuStyle['imageAspectRatio']"
|
||
(mouseenter)="keepSubmenuOpen('imageAspectRatio')"
|
||
(mouseleave)="closeSubmenu()"
|
||
>
|
||
<button class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm" [class.bg-primary/10]="isActiveAspectRatio('free')" (click)="onAction('imageAspectRatio', { ratio: 'free' })">Free</button>
|
||
<button class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm" [class.bg-primary/10]="isActiveAspectRatio('16:9')" (click)="onAction('imageAspectRatio', { ratio: '16:9' })">16:9</button>
|
||
<button class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm" [class.bg-primary/10]="isActiveAspectRatio('4:3')" (click)="onAction('imageAspectRatio', { ratio: '4:3' })">4:3</button>
|
||
<button class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm" [class.bg-primary/10]="isActiveAspectRatio('1:1')" (click)="onAction('imageAspectRatio', { ratio: '1:1' })">1:1</button>
|
||
<button class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm" [class.bg-primary/10]="isActiveAspectRatio('3:2')" (click)="onAction('imageAspectRatio', { ratio: '3:2' })">3:2</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Alignment submenu -->
|
||
<div class="relative">
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3 justify-between"
|
||
[attr.data-submenu]="'imageAlignment'"
|
||
(mouseenter)="onOpenSubmenu($event, 'imageAlignment')"
|
||
(click)="toggleSubmenu($event, 'imageAlignment')"
|
||
>
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-base">↔️</span>
|
||
<span>Alignment</span>
|
||
</div>
|
||
<span class="text-xs">›</span>
|
||
</button>
|
||
|
||
<div
|
||
*ngIf="showSubmenu === 'imageAlignment'"
|
||
class="bg-surface1 border border-border rounded-lg shadow-xl p-2 w-[180px] z-50"
|
||
[attr.data-submenu-panel]="'imageAlignment'"
|
||
[ngStyle]="submenuStyle['imageAlignment']"
|
||
(mouseenter)="keepSubmenuOpen('imageAlignment')"
|
||
(mouseleave)="closeSubmenu()"
|
||
>
|
||
<button class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm" [class.bg-primary/10]="isActiveImageAlignment('left')" (click)="onAction('imageAlignment', { alignment: 'left' })">Left</button>
|
||
<button class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm" [class.bg-primary/10]="isActiveImageAlignment('center')" (click)="onAction('imageAlignment', { alignment: 'center' })">Center</button>
|
||
<button class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm" [class.bg-primary/10]="isActiveImageAlignment('right')" (click)="onAction('imageAlignment', { alignment: 'right' })">Right</button>
|
||
<button class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm" [class.bg-primary/10]="isActiveImageAlignment('full')" (click)="onAction('imageAlignment', { alignment: 'full' })">Full width</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Replace image -->
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
|
||
(click)="onAction('imageReplace')"
|
||
>
|
||
<span class="text-base">🖼️</span>
|
||
<span>Replace</span>
|
||
</button>
|
||
|
||
<!-- Rotate image -->
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
|
||
(click)="onAction('imageRotate')"
|
||
>
|
||
<span class="text-base">🔄</span>
|
||
<span>Rotate 90°</span>
|
||
</button>
|
||
|
||
<!-- Set as preview -->
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
|
||
(click)="onAction('imageSetPreview')"
|
||
>
|
||
<span class="text-base">⭐</span>
|
||
<span>Set as preview</span>
|
||
</button>
|
||
|
||
<!-- OCR -->
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
|
||
(click)="onAction('imageOCR')"
|
||
>
|
||
<span class="text-base">🧠</span>
|
||
<span>Get text from image (OCR)</span>
|
||
</button>
|
||
|
||
<!-- Download -->
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
|
||
(click)="onAction('imageDownload')"
|
||
>
|
||
<span class="text-base">⬇️</span>
|
||
<span>Download</span>
|
||
</button>
|
||
|
||
<!-- View full size -->
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
|
||
(click)="onAction('imageViewFull')"
|
||
>
|
||
<span class="text-base">🔎</span>
|
||
<span>View full size</span>
|
||
</button>
|
||
|
||
<!-- Open in new tab -->
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
|
||
(click)="onAction('imageOpenTab')"
|
||
>
|
||
<span class="text-base">🪟</span>
|
||
<span>Open in new tab</span>
|
||
</button>
|
||
|
||
<!-- Image info -->
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
|
||
(click)="onAction('imageInfo')"
|
||
>
|
||
<span class="text-base">ℹ️</span>
|
||
<span>Image info</span>
|
||
</button>
|
||
}
|
||
|
||
<!-- Table block specific options -->
|
||
@if (block.type === 'table') {
|
||
<!-- Add caption -->
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
|
||
(click)="onAction('addCaption')"
|
||
>
|
||
<span class="text-base">📝</span>
|
||
<span>{{ hasCaption() ? 'Edit caption' : 'Add caption' }}</span>
|
||
</button>
|
||
|
||
<!-- Table layout -->
|
||
<div class="relative">
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3 justify-between"
|
||
[attr.data-submenu]="'tableLayout'"
|
||
(mouseenter)="onOpenSubmenu($event, 'tableLayout')"
|
||
(click)="toggleSubmenu($event, 'tableLayout')"
|
||
>
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-base">📐</span>
|
||
<span>Table layout</span>
|
||
</div>
|
||
<span class="text-xs">›</span>
|
||
</button>
|
||
|
||
<!-- Layout submenu -->
|
||
<div
|
||
*ngIf="showSubmenu === 'tableLayout'"
|
||
class="bg-surface1 border border-border rounded-lg shadow-xl p-2 w-[180px] z-50"
|
||
[attr.data-submenu-panel]="'tableLayout'"
|
||
[ngStyle]="submenuStyle['tableLayout']"
|
||
(mouseenter)="keepSubmenuOpen('tableLayout')"
|
||
(mouseleave)="closeSubmenu()"
|
||
>
|
||
<button
|
||
class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm"
|
||
[class.bg-primary/10]="isActiveLayout('auto')"
|
||
(click)="onTableLayout('auto')"
|
||
>
|
||
Auto
|
||
</button>
|
||
<button
|
||
class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm"
|
||
[class.bg-primary/10]="isActiveLayout('fixed')"
|
||
(click)="onTableLayout('fixed')"
|
||
>
|
||
Fixed
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Copy table -->
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
|
||
(click)="onAction('copyTable')"
|
||
>
|
||
<span class="text-base">📋</span>
|
||
<span>Copy table</span>
|
||
</button>
|
||
|
||
<!-- Filter -->
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
|
||
(click)="onAction('filterTable')"
|
||
>
|
||
<span class="text-base">🔍</span>
|
||
<span>Filter</span>
|
||
</button>
|
||
|
||
<!-- Import from CSV -->
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
|
||
(click)="onAction('importCSV')"
|
||
>
|
||
<span class="text-base">📥</span>
|
||
<span>Import from CSV</span>
|
||
</button>
|
||
|
||
<!-- Insert column (submenu inline avec 3 icônes) -->
|
||
<div class="px-4 py-2 border-t border-border dark:border-gray-700">
|
||
<div class="text-xs text-text-muted dark:text-neutral-500 mb-2">Insert column</div>
|
||
<div class="flex gap-2">
|
||
<button
|
||
class="flex-1 p-2 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition"
|
||
(click)="onInsertColumn('left')"
|
||
title="Insert left"
|
||
>
|
||
<svg class="w-4 h-4 mx-auto" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||
<rect x="4" y="4" width="6" height="16" rx="1"/>
|
||
<rect x="14" y="4" width="6" height="16" rx="1" opacity="0.3"/>
|
||
</svg>
|
||
</button>
|
||
<button
|
||
class="flex-1 p-2 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition"
|
||
(click)="onInsertColumn('center')"
|
||
title="Insert center"
|
||
>
|
||
<svg class="w-4 h-4 mx-auto" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||
<rect x="4" y="4" width="6" height="16" rx="1" opacity="0.3"/>
|
||
<rect x="9" y="4" width="6" height="16" rx="1"/>
|
||
<rect x="14" y="4" width="6" height="16" rx="1" opacity="0.3"/>
|
||
</svg>
|
||
</button>
|
||
<button
|
||
class="flex-1 p-2 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition"
|
||
(click)="onInsertColumn('right')"
|
||
title="Insert right"
|
||
>
|
||
<svg class="w-4 h-4 mx-auto" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||
<rect x="4" y="4" width="6" height="16" rx="1" opacity="0.3"/>
|
||
<rect x="14" y="4" width="6" height="16" rx="1"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Help -->
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 dark:hover:bg-gray-700 transition flex items-center gap-3"
|
||
(click)="onAction('tableHelp')"
|
||
>
|
||
<span class="text-base">❓</span>
|
||
<span>Help</span>
|
||
</button>
|
||
}
|
||
|
||
<div class="h-px bg-border dark:bg-gray-700 my-1"></div>
|
||
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
|
||
(click)="onAction('duplicate')"
|
||
>
|
||
<span class="text-base">📋</span>
|
||
<span>Duplicate</span>
|
||
</button>
|
||
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
|
||
(click)="onAction('copy')"
|
||
>
|
||
<span class="text-base">📄</span>
|
||
<span>Copy block</span>
|
||
</button>
|
||
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
|
||
(click)="onAction('lock')"
|
||
>
|
||
<span class="text-base">🔒</span>
|
||
<span>Lock block</span>
|
||
</button>
|
||
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3"
|
||
(click)="onAction('copyLink')"
|
||
>
|
||
<span class="text-base">🔗</span>
|
||
<span>Copy Link</span>
|
||
</button>
|
||
|
||
<div class="h-px bg-border dark:bg-gray-700 my-1"></div>
|
||
|
||
<button
|
||
class="w-full text-left px-4 py-2 hover:bg-red-500/10 text-red-500 transition flex items-center gap-3"
|
||
(click)="onAction('delete')"
|
||
>
|
||
<span class="text-base">🗑️</span>
|
||
<span>Delete</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Convert to submenu -->
|
||
<div
|
||
*ngIf="showSubmenu === 'convert' && convertOptions.length"
|
||
class="bg-surface1 border border-border rounded-lg shadow-xl min-w-[280px] py-2"
|
||
[attr.data-submenu-panel]="'convert'"
|
||
[ngStyle]="submenuStyle['convert']"
|
||
(mouseenter)="keepSubmenuOpen('convert')"
|
||
(mouseleave)="closeSubmenu()"
|
||
(mousedown)="$event.stopPropagation()"
|
||
>
|
||
<button
|
||
*ngFor="let item of convertOptions"
|
||
class="w-full text-left px-4 py-2 hover:bg-surface2 dark:hover:bg-gray-700 transition flex items-center justify-between"
|
||
(mousedown)="onConvert(item.type, item.preset)"
|
||
(click)="$event.preventDefault()"
|
||
>
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-base w-5 flex items-center justify-center">
|
||
@if (item.type === 'list-item' && item.preset?.kind === 'check') {
|
||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="3" y="3" width="5" height="5" rx="1"/>
|
||
<path d="M10 6h10"/>
|
||
<rect x="3" y="10" width="5" height="5" rx="1"/>
|
||
<path d="M10 13h10"/>
|
||
<rect x="3" y="17" width="5" height="5" rx="1"/>
|
||
<path d="M10 20h10"/>
|
||
</svg>
|
||
} @else if (item.type === 'list-item' && item.preset?.kind === 'bullet') {
|
||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<circle cx="5.5" cy="5.5" r="1.5"/>
|
||
<path d="M10 6h10"/>
|
||
<circle cx="5.5" cy="12.5" r="1.5"/>
|
||
<path d="M10 13h10"/>
|
||
<circle cx="5.5" cy="19.5" r="1.5"/>
|
||
<path d="M10 20h10"/>
|
||
</svg>
|
||
} @else if (item.type === 'list-item' && item.preset?.kind === 'numbered') {
|
||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<text x="4" y="7" font-size="5" text-anchor="middle" dominant-baseline="middle" fill="currentColor" stroke="none">1</text>
|
||
<path d="M10 7h10"/>
|
||
<text x="4" y="14" font-size="5" text-anchor="middle" dominant-baseline="middle" fill="currentColor" stroke="none">2</text>
|
||
<path d="M10 14h10"/>
|
||
<text x="4" y="21" font-size="5" text-anchor="middle" dominant-baseline="middle" fill="currentColor" stroke="none">3</text>
|
||
<path d="M10 21h10"/>
|
||
</svg>
|
||
} @else {
|
||
{{ item.icon }}
|
||
}
|
||
</span>
|
||
<span>{{ item.label }}</span>
|
||
</div>
|
||
<span class="text-xs text-text-muted">{{ item.shortcut }}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Background submenu moved into the Background row above -->
|
||
</div>
|
||
`,
|
||
styles: [`
|
||
:host { display: contents; }
|
||
.ctx {
|
||
pointer-events: auto;
|
||
border-radius: 0.75rem;
|
||
box-shadow: 0 10px 30px rgba(0,0,0,.25);
|
||
background: var(--card, #ffffff);
|
||
border: 1px solid var(--border, #e5e7eb);
|
||
color: var(--text-main, var(--fg, #111827));
|
||
z-index: 2147483646;
|
||
max-height: calc(100vh - 16px);
|
||
overflow-y: auto;
|
||
overflow-x: hidden; /* submenus are fixed-positioned; avoid horizontal scrollbar */
|
||
animation: fadeIn .12s ease-out;
|
||
}
|
||
/* Stronger highlight on hover/focus for all buttons inside the menu (override utility classes) */
|
||
.ctx button:hover,
|
||
.ctx button:focus,
|
||
.ctx [data-submenu-panel] button:hover {
|
||
background: var(--menu-hover, rgba(0,0,0,0.16)) !important;
|
||
}
|
||
.ctx button:focus { outline: none; }
|
||
@keyframes fadeIn { from { opacity:0; transform: scale(.97);} to { opacity:1; transform: scale(1);} }
|
||
`]
|
||
})
|
||
export class BlockContextMenuComponent implements OnChanges {
|
||
@Input() block!: Block;
|
||
@Input() visible = false;
|
||
@Input() position = { x: 0, y: 0 };
|
||
@Output() action = new EventEmitter<MenuAction>();
|
||
@Output() close = new EventEmitter<void>();
|
||
|
||
private documentService = inject(DocumentService);
|
||
private elementRef = inject(ElementRef);
|
||
readonly codeThemeService = inject(CodeThemeService);
|
||
private clipboardData: Block | null = null;
|
||
|
||
@ViewChild('menu') menuRef?: ElementRef<HTMLElement>;
|
||
|
||
// viewport-safe coordinates
|
||
left = 0;
|
||
top = 0;
|
||
private repositionRaf: number | null = null;
|
||
|
||
@HostListener('document:click', ['$event'])
|
||
onDocumentClick(event: MouseEvent): void {
|
||
const root = this.menuRef?.nativeElement;
|
||
if (this.visible && root && !root.contains(event.target as Node)) {
|
||
this.close.emit();
|
||
}
|
||
}
|
||
|
||
// Close on mousedown outside for immediate feedback
|
||
@HostListener('document:mousedown', ['$event'])
|
||
onDocumentMouseDown(event: MouseEvent): void {
|
||
const root = this.menuRef?.nativeElement;
|
||
if (this.visible && root && !root.contains(event.target as Node)) {
|
||
this.close.emit();
|
||
}
|
||
}
|
||
|
||
// Close when focus moves outside the menu (e.g., via Tab navigation)
|
||
@HostListener('document:focusin', ['$event'])
|
||
onDocumentFocusIn(event: FocusEvent): void {
|
||
const root = this.menuRef?.nativeElement;
|
||
if (this.visible && root && !root.contains(event.target as Node)) {
|
||
this.close.emit();
|
||
}
|
||
}
|
||
|
||
// Close when window loses focus (switching tabs/windows)
|
||
@HostListener('window:blur')
|
||
onWindowBlur() {
|
||
if (this.visible) {
|
||
this.close.emit();
|
||
}
|
||
}
|
||
|
||
@HostListener('window:resize') onResize() { if (this.visible) this.scheduleReposition(); }
|
||
@HostListener('window:scroll') onScroll() { if (this.visible) this.scheduleReposition(); }
|
||
|
||
// If hovering a non-submenu option within the main menu, close any open submenu
|
||
@HostListener('mouseover', ['$event'])
|
||
onMenuMouseOver(event: MouseEvent) {
|
||
if (!this.visible) return;
|
||
const root = this.menuRef?.nativeElement; if (!root) return;
|
||
const target = event.target as HTMLElement;
|
||
// Don't close if hovering over a submenu panel (they are fixed-positioned outside root)
|
||
const panel = this.showSubmenu ? document.querySelector(`[data-submenu-panel="${this.showSubmenu}"]`) as HTMLElement | null : null;
|
||
if (panel && panel.contains(target)) return;
|
||
if (!root.contains(target)) return;
|
||
const overAnchor = this._submenuAnchor ? (this._submenuAnchor === target || this._submenuAnchor.contains(target)) : false;
|
||
if (overAnchor) return;
|
||
const rowWithSubmenu = target.closest('[data-submenu]') as HTMLElement | null;
|
||
if (!rowWithSubmenu) {
|
||
this.closeSubmenu();
|
||
}
|
||
}
|
||
|
||
ngOnChanges(changes: SimpleChanges): void {
|
||
if (changes['visible']) {
|
||
if (this.visible) {
|
||
this.left = this.position.x;
|
||
this.top = this.position.y;
|
||
this.scheduleReposition();
|
||
queueMicrotask(() => this.focusFirstItem());
|
||
}
|
||
}
|
||
if ((changes['position']) && this.visible) {
|
||
this.left = this.position.x;
|
||
this.top = this.position.y;
|
||
this.scheduleReposition();
|
||
}
|
||
}
|
||
|
||
private scheduleReposition() {
|
||
if (this.repositionRaf != null) cancelAnimationFrame(this.repositionRaf);
|
||
const el = this.menuRef?.nativeElement; if (el) el.style.visibility = 'hidden';
|
||
this.repositionRaf = requestAnimationFrame(() => { this.repositionRaf = null; this.reposition(); });
|
||
}
|
||
|
||
private reposition() {
|
||
const el = this.menuRef?.nativeElement; if (!el) return;
|
||
const rect = el.getBoundingClientRect();
|
||
const vw = window.innerWidth; const vh = window.innerHeight;
|
||
let left = this.left; let top = this.top;
|
||
// horizontal clamp
|
||
if (left + rect.width > vw - 8) left = Math.max(8, vw - rect.width - 8);
|
||
if (left < 8) left = 8;
|
||
// vertical: open upwards if overflow
|
||
if (top + rect.height > vh - 8) {
|
||
top = Math.max(8, top - rect.height);
|
||
}
|
||
if (top < 8) top = 8;
|
||
// if still too tall, rely on max-height + scroll
|
||
this.left = left; this.top = top; if (el) el.style.visibility = 'visible';
|
||
// also keep any open submenu in position relative to its anchor
|
||
if (this.showSubmenu && this._submenuAnchor) {
|
||
this.positionSubmenu(this.showSubmenu, this._submenuAnchor);
|
||
}
|
||
}
|
||
|
||
// Keyboard navigation
|
||
@HostListener('window:keydown', ['$event'])
|
||
onKey(e: KeyboardEvent) {
|
||
if (!this.visible) return;
|
||
if (e.key === 'Escape') { this.close.emit(); e.preventDefault(); return; }
|
||
const items = this.getFocusableItems(); if (!items.length) return;
|
||
const active = document.activeElement as HTMLElement | null;
|
||
let idx = Math.max(0, items.indexOf(active || items[0]));
|
||
if (e.key === 'ArrowDown') { idx = (idx + 1) % items.length; items[idx].focus(); items[idx].scrollIntoView({ block: 'nearest' }); this.maybeCloseSubmenuOnFocusChange(items[idx]); e.preventDefault(); }
|
||
else if (e.key === 'ArrowUp') { idx = (idx - 1 + items.length) % items.length; items[idx].focus(); items[idx].scrollIntoView({ block: 'nearest' }); this.maybeCloseSubmenuOnFocusChange(items[idx]); e.preventDefault(); }
|
||
else if (e.key === 'Enter') { (items[idx] as HTMLButtonElement).click(); e.preventDefault(); }
|
||
else if (e.key === 'ArrowRight') { this.tryOpenSubmenuFor(items[idx]); e.preventDefault(); }
|
||
else if (e.key === 'ArrowLeft') { this.showSubmenu = null; e.preventDefault(); }
|
||
}
|
||
|
||
private getFocusableItems(): HTMLElement[] {
|
||
const root = this.menuRef?.nativeElement; if (!root) return [];
|
||
const all = Array.from(root.querySelectorAll('button')) as HTMLElement[];
|
||
return all.filter(el => el.offsetParent !== null);
|
||
}
|
||
|
||
private focusFirstItem() {
|
||
const first = this.getFocusableItems()[0]; if (first) first.focus();
|
||
}
|
||
|
||
private tryOpenSubmenuFor(btn: HTMLElement) {
|
||
const id = btn.getAttribute('data-submenu');
|
||
if (id) {
|
||
this.onOpenSubmenu({ currentTarget: btn } as any, id as any);
|
||
// focus first item inside submenu when available
|
||
setTimeout(() => {
|
||
const panel = document.querySelector(`[data-submenu-panel="${id}"]`) as HTMLElement | null;
|
||
const first = panel?.querySelector('button') as HTMLElement | null;
|
||
if (first) first.focus();
|
||
}, 0);
|
||
}
|
||
}
|
||
|
||
showSubmenu: 'add' | 'convert' | 'background' | 'lineColor' | 'borderColor' | 'codeTheme' | 'codeLanguage' | 'tableLayout' | 'imageAspectRatio' | 'imageAlignment' | null = null;
|
||
submenuStyle: Record<string, any> = {};
|
||
private _submenuAnchor: HTMLElement | null = null;
|
||
|
||
onOpenSubmenu(ev: Event, id: NonNullable<typeof this.showSubmenu>) {
|
||
const anchor = (ev.currentTarget as HTMLElement) || null;
|
||
this.showSubmenu = id;
|
||
this._submenuAnchor = anchor;
|
||
// compute after render
|
||
requestAnimationFrame(() => this.positionSubmenu(id, anchor));
|
||
}
|
||
|
||
toggleSubmenu(ev: Event, id: NonNullable<typeof this.showSubmenu>) {
|
||
if (this.showSubmenu === id) {
|
||
this.closeSubmenu();
|
||
} else {
|
||
this.onOpenSubmenu(ev, id);
|
||
}
|
||
}
|
||
|
||
keepSubmenuOpen(id: NonNullable<typeof this.showSubmenu>) {
|
||
this.showSubmenu = id;
|
||
if (this._submenuAnchor) this.positionSubmenu(id, this._submenuAnchor);
|
||
}
|
||
|
||
closeSubmenu() {
|
||
this.showSubmenu = null;
|
||
this._submenuAnchor = null;
|
||
}
|
||
|
||
private positionSubmenu(id: NonNullable<typeof this.showSubmenu>, anchor: HTMLElement | null) {
|
||
if (!anchor) return;
|
||
const panel = document.querySelector(`[data-submenu-panel="${id}"]`) as HTMLElement | null;
|
||
if (!panel) return;
|
||
const r = anchor.getBoundingClientRect();
|
||
const vw = window.innerWidth; const vh = window.innerHeight;
|
||
// ensure fixed positioning so it never affects the main menu scroll area
|
||
panel.style.position = 'fixed';
|
||
panel.style.maxHeight = Math.max(100, vh - 16) + 'px';
|
||
// First try opening to the right (small gap to allow mouse travel)
|
||
let left = r.right + 4;
|
||
// place top aligned with anchor top
|
||
let top = r.top;
|
||
// Measure panel size (after position temp offscreen)
|
||
panel.style.left = '-9999px'; panel.style.top = '-9999px';
|
||
const pw = panel.offsetWidth || 260; const ph = panel.offsetHeight || 200;
|
||
// Auto-invert horizontally if overflowing
|
||
if (left + pw > vw - 8) {
|
||
left = Math.max(8, r.left - pw - 2);
|
||
}
|
||
// Clamp vertical within viewport
|
||
if (top + ph > vh - 8) top = Math.max(8, vh - ph - 8);
|
||
if (top < 8) top = 8;
|
||
// Apply
|
||
this.submenuStyle[id] = { position: 'fixed', left: left + 'px', top: top + 'px' };
|
||
panel.style.left = left + 'px';
|
||
panel.style.top = top + 'px';
|
||
}
|
||
|
||
private maybeCloseSubmenuOnFocusChange(focused: HTMLElement) {
|
||
if (!this.showSubmenu) return;
|
||
const panel = document.querySelector(`[data-submenu-panel="${this.showSubmenu}"]`);
|
||
const isOnAnchorRow = focused.getAttribute('data-submenu') === this.showSubmenu;
|
||
const isInsidePanel = panel ? (panel as HTMLElement).contains(focused) : false;
|
||
if (!isOnAnchorRow && !isInsidePanel) {
|
||
this.closeSubmenu();
|
||
}
|
||
}
|
||
|
||
alignments = [
|
||
{ value: 'left', label: 'Align Left', lines: ['M3 6h12', 'M3 12h8', 'M3 18h12'] },
|
||
{ value: 'center', label: 'Align Center', lines: ['M6 6h12', 'M3 12h18', 'M6 18h12'] },
|
||
{ value: 'right', label: 'Align Right', lines: ['M9 6h12', 'M13 12h8', 'M9 18h12'] },
|
||
{ value: 'justify', label: 'Justify', lines: ['M3 6h18', 'M3 12h18', 'M3 18h18'] }
|
||
];
|
||
|
||
private previewState: {
|
||
kind: 'background' | 'borderColor' | 'lineColor' | null,
|
||
origBg?: string | undefined,
|
||
origBorder?: string | undefined,
|
||
origLine?: string | undefined,
|
||
confirmed?: boolean,
|
||
} = { kind: null };
|
||
|
||
onColorMenuEnter(kind: 'background' | 'borderColor' | 'lineColor') {
|
||
this.previewState = {
|
||
kind,
|
||
origBg: this.block?.meta?.bgColor,
|
||
origBorder: (this.block?.props as any)?.borderColor,
|
||
origLine: (this.block?.props as any)?.lineColor,
|
||
confirmed: false,
|
||
};
|
||
}
|
||
|
||
onColorHover(kind: 'background' | 'borderColor' | 'lineColor', value: string) {
|
||
const color = value === 'transparent' ? undefined : value;
|
||
if (kind === 'background') {
|
||
this.documentService.updateBlock(this.block.id, {
|
||
meta: { ...this.block.meta, bgColor: color }
|
||
} as any);
|
||
} else if (kind === 'borderColor') {
|
||
if (this.block.type === 'hint') {
|
||
this.documentService.updateBlockProps(this.block.id, {
|
||
...this.block.props,
|
||
borderColor: color
|
||
});
|
||
}
|
||
} else if (kind === 'lineColor') {
|
||
if (this.block.type === 'hint' || this.block.type === 'quote') {
|
||
this.documentService.updateBlockProps(this.block.id, {
|
||
...this.block.props,
|
||
lineColor: color
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
onColorConfirm(kind: 'background' | 'borderColor' | 'lineColor', value: string) {
|
||
// Mark as confirmed so we don't revert on leave
|
||
this.previewState.confirmed = true;
|
||
}
|
||
|
||
onColorMenuLeave(kind: 'background' | 'borderColor' | 'lineColor') {
|
||
if (this.previewState.kind !== kind) return;
|
||
if (this.previewState.confirmed) { this.previewState = { kind: null }; return; }
|
||
// Revert to original values
|
||
if (kind === 'background') {
|
||
this.documentService.updateBlock(this.block.id, {
|
||
meta: { ...this.block.meta, bgColor: this.previewState.origBg }
|
||
} as any);
|
||
} else if (kind === 'borderColor') {
|
||
if (this.block.type === 'hint') {
|
||
this.documentService.updateBlockProps(this.block.id, {
|
||
...this.block.props,
|
||
borderColor: this.previewState.origBorder
|
||
});
|
||
}
|
||
} else if (kind === 'lineColor') {
|
||
if (this.block.type === 'hint' || this.block.type === 'quote') {
|
||
this.documentService.updateBlockProps(this.block.id, {
|
||
...this.block.props,
|
||
lineColor: this.previewState.origLine
|
||
});
|
||
}
|
||
}
|
||
this.previewState = { kind: null };
|
||
}
|
||
|
||
get convertOptions() {
|
||
const base = [
|
||
{ type: 'list-item' as BlockType, preset: { kind: 'check', checked: false, text: '' }, icon: '☑️', label: 'Checkbox list', shortcut: 'ctrl+shift+c' },
|
||
{ type: 'list-item' as BlockType, preset: { kind: 'numbered', number: 1, text: '' }, icon: '1.', label: 'Numbered list', shortcut: 'ctrl+shift+7' },
|
||
{ type: 'list-item' as BlockType, preset: { kind: 'bullet', text: '' }, icon: '•', label: 'Bullet list', shortcut: 'ctrl+shift+8' },
|
||
{ type: 'toggle' as BlockType, preset: null, icon: '▶️', label: 'Toggle Block', shortcut: 'ctrl+alt+6' },
|
||
{ type: 'paragraph' as BlockType, preset: null, icon: '¶', label: 'Paragraph', shortcut: 'ctrl+alt+7' },
|
||
{ type: 'steps' as BlockType, preset: null, icon: '📝', label: 'Steps', shortcut: '' },
|
||
{ type: 'heading' as BlockType, preset: { level: 1 }, icon: 'H₁', label: 'Large Heading', shortcut: 'ctrl+alt+1' },
|
||
{ type: 'heading' as BlockType, preset: { level: 2 }, icon: 'H₂', label: 'Medium Heading', shortcut: 'ctrl+alt+2' },
|
||
{ type: 'heading' as BlockType, preset: { level: 3 }, icon: 'H₃', label: 'Small Heading', shortcut: 'ctrl+alt+3' },
|
||
{ type: 'code' as BlockType, preset: null, icon: '</>', label: 'Code', shortcut: 'ctrl+alt+c' },
|
||
{ type: 'quote' as BlockType, preset: null, icon: '❝', label: 'Quote', shortcut: 'ctrl+alt+y' },
|
||
{ type: 'hint' as BlockType, preset: null, icon: 'ℹ️', label: 'Hint', shortcut: 'ctrl+alt+u' },
|
||
{ type: 'button' as BlockType, preset: null, icon: '🔘', label: 'Button', shortcut: 'ctrl+alt+5' }
|
||
];
|
||
|
||
// Restrict for file/image per requirements
|
||
if (this.block?.type === 'file') {
|
||
// only when underlying file is an image kind
|
||
const props: any = this.block.props || {};
|
||
const meta = props.meta || {};
|
||
let kind = meta.kind as string | undefined;
|
||
if (!kind) {
|
||
const name = meta.name || props.name || '';
|
||
const ext = (meta.ext || name.split('.').pop() || '').toLowerCase();
|
||
if (['png','jpg','jpeg','gif','webp','bmp','svg'].includes(ext)) kind = 'image';
|
||
}
|
||
if (kind === 'image') {
|
||
return [{ type: 'image' as BlockType, preset: null, icon: '🖼️', label: 'Image', shortcut: '' }];
|
||
}
|
||
return [];
|
||
}
|
||
if (this.block?.type === 'image') {
|
||
return [{ type: 'file' as BlockType, preset: null, icon: '📎', label: 'File', shortcut: '' }];
|
||
}
|
||
return base;
|
||
}
|
||
|
||
backgroundColors = [
|
||
{ name: 'None', value: 'transparent' },
|
||
// row 1 (reds/pinks/purples)
|
||
{ name: 'Red 600', value: '#dc2626' },
|
||
{ name: 'Rose 500', value: '#f43f5e' },
|
||
{ name: 'Fuchsia 600', value: '#c026d3' },
|
||
{ name: 'Purple 600', value: '#9333ea' },
|
||
{ name: 'Indigo 600', value: '#4f46e5' },
|
||
// row 2 (blues/teals)
|
||
{ name: 'Blue 600', value: '#2563eb' },
|
||
{ name: 'Sky 500', value: '#0ea5e9' },
|
||
{ name: 'Cyan 500', value: '#06b6d4' },
|
||
{ name: 'Teal 600', value: '#0d9488' },
|
||
{ name: 'Emerald 600', value: '#059669' },
|
||
// row 3 (greens/yellows/oranges)
|
||
{ name: 'Green 600', value: '#16a34a' },
|
||
{ name: 'Lime 500', value: '#84cc16' },
|
||
{ name: 'Yellow 500', value: '#eab308' },
|
||
{ name: 'Amber 600', value: '#d97706' },
|
||
{ name: 'Orange 600', value: '#ea580c' },
|
||
// row 4 (browns/grays)
|
||
{ name: 'Stone 600', value: '#57534e' },
|
||
{ name: 'Neutral 600', value: '#525252' },
|
||
{ name: 'Slate 600', value: '#475569' },
|
||
{ name: 'Rose 300', value: '#fda4af' },
|
||
{ name: 'Sky 300', value: '#7dd3fc' }
|
||
];
|
||
|
||
onAction(type: MenuAction['type'], payload?: any): void {
|
||
if (type === 'copy') {
|
||
// Copy block to clipboard
|
||
this.copyBlockToClipboard();
|
||
} else {
|
||
// Emit action for parent to handle (including ratios/alignment payload)
|
||
this.action.emit({ type, payload });
|
||
}
|
||
this.close.emit();
|
||
}
|
||
|
||
onAlignImage(alignment: 'left' | 'center' | 'right'): void {
|
||
this.action.emit({ type: 'imageAlignment', payload: { alignment } });
|
||
this.close.emit();
|
||
}
|
||
|
||
onImageDefaultSize(): void {
|
||
this.action.emit({ type: 'imageDefaultSize' });
|
||
this.close.emit();
|
||
}
|
||
|
||
private copyBlockToClipboard(): void {
|
||
// Store in service for paste
|
||
this.clipboardData = JSON.parse(JSON.stringify(this.block));
|
||
|
||
// Also copy to system clipboard as JSON
|
||
const jsonStr = JSON.stringify(this.block, null, 2);
|
||
navigator.clipboard.writeText(jsonStr).then(() => {
|
||
console.log('Block copied to clipboard');
|
||
}).catch(err => {
|
||
console.error('Failed to copy:', err);
|
||
});
|
||
|
||
// Store in localStorage for cross-session paste
|
||
localStorage.setItem('copiedBlock', jsonStr);
|
||
}
|
||
|
||
onAlign(alignment: 'left'|'center'|'right'|'justify'): void {
|
||
// Emit action for parent to handle (works for both normal blocks and columns)
|
||
this.action.emit({ type: 'align', payload: { alignment } });
|
||
this.close.emit();
|
||
}
|
||
|
||
onIndent(delta: number): void {
|
||
// Emit action for parent to handle (works for both normal blocks and columns)
|
||
this.action.emit({ type: 'indent', payload: { delta } });
|
||
this.close.emit();
|
||
}
|
||
|
||
onConvert(type: BlockType, preset: any): void {
|
||
// Emit action with convert payload for parent to handle
|
||
this.action.emit({ type: 'convert', payload: { type, preset } });
|
||
this.close.emit();
|
||
}
|
||
|
||
onBackgroundColor(color: string): void {
|
||
// Emit action for parent to handle (works for both normal blocks and columns)
|
||
this.action.emit({ type: 'background', payload: { color } });
|
||
this.close.emit();
|
||
}
|
||
|
||
onLineColor(color: string): void {
|
||
// Emit action for parent to handle (Quote and Hint blocks)
|
||
this.action.emit({ type: 'lineColor', payload: { color } });
|
||
this.close.emit();
|
||
}
|
||
|
||
onBorderColor(color: string): void {
|
||
// Emit action for parent to handle (Hint blocks)
|
||
this.action.emit({ type: 'borderColor', payload: { color } });
|
||
this.close.emit();
|
||
}
|
||
|
||
isActiveBackgroundColor(value: string): boolean {
|
||
const current = (this.block.meta as any)?.bgColor;
|
||
return (current ?? 'transparent') === (value ?? 'transparent');
|
||
}
|
||
|
||
isActiveLineColor(value: string): boolean {
|
||
if (this.block.type === 'quote') {
|
||
const current = (this.block.props as any)?.lineColor;
|
||
return (current ?? '#3b82f6') === (value ?? '#3b82f6');
|
||
}
|
||
if (this.block.type === 'hint') {
|
||
const current = (this.block.props as any)?.lineColor;
|
||
const defaultColor = this.getDefaultHintLineColor();
|
||
return (current ?? defaultColor) === (value ?? defaultColor);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
isActiveBorderColor(value: string): boolean {
|
||
if (this.block.type === 'hint') {
|
||
const current = (this.block.props as any)?.borderColor;
|
||
const defaultColor = this.getDefaultHintBorderColor();
|
||
return (current ?? defaultColor) === (value ?? defaultColor);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
private getDefaultHintLineColor(): string {
|
||
const variant = (this.block.props as any)?.variant;
|
||
switch (variant) {
|
||
case 'info': return '#3b82f6';
|
||
case 'warning': return '#eab308';
|
||
case 'success': return '#22c55e';
|
||
case 'note': return '#a855f7';
|
||
default: return 'var(--border)';
|
||
}
|
||
}
|
||
|
||
private getDefaultHintBorderColor(): string {
|
||
const variant = (this.block.props as any)?.variant;
|
||
switch (variant) {
|
||
case 'info': return '#3b82f6';
|
||
case 'warning': return '#eab308';
|
||
case 'success': return '#22c55e';
|
||
case 'note': return '#a855f7';
|
||
default: return 'var(--border)';
|
||
}
|
||
}
|
||
|
||
// Code block specific methods
|
||
isActiveLanguage(lang: string): boolean {
|
||
if (this.block.type !== 'code') return false;
|
||
const current = (this.block.props as any)?.lang || '';
|
||
return current === lang;
|
||
}
|
||
|
||
isActiveTheme(themeId: string): boolean {
|
||
if (this.block.type !== 'code') return false;
|
||
const current = (this.block.props as any)?.theme || 'default';
|
||
return current === themeId;
|
||
}
|
||
|
||
onCodeLanguage(lang: string): void {
|
||
this.action.emit({ type: 'codeLanguage', payload: { lang } });
|
||
this.close.emit();
|
||
}
|
||
|
||
onCodeTheme(themeId: string): void {
|
||
this.action.emit({ type: 'codeTheme', payload: { themeId } });
|
||
this.close.emit();
|
||
}
|
||
|
||
getCodeWrapIcon(): string {
|
||
if (this.block.type !== 'code') return '⬜';
|
||
return (this.block.props as any)?.enableWrap ? '✅' : '⬜';
|
||
}
|
||
|
||
getCodeLineNumbersIcon(): string {
|
||
if (this.block.type !== 'code') return '⬜';
|
||
return (this.block.props as any)?.showLineNumbers ? '✅' : '⬜';
|
||
}
|
||
|
||
// Table block specific methods
|
||
hasCaption(): boolean {
|
||
if (this.block.type !== 'table') return false;
|
||
return !!(this.block.props as any)?.caption;
|
||
}
|
||
|
||
isActiveLayout(layout: string): boolean {
|
||
if (this.block.type !== 'table') return false;
|
||
const current = (this.block.props as any)?.layout || 'auto';
|
||
return current === layout;
|
||
}
|
||
|
||
onTableLayout(layout: 'auto' | 'fixed'): void {
|
||
this.action.emit({ type: 'tableLayout', payload: { layout } });
|
||
this.close.emit();
|
||
}
|
||
|
||
onInsertColumn(position: 'left' | 'center' | 'right'): void {
|
||
this.action.emit({ type: 'insertColumn', payload: { position } });
|
||
this.close.emit();
|
||
}
|
||
|
||
// Image block helpers
|
||
isActiveAspectRatio(r: string): boolean {
|
||
if (this.block.type !== 'image') return false;
|
||
const current = (this.block.props as any)?.aspectRatio || 'free';
|
||
return current === r;
|
||
}
|
||
|
||
isActiveImageAlignment(a: 'left' | 'center' | 'right' | 'full'): boolean {
|
||
if (this.block.type !== 'image') return false;
|
||
const current = (this.block.props as any)?.alignment || 'center';
|
||
return current === a;
|
||
}
|
||
}
|