usePaste.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. import {
  2. BoundsUtils,
  3. getSizeFromSrc,
  4. isNonNullable,
  5. TLAsset,
  6. TLBinding,
  7. TLCursor,
  8. TLShapeModel,
  9. uniqueId,
  10. validUUID,
  11. } from '@tldraw/core'
  12. import type { TLReactCallbacks } from '@tldraw/react'
  13. import Vec from '@tldraw/vec'
  14. import * as React from 'react'
  15. import { NIL as NIL_UUID } from 'uuid'
  16. import {
  17. HTMLShape,
  18. IFrameShape,
  19. ImageShape,
  20. LogseqPortalShape,
  21. VideoShape,
  22. YouTubeShape,
  23. type Shape,
  24. } from '../lib'
  25. import { LogseqContext } from '../lib/logseq-context'
  26. const isValidURL = (url: string) => {
  27. try {
  28. new URL(url)
  29. return true
  30. } catch {
  31. return false
  32. }
  33. }
  34. interface VideoImageAsset extends TLAsset {
  35. size?: number[]
  36. }
  37. const IMAGE_EXTENSIONS = ['.png', '.svg', '.jpg', '.jpeg', '.gif']
  38. const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.ogg']
  39. function getFileType(filename: string) {
  40. // Get extension, verify that it's an image
  41. const extensionMatch = filename.match(/\.[0-9a-z]+$/i)
  42. if (!extensionMatch) {
  43. return 'unknown'
  44. }
  45. const extension = extensionMatch[0].toLowerCase()
  46. if (IMAGE_EXTENSIONS.includes(extension)) {
  47. return 'image'
  48. }
  49. if (VIDEO_EXTENSIONS.includes(extension)) {
  50. return 'video'
  51. }
  52. return 'unknown'
  53. }
  54. type MaybeShapes = Shape['props'][] | null | undefined
  55. type CreateShapeFN<Args extends any[]> = (...args: Args) => Promise<MaybeShapes> | MaybeShapes
  56. /**
  57. * Try create a shape from a list of create shape functions. If one of the functions returns a
  58. * shape, return it, otherwise try again for the next one until all have been tried.
  59. */
  60. function tryCreateShapeHelper<Args extends any[]>(...fns: CreateShapeFN<Args>[]) {
  61. return async (...args: Args) => {
  62. for (const fn of fns) {
  63. const result = await fn(...(args as any))
  64. if (result && result.length > 0) {
  65. return result
  66. }
  67. }
  68. return null
  69. }
  70. }
  71. // TODO: support file types
  72. async function getDataFromType(item: DataTransfer | ClipboardItem, type: `text/${string}`) {
  73. if (!item.types.includes(type)) {
  74. return null
  75. }
  76. if (item instanceof DataTransfer) {
  77. return item.getData(type)
  78. }
  79. const blob = await item.getType(type)
  80. return await blob.text()
  81. }
  82. // FIXME: for assets, we should prompt the user a loading spinner
  83. export function usePaste() {
  84. const { handlers } = React.useContext(LogseqContext)
  85. return React.useCallback<TLReactCallbacks<Shape>['onPaste']>(
  86. async (app, { point, shiftKey, dataTransfer, fromDrop }) => {
  87. let imageAssetsToCreate: VideoImageAsset[] = []
  88. let assetsToClone: TLAsset[] = []
  89. const bindingsToCreate: TLBinding[] = []
  90. async function createAssetsFromURL(url: string, isVideo: boolean): Promise<VideoImageAsset> {
  91. // Do we already have an asset for this image?
  92. const existingAsset = Object.values(app.assets).find(asset => asset.src === url)
  93. if (existingAsset) {
  94. return existingAsset as VideoImageAsset
  95. } else {
  96. // Create a new asset for this image
  97. const asset: VideoImageAsset = {
  98. id: uniqueId(),
  99. type: isVideo ? 'video' : 'image',
  100. src: url,
  101. size: await getSizeFromSrc(handlers.makeAssetUrl(url), isVideo),
  102. }
  103. return asset
  104. }
  105. }
  106. async function createAssetsFromFiles(files: File[]) {
  107. const tasks = files
  108. .filter(file => getFileType(file.name) !== 'unknown')
  109. .map(async file => {
  110. try {
  111. const dataurl = await handlers.saveAsset(file)
  112. return await createAssetsFromURL(dataurl, getFileType(file.name) === 'video')
  113. } catch (err) {
  114. console.error(err)
  115. }
  116. return null
  117. })
  118. return (await Promise.all(tasks)).filter(isNonNullable)
  119. }
  120. function createHTMLShape(text: string) {
  121. return [
  122. {
  123. ...HTMLShape.defaultProps,
  124. html: text,
  125. point: [point[0], point[1]],
  126. },
  127. ]
  128. }
  129. async function tryCreateShapesFromDataTransfer(dataTransfer: DataTransfer) {
  130. return tryCreateShapeHelper(
  131. tryCreateShapeFromFiles,
  132. tryCreateShapeFromTextHTML,
  133. tryCreateShapeFromTextPlain,
  134. tryCreateShapeFromBlockUUID
  135. )(dataTransfer)
  136. }
  137. async function tryCreateShapesFromClipboard() {
  138. const items = await navigator.clipboard.read()
  139. const createShapesFn = tryCreateShapeHelper(
  140. tryCreateShapeFromTextHTML,
  141. tryCreateShapeFromTextPlain
  142. )
  143. const allShapes = (await Promise.all(items.map(item => createShapesFn(item))))
  144. .flat()
  145. .filter(isNonNullable)
  146. return allShapes
  147. }
  148. async function tryCreateShapeFromFiles(item: DataTransfer) {
  149. const files = Array.from(item.files)
  150. if (files.length > 0) {
  151. const assets = await createAssetsFromFiles(files)
  152. // ? could we get rid of this side effect?
  153. imageAssetsToCreate = assets
  154. return assets.map((asset, i) => {
  155. const defaultProps =
  156. asset.type === 'video' ? VideoShape.defaultProps : ImageShape.defaultProps
  157. const newShape = {
  158. ...defaultProps,
  159. // TODO: Should be place near the last edited shape
  160. assetId: asset.id,
  161. opacity: 1,
  162. }
  163. if (asset.size) {
  164. Object.assign(newShape, {
  165. point: [
  166. point[0] - asset.size[0] / 4 + i * 16,
  167. point[1] - asset.size[1] / 4 + i * 16,
  168. ],
  169. size: Vec.div(asset.size, 2),
  170. })
  171. }
  172. return newShape
  173. })
  174. }
  175. return null
  176. }
  177. async function tryCreateShapeFromTextHTML(item: DataTransfer | ClipboardItem) {
  178. // skips if it's a drop event or using shift key
  179. if (item.types.includes('text/plain') && (shiftKey || fromDrop)) {
  180. return null
  181. }
  182. const rawText = await getDataFromType(item, 'text/html')
  183. if (rawText) {
  184. return tryCreateShapeHelper(tryCreateClonedShapesFromJSON, createHTMLShape)(rawText)
  185. }
  186. return null
  187. }
  188. async function tryCreateShapeFromBlockUUID(dataTransfer: DataTransfer) {
  189. // This is a Logseq custom data type defined in frontend.components.block
  190. const rawText = dataTransfer.getData('block-uuid')
  191. if (rawText) {
  192. const text = rawText.trim()
  193. const allSelectedBlocks = window.logseq?.api?.get_selected_blocks?.()
  194. const blockUUIDs =
  195. allSelectedBlocks && allSelectedBlocks?.length > 1
  196. ? allSelectedBlocks.map(b => b.uuid)
  197. : [text]
  198. // ensure all uuid in blockUUIDs is persisted
  199. window.logseq?.api?.set_blocks_id?.(blockUUIDs)
  200. const tasks = blockUUIDs.map(uuid => tryCreateLogseqPortalShapesFromString(`((${uuid}))`))
  201. const newShapes = (await Promise.all(tasks)).flat().filter(isNonNullable)
  202. return newShapes.map((s, idx) => {
  203. // if there are multiple shapes, shift them to the right
  204. return {
  205. ...s,
  206. // TODO: use better alignment?
  207. point: [point[0] + (LogseqPortalShape.defaultProps.size[0] + 16) * idx, point[1]],
  208. }
  209. })
  210. }
  211. return null
  212. }
  213. async function tryCreateShapeFromTextPlain(item: DataTransfer | ClipboardItem) {
  214. const rawText = await getDataFromType(item, 'text/plain')
  215. if (rawText) {
  216. const text = rawText.trim()
  217. return tryCreateShapeHelper(
  218. tryCreateShapeFromURL,
  219. tryCreateShapeFromIframeString,
  220. tryCreateLogseqPortalShapesFromString
  221. )(text)
  222. }
  223. return null
  224. }
  225. function tryCreateClonedShapesFromJSON(rawText: string) {
  226. const result = app.api.getClonedShapesFromTldrString(rawText, point)
  227. if (result) {
  228. const { shapes, assets, bindings } = result
  229. assetsToClone.push(...assets)
  230. bindingsToCreate.push(...bindings)
  231. return shapes
  232. }
  233. return null
  234. }
  235. async function tryCreateShapeFromURL(rawText: string) {
  236. if (isValidURL(rawText) && !(shiftKey || fromDrop)) {
  237. const isYoutubeUrl = (url: string) => {
  238. const youtubeRegex =
  239. /^(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/
  240. return youtubeRegex.test(url)
  241. }
  242. if (isYoutubeUrl(rawText)) {
  243. return [
  244. {
  245. ...YouTubeShape.defaultProps,
  246. url: rawText,
  247. point: [point[0], point[1]],
  248. },
  249. ]
  250. }
  251. return [
  252. {
  253. ...IFrameShape.defaultProps,
  254. url: rawText,
  255. point: [point[0], point[1]],
  256. },
  257. ]
  258. }
  259. return null
  260. }
  261. function tryCreateShapeFromIframeString(rawText: string) {
  262. // if rawText is iframe text
  263. if (rawText.startsWith('<iframe')) {
  264. return [
  265. {
  266. ...HTMLShape.defaultProps,
  267. html: rawText,
  268. point: [point[0], point[1]],
  269. },
  270. ]
  271. }
  272. return null
  273. }
  274. async function tryCreateLogseqPortalShapesFromString(rawText: string) {
  275. if (/^\(\(.*\)\)$/.test(rawText) && rawText.length === NIL_UUID.length + 4) {
  276. const blockRef = rawText.slice(2, -2)
  277. if (validUUID(blockRef)) {
  278. return [
  279. {
  280. ...LogseqPortalShape.defaultProps,
  281. point: [point[0], point[1]],
  282. size: [400, 0], // use 0 here to enable auto-resize
  283. pageId: blockRef,
  284. blockType: 'B' as 'B',
  285. },
  286. ]
  287. }
  288. }
  289. // [[page name]] ?
  290. else if (/^\[\[.*\]\]$/.test(rawText)) {
  291. const pageName = rawText.slice(2, -2)
  292. return [
  293. {
  294. ...LogseqPortalShape.defaultProps,
  295. point: [point[0], point[1]],
  296. size: [400, 0], // use 0 here to enable auto-resize
  297. pageId: pageName,
  298. blockType: 'P' as 'P',
  299. },
  300. ]
  301. }
  302. // Otherwise, creating a new block that belongs to the current whiteboard
  303. const uuid = handlers?.addNewBlock(rawText)
  304. if (uuid) {
  305. // create text shape
  306. return [
  307. {
  308. ...LogseqPortalShape.defaultProps,
  309. size: [400, 0], // use 0 here to enable auto-resize
  310. point: [point[0], point[1]],
  311. pageId: uuid,
  312. blockType: 'B' as 'B',
  313. compact: true,
  314. },
  315. ]
  316. }
  317. return null
  318. }
  319. app.cursors.setCursor(TLCursor.Progress)
  320. let newShapes: Shape['props'][] = []
  321. try {
  322. if (dataTransfer) {
  323. newShapes.push(...((await tryCreateShapesFromDataTransfer(dataTransfer)) ?? []))
  324. } else {
  325. // from Clipboard app or Shift copy etc
  326. // in this case, we do not have the dataTransfer object
  327. newShapes.push(...((await tryCreateShapesFromClipboard()) ?? []))
  328. }
  329. } catch (error) {
  330. console.error(error)
  331. }
  332. const allShapesToAdd: TLShapeModel[] = newShapes.map(shape => {
  333. return {
  334. ...shape,
  335. parentId: app.currentPageId,
  336. id: validUUID(shape.id) ? shape.id : uniqueId(),
  337. }
  338. })
  339. app.wrapUpdate(() => {
  340. app.api.addClonedShapes(
  341. allShapesToAdd,
  342. [...imageAssetsToCreate, ...assetsToClone],
  343. bindingsToCreate
  344. )
  345. if (app.selectedShapesArray.length === 1 && allShapesToAdd.length === 1 && !fromDrop) {
  346. const source = app.selectedShapesArray[0]
  347. const target = app.getShapeById(allShapesToAdd[0].id!)!
  348. app.createNewLineBinding(source, target)
  349. }
  350. app.setSelectedShapes(allShapesToAdd.map(s => s.id))
  351. app.selectedTool.transition('idle') // clears possible editing states
  352. app.cursors.setCursor(TLCursor.Default)
  353. })
  354. },
  355. []
  356. )
  357. }