From 502aecfc9254946de587ab1353eef068ac70cacf Mon Sep 17 00:00:00 2001 From: marc Date: Mon, 10 Aug 2026 16:40:38 +0200 Subject: [PATCH] Let a margin remark become a note, and lead to it ever after MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some remarks turn into a page of their own. "Notiz anlegen" on a margin card makes one, titles it from what was written, and goes there; the pin keeps its id so the passage leads to that note afterwards. The note is created with a link back to the document, so the connection reads both ways and the document lists it among its backlinks — a note born from a passage should not be a note with nothing on it saying which passage. An existing note can be linked instead, and a link can be let go of without touching the note. Both are offered on the phone sheet too. Two older bugs stood in the way and are fixed: `openLinkTarget` finds a note's notebook by looking it up in the notes list, and a note created in the same tick is not in the copy that callback closed over — so jumping to a fresh note went nowhere at all. Creating one now navigates by the notebook it already knows. A drawing save waits 700ms for the next stroke, and leaving the document inside that window dropped it. That is precisely what turning a remark into a note does, so the link would have been lost on the way out. The viewer flushes a pending write when it unmounts, which also covers drawing and navigating away quickly. 215 unit tests, 229 browser checks across eleven suites. Co-Authored-By: Claude Opus 5 --- e2e/pdf.mjs | 76 ++++++++++++- frontend/src/App.jsx | 66 ++++++++++- frontend/src/features/notes/EditorPane.jsx | 2 + frontend/src/features/pdf/PdfMargin.jsx | 115 ++++++++++++++++++- frontend/src/features/pdf/PdfNoteSheet.jsx | 51 ++++++++- frontend/src/features/pdf/PdfViewer.jsx | 68 ++++++++++- frontend/src/features/pdf/PdfWorkspace.jsx | 25 +++- frontend/src/features/pdf/PinLinkPicker.jsx | 120 ++++++++++++++++++++ frontend/src/features/pdf/pageStrokes.ts | 9 ++ readme.md | 21 +++- 10 files changed, 541 insertions(+), 12 deletions(-) create mode 100644 frontend/src/features/pdf/PinLinkPicker.jsx diff --git a/e2e/pdf.mjs b/e2e/pdf.mjs index dd4200b..488392f 100644 --- a/e2e/pdf.mjs +++ b/e2e/pdf.mjs @@ -7,6 +7,7 @@ import { readFileSync, writeFileSync } from "node:fs"; import { inflateSync } from "node:zlib"; import { + api, captureDownloads, check, clearDownloads, @@ -119,6 +120,7 @@ try { await p.waitForTimeout(4000); check((await p.locator("[data-pdf-page]").count()) > 0, "the document is shown after importing"); + const pdfNoteId = (await api(p, "GET", "/notes")).body[0].id; check( (await p.locator("[data-pdf-position]").innerText()).includes("/ 2"), "both pages are counted", @@ -631,6 +633,73 @@ try { + /* ── a remark that turns into a note ── */ + await p.locator("[data-pdf-pin-mode]").click(); + await p.waitForTimeout(400); + const pageL = await p.locator('[data-pdf-page="1"]').boundingBox(); + await p.mouse.click(pageL.x + pageL.width * 0.35, pageL.y + pageL.height * 0.2); + await p.waitForTimeout(800); + await p.getByLabel("Pinned note").fill("Zebrastreifen nachschlagen"); + await p.waitForTimeout(400); + + const notesBefore = (await api(p, "GET", "/notes")).body.length; + await p.locator("[data-pin-make-note]").click(); + await p.waitForTimeout(4000); + + const notesAfter = (await api(p, "GET", "/notes")).body; + check(notesAfter.length === notesBefore + 1, "a remark can become a note of its own", `${notesBefore} → ${notesAfter.length}`); + + const made = notesAfter.find((n) => n.title === "Zebrastreifen nachschlagen"); + check(!!made, "titled from what was written in the margin"); + check( + (await p.locator(".ProseMirror").count()) > 0, + "and the app goes straight there rather than leaving it to be found" + ); + + /* The link has to read in both directions, or a note born from a passage has + nothing on it saying which passage. */ + const body = (await api(p, "GET", `/notes/${made.id}`)).body.content; + check(body.includes(pdfNoteId), "the new note carries a link back to the document", body.slice(0, 120)); + + const back = await api(p, "GET", `/notes/${pdfNoteId}/backlinks`); + check( + Array.isArray(back.body) && back.body.some((b) => b.id === made.id), + "so the document lists it among its backlinks", + JSON.stringify(back.body).slice(0, 120) + ); + + /* Back to the document: the pin now leads to the note. */ + await p.locator("[data-note-row]").filter({ hasText: "e2e-test" }).first().click(); + await p.waitForTimeout(4000); + const goto = p.locator(`[data-pin-goto="${made.id}"]`); + check((await goto.count()) === 1, "and the margin card links to it"); + check( + (await goto.innerText()).includes("Zebrastreifen"), + "showing the note's current title", + await goto.innerText() + ); + + await goto.click(); + await p.waitForTimeout(2500); + check( + (await p.getByLabel("Note title").inputValue()) === "Zebrastreifen nachschlagen", + "clicking it goes to the note", + await p.getByLabel("Note title").inputValue() + ); + + /* Back once more, and let go of the link — the note must survive that. */ + await p.locator("[data-note-row]").filter({ hasText: "e2e-test" }).first().click(); + await p.waitForTimeout(4000); + await p.locator(`[data-pin-goto="${made.id}"]`).locator("xpath=ancestor::*[@data-pin-card][1]").click(); + await p.waitForTimeout(700); + await p.locator("[data-pin-unlink]").click(); + await p.waitForTimeout(2500); + check((await p.locator(`[data-pin-goto="${made.id}"]`).count()) === 0, "the link can be let go of"); + check( + (await api(p, "GET", "/notes")).body.some((n) => n.id === made.id), + "and the note itself is still there" + ); + /* ── on a phone there is no margin to write in ── */ // A page narrow enough to read leaves no white space beside it, so taking a // column for notes would make the document itself unreadable. The note comes @@ -656,7 +725,12 @@ try { // backdrop up, which no real phone ever sees. Dismissed here so the rest of // the section is testing the document and not an overlay. if ((await p.locator("[data-pdf-page]").count()) === 0) { - await p.evaluate(() => document.querySelector("[data-note-row]")?.click()); + // By title: the notebook now holds the note that grew out of a remark too, + // and the first row is not necessarily the document. + await p.evaluate(() => { + const rows = [...document.querySelectorAll("[data-note-row]")]; + (rows.find((r) => r.textContent.includes("e2e-test")) || rows[0])?.click(); + }); await p.waitForTimeout(4500); } await p.mouse.click(410, 800); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6fec2d1..d7260e7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -34,6 +34,7 @@ import { EditorPane } from "./features/notes/EditorPane.jsx"; import { HistoryModal } from "./features/notes/HistoryModal.jsx"; import { DrawingLayer } from "./features/drawing/DrawingLayer.jsx"; import { CANON_W, CANON_H, PREVIEW_DRAWING_W } from "./features/drawing/strokes.js"; +import { serializeNoteContent } from "./editor/content"; /** Views that are not a notebook and therefore cannot hold a new note. */ const VIRTUAL_FOLDERS = ["all", "starred", "trash", "dashboard"]; @@ -279,9 +280,70 @@ export default function App() { [notes, openFolder, openNote] ); + /** + * Turns a remark in a PDF's margin into a note of its own. + * + * The new note opens with a link back to the document it came from, so the + * connection reads in both directions: the pin leads here, and this note shows + * up in the document's backlinks. Without that a note born from a passage + * would have nothing on it saying which passage. + */ + const createNoteForPin = useCallback( + async (source, title) => { + const folderId = source.folderId || notebooks[0]?.id; + if (!folderId) return null; + const note = await data.createNote(folderId); + const doc = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Zu " }, + { + type: "noteLink", + attrs: { noteId: source.id, label: source.title || "Dokument", kind: "note" }, + }, + ], + }, + { type: "paragraph" }, + ], + }; + data.updateNote(note.id, { + title, + content: serializeNoteContent(doc), + links: [source.id], + attachments: [], + }); + return { ...note, title }; + }, + [data, notebooks] + ); + + /** + * Opens a note that was created a moment ago. + * + * `openLinkTarget` looks the target up in the notes list to find its notebook, + * and a note created in the same tick is not in the copy that callback closed + * over — so it went nowhere at all. Here the notebook is already known. + */ + const openCreatedNote = useCallback( + (id, folderId) => { + if (folderId) setActiveFolder(folderId); + openNote(id); + }, + [openNote] + ); + const linkProps = useMemo( - () => ({ openLinkTarget, searchLinkTargets, resolveLinkLabel }), - [openLinkTarget, searchLinkTargets, resolveLinkLabel] + () => ({ + openLinkTarget, + searchLinkTargets, + resolveLinkLabel, + createNoteForPin, + openCreatedNote, + }), + [openLinkTarget, searchLinkTargets, resolveLinkLabel, createNoteForPin, openCreatedNote] ); /* ── Actions ── */ diff --git a/frontend/src/features/notes/EditorPane.jsx b/frontend/src/features/notes/EditorPane.jsx index 9ad87bf..22aadb6 100644 --- a/frontend/src/features/notes/EditorPane.jsx +++ b/frontend/src/features/notes/EditorPane.jsx @@ -230,6 +230,8 @@ export function EditorPane({ onOpenNote={linkProps.openLinkTarget} searchLinkTargets={linkProps.searchLinkTargets} resolveLinkLabel={linkProps.resolveLinkLabel} + onCreateNoteForPin={linkProps.createNoteForPin} + onOpenCreatedNote={linkProps.openCreatedNote} /> ) : ( CARD_H + (note.noteId ? 20 : 0); + /** Cards at their anchors, pushed down where they would sit on top of each other. */ function layout(notes, pageHeight, cssHeight) { let previousBottom = -Infinity; @@ -28,13 +33,14 @@ function layout(notes, pageHeight, cssHeight) { .map((note) => { const anchor = (note.y / pageHeight) * cssHeight - 12; const top = Math.max(anchor, previousBottom + CARD_GAP); - previousBottom = top + CARD_H; + previousBottom = top + heightOf(note); return { note, top }; }); } -function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) { +function Card({ T, note, top, number, open, onOpen, onChange, onRemove, link }) { const [draft, setDraft] = useState(note.text); + const [picking, setPicking] = useState(false); const areaRef = useRef(null); // Adopt text that arrived from elsewhere — another device, or an undo — but @@ -138,6 +144,23 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) { > Entfernen + {!note.noteId && ( + <> + + + + )} + {picking && ( + setPicking(false)} + onPick={(target) => { + setPicking(false); + commit(); + link.onLink(note.id, target.id); + }} + /> + )} ) : (
)} + + {/* The note this remark grew into. Shown in both states: when the card + is closed it is the whole point of the card, and when it is open it + is what stops a second note being made for the same passage. */} + {note.noteId && ( +
+ + {open && ( + + )} +
+ )}
); } -export function PdfMargin({ T, notes, pageHeight, cssHeight, openId, onOpen, onChange, onRemove, numberOf }) { +const linkAction = (T) => ({ + border: "none", + background: "transparent", + color: T.accent, + cursor: "pointer", + fontSize: 10.5, + padding: 0, + marginRight: 10, +}); + +export function PdfMargin({ + T, + notes, + pageHeight, + cssHeight, + openId, + onOpen, + onChange, + onRemove, + numberOf, + link, +}) { const placed = layout(notes, pageHeight, cssHeight); // Cards near the foot of a page may need more room than the page is tall. - const needed = placed.length ? placed[placed.length - 1].top + CARD_H : 0; + const last = placed[placed.length - 1]; + const needed = last ? last.top + heightOf(last.note) : 0; return (
onChange(note.id, text)} onRemove={() => onRemove(note.id)} + link={link} /> ))}
diff --git a/frontend/src/features/pdf/PdfNoteSheet.jsx b/frontend/src/features/pdf/PdfNoteSheet.jsx index b7594be..869df19 100644 --- a/frontend/src/features/pdf/PdfNoteSheet.jsx +++ b/frontend/src/features/pdf/PdfNoteSheet.jsx @@ -12,7 +12,7 @@ import { useEffect, useRef, useState } from "react"; * The page stays visible above it, so the passage the note is about is still * there while writing about it. */ -export function PdfNoteSheet({ T, note, number, onChange, onClose, onRemove }) { +export function PdfNoteSheet({ T, note, number, onChange, onClose, onRemove, link }) { const [draft, setDraft] = useState(note.text); const areaRef = useRef(null); @@ -109,6 +109,55 @@ export function PdfNoteSheet({ T, note, number, onChange, onClose, onRemove }) { }} /> + {/* Same two ways out as the margin card: some remarks turn into a page of + their own, and the pin should lead there afterwards. */} +
+ {note.noteId ? ( + + ) : ( + + )} +
+ + )) + )} + + + ); +} diff --git a/frontend/src/features/pdf/pageStrokes.ts b/frontend/src/features/pdf/pageStrokes.ts index 7f46eb2..0de35dc 100644 --- a/frontend/src/features/pdf/pageStrokes.ts +++ b/frontend/src/features/pdf/pageStrokes.ts @@ -27,6 +27,15 @@ export interface PinnedNote { x: number; y: number; text: string; + /** + * A note this pin points at. + * + * A margin card holds a remark; some remarks turn into a page of their own. + * Keeping the id here means the passage and the note that came out of it stay + * connected in both directions — the pin leads to the note, and the note is + * written with a link back, so it shows up in the document's backlinks. + */ + noteId?: string | null; } export interface PageStrokes { diff --git a/readme.md b/readme.md index c8156da..21d5f69 100644 --- a/readme.md +++ b/readme.md @@ -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` | 70 | import, page proportions, lazy loading, and reading with the network off | +| `pdf` | 80 | 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 | @@ -795,6 +795,25 @@ handling rather than assuming: notes was therefore stored as `NULL` — every note on it lost on the next save. Both now ask about strokes *and* pins. +**A remark can become a note of its own.** "Notiz anlegen" on a card makes one, +titles it from what was written, and goes there; the pin keeps its id, so the +passage leads to that note ever after. The note is created with a link back to +the document, so the connection reads in both directions and the document lists +it among its backlinks — a note born from a passage should not be a note with +nothing on it saying which passage. An existing note can be linked instead, and a +link can be let go of without touching the note. + +Two things had to be fixed for that to work at all, both of them older than the +feature: + +- `openLinkTarget` finds a note's notebook by looking it up in the notes list, + and a note created in the same tick is not in the copy that callback closed + over — so jumping to it went nowhere. Creating one now navigates by the + notebook it already knows. +- A drawing save waits 700ms for the next stroke. Leaving the document inside + that window dropped it, which is exactly what turning a remark into a note + does. The viewer now flushes a pending write when it unmounts. + 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,