chromium/ui/file_manager/file_manager/foreground/js/thumbnail_loader.ts

// Copyright 2014 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {ImageLoaderClient} from 'chrome-extension://pmfjbimdmchhbnneeidfognadeopoehp/image_loader_client.js';
import type {ImageTransformParam} from 'chrome-extension://pmfjbimdmchhbnneeidfognadeopoehp/image_orientation.js';
import {createRequest, LoadImageResponse, LoadImageResponseStatus} from 'chrome-extension://pmfjbimdmchhbnneeidfognadeopoehp/load_image_request.js';
import {assert, assertNotReached} from 'chrome://resources/js/assert.js';

import {getMediaType, isImage, isPDF, isRaw, isVideo} from '../../common/js/file_type.js';
import type {FilesAppEntry} from '../../common/js/files_app_entry_types.js';

import type {ThumbnailMetadataItem} from './metadata/thumbnail_model.js';

/**
 * Loads a thumbnail as an <img> using provided url.
 */
export class ThumbnailLoader {
  private image_: HTMLImageElement|null = null;
  private taskId_: number|null = null;
  private mediaType_: string;
  private priority_: number;
  /**
   * The image transform from metadata.
   */
  private transform_?: ImageTransformParam = undefined;
  private loadTarget_: LoadTarget|null = null;
  private thumbnailUrl_: string|null;
  private fallbackUrl_: string|null = null;
  private croppedThumbnailUrl_?: string|null = null;

  /**
   * @param entry_ File entry.
   * @param metadata_ Metadata object.
   * @param mediaType Media type.
   * @param loadTargets The list of load targets in preferential order. The
   *     default value is [CONTENT_METADATA, EXTERNAL_METADATA, FILE_ENTRY].
   * @param priority Priority, the highest is 0. default: 2.
   */
  constructor(
      private entry_: Entry|FilesAppEntry,
      private metadata_?: ThumbnailMetadataItem, mediaType?: string,
      suppliedLoadTargets?: LoadTarget[], priority?: number) {
    const loadTargets = suppliedLoadTargets || [
      LoadTarget.CONTENT_METADATA,
      LoadTarget.EXTERNAL_METADATA,
      LoadTarget.FILE_ENTRY,
    ];

    this.mediaType_ = mediaType || getMediaType(this.entry_);

    this.priority_ = (priority !== undefined) ? priority : 2;

    if (!this.metadata_) {
      this.thumbnailUrl_ = this.entry_.toURL();  // Use the URL directly.
      this.loadTarget_ = LoadTarget.FILE_ENTRY;
      return;
    }

    this.fallbackUrl_ = null;
    this.thumbnailUrl_ = null;
    if (this.metadata_.external && this.metadata_.external.customIconUrl) {
      this.fallbackUrl_ = this.metadata_.external.customIconUrl;
    }
    const mimeType = this.metadata_ && this.metadata_.contentMimeType;

    for (let i = 0; i < loadTargets.length; i++) {
      switch (loadTargets[i]) {
        case LoadTarget.CONTENT_METADATA:
          if (this.metadata_.thumbnail && this.metadata_.thumbnail.url) {
            this.thumbnailUrl_ = this.metadata_.thumbnail.url;
            this.transform_ = (this.metadata_.thumbnail &&
                               this.metadata_.thumbnail.transform) ??
                undefined;
            this.loadTarget_ = LoadTarget.CONTENT_METADATA;
          }
          break;
        case LoadTarget.EXTERNAL_METADATA:
          if (this.metadata_.external && this.metadata_.external.thumbnailUrl &&
              (!this.metadata_.external.present ||
               !isImage(this.entry_, mimeType))) {
            this.thumbnailUrl_ = this.metadata_.external.thumbnailUrl;
            this.croppedThumbnailUrl_ =
                this.metadata_.external.croppedThumbnailUrl;
            this.loadTarget_ = LoadTarget.EXTERNAL_METADATA;
          }
          break;
        case LoadTarget.FILE_ENTRY:
          if (isImage(this.entry_, mimeType) ||
              isVideo(this.entry_, mimeType) || isRaw(this.entry_, mimeType) ||
              isPDF(this.entry_, mimeType)) {
            this.thumbnailUrl_ = this.entry_.toURL();
            this.transform_ =
                (this.metadata_.media && this.metadata_.media.imageTransform) ??
                undefined;
            this.loadTarget_ = LoadTarget.FILE_ENTRY;
          }
          break;
        default:
          assertNotReached('Unkonwn load type: ' + loadTargets[i]);
      }
      if (this.thumbnailUrl_) {
        break;
      }
    }

    if (!this.thumbnailUrl_ && this.fallbackUrl_) {
      // Use fallback as the primary thumbnail.
      this.thumbnailUrl_ = this.fallbackUrl_;
      this.fallbackUrl_ = null;
    }  // else the generic thumbnail based on the media type will be used.
  }

  /**
   * Returns the target of loading.
   */
  getLoadTarget(): LoadTarget|null {
    return this.loadTarget_;
  }

  /**
   * Loads and attaches an image.
   *
   * @param box Container element.
   * @param fillMode Fill mode.
   * @param onSuccess Success callback, accepts the image.
   * @param autoFillThreshold Auto fill threshold.
   * @param boxWidth Container box's width.
   * @param boxHeight Container box's height.
   */
  load(
      box: Element, fillMode: FillMode,
      onSuccess: (image: HTMLImageElement) => void, autoFillThreshold: number,
      boxWidth: number, boxHeight: number) {
    if (!this.thumbnailUrl_) {
      // Relevant CSS rules are in file_types.css.
      box.setAttribute('generic-thumbnail', this.mediaType_);
      return;
    }

    this.cancel();
    this.image_ = new Image();
    this.image_.setAttribute('alt', this.entry_.name);
    this.image_.onload = () => {
      this.attachImage_(box, fillMode, autoFillThreshold, boxWidth, boxHeight);
      assert(this.image_);
      onSuccess(this.image_);
    };
    this.image_.onerror = () => {
      if (this.fallbackUrl_) {
        this.thumbnailUrl_ = this.fallbackUrl_;
        this.fallbackUrl_ = null;
        this.load(
            box, fillMode, onSuccess, AUTO_FILL_THRESHOLD_DEFAULT_VALUE,
            box.clientWidth, box.clientHeight);
      } else {
        box.setAttribute('generic-thumbnail', this.mediaType_);
      }
    };

    if (this.image_.src) {
      console.warn('Thumbnail already loaded: ' + this.thumbnailUrl_);
      return;
    }

    // TODO(mtomasz): Smarter calculation of the requested size.
    const modificationTime = this.metadata_ && this.metadata_.filesystem &&
        this.metadata_.filesystem.modificationTime &&
        this.metadata_.filesystem.modificationTime.getTime();
    this.taskId_ = ImageLoaderClient.loadToImage(
        createRequest({
          url: this.thumbnailUrl_,
          maxWidth: THUMBNAIL_MAX_WIDTH,
          maxHeight: THUMBNAIL_MAX_HEIGHT,
          cache: true,
          priority: this.priority_,
          timestamp: modificationTime,
          orientation: this.transform_,
        }),
        this.image_, () => {}, () => {
          this.image_?.onerror?.(new Event('load-error'));
        });
  }

  /**
   * Loads thumbnail as dataUrl. If the thumbnail dataUrl can be fetched from
   * metadata, this fetches it from it. Otherwise, this tries to load it from
   * thumbnail loader.
   * Compared with ThumbnailLoader.load, this method does not provide a
   * functionality to fit image to a box.
   *
   * @param fillMode Only FIT and OVER_FILL are supported. This takes effect
   *     only when an external thumbnail source is used.
   * @return A promise which is resolved when data url is fetched.
   *
   * TODO(yawano): Support cancel operation.
   */
  loadAsDataUrl(fillMode: FillMode): Promise<LoadImageResponse> {
    assert(fillMode === FillMode.FIT || fillMode === FillMode.OVER_FILL);

    return new Promise((resolve, reject) => {
      let requestUrl = this.thumbnailUrl_;

      if (fillMode === FillMode.OVER_FILL) {
        // Use the croppedThumbnailUrl_ if available.
        requestUrl = this.croppedThumbnailUrl_ || this.thumbnailUrl_;
      }

      if (!requestUrl) {
        const error = LoadImageResponseStatus.ERROR;
        reject(new LoadImageResponse(error, 0));
        return;
      }

      const modificationTime = this.metadata_ && this.metadata_.filesystem &&
          this.metadata_.filesystem.modificationTime &&
          this.metadata_.filesystem.modificationTime.getTime();

      // Load using ImageLoaderClient.
      const request = createRequest({
        url: requestUrl,
        maxWidth: THUMBNAIL_MAX_WIDTH,
        maxHeight: THUMBNAIL_MAX_HEIGHT,
        cache: true,
        priority: this.priority_,
        timestamp: modificationTime,
        orientation: this.transform_,
      });

      if (fillMode === FillMode.OVER_FILL) {
        // Set crop option to image loader. Since image of croppedThumbnailUrl_
        // is 360x360 with current implementation, it's no problem to crop it.
        request.width = 360;
        request.height = 360;
        request.crop = true;
      }

      ImageLoaderClient.getInstance().load(request, result => {
        if (!result || result.status !== LoadImageResponseStatus.SUCCESS) {
          reject(result);
        } else {
          resolve(result);
        }
      });
    });
  }

  /**
   * Cancels loading the current image.
   */
  cancel() {
    if (this.taskId_) {
      assert(this.image_);
      this.image_.onload = () => {};
      this.image_.onerror = () => {};
      ImageLoaderClient.getInstance().cancel(this.taskId_);
      this.taskId_ = null;
    }
  }

  /**
   * @return True if a valid image is loaded.
   */
  hasValidImage(): boolean {
    return !!(this.image_ && this.image_.width && this.image_.height);
  }

  /**
   * @return Image width.
   */
  getWidth(): number {
    assert(this.image_);
    return this.image_.width;
  }

  /**
   * @return Image height.
   */
  getHeight(): number {
    assert(this.image_);
    return this.image_.height;
  }

  /**
   * Attach the image to a given element.
   * @param box Container element.
   * @param fillMode Fill mode.
   * @param autoFillThreshold Threshold value which is used for fill mode auto.
   * @param boxWidth Container box's width.
   * @param boxHeight Container box's height.
   */
  private attachImage_(
      box: Element, fillMode: FillMode, autoFillThreshold: number,
      boxWidth: number, boxHeight: number) {
    if (!this.hasValidImage()) {
      box.setAttribute('generic-thumbnail', this.mediaType_);
      return;
    }

    assert(this.image_);
    ThumbnailLoader.centerImage(
        this.image_, fillMode, autoFillThreshold, boxWidth, boxHeight);

    if (this.image_.parentNode !== box) {
      box.textContent = '';
      box.appendChild(this.image_!);
    }

    if (!this.taskId_) {
      this.image_.classList.add('cached');
    }
  }

  /**
   * Updates the image style to fit/fill the container.
   *
   * Using webkit center packing does not align the image properly, so we need
   * to wait until the image loads and its dimensions are known, then manually
   * position it at the center.
   *
   * @param box Containing element.
   * @param img Element containing an image.
   * @param fillMode Fill mode.
   * @param autoFillThreshold Threshold value which is used for fill mode auto.
   * @param boxWidth Container box's width.
   * @param boxHeight Container box's height.
   */
  static centerImage(
      img: HTMLImageElement, fillMode: FillMode, autoFillThreshold: number,
      boxWidth: number, boxHeight: number) {
    const imageWidth = img.width;
    const imageHeight = img.height;

    let fractionX;
    let fractionY;

    let fill;
    switch (fillMode) {
      case FillMode.FILL:
      case FillMode.OVER_FILL:
        fill = true;
        break;
      case FillMode.FIT:
        fill = false;
        break;
      case FillMode.AUTO:
        const imageRatio = imageWidth / imageHeight;
        let boxRatio = 1.0;
        if (boxWidth && boxHeight) {
          boxRatio = boxWidth / boxHeight;
        }
        // Cropped area in percents.
        const ratioFactor = boxRatio / imageRatio;
        fill = (ratioFactor >= 1.0 - autoFillThreshold) &&
            (ratioFactor <= 1.0 + autoFillThreshold);
        break;
    }

    if (boxWidth && boxHeight) {
      // When we know the box size we can position the image correctly even
      // in a non-square box.
      const fitScaleX = boxWidth / imageWidth;
      const fitScaleY = boxHeight / imageHeight;

      let scale = fill ? Math.max(fitScaleX, fitScaleY) :
                         Math.min(fitScaleX, fitScaleY);

      if (fillMode !== FillMode.OVER_FILL) {
        scale = Math.min(scale, 1);  // Never overscale.
      }

      fractionX = imageWidth * scale / boxWidth;
      fractionY = imageHeight * scale / boxHeight;
    } else {
      // We do not know the box size so we assume it is square.
      // Compute the image position based only on the image dimensions.
      // First try vertical fit or horizontal fill.
      fractionX = imageWidth / imageHeight;
      fractionY = 1;
      if ((fractionX < 1) === !!fill) {  // Vertical fill or horizontal fit.
        fractionY = 1 / fractionX;
        fractionX = 1;
      }
    }

    function percent(fraction: number) {
      return (fraction * 100).toFixed(2) + '%';
    }

    img.style.width = percent(fractionX);
    img.style.height = percent(fractionY);
    img.style.left = percent((1 - fractionX) / 2);
    img.style.top = percent((1 - fractionY) / 2);
  }
}

/**
 * In percents (0.0 - 1.0), how much area can be cropped to fill an image
 * in a container, when loading a thumbnail in FillMode.AUTO mode.
 * The default 30% value allows to fill 16:9, 3:2 pictures in 4:3 element.
 */
const AUTO_FILL_THRESHOLD_DEFAULT_VALUE = 0.3;

/**
 * Type of displaying a thumbnail within a box.
 */
export enum FillMode {
  FILL = 0,   // Fill whole box. Image may be cropped.
  FIT,        // Keep aspect ratio, do not crop.
  OVER_FILL,  // Fill whole box with possible stretching.
  AUTO,       // Try to fill, but if incompatible aspect ratio, then fit.
}

/**
 * Load target of ThumbnailLoader.
 */
export enum LoadTarget {
  // e.g. Drive thumbnail, FSP thumbnail.
  EXTERNAL_METADATA = 'externalMetadata',
  // e.g. EXIF thumbnail.
  CONTENT_METADATA = 'contentMetadata',
  // Image file itself.
  FILE_ENTRY = 'fileEntry',
}

/**
 * Maximum thumbnail's width when generating from the full resolution image.
 */
export const THUMBNAIL_MAX_WIDTH = 500;

/**
 * Maximum thumbnail's height when generating from the full resolution image.
 */
export const THUMBNAIL_MAX_HEIGHT = 500;