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) => {
|
const openExportDialog = async (page) => {
|
||||||
await page.getByTitle("Export").click();
|
await page.getByTitle("Export").click();
|
||||||
await page.waitForSelector("[data-export-dialog]", { timeout: 10000 });
|
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();
|
const browser = await launch();
|
||||||
let session;
|
let session;
|
||||||
try {
|
try {
|
||||||
@@ -104,7 +124,7 @@ try {
|
|||||||
|
|
||||||
/* ── single note as Markdown ── */
|
/* ── single note as Markdown ── */
|
||||||
await openExportDialog(p);
|
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.waitForSelector("[data-export-done]", { timeout: 30000 });
|
||||||
await p.waitForTimeout(600);
|
await p.waitForTimeout(600);
|
||||||
|
|
||||||
@@ -143,7 +163,7 @@ try {
|
|||||||
|
|
||||||
await p.evaluate(() => (window.__downloads = []));
|
await p.evaluate(() => (window.__downloads = []));
|
||||||
await openExportDialog(p);
|
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.waitForSelector("[data-export-done]", { timeout: 30000 });
|
||||||
await p.waitForTimeout(800);
|
await p.waitForTimeout(800);
|
||||||
files = await downloads(p);
|
files = await downloads(p);
|
||||||
@@ -165,6 +185,62 @@ try {
|
|||||||
);
|
);
|
||||||
await p.screenshot({ path: `${OUT}/export-dialog.png` });
|
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 ── */
|
/* ── whole notebook, via the sidebar's context menu ── */
|
||||||
await p.evaluate(() => (window.__downloads = []));
|
await p.evaluate(() => (window.__downloads = []));
|
||||||
await p.locator("[data-note-row]").first().click({ button: "right" });
|
await p.locator("[data-note-row]").first().click({ button: "right" });
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import * as sync from "../syncEngine";
|
import * as sync from "../syncEngine";
|
||||||
import { parseNoteContent } from "../editor/content";
|
import { parseNoteContent } from "../editor/content";
|
||||||
import { parsingExtensions } from "../editor/extensions.js";
|
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 type { NoteContent, NoteMeta, Notebook } from "../types";
|
||||||
import { noteToMarkdown, safeFilename } from "./markdown";
|
import { noteToMarkdown, safeFilename } from "./markdown";
|
||||||
import { printNotes, type PrintableNote } from "./print";
|
import { printNotes, type PrintableNote } from "./print";
|
||||||
@@ -30,9 +30,10 @@ type Loaded = { meta: NoteMeta; content: NoteContent };
|
|||||||
/* ── pieces ── */
|
/* ── pieces ── */
|
||||||
|
|
||||||
/** Renders a stroke document to PNG bytes, so a drawing survives as a picture. */
|
/** Renders a stroke document to PNG bytes, so a drawing survives as a picture. */
|
||||||
async function drawingToPng(strokes: unknown): Promise<Uint8Array | null> {
|
async function drawingToPng(doc: unknown): Promise<Uint8Array | null> {
|
||||||
const doc = strokeDocOf(strokes);
|
// `doc` is already a stroke document as stored — strokeDocOf() builds one from
|
||||||
if (!doc?.strokes?.length) return null;
|
// 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 canvas = document.createElement("canvas");
|
||||||
const height = Math.round((DRAWING_EXPORT_W * CANON_H) / CANON_W);
|
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 ── */
|
/* ── 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> {
|
export async function exportNoteAsMarkdown(meta: NoteMeta): Promise<void> {
|
||||||
const { content } = await loadNote(meta);
|
const { content } = await loadNote(meta);
|
||||||
const doc = parseNoteContent(content.content, parsingExtensions);
|
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 ids = attachmentIds(doc);
|
||||||
|
|
||||||
const md = noteToMarkdown(doc, {
|
const md = noteToMarkdown(doc, {
|
||||||
@@ -133,7 +155,8 @@ const blobToDataUrl = (blob: Blob): Promise<string> =>
|
|||||||
export async function exportNotesAsPdf(
|
export async function exportNotesAsPdf(
|
||||||
metas: NoteMeta[],
|
metas: NoteMeta[],
|
||||||
title: string,
|
title: string,
|
||||||
onProgress?: (p: ExportProgress) => void
|
onProgress?: (p: ExportProgress) => void,
|
||||||
|
parts: ExportParts = "both"
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const printable: PrintableNote[] = [];
|
const printable: PrintableNote[] = [];
|
||||||
const total = metas.length;
|
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;
|
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 {
|
} catch {
|
||||||
/* an unreadable note is left out rather than aborting the whole print */
|
/* an unreadable note is left out rather than aborting the whole print */
|
||||||
}
|
}
|
||||||
@@ -225,7 +248,7 @@ export async function exportNotesAsZip({
|
|||||||
try {
|
try {
|
||||||
const { content } = await loadNote(meta);
|
const { content } = await loadNote(meta);
|
||||||
const doc = parseNoteContent(content.content, parsingExtensions);
|
const doc = parseNoteContent(content.content, parsingExtensions);
|
||||||
const hasDrawing = !!strokeDocOf(content.drawingStrokes)?.strokes?.length;
|
const hasDrawing = docStrokes(content.drawingStrokes).length > 0;
|
||||||
|
|
||||||
entries.push({
|
entries.push({
|
||||||
name: `${name}.md`,
|
name: `${name}.md`,
|
||||||
|
|||||||
@@ -138,21 +138,26 @@ const STYLES = `
|
|||||||
export interface PrintableNote {
|
export interface PrintableNote {
|
||||||
meta: NoteMeta;
|
meta: NoteMeta;
|
||||||
content: NoteContent;
|
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;
|
drawing?: string | null;
|
||||||
/** Attachment id → data URL, for images to survive into the print document. */
|
/** Attachment id → data URL, for images to survive into the print document. */
|
||||||
images?: Map<string, string>;
|
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 {
|
function articleFor(note: PrintableNote): string {
|
||||||
const doc = parseNoteContent(note.content.content, parsingExtensions);
|
|
||||||
const resolve = (id: string) => note.images?.get(id) || null;
|
const resolve = (id: string) => note.images?.get(id) || null;
|
||||||
const when = note.meta.updatedAt ? new Date(note.meta.updatedAt).toLocaleString("de-DE") : "";
|
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>
|
return `<article>
|
||||||
<h1>${esc(note.meta.title || "Untitled")}</h1>
|
<h1>${esc(note.meta.title || "Untitled")}</h1>
|
||||||
${when ? `<div class="meta">${esc(when)}</div>` : ""}
|
${when ? `<div class="meta">${esc(when)}</div>` : ""}
|
||||||
${blocks(doc.content, resolve)}
|
${body}
|
||||||
${note.drawing ? `<div class="drawing"><img src="${note.drawing}" alt="Zeichnung"></div>` : ""}
|
${note.drawing ? `<div class="drawing"><img src="${note.drawing}" alt="Zeichnung"></div>` : ""}
|
||||||
</article>`;
|
</article>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,31 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
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 { I } from "./icons.jsx";
|
||||||
import { FONT_SERIF, FONT_UI } from "./theme";
|
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
|
* A single note is asked two questions — what should come along, then in what
|
||||||
* you: Markdown keeps the notes editable somewhere else, PDF keeps them looking
|
* form — because the two are not independent: Markdown is text only, a PNG is
|
||||||
* the way they look here. Drawings only survive in PDF and as a PNG beside the
|
* the drawing only, and PDF is the one format that holds both. Offering all
|
||||||
* Markdown, which the dialog says out loud rather than leaving to be discovered.
|
* 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 }) {
|
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 [busy, setBusy] = useState(null);
|
||||||
const [progress, setProgress] = useState(null);
|
const [progress, setProgress] = useState(null);
|
||||||
const [err, setErr] = useState("");
|
const [err, setErr] = useState("");
|
||||||
@@ -33,33 +46,44 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
|||||||
[dark]
|
[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(() => {
|
useEffect(() => {
|
||||||
const onKey = (e) => e.key === "Escape" && !busy && onClose();
|
const onKey = (e) => e.key === "Escape" && !busy && onClose();
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [busy, onClose]);
|
}, [busy, onClose]);
|
||||||
|
|
||||||
const single = notes.length === 1;
|
|
||||||
|
|
||||||
const run = async (kind) => {
|
const run = async (kind) => {
|
||||||
setErr("");
|
setErr("");
|
||||||
setDone("");
|
setDone("");
|
||||||
setBusy(kind);
|
setBusy(kind);
|
||||||
setProgress(null);
|
setProgress(null);
|
||||||
try {
|
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]);
|
if (single) await exportNoteAsMarkdown(notes[0]);
|
||||||
else await exportNotesAsZip({ label, notes, notebooks, onProgress: setProgress });
|
else await exportNotesAsZip({ label, notes, notebooks, onProgress: setProgress });
|
||||||
|
setDone(single ? "Datei wurde heruntergeladen." : "Archiv wurde heruntergeladen.");
|
||||||
} else {
|
} 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) {
|
} catch (e) {
|
||||||
setErr(e?.message || "Export fehlgeschlagen");
|
setErr(e?.message || "Export fehlgeschlagen");
|
||||||
}
|
}
|
||||||
@@ -67,29 +91,30 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
|||||||
setProgress(null);
|
setProgress(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const option = (kind, title, lines) => (
|
const card = (onClick, active, title, lines, disabled = false) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => run(kind)}
|
key={title}
|
||||||
disabled={!!busy}
|
onClick={onClick}
|
||||||
|
disabled={disabled || !!busy}
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
gap: 4,
|
gap: 4,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
padding: "14px 16px",
|
padding: "13px 16px",
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
border: `1px solid ${T.border}`,
|
border: `1px solid ${active ? T.accent : T.border}`,
|
||||||
background: T.surface,
|
background: active ? T.accent + "14" : T.surface,
|
||||||
cursor: busy ? "default" : "pointer",
|
cursor: disabled || busy ? "default" : "pointer",
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
opacity: busy && busy !== kind ? 0.45 : 1,
|
opacity: disabled ? 0.4 : busy && busy !== active ? 0.5 : 1,
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
fontFamily: FONT_UI,
|
fontFamily: FONT_UI,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 14, fontWeight: 600, color: T.text }}>{busy === kind ? "Läuft…" : title}</span>
|
<span style={{ fontSize: 14, fontWeight: 600, color: T.text }}>{title}</span>
|
||||||
{lines.map((l, i) => (
|
{lines.filter(Boolean).map((l, i) => (
|
||||||
<span key={i} style={{ fontSize: 12, color: T.muted, lineHeight: 1.5 }}>
|
<span key={i} style={{ fontSize: 12, color: T.muted, lineHeight: 1.5 }}>
|
||||||
{l}
|
{l}
|
||||||
</span>
|
</span>
|
||||||
@@ -97,6 +122,84 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
|||||||
</button>
|
</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 (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -134,12 +237,21 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
|||||||
borderBottom: `1px solid ${T.border}`,
|
borderBottom: `1px solid ${T.border}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div style={{ minWidth: 0 }}>
|
||||||
<h3 style={{ margin: 0, fontSize: 17, fontWeight: 600, color: T.text, fontFamily: FONT_SERIF }}>
|
<h3 style={{ margin: 0, fontSize: 17, fontWeight: 600, color: T.text, fontFamily: FONT_SERIF }}>
|
||||||
Exportieren
|
Exportieren
|
||||||
</h3>
|
</h3>
|
||||||
<div style={{ fontSize: 12, color: T.muted, marginTop: 2 }}>
|
<div
|
||||||
{single ? label : `${label} · ${notes.length} Notizen`}
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: T.muted,
|
||||||
|
marginTop: 2,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -155,6 +267,7 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
|||||||
background: "transparent",
|
background: "transparent",
|
||||||
cursor: busy ? "default" : "pointer",
|
cursor: busy ? "default" : "pointer",
|
||||||
color: T.muted,
|
color: T.muted,
|
||||||
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{I.x}
|
{I.x}
|
||||||
@@ -162,19 +275,16 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: "16px 22px 8px" }}>
|
<div style={{ padding: "16px 22px 8px" }}>
|
||||||
{option("markdown", single ? "Als Markdown" : "Als Markdown-Archiv (ZIP)", [
|
{single && hasDrawing === null ? (
|
||||||
single
|
<div style={{ fontSize: 13, color: T.muted, padding: "12px 0" }}>Notiz wird gelesen…</div>
|
||||||
? "Eine .md-Datei — Bilder und Zeichnung kommen als ZIP mit, falls vorhanden."
|
) : parts === null ? (
|
||||||
: "Je Notiz eine .md-Datei, dazu Bilder, Zeichnungen und ein Inhaltsverzeichnis.",
|
stepParts()
|
||||||
"Bleibt anderswo bearbeitbar. Tabellen und Aufgabenhaken bleiben erhalten.",
|
) : (
|
||||||
])}
|
stepFormat()
|
||||||
{option("pdf", "Als PDF", [
|
)}
|
||||||
"Öffnet den Druckdialog — dort „Als PDF sichern“ wählen.",
|
|
||||||
"Zeichnungen sind hier mit im Dokument.",
|
|
||||||
])}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(progress || err || done) && (
|
{parts !== null && (progress || err || done) && (
|
||||||
<div style={{ padding: "0 22px 8px" }}>
|
<div style={{ padding: "0 22px 8px" }}>
|
||||||
{progress && (
|
{progress && (
|
||||||
<div style={{ fontSize: 12, color: T.muted }}>
|
<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 |
|
| `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 |
|
| `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 |
|
| `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
|
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
|
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,
|
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 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,
|
**Markdown** keeps the notes editable elsewhere. Headings, lists, quotes, code,
|
||||||
tables and task state all have equivalents and are carried over; a link to
|
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
|
another exported note becomes a relative link to its file, and a link to a note
|
||||||
|
|||||||
Reference in new issue
Block a user