Browse Source

Refactor components

Define separate components for each file preview types.
pull/1/head
TheoryOfNekomata 1 year ago
parent
commit
74361cef4d
9 changed files with 898 additions and 647 deletions
  1. +141
    -0
      packages/web/kitchen-sink/react-next/src/components/AudioFilePreview/index.tsx
  2. +80
    -0
      packages/web/kitchen-sink/react-next/src/components/AudioMiniFilePreview/index.tsx
  3. +92
    -0
      packages/web/kitchen-sink/react-next/src/components/BinaryFilePreview/index.tsx
  4. +0
    -0
      packages/web/kitchen-sink/react-next/src/components/FilePreview/index.tsx
  5. +80
    -0
      packages/web/kitchen-sink/react-next/src/components/ImageFilePreview/index.tsx
  6. +106
    -0
      packages/web/kitchen-sink/react-next/src/components/TextFilePreview/index.tsx
  7. +214
    -0
      packages/web/kitchen-sink/react-next/src/components/VideoFilePreview/index.tsx
  8. +28
    -647
      packages/web/kitchen-sink/react-next/src/pages/categories/blob/index.tsx
  9. +157
    -0
      packages/web/kitchen-sink/react-next/src/utils/blob.ts

+ 141
- 0
packages/web/kitchen-sink/react-next/src/components/AudioFilePreview/index.tsx View File

@@ -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>
);
};

+ 80
- 0
packages/web/kitchen-sink/react-next/src/components/AudioMiniFilePreview/index.tsx View File

@@ -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}
/>
);
};

+ 92
- 0
packages/web/kitchen-sink/react-next/src/components/BinaryFilePreview/index.tsx View File

@@ -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
packages/web/kitchen-sink/react-next/src/components/FilePreview/index.tsx View File


+ 80
- 0
packages/web/kitchen-sink/react-next/src/components/ImageFilePreview/index.tsx View File

@@ -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)}&times;{formatNumeral(f.metadata.height)} pixels
</dd>
</div>
)
}
</dl>
</div>
);
}

+ 106
- 0
packages/web/kitchen-sink/react-next/src/components/TextFilePreview/index.tsx View File

@@ -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>
);

+ 214
- 0
packages/web/kitchen-sink/react-next/src/components/VideoFilePreview/index.tsx View File

@@ -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)}&times;{formatNumeral(f.metadata.height)} pixels
</dd>
</div>
)
}
</dl>
</div>
);
});

VideoFilePreview.displayName = 'VideoFilePreview';

+ 28
- 647
packages/web/kitchen-sink/react-next/src/pages/categories/blob/index.tsx View File

@@ -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)}&times;{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)}&times;{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>


+ 157
- 0
packages/web/kitchen-sink/react-next/src/utils/blob.ts View File

@@ -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);
};

Loading…
Cancel
Save