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
"files": ["*.ts", "*.tsx"],
"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 { LiveblocksProvider, RoomProvider } from '@liveblocks/react'
import { RoomProvider } from '../utils/liveblocks'
import { Tldraw, useFileSystem } from '@tldraw/tldraw'
import { useAccountHandlers } from 'hooks/useAccountHandlers'
import { useMultiplayerAssets } from 'hooks/useMultiplayerAssets'
@ -8,11 +7,6 @@ import { useUploadAssets } from 'hooks/useUploadAssets'
import React, { FC } from 'react'
import { styled } from 'styles'
const client = createClient({
publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_API_KEY || '',
throttle: 80,
})
interface Props {
roomId: string
isUser: boolean
@ -29,11 +23,9 @@ const MultiplayerEditor: FC<Props> = ({
isSponsor: boolean
}) => {
return (
<LiveblocksProvider client={client}>
<RoomProvider id={roomId}>
<Editor roomId={roomId} isSponsor={isSponsor} isUser={isUser} />
</RoomProvider>
</LiveblocksProvider>
<RoomProvider id={roomId}>
<Editor roomId={roomId} isSponsor={isSponsor} isUser={isUser} />
</RoomProvider>
)
}

View file

@ -1,21 +1,20 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import React, { useState, useRef, useCallback } from 'react'
import type { TldrawApp, TDUser, TDShape, TDBinding, TDDocument, TDAsset } from '@tldraw/tldraw'
import { useRedo, useUndo, useRoom, useUpdateMyPresence } from '@liveblocks/react'
import { LiveMap, LiveObject, Lson, LsonObject } from '@liveblocks/client'
import type { TldrawApp, TDUser, TDShape, TDBinding, TDAsset } from '@tldraw/tldraw'
import {
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 }
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) {
const [app, setApp] = useState<TldrawApp>()
const [error, setError] = useState<Error>()
@ -26,9 +25,11 @@ export function useMultiplayerState(roomId: string) {
const onRedo = useRedo()
const updateMyPresence = useUpdateMyPresence()
const rLiveShapes = useRef<LiveMap<string, TDLsonShape>>()
const rLiveBindings = useRef<LiveMap<string, TDLsonBinding>>()
const rLiveAssets = useRef<LiveMap<string, TDLsonAsset>>()
const rIsPaused = useRef(false)
const rLiveShapes = useRef<Storage['shapes']>()
const rLiveBindings = useRef<Storage['bindings']>()
const rLiveAssets = useRef<Storage['assets']>()
// Callbacks --------------
@ -62,7 +63,7 @@ export function useMultiplayerState(roomId: string) {
if (!shape) {
lShapes.delete(id)
} else {
lShapes.set(shape.id, shape as TDLsonShape)
lShapes.set(shape.id, shape)
}
})
@ -70,7 +71,7 @@ export function useMultiplayerState(roomId: string) {
if (!binding) {
lBindings.delete(id)
} else {
lBindings.set(binding.id, binding as TDLsonBinding)
lBindings.set(binding.id, binding)
}
})
@ -78,7 +79,7 @@ export function useMultiplayerState(roomId: string) {
if (!asset) {
lAssets.delete(id)
} 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
unsubs.push(
room.subscribe<{ id: string; user: TDUser }>('others', (others, event) => {
room.subscribe('others', (others, event) => {
if (event.type === 'leave') {
if (event.user.presence) {
app?.removeUser(event.user.presence.id)
@ -125,30 +126,30 @@ export function useMultiplayerState(roomId: string) {
// Setup the document's storage and subscriptions
async function setupDocument() {
const storage = await room.getStorage<any>()
const storage = await room.getStorage()
// Migrate previous versions
const version = storage.root.get('version')
// 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)) {
storage.root.set('shapes', new LiveMap<string, TDLsonShape>())
storage.root.set('shapes', new LiveMap())
lShapes = storage.root.get('shapes')
}
rLiveShapes.current = lShapes
let lBindings: LiveMap<string, TDLsonBinding> = storage.root.get('bindings')
let lBindings = storage.root.get('bindings')
if (!lBindings || !('_serialize' in lBindings)) {
storage.root.set('bindings', new LiveMap<string, TDLsonBinding>())
storage.root.set('bindings', new LiveMap())
lBindings = storage.root.get('bindings')
}
rLiveBindings.current = lBindings
let lAssets: LiveMap<string, TDLsonAsset> = storage.root.get('assets')
let lAssets = storage.root.get('assets')
if (!lAssets || !('_serialize' in lAssets)) {
storage.root.set('assets', new LiveMap<string, TDLsonAsset>())
storage.root.set('assets', new LiveMap())
lAssets = storage.root.get('assets')
}
rLiveAssets.current = lAssets
@ -159,7 +160,7 @@ export function useMultiplayerState(roomId: string) {
// document was a single LiveObject named 'doc'. If we find a doc,
// then we need to move the shapes and bindings over to the new structures
// and then mark the doc as migrated.
const doc = storage.root.get('doc') as LiveObject<LsonDoc>
const doc = storage.root.get('doc')
// No doc? No problem. This was likely a newer document
if (doc) {
@ -172,11 +173,9 @@ export function useMultiplayerState(roomId: string) {
},
} = doc.toObject()
Object.values(shapes).forEach((shape) => lShapes.set(shape.id, shape as TDLsonShape))
Object.values(bindings).forEach((binding) =>
lBindings.set(binding.id, binding as TDLsonBinding)
)
Object.values(assets).forEach((asset) => lAssets.set(asset.id, asset as TDLsonAsset))
Object.values(shapes).forEach((shape) => lShapes.set(shape.id, shape))
Object.values(bindings).forEach((binding) => lBindings.set(binding.id, binding))
Object.values(assets).forEach((asset) => lAssets.set(asset.id, asset))
}
}
@ -219,13 +218,43 @@ export function useMultiplayerState(roomId: string) {
const onSessionStart = React.useCallback(() => {
if (!room) return
room.history.pause()
rIsPaused.current = true
}, [room])
const onSessionEnd = React.useCallback(() => {
if (!room) return
room.history.resume()
rIsPaused.current = false
}, [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 {
onUndo,
onRedo,

View file

@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const withPWA = require('next-pwa')
const withTM = require('next-transpile-modules')
const SentryWebpackPlugin = require('@sentry/webpack-plugin')
const {
@ -20,57 +21,59 @@ const isProduction = NODE_ENV === 'production'
const basePath = ''
module.exports = withPWA({
reactStrictMode: true,
pwa: {
disable: !isProduction,
dest: 'public',
},
productionBrowserSourceMaps: true,
env: {
NEXT_PUBLIC_COMMIT_SHA: VERCEL_GIT_COMMIT_SHA,
GA_MEASUREMENT_ID,
GITHUB_ID,
GITHUB_API_SECRET,
},
webpack: (config, options) => {
if (!options.isServer) {
config.resolve.alias['@sentry/node'] = '@sentry/browser'
}
module.exports = withTM(['@tldraw/tldraw', '@tldraw/core'])(
withPWA({
reactStrictMode: true,
pwa: {
disable: !isProduction,
dest: 'public',
},
productionBrowserSourceMaps: true,
env: {
NEXT_PUBLIC_COMMIT_SHA: VERCEL_GIT_COMMIT_SHA,
GA_MEASUREMENT_ID,
GITHUB_ID,
GITHUB_API_SECRET,
},
webpack: (config, options) => {
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(
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,
new options.webpack.DefinePlugin({
'process.env.NEXT_IS_SERVER': JSON.stringify(options.isServer.toString()),
})
)
}
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"
},
"dependencies": {
"@sentry/webpack-plugin": "^1.17.1",
"@types/next-auth": "^3.15.0",
"@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",
"@liveblocks/client": "^0.17.0-beta2",
"@liveblocks/react": "^0.17.0-beta2",
"@sentry/integrations": "^6.13.2",
"@sentry/node": "^6.13.2",
"@sentry/react": "^6.13.2",
"@sentry/tracing": "^6.13.2",
"@sentry/webpack-plugin": "^1.17.1",
"@stitches/react": "^1.2.8",
"@tldraw/core": "*",
"@tldraw/tldraw": "*",
"@types/next-auth": "^3.15.0",
"@types/react": "^18.0.12",
"@types/react-dom": "^18.0.5",
"aws-sdk": "^2.1053.0",
"eslint": "^8.8.0",
"eslint-config-next": "^12.0.10",
"lz-string": "^1.4.4",
"nanoid": "^3.3.4",
"next": "^12.1.6",
@ -42,7 +41,12 @@
"next-pwa": "^5.5.4",
"next-themes": "^0.0.15",
"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">
<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 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="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="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>

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">
<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 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 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 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="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="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.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="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>

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

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"?>
<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>
<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="bg-copy" fill="#FFFFFF" opacity="1">
<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"?>
<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>
<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="bg" fill="#FFFFFF" opacity="0.00999999978">
<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"?>
<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>
<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="bg-copy" fill="#FFFFFF" opacity="1">
<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"
},
"devDependencies": {
"@liveblocks/client": "^0.14.0",
"@liveblocks/react": "^0.14.0",
"@liveblocks/client": "^0.17.0-beta2",
"@liveblocks/react": "^0.17.0-beta2",
"@types/node": "^17.0.14",
"@types/react-router-dom": "^5.3.3",
"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 */
import * as React from 'react'
import { TDShape, Tldraw } from '@tldraw/tldraw'
import { createClient } from '@liveblocks/client'
import { LiveblocksProvider, RoomProvider } from '@liveblocks/react'
import { Tldraw } from '@tldraw/tldraw'
import { RoomProvider } from './liveblocks.config'
import { useMultiplayerState } from './useMultiplayerState'
// import { initializeApp } from 'firebase/app'
// import firebaseConfig from '../firebase.config'
// import { useMemo } from 'react'
// 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'
export function Multiplayer() {
return (
<LiveblocksProvider client={client}>
<RoomProvider id={roomId}>
<Editor roomId={roomId} />
</RoomProvider>
</LiveblocksProvider>
<RoomProvider id={roomId}>
<Editor roomId={roomId} />
</RoomProvider>
)
}

View file

@ -1,9 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as React from 'react'
import type { TldrawApp, TDUser, TDShape, TDBinding, TDDocument, TDAsset } from '@tldraw/tldraw'
import { useRedo, useUndo, useRoom, useUpdateMyPresence } from '@liveblocks/react'
import { LiveMap, LiveObject } from '@liveblocks/client'
import type { TldrawApp, TDUser, TDShape, TDBinding, TDAsset } from '@tldraw/tldraw'
import { useRedo, useUndo, useRoom, useUpdateMyPresence } from './liveblocks.config'
import { LiveMap } from '@liveblocks/client'
import type { Storage } from './liveblocks.config'
declare const window: Window & { app: TldrawApp }
@ -17,9 +18,9 @@ export function useMultiplayerState(roomId: string) {
const onRedo = useRedo()
const updateMyPresence = useUpdateMyPresence()
const rLiveShapes = React.useRef<LiveMap<string, TDShape>>()
const rLiveBindings = React.useRef<LiveMap<string, TDBinding>>()
const rLiveAssets = React.useRef<LiveMap<string, TDAsset>>()
const rLiveShapes = React.useRef<Storage['shapes'] | undefined>()
const rLiveBindings = React.useRef<Storage['bindings'] | undefined>()
const rLiveAssets = React.useRef<Storage['assets'] | undefined>()
// Callbacks --------------
@ -108,17 +109,14 @@ export function useMultiplayerState(roomId: string) {
// Handle events from the room
unsubs.push(
room.subscribe(
'event',
(e: { connectionId: number; event: { name: string; userId: string } }) => {
switch (e.event.name) {
case 'exit': {
app?.removeUser(e.event.userId)
break
}
room.subscribe('event', (e) => {
switch (e.event.name) {
case 'exit': {
app?.removeUser(e.event.userId)
break
}
}
)
})
)
// Send the exit event when the tab closes
@ -134,27 +132,27 @@ export function useMultiplayerState(roomId: string) {
// Setup the document's storage and subscriptions
async function setupDocument() {
const storage = await room.getStorage<any>()
const storage = await room.getStorage()
// 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) {
storage.root.set('shapes', new LiveMap<string, TDShape>())
storage.root.set('shapes', new LiveMap())
lShapes = storage.root.get('shapes')
}
rLiveShapes.current = lShapes
let lBindings: LiveMap<string, TDBinding> = storage.root.get('bindings')
let lBindings = storage.root.get('bindings')
if (!lBindings) {
storage.root.set('bindings', new LiveMap<string, TDBinding>())
storage.root.set('bindings', new LiveMap())
lBindings = storage.root.get('bindings')
}
rLiveBindings.current = lBindings
let lAssets: LiveMap<string, TDAsset> = storage.root.get('assets')
let lAssets = storage.root.get('assets')
if (!lAssets) {
storage.root.set('assets', new LiveMap<string, TDAsset>())
storage.root.set('assets', new LiveMap())
lAssets = storage.root.get('assets')
}
rLiveAssets.current = lAssets
@ -168,11 +166,7 @@ export function useMultiplayerState(roomId: string) {
// document was a single LiveObject named 'doc'. If we find a doc,
// then we need to move the shapes and bindings over to the new structures
// and then mark the doc as migrated.
const doc = storage.root.get('doc') as LiveObject<{
uuid: string
document: TDDocument
migrated?: boolean
}>
const doc = storage.root.get('doc')
// No doc? No problem. This was likely a newer document
if (doc) {
@ -218,7 +212,7 @@ export function useMultiplayerState(roomId: string) {
stillAlive = false
unsubs.forEach((unsub) => unsub())
}
}, [app])
}, [app, room])
return {
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 */
import * as React from 'react'
import { Tldraw } from '@tldraw/tldraw'
import { createClient } from '@liveblocks/client'
import { LiveblocksProvider, RoomProvider } from '@liveblocks/react'
import { RoomProvider } from './liveblocks.config'
import { useMultiplayerState } from './useMultiplayerState'
const client = createClient({
publicApiKey: process.env.LIVEBLOCKS_PUBLIC_API_KEY || '',
throttle: 100,
})
const roomId = 'mp-test-8'
export function Multiplayer() {
return (
<LiveblocksProvider client={client}>
<RoomProvider id={roomId}>
<Editor roomId={roomId} />
</RoomProvider>
</LiveblocksProvider>
<RoomProvider id={roomId}>
<Editor roomId={roomId} />
</RoomProvider>
)
}

View file

@ -1,9 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as React from 'react'
import type { TldrawApp, TDUser, TDShape, TDBinding, TDDocument } from '@tldraw/tldraw'
import { useRedo, useUndo, useRoom, useUpdateMyPresence } from '@liveblocks/react'
import { LiveMap, LiveObject } from '@liveblocks/client'
import type { TldrawApp, TDUser, TDShape, TDBinding } from '@tldraw/tldraw'
import { LiveMap } from '@liveblocks/client'
import { useRedo, useUndo, useRoom, useUpdateMyPresence } from './liveblocks.config'
import type { Storage } from './liveblocks.config'
declare const window: Window & { app: TldrawApp }
@ -17,8 +19,8 @@ export function useMultiplayerState(roomId: string) {
const onRedo = useRedo()
const updateMyPresence = useUpdateMyPresence()
const rLiveShapes = React.useRef<LiveMap<string, TDShape>>()
const rLiveBindings = React.useRef<LiveMap<string, TDBinding>>()
const rLiveShapes = React.useRef<Storage['shapes'] | undefined>()
const rLiveBindings = React.useRef<Storage['bindings'] | undefined>()
// Callbacks --------------
@ -97,17 +99,14 @@ export function useMultiplayerState(roomId: string) {
// Handle events from the room
unsubs.push(
room.subscribe(
'event',
(e: { connectionId: number; event: { name: string; userId: string } }) => {
switch (e.event.name) {
case 'exit': {
app?.removeUser(e.event.userId)
break
}
room.subscribe('event', (e) => {
switch (e.event.name) {
case 'exit': {
app?.removeUser(e.event.userId)
break
}
}
)
})
)
// Send the exit event when the tab closes
@ -123,20 +122,20 @@ export function useMultiplayerState(roomId: string) {
// Setup the document's storage and subscriptions
async function setupDocument() {
const storage = await room.getStorage<any>()
const storage = await room.getStorage()
// 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) {
storage.root.set('shapes', new LiveMap<string, TDShape>())
storage.root.set('shapes', new LiveMap())
lShapes = storage.root.get('shapes')
}
rLiveShapes.current = lShapes
let lBindings: LiveMap<string, TDBinding> = storage.root.get('bindings')
let lBindings = storage.root.get('bindings')
if (!lBindings) {
storage.root.set('bindings', new LiveMap<string, TDBinding>())
storage.root.set('bindings', new LiveMap())
lBindings = storage.root.get('bindings')
}
rLiveBindings.current = lBindings
@ -150,11 +149,7 @@ export function useMultiplayerState(roomId: string) {
// document was a single LiveObject named 'doc'. If we find a doc,
// then we need to move the shapes and bindings over to the new structures
// and then mark the doc as migrated.
const doc = storage.root.get('doc') as LiveObject<{
uuid: string
document: TDDocument
migrated?: boolean
}>
const doc = storage.root.get('doc')
// No doc? No problem. This was likely a newer document
if (doc) {
@ -198,7 +193,7 @@ export function useMultiplayerState(roomId: string) {
stillAlive = false
unsubs.forEach((unsub) => unsub())
}
}, [app])
}, [app, room])
return {
onUndo,

View file

@ -11,7 +11,8 @@ interface UserProps {
export function User({ user }: UserProps) {
const rCursor = React.useRef<SVGSVGElement>(null)
useCursorAnimation(rCursor, user.point)
useCursorAnimation(rCursor, user.point, user.session)
return (
<svg
ref={rCursor}

View file

@ -13,7 +13,7 @@ type Animation = {
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 rPrevPoint = React.useRef(point)
const rQueue = React.useRef<Animation[]>([])
@ -22,9 +22,19 @@ export function useCursorAnimation(ref: any, point: number[]) {
const rTimeoutId = React.useRef<any>(0)
const [spline] = React.useState(() => new Spline())
// Animate an animation
const animateNext = React.useCallback(
(animation: Animation) => {
// When the point changes, add a new animation
React.useLayoutEffect(() => {
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()
function loop() {
const t = (performance.now() - start) / animation.duration
@ -50,19 +60,18 @@ export function useCursorAnimation(ref: any, point: number[]) {
}
}
loop()
},
[spline]
)
}
// When the point changes, add a new animation
React.useLayoutEffect(() => {
const now = performance.now()
if (rState.current === 'stopped') {
rTimestamp.current = now
rPrevPoint.current = point
spline.clear()
}
spline.addPoint(point)
const animation: Animation = {
distance: spline.totalLength,
curve: spline.points.length > 3,
@ -72,8 +81,9 @@ export function useCursorAnimation(ref: any, point: number[]) {
timeStamp: now,
duration: Math.min(now - rTimestamp.current, 300),
}
rPrevPoint.current = point
rTimestamp.current = now
switch (rState.current) {
case 'stopped': {
rPrevPoint.current = point
@ -86,12 +96,21 @@ export function useCursorAnimation(ref: any, point: number[]) {
break
}
case 'animating': {
rPrevPoint.current = point
rQueue.current.push(animation)
break
}
}
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 {
@ -126,6 +145,11 @@ class Spline {
q2 = 3 * ttt - 5 * tt + 2,
q3 = -3 * ttt + 4 * tt + t,
q4 = ttt - tt
if (!(points[p0] && points[p1] && points[p2] && points[p3])) {
return [0, 0]
}
return [
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),

View file

@ -52,6 +52,7 @@ export interface TLUser<T extends TLShape> {
color: string
point: number[]
selectedIds: string[]
session?: boolean
}
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,
onAssetDelete,
onAssetUpload,
onSessionStart,
onSessionEnd,
onExport,
}: TldrawProps) {
const [sId, setSId] = React.useState(id)
@ -164,6 +166,8 @@ export function Tldraw({
onAssetDelete,
onAssetCreate,
onAssetUpload,
onSessionStart,
onSessionEnd,
})
return app
})
@ -192,6 +196,8 @@ export function Tldraw({
onAssetCreate,
onAssetUpload,
onExport,
onSessionStart,
onSessionEnd,
})
setSId(id)
@ -262,6 +268,8 @@ export function Tldraw({
onAssetCreate,
onAssetUpload,
onExport,
onSessionStart,
onSessionEnd,
}
}, [
onMount,
@ -284,6 +292,8 @@ export function Tldraw({
onAssetCreate,
onAssetUpload,
onExport,
onSessionStart,
onSessionEnd,
])
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 { 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 { useTldrawApp } from '~hooks'
import { DMItem, DMContent, DMDivider, DMTriggerIcon } from '~components/Primitives/DropdownMenu'
import { SmallIcon } from '~components/Primitives/SmallIcon'
import { MultiplayerIcon } from '~components/Primitives/icons'
import { TDAssetType, TDSnapshot } from '~types'
import { TLDR } from '~state/TLDR'
import { Utils } from '@tldraw/core'
import { FormattedMessage } from 'react-intl'
import { MultiplayerIcon2 } from '~components/Primitives/icons/MultiplayerIcon2'
const roomSelector = (state: TDSnapshot) => state.room
@ -96,7 +96,7 @@ export const MultiplayerMenu = React.memo(function MultiplayerMenu() {
return (
<DropdownMenu.Root dir="ltr">
<DMTriggerIcon id="TD-MultiplayerMenuIcon" isActive={!!room}>
<PersonIcon />
<MultiplayerIcon2 />
</DMTriggerIcon>
<DMContent variant="menu" align="start" id="TD-MultiplayerMenu">
<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.
*/
protected persist = (id?: string): void | Promise<void> => {
protected persist = (patch: Patch<T>, id?: string): void | Promise<void> => {
if (this._status !== 'ready') return
if (this.onPersist) {
this.onPersist(this._state, id)
this.onPersist(this._state, patch, id)
}
if (this._idbId) {
@ -201,7 +201,7 @@ export class StateManager<T extends Record<string, any>> {
patchState = (patch: Patch<T>, id?: string): this => {
this.applyPatch(patch, id)
if (this.onPatch) {
this.onPatch(this._state, id)
this.onPatch(this._state, patch, id)
}
return this
}
@ -240,8 +240,8 @@ export class StateManager<T extends Record<string, any>> {
this.stack.push({ ...command, id })
this.pointer = this.stack.length - 1
this.applyPatch(command.after, id)
if (this.onCommand) this.onCommand(this._state, id)
this.persist(id)
if (this.onCommand) this.onCommand(this._state, command, id)
this.persist(command.after, id)
return this
}
@ -264,17 +264,17 @@ export class StateManager<T extends Record<string, any>> {
/**
* 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.
*/
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.
*/
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.
@ -311,7 +311,7 @@ export class StateManager<T extends Record<string, any>> {
this._state = this.initialState
this.store.setState(this._state, true)
this.resetHistory()
this.persist('reset')
this.persist({}, 'reset')
if (this.onStateDidChange) {
this.onStateDidChange(this._state, 'reset')
}
@ -357,7 +357,7 @@ export class StateManager<T extends Record<string, any>> {
const command = this.stack[this.pointer]
this.pointer--
this.applyPatch(command.before, `undo`)
this.persist('undo')
this.persist(command.before, 'undo')
}
if (this.onUndo) this.onUndo(this._state)
return this
@ -372,7 +372,7 @@ export class StateManager<T extends Record<string, any>> {
this.pointer++
const command = this.stack[this.pointer]
this.applyPatch(command.after, 'redo')
this.persist('undo')
this.persist(command.after, 'undo')
}
if (this.onRedo) this.onRedo(this._state)
return this

View file

@ -41,6 +41,7 @@ import {
TDExport,
ArrowShape,
TDExportType,
TldrawPatch,
} from '~types'
import {
migrate,
@ -125,11 +126,11 @@ export interface TDCallbacks {
/**
* (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.
*/
onCommand?: (app: TldrawApp, reason?: string) => void
onCommand?: (app: TldrawApp, command: TldrawCommand, reason?: string) => void
/**
* (optional) A callback to run when the state is persisted.
*/
@ -149,7 +150,8 @@ export interface TDCallbacks {
app: TldrawApp,
shapes: Record<string, TDShape | undefined>,
bindings: Record<string, TDBinding | undefined>,
assets: Record<string, TDAsset | undefined>
assets: Record<string, TDAsset | undefined>,
addToHistory: boolean
) => void
/**
* (optional) A callback to run when the user creates a new project.
@ -510,14 +512,58 @@ export class TldrawApp extends StateManager<TDSnapshot> {
return next
}
onPatch = (app: TDSnapshot, id?: string) => {
this.callbacks.onPatch?.(this, id)
private broadcastPatch = (patch: TldrawPatch, addToHistory: boolean) => {
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.isDirty = true
this.callbacks.onCommand?.(this, id)
this.callbacks.onCommand?.(this, command, id)
}
onReplace = () => {
@ -535,12 +581,11 @@ export class TldrawApp extends StateManager<TDSnapshot> {
this.callbacks.onRedo?.(this)
}
onPersist = () => {
onPersist = (state: TDSnapshot, patch: TldrawPatch) => {
// If we are part of a room, send our changes to the server
if (this.callbacks.onChangePage) {
this.broadcastPageChanges()
}
this.callbacks.onPersist?.(this)
this.broadcastPatch(patch, true)
}
private prevSelectedIds = this.selectedIds
@ -550,13 +595,14 @@ export class TldrawApp extends StateManager<TDSnapshot> {
* @param state
* @param id
*/
protected onStateDidChange = (_app: TDSnapshot, id?: string): void => {
protected onStateDidChange = (_state: TDSnapshot, id?: string): void => {
this.callbacks.onChange?.(this, id)
if (this.room && this.selectedIds !== this.prevSelectedIds) {
this.callbacks.onChangePresence?.(this, {
...this.room.users[this.room.userId],
selectedIds: this.selectedIds,
session: !!this.session,
})
this.prevSelectedIds = this.selectedIds
}
@ -569,69 +615,69 @@ export class TldrawApp extends StateManager<TDSnapshot> {
private prevBindings = this.page.bindings
private prevAssets = this.document.assets
private broadcastPageChanges = () => {
const visited = new Set<string>()
// private broadcastPageChanges = () => {
// const visited = new Set<string>()
const changedShapes: Record<string, TDShape | undefined> = {}
const changedBindings: Record<string, TDBinding | undefined> = {}
const changedAssets: Record<string, TDAsset | undefined> = {}
// const changedShapes: Record<string, TDShape | undefined> = {}
// const changedBindings: Record<string, TDBinding | undefined> = {}
// const changedAssets: Record<string, TDAsset | undefined> = {}
this.shapes.forEach((shape) => {
visited.add(shape.id)
if (this.prevShapes[shape.id] !== shape) {
changedShapes[shape.id] = shape
}
})
// this.shapes.forEach((shape) => {
// visited.add(shape.id)
// if (this.prevShapes[shape.id] !== shape) {
// changedShapes[shape.id] = shape
// }
// })
Object.keys(this.prevShapes)
.filter((id) => !visited.has(id))
.forEach((id) => {
// After visiting all the current shapes, if we haven't visited a
// previously present shape, then it was deleted
changedShapes[id] = undefined
})
// Object.keys(this.prevShapes)
// .filter((id) => !visited.has(id))
// .forEach((id) => {
// // After visiting all the current shapes, if we haven't visited a
// // previously present shape, then it was deleted
// changedShapes[id] = undefined
// })
this.bindings.forEach((binding) => {
visited.add(binding.id)
if (this.prevBindings[binding.id] !== binding) {
changedBindings[binding.id] = binding
}
})
// this.bindings.forEach((binding) => {
// visited.add(binding.id)
// if (this.prevBindings[binding.id] !== binding) {
// changedBindings[binding.id] = binding
// }
// })
Object.keys(this.prevBindings)
.filter((id) => !visited.has(id))
.forEach((id) => {
// After visiting all the current bindings, if we haven't visited a
// previously present shape, then it was deleted
changedBindings[id] = undefined
})
// Object.keys(this.prevBindings)
// .filter((id) => !visited.has(id))
// .forEach((id) => {
// // After visiting all the current bindings, if we haven't visited a
// // previously present shape, then it was deleted
// changedBindings[id] = undefined
// })
this.assets.forEach((asset) => {
visited.add(asset.id)
if (this.prevAssets[asset.id] !== asset) {
changedAssets[asset.id] = asset
}
})
// this.assets.forEach((asset) => {
// visited.add(asset.id)
// if (this.prevAssets[asset.id] !== asset) {
// changedAssets[asset.id] = asset
// }
// })
Object.keys(this.prevAssets)
.filter((id) => !visited.has(id))
.forEach((id) => {
changedAssets[id] = undefined
})
// Object.keys(this.prevAssets)
// .filter((id) => !visited.has(id))
// .forEach((id) => {
// changedAssets[id] = undefined
// })
// Only trigger update if shapes or bindings have changed
if (
Object.keys(changedBindings).length > 0 ||
Object.keys(changedShapes).length > 0 ||
Object.keys(changedAssets).length > 0
) {
this.justSent = true
this.callbacks.onChangePage?.(this, changedShapes, changedBindings, changedAssets)
this.prevShapes = this.page.shapes
this.prevBindings = this.page.bindings
this.prevAssets = this.document.assets
}
}
// // Only trigger update if shapes or bindings have changed
// if (
// Object.keys(changedBindings).length > 0 ||
// Object.keys(changedShapes).length > 0 ||
// Object.keys(changedAssets).length > 0
// ) {
// this.justSent = true
// this.callbacks.onChangePage?.(this, changedShapes, changedBindings, changedAssets,)
// this.prevShapes = this.page.shapes
// this.prevBindings = this.page.bindings
// this.prevAssets = this.document.assets
// }
// }
getReservedContent = (coreReservedIds: string[], pageId = this.currentPageId) => {
const { bindings } = this.document.pages[pageId]
@ -928,10 +974,22 @@ export class TldrawApp extends StateManager<TDSnapshot> {
* Set or clear the editing id
* @param id [string]
*/
setEditingId = (id?: string) => {
setEditingId = (id?: string, isCreating = false) => {
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.patchState(
{
document: {
@ -978,15 +1036,15 @@ export class TldrawApp extends StateManager<TDSnapshot> {
): this => {
if (this.session) return this
this.patchState(
{
settings: {
[name]: typeof value === 'function' ? value(this.settings[name] as V) : value,
},
const patch = {
settings: {
[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
}
@ -995,15 +1053,15 @@ export class TldrawApp extends StateManager<TDSnapshot> {
*/
toggleFocusMode = (): this => {
if (this.session) return this
this.patchState(
{
settings: {
isFocusMode: !this.settings.isFocusMode,
},
const patch = {
settings: {
isFocusMode: !this.settings.isFocusMode,
},
`settings:toggled_focus_mode`
)
this.persist()
}
this.patchState(patch, `settings:toggled_focus_mode`)
this.persist(patch)
return this
}
@ -1012,15 +1070,13 @@ export class TldrawApp extends StateManager<TDSnapshot> {
*/
togglePenMode = (): this => {
if (this.session) return this
this.patchState(
{
settings: {
isPenMode: !this.settings.isPenMode,
},
const patch = {
settings: {
isPenMode: !this.settings.isPenMode,
},
`settings:toggled_pen_mode`
)
this.persist()
}
this.patchState(patch, `settings:toggled_pen_mode`)
this.persist(patch)
return this
}
@ -1029,11 +1085,9 @@ export class TldrawApp extends StateManager<TDSnapshot> {
*/
toggleDarkMode = (): this => {
if (this.session) return this
this.patchState(
{ settings: { isDarkMode: !this.settings.isDarkMode } },
`settings:toggled_dark_mode`
)
this.persist()
const patch = { settings: { isDarkMode: !this.settings.isDarkMode } }
this.patchState(patch, `settings:toggled_dark_mode`)
this.persist(patch)
return this
}
@ -1042,11 +1096,9 @@ export class TldrawApp extends StateManager<TDSnapshot> {
*/
toggleZoomSnap = () => {
if (this.session) return this
this.patchState(
{ settings: { isZoomSnap: !this.settings.isZoomSnap } },
`settings:toggled_zoom_snap`
)
this.persist()
const patch = { settings: { isZoomSnap: !this.settings.isZoomSnap } }
this.patchState(patch, `settings:toggled_zoom_snap`)
this.persist(patch)
return this
}
@ -1055,11 +1107,9 @@ export class TldrawApp extends StateManager<TDSnapshot> {
*/
toggleDebugMode = () => {
if (this.session) return this
this.patchState(
{ settings: { isDebugMode: !this.settings.isDebugMode } },
`settings:toggled_debug`
)
this.persist()
const patch = { settings: { isDebugMode: !this.settings.isDebugMode } }
this.patchState(patch, `settings:toggled_debug`)
this.persist(patch)
return this
}
@ -1067,8 +1117,9 @@ export class TldrawApp extends StateManager<TDSnapshot> {
* Toggles the state if menu is opened
*/
setMenuOpen = (isOpen: boolean): this => {
this.patchState({ appState: { isMenuOpen: isOpen } }, 'ui:toggled_menu_opened')
this.persist()
const patch = { appState: { isMenuOpen: isOpen } }
this.patchState(patch, 'ui:toggled_menu_opened')
this.persist(patch)
return this
}
@ -1076,8 +1127,9 @@ export class TldrawApp extends StateManager<TDSnapshot> {
* Toggles the state if something is loading
*/
setIsLoading = (isLoading: boolean): this => {
this.patchState({ appState: { isLoading } }, 'ui:toggled_is_loading')
this.persist()
const patch = { appState: { isLoading } }
this.patchState(patch, 'ui:toggled_is_loading')
this.persist(patch)
return this
}
@ -1103,8 +1155,9 @@ export class TldrawApp extends StateManager<TDSnapshot> {
*/
toggleGrid = (): this => {
if (this.session) return this
this.patchState({ settings: { showGrid: !this.settings.showGrid } }, 'settings:toggled_grid')
this.persist()
const patch = { settings: { showGrid: !this.settings.showGrid } }
this.patchState(patch, 'settings:toggled_grid')
this.persist(patch)
return this
}
@ -1175,7 +1228,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
this.resetHistory()
.clearSelectHistory()
.loadDocument(migrate(TldrawApp.defaultDocument, TldrawApp.version))
.persist()
.persist({})
return this
}
@ -1420,7 +1473,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
this.fileSystemHandle
)
this.fileSystemHandle = fileHandle
this.persist()
this.persist({})
this.isDirty = false
} catch (e: any) {
// Likely cancelled
@ -1436,7 +1489,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
try {
const fileHandle = await saveToFileSystem(this.document, null)
this.fileSystemHandle = fileHandle
this.persist()
this.persist({})
this.isDirty = false
} catch (e: any) {
// Likely cancelled
@ -1462,11 +1515,11 @@ export class TldrawApp extends StateManager<TDSnapshot> {
this.loadDocument(document)
this.fileSystemHandle = fileHandle
this.zoomToFit()
this.persist()
this.persist({})
} catch (e) {
console.error(e)
} finally {
this.persist()
this.persist({})
}
}
@ -1482,7 +1535,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
} catch (e) {
console.error(e)
} finally {
this.persist()
this.persist({})
}
}
@ -2700,28 +2753,28 @@ export class TldrawApp extends StateManager<TDSnapshot> {
/**
* Start a new session.
* @param session The new session
* @param type The session type
* @param args arguments of the session's start method.
*/
startSession = <T extends SessionType>(type: T, ...args: SessionArgsOfType<T>): this => {
if (this.readOnly && type !== SessionType.Brush) return this
if (this.session) {
TLDR.warn(`Already in a session! (${this.session.constructor.name})`)
this.cancelSession()
}
const Session = getSession(type)
// @ts-ignore
const Session = getSession(type) as any
this.session = new Session(this, ...args)
const result = this.session.start()
const result = this.session!.start()
if (result) {
this.patchState(result, `session:start_${this.session.constructor.name}`)
this.callbacks.onSessionStart?.(this, this.session.constructor.name)
this.patchState(result, `session:start_${this.session!.constructor.name}`)
}
this.callbacks.onSessionStart?.(this, this.session!.constructor.name)
return this
// return this.setStatus(this.session.status)
}
@ -2754,9 +2807,12 @@ export class TldrawApp extends StateManager<TDSnapshot> {
if (result) {
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
}
@ -2768,6 +2824,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
const { session } = this
if (!session) return this
this.session = undefined
const result = session.complete()
@ -2791,9 +2848,6 @@ export class TldrawApp extends StateManager<TDSnapshot> {
},
`session:complete:${session.constructor.name}`
)
this.callbacks.onSessionEnd?.(this, session.constructor.name)
return this
} else if ('after' in result) {
// Session ended with a command
@ -2870,6 +2924,8 @@ export class TldrawApp extends StateManager<TDSnapshot> {
)
}
this.callbacks.onSessionEnd?.(this, session.constructor.name)
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 {
shapes,
appState: { currentPageId, currentStyle },
@ -2935,8 +2991,14 @@ export class TldrawApp extends StateManager<TDSnapshot> {
const bounds = Text.getBounds(newShape)
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
}
@ -3668,6 +3730,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
this.callbacks.onChangePresence?.(this, {
...users[userId],
point: this.getPagePoint(info.point),
session: !!this.session,
})
}
}
@ -3894,7 +3957,11 @@ export class TldrawApp extends StateManager<TDSnapshot> {
}
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 = () => {

View file

@ -227,7 +227,7 @@ TldrawTestApp {
"align": [Function],
"altKey": false,
"applyPatch": [Function],
"broadcastPageChanges": [Function],
"broadcastPatch": [Function],
"callbacks": Object {},
"cancel": [Function],
"cancelSession": [Function],
@ -293,6 +293,7 @@ TldrawTestApp {
"deletePage": [Function],
"distribute": [Function],
"doubleClickBoundHandle": [Function],
"doubleClickCanvas": [Function],
"doubleClickShape": [Function],
"duplicate": [Function],
"duplicatePage": [Function],

View file

@ -92,6 +92,17 @@ export class BrushSession extends BaseSession {
const afterSelectedIds = didChange ? Array.from(selectedIds.values()) : currentSelectedIds
if (!didChange)
return {
document: {
pageStates: {
[this.app.currentPageId]: {
brush,
},
},
},
}
return {
appState: {
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 { EraseSession } from './EraseSession'
import { GridSession } from './GridSession'
import { EditSession } from './EditSession'
export type TldrawSession =
| ArrowSession
@ -21,6 +22,7 @@ export type TldrawSession =
| TranslateSession
| EraseSession
| GridSession
| EditSession
export interface SessionsMap {
[SessionType.Arrow]: typeof ArrowSession
@ -33,6 +35,7 @@ export interface SessionsMap {
[SessionType.TransformSingle]: typeof TransformSingleSession
[SessionType.Translate]: typeof TranslateSession
[SessionType.Grid]: typeof GridSession
[SessionType.Edit]: typeof EditSession
}
export type SessionOfType<K extends SessionType> = SessionsMap[K]
@ -52,6 +55,7 @@ export const sessions: { [K in SessionType]: SessionsMap[K] } = {
[SessionType.TransformSingle]: TransformSingleSession,
[SessionType.Translate]: TranslateSession,
[SessionType.Grid]: GridSession,
[SessionType.Edit]: EditSession,
}
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 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(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
let delta = [0, 0]
@ -83,6 +116,9 @@ export class TextUtil extends TDShapeUtil<T, E> {
break
}
}
rEditedText.current = newText
onShapeChange?.({
...shape,
id: shape.id,
@ -102,6 +138,13 @@ export class TextUtil extends TDShapeUtil<T, E> {
return
}
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
e.stopPropagation()
rInput.current!.blur()
return
}
if (!(e.key === 'Meta' || e.metaKey)) {
e.stopPropagation()
} else if (e.key === 'z' && e.metaKey) {

View file

@ -50,7 +50,7 @@ export class SelectTool extends BaseTool<Status> {
/* --------------------- Methods -------------------- */
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) {
@ -59,7 +59,7 @@ export class SelectTool extends BaseTool<Status> {
private pushSelect(id: string) {
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() {
@ -77,7 +77,7 @@ export class SelectTool extends BaseTool<Status> {
clonePaint = (point: number[]) => {
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)
@ -92,7 +92,7 @@ export class SelectTool extends BaseTool<Status> {
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)
)
@ -171,12 +171,12 @@ export class SelectTool extends BaseTool<Status> {
/* ----------------- Event Handlers ----------------- */
onCancel = () => {
if (this.app.pageState.editingId) {
this.app.setEditingId()
if (this.app.session) {
this.app.cancelSession()
} else {
this.selectNone()
}
this.app.cancelSession()
this.setStatus(Status.Idle)
}
@ -265,7 +265,7 @@ export class SelectTool extends BaseTool<Status> {
} else {
// Stat a transform session
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)
)
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.pointedId) {
this.app.completeSession()
@ -415,11 +415,18 @@ export class SelectTool extends BaseTool<Status> {
}
// Complete the current session, if any; and reset the status
this.app.completeSession()
this.setStatus(Status.Idle)
this.pointedBoundsHandle = undefined
this.pointedHandleId = 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
@ -529,7 +536,7 @@ export class SelectTool extends BaseTool<Status> {
}
}
onDoubleClickShape: TLPointerEventHandler = info => {
onDoubleClickShape: TLPointerEventHandler = (info) => {
if (this.app.readOnly) return
const shape = this.app.getShape(info.target)
@ -556,17 +563,17 @@ export class SelectTool extends BaseTool<Status> {
this.app.select(info.target)
}
onRightPointShape: TLPointerEventHandler = info => {
onRightPointShape: TLPointerEventHandler = (info) => {
if (!this.app.isSelected(info.target)) {
this.app.select(info.target)
}
}
onHoverShape: TLPointerEventHandler = info => {
onHoverShape: TLPointerEventHandler = (info) => {
this.app.setHoveredId(info.target)
}
onUnhoverShape: TLPointerEventHandler = info => {
onUnhoverShape: TLPointerEventHandler = (info) => {
const { currentPageId: oldCurrentPageId } = this.app
// Wait a frame; and if we haven't changed the hovered id,
@ -583,7 +590,7 @@ export class SelectTool extends BaseTool<Status> {
/* --------------------- Bounds --------------------- */
onPointBounds: TLBoundsEventHandler = info => {
onPointBounds: TLBoundsEventHandler = (info) => {
if (info.metaKey) {
if (!info.shiftKey) {
this.selectNone()
@ -612,12 +619,12 @@ export class SelectTool extends BaseTool<Status> {
/* ----------------- Bounds Handles ----------------- */
onPointBoundsHandle: TLBoundsHandleEventHandler = info => {
onPointBoundsHandle: TLBoundsHandleEventHandler = (info) => {
this.pointedBoundsHandle = info.target
this.setStatus(Status.PointingBoundsHandle)
}
onDoubleClickBoundsHandle: TLBoundsHandleEventHandler = info => {
onDoubleClickBoundsHandle: TLBoundsHandleEventHandler = (info) => {
switch (info.target) {
case 'center':
case 'left':
@ -650,12 +657,12 @@ export class SelectTool extends BaseTool<Status> {
/* --------------------- Handles -------------------- */
onPointHandle: TLPointerEventHandler = info => {
onPointHandle: TLPointerEventHandler = (info) => {
this.pointedHandleId = info.target as 'start' | 'end'
this.setStatus(Status.PointingHandle)
}
onDoubleClickHandle: TLPointerEventHandler = info => {
onDoubleClickHandle: TLPointerEventHandler = (info) => {
if (info.target === 'bend') {
const { selectedIds } = this.app
if (selectedIds.length !== 1) return
@ -678,7 +685,7 @@ export class SelectTool extends BaseTool<Status> {
/* ---------------------- Misc ---------------------- */
onShapeClone: TLShapeCloneHandler = info => {
onShapeClone: TLShapeCloneHandler = (info) => {
const selectedShapeId = this.app.selectedIds[0]
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])
this.app.createShapes(newShape)
this.app.patchCreate([newShape])
this.app.startSession(SessionType.Translate)

View file

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

View file

@ -44,6 +44,19 @@ export class TldrawTestApp extends TldrawApp {
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[]) => {
this.onPointerDown(
inputs.pointerDown(this.getPoint(options), 'canvas'),

View file

@ -178,6 +178,7 @@ export enum TDUserStatus {
export interface TDUser extends TLUser<TDShape> {
activeShapes: TDShape[]
status: TDUserStatus
session?: boolean
}
export type Theme = 'dark' | 'light'
@ -193,6 +194,7 @@ export enum SessionType {
Rotate = 'rotate',
Handle = 'handle',
Grid = 'grid',
Edit = 'edit',
}
export enum TDStatus {

View file

@ -1863,25 +1863,15 @@
"@jridgewell/resolve-uri" "^3.0.3"
"@jridgewell/sourcemap-codec" "^1.4.10"
"@liveblocks/client@^0.14.0":
version "0.14.0"
resolved "https://registry.yarnpkg.com/@liveblocks/client/-/client-0.14.0.tgz#2a5f7bd243d3aea7b95cf62737e49dc13df1b69b"
integrity sha512-I1nykCqYSpuBQhP1kZplYqL6L0+C1JocW01UKgPz+tthOOGdTdsNBHPcMigxou4vsOQutUuEJUcaDsd1or4A+Q==
"@liveblocks/client@^0.17.0-beta2":
version "0.17.0-beta2"
resolved "https://registry.yarnpkg.com/@liveblocks/client/-/client-0.17.0-beta2.tgz#0cd14dd29350a29765bfa41c5d7a2185010ab791"
integrity sha512-V8LOv6BIDQvLl2LLcdDWIK6Ow3hMSpmIYk5T+NbNC34MM04bdhBU3C4EHg4qRnTPtOxm4NylRiVbNDwfwmMNjw==
"@liveblocks/client@^0.16.17":
version "0.16.17"
resolved "https://registry.yarnpkg.com/@liveblocks/client/-/client-0.16.17.tgz#38f8392d7baaf20b34237d06fe3cb53586cea8f0"
integrity sha512-mWX/EGQNoWwzkEdUfdt82w9OMA5kKV3BraBt2YhZO4Zn04mfNmcOkr7V4S+EmJ4LyQ5QVNKYvA4gJQU1ahn5mQ==
"@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==
"@liveblocks/react@^0.17.0-beta2":
version "0.17.0-beta2"
resolved "https://registry.yarnpkg.com/@liveblocks/react/-/react-0.17.0-beta2.tgz#e148f9c1eb03a40db350ca2ccef180248e6d3afc"
integrity sha512-g7XSias4M2nl6eao8JxS5ynyjTvkV4FF8PavvAd9aO5sLz++1qrPW0XCXq2jle2sOyM9act/nMX35FedXvXPFA==
"@malept/cross-spawn-promise@^1.1.0":
version "1.1.1"
@ -5411,6 +5401,14 @@ enhanced-resolve@^5.0.0:
graceful-fs "^4.2.4"
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:
version "5.9.0"
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"
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:
version "2.0.1"
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"
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:
version "12.1.6"
resolved "https://registry.yarnpkg.com/next/-/next-12.1.6.tgz#eb205e64af1998651f96f9df44556d47d8bbc533"
@ -9553,6 +9564,13 @@ react-hotkeys-hook@^3.4.4:
dependencies:
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:
version "6.0.3"
resolved "https://registry.yarnpkg.com/react-intl/-/react-intl-6.0.3.tgz#eb5857f2fd525c83255bf6c8339562a7fea9f970"