// Copyright 2012 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {assertInstanceof} from 'chrome://resources/ash/common/assert.js';
import {dispatchSimpleEvent} from 'chrome://resources/ash/common/cr_deprecated.js';
import {isRTL} from 'chrome://resources/ash/common/util.js';
import {assert} from 'chrome://resources/js/assert.js';
import type {VolumeManager} from '../../../background/js/volume_manager.js';
import {RateLimiter} from '../../../common/js/async_util.js';
import {crInjectTypeAndInit} from '../../../common/js/cr_ui.js';
import {maybeShowTooltip} from '../../../common/js/dom_utils.js';
import {entriesToURLs} from '../../../common/js/entry_utils.js';
import {getIcon, getType, isEncrypted} from '../../../common/js/file_type.js';
import type {FilesAppEntry} from '../../../common/js/files_app_entry_types.js';
import {getEntryLabel, str} from '../../../common/js/translations.js';
import type {FilesTooltip} from '../../elements/files_tooltip.js';
import {type FileListModel, GROUP_BY_FIELD_DIRECTORY, GROUP_BY_FIELD_MODIFICATION_TIME, type GroupValue} from '../file_list_model.js';
import type {ListThumbnailLoader} from '../list_thumbnail_loader.js';
import {type ThumbnailLoadedEvent} from '../list_thumbnail_loader.js';
import {type MetadataItem} from '../metadata/metadata_item.js';
import {type MetadataModel} from '../metadata/metadata_model.js';
import type {A11yAnnounce} from './a11y_announce.js';
import {DragSelector} from './drag_selector.js';
import {decorateListItem, focusParentList, handleKeyDown, handlePointerDownUp, handleTap, isDlpBlocked, renderFileNameLabel, renderFileTypeIcon, renderIconBadge, updateCacheItemInlineStatus, updateInlineStatus} from './file_table_list.js';
import {FileTapHandler} from './file_tap_handler.js';
import {Grid, GridSelectionController} from './grid.js';
import {List} from './list.js';
import {ListItem} from './list_item.js';
import type {ListSelectionModel} from './list_selection_model.js';
// Align with CSS .grid-title.group-by-modificationTime.
const MODIFICATION_TIME_GROUP_HEADING_HEIGHT = 57;
// Align with CSS .grid-title.group-by-isDirectory.
const DIRECTORY_GROUP_HEADING_HEIGHT = 40;
// Align with CSS .grid-title ~ .grid-title
const GROUP_MARGIN_TOP = 16;
/**
* FileGrid constructor.
*
* Represents grid for the Grid View in the File Manager.
*/
export class FileGrid extends Grid {
private paddingTop_: number = 0;
private paddingStart_: number = 0;
private beginIndex_: number = 0;
private endIndex_: number = 0;
private metadataModel_: MetadataModel|null = null;
private listThumbnailLoader_: ListThumbnailLoader|null = null;
private volumeManager_: VolumeManager|null = null;
private relayoutRateLimiter_: RateLimiter|null = null;
private onThumbnailLoadedBound_: null|EventListener = null;
a11y: A11yAnnounce|null = null;
override get dataModel() {
return super.dataModel as FileListModel;
}
override set dataModel(model: FileListModel|null) {
// The setter for dataModel is overridden to remove/add the 'splice'
// listener for the current data model.
if (this.dataModel) {
this.dataModel.removeEventListener('splice', this.onSplice_.bind(this));
this.dataModel.removeEventListener('sorted', this.onSorted_.bind(this));
}
super.dataModel = model;
if (this.dataModel) {
this.dataModel.addEventListener('splice', this.onSplice_.bind(this));
this.dataModel.addEventListener('sorted', this.onSorted_.bind(this));
}
}
/**
* Decorates an HTML element to be a FileGrid.
*/
static decorate(
element: HTMLElement, metadataModel: MetadataModel,
volumeManager: VolumeManager, a11y: A11yAnnounce) {
const self = element as FileGrid;
Object.setPrototypeOf(self, FileGrid.prototype);
self.initialize();
self.setAttribute('aria-multiselectable', 'true');
self.setAttribute('aria-describedby', 'more-actions-info');
self.metadataModel_ = metadataModel;
self.volumeManager_ = volumeManager;
self.a11y = a11y;
// Force the list's ending spacer to be tall enough to allow overscroll.
const endSpacer = self.querySelector('.spacer:last-child');
if (endSpacer) {
endSpacer.classList.add('signals-overscroll');
}
self.listThumbnailLoader_ = null;
self.beginIndex_ = 0;
self.endIndex_ = 0;
self.onThumbnailLoadedBound_ =
self.onThumbnailLoaded_.bind(self) as EventListener;
self.itemConstructor = function(entry: Entry) {
const item = self.ownerDocument.createElement('li') as FileGridItem;
self.decorateThumbnail_(item, entry);
crInjectTypeAndInit(item, FileGridItem);
return item;
};
self.relayoutRateLimiter_ =
new RateLimiter(self.relayoutImmediately_.bind(self));
const style = window.getComputedStyle(self);
self.paddingStart_ =
parseFloat(isRTL() ? style.paddingRight : style.paddingLeft);
self.paddingTop_ = parseFloat(style.paddingTop);
self.addEventListener(
'mouseover', self.onMouseOver_.bind(self), {passive: true});
// Update the item's inline status when it's restored from List's cache.
self.addEventListener(
'cachedItemRestored',
(e) => updateCacheItemInlineStatus(
e.detail, self.dataModel!, self.metadataModel_!));
}
private onMouseOver_(event: MouseEvent) {
this.maybeShowToolTip(event);
}
maybeShowToolTip(event: Event) {
let target = null;
for (const element of event.composedPath()) {
const el = element as HTMLElement;
if (el.classList?.contains('thumbnail-item')) {
target = el;
break;
}
}
if (!target) {
return;
}
const labelElement = target.querySelector('.filename-label') as HTMLElement;
if (!labelElement) {
return;
}
maybeShowTooltip(labelElement, labelElement.innerText);
}
/**
* @param index Index of the list item.
*/
getItemLabel(index: number): string {
if (index === -1) {
return '';
}
const entry: Entry|null = this.dataModel?.item(index) as Entry;
if (!entry) {
return '';
}
const locationInfo = this.volumeManager_!.getLocationInfo(entry);
return getEntryLabel(locationInfo, entry);
}
/**
* Sets list thumbnail loader.
*/
setListThumbnailLoader(listThumbnailLoader: ListThumbnailLoader|null) {
if (this.listThumbnailLoader_) {
this.listThumbnailLoader_.removeEventListener(
'thumbnailLoaded', this.onThumbnailLoadedBound_);
}
this.listThumbnailLoader_ = listThumbnailLoader;
if (this.listThumbnailLoader_) {
this.listThumbnailLoader_.addEventListener(
'thumbnailLoaded', this.onThumbnailLoadedBound_!);
this.listThumbnailLoader_.setHighPriorityRange(
this.beginIndex_, this.endIndex_);
}
}
/**
* Returns the element containing the thumbnail of a certain list item as
* background image.
* @param index The index of the item containing the desired thumbnail.
* @return The element containing the thumbnail, or null, if an error
* occurred.
*/
getThumbnail(index: number): HTMLElement|null {
return this.getListItemByIndex(index)
?.querySelector('.img-container')
?.querySelector('.thumbnail') ??
null;
}
private onThumbnailLoaded_(event: ThumbnailLoadedEvent) {
assert(this.dataModel);
assert(this.metadataModel_);
const listItem = this.getListItemByIndex(event.detail.index);
const entry = listItem && this.dataModel.item(listItem.listIndex);
if (!entry) {
return;
}
const box = listItem.querySelector('.img-container');
if (box) {
const mimeType =
this.metadataModel_.getCache(
[entry],
['contentMimeType'])[0]!.contentMimeType;
if (!event.detail.dataUrl) {
FileGrid.clearThumbnailImage_(assertInstanceof(box, HTMLDivElement));
this.setGenericThumbnail_(
assertInstanceof(box, HTMLDivElement), entry, mimeType);
} else {
assert(event.detail.width);
assert(event.detail.height);
FileGrid.setThumbnailImage_(
assertInstanceof(box, HTMLDivElement), entry, event.detail.dataUrl,
event.detail.width, event.detail.height, mimeType);
}
}
listItem.classList.toggle('thumbnail-loaded', !!event.detail.dataUrl);
}
override mergeItems(beginIndex: number, endIndex: number) {
List.prototype.mergeItems.call(this, beginIndex, endIndex);
const fileListModel = this.dataModel;
const groupBySnapshot =
fileListModel ? fileListModel.getGroupBySnapshot() : [];
const startIndexToGroupLabel = new Map(groupBySnapshot.map(group => {
return [group.startIndex, group];
}));
// Make sure that grid item's selected attribute is updated just after the
// mergeItems operation is done. This prevents shadow of selected grid items
// from being animated unintentionally by redraw.
for (let i = beginIndex; i < endIndex; i++) {
const item = this.getListItemByIndex(i);
if (!item) {
continue;
}
const isSelected = !!this.selectionModel?.getIndexSelected(i);
if (item.selected !== isSelected) {
item.selected = isSelected;
}
// Check if index i is the start of a new group.
if (startIndexToGroupLabel.has(i)) {
// For first item in each group, we add a title div before the element.
const title = document.createElement('div');
title.setAttribute('role', 'heading');
title.innerText = startIndexToGroupLabel.get(i)!.label;
title.classList.add(
'grid-title', `group-by-${fileListModel!.groupByField}`);
this.insertBefore(title, item);
}
}
// Keep these values to set range when a new list thumbnail loader is set.
this.beginIndex_ = beginIndex;
this.endIndex_ = endIndex;
if (this.listThumbnailLoader_ !== null) {
this.listThumbnailLoader_.setHighPriorityRange(beginIndex, endIndex);
}
}
override getItemTop(index: number) {
assert(this.dataModel);
const fileListModel = this.dataModel;
const groupBySnapshot = fileListModel.getGroupBySnapshot();
let top = 0;
let totalItemCount = 0;
for (let groupIndex = 0; groupIndex < groupBySnapshot.length;
groupIndex++) {
const group = groupBySnapshot[groupIndex]!;
if (index <= group.endIndex) {
// The index falls into the current group. Calculates how many rows
// we have in the current group up until this index.
const indexInCurGroup = index - totalItemCount;
const rowsInCurGroup = Math.floor(indexInCurGroup / this.columns);
top +=
(rowsInCurGroup > 0 ? this.getGroupHeadingHeight_(groupIndex) : 0) +
rowsInCurGroup * this.getGroupItemHeight_(group.group);
break;
} else {
// The index is not in the current group. Add all row heights in this
// group to the final result.
const groupItemCount = group.endIndex - group.startIndex + 1;
const groupRowCount = Math.ceil(groupItemCount / this.columns);
top += this.getGroupHeadingHeight_(groupIndex) +
groupRowCount * this.getGroupItemHeight_(group.group);
totalItemCount += groupItemCount;
}
}
return top;
}
override getItemRow(index: number) {
assert(this.dataModel);
const fileListModel = this.dataModel;
const groupBySnapshot = fileListModel.getGroupBySnapshot();
let rows = 0;
let totalItemCount = 0;
for (const group of groupBySnapshot) {
if (index <= group.endIndex) {
// The index falls into the current group. Calculates how many rows
// we have in the current group up until this index.
const indexInCurGroup = index - totalItemCount;
rows += Math.floor(indexInCurGroup / this.columns);
break;
} else {
// The index is not in the current group. Add all rows in this
// group to the final result.
const groupItemCount = group.endIndex - group.startIndex + 1;
rows += Math.ceil(groupItemCount / this.columns);
totalItemCount += groupItemCount;
}
}
return rows;
}
/**
* Returns the column of an item which has given index.
* @param index The item index.
*/
getItemColumn(index: number) {
assert(this.dataModel);
const fileListModel = this.dataModel;
const groupBySnapshot = fileListModel.getGroupBySnapshot();
let totalItemCount = 0;
for (const group of groupBySnapshot) {
if (index <= group.endIndex) {
// The index falls into the current group. Calculates the column index
// with the remaining index in this group.
const indexInCurGroup = index - totalItemCount;
return indexInCurGroup % this.columns;
}
const groupItemCount = group.endIndex - group.startIndex + 1;
totalItemCount += groupItemCount;
}
return 0;
}
/**
* Return the item index which is placed at the given position.
* If there is no item in the given position, returns -1.
* @param row The row index.
* @param column The column index.
*/
getItemIndex(row: number, column: number) {
if (row < 0 || column < 0 || column >= this.columns) {
return -1;
}
assert(this.dataModel);
const fileListModel = this.dataModel;
const groupBySnapshot = fileListModel.getGroupBySnapshot();
let curRow = 0;
let index = 0;
for (const group of groupBySnapshot) {
const groupItemCount = group.endIndex - group.startIndex + 1;
const groupRowCount = Math.ceil(groupItemCount / this.columns);
if (row < curRow + groupRowCount) {
// The row falls into the current group. Calculate the index based on
// the column value and return.
const isLastRowInGroup = row === curRow + groupRowCount - 1;
const itemCountInLastRow =
groupItemCount - (groupRowCount - 1) * this.columns;
if (isLastRowInGroup && column >= itemCountInLastRow) {
// column is larger than the item count in this row, return -1.
// This happens when we try to find the index for the above/below
// items. For example:
// --------------------------------------
// item 0 item 1 item 2
// item 3 (end of group)
// item 4 item 5 (end of group)
// --------------------------------------
// * To find above index for item 5, we pass (row - 1, col), col is
// not existed in the above row.
// * To find the below index for item 2, we pass (row + 1, col), col
// is not existed in the below row.
return -1;
}
return index + (row - curRow) * this.columns + column;
}
curRow += groupRowCount;
index = group.endIndex + 1;
}
// `row` index is larger than the last row, return -1.
return -1;
}
override getFirstItemInRow(row: number) {
if (row < 0) {
return 0;
}
const index = this.getItemIndex(row, 0);
return index === -1 ? this.dataModel!.length : index;
}
override scrollIndexIntoView(index: number) {
const dataModel = this.dataModel;
if (!dataModel || index < 0 || index >= dataModel.length) {
return;
}
const itemHeight = this.getItemHeightByIndex_(index);
const scrollTop = this.scrollTop;
const top = this.getItemTop(index);
const clientHeight = this.clientHeight;
const computedStyle = window.getComputedStyle(this);
const paddingY = parseInt(computedStyle.paddingTop, 10) +
parseInt(computedStyle.paddingBottom, 10);
const availableHeight = clientHeight - paddingY;
const self = this;
// Function to adjust the tops of viewport and row.
const scrollToAdjustTop = () => {
self.scrollTop = top;
};
// Function to adjust the bottoms of viewport and row.
const scrollToAdjustBottom = () => {
self.scrollTop = top + itemHeight - availableHeight;
};
// Check if the entire of given indexed row can be shown in the viewport.
if (itemHeight <= availableHeight) {
if (top < scrollTop) {
scrollToAdjustTop();
} else if (scrollTop + availableHeight < top + itemHeight) {
scrollToAdjustBottom();
}
} else {
if (scrollTop < top) {
scrollToAdjustTop();
} else if (top + itemHeight < scrollTop + availableHeight) {
scrollToAdjustBottom();
}
}
}
override getItemsInViewPort(scrollTop: number, clientHeight: number) {
// Render 1 more row above to make the scrolling more smooth.
const beginRow = this.getRowForListOffset_(scrollTop) - 1;
// Render 1 more rows below, +2 here because "endIndex" is the first item
// of the row, in order to render the whole +1 row, we need to make sure
// the "endIndex" is the first item of +2 row.
const endRow = this.getRowForListOffset_(scrollTop + clientHeight - 1) + 2;
const beginIndex = Math.max(0, this.getFirstItemInRow(beginRow));
const endIndex =
Math.min(this.getFirstItemInRow(endRow), this.dataModel!.length);
const result = {
// beginIndex + 1 here because "first" will be -1 when it's being
// consumed in redraw() method in the parent class.
first: beginIndex + 1,
length: endIndex - beginIndex - 1,
last: endIndex - 1,
};
return result;
}
override getAfterFillerHeight(lastIndex: number) {
assert(this.dataModel);
const fileListModel = this.dataModel;
const groupBySnapshot = fileListModel.getGroupBySnapshot();
// Excluding the current index, because [firstIndex, lastIndex) is used
// in mergeItems().
const index = lastIndex - 1;
let afterFillerHeight = 0;
let totalItemCount = 0;
let shouldAdd = false;
// Find the group of "index" and accumulate the height after that group.
for (let groupIndex = 0; groupIndex < groupBySnapshot.length;
groupIndex++) {
const group = groupBySnapshot[groupIndex]!;
const groupItemCount = group.endIndex - group.startIndex + 1;
const groupRowCount = Math.ceil(groupItemCount / this.columns);
if (shouldAdd) {
afterFillerHeight += this.getGroupHeadingHeight_(groupIndex) +
groupRowCount * this.getGroupItemHeight_(group.group);
} else if (index <= group.endIndex) {
// index falls into the current group. Starting from this group we need
// to add all remaining group heights into the final result.
const indexInCurGroup = Math.max(0, index - totalItemCount);
// For current group, we need to add the row heights starting from the
// row which current index locates.
afterFillerHeight +=
(groupRowCount - Math.floor(indexInCurGroup / this.columns)) *
this.getGroupItemHeight_(group.group);
shouldAdd = true;
}
totalItemCount += groupItemCount;
}
return afterFillerHeight;
}
/**
* Returns the height of folder items in grid view.
* @return The height of folder items.
*/
private getFolderItemHeight_(): number {
// Align with CSS value for .thumbnail-item.directory: height + margin +
// border.
const height = 48;
return height + this.getItemMarginTop_() + 2;
}
/**
* Returns the height of file items in grid view.
* @return The height of file items.
*/
private getFileItemHeight_() {
// Align with CSS value for .thumbnail-item: height + margin + border.
return 160 + this.getItemMarginTop_() + 2;
}
/**
* Returns the height of group heading.
*/
private getGroupHeadingHeight_(groupIndex: number): number {
assert(this.dataModel);
const fileListModel = this.dataModel;
// We have an additional margin for non-first group, check
// the CSS rule ".grid-title ~ .grid-title" for more information in the CSS
// file.
const groupMarginTop = groupIndex > 0 ? GROUP_MARGIN_TOP : 0;
switch (fileListModel.groupByField) {
case GROUP_BY_FIELD_DIRECTORY:
return DIRECTORY_GROUP_HEADING_HEIGHT + groupMarginTop;
case GROUP_BY_FIELD_MODIFICATION_TIME:
return MODIFICATION_TIME_GROUP_HEADING_HEIGHT + groupMarginTop;
default:
return 0;
}
}
/**
* Returns the height of the item in the group based on the group value.
*/
private getGroupItemHeight_(groupValue?: GroupValue): number {
assert(this.dataModel);
const fileListModel = this.dataModel;
switch (fileListModel.groupByField) {
case GROUP_BY_FIELD_DIRECTORY:
return groupValue === true ? this.getFolderItemHeight_() :
this.getFileItemHeight_();
case GROUP_BY_FIELD_MODIFICATION_TIME:
return this.getFileItemHeight_();
default:
return this.getFileItemHeight_();
}
}
/**
* Returns the height of the item specified by the index.
*/
protected override getItemHeightByIndex_(index: number): number {
assert(this.dataModel);
const fileListModel = this.dataModel;
if (fileListModel.groupByField === GROUP_BY_FIELD_MODIFICATION_TIME) {
return this.getFileItemHeight_();
}
const groupBySnapshot = fileListModel.getGroupBySnapshot();
for (const group of groupBySnapshot) {
if (index <= group.endIndex) {
// The index falls into the current group, return group item height
// by its group value.
return this.getGroupItemHeight_(group.group);
}
}
return this.getFileItemHeight_();
}
/**
* Returns the width of grid items.
*/
private getItemWidth_(): number {
// Align with CSS value for .thumbnail-item: width + margin + border.
const width = 160;
return width + this.getItemMarginLeft_() + 2;
}
/**
* Returns the margin top of grid items.
*/
private getItemMarginTop_(): number {
// Align with CSS value for .thumbnail-item: margin-top.
return 16;
}
/**
* Returns the margin left of grid items.
*/
private getItemMarginLeft_(): number {
// Align with CSS value for .thumbnail-item: margin-inline-start.
return 16;
}
/**
* Returns index of a row which contains the given y-position(offset).
* @param offset The offset from the top of grid.
* @return Row index corresponding to the given offset.
*/
private getRowForListOffset_(offset: number): number {
assert(this.dataModel);
const fileListModel = this.dataModel;
const innerOffset = Math.max(0, offset - this.paddingTop_);
const groupBySnapshot = fileListModel.getGroupBySnapshot();
// Loop through all the groups, calculate the accumulated height for all
// items (item height + group heading height), until the total height
// reaches "offset", then we know how many items can be included in this
// offset.
let currentHeight = 0;
let curRow = 0;
for (let groupIndex = 0; groupIndex < groupBySnapshot.length;
groupIndex++) {
const group = groupBySnapshot[groupIndex]!;
const groupItemCount = group.endIndex - group.startIndex + 1;
const groupRowCount = Math.ceil(groupItemCount / this.columns);
const groupHeight = this.getGroupHeadingHeight_(groupIndex) +
groupRowCount * this.getGroupItemHeight_(group.group);
if (currentHeight + groupHeight > innerOffset) {
// Current offset falls into the current group. Calculates how many
// rows in the offset within the group.
const offsetInCurGroup = Math.max(
0,
innerOffset - currentHeight -
this.getGroupHeadingHeight_(groupIndex));
return curRow +
Math.floor(
offsetInCurGroup / this.getGroupItemHeight_(group.group));
}
currentHeight += groupHeight;
curRow += groupRowCount;
}
return this.getItemRow(fileListModel.length - 1);
}
override createSelectionController(sm: ListSelectionModel):
GridSelectionController {
assert(sm);
return new FileGridSelectionController(sm, this);
}
private updateGroupHeading_() {
const fileListModel = this.dataModel;
if (fileListModel &&
fileListModel.groupByField === GROUP_BY_FIELD_MODIFICATION_TIME) {
// TODO(crbug.com/1353650): find a way to update heading instead of
// redraw.
this.redraw();
}
}
/**
* Updates items to reflect metadata changes.
* @param _type Type of metadata changed.
* @param entries Entries whose metadata changed.
*/
updateListItemsMetadata(_type: string, entries: Array<Entry|FilesAppEntry>) {
const urls = entriesToURLs(entries);
const boxes =
Array.from(this.querySelectorAll<HTMLElement>('.img-container'));
assert(this.metadataModel_);
assert(this.volumeManager_);
for (const box of boxes) {
const listItem = this.getListItemAncestor(box)!;
const entry = listItem && this.dataModel!.item(listItem.listIndex);
if (!entry || urls.indexOf(entry.toURL()) === -1) {
continue;
}
this.decorateThumbnailBox_(listItem, entry);
this.updateSharedStatus_(listItem, entry);
const metadata = this.metadataModel_!.getCache(
[entry],
[
'availableOffline',
'pinned',
'canPin',
'syncStatus',
'progress',
'syncCompletedTime',
])[0] ||
{} as MetadataItem;
updateInlineStatus(listItem, metadata);
listItem.toggleAttribute(
'disabled',
isDlpBlocked(entry, this.metadataModel_, this.volumeManager_));
}
this.updateGroupHeading_();
}
/**
* Redraws the UI. Skips multiple consecutive calls.
*/
relayout() {
this.relayoutRateLimiter_!.run();
}
/**
* Redraws the UI immediately.
*/
private relayoutImmediately_() {
this.startBatchUpdates();
this.columns = 0;
this.redraw();
this.endBatchUpdates();
dispatchSimpleEvent(this, 'relayout');
}
/**
* Decorates thumbnail.
* @param entry Entry to render a thumbnail for.
*/
private decorateThumbnail_(li: ListItem, entry: Entry) {
li.className = 'thumbnail-item';
assert(this.metadataModel_);
assert(this.volumeManager_);
if (entry) {
decorateListItem(li, entry, this.metadataModel_, this.volumeManager_);
}
const frame = li.ownerDocument.createElement('div');
frame.className = 'thumbnail-frame';
li.appendChild(frame);
const box = li.ownerDocument.createElement('div');
box.classList.add('img-container', 'no-thumbnail');
frame.appendChild(box);
const bottom = li.ownerDocument.createElement('div');
bottom.className = 'thumbnail-bottom';
const metadata = this.metadataModel_.getCache(
[entry],
[
'contentMimeType',
'availableOffline',
'pinned',
'canPin',
'syncStatus',
'progress',
'syncCompletedTime',
])[0] ||
{} as MetadataItem;
const locationInfo = this.volumeManager_.getLocationInfo(entry);
const detailIcon = renderFileTypeIcon(
li.ownerDocument, entry, locationInfo, metadata.contentMimeType);
const checkmark = li.ownerDocument.createElement('div');
checkmark.className = 'detail-checkmark';
detailIcon.appendChild(checkmark);
bottom.appendChild(detailIcon);
bottom.appendChild(renderIconBadge(li.ownerDocument));
bottom.appendChild(
renderFileNameLabel(li.ownerDocument, entry, locationInfo));
frame.appendChild(bottom);
li.setAttribute('file-name', getEntryLabel(locationInfo, entry));
if (locationInfo && locationInfo.isDriveBased) {
const inlineStatus = li.ownerDocument.createElement('xf-inline-status');
inlineStatus.classList.add('tast-inline-status');
frame.appendChild(inlineStatus);
}
if (entry) {
this.decorateThumbnailBox_(assertInstanceof(li, HTMLLIElement), entry);
}
this.updateSharedStatus_(li, entry);
updateInlineStatus(li, metadata);
}
/**
* Decorates the box containing a centered thumbnail image.
*
* @param li List item which contains the box to be decorated.
* @param entry Entry which thumbnail is generating for.
*/
private decorateThumbnailBox_(li: HTMLLIElement, entry: Entry|FilesAppEntry) {
const box =
assertInstanceof(li.querySelector('.img-container'), HTMLDivElement);
if (entry.isDirectory) {
this.setGenericThumbnail_(box, entry);
return;
}
// Set thumbnail if it's already in cache, and the thumbnail data is not
// empty.
const thumbnailData =
this.listThumbnailLoader_?.getThumbnailFromCache(entry);
assert(this.metadataModel_);
const mimeType =
this.metadataModel_.getCache(
[entry],
['contentMimeType'])[0]!.contentMimeType;
if (thumbnailData && thumbnailData.dataUrl) {
FileGrid.setThumbnailImage_(
box, entry, thumbnailData.dataUrl, (thumbnailData.width || 0),
(thumbnailData.height || 0), mimeType);
li.classList.toggle('thumbnail-loaded', true);
} else {
this.setGenericThumbnail_(box, entry, mimeType);
li.classList.toggle('thumbnail-loaded', false);
}
}
/**
* Added 'shared' class to icon and placeholder of a folder item.
* @param li The grid item.
* @param entry File entry for the grid item.
*/
private updateSharedStatus_(li: ListItem, entry: Entry|FilesAppEntry) {
if (!entry.isDirectory) {
return;
}
const shared =
!!this.metadataModel_!.getCache([entry], ['shared'])[0]!.shared;
const box = li.querySelector('.img-container');
if (box) {
box.classList.toggle('shared', shared);
}
const icon = li.querySelector('.detail-icon');
if (icon) {
icon.classList.toggle('shared', shared);
}
}
/**
* Handles the splice event of the data model to change the view based on
* whether image files is dominant or not in the directory.
*/
private onSplice_() {
// When adjusting search parameters, |dataModel| is transiently empty.
// Updating whether image-dominant is active at these times can cause
// spurious changes. Avoid this problem by not updating whether
// image-dominant is active when |dataModel| is empty.
assert(this.dataModel);
const fileListModel = this.dataModel;
if (fileListModel.getFileCount() === 0 &&
fileListModel.getFolderCount() === 0) {
return;
}
}
private onSorted_() {
assert(this.dataModel);
const fileListModel = this.dataModel;
const hasGroupHeadingAfterSort = fileListModel.shouldShowGroupHeading();
// Sort doesn't trigger redraw sometimes, e.g. if we sort by Name for now,
// then we sort by time, if the list order doesn't change, no permuted event
// is triggered, thus no redraw is triggered. In this scenario, we need to
// manually trigger a redraw to remove/add the group heading.
if (hasGroupHeadingAfterSort !== fileListModel.hasGroupHeadingBeforeSort) {
this.redraw();
}
}
/**
* Sets thumbnail image to the box.
* @param box A div element to hold thumbnails.
* @param entry An entry of the thumbnail.
* @param dataUrl Data url of thumbnail.
* @param width Width of thumbnail.
* @param height Height of thumbnail.
* @param mimeType Optional mime type for the image.
*/
private static setThumbnailImage_(
box: HTMLDivElement, entry: Entry|FilesAppEntry, dataUrl: string,
width: number, height: number, mimeType?: string) {
const thumbnail = box.ownerDocument.createElement('div');
thumbnail.classList.add('thumbnail');
box.classList.toggle('no-thumbnail', false);
// If the image is JPEG or the thumbnail is larger than the grid size,
// resize it to cover the thumbnail box.
const type = getType(entry, mimeType);
if ((type.type === 'image' && type.subtype === 'JPEG') ||
width > gridSize() || height > gridSize()) {
thumbnail.style.backgroundSize = 'cover';
}
thumbnail.style.backgroundImage = 'url(' + dataUrl + ')';
const oldThumbnails = Array.from(box.querySelectorAll('.thumbnail'));
for (const oldThumbnail of oldThumbnails) {
box.removeChild(oldThumbnail);
}
box.appendChild(thumbnail);
}
/**
* Clears thumbnail image from the box.
* @param box A div element to hold thumbnails.
*/
private static clearThumbnailImage_(box: HTMLDivElement) {
const oldThumbnails = Array.from(box.querySelectorAll('.thumbnail'));
for (const oldThumbnail of oldThumbnails) {
box.removeChild(oldThumbnail);
}
box.classList.toggle('no-thumbnail', true);
}
/**
* Sets a generic thumbnail on the box.
* @param box A div element to hold thumbnails.
* @param entry An entry of the thumbnail.
* @param mimeType Optional mime type for the file.
*/
private setGenericThumbnail_(
box: HTMLDivElement, entry: Entry|FilesAppEntry, mimeType?: string) {
if (isEncrypted(entry, mimeType)) {
box.setAttribute('generic-thumbnail', 'encrypted');
box.setAttribute('aria-label', str('ENCRYPTED_ICON_TOOLTIP'));
document.querySelector<FilesTooltip>('files-tooltip')!.addTarget(box);
} else {
box.classList.toggle('no-thumbnail', true);
const locationInfo = this.volumeManager_!.getLocationInfo(entry);
const rootType = locationInfo && locationInfo.rootType || undefined;
const icon = getIcon(entry, mimeType, rootType);
box.setAttribute('generic-thumbnail', icon);
}
}
/**
* Returns whether the drag event is inside a entry in the list (and not the
* background padding area).
* @param event Drag start event.
* @return True if the mouse is over an element in the list, False if it is in
* the background.
*/
hasDragHitElement(event: MouseEvent): boolean {
const pos = DragSelector.getScrolledPosition(this, event);
if (!pos) {
return false;
}
return this.getHitElements(pos.x, pos.y).length !== 0;
}
/**
* Obtains if the drag selection should be start or not by referring the mouse
* event.
* @param event Drag start event.
* @return True if the mouse is hit to the background of the list.
*/
shouldStartDragSelection(event: MouseEvent): boolean {
// Start dragging area if the drag starts outside of the contents of the
// grid.
return !this.hasDragHitElement(event);
}
/**
* Returns the index of row corresponding to the given y position.
*
* If `isStart` is true, this returns index of the first row in which
* bottom of grid items is greater than or equal to y. Otherwise, this returns
* index of the last row in which top of grid items is less than or equal to
* y.
*/
private getHitRowIndex_(y: number, isStart: boolean): number {
assert(this.dataModel);
const fileListModel = this.dataModel;
const groupBySnapshot = fileListModel.getGroupBySnapshot();
let currentHeight = 0;
let curRow = 0;
const shift = isStart ? 0 : -this.getItemMarginTop_();
const yAfterShift = y + shift;
for (let groupIndex = 0; groupIndex < groupBySnapshot.length;
groupIndex++) {
const group = groupBySnapshot[groupIndex]!;
const groupItemCount = group.endIndex - group.startIndex + 1;
const groupRowCount = Math.ceil(groupItemCount / this.columns);
const groupHeight = this.getGroupHeadingHeight_(groupIndex) +
groupRowCount * this.getGroupItemHeight_(group.group);
if (yAfterShift < currentHeight + groupHeight) {
// The y falls into the current group.
const yInCurGroup = yAfterShift - currentHeight -
this.getGroupHeadingHeight_(groupIndex);
if (yInCurGroup < 0) {
// The remaining y in this group can't cover the current group
// heading height.
return isStart ? curRow : curRow - 1;
}
return Math.min(
curRow + groupRowCount - 1,
curRow +
Math.floor(
yInCurGroup / this.getGroupItemHeight_(group.group)));
}
currentHeight += groupHeight;
curRow += groupRowCount;
}
return curRow;
}
/**
* Returns the index of column corresponding to the given x position.
*
* If `isStart` is true, this returns index of the first column in which
* left of grid items is greater than or equal to x. Otherwise, this returns
* index of the last column in which right of grid items is less than or equal
* to x.
*/
private getHitColumnIndex_(x: number, isStart: boolean): number {
const itemWidth = this.getItemWidth_();
const shift = isStart ? 0 : -this.getItemMarginLeft_();
return Math.floor((x + shift) / itemWidth);
}
/**
* Obtains the index list of elements that are hit by the point or the
* rectangle.
*
* We should match its argument interface with FileList.getHitElements.
*
* @param x X coordinate value.
* @param y Y coordinate value.
* @param width Width of the coordinate.
* @param height Height of the coordinate.
* @return Indexes of the hit elements.
*/
override getHitElements(
x: number, y: number, width?: number, height?: number): number[] {
const currentSelection = [];
const startXWithPadding =
isRTL() ? this.clientWidth - (x + (width ?? 0)) : x;
const startX = Math.max(0, startXWithPadding - this.paddingStart_);
const endX = startX + (width ? width - 1 : 0);
const top = Math.max(0, y - this.paddingTop_);
const bottom = top + (height ? height - 1 : 0);
const firstRow = this.getHitRowIndex_(top, /* isStart= */ true);
const lastRow = this.getHitRowIndex_(bottom, /* isStart= */ false);
const firstColumn = this.getHitColumnIndex_(startX, /* isStart= */ true);
const lastColumn = this.getHitColumnIndex_(endX, /* isStart= */ false);
for (let row = firstRow; row <= lastRow; row++) {
for (let col = firstColumn; col <= lastColumn; col++) {
const index = this.getItemIndex(row, col);
if (0 <= index && index < this.dataModel!.length) {
currentSelection.push(index);
}
}
}
return currentSelection;
}
}
/**
* Grid size, in "px".
*/
function gridSize(): number {
return 160;
}
class FileGridItem extends ListItem {
/**
* Label of the item.
*/
override get label(): string {
return this.querySelector('filename-label')?.textContent ?? '';
}
override set label(_newLabel: string) {
// no-op setter. List calls this setter but Files app doesn't need it.
}
override initialize() {
super.initialize();
// Override the default role 'listitem' to 'option' to match the parent's
// role (listbox).
this.setAttribute('role', 'option');
const nameId = this.id + '-entry-name';
this.querySelector('.entry-name')!.setAttribute('id', nameId);
this.querySelector('.img-container')!.setAttribute(
'aria-labelledby', nameId);
this.setAttribute('aria-labelledby', nameId);
}
}
/**
* Selection controller for the file grid.
*/
export class FileGridSelectionController extends GridSelectionController {
private readonly tapHandler_ = new FileTapHandler();
/**
* @param selectionModel The selection model to interact with.
* @param grid The grid to interact with.
*/
constructor(selectionModel: ListSelectionModel, grid: FileGrid) {
super(selectionModel, grid);
}
override handlePointerDownUp(e: PointerEvent, index: number) {
handlePointerDownUp.call(this, e, index);
}
override handleTouchEvents(e: TouchEvent, index: number) {
assert(e);
if (this.tapHandler_.handleTouchEvents(e, index, handleTap.bind(this))) {
focusParentList(e);
}
}
override handleKeyDown(e: KeyboardEvent) {
handleKeyDown.call(this, e);
}
get filesView(): FileGrid {
return this.grid_ as FileGrid;
}
override getIndexBelow(index: number): number {
if (this.isAccessibilityEnabled()) {
return this.getIndexAfter(index);
}
if (index === this.getLastIndex()) {
return -1;
}
const grid = this.filesView;
const row = grid.getItemRow(index);
const col = grid.getItemColumn(index);
const nextIndex = grid.getItemIndex(row + 1, col);
if (nextIndex === -1) {
// The row (index `row + 1`) doesn't exist or doesn't have the enough
// columns to get the column (index `col`), and `row + 1` must be the
// last row of the group. We just need to return the last index of that
// group.
assert(grid.dataModel);
const groupBySnapshot = grid.dataModel.getGroupBySnapshot();
let curRow = 0;
for (const group of groupBySnapshot) {
const groupItemCount = group.endIndex - group.startIndex + 1;
const groupRowCount = Math.ceil(groupItemCount / grid.columns);
if (row + 1 < curRow + groupRowCount) {
// The row falls into the current group. Return the last index in the
// current group.
return group.endIndex;
}
curRow += groupRowCount;
}
return grid.dataModel!.length - 1;
}
return nextIndex;
}
override getIndexAbove(index: number) {
if (this.isAccessibilityEnabled()) {
return this.getIndexBefore(index);
}
if (index === 0) {
return -1;
}
const grid = this.filesView;
const row = grid.getItemRow(index);
// First row, no items above, just return the first index.
if (row - 1 < 0) {
return 0;
}
const col = grid.getItemColumn(index);
const nextIndex = grid.getItemIndex(row - 1, col);
if (nextIndex === -1) {
// The row (index `row - 1`) doesn't have the enough columns to get the
// column (index `col`), we need to find the last index on "row - 1".
return grid.getFirstItemInRow(row) - 1;
}
return nextIndex;
}
}