Skip to content

Commit

Permalink
create thumbnail view for file list
Browse files Browse the repository at this point in the history
  • Loading branch information
aswallace committed Apr 15, 2024
1 parent 00ee7c7 commit 63ea29a
Show file tree
Hide file tree
Showing 5 changed files with 363 additions and 16 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@
}

.font-size-button {
background-color: darkgrey;
background-color: lightgrey;
border-radius: 0;
font-size: 10px;
font-weight: normal;
Expand Down
69 changes: 66 additions & 3 deletions packages/core/components/FileDetails/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ActionButton, IButtonStyles } from "@fluentui/react";
import { ActionButton, IButtonStyles, Icon } from "@fluentui/react";
import classNames from "classnames";
import * as React from "react";
import { useDispatch, useSelector } from "react-redux";
Expand All @@ -15,6 +15,7 @@ import { ROOT_ELEMENT_ID } from "../../App";
import { selection } from "../../state";
import SvgIcon from "../../components/SvgIcon";
import { NO_IMAGE_ICON_PATH_DATA } from "../../icons";
import { RENDERABLE_IMAGE_FORMATS, THUMBNAIL_SIZE_TO_NUM_COLUMNS } from "../../constants";

import styles from "./FileDetails.module.css";

Expand Down Expand Up @@ -126,7 +127,11 @@ export default function FileDetails(props: FileDetails) {
const globalDispatch = useDispatch();
const [windowState, windowDispatch] = React.useReducer(windowStateReducer, INITIAL_STATE);
const [fileDetails, isLoading] = useFileDetails();
const fileGridColumnCount = useSelector(selection.selectors.getFileGridColumnCount);
const shouldDisplaySmallFont = useSelector(selection.selectors.getShouldDisplaySmallFont);
const shouldDisplayThumbnailView = useSelector(
selection.selectors.getShouldDisplayThumbnailView
);

// If FileDetails pane is minimized, set its width to the width of the WindowActionButtons. Else, let it be
// defined by whatever the CSS determines (setting an inline style to undefined will prompt ReactDOM to not apply
Expand Down Expand Up @@ -164,8 +169,7 @@ export default function FileDetails(props: FileDetails) {
</div>
);
} else if (fileDetails) {
const renderableImageFormats = [".jpg", ".jpeg", ".png", ".gif"];
const isFileRenderableImage = renderableImageFormats.some((format) =>
const isFileRenderableImage = RENDERABLE_IMAGE_FORMATS.some((format) =>
fileDetails?.name.toLowerCase().endsWith(format)
);
if (isFileRenderableImage) {
Expand Down Expand Up @@ -226,6 +230,65 @@ export default function FileDetails(props: FileDetails) {
[styles.hidden]: windowState.state === WindowState.MINIMIZED,
})}
/>
<ActionButton
className={classNames(styles.fontSizeButton, {
[styles.disabled]:
shouldDisplayThumbnailView &&
fileGridColumnCount === THUMBNAIL_SIZE_TO_NUM_COLUMNS.LARGE,
})}
disabled={
shouldDisplayThumbnailView &&
fileGridColumnCount === THUMBNAIL_SIZE_TO_NUM_COLUMNS.LARGE
}
onClick={() => {
globalDispatch(selection.actions.setFileThumbnailView(true));
globalDispatch(
selection.actions.setFileGridColumnCount(
THUMBNAIL_SIZE_TO_NUM_COLUMNS.LARGE
)
);
}}
title="Large thumbnail view"
>
<Icon iconName="GridViewMedium"></Icon>
</ActionButton>

<ActionButton
className={classNames(styles.fontSizeButton, {
[styles.disabled]:
shouldDisplayThumbnailView &&
fileGridColumnCount === THUMBNAIL_SIZE_TO_NUM_COLUMNS.SMALL,
})}
disabled={
shouldDisplayThumbnailView &&
fileGridColumnCount === THUMBNAIL_SIZE_TO_NUM_COLUMNS.SMALL
}
onClick={() => {
globalDispatch(selection.actions.setFileThumbnailView(true));
globalDispatch(
selection.actions.setFileGridColumnCount(
THUMBNAIL_SIZE_TO_NUM_COLUMNS.SMALL
)
);
}}
title="Small thumbnail view"
>
<Icon iconName="GridViewSmall"></Icon>
</ActionButton>
<ActionButton
className={classNames(styles.fontSizeButton, {
[styles.disabled]: !shouldDisplayThumbnailView,
})}
disabled={!shouldDisplayThumbnailView}
onClick={() =>
globalDispatch(
selection.actions.setFileThumbnailView(!shouldDisplayThumbnailView)
)
}
title="List view"
>
<Icon iconName="BulletedList"></Icon>
</ActionButton>
<div className={styles.fontSizeButtonContainer}>
<ActionButton
className={classNames(styles.fontSizeButton, {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
.small-font {
font-size: var(--smaller-font-size);
}

.file-label {
overflow-wrap: anywhere;
text-align: center;
}

.thumbnail-wrapper {
padding: 10px
}

.no-thumbnail {
fill: var(--grey);
}

.selected {
background-color: #d4e3fc;
}

.focused {
margin: 0;
border: 2px solid #669bf4;
}
173 changes: 173 additions & 0 deletions packages/core/components/FileList/LazilyRenderedThumbnail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import classNames from "classnames";
import * as React from "react";
import { useSelector } from "react-redux";

import FileSet from "../../entity/FileSet";
import FileThumbnail from "../../components/FileThumbnail";
import SvgIcon from "../../components/SvgIcon";
import { selection } from "../../state";
import { OnSelect } from "./useFileSelector";
import { RENDERABLE_IMAGE_FORMATS, THUMBNAIL_SIZE_TO_NUM_COLUMNS } from "../../constants";
import { NO_IMAGE_ICON_PATH_DATA } from "../../icons";

import styles from "./LazilyRenderedThumbnail.module.css";

/**
* Contextual data passed to LazilyRenderedThumbnails by react-window. Basically a light-weight React context.
* The same data is passed to each LazilyRenderedThumbnail within the same FileGrid.
* Follows the pattern set by LazilyRenderedRow
*/
export interface LazilyRenderedThumbnailContext {
fileSet: FileSet;
itemCount: number;
measuredWidth: number;
onContextMenu: (evt: React.MouseEvent) => void;
onSelect: OnSelect;
}

interface LazilyRenderedThumbnailProps {
columnIndex: number; // injected by react-window
data: LazilyRenderedThumbnailContext; // injected by react-window
rowIndex: number; // injected by react-window
style: React.CSSProperties; // injected by react-window
}

const MARGIN = 20; // px;

/**
* A single file in the listing of available files FMS.
* Follows the pattern set by LazilyRenderedRow
*/
export default function LazilyRenderedThumbnail(props: LazilyRenderedThumbnailProps) {
const {
data: { fileSet, itemCount, measuredWidth, onContextMenu, onSelect },
columnIndex,
rowIndex,
style,
} = props;

const shouldDisplaySmallFont = useSelector(selection.selectors.getShouldDisplaySmallFont);
const fileSelection = useSelector(selection.selectors.getFileSelection);
const fileGridColCount = useSelector(selection.selectors.getFileGridColumnCount);
const overallIndex = fileGridColCount * rowIndex + columnIndex;
const file = fileSet.getFileByIndex(overallIndex);
const thumbnailSize = measuredWidth / fileGridColCount - 2 * MARGIN;

const isSelected = React.useMemo(() => {
return fileSelection.isSelected(fileSet, overallIndex);
}, [fileSelection, fileSet, overallIndex]);

const isFocused = React.useMemo(() => {
return fileSelection.isFocused(fileSet, overallIndex);
}, [fileSelection, fileSet, overallIndex]);

const onClick = (evt: React.MouseEvent) => {
evt.preventDefault();
evt.stopPropagation();

if (onSelect && file !== undefined) {
onSelect(
{ index: overallIndex, id: file.file_id },
{
// Details on different OS keybindings
// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent#Properties
ctrlKeyIsPressed: evt.ctrlKey || evt.metaKey,
shiftKeyIsPressed: evt.shiftKey,
}
);
}
};

// Display the start of the file name and at least part of the file type
const clipFileName = (filename: string) => {
if (fileGridColCount === THUMBNAIL_SIZE_TO_NUM_COLUMNS.SMALL && filename.length > 15) {
return filename.slice(0, 6) + "..." + filename.slice(-4);
} else if (filename.length > 20) {
return filename.slice(0, 9) + "..." + filename.slice(-8);
}
return filename;
};

// If the file has a thumbnail image specified, we want to display the specified thumbnail.
// Otherwise, we want to display the file itself as the thumbnail if possible.
// If there is no thumbnail and the file cannot be displayed as the thumbnail, show a no image icon
// TODO: Add custom icons per file type
let thumbnail = (
<SvgIcon
height={thumbnailSize}
pathData={NO_IMAGE_ICON_PATH_DATA}
viewBox="0,1,22,22"
width={thumbnailSize}
className={classNames(styles.noThumbnail)}
/>
);
if (file?.thumbnail) {
// thumbnail exists
thumbnail = (
<div
className={classNames(styles.thumbnail)}
style={{ height: thumbnailSize, maxWidth: thumbnailSize }}
>
<FileThumbnail
uri={`http://aics.corp.alleninstitute.org/labkey/fmsfiles/image${file.thumbnail}`}
/>
</div>
);
} else if (file) {
const isFileRenderableImage = RENDERABLE_IMAGE_FORMATS.some((format) =>
file?.file_name.toLowerCase().endsWith(format)
);
if (isFileRenderableImage) {
// render the image as the thumbnail
thumbnail = (
<div
className={classNames(styles.fileThumbnailContainer, styles.thumbnail)}
style={{ height: thumbnailSize, maxWidth: thumbnailSize }}
>
<FileThumbnail
uri={`http://aics.corp.alleninstitute.org/labkey/fmsfiles/image${file.file_path}`}
/>
</div>
);
}
}

let content;
if (file) {
const filenameForRender = clipFileName(file?.file_name);
content = (
<div
onClick={onClick}
className={classNames({
[styles.selected]: isSelected,
[styles.focused]: isFocused,
})}
title={file?.file_name}
>
{thumbnail}
<div
className={classNames(styles.fileLabel, {
[styles.smallFont]:
shouldDisplaySmallFont ||
fileGridColCount === THUMBNAIL_SIZE_TO_NUM_COLUMNS.SMALL,
})}
>
{filenameForRender}
</div>
</div>
);
} else if (overallIndex < itemCount) {
// Grid will attempt to render a cell even if we're past the total index
content = "Loading...";
} // No `else` since if past total index we stil want empty content to fill up the outer grid

return (
<div
className={classNames(styles.thumbnailWrapper)}
style={style}
onContextMenu={onContextMenu}
>
{content}
</div>
);
}
Loading

0 comments on commit 63ea29a

Please sign in to comment.