gecko-dev/browser/extensions/formautofill/FormAutofillHandler.jsm
2017-06-07 11:46:14 +02:00

297 lines
9.2 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/*
* Defines a handler object to represent forms that autofill can handle.
*/
"use strict";
this.EXPORTED_SYMBOLS = ["FormAutofillHandler"];
const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://formautofill/FormAutofillUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "FormAutofillHeuristics",
"resource://formautofill/FormAutofillHeuristics.jsm");
this.log = null;
FormAutofillUtils.defineLazyLogGetter(this, this.EXPORTED_SYMBOLS[0]);
/**
* Handles profile autofill for a DOM Form element.
* @param {FormLike} form Form that need to be auto filled
*/
function FormAutofillHandler(form) {
this.form = form;
this.fieldDetails = [];
this.winUtils = this.form.rootElement.ownerGlobal.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIDOMWindowUtils);
}
FormAutofillHandler.prototype = {
/**
* DOM Form element to which this object is attached.
*/
form: null,
/**
* Array of collected data about relevant form fields. Each item is an object
* storing the identifying details of the field and a reference to the
* originally associated element from the form.
*
* The "section", "addressType", "contactType", and "fieldName" values are
* used to identify the exact field when the serializable data is received
* from the backend. There cannot be multiple fields which have
* the same exact combination of these values.
*
* A direct reference to the associated element cannot be sent to the user
* interface because processing may be done in the parent process.
*/
fieldDetails: null,
/**
* String of the filled profile's guid.
*/
filledProfileGUID: null,
/**
* A WindowUtils reference of which Window the form belongs
*/
winUtils: null,
/**
* Enum for form autofill MANUALLY_MANAGED_STATES values
*/
fieldStateEnum: {
// not themed
NORMAL: null,
// highlighted
AUTO_FILLED: "-moz-autofill",
// highlighted && grey color text
PREVIEW: "-moz-autofill-preview",
},
/**
* Set fieldDetails from the form about fields that can be autofilled.
*/
collectFormFields() {
let fieldDetails = FormAutofillHeuristics.getFormInfo(this.form);
this.fieldDetails = fieldDetails ? fieldDetails : [];
log.debug("Collected details on", this.fieldDetails.length, "fields");
},
/**
* Processes form fields that can be autofilled, and populates them with the
* profile provided by backend.
*
* @param {Object} profile
* A profile to be filled in.
* @param {Object} focusedInput
* A focused input element which is skipped for filling.
*/
autofillFormFields(profile, focusedInput) {
log.debug("profile in autofillFormFields:", profile);
this.filledProfileGUID = profile.guid;
for (let fieldDetail of this.fieldDetails) {
// Avoid filling field value in the following cases:
// 1. the focused input which is filled in FormFillController.
// 2. a non-empty input field
// 3. the invalid value set
// 4. value already chosen in select element
let element = fieldDetail.elementWeakRef.get();
if (!element) {
continue;
}
let value = profile[fieldDetail.fieldName];
if (element instanceof Ci.nsIDOMHTMLInputElement && !element.value && value) {
if (element !== focusedInput) {
element.setUserInput(value);
}
this.changeFieldState(fieldDetail, "AUTO_FILLED");
} else if (element instanceof Ci.nsIDOMHTMLSelectElement) {
for (let option of element.options) {
if (value === option.textContent || value === option.value) {
// Do not change value if the option is already selected.
// Use case for multiple select is not considered here.
if (option.selected) {
break;
}
// TODO: Using dispatchEvent does not 100% simulate select change.
// Should investigate further in Bug 1365895.
option.selected = true;
element.dispatchEvent(new Event("input", {"bubbles": true}));
element.dispatchEvent(new Event("change", {"bubbles": true}));
this.changeFieldState(fieldDetail, "AUTO_FILLED");
break;
}
}
}
// Unlike using setUserInput directly, FormFillController dispatches an
// asynchronous "DOMAutoComplete" event with an "input" event follows right
// after. So, we need to suppress the first "input" event fired off from
// focused input to make sure the latter change handler won't be affected
// by auto filling.
if (element === focusedInput) {
const suppressFirstInputHandler = e => {
if (e.isTrusted) {
e.stopPropagation();
element.removeEventListener("input", suppressFirstInputHandler);
}
};
element.addEventListener("input", suppressFirstInputHandler);
}
element.previewValue = "";
}
// Handle the highlight style resetting caused by user's correction afterward.
log.debug("register change handler for filled form:", this.form);
const onChangeHandler = e => {
let hasFilledFields;
if (!e.isTrusted) {
return;
}
for (let fieldDetail of this.fieldDetails) {
let element = fieldDetail.elementWeakRef.get();
if (!element) {
return;
}
if (e.target == element || (e.target == element.form && e.type == "reset")) {
this.changeFieldState(fieldDetail, "NORMAL");
}
hasFilledFields |= (fieldDetail.state == "AUTO_FILLED");
}
// Unregister listeners once no field is in AUTO_FILLED state.
if (!hasFilledFields) {
this.form.rootElement.removeEventListener("input", onChangeHandler);
this.form.rootElement.removeEventListener("reset", onChangeHandler);
}
};
this.form.rootElement.addEventListener("input", onChangeHandler);
this.form.rootElement.addEventListener("reset", onChangeHandler);
},
/**
* Populates result to the preview layers with given profile.
*
* @param {Object} profile
* A profile to be previewed with
*/
previewFormFields(profile) {
log.debug("preview profile in autofillFormFields:", profile);
for (let fieldDetail of this.fieldDetails) {
let element = fieldDetail.elementWeakRef.get();
let value = profile[fieldDetail.fieldName] || "";
// Skip the field that is null or already has text entered
if (!element || element.value) {
continue;
}
element.previewValue = value;
this.changeFieldState(fieldDetail, value ? "PREVIEW" : "NORMAL");
}
},
/**
* Clear preview text and background highlight of all fields.
*/
clearPreviewedFormFields() {
log.debug("clear previewed fields in:", this.form);
for (let fieldDetail of this.fieldDetails) {
let element = fieldDetail.elementWeakRef.get();
if (!element) {
log.warn(fieldDetail.fieldName, "is unreachable");
continue;
}
element.previewValue = "";
// We keep the state if this field has
// already been auto-filled.
if (fieldDetail.state === "AUTO_FILLED") {
continue;
}
this.changeFieldState(fieldDetail, "NORMAL");
}
},
/**
* Change the state of a field to correspond with different presentations.
*
* @param {Object} fieldDetail
* A fieldDetail of which its element is about to update the state.
* @param {string} nextState
* Used to determine the next state
*/
changeFieldState(fieldDetail, nextState) {
let element = fieldDetail.elementWeakRef.get();
if (!element) {
log.warn(fieldDetail.fieldName, "is unreachable while changing state");
return;
}
if (!(nextState in this.fieldStateEnum)) {
log.warn(fieldDetail.fieldName, "is trying to change to an invalid state");
return;
}
for (let [state, mmStateValue] of Object.entries(this.fieldStateEnum)) {
// The NORMAL state is simply the absence of other manually
// managed states so we never need to add or remove it.
if (!mmStateValue) {
continue;
}
if (state == nextState) {
this.winUtils.addManuallyManagedState(element, mmStateValue);
} else {
this.winUtils.removeManuallyManagedState(element, mmStateValue);
}
}
fieldDetail.state = nextState;
},
/**
* Return the profile that is converted from fieldDetails and only non-empty fields
* are included.
*
* @returns {Object} The new profile that convert from details with trimmed result.
*/
createProfile() {
let profile = {};
this.fieldDetails.forEach(detail => {
let element = detail.elementWeakRef.get();
// Remove the unnecessary spaces
let value = element && element.value.trim();
if (!value) {
return;
}
profile[detail.fieldName] = value;
});
return profile;
},
};