Pin notes to passages in a PDF, and put the notes control with the rest
The panel beside a PDF is for notes about the document; a note that belongs to one paragraph is a different thing, and one that has to begin "on page 14, third paragraph" has already lost its place. Click Randnotiz, click the passage, write. Pins ride in the same per-page overlay as the drawings, in PDF points, so one stays on its sentence at every zoom and in the exported file. Living outside the note body has consequences that each needed handling: nothing else would index them for search, so the viewer feeds them in with their page numbers; clearing a page's ink must not take them; and "is this overlay empty?" was answered by counting strokes — in the client *and* independently in the server, so an overlay holding only pinned notes was stored as NULL and lost every note on it. They are stamped into the annotated export as a numbered marker plus a box of text, on whichever side of the pin has room. Standard PDF fonts are Latin-1, so characters outside it are replaced rather than failing the whole export. The Notizen control moves into the viewer's toolbar. A second row for one button cost reading height on every screen and put one of the document's controls somewhere none of the others were. On a phone, where the viewer is not on screen while the notes are, the way back sits in the panel. Renaming a separator no longer shifts the row: the two rules always share what is left over equally and the middle keeps one width in both states. 215 unit tests, 206 browser checks across eleven suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2cee00e38e
commit
6438648313
16 files changed
+1228
-170
No files matched your search
@@ -1,11 +1,22 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import * as sync from "../../syncEngine";
|
||||
import * as searchIndex from "../../search";
|
||||
import { openPdf, renderPage, renderTextLayer } from "./pdfjs";
|
||||
import { extractAndCache, indexPdfText } from "./pdfText";
|
||||
import "./textLayer.css";
|
||||
import { PdfDrawLayer } from "./PdfDrawLayer.jsx";
|
||||
import { asPdfStrokeDoc, strokesForPage, totalStrokes, withPage } from "./pageStrokes";
|
||||
import { PdfPins } from "./PdfPins.jsx";
|
||||
import {
|
||||
allPinnedNotes,
|
||||
asPdfStrokeDoc,
|
||||
hasOverlay,
|
||||
notesForPage,
|
||||
strokesForPage,
|
||||
totalStrokes,
|
||||
withPage,
|
||||
withPageNotes,
|
||||
} from "./pageStrokes";
|
||||
import { COLORS } from "../../ui/constants.jsx";
|
||||
import { I } from "../../ui/icons.jsx";
|
||||
import { PEN_SIZES, DEFAULT_PEN_SIZE, newSaveToken } from "../drawing/strokes.js";
|
||||
@@ -23,7 +34,7 @@ const ZOOM_STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
|
||||
* list: a PDF page is expensive to draw and cheap to keep, and scrolling back up
|
||||
* through a document should not repaint everything.
|
||||
*/
|
||||
function PdfPage({ doc, index, width, height, size, visible, ink, selectable }) {
|
||||
function PdfPage({ doc, index, width, height, size, visible, ink, pins, selectable }) {
|
||||
const canvasRef = useRef(null);
|
||||
const textRef = useRef(null);
|
||||
const renderedAt = useRef(0);
|
||||
@@ -85,6 +96,24 @@ function PdfPage({ doc, index, width, height, size, visible, ink, selectable })
|
||||
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}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -97,10 +126,12 @@ function PdfPage({ doc, index, width, height, size, visible, ink, selectable })
|
||||
* proportions before anything is drawn, so the scrollbar is honest from the
|
||||
* first frame instead of jumping as pages arrive.
|
||||
*/
|
||||
export function PdfViewer({ note, dark, onUpdate, onPagesReady }) {
|
||||
export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
|
||||
const [state, setState] = useState({ status: "loading" });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [drawing, setDrawing] = useState(false);
|
||||
const [placing, setPlacing] = useState(false);
|
||||
const [openPin, setOpenPin] = useState(null);
|
||||
const [hideInk, setHideInk] = useState(false);
|
||||
const [tool, setTool] = useState("pen");
|
||||
const [color, setColor] = useState(COLORS[1]);
|
||||
@@ -137,7 +168,9 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady }) {
|
||||
saveTimer.current = 0;
|
||||
const token = newSaveToken();
|
||||
pendingToken.current = token;
|
||||
onUpdate?.({ drawingStrokes: totalStrokes(next) ? { ...next, t: token } : null });
|
||||
// `hasOverlay`, not `totalStrokes`: a document with pinned notes but no
|
||||
// ink would otherwise be saved as null and lose every note on it.
|
||||
onUpdate?.({ drawingStrokes: hasOverlay(next) ? { ...next, t: token } : null });
|
||||
}, 700);
|
||||
},
|
||||
[onUpdate]
|
||||
@@ -167,6 +200,73 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady }) {
|
||||
},
|
||||
[state.sizes, saveInk]
|
||||
);
|
||||
/* ── pinned notes ── */
|
||||
|
||||
/* They live in the page overlay, not in the note body, so nothing else would
|
||||
* ever put them in the search index. Page numbers travel with them: finding
|
||||
* the words is only half of finding the note. */
|
||||
useEffect(() => {
|
||||
const pinned = allPinnedNotes(ink)
|
||||
.filter((n) => n.text.trim())
|
||||
.map((n) => `S. ${n.page}: ${n.text}`)
|
||||
.join(" · ");
|
||||
searchIndex.setPinnedText(note.id, note.title || "", pinned);
|
||||
}, [ink, note.id, note.title]);
|
||||
|
||||
/** Numbered in reading order, so the pin and the list agree at a glance. */
|
||||
const pinNumbers = useMemo(() => {
|
||||
const map = new Map();
|
||||
allPinnedNotes(ink).forEach((n, i) => map.set(n.id, i + 1));
|
||||
return map;
|
||||
}, [ink]);
|
||||
|
||||
const writeNotes = useCallback(
|
||||
(pageNumber, size, change) => {
|
||||
const current = notesForPage(inkRef.current, pageNumber);
|
||||
undoRef.current = [...undoRef.current.slice(-40), inkRef.current];
|
||||
saveInk(withPageNotes(inkRef.current, pageNumber, size, change(current)));
|
||||
},
|
||||
[saveInk]
|
||||
);
|
||||
|
||||
const placeNote = useCallback(
|
||||
(pageNumber, size, at) => {
|
||||
const id = `pin-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
writeNotes(pageNumber, size, (notes) => [...notes, { id, x: at.x, y: at.y, text: "" }]);
|
||||
setPlacing(false);
|
||||
setOpenPin(id); // straight into writing — placing a pin is wanting to write
|
||||
},
|
||||
[writeNotes]
|
||||
);
|
||||
|
||||
const changeNote = useCallback(
|
||||
(pageNumber, size, id, text) =>
|
||||
writeNotes(pageNumber, size, (notes) => notes.map((n) => (n.id === id ? { ...n, text } : n))),
|
||||
[writeNotes]
|
||||
);
|
||||
|
||||
const moveNote = useCallback(
|
||||
(pageNumber, size, id, x, y) =>
|
||||
writeNotes(pageNumber, size, (notes) =>
|
||||
notes.map((n) =>
|
||||
n.id === id
|
||||
? // Clamped to the page: a pin dragged past the edge would be
|
||||
// unreachable, and its text with it.
|
||||
{ ...n, x: Math.max(0, Math.min(x, size.width)), y: Math.max(0, Math.min(y, size.height)) }
|
||||
: n
|
||||
)
|
||||
),
|
||||
[writeNotes]
|
||||
);
|
||||
|
||||
const removeNote = useCallback(
|
||||
(pageNumber, size, id) => {
|
||||
writeNotes(pageNumber, size, (notes) => notes.filter((n) => n.id !== id));
|
||||
setOpenPin(null);
|
||||
},
|
||||
[writeNotes]
|
||||
);
|
||||
|
||||
const [pageNo, setPageNo] = useState(1);
|
||||
const [visible, setVisible] = useState(() => new Set([0, 1]));
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
@@ -358,11 +458,14 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady }) {
|
||||
</span>
|
||||
<div style={{ width: 1, height: 18, background: T.border, margin: "0 2px" }} />
|
||||
|
||||
{/* Reading and drawing are separate modes: while drawing, the ink layer
|
||||
takes the pointer, and scrolling with a finger still has to work. */}
|
||||
{/* Reading, drawing and pinning are separate modes: each takes the
|
||||
pointer differently, and scrolling with a finger still has to work. */}
|
||||
<button
|
||||
data-pdf-mode={drawing ? "draw" : "read"}
|
||||
onClick={() => setDrawing((v) => !v)}
|
||||
onClick={() => {
|
||||
setDrawing((v) => !v);
|
||||
setPlacing(false);
|
||||
}}
|
||||
title={drawing ? "Zeichnen beenden" : "Zeichnen"}
|
||||
style={{
|
||||
...barButton,
|
||||
@@ -377,6 +480,28 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady }) {
|
||||
{I.pen} {drawing ? "Zeichnen" : "Lesen"}
|
||||
</button>
|
||||
|
||||
{/* A note about a passage belongs on the passage. */}
|
||||
<button
|
||||
data-pdf-pin-mode={placing ? "on" : "off"}
|
||||
onClick={() => {
|
||||
setPlacing((v) => !v);
|
||||
setDrawing(false);
|
||||
setOpenPin(null);
|
||||
}}
|
||||
title={placing ? "Stelle anklicken — oder abbrechen" : "Notiz an eine Stelle heften"}
|
||||
style={{
|
||||
...barButton,
|
||||
width: "auto",
|
||||
padding: "0 10px",
|
||||
gap: 6,
|
||||
background: placing ? T.accent + "22" : "transparent",
|
||||
color: placing ? T.accent : T.muted,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{I.pin} {placing ? "Stelle wählen" : "Randnotiz"}
|
||||
</button>
|
||||
|
||||
{drawing && (
|
||||
<>
|
||||
{["pen", "highlighter", "eraser"].map((t) => (
|
||||
@@ -491,6 +616,8 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady }) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{notesToggle}
|
||||
|
||||
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<button onClick={() => stepZoom(-1)} title="Kleiner" style={barButton}>
|
||||
−
|
||||
@@ -524,7 +651,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady }) {
|
||||
height={Math.round(size.height * scale)}
|
||||
size={size}
|
||||
visible={visible.has(i)}
|
||||
selectable={!drawing}
|
||||
selectable={!drawing && !placing}
|
||||
ink={{
|
||||
strokes: strokesForPage(ink, i + 1),
|
||||
drawing,
|
||||
@@ -534,6 +661,19 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady }) {
|
||||
hidden: hideInk,
|
||||
onCommit: commitPage,
|
||||
}}
|
||||
pins={{
|
||||
T,
|
||||
notes: notesForPage(ink, i + 1),
|
||||
placing,
|
||||
openId: openPin,
|
||||
hidden: hideInk,
|
||||
onOpen: setOpenPin,
|
||||
onPlace: placeNote,
|
||||
onChange: changeNote,
|
||||
onMove: moveNote,
|
||||
onRemove: removeNote,
|
||||
numberOf: (id) => pinNumbers.get(id) ?? "",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user