diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index 086639a84..c26fa0e8e 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -879,6 +879,9 @@ export type HTMLContainerProps = React_2.HTMLAttributes; // @public (undocumented) export const ICON_SIZES: Record; +// @public (undocumented) +export const INDENT = " "; + // @public (undocumented) export function indexGenerator(n?: number): Generator; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index a586627a5..57bf1936d 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -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' diff --git a/packages/editor/src/lib/app/managers/TextManager.ts b/packages/editor/src/lib/app/managers/TextManager.ts index 28af03d9c..160bfbe91 100644 --- a/packages/editor/src/lib/app/managers/TextManager.ts +++ b/packages/editor/src/lib/app/managers/TextManager.ts @@ -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[][] = [] diff --git a/packages/editor/src/lib/app/shapeutils/TLTextUtil/TLTextUtil.tsx b/packages/editor/src/lib/app/shapeutils/TLTextUtil/TLTextUtil.tsx index bd26d4d08..4416b1fb0 100644 --- a/packages/editor/src/lib/app/shapeutils/TLTextUtil/TLTextUtil.tsx +++ b/packages/editor/src/lib/app/shapeutils/TLTextUtil/TLTextUtil.tsx @@ -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() /** @public */ diff --git a/packages/editor/src/lib/app/shapeutils/TLTextUtil/TextHelpers.ts b/packages/editor/src/lib/app/shapeutils/TLTextUtil/TextHelpers.ts index 5b8990dfa..05606fa7f 100644 --- a/packages/editor/src/lib/app/shapeutils/TLTextUtil/TextHelpers.ts +++ b/packages/editor/src/lib/app/shapeutils/TLTextUtil/TextHelpers.ts @@ -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') } diff --git a/packages/editor/src/lib/app/shapeutils/shared/useEditableText.ts b/packages/editor/src/lib/app/shapeutils/shared/useEditableText.ts index 667e12b90..47f7bebd1 100644 --- a/packages/editor/src/lib/app/shapeutils/shared/useEditableText.ts +++ b/packages/editor/src/lib/app/shapeutils/shared/useEditableText.ts @@ -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>( id: T['id'], @@ -135,7 +135,7 @@ export function useEditableText boolean; - toEmbed: (url: string) => string; -}; - // @public (undocumented) export const EN_TRANSLATION: TLTranslation; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 898136871..e8319f8fc 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -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, diff --git a/packages/ui/src/lib/hooks/clipboard/pasteExcalidrawContent.ts b/packages/ui/src/lib/hooks/clipboard/pasteExcalidrawContent.ts new file mode 100644 index 000000000..ef90a5fa6 --- /dev/null +++ b/packages/ui/src/lib/hooks/clipboard/pasteExcalidrawContent.ts @@ -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() + const rotatedElements = new Map() + + const { currentPageId } = app + + const excElementIdsToTldrawShapeIds = new Map() + const rootShapeIds: TLShapeId[] = [] + + const skipIds = new Set() + + 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 = { + 1: 's', + 2: 'm', + 3: 'l', + 4: 'xl', +} + +const fontSizesToSizes: Record = { + 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 = { + 1: 'draw', + 2: 'sans', + 3: 'mono', +} + +const colorsToColors: Record = { + '#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 = { + solid: 'draw', + dashed: 'dashed', + dotted: 'dotted', +} + +const fillStylesToFillType: Record = { + 'cross-hatch': 'pattern', + hachure: 'pattern', + solid: 'solid', +} + +const textAlignToAlignTypes: Record = { + left: 'start', + center: 'middle', + right: 'end', +} + +const arrowheadsToArrowheadTypes: Record = { + 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' +} diff --git a/packages/ui/src/lib/hooks/clipboard/pasteFiles.ts b/packages/ui/src/lib/hooks/clipboard/pasteFiles.ts new file mode 100644 index 000000000..db518b99d --- /dev/null +++ b/packages/ui/src/lib/hooks/clipboard/pasteFiles.ts @@ -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)) +} diff --git a/packages/ui/src/lib/hooks/clipboard/pastePlainText.ts b/packages/ui/src/lib/hooks/clipboard/pastePlainText.ts new file mode 100644 index 000000000..d0700bacb --- /dev/null +++ b/packages/ui/src/lib/hooks/clipboard/pastePlainText.ts @@ -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, + }, + }, + ]) +} diff --git a/packages/ui/src/lib/hooks/clipboard/pasteSvgText.ts b/packages/ui/src/lib/hooks/clipboard/pasteSvgText.ts new file mode 100644 index 000000000..c361b1aa4 --- /dev/null +++ b/packages/ui/src/lib/hooks/clipboard/pasteSvgText.ts @@ -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) +} diff --git a/packages/ui/src/lib/hooks/clipboard/pasteTldrawContent.ts b/packages/ui/src/lib/hooks/clipboard/pasteTldrawContent.ts new file mode 100644 index 000000000..cacfb03f4 --- /dev/null +++ b/packages/ui/src/lib/hooks/clipboard/pasteTldrawContent.ts @@ -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, + }) +} diff --git a/packages/ui/src/lib/hooks/clipboard/pasteUrl.ts b/packages/ui/src/lib/hooks/clipboard/pasteUrl.ts new file mode 100644 index 000000000..d64140f77 --- /dev/null +++ b/packages/ui/src/lib/hooks/clipboard/pasteUrl.ts @@ -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) +} diff --git a/packages/ui/src/lib/hooks/useClipboardEvents.ts b/packages/ui/src/lib/hooks/useClipboardEvents.ts index bd4b30311..6b10ed760 100644 --- a/packages/ui/src/lib/hooks/useClipboardEvents.ts +++ b/packages/ui/src/lib/hooks/useClipboardEvents.ts @@ -1,56 +1,53 @@ 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 { 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' -/** @public */ -export type EmbedInfo = { - width: number - height: number - doesResize: boolean - isEmbedUrl: (url: string) => boolean - toEmbed: (url: string) => string +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((resolve, reject) => { const reader = new FileReader() @@ -65,175 +62,412 @@ async function blobAsString(blob: Blob) { }) } -async function dataTransferItemAsString(item: DataTransferItem) { - return new Promise((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)) - ) -} - +/** + * Strip HTML tags from a string. + * @param html - The HTML to strip. + * @internal + */ function stripHtml(html: string) { // See const doc = document.implementation.createHTMLDocument('') - doc.documentElement.innerHTML = html + doc.documentElement.innerHTML = html.trim() return doc.body.textContent || doc.body.innerText || '' } -// Clear the clipboard when the user copies nothing -const clearPersistedClipboard = () => { - window.navigator.clipboard.writeText('') +/** + * 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\//)) } /** - * Write serialized data to the local storage. - * - * @param data - The string to write. - * @param kind - The kind of data to write. + * 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 getStringifiedClipboard = (data: any, kind: 'text' | 'file' | 'content') => { - const s = compressToBase64( - JSON.stringify({ - type: 'application/tldraw', - kind, - data, - }) +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 + } + | { + type: 'blob' + source: Promise + } + | { + type: 'url' + source: Promise + } + | { + type: 'html' + source: Promise + } + | { + type: 'text' + source: Promise + } + | { + type: string + source: Promise + } + +/** + * 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, + }) + 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, + }) + } else if (item.type === 'text/plain') { + things.push({ + type: 'text', + source: new Promise((r) => item.getAsString(r)) as Promise, + }) + } 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((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((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((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[] + + // 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( + things + .filter((t) => t.type !== 'file') + .map( + (t) => + new Promise((r) => { + const thing = t as Exclude + + 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>/)?.[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' }) + }) + }) + ) ) - return s -} + // 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. -/** - * 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]) + // Try to paste tldraw content + for (const result of results) { + if (result.type === 'tldraw') { + pasteTldrawContent(app, result.data, point) return } - } catch (err: any) { - if (err.message !== 'Failed to fetch') { - console.error(err) + } + + // Try to paste excalidraw content + for (const result of results) { + if (result.type === 'excalidraw') { + pasteExcalidrawContent(app, result.data, point) + return } } - const embedInfo = getEmbedInfo(url) + // 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') - if (embedInfo) { - app.mark('paste') - createEmbedShapeAtPoint(app, embedInfo.url, p, embedInfo.definition) - } else { - app.mark('paste') - await createBookmarkShapeAtPoint(app, url, p) + // Edge on Windows 11 home appears to paste a link as a single 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 + } + } } -} -const pasteSvgText = async (app: App, text: string, point?: VecLike) => { - const p = point ?? (app.inputs.shiftKey ? app.inputs.currentPagePoint : app.viewportPageCenter) + // Try to paste a link + for (const result of results) { + if (result.type === 'text' && result.subtype === 'url') { + pasteUrl(app, result.data, point) + return + } + } - 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)) + // 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 + } + } } /** @@ -242,14 +476,20 @@ const pasteFiles = async (app: App, urls: string[], point?: VecLike) => { * @param app - App * @public */ -const handleMenuCopy = (app: App) => { +const handleNativeOrMenuCopy = (app: App) => { const content = app.getContent() if (!content) { - clearPersistedClipboard() + window.navigator.clipboard.writeText('') return } - const stringifiedClipboard = getStringifiedClipboard(content, 'content') + const stringifiedClipboard = compressToBase64( + JSON.stringify({ + type: 'application/tldraw', + kind: 'content', + data: content, + }) + ) if (typeof window?.navigator !== 'undefined') { // Extract the text from the clipboard @@ -282,6 +522,7 @@ const handleMenuCopy = (app: App) => { navigator.clipboard.write([ new ClipboardItem({ 'text/html': htmlBlob, + // What is this second blob used for? 'text/plain': new Blob([textContent], { type: 'text/plain' }), }), ]) @@ -291,682 +532,6 @@ const handleMenuCopy = (app: App) => { } } -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() - const rotatedElements = new Map() - - 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 = { - 1: 's', - 2: 'm', - 3: 'l', - 4: 'xl', - } - - const fontSizesToSizes: Record = { - 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 = { - 1: 'draw', - 2: 'sans', - 3: 'mono', - } - - const colorsToColors: Record = { - '#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 = { - solid: 'draw', - dashed: 'dashed', - dotted: 'dotted', - } - - const fillStylesToFillType: Record = { - 'cross-hatch': 'pattern', - hachure: 'pattern', - solid: 'solid', - } - - const textAlignToAlignTypes: Record = { - left: 'start', - center: 'middle', - right: 'end', - } - - const arrowheadsToArrowheadTypes: Record = { - 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() - const rootShapeIds: TLShapeId[] = [] - - const skipIds = new Set() - - 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>/)?.[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 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>/)?.[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(source: TLUiEventSource) { const app = useApp() @@ -976,7 +541,7 @@ export function useMenuClipboardEvents(source: TLUiEventSource) { function onCopy() { if (app.selectedIds.length === 0) return - handleMenuCopy(app) + handleNativeOrMenuCopy(app) trackEvent('copy', { source }) }, [app, trackEvent, source] @@ -986,7 +551,7 @@ export function useMenuClipboardEvents(source: TLUiEventSource) { function onCut() { if (app.selectedIds.length === 0) return - handleMenuCopy(app) + handleNativeOrMenuCopy(app) app.deleteShapes() trackEvent('cut', { source }) }, @@ -995,19 +560,20 @@ export function useMenuClipboardEvents(source: TLUiEventSource) { const paste = useCallback( async function onPaste(data: DataTransfer | ClipboardItem[], 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) { - handleNativeClipboardPaste(app, data, point) + 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, app.inputs.currentPagePoint) }) } - - // else { - // handleScenePaste(app, point) - // } - - trackEvent('paste', { source: 'menu' }) }, [app, trackEvent] ) @@ -1031,14 +597,14 @@ export function useNativeClipboardEvents() { const copy = () => { if (app.selectedIds.length === 0 || app.editingId !== null || disallowClipboardEvents(app)) return - handleMenuCopy(app) + handleNativeOrMenuCopy(app) trackEvent('copy', { source: 'kbd' }) } function cut() { if (app.selectedIds.length === 0 || app.editingId !== null || disallowClipboardEvents(app)) return - handleMenuCopy(app) + handleNativeOrMenuCopy(app) app.deleteShapes() trackEvent('cut', { source: 'kbd' }) } @@ -1058,16 +624,24 @@ export function useNativeClipboardEvents() { 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) { - handleNativeDataTransferPaste(app, event.clipboardData) + handlePasteFromEventClipboardData(app, event.clipboardData) } else { + // Or else use the clipboard API navigator.clipboard.read().then((clipboardItems) => { if (Array.isArray(clipboardItems) && clipboardItems[0] instanceof ClipboardItem) { - handleNativeClipboardPaste(app, clipboardItems, app.inputs.currentPagePoint) + handlePasteFromClipboardApi(app, clipboardItems, app.inputs.currentPagePoint) } }) } + trackEvent('paste', { source: 'kbd' }) }