Organize controls for image file preview componentpull/1/head
@@ -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" | |||||
} | } | ||||
} | } |
@@ -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: | ||||
@@ -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"> | ||||
@@ -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 | ||||
@@ -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) | ||||
@@ -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> | ||||
); | ); | ||||
} | } | ||||
@@ -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)} × {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> | </div> | ||||
); | ); | ||||
} | } |
@@ -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' | ||||
@@ -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' | ||||
@@ -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); |
@@ -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/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) => { | 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, | ||||
}, | }, | ||||
@@ -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(); | ||||
}); | }); | ||||
@@ -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: { | ||||