Write notes beside a PDF
Stage six. What you write goes into the note's ordinary content, so it is encrypted, versioned, linkable and searchable like any other note — a PDF note is a note with a document attached, not a separate kind of thing. The panel is composed from the editor pieces rather than reusing NoteEditor, which carries a whole drawing mode with its own canvas, tools and undo stack. The PDF already has a drawing layer; a second one behind the panel would be two places to draw with one of them invisible. Side by side on a wide screen, two tabs on a narrow one, and either half can be hidden — the second of the two toggles asked for. Reading the original without one's own marks in the way is a normal thing to want. The suite caught a real defect while checking this. A PDF note has two sources of searchable text — what the reader typed and what the document says — written at different moments by different code, both calling setEntry. Whichever ran last was the only searchable half: typing a note dropped the document's text, and reopening the document dropped the note. The index now holds the two apart and replaces only the half it is given, with six unit tests covering each direction, plus the snippet falling back to the document so a hit inside a PDF still shows its sentence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b12b0d6142
commit
7f9f39790f
8 files changed
+409
-16
No files matched your search
@@ -2,7 +2,7 @@ import { I } from "../../ui/icons.jsx";
|
||||
import { BackButton } from "../../ui/BackButton.jsx";
|
||||
import { FONT_SERIF, FONT_UI } from "../../ui/theme";
|
||||
import { NoteEditor } from "./NoteEditor.jsx";
|
||||
import { PdfViewer } from "../pdf/PdfViewer.jsx";
|
||||
import { PdfWorkspace } from "../pdf/PdfWorkspace.jsx";
|
||||
|
||||
const toolbarButton = (T, color) => ({
|
||||
display: "flex",
|
||||
@@ -212,7 +212,14 @@ export function EditorPane({
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: "hidden", display: "flex", minHeight: 0 }}>
|
||||
{note.type === "pdf" ? (
|
||||
<PdfViewer note={note} dark={dark} onUpdate={onUpdate} />
|
||||
<PdfWorkspace
|
||||
note={note}
|
||||
dark={dark}
|
||||
onUpdate={onUpdate}
|
||||
onOpenNote={linkProps.openLinkTarget}
|
||||
searchLinkTargets={linkProps.searchLinkTargets}
|
||||
resolveLinkLabel={linkProps.resolveLinkLabel}
|
||||
/>
|
||||
) : (
|
||||
<NoteEditor
|
||||
key={`${note.id}:${restoreEpoch}`}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { useNoteEditor, EditorSurface } from "../../editor/useNoteEditor.jsx";
|
||||
import { EditorToolbar } from "../../editor/EditorToolbar.jsx";
|
||||
import { I } from "../../ui/icons.jsx";
|
||||
import { FONT_UI } from "../../ui/theme";
|
||||
|
||||
/**
|
||||
* Notes written beside a PDF.
|
||||
*
|
||||
* Composed from the editor pieces directly rather than reusing `NoteEditor`,
|
||||
* which carries a whole drawing mode with its own canvas, tools and undo stack.
|
||||
* The PDF has its own drawing layer; a second one behind this panel would be two
|
||||
* places to draw and one of them invisible.
|
||||
*
|
||||
* The text goes into the note's ordinary `content`, so it is encrypted, versioned
|
||||
* and searchable exactly like any other note — a PDF note is a note that happens
|
||||
* to have a document attached, not a separate kind of thing.
|
||||
*/
|
||||
export function PdfNotesPanel({ note, dark, onUpdate, onOpenNote, searchLinkTargets, resolveLinkLabel }) {
|
||||
const T = {
|
||||
bg: dark ? "#0d1117" : "#fff",
|
||||
bar: dark ? "#161b22" : "#f6f8fa",
|
||||
border: dark ? "#30363d" : "#d0d7de",
|
||||
text: dark ? "#e6edf3" : "#1f2328",
|
||||
muted: dark ? "#8b949e" : "#656d76",
|
||||
toolbar: dark ? "#161b22" : "#fff",
|
||||
accent: dark ? "#388bfd" : "#0969da",
|
||||
danger: dark ? "#f85149" : "#cf222e",
|
||||
};
|
||||
|
||||
const onUpdateRef = useRef(onUpdate);
|
||||
onUpdateRef.current = onUpdate;
|
||||
|
||||
const editor = useNoteEditor({
|
||||
noteId: note.id,
|
||||
content: note.content,
|
||||
editable: true,
|
||||
onChange: ({ content, links }) => onUpdateRef.current?.({ content, links }),
|
||||
searchLinkTargets,
|
||||
});
|
||||
|
||||
// A panel that opens focused would steal the first keystroke from the reader,
|
||||
// who is usually here to read rather than to write.
|
||||
useEffect(() => {
|
||||
if (editor && !editor.isDestroyed) editor.commands.blur();
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-pdf-notes
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
background: T.bg,
|
||||
fontFamily: FONT_UI,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "5px 8px",
|
||||
borderBottom: `1px solid ${T.border}`,
|
||||
background: T.bar,
|
||||
fontSize: 11,
|
||||
color: T.muted,
|
||||
flexShrink: 0,
|
||||
overflowX: "auto",
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 5, paddingRight: 4, flexShrink: 0 }}>
|
||||
{I.text} Notizen
|
||||
</span>
|
||||
{editor && <EditorToolbar editor={editor} T={T} icons={I} compact />}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto", minHeight: 0, padding: "10px 14px" }}>
|
||||
<EditorSurface
|
||||
editor={editor}
|
||||
dark={dark}
|
||||
minHeight={200}
|
||||
onOpenNote={onOpenNote}
|
||||
resolveLinkLabel={resolveLinkLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { PdfViewer } from "./PdfViewer.jsx";
|
||||
import { PdfNotesPanel } from "./PdfNotesPanel.jsx";
|
||||
import { I } from "../../ui/icons.jsx";
|
||||
import { FONT_UI } from "../../ui/theme";
|
||||
|
||||
const MOBILE_BREAKPOINT = 900;
|
||||
|
||||
/**
|
||||
* A PDF and the notes taken beside it.
|
||||
*
|
||||
* Side by side on a wide screen, one at a time on a narrow one — a document
|
||||
* squeezed into half of a phone is not readable, and neither is what you write
|
||||
* about it.
|
||||
*
|
||||
* Both halves can be hidden. That is not decoration: reading the original
|
||||
* without one's own marks in the way is a normal thing to want, and so is
|
||||
* writing without the document taking two thirds of the screen.
|
||||
*/
|
||||
export function PdfWorkspace({ note, dark, onUpdate, onOpenNote, searchLinkTargets, resolveLinkLabel }) {
|
||||
const [showNotes, setShowNotes] = useState(false);
|
||||
const [narrow, setNarrow] = useState(() => window.innerWidth < MOBILE_BREAKPOINT);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setNarrow(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, []);
|
||||
|
||||
const T = {
|
||||
bar: dark ? "#161b22" : "#fff",
|
||||
border: dark ? "#30363d" : "#d0d7de",
|
||||
muted: dark ? "#8b949e" : "#656d76",
|
||||
accent: dark ? "#388bfd" : "#0969da",
|
||||
};
|
||||
|
||||
// On a narrow screen the two are alternatives, not neighbours.
|
||||
const showPdf = !narrow || !showNotes;
|
||||
|
||||
const tab = (active, onClick, label, icon, attr) => (
|
||||
<button
|
||||
{...attr}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "4px 10px",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background: active ? T.accent + "22" : "transparent",
|
||||
color: active ? T.accent : T.muted,
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{icon} {label}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
padding: "4px 10px",
|
||||
borderBottom: `1px solid ${T.border}`,
|
||||
background: T.bar,
|
||||
fontFamily: FONT_UI,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{narrow
|
||||
? // Two tabs: whichever is showing is the one you are in.
|
||||
[
|
||||
tab(showPdf, () => setShowNotes(false), "Dokument", I.pdf, { "data-pdf-tab": "pdf" }),
|
||||
tab(showNotes, () => setShowNotes(true), "Notizen", I.text, { "data-pdf-tab": "notes" }),
|
||||
]
|
||||
: // Wide: the document is always there, the notes fold in and out.
|
||||
tab(showNotes, () => setShowNotes((v) => !v), "Notizen", I.text, {
|
||||
"data-pdf-toggle-notes": showNotes ? "on" : "off",
|
||||
title: showNotes ? "Notizen ausblenden" : "Notizen einblenden",
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, display: "flex", minHeight: 0, minWidth: 0 }}>
|
||||
{showPdf && (
|
||||
<div style={{ flex: 1, display: "flex", minWidth: 0, minHeight: 0 }}>
|
||||
<PdfViewer note={note} dark={dark} onUpdate={onUpdate} />
|
||||
</div>
|
||||
)}
|
||||
{showNotes && (
|
||||
<div
|
||||
style={{
|
||||
width: narrow ? "100%" : "min(42%, 520px)",
|
||||
borderLeft: narrow ? "none" : `1px solid ${T.border}`,
|
||||
display: "flex",
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<PdfNotesPanel
|
||||
note={note}
|
||||
dark={dark}
|
||||
onUpdate={onUpdate}
|
||||
onOpenNote={onOpenNote}
|
||||
searchLinkTargets={searchLinkTargets}
|
||||
resolveLinkLabel={resolveLinkLabel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -75,9 +75,14 @@ export async function extractAndCache(
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Puts a PDF's text into the search index under its note. */
|
||||
/**
|
||||
* Puts a PDF's text into the search index under its note.
|
||||
*
|
||||
* Kept apart from what the reader typed beside it, so re-reading the document
|
||||
* does not drop their notes and typing does not drop the document.
|
||||
*/
|
||||
export function indexPdfText(noteId: string, title: string, text: PdfText): void {
|
||||
searchIndex.setEntry(noteId, title, text.full);
|
||||
searchIndex.setAttachedText(noteId, title, text.full);
|
||||
}
|
||||
|
||||
/** Page numbers containing a term, so a hit can be jumped to. */
|
||||
|
||||
Reference in new issue
Block a user