| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394 |
- import {
- BoundsUtils,
- getSizeFromSrc,
- isNonNullable,
- TLAsset,
- TLBinding,
- TLCursor,
- TLShapeModel,
- uniqueId,
- validUUID,
- } from '@tldraw/core'
- import type { TLReactCallbacks } from '@tldraw/react'
- import Vec from '@tldraw/vec'
- import * as React from 'react'
- import { NIL as NIL_UUID } from 'uuid'
- import {
- HTMLShape,
- IFrameShape,
- ImageShape,
- LogseqPortalShape,
- VideoShape,
- YouTubeShape,
- type Shape,
- } from '../lib'
- import { LogseqContext } from '../lib/logseq-context'
- const isValidURL = (url: string) => {
- try {
- new URL(url)
- return true
- } catch {
- return false
- }
- }
- interface VideoImageAsset extends TLAsset {
- size?: number[]
- }
- const IMAGE_EXTENSIONS = ['.png', '.svg', '.jpg', '.jpeg', '.gif']
- const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.ogg']
- function getFileType(filename: string) {
- // Get extension, verify that it's an image
- const extensionMatch = filename.match(/\.[0-9a-z]+$/i)
- if (!extensionMatch) {
- return 'unknown'
- }
- const extension = extensionMatch[0].toLowerCase()
- if (IMAGE_EXTENSIONS.includes(extension)) {
- return 'image'
- }
- if (VIDEO_EXTENSIONS.includes(extension)) {
- return 'video'
- }
- return 'unknown'
- }
- type MaybeShapes = Shape['props'][] | null | undefined
- type CreateShapeFN<Args extends any[]> = (...args: Args) => Promise<MaybeShapes> | MaybeShapes
- /**
- * Try create a shape from a list of create shape functions. If one of the functions returns a
- * shape, return it, otherwise try again for the next one until all have been tried.
- */
- function tryCreateShapeHelper<Args extends any[]>(...fns: CreateShapeFN<Args>[]) {
- return async (...args: Args) => {
- for (const fn of fns) {
- const result = await fn(...(args as any))
- if (result && result.length > 0) {
- return result
- }
- }
- return null
- }
- }
- // TODO: support file types
- async function getDataFromType(item: DataTransfer | ClipboardItem, type: `text/${string}`) {
- if (!item.types.includes(type)) {
- return null
- }
- if (item instanceof DataTransfer) {
- return item.getData(type)
- }
- const blob = await item.getType(type)
- return await blob.text()
- }
- // FIXME: for assets, we should prompt the user a loading spinner
- export function usePaste() {
- const { handlers } = React.useContext(LogseqContext)
- return React.useCallback<TLReactCallbacks<Shape>['onPaste']>(
- async (app, { point, shiftKey, dataTransfer, fromDrop }) => {
- let imageAssetsToCreate: VideoImageAsset[] = []
- let assetsToClone: TLAsset[] = []
- const bindingsToCreate: TLBinding[] = []
- async function createAssetsFromURL(url: string, isVideo: boolean): Promise<VideoImageAsset> {
- // Do we already have an asset for this image?
- const existingAsset = Object.values(app.assets).find(asset => asset.src === url)
- if (existingAsset) {
- return existingAsset as VideoImageAsset
- } else {
- // Create a new asset for this image
- const asset: VideoImageAsset = {
- id: uniqueId(),
- type: isVideo ? 'video' : 'image',
- src: url,
- size: await getSizeFromSrc(handlers.makeAssetUrl(url), isVideo),
- }
- return asset
- }
- }
- async function createAssetsFromFiles(files: File[]) {
- const tasks = files
- .filter(file => getFileType(file.name) !== 'unknown')
- .map(async file => {
- try {
- const dataurl = await handlers.saveAsset(file)
- return await createAssetsFromURL(dataurl, getFileType(file.name) === 'video')
- } catch (err) {
- console.error(err)
- }
- return null
- })
- return (await Promise.all(tasks)).filter(isNonNullable)
- }
- function createHTMLShape(text: string) {
- return [
- {
- ...HTMLShape.defaultProps,
- html: text,
- point: [point[0], point[1]],
- },
- ]
- }
- async function tryCreateShapesFromDataTransfer(dataTransfer: DataTransfer) {
- return tryCreateShapeHelper(
- tryCreateShapeFromFiles,
- tryCreateShapeFromTextHTML,
- tryCreateShapeFromTextPlain,
- tryCreateShapeFromBlockUUID
- )(dataTransfer)
- }
- async function tryCreateShapesFromClipboard() {
- const items = await navigator.clipboard.read()
- const createShapesFn = tryCreateShapeHelper(
- tryCreateShapeFromTextHTML,
- tryCreateShapeFromTextPlain
- )
- const allShapes = (await Promise.all(items.map(item => createShapesFn(item))))
- .flat()
- .filter(isNonNullable)
- return allShapes
- }
- async function tryCreateShapeFromFiles(item: DataTransfer) {
- const files = Array.from(item.files)
- if (files.length > 0) {
- const assets = await createAssetsFromFiles(files)
- // ? could we get rid of this side effect?
- imageAssetsToCreate = assets
- return assets.map((asset, i) => {
- const defaultProps =
- asset.type === 'video' ? VideoShape.defaultProps : ImageShape.defaultProps
- const newShape = {
- ...defaultProps,
- // TODO: Should be place near the last edited shape
- assetId: asset.id,
- opacity: 1,
- }
- if (asset.size) {
- Object.assign(newShape, {
- point: [
- point[0] - asset.size[0] / 4 + i * 16,
- point[1] - asset.size[1] / 4 + i * 16,
- ],
- size: Vec.div(asset.size, 2),
- })
- }
- return newShape
- })
- }
- return null
- }
- async function tryCreateShapeFromTextHTML(item: DataTransfer | ClipboardItem) {
- // skips if it's a drop event or using shift key
- if (item.types.includes('text/plain') && (shiftKey || fromDrop)) {
- return null
- }
- const rawText = await getDataFromType(item, 'text/html')
- if (rawText) {
- return tryCreateShapeHelper(tryCreateClonedShapesFromJSON, createHTMLShape)(rawText)
- }
- return null
- }
- async function tryCreateShapeFromBlockUUID(dataTransfer: DataTransfer) {
- // This is a Logseq custom data type defined in frontend.components.block
- const rawText = dataTransfer.getData('block-uuid')
- if (rawText) {
- const text = rawText.trim()
- const allSelectedBlocks = window.logseq?.api?.get_selected_blocks?.()
- const blockUUIDs =
- allSelectedBlocks && allSelectedBlocks?.length > 1
- ? allSelectedBlocks.map(b => b.uuid)
- : [text]
- // ensure all uuid in blockUUIDs is persisted
- window.logseq?.api?.set_blocks_id?.(blockUUIDs)
- const tasks = blockUUIDs.map(uuid => tryCreateLogseqPortalShapesFromString(`((${uuid}))`))
- const newShapes = (await Promise.all(tasks)).flat().filter(isNonNullable)
- return newShapes.map((s, idx) => {
- // if there are multiple shapes, shift them to the right
- return {
- ...s,
- // TODO: use better alignment?
- point: [point[0] + (LogseqPortalShape.defaultProps.size[0] + 16) * idx, point[1]],
- }
- })
- }
- return null
- }
- async function tryCreateShapeFromTextPlain(item: DataTransfer | ClipboardItem) {
- const rawText = await getDataFromType(item, 'text/plain')
- if (rawText) {
- const text = rawText.trim()
- return tryCreateShapeHelper(
- tryCreateShapeFromURL,
- tryCreateShapeFromIframeString,
- tryCreateLogseqPortalShapesFromString
- )(text)
- }
- return null
- }
- function tryCreateClonedShapesFromJSON(rawText: string) {
- const result = app.api.getClonedShapesFromTldrString(rawText, point)
- if (result) {
- const { shapes, assets, bindings } = result
- assetsToClone.push(...assets)
- bindingsToCreate.push(...bindings)
- return shapes
- }
- return null
- }
- async function tryCreateShapeFromURL(rawText: string) {
- if (isValidURL(rawText) && !(shiftKey || fromDrop)) {
- const isYoutubeUrl = (url: string) => {
- const youtubeRegex =
- /^(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/
- return youtubeRegex.test(url)
- }
- if (isYoutubeUrl(rawText)) {
- return [
- {
- ...YouTubeShape.defaultProps,
- url: rawText,
- point: [point[0], point[1]],
- },
- ]
- }
- return [
- {
- ...IFrameShape.defaultProps,
- url: rawText,
- point: [point[0], point[1]],
- },
- ]
- }
- return null
- }
- function tryCreateShapeFromIframeString(rawText: string) {
- // if rawText is iframe text
- if (rawText.startsWith('<iframe')) {
- return [
- {
- ...HTMLShape.defaultProps,
- html: rawText,
- point: [point[0], point[1]],
- },
- ]
- }
- return null
- }
- async function tryCreateLogseqPortalShapesFromString(rawText: string) {
- if (/^\(\(.*\)\)$/.test(rawText) && rawText.length === NIL_UUID.length + 4) {
- const blockRef = rawText.slice(2, -2)
- if (validUUID(blockRef)) {
- return [
- {
- ...LogseqPortalShape.defaultProps,
- point: [point[0], point[1]],
- size: [400, 0], // use 0 here to enable auto-resize
- pageId: blockRef,
- blockType: 'B' as 'B',
- },
- ]
- }
- }
- // [[page name]] ?
- else if (/^\[\[.*\]\]$/.test(rawText)) {
- const pageName = rawText.slice(2, -2)
- return [
- {
- ...LogseqPortalShape.defaultProps,
- point: [point[0], point[1]],
- size: [400, 0], // use 0 here to enable auto-resize
- pageId: pageName,
- blockType: 'P' as 'P',
- },
- ]
- }
- // Otherwise, creating a new block that belongs to the current whiteboard
- const uuid = handlers?.addNewBlock(rawText)
- if (uuid) {
- // create text shape
- return [
- {
- ...LogseqPortalShape.defaultProps,
- size: [400, 0], // use 0 here to enable auto-resize
- point: [point[0], point[1]],
- pageId: uuid,
- blockType: 'B' as 'B',
- compact: true,
- },
- ]
- }
- return null
- }
- app.cursors.setCursor(TLCursor.Progress)
- let newShapes: Shape['props'][] = []
- try {
- if (dataTransfer) {
- newShapes.push(...((await tryCreateShapesFromDataTransfer(dataTransfer)) ?? []))
- } else {
- // from Clipboard app or Shift copy etc
- // in this case, we do not have the dataTransfer object
- newShapes.push(...((await tryCreateShapesFromClipboard()) ?? []))
- }
- } catch (error) {
- console.error(error)
- }
- const allShapesToAdd: TLShapeModel[] = newShapes.map(shape => {
- return {
- ...shape,
- parentId: app.currentPageId,
- id: validUUID(shape.id) ? shape.id : uniqueId(),
- }
- })
- app.wrapUpdate(() => {
- app.api.addClonedShapes(
- allShapesToAdd,
- [...imageAssetsToCreate, ...assetsToClone],
- bindingsToCreate
- )
- if (app.selectedShapesArray.length === 1 && allShapesToAdd.length === 1 && !fromDrop) {
- const source = app.selectedShapesArray[0]
- const target = app.getShapeById(allShapesToAdd[0].id!)!
- app.createNewLineBinding(source, target)
- }
- app.setSelectedShapes(allShapesToAdd.map(s => s.id))
- app.selectedTool.transition('idle') // clears possible editing states
- app.cursors.setCursor(TLCursor.Default)
- })
- },
- []
- )
- }
|