diff --git a/src/app/editor/components/block/block-context-menu.component.ts b/src/app/editor/components/block/block-context-menu.component.ts index 52f36c7..f57a128 100644 --- a/src/app/editor/components/block/block-context-menu.component.ts +++ b/src/app/editor/components/block/block-context-menu.component.ts @@ -741,7 +741,38 @@ export interface MenuAction { (click)="onConvert(item.type, item.preset)" >
- {{ item.icon }} + + @if (item.type === 'list-item' && item.preset?.kind === 'check') { + + + + + + + + + } @else if (item.type === 'list-item' && item.preset?.kind === 'bullet') { + + + + + + + + + } @else if (item.type === 'list-item' && item.preset?.kind === 'numbered') { + + 1 + + 2 + + 3 + + + } @else { + {{ item.icon }} + } + {{ item.label }}
{{ item.shortcut }} @@ -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: '' }, diff --git a/src/app/editor/components/block/block-host.component.ts b/src/app/editor/components/block/block-host.component.ts index c8b5ef6..05d50f5 100644 --- a/src/app/editor/components/block/block-host.component.ts +++ b/src/app/editor/components/block/block-host.component.ts @@ -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';
+ + @if (dragDrop.dragging() && shouldHighlight()) { +
+ } @if (block.type !== 'columns') {
- @if (showInlineMenu) { -
- -
- } } @case ('heading') { @@ -183,13 +182,13 @@ import { ColumnsBlockComponent } from './blocks/columns-block.component'; - - @@ -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); @@ -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); }; diff --git a/src/app/editor/components/block/block-initial-menu.component.ts b/src/app/editor/components/block/block-initial-menu.component.ts index a4599b2..ac6ff58 100644 --- a/src/app/editor/components/block/block-initial-menu.component.ts +++ b/src/app/editor/components/block/block-initial-menu.component.ts @@ -23,7 +23,7 @@ export interface BlockMenuAction { - + - + - + diff --git a/src/app/editor/components/block/block-inline-toolbar.component.ts b/src/app/editor/components/block/block-inline-toolbar.component.ts index 8cda78b..0961f45 100644 --- a/src/app/editor/components/block/block-inline-toolbar.component.ts +++ b/src/app/editor/components/block/block-inline-toolbar.component.ts @@ -10,7 +10,7 @@ export interface InlineToolbarAction { standalone: true, imports: [CommonModule], template: ` -
+
@if (showDragHandle) {
-
- +
- - -
+ +
- - - - - - - - - - -
+
- @@ -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(); showDragTooltip = signal(false); diff --git a/src/app/editor/components/block/blocks/columns-block.component.ts b/src/app/editor/components/block/blocks/columns-block.component.ts index 7abca51..b8af6f0 100644 --- a/src/app/editor/components/block/blocks/columns-block.component.ts +++ b/src/app/editor/components/block/blocks/columns-block.component.ts @@ -57,7 +57,7 @@ import { BlockContextMenuComponent } from '../block-context-menu.component'; BlockContextMenuComponent ], template: ` -
+
@for (column of props.columns; track column.id; let colIndex = $index) {
- - +
+ + + + + @switch (block.type) { @case ('heading') { -
+ +
+
+ + + @if (moreOpen()) { +
+ @for (category of categories; track category) { +
+
{{ category }}
+
+ @for (item of getItems(category); track item.id) { + + } +
+
+ } +
+ }
`, 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); diff --git a/src/app/editor/components/editor-shell/editor-shell.component.ts b/src/app/editor/components/editor-shell/editor-shell.component.ts index b1fae07..d4da2f6 100644 --- a/src/app/editor/components/editor-shell/editor-shell.component.ts +++ b/src/app/editor/components/editor-shell/editor-shell.component.ts @@ -254,9 +254,9 @@ export class EditorShellComponent implements AfterViewInit { } else { // Map toolbar actions to block types const typeMap: Record = { - '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') }, diff --git a/src/app/editor/components/palette/block-menu.component.ts b/src/app/editor/components/palette/block-menu.component.ts index 9f7148d..39efd47 100644 --- a/src/app/editor/components/palette/block-menu.component.ts +++ b/src/app/editor/components/palette/block-menu.component.ts @@ -70,7 +70,38 @@ import { PaletteItem, PaletteCategory, getPaletteItemsByCategory } from '../../c (click)="selectItem(item)" (mouseenter)="setHoverItem(item)" > - {{ item.icon }} + + @if (item.id === 'checkbox-list') { + + + + + + + + + } @else if (item.id === 'bullet-list') { + + + + + + + + + } @else if (item.id === 'numbered-list') { + + 1 + + 2 + + 3 + + + } @else { + {{ item.icon }} + } +
{{ item.label }} diff --git a/src/app/editor/core/utils/reorder.ts b/src/app/editor/core/utils/reorder.ts new file mode 100644 index 0000000..09becf1 --- /dev/null +++ b/src/app/editor/core/utils/reorder.ts @@ -0,0 +1,23 @@ +export function moveItemImmutable(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)); +} diff --git a/src/app/editor/services/document.service.ts b/src/app/editor/services/document.service.ts index 40d58af..743a86d 100644 --- a/src/app/editor/services/document.service.ts +++ b/src/app/editor/services/document.service.ts @@ -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() } }; }); diff --git a/src/app/editor/services/drag-drop.service.ts b/src/app/editor/services/drag-drop.service.ts index e102af0..248d9ac 100644 --- a/src/app/editor/services/drag-drop.service.ts +++ b/src/app/editor/services/drag-drop.service.ts @@ -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(-1); readonly overIndex = signal(-1); readonly indicator = signal(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('.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('[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' }); } diff --git a/src/app/editor/services/shortcuts.service.ts b/src/app/editor/services/shortcuts.service.ts index 714a73a..08d2643 100644 --- a/src/app/editor/services/shortcuts.service.ts +++ b/src/app/editor/services/shortcuts.service.ts @@ -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); } diff --git a/vault/attachments/nimbus/2025/1111/img-pasted-20251111-155828-wu11ic.png b/vault/attachments/nimbus/2025/1111/img-pasted-20251111-155828-wu11ic.png new file mode 100644 index 0000000..5837b20 Binary files /dev/null and b/vault/attachments/nimbus/2025/1111/img-pasted-20251111-155828-wu11ic.png differ