Component props
Context menu component
<ContextMenu
x={100}
y={200}
product={productData}
onClose={() => setMenuOpen(false)}
/>
export default function ContextMenu({
x,
y,
onClose,
product,
onExcludeProduct,
}: ContextMenuProps) {
const { flashStatusText } = useStatusBar();
const { bookmarksFolderId, setBookmarksFolderId } = useAppContext();
if (!product) return null;
const menuRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<ContextMenuPosition>({ x, y });
// Adjust position to keep menu within viewport
useEffect(() => {
if (menuRef.current) {
const menuRect = menuRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let adjustedX = x;
let adjustedY = y;
// Adjust horizontal position if menu would overflow
if (x + menuRect.width > viewportWidth) {
adjustedX = viewportWidth - menuRect.width - 10;
}
// Adjust vertical position if menu would overflow
if (y + menuRect.height > viewportHeight) {
adjustedY = viewportHeight - menuRect.height - 10;
}
setPosition({ x: adjustedX, y: adjustedY });
}
}, [x, y]);
// Handle click outside to close
useEffect(() => {
/**
* Handles clicks outside the menu to close it.
* @param event - The mouse event
* @source
*/
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
/**
* Handles escape key to close the menu.
* @param event - The keyboard event
* @source
*/
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [onClose]);
/**
* Handles copying the product title to clipboard.
* Shows console feedback on success/failure.
* @source
*/
const handleCopyTitle = async () => {
try {
await navigator.clipboard.writeText(product.title || "Unknown Product");
console.log("Product title copied to clipboard");
} catch (err) {
console.error("Failed to copy product title:", err);
}
onClose();
};
/**
* Handles copying the product URL to clipboard.
* Shows console feedback on success/failure.
* @source
*/
const handleCopyUrl = async () => {
if (product.url) {
try {
await navigator.clipboard.writeText(product.url);
console.log("Product URL copied to clipboard");
} catch (err) {
console.error("Failed to copy product URL:", err);
}
}
onClose();
};
/**
* Handles opening the product URL in a new tab.
* Uses Chrome extension API when available, falls back to window.open.
* @source
*/
const handleOpenInNewTab = async () => {
if (product.url) {
// Chrome extension compatible way to open new tab
if (typeof chrome !== "undefined" && chrome.tabs) {
try {
await chrome.tabs.create({ url: product.url });
} catch {
// Fallback for non-extension environments
window.open(product.url, "_blank", "noopener,noreferrer");
}
} else {
// Fallback for non-extension environments
window.open(product.url, "_blank", "noopener,noreferrer");
}
}
onClose();
};
/**
* Handles creating a bookmark for the product in a "ChemPal Favorites" folder.
* First checks AppContext for a cached folder ID, then scans the full bookmark
* tree if needed, and creates the folder in the bookmarks root as a last resort.
* Persists the resolved folder ID back to AppContext / chrome.storage.
* @source
*/
const handleCreateBookmark = async () => {
const FOLDER_NAME = "ChemPal Favorites";
/**
* Recursively searches the bookmark tree for a folder matching the given name.
* @param nodes - Bookmark tree nodes to search
* @returns The matching folder node, or undefined if not found
*/
const findFolderInTree = (
nodes: chrome.bookmarks.BookmarkTreeNode[],
): chrome.bookmarks.BookmarkTreeNode | undefined => {
for (const node of nodes) {
if (node.title === FOLDER_NAME && !node.url) return node;
if (node.children) {
const found = findFolderInTree(node.children);
if (found) return found;
}
}
return undefined;
};
try {
let folderId = bookmarksFolderId;
// 1. If we have a cached ID, verify it still exists
if (folderId) {
try {
const [existing] = await chrome.bookmarks.get(folderId);
if (!existing || existing.url) {
// ID is stale or points to a bookmark, not a folder
folderId = null;
}
} catch {
// Bookmark was deleted — clear the cached ID
folderId = null;
}
}
// 2. If no cached ID, scan the entire bookmark tree
if (!folderId) {
const tree = await chrome.bookmarks.getTree();
const folder = findFolderInTree(tree);
folderId = folder?.id ?? null;
}
// 3. If still not found, create the folder in the bookmarks root
if (!folderId) {
const rootNodes = await chrome.bookmarks.getTree();
const rootChildren = rootNodes[0]?.children ?? [];
const created = await chrome.bookmarks.create({
parentId: rootChildren[0]?.id,
title: FOLDER_NAME,
});
folderId = created.id;
}
// Persist the resolved folder ID
if (folderId !== bookmarksFolderId) {
setBookmarksFolderId(folderId);
}
// Check if a bookmark with this URL already exists in the folder
const children = await chrome.bookmarks.getChildren(folderId);
const duplicate = children.find((node) => node.url === product.url);
if (duplicate) {
flashStatusText(`Bookmark already exists in ${FOLDER_NAME}`);
} else {
await chrome.bookmarks.create({
parentId: folderId,
title: product.title,
url: product.url,
});
flashStatusText(`Bookmark created at ${FOLDER_NAME}/${product.title}`);
}
} catch (error) {
console.error("Failed to create bookmark:", { error, product });
}
onClose();
};
/**
* Handles sharing the product.
* Uses native Web Share API when available, falls back to copying URL.
* @source
*/
const handleShare = async () => {
if (navigator.share && product.url) {
try {
await navigator.share({
title: product.title || "Chemical Product",
text: `Check out this chemical product: ${product.title}`,
url: product.url,
});
} catch (error) {
console.error("Share failed, falling back to clipboard", { error });
await handleCopyUrl();
}
} else {
// Fallback to copying URL
await handleCopyUrl();
}
onClose();
};
/**
* Handles viewing detailed product information.
* Currently logs to console - needs integration with details modal/panel.
* @source
*/
const handleViewDetails = () => {
// TODO: Implement product details modal/panel
console.log("Viewing details", { product });
onClose();
};
/**
* Handles quick search for similar products.
* Currently logs to console - needs integration with search system.
* @source
*/
const handleQuickSearch = () => {
// TODO: Implement quick search for similar products
console.log("Quick search", { product });
onClose();
};
/**
* Delegates the "Ignore Product" action to the parent via the
* `onExcludeProduct` callback, which is responsible for both persisting the
* exclusion to chrome.storage.local and removing the row from the visible
* results. We only close the menu here.
* @source
*/
const handleIgnoreProduct = async () => {
try {
await onExcludeProduct?.(product);
} catch (error) {
console.warn("Failed to ignore product:", { error });
}
onClose();
};
/**
* Handles copying formatted product information to clipboard.
* Includes title, price, supplier, and URL in a readable format.
* @source
*/
const handleCopyProductInfo = async () => {
const productInfo = [
product.title,
`Price: ${product.currencySymbol}${product.price}`,
`Supplier: ${product.supplier}`,
`URL: ${product.url}`,
];
if (product.description) {
productInfo.push(`Description: ${product.description}`);
}
try {
await navigator.clipboard.writeText(productInfo.join("\n"));
console.log("Product info copied to clipboard", { productInfo });
} catch (error) {
console.error("Failed to copy product info", { error });
}
onClose();
};
return (
<Paper
className={styles["context-menu-paper"]}
ref={menuRef}
elevation={8}
style={{
top: position.y,
left: position.x,
}}
>
<MenuList dense>
<MenuItem className={styles["context-menu-item"]} onClick={handleCopyTitle}>
<ListItemIcon>
<CopyIcon fontSize="small" />
</ListItemIcon>
<ListItemText className={styles["context-menu-option-text"]} primary="Copy Title" />
</MenuItem>
<MenuItem
className={styles["context-menu-item"]}
onClick={handleCopyUrl}
disabled={!product.url}
>
<ListItemIcon>
<HttpIcon fontSize="small" />
</ListItemIcon>
<ListItemText className={styles["context-menu-option-text"]} primary="Copy URL" />
</MenuItem>
<MenuItem className={styles["context-menu-item"]} onClick={handleCopyProductInfo}>
<ListItemIcon>
<CopyIcon fontSize="small" />
</ListItemIcon>
<ListItemText
className={styles["context-menu-option-text"]}
primary="Copy Product Info"
/>
</MenuItem>
<Divider />
<MenuItem className={styles["context-menu-item"]} onClick={handleIgnoreProduct}>
<ListItemIcon>
<BlockIcon fontSize="small" />
</ListItemIcon>
<ListItemText className={styles["context-menu-option-text"]} primary="Ignore Product" />
</MenuItem>
<Divider />
<MenuItem
className={styles["context-menu-item"]}
onClick={handleOpenInNewTab}
disabled={!product.url}
>
<ListItemIcon>
<ArrowRightIcon fontSize="small" />
</ListItemIcon>
<ListItemText className={styles["context-menu-option-text"]} primary="Open in New Tab" />
</MenuItem>
<MenuItem className={styles["context-menu-item"]} onClick={handleViewDetails}>
<ListItemIcon>
<InfoOutlineIcon fontSize="small" />
</ListItemIcon>
<ListItemText className={styles["context-menu-option-text"]} primary="View Details" />
</MenuItem>
<Divider />
<MenuItem className={styles["context-menu-item"]} onClick={handleCreateBookmark}>
<ListItemIcon>
<BookmarkIcon fontSize="small" />
</ListItemIcon>
<ListItemText className={styles["context-menu-option-text"]} primary="Create Bookmark" />
</MenuItem>
<MenuItem className={styles["context-menu-item"]} onClick={handleQuickSearch}>
<ListItemIcon>
<SearchIcon fontSize="small" />
</ListItemIcon>
<ListItemText className={styles["context-menu-option-text"]} primary="Search Similar" />
</MenuItem>
<MenuItem
className={styles["context-menu-item"]}
onClick={handleShare}
disabled={!product.url}
>
<ListItemIcon>
<SettingsIcon fontSize="small" />
</ListItemIcon>
<ListItemText className={styles["context-menu-option-text"]} primary="Share" />
</MenuItem>
</MenuList>
</Paper>
);
}
Context menu component for table rows with Chrome extension-compatible implementation.
Features: