[ENG-489] FileThumb has incorrect dimensions for some thumbnails (#731)

* WIP Fix thumb incorrect size

* Fix sidebar thumb incorrect size
 - Fix ext pill incorrect positioning on first load

* Improve `Thumb` overall style behavior
- Fix incorrect position of the video extension pill
- Simplify `MediaView`'s `Thumb` usage by internalizing the `mediaAspectSquare` style
- Change `QuickPreview` to use `cover` and size 0 for `Thumb`

* Remove unused import

* Allow modifing the internal img className

* lint

* Apply PR feedback
- Remove superfluous true value in component property
- Rename ObjectKinds to ObjectKindKey

* Fix video ext pill not being properly positioned in MediaView(non square thumbnail) in prod build

* Fix video ext pill incorrect position (now for sure)
 - Replace img size logic with ResizeObserver
 - Add error handling when thumbnails fails to load

* Shorten variable names

* Fix IndexerRuleEditor isKeyOf

---------

Co-authored-by: Jamie Pine <32987599+jamiepine@users.noreply.github.com>
This commit is contained in:
Vítor Vasconcellos 2023-04-22 06:43:08 +00:00 committed by GitHub
parent fa5671f614
commit b49d215145
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 192 additions and 127 deletions

View file

@ -1,111 +1,184 @@
import * as icons from '@sd/assets/icons';
import clsx from 'clsx';
import { CSSProperties, useEffect, useState } from 'react';
import { ExplorerItem, useLibraryContext } from '@sd/client';
import { memo, useLayoutEffect, useRef, useState } from 'react';
import { ExplorerItem, isKeyOf, useLibraryContext } from '@sd/client';
import { useExplorerStore } from '~/hooks/useExplorerStore';
import { useIsDark, usePlatform } from '~/util/Platform';
import { getExplorerItemData } from '../util';
import classes from './Thumb.module.scss';
interface Props {
data: ExplorerItem;
size: number;
loadOriginal?: boolean;
className?: string;
forceShowExtension?: boolean;
extensionClassName?: string;
export const getIcon = (
isDir: boolean,
isDark: boolean,
kind: string,
extension?: string | null
) => {
if (isDir) return icons[isDark ? 'Folder' : 'Folder_Light'];
let document: Extract<keyof typeof icons, 'Document' | 'Document_Light'> = 'Document';
if (extension) extension = `${kind}_${extension.toLowerCase()}`;
if (!isDark) {
kind = kind + '_Light';
document = 'Document_Light';
if (extension) extension = extension + '_Light';
}
return icons[
extension && isKeyOf(icons, extension) ? extension : isKeyOf(icons, kind) ? kind : document
];
};
interface VideoThumbSize {
width: number;
height: number;
}
export default function Thumb(props: Props) {
const { cas_id, isDir, kind, hasThumbnail, extension } = getExplorerItemData(props.data);
const store = useExplorerStore();
const platform = usePlatform();
const { library } = useLibraryContext();
export interface ThumbProps {
data: ExplorerItem;
size: null | number;
cover?: boolean;
className?: string;
loadOriginal?: boolean;
}
function Thumb({ size, cover, ...props }: ThumbProps) {
const isDark = useIsDark();
const platform = usePlatform();
const thumbImg = useRef<HTMLImageElement>(null);
const [thumbSize, setThumbSize] = useState<null | VideoThumbSize>(null);
const { library } = useLibraryContext();
const [thumbLoaded, setThumbLoaded] = useState<boolean>(false);
const { locationId } = useExplorerStore();
const { cas_id, isDir, kind, hasThumbnail, extension } = getExplorerItemData(props.data);
const [fullPreviewUrl, setFullPreviewUrl] = useState<string | null>(null);
// Allows disabling thumbnails when they fail to load
const [useThumb, setUseThumb] = useState<boolean>(hasThumbnail);
useEffect(() => {
if (props.loadOriginal && hasThumbnail) {
const url = platform.getFileUrl(library.uuid, store.locationId!, props.data.item.id);
if (url) setFullPreviewUrl(url);
}
}, [
props.data.item.id,
hasThumbnail,
library.uuid,
props.loadOriginal,
platform,
store.locationId
]);
useLayoutEffect(() => {
const img = thumbImg.current;
if (cover || kind !== 'Video' || !img || !thumbLoaded) return;
const videoBarsHeight = Math.floor(props.size / 10);
const videoHeight = Math.floor((props.size * 9) / 16) + videoBarsHeight * 2;
const resizeObserver = new ResizeObserver(() => {
const { width, height } = img;
setThumbSize(width && height ? { width, height } : null);
});
const imgStyle: CSSProperties =
kind === 'Video'
? {
borderTopWidth: videoBarsHeight,
borderBottomWidth: videoBarsHeight,
width: props.size,
height: videoHeight
}
: {};
resizeObserver.observe(img);
return () => resizeObserver.disconnect();
}, [kind, cover, thumbImg, thumbLoaded]);
let icon = icons['Document'];
if (isDir) {
icon = icons['Folder'];
} else if (
kind &&
extension &&
icons[`${kind}_${extension.toLowerCase()}` as keyof typeof icons]
) {
icon = icons[`${kind}_${extension.toLowerCase()}` as keyof typeof icons];
} else if (kind !== 'Unknown' && kind && icons[kind as keyof typeof icons]) {
icon = icons[kind as keyof typeof icons];
}
if (!hasThumbnail || !cas_id) {
if (!isDark) {
icon = icon?.substring(0, icon.length - 4) + '_Light' + '.png';
}
return <img src={icon} className={clsx('h-full overflow-hidden')} />;
// Only Videos and Images can show the original file
const loadOriginal = (kind === 'Video' || kind === 'Image') && props.loadOriginal;
const src = useThumb
? loadOriginal && locationId
? platform.getFileUrl(library.uuid, locationId, props.data.item.id)
: cas_id && platform.getThumbnailUrlById(cas_id)
: null;
let style = {};
if (size && kind === 'Video') {
const videoBarsHeight = Math.floor(size / 10);
style = {
borderTopWidth: videoBarsHeight,
borderBottomWidth: videoBarsHeight
};
}
const childClassName = 'max-h-full max-w-full object-contain';
return (
<div
style={size ? { maxWidth: size, width: size - 10, height: size } : {}}
className={clsx(
'relative flex h-full shrink-0 items-center justify-center border-2 border-transparent',
'relative flex shrink-0 items-center justify-center',
src &&
kind !== 'Video' && [classes.checkers, size && 'border-2 border-transparent'],
size || ['h-full', cover ? 'w-full overflow-hidden' : 'w-[90%]'],
props.className
)}
>
<img
style={{ ...imgStyle, maxWidth: props.size, width: props.size - 10 }}
decoding="async"
className={clsx(
'z-90 pointer-events-none',
hasThumbnail &&
'max-h-full w-auto max-w-full rounded-sm object-cover shadow shadow-black/30',
kind === 'Image' && classes.checkers,
kind === 'Image' && props.size > 60 && 'border-2 border-app-line',
kind === 'Video' && 'rounded border-x-0 !border-black',
props.className
)}
src={fullPreviewUrl || platform.getThumbnailUrlById(cas_id)}
/>
{extension &&
kind === 'Video' &&
hasThumbnail &&
(props.size > 80 || props.forceShowExtension) && (
<div
{src ? (
kind === 'Video' && loadOriginal ? (
<video
src={src}
onCanPlay={(e) => {
const video = e.target as HTMLVideoElement;
// Why not use the element's attribute? Because React...
// https://github.com/facebook/react/issues/10389
video.loop = true;
video.muted = true;
}}
style={style}
autoPlay
className={clsx(
'absolute bottom-[13%] right-[5%] rounded bg-black/60 py-0.5 px-1 text-[9px] font-semibold uppercase opacity-70',
props.extensionClassName
childClassName,
size && 'rounded border-x-0 border-black',
props.className
)}
>
{extension}
</div>
)}
playsInline
/>
) : (
<>
<img
src={src}
ref={thumbImg}
style={style}
onLoad={() => {
setUseThumb(true);
setThumbLoaded(true);
}}
onError={() => {
setUseThumb(false);
setThumbSize(null);
setThumbLoaded(false);
}}
decoding="async"
className={clsx(
cover
? 'min-h-full min-w-full object-cover object-center'
: childClassName,
'shadow shadow-black/30',
kind === 'Video' ? 'rounded' : 'rounded-sm',
size &&
(kind === 'Video'
? 'border-x-0 border-black'
: size > 60 && 'border-2 border-app-line'),
props.className
)}
/>
{kind === 'Video' && (!size || size > 80) && (
<div
style={
cover
? {}
: thumbSize
? {
marginTop: Math.floor(thumbSize.height / 2) - 2,
marginLeft: Math.floor(thumbSize.width / 2) - 2
}
: { display: 'none' }
}
className={clsx(
cover
? 'right-1 bottom-1'
: 'left-1/2 top-1/2 -translate-x-full -translate-y-full',
'absolute rounded',
'bg-black/60 py-0.5 px-1 text-[9px] font-semibold uppercase opacity-70'
)}
>
{extension}
</div>
)}
</>
)
) : (
<img
src={getIcon(isDir, isDark, kind, extension)}
decoding="async"
className={clsx(childClassName, props.className)}
/>
)}
</div>
);
}
export default memo(Thumb);

View file

@ -33,7 +33,7 @@ const GridViewItem = memo(({ data, selected, index, ...props }: GridViewItemProp
height: explorerStore.gridItemSize
}}
className={clsx(
'mb-1 rounded-lg border-2 border-transparent text-center active:translate-y-[1px]',
'mb-1 flex items-center justify-center justify-items-center rounded-lg border-2 border-transparent text-center active:translate-y-[1px]',
{
'bg-app-selected/20': selected
}

View file

@ -79,7 +79,7 @@ export const Inspector = ({ data, context, ...elementProps }: Props) => {
{explorerStore.layoutMode !== 'media' && (
<div
className={clsx(
'mb-[10px] flex h-52 w-full items-center justify-center overflow-hidden'
'mb-[10px] flex h-[240] w-full items-center justify-center overflow-hidden'
)}
>
<FileThumb loadOriginal size={240} data={data} />

View file

@ -35,20 +35,10 @@ const MediaViewItem = memo(({ data, index }: MediaViewItemProps) => {
)}
>
<Thumb
data={data}
size={0}
className={clsx(
'!max-w-none !rounded-none !border-0',
explorerStore.mediaAspectSquare
? '!h-full !w-full'
: '!h-auto max-h-full !w-[90%]'
)}
forceShowExtension
extensionClassName={clsx(
explorerStore.mediaAspectSquare
? '!bottom-2 !right-2'
: '!bottom-[8%] !right-[8%]'
)}
data={data}
cover={explorerStore.mediaAspectSquare}
className="!rounded-none"
/>
<Button

View file

@ -53,7 +53,7 @@ const pdfViewerEnabled = () => {
function FilePreview({ explorerItem, kind, src, onError }: FilePreviewProps) {
const className = clsx('relative inset-y-2/4 max-h-full max-w-full translate-y-[-50%]');
const fileThumb = <FileThumb size={1} data={explorerItem} className={className} />;
const fileThumb = <FileThumb size={0} data={explorerItem} cover className={className} />;
switch (kind) {
case 'PDF':
return <object data={src} type="application/pdf" className="h-full w-full border-0" />;

View file

@ -1,4 +1,4 @@
import { ExplorerItem, ObjectKind, isObject, isPath } from '@sd/client';
import { ExplorerItem, ObjectKind, ObjectKindKey, isObject, isPath } from '@sd/client';
export function getExplorerItemData(data: ExplorerItem) {
const objectData = getItemObject(data);
@ -7,7 +7,7 @@ export function getExplorerItemData(data: ExplorerItem) {
return {
cas_id: filePath?.cas_id || null,
isDir: isPath(data) && data.item.is_dir,
kind: ObjectKind[objectData?.kind || 0] || null,
kind: (ObjectKind[objectData?.kind ?? 0] as ObjectKindKey) || null,
hasThumbnail: data.has_thumbnail,
extension: filePath?.extension || null
};

View file

@ -445,7 +445,7 @@ export function IndexerRuleEditor<T extends IndexerRuleIdFieldType>({
<Tabs.Root
value={currentTab}
onValueChange={(tab) =>
isKeyOf(tab, RuleTabsInput) &&
isKeyOf(RuleTabsInput, tab) &&
setCurrentTab(tab)
}
>

View file

@ -20,7 +20,7 @@ export function arraysEqual<T>(a: T[], b: T[]) {
return a.every((n, i) => b[i] === n);
}
export function isKeyOf<T extends object>(key: PropertyKey, obj: T): key is keyof T {
export function isKeyOf<T extends object>(obj: T, key: PropertyKey): key is keyof T {
return key in obj;
}

View file

@ -1,26 +1,28 @@
// An array of Object kinds.
// Note: The order of this enum should never change, and always be kept in sync with `crates/file_ext/src/kind.rs`
export const ObjectKind = [
'Unknown',
'Document',
'Folder',
'Text',
'Package',
'Image',
'Audio',
'Video',
'Archive',
'Executable',
'Alias',
'Encrypted',
'Key',
'Link',
'WebPageArchive',
'Widget',
'Album',
'Collection',
'Font',
'Mesh',
'Code',
'Database'
];
export enum ObjectKind {
Unknown,
Document,
Folder,
Text,
Package,
Image,
Audio,
Video,
Archive,
Executable,
Alias,
Encrypted,
Key,
Link,
WebPageArchive,
Widget,
Album,
Collection,
Font,
Mesh,
Code,
Database
}
export type ObjectKindKey = keyof typeof ObjectKind;