assets: rework mime-type detection to be consistent/centralized; add support for webp/webm, apng, avif (#3730)
As I started working on image LOD stuff and wrapping my head around the codebase, this was bothering me. - there are missing popular types, especially WebP - there are places where we're copy/pasting the same list of types but they can get out-of-date with each other (also, one place described supporting webm but we didn't actually do that) This adds animated apng/avif detection as well (alongside our animated gif detection). Furthermore, it moves the gif logic to be alongside the png logic (they were in separate packages unnecessarily) ### Change Type <!-- ❗ Please select a 'Scope' label ❗️ --> - [x] `sdk` — Changes the tldraw SDK - [ ] `dotcom` — Changes the tldraw.com web app - [ ] `docs` — Changes to the documentation, examples, or templates. - [ ] `vs code` — Changes to the vscode plugin - [ ] `internal` — Does not affect user-facing stuff <!-- ❗ Please select a 'Type' label ❗️ --> - [ ] `bugfix` — Bug fix - [ ] `feature` — New feature - [x] `improvement` — Improving existing features - [ ] `chore` — Updating dependencies, other boring stuff - [ ] `galaxy brain` — Architectural changes - [ ] `tests` — Changes to any test code - [ ] `tools` — Changes to infrastructure, CI, internal scripts, debugging tools, etc. - [ ] `dunno` — I don't know ### Release Notes - Images: unify list of acceptable types and expand to include webp, webm, apng, avif
This commit is contained in:
parent
142c27053b
commit
d2d3e582e5
21 changed files with 327 additions and 71 deletions
|
@ -1,7 +1,6 @@
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
AssetRecordType,
|
AssetRecordType,
|
||||||
DEFAULT_ACCEPTED_IMG_TYPE,
|
|
||||||
MediaHelpers,
|
MediaHelpers,
|
||||||
TLAsset,
|
TLAsset,
|
||||||
TLAssetId,
|
TLAssetId,
|
||||||
|
@ -25,7 +24,7 @@ export function useMultiplayerAssets(assetUploaderUrl: string) {
|
||||||
|
|
||||||
const assetId: TLAssetId = AssetRecordType.createId(getHashForString(url))
|
const assetId: TLAssetId = AssetRecordType.createId(getHashForString(url))
|
||||||
|
|
||||||
const isImageType = DEFAULT_ACCEPTED_IMG_TYPE.includes(file.type)
|
const isImageType = MediaHelpers.isImageType(file.type)
|
||||||
|
|
||||||
let size: {
|
let size: {
|
||||||
w: number
|
w: number
|
||||||
|
@ -35,7 +34,7 @@ export function useMultiplayerAssets(assetUploaderUrl: string) {
|
||||||
|
|
||||||
if (isImageType) {
|
if (isImageType) {
|
||||||
size = await MediaHelpers.getImageSize(file)
|
size = await MediaHelpers.getImageSize(file)
|
||||||
if (file.type === 'image/gif') {
|
if (MediaHelpers.isAnimatedImageType(file.type)) {
|
||||||
isAnimated = true // await getIsGifAnimated(file) todo export me from editor
|
isAnimated = true // await getIsGifAnimated(file) todo export me from editor
|
||||||
} else {
|
} else {
|
||||||
isAnimated = false
|
isAnimated = false
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import {
|
import {
|
||||||
AssetRecordType,
|
AssetRecordType,
|
||||||
DEFAULT_ACCEPTED_IMG_TYPE,
|
|
||||||
MediaHelpers,
|
MediaHelpers,
|
||||||
TLAsset,
|
TLAsset,
|
||||||
TLAssetId,
|
TLAssetId,
|
||||||
|
@ -23,7 +22,7 @@ export async function createAssetFromFile({ file }: { type: 'file'; file: File }
|
||||||
|
|
||||||
const assetId: TLAssetId = AssetRecordType.createId(getHashForString(url))
|
const assetId: TLAssetId = AssetRecordType.createId(getHashForString(url))
|
||||||
|
|
||||||
const isImageType = DEFAULT_ACCEPTED_IMG_TYPE.includes(file.type)
|
const isImageType = MediaHelpers.isImageType(file.type)
|
||||||
|
|
||||||
let size: {
|
let size: {
|
||||||
w: number
|
w: number
|
||||||
|
@ -33,7 +32,7 @@ export async function createAssetFromFile({ file }: { type: 'file'; file: File }
|
||||||
|
|
||||||
if (isImageType) {
|
if (isImageType) {
|
||||||
size = await MediaHelpers.getImageSize(file)
|
size = await MediaHelpers.getImageSize(file)
|
||||||
if (file.type === 'image/gif') {
|
if (MediaHelpers.isAnimatedImageType(file.type)) {
|
||||||
isAnimated = true // await getIsGifAnimated(file) todo export me from editor
|
isAnimated = true // await getIsGifAnimated(file) todo export me from editor
|
||||||
} else {
|
} else {
|
||||||
isAnimated = false
|
isAnimated = false
|
||||||
|
|
|
@ -7,7 +7,6 @@ import {
|
||||||
TLAssetId,
|
TLAssetId,
|
||||||
Tldraw,
|
Tldraw,
|
||||||
getHashForString,
|
getHashForString,
|
||||||
isGifAnimated,
|
|
||||||
uniqueId,
|
uniqueId,
|
||||||
} from 'tldraw'
|
} from 'tldraw'
|
||||||
import 'tldraw/tldraw.css'
|
import 'tldraw/tldraw.css'
|
||||||
|
@ -40,10 +39,10 @@ export default function HostedImagesExample() {
|
||||||
let shapeType: 'image' | 'video'
|
let shapeType: 'image' | 'video'
|
||||||
|
|
||||||
//[c]
|
//[c]
|
||||||
if (['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'].includes(file.type)) {
|
if (MediaHelpers.isImageType(file.type)) {
|
||||||
shapeType = 'image'
|
shapeType = 'image'
|
||||||
size = await MediaHelpers.getImageSize(file)
|
size = await MediaHelpers.getImageSize(file)
|
||||||
isAnimated = file.type === 'image/gif' && (await isGifAnimated(file))
|
isAnimated = await MediaHelpers.isAnimated(file)
|
||||||
} else {
|
} else {
|
||||||
shapeType = 'video'
|
shapeType = 'video'
|
||||||
isAnimated = true
|
isAnimated = true
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { FileHelpers, MediaHelpers } from 'tldraw'
|
import { DEFAULT_SUPPORTED_MEDIA_TYPE_LIST, FileHelpers, MediaHelpers } from 'tldraw'
|
||||||
import anakin from './assets/anakin.jpeg'
|
import anakin from './assets/anakin.jpeg'
|
||||||
import distractedBf from './assets/distracted-bf.jpeg'
|
import distractedBf from './assets/distracted-bf.jpeg'
|
||||||
import expandingBrain from './assets/expanding-brain.png'
|
import expandingBrain from './assets/expanding-brain.png'
|
||||||
|
@ -13,7 +13,7 @@ export function ImagePicker({
|
||||||
function onClickChooseImage() {
|
function onClickChooseImage() {
|
||||||
const input = window.document.createElement('input')
|
const input = window.document.createElement('input')
|
||||||
input.type = 'file'
|
input.type = 'file'
|
||||||
input.accept = 'image/jpeg,image/png,image/gif,image/svg+xml,video/mp4,video/quicktime'
|
input.accept = DEFAULT_SUPPORTED_MEDIA_TYPE_LIST
|
||||||
input.addEventListener('change', async (e) => {
|
input.addEventListener('change', async (e) => {
|
||||||
const fileList = (e.target as HTMLInputElement).files
|
const fileList = (e.target as HTMLInputElement).files
|
||||||
if (!fileList || fileList.length === 0) return
|
if (!fileList || fileList.length === 0) return
|
||||||
|
|
|
@ -328,12 +328,6 @@ export function CutMenuItem(): JSX_2.Element;
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
export function DebugFlags(): JSX_2.Element | null;
|
export function DebugFlags(): JSX_2.Element | null;
|
||||||
|
|
||||||
// @public (undocumented)
|
|
||||||
export const DEFAULT_ACCEPTED_IMG_TYPE: string[];
|
|
||||||
|
|
||||||
// @public (undocumented)
|
|
||||||
export const DEFAULT_ACCEPTED_VID_TYPE: string[];
|
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
export const DefaultActionsMenu: NamedExoticComponent<TLUiActionsMenuProps>;
|
export const DefaultActionsMenu: NamedExoticComponent<TLUiActionsMenuProps>;
|
||||||
|
|
||||||
|
@ -586,7 +580,7 @@ export function ExportFileContentSubMenu(): JSX_2.Element;
|
||||||
// @public
|
// @public
|
||||||
export function exportToBlob({ editor, ids, format, opts, }: {
|
export function exportToBlob({ editor, ids, format, opts, }: {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
format: 'jpeg' | 'json' | 'png' | 'svg' | 'webp';
|
format: TLExportType;
|
||||||
ids: TLShapeId[];
|
ids: TLShapeId[];
|
||||||
opts?: Partial<TLSvgOptions>;
|
opts?: Partial<TLSvgOptions>;
|
||||||
}): Promise<Blob>;
|
}): Promise<Blob>;
|
||||||
|
@ -951,6 +945,8 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
indicator(shape: TLImageShape): JSX_2.Element | null;
|
indicator(shape: TLImageShape): JSX_2.Element | null;
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
|
isAnimated(shape: TLImageShape): boolean;
|
||||||
|
// (undocumented)
|
||||||
isAspectRatioLocked: () => boolean;
|
isAspectRatioLocked: () => boolean;
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
static migrations: TLPropsMigrations;
|
static migrations: TLPropsMigrations;
|
||||||
|
@ -976,9 +972,6 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
|
||||||
static type: "image";
|
static type: "image";
|
||||||
}
|
}
|
||||||
|
|
||||||
// @public (undocumented)
|
|
||||||
export function isGifAnimated(file: Blob): Promise<boolean>;
|
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
export function KeyboardShortcutsMenuItem(): JSX_2.Element | null;
|
export function KeyboardShortcutsMenuItem(): JSX_2.Element | null;
|
||||||
|
|
||||||
|
|
|
@ -115,13 +115,7 @@ export {
|
||||||
} from './lib/ui/hooks/useTranslation/useTranslation'
|
} from './lib/ui/hooks/useTranslation/useTranslation'
|
||||||
export { type TLUiIconType } from './lib/ui/icon-types'
|
export { type TLUiIconType } from './lib/ui/icon-types'
|
||||||
export { useDefaultHelpers, type TLUiOverrides } from './lib/ui/overrides'
|
export { useDefaultHelpers, type TLUiOverrides } from './lib/ui/overrides'
|
||||||
export {
|
export { containBoxSize, downsizeImage } from './lib/utils/assets/assets'
|
||||||
DEFAULT_ACCEPTED_IMG_TYPE,
|
|
||||||
DEFAULT_ACCEPTED_VID_TYPE,
|
|
||||||
containBoxSize,
|
|
||||||
downsizeImage,
|
|
||||||
isGifAnimated,
|
|
||||||
} from './lib/utils/assets/assets'
|
|
||||||
export { getEmbedInfo } from './lib/utils/embeds/embeds'
|
export { getEmbedInfo } from './lib/utils/embeds/embeds'
|
||||||
export { copyAs } from './lib/utils/export/copyAs'
|
export { copyAs } from './lib/utils/export/copyAs'
|
||||||
export { exportToBlob, getSvgAsImage } from './lib/utils/export/export'
|
export { exportToBlob, getSvgAsImage } from './lib/utils/export/export'
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_SUPPORTED_IMAGE_TYPES,
|
||||||
|
DEFAULT_SUPPORT_VIDEO_TYPES,
|
||||||
Editor,
|
Editor,
|
||||||
ErrorScreen,
|
ErrorScreen,
|
||||||
Expand,
|
Expand,
|
||||||
|
@ -148,21 +150,12 @@ export function Tldraw(props: TldrawProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultAcceptedImageMimeTypes = Object.freeze([
|
|
||||||
'image/jpeg',
|
|
||||||
'image/png',
|
|
||||||
'image/gif',
|
|
||||||
'image/svg+xml',
|
|
||||||
])
|
|
||||||
|
|
||||||
const defaultAcceptedVideoMimeTypes = Object.freeze(['video/mp4', 'video/quicktime'])
|
|
||||||
|
|
||||||
// We put these hooks into a component here so that they can run inside of the context provided by TldrawEditor and TldrawUi.
|
// We put these hooks into a component here so that they can run inside of the context provided by TldrawEditor and TldrawUi.
|
||||||
function InsideOfEditorAndUiContext({
|
function InsideOfEditorAndUiContext({
|
||||||
maxImageDimension = 1000,
|
maxImageDimension = 1000,
|
||||||
maxAssetSize = 10 * 1024 * 1024, // 10mb
|
maxAssetSize = 10 * 1024 * 1024, // 10mb
|
||||||
acceptedImageMimeTypes = defaultAcceptedImageMimeTypes,
|
acceptedImageMimeTypes = DEFAULT_SUPPORTED_IMAGE_TYPES,
|
||||||
acceptedVideoMimeTypes = defaultAcceptedVideoMimeTypes,
|
acceptedVideoMimeTypes = DEFAULT_SUPPORT_VIDEO_TYPES,
|
||||||
onMount,
|
onMount,
|
||||||
}: Partial<TLExternalContentProps & { onMount: TLOnMountHandler }>) {
|
}: Partial<TLExternalContentProps & { onMount: TLOnMountHandler }>) {
|
||||||
const editor = useEditor()
|
const editor = useEditor()
|
||||||
|
|
|
@ -22,7 +22,7 @@ import {
|
||||||
import { FONT_FAMILIES, FONT_SIZES, TEXT_PROPS } from './shapes/shared/default-shape-constants'
|
import { FONT_FAMILIES, FONT_SIZES, TEXT_PROPS } from './shapes/shared/default-shape-constants'
|
||||||
import { TLUiToastsContextType } from './ui/context/toasts'
|
import { TLUiToastsContextType } from './ui/context/toasts'
|
||||||
import { useTranslation } from './ui/hooks/useTranslation/useTranslation'
|
import { useTranslation } from './ui/hooks/useTranslation/useTranslation'
|
||||||
import { containBoxSize, downsizeImage, isGifAnimated } from './utils/assets/assets'
|
import { containBoxSize, downsizeImage } from './utils/assets/assets'
|
||||||
import { getEmbedInfo } from './utils/embeds/embeds'
|
import { getEmbedInfo } from './utils/embeds/embeds'
|
||||||
import { cleanupText, isRightToLeftLanguage, truncateStringWithEllipsis } from './utils/text/text'
|
import { cleanupText, isRightToLeftLanguage, truncateStringWithEllipsis } from './utils/text/text'
|
||||||
|
|
||||||
|
@ -32,9 +32,9 @@ export type TLExternalContentProps = {
|
||||||
maxImageDimension: number
|
maxImageDimension: number
|
||||||
// The maximum size (in bytes) of an asset. Assets larger than this will be rejected. Defaults to 10mb (10 * 1024 * 1024).
|
// The maximum size (in bytes) of an asset. Assets larger than this will be rejected. Defaults to 10mb (10 * 1024 * 1024).
|
||||||
maxAssetSize: number
|
maxAssetSize: number
|
||||||
// The mime types of images that are allowed to be handled. Defaults to ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'].
|
// The mime types of images that are allowed to be handled. Defaults to DEFAULT_SUPPORTED_IMAGE_TYPES.
|
||||||
acceptedImageMimeTypes: readonly string[]
|
acceptedImageMimeTypes: readonly string[]
|
||||||
// The mime types of videos that are allowed to be handled. Defaults to ['video/mp4', 'video/webm', 'video/quicktime'].
|
// The mime types of videos that are allowed to be handled. Defaults to DEFAULT_SUPPORT_VIDEO_TYPES.
|
||||||
acceptedVideoMimeTypes: readonly string[]
|
acceptedVideoMimeTypes: readonly string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -70,19 +70,19 @@ export function registerDefaultExternalContentHandlers(
|
||||||
? await MediaHelpers.getImageSize(file)
|
? await MediaHelpers.getImageSize(file)
|
||||||
: await MediaHelpers.getVideoSize(file)
|
: await MediaHelpers.getVideoSize(file)
|
||||||
|
|
||||||
const isAnimated = file.type === 'image/gif' ? await isGifAnimated(file) : isVideoType
|
const isAnimated = (await MediaHelpers.isAnimated(file)) || isVideoType
|
||||||
|
|
||||||
const hash = await getHashForBuffer(await file.arrayBuffer())
|
const hash = await getHashForBuffer(await file.arrayBuffer())
|
||||||
|
|
||||||
if (isFinite(maxImageDimension)) {
|
if (isFinite(maxImageDimension)) {
|
||||||
const resizedSize = containBoxSize(size, { w: maxImageDimension, h: maxImageDimension })
|
const resizedSize = containBoxSize(size, { w: maxImageDimension, h: maxImageDimension })
|
||||||
if (size !== resizedSize && (file.type === 'image/jpeg' || file.type === 'image/png')) {
|
if (size !== resizedSize && MediaHelpers.isStaticImageType(file.type)) {
|
||||||
size = resizedSize
|
size = resizedSize
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always rescale the image
|
// Always rescale the image
|
||||||
if (file.type === 'image/jpeg' || file.type === 'image/png') {
|
if (!isAnimated && MediaHelpers.isStaticImageType(file.type)) {
|
||||||
file = await downsizeImage(file, size.w, size.h, {
|
file = await downsizeImage(file, size.w, size.h, {
|
||||||
type: file.type,
|
type: file.type,
|
||||||
quality: 0.92,
|
quality: 0.92,
|
||||||
|
|
|
@ -3,6 +3,7 @@ import {
|
||||||
BaseBoxShapeUtil,
|
BaseBoxShapeUtil,
|
||||||
FileHelpers,
|
FileHelpers,
|
||||||
HTMLContainer,
|
HTMLContainer,
|
||||||
|
MediaHelpers,
|
||||||
TLImageShape,
|
TLImageShape,
|
||||||
TLOnDoubleClickHandler,
|
TLOnDoubleClickHandler,
|
||||||
TLShapePartial,
|
TLShapePartial,
|
||||||
|
@ -43,6 +44,17 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isAnimated(shape: TLImageShape) {
|
||||||
|
const asset = shape.props.assetId ? this.editor.getAsset(shape.props.assetId) : undefined
|
||||||
|
|
||||||
|
if (!asset) return false
|
||||||
|
|
||||||
|
return (
|
||||||
|
('mimeType' in asset.props && MediaHelpers.isAnimatedImageType(asset?.props.mimeType)) ||
|
||||||
|
('isAnimated' in asset.props && asset.props.isAnimated)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
component(shape: TLImageShape) {
|
component(shape: TLImageShape) {
|
||||||
const isCropping = this.editor.getCroppingShapeId() === shape.id
|
const isCropping = this.editor.getCroppingShapeId() === shape.id
|
||||||
const prefersReducedMotion = usePrefersReducedMotion()
|
const prefersReducedMotion = usePrefersReducedMotion()
|
||||||
|
@ -53,7 +65,7 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
|
||||||
const isSelected = shape.id === this.editor.getOnlySelectedShapeId()
|
const isSelected = shape.id === this.editor.getOnlySelectedShapeId()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (asset?.props.src && 'mimeType' in asset.props && asset?.props.mimeType === 'image/gif') {
|
if (asset?.props.src && this.isAnimated(shape)) {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
const url = asset.props.src
|
const url = asset.props.src
|
||||||
if (!url) return
|
if (!url) return
|
||||||
|
@ -79,7 +91,7 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [prefersReducedMotion, asset?.props])
|
}, [prefersReducedMotion, asset?.props, shape])
|
||||||
|
|
||||||
if (asset?.type === 'bookmark') {
|
if (asset?.type === 'bookmark') {
|
||||||
throw Error("Bookmark assets can't be rendered as images")
|
throw Error("Bookmark assets can't be rendered as images")
|
||||||
|
@ -92,8 +104,7 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
|
||||||
|
|
||||||
// We only want to reduce motion for mimeTypes that have motion
|
// We only want to reduce motion for mimeTypes that have motion
|
||||||
const reduceMotion =
|
const reduceMotion =
|
||||||
prefersReducedMotion &&
|
prefersReducedMotion && (asset?.props.mimeType?.includes('video') || this.isAnimated(shape))
|
||||||
(asset?.props.mimeType?.includes('video') || asset?.props.mimeType?.includes('gif'))
|
|
||||||
|
|
||||||
const containerStyle = getCroppedContainerStyle(shape)
|
const containerStyle = getCroppedContainerStyle(shape)
|
||||||
|
|
||||||
|
@ -151,7 +162,7 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
|
||||||
}}
|
}}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
{asset.props.isAnimated && !shape.props.playing && (
|
{this.isAnimated(shape) && !shape.props.playing && (
|
||||||
<div className="tl-image__tg">GIF</div>
|
<div className="tl-image__tg">GIF</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -218,8 +229,7 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
|
||||||
|
|
||||||
if (!asset) return
|
if (!asset) return
|
||||||
|
|
||||||
const canPlay =
|
const canPlay = asset.props.src && this.isAnimated(shape)
|
||||||
asset.props.src && 'mimeType' in asset.props && asset.props.mimeType === 'image/gif'
|
|
||||||
|
|
||||||
if (!canPlay) return
|
if (!canPlay) return
|
||||||
|
|
||||||
|
|
|
@ -133,7 +133,13 @@ export class Idle extends StateNode {
|
||||||
|
|
||||||
if (info.target === 'selection') {
|
if (info.target === 'selection') {
|
||||||
util.onDoubleClickEdge?.(shape)
|
util.onDoubleClickEdge?.(shape)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the user double clicks the canvas, we want to cancel cropping,
|
||||||
|
// especially if it's an animated image, we want the image to continue playing.
|
||||||
|
this.cancel()
|
||||||
|
this.editor.root.handleEvent(info)
|
||||||
}
|
}
|
||||||
|
|
||||||
override onKeyDown: TLEventHandlers['onKeyDown'] = () => {
|
override onKeyDown: TLEventHandlers['onKeyDown'] = () => {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEditor } from '@tldraw/editor'
|
import { DEFAULT_SUPPORTED_MEDIA_TYPE_LIST, useEditor } from '@tldraw/editor'
|
||||||
import { useCallback, useEffect, useRef } from 'react'
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
export function useInsertMedia() {
|
export function useInsertMedia() {
|
||||||
|
@ -8,7 +8,7 @@ export function useInsertMedia() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const input = window.document.createElement('input')
|
const input = window.document.createElement('input')
|
||||||
input.type = 'file'
|
input.type = 'file'
|
||||||
input.accept = 'image/jpeg,image/png,image/gif,image/svg+xml,video/mp4,video/quicktime'
|
input.accept = DEFAULT_SUPPORTED_MEDIA_TYPE_LIST
|
||||||
input.multiple = true
|
input.multiple = true
|
||||||
inputRef.current = input
|
inputRef.current = input
|
||||||
async function onchange(e: Event) {
|
async function onchange(e: Event) {
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { MediaHelpers, assertExists } from '@tldraw/editor'
|
import { MediaHelpers, assertExists } from '@tldraw/editor'
|
||||||
import { clampToBrowserMaxCanvasSize } from '../../shapes/shared/getBrowserCanvasMaxSize'
|
import { clampToBrowserMaxCanvasSize } from '../../shapes/shared/getBrowserCanvasMaxSize'
|
||||||
import { isAnimated } from './is-gif-animated'
|
|
||||||
|
|
||||||
type BoxWidthHeight = {
|
type BoxWidthHeight = {
|
||||||
w: number
|
w: number
|
||||||
|
@ -91,13 +90,3 @@ export async function downsizeImage(
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @public */
|
|
||||||
export const DEFAULT_ACCEPTED_IMG_TYPE = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml']
|
|
||||||
/** @public */
|
|
||||||
export const DEFAULT_ACCEPTED_VID_TYPE = ['video/mp4', 'video/quicktime']
|
|
||||||
|
|
||||||
/** @public */
|
|
||||||
export async function isGifAnimated(file: Blob): Promise<boolean> {
|
|
||||||
return isAnimated(await file.arrayBuffer())
|
|
||||||
}
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ import {
|
||||||
exhaustiveSwitchError,
|
exhaustiveSwitchError,
|
||||||
} from '@tldraw/editor'
|
} from '@tldraw/editor'
|
||||||
import { clampToBrowserMaxCanvasSize } from '../../shapes/shared/getBrowserCanvasMaxSize'
|
import { clampToBrowserMaxCanvasSize } from '../../shapes/shared/getBrowserCanvasMaxSize'
|
||||||
|
import { TLExportType } from './exportAs'
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export async function getSvgAsImage(
|
export async function getSvgAsImage(
|
||||||
|
@ -143,7 +144,7 @@ export async function exportToBlob({
|
||||||
}: {
|
}: {
|
||||||
editor: Editor
|
editor: Editor
|
||||||
ids: TLShapeId[]
|
ids: TLShapeId[]
|
||||||
format: 'svg' | 'png' | 'jpeg' | 'webp' | 'json'
|
format: TLExportType
|
||||||
opts?: Partial<TLSvgOptions>
|
opts?: Partial<TLSvgOptions>
|
||||||
}): Promise<Blob> {
|
}): Promise<Blob> {
|
||||||
switch (format) {
|
switch (format) {
|
||||||
|
@ -185,7 +186,7 @@ const mimeTypeByFormat = {
|
||||||
export function exportToBlobPromise(
|
export function exportToBlobPromise(
|
||||||
editor: Editor,
|
editor: Editor,
|
||||||
ids: TLShapeId[],
|
ids: TLShapeId[],
|
||||||
format: 'svg' | 'png' | 'jpeg' | 'webp' | 'json',
|
format: TLExportType,
|
||||||
opts = {} as Partial<TLSvgOptions>
|
opts = {} as Partial<TLSvgOptions>
|
||||||
): { blobPromise: Promise<Blob>; mimeType: string } {
|
): { blobPromise: Promise<Blob>; mimeType: string } {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -37,6 +37,15 @@ export function debounce<T extends unknown[], U>(callback: (...args: T) => Promi
|
||||||
// @public
|
// @public
|
||||||
export function dedupe<T>(input: T[], equals?: (a: any, b: any) => boolean): T[];
|
export function dedupe<T>(input: T[], equals?: (a: any, b: any) => boolean): T[];
|
||||||
|
|
||||||
|
// @public (undocumented)
|
||||||
|
export const DEFAULT_SUPPORT_VIDEO_TYPES: readonly string[];
|
||||||
|
|
||||||
|
// @public (undocumented)
|
||||||
|
export const DEFAULT_SUPPORTED_IMAGE_TYPES: readonly string[];
|
||||||
|
|
||||||
|
// @public (undocumented)
|
||||||
|
export const DEFAULT_SUPPORTED_MEDIA_TYPE_LIST: string;
|
||||||
|
|
||||||
// @internal
|
// @internal
|
||||||
export function deleteFromLocalStorage(key: string): void;
|
export function deleteFromLocalStorage(key: string): void;
|
||||||
|
|
||||||
|
@ -195,6 +204,14 @@ export class MediaHelpers {
|
||||||
h: number;
|
h: number;
|
||||||
w: number;
|
w: number;
|
||||||
}>;
|
}>;
|
||||||
|
// (undocumented)
|
||||||
|
static isAnimated(file: Blob): Promise<boolean>;
|
||||||
|
// (undocumented)
|
||||||
|
static isAnimatedImageType(mimeType: null | string): boolean;
|
||||||
|
// (undocumented)
|
||||||
|
static isImageType(mimeType: string): boolean;
|
||||||
|
// (undocumented)
|
||||||
|
static isStaticImageType(mimeType: null | string): boolean;
|
||||||
static loadImage(src: string): Promise<HTMLImageElement>;
|
static loadImage(src: string): Promise<HTMLImageElement>;
|
||||||
static loadVideo(src: string): Promise<HTMLVideoElement>;
|
static loadVideo(src: string): Promise<HTMLVideoElement>;
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
|
|
|
@ -25,7 +25,13 @@ export { noop, omitFromStackTrace, throttle } from './lib/function'
|
||||||
export { getHashForBuffer, getHashForObject, getHashForString, lns } from './lib/hash'
|
export { getHashForBuffer, getHashForObject, getHashForString, lns } from './lib/hash'
|
||||||
export { getFirstFromIterable } from './lib/iterable'
|
export { getFirstFromIterable } from './lib/iterable'
|
||||||
export type { JsonArray, JsonObject, JsonPrimitive, JsonValue } from './lib/json-value'
|
export type { JsonArray, JsonObject, JsonPrimitive, JsonValue } from './lib/json-value'
|
||||||
export { MediaHelpers } from './lib/media'
|
export {
|
||||||
|
DEFAULT_SUPPORTED_IMAGE_TYPES,
|
||||||
|
DEFAULT_SUPPORTED_MEDIA_TYPE_LIST,
|
||||||
|
DEFAULT_SUPPORT_VIDEO_TYPES,
|
||||||
|
MediaHelpers,
|
||||||
|
} from './lib/media/media'
|
||||||
|
export { PngHelpers } from './lib/media/png'
|
||||||
export { invLerp, lerp, modulate, rng } from './lib/number'
|
export { invLerp, lerp, modulate, rng } from './lib/number'
|
||||||
export {
|
export {
|
||||||
areObjectsShallowEqual,
|
areObjectsShallowEqual,
|
||||||
|
@ -39,7 +45,6 @@ export {
|
||||||
objectMapValues,
|
objectMapValues,
|
||||||
} from './lib/object'
|
} from './lib/object'
|
||||||
export { measureAverageDuration, measureCbDuration, measureDuration } from './lib/perf'
|
export { measureAverageDuration, measureCbDuration, measureDuration } from './lib/perf'
|
||||||
export { PngHelpers } from './lib/png'
|
|
||||||
export { type IndexKey } from './lib/reordering/IndexKey'
|
export { type IndexKey } from './lib/reordering/IndexKey'
|
||||||
export {
|
export {
|
||||||
ZERO_INDEX_KEY,
|
ZERO_INDEX_KEY,
|
||||||
|
|
150
packages/utils/src/lib/media/apng.ts
Normal file
150
packages/utils/src/lib/media/apng.ts
Normal file
|
@ -0,0 +1,150 @@
|
||||||
|
/*!
|
||||||
|
* MIT License: https://github.com/vHeemstra/is-apng/blob/main/license
|
||||||
|
* Copyright (c) Philip van Heemstra
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function isApngAnimated(buffer: ArrayBuffer): boolean {
|
||||||
|
const view = new Uint8Array(buffer)
|
||||||
|
|
||||||
|
if (
|
||||||
|
!view ||
|
||||||
|
!((typeof Buffer !== 'undefined' && Buffer.isBuffer(view)) || view instanceof Uint8Array) ||
|
||||||
|
view.length < 16
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPNG =
|
||||||
|
view[0] === 0x89 &&
|
||||||
|
view[1] === 0x50 &&
|
||||||
|
view[2] === 0x4e &&
|
||||||
|
view[3] === 0x47 &&
|
||||||
|
view[4] === 0x0d &&
|
||||||
|
view[5] === 0x0a &&
|
||||||
|
view[6] === 0x1a &&
|
||||||
|
view[7] === 0x0a
|
||||||
|
|
||||||
|
if (!isPNG) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the index of the first occurrence of a sequence in an typed array, or -1 if it is not present.
|
||||||
|
*
|
||||||
|
* Works similar to `Array.prototype.indexOf()`, but it searches for a sequence of array values (bytes).
|
||||||
|
* The bytes in the `haystack` array are decoded (UTF-8) and then used to search for `needle`.
|
||||||
|
*
|
||||||
|
* @param haystack `Uint8Array`
|
||||||
|
* Array to search in.
|
||||||
|
*
|
||||||
|
* @param needle `string | RegExp`
|
||||||
|
* The value to locate in the array.
|
||||||
|
*
|
||||||
|
* @param fromIndex `number`
|
||||||
|
* The array index at which to begin the search.
|
||||||
|
*
|
||||||
|
* @param upToIndex `number`
|
||||||
|
* The array index up to which to search.
|
||||||
|
* If omitted, search until the end.
|
||||||
|
*
|
||||||
|
* @param chunksize `number`
|
||||||
|
* Size of the chunks used when searching (default 1024).
|
||||||
|
*
|
||||||
|
* @returns boolean
|
||||||
|
* Whether the array holds Animated PNG data.
|
||||||
|
*/
|
||||||
|
function indexOfSubstring(
|
||||||
|
haystack: Uint8Array,
|
||||||
|
needle: string | RegExp,
|
||||||
|
fromIndex: number,
|
||||||
|
upToIndex?: number,
|
||||||
|
chunksize = 1024 /* Bytes */
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Adopted from: https://stackoverflow.com/a/67771214/2142071
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!needle) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
needle = new RegExp(needle, 'g')
|
||||||
|
|
||||||
|
// The needle could get split over two chunks.
|
||||||
|
// So, at every chunk we prepend the last few characters
|
||||||
|
// of the last chunk.
|
||||||
|
const needle_length = needle.source.length
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
|
||||||
|
// Handle search offset in line with
|
||||||
|
// `Array.prototype.indexOf()` and `TypedArray.prototype.subarray()`.
|
||||||
|
const full_haystack_length = haystack.length
|
||||||
|
if (typeof upToIndex === 'undefined') {
|
||||||
|
upToIndex = full_haystack_length
|
||||||
|
}
|
||||||
|
if (fromIndex >= full_haystack_length || upToIndex <= 0 || fromIndex >= upToIndex) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
haystack = haystack.subarray(fromIndex, upToIndex)
|
||||||
|
|
||||||
|
let position = -1
|
||||||
|
let current_index = 0
|
||||||
|
let full_length = 0
|
||||||
|
let needle_buffer = ''
|
||||||
|
|
||||||
|
outer: while (current_index < haystack.length) {
|
||||||
|
const next_index = current_index + chunksize
|
||||||
|
// subarray doesn't copy
|
||||||
|
const chunk = haystack.subarray(current_index, next_index)
|
||||||
|
const decoded = decoder.decode(chunk, { stream: true })
|
||||||
|
|
||||||
|
const text = needle_buffer + decoded
|
||||||
|
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
let last_index = -1
|
||||||
|
while ((match = needle.exec(text)) !== null) {
|
||||||
|
last_index = match.index - needle_buffer.length
|
||||||
|
position = full_length + last_index
|
||||||
|
break outer
|
||||||
|
}
|
||||||
|
|
||||||
|
current_index = next_index
|
||||||
|
full_length += decoded.length
|
||||||
|
|
||||||
|
// Check that the buffer doesn't itself include the needle
|
||||||
|
// this would cause duplicate finds (we could also use a Set to avoid that).
|
||||||
|
const needle_index =
|
||||||
|
last_index > -1 ? last_index + needle_length : decoded.length - needle_length
|
||||||
|
needle_buffer = decoded.slice(needle_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correct for search offset.
|
||||||
|
if (position >= 0) {
|
||||||
|
position += fromIndex >= 0 ? fromIndex : full_haystack_length + fromIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
return position
|
||||||
|
}
|
||||||
|
|
||||||
|
// APNGs have an animation control chunk ('acTL') preceding the IDATs.
|
||||||
|
// See: https://en.wikipedia.org/wiki/APNG#File_format
|
||||||
|
const idatIdx = indexOfSubstring(view, 'IDAT', 12)
|
||||||
|
if (idatIdx >= 12) {
|
||||||
|
const actlIdx = indexOfSubstring(view, 'acTL', 8, idatIdx)
|
||||||
|
return actlIdx >= 8
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// globalThis.isApng = isApng
|
||||||
|
|
||||||
|
// (new TextEncoder()).encode('IDAT')
|
||||||
|
// Decimal: [73, 68, 65, 84]
|
||||||
|
// Hex: [0x49, 0x44, 0x41, 0x54]
|
||||||
|
|
||||||
|
// (new TextEncoder()).encode('acTL')
|
||||||
|
// Decimal: [97, 99, 84, 76]
|
||||||
|
// Hex: [0x61, 0x63, 0x54, 0x4C]
|
||||||
|
|
||||||
|
// const idatIdx = buffer.indexOf('IDAT')
|
||||||
|
// const actlIdx = buffer.indexOf('acTL')
|
4
packages/utils/src/lib/media/avif.ts
Normal file
4
packages/utils/src/lib/media/avif.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export const isAvifAnimated = (buffer: ArrayBuffer) => {
|
||||||
|
const view = new Uint8Array(buffer)
|
||||||
|
return view[3] === 44
|
||||||
|
}
|
|
@ -31,7 +31,7 @@ export function isGIF(buffer: ArrayBuffer): boolean {
|
||||||
*
|
*
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
export function isAnimated(buffer: ArrayBuffer): boolean {
|
export function isGifAnimated(buffer: ArrayBuffer): boolean {
|
||||||
const view = new Uint8Array(buffer)
|
const view = new Uint8Array(buffer)
|
||||||
let hasColorTable, colorTableSize
|
let hasColorTable, colorTableSize
|
||||||
let offset = 0
|
let offset = 0
|
|
@ -1,4 +1,40 @@
|
||||||
|
import { isApngAnimated } from './apng'
|
||||||
|
import { isAvifAnimated } from './avif'
|
||||||
|
import { isGifAnimated } from './gif'
|
||||||
import { PngHelpers } from './png'
|
import { PngHelpers } from './png'
|
||||||
|
import { isWebpAnimated } from './webp'
|
||||||
|
|
||||||
|
/** @public */
|
||||||
|
export const DEFAULT_SUPPORTED_VECTOR_IMAGE_TYPES = Object.freeze(['image/svg+xml'])
|
||||||
|
/** @public */
|
||||||
|
export const DEFAULT_SUPPORTED_STATIC_IMAGE_TYPES = Object.freeze([
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp',
|
||||||
|
])
|
||||||
|
/** @public */
|
||||||
|
export const DEFAULT_SUPPORTED_ANIMATED_IMAGE_TYPES = Object.freeze([
|
||||||
|
'image/gif',
|
||||||
|
'image/apng',
|
||||||
|
'image/avif',
|
||||||
|
])
|
||||||
|
/** @public */
|
||||||
|
export const DEFAULT_SUPPORTED_IMAGE_TYPES = Object.freeze([
|
||||||
|
...DEFAULT_SUPPORTED_STATIC_IMAGE_TYPES,
|
||||||
|
...DEFAULT_SUPPORTED_VECTOR_IMAGE_TYPES,
|
||||||
|
...DEFAULT_SUPPORTED_ANIMATED_IMAGE_TYPES,
|
||||||
|
])
|
||||||
|
/** @public */
|
||||||
|
export const DEFAULT_SUPPORT_VIDEO_TYPES = Object.freeze([
|
||||||
|
'video/mp4',
|
||||||
|
'video/webm',
|
||||||
|
'video/quicktime',
|
||||||
|
])
|
||||||
|
/** @public */
|
||||||
|
export const DEFAULT_SUPPORTED_MEDIA_TYPE_LIST = [
|
||||||
|
...DEFAULT_SUPPORTED_IMAGE_TYPES,
|
||||||
|
...DEFAULT_SUPPORT_VIDEO_TYPES,
|
||||||
|
].join(',')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helpers for media
|
* Helpers for media
|
||||||
|
@ -86,6 +122,38 @@ export class MediaHelpers {
|
||||||
return { w: image.naturalWidth, h: image.naturalHeight }
|
return { w: image.naturalWidth, h: image.naturalHeight }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async isAnimated(file: Blob): Promise<boolean> {
|
||||||
|
if (file.type === 'image/gif') {
|
||||||
|
return isGifAnimated(await file.arrayBuffer())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.type === 'image/avif') {
|
||||||
|
return isAvifAnimated(await file.arrayBuffer())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.type === 'image/webp') {
|
||||||
|
return isWebpAnimated(await file.arrayBuffer())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.type === 'image/apng') {
|
||||||
|
return isApngAnimated(await file.arrayBuffer())
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
static isAnimatedImageType(mimeType: string | null): boolean {
|
||||||
|
return DEFAULT_SUPPORTED_ANIMATED_IMAGE_TYPES.includes(mimeType || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
static isStaticImageType(mimeType: string | null): boolean {
|
||||||
|
return DEFAULT_SUPPORTED_STATIC_IMAGE_TYPES.includes(mimeType || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
static isImageType(mimeType: string): boolean {
|
||||||
|
return DEFAULT_SUPPORTED_IMAGE_TYPES.includes(mimeType)
|
||||||
|
}
|
||||||
|
|
||||||
static async usingObjectURL<T>(blob: Blob, fn: (url: string) => Promise<T>): Promise<T> {
|
static async usingObjectURL<T>(blob: Blob, fn: (url: string) => Promise<T>): Promise<T> {
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
try {
|
try {
|
|
@ -43,7 +43,11 @@ if (typeof Int32Array !== 'undefined') {
|
||||||
TABLE = new Int32Array(TABLE)
|
TABLE = new Int32Array(TABLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
// crc32, https://github.com/alexgorbatchev/crc/blob/master/src/calculators/crc32.ts
|
/*!
|
||||||
|
* MIT License: https://github.com/alexgorbatchev/crc/blob/master/LICENSE
|
||||||
|
* Copyright: 2014 Alex Gorbatchev
|
||||||
|
* Code: crc32, https://github.com/alexgorbatchev/crc/blob/master/src/calculators/crc32.ts
|
||||||
|
*/
|
||||||
const crc: CRCCalculator<Uint8Array> = (current, previous) => {
|
const crc: CRCCalculator<Uint8Array> = (current, previous) => {
|
||||||
let crc = previous === 0 ? 0 : ~~previous! ^ -1
|
let crc = previous === 0 ? 0 : ~~previous! ^ -1
|
||||||
|
|
25
packages/utils/src/lib/media/webp.ts
Normal file
25
packages/utils/src/lib/media/webp.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
/*!
|
||||||
|
* MIT License: https://github.com/sindresorhus/is-webp/blob/main/license
|
||||||
|
* Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
|
||||||
|
*/
|
||||||
|
function isWebp(view: Uint8Array) {
|
||||||
|
if (!view || view.length < 12) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return view[8] === 87 && view[9] === 69 && view[10] === 66 && view[11] === 80
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWebpAnimated(buffer: ArrayBuffer) {
|
||||||
|
const view = new Uint8Array(buffer)
|
||||||
|
|
||||||
|
if (!isWebp(view)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!view || view.length < 21) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return ((view[20] >> 1) & 1) === 1
|
||||||
|
}
|
Loading…
Reference in a new issue