Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at frontend-rewrite 781 lines 28 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 annotate: `<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>`, 10 highlight: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 11-6 6v3h9l3-3"/><path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4"/></svg>`, 11 bookmark: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"/></svg>`, 12 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>`, 13 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>`, 14 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>`, 15 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>`, 16 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>`, 17}; 18 19export default defineContentScript({ 20 matches: ['<all_urls>'], 21 runAt: 'document_idle', 22 cssInjectionMode: 'ui', 23 24 async main(ctx) { 25 let overlayHost: HTMLElement | null = null; 26 let shadowRoot: ShadowRoot | null = null; 27 let popoverEl: HTMLElement | null = null; 28 let hoverIndicator: HTMLElement | null = null; 29 let composeModal: HTMLElement | null = null; 30 let activeItems: Array<{ range: Range; item: Annotation }> = []; 31 let cachedMatcher: DOMTextMatcher | null = null; 32 const injectedStyles = new Set<string>(); 33 let overlayEnabled = true; 34 35 function initOverlay() { 36 overlayHost = document.createElement('div'); 37 overlayHost.id = 'margin-overlay-host'; 38 overlayHost.style.cssText = ` 39 position: absolute; top: 0; left: 0; width: 100%; 40 height: 0; overflow: visible; 41 pointer-events: none; z-index: 2147483647; 42 `; 43 if (document.body) { 44 document.body.appendChild(overlayHost); 45 } else { 46 document.documentElement.appendChild(overlayHost); 47 } 48 49 shadowRoot = overlayHost.attachShadow({ mode: 'open' }); 50 51 const styleEl = document.createElement('style'); 52 styleEl.textContent = overlayStyles; 53 shadowRoot.appendChild(styleEl); 54 const overlayContainer = document.createElement('div'); 55 overlayContainer.className = 'margin-overlay'; 56 overlayContainer.id = 'margin-overlay-container'; 57 shadowRoot.appendChild(overlayContainer); 58 59 document.addEventListener('mousemove', handleMouseMove); 60 document.addEventListener('click', handleDocumentClick, true); 61 document.addEventListener('keydown', handleKeyDown); 62 } 63 if (document.body) { 64 initOverlay(); 65 } else { 66 document.addEventListener('DOMContentLoaded', initOverlay); 67 } 68 69 overlayEnabledItem.getValue().then((enabled) => { 70 overlayEnabled = enabled; 71 if (!enabled && overlayHost) { 72 overlayHost.style.display = 'none'; 73 sendMessage('updateBadge', { count: 0 }); 74 } else { 75 applyTheme(); 76 if ('requestIdleCallback' in window) { 77 requestIdleCallback(() => fetchAnnotations(), { timeout: 2000 }); 78 } else { 79 setTimeout(() => fetchAnnotations(), 100); 80 } 81 } 82 }); 83 84 ctx.onInvalidated(() => { 85 document.removeEventListener('mousemove', handleMouseMove); 86 document.removeEventListener('click', handleDocumentClick, true); 87 document.removeEventListener('keydown', handleKeyDown); 88 overlayHost?.remove(); 89 }); 90 91 async function applyTheme() { 92 if (!overlayHost) return; 93 const theme = await themeItem.getValue(); 94 overlayHost.classList.remove('light', 'dark'); 95 if (theme === 'system' || !theme) { 96 if (window.matchMedia('(prefers-color-scheme: light)').matches) { 97 overlayHost.classList.add('light'); 98 } 99 } else { 100 overlayHost.classList.add(theme); 101 } 102 } 103 104 themeItem.watch((newTheme) => { 105 if (overlayHost) { 106 overlayHost.classList.remove('light', 'dark'); 107 if (newTheme === 'system') { 108 if (window.matchMedia('(prefers-color-scheme: light)').matches) { 109 overlayHost.classList.add('light'); 110 } 111 } else { 112 overlayHost.classList.add(newTheme); 113 } 114 } 115 }); 116 117 overlayEnabledItem.watch((enabled) => { 118 overlayEnabled = enabled; 119 if (overlayHost) { 120 overlayHost.style.display = enabled ? '' : 'none'; 121 if (enabled) { 122 fetchAnnotations(); 123 } else { 124 activeItems = []; 125 if (typeof CSS !== 'undefined' && CSS.highlights) { 126 CSS.highlights.clear(); 127 } 128 sendMessage('updateBadge', { count: 0 }); 129 } 130 } 131 }); 132 133 function handleKeyDown(e: KeyboardEvent) { 134 if (e.key === 'Escape') { 135 if (composeModal) { 136 composeModal.remove(); 137 composeModal = null; 138 } 139 if (popoverEl) { 140 popoverEl.remove(); 141 popoverEl = null; 142 } 143 } 144 } 145 146 function showComposeModal(quoteText: string) { 147 if (!shadowRoot) return; 148 149 const container = shadowRoot.getElementById('margin-overlay-container'); 150 if (!container) return; 151 152 if (composeModal) composeModal.remove(); 153 154 composeModal = document.createElement('div'); 155 composeModal.className = 'inline-compose-modal'; 156 157 const left = Math.max(20, (window.innerWidth - 380) / 2); 158 const top = Math.max(60, window.innerHeight * 0.2); 159 160 composeModal.style.left = `${left}px`; 161 composeModal.style.top = `${top}px`; 162 163 const truncatedQuote = quoteText.length > 150 ? quoteText.slice(0, 150) + '...' : quoteText; 164 165 composeModal.innerHTML = ` 166 <div class="compose-header"> 167 <span class="compose-title">New Annotation</span> 168 <button class="compose-close">${Icons.close}</button> 169 </div> 170 <div class="compose-body"> 171 <div class="inline-compose-quote">"${escapeHtml(truncatedQuote)}"</div> 172 <textarea class="inline-compose-textarea" placeholder="Write your annotation..."></textarea> 173 </div> 174 <div class="compose-footer"> 175 <button class="btn-cancel">Cancel</button> 176 <button class="btn-submit">Post</button> 177 </div> 178 `; 179 180 composeModal.querySelector('.compose-close')?.addEventListener('click', () => { 181 composeModal?.remove(); 182 composeModal = null; 183 }); 184 185 composeModal.querySelector('.btn-cancel')?.addEventListener('click', () => { 186 composeModal?.remove(); 187 composeModal = null; 188 }); 189 190 const textarea = composeModal.querySelector( 191 '.inline-compose-textarea' 192 ) as HTMLTextAreaElement; 193 const submitBtn = composeModal.querySelector('.btn-submit') as HTMLButtonElement; 194 195 submitBtn.addEventListener('click', async () => { 196 const text = textarea?.value.trim(); 197 if (!text) return; 198 199 submitBtn.disabled = true; 200 submitBtn.textContent = 'Posting...'; 201 202 try { 203 const res = await sendMessage('createAnnotation', { 204 url: window.location.href, 205 title: document.title, 206 text, 207 selector: { type: 'TextQuoteSelector', exact: quoteText }, 208 }); 209 210 if (!res.success) { 211 throw new Error(res.error || 'Unknown error'); 212 } 213 214 showToast('Annotation created!', 'success'); 215 composeModal?.remove(); 216 composeModal = null; 217 218 setTimeout(() => fetchAnnotations(), 500); 219 } catch (error) { 220 console.error('Failed to create annotation:', error); 221 showToast('Failed to create annotation', 'error'); 222 submitBtn.disabled = false; 223 submitBtn.textContent = 'Post'; 224 } 225 }); 226 227 container.appendChild(composeModal); 228 setTimeout(() => textarea?.focus(), 100); 229 } 230 browser.runtime.onMessage.addListener((message: any) => { 231 if (message.type === 'SHOW_INLINE_ANNOTATE' && message.data?.selector?.exact) { 232 showComposeModal(message.data.selector.exact); 233 } 234 if (message.type === 'REFRESH_ANNOTATIONS') { 235 fetchAnnotations(); 236 } 237 if (message.type === 'SCROLL_TO_TEXT' && message.text) { 238 scrollToText(message.text); 239 } 240 if (message.type === 'GET_SELECTION') { 241 const selection = window.getSelection(); 242 const text = selection?.toString().trim() || ''; 243 return Promise.resolve({ text }); 244 } 245 }); 246 247 function scrollToText(text: string) { 248 if (!text || text.length < 10) return; 249 250 const searchText = text.slice(0, 150); 251 const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null); 252 253 let node: Text | null; 254 while ((node = walker.nextNode() as Text | null)) { 255 const content = node.textContent || ''; 256 const index = content.indexOf(searchText.slice(0, 50)); 257 if (index !== -1) { 258 const range = document.createRange(); 259 range.setStart(node, index); 260 range.setEnd(node, Math.min(index + searchText.length, content.length)); 261 262 const rect = range.getBoundingClientRect(); 263 const scrollY = window.scrollY + rect.top - window.innerHeight / 3; 264 265 window.scrollTo({ top: scrollY, behavior: 'smooth' }); 266 267 const highlight = document.createElement('mark'); 268 highlight.style.cssText = 269 'background: #6366f1; color: white; padding: 2px 0; border-radius: 2px; transition: background 0.5s;'; 270 range.surroundContents(highlight); 271 272 setTimeout(() => { 273 highlight.style.background = 'transparent'; 274 highlight.style.color = 'inherit'; 275 setTimeout(() => { 276 const parent = highlight.parentNode; 277 if (parent) { 278 parent.replaceChild( 279 document.createTextNode(highlight.textContent || ''), 280 highlight 281 ); 282 parent.normalize(); 283 } 284 }, 500); 285 }, 1500); 286 287 return; 288 } 289 } 290 } 291 292 function showToast(message: string, type: 'success' | 'error' = 'success') { 293 if (!shadowRoot) return; 294 295 const container = shadowRoot.getElementById('margin-overlay-container'); 296 if (!container) return; 297 298 container.querySelectorAll('.margin-toast').forEach((el) => el.remove()); 299 300 const toast = document.createElement('div'); 301 toast.className = `margin-toast ${type === 'success' ? 'toast-success' : ''}`; 302 toast.innerHTML = ` 303 <span class="toast-icon">${type === 'success' ? Icons.check : Icons.close}</span> 304 <span>${message}</span> 305 `; 306 307 container.appendChild(toast); 308 309 setTimeout(() => { 310 toast.classList.add('toast-out'); 311 setTimeout(() => toast.remove(), 200); 312 }, 2500); 313 } 314 315 async function fetchAnnotations(retryCount = 0) { 316 if (!overlayEnabled) { 317 sendMessage('updateBadge', { count: 0 }); 318 return; 319 } 320 321 try { 322 const _citedUrls = Array.from(document.querySelectorAll('[cite]')) 323 .map((el) => el.getAttribute('cite')) 324 .filter((url): url is string => !!url && url.startsWith('http')); 325 326 const annotations = await sendMessage('getAnnotations', { url: window.location.href }); 327 328 sendMessage('updateBadge', { count: annotations?.length || 0 }); 329 330 if (annotations) { 331 sendMessage('cacheAnnotations', { url: window.location.href, annotations }); 332 } 333 334 if (annotations && annotations.length > 0) { 335 renderBadges(annotations); 336 } else if (retryCount < 3) { 337 setTimeout(() => fetchAnnotations(retryCount + 1), 1000 * (retryCount + 1)); 338 } 339 } catch (error) { 340 console.error('Failed to fetch annotations:', error); 341 if (retryCount < 3) { 342 setTimeout(() => fetchAnnotations(retryCount + 1), 1000 * (retryCount + 1)); 343 } 344 } 345 } 346 347 function renderBadges(annotations: Annotation[]) { 348 if (!shadowRoot) return; 349 350 activeItems = []; 351 const rangesByColor: Record<string, Range[]> = {}; 352 353 if (!cachedMatcher) { 354 cachedMatcher = new DOMTextMatcher(); 355 } 356 const matcher = cachedMatcher; 357 358 annotations.forEach((item) => { 359 const selector = item.target?.selector || item.selector; 360 if (!selector?.exact) return; 361 362 const range = matcher.findRange(selector.exact); 363 if (range) { 364 activeItems.push({ range, item }); 365 366 const isHighlight = (item as any).type === 'Highlight'; 367 const defaultColor = isHighlight ? '#f59e0b' : '#6366f1'; 368 const color = item.color || defaultColor; 369 if (!rangesByColor[color]) rangesByColor[color] = []; 370 rangesByColor[color].push(range); 371 } 372 }); 373 374 if (typeof CSS !== 'undefined' && CSS.highlights) { 375 CSS.highlights.clear(); 376 for (const [color, ranges] of Object.entries(rangesByColor)) { 377 const highlight = new Highlight(...ranges); 378 const safeColor = color.replace(/[^a-zA-Z0-9]/g, ''); 379 const name = `margin-hl-${safeColor}`; 380 CSS.highlights.set(name, highlight); 381 injectHighlightStyle(name, color); 382 } 383 } 384 } 385 386 function injectHighlightStyle(name: string, color: string) { 387 if (injectedStyles.has(name)) return; 388 const style = document.createElement('style'); 389 style.textContent = ` 390 ::highlight(${name}) { 391 text-decoration: underline; 392 text-decoration-color: ${color}; 393 text-decoration-thickness: 2px; 394 text-underline-offset: 2px; 395 cursor: pointer; 396 } 397 `; 398 document.head.appendChild(style); 399 injectedStyles.add(name); 400 } 401 402 function handleMouseMove(e: MouseEvent) { 403 if (!overlayEnabled || !overlayHost) return; 404 405 const x = e.clientX; 406 const y = e.clientY; 407 408 const foundItems: Array<{ range: Range; item: Annotation; rect: DOMRect }> = []; 409 let firstRange: Range | null = null; 410 411 for (const { range, item } of activeItems) { 412 const rects = range.getClientRects(); 413 for (const rect of rects) { 414 if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { 415 let container: Node | null = range.commonAncestorContainer; 416 if (container.nodeType === Node.TEXT_NODE) { 417 container = container.parentNode; 418 } 419 420 if ( 421 container && 422 ((e.target as Node).contains(container) || container.contains(e.target as Node)) 423 ) { 424 if (!firstRange) firstRange = range; 425 if (!foundItems.some((f) => f.item === item)) { 426 foundItems.push({ range, item, rect }); 427 } 428 } 429 break; 430 } 431 } 432 } 433 434 if (foundItems.length > 0 && shadowRoot) { 435 document.body.style.cursor = 'pointer'; 436 437 if (!hoverIndicator) { 438 const container = shadowRoot.getElementById('margin-overlay-container'); 439 if (container) { 440 hoverIndicator = document.createElement('div'); 441 hoverIndicator.className = 'margin-hover-indicator'; 442 container.appendChild(hoverIndicator); 443 } 444 } 445 446 if (hoverIndicator && firstRange) { 447 const authorsMap = new Map<string, any>(); 448 foundItems.forEach(({ item }) => { 449 const author = item.author || item.creator || {}; 450 const id = author.did || author.handle || 'unknown'; 451 if (!authorsMap.has(id)) { 452 authorsMap.set(id, author); 453 } 454 }); 455 456 const uniqueAuthors = Array.from(authorsMap.values()); 457 const maxShow = 3; 458 const displayAuthors = uniqueAuthors.slice(0, maxShow); 459 const overflow = uniqueAuthors.length - maxShow; 460 461 let html = displayAuthors 462 .map((author, i) => { 463 const avatar = author.avatar; 464 const handle = author.handle || 'U'; 465 const marginLeft = i === 0 ? '0' : '-8px'; 466 467 if (avatar) { 468 return `<img src="${avatar}" style="width: 24px; height: 24px; border-radius: 50%; object-fit: cover; border: 2px solid #09090b; margin-left: ${marginLeft};">`; 469 } else { 470 return `<div style="width: 24px; height: 24px; border-radius: 50%; background: #6366f1; 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>`; 471 } 472 }) 473 .join(''); 474 475 if (overflow > 0) { 476 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>`; 477 } 478 479 hoverIndicator.innerHTML = html; 480 481 const firstRect = firstRange.getClientRects()[0]; 482 const totalWidth = 483 Math.min(uniqueAuthors.length, maxShow + (overflow > 0 ? 1 : 0)) * 18 + 8; 484 const leftPos = firstRect.left - totalWidth; 485 const topPos = firstRect.top + firstRect.height / 2 - 12; 486 487 hoverIndicator.style.left = `${leftPos}px`; 488 hoverIndicator.style.top = `${topPos}px`; 489 hoverIndicator.classList.add('visible'); 490 } 491 } else { 492 document.body.style.cursor = ''; 493 if (hoverIndicator) { 494 hoverIndicator.classList.remove('visible'); 495 } 496 } 497 } 498 499 function handleDocumentClick(e: MouseEvent) { 500 if (!overlayEnabled || !overlayHost) return; 501 502 const x = e.clientX; 503 const y = e.clientY; 504 505 if (popoverEl) { 506 const rect = popoverEl.getBoundingClientRect(); 507 if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { 508 return; 509 } 510 } 511 512 if (composeModal) { 513 const rect = composeModal.getBoundingClientRect(); 514 if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { 515 return; 516 } 517 composeModal.remove(); 518 composeModal = null; 519 } 520 521 const clickedItems: Annotation[] = []; 522 for (const { range, item } of activeItems) { 523 const rects = range.getClientRects(); 524 for (const rect of rects) { 525 if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { 526 let container: Node | null = range.commonAncestorContainer; 527 if (container.nodeType === Node.TEXT_NODE) { 528 container = container.parentNode; 529 } 530 531 if ( 532 container && 533 ((e.target as Node).contains(container) || container.contains(e.target as Node)) 534 ) { 535 if (!clickedItems.includes(item)) { 536 clickedItems.push(item); 537 } 538 } 539 break; 540 } 541 } 542 } 543 544 if (clickedItems.length > 0) { 545 e.preventDefault(); 546 e.stopPropagation(); 547 548 if (popoverEl) { 549 const currentIds = popoverEl.dataset.itemIds; 550 const newIds = clickedItems 551 .map((i) => i.uri || i.id) 552 .sort() 553 .join(','); 554 if (currentIds === newIds) { 555 popoverEl.remove(); 556 popoverEl = null; 557 return; 558 } 559 } 560 561 const firstItem = clickedItems[0]; 562 const match = activeItems.find((x) => x.item === firstItem); 563 if (match) { 564 const rects = match.range.getClientRects(); 565 if (rects.length > 0) { 566 const rect = rects[0]; 567 const top = rect.top + window.scrollY; 568 const left = rect.left + window.scrollX; 569 showPopover(clickedItems, top, left); 570 } 571 } 572 } else { 573 if (popoverEl) { 574 popoverEl.remove(); 575 popoverEl = null; 576 } 577 } 578 } 579 580 function showPopover(items: Annotation[], top: number, left: number) { 581 if (!shadowRoot) return; 582 if (popoverEl) popoverEl.remove(); 583 584 const container = shadowRoot.getElementById('margin-overlay-container'); 585 if (!container) return; 586 587 popoverEl = document.createElement('div'); 588 popoverEl.className = 'margin-popover'; 589 590 const ids = items 591 .map((i) => i.uri || i.id) 592 .sort() 593 .join(','); 594 popoverEl.dataset.itemIds = ids; 595 596 const popWidth = 320; 597 const screenWidth = window.innerWidth; 598 let finalLeft = left; 599 if (left + popWidth > screenWidth) finalLeft = screenWidth - popWidth - 20; 600 if (finalLeft < 10) finalLeft = 10; 601 602 popoverEl.style.top = `${top + 24}px`; 603 popoverEl.style.left = `${finalLeft}px`; 604 605 const count = items.length; 606 const title = count === 1 ? 'Annotation' : `Annotations`; 607 608 const contentHtml = items 609 .map((item) => { 610 const author = item.author || item.creator || {}; 611 const handle = author.handle || 'User'; 612 const avatar = author.avatar; 613 const text = item.body?.value || item.text || ''; 614 const id = item.id || item.uri; 615 const isHighlight = (item as any).type === 'Highlight'; 616 const createdAt = item.createdAt ? formatRelativeTime(item.createdAt) : ''; 617 618 let avatarHtml = `<div class="comment-avatar">${handle[0]?.toUpperCase() || 'U'}</div>`; 619 if (avatar) { 620 avatarHtml = `<img src="${avatar}" class="comment-avatar" style="object-fit: cover;">`; 621 } 622 623 let bodyHtml = ''; 624 if (isHighlight && !text) { 625 bodyHtml = `<div class="highlight-badge">${Icons.highlightMarker} Highlighted</div>`; 626 } else { 627 bodyHtml = `<div class="comment-text">${escapeHtml(text)}</div>`; 628 } 629 630 return ` 631 <div class="comment-item"> 632 <div class="comment-header"> 633 ${avatarHtml} 634 <div class="comment-meta"> 635 <span class="comment-handle">@${handle}</span> 636 ${createdAt ? `<span class="comment-time">${createdAt}</span>` : ''} 637 </div> 638 </div> 639 ${bodyHtml} 640 <div class="comment-actions"> 641 ${!isHighlight ? `<button class="comment-action-btn btn-reply" data-id="${id}">${Icons.reply} Reply</button>` : ''} 642 <button class="comment-action-btn btn-share" data-id="${id}" data-text="${escapeHtml(text)}">${Icons.share} Share</button> 643 </div> 644 </div> 645 `; 646 }) 647 .join(''); 648 649 popoverEl.innerHTML = ` 650 <div class="popover-header"> 651 <span class="popover-title">${title} <span class="popover-count">${count}</span></span> 652 <button class="popover-close">${Icons.close}</button> 653 </div> 654 <div class="popover-scroll-area"> 655 ${contentHtml} 656 </div> 657 `; 658 659 popoverEl.querySelector('.popover-close')?.addEventListener('click', (e) => { 660 e.stopPropagation(); 661 popoverEl?.remove(); 662 popoverEl = null; 663 }); 664 665 popoverEl.querySelectorAll('.btn-reply').forEach((btn) => { 666 btn.addEventListener('click', (e) => { 667 e.stopPropagation(); 668 const id = (btn as HTMLElement).getAttribute('data-id'); 669 if (id) { 670 window.open(`${APP_URL}/annotation/${encodeURIComponent(id)}`, '_blank'); 671 } 672 }); 673 }); 674 675 popoverEl.querySelectorAll('.btn-share').forEach((btn) => { 676 btn.addEventListener('click', async (e) => { 677 e.stopPropagation(); 678 const id = (btn as HTMLElement).getAttribute('data-id'); 679 const text = (btn as HTMLElement).getAttribute('data-text'); 680 const url = `${APP_URL}/annotation/${encodeURIComponent(id || '')}`; 681 const shareText = text ? `${text}\n${url}` : url; 682 683 try { 684 await navigator.clipboard.writeText(shareText); 685 const originalHtml = btn.innerHTML; 686 btn.innerHTML = `${Icons.check} Copied!`; 687 setTimeout(() => (btn.innerHTML = originalHtml), 2000); 688 } catch (err) { 689 console.error('Failed to copy', err); 690 } 691 }); 692 }); 693 694 container.appendChild(popoverEl); 695 } 696 697 function formatRelativeTime(dateStr: string): string { 698 const date = new Date(dateStr); 699 const now = new Date(); 700 const diffMs = now.getTime() - date.getTime(); 701 const diffMins = Math.floor(diffMs / 60000); 702 const diffHours = Math.floor(diffMs / 3600000); 703 const diffDays = Math.floor(diffMs / 86400000); 704 705 if (diffMins < 1) return 'just now'; 706 if (diffMins < 60) return `${diffMins}m`; 707 if (diffHours < 24) return `${diffHours}h`; 708 if (diffDays < 7) return `${diffDays}d`; 709 return date.toLocaleDateString(); 710 } 711 712 function escapeHtml(text: string): string { 713 const div = document.createElement('div'); 714 div.textContent = text; 715 return div.innerHTML; 716 } 717 718 let lastUrl = window.location.href; 719 function checkUrlChange() { 720 if (window.location.href !== lastUrl) { 721 lastUrl = window.location.href; 722 onUrlChange(); 723 } 724 } 725 726 function onUrlChange() { 727 if (typeof CSS !== 'undefined' && CSS.highlights) { 728 CSS.highlights.clear(); 729 } 730 activeItems = []; 731 cachedMatcher = null; 732 sendMessage('updateBadge', { count: 0 }); 733 if (overlayEnabled) { 734 setTimeout(() => fetchAnnotations(), 300); 735 } 736 } 737 738 window.addEventListener('popstate', onUrlChange); 739 740 const originalPushState = history.pushState; 741 const originalReplaceState = history.replaceState; 742 743 history.pushState = function (...args) { 744 originalPushState.apply(this, args); 745 checkUrlChange(); 746 }; 747 748 history.replaceState = function (...args) { 749 originalReplaceState.apply(this, args); 750 checkUrlChange(); 751 }; 752 753 setInterval(checkUrlChange, 500); 754 755 let domChangeTimeout: ReturnType<typeof setTimeout> | null = null; 756 const observer = new MutationObserver((mutations) => { 757 const hasSignificantChange = mutations.some( 758 (m) => m.type === 'childList' && (m.addedNodes.length > 3 || m.removedNodes.length > 3) 759 ); 760 if (hasSignificantChange && overlayEnabled && activeItems.length === 0) { 761 if (domChangeTimeout) clearTimeout(domChangeTimeout); 762 domChangeTimeout = setTimeout(() => { 763 cachedMatcher = null; 764 fetchAnnotations(); 765 }, 500); 766 } 767 }); 768 observer.observe(document.body || document.documentElement, { 769 childList: true, 770 subtree: true, 771 }); 772 773 ctx.onInvalidated(() => { 774 observer.disconnect(); 775 }); 776 777 window.addEventListener('load', () => { 778 setTimeout(() => fetchAnnotations(), 500); 779 }); 780 }, 781});