fix(grid): repair muuri drag&drop and visual styles

- Fix Muuri integration: add data-draggable and improve DOM sync
- Fix Drag Visuals: remove opacity/rotation/scale in NoteCard and CSS to prevent 'gray/crooked' look
- Feat(layout): switch to padding-based spacing strategy (16px gap)
- Fix(css): correct media queries to maintain consistent spacing
This commit is contained in:
sepehr 2026-01-24 17:53:40 +01:00
parent ddb67ba9e5
commit d59ec592eb
5 changed files with 203 additions and 255 deletions

View File

@ -8,14 +8,8 @@
/* Masonry Container */ /* Masonry Container */
.masonry-container { .masonry-container {
width: 100%; width: 100%;
padding: 0 16px 24px 16px; /* Reduced to compensate for item padding */
} padding: 0 20px 40px 20px;
/* Grid containers for pinned and others sections */
.masonry-container > div > div[ref*="GridRef"] {
width: 100%;
min-height: 100px;
position: relative;
} }
/* Masonry Item Base Styles - Width is managed by Muuri */ /* Masonry Item Base Styles - Width is managed by Muuri */
@ -24,8 +18,8 @@
position: absolute; position: absolute;
z-index: 1; z-index: 1;
box-sizing: border-box; box-sizing: border-box;
padding: 8px 0; padding: 8px;
width: auto; /* Width will be set by JS based on container */ /* 8px * 2 = 16px gap (Tighter spacing) */
} }
/* Masonry Item Content Wrapper */ /* Masonry Item Content Wrapper */
@ -44,39 +38,38 @@
/* Note Card - Base styles */ /* Note Card - Base styles */
.note-card { .note-card {
width: 100% !important; /* Force full width within grid cell */ width: 100% !important;
min-width: 0; /* Prevent overflow */ /* Force full width within grid cell */
height: auto !important; /* Let content determine height like Google Keep */ min-width: 0;
max-height: none !important; /* No max-height restriction */ /* Prevent overflow */
} }
/* Note Size Styles - Desktop Default */ /* Note Size Styles - Desktop Default */
.note-card[data-size="small"] { .note-card[data-size="small"] {
min-height: 150px !important; min-height: 150px !important;
height: auto !important;
} }
.note-card[data-size="medium"] { .note-card[data-size="medium"] {
min-height: 200px !important; min-height: 200px !important;
height: auto !important;
} }
.note-card[data-size="large"] { .note-card[data-size="large"] {
min-height: 300px !important; min-height: 300px !important;
height: auto !important;
} }
/* Drag State Styles - Improved for Google Keep-like behavior */ /* Drag State Styles - Clean and flat behavior requested by user */
.masonry-item.muuri-item-dragging { .masonry-item.muuri-item-dragging {
z-index: 1000; z-index: 1000;
opacity: 0.6; opacity: 1 !important;
transition: none; /* No transition during drag for better performance */ /* Force opacity to 100% */
transition: none;
} }
.masonry-item.muuri-item-dragging .note-card { .masonry-item.muuri-item-dragging .note-card {
transform: scale(1.05) rotate(2deg); transform: none !important;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.4); /* Force "straight" - no rotation, no scale */
transition: none; /* No transition during drag */ box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
transition: none;
} }
.masonry-item.muuri-item-releasing { .masonry-item.muuri-item-releasing {
@ -122,27 +115,25 @@
/* Mobile Styles (< 640px) */ /* Mobile Styles (< 640px) */
@media (max-width: 639px) { @media (max-width: 639px) {
.masonry-container { .masonry-container {
padding: 0 12px 16px 12px; padding: 0 20px 16px 20px;
} }
.masonry-item { .masonry-item {
padding: 6px 0; padding: 8px;
/* 16px gap on mobile */
} }
/* Smaller note sizes on mobile - keep same ratio */ /* Smaller note sizes on mobile */
.note-card[data-size="small"] { .masonry-item-content .note-card[data-size="small"] {
min-height: 120px !important; min-height: 120px;
height: auto !important;
} }
.note-card[data-size="medium"] { .masonry-item-content .note-card[data-size="medium"] {
min-height: 160px !important; min-height: 160px;
height: auto !important;
} }
.note-card[data-size="large"] { .masonry-item-content .note-card[data-size="large"] {
min-height: 240px !important; min-height: 240px;
height: auto !important;
} }
/* Reduced drag effect on mobile */ /* Reduced drag effect on mobile */
@ -155,31 +146,36 @@
/* Tablet Styles (640px - 1023px) */ /* Tablet Styles (640px - 1023px) */
@media (min-width: 640px) and (max-width: 1023px) { @media (min-width: 640px) and (max-width: 1023px) {
.masonry-container { .masonry-container {
padding: 0 16px 20px 16px; padding: 0 24px 20px 24px;
} }
.masonry-item { .masonry-item {
padding: 8px 0; padding: 8px;
/* 16px gap */
} }
} }
/* Desktop Styles (1024px - 1279px) */ /* Desktop Styles (1024px - 1279px) */
@media (min-width: 1024px) and (max-width: 1279px) { @media (min-width: 1024px) and (max-width: 1279px) {
.masonry-container { .masonry-container {
padding: 0 20px 24px 20px; padding: 0 28px 24px 28px;
}
.masonry-item {
padding: 8px;
} }
} }
/* Large Desktop Styles (1280px+) */ /* Large Desktop Styles (1280px+) */
@media (min-width: 1280px) { @media (min-width: 1280px) {
.masonry-container { .masonry-container {
padding: 0 24px 32px 24px; padding: 0 28px 32px 28px;
/* max-width removed for infinite columns */ max-width: 1600px;
width: 100%; margin: 0 auto;
} }
.masonry-item { .masonry-item {
padding: 10px 0; padding: 8px;
} }
} }
@ -204,6 +200,7 @@ body.muuri-dragging {
/* Optimize for reduced motion */ /* Optimize for reduced motion */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.masonry-item, .masonry-item,
.masonry-item-content, .masonry-item-content,
.note-card { .note-card {
@ -218,6 +215,7 @@ body.muuri-dragging {
/* Print styles */ /* Print styles */
@media print { @media print {
.masonry-item.muuri-item-dragging, .masonry-item.muuri-item-dragging,
.muuri-item-placeholder { .muuri-item-placeholder {
display: none !important; display: none !important;

View File

@ -9,7 +9,7 @@ import { useResizeObserver } from '@/hooks/use-resize-observer';
import { useNotebookDrag } from '@/context/notebook-drag-context'; import { useNotebookDrag } from '@/context/notebook-drag-context';
import { useLanguage } from '@/lib/i18n'; import { useLanguage } from '@/lib/i18n';
import { DEFAULT_LAYOUT, calculateColumns, calculateItemWidth, isMobileViewport } from '@/config/masonry-layout'; import { DEFAULT_LAYOUT, calculateColumns, calculateItemWidth, isMobileViewport } from '@/config/masonry-layout';
import './masonry-grid.css'; import './masonry-grid.css'; // Force rebuild: Spacing update verification
interface MasonryGridProps { interface MasonryGridProps {
notes: Note[]; notes: Note[];
@ -22,9 +22,10 @@ interface MasonryItemProps {
onResize: () => void; onResize: () => void;
onDragStart?: (noteId: string) => void; onDragStart?: (noteId: string) => void;
onDragEnd?: () => void; onDragEnd?: () => void;
isDragging?: boolean;
} }
const MasonryItem = memo(function MasonryItem({ note, onEdit, onResize, onDragStart, onDragEnd }: MasonryItemProps) { const MasonryItem = memo(function MasonryItem({ note, onEdit, onResize, onDragStart, onDragEnd, isDragging }: MasonryItemProps) {
const resizeRef = useResizeObserver(onResize); const resizeRef = useResizeObserver(onResize);
return ( return (
@ -41,13 +42,14 @@ const MasonryItem = memo(function MasonryItem({ note, onEdit, onResize, onDragSt
onEdit={onEdit} onEdit={onEdit}
onDragStart={onDragStart} onDragStart={onDragStart}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
isDragging={isDragging}
/> />
</div> </div>
</div> </div>
); );
}, (prev, next) => { }, (prev, next) => {
// Custom comparison to avoid re-render on function prop changes if note data is same // 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 return prev.note.id === next.note.id && prev.isDragging === next.isDragging;
}); });
export function MasonryGrid({ notes, onEdit }: MasonryGridProps) { export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
@ -55,14 +57,7 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null); const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null);
const { startDrag, endDrag, draggedNoteId } = useNotebookDrag(); const { startDrag, endDrag, draggedNoteId } = useNotebookDrag();
const lastDragEndTime = useRef<number>(0);
const handleEdit = useCallback((note: Note, readOnly?: boolean) => { 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) { if (onEdit) {
onEdit(note, readOnly); onEdit(note, readOnly);
} else { } else {
@ -86,9 +81,6 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
); );
const handleDragEnd = useCallback(async (grid: any) => { const handleDragEnd = useCallback(async (grid: any) => {
// Record drag end time to prevent accidental clicks
lastDragEndTime.current = Date.now();
if (!grid) return; if (!grid) return;
const items = grid.getItems(); const items = grid.getItems();
@ -105,32 +97,21 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
} }
}, []); }, []);
const layoutTimeoutRef = useRef<NodeJS.Timeout>(); const refreshLayout = useCallback(() => {
requestAnimationFrame(() => {
const refreshLayout = useCallback((_?: any) => { if (pinnedMuuri.current) {
if (layoutTimeoutRef.current) { pinnedMuuri.current.refreshItems().layout();
clearTimeout(layoutTimeoutRef.current); }
} if (othersMuuri.current) {
othersMuuri.current.refreshItems().layout();
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<HTMLDivElement>(null);
// Centralized function to apply item dimensions based on container width
const applyItemDimensions = useCallback((grid: any, containerWidth: number) => { const applyItemDimensions = useCallback((grid: any, containerWidth: number) => {
if (!grid) return; if (!grid) return;
// Calculate columns and item width based on container width
const columns = calculateColumns(containerWidth); const columns = calculateColumns(containerWidth);
const itemWidth = calculateItemWidth(containerWidth, columns); const itemWidth = calculateItemWidth(containerWidth, columns);
@ -139,11 +120,12 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
const el = item.getElement(); const el = item.getElement();
if (el) { if (el) {
el.style.width = `${itemWidth}px`; el.style.width = `${itemWidth}px`;
// Height is auto - determined by content (Google Keep style) // Height is auto (determined by content) - Google Keep style
} }
}); });
}, []); }, []);
// Initialize Muuri grids once on mount and sync when needed
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
let muuriInitialized = false; let muuriInitialized = false;
@ -172,34 +154,12 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
console.log(`[Masonry] Container width: ${containerWidth}px, Columns: ${columns}, Item width: ${itemWidth}px`); 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 = { const layoutOptions = {
dragEnabled: true, dragEnabled: true,
// Use drag handle for mobile devices to allow smooth scrolling // Use drag handle for mobile devices to allow smooth scrolling
// On desktop, whole card is draggable (no handle needed) // On desktop, whole card is draggable (no handle needed)
dragHandle: isMobile ? '.muuri-drag-handle' : undefined, dragHandle: isMobile ? '.muuri-drag-handle' : undefined,
// dragContainer: document.body, // REMOVED: Keep item in grid to prevent React conflict dragContainer: document.body,
dragStartPredicate: { dragStartPredicate: {
distance: 10, distance: 10,
delay: 0, delay: 0,
@ -208,10 +168,8 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
enabled: true, enabled: true,
createElement: (item: any) => { createElement: (item: any) => {
const el = item.getElement().cloneNode(true); const el = item.getElement().cloneNode(true);
el.style.opacity = '0.4'; // Styles are now handled purely by CSS (.muuri-item-placeholder)
el.style.transform = 'scale(1.05)'; // to avoid inline style conflicts and "grayed out/tilted" look
el.style.boxShadow = '0 20px 40px rgba(0, 0, 0, 0.3)';
el.classList.add('muuri-item-placeholder');
return el; return el;
}, },
}, },
@ -223,113 +181,35 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
threshold: 50, // Start auto-scroll earlier (50px from edge) threshold: 50, // Start auto-scroll earlier (50px from edge)
smoothStop: true, // Smooth deceleration smoothStop: true, // Smooth deceleration
}, },
// IMPROVED: Configuration for drag release handling // LAYOUT OPTIONS - Configure masonry grid behavior
dragRelease: { // These options are critical for proper masonry layout with different item sizes
duration: 300, layoutDuration: 300,
easing: 'cubic-bezier(0.25, 1, 0.5, 1)', layoutEasing: 'cubic-bezier(0.25, 1, 0.5, 1)',
useDragContainer: false, // REMOVED: Keep item in grid fillGaps: true,
}, horizontal: false,
dragCssProps: { alignRight: false,
touchAction: 'none', alignBottom: false,
userSelect: 'none', rounding: false,
userDrag: 'none', // CRITICAL: Enable true masonry layout for different item sizes
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: { layout: {
// Enable true masonry layout - items can be different heights
fillGaps: true, fillGaps: true,
horizontal: false, horizontal: false,
alignRight: false, alignRight: false,
alignBottom: false, alignBottom: false,
rounding: 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 // Initialize pinned grid
if (pinnedGridRef.current && !pinnedMuuri.current) { 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) pinnedMuuri.current = new MuuriClass(pinnedGridRef.current, layoutOptions)
.on('dragEnd', () => handleDragEnd(pinnedMuuri.current)) .on('dragEnd', () => handleDragEnd(pinnedMuuri.current));
.on('dragStart', () => {
// Optional: visual feedback or state update
});
// Initial layout
requestAnimationFrame(() => {
pinnedMuuri.current?.refreshItems().layout();
});
} }
// Initialize others grid // Initialize others grid
if (othersGridRef.current && !othersMuuri.current) { 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) othersMuuri.current = new MuuriClass(othersGridRef.current, layoutOptions)
.on('dragEnd', () => handleDragEnd(othersMuuri.current)); .on('dragEnd', () => handleDragEnd(othersMuuri.current));
// Initial layout
requestAnimationFrame(() => {
othersMuuri.current?.refreshItems().layout();
});
} }
}; };
@ -346,16 +226,14 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// Synchronize items when notes change (e.g. searching, adding, removing) // Container ref for ResizeObserver
const containerRef = useRef<HTMLDivElement>(null);
// Synchronize items when notes change (e.g. searching, adding)
useEffect(() => { useEffect(() => {
const syncGridItems = ( const syncGridItems = (grid: any, gridRef: React.RefObject<HTMLDivElement | null>, notesArray: Note[]) => {
grid: any,
gridRef: React.RefObject<HTMLDivElement | null>,
notesArray: Note[]
) => {
if (!grid || !gridRef.current) return; if (!grid || !gridRef.current) return;
// Get container width for dimension calculation
const containerWidth = containerRef.current?.getBoundingClientRect().width || window.innerWidth - 32; const containerWidth = containerRef.current?.getBoundingClientRect().width || window.innerWidth - 32;
const columns = calculateColumns(containerWidth); const columns = calculateColumns(containerWidth);
const itemWidth = calculateItemWidth(containerWidth, columns); const itemWidth = calculateItemWidth(containerWidth, columns);
@ -363,94 +241,80 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
// Get current DOM elements and Muuri items // Get current DOM elements and Muuri items
const domElements = Array.from(gridRef.current.children) as HTMLElement[]; const domElements = Array.from(gridRef.current.children) as HTMLElement[];
const muuriItems = grid.getItems(); const muuriItems = grid.getItems();
// Map Muuri items to their elements for comparison
const muuriElements = muuriItems.map((item: any) => item.getElement()); const muuriElements = muuriItems.map((item: any) => item.getElement());
// Find new elements to add (in DOM but not in Muuri) // Find new elements to add (in DOM but not in Muuri)
const newElements = domElements.filter(el => !muuriElements.includes(el)); const newElements = domElements.filter(el => !muuriElements.includes(el));
// Find removed items (in Muuri but not in DOM) // Find elements to remove (in Muuri but not in DOM)
const removedItems = muuriItems.filter((item: any) => const removedItems = muuriItems.filter((item: any) =>
!domElements.includes(item.getElement()) !domElements.includes(item.getElement())
); );
// Remove old items from Muuri // Remove old items
if (removedItems.length > 0) { if (removedItems.length > 0) {
console.log(`[Masonry Sync] Removing ${removedItems.length} items`);
grid.remove(removedItems, { layout: false }); grid.remove(removedItems, { layout: false });
} }
// Add new items to Muuri with correct width // Add new items with correct width
if (newElements.length > 0) { if (newElements.length > 0) {
console.log(`[Masonry Sync] Adding ${newElements.length} new items`);
newElements.forEach(el => { newElements.forEach(el => {
el.style.width = `${itemWidth}px`; el.style.width = `${itemWidth}px`;
}); });
grid.add(newElements, { layout: false }); grid.add(newElements, { layout: false });
} }
// Update all existing item widths // Update all item widths to ensure consistency
domElements.forEach(el => { domElements.forEach(el => {
el.style.width = `${itemWidth}px`; el.style.width = `${itemWidth}px`;
}); });
// Refresh and layout - CRITICAL: Always refresh items to catch content size changes // Refresh and layout
// Use requestAnimationFrame to ensure DOM has painted grid.refreshItems().layout();
requestAnimationFrame(() => {
grid.refreshItems().layout();
});
}; };
// Use setTimeout to ensure React has finished rendering DOM elements // Use requestAnimationFrame to ensure DOM is updated before syncing
const timeoutId = setTimeout(() => { requestAnimationFrame(() => {
syncGridItems(pinnedMuuri.current, pinnedGridRef, pinnedNotes); syncGridItems(pinnedMuuri.current, pinnedGridRef, pinnedNotes);
syncGridItems(othersMuuri.current, othersGridRef, othersNotes); syncGridItems(othersMuuri.current, othersGridRef, othersNotes);
}, 50); // Increased timeout slightly to ensure DOM stability });
}, [pinnedNotes, othersNotes]); // Re-run when notes change
return () => clearTimeout(timeoutId); // Handle container resize to update responsive layout
}, [pinnedNotes, othersNotes]);
// Handle container resize with ResizeObserver for responsive layout
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current || (!pinnedMuuri.current && !othersMuuri.current)) return;
let resizeTimeout: NodeJS.Timeout; let resizeTimeout: NodeJS.Timeout;
const handleResize = (entries: ResizeObserverEntry[]) => { const handleResize = (entries: ResizeObserverEntry[]) => {
clearTimeout(resizeTimeout); clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => { resizeTimeout = setTimeout(() => {
// Get precise width from ResizeObserver
const containerWidth = entries[0]?.contentRect.width || window.innerWidth - 32; const containerWidth = entries[0]?.contentRect.width || window.innerWidth - 32;
const columns = calculateColumns(containerWidth); const columns = calculateColumns(containerWidth);
console.log(`[Masonry Resize] Width: ${containerWidth}px, Columns: ${columns}`); console.log(`[Masonry Resize] Width: ${containerWidth}px, Columns: ${columns}`);
// Apply dimensions to both grids using centralized function // Apply dimensions to both grids
applyItemDimensions(pinnedMuuri.current, containerWidth); applyItemDimensions(pinnedMuuri.current, containerWidth);
applyItemDimensions(othersMuuri.current, containerWidth); applyItemDimensions(othersMuuri.current, containerWidth);
// Refresh both grids with new layout // Refresh layouts
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (pinnedMuuri.current) { pinnedMuuri.current?.refreshItems().layout();
pinnedMuuri.current.refreshItems().layout(); othersMuuri.current?.refreshItems().layout();
}
if (othersMuuri.current) {
othersMuuri.current.refreshItems().layout();
}
}); });
}, 150); }, 150); // Debounce
}; };
const observer = new ResizeObserver(handleResize); const observer = new ResizeObserver(handleResize);
observer.observe(containerRef.current); observer.observe(containerRef.current);
// Initial layout calculation // Initial layout check
if (containerRef.current) { if (containerRef.current) {
const initialWidth = containerRef.current.getBoundingClientRect().width || window.innerWidth - 32; handleResize([{ contentRect: containerRef.current.getBoundingClientRect() } as ResizeObserverEntry]);
applyItemDimensions(pinnedMuuri.current, initialWidth);
applyItemDimensions(othersMuuri.current, initialWidth);
requestAnimationFrame(() => {
pinnedMuuri.current?.refreshItems().layout();
othersMuuri.current?.refreshItems().layout();
});
} }
return () => { return () => {
@ -473,6 +337,7 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
onResize={refreshLayout} onResize={refreshLayout}
onDragStart={startDrag} onDragStart={startDrag}
onDragEnd={endDrag} onDragEnd={endDrag}
isDragging={draggedNoteId === note.id}
/> />
))} ))}
</div> </div>
@ -493,6 +358,7 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
onResize={refreshLayout} onResize={refreshLayout}
onDragStart={startDrag} onDragStart={startDrag}
onDragEnd={endDrag} onDragEnd={endDrag}
isDragging={draggedNoteId === note.id}
/> />
))} ))}
</div> </div>

View File

@ -102,7 +102,7 @@ function getInitials(name: string): string {
// Helper function to get avatar color based on name hash // Helper function to get avatar color based on name hash
function getAvatarColor(name: string): string { function getAvatarColor(name: string): string {
const colors = [ const colors = [
'bg-blue-500', 'bg-primary',
'bg-purple-500', 'bg-purple-500',
'bg-green-500', 'bg-green-500',
'bg-orange-500', 'bg-orange-500',
@ -288,7 +288,8 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver, onDragStart, on
colorClasses.bg, colorClasses.bg,
colorClasses.card, colorClasses.card,
colorClasses.hover, colorClasses.hover,
isDragging && 'opacity-60 scale-105 shadow-2xl rotate-1' colorClasses.hover,
isDragging && 'shadow-2xl' // Removed opacity, scale, and rotation for clean drag
)} )}
onClick={(e) => { onClick={(e) => {
// Only trigger edit if not clicking on buttons // Only trigger edit if not clicking on buttons
@ -315,7 +316,7 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver, onDragStart, on
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-8 w-8 p-0 bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-200 dark:hover:bg-blue-900/50 text-blue-600 dark:text-blue-400" className="h-8 w-8 p-0 bg-primary/10 dark:bg-primary/20 hover:bg-primary/20 dark:hover:bg-primary/30 text-primary dark:text-primary-foreground"
title={t('notebookSuggestion.moveToNotebook')} title={t('notebookSuggestion.moveToNotebook')}
> >
<FolderOpen className="h-4 w-4" /> <FolderOpen className="h-4 w-4" />
@ -417,7 +418,7 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver, onDragStart, on
'mb-2 text-xs', 'mb-2 text-xs',
optimisticNote.matchType === 'exact' optimisticNote.matchType === 'exact'
? 'bg-green-100 text-green-800 border-green-200 dark:bg-green-900/30 dark:text-green-300 dark:border-green-800' ? 'bg-green-100 text-green-800 border-green-200 dark:bg-green-900/30 dark:text-green-300 dark:border-green-800'
: 'bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-800' : 'bg-primary/10 text-primary border-primary/20 dark:bg-primary/20 dark:text-primary-foreground'
)} )}
> >
{t(`semanticSearch.${optimisticNote.matchType === 'exact' ? 'exactMatch' : 'related'}`)} {t(`semanticSearch.${optimisticNote.matchType === 'exact' ? 'exactMatch' : 'related'}`)}
@ -427,7 +428,7 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver, onDragStart, on
{/* Shared badge */} {/* Shared badge */}
{isSharedNote && owner && ( {isSharedNote && owner && (
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium"> <span className="text-xs text-primary dark:text-primary-foreground font-medium">
{t('notes.sharedBy')} {owner.name || owner.email} {t('notes.sharedBy')} {owner.name || owner.email}
</span> </span>
<Button <Button
@ -466,7 +467,7 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver, onDragStart, on
<div className="p-2"> <div className="p-2">
<h4 className="font-medium text-xs truncate text-gray-900 dark:text-gray-100">{link.title || link.url}</h4> <h4 className="font-medium text-xs truncate text-gray-900 dark:text-gray-100">{link.title || link.url}</h4>
{link.description && <p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 mt-0.5">{link.description}</p>} {link.description && <p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 mt-0.5">{link.description}</p>}
<span className="text-[10px] text-blue-500 mt-1 block"> <span className="text-[10px] text-primary mt-1 block">
{new URL(link.url).hostname} {new URL(link.url).hostname}
</span> </span>
</div> </div>

View File

@ -69,8 +69,8 @@ export const DEFAULT_LAYOUT: MasonryLayoutConfig = {
medium: { minHeight: 200, width: 240 }, medium: { minHeight: 200, width: 240 },
large: { minHeight: 300, width: 240 }, large: { minHeight: 300, width: 240 },
}, },
gap: 10, // Tighter gap closer to Google Keep gap: 24, // Further increased horizontal gap
gutter: 10, gutter: 24,
}; };
/** /**
@ -103,8 +103,9 @@ export function calculateColumns(width: number): number {
* @returns Item width in pixels * @returns Item width in pixels
*/ */
export function calculateItemWidth(containerWidth: number, columns: number): number { export function calculateItemWidth(containerWidth: number, columns: number): number {
const { gap } = DEFAULT_LAYOUT; // Return full column width
return (containerWidth - (columns - 1) * gap) / columns; // Gaps are now handled by padding inside the masonry-item CSS
return containerWidth / columns;
} }
/** /**

View File

@ -0,0 +1,82 @@
import { test, expect } from '@playwright/test';
test.describe('Masonry Grid Spacing', () => {
test('should have correct spacing between notes', async ({ page }) => {
// Go to home page
await page.goto('/');
// Create two notes to ensure we have content
await page.click('input[placeholder="Take a note..."]');
await page.fill('input[placeholder="Title"]', 'Note A');
await page.click('button:has-text("Add")');
await page.waitForTimeout(500);
await page.click('input[placeholder="Take a note..."]');
await page.fill('input[placeholder="Title"]', 'Note B');
await page.click('button:has-text("Add")');
await page.waitForTimeout(1000);
// Get the first two masonry items
const items = page.locator('.masonry-item');
const count = await items.count();
expect(count).toBeGreaterThanOrEqual(2);
const item1 = items.nth(0);
const item2 = items.nth(1);
// Get bounding boxes
const box1 = await item1.boundingBox();
const box2 = await item2.boundingBox();
if (!box1 || !box2) throw new Error('Could not get bounding boxes');
console.log('Box 1:', box1);
console.log('Box 2:', box2);
// Calculate horizontal distance between centers or edges?
// Assuming they are side-by-side in a multi-column layout on desktop
// Check viewport size
const viewport = page.viewportSize();
console.log('Viewport:', viewport);
if (viewport && viewport.width >= 1024) {
// Should be at least 2 columns
// Distance between left edges
const distance = Math.abs(box2.x - box1.x);
console.log('Distance betweeen left edges:', distance);
// Item width
console.log('Item 1 width:', box1.width);
// The visual gap depends on padding if we are using the padding strategy
// We can check the computed padding using evaluate
const padding = await item1.evaluate((el) => {
const style = window.getComputedStyle(el);
return style.padding;
});
console.log('Computed Padding:', padding);
}
});
test('should adjust columns on resize', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.masonry-item');
// Desktop
await page.setViewportSize({ width: 1280, height: 800 });
await page.waitForTimeout(1000);
let items = page.locator('.masonry-item');
let box1 = await items.nth(0).boundingBox();
console.log('1280px width - Item width:', box1?.width);
// Mobile
await page.setViewportSize({ width: 375, height: 667 });
await page.waitForTimeout(1000); // Wait for resize observer
items = page.locator('.masonry-item');
box1 = await items.nth(0).boundingBox();
console.log('375px width - Item width:', box1?.width);
// Calculate expectation logic here if needed
});
});