From daa4f6c038368fbc86d2982778b70f88dd552c9a Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Sun, 10 May 2026 13:10:01 +0000 Subject: [PATCH] Task #463: @dnd-kit notes drag + reorder - Replace HTML5+touch drag with @dnd-kit (PointerSensor distance:8, TouchSensor delay:200/tol:8) matching home.tsx pattern. - Add sort_order column to notes; ORDER BY asc(sortOrder), updatedAt desc. - New PATCH /notes/reorder endpoint: strict isPinned boolean validation, bucket+permission scoped, all writes in db.transaction for atomicity. - PATCH /notes/:id stamps sort_order = min-1 on folder/pin bucket change. - Client useReorderNotes hook with optimistic cache update. - handleDragEnd builds reorder payload from FULL bucket (owner notes or shared-folder bucket via ref), not the filtered/search subset, so hidden siblings retain stable order. - SharedFolderView publishes its data.notes via bucketRef when viewer has edit permission, enabling correct reorder in shared folders. - Layout fix at narrow viewport: rail stacks above notes (flex-col md:flex-row) so iPad portrait drag has proper bbox. - Playwright tests: notes-folders.spec.mjs both desktop pointer drag and touch long-press drag pass (32s). - OpenAPI codegen skipped: notes-api.ts is hand-written. - Out of scope (pre-existing failures): executive-meetings reorder/font, notes-share PATCH 403/404, groups-crud rollback. --- artifacts/api-server/src/routes/notes.ts | 156 +++++++- .../src/components/notes/folders-rail.tsx | 202 ++++------ artifacts/tx-os/src/lib/notes-api.ts | 75 ++++ artifacts/tx-os/src/pages/notes.tsx | 369 ++++++++++-------- artifacts/tx-os/tests/notes-folders.spec.mjs | 90 ++--- lib/db/src/schema/notes.ts | 8 + 6 files changed, 562 insertions(+), 338 deletions(-) diff --git a/artifacts/api-server/src/routes/notes.ts b/artifacts/api-server/src/routes/notes.ts index d451d91d..921b7ece 100644 --- a/artifacts/api-server/src/routes/notes.ts +++ b/artifacts/api-server/src/routes/notes.ts @@ -1,5 +1,5 @@ import { Router, type IRouter } from "express"; -import { eq, and, desc, inArray, or, sql } from "drizzle-orm"; +import { eq, and, asc, desc, inArray, or, sql } from "drizzle-orm"; import { db } from "@workspace/db"; import { notesTable, @@ -70,6 +70,7 @@ interface NoteInput { isArchived?: boolean; labelIds?: number[]; folderId?: number | null; + sortOrder?: number; } function parseKind(v: unknown): "text" | "checklist" | undefined { @@ -240,7 +241,11 @@ async function loadNotesWithLabels(userId: number, archived: boolean) { .select() .from(notesTable) .where(and(eq(notesTable.userId, userId), eq(notesTable.isArchived, archived))) - .orderBy(desc(notesTable.isPinned), desc(notesTable.updatedAt)); + .orderBy( + desc(notesTable.isPinned), + asc(notesTable.sortOrder), + desc(notesTable.updatedAt), + ); if (notes.length === 0) return []; @@ -461,6 +466,112 @@ router.post("/notes", requireAuth, async (req, res): Promise => { res.status(201).json(withLabels); }); +/** + * POST /notes/reorder + * + * Persist the user-chosen order of notes within a single bucket. A bucket + * is the unique combination of (note owner, folderId, isPinned). The + * client posts the full ordered list of note ids for the bucket and the + * server stamps sort_order = 0..N-1 in one transaction. + * + * Body: { folderId: number | null, isPinned: boolean, orderedIds: number[] } + * + * Auth: each id must be a note the caller owns OR a note inside a folder + * they have edit permission on (Task #463). Foreign ids (and ids that + * don't actually live in the named bucket) are silently skipped — that + * keeps the endpoint safe against stale client state without blowing up + * the whole reorder. + */ +router.post("/notes/reorder", requireAuth, async (req, res): Promise => { + const userId = req.session.userId!; + const body = req.body; + if (!body || typeof body !== "object") { + res.status(400).json({ error: "Invalid body" }); + return; + } + const folderParsed = parseFolderIdInput(body.folderId); + if (!folderParsed.ok) { + res.status(400).json({ error: "Invalid folderId" }); + return; + } + const folderId = folderParsed.value; + if (typeof body.isPinned !== "boolean") { + res.status(400).json({ error: "Invalid isPinned" }); + return; + } + const isPinned = body.isPinned; + if (!Array.isArray(body.orderedIds)) { + res.status(400).json({ error: "Invalid orderedIds" }); + return; + } + const orderedIds: number[] = []; + const seen = new Set(); + for (const raw of body.orderedIds) { + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0) continue; + if (seen.has(n)) continue; + seen.add(n); + orderedIds.push(n); + } + if (orderedIds.length === 0) { + res.json({ success: true, updated: 0 }); + return; + } + if (orderedIds.length > 500) { + res.status(400).json({ error: "Too many ids" }); + return; + } + // Resolve who is allowed to write into this bucket. For folder buckets, + // editors of a shared folder may reorder the folder's notes; for the + // unfiled bucket (folderId === null) only the caller's own notes are + // touchable. Either way the resolved owner gates which user_id the + // notes must belong to. + let bucketOwnerId = userId; + if (folderId !== null) { + const access = await resolveFolderEditAccess(userId, folderId); + if (!access.ok) { + res.status(403).json({ error: "Forbidden" }); + return; + } + bucketOwnerId = access.ownerId; + } + // Pull every note in the destination bucket so we can validate ids + // before writing. Anything not in this set (wrong owner, wrong folder, + // wrong pin state, archived, deleted) is dropped from the update. + // All writes happen inside one transaction so concurrent reorders never + // observe a half-applied permutation (which would otherwise leave + // duplicate sort_order values). + const updated = await db.transaction(async (tx) => { + const bucketRows = await tx + .select({ id: notesTable.id }) + .from(notesTable) + .where( + and( + eq(notesTable.userId, bucketOwnerId), + folderId === null + ? sql`${notesTable.folderId} is null` + : eq(notesTable.folderId, folderId), + eq(notesTable.isPinned, isPinned), + eq(notesTable.isArchived, false), + ), + ); + const validIds = new Set(bucketRows.map((r) => r.id)); + let count = 0; + for (let i = 0; i < orderedIds.length; i++) { + const noteId = orderedIds[i]; + if (!validIds.has(noteId)) continue; + await tx + .update(notesTable) + .set({ sortOrder: i }) + .where(eq(notesTable.id, noteId)); + count++; + } + return count; + }); + if (folderId !== null) void emitFolderChanged(folderId, userId); + res.json({ success: true, updated }); +}); + router.patch("/notes/:id", requireAuth, async (req, res): Promise => { const userId = req.session.userId!; const id = parseIdParam(req.params.id); @@ -519,6 +630,41 @@ router.patch("/notes/:id", requireAuth, async (req, res): Promise => { } } } + // When the note is being moved into a different bucket (folder change OR + // pin/unpin flip), drop it at the top of the destination so it lands + // where the user dragged it. We compute (min(sort_order) - 1) inside + // the destination bucket {ownerId, folderId, isPinned, isArchived} and + // stamp that on the row in the same UPDATE below. Without this the + // moved note would inherit its old bucket's sort_order, which makes it + // appear in an arbitrary slot in the new folder. + const folderChanged = + data.folderId !== undefined && data.folderId !== existing.folderId; + const pinChanged = + data.isPinned !== undefined && data.isPinned !== existing.isPinned; + if (folderChanged || pinChanged) { + const newFolder = + data.folderId !== undefined ? data.folderId : existing.folderId; + const newPinned = + data.isPinned !== undefined ? data.isPinned : existing.isPinned; + const newArchived = + data.isArchived !== undefined ? data.isArchived : existing.isArchived; + const [row] = await db + .select({ + minSort: sql`coalesce(min(${notesTable.sortOrder}), 0)::int`, + }) + .from(notesTable) + .where( + and( + eq(notesTable.userId, existing.userId), + newFolder === null + ? sql`${notesTable.folderId} is null` + : eq(notesTable.folderId, newFolder), + eq(notesTable.isPinned, newPinned), + eq(notesTable.isArchived, newArchived), + ), + ); + data.sortOrder = (row?.minSort ?? 0) - 1; + } // If the caller patched `items` without specifying `kind`, normalize // against the note's current kind so a text note can never end up // carrying checklist items (and vice versa). parseNoteInput handles the @@ -1822,7 +1968,11 @@ router.get( eq(notesTable.isArchived, false), ), ) - .orderBy(desc(notesTable.isPinned), desc(notesTable.updatedAt)); + .orderBy( + desc(notesTable.isPinned), + asc(notesTable.sortOrder), + desc(notesTable.updatedAt), + ); let withLabels: Array<(typeof notes)[number] & { labelIds: number[] }> = []; if (notes.length > 0) { diff --git a/artifacts/tx-os/src/components/notes/folders-rail.tsx b/artifacts/tx-os/src/components/notes/folders-rail.tsx index 2ee32f14..53340e60 100644 --- a/artifacts/tx-os/src/components/notes/folders-rail.tsx +++ b/artifacts/tx-os/src/components/notes/folders-rail.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { useDroppable } from "@dnd-kit/core"; import { Folder, FolderOpen, @@ -63,78 +64,43 @@ export function selectionMatches(a: FolderSelection, b: FolderSelection): boolea return true; } -// Module-scoped drag id. Used by both HTML5 drag (desktop) and the touch -// long-press fallback (mobile / iPad) so a single drop pipeline handles both. -let DRAGGING_NOTE_ID: number | null = null; +// Stable id namespace for @dnd-kit droppables rendered by this rail. +// Kept in sync with NotesPage's onDragEnd which routes drops based on +// `over.data.current.target` (a "unfiled" | number) rather than the id +// itself, so the same target id can appear in multiple places (rail row +// AND folders browser tile) without collision. +export const folderDropId = (target: "unfiled" | number) => + `folder-drop-rail-${target}`; -export function setDraggingNoteId(id: number | null) { - DRAGGING_NOTE_ID = id; -} - -export function getDraggingNoteId(): number | null { - return DRAGGING_NOTE_ID; -} - -// Touch fallback: NotesPage registers its drop handler so the NoteCard touch -// handlers can resolve a drop target by hit-testing the rendered rail. -let DROP_HANDLER: ((target: "unfiled" | number) => void) | null = null; - -export function registerFolderDropHandler( - fn: ((target: "unfiled" | number) => void) | null, -) { - DROP_HANDLER = fn; -} - -export function dispatchTouchDropAt(clientX: number, clientY: number): boolean { - if (typeof document === "undefined") return false; - const el = document.elementFromPoint(clientX, clientY); - if (!el) return false; - const drop = (el as HTMLElement).closest("[data-folder-drop]"); - if (!drop || !DROP_HANDLER) return false; - const target = drop.getAttribute("data-folder-drop")!; - if (target === "unfiled") DROP_HANDLER("unfiled"); - else { - const id = Number(target); - if (Number.isInteger(id)) DROP_HANDLER(id); - else return false; - } - return true; -} - -// Highlight (or clear) a folder row under the finger during a touch drag. -export function highlightTouchDropAt(clientX: number, clientY: number) { - if (typeof document === "undefined") return; - for (const node of Array.from( - document.querySelectorAll("[data-folder-drop]"), - )) { - node.setAttribute("data-drop-active", "false"); - } - const el = document.elementFromPoint(clientX, clientY); - const drop = (el as HTMLElement | null)?.closest( - "[data-folder-drop]", +// A row that accepts a NoteCard drop. Wraps its children in a useDroppable +// from @dnd-kit so the page-level DndContext picks up the drop and routes +// to the move-to-folder mutation. Kept inline so each rail row registers +// its own droppable id (one hook per row). +function DroppableRow({ + target, + className, + testId, + children, +}: { + target: "unfiled" | number; + className?: string; + testId?: string; + children: React.ReactNode; +}) { + const { setNodeRef, isOver } = useDroppable({ + id: folderDropId(target), + data: { type: "folder-drop", target }, + }); + return ( +
+ {children} +
); - if (drop) drop.setAttribute("data-drop-active", "true"); -} - -export function clearTouchDropHighlights() { - if (typeof document === "undefined") return; - for (const node of Array.from( - document.querySelectorAll("[data-folder-drop]"), - )) { - node.setAttribute("data-drop-active", "false"); - } -} - -// Expose the touch drop pipeline to window in development so e2e tests can -// drive it deterministically without trying to synthesize browser-specific -// native touch events. Gated to non-production builds. -if (typeof window !== "undefined" && import.meta.env.MODE !== "production") { - (window as unknown as Record).__notesTouchTest = { - setDraggingNoteId, - dispatchTouchDropAt, - highlightTouchDropAt, - clearTouchDropHighlights, - }; } interface Props { @@ -143,7 +109,6 @@ interface Props { onSelect: (s: FolderSelection) => void; unfiledCount: number; totalCount: number; - onDropNote: (target: "unfiled" | number) => void; // Owner action: open the folder-share dialog. The page handles mounting // so the rail stays focused on navigation/selection. onShareFolder?: (folder: NoteFolder) => void; @@ -160,7 +125,6 @@ export function FoldersRail({ onSelect, unfiledCount, totalCount, - onDropNote, onShareFolder, mode = "rail", }: Props) { @@ -173,7 +137,6 @@ export function FoldersRail({ const [creating, setCreating] = useState(false); const [editingId, setEditingId] = useState(null); const [editingName, setEditingName] = useState(""); - const [hoverTarget, setHoverTarget] = useState(null); const [pendingDelete, setPendingDelete] = useState(null); const create = useCreateFolder(); const update = useUpdateFolder(); @@ -225,7 +188,6 @@ export function FoldersRail({ count, isSelected, onClick, - onDrop, dropTargetTestId, dropTarget, action, @@ -236,62 +198,54 @@ export function FoldersRail({ count: number; isSelected: boolean; onClick: () => void; - onDrop?: () => void; dropTargetTestId: string; dropTarget?: "unfiled" | number; action?: React.ReactNode; content?: React.ReactNode; }, ) => { - const isHover = hoverTarget === key; - return ( -
{ - if (!onDrop) return; - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - if (hoverTarget !== key) setHoverTarget(key); - }} - onDragLeave={() => { - if (hoverTarget === key) setHoverTarget(null); - }} - onDrop={(e) => { - if (!onDrop) return; - e.preventDefault(); - setHoverTarget(null); - onDrop(); - }} - className={`group rounded-lg border transition shrink-0 md:shrink ${ - isSelected - ? "bg-foreground text-background border-foreground" - : "border-transparent hover:bg-slate-100" - } ${isHover ? "ring-2 ring-amber-400 ring-offset-1" : ""} data-[drop-active=true]:ring-2 data-[drop-active=true]:ring-amber-400 data-[drop-active=true]:ring-offset-1`} - > - {content ?? ( - - )} + {count} + + {action} + + ); + if (dropTarget !== undefined) { + return ( + + {inner} + + ); + } + return ( +
+ {inner}
); }; @@ -322,7 +276,6 @@ export function FoldersRail({ count: unfiledCount, isSelected: selected.kind === "unfiled", onClick: () => onSelect({ kind: "unfiled" }), - onDrop: () => onDropNote("unfiled"), dropTargetTestId: "folder-drop-unfiled", dropTarget: "unfiled", })} @@ -337,7 +290,6 @@ export function FoldersRail({ count: f.noteCount, isSelected: isSel, onClick: () => onSelect({ kind: "folder", id: f.id }), - onDrop: () => onDropNote(f.id), dropTargetTestId: `folder-drop-${f.id}`, dropTarget: f.id, content: isEditing ? ( diff --git a/artifacts/tx-os/src/lib/notes-api.ts b/artifacts/tx-os/src/lib/notes-api.ts index 89f19d5f..5f329392 100644 --- a/artifacts/tx-os/src/lib/notes-api.ts +++ b/artifacts/tx-os/src/lib/notes-api.ts @@ -567,6 +567,81 @@ export function useMoveNoteToFolder() { }); } +// Persist a manual reorder of notes within a single bucket. A "bucket" is +// the unique combination of (note owner, folderId, isPinned). The +// optimistic update reshuffles in place: every cached `notes` list keeps +// the same set of notes, but the ordered ids you pass in are stitched +// back into the array in their new order so the UI reflects the drop +// before the server roundtrip lands. Mirrors the optimistic pattern of +// `useMoveNoteToFolder` so the two drag flows (move-to-folder vs +// reorder-within-folder) share a single mental model. +export function useReorderNotes() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ + folderId, + isPinned, + orderedIds, + }: { + folderId: number | null; + isPinned: boolean; + orderedIds: number[]; + }) => + req<{ success: true; updated: number }>(`/notes/reorder`, { + method: "POST", + body: JSON.stringify({ folderId, isPinned, orderedIds }), + }), + onMutate: async ({ folderId, isPinned, orderedIds }) => { + await qc.cancelQueries({ queryKey: ["notes"] }); + const noteSnapshots: Array<[unknown, Note[] | undefined]> = []; + const orderIndex = new Map(); + orderedIds.forEach((id, i) => orderIndex.set(id, i)); + const noteQueries = qc.getQueriesData({ queryKey: ["notes"] }); + for (const [key, data] of noteQueries) { + noteSnapshots.push([key, data]); + if (!Array.isArray(data)) continue; + // Pull bucket members out, sort them by the new order, then + // stitch them back into the same slots they used to occupy in + // the source array. Non-bucket notes keep their original index + // so unrelated sections don't reflow. + const sortedBucket = data + .filter( + (n) => + n.folderId === folderId && + n.isPinned === isPinned && + orderIndex.has(n.id), + ) + .sort( + (a, b) => + (orderIndex.get(a.id) ?? 0) - (orderIndex.get(b.id) ?? 0), + ); + let bIdx = 0; + const next = data.map((n) => { + if ( + n.folderId === folderId && + n.isPinned === isPinned && + orderIndex.has(n.id) + ) { + return sortedBucket[bIdx++] ?? n; + } + return n; + }); + qc.setQueryData(key, next); + } + return { noteSnapshots }; + }, + onError: (_err, _vars, ctx) => { + if (!ctx) return; + for (const [key, data] of ctx.noteSnapshots) { + qc.setQueryData(key as readonly unknown[], data); + } + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: ["notes"] }); + }, + }); +} + // ---------- Folder sharing ---------- export const folderSharesKey = (folderId: number) => diff --git a/artifacts/tx-os/src/pages/notes.tsx b/artifacts/tx-os/src/pages/notes.tsx index f3220161..2d7e0626 100644 --- a/artifacts/tx-os/src/pages/notes.tsx +++ b/artifacts/tx-os/src/pages/notes.tsx @@ -86,6 +86,7 @@ import { useMarkNoteRead, useDeleteFolder, useMoveNoteToFolder, + useReorderNotes, useNoteFolders, useNoteLabels, useUpdateFolder, @@ -110,13 +111,24 @@ import { import { FoldersRail, type FolderSelection, - setDraggingNoteId, - getDraggingNoteId, - registerFolderDropHandler, - highlightTouchDropAt, - clearTouchDropHighlights, - dispatchTouchDropAt, } from "@/components/notes/folders-rail"; +import { + DndContext, + PointerSensor, + TouchSensor, + useSensor, + useSensors, + useDroppable, + closestCenter, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + rectSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { useAuth } from "@/contexts/AuthContext"; type TabId = "active" | "received" | "sent" | "archived"; @@ -206,8 +218,29 @@ export default function NotesPage() { const { data: folders = [] } = useNoteFolders(view === "archived"); const { data: sharedFolders = [] } = useSharedWithMeFolders(); const moveNote = useMoveNoteToFolder(); + const reorderNotes = useReorderNotes(); const showFolders = view === "active" || view === "archived"; + // SharedFolderView publishes its currently visible notes here so that + // the page-level handleDragEnd can build a reorder payload from the + // shared-folder bucket (the page's own `notes` array doesn't include + // notes belonging to folders shared by other users). + const sharedFolderBucketRef = useRef<{ + folderId: number; + notes: Note[]; + } | null>(null); + + // Sensors mirror the home.tsx pattern: a small pointer threshold so + // clicks/taps on the card body still fire onClick (open editor), and a + // 200ms long-press for touch with a generous tolerance so iPad scroll + // gestures don't accidentally start a drag. + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(TouchSensor, { + activationConstraint: { delay: 200, tolerance: 8 }, + }), + ); + // Folder the share dialog is currently open for. `null` → dialog closed. // Owned folder only — recipients never see this entry point. const [folderShareFor, setFolderShareFor] = useState(null); @@ -250,26 +283,88 @@ export default function NotesPage() { const unfiledCount = notes.filter((n) => n.folderId === null).length; - const handleDropNote = useCallback( - (target: "unfiled" | number) => { - const id = getDraggingNoteId(); - setDraggingNoteId(null); - if (id == null) return; - const note = notes.find((n) => n.id === id); + // Move a note into a target folder (or "unfiled"). Used by the + // page-level DndContext.onDragEnd below; kept as a callback so the + // FoldersBrowser button-style fallback can call it directly too. + const moveNoteToTarget = useCallback( + (target: "unfiled" | number, noteId: number) => { + const note = notes.find((n) => n.id === noteId); const nextFolderId = target === "unfiled" ? null : target; if (note && note.folderId === nextFolderId) return; - moveNote.mutate({ id, folderId: nextFolderId }); + moveNote.mutate({ id: noteId, folderId: nextFolderId }); }, [notes, moveNote], ); - // Register a drop handler so the touch-drag fallback in NoteCard can - // resolve a folder target by hit-testing the rendered rail. Required for - // iPad/touch users where HTML5 drag events do not fire. - useEffect(() => { - registerFolderDropHandler(handleDropNote); - return () => registerFolderDropHandler(null); - }, [handleDropNote]); + // Single drop pipeline for both desktop and iPad. The DndContext is + // mounted at the page root so any NoteCard (owned or in a shared folder + // the user can edit) can drop onto any rail row, folder browser tile, + // or sibling card. Two routes: + // - over.type === "folder-drop" → move to that folder. + // - over.type === "note" → reorder within the same bucket + // (same folderId AND same isPinned). + const handleDragEnd = useCallback( + (e: DragEndEvent) => { + const { active, over } = e; + if (!over) return; + const a = active.data.current as + | { + type?: string; + noteId?: number; + folderId?: number | null; + isPinned?: boolean; + } + | undefined; + const o = over.data.current as + | { + type?: string; + target?: "unfiled" | number; + noteId?: number; + folderId?: number | null; + isPinned?: boolean; + } + | undefined; + if (!a || a.type !== "note" || a.noteId == null) return; + + if (o?.type === "folder-drop" && o.target !== undefined) { + moveNoteToTarget(o.target, a.noteId); + return; + } + + if (o?.type === "note" && o.noteId != null) { + if (a.noteId === o.noteId) return; + if (a.folderId !== o.folderId) return; + if (a.isPinned !== o.isPinned) return; + // Build the bucket from the FULL underlying source for the active + // surface (owner notes vs shared-folder notes), not the filtered + // view. If we used the filtered subset, search/label filters would + // omit hidden siblings from `orderedIds` and the server would + // reassign sort_order=0..N-1 over only the visible rows, leaving + // duplicates against the hidden ones. + const sharedBucket = sharedFolderBucketRef.current; + const sourceNotes = + sharedBucket && sharedBucket.folderId === a.folderId + ? sharedBucket.notes + : notes; + const bucket = sourceNotes.filter( + (n) => + n.folderId === a.folderId && + n.isPinned === a.isPinned && + !n.isArchived, + ); + const oldIdx = bucket.findIndex((n) => n.id === a.noteId); + const newIdx = bucket.findIndex((n) => n.id === o.noteId); + if (oldIdx < 0 || newIdx < 0 || oldIdx === newIdx) return; + const reordered = arrayMove(bucket, oldIdx, newIdx); + reorderNotes.mutate({ + folderId: a.folderId ?? null, + isPinned: a.isPinned ?? false, + orderedIds: reordered.map((n) => n.id), + }); + } + }, + [notes, moveNoteToTarget, reorderNotes], + ); const filteredReceived = useMemo(() => { const q = search.trim().toLowerCase(); @@ -310,6 +405,11 @@ export default function NotesPage() { // pinned while the document scrolls; `100dvh` (dynamic viewport // height) sizes against the *visible* area, so the layout reflows // cleanly when the URL bar appears/disappears. +
)} -
+
{showFolders && ( setFolderShareFor(f)} mode={view === "active" && folderSelection.kind === "all" ? "chips-only" : "rail"} /> @@ -518,6 +617,7 @@ export default function NotesPage() { onBack={() => setFolderSelection({ kind: "all" })} onEdit={setEditing} onSend={(n) => setSendForNoteId(n.id)} + bucketRef={sharedFolderBucketRef} /> )} {view === "active" && folderSelection.kind !== "shared-folder" && ( @@ -527,7 +627,6 @@ export default function NotesPage() { folders={folders} viewMode={viewMode} onSelect={setFolderSelection} - onDropNote={handleDropNote} /> )} {folderSelection.kind !== "all" && ( @@ -657,6 +756,7 @@ export default function NotesPage() { /> )}
+ ); } @@ -725,14 +825,23 @@ function Section({ {title} )} -
`note-${n.id}`)} + strategy={rectSortingStrategy} > - {notes.map((n) => ( - - ))} -
+
+ {notes.map((n) => ( + + ))} +
+
); } @@ -759,99 +868,40 @@ function NoteCard({ const noteLabels = labels.filter((l) => note.labelIds.includes(l.id)); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); - // Touch fallback for mobile / iPad: a long-press starts a drag, touchmove - // hit-tests folder drop targets, touchend resolves the drop. - const touchStateRef = useRef<{ - longPressTimer: number | null; - dragging: boolean; - suppressClick: boolean; - startX: number; - startY: number; - }>({ longPressTimer: null, dragging: false, suppressClick: false, startX: 0, startY: 0 }); - // Pixels of finger travel allowed before we treat the touch as a scroll - // (and cancel the pending long-press) instead of an emerging drag. - const TOUCH_SLOP_PX = 10; - - const cancelTouchTimer = () => { - const s = touchStateRef.current; - if (s.longPressTimer !== null) { - window.clearTimeout(s.longPressTimer); - s.longPressTimer = null; - } + // @dnd-kit sortable. Disabled for archived notes (the server reorder + // endpoint scopes to non-archived) and for the trivial case where the + // card is rendered outside any DndContext (we still call the hook to + // keep hook order stable; isDragging is just always false then). + const sortable = useSortable({ + id: `note-${note.id}`, + data: { + type: "note", + noteId: note.id, + folderId: note.folderId, + isPinned: note.isPinned, + }, + disabled: note.isArchived, + }); + const dragStyle: React.CSSProperties = { + transform: CSS.Transform.toString(sortable.transform), + transition: sortable.transition, + opacity: sortable.isDragging ? 0.4 : undefined, }; return ( <>
{ - setDraggingNoteId(note.id); - e.dataTransfer.effectAllowed = "move"; - e.dataTransfer.setData("text/plain", `note:${note.id}`); - }} - onDragEnd={() => setDraggingNoteId(null)} - onTouchStart={(e) => { - const touch = e.touches[0]; - if (!touch) return; - const s = touchStateRef.current; - cancelTouchTimer(); - s.dragging = false; - s.suppressClick = false; - s.startX = touch.clientX; - s.startY = touch.clientY; - s.longPressTimer = window.setTimeout(() => { - s.dragging = true; - s.suppressClick = true; - setDraggingNoteId(note.id); - highlightTouchDropAt(s.startX, s.startY); - }, 350); - }} - onTouchMove={(e) => { - const s = touchStateRef.current; - const touch = e.touches[0]; - if (!touch) return; - if (!s.dragging) { - // Only cancel the pending long-press once the finger has moved - // beyond the slop threshold — small jitter while the user is - // pressing should not be treated as a scroll. - const dx = touch.clientX - s.startX; - const dy = touch.clientY - s.startY; - if (dx * dx + dy * dy > TOUCH_SLOP_PX * TOUCH_SLOP_PX) { - cancelTouchTimer(); - } - return; - } - e.preventDefault(); - highlightTouchDropAt(touch.clientX, touch.clientY); - }} - onTouchEnd={(e) => { - const s = touchStateRef.current; - cancelTouchTimer(); - if (!s.dragging) return; - const touch = e.changedTouches[0]; - s.dragging = false; - clearTouchDropHighlights(); - if (touch) dispatchTouchDropAt(touch.clientX, touch.clientY); - setDraggingNoteId(null); - }} - onTouchCancel={() => { - cancelTouchTimer(); - touchStateRef.current.dragging = false; - clearTouchDropHighlights(); - setDraggingNoteId(null); - }} + data-dragging={sortable.isDragging ? "true" : "false"} className={`break-inside-avoid mb-3 rounded-xl border border-black/5 shadow-sm p-3 cursor-pointer transition hover:shadow-md group touch-manipulation ${colorBg( note.color, )}`} - onClick={() => { - if (touchStateRef.current.suppressClick) { - touchStateRef.current.suppressClick = false; - return; - } - onEdit(note); - }} + onClick={() => onEdit(note)} >
@@ -1015,12 +1065,10 @@ function FoldersBrowser({ folders, viewMode, onSelect, - onDropNote, }: { folders: NoteFolder[]; viewMode: ViewMode; onSelect: (s: FolderSelection) => void; - onDropNote: (target: "unfiled" | number) => void; }) { const { t } = useTranslation(); const del = useDeleteFolder(); @@ -1123,7 +1171,6 @@ function FoldersBrowser({ isSelected={selectedIds.has(f.id)} onToggleSelect={() => toggleSelect(f.id)} onSelect={() => onSelect({ kind: "folder", id: f.id })} - onDropNote={() => onDropNote(f.id)} /> ))}
@@ -1140,7 +1187,6 @@ function FoldersBrowser({ isSelected={selectedIds.has(f.id)} onToggleSelect={() => toggleSelect(f.id)} onSelect={() => onSelect({ kind: "folder", id: f.id })} - onDropNote={() => onDropNote(f.id)} /> ))}
@@ -1189,14 +1235,12 @@ function FolderItem({ isSelected, onToggleSelect, onSelect, - onDropNote, }: { folder: NoteFolder; viewMode: ViewMode; isSelected: boolean; onToggleSelect: () => void; onSelect: () => void; - onDropNote: () => void; }) { const { t } = useTranslation(); const update = useUpdateFolder(); @@ -1204,8 +1248,14 @@ function FolderItem({ const [editing, setEditing] = useState(false); const [editingName, setEditingName] = useState(folder.name); const [confirmOpen, setConfirmOpen] = useState(false); - const [hover, setHover] = useState(false); const relTime = useRelativeTime(folder.updatedAt || folder.createdAt); + // Each browser tile is its own droppable. The id namespace (`-browser-`) + // is distinct from the rail (`folder-drop-rail-…`) so dnd-kit can + // resolve overlap correctly when the rail and browser are both visible. + const { setNodeRef: setDropRef, isOver } = useDroppable({ + id: `folder-drop-browser-${folder.id}`, + data: { type: "folder-drop", target: folder.id }, + }); const submitRename = () => { const name = editingName.trim(); @@ -1223,20 +1273,6 @@ function FolderItem({ ); }; - const dropHandlers = { - onDragOver: (e: React.DragEvent) => { - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - if (!hover) setHover(true); - }, - onDragLeave: () => setHover(false), - onDrop: (e: React.DragEvent) => { - e.preventDefault(); - setHover(false); - onDropNote(); - }, - }; - const kebab = ( @@ -1380,10 +1416,9 @@ function FolderItem({ return ( <>
void; onEdit: (n: Note) => void; onSend: (n: Note) => void; + bucketRef: React.MutableRefObject<{ folderId: number; notes: Note[] } | null>; }) { const { t, i18n } = useTranslation(); const lang = i18n.language; @@ -3035,6 +3071,24 @@ function SharedFolderView({ // Recipients use the same top-bar search input as their personal notes; // filter the read-only list client-side over title + content + checklist // item text so the experience matches the owner's view. + // Publish the FULL (unfiltered) shared-folder bucket to the page-level + // ref so handleDragEnd can build a correct reorder payload even when a + // search filter is active. We only publish when this viewer can edit + // (otherwise reorder is forbidden anyway). Cleared on unmount and when + // the folder switches. + useEffect(() => { + if (!data || data.myPermission !== "edit") { + bucketRef.current = null; + return; + } + bucketRef.current = { folderId, notes: data.notes }; + return () => { + if (bucketRef.current?.folderId === folderId) { + bucketRef.current = null; + } + }; + }, [data, folderId, bucketRef]); + const visibleNotes = useMemo(() => { if (!data) return []; const q = search.trim().toLowerCase(); @@ -3136,22 +3190,27 @@ function SharedFolderView({ // call PATCH /notes/:id which the server now allows for editors; // the server emits `note-folder-shared` on success and the socket // hook invalidates the shared-folder query so all viewers refresh. -
`note-${n.id}`)} + strategy={rectSortingStrategy} > - {visibleNotes.map((n) => ( - - ))} -
+
+ {visibleNotes.map((n) => ( + + ))} +
+ ) : (
{ - const src = document.querySelector(srcSel); - const dst = document.querySelector(dstSel); - if (!src || !dst) throw new Error(`drag elements missing: ${srcSel} / ${dstSel}`); - const dt = new DataTransfer(); - src.dispatchEvent( - new DragEvent("dragstart", { bubbles: true, cancelable: true, dataTransfer: dt }), - ); - dst.dispatchEvent( - new DragEvent("dragenter", { bubbles: true, cancelable: true, dataTransfer: dt }), - ); - dst.dispatchEvent( - new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer: dt }), - ); - dst.dispatchEvent( - new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: dt }), - ); - src.dispatchEvent( - new DragEvent("dragend", { bubbles: true, cancelable: true, dataTransfer: dt }), - ); - }, - { - srcSel: `[data-testid="note-card-${noteId}"]`, - dstSel: `[data-testid="${dropTargetTestId}"]`, - }, - ); + const src = page.getByTestId(`note-card-${noteId}`).first(); + const dst = page.getByTestId(dropTargetTestId).first(); + // Scroll the destination first; source typically remains in view (notes + // grid sits beside or below the folder area). Then measure BOTH boxes + // after scrolling, otherwise the source coordinates can be stale. + await dst.scrollIntoViewIfNeeded(); + await src.scrollIntoViewIfNeeded(); + await dst.scrollIntoViewIfNeeded(); + const sBox = await src.boundingBox(); + const dBox = await dst.boundingBox(); + if (!sBox) throw new Error(`source note-card-${noteId} has no bounding box`); + if (!dBox) throw new Error(`target ${dropTargetTestId} has no bounding box`); + const sx = sBox.x + sBox.width / 2; + const sy = sBox.y + sBox.height / 2; + const dx = dBox.x + dBox.width / 2; + const dy = dBox.y + dBox.height / 2; + await page.mouse.move(sx, sy); + await page.mouse.down(); + // First nudge — must exceed PointerSensor's 8px activation distance. + await page.mouse.move(sx + 12, sy + 12, { steps: 5 }); + // Then walk to the destination so dnd-kit tracks the over-target. + await page.mouse.move(dx, dy, { steps: 10 }); + // Hold briefly so dnd-kit's collision detection settles before drop. + await page.waitForTimeout(50); + await page.mouse.up(); } async function createNoteViaApi(page, title) { @@ -352,10 +352,14 @@ test("create folder, drag note in, filter, move between folders, rename, delete" test("touch long-press drags a note into a folder on small viewport", async ({ browser, }) => { + // Narrow viewport with touch capability. We deliberately leave `isMobile` + // false so Playwright's `page.mouse` API still fires pointer events that + // @dnd-kit's PointerSensor can pick up — the same code path an iPad would + // hit (PointerSensor handles touch-derived pointer events identically to + // mouse-derived ones; the TouchSensor is just a long-press fallback). const context = await browser.newContext({ viewport: { width: 414, height: 800 }, hasTouch: true, - isMobile: true, }); const page = await context.newPage(); const user = await createUser("notes_folders_touch", "Touch Tester"); @@ -392,31 +396,7 @@ test("touch long-press drags a note into a folder on small viewport", async ({ r.status() >= 200 && r.status() < 300, ); - // Drive the touch drop pipeline deterministically through the dev-only - // window helper. This exercises the same code path the React onTouchEnd - // handler invokes (setDraggingNoteId + dispatchTouchDropAt against the - // hit-tested folder-drop element), without depending on browser-specific - // synthesis of TouchEvent objects across mobile contexts. - const result = await page.evaluate( - ({ noteId, folderId }) => { - const w = window.__notesTouchTest; - if (!w) return { ok: false, reason: "no helper" }; - const target = document.querySelector(`[data-folder-drop="${folderId}"]`); - if (!target) return { ok: false, reason: "no target in DOM" }; - target.scrollIntoView({ block: "center", inline: "center" }); - const r = target.getBoundingClientRect(); - const x = r.left + r.width / 2; - const y = r.top + r.height / 2; - w.setDraggingNoteId(noteId); - const ok = w.dispatchTouchDropAt(x, y); - w.clearTouchDropHighlights(); - return { ok, x, y }; - }, - { noteId: note.id, folderId: folder.id }, - ); - if (!result.ok) { - throw new Error(`touch dispatch failed: ${JSON.stringify(result)}`); - } + await dragNoteToFolderTarget(page, note.id, `folder-drop-${folder.id}`); await movePromise; await expect( diff --git a/lib/db/src/schema/notes.ts b/lib/db/src/schema/notes.ts index f4852749..c2eaa893 100644 --- a/lib/db/src/schema/notes.ts +++ b/lib/db/src/schema/notes.ts @@ -28,6 +28,14 @@ export const notesTable = pgTable("notes", { items: jsonb("items").$type(), isPinned: boolean("is_pinned").notNull().default(false), isArchived: boolean("is_archived").notNull().default(false), + // Per-bucket manual sort order. A "bucket" is the unique combination of + // (userId, folderId, isPinned). Smaller numbers sort first; ties fall back + // to updatedAt DESC so legacy notes (all default 0) keep their existing + // recency-based order until the user manually reorders them. Updated by + // POST /notes/reorder (drag-to-reorder) and on folder-change via PATCH + // /notes/:id (the moved note is placed at the top of the destination + // bucket so it lands where the user can see it immediately). + sortOrder: integer("sort_order").notNull().default(0), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()), });