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:
marcandClaude Opus 5 committed 2026-08-09 01:38:03 +02:00
1 parent 3acb8b66a4
commit 0a0864c58b
27 files changed
+2962 -86

No files matched your search

+294
View File
@@ -0,0 +1,294 @@
import { describe, expect, it } from "vitest";
import { noteToMarkdown } from "../export/markdown";
import { inlineNodes, markdownToNote } from "./markdownImport";
import type { DocNode } from "../editor/content";
const doc = (md: string) => markdownToNote(md).doc;
const first = (md: string) => doc(md).content![0]!;
const plain = (n: DocNode): string => {
if (n.text) return n.text;
return (n.content ?? []).map(plain).join("");
};
describe("blocks", () => {
it("reads headings, capping them at the three levels the editor has", () => {
expect(first("# Titel\n\nrumpf").type).toBe("paragraph"); // level 1 becomes the title
expect(first("## Zwei")).toMatchObject({ type: "heading", attrs: { level: 2 } });
expect(first("##### Fünf")).toMatchObject({ type: "heading", attrs: { level: 3 } });
});
it("keeps a fenced code block verbatim, markup and all", () => {
const block = first("```js\nconst a = **not bold**;\n```");
expect(block).toMatchObject({ type: "codeBlock", attrs: { language: "js" } });
expect(plain(block)).toBe("const a = **not bold**;");
});
it("reads a horizontal rule", () => {
expect(first("---").type).toBe("horizontalRule");
});
it("reads a blockquote, including the blocks inside it", () => {
const quote = first("> ## Kopf\n> Text");
expect(quote.type).toBe("blockquote");
expect(quote.content![0]!.type).toBe("heading");
});
it("joins the lines of a paragraph", () => {
expect(plain(first("erste Zeile\nzweite Zeile"))).toBe("erste Zeile zweite Zeile");
});
it("stops a paragraph at the block that follows it", () => {
const content = doc("Absatz\n## Überschrift").content!;
expect(content.map((n) => n.type)).toEqual(["paragraph", "heading"]);
});
});
describe("lists", () => {
it("reads a bullet list", () => {
const list = first("- eins\n- zwei");
expect(list.type).toBe("bulletList");
expect(list.content).toHaveLength(2);
expect(plain(list.content![1]!)).toBe("zwei");
});
it("reads an ordered list and keeps where it starts", () => {
expect(first("3. drei\n4. vier")).toMatchObject({ type: "orderedList", attrs: { start: 3 } });
});
it("reads a task list with its boxes", () => {
const list = first("- [x] erledigt\n- [ ] offen");
expect(list.type).toBe("taskList");
expect(list.content![0]).toMatchObject({ type: "taskItem", attrs: { checked: true } });
expect(list.content![1]).toMatchObject({ type: "taskItem", attrs: { checked: false } });
expect(plain(list.content![0]!)).toBe("erledigt");
});
it("splits where a list stops having checkboxes, rather than losing them", () => {
// The editor has no node for "a list where some items have a box", so the
// run ends and a second list begins. The alternative — one list with a
// literal "[x]" in the text — throws away what the boxes meant.
const content = doc("- [x] erledigt\n- einfach").content!;
expect(content.map((n) => n.type)).toEqual(["taskList", "bulletList"]);
});
it("nests by indentation", () => {
const outer = first("- oben\n - innen");
const inner = outer.content![0]!.content!.find((n) => n.type === "bulletList");
expect(inner).toBeDefined();
expect(plain(inner!)).toBe("innen");
});
});
describe("tables", () => {
it("reads a table with a header row", () => {
const table = first("| A | B |\n| --- | --- |\n| 1 | 2 |");
expect(table.type).toBe("table");
expect(table.content).toHaveLength(2);
expect(table.content![0]!.content![0]!.type).toBe("tableHeader");
expect(plain(table.content![1]!.content![1]!)).toBe("2");
});
it("recognises the empty header the exporter writes for a headerless table", () => {
const table = first("| | |\n| --- | --- |\n| 1 | 2 |");
expect(table.content).toHaveLength(1);
expect(table.content![0]!.content![0]!.type).toBe("tableCell");
});
it("pads a short row so every row has the same number of cells", () => {
const table = first("| A | B |\n| --- | --- |\n| 1 |");
expect(table.content![1]!.content).toHaveLength(2);
});
});
describe("inline", () => {
const marksOf = (md: string) => inlineNodes(md).map((n) => (n as any).marks?.map((m: any) => m.type) ?? []);
it("reads bold, italic, strikethrough and code", () => {
expect(marksOf("**b**")).toEqual([["bold"]]);
expect(marksOf("*i*")).toEqual([["italic"]]);
expect(marksOf("~~s~~")).toEqual([["strike"]]);
expect(marksOf("`c`")).toEqual([["code"]]);
});
it("nests marks", () => {
expect(marksOf("**_beides_**")).toEqual([["bold", "italic"]]);
});
it("leaves underscores inside a word alone", () => {
// snake_case_names are the most common thing a naive parser mangles.
expect(plain({ content: inlineNodes("snake_case_name") } as DocNode)).toBe("snake_case_name");
expect(marksOf("snake_case_name")).toEqual([[]]);
});
it("does not read markup inside a code span", () => {
expect(plain({ content: inlineNodes("`**roh**`") } as DocNode)).toBe("**roh**");
});
it("reads a link", () => {
const [node] = inlineNodes("[Beispiel](https://example.com)");
expect((node as any).marks[0]).toMatchObject({ type: "link", attrs: { href: "https://example.com" } });
});
it("reads an image and reports its path", () => {
const parsed = markdownToNote("![Bild](bilder/abc.png)");
expect(parsed.imagePaths).toEqual(["bilder/abc.png"]);
});
it("reads the HTML underline the exporter falls back to", () => {
expect(marksOf("<u>x</u>")).toEqual([["underline"]]);
});
it("treats a lone asterisk as a character, not an unterminated mark", () => {
expect(plain({ content: inlineNodes("2 * 3 = 6") } as DocNode)).toBe("2 * 3 = 6");
});
it("undoes the exporter's escapes without touching a Windows path", () => {
expect(plain({ content: inlineNodes("a \\* b") } as DocNode)).toBe("a * b");
expect(plain({ content: inlineNodes("C:\\temp\\neu") } as DocNode)).toBe("C:\\temp\\neu");
});
});
describe("the title", () => {
it("comes from a leading level-one heading", () => {
expect(markdownToNote("# Mein Titel\n\nText").title).toBe("Mein Titel");
});
it("drops the timestamp line the exporter writes under it", () => {
const parsed = markdownToNote("# T\n\n*07.08.2026, 12:00:00*\n\nText");
expect(parsed.doc.content).toHaveLength(1);
expect(plain(parsed.doc.content![0]!)).toBe("Text");
});
it("is absent when the file does not start with one", () => {
expect(markdownToNote("Nur Text").title).toBeNull();
});
it("keeps a level-one heading further down as a heading", () => {
const parsed = markdownToNote("Text\n\n# Später");
expect(parsed.title).toBeNull();
expect(parsed.doc.content!.map((n) => n.type)).toEqual(["paragraph", "heading"]);
});
});
describe("an empty file", () => {
it("still produces a document the editor can open", () => {
expect(markdownToNote("").doc).toEqual({ type: "doc", content: [{ type: "paragraph" }] });
});
});
/* The pair matters more than either half: what leaves has to be able to come
* back. These run a document through the exporter and read the result. */
describe("round trip", () => {
const roundTrip = (source: DocNode) => markdownToNote(noteToMarkdown(source)).doc;
it("brings back headings, lists, tasks, quotes, code and rules", () => {
const source: DocNode = {
type: "doc",
content: [
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Kopf" }] },
{ type: "paragraph", content: [{ type: "text", text: "Ein Absatz." }] },
{
type: "bulletList",
content: [
{ type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "eins" }] }] },
{ type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "zwei" }] }] },
],
},
{
type: "taskList",
content: [
{
type: "taskItem",
attrs: { checked: true },
content: [{ type: "paragraph", content: [{ type: "text", text: "getan" }] }],
},
],
},
{
type: "blockquote",
content: [{ type: "paragraph", content: [{ type: "text", text: "zitiert" }] }],
},
{ type: "codeBlock", attrs: { language: "ts" }, content: [{ type: "text", text: "const a = 1;" }] },
{ type: "horizontalRule" },
],
};
const back = roundTrip(source);
expect(back.content!.map((n) => n.type)).toEqual([
"heading",
"paragraph",
"bulletList",
"taskList",
"blockquote",
"codeBlock",
"horizontalRule",
]);
expect(plain(back)).toContain("getan");
expect(back.content![5]!.content![0]!.text).toBe("const a = 1;");
});
it("brings back the marks on a sentence", () => {
const source: DocNode = {
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "fett", marks: [{ type: "bold" }] } as DocNode,
{ type: "text", text: " und " },
{ type: "text", text: "kursiv", marks: [{ type: "italic" }] } as DocNode,
],
},
],
};
const back = roundTrip(source);
expect(plain(back)).toBe("fett und kursiv");
const marks = back.content![0]!.content!.map((n) => (n as any).marks?.[0]?.type);
expect(marks).toEqual(["bold", undefined, "italic"]);
});
it("brings back a table with its header", () => {
const cell = (type: string, text: string): DocNode => ({
type,
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
});
const source: DocNode = {
type: "doc",
content: [
{
type: "table",
content: [
{ type: "tableRow", content: [cell("tableHeader", "A"), cell("tableHeader", "B")] },
{ type: "tableRow", content: [cell("tableCell", "1"), cell("tableCell", "2")] },
],
},
],
};
const back = roundTrip(source);
const table = back.content![0]!;
expect(table.type).toBe("table");
expect(table.content![0]!.content![0]!.type).toBe("tableHeader");
expect(plain(table)).toContain("2");
});
it("keeps a title through the round trip instead of doubling it into the body", () => {
const source: DocNode = {
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: "Inhalt" }] }],
};
const md = noteToMarkdown(source, { title: "Einkaufsliste", updatedAt: "2026-08-07T10:00:00Z" });
const parsed = markdownToNote(md);
expect(parsed.title).toBe("Einkaufsliste");
expect(plain(parsed.doc)).toBe("Inhalt");
});
it("keeps a picture's path so it can be matched back to the archive", () => {
const source: DocNode = {
type: "doc",
content: [{ type: "image", attrs: { attachmentId: "abc123", alt: "Foto" } }],
};
const md = noteToMarkdown(source, { attachmentPath: (id) => `bilder/${id}.png` });
expect(markdownToNote(md).imagePaths).toEqual(["bilder/abc123.png"]);
});
});