Fix a permanent black line in the note list, and add separators
The drop indicator was a `borderTop` set in the same style object as a `border` shorthand. Clearing the longhand resets its width and colour to the defaults while the shorthand's `solid` stays, so a row kept a 2px black line for good — measured at rest, before any drag. It is now its own element, which also stops the list twitching as a row grows and shrinks by a pixel. That element then turned out to be a dead strip of its own: it pushes the row down as it appears, so the pointer ended up over the line rather than over the row and releasing there dropped on nothing. The drag handlers moved to the whole row and the indicator no longer takes pointer events. A strip below the last row means "put it at the end", which otherwise needs a drop onto the final note. Separators are lines drawn between notes, optionally named, stored on the notebook beside its sort mode. They drag like notes do, and their position is "how many notes come before me", so they hold their place as the notes around them are renamed or edited. Shown only in the manual order — in "newest first" the notes would slide past the line on every edit. "All Notes" and "Starred" no longer offer a hand-made order: they are questions asked of every notebook, so there is nothing for an arrangement to belong to. 199 unit tests, 190 browser checks across eleven suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0a0864c58b
commit
2cee00e38e
10 files changed
+670
-64
No files matched your search
+33
-1
@@ -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 });
|
||||
|
||||
+93
-8
@@ -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.
|
||||
const rows = p.locator("[data-note-row]");
|
||||
const last = rows.nth(2);
|
||||
const first = rows.nth(0);
|
||||
await last.hover();
|
||||
// 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 first.hover();
|
||||
await first.hover(); // a second move, so the drop target registers
|
||||
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(1500);
|
||||
await p.waitForTimeout(2000);
|
||||
};
|
||||
|
||||
const rows = p.locator("[data-note-row]");
|
||||
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);
|
||||
|
||||
|
||||
+30
-3
@@ -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}
|
||||
|
||||
@@ -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 }) => (
|
||||
<div data-drop-line style={{ height: 2, borderRadius: 2, background: T.accent, margin: "2px 4px" }} />
|
||||
);
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div
|
||||
data-separator={separator.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 8px 6px",
|
||||
cursor: "grab",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ height: 1, background: T.border, flex: separator.label || editing ? 0 : 1, minWidth: 12 }}
|
||||
/>
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => 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",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setDraft(separator.label);
|
||||
setEditing(true);
|
||||
}}
|
||||
title="Umbenennen"
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: separator.label ? T.textSec : T.faint,
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.6,
|
||||
textTransform: "uppercase",
|
||||
cursor: "text",
|
||||
padding: 0,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{separator.label || "Benennen"}
|
||||
</button>
|
||||
)}
|
||||
<div style={{ height: 1, background: T.border, flex: 1, minWidth: 12 }} />
|
||||
<button
|
||||
onClick={() => onRemove(separator.id)}
|
||||
title="Trenner entfernen"
|
||||
aria-label="Remove separator"
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: T.faint,
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
lineHeight: 1,
|
||||
padding: "0 2px",
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => (
|
||||
<button
|
||||
key={m}
|
||||
data-sort-option={m}
|
||||
@@ -109,8 +230,13 @@ export function NoteList({
|
||||
trashedCount,
|
||||
snippetFor,
|
||||
sortMode,
|
||||
sortModes,
|
||||
onSortModeChange,
|
||||
reorderable,
|
||||
separators,
|
||||
onAddSeparator,
|
||||
onRenameSeparator,
|
||||
onRemoveSeparator,
|
||||
dnd,
|
||||
onToggleSidebar,
|
||||
onSelectNote,
|
||||
@@ -176,7 +302,7 @@ export function NoteList({
|
||||
</h2>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
|
||||
{!isTrashView && <SortMenu T={T} mode={sortMode} onChange={onSortModeChange} />}
|
||||
{!isTrashView && <SortMenu T={T} mode={sortMode} modes={sortModes} onChange={onSortModeChange} />}
|
||||
{!isTrashView && (
|
||||
<button
|
||||
onClick={onImportMarkdown}
|
||||
@@ -242,15 +368,39 @@ export function NoteList({
|
||||
{sortMode === "manual" && !isTrashView && (
|
||||
<div
|
||||
style={{
|
||||
padding: "5px 16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "5px 10px 5px 16px",
|
||||
fontSize: 10.5,
|
||||
color: T.faint,
|
||||
borderBottom: `1px solid ${T.borderLight}`,
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1 }}>
|
||||
{reorderable
|
||||
? "Eigene Reihenfolge — zum Umsortieren ziehen"
|
||||
: "Eigene Reihenfolge — Suche aktiv, Ziehen pausiert"}
|
||||
</span>
|
||||
{reorderable && (
|
||||
<button
|
||||
onClick={onAddSeparator}
|
||||
data-add-separator
|
||||
title="Trennlinie einfügen"
|
||||
style={{
|
||||
border: `1px solid ${T.border}`,
|
||||
background: "transparent",
|
||||
color: T.muted,
|
||||
borderRadius: 8,
|
||||
padding: "2px 8px",
|
||||
fontSize: 10.5,
|
||||
cursor: "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
+ Trenner
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -295,18 +445,50 @@ export function NoteList({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
notes.map((note) => {
|
||||
const snippet = snippetFor(note.id);
|
||||
const isTarget = reorderable && dnd?.overId === note.id;
|
||||
<>
|
||||
{/* Separators are part of the manual arrangement and only mean
|
||||
something there: in "newest first" the notes would slide past a
|
||||
line on every edit. They are not shown in the other orders, and
|
||||
are still in place on the way back. */}
|
||||
{(reorderable ? mergeEntries(notes, separators) : notes.map(asNoteEntry)).map((entry) => {
|
||||
const showDropLine = reorderable && dnd?.overId === entry.id;
|
||||
/* The whole row, indicator included, is the drop target.
|
||||
*
|
||||
* Putting these on the inner element left the indicator itself as
|
||||
* a dead strip: it pushes the row down as it appears, so the
|
||||
* pointer ended up over the line rather than over the row, and
|
||||
* releasing there dropped on nothing. */
|
||||
const rowProps = reorderable
|
||||
? {
|
||||
draggable: true,
|
||||
onDragStart: (e) => dnd.onDragStart(e, entry.id),
|
||||
onDragOver: (e) => dnd.onDragOver(e, entry.id),
|
||||
onDrop: (e) => dnd.onDrop(e, entry.id),
|
||||
onDragEnd: dnd.onDragEnd,
|
||||
}
|
||||
: {};
|
||||
|
||||
if (entry.kind === "separator") {
|
||||
return (
|
||||
<div key={entry.id} {...rowProps}>
|
||||
{showDropLine && <DropLine T={T} />}
|
||||
<SeparatorRow
|
||||
T={T}
|
||||
separator={entry.separator}
|
||||
onRename={onRenameSeparator}
|
||||
onRemove={onRemoveSeparator}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const note = entry.note;
|
||||
const snippet = snippetFor(note.id);
|
||||
return (
|
||||
<div key={note.id} {...rowProps}>
|
||||
{showDropLine && <DropLine T={T} />}
|
||||
<button
|
||||
key={note.id}
|
||||
data-note-row={note.id}
|
||||
draggable={reorderable}
|
||||
onDragStart={reorderable ? (e) => dnd.onDragStart(e, note.id) : undefined}
|
||||
onDragOver={reorderable ? (e) => dnd.onDragOver(e, note.id) : undefined}
|
||||
onDrop={reorderable ? (e) => dnd.onDrop(e, note.id) : undefined}
|
||||
onDragEnd={reorderable ? dnd.onDragEnd : undefined}
|
||||
onClick={() => onSelectNote(note.id)}
|
||||
onContextMenu={(e) => onNoteContextMenu(e, note)}
|
||||
style={{
|
||||
@@ -316,7 +498,6 @@ export function NoteList({
|
||||
padding: "12px 14px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${activeNote === note.id ? T.accent + "55" : "transparent"}`,
|
||||
borderTop: isTarget ? `2px solid ${T.accent}` : undefined,
|
||||
background: activeNote === note.id ? T.accentSoft : "transparent",
|
||||
cursor: reorderable ? "grab" : "pointer",
|
||||
marginBottom: 2,
|
||||
@@ -324,7 +505,13 @@ export function NoteList({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 3, width: "100%" }}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
marginBottom: 3,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13 }}>
|
||||
{note.trashed ? "🗑️" : note.type === "pdf" ? "📄" : "📝"}
|
||||
@@ -362,10 +549,26 @@ export function NoteList({
|
||||
{snippet}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 10, color: T.faint, marginTop: 3 }}>{fmtDate(note.updatedAt)}</div>
|
||||
<div style={{ fontSize: 10, color: T.faint, marginTop: 3 }}>
|
||||
{fmtDate(note.updatedAt)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
})}
|
||||
{/* Dropping below the last row moves something to the end, which
|
||||
otherwise needs an awkward drop onto the very last note. */}
|
||||
{reorderable && (
|
||||
<div
|
||||
data-drop-end
|
||||
onDragOver={(e) => dnd.onDragOver(e, END_ID)}
|
||||
onDrop={(e) => dnd.onDrop(e, END_ID)}
|
||||
style={{ minHeight: 40 }}
|
||||
>
|
||||
{dnd?.overId === END_ID && <DropLine T={T} />}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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> = {}): 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<typeof mergeEntries>) => 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 }]);
|
||||
});
|
||||
});
|
||||
@@ -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<SortMode, string> = {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<void>;
|
||||
emptyTrash: () => Promise<void>;
|
||||
moveNote: (id: string, folderId: string) => Promise<void>;
|
||||
/** 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<Notebook>;
|
||||
updateNotebook: (id: string, updates: Partial<Notebook>) => Promise<void>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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<void> {
|
||||
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<Notebook>): 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<Notebook>): Pr
|
||||
sortOrder: updates.sortOrder,
|
||||
maxVersions: updates.maxVersions,
|
||||
sortMode: updates.sortMode,
|
||||
separators: updates.separators,
|
||||
},
|
||||
});
|
||||
notifyStatus("pending");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user