refactor: handling DataTransfer

pull/6693/head
Peng Xiao 2022-09-16 17:47:02 +08:00
parent 13a98e87db
commit 803ec01b24
7 changed files with 342 additions and 323 deletions

View File

@ -61,27 +61,13 @@ interface LogseqTldrawProps {
onPersist?: TLReactCallbacks<Shape>['onPersist'] onPersist?: TLReactCallbacks<Shape>['onPersist']
} }
export const App = function App({ const AppInner = ({
onPersist, onPersist,
handlers,
renderers,
model, model,
...rest ...rest
}: LogseqTldrawProps): JSX.Element { }: Omit<LogseqTldrawProps, 'renderers' | 'handlers'>) => {
const memoRenders: any = React.useMemo(() => { const onDrop = useDrop()
return Object.fromEntries( const onPaste = usePaste()
Object.entries(renderers).map(([key, comp]) => {
return [key, React.memo(comp)]
})
)
}, [])
const contextValue = {
renderers: memoRenders,
handlers: handlers,
}
const onDrop = useDrop(contextValue)
const onPaste = usePaste(contextValue)
const onQuickAdd = useQuickAdd() const onQuickAdd = useQuickAdd()
const ref = React.useRef<HTMLDivElement>(null) const ref = React.useRef<HTMLDivElement>(null)
@ -95,7 +81,6 @@ export const App = function App({
) )
return ( return (
<LogseqContext.Provider value={contextValue}>
<AppProvider <AppProvider
Shapes={shapes} Shapes={shapes}
Tools={tools} Tools={tools}
@ -114,6 +99,25 @@ export const App = function App({
</div> </div>
</ContextMenu> </ContextMenu>
</AppProvider> </AppProvider>
)
}
export const App = function App({ renderers, handlers, ...rest }: LogseqTldrawProps): JSX.Element {
const memoRenders: any = React.useMemo(() => {
return Object.fromEntries(
Object.entries(renderers).map(([key, comp]) => {
return [key, React.memo(comp)]
})
)
}, [])
const contextValue = {
renderers: memoRenders,
handlers: handlers,
}
return (
<LogseqContext.Provider value={contextValue}>
<AppInner {...rest} />
</LogseqContext.Provider> </LogseqContext.Provider>
) )
} }

View File

@ -25,7 +25,11 @@ const HistoryStack = observer(function HistoryStack() {
}, []) }, [])
React.useEffect(() => { React.useEffect(() => {
anchorRef.current?.querySelector(`[data-item-index="${app.history.pointer}"]`)?.scrollIntoView() requestIdleCallback(() => {
anchorRef.current
?.querySelector(`[data-item-index="${app.history.pointer}"]`)
?.scrollIntoView()
})
}, [app.history.pointer]) }, [app.history.pointer])
return anchorRef.current return anchorRef.current

View File

@ -1,12 +1,14 @@
import type { TLReactCallbacks } from '@tldraw/react' import type { TLReactCallbacks } from '@tldraw/react'
import * as React from 'react' import * as React from 'react'
import type { Shape } from '../lib' import type { Shape } from '../lib'
import type { LogseqContextValue } from '../lib/logseq-context'
import { usePaste } from './usePaste' import { usePaste } from './usePaste'
export function useDrop(context: LogseqContextValue) { export function useDrop() {
const handlePaste = usePaste(context) const handlePaste = usePaste()
return React.useCallback<TLReactCallbacks<Shape>['onDrop']>(async (app, { dataTransfer, point }) => { return React.useCallback<TLReactCallbacks<Shape>['onDrop']>(
async (app, { dataTransfer, point }) => {
handlePaste(app, { point, shiftKey: false, dataTransfer }) handlePaste(app, { point, shiftKey: false, dataTransfer })
}, []) },
[]
)
} }

View File

@ -1,7 +1,7 @@
import { Item, Label } from '@radix-ui/react-select'
import { import {
BoundsUtils, BoundsUtils,
getSizeFromSrc, getSizeFromSrc,
isNonNullable,
TLAsset, TLAsset,
TLBinding, TLBinding,
TLCursor, TLCursor,
@ -14,17 +14,15 @@ import Vec from '@tldraw/vec'
import * as React from 'react' import * as React from 'react'
import { NIL as NIL_UUID } from 'uuid' import { NIL as NIL_UUID } from 'uuid'
import { import {
type Shape,
HTMLShape, HTMLShape,
YouTubeShape, IFrameShape,
ImageShape,
LogseqPortalShape, LogseqPortalShape,
VideoShape, VideoShape,
TextShape, YouTubeShape,
LineShape, type Shape,
ImageShape,
IFrameShape,
} from '../lib' } from '../lib'
import type { LogseqContextValue } from '../lib/logseq-context' import { LogseqContext } from '../lib/logseq-context'
const isValidURL = (url: string) => { const isValidURL = (url: string) => {
try { try {
@ -43,36 +41,64 @@ const safeParseJson = (json: string) => {
} }
} }
interface VideoImageAsset extends TLAsset {
size?: number[]
}
const IMAGE_EXTENSIONS = ['.png', '.svg', '.jpg', '.jpeg', '.gif'] const IMAGE_EXTENSIONS = ['.png', '.svg', '.jpg', '.jpeg', '.gif']
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.ogg'] 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.
*/
async function tryCreateShapeHelper<Args extends any[]>(fns: CreateShapeFN<Args>[], ...args: Args) {
for (const fn of fns) {
const result = await fn(...(args as any))
if (result && result.length > 0) {
return result
}
}
return null
}
// FIXME: for assets, we should prompt the user a loading spinner // FIXME: for assets, we should prompt the user a loading spinner
export function usePaste(context: LogseqContextValue) { export function usePaste() {
const { handlers } = context const { handlers } = React.useContext(LogseqContext)
return React.useCallback<TLReactCallbacks<Shape>['onPaste']>( return React.useCallback<TLReactCallbacks<Shape>['onPaste']>(
async (app, { point, shiftKey, dataTransfer }) => { async (app, { point, shiftKey, dataTransfer }) => {
interface VideoImageAsset extends TLAsset { let imageAssetsToCreate: VideoImageAsset[] = []
size: number[]
}
const imageAssetsToCreate: VideoImageAsset[] = []
let assetsToClone: TLAsset[] = [] let assetsToClone: TLAsset[] = []
const shapesToCreate: Shape['props'][] = [] const shapesToCreate: Shape['props'][] = []
const bindingsToCreate: TLBinding[] = [] const bindingsToCreate: TLBinding[] = []
async function createAsset(file: File): Promise<string | null> { async function createAssetsFromURL(url: string, isVideo: boolean): Promise<VideoImageAsset> {
return await handlers.saveAsset(file)
}
async function handleAssetUrl(url: string, isVideo: boolean) {
// Do we already have an asset for this image? // Do we already have an asset for this image?
const existingAsset = Object.values(app.assets).find(asset => asset.src === url) const existingAsset = Object.values(app.assets).find(asset => asset.src === url)
if (existingAsset) { if (existingAsset) {
imageAssetsToCreate.push(existingAsset as VideoImageAsset) return existingAsset as VideoImageAsset
return true
} else { } else {
try {
// Create a new asset for this image // Create a new asset for this image
const asset: VideoImageAsset = { const asset: VideoImageAsset = {
id: uniqueId(), id: uniqueId(),
@ -80,160 +106,153 @@ export function usePaste(context: LogseqContextValue) {
src: url, src: url,
size: await getSizeFromSrc(handlers.makeAssetUrl(url), isVideo), size: await getSizeFromSrc(handlers.makeAssetUrl(url), isVideo),
} }
imageAssetsToCreate.push(asset) return asset
return true
} catch {
return false
}
} }
} }
// TODO: handle PDF? async function createAssetsFromFiles(files: File[]) {
async function handleFiles(files: File[]) { const tasks = files
let added = false .filter(file => getFileType(file.name) !== 'unknown')
for (const file of files) { .map(async file => {
// Get extension, verify that it's an image
const extensionMatch = file.name.match(/\.[0-9a-z]+$/i)
if (!extensionMatch) {
continue
}
const extension = extensionMatch[0].toLowerCase()
if (![...IMAGE_EXTENSIONS, ...VIDEO_EXTENSIONS].includes(extension)) {
continue
}
const isVideo = VIDEO_EXTENSIONS.includes(extension)
try { try {
// Turn the image into a base64 dataurl const dataurl = await handlers.saveAsset(file)
const dataurl = await createAsset(file) return await createAssetsFromURL(dataurl, getFileType(file.name) === 'video')
if (!dataurl) { } catch (err) {
continue console.error(err)
}
if (await handleAssetUrl(dataurl, isVideo)) {
added = true
}
} catch (error) {
console.error(error)
}
}
return added
}
async function handleItems(items: any) {
for (const item of items) {
if (await handleDroppedItem(item)) {
const lineId = uniqueId()
const startBinding: TLBinding = {
id: uniqueId(),
distance: 200,
handleId: 'start',
fromId: lineId,
toId: app.selectedShapesArray[app.selectedShapesArray.length - 1].id,
point: [point[0], point[1]],
}
bindingsToCreate.push(startBinding)
const endBinding: TLBinding = {
id: uniqueId(),
distance: 200,
handleId: 'end',
fromId: lineId,
toId: shapesToCreate[shapesToCreate.length - 1].id,
point: [point[0], point[1]],
}
bindingsToCreate.push(endBinding)
shapesToCreate.push({
...LineShape.defaultProps,
id: lineId,
handles: {
start: { id: 'start', canBind: true, point: app.selectedShapesArray[0].getCenter(), bindingId: startBinding.id },
end: { id: 'end', canBind: true, point: [point[0], point[1]], bindingId: endBinding.id },
} }
return null
}) })
return (await Promise.all(tasks)).filter(isNonNullable)
return true
}
}
return false
} }
async function handleTransfer(dataTransfer: DataTransfer) { function createHTMLShape(text: string) {
let added = false return {
if ((dataTransfer.files?.length && await handleFiles(Array.from(dataTransfer.files))) ||
(dataTransfer.items?.length && await handleItems(Array.from(dataTransfer.items).map((item: any) => ({type: item.type, text: dataTransfer.getData(item.type)})))) ) {
added = true
}
return added
}
async function handleHTML(item: ClipboardItem) {
if (item.types.includes('text/html')) {
const blob = await item.getType('text/html')
const rawText = (await blob.text()).trim()
shapesToCreate.push({
...HTMLShape.defaultProps, ...HTMLShape.defaultProps,
html: rawText, html: text,
point: [point[0], point[1]], point: [point[0], point[1]],
}
}
// async function handleItems(items: any) {
// for (const item of items) {
// if (await handleDroppedItem(item)) {
// const lineId = uniqueId()
// const startBinding: TLBinding = {
// id: uniqueId(),
// distance: 200,
// handleId: 'start',
// fromId: lineId,
// toId: app.selectedShapesArray[app.selectedShapesArray.length - 1].id,
// point: [point[0], point[1]],
// }
// bindingsToCreate.push(startBinding)
// const endBinding: TLBinding = {
// id: uniqueId(),
// distance: 200,
// handleId: 'end',
// fromId: lineId,
// toId: shapesToCreate[shapesToCreate.length - 1].id,
// point: [point[0], point[1]],
// }
// bindingsToCreate.push(endBinding)
// shapesToCreate.push({
// ...LineShape.defaultProps,
// id: lineId,
// handles: {
// start: {
// id: 'start',
// canBind: true,
// point: app.selectedShapesArray[0].getCenter(),
// bindingId: startBinding.id,
// },
// end: {
// id: 'end',
// canBind: true,
// point: [point[0], point[1]],
// bindingId: endBinding.id,
// },
// },
// })
// return true
// }
// }
// return false
// }
async function tryCreateShapesFromDataTransfer(dataTransfer: DataTransfer) {
return tryCreateShapeHelper(
[tryCreateShapeFromFiles, tryCreateShapeFromTextHTML, tryCreateShapeFromTextPlain],
dataTransfer
)
}
async function tryCreateShapeFromFiles(dataTransfer: DataTransfer) {
const files = Array.from(dataTransfer.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 true
}
return false
} }
async function handleDroppedItem(item: any) { return newShape
switch(item.type) {
case 'text/html':
shapesToCreate.push({
...HTMLShape.defaultProps,
html: item.text,
point: [point[0], point[1]],
}) })
return true }
case 'text/plain': return null
if (await handleURL(item.text)) {
return true
} }
shapesToCreate.push({ function tryCreateShapeFromTextHTML(dataTransfer: DataTransfer) {
...TextShape.defaultProps, if (dataTransfer.types.includes('text/html') && !shiftKey) {
text: item.text, const html = dataTransfer.getData('text/html')
point: [point[0], point[1]],
}) if (html) {
return true return [createHTMLShape(html)]
default:
return false
} }
} }
return null
}
async function handleTextPlain(item: ClipboardItem) { async function tryCreateShapeFromTextPlain(dataTransfer: DataTransfer) {
if (item.types.includes('text/plain')) { if (dataTransfer.types.includes('text/plain')) {
const blob = await item.getType('text/plain') const text = dataTransfer.getData('text/plain').trim()
const rawText = (await blob.text()).trim()
if (await handleURL(rawText)) { return tryCreateShapeHelper(
return true [
tryCreateShapeFromURL,
tryCreateShapeFromIframeString,
tryCreateClonedShapesFromJSON,
tryCreateLogseqPortalShapesFromString,
],
text
)
} }
if (handleIframe(rawText)) { return null
return true
} }
if (handleTldrawShapes(rawText)) { function tryCreateClonedShapesFromJSON(rawText: string) {
return true
}
if (await handleLogseqPortalShapes(rawText)) {
return true
}
}
return false
}
function handleTldrawShapes(rawText: string) {
const data = safeParseJson(rawText) const data = safeParseJson(rawText)
try { try {
if (data?.type === 'logseq/whiteboard-shapes') { if (data?.type === 'logseq/whiteboard-shapes') {
@ -249,7 +268,7 @@ export function usePaste(context: LogseqContextValue) {
maxY: (shape.point?.[1] ?? point[1]) + (shape.size?.[1] ?? 4), maxY: (shape.point?.[1] ?? point[1]) + (shape.size?.[1] ?? 4),
})) }))
) )
const clonedShapes = shapes.map(shape => { const shapesToCreate = shapes.map(shape => {
return { return {
...shape, ...shape,
point: [ point: [
@ -258,14 +277,14 @@ export function usePaste(context: LogseqContextValue) {
], ],
} }
}) })
// @ts-expect-error - This is a valid shape
shapesToCreate.push(...clonedShapes)
// Try to rebinding the shapes to the new assets // Try to rebinding the shapes to the new assets
shapesToCreate.forEach((s, idx) => { shapesToCreate
if (s.handles) { .flatMap(s => Object.values(s.handles ?? {}))
Object.values(s.handles).forEach(h => { .forEach(h => {
if (h.bindingId) { if (!h.bindingId) {
return
}
// try to bind the new shape // try to bind the new shape
const binding = app.currentPage.bindings[h.bindingId] const binding = app.currentPage.bindings[h.bindingId]
// FIXME: if copy from a different whiteboard, the binding info // FIXME: if copy from a different whiteboard, the binding info
@ -286,21 +305,20 @@ export function usePaste(context: LogseqContextValue) {
} else { } else {
h.bindingId = undefined h.bindingId = undefined
} }
} } else {
} console.warn('binding not found', h.bindingId)
})
} }
}) })
return true return shapesToCreate as Shape['props'][]
} }
} catch (err) { } catch (err) {
console.error(err) console.error(err)
} }
return false return null
} }
async function handleURL(rawText: string) { async function tryCreateShapeFromURL(rawText: string) {
if (isValidURL(rawText)) { if (isValidURL(rawText)) {
const isYoutubeUrl = (url: string) => { const isYoutubeUrl = (url: string) => {
const youtubeRegex = const youtubeRegex =
@ -308,116 +326,108 @@ export function usePaste(context: LogseqContextValue) {
return youtubeRegex.test(url) return youtubeRegex.test(url)
} }
if (isYoutubeUrl(rawText)) { if (isYoutubeUrl(rawText)) {
shapesToCreate.push({ return [
{
...YouTubeShape.defaultProps, ...YouTubeShape.defaultProps,
url: rawText, url: rawText,
point: [point[0], point[1]], point: [point[0], point[1]],
}) },
return true ]
}
const extension = rawText.match(/\.[0-9a-z]+$/i)?.[0].toLowerCase()
if (
extension &&
[...IMAGE_EXTENSIONS, ...VIDEO_EXTENSIONS].includes(extension) &&
(await handleAssetUrl(rawText, VIDEO_EXTENSIONS.includes(extension)))
) {
return true
} }
shapesToCreate.push({ return [
{
...IFrameShape.defaultProps, ...IFrameShape.defaultProps,
url: rawText, url: rawText,
point: [point[0], point[1]], point: [point[0], point[1]],
}) },
return true ]
} }
return false return null
} }
function handleIframe(rawText: string) { function tryCreateShapeFromIframeString(rawText: string) {
// if rawText is iframe text // if rawText is iframe text
if (rawText.startsWith('<iframe')) { if (rawText.startsWith('<iframe')) {
shapesToCreate.push({ return [
{
...HTMLShape.defaultProps, ...HTMLShape.defaultProps,
html: rawText, html: rawText,
point: [point[0], point[1]], point: [point[0], point[1]],
}) },
return true ]
} }
return false return null
} }
async function handleLogseqPortalShapes(rawText: string) { async function tryCreateLogseqPortalShapesFromString(rawText: string) {
if (/^\(\(.*\)\)$/.test(rawText) && rawText.length === NIL_UUID.length + 4) { if (/^\(\(.*\)\)$/.test(rawText) && rawText.length === NIL_UUID.length + 4) {
const blockRef = rawText.slice(2, -2) const blockRef = rawText.slice(2, -2)
if (validUUID(blockRef)) { if (validUUID(blockRef)) {
shapesToCreate.push({ return [
{
...LogseqPortalShape.defaultProps, ...LogseqPortalShape.defaultProps,
point: [point[0], point[1]], point: [point[0], point[1]],
size: [400, 0], // use 0 here to enable auto-resize size: [400, 0], // use 0 here to enable auto-resize
pageId: blockRef, pageId: blockRef,
blockType: 'B', blockType: 'B' as 'B',
}) },
return true ]
} }
} else if (/^\[\[.*\]\]$/.test(rawText)) { }
// [[page name]] ?
else if (/^\[\[.*\]\]$/.test(rawText)) {
const pageName = rawText.slice(2, -2) const pageName = rawText.slice(2, -2)
shapesToCreate.push({ return [
{
...LogseqPortalShape.defaultProps, ...LogseqPortalShape.defaultProps,
point: [point[0], point[1]], point: [point[0], point[1]],
size: [400, 0], // use 0 here to enable auto-resize size: [400, 0], // use 0 here to enable auto-resize
pageId: pageName, pageId: pageName,
blockType: 'P', blockType: 'P' as 'P',
}) },
return true ]
} }
// Otherwise, creating a new block that belongs to the current whiteboard
const uuid = handlers?.addNewBlock(rawText) const uuid = handlers?.addNewBlock(rawText)
if (uuid) { if (uuid) {
// create text shape // create text shape
shapesToCreate.push({ return [
{
...LogseqPortalShape.defaultProps, ...LogseqPortalShape.defaultProps,
id: uniqueId(), id: uniqueId(),
size: [400, 0], // use 0 here to enable auto-resize size: [400, 0], // use 0 here to enable auto-resize
point: [point[0], point[1]], point: [point[0], point[1]],
pageId: uuid, pageId: uuid,
blockType: 'B', blockType: 'B' as 'B',
compact: true, compact: true,
}) },
return true ]
} }
return false
return null
} }
app.cursors.setCursor(TLCursor.Progress) app.cursors.setCursor(TLCursor.Progress)
try { try {
if (dataTransfer) { const shapesFromDataTransfer = dataTransfer
await handleTransfer(dataTransfer) ? await tryCreateShapesFromDataTransfer(dataTransfer)
: null
if (shapesFromDataTransfer) {
shapesToCreate.push(...shapesFromDataTransfer)
} else { } else {
for (const item of await navigator.clipboard.read()) { // from Clipboard app or Shift copy etc
let handled = !shiftKey ? await handleHTML(item) : false // in this case, we do not have the dataTransfer object
if (!handled) {
await handleTextPlain(item)
}
}
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} }
const allShapesToAdd: TLShapeModel[] = [ console.log(bindingsToCreate)
// assets to images
...imageAssetsToCreate.map((asset, i) => ({ const allShapesToAdd: TLShapeModel[] = shapesToCreate.map(shape => {
...(asset.type === 'video' ? VideoShape : ImageShape).defaultProps,
// TODO: Should be place near the last edited shape
point: [point[0] - asset.size[0] / 4 + i * 16, point[1] - asset.size[1] / 4 + i * 16],
size: Vec.div(asset.size, 2),
assetId: asset.id,
opacity: 1,
})),
...shapesToCreate,
].map(shape => {
return { return {
...shape, ...shape,
parentId: app.currentPageId, parentId: app.currentPageId,

View File

@ -36,4 +36,4 @@ export interface LogseqContextValue {
} }
} }
export const LogseqContext = React.createContext<Partial<LogseqContextValue>>({}) export const LogseqContext = React.createContext<LogseqContextValue>({} as LogseqContextValue)

View File

@ -105,8 +105,7 @@ const useSearch = (q: string, searchFilter: 'B' | 'P' | null) => {
React.useEffect(() => { React.useEffect(() => {
let canceled = false let canceled = false
const searchHandler = handlers?.search if (q.length > 0) {
if (q.length > 0 && searchHandler) {
const filter = { 'pages?': true, 'blocks?': true, 'files?': false } const filter = { 'pages?': true, 'blocks?': true, 'files?': false }
if (searchFilter === 'B') { if (searchFilter === 'B') {
filter['pages?'] = false filter['pages?'] = false

View File

@ -439,7 +439,7 @@ export class TLApp<
this.notify('paste', { this.notify('paste', {
point: this.inputs.currentPoint, point: this.inputs.currentPoint,
shiftKey: !!shiftKey, shiftKey: !!shiftKey,
dataTransfer: e?.clipboardData, dataTransfer: e?.clipboardData ?? undefined,
}) })
} }
} }