nandi/gleanpublic Fork 0
d310c84
Commits
Clone
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

feat(article): highlight annotation quotes in bodyUnverified

Julien Robert committed 2026-08-10T00:06:21+02:00 Browse files
d310c84 parent: a9e0b8a
modified web/src/app.css +1 -0
@@ -330,6 +330,7 @@ body {
330330
331331 .annotation-highlight {
332332 background: var(--accent-ink);
333+ color: var(--fg);
333334 border-bottom: 2px solid var(--accent);
334335 padding: 0 0.1rem;
335336 cursor: pointer;
@@ -330,6 +330,7 @@ body {
330 330
331 .annotation-highlight {331 .annotation-highlight {
332 background: var(--accent-ink);332 background: var(--accent-ink);
333+ color: var(--fg);
333 border-bottom: 2px solid var(--accent);334 border-bottom: 2px solid var(--accent);
334 padding: 0 0.1rem;335 padding: 0 0.1rem;
335 cursor: pointer;336 cursor: pointer;
modified web/src/routes/articles/[id]/+page.svelte +227 -1
@@ -45,6 +45,209 @@
4545 let bodyEl = $state<HTMLElement | null>(null);
4646 let popoverEl = $state<HTMLElement | null>(null);
4747
48+ // Escape regex metacharacters in a literal string so it can be embedded
49+ // safely in a RegExp.
50+ function escapeRegExp(s: string): string {
51+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
52+ }
53+
54+ // Collapse runs of whitespace in the quote so it matches rendered text,
55+ // where HTML whitespace normalization has already happened.
56+ function normalizeQuote(q: string): string {
57+ return q.replace(/\s+/g, " ").trim();
58+ }
59+
60+ // Strip every existing highlight under `root`, unwrapping the <mark>
61+ // elements so the underlying text is restored verbatim.
62+ function clearHighlights(root: HTMLElement) {
63+ for (const m of Array.from(
64+ root.querySelectorAll("mark.annotation-highlight"),
65+ )) {
66+ const parent = m.parentNode;
67+ if (!parent) continue;
68+ while (m.firstChild) parent.insertBefore(m.firstChild, m);
69+ parent.removeChild(m);
70+ }
71+ root.normalize();
72+ }
73+
74+ // Wrap every occurrence of any annotation quote in `root` with
75+ // <mark class="annotation-highlight">. Handles quotes that span multiple
76+ // text nodes (e.g. when an inline element like <a> sits inside the quote)
77+ // by wrapping each affected text-node fragment in its own <mark>.
78+ // Idempotent when paired with clearHighlights.
79+ //
80+ // `quoteToId` maps each normalized quote to the annotation id it belongs
81+ // to, so cross-node fragments can still point at the right annotation card.
82+ function applyHighlights(
83+ root: HTMLElement,
84+ quoteToId: Map<string, number>,
85+ ) {
86+ const sorted = [...quoteToId.keys()]
87+ .filter((q) => q.trim().length > 0)
88+ .sort((a, b) => b.length - a.length);
89+ if (sorted.length === 0) return;
90+ const pattern = new RegExp(
91+ "(" + sorted.map(escapeRegExp).join("|") + ")",
92+ "gi",
93+ );
94+
95+ const walker = document.createTreeWalker(
96+ root,
97+ NodeFilter.SHOW_TEXT,
98+ {
99+ acceptNode(node) {
100+ const parent = node.parentNode as HTMLElement | null;
101+ if (!parent) return NodeFilter.FILTER_REJECT;
102+ const tag = parent.tagName;
103+ if (
104+ tag === "SCRIPT" ||
105+ tag === "STYLE" ||
106+ tag === "MARK" ||
107+ tag === "NOSCRIPT"
108+ )
109+ return NodeFilter.FILTER_REJECT;
110+ if (!node.nodeValue || !node.nodeValue.trim())
111+ return NodeFilter.FILTER_REJECT;
112+ return NodeFilter.FILTER_ACCEPT;
113+ },
114+ },
115+ );
116+
117+ // Collect text nodes and build a flat normalized string with a map
118+ // back to (node, localOffset) for every character.
119+ const nodes: Text[] = [];
120+ let flat = "";
121+ // Each entry maps a flat-string index to the source text node and the
122+ // offset within that node's value. Built incrementally so we can map
123+ // any regex match back to its DOM position even when normalization
124+ // collapses whitespace runs.
125+ const indexMap: { node: Text; offset: number }[] = [];
126+ let n: Node | null;
127+ while ((n = walker.nextNode())) {
128+ const node = n as Text;
129+ const value = node.nodeValue ?? "";
130+ for (let i = 0; i < value.length; i++) {
131+ const ch = value[i];
132+ // Collapse any run of whitespace to a single space, mirroring
133+ // how normalizeQuote prepares the quotes.
134+ const isWs = /\s/.test(ch);
135+ const prevIsWs = flat.length > 0 && /\s/.test(flat[flat.length - 1]);
136+ if (isWs && prevIsWs) continue;
137+ flat += isWs ? " " : ch;
138+ indexMap.push({ node, offset: i });
139+ }
140+ nodes.push(node);
141+ }
142+ flat = flat.trim();
143+ if (flat.length === 0) return;
144+
145+ // Find match ranges in the flat string and remember which quote each
146+ // match belongs to (by normalized text), so cross-node fragments can
147+ // tag themselves with the right annotation id.
148+ const matches: { start: number; end: number; quote: string }[] = [];
149+ let m: RegExpExecArray | null;
150+ pattern.lastIndex = 0;
151+ while ((m = pattern.exec(flat)) !== null) {
152+ if (m[0].length === 0) {
153+ pattern.lastIndex++;
154+ continue;
155+ }
156+ matches.push({
157+ start: m.index,
158+ end: m.index + m[0].length,
159+ quote: m[0],
160+ });
161+ }
162+ if (matches.length === 0) return;
163+
164+ // Group matched text nodes so we can wrap them. For each text node,
165+ // compute the list of [localStart, localEnd, quote) intervals that fall
166+ // inside any match.
167+ // Build per-node intervals by walking the indexMap over match ranges.
168+ const byNode = new Map<
169+ Text,
170+ Array<[number, number, string]>
171+ >();
172+ for (const { start, end, quote } of matches) {
173+ for (let i = start; i < end; i++) {
174+ const entry = indexMap[i];
175+ if (!entry) continue;
176+ const list = byNode.get(entry.node) ?? [];
177+ const last = list[list.length - 1];
178+ if (last && last[1] === entry.offset && last[2] === quote)
179+ last[1] = entry.offset + 1;
180+ else
181+ list.push([entry.offset, entry.offset + 1, quote]);
182+ byNode.set(entry.node, list);
183+ }
184+ }
185+ if (byNode.size === 0) return;
186+
187+ // Wrap intervals in each affected node. Process nodes in document order
188+ // to keep DOM mutations predictable.
189+ for (const node of nodes) {
190+ const intervals = byNode.get(node);
191+ if (!intervals || intervals.length === 0) continue;
192+ const parent = node.parentNode;
193+ if (!parent) continue;
194+ const value = node.nodeValue ?? "";
195+
196+ const frag = document.createDocumentFragment();
197+ let cursor = 0;
198+ for (const [s, e, quote] of intervals) {
199+ if (s > cursor)
200+ frag.appendChild(
201+ document.createTextNode(value.slice(cursor, s)),
202+ );
203+ const mark = document.createElement("mark");
204+ mark.className = "annotation-highlight";
205+ const id = quoteToId.get(normalizeQuote(quote));
206+ if (id !== undefined)
207+ mark.dataset.annotationId = String(id);
208+ mark.textContent = value.slice(s, e);
209+ frag.appendChild(mark);
210+ cursor = e;
211+ }
212+ if (cursor < value.length)
213+ frag.appendChild(
214+ document.createTextNode(value.slice(cursor)),
215+ );
216+ parent.replaceChild(frag, node);
217+ }
218+ }
219+
220+ // Svelte action: applies highlights to the rendered body and re-applies
221+ // them whenever the quote list or the rendered content changes. Reads
222+ // the reactive values directly inside $effect so changes are tracked.
223+ function highlightAction(node: HTMLElement): void {
224+ $effect(() => {
225+ const quoteToId = annotationQuoteMap;
226+ const contentKey = showContent;
227+ // Touch contentKey so this effect re-runs when the article body
228+ // is replaced (e.g. "Fetch full content").
229+ void contentKey;
230+ // Children from {@html} are guaranteed to be present when an
231+ // action runs on mount; subsequent updates are also flushed
232+ // before effects fire.
233+ applyHighlights(node, quoteToId);
234+ // Strip our marks on cleanup so the next run starts clean and so
235+ // nothing leaks if the element is unmounted.
236+ return () => clearHighlights(node);
237+ });
238+ }
239+
240+ // Map of normalized quote -> annotation id, so each highlight (including
241+ // cross-node fragments) can tag itself with the matching annotation.
242+ const annotationQuoteMap = $derived.by<Map<string, number>>(() => {
243+ const map = new Map<string, number>();
244+ for (const a of annotations) {
245+ if (!a.quote || !a.quote.trim()) continue;
246+ map.set(normalizeQuote(a.quote), a.id);
247+ }
248+ return map;
249+ });
250+
48251 function goBack() {
49252 history.back();
50253 }
@@ -58,6 +261,23 @@
58261 annotations = data.annotations;
59262 });
60263
264+ // Scroll to the annotation card matching a clicked highlight.
265+ function onBodyClick(e: MouseEvent) {
266+ const target = e.target as HTMLElement | null;
267+ const mark = target?.closest("mark.annotation-highlight");
268+ if (!(mark instanceof HTMLElement)) return;
269+ const idStr = mark.dataset.annotationId;
270+ if (!idStr) return;
271+ const card = document.getElementById(`annotation-${idStr}`);
272+ card?.scrollIntoView({ behavior: "smooth", block: "center" });
273+ card?.classList.add("ring-2", "ring-[var(--accent)]");
274+ setTimeout(
275+ () =>
276+ card?.classList.remove("ring-2", "ring-[var(--accent)]"),
277+ 1600,
278+ );
279+ }
280+
61281 // Tracks which article id we've already auto-marked read, so the effect
62282 // fires once per navigation instead of fighting toggleRead's updates.
63283 let markedReadId = $state<number | null>(null);
@@ -361,7 +581,13 @@
361581 />{/if}
362582
363583 {#if showContent}
364- <div bind:this={bodyEl} class="article-body">
584+ <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
585+ <div
586+ bind:this={bodyEl}
587+ class="article-body"
588+ onclick={onBodyClick}
589+ use:highlightAction
590+ >
365591 <!-- eslint-disable-next-line svelte/no-at-html-tags -->
366592 {@html showContent}
367593 </div>
@@ -45,6 +45,209 @@
45 let bodyEl = $state<HTMLElement | null>(null);45 let bodyEl = $state<HTMLElement | null>(null);
46 let popoverEl = $state<HTMLElement | null>(null);46 let popoverEl = $state<HTMLElement | null>(null);
47 47
48+ // Escape regex metacharacters in a literal string so it can be embedded
49+ // safely in a RegExp.
50+ function escapeRegExp(s: string): string {
51+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
52+ }
53+
54+ // Collapse runs of whitespace in the quote so it matches rendered text,
55+ // where HTML whitespace normalization has already happened.
56+ function normalizeQuote(q: string): string {
57+ return q.replace(/\s+/g, " ").trim();
58+ }
59+
60+ // Strip every existing highlight under `root`, unwrapping the <mark>
61+ // elements so the underlying text is restored verbatim.
62+ function clearHighlights(root: HTMLElement) {
63+ for (const m of Array.from(
64+ root.querySelectorAll("mark.annotation-highlight"),
65+ )) {
66+ const parent = m.parentNode;
67+ if (!parent) continue;
68+ while (m.firstChild) parent.insertBefore(m.firstChild, m);
69+ parent.removeChild(m);
70+ }
71+ root.normalize();
72+ }
73+
74+ // Wrap every occurrence of any annotation quote in `root` with
75+ // <mark class="annotation-highlight">. Handles quotes that span multiple
76+ // text nodes (e.g. when an inline element like <a> sits inside the quote)
77+ // by wrapping each affected text-node fragment in its own <mark>.
78+ // Idempotent when paired with clearHighlights.
79+ //
80+ // `quoteToId` maps each normalized quote to the annotation id it belongs
81+ // to, so cross-node fragments can still point at the right annotation card.
82+ function applyHighlights(
83+ root: HTMLElement,
84+ quoteToId: Map<string, number>,
85+ ) {
86+ const sorted = [...quoteToId.keys()]
87+ .filter((q) => q.trim().length > 0)
88+ .sort((a, b) => b.length - a.length);
89+ if (sorted.length === 0) return;
90+ const pattern = new RegExp(
91+ "(" + sorted.map(escapeRegExp).join("|") + ")",
92+ "gi",
93+ );
94+
95+ const walker = document.createTreeWalker(
96+ root,
97+ NodeFilter.SHOW_TEXT,
98+ {
99+ acceptNode(node) {
100+ const parent = node.parentNode as HTMLElement | null;
101+ if (!parent) return NodeFilter.FILTER_REJECT;
102+ const tag = parent.tagName;
103+ if (
104+ tag === "SCRIPT" ||
105+ tag === "STYLE" ||
106+ tag === "MARK" ||
107+ tag === "NOSCRIPT"
108+ )
109+ return NodeFilter.FILTER_REJECT;
110+ if (!node.nodeValue || !node.nodeValue.trim())
111+ return NodeFilter.FILTER_REJECT;
112+ return NodeFilter.FILTER_ACCEPT;
113+ },
114+ },
115+ );
116+
117+ // Collect text nodes and build a flat normalized string with a map
118+ // back to (node, localOffset) for every character.
119+ const nodes: Text[] = [];
120+ let flat = "";
121+ // Each entry maps a flat-string index to the source text node and the
122+ // offset within that node's value. Built incrementally so we can map
123+ // any regex match back to its DOM position even when normalization
124+ // collapses whitespace runs.
125+ const indexMap: { node: Text; offset: number }[] = [];
126+ let n: Node | null;
127+ while ((n = walker.nextNode())) {
128+ const node = n as Text;
129+ const value = node.nodeValue ?? "";
130+ for (let i = 0; i < value.length; i++) {
131+ const ch = value[i];
132+ // Collapse any run of whitespace to a single space, mirroring
133+ // how normalizeQuote prepares the quotes.
134+ const isWs = /\s/.test(ch);
135+ const prevIsWs = flat.length > 0 && /\s/.test(flat[flat.length - 1]);
136+ if (isWs && prevIsWs) continue;
137+ flat += isWs ? " " : ch;
138+ indexMap.push({ node, offset: i });
139+ }
140+ nodes.push(node);
141+ }
142+ flat = flat.trim();
143+ if (flat.length === 0) return;
144+
145+ // Find match ranges in the flat string and remember which quote each
146+ // match belongs to (by normalized text), so cross-node fragments can
147+ // tag themselves with the right annotation id.
148+ const matches: { start: number; end: number; quote: string }[] = [];
149+ let m: RegExpExecArray | null;
150+ pattern.lastIndex = 0;
151+ while ((m = pattern.exec(flat)) !== null) {
152+ if (m[0].length === 0) {
153+ pattern.lastIndex++;
154+ continue;
155+ }
156+ matches.push({
157+ start: m.index,
158+ end: m.index + m[0].length,
159+ quote: m[0],
160+ });
161+ }
162+ if (matches.length === 0) return;
163+
164+ // Group matched text nodes so we can wrap them. For each text node,
165+ // compute the list of [localStart, localEnd, quote) intervals that fall
166+ // inside any match.
167+ // Build per-node intervals by walking the indexMap over match ranges.
168+ const byNode = new Map<
169+ Text,
170+ Array<[number, number, string]>
171+ >();
172+ for (const { start, end, quote } of matches) {
173+ for (let i = start; i < end; i++) {
174+ const entry = indexMap[i];
175+ if (!entry) continue;
176+ const list = byNode.get(entry.node) ?? [];
177+ const last = list[list.length - 1];
178+ if (last && last[1] === entry.offset && last[2] === quote)
179+ last[1] = entry.offset + 1;
180+ else
181+ list.push([entry.offset, entry.offset + 1, quote]);
182+ byNode.set(entry.node, list);
183+ }
184+ }
185+ if (byNode.size === 0) return;
186+
187+ // Wrap intervals in each affected node. Process nodes in document order
188+ // to keep DOM mutations predictable.
189+ for (const node of nodes) {
190+ const intervals = byNode.get(node);
191+ if (!intervals || intervals.length === 0) continue;
192+ const parent = node.parentNode;
193+ if (!parent) continue;
194+ const value = node.nodeValue ?? "";
195+
196+ const frag = document.createDocumentFragment();
197+ let cursor = 0;
198+ for (const [s, e, quote] of intervals) {
199+ if (s > cursor)
200+ frag.appendChild(
201+ document.createTextNode(value.slice(cursor, s)),
202+ );
203+ const mark = document.createElement("mark");
204+ mark.className = "annotation-highlight";
205+ const id = quoteToId.get(normalizeQuote(quote));
206+ if (id !== undefined)
207+ mark.dataset.annotationId = String(id);
208+ mark.textContent = value.slice(s, e);
209+ frag.appendChild(mark);
210+ cursor = e;
211+ }
212+ if (cursor < value.length)
213+ frag.appendChild(
214+ document.createTextNode(value.slice(cursor)),
215+ );
216+ parent.replaceChild(frag, node);
217+ }
218+ }
219+
220+ // Svelte action: applies highlights to the rendered body and re-applies
221+ // them whenever the quote list or the rendered content changes. Reads
222+ // the reactive values directly inside $effect so changes are tracked.
223+ function highlightAction(node: HTMLElement): void {
224+ $effect(() => {
225+ const quoteToId = annotationQuoteMap;
226+ const contentKey = showContent;
227+ // Touch contentKey so this effect re-runs when the article body
228+ // is replaced (e.g. "Fetch full content").
229+ void contentKey;
230+ // Children from {@html} are guaranteed to be present when an
231+ // action runs on mount; subsequent updates are also flushed
232+ // before effects fire.
233+ applyHighlights(node, quoteToId);
234+ // Strip our marks on cleanup so the next run starts clean and so
235+ // nothing leaks if the element is unmounted.
236+ return () => clearHighlights(node);
237+ });
238+ }
239+
240+ // Map of normalized quote -> annotation id, so each highlight (including
241+ // cross-node fragments) can tag itself with the matching annotation.
242+ const annotationQuoteMap = $derived.by<Map<string, number>>(() => {
243+ const map = new Map<string, number>();
244+ for (const a of annotations) {
245+ if (!a.quote || !a.quote.trim()) continue;
246+ map.set(normalizeQuote(a.quote), a.id);
247+ }
248+ return map;
249+ });
250+
48 function goBack() {251 function goBack() {
49 history.back();252 history.back();
50 }253 }
@@ -58,6 +261,23 @@
58 annotations = data.annotations;261 annotations = data.annotations;
59 });262 });
60 263
264+ // Scroll to the annotation card matching a clicked highlight.
265+ function onBodyClick(e: MouseEvent) {
266+ const target = e.target as HTMLElement | null;
267+ const mark = target?.closest("mark.annotation-highlight");
268+ if (!(mark instanceof HTMLElement)) return;
269+ const idStr = mark.dataset.annotationId;
270+ if (!idStr) return;
271+ const card = document.getElementById(`annotation-${idStr}`);
272+ card?.scrollIntoView({ behavior: "smooth", block: "center" });
273+ card?.classList.add("ring-2", "ring-[var(--accent)]");
274+ setTimeout(
275+ () =>
276+ card?.classList.remove("ring-2", "ring-[var(--accent)]"),
277+ 1600,
278+ );
279+ }
280+
61 // Tracks which article id we've already auto-marked read, so the effect281 // Tracks which article id we've already auto-marked read, so the effect
62 // fires once per navigation instead of fighting toggleRead's updates.282 // fires once per navigation instead of fighting toggleRead's updates.
63 let markedReadId = $state<number | null>(null);283 let markedReadId = $state<number | null>(null);
@@ -361,7 +581,13 @@
361 />{/if}581 />{/if}
362 582
363 {#if showContent}583 {#if showContent}
364- <div bind:this={bodyEl} class="article-body">584+ <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
585+ <div
586+ bind:this={bodyEl}
587+ class="article-body"
588+ onclick={onBodyClick}
589+ use:highlightAction
590+ >
365 <!-- eslint-disable-next-line svelte/no-at-html-tags -->591 <!-- eslint-disable-next-line svelte/no-at-html-tags -->
366 {@html showContent}592 {@html showContent}
367 </div>593 </div>