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})();