Sort a notebook, import Markdown back, and stop the page shrinking in Draw
The paper measurably shrank on switching to Draw — 733px to 460px on the same screen. The editor was a flex item at its default `flex: 0 1 auto`, so its width was its widest row's: the toolbar. Swapping a dozen formatting buttons for six drawing ones collapsed it. It now fills the pane, so the width is a property of the window. Each notebook remembers how its notes are ordered, on the server rather than per device. The manual order has to survive being looked away from, so `reindex` renumbers every note in the notebook rather than the filtered view, and dragging is switched off while a search narrows the list. Reordering has its own route: `PUT /notes/:id` would bump `updated_at` and snapshot a version, so a drag would move a note to the top of "last changed" and spend a slot of its history. Markdown and our own ZIP archives come back as notes, with their images re-uploaded as attachments. A drawing returns as the PNG it was exported as — vectors are not recoverable from pixels — and the import says so rather than leaving it to be discovered. Writing the parser turned up a real bug in the exporter: blocks were emitted with no blank line between them, so a list after a list became one list and a quote after an item was read as part of it. Creating or importing from "All Notes" now asks which notebook instead of silently taking the first one. The web-link prompt is a real dialog: a bare domain becomes https, javascript: is refused with a reason. The PDF viewer's hide-drawing control is a labelled button rather than a filled-or-hollow dot, and the export icon points down into a tray like every other download. 186 unit tests, 179 browser checks across eleven suites, including a new `offline` suite that loads the app cold with the network off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3acb8b66a4
commit
0a0864c58b
27 files changed
+2962
-86
No files matched your search
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { canReorder, reindex, sortNotes } from "./noteSort";
|
||||
import type { NoteMeta } from "../../types";
|
||||
|
||||
const note = (id: string, over: Partial<NoteMeta> = {}): NoteMeta => ({
|
||||
id,
|
||||
title: id,
|
||||
folderId: "nb",
|
||||
starred: false,
|
||||
trashed: false,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...over,
|
||||
});
|
||||
|
||||
const ids = (list: NoteMeta[]) => list.map((n) => n.id);
|
||||
|
||||
describe("sortNotes", () => {
|
||||
const notes = [
|
||||
note("b", { updatedAt: "2026-05-02T00:00:00Z", createdAt: "2026-01-03T00:00:00Z", sortOrder: 2 }),
|
||||
note("a", { updatedAt: "2026-05-03T00:00:00Z", createdAt: "2026-01-01T00:00:00Z", sortOrder: 1 }),
|
||||
note("c", { updatedAt: "2026-05-01T00:00:00Z", createdAt: "2026-01-02T00:00:00Z", sortOrder: 0 }),
|
||||
];
|
||||
|
||||
it("puts the most recently changed first", () => {
|
||||
expect(ids(sortNotes(notes, "updated"))).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("reverses that for oldest", () => {
|
||||
expect(ids(sortNotes(notes, "oldest"))).toEqual(["c", "b", "a"]);
|
||||
});
|
||||
|
||||
it("sorts by creation date, which is not the same as by change date", () => {
|
||||
expect(ids(sortNotes(notes, "created"))).toEqual(["b", "c", "a"]);
|
||||
});
|
||||
|
||||
it("follows the stored positions when manual", () => {
|
||||
expect(ids(sortNotes(notes, "manual"))).toEqual(["c", "a", "b"]);
|
||||
});
|
||||
|
||||
it("does not mutate the list it was given", () => {
|
||||
const original = [...notes];
|
||||
sortNotes(notes, "title");
|
||||
expect(notes).toEqual(original);
|
||||
});
|
||||
|
||||
it("sorts titles the way German expects, ignoring case and numbering", () => {
|
||||
const list = [
|
||||
note("3", { title: "Übung 10" }),
|
||||
note("1", { title: "übung 2" }),
|
||||
note("2", { title: "Alpha" }),
|
||||
];
|
||||
expect(sortNotes(list, "title").map((n) => n.title)).toEqual(["Alpha", "übung 2", "Übung 10"]);
|
||||
});
|
||||
|
||||
it("orders notes that have never been dragged newest first, not arbitrarily", () => {
|
||||
// Everything imported at once carries position 0; without a tie-break the
|
||||
// list would come out in whatever order the store happened to return.
|
||||
const fresh = [
|
||||
note("old", { updatedAt: "2026-01-01T00:00:00Z" }),
|
||||
note("new", { updatedAt: "2026-06-01T00:00:00Z" }),
|
||||
note("mid", { updatedAt: "2026-03-01T00:00:00Z" }),
|
||||
];
|
||||
expect(ids(sortNotes(fresh, "manual"))).toEqual(["new", "mid", "old"]);
|
||||
});
|
||||
|
||||
it("is stable across repeated sorts of an entirely tied list", () => {
|
||||
const tied = [note("z"), note("m"), note("a")];
|
||||
const once = ids(sortNotes(tied, "manual"));
|
||||
expect(ids(sortNotes([...tied].reverse(), "manual"))).toEqual(once);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reindex", () => {
|
||||
const all = [
|
||||
note("a", { sortOrder: 0 }),
|
||||
note("b", { sortOrder: 1 }),
|
||||
note("c", { sortOrder: 2 }),
|
||||
note("d", { sortOrder: 3 }),
|
||||
];
|
||||
|
||||
it("moves a note onto the position of the one it was dropped on", () => {
|
||||
expect(reindex(all, "d", "b")).toEqual(["a", "d", "b", "c"]);
|
||||
});
|
||||
|
||||
it("works upwards as well as downwards", () => {
|
||||
expect(reindex(all, "a", "c")).toEqual(["b", "c", "a", "d"]);
|
||||
});
|
||||
|
||||
it("reports nothing to do when a note is dropped on itself", () => {
|
||||
expect(reindex(all, "b", "b")).toBeNull();
|
||||
});
|
||||
|
||||
it("reports nothing to do for a note that is not in the notebook", () => {
|
||||
expect(reindex(all, "ghost", "b")).toBeNull();
|
||||
});
|
||||
|
||||
/* The point of the whole design: an arrangement has to survive being looked
|
||||
* away from. This is the case that used to go wrong in note apps — reorder
|
||||
* against a filtered list and the hidden notes keep positions that now
|
||||
* collide with the visible ones. */
|
||||
it("keeps the arrangement intact when the view was filtered in between", () => {
|
||||
const arranged = reindex(all, "d", "a")!;
|
||||
const positioned = all.map((n) => ({ ...n, sortOrder: arranged.indexOf(n.id) }));
|
||||
|
||||
// The user searches, sees only two notes, then goes back to manual order.
|
||||
const filtered = positioned.filter((n) => n.id === "b" || n.id === "d");
|
||||
expect(ids(sortNotes(filtered, "manual"))).toEqual(["d", "b"]);
|
||||
|
||||
// Clearing the search must show exactly what was arranged.
|
||||
expect(ids(sortNotes(positioned, "manual"))).toEqual(arranged);
|
||||
});
|
||||
|
||||
it("renumbers every note in the notebook, not only the ones on screen", () => {
|
||||
const order = reindex(all, "c", "a")!;
|
||||
expect(order).toHaveLength(all.length);
|
||||
expect(new Set(order)).toEqual(new Set(ids(all)));
|
||||
});
|
||||
});
|
||||
|
||||
describe("canReorder", () => {
|
||||
it("allows dragging only in manual order", () => {
|
||||
expect(canReorder("manual", false)).toBe(true);
|
||||
expect(canReorder("updated", false)).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses while a search is narrowing the list", () => {
|
||||
expect(canReorder("manual", true)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user