import { useState } from 'react' import FancyTextEditor, { TextInputKeyDownEvent } from './inputs/FancyTextEditor.tsx' import Button from './buttons/Button.tsx' import { openFileDialog } from '../utils/openFileDialog.ts' import makePica from 'pica' interface NewPostWidgetProps { onSubmit: ( content: string, media: { file: File; width: number; height: number }[], isPublic: boolean, ) => void isSubmitting?: boolean } interface Attachment { id: string file: File objectUrl: string width: number height: number } export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPostWidgetProps) { const [content, setContent] = useState('') const [attachments, setAttachments] = useState([]) const [isPublic, setIsPublic] = useState(false) const onContentInput = (value: string) => { setContent(value) } async function onAddMediaClicked() { const files = await openFileDialog('image/*', true) if (files == null || files.length === 0) { return } const newAttachments = await Promise.all(Array.from(files).map(createAttachment)) setAttachments((attachments) => [...attachments, ...newAttachments]) } const handleSubmit = () => { if (!content.trim() && attachments.length === 0) { return } onSubmit(content, attachments, isPublic) attachments.forEach(({ objectUrl }) => URL.revokeObjectURL(objectUrl)) setContent('') setAttachments([]) } const handleRemoveMedia = (id: string) => { const attachment = attachments.find((attachment) => attachment.id === id)! setAttachments(attachments.filter((attachment) => attachment.id !== id)) URL.revokeObjectURL(attachment.objectUrl) } const onInputKeyDown = (e: TextInputKeyDownEvent) => { if (e.key === 'Enter' && e.ctrlKey) { e.preventDefault() handleSubmit() } } return (
{attachments.length > 0 && (
{attachments.map(({ objectUrl, id }) => ( ))}
)}
) } async function createAttachment(file: File): Promise { if (!file.type.startsWith('image/')) { throw new Error('not an image') } file = await optimizeImageSize(file) const objectUrl = URL.createObjectURL(file) const { width, height } = await getImageFileDimensions(objectUrl) return { id: getRandomId(), file, objectUrl, width, height, } } function getImageFileDimensions(objectURL: string): Promise<{ width: number; height: number }> { return new Promise((resolve) => { const img = document.createElement('img') img.addEventListener('load', () => { resolve({ width: img.width, height: img.height }) }) img.src = objectURL }) } const pica = makePica() async function optimizeImageSize( file: File, { targetMaxWidth = 1920, targetMaxHeight = 1080, targetSizeBytes = 500 * 1024, outputType = 'image/jpeg', quality = 0.9, }: { targetMaxWidth?: number targetMaxHeight?: number targetSizeBytes?: number outputType?: string quality?: number } = {}, ): Promise { const img = document.createElement('img') const url = URL.createObjectURL(file) img.src = url await img.decode() console.debug('processing image', { width: img.width, height: img.height, targetMaxWidth, targetMaxHeight, targetSizeBytes, outputType, quality, }) const scale = Math.min(1, targetMaxWidth / img.width, targetMaxHeight / img.height) const width = Math.floor(img.width * scale) const height = Math.floor(img.height * scale) const originalSize = file.size const srcCanvas = document.createElement('canvas') srcCanvas.width = img.width srcCanvas.height = img.height srcCanvas.getContext('2d')!.drawImage(img, 0, 0) const dstCanvas = document.createElement('canvas') dstCanvas.width = width dstCanvas.height = height await pica.resize(srcCanvas, dstCanvas) let blob = await pica.toBlob(dstCanvas, outputType, quality) while (blob.size > targetSizeBytes && quality > 0.1) { quality -= 0.1 blob = await pica.toBlob(dstCanvas, outputType, quality) } console.debug( `optimized image rendered at ${Math.round(quality * 100)}% quality to ${blob.size / 1000}KB from ${originalSize / 1000}KB`, ) URL.revokeObjectURL(url) return new File([blob], file.name, { type: file.type }) } function getRandomId() { if (window.isSecureContext) { return crypto.randomUUID() } // Fallback using getRandomValues const bytes = new Uint8Array(16) crypto.getRandomValues(bytes) // Format according to RFC4122 version 4 bytes[6] = (bytes[6]! & 0x0f) | 0x40 bytes[8] = (bytes[8]! & 0x3f) | 0x80 const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')) return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}` }