A chapter is a list too, and deleting stops moving things
Deleting a note moved everything else. A row leaving is the same problem as a row arriving, in reverse: a line's position counts the rows above it, so a note deleted above a line left the line counting rows that were no longer there, and everything under it appeared to jump up past it. withoutRow renumbers the list a row leaves, and every place one leaves says so -- the bin, a move, a chapter being removed. Arrivals land at the top: an old position belongs to an arrangement that has moved on. A line could not be dragged under another line. Two lines in the same gap carry the same position, and the comparator claimed "a separator comes first" without asking what the other one was -- both directions at once for that case. A comparator that contradicts itself has no defined result, so the sort returned them in whichever order it happened to walk. A chapter is always in the user's own order and can be arranged like any other list: its notes wear handles under the same switch as the list beside them and are dragged among themselves, strips at both ends included. The two lists keep apart by scope -- a chapter unfolded under its row holds rows inside a row of the notebook's list, so the nearest row above the pointer cannot say which list a drag belongs to. The sort menu is gone inside a chapter: the mode belongs to the notebook, and a menu there would offer a choice that cannot be honoured. The chapters suite no longer assumes the first chapter row is the one that matters. A chapter is a row in the arrangement and can be anywhere in it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
312c4e190e
commit
f01522aa6f
10 files changed
+528
-85
No files matched your search
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import * as sync from "../syncEngine";
|
||||
import * as searchIndex from "../search";
|
||||
import { sameStrokeDoc } from "../features/drawing/strokes.js";
|
||||
import { placeOnTop, reindexEntries } from "../features/notes/noteSort";
|
||||
import { placeOnTop, reindexEntries, sortNotes, withoutRow } from "../features/notes/noteSort";
|
||||
import type { Arrangement } from "../features/notes/noteSort";
|
||||
import { extractOnImport } from "../features/pdf/pdfText";
|
||||
import type {
|
||||
@@ -248,16 +248,6 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
||||
setNotes((p) => p.map((n) => (n.id === id ? { ...n, starred } : n)));
|
||||
}, []);
|
||||
|
||||
const trashNote = useCallback(async (id: string) => {
|
||||
await sync.trashNote(id);
|
||||
setNotes((p) => p.map((n) => (n.id === id ? { ...n, trashed: true } : n)));
|
||||
}, []);
|
||||
|
||||
const restoreNote = useCallback(async (id: string) => {
|
||||
await sync.restoreNoteLocal(id);
|
||||
setNotes((p) => p.map((n) => (n.id === id ? { ...n, trashed: false } : n)));
|
||||
}, []);
|
||||
|
||||
const permanentDeleteNote = useCallback(async (id: string) => {
|
||||
await sync.permanentDeleteNoteLocal(id);
|
||||
searchIndex.removeEntry(id);
|
||||
@@ -270,11 +260,6 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
||||
setNotes((p) => p.filter((n) => !n.trashed));
|
||||
}, []);
|
||||
|
||||
const moveNote = useCallback(async (id: string, folderId: string, chapterId: string | null = null) => {
|
||||
await sync.moveNote(id, folderId, chapterId);
|
||||
setNotes((p) => p.map((n) => (n.id === id ? { ...n, folderId, chapterId } : n)));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Everything one list is made of: its notes, its lines, its chapters.
|
||||
*
|
||||
@@ -392,6 +377,62 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
||||
[applyArrangement, listOf]
|
||||
);
|
||||
|
||||
/**
|
||||
* Takes a row out of the list it is drawn in, and renumbers what is left.
|
||||
*
|
||||
* A row leaving is the same problem as a row arriving, in reverse. A line's
|
||||
* position counts the rows above it, so deleting a note above a line left the
|
||||
* line one row too low — and everything under it appeared to jump up past it.
|
||||
* Called *before* the note is taken out of the store, because the list as it
|
||||
* is drawn is what the lines were counted against.
|
||||
*/
|
||||
const takeOut = useCallback(
|
||||
(note: NoteMeta | undefined) => {
|
||||
if (!note?.folderId || note.trashed) return;
|
||||
const chapterId = note.chapterId ?? null;
|
||||
const list = listOf(note.folderId, chapterId);
|
||||
if (!list) return;
|
||||
const next = withoutRow(list.notes, list.mine, list.chapters, note.id);
|
||||
if (!next) return;
|
||||
applyArrangement(note.folderId, chapterId, list.all, list.others, next);
|
||||
},
|
||||
[applyArrangement, listOf]
|
||||
);
|
||||
|
||||
const trashNote = useCallback(
|
||||
async (id: string) => {
|
||||
takeOut(notesRef.current.find((n) => n.id === id));
|
||||
await sync.trashNote(id);
|
||||
setNotes((p) => p.map((n) => (n.id === id ? { ...n, trashed: true } : n)));
|
||||
},
|
||||
[takeOut]
|
||||
);
|
||||
|
||||
/* Back from the bin and back to the top, like anything else arriving in a
|
||||
list: its old position belongs to an arrangement that has moved on. */
|
||||
const restoreNote = useCallback(
|
||||
async (id: string) => {
|
||||
await sync.restoreNoteLocal(id);
|
||||
setNotes((p) => p.map((n) => (n.id === id ? { ...n, trashed: false } : n)));
|
||||
const note = notesRef.current.find((n) => n.id === id);
|
||||
if (note) putOnTop({ ...note, trashed: false });
|
||||
},
|
||||
[putOnTop]
|
||||
);
|
||||
|
||||
/* Out of one list and in at the top of the other. Both ends need saying: the
|
||||
list it leaves has to close the gap, or its lines slip. */
|
||||
const moveNote = useCallback(
|
||||
async (id: string, folderId: string, chapterId: string | null = null) => {
|
||||
const before = notesRef.current.find((n) => n.id === id);
|
||||
takeOut(before);
|
||||
await sync.moveNote(id, folderId, chapterId);
|
||||
setNotes((p) => p.map((n) => (n.id === id ? { ...n, folderId, chapterId } : n)));
|
||||
if (before) putOnTop({ ...before, folderId, chapterId });
|
||||
},
|
||||
[putOnTop, takeOut]
|
||||
);
|
||||
|
||||
const createNote = useCallback(
|
||||
async (folderId: string, chapterId: string | null = null) => {
|
||||
const note = await sync.createNote(folderId, chapterId);
|
||||
@@ -456,13 +497,40 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
||||
await sync.updateChapter(id, updates);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Removing a chapter: the row goes, the notes come back.
|
||||
*
|
||||
* Both halves have to be said in the notebook's arrangement, or the lines
|
||||
* around them slip. The chapter's row is taken out of the count, and its notes
|
||||
* arrive at the top in the order they had inside it — their old positions
|
||||
* were numbered against the chapter's list, which no longer exists.
|
||||
*/
|
||||
const deleteChapter = useCallback(
|
||||
async (id: string) => {
|
||||
const chapter = chaptersRef.current.find((c) => c.id === id);
|
||||
const held = notesRef.current.filter((n) => !n.trashed && n.chapterId === id);
|
||||
await sync.deleteChapter(id);
|
||||
setChapters((p) => p.filter((c) => c.id !== id));
|
||||
setNotes((p) => p.map((n) => (n.chapterId === id ? { ...n, chapterId: null } : n)));
|
||||
|
||||
const notebookId = chapter?.notebookId;
|
||||
if (!notebookId) return;
|
||||
const list = listOf(notebookId, null);
|
||||
if (!list) return;
|
||||
const freed = sortNotes(held, "manual").map((n) => ({ ...n, chapterId: null }));
|
||||
const next = freed.length
|
||||
? placeOnTop(
|
||||
[...freed, ...list.notes],
|
||||
list.mine,
|
||||
// Without the chapter itself: its row leaves the count as well.
|
||||
list.chapters.filter((c) => c.id !== id),
|
||||
freed.map((n) => n.id)
|
||||
)
|
||||
: // An empty one only leaves a gap to close.
|
||||
withoutRow(list.notes, list.mine, list.chapters, id);
|
||||
if (next) applyArrangement(notebookId, null, list.all, list.others, next);
|
||||
},
|
||||
[]
|
||||
[applyArrangement, listOf]
|
||||
);
|
||||
|
||||
const createNotebook = useCallback(async (fields: { name: string; cover: number }) => {
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface ReorderDrag {
|
||||
into?: boolean
|
||||
) => {
|
||||
"data-reorder-id": string;
|
||||
"data-reorder-scope": string;
|
||||
"data-reorder-into"?: string;
|
||||
onPointerDown: (e: React.PointerEvent) => void;
|
||||
};
|
||||
@@ -45,10 +46,19 @@ export interface ReorderDrag {
|
||||
gripProps: { "data-reorder-grip": string };
|
||||
}
|
||||
|
||||
/**
|
||||
* `scope` names the list these rows belong to, and is what keeps two lists on
|
||||
* the same screen out of each other's way: a chapter unfolded under its row
|
||||
* holds rows *inside* a row of the notebook's list, so "the nearest row above
|
||||
* the pointer" is not enough to say which list is being dragged in. Rows are
|
||||
* looked up by scope, and a note carried in the notebook simply finds no target
|
||||
* over the notes inside a chapter.
|
||||
*/
|
||||
export function useReorderDrag(
|
||||
enabled: boolean,
|
||||
reorder: (dragId: string, targetId: string) => void,
|
||||
fileInto?: (dragId: string, targetId: string) => void
|
||||
fileInto?: (dragId: string, targetId: string) => void,
|
||||
scope = ""
|
||||
): ReorderDrag {
|
||||
const [overId, setOverId] = useState<string | null>(null);
|
||||
const [intoId, setIntoId] = useState<string | null>(null);
|
||||
@@ -73,6 +83,12 @@ export function useReorderDrag(
|
||||
const onPointerDown = useCallback(
|
||||
(id: string) => (e: React.PointerEvent) => {
|
||||
if (!enabled || e.button > 0) return;
|
||||
/* Only presses that belong to this list. A chapter's rows sit inside a row
|
||||
of the notebook's list, so its events travel up through one — and a grip
|
||||
pressed in the chapter would otherwise pick up the chapter's whole row
|
||||
as well. The nearest scoped row decides whose press it is. */
|
||||
const own = (e.target as HTMLElement)?.closest?.("[data-reorder-scope]") as HTMLElement | null;
|
||||
if (own && own.dataset.reorderScope !== scope) return;
|
||||
const fromGrip = !!(e.target as HTMLElement)?.closest?.("[data-reorder-grip]");
|
||||
// A finger on the row itself is a scroll or a long press, not a drag.
|
||||
if (e.pointerType !== "mouse" && !fromGrip) return;
|
||||
@@ -84,7 +100,7 @@ export function useReorderDrag(
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
[enabled]
|
||||
[enabled, scope]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -103,7 +119,7 @@ export function useReorderDrag(
|
||||
// tracked with enter/leave handlers, because a finger generates no
|
||||
// pointerover on anything it did not press.
|
||||
const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
|
||||
const row = el?.closest?.("[data-reorder-id]") as HTMLElement | null;
|
||||
const row = el?.closest?.(`[data-reorder-scope="${scope}"]`) as HTMLElement | null;
|
||||
const target = row?.dataset.reorderId ?? null;
|
||||
const next = target && target !== d.id ? target : null;
|
||||
/* A chapter is both a place and a neighbour, so which one it is depends on
|
||||
@@ -156,7 +172,7 @@ export function useReorderDrag(
|
||||
window.removeEventListener("pointerup", up);
|
||||
window.removeEventListener("pointercancel", stop);
|
||||
};
|
||||
}, [enabled, reorder, fileInto, stop]);
|
||||
}, [enabled, reorder, fileInto, scope, stop]);
|
||||
|
||||
// A drag that outlives its list would leave the page unselectable.
|
||||
useEffect(
|
||||
@@ -172,6 +188,7 @@ export function useReorderDrag(
|
||||
draggingId,
|
||||
rowProps: (id: string, into = false) => ({
|
||||
"data-reorder-id": id,
|
||||
"data-reorder-scope": scope,
|
||||
...(into ? { "data-reorder-into": "" } : {}),
|
||||
onPointerDown: onPointerDown(id),
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user