Four small things about the shape of the app. Covers can be patterns as well as colours — ruled paper, graph paper, pinstripe, bands, dots, cross-hatch, linen, washes, a ledger — drawn entirely with CSS gradient layers. No image files, nothing to upload, crisp at any size, and offline like everything else. A cover is stored as an index into that list, so the new ones are appended and never inserted: renumbering would silently repaint every notebook anyone already has. Two things the patterns taught. One idea per cover: two crossed diagonals meant as a chevron came out as noisy plaid at the size a cover is actually read, and were replaced with plain bands. And the grid has to scroll — with 27 covers the dialog ran past the bottom of a laptop screen and took the Create button with it, which a check now holds at 1440x800. The search box in a notebook is simply there now, and the button that used to open it is gone. A magnifying glass cost a click and having to remember that searching was possible at all; a search box is one line and says for itself what it does. Margin notes went from 208x74 to 288x112 — from three short lines to about five. Three lines is a caption, not a remark on a passage, and the space beside the page was sitting mostly empty. And the notebook edit dialog was still part German, and would not say what the server's default number of versions actually is. It names it now, read from the server rather than written into the interface: it is set with MAX_VERSIONS_PER_NOTE, and a hardcoded "50" would quietly start lying the day that changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
218 lines
6.5 KiB
React
218 lines
6.5 KiB
React
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.
|
|
*/
|
|
|
|
/* The margin is a column you actually write in, not a strip of labels. It was
|
|
* 208 x 74, which held about three short lines and left the space beside the
|
|
* page mostly empty — for a remark on a passage, three lines is a caption. */
|
|
export const MARGIN_W = 288;
|
|
const CARD_H = 112;
|
|
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="Note on this passage…"
|
|
rows={7}
|
|
style={{
|
|
width: "100%",
|
|
resize: "vertical",
|
|
border: `1px solid ${T.border}`,
|
|
borderRadius: 8,
|
|
background: "transparent",
|
|
color: T.text,
|
|
fontSize: 12.5,
|
|
lineHeight: 1.55,
|
|
padding: "7px 9px",
|
|
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,
|
|
}}
|
|
>
|
|
Remove
|
|
</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",
|
|
}}
|
|
>
|
|
Done
|
|
</button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div
|
|
style={{
|
|
fontSize: 12.5,
|
|
lineHeight: 1.5,
|
|
color: note.text ? T.text : T.muted,
|
|
maxHeight: CARD_H - 24,
|
|
overflow: "hidden",
|
|
display: "-webkit-box",
|
|
WebkitLineClamp: 5,
|
|
WebkitBoxOrient: "vertical",
|
|
wordBreak: "break-word",
|
|
}}
|
|
>
|
|
{note.text || "Empty — tap to write"}
|
|
</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>
|
|
);
|
|
}
|