- 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
417 lines
13 KiB
Markdown
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
|