Feature/upload multiple media (#830)

This commit is contained in:
Judicael 2022-07-21 21:16:01 +03:00 committed by GitHub
parent 223391afe5
commit 7a353c7d9f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 255 additions and 364 deletions

View file

@ -0,0 +1,21 @@
import * as React from 'react'
export function ImageIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
)
}

View file

@ -16,3 +16,4 @@ export * from './MultiplayerIcon'
export * from './DiscordIcon' export * from './DiscordIcon'
export * from './LineIcon' export * from './LineIcon'
export * from './QuestionMarkIcon' export * from './QuestionMarkIcon'
export * from './ImageIcon'

View file

@ -12,7 +12,7 @@ import { useTldrawApp } from '~hooks'
import { ToolButtonWithTooltip } from '~components/Primitives/ToolButton' import { ToolButtonWithTooltip } from '~components/Primitives/ToolButton'
import { Panel } from '~components/Primitives/Panel' import { Panel } from '~components/Primitives/Panel'
import { ShapesMenu } from './ShapesMenu' import { ShapesMenu } from './ShapesMenu'
import { EraserIcon } from '~components/Primitives/icons' import { EraserIcon, ImageIcon } from '~components/Primitives/icons'
const activeToolSelector = (s: TDSnapshot) => s.appState.activeTool const activeToolSelector = (s: TDSnapshot) => s.appState.activeTool
const toolLockedSelector = (s: TDSnapshot) => s.appState.isToolLocked const toolLockedSelector = (s: TDSnapshot) => s.appState.isToolLocked
@ -51,6 +51,10 @@ export const PrimaryTools = React.memo(function PrimaryTools() {
app.selectTool(TDShapeType.Sticky) app.selectTool(TDShapeType.Sticky)
}, [app]) }, [app])
const uploadMedias = React.useCallback(async () => {
app.openAsset()
}, [app])
const panelStyle = dockPosition === 'bottom' || dockPosition === 'top' ? 'row' : 'column' const panelStyle = dockPosition === 'bottom' || dockPosition === 'top' ? 'row' : 'column'
return ( return (
@ -112,6 +116,9 @@ export const PrimaryTools = React.memo(function PrimaryTools() {
> >
<Pencil2Icon /> <Pencil2Icon />
</ToolButtonWithTooltip> </ToolButtonWithTooltip>
<ToolButtonWithTooltip label="Image" onClick={uploadMedias} id="TD-PrimaryTools-Image">
<ImageIcon />
</ToolButtonWithTooltip>
</Panel> </Panel>
) )
}) })

View file

@ -1,11 +1,5 @@
import * as React from 'react' import * as React from 'react'
import { import { ExitIcon, HamburgerMenuIcon } from '@radix-ui/react-icons'
ExitIcon,
GitHubLogoIcon,
HamburgerMenuIcon,
HeartFilledIcon,
TwitterLogoIcon,
} from '@radix-ui/react-icons'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu' import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { useTldrawApp } from '~hooks' import { useTldrawApp } from '~hooks'
import { PreferencesMenu } from '../PreferencesMenu' import { PreferencesMenu } from '../PreferencesMenu'
@ -18,9 +12,7 @@ import {
} from '~components/Primitives/DropdownMenu' } from '~components/Primitives/DropdownMenu'
import { SmallIcon } from '~components/Primitives/SmallIcon' import { SmallIcon } from '~components/Primitives/SmallIcon'
import { useFileSystemHandlers } from '~hooks' import { useFileSystemHandlers } from '~hooks'
import { HeartIcon } from '~components/Primitives/icons/HeartIcon'
import { preventEvent } from '~components/preventEvent' import { preventEvent } from '~components/preventEvent'
import { DiscordIcon } from '~components/Primitives/icons'
import { TDExportType, TDSnapshot } from '~types' import { TDExportType, TDSnapshot } from '~types'
import { Divider } from '~components/Primitives/Divider' import { Divider } from '~components/Primitives/Divider'
import { FormattedMessage, useIntl } from 'react-intl' import { FormattedMessage, useIntl } from 'react-intl'

View file

@ -110,7 +110,7 @@ describe('TldrawTestApp', () => {
expect(app.bindings.length).toBe(2) expect(app.bindings.length).toBe(2)
}) })
it('removes bindings from copied shape handles', () => { it.only('removes bindings from copied shape handles', () => {
const app = new TldrawTestApp() const app = new TldrawTestApp()
app app

View file

@ -42,6 +42,7 @@ import {
ArrowShape, ArrowShape,
TDExportType, TDExportType,
TldrawPatch, TldrawPatch,
AlignStyle,
} from '~types' } from '~types'
import { import {
migrate, migrate,
@ -49,7 +50,7 @@ import {
loadFileHandle, loadFileHandle,
openFromFileSystem, openFromFileSystem,
saveToFileSystem, saveToFileSystem,
openAssetFromFileSystem, openAssetsFromFileSystem,
fileToBase64, fileToBase64,
fileToText, fileToText,
getImageSizeFromSrc, getImageSizeFromSrc,
@ -84,6 +85,7 @@ import { clearPrevSize } from './shapes/shared/getTextSize'
import { getClipboard, setClipboard } from './IdbClipboard' import { getClipboard, setClipboard } from './IdbClipboard'
import { deepCopy } from './StateManager/copy' import { deepCopy } from './StateManager/copy'
import { getTranslation } from '~translations' import { getTranslation } from '~translations'
import { TextUtil } from './shapes/TextUtil'
const uuid = Utils.uniqueId() const uuid = Utils.uniqueId()
@ -260,11 +262,6 @@ export class TldrawApp extends StateManager<TDSnapshot> {
center: [0, 0], center: [0, 0],
} }
pasteInfo = {
center: [0, 0],
offset: [0, 0],
}
constructor(id?: string, callbacks = {} as TDCallbacks) { constructor(id?: string, callbacks = {} as TDCallbacks) {
super(TldrawApp.defaultState, id, TldrawApp.version, (prev, next, prevVersion) => { super(TldrawApp.defaultState, id, TldrawApp.version, (prev, next, prevVersion) => {
return migrate( return migrate(
@ -1221,7 +1218,6 @@ export class TldrawApp extends StateManager<TDSnapshot> {
resetDocument = (): this => { resetDocument = (): this => {
if (this.session) return this if (this.session) return this
this.session = undefined this.session = undefined
this.pasteInfo.offset = [0, 0]
this.currentTool = this.tools.select this.currentTool = this.tools.select
const doc = TldrawApp.defaultDocument const doc = TldrawApp.defaultDocument
@ -1534,9 +1530,13 @@ export class TldrawApp extends StateManager<TDSnapshot> {
openAsset = async () => { openAsset = async () => {
if (!this.disableAssets) if (!this.disableAssets)
try { try {
const file = await openAssetFromFileSystem() const file = await openAssetsFromFileSystem()
if (Array.isArray(file)) {
this.addMediaFromFiles(file, this.centerPoint)
} else {
if (!file) return if (!file) return
this.addMediaFromFile(file) this.addMediaFromFiles([file])
}
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} finally { } finally {
@ -1839,9 +1839,6 @@ export class TldrawApp extends StateManager<TDSnapshot> {
]) ])
} }
this.pasteInfo.offset = [0, 0]
this.pasteInfo.center = [0, 0]
return this return this
} }
@ -1852,13 +1849,21 @@ export class TldrawApp extends StateManager<TDSnapshot> {
paste = async (point?: number[], e?: ClipboardEvent) => { paste = async (point?: number[], e?: ClipboardEvent) => {
if (this.readOnly) return if (this.readOnly) return
const pasteTextAsSvg = async (text: string) => { const shapesToCreate: TDShape[] = []
const filesToPaste: File[] = []
let clipboardData: any
const getSvgFromText = async (text: string) => {
const div = document.createElement('div') const div = document.createElement('div')
div.innerHTML = text div.innerHTML = text
const svg = div.firstChild as SVGSVGElement const svg = div.firstChild as SVGSVGElement
svg.style.setProperty('background-color', 'transparent') svg.style.setProperty('background-color', 'transparent')
console.log(text)
const imageBlob = await TLDR.getImageForSvg(svg, TDExportType.SVG, { const imageBlob = await TLDR.getImageForSvg(svg, TDExportType.SVG, {
scale: 1, scale: 1,
quality: 1, quality: 1,
@ -1866,28 +1871,33 @@ export class TldrawApp extends StateManager<TDSnapshot> {
if (imageBlob) { if (imageBlob) {
const file = new File([imageBlob], 'image.svg') const file = new File([imageBlob], 'image.svg')
this.addMediaFromFile(file) filesToPaste.push(file)
} else { } else {
pasteTextAsShape(text) getShapeFromText(text)
} }
} }
const pasteTextAsShape = (text: string) => { const getShapeFromText = (text: string) => {
const shapeId = Utils.uniqueId() const pagePoint = this.getPagePoint(point ?? this.centerPoint, this.currentPageId)
this.createShapes({ const isMultiline = text.includes('\n')
id: shapeId,
shapesToCreate.push(
TLDR.getShapeUtil(TDShapeType.Text).getShape({
id: Utils.uniqueId(),
type: TDShapeType.Text, type: TDShapeType.Text,
parentId: this.appState.currentPageId, parentId: this.appState.currentPageId,
text: TLDR.normalizeText(text.trim()), text: TLDR.normalizeText(text.trim()),
point: this.getPagePoint(this.centerPoint, this.currentPageId), point: pagePoint,
style: { ...this.appState.currentStyle }, style: {
...this.appState.currentStyle,
textAlign: isMultiline ? AlignStyle.Start : this.appState.currentStyle.textAlign,
},
}) })
)
this.select(shapeId)
} }
const pasteAsHTML = (html: string) => { const getShapeFromHtml = (html: string) => {
try { try {
const maybeJson = html.match(/<tldraw>(.*)<\/tldraw>/)?.[1] const maybeJson = html.match(/<tldraw>(.*)<\/tldraw>/)?.[1]
@ -1900,115 +1910,103 @@ export class TldrawApp extends StateManager<TDSnapshot> {
assets: TDAsset[] assets: TDAsset[]
} = JSON.parse(maybeJson) } = JSON.parse(maybeJson)
if (json.type === 'tldr/clipboard') { if (json.type === 'tldr/clipboard') {
this.insertContent(json, { point, select: true }) clipboardData = json
return return
} else { } else {
throw Error('Not tldraw data!') throw Error('Not tldraw data!')
} }
} catch (e) { } catch (e) {
pasteTextAsShape(html) getShapeFromText(html)
return
} }
} }
if (e !== undefined) { if (e !== undefined) {
const items = e.clipboardData?.items ?? [] const items = Array.from(e.clipboardData?.items ?? [])
for (const index in items) {
const item = items[index]
// TODO await Promise.all(
// We could eventually support pasting multiple files / images, items.map(async (item) => {
// and tiling them out on the canvas. At the moment, let's just const { type, kind } = item
// support pasting one file / image.
if (item.type === 'text/html') { switch (kind) {
item.getAsString(async (text) => {
pasteAsHTML(text)
})
return
} else {
switch (item.kind) {
case 'string': { case 'string': {
item.getAsString(async (text) => { const str: string = await new Promise((resolve) => item.getAsString(resolve))
if (text.startsWith('<svg')) {
pasteTextAsSvg(text) switch (type) {
} else { case 'text/html': {
pasteTextAsShape(text) if (str.match(/<tldraw>(.*)<\/tldraw>/)?.[1]) {
} getShapeFromHtml(str)
})
return return
} }
break
}
case 'text/plain': {
console.log(str)
if (str.startsWith('<svg')) {
getSvgFromText(str)
} else {
getShapeFromText(str)
}
// return
break
}
}
break
}
case 'file': { case 'file': {
const file = item.getAsFile() const file = item.getAsFile()
if (file) { if (file) filesToPaste.push(file)
this.addMediaFromFile(file) break
return
} }
} }
}
}
}
}
getClipboard().then((clipboard) => {
if (clipboard) {
pasteAsHTML(clipboard)
}
}) })
)
if (navigator.clipboard) {
const items = 'read' in navigator.clipboard ? await navigator.clipboard.read() : []
if (items.length === 0) return
try {
for (const item of items) {
// look for png data.
const pngData = await item.getType('text/png')
if (pngData) {
const file = new File([pngData], 'image.png')
this.addMediaFromFile(file)
return
} }
// look for svg data. if (clipboardData) {
this.insertContent(clipboardData, { point, select: true })
const svgData = await item.getType('image/svg+xml') return this
if (svgData) {
const file = new File([svgData], 'image.svg')
this.addMediaFromFile(file)
return
} }
// look for plain text data. if (filesToPaste.length) {
this.addMediaFromFiles(filesToPaste, point)
const textData = await item.getType('text/plain') return this
if (textData) {
// TODO: Paste as an SVG image if the incoming data is an SVG.
const text = await textData.text()
text.trim()
if (text.startsWith('<svg')) {
pasteTextAsSvg(text)
} else {
pasteTextAsShape(text)
} }
return if (shapesToCreate.length) {
const pagePoint = this.getPagePoint(point ?? this.centerPoint, this.currentPageId)
const currentPoint = Vec.add(pagePoint, [0, 0])
shapesToCreate.forEach((shape, i) => {
const bounds = TLDR.getBounds(shape)
if (i === 0) {
// For the first shape, offset the current point so
// that the first shape's center is at the page point
currentPoint[0] -= bounds.width / 2
currentPoint[1] -= bounds.height / 2
} }
// Set the shape's point the current point
shape.point = [...currentPoint]
// Then bump the page current point by this shape's width
currentPoint[0] += bounds.width
})
this.createShapes(...shapesToCreate)
return this
} }
} catch (e) {
// noop
}
} else {
TLDR.warn('This browser does not support the Clipboard API!')
if (this.clipboard) { if (this.clipboard) {
this.insertContent(this.clipboard, { point, select: true }) // try to get clipboard data from the scene itself
} this.insertContent(this.clipboard)
} else {
// last chance to get the clipboard data, is it in storage?
getClipboard().then((text) => {
if (text) getShapeFromHtml(text)
})
} }
return this return this
@ -3015,13 +3013,13 @@ export class TldrawApp extends StateManager<TDSnapshot> {
return this return this
} }
createImageOrVideoShapeAtPoint( getImageOrVideoShapeAtPoint(
id: string, id: string,
type: TDShapeType.Image | TDShapeType.Video, type: TDShapeType.Image | TDShapeType.Video,
point: number[], point: number[],
size: number[], size: number[],
assetId: string assetId: string
): this { ) {
const { const {
shapes, shapes,
appState: { currentPageId, currentStyle }, appState: { currentPageId, currentStyle },
@ -3066,13 +3064,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
assetId, assetId,
}) })
const bounds = Shape.getBounds(newShape as never) return newShape
newShape.point = Vec.sub(newShape.point, [bounds.width / 2, bounds.height / 2])
this.createShapes(newShape)
return this
} }
/** /**
@ -3394,11 +3386,20 @@ export class TldrawApp extends StateManager<TDSnapshot> {
return this return this
} }
addMediaFromFile = async (file: File, point = this.centerPoint) => { addMediaFromFiles = async (files: File[], point = this.centerPoint) => {
this.setIsLoading(true) this.setIsLoading(true)
const id = Utils.uniqueId() // Rather than creating each shape individually (which will produce undo / redo entries
// for each shape), create an array of all the shapes that we'll need to create. We'll
// iterate through these at the bottom of the function to set their points, then create
// them through a single call to `createShapes`.
const shapesToCreate: TDShape[] = []
const pagePoint = this.getPagePoint(point) const pagePoint = this.getPagePoint(point)
for (const file of files) {
const id = Utils.uniqueId()
const extension = file.name.match(/\.[0-9a-z]+$/i) const extension = file.name.match(/\.[0-9a-z]+$/i)
if (!extension) throw Error('No extension') if (!extension) throw Error('No extension')
@ -3475,12 +3476,45 @@ export class TldrawApp extends StateManager<TDSnapshot> {
assetId = match.id assetId = match.id
} }
this.createImageOrVideoShapeAtPoint(id, shapeType, pagePoint, size, assetId) shapesToCreate.push(this.getImageOrVideoShapeAtPoint(id, shapeType, point, size, assetId))
} }
} catch (error) { } catch (error) {
// Even if one shape errors, keep going (we might have had other shapes that didn't error)
console.warn(error) console.warn(error)
this.setIsLoading(false) }
return this }
if (shapesToCreate.length) {
const currentPoint = Vec.add(pagePoint, [0, 0])
shapesToCreate.forEach((shape, i) => {
const bounds = TLDR.getBounds(shape)
if (i === 0) {
// For the first shape, offset the current point so
// that the first shape's center is at the page point
currentPoint[0] -= bounds.width / 2
currentPoint[1] -= bounds.height / 2
}
// Set the shape's point the current point
shape.point = [...currentPoint]
// Then bump the page current point by this shape's width
currentPoint[0] += bounds.width
})
const commonBounds = Utils.getCommonBounds(shapesToCreate.map(TLDR.getBounds))
this.createShapes(...shapesToCreate)
// Are the common bounds too big for the viewport?
if (!Utils.boundsContain(this.viewport, commonBounds)) {
this.zoomToSelection()
if (this.zoom > 1) {
this.resetZoom()
}
}
} }
this.setIsLoading(false) this.setIsLoading(false)
@ -3677,8 +3711,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
e.preventDefault() e.preventDefault()
if (this.disableAssets) return this if (this.disableAssets) return this
if (e.dataTransfer.files?.length) { if (e.dataTransfer.files?.length) {
const file = e.dataTransfer.files[0] this.addMediaFromFiles(Object.values(e.dataTransfer.files), [e.clientX, e.clientY])
this.addMediaFromFile(file, [e.clientX, e.clientY])
} }
return this return this
} }

View file

@ -224,7 +224,7 @@ TldrawTestApp {
}, },
}, },
"_status": "ready", "_status": "ready",
"addMediaFromFile": [Function], "addMediaFromFiles": [Function],
"addToSelectHistory": [Function], "addToSelectHistory": [Function],
"align": [Function], "align": [Function],
"altKey": false, "altKey": false,
@ -482,16 +482,6 @@ TldrawTestApp {
], ],
"pan": [Function], "pan": [Function],
"paste": [Function], "paste": [Function],
"pasteInfo": Object {
"center": Array [
0,
0,
],
"offset": Array [
0,
0,
],
},
"patchCreate": [Function], "patchCreate": [Function],
"patchState": [Function], "patchState": [Function],
"persist": [Function], "persist": [Function],
@ -828,156 +818,3 @@ TldrawTestApp {
"zoomToSelection": [Function], "zoomToSelection": [Function],
} }
`; `;
exports[`TldrawTestApp Exposes undo/redo stack: history 1`] = `
Array [
Object {
"after": Object {
"document": Object {
"pageStates": Object {
"page1": Object {
"selectedIds": Array [
"rect1",
],
},
},
"pages": Object {
"page1": Object {
"bindings": Object {},
"shapes": Object {
"rect1": Object {
"childIndex": 1,
"id": "rect1",
"label": "",
"labelPoint": Array [
0.5,
0.5,
],
"name": "Rectangle",
"parentId": "page1",
"point": Array [
0,
0,
],
"rotation": 0,
"size": Array [
100,
200,
],
"style": Object {
"color": "black",
"dash": "draw",
"isFilled": false,
"scale": 1,
"size": "small",
},
"type": "rectangle",
},
},
},
},
},
},
"before": Object {
"document": Object {
"pageStates": Object {
"page1": Object {
"selectedIds": Array [],
},
},
"pages": Object {
"page1": Object {
"bindings": Object {},
"shapes": Object {
"rect1": undefined,
},
},
},
},
},
"id": "create",
},
Object {
"after": Object {
"document": Object {
"pageStates": Object {
"page1": Object {
"selectedIds": Array [
"rect2",
],
},
},
"pages": Object {
"page1": Object {
"bindings": Object {},
"shapes": Object {
"rect2": Object {
"childIndex": 1,
"id": "rect2",
"label": "",
"labelPoint": Array [
0.5,
0.5,
],
"name": "Rectangle",
"parentId": "page1",
"point": Array [
0,
0,
],
"rotation": 0,
"size": Array [
100,
200,
],
"style": Object {
"color": "black",
"dash": "draw",
"isFilled": false,
"scale": 1,
"size": "small",
},
"type": "rectangle",
},
},
},
},
},
},
"before": Object {
"document": Object {
"pageStates": Object {
"page1": Object {
"selectedIds": Array [
"rect1",
],
},
},
"pages": Object {
"page1": Object {
"bindings": Object {},
"shapes": Object {
"rect2": undefined,
},
},
},
},
},
"id": "create",
},
]
`;
exports[`TldrawTestApp Selection When selecting all selects all: selected all 1`] = `
Array [
"rect1",
"rect2",
"rect3",
]
`;
exports[`TldrawTestApp When copying to SVG Copies grouped shapes.: copied svg with group 1`] = `"<svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 232 232\\" width=\\"200\\" height=\\"200\\" style=\\"background-color: rgb(248, 249, 250);\\"><defs><style>@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&amp;family=Source+Code+Pro&amp;family=Source+Sans+Pro&amp;family=Crimson+Pro&amp;display=block');</style></defs><g/></svg>"`;
exports[`TldrawTestApp When copying to SVG Copies shapes.: copied svg 1`] = `"<svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 236.74 236.74\\" width=\\"204.74\\" height=\\"204.74\\" style=\\"background-color: rgb(248, 249, 250);\\"><defs><style>@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&amp;family=Source+Code+Pro&amp;family=Source+Sans+Pro&amp;family=Crimson+Pro&amp;display=block');</style></defs></svg>"`;
exports[`TldrawTestApp When copying to SVG Respects child index: copied svg with reordered elements 1`] = `"<svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 232 232\\" width=\\"200\\" height=\\"200\\" style=\\"background-color: rgb(248, 249, 250);\\"><defs><style>@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&amp;family=Source+Code+Pro&amp;family=Source+Sans+Pro&amp;family=Crimson+Pro&amp;display=block');</style></defs></svg>"`;

View file

@ -106,14 +106,14 @@ export async function openFromFileSystem(): Promise<null | {
} }
} }
export async function openAssetFromFileSystem() { export async function openAssetsFromFileSystem() {
// @ts-ignore // @ts-ignore
const browserFS = await import('./browser-fs-access') const browserFS = await import('./browser-fs-access')
const fileOpen = browserFS.fileOpen const fileOpen = browserFS.fileOpen
return fileOpen({ return fileOpen({
description: 'Image or Video', description: 'Image or Video',
extensions: [...IMAGE_EXTENSIONS, ...VIDEO_EXTENSIONS], extensions: [...IMAGE_EXTENSIONS, ...VIDEO_EXTENSIONS],
multiple: false, multiple: true,
}) })
} }