mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-07-07 04:23:29 +00:00
[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:
parent
fa5671f614
commit
b49d215145
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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" />;
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -445,7 +445,7 @@ export function IndexerRuleEditor<T extends IndexerRuleIdFieldType>({
|
|||
<Tabs.Root
|
||||
value={currentTab}
|
||||
onValueChange={(tab) =>
|
||||
isKeyOf(tab, RuleTabsInput) &&
|
||||
isKeyOf(RuleTabsInput, tab) &&
|
||||
setCurrentTab(tab)
|
||||
}
|
||||
>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue