@@ -10,6 +10,7 @@ | |||
}, | |||
"dependencies": { | |||
"@reach/slider": "^0.18.0", | |||
"@theoryofnekomata/formxtra": "^1.0.3", | |||
"@types/node": "20.3.1", | |||
"@types/react": "18.2.14", | |||
"@types/react-dom": "18.2.6", | |||
@@ -1,4 +1,4 @@ | |||
lockfileVersion: '6.0' | |||
lockfileVersion: '6.1' | |||
settings: | |||
autoInstallPeers: true | |||
@@ -8,6 +8,9 @@ dependencies: | |||
'@reach/slider': | |||
specifier: ^0.18.0 | |||
version: 0.18.0(react-dom@18.2.0)(react@18.2.0) | |||
'@theoryofnekomata/formxtra': | |||
specifier: ^1.0.3 | |||
version: 1.0.3 | |||
'@types/node': | |||
specifier: 20.3.1 | |||
version: 20.3.1 | |||
@@ -359,6 +362,11 @@ packages: | |||
tslib: 2.5.3 | |||
dev: false | |||
/@theoryofnekomata/formxtra@1.0.3: | |||
resolution: {integrity: sha512-xOzE07Slttpx7vbOWqXfatJ+k44TN4zUjI57A5/sNqUDtHzp3pz94A+AVPGVoBY0QXiwzMjeN4DPMp6U1qlkyg==} | |||
engines: {node: '>=10'} | |||
dev: false | |||
/@types/debounce@1.2.1: | |||
resolution: {integrity: sha512-epMsEE85fi4lfmJUH/89/iV/LI+F5CvNIvmgs5g5jYFPfhO2S/ae8WSsLOKWdwtoaZw9Q2IhJ4tQ5tFCcS/4HA==} | |||
dev: true | |||
@@ -1 +1,5 @@ | |||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg> | |||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2 2"> | |||
<rect width="1" height="2" fill="#000"/> | |||
<rect width="1" height="1" x="1" fill="#000"/> | |||
<rect width="1" height="1" x="1" y="1" fill="#fff"/> | |||
</svg> |
@@ -8,11 +8,12 @@ import { | |||
} from '@/utils/numeral'; | |||
import theme from '@/styles/theme'; | |||
import {useMediaControls} from '../../hooks/interactive'; | |||
import {useAugmentedFile} from '@/categories/blob/react'; | |||
import {useFileMetadata} from '@/categories/blob/react'; | |||
import clsx from 'clsx'; | |||
import {WaveSurferCanvas} from '@/packages/react-wavesurfer'; | |||
import {SpectrogramCanvas, WaveformCanvas} from '@/packages/react-wavesurfer'; | |||
import {Slider} from '@/categories/number/react'; | |||
import {KeyValueTable} from '@/categories/information/react'; | |||
type AudioFilePreviewDerivedComponent = HTMLAudioElement; | |||
@@ -30,7 +31,7 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||
disabled = false, | |||
...etcProps | |||
}, forwardedRef) => { | |||
const { augmentedFile, error } = useAugmentedFile({ | |||
const { augmentedFile, error } = useFileMetadata({ | |||
file, | |||
augmentFunction: augmentAudioFile, | |||
}); | |||
@@ -99,19 +100,42 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||
type={augmentedFile.type} | |||
/> | |||
</audio> | |||
{visualizationMode === 'waveform' && ( | |||
<WaveSurferCanvas | |||
className="sm:absolute w-full sm:h-full top-0 left-0 block object-center object-contain flex-auto aspect-video sm:aspect-auto bg-primary/10" | |||
ref={mediaControllerRef} | |||
data-testid="preview" | |||
barWidth={1} | |||
barGap={1} | |||
waveColor={`rgb(${theme.primary.split(' ').map((c) => Math.floor(Number(c) / 2)).join(' ')})`} | |||
progressColor={`rgb(${theme.primary})`} | |||
interact | |||
/> | |||
)} | |||
<div className="flex gap-4 absolute top-0 right-0 z-[2] px-4"> | |||
<WaveformCanvas | |||
className={clsx( | |||
'sm:absolute w-full sm:h-full top-0 left-0 block object-center object-contain flex-auto aspect-video sm:aspect-auto bg-primary/10', | |||
visualizationMode !== 'waveform' && 'opacity-0', | |||
)} | |||
ref={mediaControllerRef} | |||
data-testid="preview" | |||
barWidth={1} | |||
barGap={1} | |||
progressColor={`rgb(${theme.primary})`} | |||
waveColor={`rgb(${theme.primary.split(' ').map((c) => Math.floor(Number(c) / 2)).join(' ')})`} | |||
interact | |||
// waveColor={`rgb(${theme.primary})`} | |||
// barHeight={4} | |||
// minPxPerSec={20000} | |||
// hideScrollbar | |||
// autoCenter | |||
// autoScroll | |||
/> | |||
<SpectrogramCanvas | |||
className={clsx( | |||
'sm:absolute w-full sm:h-full top-0 left-0 block object-center object-contain flex-auto aspect-video sm:aspect-auto bg-primary/10 pointer-events-none', | |||
visualizationMode !== 'spectrum' && 'opacity-0', | |||
)} | |||
ref={mediaControllerRef} | |||
data-testid="preview" | |||
barWidth={1} | |||
barGap={1} | |||
waveColor={`rgb(${theme.primary})`} | |||
cursorWidth={2} | |||
minPxPerSec={20000} | |||
hideScrollbar | |||
autoCenter | |||
autoScroll | |||
/> | |||
<div className="flex gap-4 absolute top-0 right-0 z-[5] px-4"> | |||
<label | |||
className={clsx( | |||
'h-12 flex items-center justify-center leading-none gap-4 select-none', | |||
@@ -278,58 +302,50 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||
<div | |||
className="flex-shrink-0 m-0 flex flex-col gap-4 justify-between" | |||
> | |||
<dl data-testid="infoBox"> | |||
<div className="w-full"> | |||
<dt className="sr-only"> | |||
Name | |||
</dt> | |||
<dd | |||
className="m-0 w-full text-ellipsis overflow-hidden font-bold" | |||
title={augmentedFile.name} | |||
ref={filenameRef} | |||
> | |||
{augmentedFile.name} | |||
</dd> | |||
</div> | |||
<div className="w-full"> | |||
<dt className="sr-only"> | |||
Type | |||
</dt> | |||
<dd | |||
title={augmentedFile.type} | |||
className="m-0 w-full text-ellipsis overflow-hidden" | |||
> | |||
{getMimeTypeDescription(augmentedFile.type, augmentedFile.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(augmentedFile.size ?? 0)} bytes`} | |||
> | |||
{formatFileSize(augmentedFile.size)} | |||
</dd> | |||
</div> | |||
{ | |||
<KeyValueTable | |||
hiddenKeys | |||
data-testid="infoBox" | |||
properties={[ | |||
{ | |||
key: 'Name', | |||
className: 'font-bold', | |||
valueProps: { | |||
ref: filenameRef, | |||
children: augmentedFile.name, | |||
}, | |||
}, | |||
{ | |||
key: 'Type', | |||
valueProps: { | |||
className: clsx( | |||
!getMimeTypeDescription(augmentedFile.type, augmentedFile.name) && 'opacity-50' | |||
), | |||
children: getMimeTypeDescription(augmentedFile.type, augmentedFile.name) || '(Loading)', | |||
}, | |||
}, | |||
{ | |||
key: 'Size', | |||
valueProps: { | |||
className: clsx( | |||
!formatFileSize(augmentedFile.size) && 'opacity-50' | |||
), | |||
title: `${formatNumeral(augmentedFile.size ?? 0)} bytes`, | |||
children: formatFileSize(augmentedFile.size) || '(Loading)', | |||
}, | |||
}, | |||
typeof augmentedFile.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(augmentedFile.metadata.duration ?? 0)} seconds`} | |||
> | |||
{formatSecondsDurationPrecise(augmentedFile.metadata.duration)} | |||
</dd> | |||
</div> | |||
) | |||
} | |||
</dl> | |||
&& { | |||
key: 'Duration', | |||
valueProps: { | |||
className: clsx( | |||
!formatSecondsDurationPrecise(augmentedFile.metadata.duration) && 'opacity-50' | |||
), | |||
title: `${formatNumeral(augmentedFile.metadata.duration ?? 0)} seconds`, | |||
children: formatSecondsDurationPrecise(augmentedFile.metadata.duration), | |||
}, | |||
}, | |||
]} | |||
/> | |||
<form | |||
id={formId} | |||
onSubmit={handleAction} | |||
@@ -1,13 +1,14 @@ | |||
import * as React from 'react'; | |||
import {augmentImageFile, FallbackFile, getMimeTypeDescription} from '@/utils/blob'; | |||
import {augmentImageFile, FileWithDataUrl, getMimeTypeDescription} from '@/utils/blob'; | |||
import {formatFileSize, formatNumeral} from '@/utils/numeral'; | |||
import clsx from 'clsx'; | |||
import {useAugmentedFile, useImageControls} from '@/categories/blob/react'; | |||
import {useFileMetadata, useFileUrl, useImageControls} from '@/categories/blob/react'; | |||
import {KeyValueTable} from '@/categories/information/react'; | |||
type ImageFilePreviewDerivedComponent = HTMLImageElement; | |||
export interface ImageFilePreviewProps extends Omit<React.HTMLProps<ImageFilePreviewDerivedComponent>, 'src' | 'alt'> { | |||
file?: File | FallbackFile; | |||
file?: Partial<FileWithDataUrl>; | |||
disabled?: boolean; | |||
} | |||
@@ -18,8 +19,9 @@ export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponen | |||
disabled = false, | |||
...etcProps | |||
}, forwardedRef) => { | |||
const { augmentedFile, error } = useAugmentedFile({ | |||
file: file as File, | |||
const { fileWithUrl, loading: urlLoading } = useFileUrl({ file }); | |||
const { augmentedFile, loading: metadataLoading, error } = useFileMetadata({ | |||
file: fileWithUrl as File, | |||
augmentFunction: augmentImageFile, | |||
}); | |||
const { | |||
@@ -35,7 +37,10 @@ export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponen | |||
return null; | |||
} | |||
console.log(augmentedFile); | |||
const cannotDisplayPicture = Boolean( | |||
typeof augmentedFile.url !== 'string' | |||
&& error | |||
); | |||
return ( | |||
<div | |||
@@ -47,112 +52,85 @@ export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponen | |||
> | |||
<div className="h-full relative"> | |||
<div className="sm:absolute top-0 left-0 w-full sm:h-full z-[3]"> | |||
{ | |||
typeof augmentedFile.metadata?.previewUrl === 'string' | |||
&& ( | |||
<img | |||
{...etcProps} | |||
ref={imageRef} | |||
className={clsx( | |||
'block h-full max-w-full object-center bg-[#000000]', | |||
{ | |||
'object-contain fixed w-full top-0 left-0': fullScreen, | |||
'object-cover w-full': !fullScreen, | |||
}, | |||
)} | |||
src={augmentedFile.metadata.previewUrl} | |||
alt="" | |||
data-testid="preview" | |||
/> | |||
) | |||
} | |||
{ | |||
error | |||
&& ( | |||
<div className="w-full h-full flex items-center justify-center text-center px-4 bg-[#000000] select-none"> | |||
{error.message} | |||
</div> | |||
) | |||
} | |||
{typeof augmentedFile.url === 'string' && ( | |||
<img | |||
{...etcProps} | |||
ref={imageRef} | |||
className={clsx( | |||
'block h-full max-w-full object-center bg-[#000000]', | |||
{ | |||
'object-contain fixed w-full top-0 left-0': fullScreen, | |||
'object-cover w-full': !fullScreen, | |||
}, | |||
)} | |||
src={augmentedFile.url} | |||
alt="" | |||
data-testid="preview" | |||
/> | |||
)} | |||
{cannotDisplayPicture && ( | |||
<div className="w-full h-full flex items-center justify-center text-center px-4 bg-[#000000] select-none"> | |||
{error!.message} | |||
</div> | |||
)} | |||
</div> | |||
</div> | |||
<div | |||
className="col-span-2 flex-shrink-0 m-0 flex flex-col gap-4 justify-between" | |||
> | |||
<dl data-testid="infoBox"> | |||
<div className="w-full font-bold"> | |||
<dt className="sr-only"> | |||
Name | |||
</dt> | |||
<dd | |||
className="m-0 w-full text-ellipsis overflow-hidden" | |||
title={augmentedFile.name} | |||
ref={filenameRef} | |||
> | |||
{augmentedFile.name} | |||
</dd> | |||
</div> | |||
<div className="w-full"> | |||
<dt className="sr-only"> | |||
Type | |||
</dt> | |||
<dd | |||
title={augmentedFile.type} | |||
className="m-0 w-full text-ellipsis overflow-hidden" | |||
> | |||
{getMimeTypeDescription(augmentedFile.type, augmentedFile.name)} | |||
</dd> | |||
</div> | |||
{ | |||
typeof augmentedFile?.size === 'number' | |||
&& ( | |||
<div className="w-full"> | |||
<dt className="sr-only"> | |||
Size | |||
</dt> | |||
<dd | |||
className="m-0 w-full text-ellipsis overflow-hidden" | |||
title={`${formatNumeral(augmentedFile.size ?? 0)} bytes`} | |||
> | |||
{formatFileSize(augmentedFile.size)} | |||
</dd> | |||
</div> | |||
) | |||
} | |||
{ | |||
<KeyValueTable | |||
hiddenKeys | |||
data-testid="infoBox" | |||
properties={[ | |||
{ | |||
key: 'Name', | |||
className: 'font-bold', | |||
valueProps: { | |||
ref: filenameRef, | |||
children: augmentedFile.name, | |||
}, | |||
}, | |||
{ | |||
key: 'Type', | |||
valueProps: { | |||
className: clsx( | |||
!getMimeTypeDescription(augmentedFile.type, augmentedFile.name) && 'opacity-50' | |||
), | |||
children: getMimeTypeDescription(augmentedFile.type, augmentedFile.name) || '(Loading)', | |||
}, | |||
}, | |||
{ | |||
key: 'Size', | |||
valueProps: { | |||
className: clsx( | |||
!formatFileSize(augmentedFile.size) && 'opacity-50' | |||
), | |||
title: `${formatNumeral(augmentedFile.size ?? 0)} bytes`, | |||
children: formatFileSize(augmentedFile.size) || '(Loading)', | |||
}, | |||
}, | |||
typeof augmentedFile.metadata?.width === 'number' | |||
&& typeof augmentedFile.metadata?.height === 'number' | |||
&& ( | |||
<div> | |||
<dt className="sr-only"> | |||
Pixel Dimensions | |||
</dt> | |||
<dd | |||
className="m-0 w-full text-ellipsis overflow-hidden" | |||
> | |||
{formatNumeral(augmentedFile.metadata.width)} × {formatNumeral(augmentedFile.metadata.height)} pixels | |||
</dd> | |||
</div> | |||
) | |||
} | |||
{ | |||
&& { | |||
key: 'Pixel Dimensions', | |||
valueProps: { | |||
children: `${formatNumeral(augmentedFile.metadata.width)} × ${formatNumeral(augmentedFile.metadata.height)} pixels`, | |||
}, | |||
}, | |||
Array.isArray(augmentedFile.metadata?.palette) | |||
&& ( | |||
<div className="mt-2"> | |||
<dt className="sr-only"> | |||
Palette | |||
</dt> | |||
<dd | |||
className="m-0 w-full text-ellipsis overflow-hidden flex flex-wrap gap-x-4 gap-y-1" | |||
> | |||
{augmentedFile.metadata?.palette.map((rgb, i) => ( | |||
<> | |||
{i > 0 && ' '} | |||
<span | |||
key={rgb.join(' ')} | |||
className="whitespace-nowrap" | |||
title={`rgb(${rgb.join(', ')})`} | |||
> | |||
&& { | |||
key: 'Palette', | |||
valueProps: { | |||
className: 'mt-1', | |||
children: augmentedFile.metadata?.palette.map((rgb, i) => ( | |||
<React.Fragment | |||
key={rgb.join(' ')} | |||
> | |||
{i > 0 && ' '} | |||
<span | |||
className="whitespace-nowrap inline-block align-top leading-none" | |||
title={`rgb(${rgb.join(', ')})`} | |||
> | |||
<span | |||
className="inline-block w-5 h-5 align-middle border border-[#ffffff] ring-1 ring-[#000000]" | |||
style={{ | |||
@@ -162,31 +140,38 @@ export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponen | |||
<span className="tabular-nums text-xs sr-only"> | |||
{ | |||
rgb | |||
.map((c) => c.toString().padStart(4, ' ').split('').map((c, i) => ( | |||
<span key={i} className={clsx({ | |||
'opacity-0': c === ' ', | |||
})}> | |||
{i === 0 && ' '} | |||
{c === ' ' && i > 0 ? '0' : c} | |||
</span> | |||
))) | |||
.map((c) => c | |||
.toString() | |||
.padStart(4, ' ') | |||
.split('') | |||
.map((cc, j) => ( | |||
<span | |||
key={`${rgb.join(',')}:${c}:${j}`} | |||
className={clsx({ | |||
'opacity-0': cc === ' ', | |||
})} | |||
> | |||
{j === 0 && ' '} | |||
{cc === ' ' && j > 0 ? '0' : cc} | |||
</span> | |||
)) | |||
) | |||
.flat() | |||
} | |||
</span> | |||
</span> | |||
</> | |||
))} | |||
</dd> | |||
</div> | |||
) | |||
} | |||
</dl> | |||
</React.Fragment> | |||
)), | |||
}, | |||
}, | |||
]} | |||
/> | |||
<form | |||
onSubmit={handleAction} | |||
className="flex gap-4" | |||
> | |||
<fieldset | |||
disabled={disabled || typeof error !== 'undefined'} | |||
disabled={disabled || cannotDisplayPicture} | |||
className="contents" | |||
> | |||
<legend className="sr-only"> | |||
@@ -1,9 +1,10 @@ | |||
import * as React from 'react'; | |||
import {augmentVideoFile, getMimeTypeDescription} from '@/utils/blob'; | |||
import {formatFileSize, formatNumeral, formatSecondsDurationConcise} from '@/utils/numeral'; | |||
import {useAugmentedFile, useMediaControls} from '@tesseract-design/web-blob-react'; | |||
import {useFileMetadata, useMediaControls} from '@tesseract-design/web-blob-react'; | |||
import clsx from 'clsx'; | |||
import {Slider} from '@tesseract-design/web-number-react'; | |||
import {KeyValueTable} from '@/categories/information/react'; | |||
type VideoFilePreviewDerivedComponent = HTMLVideoElement; | |||
@@ -21,7 +22,7 @@ export const VideoFilePreview = React.forwardRef<VideoFilePreviewDerivedComponen | |||
enhanced = false, | |||
...etcProps | |||
}, forwardedRef) => { | |||
const { augmentedFile, error } = useAugmentedFile({ | |||
const { augmentedFile, error } = useFileMetadata({ | |||
file, | |||
augmentFunction: augmentVideoFile, | |||
}); | |||
@@ -220,58 +221,47 @@ export const VideoFilePreview = React.forwardRef<VideoFilePreviewDerivedComponen | |||
<div | |||
className="flex-shrink-0 m-0 flex flex-col gap-4 justify-between" | |||
> | |||
<dl data-testid="infoBox"> | |||
<div className="w-full font-bold"> | |||
<dt className="sr-only"> | |||
Name | |||
</dt> | |||
<dd | |||
className="m-0 w-full text-ellipsis overflow-hidden" | |||
title={augmentedFile.name} | |||
ref={filenameRef} | |||
> | |||
{augmentedFile.name} | |||
</dd> | |||
</div> | |||
<div className="w-full"> | |||
<dt className="sr-only"> | |||
Type | |||
</dt> | |||
<dd | |||
title={augmentedFile.type} | |||
className="m-0 w-full text-ellipsis overflow-hidden" | |||
> | |||
{getMimeTypeDescription(augmentedFile.type, augmentedFile.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(augmentedFile.size ?? 0)} bytes`} | |||
> | |||
{formatFileSize(augmentedFile.size)} | |||
</dd> | |||
</div> | |||
{ | |||
<KeyValueTable | |||
hiddenKeys | |||
data-testid="infoBox" | |||
properties={[ | |||
{ | |||
key: 'Name', | |||
className: 'font-bold', | |||
valueProps: { | |||
ref: filenameRef, | |||
children: augmentedFile.name, | |||
}, | |||
}, | |||
{ | |||
key: 'Type', | |||
valueProps: { | |||
className: clsx( | |||
!getMimeTypeDescription(augmentedFile.type, augmentedFile.name) && 'opacity-50' | |||
), | |||
children: getMimeTypeDescription(augmentedFile.type, augmentedFile.name) || '(Loading)', | |||
}, | |||
}, | |||
{ | |||
key: 'Size', | |||
valueProps: { | |||
className: clsx( | |||
!formatFileSize(augmentedFile.size) && 'opacity-50' | |||
), | |||
title: `${formatNumeral(augmentedFile.size ?? 0)} bytes`, | |||
children: formatFileSize(augmentedFile.size) || '(Loading)', | |||
}, | |||
}, | |||
typeof augmentedFile.metadata?.width === 'number' | |||
&& typeof augmentedFile.metadata?.height === 'number' | |||
&& ( | |||
<div> | |||
<dt className="sr-only"> | |||
Pixel Dimensions | |||
</dt> | |||
<dd | |||
className="m-0 w-full text-ellipsis overflow-hidden" | |||
> | |||
{formatNumeral(augmentedFile.metadata.width)}×{formatNumeral(augmentedFile.metadata.height)} pixels | |||
</dd> | |||
</div> | |||
) | |||
} | |||
</dl> | |||
&& { | |||
key: 'Pixel Dimensions', | |||
valueProps: { | |||
children: `${formatNumeral(augmentedFile.metadata.width)} × ${formatNumeral(augmentedFile.metadata.height)} pixels`, | |||
}, | |||
}, | |||
]} | |||
/> | |||
<form | |||
id={formId} | |||
onSubmit={handleAction} | |||
@@ -1,32 +1,74 @@ | |||
import * as React from 'react'; | |||
import {addDataUrl, FileWithDataUrl} from '@/utils/blob'; | |||
export interface UseAugmentedFileOptions<T extends Partial<File> = Partial<File>> { | |||
export interface UseFileUrlOptions { | |||
file?: Partial<File>; | |||
} | |||
export const useFileUrl = (options: UseFileUrlOptions) => { | |||
const { file } = options; | |||
const [fileWithUrl, setFileWithUrl] = React.useState<Partial<FileWithDataUrl> | undefined>(file); | |||
const [loading, setLoading] = React.useState(false); | |||
React.useEffect(() => { | |||
if (!file) { | |||
setFileWithUrl(undefined); | |||
setLoading(false); | |||
return; | |||
} | |||
setLoading(true); | |||
addDataUrl(file) | |||
.then((fileWithUrl) => { | |||
setFileWithUrl(fileWithUrl); | |||
setLoading(false); | |||
}) | |||
.catch(() => { | |||
setFileWithUrl(file); | |||
setLoading(false); | |||
}); | |||
}, [file]); | |||
return React.useMemo(() => ({ | |||
fileWithUrl, | |||
loading, | |||
}), [fileWithUrl, loading]); | |||
}; | |||
export interface UseFileMetadataOptions<T extends Partial<File> = Partial<File>> { | |||
file?: File; | |||
augmentFunction: (file: File) => Promise<T>; | |||
} | |||
export const useAugmentedFile = <T extends Partial<File>>(options = {} as UseAugmentedFileOptions<T>) => { | |||
export const useFileMetadata = <T extends Partial<File>>(options: UseFileMetadataOptions<T>) => { | |||
const { file, augmentFunction } = options; | |||
const [augmentedFile, setAugmentedFile] = React.useState<T>(); | |||
const [fileWithMetadata, setFileWithMetadata] = React.useState<T | undefined>(file as T | undefined); | |||
const [loading, setLoading] = React.useState(false); | |||
const [error, setError] = React.useState<Error>(); | |||
React.useEffect(() => { | |||
if (!file) { | |||
setFileWithMetadata(undefined); | |||
setLoading(false); | |||
return; | |||
} | |||
setLoading(true); | |||
setError(undefined); | |||
augmentFunction(file) | |||
.then((theAugmentedFile) => { | |||
setAugmentedFile(theAugmentedFile); | |||
setFileWithMetadata(theAugmentedFile); | |||
setLoading(false); | |||
}) | |||
.catch((error) => { | |||
setError(error); | |||
setLoading(false); | |||
}); | |||
}, [file, augmentFunction]); | |||
return React.useMemo(() => ({ | |||
augmentedFile: (augmentedFile ?? file) as T | undefined, | |||
augmentedFile: fileWithMetadata, | |||
error, | |||
}), [augmentedFile, file, error]); | |||
loading, | |||
}), [fileWithMetadata, loading, error]); | |||
}; |
@@ -1,4 +1,5 @@ | |||
import * as React from 'react'; | |||
import { getFormValues } from '@theoryofnekomata/formxtra'; | |||
export interface UseImageControlsOptions { | |||
actionFormKey?: string; | |||
@@ -41,8 +42,14 @@ export const useImageControls = (options = {} as UseImageControlsOptions) => { | |||
const handleAction: React.FormEventHandler<HTMLFormElement> = React.useCallback((e) => { | |||
e.preventDefault(); | |||
const formData = new FormData(e.currentTarget, (e.nativeEvent as unknown as { submitter: HTMLElement }).submitter); | |||
const actionName = formData.get(actionFormKey) as keyof typeof actions; | |||
const nativeEvent = e.nativeEvent as unknown as { submitter: HTMLElement }; | |||
const formData = getFormValues( | |||
e.currentTarget, | |||
{ | |||
submitter: nativeEvent.submitter, | |||
} | |||
); | |||
const actionName = formData[actionFormKey] as keyof typeof actions; | |||
const { [actionName]: actionFunction } = actions; | |||
actionFunction?.(); | |||
}, [actions, actionFormKey]); | |||
@@ -1,4 +1,5 @@ | |||
import * as React from 'react'; | |||
import {getFormValues} from '@theoryofnekomata/formxtra'; | |||
export interface UseMediaControlsOptions<T extends HTMLMediaElement> { | |||
controllerRef: React.Ref<T>; | |||
@@ -138,8 +139,15 @@ export const useMediaControls = <T extends HTMLMediaElement>({ | |||
const handleAction: React.FormEventHandler<HTMLFormElement> = React.useCallback((e) => { | |||
e.preventDefault(); | |||
const formData = new FormData(e.currentTarget, (e.nativeEvent as unknown as { submitter: HTMLElement }).submitter); | |||
const actionName = formData.get(actionFormKey) as keyof typeof actions; | |||
e.preventDefault(); | |||
const nativeEvent = e.nativeEvent as unknown as { submitter: HTMLElement }; | |||
const formData = getFormValues( | |||
e.currentTarget, | |||
{ | |||
submitter: nativeEvent.submitter, | |||
} | |||
); | |||
const actionName = formData[actionFormKey] as keyof typeof actions; | |||
const { [actionName]: actionFunction } = actions; | |||
actionFunction?.(); | |||
}, [actions, actionFormKey]); | |||
@@ -0,0 +1,57 @@ | |||
import * as React from 'react'; | |||
import clsx from 'clsx'; | |||
type KeyValueTableDerivedElement = HTMLDListElement; | |||
interface KeyValueProperty { | |||
key: string; | |||
className?: string; | |||
valueProps?: React.HTMLProps<HTMLElement>; | |||
} | |||
export interface KeyValueTableProps extends Omit<React.HTMLProps<KeyValueTableDerivedElement>, 'children'> { | |||
hiddenKeys?: boolean; | |||
properties?: (KeyValueProperty | boolean)[]; | |||
} | |||
export const KeyValueTable = React.forwardRef<KeyValueTableDerivedElement, KeyValueTableProps>(({ | |||
hiddenKeys = false, | |||
properties = [], | |||
...etcProps | |||
}, forwardedRef) => ( | |||
<dl | |||
{...etcProps} | |||
className={clsx( | |||
'flex flex-wrap gap-y-1' | |||
)} | |||
ref={forwardedRef} | |||
> | |||
{ | |||
properties.map((property) => typeof property === 'object' && ( | |||
<div | |||
key={property.key} | |||
className={clsx('contents', property.className)} | |||
> | |||
<dt | |||
className={clsx(hiddenKeys && 'sr-only', 'w-1/3 pr-4')} | |||
> | |||
{property.key} | |||
</dt> | |||
<dd | |||
{...(property.valueProps ?? {})} | |||
className={clsx( | |||
'm-0 text-ellipsis overflow-hidden', | |||
!hiddenKeys && 'w-2/3', | |||
hiddenKeys && 'w-full', | |||
property.valueProps?.className, | |||
)} | |||
> | |||
{property.valueProps?.children} | |||
</dd> | |||
</div> | |||
)) | |||
} | |||
</dl> | |||
)); | |||
KeyValueTable.displayName = 'KeyValueTable'; |
@@ -1 +1,2 @@ | |||
export * from './components/Badge'; | |||
export * from './components/KeyValueTable'; |
@@ -0,0 +1,241 @@ | |||
import * as React from 'react'; | |||
import {WaveSurferOptions} from 'wavesurfer.js'; | |||
import clsx from 'clsx'; | |||
import {getFormValues} from '@theoryofnekomata/formxtra'; | |||
type SpectrogramCanvasDerivedComponent = HTMLAudioElement; | |||
export interface SpectrogramCanvasProps | |||
extends React.HTMLProps<SpectrogramCanvasDerivedComponent>, | |||
Omit<WaveSurferOptions, 'waveColor' | 'plugins' | 'height' | 'media' | 'container' | 'fillParent' | 'url' | 'autoplay' | 'renderFunction'> { | |||
waveColor?: string; | |||
} | |||
export const SpectrogramCanvas = React.forwardRef<SpectrogramCanvasDerivedComponent, SpectrogramCanvasProps>(({ | |||
className, | |||
children, | |||
controls, | |||
waveColor, | |||
progressColor, | |||
cursorColor, | |||
cursorWidth, | |||
barWidth, | |||
barGap, | |||
barRadius, | |||
barHeight, | |||
barAlign, | |||
minPxPerSec, | |||
peaks, | |||
duration, | |||
autoPlay, | |||
interact, | |||
hideScrollbar, | |||
audioRate, | |||
autoScroll, | |||
autoCenter, | |||
sampleRate, | |||
splitChannels, | |||
normalize, | |||
...etcProps | |||
}, forwardedRef) => { | |||
const [isPlaying, setIsPlaying] = React.useState(false); | |||
const defaultRef = React.useRef<HTMLAudioElement>(null); | |||
const ref = forwardedRef ?? defaultRef; | |||
const containerRef = React.useRef<HTMLDivElement>(null); | |||
const waveSurferRef = React.useRef<any>(null); | |||
const cursorRef = React.useRef<HTMLDivElement>(null); | |||
const handleAction: React.FormEventHandler<HTMLFormElement> = (e) => { | |||
e.preventDefault(); | |||
e.preventDefault(); | |||
const nativeEvent = e.nativeEvent as unknown as { submitter: HTMLElement }; | |||
const formData = getFormValues( | |||
e.currentTarget, | |||
{ | |||
submitter: nativeEvent.submitter, | |||
} | |||
); | |||
const actionName = formData['action'] as string; | |||
switch (actionName) { | |||
case 'togglePlayback': | |||
setIsPlaying((prev) => !prev); | |||
break; | |||
default: | |||
break; | |||
} | |||
}; | |||
React.useEffect(() => { | |||
const { current: container } = containerRef; | |||
const media = typeof ref === 'object' ? ref?.current : null; | |||
const { current: waveSurferCurrent } = waveSurferRef; | |||
const handleTimeUpdate = (e: Event) => { | |||
const thisMedia = e.currentTarget as HTMLAudioElement; | |||
if (cursorRef.current) { | |||
cursorRef.current.style.width = `${(thisMedia?.currentTime ?? 0) / (thisMedia?.duration ?? 1) * 100}%`; | |||
} | |||
}; | |||
const load = async (ref: React.Ref<HTMLAudioElement>) => { | |||
if (!(typeof ref === 'object' && ref?.current)) { | |||
return; | |||
} | |||
if (!(typeof containerRef === 'object' && containerRef?.current)) { | |||
return; | |||
} | |||
const { default: WaveSurfer } = await import('wavesurfer.js'); | |||
const { default: Spectrogram, } = await import('wavesurfer.js/dist/plugins/spectrogram'); | |||
const dummyContainer = window.document.createElement('div'); | |||
window.document.body.appendChild(dummyContainer); | |||
const waveSurferInstance = WaveSurfer.create({ | |||
container: dummyContainer, | |||
height: 100, | |||
fillParent: true, | |||
autoplay: autoPlay, | |||
waveColor, | |||
progressColor, | |||
cursorColor, | |||
barWidth, | |||
barGap, | |||
barRadius, | |||
barHeight, | |||
barAlign, | |||
minPxPerSec, | |||
peaks, | |||
duration, | |||
interact, | |||
hideScrollbar, | |||
audioRate, | |||
autoScroll, | |||
autoCenter, | |||
sampleRate, | |||
splitChannels, | |||
normalize, | |||
plugins: [], | |||
cursorWidth, | |||
media: media ?? undefined, | |||
}); | |||
let colorMap: Array<[number, number, number, number]> = []; | |||
if (waveColor?.toLowerCase().startsWith('rgb(')) { | |||
const waveColorParse = waveColor.match(/rgb\((\d+)[, ]\s*(\d+)[, ]\s*(\d+)\)/); | |||
const waveColorR = parseInt(waveColorParse?.[1] ?? '0', 10); | |||
const waveColorG = parseInt(waveColorParse?.[2] ?? '0', 10); | |||
const waveColorB = parseInt(waveColorParse?.[3] ?? '0', 10); | |||
for (let i = 0; i < 256; i += 1) { | |||
colorMap.push([waveColorR / 256, waveColorG / 256, waveColorB / 256, i / 256]); | |||
} | |||
} | |||
waveSurferInstance.registerPlugin( | |||
Spectrogram.create({ | |||
container: containerRef.current, | |||
labels: true, | |||
labelsColor: 'rgb(0 0 0/0)', | |||
height: containerRef.current.clientHeight, | |||
colorMap, | |||
}), | |||
) | |||
waveSurferInstance.on('ready', () => { | |||
if (!container) { | |||
return; | |||
} | |||
while (container.children.length > 1) { | |||
container.removeChild(container.children[0]); | |||
} | |||
dummyContainer.remove(); | |||
}); | |||
await waveSurferInstance.load(ref.current.currentSrc); | |||
waveSurferInstance.setTime(ref.current.currentTime); | |||
waveSurferRef.current = waveSurferInstance; | |||
media!.addEventListener('timeupdate', handleTimeUpdate); | |||
}; | |||
void load(ref); | |||
return () => { | |||
if (waveSurferCurrent) { | |||
(waveSurferCurrent as unknown as Record<string, Function>).destroy(); | |||
} | |||
if (container) { | |||
container.innerHTML = ''; | |||
} | |||
if (!media) { | |||
return; | |||
} | |||
media.removeEventListener('timeupdate', handleTimeUpdate); | |||
}; | |||
}, [ | |||
ref, | |||
autoPlay, | |||
waveColor, | |||
progressColor, | |||
cursorColor, | |||
barWidth, | |||
barGap, | |||
barRadius, | |||
barHeight, | |||
barAlign, | |||
minPxPerSec, | |||
peaks, | |||
duration, | |||
interact, | |||
hideScrollbar, | |||
audioRate, | |||
autoScroll, | |||
autoCenter, | |||
sampleRate, | |||
splitChannels, | |||
normalize, | |||
cursorWidth, | |||
]); | |||
return ( | |||
<div | |||
className={clsx( | |||
'relative flex flex-col', | |||
className, | |||
)} | |||
> | |||
<div | |||
className="flex-auto relative" | |||
style={{ | |||
maskImage: 'url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAFDmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNS41LjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIgogICAgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIgogICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIgogICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgeG1wOkNyZWF0ZURhdGU9IjIwMjMtMDYtMjdUMTk6MDE6MTIrMDgwMCIKICAgeG1wOk1vZGlmeURhdGU9IjIwMjMtMDYtMjdUMTk6MDI6MzMrMDg6MDAiCiAgIHhtcDpNZXRhZGF0YURhdGU9IjIwMjMtMDYtMjdUMTk6MDI6MzMrMDg6MDAiCiAgIHBob3Rvc2hvcDpEYXRlQ3JlYXRlZD0iMjAyMy0wNi0yN1QxOTowMToxMiswODAwIgogICBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIgogICBwaG90b3Nob3A6SUNDUHJvZmlsZT0ic1JHQiBJRUM2MTk2Ni0yLjEiCiAgIGV4aWY6UGl4ZWxYRGltZW5zaW9uPSIyIgogICBleGlmOlBpeGVsWURpbWVuc2lvbj0iMiIKICAgZXhpZjpDb2xvclNwYWNlPSIxIgogICB0aWZmOkltYWdlV2lkdGg9IjIiCiAgIHRpZmY6SW1hZ2VMZW5ndGg9IjIiCiAgIHRpZmY6UmVzb2x1dGlvblVuaXQ9IjIiCiAgIHRpZmY6WFJlc29sdXRpb249IjcyLzEiCiAgIHRpZmY6WVJlc29sdXRpb249IjcyLzEiPgogICA8eG1wTU06SGlzdG9yeT4KICAgIDxyZGY6U2VxPgogICAgIDxyZGY6bGkKICAgICAgc3RFdnQ6YWN0aW9uPSJwcm9kdWNlZCIKICAgICAgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWZmaW5pdHkgUGhvdG8gMiAyLjAuNCIKICAgICAgc3RFdnQ6d2hlbj0iMjAyMy0wNi0yN1QxOTowMjozMyswODowMCIvPgogICAgPC9yZGY6U2VxPgogICA8L3htcE1NOkhpc3Rvcnk+CiAgPC9yZGY6RGVzY3JpcHRpb24+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+qkfBDwAAAYFpQ0NQc1JHQiBJRUM2MTk2Ni0yLjEAACiRdZHLS0JBFIc/tRdZFNQiqIWEtaowA6lNkBEWSIgZ9Nro1auB2uVeI6Jt0DYoiNr0WtRfUNugdRAURRBta13UpuR2rgpK5BnOnG9+M+cwcwbskbSSMWo8kMnm9HDA75qbX3DVv1JHJ05gIKoY2lgoFKSqfT1gs+Jdv1Wr+rl/zRlPGArYGoRHFU3PCU8KB9dymsW7wu1KKhoXPhfu0+WCwveWHivym8XJIv9YrEfC42BvFXYlKzhWwUpKzwjLy3Fn0qtK6T7WS5oS2dkZid3iXRiECeDHxRQTjONjkBGZffTjZUBWVMn3FPKnWZFcRWaNdXSWSZIiR5+oq1I9IVEVPSEjzbrV/799NdQhb7F6kx9qX0zzowfqdyC/bZrfx6aZPwHHM1xly/krRzD8Kfp2WXMfQssmXFyXtdgeXG5Bx5MW1aMFySFuV1V4P4PmeWi7hcbFYs9K+5w+QmRDvuoG9g+gV863LP0CQBln1EZARokAAAAJcEhZcwAACxMAAAsTAQCanBgAAAAPSURBVAiZY2CAg////wMABggC/mOlCSoAAAAASUVORK5CYII=)', | |||
WebkitMaskImage: 'url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAFDmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNS41LjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIgogICAgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIgogICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIgogICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgeG1wOkNyZWF0ZURhdGU9IjIwMjMtMDYtMjdUMTk6MDE6MTIrMDgwMCIKICAgeG1wOk1vZGlmeURhdGU9IjIwMjMtMDYtMjdUMTk6MDI6MzMrMDg6MDAiCiAgIHhtcDpNZXRhZGF0YURhdGU9IjIwMjMtMDYtMjdUMTk6MDI6MzMrMDg6MDAiCiAgIHBob3Rvc2hvcDpEYXRlQ3JlYXRlZD0iMjAyMy0wNi0yN1QxOTowMToxMiswODAwIgogICBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIgogICBwaG90b3Nob3A6SUNDUHJvZmlsZT0ic1JHQiBJRUM2MTk2Ni0yLjEiCiAgIGV4aWY6UGl4ZWxYRGltZW5zaW9uPSIyIgogICBleGlmOlBpeGVsWURpbWVuc2lvbj0iMiIKICAgZXhpZjpDb2xvclNwYWNlPSIxIgogICB0aWZmOkltYWdlV2lkdGg9IjIiCiAgIHRpZmY6SW1hZ2VMZW5ndGg9IjIiCiAgIHRpZmY6UmVzb2x1dGlvblVuaXQ9IjIiCiAgIHRpZmY6WFJlc29sdXRpb249IjcyLzEiCiAgIHRpZmY6WVJlc29sdXRpb249IjcyLzEiPgogICA8eG1wTU06SGlzdG9yeT4KICAgIDxyZGY6U2VxPgogICAgIDxyZGY6bGkKICAgICAgc3RFdnQ6YWN0aW9uPSJwcm9kdWNlZCIKICAgICAgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWZmaW5pdHkgUGhvdG8gMiAyLjAuNCIKICAgICAgc3RFdnQ6d2hlbj0iMjAyMy0wNi0yN1QxOTowMjozMyswODowMCIvPgogICAgPC9yZGY6U2VxPgogICA8L3htcE1NOkhpc3Rvcnk+CiAgPC9yZGY6RGVzY3JpcHRpb24+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+qkfBDwAAAYFpQ0NQc1JHQiBJRUM2MTk2Ni0yLjEAACiRdZHLS0JBFIc/tRdZFNQiqIWEtaowA6lNkBEWSIgZ9Nro1auB2uVeI6Jt0DYoiNr0WtRfUNugdRAURRBta13UpuR2rgpK5BnOnG9+M+cwcwbskbSSMWo8kMnm9HDA75qbX3DVv1JHJ05gIKoY2lgoFKSqfT1gs+Jdv1Wr+rl/zRlPGArYGoRHFU3PCU8KB9dymsW7wu1KKhoXPhfu0+WCwveWHivym8XJIv9YrEfC42BvFXYlKzhWwUpKzwjLy3Fn0qtK6T7WS5oS2dkZid3iXRiECeDHxRQTjONjkBGZffTjZUBWVMn3FPKnWZFcRWaNdXSWSZIiR5+oq1I9IVEVPSEjzbrV/799NdQhb7F6kx9qX0zzowfqdyC/bZrfx6aZPwHHM1xly/krRzD8Kfp2WXMfQssmXFyXtdgeXG5Bx5MW1aMFySFuV1V4P4PmeWi7hcbFYs9K+5w+QmRDvuoG9g+gV863LP0CQBln1EZARokAAAAJcEhZcwAACxMAAAsTAQCanBgAAAAPSURBVAiZY2CAg////wMABggC/mOlCSoAAAAASUVORK5CYII=)', | |||
}} | |||
> | |||
<div | |||
ref={cursorRef} | |||
style={{ | |||
position: 'absolute', | |||
top: 0, | |||
left: 0, | |||
height: '100%', | |||
mixBlendMode: 'plus-lighter', | |||
opacity: 0.5, | |||
backgroundColor: 'rgb(var(--color-primary))', | |||
zIndex: 5, | |||
}} | |||
/> | |||
<div className="absolute top-0 left-0 w-full h-full" | |||
ref={containerRef} | |||
/> | |||
</div> | |||
{controls && ( | |||
<form | |||
onSubmit={handleAction} | |||
> | |||
<button | |||
type="submit" | |||
name="action" | |||
value="togglePlayback" | |||
> | |||
{isPlaying ? '⏸' : '▶'} | |||
</button> | |||
</form> | |||
)} | |||
</div> | |||
); | |||
}); | |||
SpectrogramCanvas.displayName = 'WavesurferCanvas'; |
@@ -1,14 +1,15 @@ | |||
import * as React from 'react'; | |||
import {WaveSurferOptions} from 'wavesurfer.js'; | |||
import clsx from 'clsx'; | |||
import {getFormValues} from '@theoryofnekomata/formxtra'; | |||
type WaveSurferCanvasDerivedComponent = HTMLAudioElement; | |||
type SpectrogramCanvasDerivedComponent = HTMLAudioElement; | |||
export interface WaveSurferCanvasProps | |||
extends React.HTMLProps<WaveSurferCanvasDerivedComponent>, | |||
Omit<WaveSurferOptions, 'height' | 'media' | 'container' | 'fillParent' | 'url' | 'autoplay' | 'renderFunction'> {} | |||
export interface WaveformCanvasProps | |||
extends React.HTMLProps<SpectrogramCanvasDerivedComponent>, | |||
Omit<WaveSurferOptions, 'plugins' | 'height' | 'media' | 'container' | 'fillParent' | 'url' | 'autoplay' | 'renderFunction'> {} | |||
export const WaveSurferCanvas = React.forwardRef<WaveSurferCanvasDerivedComponent, WaveSurferCanvasProps>(({ | |||
export const WaveformCanvas = React.forwardRef<SpectrogramCanvasDerivedComponent, WaveformCanvasProps>(({ | |||
className, | |||
children, | |||
controls, | |||
@@ -33,7 +34,6 @@ export const WaveSurferCanvas = React.forwardRef<WaveSurferCanvasDerivedComponen | |||
sampleRate, | |||
splitChannels, | |||
normalize, | |||
plugins, | |||
...etcProps | |||
}, forwardedRef) => { | |||
const [isPlaying, setIsPlaying] = React.useState(false); | |||
@@ -44,9 +44,16 @@ export const WaveSurferCanvas = React.forwardRef<WaveSurferCanvasDerivedComponen | |||
const handleAction: React.FormEventHandler<HTMLFormElement> = (e) => { | |||
e.preventDefault(); | |||
const formData = new FormData(e.currentTarget, (e.nativeEvent as unknown as { submitter: HTMLElement }).submitter); | |||
const action = formData.get('action'); | |||
switch (action) { | |||
e.preventDefault(); | |||
const nativeEvent = e.nativeEvent as unknown as { submitter: HTMLElement }; | |||
const formData = getFormValues( | |||
e.currentTarget, | |||
{ | |||
submitter: nativeEvent.submitter, | |||
} | |||
); | |||
const actionName = formData['action'] as string; | |||
switch (actionName) { | |||
case 'togglePlayback': | |||
setIsPlaying((prev) => !prev); | |||
break; | |||
@@ -69,11 +76,9 @@ export const WaveSurferCanvas = React.forwardRef<WaveSurferCanvasDerivedComponen | |||
return; | |||
} | |||
const { default: WaveSurfer } = await import('wavesurfer.js'); | |||
//const a = await import('wavesurfer.js/dist/plugins/spectrogram'); | |||
const waveSurferInstance = WaveSurfer.create({ | |||
container: containerRef.current, | |||
height: containerRef.current.clientHeight, | |||
fillParent: true, | |||
autoplay: autoPlay, | |||
waveColor, | |||
progressColor, | |||
@@ -94,7 +99,6 @@ export const WaveSurferCanvas = React.forwardRef<WaveSurferCanvasDerivedComponen | |||
sampleRate, | |||
splitChannels, | |||
normalize, | |||
plugins, | |||
cursorWidth, | |||
media: media ?? undefined, | |||
}); | |||
@@ -141,7 +145,6 @@ export const WaveSurferCanvas = React.forwardRef<WaveSurferCanvasDerivedComponen | |||
sampleRate, | |||
splitChannels, | |||
normalize, | |||
plugins, | |||
cursorWidth, | |||
]); | |||
@@ -155,9 +158,11 @@ export const WaveSurferCanvas = React.forwardRef<WaveSurferCanvasDerivedComponen | |||
<div | |||
className="flex-auto relative" | |||
> | |||
<div className="absolute top-0 left-0 w-full h-full" | |||
ref={containerRef} | |||
/> | |||
<div className="absolute top-0 left-0 w-full h-full"> | |||
<div className="w-full h-full" | |||
ref={containerRef} | |||
/> | |||
</div> | |||
</div> | |||
{controls && ( | |||
<form | |||
@@ -176,4 +181,4 @@ export const WaveSurferCanvas = React.forwardRef<WaveSurferCanvasDerivedComponen | |||
); | |||
}); | |||
WaveSurferCanvas.displayName = 'WavesurferCanvas'; | |||
WaveformCanvas.displayName = 'WavesurferCanvas'; |
@@ -1,10 +1,2 @@ | |||
export * from './WaveSurferCanvas'; | |||
export interface WaveSurfer extends Omit<HTMLAudioElement, 'load'> { | |||
play: () => Promise<void>; | |||
pause: () => void; | |||
on: (event: string, callback: () => void) => void; | |||
seekTo: (time: number) => void; | |||
load: (url: string) => void; | |||
destroy: () => void; | |||
} | |||
export * from './WaveformCanvas'; | |||
export * from './SpectrogramCanvas'; |
@@ -3,15 +3,18 @@ import * as React from 'react'; | |||
import * as BlobReact from '@tesseract-design/web-blob-react'; | |||
import {DefaultLayout} from '@/components/DefaultLayout'; | |||
import {Section, Subsection} from '@/components/Section'; | |||
import {addDataUrl} from '@/utils/blob'; | |||
const BlobPage: NextPage = () => { | |||
const [imageFile, setImageFile] = React.useState<File>(); | |||
const [imageFile, setImageFile] = React.useState<Partial<File>>(); | |||
React.useEffect(() => { | |||
fetch('/image.png').then((response) => { | |||
response.blob().then((blob) => { | |||
setImageFile(new File([blob], 'image.png', { | |||
response.blob().then(async (blob) => { | |||
const imageFile = new File([blob], 'image.png', { | |||
type: 'image/png', | |||
})); | |||
}); | |||
const theFile = await addDataUrl(imageFile); | |||
setImageFile(theFile); | |||
}); | |||
}); | |||
}, []); | |||
@@ -43,7 +46,14 @@ const BlobPage: NextPage = () => { | |||
<Section title="ImageFilePreview"> | |||
<Subsection title="Single File"> | |||
<BlobReact.ImageFilePreview | |||
file={imageFile ?? { name: 'image.png', type: 'image/png', metadata: { previewUrl: '/image.png' } }} | |||
file={ | |||
imageFile | |||
?? { | |||
name: 'image.png', | |||
type: 'image/png', | |||
url: '/image.png', | |||
} | |||
} | |||
className="sm:h-64" | |||
/> | |||
</Subsection> | |||
@@ -116,9 +116,9 @@ export const getContentType = (mimeType?: string, filename?: string) => { | |||
return ContentType.BINARY; | |||
} | |||
export const readAsText = (blob: Blob) => blob.text(); | |||
export const readAsText = (blob: Partial<Blob>) => blob?.text?.(); | |||
export const readAsDataURL = (blob: Blob) => new Promise<string>((resolve, reject) => { | |||
export const readAsDataURL = (blob: Partial<Blob>) => new Promise<string>((resolve, reject) => { | |||
const reader = new FileReader(); | |||
reader.addEventListener('error', () => { | |||
reject(new Error('Could not read file as data URL')); | |||
@@ -132,12 +132,12 @@ export const readAsDataURL = (blob: Blob) => new Promise<string>((resolve, rejec | |||
resolve(e.target.result as string); | |||
}); | |||
reader.readAsDataURL(blob); | |||
reader.readAsDataURL(blob as Blob); | |||
}); | |||
export const readAsArrayBuffer = (blob: Blob) => blob.arrayBuffer(); | |||
interface FileWithResolvedType<T extends ContentType> extends Partial<File> { | |||
interface FileWithResolvedType<T extends ContentType> extends Partial<FileWithDataUrl> { | |||
resolvedType: T; | |||
originalFile?: File; | |||
} | |||
@@ -156,7 +156,7 @@ export interface TextFile extends FileWithResolvedType<ContentType.TEXT> { | |||
const augmentTextFile = async (f: File): Promise<TextFile> => { | |||
const contents = await readAsText(f); | |||
const metadata = getTextMetadata(contents, f.name) as TextFileMetadata; | |||
const metadata = getTextMetadata(contents ?? '', f.name) as TextFileMetadata; | |||
return { | |||
...f, | |||
name: f.name, | |||
@@ -186,22 +186,25 @@ export interface ImageFile extends FileWithResolvedType<ContentType.IMAGE> { | |||
metadata?: ImageFileMetadata; | |||
} | |||
export const augmentImageFile = async (f: File): Promise<ImageFile> => { | |||
const previewUrl = await readAsDataURL(f); | |||
const imageMetadata = await getImageMetadata(previewUrl) as ImageFileMetadata; | |||
export interface FileWithDataUrl extends File { | |||
url?: string; | |||
} | |||
export const addDataUrl = async (f: Partial<File>): Promise<Partial<FileWithDataUrl>> => { | |||
(f as unknown as Record<string, unknown>).url = await readAsDataURL(f); | |||
return f; | |||
} | |||
export const augmentImageFile = async (f: FileWithDataUrl): Promise<ImageFile> => { | |||
const imageMetadata = await getImageMetadata(f.url) as ImageFileMetadata; | |||
return { | |||
name: f.name, | |||
type: f.type, | |||
size: f.size, | |||
lastModified: f.lastModified, | |||
resolvedType: ContentType.IMAGE, | |||
originalFile: f, | |||
metadata: { | |||
previewUrl, | |||
width: imageMetadata.width, | |||
height: imageMetadata.height, | |||
palette: imageMetadata.palette, | |||
}, | |||
url: f.url, | |||
metadata: imageMetadata, | |||
}; | |||
}; | |||
@@ -1,7 +1,8 @@ | |||
import ColorThief from 'colorthief'; | |||
export const getImageMetadata = (imageUrl: string) => new Promise<Record<string, string | number | [number, number, number][]>>((resolve, reject) => { | |||
export const getImageMetadata = (imageUrl?: string) => new Promise<Record<string, string | number | [number, number, number][]>>((resolve, reject) => { | |||
const image = new Image(); | |||
image.addEventListener('load', async (imageLoadEvent) => { | |||
const thisImage = imageLoadEvent.currentTarget as HTMLImageElement; | |||
const colorThief = new ColorThief(); | |||
@@ -20,5 +21,10 @@ export const getImageMetadata = (imageUrl: string) => new Promise<Record<string, | |||
image.remove(); | |||
}); | |||
if (imageUrl === undefined) { | |||
resolve({}); | |||
return; | |||
} | |||
image.src = imageUrl; | |||
}); |