diff --git a/packages/core/components/FileDetails/FileDetails.module.css b/packages/core/components/FileDetails/FileDetails.module.css index 85c245e8c..f891a330e 100644 --- a/packages/core/components/FileDetails/FileDetails.module.css +++ b/packages/core/components/FileDetails/FileDetails.module.css @@ -114,7 +114,7 @@ } .font-size-button { - background-color: darkgrey; + background-color: lightgrey; border-radius: 0; font-size: 10px; font-weight: normal; diff --git a/packages/core/components/FileDetails/index.tsx b/packages/core/components/FileDetails/index.tsx index e4d0ac0bb..aa355c09c 100644 --- a/packages/core/components/FileDetails/index.tsx +++ b/packages/core/components/FileDetails/index.tsx @@ -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"; @@ -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"; @@ -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 @@ -164,8 +169,7 @@ export default function FileDetails(props: FileDetails) { ); } 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) { @@ -226,6 +230,64 @@ export default function FileDetails(props: FileDetails) { [styles.hidden]: windowState.state === WindowState.MINIMIZED, })} /> + { + globalDispatch(selection.actions.setFileThumbnailView(true)); + globalDispatch( + selection.actions.setFileGridColumnCount( + THUMBNAIL_SIZE_TO_NUM_COLUMNS.LARGE + ) + ); + }} + title="Large thumbnail view" + > + + + { + globalDispatch(selection.actions.setFileThumbnailView(true)); + globalDispatch( + selection.actions.setFileGridColumnCount( + THUMBNAIL_SIZE_TO_NUM_COLUMNS.SMALL + ) + ); + }} + title="Small thumbnail view" + > + + + + globalDispatch( + selection.actions.setFileThumbnailView(!shouldDisplayThumbnailView) + ) + } + title="List view" + > + +
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 = ( + + ); + if (file?.thumbnail) { + // thumbnail exists + thumbnail = ( +
+ +
+ ); + } 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 = ( +
+ +
+ ); + } + } + + let content; + if (file) { + const filenameForRender = clipFileName(file?.file_name); + content = ( +
+ {thumbnail} +
+ {filenameForRender} +
+
+ ); + } 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 ( +
+ {content} +
+ ); +} diff --git a/packages/core/components/FileList/index.tsx b/packages/core/components/FileList/index.tsx index 6bb07d8b5..b82b31cc4 100644 --- a/packages/core/components/FileList/index.tsx +++ b/packages/core/components/FileList/index.tsx @@ -3,12 +3,13 @@ import debouncePromise from "debounce-promise"; import { defaults, isFunction } from "lodash"; import * as React from "react"; import { useSelector } from "react-redux"; -import { FixedSizeList } from "react-window"; +import { FixedSizeGrid, FixedSizeList } from "react-window"; import InfiniteLoader from "react-window-infinite-loader"; import FileSet from "../../entity/FileSet"; import Header from "./Header"; import LazilyRenderedRow from "./LazilyRenderedRow"; +import LazilyRenderedThumbnail from "./LazilyRenderedThumbnail"; import { selection } from "../../state"; import useLayoutMeasurements from "../../hooks/useLayoutMeasurements"; import useFileSelector from "./useFileSelector"; @@ -36,6 +37,8 @@ const DEFAULTS = { }; const MAX_NON_ROOT_HEIGHT = 300; +const SMALL_ROW_HEIGHT = 18; +const TALL_ROW_HEIGHT = 22; /** * Wrapper for react-window-infinite-loader and react-window that knows how to lazily fetch its own data. It will lay @@ -45,8 +48,17 @@ export default function FileList(props: FileListProps) { const [totalCount, setTotalCount] = React.useState(null); const fileSelection = useSelector(selection.selectors.getFileSelection); const isDisplayingSmallFont = useSelector(selection.selectors.getShouldDisplaySmallFont); + const shouldDisplayThumbnailView = useSelector( + selection.selectors.getShouldDisplayThumbnailView + ); + const fileGridColumnCount = useSelector(selection.selectors.getFileGridColumnCount); + const [measuredNodeRef, measuredHeight, measuredWidth] = useLayoutMeasurements< + HTMLDivElement + >(); + let defaultRowHeight = isDisplayingSmallFont ? SMALL_ROW_HEIGHT : TALL_ROW_HEIGHT; + if (shouldDisplayThumbnailView) defaultRowHeight = measuredWidth / fileGridColumnCount; const { className, fileSet, isRoot, rowHeight, sortOrder } = defaults({}, props, DEFAULTS, { - rowHeight: isDisplayingSmallFont ? 18 : 22, + rowHeight: defaultRowHeight, }); const onSelect = useFileSelector(fileSet, sortOrder); @@ -58,24 +70,29 @@ export default function FileList(props: FileListProps) { // 100% of the height of its container. // Otherwise, the height of the list should reflect the number of items it has to render, up to // a certain maximum. - const [measuredNodeRef, measuredHeight, measuredWidth] = useLayoutMeasurements< - HTMLDivElement - >(); const dataDrivenHeight = rowHeight * (totalCount || DEFAULT_TOTAL_COUNT) + 3 * rowHeight; // adding three additional rowHeights leaves room for the header + horz. scroll bar const calculatedHeight = Math.min(MAX_NON_ROOT_HEIGHT, dataDrivenHeight); const height = isRoot ? measuredHeight : calculatedHeight; const listRef = React.useRef(null); + const gridRef = React.useRef(null); const outerRef = React.useRef(null); // This hook is responsible for ensuring that if the details pane is currently showing a file row // within this FileList the file row shown in the details pane is scrolled into view. React.useEffect(() => { - if (listRef.current && outerRef.current && fileSelection.isFocused(fileSet)) { + if ( + (listRef.current || gridRef.current) && + outerRef.current && + fileSelection.isFocused(fileSet) + ) { const { indexWithinFileSet } = fileSelection.getFocusedItemIndices(); if (indexWithinFileSet !== undefined) { const listScrollTop = outerRef.current.scrollTop; - const focusedItemTop = indexWithinFileSet * rowHeight; + let focusedItemTop = indexWithinFileSet * rowHeight; + if (gridRef.current) { + focusedItemTop = (indexWithinFileSet / fileGridColumnCount) * rowHeight; + } const focusedItemBottom = focusedItemTop + rowHeight; const headerHeight = 40; // px; defined in Header.module.css; stickily sits on top of the list const visibleArea = height - headerHeight; @@ -88,11 +105,17 @@ export default function FileList(props: FileListProps) { const centeredWithinVisibleArea = Math.floor( centerOfFocusedItem - visibleArea / 2 ); - listRef.current.scrollTo(Math.max(0, centeredWithinVisibleArea)); + if (listRef.current) { + listRef.current.scrollTo(Math.max(0, centeredWithinVisibleArea)); + } else if (gridRef.current) { + gridRef.current.scrollTo({ + scrollTop: Math.max(0, centeredWithinVisibleArea), + }); + } } } } - }, [fileSelection, fileSet, height, rowHeight]); + }, [fileSelection, fileSet, height, fileGridColumnCount, rowHeight]); // Get a count of all files in the FileList, but don't wait on it React.useEffect(() => { @@ -126,7 +149,7 @@ export default function FileList(props: FileListProps) { itemCount={totalCount || DEFAULT_TOTAL_COUNT} > {({ onItemsRendered, ref: innerRef }) => { - const callbackRef = (instance: FixedSizeList | null) => { + const callbackRefList = (instance: FixedSizeList | null) => { listRef.current = instance; // react-window-infinite-loader takes a reference to the List component instance: @@ -135,8 +158,69 @@ export default function FileList(props: FileListProps) { innerRef(instance); } }; + const callbackRefGrid = (instance: FixedSizeGrid | null) => { + gridRef.current = instance; + if (isFunction(innerRef)) { + innerRef(instance); + } + }; + // Custom onItemsRendered for grids + // The built-in onItemsRendered from InfiniteLoader only supports lists + const onGridItemsRendered = (gridData: any) => { + const { + visibleRowStartIndex, + visibleRowStopIndex, + visibleColumnStopIndex, + overscanRowStartIndex, + overscanRowStopIndex, + overscanColumnStopIndex, + } = gridData; + + // Convert injected grid props to InfiniteLoader list props + const visibleStartIndex = + visibleRowStartIndex * (visibleColumnStopIndex + 1); + const visibleStopIndex = + visibleRowStopIndex * (visibleColumnStopIndex + 1); + const overscanStartIndex = + overscanRowStartIndex * (overscanColumnStopIndex + 1); + const overscanStopIndex = + overscanRowStopIndex * (overscanColumnStopIndex + 1); - return ( + onItemsRendered({ + // call onItemsRendered from InfiniteLoader + visibleStartIndex, + visibleStopIndex, + overscanStartIndex, + overscanStopIndex, + }); + }; + + const fixedSizeGrid = ( + + {LazilyRenderedThumbnail} + + ); + + const fixedSizeList = ( {LazilyRenderedRow} ); + + return shouldDisplayThumbnailView ? fixedSizeGrid : fixedSizeList; }} ); diff --git a/packages/core/components/FileList/test/LazilyRenderedThumbnail.test.tsx b/packages/core/components/FileList/test/LazilyRenderedThumbnail.test.tsx new file mode 100644 index 000000000..e65917d40 --- /dev/null +++ b/packages/core/components/FileList/test/LazilyRenderedThumbnail.test.tsx @@ -0,0 +1,202 @@ +import { configureMockStore, mergeState } from "@aics/redux-utils"; +import { render } from "@testing-library/react"; +import { expect } from "chai"; +import * as React from "react"; +import { Provider } from "react-redux"; +import * as sinon from "sinon"; + +import LazilyRenderedThumbnail from "../LazilyRenderedThumbnail"; +import { initialState } from "../../../state"; +import FileSet from "../../../entity/FileSet"; + +describe("", () => { + function makeItemData() { + const fileSet = new FileSet(); + sinon.stub(fileSet, "getFileByIndex").callsFake((index) => { + if (index === 0) { + return { + annotations: [], + file_id: "abc1230", + file_name: "my_image0.czi", + file_path: "some/path/to/my_image0.czi", + file_size: 1, + thumbnail: "some/path/to/my_image0.jpg", + uploaded: new Date().toISOString(), + }; + } + if (index === 9) { + return { + annotations: [], + file_id: "abc1239", + file_name: "my_image9.jpg", + file_path: "some/path/to/my_image9.jpg", + file_size: 1, + thumbnail: "", + uploaded: new Date().toISOString(), + }; + } + if (index === 25) { + return { + annotations: [], + file_id: "abc12325", + file_name: "my_image25.czi", + file_path: "some/path/to/my_image25.czi", + file_size: 1, + thumbnail: "", + uploaded: new Date().toISOString(), + }; + } + }); + + return { + fileSet, + measuredWidth: 600, + itemCount: 100, + onContextMenu: sinon.spy(), + onSelect: sinon.spy(), + }; + } + + it("renders thumbnail when file has one specified", () => { + // Arrange + const state = mergeState(initialState, {}); + const { store } = configureMockStore({ state }); + + // Act + const { getByText, getByRole } = render( + + + + ); + + // Assert + // Also checking for proper row/col indexing + const thumbnail = getByRole("img"); + expect(thumbnail.getAttribute("src")).to.include("some/path/to/my_image0.jpg"); + expect(getByText("my_image0.czi")).to.not.equal(null); + }); + + it("renders file as thumbnail if file is renderable type", () => { + // Arrange + const { store } = configureMockStore({ state: initialState }); + + // Act + const { getByText, getByRole } = render( + + + + ); + + // Assert + // Also confirms proper row/col indexing + const thumbnail = getByRole("img"); + expect(thumbnail.getAttribute("src")).to.include("some/path/to/my_image9.jpg"); + expect(getByText("my_image9.jpg")).to.not.equal(null); + }); + + it("renders svg as thumbnail if file has no renderable thumbnail", () => { + // Arrange + const { store } = configureMockStore({ state: initialState }); + + // Act + const { getByText, queryByRole } = render( + + + + ); + + // Assert + // Also confirms proper row/col indexing + expect(".no-thumbnail").to.exist; + expect(".svg").to.exist; + expect(queryByRole("img")).not.to.exist; + expect(getByText("my_image25.czi")).to.not.equal(null); + }); + + it("renders a loading indicator when data is not available", () => { + // Arrange + const { store } = configureMockStore({ state: initialState }); + + // Act + const { queryByText } = render( + + + + ); + + // Assert + expect(queryByText("my_image")).to.equal(null); + expect(queryByText("Loading...")).to.not.equal(null); + }); + + // We want to be able to render empty cells past the total item count in order to fill the grid + it("renders an empty cell if the index is past the total item count", () => { + // Arrange + const { store } = configureMockStore({ state: initialState }); + + // Act + const { queryByText } = render( + + + + ); + + // Assert + expect(queryByText("my_image")).to.equal(null); + expect(queryByText("Loading...")).to.equal(null); + }); + + it("renders and indexes correctly with different number of columns", () => { + // Arrange + const state = { + ...initialState, + selection: { + ...initialState.selection, + fileGridColumnCount: 10, + }, + }; + const { store } = configureMockStore({ state }); + + // Act + const { getByText } = render( + + + + ); + + // Assert + expect(".no-thumbnail").to.exist; + expect(".svg").to.exist; + expect(getByText("my_image25.czi")).to.not.equal(null); + }); +}); diff --git a/packages/core/constants/index.ts b/packages/core/constants/index.ts index 40fb3bdaf..49929ea83 100644 --- a/packages/core/constants/index.ts +++ b/packages/core/constants/index.ts @@ -153,3 +153,10 @@ export const SEARCHABLE_TOP_LEVEL_FILE_ANNOTATIONS = TOP_LEVEL_FILE_ANNOTATIONS. ); export const TOP_LEVEL_FILE_ANNOTATION_NAMES = TOP_LEVEL_FILE_ANNOTATIONS.map((a) => a.name); + +export const THUMBNAIL_SIZE_TO_NUM_COLUMNS = { + LARGE: 5, + SMALL: 10, +}; + +export const RENDERABLE_IMAGE_FORMATS = [".jpg", ".jpeg", ".png", ".gif"]; diff --git a/packages/core/state/selection/actions.ts b/packages/core/state/selection/actions.ts index 24dbdef3c..1df59e240 100644 --- a/packages/core/state/selection/actions.ts +++ b/packages/core/state/selection/actions.ts @@ -546,3 +546,42 @@ export function adjustGlobalFontSize(shouldDisplaySmallFont: boolean): AdjustGlo type: ADJUST_GLOBAL_FONT_SIZE, }; } + +/** + * SET_FILE_VIEW_TYPE + * + * Intention to set the file view type to thumbnail or list + */ +export const SET_FILE_THUMBNAIL_VIEW = makeConstant(STATE_BRANCH_NAME, "set-file-thumbnail-view"); + +export interface SetFileThumbnailView { + payload: boolean; + type: string; +} + +export function setFileThumbnailView(shouldDisplayThumbnailView: boolean): SetFileThumbnailView { + return { + payload: shouldDisplayThumbnailView, + type: SET_FILE_THUMBNAIL_VIEW, + }; +} + +/** + * SET_FILE_GRID_COLUMN_COUNT + */ +export const SET_FILE_GRID_COLUMN_COUNT = makeConstant( + STATE_BRANCH_NAME, + "set-file-grid-column-count" +); + +export interface SetFileGridColumnCount { + payload: number; + type: string; +} + +export function setFileGridColumnCount(fileGridColumnCount: number): SetFileGridColumnCount { + return { + payload: fileGridColumnCount, + type: SET_FILE_GRID_COLUMN_COUNT, + }; +} diff --git a/packages/core/state/selection/reducer.ts b/packages/core/state/selection/reducer.ts index 7ff01b00e..4de8658d6 100644 --- a/packages/core/state/selection/reducer.ts +++ b/packages/core/state/selection/reducer.ts @@ -2,7 +2,7 @@ import { makeReducer } from "@aics/redux-utils"; import { castArray, difference, omit } from "lodash"; import interaction from "../interaction"; -import { AnnotationName, PAST_YEAR_FILTER } from "../../constants"; +import { AnnotationName, PAST_YEAR_FILTER, THUMBNAIL_SIZE_TO_NUM_COLUMNS } from "../../constants"; import Annotation from "../../entity/Annotation"; import FileFilter from "../../entity/FileFilter"; import FileFolder from "../../entity/FileFolder"; @@ -24,6 +24,8 @@ import { CHANGE_VIEW, SELECT_TUTORIAL, ADJUST_GLOBAL_FONT_SIZE, + SET_FILE_THUMBNAIL_VIEW, + SET_FILE_GRID_COLUMN_COUNT, } from "./actions"; import FileSort, { SortOrder } from "../../entity/FileSort"; import Tutorial from "../../entity/Tutorial"; @@ -38,10 +40,12 @@ export interface SelectionStateBranch { [index: string]: number; // columnName to widthPercent mapping }; displayAnnotations: Annotation[]; + fileGridColumnCount: number; fileSelection: FileSelection; filters: FileFilter[]; openFileFolders: FileFolder[]; shouldDisplaySmallFont: boolean; + shouldDisplayThumbnailView: boolean; sortColumn?: FileSort; tutorial?: Tutorial; } @@ -57,10 +61,12 @@ export const initialState = { [AnnotationName.FILE_SIZE]: 0.15, }, displayAnnotations: [], + fileGridColumnCount: THUMBNAIL_SIZE_TO_NUM_COLUMNS.LARGE, fileSelection: new FileSelection(), filters: [PAST_YEAR_FILTER], openFileFolders: [], shouldDisplaySmallFont: false, + shouldDisplayThumbnailView: false, sortColumn: new FileSort(AnnotationName.UPLOADED, SortOrder.DESC), }; @@ -74,6 +80,14 @@ export default makeReducer( ...state, shouldDisplaySmallFont: action.payload, }), + [SET_FILE_THUMBNAIL_VIEW]: (state, action) => ({ + ...state, + shouldDisplayThumbnailView: action.payload, + }), + [SET_FILE_GRID_COLUMN_COUNT]: (state, action) => ({ + ...state, + fileGridColumnCount: action.payload, + }), [SET_FILE_FILTERS]: (state, action) => ({ ...state, filters: action.payload, diff --git a/packages/core/state/selection/selectors.ts b/packages/core/state/selection/selectors.ts index 0cc560a85..e6b4ec88a 100644 --- a/packages/core/state/selection/selectors.ts +++ b/packages/core/state/selection/selectors.ts @@ -19,11 +19,14 @@ export const getAvailableAnnotationsForHierarchy = (state: State) => export const getAvailableAnnotationsForHierarchyLoading = (state: State) => state.selection.availableAnnotationsForHierarchyLoading; export const getColumnWidths = (state: State) => state.selection.columnWidths; +export const getFileGridColumnCount = (state: State) => state.selection.fileGridColumnCount; export const getFileFilters = (state: State) => state.selection.filters; export const getCollection = (state: State) => state.selection.collection; export const getFileSelection = (state: State) => state.selection.fileSelection; export const getOpenFileFolders = (state: State) => state.selection.openFileFolders; export const getShouldDisplaySmallFont = (state: State) => state.selection.shouldDisplaySmallFont; +export const getShouldDisplayThumbnailView = (state: State) => + state.selection.shouldDisplayThumbnailView; export const getSortColumn = (state: State) => state.selection.sortColumn; export const getTutorial = (state: State) => state.selection.tutorial;