Browse Source

Update audio file preview component

Add spectrum view.
pull/1/head
TheoryOfNekomata 1 year ago
parent
commit
49cdad2e9f
17 changed files with 675 additions and 299 deletions
  1. +1
    -0
      packages/web-kitchensink-reactnext/package.json
  2. +9
    -1
      packages/web-kitchensink-reactnext/pnpm-lock.yaml
  3. +5
    -1
      packages/web-kitchensink-reactnext/public/next.svg
  4. +83
    -67
      packages/web-kitchensink-reactnext/src/categories/blob/react/components/AudioFilePreview/index.tsx
  5. +105
    -120
      packages/web-kitchensink-reactnext/src/categories/blob/react/components/ImageFilePreview/index.tsx
  6. +42
    -52
      packages/web-kitchensink-reactnext/src/categories/blob/react/components/VideoFilePreview/index.tsx
  7. +48
    -6
      packages/web-kitchensink-reactnext/src/categories/blob/react/hooks/blob/metadata.ts
  8. +9
    -2
      packages/web-kitchensink-reactnext/src/categories/blob/react/hooks/interactive/image.ts
  9. +10
    -2
      packages/web-kitchensink-reactnext/src/categories/blob/react/hooks/interactive/media.ts
  10. +57
    -0
      packages/web-kitchensink-reactnext/src/categories/information/react/components/KeyValueTable/index.tsx
  11. +1
    -0
      packages/web-kitchensink-reactnext/src/categories/information/react/index.ts
  12. +241
    -0
      packages/web-kitchensink-reactnext/src/packages/react-wavesurfer/SpectrogramCanvas/index.tsx
  13. +22
    -17
      packages/web-kitchensink-reactnext/src/packages/react-wavesurfer/WaveformCanvas/index.tsx
  14. +2
    -10
      packages/web-kitchensink-reactnext/src/packages/react-wavesurfer/index.ts
  15. +15
    -5
      packages/web-kitchensink-reactnext/src/pages/categories/blob/index.tsx
  16. +18
    -15
      packages/web-kitchensink-reactnext/src/utils/blob.ts
  17. +7
    -1
      packages/web-kitchensink-reactnext/src/utils/image.ts

+ 1
- 0
packages/web-kitchensink-reactnext/package.json View File

@@ -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",


+ 9
- 1
packages/web-kitchensink-reactnext/pnpm-lock.yaml View File

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


+ 5
- 1
packages/web-kitchensink-reactnext/public/next.svg View File

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

+ 83
- 67
packages/web-kitchensink-reactnext/src/categories/blob/react/components/AudioFilePreview/index.tsx View File

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


+ 105
- 120
packages/web-kitchensink-reactnext/src/categories/blob/react/components/ImageFilePreview/index.tsx View File

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


+ 42
- 52
packages/web-kitchensink-reactnext/src/categories/blob/react/components/VideoFilePreview/index.tsx View File

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


+ 48
- 6
packages/web-kitchensink-reactnext/src/categories/blob/react/hooks/blob/metadata.ts View File

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

+ 9
- 2
packages/web-kitchensink-reactnext/src/categories/blob/react/hooks/interactive/image.ts View File

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


+ 10
- 2
packages/web-kitchensink-reactnext/src/categories/blob/react/hooks/interactive/media.ts View File

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


+ 57
- 0
packages/web-kitchensink-reactnext/src/categories/information/react/components/KeyValueTable/index.tsx View File

@@ -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
- 0
packages/web-kitchensink-reactnext/src/categories/information/react/index.ts View File

@@ -1 +1,2 @@
export * from './components/Badge';
export * from './components/KeyValueTable';

+ 241
- 0
packages/web-kitchensink-reactnext/src/packages/react-wavesurfer/SpectrogramCanvas/index.tsx View File

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

packages/web-kitchensink-reactnext/src/packages/react-wavesurfer/WaveSurferCanvas/index.tsx → packages/web-kitchensink-reactnext/src/packages/react-wavesurfer/WaveformCanvas/index.tsx View File

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

+ 2
- 10
packages/web-kitchensink-reactnext/src/packages/react-wavesurfer/index.ts View File

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

+ 15
- 5
packages/web-kitchensink-reactnext/src/pages/categories/blob/index.tsx View File

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


+ 18
- 15
packages/web-kitchensink-reactnext/src/utils/blob.ts View File

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



+ 7
- 1
packages/web-kitchensink-reactnext/src/utils/image.ts View File

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

Loading…
Cancel
Save