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:
parent
ddb67ba9e5
commit
d59ec592eb
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
82
keep-notes/tests/layout-spacing.spec.ts
Normal file
82
keep-notes/tests/layout-spacing.spec.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user