ObsiViewer/docs/UNIFIED_DRAG_DROP_SYSTEM.md
Bruno Charest ee3085ce38 feat: add Nimbus Editor with Unsplash integration
- Integrated Unsplash API for image search functionality with environment configuration
- Added new Nimbus Editor page component with navigation from sidebar and mobile drawer
- Enhanced TOC with highlight animation for editor heading navigation
- Improved CDK overlay z-index hierarchy for proper menu layering
- Removed obsolete logging validation script
2025-11-11 11:38:27 -05:00

17 KiB

Système de Drag & Drop Unifié

🎯 Objectif

Un seul système de drag & drop pour TOUS les blocs, qu'ils soient en pleine largeur ou dans des colonnes, avec indicateur visuel unifié (flèche bleue).

Fonctionnalités Implémentées

1. Drag & Drop Unifié

Tous les blocs utilisent DragDropService:

  • Blocs pleine largeur → Autre position pleine largeur
  • Blocs pleine largeur → Colonne (n'importe quelle colonne)
  • Bloc de colonne → Autre colonne
  • Bloc de colonne → Pleine largeur
  • Bloc de colonne → Même colonne (réorganisation)

2. Indicateur Visuel avec Flèche Bleue

Deux modes d'indicateur:

Mode Horizontal (Changement de ligne)

aaa
─────────────────► ◄─────────────────  (Ligne bleue avec flèches)
bbb
  • Utilisé pour réorganiser des blocs verticalement
  • Flèches gauche et droite
  • Couleur: rgba(56, 189, 248, 0.9) (bleu)

Mode Vertical (Création/Ajout dans colonne)

        ▲
        │  (Ligne bleue verticale avec flèches)
   aaa  │  bbb
        │
        ▼
  • Utilisé pour créer des colonnes ou ajouter à une colonne existante
  • Flèches haut et bas
  • Couleur: rgba(56, 189, 248, 0.9) (bleu)

3. Flexibilité Totale

Image 2 - Tous les cas supportés:

┌─────────┐    ┌─────────┐    ┌─────────┐
│ H2 │ 1  │    │ H2 │ 1  │    │ H2 │ 1  │    (Colonnes multiples)
└─────────┘    └─────────┘    └─────────┘

┌─────────┐    ┌─────────┐
│ H2      │    │ H2 │ 1  │                    (Mix colonnes + blocs)
└─────────┘    └─────────┘

┌────────────────────────────────────────┐
│ H2                                     │   (Pleine largeur)
└────────────────────────────────────────┘

Tous les déplacements possibles:

  1. Drag n'importe quel bloc H2 vers n'importe quelle position
  2. Créer des colonnes en droppant sur les bords
  3. Convertir colonnes → pleine largeur en droppant hors des colonnes
  4. Réorganiser dans une même colonne

🔧 Architecture Technique

Service Central: DragDropService

Responsabilités:

  • Tracker l'état du drag (dragging, sourceId, fromIndex, overIndex)
  • Calculer la position de l'indicateur (indicator)
  • Détecter le mode de drop (line, column-left, column-right)

Signaux:

readonly dragging = signal(false);
readonly sourceId = signal<string | null>(null);
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');

Méthodes:

beginDrag(id: string, index: number, clientY: number)
updatePointer(clientY: number, clientX?: number)
endDrag()  { from, to, moved, mode }

Composants Intégrés

1. block-host.component.ts (Blocs Pleine Largeur)

Drag Start:

onDragStart(event: MouseEvent): void {
  this.dragDrop.beginDrag(this.block.id, this.index, event.clientY);
  
  const onMove = (e: MouseEvent) => {
    this.dragDrop.updatePointer(e.clientY, e.clientX);
  };
  
  const onUp = (e: MouseEvent) => {
    const { from, to, moved, mode } = this.dragDrop.endDrag();
    
    // Check if dropping into a column
    const target = document.elementFromPoint(e.clientX, e.clientY);
    const columnEl = target.closest('[data-column-id]');
    
    if (columnEl) {
      // Insert into column
      this.insertIntoColumn(colIndex, blockIndex);
    } else if (mode === 'column-left' || mode === 'column-right') {
      // Create new columns
      this.createColumns(mode, targetBlock);
    } else {
      // Regular line move
      this.documentService.moveBlock(this.block.id, toIndex);
    }
  };
}

Détection de Drop dans Colonne:

// Check if dropping into a column
const columnEl = target.closest('[data-column-id]');
if (columnEl) {
  const colIndex = parseInt(columnEl.getAttribute('data-column-index') || '0');
  const columnsBlockId = columnEl.closest('.block-wrapper[data-block-id]')
    ?.getAttribute('data-block-id');
  
  // Insert block into column
  const blockCopy = JSON.parse(JSON.stringify(this.block));
  columns[colIndex].blocks.push(blockCopy);
  
  // Delete original
  this.documentService.deleteBlock(this.block.id);
}

2. columns-block.component.ts (Blocs dans Colonnes)

Drag Start:

onDragStart(block: Block, columnIndex: number, blockIndex: number, event: MouseEvent): void {
  // Store source
  this.draggedBlock = { block, columnIndex, blockIndex };
  
  // Use DragDropService
  const virtualIndex = this.getVirtualIndex(columnIndex, blockIndex);
  this.dragDrop.beginDrag(block.id, virtualIndex, event.clientY);
  
  const onMove = (e: MouseEvent) => {
    this.dragDrop.updatePointer(e.clientY, e.clientX);
  };
  
  const onUp = (e: MouseEvent) => {
    const { moved } = this.dragDrop.endDrag();
    
    const target = document.elementFromPoint(e.clientX, e.clientY);
    const blockEl = target?.closest('[data-block-id]');
    
    if (blockEl) {
      // Move within columns
      const targetColIndex = parseInt(blockEl.getAttribute('data-column-index') || '0');
      const targetBlockIndex = parseInt(blockEl.getAttribute('data-block-index') || '0');
      this.moveBlock(fromCol, fromBlock, targetColIndex, targetBlockIndex);
    } else {
      // Convert to full-width
      this.convertToFullWidth(columnIndex, blockIndex);
    }
  };
}

Conversion vers Pleine Largeur:

private convertToFullWidth(colIndex: number, blockIndex: number): void {
  const blockToMove = column.blocks[blockIndex];
  
  // Insert as full-width after columns block
  const blockCopy = JSON.parse(JSON.stringify(blockToMove));
  this.documentService.insertBlock(this.block.id, blockCopy);
  
  // Remove from column
  updatedColumns[colIndex].blocks = 
    column.blocks.filter((_, i) => i !== blockIndex);
  
  // Redistribute widths or delete if empty
  if (nonEmptyColumns.length === 0) {
    this.documentService.deleteBlock(this.block.id);
  } else if (nonEmptyColumns.length === 1) {
    // Convert single column to full-width blocks
  } else {
    // Update with redistributed widths
    const newWidth = 100 / nonEmptyColumns.length;
  }
}

3. editor-shell.component.ts (Indicateur Visuel)

Template:

@if (dragDrop.dragging() && dragDrop.indicator()) {
  @if (dragDrop.indicator()!.mode === 'horizontal') {
    <!-- Horizontal indicator (line change) -->
    <div class="drop-indicator horizontal"
         [style.top.px]="dragDrop.indicator()!.top"
         [style.left.px]="dragDrop.indicator()!.left"
         [style.width.px]="dragDrop.indicator()!.width">
      <span class="arrow left"></span>
      <span class="arrow right"></span>
    </div>
  } @else {
    <!-- Vertical indicator (column) -->
    <div class="drop-indicator vertical"
         [style.top.px]="dragDrop.indicator()!.top"
         [style.left.px]="dragDrop.indicator()!.left"
         [style.height.px]="dragDrop.indicator()!.height">
      <span class="arrow top"></span>
      <span class="arrow bottom"></span>
    </div>
  }
}

Styles:

.drop-indicator {
  position: absolute;
  pointer-events: none;
  z-index: 1000;
}

/* Horizontal indicator */
.drop-indicator.horizontal {
  height: 3px;
  background: rgba(56, 189, 248, 0.9);
}

.drop-indicator.horizontal .arrow.left {
  left: 0;
  border-top: 8px solid transparent;
  border-bottom: 8px solid transparent;
  border-right: 12px solid rgba(56, 189, 248, 0.9);
}

.drop-indicator.horizontal .arrow.right {
  right: 0;
  border-top: 8px solid transparent;
  border-bottom: 8px solid transparent;
  border-left: 12px solid rgba(56, 189, 248, 0.9);
}

/* Vertical indicator */
.drop-indicator.vertical {
  width: 3px;
  background: rgba(56, 189, 248, 0.9);
}

.drop-indicator.vertical .arrow.top {
  top: 0;
  border-left: 8px solid transparent;
  border-right: 8px solid transparent;
  border-bottom: 12px solid rgba(56, 189, 248, 0.9);
}

.drop-indicator.vertical .arrow.bottom {
  bottom: 0;
  border-left: 8px solid transparent;
  border-right: 8px solid transparent;
  border-top: 12px solid rgba(56, 189, 248, 0.9);
}

📊 Flux de Données

Cas 1: Bloc Pleine Largeur → Colonne

1. User drags bloc pleine largeur
   ↓
2. onDragStart() in block-host.component.ts
   → dragDrop.beginDrag()
   ↓
3. User moves mouse
   → dragDrop.updatePointer()
   → indicator position calculated
   → Blue arrow displayed
   ↓
4. User drops on column
   → document.elementFromPoint()
   → target.closest('[data-column-id]')
   → Found column!
   ↓
5. Insert bloc into column
   → blockCopy created
   → columns[colIndex].blocks.push(blockCopy)
   → documentService.updateBlockProps()
   → documentService.deleteBlock(originalId)
   ↓
6. UI updates
   → Block appears in column
   → Original block removed

Cas 2: Bloc de Colonne → Pleine Largeur

1. User drags bloc in column
   ↓
2. onDragStart() in columns-block.component.ts
   → draggedBlock stored
   → dragDrop.beginDrag()
   ↓
3. User moves mouse
   → dragDrop.updatePointer()
   → indicator displayed
   ↓
4. User drops outside columns
   → target.closest('[data-column-id]') = null
   → isOutsideColumns = true
   ↓
5. convertToFullWidth()
   → blockCopy created
   → documentService.insertBlock(after columnsBlock)
   → Remove from column
   → Redistribute widths or delete empty columns
   ↓
6. UI updates
   → Block appears as full-width
   → Column updated or removed

Cas 3: Colonne → Colonne

1. User drags bloc in column A
   ↓
2. onDragStart() in columns-block.component.ts
   → draggedBlock = { block, columnIndex: A, blockIndex: X }
   ↓
3. User drops on bloc in column B
   → target.closest('[data-block-id]')
   → data-column-index = B
   → data-block-index = Y
   ↓
4. moveBlock(A, X, B, Y)
   → Remove from column A
   → Insert into column B at position Y
   → Redistribute widths if needed
   ↓
5. UI updates
   → Block appears in column B
   → Column A updated

🔍 Attributs Data Nécessaires

Bloc Pleine Largeur

<div class="block-wrapper"
     data-block-id="{{ block.id }}">
  <!-- Content -->
</div>

Bloc dans Colonne

<div class="block-in-column"
     data-block-id="{{ block.id }}"
     data-column-index="{{ colIndex }}"
     data-block-index="{{ blockIndex }}">
  <!-- Content -->
</div>

Colonne

<div class="column"
     data-column-id="{{ column.id }}"
     data-column-index="{{ colIndex }}">
  <!-- Blocks -->
</div>

Bloc Colonnes

<div class="block-wrapper"
     data-block-id="{{ columnsBlock.id }}">
  <div class="columns-container">
    <!-- Columns -->
  </div>
</div>

🧪 Tests à Effectuer

Test 1: Pleine Largeur → Colonne

1. Créer un bloc H2 en pleine largeur
2. Créer 2 colonnes avec des blocs
3. Drag le bloc H2 vers colonne 1
✅ Vérifier: Flèche bleue verticale apparaît
✅ Vérifier: Bloc H2 apparaît dans colonne 1
✅ Vérifier: Original H2 supprimé

Test 2: Colonne → Pleine Largeur

1. Créer 2 colonnes avec des blocs
2. Drag un bloc de colonne 1 vers zone pleine largeur (hors colonnes)
✅ Vérifier: Flèche bleue horizontale apparaît
✅ Vérifier: Bloc devient pleine largeur
✅ Vérifier: Colonne 1 mise à jour
✅ Vérifier: Si colonne vide, largeur redistribuée

Test 3: Colonne A → Colonne B

1. Créer 3 colonnes avec plusieurs blocs
2. Drag un bloc de colonne 1 vers colonne 2
✅ Vérifier: Flèche bleue apparaît dans colonne 2
✅ Vérifier: Bloc apparaît dans colonne 2 à la position du drop
✅ Vérifier: Bloc supprimé de colonne 1

Test 4: Réorganisation dans Même Colonne

1. Créer une colonne avec 4 blocs (pos 0,1,2,3)
2. Drag bloc pos 0 vers pos 2
✅ Vérifier: Flèche bleue apparaît entre blocs
✅ Vérifier: Bloc se déplace correctement
✅ Vérifier: Ordre: 1,0,2,3

Test 5: Création de Colonnes (Existant)

1. Créer 2 blocs H2 pleine largeur
2. Drag bloc 1 vers bord gauche/droit de bloc 2
✅ Vérifier: Flèche bleue verticale apparaît sur le bord
✅ Vérifier: Colonnes créées avec les 2 blocs
✅ Vérifier: Largeur 50/50

Test 6: Types de Blocs Variés

1. Créer colonnes avec: Heading, Paragraph, Code, Image, Table
2. Drag chaque type vers:
   - Autre colonne
   - Pleine largeur
   - Même colonne (réorganisation)
✅ Vérifier: Tous les types fonctionnent
✅ Vérifier: Aucune perte de données
✅ Vérifier: Styles préservés

Test 7: Indicateur Visuel

1. Drag un bloc (colonne ou pleine largeur)
2. Observer pendant le mouvement
✅ Vérifier: Flèche bleue toujours visible
✅ Vérifier: Position correcte (suit la souris)
✅ Vérifier: Mode horizontal vs vertical selon contexte
✅ Vérifier: Flèches aux extrémités

📈 Comparaison Avant/Après

Aspect Avant Après
Systèmes de drag 2 séparés 1 unifié
Indicateur visuel Aucun Flèche bleue
Pleine largeur → Colonne Non supporté Fonctionnel
Colonne → Pleine largeur Non supporté Fonctionnel
Colonne → Colonne ⚠️ Basique Complet
Réorganisation colonne ⚠️ Basique Complet
Feedback utilisateur Aucun Flèche bleue
Consistance Différent Identique

Avantages du Système Unifié

1. Expérience Utilisateur

  • Intuitive - Un seul comportement pour tous les blocs
  • Feedback visuel - Flèche bleue indique où le bloc sera placé
  • Flexibilité - Aucune restriction artificielle
  • Consistance - Même mécanique partout

2. Architecture

  • DRY - Un seul service (DragDropService)
  • Maintenable - Logique centralisée
  • Évolutif - Facile d'ajouter de nouveaux types de blocs
  • Testable - Service isolé

3. Performance

  • Optimisé - Signals Angular pour réactivité
  • Pas de polling - Event-driven
  • Pas de duplication - Code partagé

🚀 Utilisation

Pour l'Utilisateur Final

Drag & Drop Universel:

  1. Hover sur n'importe quel bloc → Bouton ⋯ apparaît
  2. Cliquer et maintenir sur ⋯ → Curseur devient "grabbing"
  3. Déplacer la souris → Flèche bleue indique la position de drop
  4. Relâcher → Bloc placé à la position indiquée

Scénarios:

  • Drag vers espace vide → Nouveau bloc pleine largeur
  • Drag vers bord gauche/droit d'un bloc → Crée des colonnes
  • Drag vers une colonne existante → Ajoute dans la colonne
  • Drag hors des colonnes → Convertit en pleine largeur
  • Drag dans même colonne → Réorganise

Pour les Développeurs

Ajouter un nouveau type de bloc avec drag:

// 1. Utiliser le même pattern dans le template
<button (mousedown)="onDragStart($event)"></button>

// 2. Implémenter onDragStart
onDragStart(event: MouseEvent): void {
  this.dragDrop.beginDrag(this.block.id, this.index, event.clientY);
  
  const onMove = (e: MouseEvent) => {
    this.dragDrop.updatePointer(e.clientY, e.clientX);
  };
  
  const onUp = (e: MouseEvent) => {
    const { from, to, moved, mode } = this.dragDrop.endDrag();
    // Handle drop...
  };
  
  document.addEventListener('mousemove', onMove);
  document.addEventListener('mouseup', onUp, { once: true });
}

Ajouter des attributs data:

<div class="block-wrapper"
     [attr.data-block-id]="block.id"
     [attr.data-custom-info]="someInfo">
  <!-- Content -->
</div>

Détection personnalisée:

const target = document.elementFromPoint(e.clientX, e.clientY);
const customEl = target.closest('[data-custom-info]');
if (customEl) {
  const info = customEl.getAttribute('data-custom-info');
  // Custom logic...
}

📚 Fichiers Modifiés

Services

  • src/app/editor/services/drag-drop.service.ts - Service central (déjà existant)

Composants

  • src/app/editor/components/block/block-host.component.ts - Blocs pleine largeur (modifié)
  • src/app/editor/components/block/blocks/columns-block.component.ts - Blocs colonnes (refactorisé)
  • src/app/editor/components/editor-shell/editor-shell.component.ts - Indicateur visuel (déjà existant)

Documentation

  • docs/UNIFIED_DRAG_DROP_SYSTEM.md - Ce fichier
  • docs/COLUMNS_UI_IMPROVEMENTS.md - Améliorations UI précédentes
  • docs/COLUMNS_FIXES_FINAL.md - Corrections initiales

🎉 Résultat Final

Système de drag & drop complètement unifié:

  • Une seule mécanique pour tous les blocs
  • Flèche bleue comme indicateur visuel
  • Flexibilité totale - Aucune restriction
  • Expérience intuitive - Cohérent partout

Le comportement est identique que le bloc soit en pleine largeur ou dans une colonne! 🚀


Rafraîchissez le navigateur et testez le nouveau système de drag & drop! 🎯