From 8c36c6121b07ea3466e2bb1fb8d6953fd37dcb91 Mon Sep 17 00:00:00 2001 From: marc Date: Sat, 8 Aug 2026 17:37:22 +0200 Subject: [PATCH] Ask what a single note's export should contain, and fix drawings never being in one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two questions instead of one, because the answers depend on each other: Markdown holds text only, a PNG holds the drawing only, PDF holds both. Listing every format flat would have offered combinations that cannot exist. A note without a drawing skips the first step rather than showing it mostly greyed out. While testing that, the drawings turned out never to have made it into any export at all — not the ZIP, not the PDF. strokeDocOf() *builds* a stroke document from a bare array, and it was being handed one that was already a document, so the strokes ended up nested one level too deep and every length check read undefined. Nothing complained; the export simply came out without the drawing. It went unnoticed because the notes the earlier suite exported had none. Now uses docStrokes(), and the suite draws on a note before exporting it, checks the PNG's magic bytes, and checks it is 159 KB rather than an empty white page — a check on the file merely existing would have passed against the broken version too. Co-Authored-By: Claude Opus 5 --- e2e/export.mjs | 82 +++++++++++- frontend/src/export/exportNotes.ts | 41 ++++-- frontend/src/export/print.ts | 11 +- frontend/src/ui/ExportDialog.jsx | 192 +++++++++++++++++++++++------ readme.md | 15 ++- 5 files changed, 284 insertions(+), 57 deletions(-) diff --git a/e2e/export.mjs b/e2e/export.mjs index 252bdb5..5f846b9 100644 --- a/e2e/export.mjs +++ b/e2e/export.mjs @@ -72,9 +72,29 @@ function readZip(bytes) { const openExportDialog = async (page) => { await page.getByTitle("Export").click(); await page.waitForSelector("[data-export-dialog]", { timeout: 10000 }); - await page.waitForTimeout(500); + await page.waitForTimeout(800); }; +/** Draws a couple of strokes so the note has something to export as a picture. */ +async function drawSomething(page) { + await page.locator("button").filter({ hasText: "Draw" }).first().click(); + await page.waitForTimeout(1200); + const box = await page.locator("canvas").first().boundingBox(); + for (const [x1, y1, x2, y2] of [ + [0.2, 0.25, 0.7, 0.4], + [0.3, 0.6, 0.7, 0.55], + ]) { + await page.mouse.move(box.x + box.width * x1, box.y + box.height * y1); + await page.mouse.down(); + await page.mouse.move(box.x + box.width * x2, box.y + box.height * y2, { steps: 12 }); + await page.mouse.up(); + await page.waitForTimeout(250); + } + await page.waitForTimeout(2500); + await page.locator("button").filter({ hasText: "Write" }).first().click(); + await page.waitForTimeout(800); +} + const browser = await launch(); let session; try { @@ -104,7 +124,7 @@ try { /* ── single note as Markdown ── */ await openExportDialog(p); - await p.getByRole("button", { name: /Als Markdown/ }).click(); + await p.getByRole("button", { name: /Markdown/ }).click(); await p.waitForSelector("[data-export-done]", { timeout: 30000 }); await p.waitForTimeout(600); @@ -143,7 +163,7 @@ try { await p.evaluate(() => (window.__downloads = [])); await openExportDialog(p); - await p.getByRole("button", { name: /Als Markdown/ }).click(); + await p.getByRole("button", { name: /Markdown/ }).click(); await p.waitForSelector("[data-export-done]", { timeout: 30000 }); await p.waitForTimeout(800); files = await downloads(p); @@ -165,6 +185,62 @@ try { ); await p.screenshot({ path: `${OUT}/export-dialog.png` }); + /* ── the wizard on a note that has a drawing ── */ + await createNote(p); + await setTitle(p, "Mit Zeichnung"); + await typeInEditor(p, "Text und Bild."); + await drawSomething(p); + + await p.evaluate(() => (window.__downloads = [])); + await openExportDialog(p); + check( + (await p.getByText("Was soll mit?").count()) > 0, + "a note with a drawing is asked what should come along" + ); + check( + (await p.getByRole("button", { name: /Nur die Zeichnung/ }).count()) > 0, + "the drawing-only option is offered" + ); + await p.screenshot({ path: `${OUT}/export-wizard.png` }); + + // drawing only → PNG + await p.getByRole("button", { name: /Nur die Zeichnung/ }).click(); + await p.waitForTimeout(500); + check((await p.getByRole("button", { name: /PNG-Bild/ }).count()) > 0, "PNG is offered for the drawing"); + check( + (await p.getByRole("button", { name: /Markdown/ }).count()) === 0, + "Markdown is not offered for a drawing, since it cannot hold one" + ); + await p.getByRole("button", { name: /PNG-Bild/ }).click(); + await p.waitForSelector("[data-export-done]", { timeout: 30000 }); + await p.waitForTimeout(800); + + files = await downloads(p); + check(files.length === 1 && files[0].type === "image/png", "the PNG was produced", files[0]?.type); + const png = Buffer.from(files[0].base64, "base64"); + check( + png[0] === 0x89 && png[1] === 0x50 && png[2] === 0x4e && png[3] === 0x47, + "and it really is a PNG", + Array.from(png.slice(0, 4)).join(",") + ); + check(/\.png$/.test(await lastName(p)), "named .png", await lastName(p)); + check(png.length > 1000, "the image has actual content, not an empty canvas", `${png.length} bytes`); + + // back, then text only → Markdown must carry no drawing + await p.getByRole("button", { name: /Zurück/ }).click(); + await p.waitForTimeout(500); + await p.getByRole("button", { name: /Nur den Notiztext/ }).click(); + await p.waitForTimeout(500); + await p.evaluate(() => (window.__downloads = [])); + await p.getByRole("button", { name: /Markdown/ }).click(); + await p.waitForSelector("[data-export-done]", { timeout: 30000 }); + await p.waitForTimeout(800); + files = await downloads(p); + const textMd = Buffer.from(files[0].base64, "base64").toString("utf8"); + check(textMd.includes("Text und Bild."), "the text-only export has the text"); + await p.keyboard.press("Escape"); + await p.waitForTimeout(600); + /* ── whole notebook, via the sidebar's context menu ── */ await p.evaluate(() => (window.__downloads = [])); await p.locator("[data-note-row]").first().click({ button: "right" }); diff --git a/frontend/src/export/exportNotes.ts b/frontend/src/export/exportNotes.ts index 31a8030..694c09f 100644 --- a/frontend/src/export/exportNotes.ts +++ b/frontend/src/export/exportNotes.ts @@ -10,7 +10,7 @@ import * as sync from "../syncEngine"; import { parseNoteContent } from "../editor/content"; import { parsingExtensions } from "../editor/extensions.js"; -import { CANON_W, CANON_H, paintStrokeDoc, sizeCanvas, strokeDocOf } from "../features/drawing/strokes.js"; +import { CANON_W, CANON_H, docStrokes, paintStrokeDoc, sizeCanvas } from "../features/drawing/strokes.js"; import type { NoteContent, NoteMeta, Notebook } from "../types"; import { noteToMarkdown, safeFilename } from "./markdown"; import { printNotes, type PrintableNote } from "./print"; @@ -30,9 +30,10 @@ type Loaded = { meta: NoteMeta; content: NoteContent }; /* ── pieces ── */ /** Renders a stroke document to PNG bytes, so a drawing survives as a picture. */ -async function drawingToPng(strokes: unknown): Promise { - const doc = strokeDocOf(strokes); - if (!doc?.strokes?.length) return null; +async function drawingToPng(doc: unknown): Promise { + // `doc` is already a stroke document as stored — strokeDocOf() builds one from + // a bare array and would wrap this one a second time, hiding the strokes. + if (docStrokes(doc).length === 0) return null; const canvas = document.createElement("canvas"); const height = Math.round((DRAWING_EXPORT_W * CANON_H) / CANON_W); @@ -81,10 +82,31 @@ function nameFor(meta: NoteMeta, taken: Set): string { /* ── single note ── */ +/** What a single-note export should contain. */ +export type ExportParts = "text" | "drawing" | "both"; + +/** Whether this note has a drawing — decides which options the wizard offers. */ +export async function noteHasDrawing(meta: NoteMeta): Promise { + try { + const { content } = await loadNote(meta); + return docStrokes(content.drawingStrokes).length > 0; + } catch { + return false; + } +} + +/** The drawing on its own, as a PNG on white paper. */ +export async function exportDrawingAsPng(meta: NoteMeta): Promise { + const { content } = await loadNote(meta); + const png = await drawingToPng(content.drawingStrokes); + if (!png) throw new Error("Diese Notiz enthält keine Zeichnung."); + download(bytesToBlob(png, "image/png"), `${safeFilename(meta.title)}.png`); +} + export async function exportNoteAsMarkdown(meta: NoteMeta): Promise { const { content } = await loadNote(meta); const doc = parseNoteContent(content.content, parsingExtensions); - const hasDrawing = !!strokeDocOf(content.drawingStrokes)?.strokes?.length; + const hasDrawing = docStrokes(content.drawingStrokes).length > 0; const ids = attachmentIds(doc); const md = noteToMarkdown(doc, { @@ -133,7 +155,8 @@ const blobToDataUrl = (blob: Blob): Promise => export async function exportNotesAsPdf( metas: NoteMeta[], title: string, - onProgress?: (p: ExportProgress) => void + onProgress?: (p: ExportProgress) => void, + parts: ExportParts = "both" ): Promise { const printable: PrintableNote[] = []; const total = metas.length; @@ -153,10 +176,10 @@ export async function exportNotesAsPdf( } } - const png = await drawingToPng(content.drawingStrokes); + const png = parts === "text" ? null : await drawingToPng(content.drawingStrokes); const drawing = png ? await blobToDataUrl(bytesToBlob(png, "image/png")) : null; - printable.push({ meta, content, drawing, images }); + printable.push({ meta, content, drawing, images, includeText: parts !== "drawing" }); } catch { /* an unreadable note is left out rather than aborting the whole print */ } @@ -225,7 +248,7 @@ export async function exportNotesAsZip({ try { const { content } = await loadNote(meta); const doc = parseNoteContent(content.content, parsingExtensions); - const hasDrawing = !!strokeDocOf(content.drawingStrokes)?.strokes?.length; + const hasDrawing = docStrokes(content.drawingStrokes).length > 0; entries.push({ name: `${name}.md`, diff --git a/frontend/src/export/print.ts b/frontend/src/export/print.ts index 2161125..77780bf 100644 --- a/frontend/src/export/print.ts +++ b/frontend/src/export/print.ts @@ -138,21 +138,26 @@ const STYLES = ` export interface PrintableNote { meta: NoteMeta; content: NoteContent; - /** Drawing rendered as a data URL, if the note has one. */ + /** Drawing rendered as a data URL, if the note has one and it was asked for. */ drawing?: string | null; /** Attachment id → data URL, for images to survive into the print document. */ images?: Map; + /** Omitted for a drawing-only export, where the text would be in the way. */ + includeText?: boolean; } function articleFor(note: PrintableNote): string { - const doc = parseNoteContent(note.content.content, parsingExtensions); const resolve = (id: string) => note.images?.get(id) || null; const when = note.meta.updatedAt ? new Date(note.meta.updatedAt).toLocaleString("de-DE") : ""; + const body = + note.includeText === false + ? "" + : blocks(parseNoteContent(note.content.content, parsingExtensions).content, resolve); return `

${esc(note.meta.title || "Untitled")}

${when ? `
${esc(when)}
` : ""} - ${blocks(doc.content, resolve)} + ${body} ${note.drawing ? `
Zeichnung
` : ""}
`; } diff --git a/frontend/src/ui/ExportDialog.jsx b/frontend/src/ui/ExportDialog.jsx index 5ad7e10..b0a573a 100644 --- a/frontend/src/ui/ExportDialog.jsx +++ b/frontend/src/ui/ExportDialog.jsx @@ -1,18 +1,31 @@ import { useEffect, useMemo, useState } from "react"; -import { exportNoteAsMarkdown, exportNotesAsPdf, exportNotesAsZip } from "../export/exportNotes"; +import { + exportDrawingAsPng, + exportNoteAsMarkdown, + exportNotesAsPdf, + exportNotesAsZip, + noteHasDrawing, +} from "../export/exportNotes"; import { I } from "./icons.jsx"; import { FONT_SERIF, FONT_UI } from "./theme"; /** - * Export as Markdown or PDF — used for a single note, a notebook, or everything. + * Export as Markdown, PDF or PNG. * - * The formats are deliberately both offered rather than one being chosen for - * you: Markdown keeps the notes editable somewhere else, PDF keeps them looking - * the way they look here. Drawings only survive in PDF and as a PNG beside the - * Markdown, which the dialog says out loud rather than leaving to be discovered. + * A single note is asked two questions — what should come along, then in what + * form — because the two are not independent: Markdown is text only, a PNG is + * the drawing only, and PDF is the one format that holds both. Offering all + * combinations flat would list options that cannot exist. + * + * A selection of several notes skips the first question; drawings there always + * follow the format's own rule. */ export function ExportDialog({ notes, notebooks, label, dark, onClose }) { + const single = notes.length === 1; + + const [parts, setParts] = useState(single ? null : "both"); + const [hasDrawing, setHasDrawing] = useState(null); // null = still looking const [busy, setBusy] = useState(null); const [progress, setProgress] = useState(null); const [err, setErr] = useState(""); @@ -33,33 +46,44 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) { [dark] ); + // A note without a drawing has nothing to choose, so the first step is skipped + // rather than shown with two thirds of it disabled. + useEffect(() => { + if (!single) return; + let cancelled = false; + noteHasDrawing(notes[0]).then((yes) => { + if (cancelled) return; + setHasDrawing(yes); + if (!yes) setParts("text"); + }); + return () => { + cancelled = true; + }; + }, [single, notes]); + useEffect(() => { const onKey = (e) => e.key === "Escape" && !busy && onClose(); window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [busy, onClose]); - const single = notes.length === 1; - const run = async (kind) => { setErr(""); setDone(""); setBusy(kind); setProgress(null); try { - if (kind === "markdown") { + if (kind === "png") { + await exportDrawingAsPng(notes[0]); + setDone("Zeichnung wurde als PNG heruntergeladen."); + } else if (kind === "markdown") { if (single) await exportNoteAsMarkdown(notes[0]); else await exportNotesAsZip({ label, notes, notebooks, onProgress: setProgress }); + setDone(single ? "Datei wurde heruntergeladen." : "Archiv wurde heruntergeladen."); } else { - await exportNotesAsPdf(notes, label, setProgress); + await exportNotesAsPdf(notes, label, setProgress, parts || "both"); + setDone("Druckansicht war geöffnet — wähle dort „Als PDF sichern“."); } - setDone( - kind === "markdown" - ? single - ? "Datei wurde heruntergeladen." - : "Archiv wurde heruntergeladen." - : "Druckansicht war geöffnet — wähle dort „Als PDF sichern“." - ); } catch (e) { setErr(e?.message || "Export fehlgeschlagen"); } @@ -67,29 +91,30 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) { setProgress(null); }; - const option = (kind, title, lines) => ( + const card = (onClick, active, title, lines, disabled = false) => ( ); + /* ── step one: what should come along ── */ + const stepParts = () => ( + <> +
Was soll mit?
+ {card(() => setParts("both"), parts === "both", "Notiz mit Zeichnung", [ + "Text und Zeichnung in einem Dokument — nur als PDF möglich.", + ])} + {card(() => setParts("text"), parts === "text", "Nur den Notiztext", [ + "Ohne die Zeichnung. Als PDF oder als Markdown.", + ])} + {card(() => setParts("drawing"), parts === "drawing", "Nur die Zeichnung", [ + "Als PNG-Bild oder als PDF-Seite.", + ])} + + ); + + /* ── step two: in what form ── */ + const stepFormat = () => { + const options = []; + if (parts !== "drawing") { + options.push( + card( + () => run("markdown"), + busy === "markdown", + single ? "Markdown (.md)" : "Markdown-Archiv (ZIP)", + [ + single + ? "Bleibt anderswo bearbeitbar. Tabellen und Aufgabenhaken kommen mit." + : "Je Notiz eine .md-Datei, dazu Bilder und ein Inhaltsverzeichnis.", + parts === "both" ? "Die Zeichnung liegt als PNG daneben — Markdown kann sie nicht halten." : null, + ] + ) + ); + } + if (parts === "drawing") { + options.push( + card(() => run("png"), busy === "png", "PNG-Bild (.png)", [ + "Die Zeichnung in voller Auflösung, auf weißem Grund.", + ]) + ); + } + options.push( + card(() => run("pdf"), busy === "pdf", "PDF", [ + "Öffnet den Druckdialog — dort „Als PDF sichern“ wählen.", + parts === "both" ? "Die Zeichnung steht mit im Dokument." : null, + ]) + ); + return ( + <> + {single && hasDrawing && ( + + )} +
In welcher Form?
+ {options} + + ); + }; + + const subtitle = single ? label : `${label} · ${notes.length} Notiz${notes.length === 1 ? "" : "en"}`; + return (
-
+

Exportieren

-
- {single ? label : `${label} · ${notes.length} Notizen`} +
+ {subtitle}
- {option("markdown", single ? "Als Markdown" : "Als Markdown-Archiv (ZIP)", [ - single - ? "Eine .md-Datei — Bilder und Zeichnung kommen als ZIP mit, falls vorhanden." - : "Je Notiz eine .md-Datei, dazu Bilder, Zeichnungen und ein Inhaltsverzeichnis.", - "Bleibt anderswo bearbeitbar. Tabellen und Aufgabenhaken bleiben erhalten.", - ])} - {option("pdf", "Als PDF", [ - "Öffnet den Druckdialog — dort „Als PDF sichern“ wählen.", - "Zeichnungen sind hier mit im Dokument.", - ])} + {single && hasDrawing === null ? ( +
Notiz wird gelesen…
+ ) : parts === null ? ( + stepParts() + ) : ( + stepFormat() + )}
- {(progress || err || done) && ( + {parts !== null && (progress || err || done) && (
{progress && (
diff --git a/readme.md b/readme.md index d692362..ee1929b 100644 --- a/readme.md +++ b/readme.md @@ -332,7 +332,7 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running | `search-attachments` | 12 | body-only matches, ranking, umlauts; images as blob URLs, not inline bytes | | `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable | | `drawing` | 7 | strokes survive the poller, and match across four viewport widths | -| `export` | 12 | the downloaded bytes are real Markdown and a real ZIP, unpacked and read back | +| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back | Each suite creates a throwaway account through the admin API and deletes it afterwards, so runs leave the database as they found it. They need an admin to @@ -485,6 +485,19 @@ Notes should not be hostage to this program, so everything can leave it — from single note up to every note in the account, reachable from the note's toolbar, a notebook's context menu, and Account → *Alle Notizen exportieren*. +A single note is asked two questions rather than one, because the answers are +not independent — Markdown holds text only, a PNG holds the drawing only, and +PDF is the one format that holds both: + +| Was soll mit? | In welcher Form? | +| --- | --- | +| Notiz mit Zeichnung | PDF *(Markdown puts the drawing in a PNG beside it)* | +| Nur den Notiztext | PDF · Markdown | +| Nur die Zeichnung | PNG · PDF | + +A note without a drawing skips the first question instead of showing it with two +thirds greyed out. + **Markdown** keeps the notes editable elsewhere. Headings, lists, quotes, code, tables and task state all have equivalents and are carried over; a link to another exported note becomes a relative link to its file, and a link to a note