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>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-10 14:46:56 +02:00
1 parent 6438648313
commit 011ba82a94
7 files changed
+621 -176

No files matched your search

+115 -5
View File
@@ -480,11 +480,26 @@ try {
await p.mouse.click(page1.x + page1.width * 0.4, page1.y + page1.height * 0.3); await p.mouse.click(page1.x + page1.width * 0.4, page1.y + page1.height * 0.3);
await p.waitForTimeout(800); await p.waitForTimeout(800);
check((await p.locator("[data-pin]").count()) === 1, "clicking the page leaves a pin"); check((await p.locator("[data-pin]").count()) === 1, "clicking the page leaves a marker on the spot");
const bubble = p.getByLabel("Pinned note"); check(
check(await bubble.isVisible(), "and opens straight into writing"); (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.getByRole("button", { name: "Fertig" }).click();
await p.waitForTimeout(2500); await p.waitForTimeout(2500);
@@ -539,11 +554,17 @@ try {
await p.waitForTimeout(4000); await p.waitForTimeout(4000);
check((await p.locator("[data-pin]").count()) === 1, "the pin survives a reload"); 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.locator("[data-pin]").click();
await p.waitForTimeout(600); await p.waitForTimeout(600);
check( check(
(await p.getByLabel("Pinned note").inputValue()) === "Drehmoment beachten", (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 /* 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 ── */ /* ── the notes panel lives in the same toolbar now ── */
check( check(
(await p.locator("[data-pdf-toggle-notes]").count()) === 1, (await p.locator("[data-pdf-toggle-notes]").count()) === 1,
+13 -2
View File
@@ -169,7 +169,18 @@ export function EditorPane({
); );
return ( return (
<div style={{ flex: 1, display: "flex", flexDirection: "column", background: T.bg, fontFamily: FONT_UI }}> <div
style={{
flex: 1,
// A flex item is otherwise at least as wide as its content, so a wide
// pane — a PDF beside its margin — could push itself past the window.
minWidth: 0,
display: "flex",
flexDirection: "column",
background: T.bg,
fontFamily: FONT_UI,
}}
>
<div <div
style={{ style={{
display: "flex", display: "flex",
@@ -210,7 +221,7 @@ export function EditorPane({
{I.trash} {I.trash}
</button> </button>
</div> </div>
<div style={{ flex: 1, overflow: "hidden", display: "flex", minHeight: 0 }}> <div style={{ flex: 1, overflow: "hidden", display: "flex", minHeight: 0, minWidth: 0 }}>
{note.type === "pdf" ? ( {note.type === "pdf" ? (
<PdfWorkspace <PdfWorkspace
note={note} note={note}
+214
View File
@@ -0,0 +1,214 @@
import { useEffect, useRef, useState } from "react";
/**
* The notes for a page, written in the margin beside it.
*
* This is where a reader has always put remarks: level with the passage, in the
* white space, not over the words. Writing them onto the page would cover the
* sentence the note is about, which is the one thing a note about a sentence
* must not do.
*
* Each card sits at the height of its anchor. Cards keep one height so that two
* notes close together can be laid out without measuring: they are simply pushed
* down until they no longer overlap, and the marker's hairline still says which
* spot each belongs to. The card being written in grows and rises above the
* others, because that one is being read closely and the rest are not.
*/
export const MARGIN_W = 208;
const CARD_H = 74;
const CARD_GAP = 6;
/** Cards at their anchors, pushed down where they would sit on top of each other. */
function layout(notes, pageHeight, cssHeight) {
let previousBottom = -Infinity;
return notes
.slice()
.sort((a, b) => 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 (
<div
data-pin-card={note.id}
onClick={() => !open && onOpen(note.id)}
style={{
position: "absolute",
left: 0,
right: 0,
top,
zIndex: open ? 5 : 1,
display: "flex",
gap: 6,
padding: 8,
borderRadius: 10,
border: `1px solid ${open ? T.accent : T.border}`,
background: T.bar,
boxShadow: open ? "0 8px 22px rgba(0,0,0,.28)" : "none",
cursor: open ? "default" : "pointer",
}}
>
<span
style={{
flexShrink: 0,
width: 18,
height: 18,
borderRadius: "50%",
background: T.accent,
color: "#fff",
fontSize: 10.5,
fontWeight: 700,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{number}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
{open ? (
<>
<textarea
ref={areaRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") {
commit();
onOpen(null);
}
}}
aria-label="Pinned note"
placeholder="Notiz zu dieser Stelle…"
rows={4}
style={{
width: "100%",
resize: "vertical",
border: `1px solid ${T.border}`,
borderRadius: 8,
background: "transparent",
color: T.text,
fontSize: 11.5,
lineHeight: 1.5,
padding: "5px 7px",
outline: "none",
fontFamily: "inherit",
}}
/>
<div style={{ display: "flex", alignItems: "center", marginTop: 5 }}>
<button
onClick={onRemove}
data-pin-remove
style={{
border: "none",
background: "transparent",
color: T.danger,
cursor: "pointer",
fontSize: 10.5,
padding: 0,
}}
>
Entfernen
</button>
<span style={{ flex: 1 }} />
<button
onClick={() => {
commit();
onOpen(null);
}}
style={{
border: "none",
background: T.accent,
color: "#fff",
borderRadius: 7,
cursor: "pointer",
fontSize: 10.5,
fontWeight: 600,
padding: "3px 10px",
}}
>
Fertig
</button>
</div>
</>
) : (
<div
style={{
fontSize: 11.5,
lineHeight: 1.45,
color: note.text ? T.text : T.muted,
maxHeight: CARD_H - 22,
overflow: "hidden",
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
wordBreak: "break-word",
}}
>
{note.text || "Leer — zum Schreiben antippen"}
</div>
)}
</div>
</div>
);
}
export function PdfMargin({ T, notes, pageHeight, cssHeight, openId, onOpen, onChange, onRemove, numberOf }) {
const placed = layout(notes, pageHeight, cssHeight);
// Cards near the foot of a page may need more room than the page is tall.
const needed = placed.length ? placed[placed.length - 1].top + CARD_H : 0;
return (
<div
data-pdf-margin
style={{
position: "relative",
width: MARGIN_W,
flexShrink: 0,
minHeight: Math.max(cssHeight, needed),
}}
>
{placed.map(({ note, top }) => (
<Card
key={note.id}
T={T}
note={note}
top={top}
number={numberOf(note.id)}
open={openId === note.id}
onOpen={onOpen}
onChange={(text) => onChange(note.id, text)}
onRemove={() => onRemove(note.id)}
/>
))}
</div>
);
}
+131
View File
@@ -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 (
<div
data-pin-sheet={note.id}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
zIndex: 60,
background: T.bar,
borderTop: `1px solid ${T.border}`,
borderRadius: "14px 14px 0 0",
boxShadow: "0 -8px 26px rgba(0,0,0,.3)",
padding: "10px 12px calc(10px + env(safe-area-inset-bottom))",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
<span
style={{
width: 20,
height: 20,
borderRadius: "50%",
background: T.accent,
color: "#fff",
fontSize: 11,
fontWeight: 700,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
{number}
</span>
<span style={{ fontSize: 11.5, color: T.muted, flex: 1 }}>Notiz zu dieser Stelle</span>
<button
onClick={onRemove}
data-pin-remove
style={{
border: "none",
background: "transparent",
color: T.danger,
cursor: "pointer",
fontSize: 11.5,
padding: 0,
}}
>
Entfernen
</button>
</div>
<textarea
ref={areaRef}
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
aria-label="Pinned note"
placeholder="Notiz zu dieser Stelle…"
rows={3}
style={{
width: "100%",
resize: "none",
border: `1px solid ${T.border}`,
borderRadius: 8,
background: "transparent",
color: T.text,
fontSize: 13,
lineHeight: 1.5,
padding: "8px 10px",
outline: "none",
fontFamily: "inherit",
}}
/>
<button
onClick={close}
style={{
width: "100%",
marginTop: 8,
border: "none",
background: T.accent,
color: "#fff",
borderRadius: 10,
cursor: "pointer",
fontSize: 13,
fontWeight: 600,
padding: "9px 0",
}}
>
Fertig
</button>
</div>
);
}
+37 -157
View File
@@ -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 * Only the anchor lives on the document — a small numbered dot on the spot the
* notes about a *passage* — the paragraph you were reading when you thought of * note is about. The note itself is written in the margin beside the page, or on
* it. A note that has to say "on page 14, third paragraph" is a note that has * a phone in a sheet from the bottom. Writing *onto* the page covers the
* lost its place. * 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 * Positions are in PDF points, the same space as the drawing, so a marker stays
* stays on its sentence at every zoom and on every screen. * 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 (
<div
ref={ref}
data-pin-bubble={pin.id}
onPointerDown={(e) => e.stopPropagation()}
style={{
position: "absolute",
top: 26,
[side]: 0,
width: 230,
zIndex: 30,
background: T.bar,
border: `1px solid ${T.border}`,
borderRadius: 10,
boxShadow: "0 10px 26px rgba(0,0,0,.3)",
padding: 8,
}}
>
<textarea
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation(); // the viewer's shortcuts are not wanted in here
if (e.key === "Escape") {
commit();
onClose();
}
}}
aria-label="Pinned note"
placeholder="Notiz zu dieser Stelle…"
rows={4}
style={{
width: "100%",
resize: "vertical",
border: `1px solid ${T.border}`,
borderRadius: 8,
background: "transparent",
color: T.text,
fontSize: 12,
lineHeight: 1.5,
padding: "6px 8px",
outline: "none",
fontFamily: "inherit",
}}
/>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 6 }}>
<button
onClick={onRemove}
data-pin-remove
style={{
border: "none",
background: "transparent",
color: T.danger,
cursor: "pointer",
fontSize: 11,
padding: 0,
}}
>
Entfernen
</button>
<span style={{ flex: 1 }} />
<button
onClick={() => {
commit();
onClose();
}}
style={{
border: "none",
background: T.accent,
color: "#fff",
borderRadius: 8,
cursor: "pointer",
fontSize: 11,
fontWeight: 600,
padding: "4px 12px",
}}
>
Fertig
</button>
</div>
</div>
);
}
/**
* 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.
*/ */
export function PdfPins({ export function PdfPins({
T, T,
@@ -143,11 +20,10 @@ export function PdfPins({
notes, notes,
placing, placing,
openId, openId,
withMargin,
onOpen, onOpen,
onPlace, onPlace,
onChange,
onMove, onMove,
onRemove,
numberOf, numberOf,
}) { }) {
const layerRef = useRef(null); 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 /* Dragging is tracked with pointer events rather than the HTML drag API:
* cannot report a position continuously, and a pin has to follow the finger to * that one cannot report a position continuously, and a marker has to follow
* be placed on a line of text. */ * the finger to be put on a line of text. */
const startDrag = (e, pin) => { const startDrag = (e, pin) => {
if (placing) return; if (placing) return;
e.preventDefault(); e.preventDefault();
@@ -198,8 +74,8 @@ export function PdfPins({
width: cssWidth, width: cssWidth,
height: cssHeight, height: cssHeight,
zIndex: 12, zIndex: 12,
// Only the pins are targets unless a note is being placed, or the page // Only the markers are targets unless a note is being placed, or the
// could neither be scrolled nor its text selected. // page could neither be scrolled nor its text selected.
pointerEvents: placing ? "auto" : "none", pointerEvents: placing ? "auto" : "none",
cursor: placing ? "crosshair" : "default", cursor: placing ? "crosshair" : "default",
}} }}
@@ -207,16 +83,30 @@ export function PdfPins({
{notes.map((pin) => { {notes.map((pin) => {
const xf = pin.x / pageSize.width; const xf = pin.x / pageSize.width;
const yf = pin.y / pageSize.height; const yf = pin.y / pageSize.height;
const open = openId === pin.id;
return ( return (
<div <div
key={pin.id} key={pin.id}
style={{ position: "absolute", left: `${xf * 100}%`, top: `${yf * 100}%`, pointerEvents: "auto" }}
>
{/* 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 && (
<div
style={{ style={{
position: "absolute", position: "absolute",
left: `${xf * 100}%`, left: 0,
top: `${yf * 100}%`, top: 0,
pointerEvents: "auto", height: 1,
width: `calc(${(1 - xf) * cssWidth}px)`,
// Barely there until the note is open: it crosses the page's
// own text, and a permanent line over the sentence defeats
// the point of writing beside the page rather than on it.
background: open ? T.accent : T.accent + "26",
pointerEvents: "none",
}} }}
> />
)}
<button <button
data-pin={pin.id} data-pin={pin.id}
title={pin.text || "Notiz zu dieser Stelle"} title={pin.text || "Notiz zu dieser Stelle"}
@@ -227,32 +117,22 @@ export function PdfPins({
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
width: 22, width: open ? 24 : 20,
height: 22, height: open ? 24 : 20,
borderRadius: "50%", borderRadius: "50%",
border: `2px solid ${T.bar}`, border: `2px solid ${open ? "#fff" : T.bar}`,
background: T.accent, background: T.accent,
color: "#fff", color: "#fff",
fontSize: 11, fontSize: 10.5,
fontWeight: 700, fontWeight: 700,
cursor: "grab", cursor: "grab",
boxShadow: "0 2px 6px rgba(0,0,0,.35)", boxShadow: open ? `0 0 0 3px ${T.accent}44` : "0 2px 6px rgba(0,0,0,.35)",
transform: "translate(-50%, -50%)", transform: "translate(-50%, -50%)",
touchAction: "none", touchAction: "none",
}} }}
> >
{numberOf(pin.id)} {numberOf(pin.id)}
</button> </button>
{openId === pin.id && (
<PinBubble
T={T}
pin={pin}
side={bubbleSide(xf)}
onChange={(text) => onChange(pin.id, text)}
onClose={() => onOpen(null)}
onRemove={() => onRemove(pin.id)}
/>
)}
</div> </div>
); );
})} })}
+87 -6
View File
@@ -7,12 +7,15 @@ import { extractAndCache, indexPdfText } from "./pdfText";
import "./textLayer.css"; import "./textLayer.css";
import { PdfDrawLayer } from "./PdfDrawLayer.jsx"; import { PdfDrawLayer } from "./PdfDrawLayer.jsx";
import { PdfPins } from "./PdfPins.jsx"; import { PdfPins } from "./PdfPins.jsx";
import { MARGIN_W, PdfMargin } from "./PdfMargin.jsx";
import { PdfNoteSheet } from "./PdfNoteSheet.jsx";
import { import {
allPinnedNotes, allPinnedNotes,
asPdfStrokeDoc, asPdfStrokeDoc,
hasOverlay, hasOverlay,
notesForPage, notesForPage,
strokesForPage, strokesForPage,
totalPinnedNotes,
totalStrokes, totalStrokes,
withPage, withPage,
withPageNotes, withPageNotes,
@@ -68,13 +71,19 @@ function PdfPage({ doc, index, width, height, size, visible, ink, pins, selectab
}, [doc, index, width, visible]); }, [doc, index, width, visible]);
return ( 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. */
<div
data-pdf-row={index + 1}
style={{ display: "flex", justifyContent: "center", gap: 12, marginBottom: 16 }}
>
<div <div
data-pdf-page={index + 1} data-pdf-page={index + 1}
style={{ style={{
position: "relative", position: "relative",
width, width,
height, height,
margin: "0 auto 16px", flexShrink: 0,
background: "#fff", background: "#fff",
boxShadow: "0 2px 10px rgba(0,0,0,0.25)", boxShadow: "0 2px 10px rgba(0,0,0,0.25)",
borderRadius: 2, borderRadius: 2,
@@ -106,10 +115,24 @@ function PdfPage({ doc, index, width, height, size, visible, ink, pins, selectab
notes={pins.notes} notes={pins.notes}
placing={pins.placing} placing={pins.placing}
openId={pins.openId} openId={pins.openId}
withMargin={pins.withMargin}
onOpen={pins.onOpen} onOpen={pins.onOpen}
onPlace={(at) => pins.onPlace(index + 1, size, at)} onPlace={(at) => 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)} onMove={(id, x, y) => pins.onMove(index + 1, size, id, x, y)}
numberOf={pins.numberOf}
/>
)}
</div>
{pins.withMargin && !pins.hidden && (
<PdfMargin
T={pins.T}
notes={pins.notes}
pageHeight={size.height}
cssHeight={height}
openId={pins.openId}
onOpen={pins.onOpen}
onChange={(id, text) => pins.onChange(index + 1, size, id, text)}
onRemove={(id) => pins.onRemove(index + 1, size, id)} onRemove={(id) => pins.onRemove(index + 1, size, id)}
numberOf={pins.numberOf} 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 // The widest page decides the scale, so a document mixing portrait and
// landscape does not make the narrow pages jump about as you scroll. // 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 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 pageWidth = Math.round(fitWidth * zoom);
const scale = pageWidth / widestPt; const scale = pageWidth / widestPt;
@@ -359,7 +405,10 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
let current = 1; let current = 1;
for (const child of inner.children) { 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; if (!n) continue;
const start = child.offsetTop; const start = child.offsetTop;
const end = start + child.offsetHeight; const end = start + child.offsetHeight;
@@ -428,7 +477,21 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
} }
return ( return (
<div style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0, background: T.bg }}> <div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
minHeight: 0,
/* Without this a flex item is at least as wide as its content, so the
margin column made the viewer wider, which made there be room for the
margin column — the page shrank to 254px inside a 420px phone while
the layout insisted it was 611px wide. */
minWidth: 0,
background: T.bg,
position: "relative",
}}
>
<div <div
style={{ style={{
display: "flex", display: "flex",
@@ -441,6 +504,10 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
fontSize: 12, fontSize: 12,
color: T.muted, color: T.muted,
flexShrink: 0, flexShrink: 0,
/* On a phone the controls are wider than the screen. Scrolling the bar
keeps every one of them reachable; without it the ones at the end
were simply cut off. */
overflowX: "auto",
}} }}
> >
<button onClick={() => goToPage(Math.max(1, pageNo - 1))} title="Vorherige Seite" style={barButton}> <button onClick={() => goToPage(Math.max(1, pageNo - 1))} title="Vorherige Seite" style={barButton}>
@@ -629,7 +696,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
</div> </div>
</div> </div>
<div ref={scrollRef} style={{ flex: 1, overflow: "auto", padding: 16, minHeight: 0 }}> <div ref={scrollRef} style={{ flex: 1, overflow: "auto", padding: 16, minHeight: 0, minWidth: 0 }}>
{state.status === "loading" && ( {state.status === "loading" && (
<div <div
style={{ color: T.muted, fontSize: 13, fontFamily: FONT_UI, textAlign: "center", padding: 40 }} style={{ color: T.muted, fontSize: 13, fontFamily: FONT_UI, textAlign: "center", padding: 40 }}
@@ -667,6 +734,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
placing, placing,
openId: openPin, openId: openPin,
hidden: hideInk, hidden: hideInk,
withMargin,
onOpen: setOpenPin, onOpen: setOpenPin,
onPlace: placeNote, onPlace: placeNote,
onChange: changeNote, onChange: changeNote,
@@ -679,6 +747,19 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
</div> </div>
)} )}
</div> </div>
{/* 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 && (
<PdfNoteSheet
T={T}
note={openSheet.note}
number={pinNumbers.get(openSheet.note.id) ?? ""}
onChange={(text) => changeNote(openSheet.page, openSheet.size, openSheet.note.id, text)}
onRemove={() => removeNote(openSheet.page, openSheet.size, openSheet.note.id)}
onClose={() => setOpenPin(null)}
/>
)}
</div> </div>
); );
} }
+24 -6
View File
@@ -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 | | `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 | | `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 | | `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 | | `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 | | `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 | | `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: third paragraph" has already lost its place. So a note can be pinned to a spot:
click **Randnotiz**, click the passage, write. click **Randnotiz**, click the passage, write.
Pins are stored in the same per-page overlay as the drawings, in **PDF points**. **The note is written beside the page, not on it.** Only a small numbered marker
That is what keeps one on its sentence at every zoom, on every screen, and in the sits on the document, with a hairline out to a card in the margin, level with the
exported file — the same property the strokes have, for the same reason. 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 **On a phone there is no margin.** A page narrow enough to read leaves no white
which needed handling rather than assuming: 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 - **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 local index itself, with their page numbers — finding the words is only half of