Files
marcandClaude Opus 5 632ea5dcd9 Move a note in one press, and stop Draw inventing a page
Moving lived under a right-click, behind a submenu: two steps before the
list of places appeared at all, and on a phone a long press followed by a
flyout. There is a button on the row now, and it opens one screen with
the chapters of this notebook and the other notebooks together, marking
where the note already is.

The spare sheet in Draw mode was drawn as solid paper, so pressing Draw
on a one-page note made a second page appear from nowhere while the count
still said "of 1". It exists for a good reason — pages otherwise come
only from how far the writing runs, so drawing on page two would mean
writing two pages first — but it should look like the offer it is. Dashed
outline, half opacity, and it says what it is for. It counts once
something is on it.

And the two buttons above the list did not match: the one I added last
round was larger and bolder than the one it sat beside, and the older
one's target was nineteen pixels tall, which a finger misses. Both are
one shape now. A separator's label matches a chapter's name in size for
the same reason — two type treatments in one list is noise, and the
difference was not saying anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 13:43:51 +02:00

253 lines
9.9 KiB
JavaScript

/* Drawings must look the same on every screen.
*
* Strokes are stored as vectors in a fixed 1200x1600 page space, so the same
* note rendered at different viewport widths should differ only by rasterising
* error. Each rendering is reduced to a coarse ink grid and compared cell by
* cell, which ignores antialiasing but catches a stroke that moved or vanished.
*
* The second thing checked here is the bug that started all of this: the 6-second
* poller adopting a server response that was issued before the local save, which
* made strokes disappear again seconds after being drawn. */
import {
OUT,
check,
createNote,
createNotebook,
launch,
reopenNote,
report,
setTitle,
withFreshUser,
} from "./lib.mjs";
const GRID_W = 48,
GRID_H = 64;
/** Rasterises one page's drawing surface into a GRID_W x GRID_H map of "has ink". */
const inkGrid = (page, sheet = 1) =>
page.evaluate(
([gw, gh, n]) => {
const c =
document.querySelector(`canvas[data-draw-page="${n}"]`) ||
document.querySelector(`[data-drawn-page="${n}"] canvas`);
if (!c) return null;
const off = document.createElement("canvas");
off.width = gw;
off.height = gh;
const ctx = off.getContext("2d", { willReadFrequently: true });
ctx.drawImage(c, 0, 0, gw, gh);
const d = ctx.getImageData(0, 0, gw, gh).data;
const out = [];
for (let i = 0; i < gw * gh; i++) out.push(d[i * 4 + 3] > 40 ? 1 : 0);
return out;
},
[GRID_W, GRID_H, sheet]
);
const diff = (a, b) => a.reduce((n, v, i) => n + (v === b[i] ? 0 : 1), 0);
const ink = (g) => g.reduce((n, v) => n + v, 0);
async function draw(page, sheet = 1, strokes = null) {
if (sheet === 1) {
await page.locator("button").filter({ hasText: "Draw" }).first().click();
await page.waitForTimeout(1200);
}
const canvas = page.locator(`canvas[data-draw-page="${sheet}"]`);
await canvas.scrollIntoViewIfNeeded();
await page.waitForTimeout(500);
const box = await canvas.boundingBox();
// Three strokes in different parts of the page, so a shifted origin shows up.
strokes ??= [
[0.2, 0.2, 0.7, 0.35],
[0.25, 0.55, 0.75, 0.5],
[0.5, 0.7, 0.55, 0.85],
];
for (const [x1, y1, x2, y2] of strokes) {
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: 14 });
await page.mouse.up();
await page.waitForTimeout(300);
}
await page.waitForTimeout(2500);
return box;
}
const browser = await launch();
let session;
try {
session = await withFreshUser(browser, "draw");
const p = session.page;
await createNotebook(p, "Zeichen Buch");
await createNote(p);
await setTitle(p, "Zeichnung");
await draw(p);
const reference = await inkGrid(p);
check(reference !== null && ink(reference) > 0, "strokes were drawn", `${ink(reference || [])} ink cells`);
await p.screenshot({ path: `${OUT}/drawing-1400.png` });
/* ── the poller must not undo what was just drawn ── */
await p.waitForTimeout(14000);
const afterPolling = await inkGrid(p);
check(diff(reference, afterPolling) === 0, "strokes survive 14s of background polling", `${diff(reference, afterPolling)} cells changed`);
/* ── same note, other screen widths ── */
const noteId = await p.evaluate(async () => {
const r = await fetch("/api/notes", {
headers: { Authorization: "Bearer " + localStorage.getItem("inknote_token") },
});
return (await r.json())[0].id;
});
for (const width of [1000, 700, 420]) {
await p.setViewportSize({ width, height: 900 });
await p.waitForTimeout(2500);
if (width < 768) {
// Shrinking past the breakpoint leaves the sidebar open as a full-screen
// overlay, and its backdrop swallows every click. Dismiss it by clicking
// to the right of the panel, then reopen the note from the list.
await p.mouse.click(width - 12, 450);
await p.waitForTimeout(800);
const row = p.locator(`[data-note-row="${noteId}"]`);
if (await row.count()) {
await row.click();
await p.waitForTimeout(2500);
}
}
const g = await inkGrid(p);
if (!g) {
check(false, `drawing visible at ${width}px`);
continue;
}
const d = diff(reference, g);
const pct = ((d / (GRID_W * GRID_H)) * 100).toFixed(1);
check(d / (GRID_W * GRID_H) < 0.02, `drawing matches at ${width}px`, `${d} cells, ${pct}%`);
await p.screenshot({ path: `${OUT}/drawing-${width}.png` });
}
/* ── and after a reload ── */
// A reload lands on the dashboard, which has no note list — reopen by title.
await p.setViewportSize({ width: 1400, height: 950 });
await reopenNote(p, "Zeichnung");
await p.waitForTimeout(2000);
const afterReload = await inkGrid(p);
check(afterReload !== null && ink(afterReload) > 0, "the drawing is still there after a reload");
if (afterReload) {
const d = diff(reference, afterReload);
check(d / (GRID_W * GRID_H) < 0.02, "the reloaded drawing matches the original", `${d} cells`);
}
/* ── a drawing runs over as many pages as the note has ──
* The ink used to be stuck on page one while the writing carried on without
* it. Every page is exported anyway, so every page can be drawn on. */
await createNote(p);
await setTitle(p, "Mehrere Seiten");
await p.locator("button").filter({ hasText: "Draw" }).first().click();
await p.waitForTimeout(1200);
check(
(await p.locator('canvas[data-draw-page="2"]').count()) === 1,
"there is a blank page past the end to draw on, before anything is written"
);
/* It has to read as an offer, not as a page that appeared by itself. Drawn
solid, a second sheet turned up the moment Draw was pressed while the count
still said "of 1", which looks like the app inventing a page. */
check(
(await p.locator("[data-spare-page]").count()) === 1,
"and it is marked as the spare rather than drawn as paper",
String(await p.locator("[data-spare-page]").count())
);
check(
(await p.locator("[data-note-position]").innerText()).includes("of 1"),
"so the count still says one page until something is on it",
await p.locator("[data-note-position]").innerText()
);
await draw(p, 1);
const first = await inkGrid(p, 1);
await draw(p, 2, [
[0.3, 0.25, 0.8, 0.4],
[0.2, 0.6, 0.6, 0.75],
]);
await p.waitForTimeout(2500);
const second = await inkGrid(p, 2);
check(ink(second) > 0, "a stroke lands on page two", `${ink(second)} ink cells`);
check(diff(first, await inkGrid(p, 1)) === 0, "and page one is untouched by it");
check(
(await p.locator("[data-note-position]").innerText()).includes("of 2"),
"drawing on page two makes the note two pages",
await p.locator("[data-note-position]").innerText()
);
check(
(await p.locator('canvas[data-draw-page="3"]').count()) === 1,
"and there is a blank page after it again"
);
await p.screenshot({ path: `${OUT}/drawing-pages.png` });
/* Stored per page, and it survives the trip to the server and back. */
const pageDoc = await p.evaluate(async () => {
const auth = { Authorization: "Bearer " + localStorage.getItem("inknote_token") };
const notes = await (await fetch("/api/notes", { headers: auth })).json();
const mine = notes.find((n) => n.title === "Mehrere Seiten");
return (await (await fetch(`/api/notes/${mine.id}`, { headers: auth })).json()).drawingStrokes;
});
check(!!pageDoc?.sheets, "the server stored it as pages, not as one sheet", JSON.stringify(Object.keys(pageDoc || {})));
check(
(pageDoc?.sheets?.["1"]?.length ?? 0) > 0 && (pageDoc?.sheets?.["2"]?.length ?? 0) > 0,
"with strokes on both",
`page 1: ${pageDoc?.sheets?.["1"]?.length}, page 2: ${pageDoc?.sheets?.["2"]?.length}`
);
await reopenNote(p, "Mehrere Seiten");
await p.waitForTimeout(2000);
const reloadedTwo = await inkGrid(p, 2);
check(reloadedTwo !== null && ink(reloadedTwo) > 0, "page two's ink is still there after a reload");
check(
diff(second, reloadedTwo) / (GRID_W * GRID_H) < 0.02,
"and it is the same ink",
`${diff(second, reloadedTwo)} cells`
);
/* ── a drawing must not make the note unwritable ──
* The layers that show saved ink lie over the text. They are transparent and
* deaf to the pointer — except the wrapper around one of them was not, and a
* note with a drawing could not be typed in at all: every click meant for the
* text was swallowed before it arrived. */
await p.locator("button").filter({ hasText: "Write" }).first().click();
await p.waitForTimeout(1200);
check(
await p.evaluate(() => {
const r = document.querySelector(".ProseMirror").getBoundingClientRect();
const el = document.elementFromPoint(r.x + r.width / 2, r.y + 20);
return !!el?.closest(".ProseMirror");
}),
"over the writing it is the writing that answers, not the ink layer"
);
await p.locator(".ProseMirror").click();
await p.keyboard.type("Getippt, obwohl die Notiz eine Zeichnung hat.");
await p.waitForTimeout(1800);
check(
(await p.locator(".ProseMirror").innerText()).includes("obwohl die Notiz eine Zeichnung hat"),
"and a note with a drawing can still be written in",
await p.locator(".ProseMirror").innerText()
);
/* Clearing acts on the page you are looking at, not on the whole drawing. */
await p.locator("button").filter({ hasText: "Draw" }).first().click();
await p.waitForTimeout(1200);
await p.locator('canvas[data-draw-page="1"]').scrollIntoViewIfNeeded();
await p.waitForTimeout(900);
await p.locator("[data-clear-page]").click();
await p.waitForTimeout(2500);
check(ink(await inkGrid(p, 1)) === 0, "the bin empties the page being looked at");
check(ink(await inkGrid(p, 2)) > 0, "and leaves the other pages alone");
} catch (e) {
check(false, "suite ran to the end", e.message);
} finally {
await session?.cleanup();
const code = report();
await browser.close();
process.exit(code);
}