[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 */
|
||||
import { Tldraw, TldrawApp, TDDocument, TDUser, useFileSystem } from '@tldraw/tldraw'
|
||||
import * as React from 'react'
|
||||
import { createClient, Presence } from '@liveblocks/client'
|
||||
import { LiveblocksProvider, RoomProvider, useObject, useErrorListener } from '@liveblocks/react'
|
||||
import { Utils } from '@tldraw/core'
|
||||
import { Tldraw, TldrawApp, useFileSystem } from '@tldraw/tldraw'
|
||||
import { createClient } from '@liveblocks/client'
|
||||
import { LiveblocksProvider, RoomProvider } from '@liveblocks/react'
|
||||
import { useAccountHandlers } from '-hooks/useAccountHandlers'
|
||||
import { styled } from '-styles'
|
||||
|
||||
declare const window: Window & { app: TldrawApp }
|
||||
|
||||
interface TDUserPresence extends Presence {
|
||||
user: TDUser
|
||||
}
|
||||
import { useMultiplayerState } from '-hooks/useMultiplayerState'
|
||||
|
||||
const client = createClient({
|
||||
publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_API_KEY || '',
|
||||
|
@ -47,144 +41,22 @@ function Editor({
|
|||
isUser: 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 { onSignIn, onSignOut } = useAccountHandlers()
|
||||
const { error, ...events } = useMultiplayerState(roomId)
|
||||
|
||||
if (error) return <LoadingScreen>Error: {error.message}</LoadingScreen>
|
||||
|
||||
if (doc === null) return <LoadingScreen>Loading...</LoadingScreen>
|
||||
|
||||
return (
|
||||
<div className="tldraw">
|
||||
<Tldraw
|
||||
autofocus
|
||||
onMount={handleMount}
|
||||
onPersist={handlePersist}
|
||||
onUserChange={handleUserChange}
|
||||
showPages={false}
|
||||
showSponsorLink={isSponsor}
|
||||
onSignIn={isSponsor ? undefined : onSignIn}
|
||||
onSignOut={isUser ? onSignOut : undefined}
|
||||
{...fileSystemEvents}
|
||||
{...events}
|
||||
/>
|
||||
</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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@liveblocks/client": "^0.12.3",
|
||||
"@liveblocks/react": "^0.12.3",
|
||||
"@liveblocks/client": "^0.13.0-beta.1",
|
||||
"@liveblocks/react": "^0.13.0-beta.1",
|
||||
"@sentry/integrations": "^6.13.2",
|
||||
"@sentry/node": "^6.13.2",
|
||||
"@sentry/react": "^6.13.2",
|
||||
|
|
|
@ -15,8 +15,8 @@
|
|||
"build": "node scripts/build.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@liveblocks/client": "^0.12.3",
|
||||
"@liveblocks/react": "^0.12.3",
|
||||
"@liveblocks/client": "^0.13.0-beta.1",
|
||||
"@liveblocks/react": "0.13.0-beta.1",
|
||||
"@tldraw/tldraw": "^1.1.2",
|
||||
"@types/node": "^14.14.35",
|
||||
"@types/react": "^16.9.55",
|
||||
|
@ -31,11 +31,9 @@
|
|||
"react-router": "^5.2.1",
|
||||
"react-router-dom": "^5.3.0",
|
||||
"rimraf": "3.0.2",
|
||||
"typescript": "4.2.3"
|
||||
},
|
||||
"gitHead": "a7dac0f83ad998e205c2aab58182cb4ba4e099a6",
|
||||
"dependencies": {
|
||||
"typescript": "4.2.3",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2"
|
||||
}
|
||||
}
|
||||
},
|
||||
"gitHead": "a7dac0f83ad998e205c2aab58182cb4ba4e099a6"
|
||||
}
|
|
@ -1,22 +1,16 @@
|
|||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import * as React from 'react'
|
||||
import { Tldraw, TldrawApp, TDDocument, TDUser } from '@tldraw/tldraw'
|
||||
import { createClient, Presence } from '@liveblocks/client'
|
||||
import { LiveblocksProvider, RoomProvider, useErrorListener, useObject } from '@liveblocks/react'
|
||||
import { Utils } from '@tldraw/core'
|
||||
|
||||
declare const window: Window & { app: TldrawApp }
|
||||
|
||||
interface TDUserPresence extends Presence {
|
||||
user: TDUser
|
||||
}
|
||||
import { Tldraw } from '@tldraw/tldraw'
|
||||
import { createClient } from '@liveblocks/client'
|
||||
import { LiveblocksProvider, RoomProvider } from '@liveblocks/react'
|
||||
import { useMultiplayerState } from './useMultiplayerState'
|
||||
|
||||
const client = createClient({
|
||||
publicApiKey: process.env.LIVEBLOCKS_PUBLIC_API_KEY || '',
|
||||
throttle: 80,
|
||||
throttle: 100,
|
||||
})
|
||||
|
||||
const roomId = 'mp-test-2'
|
||||
const roomId = 'mp-test-8'
|
||||
|
||||
export function Multiplayer() {
|
||||
return (
|
||||
|
@ -29,132 +23,13 @@ export function Multiplayer() {
|
|||
}
|
||||
|
||||
function Editor({ roomId }: { roomId: string }) {
|
||||
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)
|
||||
}, [])
|
||||
|
||||
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]
|
||||
)
|
||||
const { error, ...events } = useMultiplayerState(roomId)
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>
|
||||
|
||||
if (doc === null) return <div>Loading...</div>
|
||||
|
||||
return (
|
||||
<div className="tldraw">
|
||||
<Tldraw
|
||||
onMount={handleMount}
|
||||
onPersist={handlePersist}
|
||||
onUserChange={handleUserChange}
|
||||
showPages={false}
|
||||
/>
|
||||
<Tldraw showPages={false} {...events} />
|
||||
</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/intersect": "latest",
|
||||
"@tldraw/vec": "latest",
|
||||
"idb-keyval": "^6.0.3",
|
||||
"perfect-freehand": "^1.0.16",
|
||||
"react-hotkeys-hook": "^3.4.0",
|
||||
"rko": "^0.6.5",
|
||||
"tslib": "^2.3.1"
|
||||
"tslib": "^2.3.1",
|
||||
"zustand": "^3.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@swc-node/jest": "^1.3.3",
|
||||
|
|
|
@ -2,7 +2,7 @@ import * as React from 'react'
|
|||
import { IdProvider } from '@radix-ui/react-id'
|
||||
import { Renderer } from '@tldraw/core'
|
||||
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 { TldrawContext, useStylesheet, useKeyboardShortcuts, useTldrawApp } from '~hooks'
|
||||
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.
|
||||
*/
|
||||
onUserChange?: (state: TldrawApp, user: TDUser) => void
|
||||
onChangePresence?: (state: TldrawApp, user: TDUser) => void
|
||||
/**
|
||||
* (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.
|
||||
*/
|
||||
onRedo?: (state: TldrawApp) => void
|
||||
|
||||
onChangePage?: (
|
||||
app: TldrawApp,
|
||||
shapes: Record<string, TDShape | undefined>,
|
||||
bindings: Record<string, TDBinding | undefined>
|
||||
) => void
|
||||
}
|
||||
|
||||
export function Tldraw({
|
||||
|
@ -159,7 +165,7 @@ export function Tldraw({
|
|||
showSponsorLink = false,
|
||||
onMount,
|
||||
onChange,
|
||||
onUserChange,
|
||||
onChangePresence,
|
||||
onNewProject,
|
||||
onSaveProject,
|
||||
onSaveProjectAs,
|
||||
|
@ -171,6 +177,7 @@ export function Tldraw({
|
|||
onPersist,
|
||||
onPatch,
|
||||
onCommand,
|
||||
onChangePage,
|
||||
}: TldrawProps) {
|
||||
const [sId, setSId] = React.useState(id)
|
||||
|
||||
|
@ -180,7 +187,7 @@ export function Tldraw({
|
|||
new TldrawApp(id, {
|
||||
onMount,
|
||||
onChange,
|
||||
onUserChange,
|
||||
onChangePresence,
|
||||
onNewProject,
|
||||
onSaveProject,
|
||||
onSaveProjectAs,
|
||||
|
@ -189,9 +196,10 @@ export function Tldraw({
|
|||
onSignIn,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onPersist,
|
||||
onPatch,
|
||||
onCommand,
|
||||
onPersist,
|
||||
onChangePage,
|
||||
})
|
||||
)
|
||||
|
||||
|
@ -202,7 +210,7 @@ export function Tldraw({
|
|||
const newApp = new TldrawApp(id, {
|
||||
onMount,
|
||||
onChange,
|
||||
onUserChange,
|
||||
onChangePresence,
|
||||
onNewProject,
|
||||
onSaveProject,
|
||||
onSaveProjectAs,
|
||||
|
@ -211,9 +219,10 @@ export function Tldraw({
|
|||
onSignIn,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onPersist,
|
||||
onPatch,
|
||||
onCommand,
|
||||
onPersist,
|
||||
onChangePage,
|
||||
})
|
||||
|
||||
setSId(id)
|
||||
|
@ -256,7 +265,7 @@ export function Tldraw({
|
|||
app.callbacks = {
|
||||
onMount,
|
||||
onChange,
|
||||
onUserChange,
|
||||
onChangePresence,
|
||||
onNewProject,
|
||||
onSaveProject,
|
||||
onSaveProjectAs,
|
||||
|
@ -265,15 +274,15 @@ export function Tldraw({
|
|||
onSignIn,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onPersist,
|
||||
onPatch,
|
||||
onCommand,
|
||||
onPersist,
|
||||
onChangePage,
|
||||
}
|
||||
}, [
|
||||
app,
|
||||
onMount,
|
||||
onChange,
|
||||
onUserChange,
|
||||
onChangePresence,
|
||||
onNewProject,
|
||||
onSaveProject,
|
||||
onSaveProjectAs,
|
||||
|
@ -282,9 +291,10 @@ export function Tldraw({
|
|||
onSignIn,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onPersist,
|
||||
onPatch,
|
||||
onCommand,
|
||||
onPersist,
|
||||
onChangePage,
|
||||
])
|
||||
|
||||
// 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/ban-ts-comment */
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { StateManager } from 'rko'
|
||||
import { Vec } from '@tldraw/vec'
|
||||
import {
|
||||
TLBoundsEventHandler,
|
||||
|
@ -59,6 +58,7 @@ import { RectangleTool } from './tools/RectangleTool'
|
|||
import { LineTool } from './tools/LineTool'
|
||||
import { ArrowTool } from './tools/ArrowTool'
|
||||
import { StickyTool } from './tools/StickyTool'
|
||||
import { StateManager } from './StateManager'
|
||||
|
||||
const uuid = Utils.uniqueId()
|
||||
|
||||
|
@ -95,10 +95,6 @@ export interface TDCallbacks {
|
|||
* (optional) A callback to run when the user signs out via the menu.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
|
@ -119,6 +115,18 @@ export interface TDCallbacks {
|
|||
* (optional) A callback to run when the user redos.
|
||||
*/
|
||||
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> {
|
||||
|
@ -264,6 +272,8 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
|||
|
||||
const prevPage = prev.document.pages[pageId]
|
||||
|
||||
const changedShapes: Record<string, TDShape | undefined> = {}
|
||||
|
||||
if (!prevPage || page.shapes !== prevPage.shapes || page.bindings !== prevPage.bindings) {
|
||||
page.shapes = { ...page.shapes }
|
||||
page.bindings = { ...page.bindings }
|
||||
|
@ -275,12 +285,18 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
|||
let parentId: string
|
||||
|
||||
if (!shape) {
|
||||
parentId = prevPage.shapes[id]?.parentId
|
||||
parentId = prevPage?.shapes[id]?.parentId
|
||||
delete page.shapes[id]
|
||||
} else {
|
||||
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
|
||||
// (unless the group is being deleted too)
|
||||
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
|
||||
|
||||
// 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
|
||||
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
|
||||
bindingsToUpdate.forEach((binding) => {
|
||||
|
@ -453,9 +469,11 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
|||
}
|
||||
|
||||
onPersist = () => {
|
||||
this.callbacks.onPersist?.(this)
|
||||
this.broadcastPageChanges()
|
||||
}
|
||||
|
||||
private prevSelectedIds = this.selectedIds
|
||||
|
||||
/**
|
||||
* Clear the selection history after each new command, undo or redo.
|
||||
* @param state
|
||||
|
@ -463,21 +481,118 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
|||
*/
|
||||
protected onStateDidChange = (_state: TDSnapshot, id?: string): void => {
|
||||
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')) {
|
||||
// if (!id.startsWith('replace')) {
|
||||
// // If we've changed the undo stack, then the file is out of
|
||||
// // sync with any saved version on the file system.
|
||||
// this.isDirty = true
|
||||
// }
|
||||
// this.clearSelectHistory()
|
||||
// }
|
||||
// if (id.startsWith('undo') || id.startsWith('redo')) {
|
||||
// Session.cache.selectedIds = [...this.selectedIds]
|
||||
// }
|
||||
// this.onChange?.(this, id)
|
||||
// }
|
||||
/* ----------- Managing Multiplayer State ----------- */
|
||||
|
||||
private prevShapes = this.page.shapes
|
||||
private prevBindings = this.page.bindings
|
||||
|
||||
private broadcastPageChanges = () => {
|
||||
const visited = new Set<string>()
|
||||
|
||||
const changedShapes: Record<string, TDShape | undefined> = {}
|
||||
const changedBindings: Record<string, TDBinding | undefined> = {}
|
||||
|
||||
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.
|
||||
|
@ -1763,15 +1878,6 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
|||
private setSelectedIds = (ids: string[], push = false): this => {
|
||||
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(
|
||||
{
|
||||
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 {
|
||||
const {
|
||||
shapes,
|
||||
|
@ -2521,7 +2612,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
|||
if (this.state.room) {
|
||||
const { users, userId } = this.state.room
|
||||
|
||||
this.callbacks.onUserChange?.(this, {
|
||||
this.callbacks.onChangePresence?.(this, {
|
||||
...users[userId],
|
||||
point: this.getPagePoint(info.point),
|
||||
})
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import type { Patch } from 'rko'
|
||||
import type { TDShape, TldrawCommand, TDBinding } from '~types'
|
||||
import type { Patch, TDShape, TldrawCommand, TDBinding } from '~types'
|
||||
import type { TldrawApp } from '../../internal'
|
||||
|
||||
export function createShapes(
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { TDShape, TDShapeType } from '~types'
|
||||
import { Utils } from '@tldraw/core'
|
||||
import type { TDSnapshot, TldrawCommand, TDBinding } from '~types'
|
||||
import type { Patch } from 'rko'
|
||||
import type { Patch, TldrawCommand, TDBinding } from '~types'
|
||||
import type { TldrawApp } from '../../internal'
|
||||
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 Vec from '@tldraw/vec'
|
||||
import type { Patch } from 'rko'
|
||||
import { Vec } from '@tldraw/vec'
|
||||
import type { TldrawApp } from '../../internal'
|
||||
|
||||
export function styleShapes(
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { Decoration } from '~types'
|
||||
import type { ArrowShape, TldrawCommand } from '~types'
|
||||
import type { Patch } from 'rko'
|
||||
import type { Patch, ArrowShape, TldrawCommand } from '~types'
|
||||
import type { TldrawApp } from '../../internal'
|
||||
|
||||
export function toggleShapesDecoration(
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
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 { Patch } from 'rko'
|
||||
import type { TldrawApp } from '../../internal'
|
||||
|
||||
export function ungroupShapes(
|
||||
|
|
|
@ -1,8 +1,15 @@
|
|||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { TLPageState, TLBounds, Utils } from '@tldraw/core'
|
||||
import { Vec } from '@tldraw/vec'
|
||||
import { TDShape, TDStatus, SessionType, TDShapeType, TldrawPatch, TldrawCommand } from '~types'
|
||||
import type { Patch } from 'rko'
|
||||
import {
|
||||
Patch,
|
||||
TDShape,
|
||||
TDStatus,
|
||||
SessionType,
|
||||
TDShapeType,
|
||||
TldrawPatch,
|
||||
TldrawCommand,
|
||||
} from '~types'
|
||||
import { BaseSession } from '../BaseSession'
|
||||
import type { TldrawApp } from '../../internal'
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
TldrawCommand,
|
||||
TDStatus,
|
||||
ArrowShape,
|
||||
Patch,
|
||||
GroupShape,
|
||||
SessionType,
|
||||
ArrowBinding,
|
||||
|
@ -14,7 +15,6 @@ import {
|
|||
} from '~types'
|
||||
import { SLOW_SPEED, SNAP_DISTANCE } from '~constants'
|
||||
import { TLDR } from '~state/TLDR'
|
||||
import type { Patch } from 'rko'
|
||||
import { BaseSession } from '../BaseSession'
|
||||
import type { TldrawApp } from '../../internal'
|
||||
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/ban-types */
|
||||
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 {
|
||||
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 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",
|
||||
"childIndex": 1.6666666666666665,
|
||||
"point": [
|
||||
2320.57,
|
||||
2286.01,
|
||||
528.32
|
||||
],
|
||||
"rotation": 0,
|
||||
|
@ -512,7 +512,7 @@
|
|||
"parentId": "page",
|
||||
"childIndex": 1.3333333333333333,
|
||||
"point": [
|
||||
2359.33,
|
||||
2324.77,
|
||||
583.12
|
||||
],
|
||||
"rotation": 0,
|
||||
|
@ -533,7 +533,7 @@
|
|||
"parentId": "page",
|
||||
"childIndex": 1,
|
||||
"point": [
|
||||
2206.49,
|
||||
2171.93,
|
||||
508.61
|
||||
],
|
||||
"size": [
|
||||
|
@ -556,7 +556,7 @@
|
|||
"parentId": "page",
|
||||
"childIndex": 1.8333333333333333,
|
||||
"point": [
|
||||
1773.27,
|
||||
1738.71,
|
||||
534.63
|
||||
],
|
||||
"rotation": 0,
|
||||
|
@ -577,7 +577,7 @@
|
|||
"parentId": "page",
|
||||
"childIndex": 1.5,
|
||||
"point": [
|
||||
1859.03,
|
||||
1824.47,
|
||||
589.43
|
||||
],
|
||||
"rotation": 0,
|
||||
|
@ -598,7 +598,7 @@
|
|||
"parentId": "page",
|
||||
"childIndex": 1.1666666666666665,
|
||||
"point": [
|
||||
1713.69,
|
||||
1679.13,
|
||||
514.92
|
||||
],
|
||||
"size": [
|
||||
|
@ -621,7 +621,7 @@
|
|||
"parentId": "page",
|
||||
"childIndex": 1.5,
|
||||
"point": [
|
||||
1833.77,
|
||||
1799.21,
|
||||
734.99
|
||||
],
|
||||
"rotation": 0,
|
||||
|
@ -642,7 +642,7 @@
|
|||
"parentId": "page",
|
||||
"childIndex": 1.8333333333333333,
|
||||
"point": [
|
||||
1862.75,
|
||||
1828.19,
|
||||
684.94
|
||||
],
|
||||
"rotation": 0,
|
||||
|
@ -663,7 +663,7 @@
|
|||
"parentId": "page",
|
||||
"childIndex": 1.1666666666666665,
|
||||
"point": [
|
||||
1745.74,
|
||||
1711.18,
|
||||
674.08
|
||||
],
|
||||
"size": [
|
||||
|
@ -686,7 +686,7 @@
|
|||
"parentId": "page",
|
||||
"childIndex": 1.6666666666666665,
|
||||
"point": [
|
||||
1818.77,
|
||||
1784.21,
|
||||
874.35
|
||||
],
|
||||
"rotation": 0,
|
||||
|
@ -707,7 +707,7 @@
|
|||
"parentId": "page",
|
||||
"childIndex": 2,
|
||||
"point": [
|
||||
1835.75,
|
||||
1801.19,
|
||||
824.3
|
||||
],
|
||||
"rotation": 0,
|
||||
|
@ -728,7 +728,7 @@
|
|||
"parentId": "page",
|
||||
"childIndex": 1.3333333333333333,
|
||||
"point": [
|
||||
1745.74,
|
||||
1711.18,
|
||||
813.44
|
||||
],
|
||||
"size": [
|
||||
|
@ -751,7 +751,7 @@
|
|||
"parentId": "page",
|
||||
"childIndex": 8,
|
||||
"point": [
|
||||
1997.85,
|
||||
1989.03,
|
||||
350.84
|
||||
],
|
||||
"rotation": 0,
|
||||
|
@ -771,7 +771,7 @@
|
|||
"id": "end",
|
||||
"index": 1,
|
||||
"point": [
|
||||
206.62,
|
||||
192.38,
|
||||
141.77
|
||||
],
|
||||
"canBind": true,
|
||||
|
@ -781,7 +781,7 @@
|
|||
"id": "bend",
|
||||
"index": 2,
|
||||
"point": [
|
||||
103.31,
|
||||
96.19,
|
||||
70.89
|
||||
]
|
||||
}
|
||||
|
@ -806,7 +806,7 @@
|
|||
"parentId": "page",
|
||||
"childIndex": 9,
|
||||
"point": [
|
||||
1869.85,
|
||||
1871.84,
|
||||
350.84
|
||||
],
|
||||
"rotation": 0,
|
||||
|
@ -816,7 +816,7 @@
|
|||
"id": "start",
|
||||
"index": 0,
|
||||
"point": [
|
||||
0.11,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"canBind": true,
|
||||
|
@ -826,17 +826,17 @@
|
|||
"id": "end",
|
||||
"index": 1,
|
||||
"point": [
|
||||
0,
|
||||
3.08,
|
||||
148.08
|
||||
],
|
||||
"canBind": true,
|
||||
"bindingId": "7a0098b7-cf3f-4c08-0e0a-5e12f660e748"
|
||||
"bindingId": "4ab25c0d-2c56-4a2d-2c28-b3f1ef83367a"
|
||||
},
|
||||
"bend": {
|
||||
"id": "bend",
|
||||
"index": 2,
|
||||
"point": [
|
||||
0.06,
|
||||
1.54,
|
||||
74.04
|
||||
]
|
||||
}
|
||||
|
@ -861,7 +861,7 @@
|
|||
"parentId": "page",
|
||||
"childIndex": 1.6666666666666665,
|
||||
"point": [
|
||||
1345.38,
|
||||
1310.82,
|
||||
534.62
|
||||
],
|
||||
"rotation": 0,
|
||||
|
@ -882,7 +882,7 @@
|
|||
"parentId": "page",
|
||||
"childIndex": 1.3333333333333333,
|
||||
"point": [
|
||||
1381.64,
|
||||
1347.08,
|
||||
589.4
|
||||
],
|
||||
"rotation": 0,
|
||||
|
@ -903,7 +903,7 @@
|
|||
"parentId": "page",
|
||||
"childIndex": 1.1666666666666665,
|
||||
"point": [
|
||||
1243.3,
|
||||
1208.74,
|
||||
514.91
|
||||
],
|
||||
"size": [
|
||||
|
@ -926,7 +926,7 @@
|
|||
"parentId": "page",
|
||||
"childIndex": 10,
|
||||
"point": [
|
||||
1523.16,
|
||||
1498.4,
|
||||
350.84
|
||||
],
|
||||
"rotation": 0,
|
||||
|
@ -936,7 +936,7 @@
|
|||
"id": "start",
|
||||
"index": 0,
|
||||
"point": [
|
||||
217.83,
|
||||
233.38,
|
||||
0
|
||||
],
|
||||
"canBind": true,
|
||||
|
@ -956,7 +956,7 @@
|
|||
"id": "bend",
|
||||
"index": 2,
|
||||
"point": [
|
||||
108.92,
|
||||
116.69,
|
||||
74.03
|
||||
]
|
||||
}
|
||||
|
@ -1394,18 +1394,6 @@
|
|||
],
|
||||
"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": {
|
||||
"id": "ab6ad0f8-76eb-43ee-2079-60a95a564847",
|
||||
"type": "arrow",
|
||||
|
@ -1477,6 +1465,18 @@
|
|||
0.5
|
||||
],
|
||||
"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"
|
||||
write-file-atomic "^2.3.0"
|
||||
|
||||
"@liveblocks/client@^0.12.3":
|
||||
version "0.12.3"
|
||||
resolved "https://registry.yarnpkg.com/@liveblocks/client/-/client-0.12.3.tgz#03b957ccc7a6a5dc7474d224fe12c32e065e9c9c"
|
||||
integrity sha512-n82Ymngpvt4EiZEU3LWnEq7EjDmcd2wb2kjGz4m/4L7wYEd4RygAYi7bp7w5JOD1rt3Srhrwbq9Rz7TikbUheg==
|
||||
"@liveblocks/client@^0.13.0-beta.1":
|
||||
version "0.13.0-beta.1"
|
||||
resolved "https://registry.yarnpkg.com/@liveblocks/client/-/client-0.13.0-beta.1.tgz#baee31dbefb7c40c954ab61b8c421562a85f729e"
|
||||
integrity sha512-LW1CygndCQeITYFsnaEZgbe2qqIZKo4iVH/qGYEXVLptc/1PP0nzEi8Hr2lfw4JOUw003FTeQ+BSI/raP22mgg==
|
||||
|
||||
"@liveblocks/react@^0.12.3":
|
||||
version "0.12.3"
|
||||
resolved "https://registry.yarnpkg.com/@liveblocks/react/-/react-0.12.3.tgz#82d93a9a3a96401258f6c87c1150026dd9d63504"
|
||||
integrity sha512-3mHRiEwZ/s1lbGS4/bblUpLCNCBFMzEiUHHfBH3zO9+IKrH40lVdky0OujgF5zEacYcqUnVW7jT4ZvHCchvsYA==
|
||||
"@liveblocks/react@0.13.0-beta.1", "@liveblocks/react@^0.13.0-beta.1":
|
||||
version "0.13.0-beta.1"
|
||||
resolved "https://registry.yarnpkg.com/@liveblocks/react/-/react-0.13.0-beta.1.tgz#e71bc47511480967c2a11042aa920399674b5c3d"
|
||||
integrity sha512-odOO5WCVfV3B70Yy8k/11XFY/5dVSBpIPKnx+ZDxZkw/yzrA39NqS+GH7go/RvVAGSeHbg9phknOtg4X9gziAQ==
|
||||
|
||||
"@malept/cross-spawn-promise@^1.1.0":
|
||||
version "1.1.1"
|
||||
|
@ -12677,14 +12677,6 @@ ripemd160@^2.0.0, ripemd160@^2.0.1:
|
|||
hash-base "^3.0.0"
|
||||
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:
|
||||
version "2.15.4"
|
||||
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"
|
||||
integrity sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==
|
||||
|
||||
zustand@^3.6.4:
|
||||
zustand@^3.6.5:
|
||||
version "3.6.5"
|
||||
resolved "https://registry.yarnpkg.com/zustand/-/zustand-3.6.5.tgz#42a459397907d6bf0e2375351394733b2f83ee44"
|
||||
integrity sha512-/WfLJuXiEJimt61KGMHebrFBwckkCHGhAgVXTgPQHl6IMzjqm6MREb1OnDSnCRiSmRdhgdFCctceg6tSm79hiw==
|
||||
|
|
Loading…
Reference in a new issue