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>
This commit is contained in:
Vincent Driessen 2022-06-25 16:38:43 +02:00 committed by GitHub
parent 6183c41c18
commit 0acfd563fe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1073 additions and 400 deletions

View file

@ -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": "^_"
}
]
} }
} }
] ]

View file

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

View file

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

View file

@ -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,57 +21,59 @@ const isProduction = NODE_ENV === 'production'
const basePath = '' const basePath = ''
module.exports = withPWA({ module.exports = withTM(['@tldraw/tldraw', '@tldraw/core'])(
reactStrictMode: true, withPWA({
pwa: { reactStrictMode: true,
disable: !isProduction, pwa: {
dest: 'public', disable: !isProduction,
}, dest: 'public',
productionBrowserSourceMaps: true, },
env: { productionBrowserSourceMaps: true,
NEXT_PUBLIC_COMMIT_SHA: VERCEL_GIT_COMMIT_SHA, env: {
GA_MEASUREMENT_ID, NEXT_PUBLIC_COMMIT_SHA: VERCEL_GIT_COMMIT_SHA,
GITHUB_ID, GA_MEASUREMENT_ID,
GITHUB_API_SECRET, GITHUB_ID,
}, GITHUB_API_SECRET,
webpack: (config, options) => { },
if (!options.isServer) { webpack: (config, options) => {
config.resolve.alias['@sentry/node'] = '@sentry/browser' if (!options.isServer) {
} config.resolve.alias['@sentry/node'] = '@sentry/browser'
}
config.plugins.push(
new options.webpack.DefinePlugin({
'process.env.NEXT_IS_SERVER': JSON.stringify(options.isServer.toString()),
})
)
config.module.rules.push({
test: /.*packages.*\.js$/,
use: ['source-map-loader'],
enforce: 'pre',
})
if (
SENTRY_DSN &&
SENTRY_ORG &&
SENTRY_PROJECT &&
SENTRY_AUTH_TOKEN &&
VERCEL_GIT_COMMIT_SHA &&
isProduction
) {
config.plugins.push( config.plugins.push(
new SentryWebpackPlugin({ new options.webpack.DefinePlugin({
include: '.next', 'process.env.NEXT_IS_SERVER': JSON.stringify(options.isServer.toString()),
ignore: ['node_modules'],
stripPrefix: ['webpack://_N_E/'],
urlPrefix: `~${basePath}/_next`,
release: VERCEL_GIT_COMMIT_SHA,
authToken: SENTRY_AUTH_TOKEN,
org: SENTRY_PROJECT,
project: SENTRY_ORG,
}) })
) )
}
return config config.module.rules.push({
}, test: /.*packages.*\.js$/,
}) use: ['source-map-loader'],
enforce: 'pre',
})
if (
SENTRY_DSN &&
SENTRY_ORG &&
SENTRY_PROJECT &&
SENTRY_AUTH_TOKEN &&
VERCEL_GIT_COMMIT_SHA &&
isProduction
) {
config.plugins.push(
new SentryWebpackPlugin({
include: '.next',
ignore: ['node_modules'],
stripPrefix: ['webpack://_N_E/'],
urlPrefix: `~${basePath}/_next`,
release: VERCEL_GIT_COMMIT_SHA,
authToken: SENTRY_AUTH_TOKEN,
org: SENTRY_PROJECT,
project: SENTRY_ORG,
})
)
}
return config
},
})
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

@ -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', switch (e.event.name) {
(e: { connectionId: number; event: { name: string; userId: string } }) => { case 'exit': {
switch (e.event.name) { app?.removeUser(e.event.userId)
case 'exit': { break
app?.removeUser(e.event.userId)
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,

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

View file

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

View file

@ -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', switch (e.event.name) {
(e: { connectionId: number; event: { name: string; userId: string } }) => { case 'exit': {
switch (e.event.name) { app?.removeUser(e.event.userId)
case 'exit': { break
app?.removeUser(e.event.userId)
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,

View file

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

View file

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

View file

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

View file

@ -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(() => {

View file

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

View file

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

View file

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

View file

@ -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)
})
}
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)
} }
onCommand = (app: TDSnapshot, id?: string) => { 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.persist() this.patchState(patch, `settings:${name}`)
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.persist() this.patchState(patch, `settings:toggled_focus_mode`)
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() this.persist(patch)
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])
this.createShapes(newShape)
this.setEditingId(newShape.id) if (patch) {
this.patchCreate([TLDR.getShapeUtil(newShape.type).create(newShape)])
} else {
this.createShapes(newShape)
}
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 = () => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
} }
@ -60,7 +64,7 @@ export class TextTool extends BaseTool {
} }
onShapeBlur = () => { onShapeBlur = () => {
if (this.app.readOnly) return if (this.app.readOnly) return
this.stopEditingShape() this.stopEditingShape()
} }
} }

View file

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

View file

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

View file

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