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:
@@ -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(
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">;
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user