UNPKG

passbolt-styleguide

Version:

Passbolt styleguide contains common styling assets used by the different sites, plugin, etc.

281 lines (255 loc) 10.7 kB
/** * Passbolt ~ Open source password manager for teams * Copyright (c) 2021 Passbolt SA (https://www.passbolt.com) * * Licensed under GNU Affero General Public License version 3 of the or any later version. * For full copyright and license information, please see the LICENSE.txt * Redistributions of files must retain the above copyright notice. * * @copyright Copyright (c) 2021 Passbolt SA (https://www.passbolt.com) * @license https://opensource.org/licenses/AGPL-3.0 AGPL License * @link https://www.passbolt.com Passbolt(tm) * @since 3.3.0 */ import {v4 as uuidv4} from "uuid"; import DomUtils from "../Dom/DomUtils"; import browser from "webextension-polyfill"; /** * An InFormCallToActionField is represented by a DOM element identified as an username field and to which * in-form call-to-action and/or menu can be attached */ class InFormCallToActionField { /** * Retrieve all the DOM elements which can be an in-form username fields */ static findAll(selector) { const domFields = Array.from(document.querySelectorAll(selector)); const iframesFields = InFormCallToActionField.findAllInIframes(selector); return domFields.concat(iframesFields); } /** * Retrieve all the iframes elements which can be an in-form username fields * @return {*} */ static findAllInIframes(selector) { const iframes = DomUtils.getAccessibleAndSameDomainIframes(); const queryMapper = iframe => Array.from(iframe.contentDocument.querySelectorAll(selector)); return iframes .map(queryMapper) .flat(); } /** * Default constructor * @param field The DOM element * @param fieldType The type of field */ constructor(field, fieldType) { /** The field to which the in-form is attached */ this.field = field; /** Type of the field ("username" or "password") */ this.fieldType = fieldType; /** An unique identifier for the iframe */ this.iframeId = uuidv4(); /** The scrollable field parent */ this.scrollableFieldParent = null; /** Flag telling if the user is mousing over the call-to-action (iframe) */ this.isCallToActionMousingOver = false; /** In-form call-to-action click watcher */ this.callToActionClickWatcher = null; /** In-form call-to-action click listener callback */ this.callToActionClickCallback = null; this.bindCallbacks(); this.handleInsertionEvent(); this.handleRemoveEvent(); this.handleScrollEvent(); } /** * Binds methods callbacks */ bindCallbacks() { this.insertInformCallToActionIframe = this.insertInformCallToActionIframe.bind(this); this.removeInFormCallToActionWhenMouseOut = this.removeInFormCallToActionWhenMouseOut.bind(this); this.removeInFormCallToAction = this.removeInFormCallToAction.bind(this); this.removeCallToActionIframe = this.removeCallToActionIframe.bind(this); this.destroy = this.destroy.bind(this); } /** * Handle a callback after on click on the in-form-call-to-action * @param callback */ onClick(callback) { this.callToActionClickCallback = callback; } /** CALL-TO-ACTION INSERTION */ /** * Whenever the call-to-action must be inserted */ handleInsertionEvent() { if (document.activeElement === this.field) { this.insertInformCallToActionIframe(); } this.field.addEventListener("mouseover", this.insertInformCallToActionIframe); this.field.addEventListener("focus", this.insertInformCallToActionIframe); } /** * Insert an in-form call-to-action iframe */ async insertInformCallToActionIframe() { const iframes = document.querySelectorAll('iframe'); // Use of Array prototype some method cause NodeList is not an array ! const iframeId = this.iframeId; const isIframeAlreadyInserted = Array.prototype.some.call(iframes, iframe => iframe.id === iframeId); if (!isIframeAlreadyInserted) { const iframe = await this.createCallToActionIframe(); this.handleCallToActionClicked(iframe); } } /** * Create an iframe dedicated to the call-to-action * @return {HTMLIFrameElement} The created iframe */ async createCallToActionIframe() { // IMPORTANT: Calculate position before inserting iframe in document to avoid issue const {top, left} = this.calculateFieldPosition(); const portId = await port.request("passbolt.port.generate-id", "InFormCallToAction"); const iframe = document.createElement('iframe'); document.body.appendChild(iframe); const browserExtensionUrl = browser.runtime.getURL("/"); iframe.id = this.iframeId; iframe.style.position = "fixed"; iframe.style.top = `${top}px`; iframe.style.left = `${left}px`; iframe.style.border = 0; iframe.style.width = '18px'; iframe.style.height = '18px'; iframe.style.zIndex = "123456"; // For you Yahoo with love iframe.contentWindow.location = `${browserExtensionUrl}webAccessibleResources/passbolt-iframe-in-form-call-to-action.html?passbolt=${portId}`; return iframe; } /** * Calculates the position on the screen of the DOM field * @return {{top: number, left: number}} */ calculateFieldPosition() { let x = 0; let y = 0; let currentElement = this.field; let hasScroll = false; const isInIframe = this.field.ownerDocument !== document; const {top, left, height, width} = this.field.getBoundingClientRect(); // If the field is in iframe get the top and left of the iframe else get the top and the left of the body const {top: topBody, left: leftBody} = isInIframe ? this.field.ownerDocument.defaultView.frameElement.getBoundingClientRect() : document.documentElement.getBoundingClientRect(); /* * We loop to calculate the cumulated position of the field * from its ancestors and itself differential offset / scroll position */ while (currentElement && !isNaN(currentElement.offsetLeft) && !isNaN(currentElement.offsetTop)) { hasScroll = hasScroll || currentElement.scrollLeft + currentElement.scrollTop > 0; x += currentElement.offsetLeft - currentElement.scrollLeft; y += currentElement.offsetTop - currentElement.scrollTop; currentElement = currentElement.offsetParent; } /* * If there is no scroll and not in iframe on the page, we use the getBoundingClientRect to have the position of the field * this avoid a position issue if there is a transform style in css */ if (!hasScroll && !isInIframe) { x = left + width - 25; y = top + Math.floor(height / 2) - 9; } else { // Then we add the body offset (notably in case of window scroll) + some local adjustments (margin / vertical aligment ) x = x + leftBody + width - 25; y = y + topBody + Math.floor(height / 2) - 9; // Calculate the middle position of the input, 9 is the half of the iframe height } return {top: y, left: x}; } /** * Whenever the user clicked on the call-to-action iframe * @param iframe The call-to-action iframe */ handleCallToActionClicked(iframe) { /* * In case of click on iframe, the field lose the focus. Since it loses the focus, the iframe is removed. * And so the call-to-action. So, we need to restore the focus on the input. In case, it did not have * previously the focus, no matter since a click on the call-to-action should be a way to focus on the input */ this.callToActionClickWatcher = setInterval(() => { // Check if a click has been applied on some iframe const elem = document.activeElement; if (elem && elem.tagName === 'IFRAME' && elem.id === this.iframeId) { this.field.focus(); this.callToActionClickCallback(); clearInterval(this.callToActionClickWatcher); } }, 100); /* * We need to know which iframe the user click on. We cannot add a listener on iframe * since there are from different domains (target page vs extension pagemods) */ iframe.addEventListener('mouseover', () => this.isCallToActionMousingOver = true); iframe.addEventListener('mouseout', () => this.isCallToActionMousingOver = false); } /** CALL-TO-ACTION REMOVE */ /** * Whenever the call-to-action must be removed */ handleRemoveEvent() { this.field.addEventListener("mouseout", this.removeInFormCallToActionWhenMouseOut); this.field.addEventListener("blur", this.removeInFormCallToAction); } /** * Removes the in-form call-to-action iframe from the username or password field */ removeInFormCallToAction() { const isIframeMouseOver = this.isCallToActionMousingOver; const isActiveElementAnAuthenticationField = document.activeElement === this.field; if (!isIframeMouseOver && !isActiveElementAnAuthenticationField) { this.removeCallToActionIframe(); } } /** * Removes the call-to-action iframe from the username or password field when one moused out * @param event The mouse-out event */ removeInFormCallToActionWhenMouseOut(event) { const isNotCallToActionIframe = event.relatedTarget && event.relatedTarget.id !== this.iframeId; const isActiveElementAnAuthenticationField = document.activeElement === this.field; if (isNotCallToActionIframe && !isActiveElementAnAuthenticationField) { this.removeCallToActionIframe(); } } /** * Remove the call-to-action (iframe) */ removeCallToActionIframe() { const iframes = document.querySelectorAll('iframe'); iframes.forEach(iframe => { const identifierToMatch = this.iframeId; if (iframe.id === identifierToMatch) { iframe.parentNode.removeChild(iframe); } }); } /** SCROLL REPOSITION */ /** * Whenever the user scrolls the page */ handleScrollEvent() { // Remove the call-to-action this.scrollableFieldParent = DomUtils.getScrollParent(this.field); this.scrollableFieldParent.addEventListener('scroll', this.removeCallToActionIframe); } /** DESTROY */ /** * Remove all listener and iframe to clean the page and avoid issue on extension update */ destroy() { this.field.removeEventListener("mouseover", this.insertInformCallToActionIframe); this.field.removeEventListener("focus", this.insertInformCallToActionIframe); this.field.removeEventListener("mouseout", this.removeInFormCallToActionWhenMouseOut); this.field.removeEventListener("blur", this.removeInFormCallToAction); this.scrollableFieldParent.removeEventListener('scroll', this.removeCallToActionIframe); this.removeCallToActionIframe(); } } export default InFormCallToActionField;