Feat/remove selection fab context menu (#735)

This commit is contained in:
Om Shah 2026-02-16 13:31:14 -08:00 committed by GitHub
parent 1b1b34fb66
commit 8287141bba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1 additions and 739 deletions

View file

@ -6,7 +6,6 @@ import {
} from "../utils/api"
import {
CONTAINER_TAGS,
CONTEXT_MENU_IDS,
MESSAGE_TYPES,
POSTHOG_EVENT_KEY,
} from "../utils/constants"
@ -26,18 +25,6 @@ export default defineBackground(() => {
let twitterImporter: TwitterImporter | null = null
browser.runtime.onInstalled.addListener(async (details) => {
browser.contextMenus.create({
id: CONTEXT_MENU_IDS.SAVE_TO_SUPERMEMORY,
title: "sync to supermemory",
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,
@ -59,38 +46,6 @@ export default defineBackground(() => {
["requestHeaders", "extraHeaders"],
)
// Handle context menu clicks.
browser.contextMenus.onClicked.addListener(async (info, tab) => {
if (info.menuItemId === CONTEXT_MENU_IDS.SAVE_TO_SUPERMEMORY) {
if (tab?.id) {
try {
await browser.tabs.sendMessage(tab.id, {
action: MESSAGE_TYPES.SAVE_MEMORY,
actionSource: "context_menu",
})
} catch (error) {
console.error("Failed to send message to content script:", error)
}
}
}
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.
const sendMessageToCurrentTab = async (message: string) => {
const tabs = await browser.tabs.query({
@ -319,30 +274,6 @@ 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
}
},
)
})

View file

@ -2,10 +2,6 @@ import { DOMAINS, MESSAGE_TYPES } from "../../utils/constants"
import { DOMUtils } from "../../utils/ui-components"
import { initializeChatGPT } from "./chatgpt"
import { initializeClaude } from "./claude"
import {
handleOpenSearchPanel,
initializeSelectionSearch,
} from "./selection-search"
import {
saveMemory,
setupGlobalKeyboardShortcut,
@ -28,8 +24,6 @@ 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.action === MESSAGE_TYPES.TWITTER_IMPORT_OPEN_MODAL) {
await openImportModal()
} else if (message.type === MESSAGE_TYPES.IMPORT_UPDATE) {
@ -74,9 +68,6 @@ export default defineContentScript({
initializeT3()
initializeTwitter()
// Initialize universal selection search
initializeSelectionSearch()
// Start observing for dynamic changes
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", observeForDynamicChanges)

View file

@ -1,647 +0,0 @@
import { ELEMENT_IDS, MESSAGE_TYPES, UI_CONFIG } from "../../utils/constants"
// State
let currentQuery = ""
let fabElement: HTMLElement | null = null
let panelElement: HTMLElement | null = null
const selectedResults: Set<number> = 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 = `
<img src="${iconUrl}" width="16" height="16" alt="Search" style="border-radius: 2px;" />
<span>Search</span>
`
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 = `
<div style="display: flex; align-items: center; gap: 8px;">
<img src="${iconUrl}" width="20" height="20" alt="supermemory" style="border-radius: 4px;" />
<span style="font-size: 14px; font-weight: 600; color: #ffffff;">Search Results</span>
</div>
<button id="sm-panel-close" style="
background: transparent;
border: none;
color: #737373;
cursor: pointer;
padding: 4px;
font-size: 18px;
line-height: 1;
transition: color 0.15s;
">×</button>
`
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 = `
<div style="font-size: 12px; color: #737373; margin-bottom: 4px;">Searching for:</div>
<div style="font-size: 13px; color: #ffffff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(query)}</div>
`
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 = `
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 150px; gap: 12px;">
<div style="width: 24px; height: 24px; border: 2px solid rgba(255,255,255,0.1); border-top-color: #5BD3FB; border-radius: 50%; animation: sm-spin 0.8s linear infinite;"></div>
<span style="font-size: 14px; color: #737373;">Searching memories...</span>
</div>
`
} else if (state === "auth_error") {
content.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 150px; gap: 16px; text-align: center;">
<div style="font-size: 14px; color: #ef4444;">Sign in required</div>
<p style="font-size: 13px; color: #737373; margin: 0;">Please sign in to supermemory to search your memories.</p>
<button id="sm-sign-in-btn" style="
padding: 10px 20px;
background: linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%);
color: #ffffff;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
">Sign in</button>
</div>
`
} else if (state === "error") {
content.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 150px; gap: 12px; text-align: center;">
<div style="color: #ef4444; font-size: 14px;">Search failed</div>
<p style="font-size: 13px; color: #737373; margin: 0;">${escapeHtml(errorMessage || "An error occurred")}</p>
</div>
`
} 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 = `
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 150px; gap: 12px; text-align: center;">
<div style="font-size: 14px; color: #737373;">No memories found</div>
<p style="font-size: 13px; color: #525966; margin: 0;">Try a different search query.</p>
</div>
`
} else {
content.innerHTML = results
.map(
(result, index) => `
<div class="sm-result-item" data-index="${index}" style="
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
background: rgba(91, 126, 245, 0.04);
border-radius: 8px;
margin-bottom: 8px;
cursor: pointer;
transition: background 0.15s;
border: 1px solid transparent;
">
<input type="checkbox" data-index="${index}" style="
margin-top: 2px;
width: 16px;
height: 16px;
cursor: pointer;
accent-color: #5BD3FB;
" />
<div style="flex: 1; min-width: 0;">
<p style="font-size: 13px; color: #e5e5e5; margin: 0; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;">${escapeHtml(result.memory || "")}</p>
</div>
</div>
`,
)
.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 = `
<span id="sm-selected-count" style="font-size: 13px; color: #737373;">0 selected</span>
<button id="sm-copy-btn" style="
padding: 10px 16px;
background: linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%);
color: #ffffff;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
opacity: 0.5;
transition: opacity 0.15s;
" disabled>Copy selected</button>
`
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)
}
})
}

View file

@ -24,8 +24,6 @@ export const ELEMENT_IDS = {
CLAUDE_INPUT_BAR_ELEMENT: "sm-claude-input-bar-element",
T3_INPUT_BAR_ELEMENT: "sm-t3-input-bar-element",
PROJECT_SELECTION_MODAL: "sm-project-selection-modal",
SELECTION_SEARCH_FAB: "sm-selection-search-fab",
SELECTION_SEARCH_PANEL: "sm-selection-search-panel",
} as const
/**
@ -51,8 +49,6 @@ export const UI_CONFIG = {
OBSERVER_THROTTLE_DELAY: 300, // milliseconds between observer callback executions
ROUTE_CHECK_INTERVAL: 2000, // milliseconds between route change checks
API_REQUEST_TIMEOUT: 10000, // milliseconds for API request timeout
SELECTION_MIN_LENGTH: 3, // minimum characters to show FAB
SELECTION_MAX_LENGTH: 500, // maximum characters to show FAB
} as const
/**
@ -86,16 +82,9 @@ export const MESSAGE_TYPES = {
GET_RELATED_MEMORIES: "sm-get-related-memories",
CAPTURE_PROMPT: "sm-capture-prompt",
FETCH_PROJECTS: "sm-fetch-projects",
SEARCH_SELECTION: "sm-search-selection",
OPEN_SEARCH_PANEL: "sm-open-search-panel",
TWITTER_IMPORT_OPEN_MODAL: "sm-twitter-import-open-modal",
} as const
export const CONTEXT_MENU_IDS = {
SAVE_TO_SUPERMEMORY: "sm-save-to-supermemory",
SEARCH_SUPERMEMORY: "sm-search-supermemory",
} as const
export const POSTHOG_EVENT_KEY = {
TWITTER_IMPORT_STARTED: "twitter_import_started",
SAVE_MEMORY_ATTEMPTED: "save_memory_attempted",
@ -107,6 +96,4 @@ export const POSTHOG_EVENT_KEY = {
CLAUDE_CHAT_MEMORIES_AUTO_SEARCHED: "claude_chat_memories_auto_searched",
CHATGPT_CHAT_MEMORIES_SEARCHED: "chatgpt_chat_memories_searched",
CHATGPT_CHAT_MEMORIES_AUTO_SEARCHED: "chatgpt_chat_memories_auto_searched",
SELECTION_SEARCH_TRIGGERED: "selection_search_triggered",
SELECTION_SEARCH_COPIED: "selection_search_copied",
} as const

View file

@ -12,7 +12,7 @@ export default defineConfig({
name: "supermemory",
homepage_url: "https://supermemory.ai",
version: "6.1.0",
permissions: ["contextMenus", "storage", "activeTab", "webRequest", "tabs"],
permissions: ["storage", "activeTab", "webRequest", "tabs"],
host_permissions: [
"*://x.com/*",
"*://twitter.com/*",