feat: add collapsible block type and enhance paragraph menu UX - Added CollapsibleBlockComponent with registration in block host and columns - Enhanced paragraph block with inline "+" button for column layouts and improved menu positioning - Updated toggle block to support creating new toggle blocks below with dedicated event handlers - Improved menu styling with fixed positioning, keyboard navigation (arrows, Enter, Escape), and focus management - Simplified inline action handling by deleg
253 lines
10 KiB
TypeScript
253 lines
10 KiB
TypeScript
import { Component, Output, EventEmitter, Input, signal } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
|
|
export interface InlineToolbarAction {
|
|
type: 'use-ai' | 'checkbox-list' | 'numbered-list' | 'bullet-list' | 'table' | 'image' | 'file' | 'new-page' | 'heading-2' | 'more' | 'drag' | 'menu';
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-block-inline-toolbar',
|
|
standalone: true,
|
|
imports: [CommonModule],
|
|
template: `
|
|
<div class="group/block relative flex items-center justify-between gap-2 bg-[var(--editor-bg)] rounded-2xl px-4 py-2 shadow-sm z-[60]">
|
|
<!-- Drag handle (visible on hover) -->
|
|
@if (showDragHandle) {
|
|
<div
|
|
class="absolute -left-8 opacity-0 group-hover/block:opacity-100 transition-opacity cursor-grab active:cursor-grabbing"
|
|
title="Drag to move\nClick to open menu"
|
|
(mouseenter)="showDragTooltip.set(true)"
|
|
(mouseleave)="showDragTooltip.set(false)"
|
|
(click)="onAction('menu')"
|
|
>
|
|
<div class="p-1 rounded hover:bg-neutral-700 text-gray-500 hover:text-gray-300">
|
|
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
|
<circle cx="9" cy="5" r="1.5"/>
|
|
<circle cx="15" cy="5" r="1.5"/>
|
|
<circle cx="9" cy="12" r="1.5"/>
|
|
<circle cx="15" cy="12" r="1.5"/>
|
|
<circle cx="9" cy="19" r="1.5"/>
|
|
<circle cx="15" cy="19" r="1.5"/>
|
|
</svg>
|
|
</div>
|
|
|
|
<!-- Tooltip -->
|
|
@if (showDragTooltip()) {
|
|
<div class="absolute left-0 top-full mt-1 px-2 py-1 bg-neutral-800 text-white text-xs rounded whitespace-nowrap z-50 shadow-lg">
|
|
Drag to move<br/>Click to open menu
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
<!-- Input area and inline icons -->
|
|
<div class="flex items-center gap-2 w-full">
|
|
<div class="flex-1">
|
|
<ng-content />
|
|
</div>
|
|
|
|
<!-- Inline icons are only shown when the line is empty (initial prompt state) -->
|
|
<div class="flex items-center gap-1 select-none" *ngIf="isEmpty()">
|
|
<!-- Use AI -->
|
|
<button *ngIf="!actions || actions.includes('use-ai')"
|
|
class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
|
|
title="Use AI"
|
|
(click)="onAction('use-ai')"
|
|
type="button"
|
|
>
|
|
<svg class="w-6 h-6" viewBox="0 0 24 24" aria-label="Assistant AI de rédaction"
|
|
fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
<!-- Document -->
|
|
<rect x="3" y="2.5" width="12.5" height="17" rx="2.2"/>
|
|
<!-- Lignes de texte -->
|
|
<path d="M6 7h7.5M6 10h7.5M6 13h6"/>
|
|
<!-- Étincelle IA -->
|
|
<path d="M15.8 4.2v1.6M15 5h1.6M15.3 4.3l1.1 1.1M15.3 5.4l1.1-1.1"/>
|
|
<!-- Plume (nib) en bas à droite -->
|
|
<path d="M19 13.8l3.2 3.2-5 5-3.2-3.2z"/>
|
|
<circle cx="17.8" cy="18.2" r="0.9"/>
|
|
<path d="M13.9 18.9l-1.6.5.5-1.6"/>
|
|
</svg>
|
|
|
|
</button>
|
|
|
|
<!-- Checkbox list amélioré -->
|
|
<button *ngIf="!actions || actions.includes('checkbox-list')"
|
|
class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
|
|
title="Checkbox list"
|
|
(click)="onAction('checkbox-list')"
|
|
type="button"
|
|
>
|
|
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<!-- Ligne 1 -->
|
|
<rect x="3" y="3" width="5" height="5" rx="1"/>
|
|
<path d="M10 6h10"/>
|
|
|
|
<!-- Ligne 2 -->
|
|
<rect x="3" y="10" width="5" height="5" rx="1"/>
|
|
<path d="M10 13h10"/>
|
|
|
|
<!-- Ligne 3 -->
|
|
<rect x="3" y="17" width="5" height="5" rx="1"/>
|
|
<path d="M10 20h10"/>
|
|
</svg>
|
|
|
|
</button>
|
|
|
|
<!-- Bullet list -->
|
|
<button *ngIf="!actions || actions.includes('bullet-list')"
|
|
class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
|
|
title="Bullet list"
|
|
(click)="onAction('bullet-list')"
|
|
type="button"
|
|
>
|
|
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<!-- Ligne 1 -->
|
|
<circle cx="5.5" cy="5.5" r="1.5"/>
|
|
<path d="M10 6h10"/>
|
|
|
|
<!-- Ligne 2 -->
|
|
<circle cx="5.5" cy="12.5" r="1.5"/>
|
|
<path d="M10 13h10"/>
|
|
|
|
<!-- Ligne 3 -->
|
|
<circle cx="5.5" cy="19.5" r="1.5"/>
|
|
<path d="M10 20h10"/>
|
|
</svg>
|
|
|
|
</button>
|
|
|
|
<!-- Numbered list -->
|
|
<button *ngIf="!actions || actions.includes('numbered-list')"
|
|
class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
|
|
title="Numbered list"
|
|
(click)="onAction('numbered-list')"
|
|
type="button"
|
|
>
|
|
<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>
|
|
</button>
|
|
|
|
<!-- Table -->
|
|
<button *ngIf="!actions || actions.includes('table')"
|
|
class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
|
|
title="Table"
|
|
(click)="onAction('table')"
|
|
type="button"
|
|
>
|
|
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
|
<path d="M3 9h18M9 3v18"/>
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Image -->
|
|
<button *ngIf="!actions || actions.includes('image')"
|
|
class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
|
|
title="Image"
|
|
(click)="onAction('image')"
|
|
type="button"
|
|
>
|
|
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
|
<circle cx="8.5" cy="8.5" r="1.5"/>
|
|
<path d="M21 15l-5-5L5 21"/>
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- File -->
|
|
<button *ngIf="!actions || actions.includes('file')"
|
|
class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
|
|
title="File"
|
|
(click)="onAction('file')"
|
|
type="button"
|
|
>
|
|
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9l-7-7z"/>
|
|
<path d="M13 2v7h7"/>
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Link/New Page -->
|
|
<button *ngIf="!actions || actions.includes('new-page')"
|
|
class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
|
|
title="Link to page"
|
|
(click)="onAction('new-page')"
|
|
type="button"
|
|
>
|
|
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/>
|
|
<path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/>
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Heading M -->
|
|
<button *ngIf="!actions || actions.includes('heading-2')"
|
|
class="px-1.5 py-1 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer font-bold text-xs"
|
|
title="Heading 2"
|
|
(click)="onAction('heading-2')"
|
|
type="button"
|
|
>
|
|
H<sub class="text-[8px]">M</sub>
|
|
</button>
|
|
|
|
<div *ngIf="!actions || actions.length > 1" class="border-l border-gray-600 mx-2 h-4"></div>
|
|
|
|
<!-- More items (opens menu) -->
|
|
<button *ngIf="!actions || actions.includes('more')"
|
|
class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
|
|
title="More items"
|
|
(click)="onAction('more')"
|
|
type="button"
|
|
>
|
|
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M6 9l6 6 6-6"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`,
|
|
styles: [`
|
|
:host {
|
|
display: block;
|
|
}
|
|
|
|
button {
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
}
|
|
|
|
button:active {
|
|
transform: scale(0.95);
|
|
}
|
|
`]
|
|
})
|
|
export class BlockInlineToolbarComponent {
|
|
@Input() placeholder = "Start writing or type '/', '@'";
|
|
@Input() isFocused = signal(false);
|
|
@Input() isHovered = signal(false);
|
|
// New: whether the current line is empty. When true, icons are shown and placeholder is visible.
|
|
@Input() isEmpty = signal(true);
|
|
// New: whether to show the drag handle (default true, false in columns)
|
|
@Input() showDragHandle = true;
|
|
// New: list of actions to render; when undefined, render all
|
|
@Input() actions: InlineToolbarAction['type'][] | undefined;
|
|
|
|
@Output() action = new EventEmitter<InlineToolbarAction['type']>();
|
|
|
|
showDragTooltip = signal(false);
|
|
|
|
onAction(type: InlineToolbarAction['type']): void {
|
|
this.action.emit(type);
|
|
}
|
|
}
|