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