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
+30
-1
@@ -1254,6 +1254,30 @@ function countStrokes(doc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/* Notes pinned to spots in a PDF. They ride in the same overlay as the strokes
|
||||
* and are just as much the reader's work, so "is there anything here" has to ask
|
||||
* about both — storing an overlay that holds only pinned notes as null threw
|
||||
* every one of them away. */
|
||||
const MAX_PINNED_NOTES = 500;
|
||||
const MAX_PINNED_LENGTH = 4000;
|
||||
|
||||
function countPinnedNotes(doc) {
|
||||
if (!doc || typeof doc !== "object" || !doc.pages || typeof doc.pages !== "object") return 0;
|
||||
let n = 0;
|
||||
for (const page of Object.values(doc.pages)) {
|
||||
if (!page || page.notes === undefined) continue;
|
||||
if (!Array.isArray(page.notes)) return null;
|
||||
for (const pin of page.notes) {
|
||||
if (!pin || typeof pin.id !== "string" || !Number.isFinite(pin.x) || !Number.isFinite(pin.y)) {
|
||||
return null;
|
||||
}
|
||||
if (typeof pin.text === "string" && pin.text.length > MAX_PINNED_LENGTH) return null;
|
||||
}
|
||||
n += page.notes.length;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
app.put("/api/notes/:id/drawing", authMiddleware, (req, res) => {
|
||||
const { drawingStrokes, drawingData } = req.body;
|
||||
const note = db.prepare("SELECT id FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
|
||||
@@ -1262,12 +1286,17 @@ app.put("/api/notes/:id/drawing", authMiddleware, (req, res) => {
|
||||
if (drawingStrokes !== undefined) {
|
||||
// Vector strokes in the note's canonical coordinate space — the current format.
|
||||
let count = 0;
|
||||
let pinned = 0;
|
||||
if (drawingStrokes !== null) {
|
||||
count = countStrokes(drawingStrokes);
|
||||
if (count === null) return res.status(400).json({ error: "Invalid strokes format" });
|
||||
if (count > MAX_STROKES) return res.status(413).json({ error: "Too many strokes" });
|
||||
pinned = countPinnedNotes(drawingStrokes);
|
||||
if (pinned === null) return res.status(400).json({ error: "Invalid pinned notes format" });
|
||||
if (pinned > MAX_PINNED_NOTES) return res.status(413).json({ error: "Too many pinned notes" });
|
||||
}
|
||||
const empty = !drawingStrokes || count === 0;
|
||||
// A page carrying only pinned notes is not an empty overlay.
|
||||
const empty = !drawingStrokes || (count === 0 && pinned === 0);
|
||||
const previous = db.prepare("SELECT drawing_strokes FROM notes WHERE id = ?").get(req.params.id);
|
||||
// Clearing a drawing is always worth being able to undo.
|
||||
snapshotNote(req.userId, req.params.id, req.deviceId, {
|
||||
|
||||
+212
-1
@@ -5,6 +5,7 @@
|
||||
* the right proportions, and that a document read once survives without the
|
||||
* network — the offline promise is the whole reason the bytes are cached. */
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { inflateSync } from "node:zlib";
|
||||
import {
|
||||
captureDownloads,
|
||||
check,
|
||||
@@ -19,6 +20,50 @@ import {
|
||||
|
||||
const PDF_PATH = "/tmp/e2e-test.pdf";
|
||||
|
||||
/**
|
||||
* The text inside a PDF's content streams.
|
||||
*
|
||||
* pdf-lib flate-compresses what it writes, so searching the raw bytes for a word
|
||||
* finds nothing whether it is in there or not. Every stream is inflated and the
|
||||
* results joined; ones that are not compressed (or not inflatable) are taken as
|
||||
* they are.
|
||||
*/
|
||||
function inflatedText(bytes) {
|
||||
const out = [];
|
||||
const open = Buffer.from("stream");
|
||||
const close = Buffer.from("endstream");
|
||||
let at = 0;
|
||||
while ((at = bytes.indexOf(open, at)) !== -1) {
|
||||
// "endstream" contains "stream"; matching it would start the next block
|
||||
// three bytes into a keyword and inflate garbage.
|
||||
if (at >= 3 && bytes.subarray(at - 3, at).toString("latin1") === "end") {
|
||||
at += open.length;
|
||||
continue;
|
||||
}
|
||||
let start = at + open.length;
|
||||
if (bytes[start] === 0x0d) start++;
|
||||
if (bytes[start] === 0x0a) start++;
|
||||
const end = bytes.indexOf(close, start);
|
||||
if (end === -1) break;
|
||||
const chunk = bytes.subarray(start, end);
|
||||
try {
|
||||
out.push(inflateSync(chunk).toString("latin1"));
|
||||
} catch {
|
||||
out.push(chunk.toString("latin1")); // stored, not compressed
|
||||
}
|
||||
at = end + close.length;
|
||||
}
|
||||
/* Text is written as `<48656C6C6F> Tj` — hex, because the font's encoding is
|
||||
not necessarily Latin-1. Decoded here so a check can look for a word. */
|
||||
return out
|
||||
.join("\n")
|
||||
.replace(/<([0-9A-Fa-f]{4,})>/g, (whole, hex) =>
|
||||
hex.length % 2
|
||||
? whole
|
||||
: Buffer.from(hex, "hex").toString("latin1")
|
||||
);
|
||||
}
|
||||
|
||||
/** A two-page PDF built here, so the suite depends on no fixture file. */
|
||||
function writeTestPdf() {
|
||||
const objs = [
|
||||
@@ -322,7 +367,7 @@ try {
|
||||
/* The same document with the strokes written in. */
|
||||
await openExport();
|
||||
await clearDownloads(p);
|
||||
await p.getByRole("button", { name: /PDF mit den Zeichnungen/ }).click();
|
||||
await p.getByRole("button", { name: /PDF mit deinen Anmerkungen/ }).click();
|
||||
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
|
||||
await p.waitForTimeout(1500);
|
||||
files = await downloads(p);
|
||||
@@ -411,6 +456,172 @@ try {
|
||||
);
|
||||
await p.screenshot({ path: "/out/pdf-offline.png" });
|
||||
await p.context().setOffline(false);
|
||||
/* ── notes pinned to a passage ── */
|
||||
// The panel beside the document is for notes about the document; this is for
|
||||
// a note about the paragraph you were reading.
|
||||
await p.locator("[data-pdf-hide-ink]").click().catch(() => {}); // ink out of the way
|
||||
await p.waitForTimeout(400);
|
||||
await p.locator("[data-pdf-hide-ink]").click().catch(() => {});
|
||||
await p.waitForTimeout(400);
|
||||
|
||||
check(
|
||||
(await p.locator("[data-pdf-pin-mode]").count()) === 1,
|
||||
"the viewer offers pinning a note to a spot"
|
||||
);
|
||||
|
||||
await p.locator("[data-pdf-pin-mode]").click();
|
||||
await p.waitForTimeout(400);
|
||||
check(
|
||||
(await p.locator("[data-pdf-pin-mode]").getAttribute("data-pdf-pin-mode")) === "on",
|
||||
"and says it is waiting for a spot"
|
||||
);
|
||||
|
||||
const page1 = await p.locator('[data-pdf-page="1"]').boundingBox();
|
||||
await p.mouse.click(page1.x + page1.width * 0.4, page1.y + page1.height * 0.3);
|
||||
await p.waitForTimeout(800);
|
||||
|
||||
check((await p.locator("[data-pin]").count()) === 1, "clicking the page leaves a pin");
|
||||
const bubble = p.getByLabel("Pinned note");
|
||||
check(await bubble.isVisible(), "and opens straight into writing");
|
||||
|
||||
await bubble.fill("Drehmoment beachten");
|
||||
await p.getByRole("button", { name: "Fertig" }).click();
|
||||
await p.waitForTimeout(2500);
|
||||
|
||||
/* It has to be on the passage, not near it — so the position is checked
|
||||
against the page rather than merely being non-zero. */
|
||||
const placed = await p.evaluate(() => {
|
||||
const pin = document.querySelector("[data-pin]").getBoundingClientRect();
|
||||
const page = document.querySelector('[data-pdf-page="1"]').getBoundingClientRect();
|
||||
return { x: (pin.x + pin.width / 2 - page.x) / page.width, y: (pin.y + pin.height / 2 - page.y) / page.height };
|
||||
});
|
||||
check(
|
||||
Math.abs(placed.x - 0.4) < 0.03 && Math.abs(placed.y - 0.3) < 0.03,
|
||||
"the pin sits where the page was clicked",
|
||||
`${placed.x.toFixed(2)} / ${placed.y.toFixed(2)}`
|
||||
);
|
||||
|
||||
/* Stored in PDF points, so zooming must not move it off its passage — the same
|
||||
property the strokes have. */
|
||||
await p.getByTitle("Größer").click();
|
||||
await p.waitForTimeout(2000);
|
||||
const zoomed = await p.evaluate(() => {
|
||||
const pin = document.querySelector("[data-pin]").getBoundingClientRect();
|
||||
const page = document.querySelector('[data-pdf-page="1"]').getBoundingClientRect();
|
||||
return { x: (pin.x + pin.width / 2 - page.x) / page.width, y: (pin.y + pin.height / 2 - page.y) / page.height };
|
||||
});
|
||||
check(
|
||||
Math.abs(zoomed.x - placed.x) < 0.02 && Math.abs(zoomed.y - placed.y) < 0.02,
|
||||
"and stays there when the document is zoomed",
|
||||
`${zoomed.x.toFixed(2)} / ${zoomed.y.toFixed(2)}`
|
||||
);
|
||||
await p.getByTitle("Kleiner").click();
|
||||
await p.waitForTimeout(1200);
|
||||
|
||||
/* Searchable: it lives in the page overlay, not in the note body, so nothing
|
||||
else would ever index it. */
|
||||
await p.getByPlaceholder("Search notes...").fill("Drehmoment");
|
||||
await p.waitForTimeout(1500);
|
||||
check(
|
||||
(await p.locator("[data-note-row]").count()) === 1,
|
||||
"a pinned note can be searched for",
|
||||
String(await p.locator("[data-note-row]").count())
|
||||
);
|
||||
await p.getByPlaceholder("Search notes...").fill("");
|
||||
await p.waitForTimeout(1000);
|
||||
|
||||
/* It belongs to the note, so it has to come back from the server. */
|
||||
await p.reload({ waitUntil: "networkidle" });
|
||||
await p.waitForTimeout(2500);
|
||||
await p.locator("button").filter({ hasText: "PDF Buch" }).first().click();
|
||||
await p.waitForTimeout(1200);
|
||||
await p.locator("[data-note-row]").first().click();
|
||||
await p.waitForTimeout(4000);
|
||||
check((await p.locator("[data-pin]").count()) === 1, "the pin survives a reload");
|
||||
|
||||
await p.locator("[data-pin]").click();
|
||||
await p.waitForTimeout(600);
|
||||
check(
|
||||
(await p.getByLabel("Pinned note").inputValue()) === "Drehmoment beachten",
|
||||
"with the text that was written into it"
|
||||
);
|
||||
|
||||
/* Clearing a page's ink must not take its pinned notes: same page, different
|
||||
thing. */
|
||||
await p.keyboard.press("Escape");
|
||||
await p.waitForTimeout(400);
|
||||
await p.locator("[data-pdf-mode]").click();
|
||||
await p.waitForTimeout(500);
|
||||
await p.getByTitle("Seite leeren").click();
|
||||
await p.waitForTimeout(2000);
|
||||
check((await p.locator("[data-pin]").count()) === 1, "emptying the page's ink leaves the pinned note");
|
||||
await p.locator("[data-pdf-mode]").click();
|
||||
await p.waitForTimeout(500);
|
||||
|
||||
await p.locator("[data-pin]").click();
|
||||
await p.waitForTimeout(600);
|
||||
await p.locator("[data-pin-remove]").click();
|
||||
await p.waitForTimeout(2000);
|
||||
check((await p.locator("[data-pin]").count()) === 0, "a pinned note can be removed again");
|
||||
|
||||
/* An export that dropped the reader's own words would lose work as surely as
|
||||
one that dropped the drawings. Written back in before the pin is removed. */
|
||||
await p.locator("[data-pdf-pin-mode]").click();
|
||||
await p.waitForTimeout(400);
|
||||
const page1b = await p.locator('[data-pdf-page="1"]').boundingBox();
|
||||
await p.mouse.click(page1b.x + page1b.width * 0.75, page1b.y + page1b.height * 0.7);
|
||||
await p.waitForTimeout(700);
|
||||
await p.getByLabel("Pinned note").fill("Randbemerkung Zebrastreifen");
|
||||
await p.getByRole("button", { name: "Fertig" }).click();
|
||||
await p.waitForTimeout(2500);
|
||||
|
||||
/* The server keeps its own judgement of "is this overlay empty". It counted
|
||||
strokes only, so an overlay holding just pinned notes was stored as null and
|
||||
every note in it was lost. */
|
||||
const overlay = await p.evaluate(async () => {
|
||||
const t = localStorage.getItem("inknote_token");
|
||||
const notes = await (await fetch("/api/notes", { headers: { Authorization: "Bearer " + t } })).json();
|
||||
const d = await (await fetch("/api/notes/" + notes[0].id, { headers: { Authorization: "Bearer " + t } })).json();
|
||||
return JSON.stringify(d.drawingStrokes ?? null);
|
||||
});
|
||||
check(
|
||||
overlay.includes("Randbemerkung"),
|
||||
"the server stores an overlay that holds only pinned notes",
|
||||
overlay.slice(0, 120)
|
||||
);
|
||||
|
||||
await captureDownloads(p);
|
||||
await clearDownloads(p);
|
||||
await p.getByTitle("Export").click();
|
||||
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
|
||||
await p.waitForTimeout(800);
|
||||
await p.getByRole("button", { name: /PDF mit deinen Anmerkungen/ }).click();
|
||||
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
|
||||
await p.waitForTimeout(1500);
|
||||
const withPins = Buffer.from((await downloads(p))[0].base64, "base64");
|
||||
check(withPins.subarray(0, 5).toString() === "%PDF-", "the export still produces a PDF");
|
||||
check(
|
||||
inflatedText(withPins).includes("Randbemerkung"),
|
||||
"and the pinned note's own words are written into it",
|
||||
`${withPins.length} bytes`
|
||||
);
|
||||
await p.keyboard.press("Escape");
|
||||
await p.waitForTimeout(800);
|
||||
|
||||
|
||||
|
||||
/* ── the notes panel lives in the same toolbar now ── */
|
||||
check(
|
||||
(await p.locator("[data-pdf-toggle-notes]").count()) === 1,
|
||||
"the notes control sits with the document's other controls"
|
||||
);
|
||||
const sameBar = await p.evaluate(() => {
|
||||
const notes = document.querySelector("[data-pdf-toggle-notes]");
|
||||
const mode = document.querySelector("[data-pdf-mode]");
|
||||
return notes && mode ? notes.parentElement === mode.parentElement : false;
|
||||
});
|
||||
check(sameBar, "in the one toolbar, not in a row of its own above it");
|
||||
|
||||
} catch (e) {
|
||||
check(false, "suite ran to the end", e.message);
|
||||
} finally {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -17,7 +17,7 @@ A self-hosted note-taking application with rich text editing, freehand drawing,
|
||||
- **Undo/Redo** — Full history stack for drawing operations
|
||||
- **Notebooks & Folders** — Create notebooks with custom gradient covers, organize notes by folder
|
||||
- **Star/Favorite** — Quick access to important notes
|
||||
- **PDF Reading & Annotation** — Import a PDF, read it, select its text, draw on it, write notes beside it, and export it with or without your marks
|
||||
- **PDF Reading & Annotation** — Import a PDF, read it, select its text, draw on it, pin notes to individual passages, write notes beside it, and export it with or without your marks
|
||||
- **Export** — Any note, notebook or the whole account as Markdown or PDF, entirely in the browser
|
||||
- **Import** — Markdown files, or a ZIP this app exported, come back as notes with their images
|
||||
- **Sorting** — Each notebook remembers how its notes are ordered, including an arrangement made by hand
|
||||
@@ -304,7 +304,7 @@ failure distinguished by comparing an error's `.message` to a magic string.
|
||||
Failures now carry a typed `SyncError.code`; `.message` still holds the same
|
||||
string, so the remaining JavaScript callers are unaffected.
|
||||
|
||||
The 199 unit tests concentrate on the same places, and several exist because the
|
||||
The 215 unit tests concentrate on the same places, and several exist because the
|
||||
bug they cover actually happened:
|
||||
|
||||
- a queue entry that fails must stay queued, with an attempt counter
|
||||
@@ -332,7 +332,7 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
|
||||
| `search-attachments` | 12 | body-only matches, ranking, umlauts; images as blob URLs, not inline bytes |
|
||||
| `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable |
|
||||
| `drawing` | 7 | strokes survive the poller, and match across four viewport widths |
|
||||
| `pdf` | 41 | import, page proportions, lazy loading, and reading with the network off |
|
||||
| `pdf` | 57 | import, page proportions, lazy loading, and reading with the network off |
|
||||
| `attachments-lifecycle` | 10 | a picture is kept while any version shows it, and freed once none does |
|
||||
| `conflict` | 10 | a late write wins but the state it buries survives as a version |
|
||||
| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
|
||||
@@ -753,6 +753,37 @@ its history on a change that is not to the note at all.
|
||||
|
||||
---
|
||||
|
||||
## Notes pinned to a passage
|
||||
|
||||
The panel beside a PDF is for notes about the document. A note that belongs to
|
||||
*one paragraph* is a different thing — and a note that has to begin "on page 14,
|
||||
third paragraph" has already lost its place. So a note can be pinned to a spot:
|
||||
click **Randnotiz**, click the passage, write.
|
||||
|
||||
Pins are stored in the same per-page overlay as the drawings, in **PDF points**.
|
||||
That is what keeps one on its sentence at every zoom, on every screen, and in the
|
||||
exported file — the same property the strokes have, for the same reason.
|
||||
|
||||
Being in the overlay rather than in the note body has three consequences, each of
|
||||
which needed handling rather than assuming:
|
||||
|
||||
- **Search.** Nothing else would index them, so the viewer feeds them to the
|
||||
local index itself, with their page numbers — finding the words is only half of
|
||||
finding the note.
|
||||
- **Clearing the ink.** "Seite leeren" empties the drawing. It must not take the
|
||||
notes pinned to that page: same page, different thing.
|
||||
- **"Is this overlay empty?"** The client and the server each held their own
|
||||
answer, and both counted strokes only. An overlay carrying nothing but pinned
|
||||
notes was therefore stored as `NULL` — every note on it lost on the next save.
|
||||
Both now ask about strokes *and* pins.
|
||||
|
||||
They come along when the document is exported with annotations: each becomes a
|
||||
numbered marker plus a small box with its text, placed on whichever side of the
|
||||
pin has room. Only Latin-1 survives — the standard PDF fonts cannot encode more,
|
||||
and one em dash must not cost the whole export, so anything else is replaced.
|
||||
|
||||
---
|
||||
|
||||
## Import
|
||||
|
||||
The other half of the promise that notes are not hostage to this program. A
|
||||
|
||||
Reference in new issue
Block a user