Streamline mounting of components for file preview components.pull/1/head
@@ -14,16 +14,17 @@ import clsx from 'clsx'; | |||||
import {SpectrogramCanvas, WaveformCanvas} from '@modal-soft/react-wavesurfer'; | import {SpectrogramCanvas, WaveformCanvas} from '@modal-soft/react-wavesurfer'; | ||||
import {Slider} from '@/categories/number/react'; | import {Slider} from '@/categories/number/react'; | ||||
import {KeyValueTable} from '@/categories/information/react'; | import {KeyValueTable} from '@/categories/information/react'; | ||||
import {useEnhanced} from '@modal-soft/react-utils'; | |||||
type AudioFilePreviewDerivedComponent = HTMLAudioElement; | |||||
type AudioFilePreviewDerivedElement = HTMLAudioElement; | |||||
export interface AudioFilePreviewProps<F extends Partial<File> = Partial<File>> extends Omit<React.HTMLProps<AudioFilePreviewDerivedComponent>, 'controls'> { | |||||
export interface AudioFilePreviewProps<F extends Partial<File> = Partial<File>> extends Omit<React.HTMLProps<AudioFilePreviewDerivedElement>, 'controls'> { | |||||
file?: F; | file?: F; | ||||
disabled?: boolean; | disabled?: boolean; | ||||
enhanced?: boolean; | enhanced?: boolean; | ||||
} | } | ||||
export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponent, AudioFilePreviewProps>(({ | |||||
export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedElement, AudioFilePreviewProps>(({ | |||||
file, | file, | ||||
style, | style, | ||||
className, | className, | ||||
@@ -58,16 +59,12 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||||
endSeek, | endSeek, | ||||
setSeek, | setSeek, | ||||
visualizationId, | visualizationId, | ||||
formId, | |||||
} = useMediaControls<HTMLAudioElement>({ | } = useMediaControls<HTMLAudioElement>({ | ||||
controllerRef: forwardedRef, | controllerRef: forwardedRef, | ||||
visualizationMode: 'waveform', | visualizationMode: 'waveform', | ||||
}); | }); | ||||
const formId = React.useId(); | |||||
const [enhanced, setEnhanced] = React.useState(false); | |||||
React.useEffect(() => { | |||||
setEnhanced(enhancedProp); | |||||
}, [enhancedProp]); | |||||
const { enhanced } = useEnhanced({ enhanced: enhancedProp }); | |||||
if (!fileWithMetadata) { | if (!fileWithMetadata) { | ||||
return null; | return null; | ||||
@@ -90,11 +87,10 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||||
&& ( | && ( | ||||
<div | <div | ||||
className="w-full h-full bg-black flex flex-col items-stretch" | className="w-full h-full bg-black flex flex-col items-stretch" | ||||
data-testid="preview" | |||||
key={`${fileWithMetadata.url}:${fileWithMetadata.type}`} | |||||
> | > | ||||
<div | <div | ||||
className="w-full flex-auto relative aspect-video sm:aspect-auto" | className="w-full flex-auto relative aspect-video sm:aspect-auto" | ||||
key={`${fileWithMetadata?.url ?? ''}:${fileWithMetadata?.type ?? ''}`} | |||||
> | > | ||||
<audio | <audio | ||||
{...etcProps} | {...etcProps} | ||||
@@ -104,9 +100,9 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||||
onDurationChange={refreshControls} | onDurationChange={refreshControls} | ||||
onEnded={reset} | onEnded={reset} | ||||
onTimeUpdate={updateSeekFromPlayback} | onTimeUpdate={updateSeekFromPlayback} | ||||
data-testid="preview" | |||||
> | > | ||||
<source | <source | ||||
key={`${fileWithMetadata?.url ?? ''}:${fileWithMetadata?.type ?? ''}`} | |||||
src={fileWithMetadata.url} | src={fileWithMetadata.url} | ||||
type={fileWithMetadata.type} | type={fileWithMetadata.type} | ||||
/> | /> | ||||
@@ -149,7 +145,7 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||||
'absolute w-full sm:h-full top-0 left-0 block object-center object-contain flex-auto aspect-video sm:aspect-auto bg-primary/10 cursor-text opacity-0', | 'absolute w-full sm:h-full top-0 left-0 block object-center object-contain flex-auto aspect-video sm:aspect-auto bg-primary/10 cursor-text opacity-0', | ||||
'peer-checked/waveform:opacity-100', | 'peer-checked/waveform:opacity-100', | ||||
)} | )} | ||||
ref={mediaControllerRef} | |||||
audioRef={mediaControllerRef} | |||||
data-testid="preview" | data-testid="preview" | ||||
barWidth={1} | barWidth={1} | ||||
barGap={1} | barGap={1} | ||||
@@ -199,7 +195,7 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||||
'absolute w-full sm:h-full top-0 left-0 block object-center object-contain flex-auto aspect-video sm:aspect-auto bg-primary/10 pointer-events-none opacity-0', | 'absolute w-full sm:h-full top-0 left-0 block object-center object-contain flex-auto aspect-video sm:aspect-auto bg-primary/10 pointer-events-none opacity-0', | ||||
'peer-checked/waveform:opacity-100', | 'peer-checked/waveform:opacity-100', | ||||
)} | )} | ||||
ref={mediaControllerRef} | |||||
audioRef={mediaControllerRef} | |||||
data-testid="preview" | data-testid="preview" | ||||
barWidth={1} | barWidth={1} | ||||
barGap={1} | barGap={1} | ||||
@@ -216,7 +212,7 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||||
)} | )} | ||||
</div> | </div> | ||||
{enhanced && ( | {enhanced && ( | ||||
<div className="w-full flex-shrink-0 h-10 flex gap-4 items-center"> | |||||
<div className="w-full flex-shrink-0 h-10 flex gap-4 items-center bg-[#000000] px-3"> | |||||
<div | <div | ||||
className="py-1 w-14 h-full flex-shrink-0 text-primary flex items-center justify-center" | className="py-1 w-14 h-full flex-shrink-0 text-primary flex items-center justify-center" | ||||
> | > | ||||
@@ -297,8 +293,10 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||||
} | } | ||||
> | > | ||||
<Slider | <Slider | ||||
className="bg-negative text-base flex-auto" | |||||
className="text-base flex-auto" | |||||
ref={seekRef} | ref={seekRef} | ||||
min={0} | |||||
max={durationDisplay} | |||||
onMouseDown={startSeek} | onMouseDown={startSeek} | ||||
onMouseUp={endSeek} | onMouseUp={endSeek} | ||||
onChange={setSeek} | onChange={setSeek} | ||||
@@ -342,6 +340,7 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||||
className: 'font-bold', | className: 'font-bold', | ||||
valueProps: { | valueProps: { | ||||
ref: filenameRef, | ref: filenameRef, | ||||
title: fileWithMetadata.name, | |||||
children: fileWithMetadata.name, | children: fileWithMetadata.name, | ||||
}, | }, | ||||
}, | }, | ||||
@@ -1,26 +1,214 @@ | |||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import {AudioFile, getMimeTypeDescription} from '@/utils/blob'; | |||||
import {formatFileSize, formatNumeral, formatSecondsDurationPrecise} from '@/utils/numeral'; | |||||
import {useAudioControls} from '@tesseract-design/web-blob-react'; | |||||
import {augmentAudioFile} from '@/utils/blob'; | |||||
import theme from '@/styles/theme'; | |||||
import {useMediaControls} from '../../hooks/interactive'; | |||||
import {useFileMetadata, useFileUrl} from '@/categories/blob/react'; | |||||
import clsx from 'clsx'; | |||||
import {SpectrogramCanvas, WaveformCanvas} from '@modal-soft/react-wavesurfer'; | |||||
import {useEnhanced} from '@modal-soft/react-utils'; | |||||
export interface AudioMiniFilePreviewProps { | |||||
file: AudioFile; | |||||
type AudioMiniFilePreviewDerivedElement = HTMLAudioElement; | |||||
export interface AudioMiniFilePreviewProps<F extends Partial<File> = Partial<File>> extends Omit<React.HTMLProps<AudioMiniFilePreviewDerivedElement>, 'controls'> { | |||||
file?: F; | |||||
disabled?: boolean; | |||||
enhanced?: boolean; | |||||
} | } | ||||
export const AudioMiniFilePreview: React.FC<AudioMiniFilePreviewProps> = ({ | |||||
file: f, | |||||
}) => { | |||||
export const AudioMiniFilePreview = React.forwardRef<AudioMiniFilePreviewDerivedElement, AudioMiniFilePreviewProps>(({ | |||||
file, | |||||
style, | |||||
className, | |||||
enhanced: enhancedProp = false, | |||||
disabled = false, | |||||
...etcProps | |||||
}, forwardedRef) => { | |||||
const { fileWithUrl } = useFileUrl({ | |||||
file, | |||||
}); | |||||
const { fileWithMetadata, error } = useFileMetadata({ | |||||
file: fileWithUrl, | |||||
augmentFunction: augmentAudioFile, | |||||
}); | |||||
const { | const { | ||||
mountRef, | |||||
playMedia, | |||||
mediaControllerRef, | |||||
refreshControls, | |||||
reset, | |||||
updateSeekFromPlayback, | |||||
isPlaying, | isPlaying, | ||||
} = useAudioControls({ file: f }); | |||||
isSeeking, | |||||
currentTimeDisplay = 0, | |||||
seekTimeDisplay = 0, | |||||
durationDisplay = 0, | |||||
isSeekTimeCountingDown, | |||||
adjustVolume, | |||||
volumeRef, | |||||
handleAction, | |||||
filenameRef, | |||||
seekRef, | |||||
startSeek, | |||||
endSeek, | |||||
setSeek, | |||||
visualizationId, | |||||
formId, | |||||
} = useMediaControls<HTMLAudioElement>({ | |||||
controllerRef: forwardedRef, | |||||
visualizationMode: 'waveform', | |||||
}); | |||||
const { enhanced } = useEnhanced({ enhanced: enhancedProp }); | |||||
if (!fileWithMetadata) { | |||||
return null; | |||||
} | |||||
const finalSeekTimeDisplay = isSeekTimeCountingDown ? (durationDisplay - seekTimeDisplay) : seekTimeDisplay; | |||||
const finalCurrentTimeDisplay = isSeekTimeCountingDown ? (durationDisplay - currentTimeDisplay) : currentTimeDisplay; | |||||
return ( | return ( | ||||
<div | <div | ||||
className="absolute top-0 left-0 w-full h-full cursor-pointer" | |||||
ref={mountRef} | |||||
onClick={playMedia} | |||||
/> | |||||
className={clsx( | |||||
'flex flex-col sm:grid sm:grid-cols-3 gap-8 w-full', | |||||
className, | |||||
)} | |||||
style={style} | |||||
> | |||||
<div className="sm:h-full relative col-span-2"> | |||||
{ | |||||
typeof fileWithMetadata.url === 'string' | |||||
&& ( | |||||
<div | |||||
className="w-full h-full bg-black flex flex-col items-stretch" | |||||
data-testid="preview" | |||||
> | |||||
<div | |||||
className="w-full flex-auto relative aspect-video sm:aspect-auto" | |||||
key={`${fileWithMetadata?.url ?? ''}:${fileWithMetadata?.type ?? ''}`} | |||||
> | |||||
<audio | |||||
{...etcProps} | |||||
controls={!enhanced} | |||||
ref={mediaControllerRef} | |||||
onLoadedMetadata={refreshControls} | |||||
onDurationChange={refreshControls} | |||||
onEnded={reset} | |||||
onTimeUpdate={updateSeekFromPlayback} | |||||
> | |||||
<source | |||||
key={`${fileWithMetadata?.url ?? ''}:${fileWithMetadata?.type ?? ''}`} | |||||
src={fileWithMetadata.url} | |||||
type={fileWithMetadata.type} | |||||
/> | |||||
Audio playback not supported. | |||||
</audio> | |||||
{enhanced && ( | |||||
<> | |||||
<div className="flex justify-end w-full h-full gap-4 absolute top-0 right-0 z-[5] px-4"> | |||||
<div className="contents"> | |||||
<input | |||||
type="radio" | |||||
name="visualizationMode" | |||||
value="waveform" | |||||
className="sr-only peer/waveform" | |||||
defaultChecked | |||||
id={`${visualizationId}-waveform`} | |||||
/> | |||||
<label | |||||
htmlFor={`${visualizationId}-waveform`} | |||||
className={clsx( | |||||
'relative z-[5]', | |||||
'h-12 flex items-center justify-center leading-none gap-4 select-none', | |||||
'text-primary cursor-pointer', | |||||
'peer-focus/waveform:text-secondary', | |||||
'peer-active/waveform:text-tertiary', | |||||
'peer-checked/waveform:text-tertiary', | |||||
'peer-disabled/waveform:text-primary peer-disabled/waveform:cursor-not-allowed peer-disabled/waveform:opacity-50', | |||||
)} | |||||
> | |||||
<span | |||||
className={clsx( | |||||
'flex items-center uppercase font-bold h-full w-full whitespace-nowrap overflow-hidden text-ellipsis', | |||||
)} | |||||
> | |||||
Waveform | |||||
</span> | |||||
</label> | |||||
<WaveformCanvas | |||||
className={clsx( | |||||
'absolute w-full sm:h-full top-0 left-0 block object-center object-contain flex-auto aspect-video sm:aspect-auto bg-primary/10 cursor-text opacity-0', | |||||
'peer-checked/waveform:opacity-100', | |||||
)} | |||||
audioRef={mediaControllerRef} | |||||
data-testid="preview" | |||||
barWidth={1} | |||||
barGap={1} | |||||
progressColor={`rgb(${theme['color-primary']})`} | |||||
waveColor={`rgb(${theme['color-primary'].split(' ').map((c) => Math.floor(Number(c) / 2)).join(' ')})`} | |||||
interact | |||||
// waveColor={`rgb(${theme.primary})`} | |||||
// barHeight={4} | |||||
// minPxPerSec={20000} | |||||
// hideScrollbar | |||||
// autoCenter | |||||
// autoScroll | |||||
/> | |||||
</div> | |||||
<div | |||||
className="contents" | |||||
> | |||||
<input | |||||
type="radio" | |||||
name="visualizationMode" | |||||
value="spectrum" | |||||
className="sr-only peer/waveform" | |||||
id={`${visualizationId}-spectrum`} | |||||
/> | |||||
<label | |||||
htmlFor={`${visualizationId}-spectrum`} | |||||
className={clsx( | |||||
'relative z-[5]', | |||||
'h-12 flex items-center justify-center leading-none gap-4 select-none', | |||||
'text-primary cursor-pointer', | |||||
'peer-focus/waveform:text-secondary', | |||||
'peer-active/waveform:text-tertiary', | |||||
'peer-checked/waveform:text-tertiary', | |||||
'peer-disabled/waveform:text-primary peer-disabled/waveform:cursor-not-allowed peer-disabled/waveform:opacity-50', | |||||
)} | |||||
> | |||||
<span | |||||
className={clsx( | |||||
'flex items-center uppercase font-bold h-full w-full whitespace-nowrap overflow-hidden text-ellipsis', | |||||
)} | |||||
> | |||||
Spectrum | |||||
</span> | |||||
</label> | |||||
<SpectrogramCanvas | |||||
className={clsx( | |||||
'absolute w-full sm:h-full top-0 left-0 block object-center object-contain flex-auto aspect-video sm:aspect-auto bg-primary/10 pointer-events-none opacity-0', | |||||
'peer-checked/waveform:opacity-100', | |||||
)} | |||||
audioRef={mediaControllerRef} | |||||
data-testid="preview" | |||||
barWidth={1} | |||||
barGap={1} | |||||
waveColor={`rgb(${theme['color-primary']})`} | |||||
cursorWidth={2} | |||||
minPxPerSec={20000} | |||||
hideScrollbar | |||||
autoCenter | |||||
autoScroll | |||||
/> | |||||
</div> | |||||
</div> | |||||
</> | |||||
)} | |||||
</div> | |||||
</div> | |||||
) | |||||
} | |||||
</div> | |||||
</div> | |||||
); | ); | ||||
}; | |||||
}); | |||||
AudioMiniFilePreview.displayName = 'AudioMiniFilePreview'; |
@@ -5,15 +5,16 @@ import {useFileMetadata, useFileUrl} from '@/categories/blob/react'; | |||||
import clsx from 'clsx'; | import clsx from 'clsx'; | ||||
import {KeyValueTable} from '@/categories/information/react'; | import {KeyValueTable} from '@/categories/information/react'; | ||||
import {BinaryDataCanvas} from '@modal-soft/react-binary-data-canvas'; | import {BinaryDataCanvas} from '@modal-soft/react-binary-data-canvas'; | ||||
import {useEnhanced} from '@modal-soft/react-utils'; | |||||
type BinaryFilePreviewDerivedComponent = HTMLDivElement; | |||||
type BinaryFilePreviewDerivedElement = HTMLDivElement; | |||||
export interface BinaryFilePreviewProps<F extends Partial<File> = Partial<File>> extends React.HTMLProps<BinaryFilePreviewDerivedComponent> { | |||||
export interface BinaryFilePreviewProps<F extends Partial<File> = Partial<File>> extends React.HTMLProps<BinaryFilePreviewDerivedElement> { | |||||
file?: F; | file?: F; | ||||
enhanced?: boolean; | enhanced?: boolean; | ||||
} | } | ||||
export const BinaryFilePreview = React.forwardRef<BinaryFilePreviewDerivedComponent, BinaryFilePreviewProps>(({ | |||||
export const BinaryFilePreview = React.forwardRef<BinaryFilePreviewDerivedElement, BinaryFilePreviewProps>(({ | |||||
file, | file, | ||||
className, | className, | ||||
style, | style, | ||||
@@ -25,11 +26,7 @@ export const BinaryFilePreview = React.forwardRef<BinaryFilePreviewDerivedCompon | |||||
file: fileWithUrl, | file: fileWithUrl, | ||||
augmentFunction: augmentBinaryFile, | augmentFunction: augmentBinaryFile, | ||||
}); | }); | ||||
const [enhanced, setEnhanced] = React.useState(false); | |||||
React.useEffect(() => { | |||||
setEnhanced(enhancedProp); | |||||
}, [enhancedProp]); | |||||
const { enhanced } = useEnhanced({ enhanced: enhancedProp }); | |||||
if (!fileWithMetadata) { | if (!fileWithMetadata) { | ||||
return null; | return null; | ||||
@@ -42,6 +39,7 @@ export const BinaryFilePreview = React.forwardRef<BinaryFilePreviewDerivedCompon | |||||
className, | className, | ||||
)} | )} | ||||
style={style} | style={style} | ||||
key={`${fileWithMetadata?.url ?? ''}:${fileWithMetadata?.type ?? ''}`} | |||||
> | > | ||||
<div className="h-full relative"> | <div className="h-full relative"> | ||||
<div className="absolute top-0 left-0 w-full h-full"> | <div className="absolute top-0 left-0 w-full h-full"> | ||||
@@ -54,6 +52,7 @@ export const BinaryFilePreview = React.forwardRef<BinaryFilePreviewDerivedCompon | |||||
role="presentation" | role="presentation" | ||||
className="w-full h-full select-none overflow-hidden text-xs" | className="w-full h-full select-none overflow-hidden text-xs" | ||||
ref={forwardedRef} | ref={forwardedRef} | ||||
key={`${fileWithMetadata.url}:${fileWithMetadata.type}`} | |||||
> | > | ||||
<BinaryDataCanvas | <BinaryDataCanvas | ||||
arrayBuffer={fileWithMetadata.metadata?.contents} | arrayBuffer={fileWithMetadata.metadata?.contents} | ||||
@@ -4,7 +4,7 @@ import { ImageFilePreview } from '../ImageFilePreview'; | |||||
import { AudioFilePreview } from '../AudioFilePreview'; | import { AudioFilePreview } from '../AudioFilePreview'; | ||||
import { VideoFilePreview } from '../VideoFilePreview'; | import { VideoFilePreview } from '../VideoFilePreview'; | ||||
import { BinaryFilePreview } from '../BinaryFilePreview'; | import { BinaryFilePreview } from '../BinaryFilePreview'; | ||||
import {AugmentedFile, augmentFile, ContentType, getContentType} from '@/utils/blob'; | |||||
import { ContentType, getContentType } from '@/utils/blob'; | |||||
const FILE_PREVIEW_COMPONENTS: Record<ContentType, React.ElementType> = { | const FILE_PREVIEW_COMPONENTS: Record<ContentType, React.ElementType> = { | ||||
[ContentType.TEXT]: TextFilePreview, | [ContentType.TEXT]: TextFilePreview, | ||||
@@ -14,43 +14,16 @@ const FILE_PREVIEW_COMPONENTS: Record<ContentType, React.ElementType> = { | |||||
[ContentType.BINARY]: BinaryFilePreview, | [ContentType.BINARY]: BinaryFilePreview, | ||||
}; | }; | ||||
const useFilePreviews = (fileList?: FileList) => { | |||||
const [selectedFiles, setSelectedFiles] = React.useState([] as AugmentedFile[]); | |||||
React.useEffect(() => { | |||||
const loadFilePreviews = async (fileList: FileList) => { | |||||
const files = Array.from(fileList); | |||||
return Promise.all( | |||||
files.map(async (f) => { | |||||
const augmentedFile = await augmentFile(f); | |||||
if (augmentedFile.resolvedType === ContentType.TEXT && augmentedFile.metadata?.scheme) { | |||||
await import(`prismjs/components/prism-${augmentedFile.metadata.scheme}`); | |||||
} | |||||
return augmentedFile; | |||||
}) | |||||
); | |||||
} | |||||
if (fileList) { | |||||
loadFilePreviews(fileList).then((fileResult) => { | |||||
setSelectedFiles(fileResult); | |||||
}); | |||||
} | |||||
}, [fileList]); | |||||
return React.useMemo(() => ({ | |||||
files: selectedFiles, | |||||
}), [selectedFiles]); | |||||
}; | |||||
export interface FilePreviewProps { | export interface FilePreviewProps { | ||||
fileList?: FileList; | fileList?: FileList; | ||||
className?: string; | |||||
} | } | ||||
export const FilePreview: React.FC<FilePreviewProps> = ({ | export const FilePreview: React.FC<FilePreviewProps> = ({ | ||||
fileList, | fileList, | ||||
className, | |||||
}) => { | }) => { | ||||
const { files } = useFilePreviews(fileList); | |||||
if (files.length < 1) { | |||||
if ((fileList?.length ?? 0) < 1) { | |||||
return null; | return null; | ||||
} | } | ||||
@@ -59,6 +32,11 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ | |||||
const FilePreviewComponent = FILE_PREVIEW_COMPONENTS[contentType] ?? BinaryFilePreview; | const FilePreviewComponent = FILE_PREVIEW_COMPONENTS[contentType] ?? BinaryFilePreview; | ||||
return ( | return ( | ||||
<FilePreviewComponent file={f} /> | |||||
<FilePreviewComponent | |||||
key={contentType} | |||||
file={f} | |||||
className={className} | |||||
enhanced | |||||
/> | |||||
); | ); | ||||
}; | }; |
@@ -1,10 +1,11 @@ | |||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import {AugmentedFile, augmentFile, ContentType, getMimeTypeDescription} from '@/utils/blob'; | |||||
import { FilePreview as FilePreviewComponent} from '../FilePreview'; | |||||
import {ContentType, FileWithResolvedContentType, getMimeTypeDescription} from '@/utils/blob'; | |||||
import { FilePreview } 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'; | import clsx from 'clsx'; | ||||
import {useEnhanced} from '@modal-soft/react-utils'; | |||||
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'> { | ||||
/** | /** | ||||
@@ -30,69 +31,46 @@ export interface FileButtonProps extends Omit<React.HTMLProps<HTMLInputElement>, | |||||
hiddenLabel?: boolean, | hiddenLabel?: boolean, | ||||
} | } | ||||
const useFilePreviews = (fileList?: FileList) => { | |||||
const [selectedFiles, setSelectedFiles] = React.useState([] as AugmentedFile[]); | |||||
React.useEffect(() => { | |||||
const loadFilePreviews = async (fileList: FileList) => { | |||||
const files = Array.from(fileList); | |||||
return Promise.all( | |||||
files.map(async (f) => { | |||||
const augmentedFile = await augmentFile(f); | |||||
if (augmentedFile.resolvedType === ContentType.TEXT && augmentedFile.metadata?.scheme) { | |||||
await import(`prismjs/components/prism-${augmentedFile.metadata.scheme}`); | |||||
} | |||||
return augmentedFile; | |||||
}) | |||||
); | |||||
} | |||||
if (fileList) { | |||||
loadFilePreviews(fileList).then((fileResult) => { | |||||
setSelectedFiles(fileResult); | |||||
}); | |||||
} | |||||
}, [fileList]); | |||||
return React.useMemo(() => ({ | |||||
files: selectedFiles, | |||||
}), [selectedFiles]); | |||||
} | |||||
const FilePreviewGrid = ({ | const FilePreviewGrid = ({ | ||||
fileList, | |||||
fileList: files, | |||||
}: { fileList?: FileList }) => { | }: { fileList?: FileList }) => { | ||||
const { files } = useFilePreviews(fileList); | |||||
if (!files) { | |||||
return null; | |||||
} | |||||
return ( | return ( | ||||
<div className="w-full h-full overflow-auto -mx-4 px-4"> | <div className="w-full h-full overflow-auto -mx-4 px-4"> | ||||
<div className="w-full grid gap-4 grid-cols-3"> | <div className="w-full grid gap-4 grid-cols-3"> | ||||
{files.map((f, i) => ( | |||||
<div | |||||
data-testid="selectedFileItem" | |||||
key={i} | |||||
className={`w-full aspect-square 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`} | |||||
title={[f.name, getMimeTypeDescription(f.type), formatFileSize(f.size)].join(', ')} | |||||
> | |||||
{ | |||||
f.resolvedType === ContentType.IMAGE | |||||
&& typeof f.metadata?.previewUrl === 'string' | |||||
&& ( | |||||
<img | |||||
className="block w-full h-full object-center object-cover" | |||||
src={f.metadata.previewUrl} | |||||
alt={f.name} | |||||
data-testid="preview" | |||||
/> | |||||
) | |||||
} | |||||
{ | |||||
f.resolvedType === ContentType.AUDIO | |||||
&& ( | |||||
<AudioMiniFilePreview file={f} /> | |||||
) | |||||
} | |||||
</div> | |||||
))} | |||||
{Array.from(files).map((file: File, i) => { | |||||
const f = file as unknown as FileWithResolvedContentType; | |||||
return ( | |||||
<div | |||||
data-testid="selectedFileItem" | |||||
key={i} | |||||
className={`w-full aspect-square 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`} | |||||
title={[f.name, getMimeTypeDescription(f.type), formatFileSize(f.size)].join(', ')} | |||||
> | |||||
{ | |||||
f.resolvedType === ContentType.IMAGE | |||||
&& typeof f?.url === 'string' | |||||
&& ( | |||||
<img | |||||
className="block w-full h-full object-center object-cover" | |||||
src={f.url} | |||||
alt={f.name} | |||||
data-testid="preview" | |||||
/> | |||||
) | |||||
} | |||||
{ | |||||
f.resolvedType === ContentType.AUDIO | |||||
&& ( | |||||
<AudioMiniFilePreview file={f} /> | |||||
) | |||||
} | |||||
</div> | |||||
); | |||||
})} | |||||
</div> | </div> | ||||
</div> | </div> | ||||
) | ) | ||||
@@ -105,7 +83,7 @@ export const FileSelectBox = React.forwardRef<HTMLInputElement, FileButtonProps> | |||||
hint = '', | hint = '', | ||||
border = false, | border = false, | ||||
block = false, | block = false, | ||||
enhanced = false, | |||||
enhanced: enhancedProp = false, | |||||
hiddenLabel = false, | hiddenLabel = false, | ||||
multiple = false, | multiple = false, | ||||
onChange, | onChange, | ||||
@@ -116,34 +94,39 @@ export const FileSelectBox = React.forwardRef<HTMLInputElement, FileButtonProps> | |||||
}: FileButtonProps, | }: FileButtonProps, | ||||
forwardedRef, | forwardedRef, | ||||
) => { | ) => { | ||||
const [renderEnhanced, setRenderEnhanced] = React.useState(false); | |||||
const { enhanced } = useEnhanced({ enhanced: enhancedProp }); | |||||
const [fileList, setFileList] = React.useState<FileList>(); | const [fileList, setFileList] = React.useState<FileList>(); | ||||
const [lastSelectedFileAt, setLastSelectedFileAt] = React.useState<number>(); | |||||
const [lastUpdated, setLastUpdated] = 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 labelId = React.useId(); | ||||
const defaultId = React.useId(); | const defaultId = React.useId(); | ||||
const id = idProp ?? defaultId; | const id = idProp ?? defaultId; | ||||
const addFile: React.ChangeEventHandler<HTMLInputElement> = (e) => { | |||||
if (!enhanced) { | |||||
onChange?.(e); | |||||
return; | |||||
const doSetFileList: React.ChangeEventHandler<HTMLInputElement> = (e) => { | |||||
if (enhancedProp) { | |||||
setFileList(e.currentTarget.files as FileList); | |||||
setLastUpdated(Date.now()); | |||||
} | } | ||||
setFileList(e.currentTarget.files as FileList); | |||||
setLastSelectedFileAt(Date.now()); | |||||
onChange?.(e); | onChange?.(e); | ||||
}; | }; | ||||
const deleteFiles: React.MouseEventHandler<HTMLButtonElement> = () => { | |||||
if (typeof ref === 'object' && ref.current) { | |||||
ref.current.value = ''; | |||||
setFileList(undefined); | |||||
setTimeout(() => { | |||||
delegateTriggerChangeEvent(ref.current as HTMLInputElement); | |||||
}) | |||||
const doClearFileList: React.MouseEventHandler<HTMLButtonElement> = (e) => { | |||||
e.preventDefault(); | |||||
if (!(typeof ref === 'object' && ref)) { | |||||
return; | |||||
} | } | ||||
const { current } = ref; | |||||
if (!current) { | |||||
return; | |||||
} | |||||
current.value = ''; | |||||
setFileList(undefined); | |||||
setLastUpdated(Date.now()); | |||||
setTimeout(() => { | |||||
delegateTriggerChangeEvent(current); | |||||
}); | |||||
}; | }; | ||||
const cancelEvent = (e: React.DragEvent) => { | const cancelEvent = (e: React.DragEvent) => { | ||||
@@ -153,20 +136,26 @@ export const FileSelectBox = React.forwardRef<HTMLInputElement, FileButtonProps> | |||||
const handleDropZone: React.DragEventHandler<HTMLDivElement> = async (e) => { | const handleDropZone: React.DragEventHandler<HTMLDivElement> = async (e) => { | ||||
cancelEvent(e); | cancelEvent(e); | ||||
if (!(typeof ref === 'object' && ref)) { | |||||
return; | |||||
} | |||||
const { current } = ref; | |||||
if (!current) { | |||||
return; | |||||
} | |||||
const { dataTransfer } = e; | 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); | |||||
const { files } = dataTransfer; | |||||
if (!(files && files.length > 0)) { | |||||
return; | |||||
} | } | ||||
setFileList(current.files = files); | |||||
setLastUpdated(Date.now()); | |||||
setTimeout(() => { | |||||
delegateTriggerChangeEvent(current); | |||||
}); | |||||
} | } | ||||
React.useEffect(() => { | |||||
setRenderEnhanced(enhanced); | |||||
}, [enhanced]); | |||||
const filesCount = React.useMemo(() => fileList?.length ?? 0, [fileList]); | |||||
return ( | return ( | ||||
<div | <div | ||||
@@ -195,10 +184,10 @@ export const FileSelectBox = React.forwardRef<HTMLInputElement, FileButtonProps> | |||||
className={clsx( | className={clsx( | ||||
'peer', | 'peer', | ||||
{ | { | ||||
'sr-only': renderEnhanced, | |||||
'sr-only': enhanced, | |||||
} | } | ||||
)} | )} | ||||
onChange={addFile} | |||||
onChange={doSetFileList} | |||||
multiple={multiple} | multiple={multiple} | ||||
data-testid="input" | data-testid="input" | ||||
aria-labelledby={label ? `${labelId}` : undefined} | aria-labelledby={label ? `${labelId}` : undefined} | ||||
@@ -222,8 +211,8 @@ export const FileSelectBox = React.forwardRef<HTMLInputElement, FileButtonProps> | |||||
) | ) | ||||
} | } | ||||
{ | { | ||||
(fileList?.length ?? 0) < 1 | |||||
&& renderEnhanced | |||||
filesCount < 1 | |||||
&& enhanced | |||||
&& hint | && hint | ||||
&& ( | && ( | ||||
<div | <div | ||||
@@ -237,14 +226,13 @@ export const FileSelectBox = React.forwardRef<HTMLInputElement, FileButtonProps> | |||||
) | ) | ||||
} | } | ||||
{ | { | ||||
(fileList?.length ?? 0) > 0 | |||||
&& renderEnhanced | |||||
filesCount > 0 | |||||
&& enhanced | |||||
&& ( | && ( | ||||
<> | |||||
<div className={`absolute top-0 left-0 w-full h-full pointer-events-none pb-12 box-border overflow-hidden pt-8`}> | |||||
<React.Fragment key={lastUpdated}> | |||||
<div className={`sm:absolute top-0 left-0 w-full h-full pointer-events-none pb-12 box-border overflow-hidden pt-8`}> | |||||
<div | <div | ||||
className={`pointer-events-auto w-full h-full px-4 pb-4 box-border`} | className={`pointer-events-auto w-full h-full px-4 pb-4 box-border`} | ||||
key={lastSelectedFileAt} | |||||
> | > | ||||
{ | { | ||||
multiple | multiple | ||||
@@ -259,11 +247,10 @@ 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`}> | ||||
<div className="relative"> | |||||
<FilePreviewComponent | |||||
fileList={fileList} | |||||
/> | |||||
</div> | |||||
<FilePreview | |||||
className="w-full h-full relative" | |||||
fileList={fileList} | |||||
/> | |||||
</div> | </div> | ||||
</div> | </div> | ||||
) | ) | ||||
@@ -288,18 +275,18 @@ export const FileSelectBox = React.forwardRef<HTMLInputElement, FileButtonProps> | |||||
<button | <button | ||||
data-testid="clear" | data-testid="clear" | ||||
type="button" | type="button" | ||||
onClick={deleteFiles} | |||||
onClick={doClearFileList} | |||||
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" | 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" | ||||
> | > | ||||
<span | <span | ||||
className="block uppercase font-bold h-[1.1em] w-full whitespace-nowrap overflow-hidden text-ellipsis font-semi-expanded" | className="block uppercase font-bold h-[1.1em] w-full whitespace-nowrap overflow-hidden text-ellipsis font-semi-expanded" | ||||
> | > | ||||
{multiple ? 'Clear' : 'Delete'} | |||||
Clear | |||||
</span> | </span> | ||||
</button> | </button> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
</> | |||||
</React.Fragment> | |||||
) | ) | ||||
} | } | ||||
{ | { | ||||
@@ -4,16 +4,114 @@ import {formatFileSize, formatNumeral} from '@/utils/numeral'; | |||||
import clsx from 'clsx'; | import clsx from 'clsx'; | ||||
import {useFileMetadata, useFileUrl, useImageControls} from '@/categories/blob/react'; | import {useFileMetadata, useFileUrl, useImageControls} from '@/categories/blob/react'; | ||||
import {KeyValueTable} from '@/categories/information/react'; | import {KeyValueTable} from '@/categories/information/react'; | ||||
import {useEnhanced} from '@modal-soft/react-utils'; | |||||
type ImageFilePreviewDerivedComponent = HTMLImageElement; | |||||
type RgbTuple = [number, number, number]; | |||||
export interface ImageFilePreviewProps<F extends Partial<File> = Partial<File>> extends Omit<React.HTMLProps<ImageFilePreviewDerivedComponent>, 'src' | 'alt'> { | |||||
type SwatchDerivedElement = HTMLInputElement; | |||||
export interface SwatchProps extends Omit<React.HTMLProps<SwatchDerivedElement>, 'color'> { | |||||
color: RgbTuple; | |||||
mode?: 'rgb' | 'hsl'; | |||||
} | |||||
export const useSwatchControls = () => { | |||||
const id = React.useId(); | |||||
const copyColor: React.ReactEventHandler<SwatchDerivedElement> = React.useCallback(async (e) => { | |||||
const { value } = e.currentTarget; | |||||
await window.navigator.clipboard.writeText(value); | |||||
}, []); | |||||
return React.useMemo(() => ({ | |||||
id, | |||||
copyColor, | |||||
}), [id, copyColor]); | |||||
}; | |||||
export const Swatch = React.forwardRef<SwatchDerivedElement, SwatchProps>(({ | |||||
color, | |||||
mode = 'rgb', | |||||
className, | |||||
style, | |||||
...etcProps | |||||
}, forwardedRef) => { | |||||
const { id, copyColor } = useSwatchControls(); | |||||
const colorValue = `${mode}(${color.join(', ')})`; | |||||
return ( | |||||
<span | |||||
className={clsx( | |||||
'inline-block align-middle', | |||||
className, | |||||
)} | |||||
style={style} | |||||
> | |||||
<input | |||||
{...etcProps} | |||||
ref={forwardedRef} | |||||
type="text" | |||||
value={colorValue} | |||||
className="sr-only select-all peer" | |||||
readOnly | |||||
id={id} | |||||
onSelect={copyColor} | |||||
/> | |||||
<label | |||||
className={clsx( | |||||
'relative rounded ring-secondary/50 whitespace-nowrap inline-block align-top leading-none cursor-pointer', // todo eyedropper cursor | |||||
'peer-focus:outline-0 peer-focus:ring-4', | |||||
'peer-active:ring-tertiary/50', | |||||
'peer-disabled:opacity-50 peer-disabled:cursor-not-allowed', | |||||
)} | |||||
title={colorValue} | |||||
htmlFor={id} | |||||
> | |||||
<span | |||||
className="inline-block w-5 h-5 align-middle border border-[#ffffff]" | |||||
> | |||||
<span | |||||
className="block w-full h-full border border-[#000000]" | |||||
style={{ | |||||
backgroundColor: `${mode}(${color.join(' ')})`, | |||||
}} | |||||
/> | |||||
</span> | |||||
<span className="tabular-nums text-xs sr-only"> | |||||
{ | |||||
color | |||||
.map((c) => c | |||||
.toString() | |||||
.padStart(4, ' ') | |||||
.split('') | |||||
.map((cc, j) => ( | |||||
<span | |||||
key={`${cc}:${j}`} | |||||
className={clsx({ | |||||
'opacity-0': cc === ' ', | |||||
})} | |||||
> | |||||
{j === 0 && ' '} | |||||
{cc === ' ' && j > 0 ? '0' : cc} | |||||
</span> | |||||
)) | |||||
) | |||||
} | |||||
</span> | |||||
</label> | |||||
</span> | |||||
); | |||||
}); | |||||
Swatch.displayName = 'Swatch'; | |||||
type ImageFilePreviewDerivedElement = HTMLImageElement; | |||||
export interface ImageFilePreviewProps<F extends Partial<File> = Partial<File>> extends Omit<React.HTMLProps<ImageFilePreviewDerivedElement>, 'src' | 'alt'> { | |||||
file?: F; | file?: F; | ||||
disabled?: boolean; | disabled?: boolean; | ||||
enhanced?: boolean; | enhanced?: boolean; | ||||
} | } | ||||
export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponent, ImageFilePreviewProps>(({ | |||||
export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedElement, ImageFilePreviewProps>(({ | |||||
file, | file, | ||||
className, | className, | ||||
style, | style, | ||||
@@ -26,6 +124,7 @@ export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponen | |||||
file: fileWithUrl as File, | file: fileWithUrl as File, | ||||
augmentFunction: augmentImageFile, | augmentFunction: augmentImageFile, | ||||
}); | }); | ||||
const { | const { | ||||
fullScreen, | fullScreen, | ||||
handleAction, | handleAction, | ||||
@@ -35,10 +134,7 @@ export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponen | |||||
forwardedRef, | forwardedRef, | ||||
}); | }); | ||||
const [enhanced, setEnhanced] = React.useState(false); | |||||
React.useEffect(() => { | |||||
setEnhanced(enhancedProp); | |||||
}, [enhancedProp]); | |||||
const { enhanced } = useEnhanced({ enhanced: enhancedProp }); | |||||
if (!(fileWithMetadata)) { | if (!(fileWithMetadata)) { | ||||
return null; | return null; | ||||
@@ -66,7 +162,7 @@ export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponen | |||||
className={clsx( | className={clsx( | ||||
'block h-full max-w-full object-center bg-[#000000]', | 'block h-full max-w-full object-center bg-[#000000]', | ||||
{ | { | ||||
'object-contain fixed w-full top-0 left-0': fullScreen, | |||||
'object-contain fixed w-full top-0 left-0 z-[3]': fullScreen, | |||||
'object-cover w-full': !fullScreen, | 'object-cover w-full': !fullScreen, | ||||
}, | }, | ||||
)} | )} | ||||
@@ -94,6 +190,7 @@ export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponen | |||||
className: 'font-bold', | className: 'font-bold', | ||||
valueProps: { | valueProps: { | ||||
ref: filenameRef, | ref: filenameRef, | ||||
title: fileWithMetadata.name, | |||||
children: fileWithMetadata.name, | children: fileWithMetadata.name, | ||||
}, | }, | ||||
}, | }, | ||||
@@ -128,47 +225,22 @@ export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponen | |||||
&& { | && { | ||||
key: 'Palette', | key: 'Palette', | ||||
valueProps: { | valueProps: { | ||||
className: 'mt-1', | |||||
children: fileWithMetadata.metadata?.palette.map((rgb, i) => ( | |||||
<React.Fragment | |||||
key={rgb.join(' ')} | |||||
> | |||||
{i > 0 && ' '} | |||||
<span | |||||
className="whitespace-nowrap inline-block align-top leading-none" | |||||
title={`rgb(${rgb.join(', ')})`} | |||||
> | |||||
<span | |||||
className="inline-block w-5 h-5 align-middle border border-[#ffffff] ring-1 ring-[#000000]" | |||||
style={{ | |||||
backgroundColor: `rgb(${rgb.join(' ')})`, | |||||
}} | |||||
className: '-ml-4 pl-4 pt-4 ', | |||||
children: ( | |||||
<div className="flex flex-wrap gap-3"> | |||||
{fileWithMetadata.metadata?.palette.map((rgb, i) => ( | |||||
<React.Fragment | |||||
key={rgb.join(' ')} | |||||
> | |||||
{i > 0 && ' '} | |||||
<Swatch | |||||
color={rgb} | |||||
mode="rgb" | |||||
/> | /> | ||||
<span className="tabular-nums text-xs sr-only"> | |||||
{ | |||||
rgb | |||||
.map((c) => c | |||||
.toString() | |||||
.padStart(4, ' ') | |||||
.split('') | |||||
.map((cc, j) => ( | |||||
<span | |||||
key={`${rgb.join(',')}:${c}:${j}`} | |||||
className={clsx({ | |||||
'opacity-0': cc === ' ', | |||||
})} | |||||
> | |||||
{j === 0 && ' '} | |||||
{cc === ' ' && j > 0 ? '0' : cc} | |||||
</span> | |||||
)) | |||||
) | |||||
.flat() | |||||
} | |||||
</span> | |||||
</span> | |||||
</React.Fragment> | |||||
)), | |||||
</React.Fragment> | |||||
))} | |||||
</div> | |||||
), | |||||
}, | }, | ||||
}, | }, | ||||
]} | ]} | ||||
@@ -190,7 +262,7 @@ export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponen | |||||
name="action" | name="action" | ||||
value="toggleFullScreen" | value="toggleFullScreen" | ||||
className={clsx( | 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', | |||||
'h-12 flex text-primary disabled:text-primary focus:text-secondary active:text-tertiary items-center justify-center leading-none gap-4 select-none', | |||||
'focus:outline-0', | 'focus:outline-0', | ||||
'disabled:opacity-50 disabled:cursor-not-allowed', | 'disabled:opacity-50 disabled:cursor-not-allowed', | ||||
{ | { | ||||
@@ -210,7 +282,7 @@ export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponen | |||||
name="action" | name="action" | ||||
value="download" | value="download" | ||||
className={clsx( | 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', | |||||
'h-12 flex text-primary disabled:text-primary focus:text-secondary active:text-tertiary items-center justify-center leading-none gap-4 select-none', | |||||
'focus:outline-0', | 'focus:outline-0', | ||||
'disabled:opacity-50 disabled:cursor-not-allowed', | 'disabled:opacity-50 disabled:cursor-not-allowed', | ||||
)} | )} | ||||
@@ -4,7 +4,8 @@ import {useFileMetadata, useFileUrl} from '@/categories/blob/react'; | |||||
import {augmentTextFile, getMimeTypeDescription} from '@/utils/blob'; | import {augmentTextFile, getMimeTypeDescription} from '@/utils/blob'; | ||||
import clsx from 'clsx'; | import clsx from 'clsx'; | ||||
import {KeyValueTable} from '@/categories/information/react'; | import {KeyValueTable} from '@/categories/information/react'; | ||||
import {Refractor} from '../../../../../packages/react-refractor'; | |||||
import {Refractor} from '@modal-soft/react-refractor'; | |||||
import {useEnhanced} from '@modal-soft/react-utils'; | |||||
type TextFilePreviewDerivedComponent = HTMLDivElement; | type TextFilePreviewDerivedComponent = HTMLDivElement; | ||||
@@ -25,11 +26,7 @@ export const TextFilePreview = React.forwardRef<TextFilePreviewDerivedComponent, | |||||
file: fileWithUrl, | file: fileWithUrl, | ||||
augmentFunction: augmentTextFile, | augmentFunction: augmentTextFile, | ||||
}); | }); | ||||
const [enhanced, setEnhanced] = React.useState(false); | |||||
React.useEffect(() => { | |||||
setEnhanced(enhancedProp); | |||||
}, [enhancedProp]); | |||||
const { enhanced } = useEnhanced({ enhanced: enhancedProp }); | |||||
if (!fileWithMetadata) { | if (!fileWithMetadata) { | ||||
return null; | return null; | ||||
@@ -54,12 +51,12 @@ export const TextFilePreview = React.forwardRef<TextFilePreviewDerivedComponent, | |||||
role="presentation" | role="presentation" | ||||
className="w-full h-full select-none overflow-hidden text-xs" | className="w-full h-full select-none overflow-hidden text-xs" | ||||
ref={forwardedRef} | ref={forwardedRef} | ||||
key={`${fileWithMetadata.url}:${fileWithMetadata.type}`} | |||||
> | > | ||||
<Refractor | <Refractor | ||||
code={fileWithMetadata.metadata.contents} | code={fileWithMetadata.metadata.contents} | ||||
language={fileWithMetadata.metadata.scheme} | language={fileWithMetadata.metadata.scheme} | ||||
lineNumbers={Boolean(fileWithMetadata.metadata.scheme)} | lineNumbers={Boolean(fileWithMetadata.metadata.scheme)} | ||||
maxLineNumber={20} | |||||
/> | /> | ||||
</div> | </div> | ||||
) | ) | ||||
@@ -5,6 +5,7 @@ import {useFileMetadata, useFileUrl, useMediaControls} from '@tesseract-design/w | |||||
import clsx from 'clsx'; | import clsx from 'clsx'; | ||||
import {Slider} from '@tesseract-design/web-number-react'; | import {Slider} from '@tesseract-design/web-number-react'; | ||||
import {KeyValueTable} from '@/categories/information/react'; | import {KeyValueTable} from '@/categories/information/react'; | ||||
import {useEnhanced} from '@modal-soft/react-utils'; | |||||
type VideoFilePreviewDerivedComponent = HTMLVideoElement; | type VideoFilePreviewDerivedComponent = HTMLVideoElement; | ||||
@@ -46,15 +47,12 @@ export const VideoFilePreview = React.forwardRef<VideoFilePreviewDerivedComponen | |||||
mediaControllerRef, | mediaControllerRef, | ||||
handleAction, | handleAction, | ||||
filenameRef, | filenameRef, | ||||
formId, | |||||
} = useMediaControls<HTMLVideoElement>({ | } = useMediaControls<HTMLVideoElement>({ | ||||
controllerRef: forwardedRef, | controllerRef: forwardedRef, | ||||
}); | }); | ||||
const formId = React.useId(); | |||||
const [enhanced, setEnhanced] = React.useState(false); | |||||
React.useEffect(() => { | |||||
setEnhanced(enhancedProp); | |||||
}, [enhancedProp]); | |||||
const { enhanced } = useEnhanced({ enhanced: enhancedProp }); | |||||
if (!fileWithMetadata) { | if (!fileWithMetadata) { | ||||
return null; | return null; | ||||
@@ -78,10 +76,10 @@ export const VideoFilePreview = React.forwardRef<VideoFilePreviewDerivedComponen | |||||
<div | <div | ||||
className="w-full h-full bg-black flex flex-col items-stretch" | className="w-full h-full bg-black flex flex-col items-stretch" | ||||
data-testid="preview" | data-testid="preview" | ||||
key={`${fileWithMetadata.url}:${fileWithMetadata.type}`} | |||||
> | > | ||||
<div | <div | ||||
className="w-full flex-auto relative" | className="w-full flex-auto relative" | ||||
key={`${fileWithMetadata?.url ?? ''}:${fileWithMetadata?.type ?? ''}`} | |||||
> | > | ||||
<video | <video | ||||
{...etcProps} | {...etcProps} | ||||
@@ -95,10 +93,10 @@ export const VideoFilePreview = React.forwardRef<VideoFilePreviewDerivedComponen | |||||
controls={!enhanced} | controls={!enhanced} | ||||
> | > | ||||
<source | <source | ||||
key={`${fileWithMetadata?.url ?? ''}:${fileWithMetadata?.type ?? ''}`} | |||||
src={fileWithMetadata.url} | src={fileWithMetadata.url} | ||||
type={fileWithMetadata.type} | type={fileWithMetadata.type} | ||||
/> | /> | ||||
Video playback not supported. | |||||
</video> | </video> | ||||
<button | <button | ||||
className="absolute w-full h-full top-0 left-0" | className="absolute w-full h-full top-0 left-0" | ||||
@@ -114,7 +112,7 @@ export const VideoFilePreview = React.forwardRef<VideoFilePreviewDerivedComponen | |||||
</button> | </button> | ||||
</div> | </div> | ||||
{enhanced && ( | {enhanced && ( | ||||
<div className="w-full flex-shrink-0 h-10 flex gap-4 items-center"> | |||||
<div className="w-full flex-shrink-0 h-10 flex gap-4 items-center bg-[#000000] px-3"> | |||||
<div | <div | ||||
className="py-1 w-14 h-full flex-shrink-0 text-primary flex items-center justify-center" | className="py-1 w-14 h-full flex-shrink-0 text-primary flex items-center justify-center" | ||||
> | > | ||||
@@ -195,8 +193,10 @@ export const VideoFilePreview = React.forwardRef<VideoFilePreviewDerivedComponen | |||||
} | } | ||||
> | > | ||||
<Slider | <Slider | ||||
className="flex-auto bg-negative text-base" | |||||
className="flex-auto text-base" | |||||
ref={seekRef} | ref={seekRef} | ||||
min={0} | |||||
max={durationDisplay} | |||||
onMouseDown={startSeek} | onMouseDown={startSeek} | ||||
onMouseUp={endSeek} | onMouseUp={endSeek} | ||||
onChange={setSeek} | onChange={setSeek} | ||||
@@ -242,6 +242,7 @@ export const VideoFilePreview = React.forwardRef<VideoFilePreviewDerivedComponen | |||||
className: 'font-bold', | className: 'font-bold', | ||||
valueProps: { | valueProps: { | ||||
ref: filenameRef, | ref: filenameRef, | ||||
title: fileWithMetadata.name, | |||||
children: fileWithMetadata.name, | children: fileWithMetadata.name, | ||||
}, | }, | ||||
}, | }, | ||||
@@ -294,7 +295,7 @@ export const VideoFilePreview = React.forwardRef<VideoFilePreviewDerivedComponen | |||||
name="action" | name="action" | ||||
value="download" | value="download" | ||||
className={clsx( | 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', | |||||
'h-12 flex text-primary disabled:text-primary focus:text-secondary active:text-tertiary items-center justify-center leading-none gap-4 select-none', | |||||
'focus:outline-0', | 'focus:outline-0', | ||||
'disabled:opacity-50 disabled:cursor-not-allowed', | 'disabled:opacity-50 disabled:cursor-not-allowed', | ||||
)} | )} | ||||
@@ -12,11 +12,14 @@ export const useFileUrl = (options: UseFileUrlOptions) => { | |||||
React.useEffect(() => { | React.useEffect(() => { | ||||
if (!file) { | if (!file) { | ||||
setFileWithUrl(undefined); | |||||
if (fileWithUrl) { | |||||
setFileWithUrl(undefined); | |||||
} | |||||
setLoading(false); | setLoading(false); | ||||
return; | return; | ||||
} | } | ||||
setFileWithUrl(undefined); | |||||
setLoading(true); | setLoading(true); | ||||
addDataUrl(file) | addDataUrl(file) | ||||
.then((fileWithUrl) => { | .then((fileWithUrl) => { | ||||
@@ -24,7 +27,6 @@ export const useFileUrl = (options: UseFileUrlOptions) => { | |||||
setLoading(false); | setLoading(false); | ||||
}) | }) | ||||
.catch(() => { | .catch(() => { | ||||
setFileWithUrl(file); | |||||
setLoading(false); | setLoading(false); | ||||
}); | }); | ||||
}, [file]); | }, [file]); | ||||
@@ -40,7 +42,7 @@ export interface UseFileMetadataOptions<T extends Partial<File> = Partial<File>, | |||||
augmentFunction: (file: U) => Promise<T>; | augmentFunction: (file: U) => Promise<T>; | ||||
} | } | ||||
export const useFileMetadata = <T extends Partial<File>>(options: UseFileMetadataOptions<T>) => { | |||||
export const useFileMetadata = <T extends Partial<FileWithDataUrl>>(options: UseFileMetadataOptions<T>) => { | |||||
const { file, augmentFunction } = options; | const { file, augmentFunction } = options; | ||||
const [fileWithMetadata, setFileWithMetadata] = React.useState<T | undefined>(file as T | undefined); | const [fileWithMetadata, setFileWithMetadata] = React.useState<T | undefined>(file as T | undefined); | ||||
const [loading, setLoading] = React.useState(false); | const [loading, setLoading] = React.useState(false); | ||||
@@ -48,11 +50,14 @@ export const useFileMetadata = <T extends Partial<File>>(options: UseFileMetadat | |||||
React.useEffect(() => { | React.useEffect(() => { | ||||
if (!file) { | if (!file) { | ||||
setFileWithMetadata(undefined); | |||||
if (fileWithMetadata) { | |||||
setFileWithMetadata(undefined); | |||||
} | |||||
setLoading(false); | setLoading(false); | ||||
return; | return; | ||||
} | } | ||||
setFileWithMetadata(undefined); | |||||
setLoading(true); | setLoading(true); | ||||
setError(undefined); | setError(undefined); | ||||
augmentFunction(file) | augmentFunction(file) | ||||
@@ -61,6 +66,7 @@ export const useFileMetadata = <T extends Partial<File>>(options: UseFileMetadat | |||||
setLoading(false); | setLoading(false); | ||||
}) | }) | ||||
.catch((error) => { | .catch((error) => { | ||||
setFileWithMetadata(file as T); | |||||
setError(error); | setError(error); | ||||
setLoading(false); | setLoading(false); | ||||
}); | }); | ||||
@@ -13,6 +13,16 @@ export const useImageControls = (options = {} as UseImageControlsOptions) => { | |||||
const imageRef = forwardedRef ?? defaultRef; | const imageRef = forwardedRef ?? defaultRef; | ||||
const filenameRef = React.useRef<HTMLElement>(null); | const filenameRef = React.useRef<HTMLElement>(null); | ||||
React.useEffect(() => { | |||||
if (fullScreen) { | |||||
window.document.body.style.overflow = 'hidden'; | |||||
} | |||||
if (!fullScreen) { | |||||
window.document.body.style.overflow = ''; | |||||
} | |||||
}, [fullScreen]); | |||||
const toggleFullScreen = React.useCallback(() => { | const toggleFullScreen = React.useCallback(() => { | ||||
setFullScreen((b) => !b); | setFullScreen((b) => !b); | ||||
}, []); | }, []); | ||||
@@ -10,7 +10,6 @@ export interface UseMediaControlsOptions<T extends HTMLMediaElement> { | |||||
export const useMediaControls = <T extends HTMLMediaElement>({ | export const useMediaControls = <T extends HTMLMediaElement>({ | ||||
controllerRef: forwardedRef, | controllerRef: forwardedRef, | ||||
actionFormKey = 'action' as const, | actionFormKey = 'action' as const, | ||||
visualizationMode: initialVisualizationMode, | |||||
}: UseMediaControlsOptions<T>) => { | }: UseMediaControlsOptions<T>) => { | ||||
const defaultRef = React.useRef<T>(null); | const defaultRef = React.useRef<T>(null); | ||||
const ref = forwardedRef ?? defaultRef; | const ref = forwardedRef ?? defaultRef; | ||||
@@ -18,6 +17,7 @@ export const useMediaControls = <T extends HTMLMediaElement>({ | |||||
const volumeRef = React.useRef<HTMLInputElement>(null); | const volumeRef = React.useRef<HTMLInputElement>(null); | ||||
const filenameRef = React.useRef<HTMLElement>(null); | const filenameRef = React.useRef<HTMLElement>(null); | ||||
const visualizationId = React.useId(); | const visualizationId = React.useId(); | ||||
const formId = React.useId(); | |||||
const [isPlaying, setIsPlaying] = React.useState(false); | const [isPlaying, setIsPlaying] = React.useState(false); | ||||
const [isSeeking, setIsSeeking] = React.useState(false); | const [isSeeking, setIsSeeking] = React.useState(false); | ||||
const [currentTimeDisplay, setCurrentTimeDisplay] = React.useState<number>(); | const [currentTimeDisplay, setCurrentTimeDisplay] = React.useState<number>(); | ||||
@@ -45,10 +45,9 @@ export const useMediaControls = <T extends HTMLMediaElement>({ | |||||
}, []); | }, []); | ||||
const setSeek: React.ChangeEventHandler<HTMLInputElement> = React.useCallback((e) => { | const setSeek: React.ChangeEventHandler<HTMLInputElement> = React.useCallback((e) => { | ||||
if (!(typeof ref === 'object' && ref !== null)) { | |||||
if (!(typeof ref === 'object' && ref)) { | |||||
return; | return; | ||||
} | } | ||||
const { current: mediaController } = ref; | const { current: mediaController } = ref; | ||||
if (!mediaController) { | if (!mediaController) { | ||||
return; | return; | ||||
@@ -65,12 +64,10 @@ export const useMediaControls = <T extends HTMLMediaElement>({ | |||||
}, [ref, isSeeking, doSetSeek]); | }, [ref, isSeeking, doSetSeek]); | ||||
const endSeek: React.MouseEventHandler<HTMLInputElement> = React.useCallback((e) => { | const endSeek: React.MouseEventHandler<HTMLInputElement> = React.useCallback((e) => { | ||||
if (!(typeof ref === 'object' && ref !== null)) { | |||||
if (!(typeof ref === 'object' && ref)) { | |||||
return; | return; | ||||
} | } | ||||
const { current: mediaController } = ref; | const { current: mediaController } = ref; | ||||
if (!mediaController) { | if (!mediaController) { | ||||
return; | return; | ||||
} | } | ||||
@@ -96,7 +93,7 @@ export const useMediaControls = <T extends HTMLMediaElement>({ | |||||
const currentTime = videoElement.currentTime; | const currentTime = videoElement.currentTime; | ||||
setCurrentTimeDisplay(currentTime); | setCurrentTimeDisplay(currentTime); | ||||
if (!seekRef.current) { | |||||
if (!(typeof seekRef === 'object' && seekRef)) { | |||||
return; | return; | ||||
} | } | ||||
const { current: seek } = seekRef; | const { current: seek } = seekRef; | ||||
@@ -104,24 +101,32 @@ export const useMediaControls = <T extends HTMLMediaElement>({ | |||||
return; | return; | ||||
} | } | ||||
seek.value = String(currentTime); | seek.value = String(currentTime); | ||||
}, [isSeeking]); | |||||
}, [isSeeking, seekRef]); | |||||
const toggleSeekTimeCountMode = React.useCallback(() => { | const toggleSeekTimeCountMode = React.useCallback(() => { | ||||
setIsSeekTimeCountingDown((b) => !b); | setIsSeekTimeCountingDown((b) => !b); | ||||
}, []); | }, []); | ||||
const download = React.useCallback(() => { | const download = React.useCallback(() => { | ||||
if (!(typeof ref === 'object' && ref?.current !== null)) { | |||||
if (!(typeof ref === 'object' && ref)) { | |||||
return; | |||||
} | |||||
const { current: mediaController } = ref; | |||||
if (!mediaController) { | |||||
return; | return; | ||||
} | } | ||||
if (!(typeof filenameRef === 'object' && filenameRef?.current !== null)) { | |||||
if (!(typeof filenameRef === 'object' && filenameRef)) { | |||||
return; | |||||
} | |||||
const { current: filename } = filenameRef; | |||||
if (!filename) { | |||||
return; | return; | ||||
} | } | ||||
const downloadLink = window.document.createElement('a'); | const downloadLink = window.document.createElement('a'); | ||||
downloadLink.download = filenameRef.current.textContent ?? 'image'; | |||||
downloadLink.href = ref.current.currentSrc; | |||||
downloadLink.download = filename.textContent ?? 'media'; | |||||
downloadLink.href = mediaController.currentSrc; | |||||
downloadLink.addEventListener('click', () => { | downloadLink.addEventListener('click', () => { | ||||
downloadLink.remove(); | downloadLink.remove(); | ||||
}); | }); | ||||
@@ -136,7 +141,6 @@ export const useMediaControls = <T extends HTMLMediaElement>({ | |||||
}), [togglePlayback, toggleSeekTimeCountMode, download]); | }), [togglePlayback, toggleSeekTimeCountMode, download]); | ||||
const handleAction: React.FormEventHandler<HTMLFormElement> = React.useCallback((e) => { | const handleAction: React.FormEventHandler<HTMLFormElement> = React.useCallback((e) => { | ||||
e.preventDefault(); | |||||
e.preventDefault(); | e.preventDefault(); | ||||
const nativeEvent = e.nativeEvent as unknown as { submitter: HTMLElement }; | const nativeEvent = e.nativeEvent as unknown as { submitter: HTMLElement }; | ||||
const formData = getFormValues( | const formData = getFormValues( | ||||
@@ -151,77 +155,66 @@ export const useMediaControls = <T extends HTMLMediaElement>({ | |||||
}, [actions, actionFormKey]); | }, [actions, actionFormKey]); | ||||
const adjustVolume: React.ChangeEventHandler<HTMLInputElement> = React.useCallback((e) => { | const adjustVolume: React.ChangeEventHandler<HTMLInputElement> = React.useCallback((e) => { | ||||
if (!(typeof ref === 'object' && ref !== null)) { | |||||
if (!(typeof ref === 'object' && ref)) { | |||||
return; | return; | ||||
} | } | ||||
if (!ref.current) { | |||||
const { current: mediaController } = ref; | |||||
if (!mediaController) { | |||||
return; | return; | ||||
} | } | ||||
const { value } = e.currentTarget; | const { value } = e.currentTarget; | ||||
ref.current.volume = Number(value); | |||||
mediaController.volume = Number(value); | |||||
}, [ref]); | }, [ref]); | ||||
React.useEffect(() => { | React.useEffect(() => { | ||||
if (!(typeof ref === 'object' && ref !== null)) { | |||||
if (!(typeof ref === 'object' && ref)) { | |||||
return; | return; | ||||
} | } | ||||
if (!ref.current) { | |||||
const { current: mediaController } = ref; | |||||
if (!mediaController) { | |||||
return; | return; | ||||
} | } | ||||
if (isPlaying) { | if (isPlaying) { | ||||
void ref.current.play(); | |||||
void mediaController.play(); | |||||
return | return | ||||
} | } | ||||
ref.current.pause(); | |||||
mediaController.pause(); | |||||
}, [isPlaying, ref]); | }, [isPlaying, ref]); | ||||
React.useEffect(() => { | React.useEffect(() => { | ||||
if (!seekRef.current) { | |||||
if (!(typeof seekRef === 'object' && seekRef)) { | |||||
return; | return; | ||||
} | } | ||||
const { current: seek } = seekRef; | const { current: seek } = seekRef; | ||||
if (!seek) { | if (!seek) { | ||||
return; | return; | ||||
} | } | ||||
seek.value = String(currentTimeDisplay); | seek.value = String(currentTimeDisplay); | ||||
}, [currentTimeDisplay]); | |||||
}, [currentTimeDisplay, seekRef]); | |||||
React.useEffect(() => { | React.useEffect(() => { | ||||
if (!seekRef.current) { | |||||
return; | |||||
} | |||||
const { current: seek } = seekRef; | |||||
if (!seek) { | |||||
if (!(typeof ref === 'object' && ref)) { | |||||
return; | return; | ||||
} | } | ||||
seek.max = String(durationDisplay); | |||||
}, [durationDisplay]); | |||||
React.useEffect(() => { | |||||
if (!volumeRef.current) { | |||||
const { current: mediaController } = ref; | |||||
if (!mediaController) { | |||||
return; | return; | ||||
} | } | ||||
if (!(typeof ref === 'object' && ref !== null)) { | |||||
if (!(typeof volumeRef === 'object' && volumeRef)) { | |||||
return; | return; | ||||
} | } | ||||
if (!ref.current) { | |||||
const { current: volume } = volumeRef; | |||||
if (!volume) { | |||||
return; | return; | ||||
} | } | ||||
const { current: mediaController } = ref; | |||||
const { current: volume } = volumeRef; | |||||
volume.value = String(mediaController.volume); | volume.value = String(mediaController.volume); | ||||
}, [ref]); | |||||
}, [ref, volumeRef]); | |||||
return React.useMemo(() => ({ | return React.useMemo(() => ({ | ||||
seekRef, | seekRef, | ||||
@@ -243,6 +236,7 @@ export const useMediaControls = <T extends HTMLMediaElement>({ | |||||
handleAction, | handleAction, | ||||
filenameRef, | filenameRef, | ||||
visualizationId, | visualizationId, | ||||
formId, | |||||
}), [ | }), [ | ||||
refreshControls, | refreshControls, | ||||
isPlaying, | isPlaying, | ||||
@@ -261,5 +255,6 @@ export const useMediaControls = <T extends HTMLMediaElement>({ | |||||
handleAction, | handleAction, | ||||
filenameRef, | filenameRef, | ||||
visualizationId, | visualizationId, | ||||
formId, | |||||
]); | ]); | ||||
}; | }; |
@@ -1,7 +1,7 @@ | |||||
export * from './components/AudioFilePreview'; | export * from './components/AudioFilePreview'; | ||||
//export * from './components/AudioMiniFilePreview'; | |||||
export * from './components/AudioMiniFilePreview'; | |||||
export * from './components/BinaryFilePreview'; | export * from './components/BinaryFilePreview'; | ||||
//export * from './components/FileSelectBox'; | |||||
export * from './components/FileSelectBox'; | |||||
export * from './components/ImageFilePreview'; | export * from './components/ImageFilePreview'; | ||||
export * from './components/TextFilePreview'; | export * from './components/TextFilePreview'; | ||||
export * from './components/VideoFilePreview'; | export * from './components/VideoFilePreview'; | ||||
@@ -41,8 +41,6 @@ export interface MaskedTextInputProps extends Omit<React.HTMLProps<MaskedTextInp | |||||
/** | /** | ||||
* Component for inputting textual values. | * Component for inputting textual values. | ||||
* | |||||
* This component supports multiline input and adjusts its layout accordingly. | |||||
*/ | */ | ||||
export const MaskedTextInput = React.forwardRef<MaskedTextInputDerivedElement, MaskedTextInputProps>( | export const MaskedTextInput = React.forwardRef<MaskedTextInputDerivedElement, MaskedTextInputProps>( | ||||
( | ( | ||||
@@ -1,10 +1,11 @@ | |||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import * as TextControlBase from '@tesseract-design/web-base-textcontrol'; | import * as TextControlBase from '@tesseract-design/web-base-textcontrol'; | ||||
import clsx from 'clsx'; | import clsx from 'clsx'; | ||||
import styles from './style.module.css'; | |||||
type MaskedTextInputDerivedElement = HTMLInputElement; | |||||
type SpinnerDerivedElement = HTMLInputElement; | |||||
export interface MaskedTextInputProps extends Omit<React.HTMLProps<MaskedTextInputDerivedElement>, 'size' | 'type' | 'label'> { | |||||
export interface SpinnerProps extends Omit<React.HTMLProps<SpinnerDerivedElement>, 'size' | 'type' | 'label'> { | |||||
/** | /** | ||||
* Short textual description indicating the nature of the component's value. | * Short textual description indicating the nature of the component's value. | ||||
*/ | */ | ||||
@@ -40,11 +41,9 @@ export interface MaskedTextInputProps extends Omit<React.HTMLProps<MaskedTextInp | |||||
} | } | ||||
/** | /** | ||||
* Component for inputting textual values. | |||||
* | |||||
* This component supports multiline input and adjusts its layout accordingly. | |||||
* Component for inputting numeric values. | |||||
*/ | */ | ||||
export const MaskedTextInput = React.forwardRef<MaskedTextInputDerivedElement, MaskedTextInputProps>( | |||||
export const Spinner = React.forwardRef<SpinnerDerivedElement, SpinnerProps>( | |||||
( | ( | ||||
{ | { | ||||
label, | label, | ||||
@@ -57,7 +56,7 @@ export const MaskedTextInput = React.forwardRef<MaskedTextInputDerivedElement, M | |||||
hiddenLabel = false, | hiddenLabel = false, | ||||
className, | className, | ||||
...etcProps | ...etcProps | ||||
}: MaskedTextInputProps, | |||||
}: SpinnerProps, | |||||
ref, | ref, | ||||
) => { | ) => { | ||||
const labelId = React.useId(); | const labelId = React.useId(); | ||||
@@ -78,12 +77,13 @@ export const MaskedTextInput = React.forwardRef<MaskedTextInputDerivedElement, M | |||||
{...etcProps} | {...etcProps} | ||||
ref={ref} | ref={ref} | ||||
aria-labelledby={labelId} | aria-labelledby={labelId} | ||||
type="password" | |||||
type="number" | |||||
data-testid="input" | data-testid="input" | ||||
className={clsx( | className={clsx( | ||||
'bg-negative rounded-inherit w-full peer block', | |||||
'bg-negative rounded-inherit w-full peer block tabular-nums', | |||||
'focus:outline-0', | 'focus:outline-0', | ||||
'disabled:opacity-50 disabled:cursor-not-allowed', | 'disabled:opacity-50 disabled:cursor-not-allowed', | ||||
styles['spinner'], | |||||
{ | { | ||||
'text-xxs': size === 'small', | 'text-xxs': size === 'small', | ||||
'text-xs': size === 'medium', | 'text-xs': size === 'medium', | ||||
@@ -195,4 +195,4 @@ export const MaskedTextInput = React.forwardRef<MaskedTextInputDerivedElement, M | |||||
}, | }, | ||||
); | ); | ||||
MaskedTextInput.displayName = 'MaskedTextInput'; | |||||
Spinner.displayName = 'Spinner'; |
@@ -0,0 +1,12 @@ | |||||
.spinner { | |||||
position: relative; | |||||
} | |||||
.spinner::-webkit-inner-spin-button { | |||||
position: absolute; | |||||
top: 0; | |||||
right: 0; | |||||
height: 100%; | |||||
width: 1.5rem; | |||||
z-index: 2; | |||||
} |
@@ -1 +1,2 @@ | |||||
export * from './components/Slider'; | export * from './components/Slider'; | ||||
export * from './components/Spinner'; |
@@ -44,7 +44,6 @@ export const Refractor = React.forwardRef<PrismDerivedElement, PrismProps>(({ | |||||
{language} | {language} | ||||
</div> | </div> | ||||
{actions} | {actions} | ||||
</div> | </div> | ||||
)} | )} | ||||
<div | <div | ||||
@@ -0,0 +1,17 @@ | |||||
import * as React from 'react'; | |||||
export interface UseEnhancedOptions { | |||||
enhanced: boolean; | |||||
} | |||||
export const useEnhanced = (options: UseEnhancedOptions) => { | |||||
const { enhanced: enhancedProp } = options; | |||||
const [enhanced, setEnhanced] = React.useState(false); | |||||
React.useEffect(() => { | |||||
setEnhanced(enhancedProp); | |||||
}, [enhancedProp]); | |||||
return React.useMemo(() => ({ | |||||
enhanced, | |||||
}), [enhanced]); | |||||
}; |
@@ -3,15 +3,16 @@ import {WaveSurferOptions} from 'wavesurfer.js'; | |||||
import clsx from 'clsx'; | import clsx from 'clsx'; | ||||
import {getFormValues} from '@theoryofnekomata/formxtra'; | import {getFormValues} from '@theoryofnekomata/formxtra'; | ||||
type SpectrogramCanvasDerivedComponent = HTMLAudioElement; | |||||
type SpectrogramCanvasDerivedElement = HTMLDivElement; | |||||
export interface SpectrogramCanvasProps | export interface SpectrogramCanvasProps | ||||
extends React.HTMLProps<SpectrogramCanvasDerivedComponent>, | |||||
extends React.HTMLProps<SpectrogramCanvasDerivedElement>, | |||||
Omit<WaveSurferOptions, 'waveColor' | 'plugins' | 'height' | 'media' | 'container' | 'fillParent' | 'url' | 'autoplay' | 'renderFunction'> { | Omit<WaveSurferOptions, 'waveColor' | 'plugins' | 'height' | 'media' | 'container' | 'fillParent' | 'url' | 'autoplay' | 'renderFunction'> { | ||||
waveColor?: string; | waveColor?: string; | ||||
audioRef?: React.Ref<HTMLAudioElement>; | |||||
} | } | ||||
export const SpectrogramCanvas = React.forwardRef<SpectrogramCanvasDerivedComponent, SpectrogramCanvasProps>(({ | |||||
export const SpectrogramCanvas = React.forwardRef<SpectrogramCanvasDerivedElement, SpectrogramCanvasProps>(({ | |||||
className, | className, | ||||
children, | children, | ||||
controls, | controls, | ||||
@@ -36,17 +37,16 @@ export const SpectrogramCanvas = React.forwardRef<SpectrogramCanvasDerivedCompon | |||||
sampleRate, | sampleRate, | ||||
splitChannels, | splitChannels, | ||||
normalize, | normalize, | ||||
audioRef, | |||||
...etcProps | ...etcProps | ||||
}, forwardedRef) => { | }, forwardedRef) => { | ||||
const [isPlaying, setIsPlaying] = React.useState(false); | const [isPlaying, setIsPlaying] = React.useState(false); | ||||
const defaultRef = React.useRef<HTMLAudioElement>(null); | |||||
const ref = forwardedRef ?? defaultRef; | |||||
const containerRef = React.useRef<HTMLDivElement>(null); | |||||
const defaultRef = React.useRef<SpectrogramCanvasDerivedElement>(null); | |||||
const containerRef = forwardedRef ?? defaultRef; | |||||
const waveSurferRef = React.useRef<any>(null); | const waveSurferRef = React.useRef<any>(null); | ||||
const cursorRef = React.useRef<HTMLDivElement>(null); | const cursorRef = React.useRef<HTMLDivElement>(null); | ||||
const handleAction: React.FormEventHandler<HTMLFormElement> = (e) => { | const handleAction: React.FormEventHandler<HTMLFormElement> = (e) => { | ||||
e.preventDefault(); | |||||
e.preventDefault(); | e.preventDefault(); | ||||
const nativeEvent = e.nativeEvent as unknown as { submitter: HTMLElement }; | const nativeEvent = e.nativeEvent as unknown as { submitter: HTMLElement }; | ||||
const formData = getFormValues( | const formData = getFormValues( | ||||
@@ -66,24 +66,36 @@ export const SpectrogramCanvas = React.forwardRef<SpectrogramCanvasDerivedCompon | |||||
}; | }; | ||||
React.useEffect(() => { | React.useEffect(() => { | ||||
if (!(typeof audioRef === 'object' && audioRef)) { | |||||
return; | |||||
} | |||||
const { current: media } = audioRef; | |||||
if (!media) { | |||||
return; | |||||
} | |||||
if (!(typeof containerRef === 'object' && containerRef)) { | |||||
return; | |||||
} | |||||
const { current: container } = containerRef; | const { current: container } = containerRef; | ||||
const media = typeof ref === 'object' ? ref?.current : null; | |||||
const { current: waveSurferCurrent } = waveSurferRef; | |||||
if (!container) { | |||||
return; | |||||
} | |||||
if (!(typeof cursorRef === 'object' && cursorRef)) { | |||||
return; | |||||
} | |||||
const { current: cursor } = cursorRef; | |||||
if (!cursor) { | |||||
return; | |||||
} | |||||
const handleTimeUpdate = (e: Event) => { | const handleTimeUpdate = (e: Event) => { | ||||
const thisMedia = e.currentTarget as HTMLAudioElement; | const thisMedia = e.currentTarget as HTMLAudioElement; | ||||
if (cursorRef.current) { | |||||
cursorRef.current.style.width = `${(thisMedia?.currentTime ?? 0) / (thisMedia?.duration ?? 1) * 100}%`; | |||||
} | |||||
cursor.style.width = `${(thisMedia?.currentTime ?? 0) / (thisMedia?.duration ?? 1) * 100}%`; | |||||
}; | }; | ||||
const load = async (ref: React.Ref<HTMLAudioElement>) => { | |||||
if (!(typeof ref === 'object' && ref?.current)) { | |||||
return; | |||||
} | |||||
if (!(typeof containerRef === 'object' && containerRef?.current)) { | |||||
return; | |||||
} | |||||
const load = async (media: HTMLAudioElement, container: HTMLElement) => { | |||||
const { default: WaveSurfer } = await import('wavesurfer.js'); | const { default: WaveSurfer } = await import('wavesurfer.js'); | ||||
const { default: Spectrogram, } = await import('wavesurfer.js/dist/plugins/spectrogram'); | const { default: Spectrogram, } = await import('wavesurfer.js/dist/plugins/spectrogram'); | ||||
const dummyContainer = window.document.createElement('div'); | const dummyContainer = window.document.createElement('div'); | ||||
@@ -115,7 +127,7 @@ export const SpectrogramCanvas = React.forwardRef<SpectrogramCanvasDerivedCompon | |||||
normalize, | normalize, | ||||
plugins: [], | plugins: [], | ||||
cursorWidth, | cursorWidth, | ||||
media: media ?? undefined, | |||||
media, | |||||
}); | }); | ||||
let colorMap: Array<[number, number, number, number]> = []; | let colorMap: Array<[number, number, number, number]> = []; | ||||
@@ -130,10 +142,10 @@ export const SpectrogramCanvas = React.forwardRef<SpectrogramCanvasDerivedCompon | |||||
} | } | ||||
waveSurferInstance.registerPlugin( | waveSurferInstance.registerPlugin( | ||||
Spectrogram.create({ | Spectrogram.create({ | ||||
container: containerRef.current, | |||||
container, | |||||
labels: true, | labels: true, | ||||
labelsColor: 'rgb(0 0 0/0)', | labelsColor: 'rgb(0 0 0/0)', | ||||
height: containerRef.current.clientHeight, | |||||
height: container.clientHeight, | |||||
colorMap, | colorMap, | ||||
}), | }), | ||||
) | ) | ||||
@@ -147,12 +159,17 @@ export const SpectrogramCanvas = React.forwardRef<SpectrogramCanvasDerivedCompon | |||||
} | } | ||||
dummyContainer.remove(); | dummyContainer.remove(); | ||||
}); | }); | ||||
await waveSurferInstance.load(ref.current.currentSrc); | |||||
waveSurferInstance.setTime(ref.current.currentTime); | |||||
waveSurferRef.current = waveSurferInstance; | |||||
media!.addEventListener('timeupdate', handleTimeUpdate); | |||||
await waveSurferInstance.load(media.currentSrc); | |||||
waveSurferInstance.setTime(media.currentTime); | |||||
media.addEventListener('timeupdate', handleTimeUpdate); | |||||
return waveSurferInstance; | |||||
}; | }; | ||||
void load(ref); | |||||
const { current: waveSurferCurrent } = waveSurferRef; | |||||
void load(media, container).then((i) => { | |||||
waveSurferRef.current = i; | |||||
}); | |||||
return () => { | return () => { | ||||
if (waveSurferCurrent) { | if (waveSurferCurrent) { | ||||
(waveSurferCurrent as unknown as Record<string, Function>).destroy(); | (waveSurferCurrent as unknown as Record<string, Function>).destroy(); | ||||
@@ -160,13 +177,12 @@ export const SpectrogramCanvas = React.forwardRef<SpectrogramCanvasDerivedCompon | |||||
if (container) { | if (container) { | ||||
container.innerHTML = ''; | container.innerHTML = ''; | ||||
} | } | ||||
if (!media) { | |||||
return; | |||||
if (media) { | |||||
media.removeEventListener('timeupdate', handleTimeUpdate); | |||||
} | } | ||||
media.removeEventListener('timeupdate', handleTimeUpdate); | |||||
}; | }; | ||||
}, [ | }, [ | ||||
ref, | |||||
audioRef, | |||||
autoPlay, | autoPlay, | ||||
waveColor, | waveColor, | ||||
progressColor, | progressColor, | ||||
@@ -188,6 +204,7 @@ export const SpectrogramCanvas = React.forwardRef<SpectrogramCanvasDerivedCompon | |||||
splitChannels, | splitChannels, | ||||
normalize, | normalize, | ||||
cursorWidth, | cursorWidth, | ||||
containerRef, | |||||
]); | ]); | ||||
return ( | return ( | ||||
@@ -3,13 +3,15 @@ import {WaveSurferOptions} from 'wavesurfer.js'; | |||||
import clsx from 'clsx'; | import clsx from 'clsx'; | ||||
import {getFormValues} from '@theoryofnekomata/formxtra'; | import {getFormValues} from '@theoryofnekomata/formxtra'; | ||||
type SpectrogramCanvasDerivedComponent = HTMLAudioElement; | |||||
type WaveformCanvasDerivedElement = HTMLDivElement; | |||||
export interface WaveformCanvasProps | export interface WaveformCanvasProps | ||||
extends React.HTMLProps<SpectrogramCanvasDerivedComponent>, | |||||
Omit<WaveSurferOptions, 'plugins' | 'height' | 'media' | 'container' | 'fillParent' | 'url' | 'autoplay' | 'renderFunction'> {} | |||||
extends React.HTMLProps<WaveformCanvasDerivedElement>, | |||||
Omit<WaveSurferOptions, 'plugins' | 'height' | 'media' | 'container' | 'fillParent' | 'url' | 'autoplay' | 'renderFunction'> { | |||||
audioRef?: React.Ref<HTMLAudioElement>; | |||||
} | |||||
export const WaveformCanvas = React.forwardRef<SpectrogramCanvasDerivedComponent, WaveformCanvasProps>(({ | |||||
export const WaveformCanvas = React.forwardRef<WaveformCanvasDerivedElement, WaveformCanvasProps>(({ | |||||
className, | className, | ||||
children, | children, | ||||
controls, | controls, | ||||
@@ -34,16 +36,15 @@ export const WaveformCanvas = React.forwardRef<SpectrogramCanvasDerivedComponent | |||||
sampleRate, | sampleRate, | ||||
splitChannels, | splitChannels, | ||||
normalize, | normalize, | ||||
audioRef, | |||||
...etcProps | ...etcProps | ||||
}, forwardedRef) => { | }, forwardedRef) => { | ||||
const [isPlaying, setIsPlaying] = React.useState(false); | const [isPlaying, setIsPlaying] = React.useState(false); | ||||
const defaultRef = React.useRef<HTMLAudioElement>(null); | |||||
const ref = forwardedRef ?? defaultRef; | |||||
const containerRef = React.useRef<HTMLDivElement>(null); | |||||
const defaultRef = React.useRef<WaveformCanvasDerivedElement>(null); | |||||
const containerRef = forwardedRef ?? defaultRef; | |||||
const waveSurferRef = React.useRef<any>(null); | const waveSurferRef = React.useRef<any>(null); | ||||
const handleAction: React.FormEventHandler<HTMLFormElement> = (e) => { | const handleAction: React.FormEventHandler<HTMLFormElement> = (e) => { | ||||
e.preventDefault(); | |||||
e.preventDefault(); | e.preventDefault(); | ||||
const nativeEvent = e.nativeEvent as unknown as { submitter: HTMLElement }; | const nativeEvent = e.nativeEvent as unknown as { submitter: HTMLElement }; | ||||
const formData = getFormValues( | const formData = getFormValues( | ||||
@@ -63,21 +64,26 @@ export const WaveformCanvas = React.forwardRef<SpectrogramCanvasDerivedComponent | |||||
}; | }; | ||||
React.useEffect(() => { | React.useEffect(() => { | ||||
const { current: container } = containerRef; | |||||
const media = typeof ref === 'object' ? ref?.current : null; | |||||
const { current: waveSurferCurrent } = waveSurferRef; | |||||
if (!(typeof audioRef === 'object' && audioRef)) { | |||||
return; | |||||
} | |||||
const { current: media } = audioRef; | |||||
if (!media) { | |||||
return; | |||||
} | |||||
const load = async (ref: React.Ref<HTMLAudioElement>) => { | |||||
if (!(typeof ref === 'object' && ref?.current)) { | |||||
return; | |||||
} | |||||
if (!(typeof containerRef === 'object' && containerRef)) { | |||||
return; | |||||
} | |||||
const { current: container } = containerRef; | |||||
if (!container) { | |||||
return; | |||||
} | |||||
if (!(typeof containerRef === 'object' && containerRef?.current)) { | |||||
return; | |||||
} | |||||
const { default: WaveSurfer } = await import('wavesurfer.js'); | |||||
const load = async (media: HTMLAudioElement, container: HTMLElement) => { | |||||
const {default: WaveSurfer} = await import('wavesurfer.js'); | |||||
const waveSurferInstance = WaveSurfer.create({ | const waveSurferInstance = WaveSurfer.create({ | ||||
container: containerRef.current, | |||||
container, | |||||
height: 'auto', | height: 'auto', | ||||
autoplay: autoPlay, | autoplay: autoPlay, | ||||
fillParent: true, | fillParent: true, | ||||
@@ -101,7 +107,8 @@ export const WaveformCanvas = React.forwardRef<SpectrogramCanvasDerivedComponent | |||||
splitChannels, | splitChannels, | ||||
normalize, | normalize, | ||||
cursorWidth, | cursorWidth, | ||||
media: media ?? undefined, | |||||
plugins: [], | |||||
media, | |||||
}); | }); | ||||
waveSurferInstance.on('ready', () => { | waveSurferInstance.on('ready', () => { | ||||
if (!container) { | if (!container) { | ||||
@@ -111,11 +118,20 @@ export const WaveformCanvas = React.forwardRef<SpectrogramCanvasDerivedComponent | |||||
container.removeChild(container.children[0]); | container.removeChild(container.children[0]); | ||||
} | } | ||||
}); | }); | ||||
await waveSurferInstance.load(ref.current.currentSrc); | |||||
waveSurferInstance.setTime(ref.current.currentTime); | |||||
waveSurferRef.current = waveSurferInstance; | |||||
await waveSurferInstance.load(media.currentSrc); | |||||
waveSurferInstance.setTime(media.currentTime); | |||||
return waveSurferInstance; | |||||
}; | }; | ||||
void load(ref); | |||||
const { current: waveSurferCurrent } = waveSurferRef; | |||||
load(media, container) | |||||
.then((i) => { | |||||
waveSurferRef.current = i; | |||||
}) | |||||
.catch((error) => { | |||||
console.log(error); | |||||
}); | |||||
return () => { | return () => { | ||||
if (waveSurferCurrent) { | if (waveSurferCurrent) { | ||||
(waveSurferCurrent as unknown as Record<string, Function>).destroy(); | (waveSurferCurrent as unknown as Record<string, Function>).destroy(); | ||||
@@ -125,7 +141,7 @@ export const WaveformCanvas = React.forwardRef<SpectrogramCanvasDerivedComponent | |||||
} | } | ||||
}; | }; | ||||
}, [ | }, [ | ||||
ref, | |||||
audioRef, | |||||
autoPlay, | autoPlay, | ||||
waveColor, | waveColor, | ||||
progressColor, | progressColor, | ||||
@@ -147,6 +163,7 @@ export const WaveformCanvas = React.forwardRef<SpectrogramCanvasDerivedComponent | |||||
splitChannels, | splitChannels, | ||||
normalize, | normalize, | ||||
cursorWidth, | cursorWidth, | ||||
containerRef, | |||||
]); | ]); | ||||
return ( | return ( | ||||
@@ -169,15 +169,15 @@ const BlobPage: NextPage = () => { | |||||
</Section> | </Section> | ||||
<Section title="FileSelectBox"> | <Section title="FileSelectBox"> | ||||
<Subsection title="Single File"> | <Subsection title="Single File"> | ||||
{/*<BlobReact.FileSelectBox*/} | |||||
{/* border*/} | |||||
{/* enhanced*/} | |||||
{/* label="Primary Image"*/} | |||||
{/* hint="Select any files here"*/} | |||||
{/* block*/} | |||||
{/* className="h-64"*/} | |||||
{/* onChange={(e) => console.log(e.currentTarget.files)}*/} | |||||
{/*/>*/} | |||||
<BlobReact.FileSelectBox | |||||
border | |||||
enhanced | |||||
label="Primary Image" | |||||
hint="Select any files here" | |||||
block | |||||
className="sm:h-96" | |||||
onChange={(e) => console.log(e.currentTarget.files)} | |||||
/> | |||||
</Subsection> | </Subsection> | ||||
</Section> | </Section> | ||||
</DefaultLayout> | </DefaultLayout> | ||||
@@ -1,6 +1,6 @@ | |||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import { NextPage } from 'next'; | import { NextPage } from 'next'; | ||||
import {Slider} from '@/categories/number/react'; | |||||
import * as TesseractNumber from '@tesseract-design/web-number-react'; | |||||
import {Section, Subsection} from '@/components/Section'; | import {Section, Subsection} from '@/components/Section'; | ||||
const NumberPage: NextPage = () => { | const NumberPage: NextPage = () => { | ||||
@@ -8,21 +8,24 @@ const NumberPage: NextPage = () => { | |||||
<main className="my-16 md:my-32"> | <main className="my-16 md:my-32"> | ||||
<Section title="Spinner"> | <Section title="Spinner"> | ||||
<Subsection title="Default"> | <Subsection title="Default"> | ||||
TODO | |||||
<input type="number" /> | |||||
<TesseractNumber.Spinner | |||||
min={-100} | |||||
max={100} | |||||
step="any" | |||||
label="Step" | |||||
/> | |||||
</Subsection> | </Subsection> | ||||
</Section> | </Section> | ||||
<Section title="Slider"> | <Section title="Slider"> | ||||
<Subsection title="Default"> | <Subsection title="Default"> | ||||
<Slider | |||||
<TesseractNumber.Slider | |||||
min={-100} | min={-100} | ||||
max={100} | max={100} | ||||
tickMarks={[{ label: 'low', value: 25, }, 50]} | tickMarks={[{ label: 'low', value: 25, }, 50]} | ||||
/> | /> | ||||
</Subsection> | </Subsection> | ||||
<Subsection title="Vertical"> | <Subsection title="Vertical"> | ||||
<Slider | |||||
<TesseractNumber.Slider | |||||
min={-100} | min={-100} | ||||
max={100} | max={100} | ||||
tickMarks={[{ label: 'low', value: 25, }, 50]} | tickMarks={[{ label: 'low', value: 25, }, 50]} | ||||
@@ -1,7 +1,7 @@ | |||||
import * as mimeTypes from 'mime-types'; | import * as mimeTypes from 'mime-types'; | ||||
import {getTextMetadata, TextMetadata} from '@modal-soft/text-utils'; | import {getTextMetadata, TextMetadata} from '@modal-soft/text-utils'; | ||||
import {getImageMetadata, ImageMetadata} from '@modal-soft/image-utils'; | import {getImageMetadata, ImageMetadata} from '@modal-soft/image-utils'; | ||||
import {AudioMetadata, getAudioMetadata} from '@modal-soft/audio-utils'; | |||||
import {getAudioMetadata, AudioMetadata} from '@modal-soft/audio-utils'; | |||||
import {getVideoMetadata, VideoFileMetadata} from '@modal-soft/video-utils'; | import {getVideoMetadata, VideoFileMetadata} from '@modal-soft/video-utils'; | ||||
const MIME_TYPE_DESCRIPTIONS = { | const MIME_TYPE_DESCRIPTIONS = { | ||||
@@ -137,7 +137,7 @@ export const addDataUrl = async (f: Partial<File>): Promise<Partial<FileWithData | |||||
return f; | return f; | ||||
} | } | ||||
interface FileWithResolvedContentType extends Partial<FileWithDataUrl> { | |||||
export interface FileWithResolvedContentType extends Partial<FileWithDataUrl> { | |||||
resolvedType: ContentType; | resolvedType: ContentType; | ||||
} | } | ||||
@@ -204,22 +204,3 @@ export const augmentVideoFile = async <T extends Partial<FileWithDataUrl>>(file: | |||||
fileMutable.metadata = await getVideoMetadata(file.url); | fileMutable.metadata = await getVideoMetadata(file.url); | ||||
return fileMutable as unknown as VideoFile; | return fileMutable as unknown as VideoFile; | ||||
}; | }; | ||||
export type AugmentedFile = TextFile | ImageFile | AudioFile | VideoFile | BinaryFile; | |||||
export const augmentFile = async (f: File): Promise<AugmentedFile> => { | |||||
const contentType = getContentType(f.type, f.name); | |||||
switch (contentType) { | |||||
case ContentType.TEXT: | |||||
return augmentTextFile(f); | |||||
case ContentType.IMAGE: | |||||
return augmentImageFile(f); | |||||
case ContentType.AUDIO: | |||||
return augmentAudioFile(f); | |||||
case ContentType.VIDEO: | |||||
return augmentVideoFile(f); | |||||
default: | |||||
break; | |||||
} | |||||
return augmentBinaryFile(f); | |||||
}; |
@@ -34,6 +34,7 @@ module.exports = { | |||||
'code-global': 'rgb(var(--color-code-global))', | 'code-global': 'rgb(var(--color-code-global))', | ||||
'current': 'currentcolor', | 'current': 'currentcolor', | ||||
'inherit': 'inherit', | 'inherit': 'inherit', | ||||
'transparent': 'transparent', | |||||
}, | }, | ||||
extend: { | extend: { | ||||
fontSize: { | fontSize: { | ||||