Let a margin remark become a note, and lead to it ever after

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 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-10 16:40:38 +02:00
1 parent 011ba82a94
commit 502aecfc92
10 files changed
+541 -12

No files matched your search

+75 -1
View File
@@ -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);
+64 -2
View File
@@ -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 ── */
@@ -230,6 +230,8 @@ export function EditorPane({
onOpenNote={linkProps.openLinkTarget}
searchLinkTargets={linkProps.searchLinkTargets}
resolveLinkLabel={linkProps.resolveLinkLabel}
onCreateNoteForPin={linkProps.createNoteForPin}
onOpenCreatedNote={linkProps.openCreatedNote}
/>
) : (
<NoteEditor
+111 -4
View File
@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { PinLinkPicker } from "./PinLinkPicker.jsx";
/**
* The notes for a page, written in the margin beside it.
*
@@ -19,6 +21,9 @@ export const MARGIN_W = 208;
const CARD_H = 74;
const CARD_GAP = 6;
/** A card carrying a link to a note is one row taller. */
const heightOf = (note) => 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
</button>
{!note.noteId && (
<>
<button
data-pin-make-note
onClick={() => {
commit();
link.onCreate(note.id, draft);
}}
style={linkAction(T)}
>
Notiz anlegen
</button>
<button data-pin-link onClick={() => setPicking((v) => !v)} style={linkAction(T)}>
Verknüpfen
</button>
</>
)}
<span style={{ flex: 1 }} />
<button
onClick={() => {
@@ -158,6 +181,18 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) {
Fertig
</button>
</div>
{picking && (
<PinLinkPicker
T={T}
search={link.search}
onClose={() => setPicking(false)}
onPick={(target) => {
setPicking(false);
commit();
link.onLink(note.id, target.id);
}}
/>
)}
</>
) : (
<div
@@ -176,15 +211,86 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) {
{note.text || "Leer — zum Schreiben antippen"}
</div>
)}
{/* 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 && (
<div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 6 }}>
<button
data-pin-goto={note.noteId}
onClick={(e) => {
e.stopPropagation();
link.onOpenNote(note.noteId);
}}
title="Zur Notiz springen"
style={{
display: "flex",
alignItems: "center",
gap: 4,
flex: 1,
minWidth: 0,
border: "none",
background: "transparent",
color: T.accent,
cursor: "pointer",
fontSize: 10.5,
fontWeight: 600,
padding: 0,
textAlign: "left",
}}
>
<span style={{ flexShrink: 0 }}>→</span>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{link.labelOf(note.noteId) ?? "Notiz gelöscht"}
</span>
</button>
{open && (
<button
data-pin-unlink
onClick={(e) => {
e.stopPropagation();
link.onLink(note.id, null);
}}
title="Verweis lösen (die Notiz bleibt)"
style={{ ...linkAction(T), color: T.muted }}
>
Lösen
</button>
)}
</div>
)}
</div>
</div>
);
}
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 (
<div
@@ -207,6 +313,7 @@ export function PdfMargin({ T, notes, pageHeight, cssHeight, openId, onOpen, onC
onOpen={onOpen}
onChange={(text) => onChange(note.id, text)}
onRemove={() => onRemove(note.id)}
link={link}
/>
))}
</div>
+50 -1
View File
@@ -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. */}
<div style={{ display: "flex", alignItems: "center", gap: 14, marginTop: 8 }}>
{note.noteId ? (
<button
data-pin-goto={note.noteId}
onClick={() => link.onOpenNote(note.noteId)}
style={{
display: "flex",
alignItems: "center",
gap: 5,
flex: 1,
minWidth: 0,
border: "none",
background: "transparent",
color: T.accent,
cursor: "pointer",
fontSize: 12,
fontWeight: 600,
padding: 0,
textAlign: "left",
}}
>
<span style={{ flexShrink: 0 }}>→</span>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{link.labelOf?.(note.noteId) ?? "Notiz gelöscht"}
</span>
</button>
) : (
<button
data-pin-make-note
onClick={() => {
commit();
link.onCreate(draft);
}}
style={{
border: "none",
background: "transparent",
color: T.accent,
cursor: "pointer",
fontSize: 12,
padding: 0,
}}
>
Notiz anlegen
</button>
)}
</div>
<button
onClick={close}
style={{
+67 -1
View File
@@ -135,6 +135,7 @@ function PdfPage({ doc, index, width, height, size, visible, ink, pins, selectab
onChange={(id, text) => pins.onChange(index + 1, size, id, text)}
onRemove={(id) => pins.onRemove(index + 1, size, id)}
numberOf={pins.numberOf}
link={pins.link}
/>
)}
</div>
@@ -149,7 +150,7 @@ function PdfPage({ doc, index, width, height, size, visible, ink, pins, selectab
* 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, notesToggle }) {
export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, pinLinks }) {
const [state, setState] = useState({ status: "loading" });
const [zoom, setZoom] = useState(1);
const [drawing, setDrawing] = useState(false);
@@ -182,6 +183,12 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
setInk(incoming);
}, [note?.drawingStrokes]);
/* Held in a ref so the unmount flush below can reach the current one: the
* effect that runs it has no dependencies, and a captured `onUpdate` would be
* the one from the first render. */
const onUpdateRef = useRef(onUpdate);
onUpdateRef.current = onUpdate;
const saveInk = useCallback(
(next) => {
setInk(next);
@@ -199,6 +206,22 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
[onUpdate]
);
/* A save waits 700ms for the next stroke. Leaving the document inside that
* window — which is exactly what happens when a remark is turned into a note
* and the app goes there — would drop it. Flushed on the way out. */
useEffect(
() => () => {
if (!saveTimer.current) return;
clearTimeout(saveTimer.current);
saveTimer.current = 0;
const doc = inkRef.current;
onUpdateRef.current?.({
drawingStrokes: hasOverlay(doc) ? { ...doc, t: newSaveToken() } : null,
});
},
[]
);
const commitPage = useCallback(
(pageNo, strokes) => {
const size = state.sizes?.[pageNo - 1];
@@ -290,6 +313,35 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
[writeNotes]
);
/** Points a pin at a note, or lets go of one — the note itself is untouched. */
const linkNote = useCallback(
(pageNumber, size, id, noteId) =>
writeNotes(pageNumber, size, (notes) => notes.map((n) => (n.id === id ? { ...n, noteId } : n))),
[writeNotes]
);
/**
* Makes a note out of a remark and jumps to it.
*
* The pin keeps the new note's id, so the passage leads there ever after; the
* note is created with a link back, so the document lists it in its backlinks.
*/
const makeNoteFor = useCallback(
async (pageNumber, size, id, text) => {
if (!pinLinks?.onCreate) return;
const title = (text || "").trim().split("\n")[0]?.slice(0, 60) || `Notiz zu Seite ${pageNumber}`;
const created = await pinLinks.onCreate(title);
if (!created) return;
// Linked before leaving, and the pending write is flushed on unmount.
linkNote(pageNumber, size, id, created.id);
setOpenPin(null);
// Not `onOpenNote`: that one looks the note up in a list this note is not
// in yet, and would quietly go nowhere.
pinLinks.onOpenCreated?.(created.id, created.folderId);
},
[pinLinks, linkNote]
);
const [pageNo, setPageNo] = useState(1);
const [visible, setVisible] = useState(() => new Set([0, 1]));
const [containerWidth, setContainerWidth] = useState(0);
@@ -741,6 +793,13 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
onMove: moveNote,
onRemove: removeNote,
numberOf: (id) => pinNumbers.get(id) ?? "",
link: {
search: pinLinks?.search,
labelOf: pinLinks?.labelOf,
onOpenNote: pinLinks?.onOpenNote,
onCreate: (pinId, text) => makeNoteFor(i + 1, state.sizes[i], pinId, text),
onLink: (pinId, noteId) => linkNote(i + 1, state.sizes[i], pinId, noteId),
},
}}
/>
))}
@@ -758,6 +817,13 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
onChange={(text) => changeNote(openSheet.page, openSheet.size, openSheet.note.id, text)}
onRemove={() => removeNote(openSheet.page, openSheet.size, openSheet.note.id)}
onClose={() => setOpenPin(null)}
link={{
search: pinLinks?.search,
labelOf: pinLinks?.labelOf,
onOpenNote: pinLinks?.onOpenNote,
onCreate: (text) => makeNoteFor(openSheet.page, openSheet.size, openSheet.note.id, text),
onLink: (noteId) => linkNote(openSheet.page, openSheet.size, openSheet.note.id, noteId),
}}
/>
)}
</div>
+23 -2
View File
@@ -18,7 +18,16 @@ const MOBILE_BREAKPOINT = 900;
* 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 }) {
export function PdfWorkspace({
note,
dark,
onUpdate,
onOpenNote,
searchLinkTargets,
resolveLinkLabel,
onCreateNoteForPin,
onOpenCreatedNote,
}) {
const [showNotes, setShowNotes] = useState(false);
const [narrow, setNarrow] = useState(() => window.innerWidth < MOBILE_BREAKPOINT);
@@ -66,7 +75,19 @@ export function PdfWorkspace({ note, dark, onUpdate, onOpenNote, searchLinkTarge
<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} />
<PdfViewer
note={note}
dark={dark}
onUpdate={onUpdate}
notesToggle={notesToggle}
pinLinks={{
search: searchLinkTargets,
labelOf: resolveLinkLabel,
onOpenNote,
onCreate: (title) => onCreateNoteForPin?.(note, title),
onOpenCreated: onOpenCreatedNote,
}}
/>
</div>
)}
{showNotes && (
+120
View File
@@ -0,0 +1,120 @@
import { useEffect, useMemo, useRef, useState } from "react";
/**
* Choosing which note a pinned remark points at.
*
* Deliberately the same shape as the editor's `@` menu — type, see matches,
* pick one — because it is the same question asked in a different place, and
* two different ways to answer it would be one too many to remember.
*/
export function PinLinkPicker({ T, search, onPick, onClose }) {
const [query, setQuery] = useState("");
const [highlighted, setHighlighted] = useState(0);
const box = useRef(null);
const results = useMemo(
() => (search ? search(query).filter((r) => r.kind === "note") : []),
[search, query]
);
useEffect(() => setHighlighted(0), [query]);
useEffect(() => {
const away = (e) => {
if (box.current && !box.current.contains(e.target)) onClose();
};
const t = setTimeout(() => document.addEventListener("pointerdown", away), 0);
return () => {
clearTimeout(t);
document.removeEventListener("pointerdown", away);
};
}, [onClose]);
const onKeyDown = (e) => {
e.stopPropagation();
if (e.key === "Escape") return onClose();
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlighted((i) => Math.min(i + 1, results.length - 1));
}
if (e.key === "ArrowUp") {
e.preventDefault();
setHighlighted((i) => Math.max(i - 1, 0));
}
if (e.key === "Enter" && results[highlighted]) {
e.preventDefault();
onPick(results[highlighted]);
}
};
return (
<div
ref={box}
data-pin-link-picker
onPointerDown={(e) => e.stopPropagation()}
style={{
position: "absolute",
left: 8,
right: 8,
top: "100%",
marginTop: 4,
zIndex: 40,
background: T.bar,
border: `1px solid ${T.border}`,
borderRadius: 10,
boxShadow: "0 10px 26px rgba(0,0,0,.3)",
padding: 6,
}}
>
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
aria-label="Link to note"
placeholder="Notiz suchen…"
style={{
width: "100%",
border: `1px solid ${T.border}`,
borderRadius: 7,
background: "transparent",
color: T.text,
fontSize: 11.5,
padding: "4px 7px",
outline: "none",
}}
/>
<div style={{ maxHeight: 168, overflowY: "auto", marginTop: 4 }}>
{results.length === 0 ? (
<div style={{ fontSize: 11, color: T.muted, padding: "6px 7px" }}>Keine Notiz gefunden.</div>
) : (
results.map((r, i) => (
<button
key={r.id}
data-pin-link-option={r.id}
onClick={() => onPick(r)}
onMouseEnter={() => setHighlighted(i)}
style={{
display: "block",
width: "100%",
textAlign: "left",
border: "none",
borderRadius: 7,
background: i === highlighted ? T.accent + "22" : "transparent",
color: i === highlighted ? T.accent : T.text,
cursor: "pointer",
fontSize: 11.5,
padding: "5px 7px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{r.label}
</button>
))
)}
</div>
</div>
);
}
+9
View File
@@ -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 {
+20 -1
View File
@@ -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,