Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/create thumbnail view #77

Merged
merged 4 commits into from
Apr 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made this change since I was having a hard time reading the buttons with the darker background

border-radius: 0;
font-size: 10px;
font-weight: normal;
Expand Down
68 changes: 65 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,64 @@ 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
Loading