chromium/ui/file_manager/file_manager/foreground/elements/files_quick_view.ts

// Copyright 2016 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/ash/common/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';
import './files_metadata_box.js';
import './files_safe_media.js';

import type {CrDialogElement} from 'chrome://resources/ash/common/cr_elements/cr_dialog/cr_dialog.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {toSandboxedURL} from '../../common/js/url_constants.js';

import type {FilesMetadataBox} from './files_metadata_box.js';
import {getTemplate} from './files_quick_view.html.js';
import type {FilesSafeMedia} from './files_safe_media.js';


export interface FilesQuickView {
  $: {
    contentPanel: HTMLDivElement,
    dialog: CrDialogElement,
    'metadata-box': FilesMetadataBox,
  };
  $$: <T extends HTMLElement>(selector: string) => T;
  isModal: boolean;
  browsable: boolean;
  type: string;
  subtype: string;
  sourceContent: FilePreviewContent;
  metadataBoxActive: boolean;
  fire: (eventName: string) => void;
}

export class FilesQuickView extends PolymerElement {
  static get is() {
    return 'files-quick-view';
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      // File media type, e.g. image, video.
      type: String,
      subtype: String,
      filePath: String,

      // True if there is a file task that can open the file type.
      hasTask: Boolean,

      /**
       * True if the entry shown in Quick View can be deleted.
       */
      canDelete: Boolean,

      /**
       * Preview content to be sent rendered in a sandboxed environment.
       */
      sourceContent: {
        type: Object,
        observer: 'refreshUntrustedIframe',
      },

      videoPoster: String,
      audioArtwork: String,

      // Autoplay property for audio, video.
      autoplay: Boolean,

      // True if this file is not image, audio, video or HTML, but is
      // supported by Chrome - content that is directly preview-able in
      // Chrome by setting the untrusted <iframe> src attribute. Examples:
      // pdf, text.
      browsable: Boolean,

      // The metadata-box-active-changed event is fired on attribute change.
      metadataBoxActive: {
        value: true,
        type: Boolean,
        notify: true,
      },

      // Text shown when there is no playback/preview available.
      noPlaybackText: String,
      noPreviewText: String,

      /**
       * True if the Files app window is a dialog, e.g. save-as or
       * open-with.
       */
      isModal: Boolean,
    };
  }

  override ready() {
    super.ready();
    this.$.dialog.addEventListener(
        'files-safe-media-tap-inside', this.clickInside.bind(this));
    this.$.dialog.addEventListener(
        'files-safe-media-tap-outside', this.close.bind(this));
    this.$.dialog.addEventListener(
        'files-safe-media-load-error', this.loaderror.bind(this));
    this.$.contentPanel.addEventListener(
        'click', this.onContentPanelClick.bind(this));
  }

  /**
   * Send browsable preview content (i.e. content that can be displayed by the
   * browser directly e.g. PDF/text) to the chrome-untrusted:// <iframe>.
   */
  refreshUntrustedIframe() {
    if (!this.browsable) {
      return;
    }

    const iframe =
        this.shadowRoot!.querySelector<HTMLIFrameElement>('#untrusted');
    if (!iframe) {
      return;
    }

    const data = {
      browsable: this.browsable,
      subtype: this.subtype,
      sourceContent: this.sourceContent,
    };

    iframe.contentWindow?.postMessage(data, toSandboxedURL().origin);
  }

  // Clears fields.
  clear() {
    this.setProperties({
      type: '',
      subtype: '',
      filePath: '',
      hasTask: false,
      canDelete: false,
      sourceContent: {
        data: null,
        dataType: '',
      },
      videoPoster: '',
      audioArtwork: '',
      autoplay: false,
      browsable: false,
    });

    // Remove the video's untrusted <iframe> child. The <iframe> contains the
    // <video> element. Removing the <iframe> removes the <video>: that stops
    // the video and its audio track playing: crbug.com/970192
    const video =
        this.$.contentPanel.querySelector<FilesSafeMedia>('#videoSafeMedia');
    if (video) {
      video.src = {
        data: null,
        dataType: '',
      };
    }

    this.removeAttribute('load-error');
  }

  // Handle load error from the files-safe-media container.
  loaderror() {
    this.setAttribute('load-error', '');
    this.sourceContent = {
      data: null,
      dataType: '',
    };
  }

  isOpened() {
    return this.$.dialog?.open;
  }

  // Opens the dialog.
  open() {
    if (!this.isOpened()) {
      this.$.dialog.showModal();
      // Make dialog focusable and set focus to a dialog. This is how we can
      // prevent default behaviour of a dialog which by default sets focus to
      // the first input inside itself. When a dialog gains focus we remove
      // focusability to prevent selecting dialog when moving with a keyboard.
      this.$.dialog.setAttribute('tabindex', '0');
      this.$.dialog.focus();
      this.$.dialog.setAttribute('tabindex', '-1');
    }
  }

  // Closes the dialog.
  close() {
    if (this.isOpened()) {
      this.$.dialog.close();
    }
  }

  clickInside() {
    if (this.type === 'image') {
      const dialog = this.shadowRoot!.querySelector<CrDialogElement>('#dialog');
      dialog?.focus();
    }
  }

  getFilesMetadataBox() {
    return this.$['metadata-box'];
  }

  /**
   * Client should assign the function to open the file.
   */
  onOpenInNewButtonClick(_: Event) {}

  shouldShowOpenButton(hasTask: boolean, isModal: boolean) {
    return hasTask && !isModal;
  }

  /**
   * Client should assign the function to delete the file.
   */
  onDeleteButtonClick(_: Event) {}

  shouldShowDeleteButton(canDelete: boolean, isModal: boolean) {
    return canDelete && !isModal;
  }

  /**
   * See the changes on crbug.com/641587, but crbug.com/779044#c11 later undid
   * that work. So the focus remains on the metadata button when clicked after
   * the crbug.com/779044 "ghost focus" fix.
   *
   * crbug.com/641587 mentions a different UI behavior, that was wanted to fix
   * that bug. TODO(files-ng): UX to resolve the correct behavior needed here.
   */
  onMetadataButtonClick(_: Event) {
    this.metadataBoxActive = !this.metadataBoxActive;
  }

  /**
   * Close Quick View unless the clicked target or its ancestor contains
   * 'no-close-on-click' class.
   */
  onContentPanelClick(event: Event) {
    let target: HTMLElement|null = event.target as HTMLElement;
    while (target) {
      if (target.classList.contains('no-close-on-click')) {
        return;
      }
      target = target.parentElement;
    }
    this.close();
  }

  hasContent(sourceContent: FilePreviewContent) {
    return sourceContent.dataType !== '';
  }

  isHtml(type: string, subtype: string) {
    return type === 'document' && subtype === 'HTML';
  }

  isImage(type: string) {
    return type === 'image';
  }

  isVideo(type: string) {
    return type === 'video';
  }

  isAudio(type: string) {
    return type === 'audio';
  }

  audioContent(sourceContent: FilePreviewContent, type: string):
      FilePreviewContent {
    if (this.isAudio(type)) {
      return sourceContent;
    }
    return {
      data: null,
      dataType: '',
    };
  }

  isUnsupported(type: string, subtype: string, browsable: boolean) {
    return !this.isImage(type) && !this.isVideo(type) && !this.isAudio(type) &&
        !this.isHtml(type, subtype) && !browsable;
  }

  onDialogClose(e: Event) {
    if (e.target !== this.$.dialog) {
      return;
    }
    this.clear();

    // Catch and re-fire the 'close' event such that it bubbles across Shadow
    // DOM v1.
    this.dispatchEvent(
        new CustomEvent('close', {bubbles: true, composed: true}));
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'files-quick-view': FilesQuickView;
  }
}

customElements.define(FilesQuickView.is, FilesQuickView);