diff --git a/apps/browser/src/autofill/background/abstractions/overlay.background.ts b/apps/browser/src/autofill/background/abstractions/overlay.background.ts index 462acb818b..3c67872e23 100644 --- a/apps/browser/src/autofill/background/abstractions/overlay.background.ts +++ b/apps/browser/src/autofill/background/abstractions/overlay.background.ts @@ -38,6 +38,18 @@ export type FocusedFieldData = { frameId?: number; }; +export type InlineMenuElementPosition = { + top: number; + left: number; + width: number; + height: number; +}; + +export type InlineMenuPosition = { + button?: InlineMenuElementPosition; + list?: InlineMenuElementPosition; +}; + export type OverlayAddNewItemMessage = { login?: { uri?: string; @@ -120,6 +132,7 @@ export type OverlayBackgroundExtensionMessageHandlers = { message, sender, }: BackgroundOnMessageHandlerParams) => Promise; + getAutofillInlineMenuPosition: () => InlineMenuPosition; updateAutofillInlineMenuElementIsVisibleStatus: ({ message, sender, diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index 81a7754f84..41d9d8ec32 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -319,7 +319,8 @@ describe("OverlayBackground", () => { }); describe("removing pageDetails", () => { - it("removes the page details and port key for a specific tab from the pageDetailsForTab object", () => { + it("removes the page details and port key for a specific tab from the pageDetailsForTab object", async () => { + await initOverlayElementPorts(); const tabId = 1; sendMockExtensionMessage( { command: "collectPageDetailsResponse", details: createAutofillPageDetailsMock() }, @@ -1402,6 +1403,25 @@ describe("OverlayBackground", () => { }); }); + describe("getAutofillInlineMenuPosition", () => { + it("returns the current inline menu position", async () => { + overlayBackground["inlineMenuPosition"] = { + button: { left: 1, top: 2, width: 3, height: 4 }, + }; + + sendMockExtensionMessage( + { command: "getAutofillInlineMenuPosition" }, + mock(), + sendResponse, + ); + await flushPromises(); + + expect(sendResponse).toHaveBeenCalledWith({ + button: { left: 1, top: 2, width: 3, height: 4 }, + }); + }); + }); + it("triggers a debounced reposition of the inline menu if the sender frame has a `null` sub frame offsets value", async () => { jest.useFakeTimers(); const focusedFieldData = createFocusedFieldDataMock(); @@ -2095,6 +2115,22 @@ describe("OverlayBackground", () => { styles: { height: "100px" }, }); }); + + it("updates the inline menu position property's list height value", () => { + overlayBackground["inlineMenuPosition"] = { + list: { height: 50, top: 1, left: 2, width: 3 }, + }; + + sendPortMessage(listMessageConnectorSpy, { + command: "updateAutofillInlineMenuListHeight", + styles: { height: "100px" }, + portKey, + }); + + expect(overlayBackground["inlineMenuPosition"]).toStrictEqual({ + list: { height: 100, top: 1, left: 2, width: 3 }, + }); + }); }); }); diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index 3b770af200..74ec507109 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -52,6 +52,7 @@ import { SubFrameOffsetData, SubFrameOffsetsForTab, CloseInlineMenuMessage, + InlineMenuPosition, ToggleInlineMenuHiddenMessage, } from "./abstractions/overlay.background"; @@ -67,6 +68,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { private inlineMenuListPort: chrome.runtime.Port; private inlineMenuCiphers: Map = new Map(); private inlineMenuPageTranslations: Record; + private inlineMenuPosition: InlineMenuPosition = {}; private delayedCloseTimeout: number | NodeJS.Timeout; private startInlineMenuFadeInSubject = new Subject(); private cancelInlineMenuFadeInSubject = new Subject(); @@ -99,6 +101,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { focusAutofillInlineMenuList: () => this.focusInlineMenuList(), updateAutofillInlineMenuPosition: ({ message, sender }) => this.updateInlineMenuPosition(message, sender), + getAutofillInlineMenuPosition: () => this.getInlineMenuPosition(), updateAutofillInlineMenuElementIsVisibleStatus: ({ message, sender }) => this.updateInlineMenuElementIsVisibleStatus(message, sender), checkIsAutofillInlineMenuButtonVisible: () => this.checkIsInlineMenuButtonVisible(), @@ -751,6 +754,13 @@ export class OverlayBackground implements OverlayBackgroundInterface { } } + /** + * Returns the position of the currently open inline menu. + */ + private getInlineMenuPosition(): InlineMenuPosition { + return this.inlineMenuPosition; + } + /** * Handles updating the opacity of both the inline menu button and list. * This is used to simultaneously fade in the inline menu elements. @@ -807,11 +817,18 @@ export class OverlayBackground implements OverlayBackgroundInterface { ? subFrameLeftOffset + left + width - height - (fieldPaddingRight - elementOffset + 2) : subFrameLeftOffset + left + width - height + elementOffset / 2; + this.inlineMenuPosition.button = { + top: Math.round(elementTopPosition), + left: Math.round(elementLeftPosition), + height: Math.round(elementHeight), + width: Math.round(elementHeight), + }; + return { - top: `${Math.round(elementTopPosition)}px`, - left: `${Math.round(elementLeftPosition)}px`, - height: `${Math.round(elementHeight)}px`, - width: `${Math.round(elementHeight)}px`, + top: `${this.inlineMenuPosition.button.top}px`, + left: `${this.inlineMenuPosition.button.left}px`, + height: `${this.inlineMenuPosition.button.height}px`, + width: `${this.inlineMenuPosition.button.width}px`, }; } @@ -824,10 +841,18 @@ export class OverlayBackground implements OverlayBackgroundInterface { const subFrameLeftOffset = subFrameOffsets?.left || 0; const { top, left, width, height } = this.focusedFieldData.focusedFieldRects; + + this.inlineMenuPosition.list = { + top: Math.round(top + height + subFrameTopOffset), + left: Math.round(left + subFrameLeftOffset), + height: 0, + width: Math.round(width), + }; + return { - width: `${Math.round(width)}px`, - top: `${Math.round(top + height + subFrameTopOffset)}px`, - left: `${Math.round(left + subFrameLeftOffset)}px`, + width: `${this.inlineMenuPosition.list.width}px`, + top: `${this.inlineMenuPosition.list.top}px`, + left: `${this.inlineMenuPosition.list.left}px`, }; } @@ -1205,6 +1230,11 @@ export class OverlayBackground implements OverlayBackgroundInterface { * @param message - Contains the dimensions of the inline menu list */ private updateInlineMenuListHeight(message: OverlayBackgroundExtensionMessage) { + const parsedHeight = parseInt(message.styles?.height); + if (this.inlineMenuPosition.list && parsedHeight > 0) { + this.inlineMenuPosition.list.height = parsedHeight; + } + this.inlineMenuListPort?.postMessage({ command: "updateAutofillInlineMenuPosition", styles: message.styles, @@ -1532,11 +1562,13 @@ export class OverlayBackground implements OverlayBackgroundInterface { if (port.name === AutofillOverlayPort.List) { this.inlineMenuListPort = null; this.isInlineMenuListVisible = false; + this.inlineMenuPosition.list = null; } if (port.name === AutofillOverlayPort.Button) { this.inlineMenuButtonPort = null; this.isInlineMenuButtonVisible = false; + this.inlineMenuPosition.button = null; } }; } diff --git a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts index 1572770a16..616c883f18 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts @@ -11,9 +11,12 @@ describe("AutofillInlineMenuContentService", () => { let autofillInit: AutofillInit; let sendExtensionMessageSpy: jest.SpyInstance; let observeBodyMutationsSpy: jest.SpyInstance; + const waitForIdleCallback = () => + new Promise((resolve) => globalThis.requestIdleCallback(resolve)); beforeEach(() => { globalThis.document.body.innerHTML = ""; + globalThis.requestIdleCallback = jest.fn((cb, options) => setTimeout(cb, 100)); autofillInlineMenuContentService = new AutofillInlineMenuContentService(); autofillInit = new AutofillInit(null, autofillInlineMenuContentService); autofillInit.init(); @@ -302,6 +305,7 @@ describe("AutofillInlineMenuContentService", () => { autofillInlineMenuContentService["listElement"] = undefined; await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"](); + await waitForIdleCallback(); expect(globalThis.document.body.insertBefore).not.toHaveBeenCalled(); }); @@ -315,12 +319,23 @@ describe("AutofillInlineMenuContentService", () => { .mockReturnValue(true); await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"](); + await waitForIdleCallback(); + + expect(globalThis.document.body.insertBefore).not.toHaveBeenCalled(); + }); + + it("skips re-arranging the DOM elements if the last child of the body is non-existent", async () => { + document.body.innerHTML = ""; + + await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"](); + await waitForIdleCallback(); expect(globalThis.document.body.insertBefore).not.toHaveBeenCalled(); }); it("skips re-arranging the DOM elements if the last child of the body is the overlay list and the second to last child of the body is the overlay button", async () => { await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"](); + await waitForIdleCallback(); expect(globalThis.document.body.insertBefore).not.toHaveBeenCalled(); }); @@ -330,6 +345,7 @@ describe("AutofillInlineMenuContentService", () => { isInlineMenuListVisibleSpy.mockResolvedValue(false); await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"](); + await waitForIdleCallback(); expect(globalThis.document.body.insertBefore).not.toHaveBeenCalled(); }); @@ -339,6 +355,7 @@ describe("AutofillInlineMenuContentService", () => { document.body.insertBefore(injectedElement, listElement); await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"](); + await waitForIdleCallback(); expect(globalThis.document.body.insertBefore).toHaveBeenCalledWith( buttonElement, @@ -350,6 +367,7 @@ describe("AutofillInlineMenuContentService", () => { document.body.appendChild(buttonElement); await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"](); + await waitForIdleCallback(); expect(globalThis.document.body.insertBefore).toHaveBeenCalledWith( buttonElement, @@ -362,12 +380,59 @@ describe("AutofillInlineMenuContentService", () => { document.body.appendChild(injectedElement); await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"](); + await waitForIdleCallback(); expect(globalThis.document.body.insertBefore).toHaveBeenCalledWith( injectedElement, buttonElement, ); }); + + describe("handling an element that attempts to force itself as the last child", () => { + let persistentLastChild: HTMLElement; + + beforeEach(() => { + persistentLastChild = document.createElement("div"); + persistentLastChild.style.setProperty("z-index", "2147483647"); + document.body.appendChild(persistentLastChild); + autofillInlineMenuContentService["lastElementOverrides"].set(persistentLastChild, 3); + }); + + it("sets the z-index of to a lower value", async () => { + await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"](); + await waitForIdleCallback(); + + expect(persistentLastChild.style.getPropertyValue("z-index")).toBe("2147483646"); + }); + + it("closes the inline menu if the persistent last child overlays the inline menu button", async () => { + sendExtensionMessageSpy.mockResolvedValue({ + button: { top: 0, left: 0, width: 0, height: 0 }, + }); + globalThis.document.elementFromPoint = jest.fn(() => persistentLastChild); + + await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"](); + await waitForIdleCallback(); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("autofillOverlayElementClosed", { + overlayElement: AutofillOverlayElement.Button, + }); + }); + + it("closes the inline menu if the persistent last child overlays the inline menu list", async () => { + sendExtensionMessageSpy.mockResolvedValue({ + list: { top: 0, left: 0, width: 0, height: 0 }, + }); + globalThis.document.elementFromPoint = jest.fn(() => persistentLastChild); + + await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"](); + await waitForIdleCallback(); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("autofillOverlayElementClosed", { + overlayElement: AutofillOverlayElement.List, + }); + }); + }); }); describe("isTriggeringExcessiveMutationObserverIterations", () => { diff --git a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts index 767445e53c..b8702c7443 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts @@ -1,3 +1,7 @@ +import { + InlineMenuElementPosition, + InlineMenuPosition, +} from "../../../background/abstractions/overlay.background"; import { AutofillExtensionMessage } from "../../../content/abstractions/autofill-init"; import { AutofillOverlayElement, @@ -7,6 +11,7 @@ import { sendExtensionMessage, generateRandomCustomElementName, setElementStyles, + requestIdleCallbackPolyfill, } from "../../../utils"; import { InlineMenuExtensionMessageHandlers, @@ -28,6 +33,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte private bodyElementMutationObserver: MutationObserver; private mutationObserverIterations = 0; private mutationObserverIterationsResetTimeout: number | NodeJS.Timeout; + private lastElementOverrides: WeakMap = new WeakMap(); private readonly customElementDefaultStyles: Partial = { all: "initial", position: "fixed", @@ -375,12 +381,35 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte return; } + requestIdleCallbackPolyfill(this.processBodyElementMutation, { timeout: 500 }); + }; + + /** + * Processes the mutation of the body element. Will trigger when an + * idle moment in the execution of the main thread is detected. + */ + private processBodyElementMutation = async () => { const lastChild = globalThis.document.body.lastElementChild; const secondToLastChild = lastChild?.previousElementSibling; const lastChildIsInlineMenuList = lastChild === this.listElement; const lastChildIsInlineMenuButton = lastChild === this.buttonElement; const secondToLastChildIsInlineMenuButton = secondToLastChild === this.buttonElement; + if (!lastChild) { + return; + } + + const lastChildEncounterCount = this.lastElementOverrides.get(lastChild) || 0; + if (!lastChildIsInlineMenuList && !lastChildIsInlineMenuButton && lastChildEncounterCount < 3) { + this.lastElementOverrides.set(lastChild, lastChildEncounterCount + 1); + } + + if (this.lastElementOverrides.get(lastChild) >= 3) { + await this.handlePersistentLastChildOverride(lastChild); + + return; + } + if ( !lastChild || (lastChildIsInlineMenuList && secondToLastChildIsInlineMenuButton) || @@ -400,6 +429,46 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte globalThis.document.body.insertBefore(lastChild, this.buttonElement); }; + /** + * Verifies if the last child of the body element is overlaying the inline menu elements. + * This is triggered when the last child of the body is being forced by some script to + * be an element other than the inline menu elements. + * + * @param lastChild - The last child of the body element. + */ + private async handlePersistentLastChildOverride(lastChild: Element) { + const lastChildZIndex = parseInt((lastChild as HTMLElement).style.zIndex); + if (lastChildZIndex >= 2147483647) { + (lastChild as HTMLElement).style.zIndex = "2147483646"; + } + + const inlineMenuPosition: InlineMenuPosition = await this.sendExtensionMessage( + "getAutofillInlineMenuPosition", + ); + const { button, list } = inlineMenuPosition; + + if (!!button && this.elementAtCenterOfInlineMenuPosition(button) === lastChild) { + this.closeInlineMenu(); + return; + } + + if (!!list && this.elementAtCenterOfInlineMenuPosition(list) === lastChild) { + this.closeInlineMenu(); + } + } + + /** + * Returns the element present at the center of the inline menu position. + * + * @param position - The position of the inline menu element. + */ + private elementAtCenterOfInlineMenuPosition(position: InlineMenuElementPosition): Element | null { + return globalThis.document.elementFromPoint( + position.left + position.width / 2, + position.top + position.height / 2, + ); + } + /** * Identifies if the mutation observer is triggering excessive iterations. * Will trigger a blur of the most recently focused field and remove the diff --git a/apps/browser/src/autofill/services/abstractions/dom-element-visibility.service.ts b/apps/browser/src/autofill/services/abstractions/dom-element-visibility.service.ts index b7fd958bfd..b5d52b8c41 100644 --- a/apps/browser/src/autofill/services/abstractions/dom-element-visibility.service.ts +++ b/apps/browser/src/autofill/services/abstractions/dom-element-visibility.service.ts @@ -1,6 +1,4 @@ -interface DomElementVisibilityService { +export interface DomElementVisibilityService { isFormFieldViewable: (element: HTMLElement) => Promise; isElementHiddenByCss: (element: HTMLElement) => boolean; } - -export { DomElementVisibilityService }; diff --git a/apps/browser/src/autofill/services/dom-element-visibility.service.ts b/apps/browser/src/autofill/services/dom-element-visibility.service.ts index 67986eb00f..9df4ccb8fb 100644 --- a/apps/browser/src/autofill/services/dom-element-visibility.service.ts +++ b/apps/browser/src/autofill/services/dom-element-visibility.service.ts @@ -1,9 +1,9 @@ import { AutofillInlineMenuContentService } from "../overlay/inline-menu/abstractions/autofill-inline-menu-content.service"; import { FillableFormFieldElement, FormFieldElement } from "../types"; -import { DomElementVisibilityService as domElementVisibilityServiceInterface } from "./abstractions/dom-element-visibility.service"; +import { DomElementVisibilityService as DomElementVisibilityServiceInterface } from "./abstractions/dom-element-visibility.service"; -class DomElementVisibilityService implements domElementVisibilityServiceInterface { +class DomElementVisibilityService implements DomElementVisibilityServiceInterface { private cachedComputedStyle: CSSStyleDeclaration | null = null; constructor(private inlineMenuElements?: AutofillInlineMenuContentService) {}