Organize controls for image file preview componentpull/1/head
@@ -26,11 +26,10 @@ | |||
"react-dom": "18.2.0", | |||
"tailwindcss": "3.3.2", | |||
"typescript": "5.1.3", | |||
"wavesurfer.js": "^6.6.4" | |||
"wavesurfer.js": "7.0.0-beta.6" | |||
}, | |||
"devDependencies": { | |||
"@types/mime-types": "^2.1.1", | |||
"@types/prismjs": "^1.26.0", | |||
"@types/wavesurfer.js": "^6.0.6" | |||
"@types/prismjs": "^1.26.0" | |||
} | |||
} |
@@ -57,8 +57,8 @@ dependencies: | |||
specifier: 5.1.3 | |||
version: 5.1.3 | |||
wavesurfer.js: | |||
specifier: ^6.6.4 | |||
version: 6.6.4 | |||
specifier: 7.0.0-beta.6 | |||
version: 7.0.0-beta.6 | |||
devDependencies: | |||
'@types/mime-types': | |||
@@ -67,9 +67,6 @@ devDependencies: | |||
'@types/prismjs': | |||
specifier: ^1.26.0 | |||
version: 1.26.0 | |||
'@types/wavesurfer.js': | |||
specifier: ^6.0.6 | |||
version: 6.0.6 | |||
packages: | |||
@@ -352,10 +349,6 @@ packages: | |||
tslib: 2.5.3 | |||
dev: false | |||
/@types/debounce@1.2.1: | |||
resolution: {integrity: sha512-epMsEE85fi4lfmJUH/89/iV/LI+F5CvNIvmgs5g5jYFPfhO2S/ae8WSsLOKWdwtoaZw9Q2IhJ4tQ5tFCcS/4HA==} | |||
dev: true | |||
/@types/json5@0.0.29: | |||
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} | |||
dev: false | |||
@@ -394,12 +387,6 @@ packages: | |||
resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==} | |||
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): | |||
resolution: {integrity: sha512-jBONcBsDJ9UoTWrARkRRCgDz6wUggmH5RpQVlt7BimSwaTkTjwypGzKORXbR4/2Hqjk9hgwlon2rVQAjWNpkyQ==} | |||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |||
@@ -2715,8 +2702,8 @@ packages: | |||
graceful-fs: 4.2.11 | |||
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 | |||
/which-boxed-primitive@1.0.2: | |||
@@ -1,7 +1,7 @@ | |||
import * as React from 'react'; | |||
import {AudioFile, getMimeTypeDescription} from '@/utils/blob'; | |||
import {formatFileSize, formatNumeral, formatSecondsDurationPrecise} from '@/utils/numeral'; | |||
import {useAudioFilePreviewControls} from '@/categories/blob/react/hooks/audio'; | |||
import {useAudioControls} from '../../hooks/media'; | |||
export interface AudioFilePreviewProps { | |||
file: AudioFile; | |||
@@ -14,7 +14,7 @@ export const AudioFilePreview: React.FC<AudioFilePreviewProps> = ({ | |||
mediaContainerRef, | |||
playMedia, | |||
isPlaying, | |||
} = useAudioFilePreviewControls({ file: f }); | |||
} = useAudioControls({ file: f }); | |||
return ( | |||
<div className="flex flex-col gap-4 w-full h-full relative"> | |||
@@ -1,7 +1,7 @@ | |||
import * as React from 'react'; | |||
import {AudioFile, getMimeTypeDescription} from '@/utils/blob'; | |||
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 { | |||
file: AudioFile; | |||
@@ -14,7 +14,7 @@ export const AudioMiniFilePreview: React.FC<AudioMiniFilePreviewProps> = ({ | |||
mediaContainerRef, | |||
playMedia, | |||
isPlaying, | |||
} = useAudioFilePreviewControls({ file: f }); | |||
} = useAudioControls({ file: f }); | |||
return ( | |||
<div | |||
@@ -9,7 +9,7 @@ export interface BinaryFilePreviewProps { | |||
export const BinaryFilePreview: React.FC<BinaryFilePreviewProps> = ({ | |||
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`}> | |||
{ | |||
f.metadata && (f.metadata?.contents instanceof ArrayBuffer) | |||
@@ -4,6 +4,7 @@ import { FilePreview as FilePreviewComponent} from '../FilePreview'; | |||
import {formatFileSize} from '@/utils/numeral'; | |||
import {AudioMiniFilePreview} from '@tesseract-design/web-blob-react'; | |||
import {delegateTriggerChangeEvent} from '@/utils/event'; | |||
import clsx from 'clsx'; | |||
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, | |||
onChange, | |||
disabled = false, | |||
className: _className, | |||
placeholder: _placeholder, | |||
as: _as, | |||
className, | |||
id: idProp, | |||
...etcProps | |||
}: FileButtonProps, | |||
forwardedRef, | |||
@@ -121,6 +121,9 @@ export const FileSelectBox = React.forwardRef<HTMLInputElement, FileButtonProps> | |||
const [lastSelectedFileAt, setLastSelectedFileAt] = React.useState<number>(); | |||
const defaultRef = React.useRef<HTMLInputElement>(null); | |||
const ref = forwardedRef ?? defaultRef; | |||
const labelId = React.useId(); | |||
const defaultId = React.useId(); | |||
const id = idProp ?? defaultId; | |||
const addFile: React.ChangeEventHandler<HTMLInputElement> = (e) => { | |||
if (!enhanced) { | |||
@@ -153,6 +156,9 @@ export const FileSelectBox = React.forwardRef<HTMLInputElement, FileButtonProps> | |||
const { dataTransfer } = e; | |||
if (typeof ref === 'object' && ref.current) { | |||
const { files } = dataTransfer; | |||
if (!(files && files.length > 0)) { | |||
return; | |||
} | |||
setFileList(ref.current.files = files); | |||
delegateTriggerChangeEvent(ref.current); | |||
} | |||
@@ -164,7 +170,11 @@ export const FileSelectBox = React.forwardRef<HTMLInputElement, FileButtonProps> | |||
return ( | |||
<div | |||
className="block" | |||
className={clsx( | |||
'relative rounded ring-secondary/50 group', | |||
'focus-within:ring-4', | |||
className, | |||
)} | |||
onDragEnter={cancelEvent} | |||
onDragOver={cancelEvent} | |||
onDrop={handleDropZone} | |||
@@ -173,35 +183,41 @@ export const FileSelectBox = React.forwardRef<HTMLInputElement, FileButtonProps> | |||
<label | |||
className="block absolute top-0 left-0 w-full h-full cursor-pointer" | |||
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 | |||
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> | |||
) | |||
} | |||
@@ -243,37 +259,57 @@ export const FileSelectBox = React.forwardRef<HTMLInputElement, FileButtonProps> | |||
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`}> | |||
<FilePreviewComponent | |||
fileList={fileList} | |||
/> | |||
<div className="relative"> | |||
<FilePreviewComponent | |||
fileList={fileList} | |||
/> | |||
</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"> | |||
<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 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 | |||
data-testid="clear" | |||
type="button" | |||
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> | |||
</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> | |||
); | |||
} | |||
@@ -1,93 +1,247 @@ | |||
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 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 toggleFullScreen: React.MouseEventHandler<HTMLButtonElement> = (e) => { | |||
e.preventDefault(); | |||
const toggleFullScreen = React.useCallback(() => { | |||
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 ( | |||
<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 | |||
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" | |||
/> | |||
</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 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)} × {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)}×{formatNumeral(f.metadata.height)} pixels | |||
</dd> | |||
</div> | |||
) | |||
} | |||
</dl> | |||
Download | |||
</span> | |||
</button> | |||
</fieldset> | |||
</form> | |||
</div> | |||
</div> | |||
); | |||
} |
@@ -10,7 +10,7 @@ export interface TextFilePreviewProps { | |||
export const TextFilePreview: React.FC<TextFilePreviewProps> = ({ | |||
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`}> | |||
{ | |||
typeof f.metadata?.contents === 'string' | |||
@@ -1,227 +1,12 @@ | |||
import * as React from 'react'; | |||
import {getMimeTypeDescription, VideoFile} from '@/utils/blob'; | |||
import {formatFileSize, formatNumeral, formatSecondsDurationConcise} from '@/utils/numeral'; | |||
import {useVideoControls} from '@tesseract-design/web-blob-react'; | |||
export interface VideoFilePreviewProps { | |||
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>(({ | |||
file: f, | |||
}, forwardedRef) => { | |||
@@ -253,7 +38,7 @@ export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePrev | |||
const finalCurrentTimeDisplay = isSeekTimeCountingDown ? (durationDisplay - currentTimeDisplay) : currentTimeDisplay; | |||
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`}> | |||
{ | |||
typeof f.metadata?.previewUrl === 'string' | |||
@@ -1,12 +1,12 @@ | |||
import {AudioFile} from '@/utils/blob'; | |||
import * as React from 'react'; | |||
import WaveSurfer from 'wavesurfer.js'; | |||
import {AudioFile} from '@/utils/blob'; | |||
export interface UseAudioFilePreviewControlsOptions { | |||
file: AudioFile; | |||
} | |||
export const useAudioFilePreviewControls = ({ file: f }: UseAudioFilePreviewControlsOptions) => { | |||
export const useAudioControls = ({ file: f }: UseAudioFilePreviewControlsOptions) => { | |||
const mediaContainerRef = React.useRef<HTMLDivElement>(null); | |||
const mediaControllerRef = React.useRef<WaveSurfer>(null); | |||
const [isPlaying, setIsPlaying] = React.useState(false); |
@@ -0,0 +1,2 @@ | |||
export * from './audio'; | |||
export * from './video'; |
@@ -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, | |||
]); | |||
}; |
@@ -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/TextFilePreview'; | |||
export * from './components/VideoFilePreview'; | |||
//export * from './components/TextFilePreview'; | |||
//export * from './components/VideoFilePreview'; | |||
//export * from './hooks/media'; |
@@ -115,7 +115,7 @@ export const readAsText = (blob: Blob) => blob.text(); | |||
export const readAsDataURL = (blob: Blob) => new Promise<string>((resolve, reject) => { | |||
const reader = new FileReader(); | |||
reader.addEventListener('error', () => { | |||
reject(); | |||
reject(new Error('Could not read file as data URL')); | |||
}); | |||
reader.addEventListener('load', (e) => { | |||
@@ -133,6 +133,7 @@ export const readAsArrayBuffer = (blob: Blob) => blob.arrayBuffer(); | |||
interface FileWithResolvedType<T extends ContentType> extends Partial<File> { | |||
resolvedType: T; | |||
originalFile?: File; | |||
} | |||
export interface TextFileMetadata { | |||
@@ -157,6 +158,7 @@ const augmentTextFile = async (f: File): Promise<TextFile> => { | |||
size: f.size, | |||
lastModified: f.lastModified, | |||
resolvedType: ContentType.TEXT, | |||
originalFile: f, | |||
metadata: { | |||
contents, | |||
language: metadata.language, | |||
@@ -177,7 +179,7 @@ export interface ImageFile extends FileWithResolvedType<ContentType.IMAGE> { | |||
metadata?: ImageFileMetadata; | |||
} | |||
const augmentImageFile = async (f: File): Promise<ImageFile> => { | |||
export const augmentImageFile = async (f: File): Promise<ImageFile> => { | |||
const previewUrl = await readAsDataURL(f); | |||
const imageMetadata = await getImageMetadata(previewUrl) as ImageFileMetadata; | |||
return { | |||
@@ -186,6 +188,7 @@ const augmentImageFile = async (f: File): Promise<ImageFile> => { | |||
size: f.size, | |||
lastModified: f.lastModified, | |||
resolvedType: ContentType.IMAGE, | |||
originalFile: f, | |||
metadata: { | |||
previewUrl, | |||
width: imageMetadata.width, | |||
@@ -212,6 +215,7 @@ const augmentAudioFile = async (f: File): Promise<AudioFile> => { | |||
size: f.size, | |||
lastModified: f.lastModified, | |||
resolvedType: ContentType.AUDIO, | |||
originalFile: f, | |||
metadata: { | |||
previewUrl, | |||
duration: audioExtensions.duration, | |||
@@ -235,6 +239,7 @@ const augmentBinaryFile = async (f: File): Promise<BinaryFile> => { | |||
size: f.size, | |||
lastModified: f.lastModified, | |||
resolvedType: ContentType.BINARY, | |||
originalFile: f, | |||
metadata: { | |||
contents: arrayBuffer, | |||
}, | |||
@@ -259,6 +264,7 @@ const augmentVideoFile = async (f: File): Promise<VideoFile> => { | |||
size: f.size, | |||
lastModified: f.lastModified, | |||
resolvedType: ContentType.VIDEO, | |||
originalFile: f, | |||
metadata: { | |||
previewUrl, | |||
}, | |||
@@ -11,7 +11,7 @@ export const getImageMetadata = (imageUrl: string) => new Promise<Record<string, | |||
}); | |||
image.addEventListener('error', () => { | |||
reject(); | |||
reject(new Error('Could not load file as image')); | |||
image.remove(); | |||
}); | |||
@@ -29,6 +29,7 @@ module.exports = { | |||
'code-url': 'rgb(var(--color-code-url))', | |||
'code-global': 'rgb(var(--color-code-global))', | |||
'current': 'currentcolor', | |||
'inherit': 'inherit', | |||
}, | |||
extend: { | |||
fontSize: { | |||