kopia lustrzana https://github.com/Tldraw/Tldraw
673 wiersze
18 KiB
TypeScript
673 wiersze
18 KiB
TypeScript
import {
|
|
App,
|
|
getValidHttpURLList,
|
|
isSvgText,
|
|
isValidHttpURL,
|
|
TLArrowUtil,
|
|
TLBookmarkUtil,
|
|
TLClipboardModel,
|
|
TLEmbedUtil,
|
|
TLGeoUtil,
|
|
TLTextUtil,
|
|
useApp,
|
|
} from '@tldraw/editor'
|
|
import { VecLike } from '@tldraw/primitives'
|
|
import { isNonNull } from '@tldraw/utils'
|
|
import { compressToBase64, decompressFromBase64 } from 'lz-string'
|
|
import { useCallback, useEffect } from 'react'
|
|
import { pasteExcalidrawContent } from './clipboard/pasteExcalidrawContent'
|
|
import { pasteFiles } from './clipboard/pasteFiles'
|
|
import { pastePlainText } from './clipboard/pastePlainText'
|
|
import { pasteSvgText } from './clipboard/pasteSvgText'
|
|
import { pasteTldrawContent } from './clipboard/pasteTldrawContent'
|
|
import { pasteUrl } from './clipboard/pasteUrl'
|
|
import { useAppIsFocused } from './useAppIsFocused'
|
|
import { TLUiEventSource, useEvents } from './useEventsProvider'
|
|
|
|
const INPUTS = ['input', 'select', 'textarea']
|
|
|
|
/**
|
|
* Get whether to disallow clipboard events.
|
|
*
|
|
* @param app - The app instance.
|
|
* @internal
|
|
*/
|
|
function disallowClipboardEvents(app: App) {
|
|
const { activeElement } = document
|
|
return (
|
|
app.isMenuOpen ||
|
|
(activeElement &&
|
|
(activeElement.getAttribute('contenteditable') ||
|
|
INPUTS.indexOf(activeElement.tagName.toLowerCase()) > -1))
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Get a blob as a string.
|
|
*
|
|
* @param blob - The blob to get as a string.
|
|
* @internal
|
|
*/
|
|
async function blobAsString(blob: Blob) {
|
|
return new Promise<string>((resolve, reject) => {
|
|
const reader = new FileReader()
|
|
reader.addEventListener('loadend', () => {
|
|
const text = reader.result
|
|
resolve(text as string)
|
|
})
|
|
reader.addEventListener('error', () => {
|
|
reject(reader.error)
|
|
})
|
|
reader.readAsText(blob)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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 || ''
|
|
}
|
|
|
|
/**
|
|
* Whether a ClipboardItem is a file.
|
|
* @param item - The ClipboardItem to check.
|
|
* @internal
|
|
*/
|
|
const isFile = (item: ClipboardItem) => {
|
|
return item.types.find((i) => i.match(/^image\//))
|
|
}
|
|
|
|
/**
|
|
* Handle text pasted into the app.
|
|
* @param app - The app instance.
|
|
* @param data - The text to paste.
|
|
* @param point - (optional) The point at which to paste the text.
|
|
* @internal
|
|
*/
|
|
const handleText = (app: App, data: string, point?: VecLike) => {
|
|
const validUrlList = getValidHttpURLList(data)
|
|
if (validUrlList) {
|
|
for (const url of validUrlList) {
|
|
pasteUrl(app, url, point)
|
|
}
|
|
} else if (isValidHttpURL(data)) {
|
|
pasteUrl(app, data, point)
|
|
} else if (isSvgText(data)) {
|
|
pasteSvgText(app, data, point)
|
|
} else {
|
|
pastePlainText(app, data, point)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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>
|
|
}
|
|
|
|
/**
|
|
* The result of processing a `ClipboardThing`.
|
|
* @internal
|
|
*/
|
|
type ClipboardResult =
|
|
| {
|
|
type: 'tldraw'
|
|
data: TLClipboardModel
|
|
}
|
|
| {
|
|
type: 'excalidraw'
|
|
data: any
|
|
}
|
|
| {
|
|
type: 'text'
|
|
data: string
|
|
subtype: 'json' | 'html' | 'text' | 'url'
|
|
}
|
|
| {
|
|
type: 'error'
|
|
data: string | null
|
|
reason: 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 app - The app
|
|
* @param clipboardData - The clipboard data
|
|
* @param point - (optional) The point to paste at
|
|
* @internal
|
|
*/
|
|
const handlePasteFromEventClipboardData = async (
|
|
app: App,
|
|
clipboardData: DataTransfer,
|
|
point?: VecLike
|
|
) => {
|
|
// Do not paste while in any editing state
|
|
if (app.editingId !== 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(app, things, point)
|
|
}
|
|
|
|
/**
|
|
* Handle a paste using items retrieved from the Clipboard API.
|
|
* https://developer.mozilla.org/en-US/docs/Web/API/ClipboardItem
|
|
*
|
|
* @param app - The app
|
|
* @param clipboardItems - The clipboard items to handle
|
|
* @param point - (optional) The point to paste at
|
|
* @internal
|
|
*/
|
|
const handlePasteFromClipboardApi = async (
|
|
app: App,
|
|
clipboardItems: ClipboardItem[],
|
|
point?: VecLike
|
|
) => {
|
|
// 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) {
|
|
if (isFile(item)) {
|
|
for (const type of item.types) {
|
|
if (type.match(/^image\//)) {
|
|
things.push({ type: 'blob', source: item.getType(type) })
|
|
}
|
|
}
|
|
}
|
|
|
|
if (item.types.includes('text/html')) {
|
|
things.push({
|
|
type: 'html',
|
|
source: new Promise<string>((r) =>
|
|
item.getType('text/html').then((blob) => blobAsString(blob).then(r))
|
|
),
|
|
})
|
|
}
|
|
|
|
if (item.types.includes('text/uri-list')) {
|
|
things.push({
|
|
type: 'url',
|
|
source: new Promise<string>((r) =>
|
|
item.getType('text/uri-list').then((blob) => blobAsString(blob).then(r))
|
|
),
|
|
})
|
|
}
|
|
|
|
if (item.types.includes('text/plain')) {
|
|
things.push({
|
|
type: 'text',
|
|
source: new Promise<string>((r) =>
|
|
item.getType('text/plain').then((blob) => blobAsString(blob).then(r))
|
|
),
|
|
})
|
|
}
|
|
}
|
|
|
|
return await handleClipboardThings(app, things, point)
|
|
}
|
|
|
|
async function handleClipboardThings(app: App, 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) {
|
|
const fileBlobs = await Promise.all(files.map((t) => t.source!))
|
|
const urls = (fileBlobs.filter(Boolean) as (File | Blob)[]).map((blob) =>
|
|
URL.createObjectURL(blob)
|
|
)
|
|
return await pasteFiles(app, urls, 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<ClipboardResult>(
|
|
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(/<tldraw[^>]*>(.*)<\/tldraw>/)?.[1]
|
|
|
|
if (tldrawHtmlComment) {
|
|
try {
|
|
// If we've found tldraw content in the html string, use that as JSON
|
|
const jsonComment = decompressFromBase64(tldrawHtmlComment)
|
|
if (jsonComment === null) {
|
|
r({
|
|
type: 'error',
|
|
data: jsonComment,
|
|
reason: `found tldraw data comment but could not parse base64`,
|
|
})
|
|
return
|
|
} else {
|
|
const 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}`,
|
|
})
|
|
}
|
|
|
|
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 (e: any) {
|
|
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 (e) {
|
|
// 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') {
|
|
pasteTldrawContent(app, result.data, point)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Try to paste excalidraw content
|
|
for (const result of results) {
|
|
if (result.type === 'excalidraw') {
|
|
pasteExcalidrawContent(app, 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(app, href, point)
|
|
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()) {
|
|
handleText(app, stripHtml(result.data), point)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try to paste a link
|
|
for (const result of results) {
|
|
if (result.type === 'text' && result.subtype === 'url') {
|
|
pasteUrl(app, result.data, point)
|
|
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(app, result.data, point)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* When the user copies, write the contents to local storage and to the clipboard
|
|
*
|
|
* @param app - App
|
|
* @public
|
|
*/
|
|
const handleNativeOrMenuCopy = (app: App) => {
|
|
const content = app.getContent()
|
|
if (!content) {
|
|
if (navigator && navigator.clipboard) {
|
|
navigator.clipboard.writeText('')
|
|
}
|
|
return
|
|
}
|
|
|
|
const stringifiedClipboard = compressToBase64(
|
|
JSON.stringify({
|
|
type: 'application/tldraw',
|
|
kind: 'content',
|
|
data: content,
|
|
})
|
|
)
|
|
|
|
if (typeof navigator === 'undefined') {
|
|
return
|
|
} else {
|
|
// Extract the text from the clipboard
|
|
const textItems = content.shapes
|
|
.map((shape) => {
|
|
if (
|
|
app.isShapeOfType(shape, TLTextUtil) ||
|
|
app.isShapeOfType(shape, TLGeoUtil) ||
|
|
app.isShapeOfType(shape, TLArrowUtil)
|
|
) {
|
|
return shape.props.text
|
|
}
|
|
if (app.isShapeOfType(shape, TLBookmarkUtil) || app.isShapeOfType(shape, TLEmbedUtil)) {
|
|
return shape.props.url
|
|
}
|
|
return null
|
|
})
|
|
.filter(isNonNull)
|
|
|
|
if (navigator.clipboard?.write) {
|
|
const htmlBlob = new Blob([`<tldraw>${stringifiedClipboard}</tldraw>`], {
|
|
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(`<tldraw>${stringifiedClipboard}</tldraw>`)
|
|
}
|
|
}
|
|
}
|
|
|
|
/** @public */
|
|
export function useMenuClipboardEvents() {
|
|
const app = useApp()
|
|
const trackEvent = useEvents()
|
|
|
|
const copy = useCallback(
|
|
function onCopy(source: TLUiEventSource) {
|
|
if (app.selectedIds.length === 0) return
|
|
|
|
handleNativeOrMenuCopy(app)
|
|
trackEvent('copy', { source })
|
|
},
|
|
[app, trackEvent]
|
|
)
|
|
|
|
const cut = useCallback(
|
|
function onCut(source: TLUiEventSource) {
|
|
if (app.selectedIds.length === 0) return
|
|
|
|
handleNativeOrMenuCopy(app)
|
|
app.deleteShapes()
|
|
trackEvent('cut', { source })
|
|
},
|
|
[app, trackEvent]
|
|
)
|
|
|
|
const paste = useCallback(
|
|
async function onPaste(
|
|
data: DataTransfer | ClipboardItem[],
|
|
source: TLUiEventSource,
|
|
point?: VecLike
|
|
) {
|
|
// 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 (app.editingId !== null || disallowClipboardEvents(app)) return
|
|
|
|
if (Array.isArray(data) && data[0] instanceof ClipboardItem) {
|
|
handlePasteFromClipboardApi(app, 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)
|
|
})
|
|
}
|
|
},
|
|
[app, trackEvent]
|
|
)
|
|
|
|
return {
|
|
copy,
|
|
cut,
|
|
paste,
|
|
}
|
|
}
|
|
|
|
/** @public */
|
|
export function useNativeClipboardEvents() {
|
|
const app = useApp()
|
|
const trackEvent = useEvents()
|
|
|
|
const appIsFocused = useAppIsFocused()
|
|
|
|
useEffect(() => {
|
|
if (!appIsFocused) return
|
|
const copy = () => {
|
|
if (app.selectedIds.length === 0 || app.editingId !== null || disallowClipboardEvents(app))
|
|
return
|
|
handleNativeOrMenuCopy(app)
|
|
trackEvent('copy', { source: 'kbd' })
|
|
}
|
|
|
|
function cut() {
|
|
if (app.selectedIds.length === 0 || app.editingId !== null || disallowClipboardEvents(app))
|
|
return
|
|
handleNativeOrMenuCopy(app)
|
|
app.deleteShapes()
|
|
trackEvent('cut', { source: 'kbd' })
|
|
}
|
|
|
|
let disablingMiddleClickPaste = false
|
|
const pointerUpHandler = (e: PointerEvent) => {
|
|
if (e.button === 1) {
|
|
disablingMiddleClickPaste = true
|
|
requestAnimationFrame(() => {
|
|
disablingMiddleClickPaste = false
|
|
})
|
|
}
|
|
}
|
|
|
|
const paste = (event: ClipboardEvent) => {
|
|
if (disablingMiddleClickPaste) {
|
|
event.stopPropagation()
|
|
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 (app.editingId !== null || disallowClipboardEvents(app)) return
|
|
|
|
// First try to use the clipboard data on the event
|
|
if (event.clipboardData && !app.inputs.shiftKey) {
|
|
handlePasteFromEventClipboardData(app, event.clipboardData)
|
|
} else {
|
|
// Or else use the clipboard API
|
|
navigator.clipboard.read().then((clipboardItems) => {
|
|
if (Array.isArray(clipboardItems) && clipboardItems[0] instanceof ClipboardItem) {
|
|
handlePasteFromClipboardApi(app, clipboardItems, app.inputs.currentPagePoint)
|
|
}
|
|
})
|
|
}
|
|
|
|
trackEvent('paste', { source: 'kbd' })
|
|
}
|
|
|
|
document.addEventListener('copy', copy)
|
|
document.addEventListener('cut', cut)
|
|
document.addEventListener('paste', paste)
|
|
document.addEventListener('pointerup', pointerUpHandler)
|
|
|
|
return () => {
|
|
document.removeEventListener('copy', copy)
|
|
document.removeEventListener('cut', cut)
|
|
document.removeEventListener('paste', paste)
|
|
document.removeEventListener('pointerup', pointerUpHandler)
|
|
}
|
|
}, [app, trackEvent, appIsFocused])
|
|
}
|