diff --git a/backend/server.js b/backend/server.js index 803eb19..01828b7 100644 --- a/backend/server.js +++ b/backend/server.js @@ -347,6 +347,15 @@ if (!tableColumns("notebooks").includes("sort_mode")) { db.exec("ALTER TABLE notebooks ADD COLUMN sort_mode TEXT"); } +/* Lines the user drew between notes in the manual order, as JSON. + * + * A column rather than a table: they are a handful of labels that only mean + * anything as an ordered list, they are always read and written whole with the + * notebook, and nothing ever queries across them. */ +if (!tableColumns("notebooks").includes("separators")) { + db.exec("ALTER TABLE notebooks ADD COLUMN separators TEXT"); +} + if (!tableColumns("notes").includes("pdf_attachment_id")) { db.exec("ALTER TABLE notes ADD COLUMN pdf_attachment_id TEXT"); } @@ -780,8 +789,26 @@ app.post("/api/notebooks", authMiddleware, (req, res) => { /** The orders the note list offers. Anything else is refused rather than stored. */ const SORT_MODES = ["manual", "updated", "oldest", "created", "title"]; +/* An upper bound on the arrangement, so a malformed client cannot store an + * unbounded blob on a notebook. */ +const MAX_SEPARATORS = 200; +const MAX_SEPARATOR_LABEL = 80; + +/** Keeps only what a separator is allowed to be. */ +function cleanSeparators(input) { + if (!Array.isArray(input)) return null; + return input + .slice(0, MAX_SEPARATORS) + .filter((s) => s && typeof s.id === "string" && s.id) + .map((s) => ({ + id: s.id.slice(0, 64), + label: typeof s.label === "string" ? s.label.slice(0, MAX_SEPARATOR_LABEL) : "", + order: Number.isFinite(Number(s.order)) ? Math.max(0, Math.round(Number(s.order))) : 0, + })); +} + app.put("/api/notebooks/:id", authMiddleware, (req, res) => { - const { name, coverIndex, sortOrder, maxVersions, sortMode } = req.body; + const { name, coverIndex, sortOrder, maxVersions, sortMode, separators } = req.body; const nb = db.prepare("SELECT * FROM notebooks WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); if (!nb) return res.status(404).json({ error: "Not found" }); @@ -807,6 +834,11 @@ app.put("/api/notebooks/:id", authMiddleware, (req, res) => { if (sortMode !== undefined) { db.prepare("UPDATE notebooks SET sort_mode = ? WHERE id = ?").run(sortMode, req.params.id); } + if (separators !== undefined) { + const cleaned = cleanSeparators(separators); + if (!cleaned) return res.status(400).json({ error: "separators must be an array" }); + db.prepare("UPDATE notebooks SET separators = ? WHERE id = ?").run(JSON.stringify(cleaned), req.params.id); + } broadcastToUser(req.userId, { type: "notebook_updated", data: { id: req.params.id, name, coverIndex } }, req.deviceId); res.json({ success: true, defaultMaxVersions: MAX_VERSIONS_PER_NOTE }); diff --git a/e2e/organise.mjs b/e2e/organise.mjs index 71da6cc..f38875d 100644 --- a/e2e/organise.mjs +++ b/e2e/organise.mjs @@ -79,16 +79,24 @@ try { const beforeDrag = await titles(); check(beforeDrag.length === 3, "manual order starts from the notes it has", beforeDrag.join(", ")); - // Drag the last note onto the first. + // Drag the last note onto the first. The moves are stepped: a single jump can + // land before the drag has started, and the drop indicator shifts the rows as + // it appears, so the pointer has to arrive gradually. + const drag = async (fromLoc, toLoc) => { + const from = await fromLoc.boundingBox(); + const to = await toLoc.boundingBox(); + await p.mouse.move(from.x + from.width / 2, from.y + from.height / 2); + await p.mouse.down(); + await p.mouse.move(from.x + from.width / 2, from.y + from.height / 2 - 12, { steps: 4 }); + await p.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 12 }); + await p.waitForTimeout(300); + await p.mouse.move(to.x + to.width / 2, to.y + to.height / 2 + 2, { steps: 4 }); + await p.mouse.up(); + await p.waitForTimeout(2000); + }; + const rows = p.locator("[data-note-row]"); - const last = rows.nth(2); - const first = rows.nth(0); - await last.hover(); - await p.mouse.down(); - await first.hover(); - await first.hover(); // a second move, so the drop target registers - await p.mouse.up(); - await p.waitForTimeout(1500); + await drag(rows.nth(2), rows.nth(0)); const arranged = await titles(); check( @@ -122,6 +130,67 @@ try { await sortBy("manual"); await p.waitForTimeout(800); + /* ── the drop indicator ── */ + // It used to be a border on the row, set beside a `border` shorthand — which + // left a permanent 2px black line behind once it was cleared. + const strayLines = await p.locator("[data-drop-line]").count(); + check(strayLines === 0, "no drop indicator is showing while nothing is being dragged", String(strayLines)); + + const borders = await p + .locator("[data-note-row]") + .evaluateAll((rows) => rows.map((r) => getComputedStyle(r).borderTopWidth)); + check( + borders.every((b) => b === "1px"), + "and no row has grown a stray top border", + borders.join(", ") + ); + + /* ── separators ── */ + await p.locator("[data-add-separator]").click(); + await p.waitForTimeout(1800); + check((await p.locator("[data-separator]").count()) === 1, "a separator can be added"); + + const label = p.getByLabel("Separator label"); + await p.locator("[data-separator] button").first().click(); + await p.waitForTimeout(300); + await label.fill("Wichtig"); + await label.press("Enter"); + await p.waitForTimeout(1800); + check( + /wichtig/i.test(await p.locator("[data-separator]").innerText()), + "and named", + await p.locator("[data-separator]").innerText() + ); + + // It belongs to the notebook, so it has to survive a reload like the order does. + await p.reload({ waitUntil: "networkidle" }); + await p.waitForTimeout(2500); + await p.locator("button").filter({ hasText: "Ordnung" }).first().click(); + await p.waitForTimeout(1800); + check( + /wichtig/i.test(await p.locator("[data-separator]").innerText()), + "a separator survives a reload, because it belongs to the notebook" + ); + + const withSeparator = await titles(); + check( + withSeparator.join(",") === arranged.join(","), + "and adding one did not disturb the arrangement", + `${arranged.join(",")} vs ${withSeparator.join(",")}` + ); + + // Separators only mean something in a hand-made order; in "newest first" the + // notes would slide past the line on every edit. + await sortBy("updated"); + check((await p.locator("[data-separator]").count()) === 0, "it is hidden in the other orders"); + await sortBy("manual"); + await p.waitForTimeout(800); + check((await p.locator("[data-separator]").count()) === 1, "and back on the way in"); + + await p.locator("[data-separator] button[aria-label=\"Remove separator\"]").click(); + await p.waitForTimeout(1800); + check((await p.locator("[data-separator]").count()) === 0, "a separator can be removed again"); + /* ── exporting a note and importing it back ── */ await p.locator("[data-note-row]").filter({ hasText: "Beta" }).first().click(); await p.waitForTimeout(1200); @@ -238,6 +307,22 @@ try { await p.waitForTimeout(800); await p.locator("button").filter({ hasText: "All Notes" }).first().click(); await p.waitForTimeout(900); + + // "All Notes" is a question asked of every notebook, not a place — there is + // nothing for a hand-made arrangement to belong to. + await p.locator("[data-sort-button]").click(); + await p.waitForTimeout(400); + check( + (await p.locator('[data-sort-option="manual"]').count()) === 0, + "no hand-made order is offered outside a notebook" + ); + check( + (await p.locator('[data-sort-option="updated"]').count()) === 1, + "but the derived orders still are" + ); + await p.keyboard.press("Escape"); + await p.locator("h2").first().click(); + await p.waitForTimeout(500); await p.getByTitle("New Note").click(); await p.waitForTimeout(900); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 830974d..6fec2d1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -23,7 +23,13 @@ import { ImportDialog } from "./ui/ImportDialog.jsx"; import { Sidebar } from "./ui/Sidebar.jsx"; import { Dashboard } from "./features/notebooks/Dashboard.jsx"; import { NoteList } from "./features/notes/NoteList.jsx"; -import { DEFAULT_SORT, canReorder, sortNotes } from "./features/notes/noteSort"; +import { + DEFAULT_SORT, + canReorder, + sortModesFor, + sortNotes, + withNewSeparator, +} from "./features/notes/noteSort"; import { EditorPane } from "./features/notes/EditorPane.jsx"; import { HistoryModal } from "./features/notes/HistoryModal.jsx"; import { DrawingLayer } from "./features/drawing/DrawingLayer.jsx"; @@ -79,7 +85,7 @@ export default function App() { const { notes, notebooks, noteContents, backlinks } = data; const search = useSearchIndex(user, notes, searchQuery); const dnd = useNotebookDnd(data.reorderNotebooks); - const noteDnd = useNotebookDnd(data.reorderNotes); + const noteDnd = useNotebookDnd((dragId, targetId) => data.rearrange(activeFolder, dragId, targetId)); useEffect(() => sync.onSyncStatus(setSyncStatus), []); @@ -168,9 +174,13 @@ export default function App() { const activeNotebook = notebooks.find((nb) => nb.id === activeFolder) || null; /* A real notebook remembers its order on the server; a view keeps one for the session. Either way the note list is told a single mode. */ - const sortMode = activeNotebook + const rawSortMode = activeNotebook ? activeNotebook.sortMode || DEFAULT_SORT : virtualSort[activeFolder] || DEFAULT_SORT; + // A view has nowhere to keep a hand-made arrangement, so it cannot be in one — + // even if an older version of the app left "manual" behind on a notebook that + // has since been looked at through "All Notes". + const sortMode = !activeNotebook && rawSortMode === "manual" ? DEFAULT_SORT : rawSortMode; const setSortMode = useCallback( (mode) => { @@ -180,6 +190,18 @@ export default function App() { [activeNotebook, activeFolder, data] ); + /* ── Separators ── + * Lines the user draws between notes. They belong to the notebook's own + * arrangement, so they are stored on it and only ever shown in manual order. */ + const separators = activeNotebook?.separators || []; + const writeSeparators = (next) => + activeNotebook && void data.updateNotebook(activeNotebook.id, { separators: next }); + + const addSeparator = () => writeSeparators(withNewSeparator(separators, `sep-${Date.now().toString(36)}`)); + const renameSeparator = (id, label) => + writeSeparators(separators.map((s) => (s.id === id ? { ...s, label } : s))); + const removeSeparator = (id) => writeSeparators(separators.filter((s) => s.id !== id)); + const isSearching = searchQuery.trim().length > 0; const filteredNotes = useMemo(() => { @@ -586,8 +608,13 @@ export default function App() { trashedCount={trashedNotes.length} snippetFor={search.snippetFor} sortMode={sortMode} + sortModes={sortModesFor(!!activeNotebook)} onSortModeChange={setSortMode} reorderable={canReorder(sortMode, isSearching) && !!activeNotebook} + separators={separators} + onAddSeparator={addSeparator} + onRenameSeparator={renameSeparator} + onRemoveSeparator={removeSeparator} dnd={noteDnd} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} onSelectNote={openNote} diff --git a/frontend/src/features/notes/NoteList.jsx b/frontend/src/features/notes/NoteList.jsx index 21f7eb6..7e3349a 100644 --- a/frontend/src/features/notes/NoteList.jsx +++ b/frontend/src/features/notes/NoteList.jsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react"; import { I } from "../../ui/icons.jsx"; import { fmtDate } from "../../ui/format"; import { FONT_SERIF, FONT_UI } from "../../ui/theme"; -import { SORT_LABELS, SORT_MODES } from "./noteSort"; +import { END_ID, SORT_LABELS, mergeEntries } from "./noteSort"; const iconButton = (T) => ({ display: "flex", @@ -18,6 +18,127 @@ const iconButton = (T) => ({ color: T.muted, }); +/** A note rendered outside the manual order, wrapped to look like an entry. */ +const asNoteEntry = (note) => ({ kind: "note", id: note.id, note }); + +/** + * Where the dragged row will land. + * + * A real element rather than a border on the row above. A border that appears + * and disappears changes that row's height, so the list twitched under the + * cursor — and setting `borderTop` in the same style object as a `border` + * shorthand left a permanent 2px black line behind once it was cleared, because + * clearing the longhand resets its width and colour to the defaults while the + * shorthand's `solid` stays. + */ +const DropLine = ({ T }) => ( +
+); + +/** + * A line drawn between notes, with an optional name. + * + * Draggable like a note, because a divider that cannot move with what it + * divides is worse than none. + */ +function SeparatorRow({ T, separator, onRename, onRemove }) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(separator.label); + + const commit = () => { + setEditing(false); + if (draft !== separator.label) onRename(separator.id, draft); + }; + + return ( +
+
+ {editing ? ( + setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === "Enter") commit(); + if (e.key === "Escape") { + setDraft(separator.label); + setEditing(false); + } + }} + aria-label="Separator label" + placeholder="Abschnitt" + style={{ + flex: 1, + minWidth: 0, + border: `1px solid ${T.border}`, + borderRadius: 6, + background: "transparent", + color: T.textSec, + fontSize: 10.5, + fontWeight: 700, + letterSpacing: 0.6, + padding: "2px 6px", + outline: "none", + }} + /> + ) : ( + + )} +
+ +
+ ); +} + /** * How this notebook's notes are ordered. * @@ -25,7 +146,7 @@ const iconButton = (T) => ({ * device and still there next week — a reference book stays alphabetical, a * journal stays newest-first, without either being re-chosen on each visit. */ -function SortMenu({ T, mode, onChange, disabled }) { +function SortMenu({ T, mode, modes, onChange, disabled }) { const [open, setOpen] = useState(false); const box = useRef(null); @@ -65,7 +186,7 @@ function SortMenu({ T, mode, onChange, disabled }) { boxShadow: "0 10px 26px rgba(0,0,0,.28)", }} > - {SORT_MODES.map((m) => ( + {modes.map((m) => (
- {!isTrashView && } + {!isTrashView && } {!isTrashView && ( + )}
)} @@ -295,77 +445,130 @@ export function NoteList({ )}
) : ( - notes.map((note) => { - const snippet = snippetFor(note.id); - const isTarget = reorderable && dnd?.overId === note.id; - return ( - - ); - }) + ); + } + + const note = entry.note; + const snippet = snippetFor(note.id); + return ( +
+ {showDropLine && } + +
+ ); + })} + {/* Dropping below the last row moves something to the end, which + otherwise needs an awkward drop onto the very last note. */} + {reorderable && ( +
dnd.onDragOver(e, END_ID)} + onDrop={(e) => dnd.onDrop(e, END_ID)} + style={{ minHeight: 40 }} + > + {dnd?.overId === END_ID && } +
+ )} + )}
diff --git a/frontend/src/features/notes/noteSort.test.ts b/frontend/src/features/notes/noteSort.test.ts index 152f9fe..255a3ca 100644 --- a/frontend/src/features/notes/noteSort.test.ts +++ b/frontend/src/features/notes/noteSort.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it } from "vitest"; -import { canReorder, reindex, sortNotes } from "./noteSort"; +import { + END_ID, + canReorder, + mergeEntries, + reindex, + reindexEntries, + sortModesFor, + sortNotes, + withNewSeparator, +} from "./noteSort"; import type { NoteMeta } from "../../types"; const note = (id: string, over: Partial = {}): NoteMeta => ({ @@ -129,3 +138,89 @@ describe("canReorder", () => { expect(canReorder("manual", true)).toBe(false); }); }); + +describe("sortModesFor", () => { + it("offers a hand-made order inside a notebook", () => { + expect(sortModesFor(true)).toContain("manual"); + }); + + it("does not offer one in a view, which has nowhere to keep it", () => { + // "All Notes" is a question asked of every notebook, not a place — there is + // nothing for an arrangement to belong to. + expect(sortModesFor(false)).not.toContain("manual"); + expect(sortModesFor(false)).toContain("updated"); + }); +}); + +describe("separators", () => { + const notes = [note("a", { sortOrder: 0 }), note("b", { sortOrder: 1 }), note("c", { sortOrder: 2 })]; + const kinds = (list: ReturnType) => list.map((e) => `${e.kind[0]}:${e.id}`); + + it("sits between the notes it was placed between", () => { + const merged = mergeEntries(notes, [{ id: "s1", label: "Später", order: 2 }]); + expect(kinds(merged)).toEqual(["n:a", "n:b", "s:s1", "n:c"]); + }); + + it("comes before the note it shares a position with, so it reads as a header", () => { + const merged = mergeEntries(notes, [{ id: "s1", label: "", order: 0 }]); + expect(kinds(merged)[0]).toBe("s:s1"); + }); + + it("still appears when the notebook has shrunk beneath it", () => { + // Its label is the user's work; dropping it silently because two notes were + // deleted would be worse than showing it at the end. + const merged = mergeEntries(notes, [{ id: "s1", label: "Alt", order: 99 }]); + expect(kinds(merged)[merged.length - 1]).toBe("s:s1"); + }); + + it("is not pushed behind notes that have never been dragged", () => { + // Untouched notes all carry position 0. Merging on the stored numbers rather + // than on rank would pile them all at 0 and put every separator last. + const fresh = [note("x"), note("y"), note("z")]; + const merged = mergeEntries(fresh, [{ id: "s1", label: "", order: 1 }]); + expect(kinds(merged)[1]).toBe("s:s1"); + }); + + it("keeps its place as the notes around it are edited", () => { + const separators = [{ id: "s1", label: "Archiv", order: 2 }]; + const touched = notes.map((n) => (n.id === "a" ? { ...n, updatedAt: "2030-01-01T00:00:00Z" } : n)); + expect(kinds(mergeEntries(touched, separators))).toEqual(["n:a", "n:b", "s:s1", "n:c"]); + }); + + it("is added at the top, where it can be seen and then moved", () => { + const added = withNewSeparator([{ id: "old", label: "", order: 3 }], "new"); + expect(added[0]!.id).toBe("new"); + expect(added[0]!.order).toBe(0); + }); +}); + +describe("reindexEntries", () => { + const notes = [note("a", { sortOrder: 0 }), note("b", { sortOrder: 1 }), note("c", { sortOrder: 2 })]; + const separators = [{ id: "s1", label: "Teil 2", order: 1 }]; + + it("moves a note across a separator and keeps the separator where it was", () => { + const next = reindexEntries(notes, separators, "c", "a")!; + expect(next.noteIds).toEqual(["c", "a", "b"]); + expect(next.separators[0]!.order).toBe(2); + }); + + it("moves a separator itself", () => { + const next = reindexEntries(notes, separators, "s1", "a")!; + expect(next.separators[0]!.order).toBe(0); + expect(next.noteIds).toEqual(["a", "b", "c"]); + }); + + it("sends a row to the end when dropped below the last one", () => { + expect(reindexEntries(notes, [], "a", END_ID)!.noteIds).toEqual(["b", "c", "a"]); + }); + + it("reports nothing to do for a row that is not there", () => { + expect(reindexEntries(notes, separators, "ghost", "a")).toBeNull(); + expect(reindexEntries(notes, separators, "a", "ghost")).toBeNull(); + }); + + it("leaves the separators untouched when only notes moved past each other", () => { + const next = reindexEntries(notes, [{ id: "s1", label: "x", order: 0 }], "c", "b")!; + expect(next.separators).toEqual([{ id: "s1", label: "x", order: 0 }]); + }); +}); diff --git a/frontend/src/features/notes/noteSort.ts b/frontend/src/features/notes/noteSort.ts index f1742a8..98d0aba 100644 --- a/frontend/src/features/notes/noteSort.ts +++ b/frontend/src/features/notes/noteSort.ts @@ -12,7 +12,7 @@ * scramble everything that was not being looked at. */ -import type { NoteMeta, SortMode } from "../../types"; +import type { NoteMeta, Separator, SortMode } from "../../types"; export const DEFAULT_SORT: SortMode = "updated"; @@ -26,6 +26,17 @@ export const SORT_LABELS: Record = { export const SORT_MODES = Object.keys(SORT_LABELS) as SortMode[]; +/** + * The orders a view can be put in. + * + * "All Notes" and "Starred" are not places, they are questions asked of every + * notebook at once — so there is nothing for a hand-made arrangement to belong + * to, and nowhere to keep it. Offering it there would be offering a setting that + * cannot be honoured. + */ +export const sortModesFor = (isNotebook: boolean): SortMode[] => + isNotebook ? SORT_MODES : SORT_MODES.filter((m) => m !== "manual"); + const time = (value: string | undefined) => { const t = Date.parse(value ?? ""); return Number.isNaN(t) ? 0 : t; @@ -59,24 +70,122 @@ export function sortNotes(notes: NoteMeta[], mode: SortMode = DEFAULT_SORT): Not return [...notes].sort(compare[mode] ?? compare[DEFAULT_SORT]); } +/* ── separators ── */ + +/** + * A line drawn between notes to group them. + * + * Separators live in the same order as the notes and are dragged the same way, + * because a divider that cannot be moved with what it divides is worse than + * none. They belong to the notebook rather than to any note: they are part of + * the arrangement, and the arrangement is the notebook's. + * + * They exist only in the manual order. In "newest first" there is nothing for a + * line to mean — the notes would slide past it on every edit — so the other + * orders simply do not show them, and they are still there on the way back. + */ +export type Entry = + | { kind: "note"; id: string; order: number; note: NoteMeta } + | { kind: "separator"; id: string; order: number; separator: Separator }; + +/** + * Notes and separators, interleaved into the one order the user arranged. + * + * A separator sitting at the same position as a note comes first, so "insert a + * line above this note" lands where it was asked for rather than one row late. + */ +export function mergeEntries(notes: NoteMeta[], separators: Separator[] = []): Entry[] { + /* Notes are placed by their rank, not by their stored number. Notes that have + * never been dragged all carry 0, and sorting has already broken that tie — + * using the stored numbers here would pile them all at position 0 and push + * every separator behind the lot. */ + const noteEntries: Entry[] = sortNotes(notes, "manual").map((note, rank) => ({ + kind: "note" as const, + id: note.id, + order: rank, + note, + })); + + const separatorEntries: Entry[] = separators.map((separator) => ({ + kind: "separator" as const, + id: separator.id, + // Clamped, so a separator left over from a notebook that has since shrunk + // still appears — at the end — rather than vanishing with its label. + order: Math.max(0, Math.min(separator.order, noteEntries.length)), + separator, + })); + + return [...noteEntries, ...separatorEntries].sort( + // A separator sitting at the same position as a note comes first, so + // "a line above this note" lands where it was asked for. + (a, b) => a.order - b.order || (a.kind === "separator" ? -1 : 1) + ); +} + +/** The result of a drag: what to write back for notes and for separators. */ +export interface Arrangement { + noteIds: string[]; + separators: Separator[]; +} + +/** Turns an ordered entry list back into storable positions. */ +export function toArrangement(entries: Entry[]): Arrangement { + const noteIds: string[] = []; + const separators: Separator[] = []; + for (const entry of entries) { + if (entry.kind === "note") noteIds.push(entry.id); + // A separator's position is "how many notes come before me". Expressed that + // way it stays put as the notes around it are renamed, edited or opened. + else separators.push({ ...entry.separator, order: noteIds.length }); + } + return { noteIds, separators }; +} + +/** + * The whole notebook rearranged after something was dragged onto something else. + * + * `notes` must be every note in the notebook, not the filtered view. Returns + * null when the move changes nothing, which spares the caller a pointless write. + */ +export function reindexEntries( + notes: NoteMeta[], + separators: Separator[], + dragId: string, + targetId: string +): Arrangement | null { + if (dragId === targetId) return null; + const entries = mergeEntries(notes, separators); + const from = entries.findIndex((e) => e.id === dragId); + if (from < 0) return null; + + // The strip below the last row means "put it at the end" — otherwise moving + // something past the final note needs a drop onto the note itself, which puts + // it above. + const to = targetId === END_ID ? entries.length - 1 : entries.findIndex((e) => e.id === targetId); + if (to < 0) return null; + + const [moved] = entries.splice(from, 1); + if (!moved) return null; + entries.splice(to, 0, moved); + return toArrangement(entries); +} + +/** Drop target standing for "after everything". */ +export const END_ID = "__end__"; + +/** A new separator, added at the top where it can be seen and then dragged. */ +export const withNewSeparator = (separators: Separator[], id: string, label = ""): Separator[] => [ + { id, label, order: 0 }, + ...separators, +]; + /** * The full notebook renumbered after a note was dragged onto another. * - * `all` must be every note in the notebook, not the filtered view. Returns the - * ids in their new order, or null when the move changes nothing — which spares - * the caller a pointless write. + * Kept for the case with no separators; `reindexEntries` is the general one. */ export function reindex(all: NoteMeta[], dragId: string, targetId: string): string[] | null { - if (dragId === targetId) return null; - const ordered = sortNotes(all, "manual"); - const from = ordered.findIndex((n) => n.id === dragId); - const to = ordered.findIndex((n) => n.id === targetId); - if (from < 0 || to < 0) return null; - - const [moved] = ordered.splice(from, 1); - if (!moved) return null; - ordered.splice(to, 0, moved); - return ordered.map((n) => n.id); + return reindexEntries(all, [], dragId, targetId)?.noteIds ?? null; } /** diff --git a/frontend/src/hooks/useNoteData.ts b/frontend/src/hooks/useNoteData.ts index 3880169..61d2f06 100644 --- a/frontend/src/hooks/useNoteData.ts +++ b/frontend/src/hooks/useNoteData.ts @@ -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 { reindex } from "../features/notes/noteSort"; +import { reindexEntries } from "../features/notes/noteSort"; import type { Backlink, NoteContent, NoteMeta, Notebook, StrokeDoc, User } from "../types"; /** How often the whole account is reconciled with the server. */ @@ -45,8 +45,8 @@ export interface NoteData { permanentDeleteNote: (id: string) => Promise; emptyTrash: () => Promise; moveNote: (id: string, folderId: string) => Promise; - /** Drag-to-reorder within a notebook; only meaningful in manual sort. */ - reorderNotes: (dragId: string, targetId: string) => void; + /** Drag-to-reorder within a notebook; moves notes and separators alike. */ + rearrange: (notebookId: string, dragId: string, targetId: string) => void; createNotebook: (fields: { name: string; cover: number }) => Promise; updateNotebook: (id: string, updates: Partial) => Promise; @@ -265,22 +265,34 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD }, []); /** - * Puts one note where another was, in the user's own order. + * Puts one row where another was, in the user's own order. * - * The new positions are computed over *every* note in that notebook, taken - * from the store rather than from whatever the list is currently showing. A - * search or a filter must never be able to renumber the notes it is hiding. + * Notes and separators move the same way, so this takes both and writes both + * back. The new positions are computed over *every* note in that notebook, + * taken from the store rather than from whatever the list is showing: a search + * or a filter must never be able to renumber the notes it is hiding. */ - const reorderNotes = useCallback((dragId: string, targetId: string) => { - const dragged = notesRef.current.find((n) => n.id === dragId); - if (!dragged) return; - const inNotebook = notesRef.current.filter((n) => !n.trashed && n.folderId === dragged.folderId); - const order = reindex(inNotebook, dragId, targetId); - if (!order) return; + const rearrange = useCallback((notebookId: string, dragId: string, targetId: string) => { + const notebook = notebooksRef.current.find((nb) => nb.id === notebookId); + if (!notebook) return; + const inNotebook = notesRef.current.filter((n) => !n.trashed && n.folderId === notebookId); - const position = new Map(order.map((id, i) => [id, i])); + const next = reindexEntries(inNotebook, notebook.separators || [], dragId, targetId); + if (!next) return; + + const position = new Map(next.noteIds.map((id, i) => [id, i])); setNotes((p) => p.map((n) => (position.has(n.id) ? { ...n, sortOrder: position.get(n.id)! } : n))); - void sync.reorderNotes(order); + void sync.reorderNotes(next.noteIds); + + // Only written when it actually changed: a plain note drag leaves the + // separators alone, and a pointless notebook write would sync for nothing. + const before = JSON.stringify(notebook.separators || []); + if (JSON.stringify(next.separators) !== before) { + setNotebooks((p) => + p.map((nb) => (nb.id === notebookId ? { ...nb, separators: next.separators } : nb)) + ); + void sync.updateNotebook(notebookId, { separators: next.separators }); + } }, []); /* ── Notebooks ── */ @@ -333,7 +345,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD permanentDeleteNote, emptyTrash, moveNote, - reorderNotes, + rearrange, createNotebook, updateNotebook, deleteNotebook, diff --git a/frontend/src/syncEngine.ts b/frontend/src/syncEngine.ts index bb5baf2..f65f91a 100644 --- a/frontend/src/syncEngine.ts +++ b/frontend/src/syncEngine.ts @@ -73,6 +73,7 @@ interface ServerNotebook { sort_order?: number; max_versions?: number | null; sort_mode?: string | null; + separators?: string | null; } interface ServerNote { @@ -406,6 +407,7 @@ export async function fullSync(): Promise { sortOrder: nb.sort_order || 0, maxVersions: nb.max_versions ?? null, sortMode: (nb.sort_mode as Notebook["sortMode"]) ?? null, + separators: parseSeparators(nb.separators), })); const serverNbIds = new Set(nbs.map((nb) => nb.id)); const unsyncedNbs = localNbs.filter((nb) => nb._pendingCreate && !serverNbIds.has(nb.id)); @@ -447,6 +449,17 @@ export async function fullSync(): Promise { } } +/** Separators travel as a JSON string; a damaged one must not fail the sync. */ +function parseSeparators(raw: string | null | undefined): Notebook["separators"] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + function toNoteMeta(n: ServerNote, trashed: boolean): NoteMeta { return { id: n.id, @@ -476,6 +489,7 @@ type QueuedAction = sortOrder?: number; maxVersions?: number | null; sortMode?: Notebook["sortMode"]; + separators?: Notebook["separators"]; }; } | { action: "deleteNotebook"; data: { id: string } } @@ -562,6 +576,7 @@ async function processEntry(entry: QueueEntry): Promise { sortOrder: a.data.sortOrder, ...(a.data.maxVersions !== undefined ? { maxVersions: a.data.maxVersions } : {}), ...(a.data.sortMode !== undefined ? { sortMode: a.data.sortMode } : {}), + ...(a.data.separators !== undefined ? { separators: a.data.separators } : {}), }); break; case "deleteNotebook": @@ -719,6 +734,7 @@ export async function updateNotebook(id: string, updates: Partial): Pr // has to travel, while undefined must stay away and change nothing. ...(updates.maxVersions !== undefined ? { maxVersions: updates.maxVersions } : {}), ...(updates.sortMode !== undefined ? { sortMode: updates.sortMode } : {}), + ...(updates.separators !== undefined ? { separators: updates.separators } : {}), }); return; } catch (e) { @@ -734,6 +750,7 @@ export async function updateNotebook(id: string, updates: Partial): Pr sortOrder: updates.sortOrder, maxVersions: updates.maxVersions, sortMode: updates.sortMode, + separators: updates.separators, }, }); notifyStatus("pending"); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 64d7002..bb4f596 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -74,6 +74,18 @@ export interface NoteContent { */ export type SortMode = "manual" | "updated" | "oldest" | "created" | "title"; +/** + * A line drawn between notes in a notebook's own order. + * + * `order` is "how many notes come before me", so it stays meaningful as the + * notes around it are renamed, edited or opened. + */ +export interface Separator { + id: string; + label: string; + order: number; +} + export interface Notebook { id: string; name: string; @@ -83,6 +95,8 @@ export interface Notebook { maxVersions?: number | null; /** Null means the app default, newest first. */ sortMode?: SortMode | null; + /** Lines drawn between notes in the manual order. */ + separators?: Separator[]; _pendingCreate?: boolean; } diff --git a/readme.md b/readme.md index 372172d..b9b4af4 100644 --- a/readme.md +++ b/readme.md @@ -304,7 +304,7 @@ failure distinguished by comparing an error's `.message` to a magic string. Failures now carry a typed `SyncError.code`; `.message` still holds the same string, so the remaining JavaScript callers are unaffected. -The 186 unit tests concentrate on the same places, and several exist because the +The 199 unit tests concentrate on the same places, and several exist because the bug they cover actually happened: - a queue entry that fails must stay queued, with an attempt counter @@ -336,7 +336,7 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running | `attachments-lifecycle` | 10 | a picture is kept while any version shows it, and freed once none does | | `conflict` | 10 | a late write wins but the state it buries survives as a version | | `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back | -| `organise` | 24 | a hand-made order survives a search; a note exported and imported comes back whole | +| `organise` | 35 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole | | `offline` | 12 | a cold page load with the network off still opens notes and searches them | Each suite creates a throwaway account through the admin API and deletes it @@ -717,7 +717,7 @@ newest-first. | Mode | Order | | --- | --- | -| `manual` | the user's own arrangement, dragged into place | +| `manual` | the user's own arrangement, dragged into place — notebooks only | | `updated` | most recently changed first *(the default)* | | `oldest` | least recently changed first | | `created` | most recently created first | @@ -734,6 +734,18 @@ never re-derived from what is on screen, so two rules hold it up: - Dragging is switched off while a search is narrowing the list, and the list says so rather than silently ignoring the drag. +"All Notes" and "Starred" are not offered a manual order at all. They are +questions asked of every notebook at once, so there is nothing for an +arrangement to belong to and nowhere to keep it. + +**Separators** are lines drawn between notes, optionally named, stored on the +notebook beside the sort mode. They move by dragging like a note does — a +divider that cannot move with what it divides is worse than none — and their +position is recorded as "how many notes come before me", so they stay put as the +notes around them are renamed, edited or opened. They are shown only in the +manual order: in "newest first" the notes would slide past the line on every +edit. + Reordering also has its own route, `POST /api/notes/reorder`. Going through `PUT /api/notes/:id` would bump `updated_at` and snapshot a version — so dragging a note would move it to the top of "last changed" and spend a slot of