Browse Source

Implement image preview

Organize controls for image file preview component
pull/1/head
TheoryOfNekomata 1 year ago
parent
commit
6c2c54b6e5
19 changed files with 2290 additions and 377 deletions
  1. +2
    -3
      packages/web-kitchensink-reactnext/package.json
  2. +4
    -17
      packages/web-kitchensink-reactnext/pnpm-lock.yaml
  3. BIN
      packages/web-kitchensink-reactnext/public/image.png
  4. BIN
      packages/web-kitchensink-reactnext/public/video.mp4
  5. +2
    -2
      packages/web-kitchensink-reactnext/src/categories/blob/react/components/AudioFilePreview/index.tsx
  6. +2
    -2
      packages/web-kitchensink-reactnext/src/categories/blob/react/components/AudioMiniFilePreview/index.tsx
  7. +1
    -1
      packages/web-kitchensink-reactnext/src/categories/blob/react/components/BinaryFilePreview/index.tsx
  8. +76
    -40
      packages/web-kitchensink-reactnext/src/categories/blob/react/components/FileSelectBox/index.tsx
  9. +228
    -74
      packages/web-kitchensink-reactnext/src/categories/blob/react/components/ImageFilePreview/index.tsx
  10. +1
    -1
      packages/web-kitchensink-reactnext/src/categories/blob/react/components/TextFilePreview/index.tsx
  11. +2
    -217
      packages/web-kitchensink-reactnext/src/categories/blob/react/components/VideoFilePreview/index.tsx
  12. +2
    -2
      packages/web-kitchensink-reactnext/src/categories/blob/react/hooks/media/audio.ts
  13. +2
    -0
      packages/web-kitchensink-reactnext/src/categories/blob/react/hooks/media/index.ts
  14. +217
    -0
      packages/web-kitchensink-reactnext/src/categories/blob/react/hooks/media/video.ts
  15. +9
    -7
      packages/web-kitchensink-reactnext/src/categories/blob/react/index.ts
  16. +1732
    -8
      packages/web-kitchensink-reactnext/src/pages/categories/blob/index.tsx
  17. +8
    -2
      packages/web-kitchensink-reactnext/src/utils/blob.ts
  18. +1
    -1
      packages/web-kitchensink-reactnext/src/utils/image.ts
  19. +1
    -0
      packages/web-kitchensink-reactnext/tailwind.config.js

+ 2
- 3
packages/web-kitchensink-reactnext/package.json View File

@@ -26,11 +26,10 @@
"react-dom": "18.2.0", "react-dom": "18.2.0",
"tailwindcss": "3.3.2", "tailwindcss": "3.3.2",
"typescript": "5.1.3", "typescript": "5.1.3",
"wavesurfer.js": "^6.6.4"
"wavesurfer.js": "7.0.0-beta.6"
}, },
"devDependencies": { "devDependencies": {
"@types/mime-types": "^2.1.1", "@types/mime-types": "^2.1.1",
"@types/prismjs": "^1.26.0",
"@types/wavesurfer.js": "^6.0.6"
"@types/prismjs": "^1.26.0"
} }
} }

+ 4
- 17
packages/web-kitchensink-reactnext/pnpm-lock.yaml View File

@@ -57,8 +57,8 @@ dependencies:
specifier: 5.1.3 specifier: 5.1.3
version: 5.1.3 version: 5.1.3
wavesurfer.js: wavesurfer.js:
specifier: ^6.6.4
version: 6.6.4
specifier: 7.0.0-beta.6
version: 7.0.0-beta.6


devDependencies: devDependencies:
'@types/mime-types': '@types/mime-types':
@@ -67,9 +67,6 @@ devDependencies:
'@types/prismjs': '@types/prismjs':
specifier: ^1.26.0 specifier: ^1.26.0
version: 1.26.0 version: 1.26.0
'@types/wavesurfer.js':
specifier: ^6.0.6
version: 6.0.6


packages: packages:


@@ -352,10 +349,6 @@ packages:
tslib: 2.5.3 tslib: 2.5.3
dev: false dev: false


/@types/debounce@1.2.1:
resolution: {integrity: sha512-epMsEE85fi4lfmJUH/89/iV/LI+F5CvNIvmgs5g5jYFPfhO2S/ae8WSsLOKWdwtoaZw9Q2IhJ4tQ5tFCcS/4HA==}
dev: true

/@types/json5@0.0.29: /@types/json5@0.0.29:
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
dev: false dev: false
@@ -394,12 +387,6 @@ packages:
resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==} resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==}
dev: false dev: false


/@types/wavesurfer.js@6.0.6:
resolution: {integrity: sha512-fD54o0RXZXxkOb+69Rt6rGViaHpIc1Mmde2aOX9qPhlQhrCPepybGnsekiG407+7scPlaK+hmuPez5AnnmlzGg==}
dependencies:
'@types/debounce': 1.2.1
dev: true

/@typescript-eslint/parser@5.60.0(eslint@8.43.0)(typescript@5.1.3): /@typescript-eslint/parser@5.60.0(eslint@8.43.0)(typescript@5.1.3):
resolution: {integrity: sha512-jBONcBsDJ9UoTWrARkRRCgDz6wUggmH5RpQVlt7BimSwaTkTjwypGzKORXbR4/2Hqjk9hgwlon2rVQAjWNpkyQ==} resolution: {integrity: sha512-jBONcBsDJ9UoTWrARkRRCgDz6wUggmH5RpQVlt7BimSwaTkTjwypGzKORXbR4/2Hqjk9hgwlon2rVQAjWNpkyQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -2715,8 +2702,8 @@ packages:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
dev: false dev: false


/wavesurfer.js@6.6.4:
resolution: {integrity: sha512-nBbc0pD/3FdClxKUKL1UW2V9AJPL+JOjC8T6/YF9/FCAn4uo+H6Y8VBkXo9UJXIHoBewoc7iXj3tPeL0UCJhjA==}
/wavesurfer.js@7.0.0-beta.6:
resolution: {integrity: sha512-vB8J1ppZ58vozmBDmqADDdKBYY6bebSYKUgIaDXB56Qo/CpPdExSlg91tN1FAubN5swZ1IUyB8Z9xOY/TsYRoA==}
dev: false dev: false


/which-boxed-primitive@1.0.2: /which-boxed-primitive@1.0.2:


BIN
packages/web-kitchensink-reactnext/public/image.png View File

Before After
Width: 75  |  Height: 106  |  Size: 20 KiB

BIN
packages/web-kitchensink-reactnext/public/video.mp4 View File


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

@@ -1,7 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import {AudioFile, getMimeTypeDescription} from '@/utils/blob'; import {AudioFile, getMimeTypeDescription} from '@/utils/blob';
import {formatFileSize, formatNumeral, formatSecondsDurationPrecise} from '@/utils/numeral'; import {formatFileSize, formatNumeral, formatSecondsDurationPrecise} from '@/utils/numeral';
import {useAudioFilePreviewControls} from '@/categories/blob/react/hooks/audio';
import {useAudioControls} from '../../hooks/media';


export interface AudioFilePreviewProps { export interface AudioFilePreviewProps {
file: AudioFile; file: AudioFile;
@@ -14,7 +14,7 @@ export const AudioFilePreview: React.FC<AudioFilePreviewProps> = ({
mediaContainerRef, mediaContainerRef,
playMedia, playMedia,
isPlaying, isPlaying,
} = useAudioFilePreviewControls({ file: f });
} = useAudioControls({ file: f });


return ( return (
<div className="flex flex-col gap-4 w-full h-full relative"> <div className="flex flex-col gap-4 w-full h-full relative">


+ 2
- 2
packages/web-kitchensink-reactnext/src/categories/blob/react/components/AudioMiniFilePreview/index.tsx View File

@@ -1,7 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import {AudioFile, getMimeTypeDescription} from '@/utils/blob'; import {AudioFile, getMimeTypeDescription} from '@/utils/blob';
import {formatFileSize, formatNumeral, formatSecondsDurationPrecise} from '@/utils/numeral'; import {formatFileSize, formatNumeral, formatSecondsDurationPrecise} from '@/utils/numeral';
import {useAudioFilePreviewControls} from '@/categories/blob/react/hooks/audio';
import {useAudioControls} from '@tesseract-design/web-blob-react';


export interface AudioMiniFilePreviewProps { export interface AudioMiniFilePreviewProps {
file: AudioFile; file: AudioFile;
@@ -14,7 +14,7 @@ export const AudioMiniFilePreview: React.FC<AudioMiniFilePreviewProps> = ({
mediaContainerRef, mediaContainerRef,
playMedia, playMedia,
isPlaying, isPlaying,
} = useAudioFilePreviewControls({ file: f });
} = useAudioControls({ file: f });


return ( return (
<div <div


+ 1
- 1
packages/web-kitchensink-reactnext/src/categories/blob/react/components/BinaryFilePreview/index.tsx View File

@@ -9,7 +9,7 @@ export interface BinaryFilePreviewProps {
export const BinaryFilePreview: React.FC<BinaryFilePreviewProps> = ({ export const BinaryFilePreview: React.FC<BinaryFilePreviewProps> = ({
file: f, file: f,
}) => ( }) => (
<div className="flex gap-4 w-full h-full relative">
<div className="flex gap-4 w-full h-full">
<div className={`h-full w-1/3 flex-shrink-0`}> <div className={`h-full w-1/3 flex-shrink-0`}>
{ {
f.metadata && (f.metadata?.contents instanceof ArrayBuffer) f.metadata && (f.metadata?.contents instanceof ArrayBuffer)


+ 76
- 40
packages/web-kitchensink-reactnext/src/categories/blob/react/components/FileSelectBox/index.tsx View File

@@ -4,6 +4,7 @@ import { FilePreview as FilePreviewComponent} from '../FilePreview';
import {formatFileSize} from '@/utils/numeral'; import {formatFileSize} from '@/utils/numeral';
import {AudioMiniFilePreview} from '@tesseract-design/web-blob-react'; import {AudioMiniFilePreview} from '@tesseract-design/web-blob-react';
import {delegateTriggerChangeEvent} from '@/utils/event'; import {delegateTriggerChangeEvent} from '@/utils/event';
import clsx from 'clsx';


export interface FileButtonProps extends Omit<React.HTMLProps<HTMLInputElement>, 'size' | 'type' | 'style' | 'label' | 'list'> { export interface FileButtonProps extends Omit<React.HTMLProps<HTMLInputElement>, 'size' | 'type' | 'style' | 'label' | 'list'> {
/** /**
@@ -109,9 +110,8 @@ export const FileSelectBox = React.forwardRef<HTMLInputElement, FileButtonProps>
multiple = false, multiple = false,
onChange, onChange,
disabled = false, disabled = false,
className: _className,
placeholder: _placeholder,
as: _as,
className,
id: idProp,
...etcProps ...etcProps
}: FileButtonProps, }: FileButtonProps,
forwardedRef, forwardedRef,
@@ -121,6 +121,9 @@ export const FileSelectBox = React.forwardRef<HTMLInputElement, FileButtonProps>
const [lastSelectedFileAt, setLastSelectedFileAt] = React.useState<number>(); const [lastSelectedFileAt, setLastSelectedFileAt] = React.useState<number>();
const defaultRef = React.useRef<HTMLInputElement>(null); const defaultRef = React.useRef<HTMLInputElement>(null);
const ref = forwardedRef ?? defaultRef; const ref = forwardedRef ?? defaultRef;
const labelId = React.useId();
const defaultId = React.useId();
const id = idProp ?? defaultId;


const addFile: React.ChangeEventHandler<HTMLInputElement> = (e) => { const addFile: React.ChangeEventHandler<HTMLInputElement> = (e) => {
if (!enhanced) { if (!enhanced) {
@@ -153,6 +156,9 @@ export const FileSelectBox = React.forwardRef<HTMLInputElement, FileButtonProps>
const { dataTransfer } = e; const { dataTransfer } = e;
if (typeof ref === 'object' && ref.current) { if (typeof ref === 'object' && ref.current) {
const { files } = dataTransfer; const { files } = dataTransfer;
if (!(files && files.length > 0)) {
return;
}
setFileList(ref.current.files = files); setFileList(ref.current.files = files);
delegateTriggerChangeEvent(ref.current); delegateTriggerChangeEvent(ref.current);
} }
@@ -164,7 +170,11 @@ export const FileSelectBox = React.forwardRef<HTMLInputElement, FileButtonProps>


return ( return (
<div <div
className="block"
className={clsx(
'relative rounded ring-secondary/50 group',
'focus-within:ring-4',
className,
)}
onDragEnter={cancelEvent} onDragEnter={cancelEvent}
onDragOver={cancelEvent} onDragOver={cancelEvent}
onDrop={handleDropZone} onDrop={handleDropZone}
@@ -173,35 +183,41 @@ export const FileSelectBox = React.forwardRef<HTMLInputElement, FileButtonProps>
<label <label
className="block absolute top-0 left-0 w-full h-full cursor-pointer" className="block absolute top-0 left-0 w-full h-full cursor-pointer"
data-testid="clickArea" data-testid="clickArea"
>
<input
{...etcProps}
disabled={disabled}
ref={ref}
type="file"
className={`${renderEnhanced ? 'sr-only' : ''}`}
onChange={addFile}
multiple={multiple}
data-testid="input"
/>
</label>
{
border && (
<span
data-testid="border"
className="block"
/>
)
}
htmlFor={id}
/>
<input
{...etcProps}
id={id}
disabled={disabled}
ref={ref}
type="file"
className={clsx(
'peer',
{
'sr-only': renderEnhanced,
}
)}
onChange={addFile}
multiple={multiple}
data-testid="input"
aria-labelledby={label ? `${labelId}` : undefined}
/>
{ {
label
&& !hiddenLabel
&& (
label && (
<div <div
data-testid="label" data-testid="label"
className="block"
id={labelId}
className={clsx(
'absolute z-[1] w-full top-0.5 left-0 pointer-events-none pl-1 text-xxs font-bold peer-disabled:opacity-50 group-focus-within:text-secondary text-primary leading-none bg-negative',
{
'sr-only': hiddenLabel,
},
)}
> >
{label}
<div className="w-full whitespace-nowrap h-[1.1em] overflow-hidden text-ellipsis">
{label}
</div>
</div> </div>
) )
} }
@@ -243,37 +259,57 @@ export const FileSelectBox = React.forwardRef<HTMLInputElement, FileButtonProps>
className={`w-full h-full`} className={`w-full h-full`}
> >
<div data-testid="selectedFileItem" className={`h-full w-full p-4 box-border rounded overflow-hidden relative before:absolute before:content-[''] before:bg-current before:top-0 before:left-0 before:w-full before:h-full before:opacity-10`}> <div data-testid="selectedFileItem" className={`h-full w-full p-4 box-border rounded overflow-hidden relative before:absolute before:content-[''] before:bg-current before:top-0 before:left-0 before:w-full before:h-full before:opacity-10`}>
<FilePreviewComponent
fileList={fileList}
/>
<div className="relative">
<FilePreviewComponent
fileList={fileList}
/>
</div>
</div> </div>
</div> </div>
) )
} }
</div> </div>
</div> </div>
<div className="pointer-events-none absolute bottom-0 left-0 w-full text-center h-12 box-border flex">
<div className="absolute bottom-0 left-0 w-full text-center h-12 box-border flex">
<div className="w-0 flex-auto flex flex-col items-center justify-center h-full"> <div className="w-0 flex-auto flex flex-col items-center justify-center h-full">
<span
className=""
<label
data-testid="reselect"
htmlFor={id}
className="flex w-full h-full bg-negative text-primary disabled:text-primary focus:text-secondary active:text-tertiary items-center justify-center leading-none gap-4 select-none"
> >
Reselect
</span>
<span
className="block uppercase font-bold h-[1.1em] w-full whitespace-nowrap overflow-hidden text-ellipsis font-semi-expanded"
>
Reselect
</span>
</label>
</div> </div>
<div className="pointer-events-auto w-0 flex-auto flex flex-col items-center justify-center h-full">
<div className="w-0 flex-auto flex flex-col items-center justify-center h-full">
<button <button
data-testid="clear" data-testid="clear"
type="button" type="button"
onClick={deleteFiles} onClick={deleteFiles}
className=""
className="flex w-full h-full bg-negative text-primary disabled:text-primary focus:text-secondary active:text-tertiary items-center justify-center leading-none gap-4 select-none focus:outline-0"
> >
{multiple ? 'Clear' : 'Delete'}
<span
className="block uppercase font-bold h-[1.1em] w-full whitespace-nowrap overflow-hidden text-ellipsis font-semi-expanded"
>
{multiple ? 'Clear' : 'Delete'}
</span>
</button> </button>
</div> </div>
</div> </div>
</> </>
) )
} }
{
border && (
<span
data-testid="border"
className="absolute z-[1] peer-disabled:opacity-50 inset-0 rounded-inherit border-2 border-primary pointer-events-none group-focus-within:border-secondary"
/>
)
}
</div> </div>
); );
} }


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

@@ -1,93 +1,247 @@
import * as React from 'react'; import * as React from 'react';
import {getMimeTypeDescription, ImageFile} from '@/utils/blob';
import {augmentImageFile, getMimeTypeDescription, ImageFile} from '@/utils/blob';
import {formatFileSize, formatNumeral} from '@/utils/numeral'; import {formatFileSize, formatNumeral} from '@/utils/numeral';
import clsx from 'clsx';


export interface ImageFilePreviewProps {
file: ImageFile;
type ImageFilePreviewDerivedComponent = HTMLImageElement;

export interface ImageFilePreviewProps extends Omit<React.HTMLProps<ImageFilePreviewDerivedComponent>, 'src' | 'alt'> {
file?: File;
disabled?: boolean;
} }


export const ImageFilePreview: React.FC<ImageFilePreviewProps> = ({
file: f,
}) => {
const useImageFilePreview = (file?: File) => {
const [augmentedFile, setAugmentedFile] = React.useState<ImageFile>();
const [error, setError] = React.useState<Error>();

React.useEffect(() => {
if (!file) {
return;
}

augmentImageFile(file)
.then((theAugmentedFile) => {
setAugmentedFile(theAugmentedFile);
})
.catch((error) => {
setError(error);
});
}, [file]);

return React.useMemo(() => ({
augmentedFile: (augmentedFile ?? file) as ImageFile | undefined,
error,
}), [augmentedFile, file, error]);
};

interface UseImageControlsOptions {
file?: ImageFile;
actionFormKey?: string;
}

const useImageControls = (options = {} as UseImageControlsOptions) => {
const { actionFormKey = 'action' as const, file } = options;
const [fullScreen, setFullScreen] = React.useState(false); const [fullScreen, setFullScreen] = React.useState(false);


const toggleFullScreen: React.MouseEventHandler<HTMLButtonElement> = (e) => {
e.preventDefault();
const toggleFullScreen = React.useCallback(() => {
setFullScreen((b) => !b); setFullScreen((b) => !b);
};
}, []);

const download = React.useCallback(() => {
if (!file) {
return;
}

if (!file.metadata?.previewUrl) {
return;
}

const downloadLink = window.document.createElement('a');
downloadLink.download = file.name ?? 'file';
downloadLink.href = file.metadata.previewUrl;
downloadLink.addEventListener('click', () => {
downloadLink.remove();
});
downloadLink.click();
}, [file]);

const actions = React.useMemo(() => ({
toggleFullScreen,
download,
}), [toggleFullScreen, download]);

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 { [actionName]: actionFunction } = actions;
actionFunction?.();
}, [actions, actionFormKey]);

return React.useMemo(() => ({
fullScreen,
handleAction,
}), [fullScreen, handleAction]);
};

export const ImageFilePreview: React.FC<ImageFilePreviewProps> = ({
file,
className,
style,
disabled = false,
...etcProps
}) => {
const { augmentedFile, error } = useImageFilePreview(file);
const { fullScreen, handleAction } = useImageControls({
file: augmentedFile,
});

if (!augmentedFile) {
return null;
}


return ( return (
<div className="flex gap-4 w-full h-full relative">
<div className={`h-full w-1/3 flex-shrink-0`}>
{
typeof f.metadata?.previewUrl === 'string'
&& (
<button
type="button"
className={`block p-0 border-0 bg-black w-full h-full ${fullScreen ? 'fixed top-0 left-0 z-50' : ''}`.trim()}
onClick={toggleFullScreen}
>
<div
className={clsx(
'flex flex-col sm:grid sm:grid-cols-3 gap-8 w-full',
className,
)}
style={style}
>
<div className="h-full relative">
<div className="sm:absolute top-0 left-0 w-full sm:h-full">
{
typeof augmentedFile.metadata?.previewUrl === 'string'
&& (
<img <img
className={`inline-block align-top max-h-full max-w-full object-center ${fullScreen ? 'object-contain' : 'object-cover w-full'}`}
src={f.metadata.previewUrl}
alt={f.name}
{...etcProps}
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={augmentedFile.name}
data-testid="preview" data-testid="preview"
/> />
</button>
)
}
</div>
<dl className="w-2/3 flex-shrink-0 m-0" data-testid="infoBox">
<div className="w-full">
<dt className="sr-only">
Name
</dt>
<dd
className="m-0 w-full text-ellipsis overflow-hidden"
title={f.name}
>
{f.name}
</dd>
)
}
{
error
&& (
<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="w-full">
<dt className="sr-only">
Type
</dt>
<dd
title={f.type}
className="m-0 w-full text-ellipsis overflow-hidden"
>
{getMimeTypeDescription(f.type, f.name)}
</dd>
</div>
<div className="w-full">
<dt className="sr-only">
Size
</dt>
<dd
className="m-0 w-full text-ellipsis overflow-hidden"
title={`${formatNumeral(f.size ?? 0)} bytes`}
</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}
>
{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>
{
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>
<form
onSubmit={handleAction}
className="flex gap-4"
>
<fieldset
disabled={disabled || typeof error !== 'undefined'}
className="contents"
> >
{formatFileSize(f.size)}
</dd>
</div>
{
typeof f.metadata?.width === 'number'
&& typeof f.metadata?.height === 'number'
&& (
<div>
<dt className="sr-only">
Pixel Dimensions
</dt>
<dd
className="m-0 w-full text-ellipsis overflow-hidden"
<legend className="sr-only">
Controls
</legend>
<button
type="submit"
name="action"
value="toggleFullScreen"
className={clsx(
'h-12 flex bg-negative text-primary disabled:text-primary focus:text-secondary active:text-tertiary items-center justify-center leading-none gap-4 select-none',
'focus:outline-0',
'disabled:opacity-50 disabled:cursor-not-allowed',
{
'fixed top-0 left-0 w-full h-full opacity-0': fullScreen,
}
)}
>
<span
className="block uppercase font-bold h-[1.1em] w-full whitespace-nowrap overflow-hidden text-ellipsis font-semi-expanded"
>
Preview
</span>
</button>
{' '}
<button
type="submit"
name="action"
value="download"
className={clsx(
'h-12 flex bg-negative text-primary disabled:text-primary focus:text-secondary active:text-tertiary items-center justify-center leading-none gap-4 select-none',
'focus:outline-0',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
<span
className="block uppercase font-bold h-[1.1em] w-full whitespace-nowrap overflow-hidden text-ellipsis font-semi-expanded"
> >
{formatNumeral(f.metadata.width)}&times;{formatNumeral(f.metadata.height)} pixels
</dd>
</div>
)
}
</dl>
Download
</span>
</button>
</fieldset>
</form>
</div>
</div> </div>
); );
} }

+ 1
- 1
packages/web-kitchensink-reactnext/src/categories/blob/react/components/TextFilePreview/index.tsx View File

@@ -10,7 +10,7 @@ export interface TextFilePreviewProps {
export const TextFilePreview: React.FC<TextFilePreviewProps> = ({ export const TextFilePreview: React.FC<TextFilePreviewProps> = ({
file: f, file: f,
}) => ( }) => (
<div className="flex gap-4 w-full h-full relative">
<div className="flex gap-4 w-full h-full">
<div className={`h-full w-1/3 flex-shrink-0`}> <div className={`h-full w-1/3 flex-shrink-0`}>
{ {
typeof f.metadata?.contents === 'string' typeof f.metadata?.contents === 'string'


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

@@ -1,227 +1,12 @@
import * as React from 'react'; import * as React from 'react';
import {getMimeTypeDescription, VideoFile} from '@/utils/blob'; import {getMimeTypeDescription, VideoFile} from '@/utils/blob';
import {formatFileSize, formatNumeral, formatSecondsDurationConcise} from '@/utils/numeral'; import {formatFileSize, formatNumeral, formatSecondsDurationConcise} from '@/utils/numeral';
import {useVideoControls} from '@tesseract-design/web-blob-react';


export interface VideoFilePreviewProps { export interface VideoFilePreviewProps {
file: VideoFile; file: VideoFile;
} }


interface UseVideoControlsOptions {
mediaControllerRef: React.Ref<HTMLVideoElement>;
}

const useVideoControls = ({
mediaControllerRef,
}: UseVideoControlsOptions) => {
const seekRef = React.useRef<HTMLInputElement>(null);
const volumeRef = React.useRef<HTMLInputElement>(null);
const [isPlaying, setIsPlaying] = React.useState(false);
const [isSeeking, setIsSeeking] = React.useState(false);
const [currentTimeDisplay, setCurrentTimeDisplay] = React.useState<number>();
const [seekTimeDisplay, setSeekTimeDisplay] = React.useState<number>();
const [durationDisplay, setDurationDisplay] = React.useState<number>();
const [isSeekTimeCountingDown, setIsSeekTimeCountingDown] = React.useState(false);

const refreshControls: React.ReactEventHandler<HTMLVideoElement> = (e) => {
const { currentTarget: mediaController } = e;
const { current: seek } = seekRef;
if (!seek) {
return;
}

setCurrentTimeDisplay(mediaController.currentTime);
setDurationDisplay(mediaController.duration);
};

const playMedia = React.useCallback(() => {
setIsPlaying((p) => !p);
}, []);

const startSeek: React.MouseEventHandler<HTMLInputElement> = React.useCallback(() => {
setIsSeeking(true);
}, []);

const doSetSeek = (thisElement: HTMLInputElement, mediaController: HTMLVideoElement) => {
mediaController.currentTime = thisElement.valueAsNumber;
};

const setSeek: React.ChangeEventHandler<HTMLInputElement> = React.useCallback((e) => {
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) {
return;
}

const { current: mediaController } = mediaControllerRef;
if (!mediaController) {
return;
}

const { currentTarget: thisElement } = e;
setSeekTimeDisplay(thisElement.valueAsNumber);

if (isSeeking) {
return;
}

doSetSeek(thisElement, mediaController);
}, [mediaControllerRef, isSeeking]);

const endSeek: React.MouseEventHandler<HTMLInputElement> = React.useCallback((e) => {
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) {
return;
}

const { current: mediaController } = mediaControllerRef;

if (!mediaController) {
return;
}

const { currentTarget: thisElement } = e;
setIsSeeking(false);
doSetSeek(thisElement, mediaController);
}, [mediaControllerRef]);

const resetVideo: React.ReactEventHandler<HTMLVideoElement> = React.useCallback((e) => {
const videoElement = e.currentTarget;
setIsPlaying(false);
videoElement.currentTime = 0;
}, []);

const updateSeekFromPlayback: React.ReactEventHandler<HTMLVideoElement> = React.useCallback((e) => {
if (!seekRef.current) {
return;
}

if (isSeeking) {
return;
}

const { current: seek } = seekRef;

if (!seek) {
return;
}

const videoElement = e.currentTarget;
const currentTime = videoElement.currentTime;
setCurrentTimeDisplay(currentTime);
seek.value = String(currentTime);
}, [isSeeking]);

const adjustVolume: React.ChangeEventHandler<HTMLInputElement> = React.useCallback((e) => {
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) {
return;
}

if (!mediaControllerRef.current) {
return;
}
const { value } = e.currentTarget;
mediaControllerRef.current.volume = Number(value);
}, [mediaControllerRef]);

const toggleSeekTimeCountMode: React.MouseEventHandler<HTMLButtonElement> = React.useCallback((e) => {
e.preventDefault();
setIsSeekTimeCountingDown((b) => !b);
}, []);

React.useEffect(() => {
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) {
return;
}

if (!mediaControllerRef.current) {
return;
}

if (isPlaying) {
void mediaControllerRef.current.play();
return
}

mediaControllerRef.current.pause();
}, [isPlaying, mediaControllerRef]);

React.useEffect(() => {
if (!seekRef.current) {
return;
}

const { current: seek } = seekRef;
if (!seek) {
return;
}

seek.value = String(currentTimeDisplay);
}, [currentTimeDisplay]);

React.useEffect(() => {
if (!seekRef.current) {
return;
}

const { current: seek } = seekRef;
if (!seek) {
return;
}

seek.max = String(durationDisplay);
}, [durationDisplay]);

React.useEffect(() => {
if (!volumeRef.current) {
return;
}

if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) {
return;
}

if (!mediaControllerRef.current) {
return;
}

const { current: mediaController } = mediaControllerRef;
const { current: volume } = volumeRef;
volume.value = String(mediaController.volume);
}, [mediaControllerRef]);

return React.useMemo(() => ({
seekRef,
volumeRef,
isPlaying,
refreshControls,
adjustVolume,
resetVideo,
startSeek,
endSeek,
setSeek,
updateSeekFromPlayback,
playMedia,
durationDisplay,
currentTimeDisplay,
seekTimeDisplay,
isSeeking,
isSeekTimeCountingDown,
toggleSeekTimeCountMode,
}), [
isPlaying,
isSeeking,
adjustVolume,
playMedia,
resetVideo,
startSeek,
endSeek,
setSeek,
updateSeekFromPlayback,
durationDisplay,
currentTimeDisplay,
seekTimeDisplay,
isSeekTimeCountingDown,
toggleSeekTimeCountMode,
]);
};

export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePreviewProps>(({ export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePreviewProps>(({
file: f, file: f,
}, forwardedRef) => { }, forwardedRef) => {
@@ -253,7 +38,7 @@ export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePrev
const finalCurrentTimeDisplay = isSeekTimeCountingDown ? (durationDisplay - currentTimeDisplay) : currentTimeDisplay; const finalCurrentTimeDisplay = isSeekTimeCountingDown ? (durationDisplay - currentTimeDisplay) : currentTimeDisplay;


return ( return (
<div className="flex gap-4 w-full h-full relative">
<div className="flex gap-4 w-full h-full">
<div className={`h-full w-1/3 flex-shrink-0`}> <div className={`h-full w-1/3 flex-shrink-0`}>
{ {
typeof f.metadata?.previewUrl === 'string' typeof f.metadata?.previewUrl === 'string'


packages/web-kitchensink-reactnext/src/categories/blob/react/hooks/audio/index.ts → packages/web-kitchensink-reactnext/src/categories/blob/react/hooks/media/audio.ts View File

@@ -1,12 +1,12 @@
import {AudioFile} from '@/utils/blob';
import * as React from 'react'; import * as React from 'react';
import WaveSurfer from 'wavesurfer.js'; import WaveSurfer from 'wavesurfer.js';
import {AudioFile} from '@/utils/blob';


export interface UseAudioFilePreviewControlsOptions { export interface UseAudioFilePreviewControlsOptions {
file: AudioFile; file: AudioFile;
} }


export const useAudioFilePreviewControls = ({ file: f }: UseAudioFilePreviewControlsOptions) => {
export const useAudioControls = ({ file: f }: UseAudioFilePreviewControlsOptions) => {
const mediaContainerRef = React.useRef<HTMLDivElement>(null); const mediaContainerRef = React.useRef<HTMLDivElement>(null);
const mediaControllerRef = React.useRef<WaveSurfer>(null); const mediaControllerRef = React.useRef<WaveSurfer>(null);
const [isPlaying, setIsPlaying] = React.useState(false); const [isPlaying, setIsPlaying] = React.useState(false);

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

@@ -0,0 +1,2 @@
export * from './audio';
export * from './video';

+ 217
- 0
packages/web-kitchensink-reactnext/src/categories/blob/react/hooks/media/video.ts View File

@@ -0,0 +1,217 @@
import * as React from 'react';

export interface UseVideoControlsOptions {
mediaControllerRef: React.Ref<HTMLVideoElement>;
}

export const useVideoControls = ({
mediaControllerRef,
}: UseVideoControlsOptions) => {
const seekRef = React.useRef<HTMLInputElement>(null);
const volumeRef = React.useRef<HTMLInputElement>(null);
const [isPlaying, setIsPlaying] = React.useState(false);
const [isSeeking, setIsSeeking] = React.useState(false);
const [currentTimeDisplay, setCurrentTimeDisplay] = React.useState<number>();
const [seekTimeDisplay, setSeekTimeDisplay] = React.useState<number>();
const [durationDisplay, setDurationDisplay] = React.useState<number>();
const [isSeekTimeCountingDown, setIsSeekTimeCountingDown] = React.useState(false);

const refreshControls: React.ReactEventHandler<HTMLVideoElement> = (e) => {
const { currentTarget: mediaController } = e;
const { current: seek } = seekRef;
if (!seek) {
return;
}

setCurrentTimeDisplay(mediaController.currentTime);
setDurationDisplay(mediaController.duration);
};

const playMedia = React.useCallback(() => {
setIsPlaying((p) => !p);
}, []);

const startSeek: React.MouseEventHandler<HTMLInputElement> = React.useCallback(() => {
setIsSeeking(true);
}, []);

const doSetSeek = (thisElement: HTMLInputElement, mediaController: HTMLVideoElement) => {
mediaController.currentTime = thisElement.valueAsNumber;
};

const setSeek: React.ChangeEventHandler<HTMLInputElement> = React.useCallback((e) => {
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) {
return;
}

const { current: mediaController } = mediaControllerRef;
if (!mediaController) {
return;
}

const { currentTarget: thisElement } = e;
setSeekTimeDisplay(thisElement.valueAsNumber);

if (isSeeking) {
return;
}

doSetSeek(thisElement, mediaController);
}, [mediaControllerRef, isSeeking]);

const endSeek: React.MouseEventHandler<HTMLInputElement> = React.useCallback((e) => {
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) {
return;
}

const { current: mediaController } = mediaControllerRef;

if (!mediaController) {
return;
}

const { currentTarget: thisElement } = e;
setIsSeeking(false);
doSetSeek(thisElement, mediaController);
}, [mediaControllerRef]);

const resetVideo: React.ReactEventHandler<HTMLVideoElement> = React.useCallback((e) => {
const videoElement = e.currentTarget;
setIsPlaying(false);
videoElement.currentTime = 0;
}, []);

const updateSeekFromPlayback: React.ReactEventHandler<HTMLVideoElement> = React.useCallback((e) => {
if (!seekRef.current) {
return;
}

if (isSeeking) {
return;
}

const { current: seek } = seekRef;

if (!seek) {
return;
}

const videoElement = e.currentTarget;
const currentTime = videoElement.currentTime;
setCurrentTimeDisplay(currentTime);
seek.value = String(currentTime);
}, [isSeeking]);

const adjustVolume: React.ChangeEventHandler<HTMLInputElement> = React.useCallback((e) => {
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) {
return;
}

if (!mediaControllerRef.current) {
return;
}
const { value } = e.currentTarget;
mediaControllerRef.current.volume = Number(value);
}, [mediaControllerRef]);

const toggleSeekTimeCountMode: React.MouseEventHandler<HTMLButtonElement> = React.useCallback((e) => {
e.preventDefault();
setIsSeekTimeCountingDown((b) => !b);
}, []);

React.useEffect(() => {
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) {
return;
}

if (!mediaControllerRef.current) {
return;
}

if (isPlaying) {
void mediaControllerRef.current.play();
return
}

mediaControllerRef.current.pause();
}, [isPlaying, mediaControllerRef]);

React.useEffect(() => {
if (!seekRef.current) {
return;
}

const { current: seek } = seekRef;
if (!seek) {
return;
}

seek.value = String(currentTimeDisplay);
}, [currentTimeDisplay]);

React.useEffect(() => {
if (!seekRef.current) {
return;
}

const { current: seek } = seekRef;
if (!seek) {
return;
}

seek.max = String(durationDisplay);
}, [durationDisplay]);

React.useEffect(() => {
if (!volumeRef.current) {
return;
}

if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) {
return;
}

if (!mediaControllerRef.current) {
return;
}

const { current: mediaController } = mediaControllerRef;
const { current: volume } = volumeRef;
volume.value = String(mediaController.volume);
}, [mediaControllerRef]);

return React.useMemo(() => ({
seekRef,
volumeRef,
isPlaying,
refreshControls,
adjustVolume,
resetVideo,
startSeek,
endSeek,
setSeek,
updateSeekFromPlayback,
playMedia,
durationDisplay,
currentTimeDisplay,
seekTimeDisplay,
isSeeking,
isSeekTimeCountingDown,
toggleSeekTimeCountMode,
}), [
isPlaying,
isSeeking,
adjustVolume,
playMedia,
resetVideo,
startSeek,
endSeek,
setSeek,
updateSeekFromPlayback,
durationDisplay,
currentTimeDisplay,
seekTimeDisplay,
isSeekTimeCountingDown,
toggleSeekTimeCountMode,
]);
};

+ 9
- 7
packages/web-kitchensink-reactnext/src/categories/blob/react/index.ts View File

@@ -1,8 +1,10 @@
export * from './components/AudioFilePreview';
export * from './components/AudioMiniFilePreview';
export * from './components/BinaryFilePreview';
export * from './components/BinaryFilePreview';
export * from './components/FileSelectBox';
//export * from './components/AudioFilePreview';
//export * from './components/AudioMiniFilePreview';
//export * from './components/BinaryFilePreview';
//export * from './components/BinaryFilePreview';
//export * from './components/FileSelectBox';
export * from './components/ImageFilePreview'; export * from './components/ImageFilePreview';
export * from './components/TextFilePreview';
export * from './components/VideoFilePreview';
//export * from './components/TextFilePreview';
//export * from './components/VideoFilePreview';

//export * from './hooks/media';

+ 1732
- 8
packages/web-kitchensink-reactnext/src/pages/categories/blob/index.tsx
File diff suppressed because it is too large
View File


+ 8
- 2
packages/web-kitchensink-reactnext/src/utils/blob.ts View File

@@ -115,7 +115,7 @@ export const readAsText = (blob: Blob) => blob.text();
export const readAsDataURL = (blob: Blob) => new Promise<string>((resolve, reject) => { export const readAsDataURL = (blob: Blob) => new Promise<string>((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.addEventListener('error', () => { reader.addEventListener('error', () => {
reject();
reject(new Error('Could not read file as data URL'));
}); });


reader.addEventListener('load', (e) => { reader.addEventListener('load', (e) => {
@@ -133,6 +133,7 @@ export const readAsArrayBuffer = (blob: Blob) => blob.arrayBuffer();


interface FileWithResolvedType<T extends ContentType> extends Partial<File> { interface FileWithResolvedType<T extends ContentType> extends Partial<File> {
resolvedType: T; resolvedType: T;
originalFile?: File;
} }


export interface TextFileMetadata { export interface TextFileMetadata {
@@ -157,6 +158,7 @@ const augmentTextFile = async (f: File): Promise<TextFile> => {
size: f.size, size: f.size,
lastModified: f.lastModified, lastModified: f.lastModified,
resolvedType: ContentType.TEXT, resolvedType: ContentType.TEXT,
originalFile: f,
metadata: { metadata: {
contents, contents,
language: metadata.language, language: metadata.language,
@@ -177,7 +179,7 @@ export interface ImageFile extends FileWithResolvedType<ContentType.IMAGE> {
metadata?: ImageFileMetadata; metadata?: ImageFileMetadata;
} }


const augmentImageFile = async (f: File): Promise<ImageFile> => {
export const augmentImageFile = async (f: File): Promise<ImageFile> => {
const previewUrl = await readAsDataURL(f); const previewUrl = await readAsDataURL(f);
const imageMetadata = await getImageMetadata(previewUrl) as ImageFileMetadata; const imageMetadata = await getImageMetadata(previewUrl) as ImageFileMetadata;
return { return {
@@ -186,6 +188,7 @@ const augmentImageFile = async (f: File): Promise<ImageFile> => {
size: f.size, size: f.size,
lastModified: f.lastModified, lastModified: f.lastModified,
resolvedType: ContentType.IMAGE, resolvedType: ContentType.IMAGE,
originalFile: f,
metadata: { metadata: {
previewUrl, previewUrl,
width: imageMetadata.width, width: imageMetadata.width,
@@ -212,6 +215,7 @@ const augmentAudioFile = async (f: File): Promise<AudioFile> => {
size: f.size, size: f.size,
lastModified: f.lastModified, lastModified: f.lastModified,
resolvedType: ContentType.AUDIO, resolvedType: ContentType.AUDIO,
originalFile: f,
metadata: { metadata: {
previewUrl, previewUrl,
duration: audioExtensions.duration, duration: audioExtensions.duration,
@@ -235,6 +239,7 @@ const augmentBinaryFile = async (f: File): Promise<BinaryFile> => {
size: f.size, size: f.size,
lastModified: f.lastModified, lastModified: f.lastModified,
resolvedType: ContentType.BINARY, resolvedType: ContentType.BINARY,
originalFile: f,
metadata: { metadata: {
contents: arrayBuffer, contents: arrayBuffer,
}, },
@@ -259,6 +264,7 @@ const augmentVideoFile = async (f: File): Promise<VideoFile> => {
size: f.size, size: f.size,
lastModified: f.lastModified, lastModified: f.lastModified,
resolvedType: ContentType.VIDEO, resolvedType: ContentType.VIDEO,
originalFile: f,
metadata: { metadata: {
previewUrl, previewUrl,
}, },


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

@@ -11,7 +11,7 @@ export const getImageMetadata = (imageUrl: string) => new Promise<Record<string,
}); });


image.addEventListener('error', () => { image.addEventListener('error', () => {
reject();
reject(new Error('Could not load file as image'));
image.remove(); image.remove();
}); });




+ 1
- 0
packages/web-kitchensink-reactnext/tailwind.config.js View File

@@ -29,6 +29,7 @@ module.exports = {
'code-url': 'rgb(var(--color-code-url))', 'code-url': 'rgb(var(--color-code-url))',
'code-global': 'rgb(var(--color-code-global))', 'code-global': 'rgb(var(--color-code-global))',
'current': 'currentcolor', 'current': 'currentcolor',
'inherit': 'inherit',
}, },
extend: { extend: {
fontSize: { fontSize: {


Loading…
Cancel
Save