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:
parent
386007d351
commit
03857f15ff
@ -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: '' },
|
||||
|
||||
@ -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,40 +343,78 @@ 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 || [])];
|
||||
|
||||
// Add dragged block to target column
|
||||
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');
|
||||
const cRect = (columnEl as HTMLElement).getBoundingClientRect();
|
||||
const gapThreshold = 60;
|
||||
const distLeft = e.clientX - cRect.left;
|
||||
const distRight = cRect.right - e.clientX;
|
||||
|
||||
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 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: [
|
||||
...columns[colIndex].blocks.slice(0, insertIndex),
|
||||
blockCopy,
|
||||
...columns[colIndex].blocks.slice(insertIndex)
|
||||
]
|
||||
};
|
||||
this.documentService.updateBlockProps(columnsBlockId, { columns });
|
||||
this.documentService.deleteBlock(this.block.id);
|
||||
this.selectionService.setActive(blockCopy.id);
|
||||
return;
|
||||
}
|
||||
|
||||
columns[colIndex] = {
|
||||
...columns[colIndex],
|
||||
blocks: [
|
||||
...columns[colIndex].blocks.slice(0, insertIndex),
|
||||
blockCopy,
|
||||
...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,35 +468,25 @@ 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) {
|
||||
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 draggedCopy = JSON.parse(JSON.stringify(this.block));
|
||||
const targetBlock = all[targetIndex];
|
||||
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: [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;
|
||||
this.documentService.insertBlock(beforeId, newColumnsBlock);
|
||||
this.documentService.deleteBlock(targetBlockId);
|
||||
this.documentService.deleteBlock(this.block.id);
|
||||
this.selectionService.setActive(draggedCopy.id);
|
||||
return;
|
||||
}
|
||||
// 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 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 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 });
|
||||
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(targetBlock.id);
|
||||
this.documentService.deleteBlock(this.block.id);
|
||||
this.selectionService.setActive(draggedCopy.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -451,10 +496,9 @@ 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);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
<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,7 +238,9 @@ 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']>();
|
||||
|
||||
showDragTooltip = signal(false);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)"
|
||||
>
|
||||
<div
|
||||
#editable
|
||||
contenteditable="true"
|
||||
class="w-full m-0 bg-transparent text-sm text-neutral-100 dark:text-neutral-100 focus:outline-none min-h-[1.25rem]"
|
||||
(input)="onInput($event)"
|
||||
(keydown)="onKeyDown($event)"
|
||||
(focus)="isFocused.set(true)"
|
||||
(blur)="onBlur()"
|
||||
[attr.data-placeholder]="placeholder"
|
||||
></div>
|
||||
<app-block-inline-toolbar
|
||||
[placeholder]="placeholder"
|
||||
[isFocused]="isFocused"
|
||||
[isEmpty]="isEmpty"
|
||||
[showDragHandle]="false"
|
||||
[actions]="undefined"
|
||||
(action)="onInlineAction($event)"
|
||||
>
|
||||
<div
|
||||
#editable
|
||||
contenteditable="true"
|
||||
class="m-0 inline-block bg-transparent text-sm text-neutral-100 dark:text-neutral-100 focus:outline-none min-h-[1.25rem]"
|
||||
(input)="onInput($event)"
|
||||
(keydown)="onKeyDown($event)"
|
||||
(focus)="isFocused.set(true)"
|
||||
(blur)="onBlur()"
|
||||
[attr.data-placeholder]="placeholder"
|
||||
></div>
|
||||
</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);
|
||||
|
||||
@ -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') },
|
||||
|
||||
@ -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 }}
|
||||
|
||||
23
src/app/editor/core/utils/reorder.ts
Normal file
23
src/app/editor/core/utils/reorder.ts
Normal 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));
|
||||
}
|
||||
@ -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() } };
|
||||
});
|
||||
|
||||
@ -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'
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 |
Loading…
x
Reference in New Issue
Block a user