From ba84eb5ac165cb4458c99b99755aee015a927344 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sun, 28 Dec 2025 23:36:20 +0100 Subject: [PATCH] add right context menu for pinning --- extension/manifest.json | 2 +- extension/src/background.ts | 86 +++++++++++++++++++++++++++++++++++++ extension/src/types.ts | 1 + playwriter/src/prompt.md | 29 +++++++++++++ 4 files changed, 117 insertions(+), 1 deletion(-) diff --git a/extension/manifest.json b/extension/manifest.json index b1f2f37..f6ca0f0 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -3,7 +3,7 @@ "name": "Playwriter MCP", "version": "0.0.55", "description": "Automate your Browser using Cursor, Claude, VS Code. More capable and context efficient than Playwright MCP.", - "permissions": ["debugger", "tabGroups"], + "permissions": ["debugger", "tabGroups", "contextMenus"], "host_permissions": [""], "background": { "service_worker": "lib/background.mjs", diff --git a/extension/src/background.ts b/extension/src/background.ts index df2a0f4..27743cf 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -368,6 +368,17 @@ async function attachTab(tabId: number): Promise { await chrome.debugger.sendCommand(debuggee, 'Page.enable') + await chrome.debugger.sendCommand(debuggee, 'Runtime.evaluate', { + expression: ` + if (!window.__playwriter_contextmenu_listener) { + window.__playwriter_contextmenu_listener = true; + document.addEventListener('contextmenu', (e) => { + window.__playwriter_lastRightClicked = e.target; + }, true); + } + `, + }) + const result = (await chrome.debugger.sendCommand( debuggee, 'Target.getTargetInfo', @@ -935,6 +946,19 @@ async function onActionClicked(tab: chrome.tabs.Tab): Promise { resetDebugger() +chrome.contextMenus.create({ + id: 'playwriter-pin-element', + title: 'Pin to Playwriter', + contexts: ['all'], + visible: false, +}) + +function updateContextMenuVisibility(): void { + const { currentTabId, tabs } = store.getState() + const isConnected = currentTabId !== undefined && tabs.get(currentTabId)?.state === 'connected' + chrome.contextMenus.update('playwriter-pin-element', { visible: isConnected }) +} + chrome.runtime.onInstalled.addListener((details) => { if (import.meta.env.TESTING) return if (details.reason === 'install') { @@ -949,6 +973,7 @@ function serializeTabs(tabs: Map): string { store.subscribe((state, prevState) => { logger.log(state) void updateIcons() + updateContextMenuVisibility() const tabsChanged = serializeTabs(state.tabs) !== serializeTabs(prevState.tabs) if (tabsChanged) { syncTabGroupQueue = syncTabGroupQueue.then(syncTabGroup).catch((e) => { @@ -965,5 +990,66 @@ chrome.tabs.onUpdated.addListener(() => { void updateIcons() }) +chrome.contextMenus.onClicked.addListener(async (info, tab) => { + if (info.menuItemId !== 'playwriter-pin-element' || !tab?.id) return + + const tabInfo = store.getState().tabs.get(tab.id) + if (!tabInfo || tabInfo.state !== 'connected') { + logger.debug('Tab not connected, ignoring') + return + } + + const debuggee = { tabId: tab.id } + const count = (tabInfo.pinnedCount || 0) + 1 + + store.setState((state) => { + const newTabs = new Map(state.tabs) + const existing = newTabs.get(tab.id!) + if (existing) { + newTabs.set(tab.id!, { ...existing, pinnedCount: count }) + } + return { tabs: newTabs } + }) + + const name = `playwriterPinnedElem${count}` + + try { + const result = (await chrome.debugger.sendCommand(debuggee, 'Runtime.evaluate', { + expression: ` + if (window.__playwriter_lastRightClicked) { + window.${name} = window.__playwriter_lastRightClicked; + '${name}'; + } else { + throw new Error('No element was right-clicked'); + } + `, + returnByValue: true, + })) as { result?: { value?: string }; exceptionDetails?: { text: string } } + + if (result.exceptionDetails) { + logger.error('Failed to pin element:', result.exceptionDetails.text) + return + } + + await chrome.debugger.sendCommand(debuggee, 'Runtime.evaluate', { + expression: ` + (() => { + const el = window.${name}; + if (!el) return; + const orig = el.getAttribute('style') || ''; + el.setAttribute('style', orig + '; outline: 3px solid #22c55e !important; outline-offset: 2px !important; box-shadow: 0 0 0 3px #22c55e !important;'); + setTimeout(() => el.setAttribute('style', orig), 300); + return navigator.clipboard.writeText('globalThis.${name}'); + })() + `, + awaitPromise: true, + }) + + logger.debug('Pinned element as:', name) + } catch (error: any) { + logger.error('Failed to pin element:', error.message) + } +}) + // Sync icons on first load void updateIcons() diff --git a/extension/src/types.ts b/extension/src/types.ts index 6239526..d1f8d0e 100644 --- a/extension/src/types.ts +++ b/extension/src/types.ts @@ -6,6 +6,7 @@ export interface TabInfo { targetId?: string state: TabState errorText?: string + pinnedCount?: number } export interface ExtensionState { diff --git a/playwriter/src/prompt.md b/playwriter/src/prompt.md index 541c7b9..a005957 100644 --- a/playwriter/src/prompt.md +++ b/playwriter/src/prompt.md @@ -139,6 +139,25 @@ you have access to some functions in addition to playwright methods: // search only in CSS files const cssMatches = await editor.grep({ regex: /background-color/, pattern: /\.css/ }); ``` +- `getStylesForLocator({ locator, includeUserAgentStyles? })`: gets the CSS styles applied to an element, similar to browser DevTools "Styles" panel. + - `locator`: a Playwright Locator for the element to inspect + - `includeUserAgentStyles`: (optional, default: false) include browser default styles + - Returns: `StylesResult` object with: + - `element`: string description of the element (e.g. `div#main.container`) + - `inlineStyle`: object of `{ property: value }` inline styles, or null + - `rules`: array of CSS rules that apply to this element, each with: + - `selector`: the CSS selector that matched + - `source`: `{ url, line, column }` location in the stylesheet, or null + - `origin`: `"regular"` | `"user-agent"` | `"injected"` | `"inspector"` + - `declarations`: object of `{ property: value }` (values include `!important` if applicable) + - `inheritedFrom`: element description if inherited (e.g. `body`), or null for direct matches + - Example: + ```js + const loc = page.locator('.my-button'); + const styles = await getStylesForLocator({ locator: loc }); + console.log(formatStylesAsText(styles)); + ``` +- `formatStylesAsText(styles)`: formats a `StylesResult` object as human-readable text. Use this to display styles in a readable format. example: @@ -184,6 +203,16 @@ const stableLocator = page.getByRole('button', { name: 'Save' }) await stableLocator.click(); ``` +## pinned elements (user right-click to pin) + +Users can right-click an element and select "Pin to Playwriter" to store it in `globalThis.playwriterPinnedElem1` (increments for each pin). The variable name is copied to clipboard. + +```js +const el = await page.evaluateHandle(() => globalThis.playwriterPinnedElem1); +await el.click(); +const selector = await getLocatorStringForElement(el); +``` + ## finding specific elements with snapshot You can use `search` to find specific elements in the snapshot without reading the whole page structure. This is useful for finding forms, textareas, or specific text.