refactor: improve list type icons and drag-drop UX

- Replaced emoji icons with consistent SVG icons for checkbox, bullet, and numbered lists in context menu and inline toolbar
- Enhanced drag-drop visual feedback with subtle highlight on target blocks during dragging
- Refined column creation logic to use deterministic drop modes (column-left, column-right, column-gap) for more predictable behavior
- Improved comment button z-index to prevent overlap with drag indicators
This commit is contained in:
Bruno Charest 2025-11-11 23:06:33 -05:00
parent 386007d351
commit 03857f15ff
13 changed files with 626 additions and 200 deletions

View File

@ -741,7 +741,38 @@ export interface MenuAction {
(click)="onConvert(item.type, item.preset)" (click)="onConvert(item.type, item.preset)"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="text-base">{{ item.icon }}</span> <span class="text-base w-5 flex items-center justify-center">
@if (item.type === 'list-item' && item.preset?.kind === 'check') {
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="5" height="5" rx="1"/>
<path d="M10 6h10"/>
<rect x="3" y="10" width="5" height="5" rx="1"/>
<path d="M10 13h10"/>
<rect x="3" y="17" width="5" height="5" rx="1"/>
<path d="M10 20h10"/>
</svg>
} @else if (item.type === 'list-item' && item.preset?.kind === 'bullet') {
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="5.5" cy="5.5" r="1.5"/>
<path d="M10 6h10"/>
<circle cx="5.5" cy="12.5" r="1.5"/>
<path d="M10 13h10"/>
<circle cx="5.5" cy="19.5" r="1.5"/>
<path d="M10 20h10"/>
</svg>
} @else if (item.type === 'list-item' && item.preset?.kind === 'numbered') {
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<text x="4" y="7" font-size="5" text-anchor="middle" dominant-baseline="middle" fill="currentColor" stroke="none">1</text>
<path d="M10 7h10"/>
<text x="4" y="14" font-size="5" text-anchor="middle" dominant-baseline="middle" fill="currentColor" stroke="none">2</text>
<path d="M10 14h10"/>
<text x="4" y="21" font-size="5" text-anchor="middle" dominant-baseline="middle" fill="currentColor" stroke="none">3</text>
<path d="M10 21h10"/>
</svg>
} @else {
{{ item.icon }}
}
</span>
<span>{{ item.label }}</span> <span>{{ item.label }}</span>
</div> </div>
<span class="text-xs text-text-muted">{{ item.shortcut }}</span> <span class="text-xs text-text-muted">{{ item.shortcut }}</span>
@ -1079,9 +1110,9 @@ export class BlockContextMenuComponent implements OnChanges {
} }
convertOptions = [ convertOptions = [
{ type: 'list' as BlockType, preset: { kind: 'checklist' }, icon: '☑️', label: 'Checklist', shortcut: 'ctrl+shift+c' }, { type: 'list-item' as BlockType, preset: { kind: 'check', checked: false, text: '' }, icon: '☑️', label: 'Checkbox list', shortcut: 'ctrl+shift+c' },
{ type: 'list' as BlockType, preset: { kind: 'number' }, icon: '🔢', label: 'Number List', shortcut: 'ctrl+shift+7' }, { type: 'list-item' as BlockType, preset: { kind: 'numbered', number: 1, text: '' }, icon: '1.', label: 'Numbered list', shortcut: 'ctrl+shift+7' },
{ type: 'list' as BlockType, preset: { kind: 'bullet' }, icon: '•', label: 'Bullet List', shortcut: 'ctrl+shift+8' }, { type: 'list-item' as BlockType, preset: { kind: 'bullet', text: '' }, icon: '•', label: 'Bullet list', shortcut: 'ctrl+shift+8' },
{ type: 'toggle' as BlockType, preset: null, icon: '▶️', label: 'Toggle Block', shortcut: 'ctrl+alt+6' }, { type: 'toggle' as BlockType, preset: null, icon: '▶️', label: 'Toggle Block', shortcut: 'ctrl+alt+6' },
{ type: 'paragraph' as BlockType, preset: null, icon: '¶', label: 'Paragraph', shortcut: 'ctrl+alt+7' }, { type: 'paragraph' as BlockType, preset: null, icon: '¶', label: 'Paragraph', shortcut: 'ctrl+alt+7' },
{ type: 'steps' as BlockType, preset: null, icon: '📝', label: 'Steps', shortcut: '' }, { type: 'steps' as BlockType, preset: null, icon: '📝', label: 'Steps', shortcut: '' },

View File

@ -11,7 +11,7 @@ import { ComponentPortal, PortalModule } from '@angular/cdk/portal';
import { BlockCommentComposerComponent } from '../comment/block-comment-composer.component'; import { BlockCommentComposerComponent } from '../comment/block-comment-composer.component';
import { ImageInfoModalComponent } from '../image/image-info-modal.component'; import { ImageInfoModalComponent } from '../image/image-info-modal.component';
import { ImageCaptionModalComponent } from '../image/image-caption-modal.component'; import { ImageCaptionModalComponent } from '../image/image-caption-modal.component';
import { BlockInitialMenuComponent, BlockMenuAction } from './block-initial-menu.component'; import { BlockMenuAction } from './block-initial-menu.component';
// Import block components // Import block components
import { ParagraphBlockComponent } from './blocks/paragraph-block.component'; import { ParagraphBlockComponent } from './blocks/paragraph-block.component';
@ -64,7 +64,6 @@ import { ColumnsBlockComponent } from './blocks/columns-block.component';
OutlineBlockComponent, OutlineBlockComponent,
LineBlockComponent, LineBlockComponent,
ColumnsBlockComponent, ColumnsBlockComponent,
BlockInitialMenuComponent,
OverlayModule, OverlayModule,
PortalModule PortalModule
], ],
@ -72,12 +71,17 @@ import { ColumnsBlockComponent } from './blocks/columns-block.component';
<div <div
class="block-wrapper group relative" class="block-wrapper group relative"
[attr.data-block-id]="block.id" [attr.data-block-id]="block.id"
[attr.data-block-index]="index"
[class.active]="isActive()" [class.active]="isActive()"
[class.locked]="block.meta?.locked" [class.locked]="block.meta?.locked"
[style.background-color]="block.type === 'list-item' ? null : block.meta?.bgColor" [style.background-color]="block.type === 'list-item' ? null : block.meta?.bgColor"
[ngStyle]="blockStyles()" [ngStyle]="blockStyles()"
(click)="onBlockClick($event)" (click)="onBlockClick($event)"
> >
<!-- Light target highlight for UX clarity -->
@if (dragDrop.dragging() && shouldHighlight()) {
<div class="absolute inset-0 rounded-md bg-cyan-400/5 ring-1 ring-cyan-400/20 pointer-events-none"></div>
}
<!-- Ellipsis menu handle (hidden for columns block as it has its own per-block buttons) --> <!-- Ellipsis menu handle (hidden for columns block as it has its own per-block buttons) -->
@if (block.type !== 'columns') { @if (block.type !== 'columns') {
<button <button
@ -109,11 +113,6 @@ import { ColumnsBlockComponent } from './blocks/columns-block.component';
(deleteBlock)="onDeleteBlock()" (deleteBlock)="onDeleteBlock()"
/> />
</div> </div>
@if (showInlineMenu) {
<div class="flex-shrink-0">
<app-block-initial-menu (action)="onInlineMenuAction($event)" />
</div>
}
</div> </div>
} }
@case ('heading') { @case ('heading') {
@ -183,13 +182,13 @@ import { ColumnsBlockComponent } from './blocks/columns-block.component';
</div> </div>
<ng-container *ngIf="block.type !== 'table'"> <ng-container *ngIf="block.type !== 'table'">
<!-- Filled white speech bubble with count (count in black) --> <!-- Filled white speech bubble with count (count in black) -->
<button *ngIf="totalComments() > 0" class="absolute top-1/2 -translate-y-1/2 right-2 w-8 h-8 flex items-center justify-center" <button *ngIf="totalComments() > 0" class="absolute top-1/2 -translate-y-1/2 right-2 w-8 h-8 flex items-center justify-center z-20"
title="View comments" (click)="openComments()"> title="View comments" (click)="openComments()">
<svg viewBox="0 0 24 24" class="w-7 h-7"><path fill="#e5e7eb" d="M12 4c4.97 0 9 2.96 9 6.6S16.97 17.2 12 17.2c-.75 0-1.49-.07-2.2-.22L6 20l1.57-3.14C6.13 16 3 13.86 3 10.6 3 6.96 7.03 4 12 4z"/></svg> <svg viewBox="0 0 24 24" class="w-7 h-7"><path fill="#e5e7eb" d="M12 4c4.97 0 9 2.96 9 6.6S16.97 17.2 12 17.2c-.75 0-1.49-.07-2.2-.22L6 20l1.57-3.14C6.13 16 3 13.86 3 10.6 3 6.96 7.03 4 12 4z"/></svg>
<span class="absolute text-[11px] font-semibold text-black">{{ totalComments() }}</span> <span class="absolute text-[11px] font-semibold text-black">{{ totalComments() }}</span>
</button> </button>
<!-- Outline bubble (no comments) --> <!-- Outline bubble (no comments) -->
<button *ngIf="totalComments() === 0" class="absolute top-1/2 -translate-y-1/2 right-2 w-8 h-8 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center" <button *ngIf="totalComments() === 0" class="absolute top-1/2 -translate-y-1/2 right-2 w-8 h-8 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center z-20"
title="Add a comment" (click)="openComments()"> title="Add a comment" (click)="openComments()">
<svg viewBox="0 0 24 24" class="w-7 h-7"><path fill="none" stroke="#e5e7eb" stroke-width="1.5" d="M12 4c4.97 0 9 2.96 9 6.6S16.97 17.2 12 17.2c-.75 0-1.49-.07-2.2-.22L6 20l1.57-3.14C6.13 16 3 13.86 3 10.6 3 6.96 7.03 4 12 4z"/></svg> <svg viewBox="0 0 24 24" class="w-7 h-7"><path fill="none" stroke="#e5e7eb" stroke-width="1.5" d="M12 4c4.97 0 9 2.96 9 6.6S16.97 17.2 12 17.2c-.75 0-1.49-.07-2.2-.22L6 20l1.57-3.14C6.13 16 3 13.86 3 10.6 3 6.96 7.03 4 12 4z"/></svg>
</button> </button>
@ -240,7 +239,7 @@ export class BlockHostComponent implements OnDestroy {
private readonly selectionService = inject(SelectionService); private readonly selectionService = inject(SelectionService);
private readonly documentService = inject(DocumentService); private readonly documentService = inject(DocumentService);
private readonly dragDrop = inject(DragDropService); readonly dragDrop = inject(DragDropService);
private readonly comments = inject(CommentStoreService); private readonly comments = inject(CommentStoreService);
private readonly overlay = inject(Overlay); private readonly overlay = inject(Overlay);
private readonly host = inject(ElementRef<HTMLElement>); private readonly host = inject(ElementRef<HTMLElement>);
@ -258,6 +257,19 @@ export class BlockHostComponent implements OnDestroy {
this.isActive.set(this.selectionService.isActive(this.block.id)); this.isActive.set(this.selectionService.isActive(this.block.id));
} }
// Whether to show a subtle highlight on this block while dragging
shouldHighlight(): boolean {
if (!this.dragDrop.dragging()) return false;
const mode = this.dragDrop.dropMode();
const over = this.dragDrop.overIndex();
if (mode === 'line') {
// Highlight the two blocks around the insertion line to aid targeting
return over === this.index || over === this.index + 1;
}
// For column creation (left/right), highlight the target block only
return over === this.index;
}
onBlockClick(event: MouseEvent): void { onBlockClick(event: MouseEvent): void {
if (!this.block.meta?.locked) { if (!this.block.meta?.locked) {
this.selectionService.setActive(this.block.id); this.selectionService.setActive(this.block.id);
@ -312,6 +324,11 @@ export class BlockHostComponent implements OnDestroy {
if (!moved) return; if (!moved) return;
if (to < 0) return; if (to < 0) return;
if (from < 0) return; if (from < 0) return;
// Cancel if dropped outside the editor container
const crect = this.dragDrop.getContainerRect();
if (crect && (e.clientX < crect.left || e.clientX > crect.right || e.clientY < crect.top || e.clientY > crect.bottom)) {
return;
}
// Check if dropping into or between columns // Check if dropping into or between columns
const target = document.elementFromPoint(e.clientX, e.clientY); const target = document.elementFromPoint(e.clientX, e.clientY);
@ -326,22 +343,63 @@ export class BlockHostComponent implements OnDestroy {
if (columnsBlock && columnsBlock.type === 'columns') { if (columnsBlock && columnsBlock.type === 'columns') {
const columnEl = target.closest('[data-column-id]'); const columnEl = target.closest('[data-column-id]');
if (columnEl) { // If indicator is a gap between columns, create a new column deterministically
// Dropping INTO an existing column if (mode === 'column-gap') {
// Determine the columns container and compute gap index by pointer X
let columnsContainerEl = (columnsBlockEl.querySelector('[data-column-id]') as HTMLElement | null);
columnsContainerEl = (columnsContainerEl?.parentElement as HTMLElement) || columnsContainerEl;
if (!columnsContainerEl) columnsContainerEl = columnsBlockEl as HTMLElement;
if (columnsContainerEl) {
const containerRect = columnsContainerEl.getBoundingClientRect();
const props = columnsBlock.props as any;
const columns = [...(props.columns || [])];
const relativeX = e.clientX - containerRect.left;
const columnWidth = containerRect.width / columns.length;
let insertIndex = Math.floor(relativeX / columnWidth);
const gapThreshold = 60;
const posInColumn = (relativeX % columnWidth);
// if near right border of current column, insert after
if (posInColumn > (columnWidth - gapThreshold)) insertIndex += 1;
const draggedCopy = JSON.parse(JSON.stringify(this.block));
const newColumn = { id: this.generateId(), blocks: [draggedCopy], width: 100 / (columns.length + 1) };
const updatedColumns = columns.map((col: any) => ({ ...col, width: 100 / (columns.length + 1) }));
const clampedIndex = Math.max(0, Math.min(updatedColumns.length, insertIndex));
updatedColumns.splice(clampedIndex, 0, newColumn);
this.documentService.updateBlockProps(columnsBlockId, { columns: updatedColumns });
this.documentService.deleteBlock(this.block.id);
this.selectionService.setActive(draggedCopy.id);
return;
}
} else if (columnEl) {
// Dropping over an existing column: if near left/right border, create a new column; else insert into the column
const colIndex = parseInt(columnEl.getAttribute('data-column-index') || '0'); const colIndex = parseInt(columnEl.getAttribute('data-column-index') || '0');
const props = columnsBlock.props as any; const props = columnsBlock.props as any;
const columns = [...(props.columns || [])]; const columns = [...(props.columns || [])];
const cRect = (columnEl as HTMLElement).getBoundingClientRect();
const gapThreshold = 60;
const distLeft = e.clientX - cRect.left;
const distRight = cRect.right - e.clientX;
// Add dragged block to target column if (distLeft < gapThreshold || distRight < gapThreshold) {
// Create a new column before/after this one
const draggedCopy = JSON.parse(JSON.stringify(this.block));
const newColumn = { id: this.generateId(), blocks: [draggedCopy], width: 100 / (columns.length + 1) };
const updatedColumns = columns.map((col: any) => ({ ...col, width: 100 / (columns.length + 1) }));
const insertAt = distLeft < distRight ? colIndex : colIndex + 1;
const clamped = Math.max(0, Math.min(updatedColumns.length, insertAt));
updatedColumns.splice(clamped, 0, newColumn);
this.documentService.updateBlockProps(columnsBlockId, { columns: updatedColumns });
this.documentService.deleteBlock(this.block.id);
this.selectionService.setActive(draggedCopy.id);
return;
} else {
// Insert INTO the column (at a precise block index if hovered one)
const blockCopy = JSON.parse(JSON.stringify(this.block)); const blockCopy = JSON.parse(JSON.stringify(this.block));
// Determine insertion index within column
const blockEl = target.closest('[data-block-id]'); const blockEl = target.closest('[data-block-id]');
let insertIndex = columns[colIndex]?.blocks?.length || 0; let insertIndex = columns[colIndex]?.blocks?.length || 0;
if (blockEl && blockEl.getAttribute('data-block-id') !== columnsBlockId) { if (blockEl && blockEl.getAttribute('data-block-id') !== columnsBlockId) {
insertIndex = parseInt(blockEl.getAttribute('data-block-index') || '0'); insertIndex = parseInt(blockEl.getAttribute('data-block-index') || '0');
} }
columns[colIndex] = { columns[colIndex] = {
...columns[colIndex], ...columns[colIndex],
blocks: [ blocks: [
@ -350,16 +408,13 @@ export class BlockHostComponent implements OnDestroy {
...columns[colIndex].blocks.slice(insertIndex) ...columns[colIndex].blocks.slice(insertIndex)
] ]
}; };
// Update columns block
this.documentService.updateBlockProps(columnsBlockId, { columns }); this.documentService.updateBlockProps(columnsBlockId, { columns });
// Delete original block
this.documentService.deleteBlock(this.block.id); this.documentService.deleteBlock(this.block.id);
this.selectionService.setActive(blockCopy.id); this.selectionService.setActive(blockCopy.id);
return; return;
}
} else { } else {
// Dropping in the gap BETWEEN columns - insert as new column // Fallback gap detection (legacy) - insert as new column
let columnsContainerEl = columnsBlockEl.querySelector('[data-column-id]') as HTMLElement | null; let columnsContainerEl = columnsBlockEl.querySelector('[data-column-id]') as HTMLElement | null;
columnsContainerEl = (columnsContainerEl?.parentElement as HTMLElement) || columnsContainerEl; columnsContainerEl = (columnsContainerEl?.parentElement as HTMLElement) || columnsContainerEl;
if (!columnsContainerEl) { if (!columnsContainerEl) {
@ -413,31 +468,22 @@ export class BlockHostComponent implements OnDestroy {
} }
} }
} else { } else {
// Not a columns block: detect lateral drop on a normal block to create a 2-column layout // Not a columns block: deterministically create a 2-column layout using dropMode and overIndex
const targetWrapper = target.closest('.block-wrapper[data-block-id]') as HTMLElement | null; const modeNow = mode; // from endDrag()
const targetBlockId = targetWrapper?.getAttribute('data-block-id') || null; if (modeNow === 'column-left' || modeNow === 'column-right') {
if (targetWrapper && targetBlockId && targetBlockId !== this.block.id) {
const contentEl = targetWrapper.querySelector('.block-content') as HTMLElement | null;
const rect = (contentEl || targetWrapper).getBoundingClientRect();
const relX = e.clientX - rect.left;
const sideThreshold = Math.min(80, Math.max(40, rect.width * 0.15)); // 15% width, clamped 40-80px
const onLeft = relX <= sideThreshold;
const onRight = relX >= rect.width - sideThreshold;
if (onLeft || onRight) {
const all = this.documentService.blocks(); const all = this.documentService.blocks();
const targetIndex = all.findIndex(b => b.id === targetBlockId); const tgtIdx = Math.max(0, Math.min(all.length - 1, to >= all.length ? all.length - 1 : to));
if (targetIndex >= 0) { const targetBlock = all[tgtIdx];
// Prepare new columns block with dragged + target in the right order if (targetBlock && targetBlock.id !== this.block.id) {
const draggedCopy = JSON.parse(JSON.stringify(this.block)); const draggedCopy = JSON.parse(JSON.stringify(this.block));
const targetBlock = all[targetIndex]; const columns = (modeNow === 'column-left')
const columns = onLeft
? [ { id: this.generateId(), blocks: [draggedCopy], width: 50 }, { id: this.generateId(), blocks: [JSON.parse(JSON.stringify(targetBlock))], width: 50 } ] ? [ { id: this.generateId(), blocks: [draggedCopy], width: 50 }, { id: this.generateId(), blocks: [JSON.parse(JSON.stringify(targetBlock))], width: 50 } ]
: [ { id: this.generateId(), blocks: [JSON.parse(JSON.stringify(targetBlock))], width: 50 }, { id: this.generateId(), blocks: [draggedCopy], width: 50 } ]; : [ { id: this.generateId(), blocks: [JSON.parse(JSON.stringify(targetBlock))], width: 50 }, { id: this.generateId(), blocks: [draggedCopy], width: 50 } ];
const newColumnsBlock = this.documentService.createBlock('columns', { columns }); const newColumnsBlock = this.documentService.createBlock('columns', { columns });
// Insert new columns block before the target, then delete old dragged + target blocks const beforeId = tgtIdx > 0 ? all[tgtIdx - 1].id : null;
const beforeId = targetIndex > 0 ? all[targetIndex - 1].id : null; // Insert new columns before target, delete original two blocks
this.documentService.insertBlock(beforeId, newColumnsBlock); this.documentService.insertBlock(beforeId, newColumnsBlock);
this.documentService.deleteBlock(targetBlockId); this.documentService.deleteBlock(targetBlock.id);
this.documentService.deleteBlock(this.block.id); this.documentService.deleteBlock(this.block.id);
this.selectionService.setActive(draggedCopy.id); this.selectionService.setActive(draggedCopy.id);
return; return;
@ -446,15 +492,13 @@ export class BlockHostComponent implements OnDestroy {
} }
} }
} }
}
// Handle regular line move // Handle regular line move
const blocks = this.documentService.blocks(); const blocks = this.documentService.blocks();
let toIndex = to; let toIndex = to;
if (toIndex > from) toIndex = toIndex - 1;
if (toIndex < 0) toIndex = 0; if (toIndex < 0) toIndex = 0;
if (toIndex > blocks.length - 1) toIndex = blocks.length - 1; if (toIndex > blocks.length) toIndex = blocks.length; // allow end
if (toIndex === from) return; if (toIndex === from) return; // exact same slot
this.documentService.moveBlock(this.block.id, toIndex); this.documentService.moveBlock(this.block.id, toIndex);
this.selectionService.setActive(this.block.id); this.selectionService.setActive(this.block.id);
}; };

View File

@ -23,7 +23,7 @@ export interface BlockMenuAction {
</svg> </svg>
</button> </button>
<!-- Checkbox --> <!-- Checkbox (square with check) -->
<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"
title="Checkbox list" title="Checkbox list"
@ -31,12 +31,12 @@ export interface BlockMenuAction {
type="button" type="button"
> >
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="8" height="8" rx="1"/> <rect x="3" y="4" width="14" height="14" rx="2"/>
<path d="M6 7l2 2 4-4"/> <path d="M6.5 11.5l3 3 5-6"/>
</svg> </svg>
</button> </button>
<!-- Bullet list (3 horizontal lines) --> <!-- Bullet list (dots + lines) -->
<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"
title="Bullet list" title="Bullet list"
@ -44,11 +44,16 @@ export interface BlockMenuAction {
type="button" type="button"
> >
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M4 6h16M4 12h16M4 18h16"/> <circle cx="5" cy="7" r="1.6" fill="currentColor"/>
<path d="M9 7h11"/>
<circle cx="5" cy="12" r="1.6" fill="currentColor"/>
<path d="M9 12h11"/>
<circle cx="5" cy="17" r="1.6" fill="currentColor"/>
<path d="M9 17h11"/>
</svg> </svg>
</button> </button>
<!-- Numbered list (3 horizontal lines) --> <!-- Numbered list (1/2/3 + lines) -->
<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"
title="Numbered list" title="Numbered list"
@ -56,8 +61,10 @@ export interface BlockMenuAction {
type="button" type="button"
> >
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M8 6h13M8 12h13M8 18h13"/> <path d="M3.5 7h1.5"/><path d="M3 12h2"/><path d="M3 17h2"/>
<path d="M3 6h.01M3 12h.01M3 18h.01"/> <path d="M8 7h12"/>
<path d="M8 12h12"/>
<path d="M8 17h12"/>
</svg> </svg>
</button> </button>

View File

@ -10,7 +10,7 @@ export interface InlineToolbarAction {
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule],
template: ` template: `
<div class="group/block relative flex items-center gap-2 z-[60]"> <div class="group/block relative flex items-center justify-between gap-2 bg-[var(--editor-bg)] rounded-2xl px-4 py-2 shadow-sm z-[60]">
<!-- Drag handle (visible on hover) --> <!-- Drag handle (visible on hover) -->
@if (showDragHandle) { @if (showDragHandle) {
<div <div
@ -41,93 +41,121 @@ export interface InlineToolbarAction {
} }
<!-- Input area and inline icons --> <!-- Input area and inline icons -->
<div class="flex-1 flex items-center gap-1 px-2 py-0.5 rounded-lg transition-colors"> <div class="flex items-center gap-2 w-full">
<!-- Slot for actual content -->
<div class="flex-1"> <div class="flex-1">
<ng-content /> <ng-content />
</div> </div>
<!-- Quick action icons (visible on hover or focus) --> <div class="flex items-center gap-1 select-none">
<div
class="flex items-center gap-0.5 transition-opacity"
[class.opacity-100]="isEmpty() && isFocused()"
[class.opacity-0]="!isEmpty() || !isFocused()"
[class.pointer-events-none]="!isEmpty() || !isFocused()"
>
<!-- Use AI --> <!-- Use AI -->
<button <button *ngIf="!actions || actions.includes('use-ai')"
class="p-1 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200" class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
title="Use AI" title="Use AI"
(click)="onAction('use-ai')" (click)="onAction('use-ai')"
type="button" type="button"
> >
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-6 h-6" viewBox="0 0 24 24" aria-label="Assistant AI de rédaction"
<path d="M12 3l9 4.5v9L12 21l-9-4.5v-9L12 3z"/> fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 12l9-4.5M12 12v9M12 12L3 7.5"/> <!-- Document -->
<rect x="3" y="2.5" width="12.5" height="17" rx="2.2"/>
<!-- Lignes de texte -->
<path d="M6 7h7.5M6 10h7.5M6 13h6"/>
<!-- Étincelle IA -->
<path d="M15.8 4.2v1.6M15 5h1.6M15.3 4.3l1.1 1.1M15.3 5.4l1.1-1.1"/>
<!-- Plume (nib) en bas à droite -->
<path d="M19 13.8l3.2 3.2-5 5-3.2-3.2z"/>
<circle cx="17.8" cy="18.2" r="0.9"/>
<path d="M13.9 18.9l-1.6.5.5-1.6"/>
</svg> </svg>
</button> </button>
<!-- Checkbox list --> <!-- Checkbox list amélioré -->
<button <button *ngIf="!actions || actions.includes('checkbox-list')"
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200" class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
title="Checkbox list" title="Checkbox list"
(click)="onAction('checkbox-list')" (click)="onAction('checkbox-list')"
type="button" type="button"
> >
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="5" width="6" height="6" rx="1"/> <!-- Ligne 1 -->
<path d="M5 8l1.5 1.5L9 7"/> <rect x="3" y="3" width="5" height="5" rx="1"/>
<path d="M13 7h8"/> <path d="M10 6h10"/>
<!-- Ligne 2 -->
<rect x="3" y="10" width="5" height="5" rx="1"/>
<path d="M10 13h10"/>
<!-- Ligne 3 -->
<rect x="3" y="17" width="5" height="5" rx="1"/>
<path d="M10 20h10"/>
</svg> </svg>
</button> </button>
<!-- Bullet list --> <!-- Bullet list -->
<button <button *ngIf="!actions || actions.includes('bullet-list')"
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200" class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
title="Bullet list" title="Bullet list"
(click)="onAction('bullet-list')" (click)="onAction('bullet-list')"
type="button" type="button"
> >
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="5" cy="7" r="1" fill="currentColor"/> <!-- Ligne 1 -->
<path d="M9 7h12"/> <circle cx="5.5" cy="5.5" r="1.5"/>
<path d="M10 6h10"/>
<!-- Ligne 2 -->
<circle cx="5.5" cy="12.5" r="1.5"/>
<path d="M10 13h10"/>
<!-- Ligne 3 -->
<circle cx="5.5" cy="19.5" r="1.5"/>
<path d="M10 20h10"/>
</svg> </svg>
</button> </button>
<!-- Numbered list --> <!-- Numbered list -->
<button <button *ngIf="!actions || actions.includes('numbered-list')"
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200" class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
title="Numbered list" title="Numbered list"
(click)="onAction('numbered-list')" (click)="onAction('numbered-list')"
type="button" type="button"
> >
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 6h11"/> <text x="4" y="7" font-size="5" text-anchor="middle" dominant-baseline="middle" fill="currentColor" stroke="none">1</text>
<path d="M4 6h1v4M4 10h2"/> <path d="M10 7h10"/>
<text x="4" y="14" font-size="5" text-anchor="middle" dominant-baseline="middle" fill="currentColor" stroke="none">2</text>
<path d="M10 14h10"/>
<text x="4" y="21" font-size="5" text-anchor="middle" dominant-baseline="middle" fill="currentColor" stroke="none">3</text>
<path d="M10 21h10"/>
</svg> </svg>
</button> </button>
<!-- Table --> <!-- Table -->
<button <button *ngIf="!actions || actions.includes('table')"
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200" class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
title="Table" title="Table"
(click)="onAction('table')" (click)="onAction('table')"
type="button" type="button"
> >
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/> <rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M3 9h18M9 3v18"/> <path d="M3 9h18M9 3v18"/>
</svg> </svg>
</button> </button>
<!-- Image --> <!-- Image -->
<button <button *ngIf="!actions || actions.includes('image')"
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200" class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
title="Image" title="Image"
(click)="onAction('image')" (click)="onAction('image')"
type="button" type="button"
> >
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/> <rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/> <circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/> <path d="M21 15l-5-5L5 21"/>
@ -135,34 +163,34 @@ export interface InlineToolbarAction {
</button> </button>
<!-- File --> <!-- File -->
<button <button *ngIf="!actions || actions.includes('file')"
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200" class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
title="File" title="File"
(click)="onAction('file')" (click)="onAction('file')"
type="button" type="button"
> >
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9l-7-7z"/> <path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9l-7-7z"/>
<path d="M13 2v7h7"/> <path d="M13 2v7h7"/>
</svg> </svg>
</button> </button>
<!-- Link/New Page --> <!-- Link/New Page -->
<button <button *ngIf="!actions || actions.includes('new-page')"
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200" class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
title="Link to page" title="Link to page"
(click)="onAction('new-page')" (click)="onAction('new-page')"
type="button" type="button"
> >
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/> <path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/> <path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/>
</svg> </svg>
</button> </button>
<!-- Heading M --> <!-- Heading M -->
<button <button *ngIf="!actions || actions.includes('heading-2')"
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200 font-bold text-xs" class="px-1.5 py-1 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer font-bold text-xs"
title="Heading 2" title="Heading 2"
(click)="onAction('heading-2')" (click)="onAction('heading-2')"
type="button" type="button"
@ -170,16 +198,16 @@ export interface InlineToolbarAction {
H<sub class="text-[8px]">M</sub> H<sub class="text-[8px]">M</sub>
</button> </button>
<div class="w-px h-3 bg-neutral-600"></div> <div *ngIf="!actions || actions.length > 1" class="border-l border-gray-600 mx-2 h-4"></div>
<!-- More items (opens menu) --> <!-- More items (opens menu) -->
<button <button *ngIf="!actions || actions.includes('more')"
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200" class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
title="More items" title="More items"
(click)="onAction('more')" (click)="onAction('more')"
type="button" type="button"
> >
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/> <path d="M6 9l6 6 6-6"/>
</svg> </svg>
</button> </button>
@ -210,6 +238,8 @@ export class BlockInlineToolbarComponent {
@Input() isEmpty = signal(true); @Input() isEmpty = signal(true);
// New: whether to show the drag handle (default true, false in columns) // New: whether to show the drag handle (default true, false in columns)
@Input() showDragHandle = true; @Input() showDragHandle = true;
// New: list of actions to render; when undefined, render all
@Input() actions: InlineToolbarAction['type'][] | undefined;
@Output() action = new EventEmitter<InlineToolbarAction['type']>(); @Output() action = new EventEmitter<InlineToolbarAction['type']>();

View File

@ -57,7 +57,7 @@ import { BlockContextMenuComponent } from '../block-context-menu.component';
BlockContextMenuComponent BlockContextMenuComponent
], ],
template: ` template: `
<div class="flex gap-2 w-full relative" #columnsContainer> <div class="flex gap-6 w-full relative" #columnsContainer>
@for (column of props.columns; track column.id; let colIndex = $index) { @for (column of props.columns; track column.id; let colIndex = $index) {
<div <div
class="flex-1 min-w-0" class="flex-1 min-w-0"
@ -87,33 +87,26 @@ import { BlockContextMenuComponent } from '../block-context-menu.component';
</svg> </svg>
</button> </button>
<!-- Comment button - Outside right, centered vertically -->
<button
type="button"
class="absolute -right-9 top-1/2 -translate-y-1/2 w-7 h-7 flex items-center justify-center opacity-0 group-hover/block:opacity-100 transition-opacity bg-gray-700 hover:bg-gray-600 rounded-md z-10"
[class.!opacity-100]="getBlockCommentCount(block.id) > 0"
[class.bg-blue-600]="getBlockCommentCount(block.id) > 0"
[class.hover:bg-blue-500]="getBlockCommentCount(block.id) > 0"
title="Comments"
(click)="openComments(block.id)"
>
@if (getBlockCommentCount(block.id) > 0) {
<span class="text-[10px] font-semibold text-white">
{{ getBlockCommentCount(block.id) }}
</span>
} @else {
<svg class="w-4 h-4 text-gray-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
}
</button>
<!-- Render block with background color support --> <!-- Render block with background color support -->
<div <div
class="relative px-1.5 py-0.5 rounded transition-colors" class="relative px-1.5 py-0.5 pr-8 rounded transition-colors"
[style.background-color]="getBlockBgColor(block)" [style.background-color]="getBlockBgColor(block)"
[ngStyle]="getBlockStyles(block)" [ngStyle]="getBlockStyles(block)"
> >
<!-- Comment icon inside the block, aligned to the right -->
<ng-container>
<button *ngIf="getBlockCommentCount(block.id) > 0" class="absolute top-1/2 -translate-y-1/2 right-1 w-7 h-7 flex items-center justify-center z-20"
title="View comments" (click)="openComments(block.id)">
<svg viewBox="0 0 24 24" class="w-6 h-6"><path fill="#e5e7eb" d="M12 4c4.97 0 9 2.96 9 6.6S16.97 17.2 12 17.2c-.75 0-1.49-.07-2.2-.22L6 20l1.57-3.14C6.13 16 3 13.86 3 10.6 3 6.96 7.03 4 12 4z"/></svg>
<span class="absolute text-[11px] font-semibold text-black">{{ getBlockCommentCount(block.id) }}</span>
</button>
<button *ngIf="getBlockCommentCount(block.id) === 0" class="absolute top-1/2 -translate-y-1/2 right-1 w-7 h-7 opacity-0 group-hover/block:opacity-100 transition-opacity flex items-center justify-center z-20"
title="Add a comment" (click)="openComments(block.id)">
<svg viewBox="0 0 24 24" class="w-6 h-6"><path fill="none" stroke="#e5e7eb" stroke-width="1.5" d="M12 4c4.97 0 9 2.96 9 6.6S16.97 17.2 12 17.2c-.75 0-1.49-.07-2.2-.22L6 20l1.57-3.14C6.13 16 3 13.86 3 10.6 3 6.96 7.03 4 12 4z"/></svg>
</button>
</ng-container>
@switch (block.type) { @switch (block.type) {
@case ('heading') { @case ('heading') {
<app-heading-block <app-heading-block

View File

@ -1,35 +1,102 @@
import { Component, Input, Output, EventEmitter, inject, ViewChild, ElementRef, AfterViewInit, signal } from '@angular/core'; import { Component, Input, Output, EventEmitter, inject, ViewChild, ElementRef, AfterViewInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { BlockInlineToolbarComponent } from '../../block/block-inline-toolbar.component';
import { Block, ParagraphProps } from '../../../core/models/block.model'; 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';
@Component({ @Component({
selector: 'app-paragraph-block', selector: 'app-paragraph-block',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule], imports: [CommonModule, FormsModule, BlockInlineToolbarComponent],
template: ` template: `
<div <div
class="relative" class="relative"
(click)="onContainerClick($event)" (click)="onContainerClick($event)"
>
<app-block-inline-toolbar
[placeholder]="placeholder"
[isFocused]="isFocused"
[isEmpty]="isEmpty"
[showDragHandle]="false"
[actions]="undefined"
(action)="onInlineAction($event)"
> >
<div <div
#editable #editable
contenteditable="true" contenteditable="true"
class="w-full m-0 bg-transparent text-sm text-neutral-100 dark:text-neutral-100 focus:outline-none min-h-[1.25rem]" 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)" (input)="onInput($event)"
(keydown)="onKeyDown($event)" (keydown)="onKeyDown($event)"
(focus)="isFocused.set(true)" (focus)="isFocused.set(true)"
(blur)="onBlur()" (blur)="onBlur()"
[attr.data-placeholder]="placeholder" [attr.data-placeholder]="placeholder"
></div> ></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()">
@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="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"
(click)="selectItem(item)">
<span class="text-base w-5 flex items-center justify-center">
@if (item.id === 'checkbox-list') {
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="5" height="5" rx="1"/>
<path d="M10 6h10"/>
<rect x="3" y="10" width="5" height="5" rx="1"/>
<path d="M10 13h10"/>
<rect x="3" y="17" width="5" height="5" rx="1"/>
<path d="M10 20h10"/>
</svg>
} @else if (item.id === 'bullet-list') {
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="5.5" cy="5.5" r="1.5"/>
<path d="M10 6h10"/>
<circle cx="5.5" cy="12.5" r="1.5"/>
<path d="M10 13h10"/>
<circle cx="5.5" cy="19.5" r="1.5"/>
<path d="M10 20h10"/>
</svg>
} @else if (item.id === 'numbered-list') {
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<text x="4" y="7" font-size="5" text-anchor="middle" dominant-baseline="middle" fill="currentColor" stroke="none">1</text>
<path d="M10 7h10"/>
<text x="4" y="14" font-size="5" text-anchor="middle" dominant-baseline="middle" fill="currentColor" stroke="none">2</text>
<path d="M10 14h10"/>
<text x="4" y="21" font-size="5" text-anchor="middle" dominant-baseline="middle" fill="currentColor" stroke="none">3</text>
<path d="M10 21h10"/>
</svg>
} @else {
{{ item.icon }}
}
</span>
<div class="flex-1 truncate text-sm">{{ item.label }}</div>
@if (item.shortcut) {
<kbd class="px-1.5 py-0.5 text-[10px] font-mono bg-neutral-700 text-gray-400 rounded border border-neutral-600 flex-shrink-0">
{{ item.shortcut }}
</kbd>
}
</button>
}
</div>
</div>
}
</div>
}
</div> </div>
`, `,
styles: [` styles: [`
/* Show placeholder only when focused and empty */ /* Show placeholder when empty (focused or not) */
[contenteditable][data-placeholder]:empty:focus:before { [contenteditable][data-placeholder]:empty:before {
content: attr(data-placeholder); content: attr(data-placeholder);
color: rgb(107, 114, 128); color: rgb(107, 114, 128);
opacity: 0.6; opacity: 0.6;
@ -61,6 +128,124 @@ export class ParagraphBlockComponent implements AfterViewInit {
isFocused = signal(false); isFocused = signal(false);
isEmpty = signal(true); isEmpty = signal(true);
placeholder = "Start writing or type '/', '@'"; placeholder = "Start writing or type '/', '@'";
moreOpen = signal(false);
categories: PaletteCategory[] = ['BASIC','ADVANCED','MEDIA','INTEGRATIONS','VIEW','TEMPLATES','HELPFUL LINKS'];
onInlineAction(type: any): void {
if (type === 'more' || type === 'menu') {
this.moreOpen.set(!this.moreOpen());
return;
}
const id = this.block.id;
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.documentService.convertBlock(id, 'file');
break;
}
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[] {
return getPaletteItemsByCategory(category);
}
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.documentService.convertBlock(id, 'file');
break;
}
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);
// Keep focus on the same editable after conversion
setTimeout(() => this.editable?.nativeElement?.focus(), 0);
}
get props(): ParagraphProps { get props(): ParagraphProps {
return this.block.props; return this.block.props;
@ -99,19 +284,30 @@ export class ParagraphBlockComponent implements AfterViewInit {
return; return;
} }
// Handle "/" key: open palette // Handle "/" key: open inline dropdown
if (event.key === '/') { if (event.key === '/') {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
const text = target.textContent || ''; const text = target.textContent || '';
// 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.paletteService.open(); this.moreOpen.set(true);
return; return;
} }
} }
// Handle ENTER: Create new block below with initial menu // Handle "@" key: open inline dropdown (page/user search placeholder)
if (event.key === '@') {
const target = event.target as HTMLElement;
const text = target.textContent || '';
if (text.length === 0 || text.endsWith(' ')) {
event.preventDefault();
this.moreOpen.set(true);
return;
}
}
// Handle ENTER: Create new block below
if (event.key === 'Enter' && !event.shiftKey) { if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault(); event.preventDefault();
this.createBlock.emit(); this.createBlock.emit();
@ -124,17 +320,24 @@ export class ParagraphBlockComponent implements AfterViewInit {
return; return;
} }
// Handle BACKSPACE on empty block: Delete block // Handle BACKSPACE on empty block: open dropdown instead of delete
if (event.key === 'Backspace') { if (event.key === 'Backspace') {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
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.deleteBlock.emit(); this.moreOpen.set(true);
return; return;
} }
} }
// ESC closes dropdown
if (event.key === 'Escape' && this.moreOpen()) {
event.preventDefault();
this.moreOpen.set(false);
return;
}
// ArrowUp/ArrowDown navigation between blocks // ArrowUp/ArrowDown navigation between blocks
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
const el = (event.target as HTMLElement); const el = (event.target as HTMLElement);

View File

@ -254,9 +254,9 @@ export class EditorShellComponent implements AfterViewInit {
} else { } else {
// Map toolbar actions to block types // Map toolbar actions to block types
const typeMap: Record<string, any> = { const typeMap: Record<string, any> = {
'checkbox-list': { type: 'list', props: { kind: 'check', items: [] } }, 'checkbox-list': { type: 'list-item' as any, props: { kind: 'check', checked: false, text: '' } },
'numbered-list': { type: 'list', props: { kind: 'numbered', items: [] } }, 'numbered-list': { type: 'list-item' as any, props: { kind: 'numbered', number: 1, text: '' } },
'bullet-list': { type: 'list', props: { kind: 'bullet', items: [] } }, 'bullet-list': { type: 'list-item' as any, props: { kind: 'bullet', text: '' } },
'table': { type: 'table', props: this.documentService.getDefaultProps('table') }, 'table': { type: 'table', props: this.documentService.getDefaultProps('table') },
'image': { type: 'image', props: this.documentService.getDefaultProps('image') }, 'image': { type: 'image', props: this.documentService.getDefaultProps('image') },
'file': { type: 'file', props: this.documentService.getDefaultProps('file') }, 'file': { type: 'file', props: this.documentService.getDefaultProps('file') },

View File

@ -70,7 +70,38 @@ import { PaletteItem, PaletteCategory, getPaletteItemsByCategory } from '../../c
(click)="selectItem(item)" (click)="selectItem(item)"
(mouseenter)="setHoverItem(item)" (mouseenter)="setHoverItem(item)"
> >
<span class="text-base flex-shrink-0 w-5 flex items-center justify-center">{{ item.icon }}</span> <span class="text-base flex-shrink-0 w-5 flex items-center justify-center">
@if (item.id === 'checkbox-list') {
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="5" height="5" rx="1"/>
<path d="M10 6h10"/>
<rect x="3" y="10" width="5" height="5" rx="1"/>
<path d="M10 13h10"/>
<rect x="3" y="17" width="5" height="5" rx="1"/>
<path d="M10 20h10"/>
</svg>
} @else if (item.id === 'bullet-list') {
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="5.5" cy="5.5" r="1.5"/>
<path d="M10 6h10"/>
<circle cx="5.5" cy="12.5" r="1.5"/>
<path d="M10 13h10"/>
<circle cx="5.5" cy="19.5" r="1.5"/>
<path d="M10 20h10"/>
</svg>
} @else if (item.id === 'numbered-list') {
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<text x="4" y="7" font-size="5" text-anchor="middle" dominant-baseline="middle" fill="currentColor" stroke="none">1</text>
<path d="M10 7h10"/>
<text x="4" y="14" font-size="5" text-anchor="middle" dominant-baseline="middle" fill="currentColor" stroke="none">2</text>
<path d="M10 14h10"/>
<text x="4" y="21" font-size="5" text-anchor="middle" dominant-baseline="middle" fill="currentColor" stroke="none">3</text>
<path d="M10 21h10"/>
</svg>
} @else {
{{ item.icon }}
}
</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-gray-200 group-hover:text-white flex items-center gap-1.5">
{{ item.label }} {{ item.label }}

View File

@ -0,0 +1,23 @@
export function moveItemImmutable<T>(items: readonly T[], fromIndex: number, toIndex: number): T[] {
const len = items.length;
if (len === 0) return [];
const from = Math.max(0, Math.min(len - 1, fromIndex));
const to = Math.max(0, Math.min(len, toIndex)); // allow insert at end (== len)
if (from === to || from + 1 === to) {
// When moving forward by one, splice logic would be a no-op; still return a new array
const clone = items.slice();
if (from === to) return clone;
}
const next = items.slice();
const [item] = next.splice(from, 1);
next.splice(to > from ? to - 1 : to, 0, item);
return next;
}
export function clampIndex(index: number, min: number, max: number): number {
if (Number.isNaN(index)) return min;
return Math.max(min, Math.min(max, index));
}

View File

@ -1,4 +1,5 @@
import { Injectable, signal, computed, effect } from '@angular/core'; import { Injectable, signal, computed, effect } from '@angular/core';
import { moveItemImmutable } from '../core/utils/reorder';
import { Block, BlockType, DocumentModel, HeadingProps, OutlineHeading } from '../core/models/block.model'; import { Block, BlockType, DocumentModel, HeadingProps, OutlineHeading } from '../core/models/block.model';
import { generateId } from '../core/utils/id-generator'; import { generateId } from '../core/utils/id-generator';
@ -174,11 +175,10 @@ export class DocumentService {
const fromIndex = blocks.findIndex(b => b.id === id); const fromIndex = blocks.findIndex(b => b.id === id);
if (fromIndex < 0) return doc; if (fromIndex < 0) return doc;
const [block] = blocks.splice(fromIndex, 1); const moved = moveItemImmutable(blocks, fromIndex, toIndex);
blocks.splice(toIndex, 0, block);
// Renumber numbered lists after move // Renumber numbered lists after move
const renumbered = this.renumberListItems(blocks); const renumbered = this.renumberListItems(moved);
return { ...doc, blocks: renumbered, meta: { ...doc.meta, updatedAt: new Date().toISOString() } }; return { ...doc, blocks: renumbered, meta: { ...doc.meta, updatedAt: new Date().toISOString() } };
}); });

View File

@ -1,6 +1,6 @@
import { Injectable, signal } from '@angular/core'; import { Injectable, signal } from '@angular/core';
interface IndicatorRect { export interface IndicatorRect {
top: number; top: number;
left: number; left: number;
width: number; width: number;
@ -16,7 +16,7 @@ export class DragDropService {
readonly fromIndex = signal<number>(-1); readonly fromIndex = signal<number>(-1);
readonly overIndex = signal<number>(-1); readonly overIndex = signal<number>(-1);
readonly indicator = signal<IndicatorRect | null>(null); readonly indicator = signal<IndicatorRect | null>(null);
readonly dropMode = signal<'line' | 'column-left' | 'column-right'>('line'); readonly dropMode = signal<'line' | 'column-left' | 'column-right' | 'column-gap'>('line');
private containerEl: HTMLElement | null = null; private containerEl: HTMLElement | null = null;
private startY = 0; private startY = 0;
@ -62,6 +62,21 @@ export class DragDropService {
return { ...result, moved }; return { ...result, moved };
} }
/**
* Expose the container rect so components can position custom indicators
* relative to the main editor scroll container.
*/
getContainerRect(): DOMRect | null {
try { return this.containerEl?.getBoundingClientRect() ?? null; } catch { return null; }
}
/**
* Allow components to override the indicator explicitly (e.g., Y-cursor between items).
*/
setIndicator(ind: IndicatorRect | null): void {
this.indicator.set(ind);
}
private computeOverIndex(clientY: number, clientX?: number) { private computeOverIndex(clientY: number, clientX?: number) {
if (!this.containerEl) return; if (!this.containerEl) return;
const nodes = Array.from(this.containerEl.querySelectorAll<HTMLElement>('.block-wrapper')); const nodes = Array.from(this.containerEl.querySelectorAll<HTMLElement>('.block-wrapper'));
@ -80,8 +95,13 @@ export class DragDropService {
const isHoveringBlock = clientY >= r.top && clientY <= r.bottom; const isHoveringBlock = clientY >= r.top && clientY <= r.bottom;
if (isHoveringBlock) { if (isHoveringBlock) {
// If this wrapper is a Columns block, prefer gap detection below
const isColumnsWrapper = !!nodes[i].querySelector('[data-column-index]');
if (isColumnsWrapper) {
continue;
}
const relativeX = clientX - r.left; const relativeX = clientX - r.left;
const edgeThreshold = 100; // pixels from edge to trigger column mode (increased for better detection) const edgeThreshold = Math.min(120, Math.max(48, r.width * 0.25)); // 25% width, clamped 48-120px
if (relativeX < edgeThreshold) { if (relativeX < edgeThreshold) {
// Near left edge - create column on left // Near left edge - create column on left
@ -118,6 +138,43 @@ export class DragDropService {
} }
} }
} }
// Columns gap detection: show a vertical Y-style indicator between columns (stable nearest boundary)
const el = document.elementFromPoint(clientX, clientY) as HTMLElement | null;
const colEl = el?.closest('[data-column-index]') as HTMLElement | null;
if (colEl) {
const columnsContainer = colEl.parentElement as HTMLElement | null;
const columnsWrapper = (columnsContainer?.closest('.block-wrapper') as HTMLElement) || columnsContainer;
if (columnsContainer && columnsWrapper) {
const cols = Array.from(columnsContainer.querySelectorAll<HTMLElement>('[data-column-index]'));
const containerRect2 = columnsWrapper.getBoundingClientRect();
const gapThreshold = 60; // px near column borders considered as gap
let bestLeft = 0;
let bestDist = Number.POSITIVE_INFINITY;
for (let j = 0; j < cols.length; j++) {
const cRect = cols[j].getBoundingClientRect();
const dLeft = Math.abs(clientX - cRect.left);
const dRight = Math.abs(clientX - cRect.right);
const localBest = dLeft < dRight ? { left: cRect.left, dist: dLeft } : { left: cRect.right, dist: dRight };
if (localBest.dist < bestDist) {
bestDist = localBest.dist;
bestLeft = localBest.left;
}
}
if (bestDist <= gapThreshold) {
this.dropMode.set('column-gap');
this.overIndex.set(this.overIndex());
this.indicator.set({
top: containerRect2.top - containerRect.top,
left: bestLeft - containerRect.left,
width: 4,
height: containerRect2.height,
mode: 'vertical'
});
return;
}
}
}
} }
// Default horizontal mode (line change) - improved detection // Default horizontal mode (line change) - improved detection
@ -125,6 +182,7 @@ export class DragDropService {
// Find which block we're hovering over or between // Find which block we're hovering over or between
let found = false; let found = false;
let hoveredBlockEl: HTMLElement | null = null;
for (let i = 0; i < nodes.length; i++) { for (let i = 0; i < nodes.length; i++) {
const r = nodes[i].getBoundingClientRect(); const r = nodes[i].getBoundingClientRect();
@ -136,12 +194,14 @@ export class DragDropService {
// Insert BEFORE this block // Insert BEFORE this block
targetIndex = i; targetIndex = i;
indicatorTop = r.top - containerRect.top; indicatorTop = r.top - containerRect.top;
hoveredBlockEl = nodes[i];
found = true; found = true;
break; break;
} else if (clientY <= r.bottom) { } else if (clientY <= r.bottom) {
// Insert AFTER this block // Insert AFTER this block
targetIndex = i + 1; targetIndex = i + 1;
indicatorTop = r.bottom - containerRect.top; indicatorTop = r.bottom - containerRect.top;
hoveredBlockEl = nodes[i];
found = true; found = true;
break; break;
} }
@ -155,10 +215,22 @@ export class DragDropService {
} }
this.overIndex.set(targetIndex); this.overIndex.set(targetIndex);
// Default to full container width; if inside a column, clamp to that column width
let indLeft = 0;
let indWidth = containerRect.width;
const el2 = (typeof document !== 'undefined' && clientX !== undefined)
? (document.elementFromPoint(clientX, clientY) as HTMLElement | null)
: null;
const colEl2 = el2?.closest('[data-column-index]') as HTMLElement | null;
if (colEl2) {
const colRect = colEl2.getBoundingClientRect();
indLeft = colRect.left - containerRect.left;
indWidth = colRect.width;
}
this.indicator.set({ this.indicator.set({
top: indicatorTop, top: indicatorTop,
left: 0, left: indLeft,
width: containerRect.width, width: indWidth,
mode: 'horizontal' mode: 'horizontal'
}); });
} }

View File

@ -54,15 +54,15 @@ export class ShortcutsService {
this.insertOrConvertBlock('heading', { level: 3, text: '' }); this.insertOrConvertBlock('heading', { level: 3, text: '' });
break; break;
// Lists // Lists (use list-item everywhere to match palette behavior)
case 'bullet-list': case 'bullet-list':
this.insertOrConvertBlock('list', { kind: 'bullet' }); this.insertOrConvertBlock('list-item' as BlockType, { kind: 'bullet', text: '' });
break; break;
case 'numbered-list': case 'numbered-list':
this.insertOrConvertBlock('list', { kind: 'numbered' }); this.insertOrConvertBlock('list-item' as BlockType, { kind: 'numbered', number: 1, text: '' });
break; break;
case 'checkbox-list': case 'checkbox-list':
this.insertOrConvertBlock('list', { kind: 'check' }); this.insertOrConvertBlock('list-item' as BlockType, { kind: 'check', checked: false, text: '' });
break; break;
// Blocks // Blocks
@ -156,14 +156,6 @@ export class ShortcutsService {
if (preset) { if (preset) {
block.props = { ...block.props, ...preset }; block.props = { ...block.props, ...preset };
} }
// If it's a list created via shortcut, seed the first item's text for immediate visibility
if (type === 'list') {
const k = (block.props?.kind || '').toLowerCase();
const label = k === 'check' ? 'checkbox-list' : k === 'numbered' ? 'numbered-list' : 'bullet-list';
if (Array.isArray(block.props?.items) && block.props.items.length > 0) {
block.props.items = [{ ...block.props.items[0], text: label }];
}
}
this.documentService.appendBlock(block); this.documentService.appendBlock(block);
this.selectionService.setActive(block.id); this.selectionService.setActive(block.id);
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB