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
+40
@@ -236,6 +236,46 @@ try {
|
|||||||
await p.waitForTimeout(9000);
|
await p.waitForTimeout(9000);
|
||||||
check((await inkOnPage(1)) > 100, "and is still there after the sync poller has run");
|
check((await inkOnPage(1)) > 100, "and is still there after the sync poller has run");
|
||||||
|
|
||||||
|
/* ── notes beside the document ── */
|
||||||
|
check((await p.locator("[data-pdf-notes]").count()) === 0, "the notes panel starts folded away");
|
||||||
|
await p.locator("[data-pdf-toggle-notes]").click();
|
||||||
|
await p.waitForTimeout(1200);
|
||||||
|
check((await p.locator("[data-pdf-notes]").count()) === 1, "it opens beside the document");
|
||||||
|
check((await p.locator("[data-pdf-page]").count()) > 0, "and the document is still there next to it");
|
||||||
|
|
||||||
|
const noteText = "Wichtig auf Seite eins: Randbemerkung.";
|
||||||
|
await p.locator("[data-pdf-notes] .ProseMirror").click();
|
||||||
|
await p.keyboard.type(noteText);
|
||||||
|
await p.waitForTimeout(2200);
|
||||||
|
|
||||||
|
await p.locator("[data-pdf-toggle-notes]").click();
|
||||||
|
await p.waitForTimeout(600);
|
||||||
|
check((await p.locator("[data-pdf-notes]").count()) === 0, "and folds away again without losing anything");
|
||||||
|
|
||||||
|
/* Written notes belong to the note, so they survive like any other text. */
|
||||||
|
await p.reload({ waitUntil: "networkidle" });
|
||||||
|
await p.waitForTimeout(2500);
|
||||||
|
await p.locator("button").filter({ hasText: "PDF Buch" }).first().click();
|
||||||
|
await p.waitForTimeout(1200);
|
||||||
|
await p.locator("[data-note-row]").first().click();
|
||||||
|
await p.waitForTimeout(4000);
|
||||||
|
await p.locator("[data-pdf-toggle-notes]").click();
|
||||||
|
await p.waitForTimeout(1500);
|
||||||
|
check(
|
||||||
|
(await p.locator("[data-pdf-notes] .ProseMirror").innerText()).includes("Randbemerkung"),
|
||||||
|
"the notes survived a reload"
|
||||||
|
);
|
||||||
|
await p.screenshot({ path: "/out/pdf-with-notes.png" });
|
||||||
|
|
||||||
|
/* And they are searchable, alongside the document's own text. */
|
||||||
|
await p.getByPlaceholder("Search notes...").fill("Randbemerkung");
|
||||||
|
await p.waitForTimeout(1500);
|
||||||
|
check((await p.locator("[data-note-row]").count()) === 1, "notes written beside a PDF are searchable");
|
||||||
|
await p.getByPlaceholder("Search notes...").fill("");
|
||||||
|
await p.waitForTimeout(1000);
|
||||||
|
await p.locator("[data-note-row]").first().click();
|
||||||
|
await p.waitForTimeout(3000);
|
||||||
|
|
||||||
/* ── it stays readable offline ── */
|
/* ── it stays readable offline ── */
|
||||||
// A reload lands on the dashboard, which has no note list; the notebook has
|
// A reload lands on the dashboard, which has no note list; the notebook has
|
||||||
// to be opened first before a row exists to click.
|
// to be opened first before a row exists to click.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { I } from "../../ui/icons.jsx";
|
|||||||
import { BackButton } from "../../ui/BackButton.jsx";
|
import { BackButton } from "../../ui/BackButton.jsx";
|
||||||
import { FONT_SERIF, FONT_UI } from "../../ui/theme";
|
import { FONT_SERIF, FONT_UI } from "../../ui/theme";
|
||||||
import { NoteEditor } from "./NoteEditor.jsx";
|
import { NoteEditor } from "./NoteEditor.jsx";
|
||||||
import { PdfViewer } from "../pdf/PdfViewer.jsx";
|
import { PdfWorkspace } from "../pdf/PdfWorkspace.jsx";
|
||||||
|
|
||||||
const toolbarButton = (T, color) => ({
|
const toolbarButton = (T, color) => ({
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -212,7 +212,14 @@ export function EditorPane({
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, overflow: "hidden", display: "flex", minHeight: 0 }}>
|
<div style={{ flex: 1, overflow: "hidden", display: "flex", minHeight: 0 }}>
|
||||||
{note.type === "pdf" ? (
|
{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
|
<NoteEditor
|
||||||
key={`${note.id}:${restoreEpoch}`}
|
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;
|
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 {
|
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. */
|
/** Page numbers containing a term, so a hit can be jumped to. */
|
||||||
|
|||||||
@@ -74,6 +74,58 @@ describe("search", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("attached document text", () => {
|
||||||
|
/* A PDF note has two sources of text, written at different moments by
|
||||||
|
* different code. Before these, whichever ran last was the only searchable
|
||||||
|
* half — typing a note dropped the document, re-reading it dropped the note. */
|
||||||
|
it("finds a note by a word from its attached document", () => {
|
||||||
|
search.setEntry("p1", "Handbuch", body("Meine eigene Notiz."));
|
||||||
|
search.setAttachedText("p1", "Handbuch", "Der Motor hat eine Zündkerze.");
|
||||||
|
expect(search.search("Zündkerze")?.map((h) => h.id)).toEqual(["p1"]);
|
||||||
|
search.removeEntry("p1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("and by a word the user typed beside it", () => {
|
||||||
|
search.setEntry("p1", "Handbuch", body("Meine eigene Notiz."));
|
||||||
|
search.setAttachedText("p1", "Handbuch", "Der Motor hat eine Zündkerze.");
|
||||||
|
expect(search.search("eigene")?.map((h) => h.id)).toEqual(["p1"]);
|
||||||
|
search.removeEntry("p1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the document text when the note is edited afterwards", () => {
|
||||||
|
search.setAttachedText("p1", "Handbuch", "Der Motor hat eine Zündkerze.");
|
||||||
|
search.setEntry("p1", "Handbuch", body("Ganz neuer Notiztext."));
|
||||||
|
expect(search.search("Zündkerze")?.map((h) => h.id)).toEqual(["p1"]);
|
||||||
|
expect(search.search("neuer")?.map((h) => h.id)).toEqual(["p1"]);
|
||||||
|
search.removeEntry("p1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the note when the document is re-read afterwards", () => {
|
||||||
|
search.setEntry("p1", "Handbuch", body("Meine eigene Notiz."));
|
||||||
|
search.setAttachedText("p1", "Handbuch", "Der Motor hat eine Zündkerze.");
|
||||||
|
search.setAttachedText("p1", "Handbuch", "Der Motor hat eine Zündkerze und einen Riemen.");
|
||||||
|
expect(search.search("eigene")?.map((h) => h.id)).toEqual(["p1"]);
|
||||||
|
expect(search.search("Riemen")?.map((h) => h.id)).toEqual(["p1"]);
|
||||||
|
search.removeEntry("p1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the sentence a document hit was found in, not an unrelated excerpt", () => {
|
||||||
|
search.setEntry("p1", "Handbuch", body("Kurze Notiz."));
|
||||||
|
search.setAttachedText("p1", "Handbuch", "Vorher. Der Motor hat eine Zündkerze. Nachher.");
|
||||||
|
const hit = search.search("Zündkerze")?.[0];
|
||||||
|
expect(hit?.snippet).toMatch(/Zündkerze/);
|
||||||
|
search.removeEntry("p1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the attached text when it is set to empty", () => {
|
||||||
|
search.setEntry("p1", "Handbuch", body("Notiz."));
|
||||||
|
search.setAttachedText("p1", "Handbuch", "Zündkerze");
|
||||||
|
search.setEntry("p1", "Handbuch", body("Notiz."), "");
|
||||||
|
expect(search.search("Zündkerze")).toEqual([]);
|
||||||
|
search.removeEntry("p1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("contentToText", () => {
|
describe("contentToText", () => {
|
||||||
it("reads a v2 document", () => {
|
it("reads a v2 document", () => {
|
||||||
expect(search.contentToText(body("hello world"))).toBe("hello world");
|
expect(search.contentToText(body("hello world"))).toBe("hello world");
|
||||||
|
|||||||
+64
-10
@@ -21,6 +21,8 @@ interface IndexEntry {
|
|||||||
text: string;
|
text: string;
|
||||||
/** Original body text, for building the snippet shown to the user. */
|
/** Original body text, for building the snippet shown to the user. */
|
||||||
raw: string;
|
raw: string;
|
||||||
|
/** Text from an attached document, kept apart from what the user typed. */
|
||||||
|
attachedRaw: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _index = new Map<string, IndexEntry>();
|
const _index = new Map<string, IndexEntry>();
|
||||||
@@ -55,9 +57,45 @@ export function contentToText(raw: string | null | undefined): string {
|
|||||||
return `${el.textContent || ""} ${tasks}`.replace(/\s+/g, " ").trim();
|
return `${el.textContent || ""} ${tasks}`.replace(/\s+/g, " ").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setEntry(id: string, title: string, content?: string | null): void {
|
/**
|
||||||
|
* Adds or replaces a note in the index.
|
||||||
|
*
|
||||||
|
* A PDF note has two sources of text — what the reader wrote and what the
|
||||||
|
* document says — updated at different moments by different code. `attached`
|
||||||
|
* is only replaced when given, so typing a note does not drop the document's
|
||||||
|
* text and re-reading the document does not drop the note. Passing one and
|
||||||
|
* forgetting the other used to leave whichever ran last as the only searchable
|
||||||
|
* half.
|
||||||
|
*/
|
||||||
|
export function setEntry(
|
||||||
|
id: string,
|
||||||
|
title: string,
|
||||||
|
content?: string | null,
|
||||||
|
attached?: string | null
|
||||||
|
): void {
|
||||||
const raw = contentToText(content);
|
const raw = contentToText(content);
|
||||||
_index.set(id, { title: normalize(title), text: normalize(raw), raw });
|
const attachedRaw = attached === undefined ? (_index.get(id)?.attachedRaw ?? "") : (attached ?? "");
|
||||||
|
_index.set(id, {
|
||||||
|
title: normalize(title),
|
||||||
|
text: normalize(`${raw} ${attachedRaw}`),
|
||||||
|
raw,
|
||||||
|
attachedRaw,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces only the attached document's text, leaving what the user wrote. */
|
||||||
|
export function setAttachedText(id: string, title: string, attached: string): void {
|
||||||
|
const existing = _index.get(id);
|
||||||
|
setEntry(id, title, null, attached);
|
||||||
|
// setEntry with null content would blank the written half; restore it.
|
||||||
|
if (existing) {
|
||||||
|
_index.set(id, {
|
||||||
|
title: normalize(title),
|
||||||
|
text: normalize(`${existing.raw} ${attached}`),
|
||||||
|
raw: existing.raw,
|
||||||
|
attachedRaw: attached,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeEntry(id: string): void {
|
export function removeEntry(id: string): void {
|
||||||
@@ -115,16 +153,32 @@ export function search(query: string, { limit = 200 }: { limit?: number } = {}):
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A short excerpt around the first matching term, for the results list. */
|
/**
|
||||||
|
* A short excerpt around the first matching term, for the results list.
|
||||||
|
*
|
||||||
|
* Looks in what the user wrote first, then in an attached document: a hit inside
|
||||||
|
* a PDF should still show the sentence it was found in, rather than an excerpt
|
||||||
|
* of unrelated notes or nothing at all.
|
||||||
|
*/
|
||||||
function snippetFor(entry: IndexEntry, terms: string[], radius = 60): string {
|
function snippetFor(entry: IndexEntry, terms: string[], radius = 60): string {
|
||||||
const text = entry.raw;
|
const firstHit = (source: string) => {
|
||||||
if (!text) return "";
|
if (!source) return -1;
|
||||||
const haystack = normalize(text);
|
const haystack = normalize(source);
|
||||||
let at = -1;
|
let at = -1;
|
||||||
for (const term of terms) {
|
for (const term of terms) {
|
||||||
const i = haystack.indexOf(term);
|
const i = haystack.indexOf(term);
|
||||||
if (i !== -1 && (at === -1 || i < at)) at = i;
|
if (i !== -1 && (at === -1 || i < at)) at = i;
|
||||||
|
}
|
||||||
|
return at;
|
||||||
|
};
|
||||||
|
|
||||||
|
let text = entry.raw;
|
||||||
|
let at = firstHit(text);
|
||||||
|
if (at === -1 && entry.attachedRaw) {
|
||||||
|
text = entry.attachedRaw;
|
||||||
|
at = firstHit(text);
|
||||||
}
|
}
|
||||||
|
if (!text) return "";
|
||||||
if (at === -1) return text.slice(0, radius * 2).trim();
|
if (at === -1) return text.slice(0, radius * 2).trim();
|
||||||
|
|
||||||
const start = Math.max(0, at - radius);
|
const start = Math.max(0, at - radius);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ A self-hosted note-taking application with rich text editing, freehand drawing,
|
|||||||
- **Undo/Redo** — Full history stack for drawing operations
|
- **Undo/Redo** — Full history stack for drawing operations
|
||||||
- **Notebooks & Folders** — Create notebooks with custom gradient covers, organize notes by folder
|
- **Notebooks & Folders** — Create notebooks with custom gradient covers, organize notes by folder
|
||||||
- **Star/Favorite** — Quick access to important notes
|
- **Star/Favorite** — Quick access to important notes
|
||||||
- **PDF Reading** — Import a PDF, read it, select its text, draw on it; the file is cached so it stays readable offline
|
- **PDF Reading** — Import a PDF, read it, select its text, draw on it and write notes beside it; the file is cached so it stays readable offline
|
||||||
- **Export** — Any note, notebook or the whole account as Markdown or PDF, entirely in the browser
|
- **Export** — Any note, notebook or the whole account as Markdown or PDF, entirely in the browser
|
||||||
- **Dark & Light Mode** — Full theme support with refined color schemes
|
- **Dark & Light Mode** — Full theme support with refined color schemes
|
||||||
|
|
||||||
@@ -330,7 +330,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` | 26 | import, page proportions, lazy loading, and reading with the network off |
|
| `pdf` | 32 | import, page proportions, lazy loading, and reading with the network off |
|
||||||
| `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 |
|
||||||
|
|
||||||
@@ -442,6 +442,31 @@ storage is content-addressed, a cached copy can never go stale.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Notes beside a PDF
|
||||||
|
|
||||||
|
A PDF note is a note that happens to have a document attached, not a separate
|
||||||
|
kind of thing: what you write beside it goes into the note's ordinary `content`,
|
||||||
|
so it is encrypted, versioned, linkable and searchable like any other note.
|
||||||
|
|
||||||
|
The panel is composed from the editor pieces directly rather than reusing
|
||||||
|
`NoteEditor`, which carries a whole drawing mode with its own canvas and undo
|
||||||
|
stack — the PDF has its own drawing layer, and a second one hidden 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. Both halves can be
|
||||||
|
hidden: 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.
|
||||||
|
|
||||||
|
**One trap this uncovered.** 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 called `setEntry`, so whichever ran last was the only
|
||||||
|
half you could search: typing a note dropped the document's text, and re-reading
|
||||||
|
the document dropped the note. The index now keeps them apart and only replaces
|
||||||
|
the half it was given.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Selecting and searching PDF text
|
## Selecting and searching PDF text
|
||||||
|
|
||||||
pdf.js draws pages as pictures, so selecting means laying transparent spans over
|
pdf.js draws pages as pictures, so selecting means laying transparent spans over
|
||||||
|
|||||||
Reference in new issue
Block a user