diff --git a/apps/browser-extension/entrypoints/background.ts b/apps/browser-extension/entrypoints/background.ts index 131207c2..0ee09e3f 100644 --- a/apps/browser-extension/entrypoints/background.ts +++ b/apps/browser-extension/entrypoints/background.ts @@ -32,6 +32,12 @@ export default defineBackground(() => { contexts: ["selection", "page", "link"], }) + browser.contextMenus.create({ + id: CONTEXT_MENU_IDS.SEARCH_SUPERMEMORY, + title: "search supermemory", + contexts: ["selection"], + }) + if (details.reason === "install") { await trackEvent("extension_installed", { reason: details.reason, @@ -67,6 +73,22 @@ export default defineBackground(() => { } } } + + if (info.menuItemId === CONTEXT_MENU_IDS.SEARCH_SUPERMEMORY) { + if (tab?.id && info.selectionText) { + try { + await browser.tabs.sendMessage(tab.id, { + action: MESSAGE_TYPES.OPEN_SEARCH_PANEL, + data: info.selectionText, + }) + } catch (error) { + console.error( + "Failed to send search message to content script:", + error, + ) + } + } + } }) // Send message to current active tab. @@ -296,6 +318,31 @@ export default defineBackground(() => { })() return true } + + if (message.action === MESSAGE_TYPES.SEARCH_SELECTION) { + ;(async () => { + try { + const query = message.data as string + const responseData = await searchMemories(query) + await trackEvent(POSTHOG_EVENT_KEY.SELECTION_SEARCH_TRIGGERED, { + query_length: query.length, + }) + sendResponse({ success: true, data: responseData }) + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error" + const isAuthError = + errorMessage.includes("Authentication") || + errorMessage.includes("token") + sendResponse({ + success: false, + error: errorMessage, + isAuthError, + }) + } + })() + return true + } }, ) }) diff --git a/apps/browser-extension/entrypoints/content/index.ts b/apps/browser-extension/entrypoints/content/index.ts index a79f50fb..d67b37b0 100644 --- a/apps/browser-extension/entrypoints/content/index.ts +++ b/apps/browser-extension/entrypoints/content/index.ts @@ -2,9 +2,21 @@ import { DOMAINS, MESSAGE_TYPES } from "../../utils/constants" import { DOMUtils } from "../../utils/ui-components" import { initializeChatGPT } from "./chatgpt" import { initializeClaude } from "./claude" -import { saveMemory, setupGlobalKeyboardShortcut, setupStorageListener } from "./shared" +import { + handleOpenSearchPanel, + initializeSelectionSearch, +} from "./selection-search" +import { + saveMemory, + setupGlobalKeyboardShortcut, + setupStorageListener, +} from "./shared" import { initializeT3 } from "./t3" -import { handleTwitterNavigation, initializeTwitter, updateTwitterImportUI } from "./twitter" +import { + handleTwitterNavigation, + initializeTwitter, + updateTwitterImportUI, +} from "./twitter" export default defineContentScript({ matches: [""], @@ -15,6 +27,8 @@ export default defineContentScript({ DOMUtils.showToast(message.state) } else if (message.action === MESSAGE_TYPES.SAVE_MEMORY) { await saveMemory() + } else if (message.action === MESSAGE_TYPES.OPEN_SEARCH_PANEL) { + handleOpenSearchPanel(message.data as string) } else if (message.type === MESSAGE_TYPES.IMPORT_UPDATE) { updateTwitterImportUI(message) } else if (message.type === MESSAGE_TYPES.IMPORT_DONE) { @@ -57,6 +71,9 @@ export default defineContentScript({ initializeT3() initializeTwitter() + // Initialize universal selection search + initializeSelectionSearch() + // Start observing for dynamic changes if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", observeForDynamicChanges) @@ -64,4 +81,4 @@ export default defineContentScript({ observeForDynamicChanges() } }, -}) \ No newline at end of file +}) diff --git a/apps/browser-extension/entrypoints/content/selection-search.ts b/apps/browser-extension/entrypoints/content/selection-search.ts new file mode 100644 index 00000000..6473b84c --- /dev/null +++ b/apps/browser-extension/entrypoints/content/selection-search.ts @@ -0,0 +1,647 @@ +import { ELEMENT_IDS, MESSAGE_TYPES, UI_CONFIG } from "../../utils/constants" + +// State +let currentQuery = "" +let fabElement: HTMLElement | null = null +let panelElement: HTMLElement | null = null +let selectedResults: Set = new Set() + +/** + * Get the selection rectangle for positioning the FAB + */ +function getSelectionRect(): DOMRect | null { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return null + + const range = selection.getRangeAt(0) + return range.getBoundingClientRect() +} + +/** + * Check if the selection is inside our extension UI + */ +function isSelectionInsideExtensionUI(): boolean { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return false + + const anchorNode = selection.anchorNode + if (!anchorNode) return false + + const element = + anchorNode.nodeType === Node.ELEMENT_NODE + ? (anchorNode as Element) + : anchorNode.parentElement + + if (!element) return false + + // Check if selection is inside FAB or panel + return ( + !!element.closest(`#${ELEMENT_IDS.SELECTION_SEARCH_FAB}`) || + !!element.closest(`#${ELEMENT_IDS.SELECTION_SEARCH_PANEL}`) + ) +} + +/** + * Create the floating action button (FAB) + */ +function createFAB(): HTMLElement { + const fab = document.createElement("div") + fab.id = ELEMENT_IDS.SELECTION_SEARCH_FAB + + const iconUrl = browser.runtime.getURL("/icon-16.png") + + fab.innerHTML = ` + Search + Search + ` + + fab.style.cssText = ` + position: fixed; + z-index: 2147483646; + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + background: #05070A; + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 13px; + font-weight: 500; + cursor: pointer; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + transition: all 0.15s ease; + user-select: none; + ` + + fab.addEventListener("mouseenter", () => { + fab.style.background = "#0F151F" + fab.style.borderColor = "rgba(255, 255, 255, 0.2)" + }) + + fab.addEventListener("mouseleave", () => { + fab.style.background = "#05070A" + fab.style.borderColor = "rgba(255, 255, 255, 0.1)" + }) + + fab.addEventListener("click", (e) => { + e.preventDefault() + e.stopPropagation() + triggerSearch() + }) + + return fab +} + +/** + * Show the FAB near the selection + */ +function showFAB(rect: DOMRect, text: string) { + hideFAB() + + currentQuery = text + fabElement = createFAB() + + // Position FAB above the selection, centered + const fabWidth = 90 // approximate width + let left = rect.left + rect.width / 2 - fabWidth / 2 + let top = rect.top - 40 + + // Ensure FAB stays within viewport + if (left < 10) left = 10 + if (left + fabWidth > window.innerWidth - 10) { + left = window.innerWidth - fabWidth - 10 + } + if (top < 10) { + // Show below selection if not enough space above + top = rect.bottom + 10 + } + + fabElement.style.left = `${left}px` + fabElement.style.top = `${top}px` + + document.body.appendChild(fabElement) +} + +/** + * Hide the FAB + */ +export function hideFAB() { + if (fabElement) { + fabElement.remove() + fabElement = null + } +} + +/** + * Trigger search with the current query + */ +async function triggerSearch() { + if (!currentQuery) return + + hideFAB() + showPanel(currentQuery, "loading") + + try { + const response = await browser.runtime.sendMessage({ + action: MESSAGE_TYPES.SEARCH_SELECTION, + data: currentQuery, + }) + + if (response.success) { + showPanel(currentQuery, "results", response.data) + } else if (response.isAuthError) { + showPanel(currentQuery, "auth_error") + } else { + showPanel(currentQuery, "error", null, response.error) + } + } catch (error) { + console.error("Search failed:", error) + showPanel( + currentQuery, + "error", + null, + error instanceof Error ? error.message : "Search failed", + ) + } +} + +/** + * Create and show the search results panel + */ +function showPanel( + query: string, + state: "loading" | "results" | "error" | "auth_error", + data?: unknown, + errorMessage?: string, +) { + hidePanel() + selectedResults.clear() + + panelElement = document.createElement("div") + panelElement.id = ELEMENT_IDS.SELECTION_SEARCH_PANEL + + panelElement.style.cssText = ` + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 2147483647; + width: 420px; + max-width: 90vw; + max-height: 70vh; + background: #05070A; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + display: flex; + flex-direction: column; + overflow: hidden; + ` + + // Header + const header = document.createElement("div") + header.style.cssText = ` + padding: 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + justify-content: space-between; + align-items: center; + flex-shrink: 0; + ` + + const iconUrl = browser.runtime.getURL("/icon-16.png") + header.innerHTML = ` +
+ supermemory + Search Results +
+ + ` + + panelElement.appendChild(header) + + // Query display + const queryDisplay = document.createElement("div") + queryDisplay.style.cssText = ` + padding: 12px 16px; + background: rgba(91, 126, 245, 0.04); + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + flex-shrink: 0; + ` + queryDisplay.innerHTML = ` +
Searching for:
+
${escapeHtml(query)}
+ ` + panelElement.appendChild(queryDisplay) + + // Content area + const content = document.createElement("div") + content.id = "sm-panel-content" + content.style.cssText = ` + flex: 1; + overflow-y: auto; + padding: 16px; + min-height: 150px; + ` + + if (state === "loading") { + content.innerHTML = ` +
+
+ Searching memories... +
+ ` + } else if (state === "auth_error") { + content.innerHTML = ` +
+
Sign in required
+

Please sign in to supermemory to search your memories.

+ +
+ ` + } else if (state === "error") { + content.innerHTML = ` +
+
Search failed
+

${escapeHtml(errorMessage || "An error occurred")}

+
+ ` + } else if (state === "results") { + const response = data as { + searchResults?: { results?: Array<{ memory?: string; id?: string }> } + } + const results = response?.searchResults?.results || [] + + if (results.length === 0) { + content.innerHTML = ` +
+
No memories found
+

Try a different search query.

+
+ ` + } else { + content.innerHTML = results + .map( + (result, index) => ` +
+ +
+

${escapeHtml(result.memory || "")}

+
+
+ `, + ) + .join("") + } + } + + panelElement.appendChild(content) + + // Footer with copy button (only for results) + if (state === "results") { + const response = data as { + searchResults?: { results?: Array<{ memory?: string }> } + } + const results = response?.searchResults?.results || [] + + if (results.length > 0) { + const footer = document.createElement("div") + footer.style.cssText = ` + padding: 12px 16px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + justify-content: space-between; + align-items: center; + flex-shrink: 0; + ` + footer.innerHTML = ` + 0 selected + + ` + panelElement.appendChild(footer) + } + } + + // Add animations style + if (!document.getElementById("sm-panel-styles")) { + const style = document.createElement("style") + style.id = "sm-panel-styles" + style.textContent = ` + @keyframes sm-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + .sm-result-item:hover { + background: rgba(91, 126, 245, 0.08) !important; + } + .sm-result-item.selected { + border-color: rgba(91, 190, 251, 0.3) !important; + background: rgba(91, 126, 245, 0.08) !important; + } + ` + document.head.appendChild(style) + } + + document.body.appendChild(panelElement) + + // Event listeners + setupPanelEventListeners(data) +} + +/** + * Setup event listeners for the panel + */ +function setupPanelEventListeners(data: unknown) { + if (!panelElement) return + + // Close button + const closeBtn = panelElement.querySelector("#sm-panel-close") + closeBtn?.addEventListener("click", hidePanel) + + // Sign in button + const signInBtn = panelElement.querySelector("#sm-sign-in-btn") + signInBtn?.addEventListener("click", () => { + window.open( + import.meta.env.PROD + ? "https://app.supermemory.ai/login" + : "http://localhost:3000/login", + "_blank", + ) + }) + + // Result item checkboxes + const checkboxes = panelElement.querySelectorAll( + '.sm-result-item input[type="checkbox"]', + ) + checkboxes.forEach((checkbox) => { + checkbox.addEventListener("change", (e) => { + const target = e.target as HTMLInputElement + const index = Number.parseInt(target.dataset.index || "0", 10) + const item = target.closest(".sm-result-item") as HTMLElement + + if (target.checked) { + selectedResults.add(index) + item?.classList.add("selected") + } else { + selectedResults.delete(index) + item?.classList.remove("selected") + } + + updateSelectionUI() + }) + }) + + // Result item click (toggle checkbox) + const items = panelElement.querySelectorAll(".sm-result-item") + items.forEach((item) => { + item.addEventListener("click", (e) => { + const target = e.target as HTMLElement + if (target.tagName === "INPUT") return + + const checkbox = item.querySelector( + 'input[type="checkbox"]', + ) as HTMLInputElement + if (checkbox) { + checkbox.checked = !checkbox.checked + checkbox.dispatchEvent(new Event("change")) + } + }) + }) + + // Copy button + const copyBtn = panelElement.querySelector("#sm-copy-btn") + copyBtn?.addEventListener("click", () => { + copySelectedResults(data) + }) + + // Close on escape + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + hidePanel() + } + } + document.addEventListener("keydown", handleKeyDown) + + // Close on outside click + const handleOutsideClick = (e: MouseEvent) => { + if (panelElement && !panelElement.contains(e.target as Node)) { + hidePanel() + } + } + setTimeout(() => { + document.addEventListener("click", handleOutsideClick) + }, 100) + + // Cleanup listeners when panel is removed + const observer = new MutationObserver(() => { + if (!document.contains(panelElement)) { + document.removeEventListener("keydown", handleKeyDown) + document.removeEventListener("click", handleOutsideClick) + observer.disconnect() + } + }) + observer.observe(document.body, { childList: true }) +} + +/** + * Update the selection count UI + */ +function updateSelectionUI() { + const countEl = document.getElementById("sm-selected-count") + const copyBtn = document.getElementById("sm-copy-btn") as HTMLButtonElement + + if (countEl) { + countEl.textContent = `${selectedResults.size} selected` + } + + if (copyBtn) { + copyBtn.disabled = selectedResults.size === 0 + copyBtn.style.opacity = selectedResults.size === 0 ? "0.5" : "1" + } +} + +/** + * Copy selected results to clipboard + */ +async function copySelectedResults(data: unknown) { + const response = data as { + searchResults?: { results?: Array<{ memory?: string }> } + } + const results = response?.searchResults?.results || [] + + const selectedMemories = Array.from(selectedResults) + .sort((a, b) => a - b) + .map((index) => results[index]?.memory) + .filter(Boolean) + + if (selectedMemories.length === 0) return + + // Format the copied content + const formattedContent = selectedMemories + .map((memory, i) => `${i + 1}. ${memory}`) + .join("\n\n") + + try { + await navigator.clipboard.writeText(formattedContent) + + // Show copied feedback + const copyBtn = document.getElementById("sm-copy-btn") + if (copyBtn) { + const originalText = copyBtn.textContent + copyBtn.textContent = "Copied!" + setTimeout(() => { + if (copyBtn) copyBtn.textContent = originalText + }, 1500) + } + + // Track event + browser.runtime.sendMessage({ + action: MESSAGE_TYPES.CAPTURE_PROMPT, + data: { + prompt: `Copied ${selectedMemories.length} memories`, + platform: "selection_search", + source: "copy_selected", + }, + }) + } catch (error) { + console.error("Failed to copy to clipboard:", error) + } +} + +/** + * Hide the panel + */ +export function hidePanel() { + if (panelElement) { + panelElement.remove() + panelElement = null + } + selectedResults.clear() +} + +/** + * Escape HTML to prevent XSS + */ +function escapeHtml(text: string): string { + const div = document.createElement("div") + div.textContent = text + return div.innerHTML +} + +/** + * Handle selection change + */ +function handleSelectionChange() { + const selection = window.getSelection() + const text = selection?.toString().trim() || "" + + // Hide FAB if selection is empty or inside extension UI + if ( + !text || + text.length < UI_CONFIG.SELECTION_MIN_LENGTH || + text.length > UI_CONFIG.SELECTION_MAX_LENGTH || + isSelectionInsideExtensionUI() + ) { + hideFAB() + return + } + + const rect = getSelectionRect() + if (rect && rect.width > 0 && rect.height > 0) { + showFAB(rect, text) + } +} + +/** + * Handle message from background to open search panel + */ +export function handleOpenSearchPanel(query: string) { + currentQuery = query + hideFAB() + triggerSearch() +} + +/** + * Initialize selection search functionality + */ +export function initializeSelectionSearch() { + // Listen for mouseup to detect selection + document.addEventListener("mouseup", () => { + // Small delay to ensure selection is complete + setTimeout(handleSelectionChange, 10) + }) + + // Listen for keyup for keyboard selection + document.addEventListener("keyup", (e) => { + if (e.shiftKey) { + setTimeout(handleSelectionChange, 10) + } + }) + + // Hide FAB when clicking elsewhere + document.addEventListener("mousedown", (e) => { + const target = e.target as HTMLElement + if ( + fabElement && + !fabElement.contains(target) && + !panelElement?.contains(target) + ) { + // Don't hide immediately to allow FAB click + setTimeout(() => { + const selection = window.getSelection() + if (!selection || selection.toString().trim().length === 0) { + hideFAB() + } + }, 100) + } + }) +} diff --git a/apps/browser-extension/entrypoints/popup/App.tsx b/apps/browser-extension/entrypoints/popup/App.tsx index 3c16883a..c8472da4 100644 --- a/apps/browser-extension/entrypoints/popup/App.tsx +++ b/apps/browser-extension/entrypoints/popup/App.tsx @@ -311,12 +311,7 @@ function App() { }} >
- supermemory +
supermemory
{userSignedIn && (