@@ -10,6 +10,7 @@ | |||||
}, | }, | ||||
"dependencies": { | "dependencies": { | ||||
"@reach/slider": "^0.18.0", | "@reach/slider": "^0.18.0", | ||||
"@theoryofnekomata/formxtra": "^1.0.3", | |||||
"@types/node": "20.3.1", | "@types/node": "20.3.1", | ||||
"@types/react": "18.2.14", | "@types/react": "18.2.14", | ||||
"@types/react-dom": "18.2.6", | "@types/react-dom": "18.2.6", | ||||
@@ -1,4 +1,4 @@ | |||||
lockfileVersion: '6.0' | |||||
lockfileVersion: '6.1' | |||||
settings: | settings: | ||||
autoInstallPeers: true | autoInstallPeers: true | ||||
@@ -8,6 +8,9 @@ dependencies: | |||||
'@reach/slider': | '@reach/slider': | ||||
specifier: ^0.18.0 | specifier: ^0.18.0 | ||||
version: 0.18.0(react-dom@18.2.0)(react@18.2.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': | '@types/node': | ||||
specifier: 20.3.1 | specifier: 20.3.1 | ||||
version: 20.3.1 | version: 20.3.1 | ||||
@@ -359,6 +362,11 @@ packages: | |||||
tslib: 2.5.3 | tslib: 2.5.3 | ||||
dev: false | dev: false | ||||
/@theoryofnekomata/formxtra@1.0.3: | |||||
resolution: {integrity: sha512-xOzE07Slttpx7vbOWqXfatJ+k44TN4zUjI57A5/sNqUDtHzp3pz94A+AVPGVoBY0QXiwzMjeN4DPMp6U1qlkyg==} | |||||
engines: {node: '>=10'} | |||||
dev: false | |||||
/@types/debounce@1.2.1: | /@types/debounce@1.2.1: | ||||
resolution: {integrity: sha512-epMsEE85fi4lfmJUH/89/iV/LI+F5CvNIvmgs5g5jYFPfhO2S/ae8WSsLOKWdwtoaZw9Q2IhJ4tQ5tFCcS/4HA==} | resolution: {integrity: sha512-epMsEE85fi4lfmJUH/89/iV/LI+F5CvNIvmgs5g5jYFPfhO2S/ae8WSsLOKWdwtoaZw9Q2IhJ4tQ5tFCcS/4HA==} | ||||
dev: true | 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'; | } from '@/utils/numeral'; | ||||
import theme from '@/styles/theme'; | import theme from '@/styles/theme'; | ||||
import {useMediaControls} from '../../hooks/interactive'; | import {useMediaControls} from '../../hooks/interactive'; | ||||
import {useAugmentedFile} from '@/categories/blob/react'; | |||||
import {useFileMetadata} from '@/categories/blob/react'; | |||||
import clsx from 'clsx'; | import clsx from 'clsx'; | ||||
import {WaveSurferCanvas} from '@/packages/react-wavesurfer'; | |||||
import {SpectrogramCanvas, WaveformCanvas} from '@/packages/react-wavesurfer'; | |||||
import {Slider} from '@/categories/number/react'; | import {Slider} from '@/categories/number/react'; | ||||
import {KeyValueTable} from '@/categories/information/react'; | |||||
type AudioFilePreviewDerivedComponent = HTMLAudioElement; | type AudioFilePreviewDerivedComponent = HTMLAudioElement; | ||||
@@ -30,7 +31,7 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||||
disabled = false, | disabled = false, | ||||
...etcProps | ...etcProps | ||||
}, forwardedRef) => { | }, forwardedRef) => { | ||||
const { augmentedFile, error } = useAugmentedFile({ | |||||
const { augmentedFile, error } = useFileMetadata({ | |||||
file, | file, | ||||
augmentFunction: augmentAudioFile, | augmentFunction: augmentAudioFile, | ||||
}); | }); | ||||
@@ -99,19 +100,42 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||||
type={augmentedFile.type} | type={augmentedFile.type} | ||||
/> | /> | ||||
</audio> | </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 | <label | ||||
className={clsx( | className={clsx( | ||||
'h-12 flex items-center justify-center leading-none gap-4 select-none', | 'h-12 flex items-center justify-center leading-none gap-4 select-none', | ||||
@@ -278,58 +302,50 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||||
<div | <div | ||||
className="flex-shrink-0 m-0 flex flex-col gap-4 justify-between" | 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' | 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 | <form | ||||
id={formId} | id={formId} | ||||
onSubmit={handleAction} | onSubmit={handleAction} | ||||
@@ -1,13 +1,14 @@ | |||||
import * as React from 'react'; | 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 {formatFileSize, formatNumeral} from '@/utils/numeral'; | ||||
import clsx from 'clsx'; | 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; | type ImageFilePreviewDerivedComponent = HTMLImageElement; | ||||
export interface ImageFilePreviewProps extends Omit<React.HTMLProps<ImageFilePreviewDerivedComponent>, 'src' | 'alt'> { | export interface ImageFilePreviewProps extends Omit<React.HTMLProps<ImageFilePreviewDerivedComponent>, 'src' | 'alt'> { | ||||
file?: File | FallbackFile; | |||||
file?: Partial<FileWithDataUrl>; | |||||
disabled?: boolean; | disabled?: boolean; | ||||
} | } | ||||
@@ -18,8 +19,9 @@ export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponen | |||||
disabled = false, | disabled = false, | ||||
...etcProps | ...etcProps | ||||
}, forwardedRef) => { | }, 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, | augmentFunction: augmentImageFile, | ||||
}); | }); | ||||
const { | const { | ||||
@@ -35,7 +37,10 @@ export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponen | |||||
return null; | return null; | ||||
} | } | ||||
console.log(augmentedFile); | |||||
const cannotDisplayPicture = Boolean( | |||||
typeof augmentedFile.url !== 'string' | |||||
&& error | |||||
); | |||||
return ( | return ( | ||||
<div | <div | ||||
@@ -47,112 +52,85 @@ export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponen | |||||
> | > | ||||
<div className="h-full relative"> | <div className="h-full relative"> | ||||
<div className="sm:absolute top-0 left-0 w-full sm:h-full z-[3]"> | <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> | </div> | ||||
<div | <div | ||||
className="col-span-2 flex-shrink-0 m-0 flex flex-col gap-4 justify-between" | 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?.width === 'number' | ||||
&& typeof augmentedFile.metadata?.height === '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) | 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 | <span | ||||
className="inline-block w-5 h-5 align-middle border border-[#ffffff] ring-1 ring-[#000000]" | className="inline-block w-5 h-5 align-middle border border-[#ffffff] ring-1 ring-[#000000]" | ||||
style={{ | style={{ | ||||
@@ -162,31 +140,38 @@ export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponen | |||||
<span className="tabular-nums text-xs sr-only"> | <span className="tabular-nums text-xs sr-only"> | ||||
{ | { | ||||
rgb | 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() | .flat() | ||||
} | } | ||||
</span> | </span> | ||||
</span> | </span> | ||||
</> | |||||
))} | |||||
</dd> | |||||
</div> | |||||
) | |||||
} | |||||
</dl> | |||||
</React.Fragment> | |||||
)), | |||||
}, | |||||
}, | |||||
]} | |||||
/> | |||||
<form | <form | ||||
onSubmit={handleAction} | onSubmit={handleAction} | ||||
className="flex gap-4" | className="flex gap-4" | ||||
> | > | ||||
<fieldset | <fieldset | ||||
disabled={disabled || typeof error !== 'undefined'} | |||||
disabled={disabled || cannotDisplayPicture} | |||||
className="contents" | className="contents" | ||||
> | > | ||||
<legend className="sr-only"> | <legend className="sr-only"> | ||||
@@ -1,9 +1,10 @@ | |||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import {augmentVideoFile, getMimeTypeDescription} from '@/utils/blob'; | import {augmentVideoFile, getMimeTypeDescription} from '@/utils/blob'; | ||||
import {formatFileSize, formatNumeral, formatSecondsDurationConcise} from '@/utils/numeral'; | 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 clsx from 'clsx'; | ||||
import {Slider} from '@tesseract-design/web-number-react'; | import {Slider} from '@tesseract-design/web-number-react'; | ||||
import {KeyValueTable} from '@/categories/information/react'; | |||||
type VideoFilePreviewDerivedComponent = HTMLVideoElement; | type VideoFilePreviewDerivedComponent = HTMLVideoElement; | ||||
@@ -21,7 +22,7 @@ export const VideoFilePreview = React.forwardRef<VideoFilePreviewDerivedComponen | |||||
enhanced = false, | enhanced = false, | ||||
...etcProps | ...etcProps | ||||
}, forwardedRef) => { | }, forwardedRef) => { | ||||
const { augmentedFile, error } = useAugmentedFile({ | |||||
const { augmentedFile, error } = useFileMetadata({ | |||||
file, | file, | ||||
augmentFunction: augmentVideoFile, | augmentFunction: augmentVideoFile, | ||||
}); | }); | ||||
@@ -220,58 +221,47 @@ export const VideoFilePreview = React.forwardRef<VideoFilePreviewDerivedComponen | |||||
<div | <div | ||||
className="flex-shrink-0 m-0 flex flex-col gap-4 justify-between" | 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?.width === 'number' | ||||
&& typeof augmentedFile.metadata?.height === '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 | <form | ||||
id={formId} | id={formId} | ||||
onSubmit={handleAction} | onSubmit={handleAction} | ||||
@@ -1,32 +1,74 @@ | |||||
import * as React from 'react'; | 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; | file?: File; | ||||
augmentFunction: (file: File) => Promise<T>; | 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 { 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>(); | const [error, setError] = React.useState<Error>(); | ||||
React.useEffect(() => { | React.useEffect(() => { | ||||
if (!file) { | if (!file) { | ||||
setFileWithMetadata(undefined); | |||||
setLoading(false); | |||||
return; | return; | ||||
} | } | ||||
setLoading(true); | |||||
setError(undefined); | setError(undefined); | ||||
augmentFunction(file) | augmentFunction(file) | ||||
.then((theAugmentedFile) => { | .then((theAugmentedFile) => { | ||||
setAugmentedFile(theAugmentedFile); | |||||
setFileWithMetadata(theAugmentedFile); | |||||
setLoading(false); | |||||
}) | }) | ||||
.catch((error) => { | .catch((error) => { | ||||
setError(error); | setError(error); | ||||
setLoading(false); | |||||
}); | }); | ||||
}, [file, augmentFunction]); | }, [file, augmentFunction]); | ||||
return React.useMemo(() => ({ | return React.useMemo(() => ({ | ||||
augmentedFile: (augmentedFile ?? file) as T | undefined, | |||||
augmentedFile: fileWithMetadata, | |||||
error, | error, | ||||
}), [augmentedFile, file, error]); | |||||
loading, | |||||
}), [fileWithMetadata, loading, error]); | |||||
}; | }; |
@@ -1,4 +1,5 @@ | |||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import { getFormValues } from '@theoryofnekomata/formxtra'; | |||||
export interface UseImageControlsOptions { | export interface UseImageControlsOptions { | ||||
actionFormKey?: string; | actionFormKey?: string; | ||||
@@ -41,8 +42,14 @@ export const useImageControls = (options = {} as UseImageControlsOptions) => { | |||||
const handleAction: React.FormEventHandler<HTMLFormElement> = React.useCallback((e) => { | const handleAction: React.FormEventHandler<HTMLFormElement> = React.useCallback((e) => { | ||||
e.preventDefault(); | 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; | const { [actionName]: actionFunction } = actions; | ||||
actionFunction?.(); | actionFunction?.(); | ||||
}, [actions, actionFormKey]); | }, [actions, actionFormKey]); | ||||
@@ -1,4 +1,5 @@ | |||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import {getFormValues} from '@theoryofnekomata/formxtra'; | |||||
export interface UseMediaControlsOptions<T extends HTMLMediaElement> { | export interface UseMediaControlsOptions<T extends HTMLMediaElement> { | ||||
controllerRef: React.Ref<T>; | controllerRef: React.Ref<T>; | ||||
@@ -138,8 +139,15 @@ export const useMediaControls = <T extends HTMLMediaElement>({ | |||||
const handleAction: React.FormEventHandler<HTMLFormElement> = React.useCallback((e) => { | const handleAction: React.FormEventHandler<HTMLFormElement> = React.useCallback((e) => { | ||||
e.preventDefault(); | 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; | const { [actionName]: actionFunction } = actions; | ||||
actionFunction?.(); | actionFunction?.(); | ||||
}, [actions, actionFormKey]); | }, [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/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 * as React from 'react'; | ||||
import {WaveSurferOptions} from 'wavesurfer.js'; | import {WaveSurferOptions} from 'wavesurfer.js'; | ||||
import clsx from 'clsx'; | 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, | className, | ||||
children, | children, | ||||
controls, | controls, | ||||
@@ -33,7 +34,6 @@ export const WaveSurferCanvas = React.forwardRef<WaveSurferCanvasDerivedComponen | |||||
sampleRate, | sampleRate, | ||||
splitChannels, | splitChannels, | ||||
normalize, | normalize, | ||||
plugins, | |||||
...etcProps | ...etcProps | ||||
}, forwardedRef) => { | }, forwardedRef) => { | ||||
const [isPlaying, setIsPlaying] = React.useState(false); | const [isPlaying, setIsPlaying] = React.useState(false); | ||||
@@ -44,9 +44,16 @@ export const WaveSurferCanvas = React.forwardRef<WaveSurferCanvasDerivedComponen | |||||
const handleAction: React.FormEventHandler<HTMLFormElement> = (e) => { | const handleAction: React.FormEventHandler<HTMLFormElement> = (e) => { | ||||
e.preventDefault(); | 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': | case 'togglePlayback': | ||||
setIsPlaying((prev) => !prev); | setIsPlaying((prev) => !prev); | ||||
break; | break; | ||||
@@ -69,11 +76,9 @@ export const WaveSurferCanvas = React.forwardRef<WaveSurferCanvasDerivedComponen | |||||
return; | return; | ||||
} | } | ||||
const { default: WaveSurfer } = await import('wavesurfer.js'); | const { default: WaveSurfer } = await import('wavesurfer.js'); | ||||
//const a = await import('wavesurfer.js/dist/plugins/spectrogram'); | |||||
const waveSurferInstance = WaveSurfer.create({ | const waveSurferInstance = WaveSurfer.create({ | ||||
container: containerRef.current, | container: containerRef.current, | ||||
height: containerRef.current.clientHeight, | height: containerRef.current.clientHeight, | ||||
fillParent: true, | |||||
autoplay: autoPlay, | autoplay: autoPlay, | ||||
waveColor, | waveColor, | ||||
progressColor, | progressColor, | ||||
@@ -94,7 +99,6 @@ export const WaveSurferCanvas = React.forwardRef<WaveSurferCanvasDerivedComponen | |||||
sampleRate, | sampleRate, | ||||
splitChannels, | splitChannels, | ||||
normalize, | normalize, | ||||
plugins, | |||||
cursorWidth, | cursorWidth, | ||||
media: media ?? undefined, | media: media ?? undefined, | ||||
}); | }); | ||||
@@ -141,7 +145,6 @@ export const WaveSurferCanvas = React.forwardRef<WaveSurferCanvasDerivedComponen | |||||
sampleRate, | sampleRate, | ||||
splitChannels, | splitChannels, | ||||
normalize, | normalize, | ||||
plugins, | |||||
cursorWidth, | cursorWidth, | ||||
]); | ]); | ||||
@@ -155,9 +158,11 @@ export const WaveSurferCanvas = React.forwardRef<WaveSurferCanvasDerivedComponen | |||||
<div | <div | ||||
className="flex-auto relative" | 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> | </div> | ||||
{controls && ( | {controls && ( | ||||
<form | <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 * as BlobReact from '@tesseract-design/web-blob-react'; | ||||
import {DefaultLayout} from '@/components/DefaultLayout'; | import {DefaultLayout} from '@/components/DefaultLayout'; | ||||
import {Section, Subsection} from '@/components/Section'; | import {Section, Subsection} from '@/components/Section'; | ||||
import {addDataUrl} from '@/utils/blob'; | |||||
const BlobPage: NextPage = () => { | const BlobPage: NextPage = () => { | ||||
const [imageFile, setImageFile] = React.useState<File>(); | |||||
const [imageFile, setImageFile] = React.useState<Partial<File>>(); | |||||
React.useEffect(() => { | React.useEffect(() => { | ||||
fetch('/image.png').then((response) => { | 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', | type: 'image/png', | ||||
})); | |||||
}); | |||||
const theFile = await addDataUrl(imageFile); | |||||
setImageFile(theFile); | |||||
}); | }); | ||||
}); | }); | ||||
}, []); | }, []); | ||||
@@ -43,7 +46,14 @@ const BlobPage: NextPage = () => { | |||||
<Section title="ImageFilePreview"> | <Section title="ImageFilePreview"> | ||||
<Subsection title="Single File"> | <Subsection title="Single File"> | ||||
<BlobReact.ImageFilePreview | <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" | className="sm:h-64" | ||||
/> | /> | ||||
</Subsection> | </Subsection> | ||||
@@ -116,9 +116,9 @@ export const getContentType = (mimeType?: string, filename?: string) => { | |||||
return ContentType.BINARY; | 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(); | const reader = new FileReader(); | ||||
reader.addEventListener('error', () => { | reader.addEventListener('error', () => { | ||||
reject(new Error('Could not read file as data URL')); | 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); | resolve(e.target.result as string); | ||||
}); | }); | ||||
reader.readAsDataURL(blob); | |||||
reader.readAsDataURL(blob as Blob); | |||||
}); | }); | ||||
export const readAsArrayBuffer = (blob: Blob) => blob.arrayBuffer(); | 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; | resolvedType: T; | ||||
originalFile?: File; | originalFile?: File; | ||||
} | } | ||||
@@ -156,7 +156,7 @@ export interface TextFile extends FileWithResolvedType<ContentType.TEXT> { | |||||
const augmentTextFile = async (f: File): Promise<TextFile> => { | const augmentTextFile = async (f: File): Promise<TextFile> => { | ||||
const contents = await readAsText(f); | const contents = await readAsText(f); | ||||
const metadata = getTextMetadata(contents, f.name) as TextFileMetadata; | |||||
const metadata = getTextMetadata(contents ?? '', f.name) as TextFileMetadata; | |||||
return { | return { | ||||
...f, | ...f, | ||||
name: f.name, | name: f.name, | ||||
@@ -186,22 +186,25 @@ export interface ImageFile extends FileWithResolvedType<ContentType.IMAGE> { | |||||
metadata?: ImageFileMetadata; | 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 { | return { | ||||
name: f.name, | name: f.name, | ||||
type: f.type, | type: f.type, | ||||
size: f.size, | size: f.size, | ||||
lastModified: f.lastModified, | lastModified: f.lastModified, | ||||
resolvedType: ContentType.IMAGE, | 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'; | 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(); | const image = new Image(); | ||||
image.addEventListener('load', async (imageLoadEvent) => { | image.addEventListener('load', async (imageLoadEvent) => { | ||||
const thisImage = imageLoadEvent.currentTarget as HTMLImageElement; | const thisImage = imageLoadEvent.currentTarget as HTMLImageElement; | ||||
const colorThief = new ColorThief(); | const colorThief = new ColorThief(); | ||||
@@ -20,5 +21,10 @@ export const getImageMetadata = (imageUrl: string) => new Promise<Record<string, | |||||
image.remove(); | image.remove(); | ||||
}); | }); | ||||
if (imageUrl === undefined) { | |||||
resolve({}); | |||||
return; | |||||
} | |||||
image.src = imageUrl; | image.src = imageUrl; | ||||
}); | }); |