Add annotation highlight toggle and stylingUnverified
d1e88c1 parent: 2fffac8 modified
internal/tmpl/article_detail.html +85 -1 | @@ -78,6 +78,10 @@ | ||
| 78 | 78 | </div> |
| 79 | 79 | {{end}} |
| 80 | 80 | |
| 81 | + {{if .Annotations}} | |
| 82 | + <script id="annotation-quotes" type="application/json">[{{range $i, $a := .Annotations}}{{if $i}},{{end}}{{if $a.Quote.Valid}}{"id":{{$a.ID}},"quote":{{js $a.Quote.String}}}{{end}}{{end}}]</script> | |
| 83 | + {{end}} | |
| 84 | + | |
| 81 | 85 | {{if and (not .Article.Content.Valid) (not .Article.FullContent.Valid) .Article.URL.Valid (not (isEmbedURL .Article.URL.String))}} |
| 82 | 86 | <div id="article-content" class="mt-6"> |
| 83 | 87 | <button hx-post="/articles/{{.Article.ID}}/fetch-content" hx-target="#article-content" hx-swap="outerHTML" |
| @@ -94,7 +98,15 @@ | ||
| 94 | 98 | <hr class="my-8 border-spot-divider"> |
| 95 | 99 | |
| 96 | 100 | <section> |
| 97 | - <h2 class="text-lg font-semibold text-spot-text mb-4">Annotations</h2> | |
| 101 | + <div class="flex items-center justify-between mb-4"> | |
| 102 | + <h2 class="text-lg font-semibold text-spot-text">Annotations</h2> | |
| 103 | + <button id="toggle-highlights" class="text-xs text-spot-secondary hover:text-spot-text transition inline-flex items-center gap-1.5"> | |
| 104 | + <span id="toggle-highlights-icon" class="w-4 h-4 inline-flex"> | |
| 105 | + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.876-5.814a1.151 1.151 0 00-1.597-1.597L14.146 6.32a15.996 15.996 0 00-4.649 4.763m3.42 3.42a6.776 6.776 0 00-3.42-3.42"/></svg> | |
| 106 | + </span> | |
| 107 | + <span id="toggle-highlights-label">Hide highlights</span> | |
| 108 | + </button> | |
| 109 | + </div> | |
| 98 | 110 | |
| 99 | 111 | <form id="annotation-form" hx-post="/library/create" hx-target="#annotations-list" hx-swap="beforeend" |
| 100 | 112 | hx-on::after-request="this.reset()" |
| @@ -162,6 +174,78 @@ | ||
| 162 | 174 | quoteHighlight.classList.add('hidden'); |
| 163 | 175 | }; |
| 164 | 176 | |
| 177 | + function highlightQuotes() { | |
| 178 | + var el = document.getElementById('annotation-quotes'); | |
| 179 | + if (!el) return; | |
| 180 | + var quotes = JSON.parse(el.textContent); | |
| 181 | + el.remove(); | |
| 182 | + | |
| 183 | + var body = document.querySelector('.article-body'); | |
| 184 | + if (!body) return; | |
| 185 | + | |
| 186 | + var treeWalker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT); | |
| 187 | + var textNodes = []; | |
| 188 | + while (treeWalker.nextNode()) textNodes.push(treeWalker.currentNode); | |
| 189 | + | |
| 190 | + quotes.forEach(function(a) { | |
| 191 | + if (!a.quote) return; | |
| 192 | + var target = a.quote.trim(); | |
| 193 | + if (!target) return; | |
| 194 | + | |
| 195 | + for (var i = 0; i < textNodes.length; i++) { | |
| 196 | + var node = textNodes[i]; | |
| 197 | + var idx = node.nodeValue.indexOf(target); | |
| 198 | + if (idx === -1) continue; | |
| 199 | + | |
| 200 | + var range = document.createRange(); | |
| 201 | + range.setStart(node, idx); | |
| 202 | + range.setEnd(node, idx + target.length); | |
| 203 | + | |
| 204 | + var mark = document.createElement('mark'); | |
| 205 | + mark.setAttribute('data-annotation', a.id); | |
| 206 | + mark.className = 'annotation-highlight cursor-pointer'; | |
| 207 | + mark.addEventListener('click', function() { | |
| 208 | + var card = document.getElementById('annotation-' + this.getAttribute('data-annotation')); | |
| 209 | + if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |
| 210 | + }); | |
| 211 | + range.surroundContents(mark); | |
| 212 | + | |
| 213 | + textNodes = []; | |
| 214 | + treeWalker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT); | |
| 215 | + while (treeWalker.nextNode()) textNodes.push(treeWalker.currentNode); | |
| 216 | + break; | |
| 217 | + } | |
| 218 | + }); | |
| 219 | + } | |
| 220 | + | |
| 221 | + var highlightsEnabled = localStorage.getItem('annotation-highlights') !== 'false'; | |
| 222 | + | |
| 223 | + function applyHighlightsState() { | |
| 224 | + var marks = document.querySelectorAll('.annotation-highlight'); | |
| 225 | + marks.forEach(function(m) { | |
| 226 | + m.style.backgroundColor = highlightsEnabled ? '' : 'transparent'; | |
| 227 | + m.style.borderBottomColor = highlightsEnabled ? '' : 'transparent'; | |
| 228 | + }); | |
| 229 | + var btn = document.getElementById('toggle-highlights'); | |
| 230 | + var label = document.getElementById('toggle-highlights-label'); | |
| 231 | + if (btn) label.textContent = highlightsEnabled ? 'Hide highlights' : 'Show highlights'; | |
| 232 | + } | |
| 233 | + | |
| 234 | + if (highlightsEnabled) highlightQuotes(); | |
| 235 | + applyHighlightsState(); | |
| 236 | + | |
| 237 | + document.getElementById('toggle-highlights').addEventListener('click', function() { | |
| 238 | + highlightsEnabled = !highlightsEnabled; | |
| 239 | + localStorage.setItem('annotation-highlights', highlightsEnabled); | |
| 240 | + | |
| 241 | + var body = document.querySelector('.article-body'); | |
| 242 | + if (highlightsEnabled && body && !document.querySelector('.annotation-highlight') && document.getElementById('annotation-quotes')) { | |
| 243 | + highlightQuotes(); | |
| 244 | + } | |
| 245 | + | |
| 246 | + applyHighlightsState(); | |
| 247 | + }); | |
| 248 | + | |
| 165 | 249 | document.addEventListener('keydown', function(e) { |
| 166 | 250 | if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; |
| 167 | 251 | if (e.key === 'l') { |
| @@ -78,6 +78,10 @@ | |||
| 78 | </div> | 78 | </div> |
| 79 | {{end}} | 79 | {{end}} |
| 80 | 80 | ||
| 81 | + {{if .Annotations}} | ||
| 82 | + <script id="annotation-quotes" type="application/json">[{{range $i, $a := .Annotations}}{{if $i}},{{end}}{{if $a.Quote.Valid}}{"id":{{$a.ID}},"quote":{{js $a.Quote.String}}}{{end}}{{end}}]</script> | ||
| 83 | + {{end}} | ||
| 84 | + | ||
| 81 | {{if and (not .Article.Content.Valid) (not .Article.FullContent.Valid) .Article.URL.Valid (not (isEmbedURL .Article.URL.String))}} | 85 | {{if and (not .Article.Content.Valid) (not .Article.FullContent.Valid) .Article.URL.Valid (not (isEmbedURL .Article.URL.String))}} |
| 82 | <div id="article-content" class="mt-6"> | 86 | <div id="article-content" class="mt-6"> |
| 83 | <button hx-post="/articles/{{.Article.ID}}/fetch-content" hx-target="#article-content" hx-swap="outerHTML" | 87 | <button hx-post="/articles/{{.Article.ID}}/fetch-content" hx-target="#article-content" hx-swap="outerHTML" |
| @@ -94,7 +98,15 @@ | |||
| 94 | <hr class="my-8 border-spot-divider"> | 98 | <hr class="my-8 border-spot-divider"> |
| 95 | 99 | ||
| 96 | <section> | 100 | <section> |
| 97 | - <h2 class="text-lg font-semibold text-spot-text mb-4">Annotations</h2> | 101 | + <div class="flex items-center justify-between mb-4"> |
| 102 | + <h2 class="text-lg font-semibold text-spot-text">Annotations</h2> | ||
| 103 | + <button id="toggle-highlights" class="text-xs text-spot-secondary hover:text-spot-text transition inline-flex items-center gap-1.5"> | ||
| 104 | + <span id="toggle-highlights-icon" class="w-4 h-4 inline-flex"> | ||
| 105 | + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.876-5.814a1.151 1.151 0 00-1.597-1.597L14.146 6.32a15.996 15.996 0 00-4.649 4.763m3.42 3.42a6.776 6.776 0 00-3.42-3.42"/></svg> | ||
| 106 | + </span> | ||
| 107 | + <span id="toggle-highlights-label">Hide highlights</span> | ||
| 108 | + </button> | ||
| 109 | + </div> | ||
| 98 | 110 | ||
| 99 | <form id="annotation-form" hx-post="/library/create" hx-target="#annotations-list" hx-swap="beforeend" | 111 | <form id="annotation-form" hx-post="/library/create" hx-target="#annotations-list" hx-swap="beforeend" |
| 100 | hx-on::after-request="this.reset()" | 112 | hx-on::after-request="this.reset()" |
| @@ -162,6 +174,78 @@ | |||
| 162 | quoteHighlight.classList.add('hidden'); | 174 | quoteHighlight.classList.add('hidden'); |
| 163 | }; | 175 | }; |
| 164 | 176 | ||
| 177 | + function highlightQuotes() { | ||
| 178 | + var el = document.getElementById('annotation-quotes'); | ||
| 179 | + if (!el) return; | ||
| 180 | + var quotes = JSON.parse(el.textContent); | ||
| 181 | + el.remove(); | ||
| 182 | + | ||
| 183 | + var body = document.querySelector('.article-body'); | ||
| 184 | + if (!body) return; | ||
| 185 | + | ||
| 186 | + var treeWalker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT); | ||
| 187 | + var textNodes = []; | ||
| 188 | + while (treeWalker.nextNode()) textNodes.push(treeWalker.currentNode); | ||
| 189 | + | ||
| 190 | + quotes.forEach(function(a) { | ||
| 191 | + if (!a.quote) return; | ||
| 192 | + var target = a.quote.trim(); | ||
| 193 | + if (!target) return; | ||
| 194 | + | ||
| 195 | + for (var i = 0; i < textNodes.length; i++) { | ||
| 196 | + var node = textNodes[i]; | ||
| 197 | + var idx = node.nodeValue.indexOf(target); | ||
| 198 | + if (idx === -1) continue; | ||
| 199 | + | ||
| 200 | + var range = document.createRange(); | ||
| 201 | + range.setStart(node, idx); | ||
| 202 | + range.setEnd(node, idx + target.length); | ||
| 203 | + | ||
| 204 | + var mark = document.createElement('mark'); | ||
| 205 | + mark.setAttribute('data-annotation', a.id); | ||
| 206 | + mark.className = 'annotation-highlight cursor-pointer'; | ||
| 207 | + mark.addEventListener('click', function() { | ||
| 208 | + var card = document.getElementById('annotation-' + this.getAttribute('data-annotation')); | ||
| 209 | + if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' }); | ||
| 210 | + }); | ||
| 211 | + range.surroundContents(mark); | ||
| 212 | + | ||
| 213 | + textNodes = []; | ||
| 214 | + treeWalker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT); | ||
| 215 | + while (treeWalker.nextNode()) textNodes.push(treeWalker.currentNode); | ||
| 216 | + break; | ||
| 217 | + } | ||
| 218 | + }); | ||
| 219 | + } | ||
| 220 | + | ||
| 221 | + var highlightsEnabled = localStorage.getItem('annotation-highlights') !== 'false'; | ||
| 222 | + | ||
| 223 | + function applyHighlightsState() { | ||
| 224 | + var marks = document.querySelectorAll('.annotation-highlight'); | ||
| 225 | + marks.forEach(function(m) { | ||
| 226 | + m.style.backgroundColor = highlightsEnabled ? '' : 'transparent'; | ||
| 227 | + m.style.borderBottomColor = highlightsEnabled ? '' : 'transparent'; | ||
| 228 | + }); | ||
| 229 | + var btn = document.getElementById('toggle-highlights'); | ||
| 230 | + var label = document.getElementById('toggle-highlights-label'); | ||
| 231 | + if (btn) label.textContent = highlightsEnabled ? 'Hide highlights' : 'Show highlights'; | ||
| 232 | + } | ||
| 233 | + | ||
| 234 | + if (highlightsEnabled) highlightQuotes(); | ||
| 235 | + applyHighlightsState(); | ||
| 236 | + | ||
| 237 | + document.getElementById('toggle-highlights').addEventListener('click', function() { | ||
| 238 | + highlightsEnabled = !highlightsEnabled; | ||
| 239 | + localStorage.setItem('annotation-highlights', highlightsEnabled); | ||
| 240 | + | ||
| 241 | + var body = document.querySelector('.article-body'); | ||
| 242 | + if (highlightsEnabled && body && !document.querySelector('.annotation-highlight') && document.getElementById('annotation-quotes')) { | ||
| 243 | + highlightQuotes(); | ||
| 244 | + } | ||
| 245 | + | ||
| 246 | + applyHighlightsState(); | ||
| 247 | + }); | ||
| 248 | + | ||
| 165 | document.addEventListener('keydown', function(e) { | 249 | document.addEventListener('keydown', function(e) { |
| 166 | if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; | 250 | if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; |
| 167 | if (e.key === 'l') { | 251 | if (e.key === 'l') { |
modified
static/input.css +2 -0 | @@ -67,6 +67,8 @@ | ||
| 67 | 67 | .article-body iframe[src*="youtube.com"], .article-body iframe[src*="youtube-nocookie.com"], .article-body iframe[src*="vimeo.com"], .article-body iframe[src*="spotify.com"], .article-body iframe[src*="soundcloud.com"], .article-body iframe[src*="bandcamp.com"] { @apply w-full aspect-video } |
| 68 | 68 | .article-body del { @apply line-through text-spot-secondary } |
| 69 | 69 | .article-body mark { @apply bg-spot-orange/30 px-1 rounded } |
| 70 | + .article-body .annotation-highlight { @apply bg-spot-green/25 border-b-2 border-spot-green/50 px-0.5 rounded-sm transition-colors } | |
| 71 | + .article-body .annotation-highlight:hover { @apply bg-spot-green/40 } | |
| 70 | 72 | } |
| 71 | 73 | |
| 72 | 74 | @layer utilities { |
| @@ -67,6 +67,8 @@ | |||
| 67 | .article-body iframe[src*="youtube.com"], .article-body iframe[src*="youtube-nocookie.com"], .article-body iframe[src*="vimeo.com"], .article-body iframe[src*="spotify.com"], .article-body iframe[src*="soundcloud.com"], .article-body iframe[src*="bandcamp.com"] { @apply w-full aspect-video } | 67 | .article-body iframe[src*="youtube.com"], .article-body iframe[src*="youtube-nocookie.com"], .article-body iframe[src*="vimeo.com"], .article-body iframe[src*="spotify.com"], .article-body iframe[src*="soundcloud.com"], .article-body iframe[src*="bandcamp.com"] { @apply w-full aspect-video } |
| 68 | .article-body del { @apply line-through text-spot-secondary } | 68 | .article-body del { @apply line-through text-spot-secondary } |
| 69 | .article-body mark { @apply bg-spot-orange/30 px-1 rounded } | 69 | .article-body mark { @apply bg-spot-orange/30 px-1 rounded } |
| 70 | + .article-body .annotation-highlight { @apply bg-spot-green/25 border-b-2 border-spot-green/50 px-0.5 rounded-sm transition-colors } | ||
| 71 | + .article-body .annotation-highlight:hover { @apply bg-spot-green/40 } | ||
| 70 | } | 72 | } |
| 71 | 73 | ||
| 72 | @layer utilities { | 74 | @layer utilities { |