[improvement] refactor paste to support multi-line text (#1398)

This PR refactors our clipboard handlers. It should simplify the way
that things work and better handle the difference between how the native
API events are handled vs. the browser's clipboard API events.

![Kapture 2023-05-17 at 13 26
34](https://github.com/tldraw/tldraw/assets/23072548/5dedcc25-a1d2-423f-8bc2-415f761b643b)

Everything that used to be supported now also still works.

In addition, we now have several useful features:

### Multiline text can be pasted into the app

When pasting text that contains more than one line, the text is pasted
correctly; even if the clipboard also includes HTML data. Previously, we
would try to paste HTML data if we found it, because that data might
contain tldraw's own content as a comment; but if that failed, we would
paste the data as text instead. This led to pasting text that lost lots
of information from that text, such as line breaks and indentations.

### Multiline text shapes are aligned correctly

When pasting raw text that has more than one line, the text will be left
aligned—or right aligned if the text is likely from a RTL language.

![Kapture 2023-05-17 at 13 42
54](https://github.com/tldraw/tldraw/assets/23072548/f705acd5-136c-4144-80da-6e97ff766a58)

### Common minimum indentation is removed from each line

![Kapture 2023-05-17 at 13 56
28](https://github.com/tldraw/tldraw/assets/23072548/d45c95f6-6d28-4c9f-8cd3-8078700ce928)

This is something that absolutely every app should implement, but here
we go. When multiline text has "common indentation" on each line, which
is often the case when pasting text from code, then that indentation is
removed from each line.

### Auto wrapping for big pastes

When a line has no text breaks but a lot of text, we now set the width
of the text shape.

![Kapture 2023-05-17 at 14 00
04](https://github.com/tldraw/tldraw/assets/23072548/0b7f69c3-bcf9-42e9-a1ed-df026f868793)

## How it works

A `ClipboardThing` is the common interface for things that we found on
the clipboard, native or otherwise. Both `handlePasteFromClipboardApi`
and `handlePasteFromEventClipboardData` parse out `ClipboardThing`s and
pass them to `handleClipboardThings`.

<img width="905" alt="image"
src="https://github.com/tldraw/tldraw/assets/23072548/fd087539-edbb-4527-b5ff-ca7d7c1726b2">

A `ClipboardResult` is the result of processing a `ClipboardThing`, and
usually contains text and other information about that text. We make
decisions on what to create based on which `ClipboardResult`s we find.

When pasting text, we check to see whether the result would be bigger
than the viewport, or if the text is multiline, or if the text is of an
RTL language by testing certain common RTL characters. We make some
adjustments based on those factors, ensuring that the top-left corner of
the text is on screen and reasonably positioned within the viewport if
possible.

### Change Type

- [x] `minor` — New Feature

### Test Plan

1. Copy and paste shapes
2. Copy and paste text from elsewhere into the app
3. Copy and paste images from elsewhere into the app
4. Try on different browsers

### Release Notes

- Improves clipboard logic when pasting text
- Adds support for pasting multi-line text
- Adds maximum widths when pasting single-line text
- Adds support for RTL languages when pasting multi-line or wrapped text
- Strips leading indentation when pasting text
This commit is contained in:
Steve Ruiz 2023-05-17 17:32:25 +01:00 committed by GitHub
parent ad5a23e135
commit a1e72014c6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 1229 additions and 900 deletions

View file

@ -879,6 +879,9 @@ export type HTMLContainerProps = React_2.HTMLAttributes<HTMLDivElement>;
// @public (undocumented)
export const ICON_SIZES: Record<TLSizeType, number>;
// @public (undocumented)
export const INDENT = " ";
// @public (undocumented)
export function indexGenerator(n?: number): Generator<string, void, unknown>;

View file

@ -61,7 +61,7 @@ export {
type TLShapeUtilConstructor,
type TLShapeUtilFlag,
} from './lib/app/shapeutils/TLShapeUtil'
export { TLTextShapeDef, TLTextUtil } from './lib/app/shapeutils/TLTextUtil/TLTextUtil'
export { INDENT, TLTextShapeDef, TLTextUtil } from './lib/app/shapeutils/TLTextUtil/TLTextUtil'
export { TLVideoShapeDef, TLVideoUtil } from './lib/app/shapeutils/TLVideoUtil/TLVideoUtil'
export { StateNode, type StateNodeConstructor } from './lib/app/statechart/StateNode'
export { TLBoxTool, type TLBoxLike } from './lib/app/statechart/TLBoxTool/TLBoxTool'

View file

@ -1,7 +1,7 @@
import { Box2dModel, TLAlignType } from '@tldraw/tlschema'
import { uniqueId } from '../../utils/data'
import { App } from '../App'
import { TextHelpers } from '../shapeutils/TLTextUtil/TextHelpers'
import { INDENT, TextHelpers } from '../shapeutils/TLTextUtil/TextHelpers'
// const wordSeparator = new RegExp(
// `${[0x0020, 0x00a0, 0x1361, 0x10100, 0x10101, 0x1039, 0x1091]
@ -103,7 +103,7 @@ export class TextManager {
// Remove any empty strings
.filter(Boolean)
// Replacing the tabs with double spaces again.
.map((str) => (str === '\t' ? ' ' : str))
.map((str) => (str === '\t' ? INDENT : str))
// Collect each line in an array of arrays
const lines: string[][] = []

View file

@ -13,6 +13,8 @@ import { TLExportColors } from '../shared/TLExportColors'
import { useEditableText } from '../shared/useEditableText'
import { OnEditEndHandler, OnResizeHandler, TLShapeUtil, TLShapeUtilFlag } from '../TLShapeUtil'
export { INDENT } from './TextHelpers'
const sizeCache = new WeakMapCache<TLTextShape['props'], { height: number; width: number }>()
/** @public */

View file

@ -3,10 +3,13 @@
// TODO: Most of this file can be moved into a DOM utils library.
/** @public */
/** @internal */
export type ReplacerCallback = (substring: string, ...args: unknown[]) => string
/** @public */
/** @public */
export const INDENT = ' '
/** @internal */
export class TextHelpers {
static insertTextFirefox(field: HTMLTextAreaElement | HTMLInputElement, text: string): void {
// Found on https://www.everythingfrontend.com/blog/insert-text-into-textarea-at-cursor-position.html 🎈
@ -131,7 +134,7 @@ export class TextHelpers {
const newSelection = element.value.slice(firstLineStart, selectionEnd - 1)
const indentedText = newSelection.replace(
/^|\n/g, // Match all line starts
`$&${TextHelpers.INDENT}`
`$&${INDENT}`
)
const replacementsCount = indentedText.length - newSelection.length
@ -142,7 +145,7 @@ export class TextHelpers {
// Restore selection position, including the indentation
element.setSelectionRange(selectionStart + 1, selectionEnd + replacementsCount)
} else {
TextHelpers.insert(element, TextHelpers.INDENT)
TextHelpers.insert(element, INDENT)
}
}
@ -192,7 +195,7 @@ export class TextHelpers {
const newSelection = value.slice(firstLineStart, selectionEnd - 1)
const indentedText = newSelection.replace(
/^|\n/g, // Match all line starts
`$&${TextHelpers.INDENT}`
`$&${INDENT}`
)
const replacementsCount = indentedText.length - newSelection.length
@ -213,10 +216,9 @@ export class TextHelpers {
}
} else {
const selection = window.getSelection()
element.innerText =
value.slice(0, selectionStart) + TextHelpers.INDENT + value.slice(selectionStart)
element.innerText = value.slice(0, selectionStart) + INDENT + value.slice(selectionStart)
selection?.setBaseAndExtent(element, selectionStart + 1, element, selectionStart + 2)
// TextHelpers.insert(element, TextHelpers.INDENT)
// TextHelpers.insert(element, INDENT)
}
}
@ -257,8 +259,6 @@ export class TextHelpers {
static fixNewLines = /\r?\n|\r/g
static INDENT = ' '
static normalizeText(text: string) {
return text.replace(TextHelpers.fixNewLines, '\n')
}

View file

@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useRef } from 'react'
import { useValue } from 'signia-react'
import { useApp } from '../../../hooks/useApp'
import { preventDefault, stopEventPropagation } from '../../../utils/dom'
import { TextHelpers } from '../TLTextUtil/TextHelpers'
import { INDENT, TextHelpers } from '../TLTextUtil/TextHelpers'
export function useEditableText<T extends Extract<TLShape, { props: { text: string } }>>(
id: T['id'],
@ -135,7 +135,7 @@ export function useEditableText<T extends Extract<TLShape, { props: { text: stri
// ------- Bug fix ------------
// Replace tabs with spaces when pasting
const untabbedText = text.replace(/\t/g, ' ')
const untabbedText = text.replace(/\t/g, INDENT)
if (untabbedText !== text) {
const selectionStart = e.currentTarget.selectionStart
e.currentTarget.value = untabbedText

View file

@ -298,15 +298,6 @@ interface DropdownMenuItemProps extends ButtonProps {
noClose?: boolean;
}
// @public (undocumented)
export type EmbedInfo = {
width: number;
height: number;
doesResize: boolean;
isEmbedUrl: (url: string) => boolean;
toEmbed: (url: string) => string;
};
// @public (undocumented)
export const EN_TRANSLATION: TLTranslation;

View file

@ -57,11 +57,7 @@ export { AssetUrlsProvider, useAssetUrls } from './lib/hooks/useAssetUrls'
export { BreakPointProvider, useBreakpoint } from './lib/hooks/useBreakpoint'
export { useCanRedo } from './lib/hooks/useCanRedo'
export { useCanUndo } from './lib/hooks/useCanUndo'
export {
useMenuClipboardEvents,
useNativeClipboardEvents,
type EmbedInfo,
} from './lib/hooks/useClipboardEvents'
export { useMenuClipboardEvents, useNativeClipboardEvents } from './lib/hooks/useClipboardEvents'
export {
ContextMenuSchemaContext,
ContextMenuSchemaProvider,

View file

@ -0,0 +1,512 @@
import {
App,
TLAlignType,
TLArrowheadType,
TLAsset,
TLAssetId,
TLClipboardModel,
TLColorType,
TLDashType,
TLFillType,
TLFontType,
TLOpacityType,
TLShapeId,
TLSizeType,
getIndexAbove,
getIndices,
isShapeId,
uniqueId,
} from '@tldraw/editor'
import { Box2d, Vec2d, VecLike } from '@tldraw/primitives'
import { compact } from '@tldraw/utils'
/**
* When the clipboard has excalidraw content, paste it into the scene.
*
* @param app - The app instance.
* @param clipboard - The clipboard model.
* @param point - (optional) The point at which to paste the text.
* @internal
*/
export 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 { currentPageId } = app
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)
}
}
}
})
let index = 'a1'
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)
}
/* --------------- Translating Helpers --------_------ */
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': 'black',
'#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'
}

View file

@ -0,0 +1,27 @@
import { App, createShapesFromFiles } from '@tldraw/editor'
import { VecLike } from '@tldraw/primitives'
/**
* When the clipboard has a file, create an image shape from the file and paste it into the scene
*
* @param app - The app instance.
* @param urls - The file urls.
* @param point - The point at which to paste the file.
* @internal
*/
export async function pasteFiles(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))
}

View file

@ -0,0 +1,140 @@
import {
App,
FONT_FAMILIES,
FONT_SIZES,
INDENT,
TEXT_PROPS,
TLTextShapeDef,
createShapeId,
} from '@tldraw/editor'
import { VecLike } from '@tldraw/primitives'
const rtlRegex = /[\u0590-\u05FF\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/
/**
* Replace any tabs with double spaces.
* @param text - The text to replace tabs in.
* @internal
*/
function replaceTabsWithSpaces(text: string) {
return text.replace(/\t/g, INDENT)
}
/**
* Strip common minimum indentation from each line.
* @param text - The text to strip.
* @internal
*/
function stripCommonMinimumIndentation(text: string): string {
// Split the text into individual lines
const lines = text.split('\n')
// remove any leading lines that are only whitespace or newlines
while (lines[0].trim().length === 0) {
lines.shift()
}
let minIndentation = Infinity
for (const line of lines) {
if (line.trim().length > 0) {
const indentation = line.length - line.trimStart().length
minIndentation = Math.min(minIndentation, indentation)
}
}
return lines.map((line) => line.slice(minIndentation)).join('\n')
}
/**
* Strip trailing whitespace from each line and remove any trailing newlines.
* @param text - The text to strip.
* @internal
*/
function stripTrailingWhitespace(text: string): string {
return text.replace(/[ \t]+$/gm, '').replace(/\n+$/, '')
}
/**
* When the clipboard has plain text, create a text shape and insert it into the scene
*
* @param app - The app instance.
* @param text - The text to paste.
* @param point - (optional) The point at which to paste the text.
* @internal
*/
export async function pastePlainText(app: App, text: string, point?: VecLike) {
const p = point ?? (app.inputs.shiftKey ? app.inputs.currentPagePoint : app.viewportPageCenter)
const defaultProps = app.getShapeUtilByDef(TLTextShapeDef).defaultProps()
const textToPaste = stripTrailingWhitespace(
stripCommonMinimumIndentation(replaceTabsWithSpaces(text))
)
// Measure the text with default values
let w: number
let h: number
let autoSize: boolean
let align = 'middle'
const isMultiLine = textToPaste.split('\n').length > 1
// check whether the text contains the most common characters in RTL languages
const isRtl = rtlRegex.test(textToPaste)
if (isMultiLine) {
align = isMultiLine ? (isRtl ? 'end' : 'start') : 'middle'
}
const rawSize = app.textMeasure.measureText({
...TEXT_PROPS,
text: textToPaste,
fontFamily: FONT_FAMILIES[defaultProps.font],
fontSize: FONT_SIZES[defaultProps.size],
width: 'fit-content',
})
const minWidth = Math.min(
isMultiLine ? app.viewportPageBounds.width * 0.9 : 920,
Math.max(200, app.viewportPageBounds.width * 0.9)
)
if (rawSize.w > minWidth) {
const shrunkSize = app.textMeasure.measureText({
...TEXT_PROPS,
text: textToPaste,
fontFamily: FONT_FAMILIES[defaultProps.font],
fontSize: FONT_SIZES[defaultProps.size],
width: minWidth + 'px',
})
w = shrunkSize.w
h = shrunkSize.h
autoSize = false
align = isRtl ? 'end' : 'start'
} else {
// autosize is fine
w = rawSize.w
h = rawSize.h
autoSize = true
}
if (p.y - h / 2 < app.viewportPageBounds.minY + 40) {
p.y = app.viewportPageBounds.minY + 40 + h / 2
}
app.mark('paste')
app.createShapes([
{
id: createShapeId(),
type: 'text',
x: p.x - w / 2,
y: p.y - h / 2,
props: {
text: textToPaste,
// if the text has more than one line, align it to the left
align,
autoSize,
w,
},
},
])
}

View file

@ -0,0 +1,17 @@
import { App, createAssetShapeAtPoint } from '@tldraw/editor'
import { VecLike } from '@tldraw/primitives'
/**
* When the clipboard has svg text, create a text shape and insert it into the scene
*
* @param app - The app instance.
* @param text - The text to paste.
* @param point - (optional) The point at which to paste the text.
* @internal
*/
export async function pasteSvgText(app: App, text: string, point?: VecLike) {
const p = point ?? (app.inputs.shiftKey ? app.inputs.currentPagePoint : app.viewportPageCenter)
app.mark('paste')
return await createAssetShapeAtPoint(app, text, p)
}

View file

@ -0,0 +1,20 @@
import { App, TLClipboardModel } from '@tldraw/editor'
import { VecLike } from '@tldraw/primitives'
/**
* When the clipboard has tldraw content, paste it into the scene.
*
* @param app - The app instance.
* @param clipboard - The clipboard model.
* @param point - (optional) The point at which to paste the text.
* @internal
*/
export function pasteTldrawContent(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,
})
}

View file

@ -0,0 +1,47 @@
import {
App,
createBookmarkShapeAtPoint,
createEmbedShapeAtPoint,
getEmbedInfo,
} from '@tldraw/editor'
import { VecLike } from '@tldraw/primitives'
import { pasteFiles } from './pasteFiles'
/**
* When the clipboard has plain text that is a valid URL, create a bookmark shape and insert it into
* the scene
*
* @param app - The app instance.
* @param url - The URL to paste.
* @param point - (optional) The point at which to paste the file.
* @internal
*/
export async function pasteUrl(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)
}
}
app.mark('paste')
// try to paste as an embed first
const embedInfo = getEmbedInfo(url)
if (embedInfo) {
return await createEmbedShapeAtPoint(app, embedInfo.url, p, embedInfo.definition)
}
// otherwise, try to paste as a bookmark
return await createBookmarkShapeAtPoint(app, url, p)
}

File diff suppressed because it is too large Load diff