From 74d61f9c5d3bdcf65a670292958b13cc5ac93149 Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Fri, 9 Jan 2026 10:54:29 -0800 Subject: [PATCH] Add bookmark icon to sticky header on issue pages GitHub renders two separate header containers: the main header and a sticky header that appears when scrolling. Previously the bookmark button was only inserted into the main header, causing it to disappear when scrolling triggered the sticky header. Now inserts bookmark buttons into both headers and keeps them in sync when clicked. The MutationObserver handles inserting into the sticky header when it appears during scroll. Fixes #7 Co-Authored-By: Claude Opus 4.5 --- extension/assets/content.js | 126 ++++++++++++++++++++---------------- tests/extension.spec.js | 64 ++++++++++++++++-- 2 files changed, 131 insertions(+), 59 deletions(-) diff --git a/extension/assets/content.js b/extension/assets/content.js index 392d2e9..1aa3b43 100644 --- a/extension/assets/content.js +++ b/extension/assets/content.js @@ -75,6 +75,9 @@ if (typeof browser === 'undefined' && typeof chrome !== 'undefined') { // Selector for page header actions (using prefix to avoid CSS module hash) const HEADER_ACTIONS_SELECTOR = '[data-component="PH_Actions"] [class*="HeaderMenu-module__menuActionsContainer"]'; + // Selector for sticky header actions (appears when scrolling) + const STICKY_HEADER_ACTIONS_SELECTOR = '[class*="HeaderMetadata-module__stickyContainer"] [class*="HeaderMenu-module__menuActionsContainer"]'; + // Extension button marker const BOOKMARK_BUTTON_ATTR = 'data-extension-bookmark'; @@ -122,8 +125,14 @@ if (typeof browser === 'undefined' && typeof chrome !== 'undefined') { button.setAttribute('aria-label', bookmarked ? 'Remove bookmark' : 'Bookmark issue'); } + // Update all bookmark buttons on the page (main header and sticky header) + function updateAllBookmarkButtons(bookmarked) { + const buttons = document.querySelectorAll(`[${BOOKMARK_BUTTON_ATTR}]`); + buttons.forEach(button => updateBookmarkButton(button, bookmarked)); + } + // Handle bookmark button click - async function handleBookmarkClick(button) { + async function handleBookmarkClick() { const issueData = getIssueData(); if (!issueData) { return; @@ -139,7 +148,7 @@ if (typeof browser === 'undefined' && typeof chrome !== 'undefined') { data: { id: issueData.id } }); console.log('[GitHub Bookmarked Issues] Removed bookmark:', issueData.id); - updateBookmarkButton(button, false); + updateAllBookmarkButtons(false); } else { // Add bookmark await browser.runtime.sendMessage({ @@ -147,7 +156,7 @@ if (typeof browser === 'undefined' && typeof chrome !== 'undefined') { data: issueData }); console.log('[GitHub Bookmarked Issues] Added bookmark:', issueData.id); - updateBookmarkButton(button, true); + updateAllBookmarkButtons(true); } } catch (error) { console.error('[GitHub Bookmarked Issues] Failed to toggle bookmark:', error); @@ -155,42 +164,20 @@ if (typeof browser === 'undefined' && typeof chrome !== 'undefined') { } } - // Create and insert bookmark button - async function insertBookmarkButton() { - const timestamp = performance.now().toFixed(1); - const issueData = getIssueData(); - if (!issueData) { - console.log(`[GitHub Bookmarked Issues] [${timestamp}ms] Not on an issue page`); - return; - } - - const actionsContainer = document.querySelector(HEADER_ACTIONS_SELECTOR); - if (!actionsContainer) { - console.log(`[GitHub Bookmarked Issues] [${timestamp}ms] Header actions not found, selector: ${HEADER_ACTIONS_SELECTOR}`); - return; - } - - // Check if button already exists - if (document.querySelector(`[${BOOKMARK_BUTTON_ATTR}]`)) { - console.log(`[GitHub Bookmarked Issues] [${timestamp}ms] Bookmark button already exists`); - return; - } - - console.log(`[GitHub Bookmarked Issues] [${timestamp}ms] Creating button, container children: ${actionsContainer.children.length}`); - - // Create bookmark button - const bookmarkButton = document.createElement('button'); - bookmarkButton.setAttribute('data-component', 'IconButton'); - bookmarkButton.setAttribute('type', 'button'); - bookmarkButton.className = 'prc-Button-ButtonBase-c50BI prc-Button-IconButton-szpyj'; - bookmarkButton.setAttribute('data-loading', 'false'); - bookmarkButton.setAttribute('data-no-visuals', 'true'); - bookmarkButton.setAttribute('data-size', 'medium'); - bookmarkButton.setAttribute('data-variant', 'invisible'); - bookmarkButton.setAttribute(BOOKMARK_BUTTON_ATTR, 'true'); + // Create a bookmark button element + function createBookmarkButton(bookmarked) { + const button = document.createElement('button'); + button.setAttribute('data-component', 'IconButton'); + button.setAttribute('type', 'button'); + button.className = 'prc-Button-ButtonBase-c50BI prc-Button-IconButton-szpyj'; + button.setAttribute('data-loading', 'false'); + button.setAttribute('data-no-visuals', 'true'); + button.setAttribute('data-size', 'medium'); + button.setAttribute('data-variant', 'invisible'); + button.setAttribute(BOOKMARK_BUTTON_ATTR, 'true'); // Add inline styles to match GitHub's native icon buttons - bookmarkButton.style.cssText = ` + button.style.cssText = ` border: none; background: transparent; padding: 0; @@ -202,28 +189,57 @@ if (typeof browser === 'undefined' && typeof chrome !== 'undefined') { color: inherit; `; - // Check if already bookmarked and set initial state - const bookmarked = await isBookmarked(issueData.id); - updateBookmarkButton(bookmarkButton, bookmarked); + updateBookmarkButton(button, bookmarked); + button.addEventListener('click', () => handleBookmarkClick()); + + return button; + } + + // Insert bookmark button into a container if not already present + function insertButtonIntoContainer(container, bookmarked, containerName) { + // Check if this container already has a bookmark button + if (container.querySelector(`[${BOOKMARK_BUTTON_ATTR}]`)) { + return false; + } + + const button = createBookmarkButton(bookmarked); + container.appendChild(button); + + const timestamp = performance.now().toFixed(1); + console.log(`[GitHub Bookmarked Issues] [${timestamp}ms] Bookmark button added to ${containerName}`); + return true; + } + + // Create and insert bookmark buttons into all available headers + async function insertBookmarkButton() { + const timestamp = performance.now().toFixed(1); + const issueData = getIssueData(); + if (!issueData) { + console.log(`[GitHub Bookmarked Issues] [${timestamp}ms] Not on an issue page`); + return; + } - // Add click handler - bookmarkButton.addEventListener('click', () => handleBookmarkClick(bookmarkButton)); + // Find both header containers + const mainHeader = document.querySelector(HEADER_ACTIONS_SELECTOR); + const stickyHeader = document.querySelector(STICKY_HEADER_ACTIONS_SELECTOR); - // Insert as last child in header actions - actionsContainer.appendChild(bookmarkButton); + if (!mainHeader && !stickyHeader) { + console.log(`[GitHub Bookmarked Issues] [${timestamp}ms] No header actions found`); + return; + } - const insertTimestamp = performance.now().toFixed(1); - console.log(`[GitHub Bookmarked Issues] [${insertTimestamp}ms] Bookmark button added to DOM, parent: ${actionsContainer.className}`); + // Check if already bookmarked (only once for both buttons) + const bookmarked = await isBookmarked(issueData.id); - // Debug: watch for button removal (helps diagnose timing issues) - const removalObserver = new MutationObserver(() => { - if (!document.querySelector(`[${BOOKMARK_BUTTON_ATTR}]`)) { - const removeTimestamp = performance.now().toFixed(1); - console.log(`[GitHub Bookmarked Issues] [${removeTimestamp}ms] Button was REMOVED from DOM`); - removalObserver.disconnect(); - } - }); - removalObserver.observe(actionsContainer.parentElement || document.body, { childList: true, subtree: true }); + // Insert into main header if available + if (mainHeader) { + insertButtonIntoContainer(mainHeader, bookmarked, 'main header'); + } + + // Insert into sticky header if available + if (stickyHeader) { + insertButtonIntoContainer(stickyHeader, bookmarked, 'sticky header'); + } } // Track current URL to detect navigation diff --git a/tests/extension.spec.js b/tests/extension.spec.js index b841d8f..753aa8a 100644 --- a/tests/extension.spec.js +++ b/tests/extension.spec.js @@ -167,7 +167,8 @@ test.describe('', () => { await expect(page.locator(headerActionsSelector)).toBeVisible({ timeout: 15000 }); - const bookmarkButton = page.locator(bookmarkSelector); + // Use main header selector to avoid matching sticky header button + const bookmarkButton = page.locator(`${headerActionsSelector} ${bookmarkSelector}`); await expect(bookmarkButton).toBeVisible({ timeout: 10000 }); // Click to add bookmark @@ -189,6 +190,58 @@ test.describe('', () => { expect(Object.keys(bookmarks)).not.toContain(expectedKey); }).toPass({ timeout: 5000 }); }); + + test('bookmark button in sticky header', async () => { + const bookmarkSelector = '[data-extension-bookmark]'; + const headerActionsSelector = '[data-component="PH_Actions"]'; + const stickyHeaderSelector = '[class*="HeaderMetadata-module__stickyContainer"]'; + + await clearBookmarks(context); + + // Use an issue with enough content to scroll + await page.goto('https://github.com/microsoft/playwright/issues/11975'); + await page.waitForLoadState('domcontentloaded'); + + await expect(page.locator(headerActionsSelector)).toBeVisible({ timeout: 15000 }); + + // Verify bookmark button exists in main header + const mainBookmarkButton = page.locator(`${headerActionsSelector} ${bookmarkSelector}`); + await expect(mainBookmarkButton).toBeVisible({ timeout: 10000 }); + + // Scroll down to trigger sticky header + await page.evaluate(() => window.scrollTo(0, 1500)); + await page.waitForTimeout(500); + + // Verify sticky header appeared + await expect(page.locator(stickyHeaderSelector)).toBeVisible({ timeout: 5000 }); + + // Verify bookmark button exists in sticky header + const stickyBookmarkButton = page.locator(`${stickyHeaderSelector} ${bookmarkSelector}`); + await expect(stickyBookmarkButton).toBeVisible({ timeout: 5000 }); + + // Click sticky header bookmark button to add bookmark + await stickyBookmarkButton.click(); + + // Verify bookmark was added + const expectedKey = 'microsoft/playwright/issues/11975'; + await expect(async () => { + const bookmarks = await getBookmarks(context); + expect(Object.keys(bookmarks)).toContain(expectedKey); + }).toPass({ timeout: 5000 }); + + // Scroll back up and verify main header button is also in bookmarked state + await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(500); + + // Click main header button to remove bookmark (verifies sync) + await mainBookmarkButton.click(); + + // Verify bookmark was removed + await expect(async () => { + const bookmarks = await getBookmarks(context); + expect(Object.keys(bookmarks)).not.toContain(expectedKey); + }).toPass({ timeout: 5000 }); + }); }); // Bookmark button navigation tests - ensure button appears regardless of navigation path @@ -208,7 +261,8 @@ test.describe('', () => { // Wait for header actions container (needed for button insertion) await expect(page.locator(headerActionsSelector)).toBeVisible({ timeout: 15000 }); - const bookmarkButton = page.locator(bookmarkSelector); + // Use main header selector to avoid matching sticky header button + const bookmarkButton = page.locator(`${headerActionsSelector} ${bookmarkSelector}`); await expect(bookmarkButton).toBeVisible({ timeout: 10000 }); }); @@ -225,7 +279,8 @@ test.describe('', () => { // Wait for issue page to load await expect(page.locator(headerActionsSelector)).toBeVisible({ timeout: 15000 }); - const bookmarkButton = page.locator(bookmarkSelector); + // Use main header selector to avoid matching sticky header button + const bookmarkButton = page.locator(`${headerActionsSelector} ${bookmarkSelector}`); await expect(bookmarkButton).toBeVisible({ timeout: 10000 }); }); @@ -247,7 +302,8 @@ test.describe('', () => { // Wait for issue page to load await expect(page.locator(headerActionsSelector)).toBeVisible({ timeout: 15000 }); - const bookmarkButton = page.locator(bookmarkSelector); + // Use main header selector to avoid matching sticky header button + const bookmarkButton = page.locator(`${headerActionsSelector} ${bookmarkSelector}`); await expect(bookmarkButton).toBeVisible({ timeout: 10000 }); });