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
@@ -169,7 +169,18 @@ export function EditorPane({
|
||||
);
|
||||
|
||||
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
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -210,7 +221,7 @@ export function EditorPane({
|
||||
{I.trash}
|
||||
</button>
|
||||
</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" ? (
|
||||
<PdfWorkspace
|
||||
note={note}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<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.
|
||||
* 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 (
|
||||
<div
|
||||
key={pin.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${xf * 100}%`,
|
||||
top: `${yf * 100}%`,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
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={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
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
|
||||
data-pin={pin.id}
|
||||
title={pin.text || "Notiz zu dieser Stelle"}
|
||||
@@ -227,32 +117,22 @@ export function PdfPins({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 22,
|
||||
height: 22,
|
||||
width: open ? 24 : 20,
|
||||
height: open ? 24 : 20,
|
||||
borderRadius: "50%",
|
||||
border: `2px solid ${T.bar}`,
|
||||
border: `2px solid ${open ? "#fff" : T.bar}`,
|
||||
background: T.accent,
|
||||
color: "#fff",
|
||||
fontSize: 11,
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
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%)",
|
||||
touchAction: "none",
|
||||
}}
|
||||
>
|
||||
{numberOf(pin.id)}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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. */
|
||||
<div
|
||||
data-pdf-page={index + 1}
|
||||
style={{
|
||||
position: "relative",
|
||||
width,
|
||||
height,
|
||||
margin: "0 auto 16px",
|
||||
background: "#fff",
|
||||
boxShadow: "0 2px 10px rgba(0,0,0,0.25)",
|
||||
borderRadius: 2,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
data-pdf-row={index + 1}
|
||||
style={{ display: "flex", justifyContent: "center", gap: 12, marginBottom: 16 }}
|
||||
>
|
||||
<canvas ref={canvasRef} style={{ display: "block", width: "100%" }} />
|
||||
<div ref={textRef} className="pdf-text-layer" data-selectable={selectable ? "true" : "false"} />
|
||||
<PdfDrawLayer
|
||||
pageNo={index + 1}
|
||||
pageSize={size}
|
||||
cssWidth={width}
|
||||
cssHeight={height}
|
||||
strokes={ink.strokes}
|
||||
drawing={ink.drawing}
|
||||
tool={ink.tool}
|
||||
color={ink.color}
|
||||
sizeIdx={ink.sizeIdx}
|
||||
hidden={ink.hidden}
|
||||
onCommit={ink.onCommit}
|
||||
/>
|
||||
{!pins.hidden && (
|
||||
<PdfPins
|
||||
T={pins.T}
|
||||
<div
|
||||
data-pdf-page={index + 1}
|
||||
style={{
|
||||
position: "relative",
|
||||
width,
|
||||
height,
|
||||
flexShrink: 0,
|
||||
background: "#fff",
|
||||
boxShadow: "0 2px 10px rgba(0,0,0,0.25)",
|
||||
borderRadius: 2,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<canvas ref={canvasRef} style={{ display: "block", width: "100%" }} />
|
||||
<div ref={textRef} className="pdf-text-layer" data-selectable={selectable ? "true" : "false"} />
|
||||
<PdfDrawLayer
|
||||
pageNo={index + 1}
|
||||
pageSize={size}
|
||||
cssWidth={width}
|
||||
cssHeight={height}
|
||||
strokes={ink.strokes}
|
||||
drawing={ink.drawing}
|
||||
tool={ink.tool}
|
||||
color={ink.color}
|
||||
sizeIdx={ink.sizeIdx}
|
||||
hidden={ink.hidden}
|
||||
onCommit={ink.onCommit}
|
||||
/>
|
||||
{!pins.hidden && (
|
||||
<PdfPins
|
||||
T={pins.T}
|
||||
pageNo={index + 1}
|
||||
pageSize={size}
|
||||
cssWidth={width}
|
||||
cssHeight={height}
|
||||
notes={pins.notes}
|
||||
placing={pins.placing}
|
||||
openId={pins.openId}
|
||||
withMargin={pins.withMargin}
|
||||
onOpen={pins.onOpen}
|
||||
onPlace={(at) => pins.onPlace(index + 1, size, at)}
|
||||
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}
|
||||
placing={pins.placing}
|
||||
pageHeight={size.height}
|
||||
cssHeight={height}
|
||||
openId={pins.openId}
|
||||
onOpen={pins.onOpen}
|
||||
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)}
|
||||
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 (
|
||||
<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
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -441,6 +504,10 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
|
||||
fontSize: 12,
|
||||
color: T.muted,
|
||||
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}>
|
||||
@@ -629,7 +696,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
|
||||
</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" && (
|
||||
<div
|
||||
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,
|
||||
openId: openPin,
|
||||
hidden: hideInk,
|
||||
withMargin,
|
||||
onOpen: setOpenPin,
|
||||
onPlace: placeNote,
|
||||
onChange: changeNote,
|
||||
@@ -679,6 +747,19 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user