Keep/_bmad-output/implementation-artifacts/tech-spec-fix-muuri-masonry-grid.md
sepehr ddb67ba9e5 fix: unify theme system - fix theme switching persistence
- Unified localStorage key to 'theme-preference' across all components
- Fixed header.tsx using wrong localStorage key ('theme' instead of 'theme-preference')
- Added localStorage hybrid persistence for instant theme changes
- Removed router.refresh() which was causing stale data revert
- Replaced Blue theme with Sepia
- Consolidated auth() calls to prevent race conditions
- Updated UserSettingsData types to include all themes
2026-01-18 22:33:41 +01:00

417 lines
13 KiB
Markdown

---
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)
<div
className="masonry-item absolute py-1"
data-id={note.id}
data-size={note.size}
ref={resizeRef as any}
style={{ width: 'auto', height: 'auto' }}
>
// APRÈS
<div
className="masonry-item absolute py-1"
data-id={note.id}
data-size={note.size}
data-draggable="true"
ref={resizeRef as any}
style={{ width: 'auto', height: 'auto' }}
>
```
---
#### 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<HTMLDivElement>(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
<div className="masonry-container">
// APRÈS
<div ref={containerRef} className="masonry-container">
```
---
#### 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<HTMLDivElement>, 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