Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 1224 lines 36 kB view raw
1(() => { 2 let sidebarHost = null; 3 let sidebarShadow = null; 4 let popoverEl = null; 5 6 let activeItems = []; 7 let currentSelection = null; 8 9 const OVERLAY_STYLES = ` 10 :host { 11 all: initial; 12 --bg-primary: #09090b; 13 --bg-secondary: #0f0f12; 14 --bg-tertiary: #18181b; 15 --bg-card: #09090b; 16 --bg-elevated: #18181b; 17 --bg-hover: #27272a; 18 19 --text-primary: #e4e4e7; 20 --text-secondary: #a1a1aa; 21 --border: #27272a; 22 23 --accent: #6366f1; 24 --accent-hover: #4f46e5; 25 } 26 27 :host(.light) { 28 --bg-primary: #ffffff; 29 --bg-secondary: #f4f4f5; 30 --bg-tertiary: #e4e4e7; 31 --bg-card: #ffffff; 32 --bg-elevated: #f4f4f5; 33 --bg-hover: #e4e4e7; 34 35 --text-primary: #18181b; 36 --text-secondary: #52525b; 37 --border: #e4e4e7; 38 39 --accent: #4f46e5; 40 --accent-hover: #4338ca; 41 } 42 43 .margin-overlay { 44 position: absolute; 45 top: 0; 46 left: 0; 47 width: 100%; 48 height: 100%; 49 pointer-events: none; 50 } 51 52 .margin-popover { 53 position: absolute; 54 width: 320px; 55 background: var(--bg-card); 56 border: 1px solid var(--border); 57 border-radius: 12px; 58 padding: 0; 59 box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.2); 60 display: flex; 61 flex-direction: column; 62 pointer-events: auto; 63 z-index: 2147483647; 64 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 65 color: var(--text-primary); 66 opacity: 0; 67 transform: scale(0.95); 68 animation: popover-in 0.15s forwards; 69 max-height: 480px; 70 overflow: hidden; 71 } 72 @keyframes popover-in { to { opacity: 1; transform: scale(1); } } 73 .popover-header { 74 padding: 12px 16px; 75 border-bottom: 1px solid var(--border); 76 display: flex; 77 justify-content: space-between; 78 align-items: center; 79 background: var(--bg-secondary); 80 border-radius: 12px 12px 0 0; 81 font-weight: 600; 82 font-size: 13px; 83 color: var(--text-primary); 84 } 85 .popover-scroll-area { 86 overflow-y: auto; 87 max-height: 400px; 88 } 89 .popover-item-block { 90 border-bottom: 1px solid var(--border); 91 margin-bottom: 0; 92 animation: fade-in 0.2s; 93 } 94 .popover-item-block:last-child { 95 border-bottom: none; 96 } 97 .popover-item-header { 98 padding: 12px 16px 4px; 99 display: flex; 100 align-items: center; 101 gap: 8px; 102 } 103 .popover-avatar { 104 width: 24px; height: 24px; border-radius: 50%; background: var(--bg-hover); 105 display: flex; align-items: center; justify-content: center; 106 font-size: 10px; color: var(--text-secondary); 107 } 108 .popover-handle { font-size: 12px; font-weight: 600; color: var(--text-primary); } 109 .popover-close { background: none; border: none; color: var(--text-secondary); cursor: pointer; padding: 4px; } 110 .popover-close:hover { color: var(--text-primary); } 111 .popover-content { padding: 4px 16px 12px; font-size: 13px; line-height: 1.5; color: var(--text-primary); } 112 .popover-quote { 113 margin-top: 8px; padding: 6px 10px; background: var(--bg-tertiary); 114 border-left: 2px solid var(--accent); border-radius: 4px; 115 font-size: 11px; color: var(--text-secondary); font-style: italic; 116 } 117 .popover-actions { 118 padding: 8px 16px; 119 display: flex; justify-content: flex-end; gap: 8px; 120 } 121 .btn-action { 122 background: none; border: 1px solid var(--border); border-radius: 4px; 123 padding: 4px 8px; color: var(--text-secondary); font-size: 11px; cursor: pointer; 124 } 125 .btn-action:hover { background: var(--bg-hover); color: var(--text-primary); } 126 127 .margin-selection-popup { 128 position: fixed; 129 display: flex; 130 gap: 4px; 131 padding: 6px; 132 background: var(--bg-card); 133 border: 1px solid var(--border); 134 border-radius: 8px; 135 box-shadow: 0 8px 16px rgba(0,0,0,0.4); 136 z-index: 2147483647; 137 pointer-events: auto; 138 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 139 animation: popover-in 0.15s forwards; 140 } 141 .selection-btn { 142 display: flex; 143 align-items: center; 144 gap: 6px; 145 padding: 6px 12px; 146 background: transparent; 147 border: none; 148 border-radius: 6px; 149 color: var(--text-primary); 150 font-size: 12px; 151 font-weight: 500; 152 cursor: pointer; 153 transition: background 0.15s; 154 } 155 .selection-btn:hover { 156 background: var(--bg-hover); 157 } 158 .selection-btn svg { 159 width: 14px; 160 height: 14px; 161 } 162 .inline-compose-modal { 163 position: fixed; 164 width: 340px; 165 max-width: calc(100vw - 40px); 166 background: var(--bg-card); 167 border: 1px solid var(--border); 168 border-radius: 12px; 169 padding: 16px; 170 box-sizing: border-box; 171 box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5); 172 z-index: 2147483647; 173 pointer-events: auto; 174 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 175 color: var(--text-primary); 176 animation: popover-in 0.15s forwards; 177 overflow: hidden; 178 } 179 .inline-compose-modal * { 180 box-sizing: border-box; 181 } 182 .inline-compose-quote { 183 padding: 8px 12px; 184 background: var(--bg-tertiary); 185 border-left: 3px solid var(--accent); 186 border-radius: 4px; 187 font-size: 12px; 188 color: var(--text-secondary); 189 font-style: italic; 190 margin-bottom: 12px; 191 max-height: 60px; 192 overflow: hidden; 193 word-break: break-word; 194 } 195 .inline-compose-textarea { 196 width: 100%; 197 min-height: 80px; 198 padding: 10px 12px; 199 background: var(--bg-elevated); 200 border: 1px solid var(--border); 201 border-radius: 8px; 202 color: var(--text-primary); 203 font-family: inherit; 204 font-size: 13px; 205 resize: vertical; 206 margin-bottom: 12px; 207 box-sizing: border-box; 208 } 209 .inline-compose-textarea:focus { 210 outline: none; 211 border-color: var(--accent); 212 } 213 .inline-compose-actions { 214 display: flex; 215 justify-content: flex-end; 216 gap: 8px; 217 } 218 .btn-cancel { 219 padding: 8px 16px; 220 background: transparent; 221 border: 1px solid var(--border); 222 border-radius: 6px; 223 color: var(--text-secondary); 224 font-size: 13px; 225 cursor: pointer; 226 } 227 .btn-cancel:hover { 228 background: var(--bg-hover); 229 color: var(--text-primary); 230 } 231 .btn-submit { 232 padding: 8px 16px; 233 background: var(--accent); 234 border: none; 235 border-radius: 6px; 236 color: white; 237 font-size: 13px; 238 font-weight: 500; 239 cursor: pointer; 240 } 241 .btn-submit:hover { 242 background: var(--accent-hover); 243 } 244 .btn-submit:disabled { 245 opacity: 0.5; 246 cursor: not-allowed; 247 } 248 .reply-section { 249 border-top: 1px solid var(--border); 250 padding: 12px 16px; 251 background: var(--bg-secondary); 252 border-radius: 0 0 12px 12px; 253 } 254 .reply-textarea { 255 width: 100%; 256 min-height: 60px; 257 padding: 8px 10px; 258 background: var(--bg-elevated); 259 border: 1px solid var(--border); 260 border-radius: 6px; 261 color: var(--text-primary); 262 font-family: inherit; 263 font-size: 12px; 264 resize: none; 265 margin-bottom: 8px; 266 } 267 .reply-textarea:focus { 268 outline: none; 269 border-color: var(--accent); 270 } 271 .reply-submit { 272 padding: 6px 12px; 273 background: var(--accent); 274 border: none; 275 border-radius: 4px; 276 color: white; 277 font-size: 11px; 278 font-weight: 500; 279 cursor: pointer; 280 float: right; 281 } 282 .reply-submit:disabled { 283 opacity: 0.5; 284 } 285 .reply-item { 286 padding: 8px 0; 287 border-top: 1px solid var(--border); 288 } 289 .reply-item:first-child { 290 border-top: none; 291 } 292 .reply-author { 293 font-size: 11px; 294 font-weight: 600; 295 color: var(--text-secondary); 296 margin-bottom: 4px; 297 } 298 .reply-text { 299 font-size: 12px; 300 color: var(--text-primary); 301 line-height: 1.4; 302 } 303 `; 304 305 class DOMTextMatcher { 306 constructor() { 307 this.textNodes = []; 308 this.corpus = ""; 309 this.indices = []; 310 this.buildMap(); 311 } 312 313 buildMap() { 314 const walker = document.createTreeWalker( 315 document.body, 316 NodeFilter.SHOW_TEXT, 317 { 318 acceptNode: (node) => { 319 if (!node.parentNode) return NodeFilter.FILTER_REJECT; 320 const tag = node.parentNode.tagName; 321 if ( 322 ["SCRIPT", "STYLE", "NOSCRIPT", "TEXTAREA", "INPUT"].includes(tag) 323 ) 324 return NodeFilter.FILTER_REJECT; 325 if (node.textContent.trim().length === 0) 326 return NodeFilter.FILTER_SKIP; 327 328 if (node.parentNode.offsetParent === null) 329 return NodeFilter.FILTER_REJECT; 330 331 return NodeFilter.FILTER_ACCEPT; 332 }, 333 }, 334 ); 335 336 let currentNode; 337 let index = 0; 338 while ((currentNode = walker.nextNode())) { 339 const text = currentNode.textContent; 340 this.textNodes.push(currentNode); 341 this.corpus += text; 342 this.indices.push({ 343 start: index, 344 node: currentNode, 345 length: text.length, 346 }); 347 index += text.length; 348 } 349 } 350 351 findRange(searchText) { 352 if (!searchText) return null; 353 354 let matchIndex = this.corpus.indexOf(searchText); 355 356 if (matchIndex === -1) { 357 const normalizedSearch = searchText.replace(/\s+/g, " ").trim(); 358 matchIndex = this.corpus.indexOf(normalizedSearch); 359 360 if (matchIndex === -1) { 361 const fuzzyMatch = this.fuzzyFindInCorpus(searchText); 362 if (fuzzyMatch) { 363 const start = this.mapIndexToPoint(fuzzyMatch.start); 364 const end = this.mapIndexToPoint(fuzzyMatch.end); 365 if (start && end) { 366 const range = document.createRange(); 367 range.setStart(start.node, start.offset); 368 range.setEnd(end.node, end.offset); 369 return range; 370 } 371 } 372 return null; 373 } 374 } 375 376 const start = this.mapIndexToPoint(matchIndex); 377 const end = this.mapIndexToPoint(matchIndex + searchText.length); 378 379 if (start && end) { 380 const range = document.createRange(); 381 range.setStart(start.node, start.offset); 382 range.setEnd(end.node, end.offset); 383 return range; 384 } 385 return null; 386 } 387 388 fuzzyFindInCorpus(searchText) { 389 const searchWords = searchText 390 .trim() 391 .split(/\s+/) 392 .filter((w) => w.length > 0); 393 if (searchWords.length === 0) return null; 394 395 const corpusLower = this.corpus.toLowerCase(); 396 397 const firstWord = searchWords[0].toLowerCase(); 398 let searchStart = 0; 399 400 while (searchStart < corpusLower.length) { 401 const wordStart = corpusLower.indexOf(firstWord, searchStart); 402 if (wordStart === -1) break; 403 404 let corpusPos = wordStart; 405 let matched = true; 406 let lastMatchEnd = wordStart; 407 408 for (const word of searchWords) { 409 const wordLower = word.toLowerCase(); 410 while ( 411 corpusPos < corpusLower.length && 412 /\s/.test(this.corpus[corpusPos]) 413 ) { 414 corpusPos++; 415 } 416 const corpusSlice = corpusLower.slice( 417 corpusPos, 418 corpusPos + wordLower.length, 419 ); 420 if (corpusSlice !== wordLower) { 421 matched = false; 422 break; 423 } 424 425 corpusPos += wordLower.length; 426 lastMatchEnd = corpusPos; 427 } 428 429 if (matched) { 430 return { start: wordStart, end: lastMatchEnd }; 431 } 432 433 searchStart = wordStart + 1; 434 } 435 436 return null; 437 } 438 439 mapIndexToPoint(corpusIndex) { 440 for (const info of this.indices) { 441 if ( 442 corpusIndex >= info.start && 443 corpusIndex < info.start + info.length 444 ) { 445 return { node: info.node, offset: corpusIndex - info.start }; 446 } 447 } 448 if (this.indices.length > 0) { 449 const last = this.indices[this.indices.length - 1]; 450 if (corpusIndex === last.start + last.length) { 451 return { node: last.node, offset: last.length }; 452 } 453 } 454 return null; 455 } 456 } 457 458 function applyTheme(theme) { 459 if (!sidebarHost) return; 460 sidebarHost.classList.remove("light", "dark"); 461 if (theme === "system" || !theme) { 462 if (window.matchMedia("(prefers-color-scheme: light)").matches) { 463 sidebarHost.classList.add("light"); 464 } 465 } else { 466 sidebarHost.classList.add(theme); 467 } 468 } 469 470 window 471 .matchMedia("(prefers-color-scheme: light)") 472 .addEventListener("change", (e) => { 473 chrome.storage.local.get(["theme"], (result) => { 474 if (!result.theme || result.theme === "system") { 475 if (e.matches) { 476 sidebarHost?.classList.add("light"); 477 } else { 478 sidebarHost?.classList.remove("light"); 479 } 480 } 481 }); 482 }); 483 484 function initOverlay() { 485 sidebarHost = document.createElement("div"); 486 sidebarHost.id = "margin-overlay-host"; 487 sidebarHost.style.cssText = ` 488 position: absolute; top: 0; left: 0; width: 100%; 489 height: 0; 490 overflow: visible; 491 pointer-events: none; z-index: 2147483647; 492 `; 493 document.body?.appendChild(sidebarHost) || 494 document.documentElement.appendChild(sidebarHost); 495 496 sidebarShadow = sidebarHost.attachShadow({ mode: "open" }); 497 const styleEl = document.createElement("style"); 498 styleEl.textContent = OVERLAY_STYLES; 499 sidebarShadow.appendChild(styleEl); 500 501 const container = document.createElement("div"); 502 container.className = "margin-overlay"; 503 container.id = "margin-overlay-container"; 504 sidebarShadow.appendChild(container); 505 506 if (typeof chrome !== "undefined" && chrome.storage) { 507 chrome.storage.local.get(["showOverlay", "theme"], (result) => { 508 applyTheme(result.theme); 509 if (result.showOverlay === false) { 510 sidebarHost.style.display = "none"; 511 } else { 512 fetchAnnotations(); 513 } 514 }); 515 } else { 516 fetchAnnotations(); 517 } 518 519 document.addEventListener("mousemove", handleMouseMove); 520 document.addEventListener("click", handleDocumentClick, true); 521 522 chrome.storage.onChanged.addListener((changes, area) => { 523 if (area === "local") { 524 if (changes.theme) { 525 applyTheme(changes.theme.newValue); 526 } 527 if (changes.showOverlay) { 528 if (changes.showOverlay.newValue === false) { 529 sidebarHost.style.display = "none"; 530 activeItems = []; 531 if (typeof CSS !== "undefined" && CSS.highlights) { 532 CSS.highlights.clear(); 533 } 534 } else { 535 sidebarHost.style.display = ""; 536 fetchAnnotations(); 537 } 538 } 539 } 540 }); 541 } 542 543 function showInlineComposeModal() { 544 if (!sidebarShadow || !currentSelection) return; 545 546 const container = sidebarShadow.getElementById("margin-overlay-container"); 547 if (!container) return; 548 549 const existingModal = container.querySelector(".inline-compose-modal"); 550 if (existingModal) existingModal.remove(); 551 552 const modal = document.createElement("div"); 553 modal.className = "inline-compose-modal"; 554 555 modal.style.left = `${Math.max(20, (window.innerWidth - 340) / 2)}px`; 556 modal.style.top = `${Math.min(200, window.innerHeight / 4)}px`; 557 558 const truncatedQuote = 559 currentSelection.text.length > 100 560 ? currentSelection.text.substring(0, 100) + "..." 561 : currentSelection.text; 562 563 modal.innerHTML = ` 564 <div class="inline-compose-quote">"${truncatedQuote}"</div> 565 <textarea class="inline-compose-textarea" placeholder="Add your annotation..." autofocus></textarea> 566 <div class="inline-compose-actions"> 567 <button class="btn-cancel">Cancel</button> 568 <button class="btn-submit">Post Annotation</button> 569 </div> 570 `; 571 572 const textarea = modal.querySelector("textarea"); 573 const submitBtn = modal.querySelector(".btn-submit"); 574 const cancelBtn = modal.querySelector(".btn-cancel"); 575 576 cancelBtn.addEventListener("click", () => { 577 modal.remove(); 578 }); 579 580 submitBtn.addEventListener("click", async () => { 581 const text = textarea.value.trim(); 582 if (!text) return; 583 584 submitBtn.disabled = true; 585 submitBtn.textContent = "Posting..."; 586 587 chrome.runtime.sendMessage( 588 { 589 type: "CREATE_ANNOTATION", 590 data: { 591 url: currentSelection.url || window.location.href, 592 title: currentSelection.title || document.title, 593 text: text, 594 selector: currentSelection.selector, 595 }, 596 }, 597 (res) => { 598 if (res && res.success) { 599 modal.remove(); 600 fetchAnnotations(); 601 } else { 602 submitBtn.disabled = false; 603 submitBtn.textContent = "Post Annotation"; 604 alert( 605 "Failed to create annotation: " + (res?.error || "Unknown error"), 606 ); 607 } 608 }, 609 ); 610 }); 611 612 container.appendChild(modal); 613 textarea.focus(); 614 615 const handleEscape = (e) => { 616 if (e.key === "Escape") { 617 modal.remove(); 618 document.removeEventListener("keydown", handleEscape); 619 } 620 }; 621 document.addEventListener("keydown", handleEscape); 622 } 623 624 let hoverIndicator = null; 625 626 function handleMouseMove(e) { 627 const x = e.clientX; 628 const y = e.clientY; 629 630 if (sidebarHost && sidebarHost.style.display === "none") return; 631 632 let foundItems = []; 633 let firstRange = null; 634 for (const { range, item } of activeItems) { 635 const rects = range.getClientRects(); 636 for (const rect of rects) { 637 if ( 638 x >= rect.left && 639 x <= rect.right && 640 y >= rect.top && 641 y <= rect.bottom 642 ) { 643 let container = range.commonAncestorContainer; 644 if (container.nodeType === Node.TEXT_NODE) { 645 container = container.parentNode; 646 } 647 648 if ( 649 container && 650 (e.target.contains(container) || container.contains(e.target)) 651 ) { 652 if (!firstRange) firstRange = range; 653 if (!foundItems.some((f) => f.item === item)) { 654 foundItems.push({ range, item, rect }); 655 } 656 } 657 break; 658 } 659 } 660 } 661 662 if (foundItems.length > 0) { 663 document.body.style.cursor = "pointer"; 664 665 if (!hoverIndicator && sidebarShadow) { 666 const container = sidebarShadow.getElementById( 667 "margin-overlay-container", 668 ); 669 if (container) { 670 hoverIndicator = document.createElement("div"); 671 hoverIndicator.className = "margin-hover-indicator"; 672 hoverIndicator.style.cssText = ` 673 position: fixed; 674 display: flex; 675 align-items: center; 676 pointer-events: none; 677 z-index: 2147483647; 678 opacity: 0; 679 transition: opacity 0.15s, transform 0.15s; 680 transform: scale(0.8); 681 `; 682 container.appendChild(hoverIndicator); 683 } 684 } 685 686 if (hoverIndicator) { 687 const authorsMap = new Map(); 688 foundItems.forEach(({ item }) => { 689 const author = item.author || item.creator || {}; 690 const id = author.did || author.handle || "unknown"; 691 if (!authorsMap.has(id)) { 692 authorsMap.set(id, author); 693 } 694 }); 695 const uniqueAuthors = Array.from(authorsMap.values()); 696 697 const maxShow = 3; 698 const displayAuthors = uniqueAuthors.slice(0, maxShow); 699 const overflow = uniqueAuthors.length - maxShow; 700 701 let html = displayAuthors 702 .map((author, i) => { 703 const avatar = author.avatar; 704 const handle = author.handle || "U"; 705 const marginLeft = i === 0 ? "0" : "-8px"; 706 707 if (avatar) { 708 return `<img src="${avatar}" style="width: 24px; height: 24px; border-radius: 50%; object-fit: cover; border: 2px solid #09090b; margin-left: ${marginLeft};">`; 709 } else { 710 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>`; 711 } 712 }) 713 .join(""); 714 715 if (overflow > 0) { 716 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>`; 717 } 718 719 hoverIndicator.innerHTML = html; 720 721 const firstRect = firstRange.getClientRects()[0]; 722 const totalWidth = 723 Math.min(uniqueAuthors.length, maxShow + (overflow > 0 ? 1 : 0)) * 724 18 + 725 8; 726 const leftPos = firstRect.left - totalWidth; 727 const topPos = firstRect.top + firstRect.height / 2 - 12; 728 729 hoverIndicator.style.left = `${leftPos}px`; 730 hoverIndicator.style.top = `${topPos}px`; 731 hoverIndicator.style.opacity = "1"; 732 hoverIndicator.style.transform = "scale(1)"; 733 } 734 } else { 735 document.body.style.cursor = ""; 736 if (hoverIndicator) { 737 hoverIndicator.style.opacity = "0"; 738 hoverIndicator.style.transform = "scale(0.8)"; 739 } 740 } 741 } 742 743 function handleDocumentClick(e) { 744 const x = e.clientX; 745 const y = e.clientY; 746 747 if (sidebarHost && sidebarHost.style.display === "none") return; 748 749 if (popoverEl && sidebarShadow) { 750 const rect = popoverEl.getBoundingClientRect(); 751 if ( 752 x >= rect.left && 753 x <= rect.right && 754 y >= rect.top && 755 y <= rect.bottom 756 ) { 757 return; 758 } 759 } 760 761 let clickedItems = []; 762 for (const { range, item } of activeItems) { 763 const rects = range.getClientRects(); 764 for (const rect of rects) { 765 if ( 766 x >= rect.left && 767 x <= rect.right && 768 y >= rect.top && 769 y <= rect.bottom 770 ) { 771 let container = range.commonAncestorContainer; 772 if (container.nodeType === Node.TEXT_NODE) { 773 container = container.parentNode; 774 } 775 776 if ( 777 container && 778 (e.target.contains(container) || container.contains(e.target)) 779 ) { 780 if (!clickedItems.includes(item)) { 781 clickedItems.push(item); 782 } 783 } 784 break; 785 } 786 } 787 } 788 789 if (clickedItems.length > 0) { 790 e.preventDefault(); 791 e.stopPropagation(); 792 793 if (popoverEl) { 794 const currentIds = popoverEl.dataset.itemIds; 795 const newIds = clickedItems 796 .map((i) => i.uri || i.id) 797 .sort() 798 .join(","); 799 800 if (currentIds === newIds) { 801 popoverEl.remove(); 802 popoverEl = null; 803 return; 804 } 805 } 806 807 const firstItem = clickedItems[0]; 808 const match = activeItems.find((x) => x.item === firstItem); 809 if (match) { 810 const rects = match.range.getClientRects(); 811 if (rects.length > 0) { 812 const rect = rects[0]; 813 const top = rect.top + window.scrollY; 814 const left = rect.left + window.scrollX; 815 showPopover(clickedItems, top, left); 816 } 817 } 818 } else { 819 if (popoverEl) { 820 popoverEl.remove(); 821 popoverEl = null; 822 } 823 } 824 } 825 826 function renderBadges(annotations) { 827 if (!sidebarShadow) return; 828 829 const itemsToRender = annotations || []; 830 activeItems = []; 831 const rangesByColor = {}; 832 833 const matcher = new DOMTextMatcher(); 834 835 itemsToRender.forEach((item) => { 836 const selector = item.target?.selector || item.selector; 837 if (!selector?.exact) return; 838 839 const range = matcher.findRange(selector.exact); 840 if (range) { 841 activeItems.push({ range, item }); 842 843 const color = item.color || "#6366f1"; 844 if (!rangesByColor[color]) rangesByColor[color] = []; 845 rangesByColor[color].push(range); 846 } 847 }); 848 849 if (typeof CSS !== "undefined" && CSS.highlights) { 850 CSS.highlights.clear(); 851 for (const [color, ranges] of Object.entries(rangesByColor)) { 852 const highlight = new Highlight(...ranges); 853 const safeColor = color.replace(/[^a-zA-Z0-9]/g, ""); 854 const name = `margin-hl-${safeColor}`; 855 CSS.highlights.set(name, highlight); 856 injectHighlightStyle(name, color); 857 } 858 } 859 } 860 861 const injectedStyles = new Set(); 862 function injectHighlightStyle(name, color) { 863 if (injectedStyles.has(name)) return; 864 const style = document.createElement("style"); 865 style.textContent = ` 866 ::highlight(${name}) { 867 text-decoration: underline; 868 text-decoration-color: ${color}; 869 text-decoration-thickness: 2px; 870 text-underline-offset: 2px; 871 cursor: pointer; 872 } 873 `; 874 document.head.appendChild(style); 875 injectedStyles.add(name); 876 } 877 878 function showPopover(items, top, left) { 879 if (popoverEl) popoverEl.remove(); 880 const container = sidebarShadow.getElementById("margin-overlay-container"); 881 popoverEl = document.createElement("div"); 882 popoverEl.className = "margin-popover"; 883 884 const ids = items 885 .map((i) => i.uri || i.id) 886 .sort() 887 .join(","); 888 popoverEl.dataset.itemIds = ids; 889 890 const popWidth = 320; 891 const screenWidth = window.innerWidth; 892 let finalLeft = left; 893 if (left + popWidth > screenWidth) finalLeft = screenWidth - popWidth - 20; 894 895 popoverEl.style.top = `${top + 20}px`; 896 popoverEl.style.left = `${finalLeft}px`; 897 898 const hasHighlights = items.some((item) => item.type === "Highlight"); 899 const hasAnnotations = items.some((item) => item.type !== "Highlight"); 900 let title; 901 if (items.length > 1) { 902 if (hasHighlights && hasAnnotations) { 903 title = `${items.length} Items`; 904 } else if (hasHighlights) { 905 title = `${items.length} Highlights`; 906 } else { 907 title = `${items.length} Annotations`; 908 } 909 } else { 910 title = items[0]?.type === "Highlight" ? "Highlight" : "Annotation"; 911 } 912 913 let contentHtml = items 914 .map((item) => { 915 const author = item.author || item.creator || {}; 916 const handle = author.handle || "User"; 917 const avatar = author.avatar; 918 const text = item.body?.value || item.text || ""; 919 const quote = 920 item.target?.selector?.exact || item.selector?.exact || ""; 921 const id = item.id || item.uri; 922 923 let avatarHtml = `<div class="popover-avatar">${handle[0]?.toUpperCase() || "U"}</div>`; 924 if (avatar) { 925 avatarHtml = `<img src="${avatar}" class="popover-avatar" style="object-fit: cover;">`; 926 } 927 928 const isHighlight = item.type === "Highlight"; 929 930 let bodyHtml = ""; 931 if (isHighlight) { 932 bodyHtml = `<div class="popover-text" style="font-style: italic; color: #a1a1aa;">"${quote}"</div>`; 933 } else { 934 bodyHtml = `<div class="popover-text">${text}</div>`; 935 if (quote) { 936 bodyHtml += `<div class="popover-quote">"${quote}"</div>`; 937 } 938 } 939 940 return ` 941 <div class="popover-item-block"> 942 <div class="popover-item-header"> 943 <div class="popover-author"> 944 ${avatarHtml} 945 <span class="popover-handle">@${handle}</span> 946 </div> 947 </div> 948 <div class="popover-content"> 949 ${bodyHtml} 950 </div> 951 <div class="popover-actions"> 952 ${!isHighlight ? `<button class="btn-action btn-reply" data-id="${id}">Reply</button>` : ""} 953 <button class="btn-action btn-share" data-id="${id}" data-text="${text}" data-quote="${quote}">Share</button> 954 </div> 955 </div> 956 `; 957 }) 958 .join(""); 959 960 popoverEl.innerHTML = ` 961 <div class="popover-header"> 962 <span>${title}</span> 963 <button class="popover-close">✕</button> 964 </div> 965 <div class="popover-scroll-area"> 966 ${contentHtml} 967 </div> 968 `; 969 970 popoverEl.querySelector(".popover-close").addEventListener("click", (e) => { 971 e.stopPropagation(); 972 popoverEl.remove(); 973 popoverEl = null; 974 }); 975 976 const replyBtns = popoverEl.querySelectorAll(".btn-reply"); 977 replyBtns.forEach((btn) => { 978 btn.addEventListener("click", (e) => { 979 e.stopPropagation(); 980 const id = btn.getAttribute("data-id"); 981 if (id) { 982 chrome.runtime.sendMessage({ 983 type: "OPEN_APP_URL", 984 data: { path: `/annotation/${encodeURIComponent(id)}` }, 985 }); 986 } 987 }); 988 }); 989 990 const shareBtns = popoverEl.querySelectorAll(".btn-share"); 991 shareBtns.forEach((btn) => { 992 btn.addEventListener("click", async () => { 993 const id = btn.getAttribute("data-id"); 994 const text = btn.getAttribute("data-text"); 995 const quote = btn.getAttribute("data-quote"); 996 const u = `https://margin.at/annotation/${encodeURIComponent(id)}`; 997 const shareText = `${text ? text + "\n" : ""}${quote ? `"${quote}"\n` : ""}${u}`; 998 999 try { 1000 await navigator.clipboard.writeText(shareText); 1001 const originalText = btn.innerText; 1002 btn.innerText = "Copied!"; 1003 setTimeout(() => (btn.innerText = originalText), 2000); 1004 } catch (e) { 1005 console.error("Failed to copy", e); 1006 } 1007 }); 1008 }); 1009 1010 container.appendChild(popoverEl); 1011 1012 setTimeout(() => { 1013 document.addEventListener("click", closePopoverOutside); 1014 }, 0); 1015 } 1016 1017 function closePopoverOutside() { 1018 if (popoverEl) { 1019 popoverEl.remove(); 1020 popoverEl = null; 1021 document.removeEventListener("click", closePopoverOutside); 1022 } 1023 } 1024 1025 function fetchAnnotations(retryCount = 0) { 1026 if (typeof chrome !== "undefined" && chrome.runtime) { 1027 const citedUrls = Array.from(document.querySelectorAll("[cite]")) 1028 .map((el) => el.getAttribute("cite")) 1029 .filter((url) => url && url.startsWith("http")); 1030 const uniqueCitedUrls = [...new Set(citedUrls)]; 1031 1032 chrome.runtime.sendMessage( 1033 { 1034 type: "GET_ANNOTATIONS", 1035 data: { 1036 url: window.location.href, 1037 citedUrls: uniqueCitedUrls, 1038 }, 1039 }, 1040 (res) => { 1041 if (res && res.success && res.data && res.data.length > 0) { 1042 renderBadges(res.data); 1043 } else if (retryCount < 3) { 1044 setTimeout( 1045 () => fetchAnnotations(retryCount + 1), 1046 1000 * (retryCount + 1), 1047 ); 1048 } 1049 }, 1050 ); 1051 } 1052 } 1053 1054 function findCanonicalUrl(range) { 1055 if (!range) return null; 1056 let node = range.commonAncestorContainer; 1057 if (node.nodeType === Node.TEXT_NODE) { 1058 node = node.parentNode; 1059 } 1060 1061 while (node && node !== document.body) { 1062 if ( 1063 (node.tagName === "BLOCKQUOTE" || node.tagName === "Q") && 1064 node.hasAttribute("cite") 1065 ) { 1066 if (node.contains(range.commonAncestorContainer)) { 1067 return node.getAttribute("cite"); 1068 } 1069 } 1070 node = node.parentNode; 1071 } 1072 return null; 1073 } 1074 1075 chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 1076 if (request.type === "GET_SELECTOR_FOR_ANNOTATE_INLINE") { 1077 const sel = window.getSelection(); 1078 if (!sel || !sel.toString()) { 1079 sendResponse({ selector: null }); 1080 return true; 1081 } 1082 const exact = sel.toString().trim(); 1083 const canonicalUrl = findCanonicalUrl(sel.getRangeAt(0)); 1084 1085 sendResponse({ 1086 selector: { type: "TextQuoteSelector", exact }, 1087 canonicalUrl, 1088 }); 1089 return true; 1090 } 1091 1092 if (request.type === "SHOW_INLINE_ANNOTATE") { 1093 currentSelection = { 1094 text: request.data.selector?.exact || "", 1095 selector: request.data.selector, 1096 url: request.data.url, 1097 title: request.data.title, 1098 }; 1099 showInlineComposeModal(); 1100 sendResponse({ success: true }); 1101 return true; 1102 } 1103 1104 if (request.type === "GET_SELECTOR_FOR_HIGHLIGHT") { 1105 const sel = window.getSelection(); 1106 if (!sel || !sel.toString().trim()) { 1107 sendResponse({ success: false, selector: null }); 1108 return true; 1109 } 1110 const exact = sel.toString().trim(); 1111 const canonicalUrl = findCanonicalUrl(sel.getRangeAt(0)); 1112 1113 sendResponse({ 1114 success: false, 1115 selector: { type: "TextQuoteSelector", exact }, 1116 canonicalUrl, 1117 }); 1118 return true; 1119 } 1120 1121 if (request.type === "REFRESH_ANNOTATIONS") { 1122 fetchAnnotations(); 1123 sendResponse({ success: true }); 1124 return true; 1125 } 1126 1127 if (request.type === "UPDATE_OVERLAY_VISIBILITY") { 1128 if (sidebarHost) { 1129 sidebarHost.style.display = request.show ? "block" : "none"; 1130 } 1131 if (request.show) { 1132 fetchAnnotations(); 1133 } else { 1134 activeItems = []; 1135 if (typeof CSS !== "undefined" && CSS.highlights) { 1136 CSS.highlights.clear(); 1137 } 1138 } 1139 sendResponse({ success: true }); 1140 return true; 1141 } 1142 1143 if (request.type === "SCROLL_TO_TEXT") { 1144 const selector = request.selector; 1145 if (selector?.exact) { 1146 const matcher = new DOMTextMatcher(); 1147 const range = matcher.findRange(selector.exact); 1148 if (range) { 1149 const rect = range.getBoundingClientRect(); 1150 window.scrollTo({ 1151 top: window.scrollY + rect.top - window.innerHeight / 3, 1152 behavior: "smooth", 1153 }); 1154 const highlight = new Highlight(range); 1155 CSS.highlights.set("margin-scroll-flash", highlight); 1156 injectHighlightStyle("margin-scroll-flash", "#8b5cf6"); 1157 setTimeout(() => CSS.highlights.delete("margin-scroll-flash"), 2000); 1158 } 1159 } 1160 } 1161 return true; 1162 }); 1163 1164 if (document.readyState === "loading") { 1165 document.addEventListener("DOMContentLoaded", initOverlay); 1166 } else { 1167 initOverlay(); 1168 } 1169 1170 window.addEventListener("load", () => { 1171 if (typeof chrome !== "undefined" && chrome.storage) { 1172 chrome.storage.local.get(["showOverlay"], (result) => { 1173 if (result.showOverlay !== false) { 1174 setTimeout(() => fetchAnnotations(), 500); 1175 } 1176 }); 1177 } else { 1178 setTimeout(() => fetchAnnotations(), 500); 1179 } 1180 }); 1181 1182 let lastUrl = window.location.href; 1183 1184 function checkUrlChange() { 1185 if (window.location.href !== lastUrl) { 1186 lastUrl = window.location.href; 1187 onUrlChange(); 1188 } 1189 } 1190 1191 function onUrlChange() { 1192 if (typeof CSS !== "undefined" && CSS.highlights) { 1193 CSS.highlights.clear(); 1194 } 1195 activeItems = []; 1196 1197 if (typeof chrome !== "undefined" && chrome.storage) { 1198 chrome.storage.local.get(["showOverlay"], (result) => { 1199 if (result.showOverlay !== false) { 1200 fetchAnnotations(); 1201 } 1202 }); 1203 } else { 1204 fetchAnnotations(); 1205 } 1206 } 1207 1208 window.addEventListener("popstate", onUrlChange); 1209 1210 const originalPushState = history.pushState; 1211 const originalReplaceState = history.replaceState; 1212 1213 history.pushState = function (...args) { 1214 originalPushState.apply(this, args); 1215 checkUrlChange(); 1216 }; 1217 1218 history.replaceState = function (...args) { 1219 originalReplaceState.apply(this, args); 1220 checkUrlChange(); 1221 }; 1222 1223 setInterval(checkUrlChange, 1000); 1224})();