dify
This commit is contained in:
126
dify/web/app/components/base/app-icon-picker/ImageInput.tsx
Normal file
126
dify/web/app/components/base/app-icon-picker/ImageInput.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
'use client'
|
||||
|
||||
import type { ChangeEvent, FC } from 'react'
|
||||
import { createRef, useEffect, useState } from 'react'
|
||||
import Cropper, { type Area, type CropperProps } from 'react-easy-crop'
|
||||
import classNames from 'classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ImagePlus } from '../icons/src/vender/line/images'
|
||||
import { useDraggableUploader } from './hooks'
|
||||
import { checkIsAnimatedImage } from './utils'
|
||||
import { ALLOW_FILE_EXTENSIONS } from '@/types/app'
|
||||
|
||||
export type OnImageInput = {
|
||||
(isCropped: true, tempUrl: string, croppedAreaPixels: Area, fileName: string): void
|
||||
(isCropped: false, file: File): void
|
||||
}
|
||||
|
||||
type UploaderProps = {
|
||||
className?: string
|
||||
cropShape?: CropperProps['cropShape']
|
||||
onImageInput?: OnImageInput
|
||||
}
|
||||
|
||||
const ImageInput: FC<UploaderProps> = ({
|
||||
className,
|
||||
cropShape,
|
||||
onImageInput,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [inputImage, setInputImage] = useState<{ file: File; url: string }>()
|
||||
const [isAnimatedImage, setIsAnimatedImage] = useState<boolean>(false)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (inputImage)
|
||||
URL.revokeObjectURL(inputImage.url)
|
||||
}
|
||||
}, [inputImage])
|
||||
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 })
|
||||
const [zoom, setZoom] = useState(1)
|
||||
|
||||
const onCropComplete = async (_: Area, croppedAreaPixels: Area) => {
|
||||
if (!inputImage)
|
||||
return
|
||||
onImageInput?.(true, inputImage.url, croppedAreaPixels, inputImage.file.name)
|
||||
}
|
||||
|
||||
const handleLocalFileInput = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
setInputImage({ file, url: URL.createObjectURL(file) })
|
||||
checkIsAnimatedImage(file).then((isAnimatedImage) => {
|
||||
setIsAnimatedImage(!!isAnimatedImage)
|
||||
if (isAnimatedImage)
|
||||
onImageInput?.(false, file)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
isDragActive,
|
||||
handleDragEnter,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
} = useDraggableUploader((file: File) => setInputImage({ file, url: URL.createObjectURL(file) }))
|
||||
|
||||
const inputRef = createRef<HTMLInputElement>()
|
||||
|
||||
const handleShowImage = () => {
|
||||
if (isAnimatedImage) {
|
||||
return (
|
||||
<img src={inputImage?.url} alt='' />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Cropper
|
||||
image={inputImage?.url}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
cropShape={cropShape}
|
||||
onCropChange={setCrop}
|
||||
onCropComplete={onCropComplete}
|
||||
onZoomChange={setZoom}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames(className, 'w-full px-3 py-1.5')}>
|
||||
<div
|
||||
className={classNames(
|
||||
isDragActive && 'border-primary-600',
|
||||
'relative flex aspect-square flex-col items-center justify-center rounded-lg border-[1.5px] border-dashed text-gray-500')}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{
|
||||
!inputImage
|
||||
? <>
|
||||
<ImagePlus className="pointer-events-none mb-3 h-[30px] w-[30px]" />
|
||||
<div className="mb-[2px] text-sm font-medium">
|
||||
<span className="pointer-events-none">{t('common.imageInput.dropImageHere')} </span>
|
||||
<button type="button" className="text-components-button-primary-bg" onClick={() => inputRef.current?.click()}>{t('common.imageInput.browse')}</button>
|
||||
<input
|
||||
ref={inputRef} type="file" className="hidden"
|
||||
onClick={e => ((e.target as HTMLInputElement).value = '')}
|
||||
accept={ALLOW_FILE_EXTENSIONS.map(ext => `.${ext}`).join(',')}
|
||||
onChange={handleLocalFileInput}
|
||||
/>
|
||||
</div>
|
||||
<div className="pointer-events-none">{t('common.imageInput.supportedFormats')}</div>
|
||||
</>
|
||||
: handleShowImage()
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageInput
|
||||
43
dify/web/app/components/base/app-icon-picker/hooks.tsx
Normal file
43
dify/web/app/components/base/app-icon-picker/hooks.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
export const useDraggableUploader = <T extends HTMLElement>(setImageFn: (file: File) => void) => {
|
||||
const [isDragActive, setIsDragActive] = useState(false)
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent<T>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragActive(true)
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent<T>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent<T>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragActive(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent<T>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragActive(false)
|
||||
|
||||
const file = e.dataTransfer.files[0]
|
||||
|
||||
if (!file)
|
||||
return
|
||||
|
||||
setImageFn(file)
|
||||
}, [setImageFn])
|
||||
|
||||
return {
|
||||
handleDragEnter,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
isDragActive,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import AppIconPicker, { type AppIconSelection } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/AppIconPicker',
|
||||
component: AppIconPicker,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Modal workflow for choosing an application avatar. Users can switch between emoji selections and image uploads (when enabled).',
|
||||
},
|
||||
},
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
navigation: {
|
||||
pathname: '/apps/demo-app/icon-picker',
|
||||
params: { appId: 'demo-app' },
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof AppIconPicker>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const AppIconPickerDemo = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selection, setSelection] = useState<AppIconSelection | null>(null)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[320px] flex-col items-start gap-4 px-6 py-8 md:px-12">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Choose icon…
|
||||
</button>
|
||||
|
||||
<div className="rounded-lg border border-divider-subtle bg-components-panel-bg p-4 text-sm text-text-secondary shadow-sm">
|
||||
<div className="font-medium text-text-primary">Selection preview</div>
|
||||
<pre className="mt-2 max-h-44 overflow-auto rounded-md bg-background-default-subtle p-3 font-mono text-xs leading-tight text-text-primary">
|
||||
{selection ? JSON.stringify(selection, null, 2) : 'No icon selected yet.'}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<AppIconPicker
|
||||
onSelect={(result) => {
|
||||
setSelection(result)
|
||||
setOpen(false)
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => <AppIconPickerDemo />,
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selection, setSelection] = useState<AppIconSelection | null>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setOpen(true)}>Choose icon…</button>
|
||||
{open && (
|
||||
<AppIconPicker
|
||||
onSelect={(result) => {
|
||||
setSelection(result)
|
||||
setOpen(false)
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
150
dify/web/app/components/base/app-icon-picker/index.tsx
Normal file
150
dify/web/app/components/base/app-icon-picker/index.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { FC } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Area } from 'react-easy-crop'
|
||||
import Modal from '../modal'
|
||||
import Divider from '../divider'
|
||||
import Button from '../button'
|
||||
import { useLocalFileUploader } from '../image-uploader/hooks'
|
||||
import EmojiPickerInner from '../emoji-picker/Inner'
|
||||
import type { OnImageInput } from './ImageInput'
|
||||
import ImageInput from './ImageInput'
|
||||
import s from './style.module.css'
|
||||
import getCroppedImg from './utils'
|
||||
import type { AppIconType, ImageFile } from '@/types/app'
|
||||
import cn from '@/utils/classnames'
|
||||
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'
|
||||
import { noop } from 'lodash-es'
|
||||
import { RiImageCircleAiLine } from '@remixicon/react'
|
||||
|
||||
export type AppIconEmojiSelection = {
|
||||
type: 'emoji'
|
||||
icon: string
|
||||
background: string
|
||||
}
|
||||
|
||||
export type AppIconImageSelection = {
|
||||
type: 'image'
|
||||
fileId: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type AppIconSelection = AppIconEmojiSelection | AppIconImageSelection
|
||||
|
||||
type AppIconPickerProps = {
|
||||
onSelect?: (payload: AppIconSelection) => void
|
||||
onClose?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const AppIconPicker: FC<AppIconPickerProps> = ({
|
||||
onSelect,
|
||||
onClose,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const tabs = [
|
||||
{ key: 'emoji', label: t('app.iconPicker.emoji'), icon: <span className="text-lg">🤖</span> },
|
||||
{ key: 'image', label: t('app.iconPicker.image'), icon: <RiImageCircleAiLine className='size-4' /> },
|
||||
]
|
||||
const [activeTab, setActiveTab] = useState<AppIconType>('emoji')
|
||||
|
||||
const [emoji, setEmoji] = useState<{ emoji: string; background: string }>()
|
||||
const handleSelectEmoji = useCallback((emoji: string, background: string) => {
|
||||
setEmoji({ emoji, background })
|
||||
}, [setEmoji])
|
||||
|
||||
const [uploading, setUploading] = useState<boolean>()
|
||||
|
||||
const { handleLocalFileUpload } = useLocalFileUploader({
|
||||
limit: 3,
|
||||
disabled: false,
|
||||
onUpload: (imageFile: ImageFile) => {
|
||||
if (imageFile.fileId) {
|
||||
setUploading(false)
|
||||
onSelect?.({
|
||||
type: 'image',
|
||||
fileId: imageFile.fileId,
|
||||
url: imageFile.url,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
type InputImageInfo = { file: File } | { tempUrl: string; croppedAreaPixels: Area; fileName: string }
|
||||
const [inputImageInfo, setInputImageInfo] = useState<InputImageInfo>()
|
||||
|
||||
const handleImageInput: OnImageInput = async (isCropped: boolean, fileOrTempUrl: string | File, croppedAreaPixels?: Area, fileName?: string) => {
|
||||
setInputImageInfo(
|
||||
isCropped
|
||||
? { tempUrl: fileOrTempUrl as string, croppedAreaPixels: croppedAreaPixels!, fileName: fileName! }
|
||||
: { file: fileOrTempUrl as File },
|
||||
)
|
||||
}
|
||||
|
||||
const handleSelect = async () => {
|
||||
if (activeTab === 'emoji') {
|
||||
if (emoji) {
|
||||
onSelect?.({
|
||||
type: 'emoji',
|
||||
icon: emoji.emoji,
|
||||
background: emoji.background,
|
||||
})
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (!inputImageInfo)
|
||||
return
|
||||
setUploading(true)
|
||||
if ('file' in inputImageInfo) {
|
||||
handleLocalFileUpload(inputImageInfo.file)
|
||||
return
|
||||
}
|
||||
const blob = await getCroppedImg(inputImageInfo.tempUrl, inputImageInfo.croppedAreaPixels, inputImageInfo.fileName)
|
||||
const file = new File([blob], inputImageInfo.fileName, { type: blob.type })
|
||||
handleLocalFileUpload(file)
|
||||
}
|
||||
}
|
||||
|
||||
return <Modal
|
||||
onClose={noop}
|
||||
isShow
|
||||
closable={false}
|
||||
wrapperClassName={className}
|
||||
className={cn(s.container, '!h-[462px] !w-[362px] !p-0')}
|
||||
>
|
||||
{!DISABLE_UPLOAD_IMAGE_AS_ICON && <div className="w-full p-2 pb-0">
|
||||
<div className='flex items-center justify-center gap-2 rounded-xl bg-background-body p-1 text-text-primary'>
|
||||
{tabs.map(tab => (
|
||||
<button type="button"
|
||||
key={tab.key}
|
||||
className={cn(
|
||||
'system-sm-medium flex h-8 flex-1 shrink-0 items-center justify-center rounded-lg p-2 text-text-tertiary',
|
||||
activeTab === tab.key && 'bg-components-main-nav-nav-button-bg-active text-text-accent shadow-md',
|
||||
)}
|
||||
onClick={() => setActiveTab(tab.key as AppIconType)}
|
||||
>
|
||||
{tab.icon} {tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{activeTab === 'emoji' && <EmojiPickerInner className={cn('flex-1 overflow-hidden pt-2')} onSelect={handleSelectEmoji} />}
|
||||
{activeTab === 'image' && <ImageInput className={cn('flex-1 overflow-hidden')} onImageInput={handleImageInput} />}
|
||||
|
||||
<Divider className='m-0' />
|
||||
<div className='flex w-full items-center justify-center gap-2 p-3'>
|
||||
<Button className='w-full' onClick={() => onClose?.()}>
|
||||
{t('app.iconPicker.cancel')}
|
||||
</Button>
|
||||
|
||||
<Button variant="primary" className='w-full' disabled={uploading} loading={uploading} onClick={handleSelect}>
|
||||
{t('app.iconPicker.ok')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
}
|
||||
|
||||
export default AppIconPicker
|
||||
@@ -0,0 +1,9 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 362px;
|
||||
max-height: 552px;
|
||||
box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03);
|
||||
border-radius: 12px;
|
||||
}
|
||||
166
dify/web/app/components/base/app-icon-picker/utils.ts
Normal file
166
dify/web/app/components/base/app-icon-picker/utils.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
export const createImage = (url: string) =>
|
||||
new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image()
|
||||
image.addEventListener('load', () => resolve(image))
|
||||
image.addEventListener('error', error => reject(error))
|
||||
image.setAttribute('crossOrigin', 'anonymous') // needed to avoid cross-origin issues on CodeSandbox
|
||||
image.src = url
|
||||
})
|
||||
|
||||
export function getRadianAngle(degreeValue: number) {
|
||||
return (degreeValue * Math.PI) / 180
|
||||
}
|
||||
|
||||
export function getMimeType(fileName: string): string {
|
||||
const extension = fileName.split('.').pop()?.toLowerCase()
|
||||
switch (extension) {
|
||||
case 'png':
|
||||
return 'image/png'
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
return 'image/jpeg'
|
||||
case 'gif':
|
||||
return 'image/gif'
|
||||
case 'webp':
|
||||
return 'image/webp'
|
||||
default:
|
||||
return 'image/jpeg'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the new bounding area of a rotated rectangle.
|
||||
*/
|
||||
export function rotateSize(width: number, height: number, rotation: number) {
|
||||
const rotRad = getRadianAngle(rotation)
|
||||
|
||||
return {
|
||||
width:
|
||||
Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height),
|
||||
height:
|
||||
Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function was adapted from the one in the ReadMe of https://github.com/DominicTobias/react-image-crop
|
||||
*/
|
||||
export default async function getCroppedImg(
|
||||
imageSrc: string,
|
||||
pixelCrop: { x: number; y: number; width: number; height: number },
|
||||
fileName: string,
|
||||
rotation = 0,
|
||||
flip = { horizontal: false, vertical: false },
|
||||
): Promise<Blob> {
|
||||
const image = await createImage(imageSrc)
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
const mimeType = getMimeType(fileName)
|
||||
|
||||
if (!ctx)
|
||||
throw new Error('Could not create a canvas context')
|
||||
|
||||
const rotRad = getRadianAngle(rotation)
|
||||
|
||||
// calculate bounding box of the rotated image
|
||||
const { width: bBoxWidth, height: bBoxHeight } = rotateSize(
|
||||
image.width,
|
||||
image.height,
|
||||
rotation,
|
||||
)
|
||||
|
||||
// set canvas size to match the bounding box
|
||||
canvas.width = bBoxWidth
|
||||
canvas.height = bBoxHeight
|
||||
|
||||
// translate canvas context to a central location to allow rotating and flipping around the center
|
||||
ctx.translate(bBoxWidth / 2, bBoxHeight / 2)
|
||||
ctx.rotate(rotRad)
|
||||
ctx.scale(flip.horizontal ? -1 : 1, flip.vertical ? -1 : 1)
|
||||
ctx.translate(-image.width / 2, -image.height / 2)
|
||||
|
||||
// draw rotated image
|
||||
ctx.drawImage(image, 0, 0)
|
||||
|
||||
const croppedCanvas = document.createElement('canvas')
|
||||
|
||||
const croppedCtx = croppedCanvas.getContext('2d')
|
||||
|
||||
if (!croppedCtx)
|
||||
throw new Error('Could not create a canvas context')
|
||||
|
||||
// Set the size of the cropped canvas
|
||||
croppedCanvas.width = pixelCrop.width
|
||||
croppedCanvas.height = pixelCrop.height
|
||||
|
||||
// Draw the cropped image onto the new canvas
|
||||
croppedCtx.drawImage(
|
||||
canvas,
|
||||
pixelCrop.x,
|
||||
pixelCrop.y,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
0,
|
||||
0,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
croppedCanvas.toBlob((file) => {
|
||||
if (file)
|
||||
resolve(file)
|
||||
else
|
||||
reject(new Error('Could not create a blob'))
|
||||
}, mimeType)
|
||||
})
|
||||
}
|
||||
|
||||
export function checkIsAnimatedImage(file: File): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fileReader = new FileReader()
|
||||
|
||||
fileReader.onload = function (e) {
|
||||
const arr = new Uint8Array(e.target?.result as ArrayBuffer)
|
||||
|
||||
// Check file extension
|
||||
const fileName = file.name.toLowerCase()
|
||||
if (fileName.endsWith('.gif')) {
|
||||
// If file is a GIF, assume it's animated
|
||||
resolve(true)
|
||||
}
|
||||
// Check for WebP signature (RIFF and WEBP)
|
||||
else if (isWebP(arr)) {
|
||||
resolve(checkWebPAnimation(arr)) // Check if it's animated
|
||||
}
|
||||
else {
|
||||
resolve(false) // Not a GIF or WebP
|
||||
}
|
||||
}
|
||||
|
||||
fileReader.onerror = function (err) {
|
||||
reject(err) // Reject the promise on error
|
||||
}
|
||||
|
||||
// Read the file as an array buffer
|
||||
fileReader.readAsArrayBuffer(file)
|
||||
})
|
||||
}
|
||||
|
||||
// Function to check for WebP signature
|
||||
function isWebP(arr: Uint8Array) {
|
||||
return (
|
||||
arr[0] === 0x52 && arr[1] === 0x49 && arr[2] === 0x46 && arr[3] === 0x46
|
||||
&& arr[8] === 0x57 && arr[9] === 0x45 && arr[10] === 0x42 && arr[11] === 0x50
|
||||
) // "WEBP"
|
||||
}
|
||||
|
||||
// Function to check if the WebP is animated (contains ANIM chunk)
|
||||
function checkWebPAnimation(arr: Uint8Array) {
|
||||
// Search for the ANIM chunk in WebP to determine if it's animated
|
||||
for (let i = 12; i < arr.length - 4; i++) {
|
||||
if (arr[i] === 0x41 && arr[i + 1] === 0x4E && arr[i + 2] === 0x49 && arr[i + 3] === 0x4D)
|
||||
return true // Found animation
|
||||
}
|
||||
return false // No animation chunk found
|
||||
}
|
||||
Reference in New Issue
Block a user