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 (
+