ChemPal Documentation - v0.0.13-beta.5
    Preparing search index...
    • Context menu component for table rows with Chrome extension-compatible implementation.

      Features:

      • Right-click context menu for product rows
      • Chrome extension security policy compliant
      • Keyboard navigation support
      • Auto-positioning to stay within viewport
      • Click-outside-to-close functionality

      Parameters

      Returns null | Element

      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>
      );
      }