Upgrade Liveblocks to 0.17 (#738)
* Upgrade Liveblocks packages to 0.17 * Convert app to recommended Liveblocks 0.17 setup * Convert multiplayer example to recommended Liveblocks 0.17 setup * Convert multiplayer-with-images example to recommended Liveblocks 0.17 setup * Fix React rendering issue for multiplayer app This bug could manifest after _navigating_ internally to the Multiplayer example app. Liveblocks Storage would seem to remain empty, but Presence would still seem to work. In other words, you'd see cursors flying, but no document contents. This did not happen when doing a full page load. This bug only occurs in React strict mode. * update onPatch and onCommand * "Add event callbacks for `onSessionStart` and `onSessionEnd`" * Adds edit state * Pass callbacks to app * Remove console logs * interpolate cursor only when not in session * Update multiplayer icon * Fix a few things Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
17
.eslintrc
|
@ -8,7 +8,22 @@
|
||||||
// enable the rule specifically for TypeScript files
|
// enable the rule specifically for TypeScript files
|
||||||
"files": ["*.ts", "*.tsx"],
|
"files": ["*.ts", "*.tsx"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"@typescript-eslint/explicit-module-boundary-types": [0]
|
"@typescript-eslint/explicit-module-boundary-types": [0],
|
||||||
|
"no-non-null-assertion": "off",
|
||||||
|
"no-fallthrough": "off",
|
||||||
|
"@typescript-eslint/no-fallthrough": "off",
|
||||||
|
"@typescript-eslint/no-non-null-assertion": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/ban-ts-comment": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"argsIgnorePattern": "^_",
|
||||||
|
"varsIgnorePattern": "^_",
|
||||||
|
"destructuredArrayIgnorePattern": "^_",
|
||||||
|
"caughtErrorsIgnorePattern": "^_"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { createClient } from '@liveblocks/client'
|
import { RoomProvider } from '../utils/liveblocks'
|
||||||
import { LiveblocksProvider, RoomProvider } from '@liveblocks/react'
|
|
||||||
import { Tldraw, useFileSystem } from '@tldraw/tldraw'
|
import { Tldraw, useFileSystem } from '@tldraw/tldraw'
|
||||||
import { useAccountHandlers } from 'hooks/useAccountHandlers'
|
import { useAccountHandlers } from 'hooks/useAccountHandlers'
|
||||||
import { useMultiplayerAssets } from 'hooks/useMultiplayerAssets'
|
import { useMultiplayerAssets } from 'hooks/useMultiplayerAssets'
|
||||||
|
@ -8,11 +7,6 @@ import { useUploadAssets } from 'hooks/useUploadAssets'
|
||||||
import React, { FC } from 'react'
|
import React, { FC } from 'react'
|
||||||
import { styled } from 'styles'
|
import { styled } from 'styles'
|
||||||
|
|
||||||
const client = createClient({
|
|
||||||
publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_API_KEY || '',
|
|
||||||
throttle: 80,
|
|
||||||
})
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
roomId: string
|
roomId: string
|
||||||
isUser: boolean
|
isUser: boolean
|
||||||
|
@ -29,11 +23,9 @@ const MultiplayerEditor: FC<Props> = ({
|
||||||
isSponsor: boolean
|
isSponsor: boolean
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<LiveblocksProvider client={client}>
|
|
||||||
<RoomProvider id={roomId}>
|
<RoomProvider id={roomId}>
|
||||||
<Editor roomId={roomId} isSponsor={isSponsor} isUser={isUser} />
|
<Editor roomId={roomId} isSponsor={isSponsor} isUser={isUser} />
|
||||||
</RoomProvider>
|
</RoomProvider>
|
||||||
</LiveblocksProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,21 +1,20 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
import React, { useState, useRef, useCallback } from 'react'
|
import React, { useState, useRef, useCallback } from 'react'
|
||||||
import type { TldrawApp, TDUser, TDShape, TDBinding, TDDocument, TDAsset } from '@tldraw/tldraw'
|
import type { TldrawApp, TDUser, TDShape, TDBinding, TDAsset } from '@tldraw/tldraw'
|
||||||
import { useRedo, useUndo, useRoom, useUpdateMyPresence } from '@liveblocks/react'
|
import {
|
||||||
import { LiveMap, LiveObject, Lson, LsonObject } from '@liveblocks/client'
|
Storage,
|
||||||
|
useRedo,
|
||||||
|
useUndo,
|
||||||
|
useRoom,
|
||||||
|
useHistory,
|
||||||
|
useUpdateMyPresence,
|
||||||
|
} from '../utils/liveblocks'
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
|
import { LiveMap } from '@liveblocks/client'
|
||||||
|
|
||||||
declare const window: Window & { app: TldrawApp }
|
declare const window: Window & { app: TldrawApp }
|
||||||
|
|
||||||
type TDLsonShape = TDShape & Lson
|
|
||||||
type TDLsonBinding = TDBinding & Lson
|
|
||||||
type TDLsonAsset = TDAsset & Lson
|
|
||||||
type LsonDoc = {
|
|
||||||
uuid: string
|
|
||||||
document: TDDocument
|
|
||||||
migrated?: boolean
|
|
||||||
} & LsonObject
|
|
||||||
|
|
||||||
export function useMultiplayerState(roomId: string) {
|
export function useMultiplayerState(roomId: string) {
|
||||||
const [app, setApp] = useState<TldrawApp>()
|
const [app, setApp] = useState<TldrawApp>()
|
||||||
const [error, setError] = useState<Error>()
|
const [error, setError] = useState<Error>()
|
||||||
|
@ -26,9 +25,11 @@ export function useMultiplayerState(roomId: string) {
|
||||||
const onRedo = useRedo()
|
const onRedo = useRedo()
|
||||||
const updateMyPresence = useUpdateMyPresence()
|
const updateMyPresence = useUpdateMyPresence()
|
||||||
|
|
||||||
const rLiveShapes = useRef<LiveMap<string, TDLsonShape>>()
|
const rIsPaused = useRef(false)
|
||||||
const rLiveBindings = useRef<LiveMap<string, TDLsonBinding>>()
|
|
||||||
const rLiveAssets = useRef<LiveMap<string, TDLsonAsset>>()
|
const rLiveShapes = useRef<Storage['shapes']>()
|
||||||
|
const rLiveBindings = useRef<Storage['bindings']>()
|
||||||
|
const rLiveAssets = useRef<Storage['assets']>()
|
||||||
|
|
||||||
// Callbacks --------------
|
// Callbacks --------------
|
||||||
|
|
||||||
|
@ -62,7 +63,7 @@ export function useMultiplayerState(roomId: string) {
|
||||||
if (!shape) {
|
if (!shape) {
|
||||||
lShapes.delete(id)
|
lShapes.delete(id)
|
||||||
} else {
|
} else {
|
||||||
lShapes.set(shape.id, shape as TDLsonShape)
|
lShapes.set(shape.id, shape)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -70,7 +71,7 @@ export function useMultiplayerState(roomId: string) {
|
||||||
if (!binding) {
|
if (!binding) {
|
||||||
lBindings.delete(id)
|
lBindings.delete(id)
|
||||||
} else {
|
} else {
|
||||||
lBindings.set(binding.id, binding as TDLsonBinding)
|
lBindings.set(binding.id, binding)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -78,7 +79,7 @@ export function useMultiplayerState(roomId: string) {
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
lAssets.delete(id)
|
lAssets.delete(id)
|
||||||
} else {
|
} else {
|
||||||
lAssets.set(asset.id, asset as TDLsonAsset)
|
lAssets.set(asset.id, asset)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -104,7 +105,7 @@ export function useMultiplayerState(roomId: string) {
|
||||||
|
|
||||||
// Handle changes to other users' presence
|
// Handle changes to other users' presence
|
||||||
unsubs.push(
|
unsubs.push(
|
||||||
room.subscribe<{ id: string; user: TDUser }>('others', (others, event) => {
|
room.subscribe('others', (others, event) => {
|
||||||
if (event.type === 'leave') {
|
if (event.type === 'leave') {
|
||||||
if (event.user.presence) {
|
if (event.user.presence) {
|
||||||
app?.removeUser(event.user.presence.id)
|
app?.removeUser(event.user.presence.id)
|
||||||
|
@ -125,30 +126,30 @@ export function useMultiplayerState(roomId: string) {
|
||||||
|
|
||||||
// Setup the document's storage and subscriptions
|
// Setup the document's storage and subscriptions
|
||||||
async function setupDocument() {
|
async function setupDocument() {
|
||||||
const storage = await room.getStorage<any>()
|
const storage = await room.getStorage()
|
||||||
|
|
||||||
// Migrate previous versions
|
// Migrate previous versions
|
||||||
const version = storage.root.get('version')
|
const version = storage.root.get('version')
|
||||||
|
|
||||||
// Initialize (get or create) maps for shapes/bindings/assets
|
// Initialize (get or create) maps for shapes/bindings/assets
|
||||||
|
|
||||||
let lShapes: LiveMap<string, TDLsonShape> = storage.root.get('shapes')
|
let lShapes = storage.root.get('shapes')
|
||||||
if (!lShapes || !('_serialize' in lShapes)) {
|
if (!lShapes || !('_serialize' in lShapes)) {
|
||||||
storage.root.set('shapes', new LiveMap<string, TDLsonShape>())
|
storage.root.set('shapes', new LiveMap())
|
||||||
lShapes = storage.root.get('shapes')
|
lShapes = storage.root.get('shapes')
|
||||||
}
|
}
|
||||||
rLiveShapes.current = lShapes
|
rLiveShapes.current = lShapes
|
||||||
|
|
||||||
let lBindings: LiveMap<string, TDLsonBinding> = storage.root.get('bindings')
|
let lBindings = storage.root.get('bindings')
|
||||||
if (!lBindings || !('_serialize' in lBindings)) {
|
if (!lBindings || !('_serialize' in lBindings)) {
|
||||||
storage.root.set('bindings', new LiveMap<string, TDLsonBinding>())
|
storage.root.set('bindings', new LiveMap())
|
||||||
lBindings = storage.root.get('bindings')
|
lBindings = storage.root.get('bindings')
|
||||||
}
|
}
|
||||||
rLiveBindings.current = lBindings
|
rLiveBindings.current = lBindings
|
||||||
|
|
||||||
let lAssets: LiveMap<string, TDLsonAsset> = storage.root.get('assets')
|
let lAssets = storage.root.get('assets')
|
||||||
if (!lAssets || !('_serialize' in lAssets)) {
|
if (!lAssets || !('_serialize' in lAssets)) {
|
||||||
storage.root.set('assets', new LiveMap<string, TDLsonAsset>())
|
storage.root.set('assets', new LiveMap())
|
||||||
lAssets = storage.root.get('assets')
|
lAssets = storage.root.get('assets')
|
||||||
}
|
}
|
||||||
rLiveAssets.current = lAssets
|
rLiveAssets.current = lAssets
|
||||||
|
@ -159,7 +160,7 @@ export function useMultiplayerState(roomId: string) {
|
||||||
// document was a single LiveObject named 'doc'. If we find a doc,
|
// 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
|
// then we need to move the shapes and bindings over to the new structures
|
||||||
// and then mark the doc as migrated.
|
// and then mark the doc as migrated.
|
||||||
const doc = storage.root.get('doc') as LiveObject<LsonDoc>
|
const doc = storage.root.get('doc')
|
||||||
|
|
||||||
// No doc? No problem. This was likely a newer document
|
// No doc? No problem. This was likely a newer document
|
||||||
if (doc) {
|
if (doc) {
|
||||||
|
@ -172,11 +173,9 @@ export function useMultiplayerState(roomId: string) {
|
||||||
},
|
},
|
||||||
} = doc.toObject()
|
} = doc.toObject()
|
||||||
|
|
||||||
Object.values(shapes).forEach((shape) => lShapes.set(shape.id, shape as TDLsonShape))
|
Object.values(shapes).forEach((shape) => lShapes.set(shape.id, shape))
|
||||||
Object.values(bindings).forEach((binding) =>
|
Object.values(bindings).forEach((binding) => lBindings.set(binding.id, binding))
|
||||||
lBindings.set(binding.id, binding as TDLsonBinding)
|
Object.values(assets).forEach((asset) => lAssets.set(asset.id, asset))
|
||||||
)
|
|
||||||
Object.values(assets).forEach((asset) => lAssets.set(asset.id, asset as TDLsonAsset))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -219,13 +218,43 @@ export function useMultiplayerState(roomId: string) {
|
||||||
const onSessionStart = React.useCallback(() => {
|
const onSessionStart = React.useCallback(() => {
|
||||||
if (!room) return
|
if (!room) return
|
||||||
room.history.pause()
|
room.history.pause()
|
||||||
|
rIsPaused.current = true
|
||||||
}, [room])
|
}, [room])
|
||||||
|
|
||||||
const onSessionEnd = React.useCallback(() => {
|
const onSessionEnd = React.useCallback(() => {
|
||||||
if (!room) return
|
if (!room) return
|
||||||
room.history.resume()
|
room.history.resume()
|
||||||
|
rIsPaused.current = false
|
||||||
}, [room])
|
}, [room])
|
||||||
|
|
||||||
|
useHotkeys(
|
||||||
|
'ctrl+shift+l;,⌘+shift+l',
|
||||||
|
() => {
|
||||||
|
if (window.confirm('Reset the document?')) {
|
||||||
|
room.batch(() => {
|
||||||
|
const lShapes = rLiveShapes.current
|
||||||
|
const lBindings = rLiveBindings.current
|
||||||
|
const lAssets = rLiveAssets.current
|
||||||
|
|
||||||
|
if (!(lShapes && lBindings && lAssets)) return
|
||||||
|
|
||||||
|
lShapes.forEach((shape) => {
|
||||||
|
lShapes.delete(shape.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
lBindings.forEach((shape) => {
|
||||||
|
lBindings.delete(shape.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
lAssets.forEach((shape) => {
|
||||||
|
lAssets.delete(shape.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||||
const withPWA = require('next-pwa')
|
const withPWA = require('next-pwa')
|
||||||
|
const withTM = require('next-transpile-modules')
|
||||||
const SentryWebpackPlugin = require('@sentry/webpack-plugin')
|
const SentryWebpackPlugin = require('@sentry/webpack-plugin')
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
@ -20,7 +21,8 @@ const isProduction = NODE_ENV === 'production'
|
||||||
|
|
||||||
const basePath = ''
|
const basePath = ''
|
||||||
|
|
||||||
module.exports = withPWA({
|
module.exports = withTM(['@tldraw/tldraw', '@tldraw/core'])(
|
||||||
|
withPWA({
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
pwa: {
|
pwa: {
|
||||||
disable: !isProduction,
|
disable: !isProduction,
|
||||||
|
@ -73,4 +75,5 @@ module.exports = withPWA({
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
)
|
||||||
|
|
|
@ -18,23 +18,22 @@
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/webpack-plugin": "^1.17.1",
|
"@liveblocks/client": "^0.17.0-beta2",
|
||||||
"@types/next-auth": "^3.15.0",
|
"@liveblocks/react": "^0.17.0-beta2",
|
||||||
"@types/react": "^18.0.12",
|
|
||||||
"@types/react-dom": "^18.0.5",
|
|
||||||
"eslint": "^8.8.0",
|
|
||||||
"eslint-config-next": "^12.0.10",
|
|
||||||
"typescript": "^4.7.3",
|
|
||||||
"@liveblocks/client": "^0.16.17",
|
|
||||||
"@liveblocks/react": "^0.16.17",
|
|
||||||
"@sentry/integrations": "^6.13.2",
|
"@sentry/integrations": "^6.13.2",
|
||||||
"@sentry/node": "^6.13.2",
|
"@sentry/node": "^6.13.2",
|
||||||
"@sentry/react": "^6.13.2",
|
"@sentry/react": "^6.13.2",
|
||||||
"@sentry/tracing": "^6.13.2",
|
"@sentry/tracing": "^6.13.2",
|
||||||
|
"@sentry/webpack-plugin": "^1.17.1",
|
||||||
"@stitches/react": "^1.2.8",
|
"@stitches/react": "^1.2.8",
|
||||||
"@tldraw/core": "*",
|
"@tldraw/core": "*",
|
||||||
"@tldraw/tldraw": "*",
|
"@tldraw/tldraw": "*",
|
||||||
|
"@types/next-auth": "^3.15.0",
|
||||||
|
"@types/react": "^18.0.12",
|
||||||
|
"@types/react-dom": "^18.0.5",
|
||||||
"aws-sdk": "^2.1053.0",
|
"aws-sdk": "^2.1053.0",
|
||||||
|
"eslint": "^8.8.0",
|
||||||
|
"eslint-config-next": "^12.0.10",
|
||||||
"lz-string": "^1.4.4",
|
"lz-string": "^1.4.4",
|
||||||
"nanoid": "^3.3.4",
|
"nanoid": "^3.3.4",
|
||||||
"next": "^12.1.6",
|
"next": "^12.1.6",
|
||||||
|
@ -42,7 +41,12 @@
|
||||||
"next-pwa": "^5.5.4",
|
"next-pwa": "^5.5.4",
|
||||||
"next-themes": "^0.0.15",
|
"next-themes": "^0.0.15",
|
||||||
"react": "^18.1.0",
|
"react": "^18.1.0",
|
||||||
"react-dom": "^18.1.0"
|
"react-dom": "^18.1.0",
|
||||||
|
"react-hotkeys-hook": "^3.4.6",
|
||||||
|
"typescript": "^4.7.3"
|
||||||
},
|
},
|
||||||
"gitHead": "838fabdbff1a66d4d7ee8aa5c5d117bc55acbff2"
|
"gitHead": "838fabdbff1a66d4d7ee8aa5c5d117bc55acbff2",
|
||||||
|
"devDependencies": {
|
||||||
|
"next-transpile-modules": "^9.0.0"
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.5 2.49538C12.2239 2.49538 12 2.71923 12 2.99538V5.49559H9.49979C9.22364 5.49559 8.99979 5.71945 8.99979 5.99559C8.99979 6.27173 9.22364 6.49559 9.49979 6.49559H12.5C12.7761 6.49559 13 6.27173 13 5.99559V2.99538C13 2.71923 12.7761 2.49538 12.5 2.49538Z" fill="black"/>
|
<path fillRule="evenodd" clipRule="evenodd" d="M12.5 2.49538C12.2239 2.49538 12 2.71923 12 2.99538V5.49559H9.49979C9.22364 5.49559 8.99979 5.71945 8.99979 5.99559C8.99979 6.27173 9.22364 6.49559 9.49979 6.49559H12.5C12.7761 6.49559 13 6.27173 13 5.99559V2.99538C13 2.71923 12.7761 2.49538 12.5 2.49538Z" fill="black"/>
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.69698 2.04877C6.62345 1.89773 5.52991 2.09968 4.58113 2.62417C3.63236 3.14867 2.87973 3.9673 2.43667 4.95673C1.99361 5.94616 1.8841 7.05278 2.12465 8.10985C2.3652 9.16693 2.94278 10.1172 3.77036 10.8175C4.59794 11.5177 5.63069 11.9301 6.713 11.9924C7.79531 12.0547 8.86855 11.7635 9.77101 11.1628C10.6735 10.5621 11.3563 9.68441 11.7165 8.66191C11.8083 8.40146 11.6715 8.11593 11.4111 8.02417C11.1506 7.93241 10.8651 8.06916 10.7733 8.32961C10.4851 9.14762 9.93888 9.84981 9.21691 10.3304C8.49493 10.811 7.63632 11.0439 6.77046 10.994C5.9046 10.9442 5.07839 10.6143 4.41631 10.0541C3.75424 9.49386 3.29217 8.73363 3.09972 7.88796C2.90728 7.04229 2.99488 6.15698 3.34934 5.36542C3.7038 4.57387 4.30591 3.91895 5.06494 3.49935C5.82398 3.07974 6.69882 2.91819 7.55765 3.03902C8.41649 3.15985 9.21279 3.55653 9.82658 4.16928L9.83745 4.17981L12.1576 6.35996C12.3588 6.54906 12.6753 6.53921 12.8644 6.33797C13.0535 6.13673 13.0436 5.8203 12.8424 5.63121L10.5276 3.4561C9.76111 2.69329 8.76794 2.19945 7.69698 2.04877Z" fill="black"/>
|
<path fillRule="evenodd" clipRule="evenodd" d="M7.69698 2.04877C6.62345 1.89773 5.52991 2.09968 4.58113 2.62417C3.63236 3.14867 2.87973 3.9673 2.43667 4.95673C1.99361 5.94616 1.8841 7.05278 2.12465 8.10985C2.3652 9.16693 2.94278 10.1172 3.77036 10.8175C4.59794 11.5177 5.63069 11.9301 6.713 11.9924C7.79531 12.0547 8.86855 11.7635 9.77101 11.1628C10.6735 10.5621 11.3563 9.68441 11.7165 8.66191C11.8083 8.40146 11.6715 8.11593 11.4111 8.02417C11.1506 7.93241 10.8651 8.06916 10.7733 8.32961C10.4851 9.14762 9.93888 9.84981 9.21691 10.3304C8.49493 10.811 7.63632 11.0439 6.77046 10.994C5.9046 10.9442 5.07839 10.6143 4.41631 10.0541C3.75424 9.49386 3.29217 8.73363 3.09972 7.88796C2.90728 7.04229 2.99488 6.15698 3.34934 5.36542C3.7038 4.57387 4.30591 3.91895 5.06494 3.49935C5.82398 3.07974 6.69882 2.91819 7.55765 3.03902C8.41649 3.15985 9.21279 3.55653 9.82658 4.16928L9.83745 4.17981L12.1576 6.35996C12.3588 6.54906 12.6753 6.53921 12.8644 6.33797C13.0535 6.13673 13.0436 5.8203 12.8424 5.63121L10.5276 3.4561C9.76111 2.69329 8.76794 2.19945 7.69698 2.04877Z" fill="black"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
@ -1,6 +1,6 @@
|
||||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 4.65555C2 4.37941 2.22386 4.15555 2.5 4.15555H12.2C12.4761 4.15555 12.7 4.37941 12.7 4.65555C12.7 4.93169 12.4761 5.15555 12.2 5.15555H2.5C2.22386 5.15555 2 4.93169 2 4.65555Z" fill="black"/>
|
<path fillRule="evenodd" clipRule="evenodd" d="M2 4.65555C2 4.37941 2.22386 4.15555 2.5 4.15555H12.2C12.4761 4.15555 12.7 4.37941 12.7 4.65555C12.7 4.93169 12.4761 5.15555 12.2 5.15555H2.5C2.22386 5.15555 2 4.93169 2 4.65555Z" fill="black"/>
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.27208 3C6.11885 3 5.97189 3.06087 5.86353 3.16923C5.75518 3.27758 5.6943 3.42454 5.6943 3.57778V4.15556H9.00542V3.57778C9.00542 3.42454 8.94454 3.27758 8.83619 3.16923C8.72783 3.06087 8.58087 3 8.42764 3H6.27208ZM10.0054 4.15556V3.57778C10.0054 3.15933 9.83919 2.75801 9.54329 2.46212C9.2474 2.16623 8.84609 2 8.42764 2H6.27208C5.85363 2 5.45232 2.16623 5.15642 2.46212C4.86053 2.75801 4.6943 3.15933 4.6943 3.57778V4.15556H3.57764C3.30149 4.15556 3.07764 4.37941 3.07764 4.65556V12.2C3.07764 12.6185 3.24387 13.0198 3.53976 13.3157C3.83565 13.6115 4.23696 13.7778 4.65541 13.7778H10.0443C10.4628 13.7778 10.8641 13.6115 11.16 13.3157C11.4559 13.0198 11.6221 12.6185 11.6221 12.2V4.65556C11.6221 4.37941 11.3982 4.15556 11.1221 4.15556H10.0054ZM4.07764 5.15556V12.2C4.07764 12.3532 4.13851 12.5002 4.24686 12.6086C4.35522 12.7169 4.50218 12.7778 4.65541 12.7778H10.0443C10.1975 12.7778 10.3445 12.7169 10.4529 12.6086C10.5612 12.5002 10.6221 12.3532 10.6221 12.2V5.15556H4.07764Z" fill="black"/>
|
<path fillRule="evenodd" clipRule="evenodd" d="M6.27208 3C6.11885 3 5.97189 3.06087 5.86353 3.16923C5.75518 3.27758 5.6943 3.42454 5.6943 3.57778V4.15556H9.00542V3.57778C9.00542 3.42454 8.94454 3.27758 8.83619 3.16923C8.72783 3.06087 8.58087 3 8.42764 3H6.27208ZM10.0054 4.15556V3.57778C10.0054 3.15933 9.83919 2.75801 9.54329 2.46212C9.2474 2.16623 8.84609 2 8.42764 2H6.27208C5.85363 2 5.45232 2.16623 5.15642 2.46212C4.86053 2.75801 4.6943 3.15933 4.6943 3.57778V4.15556H3.57764C3.30149 4.15556 3.07764 4.37941 3.07764 4.65556V12.2C3.07764 12.6185 3.24387 13.0198 3.53976 13.3157C3.83565 13.6115 4.23696 13.7778 4.65541 13.7778H10.0443C10.4628 13.7778 10.8641 13.6115 11.16 13.3157C11.4559 13.0198 11.6221 12.6185 11.6221 12.2V4.65556C11.6221 4.37941 11.3982 4.15556 11.1221 4.15556H10.0054ZM4.07764 5.15556V12.2C4.07764 12.3532 4.13851 12.5002 4.24686 12.6086C4.35522 12.7169 4.50218 12.7778 4.65541 12.7778H10.0443C10.1975 12.7778 10.3445 12.7169 10.4529 12.6086C10.5612 12.5002 10.6221 12.3532 10.6221 12.2V5.15556H4.07764Z" fill="black"/>
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.27246 6.85001C6.5486 6.85001 6.77246 7.07386 6.77246 7.35001V10.5833C6.77246 10.8595 6.5486 11.0833 6.27246 11.0833C5.99632 11.0833 5.77246 10.8595 5.77246 10.5833V7.35001C5.77246 7.07386 5.99632 6.85001 6.27246 6.85001Z" fill="black"/>
|
<path fillRule="evenodd" clipRule="evenodd" d="M6.27246 6.85001C6.5486 6.85001 6.77246 7.07386 6.77246 7.35001V10.5833C6.77246 10.8595 6.5486 11.0833 6.27246 11.0833C5.99632 11.0833 5.77246 10.8595 5.77246 10.5833V7.35001C5.77246 7.07386 5.99632 6.85001 6.27246 6.85001Z" fill="black"/>
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.42773 6.85001C8.70388 6.85001 8.92773 7.07386 8.92773 7.35001V10.5833C8.92773 10.8595 8.70388 11.0833 8.42773 11.0833C8.15159 11.0833 7.92773 10.8595 7.92773 10.5833V7.35001C7.92773 7.07386 8.15159 6.85001 8.42773 6.85001Z" fill="black"/>
|
<path fillRule="evenodd" clipRule="evenodd" d="M8.42773 6.85001C8.70388 6.85001 8.92773 7.07386 8.92773 7.35001V10.5833C8.92773 10.8595 8.70388 11.0833 8.42773 11.0833C8.15159 11.0833 7.92773 10.8595 7.92773 10.5833V7.35001C7.92773 7.07386 8.15159 6.85001 8.42773 6.85001Z" fill="black"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
@ -1,4 +1,4 @@
|
||||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.5 2.49538C2.77614 2.49538 3 2.71923 3 2.99538V5.49559H5.50021C5.77636 5.49559 6.00021 5.71945 6.00021 5.99559C6.00021 6.27173 5.77636 6.49559 5.50021 6.49559H2.5C2.22386 6.49559 2 6.27173 2 5.99559V2.99538C2 2.71923 2.22386 2.49538 2.5 2.49538Z" fill="black"/>
|
<path fillRule="evenodd" clipRule="evenodd" d="M2.5 2.49538C2.77614 2.49538 3 2.71923 3 2.99538V5.49559H5.50021C5.77636 5.49559 6.00021 5.71945 6.00021 5.99559C6.00021 6.27173 5.77636 6.49559 5.50021 6.49559H2.5C2.22386 6.49559 2 6.27173 2 5.99559V2.99538C2 2.71923 2.22386 2.49538 2.5 2.49538Z" fill="black"/>
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.30302 2.04877C8.37655 1.89773 9.47009 2.09968 10.4189 2.62417C11.3676 3.14867 12.1203 3.9673 12.5633 4.95673C13.0064 5.94616 13.1159 7.05278 12.8753 8.10985C12.6348 9.16693 12.0572 10.1172 11.2296 10.8175C10.4021 11.5177 9.36931 11.9301 8.287 11.9924C7.20469 12.0547 6.13145 11.7635 5.22899 11.1628C4.32653 10.5621 3.64374 9.68441 3.2835 8.66191C3.19174 8.40146 3.32849 8.11593 3.58894 8.02417C3.84939 7.93241 4.13492 8.06916 4.22668 8.32961C4.51488 9.14762 5.06112 9.84981 5.78309 10.3304C6.50507 10.811 7.36368 11.0439 8.22954 10.994C9.0954 10.9442 9.92161 10.6143 10.5837 10.0541C11.2458 9.49386 11.7078 8.73363 11.9003 7.88796C12.0927 7.04229 12.0051 6.15698 11.6507 5.36542C11.2962 4.57387 10.6941 3.91895 9.93506 3.49935C9.17602 3.07974 8.30118 2.91819 7.44235 3.03902C6.58351 3.15985 5.78721 3.55653 5.17342 4.16928L5.16255 4.17981L2.84239 6.35996C2.64115 6.54906 2.32472 6.53921 2.13562 6.33797C1.94653 6.13673 1.95637 5.8203 2.15761 5.63121L4.47241 3.4561C5.23889 2.69329 6.23206 2.19945 7.30302 2.04877Z" fill="black"/>
|
<path fillRule="evenodd" clipRule="evenodd" d="M7.30302 2.04877C8.37655 1.89773 9.47009 2.09968 10.4189 2.62417C11.3676 3.14867 12.1203 3.9673 12.5633 4.95673C13.0064 5.94616 13.1159 7.05278 12.8753 8.10985C12.6348 9.16693 12.0572 10.1172 11.2296 10.8175C10.4021 11.5177 9.36931 11.9301 8.287 11.9924C7.20469 12.0547 6.13145 11.7635 5.22899 11.1628C4.32653 10.5621 3.64374 9.68441 3.2835 8.66191C3.19174 8.40146 3.32849 8.11593 3.58894 8.02417C3.84939 7.93241 4.13492 8.06916 4.22668 8.32961C4.51488 9.14762 5.06112 9.84981 5.78309 10.3304C6.50507 10.811 7.36368 11.0439 8.22954 10.994C9.0954 10.9442 9.92161 10.6143 10.5837 10.0541C11.2458 9.49386 11.7078 8.73363 11.9003 7.88796C12.0927 7.04229 12.0051 6.15698 11.6507 5.36542C11.2962 4.57387 10.6941 3.91895 9.93506 3.49935C9.17602 3.07974 8.30118 2.91819 7.44235 3.03902C6.58351 3.15985 5.78721 3.55653 5.17342 4.16928L5.16255 4.17981L2.84239 6.35996C2.64115 6.54906 2.32472 6.53921 2.13562 6.33797C1.94653 6.13673 1.95637 5.8203 2.15761 5.63121L4.47241 3.4561C5.23889 2.69329 6.23206 2.19945 7.30302 2.04877Z" fill="black"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
@ -1,7 +1,7 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
<svg width="35px" height="35px" viewBox="0 0 35 35" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
<svg width="35px" height="35px" viewBox="0 0 35 35" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
<defs></defs>
|
<defs></defs>
|
||||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fillRule="evenodd">
|
||||||
<g id="openhand">
|
<g id="openhand">
|
||||||
<g id="bg-copy" fill="#FFFFFF" opacity="1">
|
<g id="bg-copy" fill="#FFFFFF" opacity="1">
|
||||||
<rect id="bg" x="0" y="0" width="35" height="35"></rect>
|
<rect id="bg" x="0" y="0" width="35" height="35"></rect>
|
||||||
|
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
|
@ -1,7 +1,7 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
<svg width="35px" height="35px" viewBox="0 0 35 35" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
<svg width="35px" height="35px" viewBox="0 0 35 35" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
<defs></defs>
|
<defs></defs>
|
||||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fillRule="evenodd">
|
||||||
<g id="pointer">
|
<g id="pointer">
|
||||||
<g id="bg" fill="#FFFFFF" opacity="0.00999999978">
|
<g id="bg" fill="#FFFFFF" opacity="0.00999999978">
|
||||||
<rect x="0" y="0" width="35" height="35"></rect>
|
<rect x="0" y="0" width="35" height="35"></rect>
|
||||||
|
|
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 1 KiB |
|
@ -1,7 +1,7 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
<svg width="35px" height="35px" viewBox="0 0 35 35" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
<svg width="35px" height="35px" viewBox="0 0 35 35" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
<defs></defs>
|
<defs></defs>
|
||||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fillRule="evenodd">
|
||||||
<g id="resizenortheastsouthwest">
|
<g id="resizenortheastsouthwest">
|
||||||
<g id="bg-copy" fill="#FFFFFF" opacity="1">
|
<g id="bg-copy" fill="#FFFFFF" opacity="1">
|
||||||
<rect id="bg" x="0" y="0" width="35" height="35"></rect>
|
<rect id="bg" x="0" y="0" width="35" height="35"></rect>
|
||||||
|
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
52
apps/www/utils/liveblocks.ts
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import { createClient } from '@liveblocks/client'
|
||||||
|
import type { EnsureJson, LiveMap, LiveObject } from '@liveblocks/client'
|
||||||
|
import { createRoomContext } from '@liveblocks/react'
|
||||||
|
import type { TDUser, TDShape, TDBinding, TDDocument, TDAsset } from '@tldraw/tldraw'
|
||||||
|
|
||||||
|
const client = createClient({
|
||||||
|
publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_API_KEY || '',
|
||||||
|
throttle: 80,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Presence represents the properties that will exist on every User in the
|
||||||
|
// Liveblocks Room and that will automatically be kept in sync. Accessible
|
||||||
|
// through the `user.presence` property.
|
||||||
|
type Presence = {
|
||||||
|
id?: string
|
||||||
|
user: EnsureJson<TDUser>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage represents the shared document that persists in the Room, even after
|
||||||
|
// all Users leave, and for which updates are automatically persisted and
|
||||||
|
// synced to all connected clients.
|
||||||
|
export type Storage = {
|
||||||
|
version: number
|
||||||
|
doc: LiveObject<{
|
||||||
|
uuid: string
|
||||||
|
document: EnsureJson<TDDocument>
|
||||||
|
migrated?: boolean
|
||||||
|
}>
|
||||||
|
shapes: LiveMap<string, EnsureJson<TDShape>>
|
||||||
|
bindings: LiveMap<string, EnsureJson<TDBinding>>
|
||||||
|
assets: LiveMap<string, EnsureJson<TDAsset>>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally, UserMeta represents static/readonly metadata on each User, as
|
||||||
|
// provided by your own custom auth backend. This isn't used for TLDraw.
|
||||||
|
// type UserMeta = {
|
||||||
|
// id?: string, // Accessible through `user.id`
|
||||||
|
// info?: Json, // Accessible through `user.info`
|
||||||
|
// };
|
||||||
|
|
||||||
|
// Optionally, the type of custom events broadcasted and listened for in this
|
||||||
|
// room.
|
||||||
|
// type RoomEvent = {};
|
||||||
|
|
||||||
|
const { RoomProvider, useHistory, useRedo, useUndo, useRoom, useUpdateMyPresence } =
|
||||||
|
createRoomContext<
|
||||||
|
Presence,
|
||||||
|
Storage
|
||||||
|
/* UserMeta, RoomEvent */
|
||||||
|
>(client)
|
||||||
|
|
||||||
|
export { RoomProvider, useHistory, useRedo, useUndo, useRoom, useUpdateMyPresence }
|
|
@ -15,8 +15,8 @@
|
||||||
"build": "node scripts/build.mjs"
|
"build": "node scripts/build.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@liveblocks/client": "^0.14.0",
|
"@liveblocks/client": "^0.17.0-beta2",
|
||||||
"@liveblocks/react": "^0.14.0",
|
"@liveblocks/react": "^0.17.0-beta2",
|
||||||
"@types/node": "^17.0.14",
|
"@types/node": "^17.0.14",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"concurrently": "^7.0.0",
|
"concurrently": "^7.0.0",
|
||||||
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { createClient } from '@liveblocks/client'
|
||||||
|
import type { BaseUserMeta, EnsureJson } from '@liveblocks/client'
|
||||||
|
import { createRoomContext } from '@liveblocks/react'
|
||||||
|
import { LiveMap, LiveObject } from '@liveblocks/client'
|
||||||
|
import type { TDAsset, TDBinding, TDDocument, TDShape, TDUser } from '@tldraw/tldraw'
|
||||||
|
|
||||||
|
const client = createClient({
|
||||||
|
publicApiKey: process.env.LIVEBLOCKS_PUBLIC_API_KEY || '',
|
||||||
|
throttle: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Presence represents the properties that will exist on every User in the
|
||||||
|
// Liveblocks Room and that will automatically be synchronized between
|
||||||
|
// connected Users. Accessible through the `user.presence` property.
|
||||||
|
type Presence = {
|
||||||
|
id?: string
|
||||||
|
user: EnsureJson<TDUser>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage represents the shared document that persists in the Room, even after
|
||||||
|
// all Users leave. All Live structures here are automatically kept in sync
|
||||||
|
// between connected clients.
|
||||||
|
export type Storage = {
|
||||||
|
version: number
|
||||||
|
doc: LiveObject<{
|
||||||
|
uuid: string
|
||||||
|
document: EnsureJson<TDDocument>
|
||||||
|
migrated?: boolean
|
||||||
|
}>
|
||||||
|
assets: LiveMap<string, EnsureJson<TDAsset>>
|
||||||
|
bindings: LiveMap<string, EnsureJson<TDBinding>>
|
||||||
|
shapes: LiveMap<string, EnsureJson<TDShape>>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally, UserMeta represents static/readonly metadata on each User, as
|
||||||
|
// provided by a potential custom Liveblocks auth backend. This isn't used for
|
||||||
|
// TLDraw.
|
||||||
|
//
|
||||||
|
// type UserMeta = {
|
||||||
|
// id?: string, // Accessible through `user.id`
|
||||||
|
// info?: Json, // Accessible through `user.info`
|
||||||
|
// };
|
||||||
|
//
|
||||||
|
type UserMeta = BaseUserMeta
|
||||||
|
|
||||||
|
// Custom Events that TLDraw broadcasts between connected users
|
||||||
|
type RoomEvent =
|
||||||
|
// | { name: 'click', x: number, y: number }
|
||||||
|
// | { name: 'like', emoji: string }
|
||||||
|
// | etc.
|
||||||
|
{ name: 'exit'; userId: string }
|
||||||
|
|
||||||
|
const { RoomProvider, useRedo, useUndo, useRoom, useUpdateMyPresence } = createRoomContext<
|
||||||
|
Presence,
|
||||||
|
Storage,
|
||||||
|
UserMeta,
|
||||||
|
RoomEvent
|
||||||
|
>(client)
|
||||||
|
|
||||||
|
export { RoomProvider, useRedo, useUndo, useRoom, useUpdateMyPresence }
|
|
@ -1,28 +1,20 @@
|
||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { TDShape, Tldraw } from '@tldraw/tldraw'
|
import { Tldraw } from '@tldraw/tldraw'
|
||||||
import { createClient } from '@liveblocks/client'
|
import { RoomProvider } from './liveblocks.config'
|
||||||
import { LiveblocksProvider, RoomProvider } from '@liveblocks/react'
|
|
||||||
import { useMultiplayerState } from './useMultiplayerState'
|
import { useMultiplayerState } from './useMultiplayerState'
|
||||||
// import { initializeApp } from 'firebase/app'
|
// import { initializeApp } from 'firebase/app'
|
||||||
// import firebaseConfig from '../firebase.config'
|
// import firebaseConfig from '../firebase.config'
|
||||||
// import { useMemo } from 'react'
|
// import { useMemo } from 'react'
|
||||||
// import { getStorage, ref, uploadBytes, getDownloadURL, deleteObject } from 'firebase/storage'
|
// import { getStorage, ref, uploadBytes, getDownloadURL, deleteObject } from 'firebase/storage'
|
||||||
|
|
||||||
const client = createClient({
|
|
||||||
publicApiKey: process.env.LIVEBLOCKS_PUBLIC_API_KEY || '',
|
|
||||||
throttle: 100,
|
|
||||||
})
|
|
||||||
|
|
||||||
const roomId = 'mp-test-images-1'
|
const roomId = 'mp-test-images-1'
|
||||||
|
|
||||||
export function Multiplayer() {
|
export function Multiplayer() {
|
||||||
return (
|
return (
|
||||||
<LiveblocksProvider client={client}>
|
|
||||||
<RoomProvider id={roomId}>
|
<RoomProvider id={roomId}>
|
||||||
<Editor roomId={roomId} />
|
<Editor roomId={roomId} />
|
||||||
</RoomProvider>
|
</RoomProvider>
|
||||||
</LiveblocksProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import type { TldrawApp, TDUser, TDShape, TDBinding, TDDocument, TDAsset } from '@tldraw/tldraw'
|
import type { TldrawApp, TDUser, TDShape, TDBinding, TDAsset } from '@tldraw/tldraw'
|
||||||
import { useRedo, useUndo, useRoom, useUpdateMyPresence } from '@liveblocks/react'
|
import { useRedo, useUndo, useRoom, useUpdateMyPresence } from './liveblocks.config'
|
||||||
import { LiveMap, LiveObject } from '@liveblocks/client'
|
import { LiveMap } from '@liveblocks/client'
|
||||||
|
import type { Storage } from './liveblocks.config'
|
||||||
|
|
||||||
declare const window: Window & { app: TldrawApp }
|
declare const window: Window & { app: TldrawApp }
|
||||||
|
|
||||||
|
@ -17,9 +18,9 @@ export function useMultiplayerState(roomId: string) {
|
||||||
const onRedo = useRedo()
|
const onRedo = useRedo()
|
||||||
const updateMyPresence = useUpdateMyPresence()
|
const updateMyPresence = useUpdateMyPresence()
|
||||||
|
|
||||||
const rLiveShapes = React.useRef<LiveMap<string, TDShape>>()
|
const rLiveShapes = React.useRef<Storage['shapes'] | undefined>()
|
||||||
const rLiveBindings = React.useRef<LiveMap<string, TDBinding>>()
|
const rLiveBindings = React.useRef<Storage['bindings'] | undefined>()
|
||||||
const rLiveAssets = React.useRef<LiveMap<string, TDAsset>>()
|
const rLiveAssets = React.useRef<Storage['assets'] | undefined>()
|
||||||
|
|
||||||
// Callbacks --------------
|
// Callbacks --------------
|
||||||
|
|
||||||
|
@ -108,17 +109,14 @@ export function useMultiplayerState(roomId: string) {
|
||||||
|
|
||||||
// Handle events from the room
|
// Handle events from the room
|
||||||
unsubs.push(
|
unsubs.push(
|
||||||
room.subscribe(
|
room.subscribe('event', (e) => {
|
||||||
'event',
|
|
||||||
(e: { connectionId: number; event: { name: string; userId: string } }) => {
|
|
||||||
switch (e.event.name) {
|
switch (e.event.name) {
|
||||||
case 'exit': {
|
case 'exit': {
|
||||||
app?.removeUser(e.event.userId)
|
app?.removeUser(e.event.userId)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Send the exit event when the tab closes
|
// Send the exit event when the tab closes
|
||||||
|
@ -134,27 +132,27 @@ export function useMultiplayerState(roomId: string) {
|
||||||
|
|
||||||
// Setup the document's storage and subscriptions
|
// Setup the document's storage and subscriptions
|
||||||
async function setupDocument() {
|
async function setupDocument() {
|
||||||
const storage = await room.getStorage<any>()
|
const storage = await room.getStorage()
|
||||||
|
|
||||||
// Initialize (get or create) shapes and bindings maps
|
// Initialize (get or create) shapes and bindings maps
|
||||||
|
|
||||||
let lShapes: LiveMap<string, TDShape> = storage.root.get('shapes')
|
let lShapes = storage.root.get('shapes')
|
||||||
if (!lShapes) {
|
if (!lShapes) {
|
||||||
storage.root.set('shapes', new LiveMap<string, TDShape>())
|
storage.root.set('shapes', new LiveMap())
|
||||||
lShapes = storage.root.get('shapes')
|
lShapes = storage.root.get('shapes')
|
||||||
}
|
}
|
||||||
rLiveShapes.current = lShapes
|
rLiveShapes.current = lShapes
|
||||||
|
|
||||||
let lBindings: LiveMap<string, TDBinding> = storage.root.get('bindings')
|
let lBindings = storage.root.get('bindings')
|
||||||
if (!lBindings) {
|
if (!lBindings) {
|
||||||
storage.root.set('bindings', new LiveMap<string, TDBinding>())
|
storage.root.set('bindings', new LiveMap())
|
||||||
lBindings = storage.root.get('bindings')
|
lBindings = storage.root.get('bindings')
|
||||||
}
|
}
|
||||||
rLiveBindings.current = lBindings
|
rLiveBindings.current = lBindings
|
||||||
|
|
||||||
let lAssets: LiveMap<string, TDAsset> = storage.root.get('assets')
|
let lAssets = storage.root.get('assets')
|
||||||
if (!lAssets) {
|
if (!lAssets) {
|
||||||
storage.root.set('assets', new LiveMap<string, TDAsset>())
|
storage.root.set('assets', new LiveMap())
|
||||||
lAssets = storage.root.get('assets')
|
lAssets = storage.root.get('assets')
|
||||||
}
|
}
|
||||||
rLiveAssets.current = lAssets
|
rLiveAssets.current = lAssets
|
||||||
|
@ -168,11 +166,7 @@ export function useMultiplayerState(roomId: string) {
|
||||||
// document was a single LiveObject named 'doc'. If we find a doc,
|
// 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
|
// then we need to move the shapes and bindings over to the new structures
|
||||||
// and then mark the doc as migrated.
|
// and then mark the doc as migrated.
|
||||||
const doc = storage.root.get('doc') as LiveObject<{
|
const doc = storage.root.get('doc')
|
||||||
uuid: string
|
|
||||||
document: TDDocument
|
|
||||||
migrated?: boolean
|
|
||||||
}>
|
|
||||||
|
|
||||||
// No doc? No problem. This was likely a newer document
|
// No doc? No problem. This was likely a newer document
|
||||||
if (doc) {
|
if (doc) {
|
||||||
|
@ -218,7 +212,7 @@ export function useMultiplayerState(roomId: string) {
|
||||||
stillAlive = false
|
stillAlive = false
|
||||||
unsubs.forEach((unsub) => unsub())
|
unsubs.forEach((unsub) => unsub())
|
||||||
}
|
}
|
||||||
}, [app])
|
}, [app, room])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onUndo,
|
onUndo,
|
||||||
|
|
59
examples/tldraw-example/src/multiplayer/liveblocks.config.ts
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
import { createClient } from '@liveblocks/client'
|
||||||
|
import type { BaseUserMeta, EnsureJson } from '@liveblocks/client'
|
||||||
|
import { createRoomContext } from '@liveblocks/react'
|
||||||
|
import { LiveMap, LiveObject } from '@liveblocks/client'
|
||||||
|
import type { TDUser, TDShape, TDBinding, TDDocument } from '@tldraw/tldraw'
|
||||||
|
|
||||||
|
const client = createClient({
|
||||||
|
publicApiKey: process.env.LIVEBLOCKS_PUBLIC_API_KEY || '',
|
||||||
|
throttle: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Presence represents the properties that will exist on every User in the
|
||||||
|
// Liveblocks Room and that will automatically be synchronized between
|
||||||
|
// connected Users. Accessible through the `user.presence` property.
|
||||||
|
type Presence = {
|
||||||
|
id?: string
|
||||||
|
user: EnsureJson<TDUser>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage represents the shared document that persists in the Room, even after
|
||||||
|
// all Users leave. All Live structures here are automatically kept in sync
|
||||||
|
// between connected clients.
|
||||||
|
export type Storage = {
|
||||||
|
version: number
|
||||||
|
doc: LiveObject<{
|
||||||
|
uuid: string
|
||||||
|
document: EnsureJson<TDDocument>
|
||||||
|
migrated?: boolean
|
||||||
|
}>
|
||||||
|
bindings: LiveMap<string, EnsureJson<TDBinding>>
|
||||||
|
shapes: LiveMap<string, EnsureJson<TDShape>>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally, UserMeta represents static/readonly metadata on each User, as
|
||||||
|
// provided by a potential custom Liveblocks auth backend. This isn't used for
|
||||||
|
// TLDraw.
|
||||||
|
//
|
||||||
|
// type UserMeta = {
|
||||||
|
// id?: string, // Accessible through `user.id`
|
||||||
|
// info?: Json, // Accessible through `user.info`
|
||||||
|
// };
|
||||||
|
//
|
||||||
|
type UserMeta = BaseUserMeta
|
||||||
|
|
||||||
|
// Custom Events that TLDraw broadcasts between connected users
|
||||||
|
type RoomEvent =
|
||||||
|
// | { name: 'click', x: number, y: number }
|
||||||
|
// | { name: 'like', emoji: string }
|
||||||
|
// | etc.
|
||||||
|
{ name: 'exit'; userId: string }
|
||||||
|
|
||||||
|
const { RoomProvider, useRedo, useUndo, useRoom, useUpdateMyPresence } = createRoomContext<
|
||||||
|
Presence,
|
||||||
|
Storage,
|
||||||
|
UserMeta,
|
||||||
|
RoomEvent
|
||||||
|
>(client)
|
||||||
|
|
||||||
|
export { RoomProvider, useRedo, useUndo, useRoom, useUpdateMyPresence }
|
|
@ -1,24 +1,16 @@
|
||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { Tldraw } from '@tldraw/tldraw'
|
import { Tldraw } from '@tldraw/tldraw'
|
||||||
import { createClient } from '@liveblocks/client'
|
import { RoomProvider } from './liveblocks.config'
|
||||||
import { LiveblocksProvider, RoomProvider } from '@liveblocks/react'
|
|
||||||
import { useMultiplayerState } from './useMultiplayerState'
|
import { useMultiplayerState } from './useMultiplayerState'
|
||||||
|
|
||||||
const client = createClient({
|
|
||||||
publicApiKey: process.env.LIVEBLOCKS_PUBLIC_API_KEY || '',
|
|
||||||
throttle: 100,
|
|
||||||
})
|
|
||||||
|
|
||||||
const roomId = 'mp-test-8'
|
const roomId = 'mp-test-8'
|
||||||
|
|
||||||
export function Multiplayer() {
|
export function Multiplayer() {
|
||||||
return (
|
return (
|
||||||
<LiveblocksProvider client={client}>
|
|
||||||
<RoomProvider id={roomId}>
|
<RoomProvider id={roomId}>
|
||||||
<Editor roomId={roomId} />
|
<Editor roomId={roomId} />
|
||||||
</RoomProvider>
|
</RoomProvider>
|
||||||
</LiveblocksProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import type { TldrawApp, TDUser, TDShape, TDBinding, TDDocument } from '@tldraw/tldraw'
|
import type { TldrawApp, TDUser, TDShape, TDBinding } from '@tldraw/tldraw'
|
||||||
import { useRedo, useUndo, useRoom, useUpdateMyPresence } from '@liveblocks/react'
|
import { LiveMap } from '@liveblocks/client'
|
||||||
import { LiveMap, LiveObject } from '@liveblocks/client'
|
|
||||||
|
import { useRedo, useUndo, useRoom, useUpdateMyPresence } from './liveblocks.config'
|
||||||
|
import type { Storage } from './liveblocks.config'
|
||||||
|
|
||||||
declare const window: Window & { app: TldrawApp }
|
declare const window: Window & { app: TldrawApp }
|
||||||
|
|
||||||
|
@ -17,8 +19,8 @@ export function useMultiplayerState(roomId: string) {
|
||||||
const onRedo = useRedo()
|
const onRedo = useRedo()
|
||||||
const updateMyPresence = useUpdateMyPresence()
|
const updateMyPresence = useUpdateMyPresence()
|
||||||
|
|
||||||
const rLiveShapes = React.useRef<LiveMap<string, TDShape>>()
|
const rLiveShapes = React.useRef<Storage['shapes'] | undefined>()
|
||||||
const rLiveBindings = React.useRef<LiveMap<string, TDBinding>>()
|
const rLiveBindings = React.useRef<Storage['bindings'] | undefined>()
|
||||||
|
|
||||||
// Callbacks --------------
|
// Callbacks --------------
|
||||||
|
|
||||||
|
@ -97,17 +99,14 @@ export function useMultiplayerState(roomId: string) {
|
||||||
|
|
||||||
// Handle events from the room
|
// Handle events from the room
|
||||||
unsubs.push(
|
unsubs.push(
|
||||||
room.subscribe(
|
room.subscribe('event', (e) => {
|
||||||
'event',
|
|
||||||
(e: { connectionId: number; event: { name: string; userId: string } }) => {
|
|
||||||
switch (e.event.name) {
|
switch (e.event.name) {
|
||||||
case 'exit': {
|
case 'exit': {
|
||||||
app?.removeUser(e.event.userId)
|
app?.removeUser(e.event.userId)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Send the exit event when the tab closes
|
// Send the exit event when the tab closes
|
||||||
|
@ -123,20 +122,20 @@ export function useMultiplayerState(roomId: string) {
|
||||||
|
|
||||||
// Setup the document's storage and subscriptions
|
// Setup the document's storage and subscriptions
|
||||||
async function setupDocument() {
|
async function setupDocument() {
|
||||||
const storage = await room.getStorage<any>()
|
const storage = await room.getStorage()
|
||||||
|
|
||||||
// Initialize (get or create) shapes and bindings maps
|
// Initialize (get or create) shapes and bindings maps
|
||||||
|
|
||||||
let lShapes: LiveMap<string, TDShape> = storage.root.get('shapes')
|
let lShapes = storage.root.get('shapes')
|
||||||
if (!lShapes) {
|
if (!lShapes) {
|
||||||
storage.root.set('shapes', new LiveMap<string, TDShape>())
|
storage.root.set('shapes', new LiveMap())
|
||||||
lShapes = storage.root.get('shapes')
|
lShapes = storage.root.get('shapes')
|
||||||
}
|
}
|
||||||
rLiveShapes.current = lShapes
|
rLiveShapes.current = lShapes
|
||||||
|
|
||||||
let lBindings: LiveMap<string, TDBinding> = storage.root.get('bindings')
|
let lBindings = storage.root.get('bindings')
|
||||||
if (!lBindings) {
|
if (!lBindings) {
|
||||||
storage.root.set('bindings', new LiveMap<string, TDBinding>())
|
storage.root.set('bindings', new LiveMap())
|
||||||
lBindings = storage.root.get('bindings')
|
lBindings = storage.root.get('bindings')
|
||||||
}
|
}
|
||||||
rLiveBindings.current = lBindings
|
rLiveBindings.current = lBindings
|
||||||
|
@ -150,11 +149,7 @@ export function useMultiplayerState(roomId: string) {
|
||||||
// document was a single LiveObject named 'doc'. If we find a doc,
|
// 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
|
// then we need to move the shapes and bindings over to the new structures
|
||||||
// and then mark the doc as migrated.
|
// and then mark the doc as migrated.
|
||||||
const doc = storage.root.get('doc') as LiveObject<{
|
const doc = storage.root.get('doc')
|
||||||
uuid: string
|
|
||||||
document: TDDocument
|
|
||||||
migrated?: boolean
|
|
||||||
}>
|
|
||||||
|
|
||||||
// No doc? No problem. This was likely a newer document
|
// No doc? No problem. This was likely a newer document
|
||||||
if (doc) {
|
if (doc) {
|
||||||
|
@ -198,7 +193,7 @@ export function useMultiplayerState(roomId: string) {
|
||||||
stillAlive = false
|
stillAlive = false
|
||||||
unsubs.forEach((unsub) => unsub())
|
unsubs.forEach((unsub) => unsub())
|
||||||
}
|
}
|
||||||
}, [app])
|
}, [app, room])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onUndo,
|
onUndo,
|
||||||
|
|
|
@ -11,7 +11,8 @@ interface UserProps {
|
||||||
|
|
||||||
export function User({ user }: UserProps) {
|
export function User({ user }: UserProps) {
|
||||||
const rCursor = React.useRef<SVGSVGElement>(null)
|
const rCursor = React.useRef<SVGSVGElement>(null)
|
||||||
useCursorAnimation(rCursor, user.point)
|
useCursorAnimation(rCursor, user.point, user.session)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
ref={rCursor}
|
ref={rCursor}
|
||||||
|
|
|
@ -13,7 +13,7 @@ type Animation = {
|
||||||
duration: number
|
duration: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCursorAnimation(ref: any, point: number[]) {
|
export function useCursorAnimation(ref: any, point: number[], skip = false) {
|
||||||
const rState = React.useRef<AnimationState>('idle')
|
const rState = React.useRef<AnimationState>('idle')
|
||||||
const rPrevPoint = React.useRef(point)
|
const rPrevPoint = React.useRef(point)
|
||||||
const rQueue = React.useRef<Animation[]>([])
|
const rQueue = React.useRef<Animation[]>([])
|
||||||
|
@ -22,9 +22,19 @@ export function useCursorAnimation(ref: any, point: number[]) {
|
||||||
const rTimeoutId = React.useRef<any>(0)
|
const rTimeoutId = React.useRef<any>(0)
|
||||||
const [spline] = React.useState(() => new Spline())
|
const [spline] = React.useState(() => new Spline())
|
||||||
|
|
||||||
// Animate an animation
|
// When the point changes, add a new animation
|
||||||
const animateNext = React.useCallback(
|
React.useLayoutEffect(() => {
|
||||||
(animation: Animation) => {
|
if (skip) {
|
||||||
|
const elm = ref.current
|
||||||
|
if (!elm) return
|
||||||
|
|
||||||
|
rState.current = 'stopped'
|
||||||
|
rPrevPoint.current = point
|
||||||
|
elm.style.setProperty('transform', `translate(${point[0]}px, ${point[1]}px)`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const animateNext = (animation: Animation) => {
|
||||||
const start = performance.now()
|
const start = performance.now()
|
||||||
function loop() {
|
function loop() {
|
||||||
const t = (performance.now() - start) / animation.duration
|
const t = (performance.now() - start) / animation.duration
|
||||||
|
@ -50,19 +60,18 @@ export function useCursorAnimation(ref: any, point: number[]) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loop()
|
loop()
|
||||||
},
|
}
|
||||||
[spline]
|
|
||||||
)
|
|
||||||
|
|
||||||
// When the point changes, add a new animation
|
|
||||||
React.useLayoutEffect(() => {
|
|
||||||
const now = performance.now()
|
const now = performance.now()
|
||||||
|
|
||||||
if (rState.current === 'stopped') {
|
if (rState.current === 'stopped') {
|
||||||
rTimestamp.current = now
|
rTimestamp.current = now
|
||||||
rPrevPoint.current = point
|
rPrevPoint.current = point
|
||||||
spline.clear()
|
spline.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
spline.addPoint(point)
|
spline.addPoint(point)
|
||||||
|
|
||||||
const animation: Animation = {
|
const animation: Animation = {
|
||||||
distance: spline.totalLength,
|
distance: spline.totalLength,
|
||||||
curve: spline.points.length > 3,
|
curve: spline.points.length > 3,
|
||||||
|
@ -72,8 +81,9 @@ export function useCursorAnimation(ref: any, point: number[]) {
|
||||||
timeStamp: now,
|
timeStamp: now,
|
||||||
duration: Math.min(now - rTimestamp.current, 300),
|
duration: Math.min(now - rTimestamp.current, 300),
|
||||||
}
|
}
|
||||||
rPrevPoint.current = point
|
|
||||||
rTimestamp.current = now
|
rTimestamp.current = now
|
||||||
|
|
||||||
switch (rState.current) {
|
switch (rState.current) {
|
||||||
case 'stopped': {
|
case 'stopped': {
|
||||||
rPrevPoint.current = point
|
rPrevPoint.current = point
|
||||||
|
@ -86,12 +96,21 @@ export function useCursorAnimation(ref: any, point: number[]) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'animating': {
|
case 'animating': {
|
||||||
|
rPrevPoint.current = point
|
||||||
rQueue.current.push(animation)
|
rQueue.current.push(animation)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return () => clearTimeout(rTimeoutId.current)
|
return () => clearTimeout(rTimeoutId.current)
|
||||||
}, [point, spline])
|
}, [skip, point, spline])
|
||||||
|
|
||||||
|
// React.useLayoutEffect(() => {
|
||||||
|
// const cursor = rCursor.current
|
||||||
|
// if (!cursor) return
|
||||||
|
|
||||||
|
// const [x, y] = user.point
|
||||||
|
// cursor.style.transform = `translate(${x}px, ${y}px)`
|
||||||
|
// }, [skip, point])
|
||||||
}
|
}
|
||||||
|
|
||||||
class Spline {
|
class Spline {
|
||||||
|
@ -126,6 +145,11 @@ class Spline {
|
||||||
q2 = 3 * ttt - 5 * tt + 2,
|
q2 = 3 * ttt - 5 * tt + 2,
|
||||||
q3 = -3 * ttt + 4 * tt + t,
|
q3 = -3 * ttt + 4 * tt + t,
|
||||||
q4 = ttt - tt
|
q4 = ttt - tt
|
||||||
|
|
||||||
|
if (!(points[p0] && points[p1] && points[p2] && points[p3])) {
|
||||||
|
return [0, 0]
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
0.5 * (points[p0][0] * q1 + points[p1][0] * q2 + points[p2][0] * q3 + points[p3][0] * q4),
|
0.5 * (points[p0][0] * q1 + points[p1][0] * q2 + points[p2][0] * q3 + points[p3][0] * q4),
|
||||||
0.5 * (points[p0][1] * q1 + points[p1][1] * q2 + points[p2][1] * q3 + points[p3][1] * q4),
|
0.5 * (points[p0][1] * q1 + points[p1][1] * q2 + points[p2][1] * q3 + points[p3][1] * q4),
|
||||||
|
|
|
@ -52,6 +52,7 @@ export interface TLUser<T extends TLShape> {
|
||||||
color: string
|
color: string
|
||||||
point: number[]
|
point: number[]
|
||||||
selectedIds: string[]
|
selectedIds: string[]
|
||||||
|
session?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TLUsers<T extends TLShape, U extends TLUser<T> = TLUser<T>> = Record<string, U>
|
export type TLUsers<T extends TLShape, U extends TLUser<T> = TLUser<T>> = Record<string, U>
|
||||||
|
|
|
@ -138,6 +138,8 @@ export function Tldraw({
|
||||||
onAssetCreate,
|
onAssetCreate,
|
||||||
onAssetDelete,
|
onAssetDelete,
|
||||||
onAssetUpload,
|
onAssetUpload,
|
||||||
|
onSessionStart,
|
||||||
|
onSessionEnd,
|
||||||
onExport,
|
onExport,
|
||||||
}: TldrawProps) {
|
}: TldrawProps) {
|
||||||
const [sId, setSId] = React.useState(id)
|
const [sId, setSId] = React.useState(id)
|
||||||
|
@ -164,6 +166,8 @@ export function Tldraw({
|
||||||
onAssetDelete,
|
onAssetDelete,
|
||||||
onAssetCreate,
|
onAssetCreate,
|
||||||
onAssetUpload,
|
onAssetUpload,
|
||||||
|
onSessionStart,
|
||||||
|
onSessionEnd,
|
||||||
})
|
})
|
||||||
return app
|
return app
|
||||||
})
|
})
|
||||||
|
@ -192,6 +196,8 @@ export function Tldraw({
|
||||||
onAssetCreate,
|
onAssetCreate,
|
||||||
onAssetUpload,
|
onAssetUpload,
|
||||||
onExport,
|
onExport,
|
||||||
|
onSessionStart,
|
||||||
|
onSessionEnd,
|
||||||
})
|
})
|
||||||
|
|
||||||
setSId(id)
|
setSId(id)
|
||||||
|
@ -262,6 +268,8 @@ export function Tldraw({
|
||||||
onAssetCreate,
|
onAssetCreate,
|
||||||
onAssetUpload,
|
onAssetUpload,
|
||||||
onExport,
|
onExport,
|
||||||
|
onSessionStart,
|
||||||
|
onSessionEnd,
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
onMount,
|
onMount,
|
||||||
|
@ -284,6 +292,8 @@ export function Tldraw({
|
||||||
onAssetCreate,
|
onAssetCreate,
|
||||||
onAssetUpload,
|
onAssetUpload,
|
||||||
onExport,
|
onExport,
|
||||||
|
onSessionStart,
|
||||||
|
onSessionEnd,
|
||||||
])
|
])
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
export function MultiplayerIcon2() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 15 15"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="currentColor"
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
>
|
||||||
|
<path d="M1.36482 4.71089C1.36482 3.21371 2.57853 2 4.07572 2C5.5729 2 6.78661 3.21371 6.78661 4.71089C6.78661 5.76842 6.18106 6.68452 5.29779 7.13136C5.80465 7.24611 6.26407 7.44186 6.66028 7.73182C7.18674 8.11711 7.57215 8.64722 7.81073 9.30983C7.81452 9.30606 7.81832 9.3023 7.82212 9.29855C8.30448 8.82317 8.91325 8.52463 9.60905 8.37275C8.68812 7.922 8.05394 6.97569 8.05394 5.88126C8.05394 4.35017 9.29513 3.10898 10.8262 3.10898C12.3573 3.10898 13.5985 4.35017 13.5985 5.88126C13.5985 6.97561 12.9644 7.92186 12.0436 8.37265C12.7396 8.52449 13.3486 8.82306 13.831 9.29854C14.5795 10.0361 14.9481 11.1249 14.9481 12.5176C14.9481 12.7799 14.7354 12.9926 14.473 12.9926C14.2107 12.9926 13.9981 12.7799 13.9981 12.5175C13.9981 11.2848 13.6738 10.4774 13.1642 9.97518C12.6532 9.4716 11.8802 9.20024 10.8266 9.20024C9.77294 9.20024 8.99993 9.4716 8.48896 9.97518C7.97939 10.4774 7.65513 11.2848 7.65513 12.5176C7.65513 12.7799 7.44247 12.9926 7.18013 12.9926C6.9178 12.9926 6.70513 12.7799 6.70513 12.5176C6.70513 11.6734 6.84053 10.941 7.11384 10.3307C7.0922 10.2895 7.0763 10.2444 7.06737 10.1962C6.91739 9.38749 6.57392 8.84586 6.09923 8.49845C5.61626 8.14499 4.94481 7.95427 4.07574 7.95427C3.05232 7.95427 2.30368 8.21784 1.80952 8.70485C1.31675 9.19047 1.00176 9.97257 1.00176 11.1702C1.00176 11.4326 0.789093 11.6452 0.526758 11.6452C0.264423 11.6452 0.0517578 11.4326 0.0517578 11.1702C0.0517578 9.81263 0.411052 8.74925 1.14268 8.02821C1.60624 7.57137 2.18892 7.28191 2.85378 7.13142C1.97043 6.68461 1.36482 5.76848 1.36482 4.71089ZM4.07572 2.95C3.1032 2.95 2.31482 3.73838 2.31482 4.71089C2.31482 5.68341 3.1032 6.47178 4.07572 6.47178C5.04823 6.47178 5.83661 5.68341 5.83661 4.71089C5.83661 3.73838 5.04823 2.95 4.07572 2.95ZM10.8262 4.05898C9.8198 4.05898 9.00394 4.87484 9.00394 5.88126C9.00394 6.88768 9.8198 7.70355 10.8262 7.70355C11.8326 7.70355 12.6485 6.88768 12.6485 5.88126C12.6485 4.87484 11.8326 4.05898 10.8262 4.05898Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,14 +1,14 @@
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { CheckIcon, ClipboardIcon, CursorArrowIcon, PersonIcon } from '@radix-ui/react-icons'
|
import { CheckIcon, ClipboardIcon } from '@radix-ui/react-icons'
|
||||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||||
import { useTldrawApp } from '~hooks'
|
import { useTldrawApp } from '~hooks'
|
||||||
import { DMItem, DMContent, DMDivider, DMTriggerIcon } from '~components/Primitives/DropdownMenu'
|
import { DMItem, DMContent, DMDivider, DMTriggerIcon } from '~components/Primitives/DropdownMenu'
|
||||||
import { SmallIcon } from '~components/Primitives/SmallIcon'
|
import { SmallIcon } from '~components/Primitives/SmallIcon'
|
||||||
import { MultiplayerIcon } from '~components/Primitives/icons'
|
|
||||||
import { TDAssetType, TDSnapshot } from '~types'
|
import { TDAssetType, TDSnapshot } from '~types'
|
||||||
import { TLDR } from '~state/TLDR'
|
import { TLDR } from '~state/TLDR'
|
||||||
import { Utils } from '@tldraw/core'
|
import { Utils } from '@tldraw/core'
|
||||||
import { FormattedMessage } from 'react-intl'
|
import { FormattedMessage } from 'react-intl'
|
||||||
|
import { MultiplayerIcon2 } from '~components/Primitives/icons/MultiplayerIcon2'
|
||||||
|
|
||||||
const roomSelector = (state: TDSnapshot) => state.room
|
const roomSelector = (state: TDSnapshot) => state.room
|
||||||
|
|
||||||
|
@ -96,7 +96,7 @@ export const MultiplayerMenu = React.memo(function MultiplayerMenu() {
|
||||||
return (
|
return (
|
||||||
<DropdownMenu.Root dir="ltr">
|
<DropdownMenu.Root dir="ltr">
|
||||||
<DMTriggerIcon id="TD-MultiplayerMenuIcon" isActive={!!room}>
|
<DMTriggerIcon id="TD-MultiplayerMenuIcon" isActive={!!room}>
|
||||||
<PersonIcon />
|
<MultiplayerIcon2 />
|
||||||
</DMTriggerIcon>
|
</DMTriggerIcon>
|
||||||
<DMContent variant="menu" align="start" id="TD-MultiplayerMenu">
|
<DMContent variant="menu" align="start" id="TD-MultiplayerMenu">
|
||||||
<DMItem id="TD-Multiplayer-CopyInviteLink" onClick={handleCopySelect} disabled={!room}>
|
<DMItem id="TD-Multiplayer-CopyInviteLink" onClick={handleCopySelect} disabled={!room}>
|
||||||
|
|
|
@ -126,11 +126,11 @@ export class StateManager<T extends Record<string, any>> {
|
||||||
/**
|
/**
|
||||||
* Save the current state to indexdb.
|
* Save the current state to indexdb.
|
||||||
*/
|
*/
|
||||||
protected persist = (id?: string): void | Promise<void> => {
|
protected persist = (patch: Patch<T>, id?: string): void | Promise<void> => {
|
||||||
if (this._status !== 'ready') return
|
if (this._status !== 'ready') return
|
||||||
|
|
||||||
if (this.onPersist) {
|
if (this.onPersist) {
|
||||||
this.onPersist(this._state, id)
|
this.onPersist(this._state, patch, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._idbId) {
|
if (this._idbId) {
|
||||||
|
@ -201,7 +201,7 @@ export class StateManager<T extends Record<string, any>> {
|
||||||
patchState = (patch: Patch<T>, id?: string): this => {
|
patchState = (patch: Patch<T>, id?: string): this => {
|
||||||
this.applyPatch(patch, id)
|
this.applyPatch(patch, id)
|
||||||
if (this.onPatch) {
|
if (this.onPatch) {
|
||||||
this.onPatch(this._state, id)
|
this.onPatch(this._state, patch, id)
|
||||||
}
|
}
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
@ -240,8 +240,8 @@ export class StateManager<T extends Record<string, any>> {
|
||||||
this.stack.push({ ...command, id })
|
this.stack.push({ ...command, id })
|
||||||
this.pointer = this.stack.length - 1
|
this.pointer = this.stack.length - 1
|
||||||
this.applyPatch(command.after, id)
|
this.applyPatch(command.after, id)
|
||||||
if (this.onCommand) this.onCommand(this._state, id)
|
if (this.onCommand) this.onCommand(this._state, command, id)
|
||||||
this.persist(id)
|
this.persist(command.after, id)
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -264,17 +264,17 @@ export class StateManager<T extends Record<string, any>> {
|
||||||
/**
|
/**
|
||||||
* A callback fired when a patch is applied.
|
* A callback fired when a patch is applied.
|
||||||
*/
|
*/
|
||||||
public onPatch?: (state: T, id?: string) => void
|
public onPatch?: (state: T, patch: Patch<T>, id?: string) => void
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A callback fired when a patch is applied.
|
* A callback fired when a patch is applied.
|
||||||
*/
|
*/
|
||||||
public onCommand?: (state: T, id?: string) => void
|
public onCommand?: (state: T, command: Command<T>, id?: string) => void
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A callback fired when the state is persisted.
|
* A callback fired when the state is persisted.
|
||||||
*/
|
*/
|
||||||
public onPersist?: (state: T, id?: string) => void
|
public onPersist?: (state: T, patch: Patch<T>, id?: string) => void
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A callback fired when the state is replaced.
|
* A callback fired when the state is replaced.
|
||||||
|
@ -311,7 +311,7 @@ export class StateManager<T extends Record<string, any>> {
|
||||||
this._state = this.initialState
|
this._state = this.initialState
|
||||||
this.store.setState(this._state, true)
|
this.store.setState(this._state, true)
|
||||||
this.resetHistory()
|
this.resetHistory()
|
||||||
this.persist('reset')
|
this.persist({}, 'reset')
|
||||||
if (this.onStateDidChange) {
|
if (this.onStateDidChange) {
|
||||||
this.onStateDidChange(this._state, 'reset')
|
this.onStateDidChange(this._state, 'reset')
|
||||||
}
|
}
|
||||||
|
@ -357,7 +357,7 @@ export class StateManager<T extends Record<string, any>> {
|
||||||
const command = this.stack[this.pointer]
|
const command = this.stack[this.pointer]
|
||||||
this.pointer--
|
this.pointer--
|
||||||
this.applyPatch(command.before, `undo`)
|
this.applyPatch(command.before, `undo`)
|
||||||
this.persist('undo')
|
this.persist(command.before, 'undo')
|
||||||
}
|
}
|
||||||
if (this.onUndo) this.onUndo(this._state)
|
if (this.onUndo) this.onUndo(this._state)
|
||||||
return this
|
return this
|
||||||
|
@ -372,7 +372,7 @@ export class StateManager<T extends Record<string, any>> {
|
||||||
this.pointer++
|
this.pointer++
|
||||||
const command = this.stack[this.pointer]
|
const command = this.stack[this.pointer]
|
||||||
this.applyPatch(command.after, 'redo')
|
this.applyPatch(command.after, 'redo')
|
||||||
this.persist('undo')
|
this.persist(command.after, 'undo')
|
||||||
}
|
}
|
||||||
if (this.onRedo) this.onRedo(this._state)
|
if (this.onRedo) this.onRedo(this._state)
|
||||||
return this
|
return this
|
||||||
|
|
|
@ -41,6 +41,7 @@ import {
|
||||||
TDExport,
|
TDExport,
|
||||||
ArrowShape,
|
ArrowShape,
|
||||||
TDExportType,
|
TDExportType,
|
||||||
|
TldrawPatch,
|
||||||
} from '~types'
|
} from '~types'
|
||||||
import {
|
import {
|
||||||
migrate,
|
migrate,
|
||||||
|
@ -125,11 +126,11 @@ export interface TDCallbacks {
|
||||||
/**
|
/**
|
||||||
* (optional) A callback to run when the state is patched.
|
* (optional) A callback to run when the state is patched.
|
||||||
*/
|
*/
|
||||||
onPatch?: (app: TldrawApp, reason?: string) => void
|
onPatch?: (app: TldrawApp, patch: TldrawPatch, reason?: string) => void
|
||||||
/**
|
/**
|
||||||
* (optional) A callback to run when the state is changed with a command.
|
* (optional) A callback to run when the state is changed with a command.
|
||||||
*/
|
*/
|
||||||
onCommand?: (app: TldrawApp, reason?: string) => void
|
onCommand?: (app: TldrawApp, command: TldrawCommand, reason?: string) => void
|
||||||
/**
|
/**
|
||||||
* (optional) A callback to run when the state is persisted.
|
* (optional) A callback to run when the state is persisted.
|
||||||
*/
|
*/
|
||||||
|
@ -149,7 +150,8 @@ export interface TDCallbacks {
|
||||||
app: TldrawApp,
|
app: TldrawApp,
|
||||||
shapes: Record<string, TDShape | undefined>,
|
shapes: Record<string, TDShape | undefined>,
|
||||||
bindings: Record<string, TDBinding | undefined>,
|
bindings: Record<string, TDBinding | undefined>,
|
||||||
assets: Record<string, TDAsset | undefined>
|
assets: Record<string, TDAsset | undefined>,
|
||||||
|
addToHistory: boolean
|
||||||
) => void
|
) => void
|
||||||
/**
|
/**
|
||||||
* (optional) A callback to run when the user creates a new project.
|
* (optional) A callback to run when the user creates a new project.
|
||||||
|
@ -510,14 +512,58 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
onPatch = (app: TDSnapshot, id?: string) => {
|
private broadcastPatch = (patch: TldrawPatch, addToHistory: boolean) => {
|
||||||
this.callbacks.onPatch?.(this, id)
|
const changedShapes: Record<string, TDShape | undefined> = {}
|
||||||
|
const changedBindings: Record<string, TDBinding | undefined> = {}
|
||||||
|
const changedAssets: Record<string, TDAsset | undefined> = {}
|
||||||
|
|
||||||
|
const shapes = patch?.document?.pages?.[this.currentPageId]?.shapes
|
||||||
|
const bindings = patch?.document?.pages?.[this.currentPageId]?.bindings
|
||||||
|
const assets = patch?.document?.assets
|
||||||
|
|
||||||
|
if (shapes) {
|
||||||
|
Object.keys(shapes).forEach((id) => {
|
||||||
|
changedShapes[id!] = this.getShape(id, this.currentPageId)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onCommand = (app: TDSnapshot, id?: string) => {
|
if (bindings) {
|
||||||
|
Object.keys(bindings).forEach((id) => {
|
||||||
|
changedBindings[id] = this.getBinding(id, this.currentPageId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assets) {
|
||||||
|
Object.keys(assets).forEach((id) => {
|
||||||
|
changedAssets[id] = this.document.assets[id]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.callbacks.onChangePage?.(this, changedShapes, changedBindings, changedAssets, addToHistory)
|
||||||
|
}
|
||||||
|
|
||||||
|
onPatch = (state: TDSnapshot, patch: TldrawPatch, id?: string) => {
|
||||||
|
if (
|
||||||
|
(this.callbacks.onChangePage && patch?.document?.pages?.[this.currentPageId]) ||
|
||||||
|
patch?.document?.assets
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
this.session &&
|
||||||
|
this.session.type !== SessionType.Brush &&
|
||||||
|
this.session.type !== SessionType.Erase &&
|
||||||
|
this.session.type !== SessionType.Draw
|
||||||
|
) {
|
||||||
|
this.broadcastPatch(patch, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.callbacks.onPatch?.(this, patch, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
onCommand = (state: TDSnapshot, command: TldrawCommand, id?: string) => {
|
||||||
this.clearSelectHistory()
|
this.clearSelectHistory()
|
||||||
this.isDirty = true
|
this.isDirty = true
|
||||||
this.callbacks.onCommand?.(this, id)
|
this.callbacks.onCommand?.(this, command, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
onReplace = () => {
|
onReplace = () => {
|
||||||
|
@ -535,12 +581,11 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
this.callbacks.onRedo?.(this)
|
this.callbacks.onRedo?.(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
onPersist = () => {
|
onPersist = (state: TDSnapshot, patch: TldrawPatch) => {
|
||||||
// If we are part of a room, send our changes to the server
|
// If we are part of a room, send our changes to the server
|
||||||
if (this.callbacks.onChangePage) {
|
|
||||||
this.broadcastPageChanges()
|
|
||||||
}
|
|
||||||
this.callbacks.onPersist?.(this)
|
this.callbacks.onPersist?.(this)
|
||||||
|
this.broadcastPatch(patch, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private prevSelectedIds = this.selectedIds
|
private prevSelectedIds = this.selectedIds
|
||||||
|
@ -550,13 +595,14 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
* @param state
|
* @param state
|
||||||
* @param id
|
* @param id
|
||||||
*/
|
*/
|
||||||
protected onStateDidChange = (_app: TDSnapshot, id?: string): void => {
|
protected onStateDidChange = (_state: TDSnapshot, id?: string): void => {
|
||||||
this.callbacks.onChange?.(this, id)
|
this.callbacks.onChange?.(this, id)
|
||||||
|
|
||||||
if (this.room && this.selectedIds !== this.prevSelectedIds) {
|
if (this.room && this.selectedIds !== this.prevSelectedIds) {
|
||||||
this.callbacks.onChangePresence?.(this, {
|
this.callbacks.onChangePresence?.(this, {
|
||||||
...this.room.users[this.room.userId],
|
...this.room.users[this.room.userId],
|
||||||
selectedIds: this.selectedIds,
|
selectedIds: this.selectedIds,
|
||||||
|
session: !!this.session,
|
||||||
})
|
})
|
||||||
this.prevSelectedIds = this.selectedIds
|
this.prevSelectedIds = this.selectedIds
|
||||||
}
|
}
|
||||||
|
@ -569,69 +615,69 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
private prevBindings = this.page.bindings
|
private prevBindings = this.page.bindings
|
||||||
private prevAssets = this.document.assets
|
private prevAssets = this.document.assets
|
||||||
|
|
||||||
private broadcastPageChanges = () => {
|
// private broadcastPageChanges = () => {
|
||||||
const visited = new Set<string>()
|
// const visited = new Set<string>()
|
||||||
|
|
||||||
const changedShapes: Record<string, TDShape | undefined> = {}
|
// const changedShapes: Record<string, TDShape | undefined> = {}
|
||||||
const changedBindings: Record<string, TDBinding | undefined> = {}
|
// const changedBindings: Record<string, TDBinding | undefined> = {}
|
||||||
const changedAssets: Record<string, TDAsset | undefined> = {}
|
// const changedAssets: Record<string, TDAsset | undefined> = {}
|
||||||
|
|
||||||
this.shapes.forEach((shape) => {
|
// this.shapes.forEach((shape) => {
|
||||||
visited.add(shape.id)
|
// visited.add(shape.id)
|
||||||
if (this.prevShapes[shape.id] !== shape) {
|
// if (this.prevShapes[shape.id] !== shape) {
|
||||||
changedShapes[shape.id] = shape
|
// changedShapes[shape.id] = shape
|
||||||
}
|
// }
|
||||||
})
|
// })
|
||||||
|
|
||||||
Object.keys(this.prevShapes)
|
// Object.keys(this.prevShapes)
|
||||||
.filter((id) => !visited.has(id))
|
// .filter((id) => !visited.has(id))
|
||||||
.forEach((id) => {
|
// .forEach((id) => {
|
||||||
// After visiting all the current shapes, if we haven't visited a
|
// // After visiting all the current shapes, if we haven't visited a
|
||||||
// previously present shape, then it was deleted
|
// // previously present shape, then it was deleted
|
||||||
changedShapes[id] = undefined
|
// changedShapes[id] = undefined
|
||||||
})
|
// })
|
||||||
|
|
||||||
this.bindings.forEach((binding) => {
|
// this.bindings.forEach((binding) => {
|
||||||
visited.add(binding.id)
|
// visited.add(binding.id)
|
||||||
if (this.prevBindings[binding.id] !== binding) {
|
// if (this.prevBindings[binding.id] !== binding) {
|
||||||
changedBindings[binding.id] = binding
|
// changedBindings[binding.id] = binding
|
||||||
}
|
// }
|
||||||
})
|
// })
|
||||||
|
|
||||||
Object.keys(this.prevBindings)
|
// Object.keys(this.prevBindings)
|
||||||
.filter((id) => !visited.has(id))
|
// .filter((id) => !visited.has(id))
|
||||||
.forEach((id) => {
|
// .forEach((id) => {
|
||||||
// After visiting all the current bindings, if we haven't visited a
|
// // After visiting all the current bindings, if we haven't visited a
|
||||||
// previously present shape, then it was deleted
|
// // previously present shape, then it was deleted
|
||||||
changedBindings[id] = undefined
|
// changedBindings[id] = undefined
|
||||||
})
|
// })
|
||||||
|
|
||||||
this.assets.forEach((asset) => {
|
// this.assets.forEach((asset) => {
|
||||||
visited.add(asset.id)
|
// visited.add(asset.id)
|
||||||
if (this.prevAssets[asset.id] !== asset) {
|
// if (this.prevAssets[asset.id] !== asset) {
|
||||||
changedAssets[asset.id] = asset
|
// changedAssets[asset.id] = asset
|
||||||
}
|
// }
|
||||||
})
|
// })
|
||||||
|
|
||||||
Object.keys(this.prevAssets)
|
// Object.keys(this.prevAssets)
|
||||||
.filter((id) => !visited.has(id))
|
// .filter((id) => !visited.has(id))
|
||||||
.forEach((id) => {
|
// .forEach((id) => {
|
||||||
changedAssets[id] = undefined
|
// changedAssets[id] = undefined
|
||||||
})
|
// })
|
||||||
|
|
||||||
// Only trigger update if shapes or bindings have changed
|
// // Only trigger update if shapes or bindings have changed
|
||||||
if (
|
// if (
|
||||||
Object.keys(changedBindings).length > 0 ||
|
// Object.keys(changedBindings).length > 0 ||
|
||||||
Object.keys(changedShapes).length > 0 ||
|
// Object.keys(changedShapes).length > 0 ||
|
||||||
Object.keys(changedAssets).length > 0
|
// Object.keys(changedAssets).length > 0
|
||||||
) {
|
// ) {
|
||||||
this.justSent = true
|
// this.justSent = true
|
||||||
this.callbacks.onChangePage?.(this, changedShapes, changedBindings, changedAssets)
|
// this.callbacks.onChangePage?.(this, changedShapes, changedBindings, changedAssets,)
|
||||||
this.prevShapes = this.page.shapes
|
// this.prevShapes = this.page.shapes
|
||||||
this.prevBindings = this.page.bindings
|
// this.prevBindings = this.page.bindings
|
||||||
this.prevAssets = this.document.assets
|
// this.prevAssets = this.document.assets
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
getReservedContent = (coreReservedIds: string[], pageId = this.currentPageId) => {
|
getReservedContent = (coreReservedIds: string[], pageId = this.currentPageId) => {
|
||||||
const { bindings } = this.document.pages[pageId]
|
const { bindings } = this.document.pages[pageId]
|
||||||
|
@ -928,10 +974,22 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
* Set or clear the editing id
|
* Set or clear the editing id
|
||||||
* @param id [string]
|
* @param id [string]
|
||||||
*/
|
*/
|
||||||
setEditingId = (id?: string) => {
|
setEditingId = (id?: string, isCreating = false) => {
|
||||||
if (this.readOnly) return
|
if (this.readOnly) return
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
// Start a new editing session
|
||||||
|
this.startSession(SessionType.Edit, id, isCreating)
|
||||||
|
} else {
|
||||||
|
// If we're clearing the editing id and we don't have one, bail
|
||||||
|
if (!this.pageState.editingId) return
|
||||||
|
|
||||||
|
// If we're clearing the editing id and we do have one, complete the session
|
||||||
|
this.completeSession()
|
||||||
|
}
|
||||||
|
|
||||||
this.editingStartTime = performance.now()
|
this.editingStartTime = performance.now()
|
||||||
|
|
||||||
this.patchState(
|
this.patchState(
|
||||||
{
|
{
|
||||||
document: {
|
document: {
|
||||||
|
@ -978,15 +1036,15 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
): this => {
|
): this => {
|
||||||
if (this.session) return this
|
if (this.session) return this
|
||||||
|
|
||||||
this.patchState(
|
const patch = {
|
||||||
{
|
|
||||||
settings: {
|
settings: {
|
||||||
[name]: typeof value === 'function' ? value(this.settings[name] as V) : value,
|
[name]: typeof value === 'function' ? value(this.settings[name] as V) : value,
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
`settings:${name}`
|
|
||||||
)
|
this.patchState(patch, `settings:${name}`)
|
||||||
this.persist()
|
|
||||||
|
this.persist(patch)
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -995,15 +1053,15 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
*/
|
*/
|
||||||
toggleFocusMode = (): this => {
|
toggleFocusMode = (): this => {
|
||||||
if (this.session) return this
|
if (this.session) return this
|
||||||
this.patchState(
|
const patch = {
|
||||||
{
|
|
||||||
settings: {
|
settings: {
|
||||||
isFocusMode: !this.settings.isFocusMode,
|
isFocusMode: !this.settings.isFocusMode,
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
`settings:toggled_focus_mode`
|
|
||||||
)
|
this.patchState(patch, `settings:toggled_focus_mode`)
|
||||||
this.persist()
|
|
||||||
|
this.persist(patch)
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1012,15 +1070,13 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
*/
|
*/
|
||||||
togglePenMode = (): this => {
|
togglePenMode = (): this => {
|
||||||
if (this.session) return this
|
if (this.session) return this
|
||||||
this.patchState(
|
const patch = {
|
||||||
{
|
|
||||||
settings: {
|
settings: {
|
||||||
isPenMode: !this.settings.isPenMode,
|
isPenMode: !this.settings.isPenMode,
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
`settings:toggled_pen_mode`
|
this.patchState(patch, `settings:toggled_pen_mode`)
|
||||||
)
|
this.persist(patch)
|
||||||
this.persist()
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1029,11 +1085,9 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
*/
|
*/
|
||||||
toggleDarkMode = (): this => {
|
toggleDarkMode = (): this => {
|
||||||
if (this.session) return this
|
if (this.session) return this
|
||||||
this.patchState(
|
const patch = { settings: { isDarkMode: !this.settings.isDarkMode } }
|
||||||
{ settings: { isDarkMode: !this.settings.isDarkMode } },
|
this.patchState(patch, `settings:toggled_dark_mode`)
|
||||||
`settings:toggled_dark_mode`
|
this.persist(patch)
|
||||||
)
|
|
||||||
this.persist()
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1042,11 +1096,9 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
*/
|
*/
|
||||||
toggleZoomSnap = () => {
|
toggleZoomSnap = () => {
|
||||||
if (this.session) return this
|
if (this.session) return this
|
||||||
this.patchState(
|
const patch = { settings: { isZoomSnap: !this.settings.isZoomSnap } }
|
||||||
{ settings: { isZoomSnap: !this.settings.isZoomSnap } },
|
this.patchState(patch, `settings:toggled_zoom_snap`)
|
||||||
`settings:toggled_zoom_snap`
|
this.persist(patch)
|
||||||
)
|
|
||||||
this.persist()
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1055,11 +1107,9 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
*/
|
*/
|
||||||
toggleDebugMode = () => {
|
toggleDebugMode = () => {
|
||||||
if (this.session) return this
|
if (this.session) return this
|
||||||
this.patchState(
|
const patch = { settings: { isDebugMode: !this.settings.isDebugMode } }
|
||||||
{ settings: { isDebugMode: !this.settings.isDebugMode } },
|
this.patchState(patch, `settings:toggled_debug`)
|
||||||
`settings:toggled_debug`
|
this.persist(patch)
|
||||||
)
|
|
||||||
this.persist()
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1067,8 +1117,9 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
* Toggles the state if menu is opened
|
* Toggles the state if menu is opened
|
||||||
*/
|
*/
|
||||||
setMenuOpen = (isOpen: boolean): this => {
|
setMenuOpen = (isOpen: boolean): this => {
|
||||||
this.patchState({ appState: { isMenuOpen: isOpen } }, 'ui:toggled_menu_opened')
|
const patch = { appState: { isMenuOpen: isOpen } }
|
||||||
this.persist()
|
this.patchState(patch, 'ui:toggled_menu_opened')
|
||||||
|
this.persist(patch)
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1076,8 +1127,9 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
* Toggles the state if something is loading
|
* Toggles the state if something is loading
|
||||||
*/
|
*/
|
||||||
setIsLoading = (isLoading: boolean): this => {
|
setIsLoading = (isLoading: boolean): this => {
|
||||||
this.patchState({ appState: { isLoading } }, 'ui:toggled_is_loading')
|
const patch = { appState: { isLoading } }
|
||||||
this.persist()
|
this.patchState(patch, 'ui:toggled_is_loading')
|
||||||
|
this.persist(patch)
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1103,8 +1155,9 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
*/
|
*/
|
||||||
toggleGrid = (): this => {
|
toggleGrid = (): this => {
|
||||||
if (this.session) return this
|
if (this.session) return this
|
||||||
this.patchState({ settings: { showGrid: !this.settings.showGrid } }, 'settings:toggled_grid')
|
const patch = { settings: { showGrid: !this.settings.showGrid } }
|
||||||
this.persist()
|
this.patchState(patch, 'settings:toggled_grid')
|
||||||
|
this.persist(patch)
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1175,7 +1228,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
this.resetHistory()
|
this.resetHistory()
|
||||||
.clearSelectHistory()
|
.clearSelectHistory()
|
||||||
.loadDocument(migrate(TldrawApp.defaultDocument, TldrawApp.version))
|
.loadDocument(migrate(TldrawApp.defaultDocument, TldrawApp.version))
|
||||||
.persist()
|
.persist({})
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
@ -1420,7 +1473,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
this.fileSystemHandle
|
this.fileSystemHandle
|
||||||
)
|
)
|
||||||
this.fileSystemHandle = fileHandle
|
this.fileSystemHandle = fileHandle
|
||||||
this.persist()
|
this.persist({})
|
||||||
this.isDirty = false
|
this.isDirty = false
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// Likely cancelled
|
// Likely cancelled
|
||||||
|
@ -1436,7 +1489,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
try {
|
try {
|
||||||
const fileHandle = await saveToFileSystem(this.document, null)
|
const fileHandle = await saveToFileSystem(this.document, null)
|
||||||
this.fileSystemHandle = fileHandle
|
this.fileSystemHandle = fileHandle
|
||||||
this.persist()
|
this.persist({})
|
||||||
this.isDirty = false
|
this.isDirty = false
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// Likely cancelled
|
// Likely cancelled
|
||||||
|
@ -1462,11 +1515,11 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
this.loadDocument(document)
|
this.loadDocument(document)
|
||||||
this.fileSystemHandle = fileHandle
|
this.fileSystemHandle = fileHandle
|
||||||
this.zoomToFit()
|
this.zoomToFit()
|
||||||
this.persist()
|
this.persist({})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
} finally {
|
} finally {
|
||||||
this.persist()
|
this.persist({})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1482,7 +1535,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
} finally {
|
} finally {
|
||||||
this.persist()
|
this.persist({})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2700,28 +2753,28 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start a new session.
|
* Start a new session.
|
||||||
* @param session The new session
|
* @param type The session type
|
||||||
* @param args arguments of the session's start method.
|
* @param args arguments of the session's start method.
|
||||||
*/
|
*/
|
||||||
startSession = <T extends SessionType>(type: T, ...args: SessionArgsOfType<T>): this => {
|
startSession = <T extends SessionType>(type: T, ...args: SessionArgsOfType<T>): this => {
|
||||||
if (this.readOnly && type !== SessionType.Brush) return this
|
if (this.readOnly && type !== SessionType.Brush) return this
|
||||||
|
|
||||||
if (this.session) {
|
if (this.session) {
|
||||||
TLDR.warn(`Already in a session! (${this.session.constructor.name})`)
|
TLDR.warn(`Already in a session! (${this.session.constructor.name})`)
|
||||||
this.cancelSession()
|
this.cancelSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
const Session = getSession(type)
|
const Session = getSession(type) as any
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
this.session = new Session(this, ...args)
|
this.session = new Session(this, ...args)
|
||||||
|
|
||||||
const result = this.session.start()
|
const result = this.session!.start()
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
this.patchState(result, `session:start_${this.session.constructor.name}`)
|
this.patchState(result, `session:start_${this.session!.constructor.name}`)
|
||||||
this.callbacks.onSessionStart?.(this, this.session.constructor.name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.callbacks.onSessionStart?.(this, this.session!.constructor.name)
|
||||||
|
|
||||||
return this
|
return this
|
||||||
// return this.setStatus(this.session.status)
|
// return this.setStatus(this.session.status)
|
||||||
}
|
}
|
||||||
|
@ -2754,9 +2807,12 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
this.patchState(result, `session:cancel:${session.constructor.name}`)
|
this.patchState(result, `session:cancel:${session.constructor.name}`)
|
||||||
this.callbacks.onSessionEnd?.(this, session.constructor.name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.setEditingId()
|
||||||
|
|
||||||
|
this.callbacks.onSessionEnd?.(this, session.constructor.name)
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2768,6 +2824,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
const { session } = this
|
const { session } = this
|
||||||
|
|
||||||
if (!session) return this
|
if (!session) return this
|
||||||
|
|
||||||
this.session = undefined
|
this.session = undefined
|
||||||
const result = session.complete()
|
const result = session.complete()
|
||||||
|
|
||||||
|
@ -2791,9 +2848,6 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
},
|
},
|
||||||
`session:complete:${session.constructor.name}`
|
`session:complete:${session.constructor.name}`
|
||||||
)
|
)
|
||||||
|
|
||||||
this.callbacks.onSessionEnd?.(this, session.constructor.name)
|
|
||||||
return this
|
|
||||||
} else if ('after' in result) {
|
} else if ('after' in result) {
|
||||||
// Session ended with a command
|
// Session ended with a command
|
||||||
|
|
||||||
|
@ -2870,6 +2924,8 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.callbacks.onSessionEnd?.(this, session.constructor.name)
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2910,7 +2966,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
createTextShapeAtPoint(point: number[], id?: string): this {
|
createTextShapeAtPoint(point: number[], id?: string, patch?: boolean): this {
|
||||||
const {
|
const {
|
||||||
shapes,
|
shapes,
|
||||||
appState: { currentPageId, currentStyle },
|
appState: { currentPageId, currentStyle },
|
||||||
|
@ -2935,8 +2991,14 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
|
|
||||||
const bounds = Text.getBounds(newShape)
|
const bounds = Text.getBounds(newShape)
|
||||||
newShape.point = Vec.sub(newShape.point, [bounds.width / 2, bounds.height / 2])
|
newShape.point = Vec.sub(newShape.point, [bounds.width / 2, bounds.height / 2])
|
||||||
|
|
||||||
|
if (patch) {
|
||||||
|
this.patchCreate([TLDR.getShapeUtil(newShape.type).create(newShape)])
|
||||||
|
} else {
|
||||||
this.createShapes(newShape)
|
this.createShapes(newShape)
|
||||||
this.setEditingId(newShape.id)
|
}
|
||||||
|
|
||||||
|
this.setEditingId(newShape.id, true)
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
@ -3668,6 +3730,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
this.callbacks.onChangePresence?.(this, {
|
this.callbacks.onChangePresence?.(this, {
|
||||||
...users[userId],
|
...users[userId],
|
||||||
point: this.getPagePoint(info.point),
|
point: this.getPagePoint(info.point),
|
||||||
|
session: !!this.session,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3894,7 +3957,11 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
}
|
}
|
||||||
|
|
||||||
onShapeChange = (shape: { id: string } & Partial<TDShape>) => {
|
onShapeChange = (shape: { id: string } & Partial<TDShape>) => {
|
||||||
this.updateShapes(shape)
|
const pageShapes = this.document.pages[this.currentPageId].shapes
|
||||||
|
const shapeToUpdate = { ...(pageShapes[shape.id] as any), ...shape }
|
||||||
|
const patch = Commands.updateShapes(this, [shapeToUpdate], this.currentPageId).after
|
||||||
|
return this.patchState(patch, 'patched_shapes')
|
||||||
|
// this.updateShapes(shape)
|
||||||
}
|
}
|
||||||
|
|
||||||
onShapeBlur = () => {
|
onShapeBlur = () => {
|
||||||
|
|
|
@ -227,7 +227,7 @@ TldrawTestApp {
|
||||||
"align": [Function],
|
"align": [Function],
|
||||||
"altKey": false,
|
"altKey": false,
|
||||||
"applyPatch": [Function],
|
"applyPatch": [Function],
|
||||||
"broadcastPageChanges": [Function],
|
"broadcastPatch": [Function],
|
||||||
"callbacks": Object {},
|
"callbacks": Object {},
|
||||||
"cancel": [Function],
|
"cancel": [Function],
|
||||||
"cancelSession": [Function],
|
"cancelSession": [Function],
|
||||||
|
@ -293,6 +293,7 @@ TldrawTestApp {
|
||||||
"deletePage": [Function],
|
"deletePage": [Function],
|
||||||
"distribute": [Function],
|
"distribute": [Function],
|
||||||
"doubleClickBoundHandle": [Function],
|
"doubleClickBoundHandle": [Function],
|
||||||
|
"doubleClickCanvas": [Function],
|
||||||
"doubleClickShape": [Function],
|
"doubleClickShape": [Function],
|
||||||
"duplicate": [Function],
|
"duplicate": [Function],
|
||||||
"duplicatePage": [Function],
|
"duplicatePage": [Function],
|
||||||
|
|
|
@ -92,6 +92,17 @@ export class BrushSession extends BaseSession {
|
||||||
|
|
||||||
const afterSelectedIds = didChange ? Array.from(selectedIds.values()) : currentSelectedIds
|
const afterSelectedIds = didChange ? Array.from(selectedIds.values()) : currentSelectedIds
|
||||||
|
|
||||||
|
if (!didChange)
|
||||||
|
return {
|
||||||
|
document: {
|
||||||
|
pageStates: {
|
||||||
|
[this.app.currentPageId]: {
|
||||||
|
brush,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
selectByContain,
|
selectByContain,
|
||||||
|
|
|
@ -0,0 +1,174 @@
|
||||||
|
import { mockDocument, TldrawTestApp } from '~test'
|
||||||
|
import {
|
||||||
|
ColorStyle,
|
||||||
|
DashStyle,
|
||||||
|
SessionType,
|
||||||
|
SizeStyle,
|
||||||
|
TDDocument,
|
||||||
|
TDShapeType,
|
||||||
|
TextShape,
|
||||||
|
} from '~types'
|
||||||
|
|
||||||
|
const textDoc: TDDocument = {
|
||||||
|
version: 0,
|
||||||
|
id: 'doc',
|
||||||
|
name: 'New Document',
|
||||||
|
pages: {
|
||||||
|
page1: {
|
||||||
|
id: 'page1',
|
||||||
|
shapes: {
|
||||||
|
text1: {
|
||||||
|
id: 'text1',
|
||||||
|
parentId: 'page1',
|
||||||
|
name: 'Text',
|
||||||
|
childIndex: 1,
|
||||||
|
type: TDShapeType.Text,
|
||||||
|
point: [0, 0],
|
||||||
|
text: 'Hello',
|
||||||
|
style: {
|
||||||
|
dash: DashStyle.Draw,
|
||||||
|
size: SizeStyle.Medium,
|
||||||
|
color: ColorStyle.Blue,
|
||||||
|
},
|
||||||
|
label: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bindings: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pageStates: {
|
||||||
|
page1: {
|
||||||
|
id: 'page1',
|
||||||
|
selectedIds: [],
|
||||||
|
camera: {
|
||||||
|
point: [0, 0],
|
||||||
|
zoom: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
assets: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('When creating a shape...', () => {
|
||||||
|
it('begins, updateSession', () => {
|
||||||
|
const app = new TldrawTestApp().selectTool(TDShapeType.Text).doubleClickCanvas()
|
||||||
|
|
||||||
|
// We should be in the editing session
|
||||||
|
expect(app.session?.type).toBe(SessionType.Edit)
|
||||||
|
|
||||||
|
// We should be able to edit the shape
|
||||||
|
const id = app.shapes[0].id
|
||||||
|
|
||||||
|
app.onShapeChange({
|
||||||
|
id,
|
||||||
|
text: 'Hello',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(app.getShape<TextShape>(id).text).toBe('Hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancels session', () => {
|
||||||
|
const app = new TldrawTestApp().selectTool(TDShapeType.Text).doubleClickCanvas()
|
||||||
|
|
||||||
|
const id = app.shapes[0].id
|
||||||
|
|
||||||
|
app
|
||||||
|
.onShapeChange({
|
||||||
|
id,
|
||||||
|
text: 'Hello',
|
||||||
|
})
|
||||||
|
.cancel()
|
||||||
|
|
||||||
|
// Removes the editing shape
|
||||||
|
expect(app.session?.type).toBeUndefined()
|
||||||
|
expect(app.getShape(id)).toBeUndefined()
|
||||||
|
|
||||||
|
// The shape was never added to the undo stack
|
||||||
|
app.undo()
|
||||||
|
expect(app.getShape(id)).toBeUndefined()
|
||||||
|
app.redo()
|
||||||
|
expect(app.getShape(id)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('completes session', () => {
|
||||||
|
const app = new TldrawTestApp().selectTool(TDShapeType.Text).doubleClickCanvas()
|
||||||
|
|
||||||
|
const id = app.shapes[0].id
|
||||||
|
|
||||||
|
app
|
||||||
|
.onShapeChange({
|
||||||
|
id,
|
||||||
|
text: 'Hello',
|
||||||
|
})
|
||||||
|
.completeSession()
|
||||||
|
|
||||||
|
// Removes the editing shape
|
||||||
|
expect(app.session?.type).toBeUndefined()
|
||||||
|
expect(app.getShape(id)).toBeDefined()
|
||||||
|
|
||||||
|
// The shape was never added to the undo stack
|
||||||
|
app.undo()
|
||||||
|
expect(app.getShape(id)).toBeUndefined()
|
||||||
|
|
||||||
|
// The shape was added to the undo stack
|
||||||
|
app.redo()
|
||||||
|
expect(app.getShape(id)).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('When editing an existing a shape...', () => {
|
||||||
|
it('begins, updateSession', () => {
|
||||||
|
const app = new TldrawTestApp().loadDocument(textDoc)
|
||||||
|
|
||||||
|
app.doubleClickShape('text1')
|
||||||
|
|
||||||
|
expect(app.session?.type).toBe(SessionType.Edit)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancels session', () => {
|
||||||
|
const app = new TldrawTestApp()
|
||||||
|
.loadDocument(textDoc)
|
||||||
|
|
||||||
|
.doubleClickShape('text1')
|
||||||
|
.onShapeChange({
|
||||||
|
id: 'text1',
|
||||||
|
text: 'Hello World!',
|
||||||
|
})
|
||||||
|
.cancel()
|
||||||
|
|
||||||
|
// Cancelling will cancel the session and restore the original text
|
||||||
|
expect(app.session?.type).toBeUndefined()
|
||||||
|
expect(app.getShape<TextShape>('text1').text).toBe('Hello')
|
||||||
|
|
||||||
|
// The changes were never added to the undo stack
|
||||||
|
app.undo()
|
||||||
|
expect(app.getShape<TextShape>('text1').text).toBe('Hello')
|
||||||
|
|
||||||
|
// The redo will restore the shape
|
||||||
|
app.redo()
|
||||||
|
expect(app.getShape<TextShape>('text1').text).toBe('Hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('completes session', () => {
|
||||||
|
const app = new TldrawTestApp()
|
||||||
|
.loadDocument(textDoc)
|
||||||
|
.doubleClickShape('text1')
|
||||||
|
.onShapeChange({
|
||||||
|
id: 'text1',
|
||||||
|
text: 'Hello World!',
|
||||||
|
})
|
||||||
|
.completeSession()
|
||||||
|
|
||||||
|
// Cancelling will cancel the session and restore the original text
|
||||||
|
expect(app.session?.type).toBeUndefined()
|
||||||
|
expect(app.getShape<TextShape>('text1').text).toBe('Hello World!')
|
||||||
|
|
||||||
|
// The changes were never added to the undo stack
|
||||||
|
app.undo()
|
||||||
|
expect(app.getShape<TextShape>('text1').text).toBe('Hello')
|
||||||
|
|
||||||
|
// The redo will restore the shape
|
||||||
|
app.redo()
|
||||||
|
expect(app.getShape<TextShape>('text1').text).toBe('Hello World!')
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,88 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
|
import { TDShape, SessionType, TldrawPatch, TldrawCommand } from '~types'
|
||||||
|
import { BaseSession } from '../BaseSession'
|
||||||
|
import type { TldrawApp } from '../../internal'
|
||||||
|
|
||||||
|
export class EditSession extends BaseSession {
|
||||||
|
type = SessionType.Edit
|
||||||
|
performanceMode = undefined
|
||||||
|
|
||||||
|
initialShape: TDShape
|
||||||
|
initialSelectedIds: string[]
|
||||||
|
currentPageId: string
|
||||||
|
isCreating: boolean
|
||||||
|
|
||||||
|
constructor(app: TldrawApp, id: string, isCreating: boolean) {
|
||||||
|
super(app)
|
||||||
|
this.initialShape = app.getShape(id, app.currentPageId)
|
||||||
|
this.currentPageId = app.currentPageId
|
||||||
|
this.isCreating = isCreating
|
||||||
|
this.initialSelectedIds = [...app.selectedIds]
|
||||||
|
}
|
||||||
|
|
||||||
|
start = (): TldrawPatch | undefined => void null
|
||||||
|
|
||||||
|
update = (): TldrawPatch | undefined => void null
|
||||||
|
|
||||||
|
cancel = (): TldrawPatch | undefined => {
|
||||||
|
return {
|
||||||
|
document: {
|
||||||
|
pages: {
|
||||||
|
[this.currentPageId]: {
|
||||||
|
shapes: {
|
||||||
|
[this.initialShape.id]: this.isCreating ? undefined : this.initialShape,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pageStates: {
|
||||||
|
[this.currentPageId]: {
|
||||||
|
selectedIds: this.isCreating ? [] : this.initialSelectedIds,
|
||||||
|
editingId: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
complete = (): TldrawPatch | TldrawCommand | undefined => {
|
||||||
|
const shape = this.app.getShape(this.initialShape.id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'edit',
|
||||||
|
before: {
|
||||||
|
document: {
|
||||||
|
pages: {
|
||||||
|
[this.currentPageId]: {
|
||||||
|
shapes: {
|
||||||
|
[this.initialShape.id]: this.isCreating ? undefined : this.initialShape,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pageStates: {
|
||||||
|
[this.currentPageId]: {
|
||||||
|
selectedIds: this.isCreating ? [] : this.initialSelectedIds,
|
||||||
|
editingId: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
after: {
|
||||||
|
document: {
|
||||||
|
pages: {
|
||||||
|
[this.currentPageId]: {
|
||||||
|
shapes: {
|
||||||
|
[this.initialShape.id]: shape,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pageStates: {
|
||||||
|
[this.currentPageId]: {
|
||||||
|
selectedIds: [shape.id],
|
||||||
|
editingId: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1
packages/tldraw/src/state/sessions/EditSession/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from './EditSession'
|
|
@ -9,6 +9,7 @@ import { TransformSingleSession } from './TransformSingleSession'
|
||||||
import { TranslateSession } from './TranslateSession'
|
import { TranslateSession } from './TranslateSession'
|
||||||
import { EraseSession } from './EraseSession'
|
import { EraseSession } from './EraseSession'
|
||||||
import { GridSession } from './GridSession'
|
import { GridSession } from './GridSession'
|
||||||
|
import { EditSession } from './EditSession'
|
||||||
|
|
||||||
export type TldrawSession =
|
export type TldrawSession =
|
||||||
| ArrowSession
|
| ArrowSession
|
||||||
|
@ -21,6 +22,7 @@ export type TldrawSession =
|
||||||
| TranslateSession
|
| TranslateSession
|
||||||
| EraseSession
|
| EraseSession
|
||||||
| GridSession
|
| GridSession
|
||||||
|
| EditSession
|
||||||
|
|
||||||
export interface SessionsMap {
|
export interface SessionsMap {
|
||||||
[SessionType.Arrow]: typeof ArrowSession
|
[SessionType.Arrow]: typeof ArrowSession
|
||||||
|
@ -33,6 +35,7 @@ export interface SessionsMap {
|
||||||
[SessionType.TransformSingle]: typeof TransformSingleSession
|
[SessionType.TransformSingle]: typeof TransformSingleSession
|
||||||
[SessionType.Translate]: typeof TranslateSession
|
[SessionType.Translate]: typeof TranslateSession
|
||||||
[SessionType.Grid]: typeof GridSession
|
[SessionType.Grid]: typeof GridSession
|
||||||
|
[SessionType.Edit]: typeof EditSession
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionOfType<K extends SessionType> = SessionsMap[K]
|
export type SessionOfType<K extends SessionType> = SessionsMap[K]
|
||||||
|
@ -52,6 +55,7 @@ export const sessions: { [K in SessionType]: SessionsMap[K] } = {
|
||||||
[SessionType.TransformSingle]: TransformSingleSession,
|
[SessionType.TransformSingle]: TransformSingleSession,
|
||||||
[SessionType.Translate]: TranslateSession,
|
[SessionType.Translate]: TranslateSession,
|
||||||
[SessionType.Grid]: GridSession,
|
[SessionType.Grid]: GridSession,
|
||||||
|
[SessionType.Edit]: EditSession,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getSession = <K extends SessionType>(type: K): SessionOfType<K> => {
|
export const getSession = <K extends SessionType>(type: K): SessionOfType<K> => {
|
||||||
|
|
|
@ -60,6 +60,39 @@ export class TextUtil extends TDShapeUtil<T, E> {
|
||||||
const rInput = React.useRef<HTMLTextAreaElement>(null)
|
const rInput = React.useRef<HTMLTextAreaElement>(null)
|
||||||
const rIsMounted = React.useRef(false)
|
const rIsMounted = React.useRef(false)
|
||||||
|
|
||||||
|
const rEditedText = React.useRef(text)
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (text !== rEditedText.current) {
|
||||||
|
let delta = [0, 0]
|
||||||
|
this.texts.set(shape.id, text)
|
||||||
|
const currentBounds = this.getBounds(shape)
|
||||||
|
const nextBounds = this.getBounds(shape)
|
||||||
|
switch (shape.style.textAlign) {
|
||||||
|
case AlignStyle.Start: {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case AlignStyle.Middle: {
|
||||||
|
delta = Vec.div([nextBounds.width - currentBounds.width, 0], 2)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case AlignStyle.End: {
|
||||||
|
delta = [nextBounds.width - currentBounds.width, 0]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rEditedText.current = text
|
||||||
|
|
||||||
|
onShapeChange?.({
|
||||||
|
...shape,
|
||||||
|
id: shape.id,
|
||||||
|
point: Vec.sub(shape.point, delta),
|
||||||
|
text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [text])
|
||||||
|
|
||||||
const handleChange = React.useCallback(
|
const handleChange = React.useCallback(
|
||||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
let delta = [0, 0]
|
let delta = [0, 0]
|
||||||
|
@ -83,6 +116,9 @@ export class TextUtil extends TDShapeUtil<T, E> {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rEditedText.current = newText
|
||||||
|
|
||||||
onShapeChange?.({
|
onShapeChange?.({
|
||||||
...shape,
|
...shape,
|
||||||
id: shape.id,
|
id: shape.id,
|
||||||
|
@ -102,6 +138,13 @@ export class TextUtil extends TDShapeUtil<T, E> {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
rInput.current!.blur()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!(e.key === 'Meta' || e.metaKey)) {
|
if (!(e.key === 'Meta' || e.metaKey)) {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
} else if (e.key === 'z' && e.metaKey) {
|
} else if (e.key === 'z' && e.metaKey) {
|
||||||
|
|
|
@ -50,7 +50,7 @@ export class SelectTool extends BaseTool<Status> {
|
||||||
/* --------------------- Methods -------------------- */
|
/* --------------------- Methods -------------------- */
|
||||||
|
|
||||||
private deselect(id: string) {
|
private deselect(id: string) {
|
||||||
this.app.select(...this.app.selectedIds.filter(oid => oid !== id))
|
this.app.select(...this.app.selectedIds.filter((oid) => oid !== id))
|
||||||
}
|
}
|
||||||
|
|
||||||
private select(id: string) {
|
private select(id: string) {
|
||||||
|
@ -59,7 +59,7 @@ export class SelectTool extends BaseTool<Status> {
|
||||||
|
|
||||||
private pushSelect(id: string) {
|
private pushSelect(id: string) {
|
||||||
const shape = this.app.getShape(id)
|
const shape = this.app.getShape(id)
|
||||||
this.app.select(...this.app.selectedIds.filter(oid => oid !== shape.parentId), id)
|
this.app.select(...this.app.selectedIds.filter((oid) => oid !== shape.parentId), id)
|
||||||
}
|
}
|
||||||
|
|
||||||
private selectNone() {
|
private selectNone() {
|
||||||
|
@ -77,7 +77,7 @@ export class SelectTool extends BaseTool<Status> {
|
||||||
clonePaint = (point: number[]) => {
|
clonePaint = (point: number[]) => {
|
||||||
if (this.app.selectedIds.length === 0) return
|
if (this.app.selectedIds.length === 0) return
|
||||||
|
|
||||||
const shapes = this.app.selectedIds.map(id => this.app.getShape(id))
|
const shapes = this.app.selectedIds.map((id) => this.app.getShape(id))
|
||||||
|
|
||||||
const bounds = Utils.expandBounds(Utils.getCommonBounds(shapes.map(TLDR.getBounds)), 16)
|
const bounds = Utils.expandBounds(Utils.getCommonBounds(shapes.map(TLDR.getBounds)), 16)
|
||||||
|
|
||||||
|
@ -92,7 +92,7 @@ export class SelectTool extends BaseTool<Status> {
|
||||||
|
|
||||||
const centeredBounds = Utils.centerBounds(bounds, gridPoint)
|
const centeredBounds = Utils.centerBounds(bounds, gridPoint)
|
||||||
|
|
||||||
const hit = this.app.shapes.some(shape =>
|
const hit = this.app.shapes.some((shape) =>
|
||||||
TLDR.getShapeUtil(shape).hitTestBounds(shape, centeredBounds)
|
TLDR.getShapeUtil(shape).hitTestBounds(shape, centeredBounds)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -171,12 +171,12 @@ export class SelectTool extends BaseTool<Status> {
|
||||||
/* ----------------- Event Handlers ----------------- */
|
/* ----------------- Event Handlers ----------------- */
|
||||||
|
|
||||||
onCancel = () => {
|
onCancel = () => {
|
||||||
if (this.app.pageState.editingId) {
|
if (this.app.session) {
|
||||||
this.app.setEditingId()
|
this.app.cancelSession()
|
||||||
} else {
|
} else {
|
||||||
this.selectNone()
|
this.selectNone()
|
||||||
}
|
}
|
||||||
this.app.cancelSession()
|
|
||||||
this.setStatus(Status.Idle)
|
this.setStatus(Status.Idle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -265,7 +265,7 @@ export class SelectTool extends BaseTool<Status> {
|
||||||
} else {
|
} else {
|
||||||
// Stat a transform session
|
// Stat a transform session
|
||||||
this.setStatus(Status.Transforming)
|
this.setStatus(Status.Transforming)
|
||||||
const idsToTransform = this.app.selectedIds.flatMap(id =>
|
const idsToTransform = this.app.selectedIds.flatMap((id) =>
|
||||||
TLDR.getDocumentBranch(this.app.state, id, this.app.currentPageId)
|
TLDR.getDocumentBranch(this.app.state, id, this.app.currentPageId)
|
||||||
)
|
)
|
||||||
if (idsToTransform.length === 1) {
|
if (idsToTransform.length === 1) {
|
||||||
|
@ -372,7 +372,7 @@ export class SelectTool extends BaseTool<Status> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onPointerUp: TLPointerEventHandler = info => {
|
onPointerUp: TLPointerEventHandler = (info) => {
|
||||||
if (this.status === Status.TranslatingClone || this.status === Status.PointingClone) {
|
if (this.status === Status.TranslatingClone || this.status === Status.PointingClone) {
|
||||||
if (this.pointedId) {
|
if (this.pointedId) {
|
||||||
this.app.completeSession()
|
this.app.completeSession()
|
||||||
|
@ -415,11 +415,18 @@ export class SelectTool extends BaseTool<Status> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complete the current session, if any; and reset the status
|
// Complete the current session, if any; and reset the status
|
||||||
this.app.completeSession()
|
|
||||||
this.setStatus(Status.Idle)
|
this.setStatus(Status.Idle)
|
||||||
this.pointedBoundsHandle = undefined
|
this.pointedBoundsHandle = undefined
|
||||||
this.pointedHandleId = undefined
|
this.pointedHandleId = undefined
|
||||||
this.pointedId = undefined
|
this.pointedId = undefined
|
||||||
|
|
||||||
|
// Don't complete a session if we've just started one
|
||||||
|
if (this.app.session?.type === SessionType.Edit) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.app.completeSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Canvas
|
// Canvas
|
||||||
|
@ -529,7 +536,7 @@ export class SelectTool extends BaseTool<Status> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onDoubleClickShape: TLPointerEventHandler = info => {
|
onDoubleClickShape: TLPointerEventHandler = (info) => {
|
||||||
if (this.app.readOnly) return
|
if (this.app.readOnly) return
|
||||||
|
|
||||||
const shape = this.app.getShape(info.target)
|
const shape = this.app.getShape(info.target)
|
||||||
|
@ -556,17 +563,17 @@ export class SelectTool extends BaseTool<Status> {
|
||||||
this.app.select(info.target)
|
this.app.select(info.target)
|
||||||
}
|
}
|
||||||
|
|
||||||
onRightPointShape: TLPointerEventHandler = info => {
|
onRightPointShape: TLPointerEventHandler = (info) => {
|
||||||
if (!this.app.isSelected(info.target)) {
|
if (!this.app.isSelected(info.target)) {
|
||||||
this.app.select(info.target)
|
this.app.select(info.target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onHoverShape: TLPointerEventHandler = info => {
|
onHoverShape: TLPointerEventHandler = (info) => {
|
||||||
this.app.setHoveredId(info.target)
|
this.app.setHoveredId(info.target)
|
||||||
}
|
}
|
||||||
|
|
||||||
onUnhoverShape: TLPointerEventHandler = info => {
|
onUnhoverShape: TLPointerEventHandler = (info) => {
|
||||||
const { currentPageId: oldCurrentPageId } = this.app
|
const { currentPageId: oldCurrentPageId } = this.app
|
||||||
|
|
||||||
// Wait a frame; and if we haven't changed the hovered id,
|
// Wait a frame; and if we haven't changed the hovered id,
|
||||||
|
@ -583,7 +590,7 @@ export class SelectTool extends BaseTool<Status> {
|
||||||
|
|
||||||
/* --------------------- Bounds --------------------- */
|
/* --------------------- Bounds --------------------- */
|
||||||
|
|
||||||
onPointBounds: TLBoundsEventHandler = info => {
|
onPointBounds: TLBoundsEventHandler = (info) => {
|
||||||
if (info.metaKey) {
|
if (info.metaKey) {
|
||||||
if (!info.shiftKey) {
|
if (!info.shiftKey) {
|
||||||
this.selectNone()
|
this.selectNone()
|
||||||
|
@ -612,12 +619,12 @@ export class SelectTool extends BaseTool<Status> {
|
||||||
|
|
||||||
/* ----------------- Bounds Handles ----------------- */
|
/* ----------------- Bounds Handles ----------------- */
|
||||||
|
|
||||||
onPointBoundsHandle: TLBoundsHandleEventHandler = info => {
|
onPointBoundsHandle: TLBoundsHandleEventHandler = (info) => {
|
||||||
this.pointedBoundsHandle = info.target
|
this.pointedBoundsHandle = info.target
|
||||||
this.setStatus(Status.PointingBoundsHandle)
|
this.setStatus(Status.PointingBoundsHandle)
|
||||||
}
|
}
|
||||||
|
|
||||||
onDoubleClickBoundsHandle: TLBoundsHandleEventHandler = info => {
|
onDoubleClickBoundsHandle: TLBoundsHandleEventHandler = (info) => {
|
||||||
switch (info.target) {
|
switch (info.target) {
|
||||||
case 'center':
|
case 'center':
|
||||||
case 'left':
|
case 'left':
|
||||||
|
@ -650,12 +657,12 @@ export class SelectTool extends BaseTool<Status> {
|
||||||
|
|
||||||
/* --------------------- Handles -------------------- */
|
/* --------------------- Handles -------------------- */
|
||||||
|
|
||||||
onPointHandle: TLPointerEventHandler = info => {
|
onPointHandle: TLPointerEventHandler = (info) => {
|
||||||
this.pointedHandleId = info.target as 'start' | 'end'
|
this.pointedHandleId = info.target as 'start' | 'end'
|
||||||
this.setStatus(Status.PointingHandle)
|
this.setStatus(Status.PointingHandle)
|
||||||
}
|
}
|
||||||
|
|
||||||
onDoubleClickHandle: TLPointerEventHandler = info => {
|
onDoubleClickHandle: TLPointerEventHandler = (info) => {
|
||||||
if (info.target === 'bend') {
|
if (info.target === 'bend') {
|
||||||
const { selectedIds } = this.app
|
const { selectedIds } = this.app
|
||||||
if (selectedIds.length !== 1) return
|
if (selectedIds.length !== 1) return
|
||||||
|
@ -678,7 +685,7 @@ export class SelectTool extends BaseTool<Status> {
|
||||||
|
|
||||||
/* ---------------------- Misc ---------------------- */
|
/* ---------------------- Misc ---------------------- */
|
||||||
|
|
||||||
onShapeClone: TLShapeCloneHandler = info => {
|
onShapeClone: TLShapeCloneHandler = (info) => {
|
||||||
const selectedShapeId = this.app.selectedIds[0]
|
const selectedShapeId = this.app.selectedIds[0]
|
||||||
|
|
||||||
const clonedShape = this.getShapeClone(selectedShapeId, info.target)
|
const clonedShape = this.getShapeClone(selectedShapeId, info.target)
|
||||||
|
|
|
@ -50,7 +50,7 @@ export class StickyTool extends BaseTool {
|
||||||
|
|
||||||
newShape.point = Vec.sub(newShape.point, [bounds.width / 2, bounds.height / 2])
|
newShape.point = Vec.sub(newShape.point, [bounds.width / 2, bounds.height / 2])
|
||||||
|
|
||||||
this.app.createShapes(newShape)
|
this.app.patchCreate([newShape])
|
||||||
|
|
||||||
this.app.startSession(SessionType.Translate)
|
this.app.startSession(SessionType.Translate)
|
||||||
|
|
||||||
|
|
|
@ -39,7 +39,11 @@ export class TextTool extends BaseTool {
|
||||||
settings: { showGrid },
|
settings: { showGrid },
|
||||||
} = this.app
|
} = this.app
|
||||||
|
|
||||||
this.app.createTextShapeAtPoint(showGrid ? Vec.snap(currentPoint, currentGrid) : currentPoint)
|
this.app.createTextShapeAtPoint(
|
||||||
|
showGrid ? Vec.snap(currentPoint, currentGrid) : currentPoint,
|
||||||
|
undefined,
|
||||||
|
true
|
||||||
|
)
|
||||||
this.setStatus(Status.Creating)
|
this.setStatus(Status.Creating)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,6 +44,19 @@ export class TldrawTestApp extends TldrawApp {
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
doubleClickCanvas = (options?: PointerOptions | number[]) => {
|
||||||
|
this.onPointerDown(
|
||||||
|
inputs.pointerDown(this.getPoint(options), 'canvas'),
|
||||||
|
{} as React.PointerEvent
|
||||||
|
)
|
||||||
|
this.onDoubleClickCanvas(
|
||||||
|
inputs.pointerDown(this.getPoint(options), 'canvas'),
|
||||||
|
{} as React.PointerEvent
|
||||||
|
)
|
||||||
|
this.onPointerUp(inputs.pointerUp(this.getPoint(options), 'canvas'), {} as React.PointerEvent)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
doubleClickShape = (id: string, options?: PointerOptions | number[]) => {
|
doubleClickShape = (id: string, options?: PointerOptions | number[]) => {
|
||||||
this.onPointerDown(
|
this.onPointerDown(
|
||||||
inputs.pointerDown(this.getPoint(options), 'canvas'),
|
inputs.pointerDown(this.getPoint(options), 'canvas'),
|
||||||
|
|
|
@ -178,6 +178,7 @@ export enum TDUserStatus {
|
||||||
export interface TDUser extends TLUser<TDShape> {
|
export interface TDUser extends TLUser<TDShape> {
|
||||||
activeShapes: TDShape[]
|
activeShapes: TDShape[]
|
||||||
status: TDUserStatus
|
status: TDUserStatus
|
||||||
|
session?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Theme = 'dark' | 'light'
|
export type Theme = 'dark' | 'light'
|
||||||
|
@ -193,6 +194,7 @@ export enum SessionType {
|
||||||
Rotate = 'rotate',
|
Rotate = 'rotate',
|
||||||
Handle = 'handle',
|
Handle = 'handle',
|
||||||
Grid = 'grid',
|
Grid = 'grid',
|
||||||
|
Edit = 'edit',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum TDStatus {
|
export enum TDStatus {
|
||||||
|
|
54
yarn.lock
|
@ -1863,25 +1863,15 @@
|
||||||
"@jridgewell/resolve-uri" "^3.0.3"
|
"@jridgewell/resolve-uri" "^3.0.3"
|
||||||
"@jridgewell/sourcemap-codec" "^1.4.10"
|
"@jridgewell/sourcemap-codec" "^1.4.10"
|
||||||
|
|
||||||
"@liveblocks/client@^0.14.0":
|
"@liveblocks/client@^0.17.0-beta2":
|
||||||
version "0.14.0"
|
version "0.17.0-beta2"
|
||||||
resolved "https://registry.yarnpkg.com/@liveblocks/client/-/client-0.14.0.tgz#2a5f7bd243d3aea7b95cf62737e49dc13df1b69b"
|
resolved "https://registry.yarnpkg.com/@liveblocks/client/-/client-0.17.0-beta2.tgz#0cd14dd29350a29765bfa41c5d7a2185010ab791"
|
||||||
integrity sha512-I1nykCqYSpuBQhP1kZplYqL6L0+C1JocW01UKgPz+tthOOGdTdsNBHPcMigxou4vsOQutUuEJUcaDsd1or4A+Q==
|
integrity sha512-V8LOv6BIDQvLl2LLcdDWIK6Ow3hMSpmIYk5T+NbNC34MM04bdhBU3C4EHg4qRnTPtOxm4NylRiVbNDwfwmMNjw==
|
||||||
|
|
||||||
"@liveblocks/client@^0.16.17":
|
"@liveblocks/react@^0.17.0-beta2":
|
||||||
version "0.16.17"
|
version "0.17.0-beta2"
|
||||||
resolved "https://registry.yarnpkg.com/@liveblocks/client/-/client-0.16.17.tgz#38f8392d7baaf20b34237d06fe3cb53586cea8f0"
|
resolved "https://registry.yarnpkg.com/@liveblocks/react/-/react-0.17.0-beta2.tgz#e148f9c1eb03a40db350ca2ccef180248e6d3afc"
|
||||||
integrity sha512-mWX/EGQNoWwzkEdUfdt82w9OMA5kKV3BraBt2YhZO4Zn04mfNmcOkr7V4S+EmJ4LyQ5QVNKYvA4gJQU1ahn5mQ==
|
integrity sha512-g7XSias4M2nl6eao8JxS5ynyjTvkV4FF8PavvAd9aO5sLz++1qrPW0XCXq2jle2sOyM9act/nMX35FedXvXPFA==
|
||||||
|
|
||||||
"@liveblocks/react@^0.14.0":
|
|
||||||
version "0.14.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/@liveblocks/react/-/react-0.14.0.tgz#1a29ff10e88ea04d029f553f8b14a6cff4584f70"
|
|
||||||
integrity sha512-KGuEEmifh3A9OEHTYaR3+yxIQOhdAQG59d0eOqKbyV/I0X6IoX4kAtXZjnZllrWrTwnnSY2aPFESthtt19t6EQ==
|
|
||||||
|
|
||||||
"@liveblocks/react@^0.16.17":
|
|
||||||
version "0.16.17"
|
|
||||||
resolved "https://registry.yarnpkg.com/@liveblocks/react/-/react-0.16.17.tgz#e3d5c591ad284f84cf5689ad2b8f4d0d0e1ef1ba"
|
|
||||||
integrity sha512-I0WvNI+X/I+ymbVF/+W4GOCaZnsaqje01Mp9BGt7u7joL1ZNBQuva1zWPINE6pNWTk75y/pmG4o56YoC+ek4FQ==
|
|
||||||
|
|
||||||
"@malept/cross-spawn-promise@^1.1.0":
|
"@malept/cross-spawn-promise@^1.1.0":
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
|
@ -5411,6 +5401,14 @@ enhanced-resolve@^5.0.0:
|
||||||
graceful-fs "^4.2.4"
|
graceful-fs "^4.2.4"
|
||||||
tapable "^2.2.0"
|
tapable "^2.2.0"
|
||||||
|
|
||||||
|
enhanced-resolve@^5.7.0:
|
||||||
|
version "5.9.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.9.3.tgz#44a342c012cbc473254af5cc6ae20ebd0aae5d88"
|
||||||
|
integrity sha512-Bq9VSor+kjvW3f9/MiiR4eE3XYgOl7/rS8lnSxbRbF3kS0B2r+Y9w5krBWxZgDxASVZbdYrn5wT4j/Wb0J9qow==
|
||||||
|
dependencies:
|
||||||
|
graceful-fs "^4.2.4"
|
||||||
|
tapable "^2.2.0"
|
||||||
|
|
||||||
enhanced-resolve@^5.8.3:
|
enhanced-resolve@^5.8.3:
|
||||||
version "5.9.0"
|
version "5.9.0"
|
||||||
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.9.0.tgz#49ac24953ac8452ed8fed2ef1340fc8e043667ee"
|
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.9.0.tgz#49ac24953ac8452ed8fed2ef1340fc8e043667ee"
|
||||||
|
@ -6769,6 +6767,11 @@ hotkeys-js@3.8.7:
|
||||||
resolved "https://registry.yarnpkg.com/hotkeys-js/-/hotkeys-js-3.8.7.tgz#c16cab978b53d7242f860ca3932e976b92399981"
|
resolved "https://registry.yarnpkg.com/hotkeys-js/-/hotkeys-js-3.8.7.tgz#c16cab978b53d7242f860ca3932e976b92399981"
|
||||||
integrity sha512-ckAx3EkUr5XjDwjEHDorHxRO2Kb7z6Z2Sxul4MbBkN8Nho7XDslQsgMJT+CiJ5Z4TgRxxvKHEpuLE3imzqy4Lg==
|
integrity sha512-ckAx3EkUr5XjDwjEHDorHxRO2Kb7z6Z2Sxul4MbBkN8Nho7XDslQsgMJT+CiJ5Z4TgRxxvKHEpuLE3imzqy4Lg==
|
||||||
|
|
||||||
|
hotkeys-js@3.9.3:
|
||||||
|
version "3.9.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/hotkeys-js/-/hotkeys-js-3.9.3.tgz#4b755cc695b388d7f93a83aff4b0c2a45719996c"
|
||||||
|
integrity sha512-s+f0xyvDmf6+DyrFQ2SY+eA7lbvMbjqkqi0I0SpMgnN5tZx7DeH8nsWhkJR4KEq3pxDPHJppDUhdt1rZFW5LeQ==
|
||||||
|
|
||||||
html-encoding-sniffer@^2.0.1:
|
html-encoding-sniffer@^2.0.1:
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz#42a6dc4fd33f00281176e8b23759ca4e4fa185f3"
|
resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz#42a6dc4fd33f00281176e8b23759ca4e4fa185f3"
|
||||||
|
@ -8678,6 +8681,14 @@ next-themes@^0.0.15:
|
||||||
resolved "https://registry.yarnpkg.com/next-themes/-/next-themes-0.0.15.tgz#ab0cee69cd763b77d41211f631e108beab39bf7d"
|
resolved "https://registry.yarnpkg.com/next-themes/-/next-themes-0.0.15.tgz#ab0cee69cd763b77d41211f631e108beab39bf7d"
|
||||||
integrity sha512-LTmtqYi03c4gMTJmWwVK9XkHL7h0/+XrtR970Ujvtu3s0kZNeJN24aJsi4rkZOI8i19+qq6f8j+8Duwy5jqcrQ==
|
integrity sha512-LTmtqYi03c4gMTJmWwVK9XkHL7h0/+XrtR970Ujvtu3s0kZNeJN24aJsi4rkZOI8i19+qq6f8j+8Duwy5jqcrQ==
|
||||||
|
|
||||||
|
next-transpile-modules@^9.0.0:
|
||||||
|
version "9.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/next-transpile-modules/-/next-transpile-modules-9.0.0.tgz#133b1742af082e61cc76b02a0f12ffd40ce2bf90"
|
||||||
|
integrity sha512-VCNFOazIAnXn1hvgYYSTYMnoWgKgwlYh4lm1pKbSfiB3kj5ZYLcKVhfh3jkPOg1cnd9DP+pte9yCUocdPEUBTQ==
|
||||||
|
dependencies:
|
||||||
|
enhanced-resolve "^5.7.0"
|
||||||
|
escalade "^3.1.1"
|
||||||
|
|
||||||
next@^12.1.6:
|
next@^12.1.6:
|
||||||
version "12.1.6"
|
version "12.1.6"
|
||||||
resolved "https://registry.yarnpkg.com/next/-/next-12.1.6.tgz#eb205e64af1998651f96f9df44556d47d8bbc533"
|
resolved "https://registry.yarnpkg.com/next/-/next-12.1.6.tgz#eb205e64af1998651f96f9df44556d47d8bbc533"
|
||||||
|
@ -9553,6 +9564,13 @@ react-hotkeys-hook@^3.4.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
hotkeys-js "3.8.7"
|
hotkeys-js "3.8.7"
|
||||||
|
|
||||||
|
react-hotkeys-hook@^3.4.6:
|
||||||
|
version "3.4.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-hotkeys-hook/-/react-hotkeys-hook-3.4.6.tgz#21eda8e97121583a14056479e3eea9e51d2e2a69"
|
||||||
|
integrity sha512-SiGKHnauaAQglRA7qeiW5LTa0KoT2ssv8YGYKZQoM3P9v5JFEHJdXOSFml1N6K86oKQ8dLCLlxqBqGlSJWGmxQ==
|
||||||
|
dependencies:
|
||||||
|
hotkeys-js "3.9.3"
|
||||||
|
|
||||||
react-intl@^6.0.3:
|
react-intl@^6.0.3:
|
||||||
version "6.0.3"
|
version "6.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/react-intl/-/react-intl-6.0.3.tgz#eb5857f2fd525c83255bf6c8339562a7fea9f970"
|
resolved "https://registry.yarnpkg.com/react-intl/-/react-intl-6.0.3.tgz#eb5857f2fd525c83255bf6c8339562a7fea9f970"
|
||||||
|
|