// 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.
import {OmniboxElement} from './omnibox_element.js';
/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */
// @ts-ignore:next-line
import sheet from './omnibox_input.css' with {type : 'css'};
export interface QueryInputs {
inputText: string;
resetAutocompleteController: boolean;
cursorLock: boolean;
cursorPosition: number;
zeroSuggest: boolean;
preventInlineAutocomplete: boolean;
preferKeyword: boolean;
currentUrl: string;
pageClassification: number;
}
export interface DisplayInputs {
showIncompleteResults: boolean;
showDetails: boolean;
showAllProviders: boolean;
elideCells: boolean;
thinRows: boolean;
}
export class OmniboxInput extends OmniboxElement {
private elements: {
top: HTMLElement,
arrowPadding: HTMLElement,
connectWindowOmnibox: HTMLInputElement,
currentUrl: HTMLInputElement,
elideCells: HTMLInputElement,
exportClipboard: HTMLElement,
exportFile: HTMLElement,
filterText: HTMLInputElement,
historyWarning: HTMLElement,
importClipboard: HTMLElement,
importedWarning: HTMLElement,
importFile: HTMLElement,
importFileInput: HTMLInputElement,
inputText: HTMLInputElement,
lockCursorPosition: HTMLInputElement,
pageClassification: HTMLSelectElement,
preferKeyword: HTMLInputElement,
preventInlineAutocomplete: HTMLInputElement,
processBatch: HTMLElement,
processBatchInput: HTMLInputElement,
resetAutocompleteController: HTMLInputElement,
responsesCount: HTMLElement,
responseSelection: HTMLInputElement,
showAllProviders: HTMLInputElement,
showDetails: HTMLInputElement,
showIncompleteResults: HTMLInputElement,
thinRows: HTMLInputElement,
zeroSuggest: HTMLInputElement,
};
constructor() {
super('omnibox-input-template');
this.shadowRoot!.adoptedStyleSheets = [sheet];
}
connectedCallback() {
this.elements = {
top: this.$<HTMLElement>('#top')!,
arrowPadding: this.$<HTMLElement>('#arrow-padding')!,
connectWindowOmnibox: this.$<HTMLInputElement>('#connect-window-omnibox')!
,
currentUrl: this.$<HTMLInputElement>('#current-url')!,
elideCells: this.$<HTMLInputElement>('#elide-cells')!,
exportClipboard: this.$<HTMLElement>('#export-clipboard')!,
exportFile: this.$<HTMLElement>('#export-file')!,
filterText: this.$<HTMLInputElement>('#filter-text')!,
historyWarning: this.$<HTMLElement>('#history-warning')!,
importClipboard: this.$<HTMLElement>('#import-clipboard')!,
importedWarning: this.$<HTMLElement>('#imported-warning')!,
importFile: this.$<HTMLElement>('#import-file')!,
importFileInput: this.$<HTMLInputElement>('#import-file-input')!,
inputText: this.$<HTMLInputElement>('#input-text')!,
lockCursorPosition: this.$<HTMLInputElement>('#lock-cursor-position')!,
pageClassification: this.$<HTMLSelectElement>('#page-classification')!,
preferKeyword: this.$<HTMLInputElement>('#prefer-keyword')!,
preventInlineAutocomplete:
this.$<HTMLInputElement>('#prevent-inline-autocomplete')!,
processBatch: this.$<HTMLElement>('#process-batch')!,
processBatchInput: this.$<HTMLInputElement>('#process-batch-input')!,
resetAutocompleteController:
this.$<HTMLInputElement>('#reset-autocomplete-controller')!,
responsesCount: this.$<HTMLElement>('#responses-count')!,
responseSelection: this.$<HTMLInputElement>('#response-selection')!,
showAllProviders: this.$<HTMLInputElement>('#show-all-providers')!,
showDetails: this.$<HTMLInputElement>('#show-details')!,
showIncompleteResults:
this.$<HTMLInputElement>('#show-incomplete-results')!,
thinRows: this.$<HTMLInputElement>('#thin-rows')!,
zeroSuggest: this.$<HTMLInputElement>('#zero-suggest')!,
};
this.restoreInputs();
this.setupElementListeners();
}
private storeInputs() {
const inputs = {
connectWindowOmnibox: this.connectWindowOmnibox,
displayInputs: this.displayInputs,
};
window.localStorage.setItem('preserved-inputs', JSON.stringify(inputs));
}
private restoreInputs() {
const inputsString = window.localStorage.getItem('preserved-inputs');
const inputs = inputsString && JSON.parse(inputsString) || {};
this.elements.connectWindowOmnibox.checked = inputs.connectWindowOmnibox;
this.displayInputs =
inputs.displayInputs || OmniboxInput.defaultDisplayInputs;
}
private setupElementListeners() {
[this.elements.inputText,
this.elements.resetAutocompleteController,
this.elements.lockCursorPosition,
this.elements.zeroSuggest,
this.elements.preventInlineAutocomplete,
this.elements.preferKeyword,
this.elements.currentUrl,
this.elements.pageClassification,
].forEach(element => {
element.addEventListener('input', this.onQueryInputsChanged.bind(this));
});
// Set text of #arrow-padding to substring of #input-text text, from
// beginning until cursor position, in order to correctly align .arrow-up.
this.elements.inputText.addEventListener(
'input', this.positionCursorPositionIndicators.bind(this));
this.elements.connectWindowOmnibox.addEventListener(
'input', this.storeInputs.bind(this));
this.elements.responseSelection.addEventListener(
'input', this.onResponseSelectionChanged.bind(this));
this.elements.responseSelection.addEventListener(
'blur', this.onResponseSelectionBlur.bind(this));
[this.elements.showIncompleteResults,
this.elements.showDetails,
this.elements.showAllProviders,
this.elements.elideCells,
this.elements.thinRows,
].forEach(element => {
element.addEventListener('input', this.onDisplayInputsChanged.bind(this));
});
this.elements.filterText.addEventListener(
'input', this.onFilterInputsChanged.bind(this));
this.elements.exportClipboard.addEventListener(
'click', this.onExportClipboard.bind(this));
this.elements.exportFile.addEventListener(
'click', this.onExportFile.bind(this));
this.elements.importClipboard.addEventListener(
'click', this.onImportClipboard.bind(this));
this.elements.importFileInput.addEventListener(
'input', this.onImportFile.bind(this));
this.elements.processBatchInput.addEventListener(
'input', this.onProcessBatchFile.bind(this));
this.setupDragListeners(this.elements.top);
this.elements.top.addEventListener('drop', this.onImportDropped.bind(this));
this.setupDragListeners(this.elements.processBatch);
this.elements.processBatch.addEventListener(
'drop', this.onProcessBatchDropped.bind(this));
this.$all<HTMLElement>('.button').forEach(
el => el.addEventListener('keypress', (e: KeyboardEvent) => {
if (e.key === ' ' || e.key === 'Enter') {
el.click();
}
}));
}
/**
* Sets up boilerplate event listeners for an element that is able to receive
* drag events.
*/
private setupDragListeners(element: Element) {
// There are 2 classes toggled during drags:
// - `drag-background` alters the `element`'s background when the mouse is
// over it to indicate it's a drag target.
// - `drag-silence` silences mouse events from the children of `element`
// while the drag is active. This is necessary to avoid receiving
// `dragenter` & `dragleave` events when children of `element` are entered
// or left.
// Ideally, there'd be just 1 class controlling both behaviors and active
// when dragging over `element`. However, `dragenter` and `dragleave` events
// are racy (see https://stackoverflow.com/questions/7110353). So instead,
// toggle `drag-background` when the dragging-mouse enters/leaves `element`.
// And toggle `drag-silence` when the mouse begins/stops dragging. This
// workaround isn't 100% accurate, but all the other workarounds (e.g. those
// listed in the stackoverflow answers), were significantly worse.
element.addEventListener('dragenter', () => {
element.classList.add('drag-background');
element.classList.add('drag-silence');
});
element.addEventListener(
'dragleave', () => element.classList.remove('drag-background'));
element.addEventListener('dragover', e => e.preventDefault());
element.addEventListener('drop', e => {
e.preventDefault();
element.classList.remove('drag-background');
element.classList.remove('drag-silence');
});
element.addEventListener(
'mouseenter', () => element.classList.remove('drag-silence'));
}
private onQueryInputsChanged() {
this.elements.importedWarning.hidden = true;
this.dispatchEvent(
new CustomEvent('query-inputs-changed', {detail: this.queryInputs}));
}
get queryInputs(): QueryInputs {
return {
inputText: this.elements.inputText.value,
resetAutocompleteController:
this.elements.resetAutocompleteController.checked,
cursorLock: this.elements.lockCursorPosition.checked,
cursorPosition: this.cursorPosition,
zeroSuggest: this.elements.zeroSuggest.checked,
preventInlineAutocomplete:
this.elements.preventInlineAutocomplete.checked,
preferKeyword: this.elements.preferKeyword.checked,
currentUrl: this.elements.currentUrl.value,
pageClassification: Number(this.elements.pageClassification.value),
};
}
set queryInputs(queryInputs: QueryInputs) {
this.elements.inputText.value = queryInputs.inputText;
this.elements.resetAutocompleteController.checked =
queryInputs.resetAutocompleteController;
this.elements.lockCursorPosition.checked = queryInputs.cursorLock;
this.cursorPosition = queryInputs.cursorPosition;
this.elements.zeroSuggest.checked = queryInputs.zeroSuggest;
this.elements.preventInlineAutocomplete.checked =
queryInputs.preventInlineAutocomplete;
this.elements.preferKeyword.checked = queryInputs.preferKeyword;
this.elements.currentUrl.value = queryInputs.currentUrl;
this.elements.pageClassification.value =
String(queryInputs.pageClassification);
}
private get cursorPosition(): number {
return this.elements.lockCursorPosition.checked ?
this.elements.inputText.value.length :
Number(this.elements.inputText.selectionEnd);
}
private set cursorPosition(value: number) {
this.elements.inputText.setSelectionRange(value, value);
this.positionCursorPositionIndicators();
}
private positionCursorPositionIndicators() {
this.elements.arrowPadding.textContent =
this.elements.inputText.value.substring(0, this.cursorPosition);
}
get connectWindowOmnibox(): boolean {
return this.elements.connectWindowOmnibox.checked;
}
private onResponseSelectionChanged() {
const {value, max} = this.elements.responseSelection;
this.elements.historyWarning.hidden = value === '0' || value === max;
this.dispatchEvent(
new CustomEvent('response-select', {detail: Number(value) - 1}));
}
private onResponseSelectionBlur() {
const {value, min, max} = this.elements.responseSelection;
this.elements.responseSelection.value =
String(Math.max(Math.min(Number(value), Number(max)), Number(min)));
this.onResponseSelectionChanged();
}
set responsesCount(value: number) {
if (this.elements.responseSelection.value ===
this.elements.responseSelection.max) {
this.elements.responseSelection.value = String(value);
}
this.elements.responseSelection.max = String(value);
this.elements.responseSelection.min = String(value ? 1 : 0);
this.elements.responsesCount.textContent = String(value);
this.onResponseSelectionBlur();
}
private onDisplayInputsChanged() {
this.storeInputs();
this.dispatchEvent(new CustomEvent(
'display-inputs-changed', {detail: this.displayInputs}));
}
get displayInputs(): DisplayInputs {
return {
showIncompleteResults: this.elements.showIncompleteResults.checked,
showDetails: this.elements.showDetails.checked,
showAllProviders: this.elements.showAllProviders.checked,
elideCells: this.elements.elideCells.checked,
thinRows: this.elements.thinRows.checked,
};
}
set displayInputs(displayInputs: DisplayInputs) {
this.elements.showIncompleteResults.checked =
displayInputs.showIncompleteResults;
this.elements.showDetails.checked = displayInputs.showDetails;
this.elements.showAllProviders.checked = displayInputs.showAllProviders;
this.elements.elideCells.checked = displayInputs.elideCells;
this.elements.thinRows.checked = displayInputs.thinRows;
}
private onFilterInputsChanged() {
this.dispatchEvent(new CustomEvent(
'filter-input-changed', {detail: this.elements.filterText.value}));
}
private onExportClipboard() {
this.dispatchEvent(new CustomEvent('export-clipboard'));
}
private onExportFile() {
this.dispatchEvent(new CustomEvent('export-file'));
}
private async onImportClipboard() {
this.import(await navigator.clipboard.readText());
}
private onImportFile(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (file) {
this.importFile(file);
}
}
private onProcessBatchFile(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (file) {
this.processBatchFile(file);
}
}
private onImportDropped(event: DragEvent) {
const data = event.dataTransfer!;
const dragText = data.getData('Text');
if (dragText) {
this.import(dragText);
} else if (data.files[0]) {
this.importFile(data.files[0]);
}
}
private onProcessBatchDropped(event: DragEvent) {
const data = event.dataTransfer!;
const dragText = data.getData('Text');
if (dragText) {
this.processBatch(dragText);
} else if (data.files[0]) {
this.processBatchFile(data.files[0]);
}
}
private importFile(file: File) {
OmniboxInput.readFile(file).then(this.import.bind(this));
}
private processBatchFile(file: File) {
OmniboxInput.readFile(file).then(this.processBatch.bind(this));
}
private import(importString: string) {
try {
const importData = JSON.parse(importString);
// TODO(manukh): If import fails, this UI state change shouldn't happen.
this.elements.importedWarning.hidden = false;
this.dispatchEvent(new CustomEvent('import', {detail: importData}));
} catch (error) {
console.error('error during import, invalid json:', error);
}
}
private processBatch(processBatchString: string) {
try {
const processBatchData = JSON.parse(processBatchString);
this.dispatchEvent(
new CustomEvent('process-batch', {detail: processBatchData}));
} catch (error) {
console.error('error during process batch, invalid json:', error);
}
}
private static readFile(file: File): Promise<string> {
return new Promise(resolve => {
const reader = new FileReader();
reader.onloadend = () => {
if (reader.readyState === FileReader.DONE) {
resolve(reader.result as string);
} else {
console.error('error importing, unable to read file:', reader.error);
}
};
reader.readAsText(file);
});
}
static get defaultDisplayInputs(): DisplayInputs {
return {
showIncompleteResults: false,
showDetails: false,
showAllProviders: true,
elideCells: true,
thinRows: false,
};
}
}
customElements.define('omnibox-input', OmniboxInput);