Keep/keep-notes/components/masonry-grid.tsx

409 lines
14 KiB
TypeScript

'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'; // Force rebuild: Spacing update verification
interface MasonryGridProps {
notes: Note[];
onEdit?: (note: Note, readOnly?: boolean) => void;
}
interface MasonryItemProps {
note: Note;
onEdit: (note: Note, readOnly?: boolean) => void;
onResize: () => void;
onNoteSizeChange: (noteId: string, newSize: 'small' | 'medium' | 'large') => void;
onDragStart?: (noteId: string) => void;
onDragEnd?: () => void;
isDragging?: boolean;
}
const MasonryItem = function MasonryItem({ note, onEdit, onResize, onNoteSizeChange, onDragStart, onDragEnd, isDragging }: MasonryItemProps) {
const resizeRef = useResizeObserver(onResize);
useEffect(() => {
onResize();
const timer = setTimeout(onResize, 300);
return () => clearTimeout(timer);
}, [note.size, onResize]);
return (
<div
className="masonry-item absolute py-1"
data-id={note.id}
data-size={note.size}
data-draggable="true"
>
<div className="masonry-item-content relative" ref={resizeRef as any}>
<NoteCard
note={note}
onEdit={onEdit}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
isDragging={isDragging}
onResize={onResize}
onSizeChange={(newSize) => onNoteSizeChange(note.id, newSize)}
/>
</div>
</div>
);
};
export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
const { t } = useLanguage();
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null);
const { startDrag, endDrag, draggedNoteId } = useNotebookDrag();
// Local state for notes with dynamic size updates
// This allows size changes to propagate immediately without waiting for server
const [localNotes, setLocalNotes] = useState<Note[]>(notes);
// Sync localNotes when parent notes prop changes
useEffect(() => {
setLocalNotes(notes);
}, [notes]);
// Callback for when a note's size changes - update local state immediately
const handleNoteSizeChange = useCallback((noteId: string, newSize: 'small' | 'medium' | 'large') => {
setLocalNotes(prevNotes =>
prevNotes.map(n => n.id === noteId ? { ...n, size: newSize } : n)
);
}, []);
const handleEdit = useCallback((note: Note, readOnly?: boolean) => {
if (onEdit) {
onEdit(note, readOnly);
} else {
setEditingNote({ note, readOnly });
}
}, [onEdit]);
const pinnedGridRef = useRef<HTMLDivElement>(null);
const othersGridRef = useRef<HTMLDivElement>(null);
const pinnedMuuri = useRef<any>(null);
const othersMuuri = useRef<any>(null);
// Memoize filtered notes from localNotes (which includes dynamic size updates)
const pinnedNotes = useMemo(
() => localNotes.filter(n => n.isPinned),
[localNotes]
);
const othersNotes = useMemo(
() => localNotes.filter(n => !n.isPinned),
[localNotes]
);
const handleDragEnd = useCallback(async (grid: any) => {
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 refreshLayout = useCallback(() => {
requestAnimationFrame(() => {
if (pinnedMuuri.current) {
pinnedMuuri.current.refreshItems().layout();
}
if (othersMuuri.current) {
othersMuuri.current.refreshItems().layout();
}
});
}, []);
const applyItemDimensions = useCallback((grid: any, containerWidth: number) => {
if (!grid) return;
// Calculate columns and item width based on container width
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
}
});
}, []);
// Initialize Muuri grids once on mount and sync when needed
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);
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,
dragStartPredicate: {
distance: 10,
delay: 0,
},
dragPlaceholder: {
enabled: true,
createElement: (item: any) => {
const el = item.getElement().cloneNode(true);
// Styles are now handled purely by CSS (.muuri-item-placeholder)
// to avoid inline style conflicts and "grayed out/tilted" look
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
},
// LAYOUT OPTIONS - Configure masonry grid behavior
// These options are critical for proper masonry layout with different item sizes
layoutDuration: 300,
layoutEasing: 'cubic-bezier(0.25, 1, 0.5, 1)',
fillGaps: true,
horizontal: false,
alignRight: false,
alignBottom: false,
rounding: false,
// CRITICAL: Enable true masonry layout for different item sizes
layout: {
fillGaps: true,
horizontal: false,
alignRight: false,
alignBottom: false,
rounding: false,
},
};
// Initialize pinned grid
if (pinnedGridRef.current && !pinnedMuuri.current) {
pinnedMuuri.current = new MuuriClass(pinnedGridRef.current, layoutOptions)
.on('dragEnd', () => handleDragEnd(pinnedMuuri.current));
}
// Initialize others grid
if (othersGridRef.current && !othersMuuri.current) {
othersMuuri.current = new MuuriClass(othersGridRef.current, layoutOptions)
.on('dragEnd', () => handleDragEnd(othersMuuri.current));
}
};
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
}, []);
// Container ref for ResizeObserver
const containerRef = useRef<HTMLDivElement>(null);
// Synchronize items when notes change (e.g. searching, adding)
useEffect(() => {
const syncGridItems = (grid: any, gridRef: React.RefObject<HTMLDivElement | null>, 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();
// Map Muuri items to their elements for comparison
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 elements to remove (in Muuri but not in DOM)
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 to ensure consistency
domElements.forEach(el => {
el.style.width = `${itemWidth}px`;
});
// Refresh and layout
grid.refreshItems().layout();
};
// Use requestAnimationFrame to ensure DOM is updated before syncing
requestAnimationFrame(() => {
syncGridItems(pinnedMuuri.current, pinnedGridRef, pinnedNotes);
syncGridItems(othersMuuri.current, othersGridRef, othersNotes);
// CRITICAL: Force a second layout after CSS transitions (padding/height changes) complete
// NoteCard has a 200ms transition. We wait 300ms to be safe.
setTimeout(() => {
if (pinnedMuuri.current) pinnedMuuri.current.refreshItems().layout();
if (othersMuuri.current) othersMuuri.current.refreshItems().layout();
}, 300);
});
}, [pinnedNotes, othersNotes]); // Re-run when notes change
// Handle container resize to update responsive layout
useEffect(() => {
if (!containerRef.current || (!pinnedMuuri.current && !othersMuuri.current)) return;
let resizeTimeout: NodeJS.Timeout;
const handleResize = (entries: ResizeObserverEntry[]) => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
// Get precise width from ResizeObserver
const containerWidth = entries[0]?.contentRect.width || window.innerWidth - 32;
const columns = calculateColumns(containerWidth);
// 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); // Debounce
};
const observer = new ResizeObserver(handleResize);
observer.observe(containerRef.current);
// Initial layout check
if (containerRef.current) {
handleResize([{ contentRect: containerRef.current.getBoundingClientRect() } as ResizeObserverEntry]);
}
return () => {
clearTimeout(resizeTimeout);
observer.disconnect();
};
}, [applyItemDimensions]);
return (
<div ref={containerRef} className="masonry-container">
{pinnedNotes.length > 0 && (
<div className="mb-8">
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 px-2">{t('notes.pinned')}</h2>
<div ref={pinnedGridRef} className="relative min-h-[100px]">
{pinnedNotes.map(note => (
<MasonryItem
key={note.id}
note={note}
onEdit={handleEdit}
onResize={refreshLayout}
onNoteSizeChange={handleNoteSizeChange}
onDragStart={startDrag}
onDragEnd={endDrag}
isDragging={draggedNoteId === note.id}
/>
))}
</div>
</div>
)}
{othersNotes.length > 0 && (
<div>
{pinnedNotes.length > 0 && (
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 px-2">{t('notes.others')}</h2>
)}
<div ref={othersGridRef} className="relative min-h-[100px]">
{othersNotes.map(note => (
<MasonryItem
key={note.id}
note={note}
onEdit={handleEdit}
onResize={refreshLayout}
onNoteSizeChange={handleNoteSizeChange}
onDragStart={startDrag}
onDragEnd={endDrag}
isDragging={draggedNoteId === note.id}
/>
))}
</div>
</div>
)}
{editingNote && (
<NoteEditor
note={editingNote.note}
readOnly={editingNote.readOnly}
onClose={() => setEditingNote(null)}
/>
)}
</div>
);
}