diff --git a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts index c33cb6a4371..5c76fd652a7 100644 --- a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts +++ b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts @@ -13,6 +13,7 @@ import { AUTOFILL_ID, AUTOFILL_IDENTITY_ID, COPY_IDENTIFIER_ID, + COPY_ELEMENT_PATH_ID, COPY_PASSWORD_ID, COPY_USERNAME_ID, COPY_VERIFICATION_CODE_ID, @@ -74,6 +75,9 @@ export class ContextMenuClickedHandler { case COPY_IDENTIFIER_ID: this.copyToClipboard({ text: await this.getIdentifier(tab, info), tab: tab }); break; + case COPY_ELEMENT_PATH_ID: + this.copyToClipboard({ text: await this.getElementPath(tab, info), tab: tab }); + break; default: await this.cipherAction(info, tab); } @@ -237,6 +241,24 @@ export class ContextMenuClickedHandler { : null; } + private async getElementPath(tab: chrome.tabs.Tab, info: chrome.contextMenus.OnClickData) { + return new Promise((resolve, reject) => { + BrowserApi.sendTabsMessage( + tab.id, + { command: "getClickedElementPath" }, + { frameId: info.frameId }, + (identifier: string) => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + return; + } + + resolve(identifier); + }, + ); + }); + } + private async getIdentifier(tab: chrome.tabs.Tab, info: chrome.contextMenus.OnClickData) { return new Promise((resolve, reject) => { BrowserApi.sendTabsMessage( diff --git a/apps/browser/src/autofill/browser/main-context-menu-handler.ts b/apps/browser/src/autofill/browser/main-context-menu-handler.ts index 00ff55f5517..d740db750f5 100644 --- a/apps/browser/src/autofill/browser/main-context-menu-handler.ts +++ b/apps/browser/src/autofill/browser/main-context-menu-handler.ts @@ -9,6 +9,7 @@ import { AUTOFILL_ID, AUTOFILL_IDENTITY_ID, COPY_IDENTIFIER_ID, + COPY_ELEMENT_PATH_ID, COPY_PASSWORD_ID, COPY_USERNAME_ID, COPY_VERIFICATION_CODE_ID, @@ -95,6 +96,12 @@ export class MainContextMenuHandler { title: this.i18nService.t("copyElementIdentifier"), requiresUnblockedUri: true, }, + { + id: COPY_ELEMENT_PATH_ID, + parentId: ROOT_ID, + title: "Copy element path", + requiresUnblockedUri: true, + }, ]; private noCardsContextMenuItems: chrome.contextMenus.CreateProperties[] = [ { diff --git a/apps/browser/src/autofill/content/context-menu-handler.ts b/apps/browser/src/autofill/content/context-menu-handler.ts index 82cf95afc81..226eda62486 100644 --- a/apps/browser/src/autofill/content/context-menu-handler.ts +++ b/apps/browser/src/autofill/content/context-menu-handler.ts @@ -1,12 +1,11 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore const inputTags = ["input", "textarea", "select"]; const labelTags = ["label", "span"]; const attributes = ["id", "name", "label-aria", "placeholder"]; const invalidElement = chrome.i18n.getMessage("copyCustomFieldNameInvalidElement"); const noUniqueIdentifier = chrome.i18n.getMessage("copyCustomFieldNameNotUnique"); -let clickedEl: HTMLElement = null; +let clickedEl: HTMLElement | null = null; +let clickedElComposedPath: EventTarget[] | null = null; // Find the best attribute to be used as the Name for an element in a custom field. function getClickedElementIdentifier() { @@ -26,7 +25,7 @@ function getClickedElementIdentifier() { inputId = clickedEl.closest("label")?.getAttribute("for"); } - inputEl = document.getElementById(inputId); + inputEl = inputId ? document.getElementById(inputId) : null; } else { inputEl = clickedEl; } @@ -38,13 +37,58 @@ function getClickedElementIdentifier() { for (const attr of attributes) { const attributeValue = inputEl.getAttribute(attr); const selector = "[" + attr + '="' + attributeValue + '"]'; - if (!isNullOrEmpty(attributeValue) && document.querySelectorAll(selector)?.length === 1) { + if ( + attributeValue && + !isNullOrEmpty(attributeValue) && + document.querySelectorAll(selector)?.length === 1 + ) { return attributeValue; } } return noUniqueIdentifier; } +function getClickedElementPath() { + if (!clickedElComposedPath?.length) { + return invalidElement; + } + + const querySelector = clickedElComposedPath.reduce((builtQuery, target, index) => { + // Skip the Window and Document objects at the end of the path + if (!(target instanceof Element)) { + return builtQuery; + } + + const targetName = target.nodeName.toLowerCase(); + let selector = targetName; + + // Add id if present + if (target instanceof HTMLElement && target.id) { + selector += `#${target.id}`; + } + + // Check if next item is a shadow root + const nextTarget = clickedElComposedPath?.[index + 1]; + const isShadowRoot = nextTarget instanceof ShadowRoot; + + // Add separator based on whether we're crossing a shadow boundary + if (builtQuery) { + if (isShadowRoot) { + builtQuery = selector + " >>> " + builtQuery; + } else { + builtQuery = selector + " > " + builtQuery; + } + } else { + builtQuery = selector; + } + + return builtQuery; + }, ""); + + // @TODO If `clickedElComposedPath` ends with a closed shadowRoot, send a message to background to recursively pierce the closed root and find the first visible input or else the first nested closed shadowRoot. Continue until an input is found or neither an input nor closed shadow root is found at the deepest-traversed node tree level. Append the additional query rules for traversed closed shadow roots to the query string returned here. + return querySelector; +} + function isNullOrEmpty(s: string) { return s == null || s === ""; } @@ -53,6 +97,7 @@ function isNullOrEmpty(s: string) { // Remember it for use later. document.addEventListener("contextmenu", (event) => { clickedEl = event.target as HTMLElement; + clickedElComposedPath = event.composedPath(); }); // Runs when the 'Copy Custom Field Name' context menu item is actually clicked. @@ -62,12 +107,24 @@ chrome.runtime.onMessage.addListener((event, _sender, sendResponse) => { if (sendResponse) { sendResponse(identifier); } - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - chrome.runtime.sendMessage({ + + void chrome.runtime.sendMessage({ command: "getClickedElementResponse", sender: "contextMenuHandler", - identifier: identifier, + identifier, + }); + } + + if (event.command === "getClickedElementPath") { + const path = getClickedElementPath(); + if (sendResponse) { + sendResponse(path); + } + + void chrome.runtime.sendMessage({ + command: "getClickedElementPathResponse", + sender: "contextMenuHandler", + path, }); } }); diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index e364157820e..e609fa81de1 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -360,6 +360,9 @@ export default class RuntimeBackground { case "getClickedElementResponse": this.platformUtilsService.copyToClipboard(msg.identifier); break; + case "getClickedElementPathResponse": + this.platformUtilsService.copyToClipboard(msg.path); + break; case "switchAccount": { await this.main.switchAccount(msg.userId); break; diff --git a/apps/browser/src/types/tab-messages.ts b/apps/browser/src/types/tab-messages.ts index dbedb3c4a55..df99890392f 100644 --- a/apps/browser/src/types/tab-messages.ts +++ b/apps/browser/src/types/tab-messages.ts @@ -1,7 +1,8 @@ export type TabMessage = | CopyTextTabMessage | ClearClipboardTabMessage - | GetClickedElementTabMessage; + | GetClickedElementTabMessage + | GetClickedElementPathTabMessage; export type TabMessageBase = { command: T; @@ -14,3 +15,5 @@ type CopyTextTabMessage = TabMessageBase<"copyText"> & { type ClearClipboardTabMessage = TabMessageBase<"clearClipboard">; type GetClickedElementTabMessage = TabMessageBase<"getClickedElement">; + +type GetClickedElementPathTabMessage = TabMessageBase<"getClickedElementPath">; diff --git a/libs/common/src/autofill/constants/index.ts b/libs/common/src/autofill/constants/index.ts index 7d14ac7245d..0cb2b2594f2 100644 --- a/libs/common/src/autofill/constants/index.ts +++ b/libs/common/src/autofill/constants/index.ts @@ -44,6 +44,7 @@ export const AUTOFILL_ID = "autofill"; export const SHOW_AUTOFILL_BUTTON = "show-autofill-button"; export const AUTOFILL_IDENTITY_ID = "autofill-identity"; export const COPY_IDENTIFIER_ID = "copy-identifier"; +export const COPY_ELEMENT_PATH_ID = "copy-element-path"; export const COPY_PASSWORD_ID = "copy-password"; export const COPY_USERNAME_ID = "copy-username"; export const COPY_VERIFICATION_CODE_ID = "copy-totp";