Let a drawing run over as many pages as the note

The ink was stuck on page one while the writing carried on without it,
which stopped making sense the moment a note could run to four pages.
Every page is exported anyway, so every page can be drawn on.

Pages are numbered the way the PDF reader numbers them, and only pages
that carry ink are stored, so the keys are the drawn pages. While drawing
there is always one blank page past the end: pages otherwise come from
how far the writing runs, and to draw on page three you would first have
had to write three pages.

A drawing that lives entirely on page one is still written in the old
single-page shape. Nothing requires that — it means an ordinary note is
stored exactly as it always was, and a device running an older build
still shows it.

The bin clears the page being looked at. Undo and redo work on the
drawing as a whole, because one Undo button that means four different
things depending on where you scrolled is not a button anyone can use.

And a real bug found on the way out: the exporter has written
drawings/<note>.png since the interface was put into English, while the
importer was still looking for zeichnungen/<note>.png. A drawing exported
by this very app came back from its own archive as nothing at all. Both
names are read now, and so are the further pages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-11 19:25:56 +02:00
1 parent b8c5c42217
commit 228552d453
9 files changed
+671 -136

No files matched your search

+56 -19
View File
@@ -10,7 +10,15 @@
import * as sync from "../syncEngine";
import { parseNoteContent } from "../editor/content";
import { parsingExtensions } from "../editor/extensions.js";
import { CANON_W, CANON_H, docStrokes, paintStrokeDoc, sizeCanvas } from "../features/drawing/strokes.js";
import {
CANON_W,
CANON_H,
allStrokes,
drawnSheets,
sheetStrokes,
paintStrokeDoc,
sizeCanvas,
} from "../features/drawing/strokes.js";
import type { NoteContent, NoteMeta, Notebook } from "../types";
import { noteToMarkdown, safeFilename } from "./markdown";
import { asPdfStrokeDoc, hasOverlay } from "../features/pdf/pageStrokes";
@@ -31,18 +39,18 @@ type Loaded = { meta: NoteMeta; content: NoteContent };
/* ── pieces ── */
/** Renders a stroke document to PNG bytes, so a drawing survives as a picture. */
async function drawingToPng(doc: unknown): Promise<Uint8Array | null> {
/** Renders one page of a stroke document to PNG bytes. */
async function drawingToPng(doc: unknown, page = 1): 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;
if (sheetStrokes(doc, page).length === 0) return null;
const canvas = document.createElement("canvas");
const height = Math.round((DRAWING_EXPORT_W * CANON_H) / CANON_W);
const { bw, bh } = sizeCanvas(canvas, DRAWING_EXPORT_W, height);
const ctx = canvas.getContext("2d");
if (!ctx) return null;
paintStrokeDoc(ctx, doc, bw, bh);
paintStrokeDoc(ctx, doc, bw, bh, page);
// Strokes are drawn on nothing, which would export as a transparent PNG and
// leave black ink invisible in a dark image viewer. The paper goes in behind
// afterwards, because paintStrokeDoc begins by clearing the canvas.
@@ -57,6 +65,22 @@ async function drawingToPng(doc: unknown): Promise<Uint8Array | null> {
return new Uint8Array(await blob.arrayBuffer());
}
/**
* Every drawn page of a note, as PNGs with the name each should be filed under.
*
* Page one keeps the name a drawing has always had, so an archive written before
* notes could be drawn on more than one page still reads the same. The rest are
* numbered after it.
*/
async function drawingPages(doc: unknown, base: string): Promise<Array<{ name: string; data: Uint8Array }>> {
const out: Array<{ name: string; data: Uint8Array }> = [];
for (const page of drawnSheets(doc)) {
const data = await drawingToPng(doc, page);
if (data) out.push({ name: page === 1 ? `${base}.png` : `${base}-${page}.png`, data });
}
return out;
}
/** Every attachment id the document refers to. */
function attachmentIds(doc: unknown): string[] {
const ids = new Set<string>();
@@ -133,24 +157,35 @@ export type ExportParts = "text" | "drawing" | "both";
export async function noteHasDrawing(meta: NoteMeta): Promise<boolean> {
try {
const { content } = await loadNote(meta);
return docStrokes(content.drawingStrokes).length > 0;
return allStrokes(content.drawingStrokes).length > 0;
} catch {
return false;
}
}
/** The drawing on its own, as a PNG on white paper. */
/**
* The drawing on its own, on white paper.
*
* One page is a PNG, as it always was. Several are a ZIP: a drawing spread over
* four pages is four pictures, and silently exporting only the first would lose
* the rest without saying so.
*/
export async function exportDrawingAsPng(meta: NoteMeta): Promise<void> {
const { content } = await loadNote(meta);
const png = await drawingToPng(content.drawingStrokes);
if (!png) throw new Error("This note has no drawing.");
download(bytesToBlob(png, "image/png"), `${safeFilename(meta.title)}.png`);
const name = safeFilename(meta.title);
const pages = await drawingPages(content.drawingStrokes, name);
if (pages.length === 0) throw new Error("This note has no drawing.");
if (pages.length === 1) {
download(bytesToBlob(pages[0]!.data, "image/png"), `${name}.png`);
return;
}
download(createZip(pages.map((p) => ({ name: p.name, data: p.data }))), `${name}.zip`);
}
export async function exportNoteAsMarkdown(meta: NoteMeta): Promise<void> {
const { content } = await loadNote(meta);
const doc = parseNoteContent(content.content, parsingExtensions);
const hasDrawing = docStrokes(content.drawingStrokes).length > 0;
const hasDrawing = allStrokes(content.drawingStrokes).length > 0;
const ids = attachmentIds(doc);
const md = noteToMarkdown(doc, {
@@ -173,8 +208,7 @@ export async function exportNoteAsMarkdown(meta: NoteMeta): Promise<void> {
const bytes = await fetchAttachment(id);
if (bytes) entries.push({ name: `${folder}-images/${id}.png`, data: bytes });
}
const png = await drawingToPng(content.drawingStrokes);
if (png) entries.push({ name: `${folder}-drawing.png`, data: png });
entries.push(...(await drawingPages(content.drawingStrokes, `${folder}-drawing`)));
download(createZip(entries), `${folder}.zip`);
}
@@ -220,10 +254,14 @@ export async function exportNotesAsPdf(
}
}
const png = parts === "text" ? null : await drawingToPng(content.drawingStrokes);
const drawing = png ? await blobToDataUrl(bytesToBlob(png, "image/png")) : null;
const drawings: string[] = [];
if (parts !== "text") {
for (const page of await drawingPages(content.drawingStrokes, "page")) {
drawings.push(await blobToDataUrl(bytesToBlob(page.data, "image/png")));
}
}
printable.push({ meta, content, drawing, images, includeText: parts !== "drawing" });
printable.push({ meta, content, drawings, images, includeText: parts !== "drawing" });
} catch {
/* an unreadable note is left out rather than aborting the whole print */
}
@@ -292,7 +330,7 @@ export async function exportNotesAsZip({
try {
const { content } = await loadNote(meta);
const doc = parseNoteContent(content.content, parsingExtensions);
const hasDrawing = docStrokes(content.drawingStrokes).length > 0;
const hasDrawing = allStrokes(content.drawingStrokes).length > 0;
entries.push({
name: `${name}.md`,
@@ -310,8 +348,7 @@ export async function exportNotesAsZip({
const bytes = await fetchAttachment(id);
if (bytes) entries.push({ name: `images/${id}.png`, data: bytes });
}
const png = await drawingToPng(content.drawingStrokes);
if (png) entries.push({ name: `drawings/${name}.png`, data: png });
entries.push(...(await drawingPages(content.drawingStrokes, `drawings/${name}`)));
} catch (err) {
// A note that cannot be read is named in the index rather than skipped in
// silence — an export that quietly drops things is worse than useless.
+5 -3
View File
@@ -145,8 +145,8 @@ const STYLES = `
export interface PrintableNote {
meta: NoteMeta;
content: NoteContent;
/** Drawing rendered as a data URL, if the note has one and it was asked for. */
drawing?: string | null;
/** The drawing's pages as data URLs, in order, if it was asked for. */
drawings?: 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. */
@@ -165,7 +165,9 @@ function articleFor(note: PrintableNote): string {
<h1>${esc(note.meta.title || "Untitled")}</h1>
${when ? `<div class="meta">${esc(when)}</div>` : ""}
${body}
${note.drawing ? `<div class="drawing"><img src="${note.drawing}" alt="Drawing"></div>` : ""}
${(note.drawings ?? [])
.map((src, i) => `<div class="drawing"><img src="${src}" alt="Drawing, page ${i + 1}"></div>`)
.join("\n ")}
</article>`;
}