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:
@@ -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 ? (
|
||||
|
||||
@@ -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
@@ -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]"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user