Define separate components for each file preview types.pull/1/head
@@ -0,0 +1,141 @@ | |||||
import * as React from 'react'; | |||||
import WaveSurfer from 'wavesurfer.js'; | |||||
import {AudioFile, getMimeTypeDescription} from '../../utils/blob'; | |||||
import {formatFileSize, formatNumeral, formatSecondsDuration} from '../../utils/numeral'; | |||||
export interface VideoFilePreviewProps { | |||||
file: AudioFile; | |||||
} | |||||
export const AudioFilePreview: React.FC<VideoFilePreviewProps> = ({ | |||||
file: f, | |||||
}) => { | |||||
const mediaContainerRef = React.useRef<HTMLDivElement>(null); | |||||
const mediaControllerRef = React.useRef<WaveSurfer>(null); | |||||
const [isPlaying, setIsPlaying] = React.useState(false); | |||||
React.useEffect(() => { | |||||
if (!mediaControllerRef.current) { | |||||
return; | |||||
} | |||||
if (isPlaying) { | |||||
void mediaControllerRef.current.play(); | |||||
return; | |||||
} | |||||
mediaControllerRef.current.pause(); | |||||
}, [isPlaying]); | |||||
React.useEffect(() => { | |||||
if (!mediaControllerRef.current) { | |||||
return; | |||||
} | |||||
if (!mediaContainerRef.current) { | |||||
return; | |||||
} | |||||
if (typeof f.metadata?.previewUrl !== 'string') { | |||||
return; | |||||
} | |||||
mediaContainerRef.current.innerHTML = ''; | |||||
const mediaControllerRefMutable = mediaControllerRef as React.MutableRefObject<WaveSurfer>; | |||||
mediaControllerRefMutable.current = WaveSurfer.create({ | |||||
container: mediaContainerRef.current, | |||||
url: f.metadata.previewUrl, | |||||
cursorWidth: 0, | |||||
height: mediaContainerRef.current.offsetHeight, | |||||
barWidth: 2, | |||||
barGap: 2, | |||||
barRadius: 1, | |||||
}); | |||||
mediaControllerRefMutable.current.on('finish', () => { | |||||
setIsPlaying(false); | |||||
mediaControllerRefMutable.current.seekTo(0); | |||||
}); | |||||
return () => { | |||||
if (!mediaControllerRefMutable.current) { | |||||
return; | |||||
} | |||||
mediaControllerRefMutable.current.destroy(); | |||||
} | |||||
}, [f, mediaContainerRef, mediaControllerRef]); | |||||
const playMedia = () => { | |||||
setIsPlaying((p) => !p); | |||||
}; | |||||
return ( | |||||
<div className="flex flex-col gap-4 w-full h-full relative"> | |||||
<div className="h-2/5 flex-shrink-0 cursor-pointer relative"> | |||||
<div | |||||
ref={mediaContainerRef} | |||||
className="relative h-full w-full" | |||||
/> | |||||
<div className="absolute bottom-0 left-0 z-[2]"> | |||||
<button | |||||
onClick={playMedia} | |||||
> | |||||
{isPlaying ? '⏸' : '▶'} | |||||
</button> | |||||
</div> | |||||
</div> | |||||
<dl className="h-3/5 flex-shrink-0 m-0 flex flex-col items-end" data-testid="infoBox"> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Name | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
title={f.name} | |||||
> | |||||
{f.name} | |||||
</dd> | |||||
</div> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Type | |||||
</dt> | |||||
<dd | |||||
title={f.type} | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
> | |||||
{getMimeTypeDescription(f.type)} | |||||
</dd> | |||||
</div> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Size | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
title={`${formatNumeral(f.size ?? 0)} bytes`} | |||||
> | |||||
{formatFileSize(f.size)} | |||||
</dd> | |||||
</div> | |||||
{ | |||||
typeof f.metadata?.duration === 'number' | |||||
&& ( | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Duration | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
title={`${formatNumeral(f.metadata.duration)} seconds`} | |||||
> | |||||
{formatSecondsDuration(f.metadata.duration)} | |||||
</dd> | |||||
</div> | |||||
) | |||||
} | |||||
</dl> | |||||
</div> | |||||
); | |||||
}; |
@@ -0,0 +1,80 @@ | |||||
import * as React from 'react'; | |||||
import WaveSurfer from 'wavesurfer.js'; | |||||
import {AudioFile, getMimeTypeDescription} from '../../utils/blob'; | |||||
import {formatFileSize, formatNumeral, formatSecondsDuration} from '../../utils/numeral'; | |||||
export interface VideoFilePreviewProps { | |||||
file: AudioFile; | |||||
} | |||||
export const AudioMiniFilePreview: React.FC<VideoFilePreviewProps> = ({ | |||||
file: f, | |||||
}) => { | |||||
const mediaContainerRef = React.useRef<HTMLDivElement>(null); | |||||
const mediaControllerRef = React.useRef<WaveSurfer>(null); | |||||
const [isPlaying, setIsPlaying] = React.useState(false); | |||||
React.useEffect(() => { | |||||
if (!mediaControllerRef.current) { | |||||
return; | |||||
} | |||||
if (isPlaying) { | |||||
void mediaControllerRef.current.play(); | |||||
return; | |||||
} | |||||
mediaControllerRef.current.pause(); | |||||
}, [isPlaying]); | |||||
React.useEffect(() => { | |||||
if (!mediaControllerRef.current) { | |||||
return; | |||||
} | |||||
if (!mediaContainerRef.current) { | |||||
return; | |||||
} | |||||
if (typeof f.metadata?.previewUrl !== 'string') { | |||||
return; | |||||
} | |||||
mediaContainerRef.current.innerHTML = ''; | |||||
const mediaControllerRefMutable = mediaControllerRef as React.MutableRefObject<WaveSurfer>; | |||||
mediaControllerRefMutable.current = WaveSurfer.create({ | |||||
container: mediaContainerRef.current, | |||||
url: f.metadata.previewUrl, | |||||
cursorWidth: 0, | |||||
height: mediaContainerRef.current.offsetHeight, | |||||
barWidth: 2, | |||||
barGap: 2, | |||||
barRadius: 1, | |||||
}); | |||||
mediaControllerRefMutable.current.on('finish', () => { | |||||
setIsPlaying(false); | |||||
mediaControllerRefMutable.current.seekTo(0); | |||||
}); | |||||
return () => { | |||||
if (!mediaControllerRefMutable.current) { | |||||
return; | |||||
} | |||||
mediaControllerRefMutable.current.destroy(); | |||||
} | |||||
}, [f, mediaContainerRef, mediaControllerRef]); | |||||
const playMedia = () => { | |||||
setIsPlaying((p) => !p); | |||||
}; | |||||
return ( | |||||
<div | |||||
className="absolute top-0 left-0 w-full h-full cursor-pointer" | |||||
ref={mediaContainerRef} | |||||
onClick={playMedia} | |||||
/> | |||||
); | |||||
}; |
@@ -0,0 +1,92 @@ | |||||
import * as React from 'react'; | |||||
import {BinaryFile, getMimeTypeDescription} from '../../utils/blob'; | |||||
import {formatFileSize, formatNumeral} from '../../utils/numeral'; | |||||
export interface BinaryFilePreviewProps { | |||||
file: BinaryFile; | |||||
} | |||||
export const BinaryFilePreview: React.FC<BinaryFilePreviewProps> = ({ | |||||
file: f, | |||||
}) => ( | |||||
<div className="flex gap-4 w-full h-full relative"> | |||||
<div className={`h-full w-1/3 flex-shrink-0`}> | |||||
{ | |||||
f.metadata && (f.metadata?.contents instanceof ArrayBuffer) | |||||
&& ( | |||||
<div | |||||
data-testid="preview" | |||||
role="presentation" | |||||
className="w-full h-full select-none overflow-hidden text-xs" | |||||
> | |||||
<pre className="overflow-visible"> | |||||
<code> | |||||
{ | |||||
(Array.from(new Uint8Array((f.metadata.contents as ArrayBuffer).slice(0, 256))) as number[]) | |||||
.reduce( | |||||
(byteArray: number[][], byte: number, i) => { | |||||
if (i % 16 === 0) { | |||||
return [ | |||||
...byteArray, | |||||
[byte], | |||||
] | |||||
} | |||||
const lastLine = byteArray.at(-1) as number[] | |||||
return [ | |||||
...(byteArray.slice(0, -1)), | |||||
[...lastLine, byte], | |||||
] | |||||
}, | |||||
[] as number[][], | |||||
) | |||||
.map((ba: number[]) => ba | |||||
.map((a) => a.toString(16).padStart(2, '0')) | |||||
.join(' ') | |||||
) | |||||
.join('\n') | |||||
} | |||||
</code> | |||||
</pre> | |||||
</div> | |||||
) | |||||
} | |||||
</div> | |||||
<dl className="w-2/3 flex-shrink-0 m-0" data-testid="infoBox"> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Name | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
title={f.name} | |||||
> | |||||
{f.name} | |||||
</dd> | |||||
</div> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Type | |||||
</dt> | |||||
<dd | |||||
title={f.type} | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
> | |||||
{getMimeTypeDescription(f.type, f.name)} | |||||
</dd> | |||||
</div> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Size | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
title={`${formatNumeral(f.size ?? 0)} bytes`} | |||||
> | |||||
{formatFileSize(f.size)} | |||||
</dd> | |||||
</div> | |||||
</dl> | |||||
</div> | |||||
); |
@@ -0,0 +1,80 @@ | |||||
import * as React from 'react'; | |||||
import {getMimeTypeDescription, ImageFile} from '../../utils/blob'; | |||||
import {formatFileSize, formatNumeral} from '../../utils/numeral'; | |||||
export interface ImageFilePreviewProps { | |||||
file: ImageFile; | |||||
} | |||||
export const ImageFilePreview: React.FC<ImageFilePreviewProps> = ({ | |||||
file: f, | |||||
}) => { | |||||
return ( | |||||
<div className="flex gap-4 w-full h-full relative"> | |||||
<div className={`h-full w-1/3 flex-shrink-0`}> | |||||
{ | |||||
typeof f.metadata?.previewUrl === 'string' | |||||
&& ( | |||||
<img | |||||
className="block w-full h-full object-center object-cover" | |||||
src={f.metadata.previewUrl} | |||||
alt={f.name} | |||||
data-testid="preview" | |||||
/> | |||||
) | |||||
} | |||||
</div> | |||||
<dl className="w-2/3 flex-shrink-0 m-0" data-testid="infoBox"> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Name | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
title={f.name} | |||||
> | |||||
{f.name} | |||||
</dd> | |||||
</div> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Type | |||||
</dt> | |||||
<dd | |||||
title={f.type} | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
> | |||||
{getMimeTypeDescription(f.type, f.name)} | |||||
</dd> | |||||
</div> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Size | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
title={`${formatNumeral(f.size ?? 0)} bytes`} | |||||
> | |||||
{formatFileSize(f.size)} | |||||
</dd> | |||||
</div> | |||||
{ | |||||
typeof f.metadata?.width === 'number' | |||||
&& typeof f.metadata?.height === 'number' | |||||
&& ( | |||||
<div> | |||||
<dt className="sr-only"> | |||||
Pixel Dimensions | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
> | |||||
{formatNumeral(f.metadata.width)}×{formatNumeral(f.metadata.height)} pixels | |||||
</dd> | |||||
</div> | |||||
) | |||||
} | |||||
</dl> | |||||
</div> | |||||
); | |||||
} |
@@ -0,0 +1,106 @@ | |||||
import * as React from 'react'; | |||||
import Prism from 'prismjs'; | |||||
import {formatFileSize, formatNumeral} from '../../utils/numeral'; | |||||
import {TextFile} from '../../utils/blob'; | |||||
export interface TextFilePreviewProps { | |||||
file: TextFile; | |||||
} | |||||
export const TextFilePreview: React.FC<TextFilePreviewProps> = ({ | |||||
file: f, | |||||
}) => ( | |||||
<div className="flex gap-4 w-full h-full relative"> | |||||
<div className={`h-full w-1/3 flex-shrink-0`}> | |||||
{ | |||||
typeof f.metadata?.contents === 'string' | |||||
&& ( | |||||
<div | |||||
data-testid="preview" | |||||
role="presentation" | |||||
className="w-full h-full select-none overflow-hidden text-xs" | |||||
> | |||||
<pre className="overflow-visible"> | |||||
{ | |||||
typeof f.metadata.scheme === 'string' | |||||
&& ( | |||||
<code | |||||
dangerouslySetInnerHTML={{ | |||||
__html: Prism.highlight( | |||||
f.metadata.contents, | |||||
Prism.languages[f.metadata.scheme], | |||||
f.metadata.scheme, | |||||
).split('\n').slice(0, 15).join('\n'), | |||||
}} | |||||
style={{ | |||||
tabSize: 2, | |||||
}} | |||||
/> | |||||
) | |||||
} | |||||
{ | |||||
typeof f.metadata.scheme !== 'string' | |||||
&& ( | |||||
<code> | |||||
{f.metadata.contents} | |||||
</code> | |||||
) | |||||
} | |||||
</pre> | |||||
</div> | |||||
) | |||||
} | |||||
</div> | |||||
<dl className="w-2/3 flex-shrink-0 m-0" data-testid="infoBox"> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Name | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
title={f.name} | |||||
> | |||||
{f.name} | |||||
</dd> | |||||
</div> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Type | |||||
</dt> | |||||
<dd | |||||
title={f.type} | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
> | |||||
{typeof f.metadata?.schemeTitle === 'string' ? `${f.metadata.schemeTitle} Source` : 'Text File'} | |||||
</dd> | |||||
</div> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Size | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
title={`${formatNumeral(f.size ?? 0)} bytes`} | |||||
> | |||||
{formatFileSize(f.size)} | |||||
</dd> | |||||
</div> | |||||
{ | |||||
typeof f.metadata?.language === 'string' | |||||
&& ( | |||||
<div> | |||||
<dt className="sr-only"> | |||||
Language | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
> | |||||
{f.metadata.language.slice(0, 1).toUpperCase()} | |||||
{f.metadata.language.slice(1)} | |||||
</dd> | |||||
</div> | |||||
) | |||||
} | |||||
</dl> | |||||
</div> | |||||
); |
@@ -0,0 +1,214 @@ | |||||
import * as React from 'react'; | |||||
import {getMimeTypeDescription, VideoFile} from '../../utils/blob'; | |||||
import {formatFileSize, formatNumeral} from '../../utils/numeral'; | |||||
export interface VideoFilePreviewProps { | |||||
file: VideoFile; | |||||
} | |||||
export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePreviewProps>(({ | |||||
file: f, | |||||
}, forwardedRef) => { | |||||
const defaultRef = React.useRef<HTMLVideoElement>(null); | |||||
const mediaControllerRef = forwardedRef ?? defaultRef; | |||||
const seekRef = React.useRef<HTMLInputElement>(null); | |||||
const volumeRef = React.useRef<HTMLInputElement>(null); | |||||
const [isPlaying, setIsPlaying] = React.useState(false); | |||||
React.useEffect(() => { | |||||
if (typeof mediaControllerRef !== 'object') { | |||||
return; | |||||
} | |||||
if (!mediaControllerRef.current) { | |||||
return; | |||||
} | |||||
if (isPlaying) { | |||||
void mediaControllerRef.current.play(); | |||||
return | |||||
} | |||||
mediaControllerRef.current.pause(); | |||||
}, [isPlaying, mediaControllerRef]); | |||||
React.useEffect(() => { | |||||
if (typeof mediaControllerRef !== 'object') { | |||||
return; | |||||
} | |||||
if (!mediaControllerRef.current) { | |||||
return; | |||||
} | |||||
if (!seekRef.current) { | |||||
return; | |||||
} | |||||
const { current: mediaController } = mediaControllerRef; | |||||
const { current: seek } = seekRef; | |||||
seek.max = String(mediaController.duration); | |||||
seek.value = String(mediaController.currentTime); | |||||
}, [f, mediaControllerRef]); | |||||
React.useEffect(() => { | |||||
if (!volumeRef.current) { | |||||
return; | |||||
} | |||||
if (typeof mediaControllerRef !== 'object') { | |||||
return; | |||||
} | |||||
if (!mediaControllerRef.current) { | |||||
return; | |||||
} | |||||
const { current: mediaController } = mediaControllerRef; | |||||
const { current: volume } = volumeRef; | |||||
volume.value = String(mediaController.volume); | |||||
}, [f, mediaControllerRef]); | |||||
const playMedia = () => { | |||||
setIsPlaying((p) => !p); | |||||
}; | |||||
const seekMedia = () => { | |||||
}; | |||||
const resetVideo: React.ReactEventHandler<HTMLVideoElement> = (e) => { | |||||
const videoElement = e.currentTarget; | |||||
setIsPlaying(false); | |||||
videoElement.currentTime = 0; | |||||
}; | |||||
const updateSeekFromPlayback: React.ReactEventHandler<HTMLVideoElement> = (e) => { | |||||
if (!seekRef.current) { | |||||
return; | |||||
} | |||||
const { current: seek } = seekRef; | |||||
const videoElement = e.currentTarget; | |||||
seek.value = String(videoElement.currentTime); | |||||
}; | |||||
const adjustVolume: React.ChangeEventHandler<HTMLInputElement> = (e) => { | |||||
if (typeof mediaControllerRef !== 'object') { | |||||
return; | |||||
} | |||||
if (!mediaControllerRef.current) { | |||||
return; | |||||
} | |||||
const { value } = e.currentTarget; | |||||
mediaControllerRef.current.volume = Number(value); | |||||
}; | |||||
return ( | |||||
<div className="flex gap-4 w-full h-full relative"> | |||||
<div className={`h-full w-1/3 flex-shrink-0`}> | |||||
{ | |||||
typeof f.metadata?.previewUrl === 'string' | |||||
&& ( | |||||
<div | |||||
className="w-full h-full bg-black flex flex-col items-stretch" | |||||
data-testid="preview" | |||||
> | |||||
<div className="w-full flex-auto relative"> | |||||
<video | |||||
className="absolute w-full h-full top-0 left-0 block w-full h-full object-center object-contain flex-auto" | |||||
ref={mediaControllerRef as React.RefObject<HTMLVideoElement>} | |||||
onEnded={resetVideo} | |||||
onTimeUpdate={updateSeekFromPlayback} | |||||
> | |||||
<source | |||||
src={f.metadata.previewUrl} | |||||
type={f.type} | |||||
/> | |||||
</video> | |||||
</div> | |||||
<div className="w-full flex-shrink-0 h-10 flex"> | |||||
<button | |||||
onClick={playMedia} | |||||
className="w-10 h-full" | |||||
> | |||||
{isPlaying ? '⏸' : '▶'} | |||||
</button> | |||||
<input | |||||
type="range" | |||||
className="flex-auto" | |||||
ref={seekRef} | |||||
onChange={seekMedia} | |||||
defaultValue="0" | |||||
/> | |||||
<input | |||||
type="range" | |||||
ref={volumeRef} | |||||
max={1} | |||||
min={0} | |||||
onChange={adjustVolume} | |||||
step="any" | |||||
defaultValue="1" | |||||
/> | |||||
</div> | |||||
</div> | |||||
) | |||||
} | |||||
</div> | |||||
<dl className="w-2/3 flex-shrink-0 m-0" data-testid="infoBox"> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Name | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
title={f.name} | |||||
> | |||||
{f.name} | |||||
</dd> | |||||
</div> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Type | |||||
</dt> | |||||
<dd | |||||
title={f.type} | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
> | |||||
{getMimeTypeDescription(f.type, f.name)} | |||||
</dd> | |||||
</div> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Size | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
title={`${formatNumeral(f.size ?? 0)} bytes`} | |||||
> | |||||
{formatFileSize(f.size)} | |||||
</dd> | |||||
</div> | |||||
{ | |||||
typeof f.metadata?.width === 'number' | |||||
&& typeof f.metadata?.height === 'number' | |||||
&& ( | |||||
<div> | |||||
<dt className="sr-only"> | |||||
Pixel Dimensions | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
> | |||||
{formatNumeral(f.metadata.width)}×{formatNumeral(f.metadata.height)} pixels | |||||
</dd> | |||||
</div> | |||||
) | |||||
} | |||||
</dl> | |||||
</div> | |||||
); | |||||
}); | |||||
VideoFilePreview.displayName = 'VideoFilePreview'; |
@@ -3,26 +3,21 @@ import * as React from 'react'; | |||||
//import * as BlobReact from '@tesseract-design/web-blob-react'; | //import * as BlobReact from '@tesseract-design/web-blob-react'; | ||||
import * as BlobBase from '@tesseract-design/web-base-blob'; | import * as BlobBase from '@tesseract-design/web-base-blob'; | ||||
import * as ButtonBase from '@tesseract-design/web-base-button'; | import * as ButtonBase from '@tesseract-design/web-base-button'; | ||||
import Prism from 'prismjs'; | |||||
import WaveSurfer from 'wavesurfer.js'; | |||||
import {formatFileSize, formatNumeral, formatSecondsDuration} from '../../../utils/numeral'; | |||||
import {formatFileSize} from '../../../utils/numeral'; | |||||
import { | import { | ||||
AugmentedFile, | |||||
augmentFile, | |||||
ContentType, | ContentType, | ||||
getContentType, | getContentType, | ||||
getMimeTypeDescription, | getMimeTypeDescription, | ||||
readAsArrayBuffer, | |||||
readAsDataURL, | |||||
readAsText, | |||||
} from '../../../utils/blob'; | } from '../../../utils/blob'; | ||||
import {getImageMetadata} from '../../../utils/image'; | |||||
import {getAudioMetadata} from '../../../utils/audio'; | |||||
import {getTextMetadata} from '../../../utils/text'; | |||||
import {delegateTriggerChangeEvent} from '../../../utils/event'; | import {delegateTriggerChangeEvent} from '../../../utils/event'; | ||||
interface FileWithPreview extends File { | |||||
metadata?: Record<string, string | number | ArrayBuffer>; | |||||
internal?: Record<string, unknown>; | |||||
} | |||||
import {TextFilePreview} from '../../../components/TextFilePreview'; | |||||
import {ImageFilePreview} from '../../../components/ImageFilePreview'; | |||||
import {AudioFilePreview} from '../../../components/AudioFilePreview'; | |||||
import {VideoFilePreview} from '../../../components/VideoFilePreview'; | |||||
import {BinaryFilePreview} from '../../../components/BinaryFilePreview'; | |||||
import {AudioMiniFilePreview} from '../../../components/AudioMiniFilePreview'; | |||||
export interface FileButtonProps extends Omit<React.HTMLProps<HTMLInputElement>, 'size' | 'type' | 'style' | 'label' | 'list'> { | export interface FileButtonProps extends Omit<React.HTMLProps<HTMLInputElement>, 'size' | 'type' | 'style' | 'label' | 'list'> { | ||||
/** | /** | ||||
@@ -48,106 +43,15 @@ export interface FileButtonProps extends Omit<React.HTMLProps<HTMLInputElement>, | |||||
hiddenLabel?: boolean, | hiddenLabel?: boolean, | ||||
} | } | ||||
const augmentTextFile = async (f: File) => { | |||||
const contents = await readAsText(f); | |||||
const metadata = getTextMetadata(contents, f.name); | |||||
return { | |||||
...f, | |||||
name: f.name, | |||||
type: f.type, | |||||
size: f.size, | |||||
lastModified: f.lastModified, | |||||
metadata: { | |||||
contents, | |||||
language: metadata.language, | |||||
languageProbability: metadata.languageProbability, | |||||
scheme: metadata.scheme, | |||||
schemeTitle: metadata.schemeTitle, | |||||
}, | |||||
}; | |||||
} | |||||
const augmentImageFile = async (f: File) => { | |||||
const previewUrl = await readAsDataURL(f); | |||||
const imageMetadata = await getImageMetadata(previewUrl); | |||||
return { | |||||
name: f.name, | |||||
type: f.type, | |||||
size: f.size, | |||||
lastModified: f.lastModified, | |||||
metadata: { | |||||
previewUrl, | |||||
width: imageMetadata.naturalWidth, | |||||
height: imageMetadata.naturalHeight, | |||||
}, | |||||
}; | |||||
}; | |||||
const augmentAudioFile = async (f: File) => { | |||||
const previewUrl = await readAsDataURL(f); | |||||
const audioExtensions = await getAudioMetadata(previewUrl); | |||||
return { | |||||
name: f.name, | |||||
type: f.type, | |||||
size: f.size, | |||||
lastModified: f.lastModified, | |||||
metadata: { | |||||
previewUrl, | |||||
duration: audioExtensions.duration, | |||||
}, | |||||
}; | |||||
}; | |||||
const augmentBinaryFile = async (f: File) => { | |||||
const arrayBuffer = await readAsArrayBuffer(f); | |||||
return { | |||||
name: f.name, | |||||
type: f.type, | |||||
size: f.size, | |||||
lastModified: f.lastModified, | |||||
metadata: { | |||||
contents: arrayBuffer, | |||||
}, | |||||
} | |||||
}; | |||||
const augmentVideoFile = async (f: File) => { | |||||
const previewUrl = await readAsDataURL(f); | |||||
return { | |||||
name: f.name, | |||||
type: f.type, | |||||
size: f.size, | |||||
lastModified: f.lastModified, | |||||
metadata: { | |||||
previewUrl, | |||||
}, | |||||
}; | |||||
} | |||||
const CONTENT_TYPE_AUGMENT_FUNCTIONS: Record<ContentType, Function> = { | |||||
[ContentType.TEXT]: augmentTextFile, | |||||
[ContentType.IMAGE]: augmentImageFile, | |||||
[ContentType.AUDIO]: augmentAudioFile, | |||||
[ContentType.VIDEO]: augmentVideoFile, | |||||
[ContentType.BINARY]: augmentBinaryFile, | |||||
}; | |||||
const useFilePreviews = (fileList?: FileList) => { | const useFilePreviews = (fileList?: FileList) => { | ||||
const [selectedFiles, setSelectedFiles] = React.useState([] as Partial<FileWithPreview>[]); | |||||
const [selectedFiles, setSelectedFiles] = React.useState([] as AugmentedFile[]); | |||||
React.useEffect(() => { | React.useEffect(() => { | ||||
const loadFilePreviews = async (fileList: FileList) => { | const loadFilePreviews = async (fileList: FileList) => { | ||||
const files = Array.from(fileList); | const files = Array.from(fileList); | ||||
return Promise.all( | return Promise.all( | ||||
files.map(async (f) => { | files.map(async (f) => { | ||||
const contentType = getContentType(f.type, f.name); | |||||
const { [contentType]: augmentFunction } = CONTENT_TYPE_AUGMENT_FUNCTIONS; | |||||
if (!augmentFunction) { | |||||
return f; | |||||
} | |||||
const augmentedFile = await augmentFunction(f); | |||||
if (contentType === ContentType.TEXT && augmentedFile.metadata.scheme) { | |||||
const augmentedFile = await augmentFile(f); | |||||
if (augmentedFile.resolvedType === ContentType.TEXT && augmentedFile.metadata?.scheme) { | |||||
await import(`prismjs/components/prism-${augmentedFile.metadata.scheme}`); | await import(`prismjs/components/prism-${augmentedFile.metadata.scheme}`); | ||||
} | } | ||||
return augmentedFile; | return augmentedFile; | ||||
@@ -167,81 +71,18 @@ const useFilePreviews = (fileList?: FileList) => { | |||||
}), [selectedFiles]); | }), [selectedFiles]); | ||||
} | } | ||||
const FILE_PREVIEW_COMPONENTS: Record<ContentType, React.ElementType> = { | |||||
[ContentType.TEXT]: TextFilePreview, | |||||
[ContentType.IMAGE]: ImageFilePreview, | |||||
[ContentType.AUDIO]: AudioFilePreview, | |||||
[ContentType.VIDEO]: VideoFilePreview, | |||||
[ContentType.BINARY]: BinaryFilePreview, | |||||
}; | |||||
const FilePreview = ({ | const FilePreview = ({ | ||||
fileList: fileList, | fileList: fileList, | ||||
}: { fileList?: FileList }) => { | }: { fileList?: FileList }) => { | ||||
const { files } = useFilePreviews(fileList); | const { files } = useFilePreviews(fileList); | ||||
const mediaContainerRef = React.useRef<HTMLDivElement>(null); | |||||
const mediaControllerRef = React.useRef<WaveSurfer | HTMLVideoElement>(null); | |||||
const [isPlaying, setIsPlaying] = React.useState(false); | |||||
const playMedia = () => { | |||||
if (files.length < 1) { | |||||
return; | |||||
} | |||||
setIsPlaying((p) => !p); | |||||
}; | |||||
React.useEffect(() => { | |||||
const { current: mediaController } = mediaControllerRef; | |||||
if (!mediaController) { | |||||
return; | |||||
} | |||||
if (isPlaying) { | |||||
void mediaController.play(); | |||||
return | |||||
} | |||||
mediaController.pause(); | |||||
}, [isPlaying]); | |||||
React.useEffect(() => { | |||||
if (files.length < 1) { | |||||
return; | |||||
} | |||||
const [theFile] = files; | |||||
const contentType = getContentType(theFile.type, theFile.name); | |||||
if ( | |||||
contentType === ContentType.AUDIO | |||||
&& typeof theFile.metadata?.previewUrl === 'string' | |||||
&& mediaContainerRef.current !== null | |||||
) { | |||||
mediaContainerRef.current.innerHTML = ''; | |||||
const mediaControllerRefMutable = mediaControllerRef as React.MutableRefObject<WaveSurfer>; | |||||
mediaControllerRefMutable.current = WaveSurfer.create({ | |||||
container: mediaContainerRef.current, | |||||
url: theFile.metadata.previewUrl, | |||||
cursorWidth: 0, | |||||
height: mediaContainerRef.current.offsetHeight, | |||||
barWidth: 2, | |||||
barGap: 2, | |||||
barRadius: 1, | |||||
}); | |||||
mediaControllerRefMutable.current.on('finish', () => { | |||||
setIsPlaying(false); | |||||
mediaControllerRefMutable.current.seekTo(0); | |||||
}); | |||||
} else if ( | |||||
contentType === ContentType.VIDEO | |||||
&& typeof theFile.metadata?.previewUrl === 'string' | |||||
) { | |||||
(mediaControllerRef.current as HTMLVideoElement).addEventListener('ended', (e) => { | |||||
const videoElement = e.currentTarget as HTMLVideoElement; | |||||
setIsPlaying(false); | |||||
videoElement.currentTime = 0; | |||||
}); | |||||
} | |||||
return () => { | |||||
if (mediaControllerRef && mediaControllerRef.current && mediaControllerRef.current instanceof WaveSurfer) { | |||||
mediaControllerRef.current.destroy(); | |||||
} | |||||
} | |||||
}, [files, mediaContainerRef, mediaControllerRef]); | |||||
if (files.length < 1) { | if (files.length < 1) { | ||||
return null; | return null; | ||||
@@ -249,439 +90,16 @@ const FilePreview = ({ | |||||
const f = files[0]; | const f = files[0]; | ||||
const contentType = getContentType(f.type, f.name); | const contentType = getContentType(f.type, f.name); | ||||
const FilePreviewComponent = FILE_PREVIEW_COMPONENTS[contentType] ?? BinaryFilePreview; | |||||
return ( | return ( | ||||
<div | <div | ||||
className={`w-full h-full`} | className={`w-full h-full`} | ||||
> | > | ||||
<div data-testid="selectedFileItem" className={`h-full w-full p-4 box-border rounded overflow-hidden relative before:absolute before:content-[''] before:bg-current before:top-0 before:left-0 before:w-full before:h-full before:opacity-10`}> | <div data-testid="selectedFileItem" className={`h-full w-full p-4 box-border rounded overflow-hidden relative before:absolute before:content-[''] before:bg-current before:top-0 before:left-0 before:w-full before:h-full before:opacity-10`}> | ||||
{ | |||||
contentType === ContentType.TEXT | |||||
&& ( | |||||
<div className="flex gap-4 w-full h-full relative"> | |||||
<div className={`h-full w-1/3 flex-shrink-0`}> | |||||
{ | |||||
typeof f.metadata?.contents === 'string' | |||||
&& ( | |||||
<div | |||||
data-testid="preview" | |||||
role="presentation" | |||||
className="w-full h-full select-none overflow-hidden text-xs" | |||||
> | |||||
<pre className="overflow-visible"> | |||||
{ | |||||
typeof f.metadata.scheme === 'string' | |||||
&& ( | |||||
<code | |||||
dangerouslySetInnerHTML={{ | |||||
__html: Prism.highlight( | |||||
f.metadata.contents, | |||||
Prism.languages[f.metadata.scheme], | |||||
f.metadata.scheme, | |||||
).split('\n').slice(0, 15).join('\n'), | |||||
}} | |||||
style={{ | |||||
tabSize: 2, | |||||
}} | |||||
/> | |||||
) | |||||
} | |||||
{ | |||||
typeof f.metadata.scheme !== 'string' | |||||
&& ( | |||||
<code> | |||||
{f.metadata.contents} | |||||
</code> | |||||
) | |||||
} | |||||
</pre> | |||||
</div> | |||||
) | |||||
} | |||||
</div> | |||||
<dl className="w-2/3 flex-shrink-0 m-0" data-testid="infoBox"> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Name | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
title={f.name} | |||||
> | |||||
{f.name} | |||||
</dd> | |||||
</div> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Type | |||||
</dt> | |||||
<dd | |||||
title={f.type} | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
> | |||||
{typeof f.metadata?.schemeTitle === 'string' ? `${f.metadata.schemeTitle} Source` : 'Text File'} | |||||
</dd> | |||||
</div> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Size | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
title={`${formatNumeral(f.size ?? 0)} bytes`} | |||||
> | |||||
{formatFileSize(f.size)} | |||||
</dd> | |||||
</div> | |||||
{ | |||||
typeof f.metadata?.language === 'string' | |||||
&& ( | |||||
<div> | |||||
<dt className="sr-only"> | |||||
Language | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
> | |||||
{f.metadata.language.slice(0, 1).toUpperCase()} | |||||
{f.metadata.language.slice(1)} | |||||
</dd> | |||||
</div> | |||||
) | |||||
} | |||||
</dl> | |||||
</div> | |||||
) | |||||
} | |||||
{ | |||||
contentType === ContentType.IMAGE | |||||
&& ( | |||||
<div className="flex gap-4 w-full h-full relative"> | |||||
<div className={`h-full w-1/3 flex-shrink-0`}> | |||||
{ | |||||
typeof f.metadata?.previewUrl === 'string' | |||||
&& ( | |||||
<img | |||||
className="block w-full h-full object-center object-cover" | |||||
src={f.metadata.previewUrl} | |||||
alt={f.name} | |||||
data-testid="preview" | |||||
/> | |||||
) | |||||
} | |||||
</div> | |||||
<dl className="w-2/3 flex-shrink-0 m-0" data-testid="infoBox"> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Name | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
title={f.name} | |||||
> | |||||
{f.name} | |||||
</dd> | |||||
</div> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Type | |||||
</dt> | |||||
<dd | |||||
title={f.type} | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
> | |||||
{getMimeTypeDescription(f.type, f.name)} | |||||
</dd> | |||||
</div> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Size | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
title={`${formatNumeral(f.size ?? 0)} bytes`} | |||||
> | |||||
{formatFileSize(f.size)} | |||||
</dd> | |||||
</div> | |||||
{ | |||||
typeof f.metadata?.width === 'number' | |||||
&& typeof f.metadata?.height === 'number' | |||||
&& ( | |||||
<div> | |||||
<dt className="sr-only"> | |||||
Pixel Dimensions | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
> | |||||
{formatNumeral(f.metadata.width)}×{formatNumeral(f.metadata.height)} pixels | |||||
</dd> | |||||
</div> | |||||
) | |||||
} | |||||
</dl> | |||||
</div> | |||||
) | |||||
} | |||||
{ | |||||
contentType === ContentType.AUDIO | |||||
&& ( | |||||
<div className="flex flex-col gap-4 w-full h-full relative"> | |||||
<div className="h-2/5 flex-shrink-0 cursor-pointer relative"> | |||||
<div | |||||
ref={mediaContainerRef} | |||||
className="relative h-full w-full" | |||||
/> | |||||
<div className="absolute bottom-0 left-0 z-[2]"> | |||||
<button | |||||
onClick={playMedia} | |||||
> | |||||
{isPlaying ? 'Pause' : 'Play'} | |||||
</button> | |||||
</div> | |||||
</div> | |||||
<dl className="h-3/5 flex-shrink-0 m-0 flex flex-col items-end" data-testid="infoBox"> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Name | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
title={f.name} | |||||
> | |||||
{f.name} | |||||
</dd> | |||||
</div> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Type | |||||
</dt> | |||||
<dd | |||||
title={f.type} | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
> | |||||
{getMimeTypeDescription(f.type)} | |||||
</dd> | |||||
</div> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Size | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
title={`${formatNumeral(f.size ?? 0)} bytes`} | |||||
> | |||||
{formatFileSize(f.size)} | |||||
</dd> | |||||
</div> | |||||
{ | |||||
typeof f.metadata?.duration === 'number' | |||||
&& ( | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Duration | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
title={`${formatNumeral(f.metadata.duration)} seconds`} | |||||
> | |||||
{formatSecondsDuration(f.metadata.duration)} | |||||
</dd> | |||||
</div> | |||||
) | |||||
} | |||||
</dl> | |||||
</div> | |||||
) | |||||
} | |||||
{ | |||||
contentType === ContentType.VIDEO | |||||
&& ( | |||||
<div className="flex gap-4 w-full h-full relative"> | |||||
<div className={`h-full w-1/3 flex-shrink-0`}> | |||||
{ | |||||
typeof f.metadata?.previewUrl === 'string' | |||||
&& ( | |||||
<div | |||||
className="w-full h-full bg-black" | |||||
data-testid="preview relative" | |||||
> | |||||
<video | |||||
className="block w-full h-full object-center object-contain relative" | |||||
ref={mediaControllerRef as React.RefObject<HTMLVideoElement>} | |||||
> | |||||
<source | |||||
src={f.metadata.previewUrl} | |||||
type={f.type} | |||||
/> | |||||
</video> | |||||
<div className="absolute bottom-0 left-0 hover:opacity-100 opacity-0"> | |||||
<button | |||||
onClick={playMedia} | |||||
> | |||||
{isPlaying ? 'Pause' : 'Play'} | |||||
</button> | |||||
</div> | |||||
</div> | |||||
) | |||||
} | |||||
</div> | |||||
<dl className="w-2/3 flex-shrink-0 m-0" data-testid="infoBox"> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Name | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
title={f.name} | |||||
> | |||||
{f.name} | |||||
</dd> | |||||
</div> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Type | |||||
</dt> | |||||
<dd | |||||
title={f.type} | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
> | |||||
{getMimeTypeDescription(f.type, f.name)} | |||||
</dd> | |||||
</div> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Size | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
title={`${formatNumeral(f.size ?? 0)} bytes`} | |||||
> | |||||
{formatFileSize(f.size)} | |||||
</dd> | |||||
</div> | |||||
{ | |||||
typeof f.metadata?.width === 'number' | |||||
&& typeof f.metadata?.height === 'number' | |||||
&& ( | |||||
<div> | |||||
<dt className="sr-only"> | |||||
Pixel Dimensions | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
> | |||||
{formatNumeral(f.metadata.width)}×{formatNumeral(f.metadata.height)} pixels | |||||
</dd> | |||||
</div> | |||||
) | |||||
} | |||||
</dl> | |||||
</div> | |||||
) | |||||
} | |||||
{ | |||||
contentType === ContentType.BINARY | |||||
&& ( | |||||
<div className="flex gap-4 w-full h-full relative"> | |||||
<div className={`h-full w-1/3 flex-shrink-0`}> | |||||
{ | |||||
f.metadata && (f.metadata?.contents instanceof ArrayBuffer) | |||||
&& ( | |||||
<div | |||||
data-testid="preview" | |||||
role="presentation" | |||||
className="w-full h-full select-none overflow-hidden text-xs" | |||||
> | |||||
<pre className="overflow-visible"> | |||||
<code> | |||||
{ | |||||
(Array.from(new Uint8Array((f.metadata.contents as ArrayBuffer).slice(0, 256))) as number[]) | |||||
.reduce( | |||||
(byteArray: number[][], byte: number, i) => { | |||||
if (i % 16 === 0) { | |||||
return [ | |||||
...byteArray, | |||||
[byte], | |||||
] | |||||
} | |||||
const lastLine = byteArray.at(-1) as number[] | |||||
return [ | |||||
...(byteArray.slice(0, -1)), | |||||
[...lastLine, byte], | |||||
] | |||||
}, | |||||
[] as number[][], | |||||
) | |||||
.map((ba: number[]) => ba | |||||
.map((a) => a.toString(16).padStart(2, '0')) | |||||
.join(' ') | |||||
) | |||||
.join('\n') | |||||
} | |||||
</code> | |||||
</pre> | |||||
</div> | |||||
) | |||||
} | |||||
</div> | |||||
<dl className="w-2/3 flex-shrink-0 m-0" data-testid="infoBox"> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Name | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
title={f.name} | |||||
> | |||||
{f.name} | |||||
</dd> | |||||
</div> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Type | |||||
</dt> | |||||
<dd | |||||
title={f.type} | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
> | |||||
{getMimeTypeDescription(f.type, f.name)} | |||||
</dd> | |||||
</div> | |||||
<div className="w-full"> | |||||
<dt className="sr-only"> | |||||
Size | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
title={`${formatNumeral(f.size ?? 0)} bytes`} | |||||
> | |||||
{formatFileSize(f.size)} | |||||
</dd> | |||||
</div> | |||||
{ | |||||
typeof f.metadata?.language === 'string' | |||||
&& ( | |||||
<div> | |||||
<dt className="sr-only"> | |||||
Language | |||||
</dt> | |||||
<dd | |||||
className="m-0 w-full text-ellipsis overflow-hidden" | |||||
> | |||||
{f.metadata.language.slice(0, 1).toUpperCase()} | |||||
{f.metadata.language.slice(1)} | |||||
</dd> | |||||
</div> | |||||
) | |||||
} | |||||
</dl> | |||||
</div> | |||||
) | |||||
} | |||||
<FilePreviewComponent | |||||
file={f} | |||||
/> | |||||
</div> | </div> | ||||
</div> | </div> | ||||
) | ) | ||||
@@ -691,44 +109,10 @@ const FilePreviewGrid = ({ | |||||
fileList, | fileList, | ||||
}: { fileList?: FileList }) => { | }: { fileList?: FileList }) => { | ||||
const { files } = useFilePreviews(fileList); | const { files } = useFilePreviews(fileList); | ||||
const mediaContainerRef = React.useRef<HTMLDivElement>(null); | |||||
const playMedia = (index: number) => () => { | |||||
if (files.length < 1) { | |||||
return; | |||||
} | |||||
const theFile = files[index]; | |||||
if (typeof theFile.internal?.playPause === 'function') { | |||||
theFile.internal.playPause(); | |||||
} | |||||
}; | |||||
React.useEffect(() => { | |||||
if (files.length < 1) { | |||||
return; | |||||
} | |||||
files.forEach((theFile, i) => { | |||||
if (typeof theFile.internal?.createMediaInstance === 'function' && mediaContainerRef.current) { | |||||
theFile.internal.createMediaInstance(mediaContainerRef.current.children[i].children[0]); | |||||
} | |||||
}); | |||||
return () => { | |||||
files.forEach((theFile) => { | |||||
if (typeof theFile.internal?.destroyMediaInstance === 'function') { | |||||
theFile.internal.destroyMediaInstance(); | |||||
} | |||||
}); | |||||
} | |||||
}, [files, mediaContainerRef]); | |||||
return ( | return ( | ||||
<div className="w-full h-full overflow-auto -mx-4 px-4"> | <div className="w-full h-full overflow-auto -mx-4 px-4"> | ||||
<div | |||||
className={`w-full grid gap-4 grid-cols-3`} | |||||
ref={mediaContainerRef} | |||||
> | |||||
<div className="w-full grid gap-4 grid-cols-3"> | |||||
{files.map((f, i) => ( | {files.map((f, i) => ( | ||||
<div | <div | ||||
data-testid="selectedFileItem" | data-testid="selectedFileItem" | ||||
@@ -737,7 +121,7 @@ const FilePreviewGrid = ({ | |||||
title={[f.name, getMimeTypeDescription(f.type), formatFileSize(f.size)].join(', ')} | title={[f.name, getMimeTypeDescription(f.type), formatFileSize(f.size)].join(', ')} | ||||
> | > | ||||
{ | { | ||||
f.type?.startsWith('image/') | |||||
f.resolvedType === ContentType.IMAGE | |||||
&& typeof f.metadata?.previewUrl === 'string' | && typeof f.metadata?.previewUrl === 'string' | ||||
&& ( | && ( | ||||
<img | <img | ||||
@@ -749,12 +133,9 @@ const FilePreviewGrid = ({ | |||||
) | ) | ||||
} | } | ||||
{ | { | ||||
f.type?.startsWith('audio/') | |||||
f.resolvedType === ContentType.AUDIO | |||||
&& ( | && ( | ||||
<div | |||||
className="absolute top-0 left-0 w-full h-full cursor-pointer" | |||||
onClick={playMedia(i)} | |||||
/> | |||||
<AudioMiniFilePreview file={f} /> | |||||
) | ) | ||||
} | } | ||||
</div> | </div> | ||||
@@ -1,5 +1,8 @@ | |||||
import * as mimeTypes from 'mime-types'; | import * as mimeTypes from 'mime-types'; | ||||
import Blob from '../pages/categories/blob'; | import Blob from '../pages/categories/blob'; | ||||
import {getTextMetadata} from './text'; | |||||
import {getImageMetadata} from './image'; | |||||
import {getAudioMetadata} from './audio'; | |||||
const MIME_TYPE_DESCRIPTIONS = { | const MIME_TYPE_DESCRIPTIONS = { | ||||
'image/jpeg': 'JPEG Image', | 'image/jpeg': 'JPEG Image', | ||||
@@ -15,6 +18,7 @@ const MIME_TYPE_DESCRIPTIONS = { | |||||
'application/x-zip-compressed': 'Compressed ZIP Archive', | 'application/x-zip-compressed': 'Compressed ZIP Archive', | ||||
'application/x-x509-ca-cert': 'Certificate File', | 'application/x-x509-ca-cert': 'Certificate File', | ||||
'application/x-tar': 'Compressed TAR Archive', | 'application/x-tar': 'Compressed TAR Archive', | ||||
'application/x-rar': 'Compressed RAR Archive', | |||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Workbook', | 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Workbook', | ||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'Slideshow Presentation', | 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'Slideshow Presentation', | ||||
'application/msword': 'Microsoft Word Document', | 'application/msword': 'Microsoft Word Document', | ||||
@@ -121,3 +125,156 @@ export const readAsDataURL = (blob: Blob) => new Promise<string>((resolve, rejec | |||||
}); | }); | ||||
export const readAsArrayBuffer = (blob: Blob) => blob.arrayBuffer(); | export const readAsArrayBuffer = (blob: Blob) => blob.arrayBuffer(); | ||||
interface FileWithResolvedType<T extends ContentType> extends Partial<File> { | |||||
resolvedType: T; | |||||
} | |||||
export interface TextFileMetadata { | |||||
contents?: string; | |||||
scheme?: string; | |||||
schemeTitle?: string; | |||||
language?: string; | |||||
languageProbability?: number; | |||||
} | |||||
export interface TextFile extends FileWithResolvedType<ContentType.TEXT> { | |||||
metadata?: TextFileMetadata; | |||||
} | |||||
const augmentTextFile = async (f: File): Promise<TextFile> => { | |||||
const contents = await readAsText(f); | |||||
const metadata = getTextMetadata(contents, f.name) as TextFileMetadata; | |||||
return { | |||||
...f, | |||||
name: f.name, | |||||
type: f.type, | |||||
size: f.size, | |||||
lastModified: f.lastModified, | |||||
resolvedType: ContentType.TEXT, | |||||
metadata: { | |||||
contents, | |||||
language: metadata.language, | |||||
languageProbability: metadata.languageProbability, | |||||
scheme: metadata.scheme, | |||||
schemeTitle: metadata.schemeTitle, | |||||
}, | |||||
}; | |||||
}; | |||||
export interface ImageFileMetadata { | |||||
previewUrl?: string; | |||||
width?: number; | |||||
height?: number; | |||||
} | |||||
export interface ImageFile extends FileWithResolvedType<ContentType.IMAGE> { | |||||
metadata?: ImageFileMetadata; | |||||
} | |||||
const augmentImageFile = async (f: File): Promise<ImageFile> => { | |||||
const previewUrl = await readAsDataURL(f); | |||||
const imageMetadata = await getImageMetadata(previewUrl) as ImageFileMetadata; | |||||
return { | |||||
name: f.name, | |||||
type: f.type, | |||||
size: f.size, | |||||
lastModified: f.lastModified, | |||||
resolvedType: ContentType.IMAGE, | |||||
metadata: { | |||||
previewUrl, | |||||
width: imageMetadata.width, | |||||
height: imageMetadata.height, | |||||
}, | |||||
}; | |||||
}; | |||||
export interface AudioFileMetadata { | |||||
previewUrl?: string; | |||||
duration?: number; | |||||
} | |||||
export interface AudioFile extends FileWithResolvedType<ContentType.AUDIO> { | |||||
metadata?: AudioFileMetadata; | |||||
} | |||||
const augmentAudioFile = async (f: File): Promise<AudioFile> => { | |||||
const previewUrl = await readAsDataURL(f); | |||||
const audioExtensions = await getAudioMetadata(previewUrl) as AudioFileMetadata; | |||||
return { | |||||
name: f.name, | |||||
type: f.type, | |||||
size: f.size, | |||||
lastModified: f.lastModified, | |||||
resolvedType: ContentType.AUDIO, | |||||
metadata: { | |||||
previewUrl, | |||||
duration: audioExtensions.duration, | |||||
}, | |||||
}; | |||||
}; | |||||
export interface BinaryFileMetadata { | |||||
contents: ArrayBuffer; | |||||
} | |||||
export interface BinaryFile extends FileWithResolvedType<ContentType.BINARY> { | |||||
metadata?: BinaryFileMetadata; | |||||
} | |||||
const augmentBinaryFile = async (f: File): Promise<BinaryFile> => { | |||||
const arrayBuffer = await readAsArrayBuffer(f); | |||||
return { | |||||
name: f.name, | |||||
type: f.type, | |||||
size: f.size, | |||||
lastModified: f.lastModified, | |||||
resolvedType: ContentType.BINARY, | |||||
metadata: { | |||||
contents: arrayBuffer, | |||||
}, | |||||
} | |||||
}; | |||||
export interface VideoFileMetadata { | |||||
previewUrl?: string; | |||||
width?: number; | |||||
height?: number; | |||||
} | |||||
export interface VideoFile extends FileWithResolvedType<ContentType.VIDEO> { | |||||
metadata?: VideoFileMetadata; | |||||
} | |||||
const augmentVideoFile = async (f: File): Promise<VideoFile> => { | |||||
const previewUrl = await readAsDataURL(f); | |||||
return { | |||||
name: f.name, | |||||
type: f.type, | |||||
size: f.size, | |||||
lastModified: f.lastModified, | |||||
resolvedType: ContentType.VIDEO, | |||||
metadata: { | |||||
previewUrl, | |||||
}, | |||||
}; | |||||
}; | |||||
export type AugmentedFile = TextFile | ImageFile | AudioFile | VideoFile | BinaryFile; | |||||
export const augmentFile = async (f: File): Promise<AugmentedFile> => { | |||||
const contentType = getContentType(f.type, f.name); | |||||
switch (contentType) { | |||||
case ContentType.TEXT: | |||||
return augmentTextFile(f); | |||||
case ContentType.IMAGE: | |||||
return augmentImageFile(f); | |||||
case ContentType.AUDIO: | |||||
return augmentAudioFile(f); | |||||
case ContentType.VIDEO: | |||||
return augmentVideoFile(f); | |||||
default: | |||||
break; | |||||
} | |||||
return augmentBinaryFile(f); | |||||
}; |