Commit Graph
8 Commits
Author SHA1 Message Date
marcandClaude Opus 5 502aecfc92 Let a margin remark become a note, and lead to it ever after
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>
2026-08-10 16:40:38 +02:00
marcandClaude Opus 5 011ba82a94 Write pinned notes in the margin, and give the phone a sheet instead
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>
2026-08-10 14:46:56 +02:00
marcandClaude Opus 5 6438648313 Pin notes to passages in a PDF, and put the notes control with the rest
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>
2026-08-10 13:08:20 +02:00
marcandClaude Opus 5 a32d6eba99 Export a PDF with the drawings written into it
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>
2026-08-08 21:49:01 +02:00
marcandClaude Opus 5 7f9f39790f Write notes beside a PDF
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>
2026-08-08 21:29:39 +02:00
marcandClaude Opus 5 b12b0d6142 Select text in a PDF, and find PDFs by their contents
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>
2026-08-08 21:12:06 +02:00
marcandClaude Opus 5 1be3a9b9ba Draw on PDF pages
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>
2026-08-08 20:54:10 +02:00
marcandClaude Opus 5 2986bcb620 Read imported PDFs in the app
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>
2026-08-08 20:36:04 +02:00