The margin notes hung off both switches: `hidden: hideInk || !showPins`, so
turning off the drawing took the markers and their column with it — one switch
turning off both of the things the pair exists to separate.
The checks look at what is actually visible — the ink layer's opacity, how many
markers are on the page, whether the column is there — and test both directions
separately, which is what the old code failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The interface had drifted into two languages — the frame in English, everything
added later in German — which is worse than either, since the same idea appeared
under two names depending on when that part was written. Sorting by title still
uses the German collation, so Übung 2 comes before Übung 10: that is about the
notes, not the chrome.
Two archive folders were renamed with it (bilder/ → images/, zeichnungen/ →
drawings/). The importer reads both spellings, and both the old and new marker
line for a drawing — archives exported before this are on disk, and an import
that quietly dropped their drawings would be the worst kind of tidy-up.
Making a note, uploading a PDF and importing Markdown were three buttons side by
side, two of them icons you had to hover to identify. They are the same act —
putting something new in this notebook — so they are now one + menu.
The search box moves out of the sidebar and into the note list, labelled with the
notebook it narrows. Nothing changed about what it does: it has always searched
only the notebook you are in, but from the sidebar it read as searching
everything.
A separator no longer carries a sentence explaining the order the sort menu
already names. Renaming joins removing on the right-click menu — a left-click on
the label started an edit, so the row could not be touched without falling into
one.
218 unit tests, 238 browser checks across eleven suites. Two of those caught real
breakage from this change rather than needing a translation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Zeichnung" and "Randnotizen" are two halves of one question — how much of your
own work do you want to see right now — and they sat at opposite ends of the
toolbar, each beside the tool that made them. They are now one pair in one shape
at the right-hand end. Each still appears only once there is something of its
kind to hide, so the pair can be lopsided, which is honest.
The drawing tools had drifted past the pin controls as those were added, so
switching to Zeichnen made them appear somewhere other than where the eye
already was. They follow the button that turns them on again.
A separator no longer carries a ×. It sat a few pixels from the label you click
to rename, putting the gesture that tidies the list next to the one that destroys
part of it — so removal is a right-click behind a confirmation that says the
notes are not affected, which is how everything else in that list is acted on.
218 unit tests, 237 browser checks across eleven suites. The toolbar checks
compare measured positions rather than presence, since the complaint was about
where things were, not whether they existed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The linking went the wrong way round. A remark in the margin is short by design;
when there is more to say about that passage it belongs in the notes beside the
document — and it has to be possible to say *which* passage. So the pins are
offered to the editor's `@` menu exactly like notes and notebooks are, labelled
with their page, and following one scrolls the document to the spot and opens the
remark. Pins are unhidden first: a link leading to something invisible would read
as broken.
A pin link is the same node with kind "pin", and is deliberately kept out of the
links reported to the server. Its target is a marker inside this note's own
overlay, so no note has that id, and reporting it would fill the backlink table
with rows matching nothing.
They can also be hidden altogether. Reading a document as it was printed is a
normal thing to want; the toolbar takes the markers and the column away together
and the page takes the width back.
The other direction — making a note out of a remark — is removed, along with its
picker and the pin's noteId: 357 lines fewer than before. The unmount flush it
turned up stays, since a drawing save was genuinely being dropped by navigating
away inside its debounce.
218 unit tests, 231 browser checks across eleven suites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Some remarks turn into a page of their own. "Notiz anlegen" on a margin card
makes one, titles it from what was written, and goes there; the pin keeps its id
so the passage leads to that note afterwards. The note is created with a link
back to the document, so the connection reads both ways and the document lists it
among its backlinks — a note born from a passage should not be a note with
nothing on it saying which passage. An existing note can be linked instead, and a
link can be let go of without touching the note. Both are offered on the phone
sheet too.
Two older bugs stood in the way and are fixed:
`openLinkTarget` finds a note's notebook by looking it up in the notes list, and
a note created in the same tick is not in the copy that callback closed over — so
jumping to a fresh note went nowhere at all. Creating one now navigates by the
notebook it already knows.
A drawing save waits 700ms for the next stroke, and leaving the document inside
that window dropped it. That is precisely what turning a remark into a note does,
so the link would have been lost on the way out. The viewer flushes a pending
write when it unmounts, which also covers drawing and navigating away quickly.
215 unit tests, 229 browser checks across eleven suites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A note about a passage belongs beside the passage, not on top of it. Only a
numbered marker stays on the page now, with a hairline out to a card in the
margin, level with its anchor. Cards keep one height so two notes close together
can be laid out by pushing the second down; the one being written in grows and
rises above the rest.
A phone has no margin to write in — a page narrow enough to read leaves no white
space beside it, and taking a column would make the document unreadable. There
the note comes up from the bottom instead: over the page, only while open, within
reach of a thumb, with the passage still visible above it.
Reserving the column exposed a loop worth remembering. A flex item is as wide as
its content unless told otherwise, so the column made the viewer wider, which
made there be room for the column: the page ended up at 254px inside a 420px
phone while the layout insisted it had 611px. The viewer and the editor pane now
say minWidth: 0, and the toolbar scrolls rather than cutting its last controls
off on a narrow screen.
Splitting each row into page and margin also broke which pages counted as
visible — the check read data-pdf-page off the row, found nothing, and cancelled
every text layer before it rendered. It reads the row's own attribute now.
215 unit tests, 219 browser checks across eleven suites, including the phone
layout at 420px.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The panel beside a PDF is for notes about the document; a note that belongs to
one paragraph is a different thing, and one that has to begin "on page 14, third
paragraph" has already lost its place. Click Randnotiz, click the passage, write.
Pins ride in the same per-page overlay as the drawings, in PDF points, so one
stays on its sentence at every zoom and in the exported file. Living outside the
note body has consequences that each needed handling: nothing else would index
them for search, so the viewer feeds them in with their page numbers; clearing a
page's ink must not take them; and "is this overlay empty?" was answered by
counting strokes — in the client *and* independently in the server, so an
overlay holding only pinned notes was stored as NULL and lost every note on it.
They are stamped into the annotated export as a numbered marker plus a box of
text, on whichever side of the pin has room. Standard PDF fonts are Latin-1, so
characters outside it are replaced rather than failing the whole export.
The Notizen control moves into the viewer's toolbar. A second row for one button
cost reading height on every screen and put one of the document's controls
somewhere none of the others were. On a phone, where the viewer is not on screen
while the notes are, the way back sits in the panel.
Renaming a separator no longer shifts the row: the two rules always share what is
left over equally and the middle keeps one width in both states.
215 unit tests, 206 browser checks across eleven suites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage seven, and the last of the PDF work. A PDF note now offers the
document with your marks stamped in, the untouched original, or just the
notes written beside it.
pdf.js only reads, so writing needs pdf-lib — behind a dynamic import
like pdf.js, so the bundle everyone downloads carries no PDF writer for
the sake of people who never export one. Main bundle grew 6 kB; the
180 kB writer arrives only when used.
The coordinate decision from stage four is what makes this a translation
rather than a reconstruction: strokes were stored in page points from the
start, the only space a PDF understands, so nothing has to be guessed
back from screen pixels. Only the origin moves, PDF putting it at the
bottom-left.
The stored file is never modified — pdf-lib builds a new document — so a
clean copy stays available however often it has been drawn on. The suite
re-fetches the stored bytes after exporting and compares them, and checks
the plain export is byte-identical to what was imported.
Eraser strokes are dropped. On screen they remove ink by drawing in
destination-out; a PDF content stream has no equivalent, and stamping
them would put grey scribbles exactly where something had been rubbed
out.
The suite renders both files with pdf.js and counts dark pixels, 1633
against 2557. A larger file only proves bytes were added; this proves ink
can be seen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage six. What you write goes into the note's ordinary content, so it is
encrypted, versioned, linkable and searchable like any other note — a PDF
note is a note with a document attached, not a separate kind of thing.
The panel is composed from the editor pieces rather than reusing
NoteEditor, which carries a whole drawing mode with its own canvas, tools
and undo stack. The PDF already has a drawing layer; a second one behind
the panel would be two places to draw with one of them invisible.
Side by side on a wide screen, two tabs on a narrow one, and either half
can be hidden — the second of the two toggles asked for. Reading the
original without one's own marks in the way is a normal thing to want.
The suite caught a real defect while checking this. A PDF note has two
sources of searchable text — what the reader typed and what the document
says — written at different moments by different code, both calling
setEntry. Whichever ran last was the only searchable half: typing a note
dropped the document's text, and reopening the document dropped the note.
The index now holds the two apart and replaces only the half it is given,
with six unit tests covering each direction, plus the snippet falling back
to the document so a hit inside a PDF still shows its sentence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage five. pdf.js draws pages as pictures, so selecting means laying
transparent spans over them exactly where each word was painted. The
layer carries the same scale as the canvas — they drift apart otherwise
and a selection lands on the wrong words. While drawing it stops taking
pointer events entirely, or a stroke would come out as a selection.
The text is also extracted and handed to the index that already holds the
notes, so one search box finds a sentence whether it was typed or
imported. Extraction runs after the document is on screen and yields
between pages: it must never stand between the reader and the first page.
The extracted text is deliberately not sent to the server. It is
derivable from a file the server already holds, so storing it again costs
space and a second thing to encrypt for nothing gained. The price, stated
plainly in the readme, is that a PDF becomes searchable on a device once
that device has opened it — the same shape as the limitation notes have.
Text runs are joined on pdf.js's hasEOL rather than concatenated. Without
it the last word of a line glues to the first of the next and matches
nothing when searched for.
The minimal text-layer CSS is written out rather than importing
pdfjs-dist/web/pdf_viewer.css, which carries the whole reference viewer's
toolbars and dialogs.
Suite now checks the layer sits over the page rather than stacked in a
corner, that the text is transparent, that dragging selects real words,
that a word from inside the file finds the note and one that is not in it
finds nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage four. Strokes are kept per page in that page's own coordinate
space — PDF points at scale 1 — not in the fixed 1200x1600 space a note's
drawing uses. A PDF cannot use one space: every page has its own size,
and a stroke belongs to the page it was drawn on.
That is what keeps a mark on the word it was drawn over at any zoom and
on any screen, and it is the only form that can be stamped back into the
file in stage seven, where page points are the only coordinates that mean
anything. Measured rather than assumed: zooming moves a stroke by 0.001
of the page.
Pen sizes are scaled by the page width. The sizes are calibrated for the
1200-unit note page, so on a 595-point A4 page the same number would have
drawn twice as thick.
Reading and drawing are separate modes. While reading the ink layer takes
no pointer events, so scrolling and selecting text reach the page beneath
it; while drawing, a finger still scrolls and only a pen or mouse marks,
because a touch-drag on a phone means scroll far more often than it means
draw. The drawing can be hidden without being erased, for reading the
original clean.
The server accepted only the single-page shape; it now takes either and
counts strokes across pages for its limit.
Carried over from notes: the save token guard, so the six-second poller
adopting a response issued before the local save cannot undo what was
just drawn. The suite waits nine seconds and checks the ink is still
there — that bug has cost strokes twice before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage three: the viewer. Drawing on pages comes next.
pdf.js stays out of the bundle everyone downloads — it is larger than the
rest of the application put together, so it is behind a dynamic import and
a session that never opens a PDF never fetches it. Main bundle grew by
8 kB for the viewer shell; the 162 kB library arrives only when needed.
The worker is bundled rather than pulled from a CDN, because a viewer
that stops working during someone else's outage is not one you can rely
on offline.
Two things that only showed up once it ran in a browser:
- pdf.js 6 calls Uint8Array.prototype.toHex, which only very recent
browsers have. Anything older failed with "n.toHex is not a function",
which says nothing about the cause. Now on the legacy build, which
compiles those away — a little larger, and the difference between
working everywhere and working on this year's browsers.
- nginx has no MIME type for .mjs and served the worker as
octet-stream, which a browser refuses to run as a module.
Pages are laid out at their true proportions before anything is drawn, so
the scrollbar is honest immediately and a document mixing portrait and
landscape keeps both — the first version scaled every page to the widest
one, which the suite caught by checking the two aspect ratios differ.
Bytes come from the authenticated attachment route and are cached in
IndexedDB. Content-addressed storage means a cached copy can never be
stale, so a document opened once needs no connection again. Verified with
the network switched off.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>