[improvement] refactor multiplayer (#336)

* Move rko into library, improve multiplayer example

* Add presence layer

* extract to a hook

* Migrate old documents to new structures

* Update repo-map.tldr

* More improvements

* Fix bug on deleted shapes

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

View file

@ -1,17 +1,11 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
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>
)

View file

@ -0,0 +1,217 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as React from 'react'
import type { TldrawApp, TDUser, TDShape, TDBinding, TDDocument } from '@tldraw/tldraw'
import { useRedo, useUndo, useRoom, useUpdateMyPresence } from '@liveblocks/react'
import { LiveMap, LiveObject } from '@liveblocks/client'
declare const window: Window & { app: TldrawApp }
export function useMultiplayerState(roomId: string) {
const [app, setApp] = React.useState<TldrawApp>()
const [error, setError] = React.useState<Error>()
const [loading, setLoading] = React.useState(true)
const rExpectingUpdate = React.useRef(false)
const room = useRoom()
const undo = useUndo()
const redo = useRedo()
const updateMyPresence = useUpdateMyPresence()
// Document Changes --------
const rLiveShapes = React.useRef<LiveMap<string, TDShape>>()
const rLiveBindings = React.useRef<LiveMap<string, TDBinding>>()
React.useEffect(() => {
const unsubs: (() => void)[] = []
if (!(app && room)) return
// Handle errors
unsubs.push(room.subscribe('error', (error) => setError(error)))
// Handle changes to other users' presence
unsubs.push(
room.subscribe('others', (others) => {
app.updateUsers(
others
.toArray()
.filter((other) => other.presence)
.map((other) => other.presence!.user)
.filter(Boolean)
)
})
)
// Handle events from the room
unsubs.push(
room.subscribe(
'event',
(e: { connectionId: number; event: { name: string; userId: string } }) => {
switch (e.event.name) {
case 'exit': {
app?.removeUser(e.event.userId)
break
}
}
}
)
)
// Send the exit event when the tab closes
function handleExit() {
if (!(room && app?.room)) return
room?.broadcastEvent({ name: 'exit', userId: app.room.userId })
}
window.addEventListener('beforeunload', handleExit)
unsubs.push(() => window.removeEventListener('beforeunload', handleExit))
// Setup the document's storage and subscriptions
async function setupDocument() {
const storage = await room.getStorage<any>()
// Initialize (get or create) shapes and bindings maps
let lShapes: LiveMap<string, TDShape> = storage.root.get('shapes')
if (!lShapes) {
storage.root.set('shapes', new LiveMap<string, TDShape>())
lShapes = storage.root.get('shapes')
}
rLiveShapes.current = lShapes
let lBindings: LiveMap<string, TDBinding> = storage.root.get('bindings')
if (!lBindings) {
storage.root.set('bindings', new LiveMap<string, TDBinding>())
lBindings = storage.root.get('bindings')
}
rLiveBindings.current = lBindings
// Subscribe to changes
function handleChanges() {
if (rExpectingUpdate.current) {
rExpectingUpdate.current = false
return
}
app?.replacePageContent(
Object.fromEntries(lShapes.entries()),
Object.fromEntries(lBindings.entries())
)
}
unsubs.push(room.subscribe(lShapes, handleChanges))
unsubs.push(room.subscribe(lBindings, handleChanges))
// Update the document with initial content
handleChanges()
// Migrate previous versions
const version = storage.root.get('version')
if (!version) {
// The doc object will only be present if the document was created
// prior to the current multiplayer implementation. At this time, the
// document was a single LiveObject named 'doc'. If we find a doc,
// then we need to move the shapes and bindings over to the new structures
// and then mark the doc as migrated.
const doc = storage.root.get('doc') as LiveObject<{
uuid: string
document: TDDocument
migrated?: boolean
}>
// No doc? No problem. This was likely
if (doc) {
const {
document: {
pages: {
page: { shapes, bindings },
},
},
} = doc.toObject()
Object.values(shapes).forEach((shape) => lShapes.set(shape.id, shape))
Object.values(bindings).forEach((binding) => lBindings.set(binding.id, binding))
}
}
// Save the version number for future migrations
storage.root.set('version', 2)
setLoading(false)
}
setupDocument()
return () => {
unsubs.forEach((unsub) => unsub())
}
}, [room, app])
// Callbacks --------------
// Put the state into the window, for debugging.
const onMount = React.useCallback(
(app: TldrawApp) => {
app.loadRoom(roomId)
app.pause() // Turn off the app's own undo / redo stack
window.app = app
setApp(app)
},
[roomId]
)
// Update the live shapes when the app's shapes change.
const onChangePage = React.useCallback(
(
app: TldrawApp,
shapes: Record<string, TDShape | undefined>,
bindings: Record<string, TDBinding | undefined>
) => {
room.batch(() => {
const lShapes = rLiveShapes.current
const lBindings = rLiveBindings.current
if (!(lShapes && lBindings)) return
Object.entries(shapes).forEach(([id, shape]) => {
if (!shape) {
lShapes.delete(id)
} else {
lShapes.set(shape.id, shape)
}
})
Object.entries(bindings).forEach(([id, binding]) => {
if (!binding) {
lBindings.delete(id)
} else {
lBindings.set(binding.id, binding)
}
})
rExpectingUpdate.current = true
})
},
[room]
)
// Handle presence updates when the user's pointer / selection changes
const onChangePresence = React.useCallback(
(app: TldrawApp, user: TDUser) => {
updateMyPresence({ id: app.room?.userId, user })
},
[updateMyPresence]
)
return {
undo,
redo,
onMount,
onChangePage,
onChangePresence,
error,
loading,
}
}

View file

@ -18,8 +18,8 @@
"lint": "next lint"
},
"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",

View file

@ -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"
}

View file

@ -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>
)
}

View file

@ -0,0 +1,217 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as React from 'react'
import type { TldrawApp, TDUser, TDShape, TDBinding, TDDocument } from '@tldraw/tldraw'
import { useRedo, useUndo, useRoom, useUpdateMyPresence } from '@liveblocks/react'
import { LiveMap, LiveObject } from '@liveblocks/client'
declare const window: Window & { app: TldrawApp }
export function useMultiplayerState(roomId: string) {
const [app, setApp] = React.useState<TldrawApp>()
const [error, setError] = React.useState<Error>()
const [loading, setLoading] = React.useState(true)
const rExpectingUpdate = React.useRef(false)
const room = useRoom()
const undo = useUndo()
const redo = useRedo()
const updateMyPresence = useUpdateMyPresence()
// Document Changes --------
const rLiveShapes = React.useRef<LiveMap<string, TDShape>>()
const rLiveBindings = React.useRef<LiveMap<string, TDBinding>>()
React.useEffect(() => {
const unsubs: (() => void)[] = []
if (!(app && room)) return
// Handle errors
unsubs.push(room.subscribe('error', (error) => setError(error)))
// Handle changes to other users' presence
unsubs.push(
room.subscribe('others', (others) => {
app.updateUsers(
others
.toArray()
.filter((other) => other.presence)
.map((other) => other.presence!.user)
.filter(Boolean)
)
})
)
// Handle events from the room
unsubs.push(
room.subscribe(
'event',
(e: { connectionId: number; event: { name: string; userId: string } }) => {
switch (e.event.name) {
case 'exit': {
app?.removeUser(e.event.userId)
break
}
}
}
)
)
// Send the exit event when the tab closes
function handleExit() {
if (!(room && app?.room)) return
room?.broadcastEvent({ name: 'exit', userId: app.room.userId })
}
window.addEventListener('beforeunload', handleExit)
unsubs.push(() => window.removeEventListener('beforeunload', handleExit))
// Setup the document's storage and subscriptions
async function setupDocument() {
const storage = await room.getStorage<any>()
// Initialize (get or create) shapes and bindings maps
let lShapes: LiveMap<string, TDShape> = storage.root.get('shapes')
if (!lShapes) {
storage.root.set('shapes', new LiveMap<string, TDShape>())
lShapes = storage.root.get('shapes')
}
rLiveShapes.current = lShapes
let lBindings: LiveMap<string, TDBinding> = storage.root.get('bindings')
if (!lBindings) {
storage.root.set('bindings', new LiveMap<string, TDBinding>())
lBindings = storage.root.get('bindings')
}
rLiveBindings.current = lBindings
// Subscribe to changes
function handleChanges() {
if (rExpectingUpdate.current) {
rExpectingUpdate.current = false
return
}
app?.replacePageContent(
Object.fromEntries(lShapes.entries()),
Object.fromEntries(lBindings.entries())
)
}
unsubs.push(room.subscribe(lShapes, handleChanges))
unsubs.push(room.subscribe(lBindings, handleChanges))
// Update the document with initial content
handleChanges()
// Migrate previous versions
const version = storage.root.get('version')
if (!version) {
// The doc object will only be present if the document was created
// prior to the current multiplayer implementation. At this time, the
// document was a single LiveObject named 'doc'. If we find a doc,
// then we need to move the shapes and bindings over to the new structures
// and then mark the doc as migrated.
const doc = storage.root.get('doc') as LiveObject<{
uuid: string
document: TDDocument
migrated?: boolean
}>
// No doc? No problem. This was likely
if (doc) {
const {
document: {
pages: {
page: { shapes, bindings },
},
},
} = doc.toObject()
Object.values(shapes).forEach((shape) => lShapes.set(shape.id, shape))
Object.values(bindings).forEach((binding) => lBindings.set(binding.id, binding))
}
}
// Save the version number for future migrations
storage.root.set('version', 2)
setLoading(false)
}
setupDocument()
return () => {
unsubs.forEach((unsub) => unsub())
}
}, [room, app])
// Callbacks --------------
// Put the state into the window, for debugging.
const onMount = React.useCallback(
(app: TldrawApp) => {
app.loadRoom(roomId)
app.pause() // Turn off the app's own undo / redo stack
window.app = app
setApp(app)
},
[roomId]
)
// Update the live shapes when the app's shapes change.
const onChangePage = React.useCallback(
(
app: TldrawApp,
shapes: Record<string, TDShape | undefined>,
bindings: Record<string, TDBinding | undefined>
) => {
room.batch(() => {
const lShapes = rLiveShapes.current
const lBindings = rLiveBindings.current
if (!(lShapes && lBindings)) return
Object.entries(shapes).forEach(([id, shape]) => {
if (!shape) {
lShapes.delete(id)
} else {
lShapes.set(shape.id, shape)
}
})
Object.entries(bindings).forEach(([id, binding]) => {
if (!binding) {
lBindings.delete(id)
} else {
lBindings.set(binding.id, binding)
}
})
rExpectingUpdate.current = true
})
},
[room]
)
// Handle presence updates when the user's pointer / selection changes
const onChangePresence = React.useCallback(
(app: TldrawApp, user: TDUser) => {
updateMyPresence({ id: app.room?.userId, user })
},
[updateMyPresence]
)
return {
undo,
redo,
onMount,
onChangePage,
onChangePresence,
error,
loading,
}
}

View file

@ -52,10 +52,11 @@
"@tldraw/core": "^1.1.2",
"@tldraw/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",

View file

@ -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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/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),
})

View file

@ -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(

View file

@ -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'

View file

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

View file

@ -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(

View file

@ -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(

View file

@ -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'

View file

@ -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'

View file

@ -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>
}

View file

@ -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
}
}
}

View file

@ -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==