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:
parent
f2e95988e0
commit
0cc91eec62
22 changed files with 817 additions and 726 deletions
|
@ -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} />
|
||||||
|
|
|
@ -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
|
try {
|
||||||
): Promise<{ image: string; title: string; description: string }> {
|
// First, try to get the data from vscode
|
||||||
const meta = {
|
const meta = await rpc('vscode:bookmark', { url })
|
||||||
image: '',
|
|
||||||
title: '',
|
return {
|
||||||
description: '',
|
id: AssetRecordType.createId(getHashForString(url)),
|
||||||
|
typeName: 'asset',
|
||||||
|
type: 'bookmark',
|
||||||
|
props: {
|
||||||
|
src: url,
|
||||||
|
description: meta.description ?? '',
|
||||||
|
image: meta.image ?? '',
|
||||||
|
title: meta.title ?? truncateStringWithEllipsis(url, 32),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Otherwise, fallback to fetching data from the url
|
||||||
|
|
||||||
|
let meta: { image: string; title: string; description: string }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(url, { method: 'GET', mode: 'no-cors' })
|
const resp = await fetch(url, { method: 'GET', mode: 'no-cors' })
|
||||||
const html = await resp.text()
|
const html = await resp.text()
|
||||||
const doc = new DOMParser().parseFromString(html, 'text/html')
|
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||||
|
meta = {
|
||||||
meta.image = doc.head
|
image: doc.head.querySelector('meta[property="og:image"]')?.getAttribute('content') ?? '',
|
||||||
.querySelector('meta[property="og:image"]')
|
title:
|
||||||
?.getAttribute('content') as string
|
doc.head.querySelector('meta[property="og:title"]')?.getAttribute('content') ??
|
||||||
meta.title = doc.head
|
truncateStringWithEllipsis(url, 32),
|
||||||
.querySelector('meta[property="og:title"]')
|
description:
|
||||||
?.getAttribute('content') as string
|
doc.head.querySelector('meta[property="og:description"]')?.getAttribute('content') ?? '',
|
||||||
meta.description = doc.head
|
}
|
||||||
.querySelector('meta[property="og:description"]')
|
|
||||||
?.getAttribute('content') as string
|
|
||||||
|
|
||||||
return meta
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
meta = { image: '', title: truncateStringWithEllipsis(url, 32), description: '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
return meta
|
// Create the bookmark asset from the 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,
|
||||||
|
image: meta.image,
|
||||||
|
title: meta.title,
|
||||||
|
description: meta.description,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
return onCreateBookmarkFromUrlFallback(url)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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 ---------------- */
|
||||||
|
|
|
@ -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+$/, '')
|
||||||
|
}
|
|
@ -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,25 +156,12 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.editor.batch(() => {
|
this.editor.batch(() => {
|
||||||
this.editor
|
this.editor.createAssets([asset])
|
||||||
.createAssets([
|
this.editor.updateShapes([
|
||||||
{
|
|
||||||
id: assetId,
|
|
||||||
typeName: 'asset',
|
|
||||||
type: 'bookmark',
|
|
||||||
props: {
|
|
||||||
src: url,
|
|
||||||
description: meta.description,
|
|
||||||
image: meta.image,
|
|
||||||
title: meta.title,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
.updateShapes([
|
|
||||||
{
|
{
|
||||||
id: shape.id,
|
id: shape.id,
|
||||||
type: shape.type,
|
type: shape.type,
|
||||||
props: { assetId },
|
props: { assetId: asset.id },
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
))
|
))
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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([
|
||||||
|
|
|
@ -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)
|
))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in a new issue