Ask what a single note's export should contain, and fix drawings never being in one
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
2611d87f8c
commit
8c36c6121b
5 files changed
+284
-57
No files matched your search
+79
-3
@@ -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" });
|
||||
|
||||
@@ -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<Uint8Array | null> {
|
||||
const doc = strokeDocOf(strokes);
|
||||
if (!doc?.strokes?.length) return null;
|
||||
async function drawingToPng(doc: unknown): Promise<Uint8Array | null> {
|
||||
// `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>): 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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string> =>
|
||||
export async function exportNotesAsPdf(
|
||||
metas: NoteMeta[],
|
||||
title: string,
|
||||
onProgress?: (p: ExportProgress) => void
|
||||
onProgress?: (p: ExportProgress) => void,
|
||||
parts: ExportParts = "both"
|
||||
): Promise<void> {
|
||||
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`,
|
||||
|
||||
@@ -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<string, string>;
|
||||
/** 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 `<article>
|
||||
<h1>${esc(note.meta.title || "Untitled")}</h1>
|
||||
${when ? `<div class="meta">${esc(when)}</div>` : ""}
|
||||
${blocks(doc.content, resolve)}
|
||||
${body}
|
||||
${note.drawing ? `<div class="drawing"><img src="${note.drawing}" alt="Zeichnung"></div>` : ""}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
<button
|
||||
onClick={() => run(kind)}
|
||||
disabled={!!busy}
|
||||
key={title}
|
||||
onClick={onClick}
|
||||
disabled={disabled || !!busy}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
gap: 4,
|
||||
width: "100%",
|
||||
padding: "14px 16px",
|
||||
padding: "13px 16px",
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${T.border}`,
|
||||
background: T.surface,
|
||||
cursor: busy ? "default" : "pointer",
|
||||
border: `1px solid ${active ? T.accent : T.border}`,
|
||||
background: active ? T.accent + "14" : T.surface,
|
||||
cursor: disabled || busy ? "default" : "pointer",
|
||||
textAlign: "left",
|
||||
opacity: busy && busy !== kind ? 0.45 : 1,
|
||||
opacity: disabled ? 0.4 : busy && busy !== active ? 0.5 : 1,
|
||||
marginBottom: 10,
|
||||
fontFamily: FONT_UI,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: T.text }}>{busy === kind ? "Läuft…" : title}</span>
|
||||
{lines.map((l, i) => (
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: T.text }}>{title}</span>
|
||||
{lines.filter(Boolean).map((l, i) => (
|
||||
<span key={i} style={{ fontSize: 12, color: T.muted, lineHeight: 1.5 }}>
|
||||
{l}
|
||||
</span>
|
||||
@@ -97,6 +122,84 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
||||
</button>
|
||||
);
|
||||
|
||||
/* ── step one: what should come along ── */
|
||||
const stepParts = () => (
|
||||
<>
|
||||
<div style={{ fontSize: 12, color: T.muted, marginBottom: 10 }}>Was soll mit?</div>
|
||||
{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 && (
|
||||
<button
|
||||
onClick={() => setParts(null)}
|
||||
disabled={!!busy}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: 0,
|
||||
marginBottom: 12,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: busy ? "default" : "pointer",
|
||||
color: T.accent,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{I.back} Zurück
|
||||
</button>
|
||||
)}
|
||||
<div style={{ fontSize: 12, color: T.muted, marginBottom: 10 }}>In welcher Form?</div>
|
||||
{options}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const subtitle = single ? label : `${label} · ${notes.length} Notiz${notes.length === 1 ? "" : "en"}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -134,12 +237,21 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
||||
borderBottom: `1px solid ${T.border}`,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 17, fontWeight: 600, color: T.text, fontFamily: FONT_SERIF }}>
|
||||
Exportieren
|
||||
</h3>
|
||||
<div style={{ fontSize: 12, color: T.muted, marginTop: 2 }}>
|
||||
{single ? label : `${label} · ${notes.length} Notizen`}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: T.muted,
|
||||
marginTop: 2,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -155,6 +267,7 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
||||
background: "transparent",
|
||||
cursor: busy ? "default" : "pointer",
|
||||
color: T.muted,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{I.x}
|
||||
@@ -162,19 +275,16 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "16px 22px 8px" }}>
|
||||
{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 ? (
|
||||
<div style={{ fontSize: 13, color: T.muted, padding: "12px 0" }}>Notiz wird gelesen…</div>
|
||||
) : parts === null ? (
|
||||
stepParts()
|
||||
) : (
|
||||
stepFormat()
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(progress || err || done) && (
|
||||
{parts !== null && (progress || err || done) && (
|
||||
<div style={{ padding: "0 22px 8px" }}>
|
||||
{progress && (
|
||||
<div style={{ fontSize: 12, color: T.muted }}>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user