ManimCat / frontend /src /studio /plot /preview /use-plot-preview-image.ts
Bin29's picture
Sync from main: e764154 feat(plot-skill): add math-exam-diagram SKILL.md for exam-style math figures
abcf568
import { useEffect, useState } from 'react'
const PREVIEW_MAX_EDGE = 2400
interface PlotPreviewImageState {
previewSrc?: string
isPreviewReady: boolean
}
export function usePlotPreviewImage(source?: string): PlotPreviewImageState {
const [previewSrc, setPreviewSrc] = useState<string | undefined>(source)
const [isPreviewReady, setIsPreviewReady] = useState(false)
useEffect(() => {
if (!source) {
setPreviewSrc(undefined)
setIsPreviewReady(false)
return undefined
}
if (looksLikeSvg(source)) {
setPreviewSrc(source)
setIsPreviewReady(true)
return undefined
}
let cancelled = false
let objectUrl: string | undefined
setPreviewSrc(source)
setIsPreviewReady(false)
void createPreviewBitmap(source, PREVIEW_MAX_EDGE).then((nextPreviewSrc) => {
if (cancelled) {
if (nextPreviewSrc?.startsWith('blob:')) {
URL.revokeObjectURL(nextPreviewSrc)
}
return
}
objectUrl = nextPreviewSrc?.startsWith('blob:') ? nextPreviewSrc : undefined
setPreviewSrc(nextPreviewSrc ?? source)
setIsPreviewReady(true)
}).catch(() => {
if (!cancelled) {
setPreviewSrc(source)
setIsPreviewReady(true)
}
})
return () => {
cancelled = true
if (objectUrl) {
URL.revokeObjectURL(objectUrl)
}
}
}, [source])
return {
previewSrc,
isPreviewReady,
}
}
async function createPreviewBitmap(source: string, maxEdge: number) {
const image = await loadImage(source)
const width = Math.max(1, image.naturalWidth || image.width || 1)
const height = Math.max(1, image.naturalHeight || image.height || 1)
const longestEdge = Math.max(width, height)
if (longestEdge <= maxEdge) {
return source
}
const scale = maxEdge / longestEdge
const targetWidth = Math.max(1, Math.round(width * scale))
const targetHeight = Math.max(1, Math.round(height * scale))
const canvas = document.createElement('canvas')
canvas.width = targetWidth
canvas.height = targetHeight
const context = canvas.getContext('2d', { alpha: false })
if (!context) {
return source
}
context.imageSmoothingEnabled = true
context.imageSmoothingQuality = 'high'
context.drawImage(image, 0, 0, targetWidth, targetHeight)
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, 'image/webp', 0.88)
})
if (!blob) {
return source
}
return URL.createObjectURL(blob)
}
async function loadImage(source: string) {
return await new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image()
image.decoding = 'async'
image.onload = () => resolve(image)
image.onerror = () => reject(new Error('Failed to load preview source'))
if (!source.startsWith('data:')) {
image.crossOrigin = 'anonymous'
}
image.src = source
})
}
function looksLikeSvg(source: string) {
return source.startsWith('data:image/svg+xml') || /\.svg(?:[?#]|$)/i.test(source)
}