feat: add Nimbus Editor with Unsplash integration

- Integrated Unsplash API for image search functionality with environment configuration
- Added new Nimbus Editor page component with navigation from sidebar and mobile drawer
- Enhanced TOC with highlight animation for editor heading navigation
- Improved CDK overlay z-index hierarchy for proper menu layering
- Removed obsolete logging validation script
This commit is contained in:
2025-11-11 11:38:27 -05:00
parent f2049f672f
commit ee3085ce38
115 changed files with 26546 additions and 187 deletions
+2
View File
@@ -540,6 +540,8 @@
<div class="h-[calc(100vh-180px)] lg:h-[calc(100vh-140px)]">
<app-markdown-playground></app-markdown-playground>
</div>
} @else if (activeView() === 'nimbus-editor') {
<app-nimbus-editor-page></app-nimbus-editor-page>
} @else if (activeView() === 'parameters') {
<app-parameters></app-parameters>
} @else if (activeView() === 'tests-panel') {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,154 @@
import { Component, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
export interface BlockMenuAction {
type: 'heading' | 'paragraph' | 'list' | 'numbered' | 'checkbox' | 'table' | 'code' | 'image' | 'file' | 'formula' | 'more';
}
@Component({
selector: 'app-block-initial-menu',
standalone: true,
imports: [CommonModule],
template: `
<div class="flex items-center gap-1 px-3 py-2 bg-gray-800/95 backdrop-blur-sm rounded-lg shadow-xl border border-gray-700">
<!-- Edit/Text -->
<button
class="p-1.5 rounded hover:bg-gray-700 text-gray-400 hover:text-gray-200 transition-colors"
title="Text"
(click)="onAction('paragraph')"
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">
<path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/>
</svg>
</button>
<!-- Checkbox -->
<button
class="p-1.5 rounded hover:bg-gray-700 text-gray-400 hover:text-gray-200 transition-colors"
title="Checkbox list"
(click)="onAction('checkbox')"
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">
<rect x="3" y="3" width="8" height="8" rx="1"/>
<path d="M6 7l2 2 4-4"/>
</svg>
</button>
<!-- Bullet list (3 horizontal lines) -->
<button
class="p-1.5 rounded hover:bg-gray-700 text-gray-400 hover:text-gray-200 transition-colors"
title="Bullet list"
(click)="onAction('list')"
type="button"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
<!-- Numbered list (3 horizontal lines) -->
<button
class="p-1.5 rounded hover:bg-gray-700 text-gray-400 hover:text-gray-200 transition-colors"
title="Numbered list"
(click)="onAction('numbered')"
type="button"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M8 6h13M8 12h13M8 18h13"/>
<path d="M3 6h.01M3 12h.01M3 18h.01"/>
</svg>
</button>
<!-- Table -->
<button
class="p-1.5 rounded hover:bg-gray-700 text-gray-400 hover:text-gray-200 transition-colors"
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" stroke-linecap="round">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M3 9h18M3 15h18M9 3v18"/>
</svg>
</button>
<!-- Image -->
<button
class="p-1.5 rounded hover:bg-gray-700 text-gray-400 hover:text-gray-200 transition-colors"
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" stroke-linecap="round">
<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>
<!-- Attachment/File (paperclip) -->
<button
class="p-1.5 rounded hover:bg-gray-700 text-gray-400 hover:text-gray-200 transition-colors"
title="Attachment"
(click)="onAction('file')"
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">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
</svg>
</button>
<!-- Formula (fx with frame) -->
<button
class="p-1.5 rounded hover:bg-gray-700 text-gray-400 hover:text-gray-200 transition-colors"
title="Formula"
(click)="onAction('formula')"
type="button"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<text x="7" y="16" font-family="Arial" font-size="10" font-style="italic" fill="currentColor">fx</text>
</svg>
</button>
<!-- Heading (HM) -->
<button
class="p-1.5 rounded hover:bg-gray-700 text-gray-400 hover:text-gray-200 transition-colors font-semibold"
title="Heading"
(click)="onAction('heading')"
type="button"
>
<span class="text-sm">H<sub class="text-[9px]">M</sub></span>
</button>
<!-- Separator -->
<div class="w-px h-5 bg-gray-700 mx-1"></div>
<!-- More (dropdown chevron) -->
<button
class="p-1.5 rounded hover:bg-gray-700 text-gray-400 hover:text-gray-200 transition-colors"
title="More options"
(click)="onAction('more')"
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">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
</div>
`,
styles: [`
:host {
display: block;
}
`]
})
export class BlockInitialMenuComponent {
@Output() action = new EventEmitter<BlockMenuAction>();
onAction(type: BlockMenuAction['type']): void {
this.action.emit({ type });
}
}
@@ -0,0 +1,221 @@
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 gap-2 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-1 flex items-center gap-1 px-2 py-0.5 rounded-lg transition-colors">
<!-- Slot for actual content -->
<div class="flex-1">
<ng-content />
</div>
<!-- Quick action icons (visible on hover or focus) -->
<div
class="flex items-center gap-0.5 transition-opacity"
[class.opacity-100]="isEmpty() && isFocused()"
[class.opacity-0]="!isEmpty() || !isFocused()"
[class.pointer-events-none]="!isEmpty() || !isFocused()"
>
<!-- Use AI -->
<button
class="p-1 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
title="Use AI"
(click)="onAction('use-ai')"
type="button"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 3l9 4.5v9L12 21l-9-4.5v-9L12 3z"/>
<path d="M12 12l9-4.5M12 12v9M12 12L3 7.5"/>
</svg>
</button>
<!-- Checkbox list -->
<button
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
title="Checkbox list"
(click)="onAction('checkbox-list')"
type="button"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="5" width="6" height="6" rx="1"/>
<path d="M5 8l1.5 1.5L9 7"/>
<path d="M13 7h8"/>
</svg>
</button>
<!-- Bullet list -->
<button
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
title="Bullet list"
(click)="onAction('bullet-list')"
type="button"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="5" cy="7" r="1" fill="currentColor"/>
<path d="M9 7h12"/>
</svg>
</button>
<!-- Numbered list -->
<button
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
title="Numbered list"
(click)="onAction('numbered-list')"
type="button"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 6h11"/>
<path d="M4 6h1v4M4 10h2"/>
</svg>
</button>
<!-- Table -->
<button
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
title="Table"
(click)="onAction('table')"
type="button"
>
<svg class="w-4 h-4" 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
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
title="Image"
(click)="onAction('image')"
type="button"
>
<svg class="w-4 h-4" 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
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
title="File"
(click)="onAction('file')"
type="button"
>
<svg class="w-4 h-4" 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
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
title="Link to page"
(click)="onAction('new-page')"
type="button"
>
<svg class="w-4 h-4" 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
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200 font-bold text-xs"
title="Heading 2"
(click)="onAction('heading-2')"
type="button"
>
H<sub class="text-[8px]">M</sub>
</button>
<div class="w-px h-3 bg-neutral-600"></div>
<!-- More items (opens menu) -->
<button
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
title="More items"
(click)="onAction('more')"
type="button"
>
<svg class="w-4 h-4" 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;
@Output() action = new EventEmitter<InlineToolbarAction['type']>();
showDragTooltip = signal(false);
onAction(type: InlineToolbarAction['type']): void {
this.action.emit(type);
}
}
@@ -0,0 +1,63 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Block, ButtonProps } from '../../../core/models/block.model';
@Component({
selector: 'app-button-block',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="flex items-center gap-4">
<input
type="text"
class="input input-sm"
placeholder="Button label..."
[value]="props.label"
(input)="onLabelChange($event)"
/>
<input
type="text"
class="input input-sm flex-1"
placeholder="URL..."
[value]="props.url"
(input)="onUrlChange($event)"
/>
<a
[href]="props.url"
[class]="getButtonClass()"
target="_blank"
>
{{ props.label }}
</a>
</div>
`
})
export class ButtonBlockComponent {
@Input({ required: true }) block!: Block<ButtonProps>;
@Output() update = new EventEmitter<ButtonProps>();
get props(): ButtonProps {
return this.block.props;
}
onLabelChange(event: Event): void {
const target = event.target as HTMLInputElement;
this.update.emit({ ...this.props, label: target.value });
}
onUrlChange(event: Event): void {
const target = event.target as HTMLInputElement;
this.update.emit({ ...this.props, url: target.value });
}
getButtonClass(): string {
const base = 'btn btn-sm';
switch (this.props.variant) {
case 'primary': return `${base} btn-primary`;
case 'secondary': return `${base} btn-secondary`;
case 'outline': return `${base} btn-outline`;
default: return base;
}
}
}
@@ -0,0 +1,89 @@
import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, AfterViewInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Block, CodeProps } from '../../../core/models/block.model';
import { CodeThemeService } from '../../../services/code-theme.service';
@Component({
selector: 'app-code-block',
standalone: true,
imports: [CommonModule, FormsModule],
styleUrls: ['./code-themes.css'],
template: `
<div
class="rounded-xl overflow-hidden text-neutral-100 transition-colors duration-200"
[ngClass]="getThemeClass()"
>
<div class="flex items-center gap-2 px-3 py-2 bg-black/10 dark:bg-white/5">
<select
class="bg-transparent border border-transparent text-xs outline-none cursor-pointer hover:text-primary transition"
[value]="props.lang || ''"
(change)="onLangChange($event)"
>
<option value="">Plain text</option>
@for (lang of codeThemeService.getLanguages(); track lang) {
<option [value]="lang">{{ codeThemeService.getLanguageDisplay(lang) }}</option>
}
</select>
</div>
<div class="relative">
<pre
class="p-3 overflow-auto max-h-96 text-sm leading-6 m-0"
[class.whitespace-pre-wrap]="props.enableWrap"
><code
#editable
contenteditable="true"
class="focus:outline-none bg-transparent"
[class.with-line-numbers]="props.showLineNumbers"
(input)="onInput($event)"
></code></pre>
@if (props.showLineNumbers) {
<div class="absolute top-0 left-0 px-3 py-3 text-xs leading-6 text-neutral-500 pointer-events-none select-none">
@for (line of getLineNumbers(); track $index) {
<div>{{ line }}</div>
}
</div>
}
</div>
</div>
`
})
export class CodeBlockComponent implements AfterViewInit {
@Input({ required: true }) block!: Block<CodeProps>;
@Output() update = new EventEmitter<CodeProps>();
@ViewChild('editable') editable?: ElementRef<HTMLElement>;
readonly codeThemeService = inject(CodeThemeService);
get props(): CodeProps {
return this.block.props;
}
ngAfterViewInit(): void {
if (this.editable?.nativeElement) {
this.editable.nativeElement.textContent = this.props.code || '';
}
}
onInput(event: Event): void {
const target = event.target as HTMLElement;
this.update.emit({ ...this.props, code: target.textContent || '' });
}
onLangChange(event: Event): void {
const target = event.target as HTMLSelectElement;
this.update.emit({ ...this.props, lang: target.value });
}
getThemeClass(): string {
return this.codeThemeService.getThemeClass(this.props.theme);
}
getLineNumbers(): number[] {
if (!this.props.showLineNumbers) return [];
const lines = (this.props.code || '').split('\n');
return Array.from({ length: lines.length }, (_, i) => i + 1);
}
}
@@ -0,0 +1,145 @@
/* Code Block Themes for Nimbus Editor */
/* Base styles */
.theme-default {
background-color: #f5f5f5;
color: #333;
}
.theme-default code {
color: #333;
}
:host-context(.dark) .theme-default {
background-color: #1e1e1e;
color: #d4d4d4;
}
:host-context(.dark) .theme-default code {
color: #d4d4d4;
}
/* Darcula Theme */
.theme-darcula {
background-color: #2b2b2b;
color: #a9b7c6;
}
.theme-darcula code {
color: #a9b7c6;
}
/* MBO Theme */
.theme-mbo {
background-color: #2c2c2c;
color: #f8f8f2;
}
.theme-mbo code {
color: #f8f8f2;
}
/* MDN Theme */
.theme-mdn {
background-color: #f9f9fa;
color: #4d4e53;
}
.theme-mdn code {
color: #4d4e53;
}
:host-context(.dark) .theme-mdn {
background-color: #2d2d2d;
color: #e4e4e7;
}
/* Monokai Theme */
.theme-monokai {
background-color: #272822;
color: #f8f8f2;
}
.theme-monokai code {
color: #f8f8f2;
}
/* Neat Theme */
.theme-neat {
background-color: #ffffff;
color: #333333;
}
.theme-neat code {
color: #333333;
}
:host-context(.dark) .theme-neat {
background-color: #1a1a1a;
color: #e5e5e5;
}
/* NEO Theme */
.theme-neo {
background-color: #ffffff;
color: #2973b7;
}
.theme-neo code {
color: #2973b7;
}
:host-context(.dark) .theme-neo {
background-color: #1b1b1b;
color: #61afef;
}
/* Nord Theme */
.theme-nord {
background-color: #2e3440;
color: #d8dee9;
}
.theme-nord code {
color: #d8dee9;
}
/* Yeti Theme */
.theme-yeti {
background-color: #eceeef;
color: #5d646d;
}
.theme-yeti code {
color: #5d646d;
}
:host-context(.dark) .theme-yeti {
background-color: #2a2a2a;
color: #b5bbc4;
}
/* Yonce Theme */
.theme-yonce {
background-color: #1c1c1c;
color: #c5c8c6;
}
.theme-yonce code {
color: #c5c8c6;
}
/* Zenburn Theme */
.theme-zenburn {
background-color: #3f3f3f;
color: #dcdccc;
}
.theme-zenburn code {
color: #dcdccc;
}
/* Line numbers styling */
.with-line-numbers {
padding-left: 3.5rem !important;
}
@@ -0,0 +1,760 @@
import { Component, Input, Output, EventEmitter, inject, ViewChild, signal, effect } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Block, ColumnsProps, ColumnItem } from '../../../core/models/block.model';
import { DragDropService } from '../../../services/drag-drop.service';
import { CommentService } from '../../../services/comment.service';
import { DocumentService } from '../../../services/document.service';
import { SelectionService } from '../../../services/selection.service';
// Import ALL block components for full support
import { ParagraphBlockComponent } from './paragraph-block.component';
import { HeadingBlockComponent } from './heading-block.component';
import { ListItemBlockComponent } from './list-item-block.component';
import { CodeBlockComponent } from './code-block.component';
import { QuoteBlockComponent } from './quote-block.component';
import { ToggleBlockComponent } from './toggle-block.component';
import { HintBlockComponent } from './hint-block.component';
import { ButtonBlockComponent } from './button-block.component';
import { ImageBlockComponent } from './image-block.component';
import { FileBlockComponent } from './file-block.component';
import { TableBlockComponent } from './table-block.component';
import { StepsBlockComponent } from './steps-block.component';
import { LineBlockComponent } from './line-block.component';
import { DropdownBlockComponent } from './dropdown-block.component';
import { ProgressBlockComponent } from './progress-block.component';
import { KanbanBlockComponent } from './kanban-block.component';
import { EmbedBlockComponent } from './embed-block.component';
import { OutlineBlockComponent } from './outline-block.component';
import { ListBlockComponent } from './list-block.component';
import { CommentsPanelComponent } from '../../comments/comments-panel.component';
import { BlockContextMenuComponent } from '../block-context-menu.component';
@Component({
selector: 'app-columns-block',
standalone: true,
imports: [
CommonModule,
ParagraphBlockComponent,
HeadingBlockComponent,
ListItemBlockComponent,
CodeBlockComponent,
QuoteBlockComponent,
ToggleBlockComponent,
HintBlockComponent,
ButtonBlockComponent,
ImageBlockComponent,
FileBlockComponent,
TableBlockComponent,
StepsBlockComponent,
LineBlockComponent,
DropdownBlockComponent,
ProgressBlockComponent,
KanbanBlockComponent,
EmbedBlockComponent,
OutlineBlockComponent,
ListBlockComponent,
CommentsPanelComponent,
BlockContextMenuComponent
],
template: `
<div class="flex gap-2 w-full relative" #columnsContainer>
@for (column of props.columns; track column.id; let colIndex = $index) {
<div
class="flex-1 min-w-0"
[style.flex-basis.%]="column.width || (100 / props.columns.length)"
[attr.data-column-id]="column.id"
[attr.data-column-index]="colIndex"
>
@for (block of column.blocks; track block.id; let blockIndex = $index) {
<div
class="mb-1 block-in-column group/block relative"
[attr.data-block-id]="block.id"
[attr.data-column-index]="colIndex"
[attr.data-block-index]="blockIndex"
>
<!-- Menu button (3 dots) - Outside left, centered vertically -->
<button
type="button"
class="menu-handle absolute -left-9 top-1/2 -translate-y-1/2 w-7 h-7 flex items-center justify-center opacity-0 group-hover/block:opacity-100 transition-opacity bg-gray-700 hover:bg-gray-600 rounded-md z-10"
title="Drag to move or click for menu"
(click)="openMenu(block, $event)"
(mousedown)="onDragStart(block, colIndex, blockIndex, $event)"
>
<svg class="w-4 h-4 text-gray-300" viewBox="0 0 16 16" fill="currentColor">
<circle cx="3" cy="8" r="1.5"/>
<circle cx="8" cy="8" r="1.5"/>
<circle cx="13" cy="8" r="1.5"/>
</svg>
</button>
<!-- Comment button - Outside right, centered vertically -->
<button
type="button"
class="absolute -right-9 top-1/2 -translate-y-1/2 w-7 h-7 flex items-center justify-center opacity-0 group-hover/block:opacity-100 transition-opacity bg-gray-700 hover:bg-gray-600 rounded-md z-10"
[class.!opacity-100]="getBlockCommentCount(block.id) > 0"
[class.bg-blue-600]="getBlockCommentCount(block.id) > 0"
[class.hover:bg-blue-500]="getBlockCommentCount(block.id) > 0"
title="Comments"
(click)="openComments(block.id)"
>
@if (getBlockCommentCount(block.id) > 0) {
<span class="text-[10px] font-semibold text-white">
{{ getBlockCommentCount(block.id) }}
</span>
} @else {
<svg class="w-4 h-4 text-gray-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
}
</button>
<!-- Render block with background color support -->
<div
class="relative px-1.5 py-0.5 rounded transition-colors"
[style.background-color]="getBlockBgColor(block)"
[ngStyle]="getBlockStyles(block)"
>
@switch (block.type) {
@case ('heading') {
<app-heading-block
[block]="block"
(update)="onBlockUpdate($event, block.id)"
(metaChange)="onBlockMetaChange($event, block.id)"
(createBlock)="onBlockCreateBelow(block.id, colIndex, blockIndex)"
(deleteBlock)="onBlockDelete(block.id)"
/>
}
@case ('paragraph') {
<app-paragraph-block
[block]="block"
(update)="onBlockUpdate($event, block.id)"
(metaChange)="onBlockMetaChange($event, block.id)"
(createBlock)="onBlockCreateBelow(block.id, colIndex, blockIndex)"
(deleteBlock)="onBlockDelete(block.id)"
/>
}
@case ('list-item') {
<app-list-item-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('code') {
<app-code-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('quote') {
<app-quote-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('toggle') {
<app-toggle-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('hint') {
<app-hint-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('button') {
<app-button-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('image') {
<app-image-block [block]="block" (update)="onBlockUpdate($event, block.id)" (insertImagesBelow)="onInsertImagesBelowInColumn($event, colIndex, blockIndex)" />
}
@case ('file') {
<app-file-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('table') {
<app-table-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('steps') {
<app-steps-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('line') {
<app-line-block [block]="block" />
}
@case ('dropdown') {
<app-dropdown-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('progress') {
<app-progress-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('kanban') {
<app-kanban-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('embed') {
<app-embed-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('outline') {
<app-outline-block [block]="block" />
}
@case ('list') {
<app-list-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('columns') {
<div class="text-orange-400 px-3 py-2 rounded bg-orange-900/20 border border-orange-700/30 text-sm">
⚠️ Nested columns are not supported. Convert this block to full width.
</div>
}
@default {
<div class="text-gray-300 px-2 py-1 rounded bg-gray-700/30 text-sm">
Type: {{ block.type }} (not yet supported in columns)
</div>
}
}
</div>
</div>
} @empty {
<div class="text-center py-4 text-gray-500 text-xs">
Drop blocks here
</div>
}
</div>
}
</div>
<!-- Comments Panel -->
<app-comments-panel #commentsPanel />
<!-- Block Context Menu -->
<app-block-context-menu
[block]="selectedBlock() || createDummyBlock()"
[visible]="menuVisible()"
[position]="menuPosition()"
(action)="onMenuAction($event)"
(close)="closeMenu()"
/>
`,
styles: [`
:host {
display: block;
width: 100%;
}
/* Placeholder for empty contenteditable */
[contenteditable][data-placeholder]:empty:before {
content: attr(data-placeholder);
color: rgb(107, 114, 128);
opacity: 0.6;
pointer-events: none;
}
/* Focus outline */
[contenteditable]:focus {
outline: none;
}
`]
})
export class ColumnsBlockComponent {
private readonly dragDrop = inject(DragDropService);
private readonly commentService = inject(CommentService);
private readonly documentService = inject(DocumentService);
private readonly selectionService = inject(SelectionService);
@Input({ required: true }) block!: Block<ColumnsProps>;
@Output() update = new EventEmitter<ColumnsProps>();
@ViewChild('commentsPanel') commentsPanel?: CommentsPanelComponent;
// Menu state
selectedBlock = signal<Block | null>(null);
menuVisible = signal(false);
menuPosition = signal({ x: 0, y: 0 });
// Drag state
private draggedBlock: { block: Block; columnIndex: number; blockIndex: number } | null = null;
private dropIndicator = signal<{ columnIndex: number; blockIndex: number } | null>(null);
get props(): ColumnsProps {
return this.block.props;
}
getBlockCommentCount(blockId: string): number {
return this.commentService.getCommentCount(blockId);
}
openComments(blockId: string): void {
this.commentsPanel?.open(blockId);
}
onBlockMetaChange(metaChanges: any, blockId: string): void {
// Update meta for a specific block within columns
const updatedColumns = this.props.columns.map(column => ({
...column,
blocks: column.blocks.map(b => {
if (b.id === blockId) {
return { ...b, meta: { ...b.meta, ...metaChanges } };
}
return b;
})
}));
this.update.emit({ columns: updatedColumns });
}
onBlockCreateBelow(blockId: string, columnIndex: number, blockIndex: number): void {
// Create a new paragraph block after the specified block in the same column
const updatedColumns = this.props.columns.map((column, colIdx) => {
if (colIdx === columnIndex) {
const newBlock = {
id: this.generateId(),
type: 'paragraph' as any,
props: { text: '' },
children: []
};
const newBlocks = [...column.blocks];
newBlocks.splice(blockIndex + 1, 0, newBlock);
return { ...column, blocks: newBlocks };
}
return column;
});
this.update.emit({ columns: updatedColumns });
// Focus the new block after a brief delay
setTimeout(() => {
const newElement = document.querySelector(`[data-block-id="${updatedColumns[columnIndex].blocks[blockIndex + 1].id}"] [contenteditable]`) as HTMLElement;
if (newElement) {
newElement.focus();
}
}, 50);
}
onBlockDelete(blockId: string): void {
// Delete a specific block from columns
const updatedColumns = this.props.columns.map(column => ({
...column,
blocks: column.blocks.filter(b => b.id !== blockId)
}));
this.update.emit({ columns: updatedColumns });
}
onInsertImagesBelowInColumn(urls: string[], columnIndex: number, blockIndex: number): void {
if (!urls || !urls.length) return;
const updatedColumns = this.props.columns.map((column, idx) => {
if (idx !== columnIndex) return column;
const newBlocks = [...column.blocks];
let insertAt = blockIndex + 1;
for (const url of urls) {
const newBlock = this.documentService.createBlock('image', { src: url, alt: '' });
newBlocks.splice(insertAt, 0, newBlock);
insertAt++;
}
return { ...column, blocks: newBlocks };
});
this.update.emit({ columns: updatedColumns });
}
openMenu(block: Block, event: MouseEvent): void {
event.stopPropagation();
const rect = (event.target as HTMLElement).getBoundingClientRect();
this.selectedBlock.set(block);
this.menuVisible.set(true);
this.menuPosition.set({
x: rect.left,
y: rect.bottom + 5
});
}
closeMenu(): void {
this.menuVisible.set(false);
this.selectedBlock.set(null);
}
createDummyBlock(): Block {
// Return a dummy block when selectedBlock is null (to satisfy type requirements)
return {
id: '',
type: 'paragraph',
props: { text: '' },
children: []
};
}
onMenuAction(action: any): void {
const block = this.selectedBlock();
if (!block) return;
// Handle comment action
if (action.type === 'comment') {
this.openComments(block.id);
}
// Handle align action
if (action.type === 'align') {
const { alignment } = action.payload || {};
if (alignment) {
this.alignBlockInColumns(block.id, alignment);
}
}
// Handle indent action
if (action.type === 'indent') {
const { delta } = action.payload || {};
if (delta !== undefined) {
this.indentBlockInColumns(block.id, delta);
}
}
// Handle background action
if (action.type === 'background') {
const { color } = action.payload || {};
this.backgroundColorBlockInColumns(block.id, color);
}
// Handle convert action
if (action.type === 'convert') {
// Convert the block type within the columns
const { type, preset } = action.payload || {};
if (type) {
this.convertBlockInColumns(block.id, type, preset);
}
}
// Handle delete action
if (action.type === 'delete') {
this.deleteBlockFromColumns(block.id);
}
// Handle duplicate action
if (action.type === 'duplicate') {
this.duplicateBlockInColumns(block.id);
}
this.closeMenu();
}
private alignBlockInColumns(blockId: string, alignment: string): void {
const updatedColumns = this.props.columns.map(column => ({
...column,
blocks: column.blocks.map(b => {
if (b.id === blockId) {
// For list-item blocks, update props.align
if (b.type === 'list-item') {
return { ...b, props: { ...b.props, align: alignment as any } };
} else {
// For other blocks, update meta.align
const current = b.meta || {};
return { ...b, meta: { ...current, align: alignment as any } };
}
}
return b;
})
}));
this.update.emit({ columns: updatedColumns });
}
private indentBlockInColumns(blockId: string, delta: number): void {
const updatedColumns = this.props.columns.map(column => ({
...column,
blocks: column.blocks.map(b => {
if (b.id === blockId) {
// For list-item blocks, update props.indent
if (b.type === 'list-item') {
const cur = Number((b.props as any).indent || 0);
const next = Math.max(0, Math.min(7, cur + delta));
return { ...b, props: { ...b.props, indent: next } };
} else {
// For other blocks, update meta.indent
const current = (b.meta as any) || {};
const cur = Number(current.indent || 0);
const next = Math.max(0, Math.min(8, cur + delta));
return { ...b, meta: { ...current, indent: next } };
}
}
return b;
})
}));
this.update.emit({ columns: updatedColumns });
}
private backgroundColorBlockInColumns(blockId: string, color: string): void {
const updatedColumns = this.props.columns.map(column => ({
...column,
blocks: column.blocks.map(b => {
if (b.id === blockId) {
return {
...b,
meta: {
...b.meta,
bgColor: color === 'transparent' ? undefined : color
}
};
}
return b;
})
}));
this.update.emit({ columns: updatedColumns });
}
private convertBlockInColumns(blockId: string, newType: string, preset: any): void {
const updatedColumns = this.props.columns.map(column => ({
...column,
blocks: column.blocks.map(b => {
if (b.id === blockId) {
// Convert block type while preserving text content
const text = this.getBlockText(b);
let newProps: any = { text };
// Apply preset if provided
if (preset) {
newProps = { ...newProps, ...preset };
}
return { ...b, type: newType as any, props: newProps };
}
return b;
})
}));
this.update.emit({ columns: updatedColumns });
}
private deleteBlockFromColumns(blockId: string): void {
let updatedColumns = this.props.columns.map(column => ({
...column,
blocks: column.blocks.filter(b => b.id !== blockId)
}));
// Remove empty columns
updatedColumns = updatedColumns.filter(col => col.blocks.length > 0);
// If only one column remains, we could convert back to normal blocks
// But for now, we'll keep the columns structure and redistribute widths
// Redistribute widths equally
if (updatedColumns.length > 0) {
const newWidth = 100 / updatedColumns.length;
updatedColumns = updatedColumns.map(col => ({
...col,
width: newWidth
}));
}
this.update.emit({ columns: updatedColumns });
}
private duplicateBlockInColumns(blockId: string): void {
const updatedColumns = this.props.columns.map(column => {
const blockIndex = column.blocks.findIndex(b => b.id === blockId);
if (blockIndex >= 0) {
const originalBlock = column.blocks[blockIndex];
const duplicatedBlock = {
...JSON.parse(JSON.stringify(originalBlock)),
id: this.generateId()
};
const newBlocks = [...column.blocks];
newBlocks.splice(blockIndex + 1, 0, duplicatedBlock);
return { ...column, blocks: newBlocks };
}
return column;
});
this.update.emit({ columns: updatedColumns });
}
private getBlockText(block: Block): string {
if ('text' in block.props) {
return (block.props as any).text || '';
}
return '';
}
getBlockBgColor(block: Block): string | undefined {
const bgColor = (block.meta as any)?.bgColor;
return bgColor && bgColor !== 'transparent' ? bgColor : undefined;
}
getBlockStyles(block: Block): {[key: string]: any} {
const meta: any = block.meta || {};
const props: any = block.props || {};
// For list-item blocks, check props.align and props.indent
// For other blocks, check meta.align and meta.indent
const align = block.type === 'list-item' ? (props.align || 'left') : (meta.align || 'left');
const indent = block.type === 'list-item'
? Math.max(0, Math.min(7, Number(props.indent || 0)))
: Math.max(0, Math.min(8, Number(meta.indent || 0)));
return {
textAlign: align,
marginLeft: `${indent * 16}px`
};
}
private generateId(): string {
return Math.random().toString(36).substring(2, 11);
}
onDragStart(block: Block, columnIndex: number, blockIndex: number, event: MouseEvent): void {
event.stopPropagation();
// Store drag source info
this.draggedBlock = { block, columnIndex, blockIndex };
// Use DragDropService for unified drag system
// We use a virtual index based on position in the columns structure
const virtualIndex = this.getVirtualIndex(columnIndex, blockIndex);
this.dragDrop.beginDrag(block.id, virtualIndex, event.clientY);
const onMove = (e: MouseEvent) => {
// Update DragDropService pointer for visual indicators
this.dragDrop.updatePointer(e.clientY, e.clientX);
};
const onUp = (e: MouseEvent) => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
const { moved } = this.dragDrop.endDrag();
if (!moved || !this.draggedBlock) {
this.draggedBlock = null;
return;
}
// Determine drop target
const target = document.elementFromPoint(e.clientX, e.clientY);
if (!target) {
this.draggedBlock = null;
return;
}
// Check if dropping on another block in columns
const blockEl = target.closest('[data-block-id]');
if (blockEl) {
const targetColIndex = parseInt(blockEl.getAttribute('data-column-index') || '0');
const targetBlockIndex = parseInt(blockEl.getAttribute('data-block-index') || '0');
// Move within columns
this.moveBlock(
this.draggedBlock.columnIndex,
this.draggedBlock.blockIndex,
targetColIndex,
targetBlockIndex
);
} else {
// Check if dropping outside columns (convert to full-width block)
const isOutsideColumns = !target.closest('[data-column-id]');
if (isOutsideColumns) {
this.convertToFullWidth(this.draggedBlock.columnIndex, this.draggedBlock.blockIndex);
}
}
this.draggedBlock = null;
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
private getVirtualIndex(colIndex: number, blockIndex: number): number {
// Calculate a virtual index for DragDropService
// This helps with visual indicator positioning
let count = 0;
const props = this.block.props as ColumnsProps;
for (let i = 0; i < colIndex; i++) {
count += props.columns[i]?.blocks.length || 0;
}
return count + blockIndex;
}
private convertToFullWidth(colIndex: number, blockIndex: number): void {
const props = this.block.props as ColumnsProps;
const column = props.columns[colIndex];
if (!column) return;
const blockToMove = column.blocks[blockIndex];
if (!blockToMove) return;
// Insert block as full-width after the columns block
const blockCopy = JSON.parse(JSON.stringify(blockToMove));
this.documentService.insertBlock(this.block.id, blockCopy);
// Remove from column
const updatedColumns = [...props.columns];
updatedColumns[colIndex] = {
...column,
blocks: column.blocks.filter((_, i) => i !== blockIndex)
};
// Remove empty columns and redistribute widths
const nonEmptyColumns = updatedColumns.filter(col => col.blocks.length > 0);
if (nonEmptyColumns.length === 0) {
// Delete the entire columns block if no blocks left
this.documentService.deleteBlock(this.block.id);
} else if (nonEmptyColumns.length === 1) {
// Convert single column back to full-width blocks
const remainingBlocks = nonEmptyColumns[0].blocks;
remainingBlocks.forEach(b => {
const copy = JSON.parse(JSON.stringify(b));
this.documentService.insertBlock(this.block.id, copy);
});
this.documentService.deleteBlock(this.block.id);
} else {
// Update columns with redistributed widths
const newWidth = 100 / nonEmptyColumns.length;
const redistributed = nonEmptyColumns.map(col => ({ ...col, width: newWidth }));
this.update.emit({ columns: redistributed });
}
// Select the moved block
this.selectionService.setActive(blockCopy.id);
}
private moveBlock(fromCol: number, fromBlock: number, toCol: number, toBlock: number): void {
if (fromCol === toCol && fromBlock === toBlock) return;
const columns = [...this.props.columns];
// Get the block to move
const blockToMove = columns[fromCol].blocks[fromBlock];
if (!blockToMove) return;
// Remove from source
columns[fromCol] = {
...columns[fromCol],
blocks: columns[fromCol].blocks.filter((_, i) => i !== fromBlock)
};
// Adjust target index if moving within same column
let actualToBlock = toBlock;
if (fromCol === toCol && fromBlock < toBlock) {
actualToBlock--;
}
// Insert at target
const newBlocks = [...columns[toCol].blocks];
newBlocks.splice(actualToBlock, 0, blockToMove);
columns[toCol] = {
...columns[toCol],
blocks: newBlocks
};
// Remove empty columns and redistribute widths
const nonEmptyColumns = columns.filter(col => col.blocks.length > 0);
if (nonEmptyColumns.length > 0) {
const newWidth = 100 / nonEmptyColumns.length;
const redistributed = nonEmptyColumns.map(col => ({
...col,
width: newWidth
}));
this.update.emit({ columns: redistributed });
}
}
onBlockUpdate(updatedProps: any, blockId: string): void {
// Find the block in columns and update it
const updatedColumns = this.props.columns.map(column => ({
...column,
blocks: column.blocks.map(b =>
b.id === blockId ? { ...b, props: { ...b.props, ...updatedProps } } : b
)
}));
// Emit the updated columns
this.update.emit({ columns: updatedColumns });
}
}
@@ -0,0 +1,62 @@
import { Component, Input, Output, EventEmitter, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Block, DropdownProps } from '../../../core/models/block.model';
@Component({
selector: 'app-dropdown-block',
standalone: true,
imports: [CommonModule],
template: `
<div class="rounded-xl overflow-hidden bg-transparent">
<button
type="button"
class="w-full flex items-center justify-between p-3 transition-none bg-transparent"
(click)="toggle()"
>
<input
type="text"
class="flex-1 bg-transparent border-none outline-none font-semibold"
[value]="props.title"
(input)="onTitleInput($event)"
(click)="$event.stopPropagation()"
placeholder="Dropdown title..."
/>
<svg class="w-5 h-5 transition-transform" [class.rotate-180]="!isCollapsed()">
<path fill="currentColor" d="M7 10l5 5 5-5H7z"/>
</svg>
</button>
@if (!isCollapsed()) {
<div class="p-4">
<div class="text-sm text-text-muted">
Dropdown content
</div>
</div>
}
</div>
`
})
export class DropdownBlockComponent {
@Input({ required: true }) block!: Block<DropdownProps>;
@Output() update = new EventEmitter<DropdownProps>();
readonly isCollapsed = signal(true);
ngOnInit(): void {
this.isCollapsed.set(this.props.collapsed ?? true);
}
get props(): DropdownProps {
return this.block.props;
}
toggle(): void {
const newState = !this.isCollapsed();
this.isCollapsed.set(newState);
this.update.emit({ ...this.props, collapsed: newState });
}
onTitleInput(event: Event): void {
const target = event.target as HTMLInputElement;
this.update.emit({ ...this.props, title: target.value });
}
}
@@ -0,0 +1,76 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Block, EmbedProps } from '../../../core/models/block.model';
@Component({
selector: 'app-embed-block',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
@if (props.url) {
<div class="border rounded-xl overflow-hidden">
<div class="aspect-video bg-surface2">
<iframe
[src]="getSafeUrl()"
class="w-full h-full"
[sandbox]="props.sandbox ? 'allow-scripts allow-same-origin' : undefined"
referrerpolicy="no-referrer"
loading="lazy"
></iframe>
</div>
<div class="p-2 bg-surface1 text-xs text-text-muted truncate">
{{ props.url }}
</div>
</div>
} @else {
<div class="border-2 border-dashed rounded-xl p-8 text-center">
<input
type="text"
class="input input-bordered w-full max-w-md"
placeholder="Paste embed URL (YouTube, Google Drive, etc.)..."
(change)="onUrlChange($event)"
/>
</div>
}
`
})
export class EmbedBlockComponent {
@Input({ required: true }) block!: Block<EmbedProps>;
@Output() update = new EventEmitter<EmbedProps>();
get props(): EmbedProps {
return this.block.props;
}
onUrlChange(event: Event): void {
const target = event.target as HTMLInputElement;
const url = target.value;
const provider = this.detectProvider(url);
this.update.emit({ ...this.props, url, provider });
}
getSafeUrl(): string {
// Transform URLs for embedding
let url = this.props.url;
// YouTube
if (url.includes('youtube.com/watch')) {
const videoId = new URL(url).searchParams.get('v');
return `https://www.youtube.com/embed/${videoId}`;
}
if (url.includes('youtu.be/')) {
const videoId = url.split('youtu.be/')[1].split('?')[0];
return `https://www.youtube.com/embed/${videoId}`;
}
return url;
}
detectProvider(url: string): 'youtube' | 'gdrive' | 'maps' | 'generic' {
if (url.includes('youtube.com') || url.includes('youtu.be')) return 'youtube';
if (url.includes('drive.google.com')) return 'gdrive';
if (url.includes('google.com/maps')) return 'maps';
return 'generic';
}
}
@@ -0,0 +1,39 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Block, FileProps } from '../../../core/models/block.model';
@Component({
selector: 'app-file-block',
standalone: true,
imports: [CommonModule],
template: `
<div class="flex items-center gap-2 px-3 py-2 rounded-md border bg-surface1">
<div class="text-2xl leading-none">📎</div>
<div class="flex-1">
<div class="font-semibold text-sm leading-5">{{ props.name || 'Untitled file' }}</div>
@if (props.size) {
<div class="text-xs text-text-muted">{{ formatSize(props.size) }}</div>
}
</div>
@if (props.url) {
<a [href]="props.url" target="_blank" class="btn btn-xs btn-primary">
Download
</a>
}
</div>
`
})
export class FileBlockComponent {
@Input({ required: true }) block!: Block<FileProps>;
@Output() update = new EventEmitter<FileProps>();
get props(): FileProps {
return this.block.props;
}
formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
}
@@ -0,0 +1,157 @@
import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, AfterViewInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Block, HeadingProps } from '../../../core/models/block.model';
import { DocumentService } from '../../../services/document.service';
@Component({
selector: 'app-heading-block',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
@switch (props.level) {
@case (1) {
<h1
contenteditable="true"
class="text-xl font-bold focus:outline-none px-2 py-0.5 rounded-md m-0 w-full bg-transparent"
#editable
(input)="onInput($event)"
(keydown)="onKeyDown($event)"
placeholder="Heading 1"
></h1>
}
@case (2) {
<h2
contenteditable="true"
class="text-lg font-semibold focus:outline-none px-2 py-0.5 rounded-md m-0 w-full bg-transparent"
#editable
(input)="onInput($event)"
(keydown)="onKeyDown($event)"
placeholder="Heading 2"
></h2>
}
@case (3) {
<h3
contenteditable="true"
class="text-base font-semibold focus:outline-none px-2 py-0.5 rounded-md m-0 w-full bg-transparent"
#editable
(input)="onInput($event)"
(keydown)="onKeyDown($event)"
placeholder="Heading 3"
></h3>
}
}
`,
styles: [`
[contenteditable]:empty:before {
content: attr(placeholder);
color: var(--text-muted);
}
h1[contenteditable], h2[contenteditable], h3[contenteditable] {
line-height: 1.25;
}
`]
})
export class HeadingBlockComponent implements AfterViewInit {
@Input({ required: true }) block!: Block<HeadingProps>;
@Output() update = new EventEmitter<HeadingProps>();
@Output() metaChange = new EventEmitter<any>();
@Output() createBlock = new EventEmitter<void>();
@Output() deleteBlock = new EventEmitter<void>();
@ViewChild('editable') editable?: ElementRef<HTMLElement>;
private documentService = inject(DocumentService);
get props(): HeadingProps {
return this.block.props;
}
ngAfterViewInit(): void {
if (this.editable?.nativeElement) {
this.editable.nativeElement.textContent = this.props.text || '';
}
}
onInput(event: Event): void {
const target = event.target as HTMLElement;
this.update.emit({ ...this.props, text: target.textContent || '' });
}
onKeyDown(event: KeyboardEvent): void {
// Handle ENTER: Create new block below with initial menu
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
this.createBlock.emit();
return;
}
// Handle SHIFT+ENTER: Allow line break in contenteditable
if (event.key === 'Enter' && event.shiftKey) {
// Default behavior - line break within block
return;
}
// Handle BACKSPACE on empty block: Delete block
if (event.key === 'Backspace') {
const target = event.target as HTMLElement;
const selection = window.getSelection();
if (selection && selection.anchorOffset === 0 && (!target.textContent || target.textContent.length === 0)) {
event.preventDefault();
this.deleteBlock.emit();
return;
}
}
// Handle TAB: Increase indent
if (event.key === 'Tab' && !event.shiftKey) {
event.preventDefault();
const currentIndent = (this.block.meta as any)?.indent || 0;
const newIndent = Math.min(8, currentIndent + 1);
this.metaChange.emit({ indent: newIndent });
return;
}
// Handle SHIFT+TAB: Decrease indent
if (event.key === 'Tab' && event.shiftKey) {
event.preventDefault();
const currentIndent = (this.block.meta as any)?.indent || 0;
const newIndent = Math.max(0, currentIndent - 1);
this.metaChange.emit({ indent: newIndent });
return;
}
// Up/Down: navigate to previous/next block when at start/end
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
const el = (event.target as HTMLElement);
const text = el.textContent || '';
const sel = window.getSelection();
if (!sel) return;
const atStart = sel.anchorOffset === 0;
const atEnd = sel.anchorOffset === text.length;
if (event.key === 'ArrowUp' && atStart) {
event.preventDefault();
this.focusSibling(-1);
}
if (event.key === 'ArrowDown' && atEnd) {
event.preventDefault();
this.focusSibling(1);
}
}
}
private focusSibling(delta: number): void {
// Access DocumentService via window DI not available; rely on document structure
setTimeout(() => {
const host = (this.editable?.nativeElement?.closest('[data-block-id]')) as HTMLElement | null;
if (!host) return;
const blocks = Array.from(document.querySelectorAll('[data-block-id]')) as HTMLElement[];
const idx = blocks.findIndex(b => b === host);
const next = blocks[idx + delta];
const target = next?.querySelector('[contenteditable]') as HTMLElement | null;
target?.focus();
}, 0);
}
}
@@ -0,0 +1,102 @@
import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Block, HintProps } from '../../../core/models/block.model';
import { IconPickerComponent } from '../../palette/icon-picker.component';
@Component({
selector: 'app-hint-block',
standalone: true,
imports: [CommonModule, IconPickerComponent],
template: `
<div class="relative">
<div
class="px-3 py-2 rounded-md border text-sm"
[style.background]="block.meta?.bgColor || 'transparent'"
[style.borderColor]="props.borderColor || getDefaultBorderColor()"
[style.borderLeftColor]="props.lineColor || getDefaultLineColor()"
[style.borderLeftWidth.px]="4"
>
<div class="flex items-start gap-2">
<button class="text-lg leading-5 select-none" title="Change icon" (click)="togglePicker($event)">{{ getIcon() }}</button>
<div
contenteditable="true"
class="flex-1 focus:outline-none leading-5"
#editable
(input)="onInput($event)"
placeholder="Hint text..."
></div>
</div>
</div>
<div *ngIf="pickerOpen" class="absolute z-50 mt-1" style="left: 0;">
<app-icon-picker (select)="onPick($event)"></app-icon-picker>
</div>
</div>
`,
styles: [`
[contenteditable]:empty:before {
content: attr(placeholder);
color: currentColor;
opacity: 0.5;
}
`]
})
export class HintBlockComponent implements AfterViewInit {
@Input({ required: true }) block!: Block<HintProps>;
@Output() update = new EventEmitter<HintProps>();
@ViewChild('editable') editable?: ElementRef<HTMLElement>;
pickerOpen = false;
get props(): HintProps {
return this.block.props;
}
ngAfterViewInit(): void {
if (this.editable?.nativeElement) {
this.editable.nativeElement.textContent = this.props.text || '';
}
}
onInput(event: Event): void {
const target = event.target as HTMLElement;
this.update.emit({ ...this.props, text: target.textContent || '' });
}
togglePicker(ev: Event) { ev.stopPropagation(); this.pickerOpen = !this.pickerOpen; }
onPick(icon: string) {
this.pickerOpen = false;
this.update.emit({ ...this.props, icon });
}
getHintClass(): string { return ''; }
getIcon(): string {
switch (this.props.variant) {
case 'info': return this.props.icon || 'ℹ️';
case 'warning': return this.props.icon || '⚠️';
case 'success': return this.props.icon || '✅';
case 'note': return this.props.icon || '📝';
default: return this.props.icon || '💡';
}
}
getDefaultBorderColor(): string {
switch (this.props.variant) {
case 'info': return '#3b82f6'; // blue-500
case 'warning': return '#eab308'; // yellow-500
case 'success': return '#22c55e'; // green-500
case 'note': return '#a855f7'; // purple-500
default: return 'var(--border)';
}
}
getDefaultLineColor(): string {
switch (this.props.variant) {
case 'info': return '#3b82f6'; // blue-500
case 'warning': return '#eab308'; // yellow-500
case 'success': return '#22c55e'; // green-500
case 'note': return '#a855f7'; // purple-500
default: return 'var(--border)';
}
}
}
@@ -0,0 +1,477 @@
import { Component, Input, Output, EventEmitter, signal, ViewChild, ElementRef, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Block, ImageProps } from '../../../core/models/block.model';
import { ImageUploadService } from '../../../services/image-upload.service';
@Component({
selector: 'app-image-block',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
@if (props.src) {
<div
class="image-wrapper relative group"
[class]="getAlignmentClass()"
(mouseenter)="showHandles.set(true)"
(mouseleave)="showHandles.set(false)"
>
<figure class="inline-block relative">
<img
[src]="props.src"
[alt]="props.alt || ''"
[style.width.px]="props.width"
[style.height.px]="props.height"
[ngStyle]="getImgStyles()"
class="rounded-md max-w-full block"
/>
<!-- Resize visuals during active resize -->
@if (resizing) {
<div class="grid-overlay"></div>
<div class="image-outline"></div>
<div class="resize-lines">
<div class="line h"></div>
<div class="line v"></div>
</div>
}
<!-- Quick actions (3 dots) top-right -->
<button
class="absolute top-1 right-1 p-1 rounded-md bg-white/70 text-gray-800 shadow hover:bg-white transition opacity-0 group-hover:opacity-100"
title="Quick actions"
(click)="toggleQuickActions($event)"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<circle cx="5" cy="12" r="2"/>
<circle cx="12" cy="12" r="2"/>
<circle cx="19" cy="12" r="2"/>
</svg>
</button>
@if (showQuickActions()) {
<div class="absolute top-7 right-1 z-20 rounded-lg border border-neutral-300 bg-white text-gray-800 shadow-xl p-2 w-max"
(mouseleave)="showQuick.set(false)"
(click)="$event.stopPropagation()">
<div class="text-[11px] font-semibold text-gray-500 mb-1">Aspect</div>
<div class="flex items-center gap-1">
<button class="qa-chip" [class.qa-chip-active]="isActive('free')" (click)="onAspect('free')">Free</button>
<button class="qa-chip" [class.qa-chip-active]="isActive('16:9')" (click)="onAspect('16:9')">16:9</button>
<button class="qa-chip" [class.qa-chip-active]="isActive('4:3')" (click)="onAspect('4:3')">4:3</button>
<button class="qa-chip" [class.qa-chip-active]="isActive('1:1')" (click)="onAspect('1:1')">1:1</button>
<button class="qa-chip" [class.qa-chip-active]="isActive('3:2')" (click)="onAspect('3:2')">3:2</button>
</div>
<div class="flex items-center gap-2 mt-2">
<button class="qa-btn" (click)="onCrop()" title="Crop">Crop</button>
<button class="qa-btn" (click)="openSettings($event)" title="Settings">Settings</button>
</div>
</div>
}
@if (showHandles()) {
<div class="resize-handle corner top-left" (mousedown)="onResizeStart($event, 'nw')"></div>
<div class="resize-handle corner top-right" (mousedown)="onResizeStart($event, 'ne')"></div>
<div class="resize-handle corner bottom-left" (mousedown)="onResizeStart($event, 'sw')"></div>
<div class="resize-handle corner bottom-right" (mousedown)="onResizeStart($event, 'se')"></div>
<div class="resize-handle edge top" (mousedown)="onResizeStart($event, 'n')"></div>
<div class="resize-handle edge bottom" (mousedown)="onResizeStart($event, 's')"></div>
<div class="resize-handle edge left" (mousedown)="onResizeStart($event, 'w')"></div>
<div class="resize-handle edge right" (mousedown)="onResizeStart($event, 'e')"></div>
}
@if (props.caption) {
<figcaption class="text-xs text-center mt-2 text-text-muted dark:text-neutral-500 italic">
{{ props.caption }}
</figcaption>
}
</figure>
</div>
} @else {
<div class="border-2 border-dashed rounded-md px-4 py-4 text-center bg-surface1"
(dragover)="onDragOver($event)" (drop)="onDrop($event)" (paste)="onPaste($event)">
<div class="text-sm text-text-muted mb-2">Drop an image, paste from clipboard, or choose a file</div>
<div class="flex items-center justify-center gap-3 flex-wrap">
<input type="text" class="input input-bordered w-full max-w-md" placeholder="Paste image URL..." (change)="onUrlChange($event)" />
<button class="btn btn-sm btn-primary" type="button" (click)="openFileBrowser()">Browse…</button>
<button class="btn btn-sm" type="button" (click)="openUnsplash()">Unsplash</button>
</div>
<input #fileInput type="file" accept="image/*" multiple class="hidden" (change)="onFileSelected($event)" />
</div>
}
`,
styles: [`
.image-wrapper {
display: block;
}
.image-wrapper.align-left {
text-align: left;
}
.image-wrapper.align-center {
text-align: center;
}
.image-wrapper.align-right {
text-align: right;
}
.image-wrapper.align-full figure {
width: 100%;
}
.image-wrapper.align-full img {
width: 100%;
max-width: 100%;
}
.resize-handle {
position: absolute;
background: #ffffff;
border: 2px solid #9ca3af; /* gray-400 */
border-radius: 50%;
cursor: pointer;
z-index: 10;
transition: transform 0.2s;
}
.resize-handle:hover {
transform: scale(1.2);
}
.resize-handle.corner {
width: 12px;
height: 12px;
}
.resize-handle.edge {
width: 10px;
height: 10px;
}
.resize-handle.top-left {
top: -6px;
left: -6px;
cursor: nw-resize;
}
.resize-handle.top-right {
top: -6px;
right: -6px;
cursor: ne-resize;
}
.resize-handle.bottom-left {
bottom: -6px;
left: -6px;
cursor: sw-resize;
}
.resize-handle.bottom-right {
bottom: -6px;
right: -6px;
cursor: se-resize;
}
.resize-handle.top {
top: -5px;
left: 50%;
transform: translateX(-50%);
cursor: n-resize;
}
.resize-handle.bottom {
bottom: -5px;
left: 50%;
transform: translateX(-50%);
cursor: s-resize;
}
.resize-handle.left {
left: -5px;
top: 50%;
transform: translateY(-50%);
cursor: w-resize;
}
.resize-handle.right {
right: -5px;
top: 50%;
transform: translateY(-50%);
cursor: e-resize;
}
/* Grid overlay and outline during resize */
.grid-overlay {
position: absolute;
inset: 0;
pointer-events: none;
background-image: linear-gradient(90deg, rgba(147,197,253,0.25) 1px, transparent 1px),
linear-gradient(180deg, rgba(147,197,253,0.25) 1px, transparent 1px);
background-size: 20px 20px;
border-radius: 0.375rem;
}
.image-outline {
position: absolute;
inset: -1px;
pointer-events: none;
border: 2px solid rgba(147,197,253,0.9); /* light blue */
border-radius: 0.375rem;
}
.resize-lines { position: absolute; inset: 0; pointer-events: none; }
.resize-lines .line { position: absolute; background: rgba(147,197,253,0.6); }
.resize-lines .line.h { left: 0; right: 0; top: 50%; height: 1px; transform: translateY(-0.5px); }
.resize-lines .line.v { top: 0; bottom: 0; left: 50%; width: 1px; transform: translateX(-0.5px); }
/* Quick actions */
.qa-chip {
font-size: 11px;
line-height: 1rem;
padding: 2px 6px;
border-radius: 8px;
border: 1px solid #e5e7eb;
background: #ffffff;
}
.qa-chip:hover { background: #f3f4f6; }
.qa-chip-active { background: rgba(59,130,246,0.12); border-color: #93c5fd; }
.qa-btn {
font-size: 12px;
padding: 4px 8px;
border-radius: 6px;
border: 1px solid #e5e7eb;
background: #ffffff;
}
.qa-btn:hover { background: #f3f4f6; }
`]
})
export class ImageBlockComponent {
@Input({ required: true }) block!: Block<ImageProps>;
@Output() update = new EventEmitter<ImageProps>();
@Output() requestMenu = new EventEmitter<{ x: number; y: number }>();
@Output() insertImagesBelow = new EventEmitter<string[]>();
showHandles = signal(false);
showQuick = signal(false);
resizing = false;
private resizeDirection: string | null = null;
private startX = 0;
private startY = 0;
private startWidth = 0;
private startHeight = 0;
@ViewChild('fileInput') fileInput!: ElementRef<HTMLInputElement>;
private readonly uploader = inject(ImageUploadService);
unsplashOpen = signal(false);
get props(): ImageProps {
return this.block.props;
}
onUrlChange(event: Event): void {
const target = event.target as HTMLInputElement;
this.update.emit({ ...this.props, src: target.value });
}
openFileBrowser(): void {
try { this.fileInput?.nativeElement?.click(); } catch {}
}
async onFileSelected(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const files = input.files;
if (!files || files.length === 0) return;
await this.handleFiles(Array.from(files));
// Reset input so selecting the same file again triggers change
try { input.value = ''; } catch {}
}
onDragOver(ev: DragEvent): void {
ev.preventDefault();
}
async onDrop(ev: DragEvent): Promise<void> {
ev.preventDefault();
const files = ev.dataTransfer?.files;
if (!files || files.length === 0) return;
await this.handleFiles(Array.from(files));
}
async onPaste(ev: ClipboardEvent): Promise<void> {
const dt = ev.clipboardData;
if (!dt) return;
// Prefer image blobs
const imgItem = Array.from(dt.items || []).find(i => i.type.startsWith('image/'));
if (imgItem) {
ev.preventDefault();
const blob = imgItem.getAsFile();
if (blob) {
await this.handleFiles([blob as File]);
return;
}
}
// Fallback: pasted URL
const txt = dt.getData('text/plain');
if (txt && /^https?:\/\//i.test(txt)) {
ev.preventDefault();
await this.applyUrl(txt);
}
}
private async handleFiles(files: File[]): Promise<void> {
const urls: string[] = [];
for (const f of files) {
try {
const url = await this.uploader.saveFile(f, f.name);
urls.push(url);
} catch (e) {
console.warn('Image upload failed', e);
}
}
if (urls.length === 0) return;
// Set first into this block
this.update.emit({ ...this.props, src: urls[0] });
// Emit others to be inserted below
if (urls.length > 1) this.insertImagesBelow.emit(urls.slice(1));
}
async applyUrl(url: string): Promise<void> {
try {
const saved = await this.uploader.saveImageUrl(url, 'pasted');
this.update.emit({ ...this.props, src: saved });
} catch {
// If upload fails, fallback to direct URL
this.update.emit({ ...this.props, src: url });
}
}
openUnsplash(): void {
this.unsplashOpen.set(true);
// Lazy import modal to avoid circular deps; simple global event used
// We'll dispatch a custom event listened by UnsplashPicker (rendered at app root)
const ev = new CustomEvent('nimbus-open-unsplash', { detail: { callback: async (imageUrl: string) => {
this.unsplashOpen.set(false);
await this.applyUrl(imageUrl);
}}});
window.dispatchEvent(ev);
}
getAlignmentClass(): string {
const alignment = this.props.alignment || 'center';
return `align-${alignment}`;
}
getAspectRatio(): string | undefined {
if (!this.props.aspectRatio || this.props.aspectRatio === 'free') return undefined;
const ratios: Record<string, string> = {
'16:9': '16/9',
'4:3': '4/3',
'1:1': '1/1',
'3:2': '3/2',
};
return ratios[this.props.aspectRatio];
}
getImgStyles(): { [key: string]: string } {
const styles: { [key: string]: string } = {};
const ratio = this.getAspectRatio();
if (ratio) styles['aspect-ratio'] = ratio;
const rotation = (this.props as any)?.rotation || 0;
if (rotation) styles['transform'] = `rotate(${rotation}deg)`;
return styles;
}
showQuickActions(): boolean {
return this.showQuick();
}
toggleQuickActions(ev: MouseEvent) {
ev.stopPropagation();
ev.preventDefault();
this.showQuick.update(v => !v);
}
isActive(ratio: string): boolean {
return (this.props.aspectRatio || 'free') === ratio;
}
onAspect(ratio: string) {
this.update.emit({ ...this.props, aspectRatio: ratio });
}
onCrop() {
alert('Crop coming soon!');
}
openSettings(ev: MouseEvent) {
ev.stopPropagation();
const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect();
this.requestMenu.emit({ x: rect.right, y: rect.bottom });
this.showQuick.set(false);
}
onResizeStart(event: MouseEvent, direction: string): void {
event.preventDefault();
event.stopPropagation();
this.resizing = true;
this.resizeDirection = direction;
this.startX = event.clientX;
this.startY = event.clientY;
this.startWidth = this.props.width || 400;
this.startHeight = this.props.height || 300;
const onMove = (e: MouseEvent) => this.onResizeMove(e);
const onUp = () => this.onResizeEnd(onMove, onUp);
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
private onResizeMove(event: MouseEvent): void {
if (!this.resizing || !this.resizeDirection) return;
const deltaX = event.clientX - this.startX;
const deltaY = event.clientY - this.startY;
let newWidth = this.startWidth;
let newHeight = this.startHeight;
// Calculer les nouvelles dimensions selon la direction
if (this.resizeDirection.includes('e')) newWidth = this.startWidth + deltaX;
if (this.resizeDirection.includes('w')) newWidth = this.startWidth - deltaX;
if (this.resizeDirection.includes('s')) newHeight = this.startHeight + deltaY;
if (this.resizeDirection.includes('n')) newHeight = this.startHeight - deltaY;
// Limites min/max
newWidth = Math.max(100, Math.min(1200, newWidth));
newHeight = Math.max(100, Math.min(1200, newHeight));
// Si aspect ratio défini, maintenir la proportion
if (this.props.aspectRatio && this.props.aspectRatio !== 'free') {
const ratio = this.getAspectRatioValue();
if (ratio) {
newHeight = newWidth / ratio;
}
}
this.update.emit({
...this.props,
width: Math.round(newWidth),
height: Math.round(newHeight)
});
}
private onResizeEnd(onMove: (e: MouseEvent) => void, onUp: () => void): void {
this.resizing = false;
this.resizeDirection = null;
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
private getAspectRatioValue(): number | null {
const ratios: Record<string, number> = {
'16:9': 16/9,
'4:3': 4/3,
'1:1': 1,
'3:2': 3/2,
};
return ratios[this.props.aspectRatio || ''] || null;
}
}
@@ -0,0 +1,156 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CdkDragDrop, DragDropModule, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
import { Block, KanbanProps, KanbanColumn, KanbanCard } from '../../../core/models/block.model';
import { generateItemId } from '../../../core/utils/id-generator';
@Component({
selector: 'app-kanban-block',
standalone: true,
imports: [CommonModule, DragDropModule],
template: `
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
@for (column of props.columns; track column.id) {
<div class="bg-surface2 rounded-2xl p-3">
<div class="flex items-center justify-between mb-3">
<input
type="text"
class="font-semibold bg-transparent border-none outline-none flex-1"
[value]="column.title"
(input)="onColumnTitleInput($event, column.id)"
/>
<button type="button" class="btn btn-xs btn-circle" (click)="deleteColumn(column.id)">✕</button>
</div>
<div
cdkDropList
[cdkDropListData]="column.cards"
[cdkDropListConnectedTo]="getConnectedLists()"
(cdkDropListDropped)="onDrop($event, column.id)"
class="space-y-2 min-h-20"
>
@for (card of column.cards; track card.id) {
<div cdkDrag class="bg-surface1 rounded-xl p-3 shadow cursor-move">
<input
type="text"
class="font-medium bg-transparent border-none outline-none w-full mb-1"
[value]="card.title"
(input)="onCardTitleInput($event, column.id, card.id)"
/>
<textarea
class="text-sm text-text-muted bg-transparent border-none outline-none w-full resize-none"
[value]="card.description || ''"
(input)="onCardDescInput($event, column.id, card.id)"
placeholder="Description..."
rows="2"
></textarea>
</div>
}
</div>
<button type="button" class="btn btn-sm btn-block mt-2" (click)="addCard(column.id)">
+ Add card
</button>
</div>
}
</div>
<button type="button" class="btn btn-sm mt-4" (click)="addColumn()">
+ Add column
</button>
`
})
export class KanbanBlockComponent {
@Input({ required: true }) block!: Block<KanbanProps>;
@Output() update = new EventEmitter<KanbanProps>();
get props(): KanbanProps {
return this.block.props;
}
getConnectedLists(): string[] {
return this.props.columns.map(c => c.id);
}
onDrop(event: CdkDragDrop<KanbanCard[]>, columnId: string): void {
if (event.previousContainer === event.container) {
const column = this.props.columns.find(c => c.id === columnId);
if (column) {
moveItemInArray(column.cards, event.previousIndex, event.currentIndex);
this.update.emit({ ...this.props });
}
} else {
const sourceColumn = this.props.columns.find(c => c.id === event.previousContainer.id);
const targetColumn = this.props.columns.find(c => c.id === columnId);
if (sourceColumn && targetColumn) {
transferArrayItem(
sourceColumn.cards,
targetColumn.cards,
event.previousIndex,
event.currentIndex
);
this.update.emit({ ...this.props });
}
}
}
onColumnTitleInput(event: Event, columnId: string): void {
const target = event.target as HTMLInputElement;
const columns = this.props.columns.map(c =>
c.id === columnId ? { ...c, title: target.value } : c
);
this.update.emit({ columns });
}
onCardTitleInput(event: Event, columnId: string, cardId: string): void {
const target = event.target as HTMLInputElement;
const columns = this.props.columns.map(col => {
if (col.id !== columnId) return col;
return {
...col,
cards: col.cards.map(card =>
card.id === cardId ? { ...card, title: target.value } : card
)
};
});
this.update.emit({ columns });
}
onCardDescInput(event: Event, columnId: string, cardId: string): void {
const target = event.target as HTMLTextAreaElement;
const columns = this.props.columns.map(col => {
if (col.id !== columnId) return col;
return {
...col,
cards: col.cards.map(card =>
card.id === cardId ? { ...card, description: target.value } : card
)
};
});
this.update.emit({ columns });
}
addColumn(): void {
const newColumn: KanbanColumn = {
id: generateItemId(),
title: 'New Column',
cards: []
};
this.update.emit({ columns: [...this.props.columns, newColumn] });
}
deleteColumn(columnId: string): void {
const columns = this.props.columns.filter(c => c.id !== columnId);
this.update.emit({ columns });
}
addCard(columnId: string): void {
const columns = this.props.columns.map(col => {
if (col.id !== columnId) return col;
const newCard: KanbanCard = {
id: generateItemId(),
title: 'New Card',
description: ''
};
return { ...col, cards: [...col.cards, newCard] };
});
this.update.emit({ columns });
}
}
@@ -0,0 +1,28 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Block, LineProps } from '../../../core/models/block.model';
@Component({
selector: 'app-line-block',
standalone: true,
imports: [CommonModule],
template: `
<hr [class]="getLineClass()" />
`
})
export class LineBlockComponent {
@Input({ required: true }) block!: Block<LineProps>;
get props(): LineProps {
return this.block.props;
}
getLineClass(): string {
const base = 'my-4 border-border';
switch (this.props.style) {
case 'dashed': return `${base} border-dashed`;
case 'dotted': return `${base} border-dotted`;
default: return `${base} border-solid`;
}
}
}
@@ -0,0 +1,277 @@
import { Component, Input, Output, EventEmitter, signal, computed, ViewChildren, QueryList, ElementRef, inject, HostListener, AfterViewInit, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Block, ListProps, ListItem } from '../../../core/models/block.model';
import { generateItemId } from '../../../core/utils/id-generator';
import { PaletteService } from '../../../services/palette.service';
import { SelectionService } from '../../../services/selection.service';
@Component({
selector: 'app-list-block',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="w-full space-y-2">
@for (it of items(); track it.id; let i = $index) {
<div class="flex items-center gap-3 cursor-text" (click)="onItemClick(i)">
<!-- Marker (bullet, checkbox, or number) -->
<div class="flex-shrink-0 flex items-center justify-center" style="width: 24px; min-width: 24px;">
@if (kind() === 'bullet') {
<div class="rounded-full bg-slate-200" style="width: 8px; height: 8px;"></div>
} @else if (kind() === 'check') {
<input type="checkbox"
class="cursor-pointer"
style="width: 20px; height: 20px; border: 2px solid rgba(148, 163, 184, 0.8); border-radius: 2px; background: transparent;"
[checked]="it.checked || false"
(change)="onCheckChange($event, it.id)"
(click)="$event.stopPropagation()" />
} @else {
<span class="text-slate-200" style="font-size: 16px; font-weight: 500;">{{ i + 1 }}.</span>
}
</div>
<!-- Input pill - inherits block color or uses transparent background -->
<input #inp type="text"
class="flex-1 rounded-xl px-5 py-2.5 text-xl leading-7 focus:outline-none cursor-text border-none"
[class.text-slate-900]="hasBlockColor()"
[class.text-slate-100]="!hasBlockColor()"
[class.placeholder-slate-500]="hasBlockColor()"
[class.placeholder-slate-200/60]="!hasBlockColor()"
[style.background-color]="getInputBackground()"
[value]="it.text"
(input)="onInput(i, $event)"
(keydown)="onKeyDown(i, $event)"
[placeholder]="getPlaceholder()"
autocomplete="off" />
</div>
}
<!-- Inline prompt shown exactly at removed index -->
@if (promptIndex() !== null) {
<div (click)="onPromptClick()" class="cursor-text">
<div class="flex items-center gap-4 text-slate-300/90 py-2 px-3">
<span class="text-lg">Start writing or type "/", "@"</span>
<div class="h-6 w-px bg-slate-600"></div>
<div class="flex items-center gap-3 opacity-80 select-none">
<span class="text-xl">✱</span>
<span class="text-xl">☑</span>
<span class="text-xl">12³</span>
<span class="text-xl">≡</span>
<span class="text-xl">▦</span>
<span class="text-xl">🖼️</span>
<span class="text-xl">📎</span>
<span class="text-xl">🗎+</span>
<span class="text-xl">Hₘ</span>
<span class="text-xl">▾</span>
</div>
</div>
</div>
}
</div>
`
})
export class ListBlockComponent implements OnInit, AfterViewInit {
@Input({ required: true }) block!: Block<ListProps>;
@Output() update = new EventEmitter<ListProps>();
@ViewChildren('inp') inputs!: QueryList<ElementRef<HTMLInputElement>>;
// Local reactive state derived from props for keyboard UX
items = signal<ListItem[]>([]);
promptIndex = signal<number | null>(null);
kind = signal<'bullet' | 'numbered' | 'check'>('bullet');
private palette = inject(PaletteService);
private selection = inject(SelectionService);
ngOnInit(): void {
// Initialize kind signal from block props
const normalizedKind = this.normalizeKind(this.block.props.kind as any);
this.kind.set(normalizedKind);
// initialize local items from props
const init = [...(this.block.props.items || [])];
if (init.length === 0) {
init.push({ id: generateItemId(), text: '', checked: this.kind() === 'check' ? false : undefined });
}
this.items.set(init);
}
ngAfterViewInit(): void {
// Focus the first input when this block becomes active (post-append)
queueMicrotask(() => {
try {
if (this.selection.isActive(this.block.id)) {
this.focus(0);
}
} catch {}
});
}
get props(): ListProps {
return this.block.props;
}
trackById(_: number, it: ListItem) { return it.id; }
private emit(items: ListItem[]) {
this.update.emit({ ...this.props, items });
}
private normalizeKind(k: any): 'bullet' | 'numbered' | 'check' {
const v = String(k || '').toLowerCase();
if (v === 'bulleted' || v === 'bullet') return 'bullet';
if (v === 'checkbox' || v === 'check' || v === 'task') return 'check';
return 'numbered';
}
getPlaceholder(): string {
const k = this.kind();
if (k === 'bullet') return 'bullet list';
if (k === 'check') return 'checkbox list';
return 'numbered list';
}
hasBlockColor(): boolean {
return !!(this.block.meta?.bgColor);
}
getInputBackground(): string {
// If block has a custom color, use it
if (this.block.meta?.bgColor) {
return this.block.meta.bgColor;
}
// Default: transparent (uses theme background)
return 'transparent';
}
onInput(i: number, ev: Event): void {
const v = (ev.target as HTMLInputElement).value;
const arr = [...this.items()];
arr[i] = { ...arr[i], text: v };
this.items.set(arr);
this.emit(arr);
}
onCheckChange(ev: Event, itemId: string): void {
const checked = (ev.target as HTMLInputElement).checked;
const arr = this.items().map(item => item.id === itemId ? { ...item, checked } : item);
this.items.set(arr);
this.emit(arr);
}
onKeyDown(i: number, ev: KeyboardEvent): void {
const input = ev.target as HTMLInputElement;
// ENTER adds a new item below
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
this.insertAfter(i);
queueMicrotask(() => this.focus(i + 1));
return;
}
// BACKSPACE on empty shows inline prompt and removes item
if (ev.key === 'Backspace' && input.value.length === 0) {
ev.preventDefault();
this.removeAt(i);
this.promptIndex.set(i);
return;
}
// Slash in prompt opens palette
if (ev.key === '/' && this.promptIndex() !== null) {
ev.preventDefault();
try { this.palette.open(); } catch {}
return;
}
// Escape exits prompt and recreates empty item
if (ev.key === 'Escape' && this.promptIndex() !== null) {
ev.preventDefault();
this.promptIndex.set(null);
this.insertAt(i, { id: generateItemId(), text: '', checked: this.kind() === 'check' ? false : undefined });
queueMicrotask(() => this.focus(i));
}
}
insertAfter(i: number) {
const arr = [...this.items()];
arr.splice(i + 1, 0, { id: generateItemId(), text: '', checked: this.kind() === 'check' ? false : undefined });
this.items.set(arr);
this.promptIndex.set(null);
this.emit(arr);
}
insertAt(i: number, item: ListItem) {
const arr = [...this.items()];
arr.splice(i, 0, item);
this.items.set(arr);
this.emit(arr);
}
removeAt(i: number) {
const arr = [...this.items()];
arr.splice(i, 1);
this.items.set(arr);
this.emit(arr);
}
focus(i: number) {
const el = this.inputs?.get(i)?.nativeElement;
el?.focus();
const len = el?.value.length ?? 0;
el?.setSelectionRange(len, len);
}
onItemClick(i: number): void {
this.focus(i);
}
@HostListener('document:keydown', ['$event'])
onDocKey(e: KeyboardEvent): void {
const idx = this.promptIndex();
if (idx === null) return;
if (e.key === 'Escape') {
e.preventDefault();
this.promptIndex.set(null);
this.insertAt(idx, { id: generateItemId(), text: '', checked: this.kind() === 'check' ? false : undefined });
queueMicrotask(() => this.focus(idx));
return;
}
if (e.key === '/') {
e.preventDefault();
try { this.palette.open(); } catch {}
return;
}
if (e.key === 'Enter') {
e.preventDefault();
this.promptIndex.set(null);
this.insertAt(idx, { id: generateItemId(), text: '', checked: this.kind() === 'check' ? false : undefined });
queueMicrotask(() => this.focus(idx));
return;
}
// Any printable key should start a new item and seed with first char
if (!e.ctrlKey && !e.metaKey && !e.altKey && e.key.length === 1) {
e.preventDefault();
const char = e.key;
this.promptIndex.set(null);
this.insertAt(idx, { id: generateItemId(), text: char, checked: this.kind() === 'check' ? false : undefined });
queueMicrotask(() => {
const el = this.inputs?.get(idx)?.nativeElement;
if (el) {
const len = el.value.length;
el.focus();
el.setSelectionRange(len, len);
}
});
}
}
onPromptClick(): void {
const idx = this.promptIndex();
if (idx === null) return;
this.promptIndex.set(null);
this.insertAt(idx, { id: generateItemId(), text: '', checked: this.kind() === 'check' ? false : undefined });
queueMicrotask(() => this.focus(idx));
}
}
@@ -0,0 +1,196 @@
import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, inject, AfterViewInit, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Block, ListItemProps } from '../../../core/models/block.model';
import { SelectionService } from '../../../services/selection.service';
import { DocumentService } from '../../../services/document.service';
@Component({
selector: 'app-list-item-block',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="flex items-center gap-3 cursor-text w-full" (click)="focusInput()" [style.padding-left.px]="getIndentPadding()">
<!-- Marker (bullet, checkbox, or number) -->
<div class="flex-shrink-0 flex items-center justify-center" style="width: 24px; min-width: 24px;">
@if (props.kind === 'bullet') {
<span class="text-slate-200" style="font-size: 18px; line-height: 1;">{{ getBulletSymbol() }}</span>
} @else if (props.kind === 'check') {
<input type="checkbox"
class="cursor-pointer"
style="width: 20px; height: 20px; border: 2px solid rgba(148, 163, 184, 0.8); border-radius: 2px; background: transparent;"
[checked]="props.checked || false"
(change)="onCheckChange($event)"
(click)="$event.stopPropagation()" />
} @else {
<span class="text-slate-200" style="font-size: 16px; font-weight: 500;">{{ props.number || 1 }}.</span>
}
</div>
<!-- Input text - inherits block color or uses transparent background -->
<input #inp type="text"
class="flex-1 rounded-xl px-5 py-2.5 text-xl leading-7 cursor-text border-none shadow-none"
[class.text-slate-900]="hasBlockColor()"
[class.text-slate-100]="!hasBlockColor()"
[class.placeholder-slate-500]="hasBlockColor()"
[class.placeholder-slate-200/60]="!hasBlockColor()"
[class.text-left]="getAlignment() === 'left'"
[class.text-center]="getAlignment() === 'center'"
[class.text-right]="getAlignment() === 'right'"
[class.text-justify]="getAlignment() === 'justify'"
[style.background-color]="getInputBackground()"
[value]="props.text"
(input)="onInput($event)"
(keydown)="onKeyDown($event)"
[placeholder]="getPlaceholder()"
autocomplete="off"
style="outline: none !important; box-shadow: none !important;" />
</div>
`,
styles: [`
input:focus {
outline: none !important;
box-shadow: none !important;
border: none !important;
}
`]
})
export class ListItemBlockComponent implements OnInit, AfterViewInit {
@Input({ required: true }) block!: Block<ListItemProps>;
@Output() update = new EventEmitter<ListItemProps>();
@ViewChild('inp') input!: ElementRef<HTMLInputElement>;
private selection = inject(SelectionService);
private documentService = inject(DocumentService);
get props(): ListItemProps {
return this.block.props;
}
ngOnInit(): void {
// Component initialized
}
ngAfterViewInit(): void {
// Focus the input when this block becomes active
queueMicrotask(() => {
try {
if (this.selection.isActive(this.block.id)) {
this.focusInput();
}
} catch {}
});
}
hasBlockColor(): boolean {
return !!(this.block.meta?.bgColor);
}
getInputBackground(): string {
// If block has a custom color, use it
if (this.block.meta?.bgColor) {
return this.block.meta.bgColor;
}
// Default: transparent (uses theme background)
return 'transparent';
}
getPlaceholder(): string {
const k = this.props.kind;
if (k === 'bullet') return 'List';
if (k === 'check') return 'To-do';
return 'List';
}
getBulletSymbol(): string {
const indent = this.props.indent || 0;
const symbols = ['•', '■', '✱', '▸', '○', '→', '◆', '•'];
return symbols[indent % symbols.length];
}
getIndentPadding(): number {
const indent = this.props.indent || 0;
return indent * 32; // 32px per level
}
getAlignment(): 'left' | 'center' | 'right' | 'justify' {
return this.props.align || 'left';
}
onInput(ev: Event): void {
const v = (ev.target as HTMLInputElement).value;
this.update.emit({ ...this.props, text: v });
}
onCheckChange(ev: Event): void {
const checked = (ev.target as HTMLInputElement).checked;
this.update.emit({ ...this.props, checked });
}
onKeyDown(ev: KeyboardEvent): void {
const input = ev.target as HTMLInputElement;
// TAB: Increase indent
if (ev.key === 'Tab' && !ev.shiftKey) {
ev.preventDefault();
const currentIndent = this.props.indent || 0;
const newIndent = Math.min(7, currentIndent + 1);
this.update.emit({ ...this.props, indent: newIndent });
return;
}
// SHIFT+TAB: Decrease indent
if (ev.key === 'Tab' && ev.shiftKey) {
ev.preventDefault();
const currentIndent = this.props.indent || 0;
const newIndent = Math.max(0, currentIndent - 1);
this.update.emit({ ...this.props, indent: newIndent });
return;
}
// ENTER: Create new list item below
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
// Get the current block index
const blocks = this.documentService.blocks();
const currentIndex = blocks.findIndex(b => b.id === this.block.id);
if (currentIndex !== -1) {
// Create new list item with same kind and indent
let newProps: ListItemProps = {
kind: this.props.kind,
text: '',
checked: this.props.kind === 'check' ? false : undefined,
indent: this.props.indent || 0,
align: this.props.align
};
// For numbered lists, increment the number
if (this.props.kind === 'numbered' && this.props.number) {
newProps.number = this.props.number + 1;
}
const newBlock = this.documentService.createBlock('list-item' as any, newProps);
this.documentService.insertBlock(this.block.id, newBlock);
this.selection.setActive(newBlock.id);
}
return;
}
// BACKSPACE on empty: Delete this block
if (ev.key === 'Backspace' && input.value.length === 0) {
ev.preventDefault();
this.documentService.deleteBlock(this.block.id);
return;
}
}
focusInput(): void {
const el = this.input?.nativeElement;
el?.focus();
const len = el?.value.length ?? 0;
el?.setSelectionRange(len, len);
}
}
@@ -0,0 +1,50 @@
import { Component, Input, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Block, OutlineProps } from '../../../core/models/block.model';
import { DocumentService } from '../../../services/document.service';
@Component({
selector: 'app-outline-block',
standalone: true,
imports: [CommonModule],
template: `
<div class="border rounded-xl p-4 bg-surface1">
<h3 class="font-semibold mb-3 flex items-center gap-2">
<span>📑</span>
<span>Table of Contents</span>
</h3>
@if (outline().length === 0) {
<div class="text-sm text-text-muted italic">
No headings found in this document.
</div>
} @else {
<nav class="space-y-1">
@for (heading of outline(); track heading.id) {
<a
[href]="'#' + heading.blockId"
[class]="getHeadingClass(heading.level)"
class="block hover:text-primary transition-colors"
>
{{ heading.text }}
</a>
}
</nav>
}
</div>
`
})
export class OutlineBlockComponent {
@Input({ required: true }) block!: Block<OutlineProps>;
private readonly documentService = inject(DocumentService);
readonly outline = this.documentService.outline;
getHeadingClass(level: 1 | 2 | 3): string {
switch (level) {
case 1: return 'text-base font-semibold';
case 2: return 'text-sm pl-4';
case 3: return 'text-sm pl-8 text-text-muted';
default: return '';
}
}
}
@@ -0,0 +1,195 @@
import { Component, Input, Output, EventEmitter, inject, ViewChild, ElementRef, AfterViewInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Block, ParagraphProps } from '../../../core/models/block.model';
import { DocumentService } from '../../../services/document.service';
import { SelectionService } from '../../../services/selection.service';
import { PaletteService } from '../../../services/palette.service';
@Component({
selector: 'app-paragraph-block',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div
class="relative"
(click)="onContainerClick($event)"
>
<div
#editable
contenteditable="true"
class="w-full m-0 bg-transparent text-sm text-neutral-100 dark:text-neutral-100 focus:outline-none min-h-[1.25rem]"
(input)="onInput($event)"
(keydown)="onKeyDown($event)"
(focus)="isFocused.set(true)"
(blur)="onBlur()"
[attr.data-placeholder]="placeholder"
></div>
</div>
`,
styles: [`
/* Show placeholder only when focused and empty */
[contenteditable][data-placeholder]:empty:focus:before {
content: attr(data-placeholder);
color: rgb(107, 114, 128);
opacity: 0.6;
pointer-events: none;
}
[contenteditable] {
line-height: 1.25;
}
[contenteditable]:focus {
outline: none;
}
`]
})
export class ParagraphBlockComponent implements AfterViewInit {
@Input({ required: true }) block!: Block<ParagraphProps>;
@Input() showDragHandle = true; // Hide drag handle in columns
@Output() update = new EventEmitter<ParagraphProps>();
@Output() metaChange = new EventEmitter<any>();
@Output() createBlock = new EventEmitter<void>();
@Output() deleteBlock = new EventEmitter<void>();
private documentService = inject(DocumentService);
private selectionService = inject(SelectionService);
private paletteService = inject(PaletteService);
@ViewChild('editable', { static: true }) editable?: ElementRef<HTMLDivElement>;
isFocused = signal(false);
isEmpty = signal(true);
placeholder = "Start writing or type '/', '@'";
get props(): ParagraphProps {
return this.block.props;
}
ngAfterViewInit(): void {
// Initialize content once to avoid Angular rebinding while typing
if (this.editable?.nativeElement) {
this.editable.nativeElement.textContent = this.props.text || '';
this.isEmpty.set(!(this.props.text && this.props.text.length > 0));
}
}
onInput(event: Event): void {
const target = event.target as HTMLElement;
this.update.emit({ text: target.textContent || '' });
this.isEmpty.set(!(target.textContent && target.textContent.length > 0));
}
onKeyDown(event: KeyboardEvent): void {
// Handle TAB: Increase indent
if (event.key === 'Tab' && !event.shiftKey) {
event.preventDefault();
const currentIndent = (this.block.meta as any)?.indent || 0;
const newIndent = Math.min(8, currentIndent + 1);
this.metaChange.emit({ indent: newIndent });
return;
}
// Handle SHIFT+TAB: Decrease indent
if (event.key === 'Tab' && event.shiftKey) {
event.preventDefault();
const currentIndent = (this.block.meta as any)?.indent || 0;
const newIndent = Math.max(0, currentIndent - 1);
this.metaChange.emit({ indent: newIndent });
return;
}
// Handle "/" key: open palette
if (event.key === '/') {
const target = event.target as HTMLElement;
const text = target.textContent || '';
// Only trigger if "/" is at start or after space
if (text.length === 0 || text.endsWith(' ')) {
event.preventDefault();
this.paletteService.open();
return;
}
}
// Handle ENTER: Create new block below with initial menu
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
this.createBlock.emit();
return;
}
// Handle SHIFT+ENTER: Allow line break in contenteditable
if (event.key === 'Enter' && event.shiftKey) {
// Default behavior - line break within block
return;
}
// Handle BACKSPACE on empty block: Delete block
if (event.key === 'Backspace') {
const target = event.target as HTMLElement;
const selection = window.getSelection();
if (selection && selection.anchorOffset === 0 && (!target.textContent || target.textContent.length === 0)) {
event.preventDefault();
this.deleteBlock.emit();
return;
}
}
// ArrowUp/ArrowDown navigation between blocks
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
const el = (event.target as HTMLElement);
const text = el.textContent || '';
const sel = window.getSelection();
if (!sel) return;
const atStart = sel.anchorOffset === 0;
const atEnd = sel.anchorOffset === text.length;
if (event.key === 'ArrowUp' && atStart) {
event.preventDefault();
this.focusSibling(-1);
}
if (event.key === 'ArrowDown' && atEnd) {
event.preventDefault();
this.focusSibling(1);
}
}
}
onBlur(): void {
this.isFocused.set(false);
// Recompute emptiness in case content was cleared
const el = this.editable?.nativeElement;
if (el) this.isEmpty.set(!(el.textContent && el.textContent.length > 0));
}
onContainerClick(event: MouseEvent): void {
// Ignore clicks on buttons/icons to avoid stealing clicks
const target = event.target as HTMLElement;
if (target.closest('button')) return;
const el = this.editable?.nativeElement;
if (!el) return;
// Focus and place caret at start so cursor blinks before placeholder
el.focus();
const sel = window.getSelection();
if (!sel) return;
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(true); // start
sel.removeAllRanges();
sel.addRange(range);
this.isFocused.set(true);
}
private focusSibling(delta: number): void {
const blocks = this.documentService.blocks();
const idx = blocks.findIndex(b => b.id === this.block.id);
const next = blocks[idx + delta];
if (!next) return;
this.selectionService.setActive(next.id);
setTimeout(() => {
const nextEl = document.querySelector(`[data-block-id="${next.id}"] [contenteditable]`) as HTMLElement | null;
nextEl?.focus();
}, 0);
}
}
@@ -0,0 +1,56 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Block, ProgressProps } from '../../../core/models/block.model';
@Component({
selector: 'app-progress-block',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="space-y-2">
<div class="flex items-center justify-between">
<input
type="text"
class="bg-transparent border-none outline-none text-sm font-medium"
[value]="props.label || ''"
(input)="onLabelInput($event)"
placeholder="Progress label..."
/>
<span class="text-sm font-semibold">{{ props.value }}%</span>
</div>
<div class="h-3 bg-border rounded-full overflow-hidden">
<div
class="h-full bg-primary transition-all duration-300"
[style.width.%]="props.value"
></div>
</div>
<input
type="range"
class="range range-sm"
min="0"
[max]="props.max || 100"
[value]="props.value"
(input)="onValueChange($event)"
/>
</div>
`
})
export class ProgressBlockComponent {
@Input({ required: true }) block!: Block<ProgressProps>;
@Output() update = new EventEmitter<ProgressProps>();
get props(): ProgressProps {
return this.block.props;
}
onLabelInput(event: Event): void {
const target = event.target as HTMLInputElement;
this.update.emit({ ...this.props, label: target.value });
}
onValueChange(event: Event): void {
const target = event.target as HTMLInputElement;
this.update.emit({ ...this.props, value: parseInt(target.value, 10) });
}
}
@@ -0,0 +1,52 @@
import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Block, QuoteProps } from '../../../core/models/block.model';
@Component({
selector: 'app-quote-block',
standalone: true,
imports: [CommonModule],
template: `
<blockquote
class="rounded-md px-3 py-2 italic text-[15px] leading-6 text-neutral-100 border-l-4"
[style.border-left-color]="props.lineColor || '#3b82f6'"
>
<div
contenteditable="true"
class="focus:outline-none"
#editable
(input)="onInput($event)"
placeholder="Quote..."
></div>
@if (props.author) {
<footer class="text-xs mt-1 opacity-80">— {{ props.author }}</footer>
}
</blockquote>
`,
styles: [`
[contenteditable]:empty:before {
content: attr(placeholder);
color: var(--text-muted);
}
`]
})
export class QuoteBlockComponent implements AfterViewInit {
@Input({ required: true }) block!: Block<QuoteProps>;
@Output() update = new EventEmitter<QuoteProps>();
@ViewChild('editable') editable?: ElementRef<HTMLElement>;
get props(): QuoteProps {
return this.block.props;
}
ngAfterViewInit(): void {
if (this.editable?.nativeElement) {
this.editable.nativeElement.textContent = this.props.text || '';
}
}
onInput(event: Event): void {
const target = event.target as HTMLElement;
this.update.emit({ ...this.props, text: target.textContent || '' });
}
}
@@ -0,0 +1,104 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Block, StepsProps, StepItem } from '../../../core/models/block.model';
import { generateItemId } from '../../../core/utils/id-generator';
@Component({
selector: 'app-steps-block',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="space-y-2">
@for (step of props.steps; track step.id; let idx = $index) {
<div class="flex gap-3">
<div class="flex flex-col items-center">
<div [class]="getStepCircleClass(step)">
{{ idx + 1 }}
</div>
@if (idx < props.steps.length - 1) {
<div class="w-0.5 flex-1 bg-border my-1"></div>
}
</div>
<div class="flex-1 pb-2">
<input
type="text"
class="font-semibold text-base bg-transparent border-none outline-none w-full mb-1 leading-5"
[value]="step.title"
(input)="onTitleInput($event, step.id)"
placeholder="Step title..."
/>
<textarea
class="text-sm leading-5 text-text-muted bg-transparent border-none outline-none w-full resize-none"
[value]="step.description || ''"
(input)="onDescriptionInput($event, step.id)"
placeholder="Step description (optional)..."
rows="2"
></textarea>
<label class="flex items-center gap-1.5 mt-1">
<input
type="checkbox"
class="checkbox checkbox-sm"
[checked]="step.done || false"
(change)="onDoneChange($event, step.id)"
/>
<span class="text-xs leading-4">Mark as done</span>
</label>
</div>
</div>
}
</div>
<button type="button" class="btn btn-sm mt-2" (click)="addStep()">
+ Add step
</button>
`
})
export class StepsBlockComponent {
@Input({ required: true }) block!: Block<StepsProps>;
@Output() update = new EventEmitter<StepsProps>();
get props(): StepsProps {
return this.block.props;
}
getStepCircleClass(step: StepItem): string {
const base = 'w-6 h-6 rounded-full flex items-center justify-center font-semibold text-xs';
return step.done
? `${base} bg-primary text-white`
: `${base} bg-surface2 text-text-muted`;
}
onTitleInput(event: Event, stepId: string): void {
const target = event.target as HTMLInputElement;
const steps = this.props.steps.map(s =>
s.id === stepId ? { ...s, title: target.value } : s
);
this.update.emit({ steps });
}
onDescriptionInput(event: Event, stepId: string): void {
const target = event.target as HTMLTextAreaElement;
const steps = this.props.steps.map(s =>
s.id === stepId ? { ...s, description: target.value } : s
);
this.update.emit({ steps });
}
onDoneChange(event: Event, stepId: string): void {
const target = event.target as HTMLInputElement;
const steps = this.props.steps.map(s =>
s.id === stepId ? { ...s, done: target.checked } : s
);
this.update.emit({ steps });
}
addStep(): void {
const newStep: StepItem = {
id: generateItemId(),
title: '',
description: '',
done: false
};
this.update.emit({ steps: [...this.props.steps, newStep] });
}
}
@@ -0,0 +1,99 @@
import { Component, Input, Output, EventEmitter, effect, signal, WritableSignal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Block, TableProps } from '../../../core/models/block.model';
import { TableEditorComponent } from '../../../../features/editor/blocks/table/table-editor.component';
import { TableState, TableColumn as NewTableColumn, TableRow as NewTableRow, TableCell as NewTableCell } from '../../../../features/editor/blocks/table/types';
@Component({
selector: 'app-table-block',
standalone: true,
imports: [CommonModule, TableEditorComponent],
template: `
<div [class]="getTableContainerClass()">
<app-table-editor [blockId]="block?.id" [state]="state()" (stateChange)="onStateChange($event)"></app-table-editor>
@if (block?.props?.caption) {
<div class="table-caption text-center text-sm text-text-muted dark:text-neutral-500 italic mt-2 px-3">
{{ block.props.caption }}
</div>
}
</div>
`,
styles: [`
.table-layout-auto {
table-layout: auto;
}
.table-layout-fixed {
table-layout: fixed;
}
.table-caption {
user-select: text;
}
`]
})
export class TableBlockComponent {
@Input({ required: true }) block!: Block<TableProps>;
@Output() update = new EventEmitter<TableProps>();
// Bridge state for the new table editor
state: WritableSignal<TableState> = signal<TableState>({ columns: [], rows: [], selection: null, activeCell: { row: 0, col: 0 }, editing: null });
constructor() {
// Keep local state synced from input block
effect(() => {
const props = this.block?.props;
if (!props) return;
this.state.set(this.propsToState(props));
});
}
private propsToState(props: TableProps): TableState {
const filter = (props.filter || '').trim().toLowerCase();
const isHeader = !!props.header;
const sourceRows = props.rows || [];
const filtered = (!filter)
? sourceRows
: sourceRows.filter((row, idx) => {
if (isHeader && idx === 0) return true; // always keep header row
return (row.cells || []).some(c => String(c?.text || '').toLowerCase().includes(filter));
});
const maxCols = Math.max(1, ...filtered.map(r => r.cells.length));
const columns: NewTableColumn[] = Array.from({ length: maxCols }, (_, i) => ({ id: `col-${i}`, name: String.fromCharCode(65 + (i % 26)), type: 'text', width: 160 }));
const rows: NewTableRow[] = filtered.map((r, ri) => ({
id: r.id,
cells: Array.from({ length: maxCols }, (_, ci) => {
const legacy = r.cells[ci];
return { id: legacy?.id ?? `cell-${ri}-${ci}`, type: 'text', value: legacy?.text ?? '', format: { align: 'left' } } as NewTableCell;
})
}));
return { columns, rows, selection: { startRow: 0, startCol: 0, endRow: 0, endCol: 0 }, activeCell: { row: 0, col: 0 }, editing: null };
}
private stateToProps(state: TableState): TableProps {
const rows = state.rows.map(r => ({
id: r.id,
cells: r.cells.map(c => ({ id: c.id, text: String(c.value ?? '') }))
}));
// Préserver le caption et le layout existants
return {
rows,
header: this.block?.props?.header || false,
caption: this.block?.props?.caption,
layout: this.block?.props?.layout,
filter: this.block?.props?.filter
};
}
onStateChange(next: TableState) {
// Update local state and emit legacy props to persist in document
this.state.set(next);
const newProps = this.stateToProps(next);
this.update.emit(newProps);
}
getTableContainerClass(): string {
const layout = this.block?.props?.layout || 'auto';
return `table-layout-${layout}`;
}
}
@@ -0,0 +1,75 @@
import { Component, Input, Output, EventEmitter, signal, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Block, ToggleProps } from '../../../core/models/block.model';
@Component({
selector: 'app-toggle-block',
standalone: true,
imports: [CommonModule],
template: `
<div class="rounded-md overflow-hidden bg-transparent">
<button
type="button"
class="w-full flex items-center gap-1.5 px-2 py-1.5 text-left bg-transparent transition-none"
(click)="toggle()"
>
<svg class="w-3.5 h-3.5 transition-transform" [class.rotate-90]="!isCollapsed()">
<path fill="currentColor" d="M9.4 12L4 6.6l1.4-1.4L9.4 9l4-3.8 1.4 1.4L9.4 12z"/>
</svg>
<div
#editable
contenteditable="true"
class="flex-1 focus:outline-none font-semibold text-sm leading-5"
(input)="onTitleInput($event)"
(click)="$event.stopPropagation()"
placeholder="Toggle title..."
></div>
</button>
@if (!isCollapsed()) {
<div class="px-3 py-2 bg-transparent">
<div class="text-sm leading-5 text-text-muted">
Nested content will be rendered here
</div>
</div>
}
</div>
`,
styles: [`
[contenteditable]:empty:before {
content: attr(placeholder);
color: var(--text-muted);
}
`]
})
export class ToggleBlockComponent implements AfterViewInit {
@Input({ required: true }) block!: Block<ToggleProps>;
@Output() update = new EventEmitter<ToggleProps>();
@ViewChild('editable') editable?: ElementRef<HTMLDivElement>;
readonly isCollapsed = signal(true);
ngOnInit(): void {
this.isCollapsed.set(this.props.collapsed ?? true);
}
get props(): ToggleProps {
return this.block.props;
}
ngAfterViewInit(): void {
if (this.editable?.nativeElement) {
this.editable.nativeElement.textContent = this.props.title || '';
}
}
toggle(): void {
const newState = !this.isCollapsed();
this.isCollapsed.set(newState);
this.update.emit({ ...this.props, collapsed: newState });
}
onTitleInput(event: Event): void {
const target = event.target as HTMLElement;
this.update.emit({ ...this.props, title: target.textContent || '' });
}
}
@@ -0,0 +1,125 @@
import { Component, EventEmitter, Input, Output, inject, signal, WritableSignal, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { CommentStoreService } from '../../services/comment-store.service';
import { Overlay, OverlayModule, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal, PortalModule } from '@angular/cdk/portal';
import { CommentActionMenuComponent } from './comment-action-menu.component';
@Component({
selector: 'app-block-comment-composer',
standalone: true,
imports: [CommonModule, FormsModule, OverlayModule, PortalModule],
template: `
<div class="bg-[#333333] border border-neutral-600 rounded-xl shadow-xl w-[420px] max-w-[95vw]">
<div class="flex items-center justify-between px-3 py-2 border-b border-neutral-700">
<div class="text-gray-200 font-medium">Comments</div>
<button class="text-gray-300" (click)="close.emit()">
<svg viewBox="0 0 24 24" class="w-5 h-5"><path fill="currentColor" d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="relative z-20 max-h-[300px] overflow-auto px-3 py-2 space-y-3">
<div *ngFor="let c of comments()" class="space-y-1 relative">
<div class="flex items-start gap-3">
<div class="w-8 h-8 rounded-full overflow-hidden bg-neutral-600"></div>
<div class="flex-1">
<div class="flex items-center justify-between text-sm">
<div class="text-gray-200 font-semibold">{{ c.author || 'User' }}</div>
<div class="flex items-center gap-2">
<div class="text-gray-400">{{ c.createdAt | date:'shortTime' }}</div>
<button class="text-gray-400" (click)="openCommentMenu($event, c)">
<svg viewBox="0 0 24 24" class="w-5 h-5"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
</button>
</div>
</div>
<ng-container *ngIf="editingId !== c.id; else editTpl">
<div class="text-gray-200 whitespace-pre-wrap">{{ c.text }}</div>
</ng-container>
<ng-template #editTpl>
<div class="space-y-2">
<input class="nimbus-input w-full" [(ngModel)]="editText" autofocus />
<div class="flex justify-end gap-2">
<button class="px-3 py-1 rounded bg-neutral-700" (click)="cancelEdit()">Cancel</button>
<button class="px-3 py-1 rounded bg-sky-600 text-white" (click)="saveEdit(c.id)">Save</button>
</div>
</div>
</ng-template>
<!-- Reply preview inside item (optional) could go here -->
</div>
</div>
<div class="h-px bg-neutral-700"></div>
<!-- Action menu now rendered via CDK Overlay -->
</div>
<div *ngIf="!comments().length" class="text-sm text-gray-400">No comments yet.</div>
</div>
<div class="relative z-10 px-3 py-2 border-t border-neutral-700" (click)="menuForId = null">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full overflow-hidden bg-neutral-600"></div>
<input type="text" class="flex-1 nimbus-input" placeholder="Add a comment" [(ngModel)]="text" (keydown.enter)="send()" />
<button class="px-2 py-1 rounded-md bg-sky-500 text-white disabled:opacity-50" [disabled]="!text" (click)="send()">
<svg class="w-5 h-5" viewBox="0 0 24 24"><path fill="currentColor" d="M3 13l17-9-7 18-2-7z"/></svg>
</button>
</div>
</div>
</div>
`
})
export class BlockCommentComposerComponent implements OnDestroy {
@Input({ required: true }) blockId!: string;
@Output() close = new EventEmitter<void>();
private store = inject(CommentStoreService);
text = '';
comments: WritableSignal<any[]> = signal<any[]>([]);
menuForId: string | null = null;
editingId: string | null = null;
editText = '';
replyToId: string | null = null;
private overlaySvc = inject(Overlay);
private actionMenuRef?: OverlayRef;
ngOnInit() { this.refresh(); }
refresh() {
if (!this.blockId) return;
this.comments.set(this.store.list(this.blockId));
}
send() {
const t = (this.text || '').trim();
if (!t || !this.blockId) return;
this.store.add(this.blockId, { author: 'You', text: t, target: { type: 'block' }, replyToId: this.replyToId || undefined });
this.text = '';
this.replyToId = null;
this.refresh();
}
openCommentMenu(ev: MouseEvent, c: any) {
ev.stopPropagation();
this.closeActionMenu();
const anchor = ev.currentTarget as HTMLElement;
const pos = this.overlaySvc.position().flexibleConnectedTo(anchor).withPositions([
{ originX: 'end', originY: 'center', overlayX: 'start', overlayY: 'center', offsetX: 8 },
{ originX: 'start', originY: 'center', overlayX: 'end', overlayY: 'center', offsetX: -8 },
{ originX: 'center', originY: 'bottom', overlayX: 'center', overlayY: 'top', offsetY: 8 },
{ originX: 'center', originY: 'top', overlayX: 'center', overlayY: 'bottom', offsetY: -8 },
]);
this.actionMenuRef = this.overlaySvc.create({ hasBackdrop: true, backdropClass: 'cdk-overlay-transparent-backdrop', positionStrategy: pos, panelClass: 'nimbus-menu-panel' });
const portal = new ComponentPortal(CommentActionMenuComponent);
const ref: any = this.actionMenuRef.attach(portal);
ref.instance.context = { id: c.id, author: c.author, text: c.text };
const sub1 = ref.instance.reply.subscribe(() => this.onReply(c));
const sub2 = ref.instance.edit.subscribe(() => this.onStartEdit(c));
const sub3 = ref.instance.remove.subscribe(() => this.onDelete(c));
const close = () => { try { sub1.unsubscribe(); sub2.unsubscribe(); sub3.unsubscribe(); } catch {} this.closeActionMenu(); };
this.actionMenuRef.backdropClick().subscribe(close);
this.actionMenuRef.keydownEvents().subscribe((e) => { if ((e as KeyboardEvent).key === 'Escape') close(); });
}
closeActionMenu() { if (this.actionMenuRef) { this.actionMenuRef.dispose(); this.actionMenuRef = undefined; } }
ngOnDestroy(): void { this.closeActionMenu(); }
onStartEdit(c: any) { this.menuForId = null; this.editingId = c.id; this.editText = c.text; }
cancelEdit() { this.editingId = null; this.editText = ''; }
saveEdit(id: string) { if (!id || !this.blockId) return; this.store.update(this.blockId, id, this.editText || ''); this.cancelEdit(); this.refresh(); }
onDelete(c: any) { this.menuForId = null; if (!this.blockId) return; this.store.remove(this.blockId, c.id); this.refresh(); }
onReply(c: any) { this.menuForId = null; this.replyToId = c.id; }
}
@@ -0,0 +1,36 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
export interface CommentMenuItem {
id: string;
author?: string;
text?: string;
}
@Component({
selector: 'app-comment-action-menu',
standalone: true,
imports: [CommonModule],
template: `
<div class="w-36 bg-neutral-800 border border-neutral-700 rounded-lg py-1 shadow-xl">
<button class="w-full text-left px-3 py-1.5 hover:bg-neutral-700 flex items-center gap-2" (click)="reply.emit(context)">
<svg class="w-4 h-4" viewBox="0 0 24 24"><path fill="currentColor" d="M10 19l-7-7 7-7v14z"/></svg>
<span>Reply</span>
</button>
<button class="w-full text-left px-3 py-1.5 hover:bg-neutral-700 flex items-center gap-2" (click)="edit.emit(context)">
<svg class="w-4 h-4" viewBox="0 0 24 24"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25z"/></svg>
<span>Edit</span>
</button>
<button class="w-full text-left px-3 py-1.5 hover:bg-neutral-700 flex items-center gap-2 text-red-300" (click)="remove.emit(context)">
<svg class="w-4 h-4" viewBox="0 0 24 24"><path fill="currentColor" d="M6 7h12l-1 13H7L6 7zm3-3h6l1 2H8l1-2z"/></svg>
<span>Delete</span>
</button>
</div>
`
})
export class CommentActionMenuComponent {
@Input() context!: CommentMenuItem;
@Output() reply = new EventEmitter<CommentMenuItem>();
@Output() edit = new EventEmitter<CommentMenuItem>();
@Output() remove = new EventEmitter<CommentMenuItem>();
}
@@ -0,0 +1,282 @@
import { Component, Input, Output, EventEmitter, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { CommentService, Comment } from '../../services/comment.service';
@Component({
selector: 'app-comments-panel',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
@if (isOpen()) {
<div class="fixed inset-0 z-[9998] bg-black/50 flex items-center justify-center" (click)="close()">
<div
class="bg-gray-800 rounded-lg shadow-2xl w-[500px] max-h-[600px] flex flex-col border border-gray-700"
(click)="$event.stopPropagation()"
>
<!-- Header -->
<div class="px-4 py-3 border-b border-gray-700 flex items-center justify-between">
<h3 class="text-lg font-semibold text-white flex items-center gap-2">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
Comments
<span class="text-sm text-gray-400">({{ comments().length }})</span>
</h3>
<button
type="button"
class="text-gray-400 hover:text-white transition-colors"
(click)="close()"
title="Close"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Comments list -->
<div class="flex-1 overflow-y-auto p-4 space-y-3">
@for (comment of comments(); track comment.id) {
<div
class="bg-gray-700/50 rounded-lg p-3 border border-gray-600/50 relative"
[class.opacity-50]="comment.resolved"
>
<div class="flex items-start justify-between mb-2">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full bg-purple-600 flex items-center justify-center text-white text-xs font-semibold">
{{ getInitials(comment.author) }}
</div>
<div>
<div class="text-sm font-medium text-white">{{ comment.author }}</div>
<div class="text-xs text-gray-400">{{ formatDate(comment.createdAt) }}</div>
</div>
</div>
<!-- Menu button (3 dots) -->
<div class="relative z-[100]">
<button
type="button"
class="text-gray-400 hover:text-white transition-colors p-1 rounded hover:bg-gray-600"
(click)="toggleCommentMenu(comment.id, $event)"
title="Options"
>
<svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
<circle cx="3" cy="8" r="1.5"/>
<circle cx="8" cy="8" r="1.5"/>
<circle cx="13" cy="8" r="1.5"/>
</svg>
</button>
<!-- Context menu -->
@if (openMenuId() === comment.id) {
<div
class="absolute right-0 top-8 bg-gray-800 rounded-lg shadow-xl border border-gray-700 py-1 z-[200] min-w-[140px]"
(click)="$event.stopPropagation()"
>
<button
type="button"
class="w-full text-left px-4 py-2 text-sm text-gray-200 hover:bg-gray-700 flex items-center gap-3 transition-colors"
(click)="replyToComment(comment.id)"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 10l-5 3 5 3V10z"/>
<path d="M20 13h-9v-3"/>
</svg>
Reply
</button>
<button
type="button"
class="w-full text-left px-4 py-2 text-sm text-gray-200 hover:bg-gray-700 flex items-center gap-3 transition-colors"
(click)="editComment(comment.id)"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 20h9"/>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
</svg>
Edit
</button>
<button
type="button"
class="w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-500/10 flex items-center gap-3 transition-colors"
(click)="deleteComment(comment.id)"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
Delete
</button>
</div>
}
</div>
</div>
<p class="text-sm text-gray-200">{{ comment.text }}</p>
@if (comment.resolved) {
<div class="mt-2 text-xs text-green-400 flex items-center gap-1">
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 6L9 17l-5-5"/>
</svg>
Resolved
</div>
}
</div>
} @empty {
<div class="text-center py-8 text-gray-400">
<svg class="w-12 h-12 mx-auto mb-2 opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<p class="text-sm">No comments yet</p>
<p class="text-xs mt-1">Add your first comment below</p>
</div>
}
</div>
<!-- Add comment form -->
<div class="p-4 border-t border-gray-700">
<div class="flex gap-2 items-center">
<div class="w-8 h-8 rounded-full bg-gray-600 flex items-center justify-center text-white text-xs font-semibold flex-shrink-0">
{{ getInitials('Current User') }}
</div>
<input
#newCommentInput
type="text"
class="flex-1 bg-gray-700/50 border border-gray-600 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Add a comment..."
[(ngModel)]="newCommentText"
(keydown.enter)="addComment()"
autofocus
/>
<button
type="button"
class="p-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
(click)="addComment()"
[disabled]="!newCommentText.trim()"
title="Send"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/>
</svg>
</button>
</div>
</div>
</div>
</div>
}
`,
styles: [`
:host {
display: contents;
}
/* Custom scrollbar */
.overflow-y-auto {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.3) transparent;
}
.overflow-y-auto::-webkit-scrollbar {
width: 6px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.3);
border-radius: 3px;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background-color: rgba(156, 163, 175, 0.5);
}
`]
})
export class CommentsPanelComponent {
private readonly commentService = inject(CommentService);
@Input() blockId: string = '';
@Output() closePanel = new EventEmitter<void>();
isOpen = signal(false);
comments = signal<Comment[]>([]);
newCommentText = '';
openMenuId = signal<string | null>(null);
open(blockId: string): void {
this.blockId = blockId;
this.isOpen.set(true);
this.loadComments();
}
close(): void {
this.isOpen.set(false);
this.newCommentText = '';
this.closePanel.emit();
}
private loadComments(): void {
const allComments = this.commentService.getCommentsForBlock(this.blockId);
this.comments.set(allComments);
}
addComment(): void {
const text = this.newCommentText.trim();
if (!text) return;
this.commentService.addComment(this.blockId, text, 'Current User');
this.newCommentText = '';
this.loadComments();
}
deleteComment(commentId: string): void {
this.commentService.deleteComment(commentId);
this.loadComments();
}
resolveComment(commentId: string): void {
this.commentService.resolveComment(commentId);
this.loadComments();
}
getInitials(name: string): string {
return name
.split(' ')
.map(n => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
formatDate(date: Date): string {
const now = new Date();
const diff = now.getTime() - new Date(date).getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return new Date(date).toLocaleDateString();
}
toggleCommentMenu(commentId: string, event: Event): void {
event.stopPropagation();
this.openMenuId.set(this.openMenuId() === commentId ? null : commentId);
}
replyToComment(commentId: string): void {
// TODO: Implement reply functionality
console.log('Reply to comment:', commentId);
this.openMenuId.set(null);
}
editComment(commentId: string): void {
// TODO: Implement edit functionality
console.log('Edit comment:', commentId);
this.openMenuId.set(null);
}
}
@@ -0,0 +1,466 @@
import { Component, inject, HostListener, ViewChild, ElementRef, AfterViewInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DocumentService } from '../../services/document.service';
import { SelectionService } from '../../services/selection.service';
import { PaletteService } from '../../services/palette.service';
import { ShortcutsService } from '../../services/shortcuts.service';
import { TocService } from '../../services/toc.service';
import { BlockHostComponent } from '../block/block-host.component';
import { BlockMenuComponent } from '../palette/block-menu.component';
import { BlockMenuAction } from '../block/block-initial-menu.component';
import { TocButtonComponent } from '../toc/toc-button.component';
import { TocPanelComponent } from '../toc/toc-panel.component';
import { UnsplashPickerComponent } from '../unsplash/unsplash-picker.component';
import { DragDropService } from '../../services/drag-drop.service';
import { PaletteItem } from '../../core/constants/palette-items';
@Component({
selector: 'app-editor-shell',
standalone: true,
imports: [CommonModule, FormsModule, BlockHostComponent, BlockMenuComponent, TocButtonComponent, TocPanelComponent, UnsplashPickerComponent],
template: `
<div class="grid h-full w-full grid-rows-[auto,1fr] grid-cols-[1fr,auto] overflow-hidden">
<div class="row-[1] col-[1/3] px-8 py-4 bg-card dark:bg-main border-b border-border" (click)="onShellClick()">
<div class="flex items-center gap-2 mb-3 text-xs text-neutral-400 dark:text-neutral-500">
<span>{{ documentService.blocks().length }} blocks</span>
<span>•</span>
<span [class]="getSaveStateClass()">
{{ getSaveStateText() }}
</span>
</div>
<div #header class="relative">
<input
type="text"
class="text-3xl font-semibold bg-transparent border-none outline-none w-full pr-12 text-main dark:text-neutral-100"
[value]="documentService.doc().title"
(input)="onTitleChange($event)"
placeholder="Untitled Document"
/>
<app-toc-button mode="header" />
</div>
</div>
<div class="row-[2] col-[1] overflow-y-auto min-h-0">
<div class="mx-auto w-full px-8 py-4 bg-card dark:bg-main relative" (click)="onShellClick()">
<div #blockList class="flex flex-col gap-1.5 relative" (dblclick)="onBlockListDoubleClick($event)">
@for (block of documentService.blocks(); track block.id; let idx = $index) {
<app-block-host
[block]="block"
[index]="idx"
[showInlineMenu]="showInitialMenu() && block.id === insertAfterBlockId()"
(inlineMenuAction)="onInitialMenuAction($event)"
/>
} @empty {
<div class="text-center py-12 text-text-muted">
<p>Empty document</p>
<p class="text-sm mt-2">Press <kbd class="kbd kbd-sm">/</kbd> to add a block</p>
</div>
}
@if (dragDrop.dragging() && dragDrop.indicator()) {
@if (dragDrop.indicator()!.mode === 'horizontal') {
<!-- Horizontal indicator for line change (Image 2) -->
<div
class="drop-indicator horizontal"
[style.top.px]="dragDrop.indicator()!.top"
[style.left.px]="dragDrop.indicator()!.left"
[style.width.px]="dragDrop.indicator()!.width"
>
<span class="arrow left"></span>
<span class="arrow right"></span>
</div>
} @else {
<!-- Vertical indicator for column change (Image 1) -->
<div
class="drop-indicator vertical"
[style.top.px]="dragDrop.indicator()!.top"
[style.left.px]="dragDrop.indicator()!.left"
[style.height.px]="dragDrop.indicator()!.height"
>
<span class="arrow top"></span>
<span class="arrow bottom"></span>
</div>
}
}
</div>
</div>
</div>
<div class="row-[2] col-[2] h-full min-h-0 overflow-hidden">
<app-toc-panel mode="container" />
</div>
</div>
<!-- Block Menu -->
<app-block-menu (itemSelected)="onPaletteItemSelected($event)" />
<!-- Unsplash Picker Modal -->
<app-unsplash-picker />
`,
styles: [`
:host {
display: block;
height: 100%;
min-height: 0; /* allow children to manage internal scrolling */
}
.drop-indicator {
position: absolute;
pointer-events: none;
z-index: 1000;
}
/* Horizontal indicator for line changes (Image 2) */
.drop-indicator.horizontal {
height: 3px;
background: rgba(56, 189, 248, 0.9);
}
.drop-indicator.horizontal .arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 0;
height: 0;
}
.drop-indicator.horizontal .arrow.left {
left: 0;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
border-right: 12px solid rgba(56, 189, 248, 0.9);
transform: translateY(-50%) translateX(-12px);
}
.drop-indicator.horizontal .arrow.right {
right: 0;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
border-left: 12px solid rgba(56, 189, 248, 0.9);
transform: translateY(-50%) translateX(12px);
}
/* Vertical indicator for column changes (Image 1) */
.drop-indicator.vertical {
width: 4px;
background: rgba(56, 189, 248, 0.95);
box-shadow: 0 0 8px rgba(56, 189, 248, 0.6);
}
.drop-indicator.vertical .arrow {
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
}
.drop-indicator.vertical .arrow.top {
top: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 12px solid rgba(56, 189, 248, 0.9);
transform: translateX(-50%) translateY(-12px);
}
.drop-indicator.vertical .arrow.bottom {
bottom: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 12px solid rgba(56, 189, 248, 0.9);
transform: translateX(-50%) translateY(12px);
}
`]
})
export class EditorShellComponent implements AfterViewInit {
readonly documentService = inject(DocumentService);
readonly selectionService = inject(SelectionService);
readonly paletteService = inject(PaletteService);
readonly shortcutsService = inject(ShortcutsService);
readonly tocService = inject(TocService);
readonly dragDrop = inject(DragDropService);
@ViewChild('blockList', { static: true }) blockListRef!: ElementRef<HTMLElement>;
// Initial menu state
showInitialMenu = signal(false);
private insertAfterBlockId = signal<string | null>(null);
ngOnInit(): void {
// Try to load from localStorage
const loaded = this.documentService.loadFromLocalStorage();
if (!loaded) {
this.documentService.createNew('Welcome to Nimbus Editor');
}
// Always start at top of page for the editor view
try { window.scrollTo({ top: 0, behavior: 'auto' }); } catch {}
}
ngAfterViewInit(): void {
if (this.blockListRef?.nativeElement) {
this.dragDrop.setContainer(this.blockListRef.nativeElement);
}
this.updateHeaderOffset();
// Update on next tick in case layout shifts
setTimeout(() => this.updateHeaderOffset(), 0);
}
@HostListener('window:resize')
onResize() { this.updateHeaderOffset(); }
private updateHeaderOffset() {
try {
// Use offsetTop to align the TOC panel exactly under the header within the container
const top = this.blockListRef?.nativeElement?.offsetTop ?? 96;
this.tocService.setHeaderOffset(Math.max(0, Math.floor(top)));
} catch { this.tocService.setHeaderOffset(96); }
}
@HostListener('document:keydown', ['$event'])
onKeyDown(event: KeyboardEvent): void {
// Ctrl+\ pour toggle TOC
if (event.ctrlKey && event.key === '\\') {
event.preventDefault();
this.tocService.toggle();
return;
}
this.shortcutsService.handleKeyDown(event);
}
onTitleChange(event: Event): void {
const target = event.target as HTMLInputElement;
this.documentService.updateTitle(target.value);
}
onShellClick(): void {
this.selectionService.clear();
// Hide initial menu if clicking outside
if (this.showInitialMenu()) {
this.showInitialMenu.set(false);
}
}
openPalette(): void {
this.paletteService.open();
}
onToolbarAction(action: string): void {
if (action === 'more') {
this.openPalette();
} else {
// Map toolbar actions to block types
const typeMap: Record<string, any> = {
'checkbox-list': { type: 'list', props: { kind: 'check', items: [] } },
'numbered-list': { type: 'list', props: { kind: 'numbered', items: [] } },
'bullet-list': { type: 'list', props: { kind: 'bullet', items: [] } },
'table': { type: 'table', props: this.documentService.getDefaultProps('table') },
'image': { type: 'image', props: this.documentService.getDefaultProps('image') },
'file': { type: 'file', props: this.documentService.getDefaultProps('file') },
'heading-2': { type: 'heading', props: { level: 2, text: '' } },
'new-page': { type: 'paragraph', props: { text: '' } }, // Placeholder
'use-ai': { type: 'paragraph', props: { text: '' } }, // Placeholder
};
const config = typeMap[action];
if (config) {
const block = this.documentService.createBlock(config.type, config.props);
this.documentService.appendBlock(block);
this.selectionService.setActive(block.id);
}
}
}
onPaletteItemSelected(item: PaletteItem): void {
// Convert list types to list-item for independent lines
let blockType = item.type;
let props = this.documentService.getDefaultProps(blockType);
if (item.type === 'list') {
// Use list-item instead of list for independent drag & drop
blockType = 'list-item' as any;
props = this.documentService.getDefaultProps(blockType);
// Set the correct kind based on palette item
if (item.id === 'checkbox-list') {
props.kind = 'check';
props.checked = false;
} else if (item.id === 'numbered-list') {
props.kind = 'numbered';
props.number = 1;
} else if (item.id === 'bullet-list') {
props.kind = 'bullet';
}
}
const block = this.documentService.createBlock(blockType, props);
this.documentService.appendBlock(block);
this.selectionService.setActive(block.id);
}
getSaveStateClass(): string {
const state = this.documentService.saveState();
switch (state) {
case 'saved': return 'text-success';
case 'saving': return 'text-warning';
case 'error': return 'text-error';
default: return '';
}
}
getSaveStateText(): string {
const state = this.documentService.saveState();
switch (state) {
case 'saved': return '✓ Saved';
case 'saving': return '⋯ Saving...';
case 'error': return '✗ Error saving';
default: return '';
}
}
onBlockListDoubleClick(event: MouseEvent): void {
// Check if double-click was on empty space (not on a block)
const target = event.target as HTMLElement;
if (target.closest('.block-wrapper')) {
// Click was on a block, ignore
return;
}
// Find which block to insert after
const blocks = this.documentService.blocks();
const blockElements = Array.from(this.blockListRef.nativeElement.querySelectorAll('.block-wrapper'));
const containerRect = this.blockListRef.nativeElement.getBoundingClientRect();
const relativeY = event.clientY - containerRect.top;
let afterBlockId: string | null = null;
for (let i = 0; i < blockElements.length; i++) {
const blockEl = blockElements[i] as HTMLElement;
const blockRect = blockEl.getBoundingClientRect();
const blockRelativeTop = blockRect.top - containerRect.top;
const blockRelativeBottom = blockRect.bottom - containerRect.top;
if (relativeY < blockRelativeTop) {
// Insert before this block (after previous block)
afterBlockId = i > 0 ? blocks[i - 1].id : null;
break;
} else if (relativeY > blockRelativeBottom && i === blockElements.length - 1) {
// Insert after last block
afterBlockId = blocks[i].id;
break;
}
}
// If no blocks, insert at beginning
if (blocks.length === 0) {
afterBlockId = null;
}
// Create an empty paragraph block immediately
const newBlock = this.documentService.createBlock('paragraph', { text: '' });
if (afterBlockId === null) {
// Insert at beginning
this.documentService.insertBlock(null, newBlock);
} else {
// Insert after specific block
this.documentService.insertBlock(afterBlockId, newBlock);
}
// Store the block ID to show inline menu
this.insertAfterBlockId.set(newBlock.id);
this.showInitialMenu.set(true);
// Select and focus new block
this.selectionService.setActive(newBlock.id);
setTimeout(() => {
const newElement = document.querySelector(`[data-block-id="${newBlock.id}"] [contenteditable]`) as HTMLElement;
if (newElement) {
newElement.focus();
}
}, 0);
}
onInitialMenuAction(action: BlockMenuAction): void {
// Hide menu immediately
this.showInitialMenu.set(false);
const blockId = this.insertAfterBlockId();
if (!blockId) return;
// If paragraph type selected, just hide menu and keep the paragraph
if (action.type === 'paragraph') {
// Focus on the paragraph
setTimeout(() => {
const element = document.querySelector(`[data-block-id="${blockId}"] [contenteditable]`) as HTMLElement;
if (element) {
element.focus();
}
}, 0);
return;
}
// If "more" selected, open full palette
if (action.type === 'more') {
this.paletteService.open();
return;
}
// Otherwise, convert the paragraph block to the selected type
let blockType: any = 'paragraph';
let props: any = { text: '' };
switch (action.type) {
case 'heading':
blockType = 'heading';
props = { level: 2, text: '' };
break;
case 'checkbox':
blockType = 'list-item';
props = { kind: 'check', text: '', checked: false };
break;
case 'list':
blockType = 'list-item';
props = { kind: 'bullet', text: '' };
break;
case 'numbered':
blockType = 'list-item';
props = { kind: 'numbered', text: '', number: 1 };
break;
case 'formula':
blockType = 'code';
props = { language: 'latex', code: '' };
break;
case 'table':
blockType = 'table';
props = this.documentService.getDefaultProps('table');
break;
case 'code':
blockType = 'code';
props = this.documentService.getDefaultProps('code');
break;
case 'image':
blockType = 'image';
props = this.documentService.getDefaultProps('image');
break;
case 'file':
blockType = 'file';
props = this.documentService.getDefaultProps('file');
break;
}
// Convert the existing block
this.documentService.updateBlock(blockId, { type: blockType, props });
// Focus on the converted block
setTimeout(() => {
const newElement = document.querySelector(`[data-block-id="${blockId}"] [contenteditable]`) as HTMLElement;
if (newElement) {
newElement.focus();
}
}, 0);
}
}
@@ -0,0 +1,226 @@
import { Component, inject, Output, EventEmitter, signal, computed, ViewChild, ElementRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { PaletteService } from '../../services/palette.service';
import { PaletteItem, PaletteCategory, getPaletteItemsByCategory } from '../../core/constants/palette-items';
@Component({
selector: 'app-block-menu',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
@if (paletteService.isOpen()) {
<div class="fixed inset-0 z-[9999] flex items-start justify-center pt-32" (click)="close()">
<div
#menuPanel
class="bg-neutral-800/98 backdrop-blur-md rounded-lg shadow-2xl border border-neutral-700 w-[520px] max-h-[600px] overflow-hidden flex flex-col"
(click)="$event.stopPropagation()"
>
<!-- Header collapsible -->
<div class="px-3 py-2 border-b border-neutral-700 flex items-center justify-between cursor-pointer hover:bg-neutral-700/50"
(click)="toggleSuggestions()">
<h3 class="text-xs font-semibold text-gray-300 uppercase tracking-wider">SUGGESTIONS</h3>
<svg
class="w-4 h-4 text-gray-400 transition-transform"
[class.rotate-180]="!showSuggestions()"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M18 15l-6-6-6 6"/>
</svg>
</div>
<!-- Search input (only show when suggestions expanded) -->
@if (showSuggestions()) {
<div class="px-3 py-2 border-b border-neutral-700 bg-neutral-800/30">
<input
#searchInput
type="text"
class="w-full bg-neutral-700 border border-neutral-600 rounded px-3 py-1.5 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-purple-500/50"
placeholder="Search blocks..."
[value]="paletteService.query()"
(input)="onSearch($event)"
(keydown)="onKeyDown($event)"
autofocus
/>
</div>
}
<!-- Scrollable content with sticky headers -->
<div class="flex-1 overflow-auto" [class.hidden]="!showSuggestions()">
@for (category of categories; track category) {
<div>
<!-- Sticky section header -->
<div class="sticky top-0 z-10 px-3 py-1.5 bg-neutral-800/95 backdrop-blur-md border-b border-neutral-700">
<h4 class="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">{{ category }}</h4>
</div>
<!-- Items in category -->
<div class="px-1 py-0.5">
@for (item of getItemsByCategory(category); track item.id; let idx = $index) {
@if (matchesQuery(item)) {
<button
type="button"
class="flex items-center gap-2 w-full px-2 py-1.5 rounded hover:bg-neutral-700/80 transition-colors text-left group"
[class.bg-purple-600/40]="isSelectedByKeyboard(item)"
[class.ring-2]="isSelectedByKeyboard(item)"
[class.ring-purple-500/50]="isSelectedByKeyboard(item)"
(click)="selectItem(item)"
(mouseenter)="setHoverItem(item)"
>
<span class="text-base flex-shrink-0 w-5 flex items-center justify-center">{{ item.icon }}</span>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-200 group-hover:text-white flex items-center gap-1.5">
{{ item.label }}
@if (isNewItem(item.id)) {
<span class="px-1 py-0.5 text-[9px] font-semibold bg-teal-600 text-white rounded">New</span>
}
</div>
</div>
@if (item.shortcut) {
<kbd class="px-1.5 py-0.5 text-[10px] font-mono bg-neutral-700 text-gray-400 rounded border border-neutral-600 flex-shrink-0">
{{ item.shortcut }}
</kbd>
}
</button>
}
}
</div>
</div>
}
</div>
</div>
</div>
}
`,
styles: [`
:host {
--ring-color: rgba(168, 85, 247, 0.5);
}
.rotate-180 {
transform: rotate(180deg);
}
/* Custom scrollbar */
.overflow-auto {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.3) transparent;
}
.overflow-auto::-webkit-scrollbar {
width: 6px;
}
.overflow-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-auto::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.3);
border-radius: 3px;
}
.overflow-auto::-webkit-scrollbar-thumb:hover {
background-color: rgba(156, 163, 175, 0.5);
}
`]
})
export class BlockMenuComponent {
readonly paletteService = inject(PaletteService);
@Output() itemSelected = new EventEmitter<PaletteItem>();
@ViewChild('menuPanel') menuPanel?: ElementRef<HTMLDivElement>;
showSuggestions = signal(true);
selectedItem = signal<PaletteItem | null>(null);
categories: PaletteCategory[] = [
'BASIC',
'ADVANCED',
'MEDIA',
'INTEGRATIONS',
'VIEW',
'TEMPLATES',
'HELPFUL LINKS'
];
newItems = ['steps', 'kanban', 'progress', 'dropdown', 'unsplash'];
toggleSuggestions(): void {
this.showSuggestions.update(v => !v);
}
getItemsByCategory(category: PaletteCategory): PaletteItem[] {
return getPaletteItemsByCategory(category);
}
matchesQuery(item: PaletteItem): boolean {
const query = this.paletteService.query().toLowerCase().trim();
if (!query) return true;
return item.label.toLowerCase().includes(query) ||
item.description.toLowerCase().includes(query) ||
item.keywords.some(k => k.includes(query));
}
isNewItem(id: string): boolean {
return this.newItems.includes(id);
}
isSelected(item: PaletteItem): boolean {
return this.selectedItem() === item;
}
isSelectedByKeyboard(item: PaletteItem): boolean {
return this.paletteService.selectedItem() === item;
}
setHoverItem(item: PaletteItem): void {
this.selectedItem.set(item);
}
onSearch(event: Event): void {
const target = event.target as HTMLInputElement;
this.paletteService.updateQuery(target.value);
}
onKeyDown(event: KeyboardEvent): void {
if (event.key === 'ArrowDown') {
event.preventDefault();
this.paletteService.selectNext();
this.scrollToSelected();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
this.paletteService.selectPrevious();
this.scrollToSelected();
} else if (event.key === 'Enter') {
event.preventDefault();
const item = this.paletteService.selectedItem();
if (item) this.selectItem(item);
} else if (event.key === 'Escape') {
event.preventDefault();
this.close();
}
}
scrollToSelected(): void {
// Scroll selected item into view
setTimeout(() => {
const selected = this.menuPanel?.nativeElement.querySelector('.ring-purple-500\\/50');
if (selected) {
selected.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}, 0);
}
selectItem(item: PaletteItem): void {
this.itemSelected.emit(item);
this.close();
}
close(): void {
this.paletteService.close();
}
}
@@ -0,0 +1,40 @@
import { Component, EventEmitter, Output, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-icon-picker',
standalone: true,
imports: [CommonModule],
template: `
<div class="icon-picker bg-surface1 dark:bg-gray-800 border border-border dark:border-gray-700 rounded-lg shadow-xl p-2 w-64">
<input type="text" class="w-full mb-2 px-2 py-1 text-sm rounded bg-transparent border border-border dark:border-gray-700"
placeholder="Search" (input)="onSearch($event)">
<div class="max-h-64 overflow-y-auto grid grid-cols-8 gap-1">
<button *ngFor="let ic of filtered()" class="p-1 rounded hover:bg-surface2 dark:hover:bg-gray-700 text-lg"
(click)="pick(ic)">{{ ic }}</button>
</div>
</div>
`,
styles: [`
:host { display: block; }
`]
})
export class IconPickerComponent {
@Output() select = new EventEmitter<string>();
private readonly all = [
'😀','😁','😂','🤣','😊','😇','🙂','😉','😍','😘','🤔','🤨','😐','😴','🤒','🤕','👍','👎','👉','👈','👆','👇','✅','⚠️','ℹ️','💡','⭐','🚀','📌','🔔','📎','📝','📦','🧠','🎯','🏷️','🏁','🔍','🛠️','⚙️','💬','📣','🧩','🎉','🔥','💥','✨','🌟','🪄'
];
query = signal('');
filtered = signal<string[]>(this.all);
onSearch(ev: Event) {
const q = (ev.target as HTMLInputElement).value.toLowerCase();
this.query.set(q);
if (!q) { this.filtered.set(this.all); return; }
this.filtered.set(this.all.filter(ic => ic.toLowerCase().includes(q)));
}
pick(ic: string) { this.select.emit(ic); }
}
@@ -0,0 +1,100 @@
import { Component, inject, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { PaletteService } from '../../services/palette.service';
import { PaletteItem } from '../../core/constants/palette-items';
@Component({
selector: 'app-slash-palette',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
@if (paletteService.isOpen()) {
<div class="fixed inset-0 z-50" (click)="close()">
<div
class="absolute bg-surface1 rounded-2xl shadow-2xl border w-[560px] max-h-96 overflow-hidden"
style="top: 30%; left: 50%; transform: translateX(-50%)"
(click)="$event.stopPropagation()"
>
<!-- Search input -->
<input
type="text"
class="input w-full border-none bg-surface2"
placeholder="Search blocks..."
[value]="paletteService.query()"
(input)="onSearch($event)"
(keydown)="onKeyDown($event)"
autofocus
/>
<!-- Results -->
<div class="max-h-72 overflow-auto p-2">
@for (item of paletteService.results(); track item.id; let idx = $index) {
<button
type="button"
[class]="getItemClass(idx)"
(click)="selectItem(item)"
(mouseenter)="paletteService.setSelectedIndex(idx)"
>
<span class="text-2xl">{{ item.icon }}</span>
<div class="flex-1 text-left">
<div class="font-medium">{{ item.label }}</div>
<div class="text-xs text-text-muted">{{ item.description }}</div>
</div>
@if (item.shortcut) {
<kbd class="kbd kbd-sm">{{ item.shortcut }}</kbd>
}
</button>
} @empty {
<div class="text-center py-8 text-text-muted">
No blocks found
</div>
}
</div>
</div>
</div>
}
`
})
export class SlashPaletteComponent {
readonly paletteService = inject(PaletteService);
@Output() itemSelected = new EventEmitter<PaletteItem>();
onSearch(event: Event): void {
const target = event.target as HTMLInputElement;
this.paletteService.updateQuery(target.value);
}
onKeyDown(event: KeyboardEvent): void {
if (event.key === 'ArrowDown') {
event.preventDefault();
this.paletteService.selectNext();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
this.paletteService.selectPrevious();
} else if (event.key === 'Enter') {
event.preventDefault();
const item = this.paletteService.getSelectedItem();
if (item) this.selectItem(item);
} else if (event.key === 'Escape') {
event.preventDefault();
this.close();
}
}
selectItem(item: PaletteItem): void {
this.itemSelected.emit(item);
this.close();
}
close(): void {
this.paletteService.close();
}
getItemClass(idx: number): string {
const base = 'flex items-center gap-3 w-full p-3 rounded-lg hover:bg-surface2 transition-colors';
return this.paletteService.selectedIndex() === idx
? `${base} bg-primary`
: base;
}
}
@@ -0,0 +1,56 @@
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TocService } from '../../services/toc.service';
import { Input } from '@angular/core';
@Component({
selector: 'app-toc-button',
standalone: true,
imports: [CommonModule],
template: `
@if (tocService.hasHeadings()) {
<button
type="button"
[class]="buttonClass"
[class.active]="tocService.isOpen()"
(click)="tocService.toggle()"
title="Toggle Table of Contents (Ctrl+\\)"
[style.top.px]="mode === 'fixed' ? tocService.headerOffset() : null"
>
<svg class="w-5 h-5 text-text dark:text-neutral-100" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
}
`,
styles: [`
.toc-toggle-button {
backdrop-filter: blur(8px);
}
.toc-toggle-button.active {
background-color: rgba(59, 130, 246, 0.1);
border-color: rgb(59, 130, 246);
}
.toc-toggle-button:hover {
transform: scale(1.05);
}
.toc-toggle-button:active {
transform: scale(0.95);
}
`]
})
export class TocButtonComponent {
readonly tocService = inject(TocService);
@Input() mode: 'fixed' | 'header' = 'fixed';
get buttonClass(): string {
const base = 'toc-toggle-button p-2.5 rounded-lg bg-surface1 dark:bg-gray-800 border border-border dark:border-gray-700 shadow-lg hover:bg-surface2 dark:hover:bg-gray-700 transition z-30';
if (this.mode === 'header') return `${base} absolute right-0 top-1`;
return `${base} fixed right-4`;
}
}
@@ -0,0 +1,287 @@
import { Component, inject, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TocService, TocItem } from '../../services/toc.service';
@Component({
selector: 'app-toc-panel',
standalone: true,
imports: [CommonModule],
template: `
<div
id="toc-panel"
role="navigation"
aria-label="Table of Contents"
[class]="panelClass"
class="shrink-0"
tabindex="0"
(keydown)="onKeydown($event)"
(keydown.escape)="tocService.close()"
[style.width.px]="tocService.isOpen() ? 280 : 0"
[attr.aria-hidden]="!tocService.isOpen() ? 'true' : null"
[class.pointer-events-none]="!tocService.isOpen()"
>
<div class="flex flex-col h-full min-h-0 w-[280px]">
<!-- Header -->
<div class="toc-header flex items-center justify-between px-4 py-3">
<h3 class="text-sm font-semibold">Table of Contents</h3>
<button
type="button"
class="toc-close-btn p-1.5 rounded transition"
(click)="tocService.close()"
title="Close Table of Contents"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- TOC Items -->
<div class="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-4 py-3">
@if (visibleTocItems().length === 0) {
<div class="toc-empty text-sm italic text-text-muted">
Aucun titre trouvé
</div>
} @else {
<div class="space-y-1.5 text-sm">
@for (item of visibleTocItems(); track item.id) {
<button
#tocItem
type="button"
class="toc-item w-full text-left px-3 py-2 rounded-lg transition"
[ngClass]="[getTocItemClass(item), tocService.activeId() === item.blockId ? 'toc-item-active' : '']"
[title]="item.text || 'Untitled'"
[attr.aria-current]="tocService.activeId() === item.blockId ? 'true' : null"
[attr.aria-expanded]="isCollapsible(item) ? (!isCollapsed(item) ? 'true' : 'false') : null"
(click)="onItemClick(item, $event)"
>
<span class="toc-text">
<span class="toc-label">{{ item.text || 'Sans titre' }}</span>
<span class="toc-level">Niveau {{ item.level }}</span>
</span>
</button>
}
</div>
}
</div>
<!-- Footer info -->
<div class="toc-footer px-4 py-2 text-xs">
{{ tocService.tocItems().length }} heading{{ tocService.tocItems().length !== 1 ? 's' : '' }}
</div>
</div>
</div>
`,
styles: [`
.toc-panel {
background: var(--toc-bg, #111827);
color: var(--toc-fg, #e5e7eb);
border-left: 1px solid var(--toc-border, rgba(148, 163, 184, 0.18));
}
.toc-header {
border-bottom: 1px solid var(--toc-border, rgba(148, 163, 184, 0.18));
color: inherit;
}
.toc-close-btn {
color: inherit;
}
.toc-close-btn:hover {
background: color-mix(in srgb, rgba(148, 163, 184, 0.15) 60%, transparent);
}
.toc-empty {
color: var(--toc-muted, rgba(148, 163, 184, 0.75));
}
.toc-footer {
border-top: 1px solid var(--toc-border, rgba(148, 163, 184, 0.18));
color: var(--toc-muted, rgba(148, 163, 184, 0.75));
}
.toc-item {
border: 1px solid transparent;
background: color-mix(in srgb, var(--toc-bg, #111827) 70%, rgba(148, 163, 184, 0.12) 30%);
color: inherit;
box-shadow: inset 0 0 0 0 transparent;
}
.toc-item:hover {
border-color: color-mix(in srgb, var(--toc-active, #6366f1) 35%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--toc-active, #6366f1) 25%, transparent);
background: color-mix(in srgb, rgba(99, 102, 241, 0.18) 40%, var(--toc-bg, #111827));
}
.toc-item:focus-visible {
outline: 2px solid var(--toc-active, #6366f1);
outline-offset: 2px;
}
.toc-item-h1 { padding-left: 0.25rem; font-weight: 600; }
.toc-item-h2 { padding-left: 1.25rem; font-weight: 500; }
.toc-item-h3 { padding-left: 2.25rem; font-weight: 500; font-size: 0.8125rem; color: var(--toc-muted, rgba(148, 163, 184, 0.75)); }
.toc-item-active {
border-color: color-mix(in srgb, var(--toc-active, #6366f1) 60%, transparent);
background: color-mix(in srgb, var(--toc-active, #6366f1) 18%, transparent);
color: var(--toc-active, #6366f1);
}
.toc-text {
display: flex;
justify-content: space-between;
gap: 0.5rem;
color: inherit;
}
.toc-level {
font-size: 0.75rem;
color: var(--toc-muted, rgba(148, 163, 184, 0.75));
}
`]
})
export class TocPanelComponent {
readonly tocService = inject(TocService);
@Input() mode: 'fixed' | 'container' = 'fixed';
private collapsed = new Set<string>();
getTocItemClass(item: TocItem): string {
switch (item.level) {
case 1: return 'toc-item-h1';
case 2: return 'toc-item-h2';
case 3: return 'toc-item-h3';
default: return 'toc-item-h3';
}
}
get panelClass(): string {
const base = 'toc-panel shadow-xl z-40';
if (this.mode === 'container') {
return `${base} h-full overflow-hidden`;
}
return `${base} fixed right-0 top-0 bottom-0 overflow-y-auto`;
}
onItemClick(item: TocItem, ev?: MouseEvent): void {
// Shift+Click on H1/H2 toggles collapse/expand
if ((ev?.shiftKey) && this.isCollapsible(item)) {
this.toggleCollapse(item);
return;
}
this.ensureExpandedFor(item);
this.tocService.scrollToHeading(item.blockId);
}
ngOnChanges(): void { this.maybeFocusFirst(); }
ngAfterViewChecked(): void { this.maybeFocusFirst(); }
private lastFocused = false;
private maybeFocusFirst() {
// When panel opens, focus first item once
if (this.tocService.isOpen() && !this.lastFocused) {
const root = (document.getElementById('toc-panel')) as HTMLElement | null;
const btn = root?.querySelector('button');
(btn as HTMLElement | null)?.focus?.();
this.lastFocused = true;
}
if (!this.tocService.isOpen() && this.lastFocused) this.lastFocused = false;
// Auto-expand ancestors for active item
this.ensureExpandedForActive();
}
onKeydown(ev: KeyboardEvent) {
const root = document.getElementById('toc-panel') as HTMLElement | null;
if (!root) return;
const items = Array.from(root.querySelectorAll('button')) as HTMLElement[];
if (!items.length) return;
const active = document.activeElement as HTMLElement | null;
let idx = Math.max(0, items.findIndex(b => b === active));
const move = (delta: number) => {
idx = (idx + delta + items.length) % items.length;
items[idx]?.focus?.();
};
switch (ev.key) {
case 'ArrowDown': move(1); ev.preventDefault(); break;
case 'ArrowUp': move(-1); ev.preventDefault(); break;
case 'Home': idx = 0; items[idx]?.focus?.(); ev.preventDefault(); break;
case 'End': idx = items.length - 1; items[idx]?.focus?.(); ev.preventDefault(); break;
case 'Enter':
case ' ': (active as HTMLButtonElement | null)?.click?.(); ev.preventDefault(); break;
case 'Tab': {
// Focus trap inside panel
const shift = ev.shiftKey;
if (shift && idx === 0) { items[items.length - 1]?.focus?.(); ev.preventDefault(); }
else if (!shift && idx === items.length - 1) { items[0]?.focus?.(); ev.preventDefault(); }
break;
}
}
}
isCollapsible(item: TocItem): boolean { return item.level === 1 || item.level === 2; }
isCollapsed(item: TocItem): boolean { return this.collapsed.has(item.blockId); }
toggleCollapse(item: TocItem): void {
if (!this.isCollapsible(item)) return;
if (this.isCollapsed(item)) this.collapsed.delete(item.blockId); else this.collapsed.add(item.blockId);
}
visibleTocItems(): TocItem[] {
const items = this.tocService.tocItems();
const out: TocItem[] = [];
let hideLevel1: string | null = null;
let hideLevel2: string | null = null;
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (it.level === 1) {
hideLevel2 = null;
hideLevel1 = this.isCollapsed(it) ? it.blockId : null;
out.push(it);
continue;
}
if (it.level === 2) {
if (hideLevel1) continue; // hidden under collapsed H1
hideLevel2 = this.isCollapsed(it) ? it.blockId : null;
out.push(it);
continue;
}
// level 3
if (hideLevel1 || hideLevel2) continue;
out.push(it);
}
return out;
}
private ensureExpandedForActive() {
const active = this.tocService.activeId();
if (!active) return;
const items = this.tocService.tocItems();
const idx = items.findIndex(it => it.blockId === active);
if (idx < 0) return;
// Expand nearest ancestors (H2 then H1 above)
for (let i = idx - 1; i >= 0; i--) {
const it = items[i];
if (it.level === 3) continue;
if (it.level === 2) { this.collapsed.delete(it.blockId); }
if (it.level === 1) { this.collapsed.delete(it.blockId); break; }
}
}
private ensureExpandedFor(item: TocItem) {
// When navigating to item, expand its ancestors
if (item.level === 3) {
const items = this.tocService.tocItems();
const idx = items.findIndex(it => it.blockId === item.blockId);
for (let i = idx - 1; i >= 0; i--) {
const it = items[i];
if (it.level === 2) this.collapsed.delete(it.blockId);
if (it.level === 1) { this.collapsed.delete(it.blockId); break; }
}
} else if (item.level === 2) {
const items = this.tocService.tocItems();
const idx = items.findIndex(it => it.blockId === item.blockId);
for (let i = idx - 1; i >= 0; i--) { const it = items[i]; if (it.level === 1) { this.collapsed.delete(it.blockId); break; } }
}
}
}
@@ -0,0 +1,166 @@
import { Component, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
export interface ToolbarAction {
type: 'use-ai' | 'checkbox-list' | 'numbered-list' | 'bullet-list' | 'table' | 'image' | 'file' | 'new-page' | 'heading-2' | 'more';
label: string;
}
@Component({
selector: 'app-editor-toolbar',
standalone: true,
imports: [CommonModule],
template: `
<div class="group relative flex items-center gap-2 px-4 py-3 bg-neutral-800/30 border border-neutral-700 rounded-lg hover:border-neutral-600 transition-colors">
<!-- Placeholder text -->
<span class="text-gray-500 text-sm flex-1">Start writing or type '/' or '@'</span>
<!-- Quick action icons -->
<div class="flex items-center gap-1 opacity-70 group-hover:opacity-100 transition-opacity">
<!-- Use AI -->
<button
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
title="Use AI"
(click)="onAction('use-ai')"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 3l9 4.5v9L12 21l-9-4.5v-9L12 3z"/>
<path d="M12 12l9-4.5M12 12v9M12 12L3 7.5"/>
</svg>
</button>
<!-- Checkbox list -->
<button
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
title="Checkbox list"
(click)="onAction('checkbox-list')"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="5" width="6" height="6" rx="1"/>
<rect x="3" y="13" width="6" height="6" rx="1"/>
<path d="M5 8l1.5 1.5L9 7M5 16l1.5 1.5L9 15"/>
<path d="M13 7h8M13 17h8"/>
</svg>
</button>
<!-- Numbered list -->
<button
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
title="Numbered list"
(click)="onAction('numbered-list')"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 6h11M10 12h11M10 18h11"/>
<path d="M4 6h1v4M4 10h2M4 14v4h2M6 18H4M5 14h1"/>
</svg>
</button>
<!-- Bullet list -->
<button
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
title="Bullet list"
(click)="onAction('bullet-list')"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="5" cy="7" r="1" fill="currentColor"/>
<circle cx="5" cy="12" r="1" fill="currentColor"/>
<circle cx="5" cy="17" r="1" fill="currentColor"/>
<path d="M9 7h12M9 12h12M9 17h12"/>
</svg>
</button>
<!-- Table -->
<button
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
title="Table"
(click)="onAction('table')"
>
<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 9h18M3 15h18M9 3v18M15 3v18"/>
</svg>
</button>
<!-- Image -->
<button
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
title="Image"
(click)="onAction('image')"
>
<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
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
title="File"
(click)="onAction('file')"
>
<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>
<!-- New Page -->
<button
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
title="New Page"
(click)="onAction('new-page')"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z"/>
<path d="M12 11v6M9 14h6"/>
</svg>
</button>
<!-- Heading 2 -->
<button
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200 font-bold text-sm"
title="Heading 2"
(click)="onAction('heading-2')"
>
H<sub class="text-xs">M</sub>
</button>
<div class="w-px h-5 bg-neutral-600 mx-1"></div>
<!-- More items (opens menu) -->
<button
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
title="More items"
(click)="onAction('more')"
>
<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>
`,
styles: [`
:host {
display: block;
}
button {
user-select: none;
-webkit-user-select: none;
}
button:active {
transform: scale(0.95);
}
`]
})
export class EditorToolbarComponent {
@Output() action = new EventEmitter<ToolbarAction['type']>();
onAction(type: ToolbarAction['type']): void {
this.action.emit(type);
}
}
@@ -0,0 +1,94 @@
import { Component, OnDestroy, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
interface UnsplashImage {
id: string;
alt_description: string | null;
urls: { thumb: string; small: string; regular: string; full: string };
links?: { html?: string };
user?: { name?: string };
}
@Component({
selector: 'app-unsplash-picker',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div *ngIf="open()" class="fixed inset-0 z-[1000] flex items-center justify-center">
<div class="absolute inset-0 bg-black/50" (click)="close()"></div>
<div class="relative w-[min(900px,95vw)] max-h-[85vh] bg-surface1 text-main dark:text-neutral-100 rounded-xl shadow-2xl border border-border dark:border-gray-700 overflow-hidden flex flex-col">
<div class="px-4 py-3 border-b border-border dark:border-gray-700 flex items-center gap-2">
<h3 class="text-lg font-semibold flex-1">Search image</h3>
<button class="btn btn-xs" (click)="close()">Close</button>
</div>
<div class="p-3 flex items-center gap-2">
<input class="input input-bordered flex-1" placeholder="Search for an image" [(ngModel)]="query" (keyup.enter)="search()" />
<button class="btn btn-primary btn-sm" (click)="search()">Search</button>
</div>
<div class="px-3 pb-3 text-xs text-text-muted" *ngIf="error">{{ error }}</div>
<div class="px-3 pb-3 text-xs text-text-muted" *ngIf="notice">{{ notice }}</div>
<div class="p-3 pt-0 overflow-auto grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 min-h-[200px]">
<button *ngFor="let img of results" class="relative rounded-md overflow-hidden border border-border dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-primary"
title="Click to insert" (click)="select(img)">
<img [src]="img.urls.small" [alt]="img.alt_description || ''" class="block w-full h-full object-cover" />
</button>
</div>
</div>
</div>
`,
})
export class UnsplashPickerComponent implements OnDestroy {
open = signal(false);
query = '';
results: UnsplashImage[] = [];
error = '';
notice = '';
private onSelect: ((url: string) => void) | null = null;
private listener: any;
constructor() {
this.listener = (ev: CustomEvent) => {
this.onSelect = (ev.detail?.callback as (url: string) => void) || null;
this.error = '';
this.notice = '';
this.results = [];
this.query = '';
this.open.set(true);
};
window.addEventListener('nimbus-open-unsplash', this.listener as any);
}
ngOnDestroy(): void {
window.removeEventListener('nimbus-open-unsplash', this.listener as any);
}
async search(): Promise<void> {
this.error = '';
this.notice = '';
this.results = [];
const q = (this.query || '').trim();
if (!q) return;
try {
const res = await fetch(`/api/integrations/unsplash/search?q=${encodeURIComponent(q)}&perPage=24`);
if (res.status === 501) {
this.notice = 'Unsplash access key missing. Set UNSPLASH_ACCESS_KEY in server environment to enable search.';
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
this.results = Array.isArray(data?.results) ? data.results : [];
if (!this.results.length) this.notice = 'No results.';
} catch (e: any) {
this.error = 'Search failed. Please try again.';
}
}
select(img: UnsplashImage): void {
const url = img?.urls?.regular || img?.urls?.full || img?.urls?.small;
if (url && this.onSelect) this.onSelect(url);
this.close();
}
close(): void { this.open.set(false); }
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Keyboard shortcuts for Nimbus Editor
*/
export interface Shortcut {
key: string;
ctrl?: boolean;
alt?: boolean;
shift?: boolean;
meta?: boolean;
action: string;
description: string;
}
/**
* All keyboard shortcuts
*/
export const SHORTCUTS: Shortcut[] = [
// Slash palette
{ key: '/', action: 'open-palette', description: 'Open command palette' },
{ key: '/', ctrl: true, action: 'open-palette', description: 'Open command palette' },
// Headings
{ key: '1', ctrl: true, alt: true, action: 'heading-1', description: 'Insert Heading 1' },
{ key: '2', ctrl: true, alt: true, action: 'heading-2', description: 'Insert Heading 2' },
{ key: '3', ctrl: true, alt: true, action: 'heading-3', description: 'Insert Heading 3' },
// Lists
{ key: '8', ctrl: true, shift: true, action: 'bullet-list', description: 'Insert bullet list' },
{ key: '7', ctrl: true, shift: true, action: 'numbered-list', description: 'Insert numbered list' },
{ key: 'c', ctrl: true, shift: true, action: 'checkbox-list', description: 'Insert checkbox list' },
// Blocks
{ key: '6', ctrl: true, alt: true, action: 'toggle', description: 'Insert toggle block' },
{ key: 'c', ctrl: true, alt: true, action: 'code', description: 'Insert code block' },
{ key: 'y', ctrl: true, alt: true, action: 'quote', description: 'Insert quote' },
{ key: 'u', ctrl: true, alt: true, action: 'hint', description: 'Insert hint' },
{ key: '5', ctrl: true, alt: true, action: 'button', description: 'Insert button' },
// Text formatting
{ key: 'b', ctrl: true, action: 'bold', description: 'Bold text' },
{ key: 'i', ctrl: true, action: 'italic', description: 'Italic text' },
{ key: 'u', ctrl: true, action: 'underline', description: 'Underline text' },
{ key: 'k', ctrl: true, action: 'link', description: 'Insert link' },
// Block operations
{ key: 'Backspace', ctrl: true, action: 'delete-block', description: 'Delete block' },
{ key: 'ArrowUp', alt: true, action: 'move-block-up', description: 'Move block up' },
{ key: 'ArrowDown', alt: true, action: 'move-block-down', description: 'Move block down' },
{ key: 'd', ctrl: true, action: 'duplicate-block', description: 'Duplicate block' },
// List operations
{ key: 'Tab', action: 'indent', description: 'Indent list item' },
{ key: 'Tab', shift: true, action: 'dedent', description: 'Dedent list item' },
// General
{ key: 'Escape', action: 'close-overlay', description: 'Close overlay/menu' },
{ key: 's', ctrl: true, action: 'save', description: 'Save document' },
{ key: 'z', ctrl: true, action: 'undo', description: 'Undo' },
{ key: 'z', ctrl: true, shift: true, action: 'redo', description: 'Redo' },
];
/**
* Check if event matches shortcut
*/
export function matchesShortcut(event: KeyboardEvent, shortcut: Shortcut): boolean {
const key = event.key.toLowerCase();
const ctrlKey = event.ctrlKey || event.metaKey;
return (
key === shortcut.key.toLowerCase() &&
!!shortcut.ctrl === ctrlKey &&
!!shortcut.alt === event.altKey &&
!!shortcut.shift === event.shiftKey
);
}
/**
* Find shortcut by action
*/
export function findShortcutByAction(action: string): Shortcut | undefined {
return SHORTCUTS.find(s => s.action === action);
}
/**
* Format shortcut for display
*/
export function formatShortcut(shortcut: Shortcut): string {
const parts: string[] = [];
if (shortcut.ctrl) parts.push('Ctrl');
if (shortcut.alt) parts.push('Alt');
if (shortcut.shift) parts.push('Shift');
if (shortcut.meta) parts.push('Cmd');
parts.push(shortcut.key.toUpperCase());
return parts.join('+');
}
@@ -0,0 +1,467 @@
import { BlockType } from '../models/block.model';
/**
* Palette item definition
*/
export interface PaletteItem {
id: string;
type: BlockType;
category: PaletteCategory;
label: string;
description: string;
icon: string;
keywords: string[];
shortcut?: string;
}
/**
* Palette categories
*/
export type PaletteCategory = 'BASIC' | 'ADVANCED' | 'MEDIA' | 'INTEGRATIONS' | 'VIEW' | 'TEMPLATES' | 'HELPFUL LINKS';
/**
* All available palette items
*/
export const PALETTE_ITEMS: PaletteItem[] = [
// BASIC
{
id: 'heading-1',
type: 'heading',
category: 'BASIC',
label: 'Heading 1',
description: 'Big section heading',
icon: 'H1',
keywords: ['heading', 'h1', 'title', 'large'],
shortcut: 'Ctrl+Alt+1',
},
{
id: 'heading-2',
type: 'heading',
category: 'BASIC',
label: 'Heading 2',
description: 'Medium section heading',
icon: 'H2',
keywords: ['heading', 'h2', 'subtitle'],
shortcut: 'Ctrl+Alt+2',
},
{
id: 'heading-3',
type: 'heading',
category: 'BASIC',
label: 'Heading 3',
description: 'Small section heading',
icon: 'H3',
keywords: ['heading', 'h3', 'subheading'],
shortcut: 'Ctrl+Alt+3',
},
{
id: 'paragraph',
type: 'paragraph',
category: 'BASIC',
label: 'Paragraph',
description: 'Plain text block',
icon: '¶',
keywords: ['text', 'paragraph', 'p'],
},
{
id: 'bullet-list',
type: 'list',
category: 'BASIC',
label: 'Bullet List',
description: 'Simple bullet list',
icon: '•',
keywords: ['list', 'bullet', 'ul', 'unordered'],
shortcut: 'Ctrl+Shift+8',
},
{
id: 'numbered-list',
type: 'list',
category: 'BASIC',
label: 'Numbered List',
description: 'Numbered list',
icon: '1.',
keywords: ['list', 'numbered', 'ol', 'ordered'],
shortcut: 'Ctrl+Shift+7',
},
{
id: 'checkbox-list',
type: 'list',
category: 'BASIC',
label: 'Checkbox List',
description: 'To-do list with checkboxes',
icon: '☑',
keywords: ['checkbox', 'todo', 'checklist', 'task'],
shortcut: 'Ctrl+Shift+C',
},
{
id: 'toggle',
type: 'toggle',
category: 'BASIC',
label: 'Toggle Block',
description: 'Collapsible content',
icon: '▶',
keywords: ['toggle', 'collapse', 'expand', 'accordion'],
shortcut: 'Ctrl+Alt+6',
},
{
id: 'table',
type: 'table',
category: 'BASIC',
label: 'Table',
description: 'Grid of data',
icon: '⊞',
keywords: ['table', 'grid', 'cells'],
},
{
id: 'code',
type: 'code',
category: 'BASIC',
label: 'Code',
description: 'Code block with syntax highlighting',
icon: '</>',
keywords: ['code', 'programming', 'snippet'],
shortcut: 'Ctrl+Alt+C',
},
{
id: 'quote',
type: 'quote',
category: 'BASIC',
label: 'Quote',
description: 'Blockquote',
icon: '"',
keywords: ['quote', 'blockquote', 'citation'],
shortcut: 'Ctrl+Alt+Y',
},
{
id: 'line',
type: 'line',
category: 'BASIC',
label: 'Line',
description: 'Horizontal separator',
icon: '—',
keywords: ['line', 'separator', 'hr', 'divider'],
},
{
id: 'file',
type: 'file',
category: 'BASIC',
label: 'File',
description: 'Attach a file',
icon: '📎',
keywords: ['file', 'attachment', 'upload'],
},
// ADVANCED
{
id: 'steps',
type: 'steps',
category: 'ADVANCED',
label: 'Steps',
description: 'Numbered steps list',
icon: '1→2→3',
keywords: ['steps', 'tutorial', 'guide', 'process'],
},
{
id: 'kanban',
type: 'kanban',
category: 'ADVANCED',
label: 'Kanban Board',
description: 'Task board with columns',
icon: '📋',
keywords: ['kanban', 'board', 'tasks', 'workflow'],
},
{
id: 'hint',
type: 'hint',
category: 'ADVANCED',
label: 'Hint',
description: 'Callout box',
icon: '💡',
keywords: ['hint', 'callout', 'tip', 'note'],
shortcut: 'Ctrl+Alt+U',
},
{
id: 'progress',
type: 'progress',
category: 'ADVANCED',
label: 'Progress',
description: 'Progress bar',
icon: '━━━',
keywords: ['progress', 'bar', 'percentage'],
},
{
id: 'dropdown',
type: 'dropdown',
category: 'ADVANCED',
label: 'Dropdown',
description: 'Collapsible dropdown list',
icon: '▼',
keywords: ['dropdown', 'select', 'menu'],
},
{
id: 'button',
type: 'button',
category: 'ADVANCED',
label: 'Button',
description: 'Interactive button with link',
icon: '🔘',
keywords: ['button', 'link', 'cta'],
shortcut: 'Ctrl+Alt+5',
},
{
id: 'outline',
type: 'outline',
category: 'ADVANCED',
label: 'Outline',
description: 'Auto-generated table of contents',
icon: '📑',
keywords: ['outline', 'toc', 'contents', 'navigation'],
},
// MEDIA
{
id: 'image',
type: 'image',
category: 'MEDIA',
label: 'Image',
description: 'Upload or embed an image',
icon: '🖼️',
keywords: ['image', 'picture', 'photo', 'img'],
},
{
id: 'embed',
type: 'embed',
category: 'MEDIA',
label: 'Embed',
description: 'Embed external content',
icon: '🔗',
keywords: ['embed', 'iframe', 'external', 'integration'],
},
// INTEGRATIONS
{
id: 'embed-youtube',
type: 'embed',
category: 'INTEGRATIONS',
label: 'YouTube',
description: 'Embed YouTube video',
icon: '▶️',
keywords: ['youtube', 'video', 'embed'],
},
{
id: 'embed-gdrive',
type: 'embed',
category: 'INTEGRATIONS',
label: 'Google Drive',
description: 'Embed Google Drive file',
icon: '💾',
keywords: ['google', 'drive', 'docs', 'sheets'],
},
{
id: 'embed-maps',
type: 'embed',
category: 'INTEGRATIONS',
label: 'Google Maps',
description: 'Embed Google Maps',
icon: '🗺️',
keywords: ['google', 'maps', 'location'],
},
{
id: 'link',
type: 'link',
category: 'BASIC',
label: 'Link',
description: 'Add a hyperlink',
icon: '🔗',
keywords: ['link', 'url', 'hyperlink'],
shortcut: 'Ctrl+K',
},
{
id: 'audio-record',
type: 'audio',
category: 'MEDIA',
label: 'Audio Record',
description: 'Record or upload audio',
icon: '🎤',
keywords: ['audio', 'record', 'voice', 'sound'],
shortcut: 'Ctrl+Alt+8',
},
{
id: 'video-record',
type: 'video',
category: 'MEDIA',
label: 'Video Record',
description: 'Record or upload video',
icon: '🎥',
keywords: ['video', 'record', 'camera'],
shortcut: 'Ctrl+Alt+9',
},
{
id: 'bookmark',
type: 'bookmark',
category: 'MEDIA',
label: 'Bookmark',
description: 'Save a web bookmark',
icon: '🔖',
keywords: ['bookmark', 'save', 'link'],
shortcut: 'Ctrl+Alt+B',
},
{
id: 'unsplash',
type: 'unsplash',
category: 'MEDIA',
label: 'Unsplash',
description: 'Search and insert free photos',
icon: '📷',
keywords: ['unsplash', 'photo', 'stock', 'image'],
},
{
id: 'task-list',
type: 'task-list',
category: 'ADVANCED',
label: 'Task List',
description: 'Advanced task management',
icon: '✓',
keywords: ['task', 'todo', 'checklist'],
shortcut: 'Ctrl+Alt+D',
},
{
id: 'link-page',
type: 'link-page',
category: 'ADVANCED',
label: 'Link Page / Create',
description: 'Link to another page',
icon: '🔗',
keywords: ['link', 'page', 'reference'],
},
{
id: 'date',
type: 'date',
category: 'ADVANCED',
label: 'Date',
description: 'Insert a date',
icon: '📅',
keywords: ['date', 'calendar', 'time'],
},
{
id: 'mention',
type: 'mention',
category: 'ADVANCED',
label: 'Mention Member',
description: 'Mention a team member',
icon: '@',
keywords: ['mention', 'user', 'member', 'at'],
shortcut: '@',
},
{
id: 'collapsible-large',
type: 'collapsible',
category: 'ADVANCED',
label: 'Collapsible Large Heading',
description: 'Large collapsible section',
icon: '▼H₁',
keywords: ['collapsible', 'heading', 'large'],
},
{
id: 'collapsible-medium',
type: 'collapsible',
category: 'ADVANCED',
label: 'Collapsible Medium Heading',
description: 'Medium collapsible section',
icon: '▼Hₘ',
keywords: ['collapsible', 'heading', 'medium'],
},
{
id: 'collapsible-small',
type: 'collapsible',
category: 'ADVANCED',
label: 'Collapsible Small Heading',
description: 'Small collapsible section',
icon: '▼Hₛ',
keywords: ['collapsible', 'heading', 'small'],
},
{
id: '2-columns',
type: 'columns',
category: 'VIEW',
label: '2 Columns',
description: 'Two column layout',
icon: '▦',
keywords: ['columns', 'layout', 'grid'],
},
{
id: 'database',
type: 'database',
category: 'VIEW',
label: 'Database',
description: 'Structured data view',
icon: '🗄️',
keywords: ['database', 'data', 'table'],
},
{
id: 'template-marketing-strategy',
type: 'template',
category: 'TEMPLATES',
label: 'Marketing Strategy',
description: 'Marketing strategy template',
icon: '📊',
keywords: ['template', 'marketing', 'strategy'],
},
{
id: 'template-quarterly-planning',
type: 'template',
category: 'TEMPLATES',
label: 'Marketing Quarterly Planning',
description: 'Quarterly planning template',
icon: '📅',
keywords: ['template', 'quarterly', 'planning'],
},
{
id: 'template-content-plan',
type: 'template',
category: 'TEMPLATES',
label: 'Content Plan',
description: 'Content calendar template',
icon: '📝',
keywords: ['template', 'content', 'plan'],
},
{
id: 'more-templates',
type: 'template',
category: 'TEMPLATES',
label: 'More Templates',
description: 'Browse all templates',
icon: '⋯',
keywords: ['template', 'more', 'browse'],
},
{
id: 'feedback',
type: 'link',
category: 'HELPFUL LINKS',
label: 'Get Feedback',
description: 'Share feedback with us',
icon: '💬',
keywords: ['feedback', 'help', 'support'],
},
];
/**
* Get items by category
*/
export function getPaletteItemsByCategory(category: PaletteCategory): PaletteItem[] {
return PALETTE_ITEMS.filter(item => item.category === category);
}
/**
* Search palette items
*/
export function searchPaletteItems(query: string): PaletteItem[] {
const lowerQuery = query.toLowerCase().trim();
if (!lowerQuery) return PALETTE_ITEMS;
return PALETTE_ITEMS.filter(item =>
item.label.toLowerCase().includes(lowerQuery) ||
item.description.toLowerCase().includes(lowerQuery) ||
item.keywords.some(k => k.includes(lowerQuery))
);
}
+276
View File
@@ -0,0 +1,276 @@
/**
* Block types available in Nimbus Editor
*/
export type BlockType =
| 'paragraph'
| 'heading'
| 'list'
| 'list-item'
| 'toggle'
| 'quote'
| 'code'
| 'table'
| 'image'
| 'file'
| 'button'
| 'hint'
| 'dropdown'
| 'steps'
| 'kanban'
| 'embed'
| 'outline'
| 'progress'
| 'line'
| 'link'
| 'audio'
| 'video'
| 'bookmark'
| 'unsplash'
| 'task-list'
| 'link-page'
| 'date'
| 'mention'
| 'collapsible'
| 'columns'
| 'database'
| 'template';
/**
* Generic Block structure
*/
export interface Block<T = any> {
id: string;
type: BlockType;
props: T;
children?: Block[];
meta?: BlockMeta;
}
/**
* Block metadata
*/
export interface BlockMeta {
locked?: boolean;
bgColor?: string;
indent?: number; // 0-7 indentation level
align?: 'left' | 'center' | 'right' | 'justify';
createdAt?: string;
updatedAt?: string;
}
/**
* Document model
*/
export interface DocumentModel {
id: string;
title: string;
blocks: Block[];
meta?: DocumentMeta;
}
/**
* Document metadata
*/
export interface DocumentMeta {
authors?: string[];
tags?: string[];
folders?: string[];
workspace?: string;
coverImage?: string;
createdAt?: string;
updatedAt?: string;
}
// ============================================
// Block-specific Props interfaces
// ============================================
export interface ParagraphProps {
text: string;
marks?: TextMark[];
}
export interface HeadingProps {
level: 1 | 2 | 3;
text: string;
marks?: TextMark[];
}
export interface ListProps {
kind: 'bullet' | 'numbered' | 'check';
items: ListItem[];
}
export interface ListItem {
id: string;
text: string;
checked?: boolean;
children?: ListItem[];
}
export interface ListItemProps {
kind: 'bullet' | 'numbered' | 'check';
text: string;
checked?: boolean;
number?: number; // For numbered lists
indent?: number; // Indentation level (0-7)
align?: 'left' | 'center' | 'right' | 'justify'; // Text alignment
}
export interface ToggleProps {
title: string;
content: Block[];
collapsed?: boolean;
}
export interface QuoteProps {
text: string;
author?: string;
lineColor?: string; // Couleur de la ligne verticale gauche
}
export interface CodeProps {
lang?: string;
code: string;
theme?: string; // Thème de coloration syntaxique
showLineNumbers?: boolean; // Afficher les numéros de ligne
enableWrap?: boolean; // Activer le word wrap
}
export interface TableProps {
rows: TableRow[];
header?: boolean;
caption?: string; // Caption du tableau
layout?: 'fixed' | 'auto'; // Layout du tableau
filter?: string; // Filtre simple (contient)
}
export interface TableRow {
id: string;
cells: TableCell[];
}
export interface TableCell {
id: string;
text: string;
colspan?: number;
rowspan?: number;
}
export interface ImageProps {
src: string;
alt?: string;
width?: number;
height?: number;
caption?: string; // Caption de l'image
aspectRatio?: string; // Ratio d'aspect (e.g., '16:9', '4:3', '1:1', 'free')
alignment?: 'left' | 'center' | 'right' | 'full'; // Alignement de l'image
rotation?: number; // Rotation en degrés (0, 90, 180, 270)
}
export interface FileProps {
name: string;
url: string;
size?: number;
mime?: string;
}
export interface ButtonProps {
label: string;
url: string;
variant?: 'primary' | 'secondary' | 'outline';
}
export interface HintProps {
variant?: 'info' | 'warning' | 'success' | 'note';
text: string;
borderColor?: string; // Couleur de la bordure
lineColor?: string; // Couleur de la ligne verticale
icon?: string; // Emoji/icon character
}
export interface DropdownProps {
title: string;
content: Block[];
collapsed?: boolean;
}
export interface StepsProps {
steps: StepItem[];
}
export interface StepItem {
id: string;
title: string;
description?: string;
done?: boolean;
}
export interface ProgressProps {
value: number;
label?: string;
max?: number;
}
export interface KanbanProps {
columns: KanbanColumn[];
}
export interface KanbanColumn {
id: string;
title: string;
cards: KanbanCard[];
}
export interface KanbanCard {
id: string;
title: string;
description?: string;
assignees?: string[];
dueDate?: string;
priority?: 'low' | 'medium' | 'high';
}
export interface EmbedProps {
provider?: 'youtube' | 'gdrive' | 'maps' | 'generic';
url: string;
html?: string;
width?: number;
height?: number;
sandbox?: boolean;
}
export interface OutlineProps {
headings: OutlineHeading[];
}
export interface OutlineHeading {
id: string;
level: 1 | 2 | 3;
text: string;
blockId: string;
}
export interface LineProps {
style?: 'solid' | 'dashed' | 'dotted';
}
export interface ColumnsProps {
columns: ColumnItem[];
}
export interface ColumnItem {
id: string;
blocks: Block[];
width?: number; // Percentage width (e.g., 50 for 50%)
}
/**
* Text marks for inline formatting
*/
export interface TextMark {
type: 'bold' | 'italic' | 'underline' | 'strikethrough' | 'code' | 'link';
start: number;
end: number;
attrs?: Record<string, any>;
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Generate unique block IDs
*/
export function generateId(): string {
return `block_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Generate unique card/item IDs
*/
export function generateItemId(): string {
return `item_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
@@ -0,0 +1,88 @@
import { Injectable } from '@angular/core';
export interface CodeTheme {
id: string;
name: string;
cssClass: string;
}
@Injectable({
providedIn: 'root'
})
export class CodeThemeService {
private themes: CodeTheme[] = [
{ id: 'darcula', name: 'Darcula', cssClass: 'theme-darcula' },
{ id: 'default', name: 'Default', cssClass: 'theme-default' },
{ id: 'mbo', name: 'MBO', cssClass: 'theme-mbo' },
{ id: 'mdn', name: 'MDN', cssClass: 'theme-mdn' },
{ id: 'monokai', name: 'Monokai', cssClass: 'theme-monokai' },
{ id: 'neat', name: 'Neat', cssClass: 'theme-neat' },
{ id: 'neo', name: 'NEO', cssClass: 'theme-neo' },
{ id: 'nord', name: 'Nord', cssClass: 'theme-nord' },
{ id: 'yeti', name: 'Yeti', cssClass: 'theme-yeti' },
{ id: 'yonce', name: 'Yonce', cssClass: 'theme-yonce' },
{ id: 'zenburn', name: 'Zenburn', cssClass: 'theme-zenburn' },
];
private languages = [
'javascript', 'typescript', 'python', 'java', 'csharp', 'cpp', 'c',
'go', 'rust', 'php', 'ruby', 'swift', 'kotlin', 'scala',
'html', 'css', 'scss', 'json', 'xml', 'yaml', 'markdown',
'sql', 'bash', 'shell', 'powershell', 'dockerfile',
'graphql', 'plaintext'
];
getThemes(): CodeTheme[] {
return this.themes;
}
getThemeById(id: string): CodeTheme | undefined {
return this.themes.find(t => t.id === id);
}
getThemeClass(themeId?: string): string {
const theme = themeId ? this.getThemeById(themeId) : this.themes[0];
return theme?.cssClass || 'theme-default';
}
getLanguages(): string[] {
return this.languages;
}
getLanguageDisplay(lang?: string): string {
if (!lang) return 'Plain Text';
const displayNames: Record<string, string> = {
'javascript': 'JavaScript',
'typescript': 'TypeScript',
'python': 'Python',
'java': 'Java',
'csharp': 'C#',
'cpp': 'C++',
'c': 'C',
'go': 'Go',
'rust': 'Rust',
'php': 'PHP',
'ruby': 'Ruby',
'swift': 'Swift',
'kotlin': 'Kotlin',
'scala': 'Scala',
'html': 'HTML',
'css': 'CSS',
'scss': 'SCSS',
'json': 'JSON',
'xml': 'XML',
'yaml': 'YAML',
'markdown': 'Markdown',
'sql': 'SQL',
'bash': 'Bash',
'shell': 'Shell',
'powershell': 'PowerShell',
'dockerfile': 'Dockerfile',
'graphql': 'GraphQL',
'plaintext': 'Plain Text'
};
return displayNames[lang] || lang.charAt(0).toUpperCase() + lang.slice(1);
}
}
@@ -0,0 +1,61 @@
import { Injectable, signal } from '@angular/core';
export interface CommentAttachment {
id: string;
name: string;
type: string;
size?: number;
url?: string;
}
export interface CommentItem {
id: string;
blockId: string;
author: string;
text: string;
createdAt: string;
attachments?: CommentAttachment[];
replyToId?: string;
target?: { type: 'block' } | { type: 'table-cell'; row: number; col: number };
}
@Injectable({ providedIn: 'root' })
export class CommentStoreService {
private store = signal<Record<string, CommentItem[]>>({});
list(blockId: string): CommentItem[] {
const m = this.store();
return (m[blockId] || []).slice();
}
count(blockId: string): number {
const m = this.store();
return (m[blockId] || []).length;
}
add(blockId: string, item: Omit<CommentItem, 'id' | 'blockId' | 'createdAt'> & { id?: string }): CommentItem {
const id = item.id || this.uid();
const next: CommentItem = { id, blockId, author: item.author || 'You', text: item.text, createdAt: new Date().toISOString(), attachments: item.attachments, replyToId: item.replyToId, target: item.target };
const m = { ...this.store() };
m[blockId] = [...(m[blockId] || []), next];
this.store.set(m);
return next;
}
update(blockId: string, id: string, text: string) {
const m = { ...this.store() };
m[blockId] = (m[blockId] || []).map(c => c.id === id ? { ...c, text } : c);
this.store.set(m);
}
remove(blockId: string, id: string) {
const m = { ...this.store() };
m[blockId] = (m[blockId] || []).filter(c => c.id !== id);
this.store.set(m);
}
private uid(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return (crypto as any).randomUUID();
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
}
@@ -0,0 +1,89 @@
import { Injectable, signal, computed } from '@angular/core';
export interface Comment {
id: string;
blockId: string;
author: string;
text: string;
createdAt: Date;
resolved?: boolean;
}
@Injectable({
providedIn: 'root'
})
export class CommentService {
private comments = signal<Comment[]>([]);
/**
* Get all comments for a specific block
*/
getCommentsForBlock(blockId: string): Comment[] {
return this.comments().filter(c => c.blockId === blockId);
}
/**
* Get comment count for a specific block
*/
getCommentCount(blockId: string): number {
return this.comments().filter(c => c.blockId === blockId && !c.resolved).length;
}
/**
* Add a comment to a block
*/
addComment(blockId: string, text: string, author: string = 'User'): void {
const newComment: Comment = {
id: this.generateId(),
blockId,
author,
text,
createdAt: new Date()
};
this.comments.update(comments => [...comments, newComment]);
}
/**
* Delete a comment
*/
deleteComment(commentId: string): void {
this.comments.update(comments => comments.filter(c => c.id !== commentId));
}
/**
* Mark comment as resolved
*/
resolveComment(commentId: string): void {
this.comments.update(comments =>
comments.map(c => c.id === commentId ? { ...c, resolved: true } : c)
);
}
/**
* Get all comments
*/
getAllComments(): Comment[] {
return this.comments();
}
private generateId(): string {
return Math.random().toString(36).substring(2, 11);
}
/**
* Add test comments to specific blocks (for demo purposes)
*/
addTestComments(blockIds: string[]): void {
blockIds.forEach((blockId, index) => {
// Add 1-3 random comments per block
const commentCount = Math.floor(Math.random() * 3) + 1;
for (let i = 0; i < commentCount; i++) {
this.addComment(
blockId,
`Test comment ${i + 1} for block`,
`User ${index + 1}`
);
}
});
}
}
+441
View File
@@ -0,0 +1,441 @@
import { Injectable, signal, computed, effect } from '@angular/core';
import { Block, BlockType, DocumentModel, HeadingProps, OutlineHeading } from '../core/models/block.model';
import { generateId } from '../core/utils/id-generator';
/**
* Document state management service
*/
@Injectable({
providedIn: 'root'
})
export class DocumentService {
// Signals
private readonly _doc = signal<DocumentModel>({
id: 'untitled',
title: 'Untitled Document',
blocks: []
});
private readonly _saveState = signal<'saved' | 'saving' | 'error'>('saved');
private _saveTimeout: any;
private readonly SAVE_DEBOUNCE = 750;
// Public signals
readonly doc = this._doc.asReadonly();
readonly saveState = this._saveState.asReadonly();
readonly blocks = computed(() => this._doc().blocks);
readonly outline = computed(() => this.generateOutline());
constructor() {
// Auto-save effect
effect(() => {
const snapshot = this._doc();
this.scheduleSave(snapshot);
});
}
/**
* Load document
*/
load(doc: DocumentModel): void {
this._doc.set(doc);
this._saveState.set('saved');
}
/**
* Create new document
*/
createNew(title: string = 'Untitled Document'): void {
this._doc.set({
id: generateId(),
title,
blocks: [this.createBlock('paragraph', { text: '' })],
meta: {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
});
}
/**
* Update document title
*/
updateTitle(title: string): void {
this._doc.update(doc => ({
...doc,
title,
meta: { ...doc.meta, updatedAt: new Date().toISOString() }
}));
}
/**
* Update document meta
*/
updateDocumentMeta(patch: Partial<DocumentModel['meta']>): void {
this._doc.update(doc => ({
...doc,
meta: { ...doc.meta, ...patch, updatedAt: new Date().toISOString() }
}));
}
/**
* Insert block after specified block
*/
insertBlock(afterBlockId: string | null, block: Block): void {
this._doc.update(doc => {
const blocks = [...doc.blocks];
if (afterBlockId === null) {
// Insert at beginning
blocks.unshift(block);
} else {
const index = blocks.findIndex(b => b.id === afterBlockId);
if (index >= 0) {
blocks.splice(index + 1, 0, block);
} else {
blocks.push(block);
}
}
// Renumber numbered lists after insert
const renumbered = this.renumberListItems(blocks);
return { ...doc, blocks: renumbered, meta: { ...doc.meta, updatedAt: new Date().toISOString() } };
});
}
/**
* Append block at end
*/
appendBlock(block: Block): void {
this._doc.update(doc => {
const blocks = [...doc.blocks, block];
const renumbered = this.renumberListItems(blocks);
return {
...doc,
blocks: renumbered,
meta: { ...doc.meta, updatedAt: new Date().toISOString() }
};
});
}
/**
* Update block
*/
updateBlock(id: string, patch: Partial<Block>): void {
this._doc.update(doc => {
const blocks = doc.blocks.map(b =>
b.id === id
? {
...b,
...patch,
meta: {
...b.meta,
...(patch.meta || {}),
updatedAt: new Date().toISOString()
}
}
: b
);
return { ...doc, blocks, meta: { ...doc.meta, updatedAt: new Date().toISOString() } };
});
}
/**
* Update block props
*/
updateBlockProps(id: string, props: any): void {
this._doc.update(doc => {
const blocks = doc.blocks.map(b =>
b.id === id
? { ...b, props: { ...b.props, ...props }, meta: { ...b.meta, updatedAt: new Date().toISOString() } }
: b
);
return { ...doc, blocks, meta: { ...doc.meta, updatedAt: new Date().toISOString() } };
});
}
/**
* Delete block
*/
deleteBlock(id: string): void {
this._doc.update(doc => {
const blocks = doc.blocks.filter(b => b.id !== id);
const renumbered = this.renumberListItems(blocks);
return { ...doc, blocks: renumbered, meta: { ...doc.meta, updatedAt: new Date().toISOString() } };
});
}
/**
* Move block to new index
*/
moveBlock(id: string, toIndex: number): void {
this._doc.update(doc => {
const blocks = [...doc.blocks];
const fromIndex = blocks.findIndex(b => b.id === id);
if (fromIndex < 0) return doc;
const [block] = blocks.splice(fromIndex, 1);
blocks.splice(toIndex, 0, block);
// Renumber numbered lists after move
const renumbered = this.renumberListItems(blocks);
return { ...doc, blocks: renumbered, meta: { ...doc.meta, updatedAt: new Date().toISOString() } };
});
}
/**
* Renumber all numbered list items to maintain sequential order
*/
private renumberListItems(blocks: Block[]): Block[] {
const result: Block[] = [];
let currentNumber = 1;
let inNumberedList = false;
for (const block of blocks) {
if (block.type === 'list-item' && (block.props as any).kind === 'numbered') {
// We're in a numbered list
inNumberedList = true;
result.push({
...block,
props: { ...block.props, number: currentNumber }
});
currentNumber++;
} else {
// Not a numbered list item, reset counter
if (inNumberedList) {
currentNumber = 1;
inNumberedList = false;
}
result.push(block);
}
}
return result;
}
/**
* Duplicate block
*/
duplicateBlock(id: string): void {
this._doc.update(doc => {
const blocks = [...doc.blocks];
const index = blocks.findIndex(b => b.id === id);
if (index < 0) return doc;
const original = blocks[index];
const duplicate: Block = {
...original,
id: generateId(),
meta: { ...original.meta, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
};
blocks.splice(index + 1, 0, duplicate);
return { ...doc, blocks, meta: { ...doc.meta, updatedAt: new Date().toISOString() } };
});
}
/**
* Convert block to different type
*/
convertBlock(id: string, toType: BlockType, preset?: any): void {
this._doc.update(doc => {
const blocks = doc.blocks.map(b => {
if (b.id !== id) return b;
const newProps = this.convertProps(b.type, b.props, toType, preset);
return {
...b,
type: toType,
props: newProps,
meta: { ...b.meta, updatedAt: new Date().toISOString() }
};
});
return { ...doc, blocks, meta: { ...doc.meta, updatedAt: new Date().toISOString() } };
});
}
/**
* Create block helper
*/
createBlock(type: BlockType, props: any): Block {
return {
id: generateId(),
type,
props,
meta: {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
};
}
/**
* Get block by ID
*/
getBlock(id: string): Block | undefined {
return this._doc().blocks.find(b => b.id === id);
}
/**
* Generate outline from headings
*/
private generateOutline(): OutlineHeading[] {
const headings: OutlineHeading[] = [];
const blocks = this._doc().blocks;
blocks.forEach(block => {
if (block.type === 'heading') {
const props = block.props as HeadingProps;
headings.push({
id: generateId(),
level: props.level,
text: props.text,
blockId: block.id
});
}
});
return headings;
}
/**
* Convert props when changing block type
*/
private convertProps(fromType: BlockType, fromProps: any, toType: BlockType, preset?: any): any {
// If preset provided, use it
if (preset) return { ...preset };
// Paragraph -> Heading
if (fromType === 'paragraph' && toType === 'heading') {
return { level: preset?.level || 1, text: fromProps.text || '' };
}
// Paragraph -> List
if (fromType === 'paragraph' && toType === 'list') {
return {
kind: preset?.kind || 'bullet',
items: [{ id: generateId(), text: fromProps.text || '' }]
};
}
// List conversions
if (fromType === 'list' && toType === 'list') {
return { ...fromProps, kind: preset?.kind || 'bullet' };
}
// Paragraph -> Code
if (fromType === 'paragraph' && toType === 'code') {
return { code: fromProps.text || '', lang: preset?.lang || '' };
}
// Paragraph -> Quote
if (fromType === 'paragraph' && toType === 'quote') {
return { text: fromProps.text || '' };
}
// Paragraph -> Hint
if (fromType === 'paragraph' && toType === 'hint') {
return { text: fromProps.text || '', variant: preset?.variant || 'info' };
}
// Paragraph -> Button
if (fromType === 'paragraph' && toType === 'button') {
return { label: fromProps.text || 'Button', url: '', variant: 'primary' };
}
// Paragraph -> Toggle/Dropdown
if (fromType === 'paragraph' && (toType === 'toggle' || toType === 'dropdown')) {
return { title: fromProps.text || 'Toggle', content: [], collapsed: true };
}
// Default: create empty props for target type
return this.getDefaultProps(toType);
}
/**
* Get default props for block type
*/
getDefaultProps(type: BlockType): any {
switch (type) {
case 'paragraph': return { text: '' };
case 'heading': return { level: 1, text: '' };
case 'list': return { kind: 'bullet', items: [{ id: generateId(), text: '' }] };
case 'list-item': return { kind: 'bullet', text: '', checked: false, indent: 0, align: 'left' };
case 'code': return { code: '', lang: '' };
case 'quote': return { text: '' };
case 'toggle': return { title: 'Toggle', content: [], collapsed: true };
case 'dropdown': return { title: 'Dropdown', content: [], collapsed: true };
case 'table': return { rows: [{ id: generateId(), cells: [{ id: generateId(), text: '' }] }], header: false };
case 'image': return { src: '', alt: '' };
case 'file': return { name: '', url: '' };
case 'button': return { label: 'Button', url: '', variant: 'primary' };
case 'hint': return { text: '', variant: 'info' };
case 'steps': return { steps: [{ id: generateId(), title: 'Step 1', done: false }] };
case 'progress': return { value: 0, max: 100 };
case 'kanban': return { columns: [{ id: generateId(), title: 'To Do', cards: [] }] };
case 'embed': return { url: '', provider: 'generic' };
case 'outline': return { headings: [] };
case 'line': return { style: 'solid' };
case 'columns': return {
columns: [
{ id: generateId(), blocks: [], width: 50 },
{ id: generateId(), blocks: [], width: 50 }
]
};
default: return {};
}
}
/**
* Schedule save (debounced)
*/
private scheduleSave(snapshot: DocumentModel): void {
if (this._saveTimeout) {
clearTimeout(this._saveTimeout);
}
this._saveState.set('saving');
this._saveTimeout = setTimeout(() => {
this.saveToLocalStorage(snapshot);
}, this.SAVE_DEBOUNCE);
}
/**
* Save to localStorage
*/
private saveToLocalStorage(doc: DocumentModel): void {
try {
localStorage.setItem('nimbus-editor-doc', JSON.stringify(doc));
this._saveState.set('saved');
} catch (error) {
console.error('Failed to save document:', error);
this._saveState.set('error');
}
}
/**
* Load from localStorage
*/
loadFromLocalStorage(): boolean {
try {
const stored = localStorage.getItem('nimbus-editor-doc');
if (stored) {
const doc = JSON.parse(stored);
this.load(doc);
return true;
}
} catch (error) {
console.error('Failed to load document:', error);
}
return false;
}
/**
* Clear localStorage
*/
clearLocalStorage(): void {
localStorage.removeItem('nimbus-editor-doc');
}
}
@@ -0,0 +1,165 @@
import { Injectable, signal } from '@angular/core';
interface IndicatorRect {
top: number;
left: number;
width: number;
height?: number;
mode: 'horizontal' | 'vertical'; // horizontal = change line, vertical = change column
position?: 'left' | 'right'; // for vertical mode
}
@Injectable({ providedIn: 'root' })
export class DragDropService {
readonly dragging = signal(false);
readonly sourceId = signal<string | null>(null);
readonly fromIndex = signal<number>(-1);
readonly overIndex = signal<number>(-1);
readonly indicator = signal<IndicatorRect | null>(null);
readonly dropMode = signal<'line' | 'column-left' | 'column-right'>('line');
private containerEl: HTMLElement | null = null;
private startY = 0;
private moved = false;
setContainer(el: HTMLElement) {
this.containerEl = el;
}
isMoved() {
return this.moved;
}
beginDrag(id: string, index: number, clientY: number) {
this.sourceId.set(id);
this.fromIndex.set(index);
this.dragging.set(true);
this.startY = clientY;
this.moved = false;
}
updatePointer(clientY: number, clientX?: number) {
if (!this.dragging()) return;
if (Math.abs(clientY - this.startY) > 3) this.moved = true;
this.computeOverIndex(clientY, clientX);
}
endDrag() {
const result = {
from: this.fromIndex(),
to: this.overIndex(),
mode: this.dropMode()
};
this.dragging.set(false);
this.sourceId.set(null);
this.fromIndex.set(-1);
this.overIndex.set(-1);
this.indicator.set(null);
this.dropMode.set('line');
this.startY = 0;
const moved = this.moved;
this.moved = false;
return { ...result, moved };
}
private computeOverIndex(clientY: number, clientX?: number) {
if (!this.containerEl) return;
const nodes = Array.from(this.containerEl.querySelectorAll<HTMLElement>('.block-wrapper'));
if (nodes.length === 0) return;
let targetIndex = 0;
let indicatorTop = 0;
const containerRect = this.containerEl.getBoundingClientRect();
let mode: 'horizontal' | 'vertical' = 'horizontal';
let position: 'left' | 'right' | undefined = undefined;
// Check if hovering near left or right edge of a block (for column mode)
if (clientX !== undefined) {
for (let i = 0; i < nodes.length; i++) {
const r = nodes[i].getBoundingClientRect();
const isHoveringBlock = clientY >= r.top && clientY <= r.bottom;
if (isHoveringBlock) {
const relativeX = clientX - r.left;
const edgeThreshold = 100; // pixels from edge to trigger column mode (increased for better detection)
if (relativeX < edgeThreshold) {
// Near left edge - create column on left
mode = 'vertical';
position = 'left';
targetIndex = i;
this.dropMode.set('column-left');
this.overIndex.set(targetIndex);
this.indicator.set({
top: r.top - containerRect.top,
left: r.left - containerRect.left - 2, // Offset for better visibility
width: 4,
height: r.height,
mode: 'vertical',
position: 'left'
});
return;
} else if (relativeX > r.width - edgeThreshold) {
// Near right edge - create column on right
mode = 'vertical';
position = 'right';
targetIndex = i;
this.dropMode.set('column-right');
this.overIndex.set(targetIndex);
this.indicator.set({
top: r.top - containerRect.top,
left: r.right - containerRect.left - 2, // Offset for better visibility
width: 4,
height: r.height,
mode: 'vertical',
position: 'right'
});
return;
}
}
}
}
// Default horizontal mode (line change) - improved detection
this.dropMode.set('line');
// Find which block we're hovering over or between
let found = false;
for (let i = 0; i < nodes.length; i++) {
const r = nodes[i].getBoundingClientRect();
// Define drop zones: top half = insert before, bottom half = insert after
const dropZoneHeight = r.height / 2;
const topZoneEnd = r.top + dropZoneHeight;
if (clientY <= topZoneEnd) {
// Insert BEFORE this block
targetIndex = i;
indicatorTop = r.top - containerRect.top;
found = true;
break;
} else if (clientY <= r.bottom) {
// Insert AFTER this block
targetIndex = i + 1;
indicatorTop = r.bottom - containerRect.top;
found = true;
break;
}
}
// If cursor is below all blocks, insert at end
if (!found && nodes.length > 0) {
targetIndex = nodes.length;
const lastRect = nodes[nodes.length - 1].getBoundingClientRect();
indicatorTop = lastRect.bottom - containerRect.top;
}
this.overIndex.set(targetIndex);
this.indicator.set({
top: indicatorTop,
left: 0,
width: containerRect.width,
mode: 'horizontal'
});
}
}
@@ -0,0 +1,132 @@
import { Injectable } from '@angular/core';
import { DocumentModel } from '../../core/models/block.model';
export type ExportFormat = 'md' | 'html' | 'json';
@Injectable({
providedIn: 'root'
})
export class ExportService {
/**
* Export document to specified format
*/
async export(format: ExportFormat, doc: DocumentModel): Promise<string | Blob> {
switch (format) {
case 'md': return this.exportMarkdown(doc);
case 'html': return this.exportHTML(doc);
case 'json': return this.exportJSON(doc);
default: throw new Error(`Unsupported format: ${format}`);
}
}
/**
* Download exported file
*/
download(content: string | Blob, filename: string): void {
const blob = content instanceof Blob ? content : new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
private exportMarkdown(doc: DocumentModel): string {
const lines: string[] = [];
lines.push(`# ${doc.title}\n`);
for (const block of doc.blocks) {
switch (block.type) {
case 'paragraph':
lines.push(block.props.text);
break;
case 'heading':
const level = '#'.repeat(block.props.level);
lines.push(`${level} ${block.props.text}`);
break;
case 'list':
if (block.props.kind === 'bullet') {
block.props.items.forEach((item: any) => lines.push(`- ${item.text}`));
} else if (block.props.kind === 'numbered') {
block.props.items.forEach((item: any, i: number) => lines.push(`${i + 1}. ${item.text}`));
} else {
block.props.items.forEach((item: any) =>
lines.push(`- [${item.checked ? 'x' : ' '}] ${item.text}`)
);
}
break;
case 'code':
lines.push(`\`\`\`${block.props.lang || ''}`);
lines.push(block.props.code);
lines.push('```');
break;
case 'quote':
lines.push(`> ${block.props.text}`);
break;
case 'line':
lines.push('---');
break;
}
lines.push('');
}
return lines.join('\n');
}
private exportHTML(doc: DocumentModel): string {
const body: string[] = [];
for (const block of doc.blocks) {
switch (block.type) {
case 'paragraph':
body.push(`<p>${this.escapeHtml(block.props.text)}</p>`);
break;
case 'heading':
body.push(`<h${block.props.level}>${this.escapeHtml(block.props.text)}</h${block.props.level}>`);
break;
case 'list':
if (block.props.kind === 'bullet') {
body.push('<ul>');
block.props.items.forEach((item: any) => body.push(`<li>${this.escapeHtml(item.text)}</li>`));
body.push('</ul>');
} else {
body.push('<ol>');
block.props.items.forEach((item: any) => body.push(`<li>${this.escapeHtml(item.text)}</li>`));
body.push('</ol>');
}
break;
case 'code':
body.push(`<pre><code>${this.escapeHtml(block.props.code)}</code></pre>`);
break;
}
}
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>${this.escapeHtml(doc.title)}</title>
<style>
body { font-family: system-ui; max-width: 800px; margin: 40px auto; padding: 20px; }
code, pre { background: #f5f5f5; padding: 4px 8px; border-radius: 4px; }
pre { padding: 12px; overflow-x: auto; }
</style>
</head>
<body>
<h1>${this.escapeHtml(doc.title)}</h1>
${body.join('\n')}
</body>
</html>`;
}
private exportJSON(doc: DocumentModel): string {
return JSON.stringify(doc, null, 2);
}
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
@@ -0,0 +1,112 @@
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class ImageUploadService {
private attachmentsBase = 'attachments/nimbus';
async saveFile(file: File, fileNameHint?: string): Promise<string> {
const { out, ext } = await this.ensureUploadableImage(file);
const rel = this.generateTargetPath(fileNameHint, ext);
await this.putBlob(rel, out);
return `/vault/${rel}`;
}
async saveFiles(files: FileList | File[], fileNamePrefix?: string): Promise<string[]> {
const list: File[] = Array.isArray(files) ? files : Array.from(files);
const urls: string[] = [];
for (let i = 0; i < list.length; i++) {
const file = list[i];
const hint = `${fileNamePrefix || 'image'}-${i + 1}`;
const url = await this.saveFile(file, hint);
urls.push(url);
}
return urls;
}
async saveImageUrl(url: string, fileNameHint?: string): Promise<string> {
const resp = await fetch(url, { mode: 'cors' });
if (!resp.ok) throw new Error(`Failed to download image: ${resp.status}`);
const blob = await resp.blob();
const { out, ext } = await this.ensureUploadableImage(blob);
const rel = this.generateTargetPath(fileNameHint, ext);
await this.putBlob(rel, out);
return `/vault/${rel}`;
}
private async ensureUploadableImage(blob: Blob): Promise<{ out: Blob; ext: 'png' | 'svg' }> {
const type = (blob.type || '').toLowerCase();
if (type === 'image/svg+xml' || type.endsWith('/svg')) {
return { out: blob, ext: 'svg' };
}
if (type === 'image/png') return { out: blob, ext: 'png' };
// Convert to PNG via canvas
const png = await this.convertToPng(blob);
return { out: png, ext: 'png' };
}
private async convertToPng(blob: Blob): Promise<Blob> {
// Try createImageBitmap fast-path
try {
const bmp = await (window as any).createImageBitmap?.(blob);
if (bmp) {
const canvas = document.createElement('canvas');
canvas.width = bmp.width; canvas.height = bmp.height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2D not available');
ctx.drawImage(bmp as any, 0, 0);
const dataUrl = canvas.toDataURL('image/png');
return await (await fetch(dataUrl)).blob();
}
} catch { /* fallback */ }
// Fallback via HTMLImageElement
const objectUrl = URL.createObjectURL(blob);
try {
const img = await this.loadImage(objectUrl);
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth || img.width;
canvas.height = img.naturalHeight || img.height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2D not available');
ctx.drawImage(img, 0, 0);
const dataUrl = canvas.toDataURL('image/png');
return await (await fetch(dataUrl)).blob();
} finally {
URL.revokeObjectURL(objectUrl);
}
}
private loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = (e) => reject(e);
img.src = src;
});
}
private generateTargetPath(fileNameHint: string | undefined, ext: 'png' | 'svg'): string {
const now = new Date();
const yyyy = now.getFullYear();
const mm = String(now.getMonth() + 1).padStart(2, '0');
const dd = String(now.getDate()).padStart(2, '0');
const hh = String(now.getHours()).padStart(2, '0');
const mi = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
const rand = Math.random().toString(36).slice(2, 8);
const safeHint = (fileNameHint || 'image').replace(/[^a-z0-9_-]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase();
return `${this.attachmentsBase}/${yyyy}/${mm}${dd}/img-${safeHint}-${yyyy}${mm}${dd}-${hh}${mi}${ss}-${rand}.${ext}`;
}
private async putBlob(relPath: string, blob: Blob): Promise<void> {
const res = await fetch(`/api/files/blob?path=${encodeURIComponent(relPath)}`, {
method: 'PUT',
body: blob,
});
if (!res.ok) {
const msg = await res.text().catch(() => '');
throw new Error(`Upload failed (${res.status}): ${msg}`);
}
}
}
+107
View File
@@ -0,0 +1,107 @@
import { Injectable, signal, computed } from '@angular/core';
import { PaletteItem, searchPaletteItems, PALETTE_ITEMS } from '../core/constants/palette-items';
/**
* Palette state management service
*/
@Injectable({
providedIn: 'root'
})
export class PaletteService {
// State
private readonly _isOpen = signal(false);
private readonly _query = signal('');
private readonly _selectedIndex = signal(0);
private readonly _position = signal<{ top: number; left: number } | null>(null);
private readonly _triggerBlockId = signal<string | null>(null);
// Public signals
readonly isOpen = this._isOpen.asReadonly();
readonly query = this._query.asReadonly();
readonly selectedIndex = this._selectedIndex.asReadonly();
readonly position = this._position.asReadonly();
readonly triggerBlockId = this._triggerBlockId.asReadonly();
// Computed: filtered results
readonly results = computed(() => {
const q = this._query();
return q ? searchPaletteItems(q) : PALETTE_ITEMS;
});
// Computed: selected item
readonly selectedItem = computed(() => {
const items = this.results();
const index = this._selectedIndex();
return items[index] || null;
});
/**
* Open palette
*/
open(blockId: string | null = null, position?: { top: number; left: number }): void {
this._isOpen.set(true);
this._query.set('');
this._selectedIndex.set(0);
this._triggerBlockId.set(blockId);
if (position) {
this._position.set(position);
}
}
/**
* Close palette
*/
close(): void {
this._isOpen.set(false);
this._query.set('');
this._selectedIndex.set(0);
this._position.set(null);
this._triggerBlockId.set(null);
}
/**
* Update search query
*/
updateQuery(query: string): void {
this._query.set(query);
this._selectedIndex.set(0); // Reset selection
}
/**
* Navigate selection down
*/
selectNext(): void {
const items = this.results();
const current = this._selectedIndex();
if (current < items.length - 1) {
this._selectedIndex.set(current + 1);
}
}
/**
* Navigate selection up
*/
selectPrevious(): void {
const current = this._selectedIndex();
if (current > 0) {
this._selectedIndex.set(current - 1);
}
}
/**
* Set selected index directly
*/
setSelectedIndex(index: number): void {
const items = this.results();
if (index >= 0 && index < items.length) {
this._selectedIndex.set(index);
}
}
/**
* Get currently selected item
*/
getSelectedItem(): PaletteItem | null {
return this.selectedItem();
}
}
@@ -0,0 +1,57 @@
import { Injectable, signal, computed } from '@angular/core';
/**
* Selection state management service
*/
@Injectable({
providedIn: 'root'
})
export class SelectionService {
// Active block ID
private readonly _activeBlockId = signal<string | null>(null);
// Public readonly signal
readonly activeBlockId = this._activeBlockId.asReadonly();
// Computed: is any block active?
readonly hasActiveBlock = computed(() => this._activeBlockId() !== null);
/**
* Set active block
*/
setActive(blockId: string | null): void {
this._activeBlockId.set(blockId);
}
/**
* Get active block ID
*/
getActive(): string | null {
return this._activeBlockId();
}
/**
* Clear selection
*/
clear(): void {
this._activeBlockId.set(null);
}
/**
* Toggle active block
*/
toggle(blockId: string): void {
if (this._activeBlockId() === blockId) {
this._activeBlockId.set(null);
} else {
this._activeBlockId.set(blockId);
}
}
/**
* Check if block is active
*/
isActive(blockId: string): boolean {
return this._activeBlockId() === blockId;
}
}
@@ -0,0 +1,171 @@
import { Injectable, inject } from '@angular/core';
import { DocumentService } from './document.service';
import { SelectionService } from './selection.service';
import { PaletteService } from './palette.service';
import { SHORTCUTS, matchesShortcut } from '../core/constants/keyboard';
import { BlockType } from '../core/models/block.model';
/**
* Keyboard shortcuts handler service
*/
@Injectable({
providedIn: 'root'
})
export class ShortcutsService {
private readonly documentService = inject(DocumentService);
private readonly selectionService = inject(SelectionService);
private readonly paletteService = inject(PaletteService);
/**
* Handle keyboard event
*/
handleKeyDown(event: KeyboardEvent): boolean {
// Find matching shortcut
for (const shortcut of SHORTCUTS) {
if (matchesShortcut(event, shortcut)) {
this.executeAction(shortcut.action, event);
event.preventDefault();
return true;
}
}
return false;
}
/**
* Execute shortcut action
*/
private executeAction(action: string, event: KeyboardEvent): void {
const activeBlockId = this.selectionService.getActive();
switch (action) {
// Palette
case 'open-palette':
this.paletteService.open(activeBlockId);
break;
// Headings
case 'heading-1':
this.insertOrConvertBlock('heading', { level: 1, text: '' });
break;
case 'heading-2':
this.insertOrConvertBlock('heading', { level: 2, text: '' });
break;
case 'heading-3':
this.insertOrConvertBlock('heading', { level: 3, text: '' });
break;
// Lists
case 'bullet-list':
this.insertOrConvertBlock('list', { kind: 'bullet' });
break;
case 'numbered-list':
this.insertOrConvertBlock('list', { kind: 'numbered' });
break;
case 'checkbox-list':
this.insertOrConvertBlock('list', { kind: 'check' });
break;
// Blocks
case 'toggle':
this.insertOrConvertBlock('toggle', { title: 'Toggle', content: [], collapsed: true });
break;
case 'code':
this.insertOrConvertBlock('code', { code: '', lang: '' });
break;
case 'quote':
this.insertOrConvertBlock('quote', { text: '' });
break;
case 'hint':
this.insertOrConvertBlock('hint', { text: '', variant: 'info' });
break;
case 'button':
this.insertOrConvertBlock('button', { label: 'Button', url: '', variant: 'primary' });
break;
// Block operations
case 'delete-block':
if (activeBlockId) {
this.documentService.deleteBlock(activeBlockId);
}
break;
case 'move-block-up':
if (activeBlockId) {
const blocks = this.documentService.blocks();
const index = blocks.findIndex(b => b.id === activeBlockId);
if (index > 0) {
this.documentService.moveBlock(activeBlockId, index - 1);
}
}
break;
case 'move-block-down':
if (activeBlockId) {
const blocks = this.documentService.blocks();
const index = blocks.findIndex(b => b.id === activeBlockId);
if (index >= 0 && index < blocks.length - 1) {
this.documentService.moveBlock(activeBlockId, index + 1);
}
}
break;
case 'duplicate-block':
if (activeBlockId) {
this.documentService.duplicateBlock(activeBlockId);
}
break;
// Overlay
case 'close-overlay':
if (this.paletteService.isOpen()) {
this.paletteService.close();
}
break;
// Save
case 'save':
// Save is automatic via effect
console.log('Document auto-saved');
break;
// Text formatting (handled by block components)
case 'bold':
case 'italic':
case 'underline':
case 'link':
// These are handled by individual block components
break;
default:
console.log('Unhandled action:', action);
}
}
/**
* Insert or convert block based on context
*/
private insertOrConvertBlock(type: BlockType, preset?: any): void {
const activeBlockId = this.selectionService.getActive();
if (activeBlockId) {
// Convert existing block
this.documentService.convertBlock(activeBlockId, type, preset);
} else {
// Insert new block at end
const block = this.documentService.createBlock(type, this.documentService.getDefaultProps(type));
if (preset) {
block.props = { ...block.props, ...preset };
}
// If it's a list created via shortcut, seed the first item's text for immediate visibility
if (type === 'list') {
const k = (block.props?.kind || '').toLowerCase();
const label = k === 'check' ? 'checkbox-list' : k === 'numbered' ? 'numbered-list' : 'bullet-list';
if (Array.isArray(block.props?.items) && block.props.items.length > 0) {
block.props.items = [{ ...block.props.items[0], text: label }];
}
}
this.documentService.appendBlock(block);
this.selectionService.setActive(block.id);
}
}
}
+134
View File
@@ -0,0 +1,134 @@
import { Injectable, signal, computed, effect, inject } from '@angular/core';
import { DocumentService } from './document.service';
import { Block, HeadingProps } from '../core/models/block.model';
export interface TocItem {
id: string;
level: 1 | 2 | 3;
text: string;
blockId: string;
}
@Injectable({
providedIn: 'root'
})
export class TocService {
private documentService = inject(DocumentService);
// Signal pour l'état d'ouverture du panel TOC
isOpen = signal(false);
// Signal computed pour les items de la TOC
tocItems = computed(() => {
const blocks = this.documentService.blocks();
return this.extractHeadings(blocks);
});
// Header offset to position TOC UI under the page header
headerOffset = signal(0);
setHeaderOffset(px: number) { this.headerOffset.set(Math.max(0, Math.floor(px))); }
// Computed pour savoir si le bouton TOC doit être visible
hasHeadings = computed(() => {
return this.tocItems().length > 0;
});
// Active heading tracking
activeId = signal<string | null>(null);
private observer?: IntersectionObserver;
constructor() {
// Re-observe headings whenever the list changes
effect(() => {
const items = this.tocItems();
// Defer to next tick to ensure DOM updated
setTimeout(() => this.observeHeadings(items), 0);
});
}
toggle(): void {
this.isOpen.update(v => !v);
}
open(): void {
this.isOpen.set(true);
}
close(): void {
this.isOpen.set(false);
}
scrollToHeading(blockId: string): void {
const element = document.querySelector(`[data-block-id="${blockId}"]`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Highlight temporaire
element.classList.add('toc-highlight');
setTimeout(() => {
element.classList.remove('toc-highlight');
}, 1500);
}
}
private observeHeadings(items: TocItem[]) {
try { this.observer?.disconnect(); } catch {}
if (typeof window === 'undefined' || !items?.length) return;
const options: IntersectionObserverInit = { root: null, rootMargin: '0px 0px -70% 0px', threshold: [0, 0.1, 0.5, 1] };
this.observer = new IntersectionObserver((entries) => {
// Pick the first entry that is intersecting and closest to the top
const visible = entries
.filter(e => e.isIntersecting)
.sort((a, b) => (a.boundingClientRect.top - b.boundingClientRect.top));
const pick = visible[0] || null;
if (pick) {
const id = (pick.target as HTMLElement).getAttribute('data-block-id');
if (id) this.activeId.set(id);
} else {
// If none visible, find the last heading above the viewport
const above = entries
.filter(e => e.boundingClientRect.top < 0)
.sort((a, b) => b.boundingClientRect.top - a.boundingClientRect.top)[0];
const id = above ? (above.target as HTMLElement).getAttribute('data-block-id') : null;
if (id) this.activeId.set(id);
}
}, options);
for (const it of items) {
const el = document.querySelector(`[data-block-id="${it.blockId}"]`);
if (el) this.observer.observe(el);
}
}
private extractHeadings(blocks: Block[]): TocItem[] {
const headings: TocItem[] = [];
for (const block of blocks) {
if (block.type === 'heading') {
const props = block.props as HeadingProps;
if (props.level >= 1 && props.level <= 3) {
const text = props.text && props.text.trim() ? props.text : `Heading ${props.level}`;
headings.push({ id: `toc-${block.id}`, level: props.level, text, blockId: block.id });
}
}
// Parcours des enfants réguliers
if (block.children && block.children.length > 0) {
headings.push(...this.extractHeadings(block.children));
}
// Parcours spécial: blocs colonnes (headings dans props.columns[*].blocks)
if (block.type === 'columns') {
try {
const cols = (block.props as any)?.columns || [];
for (const col of cols) {
const innerBlocks = Array.isArray(col?.blocks) ? col.blocks : [];
headings.push(...this.extractHeadings(innerBlocks));
}
} catch {}
}
}
return headings;
}
}
@@ -0,0 +1,5 @@
/* Tailwind classes primarily; extra safety styles here */
:host { display: block; }
.nimbus-menu-panel { border-radius: 0.75rem; }
.menu-item { padding: 0.5rem 0.75rem; border-radius: 0.5rem; font-size: 14px; color: rgb(229 231 235); font-weight: 500; cursor: pointer; }
.menu-item:hover { background: #444; }
@@ -0,0 +1,67 @@
<div #root class="bg-[#333333] border border-neutral-600 rounded-xl shadow-xl p-2 min-w-[220px] max-w-[320px] text-gray-200" role="menu" aria-label="Table context menu"
(mouseleave)="scheduleCloseSubmenu()" (mouseenter)="cancelCloseSubmenu()">
<div class="menu-item flex items-center gap-2" (click)="action.emit({ type: 'comment-open' })">
<svg class="w-4 h-4 text-gray-300" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4h16v12H7l-3 3V4z"/></svg>
<span>Comment</span>
</div>
<div class="menu-item flex items-center justify-between"
(mouseenter)="openSubmenuFromEvent($event, 'cell-type')"
(mouseleave)="scheduleCloseSubmenu()">
<span class="flex items-center gap-2">
<svg class="w-4 h-4 text-gray-300" viewBox="0 0 24 24" fill="currentColor"><path d="M5 4h14v2H12v14h-2V6H5z"/></svg>
<span>Cell type</span>
</span>
<svg class="w-4 h-4 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
</div>
<div class="menu-item flex items-center justify-between"
(mouseenter)="openSubmenuFromEvent($event, 'add')"
(mouseleave)="scheduleCloseSubmenu()">
<span class="flex items-center gap-2">
<svg class="w-4 h-4 text-gray-300" viewBox="0 0 24 24" fill="currentColor"><path d="M11 11V5h2v6h6v2h-6v6h-2v-6H5v-2z"/></svg>
<span>Add</span>
</span>
<svg class="w-4 h-4 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
</div>
<div class="menu-item flex items-center justify-between"
(mouseenter)="openSubmenuFromEvent($event, 'delete')"
(mouseleave)="scheduleCloseSubmenu()">
<span class="flex items-center gap-2">
<svg class="w-4 h-4 text-gray-300" viewBox="0 0 24 24" fill="currentColor"><path d="M6 7h12l-1 13H7L6 7zm3-3h6l1 2H8l1-2z"/></svg>
<span>Delete</span>
</span>
<svg class="w-4 h-4 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
</div>
<div class="menu-item flex items-center justify-between"
(mouseenter)="openSubmenuFromEvent($event, 'bg-color')"
(mouseleave)="scheduleCloseSubmenu()">
<span class="flex items-center gap-2">
<svg class="w-4 h-4 text-gray-300" viewBox="0 0 24 24" fill="currentColor"><path d="M13 5.5L19.5 12l-6.5 6.5L6.5 12 13 5.5zm7 8.5a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<span>Background color</span>
</span>
<svg class="w-4 h-4 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
</div>
<div class="menu-item flex items-center justify-between"
(mouseenter)="openSubmenuFromEvent($event, 'formatting')"
(mouseleave)="scheduleCloseSubmenu()">
<span class="flex items-center gap-2">
<svg class="w-4 h-4 text-gray-300" viewBox="0 0 24 24" fill="currentColor"><path d="M5 4h14v2H5zM9 8h6v2H9zM7 12h10v2H7zM5 16h14v2H5z"/></svg>
<span>Formatting</span>
</span>
<svg class="w-4 h-4 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
</div>
<div class="h-px bg-neutral-700 my-1"></div>
<div class="menu-item flex items-center gap-2" (click)="action.emit({ type: 'copy' })">
<svg class="w-4 h-4 text-gray-300" viewBox="0 0 24 24" fill="currentColor"><path d="M16 1H4a2 2 0 00-2 2v12h2V3h12V1zm3 4H8a2 2 0 00-2 2v14h13a2 2 0 002-2V7a2 2 0 00-2-2z"/></svg>
<span>Copy cell</span>
</div>
<div class="menu-item flex items-center gap-2" (click)="action.emit({ type: 'clear' })">
<svg class="w-4 h-4 text-gray-300" viewBox="0 0 24 24" fill="currentColor"><path d="M6 7h12l-1 13H7L6 7zm3-3h6l1 2H8l1-2z"/></svg>
<span>Clear</span>
</div>
</div>
@@ -0,0 +1,360 @@
import { CommonModule } from '@angular/common';
import { Component, DestroyRef, ElementRef, EventEmitter, HostBinding, HostListener, Input, Output, Signal, WritableSignal, ViewChild, computed, effect, inject, signal, OnDestroy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Overlay, OverlayModule, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal, PortalModule } from '@angular/cdk/portal';
import { TableCell, TableColumn, TableState, TableCellType, TableAttachment } from '../types';
import { CommentActionMenuComponent } from '../../../../../editor/components/comment/comment-action-menu.component';
@Component({
selector: 'app-table-context-menu',
standalone: true,
imports: [CommonModule, OverlayModule, PortalModule],
templateUrl: './table-context-menu.component.html',
styleUrls: ['./table-context-menu.component.css']
})
export class TableContextMenuComponent {
@Input() context!: {
row: number; col: number;
cell: TableCell;
column: TableColumn;
state: TableState;
presets?: { bg: readonly string[]; text: readonly string[] };
};
@Output() action = new EventEmitter<{ type: string; payload?: any }>();
@ViewChild('root', { static: true }) rootEl!: ElementRef<HTMLElement>;
private overlay = inject(Overlay);
private destroyRef = inject(DestroyRef);
private submenuRef?: OverlayRef;
private submenuTimer?: any;
private submenuGraceMs = 450;
openSubmenuFromEvent(ev: Event, which: string) {
const anchor = ev.currentTarget as HTMLElement | null;
if (!anchor) return;
this.cancelCloseSubmenu();
this.openSubmenu(anchor, which);
}
openSubmenu(anchor: HTMLElement, which: string) {
this.closeSubmenu();
const pos = this.overlay.position().flexibleConnectedTo(anchor).withPositions([
{ originX: 'end', originY: 'top', overlayX: 'start', overlayY: 'top', offsetX: 6 },
{ originX: 'start', originY: 'top', overlayX: 'end', overlayY: 'top', offsetX: -6 },
{ originX: 'end', originY: 'bottom', overlayX: 'start', overlayY: 'bottom', offsetX: 6 },
]);
this.submenuRef = this.overlay.create({ hasBackdrop: false, positionStrategy: pos, panelClass: 'nimbus-menu-panel' });
const portal = new ComponentPortal(TableContextSubmenuComponent);
const ref = this.submenuRef.attach(portal);
ref.instance.which = which;
ref.instance.context = this.context;
ref.instance.presets = this.context.presets;
ref.instance.action.subscribe(e => this.action.emit(e));
const hoverSub = ref.instance.hover.subscribe((inside) => {
if (inside) this.cancelCloseSubmenu(); else this.scheduleCloseSubmenu();
});
this.destroyRef.onDestroy(() => hoverSub.unsubscribe());
}
scheduleCloseSubmenu() {
this.cancelCloseSubmenu();
this.submenuTimer = setTimeout(() => this.closeSubmenu(), this.submenuGraceMs);
}
cancelCloseSubmenu() {
if (this.submenuTimer) {
clearTimeout(this.submenuTimer);
this.submenuTimer = undefined;
}
}
closeSubmenu() {
if (this.submenuRef) {
this.submenuRef.dispose();
this.submenuRef = undefined;
}
}
@HostListener('keydown', ['$event'])
onKeydown(ev: KeyboardEvent) {
if (ev.key === 'Escape') {
this.action.emit({ type: 'close' });
}
}
}
@Component({
selector: 'app-table-context-submenu',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="bg-[#333333] border border-neutral-600 rounded-xl shadow-xl p-2"
[ngClass]="which === 'comment' ? 'w-[520px] max-w-[95vw] overflow-hidden' : 'w-max max-w-[320px]'"
(mouseenter)="hover.emit(true)" (mouseleave)="hover.emit(false)" (click)="menuForId = null">
<ng-container [ngSwitch]="which">
<ng-container *ngSwitchCase="'cell-type'">
<div *ngFor="let t of cellTypes" class="px-3 py-2 text-[14px] text-gray-200 font-medium hover:bg-[#444] rounded-lg cursor-pointer flex items-center gap-3"
(click)="emit('cell-type', t)">
<!-- Selected checkmark area -->
<span class="w-4 h-4 text-gray-300 flex items-center justify-center">
<svg *ngIf="context?.cell?.type === t" viewBox="0 0 24 24" class="w-4 h-4" fill="currentColor"><path d="M9 16.2l-3.5-3.5-1.4 1.4L9 19 20 8l-1.4-1.4z"/></svg>
</span>
<!-- Type icon -->
<span class="w-5 h-5 text-gray-300 flex items-center justify-center" [ngSwitch]="t">
<svg *ngSwitchCase="'text'" viewBox="0 0 24 24" class="w-5 h-5" fill="currentColor"><path d="M5 4h14v2H12v14h-2V6H5z"/></svg>
<svg *ngSwitchCase="'number'" viewBox="0 0 24 24" class="w-5 h-5" fill="currentColor"><path d="M7 3h2l-2 18H5l2-18zm10 0h2l-2 18h-2l2-18z"/></svg>
<svg *ngSwitchCase="'currency'" viewBox="0 0 24 24" class="w-5 h-5" fill="currentColor"><path d="M12 3a5 5 0 000 10h2a3 3 0 110 6H7v2h7a5 5 0 100-10h-2a3 3 0 110-6h6V3h-6z"/></svg>
<svg *ngSwitchCase="'files'" viewBox="0 0 24 24" class="w-5 h-5" fill="currentColor"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12V8l-4-6zM13 9h5v11H6V4h7v5z"/></svg>
<svg *ngSwitchCase="'checkbox'" viewBox="0 0 24 24" class="w-5 h-5" fill="currentColor"><path d="M19 3H5a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2V5a2 2 0 00-2-2zm-9 14l-4-4 1.41-1.41L10 14.17l6.59-6.59L18 9l-8 8z"/></svg>
<svg *ngSwitchCase="'single-select'" viewBox="0 0 24 24" class="w-5 h-5" fill="currentColor"><path d="M7 10h10l-5 6z"/></svg>
<svg *ngSwitchCase="'multiple-select'" viewBox="0 0 24 24" class="w-5 h-5" fill="currentColor"><path d="M7 7h10l-5 6-5-6zm0 6h10l-5 6-5-6z"/></svg>
<svg *ngSwitchCase="'mention'" viewBox="0 0 24 24" class="w-5 h-5" fill="currentColor"><path d="M12 2a10 10 0 100 20 5 5 0 005-5V9h-2v8a3 3 0 11-3-3h3V7a8 8 0 10-3 15 10 10 0 100-20z"/></svg>
<svg *ngSwitchCase="'collaborator'" viewBox="0 0 24 24" class="w-5 h-5" fill="currentColor"><path d="M12 12a4 4 0 100-8 4 4 0 000 8zm0 2c-4 0-8 2-8 5v3h16v-3c0-3-4-5-8-5z"/></svg>
<svg *ngSwitchCase="'date'" viewBox="0 0 24 24" class="w-5 h-5" fill="currentColor"><path d="M7 2h2v2h6V2h2v2h3v18H4V4h3V2zm12 18V9H5v11h14z"/></svg>
<svg *ngSwitchCase="'link'" viewBox="0 0 24 24" class="w-5 h-5" fill="currentColor"><path d="M3.9 12a5 5 0 017.07 0l1.06 1.06-1.41 1.41L9.56 13.4a3 3 0 00-4.24 4.24l3.54 3.54a3 3 0 004.24 0l2.12-2.12 1.41 1.41-2.12 2.12a5 5 0 01-7.07 0l-3.54-3.54a5 5 0 010-7.07zM14.44 4.44a5 5 0 017.07 7.07l-3.54 3.54a5 5 0 01-7.07 0L9.83 14.1l1.41-1.41 1.06 1.06a3 3 0 004.24 0l3.54-3.54a3 3 0 10-4.24-4.24L10.3 7.64 8.9 6.22l5.54-5.78z"/></svg>
<svg *ngSwitchCase="'rating'" viewBox="0 0 24 24" class="w-5 h-5" fill="currentColor"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
<svg *ngSwitchCase="'progress'" viewBox="0 0 24 24" class="w-5 h-5" fill="currentColor"><path d="M3 12a9 9 0 109 9v-2a7 7 0 110-14V3a9 9 0 00-9 9z"/></svg>
<svg *ngSwitchDefault viewBox="0 0 24 24" class="w-5 h-5" fill="currentColor"><circle cx="12" cy="12" r="6"/></svg>
</span>
<!-- Label -->
<span class="capitalize">{{ t.replace('-', ' ') }}</span>
</div>
</ng-container>
<ng-container *ngSwitchCase="'add'">
<div class="menu-item" (click)="emit('add-row-above')">Add row above</div>
<div class="menu-item" (click)="emit('add-row-below')">Add row below</div>
<div class="menu-item" (click)="emit('add-col-left')">Add column left</div>
<div class="menu-item" (click)="emit('add-col-right')">Add column right</div>
</ng-container>
<ng-container *ngSwitchCase="'delete'">
<div class="menu-item" (click)="emit('delete-row')">Delete row</div>
<div class="menu-item" (click)="emit('delete-col')">Delete column</div>
</ng-container>
<ng-container *ngSwitchCase="'bg-color'">
<div class="grid grid-cols-10 gap-1">
<button *ngFor="let c of presets?.bg || []" class="w-6 h-6 rounded-md border border-neutral-500" [style.background]="c" (click)="emit('bg-color', c)"></button>
</div>
</ng-container>
<ng-container *ngSwitchCase="'formatting'">
<div class="menu-item" (click)="emit('format-bold')">Bold</div>
<div class="menu-item" (click)="emit('format-italic')">Italic</div>
<div class="menu-item" (click)="emit('format-underline')">Underline</div>
<div class="menu-item" (click)="emit('format-strike')">Strikethrough</div>
<div class="h-px bg-neutral-700 my-1"></div>
<div class="menu-item" (click)="emit('align-left')">Align Left</div>
<div class="menu-item" (click)="emit('align-center')">Align Center</div>
<div class="menu-item" (click)="emit('align-right')">Align Right</div>
<div class="h-px bg-neutral-700 my-1"></div>
<div class="flex items-center gap-1 flex-wrap">
<button *ngFor="let c of presets?.text || []" class="w-6 h-6 rounded-full border border-neutral-500" [style.background]="c" (click)="emit('text-color', c)"></button>
</div>
</ng-container>
<ng-container *ngSwitchCase="'comment'">
<div class="w-full">
<!-- Header -->
<div class="flex items-center justify-between px-3 py-2 border-b border-neutral-700">
<div class="flex items-center gap-3">
<button class="text-gray-300" title="Previous" (click)="emit('comment-nav-prev')">
<svg viewBox="0 0 24 24" class="w-5 h-5"><path fill="currentColor" d="M15 19l-7-7 7-7"/></svg>
</button>
<button class="text-gray-300" title="Next" (click)="emit('comment-nav-next')">
<svg viewBox="0 0 24 24" class="w-5 h-5"><path fill="currentColor" d="M9 5l7 7-7 7"/></svg>
</button>
<div class="text-gray-200 font-medium">{{ context?.column?.name || 'Comments' }}</div>
</div>
<button class="text-gray-300" title="Close" (click)="emit('close')">
<svg viewBox="0 0 24 24" class="w-5 h-5"><path fill="currentColor" d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<!-- Existing comments -->
<div class="relative z-20 max-h-[360px] overflow-auto px-3 py-2 space-y-4">
<div *ngFor="let c of (context?.cell?.comments || [])" class="space-y-1 relative">
<div class="flex items-start gap-3">
<div class="w-8 h-8 rounded-full overflow-hidden bg-neutral-600"></div>
<div class="flex-1">
<div class="flex items-center justify-between text-sm">
<div class="text-gray-200 font-semibold">{{ c.author || 'User' }}</div>
<div class="text-gray-400">{{ c.createdAt | date:'shortTime' }}</div>
</div>
<!-- Quoted reply -->
<div *ngIf="c.replyToId as rid" class="mt-2 rounded-md bg-neutral-800 p-2 text-sm text-gray-300 flex items-start gap-2">
<svg class="w-4 h-4 text-gray-400 mt-0.5" viewBox="0 0 24 24"><path fill="currentColor" d="M10 19l-7-7 7-7v14z"/></svg>
<div class="flex-1">
<div class="font-semibold text-gray-200">{{ findCommentById(rid)?.author || 'User' }}</div>
<div class="truncate">{{ findCommentById(rid)?.text || '' }}</div>
</div>
</div>
<!-- Message content or edit -->
<ng-container *ngIf="editingId !== c.id; else editTpl">
<div class="text-gray-200 whitespace-pre-wrap">{{ c.text }}</div>
</ng-container>
<ng-template #editTpl>
<div class="space-y-2">
<input class="nimbus-input w-full" [(ngModel)]="editText" autofocus />
<div class="flex justify-end gap-2">
<button class="px-3 py-1 rounded bg-neutral-700" (click)="cancelEdit()">Cancel</button>
<button class="px-3 py-1 rounded bg-sky-600 text-white" (click)="saveEdit(c.id)">Save</button>
</div>
</div>
</ng-template>
<div *ngIf="c.attachments?.length" class="mt-2 space-y-2">
<div *ngFor="let a of c.attachments" class="border border-neutral-700 rounded-lg p-2 flex items-center gap-2">
<img *ngIf="a.url && a.type?.startsWith('image/')" [src]="a.url" class="w-14 h-14 object-cover rounded" />
<svg *ngIf="!(a.url && a.type?.startsWith('image/'))" viewBox="0 0 24 24" class="w-6 h-6 text-pink-400"><path fill="currentColor" d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12V8l-4-6z"/></svg>
<div class="text-sm text-gray-200 truncate">{{ a.name }}</div>
</div>
</div>
</div>
<div class="relative">
<button class="text-gray-400" (click)="openCommentMenu($event, c)">
<svg viewBox="0 0 24 24" class="w-5 h-5"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
</button>
</div>
</div>
<div class="h-px bg-neutral-700"></div>
</div>
</div>
<!-- Composer -->
<div class="relative z-10 px-3 py-2 border-t border-neutral-700">
<!-- Reply preview -->
<div *ngIf="replyTo" class="mx-11 mb-2 rounded-md bg-neutral-800 p-2 text-sm text-gray-300 flex items-start gap-2">
<svg class="w-4 h-4 text-gray-400 mt-0.5" viewBox="0 0 24 24"><path fill="currentColor" d="M10 19l-7-7 7-7v14z"/></svg>
<div class="flex-1">
<div class="font-semibold text-gray-200">{{ replyTo?.author || 'User' }}</div>
<div class="truncate">{{ replyTo?.text || '' }}</div>
</div>
<button class="text-gray-400" (click)="clearReply()">✕</button>
</div>
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full overflow-hidden bg-neutral-600"></div>
<input #commentInput type="text" class="flex-1 min-w-0 nimbus-input" placeholder="Add a comment" [(ngModel)]="tmpComment" (keydown.enter)="sendComment()" />
<button class="text-gray-300" title="Mention">
<svg class="w-5 h-5" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2a10 10 0 100 20 5 5 0 005-5V9h-2v8a3 3 0 11-3-3h3V7a8 8 0 10-3 15"/></svg>
</button>
<button class="text-gray-300" title="Attach" (click)="fileEl.click()">
<svg class="w-5 h-5" viewBox="0 0 24 24"><path fill="currentColor" d="M16.5 6.5l-7.8 7.8a3 3 0 104.2 4.2l8.5-8.5a5 5 0 10-7.1-7.1L5.7 11.5"/></svg>
<input #fileEl type="file" class="hidden" multiple (change)="onFilePicked($event)" />
</button>
<button class="px-2 py-1 rounded-md bg-sky-500 text-white disabled:opacity-50" [disabled]="!tmpComment && !(tmpAttachments?.length)" (click)="sendComment()">
<svg class="w-5 h-5" viewBox="0 0 24 24"><path fill="currentColor" d="M3 13l17-9-7 18-2-7z"/></svg>
</button>
</div>
<div *ngIf="tmpAttachments?.length" class="mt-2 grid grid-cols-2 gap-2">
<div *ngFor="let a of tmpAttachments; let i = index" class="relative border border-neutral-700 rounded-lg p-2">
<img *ngIf="a.url && a.type?.startsWith('image/')" [src]="a.url" class="w-full h-24 object-cover rounded" />
<div *ngIf="!(a.url && a.type?.startsWith('image/'))" class="flex items-center gap-2">
<svg viewBox="0 0 24 24" class="w-6 h-6 text-pink-400"><path fill="currentColor" d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12V8l-4-6z"/></svg>
<div class="text-sm text-gray-200 truncate">{{ a.name }}</div>
</div>
<button class="absolute top-1 right-1 text-gray-300" (click)="removeTmpAttachment(i)">✕</button>
</div>
</div>
</div>
</div>
</ng-container>
</ng-container>
</div>
`,
styles: [
`.menu-item { padding: 0.5rem 0.75rem; border-radius: 0.5rem; font-size: 14px; color: #e5e7eb; font-weight: 500; cursor: pointer; }`,
`.menu-item:hover { background: #444; }`
]
})
export class TableContextSubmenuComponent implements OnDestroy {
which: string = '';
context?: TableContextMenuComponent['context'];
presets?: { bg: readonly string[]; text: readonly string[] };
cellTypes: TableCellType[] = [
'text','number','currency','files','checkbox','single-select','multiple-select','mention','collaborator','date','link','rating','progress'
];
tmpComment = '';
tmpAttachments: TableAttachment[] = [];
menuForId: string | null = null;
editingId: string | null = null;
editText = '';
replyTo: { id: string; author: string; text: string } | null = null;
private overlaySvc = inject(Overlay);
private commentMenuRef?: OverlayRef;
emit(type: string, payload?: any) {
this.action.emit({ type, payload });
}
@Output() action = new EventEmitter<{ type: string; payload?: any }>();
@Output() hover = new EventEmitter<boolean>();
onFilePicked(ev: Event) {
const input = ev.target as HTMLInputElement;
const files = Array.from(input.files || []);
for (const f of files) {
const att: TableAttachment = { id: crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2), name: f.name, type: f.type, size: f.size };
try { (att as any).url = URL.createObjectURL(f); } catch {}
this.tmpAttachments.push(att);
}
input.value = '';
}
removeTmpAttachment(i: number) { this.tmpAttachments.splice(i, 1); }
sendComment() {
if (!this.tmpComment && !this.tmpAttachments.length) return;
this.emit('comment-add', { text: this.tmpComment, attachments: this.tmpAttachments, replyToId: this.replyTo?.id });
this.tmpComment = '';
this.tmpAttachments = [];
this.replyTo = null;
}
findCommentById(id: string | null | undefined) {
if (!id) return null;
const list = (this.context?.cell?.comments || []);
return list.find(c => c.id === id) || null;
}
onReply(c: any) {
this.replyTo = { id: c.id, author: c.author, text: c.text };
this.closeCommentMenu();
}
clearReply() { this.replyTo = null; }
onStartEdit(c: any) { this.editingId = c.id; this.editText = c.text; this.closeCommentMenu(); }
cancelEdit() { this.editingId = null; this.editText = ''; }
saveEdit(id: string) { if (!id) return; this.emit('comment-update', { id, text: this.editText }); this.editingId = null; this.editText = ''; }
onDelete(c: any) { this.emit('comment-delete', c.id); this.closeCommentMenu(); }
openCommentMenu(ev: MouseEvent, c: any) {
ev.stopPropagation();
this.closeCommentMenu();
const anchor = ev.currentTarget as HTMLElement;
const pos = this.overlaySvc.position().flexibleConnectedTo(anchor).withPositions([
{ originX: 'end', originY: 'center', overlayX: 'start', overlayY: 'center', offsetX: 8 },
{ originX: 'start', originY: 'center', overlayX: 'end', overlayY: 'center', offsetX: -8 },
{ originX: 'center', originY: 'bottom', overlayX: 'center', overlayY: 'top', offsetY: 8 },
{ originX: 'center', originY: 'top', overlayX: 'center', overlayY: 'bottom', offsetY: -8 },
]);
this.commentMenuRef = this.overlaySvc.create({ hasBackdrop: true, backdropClass: 'cdk-overlay-transparent-backdrop', positionStrategy: pos, panelClass: 'nimbus-menu-panel' });
const portal = new ComponentPortal(CommentActionMenuComponent);
const ref: any = this.commentMenuRef.attach(portal);
ref.instance.context = { id: c.id, author: c.author, text: c.text } as any;
const sub1 = ref.instance.reply.subscribe(() => this.onReply(c));
const sub2 = ref.instance.edit.subscribe(() => this.onStartEdit(c));
const sub3 = ref.instance.remove.subscribe(() => this.onDelete(c));
const close = () => { try { sub1.unsubscribe(); sub2.unsubscribe(); sub3.unsubscribe(); } catch {} this.closeCommentMenu(); };
this.commentMenuRef.backdropClick().subscribe(close);
this.commentMenuRef.keydownEvents().subscribe((e) => { if ((e as KeyboardEvent).key === 'Escape') close(); });
}
closeCommentMenu() {
if (this.commentMenuRef) { this.commentMenuRef.dispose(); this.commentMenuRef = undefined; }
}
ngOnDestroy(): void {
this.closeCommentMenu();
}
}
@@ -0,0 +1,11 @@
/* Nimbus Table Editor styles (Tailwind-first) */
:host { display: block; }
.nimbus-input { background-color: #1f2937; color: #f3f4f6; border-radius: 0.375rem; padding: 0.25rem 0.5rem; border: 1px solid #525252; outline: none; }
.nimbus-input:focus { box-shadow: none; border-color: #9ca3af; }
/* Subtle row/col hover aid */
.hover-rowcol { background-color: #343434; }
/* Ensure sticky header sits above selection outlines */
:host ::ng-deep .sticky { z-index: 10; }
@@ -0,0 +1,179 @@
<div class="bg-[#2E2E2E] text-gray-100 rounded-lg overflow-auto border border-neutral-700 relative group" role="grid" tabindex="0">
<!-- Header toolbar: All scale + insert column icons -->
<div class="absolute top-1 right-2 hidden md:flex items-center gap-2 z-30">
<label class="text-xs text-gray-300">All:</label>
<select class="nimbus-input h-6 px-1 py-0 text-xs w-14" [ngModel]="columnScale()" (ngModelChange)="applyUniformScale($event)">
<option [ngValue]="1">1</option>
<option [ngValue]="2">2</option>
<option [ngValue]="3">3</option>
<option [ngValue]="4">4</option>
</select>
<div class="w-px h-4 bg-neutral-600 mx-1"></div>
<button class="p-1 rounded hover:bg-[#3A3A3A]" title="Insert column left" (click)="quickInsertColLeft()">
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="currentColor"><path d="M5 4h2v16H5zM10 5h9v2h-9zM10 11h9v2h-9zM10 17h9v2h-9z"/></svg>
</button>
<button class="p-1 rounded hover:bg-[#3A3A3A]" title="Insert column center" (click)="quickInsertColCenter()">
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="currentColor"><path d="M11 4h2v16h-2zM5 5h4v2H5zM15 5h4v2h-4zM5 17h4v2H5zM15 17h4v2h-4z"/></svg>
</button>
<button class="p-1 rounded hover:bg-[#3A3A3A]" title="Insert column right" (click)="quickInsertColRight()">
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="currentColor"><path d="M17 4h2v16h-2zM5 5h9v2H5zM5 11h9v2H5zM5 17h9v2H5z"/></svg>
</button>
</div>
<div class="min-w-[640px]">
<!-- Header -->
<div class="sticky top-0 bg-[#1F1F1F] z-10 border-b border-neutral-700">
<div class="grid" [style.gridTemplateColumns]="getGridCols()">
<div class="px-2 py-2 text-sm text-gray-400 border-r border-neutral-700">#</div>
<div *ngFor="let col of columns(); let ci = index" role="columnheader" class="px-2 py-2 text-gray-200 font-semibold border-r border-neutral-700">{{ col.name }}</div>
</div>
</div>
<!-- Body -->
<div class="divide-y divide-neutral-700">
<div *ngFor="let row of rows(); let ri = index" class="grid" [style.gridTemplateColumns]="getGridCols()">
<!-- Row index -->
<div class="px-2 py-1 text-sm text-gray-400 border-r border-neutral-700 select-none">{{ ri + 1 }}</div>
<!-- Cells -->
<div *ngFor="let col of columns(); let ci = index" role="gridcell" [attr.data-cell]="ri + ',' + ci" tabindex="0"
(click)="onCellClick($event, ri, ci)" (dblclick)="onCellDblClick($event, ri, ci)"
(contextmenu)="$event.preventDefault(); openContextMenu($event, ri, ci)"
(pointerdown)="onCellPointerDown($event, ri, ci)" (pointerup)="onCellPointerUp($event)" (pointerleave)="onCellPointerLeave($event)"
(mouseenter)="hoverRow.set(ri); hoverCol.set(ci)" (mouseleave)="hoverRow.set(null); hoverCol.set(null)"
class="relative group"
[ngClass]="cellClasses(ri, ci)"
[style.color]="rows()[ri].cells[ci].format?.textColor || undefined"
[style.background]="rows()[ri].cells[ci].format?.backgroundColor || undefined">
<!-- Comment indicator: quarter circle wedge with count (rendered under content) -->
<div *ngIf="rows()[ri].cells[ci].comments?.length" class="absolute top-0 left-0 select-none z-0 pointer-events-none">
<svg viewBox="0 0 24 24" class="w-6 h-6 text-sky-600">
<path fill="currentColor" d="M0 0 L24 0 A24 24 0 0 1 0 24 Z"></path>
</svg>
<div class="absolute top-0.5 left-2 text-[11px] font-semibold text-white">{{ rows()[ri].cells[ci].comments?.length }}</div>
</div>
<!-- Cell content / editors -->
<ng-container *ngIf="!isEditing(ri, ci); else editorTpl">
<div class="relative z-10 min-h-[32px] flex items-center gap-1">
<!-- Renderers by type -->
<ng-container [ngSwitch]="rows()[ri].cells[ci].type">
<button *ngSwitchCase="'checkbox'" class="inline-flex items-center justify-center w-5 h-5 rounded border border-neutral-500 text-primary-400 bg-neutral-800 hover:bg-neutral-700"
(click)="rows()[ri].cells[ci].value = !rows()[ri].cells[ci].value; commitEdit(rows()[ri].cells[ci].value); $event.stopPropagation()">
<svg *ngIf="rows()[ri].cells[ci].value" viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>
</button>
<div *ngSwitchCase="'single-select'" class="inline-flex items-center gap-1">
<span class="px-2 py-0.5 rounded-md bg-neutral-700 hover:bg-neutral-600">{{ rows()[ri].cells[ci].value || '—' }}</span>
</div>
<div *ngSwitchCase="'multiple-select'" class="flex flex-wrap gap-1">
<span *ngFor="let v of (rows()[ri].cells[ci].value || [])" class="px-2 py-0.5 rounded-md bg-neutral-700 hover:bg-neutral-600">{{ v }}</span>
</div>
<div *ngSwitchCase="'rating'" class="flex items-center gap-1">
<ng-container *ngFor="let s of [1,2,3,4,5]">
<svg class="w-4 h-4" [class.text-yellow-400]="s <= (rows()[ri].cells[ci].value || 0)" [class.text-neutral-600]="s > (rows()[ri].cells[ci].value || 0)" viewBox="0 0 24 24" [attr.fill]="s <= (rows()[ri].cells[ci].value || 0) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="1.5"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
</ng-container>
</div>
<div *ngSwitchCase="'progress'" class="w-full max-w-[140px]">
<div class="w-full h-3 bg-neutral-700 rounded-full overflow-hidden">
<div class="h-3 bg-gradient-to-r from-blue-400 to-green-400" [style.width]="getProgressValue(ri, ci) + '%'"> </div>
</div>
<div class="text-xs text-gray-300">{{ getProgressValue(ri, ci) }}%</div>
</div>
<div *ngSwitchCase="'files'" class="flex items-center gap-2">
<span class="text-xs text-gray-300">{{ getFileCount(ri, ci) }} file(s)</span>
<button class="px-2 py-0.5 rounded-md bg-neutral-700 hover:bg-neutral-600 text-xs" (click)="addMockFile(ri, ci); $event.stopPropagation()">Attach</button>
</div>
<a *ngSwitchCase="'link'" class="text-blue-400 underline truncate" [href]="getLinkHref(ri, ci)" target="_blank" (click)="$event.stopPropagation()"><span [ngStyle]="textStyle(rows()[ri].cells[ci])">{{ getLinkLabel(ri, ci) }}</span></a>
<span *ngSwitchDefault [ngStyle]="textStyle(rows()[ri].cells[ci])">{{ rows()[ri].cells[ci].value || '' }}</span>
</ng-container>
<!-- Kebab menu button -->
<button class="absolute right-1 top-1 z-20 opacity-0 group-hover:opacity-100 transition-opacity" (click)="openContextMenu($event, ri, ci)" (mousedown)="$event.stopPropagation()">
<svg class="w-4 h-4 text-gray-400" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
</button>
</div>
</ng-container>
<!-- Editors -->
<ng-template #editorTpl>
<div class="min-h-[32px] flex items-center gap-2">
<ng-container [ngSwitch]="rows()[ri].cells[ci].type">
<input *ngSwitchCase="'number'" type="number" class="nimbus-input" [(ngModel)]="editBuffer" (blur)="commitEdit()" />
<input *ngSwitchCase="'currency'" type="number" step="0.01" class="nimbus-input" [(ngModel)]="editBuffer" (blur)="commitEdit()" />
<input *ngSwitchCase="'link'" type="url" class="nimbus-input" [(ngModel)]="editBuffer" (blur)="commitEdit()" />
<input *ngSwitchCase="'date'" type="date" class="nimbus-input" [(ngModel)]="editBuffer" (blur)="commitEdit()" />
<div *ngSwitchCase="'rating'" class="flex items-center gap-1 text-yellow-400">
<button *ngFor="let s of [1,2,3,4,5]" class="p-0.5" (click)="commitEdit(s)">
<svg class="w-5 h-5" [class.text-yellow-400]="s <= (rows()[ri].cells[ci].value || 0)" [class.text-neutral-600]="s > (rows()[ri].cells[ci].value || 0)" viewBox="0 0 24 24" [attr.fill]="s <= (rows()[ri].cells[ci].value || 0) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="1.5"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
</button>
</div>
<div *ngSwitchCase="'progress'" class="flex items-center gap-2">
<input type="range" min="0" max="100" class="w-40" [ngModel]="rows()[ri].cells[ci].value" (ngModelChange)="commitEdit($event)" />
<span class="text-xs text-gray-300">{{ rows()[ri].cells[ci].value || 0 }}%</span>
</div>
<select *ngSwitchCase="'single-select'" class="nimbus-input" [ngModel]="rows()[ri].cells[ci].value" (ngModelChange)="commitEdit($event)">
<option *ngFor="let opt of singleSelectOptions" [value]="opt">{{ opt }}</option>
</select>
<div *ngSwitchCase="'multiple-select'" class="flex flex-wrap items-center gap-1">
<span *ngFor="let v of (rows()[ri].cells[ci].value || []); let vi = index" class="px-2 py-0.5 rounded-md bg-neutral-700 hover:bg-neutral-600 text-xs flex items-center gap-1">
{{ v }}
<button class="text-gray-300" (click)="removeMultiSelectValue(ri, ci, vi)">×</button>
</span>
<select class="nimbus-input" (change)="addMultipleSelectOption(ri, ci, $event)">
<option value="" selected disabled>Add…</option>
<option *ngFor="let opt of singleSelectOptions" [value]="opt">{{ opt }}</option>
</select>
</div>
<div *ngSwitchCase="'mention'" class="flex items-center gap-2">
<input list="mentionList" class="nimbus-input w-40" [(ngModel)]="editBuffer" (blur)="commitEdit()" />
<datalist id="mentionList"><option *ngFor="let o of mentionOptions" [value]="o"></option></datalist>
</div>
<div *ngSwitchCase="'collaborator'" class="flex items-center gap-2">
<input list="collabList" class="nimbus-input w-40" [(ngModel)]="editBuffer" (blur)="commitEdit()" />
<datalist id="collabList"><option *ngFor="let o of collaboratorOptions" [value]="o"></option></datalist>
</div>
<div *ngSwitchCase="'files'" class="flex flex-col gap-1">
<div class="flex flex-wrap gap-1">
<span *ngFor="let f of (rows()[ri].cells[ci].value || [])" class="px-2 py-0.5 rounded-md bg-neutral-700 text-xs">{{ f }}</span>
</div>
<button class="px-2 py-0.5 rounded-md bg-neutral-700 hover:bg-neutral-600 text-xs w-max" (click)="addMockFile(ri, ci)">Attach</button>
</div>
<input *ngSwitchDefault type="text" class="nimbus-input" [(ngModel)]="editBuffer" (blur)="commitEdit()" />
</ng-container>
</div>
</ng-template>
</div>
</div>
</div>
</div>
<!-- Table-level comments bubble (speech bubble style to match other blocks) -->
<button *ngIf="totalComments() > 0" class="absolute top-1/2 -translate-y-1/2 right-2 w-8 h-8 flex items-center justify-center"
title="View comments" (click)="openFirstComment()">
<svg viewBox="0 0 24 24" class="w-7 h-7"><path fill="#e5e7eb" d="M12 4c4.97 0 9 2.96 9 6.6S16.97 17.2 12 17.2c-.75 0-1.49-.07-2.2-.22L6 20l1.57-3.14C6.13 16 3 13.86 3 10.6 3 6.96 7.03 4 12 4z"/></svg>
<span class="absolute text-[11px] font-semibold text-black">{{ totalComments() }}</span>
</button>
<button *ngIf="totalComments() === 0" class="absolute top-1/2 -translate-y-1/2 right-2 w-8 h-8 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
title="Add a comment" (click)="openBlockComment()">
<svg viewBox="0 0 24 24" class="w-7 h-7"><path fill="none" stroke="#e5e7eb" stroke-width="1.5" d="M12 4c4.97 0 9 2.96 9 6.6S16.97 17.2 12 17.2c-.75 0-1.49-.07-2.2-.22L6 20l1.57-3.14C6.13 16 3 13.86 3 10.6 3 6.96 7.03 4 12 4z"/></svg>
</button>
<!-- Quick-add controls: positioned outside the table -->
<!-- Add column (header level, outside right) -->
<button class="hidden md:flex items-center justify-center absolute top-[42px] right-[6px] w-5 h-5 text-gray-300 hover:text-gray-100 z-30"
title="Add column to the right" (click)="quickAddColumnRight()">
<svg viewBox="0 0 24 24" class="w-5 h-5"><path fill="currentColor" d="M9 6l6 6-6 6"/></svg>
<span class="sr-only">Add column</span>
</button>
<!-- Add row (bottom-left under # column) -->
<button class="hidden md:flex items-center justify-center absolute left-[6px] bottom-[6px] w-6 h-6 text-gray-300 hover:text-gray-100 z-30"
title="Add row below" (click)="quickAddRowBelow()">
<svg viewBox="0 0 24 24" class="w-6 h-6">
<rect x="6" y="8" width="12" height="2" rx="1" fill="currentColor" opacity="0.6"/>
<path d="M12 17l-4-4h8z" fill="currentColor"/>
</svg>
<span class="sr-only">Add row</span>
</button>
</div>
@@ -0,0 +1,777 @@
import { CommonModule } from '@angular/common';
import { Component, DestroyRef, ElementRef, EventEmitter, HostBinding, HostListener, Input, Output, Signal, WritableSignal, computed, effect, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Overlay, OverlayModule, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal, PortalModule } from '@angular/cdk/portal';
import { TableCell, TableCellType, TableColumn, TableRow, TableState, CellFormatting } from './types';
import { CommentStoreService } from '../../../../editor/services/comment-store.service';
import { TableContextMenuComponent, TableContextSubmenuComponent } from './table-context-menu/table-context-menu.component';
@Component({
selector: 'app-table-editor',
standalone: true,
imports: [CommonModule, FormsModule, OverlayModule, PortalModule],
templateUrl: './table-editor.component.html',
styleUrls: ['./table-editor.component.css']
})
export class TableEditorComponent {
@Input() blockId?: string | null;
@Input() state?: TableState | null;
@Output() stateChange = new EventEmitter<TableState>();
// Internal reactive state
columns: WritableSignal<TableColumn[]> = signal<TableColumn[]>([]);
rows: WritableSignal<TableRow[]> = signal<TableRow[]>([]);
selection: WritableSignal<TableState['selection']> = signal<TableState['selection']>(null);
activeCell: WritableSignal<{ row: number; col: number } | null> = signal<{ row: number; col: number } | null>(null);
editing: WritableSignal<{ row: number; col: number } | null> = signal<{ row: number; col: number } | null>(null);
hoverRow = signal<number | null>(null);
hoverCol = signal<number | null>(null);
editBuffer: any = null;
// Column width scale (1..4) for quick uniform sizing across all columns
columnScale = signal<number>(2);
private overlay = inject(Overlay);
private host = inject(ElementRef<HTMLElement>);
private destroyRef = inject(DestroyRef);
private commentsStore = inject(CommentStoreService);
private contextMenuRef?: OverlayRef;
private commentRef?: OverlayRef;
private lastCommentTarget?: { row: number; col: number };
// Preset color palettes
readonly backgroundColorPresets: readonly string[] = [
'#f43f5e','#fb7185','#e879f9','#c084fc','#a78bfa',
'#60a5fa','#38bdf8','#22d3ee','#34d399','#10b981',
'#f59e0b','#f97316','#ef4444','#9ca3af','#6b7280',
'#4b5563','#374151','#1f2937','#111827','#0f172a'
] as const;
readonly textColorPresets: readonly string[] = [
'#f9fafb','#e5e7eb','#d1d5db','#9ca3af','#6b7280','#374151',
'#ef4444','#f59e0b','#10b981','#22d3ee','#60a5fa','#a78bfa'
] as const;
// Mock options
readonly mentionOptions = ['Alice','Bob','Carol','Dave','Eve'];
readonly collaboratorOptions = ['Alice','Bob','Carol','Dave','Eve'];
readonly singleSelectOptions = ['Todo','Doing','Done'];
constructor() {
// Initialize demo state if not provided later
effect(() => {
const ext = this.state;
if (ext) {
this.columns.set(deepCopy(ext.columns));
this.rows.set(deepCopy(ext.rows));
this.selection.set(ext.selection ? { ...ext.selection } : null);
this.activeCell.set(ext.activeCell ? { ...ext.activeCell } : null);
this.editing.set(ext.editing ? { ...ext.editing } : null);
} else if (this.columns().length === 0) {
const cols: TableColumn[] = Array.from({ length: 5 }, (_, i) => ({ id: uid(), name: String.fromCharCode(65 + i), type: 'text' }));
const rows: TableRow[] = Array.from({ length: 10 }, (_, r) => ({ id: uid(), cells: cols.map((c, idx) => ({ id: uid(), type: c.type, value: `${String.fromCharCode(65 + idx)}${r + 1}`, format: { align: 'left' } })) }));
this.columns.set(cols);
this.rows.set(rows);
this.selection.set({ startRow: 0, startCol: 0, endRow: 0, endCol: 0 });
this.activeCell.set({ row: 0, col: 0 });
}
});
// Keep external state in sync on changes
effect(() => {
if (this.state === undefined) return; // uncontrolled allowed
// Emit when internal changes occur
this.emitStateChange();
});
}
private openCommentAtBlock() {
this.closeComment();
const anchor = this.host.nativeElement as HTMLElement;
const pos = this.overlay.position().flexibleConnectedTo(anchor).withPositions([
// Middle left of the block
{ originX: 'start', originY: 'center', overlayX: 'start', overlayY: 'center', offsetX: 8 },
// Fallback below
{ originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 8 }
]);
this.commentRef = this.overlay.create({ hasBackdrop: true, backdropClass: 'cdk-overlay-transparent-backdrop', positionStrategy: pos, panelClass: 'nimbus-menu-panel' });
const portal = new ComponentPortal(TableContextSubmenuComponent);
const ref = this.commentRef.attach(portal);
ref.instance.which = 'comment';
const ac = this.activeCell() || { row: 0, col: 0 };
(ref.instance as any).context = { row: ac.row, col: ac.col, cell: this.rows()[ac.row].cells[ac.col], column: this.columns()[ac.col], state: this.currentState(), presets: { bg: this.backgroundColorPresets, text: this.textColorPresets } };
this.lastCommentTarget = { row: ac.row, col: ac.col };
const sub = ref.instance.action.subscribe((e) => {
switch (e.type) {
case 'comment':
case 'comment-add':
this.saveComment(ac.row, ac.col, e.payload);
(ref.instance as any).context = { row: ac.row, col: ac.col, cell: this.rows()[ac.row].cells[ac.col], column: this.columns()[ac.col], state: this.currentState(), presets: { bg: this.backgroundColorPresets, text: this.textColorPresets } };
break;
case 'close': this.closeComment(); break;
}
});
this.commentRef.backdropClick().subscribe(() => this.closeComment());
this.destroyRef.onDestroy(() => sub.unsubscribe());
}
// Rendering helpers
cellClasses(r: number, c: number) {
const isActive = this.activeCell()?.row === r && this.activeCell()?.col === c;
const isEditing = this.isEditing(r, c);
const sel = this.selection();
const inSel = sel && r >= Math.min(sel.startRow, sel.endRow) && r <= Math.max(sel.startRow, sel.endRow)
&& c >= Math.min(sel.startCol, sel.endCol) && c <= Math.max(sel.startCol, sel.endCol);
const cell = this.rows()[r]?.cells[c];
const align = cell?.format?.align || 'left';
const bg = cell?.format?.backgroundColor ? '' : 'bg-[#2E2E2E]';
const hover = (this.hoverRow() === r || this.hoverCol() === c) ? 'hover-rowcol' : '';
return [
'relative transition-colors', bg, 'hover:bg-[#3A3A3A]', 'border-[0.5px] border-neutral-600', 'px-2 py-1', hover,
align === 'center' ? 'text-center' : align === 'right' ? 'text-right' : 'text-left',
inSel && !isEditing ? 'ring-2 ring-primary/70 ring-offset-0 ring-inset' : '',
isActive && !isEditing ? 'outline outline-2 outline-primary/70' : ''
].join(' ');
}
textStyle(cell: TableCell) {
const fmt = cell.format || {};
return {
'font-weight': fmt.bold ? '600' : '400',
'font-style': fmt.italic ? 'italic' : 'normal',
'text-decoration': `${fmt.underline ? 'underline ' : ''}${fmt.strikethrough ? ' line-through' : ''}`.trim(),
'color': fmt.textColor || undefined,
'background': fmt.backgroundColor || undefined
} as any;
}
// Selection
selectCell(row: number, col: number, opts?: { extend?: boolean; toggle?: boolean }) {
const rows = this.rows();
if (!rows[row] || !rows[row].cells[col]) return;
if (opts?.extend && this.selection()) {
const s = { ...this.selection()! };
s.endRow = row; s.endCol = col;
this.selection.set(s);
this.activeCell.set({ row, col });
return;
}
this.selection.set({ startRow: row, startCol: col, endRow: row, endCol: col });
this.activeCell.set({ row, col });
}
// Editing lifecycle
startEdit(row: number, col: number) {
this.editing.set({ row, col });
try { this.editBuffer = deepCopy(this.rows()[row].cells[col].value); } catch { this.editBuffer = this.rows()[row].cells[col].value; }
setTimeout(() => {
const el = this.getCellEl(row, col)?.querySelector('input, select, textarea') as (HTMLElement | null);
if (el) {
(el as any).focus?.();
if ('select' in (el as any)) { try { (el as any).select(); } catch {} }
}
});
}
commitEdit(value?: any) {
const e = this.editing(); if (!e) return;
const rows = deepCopy(this.rows());
const cell = rows[e.row].cells[e.col];
const nextVal = (value === undefined) ? this.editBuffer : value;
cell.value = this.coerceValueForType(nextVal, cell.type);
this.rows.set(rows);
this.editing.set(null);
this.editBuffer = null;
this.emitStateChange();
}
updateComment(row: number, col: number, id: string, text: string) {
if (!id) return;
const rows = deepCopy(this.rows());
const cell = rows[row].cells[col];
const list = Array.isArray(cell.comments) ? cell.comments : [];
const idx = list.findIndex(c => c.id === id);
if (idx >= 0) { list[idx].text = text; }
cell.comments = list;
rows[row].cells[col] = cell;
this.rows.set(rows);
this.emitStateChange();
if (this.blockId) {
try { this.commentsStore.update(this.blockId, id, text); } catch {}
}
}
deleteComment(row: number, col: number, id: string) {
if (!id) return;
const rows = deepCopy(this.rows());
const cell = rows[row].cells[col];
const list = Array.isArray(cell.comments) ? cell.comments : [];
cell.comments = list.filter(c => c.id !== id);
rows[row].cells[col] = cell;
this.rows.set(rows);
this.emitStateChange();
if (this.blockId) {
try { this.commentsStore.remove(this.blockId, id); } catch {}
}
}
cancelEdit() { this.editing.set(null); }
// CRUD operations
addRowAbove(rowIndex: number) { this._addRow(rowIndex); }
addRowBelow(rowIndex: number) { this._addRow(rowIndex + 1); }
private _addRow(at: number) {
const cols = this.columns();
const rows = deepCopy(this.rows());
const newRow: TableRow = { id: uid(), cells: cols.map(c => ({ id: uid(), type: c.type, value: '', format: {} })) };
rows.splice(at, 0, newRow);
this.rows.set(rows);
this.selectCell(at, 0);
this.emitStateChange();
}
addColumnLeft(colIndex: number) { this._addColumn(colIndex); }
addColumnRight(colIndex: number) { this._addColumn(colIndex + 1); }
addColumnAt(at: number) { this._addColumn(Math.max(0, at)); }
private _addColumn(at: number) {
const columns = deepCopy(this.columns());
const name = this.nextColumnName(columns.length);
const col: TableColumn = { id: uid(), name, type: 'text' };
columns.splice(at, 0, col);
const rows = deepCopy(this.rows());
for (const r of rows) r.cells.splice(at, 0, { id: uid(), type: col.type, value: '', format: {} });
this.columns.set(columns); this.rows.set(rows);
this.selectCell(0, at);
this.emitStateChange();
}
deleteRow(rowIndex: number) {
const rows = deepCopy(this.rows());
if (!rows[rowIndex]) return;
rows.splice(rowIndex, 1);
this.rows.set(rows);
const newRow = Math.max(0, rowIndex - 1);
this.selectCell(newRow, 0);
this.emitStateChange();
}
deleteColumn(colIndex: number) {
const columns = deepCopy(this.columns());
const rows = deepCopy(this.rows());
if (!columns[colIndex]) return;
columns.splice(colIndex, 1);
for (const r of rows) r.cells.splice(colIndex, 1);
this.columns.set(columns); this.rows.set(rows);
const newCol = Math.max(0, colIndex - 1);
this.selectCell(0, newCol);
this.emitStateChange();
}
// Formatting
applyFormatting(fmt: Partial<CellFormatting>) {
const sel = this.selection(); if (!sel) return;
const rows = deepCopy(this.rows());
for (let r = Math.min(sel.startRow, sel.endRow); r <= Math.max(sel.startRow, sel.endRow); r++) {
for (let c = Math.min(sel.startCol, sel.endCol); c <= Math.max(sel.startCol, sel.endCol); c++) {
rows[r].cells[c].format = { ...(rows[r].cells[c].format || {}), ...fmt };
}
}
this.rows.set(rows);
this.emitStateChange();
}
applyBackgroundColor(color: string) { this.applyFormatting({ backgroundColor: color }); }
applyTextColor(color: string) { this.applyFormatting({ textColor: color }); }
// Cell type
setCellType(row: number, col: number, type: TableCellType) {
const rows = deepCopy(this.rows());
const cell = rows[row].cells[col];
cell.value = this.convertType(cell.value, cell.type, type);
cell.type = type;
rows[row].cells[col] = cell;
this.rows.set(rows);
this.emitStateChange();
}
// Clipboard
async copySelectionToClipboard() {
const sel = this.selection(); if (!sel) return;
const rows = this.rows();
const lines: string[] = [];
for (let r = Math.min(sel.startRow, sel.endRow); r <= Math.max(sel.startRow, sel.endRow); r++) {
const vals: string[] = [];
for (let c = Math.min(sel.startCol, sel.endCol); c <= Math.max(sel.startCol, sel.endCol); c++) {
vals.push(String(rows[r].cells[c].value ?? ''));
}
lines.push(vals.join('\t'));
}
const tsv = lines.join('\n');
try { await navigator.clipboard.writeText(tsv); } catch {}
}
async pasteClipboardToSelection(text?: string) {
const sel = this.selection(); if (!sel) return;
let data = text;
if (!data) {
try { data = await navigator.clipboard.readText(); } catch { return; }
}
if (!data) return;
const rows = deepCopy(this.rows());
const startR = Math.min(sel.startRow, sel.endRow);
const startC = Math.min(sel.startCol, sel.endCol);
const lines = data.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const parts = lines[i].split('\t');
for (let j = 0; j < parts.length; j++) {
const r = startR + i, c = startC + j;
if (rows[r] && rows[r].cells[c]) {
const cell = rows[r].cells[c];
rows[r].cells[c].value = this.coerceValueForType(parts[j], cell.type);
}
}
}
this.rows.set(rows);
this.emitStateChange();
}
// Context menu
openContextMenu(ev: MouseEvent | { x: number; y: number } | HTMLElement, row: number, col: number) {
this.closeContextMenus();
const positionBuilder = this.overlay.position().flexibleConnectedTo(
ev instanceof MouseEvent ? { x: ev.clientX, y: ev.clientY } : (ev instanceof HTMLElement ? ev : { x: ev.x, y: ev.y })
).withPositions([
{ originX: 'end', originY: 'top', overlayX: 'start', overlayY: 'top' },
{ originX: 'start', originY: 'top', overlayX: 'end', overlayY: 'top' },
{ originX: 'end', originY: 'bottom', overlayX: 'start', overlayY: 'bottom' },
]);
this.contextMenuRef = this.overlay.create({
hasBackdrop: true,
backdropClass: 'cdk-overlay-transparent-backdrop',
positionStrategy: positionBuilder,
panelClass: 'nimbus-menu-panel'
});
const portal = new ComponentPortal(TableContextMenuComponent);
const ref = this.contextMenuRef.attach(portal);
const cell = this.rows()[row].cells[col];
ref.instance.context = { row, col, cell, column: this.columns()[col], state: this.currentState(), presets: { bg: this.backgroundColorPresets, text: this.textColorPresets } };
const sub = ref.instance.action.subscribe((e) => this.onMenuAction(e, row, col));
this.contextMenuRef.backdropClick().subscribe(() => this.closeContextMenus());
this.contextMenuRef.keydownEvents().subscribe((e) => { if (e.key === 'Escape') this.closeContextMenus(); });
this.destroyRef.onDestroy(() => sub.unsubscribe());
}
private onMenuAction(e: { type: string; payload?: any }, row: number, col: number) {
switch (e.type) {
case 'close': this.closeContextMenus(); break;
case 'comment-open': this.closeContextMenus(); this.openCommentPopover(row, col); break;
case 'add-row-above': this.addRowAbove(row); break;
case 'add-row-below': this.addRowBelow(row); break;
case 'add-col-left': this.addColumnLeft(col); break;
case 'add-col-right': this.addColumnRight(col); break;
case 'delete-row': this.deleteRow(row); break;
case 'delete-col': this.deleteColumn(col); break;
case 'bg-color': this.applyBackgroundColor(e.payload); break;
case 'format-bold': this.toggleFormatting('bold'); break;
case 'format-italic': this.toggleFormatting('italic'); break;
case 'format-underline': this.toggleFormatting('underline'); break;
case 'format-strike': this.toggleFormatting('strikethrough'); break;
case 'align-left': this.applyFormatting({ align: 'left' }); break;
case 'align-center': this.applyFormatting({ align: 'center' }); break;
case 'align-right': this.applyFormatting({ align: 'right' }); break;
case 'text-color': this.applyTextColor(e.payload); break;
case 'cell-type': this.setCellType(row, col, e.payload as TableCellType); break;
case 'copy': this.copySelectionToClipboard(); break;
case 'clear': this.clearSelection(); break;
case 'comment': this.saveComment(row, col, e.payload); this.closeContextMenus(); break;
case 'comment-add': this.saveComment(row, col, e.payload); this.closeContextMenus(); break;
}
}
private openCommentPopover(row: number, col: number) {
this.closeComment();
const cellEl = this.getCellEl(row, col);
if (!cellEl) return;
const pos = this.overlay.position().flexibleConnectedTo(cellEl).withPositions([
// Prefer under the cell
{ originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 8 },
{ originX: 'center', originY: 'bottom', overlayX: 'center', overlayY: 'top', offsetY: 8 },
// Fallback above the cell
{ originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -8 },
]);
this.commentRef = this.overlay.create({ hasBackdrop: true, backdropClass: 'cdk-overlay-transparent-backdrop', positionStrategy: pos, panelClass: 'nimbus-menu-panel' });
const portal = new ComponentPortal(TableContextSubmenuComponent);
const ref = this.commentRef.attach(portal);
ref.instance.which = 'comment';
(ref.instance as any).context = { row, col, cell: this.rows()[row].cells[col], column: this.columns()[col], state: this.currentState(), presets: { bg: this.backgroundColorPresets, text: this.textColorPresets } };
this.lastCommentTarget = { row, col };
const sub = ref.instance.action.subscribe((e) => {
switch (e.type) {
case 'comment':
case 'comment-add':
this.saveComment(row, col, e.payload);
// Refresh context with updated cell, keep panel open
(ref.instance as any).context = { row, col, cell: this.rows()[row].cells[col], column: this.columns()[col], state: this.currentState(), presets: { bg: this.backgroundColorPresets, text: this.textColorPresets } };
break;
case 'comment-nav-prev': this.navigateComment(-1); break;
case 'comment-nav-next': this.navigateComment(1); break;
case 'comment-delete': this.deleteComment(row, col, e.payload); (ref.instance as any).context = { row, col, cell: this.rows()[row].cells[col], column: this.columns()[col], state: this.currentState(), presets: { bg: this.backgroundColorPresets, text: this.textColorPresets } }; break;
case 'comment-update': this.updateComment(row, col, e.payload?.id, e.payload?.text); (ref.instance as any).context = { row, col, cell: this.rows()[row].cells[col], column: this.columns()[col], state: this.currentState(), presets: { bg: this.backgroundColorPresets, text: this.textColorPresets } }; break;
case 'close': this.closeComment(); break;
}
});
this.commentRef.backdropClick().subscribe(() => this.closeComment());
this.destroyRef.onDestroy(() => sub.unsubscribe());
}
private closeComment() { if (this.commentRef) { this.commentRef.dispose(); this.commentRef = undefined; } }
closeContextMenus() { if (this.contextMenuRef) { this.contextMenuRef.dispose(); this.contextMenuRef = undefined; } }
clearSelection() {
const sel = this.selection(); if (!sel) return;
const rows = deepCopy(this.rows());
for (let r = Math.min(sel.startRow, sel.endRow); r <= Math.max(sel.startRow, sel.endRow); r++) {
for (let c = Math.min(sel.startCol, sel.endCol); c <= Math.max(sel.startCol, sel.endCol); c++) {
rows[r].cells[c].value = '';
}
}
this.rows.set(rows);
this.emitStateChange();
}
saveComment(row: number, col: number, payload: any) {
const rows = deepCopy(this.rows());
const cell = rows[row].cells[col];
const list = Array.isArray(cell.comments) ? cell.comments : [];
const text = typeof payload === 'string' ? payload : (payload?.text || '');
const attachments = (payload && Array.isArray(payload.attachments)) ? payload.attachments : undefined;
const replyToId = payload?.replyToId as string | undefined;
const id = uid();
list.push({ id, author: 'You', text, createdAt: new Date().toISOString(), attachments, replyToId });
cell.comments = list;
rows[row].cells[col] = cell;
this.rows.set(rows);
this.emitStateChange();
if (this.blockId) {
try {
this.commentsStore.add(this.blockId, { id, author: 'You', text, attachments, replyToId, target: { type: 'table-cell', row, col } as any });
} catch {}
}
}
// Keyboard
@HostListener('keydown', ['$event']) onKeydown(ev: KeyboardEvent) {
const e = ev;
const editing = this.editing();
if (editing) {
if (e.key === 'Escape') { this.cancelEdit(); e.preventDefault(); }
if (e.key === 'Enter') { this.commitEdit(); e.preventDefault(); }
return;
}
const ac = this.activeCell(); if (!ac) return;
const maxR = this.rows().length - 1; const maxC = this.columns().length - 1;
const move = (r: number, c: number) => { this.selectCell(Math.max(0, Math.min(maxR, r)), Math.max(0, Math.min(maxC, c)), e.shiftKey ? { extend: true } : undefined); };
const printable = e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey;
switch (e.key) {
case 'Enter': this.startEdit(ac.row, ac.col); e.preventDefault(); break;
case 'Tab': {
if (e.shiftKey) {
if (ac.col > 0) move(ac.row, ac.col - 1); else move(Math.max(0, ac.row - 1), this.columns().length - 1);
} else {
if (ac.col < this.columns().length - 1) move(ac.row, ac.col + 1); else move(Math.min(maxR, ac.row + 1), 0);
}
e.preventDefault();
break;
}
case 'ArrowLeft': move(ac.row, ac.col - 1); e.preventDefault(); break;
case 'ArrowRight': move(ac.row, ac.col + 1); e.preventDefault(); break;
case 'ArrowUp': move(ac.row - 1, ac.col); e.preventDefault(); break;
case 'ArrowDown': move(ac.row + 1, ac.col); e.preventDefault(); break;
case 'Home': move(e.ctrlKey || e.metaKey ? 0 : ac.row, 0); e.preventDefault(); break;
case 'End': move(e.ctrlKey || e.metaKey ? maxR : ac.row, maxC); e.preventDefault(); break;
case 'c': if (e.ctrlKey || e.metaKey) { this.copySelectionToClipboard(); e.preventDefault(); } break;
case 'v': if (e.ctrlKey || e.metaKey) { this.pasteClipboardToSelection(); e.preventDefault(); } break;
default:
if (printable) {
const cell = this.rows()[ac.row]?.cells[ac.col];
if (cell && (cell.type === 'text' || cell.type === 'number' || cell.type === 'currency' || cell.type === 'link' || cell.type === 'date')) {
this.startEdit(ac.row, ac.col);
this.editBuffer = e.key;
e.preventDefault();
}
}
break;
}
}
// Mouse interactions
onCellClick(ev: MouseEvent, r: number, c: number) {
const sel = this.selection();
if (ev.shiftKey && sel) { this.selectCell(r, c, { extend: true }); return; }
if ((ev.ctrlKey || (ev as any).metaKey) && sel) {
const inSel = r >= Math.min(sel.startRow, sel.endRow) && r <= Math.max(sel.startRow, sel.endRow)
&& c >= Math.min(sel.startCol, sel.endCol) && c <= Math.max(sel.startCol, sel.endCol);
const spansMany = Math.abs(sel.endRow - sel.startRow) + Math.abs(sel.endCol - sel.startCol) > 0;
if (inSel && spansMany) {
this.selection.set({ startRow: r, startCol: c, endRow: r, endCol: c });
this.activeCell.set({ row: r, col: c });
return;
}
const s = { ...sel };
s.startRow = Math.min(s.startRow, r);
s.startCol = Math.min(s.startCol, c);
s.endRow = Math.max(s.endRow, r);
s.endCol = Math.max(s.endCol, c);
this.selection.set(s);
this.activeCell.set({ row: r, col: c });
return;
}
// If clicking the already active cell, start edit for inline-editable types
const wasActive = this.activeCell()?.row === r && this.activeCell()?.col === c;
this.selectCell(r, c);
const t = this.rows()[r]?.cells[c]?.type;
if (wasActive && t && (
t === 'text' || t === 'number' || t === 'currency' || t === 'link' || t === 'date' ||
t === 'single-select' || t === 'multiple-select' || t === 'mention' || t === 'collaborator' ||
t === 'rating' || t === 'progress' || t === 'files'
)) {
this.startEdit(r, c);
}
}
onCellDblClick(_ev: MouseEvent, r: number, c: number) { this.startEdit(r, c); }
// Long press for mobile
private longPressTimer?: any;
onCellPointerDown(ev: PointerEvent, r: number, c: number) {
(ev.target as HTMLElement).setPointerCapture(ev.pointerId);
this.longPressTimer = setTimeout(() => this.openContextMenu({ x: ev.clientX, y: ev.clientY }, r, c), 500);
}
onCellPointerUp(ev: PointerEvent) { if (this.longPressTimer) { clearTimeout(this.longPressTimer); this.longPressTimer = undefined; } }
onCellPointerLeave(ev: PointerEvent) { if (this.longPressTimer) { clearTimeout(this.longPressTimer); this.longPressTimer = undefined; } }
// Rendering helpers
renderCell(cell: TableCell): string { return formatCell(cell); }
// Inline editors
isEditing(r: number, c: number) { const e = this.editing(); return !!e && e.row === r && e.col === c; }
// Type conversion and coercion
private coerceValueForType(value: any, type: TableCellType): any {
switch (type) {
case 'number':
case 'currency': {
const n = parseFloat(value); return isNaN(n) ? null : n;
}
case 'checkbox': {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value !== 0;
const v = String(value ?? '').trim().toLowerCase();
return v === 'true' || v === 'yes' || v === '1' || (v.length > 0 && v !== 'false');
}
case 'rating': {
const n = Math.max(0, Math.min(5, parseInt(value, 10))); return isNaN(n) ? 0 : n;
}
case 'progress': {
const n = Math.max(0, Math.min(100, parseInt(value, 10))); return isNaN(n) ? 0 : n;
}
case 'date': {
const s = String(value || '');
const d = new Date(s);
return isNaN(d.getTime()) ? '' : s.slice(0, 10);
}
default: return value;
}
}
private convertType(value: any, from: TableCellType, to: TableCellType) {
if (from === to) return value;
// Convert cautiously
if (to === 'checkbox') return this.coerceValueForType(value, 'checkbox');
if (to === 'number' || to === 'currency') return this.coerceValueForType(value, to);
if (to === 'rating') return this.coerceValueForType(value, 'rating');
if (to === 'progress') return this.coerceValueForType(value, 'progress');
if (to === 'date') return this.coerceValueForType(value, 'date');
return value ?? '';
}
// Utility
emitStateChange() {
const next: TableState = { columns: deepCopy(this.columns()), rows: deepCopy(this.rows()), selection: this.selection() ? { ...this.selection()! } : null, activeCell: this.activeCell() ? { ...this.activeCell()! } : undefined, editing: this.editing() ? { ...this.editing()! } : undefined };
this.stateChange.emit(next);
}
currentState(): TableState { return { columns: this.columns(), rows: this.rows(), selection: this.selection(), activeCell: this.activeCell(), editing: this.editing() } as any; }
nextColumnName(index: number) {
// Simple base-26 A..Z then AA..AZ etc.
let n = index; let name = '';
do { name = String.fromCharCode(65 + (n % 26)) + name; n = Math.floor(n / 26) - 1; } while (n >= 0);
return name;
}
// Helpers
getCellEl(r: number, c: number): HTMLElement | null { return this.host.nativeElement.querySelector(`[data-cell="${r},${c}"]`); }
toggleFormatting(kind: keyof Pick<CellFormatting, 'bold'|'italic'|'underline'|'strikethrough'>) {
const sel = this.selection(); if (!sel) return;
const rows = deepCopy(this.rows());
for (let r = Math.min(sel.startRow, sel.endRow); r <= Math.max(sel.startRow, sel.endRow); r++) {
for (let c = Math.min(sel.startCol, sel.endCol); c <= Math.max(sel.startCol, sel.endCol); c++) {
const fmt = rows[r].cells[c].format || {};
(fmt as any)[kind] = !((fmt as any)[kind]);
rows[r].cells[c].format = fmt;
}
}
this.rows.set(rows);
this.emitStateChange();
}
// Template helpers used in HTML
getGridCols(): string {
const colWidths = this.columns().map(c => (c.width || 160) + 'px').join(' ');
return '48px ' + colWidths;
}
getProgressValue(r: number, c: number): number {
const v = this.rows()[r]?.cells[c]?.value;
const n = parseInt(String(v ?? 0), 10);
return Math.max(0, Math.min(100, isNaN(n) ? 0 : n));
}
getFileCount(r: number, c: number): number { const v = this.rows()[r]?.cells[c]?.value; return Array.isArray(v) ? v.length : 0; }
addMockFile(r: number, c: number) {
const rows = deepCopy(this.rows());
const arr = Array.isArray(rows[r].cells[c].value) ? rows[r].cells[c].value : [];
const next = [...arr, `File ${arr.length + 1}`];
rows[r].cells[c].value = next;
this.rows.set(rows);
this.emitStateChange();
}
removeMultiSelectValue(r: number, c: number, index: number) {
const rows = deepCopy(this.rows());
const arr = Array.isArray(rows[r].cells[c].value) ? rows[r].cells[c].value : [];
arr.splice(index, 1);
rows[r].cells[c].value = arr;
this.rows.set(rows);
this.emitStateChange();
}
addMultipleSelectOption(r: number, c: number, ev: Event) {
const sel = ev.target as HTMLSelectElement;
const val = sel.value;
if (!val) return;
const rows = deepCopy(this.rows());
const arr = Array.isArray(rows[r].cells[c].value) ? rows[r].cells[c].value : [];
if (!arr.includes(val)) arr.push(val);
rows[r].cells[c].value = arr;
this.rows.set(rows);
this.emitStateChange();
sel.value = '';
}
getLinkHref(r: number, c: number): string { const v = this.rows()[r]?.cells[c]?.value; return v ? String(v) : '#'; }
getLinkLabel(r: number, c: number): string { const v = this.rows()[r]?.cells[c]?.value; return v ? String(v) : '—'; }
totalComments(): number {
try {
if (this.blockId) {
return this.commentsStore.count(this.blockId);
}
return this.rows().reduce((sum, row) => sum + row.cells.reduce((s, cell) => s + (cell.comments?.length || 0), 0), 0);
} catch { return 0; }
}
openBlockComment() {
const ac = this.activeCell();
const r = ac?.row ?? 0;
const c = ac?.col ?? 0;
this.openCommentPopover(r, c);
}
openCommentsBubble() {
if (this.totalComments() > 0) {
this.openFirstComment();
} else {
this.openCommentAtBlock();
}
}
openFirstComment() {
const list = this.getCellsWithComments();
if (list.length) {
this.selectCell(list[0].row, list[0].col);
this.openCommentPopover(list[0].row, list[0].col);
}
}
private getCellsWithComments(): { row: number; col: number }[] {
const res: { row: number; col: number }[] = [];
const rows = this.rows();
for (let r = 0; r < rows.length; r++) {
for (let c = 0; c < rows[r].cells.length; c++) {
if (rows[r].cells[c].comments && rows[r].cells[c].comments!.length > 0) res.push({ row: r, col: c });
}
}
return res;
}
private navigateComment(offset: number) {
const list = this.getCellsWithComments();
if (!list.length) return;
const cur = this.lastCommentTarget || list[0];
const idx = list.findIndex(p => p.row === cur.row && p.col === cur.col);
const next = list[(idx + offset + list.length) % list.length];
this.closeComment();
this.selectCell(next.row, next.col);
this.openCommentPopover(next.row, next.col);
}
// Quick-add controls invoked from template buttons
quickAddColumnRight() {
const ac = this.activeCell();
const col = ac?.col ?? (this.columns().length - 1);
const idx = Math.max(0, Math.min(this.columns().length - 1, col));
this.addColumnRight(idx);
}
quickAddRowBelow() {
const ac = this.activeCell();
const row = ac?.row ?? (this.rows().length - 1);
const idx = Math.max(0, Math.min(this.rows().length - 1, row));
this.addRowBelow(idx);
}
// Toolbar helpers
quickInsertColLeft() {
const ac = this.activeCell();
const idx = Math.max(0, Math.min(this.columns().length, (ac?.col ?? 0)));
this.addColumnLeft(idx);
}
quickInsertColCenter() {
const idx = Math.floor(this.columns().length / 2);
this.addColumnAt(idx);
}
quickInsertColRight() {
const ac = this.activeCell();
const idx = Math.max(0, Math.min(this.columns().length - 1, (ac?.col ?? (this.columns().length - 1))));
this.addColumnRight(idx);
}
applyUniformScale(scale: number) {
const widths = { 1: 120, 2: 160, 3: 200, 4: 240 } as const;
const w = widths[Math.max(1, Math.min(4, Number(scale) || 2)) as 1|2|3|4];
const cols = deepCopy(this.columns());
for (let i = 0; i < cols.length; i++) cols[i].width = w;
this.columns.set(cols);
this.columnScale.set(Number(scale));
this.emitStateChange();
}
}
// Template helpers
function formatCell(cell: TableCell): string {
switch (cell.type) {
case 'currency': return typeof cell.value === 'number' ? new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(cell.value) : String(cell.value ?? '');
case 'number': return typeof cell.value === 'number' ? String(cell.value) : String(cell.value ?? '');
case 'link': return String(cell.value ?? '');
case 'date': return String(cell.value ?? '');
case 'rating': return `${cell.value ?? 0}/5`;
case 'progress': return `${cell.value ?? 0}%`;
case 'checkbox': return cell.value ? '✓' : '';
case 'single-select': return String(cell.value ?? '');
case 'multiple-select': return Array.isArray(cell.value) ? cell.value.join(', ') : '';
case 'files': return Array.isArray(cell.value) ? `${cell.value.length} file(s)` : '';
default: return String(cell.value ?? '');
}
}
function uid() { try { return crypto.randomUUID(); } catch { return 'id-' + Math.random().toString(36).slice(2); } }
function deepCopy<T>(v: T): T { return JSON.parse(JSON.stringify(v)); }
// Template helper methods used by editors (chips/files)
export interface MultiSelectChange { add?: string; removeIndex?: number; }
@@ -0,0 +1,75 @@
export interface TableCell {
id: string;
value: any;
type: TableCellType;
format?: CellFormatting;
color?: string;
comments?: TableComment[];
}
export type TableCellType =
| 'text'
| 'number'
| 'currency'
| 'files'
| 'checkbox'
| 'single-select'
| 'multiple-select'
| 'mention'
| 'collaborator'
| 'date'
| 'link'
| 'rating'
| 'progress';
export interface CellFormatting {
bold?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
align?: 'left' | 'center' | 'right';
textColor?: string;
backgroundColor?: string;
}
export interface TableColumn {
id: string;
name: string; // A, B, C…
type: TableCellType; // default type for new cells in this column
width?: number; // px, resizable in future
}
export interface TableRow {
id: string;
cells: TableCell[];
}
export interface TableState {
columns: TableColumn[];
rows: TableRow[];
selection: { // Rect selection in grid coordinates
startRow: number;
startCol: number;
endRow: number;
endCol: number;
} | null;
activeCell?: { row: number; col: number } | null;
editing?: { row: number; col: number } | null;
}
export interface TableAttachment {
id: string;
name: string;
type: string;
size?: number;
url?: string; // object or remote url for preview/download
}
export interface TableComment {
id: string;
author: string;
text: string;
createdAt: string; // ISO timestamp
attachments?: TableAttachment[];
replyToId?: string;
}
@@ -1,5 +1,6 @@
import { Component, EventEmitter, Input, Output, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { MobileNavService } from '../../shared/services/mobile-nav.service';
import { FileExplorerComponent } from '../../../components/file-explorer/file-explorer.component';
import { QuickLinksComponent } from '../quick-links/quick-links.component';
@@ -13,7 +14,7 @@ import { AIToolsService, type AIToolItem } from '../../services/ai-tools.service
@Component({
selector: 'app-sidebar-drawer',
standalone: true,
imports: [CommonModule, FileExplorerComponent, QuickLinksComponent, ScrollableOverlayDirective, TrashExplorerComponent],
imports: [CommonModule, RouterModule, FileExplorerComponent, QuickLinksComponent, ScrollableOverlayDirective, TrashExplorerComponent],
template: `
<aside class="fixed left-0 top-0 bottom-0 w-80 max-w-[80vw] bg-card dark:bg-main shadow-2xl z-40 transform transition-all duration-300 ease-out flex flex-col"
[class.-translate-x-full]="!mobileNav.sidebarOpen()"
@@ -51,6 +52,11 @@ import { AIToolsService, type AIToolItem } from '../../services/ai-tools.service
class="mt-1 w-full text-left block text-sm px-3 py-2 rounded-lg hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 text-main dark:text-main hover:text-main dark:hover:text-gray-100 transition-all active:scale-[0.98] transform">
🔬 API Tests Panel
</button>
<button
(click)="onNimbusEditorClick()"
class="mt-1 w-full text-left block text-sm px-3 py-2 rounded-lg hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 text-main dark:text-main hover:text-main dark:hover:text-gray-100 transition-all active:scale-[0.98] transform">
🧠 Éditeur Nimbus
</button>
<button
(click)="onTestsExcalidrawClick()"
class="mt-1 w-full text-left block text-sm px-3 py-2 rounded-lg hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 text-main dark:text-main hover:text-main dark:hover:text-gray-100 transition-all active:scale-[0.98] transform">
@@ -197,6 +203,7 @@ export class AppSidebarDrawerComponent {
@Output() markdownPlaygroundSelected = new EventEmitter<void>();
@Output() testsPanelSelected = new EventEmitter<void>();
@Output() testsExcalidrawSelected = new EventEmitter<void>();
@Output() nimbusEditorSelected = new EventEmitter<void>();
@Output() helpPageSelected = new EventEmitter<void>();
@Output() aboutSelected = new EventEmitter<void>();
@Output() aiToolSelected = new EventEmitter<string>();
@@ -239,6 +246,11 @@ export class AppSidebarDrawerComponent {
this.mobileNav.sidebarOpen.set(false);
}
onNimbusEditorClick(): void {
this.nimbusEditorSelected.emit();
this.mobileNav.sidebarOpen.set(false);
}
onHelpPageClick(): void {
this.helpPageSelected.emit();
this.mobileNav.sidebarOpen.set(false);
@@ -33,7 +33,7 @@ import { AIToolsService, type AIToolItem } from '../../services/ai-tools.service
<!-- Section Tests (dev-only) -->
<section *ngIf="env.features.showTestSection" class="border-b border-border dark:border-gray-800">
<button class="w-full flex items-center justify-between px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card"
(click)="open.tests = !open.tests">
(click)="toggleTests()">
<span class="flex items-center gap-2">🧪 <span>Section Tests</span></span>
<span class="text-xs text-muted">{{ open.tests ? '▾' : '▸' }}</span>
</button>
@@ -41,17 +41,22 @@ import { AIToolsService, type AIToolItem } from '../../services/ai-tools.service
<button
(click)="onMarkdownPlaygroundClick()"
class="w-full text-left block text-sm px-2 py-1.5 rounded hover:bg-surface1 dark:hover:bg-card text-main dark:text-main hover:text-main dark:hover:text-gray-100">
Markdown Playground
📝 Markdown Playground
</button>
<button
(click)="onApiTestsPanelClick()"
class="mt-1 w-full text-left block text-sm px-2 py-1.5 rounded hover:bg-surface1 dark:hover:bg-card text-main dark:text-main hover:text-main dark:hover:text-gray-100">
API Tests Panel
🧠 API Tests Panel
</button>
<button
(click)="onNimbusEditorClick()"
class="mt-1 w-full text-left block text-sm px-2 py-1.5 rounded hover:bg-surface1 dark:hover:bg-card text-main dark:text-main hover:text-main dark:hover:text-gray-100">
📑 Éditeur Nimbus
</button>
<button
(click)="testsExcalidrawSelected.emit()"
class="mt-1 w-full text-left block text-sm px-2 py-1.5 rounded hover:bg-surface1 dark:hover:bg-card text-main dark:text-main hover:text-main dark:hover:text-gray-100">
Test Excalidraw
🖼️ Test Excalidraw
</button>
</div>
</section>
@@ -241,6 +246,7 @@ export class NimbusSidebarComponent implements OnChanges {
@Output() quickLinkSelected = new EventEmitter<string>();
@Output() markdownPlaygroundSelected = new EventEmitter<void>();
@Output() testsPanelSelected = new EventEmitter<void>();
@Output() nimbusEditorSelected = new EventEmitter<void>();
@Output() testsExcalidrawSelected = new EventEmitter<void>();
@Output() helpPageSelected = new EventEmitter<void>();
@Output() aboutSelected = new EventEmitter<void>();
@@ -304,6 +310,16 @@ export class NimbusSidebarComponent implements OnChanges {
this.sidebar.open(which);
}
toggleTests(): void {
// Create a new object reference to ensure change detection updates the template
const current = this.open.tests;
this.open = { ...this.open, tests: !current };
}
onNimbusEditorClick(): void {
this.nimbusEditorSelected.emit();
}
onCreateFolderAtRoot(): void {
// If not yet rendered, open the section first, then defer action
if (!this.open.folders) {
@@ -0,0 +1,64 @@
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { EditorShellComponent } from '../../../editor/components/editor-shell/editor-shell.component';
import { ExportService } from '../../../editor/services/export/export.service';
import { DocumentService } from '../../../editor/services/document.service';
@Component({
selector: 'app-nimbus-editor-page',
standalone: true,
imports: [CommonModule, EditorShellComponent],
template: `
<div class="flex flex-col h-screen min-h-0 overflow-hidden bg-card dark:bg-main">
<!-- Top actions bar -->
<div class="flex items-center justify-between px-4 py-2 border-b border-border dark:border-gray-800">
<div class="text-sm font-semibold">Éditeur Nimbus — Section Tests</div>
<div class="flex items-center gap-2">
<button
type="button"
class="px-3 py-1.5 text-sm rounded bg-red-600 text-white hover:bg-red-500"
title="Effacer tous les éléments et repartir d'une page vide"
(click)="clearDocument()"
>
Effacer la page
</button>
</div>
</div>
<div class="flex-1 min-h-0 overflow-auto">
<app-editor-shell />
</div>
</div>
`,
styles: [`
:host {
display: block;
overflow-x: hidden;
}
`]
})
export class NimbusEditorPageComponent {
private readonly exportService = inject(ExportService);
private readonly documentService = inject(DocumentService);
async exportAs(format: 'md' | 'html' | 'json'): Promise<void> {
try {
const doc = this.documentService.doc();
const content = await this.exportService.export(format, doc);
const filename = `${doc.title || 'document'}.${format}`;
this.exportService.download(content, filename);
console.log(`✓ Exported as ${format.toUpperCase()}`);
} catch (error) {
console.error('Export failed:', error);
}
}
clearDocument(): void {
if (confirm('Clear the document and start fresh?')) {
this.documentService.clearLocalStorage();
this.documentService.createNew('New Document');
}
}
}
+5
View File
@@ -1,6 +1,7 @@
import { Routes } from '@angular/router';
import { MarkdownPlaygroundComponent } from './markdown-playground/markdown-playground.component';
import { TestsPanelComponent } from './tests-panel.component';
import { NimbusEditorPageComponent } from './nimbus-editor/nimbus-editor-page.component';
export const TESTS_ROUTES: Routes = [
{
@@ -11,6 +12,10 @@ export const TESTS_ROUTES: Routes = [
path: 'panel',
component: TestsPanelComponent
},
{
path: 'nimbus-editor',
component: NimbusEditorPageComponent
},
{
path: '',
pathMatch: 'full',
@@ -17,6 +17,7 @@ import { QuickLinksComponent } from '../../features/quick-links/quick-links.comp
import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrollable-overlay.directive';
import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playground/markdown-playground.component';
import { TestsPanelComponent } from '../../features/tests/tests-panel.component';
import { NimbusEditorPageComponent } from '../../features/tests/nimbus-editor/nimbus-editor-page.component';
import { TestExcalidrawPageComponent } from '../../features/tests/test-excalidraw-page.component';
import { ParametersPage } from '../../features/parameters/parameters.page';
import { AboutPanelComponent } from '../../features/about/about-panel.component';
@@ -34,12 +35,12 @@ import { KeyboardShortcutsService } from '../../services/keyboard-shortcuts.serv
@Component({
selector: 'app-shell-nimbus-layout',
standalone: true,
imports: [CommonModule, FileExplorerComponent, NoteViewerComponent, AppBottomNavigationComponent, AppSidebarDrawerComponent, AppTocOverlayComponent, SwipeNavDirective, PaginatedNotesListComponent, NimbusSidebarComponent, QuickLinksComponent, ScrollableOverlayDirective, MarkdownPlaygroundComponent, TestsPanelComponent, TestExcalidrawPageComponent, ParametersPage, AboutPanelComponent, NoteInfoModalComponent, InPageSearchOverlayComponent, GeminiPanelComponent],
imports: [CommonModule, FileExplorerComponent, NoteViewerComponent, AppBottomNavigationComponent, AppSidebarDrawerComponent, AppTocOverlayComponent, SwipeNavDirective, PaginatedNotesListComponent, NimbusSidebarComponent, QuickLinksComponent, ScrollableOverlayDirective, MarkdownPlaygroundComponent, TestsPanelComponent, TestExcalidrawPageComponent, ParametersPage, AboutPanelComponent, NoteInfoModalComponent, InPageSearchOverlayComponent, GeminiPanelComponent, NimbusEditorPageComponent],
template: `
<div class="relative h-screen flex flex-col bg-card dark:bg-main text-main dark:text-gray-100" [style.--sidebar-width.px]="isSidebarOpen ? leftSidebarWidth : 64">
<!-- Fullscreen overlay for note -->
<div *ngIf="noteFullScreen && selectedNote && activeView !== 'markdown-playground' && activeView !== 'tests-excalidraw'" class="absolute inset-0 z-50 flex flex-col bg-card dark:bg-main">
<div *ngIf="noteFullScreen && selectedNote && activeView !== 'markdown-playground' && activeView !== 'tests-excalidraw' && activeView !== 'nimbus-editor'" class="absolute inset-0 z-50 flex flex-col bg-card dark:bg-main">
<div class="note-content-area flex-1 overflow-y-auto" appScrollableOverlay>
<app-note-viewer
[note]="selectedNote || null"
@@ -84,6 +85,7 @@ import { KeyboardShortcutsService } from '../../services/keyboard-shortcuts.serv
(quickLinkSelected)="onQuickLink($event)"
(markdownPlaygroundSelected)="onMarkdownPlaygroundSelected()"
(testsPanelSelected)="onTestsPanelSelected()"
(nimbusEditorSelected)="onNimbusEditorSelected()"
(testsExcalidrawSelected)="onTestsExcalidrawSelected()"
(helpPageSelected)="onHelpPageSelected()"
(aboutSelected)="onAboutSelected()"
@@ -161,6 +163,7 @@ import { KeyboardShortcutsService } from '../../services/keyboard-shortcuts.serv
<div *ngSwitchCase="'tests'" class="p-3">
<button type="button" class="w-full text-left flex items-center gap-2 px-3 py-2 text-sm rounded-lg hover:bg-surface1 dark:hover:bg-card" (click)="onTestsPanelSelected(); $event.stopPropagation(); hoveredFlyout = null">🔬 <span>API Tests Panel</span></button>
<button type="button" class="w-full text-left flex items-center gap-2 px-3 py-2 text-sm rounded-lg hover:bg-surface1 dark:hover:bg-card" (click)="onMarkdownPlaygroundSelected(); $event.stopPropagation(); hoveredFlyout = null">📝 <span>Markdown Playground</span></button>
<button type="button" class="w-full text-left flex items-center gap-2 px-3 py-2 text-sm rounded-lg hover:bg-surface1 dark:hover:bg-card" (click)="onNimbusEditorSelected(); $event.stopPropagation(); hoveredFlyout = null">🧠 <span>Éditeur Nimbus</span></button>
<button type="button" class="w-full text-left flex items-center gap-2 px-3 py-2 text-sm rounded-lg hover:bg-surface1 dark:hover:bg-card" (click)="onTestsExcalidrawSelected(); $event.stopPropagation(); hoveredFlyout = null">🎨 <span>Test Excalidraw</span></button>
</div>
<div *ngSwitchCase="'playground'" class="p-3">
@@ -198,12 +201,13 @@ import { KeyboardShortcutsService } from '../../services/keyboard-shortcuts.serv
<!-- Note View + ToC -->
<section class="flex-1 relative min-w-0 flex">
<div #pageRoot class="note-content-area flex-1 overflow-y-auto" appScrollableOverlay>
<div #pageRoot class="note-content-area flex-1 overflow-y-auto overflow-x-hidden" appScrollableOverlay>
<app-markdown-playground *ngIf="activeView === 'markdown-playground'"></app-markdown-playground>
<app-nimbus-editor-page *ngIf="activeView === 'nimbus-editor'"></app-nimbus-editor-page>
<app-parameters *ngIf="activeView === 'parameters'"></app-parameters>
<app-tests-panel *ngIf="activeView === 'tests-panel'"></app-tests-panel>
<app-test-excalidraw-page *ngIf="activeView === 'tests-excalidraw'"></app-test-excalidraw-page>
<app-note-viewer *ngIf="activeView !== 'markdown-playground' && activeView !== 'parameters' && activeView !== 'tests-panel' && activeView !== 'tests-excalidraw'"
<app-note-viewer *ngIf="activeView !== 'markdown-playground' && activeView !== 'parameters' && activeView !== 'tests-panel' && activeView !== 'tests-excalidraw' && activeView !== 'nimbus-editor'"
[note]="selectedNote || null"
[noteHtmlContent]="renderedNoteContent"
[allNotes]="vault.allNotes()"
@@ -277,8 +281,9 @@ import { KeyboardShortcutsService } from '../../services/keyboard-shortcuts.serv
(clearQuickLinkFilter)="onClearQuickLinkFilter()"
></app-paginated-notes-list>
</div>
<div [hidden]="mobileNav.activeTab() !== 'page'" class="note-content-area h-full overflow-y-auto" appScrollableOverlay>
<div [hidden]="mobileNav.activeTab() !== 'page'" class="note-content-area h-full overflow-y-auto overflow-x-hidden" appScrollableOverlay>
<app-markdown-playground *ngIf="activeView === 'markdown-playground'"></app-markdown-playground>
<app-nimbus-editor-page *ngIf="activeView === 'nimbus-editor'"></app-nimbus-editor-page>
<app-parameters *ngIf="activeView === 'parameters'"></app-parameters>
<app-tests-panel *ngIf="activeView === 'tests-panel'"></app-tests-panel>
<app-test-excalidraw-page *ngIf="activeView === 'tests-excalidraw'"></app-test-excalidraw-page>
@@ -301,6 +306,7 @@ import { KeyboardShortcutsService } from '../../services/keyboard-shortcuts.serv
(quickLinkSelected)="onQuickLink($event)"
(markdownPlaygroundSelected)="onMarkdownPlaygroundSelected()"
(testsPanelSelected)="onTestsPanelSelected()"
(nimbusEditorSelected)="onNimbusEditorSelected()"
(testsExcalidrawSelected)="onTestsExcalidrawSelected()"
(helpPageSelected)="onHelpPageSelected()"
(aboutSelected)="onAboutSelected()"
@@ -332,6 +338,10 @@ import { KeyboardShortcutsService } from '../../services/keyboard-shortcuts.serv
<div class="note-content-area h-full animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
<app-markdown-playground></app-markdown-playground>
</div>
} @else if (activeView === 'nimbus-editor') {
<div class="note-content-area h-full animate-fadeIn overflow-x-hidden" style="overflow-y: auto !important;" appScrollableOverlay>
<app-nimbus-editor-page></app-nimbus-editor-page>
</div>
} @else if (activeView === 'tests-panel') {
<div class="note-content-area h-full animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
<app-tests-panel></app-tests-panel>
@@ -444,6 +454,15 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
search: ''
});
// --- Actions: Tests/Nimbus Editor ---
onNimbusEditorSelected(): void {
this.activeView = 'nimbus-editor';
if (!this.responsive.isDesktop()) {
this.mobileNav.setActiveTab('page');
}
this.hoveredFlyout = null;
}
// Signal pour forcer un recalcul de la liste filtrée
private filterChangeCounter = signal(0);
+97
View File
@@ -0,0 +1,97 @@
{
"id": "demo-doc",
"title": "Welcome to Nimbus Editor 🧠",
"blocks": [
{
"id": "block_1",
"type": "paragraph",
"props": {
"text": "This is a powerful block-based editor inspired by Fusebase/Nimbus. Try creating different types of blocks using the / command or the toolbar above."
},
"meta": {
"createdAt": "2025-01-04T00:00:00Z",
"updatedAt": "2025-01-04T00:00:00Z"
}
},
{
"id": "block_2",
"type": "heading",
"props": {
"level": 2,
"text": "Features"
},
"meta": {
"createdAt": "2025-01-04T00:00:00Z",
"updatedAt": "2025-01-04T00:00:00Z"
}
},
{
"id": "block_3",
"type": "list",
"props": {
"kind": "check",
"items": [
{
"id": "item_1",
"text": "15+ block types (paragraph, heading, list, code, table, kanban...)",
"checked": true
},
{
"id": "item_2",
"text": "Slash menu (/) for quick block insertion",
"checked": true
},
{
"id": "item_3",
"text": "Keyboard shortcuts (Ctrl+Alt+1/2/3 for headings)",
"checked": true
},
{
"id": "item_4",
"text": "Export to Markdown, HTML, JSON",
"checked": true
},
{
"id": "item_5",
"text": "Auto-save to localStorage",
"checked": true
}
]
},
"meta": {
"createdAt": "2025-01-04T00:00:00Z",
"updatedAt": "2025-01-04T00:00:00Z"
}
},
{
"id": "block_4",
"type": "hint",
"props": {
"variant": "info",
"text": "Press / to open the command palette and explore all available block types!"
},
"meta": {
"createdAt": "2025-01-04T00:00:00Z",
"updatedAt": "2025-01-04T00:00:00Z"
}
},
{
"id": "block_5",
"type": "code",
"props": {
"lang": "typescript",
"code": "// Example TypeScript code\nconst greeting = (name: string) => {\n return `Hello, ${name}!`;\n};\n\nconsole.log(greeting('Nimbus'));"
},
"meta": {
"createdAt": "2025-01-04T00:00:00Z",
"updatedAt": "2025-01-04T00:00:00Z"
}
}
],
"meta": {
"authors": ["ObsiViewer Team"],
"tags": ["demo", "nimbus", "editor"],
"createdAt": "2025-01-04T00:00:00Z",
"updatedAt": "2025-01-04T00:00:00Z"
}
}
+11
View File
@@ -18,6 +18,17 @@
padding: 0 0.125rem;
}
/* Elevate Angular CDK overlays above comment panels and other UI */
.cdk-overlay-container,
.cdk-global-overlay-wrapper {
z-index: 10000 !important;
}
/* Ensure our contextual menus sit on top if multiple overlays coexist */
.nimbus-menu-panel {
z-index: 10001 !important;
}
.dark .ov-mark {
background-color: rgba(253, 224, 71, 0.3);
outline-color: rgba(253, 224, 71, 0.4);
+14
View File
@@ -28,3 +28,17 @@
@media (prefers-reduced-motion: reduce) {
.toc-link { transition: none !important; }
}
/* Highlight animation for editor headings when clicked from TOC */
.toc-highlight {
animation: tocHighlightPulse 1.5s ease-out;
}
@keyframes tocHighlightPulse {
0% {
background-color: color-mix(in oklab, var(--toc-active) 20%, transparent);
}
100% {
background-color: transparent;
}
}