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
This commit is contained in:
2026-01-09 22:13:49 +01:00
parent 3c4b9d6176
commit 640fcb26f7
218 changed files with 51363 additions and 902 deletions

View File

@@ -1,8 +1,8 @@
'use client';
'use client'
import { useState, useEffect, useRef, useCallback, memo } from 'react';
import { Note } from '@/lib/types';
import { NoteCard } from './note-card';
import { useState, useEffect, useRef, useCallback } from 'react';
import { NoteEditor } from './note-editor';
import { updateFullOrder } from '@/app/actions/notes';
import { useResizeObserver } from '@/hooks/use-resize-observer';
@@ -13,18 +13,32 @@ interface MasonryGridProps {
interface MasonryItemProps {
note: Note;
onEdit: (note: Note) => void;
onEdit: (note: Note, readOnly?: boolean) => void;
onResize: () => void;
}
function MasonryItem({ note, onEdit, onResize }: MasonryItemProps) {
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 w-full sm:w-1/2 lg:w-1/3 xl:w-1/4 2xl:w-1/5 p-2"
className={`masonry-item absolute p-2 ${sizeClasses}`}
data-id={note.id}
ref={resizeRef as any}
>
@@ -33,20 +47,28 @@ function MasonryItem({ note, onEdit, onResize }: MasonryItemProps) {
</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 | null>(null);
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'))
@@ -56,6 +78,11 @@ export function MasonryGrid({ notes }: MasonryGridProps) {
await updateFullOrder(ids);
} catch (error) {
console.error('Failed to persist order:', error);
} finally {
// Reset after animation/server roundtrip
setTimeout(() => {
isDraggingRef.current = false;
}, 1000);
}
};
@@ -82,8 +109,13 @@ export function MasonryGrid({ notes }: MasonryGridProps) {
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,
@@ -125,10 +157,12 @@ export function MasonryGrid({ notes }: MasonryGridProps) {
pinnedMuuri.current = null;
othersMuuri.current = null;
};
}, [pinnedNotes.length, othersNotes.length]);
}, [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();
}
@@ -144,10 +178,10 @@ export function MasonryGrid({ notes }: MasonryGridProps) {
<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={setEditingNote}
<MasonryItem
key={note.id}
note={note}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
onResize={refreshLayout}
/>
))}
@@ -162,10 +196,10 @@ export function MasonryGrid({ notes }: MasonryGridProps) {
)}
<div ref={othersGridRef} className="relative min-h-[100px]">
{othersNotes.map(note => (
<MasonryItem
key={note.id}
note={note}
onEdit={setEditingNote}
<MasonryItem
key={note.id}
note={note}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
onResize={refreshLayout}
/>
))}
@@ -174,7 +208,11 @@ export function MasonryGrid({ notes }: MasonryGridProps) {
)}
{editingNote && (
<NoteEditor note={editingNote} onClose={() => setEditingNote(null)} />
<NoteEditor
note={editingNote.note}
readOnly={editingNote.readOnly}
onClose={() => setEditingNote(null)}
/>
)}
<style jsx global>{`