[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:
parent
ad5a23e135
commit
a1e72014c6
15 changed files with 1229 additions and 900 deletions
|
@ -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>;
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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[][] = []
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
512
packages/ui/src/lib/hooks/clipboard/pasteExcalidrawContent.ts
Normal file
512
packages/ui/src/lib/hooks/clipboard/pasteExcalidrawContent.ts
Normal 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'
|
||||
}
|
27
packages/ui/src/lib/hooks/clipboard/pasteFiles.ts
Normal file
27
packages/ui/src/lib/hooks/clipboard/pasteFiles.ts
Normal 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))
|
||||
}
|
140
packages/ui/src/lib/hooks/clipboard/pastePlainText.ts
Normal file
140
packages/ui/src/lib/hooks/clipboard/pastePlainText.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
17
packages/ui/src/lib/hooks/clipboard/pasteSvgText.ts
Normal file
17
packages/ui/src/lib/hooks/clipboard/pasteSvgText.ts
Normal 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)
|
||||
}
|
20
packages/ui/src/lib/hooks/clipboard/pasteTldrawContent.ts
Normal file
20
packages/ui/src/lib/hooks/clipboard/pasteTldrawContent.ts
Normal 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,
|
||||
})
|
||||
}
|
47
packages/ui/src/lib/hooks/clipboard/pasteUrl.ts
Normal file
47
packages/ui/src/lib/hooks/clipboard/pasteUrl.ts
Normal 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
Loading…
Reference in a new issue