409 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|