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:
Bruno Charest 2025-11-14 22:29:11 -05:00
parent 85d021b154
commit 9887be548e
21 changed files with 871 additions and 335 deletions

View File

@ -34,6 +34,7 @@ import { EmbedBlockComponent } from './blocks/embed-block.component';
import { OutlineBlockComponent } from './blocks/outline-block.component'; import { OutlineBlockComponent } from './blocks/outline-block.component';
import { LineBlockComponent } from './blocks/line-block.component'; import { LineBlockComponent } from './blocks/line-block.component';
import { ColumnsBlockComponent } from './blocks/columns-block.component'; import { ColumnsBlockComponent } from './blocks/columns-block.component';
import { CollapsibleBlockComponent } from './blocks/collapsible-block.component';
/** /**
* Block host component - routes to specific block type * Block host component - routes to specific block type
@ -64,6 +65,7 @@ import { ColumnsBlockComponent } from './blocks/columns-block.component';
OutlineBlockComponent, OutlineBlockComponent,
LineBlockComponent, LineBlockComponent,
ColumnsBlockComponent, ColumnsBlockComponent,
CollapsibleBlockComponent,
OverlayModule, OverlayModule,
PortalModule PortalModule
], ],
@ -152,7 +154,12 @@ import { ColumnsBlockComponent } from './blocks/columns-block.component';
<app-hint-block [block]="block" (update)="onBlockUpdate($event)" /> <app-hint-block [block]="block" (update)="onBlockUpdate($event)" />
} }
@case ('toggle') { @case ('toggle') {
<app-toggle-block [block]="block" (update)="onBlockUpdate($event)" /> <app-toggle-block
[block]="block"
(update)="onBlockUpdate($event)"
(createBlock)="onCreateToggleBelow()"
(createParagraph)="onCreateBlockBelow()"
/>
} }
@case ('dropdown') { @case ('dropdown') {
<app-dropdown-block [block]="block" (update)="onBlockUpdate($event)" /> <app-dropdown-block [block]="block" (update)="onBlockUpdate($event)" />
@ -178,6 +185,9 @@ import { ColumnsBlockComponent } from './blocks/columns-block.component';
@case ('columns') { @case ('columns') {
<app-columns-block [block]="block" (update)="onBlockUpdate($event)" /> <app-columns-block [block]="block" (update)="onBlockUpdate($event)" />
} }
@case ('collapsible') {
<app-collapsible-block [block]="block" (update)="onBlockUpdate($event)" />
}
} }
</div> </div>
<ng-container *ngIf="block.type !== 'table'"> <ng-container *ngIf="block.type !== 'table'">
@ -969,6 +979,17 @@ export class BlockHostComponent implements OnDestroy {
}, 50); }, 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 { onDeleteBlock(): void {
// Delete current block // Delete current block
this.documentService.deleteBlock(this.block.id); this.documentService.deleteBlock(this.block.id);

View File

@ -10,7 +10,7 @@ export interface BlockMenuAction {
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule],
template: ` 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 --> <!-- Edit/Text -->
<button <button
class="p-1.5 rounded hover:bg-gray-700 text-gray-400 hover:text-gray-200 transition-colors" class="p-1.5 rounded hover:bg-gray-700 text-gray-400 hover:text-gray-200 transition-colors"

View File

@ -46,7 +46,8 @@ export interface InlineToolbarAction {
<ng-content /> <ng-content />
</div> </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 --> <!-- Use AI -->
<button *ngIf="!actions || actions.includes('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" class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"

View File

@ -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 });
}
}

View File

@ -13,6 +13,7 @@ import { ListItemBlockComponent } from './list-item-block.component';
import { CodeBlockComponent } from './code-block.component'; import { CodeBlockComponent } from './code-block.component';
import { QuoteBlockComponent } from './quote-block.component'; import { QuoteBlockComponent } from './quote-block.component';
import { ToggleBlockComponent } from './toggle-block.component'; import { ToggleBlockComponent } from './toggle-block.component';
import { CollapsibleBlockComponent } from './collapsible-block.component';
import { HintBlockComponent } from './hint-block.component'; import { HintBlockComponent } from './hint-block.component';
import { ButtonBlockComponent } from './button-block.component'; import { ButtonBlockComponent } from './button-block.component';
import { ImageBlockComponent } from './image-block.component'; import { ImageBlockComponent } from './image-block.component';
@ -41,6 +42,7 @@ import { DragDropFilesDirective } from '../../../../blocks/file/directives/drag-
CodeBlockComponent, CodeBlockComponent,
QuoteBlockComponent, QuoteBlockComponent,
ToggleBlockComponent, ToggleBlockComponent,
CollapsibleBlockComponent,
HintBlockComponent, HintBlockComponent,
ButtonBlockComponent, ButtonBlockComponent,
ImageBlockComponent, ImageBlockComponent,
@ -122,6 +124,7 @@ import { DragDropFilesDirective } from '../../../../blocks/file/directives/drag-
@case ('paragraph') { @case ('paragraph') {
<app-paragraph-block <app-paragraph-block
[block]="block" [block]="block"
[inColumn]="true"
(update)="onBlockUpdate($event, block.id)" (update)="onBlockUpdate($event, block.id)"
(metaChange)="onBlockMetaChange($event, block.id)" (metaChange)="onBlockMetaChange($event, block.id)"
(createBlock)="onBlockCreateBelow(block.id, colIndex, blockIndex)" (createBlock)="onBlockCreateBelow(block.id, colIndex, blockIndex)"
@ -140,6 +143,9 @@ import { DragDropFilesDirective } from '../../../../blocks/file/directives/drag-
@case ('toggle') { @case ('toggle') {
<app-toggle-block [block]="block" (update)="onBlockUpdate($event, block.id)" /> <app-toggle-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
} }
@case ('collapsible') {
<app-collapsible-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
}
@case ('hint') { @case ('hint') {
<app-hint-block [block]="block" (update)="onBlockUpdate($event, block.id)" /> <app-hint-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
} }

View File

@ -6,9 +6,8 @@ import { Block, ParagraphProps } from '../../../core/models/block.model';
import { DocumentService } from '../../../services/document.service'; import { DocumentService } from '../../../services/document.service';
import { SelectionService } from '../../../services/selection.service'; import { SelectionService } from '../../../services/selection.service';
import { PaletteService } from '../../../services/palette.service'; import { PaletteService } from '../../../services/palette.service';
import { PaletteCategory, PaletteItem, getPaletteItemsByCategory } from '../../../core/constants/palette-items'; import { PaletteCategory, PaletteItem, getPaletteItemsByCategory, PALETTE_ITEMS } from '../../../core/constants/palette-items';
import { FilePickerService } from '../../../../blocks/file/services/file-picker.service';
import { BlockInsertionService } from '../../../../blocks/file/services/block-insertion.service';
@Component({ @Component({
selector: 'app-paragraph-block', selector: 'app-paragraph-block',
@ -24,30 +23,52 @@ import { BlockInsertionService } from '../../../../blocks/file/services/block-in
[isFocused]="isFocused" [isFocused]="isFocused"
[isEmpty]="isEmpty" [isEmpty]="isEmpty"
[showDragHandle]="false" [showDragHandle]="false"
[actions]="undefined" [actions]="inColumn ? [] : undefined"
(action)="onInlineAction($event)" (action)="onInlineAction($event)"
> >
<div <div class="flex items-center gap-2">
#editable <div
contenteditable="true" #editable
class="m-0 inline-block bg-transparent text-sm text-neutral-100 dark:text-neutral-100 focus:outline-none min-h-[1.25rem]" contenteditable="true"
(input)="onInput($event)" class="m-0 inline-block bg-transparent text-sm text-neutral-100 dark:text-neutral-100 focus:outline-none min-h-[1.25rem]"
(keydown)="onKeyDown($event)" (input)="onInput($event)"
(focus)="isFocused.set(true)" (keydown)="onKeyDown($event)"
(blur)="onBlur()" (focus)="isFocused.set(true)"
[attr.data-placeholder]="placeholder" (blur)="onBlur()"
></div> [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> </app-block-inline-toolbar>
<!-- Anchored dropdown menu (more) --> <!-- Anchored dropdown menu (more) -->
@if (moreOpen()) { @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) { @for (category of categories; track category) {
<div> <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"> <div class="px-1 py-0.5">
@for (item of getItems(category); track item.id) { @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)"> (click)="selectItem(item)">
<span class="text-base w-5 flex items-center justify-center"> <span class="text-base w-5 flex items-center justify-center">
@if (item.id === 'checkbox-list') { @if (item.id === 'checkbox-list') {
@ -117,6 +138,8 @@ import { BlockInsertionService } from '../../../../blocks/file/services/block-in
export class ParagraphBlockComponent implements AfterViewInit { export class ParagraphBlockComponent implements AfterViewInit {
@Input({ required: true }) block!: Block<ParagraphProps>; @Input({ required: true }) block!: Block<ParagraphProps>;
@Input() showDragHandle = true; // Hide drag handle in columns @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() update = new EventEmitter<ParagraphProps>();
@Output() metaChange = new EventEmitter<any>(); @Output() metaChange = new EventEmitter<any>();
@Output() createBlock = new EventEmitter<void>(); @Output() createBlock = new EventEmitter<void>();
@ -125,61 +148,39 @@ export class ParagraphBlockComponent implements AfterViewInit {
private documentService = inject(DocumentService); private documentService = inject(DocumentService);
private selectionService = inject(SelectionService); private selectionService = inject(SelectionService);
private paletteService = inject(PaletteService); private paletteService = inject(PaletteService);
private filePicker = inject(FilePickerService);
private blockInserter = inject(BlockInsertionService);
@ViewChild('editable', { static: true }) editable?: ElementRef<HTMLDivElement>; @ViewChild('editable', { static: true }) editable?: ElementRef<HTMLDivElement>;
@ViewChild('menuPanel') menuPanel?: ElementRef<HTMLDivElement>;
isFocused = signal(false); isFocused = signal(false);
isEmpty = signal(true); isEmpty = signal(true);
placeholder = "Start writing or type '/', '@'"; placeholder = "Start writing or type '/', '@'";
columnPlaceholder = "Type '/' or add +";
moreOpen = signal(false); moreOpen = signal(false);
menuTop = signal(0);
menuLeft = signal(0);
categories: PaletteCategory[] = ['BASIC','ADVANCED','MEDIA','INTEGRATIONS','VIEW','TEMPLATES','HELPFUL LINKS']; categories: PaletteCategory[] = ['BASIC','ADVANCED','MEDIA','INTEGRATIONS','VIEW','TEMPLATES','HELPFUL LINKS'];
onInlineAction(type: any): void { onInlineAction(type: any): void {
if (type === 'more' || type === 'menu') { if (type === 'more' || type === 'menu') {
this.moreOpen.set(!this.moreOpen()); this.openMenu();
return; 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; if (!id) return;
switch (type) { const item = PALETTE_ITEMS.find(i => i.id === id);
case 'checkbox-list': { if (!item) return;
this.documentService.convertBlock(id, 'list-item' as any); try { this.selectionService.setActive(this.block.id); } catch {}
this.documentService.updateBlockProps(id, { kind: 'check', checked: false, text: '' }); this.paletteService.applySelection(item);
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;
}
} }
getItems(category: PaletteCategory): PaletteItem[] { getItems(category: PaletteCategory): PaletteItem[] {
@ -187,67 +188,9 @@ export class ParagraphBlockComponent implements AfterViewInit {
} }
selectItem(item: PaletteItem): void { selectItem(item: PaletteItem): void {
const id = this.block.id; try { this.selectionService.setActive(this.block.id); } catch {}
if (!id) return; this.paletteService.applySelection(item);
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);
}
}
this.moreOpen.set(false); this.moreOpen.set(false);
// Keep focus on the same editable after conversion (if still present)
setTimeout(() => this.editable?.nativeElement?.focus(), 0); setTimeout(() => this.editable?.nativeElement?.focus(), 0);
} }
@ -269,6 +212,13 @@ export class ParagraphBlockComponent implements AfterViewInit {
this.isEmpty.set(!(target.textContent && target.textContent.length > 0)); 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 { onKeyDown(event: KeyboardEvent): void {
// Handle TAB: Increase indent // Handle TAB: Increase indent
if (event.key === 'Tab' && !event.shiftKey) { if (event.key === 'Tab' && !event.shiftKey) {
@ -295,7 +245,7 @@ export class ParagraphBlockComponent implements AfterViewInit {
// Only trigger if "/" is at start or after space // Only trigger if "/" is at start or after space
if (text.length === 0 || text.endsWith(' ')) { if (text.length === 0 || text.endsWith(' ')) {
event.preventDefault(); event.preventDefault();
this.moreOpen.set(true); this.openMenu();
return; return;
} }
} }
@ -306,7 +256,7 @@ export class ParagraphBlockComponent implements AfterViewInit {
const text = target.textContent || ''; const text = target.textContent || '';
if (text.length === 0 || text.endsWith(' ')) { if (text.length === 0 || text.endsWith(' ')) {
event.preventDefault(); event.preventDefault();
this.moreOpen.set(true); this.openMenu();
return; return;
} }
} }
@ -330,7 +280,7 @@ export class ParagraphBlockComponent implements AfterViewInit {
const selection = window.getSelection(); const selection = window.getSelection();
if (selection && selection.anchorOffset === 0 && (!target.textContent || target.textContent.length === 0)) { if (selection && selection.anchorOffset === 0 && (!target.textContent || target.textContent.length === 0)) {
event.preventDefault(); event.preventDefault();
this.moreOpen.set(true); this.openMenu();
return; return;
} }
} }
@ -370,6 +320,54 @@ export class ParagraphBlockComponent implements AfterViewInit {
if (el) this.isEmpty.set(!(el.textContent && el.textContent.length > 0)); 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 { onContainerClick(event: MouseEvent): void {
// Ignore clicks on buttons/icons to avoid stealing clicks // Ignore clicks on buttons/icons to avoid stealing clicks
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
@ -400,18 +398,19 @@ export class ParagraphBlockComponent implements AfterViewInit {
}, 0); }, 0);
} }
private handleFileInsertion(blockId: string): void { private openMenu(): void {
this.filePicker.pick({ multiple: true, accept: '*/*' }).then(async files => { this.moreOpen.set(true);
if (!files.length) return; try { this.selectionService.setActive(this.block.id); } catch {}
const blocks = this.documentService.blocks(); // Compute viewport position near the editable content
const insertIndex = blocks.findIndex(b => b.id === blockId); setTimeout(() => {
if (insertIndex < 0) return; const el = this.editable?.nativeElement;
const rect = el?.getBoundingClientRect();
this.documentService.deleteBlock(blockId); const top = (rect?.bottom ?? 0) + 8;
const created = await this.blockInserter.createFromFiles(files, insertIndex); const left = Math.max(8, Math.min((rect?.left ?? 0), window.innerWidth - 440));
if (created.length) { this.menuTop.set(Math.round(top));
this.selectionService.setActive(created[created.length - 1]); this.menuLeft.set(Math.round(left));
} try { this.menuPanel?.nativeElement.focus(); } catch {}
}); }, 0);
} }
} }

View File

@ -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 { CommonModule } from '@angular/common';
import { Block, ToggleProps } from '../../../core/models/block.model'; 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({ @Component({
selector: 'app-toggle-block', selector: 'app-toggle-block',
standalone: true, standalone: true,
imports: [CommonModule], imports: [
CommonModule,
ParagraphBlockComponent,
HeadingBlockComponent,
ListItemBlockComponent,
CodeBlockComponent,
QuoteBlockComponent,
HintBlockComponent,
ButtonBlockComponent,
ImageBlockComponent,
FileBlockComponent,
TableBlockComponent,
StepsBlockComponent,
LineBlockComponent,
DropdownBlockComponent,
ProgressBlockComponent,
KanbanBlockComponent,
EmbedBlockComponent,
],
template: ` template: `
<div class="rounded-md overflow-hidden bg-transparent"> <div class="rounded-md overflow-hidden bg-transparent">
<button <div class="w-full flex items-center gap-1.5 px-2 py-1.5 text-left bg-transparent transition-none">
type="button" <button type="button" class="p-1 rounded hover:bg-surface2" (click)="toggle()" title="Expand/Collapse">
class="w-full flex items-center gap-1.5 px-2 py-1.5 text-left bg-transparent transition-none" <svg class="w-3.5 h-3.5 transition-transform" [class.-rotate-90]="isCollapsed()">
(click)="toggle()" <path fill="currentColor" d="M9.4 12L4 6.6l1.4-1.4L9.4 9l4-3.8 1.4 1.4L9.4 12z"/>
> </svg>
<svg class="w-3.5 h-3.5 transition-transform" [class.rotate-90]="!isCollapsed()"> </button>
<path fill="currentColor" d="M9.4 12L4 6.6l1.4-1.4L9.4 9l4-3.8 1.4 1.4L9.4 12z"/>
</svg>
<div <div
#editable #editable
contenteditable="true" 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)" (input)="onTitleInput($event)"
(keydown)="onTitleKeyDown($event)"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
placeholder="Toggle title..." placeholder="Toggle title..."
></div> ></div>
</button> </div>
@if (!isCollapsed()) { @if (!isCollapsed()) {
<div class="px-3 py-2 bg-transparent"> <div class="pl-6 pr-3 py-0.5">
<div class="text-sm leading-5 text-text-muted"> <ng-container *ngFor="let child of props.content; let i = index; trackBy: trackById">
Nested content will be rendered here <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> </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> }
</div>
`, `,
styles: [` styles: [`
[contenteditable]:empty:before { [contenteditable]:empty:before {
@ -44,17 +109,19 @@ import { Block, ToggleProps } from '../../../core/models/block.model';
export class ToggleBlockComponent implements AfterViewInit { export class ToggleBlockComponent implements AfterViewInit {
@Input({ required: true }) block!: Block<ToggleProps>; @Input({ required: true }) block!: Block<ToggleProps>;
@Output() update = new EventEmitter<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>; @ViewChild('editable') editable?: ElementRef<HTMLDivElement>;
readonly isCollapsed = signal(true); readonly isCollapsed = signal(true);
private readonly documentService = inject(DocumentService);
ngOnInit(): void { ngOnInit(): void {
this.isCollapsed.set(this.props.collapsed ?? true); this.isCollapsed.set(this.props.collapsed ?? true);
} }
get props(): ToggleProps { get props(): ToggleProps { return this.block.props; }
return this.block.props;
}
ngAfterViewInit(): void { ngAfterViewInit(): void {
if (this.editable?.nativeElement) { if (this.editable?.nativeElement) {
@ -65,6 +132,20 @@ export class ToggleBlockComponent implements AfterViewInit {
toggle(): void { toggle(): void {
const newState = !this.isCollapsed(); const newState = !this.isCollapsed();
this.isCollapsed.set(newState); 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 }); this.update.emit({ ...this.props, collapsed: newState });
} }
@ -72,4 +153,45 @@ export class ToggleBlockComponent implements AfterViewInit {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
this.update.emit({ ...this.props, title: target.textContent || '' }); 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 });
}
} }

View File

@ -16,7 +16,7 @@ import { DragDropService } from '../../services/drag-drop.service';
import { DragDropFilesDirective } from '../../../blocks/file/directives/drag-drop-files.directive'; import { DragDropFilesDirective } from '../../../blocks/file/directives/drag-drop-files.directive';
import { FilePickerService } from '../../../blocks/file/services/file-picker.service'; import { FilePickerService } from '../../../blocks/file/services/file-picker.service';
import { BlockInsertionService } from '../../../blocks/file/services/block-insertion.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({ @Component({
selector: 'app-editor-shell', selector: 'app-editor-shell',
@ -33,10 +33,25 @@ import { PaletteItem } from '../../core/constants/palette-items';
</span> </span>
</div> </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 <input
type="text" 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" [value]="documentService.doc().title"
(input)="onTitleChange($event)" (input)="onTitleChange($event)"
placeholder="Untitled Document" placeholder="Untitled Document"
@ -256,60 +271,38 @@ export class EditorShellComponent implements AfterViewInit {
onToolbarAction(action: string): void { onToolbarAction(action: string): void {
if (action === 'more') { if (action === 'more') {
this.openPalette(); this.openPalette();
} else { return;
// Map toolbar actions to block types }
const typeMap: Record<string, any> = { // Map toolbar action to palette item id
'checkbox-list': { type: 'list-item' as any, props: { kind: 'check', checked: false, text: '' } }, const idMap: Record<string, string> = {
'numbered-list': { type: 'list-item' as any, props: { kind: 'numbered', number: 1, text: '' } }, 'checkbox-list': 'checkbox-list',
'bullet-list': { type: 'list-item' as any, props: { kind: 'bullet', text: '' } }, 'numbered-list': 'numbered-list',
'table': { type: 'table', props: this.documentService.getDefaultProps('table') }, 'bullet-list': 'bullet-list',
'image': { type: 'image', props: this.documentService.getDefaultProps('image') }, 'table': 'table',
'file': null, 'image': 'image',
'heading-2': { type: 'heading', props: { level: 2, text: '' } }, 'file': 'file',
'new-page': { type: 'paragraph', props: { text: '' } }, // Placeholder 'heading-2': 'heading-2',
'use-ai': { type: 'paragraph', props: { text: '' } }, // Placeholder // 'new-page' and 'use-ai' are placeholders and not palette-backed
}; };
const id = idMap[action];
const config = typeMap[action]; if (!id) return;
if (action === 'file') { const item = PALETTE_ITEMS.find(i => i.id === id);
this.filePicker.pick({ multiple: true, accept: '*/*' }).then(files => { if (item) {
if (!files.length) return; this.paletteService.applySelection(item);
this.insertFilesAtCursor(files);
});
return;
}
if (config) {
const block = this.documentService.createBlock(config.type, config.props);
this.documentService.appendBlock(block);
this.selectionService.setActive(block.id);
}
} }
} }
onPaletteItemSelected(item: PaletteItem): void { onPaletteItemSelected(item: PaletteItem): void {
// Special handling for File: open multi-picker and create N blocks // Delegate to authoritative creation flow
if (item.type === 'file' || item.id === 'file') { this.paletteService.applySelection(item);
this.filePicker.pick({ multiple: true, accept: '*/*' }).then(files => { }
if (!files.length) return;
this.insertFilesAtCursor(files);
});
return;
}
// Convert list types to list-item for independent lines onClearDocument(): void {
let blockType = item.type; // Empty page: remove all blocks and clear selection/menus
let props = this.documentService.getDefaultProps(blockType); this.documentService.clearBlocks();
if (item.type === 'list') { this.selectionService.clear();
blockType = 'list-item' as any; this.showInitialMenu.set(false);
props = this.documentService.getDefaultProps(blockType); this.insertAfterBlockId.set(null);
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);
} }
/** /**
@ -431,91 +424,64 @@ export class EditorShellComponent implements AfterViewInit {
}, 0); }, 0);
} }
onInitialMenuAction(action: BlockMenuAction): void { async onInitialMenuAction(action: BlockMenuAction): Promise<void> {
// Hide menu immediately // Hide menu immediately
this.showInitialMenu.set(false); this.showInitialMenu.set(false);
const blockId = this.insertAfterBlockId(); const blockId = this.insertAfterBlockId();
if (!blockId) return; 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') { if (action.type === 'paragraph') {
// Focus on the paragraph
setTimeout(() => { setTimeout(() => {
const element = document.querySelector(`[data-block-id="${blockId}"] [contenteditable]`) as HTMLElement; const element = document.querySelector(`[data-block-id="${blockId}"] [contenteditable]`) as HTMLElement;
if (element) { element?.focus();
element.focus();
}
}, 0); }, 0);
return; return;
} }
// If "more" selected, open full palette // More: open full palette
if (action.type === 'more') { if (action.type === 'more') {
this.paletteService.open(); this.paletteService.open(blockId);
return; return;
} }
// Otherwise, convert the paragraph block to the selected type // Map initial menu actions to palette items
let blockType: any = 'paragraph'; const idMap: Record<string, string> = {
let props: any = { text: '' }; heading: 'heading-2',
checkbox: 'checkbox-list',
list: 'bullet-list',
numbered: 'numbered-list',
table: 'table',
code: 'code',
image: 'image',
file: 'file',
};
switch (action.type) { // Special-case formula: insert a code block then switch language to LaTeX
case 'heading': if (action.type === 'formula') {
blockType = 'heading'; const codeItem = PALETTE_ITEMS.find(i => i.id === 'code');
props = { level: 2, text: '' }; if (codeItem) {
break; await this.paletteService.applySelection(codeItem);
case 'checkbox': const newActiveId = this.selectionService.getActive();
blockType = 'list-item'; if (newActiveId) {
props = { kind: 'check', text: '', checked: false }; const blk: any = this.documentService.getBlock(newActiveId);
break; if (blk?.type === 'code') {
case 'list': this.documentService.updateBlockProps(newActiveId, { ...(blk.props || {}), language: 'latex', code: '' });
blockType = 'list-item'; }
props = { kind: 'bullet', text: '' }; }
break; }
case 'numbered': return;
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 const id = idMap[action.type];
this.documentService.updateBlock(blockId, { type: blockType, props }); if (!id) return;
const item = PALETTE_ITEMS.find(i => i.id === id);
// Focus on the converted block if (item) {
setTimeout(() => { await this.paletteService.applySelection(item);
const newElement = document.querySelector(`[data-block-id="${blockId}"] [contenteditable]`) as HTMLElement; }
if (newElement) {
newElement.focus();
}
}, 0);
} }
} }

View File

@ -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 class="fixed inset-0 z-[9999] flex items-start justify-center pt-32" (click)="close()">
<div <div
#menuPanel #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()" (click)="$event.stopPropagation()"
> >
<!-- Header collapsible --> <!-- 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()"> (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 <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()" [class.rotate-180]="!showSuggestions()"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
@ -34,11 +34,11 @@ import { PaletteItem, PaletteCategory, getPaletteItemsByCategory } from '../../c
<!-- Search input (only show when suggestions expanded) --> <!-- Search input (only show when suggestions expanded) -->
@if (showSuggestions()) { @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 <input
#searchInput #searchInput
type="text" 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..." placeholder="Search blocks..."
[value]="paletteService.query()" [value]="paletteService.query()"
(input)="onSearch($event)" (input)="onSearch($event)"
@ -53,8 +53,8 @@ import { PaletteItem, PaletteCategory, getPaletteItemsByCategory } from '../../c
@for (category of categories; track category) { @for (category of categories; track category) {
<div> <div>
<!-- Sticky section header --> <!-- 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"> <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-gray-400 uppercase tracking-wider">{{ category }}</h4> <h4 class="text-[10px] font-semibold text-text-muted uppercase tracking-wider">{{ category }}</h4>
</div> </div>
<!-- Items in category --> <!-- Items in category -->
@ -63,10 +63,10 @@ import { PaletteItem, PaletteCategory, getPaletteItemsByCategory } from '../../c
@if (matchesQuery(item)) { @if (matchesQuery(item)) {
<button <button
type="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="flex items-center gap-2 w-full px-2 py-1.5 rounded hover:bg-surface2 transition-colors text-left group"
[class.bg-purple-600/40]="isSelectedByKeyboard(item)" [class.bg-primary/30]="isSelectedByKeyboard(item)"
[class.ring-2]="isSelectedByKeyboard(item)" [class.ring-2]="isSelectedByKeyboard(item)"
[class.ring-purple-500/50]="isSelectedByKeyboard(item)" [class.ring-app]="isSelectedByKeyboard(item)"
(click)="selectItem(item)" (click)="selectItem(item)"
(mouseenter)="setHoverItem(item)" (mouseenter)="setHoverItem(item)"
> >
@ -103,15 +103,15 @@ import { PaletteItem, PaletteCategory, getPaletteItemsByCategory } from '../../c
} }
</span> </span>
<div class="flex-1 min-w-0"> <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 }} {{ item.label }}
@if (isNewItem(item.id)) { @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>
</div> </div>
@if (item.shortcut) { @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 }} {{ item.shortcut }}
</kbd> </kbd>
} }
@ -138,7 +138,8 @@ import { PaletteItem, PaletteCategory, getPaletteItemsByCategory } from '../../c
/* Custom scrollbar */ /* Custom scrollbar */
.overflow-auto { .overflow-auto {
scrollbar-width: thin; 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 { .overflow-auto::-webkit-scrollbar {

View File

@ -123,6 +123,13 @@ export interface ToggleProps {
collapsed?: boolean; collapsed?: boolean;
} }
export interface CollapsibleProps {
level: 1 | 2 | 3; // 1=Large, 2=Medium, 3=Small
title: string;
content: Block[];
collapsed?: boolean;
}
export interface QuoteProps { export interface QuoteProps {
text: string; text: string;
author?: string; author?: string;

View File

@ -282,22 +282,43 @@ export class DocumentService {
* Generate outline from headings * Generate outline from headings
*/ */
private generateOutline(): OutlineHeading[] { private generateOutline(): OutlineHeading[] {
const headings: OutlineHeading[] = []; const walk = (blocks: Block[]): OutlineHeading[] => {
const blocks = this._doc().blocks; const out: OutlineHeading[] = [];
for (const block of blocks) {
blocks.forEach(block => { if (block.type === 'heading') {
if (block.type === 'heading') { const props = block.props as HeadingProps;
const props = block.props as HeadingProps; out.push({ id: generateId(), level: props.level, text: props.text, blockId: block.id });
headings.push({ } else if (block.type === 'collapsible') {
id: generateId(), try {
level: props.level, const p: any = block.props || {};
text: props.text, const lvl = Math.max(1, Math.min(3, Number(p.level || 1))) as 1|2|3;
blockId: block.id 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; if (marks?.length) result.marks = marks;
return result; 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': { case 'list-item': {
const kind = preset?.kind ?? fromProps?.kind ?? 'bullet'; const kind = preset?.kind ?? fromProps?.kind ?? 'bullet';
const result: any = { const result: any = {
@ -511,6 +544,7 @@ export class DocumentService {
case 'code': return { code: '', lang: '' }; case 'code': return { code: '', lang: '' };
case 'quote': return { text: '' }; case 'quote': return { text: '' };
case 'toggle': return { title: 'Toggle', content: [], collapsed: true }; 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 'dropdown': return { title: 'Dropdown', content: [], collapsed: true };
case 'table': return { rows: [{ id: generateId(), cells: [{ id: generateId(), text: '' }] }], header: false }; case 'table': return { rows: [{ id: generateId(), cells: [{ id: generateId(), text: '' }] }], header: false };
case 'image': return { src: '', alt: '' }; case 'image': return { src: '', alt: '' };
@ -583,4 +617,15 @@ export class DocumentService {
clearLocalStorage(): void { clearLocalStorage(): void {
localStorage.removeItem('nimbus-editor-doc'); 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() }
}));
}
} }

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 { 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 * Palette state management service
@ -8,6 +12,11 @@ import { PaletteItem, searchPaletteItems, PALETTE_ITEMS } from '../core/constant
providedIn: 'root' providedIn: 'root'
}) })
export class PaletteService { 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 // State
private readonly _isOpen = signal(false); private readonly _isOpen = signal(false);
private readonly _query = signal(''); private readonly _query = signal('');
@ -35,6 +44,83 @@ export class PaletteService {
return items[index] || null; 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 * Open palette
*/ */

View File

@ -101,34 +101,37 @@ export class TocService {
} }
private extractHeadings(blocks: Block[]): TocItem[] { private extractHeadings(blocks: Block[]): TocItem[] {
const headings: TocItem[] = []; const out: TocItem[] = [];
for (const block of blocks) { for (const block of blocks) {
if (block.type === 'heading') { if (block.type === 'heading') {
const props = block.props as HeadingProps; const props = block.props as HeadingProps;
if (props.level >= 1 && props.level <= 3) { if (props.level >= 1 && props.level <= 3) {
const text = props.text && props.text.trim() ? props.text : `Heading ${props.level}`; 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) { 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') { if (block.type === 'columns') {
try { try {
const cols = (block.props as any)?.columns || []; const cols = (block.props as any)?.columns || [];
for (const col of cols) { for (const col of cols) {
const innerBlocks = Array.isArray(col?.blocks) ? col.blocks : []; const innerBlocks = Array.isArray(col?.blocks) ? col.blocks : [];
headings.push(...this.extractHeadings(innerBlocks)); out.push(...this.extractHeadings(innerBlocks));
} }
} catch {} } catch {}
} }
} }
return out;
return headings;
} }
} }

View File

@ -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="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="text-sm font-semibold">Éditeur Nimbus Section Tests</div>
<div class="flex items-center gap-2"> <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> </div>

View File

@ -1,3 +1,16 @@
{ {
"items": [] "items": [
{
"type": "file",
"path": "Allo-3/bruno.md",
"title": "bruno.md",
"ctime": 1763066387749
},
{
"type": "file",
"path": "tata/Les Compléments Alimentaires Un Guide Général.md",
"title": "Les Compléments Alimentaires Un Guide Général.md",
"ctime": 1763156633684
}
]
} }

View File

@ -12,7 +12,7 @@ task: false
archive: true archive: true
draft: false draft: false
private: false private: false
description: "Stargate Atlantis: une expédition militaire et scientifique découvre la cité mythique d'Atlantis dans la galaxie de Pégase et affronte les Wraiths." description: "Une expédition militaire et scientifique découvre la cité mythique d'Atlantis dans la galaxie de Pégase et affronte les Wraiths."
--- ---
*Stargate Atlantis* est une série de science-fiction dérivée de la populaire *Stargate SG-1*. Elle suit les aventures d'une expédition internationale, composée de scientifiques et de militaires, qui voyage à travers la porte des étoiles vers la lointaine galaxie de Pégase. Leur destination est la cité mythique d'Atlantis, une métropole volante abandonnée construite par une race ancienne et technologiquement supérieure connue sous le nom d'Anciens. *Stargate Atlantis* est une série de science-fiction dérivée de la populaire *Stargate SG-1*. Elle suit les aventures d'une expédition internationale, composée de scientifiques et de militaires, qui voyage à travers la porte des étoiles vers la lointaine galaxie de Pégase. Leur destination est la cité mythique d'Atlantis, une métropole volante abandonnée construite par une race ancienne et technologiquement supérieure connue sous le nom d'Anciens.

View File

@ -0,0 +1,32 @@
---
titre: Drawing-20251114-1647.excalidraw
auteur: Bruno Charest
creation_date: 2025-11-14T16:47:49-04:00
modification_date: 2025-11-14T16:47:49-04:00
catégorie: ""
tags: []
aliases: []
status: en-cours
publish: false
favoris: false
template: false
task: false
archive: false
draft: false
private: false
excalidraw-plugin: parsed
created: 2025-11-14T21:47:48.100Z
updated: 2025-11-14T21:47:48.100Z
title: Drawing-20251114-1647
---
==⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠==
# Excalidraw Data
## Text Elements
%%
## Drawing
```compressed-json
N4IgLgngDgpiBcIYA8DGBDANgSwCYCd0B3EAGhADcZ8BnbAewDsEAmcm+gV31TkXoBGdXNnSMAtCgw4CxcVEycA5tmbkYmGAFsYjMDQQBtALrl0UKAGUw6MH2ABfcgDNsmg/EcOgA===
```
%%

View File

@ -0,0 +1,17 @@
---
excalidraw-plugin: parsed
created: "2025-11-14T21:47:48.100Z"
updated: "2025-11-14T21:47:48.100Z"
title: "Drawing-20251114-1647"
---
==⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠==
# Excalidraw Data
## Text Elements
%%
## Drawing
```compressed-json
N4IgLgngDgpiBcIYA8DGBDANgSwCYCd0B3EAGhADcZ8BnbAewDsEAmcm+gV31TkXoBGdXNnSMAtCgw4CxcVEycA5tmbkYmGAFsYjMDQQBtALrl0UKAGUw6MH2ABfcgDNsmg/EcOgA===
```
%%

Binary file not shown.

After

Width:  |  Height:  |  Size: 805 KiB

View File

@ -7,11 +7,12 @@ tags: [""]
aliases: [""] aliases: [""]
status: "en-cours" status: "en-cours"
publish: false publish: false
favoris: false favoris: true
template: false template: false
task: false task: false
archive: false archive: false
draft: false draft: false
private: false private: false
description: "Nouvelle note 7 de Bruno Charest, actuellement en cours de rédaction." description: "Note intitulée 'Nouvelle note 7', rédigée par Bruno Charest et en cours de travail."
color: "#F59E0B"
--- ---

View File

@ -13,7 +13,7 @@ draft: true
private: false private: false
titre: "" titre: ""
readOnly: false readOnly: false
description: "Les Compléments Alimentaires : Un Guide Général Dans notre quête constante de bien-être et de..." description: "Ce guide offre une vue d'ensemble équilibrée sur les compléments alimentaires, leurs bénéfices, risques et l'importance d'une utilisation éclairée."
color: "#00AEEF" color: "#00AEEF"
--- ---
## Les Compléments Alimentaires : Un Guide Général ## Les Compléments Alimentaires : Un Guide Général