1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-07 20:24:01 +00:00

add context menu option to generate and copy a targeted element's query path

This commit is contained in:
Jonathan Prusik
2025-10-14 16:22:42 -04:00
parent 6809519184
commit 189cd86941
6 changed files with 103 additions and 10 deletions

View File

@@ -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<string>((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<string>((resolve, reject) => {
BrowserApi.sendTabsMessage(

View File

@@ -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[] = [
{

View File

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

View File

@@ -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;

View File

@@ -1,7 +1,8 @@
export type TabMessage =
| CopyTextTabMessage
| ClearClipboardTabMessage
| GetClickedElementTabMessage;
| GetClickedElementTabMessage
| GetClickedElementPathTabMessage;
export type TabMessageBase<T extends string> = {
command: T;
@@ -14,3 +15,5 @@ type CopyTextTabMessage = TabMessageBase<"copyText"> & {
type ClearClipboardTabMessage = TabMessageBase<"clearClipboard">;
type GetClickedElementTabMessage = TabMessageBase<"getClickedElement">;
type GetClickedElementPathTabMessage = TabMessageBase<"getClickedElementPath">;

View File

@@ -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";