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