diff --git a/injected/integration-test/autofill-password-import.spec.js b/injected/integration-test/autofill-password-import.spec.js
index 3957d92cf..d6efb7220 100644
--- a/injected/integration-test/autofill-password-import.spec.js
+++ b/injected/integration-test/autofill-password-import.spec.js
@@ -1,26 +1,29 @@
-import { test } from '@playwright/test'
+import { test, expect } from '@playwright/test'
import { readFileSync } from 'fs'
import {
mockAndroidMessaging,
wrapWebkitScripts
} from '@duckduckgo/messaging/lib/test-utils.mjs'
import { perPlatform } from './type-helpers.mjs'
+import { OVERLAY_ID } from '../src/features/autofill-password-import'
test('Password import feature', async ({ page }, testInfo) => {
const passwordImportFeature = AutofillPasswordImportSpec.create(page, testInfo)
await passwordImportFeature.enabled()
await passwordImportFeature.navigate()
- const didAnimatePasswordOptions = passwordImportFeature.waitForAnimation('a[aria-label="Password options"]')
await passwordImportFeature.clickOnElement('Home page')
- await didAnimatePasswordOptions
+ await passwordImportFeature.waitForAnimation()
- const didAnimateSignin = passwordImportFeature.waitForAnimation('a[aria-label="Sign in"]')
await passwordImportFeature.clickOnElement('Signin page')
- await didAnimateSignin
+ await passwordImportFeature.waitForAnimation()
- const didAnimateExport = passwordImportFeature.waitForAnimation('button[aria-label="Export"]')
await passwordImportFeature.clickOnElement('Export page')
- await didAnimateExport
+ await passwordImportFeature.waitForAnimation()
+
+ // Test unsupported path
+ await passwordImportFeature.clickOnElement('Unsupported page')
+ const overlay = page.locator(`#${OVERLAY_ID}`)
+ await expect(overlay).not.toBeVisible()
})
export class AutofillPasswordImportSpec {
@@ -94,17 +97,10 @@ export class AutofillPasswordImportSpec {
/**
* Helper to assert that an element is animating
- * @param {string} selector
*/
- async waitForAnimation (selector) {
- const locator = this.page.locator(selector)
- return await locator.evaluate((el) => {
- if (el != null) {
- return el.getAnimations().some((animation) => animation.playState === 'running')
- } else {
- return false
- }
- }, selector)
+ async waitForAnimation () {
+ const locator = this.page.locator(`#${OVERLAY_ID}`)
+ await expect(locator).toBeVisible()
}
/**
diff --git a/injected/integration-test/test-pages/autofill-password-import/index.html b/injected/integration-test/test-pages/autofill-password-import/index.html
index 10187c088..fc0799f81 100644
--- a/injected/integration-test/test-pages/autofill-password-import/index.html
+++ b/injected/integration-test/test-pages/autofill-password-import/index.html
@@ -32,17 +32,21 @@
Sign in
-
+
+
+
+
diff --git a/injected/src/features/autofill-password-import.js b/injected/src/features/autofill-password-import.js
index 2b846cc8f..336684de0 100644
--- a/injected/src/features/autofill-password-import.js
+++ b/injected/src/features/autofill-password-import.js
@@ -1,8 +1,12 @@
import ContentFeature from '../content-feature'
import { DDGProxy, DDGReflect, withExponentialBackoff } from '../utils'
-const ANIMATION_DURATION_MS = 1000
-const ANIMATION_ITERATIONS = Infinity
+export const ANIMATION_DURATION_MS = 1000
+export const ANIMATION_ITERATIONS = Infinity
+export const BACKGROUND_COLOR_START = 'rgba(85, 127, 243, 0.10)'
+export const BACKGROUND_COLOR_END = 'rgba(85, 127, 243, 0.25)'
+export const OVERLAY_ID = 'ddg-password-import-overlay'
+export const DELAY_BEFORE_ANIMATION = 300
/**
* This feature is responsible for animating some buttons passwords.google.com,
@@ -21,8 +25,14 @@ export default class AutofillPasswordImport extends ContentFeature {
*/
get settingsButtonStyle () {
return {
- scale: 1,
- backgroundColor: 'rgba(0, 39, 142, 0.5)'
+ transform: {
+ start: 'scale(0.90)',
+ mid: 'scale(0.96)'
+ },
+ zIndex: '984',
+ borderRadius: '100%',
+ offsetLeftEm: 0.02,
+ offsetTopEm: 0
}
}
@@ -31,8 +41,14 @@ export default class AutofillPasswordImport extends ContentFeature {
*/
get exportButtonStyle () {
return {
- scale: 1.01,
- backgroundColor: 'rgba(0, 39, 142, 0.5)'
+ transform: {
+ start: 'scale(1)',
+ mid: 'scale(1.01)'
+ },
+ zIndex: '984',
+ borderRadius: '100%',
+ offsetLeftEm: 0,
+ offsetTopEm: 0
}
}
@@ -41,15 +57,21 @@ export default class AutofillPasswordImport extends ContentFeature {
*/
get signInButtonStyle () {
return {
- scale: 1.5,
- backgroundColor: 'rgba(0, 39, 142, 0.5)'
+ transform: {
+ start: 'scale(1)',
+ mid: 'scale(1.3, 1.5)'
+ },
+ zIndex: '999',
+ borderRadius: '2px',
+ offsetLeftEm: 0,
+ offsetTopEm: -0.05
}
}
/**
* Takes a path and returns the element and style to animate.
* @param {string} path
- * @returns {Promise<{element: HTMLElement|Element, style: any, shouldTap: boolean}|null>}
+ * @returns {Promise<{element: HTMLElement|Element, style: any, shouldTap: boolean, shouldWatchForRemoval: boolean}|null>}
*/
async getElementAndStyleFromPath (path) {
if (path === '/') {
@@ -58,7 +80,8 @@ export default class AutofillPasswordImport extends ContentFeature {
? {
style: this.settingsButtonStyle,
element,
- shouldTap: this.#settingsButtonSettings?.shouldAutotap ?? false
+ shouldTap: this.#settingsButtonSettings?.shouldAutotap ?? false,
+ shouldWatchForRemoval: false
}
: null
} else if (path === '/options') {
@@ -67,7 +90,8 @@ export default class AutofillPasswordImport extends ContentFeature {
? {
style: this.exportButtonStyle,
element,
- shouldTap: this.#exportButtonSettings?.shouldAutotap ?? false
+ shouldTap: this.#exportButtonSettings?.shouldAutotap ?? false,
+ shouldWatchForRemoval: true
}
: null
} else if (path === '/intro') {
@@ -76,7 +100,8 @@ export default class AutofillPasswordImport extends ContentFeature {
? {
style: this.signInButtonStyle,
element,
- shouldTap: this.#signInButtonSettings?.shouldAutotap ?? false
+ shouldTap: this.#signInButtonSettings?.shouldAutotap ?? false,
+ shouldWatchForRemoval: false
}
: null
} else {
@@ -84,31 +109,107 @@ export default class AutofillPasswordImport extends ContentFeature {
}
}
+ /**
+ * Removes the overlay if it exists.
+ */
+ removeOverlayIfNeeded () {
+ const existingOverlay = document.getElementById(OVERLAY_ID)
+ if (existingOverlay != null) {
+ existingOverlay.style.display = 'none'
+ existingOverlay.remove()
+ }
+ }
+
+ /**
+ * Inserts an overlay element to animate, by adding a div to the body
+ * and styling it based on the found element.
+ * @param {HTMLElement|Element} mainElement
+ * @param {any} style
+ * @returns {HTMLElement|Element|null}
+ */
+ insertOverlayElement (mainElement, style) {
+ this.removeOverlayIfNeeded()
+
+ const overlay = document.createElement('div')
+ overlay.setAttribute('id', OVERLAY_ID)
+ const svgElement = mainElement.parentNode?.querySelector('svg') ?? mainElement.querySelector('svg')
+
+ const isRound = style.borderRadius === '100%'
+ const elementToCenterOn = (isRound && svgElement != null) ? svgElement : mainElement
+ if (elementToCenterOn) {
+ const { top, left, width, height } = elementToCenterOn.getBoundingClientRect()
+ overlay.style.position = 'absolute'
+
+ overlay.style.top = `calc(${top}px + ${window.scrollY}px - ${isRound ? height / 2 : 0}px - 1px - ${style.offsetTopEm}em)`
+ overlay.style.left = `calc(${left}px + ${window.scrollX}px - ${isRound ? width / 2 : 0}px - 1px - ${style.offsetLeftEm}em)`
+
+ const mainElementRect = mainElement.getBoundingClientRect()
+ overlay.style.width = `${mainElementRect.width}px`
+ overlay.style.height = `${mainElementRect.height}px`
+ overlay.style.zIndex = style.zIndex
+
+ // Ensure overlay is non-interactive
+ overlay.style.pointerEvents = 'none'
+
+ // insert in document.body
+ document.body.appendChild(overlay)
+ return overlay
+ } else {
+ return null
+ }
+
+ }
+
+ /**
+ * Observes the removal of an element from the DOM.
+ * @param {HTMLElement|Element} element
+ * @param {any} onRemoveCallback
+ */
+ observeElementRemoval (element, onRemoveCallback) {
+ // Set up the mutation observer
+ const observer = new MutationObserver((mutations) => {
+ mutations.forEach((mutation) => {
+ // Check if the element has been removed from its parent
+ if (mutation.type === 'childList' && !document.contains(element)) {
+ // Element has been removed
+ onRemoveCallback()
+ observer.disconnect() // Stop observing
+ }
+ })
+ })
+
+ // Start observing the parent node for child list changes
+ observer.observe(document.body, { childList: true, subtree: true })
+ }
+
/**
* Moves the element into view and animates it.
* @param {HTMLElement|Element} element
* @param {any} style
*/
animateElement (element, style) {
- element.scrollIntoView({
- behavior: 'smooth',
- block: 'center',
- inline: 'center'
- }) // Scroll into view
- const keyframes = [
- { backgroundColor: 'rgba(0, 0, 255, 0)', offset: 0, borderRadius: '2px' }, // Start: transparent
- { backgroundColor: style.backgroundColor, offset: 0.5, borderRadius: '2px', transform: `scale(${style.scale})` }, // Midpoint: blue with 50% opacity
- { backgroundColor: 'rgba(0, 0, 255, 0)', borderRadius: '2px', offset: 1 } // End: transparent
- ]
-
- // Define the animation options
- const options = {
- duration: ANIMATION_DURATION_MS,
- iterations: ANIMATION_ITERATIONS
+ const overlay = this.insertOverlayElement(element, style)
+ if (overlay != null) {
+ overlay.scrollIntoView({
+ behavior: 'smooth',
+ block: 'center',
+ inline: 'center'
+ }) // Scroll into view
+ const keyframes = [
+ { backgroundColor: BACKGROUND_COLOR_START, offset: 0, borderRadius: style.borderRadius, border: `1px solid ${BACKGROUND_COLOR_START}`, transform: style.transform.start }, // Start: 10% blue
+ { backgroundColor: BACKGROUND_COLOR_END, offset: 0.5, borderRadius: style.borderRadius, border: `1px solid ${BACKGROUND_COLOR_END}`, transform: style.transform.mid, transformOrigin: 'center' }, // Middle: 25% blue
+ { backgroundColor: BACKGROUND_COLOR_START, offset: 1, borderRadius: style.borderRadius, border: `1px solid ${BACKGROUND_COLOR_START}`, transform: style.transform.start } // End: 10% blue
+ ]
+
+ // Define the animation options
+ const options = {
+ duration: ANIMATION_DURATION_MS,
+ iterations: ANIMATION_ITERATIONS
+ }
+
+ // Apply the animation to the element
+ overlay.animate(keyframes, options)
}
-
- // Apply the animation to the element
- element.animate(keyframes, options)
}
autotapElement (element) {
@@ -157,17 +258,36 @@ export default class AutofillPasswordImport extends ContentFeature {
* @param {string} path
*/
async handleElementForPath (path) {
+ this.removeOverlayIfNeeded()
const supportedPaths = [
this.#exportButtonSettings?.path,
this.#settingsButtonSettings?.path,
this.#signInButtonSettings?.path
]
- if (supportedPaths.indexOf(path)) {
+ if (supportedPaths.includes(path)) {
try {
- const { element, style, shouldTap } = await this.getElementAndStyleFromPath(path) ?? {}
+ const { element, style, shouldTap, shouldWatchForRemoval } = await this.getElementAndStyleFromPath(path) ?? {}
if (element != null) {
- // eslint-disable-next-line @typescript-eslint/no-unused-expressions
- shouldTap ? this.autotapElement(element) : this.animateElement(element, style)
+ if (shouldTap) {
+ this.autotapElement(element)
+ } else {
+ const domLoaded = new Promise((resolve) => {
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", resolve);
+ } else {
+ // @ts-expect-error - caller doesn't expect a value here
+ resolve()
+ }
+ })
+ await domLoaded
+ this.animateElement(element, style)
+ }
+ if (shouldWatchForRemoval) {
+ // Sometimes navigation events are not triggered, then we need to watch for removal
+ this.observeElementRemoval(element, () => {
+ this.removeOverlayIfNeeded()
+ })
+ }
}
} catch {
console.error('password-import: handleElementForPath failed for path:', path)