kopia lustrzana https://github.com/Tldraw/Tldraw
1071 wiersze
27 KiB
TypeScript
1071 wiersze
27 KiB
TypeScript
import {
|
|
App,
|
|
createAssetShapeAtPoint,
|
|
createBookmarkShapeAtPoint,
|
|
createEmbedShapeAtPoint,
|
|
createShapeId,
|
|
createShapesFromFiles,
|
|
FONT_FAMILIES,
|
|
FONT_SIZES,
|
|
getEmbedInfo,
|
|
getIndexAbove,
|
|
getIndices,
|
|
getValidHttpURLList,
|
|
isShapeId,
|
|
isSvgText,
|
|
isValidHttpURL,
|
|
TEXT_PROPS,
|
|
TLAlignType,
|
|
TLArrowheadType,
|
|
TLArrowShapeDef,
|
|
TLAsset,
|
|
TLAssetId,
|
|
TLBookmarkShapeDef,
|
|
TLClipboardModel,
|
|
TLColorType,
|
|
TLDashType,
|
|
TLEmbedShapeDef,
|
|
TLFillType,
|
|
TLFontType,
|
|
TLGeoShapeDef,
|
|
TLOpacityType,
|
|
TLShapeId,
|
|
TLSizeType,
|
|
TLTextShapeDef,
|
|
uniqueId,
|
|
useApp,
|
|
} from '@tldraw/editor'
|
|
import { Box2d, Vec2d, VecLike } from '@tldraw/primitives'
|
|
import { compact, isNonNull } from '@tldraw/utils'
|
|
import { compressToBase64, decompressFromBase64 } from 'lz-string'
|
|
import { useCallback, useEffect } from 'react'
|
|
import { useAppIsFocused } from './useAppIsFocused'
|
|
import { useEvents } from './useEventsProvider'
|
|
|
|
/** @public */
|
|
export type EmbedInfo = {
|
|
width: number
|
|
height: number
|
|
doesResize: boolean
|
|
isEmbedUrl: (url: string) => boolean
|
|
toEmbed: (url: string) => string
|
|
}
|
|
|
|
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)
|
|
})
|
|
}
|
|
|
|
async function dataTransferItemAsString(item: DataTransferItem) {
|
|
return new Promise<string>((resolve) => {
|
|
item.getAsString((text) => {
|
|
resolve(text)
|
|
})
|
|
})
|
|
}
|
|
|
|
const INPUTS = ['input', 'select', 'textarea']
|
|
|
|
function disallowClipboardEvents(app: App) {
|
|
const { activeElement } = document
|
|
return (
|
|
app.isMenuOpen ||
|
|
(activeElement &&
|
|
(activeElement.getAttribute('contenteditable') ||
|
|
INPUTS.indexOf(activeElement.tagName.toLowerCase()) > -1))
|
|
)
|
|
}
|
|
|
|
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
|
|
return doc.body.textContent || doc.body.innerText || ''
|
|
}
|
|
|
|
// Clear the clipboard when the user copies nothing
|
|
const clearPersistedClipboard = () => {
|
|
window.navigator.clipboard.writeText('')
|
|
}
|
|
|
|
/**
|
|
* Write serialized data to the local storage.
|
|
*
|
|
* @param data - The string to write.
|
|
* @param kind - The kind of data to write.
|
|
* @internal
|
|
*/
|
|
const getStringifiedClipboard = (data: any, kind: 'text' | 'file' | 'content') => {
|
|
const s = compressToBase64(
|
|
JSON.stringify({
|
|
type: 'application/tldraw',
|
|
kind,
|
|
data,
|
|
})
|
|
)
|
|
|
|
return s
|
|
}
|
|
|
|
/**
|
|
* When the clipboard has tldraw content, paste it into the scene.
|
|
*
|
|
* @param clipboard - The clipboard model.
|
|
* @param point - The center point at which to paste the content.
|
|
* @internal
|
|
*/
|
|
const pasteTldrawContent = async (app: App, clipboard: TLClipboardModel, point?: VecLike) => {
|
|
const p = point ?? (app.inputs.shiftKey ? app.inputs.currentPagePoint : undefined)
|
|
|
|
app.mark('paste')
|
|
app.putContent(clipboard, {
|
|
point: p,
|
|
select: true,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* When the clipboard has plain text, create a text shape and insert it into the scene
|
|
*
|
|
* @param text - The text to paste.
|
|
* @param point - The point at which to paste the text.
|
|
* @internal
|
|
*/
|
|
const pastePlainText = async (app: App, text: string, point?: VecLike) => {
|
|
const p = point ?? (app.inputs.shiftKey ? app.inputs.currentPagePoint : app.viewportPageCenter)
|
|
const defaultProps = app.getShapeUtilByDef(TLTextShapeDef).defaultProps()
|
|
|
|
// Measure the text with default values
|
|
const { w, h } = app.textMeasure.measureText({
|
|
...TEXT_PROPS,
|
|
text: stripHtml(text),
|
|
fontFamily: FONT_FAMILIES[defaultProps.font],
|
|
fontSize: FONT_SIZES[defaultProps.size],
|
|
width: 'fit-content',
|
|
})
|
|
|
|
app.mark('paste')
|
|
app.createShapes([
|
|
{
|
|
id: createShapeId(),
|
|
type: 'text',
|
|
x: p.x - w / 2,
|
|
y: p.y - h / 2,
|
|
props: {
|
|
text: stripHtml(text),
|
|
autoSize: true,
|
|
},
|
|
},
|
|
])
|
|
}
|
|
|
|
/**
|
|
* When the clipboard has plain text that is a valid URL, create a bookmark shape and insert it into
|
|
* the scene
|
|
*
|
|
* @param url - The URL to paste.
|
|
* @param point - The point at which to paste the file.
|
|
* @internal
|
|
*/
|
|
const pasteUrl = async (app: App, url: string, point?: VecLike) => {
|
|
const p = point ?? (app.inputs.shiftKey ? app.inputs.currentPagePoint : app.viewportPageCenter)
|
|
|
|
// Lets see if its an image and we have CORs
|
|
try {
|
|
const resp = await fetch(url)
|
|
if (resp.headers.get('content-type')?.match(/^image\//)) {
|
|
app.mark('paste')
|
|
pasteFiles(app, [url])
|
|
return
|
|
}
|
|
} catch (err: any) {
|
|
if (err.message !== 'Failed to fetch') {
|
|
console.error(err)
|
|
}
|
|
}
|
|
|
|
const embedInfo = getEmbedInfo(url)
|
|
|
|
if (embedInfo) {
|
|
app.mark('paste')
|
|
createEmbedShapeAtPoint(app, embedInfo.url, p, embedInfo.definition)
|
|
} else {
|
|
app.mark('paste')
|
|
await createBookmarkShapeAtPoint(app, url, p)
|
|
}
|
|
}
|
|
|
|
const pasteSvgText = async (app: App, text: string, point?: VecLike) => {
|
|
const p = point ?? (app.inputs.shiftKey ? app.inputs.currentPagePoint : app.viewportPageCenter)
|
|
|
|
app.mark('paste')
|
|
await createAssetShapeAtPoint(app, text, p)
|
|
}
|
|
|
|
/**
|
|
* When the clipboard has a file, create an image shape from the file and paste it into the scene
|
|
*
|
|
* @param url - The file's url.
|
|
* @param point - The point at which to paste the file.
|
|
* @internal
|
|
*/
|
|
const pasteFiles = async (app: App, urls: string[], point?: VecLike) => {
|
|
const p = point ?? (app.inputs.shiftKey ? app.inputs.currentPagePoint : app.viewportPageCenter)
|
|
|
|
const blobs = await Promise.all(urls.map(async (url) => await (await fetch(url)).blob()))
|
|
|
|
const files = blobs.map(
|
|
(blob) =>
|
|
new File([blob], 'tldrawFile', {
|
|
type: blob.type,
|
|
})
|
|
)
|
|
|
|
app.mark('paste')
|
|
await createShapesFromFiles(app, files, p, false)
|
|
|
|
urls.forEach((url) => URL.revokeObjectURL(url))
|
|
}
|
|
|
|
/**
|
|
* When the user copies, write the contents to local storage and to the clipboard
|
|
*
|
|
* @param app - App
|
|
* @public
|
|
*/
|
|
const handleMenuCopy = (app: App) => {
|
|
const content = app.getContent()
|
|
if (!content) {
|
|
clearPersistedClipboard()
|
|
return
|
|
}
|
|
|
|
const stringifiedClipboard = getStringifiedClipboard(content, 'content')
|
|
|
|
if (typeof window?.navigator !== 'undefined') {
|
|
// Extract the text from the clipboard
|
|
const textItems = content.shapes
|
|
.map((shape) => {
|
|
if (TLTextShapeDef.is(shape) || TLGeoShapeDef.is(shape) || TLArrowShapeDef.is(shape)) {
|
|
return shape.props.text
|
|
}
|
|
if (TLBookmarkShapeDef.is(shape) || TLEmbedShapeDef.is(shape)) {
|
|
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,
|
|
'text/plain': new Blob([textContent], { type: 'text/plain' }),
|
|
}),
|
|
])
|
|
} else if (navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(`<tldraw>${stringifiedClipboard}</tldraw>`)
|
|
}
|
|
}
|
|
}
|
|
|
|
const pasteText = (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)
|
|
}
|
|
}
|
|
|
|
async function pasteExcalidrawContent(app: App, clipboard: any, point?: VecLike) {
|
|
const { elements, files } = clipboard
|
|
|
|
const tldrawContent: TLClipboardModel = {
|
|
shapes: [],
|
|
rootShapeIds: [],
|
|
assets: [],
|
|
schema: app.store.schema.serialize(),
|
|
}
|
|
|
|
const groupShapeIdToChildren = new Map<string, TLShapeId[]>()
|
|
const rotatedElements = new Map<TLShapeId, number>()
|
|
|
|
const getOpacity = (opacity: number): TLOpacityType => {
|
|
const t = opacity / 100
|
|
if (t < 0.2) {
|
|
return '0.1'
|
|
} else if (t < 0.4) {
|
|
return '0.25'
|
|
} else if (t < 0.6) {
|
|
return '0.5'
|
|
} else if (t < 0.8) {
|
|
return '0.75'
|
|
}
|
|
|
|
return '1'
|
|
}
|
|
|
|
const strokeWidthsToSizes: Record<number, TLSizeType> = {
|
|
1: 's',
|
|
2: 'm',
|
|
3: 'l',
|
|
4: 'xl',
|
|
}
|
|
|
|
const fontSizesToSizes: Record<number, TLSizeType> = {
|
|
16: 's',
|
|
20: 'm',
|
|
28: 'l',
|
|
36: 'xl',
|
|
}
|
|
|
|
function getFontSizeAndScale(fontSize: number): { size: TLSizeType; scale: number } {
|
|
const size = fontSizesToSizes[fontSize]
|
|
if (size) {
|
|
return { size, scale: 1 }
|
|
}
|
|
if (fontSize < 16) {
|
|
return { size: 's', scale: fontSize / 16 }
|
|
}
|
|
if (fontSize > 36) {
|
|
return { size: 'xl', scale: fontSize / 36 }
|
|
}
|
|
return { size: 'm', scale: 1 }
|
|
}
|
|
|
|
const fontFamilyToFontType: Record<number, TLFontType> = {
|
|
1: 'draw',
|
|
2: 'sans',
|
|
3: 'mono',
|
|
}
|
|
|
|
const colorsToColors: Record<string, TLColorType> = {
|
|
'#ffffff': 'grey',
|
|
// Strokes
|
|
'#000000': 'black',
|
|
'#343a40': 'grey',
|
|
'#495057': 'grey',
|
|
'#c92a2a': 'red',
|
|
'#a61e4d': 'light-red',
|
|
'#862e9c': 'violet',
|
|
'#5f3dc4': 'light-violet',
|
|
'#364fc7': 'blue',
|
|
'#1864ab': 'light-blue',
|
|
'#0b7285': 'light-green',
|
|
'#087f5b': 'light-green',
|
|
'#2b8a3e': 'green',
|
|
'#5c940d': 'light-green',
|
|
'#e67700': 'yellow',
|
|
'#d9480f': 'orange',
|
|
// Backgrounds
|
|
'#ced4da': 'grey',
|
|
'#868e96': 'grey',
|
|
'#fa5252': 'light-red',
|
|
'#e64980': 'red',
|
|
'#be4bdb': 'light-violet',
|
|
'#7950f2': 'violet',
|
|
'#4c6ef5': 'blue',
|
|
'#228be6': 'light-blue',
|
|
'#15aabf': 'light-green',
|
|
'#12b886': 'green',
|
|
'#40c057': 'green',
|
|
'#82c91e': 'light-green',
|
|
'#fab005': 'yellow',
|
|
'#fd7e14': 'orange',
|
|
'#212529': 'grey',
|
|
}
|
|
|
|
const strokeStylesToStrokeTypes: Record<string, TLDashType> = {
|
|
solid: 'draw',
|
|
dashed: 'dashed',
|
|
dotted: 'dotted',
|
|
}
|
|
|
|
const fillStylesToFillType: Record<string, TLFillType> = {
|
|
'cross-hatch': 'pattern',
|
|
hachure: 'pattern',
|
|
solid: 'solid',
|
|
}
|
|
|
|
const textAlignToAlignTypes: Record<string, TLAlignType> = {
|
|
left: 'start',
|
|
center: 'middle',
|
|
right: 'end',
|
|
}
|
|
|
|
const arrowheadsToArrowheadTypes: Record<string, TLArrowheadType> = {
|
|
arrow: 'arrow',
|
|
dot: 'dot',
|
|
triangle: 'triangle',
|
|
bar: 'pipe',
|
|
}
|
|
|
|
function getBend(element: any, startPoint: any, endPoint: any) {
|
|
let bend = 0
|
|
if (element.points.length > 2) {
|
|
const start = new Vec2d(startPoint[0], startPoint[1])
|
|
const end = new Vec2d(endPoint[0], endPoint[1])
|
|
const handle = new Vec2d(element.points[1][0], element.points[1][1])
|
|
const delta = Vec2d.Sub(end, start)
|
|
const v = Vec2d.Per(delta)
|
|
|
|
const med = Vec2d.Med(end, start)
|
|
const A = Vec2d.Sub(med, v)
|
|
const B = Vec2d.Add(med, v)
|
|
|
|
const point = Vec2d.NearestPointOnLineSegment(A, B, handle, false)
|
|
bend = Vec2d.Dist(point, med)
|
|
if (Vec2d.Clockwise(point, end, med)) bend *= -1
|
|
}
|
|
return bend
|
|
}
|
|
|
|
const getDash = (element: any): TLDashType => {
|
|
let dash: TLDashType = strokeStylesToStrokeTypes[element.strokeStyle] ?? 'draw'
|
|
if (dash === 'draw' && element.roughness === 0) {
|
|
dash = 'solid'
|
|
}
|
|
return dash
|
|
}
|
|
|
|
const getFill = (element: any): TLFillType => {
|
|
if (element.backgroundColor === 'transparent') {
|
|
return 'none'
|
|
}
|
|
return fillStylesToFillType[element.fillStyle] ?? 'solid'
|
|
}
|
|
|
|
const { currentPageId } = app
|
|
|
|
let index = 'a1'
|
|
|
|
const excElementIdsToTldrawShapeIds = new Map<string, TLShapeId>()
|
|
const rootShapeIds: TLShapeId[] = []
|
|
|
|
const skipIds = new Set<string>()
|
|
|
|
elements.forEach((element: any) => {
|
|
excElementIdsToTldrawShapeIds.set(element.id, app.createShapeId())
|
|
|
|
if (element.boundElements !== null) {
|
|
for (const boundElement of element.boundElements) {
|
|
if (boundElement.type === 'text') {
|
|
skipIds.add(boundElement.id)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
for (const element of elements) {
|
|
if (skipIds.has(element.id)) {
|
|
continue
|
|
}
|
|
|
|
const id = excElementIdsToTldrawShapeIds.get(element.id)!
|
|
|
|
const base = {
|
|
id,
|
|
typeName: 'shape',
|
|
parentId: currentPageId,
|
|
index,
|
|
x: element.x,
|
|
y: element.y,
|
|
rotation: 0,
|
|
isLocked: element.locked,
|
|
} as const
|
|
|
|
if (element.angle !== 0) {
|
|
rotatedElements.set(id, element.angle)
|
|
}
|
|
|
|
if (element.groupIds && element.groupIds.length > 0) {
|
|
if (groupShapeIdToChildren.has(element.groupIds[0])) {
|
|
groupShapeIdToChildren.get(element.groupIds[0])?.push(id)
|
|
} else {
|
|
groupShapeIdToChildren.set(element.groupIds[0], [id])
|
|
}
|
|
} else {
|
|
rootShapeIds.push(id)
|
|
}
|
|
|
|
switch (element.type) {
|
|
case 'rectangle':
|
|
case 'ellipse':
|
|
case 'diamond': {
|
|
let text = ''
|
|
let align: TLAlignType = 'middle'
|
|
|
|
if (element.boundElements !== null) {
|
|
for (const boundElement of element.boundElements) {
|
|
if (boundElement.type === 'text') {
|
|
const labelElement = elements.find((elm: any) => elm.id === boundElement.id)
|
|
if (labelElement) {
|
|
text = labelElement.text
|
|
align = textAlignToAlignTypes[labelElement.textAlign]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const colorToUse =
|
|
element.backgroundColor === 'transparent' ? element.strokeColor : element.backgroundColor
|
|
|
|
tldrawContent.shapes.push({
|
|
...base,
|
|
type: 'geo',
|
|
props: {
|
|
geo: element.type,
|
|
opacity: getOpacity(element.opacity),
|
|
url: element.link ?? '',
|
|
w: element.width,
|
|
h: element.height,
|
|
size: strokeWidthsToSizes[element.strokeWidth] ?? 'draw',
|
|
color: colorsToColors[colorToUse] ?? 'black',
|
|
text,
|
|
align,
|
|
dash: getDash(element),
|
|
fill: getFill(element),
|
|
},
|
|
})
|
|
break
|
|
}
|
|
case 'freedraw': {
|
|
tldrawContent.shapes.push({
|
|
...base,
|
|
type: 'draw',
|
|
props: {
|
|
dash: getDash(element),
|
|
size: strokeWidthsToSizes[element.strokeWidth],
|
|
opacity: getOpacity(element.opacity),
|
|
color: colorsToColors[element.strokeColor] ?? 'black',
|
|
segments: [
|
|
{
|
|
type: 'free',
|
|
points: element.points.map(([x, y, z = 0.5]: number[]) => ({
|
|
x,
|
|
y,
|
|
z,
|
|
})),
|
|
},
|
|
],
|
|
},
|
|
})
|
|
break
|
|
}
|
|
case 'line': {
|
|
const start = element.points[0]
|
|
const end = element.points[element.points.length - 1]
|
|
const indices = getIndices(element.points.length)
|
|
|
|
tldrawContent.shapes.push({
|
|
...base,
|
|
type: 'line',
|
|
props: {
|
|
dash: getDash(element),
|
|
size: strokeWidthsToSizes[element.strokeWidth],
|
|
opacity: getOpacity(element.opacity),
|
|
color: colorsToColors[element.strokeColor] ?? 'black',
|
|
spline: element.roundness ? 'cubic' : 'line',
|
|
handles: {
|
|
start: {
|
|
id: 'start',
|
|
type: 'vertex',
|
|
index: indices[0],
|
|
x: start[0],
|
|
y: start[1],
|
|
},
|
|
end: {
|
|
id: 'end',
|
|
type: 'vertex',
|
|
index: indices[indices.length - 1],
|
|
x: end[0],
|
|
y: end[1],
|
|
},
|
|
...Object.fromEntries(
|
|
element.points.slice(1, -1).map(([x, y]: number[], i: number) => {
|
|
const id = uniqueId()
|
|
return [
|
|
id,
|
|
{
|
|
id,
|
|
type: 'vertex',
|
|
index: indices[i + 1],
|
|
x,
|
|
y,
|
|
},
|
|
]
|
|
})
|
|
),
|
|
},
|
|
},
|
|
})
|
|
break
|
|
}
|
|
case 'arrow': {
|
|
let text = ''
|
|
|
|
if (element.boundElements !== null) {
|
|
for (const boundElement of element.boundElements) {
|
|
if (boundElement.type === 'text') {
|
|
const labelElement = elements.find((elm: any) => elm.id === boundElement.id)
|
|
if (labelElement) {
|
|
text = labelElement.text
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const start = element.points[0]
|
|
const end = element.points[element.points.length - 1]
|
|
|
|
const startTargetId = excElementIdsToTldrawShapeIds.get(element.startBinding?.elementId)
|
|
const endTargetId = excElementIdsToTldrawShapeIds.get(element.endBinding?.elementId)
|
|
|
|
tldrawContent.shapes.push({
|
|
...base,
|
|
type: 'arrow',
|
|
props: {
|
|
text,
|
|
bend: getBend(element, start, end),
|
|
dash: getDash(element),
|
|
opacity: getOpacity(element.opacity),
|
|
size: strokeWidthsToSizes[element.strokeWidth] ?? 'm',
|
|
color: colorsToColors[element.strokeColor] ?? 'black',
|
|
start: startTargetId
|
|
? {
|
|
type: 'binding',
|
|
boundShapeId: startTargetId,
|
|
normalizedAnchor: { x: 0.5, y: 0.5 },
|
|
isExact: false,
|
|
}
|
|
: {
|
|
type: 'point',
|
|
x: start[0],
|
|
y: start[1],
|
|
},
|
|
end: endTargetId
|
|
? {
|
|
type: 'binding',
|
|
boundShapeId: endTargetId,
|
|
normalizedAnchor: { x: 0.5, y: 0.5 },
|
|
isExact: false,
|
|
}
|
|
: {
|
|
type: 'point',
|
|
x: end[0],
|
|
y: end[1],
|
|
},
|
|
arrowheadEnd: arrowheadsToArrowheadTypes[element.endArrowhead] ?? 'none',
|
|
arrowheadStart: arrowheadsToArrowheadTypes[element.startArrowhead] ?? 'none',
|
|
},
|
|
})
|
|
break
|
|
}
|
|
case 'text': {
|
|
const { size, scale } = getFontSizeAndScale(element.fontSize)
|
|
|
|
tldrawContent.shapes.push({
|
|
...base,
|
|
type: 'text',
|
|
props: {
|
|
size,
|
|
scale,
|
|
font: fontFamilyToFontType[element.fontFamily] ?? 'draw',
|
|
opacity: getOpacity(element.opacity),
|
|
color: colorsToColors[element.strokeColor] ?? 'black',
|
|
text: element.text,
|
|
align: textAlignToAlignTypes[element.textAlign],
|
|
},
|
|
})
|
|
break
|
|
}
|
|
case 'image': {
|
|
const file = files[element.fileId]
|
|
if (!file) break
|
|
|
|
const assetId: TLAssetId = TLAsset.createId()
|
|
tldrawContent.assets.push({
|
|
id: assetId,
|
|
typeName: 'asset',
|
|
type: 'image',
|
|
props: {
|
|
w: element.width,
|
|
h: element.height,
|
|
name: element.id ?? 'Untitled',
|
|
isAnimated: false,
|
|
mimeType: file.mimeType,
|
|
src: file.dataURL,
|
|
},
|
|
})
|
|
|
|
tldrawContent.shapes.push({
|
|
...base,
|
|
type: 'image',
|
|
props: {
|
|
opacity: getOpacity(element.opacity),
|
|
w: element.width,
|
|
h: element.height,
|
|
assetId,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
index = getIndexAbove(index)
|
|
}
|
|
|
|
const p = point ?? (app.inputs.shiftKey ? app.inputs.currentPagePoint : undefined)
|
|
|
|
app.mark('paste')
|
|
|
|
app.putContent(tldrawContent, {
|
|
point: p,
|
|
select: false,
|
|
preserveIds: true,
|
|
})
|
|
for (const groupedShapeIds of groupShapeIdToChildren.values()) {
|
|
if (groupedShapeIds.length > 1) {
|
|
app.groupShapes(groupedShapeIds)
|
|
const groupShape = app.getShapeById(groupedShapeIds[0])
|
|
if (groupShape?.parentId && isShapeId(groupShape.parentId)) {
|
|
rootShapeIds.push(groupShape.parentId)
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const [id, angle] of rotatedElements) {
|
|
app.select(id)
|
|
app.rotateShapesBy([id], angle)
|
|
}
|
|
|
|
const rootShapes = compact(rootShapeIds.map((id) => app.getShapeById(id)))
|
|
const bounds = Box2d.Common(rootShapes.map((s) => app.getPageBounds(s)!))
|
|
const viewPortCenter = app.viewportPageBounds.center
|
|
app.updateShapes(
|
|
rootShapes.map((s) => {
|
|
const delta = {
|
|
x: (s.x ?? 0) - (bounds.x + bounds.w / 2),
|
|
y: (s.y ?? 0) - (bounds.y + bounds.h / 2),
|
|
}
|
|
|
|
return {
|
|
id: s.id,
|
|
type: s.type,
|
|
x: viewPortCenter.x + delta.x,
|
|
y: viewPortCenter.y + delta.y,
|
|
}
|
|
})
|
|
)
|
|
app.setSelectedIds(rootShapeIds)
|
|
}
|
|
|
|
const handleFilesBlob = async (app: App, blobs: Blob[], point?: VecLike) => {
|
|
const urls = blobs.map((blob) => URL.createObjectURL(blob))
|
|
|
|
pasteFiles(app, urls, point)
|
|
}
|
|
|
|
const handleHtmlString = async (app: App, html: string, point?: VecLike) => {
|
|
const s = html.match(/<tldraw[^>]*>(.*)<\/tldraw>/)?.[1]
|
|
if (s) {
|
|
try {
|
|
const json = JSON.parse(decompressFromBase64(s)!)
|
|
if (json.type === 'application/tldraw') {
|
|
pasteTldrawContent(app, json.data, point)
|
|
} else {
|
|
pasteText(app, s, point)
|
|
}
|
|
} catch (error) {
|
|
pasteText(app, s, point)
|
|
}
|
|
} else {
|
|
const rootNode = new DOMParser().parseFromString(html, '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')!
|
|
pasteText(app, href, point)
|
|
} else {
|
|
pasteText(app, html, point)
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleTextString = async (app: App, text: string, point?: VecLike) => {
|
|
const s = text.trim()
|
|
|
|
const tldrawContent = text.match(/<tldraw[^>]*>(.*)<\/tldraw>/)?.[1]
|
|
if (tldrawContent) {
|
|
handleHtmlString(app, text)
|
|
} else if (s) {
|
|
try {
|
|
const json = JSON.parse(s)
|
|
if (json.type === 'application/tldraw') {
|
|
pasteTldrawContent(app, json.data, point)
|
|
} else if (json.type === 'excalidraw/clipboard') {
|
|
pasteExcalidrawContent(app, json, point)
|
|
} else {
|
|
pasteText(app, s, point)
|
|
}
|
|
} catch (error) {
|
|
pasteText(app, s, point)
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleNativeDataTransferPaste = async (
|
|
app: App,
|
|
clipboardData: DataTransfer,
|
|
point?: VecLike
|
|
) => {
|
|
// Do not paste while in any editing state
|
|
if (app.isIn('select.editing')) return
|
|
|
|
if (clipboardData) {
|
|
const items = Object.values(clipboardData.items)
|
|
|
|
// In some cases, the clipboard will contain both the name of a file and the file itself
|
|
// we need to avoid writing a text shape for the name AND an image or video shape for the file
|
|
const writingFile = items.some((item) => item.kind === 'file')
|
|
|
|
// If we're pasting in tldraw content (shapes, etc) then the clipboard may
|
|
// contain both text content. We'll only paste the content.
|
|
const writingContent = items.some((item) => item.type === 'text/html')
|
|
|
|
// 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: Blob[] = []
|
|
const text: DataTransferItem[] = []
|
|
|
|
items.forEach((item) => {
|
|
if (item.kind === 'file') {
|
|
const file = item.getAsFile()
|
|
if (file) {
|
|
files.push(file)
|
|
}
|
|
} else if (item.kind === 'string') {
|
|
text.push(item)
|
|
}
|
|
})
|
|
|
|
if (files.length > 0) {
|
|
handleFilesBlob(app, files, point)
|
|
}
|
|
|
|
for (const item of text) {
|
|
if (!writingFile && item.type === 'text/html') {
|
|
await handleHtmlString(app, await dataTransferItemAsString(item), point)
|
|
} else if (item.type === 'text/plain') {
|
|
if (!writingContent) {
|
|
await handleTextString(app, await dataTransferItemAsString(item), point)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleNativeClipboardPaste = async (
|
|
app: App,
|
|
clipboardItems: ClipboardItem[],
|
|
point?: VecLike
|
|
) => {
|
|
// Do not paste while in any editing state
|
|
if (app.isIn('select.editing')) return
|
|
|
|
const isFile = (item: ClipboardItem) => {
|
|
return item.types.find((i) => i.match(/^image\//))
|
|
}
|
|
|
|
// In some cases, the clipboard will contain both the name of a file and the file itself
|
|
// we need to avoid writing a text shape for the name AND an image or video shape for the file
|
|
const writingFile = clipboardItems.some((item) => isFile(item))
|
|
|
|
// If we're pasting in tldraw content (shapes, etc) then the clipboard may
|
|
// contain both text content. We'll only paste the content.
|
|
const writingContent = clipboardItems.some((item) => item.types.includes('text/html'))
|
|
|
|
// 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: ClipboardItem[] = clipboardItems.filter((item) => {
|
|
if (item.types.find((i) => i.match(/^image\//))) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
})
|
|
|
|
await Promise.all(
|
|
files.map(async (item) => {
|
|
const type = item.types.find((t) => t !== 'text/plain' && t !== 'text/html')
|
|
if (type) {
|
|
const file = await item.getType(type)
|
|
if (file) {
|
|
await handleFilesBlob(app, [file], point)
|
|
}
|
|
}
|
|
})
|
|
)
|
|
|
|
for (const item of clipboardItems) {
|
|
if (item.types.includes('text/html')) {
|
|
if (writingFile) break
|
|
|
|
const blob = await item.getType('text/html')
|
|
await handleHtmlString(app, await blobAsString(blob), point)
|
|
} else if (item.types.includes('text/uri-list')) {
|
|
if (writingContent) break
|
|
|
|
const blob = await item.getType('text/uri-list')
|
|
await pasteUrl(app, await blobAsString(blob), point)
|
|
} else if (item.types.includes('text/plain')) {
|
|
if (writingContent) break
|
|
|
|
const blob = await item.getType('text/plain')
|
|
await handleTextString(app, await blobAsString(blob), point)
|
|
}
|
|
}
|
|
}
|
|
|
|
/** @public */
|
|
export function useMenuClipboardEvents() {
|
|
const app = useApp()
|
|
const trackEvent = useEvents()
|
|
|
|
const copy = useCallback(
|
|
function onCopy() {
|
|
if (app.selectedIds.length === 0) return
|
|
|
|
handleMenuCopy(app)
|
|
trackEvent('menu', 'copy')
|
|
},
|
|
[app, trackEvent]
|
|
)
|
|
|
|
const cut = useCallback(
|
|
function onCut() {
|
|
if (app.selectedIds.length === 0) return
|
|
|
|
handleMenuCopy(app)
|
|
app.deleteShapes()
|
|
trackEvent('menu', 'cut')
|
|
},
|
|
[app, trackEvent]
|
|
)
|
|
|
|
const paste = useCallback(
|
|
async function onPaste(data: DataTransfer | ClipboardItem[], point?: VecLike) {
|
|
if (Array.isArray(data) && data[0] instanceof ClipboardItem) {
|
|
handleNativeClipboardPaste(app, data, point)
|
|
} else {
|
|
navigator.clipboard.read().then((clipboardItems) => {
|
|
paste(clipboardItems, app.inputs.currentPagePoint)
|
|
})
|
|
}
|
|
|
|
// else {
|
|
// handleScenePaste(app, point)
|
|
// }
|
|
|
|
trackEvent('menu', 'paste')
|
|
},
|
|
[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
|
|
handleMenuCopy(app)
|
|
trackEvent('kbd', 'copy')
|
|
}
|
|
|
|
function cut() {
|
|
if (app.selectedIds.length === 0 || app.editingId !== null || disallowClipboardEvents(app))
|
|
return
|
|
handleMenuCopy(app)
|
|
app.deleteShapes()
|
|
trackEvent('kbd', 'cut')
|
|
}
|
|
|
|
const paste = (e: ClipboardEvent) => {
|
|
if (app.editingId !== null || disallowClipboardEvents(app)) return
|
|
if (e.clipboardData && !app.inputs.shiftKey) {
|
|
handleNativeDataTransferPaste(app, e.clipboardData)
|
|
} else {
|
|
navigator.clipboard.read().then((clipboardItems) => {
|
|
if (Array.isArray(clipboardItems) && clipboardItems[0] instanceof ClipboardItem) {
|
|
handleNativeClipboardPaste(app, clipboardItems, app.inputs.currentPagePoint)
|
|
}
|
|
})
|
|
}
|
|
trackEvent('kbd', 'paste')
|
|
}
|
|
|
|
document.addEventListener('copy', copy)
|
|
document.addEventListener('cut', cut)
|
|
document.addEventListener('paste', paste)
|
|
|
|
return () => {
|
|
document.removeEventListener('copy', copy)
|
|
document.removeEventListener('cut', cut)
|
|
document.removeEventListener('paste', paste)
|
|
}
|
|
}, [app, trackEvent, appIsFocused])
|
|
}
|