Social Annotations in the Atmosphere
15
fork

Configure Feed

Select the types of activity you want to include in your feed.

WIP: shared arcitecture

+896 -337
+12 -215
entrypoints/background.ts
··· 1 + import { BrowserStorageAdapter, ExtensionBackgroundWorker } from '@seams/core'; 1 2 import { loadSession } from '@/lib/oauth'; 2 3 import { listAnnotations, listComments } from '@/lib/pds'; 3 4 4 5 const BACKEND_URL = import.meta.env.BACKEND_URL || 'http://localhost:8080'; 5 6 6 7 export default defineBackground(() => { 7 - let syncing = false; 8 - 9 - function normalizeUrl(url: string): string { 10 - try { 11 - const parsed = new URL(url); 12 - // Remove fragment 13 - parsed.hash = ''; 14 - // Remove trailing slash 15 - let path = parsed.pathname; 16 - if (path.endsWith('/') && path !== '/') { 17 - path = path.slice(0, -1); 18 - } 19 - parsed.pathname = path; 20 - return parsed.toString(); 21 - } catch { 22 - return url; 23 - } 24 - } 25 - 26 - // Sync on startup 27 - browser.runtime.onStartup.addListener(async () => { 28 - console.log('[background] Extension startup, syncing from PDS...'); 29 - await syncUserAnnotations(); 30 - }); 31 - 32 - // Sync on install (first run) 33 - browser.runtime.onInstalled.addListener(async () => { 34 - console.log('[background] Extension installed, syncing from PDS...'); 35 - await syncUserAnnotations(); 36 - }); 37 - 38 - // Open sidepanel when extension icon is clicked 39 - const actionApi = browser.action || browser.browserAction; 40 - if (actionApi) { 41 - actionApi.onClicked.addListener(async (tab) => { 42 - if (tab.id) { 43 - // Chrome API 44 - if (browser.sidePanel) { 45 - await browser.sidePanel.open({ tabId: tab.id }); 46 - } 47 - } 48 - }); 49 - } 50 - 51 - // Pre-fetch annotations when tab becomes active 52 - browser.tabs.onActivated.addListener(async (activeInfo) => { 53 - try { 54 - const tab = await browser.tabs.get(activeInfo.tabId); 55 - if (tab.url && !tab.url.startsWith('chrome://') && !tab.url.startsWith('about:')) { 56 - const normalized = normalizeUrl(tab.url); 57 - console.log('[background] Tab activated, pre-fetching annotations for', normalized); 58 - await fetchAnnotationsForUrl(normalized); 59 - } 60 - } catch (error) { 61 - console.error('[background] Failed to pre-fetch on tab activation:', error); 62 - } 8 + const storage = new BrowserStorageAdapter(); 9 + 10 + const worker = new ExtensionBackgroundWorker({ 11 + storage, 12 + fetchAnnotationsForUrl: async (url: string) => { 13 + return []; 14 + }, 15 + fetchUserAnnotations: listAnnotations, 16 + fetchComments: listComments, 17 + backendUrl: BACKEND_URL, 63 18 }); 64 19 65 - // Pre-fetch annotations when tab URL updates (full page navigation) 66 - browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { 67 - // Only fetch when URL actually changes and page is loaded 68 - if (changeInfo.status === 'complete' && tab.url && !tab.url.startsWith('chrome://') && !tab.url.startsWith('about:')) { 69 - const normalized = normalizeUrl(tab.url); 70 - console.log('[background] Tab updated, pre-fetching annotations for', normalized); 71 - await fetchAnnotationsForUrl(normalized); 72 - 73 - // Notify content script of URL change 74 - browser.tabs.sendMessage(tabId, { 75 - type: 'URL_CHANGED', 76 - url: normalized 77 - }).catch(() => { 78 - // Content script might not be ready yet 79 - }); 80 - } 81 - }); 82 - 83 - // Detect SPA navigation (history.pushState/replaceState) 84 - if (browser.webNavigation) { 85 - console.log('[background] Setting up webNavigation.onHistoryStateUpdated listener'); 86 - browser.webNavigation.onHistoryStateUpdated.addListener(async (details) => { 87 - if (details.frameId !== 0) return; // Only main frame 88 - 89 - const normalized = normalizeUrl(details.url); 90 - console.log('[background] SPA navigation detected:', normalized); 91 - await fetchAnnotationsForUrl(normalized); 92 - 93 - // Notify content script of URL change 94 - browser.tabs.sendMessage(details.tabId, { 95 - type: 'URL_CHANGED', 96 - url: normalized 97 - }).catch(() => { 98 - console.log('[background] Failed to notify content script'); 99 - }); 100 - }); 101 - } else { 102 - console.error('[background] browser.webNavigation is not available!'); 103 - } 104 - 105 - // Handle messages 106 - browser.runtime.onMessage.addListener((message, sender, sendResponse) => { 107 - if (message.type === 'SYNC_CACHE') { 108 - console.log('[background] SYNC_CACHE requested'); 109 - syncUserAnnotations(); // fire-and-forget 110 - sendResponse({ success: true }); 111 - } 112 - 113 - if (message.type === 'FETCH_ANNOTATIONS_FOR_URL') { 114 - console.log('[background] FETCH_ANNOTATIONS_FOR_URL requested for', message.url); 115 - fetchAnnotationsForUrl(message.url) 116 - .then(() => sendResponse({ success: true })) 117 - .catch(err => sendResponse({ success: false, error: err.message })); 118 - return true; // Keep channel open for async response 119 - } 120 - }); 121 - 122 - async function fetchAnnotationsForUrl(url: string) { 123 - console.log(`[background] Fetching annotations for ${url}`); 124 - 125 - try { 126 - const response = await fetch( 127 - `${BACKEND_URL}/api/annotations?url=${encodeURIComponent(url)}&limit=100` 128 - ); 129 - 130 - if (!response.ok) { 131 - throw new Error(`Backend error: ${response.status}`); 132 - } 133 - 134 - const data = await response.json(); 135 - const newAnnotations = data.annotations || []; 136 - 137 - // Transform backend format to extension format 138 - const transformed = newAnnotations.map((ann: any) => { 139 - const selectors = JSON.parse(ann.selectors || '[]'); 140 - return { 141 - $type: 'community.lexicon.annotation.annotation', 142 - uri: ann.uri, 143 - cid: ann.cid, 144 - target: [{ 145 - source: ann.targetUrl, 146 - selector: selectors, 147 - }], 148 - body: ann.body || '', 149 - tags: ann.tags ? JSON.parse(ann.tags) : [], 150 - createdAt: ann.createdAt, 151 - author: ann.authorHandle ? { 152 - did: ann.authorDid, 153 - handle: ann.authorHandle, 154 - } : undefined, 155 - }; 156 - }); 157 - 158 - // Merge with existing annotations (avoid duplicates by URI) 159 - const { annotations = [] } = await browser.storage.local.get('annotations'); 160 - const existingUris = new Set(annotations.map((a: any) => a.uri)); 161 - const toAdd = transformed.filter((a: any) => !existingUris.has(a.uri)); 162 - 163 - // Limit total annotations to prevent unbounded memory growth (keep most recent 500) 164 - const MAX_ANNOTATIONS = 500; 165 - const updated = [...annotations, ...toAdd].slice(-MAX_ANNOTATIONS); 166 - 167 - await browser.storage.local.set({ 168 - annotations: updated 169 - }); 170 - 171 - console.log(`[background] Fetched ${newAnnotations.length} annotations for ${url}, added ${toAdd.length} new (total: ${updated.length})`); 172 - } catch (error) { 173 - console.error('[background] Failed to fetch annotations:', error); 174 - throw error; 175 - } 176 - } 177 - 178 - async function syncUserAnnotations() { 179 - if (syncing) { 180 - console.log('[background] Sync already in progress, skipping'); 181 - return; 182 - } 183 - 184 - syncing = true; 185 - 186 - try { 187 - console.log('[background] Syncing user annotations from PDS...'); 188 - 189 - const session = await loadSession(); 190 - if (!session) { 191 - console.log('[background] No session, skipping sync'); 192 - return; 193 - } 194 - 195 - // Fetch user's own annotations from their PDS 196 - const userAnnotations = await listAnnotations(); 197 - 198 - // Fetch user's comments 199 - const comments = await listComments(); 200 - 201 - await browser.storage.local.set({ 202 - userAnnotations, 203 - comments, 204 - lastSync: Date.now(), 205 - syncError: null, 206 - lastSyncAttempt: Date.now() 207 - }); 208 - 209 - console.log('[background] Sync complete:', { 210 - userAnnotations: userAnnotations.length, 211 - comments: comments.length, 212 - source: 'User PDS' 213 - }); 214 - } catch (error) { 215 - console.error('[background] Sync error:', error); 216 - await browser.storage.local.set({ 217 - syncError: String(error?.message || error), 218 - lastSyncAttempt: Date.now() 219 - }); 220 - } finally { 221 - syncing = false; 222 - } 223 - } 20 + worker.start(); 224 21 });
+9 -122
entrypoints/content.ts
··· 1 - import { generateSelectors } from '@/lib/selectors/generate'; 2 - import { applyHighlights, clearHighlights } from '@/lib/highlights/apply'; 3 - import type { Selector, Annotation } from '@/lib/types/annotation'; 1 + import { BrowserStorageAdapter, ExtensionContentScript, generateSelectors, applyHighlights, clearHighlights } from '@seams/core'; 4 2 5 3 export default defineContentScript({ 6 4 matches: ['<all_urls>'], 7 5 main() { 8 - console.log('[content] content script loaded'); 9 - 10 - let currentSelection: { 11 - text: string; 12 - selectors: Selector[]; 13 - } | null = null; 14 - 15 - let currentUrl: string = normalizeUrl(window.location.href); 16 - 17 - function normalizeUrl(url: string): string { 18 - try { 19 - const parsed = new URL(url); 20 - // Remove fragment 21 - parsed.hash = ''; 22 - // Remove trailing slash 23 - let path = parsed.pathname; 24 - if (path.endsWith('/') && path !== '/') { 25 - path = path.slice(0, -1); 26 - } 27 - parsed.pathname = path; 28 - return parsed.toString(); 29 - } catch { 30 - return url; 31 - } 32 - } 33 - 34 - // Load and render highlights on page load 35 - (async function init() { 36 - currentUrl = normalizeUrl(window.location.href); 37 - await loadAndRenderHighlights(); 38 - })(); 6 + const storage = new BrowserStorageAdapter(); 39 7 40 - async function loadAndRenderHighlights() { 41 - // Always clear highlights first 42 - clearHighlights(); 43 - 44 - const url = normalizeUrl(currentUrl); 45 - 46 - // Read from storage cache 47 - const { annotations = [] } = await browser.storage.local.get('annotations'); 48 - 49 - // Filter by current URL 50 - const pageAnnotations = annotations.filter( 51 - (ann: Annotation) => normalizeUrl(ann.target[0]?.source) === url 52 - ); 53 - 54 - console.log(`[content] Found ${pageAnnotations.length} annotations in cache for ${url}`); 55 - 56 - // If no annotations in cache, background worker is still fetching 57 - // storage.onChanged listener will trigger re-render when data arrives 58 - if (pageAnnotations.length === 0) { 59 - console.log(`[content] No cached annotations yet, waiting for background fetch...`); 60 - return; 61 - } 62 - 63 - // Apply highlights from cache 64 - if (pageAnnotations.length > 0) { 65 - applyHighlights(pageAnnotations); 66 - } 67 - } 68 - 69 - // Watch for storage changes 70 - browser.storage.onChanged.addListener((changes, area) => { 71 - if (area === 'local' && changes.annotations) { 72 - console.log('[content] Annotations cache updated, re-rendering'); 73 - loadAndRenderHighlights(); 74 - } 8 + const content = new ExtensionContentScript({ 9 + storage, 10 + applyHighlights, 11 + clearHighlights, 12 + generateSelectors, 75 13 }); 76 - 77 - // Track text selection 78 - document.addEventListener('mouseup', () => { 79 - const selection = window.getSelection(); 80 - if (selection && selection.toString().trim().length > 0) { 81 - const text = selection.toString().trim(); 82 - const root = document.querySelector('main') || document.querySelector('article') || document.body; 83 - const selectors = generateSelectors(selection, root); 84 - 85 - currentSelection = { text, selectors }; 86 - 87 - console.log('[content] text selected:', text); 88 - 89 - // Notify sidepanel of selection change (ephemeral state) 90 - browser.runtime.sendMessage({ 91 - type: 'SELECTION_CHANGED', 92 - selection: currentSelection 93 - }).catch(() => { 94 - // Sidepanel might not be open 95 - }); 96 - } else { 97 - currentSelection = null; 98 - 99 - // Notify sidepanel selection was cleared 100 - browser.runtime.sendMessage({ 101 - type: 'SELECTION_CHANGED', 102 - selection: null 103 - }).catch(() => {}); 104 - } 105 - }); 106 - 107 - // Listen for URL changes from background script 108 - browser.runtime.onMessage.addListener((message) => { 109 - if (message.type === 'URL_CHANGED') { 110 - const newUrl = normalizeUrl(message.url); 111 - if (newUrl !== currentUrl) { 112 - console.log('[content] URL changed (from background):', currentUrl, '→', newUrl); 113 - currentUrl = newUrl; 114 - currentSelection = null; 115 - loadAndRenderHighlights(); 116 - } 117 - } 118 - }); 119 - 120 - // Respond to GET_STATE requests 121 - browser.runtime.onMessage.addListener((message, sender, sendResponse) => { 122 - if (message.type === 'GET_STATE') { 123 - sendResponse({ 124 - url: currentUrl, 125 - selection: currentSelection 126 - }); 127 - } 128 - }); 14 + 15 + content.start(); 129 16 }, 130 17 });
+219
packages/core/src/background/extension.ts
··· 1 + // Extension background worker - handles tab events, fetching, and syncing 2 + import type { StorageAdapter } from '../storage/adapter'; 3 + import type { Annotation } from '../types'; 4 + import { normalizeUrl } from '../utils'; 5 + 6 + declare const browser: any; 7 + 8 + export interface ExtensionBackgroundWorkerOptions { 9 + storage: StorageAdapter; 10 + fetchAnnotationsForUrl: (url: string) => Promise<Annotation[]>; 11 + fetchUserAnnotations: () => Promise<Annotation[]>; 12 + fetchComments: () => Promise<any[]>; 13 + backendUrl?: string; 14 + } 15 + 16 + export class ExtensionBackgroundWorker { 17 + private storage: StorageAdapter; 18 + private fetchAnnotationsForUrl: (url: string) => Promise<Annotation[]>; 19 + private fetchUserAnnotations: () => Promise<Annotation[]>; 20 + private fetchComments: () => Promise<any[]>; 21 + private backendUrl: string; 22 + private syncing = false; 23 + 24 + constructor(options: ExtensionBackgroundWorkerOptions) { 25 + this.storage = options.storage; 26 + this.fetchAnnotationsForUrl = options.fetchAnnotationsForUrl; 27 + this.fetchUserAnnotations = options.fetchUserAnnotations; 28 + this.fetchComments = options.fetchComments; 29 + this.backendUrl = options.backendUrl || 'http://localhost:8080'; 30 + } 31 + 32 + async start(): Promise<void> { 33 + this.registerListeners(); 34 + } 35 + 36 + private registerListeners(): void { 37 + // Sync on startup 38 + browser.runtime.onStartup.addListener(async () => { 39 + console.log('[background] Extension startup, syncing from PDS...'); 40 + await this.syncUserAnnotations(); 41 + }); 42 + 43 + // Sync on install (first run) 44 + browser.runtime.onInstalled.addListener(async () => { 45 + console.log('[background] Extension installed, syncing from PDS...'); 46 + await this.syncUserAnnotations(); 47 + }); 48 + 49 + // Open sidepanel when extension icon is clicked 50 + const actionApi = browser.action || browser.browserAction; 51 + if (actionApi) { 52 + actionApi.onClicked.addListener(async (tab: any) => { 53 + if (tab.id) { 54 + if (browser.sidePanel) { 55 + await browser.sidePanel.open({ tabId: tab.id }); 56 + } 57 + } 58 + }); 59 + } 60 + 61 + // Pre-fetch annotations when tab becomes active 62 + browser.tabs.onActivated.addListener(async (activeInfo: any) => { 63 + try { 64 + const tab = await browser.tabs.get(activeInfo.tabId); 65 + if (tab.url && !tab.url.startsWith('chrome://') && !tab.url.startsWith('about:')) { 66 + const normalized = normalizeUrl(tab.url); 67 + console.log('[background] Tab activated, pre-fetching annotations for', normalized); 68 + await this.fetchAndCacheAnnotations(normalized); 69 + } 70 + } catch (error) { 71 + console.error('[background] Failed to pre-fetch on tab activation:', error); 72 + } 73 + }); 74 + 75 + // Pre-fetch annotations when tab URL updates (full page navigation) 76 + browser.tabs.onUpdated.addListener(async (tabId: number, changeInfo: any, tab: any) => { 77 + if (changeInfo.status === 'complete' && tab.url && !tab.url.startsWith('chrome://') && !tab.url.startsWith('about:')) { 78 + const normalized = normalizeUrl(tab.url); 79 + console.log('[background] Tab updated, pre-fetching annotations for', normalized); 80 + await this.fetchAndCacheAnnotations(normalized); 81 + 82 + // Notify content script of URL change 83 + browser.tabs.sendMessage(tabId, { 84 + type: 'URL_CHANGED', 85 + url: normalized 86 + }).catch(() => { 87 + // Content script might not be ready yet 88 + }); 89 + } 90 + }); 91 + 92 + // Detect SPA navigation (history.pushState/replaceState) 93 + if (browser.webNavigation) { 94 + console.log('[background] Setting up webNavigation.onHistoryStateUpdated listener'); 95 + browser.webNavigation.onHistoryStateUpdated.addListener(async (details: any) => { 96 + if (details.frameId !== 0) return; 97 + 98 + const normalized = normalizeUrl(details.url); 99 + console.log('[background] SPA navigation detected:', normalized); 100 + await this.fetchAndCacheAnnotations(normalized); 101 + 102 + // Notify content script of URL change 103 + browser.tabs.sendMessage(details.tabId, { 104 + type: 'URL_CHANGED', 105 + url: normalized 106 + }).catch(() => { 107 + console.log('[background] Failed to notify content script'); 108 + }); 109 + }); 110 + } else { 111 + console.error('[background] browser.webNavigation is not available!'); 112 + } 113 + 114 + // Handle messages 115 + browser.runtime.onMessage.addListener((message: any, sender: any, sendResponse: any) => { 116 + if (message.type === 'SYNC_CACHE') { 117 + console.log('[background] SYNC_CACHE requested'); 118 + this.syncUserAnnotations(); 119 + sendResponse({ success: true }); 120 + } 121 + 122 + if (message.type === 'FETCH_ANNOTATIONS_FOR_URL') { 123 + console.log('[background] FETCH_ANNOTATIONS_FOR_URL requested for', message.url); 124 + this.fetchAndCacheAnnotations(message.url) 125 + .then(() => sendResponse({ success: true })) 126 + .catch(err => sendResponse({ success: false, error: err.message })); 127 + return true; 128 + } 129 + }); 130 + } 131 + 132 + private async fetchAndCacheAnnotations(url: string): Promise<void> { 133 + console.log(`[background] Fetching annotations for ${url}`); 134 + 135 + try { 136 + const response = await fetch( 137 + `${this.backendUrl}/api/annotations?url=${encodeURIComponent(url)}&limit=100` 138 + ); 139 + 140 + if (!response.ok) { 141 + throw new Error(`Backend error: ${response.status}`); 142 + } 143 + 144 + const data = await response.json(); 145 + const newAnnotations = data.annotations || []; 146 + 147 + // Transform backend format to extension format 148 + const transformed = newAnnotations.map((ann: any) => { 149 + const selectors = JSON.parse(ann.selectors || '[]'); 150 + return { 151 + $type: 'community.lexicon.annotation.annotation', 152 + uri: ann.uri, 153 + cid: ann.cid, 154 + target: [{ 155 + source: ann.targetUrl, 156 + selector: selectors, 157 + }], 158 + body: ann.body || '', 159 + tags: ann.tags ? JSON.parse(ann.tags) : [], 160 + createdAt: ann.createdAt, 161 + author: ann.authorHandle ? { 162 + did: ann.authorDid, 163 + handle: ann.authorHandle, 164 + } : undefined, 165 + }; 166 + }); 167 + 168 + // Merge with existing annotations 169 + const annotations = await this.storage.get('annotations') || []; 170 + const existingUris = new Set(annotations.map((a: any) => a.uri)); 171 + const toAdd = transformed.filter((a: any) => !existingUris.has(a.uri)); 172 + 173 + // Limit total annotations to prevent unbounded memory growth 174 + const MAX_ANNOTATIONS = 500; 175 + const updated = [...annotations, ...toAdd].slice(-MAX_ANNOTATIONS); 176 + 177 + await this.storage.set('annotations', updated); 178 + 179 + console.log(`[background] Fetched ${newAnnotations.length} annotations for ${url}, added ${toAdd.length} new (total: ${updated.length})`); 180 + } catch (error) { 181 + console.error('[background] Failed to fetch annotations:', error); 182 + throw error; 183 + } 184 + } 185 + 186 + private async syncUserAnnotations(): Promise<void> { 187 + if (this.syncing) { 188 + console.log('[background] Sync already in progress, skipping'); 189 + return; 190 + } 191 + 192 + this.syncing = true; 193 + 194 + try { 195 + console.log('[background] Syncing user annotations from PDS...'); 196 + 197 + const userAnnotations = await this.fetchUserAnnotations(); 198 + const comments = await this.fetchComments(); 199 + 200 + await this.storage.set('userAnnotations', userAnnotations); 201 + await this.storage.set('comments', comments); 202 + await this.storage.set('lastSync', Date.now()); 203 + await this.storage.set('syncError', null); 204 + await this.storage.set('lastSyncAttempt', Date.now()); 205 + 206 + console.log('[background] Sync complete:', { 207 + userAnnotations: userAnnotations.length, 208 + comments: comments.length, 209 + source: 'User PDS' 210 + }); 211 + } catch (error) { 212 + console.error('[background] Sync error:', error); 213 + await this.storage.set('syncError', String((error as any)?.message || error)); 214 + await this.storage.set('lastSyncAttempt', Date.now()); 215 + } finally { 216 + this.syncing = false; 217 + } 218 + } 219 + }
+2
packages/core/src/background/index.ts
··· 1 1 export { BackgroundWorker } from './worker'; 2 2 export type { BackgroundWorkerOptions } from './worker'; 3 + export { ExtensionBackgroundWorker } from './extension'; 4 + export type { ExtensionBackgroundWorkerOptions } from './extension';
+120
packages/core/src/content/extension.ts
··· 1 + // Extension content script - handles selection tracking and highlight rendering 2 + import type { StorageAdapter } from '../storage/adapter'; 3 + import type { Annotation } from '../types'; 4 + import { normalizeUrl } from '../utils'; 5 + 6 + declare const browser: any; 7 + 8 + export interface ExtensionContentScriptOptions { 9 + storage: StorageAdapter; 10 + applyHighlights: (annotations: Annotation[]) => void; 11 + clearHighlights: () => void; 12 + generateSelectors: (selection: Selection, root: Element) => any[]; 13 + } 14 + 15 + export class ExtensionContentScript { 16 + private storage: StorageAdapter; 17 + private applyHighlights: (annotations: Annotation[]) => void; 18 + private clearHighlights: () => void; 19 + private generateSelectors: (selection: Selection, root: Element) => any[]; 20 + private currentUrl: string; 21 + private currentSelection: { text: string; selectors: any[] } | null = null; 22 + 23 + constructor(options: ExtensionContentScriptOptions) { 24 + this.storage = options.storage; 25 + this.applyHighlights = options.applyHighlights; 26 + this.clearHighlights = options.clearHighlights; 27 + this.generateSelectors = options.generateSelectors; 28 + this.currentUrl = normalizeUrl(window.location.href); 29 + } 30 + 31 + async start(): Promise<void> { 32 + console.log('[content] content script loaded'); 33 + 34 + // Initial render 35 + await this.loadAndRenderHighlights(); 36 + 37 + // Listen for storage changes 38 + this.storage.onChange(({ key }) => { 39 + if (key === 'annotations') { 40 + console.log('[content] Annotations cache updated, re-rendering'); 41 + this.loadAndRenderHighlights(); 42 + } 43 + }); 44 + 45 + // Track text selection 46 + document.addEventListener('mouseup', () => { 47 + const selection = window.getSelection(); 48 + if (selection && selection.toString().trim().length > 0) { 49 + const text = selection.toString().trim(); 50 + const root = document.querySelector('main') || document.querySelector('article') || document.body; 51 + const selectors = this.generateSelectors(selection, root); 52 + 53 + this.currentSelection = { text, selectors }; 54 + 55 + console.log('[content] text selected:', text); 56 + 57 + // Notify sidepanel of selection change 58 + browser.runtime.sendMessage({ 59 + type: 'SELECTION_CHANGED', 60 + selection: this.currentSelection 61 + }).catch(() => { 62 + // Sidepanel might not be open 63 + }); 64 + } else { 65 + this.currentSelection = null; 66 + 67 + browser.runtime.sendMessage({ 68 + type: 'SELECTION_CHANGED', 69 + selection: null 70 + }).catch(() => {}); 71 + } 72 + }); 73 + 74 + // Listen for URL changes from background script 75 + browser.runtime.onMessage.addListener((message: any) => { 76 + if (message.type === 'URL_CHANGED') { 77 + const newUrl = normalizeUrl(message.url); 78 + if (newUrl !== this.currentUrl) { 79 + console.log('[content] URL changed (from background):', this.currentUrl, '→', newUrl); 80 + this.currentUrl = newUrl; 81 + this.currentSelection = null; 82 + this.loadAndRenderHighlights(); 83 + } 84 + } 85 + }); 86 + 87 + // Respond to GET_STATE requests 88 + browser.runtime.onMessage.addListener((message: any, sender: any, sendResponse: any) => { 89 + if (message.type === 'GET_STATE') { 90 + sendResponse({ 91 + url: this.currentUrl, 92 + selection: this.currentSelection 93 + }); 94 + } 95 + }); 96 + } 97 + 98 + private async loadAndRenderHighlights(): Promise<void> { 99 + this.clearHighlights(); 100 + 101 + const url = normalizeUrl(this.currentUrl); 102 + 103 + const annotations = await this.storage.get('annotations') || []; 104 + 105 + const pageAnnotations = annotations.filter( 106 + (ann: Annotation) => normalizeUrl(ann.target[0]?.source) === url 107 + ); 108 + 109 + console.log(`[content] Found ${pageAnnotations.length} annotations in cache for ${url}`); 110 + 111 + if (pageAnnotations.length === 0) { 112 + console.log(`[content] No cached annotations yet, waiting for background fetch...`); 113 + return; 114 + } 115 + 116 + if (pageAnnotations.length > 0) { 117 + this.applyHighlights(pageAnnotations); 118 + } 119 + } 120 + }
+2
packages/core/src/content/index.ts
··· 1 1 export { ContentScript } from './script'; 2 2 export type { ContentScriptOptions } from './script'; 3 + export { ExtensionContentScript } from './extension'; 4 + export type { ExtensionContentScriptOptions } from './extension';
+1
packages/core/src/index.ts
··· 3 3 export * from './storage'; 4 4 export * from './background'; 5 5 export * from './content'; 6 + export * from './utils';
+50
packages/core/src/storage/browser.ts
··· 1 + // Browser extension storage adapter using browser.storage.local + browser.storage.onChanged 2 + import type { StorageAdapter, StorageChange } from './adapter'; 3 + 4 + // Use global browser API (provided by WXT or webextension-polyfill) 5 + declare const browser: any; 6 + 7 + export class BrowserStorageAdapter implements StorageAdapter { 8 + private listeners: Array<(change: StorageChange) => void> = []; 9 + private listenerRegistered = false; 10 + 11 + constructor() { 12 + // Lazy register the storage change listener 13 + this.ensureListener(); 14 + } 15 + 16 + private ensureListener() { 17 + if (this.listenerRegistered) return; 18 + 19 + if (typeof browser !== 'undefined' && browser.storage && browser.storage.onChanged) { 20 + browser.storage.onChanged.addListener((changes: any, area: string) => { 21 + if (area !== 'local') return; 22 + 23 + Object.entries(changes).forEach(([key, change]: [string, any]) => { 24 + const storageChange: StorageChange = { 25 + key, 26 + newValue: change.newValue, 27 + oldValue: change.oldValue, 28 + }; 29 + this.listeners.forEach(callback => callback(storageChange)); 30 + }); 31 + }); 32 + 33 + this.listenerRegistered = true; 34 + } 35 + } 36 + 37 + async get(key: string): Promise<any> { 38 + const result = await browser.storage.local.get(key); 39 + return result[key] ?? null; 40 + } 41 + 42 + async set(key: string, value: any): Promise<void> { 43 + await browser.storage.local.set({ [key]: value }); 44 + } 45 + 46 + onChange(callback: (change: StorageChange) => void): void { 47 + this.listeners.push(callback); 48 + this.ensureListener(); 49 + } 50 + }
+1
packages/core/src/storage/index.ts
··· 1 1 export type { StorageAdapter, StorageChange } from './adapter'; 2 2 export { WebStorageAdapter } from './web'; 3 + export { BrowserStorageAdapter } from './browser';
+256
packages/core/src/utils/highlights/apply.ts
··· 1 + import { findAnnotationRange } from '../selectors/match'; 2 + import { showAnnotationPopover } from './popover'; 3 + import type { Annotation } from '../../types'; 4 + 5 + export function applyHighlights(annotations: Annotation[], container?: HTMLElement) { 6 + // Use main/article as root to avoid matching text in script tags 7 + const root = container || document.querySelector('main') || document.querySelector('article') || document.body; 8 + 9 + // Clear existing highlights first 10 + clearHighlights(root); 11 + 12 + console.log(`[synthesis] Applying ${annotations.length} highlights`); 13 + console.log('[synthesis] Container:', root.tagName, root.textContent?.substring(0, 100)); 14 + 15 + annotations.forEach((annotation, index) => { 16 + console.log(`[synthesis] Processing annotation ${index + 1}/${annotations.length}`); 17 + const range = findAnnotationRange(annotation, root); 18 + 19 + if (!range) { 20 + console.warn('[synthesis] Could not anchor annotation:', annotation); 21 + return; 22 + } 23 + 24 + // Check if range is inside a script/style tag (not visible) 25 + let node = range.commonAncestorContainer; 26 + while (node && node !== container) { 27 + if (node.nodeName === 'SCRIPT' || node.nodeName === 'STYLE') { 28 + console.warn('[synthesis] Skipping highlight inside', node.nodeName, 'tag'); 29 + return; 30 + } 31 + node = node.parentNode as HTMLElement; 32 + } 33 + 34 + console.log('[synthesis] Found range, attempting to highlight'); 35 + try { 36 + highlightRange(range, annotation); 37 + console.log('[synthesis] Successfully highlighted annotation', index + 1); 38 + } catch (error) { 39 + console.warn('[synthesis] Failed to highlight range:', error); 40 + } 41 + }); 42 + 43 + console.log('[synthesis] Finished applying highlights'); 44 + } 45 + 46 + function highlightRange(range: Range, annotation: Annotation) { 47 + console.log('[synthesis] Highlighting range:', range.toString().substring(0, 50)); 48 + 49 + const highlight = document.createElement('span'); 50 + highlight.className = 'synthesis-highlight'; 51 + highlight.dataset.annotationId = annotation.uri || annotation.createdAt; 52 + highlight.style.cssText = ` 53 + background-color: rgba(255, 235, 59, 0.6) !important; 54 + cursor: pointer !important; 55 + transition: background-color 0.2s !important; 56 + `; 57 + 58 + // Hover effect 59 + highlight.addEventListener('mouseenter', () => { 60 + highlight.style.cssText = ` 61 + background-color: rgba(255, 235, 59, 0.8) !important; 62 + cursor: pointer !important; 63 + transition: background-color 0.2s !important; 64 + `; 65 + }); 66 + 67 + highlight.addEventListener('mouseleave', () => { 68 + highlight.style.cssText = ` 69 + background-color: rgba(255, 235, 59, 0.6) !important; 70 + cursor: pointer !important; 71 + transition: background-color 0.2s !important; 72 + `; 73 + }); 74 + 75 + // Click to show annotation popover 76 + highlight.addEventListener('click', (e) => { 77 + e.preventDefault(); 78 + e.stopPropagation(); 79 + console.log('[synthesis] Clicked annotation:', annotation); 80 + 81 + showAnnotationPopover( 82 + annotation, 83 + highlight, 84 + // On save 85 + async (updatedAnnotation) => { 86 + const stored = await browser.storage.local.get('annotations'); 87 + const annotations = stored.annotations || []; 88 + const index = annotations.findIndex((a: Annotation) => 89 + a.createdAt === annotation.createdAt 90 + ); 91 + if (index !== -1) { 92 + annotations[index] = updatedAnnotation; 93 + await browser.storage.local.set({ annotations }); 94 + console.log('[synthesis] Annotation updated:', updatedAnnotation); 95 + 96 + // Update the annotation object in memory so next click shows updated note 97 + Object.assign(annotation, updatedAnnotation); 98 + } 99 + }, 100 + // On delete 101 + async () => { 102 + const stored = await browser.storage.local.get('annotations'); 103 + const annotations = stored.annotations || []; 104 + const filtered = annotations.filter((a: Annotation) => 105 + a.createdAt !== annotation.createdAt 106 + ); 107 + await browser.storage.local.set({ annotations: filtered }); 108 + console.log('[synthesis] Annotation deleted'); 109 + 110 + // Remove highlight 111 + highlight.remove(); 112 + } 113 + ); 114 + }); 115 + 116 + // Wrap text nodes individually to preserve block structure 117 + try { 118 + const textNodes = getTextNodesInRange(range); 119 + console.log('[synthesis] Found', textNodes.length, 'text nodes to highlight'); 120 + 121 + if (textNodes.length === 0) { 122 + console.warn('[synthesis] No text nodes found in range'); 123 + return; 124 + } 125 + 126 + // Wrap each text node 127 + textNodes.forEach((textNode, index) => { 128 + const nodeRange = document.createRange(); 129 + nodeRange.selectNodeContents(textNode); 130 + 131 + // For first and last nodes, use the original range boundaries 132 + if (index === 0 && textNode === range.startContainer) { 133 + nodeRange.setStart(textNode, range.startOffset); 134 + } 135 + if (index === textNodes.length - 1 && textNode === range.endContainer) { 136 + nodeRange.setEnd(textNode, range.endOffset); 137 + } 138 + 139 + const span = document.createElement('span'); 140 + span.className = 'synthesis-highlight'; 141 + span.dataset.annotationId = annotation.uri || annotation.createdAt; 142 + span.style.cssText = highlight.style.cssText; 143 + 144 + // Copy event listeners 145 + span.addEventListener('mouseenter', () => { 146 + const allSpans = document.querySelectorAll( 147 + `.synthesis-highlight[data-annotation-id="${annotation.uri || annotation.createdAt}"]` 148 + ); 149 + allSpans.forEach(s => { 150 + (s as HTMLElement).style.cssText = ` 151 + background-color: rgba(255, 235, 59, 0.8) !important; 152 + cursor: pointer !important; 153 + transition: background-color 0.2s !important; 154 + `; 155 + }); 156 + }); 157 + 158 + span.addEventListener('mouseleave', () => { 159 + const allSpans = document.querySelectorAll( 160 + `.synthesis-highlight[data-annotation-id="${annotation.uri || annotation.createdAt}"]` 161 + ); 162 + allSpans.forEach(s => { 163 + (s as HTMLElement).style.cssText = ` 164 + background-color: rgba(255, 235, 59, 0.6) !important; 165 + cursor: pointer !important; 166 + transition: background-color 0.2s !important; 167 + `; 168 + }); 169 + }); 170 + 171 + span.addEventListener('click', (e) => { 172 + e.preventDefault(); 173 + e.stopPropagation(); 174 + console.log('[synthesis] Clicked annotation:', annotation); 175 + 176 + showAnnotationPopover( 177 + annotation, 178 + span, 179 + async (updatedAnnotation) => { 180 + const stored = await browser.storage.local.get('annotations'); 181 + const annotations = stored.annotations || []; 182 + const idx = annotations.findIndex((a: Annotation) => 183 + a.createdAt === annotation.createdAt 184 + ); 185 + if (idx !== -1) { 186 + annotations[idx] = updatedAnnotation; 187 + await browser.storage.local.set({ annotations }); 188 + console.log('[synthesis] Annotation updated:', updatedAnnotation); 189 + Object.assign(annotation, updatedAnnotation); 190 + } 191 + }, 192 + async () => { 193 + const stored = await browser.storage.local.get('annotations'); 194 + const annotations = stored.annotations || []; 195 + const filtered = annotations.filter((a: Annotation) => 196 + a.createdAt !== annotation.createdAt 197 + ); 198 + await browser.storage.local.set({ annotations: filtered }); 199 + console.log('[synthesis] Annotation deleted'); 200 + 201 + // Remove all highlight spans for this annotation 202 + const allSpans = document.querySelectorAll( 203 + `.synthesis-highlight[data-annotation-id="${annotation.uri || annotation.createdAt}"]` 204 + ); 205 + allSpans.forEach(s => s.remove()); 206 + } 207 + ); 208 + }); 209 + 210 + nodeRange.surroundContents(span); 211 + }); 212 + 213 + console.log('[synthesis] Successfully applied highlight across', textNodes.length, 'text nodes'); 214 + } catch (error) { 215 + console.error('[synthesis] Failed to apply highlight:', error); 216 + } 217 + } 218 + 219 + function getTextNodesInRange(range: Range): Text[] { 220 + const textNodes: Text[] = []; 221 + const walker = document.createTreeWalker( 222 + range.commonAncestorContainer, 223 + NodeFilter.SHOW_TEXT, 224 + { 225 + acceptNode: (node) => { 226 + if (range.intersectsNode(node)) { 227 + return NodeFilter.FILTER_ACCEPT; 228 + } 229 + return NodeFilter.FILTER_REJECT; 230 + } 231 + } 232 + ); 233 + 234 + let node: Node | null; 235 + while (node = walker.nextNode()) { 236 + textNodes.push(node as Text); 237 + } 238 + 239 + return textNodes; 240 + } 241 + 242 + export function clearHighlights(container: HTMLElement = document.body) { 243 + const highlights = container.querySelectorAll('.synthesis-highlight'); 244 + console.log(`[synthesis] Clearing ${highlights.length} highlights`); 245 + 246 + highlights.forEach(highlight => { 247 + const parent = highlight.parentNode; 248 + if (parent) { 249 + while (highlight.firstChild) { 250 + parent.insertBefore(highlight.firstChild, highlight); 251 + } 252 + parent.removeChild(highlight); 253 + parent.normalize(); 254 + } 255 + }); 256 + }
+68
packages/core/src/utils/highlights/popover.ts
··· 1 + import type { Annotation } from '../types/annotation'; 2 + 3 + let currentPopover: HTMLElement | null = null; 4 + 5 + export function showAnnotationPopover( 6 + annotation: Annotation, 7 + targetElement: HTMLElement, 8 + onSave: (updatedAnnotation: Annotation) => void, 9 + onDelete: () => void 10 + ) { 11 + // Remove existing popover 12 + hidePopover(); 13 + 14 + const popover = document.createElement('div'); 15 + popover.className = 'synthesis-popover'; 16 + popover.style.cssText = ` 17 + position: absolute; 18 + z-index: 999999; 19 + background: white; 20 + border: 1px solid #ccc; 21 + border-radius: 8px; 22 + padding: 12px; 23 + box-shadow: 0 2px 8px rgba(0,0,0,0.15); 24 + min-width: 300px; 25 + max-width: 400px; 26 + `; 27 + 28 + const rect = targetElement.getBoundingClientRect(); 29 + popover.style.left = `${rect.left + window.scrollX}px`; 30 + popover.style.top = `${rect.bottom + window.scrollY + 5}px`; 31 + 32 + const quote = annotation.target[0]?.selector?.find((s: any) => s.$type === 'community.lexicon.annotation.annotation#textQuoteSelector'); 33 + const quotedText = quote?.exact || ''; 34 + 35 + popover.innerHTML = ` 36 + <div style="margin-bottom: 8px; font-size: 13px; color: #666; font-style: italic; border-left: 3px solid #0085ff; padding-left: 8px;"> 37 + ${quotedText} 38 + </div> 39 + <div style="padding: 8px 0; font-size: 14px; color: #333;"> 40 + ${annotation.body || '<em style="color: #999;">No note</em>'} 41 + </div> 42 + `; 43 + 44 + document.body.appendChild(popover); 45 + currentPopover = popover; 46 + 47 + // Close on click outside 48 + setTimeout(() => { 49 + document.addEventListener('click', handleClickOutside); 50 + }, 0); 51 + 52 + // Stop propagation on popover clicks 53 + popover.addEventListener('click', (e) => { 54 + e.stopPropagation(); 55 + }); 56 + } 57 + 58 + function handleClickOutside() { 59 + hidePopover(); 60 + } 61 + 62 + export function hidePopover() { 63 + if (currentPopover) { 64 + currentPopover.remove(); 65 + currentPopover = null; 66 + document.removeEventListener('click', handleClickOutside); 67 + } 68 + }
+4
packages/core/src/utils/index.ts
··· 1 + export * from './url'; 2 + export * from './selectors/generate'; 3 + export * from './selectors/match'; 4 + export * from './highlights/apply';
+67
packages/core/src/utils/selectors/generate.ts
··· 1 + import * as textQuote from 'dom-anchor-text-quote'; 2 + import * as textPosition from 'dom-anchor-text-position'; 3 + import type { Selector, TextQuoteSelector, TextPositionSelector } from '../types/annotation'; 4 + 5 + /** 6 + * Generate W3C selectors from a DOM selection using Hypothesis libraries 7 + */ 8 + export function generateSelectors(selection: Selection, root?: HTMLElement): Selector[] { 9 + if (!selection.rangeCount) return []; 10 + 11 + const range = selection.getRangeAt(0); 12 + const container = root || document.body; 13 + const selectors: Selector[] = []; 14 + 15 + // TextQuoteSelector - most robust for content changes 16 + const textQuoteSelector = generateTextQuoteSelector(range, container); 17 + if (textQuoteSelector) selectors.push(textQuoteSelector); 18 + 19 + // TextPositionSelector - precise but fragile 20 + const textPositionSelector = generateTextPositionSelector(range, container); 21 + if (textPositionSelector) selectors.push(textPositionSelector); 22 + 23 + return selectors; 24 + } 25 + 26 + function generateTextQuoteSelector( 27 + range: Range, 28 + root: HTMLElement 29 + ): TextQuoteSelector | null { 30 + const exact = range.toString().trim(); 31 + if (!exact) return null; 32 + 33 + try { 34 + const selector = textQuote.fromRange(root, range); 35 + 36 + return { 37 + $type: 'community.lexicon.annotation.annotation#textQuoteSelector', 38 + exact: selector.exact, 39 + prefix: selector.prefix || undefined, 40 + suffix: selector.suffix || undefined 41 + }; 42 + } catch (error) { 43 + console.warn('Failed to generate TextQuoteSelector:', error); 44 + return null; 45 + } 46 + } 47 + 48 + function generateTextPositionSelector( 49 + range: Range, 50 + root: HTMLElement 51 + ): TextPositionSelector | null { 52 + const exact = range.toString().trim(); 53 + if (!exact) return null; 54 + 55 + try { 56 + const selector = textPosition.fromRange(root, range); 57 + 58 + return { 59 + $type: 'community.lexicon.annotation.annotation#textPositionSelector', 60 + start: selector.start, 61 + end: selector.end 62 + }; 63 + } catch (error) { 64 + console.warn('Failed to generate TextPositionSelector:', error); 65 + return null; 66 + } 67 + }
+67
packages/core/src/utils/selectors/match.ts
··· 1 + import * as textQuote from 'dom-anchor-text-quote'; 2 + import * as textPosition from 'dom-anchor-text-position'; 3 + import type { Annotation, TextQuoteSelector, TextPositionSelector } from '../types/annotation'; 4 + 5 + /** 6 + * Find the DOM Range for an annotation using its selectors 7 + */ 8 + export function findAnnotationRange( 9 + annotation: Annotation, 10 + container: HTMLElement = document.body 11 + ): Range | null { 12 + const selectors = annotation.target?.[0]?.selector; 13 + if (!selectors || selectors.length === 0) { 14 + console.warn('[synthesis] No selectors found in annotation'); 15 + return null; 16 + } 17 + 18 + console.log('[synthesis] Trying to match annotation with', selectors.length, 'selectors'); 19 + 20 + // Try each selector in order 21 + for (const selector of selectors) { 22 + let range: Range | null = null; 23 + 24 + console.log('[synthesis] Trying selector type:', selector.$type); 25 + 26 + switch (selector.$type) { 27 + case 'community.lexicon.annotation.annotation#textPositionSelector': 28 + range = matchTextPositionSelector(selector as TextPositionSelector, container); 29 + break; 30 + case 'community.lexicon.annotation.annotation#textQuoteSelector': 31 + range = matchTextQuoteSelector(selector as TextQuoteSelector, container); 32 + break; 33 + } 34 + 35 + if (range) { 36 + console.log('[synthesis] Successfully matched with', selector.$type); 37 + return range; 38 + } 39 + } 40 + 41 + console.warn('[synthesis] Could not match any selector'); 42 + return null; 43 + } 44 + 45 + function matchTextPositionSelector( 46 + selector: TextPositionSelector, 47 + container: HTMLElement 48 + ): Range | null { 49 + try { 50 + return textPosition.toRange(container, selector); 51 + } catch (e) { 52 + console.warn('TextPositionSelector match failed:', e); 53 + return null; 54 + } 55 + } 56 + 57 + function matchTextQuoteSelector( 58 + selector: TextQuoteSelector, 59 + container: HTMLElement 60 + ): Range | null { 61 + try { 62 + return textQuote.toRange(container, selector); 63 + } catch (e) { 64 + console.warn('TextQuoteSelector match failed:', e); 65 + return null; 66 + } 67 + }
+18
packages/core/src/utils/url.ts
··· 1 + // URL normalization utilities 2 + 3 + export function normalizeUrl(url: string): string { 4 + try { 5 + const parsed = new URL(url); 6 + // Remove fragment 7 + parsed.hash = ''; 8 + // Remove trailing slash 9 + let path = parsed.pathname; 10 + if (path.endsWith('/') && path !== '/') { 11 + path = path.slice(0, -1); 12 + } 13 + parsed.pathname = path; 14 + return parsed.toString(); 15 + } catch { 16 + return url; 17 + } 18 + }