233 lines
6.3 KiB
TypeScript
233 lines
6.3 KiB
TypeScript
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<Attachment[]>([])
|
|
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 (
|
|
<div className="w-full p-4 border-b border-gray-200">
|
|
<FancyTextEditor
|
|
value={content}
|
|
onInput={onContentInput}
|
|
onKeyDown={onInputKeyDown}
|
|
className="mb-3"
|
|
placeholder="write something..."
|
|
/>
|
|
|
|
{attachments.length > 0 && (
|
|
<div className="flex flex-row flex-wrap gap-2 mb-3">
|
|
{attachments.map(({ objectUrl, id }) => (
|
|
<button
|
|
key={id}
|
|
className="relative cursor-pointer"
|
|
onClick={() => handleRemoveMedia(id)}
|
|
disabled={isSubmitting}
|
|
>
|
|
<img src={objectUrl} alt="" className="w-24 h-24 object-cover rounded-md" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-between items-center pt-2">
|
|
<div className="flex items-center gap-2">
|
|
<Button secondary onClick={onAddMediaClicked}>
|
|
+ add media
|
|
</Button>
|
|
<label className="flex items-center gap-1 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={isPublic}
|
|
onChange={(e) => setIsPublic(e.target.checked)}
|
|
disabled={isSubmitting}
|
|
className="form-checkbox h-4 w-4 text-blue-600"
|
|
/>
|
|
<span className="text-primary-500">public</span>
|
|
</label>
|
|
</div>
|
|
<Button
|
|
onClick={handleSubmit}
|
|
disabled={isSubmitting || (content.trim() === '' && attachments.length === 0)}
|
|
>
|
|
post
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
async function createAttachment(file: File): Promise<Attachment> {
|
|
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<File> {
|
|
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('')}`
|
|
}
|