| // Copyright 2018 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| /** |
| * @fileoverview Add functionality related to getting search engine details. |
| */ |
| |
| import {sendWebKitMessage} from '//ios/web/public/js_messaging/resources/utils.js'; |
| |
| /** |
| * Encodes `url` in "application/x-www-form-urlencoded" content type of <form>. |
| * The standard is defined in: |
| * https://www.w3.org/TR/html4/interact/forms.html#h-17.13.4.1 |
| * This solution comes from: |
| * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent |
| */ |
| function encodeFormData(url: string): string { |
| return encodeURIComponent(url).replace('%20', '+'); |
| } |
| |
| /** |
| * Returns element if it's of a type that can submit a form or null otherwise. |
| */ |
| function asSubmitElement(element: Element): HTMLButtonElement|HTMLInputElement| |
| null { |
| if (element instanceof HTMLButtonElement) { |
| return element; |
| } |
| if (element instanceof HTMLInputElement && element.type === 'submit') { |
| return element; |
| } |
| return null; |
| } |
| |
| /** |
| * Returns the value stored in the element's `name` property. If the |
| * element does not have the name property, then null is returned. |
| */ |
| function getElementName(element: Element): string|null { |
| return (element as Element & {'name': string}).name; |
| } |
| |
| /** |
| * Returns whether the element is disabled. If the element does not have the |
| * disabled property, then null is returned. |
| */ |
| function isDisabledElement(element: Element): boolean { |
| return (element as Element & {'disabled': boolean}).disabled; |
| } |
| |
| /** |
| * Returns if `element` is checkable(i.e. <input type="radio"> or <input |
| * type="checkbox">). |
| * @param element An element inside a <form>. |
| */ |
| function isCheckableElement(element: Element): boolean { |
| return element instanceof HTMLInputElement && |
| (element.type === 'radio' || element.type === 'checkbox'); |
| } |
| |
| // Records the active submit element of <form> being submitted. |
| let activeSubmitElement: HTMLButtonElement|HTMLInputElement|null = null; |
| |
| /** |
| * Returns the submit element which triggers the submission of `form`. If there |
| * is no submit element clicked before `form`'s submission, the first submit |
| * element of `form` will be returned. Otherwise, returns undefined if not |
| * found. |
| * @param form The <form> on submission. |
| */ |
| function getActiveSubmitElement(form: HTMLFormElement): HTMLButtonElement| |
| HTMLInputElement|null { |
| if (activeSubmitElement && activeSubmitElement.form === form) { |
| return activeSubmitElement; |
| } |
| for (const element of form.elements) { |
| const submitElement = asSubmitElement(element); |
| if (submitElement) { |
| return submitElement; |
| } |
| } |
| |
| return null; |
| } |
| |
| /** |
| * A set of all the text categories of <input>'s type attribute. |
| * This set is based on: |
| * https://cs.chromium.org/chromium/src/third_party/blink/renderer/core/html/forms/text_field_input_type.h |
| * https://cs.chromium.org/chromium/src/third_party/blink/renderer/core/html/forms/base_text_input_type.h |
| * "password" is not in the map because it makes the <form> invalid. |
| */ |
| const TEXT_INPUT_TYPES = |
| new Set(['email', 'search', 'tel', 'text', 'url', 'number']); |
| |
| /** |
| * Returns false if `element` is <input type="radio|checkbox"> or <select> and |
| * it's not in its default state, otherwise true. The default state is the state |
| * of the form element on initial load of the page, and leties depending upon |
| * the form element. |
| * @param element an Element in <form>. |
| */ |
| function isInDefaultState(element: Element): boolean { |
| if (isCheckableElement(element)) { |
| const inputElement = element as HTMLInputElement; |
| return inputElement.checked === inputElement.defaultChecked; |
| } |
| |
| if (element instanceof HTMLSelectElement) { |
| for (const option of element.options) { |
| if (option.selected !== option.defaultSelected) { |
| return false; |
| } |
| } |
| } |
| |
| return true; |
| } |
| |
| /** |
| * Looks for a suitable search text field in |form|. Returns undefined if |form| |
| * is not a valid searchable <form>. The code is based on the function with same |
| * name in: |
| * https://cs.chromium.org/chromium/src/third_party/blink/renderer/core/exported/web_searchable_form_data.cc |
| * |
| * The criteria for a valid searchable <form>: |
| * 1. Has no <textarea>; |
| * 2. Has no <input type="password">; |
| * 3. Has no <input type="file">; |
| * 4. Has exactly one <input> with "type" from `TEXT_INPUT_TYPES_`; |
| * 5. Has no element that is not in default state; |
| * Any element that doesn't have "name" attribute or has "disabled" attribute |
| * will be ignored. |
| * @param form The form being submitted. |
| */ |
| function findSuitableSearchInputElement(form: HTMLFormElement): |
| HTMLInputElement|undefined { |
| let result: HTMLInputElement|undefined = undefined; |
| for (const element of form.elements) { |
| if (isDisabledElement(element) || !getElementName(element)) { |
| continue; |
| } |
| if (!isInDefaultState(element) || element instanceof HTMLTextAreaElement) { |
| return; |
| } |
| if (element instanceof HTMLInputElement) { |
| if (element.type === 'file' || element.type === 'password') { |
| return; |
| } |
| if (TEXT_INPUT_TYPES.has(element.type)) { |
| if (result) { |
| return; |
| } |
| result = element; |
| } |
| } |
| } |
| return result; |
| } |
| |
| /** |
| * Generates a searchable URL from `form` if it's a valid searchable <form>. |
| * The code is based on the function with same name in: |
| * https://cs.chromium.org/chromium/src/third_party/blink/renderer/core/exported/web_searchable_form_data.cc |
| * TODO(crbug.com/40394195): Use <form>'s "accept-charset" attribute to encode the |
| * searchableURL. |
| */ |
| function generateSearchableUrl(form: Element): string|undefined { |
| if (!(form instanceof HTMLFormElement)) { |
| return; |
| } |
| |
| // Only consider <form> that navigates in current frame, because currently |
| // TemplateURLs are created by SearchEngineTabHelper, which cannot handle |
| // navigation across WebState. |
| if (form.target && form.target !== '_self') { |
| return; |
| } |
| |
| // Only consider forms that GET data. |
| if (form.method && form.method.toLowerCase() !== 'get') { |
| return; |
| } |
| |
| const searchInput = findSuitableSearchInputElement(form); |
| if (!searchInput) { |
| return; |
| } |
| |
| const activeSubmitElement = getActiveSubmitElement(form); |
| |
| // The "name=value" pairs appended to the end of the action URL. |
| const queryArgs: string[] = []; |
| for (const element of form.elements) { |
| const elementName = getElementName(element); |
| if (isDisabledElement(element) || !elementName) { |
| continue; |
| } |
| |
| const submitElement = asSubmitElement(element); |
| if (submitElement) { |
| // Only append the active submit element's name-value pair. |
| if (submitElement === activeSubmitElement) { |
| let value = submitElement.value; |
| // <input type="submit"> will have "Submit" as default "value" when |
| // submitted with empty "value" and non-empty "name". This probably |
| // comes from the default label text of <input type="submit">: |
| // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/submit |
| if (submitElement instanceof HTMLInputElement && !value) { |
| value = 'Submit'; |
| } |
| |
| queryArgs.push( |
| encodeFormData(elementName) + '=' + encodeFormData(value)); |
| } |
| continue; |
| } |
| if (element === searchInput) { |
| queryArgs.push(encodeFormData(elementName) + '={searchTerms}'); |
| } else { |
| // Ignore unchecked checkable element. |
| if (isCheckableElement(element) && |
| !(element as HTMLInputElement).checked) { |
| continue; |
| } |
| const elementValue = (element as Element & {'value': string}).value; |
| queryArgs.push( |
| encodeFormData(elementName) + '=' + encodeFormData(elementValue)); |
| } |
| } |
| // If `form` uses "GET" method, appended query args in `form`.action should be |
| // dropped. Use URL class to get rid of these query args. |
| const url = new URL(form.action); |
| return url.origin + url.pathname + '?' + queryArgs.join('&'); |
| } |
| |
| /** |
| * Adds listener for 'click' event on `document`. When a submit element is |
| * clicked, records it in `activeSubmitElement` for `generateSearchableUrl`, |
| * which will be called in the 'submit' event callbacks within current call |
| * stack. Appends a callback at the end of Js task queue with timeout=0ms that |
| * sets `activeSubmitElement` back to undefined after the submission. |
| * |
| * The call stack of form submission: |
| * User clicks button. |
| * "click" event emitted and bubbles up to `document`. |
| * Records current button as `activeSubmitElement`. |
| * Posts callback that unsets active submit element by setTimeout(..., 0). |
| * "click" event ends. |
| * "submit" event emitted and bubbles up to `document`. |
| * Generates searchable URL based on `activeSubmitElement`. |
| * "submit" event ends. |
| * Call stack of user's click on button finishes. |
| * ... |
| * Js task queue running... |
| * ... |
| * Callback posted by setTimeout(..., 0) is invoked and clean up |
| * `activeSubmitElement`. |
| */ |
| document.addEventListener('click', function(event) { |
| if (event.defaultPrevented) { |
| return; |
| } |
| const element = event.target; |
| |
| if (!(element instanceof Element)) { |
| return; |
| } |
| const submitElement = asSubmitElement(element); |
| if (!submitElement) { |
| return; |
| } |
| activeSubmitElement = submitElement; |
| setTimeout(function() { |
| if (activeSubmitElement === element) { |
| activeSubmitElement = null; |
| } |
| }, 0); |
| }); |
| |
| /** |
| * Adds listener for 'submit' event on `document`. When a <form> is submitted, |
| * try to generate a searchableUrl. If succeeded, send it back to native code. |
| * TODO(crbug.com/40394195): Refactor /components/autofill/ios/form_util to reuse |
| * FormActivityObserver, so that all the data about form submission can be |
| * sent in a single message. |
| */ |
| document.addEventListener('submit', function(event) { |
| if (event.defaultPrevented || !(event.target instanceof Element)) { |
| return; |
| } |
| const url = generateSearchableUrl(event.target); |
| if (url) { |
| sendWebKitMessage( |
| 'SearchEngineMessage', {'command': 'searchableUrl', 'url': url}); |
| } |
| }, false); |
| |
| /** |
| * Finds <link> of OSDD(Open Search Description Document) in the main frame. If |
| * found, sends a message containing the page's URL and OSDD's URL to native |
| * side. If the page has multiple OSDD <links>s (which should never happen on a |
| * sane web site), only send the first <link>. |
| */ |
| function findOpenSearchLink(): void { |
| const links = document.getElementsByTagName('link'); |
| for (const link of links) { |
| if (link.type === 'application/opensearchdescription+xml') { |
| sendWebKitMessage('SearchEngineMessage', { |
| 'command': 'openSearch', |
| 'pageUrl': document.URL, |
| 'osddUrl': link.href, |
| }); |
| return; |
| } |
| } |
| } |
| |
| // If document is loaded, finds the Open Search <link>, otherwise waits until |
| // it's loaded and then starts finding. |
| if (document.readyState === 'complete') { |
| findOpenSearchLink(); |
| } else { |
| window.addEventListener('load', findOpenSearchLink); |
| } |