--- title: 'Fix Muuri Masonry Grid - Drag & Drop et Layout Responsive' slug: 'fix-muuri-masonry-grid' created: '2026-01-18' status: 'ready-for-dev' stepsCompleted: [1, 2, 3, 4] tech_stack: ['muuri@0.9.5', 'react@19.2.3', 'typescript@5.x', 'next.js@16.1.1', 'web-animations-js'] files_to_modify: - 'components/masonry-grid.tsx' - 'components/note-card.tsx' - 'components/masonry-grid.css' - 'config/masonry-layout.ts' - 'tests/drag-drop.spec.ts' code_patterns: - 'Dynamic Muuri import (SSR-safe)' - 'useResizeObserver hook with RAF debounce' - 'NotebookDragContext for cross-component state' - 'dragHandle: .muuri-drag-handle (mobile only)' - 'NoteSize type: small | medium | large' test_patterns: - 'Playwright E2E with [data-draggable="true"] selectors' - 'API cleanup in beforeAll/afterEach' - 'dragTo() for reliable drag operations' --- # Tech-Spec: Fix Muuri Masonry Grid - Drag & Drop et Layout Responsive **Created:** 2026-01-18 **Status:** 🔍 Review ## Overview ### Problem Statement Le systĂšme de grille masonry avec Muuri prĂ©sente 4 problĂšmes critiques: 1. **❌ Drag & Drop cassĂ©** - Les tests Playwright cherchent `data-draggable="true"` mais l'attribut est sur `NoteCard` (ligne 273), pas sur le `MasonryItem` wrapper que Muuri manipule. 2. **❌ Tailles de notes non gĂ©rĂ©es** - Les notes ont `data-size` mais Muuri ne recalcule pas le layout aprĂšs le rendu du contenu. La fonction `getItemDimensions` est dĂ©finie mais jamais rĂ©utilisĂ©e lors des syncs. 3. **❌ Layout non responsive** - Les colonnes sont calculĂ©es via `calculateColumns()` mais les largeurs ne sont appliquĂ©es qu'une seule fois. Le `useEffect` de sync (lignes 295-322) ne gĂšre pas l'ajout/suppression d'items. 4. **❌ Synchronisation items cassĂ©e** - Quand React ajoute/supprime des notes, Muuri n'est pas notifiĂ©. Les nouveaux items ne sont pas ajoutĂ©s Ă  la grille Muuri. ### Solution Refactoriser l'intĂ©gration Muuri en 5 tĂąches: 1. Propager `data-draggable="true"` au `MasonryItem` wrapper 2. Centraliser le calcul des dimensions dans une fonction rĂ©utilisable 3. Utiliser `ResizeObserver` sur le conteneur principal 4. Synchroniser les items DOM avec Muuri aprĂšs chaque rendu React 5. VĂ©rifier les tests Playwright ### Scope **In Scope:** - ✅ Correction du drag & drop Muuri - ✅ Layout responsive avec colonnes dynamiques (1→5 selon largeur) - ✅ Gestion correcte des tailles (small/medium/large) - ✅ CompatibilitĂ© tests Playwright existants **Out of Scope:** - ❌ Nouvelles tailles de notes - ❌ Migration vers autre librairie - ❌ Modification persistance ordre --- ## Context for Development ### Codebase Patterns **Import dynamique Muuri (SSR-safe):** ```typescript const MuuriClass = (await import('muuri')).default; pinnedMuuri.current = new MuuriClass(pinnedGridRef.current, layoutOptions); ``` **Hook useResizeObserver existant:** ```typescript // hooks/use-resize-observer.ts const observer = new ResizeObserver((entries) => { if (frameId.current) cancelAnimationFrame(frameId.current); frameId.current = requestAnimationFrame(() => { for (const entry of entries) callback(entry); }); }); ``` **NotebookDragContext (Ă©tat cross-component):** ```typescript const { startDrag, endDrag, draggedNoteId } = useNotebookDrag(); ``` **Drag handle mobile:** ```typescript dragHandle: isMobile ? '.muuri-drag-handle' : undefined, ``` ### Files to Reference | File | Purpose | Lines clĂ©s | | ---- | ------- | ---------- | | [masonry-grid.tsx](file:///d:/dev_new_pc/Keep/keep-notes/components/masonry-grid.tsx) | Composant grille Muuri | 116-292 (init), 295-322 (sync) | | [note-card.tsx](file:///d:/dev_new_pc/Keep/keep-notes/components/note-card.tsx) | Carte note avec data-draggable | 271-301 (Card props) | | [masonry-grid.css](file:///d:/dev_new_pc/Keep/keep-notes/components/masonry-grid.css) | Styles tailles et drag | 54-67, 70-97 | | [masonry-layout.ts](file:///d:/dev_new_pc/Keep/keep-notes/config/masonry-layout.ts) | Config breakpoints | 81-90 (calculateColumns) | | [drag-drop.spec.ts](file:///d:/dev_new_pc/Keep/keep-notes/tests/drag-drop.spec.ts) | Tests E2E | 45, 75-78 (data-draggable) | ### Technical Decisions 1. **Garder Muuri** - Fonctionne pour masonry, on corrige l'intĂ©gration 2. **RĂ©utiliser useResizeObserver** - Hook existant avec RAF debounce 3. **Hauteur auto** - Comme Google Keep, contenu dĂ©termine hauteur 4. **Largeur fixe** - Toutes notes mĂȘme largeur par colonne --- ## Implementation Plan ### Tasks #### Task 1: Ajouter `data-draggable` au MasonryItem wrapper - [ ] **File:** `components/masonry-grid.tsx` - [ ] **Action:** Ajouter `data-draggable="true"` au div wrapper `.masonry-item` - [ ] **Lignes:** 32-37 ```typescript // AVANT (ligne 32-37)
// APRÈS
``` --- #### Task 2: Créer fonction `applyItemDimensions` réutilisable - [ ] **File:** `components/masonry-grid.tsx` - [ ] **Action:** Extraire la logique de calcul des dimensions dans une fonction callback - [ ] **Position:** AprÚs la ligne 109 (refreshLayout) ```typescript // Nouvelle fonction à ajouter aprÚs refreshLayout const applyItemDimensions = useCallback((grid: any, containerWidth: number) => { if (!grid) return; const columns = calculateColumns(containerWidth); const itemWidth = calculateItemWidth(containerWidth, columns); const items = grid.getItems(); items.forEach((item: any) => { const el = item.getElement(); if (el) { el.style.width = `${itemWidth}px`; // Height auto - determined by content (Google Keep style) } }); }, []); ``` --- #### Task 3: Améliorer la gestion du resize avec ResizeObserver sur conteneur - [ ] **File:** `components/masonry-grid.tsx` - [ ] **Action:** Remplacer `window.addEventListener('resize')` par ResizeObserver sur `.masonry-container` - [ ] **Lignes:** 325-378 (useEffect resize) ```typescript // REMPLACER le useEffect de resize (lignes 325-378) const containerRef = useRef(null); useEffect(() => { if (!containerRef.current || (!pinnedMuuri.current && !othersMuuri.current)) return; let resizeTimeout: NodeJS.Timeout; const handleResize = (entries: ResizeObserverEntry[]) => { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { const containerWidth = entries[0]?.contentRect.width || window.innerWidth - 32; const columns = calculateColumns(containerWidth); const itemWidth = calculateItemWidth(containerWidth, columns); console.log(`[Masonry Resize] Width: ${containerWidth}px, Columns: ${columns}`); // Apply dimensions to both grids applyItemDimensions(pinnedMuuri.current, containerWidth); applyItemDimensions(othersMuuri.current, containerWidth); // Refresh layouts requestAnimationFrame(() => { pinnedMuuri.current?.refreshItems().layout(); othersMuuri.current?.refreshItems().layout(); }); }, 150); }; const observer = new ResizeObserver(handleResize); observer.observe(containerRef.current); // Initial layout handleResize([{ contentRect: containerRef.current.getBoundingClientRect() } as ResizeObserverEntry]); return () => { clearTimeout(resizeTimeout); observer.disconnect(); }; }, [applyItemDimensions]); ``` - [ ] **Action:** Ajouter `ref={containerRef}` au div `.masonry-container` (ligne 381) ```typescript // AVANT
// APRÈS
``` --- #### Task 4: Synchroniser items DOM ↔ Muuri aprĂšs rendu React - [ ] **File:** `components/masonry-grid.tsx` - [ ] **Action:** AmĂ©liorer le useEffect de sync pour gĂ©rer ajout/suppression d'items - [ ] **Lignes:** 295-322 ```typescript // REMPLACER le useEffect de sync (lignes 295-322) useEffect(() => { const syncGridItems = (grid: any, gridRef: React.RefObject, notesArray: Note[]) => { if (!grid || !gridRef.current) return; const containerWidth = containerRef.current?.getBoundingClientRect().width || window.innerWidth - 32; const columns = calculateColumns(containerWidth); const itemWidth = calculateItemWidth(containerWidth, columns); // Get current DOM elements and Muuri items const domElements = Array.from(gridRef.current.children) as HTMLElement[]; const muuriItems = grid.getItems(); const muuriElements = muuriItems.map((item: any) => item.getElement()); // Find new elements to add const newElements = domElements.filter(el => !muuriElements.includes(el)); // Find removed elements const removedItems = muuriItems.filter((item: any) => !domElements.includes(item.getElement()) ); // Remove old items if (removedItems.length > 0) { grid.remove(removedItems, { layout: false }); } // Add new items with correct width if (newElements.length > 0) { newElements.forEach(el => { el.style.width = `${itemWidth}px`; }); grid.add(newElements, { layout: false }); } // Update all item widths domElements.forEach(el => { el.style.width = `${itemWidth}px`; }); // Refresh and layout grid.refreshItems().layout(); }; requestAnimationFrame(() => { syncGridItems(pinnedMuuri.current, pinnedGridRef, pinnedNotes); syncGridItems(othersMuuri.current, othersGridRef, othersNotes); }); }, [pinnedNotes, othersNotes]); ``` --- #### Task 5: VĂ©rifier les tests Playwright - [ ] **File:** `tests/drag-drop.spec.ts` - [ ] **Action:** ExĂ©cuter les tests et vĂ©rifier que les sĂ©lecteurs `[data-draggable="true"]` matchent le wrapper - [ ] **Commande:** `npx playwright test drag-drop.spec.ts` **Points de vĂ©rification:** - Ligne 45: `page.locator('[data-draggable="true"]')` doit trouver les `.masonry-item` wrappers - Ligne 149: `firstNote.dragTo(secondNote)` doit fonctionner avec Muuri --- ## Acceptance Criteria ### AC1: Drag & Drop fonctionnel - [ ] **Given** une grille de notes affichĂ©e - [ ] **When** je drag une note vers une autre position - [ ] **Then** la note se dĂ©place visuellement avec placeholder - [ ] **And** l'ordre est persistĂ© aprĂšs le drop ### AC2: Layout responsive - [ ] **Given** une grille de notes avec diffĂ©rentes tailles - [ ] **When** je redimensionne la fenĂȘtre du navigateur - [ ] **Then** le nombre de colonnes s'adapte: - < 480px: 1 colonne - 480-768px: 2 colonnes - 768-1024px: 2 colonnes - 1024-1280px: 3 colonnes - 1280-1600px: 4 colonnes - > 1600px: 5 colonnes ### AC3: Tailles de notes respectĂ©es - [ ] **Given** une note avec `data-size="large"` - [ ] **When** la note est affichĂ©e dans la grille - [ ] **Then** elle a une `min-height` de 300px - [ ] **And** sa hauteur finale est dĂ©terminĂ©e par son contenu ### AC4: Synchronisation React-Muuri - [ ] **Given** une grille avec des notes - [ ] **When** j'ajoute une nouvelle note via l'input - [ ] **Then** la note apparaĂźt dans la grille avec les bonnes dimensions - [ ] **And** elle est draggable immĂ©diatement ### AC5: Tests Playwright passants - [ ] **Given** les tests Playwright existants - [ ] **When** j'exĂ©cute `npx playwright test drag-drop.spec.ts` - [ ] **Then** tous les tests passent avec les sĂ©lecteurs `[data-draggable="true"]` --- ## Additional Context ### Dependencies | DĂ©pendance | Version | Usage | |------------|---------|-------| | muuri | ^0.9.5 | Grille masonry avec drag & drop | | web-animations-js | (bundled) | Polyfill animations | | ResizeObserver | Native | DĂ©tection resize conteneur | ### Testing Strategy **Tests automatisĂ©s:** ```bash # ExĂ©cuter tests drag-drop npx playwright test drag-drop.spec.ts # ExĂ©cuter tests responsive (Ă  ajouter) npx playwright test --grep "responsive" ``` **Tests manuels:** 1. Ouvrir l'app sur diffĂ©rentes tailles d'Ă©cran 2. VĂ©rifier le nombre de colonnes selon breakpoints 3. Drag une note et vĂ©rifier le placeholder 4. Ajouter une note et vĂ©rifier qu'elle est draggable 5. Redimensionner la fenĂȘtre et vĂ©rifier le re-layout ### Notes & Risques > [!WARNING] > **Risque: Synchronisation timing** > Le `requestAnimationFrame` dans `syncGridItems` doit s'exĂ©cuter APRÈS que React ait rendu les nouveaux Ă©lĂ©ments DOM. Si des problĂšmes de timing apparaissent, utiliser `setTimeout(..., 0)` ou `MutationObserver`. > [!NOTE] > **Comportement Google Keep** > Google Keep utilise des hauteurs automatiques basĂ©es sur le contenu. On ne fixe pas de hauteur, seulement la largeur. Muuri gĂšre le positionnement vertical automatiquement. > [!TIP] > **Debug Muuri** > Ajouter `console.log` dans `handleDragEnd` pour vĂ©rifier que l'ordre est bien capturĂ© aprĂšs un drag. --- ## Ordre d'exĂ©cution recommandĂ© ```mermaid flowchart TD T1[Task 1: data-draggable] --> T4[Task 4: Sync React-Muuri] T2[Task 2: applyItemDimensions] --> T3[Task 3: ResizeObserver] T3 --> T4 T4 --> T5[Task 5: Tests Playwright] ``` 1. **Task 1** (5 min) - Modification simple, dĂ©bloque les tests 2. **Task 2** (10 min) - Refactoring fonction, prĂ©pare Task 3 3. **Task 3** (15 min) - ResizeObserver, dĂ©pend de Task 2 4. **Task 4** (20 min) - Sync React-Muuri, le plus critique 5. **Task 5** (5 min) - Validation finale **Temps estimĂ© total:** ~55 minutes