Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Autofill password import] Misc fixes #1184

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
175 changes: 143 additions & 32 deletions injected/src/features/autofill-password-import.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { DDGProxy, DDGReflect, withExponentialBackoff } from '../utils'

const ANIMATION_DURATION_MS = 1000
const ANIMATION_ITERATIONS = Infinity
const BACKGROUND_COLOR_START = 'rgba(85, 127, 243, 0.10)'
const BACKGROUND_COLOR_END = 'rgba(85, 127, 243, 0.25)'
const OVERLAY_ID = 'ddg-password-import-overlay'
const ANIMATION_TIMEOUT = 300

/**
* This feature is responsible for animating some buttons passwords.google.com,
Expand All @@ -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
}
}

Expand All @@ -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
}
}

Expand All @@ -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 === '/') {
Expand All @@ -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') {
Expand All @@ -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') {
Expand All @@ -76,39 +100,116 @@ export default class AutofillPasswordImport extends ContentFeature {
? {
style: this.signInButtonStyle,
element,
shouldTap: this.#signInButtonSettings?.shouldAutotap ?? false
shouldTap: this.#signInButtonSettings?.shouldAutotap ?? false,
shouldWatchForRemoval: false
}
: null
} else {
return null
}
}

/**
* 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%'
jonathanKingston marked this conversation as resolved.
Show resolved Hide resolved
const elementToCenterOn = isRound ? 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)`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer these ternaries as variables outside of the template string to simplify the expression a little. Feel free to skip if you care about keeping.

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) {
Expand Down Expand Up @@ -157,17 +258,27 @@ 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 {
setTimeout(() => this.animateElement(element, style), ANIMATION_TIMEOUT)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this is safer:

const domLoaded = new Promise((resolve) => {
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", resolve);
  } else {
    resolve();
  }
})
Suggested change
setTimeout(() => this.animateElement(element, style), ANIMATION_TIMEOUT)
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)
Expand Down
Loading