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)"
>
<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>
</div>
<span class="text-xs text-text-muted">{{ item.shortcut }}</span>
@ -1079,9 +1110,9 @@ export class BlockContextMenuComponent implements OnChanges {
}
convertOptions = [
{ type: 'list' as BlockType, preset: { kind: 'checklist' }, icon: '☑️', label: 'Checklist', shortcut: 'ctrl+shift+c' },
{ type: 'list' as BlockType, preset: { kind: 'number' }, icon: '🔢', label: 'Number 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: 'check', checked: false, text: '' }, icon: '☑️', label: 'Checkbox list', shortcut: 'ctrl+shift+c' },
{ type: 'list-item' as BlockType, preset: { kind: 'numbered', number: 1, text: '' }, icon: '1.', label: 'Numbered list', shortcut: 'ctrl+shift+7' },
{ 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: 'paragraph' as BlockType, preset: null, icon: '¶', label: 'Paragraph', shortcut: 'ctrl+alt+7' },
{ 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 { ImageInfoModalComponent } from '../image/image-info-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 { ParagraphBlockComponent } from './blocks/paragraph-block.component';
@ -64,7 +64,6 @@ import { ColumnsBlockComponent } from './blocks/columns-block.component';
OutlineBlockComponent,
LineBlockComponent,
ColumnsBlockComponent,
BlockInitialMenuComponent,
OverlayModule,
PortalModule
],
@ -72,12 +71,17 @@ import { ColumnsBlockComponent } from './blocks/columns-block.component';
<div
class="block-wrapper group relative"
[attr.data-block-id]="block.id"
[attr.data-block-index]="index"
[class.active]="isActive()"
[class.locked]="block.meta?.locked"
[style.background-color]="block.type === 'list-item' ? null : block.meta?.bgColor"
[ngStyle]="blockStyles()"
(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) -->
@if (block.type !== 'columns') {
<button
@ -109,11 +113,6 @@ import { ColumnsBlockComponent } from './blocks/columns-block.component';
(deleteBlock)="onDeleteBlock()"
/>
</div>
@if (showInlineMenu) {
<div class="flex-shrink-0">
<app-block-initial-menu (action)="onInlineMenuAction($event)" />
</div>
}
</div>
}
@case ('heading') {
@ -183,13 +182,13 @@ import { ColumnsBlockComponent } from './blocks/columns-block.component';
</div>
<ng-container *ngIf="block.type !== 'table'">
<!-- 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()">
<svg viewBox="0 0 24 24" class="w-7 h-7"><path fill="#e5e7eb" d="M12 4c4.97 0 9 2.96 9 6.6S16.97 17.2 12 17.2c-.75 0-1.49-.07-2.2-.22L6 20l1.57-3.14C6.13 16 3 13.86 3 10.6 3 6.96 7.03 4 12 4z"/></svg>
<span class="absolute text-[11px] font-semibold text-black">{{ totalComments() }}</span>
</button>
<!-- 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()">
<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>
@ -240,7 +239,7 @@ export class BlockHostComponent implements OnDestroy {
private readonly selectionService = inject(SelectionService);
private readonly documentService = inject(DocumentService);
private readonly dragDrop = inject(DragDropService);
readonly dragDrop = inject(DragDropService);
private readonly comments = inject(CommentStoreService);
private readonly overlay = inject(Overlay);
private readonly host = inject(ElementRef<HTMLElement>);
@ -258,6 +257,19 @@ export class BlockHostComponent implements OnDestroy {
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 {
if (!this.block.meta?.locked) {
this.selectionService.setActive(this.block.id);
@ -312,6 +324,11 @@ export class BlockHostComponent implements OnDestroy {
if (!moved) return;
if (to < 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
const target = document.elementFromPoint(e.clientX, e.clientY);
@ -326,22 +343,63 @@ export class BlockHostComponent implements OnDestroy {
if (columnsBlock && columnsBlock.type === 'columns') {
const columnEl = target.closest('[data-column-id]');
if (columnEl) {
// Dropping INTO an existing column
// If indicator is a gap between columns, create a new column deterministically
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 props = columnsBlock.props as any;
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));
// Determine insertion index within column
const blockEl = target.closest('[data-block-id]');
let insertIndex = columns[colIndex]?.blocks?.length || 0;
if (blockEl && blockEl.getAttribute('data-block-id') !== columnsBlockId) {
insertIndex = parseInt(blockEl.getAttribute('data-block-index') || '0');
}
columns[colIndex] = {
...columns[colIndex],
blocks: [
@ -350,16 +408,13 @@ export class BlockHostComponent implements OnDestroy {
...columns[colIndex].blocks.slice(insertIndex)
]
};
// Update columns block
this.documentService.updateBlockProps(columnsBlockId, { columns });
// Delete original block
this.documentService.deleteBlock(this.block.id);
this.selectionService.setActive(blockCopy.id);
return;
}
} 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;
columnsContainerEl = (columnsContainerEl?.parentElement as HTMLElement) || columnsContainerEl;
if (!columnsContainerEl) {
@ -413,31 +468,22 @@ export class BlockHostComponent implements OnDestroy {
}
}
} else {
// Not a columns block: detect lateral drop on a normal block to create a 2-column layout
const targetWrapper = target.closest('.block-wrapper[data-block-id]') as HTMLElement | null;
const targetBlockId = targetWrapper?.getAttribute('data-block-id') || null;
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) {
// Not a columns block: deterministically create a 2-column layout using dropMode and overIndex
const modeNow = mode; // from endDrag()
if (modeNow === 'column-left' || modeNow === 'column-right') {
const all = this.documentService.blocks();
const targetIndex = all.findIndex(b => b.id === targetBlockId);
if (targetIndex >= 0) {
// Prepare new columns block with dragged + target in the right order
const tgtIdx = Math.max(0, Math.min(all.length - 1, to >= all.length ? all.length - 1 : to));
const targetBlock = all[tgtIdx];
if (targetBlock && targetBlock.id !== this.block.id) {
const draggedCopy = JSON.parse(JSON.stringify(this.block));
const targetBlock = all[targetIndex];
const columns = onLeft
const columns = (modeNow === 'column-left')
? [ { 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 } ];
const newColumnsBlock = this.documentService.createBlock('columns', { columns });
// Insert new columns block before the target, then delete old dragged + target blocks
const beforeId = targetIndex > 0 ? all[targetIndex - 1].id : null;
const beforeId = tgtIdx > 0 ? all[tgtIdx - 1].id : null;
// Insert new columns before target, delete original two blocks
this.documentService.insertBlock(beforeId, newColumnsBlock);
this.documentService.deleteBlock(targetBlockId);
this.documentService.deleteBlock(targetBlock.id);
this.documentService.deleteBlock(this.block.id);
this.selectionService.setActive(draggedCopy.id);
return;
@ -446,15 +492,13 @@ export class BlockHostComponent implements OnDestroy {
}
}
}
}
// Handle regular line move
const blocks = this.documentService.blocks();
let toIndex = to;
if (toIndex > from) toIndex = toIndex - 1;
if (toIndex < 0) toIndex = 0;
if (toIndex > blocks.length - 1) toIndex = blocks.length - 1;
if (toIndex === from) return;
if (toIndex > blocks.length) toIndex = blocks.length; // allow end
if (toIndex === from) return; // exact same slot
this.documentService.moveBlock(this.block.id, toIndex);
this.selectionService.setActive(this.block.id);
};

View File

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

View File

@ -10,7 +10,7 @@ export interface InlineToolbarAction {
standalone: true,
imports: [CommonModule],
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) -->
@if (showDragHandle) {
<div
@ -41,93 +41,121 @@ export interface InlineToolbarAction {
}
<!-- Input area and inline icons -->
<div class="flex-1 flex items-center gap-1 px-2 py-0.5 rounded-lg transition-colors">
<!-- Slot for actual content -->
<div class="flex items-center gap-2 w-full">
<div class="flex-1">
<ng-content />
</div>
<!-- Quick action icons (visible on hover or focus) -->
<div
class="flex items-center gap-0.5 transition-opacity"
[class.opacity-100]="isEmpty() && isFocused()"
[class.opacity-0]="!isEmpty() || !isFocused()"
[class.pointer-events-none]="!isEmpty() || !isFocused()"
>
<div class="flex items-center gap-1 select-none">
<!-- Use AI -->
<button
class="p-1 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
<button *ngIf="!actions || actions.includes('use-ai')"
class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
title="Use AI"
(click)="onAction('use-ai')"
type="button"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 3l9 4.5v9L12 21l-9-4.5v-9L12 3z"/>
<path d="M12 12l9-4.5M12 12v9M12 12L3 7.5"/>
<svg class="w-6 h-6" viewBox="0 0 24 24" aria-label="Assistant AI de rédaction"
fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<!-- 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>
</button>
<!-- Checkbox list -->
<button
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
<!-- Checkbox list amélioré -->
<button *ngIf="!actions || actions.includes('checkbox-list')"
class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
title="Checkbox list"
(click)="onAction('checkbox-list')"
type="button"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="5" width="6" height="6" rx="1"/>
<path d="M5 8l1.5 1.5L9 7"/>
<path d="M13 7h8"/>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<!-- Ligne 1 -->
<rect x="3" y="3" width="5" height="5" rx="1"/>
<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>
</button>
<!-- Bullet list -->
<button
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
<button *ngIf="!actions || actions.includes('bullet-list')"
class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
title="Bullet list"
(click)="onAction('bullet-list')"
type="button"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="5" cy="7" r="1" fill="currentColor"/>
<path d="M9 7h12"/>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<!-- Ligne 1 -->
<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>
</button>
<!-- Numbered list -->
<button
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
<button *ngIf="!actions || actions.includes('numbered-list')"
class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
title="Numbered list"
(click)="onAction('numbered-list')"
type="button"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 6h11"/>
<path d="M4 6h1v4M4 10h2"/>
<svg 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>
</button>
<!-- Table -->
<button
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
<button *ngIf="!actions || actions.includes('table')"
class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
title="Table"
(click)="onAction('table')"
type="button"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M3 9h18M9 3v18"/>
</svg>
</button>
<!-- Image -->
<button
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
<button *ngIf="!actions || actions.includes('image')"
class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
title="Image"
(click)="onAction('image')"
type="button"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/>
@ -135,34 +163,34 @@ export interface InlineToolbarAction {
</button>
<!-- File -->
<button
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
<button *ngIf="!actions || actions.includes('file')"
class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
title="File"
(click)="onAction('file')"
type="button"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9l-7-7z"/>
<path d="M13 2v7h7"/>
</svg>
</button>
<!-- Link/New Page -->
<button
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
<button *ngIf="!actions || actions.includes('new-page')"
class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
title="Link to page"
(click)="onAction('new-page')"
type="button"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/>
</svg>
</button>
<!-- Heading M -->
<button
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200 font-bold text-xs"
<button *ngIf="!actions || actions.includes('heading-2')"
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"
(click)="onAction('heading-2')"
type="button"
@ -170,16 +198,16 @@ export interface InlineToolbarAction {
H<sub class="text-[8px]">M</sub>
</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) -->
<button
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
<button *ngIf="!actions || actions.includes('more')"
class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
title="More items"
(click)="onAction('more')"
type="button"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
@ -210,6 +238,8 @@ export class BlockInlineToolbarComponent {
@Input() isEmpty = signal(true);
// New: whether to show the drag handle (default true, false in columns)
@Input() showDragHandle = true;
// New: list of actions to render; when undefined, render all
@Input() actions: InlineToolbarAction['type'][] | undefined;
@Output() action = new EventEmitter<InlineToolbarAction['type']>();

View File

@ -57,7 +57,7 @@ import { BlockContextMenuComponent } from '../block-context-menu.component';
BlockContextMenuComponent
],
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) {
<div
class="flex-1 min-w-0"
@ -87,33 +87,26 @@ import { BlockContextMenuComponent } from '../block-context-menu.component';
</svg>
</button>
<!-- Comment button - Outside right, centered vertically -->
<button
type="button"
class="absolute -right-9 top-1/2 -translate-y-1/2 w-7 h-7 flex items-center justify-center opacity-0 group-hover/block:opacity-100 transition-opacity bg-gray-700 hover:bg-gray-600 rounded-md z-10"
[class.!opacity-100]="getBlockCommentCount(block.id) > 0"
[class.bg-blue-600]="getBlockCommentCount(block.id) > 0"
[class.hover:bg-blue-500]="getBlockCommentCount(block.id) > 0"
title="Comments"
(click)="openComments(block.id)"
>
@if (getBlockCommentCount(block.id) > 0) {
<span class="text-[10px] font-semibold text-white">
{{ getBlockCommentCount(block.id) }}
</span>
} @else {
<svg class="w-4 h-4 text-gray-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
}
</button>
<!-- Render block with background color support -->
<div
class="relative px-1.5 py-0.5 rounded transition-colors"
class="relative px-1.5 py-0.5 pr-8 rounded transition-colors"
[style.background-color]="getBlockBgColor(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) {
@case ('heading') {
<app-heading-block

View File

@ -1,35 +1,102 @@
import { Component, Input, Output, EventEmitter, inject, ViewChild, ElementRef, AfterViewInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { BlockInlineToolbarComponent } from '../../block/block-inline-toolbar.component';
import { Block, ParagraphProps } from '../../../core/models/block.model';
import { DocumentService } from '../../../services/document.service';
import { SelectionService } from '../../../services/selection.service';
import { PaletteService } from '../../../services/palette.service';
import { PaletteCategory, PaletteItem, getPaletteItemsByCategory } from '../../../core/constants/palette-items';
@Component({
selector: 'app-paragraph-block',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [CommonModule, FormsModule, BlockInlineToolbarComponent],
template: `
<div
class="relative"
(click)="onContainerClick($event)"
>
<app-block-inline-toolbar
[placeholder]="placeholder"
[isFocused]="isFocused"
[isEmpty]="isEmpty"
[showDragHandle]="false"
[actions]="undefined"
(action)="onInlineAction($event)"
>
<div
#editable
contenteditable="true"
class="w-full m-0 bg-transparent text-sm text-neutral-100 dark:text-neutral-100 focus:outline-none min-h-[1.25rem]"
class="m-0 inline-block bg-transparent text-sm text-neutral-100 dark:text-neutral-100 focus:outline-none min-h-[1.25rem]"
(input)="onInput($event)"
(keydown)="onKeyDown($event)"
(focus)="isFocused.set(true)"
(blur)="onBlur()"
[attr.data-placeholder]="placeholder"
></div>
</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>
`,
styles: [`
/* Show placeholder only when focused and empty */
[contenteditable][data-placeholder]:empty:focus:before {
/* Show placeholder when empty (focused or not) */
[contenteditable][data-placeholder]:empty:before {
content: attr(data-placeholder);
color: rgb(107, 114, 128);
opacity: 0.6;
@ -61,6 +128,124 @@ export class ParagraphBlockComponent implements AfterViewInit {
isFocused = signal(false);
isEmpty = signal(true);
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 {
return this.block.props;
@ -99,19 +284,30 @@ export class ParagraphBlockComponent implements AfterViewInit {
return;
}
// Handle "/" key: open palette
// Handle "/" key: open inline dropdown
if (event.key === '/') {
const target = event.target as HTMLElement;
const text = target.textContent || '';
// Only trigger if "/" is at start or after space
if (text.length === 0 || text.endsWith(' ')) {
event.preventDefault();
this.paletteService.open();
this.moreOpen.set(true);
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) {
event.preventDefault();
this.createBlock.emit();
@ -124,17 +320,24 @@ export class ParagraphBlockComponent implements AfterViewInit {
return;
}
// Handle BACKSPACE on empty block: Delete block
// Handle BACKSPACE on empty block: open dropdown instead of delete
if (event.key === 'Backspace') {
const target = event.target as HTMLElement;
const selection = window.getSelection();
if (selection && selection.anchorOffset === 0 && (!target.textContent || target.textContent.length === 0)) {
event.preventDefault();
this.deleteBlock.emit();
this.moreOpen.set(true);
return;
}
}
// ESC closes dropdown
if (event.key === 'Escape' && this.moreOpen()) {
event.preventDefault();
this.moreOpen.set(false);
return;
}
// ArrowUp/ArrowDown navigation between blocks
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
const el = (event.target as HTMLElement);

View File

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

View File

@ -70,7 +70,38 @@ import { PaletteItem, PaletteCategory, getPaletteItemsByCategory } from '../../c
(click)="selectItem(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="text-sm font-medium text-gray-200 group-hover:text-white flex items-center gap-1.5">
{{ 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 { moveItemImmutable } from '../core/utils/reorder';
import { Block, BlockType, DocumentModel, HeadingProps, OutlineHeading } from '../core/models/block.model';
import { generateId } from '../core/utils/id-generator';
@ -174,11 +175,10 @@ export class DocumentService {
const fromIndex = blocks.findIndex(b => b.id === id);
if (fromIndex < 0) return doc;
const [block] = blocks.splice(fromIndex, 1);
blocks.splice(toIndex, 0, block);
const moved = moveItemImmutable(blocks, fromIndex, toIndex);
// 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() } };
});

View File

@ -1,6 +1,6 @@
import { Injectable, signal } from '@angular/core';
interface IndicatorRect {
export interface IndicatorRect {
top: number;
left: number;
width: number;
@ -16,7 +16,7 @@ export class DragDropService {
readonly fromIndex = signal<number>(-1);
readonly overIndex = signal<number>(-1);
readonly indicator = signal<IndicatorRect | null>(null);
readonly dropMode = signal<'line' | 'column-left' | 'column-right'>('line');
readonly dropMode = signal<'line' | 'column-left' | 'column-right' | 'column-gap'>('line');
private containerEl: HTMLElement | null = null;
private startY = 0;
@ -62,6 +62,21 @@ export class DragDropService {
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) {
if (!this.containerEl) return;
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;
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 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) {
// 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
@ -125,6 +182,7 @@ export class DragDropService {
// Find which block we're hovering over or between
let found = false;
let hoveredBlockEl: HTMLElement | null = null;
for (let i = 0; i < nodes.length; i++) {
const r = nodes[i].getBoundingClientRect();
@ -136,12 +194,14 @@ export class DragDropService {
// Insert BEFORE this block
targetIndex = i;
indicatorTop = r.top - containerRect.top;
hoveredBlockEl = nodes[i];
found = true;
break;
} else if (clientY <= r.bottom) {
// Insert AFTER this block
targetIndex = i + 1;
indicatorTop = r.bottom - containerRect.top;
hoveredBlockEl = nodes[i];
found = true;
break;
}
@ -155,10 +215,22 @@ export class DragDropService {
}
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({
top: indicatorTop,
left: 0,
width: containerRect.width,
left: indLeft,
width: indWidth,
mode: 'horizontal'
});
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB