From 63ea29a3a8dabd6eba303e82373959dcc672ecc2 Mon Sep 17 00:00:00 2001 From: Anya Wallace Date: Mon, 15 Apr 2024 11:54:14 -0700 Subject: [PATCH] create thumbnail view for file list --- .../FileDetails/FileDetails.module.css | 2 +- .../core/components/FileDetails/index.tsx | 69 ++++++- .../LazilyRenderedThumbnail.module.css | 25 +++ .../FileList/LazilyRenderedThumbnail.tsx | 173 ++++++++++++++++++ packages/core/components/FileList/index.tsx | 110 +++++++++-- 5 files changed, 363 insertions(+), 16 deletions(-) create mode 100644 packages/core/components/FileList/LazilyRenderedThumbnail.module.css create mode 100644 packages/core/components/FileList/LazilyRenderedThumbnail.tsx 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..278fcca90 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,65 @@ 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; }} );