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
@@ -13,7 +13,7 @@ import { parsingExtensions } from "../editor/extensions.js";
|
||||
import { CANON_W, CANON_H, docStrokes, paintStrokeDoc, sizeCanvas } from "../features/drawing/strokes.js";
|
||||
import type { NoteContent, NoteMeta, Notebook } from "../types";
|
||||
import { noteToMarkdown, safeFilename } from "./markdown";
|
||||
import { asPdfStrokeDoc, totalStrokes } from "../features/pdf/pageStrokes";
|
||||
import { asPdfStrokeDoc, hasOverlay } from "../features/pdf/pageStrokes";
|
||||
import { stampStrokesIntoPdf } from "../features/pdf/stampPdf";
|
||||
import { printNotes, type PrintableNote } from "./print";
|
||||
import { bytesToBlob, createZip, download, type ZipEntry } from "./zip";
|
||||
@@ -103,7 +103,9 @@ export async function exportPdfNote(meta: NoteMeta, withInk: boolean): Promise<v
|
||||
|
||||
const { content } = await loadNote(meta);
|
||||
const ink = asPdfStrokeDoc(content.drawingStrokes);
|
||||
if (totalStrokes(ink) === 0) {
|
||||
// Pinned notes count as much as strokes here: both are the reader's own work
|
||||
// on the document, and an export that dropped either would lose it silently.
|
||||
if (!hasOverlay(ink)) {
|
||||
download(bytesToBlob(bytes, "application/pdf"), `${name}.pdf`);
|
||||
return;
|
||||
}
|
||||
@@ -112,11 +114,11 @@ export async function exportPdfNote(meta: NoteMeta, withInk: boolean): Promise<v
|
||||
download(bytesToBlob(stamped, "application/pdf"), `${name} (mit Notizen).pdf`);
|
||||
}
|
||||
|
||||
/** True when the note's PDF carries any drawing, so the wizard can say so. */
|
||||
/** True when the PDF carries drawings or pinned notes, so the wizard can say so. */
|
||||
export async function pdfNoteHasInk(meta: NoteMeta): Promise<boolean> {
|
||||
try {
|
||||
const { content } = await loadNote(meta);
|
||||
return totalStrokes(asPdfStrokeDoc(content.drawingStrokes)) > 0;
|
||||
return hasOverlay(asPdfStrokeDoc(content.drawingStrokes));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ const DropLine = ({ T }) => (
|
||||
<div data-drop-line style={{ height: 2, borderRadius: 2, background: T.accent, margin: "2px 4px" }} />
|
||||
);
|
||||
|
||||
/** Width the separator's label keeps in both states, so renaming moves nothing. */
|
||||
const MIDDLE_W = 132;
|
||||
|
||||
/**
|
||||
* A line drawn between notes, with an optional name.
|
||||
*
|
||||
@@ -61,64 +64,73 @@ function SeparatorRow({ T, separator, onRename, onRemove }) {
|
||||
cursor: "grab",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ height: 1, background: T.border, flex: separator.label || editing ? 0 : 1, minWidth: 12 }}
|
||||
/>
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commit();
|
||||
if (e.key === "Escape") {
|
||||
{/* The two rules always share what is left over equally, and the middle
|
||||
keeps one width whether it is showing a label or an input. Sizing it to
|
||||
its contents made the whole row jump sideways the moment renaming
|
||||
started, which is exactly when the eye is on it. */}
|
||||
<div style={{ height: 1, background: T.border, flex: 1, minWidth: 8 }} />
|
||||
<div style={{ flex: "0 0 auto", width: MIDDLE_W, display: "flex" }}>
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commit();
|
||||
if (e.key === "Escape") {
|
||||
setDraft(separator.label);
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
aria-label="Separator label"
|
||||
placeholder="Abschnitt"
|
||||
style={{
|
||||
width: "100%",
|
||||
minWidth: 0,
|
||||
border: `1px solid ${T.border}`,
|
||||
borderRadius: 6,
|
||||
background: "transparent",
|
||||
color: T.textSec,
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.6,
|
||||
textAlign: "center",
|
||||
// Matches the button's box exactly, so nothing shifts vertically
|
||||
// either when the input replaces it.
|
||||
padding: "1px 4px",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setDraft(separator.label);
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
aria-label="Separator label"
|
||||
placeholder="Abschnitt"
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
border: `1px solid ${T.border}`,
|
||||
borderRadius: 6,
|
||||
background: "transparent",
|
||||
color: T.textSec,
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.6,
|
||||
padding: "2px 6px",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setDraft(separator.label);
|
||||
setEditing(true);
|
||||
}}
|
||||
title="Umbenennen"
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: separator.label ? T.textSec : T.faint,
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.6,
|
||||
textTransform: "uppercase",
|
||||
cursor: "text",
|
||||
padding: 0,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{separator.label || "Benennen"}
|
||||
</button>
|
||||
)}
|
||||
<div style={{ height: 1, background: T.border, flex: 1, minWidth: 12 }} />
|
||||
setEditing(true);
|
||||
}}
|
||||
title="Umbenennen"
|
||||
style={{
|
||||
width: "100%",
|
||||
border: "1px solid transparent",
|
||||
background: "transparent",
|
||||
color: separator.label ? T.textSec : T.faint,
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.6,
|
||||
textTransform: "uppercase",
|
||||
textAlign: "center",
|
||||
cursor: "text",
|
||||
padding: "1px 4px",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{separator.label || "Benennen"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ height: 1, background: T.border, flex: 1, minWidth: 8 }} />
|
||||
<button
|
||||
onClick={() => onRemove(separator.id)}
|
||||
title="Trenner entfernen"
|
||||
@@ -131,6 +143,7 @@ function SeparatorRow({ T, separator, onRename, onRemove }) {
|
||||
fontSize: 13,
|
||||
lineHeight: 1,
|
||||
padding: "0 2px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
×
|
||||
|
||||
@@ -17,7 +17,15 @@ import { FONT_UI } from "../../ui/theme";
|
||||
* and searchable exactly like any other note — a PDF note is a note that happens
|
||||
* to have a document attached, not a separate kind of thing.
|
||||
*/
|
||||
export function PdfNotesPanel({ note, dark, onUpdate, onOpenNote, searchLinkTargets, resolveLinkLabel }) {
|
||||
export function PdfNotesPanel({
|
||||
note,
|
||||
dark,
|
||||
onUpdate,
|
||||
onOpenNote,
|
||||
searchLinkTargets,
|
||||
resolveLinkLabel,
|
||||
onBackToDocument,
|
||||
}) {
|
||||
const T = {
|
||||
bg: dark ? "#0d1117" : "#fff",
|
||||
bar: dark ? "#161b22" : "#f6f8fa",
|
||||
@@ -72,9 +80,32 @@ export function PdfNotesPanel({ note, dark, onUpdate, onOpenNote, searchLinkTarg
|
||||
overflowX: "auto",
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 5, paddingRight: 4, flexShrink: 0 }}>
|
||||
{I.text} Notizen
|
||||
</span>
|
||||
{onBackToDocument ? (
|
||||
<button
|
||||
data-pdf-back-to-document
|
||||
onClick={onBackToDocument}
|
||||
title="Zurück zum Dokument"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
flexShrink: 0,
|
||||
border: `1px solid ${T.border}`,
|
||||
borderRadius: 8,
|
||||
background: "transparent",
|
||||
color: T.muted,
|
||||
cursor: "pointer",
|
||||
fontSize: 11,
|
||||
padding: "3px 8px",
|
||||
}}
|
||||
>
|
||||
{I.back} Dokument
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 5, paddingRight: 4, flexShrink: 0 }}>
|
||||
{I.text} Notizen
|
||||
</span>
|
||||
)}
|
||||
{editor && <EditorToolbar editor={editor} T={T} icons={I} compact dark={dark} />}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Notes pinned to a spot on a PDF 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function PdfPins({
|
||||
T,
|
||||
pageNo,
|
||||
pageSize,
|
||||
cssWidth,
|
||||
cssHeight,
|
||||
notes,
|
||||
placing,
|
||||
openId,
|
||||
onOpen,
|
||||
onPlace,
|
||||
onChange,
|
||||
onMove,
|
||||
onRemove,
|
||||
numberOf,
|
||||
}) {
|
||||
const layerRef = useRef(null);
|
||||
const dragging = useRef(null);
|
||||
|
||||
const toPoints = (e) => {
|
||||
const box = layerRef.current.getBoundingClientRect();
|
||||
return {
|
||||
x: ((e.clientX - box.left) / box.width) * pageSize.width,
|
||||
y: ((e.clientY - box.top) / box.height) * pageSize.height,
|
||||
};
|
||||
};
|
||||
|
||||
/* 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. */
|
||||
const startDrag = (e, pin) => {
|
||||
if (placing) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
dragging.current = { id: pin.id, moved: false };
|
||||
};
|
||||
const onPointerMove = (e) => {
|
||||
const drag = dragging.current;
|
||||
if (!drag) return;
|
||||
drag.moved = true;
|
||||
const at = toPoints(e);
|
||||
onMove(drag.id, at.x, at.y);
|
||||
};
|
||||
const endDrag = (e, pin) => {
|
||||
const drag = dragging.current;
|
||||
dragging.current = null;
|
||||
if (!drag) return;
|
||||
e.stopPropagation();
|
||||
// A press that never moved is a click, and a click opens the note.
|
||||
if (!drag.moved) onOpen(pin.id === openId ? null : pin.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={layerRef}
|
||||
data-pdf-pins={pageNo}
|
||||
onClick={placing ? (e) => onPlace(toPoints(e)) : undefined}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
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.
|
||||
pointerEvents: placing ? "auto" : "none",
|
||||
cursor: placing ? "crosshair" : "default",
|
||||
}}
|
||||
>
|
||||
{notes.map((pin) => {
|
||||
const xf = pin.x / pageSize.width;
|
||||
const yf = pin.y / pageSize.height;
|
||||
return (
|
||||
<div
|
||||
key={pin.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${xf * 100}%`,
|
||||
top: `${yf * 100}%`,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
data-pin={pin.id}
|
||||
title={pin.text || "Notiz zu dieser Stelle"}
|
||||
onPointerDown={(e) => startDrag(e, pin)}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={(e) => endDrag(e, pin)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: "50%",
|
||||
border: `2px solid ${T.bar}`,
|
||||
background: T.accent,
|
||||
color: "#fff",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: "grab",
|
||||
boxShadow: "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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useEffect, useState } from "react";
|
||||
import { PdfViewer } from "./PdfViewer.jsx";
|
||||
import { PdfNotesPanel } from "./PdfNotesPanel.jsx";
|
||||
import { I } from "../../ui/icons.jsx";
|
||||
import { FONT_UI } from "../../ui/theme";
|
||||
|
||||
const MOBILE_BREAKPOINT = 900;
|
||||
|
||||
@@ -14,9 +13,10 @@ const MOBILE_BREAKPOINT = 900;
|
||||
* squeezed into half of a phone is not readable, and neither is what you write
|
||||
* about it.
|
||||
*
|
||||
* Both halves can be hidden. That is not decoration: reading the original
|
||||
* without one's own marks in the way is a normal thing to want, and so is
|
||||
* writing without the document taking two thirds of the screen.
|
||||
* The control for the notes sits *in* the viewer's toolbar rather than in a bar
|
||||
* of its own above it. A second row for a single button cost a strip of reading
|
||||
* height on every screen and put one of the document's controls somewhere none
|
||||
* of the others were.
|
||||
*/
|
||||
export function PdfWorkspace({ note, dark, onUpdate, onOpenNote, searchLinkTargets, resolveLinkLabel }) {
|
||||
const [showNotes, setShowNotes] = useState(false);
|
||||
@@ -29,7 +29,6 @@ export function PdfWorkspace({ note, dark, onUpdate, onOpenNote, searchLinkTarge
|
||||
}, []);
|
||||
|
||||
const T = {
|
||||
bar: dark ? "#161b22" : "#fff",
|
||||
border: dark ? "#30363d" : "#d0d7de",
|
||||
muted: dark ? "#8b949e" : "#656d76",
|
||||
accent: dark ? "#388bfd" : "#0969da",
|
||||
@@ -38,81 +37,61 @@ export function PdfWorkspace({ note, dark, onUpdate, onOpenNote, searchLinkTarge
|
||||
// On a narrow screen the two are alternatives, not neighbours.
|
||||
const showPdf = !narrow || !showNotes;
|
||||
|
||||
const tab = (active, onClick, label, icon, attr) => (
|
||||
const notesToggle = (
|
||||
<button
|
||||
{...attr}
|
||||
onClick={onClick}
|
||||
data-pdf-toggle-notes={showNotes ? "on" : "off"}
|
||||
onClick={() => setShowNotes((v) => !v)}
|
||||
title={showNotes ? "Notizen ausblenden" : "Notizen einblenden"}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
padding: "4px 10px",
|
||||
height: 28,
|
||||
padding: "0 10px",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background: active ? T.accent + "22" : "transparent",
|
||||
color: active ? T.accent : T.muted,
|
||||
background: showNotes ? T.accent + "22" : "transparent",
|
||||
color: showNotes ? T.accent : T.muted,
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{icon} {label}
|
||||
{I.text} Notizen
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
padding: "4px 10px",
|
||||
borderBottom: `1px solid ${T.border}`,
|
||||
background: T.bar,
|
||||
fontFamily: FONT_UI,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{narrow
|
||||
? // Two tabs: whichever is showing is the one you are in.
|
||||
[
|
||||
tab(showPdf, () => setShowNotes(false), "Dokument", I.pdf, { "data-pdf-tab": "pdf" }),
|
||||
tab(showNotes, () => setShowNotes(true), "Notizen", I.text, { "data-pdf-tab": "notes" }),
|
||||
]
|
||||
: // Wide: the document is always there, the notes fold in and out.
|
||||
tab(showNotes, () => setShowNotes((v) => !v), "Notizen", I.text, {
|
||||
"data-pdf-toggle-notes": showNotes ? "on" : "off",
|
||||
title: showNotes ? "Notizen ausblenden" : "Notizen einblenden",
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, display: "flex", minHeight: 0, minWidth: 0 }}>
|
||||
{showPdf && (
|
||||
<div style={{ flex: 1, display: "flex", minWidth: 0, minHeight: 0 }}>
|
||||
<PdfViewer note={note} dark={dark} onUpdate={onUpdate} />
|
||||
</div>
|
||||
)}
|
||||
{showNotes && (
|
||||
<div
|
||||
style={{
|
||||
width: narrow ? "100%" : "min(42%, 520px)",
|
||||
borderLeft: narrow ? "none" : `1px solid ${T.border}`,
|
||||
display: "flex",
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<PdfNotesPanel
|
||||
note={note}
|
||||
dark={dark}
|
||||
onUpdate={onUpdate}
|
||||
onOpenNote={onOpenNote}
|
||||
searchLinkTargets={searchLinkTargets}
|
||||
resolveLinkLabel={resolveLinkLabel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1, display: "flex", minHeight: 0, minWidth: 0 }}>
|
||||
{showPdf && (
|
||||
<div style={{ flex: 1, display: "flex", minWidth: 0, minHeight: 0 }}>
|
||||
<PdfViewer note={note} dark={dark} onUpdate={onUpdate} notesToggle={notesToggle} />
|
||||
</div>
|
||||
)}
|
||||
{showNotes && (
|
||||
<div
|
||||
style={{
|
||||
width: narrow ? "100%" : "min(42%, 520px)",
|
||||
borderLeft: narrow ? "none" : `1px solid ${T.border}`,
|
||||
display: "flex",
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<PdfNotesPanel
|
||||
note={note}
|
||||
dark={dark}
|
||||
onUpdate={onUpdate}
|
||||
onOpenNote={onOpenNote}
|
||||
searchLinkTargets={searchLinkTargets}
|
||||
resolveLinkLabel={resolveLinkLabel}
|
||||
/* On a phone the viewer is not on screen at all, so the way back to
|
||||
the document has to be here. */
|
||||
onBackToDocument={narrow ? () => setShowNotes(false) : null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
allPinnedNotes,
|
||||
asPdfStrokeDoc,
|
||||
emptyPdfDoc,
|
||||
hasOverlay,
|
||||
notesForPage,
|
||||
strokesForPage,
|
||||
totalPinnedNotes,
|
||||
totalStrokes,
|
||||
withPage,
|
||||
withPageNotes,
|
||||
} from "./pageStrokes";
|
||||
import type { Stroke } from "../../types";
|
||||
|
||||
const A4 = { width: 595, height: 842 };
|
||||
const stroke = (): Stroke => ({ t: "pen", c: "#000000", s: 2, p: [10, 10, 20, 20] });
|
||||
const pin = (id: string, text = "") => ({ id, x: 100, y: 200, text });
|
||||
|
||||
describe("pinned notes on a page", () => {
|
||||
it("are stored beside the strokes, in the same page space", () => {
|
||||
const doc = withPageNotes(emptyPdfDoc(), 3, A4, [pin("p1", "hier weiterlesen")]);
|
||||
expect(notesForPage(doc, 3)).toEqual([pin("p1", "hier weiterlesen")]);
|
||||
expect(doc.pages["3"]!.w).toBe(595);
|
||||
});
|
||||
|
||||
/* The one that would have hurt: erasing the ink from a page must not take the
|
||||
* notes pinned to it. They are on the same page but they are not the drawing. */
|
||||
it("survive the page's ink being cleared", () => {
|
||||
let doc = withPageNotes(emptyPdfDoc(), 1, A4, [pin("p1", "wichtig")]);
|
||||
doc = withPage(doc, 1, A4, [stroke()]);
|
||||
expect(totalStrokes(doc)).toBe(1);
|
||||
|
||||
doc = withPage(doc, 1, A4, []); // "Seite leeren"
|
||||
expect(totalStrokes(doc)).toBe(0);
|
||||
expect(notesForPage(doc, 1)).toEqual([pin("p1", "wichtig")]);
|
||||
});
|
||||
|
||||
it("and conversely, removing every note leaves the ink alone", () => {
|
||||
let doc = withPage(emptyPdfDoc(), 1, A4, [stroke()]);
|
||||
doc = withPageNotes(doc, 1, A4, [pin("p1")]);
|
||||
doc = withPageNotes(doc, 1, A4, []);
|
||||
expect(strokesForPage(doc, 1)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("drop the page only once nothing is left on it", () => {
|
||||
let doc = withPageNotes(emptyPdfDoc(), 1, A4, [pin("p1")]);
|
||||
doc = withPageNotes(doc, 1, A4, []);
|
||||
expect(doc.pages["1"]).toBeUndefined();
|
||||
});
|
||||
|
||||
/* The save path asks this before writing. Asking `totalStrokes` instead would
|
||||
* have written null for a document with notes but no ink — losing all of them. */
|
||||
it("count as content worth saving, even with no ink anywhere", () => {
|
||||
const doc = withPageNotes(emptyPdfDoc(), 2, A4, [pin("p1", "x")]);
|
||||
expect(totalStrokes(doc)).toBe(0);
|
||||
expect(totalPinnedNotes(doc)).toBe(1);
|
||||
expect(hasOverlay(doc)).toBe(true);
|
||||
expect(hasOverlay(emptyPdfDoc())).toBe(false);
|
||||
});
|
||||
|
||||
it("are read back from storage rather than discarded as a strokeless page", () => {
|
||||
const stored = { v: 2, pages: { "5": { w: 595, h: 842, strokes: [], notes: [pin("p1", "x")] } } };
|
||||
expect(notesForPage(asPdfStrokeDoc(stored), 5)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ignore entries that are not notes rather than failing the whole document", () => {
|
||||
const stored = {
|
||||
v: 2,
|
||||
pages: { "1": { w: 595, h: 842, strokes: [stroke()], notes: [pin("ok"), null, { x: 1 }] } },
|
||||
};
|
||||
const doc = asPdfStrokeDoc(stored);
|
||||
expect(notesForPage(doc, 1).map((n) => n.id)).toEqual(["ok"]);
|
||||
expect(strokesForPage(doc, 1)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("allPinnedNotes", () => {
|
||||
it("lists them in reading order across pages", () => {
|
||||
let doc = withPageNotes(emptyPdfDoc(), 2, A4, [
|
||||
{ id: "b", x: 10, y: 400, text: "" },
|
||||
{ id: "a", x: 10, y: 100, text: "" },
|
||||
]);
|
||||
doc = withPageNotes(doc, 1, A4, [{ id: "first", x: 10, y: 50, text: "" }]);
|
||||
expect(allPinnedNotes(doc).map((n) => n.id)).toEqual(["first", "a", "b"]);
|
||||
});
|
||||
|
||||
it("carries the page each one sits on, which is half of finding it again", () => {
|
||||
const doc = withPageNotes(emptyPdfDoc(), 7, A4, [pin("p1", "x")]);
|
||||
expect(allPinnedNotes(doc)[0]!.page).toBe(7);
|
||||
});
|
||||
|
||||
it("is empty for a document nobody has annotated", () => {
|
||||
expect(allPinnedNotes(emptyPdfDoc())).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -16,11 +16,26 @@ import type { Stroke } from "../../types";
|
||||
|
||||
export const PDF_STROKES_VERSION = 2;
|
||||
|
||||
/**
|
||||
* A note pinned to a spot on a page.
|
||||
*
|
||||
* `x`/`y` are in the same PDF points as the strokes, which is what keeps the pin
|
||||
* on its passage at every zoom, on every screen, and in the exported file.
|
||||
*/
|
||||
export interface PinnedNote {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface PageStrokes {
|
||||
/** Page width in PDF points — the space `strokes` is expressed in. */
|
||||
w: number;
|
||||
h: number;
|
||||
strokes: Stroke[];
|
||||
/** Notes pinned to this page. Absent on pages that have none. */
|
||||
notes?: PinnedNote[];
|
||||
}
|
||||
|
||||
export interface PdfStrokeDoc {
|
||||
@@ -48,30 +63,84 @@ export function asPdfStrokeDoc(stored: unknown): PdfStrokeDoc {
|
||||
if (!isPdfStrokeDoc(stored)) return emptyPdfDoc();
|
||||
const pages: Record<string, PageStrokes> = {};
|
||||
for (const [key, page] of Object.entries(stored.pages)) {
|
||||
if (page && Array.isArray(page.strokes) && page.w > 0 && page.h > 0) pages[key] = page;
|
||||
if (!page || !(page.w > 0) || !(page.h > 0)) continue;
|
||||
const strokes = Array.isArray(page.strokes) ? page.strokes : [];
|
||||
const notes = Array.isArray(page.notes) ? page.notes.filter(isPinnedNote) : [];
|
||||
// A page with only pinned notes is still a page worth keeping. Requiring
|
||||
// strokes here would have thrown away every note the moment the ink was
|
||||
// erased from that page.
|
||||
if (strokes.length === 0 && notes.length === 0) continue;
|
||||
pages[key] = notes.length ? { ...page, strokes, notes } : { ...page, strokes, notes: undefined };
|
||||
}
|
||||
return { v: PDF_STROKES_VERSION, pages };
|
||||
}
|
||||
|
||||
const isPinnedNote = (n: unknown): n is PinnedNote =>
|
||||
!!n &&
|
||||
typeof n === "object" &&
|
||||
typeof (n as PinnedNote).id === "string" &&
|
||||
Number.isFinite((n as PinnedNote).x) &&
|
||||
Number.isFinite((n as PinnedNote).y);
|
||||
|
||||
export const strokesForPage = (doc: PdfStrokeDoc, pageNo: number): Stroke[] =>
|
||||
doc.pages[String(pageNo)]?.strokes ?? [];
|
||||
|
||||
/** A copy of the document with one page replaced. Empty pages are dropped. */
|
||||
export const notesForPage = (doc: PdfStrokeDoc, pageNo: number): PinnedNote[] =>
|
||||
doc.pages[String(pageNo)]?.notes ?? [];
|
||||
|
||||
/** A copy of the document with one page's strokes replaced. */
|
||||
export function withPage(
|
||||
doc: PdfStrokeDoc,
|
||||
pageNo: number,
|
||||
size: { width: number; height: number },
|
||||
strokes: Stroke[]
|
||||
): PdfStrokeDoc {
|
||||
const key = String(pageNo);
|
||||
const notes = doc.pages[key]?.notes ?? [];
|
||||
const pages = { ...doc.pages };
|
||||
if (strokes.length === 0) delete pages[String(pageNo)];
|
||||
else pages[String(pageNo)] = { w: size.width, h: size.height, strokes };
|
||||
// Clearing the ink must not take the pinned notes with it — they are on the
|
||||
// same page but they are not the drawing.
|
||||
if (strokes.length === 0 && notes.length === 0) delete pages[key];
|
||||
else pages[key] = { w: size.width, h: size.height, strokes, ...(notes.length ? { notes } : {}) };
|
||||
return { v: PDF_STROKES_VERSION, pages };
|
||||
}
|
||||
|
||||
/** A copy of the document with one page's pinned notes replaced. */
|
||||
export function withPageNotes(
|
||||
doc: PdfStrokeDoc,
|
||||
pageNo: number,
|
||||
size: { width: number; height: number },
|
||||
notes: PinnedNote[]
|
||||
): PdfStrokeDoc {
|
||||
const key = String(pageNo);
|
||||
const strokes = doc.pages[key]?.strokes ?? [];
|
||||
const pages = { ...doc.pages };
|
||||
if (strokes.length === 0 && notes.length === 0) delete pages[key];
|
||||
else pages[key] = { w: size.width, h: size.height, strokes, ...(notes.length ? { notes } : {}) };
|
||||
return { v: PDF_STROKES_VERSION, pages };
|
||||
}
|
||||
|
||||
export const totalStrokes = (doc: PdfStrokeDoc): number =>
|
||||
Object.values(doc.pages).reduce((n, p) => n + p.strokes.length, 0);
|
||||
|
||||
export const totalPinnedNotes = (doc: PdfStrokeDoc): number =>
|
||||
Object.values(doc.pages).reduce((n, p) => n + (p.notes?.length ?? 0), 0);
|
||||
|
||||
/**
|
||||
* Whether the overlay holds anything at all.
|
||||
*
|
||||
* The save path used to ask `totalStrokes`, which would have written null the
|
||||
* moment a document had pinned notes but no ink — losing every note on it.
|
||||
*/
|
||||
export const hasOverlay = (doc: PdfStrokeDoc): boolean => totalStrokes(doc) > 0 || totalPinnedNotes(doc) > 0;
|
||||
|
||||
/** Every pinned note in the document, in reading order. */
|
||||
export const allPinnedNotes = (doc: PdfStrokeDoc): Array<PinnedNote & { page: number }> =>
|
||||
Object.entries(doc.pages)
|
||||
.flatMap(([key, page]) => (page.notes ?? []).map((n) => ({ ...n, page: Number(key) })))
|
||||
.filter((n) => Number.isFinite(n.page))
|
||||
.sort((a, b) => a.page - b.page || a.y - b.y || a.x - b.x);
|
||||
|
||||
/** Page numbers that carry ink, in order — used by the export. */
|
||||
export const drawnPages = (doc: PdfStrokeDoc): number[] =>
|
||||
Object.keys(doc.pages)
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
|
||||
import type { Stroke } from "../../types";
|
||||
import { HIGHLIGHTER_ALPHA } from "../drawing/strokes.js";
|
||||
import type { PdfStrokeDoc } from "./pageStrokes";
|
||||
import { drawnPages } from "./pageStrokes";
|
||||
import type { PdfStrokeDoc, PinnedNote } from "./pageStrokes";
|
||||
import { allPinnedNotes, drawnPages } from "./pageStrokes";
|
||||
|
||||
/** `#rrggbb` → the 0–1 components pdf-lib wants. */
|
||||
function toRgb(hex: string): { r: number; g: number; b: number } {
|
||||
@@ -39,7 +39,7 @@ const opacityFor = (tool: string) => (tool === "highlighter" ? parseInt(HIGHLIGH
|
||||
* would put grey scribbles where the user had rubbed something out.
|
||||
*/
|
||||
export async function stampStrokesIntoPdf(originalBytes: Uint8Array, ink: PdfStrokeDoc): Promise<Uint8Array> {
|
||||
const { PDFDocument, rgb } = await import("pdf-lib");
|
||||
const { PDFDocument, StandardFonts, rgb } = await import("pdf-lib");
|
||||
const doc = await PDFDocument.load(originalBytes);
|
||||
const pages = doc.getPages();
|
||||
|
||||
@@ -77,5 +77,114 @@ export async function stampStrokesIntoPdf(originalBytes: Uint8Array, ink: PdfStr
|
||||
}
|
||||
}
|
||||
|
||||
await stampPinnedNotes(doc, ink, { StandardFonts, rgb });
|
||||
|
||||
return doc.save();
|
||||
}
|
||||
|
||||
/* ── pinned notes ── */
|
||||
|
||||
const NOTE_BOX_W = 150;
|
||||
const NOTE_FONT_SIZE = 8;
|
||||
const NOTE_LINE_H = 10;
|
||||
const MARKER_R = 8;
|
||||
|
||||
/** Greedy wrap at a measured width — the only way to know is to ask the font. */
|
||||
function wrap(text: string, font: any, size: number, maxWidth: number): string[] {
|
||||
const lines: string[] = [];
|
||||
for (const paragraph of text.split("\n")) {
|
||||
let line = "";
|
||||
for (const word of paragraph.split(/\s+/).filter(Boolean)) {
|
||||
const candidate = line ? `${line} ${word}` : word;
|
||||
if (font.widthOfTextAtSize(candidate, size) <= maxWidth) {
|
||||
line = candidate;
|
||||
} else {
|
||||
if (line) lines.push(line);
|
||||
line = word;
|
||||
}
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the pinned notes into the exported document.
|
||||
*
|
||||
* They are the reader's own words about a passage, so an export that dropped
|
||||
* them would quietly lose work — the same standard the drawings are held to.
|
||||
*
|
||||
* Each becomes a numbered marker on its spot plus a small box holding the text,
|
||||
* placed on whichever side of the pin has room. The box is opaque, because a
|
||||
* note printed over the sentence it is about leaves neither readable.
|
||||
*
|
||||
* Only Latin-1 survives: the standard PDF fonts cannot encode more, and pdf-lib
|
||||
* throws rather than guess. One em dash must not cost the whole export, so
|
||||
* anything outside that range is replaced.
|
||||
*/
|
||||
async function stampPinnedNotes(
|
||||
doc: any,
|
||||
ink: PdfStrokeDoc,
|
||||
lib: { StandardFonts: any; rgb: any }
|
||||
): Promise<void> {
|
||||
const notes = allPinnedNotes(ink).filter((n) => n.text.trim());
|
||||
if (notes.length === 0) return;
|
||||
|
||||
const { rgb } = lib;
|
||||
const font = await doc.embedFont(lib.StandardFonts.Helvetica);
|
||||
const bold = await doc.embedFont(lib.StandardFonts.HelveticaBold);
|
||||
const pages = doc.getPages();
|
||||
|
||||
// Numbered in reading order, exactly as they are on screen.
|
||||
notes.forEach((note: PinnedNote & { page: number }, index: number) => {
|
||||
const page = pages[note.page - 1];
|
||||
const stored = ink.pages[String(note.page)];
|
||||
if (!page || !stored) return;
|
||||
|
||||
const { width, height } = page.getSize();
|
||||
const sx = width / stored.w;
|
||||
const sy = height / stored.h;
|
||||
const x = note.x * sx;
|
||||
const y = height - note.y * sy; // PDF counts from the bottom
|
||||
|
||||
const text = note.text.replace(/[^\u0000-\u00FF]/g, "?");
|
||||
const lines = wrap(text, font, NOTE_FONT_SIZE, NOTE_BOX_W - 10);
|
||||
const boxH = lines.length * NOTE_LINE_H + 8;
|
||||
|
||||
// Whichever side has room, so a note near the right margin is not cut off.
|
||||
const boxX = x + MARKER_R + 4 + NOTE_BOX_W > width ? x - MARKER_R - 4 - NOTE_BOX_W : x + MARKER_R + 4;
|
||||
const boxY = Math.max(4, Math.min(y - boxH / 2, height - boxH - 4));
|
||||
|
||||
page.drawRectangle({
|
||||
x: Math.max(4, boxX),
|
||||
y: boxY,
|
||||
width: NOTE_BOX_W,
|
||||
height: boxH,
|
||||
color: rgb(1, 0.98, 0.85),
|
||||
borderColor: rgb(0.85, 0.72, 0.3),
|
||||
borderWidth: 0.7,
|
||||
opacity: 0.97,
|
||||
});
|
||||
|
||||
lines.forEach((line: string, i: number) => {
|
||||
page.drawText(line, {
|
||||
x: Math.max(4, boxX) + 5,
|
||||
y: boxY + boxH - 5 - NOTE_LINE_H * (i + 1) + 2.5,
|
||||
size: NOTE_FONT_SIZE,
|
||||
font,
|
||||
color: rgb(0.15, 0.15, 0.15),
|
||||
});
|
||||
});
|
||||
|
||||
// The marker last, so it stays on top if the box reaches under it.
|
||||
page.drawCircle({ x, y, size: MARKER_R, color: rgb(0.05, 0.45, 0.9), opacity: 0.95 });
|
||||
const label = String(index + 1);
|
||||
page.drawText(label, {
|
||||
x: x - bold.widthOfTextAtSize(label, 9) / 2,
|
||||
y: y - 3.2,
|
||||
size: 9,
|
||||
font: bold,
|
||||
color: rgb(1, 1, 1),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -158,3 +158,52 @@ describe("contentToText", () => {
|
||||
expect(search.contentToText("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("notes pinned into a PDF", () => {
|
||||
beforeEach(() => {
|
||||
seed();
|
||||
search.removeEntry("pdf");
|
||||
});
|
||||
|
||||
it("are searchable, even though they live outside the note body", () => {
|
||||
// They are stored in the page overlay, so nothing else puts them in the
|
||||
// index — and a note written onto page 14 that cannot be found again is
|
||||
// not much of a note.
|
||||
search.setEntry("pdf", "Handbuch", body("Meine Anmerkungen."));
|
||||
search.setPinnedText("pdf", "Handbuch", "S. 14: Achtung, Drehmoment beachten");
|
||||
expect(search.search("drehmoment")?.map((h) => h.id)).toEqual(["pdf"]);
|
||||
});
|
||||
|
||||
it("show the pinned text as the excerpt, with its page", () => {
|
||||
search.setEntry("pdf", "Handbuch", body("Meine Anmerkungen."));
|
||||
search.setPinnedText("pdf", "Handbuch", "S. 14: Achtung, Drehmoment beachten");
|
||||
expect(search.search("drehmoment")?.[0]?.snippet).toContain("S. 14");
|
||||
});
|
||||
|
||||
it("do not displace what the reader typed beside the document", () => {
|
||||
search.setEntry("pdf", "Handbuch", body("Meine Anmerkungen."));
|
||||
search.setPinnedText("pdf", "Handbuch", "Drehmoment");
|
||||
expect(search.search("anmerkungen")?.map((h) => h.id)).toEqual(["pdf"]);
|
||||
expect(search.search("drehmoment")?.map((h) => h.id)).toEqual(["pdf"]);
|
||||
});
|
||||
|
||||
it("survive the document's own text being indexed afterwards", () => {
|
||||
search.setPinnedText("pdf", "Handbuch", "Drehmoment");
|
||||
search.setAttachedText("pdf", "Handbuch", "Der Hersteller empfiehlt regelmäßige Wartung.");
|
||||
expect(search.search("drehmoment")?.map((h) => h.id)).toEqual(["pdf"]);
|
||||
expect(search.search("wartung")?.map((h) => h.id)).toEqual(["pdf"]);
|
||||
});
|
||||
|
||||
it("survive the note body being saved afterwards", () => {
|
||||
// Typing in the panel beside the PDF must not wipe what is pinned into it.
|
||||
search.setPinnedText("pdf", "Handbuch", "Drehmoment");
|
||||
search.setEntry("pdf", "Handbuch", body("Später weiterlesen."));
|
||||
expect(search.search("drehmoment")?.map((h) => h.id)).toEqual(["pdf"]);
|
||||
});
|
||||
|
||||
it("are replaced wholesale, so a deleted pin stops being findable", () => {
|
||||
search.setPinnedText("pdf", "Handbuch", "Drehmoment");
|
||||
search.setPinnedText("pdf", "Handbuch", "");
|
||||
expect(search.search("drehmoment")).toEqual([]);
|
||||
});
|
||||
});
|
||||
+38
-15
@@ -23,6 +23,8 @@ interface IndexEntry {
|
||||
raw: string;
|
||||
/** Text from an attached document, kept apart from what the user typed. */
|
||||
attachedRaw: string;
|
||||
/** Notes pinned to spots in an attached document. */
|
||||
pinnedRaw: string;
|
||||
}
|
||||
|
||||
const _index = new Map<string, IndexEntry>();
|
||||
@@ -68,30 +70,45 @@ export function contentToText(raw: string | null | undefined): string {
|
||||
* half.
|
||||
*/
|
||||
export function setEntry(id: string, title: string, content?: string | null, attached?: string | null): void {
|
||||
const existing = _index.get(id);
|
||||
const raw = contentToText(content);
|
||||
const attachedRaw = attached === undefined ? (_index.get(id)?.attachedRaw ?? "") : (attached ?? "");
|
||||
const attachedRaw = attached === undefined ? (existing?.attachedRaw ?? "") : (attached ?? "");
|
||||
const pinnedRaw = existing?.pinnedRaw ?? "";
|
||||
_index.set(id, {
|
||||
title: normalize(title),
|
||||
text: normalize(`${raw} ${attachedRaw}`),
|
||||
text: normalize(`${raw} ${attachedRaw} ${pinnedRaw}`),
|
||||
raw,
|
||||
attachedRaw,
|
||||
pinnedRaw,
|
||||
});
|
||||
}
|
||||
|
||||
/** Replaces one of the three halves, leaving the other two alone. */
|
||||
function setPart(id: string, title: string, part: "attachedRaw" | "pinnedRaw", value: string): void {
|
||||
const existing = _index.get(id) ?? { raw: "", attachedRaw: "", pinnedRaw: "", title: "", text: "" };
|
||||
const next = { ...existing, [part]: value };
|
||||
_index.set(id, {
|
||||
title: normalize(title),
|
||||
text: normalize(`${next.raw} ${next.attachedRaw} ${next.pinnedRaw}`),
|
||||
raw: next.raw,
|
||||
attachedRaw: next.attachedRaw,
|
||||
pinnedRaw: next.pinnedRaw,
|
||||
});
|
||||
}
|
||||
|
||||
/** Replaces only the attached document's text, leaving what the user wrote. */
|
||||
export function setAttachedText(id: string, title: string, attached: string): void {
|
||||
const existing = _index.get(id);
|
||||
setEntry(id, title, null, attached);
|
||||
// setEntry with null content would blank the written half; restore it.
|
||||
if (existing) {
|
||||
_index.set(id, {
|
||||
title: normalize(title),
|
||||
text: normalize(`${existing.raw} ${attached}`),
|
||||
raw: existing.raw,
|
||||
attachedRaw: attached,
|
||||
});
|
||||
}
|
||||
}
|
||||
export const setAttachedText = (id: string, title: string, attached: string): void =>
|
||||
setPart(id, title, "attachedRaw", attached);
|
||||
|
||||
/**
|
||||
* Replaces only the notes pinned into an attached document.
|
||||
*
|
||||
* These live in the page overlay rather than in the note body, so nothing else
|
||||
* puts them in the index — and a note you wrote onto page 14 that cannot be
|
||||
* searched for is a note you will not find again.
|
||||
*/
|
||||
export const setPinnedText = (id: string, title: string, pinned: string): void =>
|
||||
setPart(id, title, "pinnedRaw", pinned);
|
||||
|
||||
export function removeEntry(id: string): void {
|
||||
_index.delete(id);
|
||||
@@ -169,6 +186,12 @@ function snippetFor(entry: IndexEntry, terms: string[], radius = 60): string {
|
||||
|
||||
let text = entry.raw;
|
||||
let at = firstHit(text);
|
||||
// A pinned note is short and was written deliberately, so it outranks the
|
||||
// document's own text as the excerpt to show.
|
||||
if (at === -1 && entry.pinnedRaw) {
|
||||
text = entry.pinnedRaw;
|
||||
at = firstHit(text);
|
||||
}
|
||||
if (at === -1 && entry.attachedRaw) {
|
||||
text = entry.attachedRaw;
|
||||
at = firstHit(text);
|
||||
|
||||
@@ -90,7 +90,7 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
||||
await exportPdfNote(notes[0], kind === "pdf-stamped");
|
||||
setDone(
|
||||
kind === "pdf-stamped"
|
||||
? "PDF mit den Zeichnungen wurde heruntergeladen."
|
||||
? "PDF mit deinen Anmerkungen wurde heruntergeladen."
|
||||
: "Das unveränderte Original wurde heruntergeladen."
|
||||
);
|
||||
} else if (kind === "png") {
|
||||
@@ -149,15 +149,15 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
||||
{card(
|
||||
() => run("pdf-stamped"),
|
||||
busy === "pdf-stamped",
|
||||
"PDF mit den Zeichnungen",
|
||||
"PDF mit deinen Anmerkungen",
|
||||
[
|
||||
"Deine Striche werden fest ins Dokument gerechnet.",
|
||||
hasInk === false ? "Auf diesem Dokument ist noch nichts gezeichnet." : null,
|
||||
"Striche und Randnotizen werden fest ins Dokument gerechnet.",
|
||||
hasInk === false ? "Auf diesem Dokument ist noch nichts angemerkt." : null,
|
||||
],
|
||||
hasInk === false
|
||||
)}
|
||||
{card(() => run("pdf-original"), busy === "pdf-original", "PDF im Original", [
|
||||
"Genau die Datei, die du importiert hast — ohne deine Zeichnungen.",
|
||||
"Genau die Datei, die du importiert hast — ohne deine Anmerkungen.",
|
||||
])}
|
||||
{card(() => run("markdown"), busy === "markdown", "Nur die Notizen daneben (Markdown)", [
|
||||
"Was du neben dem Dokument geschrieben hast, ohne das PDF selbst.",
|
||||
|
||||
@@ -506,6 +506,20 @@ export const I = {
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
),
|
||||
pin: (
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8z" />
|
||||
</svg>
|
||||
),
|
||||
eye: (
|
||||
<svg
|
||||
width="15"
|
||||
|
||||
Reference in new issue
Block a user