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:
1 parent
011ba82a94
commit
502aecfc92
10 files changed
+541
-12
No files matched your search
+75
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user