canvas-website/src/utils/multiPasteHandler.ts

371 lines
8.7 KiB
TypeScript

import { Editor, Vec, createShapeId, AssetRecordType, getHashForString } from "tldraw"
import { WORKER_URL } from "../constants/workerUrl"
// URL patterns to detect multiple URLs in pasted text
const URL_REGEX = /https?:\/\/[^\s<>"{}|\\^`\[\]]+/gi
// Image file extensions
const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)$/i
/**
* Check if a URL points to an image
*/
function isImageUrl(url: string): boolean {
try {
const urlObj = new URL(url)
return IMAGE_EXTENSIONS.test(urlObj.pathname)
} catch {
return false
}
}
/**
* Extract all URLs from a string of text
*/
function extractUrls(text: string): string[] {
const matches = text.match(URL_REGEX)
if (!matches) return []
// Deduplicate URLs
return [...new Set(matches)]
}
/**
* Unfurl a bookmark URL to get metadata
*/
async function unfurlUrl(url: string): Promise<{
title: string
description: string
image: string
favicon: string
}> {
try {
const response = await fetch(
`${WORKER_URL}/unfurl?url=${encodeURIComponent(url)}`
)
if (!response.ok) throw new Error('Failed to unfurl')
return await response.json()
} catch {
return { title: '', description: '', image: '', favicon: '' }
}
}
/**
* Create a bookmark shape for a URL
*/
async function createBookmarkForUrl(
editor: Editor,
url: string,
position: Vec
): Promise<void> {
const assetId = AssetRecordType.createId(getHashForString(url))
// Check if asset already exists
const existingAsset = editor.getAsset(assetId)
if (!existingAsset) {
const metadata = await unfurlUrl(url)
editor.createAssets([{
id: assetId,
typeName: 'asset',
type: 'bookmark',
meta: {},
props: {
src: url,
title: metadata.title || url,
description: metadata.description || '',
image: metadata.image || '',
favicon: metadata.favicon || '',
},
}])
}
editor.createShape({
id: createShapeId(),
type: 'bookmark',
x: position.x,
y: position.y,
props: {
assetId,
url,
},
})
}
/**
* Create an image shape for an image URL
*/
async function createImageForUrl(
editor: Editor,
url: string,
position: Vec
): Promise<void> {
const assetId = AssetRecordType.createId(getHashForString(url))
// Check if asset already exists
const existingAsset = editor.getAsset(assetId)
if (!existingAsset) {
// Try to get image dimensions
let w = 300
let h = 200
try {
const img = new Image()
img.crossOrigin = 'anonymous'
await new Promise<void>((resolve, reject) => {
img.onload = () => {
w = img.naturalWidth
h = img.naturalHeight
resolve()
}
img.onerror = () => reject()
img.src = url
})
} catch {
// Use default dimensions
}
editor.createAssets([{
id: assetId,
typeName: 'asset',
type: 'image',
meta: {},
props: {
src: url,
w,
h,
mimeType: 'image/jpeg',
name: url.split('/').pop() || 'image',
isAnimated: url.endsWith('.gif'),
},
}])
}
const asset = editor.getAsset(assetId)
const props = asset?.props as { w?: number; h?: number } | undefined
editor.createShape({
id: createShapeId(),
type: 'image',
x: position.x,
y: position.y,
props: {
assetId,
w: props?.w || 300,
h: props?.h || 200,
},
})
}
/**
* Create an image shape from a File
*/
async function createImageFromFile(
editor: Editor,
file: File,
position: Vec
): Promise<void> {
// Read file as data URL
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(file)
})
const assetId = AssetRecordType.createId(getHashForString(dataUrl.slice(0, 100) + file.name))
// Get image dimensions
let w = 300
let h = 200
try {
const img = new Image()
await new Promise<void>((resolve, reject) => {
img.onload = () => {
w = img.naturalWidth
h = img.naturalHeight
resolve()
}
img.onerror = reject
img.src = dataUrl
})
} catch {
// Use default dimensions
}
editor.createAssets([{
id: assetId,
typeName: 'asset',
type: 'image',
meta: {},
props: {
src: dataUrl,
w,
h,
mimeType: file.type || 'image/jpeg',
name: file.name,
isAnimated: file.type === 'image/gif',
},
}])
editor.createShape({
id: createShapeId(),
type: 'image',
x: position.x,
y: position.y,
props: {
assetId,
w,
h,
},
})
}
/**
* Configuration for grid layout of pasted items
*/
const GRID_CONFIG = {
spacing: 20, // Space between items
itemWidth: 320, // Default width per item
itemHeight: 240, // Default height per item
maxColumns: 5, // Maximum items per row
}
/**
* Calculate grid positions for multiple items
*/
function calculateGridPositions(
startPosition: Vec,
count: number
): Vec[] {
const positions: Vec[] = []
const columns = Math.min(count, GRID_CONFIG.maxColumns)
for (let i = 0; i < count; i++) {
const col = i % columns
const row = Math.floor(i / columns)
positions.push(new Vec(
startPosition.x + col * (GRID_CONFIG.itemWidth + GRID_CONFIG.spacing),
startPosition.y + row * (GRID_CONFIG.itemHeight + GRID_CONFIG.spacing)
))
}
return positions
}
/**
* Main paste handler that supports multiple items
*/
export function setupMultiPasteHandler(editor: Editor): () => void {
const handlePaste = async (e: ClipboardEvent) => {
// Don't intercept if user is typing in an input
const activeElement = document.activeElement
if (
activeElement instanceof HTMLInputElement ||
activeElement instanceof HTMLTextAreaElement ||
(activeElement instanceof HTMLElement && activeElement.isContentEditable)
) {
return
}
const clipboardData = e.clipboardData
if (!clipboardData) return
// Get paste position (center of viewport)
const viewportBounds = editor.getViewportPageBounds()
const center = viewportBounds.center
// Collect all items to paste
const imageFiles: File[] = []
const urls: string[] = []
// Check for files (images)
if (clipboardData.files.length > 0) {
for (let i = 0; i < clipboardData.files.length; i++) {
const file = clipboardData.files[i]
if (file.type.startsWith('image/')) {
imageFiles.push(file)
}
}
}
// Check for text content that might contain URLs
const textData = clipboardData.getData('text/plain')
if (textData) {
const extractedUrls = extractUrls(textData)
urls.push(...extractedUrls)
}
// Check for URL data type
const urlData = clipboardData.getData('text/uri-list')
if (urlData) {
// URI list can contain multiple URLs separated by newlines
const uriUrls = urlData.split('\n').filter(line => line.trim() && !line.startsWith('#'))
for (const url of uriUrls) {
if (!urls.includes(url.trim())) {
urls.push(url.trim())
}
}
}
// If we have multiple items, handle them ourselves
const totalItems = imageFiles.length + urls.length
if (totalItems > 1) {
// Prevent default tldraw handling
e.preventDefault()
e.stopPropagation()
console.log(`📋 Multi-paste: ${imageFiles.length} images, ${urls.length} URLs`)
// Calculate grid positions
const positions = calculateGridPositions(center, totalItems)
let positionIndex = 0
// Batch all shape creation in a single history entry
editor.mark('multi-paste')
// Process image files first
for (const file of imageFiles) {
const position = positions[positionIndex++]
try {
await createImageFromFile(editor, file, position)
} catch (err) {
console.error('Failed to create image from file:', err)
}
}
// Process URLs
for (const url of urls) {
const position = positions[positionIndex++]
try {
if (isImageUrl(url)) {
await createImageForUrl(editor, url, position)
} else {
await createBookmarkForUrl(editor, url, position)
}
} catch (err) {
console.error('Failed to create shape for URL:', err)
}
}
return
}
// For single items, let tldraw handle it normally
// (don't prevent default)
}
// Add event listener
document.addEventListener('paste', handlePaste, { capture: true })
// Return cleanup function
return () => {
document.removeEventListener('paste', handlePaste, { capture: true })
}
}