[improvement] refactor multiplayer (#336)

* Move rko into library, improve multiplayer example

* Add presence layer

* extract to a hook

* Migrate old documents to new structures

* Update repo-map.tldr

* More improvements

* Fix bug on deleted shapes

* Update MultiplayerEditor.tsx
This commit is contained in:
Steve Ruiz 2021-11-22 14:00:24 +00:00 committed by GitHub
parent cdfad49184
commit 5e6a6c9967
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1174 additions and 412 deletions

View file

@ -1,17 +1,11 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Tldraw, TldrawApp, TDDocument, TDUser, useFileSystem } from '@tldraw/tldraw'
import * as React from 'react' import * as React from 'react'
import { createClient, Presence } from '@liveblocks/client' import { Tldraw, TldrawApp, useFileSystem } from '@tldraw/tldraw'
import { LiveblocksProvider, RoomProvider, useObject, useErrorListener } from '@liveblocks/react' import { createClient } from '@liveblocks/client'
import { Utils } from '@tldraw/core' import { LiveblocksProvider, RoomProvider } from '@liveblocks/react'
import { useAccountHandlers } from '-hooks/useAccountHandlers' import { useAccountHandlers } from '-hooks/useAccountHandlers'
import { styled } from '-styles' import { styled } from '-styles'
import { useMultiplayerState } from '-hooks/useMultiplayerState'
declare const window: Window & { app: TldrawApp }
interface TDUserPresence extends Presence {
user: TDUser
}
const client = createClient({ const client = createClient({
publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_API_KEY || '', publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_API_KEY || '',
@ -47,144 +41,22 @@ function Editor({
isUser: boolean isUser: boolean
isSponsor: boolean isSponsor: boolean
}) { }) {
const [docId] = React.useState(() => Utils.uniqueId())
const [app, setApp] = React.useState<TldrawApp>()
const [error, setError] = React.useState<Error>()
useErrorListener((err) => setError(err))
// Setup document
const doc = useObject<{ uuid: string; document: TDDocument }>('doc', {
uuid: docId,
document: {
...TldrawApp.defaultDocument,
id: roomId,
},
})
// Put the state into the window, for debugging.
const handleMount = React.useCallback((app: TldrawApp) => {
window.app = app
setApp(app)
}, [])
// Setup client
React.useEffect(() => {
const room = client.getRoom(roomId)
if (!room) return
if (!doc) return
if (!app) return
app.loadRoom(roomId)
// Subscribe to presence changes; when others change, update the state
room.subscribe<TDUserPresence>('others', (others) => {
app.updateUsers(
others
.toArray()
.filter((other) => other.presence)
.map((other) => other.presence!.user)
.filter(Boolean)
)
})
room.subscribe('event', (event) => {
if (event.event?.name === 'exit') {
app.removeUser(event.event.userId)
}
})
function handleDocumentUpdates() {
if (!doc) return
if (!app?.room) return
const docObject = doc.toObject()
// Only merge the change if it caused by someone else
if (docObject.uuid !== docId) {
app.mergeDocument(docObject.document)
} else {
app.updateUsers(
Object.values(app.room.users).map((user) => {
return {
...user,
selectedIds: user.selectedIds,
}
})
)
}
}
function handleExit() {
if (!app?.room) return
room?.broadcastEvent({ name: 'exit', userId: app.room.userId })
}
window.addEventListener('beforeunload', handleExit)
// When the shared document changes, update the state
doc.subscribe(handleDocumentUpdates)
// Load the shared document
const newDocument = doc.toObject().document
if (newDocument) {
app.loadDocument(newDocument)
app.loadRoom(roomId)
// Update the user's presence with the user from state
if (app.state.room) {
const { users, userId } = app.state.room
room.updatePresence({ id: userId, user: users[userId] })
}
}
return () => {
window.removeEventListener('beforeunload', handleExit)
doc.unsubscribe(handleDocumentUpdates)
}
}, [doc, docId, app, roomId])
const handlePersist = React.useCallback(
(app: TldrawApp) => {
doc?.update({ uuid: docId, document: app.document })
},
[docId, doc]
)
const handleUserChange = React.useCallback(
(app: TldrawApp, user: TDUser) => {
const room = client.getRoom(roomId)
room?.updatePresence({ id: app.room?.userId, user })
},
[roomId]
)
const fileSystemEvents = useFileSystem() const fileSystemEvents = useFileSystem()
const { onSignIn, onSignOut } = useAccountHandlers() const { onSignIn, onSignOut } = useAccountHandlers()
const { error, ...events } = useMultiplayerState(roomId)
if (error) return <LoadingScreen>Error: {error.message}</LoadingScreen> if (error) return <LoadingScreen>Error: {error.message}</LoadingScreen>
if (doc === null) return <LoadingScreen>Loading...</LoadingScreen>
return ( return (
<div className="tldraw"> <div className="tldraw">
<Tldraw <Tldraw
autofocus autofocus
onMount={handleMount}
onPersist={handlePersist}
onUserChange={handleUserChange}
showPages={false} showPages={false}
showSponsorLink={isSponsor} showSponsorLink={isSponsor}
onSignIn={isSponsor ? undefined : onSignIn} onSignIn={isSponsor ? undefined : onSignIn}
onSignOut={isUser ? onSignOut : undefined} onSignOut={isUser ? onSignOut : undefined}
{...fileSystemEvents} {...fileSystemEvents}
{...events}
/> />
</div> </div>
) )

View file

@ -0,0 +1,217 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as React from 'react'
import type { TldrawApp, TDUser, TDShape, TDBinding, TDDocument } from '@tldraw/tldraw'
import { useRedo, useUndo, useRoom, useUpdateMyPresence } from '@liveblocks/react'
import { LiveMap, LiveObject } from '@liveblocks/client'
declare const window: Window & { app: TldrawApp }
export function useMultiplayerState(roomId: string) {
const [app, setApp] = React.useState<TldrawApp>()
const [error, setError] = React.useState<Error>()
const [loading, setLoading] = React.useState(true)
const rExpectingUpdate = React.useRef(false)
const room = useRoom()
const undo = useUndo()
const redo = useRedo()
const updateMyPresence = useUpdateMyPresence()
// Document Changes --------
const rLiveShapes = React.useRef<LiveMap<string, TDShape>>()
const rLiveBindings = React.useRef<LiveMap<string, TDBinding>>()
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))
// Setup the document's storage and subscriptions
async function setupDocument() {
const storage = await room.getStorage<any>()
// Initialize (get or create) shapes and bindings maps
let lShapes: LiveMap<string, TDShape> = storage.root.get('shapes')
if (!lShapes) {
storage.root.set('shapes', new LiveMap<string, TDShape>())
lShapes = storage.root.get('shapes')
}
rLiveShapes.current = lShapes
let lBindings: LiveMap<string, TDBinding> = storage.root.get('bindings')
if (!lBindings) {
storage.root.set('bindings', new LiveMap<string, TDBinding>())
lBindings = storage.root.get('bindings')
}
rLiveBindings.current = lBindings
// Subscribe to changes
function handleChanges() {
if (rExpectingUpdate.current) {
rExpectingUpdate.current = false
return
}
app?.replacePageContent(
Object.fromEntries(lShapes.entries()),
Object.fromEntries(lBindings.entries())
)
}
unsubs.push(room.subscribe(lShapes, handleChanges))
unsubs.push(room.subscribe(lBindings, handleChanges))
// Update the document with initial content
handleChanges()
// 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
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)
setLoading(false)
}
setupDocument()
return () => {
unsubs.forEach((unsub) => unsub())
}
}, [room, app])
// Callbacks --------------
// Put the state into the window, for debugging.
const onMount = React.useCallback(
(app: TldrawApp) => {
app.loadRoom(roomId)
app.pause() // Turn off the app's own undo / redo stack
window.app = app
setApp(app)
},
[roomId]
)
// Update the live shapes when the app's shapes change.
const onChangePage = React.useCallback(
(
app: TldrawApp,
shapes: Record<string, TDShape | undefined>,
bindings: Record<string, TDBinding | undefined>
) => {
room.batch(() => {
const lShapes = rLiveShapes.current
const lBindings = rLiveBindings.current
if (!(lShapes && lBindings)) return
Object.entries(shapes).forEach(([id, shape]) => {
if (!shape) {
lShapes.delete(id)
} else {
lShapes.set(shape.id, shape)
}
})
Object.entries(bindings).forEach(([id, binding]) => {
if (!binding) {
lBindings.delete(id)
} else {
lBindings.set(binding.id, binding)
}
})
rExpectingUpdate.current = true
})
},
[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]
)
return {
undo,
redo,
onMount,
onChangePage,
onChangePresence,
error,
loading,
}
}

View file

@ -18,8 +18,8 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@liveblocks/client": "^0.12.3", "@liveblocks/client": "^0.13.0-beta.1",
"@liveblocks/react": "^0.12.3", "@liveblocks/react": "^0.13.0-beta.1",
"@sentry/integrations": "^6.13.2", "@sentry/integrations": "^6.13.2",
"@sentry/node": "^6.13.2", "@sentry/node": "^6.13.2",
"@sentry/react": "^6.13.2", "@sentry/react": "^6.13.2",

View file

@ -15,8 +15,8 @@
"build": "node scripts/build.mjs" "build": "node scripts/build.mjs"
}, },
"devDependencies": { "devDependencies": {
"@liveblocks/client": "^0.12.3", "@liveblocks/client": "^0.13.0-beta.1",
"@liveblocks/react": "^0.12.3", "@liveblocks/react": "0.13.0-beta.1",
"@tldraw/tldraw": "^1.1.2", "@tldraw/tldraw": "^1.1.2",
"@types/node": "^14.14.35", "@types/node": "^14.14.35",
"@types/react": "^16.9.55", "@types/react": "^16.9.55",
@ -31,11 +31,9 @@
"react-router": "^5.2.1", "react-router": "^5.2.1",
"react-router-dom": "^5.3.0", "react-router-dom": "^5.3.0",
"rimraf": "3.0.2", "rimraf": "3.0.2",
"typescript": "4.2.3" "typescript": "4.2.3",
},
"gitHead": "a7dac0f83ad998e205c2aab58182cb4ba4e099a6",
"dependencies": {
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2" "react-dom": "^17.0.2"
} },
} "gitHead": "a7dac0f83ad998e205c2aab58182cb4ba4e099a6"
}

View file

@ -1,22 +1,16 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as React from 'react' import * as React from 'react'
import { Tldraw, TldrawApp, TDDocument, TDUser } from '@tldraw/tldraw' import { Tldraw } from '@tldraw/tldraw'
import { createClient, Presence } from '@liveblocks/client' import { createClient } from '@liveblocks/client'
import { LiveblocksProvider, RoomProvider, useErrorListener, useObject } from '@liveblocks/react' import { LiveblocksProvider, RoomProvider } from '@liveblocks/react'
import { Utils } from '@tldraw/core' import { useMultiplayerState } from './useMultiplayerState'
declare const window: Window & { app: TldrawApp }
interface TDUserPresence extends Presence {
user: TDUser
}
const client = createClient({ const client = createClient({
publicApiKey: process.env.LIVEBLOCKS_PUBLIC_API_KEY || '', publicApiKey: process.env.LIVEBLOCKS_PUBLIC_API_KEY || '',
throttle: 80, throttle: 100,
}) })
const roomId = 'mp-test-2' const roomId = 'mp-test-8'
export function Multiplayer() { export function Multiplayer() {
return ( return (
@ -29,132 +23,13 @@ export function Multiplayer() {
} }
function Editor({ roomId }: { roomId: string }) { function Editor({ roomId }: { roomId: string }) {
const [docId] = React.useState(() => Utils.uniqueId()) const { error, ...events } = useMultiplayerState(roomId)
const [app, setApp] = React.useState<TldrawApp>()
const [error, setError] = React.useState<Error>()
useErrorListener((err) => setError(err))
// Setup document
const doc = useObject<{ uuid: string; document: TDDocument }>('doc', {
uuid: docId,
document: {
...TldrawApp.defaultDocument,
id: roomId,
},
})
// Put the state into the window, for debugging.
const handleMount = React.useCallback((app: TldrawApp) => {
window.app = app
setApp(app)
}, [])
React.useEffect(() => {
const room = client.getRoom(roomId)
if (!room) return
if (!doc) return
if (!app) return
// Subscribe to presence changes; when others change, update the state
room.subscribe<TDUserPresence>('others', (others) => {
app.updateUsers(
others
.toArray()
.filter((other) => other.presence)
.map((other) => other.presence!.user)
.filter(Boolean)
)
})
room.subscribe('event', (event) => {
if (event.event?.name === 'exit') {
app.removeUser(event.event.userId)
}
})
function handleDocumentUpdates() {
if (!doc) return
if (!app?.room) return
const docObject = doc.toObject()
// Only merge the change if it caused by someone else
if (docObject.uuid !== docId) {
app.mergeDocument(docObject.document)
} else {
app.updateUsers(
Object.values(app.room.users).map((user) => {
return {
...user,
selectedIds: user.selectedIds,
}
})
)
}
}
function handleExit() {
if (!app?.room) return
room?.broadcastEvent({ name: 'exit', userId: app.room.userId })
}
window.addEventListener('beforeunload', handleExit)
// When the shared document changes, update the state
doc.subscribe(handleDocumentUpdates)
// Load the shared document
const newDocument = doc.toObject().document
if (newDocument) {
app.loadDocument(newDocument)
app.loadRoom(roomId)
// Update the user's presence with the user from state
if (app.state.room) {
const { users, userId } = app.state.room
room.updatePresence({ id: userId, user: users[userId] })
}
}
return () => {
window.removeEventListener('beforeunload', handleExit)
doc.unsubscribe(handleDocumentUpdates)
}
}, [doc, docId, app])
const handlePersist = React.useCallback(
(app: TldrawApp) => {
doc?.update({ uuid: docId, document: app.document })
},
[docId, doc]
)
const handleUserChange = React.useCallback(
(app: TldrawApp, user: TDUser) => {
const room = client.getRoom(roomId)
room?.updatePresence({ id: app.room?.userId, user })
},
[client]
)
if (error) return <div>Error: {error.message}</div> if (error) return <div>Error: {error.message}</div>
if (doc === null) return <div>Loading...</div>
return ( return (
<div className="tldraw"> <div className="tldraw">
<Tldraw <Tldraw showPages={false} {...events} />
onMount={handleMount}
onPersist={handlePersist}
onUserChange={handleUserChange}
showPages={false}
/>
</div> </div>
) )
} }

View file

@ -0,0 +1,217 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as React from 'react'
import type { TldrawApp, TDUser, TDShape, TDBinding, TDDocument } from '@tldraw/tldraw'
import { useRedo, useUndo, useRoom, useUpdateMyPresence } from '@liveblocks/react'
import { LiveMap, LiveObject } from '@liveblocks/client'
declare const window: Window & { app: TldrawApp }
export function useMultiplayerState(roomId: string) {
const [app, setApp] = React.useState<TldrawApp>()
const [error, setError] = React.useState<Error>()
const [loading, setLoading] = React.useState(true)
const rExpectingUpdate = React.useRef(false)
const room = useRoom()
const undo = useUndo()
const redo = useRedo()
const updateMyPresence = useUpdateMyPresence()
// Document Changes --------
const rLiveShapes = React.useRef<LiveMap<string, TDShape>>()
const rLiveBindings = React.useRef<LiveMap<string, TDBinding>>()
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))
// Setup the document's storage and subscriptions
async function setupDocument() {
const storage = await room.getStorage<any>()
// Initialize (get or create) shapes and bindings maps
let lShapes: LiveMap<string, TDShape> = storage.root.get('shapes')
if (!lShapes) {
storage.root.set('shapes', new LiveMap<string, TDShape>())
lShapes = storage.root.get('shapes')
}
rLiveShapes.current = lShapes
let lBindings: LiveMap<string, TDBinding> = storage.root.get('bindings')
if (!lBindings) {
storage.root.set('bindings', new LiveMap<string, TDBinding>())
lBindings = storage.root.get('bindings')
}
rLiveBindings.current = lBindings
// Subscribe to changes
function handleChanges() {
if (rExpectingUpdate.current) {
rExpectingUpdate.current = false
return
}
app?.replacePageContent(
Object.fromEntries(lShapes.entries()),
Object.fromEntries(lBindings.entries())
)
}
unsubs.push(room.subscribe(lShapes, handleChanges))
unsubs.push(room.subscribe(lBindings, handleChanges))
// Update the document with initial content
handleChanges()
// 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
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)
setLoading(false)
}
setupDocument()
return () => {
unsubs.forEach((unsub) => unsub())
}
}, [room, app])
// Callbacks --------------
// Put the state into the window, for debugging.
const onMount = React.useCallback(
(app: TldrawApp) => {
app.loadRoom(roomId)
app.pause() // Turn off the app's own undo / redo stack
window.app = app
setApp(app)
},
[roomId]
)
// Update the live shapes when the app's shapes change.
const onChangePage = React.useCallback(
(
app: TldrawApp,
shapes: Record<string, TDShape | undefined>,
bindings: Record<string, TDBinding | undefined>
) => {
room.batch(() => {
const lShapes = rLiveShapes.current
const lBindings = rLiveBindings.current
if (!(lShapes && lBindings)) return
Object.entries(shapes).forEach(([id, shape]) => {
if (!shape) {
lShapes.delete(id)
} else {
lShapes.set(shape.id, shape)
}
})
Object.entries(bindings).forEach(([id, binding]) => {
if (!binding) {
lBindings.delete(id)
} else {
lBindings.set(binding.id, binding)
}
})
rExpectingUpdate.current = true
})
},
[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]
)
return {
undo,
redo,
onMount,
onChangePage,
onChangePresence,
error,
loading,
}
}

View file

@ -52,10 +52,11 @@
"@tldraw/core": "^1.1.2", "@tldraw/core": "^1.1.2",
"@tldraw/intersect": "latest", "@tldraw/intersect": "latest",
"@tldraw/vec": "latest", "@tldraw/vec": "latest",
"idb-keyval": "^6.0.3",
"perfect-freehand": "^1.0.16", "perfect-freehand": "^1.0.16",
"react-hotkeys-hook": "^3.4.0", "react-hotkeys-hook": "^3.4.0",
"rko": "^0.6.5", "tslib": "^2.3.1",
"tslib": "^2.3.1" "zustand": "^3.6.5"
}, },
"devDependencies": { "devDependencies": {
"@swc-node/jest": "^1.3.3", "@swc-node/jest": "^1.3.3",

View file

@ -2,7 +2,7 @@ import * as React from 'react'
import { IdProvider } from '@radix-ui/react-id' import { IdProvider } from '@radix-ui/react-id'
import { Renderer } from '@tldraw/core' import { Renderer } from '@tldraw/core'
import { styled, dark } from '~styles' import { styled, dark } from '~styles'
import { TDDocument, TDStatus, TDUser } from '~types' import { TDDocument, TDShape, TDBinding, TDStatus, TDUser } from '~types'
import { TldrawApp, TDCallbacks } from '~state' import { TldrawApp, TDCallbacks } from '~state'
import { TldrawContext, useStylesheet, useKeyboardShortcuts, useTldrawApp } from '~hooks' import { TldrawContext, useStylesheet, useKeyboardShortcuts, useTldrawApp } from '~hooks'
import { shapeUtils } from '~state/shapes' import { shapeUtils } from '~state/shapes'
@ -116,7 +116,7 @@ export interface TldrawProps extends TDCallbacks {
/** /**
* (optional) A callback to run when the user creates a new project. * (optional) A callback to run when the user creates a new project.
*/ */
onUserChange?: (state: TldrawApp, user: TDUser) => void onChangePresence?: (state: TldrawApp, user: TDUser) => void
/** /**
* (optional) A callback to run when the component's state changes. * (optional) A callback to run when the component's state changes.
*/ */
@ -141,6 +141,12 @@ export interface TldrawProps extends TDCallbacks {
* (optional) A callback to run when the user redos. * (optional) A callback to run when the user redos.
*/ */
onRedo?: (state: TldrawApp) => void onRedo?: (state: TldrawApp) => void
onChangePage?: (
app: TldrawApp,
shapes: Record<string, TDShape | undefined>,
bindings: Record<string, TDBinding | undefined>
) => void
} }
export function Tldraw({ export function Tldraw({
@ -159,7 +165,7 @@ export function Tldraw({
showSponsorLink = false, showSponsorLink = false,
onMount, onMount,
onChange, onChange,
onUserChange, onChangePresence,
onNewProject, onNewProject,
onSaveProject, onSaveProject,
onSaveProjectAs, onSaveProjectAs,
@ -171,6 +177,7 @@ export function Tldraw({
onPersist, onPersist,
onPatch, onPatch,
onCommand, onCommand,
onChangePage,
}: TldrawProps) { }: TldrawProps) {
const [sId, setSId] = React.useState(id) const [sId, setSId] = React.useState(id)
@ -180,7 +187,7 @@ export function Tldraw({
new TldrawApp(id, { new TldrawApp(id, {
onMount, onMount,
onChange, onChange,
onUserChange, onChangePresence,
onNewProject, onNewProject,
onSaveProject, onSaveProject,
onSaveProjectAs, onSaveProjectAs,
@ -189,9 +196,10 @@ export function Tldraw({
onSignIn, onSignIn,
onUndo, onUndo,
onRedo, onRedo,
onPersist,
onPatch, onPatch,
onCommand, onCommand,
onPersist, onChangePage,
}) })
) )
@ -202,7 +210,7 @@ export function Tldraw({
const newApp = new TldrawApp(id, { const newApp = new TldrawApp(id, {
onMount, onMount,
onChange, onChange,
onUserChange, onChangePresence,
onNewProject, onNewProject,
onSaveProject, onSaveProject,
onSaveProjectAs, onSaveProjectAs,
@ -211,9 +219,10 @@ export function Tldraw({
onSignIn, onSignIn,
onUndo, onUndo,
onRedo, onRedo,
onPersist,
onPatch, onPatch,
onCommand, onCommand,
onPersist, onChangePage,
}) })
setSId(id) setSId(id)
@ -256,7 +265,7 @@ export function Tldraw({
app.callbacks = { app.callbacks = {
onMount, onMount,
onChange, onChange,
onUserChange, onChangePresence,
onNewProject, onNewProject,
onSaveProject, onSaveProject,
onSaveProjectAs, onSaveProjectAs,
@ -265,15 +274,15 @@ export function Tldraw({
onSignIn, onSignIn,
onUndo, onUndo,
onRedo, onRedo,
onPersist,
onPatch, onPatch,
onCommand, onCommand,
onPersist, onChangePage,
} }
}, [ }, [
app,
onMount, onMount,
onChange, onChange,
onUserChange, onChangePresence,
onNewProject, onNewProject,
onSaveProject, onSaveProject,
onSaveProjectAs, onSaveProjectAs,
@ -282,9 +291,10 @@ export function Tldraw({
onSignIn, onSignIn,
onUndo, onUndo,
onRedo, onRedo,
onPersist,
onPatch, onPatch,
onCommand, onCommand,
onPersist, onChangePage,
]) ])
// Use the `key` to ensure that new selector hooks are made when the id changes // Use the `key` to ensure that new selector hooks are made when the id changes

View file

@ -0,0 +1,419 @@
import createVanilla, { StoreApi } from 'zustand/vanilla'
import create, { UseBoundStore } from 'zustand'
import * as idb from 'idb-keyval'
import { deepCopy } from './copy'
import { merge } from './merge'
import type { Patch, Command } from '../../types'
export class StateManager<T extends Record<string, any>> {
/**
* An ID used to persist state in indexdb.
*/
protected _idbId?: string
/**
* The initial state.
*/
private initialState: T
/**
* A zustand store that also holds the state.
*/
private store: StoreApi<T>
/**
* The index of the current command.
*/
protected pointer = -1
/**
* The current state.
*/
private _state: T
/**
* The state manager's current status, with regard to restoring persisted state.
*/
private _status: 'loading' | 'ready' = 'loading'
/**
* A stack of commands used for history (undo and redo).
*/
protected stack: Command<T>[] = []
/**
* A snapshot of the current state.
*/
protected _snapshot: T
/**
* A React hook for accessing the zustand store.
*/
public readonly useStore: UseBoundStore<T>
/**
* A promise that will resolve when the state manager has loaded any peristed state.
*/
public ready: Promise<'none' | 'restored' | 'migrated'>
public isPaused = false
constructor(
initialState: T,
id?: string,
version?: number,
update?: (prev: T, next: T, prevVersion: number) => T
) {
this._idbId = id
this._state = deepCopy(initialState)
this._snapshot = deepCopy(initialState)
this.initialState = deepCopy(initialState)
this.store = createVanilla(() => this._state)
this.useStore = create(this.store)
this.ready = new Promise<'none' | 'restored' | 'migrated'>((resolve) => {
let message: 'none' | 'restored' | 'migrated' = 'none'
if (this._idbId) {
message = 'restored'
idb
.get(this._idbId)
.then(async (saved) => {
if (saved) {
let next = saved
if (version) {
const savedVersion = await idb.get<number>(id + '_version')
if (savedVersion && savedVersion < version) {
next = update ? update(saved, initialState, savedVersion) : initialState
message = 'migrated'
}
}
await idb.set(id + '_version', version || -1)
this._state = deepCopy(next)
this._snapshot = deepCopy(next)
this.store.setState(this._state, true)
} else {
await idb.set(id + '_version', version || -1)
}
this._status = 'ready'
resolve(message)
})
.catch((e) => console.error(e))
} else {
// We need to wait for any override to `onReady` to take effect.
this._status = 'ready'
resolve(message)
}
resolve(message)
}).then((message) => {
if (this.onReady) this.onReady(message)
return message
})
}
/**
* Save the current state to indexdb.
*/
protected persist = (id?: string): void | Promise<void> => {
if (this.onPersist) {
this.onPersist(this._state, id)
}
if (this._idbId) {
return idb.set(this._idbId, this._state).catch((e) => console.error(e))
}
}
/**
* Apply a patch to the current state.
* This does not effect the undo/redo stack.
* This does not persist the state.
* @param patch The patch to apply.
* @param id (optional) An id for the patch.
*/
private applyPatch = (patch: Patch<T>, id?: string) => {
const prev = this._state
const next = merge(this._state, patch)
const final = this.cleanup(next, prev, patch, id)
if (this.onStateWillChange) {
this.onStateWillChange(final, id)
}
this._state = final
this.store.setState(this._state, true)
if (this.onStateDidChange) {
this.onStateDidChange(this._state, id)
}
return this
}
// Internal API ---------------------------------
/**
* Perform any last changes to the state before updating.
* Override this on your extending class.
* @param nextState The next state.
* @param prevState The previous state.
* @param patch The patch that was just applied.
* @param id (optional) An id for the just-applied patch.
* @returns The final new state to apply.
*/
protected cleanup = (nextState: T, prevState: T, patch: Patch<T>, id?: string): T => nextState
/**
* A life-cycle method called when the state is about to change.
* @param state The next state.
* @param id An id for the change.
*/
protected onStateWillChange?: (state: T, id?: string) => void
/**
* A life-cycle method called when the state has changed.
* @param state The next state.
* @param id An id for the change.
*/
protected onStateDidChange?: (state: T, id?: string) => void
/**
* Apply a patch to the current state.
* This does not effect the undo/redo stack.
* This does not persist the state.
* @param patch The patch to apply.
* @param id (optional) An id for this patch.
*/
protected patchState = (patch: Patch<T>, id?: string): this => {
this.applyPatch(patch, id)
if (this.onPatch) {
this.onPatch(this._state, id)
}
return this
}
/**
* Replace the current state.
* This does not effect the undo/redo stack.
* This does not persist the state.
* @param state The new state.
* @param id An id for this change.
*/
protected replaceState = (state: T, id?: string): this => {
const final = this.cleanup(state, this._state, state, id)
if (this.onStateWillChange) {
this.onStateWillChange(final, 'replace')
}
this._state = final
this.store.setState(this._state, true)
if (this.onStateDidChange) {
this.onStateDidChange(this._state, 'replace')
}
return this
}
/**
* Update the state using a Command.
* This effects the undo/redo stack.
* This persists the state.
* @param command The command to apply and add to the undo/redo stack.
* @param id (optional) An id for this command.
*/
protected setState = (command: Command<T>, id = command.id) => {
if (this.pointer < this.stack.length - 1) {
this.stack = this.stack.slice(0, this.pointer + 1)
}
this.stack.push({ ...command, id })
this.pointer = this.stack.length - 1
this.applyPatch(command.after, id)
if (this.onCommand) this.onCommand(this._state, id)
this.persist(id)
return this
}
// Public API ---------------------------------
public pause() {
this.isPaused = true
}
public resume() {
this.isPaused = false
}
/**
* A callback fired when the constructor finishes loading any
* persisted data.
*/
protected onReady?: (message: 'none' | 'restored' | 'migrated') => void
/**
* A callback fired when a patch is applied.
*/
public onPatch?: (state: T, id?: string) => void
/**
* A callback fired when a patch is applied.
*/
public onCommand?: (state: T, id?: string) => void
/**
* A callback fired when the state is persisted.
*/
public onPersist?: (state: T, id?: string) => void
/**
* A callback fired when the state is replaced.
*/
public onReplace?: (state: T) => void
/**
* A callback fired when the state is reset.
*/
public onReset?: (state: T) => void
/**
* A callback fired when the history is reset.
*/
public onResetHistory?: (state: T) => void
/**
* A callback fired when a command is undone.
*/
public onUndo?: (state: T) => void
/**
* A callback fired when a command is redone.
*/
public onRedo?: (state: T) => void
/**
* Reset the state to the initial state and reset history.
*/
public reset = () => {
if (this.onStateWillChange) {
this.onStateWillChange(this.initialState, 'reset')
}
this._state = this.initialState
this.store.setState(this._state, true)
this.resetHistory()
this.persist('reset')
if (this.onStateDidChange) {
this.onStateDidChange(this._state, 'reset')
}
if (this.onReset) {
this.onReset(this._state)
}
return this
}
/**
* Force replace a new undo/redo history. It's your responsibility
* to make sure that this is compatible with the current state!
* @param history The new array of commands.
* @param pointer (optional) The new pointer position.
*/
public replaceHistory = (history: Command<T>[], pointer = history.length - 1): this => {
this.stack = history
this.pointer = pointer
if (this.onReplace) {
this.onReplace(this._state)
}
return this
}
/**
* Reset the history stack (without resetting the state).
*/
public resetHistory = (): this => {
this.stack = []
this.pointer = -1
if (this.onResetHistory) {
this.onResetHistory(this._state)
}
return this
}
/**
* Move backward in the undo/redo stack.
*/
public undo = (): this => {
if (!this.isPaused) {
if (!this.canUndo) return this
const command = this.stack[this.pointer]
this.pointer--
this.applyPatch(command.before, `undo`)
this.persist('undo')
}
if (this.onUndo) this.onUndo(this._state)
return this
}
/**
* Move forward in the undo/redo stack.
*/
public redo = (): this => {
if (!this.isPaused) {
if (!this.canRedo) return this
this.pointer++
const command = this.stack[this.pointer]
this.applyPatch(command.after, 'redo')
this.persist('undo')
}
if (this.onRedo) this.onRedo(this._state)
return this
}
/**
* Save a snapshot of the current state, accessible at `this.snapshot`.
*/
public setSnapshot = (): this => {
this._snapshot = { ...this._state }
return this
}
/**
* Force the zustand state to update.
*/
public forceUpdate = () => {
this.store.setState(this._state, true)
}
/**
* Get whether the state manager can undo.
*/
public get canUndo(): boolean {
return this.pointer > -1
}
/**
* Get whether the state manager can redo.
*/
public get canRedo(): boolean {
return this.pointer < this.stack.length - 1
}
/**
* The current state.
*/
public get state(): T {
return this._state
}
/**
* The current status.
*/
public get status(): string {
return this._status
}
/**
* The most-recent snapshot.
*/
protected get snapshot(): T {
return this._snapshot
}
}

View file

@ -0,0 +1,41 @@
/**
* Deep copy function for TypeScript.
* @param T Generic type of target/copied value.
* @param target Target value to be copied.
* @see Source project, ts-deeply https://github.com/ykdr2017/ts-deepcopy
* @see Code pen https://codepen.io/erikvullings/pen/ejyBYg
*/
export function deepCopy<T>(target: T): T {
if (target === null) {
return target
}
if (target instanceof Date) {
return new Date(target.getTime()) as any
}
// First part is for array and second part is for Realm.Collection
// if (target instanceof Array || typeof (target as any).type === 'string') {
if (typeof target === 'object') {
if (typeof target[Symbol.iterator as keyof T] === 'function') {
const cp = [] as any[]
if ((target as any as any[]).length > 0) {
for (const arrayMember of target as any as any[]) {
cp.push(deepCopy(arrayMember))
}
}
return cp as any as T
} else {
const targetKeys = Object.keys(target)
const cp = {} as T
if (targetKeys.length > 0) {
for (const key of targetKeys) {
cp[key as keyof T] = deepCopy(target[key as keyof T])
}
}
return cp
}
}
// Means that object is atomic
return target
}

View file

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

View file

@ -0,0 +1,19 @@
import type { Patch } from '../../types'
/**
* Recursively merge an object with a deep partial of the same type.
* @param target The original complete object.
* @param patch The deep partial to merge with the original object.
*/
export function merge<T>(target: T, patch: Patch<T>): T {
const result: T = { ...target }
const entries = Object.entries(patch) as [keyof T, T[keyof T]][]
for (const [key, value] of entries)
result[key] =
value === Object(value) && !Array.isArray(value) ? merge(result[key], value) : value
return result
}

View file

@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/ban-ts-comment */ /* eslint-disable @typescript-eslint/ban-ts-comment */
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import { StateManager } from 'rko'
import { Vec } from '@tldraw/vec' import { Vec } from '@tldraw/vec'
import { import {
TLBoundsEventHandler, TLBoundsEventHandler,
@ -59,6 +58,7 @@ import { RectangleTool } from './tools/RectangleTool'
import { LineTool } from './tools/LineTool' import { LineTool } from './tools/LineTool'
import { ArrowTool } from './tools/ArrowTool' import { ArrowTool } from './tools/ArrowTool'
import { StickyTool } from './tools/StickyTool' import { StickyTool } from './tools/StickyTool'
import { StateManager } from './StateManager'
const uuid = Utils.uniqueId() const uuid = Utils.uniqueId()
@ -95,10 +95,6 @@ export interface TDCallbacks {
* (optional) A callback to run when the user signs out via the menu. * (optional) A callback to run when the user signs out via the menu.
*/ */
onSignOut?: (state: TldrawApp) => void onSignOut?: (state: TldrawApp) => void
/**
* (optional) A callback to run when the user creates a new project.
*/
onUserChange?: (state: TldrawApp, user: TDUser) => void
/** /**
* (optional) A callback to run when the state is patched. * (optional) A callback to run when the state is patched.
*/ */
@ -119,6 +115,18 @@ export interface TDCallbacks {
* (optional) A callback to run when the user redos. * (optional) A callback to run when the user redos.
*/ */
onRedo?: (state: TldrawApp) => void onRedo?: (state: TldrawApp) => void
/**
* (optional) A callback to run when the user changes the current page's shapes.
*/
onChangePage?: (
app: TldrawApp,
shapes: Record<string, TDShape | undefined>,
bindings: Record<string, TDBinding | undefined>
) => void
/**
* (optional) A callback to run when the user creates a new project.
*/
onChangePresence?: (state: TldrawApp, user: TDUser) => void
} }
export class TldrawApp extends StateManager<TDSnapshot> { export class TldrawApp extends StateManager<TDSnapshot> {
@ -264,6 +272,8 @@ export class TldrawApp extends StateManager<TDSnapshot> {
const prevPage = prev.document.pages[pageId] const prevPage = prev.document.pages[pageId]
const changedShapes: Record<string, TDShape | undefined> = {}
if (!prevPage || page.shapes !== prevPage.shapes || page.bindings !== prevPage.bindings) { if (!prevPage || page.shapes !== prevPage.shapes || page.bindings !== prevPage.bindings) {
page.shapes = { ...page.shapes } page.shapes = { ...page.shapes }
page.bindings = { ...page.bindings } page.bindings = { ...page.bindings }
@ -275,12 +285,18 @@ export class TldrawApp extends StateManager<TDSnapshot> {
let parentId: string let parentId: string
if (!shape) { if (!shape) {
parentId = prevPage.shapes[id]?.parentId parentId = prevPage?.shapes[id]?.parentId
delete page.shapes[id] delete page.shapes[id]
} else { } else {
parentId = shape.parentId parentId = shape.parentId
} }
if (page.id === next.appState.currentPageId) {
if (prevPage?.shapes[id] !== shape) {
changedShapes[id] = shape
}
}
// If the shape is the child of a group, then update the group // If the shape is the child of a group, then update the group
// (unless the group is being deleted too) // (unless the group is being deleted too)
if (parentId && parentId !== pageId) { if (parentId && parentId !== pageId) {
@ -298,15 +314,15 @@ export class TldrawApp extends StateManager<TDSnapshot> {
} }
}) })
// Find which shapes have changed
const changedShapeIds = Object.values(page.shapes)
.filter((shape) => prevPage?.shapes[shape.id] !== shape)
.map((shape) => shape.id)
next.document.pages[pageId] = page next.document.pages[pageId] = page
// Find which shapes have changed
// const changedShapes = Object.entries(page.shapes).filter(
// ([id, shape]) => prevPage?.shapes[shape.id] !== shape
// )
// Get bindings related to the changed shapes // Get bindings related to the changed shapes
const bindingsToUpdate = TLDR.getRelatedBindings(next, changedShapeIds, pageId) const bindingsToUpdate = TLDR.getRelatedBindings(next, Object.keys(changedShapes), pageId)
// Update all of the bindings we've just collected // Update all of the bindings we've just collected
bindingsToUpdate.forEach((binding) => { bindingsToUpdate.forEach((binding) => {
@ -453,9 +469,11 @@ export class TldrawApp extends StateManager<TDSnapshot> {
} }
onPersist = () => { onPersist = () => {
this.callbacks.onPersist?.(this) this.broadcastPageChanges()
} }
private prevSelectedIds = this.selectedIds
/** /**
* Clear the selection history after each new command, undo or redo. * Clear the selection history after each new command, undo or redo.
* @param state * @param state
@ -463,21 +481,118 @@ export class TldrawApp extends StateManager<TDSnapshot> {
*/ */
protected onStateDidChange = (_state: TDSnapshot, id?: string): void => { protected onStateDidChange = (_state: TDSnapshot, id?: string): void => {
this.callbacks.onChange?.(this, id) this.callbacks.onChange?.(this, id)
if (this.room && this.selectedIds !== this.prevSelectedIds) {
this.callbacks.onChangePresence?.(this, {
...this.room.users[this.room.userId],
selectedIds: this.selectedIds,
})
this.prevSelectedIds = this.selectedIds
}
} }
// if (id && !id.startsWith('patch')) { /* ----------- Managing Multiplayer State ----------- */
// if (!id.startsWith('replace')) {
// // If we've changed the undo stack, then the file is out of private prevShapes = this.page.shapes
// // sync with any saved version on the file system. private prevBindings = this.page.bindings
// this.isDirty = true
// } private broadcastPageChanges = () => {
// this.clearSelectHistory() const visited = new Set<string>()
// }
// if (id.startsWith('undo') || id.startsWith('redo')) { const changedShapes: Record<string, TDShape | undefined> = {}
// Session.cache.selectedIds = [...this.selectedIds] const changedBindings: Record<string, TDBinding | undefined> = {}
// }
// this.onChange?.(this, id) this.shapes.forEach((shape) => {
// } visited.add(shape.id)
if (this.prevShapes[shape.id] !== shape) {
changedShapes[shape.id] = shape
}
})
Object.keys(this.prevShapes)
.filter((id) => !visited.has(id))
.forEach((id) => {
changedShapes[id] = undefined
})
this.bindings.forEach((binding) => {
visited.add(binding.id)
if (this.prevBindings[binding.id] !== binding) {
changedBindings[binding.id] = binding
}
})
Object.keys(this.prevShapes)
.filter((id) => !visited.has(id))
.forEach((id) => {
changedBindings[id] = undefined
})
this.callbacks.onChangePage?.(this, changedShapes, changedBindings)
this.callbacks.onPersist?.(this)
this.prevShapes = this.page.shapes
this.prevBindings = this.page.bindings
}
/**
* Manually patch a set of shapes.
* @param shapes An array of shape partials, containing the changes to be made to each shape.
* @command
*/
public replacePageContent = (
shapes: Record<string, TDShape>,
bindings: Record<string, TDBinding>,
pageId = this.currentPageId
): this => {
this.useStore.setState((current) => {
const { hoveredId, editingId, bindingId, selectedIds } = current.document.pageStates[pageId]
const next = {
...current,
document: {
...current.document,
pages: {
[pageId]: {
...current.document.pages[pageId],
shapes,
bindings,
},
},
pageStates: {
...current.document.pageStates,
[pageId]: {
...current.document.pageStates[pageId],
selectedIds: selectedIds.filter((id) => shapes[id] !== undefined),
hoveredId: hoveredId
? shapes[hoveredId] === undefined
? undefined
: hoveredId
: undefined,
editingId: editingId
? shapes[editingId] === undefined
? undefined
: hoveredId
: undefined,
bindingId: bindingId
? bindings[bindingId] === undefined
? undefined
: bindingId
: undefined,
},
},
},
}
this.state.document = next.document
this.prevShapes = next.document.pages[this.currentPageId].shapes
this.prevBindings = next.document.pages[this.currentPageId].bindings
return next
}, true)
return this
}
/** /**
* Set the current status. * Set the current status.
@ -1763,15 +1878,6 @@ export class TldrawApp extends StateManager<TDSnapshot> {
private setSelectedIds = (ids: string[], push = false): this => { private setSelectedIds = (ids: string[], push = false): this => {
const nextIds = push ? [...this.pageState.selectedIds, ...ids] : [...ids] const nextIds = push ? [...this.pageState.selectedIds, ...ids] : [...ids]
if (this.state.room) {
const { users, userId } = this.state.room
this.callbacks.onUserChange?.(this, {
...users[userId],
selectedIds: nextIds,
})
}
return this.patchState( return this.patchState(
{ {
appState: { appState: {
@ -2065,21 +2171,6 @@ export class TldrawApp extends StateManager<TDSnapshot> {
) )
} }
/**
* Manually patch a set of shapes.
* @param shapes An array of shape partials, containing the changes to be made to each shape.
* @command
*/
patchShapes = (...shapes: ({ id: string } & Partial<TDShape>)[]): this => {
const pageShapes = this.document.pages[this.currentPageId].shapes
const shapesToUpdate = shapes.filter((shape) => pageShapes[shape.id])
if (shapesToUpdate.length === 0) return this
return this.patchState(
Commands.updateShapes(this, shapesToUpdate, this.currentPageId).after,
'updated_shapes'
)
}
createTextShapeAtPoint(point: number[], id?: string): this { createTextShapeAtPoint(point: number[], id?: string): this {
const { const {
shapes, shapes,
@ -2521,7 +2612,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
if (this.state.room) { if (this.state.room) {
const { users, userId } = this.state.room const { users, userId } = this.state.room
this.callbacks.onUserChange?.(this, { this.callbacks.onChangePresence?.(this, {
...users[userId], ...users[userId],
point: this.getPagePoint(info.point), point: this.getPagePoint(info.point),
}) })

View file

@ -1,5 +1,4 @@
import type { Patch } from 'rko' import type { Patch, TDShape, TldrawCommand, TDBinding } from '~types'
import type { TDShape, TldrawCommand, TDBinding } from '~types'
import type { TldrawApp } from '../../internal' import type { TldrawApp } from '../../internal'
export function createShapes( export function createShapes(

View file

@ -1,7 +1,6 @@
import { TDShape, TDShapeType } from '~types' import { TDShape, TDShapeType } from '~types'
import { Utils } from '@tldraw/core' import { Utils } from '@tldraw/core'
import type { TDSnapshot, TldrawCommand, TDBinding } from '~types' import type { Patch, TldrawCommand, TDBinding } from '~types'
import type { Patch } from 'rko'
import type { TldrawApp } from '../../internal' import type { TldrawApp } from '../../internal'
import { TLDR } from '~state/TLDR' import { TLDR } from '~state/TLDR'

View file

@ -1,7 +1,6 @@
import { ShapeStyles, TldrawCommand, TDShape, TDShapeType, TextShape } from '~types' import { Patch, ShapeStyles, TldrawCommand, TDShape, TDShapeType, TextShape } from '~types'
import { TLDR } from '~state/TLDR' import { TLDR } from '~state/TLDR'
import Vec from '@tldraw/vec' import { Vec } from '@tldraw/vec'
import type { Patch } from 'rko'
import type { TldrawApp } from '../../internal' import type { TldrawApp } from '../../internal'
export function styleShapes( export function styleShapes(

View file

@ -1,6 +1,5 @@
import { Decoration } from '~types' import { Decoration } from '~types'
import type { ArrowShape, TldrawCommand } from '~types' import type { Patch, ArrowShape, TldrawCommand } from '~types'
import type { Patch } from 'rko'
import type { TldrawApp } from '../../internal' import type { TldrawApp } from '../../internal'
export function toggleShapesDecoration( export function toggleShapesDecoration(

View file

@ -1,7 +1,6 @@
import { TLDR } from '~state/TLDR' import { TLDR } from '~state/TLDR'
import type { GroupShape, TDBinding, TDShape } from '~types' import type { Patch, GroupShape, TDBinding, TDShape } from '~types'
import type { TldrawCommand } from '~types' import type { TldrawCommand } from '~types'
import type { Patch } from 'rko'
import type { TldrawApp } from '../../internal' import type { TldrawApp } from '../../internal'
export function ungroupShapes( export function ungroupShapes(

View file

@ -1,8 +1,15 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import { TLPageState, TLBounds, Utils } from '@tldraw/core' import { TLPageState, TLBounds, Utils } from '@tldraw/core'
import { Vec } from '@tldraw/vec' import { Vec } from '@tldraw/vec'
import { TDShape, TDStatus, SessionType, TDShapeType, TldrawPatch, TldrawCommand } from '~types' import {
import type { Patch } from 'rko' Patch,
TDShape,
TDStatus,
SessionType,
TDShapeType,
TldrawPatch,
TldrawCommand,
} from '~types'
import { BaseSession } from '../BaseSession' import { BaseSession } from '../BaseSession'
import type { TldrawApp } from '../../internal' import type { TldrawApp } from '../../internal'

View file

@ -7,6 +7,7 @@ import {
TldrawCommand, TldrawCommand,
TDStatus, TDStatus,
ArrowShape, ArrowShape,
Patch,
GroupShape, GroupShape,
SessionType, SessionType,
ArrowBinding, ArrowBinding,
@ -14,7 +15,6 @@ import {
} from '~types' } from '~types'
import { SLOW_SPEED, SNAP_DISTANCE } from '~constants' import { SLOW_SPEED, SNAP_DISTANCE } from '~constants'
import { TLDR } from '~state/TLDR' import { TLDR } from '~state/TLDR'
import type { Patch } from 'rko'
import { BaseSession } from '../BaseSession' import { BaseSession } from '../BaseSession'
import type { TldrawApp } from '../../internal' import type { TldrawApp } from '../../internal'

View file

@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/ban-types */ /* eslint-disable @typescript-eslint/ban-types */
import type { TLPage, TLUser, TLPageState } from '@tldraw/core' import type { TLPage, TLUser, TLPageState } from '@tldraw/core'
import type { Command, Patch } from 'rko'
import type { FileSystemHandle } from '~state/data/browser-fs-access' import type { FileSystemHandle } from '~state/data/browser-fs-access'
import type { import type {
TLBinding, TLBinding,
@ -463,3 +462,11 @@ export type MappedByType<U extends string, T extends { type: U }> = {
} }
export type ShapesWithProp<U> = MembersWithRequiredKey<MappedByType<TDShapeType, TDShape>, U> export type ShapesWithProp<U> = MembersWithRequiredKey<MappedByType<TDShapeType, TDShape>, U>
export type Patch<T> = Partial<{ [P in keyof T]: Patch<T[P]> }>
export interface Command<T extends { [key: string]: any }> {
id?: string
before: Patch<T>
after: Patch<T>
}

View file

@ -491,7 +491,7 @@
"parentId": "page", "parentId": "page",
"childIndex": 1.6666666666666665, "childIndex": 1.6666666666666665,
"point": [ "point": [
2320.57, 2286.01,
528.32 528.32
], ],
"rotation": 0, "rotation": 0,
@ -512,7 +512,7 @@
"parentId": "page", "parentId": "page",
"childIndex": 1.3333333333333333, "childIndex": 1.3333333333333333,
"point": [ "point": [
2359.33, 2324.77,
583.12 583.12
], ],
"rotation": 0, "rotation": 0,
@ -533,7 +533,7 @@
"parentId": "page", "parentId": "page",
"childIndex": 1, "childIndex": 1,
"point": [ "point": [
2206.49, 2171.93,
508.61 508.61
], ],
"size": [ "size": [
@ -556,7 +556,7 @@
"parentId": "page", "parentId": "page",
"childIndex": 1.8333333333333333, "childIndex": 1.8333333333333333,
"point": [ "point": [
1773.27, 1738.71,
534.63 534.63
], ],
"rotation": 0, "rotation": 0,
@ -577,7 +577,7 @@
"parentId": "page", "parentId": "page",
"childIndex": 1.5, "childIndex": 1.5,
"point": [ "point": [
1859.03, 1824.47,
589.43 589.43
], ],
"rotation": 0, "rotation": 0,
@ -598,7 +598,7 @@
"parentId": "page", "parentId": "page",
"childIndex": 1.1666666666666665, "childIndex": 1.1666666666666665,
"point": [ "point": [
1713.69, 1679.13,
514.92 514.92
], ],
"size": [ "size": [
@ -621,7 +621,7 @@
"parentId": "page", "parentId": "page",
"childIndex": 1.5, "childIndex": 1.5,
"point": [ "point": [
1833.77, 1799.21,
734.99 734.99
], ],
"rotation": 0, "rotation": 0,
@ -642,7 +642,7 @@
"parentId": "page", "parentId": "page",
"childIndex": 1.8333333333333333, "childIndex": 1.8333333333333333,
"point": [ "point": [
1862.75, 1828.19,
684.94 684.94
], ],
"rotation": 0, "rotation": 0,
@ -663,7 +663,7 @@
"parentId": "page", "parentId": "page",
"childIndex": 1.1666666666666665, "childIndex": 1.1666666666666665,
"point": [ "point": [
1745.74, 1711.18,
674.08 674.08
], ],
"size": [ "size": [
@ -686,7 +686,7 @@
"parentId": "page", "parentId": "page",
"childIndex": 1.6666666666666665, "childIndex": 1.6666666666666665,
"point": [ "point": [
1818.77, 1784.21,
874.35 874.35
], ],
"rotation": 0, "rotation": 0,
@ -707,7 +707,7 @@
"parentId": "page", "parentId": "page",
"childIndex": 2, "childIndex": 2,
"point": [ "point": [
1835.75, 1801.19,
824.3 824.3
], ],
"rotation": 0, "rotation": 0,
@ -728,7 +728,7 @@
"parentId": "page", "parentId": "page",
"childIndex": 1.3333333333333333, "childIndex": 1.3333333333333333,
"point": [ "point": [
1745.74, 1711.18,
813.44 813.44
], ],
"size": [ "size": [
@ -751,7 +751,7 @@
"parentId": "page", "parentId": "page",
"childIndex": 8, "childIndex": 8,
"point": [ "point": [
1997.85, 1989.03,
350.84 350.84
], ],
"rotation": 0, "rotation": 0,
@ -771,7 +771,7 @@
"id": "end", "id": "end",
"index": 1, "index": 1,
"point": [ "point": [
206.62, 192.38,
141.77 141.77
], ],
"canBind": true, "canBind": true,
@ -781,7 +781,7 @@
"id": "bend", "id": "bend",
"index": 2, "index": 2,
"point": [ "point": [
103.31, 96.19,
70.89 70.89
] ]
} }
@ -806,7 +806,7 @@
"parentId": "page", "parentId": "page",
"childIndex": 9, "childIndex": 9,
"point": [ "point": [
1869.85, 1871.84,
350.84 350.84
], ],
"rotation": 0, "rotation": 0,
@ -816,7 +816,7 @@
"id": "start", "id": "start",
"index": 0, "index": 0,
"point": [ "point": [
0.11, 0,
0 0
], ],
"canBind": true, "canBind": true,
@ -826,17 +826,17 @@
"id": "end", "id": "end",
"index": 1, "index": 1,
"point": [ "point": [
0, 3.08,
148.08 148.08
], ],
"canBind": true, "canBind": true,
"bindingId": "7a0098b7-cf3f-4c08-0e0a-5e12f660e748" "bindingId": "4ab25c0d-2c56-4a2d-2c28-b3f1ef83367a"
}, },
"bend": { "bend": {
"id": "bend", "id": "bend",
"index": 2, "index": 2,
"point": [ "point": [
0.06, 1.54,
74.04 74.04
] ]
} }
@ -861,7 +861,7 @@
"parentId": "page", "parentId": "page",
"childIndex": 1.6666666666666665, "childIndex": 1.6666666666666665,
"point": [ "point": [
1345.38, 1310.82,
534.62 534.62
], ],
"rotation": 0, "rotation": 0,
@ -882,7 +882,7 @@
"parentId": "page", "parentId": "page",
"childIndex": 1.3333333333333333, "childIndex": 1.3333333333333333,
"point": [ "point": [
1381.64, 1347.08,
589.4 589.4
], ],
"rotation": 0, "rotation": 0,
@ -903,7 +903,7 @@
"parentId": "page", "parentId": "page",
"childIndex": 1.1666666666666665, "childIndex": 1.1666666666666665,
"point": [ "point": [
1243.3, 1208.74,
514.91 514.91
], ],
"size": [ "size": [
@ -926,7 +926,7 @@
"parentId": "page", "parentId": "page",
"childIndex": 10, "childIndex": 10,
"point": [ "point": [
1523.16, 1498.4,
350.84 350.84
], ],
"rotation": 0, "rotation": 0,
@ -936,7 +936,7 @@
"id": "start", "id": "start",
"index": 0, "index": 0,
"point": [ "point": [
217.83, 233.38,
0 0
], ],
"canBind": true, "canBind": true,
@ -956,7 +956,7 @@
"id": "bend", "id": "bend",
"index": 2, "index": 2,
"point": [ "point": [
108.92, 116.69,
74.03 74.03
] ]
} }
@ -1394,18 +1394,6 @@
], ],
"distance": 16 "distance": 16
}, },
"7a0098b7-cf3f-4c08-0e0a-5e12f660e748": {
"id": "7a0098b7-cf3f-4c08-0e0a-5e12f660e748",
"type": "arrow",
"fromId": "751c4107-ec6d-4a01-2af2-fefc3f7d89b5",
"toId": "7b6f1ad1-fe5a-4007-3a1d-a8661aeacdc3",
"handleId": "end",
"point": [
0.4,
0.75
],
"distance": 16
},
"ab6ad0f8-76eb-43ee-2079-60a95a564847": { "ab6ad0f8-76eb-43ee-2079-60a95a564847": {
"id": "ab6ad0f8-76eb-43ee-2079-60a95a564847", "id": "ab6ad0f8-76eb-43ee-2079-60a95a564847",
"type": "arrow", "type": "arrow",
@ -1477,6 +1465,18 @@
0.5 0.5
], ],
"distance": 16 "distance": 16
},
"4ab25c0d-2c56-4a2d-2c28-b3f1ef83367a": {
"id": "4ab25c0d-2c56-4a2d-2c28-b3f1ef83367a",
"type": "arrow",
"fromId": "751c4107-ec6d-4a01-2af2-fefc3f7d89b5",
"toId": "7b6f1ad1-fe5a-4007-3a1d-a8661aeacdc3",
"handleId": "end",
"point": [
0.5,
0.5
],
"distance": 16
} }
} }
} }

View file

@ -1999,15 +1999,15 @@
npmlog "^4.1.2" npmlog "^4.1.2"
write-file-atomic "^2.3.0" write-file-atomic "^2.3.0"
"@liveblocks/client@^0.12.3": "@liveblocks/client@^0.13.0-beta.1":
version "0.12.3" version "0.13.0-beta.1"
resolved "https://registry.yarnpkg.com/@liveblocks/client/-/client-0.12.3.tgz#03b957ccc7a6a5dc7474d224fe12c32e065e9c9c" resolved "https://registry.yarnpkg.com/@liveblocks/client/-/client-0.13.0-beta.1.tgz#baee31dbefb7c40c954ab61b8c421562a85f729e"
integrity sha512-n82Ymngpvt4EiZEU3LWnEq7EjDmcd2wb2kjGz4m/4L7wYEd4RygAYi7bp7w5JOD1rt3Srhrwbq9Rz7TikbUheg== integrity sha512-LW1CygndCQeITYFsnaEZgbe2qqIZKo4iVH/qGYEXVLptc/1PP0nzEi8Hr2lfw4JOUw003FTeQ+BSI/raP22mgg==
"@liveblocks/react@^0.12.3": "@liveblocks/react@0.13.0-beta.1", "@liveblocks/react@^0.13.0-beta.1":
version "0.12.3" version "0.13.0-beta.1"
resolved "https://registry.yarnpkg.com/@liveblocks/react/-/react-0.12.3.tgz#82d93a9a3a96401258f6c87c1150026dd9d63504" resolved "https://registry.yarnpkg.com/@liveblocks/react/-/react-0.13.0-beta.1.tgz#e71bc47511480967c2a11042aa920399674b5c3d"
integrity sha512-3mHRiEwZ/s1lbGS4/bblUpLCNCBFMzEiUHHfBH3zO9+IKrH40lVdky0OujgF5zEacYcqUnVW7jT4ZvHCchvsYA== integrity sha512-odOO5WCVfV3B70Yy8k/11XFY/5dVSBpIPKnx+ZDxZkw/yzrA39NqS+GH7go/RvVAGSeHbg9phknOtg4X9gziAQ==
"@malept/cross-spawn-promise@^1.1.0": "@malept/cross-spawn-promise@^1.1.0":
version "1.1.1" version "1.1.1"
@ -12677,14 +12677,6 @@ ripemd160@^2.0.0, ripemd160@^2.0.1:
hash-base "^3.0.0" hash-base "^3.0.0"
inherits "^2.0.1" inherits "^2.0.1"
rko@^0.6.5:
version "0.6.5"
resolved "https://registry.yarnpkg.com/rko/-/rko-0.6.5.tgz#48069a97bc3ae96c86da2502e909247c6c25f861"
integrity sha512-0cYMs8iYJY2J7IuxSzGxWoelxkghvvvT3fWCwi/942uy6ORAYaJpQ73s7DIRR87W6jHNJvtcCzyZVfmtkoQzmg==
dependencies:
idb-keyval "^6.0.3"
zustand "^3.6.4"
roarr@^2.15.3: roarr@^2.15.3:
version "2.15.4" version "2.15.4"
resolved "https://registry.yarnpkg.com/roarr/-/roarr-2.15.4.tgz#f5fe795b7b838ccfe35dc608e0282b9eba2e7afd" resolved "https://registry.yarnpkg.com/roarr/-/roarr-2.15.4.tgz#f5fe795b7b838ccfe35dc608e0282b9eba2e7afd"
@ -15204,7 +15196,7 @@ zen-observable@0.8.15:
resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.15.tgz#96415c512d8e3ffd920afd3889604e30b9eaac15" resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.15.tgz#96415c512d8e3ffd920afd3889604e30b9eaac15"
integrity sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ== integrity sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==
zustand@^3.6.4: zustand@^3.6.5:
version "3.6.5" version "3.6.5"
resolved "https://registry.yarnpkg.com/zustand/-/zustand-3.6.5.tgz#42a459397907d6bf0e2375351394733b2f83ee44" resolved "https://registry.yarnpkg.com/zustand/-/zustand-3.6.5.tgz#42a459397907d6bf0e2375351394733b2f83ee44"
integrity sha512-/WfLJuXiEJimt61KGMHebrFBwckkCHGhAgVXTgPQHl6IMzjqm6MREb1OnDSnCRiSmRdhgdFCctceg6tSm79hiw== integrity sha512-/WfLJuXiEJimt61KGMHebrFBwckkCHGhAgVXTgPQHl6IMzjqm6MREb1OnDSnCRiSmRdhgdFCctceg6tSm79hiw==