'use client' import { useState, useEffect, useRef, useCallback, memo, useMemo } from 'react'; import { Note } from '@/lib/types'; import { NoteCard } from './note-card'; import { NoteEditor } from './note-editor'; import { updateFullOrderWithoutRevalidation } from '@/app/actions/notes'; import { useResizeObserver } from '@/hooks/use-resize-observer'; import { useNotebookDrag } from '@/context/notebook-drag-context'; import { useLanguage } from '@/lib/i18n'; import { DEFAULT_LAYOUT, calculateColumns, calculateItemWidth, isMobileViewport } from '@/config/masonry-layout'; import './masonry-grid.css'; interface MasonryGridProps { notes: Note[]; onEdit?: (note: Note, readOnly?: boolean) => void; } interface MasonryItemProps { note: Note; onEdit: (note: Note, readOnly?: boolean) => void; onResize: () => void; onDragStart?: (noteId: string) => void; onDragEnd?: () => void; } const MasonryItem = memo(function MasonryItem({ note, onEdit, onResize, onDragStart, onDragEnd }: MasonryItemProps) { const resizeRef = useResizeObserver(onResize); return (
); }, (prev, next) => { // Custom comparison to avoid re-render on function prop changes if note data is same return prev.note.id === next.note.id; // Removed isDragging comparison }); export function MasonryGrid({ notes, onEdit }: MasonryGridProps) { const { t } = useLanguage(); const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null); const { startDrag, endDrag, draggedNoteId } = useNotebookDrag(); const lastDragEndTime = useRef(0); const handleEdit = useCallback((note: Note, readOnly?: boolean) => { // Prevent opening note if it was just dragged (within 200ms) if (Date.now() - lastDragEndTime.current < 200) { return; } if (onEdit) { onEdit(note, readOnly); } else { setEditingNote({ note, readOnly }); } }, [onEdit]); const pinnedGridRef = useRef(null); const othersGridRef = useRef(null); const pinnedMuuri = useRef(null); const othersMuuri = useRef(null); // Memoize filtered notes (order comes from array) const pinnedNotes = useMemo( () => notes.filter(n => n.isPinned), [notes] ); const othersNotes = useMemo( () => notes.filter(n => !n.isPinned), [notes] ); const handleDragEnd = useCallback(async (grid: any) => { // Record drag end time to prevent accidental clicks lastDragEndTime.current = Date.now(); if (!grid) return; const items = grid.getItems(); const ids = items .map((item: any) => item.getElement()?.getAttribute('data-id')) .filter((id: any): id is string => !!id); try { // Save order to database WITHOUT triggering a full page refresh // Muuri has already updated the visual layout await updateFullOrderWithoutRevalidation(ids); } catch (error) { console.error('Failed to persist order:', error); } }, []); const layoutTimeoutRef = useRef(); const refreshLayout = useCallback((_?: any) => { if (layoutTimeoutRef.current) { clearTimeout(layoutTimeoutRef.current); } layoutTimeoutRef.current = setTimeout(() => { requestAnimationFrame(() => { if (pinnedMuuri.current) { pinnedMuuri.current.refreshItems().layout(); } if (othersMuuri.current) { othersMuuri.current.refreshItems().layout(); } }); }, 100); // 100ms debounce }, []); // Ref for container to use with ResizeObserver const containerRef = useRef(null); // Centralized function to apply item dimensions based on container width 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 is auto - determined by content (Google Keep style) } }); }, []); useEffect(() => { let isMounted = true; let muuriInitialized = false; const initMuuri = async () => { // Prevent duplicate initialization if (muuriInitialized) return; muuriInitialized = true; // Import web-animations-js polyfill await import('web-animations-js'); // Dynamic import of Muuri to avoid SSR window error const MuuriClass = (await import('muuri')).default; if (!isMounted) return; // Detect if we are on a touch device (mobile behavior) const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0; const isMobileWidth = window.innerWidth < 768; const isMobile = isTouchDevice || isMobileWidth; // Get container width for responsive calculation const containerWidth = window.innerWidth - 32; // Subtract padding const columns = calculateColumns(containerWidth); const itemWidth = calculateItemWidth(containerWidth, columns); console.log(`[Masonry] Container width: ${containerWidth}px, Columns: ${columns}, Item width: ${itemWidth}px`); // Calculate item dimensions based on note size const getItemDimensions = (note: Note) => { const baseWidth = itemWidth; let baseHeight = 200; // Default medium height switch (note.size) { case 'small': baseHeight = 150; break; case 'medium': baseHeight = 200; break; case 'large': baseHeight = 300; break; default: baseHeight = 200; } return { width: baseWidth, height: baseHeight }; }; const layoutOptions = { dragEnabled: true, // Use drag handle for mobile devices to allow smooth scrolling // On desktop, whole card is draggable (no handle needed) dragHandle: isMobile ? '.muuri-drag-handle' : undefined, // dragContainer: document.body, // REMOVED: Keep item in grid to prevent React conflict dragStartPredicate: { distance: 10, delay: 0, }, dragPlaceholder: { enabled: true, createElement: (item: any) => { const el = item.getElement().cloneNode(true); el.style.opacity = '0.4'; el.style.transform = 'scale(1.05)'; el.style.boxShadow = '0 20px 40px rgba(0, 0, 0, 0.3)'; el.classList.add('muuri-item-placeholder'); return el; }, }, dragAutoScroll: { targets: [window], speed: (item: any, target: any, intersection: any) => { return intersection * 30; // Faster auto-scroll for better UX }, threshold: 50, // Start auto-scroll earlier (50px from edge) smoothStop: true, // Smooth deceleration }, // IMPROVED: Configuration for drag release handling dragRelease: { duration: 300, easing: 'cubic-bezier(0.25, 1, 0.5, 1)', useDragContainer: false, // REMOVED: Keep item in grid }, dragCssProps: { touchAction: 'none', userSelect: 'none', userDrag: 'none', tapHighlightColor: 'rgba(0, 0, 0, 0)', touchCallout: 'none', contentZooming: 'none', }, // CRITICAL: Grid layout configuration for fixed width items layoutDuration: 30, // Much faster layout for responsiveness layoutEasing: 'ease-out', // Use grid layout for better control over item placement layout: { // Enable true masonry layout - items can be different heights fillGaps: true, horizontal: false, alignRight: false, alignBottom: false, rounding: false, // Set fixed width and let height be determined by content // This creates a Google Keep-like masonry layout itemPositioning: { onLayout: true, onResize: true, onInit: true, }, }, dragSort: true, dragSortInterval: 50, // Enable drag and drop with proper drag handling dragSortHeuristics: { sortInterval: 0, // Zero interval for immediate sorting minDragDistance: 5, minBounceBackAngle: 1, }, // Grid configuration for responsive columns visibleStyles: { opacity: '1', transform: 'scale(1)', }, hiddenStyles: { opacity: '0', transform: 'scale(0.5)', }, }; // Initialize pinned grid if (pinnedGridRef.current && !pinnedMuuri.current) { // Set container width explicitly pinnedGridRef.current.style.width = '100%'; pinnedGridRef.current.style.position = 'relative'; // Get all items in the pinned grid and set their dimensions const pinnedItems = Array.from(pinnedGridRef.current.children); pinnedItems.forEach((item) => { const noteId = item.getAttribute('data-id'); const note = pinnedNotes.find(n => n.id === noteId); if (note) { const dims = getItemDimensions(note); (item as HTMLElement).style.width = `${dims.width}px`; // Don't set height - let content determine it like Google Keep } }); pinnedMuuri.current = new MuuriClass(pinnedGridRef.current, layoutOptions) .on('dragEnd', () => handleDragEnd(pinnedMuuri.current)) .on('dragStart', () => { // Optional: visual feedback or state update }); // Initial layout requestAnimationFrame(() => { pinnedMuuri.current?.refreshItems().layout(); }); } // Initialize others grid if (othersGridRef.current && !othersMuuri.current) { // Set container width explicitly othersGridRef.current.style.width = '100%'; othersGridRef.current.style.position = 'relative'; // Get all items in the others grid and set their dimensions const othersItems = Array.from(othersGridRef.current.children); othersItems.forEach((item) => { const noteId = item.getAttribute('data-id'); const note = othersNotes.find(n => n.id === noteId); if (note) { const dims = getItemDimensions(note); (item as HTMLElement).style.width = `${dims.width}px`; // Don't set height - let content determine it like Google Keep } }); othersMuuri.current = new MuuriClass(othersGridRef.current, layoutOptions) .on('dragEnd', () => handleDragEnd(othersMuuri.current)); // Initial layout requestAnimationFrame(() => { othersMuuri.current?.refreshItems().layout(); }); } }; initMuuri(); return () => { isMounted = false; pinnedMuuri.current?.destroy(); othersMuuri.current?.destroy(); pinnedMuuri.current = null; othersMuuri.current = null; }; // Only run once on mount // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Synchronize items when notes change (e.g. searching, adding, removing) useEffect(() => { const syncGridItems = ( grid: any, gridRef: React.RefObject, notesArray: Note[] ) => { if (!grid || !gridRef.current) return; // Get container width for dimension calculation 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 (in DOM but not in Muuri) const newElements = domElements.filter(el => !muuriElements.includes(el)); // Find removed items (in Muuri but not in DOM) const removedItems = muuriItems.filter((item: any) => !domElements.includes(item.getElement()) ); // Remove old items from Muuri if (removedItems.length > 0) { console.log(`[Masonry Sync] Removing ${removedItems.length} items`); grid.remove(removedItems, { layout: false }); } // Add new items to Muuri with correct width if (newElements.length > 0) { console.log(`[Masonry Sync] Adding ${newElements.length} new items`); newElements.forEach(el => { el.style.width = `${itemWidth}px`; }); grid.add(newElements, { layout: false }); } // Update all existing item widths domElements.forEach(el => { el.style.width = `${itemWidth}px`; }); // Refresh and layout - CRITICAL: Always refresh items to catch content size changes // Use requestAnimationFrame to ensure DOM has painted requestAnimationFrame(() => { grid.refreshItems().layout(); }); }; // Use setTimeout to ensure React has finished rendering DOM elements const timeoutId = setTimeout(() => { syncGridItems(pinnedMuuri.current, pinnedGridRef, pinnedNotes); syncGridItems(othersMuuri.current, othersGridRef, othersNotes); }, 50); // Increased timeout slightly to ensure DOM stability return () => clearTimeout(timeoutId); }, [pinnedNotes, othersNotes]); // Handle container resize with ResizeObserver for responsive layout useEffect(() => { if (!containerRef.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); console.log(`[Masonry Resize] Width: ${containerWidth}px, Columns: ${columns}`); // Apply dimensions to both grids using centralized function applyItemDimensions(pinnedMuuri.current, containerWidth); applyItemDimensions(othersMuuri.current, containerWidth); // Refresh both grids with new layout requestAnimationFrame(() => { if (pinnedMuuri.current) { pinnedMuuri.current.refreshItems().layout(); } if (othersMuuri.current) { othersMuuri.current.refreshItems().layout(); } }); }, 150); }; const observer = new ResizeObserver(handleResize); observer.observe(containerRef.current); // Initial layout calculation if (containerRef.current) { const initialWidth = containerRef.current.getBoundingClientRect().width || window.innerWidth - 32; applyItemDimensions(pinnedMuuri.current, initialWidth); applyItemDimensions(othersMuuri.current, initialWidth); requestAnimationFrame(() => { pinnedMuuri.current?.refreshItems().layout(); othersMuuri.current?.refreshItems().layout(); }); } return () => { clearTimeout(resizeTimeout); observer.disconnect(); }; }, [applyItemDimensions]); return (
{pinnedNotes.length > 0 && (

{t('notes.pinned')}

{pinnedNotes.map(note => ( ))}
)} {othersNotes.length > 0 && (
{pinnedNotes.length > 0 && (

{t('notes.others')}

)}
{othersNotes.map(note => ( ))}
)} {editingNote && ( setEditingNote(null)} /> )}
); }