[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:
parent
cdfad49184
commit
5e6a6c9967
23 changed files with 1174 additions and 412 deletions
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
217
apps/www/hooks/useMultiplayerState.ts
Normal file
217
apps/www/hooks/useMultiplayerState.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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",
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
217
examples/tldraw-example/src/multiplayer/useMultiplayerState.ts
Normal file
217
examples/tldraw-example/src/multiplayer/useMultiplayerState.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
419
packages/tldraw/src/state/StateManager/StateManager.ts
Normal file
419
packages/tldraw/src/state/StateManager/StateManager.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
41
packages/tldraw/src/state/StateManager/copy.ts
Normal file
41
packages/tldraw/src/state/StateManager/copy.ts
Normal 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
|
||||||
|
}
|
1
packages/tldraw/src/state/StateManager/index.ts
Normal file
1
packages/tldraw/src/state/StateManager/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from './StateManager'
|
19
packages/tldraw/src/state/StateManager/merge.ts
Normal file
19
packages/tldraw/src/state/StateManager/merge.ts
Normal 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
|
||||||
|
}
|
|
@ -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),
|
||||||
})
|
})
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
26
yarn.lock
26
yarn.lock
|
@ -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==
|
||||||
|
|
Loading…
Reference in a new issue