Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
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});