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 BlobBase from '@tesseract-design/web-base-blob'; | |||
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 { | |||
AugmentedFile, | |||
augmentFile, | |||
ContentType, | |||
getContentType, | |||
getMimeTypeDescription, | |||
readAsArrayBuffer, | |||
readAsDataURL, | |||
readAsText, | |||
} from '../../../utils/blob'; | |||
import {getImageMetadata} from '../../../utils/image'; | |||
import {getAudioMetadata} from '../../../utils/audio'; | |||
import {getTextMetadata} from '../../../utils/text'; | |||
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'> { | |||
/** | |||
@@ -48,106 +43,15 @@ export interface FileButtonProps extends Omit<React.HTMLProps<HTMLInputElement>, | |||
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 [selectedFiles, setSelectedFiles] = React.useState([] as Partial<FileWithPreview>[]); | |||
const [selectedFiles, setSelectedFiles] = React.useState([] as AugmentedFile[]); | |||
React.useEffect(() => { | |||
const loadFilePreviews = async (fileList: FileList) => { | |||
const files = Array.from(fileList); | |||
return Promise.all( | |||
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}`); | |||
} | |||
return augmentedFile; | |||
@@ -167,81 +71,18 @@ const useFilePreviews = (fileList?: FileList) => { | |||
}), [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 = ({ | |||
fileList: fileList, | |||
}: { fileList?: 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) { | |||
return null; | |||
@@ -249,439 +90,16 @@ const FilePreview = ({ | |||
const f = files[0]; | |||
const contentType = getContentType(f.type, f.name); | |||
const FilePreviewComponent = FILE_PREVIEW_COMPONENTS[contentType] ?? BinaryFilePreview; | |||
return ( | |||
<div | |||
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`}> | |||
{ | |||
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> | |||
) | |||
@@ -691,44 +109,10 @@ const FilePreviewGrid = ({ | |||
fileList, | |||
}: { fileList?: 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 ( | |||
<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) => ( | |||
<div | |||
data-testid="selectedFileItem" | |||
@@ -737,7 +121,7 @@ const FilePreviewGrid = ({ | |||
title={[f.name, getMimeTypeDescription(f.type), formatFileSize(f.size)].join(', ')} | |||
> | |||
{ | |||
f.type?.startsWith('image/') | |||
f.resolvedType === ContentType.IMAGE | |||
&& typeof f.metadata?.previewUrl === 'string' | |||
&& ( | |||
<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> | |||
@@ -1,5 +1,8 @@ | |||
import * as mimeTypes from 'mime-types'; | |||
import Blob from '../pages/categories/blob'; | |||
import {getTextMetadata} from './text'; | |||
import {getImageMetadata} from './image'; | |||
import {getAudioMetadata} from './audio'; | |||
const MIME_TYPE_DESCRIPTIONS = { | |||
'image/jpeg': 'JPEG Image', | |||
@@ -15,6 +18,7 @@ const MIME_TYPE_DESCRIPTIONS = { | |||
'application/x-zip-compressed': 'Compressed ZIP Archive', | |||
'application/x-x509-ca-cert': 'Certificate File', | |||
'application/x-tar': 'Compressed TAR Archive', | |||
'application/x-rar': 'Compressed RAR Archive', | |||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Workbook', | |||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'Slideshow Presentation', | |||
'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(); | |||
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); | |||
}; |