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:
@@ -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); }
|
||||
}
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+5
@@ -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; }
|
||||
+67
@@ -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>
|
||||
+360
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user