Keep/keep-notes/components/masonry-grid.tsx
sepehr 640fcb26f7 fix: improve note interactions and markdown LaTeX support
## Bug Fixes

### Note Card Actions
- Fix broken size change functionality (missing state declaration)
- Implement React 19 useOptimistic for instant UI feedback
- Add startTransition for non-blocking updates
- Ensure smooth animations without page refresh
- All note actions now work: pin, archive, color, size, checklist

### Markdown LaTeX Rendering
- Add remark-math and rehype-katex plugins
- Support inline equations with dollar sign syntax
- Support block equations with double dollar sign syntax
- Import KaTeX CSS for proper styling
- Equations now render correctly instead of showing raw LaTeX

## Technical Details

- Replace undefined currentNote references with optimistic state
- Add optimistic updates before server actions for instant feedback
- Use router.refresh() in transitions for smart cache invalidation
- Install remark-math, rehype-katex, and katex packages

## Testing

- Build passes successfully with no TypeScript errors
- Dev server hot-reloads changes correctly
2026-01-09 22:13:49 +01:00

242 lines
7.0 KiB
TypeScript

'use client'
import { useState, useEffect, useRef, useCallback, memo } from 'react';
import { Note } from '@/lib/types';
import { NoteCard } from './note-card';
import { NoteEditor } from './note-editor';
import { updateFullOrder } from '@/app/actions/notes';
import { useResizeObserver } from '@/hooks/use-resize-observer';
interface MasonryGridProps {
notes: Note[];
}
interface MasonryItemProps {
note: Note;
onEdit: (note: Note, readOnly?: boolean) => void;
onResize: () => void;
}
function getSizeClasses(size: string = 'small') {
switch (size) {
case 'medium':
return 'w-full sm:w-full lg:w-2/3 xl:w-2/4 2xl:w-2/5';
case 'large':
return 'w-full';
case 'small':
default:
return 'w-full sm:w-1/2 lg:w-1/3 xl:w-1/4 2xl:w-1/5';
}
}
const MasonryItem = memo(function MasonryItem({ note, onEdit, onResize }: MasonryItemProps) {
const resizeRef = useResizeObserver(() => {
onResize();
});
const sizeClasses = getSizeClasses(note.size);
return (
<div
className={`masonry-item absolute p-2 ${sizeClasses}`}
data-id={note.id}
ref={resizeRef as any}
>
<div className="masonry-item-content relative">
<NoteCard note={note} onEdit={onEdit} />
</div>
</div>
);
}, (prev, next) => {
// Custom comparison to avoid re-render on function prop changes if note data is same
return prev.note === next.note;
});
export function MasonryGrid({ notes }: MasonryGridProps) {
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null);
const pinnedGridRef = useRef<HTMLDivElement>(null);
const othersGridRef = useRef<HTMLDivElement>(null);
const pinnedMuuri = useRef<any>(null);
const othersMuuri = useRef<any>(null);
const isDraggingRef = useRef(false);
const pinnedNotes = notes.filter(n => n.isPinned).sort((a, b) => a.order - b.order);
const othersNotes = notes.filter(n => !n.isPinned).sort((a, b) => a.order - b.order);
const handleDragEnd = async (grid: any) => {
if (!grid) return;
// Prevent layout refresh during server update
isDraggingRef.current = true;
const items = grid.getItems();
const ids = items
.map((item: any) => item.getElement()?.getAttribute('data-id'))
.filter((id: any): id is string => !!id);
try {
await updateFullOrder(ids);
} catch (error) {
console.error('Failed to persist order:', error);
} finally {
// Reset after animation/server roundtrip
setTimeout(() => {
isDraggingRef.current = false;
}, 1000);
}
};
const refreshLayout = useCallback(() => {
// Use requestAnimationFrame for smoother updates
requestAnimationFrame(() => {
if (pinnedMuuri.current) {
pinnedMuuri.current.refreshItems().layout();
}
if (othersMuuri.current) {
othersMuuri.current.refreshItems().layout();
}
});
}, []);
useEffect(() => {
let isMounted = true;
const initMuuri = async () => {
// 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 isMobile = window.matchMedia('(pointer: coarse)').matches;
const layoutOptions = {
dragEnabled: true,
// On mobile, restrict drag to handle to allow scrolling. On desktop, allow drag from anywhere.
dragHandle: isMobile ? '.drag-handle' : undefined,
dragContainer: document.body,
dragStartPredicate: {
distance: 10,
delay: 0,
},
dragPlaceholder: {
enabled: true,
createElement: (item: any) => {
const el = item.getElement().cloneNode(true);
el.style.opacity = '0.5';
return el;
},
},
dragAutoScroll: {
targets: [window],
speed: (item: any, target: any, intersection: any) => {
return intersection * 20;
},
},
};
if (pinnedGridRef.current && !pinnedMuuri.current && pinnedNotes.length > 0) {
pinnedMuuri.current = new MuuriClass(pinnedGridRef.current, layoutOptions)
.on('dragEnd', () => handleDragEnd(pinnedMuuri.current));
}
if (othersGridRef.current && !othersMuuri.current && othersNotes.length > 0) {
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;
};
}, [pinnedNotes.length > 0, othersNotes.length > 0]);
// Synchronize items when notes change (e.g. searching, adding)
useEffect(() => {
if (isDraggingRef.current) return;
if (pinnedMuuri.current) {
pinnedMuuri.current.refreshItems().layout();
}
if (othersMuuri.current) {
othersMuuri.current.refreshItems().layout();
}
}, [notes]);
return (
<div 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">Pinned</h2>
<div ref={pinnedGridRef} className="relative min-h-[100px]">
{pinnedNotes.map(note => (
<MasonryItem
key={note.id}
note={note}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
onResize={refreshLayout}
/>
))}
</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">Others</h2>
)}
<div ref={othersGridRef} className="relative min-h-[100px]">
{othersNotes.map(note => (
<MasonryItem
key={note.id}
note={note}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
onResize={refreshLayout}
/>
))}
</div>
</div>
)}
{editingNote && (
<NoteEditor
note={editingNote.note}
readOnly={editingNote.readOnly}
onClose={() => setEditingNote(null)}
/>
)}
<style jsx global>{`
.masonry-item {
display: block;
position: absolute;
z-index: 1;
}
.masonry-item.muuri-item-dragging {
z-index: 3;
}
.masonry-item.muuri-item-releasing {
z-index: 2;
}
.masonry-item.muuri-item-hidden {
z-index: 0;
}
.masonry-item-content {
position: relative;
width: 100%;
height: 100%;
}
`}</style>
</div>
);
}