Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 1177 lines 40 kB view raw
1import { sendMessage } from '@/utils/messaging'; 2import { overlayEnabledItem, themeItem } from '@/utils/storage'; 3import { overlayStyles } from '@/utils/overlay-styles'; 4import { DOMTextMatcher } from '@/utils/text-matcher'; 5import type { Annotation } from '@/utils/types'; 6import { APP_URL } from '@/utils/types'; 7 8const Icons = { 9 close: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`, 10 reply: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>`, 11 share: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" x2="12" y1="2" y2="15"/></svg>`, 12 check: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`, 13 highlightMarker: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5Z"/><path d="m2 17 10 5 10-5"/><path d="m2 12 10 5 10-5"/></svg>`, 14 message: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`, 15 send: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>`, 16}; 17 18function formatRelativeTime(dateString: string) { 19 const date = new Date(dateString); 20 const now = new Date(); 21 const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); 22 23 if (diffInSeconds < 60) return 'just now'; 24 if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m`; 25 if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h`; 26 if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)}d`; 27 return date.toLocaleDateString(); 28} 29 30function escapeHtml(unsafe: string) { 31 return unsafe 32 .replace(/&/g, '&amp;') 33 .replace(/</g, '&lt;') 34 .replace(/>/g, '&gt;') 35 .replace(/"/g, '&quot;') 36 .replace(/'/g, '&#039;'); 37} 38 39export async function initContentScript(ctx: { onInvalidated: (cb: () => void) => void }) { 40 let overlayHost: HTMLElement | null = null; 41 let shadowRoot: ShadowRoot | null = null; 42 let popoverEl: HTMLElement | null = null; 43 let hoverIndicator: HTMLElement | null = null; 44 let composeModal: HTMLElement | null = null; 45 let activeItems: Array<{ range: Range; item: Annotation }> = []; 46 let cachedMatcher: DOMTextMatcher | null = null; 47 const injectedStyles = new Set<string>(); 48 let overlayEnabled = true; 49 let currentUserDid: string | null = null; 50 let cachedUserTags: string[] = []; 51 52 function getPageUrl(): string { 53 const pdfUrl = document.documentElement.dataset.marginPdfUrl; 54 if (pdfUrl) return pdfUrl; 55 56 if (window.location.href.includes('/pdfjs/web/viewer.html')) { 57 try { 58 const params = new URLSearchParams(window.location.search); 59 const fileParam = params.get('file'); 60 if (fileParam) { 61 document.documentElement.dataset.marginPdfUrl = fileParam; 62 return fileParam; 63 } 64 } catch { 65 /* ignore */ 66 } 67 } 68 69 return window.location.href; 70 } 71 72 function getPageDOIUrl(): string | null { 73 try { 74 if (new URL(window.location.href).hostname === 'doi.org') return null; 75 } catch { 76 return null; 77 } 78 79 const metaDOI = 80 document.querySelector<HTMLMetaElement>('meta[name="citation_doi"]') || 81 document.querySelector<HTMLMetaElement>('meta[name="dc.identifier"]') || 82 document.querySelector<HTMLMetaElement>('meta[name="DC.identifier"]'); 83 if (metaDOI?.content) { 84 const doi = metaDOI.content.replace(/^doi:/i, '').trim(); 85 if (doi.startsWith('10.')) return `https://doi.org/${doi}`; 86 } 87 88 const canonical = document.querySelector<HTMLLinkElement>('link[rel="canonical"]'); 89 if (canonical?.href) { 90 try { 91 if (new URL(canonical.href).hostname === 'doi.org') return canonical.href; 92 } catch { 93 /* ignore */ 94 } 95 } 96 97 return null; 98 } 99 100 function getPageCiteUrls(): string[] { 101 const urls = new Set<string>(); 102 document.querySelectorAll<Element>('q[cite], blockquote[cite]').forEach((el) => { 103 const cite = el.getAttribute('cite'); 104 if (!cite) return; 105 try { 106 const abs = new URL(cite, window.location.href).href; 107 if (abs !== window.location.href) urls.add(abs); 108 } catch { 109 /* ignore */ 110 } 111 }); 112 return Array.from(urls); 113 } 114 115 function getCiteUrlForText(text: string): string | null { 116 if (!text) return null; 117 if (!cachedMatcher) cachedMatcher = new DOMTextMatcher(); 118 const range = cachedMatcher.findRange(text); 119 if (!range) return null; 120 121 let node: Node | null = range.commonAncestorContainer; 122 while (node && node !== document.body) { 123 if (node.nodeType === Node.ELEMENT_NODE) { 124 const el = node as Element; 125 if ((el.tagName === 'Q' || el.tagName === 'BLOCKQUOTE') && el.hasAttribute('cite')) { 126 const cite = el.getAttribute('cite')!; 127 try { 128 return new URL(cite, window.location.href).href; 129 } catch { 130 return null; 131 } 132 } 133 } 134 node = node.parentNode; 135 } 136 return null; 137 } 138 139 function isPdfContext(): boolean { 140 return !!( 141 document.querySelector('.pdfViewer') || 142 window.location.href.includes('/pdfjs/web/viewer.html') 143 ); 144 } 145 146 sendMessage('checkSession', undefined) 147 .then((session) => { 148 if (session.authenticated && session.did) { 149 currentUserDid = session.did; 150 Promise.all([ 151 sendMessage('getUserTags', { did: session.did }).catch(() => [] as string[]), 152 sendMessage('getTrendingTags', undefined).catch(() => [] as string[]), 153 ]).then(([userTags, trendingTags]) => { 154 const seen = new Set(userTags); 155 cachedUserTags = [...userTags]; 156 for (const t of trendingTags) { 157 if (!seen.has(t)) { 158 cachedUserTags.push(t); 159 seen.add(t); 160 } 161 } 162 }); 163 } 164 }) 165 .catch(() => {}); 166 167 function initOverlay() { 168 overlayHost = document.createElement('div'); 169 overlayHost.id = 'margin-overlay-host'; 170 overlayHost.style.cssText = ` 171 position: absolute; top: 0; left: 0; width: 100%; 172 height: 0; overflow: visible; 173 pointer-events: none; z-index: 2147483647; 174 `; 175 if (document.body) { 176 document.body.appendChild(overlayHost); 177 } else { 178 document.documentElement.appendChild(overlayHost); 179 } 180 181 shadowRoot = overlayHost.attachShadow({ mode: 'open' }); 182 183 const styleEl = document.createElement('style'); 184 styleEl.textContent = overlayStyles; 185 shadowRoot.appendChild(styleEl); 186 const overlayContainer = document.createElement('div'); 187 overlayContainer.className = 'margin-overlay'; 188 overlayContainer.id = 'margin-overlay-container'; 189 shadowRoot.appendChild(overlayContainer); 190 191 document.addEventListener('mousemove', handleMouseMove); 192 document.addEventListener('click', handleDocumentClick, true); 193 document.addEventListener('keydown', handleKeyDown); 194 } 195 if (document.body) { 196 initOverlay(); 197 } else { 198 document.addEventListener('DOMContentLoaded', initOverlay); 199 } 200 201 overlayEnabledItem.getValue().then((enabled) => { 202 overlayEnabled = enabled; 203 if (!enabled && overlayHost) { 204 overlayHost.style.display = 'none'; 205 sendMessage('updateBadge', { count: 0 }); 206 } else { 207 applyTheme(); 208 if ('requestIdleCallback' in window) { 209 requestIdleCallback(() => fetchAnnotations(), { timeout: 2000 }); 210 } else { 211 setTimeout(() => fetchAnnotations(), 100); 212 } 213 } 214 }); 215 216 ctx.onInvalidated(() => { 217 document.removeEventListener('mousemove', handleMouseMove); 218 document.removeEventListener('click', handleDocumentClick, true); 219 document.removeEventListener('keydown', handleKeyDown); 220 221 overlayHost?.remove(); 222 }); 223 224 async function applyTheme() { 225 if (!overlayHost) return; 226 const theme = await themeItem.getValue(); 227 overlayHost.classList.remove('light', 'dark'); 228 if (theme === 'system' || !theme) { 229 if (window.matchMedia('(prefers-color-scheme: light)').matches) { 230 overlayHost.classList.add('light'); 231 } 232 } else { 233 overlayHost.classList.add(theme); 234 } 235 } 236 237 themeItem.watch((newTheme) => { 238 if (overlayHost) { 239 overlayHost.classList.remove('light', 'dark'); 240 if (newTheme === 'system') { 241 if (window.matchMedia('(prefers-color-scheme: light)').matches) { 242 overlayHost.classList.add('light'); 243 } 244 } else { 245 overlayHost.classList.add(newTheme); 246 } 247 } 248 }); 249 250 overlayEnabledItem.watch((enabled) => { 251 overlayEnabled = enabled; 252 if (overlayHost) { 253 overlayHost.style.display = enabled ? '' : 'none'; 254 if (enabled) { 255 fetchAnnotations(); 256 } else { 257 activeItems = []; 258 if (typeof CSS !== 'undefined' && CSS.highlights) { 259 CSS.highlights.clear(); 260 } 261 sendMessage('updateBadge', { count: 0 }); 262 } 263 } 264 }); 265 266 function handleKeyDown(e: KeyboardEvent) { 267 if (e.key === 'Escape') { 268 if (composeModal) { 269 composeModal.remove(); 270 composeModal = null; 271 } 272 if (popoverEl) { 273 popoverEl.remove(); 274 popoverEl = null; 275 } 276 } 277 } 278 279 function showComposeModal(quoteText: string) { 280 if (!shadowRoot) return; 281 282 const container = shadowRoot.getElementById('margin-overlay-container'); 283 if (!container) return; 284 285 if (composeModal) composeModal.remove(); 286 287 composeModal = document.createElement('div'); 288 composeModal.className = 'inline-compose-modal'; 289 290 const left = Math.max(20, (window.innerWidth - 380) / 2); 291 const top = Math.max(60, window.innerHeight * 0.2); 292 293 composeModal.style.left = `${left}px`; 294 composeModal.style.top = `${top}px`; 295 296 const truncatedQuote = quoteText.length > 150 ? quoteText.slice(0, 150) + '...' : quoteText; 297 298 const header = document.createElement('div'); 299 header.className = 'compose-header'; 300 301 const titleSpan = document.createElement('span'); 302 titleSpan.className = 'compose-title'; 303 titleSpan.textContent = 'New Annotation'; 304 header.appendChild(titleSpan); 305 306 const closeBtn = document.createElement('button'); 307 closeBtn.className = 'compose-close'; 308 closeBtn.innerHTML = Icons.close; 309 header.appendChild(closeBtn); 310 311 composeModal.appendChild(header); 312 313 const body = document.createElement('div'); 314 body.className = 'compose-body'; 315 316 const quoteDiv = document.createElement('div'); 317 quoteDiv.className = 'inline-compose-quote'; 318 quoteDiv.textContent = `"${truncatedQuote}"`; 319 body.appendChild(quoteDiv); 320 321 const textarea = document.createElement('textarea'); 322 textarea.className = 'inline-compose-textarea'; 323 textarea.placeholder = 'Write your annotation...'; 324 body.appendChild(textarea); 325 326 const tagSection = document.createElement('div'); 327 tagSection.className = 'compose-tags-section'; 328 329 const tagContainer = document.createElement('div'); 330 tagContainer.className = 'compose-tags-container'; 331 332 const tagInput = document.createElement('input'); 333 tagInput.type = 'text'; 334 tagInput.className = 'compose-tag-input'; 335 tagInput.placeholder = 'Add tags...'; 336 337 const tagSuggestionsDropdown = document.createElement('div'); 338 tagSuggestionsDropdown.className = 'compose-tag-suggestions'; 339 tagSuggestionsDropdown.style.display = 'none'; 340 341 const composeTags: string[] = []; 342 343 function renderTags() { 344 tagContainer.querySelectorAll('.compose-tag-pill').forEach((el) => el.remove()); 345 composeTags.forEach((tag) => { 346 const pill = document.createElement('span'); 347 pill.className = 'compose-tag-pill'; 348 pill.innerHTML = `${escapeHtml(tag)} <button class="compose-tag-remove">${Icons.close}</button>`; 349 pill.querySelector('.compose-tag-remove')?.addEventListener('click', (e) => { 350 e.stopPropagation(); 351 const idx = composeTags.indexOf(tag); 352 if (idx > -1) composeTags.splice(idx, 1); 353 renderTags(); 354 }); 355 tagContainer.insertBefore(pill, tagInput); 356 }); 357 tagInput.placeholder = composeTags.length === 0 ? 'Add tags...' : ''; 358 } 359 360 function addComposeTag(tag: string) { 361 const normalized = tag 362 .trim() 363 .toLowerCase() 364 .replace(/[^a-z0-9_-]/g, ''); 365 if (normalized && !composeTags.includes(normalized) && composeTags.length < 10) { 366 composeTags.push(normalized); 367 renderTags(); 368 } 369 tagInput.value = ''; 370 tagSuggestionsDropdown.style.display = 'none'; 371 tagInput.focus(); 372 } 373 374 function showTagSuggestions() { 375 const query = tagInput.value.trim().toLowerCase(); 376 if (!query) { 377 tagSuggestionsDropdown.style.display = 'none'; 378 return; 379 } 380 const matches = cachedUserTags 381 .filter((t) => t.toLowerCase().includes(query) && !composeTags.includes(t)) 382 .slice(0, 6); 383 if (matches.length === 0) { 384 tagSuggestionsDropdown.style.display = 'none'; 385 return; 386 } 387 tagSuggestionsDropdown.innerHTML = matches 388 .map((t) => `<button class="compose-tag-suggestion-item">${escapeHtml(t)}</button>`) 389 .join(''); 390 tagSuggestionsDropdown.style.display = 'block'; 391 tagSuggestionsDropdown.querySelectorAll('.compose-tag-suggestion-item').forEach((btn) => { 392 btn.addEventListener('click', (e) => { 393 e.stopPropagation(); 394 addComposeTag(btn.textContent || ''); 395 }); 396 }); 397 } 398 399 tagInput.addEventListener('input', showTagSuggestions); 400 tagInput.addEventListener('keydown', (e) => { 401 if (e.key === 'Enter' || e.key === ',') { 402 e.preventDefault(); 403 if (tagInput.value.trim()) addComposeTag(tagInput.value); 404 } else if (e.key === 'Backspace' && !tagInput.value && composeTags.length > 0) { 405 composeTags.pop(); 406 renderTags(); 407 } else if (e.key === 'Escape') { 408 tagSuggestionsDropdown.style.display = 'none'; 409 } 410 }); 411 412 tagContainer.appendChild(tagInput); 413 tagSection.appendChild(tagContainer); 414 tagSection.appendChild(tagSuggestionsDropdown); 415 body.appendChild(tagSection); 416 417 composeModal.appendChild(body); 418 419 const footer = document.createElement('div'); 420 footer.className = 'compose-footer'; 421 422 const cancelBtn = document.createElement('button'); 423 cancelBtn.className = 'btn-cancel'; 424 cancelBtn.textContent = 'Cancel'; 425 footer.appendChild(cancelBtn); 426 427 const submitBtn = document.createElement('button'); 428 submitBtn.className = 'btn-submit'; 429 submitBtn.textContent = 'Post'; 430 footer.appendChild(submitBtn); 431 432 composeModal.appendChild(footer); 433 434 composeModal.querySelector('.compose-close')?.addEventListener('click', () => { 435 composeModal?.remove(); 436 composeModal = null; 437 }); 438 439 cancelBtn.addEventListener('click', () => { 440 composeModal?.remove(); 441 composeModal = null; 442 }); 443 444 submitBtn.addEventListener('click', async () => { 445 const text = textarea?.value.trim(); 446 if (!text) return; 447 448 submitBtn.disabled = true; 449 submitBtn.textContent = 'Posting...'; 450 451 try { 452 const citeUrl = getCiteUrlForText(quoteText); 453 const res = await sendMessage('createAnnotation', { 454 url: citeUrl || getPageUrl(), 455 title: document.title, 456 text, 457 selector: { type: 'TextQuoteSelector', exact: quoteText }, 458 tags: composeTags.length > 0 ? composeTags : undefined, 459 }); 460 461 if (!res.success) { 462 throw new Error(res.error || 'Unknown error'); 463 } 464 465 showToast('Annotation created!', 'success'); 466 composeModal?.remove(); 467 composeModal = null; 468 469 setTimeout(() => fetchAnnotations(0, true), 500); 470 } catch (error) { 471 console.error('Failed to create annotation:', error); 472 showToast('Failed to create annotation', 'error'); 473 submitBtn.disabled = false; 474 submitBtn.textContent = 'Post'; 475 } 476 }); 477 478 container.appendChild(composeModal); 479 setTimeout(() => textarea?.focus(), 100); 480 } 481 browser.runtime.onMessage.addListener((message: any) => { 482 if (message.type === 'SHOW_INLINE_ANNOTATE' && message.data?.selector?.exact) { 483 showComposeModal(message.data.selector.exact); 484 } 485 if (message.type === 'REFRESH_ANNOTATIONS') { 486 fetchAnnotations(0, true); 487 } 488 if (message.type === 'SCROLL_TO_TEXT' && message.text) { 489 scrollToText(message.text); 490 } 491 if (message.type === 'GET_SELECTION') { 492 const selection = window.getSelection(); 493 const text = selection?.toString().trim() || ''; 494 return Promise.resolve({ text }); 495 } 496 if (message.type === 'GET_DOI') { 497 return Promise.resolve({ doiUrl: getPageDOIUrl() }); 498 } 499 if (message.type === 'GET_CITE_URL') { 500 return Promise.resolve({ citeUrl: getCiteUrlForText(message.text || '') }); 501 } 502 }); 503 504 function scrollToText(text: string) { 505 if (!text || text.length < 3) return; 506 507 if (!cachedMatcher) { 508 cachedMatcher = new DOMTextMatcher(); 509 } 510 511 const range = cachedMatcher.findRange(text); 512 if (!range) return; 513 514 const rect = range.getBoundingClientRect(); 515 const scrollY = window.scrollY + rect.top - window.innerHeight / 3; 516 window.scrollTo({ top: scrollY, behavior: 'smooth' }); 517 518 if (typeof CSS !== 'undefined' && CSS.highlights) { 519 const tempHighlight = new Highlight(range); 520 const hlName = 'margin-scroll-flash'; 521 CSS.highlights.set(hlName, tempHighlight); 522 injectHighlightStyle(hlName, '#3b82f6'); 523 524 const flashStyle = document.createElement('style'); 525 flashStyle.textContent = `::highlight(${hlName}) { 526 background-color: rgba(99, 102, 241, 0.25); 527 text-decoration: underline; 528 text-decoration-color: #3b82f6; 529 text-decoration-thickness: 3px; 530 text-underline-offset: 2px; 531 }`; 532 document.head.appendChild(flashStyle); 533 534 setTimeout(() => { 535 CSS.highlights.delete(hlName); 536 flashStyle.remove(); 537 }, 2500); 538 } else { 539 try { 540 const highlight = document.createElement('mark'); 541 highlight.style.cssText = 542 'background: rgba(59, 130, 246, 0.25); color: inherit; padding: 2px 0; border-radius: 2px; text-decoration: underline; text-decoration-color: #3b82f6; text-decoration-thickness: 3px; transition: all 0.5s;'; 543 range.surroundContents(highlight); 544 545 setTimeout(() => { 546 highlight.style.background = 'transparent'; 547 highlight.style.textDecoration = 'none'; 548 setTimeout(() => { 549 const parent = highlight.parentNode; 550 if (parent) { 551 parent.replaceChild(document.createTextNode(highlight.textContent || ''), highlight); 552 parent.normalize(); 553 } 554 }, 500); 555 }, 2000); 556 } catch { 557 // ignore 558 } 559 } 560 } 561 562 function showToast(message: string, type: 'success' | 'error' = 'success') { 563 if (!shadowRoot) return; 564 565 const container = shadowRoot.getElementById('margin-overlay-container'); 566 if (!container) return; 567 568 container.querySelectorAll('.margin-toast').forEach((el) => el.remove()); 569 570 const toast = document.createElement('div'); 571 toast.className = `margin-toast ${type === 'success' ? 'toast-success' : ''}`; 572 const iconSpan = document.createElement('span'); 573 iconSpan.className = 'toast-icon'; 574 iconSpan.innerHTML = type === 'success' ? Icons.check : Icons.close; 575 toast.appendChild(iconSpan); 576 577 const msgSpan = document.createElement('span'); 578 msgSpan.textContent = message; 579 toast.appendChild(msgSpan); 580 581 container.appendChild(toast); 582 583 setTimeout(() => { 584 toast.classList.add('toast-out'); 585 setTimeout(() => toast.remove(), 200); 586 }, 2500); 587 } 588 589 async function fetchAnnotations(retryCount = 0, cacheBust = false) { 590 if (!overlayEnabled) { 591 sendMessage('updateBadge', { count: 0 }); 592 return; 593 } 594 595 try { 596 const pageUrl = getPageUrl(); 597 const doiUrl = getPageDOIUrl(); 598 const citeUrls = getPageCiteUrls(); 599 const citedUrls = [...(doiUrl ? [doiUrl] : []), ...citeUrls]; 600 601 const annotations = await sendMessage('getAnnotations', { 602 url: pageUrl, 603 citedUrls, 604 cacheBust, 605 }); 606 607 sendMessage('updateBadge', { count: annotations?.length || 0 }); 608 609 if (annotations) { 610 sendMessage('cacheAnnotations', { url: pageUrl, annotations }); 611 } 612 613 if (annotations && annotations.length > 0) { 614 renderBadges(annotations); 615 } else if (retryCount < 3) { 616 setTimeout(() => fetchAnnotations(retryCount + 1, cacheBust), 1000 * (retryCount + 1)); 617 } 618 } catch (error) { 619 console.error('Failed to fetch annotations:', error); 620 if (retryCount < 3) { 621 setTimeout(() => fetchAnnotations(retryCount + 1, cacheBust), 1000 * (retryCount + 1)); 622 } 623 } 624 } 625 626 function renderBadges(annotations: Annotation[]) { 627 if (!shadowRoot) return; 628 629 activeItems = []; 630 const rangesByColor: Record<string, Range[]> = {}; 631 632 if (!cachedMatcher) { 633 cachedMatcher = new DOMTextMatcher(); 634 } 635 const matcher = cachedMatcher; 636 637 annotations.forEach((item) => { 638 const selector = item.target?.selector || item.selector; 639 if (!selector?.exact) return; 640 641 const range = matcher.findRange(selector.exact); 642 if (range) { 643 activeItems.push({ range, item }); 644 645 const isHighlight = (item as any).type === 'Highlight'; 646 const defaultColor = isHighlight ? '#f59e0b' : '#3b82f6'; 647 const color = item.color || defaultColor; 648 if (!rangesByColor[color]) rangesByColor[color] = []; 649 rangesByColor[color].push(range); 650 } 651 }); 652 653 if (typeof CSS !== 'undefined' && CSS.highlights) { 654 CSS.highlights.clear(); 655 for (const [color, ranges] of Object.entries(rangesByColor)) { 656 const highlight = new Highlight(...ranges); 657 const safeColor = color.replace(/[^a-zA-Z0-9]/g, ''); 658 const name = `margin-hl-${safeColor}`; 659 CSS.highlights.set(name, highlight); 660 injectHighlightStyle(name, color); 661 } 662 } 663 } 664 665 function injectHighlightStyle(name: string, color: string) { 666 if (injectedStyles.has(name)) return; 667 const style = document.createElement('style'); 668 669 if (isPdfContext()) { 670 const hex = color.replace('#', ''); 671 const r = parseInt(hex.substring(0, 2), 16) || 99; 672 const g = parseInt(hex.substring(2, 4), 16) || 102; 673 const b = parseInt(hex.substring(4, 6), 16) || 241; 674 style.textContent = ` 675 ::highlight(${name}) { 676 background-color: rgba(${r}, ${g}, ${b}, 0.35); 677 cursor: pointer; 678 } 679 `; 680 } else { 681 style.textContent = ` 682 ::highlight(${name}) { 683 text-decoration: underline; 684 text-decoration-color: ${color}; 685 text-decoration-thickness: 2px; 686 text-underline-offset: 2px; 687 cursor: pointer; 688 } 689 `; 690 } 691 692 document.head.appendChild(style); 693 injectedStyles.add(name); 694 } 695 696 let hoverRafId: number | null = null; 697 698 function handleMouseMove(e: MouseEvent) { 699 if (!overlayEnabled || !overlayHost) return; 700 701 if (hoverRafId) cancelAnimationFrame(hoverRafId); 702 hoverRafId = requestAnimationFrame(() => { 703 processHover(e.clientX, e.clientY, e); 704 }); 705 } 706 707 function processHover(x: number, y: number, e: MouseEvent) { 708 const foundItems: Array<{ range: Range; item: Annotation; rect: DOMRect }> = []; 709 let firstRange: Range | null = null; 710 711 for (const { range, item } of activeItems) { 712 const rects = range.getClientRects(); 713 for (const rect of rects) { 714 if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { 715 let container: Node | null = range.commonAncestorContainer; 716 if (container.nodeType === Node.TEXT_NODE) { 717 container = container.parentNode; 718 } 719 720 if ( 721 container && 722 ((e.target as Node).contains(container) || container.contains(e.target as Node)) 723 ) { 724 if (!firstRange) firstRange = range; 725 if (!foundItems.some((f) => f.item === item)) { 726 foundItems.push({ range, item, rect }); 727 } 728 } 729 break; 730 } 731 } 732 } 733 734 if (foundItems.length > 0 && shadowRoot) { 735 document.body.style.cursor = 'pointer'; 736 737 if (!hoverIndicator) { 738 const container = shadowRoot.getElementById('margin-overlay-container'); 739 if (container) { 740 hoverIndicator = document.createElement('div'); 741 hoverIndicator.className = 'margin-hover-indicator'; 742 container.appendChild(hoverIndicator); 743 } 744 } 745 746 if (hoverIndicator && firstRange) { 747 const authorsMap = new Map<string, any>(); 748 foundItems.forEach(({ item }) => { 749 const author = item.author || item.creator || {}; 750 const id = author.did || author.handle || 'unknown'; 751 if (!authorsMap.has(id)) { 752 authorsMap.set(id, author); 753 } 754 }); 755 756 const uniqueAuthors = Array.from(authorsMap.values()); 757 const maxShow = 3; 758 const displayAuthors = uniqueAuthors.slice(0, maxShow); 759 const overflow = uniqueAuthors.length - maxShow; 760 761 let html = displayAuthors 762 .map((author, i) => { 763 const avatar = author.avatar; 764 const handle = author.handle || 'U'; 765 const marginLeft = i === 0 ? '0' : '-8px'; 766 767 if (avatar) { 768 return `<img src="${avatar}" style="width: 24px; height: 24px; border-radius: 50%; object-fit: cover; border: 2px solid #09090b; margin-left: ${marginLeft};">`; 769 } else { 770 return `<div style="width: 24px; height: 24px; border-radius: 50%; background: #3b82f6; color: white; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 600; font-family: -apple-system, sans-serif; border: 2px solid #09090b; margin-left: ${marginLeft};">${handle[0]?.toUpperCase() || 'U'}</div>`; 771 } 772 }) 773 .join(''); 774 775 if (overflow > 0) { 776 html += `<div style="width: 24px; height: 24px; border-radius: 50%; background: #27272a; color: #a1a1aa; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 600; font-family: -apple-system, sans-serif; border: 2px solid #09090b; margin-left: -8px;">+${overflow}</div>`; 777 } 778 779 hoverIndicator.innerHTML = html; 780 781 const firstRect = firstRange.getClientRects()[0]; 782 const totalWidth = 783 Math.min(uniqueAuthors.length, maxShow + (overflow > 0 ? 1 : 0)) * 18 + 8; 784 const leftPos = firstRect.left - totalWidth; 785 const topPos = firstRect.top + firstRect.height / 2 - 12; 786 787 hoverIndicator.style.left = `${leftPos}px`; 788 hoverIndicator.style.top = `${topPos}px`; 789 hoverIndicator.classList.add('visible'); 790 } 791 } else { 792 document.body.style.cursor = ''; 793 if (hoverIndicator) { 794 hoverIndicator.classList.remove('visible'); 795 } 796 } 797 } 798 799 function handleDocumentClick(e: MouseEvent) { 800 if (!overlayEnabled || !overlayHost) return; 801 802 const x = e.clientX; 803 const y = e.clientY; 804 805 if (popoverEl) { 806 const rect = popoverEl.getBoundingClientRect(); 807 if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { 808 return; 809 } 810 } 811 812 if (composeModal) { 813 const rect = composeModal.getBoundingClientRect(); 814 if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { 815 return; 816 } 817 composeModal.remove(); 818 composeModal = null; 819 } 820 821 const clickedItems: Annotation[] = []; 822 for (const { range, item } of activeItems) { 823 const rects = range.getClientRects(); 824 for (const rect of rects) { 825 if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { 826 let container: Node | null = range.commonAncestorContainer; 827 if (container.nodeType === Node.TEXT_NODE) { 828 container = container.parentNode; 829 } 830 831 if ( 832 container && 833 ((e.target as Node).contains(container) || container.contains(e.target as Node)) 834 ) { 835 if (!clickedItems.includes(item)) { 836 clickedItems.push(item); 837 } 838 } 839 break; 840 } 841 } 842 } 843 844 if (clickedItems.length > 0) { 845 e.preventDefault(); 846 e.stopPropagation(); 847 848 if (popoverEl) { 849 const currentIds = popoverEl.dataset.itemIds; 850 const newIds = clickedItems 851 .map((i) => i.uri || i.id) 852 .sort() 853 .join(','); 854 if (currentIds === newIds) { 855 popoverEl.remove(); 856 popoverEl = null; 857 return; 858 } 859 } 860 861 const firstItem = clickedItems[0]; 862 const match = activeItems.find((x) => x.item === firstItem); 863 if (match) { 864 const rects = match.range.getClientRects(); 865 if (rects.length > 0) { 866 const rect = rects[0]; 867 const top = rect.top + window.scrollY; 868 const left = rect.left + window.scrollX; 869 showPopover(clickedItems, top, left); 870 } 871 } 872 } else { 873 if (popoverEl) { 874 popoverEl.remove(); 875 popoverEl = null; 876 } 877 } 878 } 879 880 function showPopover(items: Annotation[], top: number, left: number) { 881 if (!shadowRoot) return; 882 if (popoverEl) popoverEl.remove(); 883 884 const container = shadowRoot.getElementById('margin-overlay-container'); 885 if (!container) return; 886 887 popoverEl = document.createElement('div'); 888 popoverEl.className = 'margin-popover'; 889 890 const ids = items 891 .map((i) => i.uri || i.id) 892 .sort() 893 .join(','); 894 popoverEl.dataset.itemIds = ids; 895 896 const popWidth = 320; 897 const screenWidth = window.innerWidth; 898 let finalLeft = left; 899 if (left + popWidth > screenWidth) finalLeft = screenWidth - popWidth - 20; 900 if (finalLeft < 10) finalLeft = 10; 901 902 popoverEl.style.top = `${top + 24}px`; 903 popoverEl.style.left = `${finalLeft}px`; 904 905 const count = items.length; 906 const title = count === 1 ? 'Annotation' : `Annotations`; 907 908 const contentHtml = items 909 .map((item) => { 910 const author = item.author || item.creator || {}; 911 const handle = author.handle || 'User'; 912 const avatar = author.avatar; 913 const text = item.body?.value || item.text || ''; 914 const id = item.id || item.uri; 915 const isHighlight = (item as any).type === 'Highlight'; 916 const isOwned = currentUserDid && author.did === currentUserDid; 917 const createdAt = item.createdAt ? formatRelativeTime(item.createdAt) : ''; 918 919 let avatarHtml = `<div class="comment-avatar">${handle[0]?.toUpperCase() || 'U'}</div>`; 920 if (avatar) { 921 avatarHtml = `<img src="${avatar}" class="comment-avatar" style="object-fit: cover;">`; 922 } 923 924 let bodyHtml = ''; 925 if (isHighlight && !text) { 926 bodyHtml = `<div class="highlight-badge">${Icons.highlightMarker} Highlighted</div>`; 927 } else { 928 bodyHtml = `<div class="comment-text">${escapeHtml(text)}</div>`; 929 } 930 931 const addNoteBtn = 932 isHighlight && isOwned 933 ? `<button class="comment-action-btn btn-add-note" data-id="${id}" data-uri="${id}">${Icons.message} Annotate</button>` 934 : ''; 935 936 return ` 937 <div class="comment-item" data-item-id="${id}"> 938 <div class="comment-header"> 939 ${avatarHtml} 940 <div class="comment-meta"> 941 <span class="comment-handle">@${handle}</span> 942 ${createdAt ? `<span class="comment-time">${createdAt}</span>` : ''} 943 </div> 944 </div> 945 ${bodyHtml} 946 <div class="comment-actions"> 947 ${addNoteBtn} 948 ${!isHighlight ? `<button class="comment-action-btn btn-reply" data-id="${id}">${Icons.reply} Reply</button>` : ''} 949 <button class="comment-action-btn btn-share" data-id="${id}" data-text="${escapeHtml(text)}">${Icons.share} Share</button> 950 </div> 951 </div> 952 `; 953 }) 954 .join(''); 955 956 popoverEl.innerHTML = ` 957 <div class="popover-header"> 958 <span class="popover-title">${title} <span class="popover-count">${count}</span></span> 959 <button class="popover-close">${Icons.close}</button> 960 </div> 961 <div class="popover-scroll-area"> 962 ${contentHtml} 963 </div> 964 `; 965 966 popoverEl.querySelector('.popover-close')?.addEventListener('click', (e) => { 967 e.stopPropagation(); 968 popoverEl?.remove(); 969 popoverEl = null; 970 }); 971 972 popoverEl.querySelectorAll('.btn-add-note').forEach((btn) => { 973 btn.addEventListener('click', (e) => { 974 e.stopPropagation(); 975 const uri = (btn as HTMLElement).getAttribute('data-uri') || ''; 976 const itemId = (btn as HTMLElement).getAttribute('data-id') || ''; 977 const commentItem = btn.closest('.comment-item'); 978 if (!commentItem) return; 979 980 if (commentItem.querySelector('.add-note-form')) return; 981 982 const form = document.createElement('div'); 983 form.className = 'add-note-form'; 984 form.innerHTML = ` 985 <textarea class="add-note-textarea" placeholder="Add your note..." rows="3"></textarea> 986 <div class="add-note-actions"> 987 <button class="add-note-cancel">${Icons.close}</button> 988 <button class="add-note-submit">${Icons.send}</button> 989 </div> 990 `; 991 992 commentItem.appendChild(form); 993 const textarea = form.querySelector('textarea') as HTMLTextAreaElement; 994 textarea?.focus(); 995 996 textarea?.addEventListener('keydown', (ke) => { 997 if (ke.key === 'Enter' && !ke.shiftKey) { 998 ke.preventDefault(); 999 submitNote(); 1000 } 1001 if (ke.key === 'Escape') { 1002 form.remove(); 1003 } 1004 }); 1005 1006 form.querySelector('.add-note-cancel')?.addEventListener('click', (ce) => { 1007 ce.stopPropagation(); 1008 form.remove(); 1009 }); 1010 1011 form.querySelector('.add-note-submit')?.addEventListener('click', (se) => { 1012 se.stopPropagation(); 1013 submitNote(); 1014 }); 1015 1016 async function submitNote() { 1017 const noteText = textarea?.value.trim(); 1018 if (!noteText) return; 1019 1020 const submitBtn = form.querySelector('.add-note-submit') as HTMLButtonElement; 1021 if (submitBtn) submitBtn.disabled = true; 1022 textarea.disabled = true; 1023 1024 try { 1025 const matchingItem = items.find((i) => (i.id || i.uri) === itemId); 1026 const selector = matchingItem?.target?.selector || matchingItem?.selector; 1027 1028 const result = await sendMessage('convertHighlightToAnnotation', { 1029 highlightUri: uri, 1030 url: getPageUrl(), 1031 title: document.title, 1032 text: noteText, 1033 selector: selector ? { type: 'TextQuoteSelector', exact: selector.exact } : undefined, 1034 }); 1035 1036 if (result.success) { 1037 showToast('Highlight converted to annotation!', 'success'); 1038 popoverEl?.remove(); 1039 popoverEl = null; 1040 cachedMatcher = null; 1041 setTimeout(() => fetchAnnotations(), 500); 1042 } else { 1043 showToast('Failed to convert', 'error'); 1044 if (submitBtn) submitBtn.disabled = false; 1045 textarea.disabled = false; 1046 } 1047 } catch { 1048 showToast('Failed to convert', 'error'); 1049 if (submitBtn) submitBtn.disabled = false; 1050 textarea.disabled = false; 1051 } 1052 } 1053 }); 1054 }); 1055 1056 popoverEl.querySelectorAll('.btn-reply').forEach((btn) => { 1057 btn.addEventListener('click', (e) => { 1058 e.stopPropagation(); 1059 const id = (btn as HTMLElement).getAttribute('data-id'); 1060 if (id) { 1061 window.open(`${APP_URL}/annotation/${encodeURIComponent(id)}`, '_blank'); 1062 } 1063 }); 1064 }); 1065 1066 popoverEl.querySelectorAll('.btn-share').forEach((btn) => { 1067 btn.addEventListener('click', async (_e) => { 1068 const text = (btn as HTMLElement).getAttribute('data-text') || ''; 1069 try { 1070 await navigator.clipboard.writeText(text); 1071 const originalInner = btn.innerHTML; 1072 btn.innerHTML = `${Icons.check} Copied!`; 1073 setTimeout(() => { 1074 btn.innerHTML = originalInner; 1075 }, 2000); 1076 } catch (error) { 1077 console.error('Failed to copy', error); 1078 } 1079 }); 1080 }); 1081 1082 container.appendChild(popoverEl); 1083 } 1084 1085 let lastPolledUrl = getPageUrl(); 1086 1087 function onUrlChange() { 1088 lastPolledUrl = getPageUrl(); 1089 if (typeof CSS !== 'undefined' && CSS.highlights) { 1090 CSS.highlights.clear(); 1091 } 1092 injectedStyles.clear(); 1093 document.querySelectorAll('style').forEach((s) => { 1094 if (s.textContent?.includes('::highlight(margin-hl-')) s.remove(); 1095 }); 1096 activeItems = []; 1097 cachedMatcher = null; 1098 sendMessage('updateBadge', { count: 0 }); 1099 if (overlayEnabled) { 1100 setTimeout(() => fetchAnnotations(), 300); 1101 } 1102 } 1103 1104 window.addEventListener('popstate', onUrlChange); 1105 1106 const originalPushState = history.pushState; 1107 const originalReplaceState = history.replaceState; 1108 1109 history.pushState = function (...args) { 1110 originalPushState.apply(this, args); 1111 onUrlChange(); 1112 }; 1113 1114 history.replaceState = function (...args) { 1115 originalReplaceState.apply(this, args); 1116 onUrlChange(); 1117 }; 1118 1119 // Only re-fetch when the URL actually changes (not every 500ms) 1120 setInterval(() => { 1121 const currentUrl = getPageUrl(); 1122 if (currentUrl !== lastPolledUrl) { 1123 onUrlChange(); 1124 } 1125 }, 500); 1126 1127 let domChangeTimeout: ReturnType<typeof setTimeout> | null = null; 1128 let domChangeCount = 0; 1129 const observer = new MutationObserver((mutations) => { 1130 const hasSignificantChange = mutations.some( 1131 (m) => m.type === 'childList' && (m.addedNodes.length > 3 || m.removedNodes.length > 3) 1132 ); 1133 if (hasSignificantChange && overlayEnabled) { 1134 domChangeCount++; 1135 if (domChangeTimeout) clearTimeout(domChangeTimeout); 1136 const delay = Math.min(500 + domChangeCount * 100, 2000); 1137 domChangeTimeout = setTimeout(() => { 1138 cachedMatcher = null; 1139 domChangeCount = 0; 1140 fetchAnnotations(); 1141 }, delay); 1142 } 1143 }); 1144 observer.observe(document.body || document.documentElement, { 1145 childList: true, 1146 subtree: true, 1147 }); 1148 1149 if (document.querySelector('.pdfViewer') || /\.pdf(\?|#|$)/i.test(window.location.href)) { 1150 const pdfObserver = new MutationObserver(() => { 1151 const textLayers = document.querySelectorAll('.textLayer span'); 1152 if (textLayers.length > 10) { 1153 if (domChangeTimeout) clearTimeout(domChangeTimeout); 1154 domChangeTimeout = setTimeout(() => { 1155 cachedMatcher = null; 1156 fetchAnnotations(); 1157 }, 1000); 1158 } 1159 }); 1160 pdfObserver.observe(document.body || document.documentElement, { 1161 childList: true, 1162 subtree: true, 1163 }); 1164 1165 ctx.onInvalidated(() => { 1166 pdfObserver.disconnect(); 1167 }); 1168 } 1169 1170 ctx.onInvalidated(() => { 1171 observer.disconnect(); 1172 }); 1173 1174 window.addEventListener('load', () => { 1175 setTimeout(() => fetchAnnotations(), 500); 1176 }); 1177}