tldraw
Version:
A tiny little drawing editor.
836 lines (753 loc) • 24.4 kB
text/typescript
import {
Editor,
FileHelpers,
TLExternalContentSource,
Vec,
VecLike,
assert,
compact,
isDefined,
preventDefault,
uniq,
useEditor,
useMaybeEditor,
useValue,
} from '@tldraw/editor'
import lz from 'lz-string'
import { useCallback, useEffect } from 'react'
import { TLDRAW_CUSTOM_PNG_MIME_TYPE, getCanonicalClipboardReadType } from '../../utils/clipboard'
import { TLUiEventSource, useUiEvents } from '../context/events'
import { pasteFiles } from './clipboard/pasteFiles'
import { pasteUrl } from './clipboard/pasteUrl'
// Expected paste mime types. The earlier in this array they appear, the higher preference we give
// them. For example, we prefer the `web image/png+tldraw` type to plain `image/png` as it does not
// strip some of the extra metadata we write into it.
const expectedPasteFileMimeTypes = [
TLDRAW_CUSTOM_PNG_MIME_TYPE,
'image/png',
'image/jpeg',
'image/webp',
'image/svg+xml',
] satisfies string[]
/**
* Strip HTML tags from a string.
* @param html - The HTML to strip.
* @internal
*/
function stripHtml(html: string) {
// See <https://github.com/developit/preact-markup/blob/4788b8d61b4e24f83688710746ee36e7464f7bbc/src/parse-markup.js#L60-L69>
const doc = document.implementation.createHTMLDocument('')
doc.documentElement.innerHTML = html.trim()
return doc.body.textContent || doc.body.innerText || ''
}
/** @public */
export const isValidHttpURL = (url: string) => {
try {
const u = new URL(url)
return u.protocol === 'http:' || u.protocol === 'https:'
} catch {
return false
}
}
/** @public */
const getValidHttpURLList = (url: string) => {
const urls = url.split(/[\n\s]/)
for (const url of urls) {
try {
const u = new URL(url)
if (!(u.protocol === 'http:' || u.protocol === 'https:')) {
return
}
} catch {
return
}
}
return uniq(urls)
}
/** @public */
const isSvgText = (text: string) => {
return /^<svg/.test(text)
}
const INPUTS = ['input', 'select', 'textarea']
/**
* Get whether to disallow clipboard events.
*
* @internal
*/
function areShortcutsDisabled(editor: Editor) {
const { activeElement } = document
return (
editor.menus.hasAnyOpenMenus() ||
(activeElement &&
((activeElement as HTMLElement).isContentEditable ||
INPUTS.indexOf(activeElement.tagName.toLowerCase()) > -1))
)
}
/**
* Handle text pasted into the editor.
* @param editor - The editor instance.
* @param data - The text to paste.
* @param point - The point at which to paste the text.
* @internal
*/
const handleText = (
editor: Editor,
data: string,
point?: VecLike,
sources?: TLExternalContentSource[]
) => {
const validUrlList = getValidHttpURLList(data)
if (validUrlList) {
for (const url of validUrlList) {
pasteUrl(editor, url, point)
}
} else if (isValidHttpURL(data)) {
pasteUrl(editor, data, point)
} else if (isSvgText(data)) {
editor.markHistoryStoppingPoint('paste')
editor.putExternalContent({
type: 'svg-text',
text: data,
point,
sources,
})
} else {
editor.markHistoryStoppingPoint('paste')
editor.putExternalContent({
type: 'text',
text: data,
point,
sources,
})
}
}
/**
* Something found on the clipboard, either through the event's clipboard data or the browser's clipboard API.
* @internal
*/
type ClipboardThing =
| {
type: 'file'
source: Promise<File | null>
}
| {
type: 'blob'
source: Promise<Blob | null>
}
| {
type: 'url'
source: Promise<string>
}
| {
type: 'html'
source: Promise<string>
}
| {
type: 'text'
source: Promise<string>
}
| {
type: string
source: Promise<string>
}
/**
* Handle a paste using event clipboard data. This is the "original"
* paste method that uses the clipboard data from the paste event.
* https://developer.mozilla.org/en-US/docs/Web/API/ClipboardEvent/clipboardData
*
* @param editor - The editor
* @param clipboardData - The clipboard data
* @param point - The point to paste at
* @internal
*/
const handlePasteFromEventClipboardData = async (
editor: Editor,
clipboardData: DataTransfer,
point?: VecLike
) => {
// Do not paste while in any editing state
if (editor.getEditingShapeId() !== null) return
if (!clipboardData) {
throw Error('No clipboard data')
}
const things: ClipboardThing[] = []
for (const item of Object.values(clipboardData.items)) {
switch (item.kind) {
case 'file': {
// files are always blobs
things.push({
type: 'file',
source: new Promise((r) => r(item.getAsFile())) as Promise<File | null>,
})
break
}
case 'string': {
// strings can be text or html
if (item.type === 'text/html') {
things.push({
type: 'html',
source: new Promise((r) => item.getAsString(r)) as Promise<string>,
})
} else if (item.type === 'text/plain') {
things.push({
type: 'text',
source: new Promise((r) => item.getAsString(r)) as Promise<string>,
})
} else {
things.push({ type: item.type, source: new Promise((r) => item.getAsString(r)) })
}
break
}
}
}
handleClipboardThings(editor, things, point)
}
/**
* Handle a paste using items retrieved from the Clipboard API.
* https://developer.mozilla.org/en-US/docs/Web/API/ClipboardItem
*
* @param editor - The editor
* @param clipboardItems - The clipboard items to handle
* @param point - The point to paste at
* @internal
*/
const handlePasteFromClipboardApi = async ({
editor,
clipboardItems,
point,
fallbackFiles,
}: {
editor: Editor
clipboardItems: ClipboardItem[]
point?: VecLike
fallbackFiles?: File[]
}) => {
// We need to populate the array of clipboard things
// based on the ClipboardItems from the Clipboard API.
// This is done in a different way than when using
// the clipboard data from the paste event.
const things: ClipboardThing[] = []
for (const item of clipboardItems) {
for (const type of expectedPasteFileMimeTypes) {
if (item.types.includes(type)) {
const blobPromise = item
.getType(type)
.then((blob) => FileHelpers.rewriteMimeType(blob, getCanonicalClipboardReadType(type)))
things.push({
type: 'blob',
source: blobPromise,
})
break
}
}
if (item.types.includes('text/html')) {
things.push({
type: 'html',
source: (async () => {
const blob = await item.getType('text/html')
return await FileHelpers.blobToText(blob)
})(),
})
}
if (item.types.includes('text/uri-list')) {
things.push({
type: 'url',
source: (async () => {
const blob = await item.getType('text/uri-list')
return await FileHelpers.blobToText(blob)
})(),
})
}
if (item.types.includes('text/plain')) {
things.push({
type: 'text',
source: (async () => {
const blob = await item.getType('text/plain')
return await FileHelpers.blobToText(blob)
})(),
})
}
}
if (fallbackFiles?.length && things.length === 1 && things[0].type === 'text') {
things.pop()
things.push(
...fallbackFiles.map((f): ClipboardThing => ({ type: 'file', source: Promise.resolve(f) }))
)
} else if (fallbackFiles?.length && things.length === 0) {
// Files pasted in Safari from your computer don't have types, so we need to use the fallback files directly
// if they're available. This only works if pasted keyboard shortcuts. Pasting from the menu in Safari seems to never
// let you access files that are copied from your computer.
things.push(
...fallbackFiles.map((f): ClipboardThing => ({ type: 'file', source: Promise.resolve(f) }))
)
}
return await handleClipboardThings(editor, things, point)
}
async function handleClipboardThings(editor: Editor, things: ClipboardThing[], point?: VecLike) {
// 1. Handle files
//
// We need to handle files separately because if we want them to
// be placed next to each other, we need to create them all at once.
const files = things.filter(
(t) => (t.type === 'file' || t.type === 'blob') && t.source !== null
) as Extract<ClipboardThing, { type: 'file' } | { type: 'blob' }>[]
// Just paste the files, nothing else
if (files.length) {
if (files.length > editor.options.maxFilesAtOnce) {
throw Error('Too many files')
}
const fileBlobs = compact(await Promise.all(files.map((t) => t.source)))
return await pasteFiles(editor, fileBlobs, point)
}
// 2. Generate clipboard results for non-file things
//
// Getting the source from the items is async, however they must be accessed syncronously;
// we can't await them in a loop. So we'll map them to promises and await them all at once,
// then make decisions based on what we find.
const results = await Promise.all<TLExternalContentSource>(
things
.filter((t) => t.type !== 'file')
.map(
(t) =>
new Promise((r) => {
const thing = t as Exclude<ClipboardThing, { type: 'file' } | { type: 'blob' }>
if (thing.type === 'file') {
r({ type: 'error', data: null, reason: 'unexpected file' })
return
}
thing.source.then((text) => {
// first, see if we can find tldraw content, which is JSON inside of an html comment
const tldrawHtmlComment = text.match(/<div data-tldraw[^>]*>(.*)<\/div>/)?.[1]
if (tldrawHtmlComment) {
try {
// First try parsing as plain JSON (version 2/3 formats)
let json
try {
json = JSON.parse(tldrawHtmlComment)
} catch {
// Fall back to LZ decompression (legacy format)
const jsonComment = lz.decompressFromBase64(tldrawHtmlComment)
if (jsonComment === null) {
r({
type: 'error',
data: null,
reason: `found tldraw data comment but could not parse`,
})
return
}
json = JSON.parse(jsonComment)
}
if (json.type !== 'application/tldraw') {
r({
type: 'error',
data: json,
reason: `found tldraw data comment but JSON was of a different type: ${json.type}`,
})
return
}
// Handle versioned clipboard format
if (json.version === 3) {
// Version 3: Assets are plain, decompress only other data
try {
const otherData = JSON.parse(
lz.decompressFromBase64(json.data.otherCompressed) || '{}'
)
const reconstructedData = {
assets: json.data.assets || [],
...otherData,
}
r({ type: 'tldraw', data: reconstructedData })
return
} catch (error) {
r({
type: 'error',
data: json,
reason: `failed to decompress version 2 clipboard data: ${error}`,
})
return
}
}
if (json.version === 2) {
// Version 2: Everything is plain, this had issues with encoding... :-/
// TODO: nix this support after some time.
r({ type: 'tldraw', data: json.data })
} else {
// Version 1 or no version: Legacy format
if (typeof json.data === 'string') {
r({
type: 'error',
data: json,
reason:
'found tldraw json but data was a string instead of a TLClipboardModel object',
})
return
}
r({ type: 'tldraw', data: json.data })
return
}
} catch {
r({
type: 'error',
data: tldrawHtmlComment,
reason:
'found tldraw json but data was a string instead of a TLClipboardModel object',
})
return
}
} else {
if (thing.type === 'html') {
r({ type: 'text', data: text, subtype: 'html' })
return
}
if (thing.type === 'url') {
r({ type: 'text', data: text, subtype: 'url' })
return
}
// if we have not found a tldraw comment, Otherwise, try to parse the text as JSON directly.
try {
const json = JSON.parse(text)
if (json.type === 'excalidraw/clipboard') {
// If the clipboard contains content copied from excalidraw, then paste that
r({ type: 'excalidraw', data: json })
return
} else {
r({ type: 'text', data: text, subtype: 'json' })
return
}
} catch {
// If we could not parse the text as JSON, then it's just text
r({ type: 'text', data: text, subtype: 'text' })
return
}
}
r({ type: 'error', data: text, reason: 'unhandled case' })
})
})
)
)
// 3.
//
// Now that we know what kind of stuff we're dealing with, we can actual create some content.
// There are priorities here, so order matters: we've already handled images and files, which
// take first priority; then we want to handle tldraw content, then excalidraw content, then
// html content, then links, and finally text content.
// Try to paste tldraw content
for (const result of results) {
if (result.type === 'tldraw') {
editor.markHistoryStoppingPoint('paste')
editor.putExternalContent({ type: 'tldraw', content: result.data, point })
return
}
}
// Try to paste excalidraw content
for (const result of results) {
if (result.type === 'excalidraw') {
editor.markHistoryStoppingPoint('paste')
editor.putExternalContent({ type: 'excalidraw', content: result.data, point })
return
}
}
// Try to paste html content
for (const result of results) {
if (result.type === 'text' && result.subtype === 'html') {
// try to find a link
const rootNode = new DOMParser().parseFromString(result.data, 'text/html')
const bodyNode = rootNode.querySelector('body')
// Edge on Windows 11 home appears to paste a link as a single <a/> in
// the HTML document. If we're pasting a single like tag we'll just
// assume the user meant to paste the URL.
const isHtmlSingleLink =
bodyNode &&
Array.from(bodyNode.children).filter((el) => el.nodeType === 1).length === 1 &&
bodyNode.firstElementChild &&
bodyNode.firstElementChild.tagName === 'A' &&
bodyNode.firstElementChild.hasAttribute('href') &&
bodyNode.firstElementChild.getAttribute('href') !== ''
if (isHtmlSingleLink) {
const href = bodyNode.firstElementChild.getAttribute('href')!
handleText(editor, href, point, results)
return
}
// If the html is NOT a link, and we have NO OTHER texty content, then paste the html as text
if (!results.some((r) => r.type === 'text' && r.subtype !== 'html') && result.data.trim()) {
const html = stripHtml(result.data) ?? ''
if (html) {
handleText(editor, stripHtml(result.data), point, results)
return
}
}
// If the html is NOT a link, and we have other texty content, then paste the html as a text shape
if (results.some((r) => r.type === 'text' && r.subtype !== 'html')) {
const html = stripHtml(result.data) ?? ''
if (html) {
editor.markHistoryStoppingPoint('paste')
editor.putExternalContent({
type: 'text',
text: html,
html: result.data,
point,
sources: results,
})
return
}
}
}
// Allow you to paste YouTube or Google Maps embeds, for example.
if (result.type === 'text' && result.subtype === 'text' && result.data.startsWith('<iframe ')) {
// try to find an iframe
const rootNode = new DOMParser().parseFromString(result.data, 'text/html')
const bodyNode = rootNode.querySelector('body')
const isSingleIframe =
bodyNode &&
Array.from(bodyNode.children).filter((el) => el.nodeType === 1).length === 1 &&
bodyNode.firstElementChild &&
bodyNode.firstElementChild.tagName === 'IFRAME' &&
bodyNode.firstElementChild.hasAttribute('src') &&
bodyNode.firstElementChild.getAttribute('src') !== ''
if (isSingleIframe) {
const src = bodyNode.firstElementChild.getAttribute('src')!
handleText(editor, src, point, results)
return
}
}
}
// Try to paste a link
for (const result of results) {
if (result.type === 'text' && result.subtype === 'url') {
pasteUrl(editor, result.data, point, results)
return
}
}
// Finally, if we haven't bailed on anything yet, we can paste text content
for (const result of results) {
if (result.type === 'text' && result.subtype === 'text' && result.data.trim()) {
// The clipboard may include multiple text items, but we only want to paste the first one
handleText(editor, result.data, point, results)
return
}
}
}
/**
* When the user copies, write the contents to local storage and to the clipboard
*
* @param editor - The editor instance.
* @public
*/
const handleNativeOrMenuCopy = async (editor: Editor) => {
const navigator =
editor.getContainer().ownerDocument?.defaultView?.navigator ?? globalThis.navigator
const content = await editor.resolveAssetsInContent(
editor.getContentFromCurrentPage(editor.getSelectedShapeIds())
)
if (!content) {
if (navigator && navigator.clipboard) {
navigator.clipboard.writeText('')
}
return
}
// Use versioned clipboard format for better compression
// Version 3: Don't compress assets, only compress other data
const { assets, ...otherData } = content
const clipboardData = {
type: 'application/tldraw',
kind: 'content',
version: 3,
data: {
assets: assets || [], // Plain JSON, no compression
otherCompressed: lz.compressToBase64(JSON.stringify(otherData)), // Only compress non-asset data
},
}
// Don't compress the final structure - just use plain JSON
const stringifiedClipboard = JSON.stringify(clipboardData)
if (typeof navigator === 'undefined') {
return
} else {
// Extract the text from the clipboard
const textItems = content.shapes
.map((shape) => {
const util = editor.getShapeUtil(shape)
return util.getText(shape)
})
.filter(isDefined)
if (navigator.clipboard?.write) {
const htmlBlob = new Blob([`<div data-tldraw>${stringifiedClipboard}</div>`], {
type: 'text/html',
})
let textContent = textItems.join(' ')
// This is a bug in chrome android where it won't paste content if
// the text/plain content is "" so we need to always add an empty
// space 🤬
if (textContent === '') {
textContent = ' '
}
navigator.clipboard.write([
new ClipboardItem({
'text/html': htmlBlob,
// What is this second blob used for?
'text/plain': new Blob([textContent], { type: 'text/plain' }),
}),
])
} else if (navigator.clipboard.writeText) {
navigator.clipboard.writeText(`<div data-tldraw>${stringifiedClipboard}</div>`)
}
}
}
/** @public */
export function useMenuClipboardEvents() {
const editor = useMaybeEditor()
const trackEvent = useUiEvents()
const copy = useCallback(
async function onCopy(source: TLUiEventSource) {
assert(editor, 'editor is required for copy')
if (editor.getSelectedShapeIds().length === 0) return
await handleNativeOrMenuCopy(editor)
trackEvent('copy', { source })
},
[editor, trackEvent]
)
const cut = useCallback(
async function onCut(source: TLUiEventSource) {
if (!editor) return
if (editor.getSelectedShapeIds().length === 0) return
await handleNativeOrMenuCopy(editor)
editor.deleteShapes(editor.getSelectedShapeIds())
trackEvent('cut', { source })
},
[editor, trackEvent]
)
const paste = useCallback(
async function onPaste(
data: DataTransfer | ClipboardItem[],
source: TLUiEventSource,
point?: VecLike
) {
if (!editor) return
// If we're editing a shape, or we are focusing an editable input, then
// we would want the user's paste interaction to go to that element or
// input instead; e.g. when pasting text into a text shape's content
if (editor.getEditingShapeId() !== null) return
if (Array.isArray(data) && data[0] instanceof ClipboardItem) {
handlePasteFromClipboardApi({ editor, clipboardItems: data, point })
trackEvent('paste', { source: 'menu' })
} else {
// Read it first and then recurse, kind of weird
navigator.clipboard.read().then((clipboardItems) => {
paste(clipboardItems, source, point)
})
}
},
[editor, trackEvent]
)
return {
copy,
cut,
paste,
}
}
/** @public */
export function useNativeClipboardEvents() {
const editor = useEditor()
const ownerDocument = editor.getContainer().ownerDocument
const trackEvent = useUiEvents()
const appIsFocused = useValue('editor.isFocused', () => editor.getInstanceState().isFocused, [
editor,
])
useEffect(() => {
if (!appIsFocused) return
const copy = async (e: ClipboardEvent) => {
if (
editor.getSelectedShapeIds().length === 0 ||
editor.getEditingShapeId() !== null ||
areShortcutsDisabled(editor)
) {
return
}
preventDefault(e)
await handleNativeOrMenuCopy(editor)
trackEvent('copy', { source: 'kbd' })
}
async function cut(e: ClipboardEvent) {
if (
editor.getSelectedShapeIds().length === 0 ||
editor.getEditingShapeId() !== null ||
areShortcutsDisabled(editor)
) {
return
}
preventDefault(e)
await handleNativeOrMenuCopy(editor)
editor.deleteShapes(editor.getSelectedShapeIds())
trackEvent('cut', { source: 'kbd' })
}
let disablingMiddleClickPaste = false
const pointerUpHandler = (e: PointerEvent) => {
if (e.button === 1) {
// middle mouse button
disablingMiddleClickPaste = true
editor.timers.requestAnimationFrame(() => {
disablingMiddleClickPaste = false
})
}
}
const paste = (e: ClipboardEvent) => {
if (disablingMiddleClickPaste) {
editor.markEventAsHandled(e)
return
}
// If we're editing a shape, or we are focusing an editable input, then
// we would want the user's paste interaction to go to that element or
// input instead; e.g. when pasting text into a text shape's content
if (editor.getEditingShapeId() !== null || areShortcutsDisabled(editor)) return
// Where should the shapes go?
let point: Vec | undefined = undefined
let pasteAtCursor = false
// | Shiftkey | Paste at cursor mode | Paste at point? |
// | N | N | N |
// | Y | N | Y |
// | N | Y | Y |
// | Y | Y | N |
if (editor.inputs.getShiftKey()) pasteAtCursor = true
if (editor.user.getIsPasteAtCursorMode()) pasteAtCursor = !pasteAtCursor
if (pasteAtCursor) point = editor.inputs.getCurrentPagePoint()
const pasteFromEvent = () => {
if (e.clipboardData) {
handlePasteFromEventClipboardData(editor, e.clipboardData, point)
}
}
// if we can read from the clipboard API, we want to try using that first. that allows
// us to access most things, and doesn't strip out metadata added to tldraw's own
// copy-as-png features - so copied shapes come back in at the correct size.
if (navigator.clipboard?.read) {
// We can't read files from the filesystem using the clipboard API though - they'll
// just come in as the file names instead. So we'll use the clipboard event's files
// as a fallback - if we only got text, but do have files, we use those instead.
const fallbackFiles = Array.from(e.clipboardData?.files || [])
navigator.clipboard.read().then(
(clipboardItems) => {
if (Array.isArray(clipboardItems) && clipboardItems[0] instanceof ClipboardItem) {
handlePasteFromClipboardApi({ editor, clipboardItems, point, fallbackFiles })
}
},
() => {
// if reading from the clipboard fails, try to use the event clipboard data
pasteFromEvent()
}
)
} else {
pasteFromEvent()
}
preventDefault(e)
trackEvent('paste', { source: 'kbd' })
}
ownerDocument?.addEventListener('copy', copy)
ownerDocument?.addEventListener('cut', cut)
ownerDocument?.addEventListener('paste', paste)
ownerDocument?.addEventListener('pointerup', pointerUpHandler)
return () => {
ownerDocument?.removeEventListener('copy', copy)
ownerDocument?.removeEventListener('cut', cut)
ownerDocument?.removeEventListener('paste', paste)
ownerDocument?.removeEventListener('pointerup', pointerUpHandler)
}
}, [editor, trackEvent, appIsFocused, ownerDocument])
}