feat: add collapsible block type and enhance paragraph menu UX

- Added CollapsibleBlockComponent with registration in block host and columns
- Enhanced paragraph block with inline "+" button for column layouts and improved menu positioning
- Updated toggle block to support creating new toggle blocks below with dedicated event handlers
- Improved menu styling with fixed positioning, keyboard navigation (arrows, Enter, Escape), and focus management
- Simplified inline action handling by deleg
This commit is contained in:
2025-11-14 22:29:11 -05:00
parent 85d021b154
commit 9887be548e
21 changed files with 871 additions and 335 deletions
@@ -34,6 +34,7 @@ import { EmbedBlockComponent } from './blocks/embed-block.component';
import { OutlineBlockComponent } from './blocks/outline-block.component';
import { LineBlockComponent } from './blocks/line-block.component';
import { ColumnsBlockComponent } from './blocks/columns-block.component';
import { CollapsibleBlockComponent } from './blocks/collapsible-block.component';
/**
* Block host component - routes to specific block type
@@ -64,6 +65,7 @@ import { ColumnsBlockComponent } from './blocks/columns-block.component';
OutlineBlockComponent,
LineBlockComponent,
ColumnsBlockComponent,
CollapsibleBlockComponent,
OverlayModule,
PortalModule
],
@@ -152,7 +154,12 @@ import { ColumnsBlockComponent } from './blocks/columns-block.component';
<app-hint-block [block]="block" (update)="onBlockUpdate($event)" />
}
@case ('toggle') {
<app-toggle-block [block]="block" (update)="onBlockUpdate($event)" />
<app-toggle-block
[block]="block"
(update)="onBlockUpdate($event)"
(createBlock)="onCreateToggleBelow()"
(createParagraph)="onCreateBlockBelow()"
/>
}
@case ('dropdown') {
<app-dropdown-block [block]="block" (update)="onBlockUpdate($event)" />
@@ -178,6 +185,9 @@ import { ColumnsBlockComponent } from './blocks/columns-block.component';
@case ('columns') {
<app-columns-block [block]="block" (update)="onBlockUpdate($event)" />
}
@case ('collapsible') {
<app-collapsible-block [block]="block" (update)="onBlockUpdate($event)" />
}
}
</div>
<ng-container *ngIf="block.type !== 'table'">
@@ -969,6 +979,17 @@ export class BlockHostComponent implements OnDestroy {
}, 50);
}
onCreateToggleBelow(): void {
// Create a new Toggle block right below current block
const preset = this.documentService.getDefaultProps('toggle');
const newBlock = this.documentService.createBlock('toggle', preset);
this.documentService.insertBlock(this.block.id, newBlock);
setTimeout(() => {
const el = document.querySelector(`[data-block-id="${newBlock.id}"] [contenteditable]`) as HTMLElement | null;
el?.focus();
}, 50);
}
onDeleteBlock(): void {
// Delete current block
this.documentService.deleteBlock(this.block.id);
@@ -10,7 +10,7 @@ export interface BlockMenuAction {
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">
<div class="flex items-center gap-1 px-3 py-2 bg-surface1 rounded-2xl shadow-surface-md border border-app">
<!-- Edit/Text -->
<button
class="p-1.5 rounded hover:bg-gray-700 text-gray-400 hover:text-gray-200 transition-colors"
@@ -46,7 +46,8 @@ export interface InlineToolbarAction {
<ng-content />
</div>
<div class="flex items-center gap-1 select-none">
<!-- Inline icons are only shown when the line is empty (initial prompt state) -->
<div class="flex items-center gap-1 select-none" *ngIf="isEmpty()">
<!-- Use AI -->
<button *ngIf="!actions || actions.includes('use-ai')"
class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
@@ -0,0 +1,224 @@
import { Component, Input, Output, EventEmitter, inject, ViewChild, ElementRef, AfterViewInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Block } from '../../../core/models/block.model';
import { CollapsibleProps } from '../../../core/models/block.model';
import { DocumentService } from '../../../services/document.service';
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';
@Component({
selector: 'app-collapsible-block',
standalone: true,
imports: [
CommonModule,
ParagraphBlockComponent,
HeadingBlockComponent,
ListItemBlockComponent,
CodeBlockComponent,
QuoteBlockComponent,
ToggleBlockComponent,
HintBlockComponent,
ButtonBlockComponent,
ImageBlockComponent,
FileBlockComponent,
TableBlockComponent,
StepsBlockComponent,
LineBlockComponent,
DropdownBlockComponent,
ProgressBlockComponent,
KanbanBlockComponent,
EmbedBlockComponent,
OutlineBlockComponent,
],
template: `
<div class="rounded-md">
<div class="flex items-center gap-2 px-1 py-1 group" (click)="onHeaderClick($event)">
<!-- Arrow -->
<button type="button" class="p-1 rounded hover:bg-surface2" (click)="toggle($event)" title="Expand/Collapse">
<svg class="w-4 h-4 transition-transform" [class.-rotate-90]="isCollapsed()" [class.rotate-0]="!isCollapsed()"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.06l3.71-3.83a.75.75 0 011.08 1.04l-4.25 4.4a.75.75 0 01-1.08 0l-4.25-4.4a.75.75 0 01.02-1.06z"
clip-rule="evenodd" />
</svg>
</button>
<!-- Title styled like heading level -->
<ng-container [ngSwitch]="props.level">
<h1 *ngSwitchCase="1" #editable contenteditable="true"
class="flex-1 font-bold text-xl leading-tight bg-transparent outline-none px-1"
(input)="onTitleInput($event)" (keydown)="onTitleKeyDown($event)"
placeholder="Collapsible Large Heading"></h1>
<h2 *ngSwitchCase="2" #editable contenteditable="true"
class="flex-1 font-semibold text-lg leading-tight bg-transparent outline-none px-1"
(input)="onTitleInput($event)" (keydown)="onTitleKeyDown($event)"
placeholder="Collapsible Medium Heading"></h2>
<h3 *ngSwitchCase="3" #editable contenteditable="true"
class="flex-1 font-semibold text-base leading-tight bg-transparent outline-none px-1"
(input)="onTitleInput($event)" (keydown)="onTitleKeyDown($event)"
placeholder="Collapsible Small Heading"></h3>
</ng-container>
</div>
<div *ngIf="!isCollapsed()" class="pl-6">
<div class="flex flex-col gap-0.5 py-0.5">
<ng-container *ngFor="let child of props.content; let i = index; trackBy: trackById">
<div class="group/row relative pr-10">
<div class="absolute -left-6 top-1/2 -translate-y-1/2 opacity-0 group-hover/row:opacity-100 transition-opacity flex gap-1">
<button type="button" class="p-1 rounded bg-surface2 hover:bg-surface3" title="Add below" (click)="insertParagraphBelow(i)">
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path d="M10 4c.41 0 .75.34.75.75V9.25h4.5a.75.75 0 010 1.5h-4.5v4.5a.75.75 0 01-1.5 0v-4.5H4.75a.75.75 0 010-1.5h4.5V4.75c0-.41.34-.75.75-.75z"/></svg>
</button>
<button type="button" class="p-1 rounded bg-surface2 hover:bg-surface3" title="Delete" (click)="removeChild(i)">
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8.5 3a1 1 0 00-1 1V5H5a.75.75 0 000 1.5h10A.75.75 0 0015 5h-2.5V4a1 1 0 00-1-1h-3zM6.75 8.25a.75.75 0 011.5 0v6a.75.75 0 01-1.5 0v-6zm5 0a.75.75 0 011.5 0v6a.75.75 0 01-1.5 0v-6z" clip-rule="evenodd"/></svg>
</button>
</div>
<ng-container [ngSwitch]="child.type">
<app-heading-block *ngSwitchCase="'heading'" [block]="child"
(update)="onChildUpdate(i, $event)"
(metaChange)="onChildMeta(i, $event)"
(createBlock)="insertParagraphBelow(i)"
(deleteBlock)="removeChild(i)" />
<app-paragraph-block *ngSwitchCase="'paragraph'" [block]="child"
(update)="onChildUpdate(i, $event)"
(metaChange)="onChildMeta(i, $event)"
(createBlock)="insertParagraphBelow(i)"
(deleteBlock)="removeChild(i)" />
<app-list-item-block *ngSwitchCase="'list-item'" [block]="child" (update)="onChildUpdate(i, $event)" />
<app-code-block *ngSwitchCase="'code'" [block]="child" (update)="onChildUpdate(i, $event)" />
<app-quote-block *ngSwitchCase="'quote'" [block]="child" (update)="onChildUpdate(i, $event)" />
<app-toggle-block *ngSwitchCase="'toggle'" [block]="child" (update)="onChildUpdate(i, $event)" />
<app-hint-block *ngSwitchCase="'hint'" [block]="child" (update)="onChildUpdate(i, $event)" />
<app-button-block *ngSwitchCase="'button'" [block]="child" (update)="onChildUpdate(i, $event)" />
<app-image-block *ngSwitchCase="'image'" [block]="child" (update)="onChildUpdate(i, $event)" />
<app-file-block *ngSwitchCase="'file'" [block]="child" (update)="onChildUpdate(i, $event)" />
<app-table-block *ngSwitchCase="'table'" [block]="child" (update)="onChildUpdate(i, $event)" />
<app-steps-block *ngSwitchCase="'steps'" [block]="child" (update)="onChildUpdate(i, $event)" />
<app-line-block *ngSwitchCase="'line'" [block]="child" />
<app-dropdown-block *ngSwitchCase="'dropdown'" [block]="child" (update)="onChildUpdate(i, $event)" />
<app-progress-block *ngSwitchCase="'progress'" [block]="child" (update)="onChildUpdate(i, $event)" />
<app-kanban-block *ngSwitchCase="'kanban'" [block]="child" (update)="onChildUpdate(i, $event)" />
<app-embed-block *ngSwitchCase="'embed'" [block]="child" (update)="onChildUpdate(i, $event)" />
<app-outline-block *ngSwitchCase="'outline'" [block]="child" />
</ng-container>
</div>
</ng-container>
<div *ngIf="props.content.length === 0" class="text-xs text-text-muted pl-1 py-1">
Press Enter in the title to start writing…
</div>
</div>
</div>
</div>
`,
styles: [`
[contenteditable]:empty:before { content: attr(placeholder); color: var(--text-muted); }
`]
})
export class CollapsibleBlockComponent implements AfterViewInit {
@Input({ required: true }) block!: Block<CollapsibleProps>;
@Output() update = new EventEmitter<CollapsibleProps>();
@ViewChild('editable') editable?: ElementRef<HTMLElement>;
readonly isCollapsed = signal(true);
private readonly documentService = inject(DocumentService);
get props(): CollapsibleProps { return this.block.props; }
ngOnInit(): void {
this.isCollapsed.set(this.props.collapsed ?? true);
}
ngAfterViewInit(): void {
const el = this.editable?.nativeElement;
if (el) { el.textContent = this.props.title || ''; }
}
toggle(ev?: MouseEvent): void {
ev?.stopPropagation();
const next = !this.isCollapsed();
this.isCollapsed.set(next);
this.update.emit({ ...this.props, collapsed: next });
}
onHeaderClick(ev: MouseEvent): void {
// Avoid toggling by default; only arrow toggles
ev.stopPropagation();
}
onTitleInput(event: Event): void {
const target = event.target as HTMLElement;
this.update.emit({ ...this.props, title: target.textContent || '' });
}
onTitleKeyDown(event: KeyboardEvent): void {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
// Ensure open and create first inner paragraph, focus it
const opened = this.isCollapsed();
if (opened) {
this.isCollapsed.set(false);
}
if (!Array.isArray(this.props.content)) {
this.props.content = [];
}
if (this.props.content.length === 0) {
const p = this.documentService.createBlock('paragraph', { text: '' });
const updated = { ...this.props, content: [p], collapsed: false } as CollapsibleProps;
this.update.emit(updated);
setTimeout(() => {
const el = document.querySelector(`[data-block-id="${p.id}"] [contenteditable]`) as HTMLElement | null;
el?.focus();
}, 0);
} else {
// Focus the first existing child
setTimeout(() => {
const first = this.props.content[0];
const el = document.querySelector(`[data-block-id="${first.id}"] [contenteditable]`) as HTMLElement | null;
el?.focus();
}, 0);
}
}
}
trackById = (_: number, b: Block) => b.id;
onChildUpdate(index: number, patch: any): void {
const next = this.props.content.map((b, i) => i === index ? ({ ...b, props: { ...(b as any).props, ...patch } as any }) : b);
this.update.emit({ ...this.props, content: next });
}
onChildMeta(index: number, meta: any): void {
const next = this.props.content.map((b, i) => i === index ? ({ ...b, meta: { ...(b.meta || {}), ...meta } }) : b);
this.update.emit({ ...this.props, content: next });
}
insertParagraphBelow(index: number): void {
const p = this.documentService.createBlock('paragraph', { text: '' });
const next = [...this.props.content];
next.splice(index + 1, 0, p);
this.update.emit({ ...this.props, content: next });
setTimeout(() => {
const el = document.querySelector(`[data-block-id="${p.id}"] [contenteditable]`) as HTMLElement | null;
el?.focus();
}, 0);
}
removeChild(index: number): void {
const next = this.props.content.filter((_, i) => i !== index);
this.update.emit({ ...this.props, content: next });
}
}
@@ -13,6 +13,7 @@ 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 { CollapsibleBlockComponent } from './collapsible-block.component';
import { HintBlockComponent } from './hint-block.component';
import { ButtonBlockComponent } from './button-block.component';
import { ImageBlockComponent } from './image-block.component';
@@ -41,6 +42,7 @@ import { DragDropFilesDirective } from '../../../../blocks/file/directives/drag-
CodeBlockComponent,
QuoteBlockComponent,
ToggleBlockComponent,
CollapsibleBlockComponent,
HintBlockComponent,
ButtonBlockComponent,
ImageBlockComponent,
@@ -122,6 +124,7 @@ import { DragDropFilesDirective } from '../../../../blocks/file/directives/drag-
@case ('paragraph') {
<app-paragraph-block
[block]="block"
[inColumn]="true"
(update)="onBlockUpdate($event, block.id)"
(metaChange)="onBlockMetaChange($event, block.id)"
(createBlock)="onBlockCreateBelow(block.id, colIndex, blockIndex)"
@@ -140,6 +143,9 @@ import { DragDropFilesDirective } from '../../../../blocks/file/directives/drag-
@case ('toggle') {
<app-toggle-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('collapsible') {
<app-collapsible-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('hint') {
<app-hint-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@@ -6,9 +6,8 @@ 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';
import { PaletteCategory, PaletteItem, getPaletteItemsByCategory } from '../../../core/constants/palette-items';
import { FilePickerService } from '../../../../blocks/file/services/file-picker.service';
import { BlockInsertionService } from '../../../../blocks/file/services/block-insertion.service';
import { PaletteCategory, PaletteItem, getPaletteItemsByCategory, PALETTE_ITEMS } from '../../../core/constants/palette-items';
@Component({
selector: 'app-paragraph-block',
@@ -24,30 +23,52 @@ import { BlockInsertionService } from '../../../../blocks/file/services/block-in
[isFocused]="isFocused"
[isEmpty]="isEmpty"
[showDragHandle]="false"
[actions]="undefined"
[actions]="inColumn ? [] : undefined"
(action)="onInlineAction($event)"
>
<div
#editable
contenteditable="true"
class="m-0 inline-block 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 class="flex items-center gap-2">
<div
#editable
contenteditable="true"
class="m-0 inline-block 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]="inColumn ? columnPlaceholder : placeholder"
></div>
@if (inColumn) {
<button
type="button"
class="inline-flex items-center justify-center w-6 h-6 rounded-full border border-gray-500 text-gray-200 text-base leading-none hover:bg-gray-600 hover:border-gray-400 transition-colors select-none"
title="Add block"
(click)="onPlusClick($event)"
>
<span class="inline-block text-[14px] leading-none translate-y-[0.5px]">+</span>
</button>
}
</div>
</app-block-inline-toolbar>
<!-- Anchored dropdown menu (more) -->
@if (moreOpen()) {
<div class="absolute top-full right-0 w-[420px] max-h-[60vh] overflow-y-auto bg-[var(--menu-bg)] rounded-xl shadow-lg p-2 z-[999]" (mousedown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
<div
#menuPanel
class="fixed w-[420px] max-h-[60vh] overflow-y-auto bg-surface1 rounded-2xl shadow-surface-md border border-app p-2 z-[11000]"
[style.top.px]="menuTop()"
[style.left.px]="menuLeft()"
tabindex="-1"
(mousedown)="$event.stopPropagation()"
(click)="$event.stopPropagation()"
(keydown)="onMenuKeyDown($event)"
(focusout)="onMenuFocusOut($event)"
>
@for (category of categories; track category) {
<div>
<div class="sticky top-0 bg-[var(--menu-bg)] font-semibold text-xs text-gray-400 py-1 px-3 backdrop-blur">{{ category }}</div>
<div class="sticky top-0 bg-surface1 border-b border-app font-semibold text-xs text-gray-400 py-1 px-3">{{ category }}</div>
<div class="px-1 py-0.5">
@for (item of getItems(category); track item.id) {
<button type="button" class="flex items-center gap-2 w-full px-2 py-1.5 rounded hover:bg-neutral-700/60 transition-colors text-left"
<button type="button" class="flex items-center gap-2 w-full px-2 py-1.5 rounded hover:bg-surface2 transition-colors text-left"
(click)="selectItem(item)">
<span class="text-base w-5 flex items-center justify-center">
@if (item.id === 'checkbox-list') {
@@ -117,6 +138,8 @@ import { BlockInsertionService } from '../../../../blocks/file/services/block-in
export class ParagraphBlockComponent implements AfterViewInit {
@Input({ required: true }) block!: Block<ParagraphProps>;
@Input() showDragHandle = true; // Hide drag handle in columns
// When true, this paragraph is rendered inside a columns block and uses the "+" prompt variant
@Input() inColumn = false;
@Output() update = new EventEmitter<ParagraphProps>();
@Output() metaChange = new EventEmitter<any>();
@Output() createBlock = new EventEmitter<void>();
@@ -125,61 +148,39 @@ export class ParagraphBlockComponent implements AfterViewInit {
private documentService = inject(DocumentService);
private selectionService = inject(SelectionService);
private paletteService = inject(PaletteService);
private filePicker = inject(FilePickerService);
private blockInserter = inject(BlockInsertionService);
@ViewChild('editable', { static: true }) editable?: ElementRef<HTMLDivElement>;
@ViewChild('menuPanel') menuPanel?: ElementRef<HTMLDivElement>;
isFocused = signal(false);
isEmpty = signal(true);
placeholder = "Start writing or type '/', '@'";
columnPlaceholder = "Type '/' or add +";
moreOpen = signal(false);
menuTop = signal(0);
menuLeft = signal(0);
categories: PaletteCategory[] = ['BASIC','ADVANCED','MEDIA','INTEGRATIONS','VIEW','TEMPLATES','HELPFUL LINKS'];
onInlineAction(type: any): void {
if (type === 'more' || type === 'menu') {
this.moreOpen.set(!this.moreOpen());
this.openMenu();
return;
}
const id = this.block.id;
const map: Record<string, string> = {
'checkbox-list': 'checkbox-list',
'bullet-list': 'bullet-list',
'numbered-list': 'numbered-list',
'table': 'table',
'image': 'image',
'file': 'file',
'heading-2': 'heading-2',
};
const id = map[type];
if (!id) return;
switch (type) {
case 'checkbox-list': {
this.documentService.convertBlock(id, 'list-item' as any);
this.documentService.updateBlockProps(id, { kind: 'check', checked: false, text: '' });
break;
}
case 'bullet-list': {
this.documentService.convertBlock(id, 'list-item' as any);
this.documentService.updateBlockProps(id, { kind: 'bullet', text: '' });
break;
}
case 'numbered-list': {
this.documentService.convertBlock(id, 'list-item' as any);
this.documentService.updateBlockProps(id, { kind: 'numbered', number: 1, text: '' });
break;
}
case 'table': {
this.documentService.convertBlock(id, 'table');
break;
}
case 'image': {
this.documentService.convertBlock(id, 'image');
break;
}
case 'file': {
this.handleFileInsertion(id);
return;
}
case 'heading-2': {
this.documentService.convertBlock(id, 'heading');
this.documentService.updateBlockProps(id, { level: 2 });
break;
}
case 'use-ai':
case 'new-page':
default:
break;
}
const item = PALETTE_ITEMS.find(i => i.id === id);
if (!item) return;
try { this.selectionService.setActive(this.block.id); } catch {}
this.paletteService.applySelection(item);
}
getItems(category: PaletteCategory): PaletteItem[] {
@@ -187,67 +188,9 @@ export class ParagraphBlockComponent implements AfterViewInit {
}
selectItem(item: PaletteItem): void {
const id = this.block.id;
if (!id) return;
switch (item.id) {
case 'heading-1':
case 'heading-2':
case 'heading-3': {
this.documentService.convertBlock(id, 'heading');
const level = item.id === 'heading-1' ? 1 : item.id === 'heading-2' ? 2 : 3;
this.documentService.updateBlockProps(id, { level });
break;
}
case 'bullet-list': {
this.documentService.convertBlock(id, 'list');
this.documentService.updateBlockProps(id, { kind: 'bullet' });
break;
}
case 'numbered-list': {
this.documentService.convertBlock(id, 'list');
this.documentService.updateBlockProps(id, { kind: 'numbered' });
break;
}
case 'checkbox-list': {
this.documentService.convertBlock(id, 'list');
this.documentService.updateBlockProps(id, { kind: 'check' });
break;
}
case 'table': {
this.documentService.convertBlock(id, 'table');
break;
}
case 'image': {
this.documentService.convertBlock(id, 'image');
break;
}
case 'file': {
this.handleFileInsertion(id);
return;
}
case 'paragraph': {
this.documentService.convertBlock(id, 'paragraph');
break;
}
case 'code': {
this.documentService.convertBlock(id, 'code');
break;
}
case 'quote': {
this.documentService.convertBlock(id, 'quote');
break;
}
case 'line': {
this.documentService.convertBlock(id, 'line');
break;
}
default: {
// Fallback to convert by type
this.documentService.convertBlock(id, item.type as any);
}
}
try { this.selectionService.setActive(this.block.id); } catch {}
this.paletteService.applySelection(item);
this.moreOpen.set(false);
// Keep focus on the same editable after conversion (if still present)
setTimeout(() => this.editable?.nativeElement?.focus(), 0);
}
@@ -269,6 +212,13 @@ export class ParagraphBlockComponent implements AfterViewInit {
this.isEmpty.set(!(target.textContent && target.textContent.length > 0));
}
onPlusClick(event: MouseEvent): void {
// Do not trigger container click / focus moves
event.stopPropagation();
event.preventDefault();
this.openMenu();
}
onKeyDown(event: KeyboardEvent): void {
// Handle TAB: Increase indent
if (event.key === 'Tab' && !event.shiftKey) {
@@ -295,7 +245,7 @@ export class ParagraphBlockComponent implements AfterViewInit {
// Only trigger if "/" is at start or after space
if (text.length === 0 || text.endsWith(' ')) {
event.preventDefault();
this.moreOpen.set(true);
this.openMenu();
return;
}
}
@@ -306,7 +256,7 @@ export class ParagraphBlockComponent implements AfterViewInit {
const text = target.textContent || '';
if (text.length === 0 || text.endsWith(' ')) {
event.preventDefault();
this.moreOpen.set(true);
this.openMenu();
return;
}
}
@@ -330,7 +280,7 @@ export class ParagraphBlockComponent implements AfterViewInit {
const selection = window.getSelection();
if (selection && selection.anchorOffset === 0 && (!target.textContent || target.textContent.length === 0)) {
event.preventDefault();
this.moreOpen.set(true);
this.openMenu();
return;
}
}
@@ -370,6 +320,54 @@ export class ParagraphBlockComponent implements AfterViewInit {
if (el) this.isEmpty.set(!(el.textContent && el.textContent.length > 0));
}
onMenuKeyDown(event: KeyboardEvent): void {
if (!this.menuPanel) return;
const panel = this.menuPanel.nativeElement;
if (event.key === 'Escape') {
event.preventDefault();
this.moreOpen.set(false);
return;
}
const items = Array.from(panel.querySelectorAll<HTMLButtonElement>('button[type="button"]'));
if (!items.length) return;
const active = document.activeElement as HTMLElement | null;
let index = items.findIndex(btn => btn === active);
if (event.key === 'ArrowDown') {
event.preventDefault();
index = index < 0 ? 0 : Math.min(items.length - 1, index + 1);
items[index].focus();
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
index = index < 0 ? items.length - 1 : Math.max(0, index - 1);
items[index].focus();
return;
}
if (event.key === 'Enter') {
// Let the focused button handle the click
if (active && active.tagName === 'BUTTON') {
event.preventDefault();
(active as HTMLButtonElement).click();
}
}
}
onMenuFocusOut(event: FocusEvent): void {
if (!this.menuPanel) return;
const panel = this.menuPanel.nativeElement;
const related = event.relatedTarget as HTMLElement | null;
if (!related || !panel.contains(related)) {
this.moreOpen.set(false);
}
}
onContainerClick(event: MouseEvent): void {
// Ignore clicks on buttons/icons to avoid stealing clicks
const target = event.target as HTMLElement;
@@ -400,18 +398,19 @@ export class ParagraphBlockComponent implements AfterViewInit {
}, 0);
}
private handleFileInsertion(blockId: string): void {
this.filePicker.pick({ multiple: true, accept: '*/*' }).then(async files => {
if (!files.length) return;
const blocks = this.documentService.blocks();
const insertIndex = blocks.findIndex(b => b.id === blockId);
if (insertIndex < 0) return;
this.documentService.deleteBlock(blockId);
const created = await this.blockInserter.createFromFiles(files, insertIndex);
if (created.length) {
this.selectionService.setActive(created[created.length - 1]);
}
});
private openMenu(): void {
this.moreOpen.set(true);
try { this.selectionService.setActive(this.block.id); } catch {}
// Compute viewport position near the editable content
setTimeout(() => {
const el = this.editable?.nativeElement;
const rect = el?.getBoundingClientRect();
const top = (rect?.bottom ?? 0) + 8;
const left = Math.max(8, Math.min((rect?.left ?? 0), window.innerWidth - 440));
this.menuTop.set(Math.round(top));
this.menuLeft.set(Math.round(left));
try { this.menuPanel?.nativeElement.focus(); } catch {}
}, 0);
}
}
@@ -1,38 +1,103 @@
import { Component, Input, Output, EventEmitter, signal, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
import { Component, Input, Output, EventEmitter, signal, ViewChild, ElementRef, AfterViewInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Block, ToggleProps } from '../../../core/models/block.model';
import { DocumentService } from '../../../services/document.service';
// Render nested blocks (subset similar to Columns)
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 { 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';
@Component({
selector: 'app-toggle-block',
standalone: true,
imports: [CommonModule],
imports: [
CommonModule,
ParagraphBlockComponent,
HeadingBlockComponent,
ListItemBlockComponent,
CodeBlockComponent,
QuoteBlockComponent,
HintBlockComponent,
ButtonBlockComponent,
ImageBlockComponent,
FileBlockComponent,
TableBlockComponent,
StepsBlockComponent,
LineBlockComponent,
DropdownBlockComponent,
ProgressBlockComponent,
KanbanBlockComponent,
EmbedBlockComponent,
],
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 class="w-full flex items-center gap-1.5 px-2 py-1.5 text-left bg-transparent transition-none">
<button type="button" class="p-1 rounded hover:bg-surface2" (click)="toggle()" title="Expand/Collapse">
<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>
</button>
<div
#editable
contenteditable="true"
class="flex-1 focus:outline-none font-semibold text-sm leading-5"
class="flex-1 focus:outline-none text-sm leading-5"
(input)="onTitleInput($event)"
(keydown)="onTitleKeyDown($event)"
(click)="$event.stopPropagation()"
placeholder="Toggle title..."
></div>
</button>
</div>
@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 class="pl-6 pr-3 py-0.5">
<ng-container *ngFor="let child of props.content; let i = index; trackBy: trackById">
<div class="group/row relative pr-10">
<div class="absolute -left-6 top-1/2 -translate-y-1/2 opacity-0 group-hover/row:opacity-100 transition-opacity flex gap-1">
<button type="button" class="p-1 rounded bg-surface2 hover:bg-surface3" title="Add below" (click)="insertParagraphBelow(i)">
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path d="M10 4c.41 0 .75.34.75.75V9.25h4.5a.75.75 0 010 1.5h-4.5v4.5a.75.75 0 01-1.5 0v-4.5H4.75a.75.75 0 010-1.5h4.5V4.75c0-.41.34-.75.75-.75z"/></svg>
</button>
<button type="button" class="p-1 rounded bg-surface2 hover:bg-surface3" title="Delete" (click)="removeChild(i)">
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8.5 3a1 1 0 00-1 1V5H5a.75.75 0 000 1.5h10A.75.75 0 0015 5h-2.5V4a1 1 0 00-1-1h-3zM6.75 8.25a.75.75 0 011.5 0v6a.75.75 0 01-1.5 0v-6zm5 0a.75.75 0 011.5 0v6a.75.75 0 01-1.5 0v-6z" clip-rule="evenodd"/></svg>
</button>
</div>
<ng-container [ngSwitch]="child.type">
<app-heading-block *ngSwitchCase="'heading'" [block]="child" (update)="onChildUpdate(i, $event)" (metaChange)="onChildMeta(i, $event)" (createBlock)="insertParagraphBelow(i)" (deleteBlock)="removeChild(i)" />
<app-paragraph-block *ngSwitchCase="'paragraph'" [block]="child" (update)="onChildUpdate(i, $event)" (metaChange)="onChildMeta(i, $event)" (createBlock)="insertParagraphBelow(i)" (deleteBlock)="removeChild(i)" />
<app-list-item-block *ngSwitchCase="'list-item'" [block]="child" (update)="onChildUpdate(i, $event)" />
<app-code-block *ngSwitchCase="'code'" [block]="child" (update)="onChildUpdate(i, $event)" />
<app-quote-block *ngSwitchCase="'quote'" [block]="child" (update)="onChildUpdate(i, $event)" />
<app-hint-block *ngSwitchCase="'hint'" [block]="child" (update)="onChildUpdate(i, $event)" />
<app-button-block *ngSwitchCase="'button'" [block]="child" (update)="onChildUpdate(i, $event)" />
<app-image-block *ngSwitchCase="'image'" [block]="child" (update)="onChildUpdate(i, $event)" />
<app-file-block *ngSwitchCase="'file'" [block]="child" (update)="onChildUpdate(i, $event)" />
<app-table-block *ngSwitchCase="'table'" [block]="child" (update)="onChildUpdate(i, $event)" />
<app-steps-block *ngSwitchCase="'steps'" [block]="child" (update)="onChildUpdate(i, $event)" />
<app-line-block *ngSwitchCase="'line'" [block]="child" />
<app-dropdown-block *ngSwitchCase="'dropdown'" [block]="child" (update)="onChildUpdate(i, $event)" />
<app-progress-block *ngSwitchCase="'progress'" [block]="child" (update)="onChildUpdate(i, $event)" />
<app-kanban-block *ngSwitchCase="'kanban'" [block]="child" (update)="onChildUpdate(i, $event)" />
<app-embed-block *ngSwitchCase="'embed'" [block]="child" (update)="onChildUpdate(i, $event)" />
</ng-container>
</div>
</ng-container>
<div *ngIf="props.content.length === 0" class="text-xs text-text-muted pl-1 py-1">
Press Enter in the title to start writing…
</div>
}
</div>
</div>
}
</div>
`,
styles: [`
[contenteditable]:empty:before {
@@ -44,17 +109,19 @@ import { Block, ToggleProps } from '../../../core/models/block.model';
export class ToggleBlockComponent implements AfterViewInit {
@Input({ required: true }) block!: Block<ToggleProps>;
@Output() update = new EventEmitter<ToggleProps>();
// For host to create block below (used for quick multi-toggle creation)
@Output() createBlock = new EventEmitter<void>();
@Output() createParagraph = new EventEmitter<void>();
@ViewChild('editable') editable?: ElementRef<HTMLDivElement>;
readonly isCollapsed = signal(true);
private readonly documentService = inject(DocumentService);
ngOnInit(): void {
this.isCollapsed.set(this.props.collapsed ?? true);
}
get props(): ToggleProps {
return this.block.props;
}
get props(): ToggleProps { return this.block.props; }
ngAfterViewInit(): void {
if (this.editable?.nativeElement) {
@@ -65,6 +132,20 @@ export class ToggleBlockComponent implements AfterViewInit {
toggle(): void {
const newState = !this.isCollapsed();
this.isCollapsed.set(newState);
// If opening and no content yet, create first paragraph for typing
if (!newState) {
const empty = !this.props.content || this.props.content.length === 0;
if (empty) {
const p = this.documentService.createBlock('paragraph', { text: '' });
const next = { ...this.props, collapsed: false, content: [p] } as ToggleProps;
this.update.emit(next);
setTimeout(() => {
const el = document.querySelector(`[data-block-id="${p.id}"] [contenteditable]`) as HTMLElement | null;
el?.focus();
}, 0);
return;
}
}
this.update.emit({ ...this.props, collapsed: newState });
}
@@ -72,4 +153,45 @@ export class ToggleBlockComponent implements AfterViewInit {
const target = event.target as HTMLElement;
this.update.emit({ ...this.props, title: target.textContent || '' });
}
onTitleKeyDown(event: KeyboardEvent): void {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
const text = (this.editable?.nativeElement?.textContent || '').trim();
if (text.length === 0) {
// Exit creation: ask host to insert a paragraph below
this.createParagraph.emit();
return;
}
// Multi-toggle creation: ask host to create another toggle below
this.createBlock.emit();
}
}
trackById = (_: number, b: Block) => b.id;
onChildUpdate(index: number, patch: any): void {
const next = this.props.content.map((b, i) => i === index ? ({ ...b, props: { ...(b as any).props, ...patch } as any }) : b);
this.update.emit({ ...this.props, content: next });
}
onChildMeta(index: number, meta: any): void {
const next = this.props.content.map((b, i) => i === index ? ({ ...b, meta: { ...(b.meta || {}), ...meta } }) : b);
this.update.emit({ ...this.props, content: next });
}
insertParagraphBelow(index: number): void {
const p = this.documentService.createBlock('paragraph', { text: '' });
const next = [...this.props.content];
next.splice(index + 1, 0, p);
this.update.emit({ ...this.props, content: next });
setTimeout(() => {
const el = document.querySelector(`[data-block-id="${p.id}"] [contenteditable]`) as HTMLElement | null;
el?.focus();
}, 0);
}
removeChild(index: number): void {
const next = this.props.content.filter((_, i) => i !== index);
this.update.emit({ ...this.props, content: next });
}
}
@@ -16,7 +16,7 @@ import { DragDropService } from '../../services/drag-drop.service';
import { DragDropFilesDirective } from '../../../blocks/file/directives/drag-drop-files.directive';
import { FilePickerService } from '../../../blocks/file/services/file-picker.service';
import { BlockInsertionService } from '../../../blocks/file/services/block-insertion.service';
import { PaletteItem } from '../../core/constants/palette-items';
import { PaletteItem, PALETTE_ITEMS } from '../../core/constants/palette-items';
@Component({
selector: 'app-editor-shell',
@@ -33,10 +33,25 @@ import { PaletteItem } from '../../core/constants/palette-items';
</span>
</div>
<div #header class="relative">
<div #header class="relative flex items-center gap-2">
<!-- Clear page button (icon) aligned to the left of the title input -->
<button
type="button"
class="inline-flex items-center justify-center h-8 px-3 rounded-full bg-red-600 text-white hover:bg-red-700 transition-colors"
title="Clear page"
(click)="onClearDocument()"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6" />
<path d="M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</button>
<input
type="text"
class="text-3xl font-semibold bg-transparent border-none outline-none w-full pr-12 text-main dark:text-neutral-100"
class="flex-1 text-3xl font-semibold bg-transparent border-none outline-none text-main dark:text-neutral-100"
[value]="documentService.doc().title"
(input)="onTitleChange($event)"
placeholder="Untitled Document"
@@ -256,60 +271,38 @@ export class EditorShellComponent implements AfterViewInit {
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-item' as any, props: { kind: 'check', checked: false, text: '' } },
'numbered-list': { type: 'list-item' as any, props: { kind: 'numbered', number: 1, text: '' } },
'bullet-list': { type: 'list-item' as any, props: { kind: 'bullet', text: '' } },
'table': { type: 'table', props: this.documentService.getDefaultProps('table') },
'image': { type: 'image', props: this.documentService.getDefaultProps('image') },
'file': null,
'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 (action === 'file') {
this.filePicker.pick({ multiple: true, accept: '*/*' }).then(files => {
if (!files.length) return;
this.insertFilesAtCursor(files);
});
return;
}
if (config) {
const block = this.documentService.createBlock(config.type, config.props);
this.documentService.appendBlock(block);
this.selectionService.setActive(block.id);
}
return;
}
// Map toolbar action to palette item id
const idMap: Record<string, string> = {
'checkbox-list': 'checkbox-list',
'numbered-list': 'numbered-list',
'bullet-list': 'bullet-list',
'table': 'table',
'image': 'image',
'file': 'file',
'heading-2': 'heading-2',
// 'new-page' and 'use-ai' are placeholders and not palette-backed
};
const id = idMap[action];
if (!id) return;
const item = PALETTE_ITEMS.find(i => i.id === id);
if (item) {
this.paletteService.applySelection(item);
}
}
onPaletteItemSelected(item: PaletteItem): void {
// Special handling for File: open multi-picker and create N blocks
if (item.type === 'file' || item.id === 'file') {
this.filePicker.pick({ multiple: true, accept: '*/*' }).then(files => {
if (!files.length) return;
this.insertFilesAtCursor(files);
});
return;
}
// Delegate to authoritative creation flow
this.paletteService.applySelection(item);
}
// Convert list types to list-item for independent lines
let blockType = item.type;
let props = this.documentService.getDefaultProps(blockType);
if (item.type === 'list') {
blockType = 'list-item' as any;
props = this.documentService.getDefaultProps(blockType);
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);
onClearDocument(): void {
// Empty page: remove all blocks and clear selection/menus
this.documentService.clearBlocks();
this.selectionService.clear();
this.showInitialMenu.set(false);
this.insertAfterBlockId.set(null);
}
/**
@@ -431,91 +424,64 @@ export class EditorShellComponent implements AfterViewInit {
}, 0);
}
onInitialMenuAction(action: BlockMenuAction): void {
async onInitialMenuAction(action: BlockMenuAction): Promise<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
// Ensure the placeholder paragraph is the active block so applySelection can convert/insert correctly
this.selectionService.setActive(blockId);
// Paragraph: keep as-is and just focus
if (action.type === 'paragraph') {
// Focus on the paragraph
setTimeout(() => {
const element = document.querySelector(`[data-block-id="${blockId}"] [contenteditable]`) as HTMLElement;
if (element) {
element.focus();
}
element?.focus();
}, 0);
return;
}
// If "more" selected, open full palette
// More: open full palette
if (action.type === 'more') {
this.paletteService.open();
this.paletteService.open(blockId);
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':
// Open picker and replace the placeholder paragraph with N file blocks at the same index
const currentBlocks = this.documentService.blocks();
const idx = currentBlocks.findIndex(b => b.id === blockId);
this.filePicker.pick({ multiple: true, accept: '*/*' }).then(files => {
if (!files.length) return;
// Delete the placeholder paragraph
this.documentService.deleteBlock(blockId);
// Insert at original index
this.inserter.createFromFiles(files, idx);
});
return; // early exit; we handle asynchronously
}
// 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();
// Map initial menu actions to palette items
const idMap: Record<string, string> = {
heading: 'heading-2',
checkbox: 'checkbox-list',
list: 'bullet-list',
numbered: 'numbered-list',
table: 'table',
code: 'code',
image: 'image',
file: 'file',
};
// Special-case formula: insert a code block then switch language to LaTeX
if (action.type === 'formula') {
const codeItem = PALETTE_ITEMS.find(i => i.id === 'code');
if (codeItem) {
await this.paletteService.applySelection(codeItem);
const newActiveId = this.selectionService.getActive();
if (newActiveId) {
const blk: any = this.documentService.getBlock(newActiveId);
if (blk?.type === 'code') {
this.documentService.updateBlockProps(newActiveId, { ...(blk.props || {}), language: 'latex', code: '' });
}
}
}
}, 0);
return;
}
const id = idMap[action.type];
if (!id) return;
const item = PALETTE_ITEMS.find(i => i.id === id);
if (item) {
await this.paletteService.applySelection(item);
}
}
}
@@ -13,15 +13,15 @@ import { PaletteItem, PaletteCategory, getPaletteItemsByCategory } from '../../c
<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"
class="bg-surface1 rounded-2xl shadow-surface-md border border-app 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"
<div class="px-3 py-2 border-b border-app flex items-center justify-between cursor-pointer hover:bg-surface2/60"
(click)="toggleSuggestions()">
<h3 class="text-xs font-semibold text-gray-300 uppercase tracking-wider">SUGGESTIONS</h3>
<h3 class="text-xs font-semibold text-text-muted uppercase tracking-wider">SUGGESTIONS</h3>
<svg
class="w-4 h-4 text-gray-400 transition-transform"
class="w-4 h-4 text-text-muted transition-transform"
[class.rotate-180]="!showSuggestions()"
viewBox="0 0 24 24"
fill="none"
@@ -34,11 +34,11 @@ import { PaletteItem, PaletteCategory, getPaletteItemsByCategory } from '../../c
<!-- Search input (only show when suggestions expanded) -->
@if (showSuggestions()) {
<div class="px-3 py-2 border-b border-neutral-700 bg-neutral-800/30">
<div class="px-3 py-2 border-b border-app bg-surface1">
<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"
class="input w-full border border-app bg-surface2 rounded px-3 py-1.5 text-sm text-text-main placeholder-text-muted focus:outline-none focus:ring-1 ring-app"
placeholder="Search blocks..."
[value]="paletteService.query()"
(input)="onSearch($event)"
@@ -53,8 +53,8 @@ import { PaletteItem, PaletteCategory, getPaletteItemsByCategory } from '../../c
@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 class="sticky top-0 z-10 px-3 py-1.5 bg-surface1 border-b border-app">
<h4 class="text-[10px] font-semibold text-text-muted uppercase tracking-wider">{{ category }}</h4>
</div>
<!-- Items in category -->
@@ -63,10 +63,10 @@ import { PaletteItem, PaletteCategory, getPaletteItemsByCategory } from '../../c
@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="flex items-center gap-2 w-full px-2 py-1.5 rounded hover:bg-surface2 transition-colors text-left group"
[class.bg-primary/30]="isSelectedByKeyboard(item)"
[class.ring-2]="isSelectedByKeyboard(item)"
[class.ring-purple-500/50]="isSelectedByKeyboard(item)"
[class.ring-app]="isSelectedByKeyboard(item)"
(click)="selectItem(item)"
(mouseenter)="setHoverItem(item)"
>
@@ -103,15 +103,15 @@ import { PaletteItem, PaletteCategory, getPaletteItemsByCategory } from '../../c
}
</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">
<div class="text-sm font-medium text-text-main group-hover:text-text-main 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>
<span class="px-1 py-0.5 text-[9px] font-semibold bg-brand 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">
<kbd class="px-1.5 py-0.5 text-[10px] font-mono bg-surface2 text-text-muted rounded border border-app flex-shrink-0">
{{ item.shortcut }}
</kbd>
}
@@ -138,7 +138,8 @@ import { PaletteItem, PaletteCategory, getPaletteItemsByCategory } from '../../c
/* Custom scrollbar */
.overflow-auto {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.3) transparent;
/* Use text-muted tone for scrollbar for theme compatibility */
scrollbar-color: var(--text-muted)10 transparent;
}
.overflow-auto::-webkit-scrollbar {
@@ -123,6 +123,13 @@ export interface ToggleProps {
collapsed?: boolean;
}
export interface CollapsibleProps {
level: 1 | 2 | 3; // 1=Large, 2=Medium, 3=Small
title: string;
content: Block[];
collapsed?: boolean;
}
export interface QuoteProps {
text: string;
author?: string;
+59 -14
View File
@@ -282,22 +282,43 @@ export class DocumentService {
* 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
});
const walk = (blocks: Block[]): OutlineHeading[] => {
const out: OutlineHeading[] = [];
for (const block of blocks) {
if (block.type === 'heading') {
const props = block.props as HeadingProps;
out.push({ id: generateId(), level: props.level, text: props.text, blockId: block.id });
} else if (block.type === 'collapsible') {
try {
const p: any = block.props || {};
const lvl = Math.max(1, Math.min(3, Number(p.level || 1))) as 1|2|3;
const title = (p.title && String(p.title).trim()) || `Heading ${lvl}`;
out.push({ id: generateId(), level: lvl, text: title, blockId: block.id });
// Recurse into its content to catch inner headings as well
if (Array.isArray(p.content)) out.push(...walk(p.content));
} catch {}
}
// Generic children traversal
if (block.children && block.children.length) out.push(...walk(block.children));
// Special traversal for columns
if (block.type === 'columns') {
try {
const cols = (block.props as any)?.columns || [];
for (const col of cols) {
const inner = Array.isArray(col?.blocks) ? col.blocks : [];
out.push(...walk(inner));
}
} catch {}
}
}
});
return out;
};
return headings;
try {
return walk(this._doc().blocks);
} catch {
return [];
}
}
/**
@@ -352,6 +373,18 @@ export class DocumentService {
if (marks?.length) result.marks = marks;
return result;
}
case 'collapsible': {
const level = preset?.level ?? 1;
const content = Array.isArray(fromProps?.content)
? this.cloneBlocks(fromProps.content)
: [];
return {
level,
title: text || preset?.title || '',
content,
collapsed: fromProps?.collapsed ?? true
};
}
case 'list-item': {
const kind = preset?.kind ?? fromProps?.kind ?? 'bullet';
const result: any = {
@@ -511,6 +544,7 @@ export class DocumentService {
case 'code': return { code: '', lang: '' };
case 'quote': return { text: '' };
case 'toggle': return { title: 'Toggle', content: [], collapsed: true };
case 'collapsible': return { level: 1, title: '', 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: '' };
@@ -583,4 +617,15 @@ export class DocumentService {
clearLocalStorage(): void {
localStorage.removeItem('nimbus-editor-doc');
}
/**
* Clear all blocks from the current document while preserving id/title/meta
*/
clearBlocks(): void {
this._doc.update(doc => ({
...doc,
blocks: [],
meta: { ...doc.meta, updatedAt: new Date().toISOString() }
}));
}
}
+87 -1
View File
@@ -1,5 +1,9 @@
import { Injectable, signal, computed } from '@angular/core';
import { Injectable, signal, computed, inject } from '@angular/core';
import { PaletteItem, searchPaletteItems, PALETTE_ITEMS } from '../core/constants/palette-items';
import { DocumentService } from './document.service';
import { SelectionService } from './selection.service';
import { FilePickerService } from '../../blocks/file/services/file-picker.service';
import { BlockInsertionService } from '../../blocks/file/services/block-insertion.service';
/**
* Palette state management service
@@ -8,6 +12,11 @@ import { PaletteItem, searchPaletteItems, PALETTE_ITEMS } from '../core/constant
providedIn: 'root'
})
export class PaletteService {
// Dependencies for authoritative block creation
private readonly documentService = inject(DocumentService);
private readonly selectionService = inject(SelectionService);
private readonly filePicker = inject(FilePickerService);
private readonly inserter = inject(BlockInsertionService);
// State
private readonly _isOpen = signal(false);
private readonly _query = signal('');
@@ -35,6 +44,83 @@ export class PaletteService {
return items[index] || null;
});
/**
* Authoritative handler to apply a selected palette item.
* Used by all menus to create/insert blocks consistently.
*/
async applySelection(item: PaletteItem): Promise<void> {
// Special handling for File: open multi-picker and create N blocks
if (item.type === 'file' || item.id === 'file') {
const files = await this.filePicker.pick({ multiple: true, accept: '*/*' });
if (!files.length) return;
// Insert near cursor behavior similar to editor-shell implementation
const blocks = this.documentService.blocks();
const activeId = this.selectionService.getActive();
let insertIndex = blocks.length;
let replaceBlockId: string | null = null;
if (activeId) {
const i = blocks.findIndex(b => b.id === activeId);
if (i >= 0) {
const blk: any = blocks[i];
if (blk.type === 'paragraph' && (!blk.props?.text || String(blk.props.text).trim() === '')) {
replaceBlockId = blk.id;
insertIndex = i;
} else {
insertIndex = i + 1;
}
}
}
if (replaceBlockId) {
this.documentService.deleteBlock(replaceBlockId);
}
this.inserter.createFromFiles(files, insertIndex);
this.close();
return;
}
// Convert list types to list-item for independent lines
let blockType = item.type;
let props = this.documentService.getDefaultProps(blockType);
// Map collapsible variants to preset levels
if (item.id === 'collapsible-large' || item.id === 'collapsible-medium' || item.id === 'collapsible-small') {
blockType = 'collapsible' as any;
props = this.documentService.getDefaultProps(blockType);
const level = item.id === 'collapsible-large' ? 1 : item.id === 'collapsible-medium' ? 2 : 3;
(props as any).level = level;
}
if (item.type === 'list') {
blockType = 'list-item' as any;
props = this.documentService.getDefaultProps(blockType);
if (item.id === 'checkbox-list') { (props as any).kind = 'check'; (props as any).checked = false; }
else if (item.id === 'numbered-list') { (props as any).kind = 'numbered'; (props as any).number = 1; }
else if (item.id === 'bullet-list') { (props as any).kind = 'bullet'; }
}
// Insert/convert near active block
const activeId = this.selectionService.getActive();
if (activeId) {
const active = this.documentService.getBlock(activeId) as any;
// Convert the current active block to the requested type.
// Preserve text if the target props support it and the active has text.
if (props && typeof (props as any) === 'object' && 'text' in (props as any)) {
const currentText = (active?.props?.text ?? '') as string;
props = { ...(props as any), text: currentText } as any;
}
this.documentService.convertBlock(activeId, blockType as any, props);
this.selectionService.setActive(activeId);
this.close();
return;
}
// No active selection: append
const block = this.documentService.createBlock(blockType as any, props);
this.documentService.appendBlock(block);
this.selectionService.setActive(block.id);
this.close();
}
/**
* Open palette
*/
+13 -10
View File
@@ -101,34 +101,37 @@ export class TocService {
}
private extractHeadings(blocks: Block[]): TocItem[] {
const headings: TocItem[] = [];
const out: 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 });
out.push({ id: `toc-${block.id}`, level: props.level, text, blockId: block.id });
}
} else if (block.type === 'collapsible') {
try {
const p: any = block.props || {};
const lvl = Math.max(1, Math.min(3, Number(p.level || 1))) as 1|2|3;
const title = (p.title && String(p.title).trim()) || `Heading ${lvl}`;
out.push({ id: `toc-${block.id}`, level: lvl, text: title, blockId: block.id });
if (Array.isArray(p.content)) out.push(...this.extractHeadings(p.content));
} catch {}
}
// Parcours des enfants réguliers
if (block.children && block.children.length > 0) {
headings.push(...this.extractHeadings(block.children));
out.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));
out.push(...this.extractHeadings(innerBlocks));
}
} catch {}
}
}
return headings;
return out;
}
}
@@ -14,14 +14,6 @@ import { DocumentService } from '../../../editor/services/document.service';
<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>