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:
1 parent
6438648313
commit
011ba82a94
7 files changed
+653
-208
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user