diff --git a/.gitignore b/.gitignore index f799d1a8d..173446d8b 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ apps/www/public/worker-* apps/www/public/sw.js apps/www/public/sw.js.map .env +firebase.config.* \ No newline at end of file diff --git a/apps/www/components/MultiplayerEditor.tsx b/apps/www/components/MultiplayerEditor.tsx index 9da914df8..44b66a248 100644 --- a/apps/www/components/MultiplayerEditor.tsx +++ b/apps/www/components/MultiplayerEditor.tsx @@ -51,6 +51,7 @@ function Editor({
) diff --git a/examples/tldraw-example/src/multiplayer-with-images/index.ts b/examples/tldraw-example/src/multiplayer-with-images/index.ts new file mode 100644 index 000000000..44e9f8535 --- /dev/null +++ b/examples/tldraw-example/src/multiplayer-with-images/index.ts @@ -0,0 +1 @@ +export * from './multiplayer' diff --git a/examples/tldraw-example/src/multiplayer-with-images/multiplayer.tsx b/examples/tldraw-example/src/multiplayer-with-images/multiplayer.tsx new file mode 100644 index 000000000..063b0140e --- /dev/null +++ b/examples/tldraw-example/src/multiplayer-with-images/multiplayer.tsx @@ -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 ( + + + + + + ) +} + +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
Error: {error.message}
+ + return ( +
+ { + // 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) + // }} + /> +
+ ) +} diff --git a/examples/tldraw-example/src/multiplayer-with-images/useMultiplayerState.ts b/examples/tldraw-example/src/multiplayer-with-images/useMultiplayerState.ts new file mode 100644 index 000000000..3999267bf --- /dev/null +++ b/examples/tldraw-example/src/multiplayer-with-images/useMultiplayerState.ts @@ -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() + const [error, setError] = React.useState() + const [loading, setLoading] = React.useState(true) + + const room = useRoom() + const onUndo = useUndo() + const onRedo = useRedo() + const updateMyPresence = useUpdateMyPresence() + + const rLiveShapes = React.useRef>() + const rLiveBindings = React.useRef>() + + // 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, + bindings: Record + ) => { + 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() + + // Initialize (get or create) shapes and bindings maps + + let lShapes: LiveMap = storage.root.get('shapes') + if (!lShapes) { + storage.root.set('shapes', new LiveMap()) + lShapes = storage.root.get('shapes') + } + rLiveShapes.current = lShapes + + let lBindings: LiveMap = storage.root.get('bindings') + if (!lBindings) { + storage.root.set('bindings', new LiveMap()) + 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, + } +} diff --git a/examples/tldraw-example/src/multiplayer/multiplayer.tsx b/examples/tldraw-example/src/multiplayer/multiplayer.tsx index f98101c78..9a15a48ec 100644 --- a/examples/tldraw-example/src/multiplayer/multiplayer.tsx +++ b/examples/tldraw-example/src/multiplayer/multiplayer.tsx @@ -24,12 +24,11 @@ export function Multiplayer() { function Editor({ roomId }: { roomId: string }) { const { error, ...events } = useMultiplayerState(roomId) - if (error) return
Error: {error.message}
return (
- +
) } diff --git a/examples/tldraw-example/src/multiplayer/useMultiplayerState.ts b/examples/tldraw-example/src/multiplayer/useMultiplayerState.ts index e936ed495..3999267bf 100644 --- a/examples/tldraw-example/src/multiplayer/useMultiplayerState.ts +++ b/examples/tldraw-example/src/multiplayer/useMultiplayerState.ts @@ -4,7 +4,6 @@ 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' -import { Utils } from '@tldraw/core' declare const window: Window & { app: TldrawApp } @@ -188,7 +187,6 @@ export function useMultiplayerState(roomId: string) { // Update the document with initial content handleChanges() - setLoading(false) } } diff --git a/packages/core/src/components/Bounds/CenterHandle.tsx b/packages/core/src/components/Bounds/CenterHandle.tsx index 55e8a0ffa..62c915040 100644 --- a/packages/core/src/components/Bounds/CenterHandle.tsx +++ b/packages/core/src/components/Bounds/CenterHandle.tsx @@ -15,7 +15,7 @@ export const CenterHandle = observer(function CenterHandle({ }): JSX.Element { return ( (function CloneButton({ targetSize, size, }: CloneButtonProps) { + const s = targetSize * 2 const x = { - left: -44, - topLeft: -44, - bottomLeft: -44, - right: bounds.width + 44, - topRight: bounds.width + 44, - bottomRight: bounds.width + 44, - top: bounds.width / 2, - bottom: bounds.width / 2, + left: -s, + topLeft: -s, + bottomLeft: -s, + right: bounds.width, + topRight: bounds.width, + bottomRight: bounds.width, + top: bounds.width / 2 - s / 2, + bottom: bounds.width / 2 - s / 2, }[side] const y = { - left: bounds.height / 2, - right: bounds.height / 2, - top: -44, - topLeft: -44, - topRight: -44, - bottom: bounds.height + 44, - bottomLeft: bounds.height + 44, - bottomRight: bounds.height + 44, + left: bounds.height / 2 - s / 2, + right: bounds.height / 2 - s / 2, + top: -s * 2, + topLeft: -s, + topRight: -s, + bottom: bounds.height, + bottomLeft: bounds.height, + bottomRight: bounds.height, }[side] const { callbacks, inputs } = useTLContext() @@ -62,17 +63,11 @@ export const CloneButton = observer(function CloneButton({ return ( - + { const cloneBtn = screen.getByLabelText('clone button') - expect(cloneBtn).toHaveAttribute('transform', 'translate(50, -44)') + expect(cloneBtn).toHaveAttribute('transform', 'translate(30, -80)') // transparent rect const rect = cloneBtn.querySelector('rect') - expect(rect).toHaveAttribute('height', '80') - expect(rect).toHaveAttribute('width', '80') - expect(rect).toHaveAttribute('x', '-40') - expect(rect).toHaveAttribute('y', '-40') + expect(rect).toHaveAttribute('height', '40') + expect(rect).toHaveAttribute('width', '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('path')).toHaveAttribute('d', 'M -5,-5 L 5,0 -5,5 Z') }) diff --git a/packages/core/src/components/Canvas/Canvas.test.tsx b/packages/core/src/components/Canvas/Canvas.test.tsx index a1e0d4da1..e6e8ff441 100644 --- a/packages/core/src/components/Canvas/Canvas.test.tsx +++ b/packages/core/src/components/Canvas/Canvas.test.tsx @@ -19,6 +19,7 @@ describe('page', () => { onBoundsChange={() => { // noop }} + assets={{}} /> ) }) diff --git a/packages/core/src/components/Canvas/Canvas.tsx b/packages/core/src/components/Canvas/Canvas.tsx index fb88fa190..943e04373 100644 --- a/packages/core/src/components/Canvas/Canvas.tsx +++ b/packages/core/src/components/Canvas/Canvas.tsx @@ -9,7 +9,16 @@ import { useCameraCss, useKeyEvents, } 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 { Page } from '~components/Page' import { Users } from '~components/Users' @@ -20,13 +29,10 @@ import { SnapLines } from '~components/SnapLines/SnapLines' import { Grid } from '~components/Grid' import { Overlay } from '~components/Overlay' -function resetError() { - void null -} - interface CanvasProps> { page: TLPage pageState: TLPageState + assets: TLAssets snapLines?: TLSnapLine[] grid?: number users?: TLUsers @@ -52,6 +58,7 @@ export const Canvas = observer(function _Canvas< id, page, pageState, + assets, snapLines, grid, users, @@ -96,6 +103,7 @@ export const Canvas = observer(function _Canvas< {() => (
- {children} +
{children}
)} diff --git a/packages/core/src/components/Page/Page.tsx b/packages/core/src/components/Page/Page.tsx index 106077f4b..2659d89a0 100644 --- a/packages/core/src/components/Page/Page.tsx +++ b/packages/core/src/components/Page/Page.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { observer } from 'mobx-react-lite' 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 { Bounds } from '~components/Bounds' import { BoundsBg } from '~components/Bounds/BoundsBg' @@ -13,6 +13,7 @@ import type { TLShapeUtil } from '~TLShapeUtil' interface PageProps> { page: TLPage pageState: TLPageState + assets: TLAssets hideBounds: boolean hideHandles: boolean hideIndicators: boolean @@ -29,6 +30,7 @@ interface PageProps> { export const Page = observer(function _Page>({ page, pageState, + assets, hideBounds, hideHandles, hideIndicators, @@ -40,30 +42,29 @@ export const Page = observer(function _Page): JSX.Element { 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 { selectedIds, hoveredId, + editingId, camera: { zoom }, } = pageState let _hideCloneHandles = true + let _isEditing = false // Does the selected shape have handles? let shapeWithHandles: TLShape | undefined = undefined - const selectedShapes = selectedIds.map((id) => page.shapes[id]) if (selectedShapes.length === 1) { const shape = selectedShapes[0] - + _isEditing = editingId === shape.id const utils = shapeUtils[shape.type] as TLShapeUtil - _hideCloneHandles = hideCloneHandles || !utils.showCloneHandles - if (shape.handles !== undefined) { shapeWithHandles = shape } @@ -82,9 +83,10 @@ export const Page = observer(function _Page ))} - {!hideIndicators && hoveredId && ( + {!hideIndicators && hoveredId && hoveredId !== editingId && ( extends Partial { test('mounts component without crashing', () => { renderWithSvg( - + ) }) }) diff --git a/packages/core/src/components/ShapeIndicator/ShapeIndicator.tsx b/packages/core/src/components/ShapeIndicator/ShapeIndicator.tsx index 65b61180d..510f12f96 100644 --- a/packages/core/src/components/ShapeIndicator/ShapeIndicator.tsx +++ b/packages/core/src/components/ShapeIndicator/ShapeIndicator.tsx @@ -8,12 +8,14 @@ interface IndicatorProps { meta: M extends unknown ? M : undefined isSelected?: boolean isHovered?: boolean + isEditing?: boolean user?: TLUser } export const ShapeIndicator = observer(function ShapeIndicator({ isHovered = false, isSelected = false, + isEditing = false, shape, user, meta, @@ -26,9 +28,12 @@ export const ShapeIndicator = observer(function ShapeIndicator diff --git a/packages/core/src/hooks/useBoundsHandleEvents.tsx b/packages/core/src/hooks/useBoundsHandleEvents.tsx index 210239bbd..d088e829f 100644 --- a/packages/core/src/hooks/useBoundsHandleEvents.tsx +++ b/packages/core/src/hooks/useBoundsHandleEvents.tsx @@ -11,10 +11,10 @@ export function useBoundsHandleEvents( (e: React.PointerEvent) => { if (e.button !== 0) return if (!inputs.pointerIsValid(e)) return - e.stopPropagation() - e.currentTarget?.setPointerCapture(e.pointerId) const info = inputs.pointerDown(e, id) - + if (inputs.isDoubleClick() && !(info.altKey || info.metaKey)) { + callbacks.onDoubleClickBoundsHandle?.(info, e) + } callbacks.onPointBoundsHandle?.(info, e) callbacks.onPointerDown?.(info, e) }, @@ -25,18 +25,7 @@ export function useBoundsHandleEvents( (e: React.PointerEvent) => { if (e.button !== 0) return if (!inputs.pointerIsValid(e)) return - e.stopPropagation() - const isDoubleClick = inputs.isDoubleClick() 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.onPointerUp?.(info, e) }, @@ -47,7 +36,6 @@ export function useBoundsHandleEvents( (e: React.PointerEvent) => { if (!inputs.pointerIsValid(e)) return e.stopPropagation() - if (e.currentTarget.hasPointerCapture(e.pointerId)) { callbacks.onDragBoundsHandle?.(inputs.pointerMove(e, id), e) } diff --git a/packages/core/src/hooks/useCanvasEvents.tsx b/packages/core/src/hooks/useCanvasEvents.tsx index c9f87c650..2dff7fa7a 100644 --- a/packages/core/src/hooks/useCanvasEvents.tsx +++ b/packages/core/src/hooks/useCanvasEvents.tsx @@ -61,5 +61,7 @@ export function useCanvasEvents() { onPointerDown, onPointerMove, onPointerUp, + onDrop: callbacks.onDrop, + onDragOver: callbacks.onDragOver, } } diff --git a/packages/core/src/hooks/useShapeTree.tsx b/packages/core/src/hooks/useShapeTree.tsx index 160a20693..5dfcf410d 100644 --- a/packages/core/src/hooks/useShapeTree.tsx +++ b/packages/core/src/hooks/useShapeTree.tsx @@ -1,7 +1,15 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-non-null-assertion */ 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 { Vec } from '@tldraw/vec' import { useTLContext } from '~hooks' @@ -13,6 +21,7 @@ function addToShapeTree>( pageState: TLPageState & { bindingTargetId?: string | null }, + assets: TLAssets, isChildOfGhost = false, isChildOfSelected = false, meta?: M @@ -20,6 +29,7 @@ function addToShapeTree>( // Create a node for this shape const node: IShapeTreeNode = { shape, + asset: shape.assetId ? assets[shape.assetId] : undefined, meta: meta as any, isChildOfSelected, isGhost: shape.isGhost || isChildOfGhost, @@ -54,6 +64,7 @@ function addToShapeTree>( node.children!, shapes, pageState, + assets, node.isGhost, node.isSelected || node.isChildOfSelected, meta @@ -69,6 +80,7 @@ function shapeIsInViewport(bounds: TLBounds, viewport: TLBounds) { export function useShapeTree>( page: TLPage, pageState: TLPageState, + assets: TLAssets, meta?: M ) { const { callbacks, shapeUtils, bounds } = useTLContext() @@ -154,6 +166,7 @@ export function useShapeTree + +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 = Partial<{ [P in keyof T]: T | Partial | Patch }> export type TLForwardedRef = @@ -57,6 +83,7 @@ export interface TLShape { childIndex: number name: string point: number[] + assetId?: string rotation?: number children?: string[] handles?: Record @@ -69,6 +96,7 @@ export interface TLShape { export interface TLComponentProps { shape: T + asset?: TLAsset isEditing: boolean isBinding: boolean isHovered: boolean @@ -117,6 +145,8 @@ export type TLWheelEventHandler = ( e: React.WheelEvent | WheelEvent ) => void +export type TLDropEventHandler = (e: React.DragEvent) => void + export type TLPinchEventHandler = ( info: TLPointerInfo, e: @@ -176,6 +206,8 @@ export interface TLCallbacks { onRightPointCanvas: TLCanvasEventHandler onDragCanvas: TLCanvasEventHandler onReleaseCanvas: TLCanvasEventHandler + onDragOver: TLDropEventHandler + onDrop: TLDropEventHandler // Shape onPointShape: TLPointerEventHandler @@ -314,6 +346,7 @@ export type Snap = export interface IShapeTreeNode { shape: T + asset?: TLAsset children?: IShapeTreeNode[] isGhost: boolean isChildOfSelected: boolean diff --git a/packages/tldraw/src/Tldraw.tsx b/packages/tldraw/src/Tldraw.tsx index bc4141671..770bbbd3d 100644 --- a/packages/tldraw/src/Tldraw.tsx +++ b/packages/tldraw/src/Tldraw.tsx @@ -11,6 +11,7 @@ import { ContextMenu } from '~components/ContextMenu' import { FocusButton } from '~components/FocusButton' import { TLDR } from '~state/TLDR' import { GRID_SIZE } from '~constants' +import { Loading } from '~components/Loading' export interface TldrawProps extends TDCallbacks { /** @@ -78,6 +79,14 @@ export interface TldrawProps extends TDCallbacks { */ 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. */ @@ -142,6 +151,16 @@ export interface TldrawProps extends TDCallbacks { */ 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 + + /** + * (optional) A callback to run when the user deletes an image or video. + */ + onImageDelete?: (id: string) => void + onChangePage?: ( app: TldrawApp, shapes: Record, @@ -153,7 +172,6 @@ export function Tldraw({ id, document, currentPageId, - darkMode = false, autofocus = true, showMenu = true, showPages = true, @@ -163,6 +181,7 @@ export function Tldraw({ showUI = true, readOnly = false, showSponsorLink = false, + disableAssets = false, onMount, onChange, onChangePresence, @@ -170,6 +189,7 @@ export function Tldraw({ onSaveProject, onSaveProjectAs, onOpenProject, + onOpenMedia, onSignOut, onSignIn, onUndo, @@ -178,30 +198,35 @@ export function Tldraw({ onPatch, onCommand, onChangePage, + onImageCreate, + onImageDelete, }: TldrawProps) { const [sId, setSId] = React.useState(id) // Create a new app when the component mounts. - const [app, setApp] = React.useState( - () => - new TldrawApp(id, { - onMount, - onChange, - onChangePresence, - onNewProject, - onSaveProject, - onSaveProjectAs, - onOpenProject, - onSignOut, - onSignIn, - onUndo, - onRedo, - onPersist, - onPatch, - onCommand, - onChangePage, - }) - ) + const [app, setApp] = React.useState(() => { + const app = new TldrawApp(id, { + onMount, + onChange, + onChangePresence, + onNewProject, + onSaveProject, + onSaveProjectAs, + onOpenProject, + onOpenMedia, + onSignOut, + onSignIn, + onUndo, + onRedo, + onPersist, + onPatch, + onCommand, + onChangePage, + onImageDelete, + onImageCreate, + }) + return app + }) // Create a new app if the `id` prop changes. React.useEffect(() => { @@ -215,6 +240,7 @@ export function Tldraw({ onSaveProject, onSaveProjectAs, onOpenProject, + onOpenMedia, onSignOut, onSignIn, onUndo, @@ -223,10 +249,10 @@ export function Tldraw({ onPatch, onCommand, onChangePage, + onImageDelete, + onImageCreate, }) - setSId(id) - setApp(newApp) }, [sId, id]) @@ -234,7 +260,6 @@ export function Tldraw({ // are the same, or else load a new document if the ids are different. React.useEffect(() => { if (!document) return - if (document.id === app.document.id) { app.updateDocument(document) } else { @@ -242,24 +267,22 @@ export function Tldraw({ } }, [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(() => { if (!currentPageId) return app.changePage(currentPageId) }, [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(() => { app.readOnly = 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. React.useEffect(() => { app.callbacks = { @@ -270,6 +293,7 @@ export function Tldraw({ onSaveProject, onSaveProjectAs, onOpenProject, + onOpenMedia, onSignOut, onSignIn, onUndo, @@ -278,6 +302,8 @@ export function Tldraw({ onPatch, onCommand, onChangePage, + onImageDelete, + onImageCreate, } }, [ onMount, @@ -287,6 +313,7 @@ export function Tldraw({ onSaveProject, onSaveProjectAs, onOpenProject, + onOpenMedia, onSignOut, onSignIn, onUndo, @@ -295,6 +322,8 @@ export function Tldraw({ onPatch, onCommand, onChangePage, + onImageDelete, + onImageCreate, ]) // 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 pageState = document.pageStates[page.id] + const assets = document.assets const { selectedIds } = pageState const isHideBoundsShape = @@ -366,22 +396,6 @@ const InnerTldraw = React.memo(function InnerTldraw({ page.shapes[selectedIds[0]] && 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 const meta = React.useMemo(() => { return { isDarkMode: settings.isDarkMode } @@ -414,8 +428,28 @@ const InnerTldraw = React.memo(function InnerTldraw({ 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 ( + {showUI && ( diff --git a/packages/tldraw/src/components/Loading/Loading.tsx b/packages/tldraw/src/components/Loading/Loading.tsx new file mode 100644 index 000000000..7c7f66ac1 --- /dev/null +++ b/packages/tldraw/src/components/Loading/Loading.tsx @@ -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 +} + +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%)`, + }, + }, + }, +}) diff --git a/packages/tldraw/src/components/Loading/index.ts b/packages/tldraw/src/components/Loading/index.ts new file mode 100644 index 000000000..71170faae --- /dev/null +++ b/packages/tldraw/src/components/Loading/index.ts @@ -0,0 +1 @@ +export { Loading } from './Loading' diff --git a/packages/tldraw/src/components/TopPanel/Menu/Menu.tsx b/packages/tldraw/src/components/TopPanel/Menu/Menu.tsx index 10b680a6c..ffc9bf5a4 100644 --- a/packages/tldraw/src/components/TopPanel/Menu/Menu.tsx +++ b/packages/tldraw/src/components/TopPanel/Menu/Menu.tsx @@ -16,6 +16,7 @@ import { HeartIcon } from '~components/Primitives/icons/HeartIcon' import { preventEvent } from '~components/preventEvent' import { DiscordIcon } from '~components/Primitives/icons' import type { TDSnapshot } from '~types' +import { Divider } from '~components/Primitives/Divider' interface MenuProps { showSponsorLink: boolean @@ -26,9 +27,14 @@ const numberOfSelectedIdsSelector = (s: TDSnapshot) => { 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) { const app = useTldrawApp() const numberOfSelectedIds = app.useStore(numberOfSelectedIdsSelector) + const disableAssets = app.useStore(disableAssetsSelector) const { onNewProject, onOpenProject, onSaveProject, onSaveProjectAs } = useFileSystemHandlers() @@ -64,10 +70,14 @@ export const Menu = React.memo(function Menu({ showSponsorLink, readOnly }: Menu app.selectAll() }, [app]) - const handleselectNone = React.useCallback(() => { + const handleSelectNone = React.useCallback(() => { app.selectNone() }, [app]) + const handleUploadMedia = React.useCallback(() => { + app.openAsset() + }, [app]) + const showFileMenu = app.callbacks.onNewProject || app.callbacks.onOpenProject || @@ -106,6 +116,14 @@ export const Menu = React.memo(function Menu({ showSponsorLink, readOnly }: Menu Save As... )} + {!disableAssets && ( + <> + + + Upload Media + + + )} )} {!readOnly && ( @@ -148,7 +166,7 @@ export const Menu = React.memo(function Menu({ showSponsorLink, readOnly }: Menu Select All - + Select None diff --git a/packages/tldraw/src/components/TopPanel/StyleMenu/StyleMenu.tsx b/packages/tldraw/src/components/TopPanel/StyleMenu/StyleMenu.tsx index 75eca51fc..d35438435 100644 --- a/packages/tldraw/src/components/TopPanel/StyleMenu/StyleMenu.tsx +++ b/packages/tldraw/src/components/TopPanel/StyleMenu/StyleMenu.tsx @@ -33,7 +33,6 @@ import { TextAlignLeftIcon, TextAlignRightIcon, } from '@radix-ui/react-icons' -import { RowButton } from '~components/Primitives/RowButton' const currentStyleSelector = (s: TDSnapshot) => s.appState.currentStyle const selectedIdsSelector = (s: TDSnapshot) => @@ -290,27 +289,6 @@ const ColorGrid = styled('div', { 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', { position: 'relative', width: '100%', diff --git a/packages/tldraw/src/constants.ts b/packages/tldraw/src/constants.ts index 71fb1bc4b..02a9124ae 100644 --- a/packages/tldraw/src/constants.ts +++ b/packages/tldraw/src/constants.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ export const GRID_SIZE = 8 +export const SVG_EXPORT_PADDING = 16 export const BINDING_DISTANCE = 16 export const CLONING_DISTANCE = 32 export const FIT_TO_SCREEN_PADDING = 128 @@ -79,3 +80,8 @@ export const USER_COLORS = [ '#55B467', '#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'] diff --git a/packages/tldraw/src/hooks/useFileSystem.ts b/packages/tldraw/src/hooks/useFileSystem.ts index e2a2350e3..a2849266e 100644 --- a/packages/tldraw/src/hooks/useFileSystem.ts +++ b/packages/tldraw/src/hooks/useFileSystem.ts @@ -42,10 +42,15 @@ export function useFileSystem() { [promptSaveBeforeChange] ) + const onOpenMedia = React.useCallback(async (app: TldrawApp) => { + app.openAsset?.() + }, []) + return { onNewProject, onSaveProject, onSaveProjectAs, onOpenProject, + onOpenMedia, } } diff --git a/packages/tldraw/src/hooks/useFileSystemHandlers.ts b/packages/tldraw/src/hooks/useFileSystemHandlers.ts index a8717b542..369e37448 100644 --- a/packages/tldraw/src/hooks/useFileSystemHandlers.ts +++ b/packages/tldraw/src/hooks/useFileSystemHandlers.ts @@ -36,10 +36,19 @@ export function useFileSystemHandlers() { [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 { onNewProject, onSaveProject, onSaveProjectAs, onOpenProject, + onOpenMedia, } } diff --git a/packages/tldraw/src/hooks/useKeyboardShortcuts.tsx b/packages/tldraw/src/hooks/useKeyboardShortcuts.tsx index 918677ef4..e3e8635c1 100644 --- a/packages/tldraw/src/hooks/useKeyboardShortcuts.tsx +++ b/packages/tldraw/src/hooks/useKeyboardShortcuts.tsx @@ -9,7 +9,7 @@ export function useKeyboardShortcuts(ref: React.RefObject) { const canHandleEvent = React.useCallback( (ignoreMenus = false) => { 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)) }, [ref] @@ -155,7 +155,8 @@ export function useKeyboardShortcuts(ref: React.RefObject) { // File System - const { onNewProject, onOpenProject, onSaveProject, onSaveProjectAs } = useFileSystemHandlers() + const { onNewProject, onOpenProject, onSaveProject, onSaveProjectAs, onOpenMedia } = + useFileSystemHandlers() useHotkeys( 'ctrl+n,⌘+n', @@ -198,6 +199,15 @@ export function useKeyboardShortcuts(ref: React.RefObject) { undefined, [app] ) + useHotkeys( + 'ctrl+u,⌘+u', + (e) => { + if (!canHandleEvent()) return + onOpenMedia(e) + }, + undefined, + [app] + ) // Undo Redo diff --git a/packages/tldraw/src/state/StateManager/StateManager.ts b/packages/tldraw/src/state/StateManager/StateManager.ts index e46f580d2..57c49a70e 100644 --- a/packages/tldraw/src/state/StateManager/StateManager.ts +++ b/packages/tldraw/src/state/StateManager/StateManager.ts @@ -110,8 +110,6 @@ export class StateManager> { this._status = 'ready' resolve(message) } - - resolve(message) }).then((message) => { if (this.onReady) this.onReady(message) return message diff --git a/packages/tldraw/src/state/TldrawApp.spec.ts b/packages/tldraw/src/state/TldrawApp.spec.ts index 8ac1774ef..9e2093401 100644 --- a/packages/tldraw/src/state/TldrawApp.spec.ts +++ b/packages/tldraw/src/state/TldrawApp.spec.ts @@ -6,7 +6,7 @@ import type { SelectTool } from './tools/SelectTool' describe('TldrawTestApp', () => { describe('When copying and pasting...', () => { it('copies a shape', () => { - const app = new TldrawTestApp().loadDocument(mockDocument).selectNone().copy(['rect1']) + new TldrawTestApp().loadDocument(mockDocument).selectNone().copy(['rect1']) }) it('pastes a shape', () => { @@ -43,6 +43,8 @@ describe('TldrawTestApp', () => { expect(Object.keys(app.page.shapes).length).toBe(1) }) + it.todo('Copies and pastes a shape with an asset') + it('Copies grouped shapes.', () => { const app = new TldrawTestApp() .loadDocument(mockDocument) @@ -581,10 +583,10 @@ describe('TldrawTestApp', () => { it('Respects child index', () => { const result = new TldrawTestApp() - .loadDocument(mockDocument) - .moveToBack(['rect2']) - .selectAll() - .copySvg() + .loadDocument(mockDocument) + .moveToBack(['rect2']) + .selectAll() + .copySvg() expect(result).toMatchSnapshot('copied svg with reordered elements') }) @@ -710,3 +712,13 @@ describe('TldrawTestApp', () => { .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') +}) diff --git a/packages/tldraw/src/state/TldrawApp.ts b/packages/tldraw/src/state/TldrawApp.ts index d94a58037..393dac8a0 100644 --- a/packages/tldraw/src/state/TldrawApp.ts +++ b/packages/tldraw/src/state/TldrawApp.ts @@ -14,6 +14,9 @@ import { TLWheelEventHandler, Utils, TLBounds, + TLDropEventHandler, + TLAssetType, + TLAsset, } from '@tldraw/core' import { FlipType, @@ -41,6 +44,9 @@ import { loadFileHandle, openFromFileSystem, saveToFileSystem, + openAssetFromFileSystem, + fileToBase64, + getSizeFromDataurl, } from './data' import { TLDR } from './TLDR' import { shapeUtils } from '~state/shapes' @@ -48,7 +54,14 @@ import { defaultStyle } from '~state/shapes/shared/shape-styles' import * as Commands from './commands' import { SessionArgsOfType, getSession, TldrawSession } from './sessions' 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 { EraseTool } from './tools/EraseTool' 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. */ 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. */ @@ -128,6 +145,9 @@ export interface TDCallbacks { * (optional) A callback to run when the user creates a new project. */ onChangePresence?: (state: TldrawApp, user: TDUser) => void + + onImageDelete?: (id: string) => void + onImageCreate?: (file: File, id: string) => Promise } export class TldrawApp extends StateManager { @@ -194,6 +214,7 @@ export class TldrawApp extends StateManager { clipboard?: { shapes: TDShape[] bindings: TDBinding[] + assets: TLAsset[] } rotationInfo = { @@ -262,6 +283,8 @@ export class TldrawApp extends StateManager { protected cleanup = (state: TDSnapshot, prev: TDSnapshot): TDSnapshot => { const next = { ...state } + const assetIdsInUse = new Set([]) + // Remove deleted shapes and bindings (in Commands, these will be set to undefined) if (next.document !== prev.document) { Object.entries(next.document.pages).forEach(([pageId, page]) => { @@ -290,6 +313,7 @@ export class TldrawApp extends StateManager { parentId = prevPage?.shapes[id]?.parentId delete page.shapes[id] } else { + if (shape.assetId) assetIdsInUse.add(shape.assetId) parentId = shape.parentId } @@ -407,6 +431,8 @@ export class TldrawApp extends StateManager { }) } + // Cleanup assets + const currentPageId = next.appState.currentPageId const currentPageState = next.document.pageStates[currentPageId] @@ -973,7 +999,32 @@ export class TldrawApp extends StateManager { 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. @@ -1051,6 +1102,7 @@ export class TldrawApp extends StateManager { .clearSelectHistory() .loadDocument(migrate(TldrawApp.defaultDocument, TldrawApp.version)) .persist() + return this } @@ -1258,6 +1310,7 @@ export class TldrawApp extends StateManager { appState: { ...TldrawApp.defaultState.appState, currentPageId: Object.keys(document.pages)[0], + disableAssets: this.disableAssets, }, }, 'loaded_document' @@ -1282,7 +1335,10 @@ export class TldrawApp extends StateManager { saveProject = async () => { if (this.readOnly) return try { - const fileHandle = await saveToFileSystem(this.document, this.fileSystemHandle) + const fileHandle = await saveToFileSystem( + migrate(this.document, TldrawApp.version), + this.fileSystemHandle + ) this.fileSystemHandle = fileHandle this.persist() this.isDirty = false @@ -1334,6 +1390,23 @@ export class TldrawApp extends StateManager { } } + /** + * 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. * Should move to the www layer. @@ -1560,28 +1633,29 @@ export class TldrawApp extends StateManager { const copyingShapeIds = ids.flatMap((id) => TLDR.getDocumentBranch(this.state, id, this.currentPageId) ) - const copyingShapes = copyingShapeIds.map((id) => Utils.deepClone(this.getShape(id, this.currentPageId)) ) - if (copyingShapes.length === 0) return this - const copyingBindings: TDBinding[] = Object.values(this.page.bindings).filter( (binding) => 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 = { shapes: copyingShapes, bindings: copyingBindings, + assets: copyingAssets, } - try { const text = JSON.stringify({ type: 'tldr/clipboard', - shapes: copyingShapes, - bindings: copyingBindings, + ...this.clipboard, }) navigator.clipboard.writeText(text).then( @@ -1595,10 +1669,8 @@ export class TldrawApp extends StateManager { } catch (e) { // Browser does not support copying to clipboard } - this.pasteInfo.offset = [0, 0] this.pasteInfo.center = [0, 0] - return this } @@ -1618,35 +1690,35 @@ export class TldrawApp extends StateManager { */ paste = (point?: number[]) => { if (this.readOnly) return - const pasteInCurrentPage = (shapes: TDShape[], bindings: TDBinding[]) => { + const pasteInCurrentPage = (shapes: TDShape[], bindings: TDBinding[], assets: TLAsset[]) => { const idsMap: Record = {} - + 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())) - bindings.forEach((binding) => (idsMap[binding.id] = Utils.uniqueId())) - let startIndex = TLDR.getTopChildIndex(this.state, this.currentPageId) - const shapesToPaste = shapes .sort((a, b) => a.childIndex - b.childIndex) .map((shape) => { const parentShapeId = idsMap[shape.parentId] - const copy = { ...shape, id: idsMap[shape.id], parentId: parentShapeId || this.currentPageId, } - if (shape.children) { copy.children = shape.children.map((id) => idsMap[id]) } - if (!parentShapeId) { copy.childIndex = startIndex startIndex++ } - if (copy.handles) { Object.values(copy.handles).forEach((handle) => { if (handle.bindingId) { @@ -1654,21 +1726,16 @@ export class TldrawApp extends StateManager { } }) } - return copy }) - const bindingsToPaste = bindings.map((binding) => ({ ...binding, id: idsMap[binding.id], toId: idsMap[binding.toId], fromId: idsMap[binding.fromId], })) - const commonBounds = Utils.getCommonBounds(shapesToPaste.map(TLDR.getBounds)) - let center = Vec.toFixed(this.getPagePoint(point || this.centerPoint)) - if ( Vec.dist(center, this.pasteInfo.center) < 2 || Vec.dist(center, Vec.toFixed(Utils.getBoundsCenter(commonBounds))) < 2 @@ -1679,14 +1746,11 @@ export class TldrawApp extends StateManager { this.pasteInfo.center = center this.pasteInfo.offset = [0, 0] } - const centeredBounds = Utils.centerBounds(commonBounds, center) - const delta = Vec.sub( Utils.getBoundsCenter(centeredBounds), Utils.getBoundsCenter(commonBounds) ) - this.create( shapesToPaste.map((shape) => TLDR.getShapeUtil(shape.type).create({ @@ -1705,19 +1769,19 @@ export class TldrawApp extends StateManager { navigator.clipboard.readText().then((result) => { try { - const data: { type: string; shapes: TDShape[]; bindings: TDBinding[] } = - JSON.parse(result) - + const data: { + type: string + shapes: TDShape[] + bindings: TDBinding[] + assets: TLAsset[] + } = JSON.parse(result) if (data.type !== 'tldr/clipboard') { throw Error('The pasted string was not from the Tldraw clipboard.') } - - pasteInCurrentPage(data.shapes, data.bindings) + pasteInCurrentPage(data.shapes, data.bindings, data.assets) } catch (e) { TLDR.warn(e) - const shapeId = Utils.uniqueId() - this.createShapes({ id: shapeId, type: TDShapeType.Text, @@ -1726,7 +1790,6 @@ export class TldrawApp extends StateManager { point: this.getPagePoint(this.centerPoint, this.currentPageId), style: { ...this.appState.currentStyle }, }) - this.select(shapeId) } }) @@ -1734,7 +1797,7 @@ export class TldrawApp extends StateManager { // Navigator does not support clipboard. Note that this fallback will // not support pasting from one document to another. 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 { copySvg = (ids = this.selectedIds, pageId = this.currentPageId) => { if (ids.length === 0) ids = Object.keys(this.page.shapes) 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') + // Embed our custom fonts const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs') 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');` defs.appendChild(style) svg.appendChild(defs) - - function getSvgElementForShape(shape: TDShape) { + // Get the shapes in order + 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 element = util.getSvgElement(shape) const bounds = util.getBounds(shape) - - if (!element) return - - element.setAttribute( + const elm = util.getSvgElement(shape) + if (!elm) return + // If the element is an image, set the asset src as the xlinkhref + 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', - `translate(${padding + shape.point[0] - commonBounds.minX}, ${ - padding + shape.point[1] - commonBounds.minY + `translate(${SVG_EXPORT_PADDING + shape.point[0] - commonBounds.minX}, ${ + SVG_EXPORT_PADDING + shape.point[1] - commonBounds.minY }) rotate(${((shape.rotation || 0) * 180) / Math.PI}, ${bounds.width / 2}, ${ bounds.height / 2 })` ) - - return element + return elm } - + // Assemble the final SVG by iterating through each shape and its children shapes.forEach((shape) => { + // The shape is a group! Just add the children. if (shape.children?.length) { - // Create a group element for shape + // Create a group elm for shape const g = document.createElementNS('http://www.w3.org/2000/svg', 'g') - - // Get the shape's children as elements - shape.children - .map((childId) => this.getShape(childId, pageId)) - .map(getSvgElementForShape) - .filter(Boolean) - .forEach((element) => g.appendChild(element!)) - - // Add the group element to the SVG + // Get the shape's children as elms and add them to the group + shape.children.forEach((childId) => { + const shape = this.getShape(childId, pageId) + const elm = getSvgElementForShape(shape) + if (elm) g.appendChild(elm) + }) + // Add the group elm to the SVG svg.appendChild(g) - return } - - const element = getSvgElementForShape(shape) - - if (element) { - svg.appendChild(element) - } + // Just add the shape's element to the + const elm = getSvgElementForShape(shape) + if (elm) svg.appendChild(elm) }) - - // Resize the element to the bounding box + // Resize the elm to the bounding box svg.setAttribute( '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('height', String(commonBounds.height)) svg.setAttribute('fill', 'transparent') + // Clean up the SVG by removing any hidden elements svg .querySelectorAll('.tl-fill-hitarea, .tl-stroke-hitarea, .tl-binding-indicator') - .forEach((element) => element.remove()) - - const s = new XMLSerializer() - - const svgString = s + .forEach((elm) => elm.remove()) + // Serialize the SVG to a string + const svgString = new XMLSerializer() .serializeToString(svg) .replaceAll(' ', '') .replaceAll(/((\s|")[0-9]*\.[0-9]{2})([0-9]*)(\b|"|\))/g, '$1') - + // Copy the string to the clipboard TLDR.copyStringToClipboard(svgString) - return svgString } @@ -1843,7 +1903,6 @@ export class TldrawApp extends StateManager { copyJson = (ids = this.selectedIds, pageId = this.currentPageId) => { if (ids.length === 0) ids = Object.keys(this.page.shapes) if (ids.length === 0) return - const shapes = ids.map((id) => this.getShape(id, pageId)) const json = JSON.stringify(shapes, null, 2) TLDR.copyStringToClipboard(json) @@ -1872,7 +1931,6 @@ export class TldrawApp extends StateManager { }, reason ) - return this } @@ -2405,6 +2463,44 @@ export class TldrawApp extends StateManager { 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. * @param shapes An array of shapes. @@ -2431,6 +2527,14 @@ export class TldrawApp extends StateManager { * @command */ 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 return this.setState(Commands.deleteShapes(this, ids)) } @@ -2701,6 +2805,52 @@ export class TldrawApp extends StateManager { 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 */ /* -------------------------------------------------- */ @@ -2821,6 +2971,20 @@ export class TldrawApp extends StateManager { /* ------------- 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) onPinchEnd: TLPinchEventHandler = (info, e) => this.currentTool.onPinchEnd?.(info, e) @@ -3014,6 +3178,21 @@ export class TldrawApp extends StateManager { this.originPoint = this.getPagePoint(info.point) this.updateInputs(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) => { @@ -3181,12 +3360,12 @@ export class TldrawApp extends StateManager { getShapeUtil = TLDR.getShapeUtil - static version = 14 + static version = 15 static defaultDocument: TDDocument = { id: 'doc', name: 'New Document', - version: 14, + version: 15, pages: { page: { id: 'page', @@ -3206,6 +3385,7 @@ export class TldrawApp extends StateManager { }, }, }, + assets: {}, } static defaultState: TDSnapshot = { @@ -3215,6 +3395,7 @@ export class TldrawApp extends StateManager { isZoomSnap: false, isFocusMode: false, isSnapping: false, + //@ts-ignore isDebugMode: process.env.NODE_ENV === 'development', isReadonlyMode: false, nudgeDistanceLarge: 16, @@ -3234,6 +3415,8 @@ export class TldrawApp extends StateManager { isMenuOpen: false, isEmptyCanvas: false, snapLines: [], + isLoading: false, + disableAssets: false, }, document: TldrawApp.defaultDocument, } diff --git a/packages/tldraw/src/state/data/filesystem.ts b/packages/tldraw/src/state/data/filesystem.ts index e48e9720d..8f95f5532 100644 --- a/packages/tldraw/src/state/data/filesystem.ts +++ b/packages/tldraw/src/state/data/filesystem.ts @@ -1,6 +1,7 @@ import type { TDDocument, TDFile } from '~types' import { fileSave, fileOpen, FileSystemHandle } from './browser-fs-access' import { get as getFromIdb, set as setToIdb } from 'idb-keyval' +import { IMAGE_EXTENSIONS, VIDEO_EXTENSIONS } from '~constants' const options = { mode: 'readwrite' as const } @@ -96,3 +97,31 @@ export async function openFromFileSystem(): Promise { + 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 { + return new Promise((resolve) => { + const img = new Image() + img.onload = () => resolve([img.width, img.height]) + img.src = dataURL + }) +} diff --git a/packages/tldraw/src/state/data/migrate.ts b/packages/tldraw/src/state/data/migrate.ts index e6d84c733..ccd7110f7 100644 --- a/packages/tldraw/src/state/data/migrate.ts +++ b/packages/tldraw/src/state/data/migrate.ts @@ -4,6 +4,23 @@ import { Decoration, FontStyle, TDDocument, TDShapeType, TextShape } from '~type export function migrate(document: TDDocument, newVersion: number): TDDocument { const { version = 0 } = document + // Remove unused assets when loading a document + if ('assets' in document) { + const assetIdsInUse = new Set() + + 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 < 14) { @@ -51,6 +68,10 @@ export function migrate(document: TDDocument, newVersion: number): TDDocument { document.name = 'New Document' } + if (version < 15) { + document.assets = {} + } + // Cleanup Object.values(document.pageStates).forEach((pageState) => { pageState.selectedIds = pageState.selectedIds.filter((id) => { diff --git a/packages/tldraw/src/state/sessions/TranslateSession/TranslateSession.ts b/packages/tldraw/src/state/sessions/TranslateSession/TranslateSession.ts index 6389617fd..e80b3c363 100644 --- a/packages/tldraw/src/state/sessions/TranslateSession/TranslateSession.ts +++ b/packages/tldraw/src/state/sessions/TranslateSession/TranslateSession.ts @@ -12,6 +12,7 @@ import { SessionType, ArrowBinding, TldrawPatch, + TDShapeType, } from '~types' import { SLOW_SPEED, SNAP_DISTANCE } from '~constants' import { TLDR } from '~state/TLDR' @@ -632,6 +633,11 @@ export class TranslateSession extends BaseSession { 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) }) diff --git a/packages/tldraw/src/state/shapes/ImageUtil/ImageUtil.spec.tsx b/packages/tldraw/src/state/shapes/ImageUtil/ImageUtil.spec.tsx new file mode 100644 index 000000000..20677469d --- /dev/null +++ b/packages/tldraw/src/state/shapes/ImageUtil/ImageUtil.spec.tsx @@ -0,0 +1,7 @@ +import { Image } from '..' + +describe('Image shape', () => { + it('Creates a shape', () => { + expect(Image.create({ id: 'image' })).toMatchSnapshot('image') + }) +}) diff --git a/packages/tldraw/src/state/shapes/ImageUtil/ImageUtil.tsx b/packages/tldraw/src/state/shapes/ImageUtil/ImageUtil.tsx new file mode 100644 index 000000000..329cdc2a3 --- /dev/null +++ b/packages/tldraw/src/state/shapes/ImageUtil/ImageUtil.tsx @@ -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 { + type = TDShapeType.Image as const + + canBind = true + + canClone = true + + isAspectRatioLocked = true + + showCloneHandles = true + + getShape = (props: Partial): T => { + return Utils.deepMerge( + { + 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( + ({ 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(null) + const wrapperRef = React.useRef(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 ( + + {isBinding && ( +
+ )} + + + + + ) + } + ) + + Indicator = TDShapeUtil.Indicator(({ shape }) => { + const { + size: [width, height], + } = shape + + return ( + + ) + }) + + 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, +}) diff --git a/packages/tldraw/src/state/shapes/ImageUtil/__snapshots__/ImageUtil.spec.tsx.snap b/packages/tldraw/src/state/shapes/ImageUtil/__snapshots__/ImageUtil.spec.tsx.snap new file mode 100644 index 000000000..88bbfcf08 --- /dev/null +++ b/packages/tldraw/src/state/shapes/ImageUtil/__snapshots__/ImageUtil.spec.tsx.snap @@ -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", +} +`; diff --git a/packages/tldraw/src/state/shapes/ImageUtil/index.ts b/packages/tldraw/src/state/shapes/ImageUtil/index.ts new file mode 100644 index 000000000..74bb446e7 --- /dev/null +++ b/packages/tldraw/src/state/shapes/ImageUtil/index.ts @@ -0,0 +1 @@ +export * from './ImageUtil' diff --git a/packages/tldraw/src/state/shapes/TextUtil/TextUtil.tsx b/packages/tldraw/src/state/shapes/TextUtil/TextUtil.tsx index 8ef68b3a2..f8f0ed7d8 100644 --- a/packages/tldraw/src/state/shapes/TextUtil/TextUtil.tsx +++ b/packages/tldraw/src/state/shapes/TextUtil/TextUtil.tsx @@ -419,10 +419,8 @@ const commonTextWrapping = { const InnerWrapper = styled('div', { position: 'absolute', - top: 'var(--tl-padding)', - left: 'var(--tl-padding)', - width: 'calc(100% - (var(--tl-padding) * 2))', - height: 'calc(100% - (var(--tl-padding) * 2))', + width: '100%', + height: '100%', padding: '4px', zIndex: 1, minHeight: 1, diff --git a/packages/tldraw/src/state/shapes/VideoUtil/VideoUtil.spec.tsx b/packages/tldraw/src/state/shapes/VideoUtil/VideoUtil.spec.tsx new file mode 100644 index 000000000..4459e1d27 --- /dev/null +++ b/packages/tldraw/src/state/shapes/VideoUtil/VideoUtil.spec.tsx @@ -0,0 +1,7 @@ +import { Video } from '..' + +describe('Video shape', () => { + it('Creates a shape', () => { + expect(Video.create({ id: 'video' })).toMatchSnapshot('video') + }) +}) diff --git a/packages/tldraw/src/state/shapes/VideoUtil/VideoUtil.tsx b/packages/tldraw/src/state/shapes/VideoUtil/VideoUtil.tsx new file mode 100644 index 000000000..8b0ea625d --- /dev/null +++ b/packages/tldraw/src/state/shapes/VideoUtil/VideoUtil.tsx @@ -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 { + type = TDShapeType.Video as const + canBind = true + canEdit = true + canClone = true + isAspectRatioLocked = true + showCloneHandles = true + isStateful = true // don't unmount + + getShape = (props: Partial): T => { + return Utils.deepMerge( + { + 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( + ({ shape, asset, isBinding, isEditing, isGhost, meta, events, onShapeChange }, ref) => { + const rVideo = React.useRef(null) + const wrapperRef = React.useRef(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 ( + + {isBinding && ( +
+ )} + + + + + + + ) + } + ) + + Indicator = TDShapeUtil.Indicator(({ shape }) => { + const { + size: [width, height], + } = shape + + return ( + + ) + }) + + 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, +}) diff --git a/packages/tldraw/src/state/shapes/VideoUtil/__snapshots__/VideoUtil.spec.tsx.snap b/packages/tldraw/src/state/shapes/VideoUtil/__snapshots__/VideoUtil.spec.tsx.snap new file mode 100644 index 000000000..d051bec02 --- /dev/null +++ b/packages/tldraw/src/state/shapes/VideoUtil/__snapshots__/VideoUtil.spec.tsx.snap @@ -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", +} +`; diff --git a/packages/tldraw/src/state/shapes/VideoUtil/index.ts b/packages/tldraw/src/state/shapes/VideoUtil/index.ts new file mode 100644 index 000000000..8b269f887 --- /dev/null +++ b/packages/tldraw/src/state/shapes/VideoUtil/index.ts @@ -0,0 +1 @@ +export * from './VideoUtil' diff --git a/packages/tldraw/src/state/shapes/index.ts b/packages/tldraw/src/state/shapes/index.ts index d01e67adc..ce3a79d9d 100644 --- a/packages/tldraw/src/state/shapes/index.ts +++ b/packages/tldraw/src/state/shapes/index.ts @@ -7,7 +7,9 @@ import { GroupUtil } from './GroupUtil' import { StickyUtil } from './StickyUtil' import { TextUtil } from './TextUtil' import { DrawUtil } from './DrawUtil' +import { ImageUtil } from './ImageUtil' import { TDShape, TDShapeType } from '~types' +import { VideoUtil } from './VideoUtil' export const Rectangle = new RectangleUtil() export const Triangle = new TriangleUtil() @@ -17,6 +19,8 @@ export const Arrow = new ArrowUtil() export const Text = new TextUtil() export const Group = new GroupUtil() export const Sticky = new StickyUtil() +export const Image = new ImageUtil() +export const Video = new VideoUtil() export const shapeUtils = { [TDShapeType.Rectangle]: Rectangle, @@ -27,6 +31,8 @@ export const shapeUtils = { [TDShapeType.Text]: Text, [TDShapeType.Group]: Group, [TDShapeType.Sticky]: Sticky, + [TDShapeType.Image]: Image, + [TDShapeType.Video]: Video, } export const getShapeUtil = (shape: T | T['type']) => { diff --git a/packages/tldraw/src/state/tools/SelectTool/SelectTool.ts b/packages/tldraw/src/state/tools/SelectTool/SelectTool.ts index d91eacc5a..56fd73ee4 100644 --- a/packages/tldraw/src/state/tools/SelectTool/SelectTool.ts +++ b/packages/tldraw/src/state/tools/SelectTool/SelectTool.ts @@ -173,7 +173,11 @@ export class SelectTool extends BaseTool { /* ----------------- Event Handlers ----------------- */ onCancel = () => { - this.selectNone() + if (this.app.pageState.editingId) { + this.app.setEditingId() + } else { + this.selectNone() + } this.app.cancelSession() this.setStatus(Status.Idle) } @@ -372,7 +376,7 @@ export class SelectTool extends BaseTool { } } - onPointerUp: TLPointerEventHandler = (info, e) => { + onPointerUp: TLPointerEventHandler = (info) => { if (this.status === Status.MiddleWheelPanning) { this.setStatus(Status.Idle) return diff --git a/packages/tldraw/src/test/mockDocument.tsx b/packages/tldraw/src/test/mockDocument.tsx index 179ae24ed..e957a0475 100644 --- a/packages/tldraw/src/test/mockDocument.tsx +++ b/packages/tldraw/src/test/mockDocument.tsx @@ -64,4 +64,5 @@ export const mockDocument: TDDocument = { }, }, }, + assets: {}, } diff --git a/packages/tldraw/src/types.ts b/packages/tldraw/src/types.ts index 22c19064d..08c955d53 100644 --- a/packages/tldraw/src/types.ts +++ b/packages/tldraw/src/types.ts @@ -19,6 +19,7 @@ import type { TLBoundsHandleEventHandler, TLShapeBlurHandler, TLShapeCloneHandler, + TLAssets, } from '@tldraw/core' /* -------------------------------------------------- */ @@ -102,6 +103,8 @@ export interface TDSnapshot { isMenuOpen: boolean status: string snapLines: TLSnapLine[] + isLoading: boolean + disableAssets: boolean } document: TDDocument room?: { @@ -130,6 +133,7 @@ export interface TDDocument { version: number pages: Record pageStates: Record + assets: TLAssets } // The shape of a single page in the Tldraw document @@ -277,6 +281,8 @@ export enum TDShapeType { Line = 'line', Text = 'text', Group = 'group', + Image = 'image', + Video = 'video', } export enum Decoration { @@ -338,6 +344,20 @@ export interface RectangleShape extends TDBaseShape { 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 export interface TriangleShape extends TDBaseShape { type: TDShapeType.Triangle @@ -374,6 +394,8 @@ export type TDShape = | TextShape | GroupShape | StickyShape + | ImageShape + | VideoShape /* ------------------ Shape Styles ------------------ */ diff --git a/yarn.lock b/yarn.lock index 5367b94c3..6f33bb8df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1077,6 +1077,385 @@ unique-filename "^1.1.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": version "5.0.2" 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" 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": version "0.1.0" 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" 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": version "3.0.5" 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" 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": version "14.18.0" 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" 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: version "2.6.12" 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" 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: version "9.0.2" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-9.0.2.tgz#dacc20160935a37dea6364aa1bef819fb9b6ab05" @@ -7047,6 +7489,13 @@ fastq@^1.6.0: dependencies: 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: version "2.0.1" 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" 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: version "3.0.4" 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" 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: version "2.1.0" 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: 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: version "6.1.5" 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" 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: version "1.1.0" resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226" @@ -9663,6 +10164,13 @@ lie@3.1.1: dependencies: 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: version "1.2.4" 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" 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: version "4.5.0" 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" 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: version "1.4.0" 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" 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: version "2.6.6" 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" semver "^6.2.0" -pako@~1.0.5: +pako@~1.0.2, pako@~1.0.5: version "1.0.11" resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" 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" 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: version "1.1.1" 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" 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: version "1.4.8" 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" 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" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -12734,6 +13283,25 @@ seek-bzip@^1.0.5: dependencies: 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: version "1.0.0" 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" 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: version "2.0.1" 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" 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" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== @@ -14535,6 +15108,20 @@ webpack-sources@^1.4.3: source-list-map "^2.0.0" 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: version "1.0.5" resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz#5abacf777c32166a51d085d6b4f3e7d27113ddb0" @@ -14542,6 +15129,11 @@ whatwg-encoding@^1.0.5: dependencies: 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: version "2.3.0" 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" 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: version "7.5.6" 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" 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" resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==