Add line numbers and highlighting for binary and text file previews.pull/1/head
@@ -0,0 +1,106 @@ | |||
import os | |||
import random | |||
from PIL import Image, ImageDraw, ImageFont | |||
# set up the image folder and font file | |||
image_folder = 'dogetemplates/' | |||
font_file = 'impact.ttf' | |||
# get a list of all the image files in the folder | |||
image_files = [os.path.join(image_folder, f) for f in os.listdir(image_folder) if os.path.isfile(os.path.join(image_folder, f))] | |||
# set up the text parameters | |||
text_color = (255, 255, 255) | |||
text_outline_color = (0, 0, 0) | |||
text_outline_width = 2 | |||
# load the text files | |||
with open('strings.txt', 'r') as f: | |||
string_lines = f.readlines() | |||
with open('strings_toptext.txt', 'r') as f: | |||
string_toptext_lines = f.readlines() | |||
with open('strings_bottomtext.txt', 'r') as f: | |||
string_bottomtext_lines = f.readlines() | |||
# create the output folder if it doesn't exist | |||
output_folder = 'randomdoges' | |||
if not os.path.exists(output_folder): | |||
os.makedirs(output_folder) | |||
# generate a random meme | |||
image_file = random.choice(image_files) | |||
image = Image.open(image_file) | |||
draw = ImageDraw.Draw(image) | |||
# scale the font size based on a 500x500 image | |||
if image.width < 500 and image.height < 500: | |||
font_size = int(40) | |||
else: | |||
font_size = int(40 * (min(image.width, image.height) / 500)) | |||
font = ImageFont.truetype(font_file, font_size) | |||
top_text = random.choice(string_lines + string_toptext_lines).strip().upper() | |||
bottom_text = random.choice(string_lines + string_bottomtext_lines).strip().upper() | |||
text_width, text_height = draw.textsize(top_text, font) | |||
while text_width > image.width: | |||
font_size -= 1 | |||
font = ImageFont.truetype(font_file, font_size) | |||
text_width, text_height = draw.textsize(top_text, font) | |||
x = (image.width - text_width) / 2 | |||
y = 10 | |||
draw.text((x, y), top_text, font=font, fill=text_color, stroke_width=text_outline_width, stroke_fill=text_outline_color) | |||
text_width, text_height = draw.textsize(bottom_text, font) | |||
while text_width > image.width: | |||
font_size -= 1 | |||
font = ImageFont.truetype(font_file, font_size) | |||
text_width, text_height = draw.textsize(bottom_text, font) | |||
x = (image.width - text_width) / 2 | |||
y = image.height - text_height - 10 | |||
draw.text((x, y), bottom_text, font=font, fill=text_color, stroke_width=text_outline_width, stroke_fill=text_outline_color) | |||
# set the image size | |||
min_size = 225 | |||
max_size = 960 | |||
new_size = random.randint(min_size, max_size) | |||
image = image.resize((new_size, new_size)) | |||
# wrap text to new lines if it goes out of bounds | |||
max_width = int(image.width * 0.8) | |||
top_text_lines = [] | |||
bottom_text_lines = [] | |||
for text, lines in [(top_text, top_text_lines), (bottom_text, bottom_text_lines)]: | |||
words = text.split() | |||
current_line = words[0] | |||
for word in words[1:]: | |||
if draw.textsize(current_line + ' ' + word, font=font)[0] < max_width: | |||
current_line += ' ' + word | |||
else: | |||
lines.append(current_line) | |||
current_line = word | |||
lines.append(current_line) | |||
# draw the text on the image | |||
text_y = 10 | |||
for line in top_text_lines: | |||
text_width, text_height = draw.textsize(line, font) | |||
x = (image.width - text_width) / 2 | |||
draw.text((x, text_y), line, font=font, fill=text_color, stroke_width=text_outline_width, stroke_fill=text_outline_color) | |||
text_y += text_height | |||
text_y = image.height - 10 | |||
for line in reversed(bottom_text_lines): | |||
text_width, text_height = draw.textsize(line, font) | |||
x = (image.width - text_width) / 2 | |||
text_y -= text_height | |||
draw.text((x, text_y), line, font=font, fill=text_color, stroke_width=text_outline_width, stroke_fill=text_outline_color) | |||
# save the meme with an incrementing file name in the output folder | |||
file_num = len(os.listdir(output_folder)) | |||
file_name = f"randomdoge{file_num:04d}.jpg" # 4-digit padded file number | |||
output_path = os.path.join(output_folder, file_name) | |||
image.save(output_path) |
@@ -0,0 +1,95 @@ | |||
Law 1: Never Outshine the Master: Ensure that those above you always feel superior. Go out of your way to make your bosses look better and feel smarter than anyone else. Everyone is insecure, but an insecure boss can retaliate more strongly than others can. | |||
Law 2: Never Put too Much Trust in Friends, Learn How to Use Enemies: Keep a close eye on your friends — they get envious and will undermine you. If you co-opt an enemy, he’ll be more loyal than a friend because he’ll try harder to prove himself worthy of your trust. | |||
Law 3: Conceal Your Intentions: Always hide your true intentions. Create a smokescreen. If you keep people off-balance and in the dark, they can’t counter your efforts. | |||
Law 4: Always Say Less than Necessary: Say little and be ambiguous, leaving the meaning to others to interpret. The less you say, the more intimidating and powerful you are. | |||
Law 5: So Much Depends on Reputation — Guard It with Your Life: Nurture and guard your reputation because reputation is integral to power. With a strong reputation, you can influence and intimidate others. | |||
Law 6: Create an Air of Mystery: Be outrageous or create an aura of mystery. Any attention — positive or negative — is better than being ignored. Attention brings you wealth. | |||
Law 7: Get Others to Do the Work for You, but Always Take the Credit: Get others to do your work for you. Use their skill, time, and energy to further your ambitions while taking full credit. You’ll be admired for your efficiency. | |||
Law 8: Make Other People Come to You — Use Bait if Necessary: Make your opponent come to you. When you force others to act, you’re in control. Bait them, then attack. | |||
Law 9: Win Through Your Actions, Never Through Argument: Demonstrate your point rather than arguing. Arguing rarely changes anyone’s mind, but people believe what they see. They’re also less likely to be offended. | |||
Law 10: Infection: Avoid the Unhappy and Unlucky: Avoid miserable people. The perpetually miserable spread misery like an infection, and they’ll drown you in it. | |||
Law 11: Learn to Keep People Dependent on You: Make your superior dependent on you. The more she needs you, the more security and freedom you have to pursue your goals. | |||
Law 12: Use Selective Honesty and Generosity to Disarm Your Victim: Use honesty and generosity to disarm and distract others from your schemes. Even the most suspicious people respond to acts of kindness, leaving them vulnerable to manipulation. | |||
Law 13: When Asking for Help, Appeal to People’s Self-Interest, Never to their Mercy or Gratitude: When you need help from someone in a position of power, appeal to their self-interest. They’ll be glad to help if they’ll get something in return, and you’ll get what you want without seeming desperate or irritating. | |||
Law 14: Pose as a Friend, Work as a Spy: Be friendly, sympathetic, and interested to get people to reveal their deepest thoughts and feelings. When you know your opponent’s secrets, you can predict his behavior and control him. | |||
Law 15: Crush Your Enemy Totally: Crush your enemy completely. If you leave even one ember smoldering, it will eventually ignite. You can’t afford to be lenient. | |||
Law 16: Use Absence to Increase Respect and Honor: Once you’ve become well-known, don’t wear out your welcome. The more you’re seen and heard from, the more you cheapen your brand. | |||
Law 17: Keep Others in Suspended Terror: Cultivate an Air of Unpredictability: Throw others off balance and unnerve them with random, unpredictable acts. You’ll gain the upper hand. | |||
Law 18: Do Not Build Fortresses to Protect Yourself – Isolation is Dangerous: Never isolate yourself when under pressure. This cuts you off from information you need, and when real danger arises you won’t see it coming. | |||
Law 19: Know Who You’re Dealing With – Do Not Offend the Wrong Person: When attempting to deceive someone, know who you’re dealing with, so you don’t waste your time or stir up a hornets’ nest in reaction. | |||
Law 20: Do Not Commit to Anyone: Don’t commit to any side or cause except yourself. By maintaining your independence, you remain in control — others will vie for your attention. You also have the ability to pit the sides against each other. | |||
Law 21: Play a Sucker to Catch a Sucker – Seem Dumber Than Your Mark: Make your intended victims feel as though they’re smarter than you are, and they won’t suspect you of having ulterior motives. | |||
Law 22: Use the Surrender Tactic: Transform Weakness into Power: When you’re weaker, surrender rather than fighting for the sake of honor. This gives you time to build strength and undermine your victor. You’ll win in the end. | |||
Law 23: Concentrate Your Forces: Focus your resources and energies where you’ll have the most impact or get the most benefit. Otherwise, you’ll waste limited time and energy. | |||
Law 24: Play the Perfect Courtier: Learn the rules of the society you’re playing in, and follow them to avoid attracting unfavorable attention. This includes appearing like a team player and being careful about criticizing diplomatically. | |||
Law 25: Re-Create Yourself: Create a powerful image that stands out, rather than letting others define you. Change your appearance and emotions to suit the occasion. People who seem larger than life attract admiration and power. | |||
Law 26: Keep Your Hands Clean: You’ll inevitably make mistakes or need to take care of unpleasant problems. But keep your hands clean by finding others to do the dirty work, and scapegoats to blame. | |||
Law 27: Play on People’s Need to Believe to Create a Cultlike Following: Offer people something to believe in and someone to follow. Promise the world but keep it vague; whip up enthusiasm. People will respond to a desperate need for belonging. Followers line your pockets, and your opponents are afraid to rile them. | |||
Law 28: Enter Action with Boldness: When you act, do so boldly — and if you make mistakes, correct them with even greater boldness. Boldness brings admiration and power. | |||
Law 29: Plan All the Way to the End: Make detailed plans with a clear ending. Take into account all possible developments. Then don’t be tempted from your path. Otherwise, you risk being surprised and forced to react without time to think. | |||
Law 30: Make Your Accomplishments Seem Effortless: Make difficult feats seem effortless and you’ll inspire awe in others and seem powerful. By contrast, when you make too much of your efforts, your achievement will seem less impressive and you’ll lose respect. | |||
Law 31: Control the Options: Get Others to Play with the Cards You Deal: To deceive people, seem to give them a meaningful choice. But sharply limit their options to a few that work in your favor regardless of which they choose. Your victims will feel in control, but you’ll pull the strings. | |||
Law 32: Play to People’s Fantasies: Conjure up alluring fantasies in contrast to the gloomy realities of life, and people will flock to you. Spin the right tale and wealth and power will follow. | |||
Law 33: Discover Each Man’s Thumbscrew: Everyone has a weakness, a hole in his armor. Find it and it’s leverage that you can use to your advantage. | |||
Law 34: Be Royal in Your Own Fashion: Act Like a King to Be Treated Like One: Act like royalty and people will treat you that way. Project dignity and supreme confidence that you’re destined for great things, and others will believe it. | |||
Law 35: Master the Art of Timing: Anticipate the ebb and flow of power. Recognize when the time is right, and align yourself with the right side. Be patient and wait for your moment. Bad timing ends careers and ambitions. | |||
Law 36: Disdain Things You Cannot Have: Ignoring Them Is the Best Revenge: Sometimes it’s better to ignore things because reacting can make small problems worse, make you look bad, and give your enemy attention. | |||
Law 37: Create Compelling Spectacles: In addition to words, use visuals and symbols to underscore your power. What people see makes a greater impression on them than what they hear. | |||
Law 38: Think as You Like But Behave Like Others: Don’t make a show of being different, or people will think you look down on them and will retaliate against you. | |||
Law 39: Stir Up Waters to Catch Fish: Always stay calm and objective. When you get angry, you’ve lost control. But if you can make your enemies angry, you gain an advantage. | |||
Law 40: Despise the Free Lunch: Use money and generosity strategically to achieve your goals. Use gifts to build a reputation of generosity, and also to obligate people to you. | |||
Law 41: Avoid Stepping Into a Great Man’s Shoes: If you succeed a great leader or famous parent, find or create your own space to fill. Sharply separate from the past and set your own standards — or you’ll be deemed a failure for not being a clone of your predecessor. | |||
Law 42: Strike the Shepherd and the Sheep Will Scatter: Trouble in a group often starts with a single individual who stirs the pot. Stop them before others succumb to their influence. | |||
Law 43: Work on the Hearts and Minds of Others: Win others’ hearts and minds. Play on their emotions and weaknesses, and appeal to their self-interest. You’ll have them eating out of your hand, and they’ll be less likely to turn on you. | |||
Law 44: Disarm and Infuriate with the Mirror Effect: Seduce people by mirroring their emotions and interests; create the illusion that you share their values. They’ll be so grateful to be understood that they won’t notice your ulterior motives. | |||
Law 45: Preach the Need for Change, But Never Reform Too Much at Once: Talk change but move slowly. Evoke revered history and cloak your changes in familiar rituals. Too much change is unsettling and will spark backlash. | |||
Law 46: Never Appear Too Perfect: To forestall or mitigate envy, admit to a flaw or weakness, emphasize the role of luck, or downplay your talents. If you don’t recognize and nip envy in the bud, it will grow and the envious will work insidiously against you. | |||
Law 47: Do Not Go Past the Mark You Aimed For; In Victory, Learn When to Stop: When you’ve won, don’t let emotions push you past your goal. The moment of victory is dangerous because if you press your luck, you’ll blunder into something you haven’t planned for. | |||
Law 48: Assume Formlessness: Be flexible, fluid, and unpredictable — formless — so your opponents can’t get a fix on you and can’t figure out how to respond. |
@@ -8,17 +8,17 @@ import { | |||
} from '@/utils/numeral'; | |||
import theme from '@/styles/theme'; | |||
import {useMediaControls} from '../../hooks/interactive'; | |||
import {useFileMetadata} from '@/categories/blob/react'; | |||
import {useFileMetadata, useFileUrl} from '@/categories/blob/react'; | |||
import clsx from 'clsx'; | |||
import {SpectrogramCanvas, WaveformCanvas} from '@/packages/react-wavesurfer'; | |||
import {SpectrogramCanvas, WaveformCanvas} from '@modal-soft/react-wavesurfer'; | |||
import {Slider} from '@/categories/number/react'; | |||
import {KeyValueTable} from '@/categories/information/react'; | |||
type AudioFilePreviewDerivedComponent = HTMLAudioElement; | |||
export interface AudioFilePreviewProps extends Omit<React.HTMLProps<AudioFilePreviewDerivedComponent>, 'controls'> { | |||
file?: File; | |||
export interface AudioFilePreviewProps<F extends Partial<File> = Partial<File>> extends Omit<React.HTMLProps<AudioFilePreviewDerivedComponent>, 'controls'> { | |||
file?: F; | |||
disabled?: boolean; | |||
enhanced?: boolean; | |||
} | |||
@@ -27,12 +27,15 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||
file, | |||
style, | |||
className, | |||
enhanced = false, | |||
enhanced: enhancedProp = false, | |||
disabled = false, | |||
...etcProps | |||
}, forwardedRef) => { | |||
const { augmentedFile, error } = useFileMetadata({ | |||
const { fileWithUrl } = useFileUrl({ | |||
file, | |||
}); | |||
const { fileWithMetadata, error } = useFileMetadata({ | |||
file: fileWithUrl, | |||
augmentFunction: augmentAudioFile, | |||
}); | |||
const { | |||
@@ -62,7 +65,12 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||
}); | |||
const formId = React.useId(); | |||
if (!augmentedFile) { | |||
const [enhanced, setEnhanced] = React.useState(false); | |||
React.useEffect(() => { | |||
setEnhanced(enhancedProp); | |||
}, [enhancedProp]); | |||
if (!fileWithMetadata) { | |||
return null; | |||
} | |||
@@ -79,7 +87,7 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||
> | |||
<div className="h-full relative col-span-2"> | |||
{ | |||
typeof augmentedFile.metadata?.previewUrl === 'string' | |||
typeof fileWithMetadata.url === 'string' | |||
&& ( | |||
<div | |||
className="w-full h-full bg-black flex flex-col items-stretch" | |||
@@ -96,96 +104,101 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||
onTimeUpdate={updateSeekFromPlayback} | |||
> | |||
<source | |||
src={augmentedFile.metadata.previewUrl} | |||
type={augmentedFile.type} | |||
src={fileWithMetadata.url} | |||
type={fileWithMetadata.type} | |||
/> | |||
Audio playback not supported. | |||
</audio> | |||
<WaveformCanvas | |||
className={clsx( | |||
'sm: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', | |||
visualizationMode !== 'waveform' && 'opacity-0', | |||
)} | |||
ref={mediaControllerRef} | |||
data-testid="preview" | |||
barWidth={1} | |||
barGap={1} | |||
progressColor={`rgb(${theme.primary})`} | |||
waveColor={`rgb(${theme.primary.split(' ').map((c) => Math.floor(Number(c) / 2)).join(' ')})`} | |||
interact | |||
// waveColor={`rgb(${theme.primary})`} | |||
// barHeight={4} | |||
// minPxPerSec={20000} | |||
// hideScrollbar | |||
// autoCenter | |||
// autoScroll | |||
/> | |||
<SpectrogramCanvas | |||
className={clsx( | |||
'sm: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', | |||
visualizationMode !== 'spectrum' && 'opacity-0', | |||
)} | |||
ref={mediaControllerRef} | |||
data-testid="preview" | |||
barWidth={1} | |||
barGap={1} | |||
waveColor={`rgb(${theme.primary})`} | |||
cursorWidth={2} | |||
minPxPerSec={20000} | |||
hideScrollbar | |||
autoCenter | |||
autoScroll | |||
/> | |||
<div className="flex gap-4 absolute top-0 right-0 z-[5] px-4"> | |||
<label | |||
className={clsx( | |||
'h-12 flex items-center justify-center leading-none gap-4 select-none', | |||
)} | |||
> | |||
<input | |||
type="radio" | |||
name="visualizationMode" | |||
value="waveform" | |||
className="sr-only peer/waveform" | |||
onChange={handleVisualizationModeChange} | |||
defaultChecked | |||
/> | |||
<span | |||
{enhanced && ( | |||
<> | |||
<WaveformCanvas | |||
className={clsx( | |||
'block uppercase font-bold h-[1.1em] w-full whitespace-nowrap overflow-hidden text-ellipsis font-semi-expanded text-primary', | |||
'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', | |||
'sm: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', | |||
visualizationMode !== 'waveform' && 'opacity-0', | |||
)} | |||
> | |||
Waveform | |||
</span> | |||
</label> | |||
<label | |||
className={clsx( | |||
'h-12 flex items-center justify-center leading-none gap-4 select-none', | |||
)} | |||
> | |||
<input | |||
type="radio" | |||
name="visualizationMode" | |||
value="spectrum" | |||
className="sr-only peer/waveform" | |||
onChange={handleVisualizationModeChange} | |||
ref={mediaControllerRef} | |||
data-testid="preview" | |||
barWidth={1} | |||
barGap={1} | |||
progressColor={`rgb(${theme.primary})`} | |||
waveColor={`rgb(${theme.primary.split(' ').map((c) => Math.floor(Number(c) / 2)).join(' ')})`} | |||
interact | |||
// waveColor={`rgb(${theme.primary})`} | |||
// barHeight={4} | |||
// minPxPerSec={20000} | |||
// hideScrollbar | |||
// autoCenter | |||
// autoScroll | |||
/> | |||
<span | |||
<SpectrogramCanvas | |||
className={clsx( | |||
'block uppercase font-bold h-[1.1em] w-full whitespace-nowrap overflow-hidden text-ellipsis font-semi-expanded text-primary', | |||
'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', | |||
'sm: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', | |||
visualizationMode !== 'spectrum' && 'opacity-0', | |||
)} | |||
> | |||
Spectrum | |||
</span> | |||
</label> | |||
</div> | |||
ref={mediaControllerRef} | |||
data-testid="preview" | |||
barWidth={1} | |||
barGap={1} | |||
waveColor={`rgb(${theme.primary})`} | |||
cursorWidth={2} | |||
minPxPerSec={20000} | |||
hideScrollbar | |||
autoCenter | |||
autoScroll | |||
/> | |||
<div className="flex gap-4 absolute top-0 right-0 z-[5] px-4"> | |||
<label | |||
className={clsx( | |||
'h-12 flex items-center justify-center leading-none gap-4 select-none', | |||
)} | |||
> | |||
<input | |||
type="radio" | |||
name="visualizationMode" | |||
value="waveform" | |||
className="sr-only peer/waveform" | |||
onChange={handleVisualizationModeChange} | |||
defaultChecked | |||
/> | |||
<span | |||
className={clsx( | |||
'block uppercase font-bold h-[1.1em] w-full whitespace-nowrap overflow-hidden text-ellipsis font-semi-expanded text-primary', | |||
'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', | |||
)} | |||
> | |||
Waveform | |||
</span> | |||
</label> | |||
<label | |||
className={clsx( | |||
'h-12 flex items-center justify-center leading-none gap-4 select-none', | |||
)} | |||
> | |||
<input | |||
type="radio" | |||
name="visualizationMode" | |||
value="spectrum" | |||
className="sr-only peer/waveform" | |||
onChange={handleVisualizationModeChange} | |||
/> | |||
<span | |||
className={clsx( | |||
'block uppercase font-bold h-[1.1em] w-full whitespace-nowrap overflow-hidden text-ellipsis font-semi-expanded text-primary', | |||
'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', | |||
)} | |||
> | |||
Spectrum | |||
</span> | |||
</label> | |||
</div> | |||
</> | |||
)} | |||
</div> | |||
{enhanced && ( | |||
<div className="w-full flex-shrink-0 h-10 flex gap-4 items-center"> | |||
@@ -298,83 +311,83 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||
) | |||
} | |||
</div> | |||
<div | |||
className="flex-shrink-0 m-0 flex flex-col gap-4 justify-between" | |||
> | |||
<div className="flex-shrink-0 m-0 flex flex-col gap-4 justify-between"> | |||
<KeyValueTable | |||
hiddenKeys | |||
data-testid="infoBox" | |||
properties={[ | |||
{ | |||
Boolean(fileWithMetadata.name) && { | |||
key: 'Name', | |||
className: 'font-bold', | |||
valueProps: { | |||
ref: filenameRef, | |||
children: augmentedFile.name, | |||
children: fileWithMetadata.name, | |||
}, | |||
}, | |||
{ | |||
(enhanced && Boolean(getMimeTypeDescription(fileWithMetadata.type, fileWithMetadata.name) || '(Loading)') || Boolean(fileWithMetadata.type)) && { | |||
key: 'Type', | |||
valueProps: { | |||
className: clsx( | |||
!getMimeTypeDescription(augmentedFile.type, augmentedFile.name) && 'opacity-50' | |||
!getMimeTypeDescription(fileWithMetadata.type, fileWithMetadata.name) && 'opacity-50' | |||
), | |||
children: getMimeTypeDescription(augmentedFile.type, augmentedFile.name) || '(Loading)', | |||
children: getMimeTypeDescription(fileWithMetadata.type, fileWithMetadata.name) || '(Loading)', | |||
}, | |||
}, | |||
{ | |||
(enhanced && Boolean(formatFileSize(fileWithMetadata.size) || '(Loading)') || Boolean(fileWithMetadata.size)) && { | |||
key: 'Size', | |||
valueProps: { | |||
className: clsx( | |||
!formatFileSize(augmentedFile.size) && 'opacity-50' | |||
!formatFileSize(fileWithMetadata.size) && 'opacity-50' | |||
), | |||
title: `${formatNumeral(augmentedFile.size ?? 0)} bytes`, | |||
children: formatFileSize(augmentedFile.size) || '(Loading)', | |||
title: `${formatNumeral(fileWithMetadata.size ?? 0)} byte(s)`, | |||
children: formatFileSize(fileWithMetadata.size) || '(Loading)', | |||
}, | |||
}, | |||
typeof augmentedFile.metadata?.duration === 'number' | |||
typeof fileWithMetadata.metadata?.duration === 'number' | |||
&& { | |||
key: 'Duration', | |||
valueProps: { | |||
className: clsx( | |||
!formatSecondsDurationPrecise(augmentedFile.metadata.duration) && 'opacity-50' | |||
!formatSecondsDurationPrecise(fileWithMetadata.metadata.duration) && 'opacity-50' | |||
), | |||
title: `${formatNumeral(augmentedFile.metadata.duration ?? 0)} seconds`, | |||
children: formatSecondsDurationPrecise(augmentedFile.metadata.duration), | |||
title: `${formatNumeral(fileWithMetadata.metadata.duration ?? 0)} second(s)`, | |||
children: formatSecondsDurationPrecise(fileWithMetadata.metadata.duration), | |||
}, | |||
}, | |||
]} | |||
/> | |||
<form | |||
id={formId} | |||
onSubmit={handleAction} | |||
className="flex gap-4" | |||
> | |||
<fieldset | |||
disabled={disabled || typeof error !== 'undefined'} | |||
className="contents" | |||
{enhanced && ( | |||
<form | |||
id={formId} | |||
onSubmit={handleAction} | |||
className="flex gap-4" | |||
> | |||
<legend className="sr-only"> | |||
Controls | |||
</legend> | |||
<button | |||
type="submit" | |||
name="action" | |||
value="download" | |||
className={clsx( | |||
'h-12 flex bg-negative text-primary disabled:text-primary focus:text-secondary active:text-tertiary items-center justify-center leading-none gap-4 select-none', | |||
'focus:outline-0', | |||
'disabled:opacity-50 disabled:cursor-not-allowed', | |||
)} | |||
<fieldset | |||
disabled={disabled || typeof error !== 'undefined'} | |||
className="contents" | |||
> | |||
<legend className="sr-only"> | |||
Controls | |||
</legend> | |||
<button | |||
type="submit" | |||
name="action" | |||
value="download" | |||
className={clsx( | |||
'h-12 flex bg-negative text-primary disabled:text-primary focus:text-secondary active:text-tertiary items-center justify-center leading-none gap-4 select-none', | |||
'focus:outline-0', | |||
'disabled:opacity-50 disabled:cursor-not-allowed', | |||
)} | |||
> | |||
<span | |||
className="block uppercase font-bold h-[1.1em] w-full whitespace-nowrap overflow-hidden text-ellipsis font-semi-expanded" | |||
> | |||
Download | |||
</span> | |||
</button> | |||
</fieldset> | |||
</form> | |||
</button> | |||
</fieldset> | |||
</form> | |||
)} | |||
</div> | |||
</div> | |||
); | |||
@@ -1,92 +1,155 @@ | |||
import * as React from 'react'; | |||
import {BinaryFile, getMimeTypeDescription} from '@/utils/blob'; | |||
import {augmentBinaryFile, getMimeTypeDescription} from '@/utils/blob'; | |||
import {formatFileSize, formatNumeral} from '@/utils/numeral'; | |||
import {useFileMetadata, useFileUrl} from '@/categories/blob/react'; | |||
import clsx from 'clsx'; | |||
import {KeyValueTable} from '@/categories/information/react'; | |||
import {BinaryDataCanvas} from '@modal-soft/react-binary-data-canvas'; | |||
export interface BinaryFilePreviewProps { | |||
file: BinaryFile; | |||
type BinaryFilePreviewDerivedComponent = HTMLDivElement; | |||
export interface BinaryFilePreviewProps<F extends Partial<File> = Partial<File>> extends React.HTMLProps<BinaryFilePreviewDerivedComponent> { | |||
file?: F; | |||
enhanced?: boolean; | |||
} | |||
export const BinaryFilePreview: React.FC<BinaryFilePreviewProps> = ({ | |||
file: f, | |||
}) => ( | |||
<div className="flex gap-4 w-full h-full"> | |||
<div className={`h-full w-1/3 flex-shrink-0`}> | |||
{ | |||
f.metadata && (f.metadata?.contents instanceof ArrayBuffer) | |||
&& ( | |||
<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], | |||
] | |||
} | |||
export const BinaryFilePreview = React.forwardRef<BinaryFilePreviewDerivedComponent, BinaryFilePreviewProps>(({ | |||
file, | |||
className, | |||
style, | |||
enhanced: enhancedProp = false, | |||
...etcProps | |||
}, forwardedRef) => { | |||
const { fileWithUrl } = useFileUrl({ file }); | |||
const { fileWithMetadata, error } = useFileMetadata({ | |||
file: fileWithUrl, | |||
augmentFunction: augmentBinaryFile, | |||
}); | |||
const lastLine = byteArray.at(-1) as number[] | |||
const [enhanced, setEnhanced] = React.useState(false); | |||
React.useEffect(() => { | |||
setEnhanced(enhancedProp); | |||
}, [enhancedProp]); | |||
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> | |||
if (!fileWithMetadata) { | |||
return null; | |||
} | |||
return ( | |||
<div | |||
className={clsx( | |||
'flex flex-col sm:grid sm:grid-cols-3 gap-8 w-full', | |||
className, | |||
)} | |||
style={style} | |||
> | |||
<div className="h-full relative"> | |||
<div className="absolute top-0 left-0 w-full h-full"> | |||
{ | |||
fileWithMetadata.metadata && (fileWithMetadata.metadata?.contents instanceof ArrayBuffer) | |||
&& ( | |||
<div | |||
{...etcProps} | |||
data-testid="preview" | |||
role="presentation" | |||
className="w-full h-full select-none overflow-hidden text-xs" | |||
ref={forwardedRef} | |||
> | |||
<BinaryDataCanvas | |||
arrayBuffer={fileWithMetadata.metadata?.contents} | |||
className="overflow-visible binary-file-preview" | |||
headers | |||
byteClassName={(byte, index) => { | |||
if (byte < 0x10) { | |||
return 'x00'; | |||
} | |||
if (byte < 0x20) { | |||
return 'x10'; | |||
} | |||
if (byte < 0x30) { | |||
return 'x20'; | |||
} | |||
if (byte < 0x40) { | |||
return 'x30'; | |||
} | |||
if (byte < 0x50) { | |||
return 'x40'; | |||
} | |||
if (byte < 0x60) { | |||
return 'x50'; | |||
} | |||
if (byte < 0x70) { | |||
return 'x60'; | |||
} | |||
if (byte < 0x80) { | |||
return 'x70'; | |||
} | |||
if (byte < 0x90) { | |||
return 'x80'; | |||
} | |||
if (byte < 0xA0) { | |||
return 'x90'; | |||
} | |||
if (byte < 0xB0) { | |||
return 'xa0'; | |||
} | |||
if (byte < 0xC0) { | |||
return 'xb0'; | |||
} | |||
if (byte < 0xD0) { | |||
return 'xc0'; | |||
} | |||
if (byte < 0xE0) { | |||
return 'xd0'; | |||
} | |||
if (byte < 0xF0) { | |||
return 'xe0'; | |||
} | |||
return 'xf0'; | |||
}} | |||
/> | |||
</div> | |||
) | |||
} | |||
</div> | |||
</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 className="col-span-2 flex-shrink-0 m-0 flex flex-col gap-4 justify-between"> | |||
<KeyValueTable | |||
hiddenKeys | |||
data-testid="infoBox" | |||
properties={[ | |||
Boolean(fileWithMetadata.name) && { | |||
key: 'Name', | |||
className: 'font-bold', | |||
valueProps: { | |||
children: fileWithMetadata.name, | |||
title: fileWithMetadata.name, | |||
}, | |||
}, | |||
(enhanced && Boolean(getMimeTypeDescription(fileWithMetadata.type, fileWithMetadata.name) || '(Loading)') || Boolean(fileWithMetadata.type)) && { | |||
key: 'Type', | |||
valueProps: { | |||
className: clsx( | |||
!getMimeTypeDescription(fileWithMetadata.type, fileWithMetadata.name) && 'opacity-50' | |||
), | |||
children: getMimeTypeDescription(fileWithMetadata.type, fileWithMetadata.name) || '(Loading)', | |||
}, | |||
}, | |||
(enhanced && Boolean(formatFileSize(fileWithMetadata.size) || '(Loading)') || Boolean(fileWithMetadata.size)) && { | |||
key: 'Size', | |||
valueProps: { | |||
className: clsx( | |||
!formatFileSize(fileWithMetadata.size) && 'opacity-50' | |||
), | |||
title: `${formatNumeral(fileWithMetadata.size ?? 0)} byte(s)`, | |||
children: formatFileSize(fileWithMetadata.size) || '(Loading)', | |||
}, | |||
}, | |||
]} | |||
/> | |||
</div> | |||
</dl> | |||
</div> | |||
); | |||
</div> | |||
); | |||
}); | |||
BinaryFilePreview.displayName = 'BinaryFilePreview'; |
@@ -1,5 +1,5 @@ | |||
import * as React from 'react'; | |||
import {augmentImageFile, FileWithDataUrl, getMimeTypeDescription} from '@/utils/blob'; | |||
import {augmentImageFile, getMimeTypeDescription} from '@/utils/blob'; | |||
import {formatFileSize, formatNumeral} from '@/utils/numeral'; | |||
import clsx from 'clsx'; | |||
import {useFileMetadata, useFileUrl, useImageControls} from '@/categories/blob/react'; | |||
@@ -7,9 +7,10 @@ import {KeyValueTable} from '@/categories/information/react'; | |||
type ImageFilePreviewDerivedComponent = HTMLImageElement; | |||
export interface ImageFilePreviewProps extends Omit<React.HTMLProps<ImageFilePreviewDerivedComponent>, 'src' | 'alt'> { | |||
file?: Partial<FileWithDataUrl>; | |||
export interface ImageFilePreviewProps<F extends Partial<File> = Partial<File>> extends Omit<React.HTMLProps<ImageFilePreviewDerivedComponent>, 'src' | 'alt'> { | |||
file?: F; | |||
disabled?: boolean; | |||
enhanced?: boolean; | |||
} | |||
export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponent, ImageFilePreviewProps>(({ | |||
@@ -17,10 +18,11 @@ export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponen | |||
className, | |||
style, | |||
disabled = false, | |||
enhanced: enhancedProp = false, | |||
...etcProps | |||
}, forwardedRef) => { | |||
const { fileWithUrl, loading: urlLoading } = useFileUrl({ file }); | |||
const { augmentedFile, loading: metadataLoading, error } = useFileMetadata({ | |||
const { fileWithMetadata, loading: metadataLoading, error } = useFileMetadata({ | |||
file: fileWithUrl as File, | |||
augmentFunction: augmentImageFile, | |||
}); | |||
@@ -33,12 +35,17 @@ export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponen | |||
forwardedRef, | |||
}); | |||
if (!(augmentedFile)) { | |||
const [enhanced, setEnhanced] = React.useState(false); | |||
React.useEffect(() => { | |||
setEnhanced(enhancedProp); | |||
}, [enhancedProp]); | |||
if (!(fileWithMetadata)) { | |||
return null; | |||
} | |||
const cannotDisplayPicture = Boolean( | |||
typeof augmentedFile.url !== 'string' | |||
typeof fileWithMetadata.url !== 'string' | |||
&& error | |||
); | |||
@@ -52,7 +59,7 @@ export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponen | |||
> | |||
<div className="h-full relative"> | |||
<div className="sm:absolute top-0 left-0 w-full sm:h-full z-[3]"> | |||
{typeof augmentedFile.url === 'string' && ( | |||
{typeof fileWithMetadata.url === 'string' && ( | |||
<img | |||
{...etcProps} | |||
ref={imageRef} | |||
@@ -63,7 +70,7 @@ export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponen | |||
'object-cover w-full': !fullScreen, | |||
}, | |||
)} | |||
src={augmentedFile.url} | |||
src={fileWithMetadata.url} | |||
alt="" | |||
data-testid="preview" | |||
/> | |||
@@ -82,47 +89,47 @@ export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponen | |||
hiddenKeys | |||
data-testid="infoBox" | |||
properties={[ | |||
{ | |||
Boolean(fileWithMetadata.name) && { | |||
key: 'Name', | |||
className: 'font-bold', | |||
valueProps: { | |||
ref: filenameRef, | |||
children: augmentedFile.name, | |||
children: fileWithMetadata.name, | |||
}, | |||
}, | |||
{ | |||
(enhanced && Boolean(getMimeTypeDescription(fileWithMetadata.type, fileWithMetadata.name) || '(Loading)') || Boolean(fileWithMetadata.type)) && { | |||
key: 'Type', | |||
valueProps: { | |||
className: clsx( | |||
!getMimeTypeDescription(augmentedFile.type, augmentedFile.name) && 'opacity-50' | |||
!getMimeTypeDescription(fileWithMetadata.type, fileWithMetadata.name) && 'opacity-50' | |||
), | |||
children: getMimeTypeDescription(augmentedFile.type, augmentedFile.name) || '(Loading)', | |||
children: getMimeTypeDescription(fileWithMetadata.type, fileWithMetadata.name) || '(Loading)', | |||
}, | |||
}, | |||
{ | |||
(enhanced && Boolean(formatFileSize(fileWithMetadata.size) || '(Loading)') || Boolean(fileWithMetadata.size)) && { | |||
key: 'Size', | |||
valueProps: { | |||
className: clsx( | |||
!formatFileSize(augmentedFile.size) && 'opacity-50' | |||
!formatFileSize(fileWithMetadata.size) && 'opacity-50' | |||
), | |||
title: `${formatNumeral(augmentedFile.size ?? 0)} bytes`, | |||
children: formatFileSize(augmentedFile.size) || '(Loading)', | |||
title: `${formatNumeral(fileWithMetadata.size ?? 0)} byte(s)`, | |||
children: formatFileSize(fileWithMetadata.size) || '(Loading)', | |||
}, | |||
}, | |||
typeof augmentedFile.metadata?.width === 'number' | |||
&& typeof augmentedFile.metadata?.height === 'number' | |||
typeof fileWithMetadata.metadata?.width === 'number' | |||
&& typeof fileWithMetadata.metadata?.height === 'number' | |||
&& { | |||
key: 'Pixel Dimensions', | |||
valueProps: { | |||
children: `${formatNumeral(augmentedFile.metadata.width)} × ${formatNumeral(augmentedFile.metadata.height)} pixels`, | |||
children: `${formatNumeral(fileWithMetadata.metadata.width)} × ${formatNumeral(fileWithMetadata.metadata.height)} pixel(s)`, | |||
}, | |||
}, | |||
Array.isArray(augmentedFile.metadata?.palette) | |||
Array.isArray(fileWithMetadata.metadata?.palette) | |||
&& { | |||
key: 'Palette', | |||
valueProps: { | |||
className: 'mt-1', | |||
children: augmentedFile.metadata?.palette.map((rgb, i) => ( | |||
children: fileWithMetadata.metadata?.palette.map((rgb, i) => ( | |||
<React.Fragment | |||
key={rgb.join(' ')} | |||
> | |||
@@ -166,55 +173,57 @@ export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponen | |||
}, | |||
]} | |||
/> | |||
<form | |||
onSubmit={handleAction} | |||
className="flex gap-4" | |||
> | |||
<fieldset | |||
disabled={disabled || cannotDisplayPicture} | |||
className="contents" | |||
{enhanced && ( | |||
<form | |||
onSubmit={handleAction} | |||
className="flex gap-4" | |||
> | |||
<legend className="sr-only"> | |||
Controls | |||
</legend> | |||
<button | |||
type="submit" | |||
name="action" | |||
value="toggleFullScreen" | |||
className={clsx( | |||
'h-12 flex bg-negative text-primary disabled:text-primary focus:text-secondary active:text-tertiary items-center justify-center leading-none gap-4 select-none', | |||
'focus:outline-0', | |||
'disabled:opacity-50 disabled:cursor-not-allowed', | |||
{ | |||
'fixed top-0 left-0 w-full h-full opacity-0 z-[3]': fullScreen, | |||
} | |||
)} | |||
<fieldset | |||
disabled={disabled || cannotDisplayPicture} | |||
className="contents" | |||
> | |||
<legend className="sr-only"> | |||
Controls | |||
</legend> | |||
<button | |||
type="submit" | |||
name="action" | |||
value="toggleFullScreen" | |||
className={clsx( | |||
'h-12 flex bg-negative text-primary disabled:text-primary focus:text-secondary active:text-tertiary items-center justify-center leading-none gap-4 select-none', | |||
'focus:outline-0', | |||
'disabled:opacity-50 disabled:cursor-not-allowed', | |||
{ | |||
'fixed top-0 left-0 w-full h-full opacity-0 z-[3]': fullScreen, | |||
} | |||
)} | |||
> | |||
<span | |||
className="block uppercase font-bold h-[1.1em] w-full whitespace-nowrap overflow-hidden text-ellipsis font-semi-expanded" | |||
> | |||
Preview | |||
</span> | |||
</button> | |||
{' '} | |||
<button | |||
type="submit" | |||
name="action" | |||
value="download" | |||
className={clsx( | |||
'h-12 flex bg-negative text-primary disabled:text-primary focus:text-secondary active:text-tertiary items-center justify-center leading-none gap-4 select-none', | |||
'focus:outline-0', | |||
'disabled:opacity-50 disabled:cursor-not-allowed', | |||
)} | |||
> | |||
</button> | |||
{' '} | |||
<button | |||
type="submit" | |||
name="action" | |||
value="download" | |||
className={clsx( | |||
'h-12 flex bg-negative text-primary disabled:text-primary focus:text-secondary active:text-tertiary items-center justify-center leading-none gap-4 select-none', | |||
'focus:outline-0', | |||
'disabled:opacity-50 disabled:cursor-not-allowed', | |||
)} | |||
> | |||
<span | |||
className="block uppercase font-bold h-[1.1em] w-full whitespace-nowrap overflow-hidden text-ellipsis font-semi-expanded" | |||
> | |||
Download | |||
</span> | |||
</button> | |||
</fieldset> | |||
</form> | |||
</button> | |||
</fieldset> | |||
</form> | |||
)} | |||
</div> | |||
</div> | |||
); | |||
@@ -1,106 +1,146 @@ | |||
import * as React from 'react'; | |||
import Prism from 'prismjs'; | |||
import {formatFileSize, formatNumeral} from '@/utils/numeral'; | |||
import {TextFile} from '@/utils/blob'; | |||
import {useFileMetadata, useFileUrl} from '@/categories/blob/react'; | |||
import {augmentTextFile, getMimeTypeDescription} from '@/utils/blob'; | |||
import clsx from 'clsx'; | |||
import {KeyValueTable} from '@/categories/information/react'; | |||
import {Prism} from '@modal-soft/react-prism'; | |||
export interface TextFilePreviewProps { | |||
file: TextFile; | |||
type TextFilePreviewDerivedComponent = HTMLDivElement; | |||
export interface TextFilePreviewProps<F extends Partial<File> = Partial<File>> extends React.HTMLProps<TextFilePreviewDerivedComponent> { | |||
file?: F; | |||
enhanced?: boolean; | |||
} | |||
export const TextFilePreview: React.FC<TextFilePreviewProps> = ({ | |||
file: f, | |||
}) => ( | |||
<div className="flex gap-4 w-full h-full"> | |||
<div className={`h-full w-1/3 flex-shrink-0`}> | |||
{ | |||
typeof f.metadata?.contents === 'string' | |||
&& ( | |||
<div | |||
data-testid="preview" | |||
role="presentation" | |||
className="w-full h-full select-none overflow-hidden text-xs" | |||
> | |||
<pre className="overflow-visible"> | |||
{ | |||
typeof f.metadata.scheme === 'string' | |||
&& ( | |||
<code | |||
dangerouslySetInnerHTML={{ | |||
__html: Prism.highlight( | |||
f.metadata.contents, | |||
Prism.languages[f.metadata.scheme], | |||
f.metadata.scheme, | |||
).split('\n').slice(0, 15).join('\n'), | |||
}} | |||
style={{ | |||
tabSize: 2, | |||
}} | |||
/> | |||
) | |||
} | |||
{ | |||
typeof f.metadata.scheme !== 'string' | |||
&& ( | |||
<code> | |||
{f.metadata.contents} | |||
</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" | |||
> | |||
{typeof f.metadata?.schemeTitle === 'string' ? `${f.metadata.schemeTitle} Source` : 'Text File'} | |||
</dd> | |||
export const TextFilePreview = React.forwardRef<TextFilePreviewDerivedComponent, TextFilePreviewProps>(({ | |||
file, | |||
className, | |||
style, | |||
enhanced: enhancedProp = false, | |||
...etcProps | |||
}, forwardedRef) => { | |||
const { fileWithUrl } = useFileUrl({ file }); | |||
const { fileWithMetadata, error } = useFileMetadata({ | |||
file: fileWithUrl, | |||
augmentFunction: augmentTextFile, | |||
}); | |||
const [enhanced, setEnhanced] = React.useState(false); | |||
React.useEffect(() => { | |||
setEnhanced(enhancedProp); | |||
}, [enhancedProp]); | |||
if (!fileWithMetadata) { | |||
return null; | |||
} | |||
return ( | |||
<div | |||
className={clsx( | |||
'flex flex-col sm:grid sm:grid-cols-3 gap-8 w-full', | |||
className, | |||
)} | |||
style={style} | |||
> | |||
<div className="h-full relative col-span-2"> | |||
<div className="absolute top-0 left-0 w-full h-full"> | |||
{ | |||
typeof fileWithMetadata.metadata?.contents === 'string' | |||
&& ( | |||
<div | |||
{...etcProps} | |||
data-testid="preview" | |||
role="presentation" | |||
className="w-full h-full select-none overflow-hidden text-xs" | |||
ref={forwardedRef} | |||
> | |||
<Prism | |||
code={fileWithMetadata.metadata.contents} | |||
language={fileWithMetadata.metadata.scheme} | |||
lineNumbers={Boolean(fileWithMetadata.metadata.scheme)} | |||
maxLineNumber={20} | |||
/> | |||
</div> | |||
) | |||
} | |||
</div> | |||
</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 className="flex-shrink-0 m-0 flex flex-col gap-4 justify-between"> | |||
<KeyValueTable | |||
hiddenKeys | |||
data-testid="infoBox" | |||
properties={[ | |||
Boolean(fileWithMetadata.name) && { | |||
key: 'Name', | |||
className: 'font-bold', | |||
valueProps: { | |||
children: fileWithMetadata.name, | |||
title: fileWithMetadata.name, | |||
}, | |||
}, | |||
(enhanced && Boolean(getMimeTypeDescription(fileWithMetadata.type, fileWithMetadata.name) || '(Loading)') || Boolean(fileWithMetadata.type)) && { | |||
key: 'Type', | |||
valueProps: { | |||
children: typeof fileWithMetadata.metadata?.schemeTitle === 'string' ? `${fileWithMetadata.metadata.schemeTitle} Source` : 'Text File', | |||
}, | |||
}, | |||
(enhanced && Boolean(formatFileSize(fileWithMetadata.size) || '(Loading)') || Boolean(fileWithMetadata.size)) && { | |||
key: 'Size', | |||
valueProps: { | |||
className: clsx( | |||
!formatFileSize(fileWithMetadata.size) && 'opacity-50' | |||
), | |||
title: `${formatNumeral(fileWithMetadata.size ?? 0)} byte(s)`, | |||
children: formatFileSize(fileWithMetadata.size) || '(Loading)', | |||
}, | |||
}, | |||
Array.isArray(fileWithMetadata.metadata?.languageMatches) | |||
&& { | |||
key: 'Language', | |||
valueProps: { | |||
children: ( | |||
<dl | |||
className="text-sm" | |||
> | |||
{fileWithMetadata.metadata!.languageMatches.slice(0, 3).map(([language, probability], index) => { | |||
return ( | |||
<div key={index} className="flex justify-between"> | |||
<dt> | |||
{language.slice(0, 1).toUpperCase()} | |||
{language.slice(1)} | |||
</dt> | |||
<dd className="tabular-nums"> | |||
({(probability * 100).toFixed(3)}%) | |||
</dd> | |||
</div> | |||
); | |||
})} | |||
</dl> | |||
), | |||
}, | |||
}, | |||
typeof fileWithMetadata.metadata?.lineCount === 'number' | |||
&& typeof fileWithMetadata.metadata?.linesOfCode !== 'number' | |||
&& { | |||
key: 'Lines', | |||
valueProps: { | |||
children: `${formatNumeral(fileWithMetadata.metadata.lineCount)} line(s)` | |||
}, | |||
}, | |||
typeof fileWithMetadata.metadata?.lineCount === 'number' | |||
&& typeof fileWithMetadata.metadata?.linesOfCode === 'number' | |||
&& { | |||
key: 'Lines', | |||
valueProps: { | |||
children: `${formatNumeral(fileWithMetadata.metadata.lineCount)} line(s), ${formatNumeral(fileWithMetadata.metadata.linesOfCode)} loc`, | |||
}, | |||
}, | |||
]} | |||
/> | |||
</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> | |||
); | |||
}); | |||
TextFilePreview.displayName = 'TextFilePreview'; |
@@ -1,15 +1,15 @@ | |||
import * as React from 'react'; | |||
import {augmentVideoFile, getMimeTypeDescription} from '@/utils/blob'; | |||
import {formatFileSize, formatNumeral, formatSecondsDurationConcise} from '@/utils/numeral'; | |||
import {useFileMetadata, useMediaControls} from '@tesseract-design/web-blob-react'; | |||
import {useFileMetadata, useFileUrl, useMediaControls} from '@tesseract-design/web-blob-react'; | |||
import clsx from 'clsx'; | |||
import {Slider} from '@tesseract-design/web-number-react'; | |||
import {KeyValueTable} from '@/categories/information/react'; | |||
type VideoFilePreviewDerivedComponent = HTMLVideoElement; | |||
export interface VideoFilePreviewProps extends Omit<React.HTMLProps<VideoFilePreviewDerivedComponent>, 'controls'> { | |||
file?: File; | |||
export interface VideoFilePreviewProps<F extends Partial<File> = Partial<File>> extends Omit<React.HTMLProps<VideoFilePreviewDerivedComponent>, 'controls'> { | |||
file?: F; | |||
disabled?: boolean; | |||
enhanced?: boolean; | |||
} | |||
@@ -19,11 +19,12 @@ export const VideoFilePreview = React.forwardRef<VideoFilePreviewDerivedComponen | |||
className, | |||
style, | |||
disabled = false, | |||
enhanced = false, | |||
enhanced: enhancedProp = false, | |||
...etcProps | |||
}, forwardedRef) => { | |||
const { augmentedFile, error } = useFileMetadata({ | |||
file, | |||
const { fileWithUrl } = useFileUrl({ file }); | |||
const { fileWithMetadata, error } = useFileMetadata({ | |||
file: fileWithUrl, | |||
augmentFunction: augmentVideoFile, | |||
}); | |||
const { | |||
@@ -50,7 +51,12 @@ export const VideoFilePreview = React.forwardRef<VideoFilePreviewDerivedComponen | |||
}); | |||
const formId = React.useId(); | |||
if (!augmentedFile) { | |||
const [enhanced, setEnhanced] = React.useState(false); | |||
React.useEffect(() => { | |||
setEnhanced(enhancedProp); | |||
}, [enhancedProp]); | |||
if (!fileWithMetadata) { | |||
return null; | |||
} | |||
@@ -67,7 +73,7 @@ export const VideoFilePreview = React.forwardRef<VideoFilePreviewDerivedComponen | |||
> | |||
<div className="h-full relative col-span-2"> | |||
{ | |||
typeof augmentedFile.metadata?.previewUrl === 'string' | |||
typeof fileWithMetadata.url === 'string' | |||
&& ( | |||
<div | |||
className="w-full h-full bg-black flex flex-col items-stretch" | |||
@@ -86,8 +92,8 @@ export const VideoFilePreview = React.forwardRef<VideoFilePreviewDerivedComponen | |||
controls={!enhanced} | |||
> | |||
<source | |||
src={augmentedFile.metadata.previewUrl} | |||
type={augmentedFile.type} | |||
src={fileWithMetadata.url} | |||
type={fileWithMetadata.type} | |||
/> | |||
</video> | |||
<button | |||
@@ -221,73 +227,78 @@ export const VideoFilePreview = React.forwardRef<VideoFilePreviewDerivedComponen | |||
hiddenKeys | |||
data-testid="infoBox" | |||
properties={[ | |||
{ | |||
Boolean(fileWithMetadata.name) && { | |||
key: 'Name', | |||
className: 'font-bold', | |||
valueProps: { | |||
ref: filenameRef, | |||
children: augmentedFile.name, | |||
children: fileWithMetadata.name, | |||
}, | |||
}, | |||
{ | |||
(enhanced && Boolean(getMimeTypeDescription(fileWithMetadata.type, fileWithMetadata.name) || '(Loading)') || Boolean(fileWithMetadata.type)) && { | |||
key: 'Type', | |||
valueProps: { | |||
className: clsx( | |||
!getMimeTypeDescription(augmentedFile.type, augmentedFile.name) && 'opacity-50' | |||
!getMimeTypeDescription(fileWithMetadata.type, fileWithMetadata.name) && 'opacity-50' | |||
), | |||
children: getMimeTypeDescription(augmentedFile.type, augmentedFile.name) || '(Loading)', | |||
children: getMimeTypeDescription(fileWithMetadata.type, fileWithMetadata.name) || '(Loading)', | |||
}, | |||
}, | |||
{ | |||
(enhanced && Boolean(formatFileSize(fileWithMetadata.size) || '(Loading)') || Boolean(fileWithMetadata.size)) && { | |||
key: 'Size', | |||
valueProps: { | |||
className: clsx( | |||
!formatFileSize(augmentedFile.size) && 'opacity-50' | |||
!formatFileSize(fileWithMetadata.size) && 'opacity-50' | |||
), | |||
title: `${formatNumeral(augmentedFile.size ?? 0)} bytes`, | |||
children: formatFileSize(augmentedFile.size) || '(Loading)', | |||
title: `${formatNumeral(fileWithMetadata.size ?? 0)} byte(s)`, | |||
children: formatFileSize(fileWithMetadata.size) || '(Loading)', | |||
}, | |||
}, | |||
typeof augmentedFile.metadata?.width === 'number' | |||
&& typeof augmentedFile.metadata?.height === 'number' | |||
typeof fileWithMetadata.metadata?.width === 'number' | |||
&& typeof fileWithMetadata.metadata?.height === 'number' | |||
&& { | |||
key: 'Pixel Dimensions', | |||
valueProps: { | |||
children: `${formatNumeral(augmentedFile.metadata.width)} × ${formatNumeral(augmentedFile.metadata.height)} pixels`, | |||
children: `${formatNumeral(fileWithMetadata.metadata.width)} × ${formatNumeral(fileWithMetadata.metadata.height)} pixel(s)`, | |||
}, | |||
}, | |||
]} | |||
/> | |||
<form | |||
id={formId} | |||
onSubmit={handleAction} | |||
className="flex gap-4" | |||
> | |||
<fieldset | |||
disabled={disabled || typeof error !== 'undefined'} | |||
className="contents" | |||
> | |||
<legend className="sr-only"> | |||
Controls | |||
</legend> | |||
<button | |||
type="submit" | |||
name="action" | |||
value="download" | |||
className={clsx( | |||
'h-12 flex bg-negative text-primary disabled:text-primary focus:text-secondary active:text-tertiary items-center justify-center leading-none gap-4 select-none', | |||
'focus:outline-0', | |||
'disabled:opacity-50 disabled:cursor-not-allowed', | |||
)} | |||
{ | |||
enhanced | |||
&& ( | |||
<form | |||
id={formId} | |||
onSubmit={handleAction} | |||
className="flex gap-4" | |||
> | |||
<fieldset | |||
disabled={disabled || typeof error !== 'undefined'} | |||
className="contents" | |||
> | |||
<legend className="sr-only"> | |||
Controls | |||
</legend> | |||
<button | |||
type="submit" | |||
name="action" | |||
value="download" | |||
className={clsx( | |||
'h-12 flex bg-negative text-primary disabled:text-primary focus:text-secondary active:text-tertiary items-center justify-center leading-none gap-4 select-none', | |||
'focus:outline-0', | |||
'disabled:opacity-50 disabled:cursor-not-allowed', | |||
)} | |||
> | |||
<span | |||
className="block uppercase font-bold h-[1.1em] w-full whitespace-nowrap overflow-hidden text-ellipsis font-semi-expanded" | |||
> | |||
Download | |||
</span> | |||
</button> | |||
</fieldset> | |||
</form> | |||
</button> | |||
</fieldset> | |||
</form> | |||
) | |||
} | |||
</div> | |||
</div> | |||
); | |||
@@ -35,9 +35,9 @@ export const useFileUrl = (options: UseFileUrlOptions) => { | |||
}), [fileWithUrl, loading]); | |||
}; | |||
export interface UseFileMetadataOptions<T extends Partial<File> = Partial<File>> { | |||
file?: File; | |||
augmentFunction: (file: File) => Promise<T>; | |||
export interface UseFileMetadataOptions<T extends Partial<File> = Partial<File>, U extends Partial<File> = Partial<File>> { | |||
file?: U; | |||
augmentFunction: (file: U) => Promise<T>; | |||
} | |||
export const useFileMetadata = <T extends Partial<File>>(options: UseFileMetadataOptions<T>) => { | |||
@@ -67,7 +67,7 @@ export const useFileMetadata = <T extends Partial<File>>(options: UseFileMetadat | |||
}, [file, augmentFunction]); | |||
return React.useMemo(() => ({ | |||
augmentedFile: fileWithMetadata, | |||
fileWithMetadata, | |||
error, | |||
loading, | |||
}), [fileWithMetadata, loading, error]); | |||
@@ -1,9 +1,9 @@ | |||
export * from './components/AudioFilePreview'; | |||
//export * from './components/AudioMiniFilePreview'; | |||
//export * from './components/BinaryFilePreview'; | |||
export * from './components/BinaryFilePreview'; | |||
//export * from './components/FileSelectBox'; | |||
export * from './components/ImageFilePreview'; | |||
//export * from './components/TextFilePreview'; | |||
export * from './components/TextFilePreview'; | |||
export * from './components/VideoFilePreview'; | |||
export * from './hooks/blob'; | |||
@@ -22,7 +22,7 @@ export const KeyValueTable = React.forwardRef<KeyValueTableDerivedElement, KeyVa | |||
<dl | |||
{...etcProps} | |||
className={clsx( | |||
'flex flex-wrap gap-y-1' | |||
'grid gap-y-1 grid-cols-3', | |||
)} | |||
ref={forwardedRef} | |||
> | |||
@@ -33,7 +33,7 @@ export const KeyValueTable = React.forwardRef<KeyValueTableDerivedElement, KeyVa | |||
className={clsx('contents', property.className)} | |||
> | |||
<dt | |||
className={clsx(hiddenKeys && 'sr-only', 'w-1/3 pr-4')} | |||
className={clsx(hiddenKeys && 'sr-only', 'pr-4')} | |||
> | |||
{property.key} | |||
</dt> | |||
@@ -41,8 +41,8 @@ export const KeyValueTable = React.forwardRef<KeyValueTableDerivedElement, KeyVa | |||
{...(property.valueProps ?? {})} | |||
className={clsx( | |||
'm-0 text-ellipsis overflow-hidden', | |||
!hiddenKeys && 'w-2/3', | |||
hiddenKeys && 'w-full', | |||
!hiddenKeys && 'col-span-2', | |||
hiddenKeys && 'col-span-3', | |||
property.valueProps?.className, | |||
)} | |||
> | |||
@@ -1,6 +1,11 @@ | |||
import WaveSurfer from 'wavesurfer.js'; | |||
export const getAudioMetadata = (audioUrl: string, fileType: string) => new Promise<Record<string, string | number>>(async (resolve, reject) => { | |||
export interface AudioMetadata { | |||
duration?: number; | |||
} | |||
export const getAudioMetadata = (audioUrl?: string, fileType?: string) => new Promise<AudioMetadata>(async (resolve, reject) => { | |||
// TODO server side | |||
if (fileType === 'audio/mid') { | |||
resolve({}); | |||
return; | |||
@@ -22,6 +27,11 @@ export const getAudioMetadata = (audioUrl: string, fileType: string) => new Prom | |||
resolve(metadata); | |||
}); | |||
if (audioUrl === undefined) { | |||
resolve({}); | |||
return; | |||
} | |||
await waveSurferInstance.load(audioUrl); | |||
} catch (err) { | |||
reject(err); |
@@ -1,6 +1,15 @@ | |||
import ColorThief from 'colorthief'; | |||
export const getImageMetadata = (imageUrl?: string) => new Promise<Record<string, string | number | [number, number, number][]>>((resolve, reject) => { | |||
type RgbTuple = [number, number, number]; | |||
export interface ImageMetadata { | |||
width?: number; | |||
height?: number; | |||
palette?: RgbTuple[]; | |||
} | |||
export const getImageMetadata = (imageUrl?: string) => new Promise<ImageMetadata>((resolve, reject) => { | |||
// TODO server side | |||
const image = new Image(); | |||
image.addEventListener('load', async (imageLoadEvent) => { |
@@ -0,0 +1,117 @@ | |||
import * as React from 'react'; | |||
import clsx from 'clsx'; | |||
type BinaryDataCanvasDerivedElement = HTMLPreElement; | |||
export interface BinaryDataCanvasProps extends Omit<React.HTMLProps<BinaryDataCanvasDerivedElement>, 'children' | 'headers'> { | |||
arrayBuffer?: ArrayBuffer; | |||
lineClassName?: string; | |||
byteClassName?: string | ((byte: number, index: number) => string) | |||
headers?: boolean; | |||
} | |||
const BYTES_PER_LINE = 16 as const; | |||
export const BinaryDataCanvas = React.forwardRef<BinaryDataCanvasDerivedElement, BinaryDataCanvasProps>(({ | |||
arrayBuffer, | |||
lineClassName, | |||
byteClassName, | |||
headers = false, | |||
className, | |||
...etcProps | |||
}, forwardedRef) => { | |||
const bytesGrouped = arrayBuffer ? (Array.from(new Uint8Array(arrayBuffer)).slice(0, BYTES_PER_LINE * 20) as number[]) | |||
.reduce( | |||
(byteArray: number[][], byte: number, i) => { | |||
if (i % BYTES_PER_LINE === 0) { | |||
return [ | |||
...byteArray, | |||
[byte], | |||
] | |||
} | |||
const lastLine = byteArray.at(-1) as number[] | |||
return [ | |||
...(byteArray.slice(0, -1)), | |||
[...lastLine, byte], | |||
] | |||
}, | |||
[] as number[][], | |||
) : [] as number[][]; | |||
return ( | |||
<div className={clsx( | |||
'flex gap-2 leading-tight', | |||
className, | |||
)}> | |||
{headers && ( | |||
<pre | |||
style={{ | |||
padding: 0, | |||
overflow: 'visible', | |||
margin: 0, | |||
textAlign: 'right', | |||
}} | |||
> | |||
<code> | |||
{'\u00a0'} | |||
</code> | |||
{bytesGrouped.map((_, i) => ( | |||
<React.Fragment key={i}> | |||
{'\n'} | |||
<code | |||
style={{ | |||
whiteSpace: 'nowrap', | |||
}} | |||
> | |||
0x{(BYTES_PER_LINE * i).toString(16).padStart(2, '0')} | |||
</code> | |||
</React.Fragment> | |||
))} | |||
</pre> | |||
)} | |||
<div> | |||
<pre | |||
{...etcProps} | |||
ref={forwardedRef} | |||
className="leading-tight" | |||
> | |||
<code> | |||
{headers && new Array(BYTES_PER_LINE).fill(0).map((_, i) => ( | |||
<span | |||
key={i} | |||
> | |||
{i > 0 && ' '} | |||
{i.toString(16).padStart(2, '+')} | |||
</span> | |||
))} | |||
{ | |||
bytesGrouped.map((line: number[], l) => ( | |||
<React.Fragment key={l}> | |||
{l > 0 && '\n'} | |||
<span | |||
className={lineClassName} | |||
> | |||
{line.map((byte, b) => ( | |||
<React.Fragment key={b}> | |||
{b > 0 && ' '} | |||
<span | |||
className={typeof byteClassName === 'function' ? byteClassName(byte, l * BYTES_PER_LINE + b) : byteClassName} | |||
> | |||
{byte.toString(16).padStart(2, '0')} | |||
</span> | |||
</React.Fragment> | |||
))} | |||
</span> | |||
</React.Fragment> | |||
)) | |||
} | |||
</code> | |||
</pre> | |||
</div> | |||
</div> | |||
) | |||
}); | |||
BinaryDataCanvas.displayName = 'BinaryDataCanvas'; |
@@ -0,0 +1,134 @@ | |||
import * as React from 'react'; | |||
import PrismJs from 'prismjs'; | |||
import clsx from 'clsx'; | |||
type PrismDerivedElement = HTMLPreElement; | |||
export interface PrismProps extends Omit<React.HTMLProps<PrismDerivedElement>, 'children'> { | |||
code?: string; | |||
language?: string; | |||
lineNumbers?: boolean; | |||
maxLineNumber?: number; | |||
tabSize?: number; | |||
} | |||
export const Prism = React.forwardRef<PrismDerivedElement, PrismProps>(({ | |||
code, | |||
language = 'plain', | |||
lineNumbers = false, | |||
maxLineNumber = Infinity, | |||
tabSize = 2, | |||
className, | |||
style, | |||
...etcProps | |||
}, forwardedRef) => { | |||
const [highlightedCode, setHighlightedCode] = React.useState<string>(''); | |||
React.useEffect(() => { | |||
const loadHighlighter = async () => { | |||
if (language !== 'plain') { | |||
await import(`prismjs/components/prism-${language}`); | |||
} | |||
if (!code) { | |||
setHighlightedCode(''); | |||
return; | |||
} | |||
if (!Number.isFinite(maxLineNumber)) { | |||
setHighlightedCode( | |||
PrismJs.highlight( | |||
code, | |||
PrismJs.languages[language], | |||
language, | |||
) | |||
) | |||
return; | |||
} | |||
setHighlightedCode( | |||
PrismJs.highlight( | |||
code, | |||
PrismJs.languages[language], | |||
language, | |||
).split('\n').slice(0, maxLineNumber).join('\n') | |||
); | |||
} | |||
void loadHighlighter(); | |||
}, [language, code, maxLineNumber]); | |||
if (!code) { | |||
return null; | |||
} | |||
return ( | |||
<div | |||
className={clsx( | |||
'flex gap-2 leading-tight', | |||
className, | |||
)} | |||
style={style} | |||
> | |||
{lineNumbers && ( | |||
<pre | |||
style={{ | |||
padding: 0, | |||
overflow: 'visible', | |||
margin: 0, | |||
textAlign: 'right', | |||
}} | |||
> | |||
{new Array(maxLineNumber).fill(0).map((_, i) => ( | |||
<React.Fragment key={i}> | |||
{i > 0 && '\n'} | |||
<code | |||
style={{ | |||
whiteSpace: 'nowrap', | |||
}} | |||
> | |||
{i + 1} | |||
</code> | |||
</React.Fragment> | |||
))} | |||
</pre> | |||
)} | |||
<pre | |||
{...etcProps} | |||
ref={forwardedRef} | |||
> | |||
{ | |||
language !== 'plain' | |||
&& ( | |||
<code | |||
className="prism" | |||
dangerouslySetInnerHTML={{ | |||
__html: highlightedCode, | |||
}} | |||
style={{ | |||
tabSize, | |||
}} | |||
/> | |||
) | |||
} | |||
{ | |||
language === 'plain' | |||
&& ( | |||
<code | |||
className="prism" | |||
style={{ | |||
tabSize, | |||
}} | |||
> | |||
{code} | |||
</code> | |||
) | |||
} | |||
</pre> | |||
</div> | |||
) | |||
}); | |||
Prism.displayName = 'Prism'; | |||
@@ -80,6 +80,7 @@ export const WaveformCanvas = React.forwardRef<SpectrogramCanvasDerivedComponent | |||
container: containerRef.current, | |||
height: containerRef.current.clientHeight, | |||
autoplay: autoPlay, | |||
fillParent: true, | |||
waveColor, | |||
progressColor, | |||
cursorColor, | |||
@@ -159,7 +160,7 @@ export const WaveformCanvas = React.forwardRef<SpectrogramCanvasDerivedComponent | |||
className="flex-auto relative" | |||
> | |||
<div className="absolute top-0 left-0 w-full h-full"> | |||
<div className="w-full h-full" | |||
<div className="w-full h-full relative" | |||
ref={containerRef} | |||
/> | |||
</div> | |||
@@ -31,15 +31,40 @@ const RESOLVED_ALIASES = Object.fromEntries( | |||
) | |||
); | |||
export const getTextMetadata = (contents: string, filename: string) => { | |||
type Language = string; | |||
type LanguageProbability = number; | |||
type LanguageMatch = [Language, LanguageProbability]; | |||
export interface TextMetadata { | |||
contents?: string; | |||
scheme?: string; | |||
schemeTitle?: string; | |||
languageMatches?: LanguageMatch[]; | |||
lineCount?: number; | |||
linesOfCode?: number; | |||
} | |||
const countLinesOfCode = (lines: string[], scheme: string): number => { | |||
// TODO count loc depending on scheme | |||
//return lines.filter((line) => !line.trim().startsWith('//')).length; | |||
return lines.filter((line) => line.trim().length > 0).length; | |||
} | |||
export const getTextMetadata = (contents: string, filename?: string): Promise<TextMetadata> => { | |||
const lineNormalizedContents = contents.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); | |||
const lines = lineNormalizedContents.split('\n'); | |||
const lineCount = lines.length; | |||
const metadata = Object.entries(RESOLVED_ALIASES).reduce( | |||
(theMetadata, [key, value]) => { | |||
if (typeof theMetadata.scheme === 'undefined' && filename.endsWith(value.extension)) { | |||
if (typeof theMetadata.scheme === 'undefined' && filename?.endsWith(value.extension)) { | |||
if (value.aliasOf) { | |||
return { | |||
...theMetadata, | |||
scheme: value.aliasOf, | |||
schemeTitle: value.title, | |||
linesOfCode: countLinesOfCode(lines, value.aliasOf), | |||
} | |||
} | |||
@@ -47,21 +72,22 @@ export const getTextMetadata = (contents: string, filename: string) => { | |||
...theMetadata, | |||
scheme: key, | |||
schemeTitle: value.title, | |||
linesOfCode: countLinesOfCode(lines, key), | |||
}; | |||
} | |||
return theMetadata; | |||
}, | |||
{} as Record<string, number | string> | |||
{ | |||
contents, | |||
lineCount, | |||
} as TextMetadata | |||
); | |||
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; | |||
metadata.languageMatches = naturalLanguageDetector.detect(contents); | |||
} | |||
return metadata; | |||
return Promise.resolve(metadata); | |||
} |
@@ -1,4 +1,11 @@ | |||
export const getVideoMetadata = (videoUrl: string) => new Promise<Record<string, string | number>>((resolve, reject) => { | |||
export interface VideoFileMetadata { | |||
width?: number; | |||
height?: number; | |||
duration?: number; | |||
} | |||
export const getVideoMetadata = (videoUrl?: string) => new Promise<VideoFileMetadata>((resolve, reject) => { | |||
// TODO server side | |||
const video = window.document.createElement('video'); | |||
const source = window.document.createElement('source'); | |||
@@ -7,6 +14,7 @@ export const getVideoMetadata = (videoUrl: string) => new Promise<Record<string, | |||
const metadata = { | |||
width: thisVideo.videoWidth, | |||
height: thisVideo.videoHeight, | |||
duration: thisVideo.duration, | |||
}; | |||
video.remove(); | |||
resolve(metadata); | |||
@@ -17,6 +25,11 @@ export const getVideoMetadata = (videoUrl: string) => new Promise<Record<string, | |||
video.remove(); | |||
}); | |||
if (typeof videoUrl === 'undefined') { | |||
resolve({}); | |||
return; | |||
} | |||
source.src = videoUrl; | |||
video.appendChild(source); | |||
}); |
@@ -41,18 +41,52 @@ const BlobPage: NextPage = () => { | |||
}); | |||
}, []); | |||
const [binaryFile, setBinaryFile] = React.useState<File>(); | |||
React.useEffect(() => { | |||
fetch('/binary.bin').then((response) => { | |||
response.blob().then((blob) => { | |||
setBinaryFile(new File([blob], 'binary.bin', { | |||
type: 'application/octet-stream', | |||
})); | |||
}); | |||
}); | |||
}, []); | |||
const [plaintextFile, setPlaintextFile] = React.useState<File>(); | |||
React.useEffect(() => { | |||
fetch('/plaintext.txt').then((response) => { | |||
response.blob().then((blob) => { | |||
setPlaintextFile(new File([blob], 'plaintext.txt', { | |||
type: 'text/plain', | |||
})); | |||
}); | |||
}); | |||
}, []); | |||
const [codeFile, setCodeFile] = React.useState<File>(); | |||
React.useEffect(() => { | |||
fetch('/code.py').then((response) => { | |||
response.blob().then((blob) => { | |||
setCodeFile(new File([blob], 'code.py', { | |||
type: 'text/x-python', | |||
})); | |||
}); | |||
}); | |||
}, []); | |||
return ( | |||
<DefaultLayout> | |||
<Section title="ImageFilePreview"> | |||
<Subsection title="Single File"> | |||
<BlobReact.ImageFilePreview | |||
enhanced | |||
file={ | |||
imageFile | |||
?? { | |||
name: 'image.png', | |||
type: 'image/png', | |||
url: '/image.png', | |||
} | |||
} as Partial<File> | |||
} | |||
className="sm:h-64" | |||
/> | |||
@@ -61,7 +95,14 @@ const BlobPage: NextPage = () => { | |||
<Section title="VideoFilePreview"> | |||
<Subsection title="Single File"> | |||
<BlobReact.VideoFilePreview | |||
file={videoFile} | |||
file={ | |||
videoFile | |||
?? { | |||
name: 'video.mp4', | |||
type: 'video/mp4', | |||
url: '/video.mp4', | |||
} as Partial<File> | |||
} | |||
className="sm:h-64" | |||
enhanced | |||
/> | |||
@@ -70,12 +111,64 @@ const BlobPage: NextPage = () => { | |||
<Section title="AudioFilePreview"> | |||
<Subsection title="Single File"> | |||
<BlobReact.AudioFilePreview | |||
file={audioFile} | |||
file={ | |||
audioFile | |||
?? { | |||
name: 'audio.wav', | |||
type: 'audio/wav', | |||
url: '/audio.wav', | |||
} as Partial<File> | |||
} | |||
className="sm:h-64" | |||
enhanced | |||
/> | |||
</Subsection> | |||
</Section> | |||
<Section title="BinaryFilePreview"> | |||
<Subsection title="Single File"> | |||
<BlobReact.BinaryFilePreview | |||
file={ | |||
binaryFile | |||
?? { | |||
name: 'binary.bin', | |||
type: 'application/octet-stream', | |||
url: '/binary.bin', | |||
} as Partial<File> | |||
} | |||
className="sm:h-64" | |||
/> | |||
</Subsection> | |||
</Section> | |||
<Section title="TextFilePreview"> | |||
<Subsection title="Single File (Plaintext)"> | |||
<BlobReact.TextFilePreview | |||
file={ | |||
plaintextFile | |||
?? { | |||
name: 'plaintext.txt', | |||
type: 'text/plain', | |||
url: '/plaintext.txt', | |||
} as Partial<File> | |||
} | |||
className="sm:h-64" | |||
/> | |||
</Subsection> | |||
</Section> | |||
<Section title="TextFilePreview"> | |||
<Subsection title="Single File (Code)"> | |||
<BlobReact.TextFilePreview | |||
file={ | |||
codeFile | |||
?? { | |||
name: 'code.py', | |||
type: 'text/x-python', | |||
url: '/code.py', | |||
} as Partial<File> | |||
} | |||
className="sm:h-64" | |||
/> | |||
</Subsection> | |||
</Section> | |||
<Section title="FileSelectBox"> | |||
<Subsection title="Single File"> | |||
{/*<BlobReact.FileSelectBox*/} | |||
@@ -3,7 +3,7 @@ import Link from 'next/link'; | |||
import * as fs from 'fs/promises'; | |||
import * as path from 'path'; | |||
import * as Navigation from '@tesseract-design/web-navigation-react'; | |||
import { DefaultLayout } from 'src/components/DefaultLayout'; | |||
import { DefaultLayout } from '@/components/DefaultLayout'; | |||
import * as React from 'react' | |||
type Page = { | |||
@@ -78,3 +78,34 @@ | |||
.slider:active::-webkit-slider-thumb { | |||
box-shadow: -100000.5em 0 0 100000em rgb(var(--color-tertiary) / 50%); | |||
} | |||
.prism .token.number { color: rgb(var(--color-code-number)); } | |||
.prism .token.keyword { color: rgb(var(--color-code-keyword)); } | |||
.prism .token.type { color: rgb(var(--color-code-type)); } | |||
.prism .token.instance-attribute { color: rgb(var(--color-code-instance-attribute)); } | |||
.prism .token.function { color: rgb(var(--color-code-function)); } | |||
.prism .token.parameter { color: rgb(var(--color-code-parameter)); } | |||
.prism .token.property { color: rgb(var(--color-code-property)); } | |||
.prism .token.string { color: rgb(var(--color-code-string)); } | |||
.prism .token.variable { color: rgb(var(--color-code-variable)); } | |||
.prism .token.regexp { color: rgb(var(--color-code-regexp)); } | |||
.prism .token.url { color: rgb(var(--color-code-url)); } | |||
.prism .token.global { color: rgb(var(--color-code-global)); } | |||
.prism .token.comment { opacity: 0.5; } | |||
.binary-file-preview .x00 { color: rgb(var(--color-code-keyword)); } | |||
.binary-file-preview .x10 { color: rgb(var(--color-code-global)); } | |||
.binary-file-preview .x20 { color: rgb(var(--color-code-string)); } | |||
.binary-file-preview .x30 { color: rgb(var(--color-code-number)); } | |||
.binary-file-preview .x40 { color: rgb(var(--color-code-url)); } | |||
.binary-file-preview .x50 { color: rgb(var(--color-code-type)); } | |||
.binary-file-preview .x60 { color: rgb(var(--color-code-parameter)); } | |||
.binary-file-preview .x70 { color: rgb(var(--color-code-property)); } | |||
.binary-file-preview .x80 { color: rgb(var(--color-negative)); background-color: rgb(var(--color-code-keyword)); } | |||
.binary-file-preview .x90 { color: rgb(var(--color-negative)); background-color: rgb(var(--color-code-global)); } | |||
.binary-file-preview .xa0 { color: rgb(var(--color-negative)); background-color: rgb(var(--color-code-string)); } | |||
.binary-file-preview .xb0 { color: rgb(var(--color-negative)); background-color: rgb(var(--color-code-number)); } | |||
.binary-file-preview .xc0 { color: rgb(var(--color-negative)); background-color: rgb(var(--color-code-url)); } | |||
.binary-file-preview .xd0 { color: rgb(var(--color-negative)); background-color: rgb(var(--color-code-type)); } | |||
.binary-file-preview .xe0 { color: rgb(var(--color-negative)); background-color: rgb(var(--color-code-parameter)); } | |||
.binary-file-preview .xf0 { color: rgb(var(--color-negative)); background-color: rgb(var(--color-code-property)); } |
@@ -16,7 +16,7 @@ const theme = { | |||
"code-variable": "139 194 117", | |||
"code-regexp": "116 167 43", | |||
"code-url": "0 153 204", | |||
"code-global": "194 128 80" | |||
"code-global": "194 128 80", | |||
} as const; | |||
export default theme; |
@@ -1,13 +1,8 @@ | |||
import * as mimeTypes from 'mime-types'; | |||
import Blob from '../pages/categories/blob'; | |||
import {getTextMetadata} from './text'; | |||
import {getImageMetadata} from './image'; | |||
import {getAudioMetadata} from './audio'; | |||
import {getVideoMetadata} from '@/utils/video'; | |||
export interface FallbackFile extends Partial<File> { | |||
metadata?: Record<string, unknown>; | |||
} | |||
import {getTextMetadata, TextMetadata} from '@modal-soft/text-utils'; | |||
import {getImageMetadata, ImageMetadata} from '@modal-soft/image-utils'; | |||
import {AudioMetadata, getAudioMetadata} from '@modal-soft/audio-utils'; | |||
import {getVideoMetadata, VideoFileMetadata} from '@modal-soft/video-utils'; | |||
const MIME_TYPE_DESCRIPTIONS = { | |||
'image/gif': 'GIF Image', | |||
@@ -116,8 +111,6 @@ export const getContentType = (mimeType?: string, filename?: string) => { | |||
return ContentType.BINARY; | |||
} | |||
export const readAsText = (blob: Partial<Blob>) => blob?.text?.(); | |||
export const readAsDataURL = (blob: Partial<Blob>) => new Promise<string>((resolve, reject) => { | |||
const reader = new FileReader(); | |||
reader.addEventListener('error', () => { | |||
@@ -135,57 +128,6 @@ export const readAsDataURL = (blob: Partial<Blob>) => new Promise<string>((resol | |||
reader.readAsDataURL(blob as Blob); | |||
}); | |||
export const readAsArrayBuffer = (blob: Blob) => blob.arrayBuffer(); | |||
interface FileWithResolvedType<T extends ContentType> extends Partial<FileWithDataUrl> { | |||
resolvedType: T; | |||
originalFile?: File; | |||
} | |||
export interface TextFileMetadata { | |||
contents?: string; | |||
scheme?: string; | |||
schemeTitle?: string; | |||
language?: string; | |||
languageProbability?: number; | |||
} | |||
export interface TextFile extends FileWithResolvedType<ContentType.TEXT> { | |||
metadata?: TextFileMetadata; | |||
} | |||
const augmentTextFile = async (f: File): Promise<TextFile> => { | |||
const contents = await readAsText(f); | |||
const metadata = getTextMetadata(contents ?? '', f.name) as TextFileMetadata; | |||
return { | |||
...f, | |||
name: f.name, | |||
type: f.type, | |||
size: f.size, | |||
lastModified: f.lastModified, | |||
resolvedType: ContentType.TEXT, | |||
originalFile: f, | |||
metadata: { | |||
contents, | |||
language: metadata.language, | |||
languageProbability: metadata.languageProbability, | |||
scheme: metadata.scheme, | |||
schemeTitle: metadata.schemeTitle, | |||
}, | |||
}; | |||
}; | |||
export interface ImageFileMetadata { | |||
previewUrl?: string; | |||
width?: number; | |||
height?: number; | |||
palette?: [number, number, number][]; | |||
} | |||
export interface ImageFile extends FileWithResolvedType<ContentType.IMAGE> { | |||
metadata?: ImageFileMetadata; | |||
} | |||
export interface FileWithDataUrl extends File { | |||
url?: string; | |||
} | |||
@@ -195,94 +137,72 @@ export const addDataUrl = async (f: Partial<File>): Promise<Partial<FileWithData | |||
return f; | |||
} | |||
export const augmentImageFile = async (f: FileWithDataUrl): Promise<ImageFile> => { | |||
const imageMetadata = await getImageMetadata(f.url) as ImageFileMetadata; | |||
return { | |||
name: f.name, | |||
type: f.type, | |||
size: f.size, | |||
lastModified: f.lastModified, | |||
resolvedType: ContentType.IMAGE, | |||
url: f.url, | |||
metadata: imageMetadata, | |||
}; | |||
interface FileWithResolvedContentType extends Partial<FileWithDataUrl> { | |||
resolvedType: ContentType; | |||
} | |||
export interface TextFile extends FileWithResolvedContentType { | |||
resolvedType: ContentType.TEXT; | |||
metadata?: TextMetadata; | |||
} | |||
export const augmentTextFile = async <T extends Partial<FileWithDataUrl>>(file: T): Promise<TextFile> => { | |||
const contents = typeof file?.text === 'function' ? await file.text() : ''; | |||
const fileMutable = file as unknown as Record<string, TextMetadata>; | |||
fileMutable.metadata = await getTextMetadata(contents ?? '', file.name); | |||
return fileMutable as unknown as TextFile; | |||
}; | |||
export interface AudioFileMetadata { | |||
previewUrl?: string; | |||
duration?: number; | |||
export interface ImageFile extends FileWithResolvedContentType { | |||
resolvedType: ContentType.IMAGE; | |||
metadata?: ImageMetadata; | |||
} | |||
export interface AudioFile extends FileWithResolvedType<ContentType.AUDIO> { | |||
metadata?: AudioFileMetadata; | |||
export const augmentImageFile = async <T extends Partial<FileWithDataUrl>>(file: T): Promise<ImageFile> => { | |||
const fileMutable = file as unknown as Record<string, ImageMetadata>; | |||
fileMutable.metadata = await getImageMetadata(file.url); | |||
return fileMutable as unknown as ImageFile; | |||
}; | |||
export interface AudioFile extends FileWithResolvedContentType { | |||
resolvedType: ContentType.AUDIO; | |||
metadata?: AudioMetadata; | |||
} | |||
export const augmentAudioFile = async (f: File): Promise<AudioFile> => { | |||
const previewUrl = await readAsDataURL(f); | |||
const audioExtensions = await getAudioMetadata(previewUrl, f.type) as AudioFileMetadata; | |||
return { | |||
name: f.name, | |||
type: f.type, | |||
size: f.size, | |||
lastModified: f.lastModified, | |||
resolvedType: ContentType.AUDIO, | |||
originalFile: f, | |||
metadata: { | |||
previewUrl, | |||
duration: audioExtensions.duration, | |||
}, | |||
}; | |||
export const augmentAudioFile = async <T extends Partial<FileWithDataUrl>>(file: T): Promise<AudioFile> => { | |||
const fileMutable = file as unknown as Record<string, AudioMetadata>; | |||
fileMutable.metadata = await getAudioMetadata(file.url, file.type); | |||
return fileMutable as unknown as AudioFile; | |||
}; | |||
export interface BinaryFileMetadata { | |||
contents: ArrayBuffer; | |||
} | |||
export interface BinaryFile extends FileWithResolvedType<ContentType.BINARY> { | |||
export interface BinaryFile extends FileWithResolvedContentType { | |||
resolvedType: ContentType.BINARY; | |||
metadata?: BinaryFileMetadata; | |||
} | |||
const augmentBinaryFile = async (f: File): Promise<BinaryFile> => { | |||
const arrayBuffer = await readAsArrayBuffer(f); | |||
return { | |||
name: f.name, | |||
type: f.type, | |||
size: f.size, | |||
lastModified: f.lastModified, | |||
resolvedType: ContentType.BINARY, | |||
originalFile: f, | |||
metadata: { | |||
contents: arrayBuffer, | |||
}, | |||
export const augmentBinaryFile = async <T extends Partial<FileWithDataUrl>>(file: T): Promise<BinaryFile> => { | |||
const metadata = {} as BinaryFileMetadata; | |||
if (typeof file?.arrayBuffer === 'function') { | |||
metadata.contents = await file.arrayBuffer(); | |||
} | |||
const fileMutable = file as unknown as Record<string, BinaryFileMetadata>; | |||
fileMutable.metadata = metadata; | |||
return fileMutable as unknown as BinaryFile; | |||
}; | |||
export interface VideoFileMetadata { | |||
previewUrl?: string; | |||
width?: number; | |||
height?: number; | |||
} | |||
export interface VideoFile extends FileWithResolvedType<ContentType.VIDEO> { | |||
export interface VideoFile extends FileWithResolvedContentType { | |||
resolvedType: ContentType.VIDEO; | |||
metadata?: VideoFileMetadata; | |||
} | |||
export const augmentVideoFile = async (f: File): Promise<VideoFile> => { | |||
const previewUrl = await readAsDataURL(f); | |||
const videoMetadata = await getVideoMetadata(previewUrl); | |||
return { | |||
name: f.name, | |||
type: f.type, | |||
size: f.size, | |||
lastModified: f.lastModified, | |||
resolvedType: ContentType.VIDEO, | |||
originalFile: f, | |||
metadata: { | |||
previewUrl, | |||
width: videoMetadata.width as number, | |||
height: videoMetadata.height as number, | |||
}, | |||
}; | |||
export const augmentVideoFile = async <T extends Partial<FileWithDataUrl>>(file: T): Promise<VideoFile> => { | |||
const fileMutable = file as unknown as Record<string, AudioMetadata>; | |||
fileMutable.metadata = await getVideoMetadata(file.url); | |||
return fileMutable as unknown as VideoFile; | |||
}; | |||
export type AugmentedFile = TextFile | ImageFile | AudioFile | VideoFile | BinaryFile; | |||
@@ -20,7 +20,7 @@ export const formatFileSize = (size?: number) => { | |||
} | |||
if (size < (2 ** 10)) { | |||
return `${formatNumeral(size)} bytes`; | |||
return `${formatNumeral(size)} byte(s)`; | |||
} | |||
if (size < (2 ** 20)) { | |||
@@ -27,6 +27,7 @@ | |||
"@tesseract-design/web-option-react": ["./src/categories/option/react"], | |||
"@tesseract-design/web-number-react": ["./src/categories/number/react"], | |||
"@tesseract-design/web-navigation-react": ["./src/categories/navigation/react"], | |||
"@modal-soft/*": ["./src/packages/*"], | |||
} | |||
}, | |||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], | |||