瀏覽代碼

Implement all content types

Implement content type previews of text, binary, audio, video, and
image types.
pull/1/head
TheoryOfNekomata 1 年之前
父節點
當前提交
85071932c4
共有 8 個檔案被更改,包括 661 行新增342 行删除
  1. +2
    -0
      packages/web/kitchen-sink/react-next/package.json
  2. +362
    -342
      packages/web/kitchen-sink/react-next/src/pages/categories/blob/index.tsx
  3. +24
    -0
      packages/web/kitchen-sink/react-next/src/utils/audio.ts
  4. +123
    -0
      packages/web/kitchen-sink/react-next/src/utils/blob.ts
  5. +19
    -0
      packages/web/kitchen-sink/react-next/src/utils/image.ts
  6. +47
    -0
      packages/web/kitchen-sink/react-next/src/utils/numeral.ts
  7. +67
    -0
      packages/web/kitchen-sink/react-next/src/utils/text.ts
  8. +17
    -0
      packages/web/kitchen-sink/react-next/yarn.lock

+ 2
- 0
packages/web/kitchen-sink/react-next/package.json 查看文件

@@ -18,6 +18,7 @@
"@tesseract-design/web-option-react": "link:../../categories/option/react",
"language-map": "^1.5.0",
"languagedetect": "^2.0.0",
"mime-types": "^2.1.35",
"next": "12.2.4",
"prismjs": "^1.29.0",
"react": "18.2.0",
@@ -25,6 +26,7 @@
"wavesurfer.js": "^7.0.0-beta.6"
},
"devDependencies": {
"@types/mime-types": "^2.1.1",
"@types/node": "18.6.4",
"@types/prismjs": "^1.26.0",
"@types/react": "18.0.15",


+ 362
- 342
packages/web/kitchen-sink/react-next/src/pages/categories/blob/index.tsx 查看文件

@@ -1,18 +1,25 @@
import {NextPage} from 'next';
import * as React from 'react';
//import * as BlobReact from '@tesseract-design/web-blob-react';

import * as BlobBase from '@tesseract-design/web-base-blob';
import * as ButtonBase from '@tesseract-design/web-base-button';
import WaveSurfer from 'wavesurfer.js';
import LanguageDetect from 'languagedetect';
import Prism from 'prismjs';
import { languages } from 'prismjs/components';

console.log(languages);
import WaveSurfer from 'wavesurfer.js';
import {formatFileSize, formatNumeral, formatSecondsDuration} from '../../../utils/numeral';
import {
ContentType,
getContentType,
getMimeTypeDescription,
readAsArrayBuffer,
readAsDataURL,
readAsText,
} from '../../../utils/blob';
import {getImageMetadata} from '../../../utils/image';
import {getAudioMetadata} from '../../../utils/audio';
import {getTextMetadata} from '../../../utils/text';

interface FileWithPreview extends File {
metadata?: Record<string, string | number>;
metadata?: Record<string, string | number | ArrayBuffer>;
internal?: Record<string, unknown>;
}

@@ -40,340 +47,118 @@ export interface FileButtonProps extends Omit<React.HTMLProps<HTMLInputElement>,
hiddenLabel?: boolean,
}

const formatNumeral = (n?: number) => {
if (typeof n !== 'number') {
return '';
}

if (!Number.isFinite(n)) {
return '';
}

return new Intl.NumberFormat().format(n);
const augmentTextFile = async (f: File) => {
const contents = await readAsText(f);
const metadata = getTextMetadata(contents, f.name);
return {
...f,
name: f.name,
type: f.type,
size: f.size,
lastModified: f.lastModified,
metadata: {
contents,
language: metadata.language,
languageProbability: metadata.languageProbability,
scheme: metadata.scheme,
schemeTitle: metadata.schemeTitle,
},
};
}

const formatFileSize = (size?: number) => {
if (typeof size !== 'number') {
return '';
}

if (!Number.isFinite(size)) {
return '';
}

if (size < (2 ** 10)) {
return `${formatNumeral(size)} bytes`;
}

if (size < (2 ** 20)) {
return `${(size / (2 ** 10)).toFixed(3)} kiB`;
}

if (size < (2 ** 30)) {
return `${(size / (2 ** 20)).toFixed(3)} MiB`;
}

if (size < (2 ** 40)) {
return `${(size / (2 ** 30)).toFixed(3)} GiB`;
}
const augmentImageFile = async (f: File) => {
const previewUrl = await readAsDataURL(f);
const imageMetadata = await getImageMetadata(previewUrl);
return {
name: f.name,
type: f.type,
size: f.size,
lastModified: f.lastModified,
metadata: {
previewUrl,
width: imageMetadata.naturalWidth,
height: imageMetadata.naturalHeight,
},
};
};

const MIME_TYPE_DESCRIPTIONS = {
'image/jpeg': 'JPEG Image',
'image/png': 'PNG Image',
'image/tiff': 'TIFF Image',
'image/svg+xml': 'SVG Image',
'audio/wav': 'WAVE Audio',
'audio/ogg': 'OGG Audio',
'audio/mpeg': 'MPEG Audio',
'application/json': 'JSON Data',
'application/xml': 'XML Data',
} as const;

const getMimeTypeDescription = (type?: string) => {
if (typeof type !== 'string') {
return '';
}
const augmentAudioFile = async (f: File) => {
const previewUrl = await readAsDataURL(f);
const audioExtensions = await getAudioMetadata(previewUrl);
return {
name: f.name,
type: f.type,
size: f.size,
lastModified: f.lastModified,
metadata: {
previewUrl,
duration: audioExtensions.duration,
},
};
};

if (type === 'application/octet-stream') {
return type;
const augmentBinaryFile = async (f: File) => {
const arrayBuffer = await readAsArrayBuffer(f);
return {
name: f.name,
type: f.type,
size: f.size,
lastModified: f.lastModified,
metadata: {
contents: arrayBuffer,
},
}

const {
[type as keyof typeof MIME_TYPE_DESCRIPTIONS]: description = type,
} = MIME_TYPE_DESCRIPTIONS;

return description;
}

const formatSecondsDuration = (seconds: number) => {
const secondsInt = Math.floor(seconds);
const secondsFrac = seconds - secondsInt;
const hh = Math.floor(secondsInt / 3600).toString().padStart(2, '0');
const mm = Math.floor(secondsInt / 60 % 60).toString().padStart(2, '0');
const ss = (secondsInt % 60).toString().padStart(2, '0');
const sss = Math.floor(secondsFrac * 1000).toString().padStart(3, '0');
return `${hh}:${mm}:${ss}.${sss}`;
};

enum ContentType {
TEXT = 'text',
AUDIO = 'audio',
VIDEO = 'video',
IMAGE = 'image',
BINARY = 'binary',
const augmentVideoFile = async (f: File) => {
const previewUrl = await readAsDataURL(f);
return {
name: f.name,
type: f.type,
size: f.size,
lastModified: f.lastModified,
metadata: {
previewUrl,
},
};
}

const getContentType = (mimeType?: string) => {
if (typeof mimeType !== 'string') {
return ContentType.BINARY;
}

if (
mimeType === 'application/json'
|| mimeType === 'application/xml'
|| mimeType.startsWith('text/')
) {
return ContentType.TEXT;
}

if (mimeType.startsWith('video/')) {
return ContentType.VIDEO;
}

if (mimeType.startsWith('audio/')) {
return ContentType.AUDIO;
}

if (mimeType.startsWith('image/')) {
return ContentType.IMAGE;
}

return ContentType.BINARY;
}
const CONTENT_TYPE_AUGMENT_FUNCTIONS: Record<ContentType, Function> = {
[ContentType.TEXT]: augmentTextFile,
[ContentType.IMAGE]: augmentImageFile,
[ContentType.AUDIO]: augmentAudioFile,
[ContentType.VIDEO]: augmentVideoFile,
[ContentType.BINARY]: augmentBinaryFile,
};

const useFilePreviews = (fileList?: FileList) => {
const [selectedFiles, setSelectedFiles] = React.useState([] as Partial<FileWithPreview>[]);
React.useEffect(() => {
const loadFilePreviews = async (fileList?: FileList) => {
if (!fileList) {
return;
}
const loadFilePreviews = async (fileList: FileList) => {
const files = Array.from(fileList);
const fileResult = await Promise.all(
files.map((f) => new Promise<Partial<FileWithPreview>>((resolve, reject) => {
const contentType = getContentType(f.type);

switch (contentType) {
case ContentType.TEXT: {
const reader = new FileReader();
reader.addEventListener('error', () => {
reject();
});

reader.addEventListener('load', async (loadEvent) => {
const target = loadEvent.target as FileReader;
const contents = target.result as string;
const resolvedAliases = Object.fromEntries(
Object
.entries(languages)
.reduce(
(resolved, [languageId, languageDefinition]) => {
if (Array.isArray(languageDefinition.alias)) {
return [
...resolved,
...(languageDefinition.alias.map((a: string) => [a, { title: languageDefinition.title, extension: `.${a}`} ])),
[languageId, { title: languageDefinition.title, extension: `.${languageId}`}],
];
}

if (typeof languageDefinition.alias === 'string') {
return [
...resolved,
[languageDefinition.alias, { title: languageDefinition.title, extension: `.${languageDefinition.alias}`}],
[languageId, { title: languageDefinition.title, extension: `.${languageId}`}],
];
}

return [
...resolved,
[languageId, { title: languageDefinition.title, extension: `.${languageId}`}],
];
},
[] as [string, { title: string, extension: string }][]
)
);
const metadata = Object
.entries(resolvedAliases)
.reduce(
(theMetadata, [key, value]) => {
if (typeof theMetadata.scheme === 'undefined' && f.name.endsWith(value.extension)) {
return {
...theMetadata,
};
}

return theMetadata;
},
{
contents,
} as Record<string, number | string>
);

if (typeof metadata.scheme !== 'string') {
const naturalLanguageDetector = new LanguageDetect();
const probableLanguages = naturalLanguageDetector.detect(contents);
const [languageName, probability] = probableLanguages[0];
metadata.language = languageName;
} else {
await import(`prismjs/components/prism-${metadata.scheme}`);
}
return Promise.all(
files.map(async (f) => {
const contentType = getContentType(f.type, f.name);
const { [contentType]: augmentFunction } = CONTENT_TYPE_AUGMENT_FUNCTIONS;

resolve({
...f,
name: f.name,
type: f.type,
size: f.size,
lastModified: f.lastModified,
metadata,
});
});

reader.readAsText(f);
return;
}
case ContentType.IMAGE: {
const reader = new FileReader();
reader.addEventListener('error', () => {
reject();
});

reader.addEventListener('load', (loadEvent) => {
const target = loadEvent.target as FileReader;
const previewUrl = target.result as string;
const image = new Image();
image.addEventListener('load', (imageLoadEvent) => {
const thisImage = imageLoadEvent.currentTarget as HTMLImageElement;
resolve({
...f,
name: f.name,
type: f.type,
size: f.size,
lastModified: f.lastModified,
metadata: {
previewUrl,
width: thisImage.naturalWidth,
height: thisImage.naturalHeight,
},
});
});

image.addEventListener('error', () => {
reject();
});

image.src = previewUrl;
});

reader.readAsDataURL(f);
return;
if (!augmentFunction) {
return f;
}
case ContentType.AUDIO: {
const reader = new FileReader();
reader.addEventListener('error', () => {
reject();
});

reader.addEventListener('load', (loadEvent) => {
const target = loadEvent.target as FileReader;
const previewUrl = target.result as string;
let mediaInstance = null as (WaveSurfer | null);

const waveSurferInstance = WaveSurfer.create({
container: window.document.createElement('div'),
});

waveSurferInstance.on('ready', async () => {
const metadata = {
duration: waveSurferInstance.getDuration(),
};

waveSurferInstance.destroy();
resolve({
...f,
name: f.name,
type: f.type,
size: f.size,
lastModified: f.lastModified,
metadata,
internal: {
createMediaInstance(container: HTMLElement) {
if (mediaInstance instanceof WaveSurfer) {
mediaInstance.destroy();
}
mediaInstance = WaveSurfer.create({
container,
url: previewUrl,
height: container.clientHeight,
interact: false,
cursorWidth: 0,
barWidth: 2,
barGap: 2,
barRadius: 1,
normalize: true,
});
},
destroyMediaInstance() {
if (!(mediaInstance instanceof WaveSurfer)) {
return;
}
mediaInstance.destroy();
},
play() {
if (!(mediaInstance instanceof WaveSurfer)) {
return;
}
mediaInstance.play();
},
pause() {
if (!(mediaInstance instanceof WaveSurfer)) {
return;
}
mediaInstance.pause();
},
stop() {
if (!(mediaInstance instanceof WaveSurfer)) {
return;
}
mediaInstance.stop();
},
playPause() {
if (!(mediaInstance instanceof WaveSurfer)) {
return;
}
mediaInstance.playPause();
},
},
})
});

waveSurferInstance.load(previewUrl);
});

reader.readAsDataURL(f);
return;
}
default:
break;
const augmentedFile = await augmentFunction(f);
if (contentType === ContentType.TEXT && augmentedFile.metadata.scheme) {
await import(`prismjs/components/prism-${augmentedFile.metadata.scheme}`);
}
resolve(f);
}))
return augmentedFile;
})
);

setSelectedFiles(fileResult);
}

loadFilePreviews(fileList);
if (fileList) {
loadFilePreviews(fileList).then((fileResult) => {
setSelectedFiles(fileResult);
});
}
}, [fileList]);

return React.useMemo(() => ({
@@ -386,42 +171,83 @@ const FilePreview = ({
}: { fileList?: FileList }) => {
const { files } = useFilePreviews(fileList);
const mediaContainerRef = React.useRef<HTMLDivElement>(null);
const mediaControllerRef = React.useRef<WaveSurfer | HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = React.useState(false);

const playMedia = () => {
if (files.length < 1) {
return;
}
const [theFile] = files;
if (typeof theFile.internal?.playPause === 'function') {
theFile.internal.playPause();
}

setIsPlaying((p) => !p);
};

React.useEffect(() => {
const { current: mediaController } = mediaControllerRef;
if (!mediaController) {
return;
}

if (isPlaying) {
void mediaController.play();
return
}
mediaController.pause();
}, [isPlaying]);

React.useEffect(() => {
if (files.length < 1) {
return;
}

const [theFile] = files;
if (typeof theFile.internal?.createMediaInstance === 'function' && mediaContainerRef.current) {
theFile.internal.createMediaInstance(mediaContainerRef.current);
const contentType = getContentType(theFile.type, theFile.name);
if (
contentType === ContentType.AUDIO
&& typeof theFile.metadata?.previewUrl === 'string'
&& mediaContainerRef.current !== null
) {
mediaContainerRef.current.innerHTML = '';

const mediaControllerRefMutable = mediaControllerRef as React.MutableRefObject<WaveSurfer>;
mediaControllerRefMutable.current = WaveSurfer.create({
container: mediaContainerRef.current,
url: theFile.metadata.previewUrl,
cursorWidth: 0,
height: mediaContainerRef.current.offsetHeight,
barWidth: 2,
barGap: 2,
barRadius: 1,
});

mediaControllerRefMutable.current.on('finish', () => {
setIsPlaying(false);
mediaControllerRefMutable.current.seekTo(0);
});
} else if (
contentType === ContentType.VIDEO
&& typeof theFile.metadata?.previewUrl === 'string'
) {
(mediaControllerRef.current as HTMLVideoElement).addEventListener('ended', (e) => {
const videoElement = e.currentTarget as HTMLVideoElement;
setIsPlaying(false);
videoElement.currentTime = 0;
});
}

return () => {
if (typeof theFile.internal?.destroyMediaInstance === 'function') {
theFile.internal.destroyMediaInstance();
if (mediaControllerRef && mediaControllerRef.current && mediaControllerRef.current instanceof WaveSurfer) {
mediaControllerRef.current.destroy();
}
}
}, [files, mediaContainerRef]);
}, [files, mediaContainerRef, mediaControllerRef]);

if (files.length < 1) {
return null;
}

const f = files[0];
const contentType = getContentType(f.type);

console.log(f);
const contentType = getContentType(f.type, f.name);

return (
<div
@@ -435,16 +261,15 @@ const FilePreview = ({
<div className={`h-full w-1/3 flex-shrink-0`}>
{
typeof f.metadata?.contents === 'string'
&& typeof f.metadata?.scheme === 'string'
&& (
<div
data-testid="preview"
role="presentation"
className="w-full h-full select-none overflow-hidden"
className="w-full h-full select-none overflow-hidden text-xs"
>
<pre className="overflow-visible">
{
f.metadata.scheme
typeof f.metadata.scheme === 'string'
&& (
<code
dangerouslySetInnerHTML={{
@@ -452,7 +277,7 @@ const FilePreview = ({
f.metadata.contents,
Prism.languages[f.metadata.scheme],
f.metadata.scheme,
),
).split('\n').slice(0, 15).join('\n'),
}}
style={{
tabSize: 2,
@@ -461,7 +286,7 @@ const FilePreview = ({
)
}
{
!f.metadata.scheme
typeof f.metadata.scheme !== 'string'
&& (
<code>
{f.metadata.contents}
@@ -564,7 +389,7 @@ const FilePreview = ({
title={f.type}
className="m-0 w-full text-ellipsis overflow-hidden"
>
{getMimeTypeDescription(f.type)}
{getMimeTypeDescription(f.type, f.name)}
</dd>
</div>
<div className="w-full">
@@ -602,11 +427,19 @@ const FilePreview = ({
contentType === ContentType.AUDIO
&& (
<div className="flex flex-col gap-4 w-full h-full relative">
<div
className={`h-2/5 flex-shrink-0 cursor-pointer`}
ref={mediaContainerRef}
onClick={playMedia}
/>
<div className="h-2/5 flex-shrink-0 cursor-pointer relative">
<div
ref={mediaContainerRef}
className="relative h-full w-full"
/>
<div className="absolute bottom-0 left-0 z-[2]">
<button
onClick={playMedia}
>
{isPlaying ? 'Pause' : 'Play'}
</button>
</div>
</div>
<dl className="h-3/5 flex-shrink-0 m-0 flex flex-col items-end" data-testid="infoBox">
<div className="w-full">
<dt className="sr-only">
@@ -661,6 +494,193 @@ const FilePreview = ({
</div>
)
}
{
contentType === ContentType.VIDEO
&& (
<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'
&& (
<div
className="w-full h-full bg-black"
data-testid="preview relative"
>
<video
className="block w-full h-full object-center object-contain relative"
ref={mediaControllerRef as React.RefObject<HTMLVideoElement>}
>
<source
src={f.metadata.previewUrl}
type={f.type}
/>
</video>
<div className="absolute bottom-0 left-0 hover:opacity-100 opacity-0">
<button
onClick={playMedia}
>
{isPlaying ? 'Pause' : 'Play'}
</button>
</div>
</div>
)
}
</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>
</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`}
>
{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"
>
{formatNumeral(f.metadata.width)}&times;{formatNumeral(f.metadata.height)} pixels
</dd>
</div>
)
}
</dl>
</div>
)
}
{
contentType === ContentType.BINARY
&& (
<div className="flex gap-4 w-full h-full relative">
<div className={`h-full w-1/3 flex-shrink-0`}>
{
f.metadata && (f.metadata?.contents instanceof ArrayBuffer)
&& (
<div
data-testid="preview"
role="presentation"
className="w-full h-full select-none overflow-hidden text-xs"
>
<pre className="overflow-visible">
<code>
{
(Array.from(new Uint8Array((f.metadata.contents as ArrayBuffer).slice(0, 256))) as number[])
.reduce(
(byteArray: number[][], byte: number, i) => {
if (i % 16 === 0) {
return [
...byteArray,
[byte],
]
}

const lastLine = byteArray.at(-1) as number[]

return [
...(byteArray.slice(0, -1)),
[...lastLine, byte],
]
},
[] as number[][],
)
.map((ba: number[]) => ba
.map((a) => a.toString(16).padStart(2, '0'))
.join(' ')
)
.join('\n')
}
</code>
</pre>
</div>
)
}
</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>
</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`}
>
{formatFileSize(f.size)}
</dd>
</div>
{
typeof f.metadata?.language === 'string'
&& (
<div>
<dt className="sr-only">
Language
</dt>
<dd
className="m-0 w-full text-ellipsis overflow-hidden"
>
{f.metadata.language.slice(0, 1).toUpperCase()}
{f.metadata.language.slice(1)}
</dd>
</div>
)
}
</dl>
</div>
)
}
</div>
</div>
)


+ 24
- 0
packages/web/kitchen-sink/react-next/src/utils/audio.ts 查看文件

@@ -0,0 +1,24 @@
import WaveSurfer from 'wavesurfer.js';

export const getAudioMetadata = (audioUrl: string) => new Promise<Record<string, string | number>>(async (resolve, reject) => {
try {
const dummyContainer = window.document.createElement('div');
const waveSurferInstance = WaveSurfer.create({
container: dummyContainer,
});

waveSurferInstance.on('ready', async () => {
const metadata = {
duration: waveSurferInstance.getDuration(),
};

waveSurferInstance.destroy();
dummyContainer.remove();
resolve(metadata);
});

await waveSurferInstance.load(audioUrl);
} catch (err) {
reject(err);
}
});

+ 123
- 0
packages/web/kitchen-sink/react-next/src/utils/blob.ts 查看文件

@@ -0,0 +1,123 @@
import * as mimeTypes from 'mime-types';
import Blob from '../pages/categories/blob';

const MIME_TYPE_DESCRIPTIONS = {
'image/jpeg': 'JPEG Image',
'image/png': 'PNG Image',
'image/tiff': 'TIFF Image',
'image/svg+xml': 'SVG Image',
'audio/wav': 'WAVE Audio',
'audio/ogg': 'OGG Audio',
'audio/mpeg': 'MPEG Audio',
'application/json': 'JSON Data',
'application/xml': 'XML Data',
'application/x-bittorrent': 'Torrent File',
'application/x-zip-compressed': 'Compressed ZIP Archive',
'application/x-x509-ca-cert': 'Certificate File',
'application/x-tar': 'Compressed TAR Archive',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Workbook',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'Slideshow Presentation',
'application/msword': 'Microsoft Word Document',
'application/pdf': 'PDF Document',
} as const;

const EXTENSION_DESCRIPTIONS = {
'rar': 'Compressed RAR Archive',
'7z': 'Compressed 7-Zip Archive',
'psd': 'Adobe Photoshop Document',
'dmg': 'Disk Image',
'fb2k-component': 'foobar2000 Component',
} as const;

export const getMimeTypeDescription = (type?: string, filename?: string) => {
if (typeof (type as unknown) !== 'string') {
return '';
}

if (type === 'application/octet-stream' || type === '') {
if (typeof filename === 'string' && filename.includes('.')) {
const extension = filename.slice(filename.lastIndexOf('.') + '.'.length).toLowerCase();
const {
[extension as keyof typeof EXTENSION_DESCRIPTIONS]: extensionDescription = `${extension.toUpperCase()} File`,
} = EXTENSION_DESCRIPTIONS;

return extensionDescription;
}
return `${type} File`;
}

const {
[type as keyof typeof MIME_TYPE_DESCRIPTIONS]: description = type,
} = MIME_TYPE_DESCRIPTIONS;

return description;
}

export enum ContentType {
TEXT = 'text',
AUDIO = 'audio',
VIDEO = 'video',
IMAGE = 'image',
BINARY = 'binary',
}

export const getContentType = (mimeType?: string, filename?: string) => {
let effectiveMimeType: string;
if (typeof mimeType !== 'string') {
if (typeof filename !== 'string') {
return ContentType.BINARY;
}
const lookupMimeType = mimeTypes.lookup(filename);

if (typeof lookupMimeType !== 'string') {
return ContentType.BINARY;
}

effectiveMimeType = lookupMimeType;
} else {
effectiveMimeType = mimeType;
}

if (
effectiveMimeType === 'application/json'
|| effectiveMimeType === 'application/xml'
|| effectiveMimeType.startsWith('text/')
) {
return ContentType.TEXT;
}

if (effectiveMimeType.startsWith('video/')) {
return ContentType.VIDEO;
}

if (effectiveMimeType.startsWith('audio/')) {
return ContentType.AUDIO;
}

if (effectiveMimeType.startsWith('image/')) {
return ContentType.IMAGE;
}

return ContentType.BINARY;
}

export const readAsText = (blob: Blob) => blob.text();

export const readAsDataURL = (blob: Blob) => new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener('error', () => {
reject();
});

reader.addEventListener('load', (e) => {
if (!e.target) {
reject();
return;
}
resolve(e.target.result as string);
});

reader.readAsDataURL(blob);
});

export const readAsArrayBuffer = (blob: Blob) => blob.arrayBuffer();

+ 19
- 0
packages/web/kitchen-sink/react-next/src/utils/image.ts 查看文件

@@ -0,0 +1,19 @@
export const getImageMetadata = (imageUrl: string) => new Promise<Record<string, string | number>>((resolve, reject) => {
const image = new Image();
image.addEventListener('load', (imageLoadEvent) => {
const thisImage = imageLoadEvent.currentTarget as HTMLImageElement;
const metadata = {
width: thisImage.naturalWidth,
height: thisImage.naturalHeight,
};
image.remove();
resolve(metadata);
});

image.addEventListener('error', () => {
reject();
image.remove();
});

image.src = imageUrl;
});

+ 47
- 0
packages/web/kitchen-sink/react-next/src/utils/numeral.ts 查看文件

@@ -0,0 +1,47 @@
export const formatNumeral = (n?: number) => {
if (typeof n !== 'number') {
return '';
}

if (!Number.isFinite(n)) {
return '';
}

return new Intl.NumberFormat().format(n);
}

export const formatFileSize = (size?: number) => {
if (typeof size !== 'number') {
return '';
}

if (!Number.isFinite(size)) {
return '';
}

if (size < (2 ** 10)) {
return `${formatNumeral(size)} bytes`;
}

if (size < (2 ** 20)) {
return `${(size / (2 ** 10)).toFixed(3)} kiB`;
}

if (size < (2 ** 30)) {
return `${(size / (2 ** 20)).toFixed(3)} MiB`;
}

if (size < (2 ** 40)) {
return `${(size / (2 ** 30)).toFixed(3)} GiB`;
}
};

export const formatSecondsDuration = (seconds: number) => {
const secondsInt = Math.floor(seconds);
const secondsFrac = seconds - secondsInt;
const hh = Math.floor(secondsInt / 3600).toString().padStart(2, '0');
const mm = Math.floor(secondsInt / 60 % 60).toString().padStart(2, '0');
const ss = (secondsInt % 60).toString().padStart(2, '0');
const sss = Math.floor(secondsFrac * 1000).toString().padStart(3, '0');
return `${hh}:${mm}:${ss}.${sss}`;
};

+ 67
- 0
packages/web/kitchen-sink/react-next/src/utils/text.ts 查看文件

@@ -0,0 +1,67 @@
import LanguageDetect from 'languagedetect';
import {languages} from 'prismjs/components';

const RESOLVED_ALIASES = Object.fromEntries(
Object
.entries(languages)
.reduce(
(resolved, [languageId, languageDefinition]) => {
if (Array.isArray(languageDefinition.alias)) {
return [
...resolved,
...(languageDefinition.alias.map((a: string) => [a, { aliasOf: languageId, title: languageDefinition.title, extension: `.${a}`} ])),
[languageId, { title: languageDefinition.title, extension: `.${languageId}`}],
];
}

if (typeof languageDefinition.alias === 'string') {
return [
...resolved,
[languageDefinition.alias, { aliasOf: languageId, title: languageDefinition.title, extension: `.${languageDefinition.alias}`}],
[languageId, { title: languageDefinition.title, extension: `.${languageId}`}],
];
}

return [
...resolved,
[languageId, { title: languageDefinition.title, extension: `.${languageId}`}],
];
},
[] as [string, { aliasOf?: string, title: string, extension: string }][]
)
);

export const getTextMetadata = (contents: string, filename: string) => {
const metadata = Object.entries(RESOLVED_ALIASES).reduce(
(theMetadata, [key, value]) => {
if (typeof theMetadata.scheme === 'undefined' && filename.endsWith(value.extension)) {
if (value.aliasOf) {
return {
...theMetadata,
scheme: value.aliasOf,
schemeTitle: value.title,
}
}

return {
...theMetadata,
scheme: key,
schemeTitle: value.title,
};
}

return theMetadata;
},
{} as Record<string, number | string>
);

if (typeof metadata.scheme !== 'string') {
const naturalLanguageDetector = new LanguageDetect();
const probableLanguages = naturalLanguageDetector.detect(contents);
const [languageName, probability] = probableLanguages[0];
metadata.language = languageName;
metadata.languageProbability = probability;
}

return metadata;
}

+ 17
- 0
packages/web/kitchen-sink/react-next/yarn.lock 查看文件

@@ -256,6 +256,11 @@
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
"@types/mime-types@^2.1.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@types/mime-types/-/mime-types-2.1.1.tgz#d9ba43490fa3a3df958759adf69396c3532cf2c1"
integrity sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==
"@types/node@18.6.4":
version "18.6.4"
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.6.4.tgz#fd26723a8a3f8f46729812a7f9b4fc2d1608ed39"
@@ -1618,6 +1623,18 @@ micromatch@^4.0.4, micromatch@^4.0.5:
braces "^3.0.2"
picomatch "^2.3.1"
mime-db@1.52.0:
version "1.52.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
mime-types@^2.1.35:
version "2.1.35"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
dependencies:
mime-db "1.52.0"
minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"


Loading…
取消
儲存