Add Image and Video shapes (#460)

* Added image and video shapes

* Fixed bugs; Added optional onImageUpload callback

* Added id field to onImageUpload

* Added onImageDelete callback for cleanup

* Added firebase storage to multiplayer for media

* Added firebase storage to multiplayer for media

* Silence unnecessary TS errors

* Fixed bugs; Added tests

* Added tests

* Disable images for multiplayer example

* switch to assets in document, rather than on shapes, fix resize, fix sizes

* bump version, add migration for assets table

* Rename onImageUpload

* Add isPlaying state to video (not complete)

* Revert "Add isPlaying state to video (not complete)"

This reverts commit 3dc2ba703f4194eb7c47524d384dc8392daa18be.

* Adds controls when editing video, sync current time when cloning

* Remove unused tools

* avoid duplication in assets

* Remove unused image styles from style menu

* Fix placement of clone buttons

* Fix flag to hide image assets in multiplayer

* move getSizeFromDataUrl to filesystem

* Update VideoUtil.tsx

* Re-center video after it loads

* Add copy and paste support for assets

* Fix bug in state manager, remove unused assets on load, fix indicators

* Add multiplayer with images example

* Update MultiplayerEditor.tsx

* Add images to copy SVG

* tighten up some code around svg export

* Update TldrawApp.spec.ts

* Update useBoundsHandleEvents.tsx

* Reset image size by double clicking bounds

* fix reset size

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
Faraz Shaikh 2021-12-25 21:06:33 +04:00 committed by GitHub
parent 2f84abcc1e
commit 1c65c031b2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 2044 additions and 260 deletions

1
.gitignore vendored
View file

@ -16,3 +16,4 @@ apps/www/public/worker-*
apps/www/public/sw.js apps/www/public/sw.js
apps/www/public/sw.js.map apps/www/public/sw.js.map
.env .env
firebase.config.*

View file

@ -51,6 +51,7 @@ function Editor({
<div className="tldraw"> <div className="tldraw">
<Tldraw <Tldraw
autofocus autofocus
disableAssets
showPages={false} showPages={false}
showSponsorLink={!isSponsor} showSponsorLink={!isSponsor}
onSignIn={isSponsor ? undefined : onSignIn} onSignIn={isSponsor ? undefined : onSignIn}

View file

@ -24,10 +24,10 @@
"@types/react-router-dom": "^5.1.8", "@types/react-router-dom": "^5.1.8",
"concurrently": "6.0.1", "concurrently": "6.0.1",
"create-serve": "1.0.1", "create-serve": "1.0.1",
"dotenv": "^10.0.0",
"esbuild": "^0.13.8", "esbuild": "^0.13.8",
"esbuild-envfile-plugin": "^1.0.1", "esbuild-envfile-plugin": "^1.0.1",
"esbuild-serve": "^1.0.1", "esbuild-serve": "^1.0.1",
"firebase": "^9.6.1",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-router": "^5.2.1", "react-router": "^5.2.1",

View file

@ -35,7 +35,7 @@ export default function Develop(): JSX.Element {
onSignIn={handleSignIn} onSignIn={handleSignIn}
onSignOut={handleSignOut} onSignOut={handleSignOut}
onPersist={handlePersist} onPersist={handlePersist}
showSponsorLink={true} showSponsorLink={false}
/> />
</div> </div>
) )

View file

@ -0,0 +1 @@
export * from './multiplayer'

View file

@ -0,0 +1,60 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as React from 'react'
import { TDShape, Tldraw } from '@tldraw/tldraw'
import { createClient } from '@liveblocks/client'
import { LiveblocksProvider, RoomProvider } from '@liveblocks/react'
import { useMultiplayerState } from './useMultiplayerState'
// import { initializeApp } from 'firebase/app'
// import firebaseConfig from '../firebase.config'
// import { useMemo } from 'react'
// import { getStorage, ref, uploadBytes, getDownloadURL, deleteObject } from 'firebase/storage'
const client = createClient({
publicApiKey: process.env.LIVEBLOCKS_PUBLIC_API_KEY || '',
throttle: 100,
})
const roomId = 'mp-test-8'
export function Multiplayer() {
return (
<LiveblocksProvider client={client}>
<RoomProvider id={roomId}>
<Editor roomId={roomId} />
</RoomProvider>
</LiveblocksProvider>
)
}
function Editor({ roomId }: { roomId: string }) {
const { error, ...events } = useMultiplayerState(roomId)
// const app = useMemo(() => initializeApp(firebaseConfig), [firebaseConfig])
// const storage = useMemo(() => getStorage(app, firebaseConfig.storageBucket), [])
if (error) return <div>Error: {error.message}</div>
return (
<div className="tldraw">
<Tldraw
showPages={false}
{...events}
/**
* Warning: Keeping images enabled for multiplayer applications
* without provifing a storage bucket based solution will cause
* massive base64 string to be written to the liveblocks room.
*/
disableAssets={true}
// onImageCreate={async (file: File, id: string) => {
// const imageRef = ref(storage, id)
// const snapshot = await uploadBytes(imageRef, file)
// const url = await getDownloadURL(snapshot.ref)
// return url
// }}
// onImageDelete={async (id: string) => {
// const imageRef = ref(storage, id)
// await deleteObject(imageRef)
// }}
/>
</div>
)
}

View file

@ -0,0 +1,211 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as React from 'react'
import type { TldrawApp, TDUser, TDShape, TDBinding, TDDocument } from '@tldraw/tldraw'
import { useRedo, useUndo, useRoom, useUpdateMyPresence } from '@liveblocks/react'
import { LiveMap, LiveObject } from '@liveblocks/client'
declare const window: Window & { app: TldrawApp }
export function useMultiplayerState(roomId: string) {
const [app, setApp] = React.useState<TldrawApp>()
const [error, setError] = React.useState<Error>()
const [loading, setLoading] = React.useState(true)
const room = useRoom()
const onUndo = useUndo()
const onRedo = useRedo()
const updateMyPresence = useUpdateMyPresence()
const rLiveShapes = React.useRef<LiveMap<string, TDShape>>()
const rLiveBindings = React.useRef<LiveMap<string, TDBinding>>()
// Callbacks --------------
// Put the state into the window, for debugging.
const onMount = React.useCallback(
(app: TldrawApp) => {
app.loadRoom(roomId)
app.pause() // Turn off the app's own undo / redo stack
window.app = app
setApp(app)
},
[roomId]
)
// Update the live shapes when the app's shapes change.
const onChangePage = React.useCallback(
(
app: TldrawApp,
shapes: Record<string, TDShape | undefined>,
bindings: Record<string, TDBinding | undefined>
) => {
room.batch(() => {
const lShapes = rLiveShapes.current
const lBindings = rLiveBindings.current
if (!(lShapes && lBindings)) return
Object.entries(shapes).forEach(([id, shape]) => {
if (!shape) {
lShapes.delete(id)
} else {
lShapes.set(shape.id, shape)
}
})
Object.entries(bindings).forEach(([id, binding]) => {
if (!binding) {
lBindings.delete(id)
} else {
lBindings.set(binding.id, binding)
}
})
})
},
[room]
)
// Handle presence updates when the user's pointer / selection changes
const onChangePresence = React.useCallback(
(app: TldrawApp, user: TDUser) => {
updateMyPresence({ id: app.room?.userId, user })
},
[updateMyPresence]
)
// Document Changes --------
React.useEffect(() => {
const unsubs: (() => void)[] = []
if (!(app && room)) return
// Handle errors
unsubs.push(room.subscribe('error', (error) => setError(error)))
// Handle changes to other users' presence
unsubs.push(
room.subscribe('others', (others) => {
app.updateUsers(
others
.toArray()
.filter((other) => other.presence)
.map((other) => other.presence!.user)
.filter(Boolean)
)
})
)
// Handle events from the room
unsubs.push(
room.subscribe(
'event',
(e: { connectionId: number; event: { name: string; userId: string } }) => {
switch (e.event.name) {
case 'exit': {
app?.removeUser(e.event.userId)
break
}
}
}
)
)
// Send the exit event when the tab closes
function handleExit() {
if (!(room && app?.room)) return
room?.broadcastEvent({ name: 'exit', userId: app.room.userId })
}
window.addEventListener('beforeunload', handleExit)
unsubs.push(() => window.removeEventListener('beforeunload', handleExit))
let stillAlive = true
// Setup the document's storage and subscriptions
async function setupDocument() {
const storage = await room.getStorage<any>()
// Initialize (get or create) shapes and bindings maps
let lShapes: LiveMap<string, TDShape> = storage.root.get('shapes')
if (!lShapes) {
storage.root.set('shapes', new LiveMap<string, TDShape>())
lShapes = storage.root.get('shapes')
}
rLiveShapes.current = lShapes
let lBindings: LiveMap<string, TDBinding> = storage.root.get('bindings')
if (!lBindings) {
storage.root.set('bindings', new LiveMap<string, TDBinding>())
lBindings = storage.root.get('bindings')
}
rLiveBindings.current = lBindings
// Migrate previous versions
const version = storage.root.get('version')
if (!version) {
// The doc object will only be present if the document was created
// prior to the current multiplayer implementation. At this time, the
// document was a single LiveObject named 'doc'. If we find a doc,
// then we need to move the shapes and bindings over to the new structures
// and then mark the doc as migrated.
const doc = storage.root.get('doc') as LiveObject<{
uuid: string
document: TDDocument
migrated?: boolean
}>
// No doc? No problem. This was likely a newer document
if (doc) {
const {
document: {
pages: {
page: { shapes, bindings },
},
},
} = doc.toObject()
Object.values(shapes).forEach((shape) => lShapes.set(shape.id, shape))
Object.values(bindings).forEach((binding) => lBindings.set(binding.id, binding))
}
}
// Save the version number for future migrations
storage.root.set('version', 2)
// Subscribe to changes
const handleChanges = () => {
app?.replacePageContent(
Object.fromEntries(lShapes.entries()),
Object.fromEntries(lBindings.entries())
)
}
if (stillAlive) {
unsubs.push(room.subscribe(lShapes, handleChanges))
// Update the document with initial content
handleChanges()
setLoading(false)
}
}
setupDocument()
return () => {
stillAlive = false
unsubs.forEach((unsub) => unsub())
}
}, [app])
return {
onUndo,
onRedo,
onMount,
onChangePage,
onChangePresence,
error,
loading,
}
}

View file

@ -24,12 +24,11 @@ export function Multiplayer() {
function Editor({ roomId }: { roomId: string }) { function Editor({ roomId }: { roomId: string }) {
const { error, ...events } = useMultiplayerState(roomId) const { error, ...events } = useMultiplayerState(roomId)
if (error) return <div>Error: {error.message}</div> if (error) return <div>Error: {error.message}</div>
return ( return (
<div className="tldraw"> <div className="tldraw">
<Tldraw showPages={false} {...events} /> <Tldraw showPages={false} {...events} disableAssets={true} />
</div> </div>
) )
} }

View file

@ -4,7 +4,6 @@ import * as React from 'react'
import type { TldrawApp, TDUser, TDShape, TDBinding, TDDocument } from '@tldraw/tldraw' import type { TldrawApp, TDUser, TDShape, TDBinding, TDDocument } from '@tldraw/tldraw'
import { useRedo, useUndo, useRoom, useUpdateMyPresence } from '@liveblocks/react' import { useRedo, useUndo, useRoom, useUpdateMyPresence } from '@liveblocks/react'
import { LiveMap, LiveObject } from '@liveblocks/client' import { LiveMap, LiveObject } from '@liveblocks/client'
import { Utils } from '@tldraw/core'
declare const window: Window & { app: TldrawApp } declare const window: Window & { app: TldrawApp }
@ -188,7 +187,6 @@ export function useMultiplayerState(roomId: string) {
// Update the document with initial content // Update the document with initial content
handleChanges() handleChanges()
setLoading(false) setLoading(false)
} }
} }

View file

@ -15,7 +15,7 @@ export const CenterHandle = observer<CenterHandleProps>(function CenterHandle({
}): JSX.Element { }): JSX.Element {
return ( return (
<rect <rect
className={isLocked ? 'tl-bounds-center tl-dashed' : 'tl-bounds-center'} className={['tl-bounds-center', isLocked ? 'tl-dashed' : ''].join(' ')}
x={-1} x={-1}
y={-1} y={-1}
width={bounds.width + 2} width={bounds.width + 2}

View file

@ -27,26 +27,27 @@ export const CloneButton = observer<CloneButtonProps>(function CloneButton({
targetSize, targetSize,
size, size,
}: CloneButtonProps) { }: CloneButtonProps) {
const s = targetSize * 2
const x = { const x = {
left: -44, left: -s,
topLeft: -44, topLeft: -s,
bottomLeft: -44, bottomLeft: -s,
right: bounds.width + 44, right: bounds.width,
topRight: bounds.width + 44, topRight: bounds.width,
bottomRight: bounds.width + 44, bottomRight: bounds.width,
top: bounds.width / 2, top: bounds.width / 2 - s / 2,
bottom: bounds.width / 2, bottom: bounds.width / 2 - s / 2,
}[side] }[side]
const y = { const y = {
left: bounds.height / 2, left: bounds.height / 2 - s / 2,
right: bounds.height / 2, right: bounds.height / 2 - s / 2,
top: -44, top: -s * 2,
topLeft: -44, topLeft: -s,
topRight: -44, topRight: -s,
bottom: bounds.height + 44, bottom: bounds.height,
bottomLeft: bounds.height + 44, bottomLeft: bounds.height,
bottomRight: bounds.height + 44, bottomRight: bounds.height,
}[side] }[side]
const { callbacks, inputs } = useTLContext() const { callbacks, inputs } = useTLContext()
@ -62,17 +63,11 @@ export const CloneButton = observer<CloneButtonProps>(function CloneButton({
return ( return (
<g className="tl-clone-target" transform={`translate(${x}, ${y})`} aria-label="clone button"> <g className="tl-clone-target" transform={`translate(${x}, ${y})`} aria-label="clone button">
<rect <rect className="tl-transparent" width={targetSize * 2} height={targetSize * 2} />
className="tl-transparent"
width={targetSize * 4}
height={targetSize * 4}
x={-targetSize * 2}
y={-targetSize * 2}
/>
<g <g
className="tl-clone-button-target" className="tl-clone-button-target"
onPointerDown={handleClick} onPointerDown={handleClick}
transform={`rotate(${ROTATIONS[side]})`} transform={`translate(${targetSize}, ${targetSize}) rotate(${ROTATIONS[side]})`}
> >
<circle className="tl-transparent " r={targetSize} /> <circle className="tl-transparent " r={targetSize} />
<path <path

View file

@ -28,17 +28,18 @@ describe('CloneButton', () => {
const cloneBtn = screen.getByLabelText('clone button') const cloneBtn = screen.getByLabelText('clone button')
expect(cloneBtn).toHaveAttribute('transform', 'translate(50, -44)') expect(cloneBtn).toHaveAttribute('transform', 'translate(30, -80)')
// transparent rect // transparent rect
const rect = cloneBtn.querySelector('rect') const rect = cloneBtn.querySelector('rect')
expect(rect).toHaveAttribute('height', '80') expect(rect).toHaveAttribute('height', '40')
expect(rect).toHaveAttribute('width', '80') expect(rect).toHaveAttribute('width', '40')
expect(rect).toHaveAttribute('x', '-40')
expect(rect).toHaveAttribute('y', '-40')
expect(cloneBtn.querySelector('g')).toHaveAttribute('transform', 'rotate(270)') expect(cloneBtn.querySelector('g')).toHaveAttribute(
'transform',
'translate(20, 20) rotate(270)'
)
expect(cloneBtn.querySelector('circle')).toHaveAttribute('r', '20') expect(cloneBtn.querySelector('circle')).toHaveAttribute('r', '20')
expect(cloneBtn.querySelector('path')).toHaveAttribute('d', 'M -5,-5 L 5,0 -5,5 Z') expect(cloneBtn.querySelector('path')).toHaveAttribute('d', 'M -5,-5 L 5,0 -5,5 Z')
}) })

View file

@ -19,6 +19,7 @@ describe('page', () => {
onBoundsChange={() => { onBoundsChange={() => {
// noop // noop
}} }}
assets={{}}
/> />
) )
}) })

View file

@ -9,7 +9,16 @@ import {
useCameraCss, useCameraCss,
useKeyEvents, useKeyEvents,
} from '~hooks' } from '~hooks'
import type { TLBinding, TLBounds, TLPage, TLPageState, TLShape, TLSnapLine, TLUsers } from '~types' import type {
TLAssets,
TLBinding,
TLBounds,
TLPage,
TLPageState,
TLShape,
TLSnapLine,
TLUsers,
} from '~types'
import { Brush } from '~components/Brush' import { Brush } from '~components/Brush'
import { Page } from '~components/Page' import { Page } from '~components/Page'
import { Users } from '~components/Users' import { Users } from '~components/Users'
@ -20,13 +29,10 @@ import { SnapLines } from '~components/SnapLines/SnapLines'
import { Grid } from '~components/Grid' import { Grid } from '~components/Grid'
import { Overlay } from '~components/Overlay' import { Overlay } from '~components/Overlay'
function resetError() {
void null
}
interface CanvasProps<T extends TLShape, M extends Record<string, unknown>> { interface CanvasProps<T extends TLShape, M extends Record<string, unknown>> {
page: TLPage<T, TLBinding> page: TLPage<T, TLBinding>
pageState: TLPageState pageState: TLPageState
assets: TLAssets
snapLines?: TLSnapLine[] snapLines?: TLSnapLine[]
grid?: number grid?: number
users?: TLUsers<T> users?: TLUsers<T>
@ -52,6 +58,7 @@ export const Canvas = observer(function _Canvas<
id, id,
page, page,
pageState, pageState,
assets,
snapLines, snapLines,
grid, grid,
users, users,
@ -96,6 +103,7 @@ export const Canvas = observer(function _Canvas<
<Page <Page
page={page} page={page}
pageState={pageState} pageState={pageState}
assets={assets}
hideBounds={hideBounds} hideBounds={hideBounds}
hideIndicators={hideIndicators} hideIndicators={hideIndicators}
hideHandles={hideHandles} hideHandles={hideHandles}

View file

@ -11,7 +11,7 @@ export const HTMLContainer = React.forwardRef<HTMLDivElement, HTMLContainerProps
<Observer> <Observer>
{() => ( {() => (
<div ref={ref} className={`tl-positioned-div ${className}`} {...rest}> <div ref={ref} className={`tl-positioned-div ${className}`} {...rest}>
{children} <div className="tl-inner-div">{children}</div>
</div> </div>
)} )}
</Observer> </Observer>

View file

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import * as React from 'react' import * as React from 'react'
import type { TLBinding, TLPage, TLPageState, TLShape } from '~types' import type { TLAssets, TLBinding, TLPage, TLPageState, TLShape } from '~types'
import { useSelection, useShapeTree, useTLContext } from '~hooks' import { useSelection, useShapeTree, useTLContext } from '~hooks'
import { Bounds } from '~components/Bounds' import { Bounds } from '~components/Bounds'
import { BoundsBg } from '~components/Bounds/BoundsBg' import { BoundsBg } from '~components/Bounds/BoundsBg'
@ -13,6 +13,7 @@ import type { TLShapeUtil } from '~TLShapeUtil'
interface PageProps<T extends TLShape, M extends Record<string, unknown>> { interface PageProps<T extends TLShape, M extends Record<string, unknown>> {
page: TLPage<T, TLBinding> page: TLPage<T, TLBinding>
pageState: TLPageState pageState: TLPageState
assets: TLAssets
hideBounds: boolean hideBounds: boolean
hideHandles: boolean hideHandles: boolean
hideIndicators: boolean hideIndicators: boolean
@ -29,6 +30,7 @@ interface PageProps<T extends TLShape, M extends Record<string, unknown>> {
export const Page = observer(function _Page<T extends TLShape, M extends Record<string, unknown>>({ export const Page = observer(function _Page<T extends TLShape, M extends Record<string, unknown>>({
page, page,
pageState, pageState,
assets,
hideBounds, hideBounds,
hideHandles, hideHandles,
hideIndicators, hideIndicators,
@ -40,30 +42,29 @@ export const Page = observer(function _Page<T extends TLShape, M extends Record<
}: PageProps<T, M>): JSX.Element { }: PageProps<T, M>): JSX.Element {
const { bounds: rendererBounds, shapeUtils } = useTLContext() const { bounds: rendererBounds, shapeUtils } = useTLContext()
const shapeTree = useShapeTree(page, pageState, meta) const shapeTree = useShapeTree(page, pageState, assets, meta)
const { bounds, isLinked, isLocked, rotation } = useSelection(page, pageState, shapeUtils) const { bounds, isLinked, isLocked, rotation } = useSelection(page, pageState, shapeUtils)
const { const {
selectedIds, selectedIds,
hoveredId, hoveredId,
editingId,
camera: { zoom }, camera: { zoom },
} = pageState } = pageState
let _hideCloneHandles = true let _hideCloneHandles = true
let _isEditing = false
// Does the selected shape have handles? // Does the selected shape have handles?
let shapeWithHandles: TLShape | undefined = undefined let shapeWithHandles: TLShape | undefined = undefined
const selectedShapes = selectedIds.map((id) => page.shapes[id]) const selectedShapes = selectedIds.map((id) => page.shapes[id])
if (selectedShapes.length === 1) { if (selectedShapes.length === 1) {
const shape = selectedShapes[0] const shape = selectedShapes[0]
_isEditing = editingId === shape.id
const utils = shapeUtils[shape.type] as TLShapeUtil<any, any> const utils = shapeUtils[shape.type] as TLShapeUtil<any, any>
_hideCloneHandles = hideCloneHandles || !utils.showCloneHandles _hideCloneHandles = hideCloneHandles || !utils.showCloneHandles
if (shape.handles !== undefined) { if (shape.handles !== undefined) {
shapeWithHandles = shape shapeWithHandles = shape
} }
@ -82,9 +83,10 @@ export const Page = observer(function _Page<T extends TLShape, M extends Record<
shape={shape} shape={shape}
meta={meta as any} meta={meta as any}
isSelected isSelected
isEditing={_isEditing}
/> />
))} ))}
{!hideIndicators && hoveredId && ( {!hideIndicators && hoveredId && hoveredId !== editingId && (
<ShapeIndicator <ShapeIndicator
key={'hovered_' + hoveredId} key={'hovered_' + hoveredId}
shape={page.shapes[hoveredId]} shape={page.shapes[hoveredId]}

View file

@ -11,6 +11,7 @@ import type {
TLBinding, TLBinding,
TLSnapLine, TLSnapLine,
TLUsers, TLUsers,
TLAssets,
} from '../../types' } from '../../types'
import { Canvas } from '../Canvas' import { Canvas } from '../Canvas'
import { Inputs } from '../../inputs' import { Inputs } from '../../inputs'
@ -30,6 +31,10 @@ export interface RendererProps<T extends TLShape, M = any> extends Partial<TLCal
* The current page state. * The current page state.
*/ */
pageState: TLPageState pageState: TLPageState
/**
* A map of assets to be used in the renderer.
*/
assets: TLAssets
/** /**
* (optional) A unique id to be applied to the renderer element, used to scope styles. * (optional) A unique id to be applied to the renderer element, used to scope styles.
*/ */
@ -121,6 +126,7 @@ export const Renderer = observer(function _Renderer<
shapeUtils, shapeUtils,
page, page,
pageState, pageState,
assets,
users, users,
userId, userId,
theme, theme,
@ -177,6 +183,7 @@ export const Renderer = observer(function _Renderer<
id={id} id={id}
page={page} page={page}
pageState={pageState} pageState={pageState}
assets={assets}
snapLines={snapLines} snapLines={snapLines}
grid={grid} grid={grid}
users={users} users={users}

View file

@ -6,7 +6,13 @@ import { boxShape } from '~TLShapeUtil/TLShapeUtil.spec'
describe('shape indicator', () => { describe('shape indicator', () => {
test('mounts component without crashing', () => { test('mounts component without crashing', () => {
renderWithSvg( renderWithSvg(
<ShapeIndicator shape={boxShape} isSelected={true} isHovered={false} meta={undefined} /> <ShapeIndicator
shape={boxShape}
isSelected={true}
isHovered={false}
isEditing={false}
meta={undefined}
/>
) )
}) })
}) })

View file

@ -8,12 +8,14 @@ interface IndicatorProps<T extends TLShape, M = unknown> {
meta: M extends unknown ? M : undefined meta: M extends unknown ? M : undefined
isSelected?: boolean isSelected?: boolean
isHovered?: boolean isHovered?: boolean
isEditing?: boolean
user?: TLUser<T> user?: TLUser<T>
} }
export const ShapeIndicator = observer(function ShapeIndicator<T extends TLShape, M>({ export const ShapeIndicator = observer(function ShapeIndicator<T extends TLShape, M>({
isHovered = false, isHovered = false,
isSelected = false, isSelected = false,
isEditing = false,
shape, shape,
user, user,
meta, meta,
@ -26,9 +28,12 @@ export const ShapeIndicator = observer(function ShapeIndicator<T extends TLShape
return ( return (
<div <div
ref={rPositioned} ref={rPositioned}
className={ className={[
'tl-indicator tl-absolute ' + (user ? '' : isSelected ? 'tl-selected' : 'tl-hovered') 'tl-indicator',
} 'tl-absolute',
isSelected && !user ? 'tl-selected' : 'tl-hovered',
isEditing ? 'tl-editing' : '',
].join(' ')}
> >
<svg width="100%" height="100%"> <svg width="100%" height="100%">
<g className="tl-centered-g" stroke={user?.color}> <g className="tl-centered-g" stroke={user?.color}>

View file

@ -11,10 +11,10 @@ export function useBoundsHandleEvents(
(e: React.PointerEvent) => { (e: React.PointerEvent) => {
if (e.button !== 0) return if (e.button !== 0) return
if (!inputs.pointerIsValid(e)) return if (!inputs.pointerIsValid(e)) return
e.stopPropagation()
e.currentTarget?.setPointerCapture(e.pointerId)
const info = inputs.pointerDown(e, id) const info = inputs.pointerDown(e, id)
if (inputs.isDoubleClick() && !(info.altKey || info.metaKey)) {
callbacks.onDoubleClickBoundsHandle?.(info, e)
}
callbacks.onPointBoundsHandle?.(info, e) callbacks.onPointBoundsHandle?.(info, e)
callbacks.onPointerDown?.(info, e) callbacks.onPointerDown?.(info, e)
}, },
@ -25,18 +25,7 @@ export function useBoundsHandleEvents(
(e: React.PointerEvent) => { (e: React.PointerEvent) => {
if (e.button !== 0) return if (e.button !== 0) return
if (!inputs.pointerIsValid(e)) return if (!inputs.pointerIsValid(e)) return
e.stopPropagation()
const isDoubleClick = inputs.isDoubleClick()
const info = inputs.pointerUp(e, id) const info = inputs.pointerUp(e, id)
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget?.releasePointerCapture(e.pointerId)
}
if (isDoubleClick && !(info.altKey || info.metaKey)) {
callbacks.onDoubleClickBoundsHandle?.(info, e)
}
callbacks.onReleaseBoundsHandle?.(info, e) callbacks.onReleaseBoundsHandle?.(info, e)
callbacks.onPointerUp?.(info, e) callbacks.onPointerUp?.(info, e)
}, },
@ -47,7 +36,6 @@ export function useBoundsHandleEvents(
(e: React.PointerEvent) => { (e: React.PointerEvent) => {
if (!inputs.pointerIsValid(e)) return if (!inputs.pointerIsValid(e)) return
e.stopPropagation() e.stopPropagation()
if (e.currentTarget.hasPointerCapture(e.pointerId)) { if (e.currentTarget.hasPointerCapture(e.pointerId)) {
callbacks.onDragBoundsHandle?.(inputs.pointerMove(e, id), e) callbacks.onDragBoundsHandle?.(inputs.pointerMove(e, id), e)
} }

View file

@ -61,5 +61,7 @@ export function useCanvasEvents() {
onPointerDown, onPointerDown,
onPointerMove, onPointerMove,
onPointerUp, onPointerUp,
onDrop: callbacks.onDrop,
onDragOver: callbacks.onDragOver,
} }
} }

View file

@ -1,7 +1,15 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as React from 'react' import * as React from 'react'
import type { IShapeTreeNode, TLPage, TLPageState, TLShape, TLBinding, TLBounds } from '~types' import type {
IShapeTreeNode,
TLPage,
TLPageState,
TLShape,
TLBinding,
TLBounds,
TLAssets,
} from '~types'
import { Utils } from '~utils' import { Utils } from '~utils'
import { Vec } from '@tldraw/vec' import { Vec } from '@tldraw/vec'
import { useTLContext } from '~hooks' import { useTLContext } from '~hooks'
@ -13,6 +21,7 @@ function addToShapeTree<T extends TLShape, M extends Record<string, unknown>>(
pageState: TLPageState & { pageState: TLPageState & {
bindingTargetId?: string | null bindingTargetId?: string | null
}, },
assets: TLAssets,
isChildOfGhost = false, isChildOfGhost = false,
isChildOfSelected = false, isChildOfSelected = false,
meta?: M meta?: M
@ -20,6 +29,7 @@ function addToShapeTree<T extends TLShape, M extends Record<string, unknown>>(
// Create a node for this shape // Create a node for this shape
const node: IShapeTreeNode<T, M> = { const node: IShapeTreeNode<T, M> = {
shape, shape,
asset: shape.assetId ? assets[shape.assetId] : undefined,
meta: meta as any, meta: meta as any,
isChildOfSelected, isChildOfSelected,
isGhost: shape.isGhost || isChildOfGhost, isGhost: shape.isGhost || isChildOfGhost,
@ -54,6 +64,7 @@ function addToShapeTree<T extends TLShape, M extends Record<string, unknown>>(
node.children!, node.children!,
shapes, shapes,
pageState, pageState,
assets,
node.isGhost, node.isGhost,
node.isSelected || node.isChildOfSelected, node.isSelected || node.isChildOfSelected,
meta meta
@ -69,6 +80,7 @@ function shapeIsInViewport(bounds: TLBounds, viewport: TLBounds) {
export function useShapeTree<T extends TLShape, M extends Record<string, unknown>>( export function useShapeTree<T extends TLShape, M extends Record<string, unknown>>(
page: TLPage<T, TLBinding>, page: TLPage<T, TLBinding>,
pageState: TLPageState, pageState: TLPageState,
assets: TLAssets,
meta?: M meta?: M
) { ) {
const { callbacks, shapeUtils, bounds } = useTLContext() const { callbacks, shapeUtils, bounds } = useTLContext()
@ -154,6 +166,7 @@ export function useShapeTree<T extends TLShape, M extends Record<string, unknown
tree, tree,
page.shapes, page.shapes,
{ ...pageState, bindingTargetId }, { ...pageState, bindingTargetId },
assets,
shape.isGhost, shape.isGhost,
false, false,
meta meta

View file

@ -219,6 +219,12 @@ const tlcss = css`
contain: layout style size; contain: layout style size;
} }
.tl-inner-div {
position: relative;
width: 100%;
height: 100%;
}
.tl-stroke-hitarea { .tl-stroke-hitarea {
cursor: pointer; cursor: pointer;
fill: none; fill: none;
@ -309,12 +315,16 @@ const tlcss = css`
border-width: calc(1px * var(--tl-scale)); border-width: calc(1px * var(--tl-scale));
} }
.tl-hovered {
stroke: var(--tl-selectStroke);
}
.tl-selected { .tl-selected {
stroke: var(--tl-selectStroke); stroke: var(--tl-selectStroke);
} }
.tl-hovered { .tl-editing {
stroke: var(--tl-selectStroke); stroke-width: calc(2.5px * min(5, var(--tl-scale)));
} }
.tl-clone-target { .tl-clone-target {

View file

@ -4,6 +4,32 @@
import type React from 'react' import type React from 'react'
export type TLAssets = Record<string, TLAsset>
export enum TLAssetType {
Image = 'image',
Video = 'video',
}
export interface TLBaseAsset {
id: string
type: TLAssetType
}
export interface TLImageAsset extends TLBaseAsset {
type: TLAssetType.Image
src: string
size: number[]
}
export interface TLVideoAsset extends TLBaseAsset {
type: TLAssetType.Video
src: string
size: number[]
}
export type TLAsset = TLImageAsset | TLVideoAsset
export type Patch<T> = Partial<{ [P in keyof T]: T | Partial<T> | Patch<T[P]> }> export type Patch<T> = Partial<{ [P in keyof T]: T | Partial<T> | Patch<T[P]> }>
export type TLForwardedRef<T> = export type TLForwardedRef<T> =
@ -57,6 +83,7 @@ export interface TLShape {
childIndex: number childIndex: number
name: string name: string
point: number[] point: number[]
assetId?: string
rotation?: number rotation?: number
children?: string[] children?: string[]
handles?: Record<string, TLHandle> handles?: Record<string, TLHandle>
@ -69,6 +96,7 @@ export interface TLShape {
export interface TLComponentProps<T extends TLShape, E = any, M = any> { export interface TLComponentProps<T extends TLShape, E = any, M = any> {
shape: T shape: T
asset?: TLAsset
isEditing: boolean isEditing: boolean
isBinding: boolean isBinding: boolean
isHovered: boolean isHovered: boolean
@ -117,6 +145,8 @@ export type TLWheelEventHandler = (
e: React.WheelEvent<Element> | WheelEvent e: React.WheelEvent<Element> | WheelEvent
) => void ) => void
export type TLDropEventHandler = (e: React.DragEvent<Element>) => void
export type TLPinchEventHandler = ( export type TLPinchEventHandler = (
info: TLPointerInfo<string>, info: TLPointerInfo<string>,
e: e:
@ -176,6 +206,8 @@ export interface TLCallbacks<T extends TLShape> {
onRightPointCanvas: TLCanvasEventHandler onRightPointCanvas: TLCanvasEventHandler
onDragCanvas: TLCanvasEventHandler onDragCanvas: TLCanvasEventHandler
onReleaseCanvas: TLCanvasEventHandler onReleaseCanvas: TLCanvasEventHandler
onDragOver: TLDropEventHandler
onDrop: TLDropEventHandler
// Shape // Shape
onPointShape: TLPointerEventHandler onPointShape: TLPointerEventHandler
@ -314,6 +346,7 @@ export type Snap =
export interface IShapeTreeNode<T extends TLShape, M = any> { export interface IShapeTreeNode<T extends TLShape, M = any> {
shape: T shape: T
asset?: TLAsset
children?: IShapeTreeNode<TLShape, M>[] children?: IShapeTreeNode<TLShape, M>[]
isGhost: boolean isGhost: boolean
isChildOfSelected: boolean isChildOfSelected: boolean

View file

@ -11,6 +11,7 @@ import { ContextMenu } from '~components/ContextMenu'
import { FocusButton } from '~components/FocusButton' import { FocusButton } from '~components/FocusButton'
import { TLDR } from '~state/TLDR' import { TLDR } from '~state/TLDR'
import { GRID_SIZE } from '~constants' import { GRID_SIZE } from '~constants'
import { Loading } from '~components/Loading'
export interface TldrawProps extends TDCallbacks { export interface TldrawProps extends TDCallbacks {
/** /**
@ -78,6 +79,14 @@ export interface TldrawProps extends TDCallbacks {
*/ */
darkMode?: boolean darkMode?: boolean
/**
* (optional) If provided, image/video componnets will be disabled.
*
* Warning: Keeping this enabled for multiplayer applications without provifing a storage
* bucket based solution will cause massive base64 string to be written to the liveblocks room.
*/
disableAssets?: boolean
/** /**
* (optional) A callback to run when the component mounts. * (optional) A callback to run when the component mounts.
*/ */
@ -142,6 +151,16 @@ export interface TldrawProps extends TDCallbacks {
*/ */
onRedo?: (state: TldrawApp) => void onRedo?: (state: TldrawApp) => void
/**
* (optional) A callback to run when the user creates an image or video asset. Returns the desired "src" attribute eg: base64 (default) or remote URL
*/
onImageCreate?: (file: File, id: string) => Promise<string>
/**
* (optional) A callback to run when the user deletes an image or video.
*/
onImageDelete?: (id: string) => void
onChangePage?: ( onChangePage?: (
app: TldrawApp, app: TldrawApp,
shapes: Record<string, TDShape | undefined>, shapes: Record<string, TDShape | undefined>,
@ -153,7 +172,6 @@ export function Tldraw({
id, id,
document, document,
currentPageId, currentPageId,
darkMode = false,
autofocus = true, autofocus = true,
showMenu = true, showMenu = true,
showPages = true, showPages = true,
@ -163,6 +181,7 @@ export function Tldraw({
showUI = true, showUI = true,
readOnly = false, readOnly = false,
showSponsorLink = false, showSponsorLink = false,
disableAssets = false,
onMount, onMount,
onChange, onChange,
onChangePresence, onChangePresence,
@ -170,6 +189,7 @@ export function Tldraw({
onSaveProject, onSaveProject,
onSaveProjectAs, onSaveProjectAs,
onOpenProject, onOpenProject,
onOpenMedia,
onSignOut, onSignOut,
onSignIn, onSignIn,
onUndo, onUndo,
@ -178,30 +198,35 @@ export function Tldraw({
onPatch, onPatch,
onCommand, onCommand,
onChangePage, onChangePage,
onImageCreate,
onImageDelete,
}: TldrawProps) { }: TldrawProps) {
const [sId, setSId] = React.useState(id) const [sId, setSId] = React.useState(id)
// Create a new app when the component mounts. // Create a new app when the component mounts.
const [app, setApp] = React.useState( const [app, setApp] = React.useState(() => {
() => const app = new TldrawApp(id, {
new TldrawApp(id, { onMount,
onMount, onChange,
onChange, onChangePresence,
onChangePresence, onNewProject,
onNewProject, onSaveProject,
onSaveProject, onSaveProjectAs,
onSaveProjectAs, onOpenProject,
onOpenProject, onOpenMedia,
onSignOut, onSignOut,
onSignIn, onSignIn,
onUndo, onUndo,
onRedo, onRedo,
onPersist, onPersist,
onPatch, onPatch,
onCommand, onCommand,
onChangePage, onChangePage,
}) onImageDelete,
) onImageCreate,
})
return app
})
// Create a new app if the `id` prop changes. // Create a new app if the `id` prop changes.
React.useEffect(() => { React.useEffect(() => {
@ -215,6 +240,7 @@ export function Tldraw({
onSaveProject, onSaveProject,
onSaveProjectAs, onSaveProjectAs,
onOpenProject, onOpenProject,
onOpenMedia,
onSignOut, onSignOut,
onSignIn, onSignIn,
onUndo, onUndo,
@ -223,10 +249,10 @@ export function Tldraw({
onPatch, onPatch,
onCommand, onCommand,
onChangePage, onChangePage,
onImageDelete,
onImageCreate,
}) })
setSId(id) setSId(id)
setApp(newApp) setApp(newApp)
}, [sId, id]) }, [sId, id])
@ -234,7 +260,6 @@ export function Tldraw({
// are the same, or else load a new document if the ids are different. // are the same, or else load a new document if the ids are different.
React.useEffect(() => { React.useEffect(() => {
if (!document) return if (!document) return
if (document.id === app.document.id) { if (document.id === app.document.id) {
app.updateDocument(document) app.updateDocument(document)
} else { } else {
@ -242,24 +267,22 @@ export function Tldraw({
} }
}, [document, app]) }, [document, app])
// Change the page when the `currentPageId` prop changes // Disable assets when the `disableAssets` prop changes.
React.useEffect(() => {
app.setDisableAssets(disableAssets)
}, [app, disableAssets])
// Change the page when the `currentPageId` prop changes.
React.useEffect(() => { React.useEffect(() => {
if (!currentPageId) return if (!currentPageId) return
app.changePage(currentPageId) app.changePage(currentPageId)
}, [currentPageId, app]) }, [currentPageId, app])
// Toggle the app's readOnly mode when the `readOnly` prop changes // Toggle the app's readOnly mode when the `readOnly` prop changes.
React.useEffect(() => { React.useEffect(() => {
app.readOnly = readOnly app.readOnly = readOnly
}, [app, readOnly]) }, [app, readOnly])
// Toggle the app's readOnly mode when the `readOnly` prop changes
React.useEffect(() => {
if (darkMode && !app.settings.isDarkMode) {
// app.toggleDarkMode()
}
}, [app, darkMode])
// Update the app's callbacks when any callback changes. // Update the app's callbacks when any callback changes.
React.useEffect(() => { React.useEffect(() => {
app.callbacks = { app.callbacks = {
@ -270,6 +293,7 @@ export function Tldraw({
onSaveProject, onSaveProject,
onSaveProjectAs, onSaveProjectAs,
onOpenProject, onOpenProject,
onOpenMedia,
onSignOut, onSignOut,
onSignIn, onSignIn,
onUndo, onUndo,
@ -278,6 +302,8 @@ export function Tldraw({
onPatch, onPatch,
onCommand, onCommand,
onChangePage, onChangePage,
onImageDelete,
onImageCreate,
} }
}, [ }, [
onMount, onMount,
@ -287,6 +313,7 @@ export function Tldraw({
onSaveProject, onSaveProject,
onSaveProjectAs, onSaveProjectAs,
onOpenProject, onOpenProject,
onOpenMedia,
onSignOut, onSignOut,
onSignIn, onSignIn,
onUndo, onUndo,
@ -295,6 +322,8 @@ export function Tldraw({
onPatch, onPatch,
onCommand, onCommand,
onChangePage, onChangePage,
onImageDelete,
onImageCreate,
]) ])
// Use the `key` to ensure that new selector hooks are made when the id changes // Use the `key` to ensure that new selector hooks are made when the id changes
@ -354,6 +383,7 @@ const InnerTldraw = React.memo(function InnerTldraw({
const page = document.pages[appState.currentPageId] const page = document.pages[appState.currentPageId]
const pageState = document.pageStates[page.id] const pageState = document.pageStates[page.id]
const assets = document.assets
const { selectedIds } = pageState const { selectedIds } = pageState
const isHideBoundsShape = const isHideBoundsShape =
@ -366,22 +396,6 @@ const InnerTldraw = React.memo(function InnerTldraw({
page.shapes[selectedIds[0]] && page.shapes[selectedIds[0]] &&
TLDR.getShapeUtil(page.shapes[selectedIds[0]].type).hideResizeHandles TLDR.getShapeUtil(page.shapes[selectedIds[0]].type).hideResizeHandles
const isInSession = app.session !== undefined
// Hide bounds when not using the select tool, or when the only selected shape has handles
const hideBounds =
(isInSession && app.session?.constructor.name !== 'BrushSession') ||
!isSelecting ||
isHideBoundsShape ||
!!pageState.editingId
// Hide bounds when not using the select tool, or when in session
const hideHandles = isInSession || !isSelecting
// Hide indicators when not using the select tool, or when in session
const hideIndicators =
(isInSession && state.appState.status !== TDStatus.Brushing) || !isSelecting
// Custom rendering meta, with dark mode for shapes // Custom rendering meta, with dark mode for shapes
const meta = React.useMemo(() => { const meta = React.useMemo(() => {
return { isDarkMode: settings.isDarkMode } return { isDarkMode: settings.isDarkMode }
@ -414,8 +428,28 @@ const InnerTldraw = React.memo(function InnerTldraw({
elm.dispatchEvent(new Event('pointerup', { bubbles: true })) elm.dispatchEvent(new Event('pointerup', { bubbles: true }))
}, []) }, [])
const isInSession = app.session !== undefined
// Hide bounds when not using the select tool, or when the only selected shape has handles
const hideBounds =
(isInSession && app.session?.constructor.name !== 'BrushSession') ||
!isSelecting ||
isHideBoundsShape ||
!!pageState.editingId
// Hide bounds when not using the select tool, or when in session
const hideHandles = isInSession || !isSelecting
// Hide indicators when not using the select tool, or when in session
const hideIndicators =
(isInSession && state.appState.status !== TDStatus.Brushing) || !isSelecting
const hideCloneHandles =
isInSession || !isSelecting || !settings.showCloneHandles || pageState.camera.zoom < 0.2
return ( return (
<StyledLayout ref={rWrapper} tabIndex={-0} className={settings.isDarkMode ? dark : ''}> <StyledLayout ref={rWrapper} tabIndex={-0} className={settings.isDarkMode ? dark : ''}>
<Loading />
<OneOff focusableRef={rWrapper} autofocus={autofocus} /> <OneOff focusableRef={rWrapper} autofocus={autofocus} />
<ContextMenu onBlur={handleMenuBlur}> <ContextMenu onBlur={handleMenuBlur}>
<Renderer <Renderer
@ -424,6 +458,7 @@ const InnerTldraw = React.memo(function InnerTldraw({
shapeUtils={shapeUtils} shapeUtils={shapeUtils}
page={page} page={page}
pageState={pageState} pageState={pageState}
assets={assets}
snapLines={appState.snapLines} snapLines={appState.snapLines}
grid={GRID_SIZE} grid={GRID_SIZE}
users={room?.users} users={room?.users}
@ -435,7 +470,7 @@ const InnerTldraw = React.memo(function InnerTldraw({
hideResizeHandles={isHideResizeHandlesShape} hideResizeHandles={isHideResizeHandlesShape}
hideIndicators={hideIndicators} hideIndicators={hideIndicators}
hideBindingHandles={!settings.showBindingHandles} hideBindingHandles={!settings.showBindingHandles}
hideCloneHandles={!settings.showCloneHandles} hideCloneHandles={hideCloneHandles}
hideRotateHandles={!settings.showRotateHandles} hideRotateHandles={!settings.showRotateHandles}
hideGrid={!settings.showGrid} hideGrid={!settings.showGrid}
onPinchStart={app.onPinchStart} onPinchStart={app.onPinchStart}
@ -487,6 +522,8 @@ const InnerTldraw = React.memo(function InnerTldraw({
onBoundsChange={app.updateBounds} onBoundsChange={app.updateBounds}
onKeyDown={app.onKeyDown} onKeyDown={app.onKeyDown}
onKeyUp={app.onKeyUp} onKeyUp={app.onKeyUp}
onDragOver={app.onDragOver}
onDrop={app.onDrop}
/> />
</ContextMenu> </ContextMenu>
{showUI && ( {showUI && (

View file

@ -0,0 +1,43 @@
import * as React from 'react'
import { Panel } from '~components/Primitives/Panel'
import { useTldrawApp } from '~hooks'
import { styled } from '~styles'
import type { TDSnapshot } from '~types'
const loadingSelector = (s: TDSnapshot) => s.appState.isLoading
export function Loading() {
const app = useTldrawApp()
const isLoading = app.useStore(loadingSelector)
return <StyledLoadingPanelContainer hidden={!isLoading}>Loading...</StyledLoadingPanelContainer>
}
const StyledLoadingPanelContainer = styled('div', {
position: 'absolute',
top: 0,
left: '50%',
transform: `translate(-50%, 0)`,
borderBottomLeftRadius: '12px',
borderBottomRightRadius: '12px',
padding: '8px',
fontFamily: 'var(--fonts-ui)',
fontSize: 'var(--fontSizes-1)',
boxShadow: 'var(--shadows-panel)',
backgroundColor: 'white',
zIndex: 200,
pointerEvents: 'none',
'& > div > *': {
pointerEvents: 'all',
},
variants: {
transform: {
hidden: {
transform: `translate(-50%, 100%)`,
},
visible: {
transform: `translate(-50%, 0%)`,
},
},
},
})

View file

@ -0,0 +1 @@
export { Loading } from './Loading'

View file

@ -16,6 +16,7 @@ import { HeartIcon } from '~components/Primitives/icons/HeartIcon'
import { preventEvent } from '~components/preventEvent' import { preventEvent } from '~components/preventEvent'
import { DiscordIcon } from '~components/Primitives/icons' import { DiscordIcon } from '~components/Primitives/icons'
import type { TDSnapshot } from '~types' import type { TDSnapshot } from '~types'
import { Divider } from '~components/Primitives/Divider'
interface MenuProps { interface MenuProps {
showSponsorLink: boolean showSponsorLink: boolean
@ -26,9 +27,14 @@ const numberOfSelectedIdsSelector = (s: TDSnapshot) => {
return s.document.pageStates[s.appState.currentPageId].selectedIds.length return s.document.pageStates[s.appState.currentPageId].selectedIds.length
} }
const disableAssetsSelector = (s: TDSnapshot) => {
return s.appState.disableAssets
}
export const Menu = React.memo(function Menu({ showSponsorLink, readOnly }: MenuProps) { export const Menu = React.memo(function Menu({ showSponsorLink, readOnly }: MenuProps) {
const app = useTldrawApp() const app = useTldrawApp()
const numberOfSelectedIds = app.useStore(numberOfSelectedIdsSelector) const numberOfSelectedIds = app.useStore(numberOfSelectedIdsSelector)
const disableAssets = app.useStore(disableAssetsSelector)
const { onNewProject, onOpenProject, onSaveProject, onSaveProjectAs } = useFileSystemHandlers() const { onNewProject, onOpenProject, onSaveProject, onSaveProjectAs } = useFileSystemHandlers()
@ -64,10 +70,14 @@ export const Menu = React.memo(function Menu({ showSponsorLink, readOnly }: Menu
app.selectAll() app.selectAll()
}, [app]) }, [app])
const handleselectNone = React.useCallback(() => { const handleSelectNone = React.useCallback(() => {
app.selectNone() app.selectNone()
}, [app]) }, [app])
const handleUploadMedia = React.useCallback(() => {
app.openAsset()
}, [app])
const showFileMenu = const showFileMenu =
app.callbacks.onNewProject || app.callbacks.onNewProject ||
app.callbacks.onOpenProject || app.callbacks.onOpenProject ||
@ -106,6 +116,14 @@ export const Menu = React.memo(function Menu({ showSponsorLink, readOnly }: Menu
Save As... Save As...
</DMItem> </DMItem>
)} )}
{!disableAssets && (
<>
<Divider />
<DMItem onClick={handleUploadMedia} kbd="#U">
Upload Media
</DMItem>
</>
)}
</DMSubMenu> </DMSubMenu>
)} )}
{!readOnly && ( {!readOnly && (
@ -148,7 +166,7 @@ export const Menu = React.memo(function Menu({ showSponsorLink, readOnly }: Menu
<DMItem onSelect={preventEvent} onClick={handleSelectAll} kbd="#A"> <DMItem onSelect={preventEvent} onClick={handleSelectAll} kbd="#A">
Select All Select All
</DMItem> </DMItem>
<DMItem onSelect={preventEvent} onClick={handleselectNone}> <DMItem onSelect={preventEvent} onClick={handleSelectNone}>
Select None Select None
</DMItem> </DMItem>
</DMSubMenu> </DMSubMenu>

View file

@ -33,7 +33,6 @@ import {
TextAlignLeftIcon, TextAlignLeftIcon,
TextAlignRightIcon, TextAlignRightIcon,
} from '@radix-ui/react-icons' } from '@radix-ui/react-icons'
import { RowButton } from '~components/Primitives/RowButton'
const currentStyleSelector = (s: TDSnapshot) => s.appState.currentStyle const currentStyleSelector = (s: TDSnapshot) => s.appState.currentStyle
const selectedIdsSelector = (s: TDSnapshot) => const selectedIdsSelector = (s: TDSnapshot) =>
@ -290,27 +289,6 @@ const ColorGrid = styled('div', {
gap: 0, gap: 0,
}) })
// const StyledRowInner = styled('div', {
// height: '100%',
// width: '100%',
// backgroundColor: '$panel',
// borderRadius: '$2',
// display: 'flex',
// gap: '$1',
// flexDirection: 'row',
// alignItems: 'center',
// padding: '0 $3',
// justifyContent: 'space-between',
// border: '1px solid transparent',
// '& svg': {
// position: 'relative',
// stroke: '$overlay',
// strokeWidth: 1,
// zIndex: 1,
// },
// })
export const StyledRow = styled('div', { export const StyledRow = styled('div', {
position: 'relative', position: 'relative',
width: '100%', width: '100%',

View file

@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
export const GRID_SIZE = 8 export const GRID_SIZE = 8
export const SVG_EXPORT_PADDING = 16
export const BINDING_DISTANCE = 16 export const BINDING_DISTANCE = 16
export const CLONING_DISTANCE = 32 export const CLONING_DISTANCE = 32
export const FIT_TO_SCREEN_PADDING = 128 export const FIT_TO_SCREEN_PADDING = 128
@ -79,3 +80,8 @@ export const USER_COLORS = [
'#55B467', '#55B467',
'#FF802B', '#FF802B',
] ]
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
export const IMAGE_EXTENSIONS = ['.png', '.svg', '.jpg', '.jpeg', '.gif']
export const VIDEO_EXTENSIONS = isSafari ? [] : ['.mp4', '.webm']

View file

@ -42,10 +42,15 @@ export function useFileSystem() {
[promptSaveBeforeChange] [promptSaveBeforeChange]
) )
const onOpenMedia = React.useCallback(async (app: TldrawApp) => {
app.openAsset?.()
}, [])
return { return {
onNewProject, onNewProject,
onSaveProject, onSaveProject,
onSaveProjectAs, onSaveProjectAs,
onOpenProject, onOpenProject,
onOpenMedia,
} }
} }

View file

@ -36,10 +36,19 @@ export function useFileSystemHandlers() {
[app] [app]
) )
const onOpenMedia = React.useCallback(
async (e?: React.MouseEvent | React.KeyboardEvent | KeyboardEvent) => {
if (e && app.callbacks.onOpenMedia) e.preventDefault()
app.callbacks.onOpenMedia?.(app)
},
[app]
)
return { return {
onNewProject, onNewProject,
onSaveProject, onSaveProject,
onSaveProjectAs, onSaveProjectAs,
onOpenProject, onOpenProject,
onOpenMedia,
} }
} }

View file

@ -9,7 +9,7 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
const canHandleEvent = React.useCallback( const canHandleEvent = React.useCallback(
(ignoreMenus = false) => { (ignoreMenus = false) => {
const elm = ref.current const elm = ref.current
if (ignoreMenus && app.isMenuOpen()) return true if (ignoreMenus && app.isMenuOpen) return true
return elm && (document.activeElement === elm || elm.contains(document.activeElement)) return elm && (document.activeElement === elm || elm.contains(document.activeElement))
}, },
[ref] [ref]
@ -155,7 +155,8 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
// File System // File System
const { onNewProject, onOpenProject, onSaveProject, onSaveProjectAs } = useFileSystemHandlers() const { onNewProject, onOpenProject, onSaveProject, onSaveProjectAs, onOpenMedia } =
useFileSystemHandlers()
useHotkeys( useHotkeys(
'ctrl+n,⌘+n', 'ctrl+n,⌘+n',
@ -198,6 +199,15 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
undefined, undefined,
[app] [app]
) )
useHotkeys(
'ctrl+u,⌘+u',
(e) => {
if (!canHandleEvent()) return
onOpenMedia(e)
},
undefined,
[app]
)
// Undo Redo // Undo Redo

View file

@ -110,8 +110,6 @@ export class StateManager<T extends Record<string, any>> {
this._status = 'ready' this._status = 'ready'
resolve(message) resolve(message)
} }
resolve(message)
}).then((message) => { }).then((message) => {
if (this.onReady) this.onReady(message) if (this.onReady) this.onReady(message)
return message return message

View file

@ -6,7 +6,7 @@ import type { SelectTool } from './tools/SelectTool'
describe('TldrawTestApp', () => { describe('TldrawTestApp', () => {
describe('When copying and pasting...', () => { describe('When copying and pasting...', () => {
it('copies a shape', () => { it('copies a shape', () => {
const app = new TldrawTestApp().loadDocument(mockDocument).selectNone().copy(['rect1']) new TldrawTestApp().loadDocument(mockDocument).selectNone().copy(['rect1'])
}) })
it('pastes a shape', () => { it('pastes a shape', () => {
@ -43,6 +43,8 @@ describe('TldrawTestApp', () => {
expect(Object.keys(app.page.shapes).length).toBe(1) expect(Object.keys(app.page.shapes).length).toBe(1)
}) })
it.todo('Copies and pastes a shape with an asset')
it('Copies grouped shapes.', () => { it('Copies grouped shapes.', () => {
const app = new TldrawTestApp() const app = new TldrawTestApp()
.loadDocument(mockDocument) .loadDocument(mockDocument)
@ -581,10 +583,10 @@ describe('TldrawTestApp', () => {
it('Respects child index', () => { it('Respects child index', () => {
const result = new TldrawTestApp() const result = new TldrawTestApp()
.loadDocument(mockDocument) .loadDocument(mockDocument)
.moveToBack(['rect2']) .moveToBack(['rect2'])
.selectAll() .selectAll()
.copySvg() .copySvg()
expect(result).toMatchSnapshot('copied svg with reordered elements') expect(result).toMatchSnapshot('copied svg with reordered elements')
}) })
@ -710,3 +712,13 @@ describe('TldrawTestApp', () => {
.expectSelectedIdsToBe(['box1']) .expectSelectedIdsToBe(['box1'])
}) })
}) })
describe('When adding an image', () => {
it.todo('Adds the image to the assets table')
it.todo('Does not add the image if that image already exists as an asset')
})
describe('When adding a video', () => {
it.todo('Adds the video to the assets table')
it.todo('Does not add the video if that video already exists as an asset')
})

View file

@ -14,6 +14,9 @@ import {
TLWheelEventHandler, TLWheelEventHandler,
Utils, Utils,
TLBounds, TLBounds,
TLDropEventHandler,
TLAssetType,
TLAsset,
} from '@tldraw/core' } from '@tldraw/core'
import { import {
FlipType, FlipType,
@ -41,6 +44,9 @@ import {
loadFileHandle, loadFileHandle,
openFromFileSystem, openFromFileSystem,
saveToFileSystem, saveToFileSystem,
openAssetFromFileSystem,
fileToBase64,
getSizeFromDataurl,
} from './data' } from './data'
import { TLDR } from './TLDR' import { TLDR } from './TLDR'
import { shapeUtils } from '~state/shapes' import { shapeUtils } from '~state/shapes'
@ -48,7 +54,14 @@ import { defaultStyle } from '~state/shapes/shared/shape-styles'
import * as Commands from './commands' import * as Commands from './commands'
import { SessionArgsOfType, getSession, TldrawSession } from './sessions' import { SessionArgsOfType, getSession, TldrawSession } from './sessions'
import type { BaseTool } from './tools/BaseTool' import type { BaseTool } from './tools/BaseTool'
import { USER_COLORS, FIT_TO_SCREEN_PADDING, GRID_SIZE } from '~constants' import {
USER_COLORS,
FIT_TO_SCREEN_PADDING,
GRID_SIZE,
IMAGE_EXTENSIONS,
VIDEO_EXTENSIONS,
SVG_EXPORT_PADDING,
} from '~constants'
import { SelectTool } from './tools/SelectTool' import { SelectTool } from './tools/SelectTool'
import { EraseTool } from './tools/EraseTool' import { EraseTool } from './tools/EraseTool'
import { TextTool } from './tools/TextTool' import { TextTool } from './tools/TextTool'
@ -88,6 +101,10 @@ export interface TDCallbacks {
* (optional) A callback to run when the user opens new project through the menu or through a keyboard shortcut. * (optional) A callback to run when the user opens new project through the menu or through a keyboard shortcut.
*/ */
onOpenProject?: (state: TldrawApp, e?: KeyboardEvent) => void onOpenProject?: (state: TldrawApp, e?: KeyboardEvent) => void
/**
* (optional) A callback to run when the opens a file to upload.
*/
onOpenMedia?: (state: TldrawApp) => void
/** /**
* (optional) A callback to run when the user signs in via the menu. * (optional) A callback to run when the user signs in via the menu.
*/ */
@ -128,6 +145,9 @@ export interface TDCallbacks {
* (optional) A callback to run when the user creates a new project. * (optional) A callback to run when the user creates a new project.
*/ */
onChangePresence?: (state: TldrawApp, user: TDUser) => void onChangePresence?: (state: TldrawApp, user: TDUser) => void
onImageDelete?: (id: string) => void
onImageCreate?: (file: File, id: string) => Promise<string>
} }
export class TldrawApp extends StateManager<TDSnapshot> { export class TldrawApp extends StateManager<TDSnapshot> {
@ -194,6 +214,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
clipboard?: { clipboard?: {
shapes: TDShape[] shapes: TDShape[]
bindings: TDBinding[] bindings: TDBinding[]
assets: TLAsset[]
} }
rotationInfo = { rotationInfo = {
@ -262,6 +283,8 @@ export class TldrawApp extends StateManager<TDSnapshot> {
protected cleanup = (state: TDSnapshot, prev: TDSnapshot): TDSnapshot => { protected cleanup = (state: TDSnapshot, prev: TDSnapshot): TDSnapshot => {
const next = { ...state } const next = { ...state }
const assetIdsInUse = new Set<string>([])
// Remove deleted shapes and bindings (in Commands, these will be set to undefined) // Remove deleted shapes and bindings (in Commands, these will be set to undefined)
if (next.document !== prev.document) { if (next.document !== prev.document) {
Object.entries(next.document.pages).forEach(([pageId, page]) => { Object.entries(next.document.pages).forEach(([pageId, page]) => {
@ -290,6 +313,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
parentId = prevPage?.shapes[id]?.parentId parentId = prevPage?.shapes[id]?.parentId
delete page.shapes[id] delete page.shapes[id]
} else { } else {
if (shape.assetId) assetIdsInUse.add(shape.assetId)
parentId = shape.parentId parentId = shape.parentId
} }
@ -407,6 +431,8 @@ export class TldrawApp extends StateManager<TDSnapshot> {
}) })
} }
// Cleanup assets
const currentPageId = next.appState.currentPageId const currentPageId = next.appState.currentPageId
const currentPageState = next.document.pageStates[currentPageId] const currentPageState = next.document.pageStates[currentPageId]
@ -973,7 +999,32 @@ export class TldrawApp extends StateManager<TDSnapshot> {
return this return this
} }
isMenuOpen = (): boolean => this.appState.isMenuOpen /**
* Toggles the state if something is loading
*/
setIsLoading = (isLoading: boolean): this => {
this.patchState({ appState: { isLoading } }, 'ui:toggled_is_loading')
this.persist()
return this
}
setDisableAssets = (disableAssets: boolean): this => {
this.patchState({ appState: { disableAssets } }, 'ui:toggled_disable_images')
this.persist()
return this
}
get isMenuOpen(): boolean {
return this.appState.isMenuOpen
}
get isLoading(): boolean {
return this.appState.isLoading
}
get disableAssets(): boolean {
return this.appState.disableAssets
}
/** /**
* Toggle grids. * Toggle grids.
@ -1051,6 +1102,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
.clearSelectHistory() .clearSelectHistory()
.loadDocument(migrate(TldrawApp.defaultDocument, TldrawApp.version)) .loadDocument(migrate(TldrawApp.defaultDocument, TldrawApp.version))
.persist() .persist()
return this return this
} }
@ -1258,6 +1310,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
appState: { appState: {
...TldrawApp.defaultState.appState, ...TldrawApp.defaultState.appState,
currentPageId: Object.keys(document.pages)[0], currentPageId: Object.keys(document.pages)[0],
disableAssets: this.disableAssets,
}, },
}, },
'loaded_document' 'loaded_document'
@ -1282,7 +1335,10 @@ export class TldrawApp extends StateManager<TDSnapshot> {
saveProject = async () => { saveProject = async () => {
if (this.readOnly) return if (this.readOnly) return
try { try {
const fileHandle = await saveToFileSystem(this.document, this.fileSystemHandle) const fileHandle = await saveToFileSystem(
migrate(this.document, TldrawApp.version),
this.fileSystemHandle
)
this.fileSystemHandle = fileHandle this.fileSystemHandle = fileHandle
this.persist() this.persist()
this.isDirty = false this.isDirty = false
@ -1334,6 +1390,23 @@ export class TldrawApp extends StateManager<TDSnapshot> {
} }
} }
/**
* Upload media from file
*/
openAsset = async () => {
if (!this.isLocal) return
if (!this.disableAssets)
try {
const file = await openAssetFromFileSystem()
if (!file) return
this.addMediaFromFile(file)
} catch (e) {
console.error(e)
} finally {
this.persist()
}
}
/** /**
* Sign out of the current account. * Sign out of the current account.
* Should move to the www layer. * Should move to the www layer.
@ -1560,28 +1633,29 @@ export class TldrawApp extends StateManager<TDSnapshot> {
const copyingShapeIds = ids.flatMap((id) => const copyingShapeIds = ids.flatMap((id) =>
TLDR.getDocumentBranch(this.state, id, this.currentPageId) TLDR.getDocumentBranch(this.state, id, this.currentPageId)
) )
const copyingShapes = copyingShapeIds.map((id) => const copyingShapes = copyingShapeIds.map((id) =>
Utils.deepClone(this.getShape(id, this.currentPageId)) Utils.deepClone(this.getShape(id, this.currentPageId))
) )
if (copyingShapes.length === 0) return this if (copyingShapes.length === 0) return this
const copyingBindings: TDBinding[] = Object.values(this.page.bindings).filter( const copyingBindings: TDBinding[] = Object.values(this.page.bindings).filter(
(binding) => (binding) =>
copyingShapeIds.includes(binding.fromId) && copyingShapeIds.includes(binding.toId) copyingShapeIds.includes(binding.fromId) && copyingShapeIds.includes(binding.toId)
) )
const copyingAssets = copyingShapes
.map((shape) => {
if (!shape.assetId) return
return this.document.assets[shape.assetId]
})
.filter(Boolean) as TLAsset[]
this.clipboard = { this.clipboard = {
shapes: copyingShapes, shapes: copyingShapes,
bindings: copyingBindings, bindings: copyingBindings,
assets: copyingAssets,
} }
try { try {
const text = JSON.stringify({ const text = JSON.stringify({
type: 'tldr/clipboard', type: 'tldr/clipboard',
shapes: copyingShapes, ...this.clipboard,
bindings: copyingBindings,
}) })
navigator.clipboard.writeText(text).then( navigator.clipboard.writeText(text).then(
@ -1595,10 +1669,8 @@ export class TldrawApp extends StateManager<TDSnapshot> {
} catch (e) { } catch (e) {
// Browser does not support copying to clipboard // Browser does not support copying to clipboard
} }
this.pasteInfo.offset = [0, 0] this.pasteInfo.offset = [0, 0]
this.pasteInfo.center = [0, 0] this.pasteInfo.center = [0, 0]
return this return this
} }
@ -1618,35 +1690,35 @@ export class TldrawApp extends StateManager<TDSnapshot> {
*/ */
paste = (point?: number[]) => { paste = (point?: number[]) => {
if (this.readOnly) return if (this.readOnly) return
const pasteInCurrentPage = (shapes: TDShape[], bindings: TDBinding[]) => { const pasteInCurrentPage = (shapes: TDShape[], bindings: TDBinding[], assets: TLAsset[]) => {
const idsMap: Record<string, string> = {} const idsMap: Record<string, string> = {}
const newAssets = assets.filter((asset) => this.document.assets[asset.id] === undefined)
if (newAssets.length) {
this.patchState({
document: {
assets: Object.fromEntries(newAssets.map((asset) => [asset.id, asset])),
},
})
}
shapes.forEach((shape) => (idsMap[shape.id] = Utils.uniqueId())) shapes.forEach((shape) => (idsMap[shape.id] = Utils.uniqueId()))
bindings.forEach((binding) => (idsMap[binding.id] = Utils.uniqueId())) bindings.forEach((binding) => (idsMap[binding.id] = Utils.uniqueId()))
let startIndex = TLDR.getTopChildIndex(this.state, this.currentPageId) let startIndex = TLDR.getTopChildIndex(this.state, this.currentPageId)
const shapesToPaste = shapes const shapesToPaste = shapes
.sort((a, b) => a.childIndex - b.childIndex) .sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => { .map((shape) => {
const parentShapeId = idsMap[shape.parentId] const parentShapeId = idsMap[shape.parentId]
const copy = { const copy = {
...shape, ...shape,
id: idsMap[shape.id], id: idsMap[shape.id],
parentId: parentShapeId || this.currentPageId, parentId: parentShapeId || this.currentPageId,
} }
if (shape.children) { if (shape.children) {
copy.children = shape.children.map((id) => idsMap[id]) copy.children = shape.children.map((id) => idsMap[id])
} }
if (!parentShapeId) { if (!parentShapeId) {
copy.childIndex = startIndex copy.childIndex = startIndex
startIndex++ startIndex++
} }
if (copy.handles) { if (copy.handles) {
Object.values(copy.handles).forEach((handle) => { Object.values(copy.handles).forEach((handle) => {
if (handle.bindingId) { if (handle.bindingId) {
@ -1654,21 +1726,16 @@ export class TldrawApp extends StateManager<TDSnapshot> {
} }
}) })
} }
return copy return copy
}) })
const bindingsToPaste = bindings.map((binding) => ({ const bindingsToPaste = bindings.map((binding) => ({
...binding, ...binding,
id: idsMap[binding.id], id: idsMap[binding.id],
toId: idsMap[binding.toId], toId: idsMap[binding.toId],
fromId: idsMap[binding.fromId], fromId: idsMap[binding.fromId],
})) }))
const commonBounds = Utils.getCommonBounds(shapesToPaste.map(TLDR.getBounds)) const commonBounds = Utils.getCommonBounds(shapesToPaste.map(TLDR.getBounds))
let center = Vec.toFixed(this.getPagePoint(point || this.centerPoint)) let center = Vec.toFixed(this.getPagePoint(point || this.centerPoint))
if ( if (
Vec.dist(center, this.pasteInfo.center) < 2 || Vec.dist(center, this.pasteInfo.center) < 2 ||
Vec.dist(center, Vec.toFixed(Utils.getBoundsCenter(commonBounds))) < 2 Vec.dist(center, Vec.toFixed(Utils.getBoundsCenter(commonBounds))) < 2
@ -1679,14 +1746,11 @@ export class TldrawApp extends StateManager<TDSnapshot> {
this.pasteInfo.center = center this.pasteInfo.center = center
this.pasteInfo.offset = [0, 0] this.pasteInfo.offset = [0, 0]
} }
const centeredBounds = Utils.centerBounds(commonBounds, center) const centeredBounds = Utils.centerBounds(commonBounds, center)
const delta = Vec.sub( const delta = Vec.sub(
Utils.getBoundsCenter(centeredBounds), Utils.getBoundsCenter(centeredBounds),
Utils.getBoundsCenter(commonBounds) Utils.getBoundsCenter(commonBounds)
) )
this.create( this.create(
shapesToPaste.map((shape) => shapesToPaste.map((shape) =>
TLDR.getShapeUtil(shape.type).create({ TLDR.getShapeUtil(shape.type).create({
@ -1705,19 +1769,19 @@ export class TldrawApp extends StateManager<TDSnapshot> {
navigator.clipboard.readText().then((result) => { navigator.clipboard.readText().then((result) => {
try { try {
const data: { type: string; shapes: TDShape[]; bindings: TDBinding[] } = const data: {
JSON.parse(result) type: string
shapes: TDShape[]
bindings: TDBinding[]
assets: TLAsset[]
} = JSON.parse(result)
if (data.type !== 'tldr/clipboard') { if (data.type !== 'tldr/clipboard') {
throw Error('The pasted string was not from the Tldraw clipboard.') throw Error('The pasted string was not from the Tldraw clipboard.')
} }
pasteInCurrentPage(data.shapes, data.bindings, data.assets)
pasteInCurrentPage(data.shapes, data.bindings)
} catch (e) { } catch (e) {
TLDR.warn(e) TLDR.warn(e)
const shapeId = Utils.uniqueId() const shapeId = Utils.uniqueId()
this.createShapes({ this.createShapes({
id: shapeId, id: shapeId,
type: TDShapeType.Text, type: TDShapeType.Text,
@ -1726,7 +1790,6 @@ export class TldrawApp extends StateManager<TDSnapshot> {
point: this.getPagePoint(this.centerPoint, this.currentPageId), point: this.getPagePoint(this.centerPoint, this.currentPageId),
style: { ...this.appState.currentStyle }, style: { ...this.appState.currentStyle },
}) })
this.select(shapeId) this.select(shapeId)
} }
}) })
@ -1734,7 +1797,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
// Navigator does not support clipboard. Note that this fallback will // Navigator does not support clipboard. Note that this fallback will
// not support pasting from one document to another. // not support pasting from one document to another.
if (this.clipboard) { if (this.clipboard) {
pasteInCurrentPage(this.clipboard.shapes, this.clipboard.bindings) pasteInCurrentPage(this.clipboard.shapes, this.clipboard.bindings, this.clipboard.assets)
} }
} }
@ -1750,87 +1813,84 @@ export class TldrawApp extends StateManager<TDSnapshot> {
copySvg = (ids = this.selectedIds, pageId = this.currentPageId) => { copySvg = (ids = this.selectedIds, pageId = this.currentPageId) => {
if (ids.length === 0) ids = Object.keys(this.page.shapes) if (ids.length === 0) ids = Object.keys(this.page.shapes)
if (ids.length === 0) return if (ids.length === 0) return
const shapes = ids.map((id) => this.getShape(id, pageId))
shapes.sort((a, b) => a.childIndex - b.childIndex)
const commonBounds = Utils.getCommonBounds(shapes.map(TLDR.getRotatedBounds))
const padding = 16
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
// Embed our custom fonts
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs') const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs')
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style') const style = document.createElementNS('http://www.w3.org/2000/svg', 'style')
style.textContent = `@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Source+Code+Pro&family=Source+Sans+Pro&family=Source+Serif+Pro&display=swap');` style.textContent = `@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Source+Code+Pro&family=Source+Sans+Pro&family=Source+Serif+Pro&display=swap');`
defs.appendChild(style) defs.appendChild(style)
svg.appendChild(defs) svg.appendChild(defs)
// Get the shapes in order
function getSvgElementForShape(shape: TDShape) { const shapes = ids
.map((id) => this.getShape(id, pageId))
.sort((a, b) => a.childIndex - b.childIndex)
// Find their common bounding box. S hapes will be positioned relative to this box
const commonBounds = Utils.getCommonBounds(shapes.map(TLDR.getRotatedBounds))
// A quick routine to get an SVG element for each shape
const getSvgElementForShape = (shape: TDShape) => {
const util = TLDR.getShapeUtil(shape) const util = TLDR.getShapeUtil(shape)
const element = util.getSvgElement(shape)
const bounds = util.getBounds(shape) const bounds = util.getBounds(shape)
const elm = util.getSvgElement(shape)
if (!element) return if (!elm) return
// If the element is an image, set the asset src as the xlinkhref
element.setAttribute( if (shape.type === TDShapeType.Image) {
elm.setAttribute('xlink:href', this.document.assets[shape.assetId].src)
}
// Put the element in the correct position relative to the common bounds
elm.setAttribute(
'transform', 'transform',
`translate(${padding + shape.point[0] - commonBounds.minX}, ${ `translate(${SVG_EXPORT_PADDING + shape.point[0] - commonBounds.minX}, ${
padding + shape.point[1] - commonBounds.minY SVG_EXPORT_PADDING + shape.point[1] - commonBounds.minY
}) rotate(${((shape.rotation || 0) * 180) / Math.PI}, ${bounds.width / 2}, ${ }) rotate(${((shape.rotation || 0) * 180) / Math.PI}, ${bounds.width / 2}, ${
bounds.height / 2 bounds.height / 2
})` })`
) )
return elm
return element
} }
// Assemble the final SVG by iterating through each shape and its children
shapes.forEach((shape) => { shapes.forEach((shape) => {
// The shape is a group! Just add the children.
if (shape.children?.length) { if (shape.children?.length) {
// Create a group <g> element for shape // Create a group <g> elm for shape
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g') const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
// Get the shape's children as elms and add them to the group
// Get the shape's children as elements shape.children.forEach((childId) => {
shape.children const shape = this.getShape(childId, pageId)
.map((childId) => this.getShape(childId, pageId)) const elm = getSvgElementForShape(shape)
.map(getSvgElementForShape) if (elm) g.appendChild(elm)
.filter(Boolean) })
.forEach((element) => g.appendChild(element!)) // Add the group elm to the SVG
// Add the group element to the SVG
svg.appendChild(g) svg.appendChild(g)
return return
} }
// Just add the shape's element to the
const element = getSvgElementForShape(shape) const elm = getSvgElementForShape(shape)
if (elm) svg.appendChild(elm)
if (element) {
svg.appendChild(element)
}
}) })
// Resize the elm to the bounding box
// Resize the element to the bounding box
svg.setAttribute( svg.setAttribute(
'viewBox', 'viewBox',
[0, 0, commonBounds.width + padding * 2, commonBounds.height + padding * 2].join(' ') [
0,
0,
commonBounds.width + SVG_EXPORT_PADDING * 2,
commonBounds.height + SVG_EXPORT_PADDING * 2,
].join(' ')
) )
svg.setAttribute('width', String(commonBounds.width)) svg.setAttribute('width', String(commonBounds.width))
svg.setAttribute('height', String(commonBounds.height)) svg.setAttribute('height', String(commonBounds.height))
svg.setAttribute('fill', 'transparent') svg.setAttribute('fill', 'transparent')
// Clean up the SVG by removing any hidden elements
svg svg
.querySelectorAll('.tl-fill-hitarea, .tl-stroke-hitarea, .tl-binding-indicator') .querySelectorAll('.tl-fill-hitarea, .tl-stroke-hitarea, .tl-binding-indicator')
.forEach((element) => element.remove()) .forEach((elm) => elm.remove())
// Serialize the SVG to a string
const s = new XMLSerializer() const svgString = new XMLSerializer()
const svgString = s
.serializeToString(svg) .serializeToString(svg)
.replaceAll('&#10; ', '') .replaceAll('&#10; ', '')
.replaceAll(/((\s|")[0-9]*\.[0-9]{2})([0-9]*)(\b|"|\))/g, '$1') .replaceAll(/((\s|")[0-9]*\.[0-9]{2})([0-9]*)(\b|"|\))/g, '$1')
// Copy the string to the clipboard
TLDR.copyStringToClipboard(svgString) TLDR.copyStringToClipboard(svgString)
return svgString return svgString
} }
@ -1843,7 +1903,6 @@ export class TldrawApp extends StateManager<TDSnapshot> {
copyJson = (ids = this.selectedIds, pageId = this.currentPageId) => { copyJson = (ids = this.selectedIds, pageId = this.currentPageId) => {
if (ids.length === 0) ids = Object.keys(this.page.shapes) if (ids.length === 0) ids = Object.keys(this.page.shapes)
if (ids.length === 0) return if (ids.length === 0) return
const shapes = ids.map((id) => this.getShape(id, pageId)) const shapes = ids.map((id) => this.getShape(id, pageId))
const json = JSON.stringify(shapes, null, 2) const json = JSON.stringify(shapes, null, 2)
TLDR.copyStringToClipboard(json) TLDR.copyStringToClipboard(json)
@ -1872,7 +1931,6 @@ export class TldrawApp extends StateManager<TDSnapshot> {
}, },
reason reason
) )
return this return this
} }
@ -2405,6 +2463,44 @@ export class TldrawApp extends StateManager<TDSnapshot> {
return this return this
} }
createImageOrVideoShapeAtPoint(
id: string,
type: TDShapeType.Image | TDShapeType.Video,
point: number[],
size: number[],
assetId: string
): this {
const {
shapes,
appState: { currentPageId, currentStyle },
} = this
const childIndex =
shapes.length === 0
? 1
: shapes
.filter((shape) => shape.parentId === currentPageId)
.sort((a, b) => b.childIndex - a.childIndex)[0].childIndex + 1
const Shape = shapeUtils[type]
const newShape = Shape.create({
id,
parentId: currentPageId,
childIndex,
point,
size,
style: { ...currentStyle },
assetId,
})
const bounds = Shape.getBounds(newShape as never)
newShape.point = Vec.sub(newShape.point, [bounds.width / 2, bounds.height / 2])
this.createShapes(newShape)
return this
}
/** /**
* Create one or more shapes. * Create one or more shapes.
* @param shapes An array of shapes. * @param shapes An array of shapes.
@ -2431,6 +2527,14 @@ export class TldrawApp extends StateManager<TDSnapshot> {
* @command * @command
*/ */
delete = (ids = this.selectedIds): this => { delete = (ids = this.selectedIds): this => {
if (this.callbacks.onImageDelete) {
ids.forEach((id) => {
const node = this.getShape(id)
if (node.type === TDShapeType.Image || node.type === TDShapeType.Video)
this.callbacks.onImageDelete!(id)
})
}
if (ids.length === 0) return this if (ids.length === 0) return this
return this.setState(Commands.deleteShapes(this, ids)) return this.setState(Commands.deleteShapes(this, ids))
} }
@ -2701,6 +2805,52 @@ export class TldrawApp extends StateManager<TDSnapshot> {
return this return this
} }
private addMediaFromFile = async (file: File, point = this.centerPoint) => {
this.setIsLoading(true)
const id = Utils.uniqueId()
try {
let dataurl: string | ArrayBuffer | null
if (this.callbacks.onImageCreate) dataurl = await this.callbacks.onImageCreate(file, id)
else dataurl = await fileToBase64(file)
if (typeof dataurl === 'string') {
const extension = file.name.match(/\.[0-9a-z]+$/i)
if (!extension) throw Error('No extension')
const isImage = IMAGE_EXTENSIONS.includes(extension[0].toLowerCase())
const isVideo = VIDEO_EXTENSIONS.includes(extension[0].toLowerCase())
if (!(isImage || isVideo)) throw Error('Wrong extension')
let assetId = Utils.uniqueId()
const pagePoint = this.getPagePoint(point)
const shapeType = isImage ? TDShapeType.Image : TDShapeType.Video
const assetType = isImage ? TLAssetType.Image : TLAssetType.Video
const size = isImage ? await getSizeFromDataurl(dataurl) : [401.42, 401.42] // special
const match = Object.values(this.document.assets).find(
(asset) => asset.type === assetType && asset.src === dataurl
)
if (!match) {
this.patchState({
document: {
assets: {
[assetId]: {
id: assetId,
type: assetType,
src: dataurl,
size,
},
},
},
})
} else assetId = match.id
this.createImageOrVideoShapeAtPoint(id, shapeType, pagePoint, size, assetId)
}
} catch (error) {
console.error(error)
this.setIsLoading(false)
return this
}
this.setIsLoading(false)
return this
}
/* -------------------------------------------------- */ /* -------------------------------------------------- */
/* Event Handlers */ /* Event Handlers */
/* -------------------------------------------------- */ /* -------------------------------------------------- */
@ -2821,6 +2971,20 @@ export class TldrawApp extends StateManager<TDSnapshot> {
/* ------------- Renderer Event Handlers ------------ */ /* ------------- Renderer Event Handlers ------------ */
onDragOver: TLDropEventHandler = (e) => {
e.preventDefault()
}
onDrop: TLDropEventHandler = async (e) => {
e.preventDefault()
if (this.disableAssets) return this
if (e.dataTransfer.files?.length) {
const file = e.dataTransfer.files[0]
this.addMediaFromFile(file, [e.clientX, e.clientY])
}
return this
}
onPinchStart: TLPinchEventHandler = (info, e) => this.currentTool.onPinchStart?.(info, e) onPinchStart: TLPinchEventHandler = (info, e) => this.currentTool.onPinchStart?.(info, e)
onPinchEnd: TLPinchEventHandler = (info, e) => this.currentTool.onPinchEnd?.(info, e) onPinchEnd: TLPinchEventHandler = (info, e) => this.currentTool.onPinchEnd?.(info, e)
@ -3014,6 +3178,21 @@ export class TldrawApp extends StateManager<TDSnapshot> {
this.originPoint = this.getPagePoint(info.point) this.originPoint = this.getPagePoint(info.point)
this.updateInputs(info, e) this.updateInputs(info, e)
this.currentTool.onDoubleClickBoundsHandle?.(info, e) this.currentTool.onDoubleClickBoundsHandle?.(info, e)
// hack time to reset the size / clipping of an image
if (this.selectedIds.length !== 1) return
const shape = this.getShape(this.selectedIds[0])
if (shape.type === TDShapeType.Image || shape.type === TDShapeType.Video) {
const asset = this.document.assets[shape.assetId]
const util = TLDR.getShapeUtil(shape)
const centerA = util.getCenter(shape)
const centerB = util.getCenter({ ...shape, size: asset.size })
const delta = Vec.sub(centerB, centerA)
this.updateShapes({
id: shape.id,
point: Vec.sub(shape.point, delta),
size: asset.size,
})
}
} }
onRightPointBoundsHandle: TLBoundsHandleEventHandler = (info, e) => { onRightPointBoundsHandle: TLBoundsHandleEventHandler = (info, e) => {
@ -3181,12 +3360,12 @@ export class TldrawApp extends StateManager<TDSnapshot> {
getShapeUtil = TLDR.getShapeUtil getShapeUtil = TLDR.getShapeUtil
static version = 14 static version = 15
static defaultDocument: TDDocument = { static defaultDocument: TDDocument = {
id: 'doc', id: 'doc',
name: 'New Document', name: 'New Document',
version: 14, version: 15,
pages: { pages: {
page: { page: {
id: 'page', id: 'page',
@ -3206,6 +3385,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
}, },
}, },
}, },
assets: {},
} }
static defaultState: TDSnapshot = { static defaultState: TDSnapshot = {
@ -3215,6 +3395,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
isZoomSnap: false, isZoomSnap: false,
isFocusMode: false, isFocusMode: false,
isSnapping: false, isSnapping: false,
//@ts-ignore
isDebugMode: process.env.NODE_ENV === 'development', isDebugMode: process.env.NODE_ENV === 'development',
isReadonlyMode: false, isReadonlyMode: false,
nudgeDistanceLarge: 16, nudgeDistanceLarge: 16,
@ -3234,6 +3415,8 @@ export class TldrawApp extends StateManager<TDSnapshot> {
isMenuOpen: false, isMenuOpen: false,
isEmptyCanvas: false, isEmptyCanvas: false,
snapLines: [], snapLines: [],
isLoading: false,
disableAssets: false,
}, },
document: TldrawApp.defaultDocument, document: TldrawApp.defaultDocument,
} }

View file

@ -1,6 +1,7 @@
import type { TDDocument, TDFile } from '~types' import type { TDDocument, TDFile } from '~types'
import { fileSave, fileOpen, FileSystemHandle } from './browser-fs-access' import { fileSave, fileOpen, FileSystemHandle } from './browser-fs-access'
import { get as getFromIdb, set as setToIdb } from 'idb-keyval' import { get as getFromIdb, set as setToIdb } from 'idb-keyval'
import { IMAGE_EXTENSIONS, VIDEO_EXTENSIONS } from '~constants'
const options = { mode: 'readwrite' as const } const options = { mode: 'readwrite' as const }
@ -96,3 +97,31 @@ export async function openFromFileSystem(): Promise<null | {
document: file.document, document: file.document,
} }
} }
export async function openAssetFromFileSystem() {
return fileOpen({
description: 'Image or Video',
extensions: [...IMAGE_EXTENSIONS, ...VIDEO_EXTENSIONS],
multiple: false,
})
}
export function fileToBase64(file: Blob): Promise<string | ArrayBuffer | null> {
return new Promise((resolve, reject) => {
if (file) {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => resolve(reader.result)
reader.onerror = (error) => reject(error)
reader.onabort = (error) => reject(error)
}
})
}
export function getSizeFromDataurl(dataURL: string): Promise<number[]> {
return new Promise((resolve) => {
const img = new Image()
img.onload = () => resolve([img.width, img.height])
img.src = dataURL
})
}

View file

@ -4,6 +4,23 @@ import { Decoration, FontStyle, TDDocument, TDShapeType, TextShape } from '~type
export function migrate(document: TDDocument, newVersion: number): TDDocument { export function migrate(document: TDDocument, newVersion: number): TDDocument {
const { version = 0 } = document const { version = 0 } = document
// Remove unused assets when loading a document
if ('assets' in document) {
const assetIdsInUse = new Set<string>()
Object.values(document.pages).forEach((page) =>
Object.values(page.shapes).forEach((shape) => {
if (shape.assetId) assetIdsInUse.add(shape.assetId)
})
)
Object.keys(document.assets).forEach((assetId) => {
if (!assetIdsInUse.has(assetId)) {
delete document.assets[assetId]
}
})
}
if (version === newVersion) return document if (version === newVersion) return document
if (version < 14) { if (version < 14) {
@ -51,6 +68,10 @@ export function migrate(document: TDDocument, newVersion: number): TDDocument {
document.name = 'New Document' document.name = 'New Document'
} }
if (version < 15) {
document.assets = {}
}
// Cleanup // Cleanup
Object.values(document.pageStates).forEach((pageState) => { Object.values(document.pageStates).forEach((pageState) => {
pageState.selectedIds = pageState.selectedIds.filter((id) => { pageState.selectedIds = pageState.selectedIds.filter((id) => {

View file

@ -12,6 +12,7 @@ import {
SessionType, SessionType,
ArrowBinding, ArrowBinding,
TldrawPatch, TldrawPatch,
TDShapeType,
} from '~types' } from '~types'
import { SLOW_SPEED, SNAP_DISTANCE } from '~constants' import { SLOW_SPEED, SNAP_DISTANCE } from '~constants'
import { TLDR } from '~state/TLDR' import { TLDR } from '~state/TLDR'
@ -632,6 +633,11 @@ export class TranslateSession extends BaseSession {
childIndex: TLDR.getChildIndexAbove(this.app.state, shape.id, currentPageId), childIndex: TLDR.getChildIndexAbove(this.app.state, shape.id, currentPageId),
} }
if (clone.type === TDShapeType.Video) {
const element = document.getElementById(shape.id + '_video') as HTMLVideoElement
if (element) clone.currentTime = (element.currentTime + 16) % element.duration
}
clones.push(clone) clones.push(clone)
}) })

View file

@ -0,0 +1,7 @@
import { Image } from '..'
describe('Image shape', () => {
it('Creates a shape', () => {
expect(Image.create({ id: 'image' })).toMatchSnapshot('image')
})
})

View file

@ -0,0 +1,175 @@
import * as React from 'react'
import { Utils, HTMLContainer } from '@tldraw/core'
import { TDShapeType, TDMeta, ImageShape } from '~types'
import { GHOSTED_OPACITY } from '~constants'
import { TDShapeUtil } from '../TDShapeUtil'
import {
defaultStyle,
getBoundsRectangle,
transformRectangle,
transformSingleRectangle,
} from '~state/shapes/shared'
import { styled } from '@stitches/react'
type T = ImageShape
type E = HTMLDivElement
export class ImageUtil extends TDShapeUtil<T, E> {
type = TDShapeType.Image as const
canBind = true
canClone = true
isAspectRatioLocked = true
showCloneHandles = true
getShape = (props: Partial<T>): T => {
return Utils.deepMerge<T>(
{
id: 'image',
type: TDShapeType.Image,
name: 'Image',
parentId: 'page',
childIndex: 1,
point: [0, 0],
size: [1, 1],
rotation: 0,
style: defaultStyle,
assetId: 'assetId',
},
props
)
}
Component = TDShapeUtil.Component<T, E, TDMeta>(
({ shape, asset = { src: '' }, isBinding, isGhost, meta, events, onShapeChange }, ref) => {
const { size } = shape
React.useEffect(() => {
if (wrapperRef?.current) {
const [width, height] = size
wrapperRef.current.style.width = `${width}px`
wrapperRef.current.style.height = `${height}px`
}
}, [size])
const imgRef = React.useRef<HTMLImageElement>(null)
const wrapperRef = React.useRef<HTMLDivElement>(null)
const onImageLoad = React.useCallback(() => {
if (imgRef?.current && wrapperRef?.current) {
const { width, height } = imgRef?.current
wrapperRef.current.style.width = `${width}px`
wrapperRef.current.style.height = `${height}px`
onShapeChange?.({ id: shape.id, size: [width, height] })
}
}, [])
return (
<HTMLContainer ref={ref} {...events}>
{isBinding && (
<div
className="tl-binding-indicator"
style={{
position: 'absolute',
top: `calc(${-this.bindingDistance}px * var(--tl-zoom))`,
left: `calc(${-this.bindingDistance}px * var(--tl-zoom))`,
width: `calc(100% + ${this.bindingDistance * 2}px * var(--tl-zoom))`,
height: `calc(100% + ${this.bindingDistance * 2}px * var(--tl-zoom))`,
backgroundColor: 'var(--tl-selectFill)',
}}
/>
)}
<Wrapper
ref={wrapperRef}
isDarkMode={meta.isDarkMode} //
isGhost={isGhost}
>
<ImageElement
ref={imgRef}
src={asset.src}
alt="tl_image_asset"
draggable={false}
onLoad={onImageLoad}
/>
</Wrapper>
</HTMLContainer>
)
}
)
Indicator = TDShapeUtil.Indicator<T>(({ shape }) => {
const {
size: [width, height],
} = shape
return (
<rect x={0} y={0} rx={2} ry={2} width={Math.max(1, width)} height={Math.max(1, height)} />
)
})
getBounds = (shape: T) => {
return getBoundsRectangle(shape, this.boundsCache)
}
shouldRender = (prev: T, next: T) => {
return next.size !== prev.size || next.style !== prev.style
}
transform = transformRectangle
transformSingle = transformSingleRectangle
getSvgElement = (shape: ImageShape) => {
const bounds = this.getBounds(shape)
const elm = document.createElementNS('http://www.w3.org/2000/svg', 'image')
elm.setAttribute('width', `${bounds.width}`)
elm.setAttribute('height', `${bounds.height}`)
return elm
}
}
const Wrapper = styled('div', {
pointerEvents: 'all',
position: 'relative',
fontFamily: 'sans-serif',
fontSize: '2em',
height: '100%',
width: '100%',
borderRadius: '3px',
perspective: '800px',
p: {
userSelect: 'none',
},
img: {
userSelect: 'none',
},
variants: {
isGhost: {
false: { opacity: 1 },
true: { transition: 'opacity .2s', opacity: GHOSTED_OPACITY },
},
isDarkMode: {
true: {
boxShadow:
'2px 3px 12px -2px rgba(0,0,0,.3), 1px 1px 4px rgba(0,0,0,.3), 1px 1px 2px rgba(0,0,0,.3)',
},
false: {
boxShadow:
'2px 3px 12px -2px rgba(0,0,0,.2), 1px 1px 4px rgba(0,0,0,.16), 1px 1px 2px rgba(0,0,0,.16)',
},
},
},
})
const ImageElement = styled('img', {
width: '100%',
height: '100%',
maxWidth: '100%',
minWidth: '100%',
pointerEvents: 'none',
objectFit: 'cover',
borderRadius: 2,
})

View file

@ -0,0 +1,28 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Image shape Creates a shape: image 1`] = `
Object {
"assetId": "assetId",
"childIndex": 1,
"id": "image",
"name": "Image",
"parentId": "page",
"point": Array [
0,
0,
],
"rotation": 0,
"size": Array [
1,
1,
],
"style": Object {
"color": "black",
"dash": "draw",
"isFilled": false,
"scale": 1,
"size": "small",
},
"type": "image",
}
`;

View file

@ -0,0 +1 @@
export * from './ImageUtil'

View file

@ -419,10 +419,8 @@ const commonTextWrapping = {
const InnerWrapper = styled('div', { const InnerWrapper = styled('div', {
position: 'absolute', position: 'absolute',
top: 'var(--tl-padding)', width: '100%',
left: 'var(--tl-padding)', height: '100%',
width: 'calc(100% - (var(--tl-padding) * 2))',
height: 'calc(100% - (var(--tl-padding) * 2))',
padding: '4px', padding: '4px',
zIndex: 1, zIndex: 1,
minHeight: 1, minHeight: 1,

View file

@ -0,0 +1,7 @@
import { Video } from '..'
describe('Video shape', () => {
it('Creates a shape', () => {
expect(Video.create({ id: 'video' })).toMatchSnapshot('video')
})
})

View file

@ -0,0 +1,209 @@
import * as React from 'react'
import { Utils, HTMLContainer } from '@tldraw/core'
import { TDShapeType, TDMeta, VideoShape } from '~types'
import { GHOSTED_OPACITY } from '~constants'
import { TDShapeUtil } from '../TDShapeUtil'
import {
defaultStyle,
getBoundsRectangle,
transformRectangle,
transformSingleRectangle,
} from '~state/shapes/shared'
import { styled } from '@stitches/react'
import Vec from '@tldraw/vec'
type T = VideoShape
type E = HTMLDivElement
export class VideoUtil extends TDShapeUtil<T, E> {
type = TDShapeType.Video as const
canBind = true
canEdit = true
canClone = true
isAspectRatioLocked = true
showCloneHandles = true
isStateful = true // don't unmount
getShape = (props: Partial<T>): T => {
return Utils.deepMerge<T>(
{
id: 'video',
type: TDShapeType.Video,
name: 'Video',
parentId: 'page',
childIndex: 1,
point: [0, 0],
size: [1, 1],
rotation: 0,
style: defaultStyle,
assetId: 'assetId',
isPlaying: true,
currentTime: 0,
},
props
)
}
Component = TDShapeUtil.Component<T, E, TDMeta>(
({ shape, asset, isBinding, isEditing, isGhost, meta, events, onShapeChange }, ref) => {
const rVideo = React.useRef<HTMLVideoElement>(null)
const wrapperRef = React.useRef<HTMLDivElement>(null)
const { currentTime = 0, size, isPlaying } = shape
React.useEffect(() => {
if (wrapperRef.current) {
const [width, height] = size
wrapperRef.current.style.width = `${width}px`
wrapperRef.current.style.height = `${height}px`
}
}, [size])
const onImageLoad = React.useCallback(() => {
if (rVideo.current && wrapperRef.current) {
if (!Vec.isEqual(size, [401.42, 401.42])) return
const { videoWidth, videoHeight } = rVideo.current
wrapperRef.current.style.width = `${videoWidth}px`
wrapperRef.current.style.height = `${videoHeight}px`
const newSize = [videoWidth, videoHeight]
const delta = Vec.sub(size, newSize)
onShapeChange?.({
id: shape.id,
point: Vec.add(shape.point, Vec.div(delta, 2)),
size: [videoWidth, videoHeight],
})
}
}, [size])
React.useLayoutEffect(() => {
const video = rVideo.current
if (!video) return
if (isPlaying) video.play()
// throws error on safari
else video.pause()
}, [isPlaying])
React.useLayoutEffect(() => {
const video = rVideo.current
if (!video) return
if (currentTime !== video.currentTime) {
video.currentTime = currentTime
}
}, [currentTime])
const handlePlay = React.useCallback(() => {
onShapeChange?.({ id: shape.id, isPlaying: true })
}, [])
const handlePause = React.useCallback(() => {
onShapeChange?.({ id: shape.id, isPlaying: false })
}, [])
const handleSetCurrentTime = React.useCallback(() => {
const video = rVideo.current
if (!video) return
if (!isEditing) return
onShapeChange?.({ id: shape.id, currentTime: video.currentTime })
}, [isEditing])
return (
<HTMLContainer ref={ref} {...events}>
{isBinding && (
<div
className="tl-binding-indicator"
style={{
position: 'absolute',
top: -this.bindingDistance,
left: -this.bindingDistance,
width: `calc(100% + ${this.bindingDistance * 2}px)`,
height: `calc(100% + ${this.bindingDistance * 2}px)`,
backgroundColor: 'var(--tl-selectFill)',
}}
/>
)}
<Wrapper ref={wrapperRef} isDarkMode={meta.isDarkMode} isGhost={isGhost}>
<VideoElement
ref={rVideo}
id={shape.id + '_video'}
muted
loop
playsInline
disableRemotePlayback
disablePictureInPicture
controls={isEditing}
autoPlay={isPlaying}
onPlay={handlePlay}
onPause={handlePause}
onTimeUpdate={handleSetCurrentTime}
onLoadedMetadata={onImageLoad}
>
<source src={asset?.src} />
</VideoElement>
</Wrapper>
</HTMLContainer>
)
}
)
Indicator = TDShapeUtil.Indicator<T>(({ shape }) => {
const {
size: [width, height],
} = shape
return (
<rect x={0} y={0} rx={2} ry={2} width={Math.max(1, width)} height={Math.max(1, height)} />
)
})
getBounds = (shape: T) => {
return getBoundsRectangle(shape, this.boundsCache)
}
shouldRender = (prev: T, next: T) => {
return next.size !== prev.size || next.style !== prev.style || next.isPlaying !== prev.isPlaying
}
transform = transformRectangle
transformSingle = transformSingleRectangle
}
const Wrapper = styled('div', {
pointerEvents: 'all',
position: 'relative',
fontFamily: 'sans-serif',
fontSize: '2em',
height: '100%',
width: '100%',
borderRadius: '3px',
perspective: '800px',
p: {
userSelect: 'none',
},
img: {
userSelect: 'none',
},
variants: {
isGhost: {
false: { opacity: 1 },
true: { transition: 'opacity .2s', opacity: GHOSTED_OPACITY },
},
isDarkMode: {
true: {
boxShadow:
'2px 3px 12px -2px rgba(0,0,0,.3), 1px 1px 4px rgba(0,0,0,.3), 1px 1px 2px rgba(0,0,0,.3)',
},
false: {
boxShadow:
'2px 3px 12px -2px rgba(0,0,0,.2), 1px 1px 4px rgba(0,0,0,.16), 1px 1px 2px rgba(0,0,0,.16)',
},
},
},
})
const VideoElement = styled('video', {
maxWidth: '100%',
minWidth: '100%',
pointerEvents: 'all',
borderRadius: 2,
})

View file

@ -0,0 +1,30 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Video shape Creates a shape: video 1`] = `
Object {
"assetId": "assetId",
"childIndex": 1,
"currentTime": 0,
"id": "video",
"isPlaying": true,
"name": "Video",
"parentId": "page",
"point": Array [
0,
0,
],
"rotation": 0,
"size": Array [
1,
1,
],
"style": Object {
"color": "black",
"dash": "draw",
"isFilled": false,
"scale": 1,
"size": "small",
},
"type": "video",
}
`;

View file

@ -0,0 +1 @@
export * from './VideoUtil'

View file

@ -7,7 +7,9 @@ import { GroupUtil } from './GroupUtil'
import { StickyUtil } from './StickyUtil' import { StickyUtil } from './StickyUtil'
import { TextUtil } from './TextUtil' import { TextUtil } from './TextUtil'
import { DrawUtil } from './DrawUtil' import { DrawUtil } from './DrawUtil'
import { ImageUtil } from './ImageUtil'
import { TDShape, TDShapeType } from '~types' import { TDShape, TDShapeType } from '~types'
import { VideoUtil } from './VideoUtil'
export const Rectangle = new RectangleUtil() export const Rectangle = new RectangleUtil()
export const Triangle = new TriangleUtil() export const Triangle = new TriangleUtil()
@ -17,6 +19,8 @@ export const Arrow = new ArrowUtil()
export const Text = new TextUtil() export const Text = new TextUtil()
export const Group = new GroupUtil() export const Group = new GroupUtil()
export const Sticky = new StickyUtil() export const Sticky = new StickyUtil()
export const Image = new ImageUtil()
export const Video = new VideoUtil()
export const shapeUtils = { export const shapeUtils = {
[TDShapeType.Rectangle]: Rectangle, [TDShapeType.Rectangle]: Rectangle,
@ -27,6 +31,8 @@ export const shapeUtils = {
[TDShapeType.Text]: Text, [TDShapeType.Text]: Text,
[TDShapeType.Group]: Group, [TDShapeType.Group]: Group,
[TDShapeType.Sticky]: Sticky, [TDShapeType.Sticky]: Sticky,
[TDShapeType.Image]: Image,
[TDShapeType.Video]: Video,
} }
export const getShapeUtil = <T extends TDShape>(shape: T | T['type']) => { export const getShapeUtil = <T extends TDShape>(shape: T | T['type']) => {

View file

@ -173,7 +173,11 @@ export class SelectTool extends BaseTool<Status> {
/* ----------------- Event Handlers ----------------- */ /* ----------------- Event Handlers ----------------- */
onCancel = () => { onCancel = () => {
this.selectNone() if (this.app.pageState.editingId) {
this.app.setEditingId()
} else {
this.selectNone()
}
this.app.cancelSession() this.app.cancelSession()
this.setStatus(Status.Idle) this.setStatus(Status.Idle)
} }
@ -372,7 +376,7 @@ export class SelectTool extends BaseTool<Status> {
} }
} }
onPointerUp: TLPointerEventHandler = (info, e) => { onPointerUp: TLPointerEventHandler = (info) => {
if (this.status === Status.MiddleWheelPanning) { if (this.status === Status.MiddleWheelPanning) {
this.setStatus(Status.Idle) this.setStatus(Status.Idle)
return return

View file

@ -64,4 +64,5 @@ export const mockDocument: TDDocument = {
}, },
}, },
}, },
assets: {},
} }

View file

@ -19,6 +19,7 @@ import type {
TLBoundsHandleEventHandler, TLBoundsHandleEventHandler,
TLShapeBlurHandler, TLShapeBlurHandler,
TLShapeCloneHandler, TLShapeCloneHandler,
TLAssets,
} from '@tldraw/core' } from '@tldraw/core'
/* -------------------------------------------------- */ /* -------------------------------------------------- */
@ -102,6 +103,8 @@ export interface TDSnapshot {
isMenuOpen: boolean isMenuOpen: boolean
status: string status: string
snapLines: TLSnapLine[] snapLines: TLSnapLine[]
isLoading: boolean
disableAssets: boolean
} }
document: TDDocument document: TDDocument
room?: { room?: {
@ -130,6 +133,7 @@ export interface TDDocument {
version: number version: number
pages: Record<string, TDPage> pages: Record<string, TDPage>
pageStates: Record<string, TLPageState> pageStates: Record<string, TLPageState>
assets: TLAssets
} }
// The shape of a single page in the Tldraw document // The shape of a single page in the Tldraw document
@ -277,6 +281,8 @@ export enum TDShapeType {
Line = 'line', Line = 'line',
Text = 'text', Text = 'text',
Group = 'group', Group = 'group',
Image = 'image',
Video = 'video',
} }
export enum Decoration { export enum Decoration {
@ -338,6 +344,20 @@ export interface RectangleShape extends TDBaseShape {
size: number[] size: number[]
} }
export interface ImageShape extends TDBaseShape {
type: TDShapeType.Image
size: number[]
assetId: string
}
export interface VideoShape extends TDBaseShape {
type: TDShapeType.Video
size: number[]
assetId: string
isPlaying: boolean
currentTime: number
}
// The shape created by the Triangle tool // The shape created by the Triangle tool
export interface TriangleShape extends TDBaseShape { export interface TriangleShape extends TDBaseShape {
type: TDShapeType.Triangle type: TDShapeType.Triangle
@ -374,6 +394,8 @@ export type TDShape =
| TextShape | TextShape
| GroupShape | GroupShape
| StickyShape | StickyShape
| ImageShape
| VideoShape
/* ------------------ Shape Styles ------------------ */ /* ------------------ Shape Styles ------------------ */

615
yarn.lock
View file

@ -1077,6 +1077,385 @@
unique-filename "^1.1.1" unique-filename "^1.1.1"
which "^1.3.1" which "^1.3.1"
"@firebase/analytics-compat@0.1.5":
version "0.1.5"
resolved "https://registry.yarnpkg.com/@firebase/analytics-compat/-/analytics-compat-0.1.5.tgz#9fd587b1b6fa283354428a0f96a19db2389e7da4"
integrity sha512-5cfr0uWwlhoHQYAr6UtQCHwnGjs/3J/bWrfA3INNtzaN4/tTTLTD02iobbccRcM7dM5TR0sZFWS5orfAU3OBFg==
dependencies:
"@firebase/analytics" "0.7.4"
"@firebase/analytics-types" "0.7.0"
"@firebase/component" "0.5.9"
"@firebase/util" "1.4.2"
tslib "^2.1.0"
"@firebase/analytics-types@0.7.0":
version "0.7.0"
resolved "https://registry.yarnpkg.com/@firebase/analytics-types/-/analytics-types-0.7.0.tgz#91960e7c87ce8bf18cf8dd9e55ccbf5dc3989b5d"
integrity sha512-DNE2Waiwy5+zZnCfintkDtBfaW6MjIG883474v6Z0K1XZIvl76cLND4iv0YUb48leyF+PJK1KO2XrgHb/KpmhQ==
"@firebase/analytics@0.7.4":
version "0.7.4"
resolved "https://registry.yarnpkg.com/@firebase/analytics/-/analytics-0.7.4.tgz#33b3d6a34736e1a726652e48b6bd39163e6561c2"
integrity sha512-AU3XMwHW7SFGCNeUKKNW2wXGTdmS164ackt/Epu2bDXCT1OcauPE1AVd+ofULSIDCaDUAQVmvw3JrobgogEU7Q==
dependencies:
"@firebase/component" "0.5.9"
"@firebase/installations" "0.5.4"
"@firebase/logger" "0.3.2"
"@firebase/util" "1.4.2"
tslib "^2.1.0"
"@firebase/app-check-compat@0.2.2":
version "0.2.2"
resolved "https://registry.yarnpkg.com/@firebase/app-check-compat/-/app-check-compat-0.2.2.tgz#7d6c04464a78cbc6a717cb4f33871e2f980cdb02"
integrity sha512-nX2Ou8Rwo+TMMNDecQOGH78kFw6sORLrsGyu0eC95M853JjisVxTngN1TU/RL5h83ElJ0HhNlz6C3FYAuGNqqA==
dependencies:
"@firebase/app-check" "0.5.2"
"@firebase/component" "0.5.9"
"@firebase/logger" "0.3.2"
"@firebase/util" "1.4.2"
tslib "^2.1.0"
"@firebase/app-check-interop-types@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@firebase/app-check-interop-types/-/app-check-interop-types-0.1.0.tgz#83afd9d41f99166c2bdb2d824e5032e9edd8fe53"
integrity sha512-uZfn9s4uuRsaX5Lwx+gFP3B6YsyOKUE+Rqa6z9ojT4VSRAsZFko9FRn6OxQUA1z5t5d08fY4pf+/+Dkd5wbdbA==
"@firebase/app-check@0.5.2":
version "0.5.2"
resolved "https://registry.yarnpkg.com/@firebase/app-check/-/app-check-0.5.2.tgz#5166aeed767efb8e5f0c719b83439e58abbee0fd"
integrity sha512-DJrvxcn5QPO5dU735GA9kYpf+GwmCmnd/oQdWVExrRG+yjaLnP0rSJ2HKQ4bZKGo8qig3P7fwQpdMOgP2BXFjQ==
dependencies:
"@firebase/component" "0.5.9"
"@firebase/logger" "0.3.2"
"@firebase/util" "1.4.2"
tslib "^2.1.0"
"@firebase/app-compat@0.1.12":
version "0.1.12"
resolved "https://registry.yarnpkg.com/@firebase/app-compat/-/app-compat-0.1.12.tgz#8a5fc169ad52c1fe9fe5119d543f12f9335cc8b2"
integrity sha512-hRzCCFjwTwrFsAFcuUW2TPpyShJ/OaoA1Yxp4QJr6Xod8g+CQxTMZ4RJ51I5t9fErXvl65VxljhfqFEyB3ZmJA==
dependencies:
"@firebase/app" "0.7.11"
"@firebase/component" "0.5.9"
"@firebase/logger" "0.3.2"
"@firebase/util" "1.4.2"
tslib "^2.1.0"
"@firebase/app-types@0.7.0":
version "0.7.0"
resolved "https://registry.yarnpkg.com/@firebase/app-types/-/app-types-0.7.0.tgz#c9e16d1b8bed1a991840b8d2a725fb58d0b5899f"
integrity sha512-6fbHQwDv2jp/v6bXhBw2eSRbNBpxHcd1NBF864UksSMVIqIyri9qpJB1Mn6sGZE+bnDsSQBC5j2TbMxYsJQkQg==
"@firebase/app@0.7.11":
version "0.7.11"
resolved "https://registry.yarnpkg.com/@firebase/app/-/app-0.7.11.tgz#b85d553dc44620ee0f795ecb6aeabd6c43737390"
integrity sha512-GnG2XxlMrqd8zRa14Y3gvkPpr0tKTLZtxhUnShWkeSM5bQqk1DK2k9qDsf6D3cYfKCWv+JIg1zmL3oalxfhNNA==
dependencies:
"@firebase/component" "0.5.9"
"@firebase/logger" "0.3.2"
"@firebase/util" "1.4.2"
tslib "^2.1.0"
"@firebase/auth-compat@0.2.4":
version "0.2.4"
resolved "https://registry.yarnpkg.com/@firebase/auth-compat/-/auth-compat-0.2.4.tgz#e2862ed0177520b34abc6be6adca9f220a928ed9"
integrity sha512-2OpV6o8U33xiC98G9UrlhEMOOHfXmoum74VghP85BufLroi7erLKawBaDbYiHWK2QYudd8cbOPkk5GDocl1KNQ==
dependencies:
"@firebase/auth" "0.19.4"
"@firebase/auth-types" "0.11.0"
"@firebase/component" "0.5.9"
"@firebase/util" "1.4.2"
node-fetch "2.6.5"
selenium-webdriver "^4.0.0-beta.2"
tslib "^2.1.0"
"@firebase/auth-interop-types@0.1.6":
version "0.1.6"
resolved "https://registry.yarnpkg.com/@firebase/auth-interop-types/-/auth-interop-types-0.1.6.tgz#5ce13fc1c527ad36f1bb1322c4492680a6cf4964"
integrity sha512-etIi92fW3CctsmR9e3sYM3Uqnoq861M0Id9mdOPF6PWIg38BXL5k4upCNBggGUpLIS0H1grMOvy/wn1xymwe2g==
"@firebase/auth-types@0.11.0":
version "0.11.0"
resolved "https://registry.yarnpkg.com/@firebase/auth-types/-/auth-types-0.11.0.tgz#b9c73c60ca07945b3bbd7a097633e5f78fa9e886"
integrity sha512-q7Bt6cx+ySj9elQHTsKulwk3+qDezhzRBFC9zlQ1BjgMueUOnGMcvqmU0zuKlQ4RhLSH7MNAdBV2znVaoN3Vxw==
"@firebase/auth@0.19.4":
version "0.19.4"
resolved "https://registry.yarnpkg.com/@firebase/auth/-/auth-0.19.4.tgz#7d4962e70578e915d1a887be3d662c1fb030471e"
integrity sha512-0FefLGnP0mbgvSSan7j2e25i3pllqF9+KYO5fwuAo3YcgjCyNMBJKaXPlz/J+z6jRHa2itjh4W48jD4Y/FCMqw==
dependencies:
"@firebase/component" "0.5.9"
"@firebase/logger" "0.3.2"
"@firebase/util" "1.4.2"
node-fetch "2.6.5"
selenium-webdriver "4.0.0-rc-1"
tslib "^2.1.0"
"@firebase/component@0.5.9":
version "0.5.9"
resolved "https://registry.yarnpkg.com/@firebase/component/-/component-0.5.9.tgz#a859f655bd6e5b691bc5596fe43a91b12a443052"
integrity sha512-oLCY3x9WbM5rn06qmUvbtJuPj4dIw/C9T4Th52IiHF5tiCRC5k6YthvhfUVcTwfoUhK0fOgtwuKJKA/LpCPjgA==
dependencies:
"@firebase/util" "1.4.2"
tslib "^2.1.0"
"@firebase/database-compat@0.1.4":
version "0.1.4"
resolved "https://registry.yarnpkg.com/@firebase/database-compat/-/database-compat-0.1.4.tgz#9bad05a4a14e557271b887b9ab97f8b39f91f5aa"
integrity sha512-dIJiZLDFF3U+MoEwoPBy7zxWmBUro1KefmwSHlpOoxmPv76tuoPm85NumpW/HmMrtTcTkC2qowtb6NjGE8X7mw==
dependencies:
"@firebase/component" "0.5.9"
"@firebase/database" "0.12.4"
"@firebase/database-types" "0.9.3"
"@firebase/logger" "0.3.2"
"@firebase/util" "1.4.2"
tslib "^2.1.0"
"@firebase/database-types@0.9.3":
version "0.9.3"
resolved "https://registry.yarnpkg.com/@firebase/database-types/-/database-types-0.9.3.tgz#d1a8ee34601136fd0047817d94432d89fdba5fef"
integrity sha512-R+YXLWy/Q7mNUxiUYiMboTwvVoprrgfyvf1Viyevskw6IoH1q8HV1UjlkLSgmRsOT9HPWt7XZUEStVZJFknHwg==
dependencies:
"@firebase/app-types" "0.7.0"
"@firebase/util" "1.4.2"
"@firebase/database@0.12.4":
version "0.12.4"
resolved "https://registry.yarnpkg.com/@firebase/database/-/database-0.12.4.tgz#7ad26393f59ede2b93444406651f976a7008114d"
integrity sha512-XkrL1kXELRNkqKcltuT4hfG1gWmFiGvjFY+z7Lhb//12MqdkLjwa9YMK8c6Lo+Ro+IkWcJArQaOQYe3GkU5Wgg==
dependencies:
"@firebase/auth-interop-types" "0.1.6"
"@firebase/component" "0.5.9"
"@firebase/logger" "0.3.2"
"@firebase/util" "1.4.2"
faye-websocket "0.11.4"
tslib "^2.1.0"
"@firebase/firestore-compat@0.1.10":
version "0.1.10"
resolved "https://registry.yarnpkg.com/@firebase/firestore-compat/-/firestore-compat-0.1.10.tgz#910ba0304ec9cb9202b08852dab206d3511833ec"
integrity sha512-wnyUzx5bHatnsP+3nX0FmA1jxfDxVW5gCdM59sXxd0PWf4oUOONRlqVstVAHVUH123huGaNdEXY6LUlP7H0EnA==
dependencies:
"@firebase/component" "0.5.9"
"@firebase/firestore" "3.4.1"
"@firebase/firestore-types" "2.5.0"
"@firebase/util" "1.4.2"
tslib "^2.1.0"
"@firebase/firestore-types@2.5.0":
version "2.5.0"
resolved "https://registry.yarnpkg.com/@firebase/firestore-types/-/firestore-types-2.5.0.tgz#16fca40b6980fdb000de86042d7a96635f2bcdd7"
integrity sha512-I6c2m1zUhZ5SH0cWPmINabDyH5w0PPFHk2UHsjBpKdZllzJZ2TwTkXbDtpHUZNmnc/zAa0WNMNMvcvbb/xJLKA==
"@firebase/firestore@3.4.1":
version "3.4.1"
resolved "https://registry.yarnpkg.com/@firebase/firestore/-/firestore-3.4.1.tgz#b988a25213e51b112db4fef8d939634957f35b9f"
integrity sha512-KSXuaiavHUqk3+0qRe4U8QZ1vfpOc4PuesohLcjA824HexBzXd+6NoUmBs/F9pyS9Ka1rJeECXzXgpk0pInSBw==
dependencies:
"@firebase/component" "0.5.9"
"@firebase/logger" "0.3.2"
"@firebase/util" "1.4.2"
"@firebase/webchannel-wrapper" "0.6.1"
"@grpc/grpc-js" "^1.3.2"
"@grpc/proto-loader" "^0.6.0"
node-fetch "2.6.5"
tslib "^2.1.0"
"@firebase/functions-compat@0.1.7":
version "0.1.7"
resolved "https://registry.yarnpkg.com/@firebase/functions-compat/-/functions-compat-0.1.7.tgz#0c73acedbf2701715fbec6b293ba1cd2549812c5"
integrity sha512-Rv3mAUIhsLTxIgPWJSESUcmE1tzNHzUlqQStPnxHn6eFFgHVhkU2wg/NMrKZWTFlb51jpKTjh51AQDhRdT3n3A==
dependencies:
"@firebase/component" "0.5.9"
"@firebase/functions" "0.7.6"
"@firebase/functions-types" "0.5.0"
"@firebase/util" "1.4.2"
tslib "^2.1.0"
"@firebase/functions-types@0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@firebase/functions-types/-/functions-types-0.5.0.tgz#b50ba95ccce9e96f7cda453228ffe1684645625b"
integrity sha512-qza0M5EwX+Ocrl1cYI14zoipUX4gI/Shwqv0C1nB864INAD42Dgv4v94BCyxGHBg2kzlWy8PNafdP7zPO8aJQA==
"@firebase/functions@0.7.6":
version "0.7.6"
resolved "https://registry.yarnpkg.com/@firebase/functions/-/functions-0.7.6.tgz#c2ae5866943d812580bda26200c0b17295505dc3"
integrity sha512-Kl6a2PbRkOlSlOWJSgYuNp3e53G3cb+axF+r7rbWhJIHiaelG16GerBMxZTSxyiCz77C24LwiA2TKNwe85ObZg==
dependencies:
"@firebase/app-check-interop-types" "0.1.0"
"@firebase/auth-interop-types" "0.1.6"
"@firebase/component" "0.5.9"
"@firebase/messaging-interop-types" "0.1.0"
"@firebase/util" "1.4.2"
node-fetch "2.6.5"
tslib "^2.1.0"
"@firebase/installations@0.5.4":
version "0.5.4"
resolved "https://registry.yarnpkg.com/@firebase/installations/-/installations-0.5.4.tgz#c6f5a40eee930d447c909d84f01f5ebfe2f5f46e"
integrity sha512-rYb6Ju/tIBhojmM8FsgS96pErKl6gPgJFnffMO4bKH7HilXhOfgLfKU9k51ZDcps8N0npDx9+AJJ6pL1aYuYZQ==
dependencies:
"@firebase/component" "0.5.9"
"@firebase/util" "1.4.2"
idb "3.0.2"
tslib "^2.1.0"
"@firebase/logger@0.3.2":
version "0.3.2"
resolved "https://registry.yarnpkg.com/@firebase/logger/-/logger-0.3.2.tgz#5046ffa8295c577846d54b6ca95645a03809800e"
integrity sha512-lzLrcJp9QBWpo40OcOM9B8QEtBw2Fk1zOZQdvv+rWS6gKmhQBCEMc4SMABQfWdjsylBcDfniD1Q+fUX1dcBTXA==
dependencies:
tslib "^2.1.0"
"@firebase/messaging-compat@0.1.4":
version "0.1.4"
resolved "https://registry.yarnpkg.com/@firebase/messaging-compat/-/messaging-compat-0.1.4.tgz#14dffa349e241557b10d8fb7f5896a04d3f857a7"
integrity sha512-6477jBw7w7hk0uhnTUMsPoukalpcwbxTTo9kMguHVSXe0t3OdoxeXEaapaNJlOmU4Kgc8j3rsms8IDLdKVpvlA==
dependencies:
"@firebase/component" "0.5.9"
"@firebase/messaging" "0.9.4"
"@firebase/util" "1.4.2"
tslib "^2.1.0"
"@firebase/messaging-interop-types@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@firebase/messaging-interop-types/-/messaging-interop-types-0.1.0.tgz#bdac02dd31edd5cb9eec37b1db698ea5e2c1a631"
integrity sha512-DbvUl/rXAZpQeKBnwz0NYY5OCqr2nFA0Bj28Fmr3NXGqR4PAkfTOHuQlVtLO1Nudo3q0HxAYLa68ZDAcuv2uKQ==
"@firebase/messaging@0.9.4":
version "0.9.4"
resolved "https://registry.yarnpkg.com/@firebase/messaging/-/messaging-0.9.4.tgz#a1cd38ad92eb92cde908dc695767362087137f6d"
integrity sha512-OvYV4MLPfDpdP/yltLqZXZRx6rXWz52bEilS2jL2B4sGiuTaXSkR6BIHB54EPTblu32nbyZYdlER4fssz4TfXw==
dependencies:
"@firebase/component" "0.5.9"
"@firebase/installations" "0.5.4"
"@firebase/messaging-interop-types" "0.1.0"
"@firebase/util" "1.4.2"
idb "3.0.2"
tslib "^2.1.0"
"@firebase/performance-compat@0.1.4":
version "0.1.4"
resolved "https://registry.yarnpkg.com/@firebase/performance-compat/-/performance-compat-0.1.4.tgz#0e887e9d707515db0594117072375e18200703a9"
integrity sha512-YuGfmpC0o+YvEBlEZCbPdNbT4Nn2qhi5uMXjqKnNIUepmXUsgOYDiAqM9nxHPoE/6IkvoFMdCj5nTUYVLCFXgg==
dependencies:
"@firebase/component" "0.5.9"
"@firebase/logger" "0.3.2"
"@firebase/performance" "0.5.4"
"@firebase/performance-types" "0.1.0"
"@firebase/util" "1.4.2"
tslib "^2.1.0"
"@firebase/performance-types@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@firebase/performance-types/-/performance-types-0.1.0.tgz#5e6efa9dc81860aee2cb7121b39ae8fa137e69fc"
integrity sha512-6p1HxrH0mpx+622Ql6fcxFxfkYSBpE3LSuwM7iTtYU2nw91Hj6THC8Bc8z4nboIq7WvgsT/kOTYVVZzCSlXl8w==
"@firebase/performance@0.5.4":
version "0.5.4"
resolved "https://registry.yarnpkg.com/@firebase/performance/-/performance-0.5.4.tgz#480bf61a8ff248e55506172be267029270457743"
integrity sha512-ES6aS4eoMhf9CczntBADDsXhaFea/3a0FADwy/VpWXXBxVb8tqc5tPcoTwd9L5M/aDeSiQMy344rhrSsTbIZEg==
dependencies:
"@firebase/component" "0.5.9"
"@firebase/installations" "0.5.4"
"@firebase/logger" "0.3.2"
"@firebase/util" "1.4.2"
tslib "^2.1.0"
"@firebase/polyfill@0.3.36":
version "0.3.36"
resolved "https://registry.yarnpkg.com/@firebase/polyfill/-/polyfill-0.3.36.tgz#c057cce6748170f36966b555749472b25efdb145"
integrity sha512-zMM9oSJgY6cT2jx3Ce9LYqb0eIpDE52meIzd/oe/y70F+v9u1LDqk5kUF5mf16zovGBWMNFmgzlsh6Wj0OsFtg==
dependencies:
core-js "3.6.5"
promise-polyfill "8.1.3"
whatwg-fetch "2.0.4"
"@firebase/remote-config-compat@0.1.4":
version "0.1.4"
resolved "https://registry.yarnpkg.com/@firebase/remote-config-compat/-/remote-config-compat-0.1.4.tgz#25561c070b2ba8e41e3f33aa9e9db592bbec5a37"
integrity sha512-6WeKR7E9KJ1RIF9GZiyle1uD4IsIPUBKUnUnFkQhj3FV6cGvQwbeG0rbh7QQLvd0IWuh9lABYjHXWp+rGHQk8A==
dependencies:
"@firebase/component" "0.5.9"
"@firebase/logger" "0.3.2"
"@firebase/remote-config" "0.3.3"
"@firebase/remote-config-types" "0.2.0"
"@firebase/util" "1.4.2"
tslib "^2.1.0"
"@firebase/remote-config-types@0.2.0":
version "0.2.0"
resolved "https://registry.yarnpkg.com/@firebase/remote-config-types/-/remote-config-types-0.2.0.tgz#1e2759fc01f20b58c564db42196f075844c3d1fd"
integrity sha512-hqK5sCPeZvcHQ1D6VjJZdW6EexLTXNMJfPdTwbD8NrXUw6UjWC4KWhLK/TSlL0QPsQtcKRkaaoP+9QCgKfMFPw==
"@firebase/remote-config@0.3.3":
version "0.3.3"
resolved "https://registry.yarnpkg.com/@firebase/remote-config/-/remote-config-0.3.3.tgz#dedee2de508e2392ec2f254368adb7c2d969fc16"
integrity sha512-9hZWfB3k3IYsjHbWeUfhv/SDCcOgv/JMJpLXlUbTppXPm1IZ3X9ZW4I9bS86gGYr7m/kSv99U0oxQ7N9PoR8Iw==
dependencies:
"@firebase/component" "0.5.9"
"@firebase/installations" "0.5.4"
"@firebase/logger" "0.3.2"
"@firebase/util" "1.4.2"
tslib "^2.1.0"
"@firebase/storage-compat@0.1.8":
version "0.1.8"
resolved "https://registry.yarnpkg.com/@firebase/storage-compat/-/storage-compat-0.1.8.tgz#edbd9e2d8178c5695817e75f1da5c570c11f44dd"
integrity sha512-L5R0DQoHCDKIgcBbqTx+6+RQ2533WFKeV3cfLAZCTGjyMUustj0eYDsr7fLhGexwsnpT3DaxhlbzT3icUWoDaA==
dependencies:
"@firebase/component" "0.5.9"
"@firebase/storage" "0.9.0"
"@firebase/storage-types" "0.6.0"
"@firebase/util" "1.4.2"
tslib "^2.1.0"
"@firebase/storage-types@0.6.0":
version "0.6.0"
resolved "https://registry.yarnpkg.com/@firebase/storage-types/-/storage-types-0.6.0.tgz#0b1af64a2965af46fca138e5b70700e9b7e6312a"
integrity sha512-1LpWhcCb1ftpkP/akhzjzeFxgVefs6eMD2QeKiJJUGH1qOiows2w5o0sKCUSQrvrRQS1lz3SFGvNR1Ck/gqxeA==
"@firebase/storage@0.9.0":
version "0.9.0"
resolved "https://registry.yarnpkg.com/@firebase/storage/-/storage-0.9.0.tgz#e33d2dea4c056d70d801a20521aa96fa2e4fbfb8"
integrity sha512-1gSYdrwP9kECmugH9L3tvNMvSjnNJGamj91rrESOFk2ZHDO93qKR90awc68NnhmzFAJOT/eJzVm35LKU6SqUNg==
dependencies:
"@firebase/component" "0.5.9"
"@firebase/util" "1.4.2"
node-fetch "2.6.5"
tslib "^2.1.0"
"@firebase/util@1.4.2":
version "1.4.2"
resolved "https://registry.yarnpkg.com/@firebase/util/-/util-1.4.2.tgz#271c63bb7cce4607f7679dc5624ef241c4cf2498"
integrity sha512-JMiUo+9QE9lMBvEtBjqsOFdmJgObFvi7OL1A0uFGwTmlCI1ZeNPOEBrwXkgTOelVCdiMO15mAebtEyxFuQ6FsA==
dependencies:
tslib "^2.1.0"
"@firebase/webchannel-wrapper@0.6.1":
version "0.6.1"
resolved "https://registry.yarnpkg.com/@firebase/webchannel-wrapper/-/webchannel-wrapper-0.6.1.tgz#0c74724ba6e9ea6ad25a391eab60a79eaba4c556"
integrity sha512-9FqhNjKQWpQ3fGnSOCovHOm+yhhiorKEqYLAfd525jWavunDJcx8rOW6i6ozAh+FbwcYMkL7b+3j4UR/30MpoQ==
"@grpc/grpc-js@^1.3.2":
version "1.4.5"
resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.4.5.tgz#0cd840b47180624eeedf066f2cdc422d052401f8"
integrity sha512-A6cOzSu7dqXZ7rzvh/9JZf+Jg/MOpLEMP0IdT8pT8hrWJZ6TB4ydN/MRuqOtAugInJe/VQ9F8BPricUpYZSaZA==
dependencies:
"@grpc/proto-loader" "^0.6.4"
"@types/node" ">=12.12.47"
"@grpc/proto-loader@^0.6.0", "@grpc/proto-loader@^0.6.4":
version "0.6.7"
resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.6.7.tgz#e62a202f4cf5897bdd0e244dec1dbc80d84bdfa1"
integrity sha512-QzTPIyJxU0u+r2qGe8VMl3j/W2ryhEvBv7hc42OjYfthSj370fUrb7na65rG6w3YLZS/fb8p89iTBobfWGDgdw==
dependencies:
"@types/long" "^4.0.1"
lodash.camelcase "^4.3.0"
long "^4.0.0"
protobufjs "^6.10.0"
yargs "^16.1.1"
"@hapi/accept@5.0.2": "@hapi/accept@5.0.2":
version "5.0.2" version "5.0.2"
resolved "https://registry.yarnpkg.com/@hapi/accept/-/accept-5.0.2.tgz#ab7043b037e68b722f93f376afb05e85c0699523" resolved "https://registry.yarnpkg.com/@hapi/accept/-/accept-5.0.2.tgz#ab7043b037e68b722f93f376afb05e85c0699523"
@ -2378,6 +2757,59 @@
resolved "https://registry.yarnpkg.com/@panva/hkdf/-/hkdf-1.0.1.tgz#ed0da773bd5f794d0603f5a5b5cee6d2354e5660" resolved "https://registry.yarnpkg.com/@panva/hkdf/-/hkdf-1.0.1.tgz#ed0da773bd5f794d0603f5a5b5cee6d2354e5660"
integrity sha512-mMyQ9vjpuFqePkfe5bZVIf/H3Dmk6wA8Kjxff9RcO4kqzJo+Ek9pGKwZHpeMr7Eku0QhLXMCd7fNCSnEnRMubg== integrity sha512-mMyQ9vjpuFqePkfe5bZVIf/H3Dmk6wA8Kjxff9RcO4kqzJo+Ek9pGKwZHpeMr7Eku0QhLXMCd7fNCSnEnRMubg==
"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf"
integrity sha1-m4sMxmPWaafY9vXQiToU00jzD78=
"@protobufjs/base64@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735"
integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==
"@protobufjs/codegen@^2.0.4":
version "2.0.4"
resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.4.tgz#7ef37f0d010fb028ad1ad59722e506d9262815cb"
integrity sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==
"@protobufjs/eventemitter@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70"
integrity sha1-NVy8mLr61ZePntCV85diHx0Ga3A=
"@protobufjs/fetch@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45"
integrity sha1-upn7WYYUr2VwDBYZ/wbUVLDYTEU=
dependencies:
"@protobufjs/aspromise" "^1.1.1"
"@protobufjs/inquire" "^1.1.0"
"@protobufjs/float@^1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1"
integrity sha1-Xp4avctz/Ap8uLKR33jIy9l7h9E=
"@protobufjs/inquire@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.0.tgz#ff200e3e7cf2429e2dcafc1140828e8cc638f089"
integrity sha1-/yAOPnzyQp4tyvwRQIKOjMY48Ik=
"@protobufjs/path@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d"
integrity sha1-bMKyDFya1q0NzP0hynZz2Nf79o0=
"@protobufjs/pool@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54"
integrity sha1-Cf0V8tbTq/qbZbw2ZQbWrXhG/1Q=
"@protobufjs/utf8@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570"
integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=
"@radix-ui/popper@0.1.0": "@radix-ui/popper@0.1.0":
version "0.1.0" version "0.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/popper/-/popper-0.1.0.tgz#c387a38f31b7799e1ea0d2bb1ca0c91c2931b063" resolved "https://registry.yarnpkg.com/@radix-ui/popper/-/popper-0.1.0.tgz#c387a38f31b7799e1ea0d2bb1ca0c91c2931b063"
@ -3248,6 +3680,11 @@
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
"@types/long@^4.0.1":
version "4.0.1"
resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9"
integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==
"@types/minimatch@*", "@types/minimatch@^3.0.3": "@types/minimatch@*", "@types/minimatch@^3.0.3":
version "3.0.5" version "3.0.5"
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40"
@ -3275,6 +3712,11 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.11.tgz#6ea7342dfb379ea1210835bada87b3c512120234" resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.11.tgz#6ea7342dfb379ea1210835bada87b3c512120234"
integrity sha512-KB0sixD67CeecHC33MYn+eYARkqTheIRNuu97y2XMjR7Wu3XibO1vaY6VBV6O/a89SPI81cEUIYT87UqUWlZNw== integrity sha512-KB0sixD67CeecHC33MYn+eYARkqTheIRNuu97y2XMjR7Wu3XibO1vaY6VBV6O/a89SPI81cEUIYT87UqUWlZNw==
"@types/node@>=12.12.47", "@types/node@>=13.7.0":
version "17.0.2"
resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.2.tgz#a4c07d47ff737e8ee7e586fe636ff0e1ddff070a"
integrity sha512-JepeIUPFDARgIs0zD/SKPgFsJEAF0X5/qO80llx59gOxFTboS9Amv3S+QfB7lqBId5sFXJ99BN0J6zFRvL9dDA==
"@types/node@^14.14.35", "@types/node@^14.6.2": "@types/node@^14.14.35", "@types/node@^14.6.2":
version "14.18.0" version "14.18.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.0.tgz#98df2397f6936bfbff4f089e40e06fa5dd88d32a" resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.0.tgz#98df2397f6936bfbff4f089e40e06fa5dd88d32a"
@ -5308,6 +5750,11 @@ core-js-pure@^3.19.0:
resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.19.2.tgz#26b5bfb503178cff6e3e115bc2ba6c6419383680" resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.19.2.tgz#26b5bfb503178cff6e3e115bc2ba6c6419383680"
integrity sha512-5LkcgQEy8pFeVnd/zomkUBSwnmIxuF1C8E9KrMAbOc8f34IBT9RGvTYeNDdp1PnvMJrrVhvk1hg/yVV5h/znlg== integrity sha512-5LkcgQEy8pFeVnd/zomkUBSwnmIxuF1C8E9KrMAbOc8f34IBT9RGvTYeNDdp1PnvMJrrVhvk1hg/yVV5h/znlg==
core-js@3.6.5:
version "3.6.5"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.6.5.tgz#7395dc273af37fb2e50e9bd3d9fe841285231d1a"
integrity sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==
core-js@^2.5.3: core-js@^2.5.3:
version "2.6.12" version "2.6.12"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec"
@ -6108,11 +6555,6 @@ dotenv@8.2.0:
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a"
integrity sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw== integrity sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==
dotenv@^10.0.0:
version "10.0.0"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-10.0.0.tgz#3d4227b8fb95f81096cdd2b66653fb2c7085ba81"
integrity sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==
dotenv@^9.0.2: dotenv@^9.0.2:
version "9.0.2" version "9.0.2"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-9.0.2.tgz#dacc20160935a37dea6364aa1bef819fb9b6ab05" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-9.0.2.tgz#dacc20160935a37dea6364aa1bef819fb9b6ab05"
@ -7047,6 +7489,13 @@ fastq@^1.6.0:
dependencies: dependencies:
reusify "^1.0.4" reusify "^1.0.4"
faye-websocket@0.11.4:
version "0.11.4"
resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.4.tgz#7f0d9275cfdd86a1c963dc8b65fcc451edcbb1da"
integrity sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==
dependencies:
websocket-driver ">=0.5.1"
fb-watchman@^2.0.0: fb-watchman@^2.0.0:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.1.tgz#fc84fb39d2709cf3ff6d743706157bb5708a8a85" resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.1.tgz#fc84fb39d2709cf3ff6d743706157bb5708a8a85"
@ -7212,6 +7661,38 @@ find-up@^4.0.0, find-up@^4.1.0:
locate-path "^5.0.0" locate-path "^5.0.0"
path-exists "^4.0.0" path-exists "^4.0.0"
firebase@^9.6.1:
version "9.6.1"
resolved "https://registry.yarnpkg.com/firebase/-/firebase-9.6.1.tgz#08e0fd0799f57a885f895b86a6ed2bc0083412fe"
integrity sha512-d4wbkVMRiSREa1jfFx2z/Kq3KueEKfNWApvdrEAxvzDRN4eiFLeZSZM/MOxj7TR01e/hANnw2lrYKMUpg21ukg==
dependencies:
"@firebase/analytics" "0.7.4"
"@firebase/analytics-compat" "0.1.5"
"@firebase/app" "0.7.11"
"@firebase/app-check" "0.5.2"
"@firebase/app-check-compat" "0.2.2"
"@firebase/app-compat" "0.1.12"
"@firebase/app-types" "0.7.0"
"@firebase/auth" "0.19.4"
"@firebase/auth-compat" "0.2.4"
"@firebase/database" "0.12.4"
"@firebase/database-compat" "0.1.4"
"@firebase/firestore" "3.4.1"
"@firebase/firestore-compat" "0.1.10"
"@firebase/functions" "0.7.6"
"@firebase/functions-compat" "0.1.7"
"@firebase/installations" "0.5.4"
"@firebase/messaging" "0.9.4"
"@firebase/messaging-compat" "0.1.4"
"@firebase/performance" "0.5.4"
"@firebase/performance-compat" "0.1.4"
"@firebase/polyfill" "0.3.36"
"@firebase/remote-config" "0.3.3"
"@firebase/remote-config-compat" "0.1.4"
"@firebase/storage" "0.9.0"
"@firebase/storage-compat" "0.1.8"
"@firebase/util" "1.4.2"
flat-cache@^3.0.4: flat-cache@^3.0.4:
version "3.0.4" version "3.0.4"
resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11"
@ -8001,6 +8482,11 @@ http-errors@~1.6.2:
setprototypeof "1.1.0" setprototypeof "1.1.0"
statuses ">= 1.4.0 < 2" statuses ">= 1.4.0 < 2"
http-parser-js@>=0.5.1:
version "0.5.5"
resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.5.tgz#d7c30d5d3c90d865b4a2e870181f9d6f22ac7ac5"
integrity sha512-x+JVEkO2PoM8qqpbPbOL3cqHPwerep7OwzK7Ay+sMQjKzaKCqWvjoXm5tqMP9tXWWTnTzAjIhXg+J99XYuPhPA==
http-proxy-agent@^2.1.0: http-proxy-agent@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-2.1.0.tgz#e4821beef5b2142a2026bd73926fe537631c5405" resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-2.1.0.tgz#e4821beef5b2142a2026bd73926fe537631c5405"
@ -8108,6 +8594,11 @@ idb-keyval@^6.0.3:
dependencies: dependencies:
safari-14-idb-fix "^3.0.0" safari-14-idb-fix "^3.0.0"
idb@3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/idb/-/idb-3.0.2.tgz#c8e9122d5ddd40f13b60ae665e4862f8b13fa384"
integrity sha512-+FLa/0sTXqyux0o6C+i2lOR0VoS60LU/jzUo5xjfY6+7sEEgy4Gz1O7yFBXvjd7N0NyIGWIRg8DcQSLEG+VSPw==
idb@^6.1.4: idb@^6.1.4:
version "6.1.5" version "6.1.5"
resolved "https://registry.yarnpkg.com/idb/-/idb-6.1.5.tgz#dbc53e7adf1ac7c59f9b2bf56e00b4ea4fce8c7b" resolved "https://registry.yarnpkg.com/idb/-/idb-6.1.5.tgz#dbc53e7adf1ac7c59f9b2bf56e00b4ea4fce8c7b"
@ -9462,6 +9953,16 @@ jsprim@^1.2.2:
array-includes "^3.1.3" array-includes "^3.1.3"
object.assign "^4.1.2" object.assign "^4.1.2"
jszip@^3.6.0:
version "3.7.1"
resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.7.1.tgz#bd63401221c15625a1228c556ca8a68da6fda3d9"
integrity sha512-ghL0tz1XG9ZEmRMcEN2vt7xabrDdqHHeykgARpmZ0BiIctWxM47Vt63ZO2dnp4QYt/xJVLLy5Zv1l/xRdh2byg==
dependencies:
lie "~3.3.0"
pako "~1.0.2"
readable-stream "~2.3.6"
set-immediate-shim "~1.0.1"
keygrip@~1.1.0: keygrip@~1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226" resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226"
@ -9663,6 +10164,13 @@ lie@3.1.1:
dependencies: dependencies:
immediate "~3.0.5" immediate "~3.0.5"
lie@~3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a"
integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==
dependencies:
immediate "~3.0.5"
lines-and-columns@^1.1.6: lines-and-columns@^1.1.6:
version "1.2.4" version "1.2.4"
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
@ -9816,6 +10324,11 @@ lodash._reinterpolate@^3.0.0:
resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0= integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=
lodash.camelcase@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
integrity sha1-soqmKIorn8ZRA1x3EfZathkDMaY=
lodash.clonedeep@^4.5.0: lodash.clonedeep@^4.5.0:
version "4.5.0" version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
@ -9899,6 +10412,11 @@ log-update@^4.0.0:
slice-ansi "^4.0.0" slice-ansi "^4.0.0"
wrap-ansi "^6.2.0" wrap-ansi "^6.2.0"
long@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28"
integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0:
version "1.4.0" version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
@ -10715,6 +11233,13 @@ node-fetch@2.6.1:
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
node-fetch@2.6.5:
version "2.6.5"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.5.tgz#42735537d7f080a7e5f78b6c549b7146be1742fd"
integrity sha512-mmlIVHJEu5rnIxgEgez6b9GgWXbkZj5YZ7fx+2r94a2E+Uirsp6HsPTPlomfdHtpt/B0cdKviwkoaM6pyvUOpQ==
dependencies:
whatwg-url "^5.0.0"
node-fetch@^2.5.0, node-fetch@^2.6.0, node-fetch@^2.6.1: node-fetch@^2.5.0, node-fetch@^2.6.0, node-fetch@^2.6.1:
version "2.6.6" version "2.6.6"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.6.tgz#1751a7c01834e8e1697758732e9efb6eeadfaf89" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.6.tgz#1751a7c01834e8e1697758732e9efb6eeadfaf89"
@ -11283,7 +11808,7 @@ package-json@^6.3.0:
registry-url "^5.0.0" registry-url "^5.0.0"
semver "^6.2.0" semver "^6.2.0"
pako@~1.0.5: pako@~1.0.2, pako@~1.0.5:
version "1.0.11" version "1.0.11"
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==
@ -11778,6 +12303,11 @@ promise-inflight@^1.0.1:
resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3"
integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM= integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM=
promise-polyfill@8.1.3:
version "8.1.3"
resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-8.1.3.tgz#8c99b3cf53f3a91c68226ffde7bde81d7f904116"
integrity sha512-MG5r82wBzh7pSKDRa9y+vllNHz3e3d4CNj1PQE4BQYxLme0gKYYBm9YENq+UkEikyZ0XbiGWxYlVw3Rl9O/U8g==
promise-retry@^1.1.1: promise-retry@^1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/promise-retry/-/promise-retry-1.1.1.tgz#6739e968e3051da20ce6497fb2b50f6911df3d6d" resolved "https://registry.yarnpkg.com/promise-retry/-/promise-retry-1.1.1.tgz#6739e968e3051da20ce6497fb2b50f6911df3d6d"
@ -11824,6 +12354,25 @@ proto-list@~1.2.1:
resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849"
integrity sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk= integrity sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=
protobufjs@^6.10.0:
version "6.11.2"
resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.11.2.tgz#de39fabd4ed32beaa08e9bb1e30d08544c1edf8b"
integrity sha512-4BQJoPooKJl2G9j3XftkIXjoC9C0Av2NOrWmbLWT1vH32GcSUHjM0Arra6UfTsVyfMAuFzaLucXn1sadxJydAw==
dependencies:
"@protobufjs/aspromise" "^1.1.2"
"@protobufjs/base64" "^1.1.2"
"@protobufjs/codegen" "^2.0.4"
"@protobufjs/eventemitter" "^1.1.0"
"@protobufjs/fetch" "^1.1.0"
"@protobufjs/float" "^1.0.2"
"@protobufjs/inquire" "^1.1.0"
"@protobufjs/path" "^1.1.2"
"@protobufjs/pool" "^1.1.0"
"@protobufjs/utf8" "^1.1.0"
"@types/long" "^4.0.1"
"@types/node" ">=13.7.0"
long "^4.0.0"
protocols@^1.1.0, protocols@^1.4.0: protocols@^1.1.0, protocols@^1.4.0:
version "1.4.8" version "1.4.8"
resolved "https://registry.yarnpkg.com/protocols/-/protocols-1.4.8.tgz#48eea2d8f58d9644a4a32caae5d5db290a075ce8" resolved "https://registry.yarnpkg.com/protocols/-/protocols-1.4.8.tgz#48eea2d8f58d9644a4a32caae5d5db290a075ce8"
@ -12658,7 +13207,7 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@^5.2.1, safe-buffer@~5.2.0: safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@^5.2.1, safe-buffer@~5.2.0:
version "5.2.1" version "5.2.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
@ -12734,6 +13283,25 @@ seek-bzip@^1.0.5:
dependencies: dependencies:
commander "^2.8.1" commander "^2.8.1"
selenium-webdriver@4.0.0-rc-1:
version "4.0.0-rc-1"
resolved "https://registry.yarnpkg.com/selenium-webdriver/-/selenium-webdriver-4.0.0-rc-1.tgz#b1e7e5821298c8a071e988518dd6b759f0c41281"
integrity sha512-bcrwFPRax8fifRP60p7xkWDGSJJoMkPAzufMlk5K2NyLPht/YZzR2WcIk1+3gR8VOCLlst1P2PI+MXACaFzpIw==
dependencies:
jszip "^3.6.0"
rimraf "^3.0.2"
tmp "^0.2.1"
ws ">=7.4.6"
selenium-webdriver@^4.0.0-beta.2:
version "4.1.0"
resolved "https://registry.yarnpkg.com/selenium-webdriver/-/selenium-webdriver-4.1.0.tgz#d11e5d43674e2718265a30684bcbf6ec734fd3bd"
integrity sha512-kUDH4N8WruYprTzvug4Pl73Th+WKb5YiLz8z/anOpHyUNUdM3UzrdTOxmSNaf9AczzBeY+qXihzku8D1lMaKOg==
dependencies:
jszip "^3.6.0"
tmp "^0.2.1"
ws ">=7.4.6"
semver-compare@^1.0.0: semver-compare@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc"
@ -12794,6 +13362,11 @@ set-blocking@^2.0.0, set-blocking@~2.0.0:
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
set-immediate-shim@~1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61"
integrity sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E=
set-value@^2.0.0, set-value@^2.0.1: set-value@^2.0.0, set-value@^2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b" resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b"
@ -13936,7 +14509,7 @@ tslib@^1.0.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3.1: tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3.1:
version "2.3.1" version "2.3.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"
integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==
@ -14535,6 +15108,20 @@ webpack-sources@^1.4.3:
source-list-map "^2.0.0" source-list-map "^2.0.0"
source-map "~0.6.1" source-map "~0.6.1"
websocket-driver@>=0.5.1:
version "0.7.4"
resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.4.tgz#89ad5295bbf64b480abcba31e4953aca706f5760"
integrity sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==
dependencies:
http-parser-js ">=0.5.1"
safe-buffer ">=5.1.0"
websocket-extensions ">=0.1.1"
websocket-extensions@>=0.1.1:
version "0.1.4"
resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42"
integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==
whatwg-encoding@^1.0.5: whatwg-encoding@^1.0.5:
version "1.0.5" version "1.0.5"
resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz#5abacf777c32166a51d085d6b4f3e7d27113ddb0" resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz#5abacf777c32166a51d085d6b4f3e7d27113ddb0"
@ -14542,6 +15129,11 @@ whatwg-encoding@^1.0.5:
dependencies: dependencies:
iconv-lite "0.4.24" iconv-lite "0.4.24"
whatwg-fetch@2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz#dde6a5df315f9d39991aa17621853d720b85566f"
integrity sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng==
whatwg-mimetype@^2.3.0: whatwg-mimetype@^2.3.0:
version "2.3.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf" resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf"
@ -14905,6 +15497,11 @@ write-pkg@^3.1.0:
sort-keys "^2.0.0" sort-keys "^2.0.0"
write-json-file "^2.2.0" write-json-file "^2.2.0"
ws@>=7.4.6:
version "8.4.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.4.0.tgz#f05e982a0a88c604080e8581576e2a063802bed6"
integrity sha512-IHVsKe2pjajSUIl4KYMQOdlyliovpEPquKkqbwswulszzI7r0SfQrxnXdWAEqOlDCLrVSJzo+O1hAwdog2sKSQ==
ws@^7.4.3, ws@^7.4.6: ws@^7.4.3, ws@^7.4.6:
version "7.5.6" version "7.5.6"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.6.tgz#e59fc509fb15ddfb65487ee9765c5a51dec5fe7b" resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.6.tgz#e59fc509fb15ddfb65487ee9765c5a51dec5fe7b"
@ -15011,7 +15608,7 @@ yargs-unparser@2.0.0:
flat "^5.0.2" flat "^5.0.2"
is-plain-obj "^2.1.0" is-plain-obj "^2.1.0"
yargs@16.2.0, yargs@^16.2.0: yargs@16.2.0, yargs@^16.1.1, yargs@^16.2.0:
version "16.2.0" version "16.2.0"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66"
integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==