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
+64
-10
@@ -21,6 +21,8 @@ interface IndexEntry {
|
||||
text: string;
|
||||
/** Original body text, for building the snippet shown to the user. */
|
||||
raw: string;
|
||||
/** Text from an attached document, kept apart from what the user typed. */
|
||||
attachedRaw: string;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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);
|
||||
_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 {
|
||||
@@ -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 {
|
||||
const text = entry.raw;
|
||||
if (!text) return "";
|
||||
const haystack = normalize(text);
|
||||
let at = -1;
|
||||
for (const term of terms) {
|
||||
const i = haystack.indexOf(term);
|
||||
if (i !== -1 && (at === -1 || i < at)) at = i;
|
||||
const firstHit = (source: string) => {
|
||||
if (!source) return -1;
|
||||
const haystack = normalize(source);
|
||||
let at = -1;
|
||||
for (const term of terms) {
|
||||
const i = haystack.indexOf(term);
|
||||
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();
|
||||
|
||||
const start = Math.max(0, at - radius);
|
||||
|
||||
Reference in new issue
Block a user