Custom asset type

Accept non-media files like PDFs and CSVs by pairing a custom AssetUtil with a shape that displays them.

import {
	AssetUtil,
	BaseBoxShapeUtil,
	HTMLContainer,
	T,
	TLAsset,
	TLAssetId,
	TLBaseAsset,
	TLShape,
	TLShapePartial,
	Tldraw,
	VecModel,
	createShapeId,
	toRichText,
} from 'tldraw'
import 'tldraw/tldraw.css'

// There's a guide at the bottom of this file!

// [1]
const FILE_ASSET_TYPE = 'file' as const

interface FileAssetProps {
	name: string
	size: number
	mimeType: string
	src: string | null
}

type TLFileAsset = TLBaseAsset<typeof FILE_ASSET_TYPE, FileAssetProps>

declare module 'tldraw' {
	interface TLGlobalAssetPropsMap {
		[FILE_ASSET_TYPE]: FileAssetProps
	}
}

// [2]
class FileAssetUtil extends AssetUtil<TLFileAsset> {
	static override type = FILE_ASSET_TYPE

	static supportedMimeTypes = [
		'application/pdf',
		'text/plain',
		'text/csv',
		'application/json',
		'application/zip',
		'application/xml',
		'text/xml',
	] as const

	static supportedExtensions = ['.pdf', '.txt', '.csv', '.json', '.zip', '.xml'] as const

	// [3]
	static override props = {
		name: T.string,
		size: T.number,
		mimeType: T.string,
		src: T.string.nullable(),
	}

	override getDefaultProps(): TLFileAsset['props'] {
		return {
			name: '',
			size: 0,
			mimeType: '',
			src: null,
		}
	}

	// [4]
	override getSupportedMimeTypes() {
		return [...FileAssetUtil.supportedMimeTypes]
	}

	// [5]
	override async getAssetFromFile(file: File, assetId: TLAssetId): Promise<TLFileAsset> {
		return {
			id: assetId,
			type: FILE_ASSET_TYPE,
			typeName: 'asset',
			props: {
				name: file.name,
				size: file.size,
				mimeType: file.type,
				src: null,
			},
			meta: {},
		}
	}
}

const FILE_CARD_TYPE = 'file-card' as const

declare module 'tldraw' {
	export interface TLGlobalShapePropsMap {
		[FILE_CARD_TYPE]: {
			assetId: TLAssetId | null
			w: number
			h: number
		}
	}
}

type FileCardShape = TLShape<typeof FILE_CARD_TYPE>

function formatFileSize(bytes: number): string {
	if (bytes === 0) return '0 B'
	const units = ['B', 'KB', 'MB', 'GB']
	const i = Math.floor(Math.log(bytes) / Math.log(1024))
	const value = bytes / Math.pow(1024, i)
	return `${value % 1 === 0 ? value : value.toFixed(1)} ${units[i]}`
}

// [6]
class FileCardShapeUtil extends BaseBoxShapeUtil<FileCardShape> {
	static override type = FILE_CARD_TYPE
	static override handledAssetTypes = [FILE_ASSET_TYPE] as const

	override getDefaultProps() {
		return {
			assetId: null as TLAssetId | null,
			w: 200,
			h: 64,
		}
	}

	// [7]
	override createShapeForAsset(asset: TLAsset, position: VecModel): TLShapePartial {
		return {
			id: createShapeId(),
			type: FILE_CARD_TYPE,
			x: position.x,
			y: position.y,
			props: {
				assetId: asset.id,
				w: 200,
				h: 64,
			},
		}
	}

	override component(shape: FileCardShape) {
		const asset = shape.props.assetId
			? this.editor.getAsset<TLFileAsset>(shape.props.assetId)
			: null

		const name = asset?.props.name ?? 'Unknown file'
		const size = asset?.props.size ?? 0
		const src = asset?.props.src

		return (
			<HTMLContainer>
				<div
					style={{
						display: 'flex',
						alignItems: 'center',
						gap: 10,
						padding: '10px 14px',
						background: 'var(--color-background)',
						border: '1px solid var(--color-muted-2)',
						borderRadius: 8,
						height: '100%',
						boxSizing: 'border-box',
						fontFamily: 'sans-serif',
						overflow: 'hidden',
					}}
				>
					<div style={{ fontSize: 24, flexShrink: 0 }}>📄</div>
					<div style={{ overflow: 'hidden', flex: 1, minWidth: 0 }}>
						<div
							style={{
								fontSize: 13,
								fontWeight: 500,
								whiteSpace: 'nowrap',
								overflow: 'hidden',
								textOverflow: 'ellipsis',
								color: 'var(--color-text-1)',
							}}
						>
							{/* [8] */}
							{src ? (
								<a
									href={src}
									target="_blank"
									rel="noreferrer"
									style={{ color: 'inherit', textDecoration: 'underline' }}
								>
									{name}
								</a>
							) : (
								name
							)}
						</div>
						<div style={{ fontSize: 11, color: 'var(--color-text-3)', marginTop: 2 }}>
							{formatFileSize(size)}
						</div>
					</div>
				</div>
			</HTMLContainer>
		)
	}

	override getIndicatorPath(shape: FileCardShape) {
		const path = new Path2D()
		path.rect(0, 0, shape.props.w, shape.props.h)
		return path
	}
}

// [9]
export default function CustomAssetTypeExample() {
	const instructionText = `Drag a file with these supported extensions ${FileAssetUtil.supportedExtensions.join(', ')} onto the board`

	return (
		<div className="tldraw__editor">
			<Tldraw
				assetUtils={[FileAssetUtil]}
				shapeUtils={[FileCardShapeUtil]}
				persistenceKey="custom-asset-type-example"
				onMount={(editor) => {
					if (editor.getCurrentPageShapes().length === 0) {
						editor.createShapes([
							{
								id: createShapeId(),
								type: 'text',
								x: 100,
								y: 100,
								props: {
									richText: toRichText(instructionText),
								},
							},
						])
					}
				}}
			/>
		</div>
	)
}

/*
By default tldraw handles image, video, and bookmark assets. An `AssetUtil` teaches the
editor about a new asset type, and a `ShapeUtil` with `handledAssetTypes` tells it what
shape to create when one of those assets lands on the canvas.

[1]
The custom asset type. Augmenting `TLGlobalAssetPropsMap` makes `TLAsset` (and
`editor.getAsset`) aware of it, the same way `TLGlobalShapePropsMap` works for shapes.

[2]
`FileAssetUtil` handles MIME-type matching and file-to-asset conversion for the new type.

[3]
Static `props` are the store validators for the asset's props, using the same `T`
validators as shape utils.

[4]
When a file is dropped or pasted, the editor asks each registered `AssetUtil` whether it
supports the file's MIME type. Only files that match reach `getAssetFromFile`.

[5]
`getAssetFromFile` builds the asset record from a dropped file. `src` is left `null`
because the asset store's `upload` fills in the URL after the file is stored.

[6]
`handledAssetTypes` connects the shape to the asset type: when a `file` asset is
created, the editor finds this shape util and calls `createShapeForAsset`.

[7]
Return the shape partial to place on the canvas for a new asset. Returning `null` would
skip creating a shape.

[8]
Once the asset store has uploaded the file and set `src`, the filename becomes a link.

[9]
Register both utils. No custom external content handler is needed: the default file
handler picks the matching `AssetUtil` and uploads through the asset store. Here that is
the default store, which inlines files as data URLs; a real app would pass its own
`TLAssetStore` via the `assets` prop (see the "Hosted images" example).
*/

Out of the box, dropping a PDF or text file on the canvas does nothing because no asset type claims those MIME types. This example adds a file asset type with a FileAssetUtil that declares its supported MIME types and converts a dropped File into an asset record. A FileCardShapeUtil lists file in its handledAssetTypes and implements createShapeForAsset, so the editor knows to render a card for each uploaded file.

Try dragging a .pdf, .txt, .csv, .json, .zip, or .xml file onto the canvas. The default asset store inlines the file as a data URL, so the card's filename becomes a link you can open.

Is this page helpful?
Prev
Clipboard events
Next
Meta migrations