Link the margin notes from the notes, and let them be hidden
The linking went the wrong way round. A remark in the margin is short by design; when there is more to say about that passage it belongs in the notes beside the document — and it has to be possible to say *which* passage. So the pins are offered to the editor's `@` menu exactly like notes and notebooks are, labelled with their page, and following one scrolls the document to the spot and opens the remark. Pins are unhidden first: a link leading to something invisible would read as broken. A pin link is the same node with kind "pin", and is deliberately kept out of the links reported to the server. Its target is a marker inside this note's own overlay, so no note has that id, and reporting it would fill the backlink table with rows matching nothing. They can also be hidden altogether. Reading a document as it was printed is a normal thing to want; the toolbar takes the markers and the column away together and the page takes the width back. The other direction — making a note out of a remark — is removed, along with its picker and the pin's noteId: 357 lines fewer than before. The unmount flush it turned up stays, since a drawing save was genuinely being dropped by navigating away inside its debounce. 218 unit tests, 231 browser checks across eleven suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
502aecfc92
commit
4c1b632029
13 files changed
+239
-484
No files matched your search
+63
-43
@@ -633,71 +633,91 @@ try {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* ── a remark that turns into a note ── */
|
/* ── linking a margin note from the notes beside the document ── */
|
||||||
|
// A remark in the margin is short by design. When there is more to say about
|
||||||
|
// that passage it belongs in the notes — and it has to be possible to say
|
||||||
|
// *which* passage.
|
||||||
await p.locator("[data-pdf-pin-mode]").click();
|
await p.locator("[data-pdf-pin-mode]").click();
|
||||||
await p.waitForTimeout(400);
|
await p.waitForTimeout(400);
|
||||||
const pageL = await p.locator('[data-pdf-page="1"]').boundingBox();
|
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.mouse.click(pageL.x + pageL.width * 0.35, pageL.y + pageL.height * 0.2);
|
||||||
await p.waitForTimeout(800);
|
await p.waitForTimeout(800);
|
||||||
await p.getByLabel("Pinned note").fill("Zebrastreifen nachschlagen");
|
await p.getByLabel("Pinned note").fill("Zebrastreifen nachschlagen");
|
||||||
await p.waitForTimeout(400);
|
await p.getByRole("button", { name: "Fertig" }).click();
|
||||||
|
await p.waitForTimeout(2500);
|
||||||
|
|
||||||
const notesBefore = (await api(p, "GET", "/notes")).body.length;
|
await p.locator("[data-pdf-toggle-notes]").click();
|
||||||
await p.locator("[data-pin-make-note]").click();
|
await p.waitForTimeout(1200);
|
||||||
await p.waitForTimeout(4000);
|
const panel = p.locator("[data-pdf-notes] .ProseMirror");
|
||||||
|
await panel.click();
|
||||||
|
await p.keyboard.type("Dazu ausführlicher: ");
|
||||||
|
await p.keyboard.type("@");
|
||||||
|
await p.waitForTimeout(1200);
|
||||||
|
|
||||||
const notesAfter = (await api(p, "GET", "/notes")).body;
|
const suggestions = await p.locator("body").innerText();
|
||||||
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(
|
check(
|
||||||
(await p.locator(".ProseMirror").count()) > 0,
|
/Zebrastreifen nachschlagen/.test(suggestions),
|
||||||
"and the app goes straight there rather than leaving it to be found"
|
"the margin notes are offered when linking from the notes",
|
||||||
|
suggestions.slice(0, 160).replace(/\n/g, " ")
|
||||||
);
|
);
|
||||||
|
|
||||||
/* The link has to read in both directions, or a note born from a passage has
|
await p.keyboard.press("Enter");
|
||||||
nothing on it saying which passage. */
|
await p.waitForTimeout(2500);
|
||||||
const body = (await api(p, "GET", `/notes/${made.id}`)).body.content;
|
const linkEl = panel.locator("[data-note-link]").first();
|
||||||
check(body.includes(pdfNoteId), "the new note carries a link back to the document", body.slice(0, 120));
|
check((await linkEl.count()) === 1, "one can be inserted as a link");
|
||||||
|
|
||||||
const back = await api(p, "GET", `/notes/${pdfNoteId}/backlinks`);
|
|
||||||
check(
|
check(
|
||||||
Array.isArray(back.body) && back.body.some((b) => b.id === made.id),
|
(await linkEl.getAttribute("data-kind")) === "pin",
|
||||||
"so the document lists it among its backlinks",
|
"recorded as a link to a spot, not to a note",
|
||||||
JSON.stringify(back.body).slice(0, 120)
|
await linkEl.getAttribute("data-kind")
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
(await linkEl.innerText()).includes("S. 1"),
|
||||||
|
"labelled with the page it points at",
|
||||||
|
await linkEl.innerText()
|
||||||
);
|
);
|
||||||
|
|
||||||
/* Back to the document: the pin now leads to the note. */
|
/* The link must not reach the backlink table: its target is a marker in this
|
||||||
await p.locator("[data-note-row]").filter({ hasText: "e2e-test" }).first().click();
|
note's own overlay, and no note has that id. */
|
||||||
await p.waitForTimeout(4000);
|
const backAfter = await api(p, "GET", `/notes/${pdfNoteId}/backlinks`);
|
||||||
const goto = p.locator(`[data-pin-goto="${made.id}"]`);
|
|
||||||
check((await goto.count()) === 1, "and the margin card links to it");
|
|
||||||
check(
|
check(
|
||||||
(await goto.innerText()).includes("Zebrastreifen"),
|
Array.isArray(backAfter.body) && backAfter.body.length === 0,
|
||||||
"showing the note's current title",
|
"and is not reported as a link to a note",
|
||||||
await goto.innerText()
|
JSON.stringify(backAfter.body).slice(0, 100)
|
||||||
);
|
);
|
||||||
|
|
||||||
await goto.click();
|
/* Following it goes to the passage. */
|
||||||
|
await panel.locator("[data-note-link]").first().click();
|
||||||
await p.waitForTimeout(2500);
|
await p.waitForTimeout(2500);
|
||||||
check(
|
check(
|
||||||
(await p.getByLabel("Note title").inputValue()) === "Zebrastreifen nachschlagen",
|
(await p.getByLabel("Pinned note").inputValue()) === "Zebrastreifen nachschlagen",
|
||||||
"clicking it goes to the note",
|
"following the link opens the margin note it points at",
|
||||||
await p.getByLabel("Note title").inputValue()
|
await p.getByLabel("Pinned note").inputValue()
|
||||||
);
|
);
|
||||||
|
|
||||||
/* Back once more, and let go of the link — the note must survive that. */
|
// With the notes panel open there is no room for a margin column beside the
|
||||||
await p.locator("[data-note-row]").filter({ hasText: "e2e-test" }).first().click();
|
// page, so the remark opens in the sheet instead. Closing the panel gives the
|
||||||
await p.waitForTimeout(4000);
|
// column back — and the same remark is still the open one.
|
||||||
await p.locator(`[data-pin-goto="${made.id}"]`).locator("xpath=ancestor::*[@data-pin-card][1]").click();
|
await p.locator("[data-pdf-toggle-notes]").click();
|
||||||
await p.waitForTimeout(700);
|
await p.waitForTimeout(1500);
|
||||||
await p.locator("[data-pin-unlink]").click();
|
const openCard = p.locator("[data-pin-card]").filter({ has: p.getByLabel("Pinned note") });
|
||||||
await p.waitForTimeout(2500);
|
check((await openCard.count()) === 1, "and it is the card that is open in the margin");
|
||||||
check((await p.locator(`[data-pin-goto="${made.id}"]`).count()) === 0, "the link can be let go of");
|
await p.keyboard.press("Escape");
|
||||||
|
await p.waitForTimeout(600);
|
||||||
|
|
||||||
|
/* ── showing and hiding them ── */
|
||||||
|
check((await p.locator("[data-pdf-show-pins]").count()) === 1, "the margin notes can be hidden");
|
||||||
|
await p.locator("[data-pdf-show-pins]").click();
|
||||||
|
await p.waitForTimeout(1200);
|
||||||
|
check((await p.locator("[data-pin]").count()) === 0, "hiding takes the markers off the page");
|
||||||
|
check((await p.locator("[data-pdf-margin]").count()) === 0, "and the column with them");
|
||||||
|
|
||||||
|
const pageWide = (await p.locator('[data-pdf-page="2"]').boundingBox()).width;
|
||||||
|
await p.locator("[data-pdf-show-pins]").click();
|
||||||
|
await p.waitForTimeout(1200);
|
||||||
|
check((await p.locator("[data-pin]").count()) > 0, "showing brings them back");
|
||||||
check(
|
check(
|
||||||
(await api(p, "GET", "/notes")).body.some((n) => n.id === made.id),
|
(await p.locator('[data-pdf-page="2"]').boundingBox()).width < pageWide,
|
||||||
"and the note itself is still there"
|
"and the page gives the column its width again"
|
||||||
);
|
);
|
||||||
|
|
||||||
/* ── on a phone there is no margin to write in ── */
|
/* ── on a phone there is no margin to write in ── */
|
||||||
|
|||||||
+2
-64
@@ -34,7 +34,6 @@ import { EditorPane } from "./features/notes/EditorPane.jsx";
|
|||||||
import { HistoryModal } from "./features/notes/HistoryModal.jsx";
|
import { HistoryModal } from "./features/notes/HistoryModal.jsx";
|
||||||
import { DrawingLayer } from "./features/drawing/DrawingLayer.jsx";
|
import { DrawingLayer } from "./features/drawing/DrawingLayer.jsx";
|
||||||
import { CANON_W, CANON_H, PREVIEW_DRAWING_W } from "./features/drawing/strokes.js";
|
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. */
|
/** Views that are not a notebook and therefore cannot hold a new note. */
|
||||||
const VIRTUAL_FOLDERS = ["all", "starred", "trash", "dashboard"];
|
const VIRTUAL_FOLDERS = ["all", "starred", "trash", "dashboard"];
|
||||||
@@ -280,70 +279,9 @@ export default function App() {
|
|||||||
[notes, openFolder, openNote]
|
[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(
|
const linkProps = useMemo(
|
||||||
() => ({
|
() => ({ openLinkTarget, searchLinkTargets, resolveLinkLabel }),
|
||||||
openLinkTarget,
|
[openLinkTarget, searchLinkTargets, resolveLinkLabel]
|
||||||
searchLinkTargets,
|
|
||||||
resolveLinkLabel,
|
|
||||||
createNoteForPin,
|
|
||||||
openCreatedNote,
|
|
||||||
}),
|
|
||||||
[openLinkTarget, searchLinkTargets, resolveLinkLabel, createNoteForPin, openCreatedNote]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/* ── Actions ── */
|
/* ── Actions ── */
|
||||||
|
|||||||
@@ -181,3 +181,42 @@ describe("docToText", () => {
|
|||||||
expect(docToText(doc, 50).length).toBeLessThanOrEqual(50);
|
expect(docToText(doc, 50).length).toBeLessThanOrEqual(50);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("links to a spot in a PDF", () => {
|
||||||
|
/* They use the same node with kind "pin", but the target is a marker inside
|
||||||
|
* this note's own overlay — not another note. Reporting one as a note link
|
||||||
|
* would fill the backlink table with ids that match nothing at all. */
|
||||||
|
const doc = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [
|
||||||
|
{ type: "noteLink", attrs: { noteId: "pin-abc", label: "S. 3", kind: "pin" } },
|
||||||
|
{ type: "noteLink", attrs: { noteId: "note-1", label: "Andere Notiz", kind: "note" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("are left out of the links reported to the server", () => {
|
||||||
|
expect(extractNoteLinks(doc)).toEqual(["note-1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("do not stop the real links in the same paragraph being reported", () => {
|
||||||
|
expect(extractNoteLinks(doc)).toContain("note-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("are left out even when they are the only links there are", () => {
|
||||||
|
const onlyPins = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "noteLink", attrs: { noteId: "pin-x", label: "S. 1", kind: "pin" } }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(extractNoteLinks(onlyPins)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -117,6 +117,10 @@ function walk(node: DocNode | undefined | null, visit: (n: DocNode) => void): vo
|
|||||||
export function extractNoteLinks(doc: DocNode, selfId?: string): string[] {
|
export function extractNoteLinks(doc: DocNode, selfId?: string): string[] {
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
walk(doc, (n) => {
|
walk(doc, (n) => {
|
||||||
|
// A link to a spot in a PDF uses the same node with kind "pin", but its
|
||||||
|
// target is a marker inside this note's own overlay, not another note.
|
||||||
|
// Reporting it would fill the backlink table with ids that match nothing.
|
||||||
|
if (n.attrs?.kind === "pin") return;
|
||||||
if (n.type === "noteLink" && n.attrs?.noteId && n.attrs.noteId !== selfId) ids.add(n.attrs.noteId);
|
if (n.type === "noteLink" && n.attrs?.noteId && n.attrs.noteId !== selfId) ids.add(n.attrs.noteId);
|
||||||
});
|
});
|
||||||
return [...ids];
|
return [...ids];
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Note links — type `@` to reference another note or notebook.
|
* Note links — type `@` to reference another note, a notebook, or, inside a PDF,
|
||||||
|
* one of the notes pinned to its pages.
|
||||||
*
|
*
|
||||||
* A link is an atomic inline node carrying the target's id, so it survives
|
* A link is an atomic inline node carrying the target's id, so it survives
|
||||||
* renames: the label is refreshed from the current title on render, and the id
|
* renames: the label is refreshed from the current title on render, and the id
|
||||||
* is what the backlink index is built from.
|
* is what the backlink index is built from. A `pin` link is deliberately left
|
||||||
|
* out of that index — its target is a marker in the note's own overlay, so no
|
||||||
|
* note has that id.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Node, mergeAttributes } from "@tiptap/core";
|
import { Node, mergeAttributes } from "@tiptap/core";
|
||||||
@@ -68,7 +71,7 @@ export const NoteLink = Node.create({
|
|||||||
href: "#",
|
href: "#",
|
||||||
title: node.attrs.label,
|
title: node.attrs.label,
|
||||||
}),
|
}),
|
||||||
`${node.attrs.kind === "notebook" ? "📓" : "📝"} ${node.attrs.label}`,
|
`${{ notebook: "📓", pin: "📌" }[node.attrs.kind] || "📝"} ${node.attrs.label}`,
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -230,8 +230,6 @@ export function EditorPane({
|
|||||||
onOpenNote={linkProps.openLinkTarget}
|
onOpenNote={linkProps.openLinkTarget}
|
||||||
searchLinkTargets={linkProps.searchLinkTargets}
|
searchLinkTargets={linkProps.searchLinkTargets}
|
||||||
resolveLinkLabel={linkProps.resolveLinkLabel}
|
resolveLinkLabel={linkProps.resolveLinkLabel}
|
||||||
onCreateNoteForPin={linkProps.createNoteForPin}
|
|
||||||
onOpenCreatedNote={linkProps.openCreatedNote}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<NoteEditor
|
<NoteEditor
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { PinLinkPicker } from "./PinLinkPicker.jsx";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The notes for a page, written in the margin beside it.
|
* The notes for a page, written in the margin beside it.
|
||||||
*
|
*
|
||||||
@@ -21,9 +19,6 @@ export const MARGIN_W = 208;
|
|||||||
const CARD_H = 74;
|
const CARD_H = 74;
|
||||||
const CARD_GAP = 6;
|
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. */
|
/** Cards at their anchors, pushed down where they would sit on top of each other. */
|
||||||
function layout(notes, pageHeight, cssHeight) {
|
function layout(notes, pageHeight, cssHeight) {
|
||||||
let previousBottom = -Infinity;
|
let previousBottom = -Infinity;
|
||||||
@@ -33,14 +28,13 @@ function layout(notes, pageHeight, cssHeight) {
|
|||||||
.map((note) => {
|
.map((note) => {
|
||||||
const anchor = (note.y / pageHeight) * cssHeight - 12;
|
const anchor = (note.y / pageHeight) * cssHeight - 12;
|
||||||
const top = Math.max(anchor, previousBottom + CARD_GAP);
|
const top = Math.max(anchor, previousBottom + CARD_GAP);
|
||||||
previousBottom = top + heightOf(note);
|
previousBottom = top + CARD_H;
|
||||||
return { note, top };
|
return { note, top };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function Card({ T, note, top, number, open, onOpen, onChange, onRemove, link }) {
|
function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) {
|
||||||
const [draft, setDraft] = useState(note.text);
|
const [draft, setDraft] = useState(note.text);
|
||||||
const [picking, setPicking] = useState(false);
|
|
||||||
const areaRef = useRef(null);
|
const areaRef = useRef(null);
|
||||||
|
|
||||||
// Adopt text that arrived from elsewhere — another device, or an undo — but
|
// Adopt text that arrived from elsewhere — another device, or an undo — but
|
||||||
@@ -144,23 +138,6 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove, link })
|
|||||||
>
|
>
|
||||||
Entfernen
|
Entfernen
|
||||||
</button>
|
</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 }} />
|
<span style={{ flex: 1 }} />
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -181,18 +158,6 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove, link })
|
|||||||
Fertig
|
Fertig
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{picking && (
|
|
||||||
<PinLinkPicker
|
|
||||||
T={T}
|
|
||||||
search={link.search}
|
|
||||||
onClose={() => setPicking(false)}
|
|
||||||
onPick={(target) => {
|
|
||||||
setPicking(false);
|
|
||||||
commit();
|
|
||||||
link.onLink(note.id, target.id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
@@ -211,86 +176,15 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove, link })
|
|||||||
{note.text || "Leer — zum Schreiben antippen"}
|
{note.text || "Leer — zum Schreiben antippen"}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const linkAction = (T) => ({
|
export function PdfMargin({ T, notes, pageHeight, cssHeight, openId, onOpen, onChange, onRemove, numberOf }) {
|
||||||
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);
|
const placed = layout(notes, pageHeight, cssHeight);
|
||||||
// Cards near the foot of a page may need more room than the page is tall.
|
// Cards near the foot of a page may need more room than the page is tall.
|
||||||
const last = placed[placed.length - 1];
|
const needed = placed.length ? placed[placed.length - 1].top + CARD_H : 0;
|
||||||
const needed = last ? last.top + heightOf(last.note) : 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -313,7 +207,6 @@ export function PdfMargin({
|
|||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
onChange={(text) => onChange(note.id, text)}
|
onChange={(text) => onChange(note.id, text)}
|
||||||
onRemove={() => onRemove(note.id)}
|
onRemove={() => onRemove(note.id)}
|
||||||
link={link}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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
|
* The page stays visible above it, so the passage the note is about is still
|
||||||
* there while writing about it.
|
* there while writing about it.
|
||||||
*/
|
*/
|
||||||
export function PdfNoteSheet({ T, note, number, onChange, onClose, onRemove, link }) {
|
export function PdfNoteSheet({ T, note, number, onChange, onClose, onRemove }) {
|
||||||
const [draft, setDraft] = useState(note.text);
|
const [draft, setDraft] = useState(note.text);
|
||||||
const areaRef = useRef(null);
|
const areaRef = useRef(null);
|
||||||
|
|
||||||
@@ -109,55 +109,6 @@ export function PdfNoteSheet({ T, note, number, onChange, onClose, onRemove, lin
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 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
|
<button
|
||||||
onClick={close}
|
onClick={close}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -135,7 +135,6 @@ function PdfPage({ doc, index, width, height, size, visible, ink, pins, selectab
|
|||||||
onChange={(id, text) => pins.onChange(index + 1, size, id, text)}
|
onChange={(id, text) => pins.onChange(index + 1, size, id, text)}
|
||||||
onRemove={(id) => pins.onRemove(index + 1, size, id)}
|
onRemove={(id) => pins.onRemove(index + 1, size, id)}
|
||||||
numberOf={pins.numberOf}
|
numberOf={pins.numberOf}
|
||||||
link={pins.link}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -150,12 +149,13 @@ function PdfPage({ doc, index, width, height, size, visible, ink, pins, selectab
|
|||||||
* proportions before anything is drawn, so the scrollbar is honest from the
|
* proportions before anything is drawn, so the scrollbar is honest from the
|
||||||
* first frame instead of jumping as pages arrive.
|
* first frame instead of jumping as pages arrive.
|
||||||
*/
|
*/
|
||||||
export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, pinLinks }) {
|
export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, focusPin, onFocusHandled }) {
|
||||||
const [state, setState] = useState({ status: "loading" });
|
const [state, setState] = useState({ status: "loading" });
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
const [drawing, setDrawing] = useState(false);
|
const [drawing, setDrawing] = useState(false);
|
||||||
const [placing, setPlacing] = useState(false);
|
const [placing, setPlacing] = useState(false);
|
||||||
const [openPin, setOpenPin] = useState(null);
|
const [openPin, setOpenPin] = useState(null);
|
||||||
|
const [showPins, setShowPins] = useState(true);
|
||||||
const [hideInk, setHideInk] = useState(false);
|
const [hideInk, setHideInk] = useState(false);
|
||||||
const [tool, setTool] = useState("pen");
|
const [tool, setTool] = useState("pen");
|
||||||
const [color, setColor] = useState(COLORS[1]);
|
const [color, setColor] = useState(COLORS[1]);
|
||||||
@@ -313,34 +313,24 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, pin
|
|||||||
[writeNotes]
|
[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.
|
* Someone followed a link to a pinned note from the notes beside the document.
|
||||||
*
|
*
|
||||||
* The pin keeps the new note's id, so the passage leads there ever after; the
|
* Scrolls its page into view and opens it. The pins are unhidden first: a link
|
||||||
* note is created with a link back, so the document lists it in its backlinks.
|
* that led to something invisible would read as broken.
|
||||||
*/
|
*/
|
||||||
const makeNoteFor = useCallback(
|
useEffect(() => {
|
||||||
async (pageNumber, size, id, text) => {
|
if (!focusPin || state.status !== "ready") return;
|
||||||
if (!pinLinks?.onCreate) return;
|
const target = allPinnedNotes(ink).find((n) => n.id === focusPin);
|
||||||
const title = (text || "").trim().split("\n")[0]?.slice(0, 60) || `Notiz zu Seite ${pageNumber}`;
|
onFocusHandled?.();
|
||||||
const created = await pinLinks.onCreate(title);
|
if (!target) return;
|
||||||
if (!created) return;
|
setShowPins(true);
|
||||||
// Linked before leaving, and the pending write is flushed on unmount.
|
setOpenPin(target.id);
|
||||||
linkNote(pageNumber, size, id, created.id);
|
const row = innerRef.current?.querySelector(`[data-pdf-row="${target.page}"]`);
|
||||||
setOpenPin(null);
|
if (row && scrollRef.current) {
|
||||||
// Not `onOpenNote`: that one looks the note up in a list this note is not
|
scrollRef.current.scrollTo({ top: row.offsetTop - 8, behavior: "smooth" });
|
||||||
// in yet, and would quietly go nowhere.
|
}
|
||||||
pinLinks.onOpenCreated?.(created.id, created.folderId);
|
}, [focusPin, state.status, ink, onFocusHandled]);
|
||||||
},
|
|
||||||
[pinLinks, linkNote]
|
|
||||||
);
|
|
||||||
|
|
||||||
const [pageNo, setPageNo] = useState(1);
|
const [pageNo, setPageNo] = useState(1);
|
||||||
const [visible, setVisible] = useState(() => new Set([0, 1]));
|
const [visible, setVisible] = useState(() => new Set([0, 1]));
|
||||||
@@ -429,7 +419,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, pin
|
|||||||
* is being placed — so it is already there before the first note exists and
|
* is being placed — so it is already there before the first note exists and
|
||||||
* the page does not jump sideways at the moment of writing it. */
|
* the page does not jump sideways at the moment of writing it. */
|
||||||
const roomForMargin = containerWidth >= MARGIN_W + 380;
|
const roomForMargin = containerWidth >= MARGIN_W + 380;
|
||||||
const withMargin = roomForMargin && (placing || totalPinnedNotes(ink) > 0);
|
const withMargin = showPins && roomForMargin && (placing || totalPinnedNotes(ink) > 0);
|
||||||
|
|
||||||
/* The open note, with the page it belongs to — the sheet needs the page size
|
/* The open note, with the page it belongs to — the sheet needs the page size
|
||||||
to write a change back into the right page's overlay. */
|
to write a change back into the right page's overlay. */
|
||||||
@@ -599,6 +589,31 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, pin
|
|||||||
{I.pen} {drawing ? "Zeichnen" : "Lesen"}
|
{I.pen} {drawing ? "Zeichnen" : "Lesen"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Reading the document as it was printed is a normal thing to want, and
|
||||||
|
so is having every remark in view. Both are one click away. */}
|
||||||
|
{totalPinnedNotes(ink) > 0 && (
|
||||||
|
<button
|
||||||
|
data-pdf-show-pins={showPins ? "on" : "off"}
|
||||||
|
onClick={() => {
|
||||||
|
setShowPins((v) => !v);
|
||||||
|
setOpenPin(null);
|
||||||
|
}}
|
||||||
|
title={showPins ? "Randnotizen ausblenden" : "Randnotizen einblenden"}
|
||||||
|
style={{
|
||||||
|
...barButton,
|
||||||
|
width: "auto",
|
||||||
|
padding: "0 10px",
|
||||||
|
gap: 6,
|
||||||
|
border: `1px solid ${showPins ? T.border : T.accent + "66"}`,
|
||||||
|
background: showPins ? "transparent" : T.accent + "1a",
|
||||||
|
color: showPins ? T.muted : T.accent,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showPins ? I.eye : I.eyeOff} Randnotizen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* A note about a passage belongs on the passage. */}
|
{/* A note about a passage belongs on the passage. */}
|
||||||
<button
|
<button
|
||||||
data-pdf-pin-mode={placing ? "on" : "off"}
|
data-pdf-pin-mode={placing ? "on" : "off"}
|
||||||
@@ -785,7 +800,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, pin
|
|||||||
notes: notesForPage(ink, i + 1),
|
notes: notesForPage(ink, i + 1),
|
||||||
placing,
|
placing,
|
||||||
openId: openPin,
|
openId: openPin,
|
||||||
hidden: hideInk,
|
hidden: hideInk || !showPins,
|
||||||
withMargin,
|
withMargin,
|
||||||
onOpen: setOpenPin,
|
onOpen: setOpenPin,
|
||||||
onPlace: placeNote,
|
onPlace: placeNote,
|
||||||
@@ -793,13 +808,6 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, pin
|
|||||||
onMove: moveNote,
|
onMove: moveNote,
|
||||||
onRemove: removeNote,
|
onRemove: removeNote,
|
||||||
numberOf: (id) => pinNumbers.get(id) ?? "",
|
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),
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -817,13 +825,6 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, pin
|
|||||||
onChange={(text) => changeNote(openSheet.page, openSheet.size, openSheet.note.id, text)}
|
onChange={(text) => changeNote(openSheet.page, openSheet.size, openSheet.note.id, text)}
|
||||||
onRemove={() => removeNote(openSheet.page, openSheet.size, openSheet.note.id)}
|
onRemove={() => removeNote(openSheet.page, openSheet.size, openSheet.note.id)}
|
||||||
onClose={() => setOpenPin(null)}
|
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>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { PdfViewer } from "./PdfViewer.jsx";
|
import { PdfViewer } from "./PdfViewer.jsx";
|
||||||
import { PdfNotesPanel } from "./PdfNotesPanel.jsx";
|
import { PdfNotesPanel } from "./PdfNotesPanel.jsx";
|
||||||
import { I } from "../../ui/icons.jsx";
|
import { I } from "../../ui/icons.jsx";
|
||||||
|
import { allPinnedNotes, asPdfStrokeDoc } from "./pageStrokes";
|
||||||
|
|
||||||
const MOBILE_BREAKPOINT = 900;
|
const MOBILE_BREAKPOINT = 900;
|
||||||
|
|
||||||
@@ -18,17 +19,11 @@ const MOBILE_BREAKPOINT = 900;
|
|||||||
* height on every screen and put one of the document's controls somewhere none
|
* height on every screen and put one of the document's controls somewhere none
|
||||||
* of the others were.
|
* of the others were.
|
||||||
*/
|
*/
|
||||||
export function PdfWorkspace({
|
export function PdfWorkspace({ note, dark, onUpdate, onOpenNote, searchLinkTargets, resolveLinkLabel }) {
|
||||||
note,
|
|
||||||
dark,
|
|
||||||
onUpdate,
|
|
||||||
onOpenNote,
|
|
||||||
searchLinkTargets,
|
|
||||||
resolveLinkLabel,
|
|
||||||
onCreateNoteForPin,
|
|
||||||
onOpenCreatedNote,
|
|
||||||
}) {
|
|
||||||
const [showNotes, setShowNotes] = useState(false);
|
const [showNotes, setShowNotes] = useState(false);
|
||||||
|
// Set when a link in the notes points at a marker; the viewer clears it once
|
||||||
|
// it has scrolled there.
|
||||||
|
const [focusPin, setFocusPin] = useState(null);
|
||||||
const [narrow, setNarrow] = useState(() => window.innerWidth < MOBILE_BREAKPOINT);
|
const [narrow, setNarrow] = useState(() => window.innerWidth < MOBILE_BREAKPOINT);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -46,6 +41,55 @@ export function PdfWorkspace({
|
|||||||
// On a narrow screen the two are alternatives, not neighbours.
|
// On a narrow screen the two are alternatives, not neighbours.
|
||||||
const showPdf = !narrow || !showNotes;
|
const showPdf = !narrow || !showNotes;
|
||||||
|
|
||||||
|
/* ── linking the margin notes from the notes ──
|
||||||
|
*
|
||||||
|
* A remark in the margin is short by design. When there is more to say about
|
||||||
|
* that passage it belongs in the notes beside the document, and it has to be
|
||||||
|
* possible to say *which* passage — so the pins are offered to the editor's
|
||||||
|
* `@` menu exactly like notes and notebooks are, and following one scrolls
|
||||||
|
* the document to the spot and opens the remark. */
|
||||||
|
const pins = useMemo(() => allPinnedNotes(asPdfStrokeDoc(note?.drawingStrokes)), [note?.drawingStrokes]);
|
||||||
|
|
||||||
|
const pinLabel = useCallback((pin) => {
|
||||||
|
const text = (pin.text || "").trim().replace(/\s+/g, " ");
|
||||||
|
return text ? `S. ${pin.page}: ${text.slice(0, 40)}` : `S. ${pin.page}: Randnotiz`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const searchTargets = useCallback(
|
||||||
|
(query) => {
|
||||||
|
const q = (query || "").toLowerCase().trim();
|
||||||
|
const fromPins = pins
|
||||||
|
.filter((pin) => !q || pinLabel(pin).toLowerCase().includes(q))
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((pin) => ({ id: pin.id, label: pinLabel(pin), kind: "pin" }));
|
||||||
|
// Pins first: inside a document, the passage is usually what is meant.
|
||||||
|
return [...fromPins, ...(searchLinkTargets ? searchLinkTargets(query) : [])];
|
||||||
|
},
|
||||||
|
[pins, pinLabel, searchLinkTargets]
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolveLabel = useCallback(
|
||||||
|
(id) => {
|
||||||
|
const pin = pins.find((p) => p.id === id);
|
||||||
|
if (pin) return pinLabel(pin);
|
||||||
|
return resolveLinkLabel ? resolveLinkLabel(id) : null;
|
||||||
|
},
|
||||||
|
[pins, pinLabel, resolveLinkLabel]
|
||||||
|
);
|
||||||
|
|
||||||
|
const openTarget = useCallback(
|
||||||
|
(id, kind) => {
|
||||||
|
if (kind === "pin") {
|
||||||
|
setFocusPin(id);
|
||||||
|
// On a phone the document is not on screen while the notes are.
|
||||||
|
if (narrow) setShowNotes(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onOpenNote?.(id, kind);
|
||||||
|
},
|
||||||
|
[narrow, onOpenNote]
|
||||||
|
);
|
||||||
|
|
||||||
const notesToggle = (
|
const notesToggle = (
|
||||||
<button
|
<button
|
||||||
data-pdf-toggle-notes={showNotes ? "on" : "off"}
|
data-pdf-toggle-notes={showNotes ? "on" : "off"}
|
||||||
@@ -80,13 +124,8 @@ export function PdfWorkspace({
|
|||||||
dark={dark}
|
dark={dark}
|
||||||
onUpdate={onUpdate}
|
onUpdate={onUpdate}
|
||||||
notesToggle={notesToggle}
|
notesToggle={notesToggle}
|
||||||
pinLinks={{
|
focusPin={focusPin}
|
||||||
search: searchLinkTargets,
|
onFocusHandled={() => setFocusPin(null)}
|
||||||
labelOf: resolveLinkLabel,
|
|
||||||
onOpenNote,
|
|
||||||
onCreate: (title) => onCreateNoteForPin?.(note, title),
|
|
||||||
onOpenCreated: onOpenCreatedNote,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -104,9 +143,9 @@ export function PdfWorkspace({
|
|||||||
note={note}
|
note={note}
|
||||||
dark={dark}
|
dark={dark}
|
||||||
onUpdate={onUpdate}
|
onUpdate={onUpdate}
|
||||||
onOpenNote={onOpenNote}
|
onOpenNote={openTarget}
|
||||||
searchLinkTargets={searchLinkTargets}
|
searchLinkTargets={searchTargets}
|
||||||
resolveLinkLabel={resolveLinkLabel}
|
resolveLinkLabel={resolveLabel}
|
||||||
/* On a phone the viewer is not on screen at all, so the way back to
|
/* On a phone the viewer is not on screen at all, so the way back to
|
||||||
the document has to be here. */
|
the document has to be here. */
|
||||||
onBackToDocument={narrow ? () => setShowNotes(false) : null}
|
onBackToDocument={narrow ? () => setShowNotes(false) : null}
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
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,15 +27,6 @@ export interface PinnedNote {
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
text: string;
|
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 {
|
export interface PageStrokes {
|
||||||
|
|||||||
@@ -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
|
Failures now carry a typed `SyncError.code`; `.message` still holds the same
|
||||||
string, so the remaining JavaScript callers are unaffected.
|
string, so the remaining JavaScript callers are unaffected.
|
||||||
|
|
||||||
The 215 unit tests concentrate on the same places, and several exist because the
|
The 218 unit tests concentrate on the same places, and several exist because the
|
||||||
bug they cover actually happened:
|
bug they cover actually happened:
|
||||||
|
|
||||||
- a queue entry that fails must stay queued, with an attempt counter
|
- 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 |
|
| `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 |
|
| `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 |
|
| `drawing` | 7 | strokes survive the poller, and match across four viewport widths |
|
||||||
| `pdf` | 80 | import, page proportions, lazy loading, and reading with the network off |
|
| `pdf` | 82 | 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 |
|
| `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 |
|
| `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 |
|
| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
|
||||||
@@ -795,24 +795,22 @@ handling rather than assuming:
|
|||||||
notes was therefore stored as `NULL` — every note on it lost on the next save.
|
notes was therefore stored as `NULL` — every note on it lost on the next save.
|
||||||
Both now ask about strokes *and* pins.
|
Both now ask about strokes *and* pins.
|
||||||
|
|
||||||
**A remark can become a note of its own.** "Notiz anlegen" on a card makes one,
|
**The margin notes can be linked from the notes.** A remark in the margin is
|
||||||
titles it from what was written, and goes there; the pin keeps its id, so the
|
short by design; when there is more to say about that passage it belongs in the
|
||||||
passage leads to that note ever after. The note is created with a link back to
|
notes beside the document — and it has to be possible to say *which* passage. So
|
||||||
the document, so the connection reads in both directions and the document lists
|
the pins are offered to the editor's `@` menu exactly like notes and notebooks
|
||||||
it among its backlinks — a note born from a passage should not be a note with
|
are, labelled with their page, and following one scrolls the document to the spot
|
||||||
nothing on it saying which passage. An existing note can be linked instead, and a
|
and opens the remark. They are unhidden first: a link that led to something
|
||||||
link can be let go of without touching the note.
|
invisible would read as broken.
|
||||||
|
|
||||||
Two things had to be fixed for that to work at all, both of them older than the
|
A pin link is the same node with `kind: "pin"`, and is deliberately left out of
|
||||||
feature:
|
the links reported to the server. Its target is a marker inside this note's own
|
||||||
|
overlay, so no note has that id, and reporting it would fill the backlink table
|
||||||
|
with rows that match nothing.
|
||||||
|
|
||||||
- `openLinkTarget` finds a note's notebook by looking it up in the notes list,
|
**They can be hidden.** Reading the document as it was printed is a normal thing
|
||||||
and a note created in the same tick is not in the copy that callback closed
|
to want; the toolbar toggles the markers and the column together, and the page
|
||||||
over — so jumping to it went nowhere. Creating one now navigates by the
|
takes the width back while they are away.
|
||||||
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
|
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
|
numbered marker plus a small box with its text, placed on whichever side of the
|
||||||
|
|||||||
Reference in new issue
Block a user