// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'chrome://resources/cr_elements/cr_button/cr_button.js';
import '//resources/polymer/v3_0/paper-spinner/paper-spinner-lite.js';
import type {CrIconButtonElement} from 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.js';
import {I18nMixinLit} from 'chrome://resources/cr_elements/i18n_mixin_lit.js';
import {CrLitElement} from 'chrome://resources/lit/v3_0/lit.rollup.js';
import type {PropertyValues} from 'chrome://resources/lit/v3_0/lit.rollup.js';
import type {LensFormElement} from './lens_form.js';
import {LensErrorType, LensSubmitType} from './lens_form.js';
import {getCss} from './lens_upload_dialog.css.js';
import {getHtml} from './lens_upload_dialog.html.js';
import {WindowProxy} from './window_proxy.js';
enum DialogState {
// Dialog is currently hidden from the user.
HIDDEN,
// Dialog is open and awaiting user input.
NORMAL,
// User is dragging a file over the UI.
DRAGGING,
// User dropped a file and a request to Lens is started.
LOADING,
// User selected a file that resulted in an error.
ERROR,
// User is offline.
OFFLINE,
}
enum LensErrorMessage {
// No error.
NONE,
// User provided an invalid file format.
FILE_TYPE,
// User provided a file that is too large to handle.
FILE_SIZE,
// User provided multiple files.
MULTIPLE_FILES,
// User provided URL with improper scheme.
SCHEME,
// User provided invalid URL.
CONFORMANCE,
// User provided multiple URLs.
MULTIPLE_URLS,
}
const EventKeys = {
ENTER: 'Enter',
ESCAPE: 'Escape',
SPACE: ' ',
TAB: 'Tab',
};
export interface LensUploadDialogElement {
$: {
dialog: HTMLDivElement,
lensForm: LensFormElement,
dragDropArea: HTMLDivElement,
closeButton: CrIconButtonElement,
};
}
/**
* List of possible upload dialog actions. This enum must match with the
* numbering for NewTabPageLensUploadDialogActions in histogram/enums.xml. These
* values are persisted to logs. Entries should not be renumbered, removed or
* reused.
*/
export enum LensUploadDialogAction {
URL_SUBMITTED = 0,
FILE_SUBMITTED = 1,
IMAGE_DROPPED = 2,
DIALOG_OPENED = 3,
DIALOG_CLOSED = 4,
ERROR_SHOWN = 5,
}
/**
* List of possible upload dialog errors. This enum must match with the
* numbering for NewTabPageLensUploadDialogErrors in histogram/enums.xml. These
* values are persisted to logs. Entries should not be renumbered, removed or
* reused.
*/
export enum LensUploadDialogError {
FILE_SIZE = 0,
FILE_TYPE = 1,
MULTIPLE_FILES = 2,
MULTIPLE_URLS = 3,
LENGTH_TOO_GREAT = 4,
INVALID_SCHEME = 5,
INVALID_URL = 6,
NETWORK_ERROR = 7,
}
export function recordLensUploadDialogAction(action: LensUploadDialogAction) {
chrome.metricsPrivate.recordEnumerationValue(
'NewTabPage.Lens.UploadDialog.DialogAction', action,
Object.keys(LensUploadDialogAction).length);
}
export function recordLensUploadDialogError(action: LensUploadDialogError) {
chrome.metricsPrivate.recordEnumerationValue(
'NewTabPage.Lens.UploadDialog.DialogError', action,
Object.keys(LensUploadDialogError).length);
}
const LensUploadDialogElementBase = I18nMixinLit(CrLitElement);
// Modal that lets the user upload images for search on Lens.
export class LensUploadDialogElement extends LensUploadDialogElementBase {
static get is() {
return 'ntp-lens-upload-dialog';
}
static override get styles() {
return getCss();
}
override render() {
return getHtml.bind(this)();
}
static override get properties() {
return {
dialogState_: {type: DialogState},
lensErrorMessage_: {type: Number},
isHidden_: {type: Boolean},
isNormalOrError_: {type: Boolean},
isDragging_: {
type: Boolean,
reflect: true,
},
isLoading_: {
type: Boolean,
reflect: true,
},
isError_: {type: Boolean},
isOffline_: {type: Boolean},
uploadUrl_: {type: String},
};
}
protected isHidden_: boolean;
protected isError_: boolean;
protected isNormalOrError_: boolean;
protected isDragging_: boolean;
protected isLoading_: boolean;
protected isOffline_: boolean;
private dialogState_ = DialogState.HIDDEN;
private lensErrorMessage_ = LensErrorMessage.NONE;
private outsideHandlerAttached_ = false;
protected uploadUrl_: string = '';
private dragCount: number = 0;
override willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);
const changedPrivateProperties =
changedProperties as Map<PropertyKey, unknown>;
if (changedPrivateProperties.has('dialogState_')) {
this.isHidden_ = this.computeIsHidden_();
this.isNormalOrError_ = this.computeIsNormalOrError_();
this.isDragging_ = this.computeIsDragging_();
this.isLoading_ = this.computeIsLoading_();
this.isError_ = this.computeIsError_();
this.isOffline_ = this.computeIsOffline_();
}
}
private computeIsHidden_(): boolean {
return this.dialogState_ === DialogState.HIDDEN;
}
private computeIsNormalOrError_(): boolean {
return this.dialogState_ === DialogState.NORMAL ||
this.dialogState_ === DialogState.ERROR;
}
private computeIsDragging_(): boolean {
return this.dialogState_ === DialogState.DRAGGING;
}
private computeIsLoading_(): boolean {
return this.dialogState_ === DialogState.LOADING;
}
private computeIsError_(): boolean {
return this.dialogState_ === DialogState.ERROR;
}
private computeIsOffline_(): boolean {
return this.dialogState_ === DialogState.OFFLINE;
}
override connectedCallback() {
super.connectedCallback();
this.openDialog();
}
override disconnectedCallback() {
super.disconnectedCallback();
this.detachOutsideHandler_();
}
openDialog() {
this.setOnlineState_();
// Click handler needs to be attached outside of the initial event handler,
// otherwise the click of the icon which initially opened the dialog would
// also be registered in the outside click handler, causing the dialog to
// immediately close after opening.
this.updateComplete.then(() => {
this.attachOutsideHandler_();
if (this.isOffline_) {
this.shadowRoot!.getElementById('offlineRetryButton')!.focus();
} else {
this.shadowRoot!.getElementById('uploadText')!.focus();
}
});
recordLensUploadDialogAction(LensUploadDialogAction.DIALOG_OPENED);
}
closeDialog() {
if (this.isHidden_) {
return;
}
this.dialogState_ = DialogState.HIDDEN;
this.detachOutsideHandler_();
this.dispatchEvent(new Event('close-lens-search'));
recordLensUploadDialogAction(LensUploadDialogAction.DIALOG_CLOSED);
}
protected getErrorString_() {
switch (this.lensErrorMessage_) {
case LensErrorMessage.FILE_TYPE:
return this.i18n('lensSearchUploadDialogErrorFileType');
case LensErrorMessage.FILE_SIZE:
return this.i18n('lensSearchUploadDialogErrorFileSize');
case LensErrorMessage.MULTIPLE_FILES:
return this.i18n('lensSearchUploadDialogErrorMultipleFiles');
case LensErrorMessage.SCHEME:
return this.i18n('lensSearchUploadDialogValidationErrorScheme');
case LensErrorMessage.CONFORMANCE:
return this.i18n('lensSearchUploadDialogValidationErrorConformance');
case LensErrorMessage.MULTIPLE_URLS:
return this.i18n('lensSearchUploadDialogErrorMultipleUrls');
default:
return '';
}
}
/**
* Checks to see if the user is online or offline and sets the dialog state
* accordingly.
*/
private setOnlineState_() {
this.dialogState_ = WindowProxy.getInstance().onLine ? DialogState.NORMAL :
DialogState.OFFLINE;
}
private outsideKeyHandler_ = (event: KeyboardEvent) => {
if (event.key === EventKeys.ESCAPE) {
this.closeDialog();
}
};
private attachOutsideHandler_() {
if (!this.outsideHandlerAttached_) {
document.addEventListener('keydown', this.outsideKeyHandler_);
this.outsideHandlerAttached_ = true;
}
}
private detachOutsideHandler_() {
if (this.outsideHandlerAttached_) {
document.removeEventListener('keydown', this.outsideKeyHandler_);
this.outsideHandlerAttached_ = false;
}
}
protected onCloseButtonKeydown_(event: KeyboardEvent) {
if (event.key === EventKeys.TAB && (this.isDragging_ || this.isLoading_)) {
event.preventDefault();
// In the dragging and loading states, the close button is the only
// tabbable element in the dialog, so focus should stay on it.
} else if (event.key === EventKeys.TAB && event.shiftKey) {
event.preventDefault();
if (this.isNormalOrError_) {
this.shadowRoot!.getElementById('inputSubmit')!.focus();
} else if (this.isOffline_) {
this.shadowRoot!.getElementById('offlineRetryButton')!.focus();
}
}
}
protected onOfflineRetryButtonKeydown_(event: KeyboardEvent) {
if (event.key === EventKeys.TAB && !event.shiftKey) {
event.preventDefault();
this.$.closeButton.focus();
}
}
protected onCloseButtonClick_() {
this.closeDialog();
}
protected onOfflineRetryButtonClick_() {
this.setOnlineState_();
}
protected onUploadFileKeyDown_(event: KeyboardEvent) {
if (event.key === EventKeys.ENTER || event.key === EventKeys.SPACE) {
this.$.lensForm.openSystemFilePicker();
}
}
protected onUploadFileClick_() {
this.$.lensForm.openSystemFilePicker();
}
protected handleFormLoading_(event: CustomEvent<LensSubmitType>) {
this.dialogState_ = DialogState.LOADING;
switch (event.detail) {
case LensSubmitType.FILE:
recordLensUploadDialogAction(LensUploadDialogAction.FILE_SUBMITTED);
break;
case LensSubmitType.URL:
recordLensUploadDialogAction(LensUploadDialogAction.URL_SUBMITTED);
break;
}
}
protected handleFormError_(event: CustomEvent<LensErrorType>) {
switch (event.detail) {
case LensErrorType.MULTIPLE_FILES:
this.dialogState_ = DialogState.ERROR;
this.lensErrorMessage_ = LensErrorMessage.MULTIPLE_FILES;
recordLensUploadDialogAction(LensUploadDialogAction.ERROR_SHOWN);
recordLensUploadDialogError(LensUploadDialogError.MULTIPLE_FILES);
break;
case LensErrorType.NO_FILE:
this.dialogState_ = DialogState.NORMAL;
this.lensErrorMessage_ = LensErrorMessage.NONE;
break;
case LensErrorType.FILE_TYPE:
this.dialogState_ = DialogState.ERROR;
this.lensErrorMessage_ = LensErrorMessage.FILE_TYPE;
recordLensUploadDialogAction(LensUploadDialogAction.ERROR_SHOWN);
recordLensUploadDialogError(LensUploadDialogError.FILE_TYPE);
break;
case LensErrorType.FILE_SIZE:
this.dialogState_ = DialogState.ERROR;
this.lensErrorMessage_ = LensErrorMessage.FILE_SIZE;
recordLensUploadDialogAction(LensUploadDialogAction.ERROR_SHOWN);
recordLensUploadDialogError(LensUploadDialogError.FILE_SIZE);
break;
case LensErrorType.INVALID_SCHEME:
this.dialogState_ = DialogState.ERROR;
this.lensErrorMessage_ = LensErrorMessage.SCHEME;
recordLensUploadDialogAction(LensUploadDialogAction.ERROR_SHOWN);
recordLensUploadDialogError(LensUploadDialogError.INVALID_SCHEME);
break;
case LensErrorType.INVALID_URL:
this.dialogState_ = DialogState.ERROR;
this.lensErrorMessage_ = LensErrorMessage.CONFORMANCE;
recordLensUploadDialogAction(LensUploadDialogAction.ERROR_SHOWN);
recordLensUploadDialogError(LensUploadDialogError.INVALID_URL);
break;
case LensErrorType.LENGTH_TOO_GREAT:
this.dialogState_ = DialogState.ERROR;
this.lensErrorMessage_ = LensErrorMessage.CONFORMANCE;
recordLensUploadDialogAction(LensUploadDialogAction.ERROR_SHOWN);
recordLensUploadDialogError(LensUploadDialogError.LENGTH_TOO_GREAT);
break;
default:
this.dialogState_ = DialogState.NORMAL;
this.lensErrorMessage_ = LensErrorMessage.NONE;
}
}
protected onUrlKeyDown_(event: KeyboardEvent) {
if (event.key === EventKeys.ENTER) {
event.preventDefault();
this.onSubmitUrl_();
}
}
protected onInputSubmitKeyDown_(event: KeyboardEvent) {
if (event.key === EventKeys.ENTER || event.key === EventKeys.SPACE) {
this.onSubmitUrl_();
} else if (event.key === EventKeys.TAB && !event.shiftKey) {
event.preventDefault();
this.$.closeButton.focus();
}
}
protected onSubmitUrl_() {
const url = this.uploadUrl_.trim();
if (url.length > 0) {
this.$.lensForm.submitUrl(url);
}
}
protected onDragEnter_(e: DragEvent) {
e.preventDefault();
this.dragCount += 1;
if (this.dragCount === 1) {
this.dialogState_ = DialogState.DRAGGING;
}
}
protected onDragOver_(e: DragEvent) {
e.preventDefault();
}
protected onDragLeave_(e: DragEvent) {
e.preventDefault();
this.dragCount -= 1;
if (this.dragCount === 0) {
this.dialogState_ = DialogState.NORMAL;
}
}
protected onDrop_(e: DragEvent) {
e.preventDefault();
this.dragCount = 0;
if (e.dataTransfer) {
this.$.lensForm.submitFileList(e.dataTransfer.files);
recordLensUploadDialogAction(LensUploadDialogAction.IMAGE_DROPPED);
}
}
protected onFocusOut_(event: FocusEvent) {
// If the focus event is occurring during a drag into the upload dialog,
// do nothing. See b/284201957#6 for scenario in which this is necessary.
if (this.dragCount === 1) {
return;
}
// Focus ensures that the file picker pop-up does not close dialog.
const outsideDialog = document.hasFocus() &&
(!event.relatedTarget ||
!this.$.dialog.contains(event.relatedTarget as Node));
if (outsideDialog) {
this.closeDialog();
}
}
protected onInputBoxInput_(e: Event) {
this.uploadUrl_ = (e.target as HTMLInputElement).value;
}
}
declare global {
interface HTMLElementTagNameMap {
'ntp-lens-upload-dialog': LensUploadDialogElement;
}
}
customElements.define(LensUploadDialogElement.is, LensUploadDialogElement);