feat(article): highlight annotation quotes in bodyUnverified
d310c84 parent: a9e0b8a modified
web/src/app.css +1 -0 | @@ -330,6 +330,7 @@ body { | ||
| 330 | 330 | |
| 331 | 331 | .annotation-highlight { |
| 332 | 332 | background: var(--accent-ink); |
| 333 | + color: var(--fg); | |
| 333 | 334 | border-bottom: 2px solid var(--accent); |
| 334 | 335 | padding: 0 0.1rem; |
| 335 | 336 | 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 @@ | ||
| 45 | 45 | let bodyEl = $state<HTMLElement | null>(null); |
| 46 | 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 | 251 | function goBack() { |
| 49 | 252 | history.back(); |
| 50 | 253 | } |
| @@ -58,6 +261,23 @@ | ||
| 58 | 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 | 281 | // Tracks which article id we've already auto-marked read, so the effect |
| 62 | 282 | // fires once per navigation instead of fighting toggleRead's updates. |
| 63 | 283 | let markedReadId = $state<number | null>(null); |
| @@ -361,7 +581,13 @@ | ||
| 361 | 581 | />{/if} |
| 362 | 582 | |
| 363 | 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 | 591 | <!-- eslint-disable-next-line svelte/no-at-html-tags --> |
| 366 | 592 | {@html showContent} |
| 367 | 593 | </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 effect | 281 | // 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> |