ExternalContentManager for handling external content (files, images, etc) (#1550)

This PR improves the editor's APIs around creating assets and files.
This allows end user developers to replace behavior that might occur,
for example, when pasting images or dragging files onto the canvas.

Here, we:
- remove `onCreateAssetFromFile` prop
- remove `onCreateBookmarkFromUrl` prop
- introduce `onEditorReady` prop
- introduce `onEditorWillDispose` prop
- introduce `ExternalContentManager`

The `ExternalContentManager` (ECM) is used in circumstances where we're
turning external content (text, images, urls, etc) into assets or
shapes. It is designed to allow certain methods to be overwritten by
other developers as a kind of weakly supported hack.

For example, when a user drags an image onto the canvas, the event
handler passes a `TLExternalContent` object to the editor's
`putExternalContent` method. This method runs the ECM's handler for this
content type. That handler may in turn run other methods, such as
`createAssetFromFile` or `createShapesForAssets`, which will lead to the
image being created on the canvas.

If a developer wanted to change the way that assets are created from
files, then they could overwrite that method at runtime.

```ts
const handleEditorReady = (editor: Editor) => {
  editor.externalContentManager.createAssetFromFile = myHandler
}

function Example() {
  return <Tldraw onEditorReady={handleEditorReady}/>
}
```

If you wanted to go even deeper, you could override the editor's
`putExternalContent` method.

```ts
const handleEditorReady = (editor: Editor) => {
const handleExternalContent = (info: TLExternalContent): Promise<void> => {
	if (info.type === 'files') {
	   // do something here
	} else {
          // do the normal thing
          editor.externalContentManager.handleContent(info)
        }
}
```

### Change Type

- [x] `major`

### Test Plan

1. Drag images, urls, etc. onto the canvas
2. Use copy and paste for single and multiple files
3. Use bookmark / embed shapes and convert between eachother

### Release Notes

- [editor] add `ExternalContentManager` for plopping content onto the
canvas
- [editor] remove `onCreateAssetFromFile` prop
- [editor] remove `onCreateBookmarkFromUrl` prop
- [editor] introduce `onEditorReady` prop
- [editor] introduce `onEditorWillDispose` prop
- [editor] introduce `ExternalContentManager`
This commit is contained in:
Steve Ruiz 2023-06-08 15:53:11 +01:00 committed by GitHub
parent f2e95988e0
commit 0cc91eec62
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 817 additions and 726 deletions

View file

@ -7,13 +7,13 @@ import { ContextMenu, TLUiMenuSchema, TldrawUi } from '@tldraw/ui'
import '@tldraw/ui/ui.css' import '@tldraw/ui/ui.css'
// eslint-disable-next-line import/no-internal-modules // eslint-disable-next-line import/no-internal-modules
import { getAssetUrlsByImport } from '@tldraw/assets/imports' import { getAssetUrlsByImport } from '@tldraw/assets/imports'
import { useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { VscodeMessage } from '../../messages' import { VscodeMessage } from '../../messages'
import '../public/index.css' import '../public/index.css'
import { ChangeResponder } from './ChangeResponder' import { ChangeResponder } from './ChangeResponder'
import { FileOpen } from './FileOpen' import { FileOpen } from './FileOpen'
import { FullPageMessage } from './FullPageMessage' import { FullPageMessage } from './FullPageMessage'
import { onCreateBookmarkFromUrl } from './utils/bookmarks' import { onCreateAssetFromUrl } from './utils/bookmarks'
import { vscode } from './utils/vscode' import { vscode } from './utils/vscode'
setRuntimeOverrides({ setRuntimeOverrides({
@ -119,13 +119,12 @@ export type TLDrawInnerProps = {
function TldrawInner({ uri, assetSrc, isDarkMode, fileContents }: TLDrawInnerProps) { function TldrawInner({ uri, assetSrc, isDarkMode, fileContents }: TLDrawInnerProps) {
const assetUrls = useMemo(() => getAssetUrlsByImport({ baseUrl: assetSrc }), [assetSrc]) const assetUrls = useMemo(() => getAssetUrlsByImport({ baseUrl: assetSrc }), [assetSrc])
const handleMount = useCallback((editor: Editor) => {
editor.externalContentManager.createAssetFromUrl = onCreateAssetFromUrl
}, [])
return ( return (
<TldrawEditor <TldrawEditor assetUrls={assetUrls} persistenceKey={uri} onMount={handleMount} autoFocus>
assetUrls={assetUrls}
persistenceKey={uri}
onCreateBookmarkFromUrl={onCreateBookmarkFromUrl}
autoFocus
>
{/* <DarkModeHandler themeKind={themeKind} /> */} {/* <DarkModeHandler themeKind={themeKind} /> */}
<TldrawUi assetUrls={assetUrls} overrides={[menuOverrides, linksUiOverrides]}> <TldrawUi assetUrls={assetUrls} overrides={[menuOverrides, linksUiOverrides]}>
<FileOpen fileContents={fileContents} forceDarkMode={isDarkMode} /> <FileOpen fileContents={fileContents} forceDarkMode={isDarkMode} />

View file

@ -1,47 +1,56 @@
import { AssetRecordType, Editor, TLAsset, truncateStringWithEllipsis } from '@tldraw/editor'
import { getHashForString } from '@tldraw/utils'
import { rpc } from './rpc' import { rpc } from './rpc'
async function onCreateBookmarkFromUrlFallback( export async function onCreateAssetFromUrl(editor: Editor, url: string): Promise<TLAsset> {
url: string
): Promise<{ image: string; title: string; description: string }> {
const meta = {
image: '',
title: '',
description: '',
}
try { try {
const resp = await fetch(url, { method: 'GET', mode: 'no-cors' }) // First, try to get the data from vscode
const html = await resp.text() const meta = await rpc('vscode:bookmark', { url })
const doc = new DOMParser().parseFromString(html, 'text/html')
meta.image = doc.head
.querySelector('meta[property="og:image"]')
?.getAttribute('content') as string
meta.title = doc.head
.querySelector('meta[property="og:title"]')
?.getAttribute('content') as string
meta.description = doc.head
.querySelector('meta[property="og:description"]')
?.getAttribute('content') as string
return meta
} catch (error) {
console.error(error)
}
return meta
}
export async function onCreateBookmarkFromUrl(url: string) {
try {
const data = await rpc('vscode:bookmark', { url })
return { return {
title: data.title || '', id: AssetRecordType.createId(getHashForString(url)),
description: data.description || '', typeName: 'asset',
image: data.image || '', type: 'bookmark',
props: {
src: url,
description: meta.description ?? '',
image: meta.image ?? '',
title: meta.title ?? truncateStringWithEllipsis(url, 32),
},
} }
} catch (error) { } catch (error) {
return onCreateBookmarkFromUrlFallback(url) // Otherwise, fallback to fetching data from the url
let meta: { image: string; title: string; description: string }
try {
const resp = await fetch(url, { method: 'GET', mode: 'no-cors' })
const html = await resp.text()
const doc = new DOMParser().parseFromString(html, 'text/html')
meta = {
image: doc.head.querySelector('meta[property="og:image"]')?.getAttribute('content') ?? '',
title:
doc.head.querySelector('meta[property="og:title"]')?.getAttribute('content') ??
truncateStringWithEllipsis(url, 32),
description:
doc.head.querySelector('meta[property="og:description"]')?.getAttribute('content') ?? '',
}
} catch (error) {
console.error(error)
meta = { image: '', title: truncateStringWithEllipsis(url, 32), description: '' }
}
// Create the bookmark asset from the meta
return {
id: AssetRecordType.createId(getHashForString(url)),
typeName: 'asset',
type: 'bookmark',
props: {
src: url,
image: meta.image,
title: meta.title,
description: meta.description,
},
}
} }
} }

View file

@ -245,25 +245,9 @@ export function containBoxSize(originalSize: BoxWidthHeight, containBoxSize: Box
// @public (undocumented) // @public (undocumented)
export function correctSpacesToNbsp(input: string): string; export function correctSpacesToNbsp(input: string): string;
// @public (undocumented)
export function createAssetShapeAtPoint(editor: Editor, svgString: string, point: Vec2dModel): Promise<void>;
// @public
export function createBookmarkShapeAtPoint(editor: Editor, url: string, point: Vec2dModel): Promise<void>;
// @public (undocumented)
export function createEmbedShapeAtPoint(editor: Editor, url: string, point: Vec2dModel, props: {
width?: number;
height?: number;
doesResize?: boolean;
}): void;
// @public // @public
export function createSessionStateSnapshotSignal(store: TLStore): Signal<null | TLSessionStateSnapshot>; export function createSessionStateSnapshotSignal(store: TLStore): Signal<null | TLSessionStateSnapshot>;
// @public (undocumented)
export function createShapesFromFiles(editor: Editor, files: File[], position: VecLike, _ignoreParent?: boolean): Promise<void>;
// @public // @public
export function createTLStore(opts?: TLStoreOptions): TLStore; export function createTLStore(opts?: TLStoreOptions): TLStore;
@ -463,6 +447,8 @@ export class Editor extends EventEmitter<TLEventMap> {
enableAnimations: boolean; enableAnimations: boolean;
get erasingIds(): TLShapeId[]; get erasingIds(): TLShapeId[];
get erasingIdsSet(): Set<TLShapeId>; get erasingIdsSet(): Set<TLShapeId>;
// (undocumented)
externalContentManager: PlopManager;
findAncestor(shape: TLShape, predicate: (parent: TLShape) => boolean): TLShape | undefined; findAncestor(shape: TLShape, predicate: (parent: TLShape) => boolean): TLShape | undefined;
findCommonAncestor(shapes: TLShape[], predicate?: (shape: TLShape) => boolean): TLShapeId | undefined; findCommonAncestor(shapes: TLShape[], predicate?: (shape: TLShape) => boolean): TLShapeId | undefined;
flipShapes(operation: 'horizontal' | 'vertical', ids?: TLShapeId[]): this; flipShapes(operation: 'horizontal' | 'vertical', ids?: TLShapeId[]): this;
@ -614,12 +600,6 @@ export class Editor extends EventEmitter<TLEventMap> {
mark(reason?: string, onUndo?: boolean, onRedo?: boolean): string; mark(reason?: string, onUndo?: boolean, onRedo?: boolean): string;
moveShapesToPage(ids: TLShapeId[], pageId: TLPageId): this; moveShapesToPage(ids: TLShapeId[], pageId: TLPageId): this;
nudgeShapes(ids: TLShapeId[], direction: Vec2dModel, major?: boolean, ephemeral?: boolean): this; nudgeShapes(ids: TLShapeId[], direction: Vec2dModel, major?: boolean, ephemeral?: boolean): this;
onCreateAssetFromFile(file: File): Promise<TLAsset>;
onCreateBookmarkFromUrl(url: string): Promise<{
image: string;
title: string;
description: string;
}>;
get onlySelectedShape(): null | TLShape; get onlySelectedShape(): null | TLShape;
// (undocumented) // (undocumented)
get opacity(): null | number; get opacity(): null | number;
@ -647,6 +627,7 @@ export class Editor extends EventEmitter<TLEventMap> {
preservePosition?: boolean; preservePosition?: boolean;
preserveIds?: boolean; preserveIds?: boolean;
}): this; }): this;
putExternalContent(info: TLExternalContent): Promise<void>;
redo(): this; redo(): this;
renamePage(id: TLPageId, name: string, squashing?: boolean): this; renamePage(id: TLPageId, name: string, squashing?: boolean): this;
get renderingShapes(): { get renderingShapes(): {
@ -1760,6 +1741,34 @@ export function OptionalErrorBoundary({ children, fallback, ...props }: Omit<TLE
fallback: ((error: unknown) => React_3.ReactNode) | null; fallback: ((error: unknown) => React_3.ReactNode) | null;
}): JSX.Element; }): JSX.Element;
// @public (undocumented)
export class PlopManager {
constructor(editor: Editor);
createAssetFromFile(_editor: Editor, file: File): Promise<TLAsset>;
createAssetFromUrl(_editor: Editor, url: string): Promise<TLAsset>;
// (undocumented)
createShapesForAssets(editor: Editor, assets: TLAsset[], position: VecLike): Promise<void>;
// (undocumented)
editor: Editor;
// (undocumented)
handleContent: (info: TLExternalContent) => Promise<void>;
handleEmbed(editor: Editor, { point, url, embed }: Extract<TLExternalContent, {
type: 'embed';
}>): Promise<void>;
handleFiles(editor: Editor, { point, files }: Extract<TLExternalContent, {
type: 'files';
}>): Promise<void>;
handleSvgText(editor: Editor, { point, text }: Extract<TLExternalContent, {
type: 'svg-text';
}>): Promise<void>;
handleText(editor: Editor, { point, text }: Extract<TLExternalContent, {
type: 'text';
}>): Promise<void>;
handleUrl: (editor: Editor, { point, url }: Extract<TLExternalContent, {
type: 'url';
}>) => Promise<void>;
}
// @public // @public
export function preventDefault(event: Event | React_2.BaseSyntheticEvent): void; export function preventDefault(event: Event | React_2.BaseSyntheticEvent): void;
@ -2186,13 +2195,7 @@ export type TldrawEditorProps = {
assetUrls?: TLEditorAssetUrls; assetUrls?: TLEditorAssetUrls;
autoFocus?: boolean; autoFocus?: boolean;
components?: Partial<TLEditorComponents>; components?: Partial<TLEditorComponents>;
onMount?: (editor: Editor) => void; onMount?: (editor: Editor) => (() => void) | undefined | void;
onCreateAssetFromFile?: (file: File) => Promise<TLAsset>;
onCreateBookmarkFromUrl?: (url: string) => Promise<{
image: string;
title: string;
description: string;
}>;
} & ({ } & ({
store: TLStore | TLStoreWithStatus; store: TLStore | TLStoreWithStatus;
} | { } | {
@ -2376,6 +2379,31 @@ export type TLExitEventHandler = (info: any, to: string) => void;
// @public (undocumented) // @public (undocumented)
export type TLExportType = 'jpeg' | 'json' | 'png' | 'svg' | 'webp'; export type TLExportType = 'jpeg' | 'json' | 'png' | 'svg' | 'webp';
// @public (undocumented)
export type TLExternalContent = {
type: 'embed';
url: string;
point?: VecLike;
embed: EmbedDefinition;
} | {
type: 'files';
files: File[];
point?: VecLike;
ignoreParent: boolean;
} | {
type: 'svg-text';
text: string;
point?: VecLike;
} | {
type: 'text';
point?: VecLike;
text: string;
} | {
type: 'url';
url: string;
point?: VecLike;
};
// @public (undocumented) // @public (undocumented)
export type TLHistoryEntry = TLCommand | TLHistoryMark; export type TLHistoryEntry = TLCommand | TLHistoryMark;

View file

@ -81,6 +81,10 @@ export {
ZOOMS, ZOOMS,
} from './lib/constants' } from './lib/constants'
export { Editor, type TLAnimationOptions, type TLEditorOptions } from './lib/editor/Editor' export { Editor, type TLAnimationOptions, type TLEditorOptions } from './lib/editor/Editor'
export {
ExternalContentManager as PlopManager,
type TLExternalContent,
} from './lib/editor/managers/ExternalContentManager'
export { ArrowShapeUtil } from './lib/editor/shapeutils/ArrowShapeUtil/ArrowShapeUtil' export { ArrowShapeUtil } from './lib/editor/shapeutils/ArrowShapeUtil/ArrowShapeUtil'
export { BaseBoxShapeUtil, type TLBaseBoxShape } from './lib/editor/shapeutils/BaseBoxShapeUtil' export { BaseBoxShapeUtil, type TLBaseBoxShape } from './lib/editor/shapeutils/BaseBoxShapeUtil'
export { BookmarkShapeUtil } from './lib/editor/shapeutils/BookmarkShapeUtil/BookmarkShapeUtil' export { BookmarkShapeUtil } from './lib/editor/shapeutils/BookmarkShapeUtil/BookmarkShapeUtil'
@ -185,10 +189,6 @@ export {
ACCEPTED_IMG_TYPE, ACCEPTED_IMG_TYPE,
ACCEPTED_VID_TYPE, ACCEPTED_VID_TYPE,
containBoxSize, containBoxSize,
createAssetShapeAtPoint,
createBookmarkShapeAtPoint,
createEmbedShapeAtPoint,
createShapesFromFiles,
dataUrlToFile, dataUrlToFile,
getFileMetaData, getFileMetaData,
getImageSizeFromSrc, getImageSizeFromSrc,

View file

@ -1,5 +1,5 @@
import { Store, StoreSnapshot } from '@tldraw/store' import { Store, StoreSnapshot } from '@tldraw/store'
import { TLAsset, TLRecord, TLStore } from '@tldraw/tlschema' import { TLRecord, TLStore } from '@tldraw/tlschema'
import { annotateError } from '@tldraw/utils' import { annotateError } from '@tldraw/utils'
import React, { memo, useCallback, useLayoutEffect, useState, useSyncExternalStore } from 'react' import React, { memo, useCallback, useLayoutEffect, useState, useSyncExternalStore } from 'react'
import { TLEditorAssetUrls, defaultEditorAssetUrls } from './assetUrls' import { TLEditorAssetUrls, defaultEditorAssetUrls } from './assetUrls'
@ -48,6 +48,7 @@ export type TldrawEditorProps = {
* Overrides for the tldraw user interface components. * Overrides for the tldraw user interface components.
*/ */
components?: Partial<TLEditorComponents> components?: Partial<TLEditorComponents>
/** /**
* Called when the editor has mounted. * Called when the editor has mounted.
* *
@ -61,40 +62,7 @@ export type TldrawEditorProps = {
* *
* @param editor - The editor instance. * @param editor - The editor instance.
*/ */
onMount?: (editor: Editor) => void onMount?: (editor: Editor) => (() => void) | undefined | void
/**
* Called when the editor generates a new asset from a file, such as when an image is dropped into
* the canvas.
*
* @example
*
* ```ts
* const editor = new App({
* onCreateAssetFromFile: (file) => uploadFileAndCreateAsset(file),
* })
* ```
*
* @param file - The file to generate an asset from.
* @param id - The id to be assigned to the resulting asset.
*/
onCreateAssetFromFile?: (file: File) => Promise<TLAsset>
/**
* Called when a URL is converted to a bookmark. This callback should return the metadata for the
* bookmark.
*
* @example
*
* ```ts
* editor.onCreateBookmarkFromUrl(url, id)
* ```
*
* @param url - The url that was created.
* @public
*/
onCreateBookmarkFromUrl?: (
url: string
) => Promise<{ image: string; title: string; description: string }>
} & ( } & (
| { | {
/** /**
@ -235,8 +203,6 @@ const TldrawEditorWithLoadingStore = memo(function TldrawEditorBeforeLoading({
function TldrawEditorWithReadyStore({ function TldrawEditorWithReadyStore({
onMount, onMount,
children, children,
onCreateAssetFromFile,
onCreateBookmarkFromUrl,
store, store,
tools, tools,
shapes, shapes,
@ -258,36 +224,25 @@ function TldrawEditorWithReadyStore({
;(window as any).app = editor ;(window as any).app = editor
;(window as any).editor = editor ;(window as any).editor = editor
setEditor(editor) setEditor(editor)
return () => { return () => {
editor.dispose() editor.dispose()
} }
}, [container, shapes, tools, store]) }, [container, shapes, tools, store])
React.useEffect(() => {
if (!editor) return
// Overwrite the default onCreateAssetFromFile handler.
if (onCreateAssetFromFile) {
editor.onCreateAssetFromFile = onCreateAssetFromFile
}
if (onCreateBookmarkFromUrl) {
editor.onCreateBookmarkFromUrl = onCreateBookmarkFromUrl
}
}, [editor, onCreateAssetFromFile, onCreateBookmarkFromUrl])
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
if (editor && autoFocus) editor.focus() if (editor && autoFocus) editor.focus()
}, [editor, autoFocus]) }, [editor, autoFocus])
const onMountEvent = useEvent((editor: Editor) => { const onMountEvent = useEvent((editor: Editor) => {
onMount?.(editor) const teardown = onMount?.(editor)
editor.emit('mount') editor.emit('mount')
window.tldrawReady = true window.tldrawReady = true
return teardown
}) })
React.useEffect(() => { React.useLayoutEffect(() => {
if (editor) onMountEvent(editor) if (editor) return onMountEvent?.(editor)
}, [editor, onMountEvent]) }, [editor, onMountEvent])
const crashingError = useSyncExternalStore( const crashingError = useSyncExternalStore(

View file

@ -105,7 +105,7 @@ import {
} from '../constants' } from '../constants'
import { exportPatternSvgDefs } from '../hooks/usePattern' import { exportPatternSvgDefs } from '../hooks/usePattern'
import { WeakMapCache } from '../utils/WeakMapCache' import { WeakMapCache } from '../utils/WeakMapCache'
import { dataUrlToFile, getMediaAssetFromFile } from '../utils/assets' import { dataUrlToFile } from '../utils/assets'
import { getIncrementedName, uniqueId } from '../utils/data' import { getIncrementedName, uniqueId } from '../utils/data'
import { setPropsForNextShape } from '../utils/props-for-next-shape' import { setPropsForNextShape } from '../utils/props-for-next-shape'
import { applyRotationToSnapshotShapes, getRotationSnapshot } from '../utils/rotation' import { applyRotationToSnapshotShapes, getRotationSnapshot } from '../utils/rotation'
@ -116,6 +116,7 @@ import { ActiveAreaManager, getActiveAreaScreenSpace } from './managers/ActiveAr
import { CameraManager } from './managers/CameraManager' import { CameraManager } from './managers/CameraManager'
import { ClickManager } from './managers/ClickManager' import { ClickManager } from './managers/ClickManager'
import { DprManager } from './managers/DprManager' import { DprManager } from './managers/DprManager'
import { ExternalContentManager, TLExternalContent } from './managers/ExternalContentManager'
import { HistoryManager } from './managers/HistoryManager' import { HistoryManager } from './managers/HistoryManager'
import { SnapManager } from './managers/SnapManager' import { SnapManager } from './managers/SnapManager'
import { TextManager } from './managers/TextManager' import { TextManager } from './managers/TextManager'
@ -381,6 +382,9 @@ export class Editor extends EventEmitter<TLEventMap> {
/** @internal */ /** @internal */
private _updateDepth = 0 private _updateDepth = 0
/** @public */
externalContentManager = new ExternalContentManager(this)
/** /**
* A manager for the app's snapping feature. * A manager for the app's snapping feature.
* *
@ -4492,7 +4496,7 @@ export class Editor extends EventEmitter<TLEventMap> {
asset.props.mimeType ?? 'image/png' asset.props.mimeType ?? 'image/png'
) )
const newAsset = await this.onCreateAssetFromFile(file) const newAsset = await this.externalContentManager.createAssetFromFile(this, file)
return [asset, newAsset] as const return [asset, newAsset] as const
}) })
@ -8910,54 +8914,12 @@ export class Editor extends EventEmitter<TLEventMap> {
/* -------------------- Callbacks ------------------- */ /* -------------------- Callbacks ------------------- */
/** /**
* A callback fired when a file is converted to an asset. This callback should return the asset * Handle external content, such as files, urls, embeds, or plain text which has been put into the app, for example by pasting external text or dropping external images onto canvas.
* partial.
* *
* @example * @param info - Info about the external content.
*
* ```ts
* editor.onCreateAssetFromFile(myFile)
* ```
*
* @param file - The file to upload.
* @public
*/ */
async putExternalContent(info: TLExternalContent): Promise<void> {
async onCreateAssetFromFile(file: File): Promise<TLAsset> { this.externalContentManager.handleContent(info)
return await getMediaAssetFromFile(file)
}
/**
* A callback fired when a URL is converted to a bookmark. This callback should return the
* metadata for the bookmark.
*
* @example
*
* ```ts
* editor.onCreateBookmarkFromUrl(url, id)
* ```
*
* @param url - The url that was created.
* @public
*/
async onCreateBookmarkFromUrl(
url: string
): Promise<{ image: string; title: string; description: string }> {
try {
const resp = await fetch(url, { method: 'GET', mode: 'no-cors' })
const html = await resp.text()
const doc = new DOMParser().parseFromString(html, 'text/html')
return {
image: doc.head.querySelector('meta[property="og:image"]')?.getAttribute('content') ?? '',
title: doc.head.querySelector('meta[property="og:title"]')?.getAttribute('content') ?? '',
description:
doc.head.querySelector('meta[property="og:description"]')?.getAttribute('content') ?? '',
}
} catch (error) {
console.error(error)
return { image: '', title: '', description: '' }
}
} }
/* ---------------- Text Measurement ---------------- */ /* ---------------- Text Measurement ---------------- */

View file

@ -0,0 +1,604 @@
import { Vec2d, VecLike } from '@tldraw/primitives'
import {
AssetRecordType,
EmbedDefinition,
TLAsset,
TLAssetId,
TLShapePartial,
createShapeId,
} from '@tldraw/tlschema'
import { compact, getHashForString } from '@tldraw/utils'
import {
FONT_FAMILIES,
FONT_SIZES,
MAX_ASSET_HEIGHT,
MAX_ASSET_WIDTH,
TEXT_PROPS,
} from '../../constants'
import {
ACCEPTED_IMG_TYPE,
ACCEPTED_VID_TYPE,
containBoxSize,
getFileMetaData,
getImageSizeFromSrc,
getResizedImageDataUrl,
getVideoSizeFromSrc,
isImage,
} from '../../utils/assets'
import { truncateStringWithEllipsis } from '../../utils/dom'
import { getEmbedInfo } from '../../utils/embeds'
import { Editor } from '../Editor'
import { INDENT } from '../shapeutils/TextShapeUtil/TextHelpers'
import { TextShapeUtil } from '../shapeutils/TextShapeUtil/TextShapeUtil'
/** @public */
export type TLExternalContent =
| {
type: 'text'
point?: VecLike
text: string
}
| {
type: 'files'
files: File[]
point?: VecLike
ignoreParent: boolean
}
| {
type: 'url'
url: string
point?: VecLike
}
| {
type: 'svg-text'
text: string
point?: VecLike
}
| {
type: 'embed'
url: string
point?: VecLike
embed: EmbedDefinition
}
/** @public */
export class ExternalContentManager {
constructor(public editor: Editor) {}
handleContent = async (info: TLExternalContent) => {
switch (info.type) {
case 'text': {
return await this.handleText(this.editor, info)
}
case 'files': {
return await this.handleFiles(this.editor, info)
}
case 'embed': {
return await this.handleEmbed(this.editor, info)
}
case 'svg-text': {
return await this.handleSvgText(this.editor, info)
}
case 'url': {
return await this.handleUrl(this.editor, info)
}
}
}
/**
* Handle svg text from an external source. Feeling lucky? Overwrite this at runtime to change the way this type of external content is handled.
*
* @example
* ```ts
* editor.this.handleSvgText = myCustomMethod
* ```
*
* @param editor - The editor instance.
* @param info - The info object describing the external content.
*
* @public
*/
async handleSvgText(
editor: Editor,
{ point, text }: Extract<TLExternalContent, { type: 'svg-text' }>
) {
const position =
point ?? (editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.viewportPageCenter)
const svg = new DOMParser().parseFromString(text, 'image/svg+xml').querySelector('svg')
if (!svg) {
throw new Error('No <svg/> element present')
}
let width = parseFloat(svg.getAttribute('width') || '0')
let height = parseFloat(svg.getAttribute('height') || '0')
if (!(width && height)) {
document.body.appendChild(svg)
const box = svg.getBoundingClientRect()
document.body.removeChild(svg)
width = box.width
height = box.height
}
const asset = await this.createAssetFromFile(
editor,
new File([text], 'asset.svg', { type: 'image/svg+xml' })
)
this.createShapesForAssets(editor, [asset], position)
}
/**
* Handle embed info from an external source. Feeling lucky? Overwrite this at runtime to change the way this type of external content is handled.
*
* @example
* ```ts
* editor.this.handleEmbed = myCustomMethod
* ```
*
* @param editor - The editor instance
* @param info - The info object describing the external content.
*
* @public
*/
async handleEmbed(
editor: Editor,
{ point, url, embed }: Extract<TLExternalContent, { type: 'embed' }>
) {
const position =
point ?? (editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.viewportPageCenter)
const { width, height, doesResize } = embed
editor.createShapes(
[
{
id: createShapeId(),
type: 'embed',
x: position.x - (width || 450) / 2,
y: position.y - (height || 450) / 2,
props: {
w: width,
h: height,
doesResize: doesResize,
url,
},
},
],
true
)
}
/**
* Handle files from an external source. Feeling lucky? Overwrite this at runtime to change the way this type of external content is handled.
*
* @example
* ```ts
* editor.this.handleFiles = myCustomMethod
* ```
*
* @param editor - The editor instance
* @param info - The info object describing the external content.
*
* @public
*/
async handleFiles(
editor: Editor,
{ point, files }: Extract<TLExternalContent, { type: 'files' }>
) {
const position =
point ?? (editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.viewportPageCenter)
const pagePoint = new Vec2d(position.x, position.y)
const assets: TLAsset[] = []
await Promise.all(
files.map(async (file, i) => {
// Use mime type instead of file ext, this is because
// window.navigator.clipboard does not preserve file names
// of copied files.
if (!file.type) throw new Error('No mime type')
// We can only accept certain extensions (either images or a videos)
if (!ACCEPTED_IMG_TYPE.concat(ACCEPTED_VID_TYPE).includes(file.type)) {
console.warn(`${file.name} not loaded - Extension not allowed.`)
return null
}
try {
const asset = await this.createAssetFromFile(editor, file)
if (!asset) throw Error('Could not create an asset')
assets[i] = asset
} catch (error) {
console.error(error)
return null
}
})
)
this.createShapesForAssets(editor, compact(assets), pagePoint)
}
/**
* Handle plain text from an external source. Feeling lucky? Overwrite this at runtime to change the way this type of external content is handled.
*
* @example
* ```ts
* editor.this.handleText = myCustomMethod
* ```
*
* @param editor - The editor instance
* @param info - The info object describing the external content.
*
* @public
*/
async handleText(editor: Editor, { point, text }: Extract<TLExternalContent, { type: 'text' }>) {
const p =
point ?? (editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.viewportPageCenter)
const defaultProps = editor.getShapeUtil(TextShapeUtil).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 = editor.textMeasure.measureText(textToPaste, {
...TEXT_PROPS,
fontFamily: FONT_FAMILIES[defaultProps.font],
fontSize: FONT_SIZES[defaultProps.size],
width: 'fit-content',
})
const minWidth = Math.min(
isMultiLine ? editor.viewportPageBounds.width * 0.9 : 920,
Math.max(200, editor.viewportPageBounds.width * 0.9)
)
if (rawSize.w > minWidth) {
const shrunkSize = editor.textMeasure.measureText(textToPaste, {
...TEXT_PROPS,
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 < editor.viewportPageBounds.minY + 40) {
p.y = editor.viewportPageBounds.minY + 40 + h / 2
}
editor.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,
},
},
])
}
/**
* Handle urls from an external source. Feeling lucky? Overwrite this at runtime to change the way this type of external content is handled.
*
* @example
* ```ts
* editor.this.handleUrl = myCustomMethod
* ```
*
* @param editor - The editor instance
* @param info - The info object describing the external content.
*
* @public
*/
handleUrl = async (
editor: Editor,
{ point, url }: Extract<TLExternalContent, { type: 'url' }>
) => {
// try to paste as an embed first
const embedInfo = getEmbedInfo(url)
if (embedInfo) {
return this.handleEmbed(editor, {
type: 'embed',
url: embedInfo.url,
point,
embed: embedInfo.definition,
})
}
const position =
point ?? (editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.viewportPageCenter)
const assetId: TLAssetId = AssetRecordType.createId(getHashForString(url))
// Use an existing asset if we have one, or else else create a new one
let asset = editor.getAssetById(assetId) as TLAsset
let shouldAlsoCreateAsset = false
if (!asset) {
shouldAlsoCreateAsset = true
asset = await this.createAssetFromUrl(editor, url)
}
editor.batch(() => {
if (shouldAlsoCreateAsset) {
editor.createAssets([asset])
}
this.createShapesForAssets(editor, [asset], position)
})
}
async createShapesForAssets(editor: Editor, assets: TLAsset[], position: VecLike) {
if (!assets.length) return
const currentPoint = Vec2d.From(position)
const paritals: TLShapePartial[] = []
for (const asset of assets) {
switch (asset.type) {
case 'bookmark': {
paritals.push({
id: createShapeId(),
type: 'bookmark',
x: currentPoint.x - 150,
y: currentPoint.y - 160,
opacity: 1,
props: {
assetId: asset.id,
url: asset.props.src,
},
})
currentPoint.x += 300
break
}
case 'image': {
paritals.push({
id: createShapeId(),
type: 'image',
x: currentPoint.x - asset.props.w / 2,
y: currentPoint.y - asset.props.h / 2,
opacity: 1,
props: {
assetId: asset.id,
w: asset.props.w,
h: asset.props.h,
},
})
currentPoint.x += asset.props.w
break
}
case 'video': {
paritals.push({
id: createShapeId(),
type: 'video',
x: currentPoint.x - asset.props.w / 2,
y: currentPoint.y - asset.props.h / 2,
opacity: 1,
props: {
assetId: asset.id,
w: asset.props.w,
h: asset.props.h,
},
})
currentPoint.x += asset.props.w
}
}
}
editor.batch(() => {
// Create any assets
const assetsToCreate = assets.filter((asset) => !editor.getAssetById(asset.id))
if (assetsToCreate.length) {
editor.createAssets(assetsToCreate)
}
// Create the shapes
editor.createShapes(paritals, true)
// Re-position shapes so that the center of the group is at the provided point
const { viewportPageBounds } = editor
let { selectedPageBounds } = editor
if (selectedPageBounds) {
const offset = selectedPageBounds!.center.sub(position)
editor.updateShapes(
paritals.map((partial) => {
return {
id: partial.id,
type: partial.type,
x: partial.x! - offset.x,
y: partial.y! - offset.y,
}
})
)
}
// Zoom out to fit the shapes, if necessary
selectedPageBounds = editor.selectedPageBounds
if (selectedPageBounds && !viewportPageBounds.contains(selectedPageBounds)) {
editor.zoomToSelection()
}
})
}
/**
* Override this method to change how assets are created from files.
*
* @param editor - The editor instance
* @param file - The file to create the asset from.
*/
async createAssetFromFile(_editor: Editor, file: File): Promise<TLAsset> {
return await new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(reader.error)
reader.onload = async () => {
let dataUrl = reader.result as string
const isImageType = isImage(file.type)
const sizeFn = isImageType ? getImageSizeFromSrc : getVideoSizeFromSrc
// Hack to make .mov videos work via dataURL.
if (file.type === 'video/quicktime' && dataUrl.includes('video/quicktime')) {
dataUrl = dataUrl.replace('video/quicktime', 'video/mp4')
}
const originalSize = await sizeFn(dataUrl)
const size = containBoxSize(originalSize, { w: MAX_ASSET_WIDTH, h: MAX_ASSET_HEIGHT })
if (size !== originalSize && (file.type === 'image/jpeg' || file.type === 'image/png')) {
// If we created a new size and the type is an image, rescale the image
dataUrl = await getResizedImageDataUrl(dataUrl, size.w, size.h)
}
const assetId: TLAssetId = AssetRecordType.createId(getHashForString(dataUrl))
const metadata = await getFileMetaData(file)
const asset: Extract<TLAsset, { type: 'image' | 'video' }> = {
id: assetId,
type: isImageType ? 'image' : 'video',
typeName: 'asset',
props: {
name: file.name,
src: dataUrl,
w: size.w,
h: size.h,
mimeType: file.type,
isAnimated: metadata.isAnimated,
},
}
resolve(asset)
}
reader.readAsDataURL(file)
})
}
/**
* Override me to change the way assets are created from urls.
*
* @param editor - The editor instance
* @param url - The url to create the asset from
*/
async createAssetFromUrl(_editor: Editor, url: string): Promise<TLAsset> {
let meta: { image: string; title: string; description: string }
try {
const resp = await fetch(url, { method: 'GET', mode: 'no-cors' })
const html = await resp.text()
const doc = new DOMParser().parseFromString(html, 'text/html')
meta = {
image: doc.head.querySelector('meta[property="og:image"]')?.getAttribute('content') ?? '',
title:
doc.head.querySelector('meta[property="og:title"]')?.getAttribute('content') ??
truncateStringWithEllipsis(url, 32),
description:
doc.head.querySelector('meta[property="og:description"]')?.getAttribute('content') ?? '',
}
} catch (error) {
console.error(error)
meta = { image: '', title: truncateStringWithEllipsis(url, 32), description: '' }
}
// Create the bookmark asset from the meta
return {
id: AssetRecordType.createId(getHashForString(url)),
typeName: 'asset',
type: 'bookmark',
props: {
src: url,
description: meta.description,
image: meta.image,
title: meta.title,
},
}
}
}
/* --------------------- Helpers -------------------- */
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+$/, '')
}

View file

@ -140,11 +140,11 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
}, },
]) ])
} }
} else if (this.editor.onCreateBookmarkFromUrl) { } else {
// Create a bookmark asset for the URL. First get its meta // Create a bookmark asset for the URL. First get its meta
// data, then create the asset and update the shape. // data, then create the asset and update the shape.
this.editor.onCreateBookmarkFromUrl(url).then((meta) => { this.editor.externalContentManager.createAssetFromUrl(this.editor, url).then((asset) => {
if (!meta) { if (!asset) {
this.editor.updateShapes([ this.editor.updateShapes([
{ {
id: shape.id, id: shape.id,
@ -156,27 +156,14 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
} }
this.editor.batch(() => { this.editor.batch(() => {
this.editor this.editor.createAssets([asset])
.createAssets([ this.editor.updateShapes([
{ {
id: assetId, id: shape.id,
typeName: 'asset', type: shape.type,
type: 'bookmark', props: { assetId: asset.id },
props: { },
src: url, ])
description: meta.description,
image: meta.image,
title: meta.title,
},
},
])
.updateShapes([
{
id: shape.id,
type: shape.type,
props: { assetId },
},
])
}) })
}) })
} }

View file

@ -141,7 +141,7 @@ export class Idle extends StateNode {
case 'canvas': { case 'canvas': {
// Create text shape and transition to editing_shape // Create text shape and transition to editing_shape
if (this.editor.isReadOnly) break if (this.editor.isReadOnly) break
this.createTextShapeAtPoint(info) this.handleDoubleClickOnCanvas(info)
break break
} }
case 'selection': { case 'selection': {
@ -209,7 +209,7 @@ export class Idle extends StateNode {
// If the shape's double click handler has not created a change, // If the shape's double click handler has not created a change,
// and if the shape cannot edit, then create a text shape and // and if the shape cannot edit, then create a text shape and
// begin editing the text shape // begin editing the text shape
this.createTextShapeAtPoint(info) this.handleDoubleClickOnCanvas(info)
} }
break break
} }
@ -375,7 +375,7 @@ export class Idle extends StateNode {
this.parent.transition('editing_shape', info) this.parent.transition('editing_shape', info)
} }
private createTextShapeAtPoint(info: TLClickEventInfo) { handleDoubleClickOnCanvas(info: TLClickEventInfo) {
this.editor.mark('creating text shape') this.editor.mark('creating text shape')
const id = createShapeId() const id = createShapeId()

View file

@ -1,5 +1,4 @@
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { createShapesFromFiles } from '../utils/assets'
import { preventDefault, releasePointerCapture, setPointerCapture } from '../utils/dom' import { preventDefault, releasePointerCapture, setPointerCapture } from '../utils/dom'
import { getPointerInfo } from '../utils/svg' import { getPointerInfo } from '../utils/svg'
import { useEditor } from './useEditor' import { useEditor } from './useEditor'
@ -107,7 +106,12 @@ export function useCanvasEvents() {
(file) => !file.name.endsWith('.tldr') (file) => !file.name.endsWith('.tldr')
) )
await createShapesFromFiles(editor, files, editor.screenToPage(e.clientX, e.clientY), false) await editor.putExternalContent({
type: 'files',
files,
point: editor.screenToPage(e.clientX, e.clientY),
ignoreParent: false,
})
} }
return { return {

View file

@ -36,7 +36,12 @@ describe('<TldrawEditor />', () => {
let store: any let store: any
render( render(
await act(async () => ( await act(async () => (
<TldrawEditor onMount={(editor) => (store = editor.store)} autoFocus> <TldrawEditor
onMount={(editor) => {
store = editor.store
}}
autoFocus
>
<div data-testid="canvas-1" /> <div data-testid="canvas-1" />
</TldrawEditor> </TldrawEditor>
)) ))

View file

@ -1,19 +1,7 @@
import { Box2d, Vec2d, VecLike } from '@tldraw/primitives' import { AssetRecordType, TLAsset, TLAssetId } from '@tldraw/tlschema'
import { import { getHashForString } from '@tldraw/utils'
AssetRecordType,
TLAsset,
TLAssetId,
TLBookmarkAsset,
TLImageShape,
TLShapePartial,
TLVideoShape,
Vec2dModel,
createShapeId,
} from '@tldraw/tlschema'
import { compact, getHashForString } from '@tldraw/utils'
import uniq from 'lodash.uniq' import uniq from 'lodash.uniq'
import { MAX_ASSET_HEIGHT, MAX_ASSET_WIDTH } from '../constants' import { MAX_ASSET_HEIGHT, MAX_ASSET_WIDTH } from '../constants'
import { Editor } from '../editor/Editor'
import { isAnimated } from './is-gif-animated' import { isAnimated } from './is-gif-animated'
import { findChunk, isPng, parsePhys } from './png' import { findChunk, isPng, parsePhys } from './png'
@ -251,297 +239,6 @@ export function containBoxSize(
} }
} }
/** @public */
export async function createShapesFromFiles(
editor: Editor,
files: File[],
position: VecLike,
_ignoreParent = false
) {
const pagePoint = new Vec2d(position.x, position.y)
const newAssetsForFiles = new Map<File, TLAsset>()
const shapePartials = await Promise.all(
files.map(async (file, i) => {
// Use mime type instead of file ext, this is because
// window.navigator.clipboard does not preserve file names
// of copied files.
if (!file.type) throw new Error('No mime type')
// We can only accept certain extensions (either images or a videos)
if (!ACCEPTED_IMG_TYPE.concat(ACCEPTED_VID_TYPE).includes(file.type)) {
console.warn(`${file.name} not loaded - Extension not allowed.`)
return null
}
try {
const asset = await editor.onCreateAssetFromFile(file)
if (asset.type === 'bookmark') return
if (!asset) throw Error('Could not create an asset')
newAssetsForFiles.set(file, asset)
const shapePartial: TLShapePartial<TLImageShape | TLVideoShape> = {
id: createShapeId(),
type: asset.type,
x: pagePoint.x + i,
y: pagePoint.y,
props: {
w: asset.props!.w,
h: asset.props!.h,
},
}
return shapePartial
} catch (error) {
console.error(error)
return null
}
})
)
// Filter any nullish values and sort the resulting models by x, so that the
// left-most model is created first (and placed lowest in the z-order).
const results = compact(shapePartials).sort((a, b) => a.x! - b.x!)
if (results.length === 0) return
// Adjust the placement of the models.
for (let i = 0; i < results.length; i++) {
const model = results[i]
if (i === 0) {
// The first shape is placed so that its center is at the dropping point
model.x! -= model.props!.w! / 2
model.y! -= model.props!.h! / 2
} else {
// Later models are placed to the right of the first shape
const prevModel = results[i - 1]
model.x = prevModel.x! + prevModel.props!.w!
model.y = prevModel.y!
}
}
const shapeUpdates = await Promise.all(
files.map(async (file, i) => {
const shape = results[i]
if (!shape) return
const asset = newAssetsForFiles.get(file)
if (!asset) return
// Does the asset collection already have a model with this id
let existing: TLAsset | undefined = editor.getAssetById(asset.id)
if (existing) {
newAssetsForFiles.delete(file)
if (shape.props) {
shape.props.assetId = existing.id
}
return shape
}
existing = editor.getAssetBySrc(asset.props!.src!)
if (existing) {
if (shape.props) {
shape.props.assetId = existing.id
}
return shape
}
// Create a new model for the new source file
if (shape.props) {
shape.props.assetId = asset.id
}
return shape
})
)
const filteredUpdates = compact(shapeUpdates)
editor.createAssets(compact([...newAssetsForFiles.values()]))
editor.createShapes(filteredUpdates)
editor.setSelectedIds(filteredUpdates.map((s) => s.id))
const { selectedIds, viewportPageBounds } = editor
const pageBounds = Box2d.Common(compact(selectedIds.map((id) => editor.getPageBoundsById(id))))
if (pageBounds && !viewportPageBounds.contains(pageBounds)) {
editor.zoomToSelection()
}
}
/** @public */
export function createEmbedShapeAtPoint(
editor: Editor,
url: string,
point: Vec2dModel,
props: {
width?: number
height?: number
doesResize?: boolean
}
) {
editor.createShapes(
[
{
id: createShapeId(),
type: 'embed',
x: point.x - (props.width || 450) / 2,
y: point.y - (props.height || 450) / 2,
props: {
w: props.width,
h: props.height,
doesResize: props.doesResize,
url,
},
},
],
true
)
}
/**
* Create a bookmark shape at a given point.
*
* @param editor - The editor to create the bookmark shape in.
* @param url - The bookmark's url.
* @param point - The point to insert the bookmark shape.
* @public
*/
export async function createBookmarkShapeAtPoint(editor: Editor, url: string, point: Vec2dModel) {
const assetId: TLAssetId = AssetRecordType.createId(getHashForString(url))
const existing = editor.getAssetById(assetId) as TLBookmarkAsset
if (existing) {
editor.createShapes([
{
id: createShapeId(),
type: 'bookmark',
x: point.x - 150,
y: point.y - 160,
opacity: 1,
props: {
assetId: existing.id,
url: existing.props.src!,
},
},
])
return
}
editor.batch(async () => {
const shapeId = createShapeId()
editor.createShapes(
[
{
id: shapeId,
type: 'bookmark',
x: point.x,
y: point.y,
opacity: 1,
props: {
url: url,
},
},
],
true
)
const meta = await editor.onCreateBookmarkFromUrl(url)
if (meta) {
editor.createAssets([
{
id: assetId,
typeName: 'asset',
type: 'bookmark',
props: {
src: url,
description: meta.description,
image: meta.image,
title: meta.title,
},
},
])
editor.updateShapes([
{
id: shapeId,
type: 'bookmark',
opacity: 1,
props: {
assetId: assetId,
},
},
])
}
})
}
/** @public */
export async function createAssetShapeAtPoint(
editor: Editor,
svgString: string,
point: Vec2dModel
) {
const svg = new DOMParser().parseFromString(svgString, 'image/svg+xml').querySelector('svg')
if (!svg) {
throw new Error('No <svg/> element present')
}
let width = parseFloat(svg.getAttribute('width') || '0')
let height = parseFloat(svg.getAttribute('height') || '0')
if (!(width && height)) {
document.body.appendChild(svg)
const box = svg.getBoundingClientRect()
document.body.removeChild(svg)
width = box.width
height = box.height
}
const asset = await editor.onCreateAssetFromFile(
new File([svgString], 'asset.svg', { type: 'image/svg+xml' })
)
if (asset.type !== 'bookmark') {
asset.props.w = width
asset.props.h = height
}
editor.batch(() => {
editor.createAssets([asset])
editor.createShapes(
[
{
id: createShapeId(),
type: 'image',
x: point.x - width / 2,
y: point.y - height / 2,
opacity: 1,
props: {
assetId: asset.id,
w: width,
h: height,
},
},
],
true
)
})
}
/** @public */ /** @public */
export const isValidHttpURL = (url: string) => { export const isValidHttpURL = (url: string) => {
try { try {

View file

@ -629,7 +629,7 @@ async function tryMigrateAsset(editor: Editor, placeholderAsset: TLAsset) {
type: response.headers.get('content-type') ?? placeholderAsset.props.mimeType ?? undefined, type: response.headers.get('content-type') ?? placeholderAsset.props.mimeType ?? undefined,
}) })
const newAsset = await editor.onCreateAssetFromFile(file) const newAsset = await editor.externalContentManager.createAssetFromFile(editor, file)
if (newAsset.type === 'bookmark') return if (newAsset.type === 'bookmark') return
editor.updateAssets([ editor.updateAssets([

View file

@ -1,3 +1,6 @@
import { act } from '@testing-library/react'
import { TldrawEditor } from '@tldraw/editor'
let originalFetch: typeof window.fetch let originalFetch: typeof window.fetch
beforeEach(() => { beforeEach(() => {
window.fetch = jest.fn().mockImplementation((...args: Parameters<typeof fetch>) => { window.fetch = jest.fn().mockImplementation((...args: Parameters<typeof fetch>) => {
@ -16,10 +19,10 @@ afterEach(() => {
describe('<Tldraw />', () => { describe('<Tldraw />', () => {
it('Renders without crashing', async () => { it('Renders without crashing', async () => {
// const onMount = jest.fn() await act(async () => (
// act(() => render(<Tldraw onMount={onMount} />)) <TldrawEditor autoFocus>
<div data-testid="canvas-1" />
// todo </TldrawEditor>
expect(true).toBe(true) ))
}) })
}) })

View file

@ -1,4 +1,4 @@
import { defaultUiAssetUrls, TLUiAssetUrls } from './assetUrls' import { TLUiAssetUrls, defaultUiAssetUrls } from './assetUrls'
import { ActionsProvider } from './hooks/useActions' import { ActionsProvider } from './hooks/useActions'
import { ActionsMenuSchemaProvider } from './hooks/useActionsMenuSchema' import { ActionsMenuSchemaProvider } from './hooks/useActionsMenuSchema'
import { AssetUrlsProvider } from './hooks/useAssetUrls' import { AssetUrlsProvider } from './hooks/useAssetUrls'

View file

@ -1,4 +1,4 @@
import { TLEmbedResult, createEmbedShapeAtPoint, getEmbedInfo, useEditor } from '@tldraw/editor' import { TLEmbedResult, getEmbedInfo, useEditor } from '@tldraw/editor'
import { EMBED_DEFINITIONS, EmbedDefinition } from '@tldraw/tlschema' import { EMBED_DEFINITIONS, EmbedDefinition } from '@tldraw/tlschema'
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import { track } from 'signia-react' import { track } from 'signia-react'
@ -105,10 +105,11 @@ export const EmbedDialog = track(function EmbedDialog({ onClose }: TLUiDialogPro
onClick={() => { onClick={() => {
if (!embedInfoForUrl) return if (!embedInfoForUrl) return
createEmbedShapeAtPoint(editor, url, editor.viewportPageCenter, { editor.putExternalContent({
width: embedInfoForUrl.definition.width, type: 'embed',
height: embedInfoForUrl.definition.height, url,
doesResize: embedInfoForUrl.definition.doesResize, point: editor.viewportPageCenter,
embed: embedInfoForUrl.definition,
}) })
onClose() onClose()

View file

@ -1,4 +1,4 @@
import { Editor, createShapesFromFiles } from '@tldraw/editor' import { Editor } from '@tldraw/editor'
import { VecLike } from '@tldraw/primitives' import { VecLike } from '@tldraw/primitives'
/** /**
@ -6,23 +6,21 @@ import { VecLike } from '@tldraw/primitives'
* *
* @param editor - The editor instance. * @param editor - The editor instance.
* @param urls - The file urls. * @param urls - The file urls.
* @param point - The point at which to paste the file. * @param point - (optional) The point at which to paste the file.
* @internal * @internal
*/ */
export async function pasteFiles(editor: Editor, urls: string[], point?: VecLike) { export async function pasteFiles(editor: Editor, urls: string[], point?: VecLike) {
const p =
point ?? (editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.viewportPageCenter)
const blobs = await Promise.all(urls.map(async (url) => await (await fetch(url)).blob())) 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 }))
const files = blobs.map(
(blob) =>
new File([blob], 'tldrawFile', {
type: blob.type,
})
)
editor.mark('paste') editor.mark('paste')
await createShapesFromFiles(editor, files, p, false)
await editor.putExternalContent({
type: 'files',
files,
point,
ignoreParent: false,
})
urls.forEach((url) => URL.revokeObjectURL(url)) urls.forEach((url) => URL.revokeObjectURL(url))
} }

View file

@ -1,139 +0,0 @@
import {
Editor,
FONT_FAMILIES,
FONT_SIZES,
INDENT,
TEXT_PROPS,
TextShapeUtil,
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 editor - The editor instance.
* @param text - The text to paste.
* @param point - (optional) The point at which to paste the text.
* @internal
*/
export async function pastePlainText(editor: Editor, text: string, point?: VecLike) {
const p =
point ?? (editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.viewportPageCenter)
const defaultProps = editor.getShapeUtil(TextShapeUtil).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 = editor.textMeasure.measureText(textToPaste, {
...TEXT_PROPS,
fontFamily: FONT_FAMILIES[defaultProps.font],
fontSize: FONT_SIZES[defaultProps.size],
width: 'fit-content',
})
const minWidth = Math.min(
isMultiLine ? editor.viewportPageBounds.width * 0.9 : 920,
Math.max(200, editor.viewportPageBounds.width * 0.9)
)
if (rawSize.w > minWidth) {
const shrunkSize = editor.textMeasure.measureText(textToPaste, {
...TEXT_PROPS,
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 < editor.viewportPageBounds.minY + 40) {
p.y = editor.viewportPageBounds.minY + 40 + h / 2
}
editor.mark('paste')
editor.createShapes([
{
id: createShapeId(),
type: 'text',
x: p.x - w / 2,
y: p.y - h / 2,
props: {
text: textToPaste,
// if the text has more than one line, align it to the left
align,
autoSize,
w,
},
},
])
}

View file

@ -1,18 +0,0 @@
import { Editor, 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 editor - The editor instance.
* @param text - The text to paste.
* @param point - (optional) The point at which to paste the text.
* @internal
*/
export async function pasteSvgText(editor: Editor, text: string, point?: VecLike) {
const p =
point ?? (editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.viewportPageCenter)
editor.mark('paste')
return await createAssetShapeAtPoint(editor, text, p)
}

View file

@ -1,9 +1,4 @@
import { import { Editor } from '@tldraw/editor'
Editor,
createBookmarkShapeAtPoint,
createEmbedShapeAtPoint,
getEmbedInfo,
} from '@tldraw/editor'
import { VecLike } from '@tldraw/primitives' import { VecLike } from '@tldraw/primitives'
import { pasteFiles } from './pasteFiles' import { pasteFiles } from './pasteFiles'
@ -17,9 +12,6 @@ import { pasteFiles } from './pasteFiles'
* @internal * @internal
*/ */
export async function pasteUrl(editor: Editor, url: string, point?: VecLike) { export async function pasteUrl(editor: Editor, url: string, point?: VecLike) {
const p =
point ?? (editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.viewportPageCenter)
// Lets see if its an image and we have CORs // Lets see if its an image and we have CORs
try { try {
const resp = await fetch(url) const resp = await fetch(url)
@ -36,13 +28,9 @@ export async function pasteUrl(editor: Editor, url: string, point?: VecLike) {
editor.mark('paste') editor.mark('paste')
// try to paste as an embed first return await editor.putExternalContent({
const embedInfo = getEmbedInfo(url) type: 'url',
point,
if (embedInfo) { url,
return await createEmbedShapeAtPoint(editor, embedInfo.url, p, embedInfo.definition) })
}
// otherwise, try to paste as a bookmark
return await createBookmarkShapeAtPoint(editor, url, p)
} }

View file

@ -17,8 +17,6 @@ import { compressToBase64, decompressFromBase64 } from 'lz-string'
import { useCallback, useEffect } from 'react' import { useCallback, useEffect } from 'react'
import { pasteExcalidrawContent } from './clipboard/pasteExcalidrawContent' import { pasteExcalidrawContent } from './clipboard/pasteExcalidrawContent'
import { pasteFiles } from './clipboard/pasteFiles' import { pasteFiles } from './clipboard/pasteFiles'
import { pastePlainText } from './clipboard/pastePlainText'
import { pasteSvgText } from './clipboard/pasteSvgText'
import { pasteTldrawContent } from './clipboard/pasteTldrawContent' import { pasteTldrawContent } from './clipboard/pasteTldrawContent'
import { pasteUrl } from './clipboard/pasteUrl' import { pasteUrl } from './clipboard/pasteUrl'
import { useEditorIsFocused } from './useEditorIsFocused' import { useEditorIsFocused } from './useEditorIsFocused'
@ -99,9 +97,19 @@ const handleText = (editor: Editor, data: string, point?: VecLike) => {
} else if (isValidHttpURL(data)) { } else if (isValidHttpURL(data)) {
pasteUrl(editor, data, point) pasteUrl(editor, data, point)
} else if (isSvgText(data)) { } else if (isSvgText(data)) {
pasteSvgText(editor, data, point) editor.mark('paste')
editor.putExternalContent({
type: 'svg-text',
text: data,
point,
})
} else { } else {
pastePlainText(editor, data, point) editor.mark('paste')
editor.putExternalContent({
type: 'text',
text: data,
point,
})
} }
} }

View file

@ -1,4 +1,4 @@
import { ACCEPTED_ASSET_TYPE, createShapesFromFiles, useEditor } from '@tldraw/editor' import { ACCEPTED_ASSET_TYPE, useEditor } from '@tldraw/editor'
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
export function useInsertMedia() { export function useInsertMedia() {
@ -14,12 +14,12 @@ export function useInsertMedia() {
async function onchange(e: Event) { async function onchange(e: Event) {
const fileList = (e.target as HTMLInputElement).files const fileList = (e.target as HTMLInputElement).files
if (!fileList || fileList.length === 0) return if (!fileList || fileList.length === 0) return
await createShapesFromFiles( await editor.putExternalContent({
editor, type: 'files',
Array.from(fileList), files: Array.from(fileList),
editor.viewportPageBounds.center, point: editor.viewportPageBounds.center,
false ignoreParent: false,
) })
input.value = '' input.value = ''
} }
input.addEventListener('change', onchange) input.addEventListener('change', onchange)