- 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
13 KiB
| title | slug | created | status | stepsCompleted | tech_stack | files_to_modify | code_patterns | test_patterns | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Fix Muuri Masonry Grid - Drag & Drop et Layout Responsive | fix-muuri-masonry-grid | 2026-01-18 | ready-for-dev |
|
|
|
|
|
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:
-
❌ Drag & Drop cassé - Les tests Playwright cherchent
data-draggable="true"mais l'attribut est surNoteCard(ligne 273), pas sur leMasonryItemwrapper que Muuri manipule. -
❌ Tailles de notes non gérées - Les notes ont
data-sizemais Muuri ne recalcule pas le layout après le rendu du contenu. La fonctiongetItemDimensionsest définie mais jamais réutilisée lors des syncs. -
❌ Layout non responsive - Les colonnes sont calculées via
calculateColumns()mais les largeurs ne sont appliquées qu'une seule fois. LeuseEffectde sync (lignes 295-322) ne gère pas l'ajout/suppression d'items. -
❌ 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:
- Propager
data-draggable="true"auMasonryItemwrapper - Centraliser le calcul des dimensions dans une fonction réutilisable
- Utiliser
ResizeObserversur le conteneur principal - Synchroniser les items DOM avec Muuri après chaque rendu React
- 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):
const MuuriClass = (await import('muuri')).default;
pinnedMuuri.current = new MuuriClass(pinnedGridRef.current, layoutOptions);
Hook useResizeObserver existant:
// 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):
const { startDrag, endDrag, draggedNoteId } = useNotebookDrag();
Drag handle mobile:
dragHandle: isMobile ? '.muuri-drag-handle' : undefined,
Files to Reference
| File | Purpose | Lines clés |
|---|---|---|
| masonry-grid.tsx | Composant grille Muuri | 116-292 (init), 295-322 (sync) |
| note-card.tsx | Carte note avec data-draggable | 271-301 (Card props) |
| masonry-grid.css | Styles tailles et drag | 54-67, 70-97 |
| masonry-layout.ts | Config breakpoints | 81-90 (calculateColumns) |
| drag-drop.spec.ts | Tests E2E | 45, 75-78 (data-draggable) |
Technical Decisions
- Garder Muuri - Fonctionne pour masonry, on corrige l'intégration
- Réutiliser useResizeObserver - Hook existant avec RAF debounce
- Hauteur auto - Comme Google Keep, contenu détermine hauteur
- 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
// 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)
// 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)
// 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)
// 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
// 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-itemwrappers - 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-heightde 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:
# Exécuter tests drag-drop
npx playwright test drag-drop.spec.ts
# Exécuter tests responsive (à ajouter)
npx playwright test --grep "responsive"
Tests manuels:
- Ouvrir l'app sur différentes tailles d'écran
- Vérifier le nombre de colonnes selon breakpoints
- Drag une note et vérifier le placeholder
- Ajouter une note et vérifier qu'elle est draggable
- Redimensionner la fenêtre et vérifier le re-layout
Notes & Risques
Warning
Risque: Synchronisation timing Le
requestAnimationFramedanssyncGridItemsdoit s'exécuter APRÈS que React ait rendu les nouveaux éléments DOM. Si des problèmes de timing apparaissent, utilisersetTimeout(..., 0)ouMutationObserver.
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.logdanshandleDragEndpour vérifier que l'ordre est bien capturé après un drag.
Ordre d'exécution recommandé
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]
- Task 1 (5 min) - Modification simple, débloque les tests
- Task 2 (10 min) - Refactoring fonction, prépare Task 3
- Task 3 (15 min) - ResizeObserver, dépend de Task 2
- Task 4 (20 min) - Sync React-Muuri, le plus critique
- Task 5 (5 min) - Validation finale
Temps estimé total: ~55 minutes