diff --git a/e2e/pdf.mjs b/e2e/pdf.mjs
index bd7010b..dd4200b 100644
--- a/e2e/pdf.mjs
+++ b/e2e/pdf.mjs
@@ -480,11 +480,26 @@ try {
await p.mouse.click(page1.x + page1.width * 0.4, page1.y + page1.height * 0.3);
await p.waitForTimeout(800);
- check((await p.locator("[data-pin]").count()) === 1, "clicking the page leaves a pin");
- const bubble = p.getByLabel("Pinned note");
- check(await bubble.isVisible(), "and opens straight into writing");
+ check((await p.locator("[data-pin]").count()) === 1, "clicking the page leaves a marker on the spot");
+ check(
+ (await p.locator("[data-pdf-margin]").count()) > 0,
+ "and the note itself belongs in the margin beside the page, not on it"
+ );
+ const field = p.getByLabel("Pinned note");
+ check(await field.isVisible(), "which opens straight into writing");
- await bubble.fill("Drehmoment beachten");
+ /* The card has to be level with the passage — that is the whole point of a
+ margin note, and the reason it is not simply a list under the document. */
+ const levels = await p.evaluate(() => {
+ const marker = document.querySelector("[data-pin]").getBoundingClientRect();
+ const card = document.querySelector("[data-pin-card]").getBoundingClientRect();
+ const page = document.querySelector('[data-pdf-page="1"]').getBoundingClientRect();
+ return { gap: Math.abs(marker.top - card.top), pageRight: page.right, cardLeft: card.left };
+ });
+ check(levels.gap < 40, "the card sits level with its marker", `${Math.round(levels.gap)}px apart`);
+ check(levels.cardLeft >= levels.pageRight - 2, "and beside the page rather than over it");
+
+ await field.fill("Drehmoment beachten");
await p.getByRole("button", { name: "Fertig" }).click();
await p.waitForTimeout(2500);
@@ -539,11 +554,17 @@ try {
await p.waitForTimeout(4000);
check((await p.locator("[data-pin]").count()) === 1, "the pin survives a reload");
+ check(
+ (await p.locator("[data-pin-card]").innerText()).includes("Drehmoment"),
+ "with the text showing in the margin without being opened first",
+ await p.locator("[data-pin-card]").innerText()
+ );
+
await p.locator("[data-pin]").click();
await p.waitForTimeout(600);
check(
(await p.getByLabel("Pinned note").inputValue()) === "Drehmoment beachten",
- "with the text that was written into it"
+ "and the same text when opened for editing"
);
/* Clearing a page's ink must not take its pinned notes: same page, different
@@ -610,6 +631,95 @@ try {
+ /* ── on a phone there is no margin to write in ── */
+ // A page narrow enough to read leaves no white space beside it, so taking a
+ // column for notes would make the document itself unreadable. The note comes
+ // up from the bottom instead — over the page, but only while it is open.
+ await p.locator("[data-pdf-pin-mode]").click();
+ await p.waitForTimeout(400);
+ const pageM = await p.locator('[data-pdf-page="1"]').boundingBox();
+ await p.mouse.click(pageM.x + pageM.width * 0.5, pageM.y + pageM.height * 0.4);
+ await p.waitForTimeout(800);
+ await p.getByLabel("Pinned note").fill("Am Rand notiert");
+ await p.getByRole("button", { name: "Fertig" }).click();
+ await p.waitForTimeout(2500);
+
+ const wideMargins = await p.locator("[data-pdf-margin]").count();
+ check(wideMargins > 0, "on a wide screen the note is in the margin", String(wideMargins));
+ const pageWithMargin = (await p.locator('[data-pdf-page="1"]').boundingBox()).width;
+
+ await p.setViewportSize({ width: 420, height: 820 });
+ await p.waitForTimeout(2000);
+ // Below the phone breakpoint the list and the editor take turns, so the note
+ // has to be opened again to be looking at the document at all.
+ // Shrinking a desktop window past the breakpoint leaves the sidebar and its
+ // backdrop up, which no real phone ever sees. Dismissed here so the rest of
+ // the section is testing the document and not an overlay.
+ if ((await p.locator("[data-pdf-page]").count()) === 0) {
+ await p.evaluate(() => document.querySelector("[data-note-row]")?.click());
+ await p.waitForTimeout(4500);
+ }
+ await p.mouse.click(410, 800);
+ await p.waitForTimeout(800);
+ check(
+ (await p.locator("[data-pdf-margin]").count()) === 0,
+ "on a narrow one the margin is gone",
+ String(await p.locator("[data-pdf-margin]").count())
+ );
+ check((await p.locator("[data-pin]").count()) > 0, "but the markers are still on their spots");
+
+ // The widest page should fill what the screen has, rather than share it with
+ // a column. (Page 1 is portrait in a document whose widest page is landscape,
+ // so it is legitimately narrower — page 2 is the one that sets the scale.)
+ void pageWithMargin;
+ const widest = (await p.locator('[data-pdf-page="2"]').boundingBox()).width;
+ check(
+ widest > 420 - 60,
+ "and the page gets the width the column was taking",
+ `${Math.round(widest)}px of 420`
+ );
+
+ /* Reserving a 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. A flex item is as wide as its content unless told
+ otherwise. */
+ const fits = await p.evaluate(() => {
+ let el = document.querySelector("[data-pdf-page]")?.parentElement;
+ while (el && getComputedStyle(el).overflow !== "auto") el = el.parentElement;
+ return { scroller: el?.clientWidth ?? 0, window: window.innerWidth };
+ });
+ check(
+ fits.scroller <= fits.window,
+ "and the document area does not push itself past the window",
+ `${fits.scroller} vs ${fits.window}`
+ );
+
+ const marker = p.locator("[data-pin]").first();
+ const markerText = await marker.getAttribute("title");
+ await marker.scrollIntoViewIfNeeded();
+ await marker.click();
+ await p.waitForTimeout(800);
+ check((await p.locator("[data-pin-sheet]").count()) === 1, "tapping the marker opens a sheet");
+ check(
+ (await p.getByLabel("Pinned note").inputValue()) === markerText,
+ "with the note that was written on the desktop",
+ markerText
+ );
+
+ const sheet = await p.locator("[data-pin-sheet]").boundingBox();
+ check(
+ sheet.y + sheet.height > 700,
+ "at the bottom, where a thumb reaches",
+ `top ${Math.round(sheet.y)} of 820`
+ );
+
+ await p.getByRole("button", { name: "Fertig" }).click();
+ await p.waitForTimeout(800);
+ check((await p.locator("[data-pin-sheet]").count()) === 0, "and closes again");
+
+ await p.setViewportSize({ width: 1400, height: 950 });
+ await p.waitForTimeout(2000);
+
/* ── the notes panel lives in the same toolbar now ── */
check(
(await p.locator("[data-pdf-toggle-notes]").count()) === 1,
diff --git a/frontend/src/features/notes/EditorPane.jsx b/frontend/src/features/notes/EditorPane.jsx
index cc03867..9ad87bf 100644
--- a/frontend/src/features/notes/EditorPane.jsx
+++ b/frontend/src/features/notes/EditorPane.jsx
@@ -169,7 +169,18 @@ export function EditorPane({
);
return (
-
+
-
+
{note.type === "pdf" ? (
a.y - b.y)
+ .map((note) => {
+ const anchor = (note.y / pageHeight) * cssHeight - 12;
+ const top = Math.max(anchor, previousBottom + CARD_GAP);
+ previousBottom = top + CARD_H;
+ return { note, top };
+ });
+}
+
+function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) {
+ const [draft, setDraft] = useState(note.text);
+ const areaRef = useRef(null);
+
+ // Adopt text that arrived from elsewhere — another device, or an undo — but
+ // never while this card is the one being typed into.
+ useEffect(() => {
+ if (!open) setDraft(note.text);
+ }, [note.text, open]);
+
+ useEffect(() => {
+ if (open) areaRef.current?.focus();
+ }, [open]);
+
+ // Committed on the way out, not per keystroke: every save rewrites the whole
+ // overlay, and one per character would be absurd.
+ const commit = () => {
+ if (draft !== note.text) onChange(draft);
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/features/pdf/PdfNoteSheet.jsx b/frontend/src/features/pdf/PdfNoteSheet.jsx
new file mode 100644
index 0000000..b7594be
--- /dev/null
+++ b/frontend/src/features/pdf/PdfNoteSheet.jsx
@@ -0,0 +1,131 @@
+import { useEffect, useRef, useState } from "react";
+
+/**
+ * A pinned note on a phone.
+ *
+ * There is no margin to write in: a page narrow enough to read on a phone leaves
+ * no white space beside it, and stealing 200px for a column would make the
+ * document itself unreadable. So the note comes up from the bottom instead —
+ * over the page rather than beside it, but only while it is being read or
+ * written, and within reach of a thumb rather than at the top of the screen.
+ *
+ * The page stays visible above it, so the passage the note is about is still
+ * there while writing about it.
+ */
+export function PdfNoteSheet({ T, note, number, onChange, onClose, onRemove }) {
+ const [draft, setDraft] = useState(note.text);
+ const areaRef = useRef(null);
+
+ useEffect(() => setDraft(note.text), [note.id]);
+
+ const commit = () => {
+ if (draft !== note.text) onChange(draft);
+ };
+ const close = () => {
+ commit();
+ onClose();
+ };
+
+ useEffect(() => {
+ const onKey = (e) => {
+ if (e.key === "Escape") close();
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ });
+
+ return (
+
+
+
+ {number}
+
+ Notiz zu dieser Stelle
+
+
+
+
+ );
+}
diff --git a/frontend/src/features/pdf/PdfPins.jsx b/frontend/src/features/pdf/PdfPins.jsx
index 3eea4d4..ba8a419 100644
--- a/frontend/src/features/pdf/PdfPins.jsx
+++ b/frontend/src/features/pdf/PdfPins.jsx
@@ -1,138 +1,15 @@
-import { useEffect, useRef, useState } from "react";
+import { useRef } from "react";
/**
- * Notes pinned to a spot on a PDF page.
+ * The markers for notes pinned to a page.
*
- * The panel beside the document is for notes about the document; this is for
- * notes about a *passage* — the paragraph you were reading when you thought of
- * it. A note that has to say "on page 14, third paragraph" is a note that has
- * lost its place.
+ * Only the anchor lives on the document — a small numbered dot on the spot the
+ * note is about. The note itself is written in the margin beside the page, or on
+ * a phone in a sheet from the bottom. Writing *onto* the page covers the
+ * sentence the note is about, which is the one thing it must not do.
*
- * Positions are stored in PDF points, the same space as the drawing, so a pin
- * stays on its sentence at every zoom and on every screen.
- */
-
-/** Where a pin's bubble opens, kept inside the page rather than off its edge. */
-function bubbleSide(xFraction) {
- return xFraction > 0.6 ? "right" : "left";
-}
-
-function PinBubble({ T, pin, onChange, onClose, onRemove, side }) {
- const ref = useRef(null);
- const [draft, setDraft] = useState(pin.text);
-
- // Committed on the way out rather than on every keystroke: each save is a
- // write of the whole overlay, and one per character would be absurd.
- const commit = () => {
- if (draft !== pin.text) onChange(draft);
- };
-
- useEffect(() => {
- const away = (e) => {
- if (ref.current && !ref.current.contains(e.target)) {
- commit();
- onClose();
- }
- };
- // Deferred, or the click that opened the bubble closes it again.
- const t = setTimeout(() => document.addEventListener("pointerdown", away), 0);
- return () => {
- clearTimeout(t);
- document.removeEventListener("pointerdown", away);
- };
- });
-
- return (
-
- );
-}
-
-/**
- * The pins on one page.
- *
- * Sits above the ink layer but below nothing else, and only takes the pointer
- * where a pin actually is — the rest of the page must stay selectable and
- * scrollable.
+ * Positions are in PDF points, the same space as the drawing, so a marker stays
+ * on its sentence at every zoom and on every screen.
*/
export function PdfPins({
T,
@@ -143,11 +20,10 @@ export function PdfPins({
notes,
placing,
openId,
+ withMargin,
onOpen,
onPlace,
- onChange,
onMove,
- onRemove,
numberOf,
}) {
const layerRef = useRef(null);
@@ -161,9 +37,9 @@ export function PdfPins({
};
};
- /* Dragging a pin is tracked here rather than with the HTML drag API: that one
- * cannot report a position continuously, and a pin has to follow the finger to
- * be placed on a line of text. */
+ /* Dragging is tracked with pointer events rather than the HTML drag API:
+ * that one cannot report a position continuously, and a marker has to follow
+ * the finger to be put on a line of text. */
const startDrag = (e, pin) => {
if (placing) return;
e.preventDefault();
@@ -198,8 +74,8 @@ export function PdfPins({
width: cssWidth,
height: cssHeight,
zIndex: 12,
- // Only the pins are targets unless a note is being placed, or the page
- // could neither be scrolled nor its text selected.
+ // Only the markers are targets unless a note is being placed, or the
+ // page could neither be scrolled nor its text selected.
pointerEvents: placing ? "auto" : "none",
cursor: placing ? "crosshair" : "default",
}}
@@ -207,16 +83,30 @@ export function PdfPins({
{notes.map((pin) => {
const xf = pin.x / pageSize.width;
const yf = pin.y / pageSize.height;
+ const open = openId === pin.id;
return (
+ {/* A hairline out to the margin, so it is visible which note belongs
+ to which spot without having to click one to find out. */}
+ {withMargin && (
+
+ )}
- {openId === pin.id && (
- onChange(pin.id, text)}
- onClose={() => onOpen(null)}
- onRemove={() => onRemove(pin.id)}
- />
- )}
);
})}
diff --git a/frontend/src/features/pdf/PdfViewer.jsx b/frontend/src/features/pdf/PdfViewer.jsx
index a1c3e81..22f6025 100644
--- a/frontend/src/features/pdf/PdfViewer.jsx
+++ b/frontend/src/features/pdf/PdfViewer.jsx
@@ -7,12 +7,15 @@ import { extractAndCache, indexPdfText } from "./pdfText";
import "./textLayer.css";
import { PdfDrawLayer } from "./PdfDrawLayer.jsx";
import { PdfPins } from "./PdfPins.jsx";
+import { MARGIN_W, PdfMargin } from "./PdfMargin.jsx";
+import { PdfNoteSheet } from "./PdfNoteSheet.jsx";
import {
allPinnedNotes,
asPdfStrokeDoc,
hasOverlay,
notesForPage,
strokesForPage,
+ totalPinnedNotes,
totalStrokes,
withPage,
withPageNotes,
@@ -68,48 +71,68 @@ function PdfPage({ doc, index, width, height, size, visible, ink, pins, selectab
}, [doc, index, width, visible]);
return (
+ /* The page and its margin are one row, so a note card can be placed at the
+ height of its anchor without knowing where the page sits in the scroll. */
+
+ {pins.withMargin && !pins.hidden && (
+ pins.onPlace(index + 1, size, at)}
onChange={(id, text) => pins.onChange(index + 1, size, id, text)}
- onMove={(id, x, y) => pins.onMove(index + 1, size, id, x, y)}
onRemove={(id) => pins.onRemove(index + 1, size, id)}
numberOf={pins.numberOf}
/>
@@ -343,7 +366,30 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
// The widest page decides the scale, so a document mixing portrait and
// landscape does not make the narrow pages jump about as you scroll.
const widestPt = sizes.reduce((w, s) => Math.max(w, s.width), 0) || 1;
- const fitWidth = Math.max(240, Math.min(containerWidth - 32, 1100));
+
+ /* Whether there is room to write beside the page.
+ *
+ * On a phone there is not: a page narrow enough to read leaves no white space
+ * next to it, and taking 200px for a column would make the document itself
+ * unreadable. There the notes come up from the bottom instead.
+ *
+ * The column is kept as soon as the document has any pinned note, or while one
+ * is being placed — so it is already there before the first note exists and
+ * the page does not jump sideways at the moment of writing it. */
+ const roomForMargin = containerWidth >= MARGIN_W + 380;
+ const withMargin = roomForMargin && (placing || totalPinnedNotes(ink) > 0);
+
+ /* The open note, with the page it belongs to — the sheet needs the page size
+ to write a change back into the right page's overlay. */
+ const openSheet = (() => {
+ if (!openPin) return null;
+ const found = allPinnedNotes(ink).find((n) => n.id === openPin);
+ const size = found ? sizes[found.page - 1] : null;
+ return found && size ? { note: found, page: found.page, size } : null;
+ })();
+
+ const reserved = withMargin ? MARGIN_W + 12 : 0;
+ const fitWidth = Math.max(240, Math.min(containerWidth - 32 - reserved, 1100));
const pageWidth = Math.round(fitWidth * zoom);
const scale = pageWidth / widestPt;
@@ -359,7 +405,10 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
let current = 1;
for (const child of inner.children) {
- const n = Number(child.dataset.pdfPage);
+ // The row, not the page: the page sits inside it next to its margin, and
+ // reading `dataset.pdfPage` off the row found nothing — so every page was
+ // "out of view", and the text layers were cancelled before they rendered.
+ const n = Number(child.dataset.pdfRow);
if (!n) continue;
const start = child.offsetTop;
const end = start + child.offsetHeight;
@@ -428,7 +477,21 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
}
return (
-
+
-
+
{state.status === "loading" && (
)}
+
+ {/* No margin to write in — the note comes up from the bottom instead, over
+ the page but within reach of a thumb, and only while it is open. */}
+ {!withMargin && openSheet && (
+ changeNote(openSheet.page, openSheet.size, openSheet.note.id, text)}
+ onRemove={() => removeNote(openSheet.page, openSheet.size, openSheet.note.id)}
+ onClose={() => setOpenPin(null)}
+ />
+ )}
);
}
diff --git a/readme.md b/readme.md
index 55315ea..c8156da 100644
--- a/readme.md
+++ b/readme.md
@@ -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 |
| `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 |
-| `pdf` | 57 | import, page proportions, lazy loading, and reading with the network off |
+| `pdf` | 70 | import, page proportions, lazy loading, and reading with the network off |
| `attachments-lifecycle` | 10 | a picture is kept while any version shows it, and freed once none does |
| `conflict` | 10 | a late write wins but the state it buries survives as a version |
| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
@@ -760,12 +760,30 @@ The panel beside a PDF is for notes about the document. A note that belongs to
third paragraph" has already lost its place. So a note can be pinned to a spot:
click **Randnotiz**, click the passage, write.
-Pins are stored in the same per-page overlay as the drawings, in **PDF points**.
-That is what keeps one on its sentence at every zoom, on every screen, and in the
-exported file — the same property the strokes have, for the same reason.
+**The note is written beside the page, not on it.** Only a small numbered marker
+sits on the document, with a hairline out to a card in the margin, level with the
+passage. Writing onto the page would cover the sentence the note is about, which
+is the one thing it must not do. Cards keep one height, so two notes close
+together are laid out by pushing the second down until they no longer overlap;
+the one being written in grows and rises above the rest.
-Being in the overlay rather than in the note body has three consequences, each of
-which needed handling rather than assuming:
+**On a phone there is no margin.** A page narrow enough to read leaves no white
+space beside it, and taking 200px for a column would make the document itself
+unreadable. There, tapping a marker brings the note up from the bottom — over the
+page, but only while it is open, within reach of a thumb, and with the passage
+still visible above it.
+
+The column is kept as soon as the document has one pinned note, or while one is
+being placed, so the page does not jump sideways at the moment of writing the
+first. Reserving it also 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.
+
+Positions are stored in **PDF points**, the same space as the drawings, so a
+marker stays on its sentence at every zoom and on every screen. Being in the
+overlay rather than in the note body has three consequences, each of which needed
+handling rather than assuming:
- **Search.** Nothing else would index them, so the viewer feeds them to the
local index itself, with their page numbers — finding the words is only half of