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.
This commit is contained in:
riyadhafraa
2026-05-10 13:10:01 +00:00
parent 17c7b1bdaa
commit daa4f6c038
6 changed files with 562 additions and 338 deletions
+153 -3
View File
@@ -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<void> => {
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<void> => {
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<number>();
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<void> => {
const userId = req.session.userId!;
const id = parseIdParam(req.params.id);
@@ -519,6 +630,41 @@ router.patch("/notes/:id", requireAuth, async (req, res): Promise<void> => {
}
}
}
// 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<number>`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) {
@@ -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<HTMLElement>("[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<HTMLElement>("[data-folder-drop]"),
)) {
node.setAttribute("data-drop-active", "false");
}
const el = document.elementFromPoint(clientX, clientY);
const drop = (el as HTMLElement | null)?.closest<HTMLElement>(
"[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 (
<div
ref={setNodeRef}
data-testid={testId}
data-drop-active={isOver ? "true" : "false"}
className={className}
>
{children}
</div>
);
if (drop) drop.setAttribute("data-drop-active", "true");
}
export function clearTouchDropHighlights() {
if (typeof document === "undefined") return;
for (const node of Array.from(
document.querySelectorAll<HTMLElement>("[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<string, unknown>).__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
// <FolderShareDialog> 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<number | null>(null);
const [editingName, setEditingName] = useState("");
const [hoverTarget, setHoverTarget] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<NoteFolder | null>(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 (
<div
key={key}
data-testid={dropTargetTestId}
data-folder-drop={dropTarget !== undefined ? String(dropTarget) : undefined}
data-drop-active={isHover ? "true" : "false"}
onDragOver={(e) => {
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 ?? (
<button
type="button"
onClick={onClick}
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-sm text-start whitespace-nowrap"
const className = `group rounded-lg border transition shrink-0 md:shrink ${
isSelected
? "bg-foreground text-background border-foreground"
: "border-transparent hover:bg-slate-100"
} data-[drop-active=true]:ring-2 data-[drop-active=true]:ring-amber-400 data-[drop-active=true]:ring-offset-1`;
const inner =
content ?? (
<button
type="button"
onClick={onClick}
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-sm text-start whitespace-nowrap"
>
<span className="shrink-0">{icon}</span>
<span className="flex-1 truncate">{label}</span>
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full ${
isSelected
? "bg-background/20 text-background"
: "bg-slate-200/80 text-muted-foreground"
}`}
data-testid={`${dropTargetTestId}-count`}
>
<span className="shrink-0">{icon}</span>
<span className="flex-1 truncate">{label}</span>
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full ${
isSelected
? "bg-background/20 text-background"
: "bg-slate-200/80 text-muted-foreground"
}`}
data-testid={`${dropTargetTestId}-count`}
>
{count}
</span>
{action}
</button>
)}
{count}
</span>
{action}
</button>
);
if (dropTarget !== undefined) {
return (
<DroppableRow
key={key}
target={dropTarget}
className={className}
testId={dropTargetTestId}
>
{inner}
</DroppableRow>
);
}
return (
<div key={key} data-testid={dropTargetTestId} className={className}>
{inner}
</div>
);
};
@@ -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 ? (
+75
View File
@@ -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<number, number>();
orderedIds.forEach((id, i) => orderIndex.set(id, i));
const noteQueries = qc.getQueriesData<Note[]>({ 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<Note[]>(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) =>
+214 -155
View File
@@ -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<NoteFolder | null>(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.
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<div
className="min-h-[100dvh] os-bg flex flex-col"
dir={isRtl ? "rtl" : "ltr"}
@@ -492,7 +592,7 @@ export default function NotesPage() {
</div>
)}
<div className="flex-1 px-4 py-6 max-w-6xl w-full mx-auto flex gap-6">
<div className="flex-1 px-4 py-6 max-w-6xl w-full mx-auto flex flex-col md:flex-row gap-6">
{showFolders && (
<FoldersRail
folders={folders}
@@ -500,7 +600,6 @@ export default function NotesPage() {
onSelect={setFolderSelection}
unfiledCount={unfiledCount}
totalCount={notes.length}
onDropNote={handleDropNote}
onShareFolder={(f) => 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() {
/>
)}
</div>
</DndContext>
);
}
@@ -725,14 +825,23 @@ function Section({
{title}
</h2>
)}
<div
className="gap-3 [column-fill:_balance]"
style={{ columnWidth: 230 }}
{/* SortableContext registers the bucket order with @dnd-kit. The
page-level handleDragEnd uses (folderId, isPinned) — not the
context items list — to compute the new order, so the context's
job here is just to make each card a sortable participant. */}
<SortableContext
items={notes.map((n) => `note-${n.id}`)}
strategy={rectSortingStrategy}
>
{notes.map((n) => (
<NoteCard key={n.id} note={n} labels={labels} onEdit={onEdit} onSend={onSend} />
))}
</div>
<div
className="gap-3 [column-fill:_balance]"
style={{ columnWidth: 230 }}
>
{notes.map((n) => (
<NoteCard key={n.id} note={n} labels={labels} onEdit={onEdit} onSend={onSend} />
))}
</div>
</SortableContext>
</div>
);
}
@@ -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 (
<>
<div
ref={sortable.setNodeRef}
style={dragStyle}
{...sortable.attributes}
{...sortable.listeners}
data-testid={`note-card-${note.id}`}
data-folder-id={note.folderId ?? ""}
draggable
onDragStart={(e) => {
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)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
@@ -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)}
/>
))}
</div>
@@ -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)}
/>
))}
</div>
@@ -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 = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -1380,10 +1416,9 @@ function FolderItem({
return (
<>
<div
{...dropHandlers}
ref={setDropRef}
data-testid={`folder-drop-${folder.id}`}
data-folder-drop={String(folder.id)}
data-drop-active={hover ? "true" : "false"}
data-drop-active={isOver ? "true" : "false"}
data-selected={isSelected ? "true" : "false"}
className="group flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-slate-50/80 transition data-[drop-active=true]:ring-2 data-[drop-active=true]:ring-amber-400 data-[drop-active=true]:ring-inset data-[selected=true]:bg-slate-100"
onClick={editing ? undefined : onSelect}
@@ -1432,10 +1467,9 @@ function FolderItem({
return (
<>
<div
{...dropHandlers}
ref={setDropRef}
data-testid={`folder-drop-${folder.id}`}
data-folder-drop={String(folder.id)}
data-drop-active={hover ? "true" : "false"}
data-drop-active={isOver ? "true" : "false"}
data-selected={isSelected ? "true" : "false"}
onClick={editing ? undefined : onSelect}
className="group relative rounded-xl border border-slate-200/70 bg-white/70 hover:shadow-md hover:border-slate-300 p-3 cursor-pointer transition data-[drop-active=true]:ring-2 data-[drop-active=true]:ring-amber-400 data-[selected=true]:ring-2 data-[selected=true]:ring-slate-900 data-[selected=true]:bg-slate-50"
@@ -3020,6 +3054,7 @@ function SharedFolderView({
onBack,
onEdit,
onSend,
bucketRef,
}: {
folderId: number;
labels: NoteLabel[];
@@ -3027,6 +3062,7 @@ function SharedFolderView({
onBack: () => 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.
<div
className="gap-3 mt-2 [column-fill:_balance]"
style={{ columnWidth: 230 }}
data-testid="shared-folder-notes"
<SortableContext
items={visibleNotes.map((n) => `note-${n.id}`)}
strategy={rectSortingStrategy}
>
{visibleNotes.map((n) => (
<NoteCard
key={n.id}
note={n}
labels={labels}
onEdit={onEdit}
onSend={onSend}
hideSend
/>
))}
</div>
<div
className="gap-3 mt-2 [column-fill:_balance]"
style={{ columnWidth: 230 }}
data-testid="shared-folder-notes"
>
{visibleNotes.map((n) => (
<NoteCard
key={n.id}
note={n}
labels={labels}
onEdit={onEdit}
onSend={onSend}
hideSend
/>
))}
</div>
</SortableContext>
) : (
<div
className="gap-3 mt-2 [column-fill:_balance]"
+35 -55
View File
@@ -75,36 +75,36 @@ async function loginViaUi(page, username) {
]);
}
// Synthesize HTML5 drag-and-drop with a shared DataTransfer so React's drag
// handlers fire reliably regardless of pointer-based heuristics.
// Drive a real pointer drag so @dnd-kit's PointerSensor activates. The
// sensor's activation constraint is `distance: 8`, so we first nudge the
// pointer past the threshold from the source, then move to the target in
// steps (so over-targets are detected on the way), then release.
async function dragNoteToFolderTarget(page, noteId, dropTargetTestId) {
await page.evaluate(
({ srcSel, dstSel }) => {
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(
+8
View File
@@ -28,6 +28,14 @@ export const notesTable = pgTable("notes", {
items: jsonb("items").$type<ChecklistItem[]>(),
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()),
});