[chore] refactor user preferences (#1435)

- Remove TLUser, TLUserPresence
- Add first-class support for user preferences that persists across
rooms and tabs

### Change Type

<!-- 💡 Indicate the type of change your pull request is. -->
<!-- 🤷‍♀️ If you're not sure, don't select anything -->
<!-- ✂️ Feel free to delete unselected options -->

<!-- To select one, put an x in the box: [x] -->

- [ ] `patch` — Bug Fix
- [ ] `minor` — New Feature
- [x] `major` — Breaking Change
- [ ] `dependencies` — Dependency Update (publishes a `patch` release,
for devDependencies use `internal`)
- [ ] `documentation` — Changes to the documentation only (will not
publish a new version)
- [ ] `tests` — Changes to any testing-related code only (will not
publish a new version)
- [ ] `internal` — Any other changes that don't affect the published
package (will not publish a new version)

### Test Plan

1. Add a step-by-step description of how to test your PR here.
2.

- [ ] Unit Tests
- [ ] Webdriver tests

### Release Notes

- Add a brief release note for your PR here.
This commit is contained in:
David Sheldrick 2023-05-25 10:54:29 +01:00 committed by GitHub
parent 53b289310d
commit 356a0d1e73
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
60 changed files with 710 additions and 955 deletions

View file

@ -1,64 +0,0 @@
# name: Webdriver nightly (browserstack)
# on:
# workflow_dispatch:
# schedule:
# - cron: '0 2 * * *' # run at 2 AM UTC
# jobs:
# test:
# name: 'nightly'
# runs-on: ${{ matrix.os }}
# strategy:
# fail-fast: false
# matrix:
# os: [ubuntu-latest-16-cores-open]
# node-version: [16]
# container:
# image: node:${{ matrix.node-version }}
# options: --network-alias testhost
# volumes:
# - /home/runner/work/_temp/e2e:/home/runner/work/_temp/e2e
# steps:
# # start browserstack
# - name: 'BrowserStack Env Setup' # Invokes the setup-env action
# uses: browserstack/github-actions/setup-env@master
# with:
# username: jamieblair_YXsTBS
# access-key: BUcyZn9PF4iwKgayXinm
# - name: 'BrowserStack Local Tunnel Setup' # Invokes the setup-local action
# uses: browserstack/github-actions/setup-local@master
# with:
# local-testing: start
# local-identifier: random
# - name: Check out code
# uses: actions/checkout@v3
# with:
# fetch-depth: 0
# submodules: true
# - name: Setup Node.js environment
# uses: actions/setup-node@v3
# with:
# node-version: 18
# cache: 'yarn'
# cache-dependency-path: 'public-yarn.lock'
# - name: Enable corepack
# run: corepack enable
# - name: Install dependencies
# run: yarn
# - run: yarn e2e test:ci nightly
# env:
# CI: true
# DOWNLOADS_DIR: '/home/runner/work/_temp/e2e/'
# TEST_URL: 'https://testhost:5421'
# WB_BUILD_NAME: 'nightly'
# BROWSERSTACK_USER: ${{ secrets.BROWSERSTACK_USER }}
# BROWSERSTACK_KEY: ${{ secrets.BROWSERSTACK_KEY }}

View file

@ -1,117 +0,0 @@
# name: Webdriver on demand (browserstack)
# on:
# workflow_dispatch:
# inputs:
# WD_BROWSER_CHROME:
# description: 'Chrome'
# required: false
# default: true
# type: boolean
# WD_BROWSER_FIREFOX:
# description: 'Firefox'
# required: false
# default: true
# type: boolean
# WD_BROWSER_EDGE:
# description: 'Edge'
# required: false
# default: true
# type: boolean
# WD_BROWSER_SAFARI:
# description: 'Safari'
# required: false
# default: true
# type: boolean
# WD_BROWSER_SAMSUNG:
# description: 'Samsung'
# required: false
# default: true
# type: boolean
# WD_OS_WINDOWS:
# description: 'Windows'
# required: false
# default: true
# type: boolean
# WD_OS_MACOS:
# description: 'MacOS'
# required: false
# default: true
# type: boolean
# WD_OS_ANDROID:
# description: 'Android'
# required: false
# default: true
# type: boolean
# WD_OS_IOS:
# description: 'iOS'
# required: false
# default: true
# type: boolean
# jobs:
# test:
# name: 'on-demand'
# runs-on: ${{ matrix.os }}
# strategy:
# fail-fast: false
# matrix:
# os: [ubuntu-latest-16-cores-open]
# node-version: [16]
# container:
# image: node:${{ matrix.node-version }}
# options: --network-alias testhost
# volumes:
# - /home/runner/work/_temp/e2e:/home/runner/work/_temp/e2e
# steps:
# # start browserstack
# - name: 'BrowserStack Env Setup' # Invokes the setup-env action
# uses: browserstack/github-actions/setup-env@master
# with:
# username: jamieblair_YXsTBS
# access-key: BUcyZn9PF4iwKgayXinm
# - name: 'BrowserStack Local Tunnel Setup' # Invokes the setup-local action
# uses: browserstack/github-actions/setup-local@master
# with:
# local-testing: start
# local-identifier: random
# - name: Check out code
# uses: actions/checkout@v3
# with:
# fetch-depth: 0
# submodules: true
# - name: Setup Node.js environment
# uses: actions/setup-node@v3
# with:
# node-version: 18
# cache: 'yarn'
# cache-dependency-path: 'public-yarn.lock'
# - name: Enable corepack
# run: corepack enable
# - name: Install dependencies
# run: yarn
# - run: yarn e2e test:ci nightly
# env:
# CI: true
# DOWNLOADS_DIR: '/home/runner/work/_temp/e2e/'
# TEST_URL: 'https://testhost:5421'
# WD_BROWSER_CHROME: ${{ inputs.WD_BROWSER_CHROME }}
# WD_BROWSER_FIREFOX: ${{ inputs.WD_BROWSER_FIREFOX }}
# WD_BROWSER_EDGE: ${{ inputs.WD_BROWSER_EDGE }}
# WD_BROWSER_SAFARI: ${{ inputs.WD_BROWSER_SAFARI }}
# WD_BROWSER_SAMSUNG: ${{ inputs.WD_BROWSER_SAMSUNG }}
# WD_OS_WINDOWS: ${{ inputs.WD_OS_WINDOWS }}
# WD_OS_MACOS: ${{ inputs.WD_OS_MACOS }}
# WD_OS_ANDROID: ${{ inputs.WD_OS_ANDROID }}
# WD_OS_IOS: ${{ inputs.WD_OS_IOS }}
# WB_BUILD_NAME: 'ondemand'
# BROWSERSTACK_USER: ${{ secrets.BROWSERSTACK_USER }}
# BROWSERSTACK_KEY: ${{ secrets.BROWSERSTACK_KEY }}

View file

@ -1,10 +1,10 @@
import { Tldraw, TLInstance, TLInstancePageState, TLUser, TLUserPresence } from '@tldraw/tldraw' import { Tldraw, TLInstance, TLInstancePresence } from '@tldraw/tldraw'
import '@tldraw/tldraw/editor.css' import '@tldraw/tldraw/editor.css'
import '@tldraw/tldraw/ui.css' import '@tldraw/tldraw/ui.css'
import { useRef } from 'react' import { useRef } from 'react'
const SHOW_MOVING_CURSOR = false const SHOW_MOVING_CURSOR = true
const CURSOR_SPEED = 0.1 const CURSOR_SPEED = 0.5
const CIRCLE_RADIUS = 100 const CIRCLE_RADIUS = 100
const UPDATE_FPS = 60 const UPDATE_FPS = 60
@ -15,39 +15,19 @@ export default function UserPresenceExample() {
<Tldraw <Tldraw
persistenceKey="user-presence-example" persistenceKey="user-presence-example"
onMount={(app) => { onMount={(app) => {
// There are several records related to user presence that must be // For every connected peer you should put a TLInstancePresence record in the
// included for each user. These are created automatically by each // store with their cursor position etc.
// editor or editor instance, so in a "regular" multiplayer sharing
// all records will include all of these records. In this example,
// we're having to create these ourselves.
const userId = TLUser.createCustomId('user-1') const peerPresence = TLInstancePresence.create({
id: TLInstancePresence.createCustomId('peer-1-presence'),
const user = TLUser.create({ currentPageId: app.currentPageId,
id: userId, userId: 'peer-1',
name: 'User 1', instanceId: TLInstance.createCustomId('peer-1-editor-instance'),
userName: 'Peer 1',
cursor: { x: 0, y: 0, type: 'default', rotation: 0 },
}) })
const userPresence = TLUserPresence.create({ app.store.put([peerPresence])
...app.userPresence,
id: TLUserPresence.createCustomId('user-1'),
cursor: { x: 0, y: 0 },
userId,
})
const instance = TLInstance.create({
...app.instanceState,
id: TLInstance.createCustomId('user-1'),
userId,
})
const instancePageState = TLInstancePageState.create({
...app.pageState,
id: TLInstancePageState.createCustomId('user-1'),
instanceId: TLInstance.createCustomId('instance-1'),
})
app.store.put([user, instance, userPresence, instancePageState])
// Make the fake user's cursor rotate in a circle // Make the fake user's cursor rotate in a circle
if (rTimeout.current) { if (rTimeout.current) {
@ -62,24 +42,21 @@ export default function UserPresenceExample() {
// rotate in a circle // rotate in a circle
app.store.put([ app.store.put([
{ {
...userPresence, ...peerPresence,
cursor: { cursor: {
x: Math.cos(t * Math.PI * 2) * CIRCLE_RADIUS, ...peerPresence.cursor,
y: Math.sin(t * Math.PI * 2) * CIRCLE_RADIUS, x: 150 + Math.cos(t * Math.PI * 2) * CIRCLE_RADIUS,
y: 150 + Math.sin(t * Math.PI * 2) * CIRCLE_RADIUS,
}, },
lastActivityTimestamp: now, lastActivityTimestamp: now,
}, },
]) ])
}, 1000 / UPDATE_FPS) }, 1000 / UPDATE_FPS)
} else { } else {
app.store.put([ app.store.put([{ ...peerPresence, lastActivityTimestamp: Date.now() }])
{ ...userPresence, cursor: { x: 0, y: 0 }, lastActivityTimestamp: Date.now() },
])
rTimeout.current = setInterval(() => { rTimeout.current = setInterval(() => {
app.store.put([ app.store.put([{ ...peerPresence, lastActivityTimestamp: Date.now() }])
{ ...userPresence, cursor: { x: 0, y: 0 }, lastActivityTimestamp: Date.now() },
])
}, 1000) }, 1000)
} }
}} }}

View file

@ -1,7 +1,6 @@
import { import {
Canvas, Canvas,
ContextMenu, ContextMenu,
getUserData,
TldrawEditor, TldrawEditor,
TldrawEditorConfig, TldrawEditorConfig,
TldrawUi, TldrawUi,
@ -13,28 +12,19 @@ import '@tldraw/tldraw/ui.css'
const instanceId = TLInstance.createCustomId('example') const instanceId = TLInstance.createCustomId('example')
// for custom config, see 3-custom-config
const config = new TldrawEditorConfig() const config = new TldrawEditorConfig()
export default function Example() { export default function Example() {
const userData = getUserData()
const syncedStore = useLocalSyncClient({ const syncedStore = useLocalSyncClient({
config, config,
instanceId, instanceId,
userId: userData.id,
universalPersistenceKey: 'exploded-example', universalPersistenceKey: 'exploded-example',
// config: myConfig // for custom config, see 3-custom-config
}) })
return ( return (
<div className="tldraw__editor"> <div className="tldraw__editor">
<TldrawEditor <TldrawEditor instanceId={instanceId} store={syncedStore} config={config} autoFocus>
instanceId={instanceId}
userId={userData.id}
store={syncedStore}
config={config}
autoFocus
>
<TldrawUi> <TldrawUi>
<ContextMenu> <ContextMenu>
<Canvas /> <Canvas />

View file

@ -1,4 +1,4 @@
import { SyncedStore, TLInstanceId, TLUserId, useApp } from '@tldraw/editor' import { SyncedStore, TLInstanceId, useApp } from '@tldraw/editor'
import { parseAndLoadDocument, serializeTldrawJson } from '@tldraw/file-format' import { parseAndLoadDocument, serializeTldrawJson } from '@tldraw/file-format'
import { useDefaultHelpers } from '@tldraw/ui' import { useDefaultHelpers } from '@tldraw/ui'
import { debounce } from '@tldraw/utils' import { debounce } from '@tldraw/utils'
@ -11,11 +11,9 @@ import type { VscodeMessage } from '../../messages'
export const ChangeResponder = ({ export const ChangeResponder = ({
syncedStore, syncedStore,
userId,
instanceId, instanceId,
}: { }: {
syncedStore: SyncedStore syncedStore: SyncedStore
userId: TLUserId
instanceId: TLInstanceId instanceId: TLInstanceId
}) => { }) => {
const app = useApp() const app = useApp()
@ -46,7 +44,7 @@ export const ChangeResponder = ({
clearToasts() clearToasts()
window.removeEventListener('message', handleMessage) window.removeEventListener('message', handleMessage)
} }
}, [app, userId, instanceId, msg, addToast, clearToasts]) }, [app, instanceId, msg, addToast, clearToasts])
React.useEffect(() => { React.useEffect(() => {
// When the history changes, send the new file contents to VSCode // When the history changes, send the new file contents to VSCode
@ -71,7 +69,7 @@ export const ChangeResponder = ({
handleChange() handleChange()
app.off('change-history', handleChange) app.off('change-history', handleChange)
} }
}, [app, syncedStore, userId, instanceId]) }, [app, syncedStore, instanceId])
return null return null
} }

View file

@ -1,17 +1,15 @@
import { TLInstanceId, TLUserId, useApp } from '@tldraw/editor' import { TLInstanceId, useApp } from '@tldraw/editor'
import { parseAndLoadDocument } from '@tldraw/file-format' import { parseAndLoadDocument } from '@tldraw/file-format'
import { useDefaultHelpers } from '@tldraw/ui' import { useDefaultHelpers } from '@tldraw/ui'
import React from 'react' import React from 'react'
import { vscode } from './utils/vscode' import { vscode } from './utils/vscode'
export function FileOpen({ export function FileOpen({
userId,
fileContents, fileContents,
instanceId, instanceId,
forceDarkMode, forceDarkMode,
}: { }: {
instanceId: TLInstanceId instanceId: TLInstanceId
userId: TLUserId
fileContents: string fileContents: string
forceDarkMode: boolean forceDarkMode: boolean
}) { }) {
@ -44,17 +42,7 @@ export function FileOpen({
return () => { return () => {
clearToasts() clearToasts()
} }
}, [ }, [fileContents, app, instanceId, addToast, msg, clearToasts, forceDarkMode, isFileLoaded])
fileContents,
app,
userId,
instanceId,
addToast,
msg,
clearToasts,
forceDarkMode,
isFileLoaded,
])
return null return null
} }

View file

@ -5,7 +5,6 @@ import {
setRuntimeOverrides, setRuntimeOverrides,
TldrawEditor, TldrawEditor,
TldrawEditorConfig, TldrawEditorConfig,
TLUserId,
} from '@tldraw/editor' } from '@tldraw/editor'
import { linksUiOverrides } from './utils/links' import { linksUiOverrides } from './utils/links'
// eslint-disable-next-line import/no-internal-modules // eslint-disable-next-line import/no-internal-modules
@ -97,7 +96,6 @@ export const TldrawWrapper = () => {
assetSrc: message.data.assetSrc, assetSrc: message.data.assetSrc,
fileContents: message.data.fileContents, fileContents: message.data.fileContents,
uri: message.data.uri, uri: message.data.uri,
userId: message.data.userId as TLUserId,
isDarkMode: message.data.isDarkMode, isDarkMode: message.data.isDarkMode,
config, config,
}) })
@ -128,24 +126,15 @@ export type TLDrawInnerProps = {
assetSrc: string assetSrc: string
fileContents: string fileContents: string
uri: string uri: string
userId: TLUserId
isDarkMode: boolean isDarkMode: boolean
config: TldrawEditorConfig config: TldrawEditorConfig
} }
function TldrawInner({ function TldrawInner({ uri, config, assetSrc, isDarkMode, fileContents }: TLDrawInnerProps) {
uri,
config,
assetSrc,
userId,
isDarkMode,
fileContents,
}: TLDrawInnerProps) {
const instanceId = TAB_ID const instanceId = TAB_ID
const syncedStore = useLocalSyncClient({ const syncedStore = useLocalSyncClient({
universalPersistenceKey: uri, universalPersistenceKey: uri,
instanceId, instanceId,
userId,
config, config,
}) })
@ -156,20 +145,14 @@ function TldrawInner({
config={config} config={config}
assetUrls={assetUrls} assetUrls={assetUrls}
instanceId={TAB_ID} instanceId={TAB_ID}
userId={userId}
store={syncedStore} store={syncedStore}
onCreateBookmarkFromUrl={onCreateBookmarkFromUrl} onCreateBookmarkFromUrl={onCreateBookmarkFromUrl}
autoFocus autoFocus
> >
{/* <DarkModeHandler themeKind={themeKind} /> */} {/* <DarkModeHandler themeKind={themeKind} /> */}
<TldrawUi assetUrls={assetUrls} overrides={[menuOverrides, linksUiOverrides]}> <TldrawUi assetUrls={assetUrls} overrides={[menuOverrides, linksUiOverrides]}>
<FileOpen <FileOpen instanceId={instanceId} fileContents={fileContents} forceDarkMode={isDarkMode} />
instanceId={instanceId} <ChangeResponder syncedStore={syncedStore} instanceId={instanceId} />
userId={userId}
fileContents={fileContents}
forceDarkMode={isDarkMode}
/>
<ChangeResponder syncedStore={syncedStore} userId={userId} instanceId={instanceId} />
<ContextMenu> <ContextMenu>
<Canvas /> <Canvas />
</ContextMenu> </ContextMenu>

View file

@ -93,10 +93,7 @@ import { TLStyleType } from '@tldraw/tlschema';
import { TLTextShape } from '@tldraw/tlschema'; import { TLTextShape } from '@tldraw/tlschema';
import { TLTextShapeProps } from '@tldraw/tlschema'; import { TLTextShapeProps } from '@tldraw/tlschema';
import { TLUnknownShape } from '@tldraw/tlschema'; import { TLUnknownShape } from '@tldraw/tlschema';
import { TLUser } from '@tldraw/tlschema';
import { TLUserDocument } from '@tldraw/tlschema'; import { TLUserDocument } from '@tldraw/tlschema';
import { TLUserId } from '@tldraw/tlschema';
import { TLUserPresence } from '@tldraw/tlschema';
import { TLVideoAsset } from '@tldraw/tlschema'; import { TLVideoAsset } from '@tldraw/tlschema';
import { TLVideoShape } from '@tldraw/tlschema'; import { TLVideoShape } from '@tldraw/tlschema';
import { UnknownRecord } from '@tldraw/tlstore'; import { UnknownRecord } from '@tldraw/tlstore';
@ -368,6 +365,7 @@ export class App extends EventEmitter<TLEventMap> {
// (undocumented) // (undocumented)
get isToolLocked(): boolean; get isToolLocked(): boolean;
isWithinSelection(id: TLShapeId): boolean; isWithinSelection(id: TLShapeId): boolean;
get locale(): string;
// (undocumented) // (undocumented)
lockShapes(_ids?: TLShapeId[]): this; lockShapes(_ids?: TLShapeId[]): this;
mark(reason?: string, onUndo?: boolean, onRedo?: boolean): string; mark(reason?: string, onUndo?: boolean, onRedo?: boolean): string;
@ -467,6 +465,7 @@ export class App extends EventEmitter<TLEventMap> {
setHintingIds(ids: TLShapeId[]): this; setHintingIds(ids: TLShapeId[]): this;
setHoveredId(id?: null | TLShapeId): this; setHoveredId(id?: null | TLShapeId): this;
setInstancePageState(partial: Partial<TLInstancePageState>, ephemeral?: boolean): void; setInstancePageState(partial: Partial<TLInstancePageState>, ephemeral?: boolean): void;
setLocale(locale: string): void;
// (undocumented) // (undocumented)
setPenMode(isPenMode: boolean): this; setPenMode(isPenMode: boolean): this;
setProp(key: TLShapeProp, value: any, ephemeral?: boolean, squashing?: boolean): this; setProp(key: TLShapeProp, value: any, ephemeral?: boolean, squashing?: boolean): this;
@ -495,7 +494,7 @@ export class App extends EventEmitter<TLEventMap> {
readonly snaps: SnapManager; readonly snaps: SnapManager;
get sortedShapesArray(): TLShape[]; get sortedShapesArray(): TLShape[];
stackShapes(operation: 'horizontal' | 'vertical', ids?: TLShapeId[], gap?: number): this; stackShapes(operation: 'horizontal' | 'vertical', ids?: TLShapeId[], gap?: number): this;
startFollowingUser: (userId: TLUserId) => this | undefined; startFollowingUser: (userId: string) => this | undefined;
stopCameraAnimation(): this; stopCameraAnimation(): this;
stopFollowingUser: () => this; stopFollowingUser: () => this;
readonly store: TLStore; readonly store: TLStore;
@ -511,22 +510,12 @@ export class App extends EventEmitter<TLEventMap> {
updateInstanceState(partial: Partial<Omit<TLInstance, 'currentPageId' | 'documentId' | 'userId'>>, ephemeral?: boolean, squashing?: boolean): this; updateInstanceState(partial: Partial<Omit<TLInstance, 'currentPageId' | 'documentId' | 'userId'>>, ephemeral?: boolean, squashing?: boolean): this;
updatePage(partial: RequiredKeys<TLPage, 'id'>, squashing?: boolean): this; updatePage(partial: RequiredKeys<TLPage, 'id'>, squashing?: boolean): this;
updateShapes(partials: (null | TLShapePartial | undefined)[], squashing?: boolean): this; updateShapes(partials: (null | TLShapePartial | undefined)[], squashing?: boolean): this;
updateUser(partial: Partial<TLUser>): void;
updateUserDocumentSettings(partial: Partial<TLUserDocument>, ephemeral?: boolean): this; updateUserDocumentSettings(partial: Partial<TLUserDocument>, ephemeral?: boolean): this;
// (undocumented)
updateUserPresence: ({ cursor, color, viewportPageBounds, }?: {
cursor?: undefined | Vec2dModel;
color?: string | undefined;
viewportPageBounds?: Box2dModel | undefined;
}) => void;
updateViewportScreenBounds(center?: boolean): this; updateViewportScreenBounds(center?: boolean): this;
get user(): TLUser; // @internal (undocumented)
readonly user: UserPreferencesManager;
// (undocumented) // (undocumented)
get userDocumentSettings(): TLUserDocument; get userDocumentSettings(): TLUserDocument;
get userId(): TLUserId;
// (undocumented)
get userPresence(): TLUserPresence | undefined;
get userSettings(): TLUser;
get viewportPageBounds(): Box2d; get viewportPageBounds(): Box2d;
get viewportPageCenter(): Vec2d; get viewportPageCenter(): Vec2d;
get viewportScreenBounds(): Box2d; get viewportScreenBounds(): Box2d;
@ -1794,10 +1783,13 @@ export class TldrawEditorConfig {
// (undocumented) // (undocumented)
createStore(config: { createStore(config: {
initialData?: StoreSnapshot<TLRecord>; initialData?: StoreSnapshot<TLRecord>;
userId: TLUserId;
instanceId: TLInstanceId; instanceId: TLInstanceId;
}): TLStore; }): TLStore;
// (undocumented) // (undocumented)
readonly derivePresenceState: (store: TLStore) => Signal<null | TLInstancePresence>;
// (undocumented)
readonly setUserPreferences: (userPreferences: TLUserPreferences) => void;
// (undocumented)
readonly shapeUtils: Record<TLShape['type'], TLShapeUtilConstructor<any>>; readonly shapeUtils: Record<TLShape['type'], TLShapeUtilConstructor<any>>;
// (undocumented) // (undocumented)
readonly storeSchema: StoreSchema<TLRecord, TLStoreProps>; readonly storeSchema: StoreSchema<TLRecord, TLStoreProps>;
@ -1805,6 +1797,8 @@ export class TldrawEditorConfig {
readonly TLShape: RecordType<TLShape, 'index' | 'parentId' | 'props' | 'type'>; readonly TLShape: RecordType<TLShape, 'index' | 'parentId' | 'props' | 'type'>;
// (undocumented) // (undocumented)
readonly tools: readonly StateNodeConstructor[]; readonly tools: readonly StateNodeConstructor[];
// (undocumented)
readonly userPreferences: Signal<TLUserPreferences>;
} }
// @public (undocumented) // @public (undocumented)
@ -1825,7 +1819,6 @@ export interface TldrawEditorProps {
}>; }>;
onMount?: (app: App) => void; onMount?: (app: App) => void;
store?: SyncedStore | TLStore; store?: SyncedStore | TLStore;
userId?: TLUserId;
} }
// @public (undocumented) // @public (undocumented)
@ -2658,17 +2651,20 @@ export const useApp: () => App;
export function useContainer(): HTMLDivElement; export function useContainer(): HTMLDivElement;
// @internal (undocumented) // @internal (undocumented)
export function usePeerIds(): TLUserId[]; export function usePeerIds(): string[];
// @public (undocumented) // @public (undocumented)
export function usePrefersReducedMotion(): boolean; export function usePrefersReducedMotion(): boolean;
// @internal (undocumented) // @internal (undocumented)
export function usePresence(userId: TLUserId): null | TLInstancePresence; export function usePresence(userId: string): null | TLInstancePresence;
// @public (undocumented) // @public (undocumented)
export function useQuickReactor(name: string, reactFn: () => void, deps?: any[]): void; export function useQuickReactor(name: string, reactFn: () => void, deps?: any[]): void;
// @internal (undocumented)
export const USER_COLORS: readonly ["#FF802B", "#EC5E41", "#F2555A", "#F04F88", "#E34BA9", "#BD54C6", "#9D5BD2", "#7B66DC", "#02B1CC", "#11B3A3", "#39B178", "#55B467"];
// @public (undocumented) // @public (undocumented)
export function useReactor(name: string, reactFn: () => void, deps?: any[] | undefined): void; export function useReactor(name: string, reactFn: () => void, deps?: any[] | undefined): void;

View file

@ -131,6 +131,7 @@ export {
type ReadySyncedStore, type ReadySyncedStore,
type SyncedStore, type SyncedStore,
} from './lib/config/SyncedStore' } from './lib/config/SyncedStore'
export { USER_COLORS } from './lib/config/TLUserPreferences'
export { TldrawEditorConfig } from './lib/config/TldrawEditorConfig' export { TldrawEditorConfig } from './lib/config/TldrawEditorConfig'
export { export {
ANIMATION_MEDIUM_MS, ANIMATION_MEDIUM_MS,

View file

@ -1,7 +1,7 @@
import { TLAsset, TLInstance, TLInstanceId, TLStore, TLUser, TLUserId } from '@tldraw/tlschema' import { TLAsset, TLInstance, TLInstanceId, TLStore } from '@tldraw/tlschema'
import { Store } from '@tldraw/tlstore' import { Store } from '@tldraw/tlstore'
import { annotateError } from '@tldraw/utils' import { annotateError } from '@tldraw/utils'
import React, { useCallback, useEffect, useState, useSyncExternalStore } from 'react' import React, { useCallback, useMemo, useSyncExternalStore } from 'react'
import { App } from './app/App' import { App } from './app/App'
import { EditorAssetUrls, defaultEditorAssetUrls } from './assetUrls' import { EditorAssetUrls, defaultEditorAssetUrls } from './assetUrls'
import { OptionalErrorBoundary } from './components/ErrorBoundary' import { OptionalErrorBoundary } from './components/ErrorBoundary'
@ -87,8 +87,6 @@ export interface TldrawEditorProps {
* from a server or database. * from a server or database.
*/ */
store?: TLStore | SyncedStore store?: TLStore | SyncedStore
/** The id of the current user. If not given, one will be generated. */
userId?: TLUserId
/** /**
* The id of the app instance (e.g. a browser tab if the app will have only one tldraw app per * The id of the app instance (e.g. a browser tab if the app will have only one tldraw app per
* tab). If not given, one will be generated. * tab). If not given, one will be generated.
@ -132,38 +130,19 @@ export function TldrawEditor(props: TldrawEditorProps) {
) )
} }
function TldrawEditorBeforeLoading({ function TldrawEditorBeforeLoading({ config, instanceId, store, ...props }: TldrawEditorProps) {
config,
userId,
instanceId,
store,
...props
}: TldrawEditorProps) {
const { done: preloadingComplete, error: preloadingError } = usePreloadAssets( const { done: preloadingComplete, error: preloadingError } = usePreloadAssets(
props.assetUrls ?? defaultEditorAssetUrls props.assetUrls ?? defaultEditorAssetUrls
) )
const [_store, _setStore] = useState<TLStore | SyncedStore>(() => { const _store = useMemo<TLStore | SyncedStore>(() => {
return ( return (
store ?? store ??
config.createStore({ config.createStore({
userId: userId ?? TLUser.createId(),
instanceId: instanceId ?? TLInstance.createId(), instanceId: instanceId ?? TLInstance.createId(),
}) })
) )
}) }, [store, config, instanceId])
useEffect(() => {
_setStore(() => {
return (
store ??
config.createStore({
userId: userId ?? TLUser.createId(),
instanceId: instanceId ?? TLInstance.createId(),
})
)
})
}, [store, config, userId, instanceId])
let loadedStore: TLStore | SyncedStore let loadedStore: TLStore | SyncedStore
if (!(_store instanceof Store)) { if (!(_store instanceof Store)) {
@ -188,12 +167,6 @@ function TldrawEditorBeforeLoading({
) )
} }
if (userId && loadedStore.props.userId !== userId) {
console.error(
`The store's userId (${loadedStore.props.userId}) does not match the userId prop (${userId}). This may cause unexpected behavior.`
)
}
if (preloadingError) { if (preloadingError) {
return <ErrorScreen>Could not load assets. Please refresh the page.</ErrorScreen> return <ErrorScreen>Could not load assets. Please refresh the page.</ErrorScreen>
} }
@ -208,7 +181,6 @@ function TldrawEditorBeforeLoading({
function TldrawEditorAfterLoading({ function TldrawEditorAfterLoading({
onMount, onMount,
config, config,
isDarkMode,
children, children,
onCreateAssetFromFile, onCreateAssetFromFile,
onCreateBookmarkFromUrl, onCreateBookmarkFromUrl,
@ -257,20 +229,15 @@ function TldrawEditorAfterLoading({
const onMountEvent = useEvent((app: App) => { const onMountEvent = useEvent((app: App) => {
onMount?.(app) onMount?.(app)
app.emit('mount') app.emit('mount')
window.tldrawReady = true
}) })
React.useEffect(() => { React.useEffect(() => {
if (app) { if (app) {
// Set the initial theme state.
if (isDarkMode !== undefined) {
app.updateUserDocumentSettings({ isDarkMode })
}
// Run onMount // Run onMount
window.tldrawReady = true
onMountEvent(app) onMountEvent(app)
} }
}, [app, onMountEvent, isDarkMode]) }, [app, onMountEvent])
const crashingError = useSyncExternalStore( const crashingError = useSyncExternalStore(
useCallback( useCallback(

View file

@ -40,6 +40,7 @@ import {
TLInstanceId, TLInstanceId,
TLInstancePageState, TLInstancePageState,
TLNullableShapeProps, TLNullableShapeProps,
TLPOINTER_ID,
TLPage, TLPage,
TLPageId, TLPageId,
TLParentId, TLParentId,
@ -52,9 +53,7 @@ import {
TLSizeStyle, TLSizeStyle,
TLStore, TLStore,
TLUnknownShape, TLUnknownShape,
TLUser,
TLUserDocument, TLUserDocument,
TLUserId,
TLVideoAsset, TLVideoAsset,
Vec2dModel, Vec2dModel,
createCustomShapeId, createCustomShapeId,
@ -116,6 +115,7 @@ import { HistoryManager } from './managers/HistoryManager'
import { SnapManager } from './managers/SnapManager' import { SnapManager } from './managers/SnapManager'
import { TextManager } from './managers/TextManager' import { TextManager } from './managers/TextManager'
import { TickManager } from './managers/TickManager' import { TickManager } from './managers/TickManager'
import { UserPreferencesManager } from './managers/UserPreferencesManager'
import { TLArrowUtil } from './shapeutils/TLArrowUtil/TLArrowUtil' import { TLArrowUtil } from './shapeutils/TLArrowUtil/TLArrowUtil'
import { getCurvedArrowInfo } from './shapeutils/TLArrowUtil/arrow/curved-arrow' import { getCurvedArrowInfo } from './shapeutils/TLArrowUtil/arrow/curved-arrow'
import { import {
@ -185,6 +185,8 @@ export class App extends EventEmitter<TLEventMap> {
this.store = store this.store = store
this.user = new UserPreferencesManager(this)
this.getContainer = getContainer ?? (() => document.body) this.getContainer = getContainer ?? (() => document.body)
this.textMeasure = new TextManager(this) this.textMeasure = new TextManager(this)
@ -355,6 +357,11 @@ export class App extends EventEmitter<TLEventMap> {
*/ */
readonly snaps = new SnapManager(this) readonly snaps = new SnapManager(this)
/**
* @internal
*/
readonly user: UserPreferencesManager
/** /**
* Whether the editor is running in Safari. * Whether the editor is running in Safari.
* *
@ -424,21 +431,6 @@ export class App extends EventEmitter<TLEventMap> {
*/ */
getContainer: () => HTMLElement getContainer: () => HTMLElement
/**
* The editor's userId (defined in its store.props).
*
* @example
*
* ```ts
* const userId = app.userId
* ```
*
* @public
*/
get userId(): TLUserId {
return this.store.props.userId
}
/** /**
* The editor's instanceId (defined in its store.props). * The editor's instanceId (defined in its store.props).
* *
@ -1197,7 +1189,6 @@ export class App extends EventEmitter<TLEventMap> {
} }
} }
this.updateUserPresence()
this.emit('update') this.emit('update')
} }
@ -1500,16 +1491,6 @@ export class App extends EventEmitter<TLEventMap> {
return this.documentSettings.gridSize return this.documentSettings.gridSize
} }
/**
* The user's global settings.
*
* @public
* @readonly
*/
get userSettings(): TLUser {
return this.store.get(this.userId)!
}
get isSnapMode() { get isSnapMode() {
return this.userDocumentSettings.isSnapMode return this.userDocumentSettings.isSnapMode
} }
@ -1522,12 +1503,12 @@ export class App extends EventEmitter<TLEventMap> {
} }
get isDarkMode() { get isDarkMode() {
return this.userDocumentSettings.isDarkMode return this.user.isDarkMode
} }
setDarkMode(isDarkMode: boolean) { setDarkMode(isDarkMode: boolean) {
if (isDarkMode !== this.isDarkMode) { if (isDarkMode !== this.isDarkMode) {
this.updateUserDocumentSettings({ isDarkMode }, true) this.user.updateUserPreferences({ isDarkMode })
} }
return this return this
} }
@ -1556,7 +1537,7 @@ export class App extends EventEmitter<TLEventMap> {
/** @internal */ /** @internal */
@computed private get _userDocumentSettings() { @computed private get _userDocumentSettings() {
return this.store.query.record('user_document', () => ({ userId: { eq: this.userId } })) return this.store.query.record('user_document')
} }
get userDocumentSettings(): TLUserDocument { get userDocumentSettings(): TLUserDocument {
@ -1609,15 +1590,6 @@ export class App extends EventEmitter<TLEventMap> {
// User / User App State // User / User App State
/**
* The current user state.
*
* @public
*/
get user(): TLUser {
return this.store.get(this.userId)!
}
/** The current tab state */ /** The current tab state */
get instanceState(): TLInstance { get instanceState(): TLInstance {
return this.store.get(this.instanceId)! return this.store.get(this.instanceId)!
@ -3467,7 +3439,15 @@ export class App extends EventEmitter<TLEventMap> {
} }
// todo: We only have to do this if there are multiple users in the document // todo: We only have to do this if there are multiple users in the document
this.updateUserPresence({ cursor: currentPagePoint.toJson() }) this.store.put([
{
id: TLPOINTER_ID,
typeName: 'pointer',
x: currentPagePoint.x,
y: currentPagePoint.y,
lastActivityTimestamp: Date.now(),
},
])
} }
/* --------------------- Events --------------------- */ /* --------------------- Events --------------------- */
@ -5017,6 +4997,27 @@ export class App extends EventEmitter<TLEventMap> {
} }
) )
/**
* Get the editor's locale.
* @public
*/
get locale() {
return this.user.locale
}
/**
* Update the editor's locale. This affects which translations are used when rendering UI elements.
*
* @example
*
* ```ts
* app.setLocale('fr')
* ```
*/
setLocale(locale: string) {
this.user.updateUserPreferences({ locale })
}
/** /**
* Update a page. * Update a page.
* *
@ -5260,56 +5261,6 @@ export class App extends EventEmitter<TLEventMap> {
} }
) )
/**
* Set user state. Always ephemeral for now.
*
* @example
*
* ```ts
* app.updateUser({ color: '#923433' })
* ```
*
* @param partial - The partial of the user state object containing the changes.
* @public
*/
updateUser(partial: Partial<TLUser>) {
const next = { ...this.user, ...partial }
this.store.put([next])
}
/** @internal */
@computed private get _currentUserPresence() {
return this.store.query.record('user_presence', () => ({ userId: { eq: this.userId } }))
}
get userPresence() {
return this._currentUserPresence.value
}
// when a user performs any action in the app, we update their presence record
updateUserPresence = ({
cursor,
color,
viewportPageBounds,
}: { cursor?: Vec2dModel; color?: string; viewportPageBounds?: Box2dModel } = {}) => {
const presence = this._currentUserPresence.value
if (!presence) {
console.error('No presence found for current user')
return
}
this.store.put([
{
...presence,
cursor: cursor ?? presence.cursor,
color: color ?? presence.color,
viewportPageBounds: viewportPageBounds ?? presence.viewportPageBounds,
lastUsedInstanceId: this.instanceId,
lastActivityTimestamp: Date.now(),
},
])
}
/** /**
* Select one or more shapes. * Select one or more shapes.
* *
@ -5577,7 +5528,7 @@ export class App extends EventEmitter<TLEventMap> {
scale = 1, scale = 1,
background = false, background = false,
padding = SVG_PADDING, padding = SVG_PADDING,
darkMode = this.userDocumentSettings.isDarkMode, darkMode = this.isDarkMode,
preserveAspectRatio = false, preserveAspectRatio = false,
} = opts } = opts
@ -7246,17 +7197,11 @@ export class App extends EventEmitter<TLEventMap> {
this.store.put([{ ...this.instanceState, currentPageId: toId }]) this.store.put([{ ...this.instanceState, currentPageId: toId }])
this.updateUserPresence({
viewportPageBounds: this.viewportPageBounds.toJson(),
})
this.updateCullingBounds() this.updateCullingBounds()
}, },
undo: ({ fromId }) => { undo: ({ fromId }) => {
this.store.put([{ ...this.instanceState, currentPageId: fromId }]) this.store.put([{ ...this.instanceState, currentPageId: fromId }])
this.updateUserPresence({
viewportPageBounds: this.viewportPageBounds.toJson(),
})
this.updateCullingBounds() this.updateCullingBounds()
}, },
squash: ({ fromId }, { toId }) => { squash: ({ fromId }, { toId }) => {
@ -7890,10 +7835,6 @@ export class App extends EventEmitter<TLEventMap> {
isPen: this.isPenMode ?? false, isPen: this.isPenMode ?? false,
}) })
this.updateUserPresence({
viewportPageBounds: this.viewportPageBounds.toJson(),
})
this._cameraManager.tick() this._cameraManager.tick()
}) })
@ -8494,7 +8435,7 @@ export class App extends EventEmitter<TLEventMap> {
* @param userId - The id of the user to follow. * @param userId - The id of the user to follow.
* @public * @public
*/ */
startFollowingUser = (userId: TLUserId) => { startFollowingUser = (userId: string) => {
// Currently, we get the leader's viewport page bounds from their user presence. // Currently, we get the leader's viewport page bounds from their user presence.
// This is a placeholder until the ephemeral PR lands. // This is a placeholder until the ephemeral PR lands.
// After that, we'll be able to get the required data from their instance presence instead. // After that, we'll be able to get the required data from their instance presence instead.
@ -8502,8 +8443,14 @@ export class App extends EventEmitter<TLEventMap> {
userId: { eq: userId }, userId: { eq: userId },
})) }))
const thisUserId = this.user.id
if (!thisUserId) {
console.warn('You should set the userId for the current instance before following a user')
}
// If the leader is following us, then we can't follow them // If the leader is following us, then we can't follow them
if (leaderPresences.value.some((p) => p.followingUserId === this.userId)) { if (leaderPresences.value.some((p) => p.followingUserId === thisUserId)) {
return return
} }
@ -8554,7 +8501,7 @@ export class App extends EventEmitter<TLEventMap> {
// At this point, let's check if we're following someone who's following us. // At this point, let's check if we're following someone who's following us.
// If so, we can't try to contain their entire viewport // If so, we can't try to contain their entire viewport
// because that would become a feedback loop where we zoom, they zoom, etc. // because that would become a feedback loop where we zoom, they zoom, etc.
const isFollowingFollower = leaderPresence.followingUserId === this.userId const isFollowingFollower = leaderPresence.followingUserId === thisUserId
// Figure out how much to zoom // Figure out how much to zoom
const desiredWidth = width + (leaderWidth - width) * chaseProportion const desiredWidth = width + (leaderWidth - width) * chaseProportion

View file

@ -0,0 +1,33 @@
import { TLUserPreferences } from '../../config/TLUserPreferences'
import { App } from '../App'
export class UserPreferencesManager {
constructor(private readonly editor: App) {}
updateUserPreferences = (userPreferences: Partial<TLUserPreferences>) => {
this.editor.config.setUserPreferences({
...this.editor.config.userPreferences.value,
...userPreferences,
})
}
get isDarkMode() {
return this.editor.config.userPreferences.value.isDarkMode
}
get id() {
return this.editor.config.userPreferences.value.id
}
get name() {
return this.editor.config.userPreferences.value.name
}
get locale() {
return this.editor.config.userPreferences.value.locale
}
get color() {
return this.editor.config.userPreferences.value.color
}
}

View file

@ -2,8 +2,10 @@ import { createCustomShapeId, TLGeoShape, TLLineShape } from '@tldraw/tlschema'
import { deepCopy } from '@tldraw/utils' import { deepCopy } from '@tldraw/utils'
import { TestApp } from '../../../test/TestApp' import { TestApp } from '../../../test/TestApp'
let i = 0 jest.mock('nanoid', () => {
jest.mock('nanoid', () => ({ nanoid: () => 'id' + i++ })) let i = 0
return { nanoid: () => 'id' + i++ }
})
let app: TestApp let app: TestApp
const id = createCustomShapeId('line1') const id = createCustomShapeId('line1')

View file

@ -5,7 +5,7 @@ Object {
"id": "shape:line1", "id": "shape:line1",
"index": "a1", "index": "a1",
"isLocked": false, "isLocked": false,
"parentId": "page:id51", "parentId": "page:id50",
"props": Object { "props": Object {
"color": "black", "color": "black",
"dash": "draw", "dash": "draw",

View file

@ -33,7 +33,7 @@ export const ShapeFill = React.memo(function ShapeFill({ d, color, fill }: Shape
const PatternFill = function PatternFill({ d, color }: ShapeFillProps) { const PatternFill = function PatternFill({ d, color }: ShapeFillProps) {
const app = useApp() const app = useApp()
const zoomLevel = useValue('zoomLevel', () => app.zoomLevel, [app]) const zoomLevel = useValue('zoomLevel', () => app.zoomLevel, [app])
const isDarkMode = useValue('isDarkMode', () => app.userDocumentSettings.isDarkMode, [app]) const isDarkMode = useValue('isDarkMode', () => app.isDarkMode, [app])
const intZoom = Math.ceil(zoomLevel) const intZoom = Math.ceil(zoomLevel)
const teenyTiny = app.zoomLevel <= 0.18 const teenyTiny = app.zoomLevel <= 0.18

View file

@ -30,7 +30,7 @@ export const DefaultErrorFallback: TLErrorFallback = ({ error, app }) => {
() => { () => {
try { try {
if (app) { if (app) {
return app.userDocumentSettings.isDarkMode return app.isDarkMode
} }
} catch { } catch {
// we're in a funky error state so this might not work for spooky // we're in a funky error state so this might not work for spooky

View file

@ -1,4 +1,3 @@
import { TLUserId } from '@tldraw/tlschema'
import { track } from 'signia-react' import { track } from 'signia-react'
import { useApp } from '../hooks/useApp' import { useApp } from '../hooks/useApp'
import { useEditorComponents } from '../hooks/useEditorComponents' import { useEditorComponents } from '../hooks/useEditorComponents'
@ -16,7 +15,7 @@ export const LiveCollaborators = track(function Collaborators() {
) )
}) })
const Collaborator = track(function Collaborator({ userId }: { userId: TLUserId }) { const Collaborator = track(function Collaborator({ userId }: { userId: string }) {
const app = useApp() const app = useApp()
const { viewportPageBounds, zoomLevel } = app const { viewportPageBounds, zoomLevel } = app

View file

@ -0,0 +1,158 @@
import { getDefaultTranslationLocale } from '@tldraw/tlschema'
import { defineMigrations, migrate } from '@tldraw/tlstore'
import { T } from '@tldraw/tlvalidate'
import { atom } from 'signia'
import { uniqueId } from '../utils/data'
const USER_DATA_KEY = 'TLDRAW_USER_DATA_v3'
/**
* A user of tldraw
*
* @public
*/
export interface TLUserPreferences {
id: string
name: string
locale: string
color: string
isDarkMode: boolean
}
interface UserDataSnapshot {
version: number
user: TLUserPreferences
}
interface UserChangeBroadcastMessage {
type: typeof broadcastEventKey
origin: string
}
const userTypeValidator: T.Validator<TLUserPreferences> = T.model(
'user',
T.object({
id: T.string,
name: T.string,
locale: T.string,
color: T.string,
isDarkMode: T.boolean,
})
)
const userTypeMigrations = defineMigrations({})
/** @internal */
export const USER_COLORS = [
'#FF802B',
'#EC5E41',
'#F2555A',
'#F04F88',
'#E34BA9',
'#BD54C6',
'#9D5BD2',
'#7B66DC',
'#02B1CC',
'#11B3A3',
'#39B178',
'#55B467',
] as const
function getRandomColor() {
return USER_COLORS[Math.floor(Math.random() * USER_COLORS.length)]
}
function getFreshUserPreferences(): TLUserPreferences {
return {
id: uniqueId(),
locale: typeof window !== 'undefined' ? getDefaultTranslationLocale() : 'en',
name: 'New User',
color: getRandomColor(),
// TODO: detect dark mode
isDarkMode: false,
}
}
function loadUserPreferences(): TLUserPreferences {
const userData =
typeof window === 'undefined'
? null
: ((JSON.parse(window?.localStorage?.getItem(USER_DATA_KEY) || 'null') ??
null) as null | UserDataSnapshot)
if (userData === null) {
return getFreshUserPreferences()
}
if (!('version' in userData) || !('user' in userData)) {
return getFreshUserPreferences()
}
const migrationResult = migrate<TLUserPreferences>({
value: userData.user,
fromVersion: userData.version,
toVersion: userTypeMigrations.currentVersion ?? 0,
migrations: userTypeMigrations,
})
if (migrationResult.type === 'error') {
return getFreshUserPreferences()
}
try {
userTypeValidator.validate(migrationResult.value)
} catch (e) {
return getFreshUserPreferences()
}
return migrationResult.value
}
const globalUserPreferences = atom<TLUserPreferences>('globalUserData', loadUserPreferences())
function storeUserPreferences() {
if (typeof window !== 'undefined' && window.localStorage) {
window.localStorage.setItem(
USER_DATA_KEY,
JSON.stringify({
version: userTypeMigrations.currentVersion,
user: globalUserPreferences.value,
})
)
}
}
export function setUserPreferences(user: TLUserPreferences) {
userTypeValidator.validate(user)
globalUserPreferences.set(user)
storeUserPreferences()
broadcastUserPreferencesChange()
}
const isTest = typeof process !== 'undefined' && process.env.NODE_ENV === 'test'
const channel =
typeof BroadcastChannel !== 'undefined' && !isTest
? new BroadcastChannel('tldraw-user-sync')
: null
channel?.addEventListener('message', (e) => {
const data = e.data as undefined | UserChangeBroadcastMessage
if (data?.type === broadcastEventKey && data?.origin !== broadcastOrigin) {
globalUserPreferences.set(loadUserPreferences())
}
})
const broadcastOrigin = uniqueId()
const broadcastEventKey = 'tldraw-user-preferences-change' as const
function broadcastUserPreferencesChange() {
channel?.postMessage({
type: broadcastEventKey,
origin: broadcastOrigin,
} satisfies UserChangeBroadcastMessage)
}
/** @public */
export function getUserPreferences() {
return globalUserPreferences.value
}

View file

@ -9,12 +9,10 @@ import {
TLShape, TLShape,
TLStore, TLStore,
TLStoreProps, TLStoreProps,
TLUser,
TLUserId,
createTLSchema, createTLSchema,
} from '@tldraw/tlschema' } from '@tldraw/tlschema'
import { Migrations, RecordType, Store, StoreSchema, StoreSnapshot } from '@tldraw/tlstore' import { Migrations, RecordType, Store, StoreSchema, StoreSnapshot } from '@tldraw/tlstore'
import { Signal } from 'signia' import { Signal, computed } from 'signia'
import { TLArrowUtil } from '../app/shapeutils/TLArrowUtil/TLArrowUtil' import { TLArrowUtil } from '../app/shapeutils/TLArrowUtil/TLArrowUtil'
import { TLBookmarkUtil } from '../app/shapeutils/TLBookmarkUtil/TLBookmarkUtil' import { TLBookmarkUtil } from '../app/shapeutils/TLBookmarkUtil/TLBookmarkUtil'
import { TLDrawUtil } from '../app/shapeutils/TLDrawUtil/TLDrawUtil' import { TLDrawUtil } from '../app/shapeutils/TLDrawUtil/TLDrawUtil'
@ -29,6 +27,7 @@ import { TLShapeUtilConstructor } from '../app/shapeutils/TLShapeUtil'
import { TLTextUtil } from '../app/shapeutils/TLTextUtil/TLTextUtil' import { TLTextUtil } from '../app/shapeutils/TLTextUtil/TLTextUtil'
import { TLVideoUtil } from '../app/shapeutils/TLVideoUtil/TLVideoUtil' import { TLVideoUtil } from '../app/shapeutils/TLVideoUtil/TLVideoUtil'
import { StateNodeConstructor } from '../app/statechart/StateNode' import { StateNodeConstructor } from '../app/statechart/StateNode'
import { TLUserPreferences, getUserPreferences, setUserPreferences } from './TLUserPreferences'
// Secret shape types that don't have a shape util yet // Secret shape types that don't have a shape util yet
type ShapeTypesNotImplemented = 'icon' type ShapeTypesNotImplemented = 'icon'
@ -63,6 +62,8 @@ export type TldrawEditorConfigOptions = {
> >
/** @internal */ /** @internal */
derivePresenceState?: (store: TLStore) => Signal<TLInstancePresence | null> derivePresenceState?: (store: TLStore) => Signal<TLInstancePresence | null>
userPreferences?: Signal<TLUserPreferences>
setUserPreferences?: (userPreferences: TLUserPreferences) => void
} }
/** @public */ /** @public */
@ -78,11 +79,18 @@ export class TldrawEditorConfig {
// The schema used for the store incorporating any custom shapes // The schema used for the store incorporating any custom shapes
readonly storeSchema: StoreSchema<TLRecord, TLStoreProps> readonly storeSchema: StoreSchema<TLRecord, TLStoreProps>
readonly derivePresenceState: (store: TLStore) => Signal<TLInstancePresence | null>
readonly userPreferences: Signal<TLUserPreferences>
readonly setUserPreferences: (userPreferences: TLUserPreferences) => void
constructor(opts = {} as TldrawEditorConfigOptions) { constructor(opts = {} as TldrawEditorConfigOptions) {
const { shapes = {}, tools = [], derivePresenceState } = opts const { shapes = {}, tools = [], derivePresenceState } = opts
this.tools = tools this.tools = tools
this.derivePresenceState = derivePresenceState ?? (() => computed('presence', () => null))
this.userPreferences =
opts.userPreferences ?? computed('userPreferences', () => getUserPreferences())
this.setUserPreferences = opts.setUserPreferences ?? setUserPreferences
this.shapeUtils = { this.shapeUtils = {
...DEFAULT_SHAPE_UTILS, ...DEFAULT_SHAPE_UTILS,
@ -91,7 +99,6 @@ export class TldrawEditorConfig {
this.storeSchema = createTLSchema({ this.storeSchema = createTLSchema({
customShapes: shapes, customShapes: shapes,
derivePresenceState: derivePresenceState,
}) })
this.TLShape = this.storeSchema.types.shape as RecordType< this.TLShape = this.storeSchema.types.shape as RecordType<
@ -103,18 +110,17 @@ export class TldrawEditorConfig {
createStore(config: { createStore(config: {
/** The store's initial data. */ /** The store's initial data. */
initialData?: StoreSnapshot<TLRecord> initialData?: StoreSnapshot<TLRecord>
userId: TLUserId
instanceId: TLInstanceId instanceId: TLInstanceId
}): TLStore { }): TLStore {
let initialData = config.initialData let initialData = config.initialData
if (initialData) { if (initialData) {
initialData = CLIENT_FIXUP_SCRIPT(initialData) initialData = CLIENT_FIXUP_SCRIPT(initialData)
} }
return new Store({
return new Store<TLRecord, TLStoreProps>({
schema: this.storeSchema, schema: this.storeSchema,
initialData, initialData,
props: { props: {
userId: config?.userId ?? TLUser.createId(),
instanceId: config?.instanceId ?? TLInstance.createId(), instanceId: config?.instanceId ?? TLInstance.createId(),
documentId: TLDOCUMENT_ID, documentId: TLDOCUMENT_ID,
}, },

View file

@ -6,7 +6,7 @@ import { useContainer } from './useContainer'
export function useDarkMode() { export function useDarkMode() {
const app = useApp() const app = useApp()
const container = useContainer() const container = useContainer()
const isDarkMode = useValue('isDarkMode', () => app.userDocumentSettings.isDarkMode, [app]) const isDarkMode = useValue('isDarkMode', () => app.isDarkMode, [app])
React.useEffect(() => { React.useEffect(() => {
if (isDarkMode) { if (isDarkMode) {

View file

@ -11,7 +11,7 @@ import { useApp } from './useApp'
export function usePeerIds() { export function usePeerIds() {
const app = useApp() const app = useApp()
const $presences = useMemo(() => { const $presences = useMemo(() => {
return app.store.query.records('instance_presence') return app.store.query.records('instance_presence', () => ({ userId: { neq: app.user.id } }))
}, [app]) }, [app])
const $userIds = useComputed( const $userIds = useComputed(

View file

@ -1,4 +1,4 @@
import { TLInstancePresence, TLUserId } from '@tldraw/tlschema' import { TLInstancePresence } from '@tldraw/tlschema'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useValue } from 'signia-react' import { useValue } from 'signia-react'
import { useApp } from './useApp' import { useApp } from './useApp'
@ -8,7 +8,7 @@ import { useApp } from './useApp'
* @returns The list of peer UserIDs * @returns The list of peer UserIDs
* @internal * @internal
*/ */
export function usePresence(userId: TLUserId): TLInstancePresence | null { export function usePresence(userId: string): TLInstancePresence | null {
const app = useApp() const app = useApp()
const $presences = useMemo(() => { const $presences = useMemo(() => {

View file

@ -13,7 +13,6 @@ import {
TLPage, TLPage,
TLShapeId, TLShapeId,
TLShapePartial, TLShapePartial,
TLUser,
createCustomShapeId, createCustomShapeId,
createShapeId, createShapeId,
} from '@tldraw/tlschema' } from '@tldraw/tlschema'
@ -52,7 +51,6 @@ declare global {
} }
} }
export const TEST_INSTANCE_ID = TLInstance.createCustomId('testInstance1') export const TEST_INSTANCE_ID = TLInstance.createCustomId('testInstance1')
export const TEST_USER_ID = TLUser.createCustomId('testUser1')
export class TestApp extends App { export class TestApp extends App {
constructor(options = {} as Partial<Omit<AppOptions, 'store'>>) { constructor(options = {} as Partial<Omit<AppOptions, 'store'>>) {
@ -62,7 +60,6 @@ export class TestApp extends App {
super({ super({
config, config,
store: config.createStore({ store: config.createStore({
userId: TEST_USER_ID,
instanceId: TEST_INSTANCE_ID, instanceId: TEST_INSTANCE_ID,
}), }),
getContainer: () => elm, getContainer: () => elm,

View file

@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { TLInstance, TLUser } from '@tldraw/tlschema' import { TLInstance } from '@tldraw/tlschema'
import { TldrawEditor } from '../TldrawEditor' import { TldrawEditor } from '../TldrawEditor'
import { TldrawEditorConfig } from '../config/TldrawEditorConfig' import { TldrawEditorConfig } from '../config/TldrawEditorConfig'
@ -25,7 +25,6 @@ describe('<Tldraw />', () => {
const initialStore = config.createStore({ const initialStore = config.createStore({
instanceId: TLInstance.createCustomId('test'), instanceId: TLInstance.createCustomId('test'),
userId: TLUser.createCustomId('test'),
}) })
const onMount = jest.fn() const onMount = jest.fn()
@ -54,7 +53,6 @@ describe('<Tldraw />', () => {
// re-render with a new store: // re-render with a new store:
const newStore = config.createStore({ const newStore = config.createStore({
instanceId: TLInstance.createCustomId('test'), instanceId: TLInstance.createCustomId('test'),
userId: TLUser.createCustomId('test'),
}) })
rendered.rerender( rendered.rerender(
<TldrawEditor config={config} store={newStore} onMount={onMount} autoFocus> <TldrawEditor config={config} store={newStore} onMount={onMount} autoFocus>

View file

@ -19,8 +19,10 @@ import { TLLineTool } from '../../app/statechart/TLLineTool/TLLineTool'
import { TLNoteTool } from '../../app/statechart/TLNoteTool/TLNoteTool' import { TLNoteTool } from '../../app/statechart/TLNoteTool/TLNoteTool'
import { TestApp } from '../TestApp' import { TestApp } from '../TestApp'
let i = 0 jest.mock('nanoid', () => {
jest.mock('nanoid', () => ({ nanoid: () => 'id' + i++ })) let i = 0
return { nanoid: () => 'id' + i++ }
})
const ids = { const ids = {
boxA: createCustomShapeId('boxA'), boxA: createCustomShapeId('boxA'),

View file

@ -12,7 +12,6 @@ import { TldrawEditorConfig } from '@tldraw/editor';
import { TLInstanceId } from '@tldraw/editor'; import { TLInstanceId } from '@tldraw/editor';
import { TLStore } from '@tldraw/editor'; import { TLStore } from '@tldraw/editor';
import { TLTranslationKey } from '@tldraw/ui'; import { TLTranslationKey } from '@tldraw/ui';
import { TLUserId } from '@tldraw/editor';
import { ToastsContextType } from '@tldraw/ui'; import { ToastsContextType } from '@tldraw/ui';
import { UnknownRecord } from '@tldraw/tlstore'; import { UnknownRecord } from '@tldraw/tlstore';
@ -23,10 +22,9 @@ export function isV1File(data: any): boolean;
export function parseAndLoadDocument(app: App, document: string, msg: (id: TLTranslationKey) => string, addToast: ToastsContextType['addToast'], onV1FileLoad?: () => void, forceDarkMode?: boolean): Promise<void>; export function parseAndLoadDocument(app: App, document: string, msg: (id: TLTranslationKey) => string, addToast: ToastsContextType['addToast'], onV1FileLoad?: () => void, forceDarkMode?: boolean): Promise<void>;
// @public (undocumented) // @public (undocumented)
export function parseTldrawJsonFile({ config, json, userId, instanceId, }: { export function parseTldrawJsonFile({ config, json, instanceId, }: {
config: TldrawEditorConfig; config: TldrawEditorConfig;
json: string; json: string;
userId: TLUserId;
instanceId: TLInstanceId; instanceId: TLInstanceId;
}): Result<TLStore, TldrawFileParseError>; }): Result<TLStore, TldrawFileParseError>;

View file

@ -7,7 +7,6 @@ import {
TLInstanceId, TLInstanceId,
TLRecord, TLRecord,
TLStore, TLStore,
TLUserId,
} from '@tldraw/editor' } from '@tldraw/editor'
import { import {
ID, ID,
@ -84,12 +83,10 @@ export type TldrawFileParseError =
export function parseTldrawJsonFile({ export function parseTldrawJsonFile({
config, config,
json, json,
userId,
instanceId, instanceId,
}: { }: {
config: TldrawEditorConfig config: TldrawEditorConfig
json: string json: string
userId: TLUserId
instanceId: TLInstanceId instanceId: TLInstanceId
}): Result<TLStore, TldrawFileParseError> { }): Result<TLStore, TldrawFileParseError> {
// first off, we parse .json file and check it matches the general shape of // first off, we parse .json file and check it matches the general shape of
@ -140,7 +137,7 @@ export function parseTldrawJsonFile({
// we should be able to validate them. if any of the records at this stage // we should be able to validate them. if any of the records at this stage
// are invalid, we don't open the file // are invalid, we don't open the file
try { try {
return Result.ok(config.createStore({ initialData: migrationResult.value, userId, instanceId })) return Result.ok(config.createStore({ initialData: migrationResult.value, instanceId }))
} catch (e) { } catch (e) {
// junk data in the records (they're not validated yet!) could cause the // junk data in the records (they're not validated yet!) could cause the
// migrations to crash. We treat any throw from a migration as an // migrations to crash. We treat any throw from a migration as an
@ -211,7 +208,6 @@ export async function parseAndLoadDocument(
config: new TldrawEditorConfig(), config: new TldrawEditorConfig(),
json: document, json: document,
instanceId: app.instanceId, instanceId: app.instanceId,
userId: app.userId,
}) })
if (!parseFileResult.ok) { if (!parseFileResult.ok) {
let description let description

View file

@ -1,4 +1,4 @@
import { createCustomShapeId, TldrawEditorConfig, TLInstance, TLUser } from '@tldraw/editor' import { createCustomShapeId, TldrawEditorConfig, TLInstance } from '@tldraw/editor'
import { MigrationFailureReason, UnknownRecord } from '@tldraw/tlstore' import { MigrationFailureReason, UnknownRecord } from '@tldraw/tlstore'
import { assert } from '@tldraw/utils' import { assert } from '@tldraw/utils'
import { parseTldrawJsonFile as _parseTldrawJsonFile, TldrawFile } from '../lib/file' import { parseTldrawJsonFile as _parseTldrawJsonFile, TldrawFile } from '../lib/file'
@ -7,7 +7,6 @@ const parseTldrawJsonFile = (config: TldrawEditorConfig, json: string) =>
_parseTldrawJsonFile({ _parseTldrawJsonFile({
config, config,
json, json,
userId: TLUser.createCustomId('user'),
instanceId: TLInstance.createCustomId('instance'), instanceId: TLInstance.createCustomId('instance'),
}) })
@ -22,7 +21,7 @@ describe('parseTldrawJsonFile', () => {
expect(result.error.type).toBe('notATldrawFile') expect(result.error.type).toBe('notATldrawFile')
}) })
it('returns an error if the file doesnt look like a tldraw file', () => { it("returns an error if the file doesn't look like a tldraw file", () => {
const result = parseTldrawJsonFile( const result = parseTldrawJsonFile(
new TldrawEditorConfig(), new TldrawEditorConfig(),
JSON.stringify({ not: 'a tldraw file' }) JSON.stringify({ not: 'a tldraw file' })

View file

@ -1,12 +1,7 @@
import { Canvas, TldrawEditor, TldrawEditorConfig, TldrawEditorProps } from '@tldraw/editor' import { Canvas, TldrawEditor, TldrawEditorConfig, TldrawEditorProps } from '@tldraw/editor'
import { import { DEFAULT_DOCUMENT_NAME, TAB_ID, useLocalSyncClient } from '@tldraw/tlsync-client'
DEFAULT_DOCUMENT_NAME,
TAB_ID,
getUserData,
useLocalSyncClient,
} from '@tldraw/tlsync-client'
import { ContextMenu, TldrawUi, TldrawUiContextProviderProps } from '@tldraw/ui' import { ContextMenu, TldrawUi, TldrawUiContextProviderProps } from '@tldraw/ui'
import { useEffect, useState } from 'react' import { useMemo } from 'react'
/** @public */ /** @public */
export function Tldraw( export function Tldraw(
@ -28,31 +23,16 @@ export function Tldraw(
...rest ...rest
} = props } = props
const [_config, _setConfig] = useState(() => config ?? new TldrawEditorConfig()) const _config = useMemo(() => config ?? new TldrawEditorConfig(), [config])
useEffect(() => {
_setConfig(config ?? new TldrawEditorConfig())
}, [config])
const userData = getUserData()
const userId = props.userId ?? userData.id
const syncedStore = useLocalSyncClient({ const syncedStore = useLocalSyncClient({
instanceId, instanceId,
userId,
config: _config, config: _config,
universalPersistenceKey: persistenceKey, universalPersistenceKey: persistenceKey,
}) })
return ( return (
<TldrawEditor <TldrawEditor {...rest} instanceId={instanceId} store={syncedStore} config={_config}>
{...rest}
instanceId={instanceId}
userId={userId}
store={syncedStore}
config={_config}
>
<TldrawUi {...rest}> <TldrawUi {...rest}>
<ContextMenu> <ContextMenu>
<Canvas /> <Canvas />

View file

@ -87,6 +87,13 @@ export function createCustomShapeId(id: string): TLShapeId;
// @internal (undocumented) // @internal (undocumented)
export function createIntegrityChecker(store: TLStore): () => void; export function createIntegrityChecker(store: TLStore): () => void;
// @internal (undocumented)
export const createPresenceStateDerivation: ($user: Signal<{
id: string;
color: string;
name: string;
}>) => (store: TLStore) => Signal<null | TLInstancePresence>;
// @public (undocumented) // @public (undocumented)
export function createShapeId(): TLShapeId; export function createShapeId(): TLShapeId;
@ -107,7 +114,6 @@ export function createShapeValidator<Type extends string, Props extends object>(
// @public // @public
export function createTLSchema<T extends TLUnknownShape>(opts?: { export function createTLSchema<T extends TLUnknownShape>(opts?: {
customShapes?: { [K in T["type"]]: CustomShapeInfo<T>; } | undefined; customShapes?: { [K in T["type"]]: CustomShapeInfo<T>; } | undefined;
derivePresenceState?: ((store: TLStore) => Signal<null | TLInstancePresence>) | undefined;
}): StoreSchema<TLRecord, TLStoreProps>; }): StoreSchema<TLRecord, TLStoreProps>;
// @public (undocumented) // @public (undocumented)
@ -119,9 +125,6 @@ export const cursorValidator: T.Validator<TLCursor>;
// @internal (undocumented) // @internal (undocumented)
export const dashValidator: T.Validator<"dashed" | "dotted" | "draw" | "solid">; export const dashValidator: T.Validator<"dashed" | "dotted" | "draw" | "solid">;
// @internal (undocumented)
export const defaultDerivePresenceState: (store: TLStore) => Signal<null | TLInstancePresence>;
// @public (undocumented) // @public (undocumented)
export const documentTypeValidator: T.Validator<TLDocument>; export const documentTypeValidator: T.Validator<TLDocument>;
@ -351,6 +354,9 @@ export const geoShapeTypeValidator: T.Validator<TLGeoShape>;
// @internal (undocumented) // @internal (undocumented)
export const geoValidator: T.Validator<"arrow-down" | "arrow-left" | "arrow-right" | "arrow-up" | "check-box" | "diamond" | "ellipse" | "hexagon" | "octagon" | "oval" | "pentagon" | "rectangle" | "rhombus-2" | "rhombus" | "star" | "trapezoid" | "triangle" | "x-box">; export const geoValidator: T.Validator<"arrow-down" | "arrow-left" | "arrow-right" | "arrow-up" | "check-box" | "diamond" | "ellipse" | "hexagon" | "octagon" | "oval" | "pentagon" | "rectangle" | "rhombus-2" | "rhombus" | "star" | "trapezoid" | "triangle" | "x-box">;
// @public (undocumented)
export function getDefaultTranslationLocale(): TLTranslationLocale;
// @public (undocumented) // @public (undocumented)
export const groupShapeTypeMigrations: Migrations; export const groupShapeTypeMigrations: Migrations;
@ -432,6 +438,9 @@ export const pageTypeValidator: T.Validator<TLPage>;
// @internal (undocumented) // @internal (undocumented)
export const parentIdValidator: T.Validator<TLParentId>; export const parentIdValidator: T.Validator<TLParentId>;
// @public (undocumented)
export const pointerTypeValidator: T.Validator<TLPointer>;
// @internal (undocumented) // @internal (undocumented)
export const rootShapeTypeMigrations: Migrations; export const rootShapeTypeMigrations: Migrations;
@ -970,7 +979,7 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
// (undocumented) // (undocumented)
exportBackground: boolean; exportBackground: boolean;
// (undocumented) // (undocumented)
followingUserId: null | TLUserId; followingUserId: null | string;
// (undocumented) // (undocumented)
isDebugMode: boolean; isDebugMode: boolean;
// (undocumented) // (undocumented)
@ -984,13 +993,11 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
// (undocumented) // (undocumented)
scribble: null | TLScribble; scribble: null | TLScribble;
// (undocumented) // (undocumented)
userId: TLUserId;
// (undocumented)
zoomBrush: Box2dModel | null; zoomBrush: Box2dModel | null;
} }
// @public (undocumented) // @public (undocumented)
export const TLInstance: RecordType<TLInstance, "currentPageId" | "userId">; export const TLInstance: RecordType<TLInstance, "currentPageId">;
// @public (undocumented) // @public (undocumented)
export type TLInstanceId = ID<TLInstance>; export type TLInstanceId = ID<TLInstance>;
@ -1047,7 +1054,7 @@ export interface TLInstancePresence extends BaseRecord<'instance_presence', TLIn
rotation: number; rotation: number;
}; };
// (undocumented) // (undocumented)
followingUserId: null | TLUserId; followingUserId: null | string;
// (undocumented) // (undocumented)
instanceId: TLInstanceId; instanceId: TLInstanceId;
// (undocumented) // (undocumented)
@ -1059,13 +1066,13 @@ export interface TLInstancePresence extends BaseRecord<'instance_presence', TLIn
// (undocumented) // (undocumented)
selectedIds: TLShapeId[]; selectedIds: TLShapeId[];
// (undocumented) // (undocumented)
userId: TLUserId; userId: string;
// (undocumented) // (undocumented)
userName: string; userName: string;
} }
// @public (undocumented) // @public (undocumented)
export const TLInstancePresence: RecordType<TLInstancePresence, "brush" | "camera" | "color" | "currentPageId" | "cursor" | "followingUserId" | "instanceId" | "lastActivityTimestamp" | "screenBounds" | "scribble" | "selectedIds" | "userId" | "userName">; export const TLInstancePresence: RecordType<TLInstancePresence, "currentPageId" | "instanceId" | "userId" | "userName">;
// @public (undocumented) // @public (undocumented)
export type TLInstancePropsForNextShape = Pick<TLShapeProps, TLStyleType>; export type TLInstancePropsForNextShape = Pick<TLShapeProps, TLStyleType>;
@ -1133,8 +1140,27 @@ export type TLPageId = ID<TLPage>;
// @public (undocumented) // @public (undocumented)
export type TLParentId = TLPageId | TLShapeId; export type TLParentId = TLPageId | TLShapeId;
// @public
export interface TLPointer extends BaseRecord<'pointer', TLPointerId> {
// (undocumented)
lastActivityTimestamp: number;
// (undocumented)
x: number;
// (undocumented)
y: number;
}
// @public (undocumented) // @public (undocumented)
export type TLRecord = TLAsset | TLCamera | TLDocument | TLInstance | TLInstancePageState | TLInstancePresence | TLPage | TLShape | TLUser | TLUserDocument | TLUserPresence; export const TLPointer: RecordType<TLPointer, never>;
// @public (undocumented)
export const TLPOINTER_ID: TLPointerId;
// @public (undocumented)
export type TLPointerId = ID<TLPointer>;
// @public (undocumented)
export type TLRecord = TLAsset | TLCamera | TLDocument | TLInstance | TLInstancePageState | TLInstancePresence | TLPage | TLPointer | TLShape | TLUserDocument;
// @public (undocumented) // @public (undocumented)
export type TLScribble = { export type TLScribble = {
@ -1192,7 +1218,6 @@ export type TLStore = Store<TLRecord, TLStoreProps>;
// @public (undocumented) // @public (undocumented)
export type TLStoreProps = { export type TLStoreProps = {
userId: TLUserId;
instanceId: TLInstanceId; instanceId: TLInstanceId;
documentId: typeof TLDOCUMENT_ID; documentId: typeof TLDOCUMENT_ID;
}; };
@ -1262,21 +1287,8 @@ export type TLUiColorType = SetValue<typeof TL_UI_COLOR_TYPES>;
// @public // @public
export type TLUnknownShape = TLBaseShape<string, object>; export type TLUnknownShape = TLBaseShape<string, object>;
// @public
export interface TLUser extends BaseRecord<'user', TLUserId> {
// (undocumented)
locale: string;
// (undocumented)
name: string;
}
// @public (undocumented)
export const TLUser: RecordType<TLUser, never>;
// @public // @public
export interface TLUserDocument extends BaseRecord<'user_document', TLUserDocumentId> { export interface TLUserDocument extends BaseRecord<'user_document', TLUserDocumentId> {
// (undocumented)
isDarkMode: boolean;
// (undocumented) // (undocumented)
isGridMode: boolean; isGridMode: boolean;
// (undocumented) // (undocumented)
@ -1289,41 +1301,14 @@ export interface TLUserDocument extends BaseRecord<'user_document', TLUserDocume
lastUpdatedPageId: ID<TLPage> | null; lastUpdatedPageId: ID<TLPage> | null;
// (undocumented) // (undocumented)
lastUsedTabId: ID<TLInstance> | null; lastUsedTabId: ID<TLInstance> | null;
// (undocumented)
userId: TLUserId;
} }
// @public (undocumented) // @public (undocumented)
export const TLUserDocument: RecordType<TLUserDocument, "userId">; export const TLUserDocument: RecordType<TLUserDocument, never>;
// @public (undocumented) // @public (undocumented)
export type TLUserDocumentId = ID<TLUserDocument>; export type TLUserDocumentId = ID<TLUserDocument>;
// @public (undocumented)
export type TLUserId = ID<TLUser>;
// @public (undocumented)
export interface TLUserPresence extends BaseRecord<'user_presence', TLUserPresenceId> {
// (undocumented)
color: string;
// (undocumented)
cursor: Vec2dModel;
// (undocumented)
lastActivityTimestamp: number;
// (undocumented)
lastUsedInstanceId: null | TLInstanceId;
// (undocumented)
userId: TLUserId;
// (undocumented)
viewportPageBounds: Box2dModel;
}
// @public (undocumented)
export const TLUserPresence: RecordType<TLUserPresence, "userId">;
// @public (undocumented)
export type TLUserPresenceId = ID<TLUserPresence>;
// @public (undocumented) // @public (undocumented)
export type TLVerticalAlignType = SetValue<typeof TL_VERTICAL_ALIGN_TYPES>; export type TLVerticalAlignType = SetValue<typeof TL_VERTICAL_ALIGN_TYPES>;
@ -1354,27 +1339,12 @@ export type TLVideoShapeProps = {
// @public (undocumented) // @public (undocumented)
export const uiColorTypeValidator: T.Validator<"accent" | "black" | "laser" | "muted-1" | "selection-fill" | "selection-stroke" | "white">; export const uiColorTypeValidator: T.Validator<"accent" | "black" | "laser" | "muted-1" | "selection-fill" | "selection-stroke" | "white">;
// @internal (undocumented)
export const USER_COLORS: string[];
// @public (undocumented) // @public (undocumented)
export const userDocumentTypeMigrations: Migrations; export const userDocumentTypeMigrations: Migrations;
// @public (undocumented) // @public (undocumented)
export const userDocumentTypeValidator: T.Validator<TLUserDocument>; export const userDocumentTypeValidator: T.Validator<TLUserDocument>;
// @internal (undocumented)
export const userIdValidator: T.Validator<TLUserId>;
// @public (undocumented)
export const userPresenceTypeMigrations: Migrations;
// @public (undocumented)
export const userPresenceTypeValidator: T.Validator<TLUserPresence>;
// @public (undocumented)
export const userTypeValidator: T.Validator<TLUser>;
// @public (undocumented) // @public (undocumented)
export interface Vec2dModel { export interface Vec2dModel {
// (undocumented) // (undocumented)

View file

@ -5,10 +5,9 @@ import { TLInstance } from './records/TLInstance'
import { TLInstancePageState } from './records/TLInstancePageState' import { TLInstancePageState } from './records/TLInstancePageState'
import { TLInstancePresence } from './records/TLInstancePresence' import { TLInstancePresence } from './records/TLInstancePresence'
import { TLPage } from './records/TLPage' import { TLPage } from './records/TLPage'
import { TLPointer } from './records/TLPointer'
import { TLShape } from './records/TLShape' import { TLShape } from './records/TLShape'
import { TLUser } from './records/TLUser'
import { TLUserDocument } from './records/TLUserDocument' import { TLUserDocument } from './records/TLUserDocument'
import { TLUserPresence } from './records/TLUserPresence'
/** @public */ /** @public */
export type TLRecord = export type TLRecord =
@ -19,7 +18,6 @@ export type TLRecord =
| TLInstancePageState | TLInstancePageState
| TLPage | TLPage
| TLShape | TLShape
| TLUser
| TLUserDocument | TLUserDocument
| TLUserPresence
| TLInstancePresence | TLInstancePresence
| TLPointer

View file

@ -6,9 +6,8 @@ import { TLDOCUMENT_ID, TLDocument } from './records/TLDocument'
import { TLInstance, TLInstanceId } from './records/TLInstance' import { TLInstance, TLInstanceId } from './records/TLInstance'
import { TLInstancePageState } from './records/TLInstancePageState' import { TLInstancePageState } from './records/TLInstancePageState'
import { TLPage } from './records/TLPage' import { TLPage } from './records/TLPage'
import { TLUser, TLUserId } from './records/TLUser' import { TLPOINTER_ID, TLPointer } from './records/TLPointer'
import { TLUserDocument } from './records/TLUserDocument' import { TLUserDocument } from './records/TLUserDocument'
import { TLUserPresence } from './records/TLUserPresence'
function sortByIndex<T extends { index: string }>(a: T, b: T) { function sortByIndex<T extends { index: string }>(a: T, b: T) {
if (a.index < b.index) { if (a.index < b.index) {
@ -19,22 +18,6 @@ function sortByIndex<T extends { index: string }>(a: T, b: T) {
return 0 return 0
} }
/** @internal */
export const USER_COLORS = [
'#FF802B',
'#EC5E41',
'#F2555A',
'#F04F88',
'#E34BA9',
'#BD54C6',
'#9D5BD2',
'#7B66DC',
'#02B1CC',
'#11B3A3',
'#39B178',
'#55B467',
]
function redactRecordForErrorReporting(record: any) { function redactRecordForErrorReporting(record: any) {
if (record.typeName === 'asset') { if (record.typeName === 'asset') {
if ('src' in record) { if ('src' in record) {
@ -55,7 +38,6 @@ export type TLStoreSnapshot = StoreSnapshot<TLRecord>
/** @public */ /** @public */
export type TLStoreProps = { export type TLStoreProps = {
userId: TLUserId
instanceId: TLInstanceId instanceId: TLInstanceId
documentId: typeof TLDOCUMENT_ID documentId: typeof TLDOCUMENT_ID
} }
@ -90,10 +72,6 @@ export const onValidationFailure: StoreSchemaOptions<
throw error throw error
} }
function getRandomColor() {
return USER_COLORS[Math.floor(Math.random() * USER_COLORS.length)]
}
function getDefaultPages() { function getDefaultPages() {
return [TLPage.create({ name: 'Page 1', index: 'a1' })] return [TLPage.create({ name: 'Page 1', index: 'a1' })]
} }
@ -101,32 +79,32 @@ function getDefaultPages() {
/** @internal */ /** @internal */
export function createIntegrityChecker(store: TLStore): () => void { export function createIntegrityChecker(store: TLStore): () => void {
const $pages = store.query.records('page') const $pages = store.query.records('page')
const $userDocumentSettings = store.query.record('user_document', () => ({ const $userDocumentSettings = store.query.record('user_document')
userId: { eq: store.props.userId },
}))
const $instanceState = store.query.record('instance', () => ({ const $instanceState = store.query.record('instance', () => ({
id: { eq: store.props.instanceId }, id: { eq: store.props.instanceId },
})) }))
const $user = store.query.record('user', () => ({ id: { eq: store.props.userId } }))
const $userPresences = store.query.records('user_presence')
const $instancePageStates = store.query.records('instance_page_state') const $instancePageStates = store.query.records('instance_page_state')
const ensureStoreIsUsable = (): void => { const ensureStoreIsUsable = (): void => {
const { userId, instanceId: tabId } = store.props const { instanceId: tabId } = store.props
// make sure we have exactly one document // make sure we have exactly one document
if (!store.has(TLDOCUMENT_ID)) { if (!store.has(TLDOCUMENT_ID)) {
store.put([TLDocument.create({ id: TLDOCUMENT_ID })]) store.put([TLDocument.create({ id: TLDOCUMENT_ID })])
return ensureStoreIsUsable() return ensureStoreIsUsable()
} }
if (!store.has(TLPOINTER_ID)) {
store.put([TLPointer.create({ id: TLPOINTER_ID })])
return ensureStoreIsUsable()
}
// make sure we have document state for the current user // make sure we have document state for the current user
const userDocumentSettings = $userDocumentSettings.value const userDocumentSettings = $userDocumentSettings.value
if (!userDocumentSettings) { if (!userDocumentSettings) {
store.put([TLUserDocument.create({ userId })]) store.put([TLUserDocument.create({})])
return ensureStoreIsUsable() return ensureStoreIsUsable()
} }
@ -151,7 +129,6 @@ export function createIntegrityChecker(store: TLStore): () => void {
store.put([ store.put([
TLInstance.create({ TLInstance.create({
id: tabId, id: tabId,
userId,
currentPageId, currentPageId,
propsForNextShape, propsForNextShape,
exportBackground: true, exportBackground: true,
@ -169,22 +146,6 @@ export function createIntegrityChecker(store: TLStore): () => void {
return ensureStoreIsUsable() return ensureStoreIsUsable()
} }
// make sure we have a user state record for the current user
if (!$user.value) {
store.put([TLUser.create({ id: userId })])
return ensureStoreIsUsable()
}
const userPresences = $userPresences.value.filter((r) => r.userId === userId)
if (userPresences.length === 0) {
store.put([TLUserPresence.create({ userId, color: getRandomColor() })])
return ensureStoreIsUsable()
} else if (userPresences.length > 1) {
// make sure we don't duplicate user presences
store.remove(userPresences.slice(1).map((r) => r.id))
}
// make sure each page has a instancePageState and camera
for (const page of pages) { for (const page of pages) {
const instancePageStates = $instancePageStates.value.filter( const instancePageStates = $instancePageStates.value.filter(
(tps) => tps.pageId === page.id && tps.instanceId === tabId (tps) => tps.pageId === page.id && tps.instanceId === tabId

View file

@ -0,0 +1,58 @@
import { Signal, computed } from 'signia'
import { TLStore } from './TLStore'
import { TLInstancePresence } from './records/TLInstancePresence'
/** @internal */
export const createPresenceStateDerivation =
($user: Signal<{ id: string; color: string; name: string }>) =>
(store: TLStore): Signal<TLInstancePresence | null> => {
const $instance = store.query.record('instance', () => ({
id: { eq: store.props.instanceId },
}))
const $pageState = store.query.record('instance_page_state', () => ({
instanceId: { eq: store.props.instanceId },
pageId: { eq: $instance.value?.currentPageId ?? ('' as any) },
}))
const $camera = store.query.record('camera', () => ({
id: { eq: $pageState.value?.cameraId ?? ('' as any) },
}))
const $pointer = store.query.record('pointer')
return computed('instancePresence', () => {
const pageState = $pageState.value
const instance = $instance.value
const camera = $camera.value
const pointer = $pointer.value
const user = $user.value
if (!pageState || !instance || !camera || !pointer || !user) {
return null
}
return TLInstancePresence.create({
id: TLInstancePresence.createCustomId(store.props.instanceId),
instanceId: store.props.instanceId,
selectedIds: pageState.selectedIds,
brush: instance.brush,
scribble: instance.scribble,
userId: user.id,
userName: user.name,
followingUserId: instance.followingUserId,
camera: {
x: camera.x,
y: camera.y,
z: camera.z,
},
color: user.color,
currentPageId: instance.currentPageId,
cursor: {
x: pointer.x,
y: pointer.y,
rotation: instance.cursor.rotation,
type: instance.cursor.type,
},
lastActivityTimestamp: pointer.lastActivityTimestamp,
screenBounds: instance.screenBounds,
})
})
}

View file

@ -1,9 +1,7 @@
import { Migrations, StoreSchema, createRecordType, defineMigrations } from '@tldraw/tlstore' import { Migrations, StoreSchema, createRecordType, defineMigrations } from '@tldraw/tlstore'
import { T } from '@tldraw/tlvalidate' import { T } from '@tldraw/tlvalidate'
import { Signal } from 'signia'
import { TLRecord } from './TLRecord' import { TLRecord } from './TLRecord'
import { TLStore, TLStoreProps, createIntegrityChecker, onValidationFailure } from './TLStore' import { TLStoreProps, createIntegrityChecker, onValidationFailure } from './TLStore'
import { defaultDerivePresenceState } from './defaultDerivePresenceState'
import { TLAsset } from './records/TLAsset' import { TLAsset } from './records/TLAsset'
import { TLCamera } from './records/TLCamera' import { TLCamera } from './records/TLCamera'
import { TLDocument } from './records/TLDocument' import { TLDocument } from './records/TLDocument'
@ -11,10 +9,9 @@ import { TLInstance } from './records/TLInstance'
import { TLInstancePageState } from './records/TLInstancePageState' import { TLInstancePageState } from './records/TLInstancePageState'
import { TLInstancePresence } from './records/TLInstancePresence' import { TLInstancePresence } from './records/TLInstancePresence'
import { TLPage } from './records/TLPage' import { TLPage } from './records/TLPage'
import { TLPointer } from './records/TLPointer'
import { TLShape, TLUnknownShape, rootShapeTypeMigrations } from './records/TLShape' import { TLShape, TLUnknownShape, rootShapeTypeMigrations } from './records/TLShape'
import { TLUser } from './records/TLUser'
import { TLUserDocument } from './records/TLUserDocument' import { TLUserDocument } from './records/TLUserDocument'
import { TLUserPresence } from './records/TLUserPresence'
import { storeMigrations } from './schema' import { storeMigrations } from './schema'
import { arrowShapeTypeMigrations, arrowShapeTypeValidator } from './shapes/TLArrowShape' import { arrowShapeTypeMigrations, arrowShapeTypeValidator } from './shapes/TLArrowShape'
import { bookmarkShapeTypeMigrations, bookmarkShapeTypeValidator } from './shapes/TLBookmarkShape' import { bookmarkShapeTypeMigrations, bookmarkShapeTypeValidator } from './shapes/TLBookmarkShape'
@ -61,10 +58,9 @@ type CustomShapeInfo<T extends TLUnknownShape> = {
export function createTLSchema<T extends TLUnknownShape>( export function createTLSchema<T extends TLUnknownShape>(
opts = {} as { opts = {} as {
customShapes?: { [K in T['type']]: CustomShapeInfo<T> } customShapes?: { [K in T['type']]: CustomShapeInfo<T> }
derivePresenceState?: (store: TLStore) => Signal<TLInstancePresence | null>
} }
) { ) {
const { customShapes = {}, derivePresenceState } = opts const { customShapes = {} } = opts
const defaultShapeSubTypeEntries = Object.entries(DEFAULT_SHAPES) as [ const defaultShapeSubTypeEntries = Object.entries(DEFAULT_SHAPES) as [
TLShape['type'], TLShape['type'],
@ -76,7 +72,7 @@ export function createTLSchema<T extends TLUnknownShape>(
CustomShapeInfo<T> CustomShapeInfo<T>
][] ][]
// Create a shape record that incorporates the defeault shapes and any custom shapes // Create a shape record that incorporates the default shapes and any custom shapes
// into its subtype migrations and validators, so that we can migrate any new custom // into its subtype migrations and validators, so that we can migrate any new custom
// subtypes. Note that migrations AND validators for custom shapes are optional. If // subtypes. Note that migrations AND validators for custom shapes are optional. If
// not provided, we use an empty migrations set and/or an "any" validator. // not provided, we use an empty migrations set and/or an "any" validator.
@ -119,16 +115,14 @@ export function createTLSchema<T extends TLUnknownShape>(
instance_page_state: TLInstancePageState, instance_page_state: TLInstancePageState,
page: TLPage, page: TLPage,
shape: shapeRecord, shape: shapeRecord,
user: TLUser,
user_document: TLUserDocument, user_document: TLUserDocument,
user_presence: TLUserPresence,
instance_presence: TLInstancePresence, instance_presence: TLInstancePresence,
pointer: TLPointer,
}, },
{ {
snapshotMigrations: storeMigrations, snapshotMigrations: storeMigrations,
onValidationFailure, onValidationFailure,
createIntegrityChecker: createIntegrityChecker, createIntegrityChecker: createIntegrityChecker,
derivePresenceState: derivePresenceState ?? defaultDerivePresenceState,
} }
) )
} }

View file

@ -1,57 +0,0 @@
import { Signal, computed } from 'signia'
import { TLStore } from './TLStore'
import { TLInstancePresence } from './records/TLInstancePresence'
/** @internal */
export const defaultDerivePresenceState = (store: TLStore): Signal<TLInstancePresence | null> => {
const $instance = store.query.record('instance', () => ({
id: { eq: store.props.instanceId },
}))
const $user = store.query.record('user', () => ({ id: { eq: store.props.userId } }))
const $userPresence = store.query.record('user_presence', () => ({
userId: { eq: store.props.userId },
}))
const $pageState = store.query.record('instance_page_state', () => ({
instanceId: { eq: store.props.instanceId },
pageId: { eq: $instance.value?.currentPageId ?? ('' as any) },
}))
const $camera = store.query.record('camera', () => ({
id: { eq: $pageState.value?.cameraId ?? ('' as any) },
}))
return computed('instancePresence', () => {
const pageState = $pageState.value
const instance = $instance.value
const user = $user.value
const userPresence = $userPresence.value
const camera = $camera.value
if (!pageState || !instance || !user || !userPresence || !camera) {
return null
}
return TLInstancePresence.create({
id: TLInstancePresence.createCustomId(store.props.instanceId),
instanceId: store.props.instanceId,
selectedIds: pageState.selectedIds,
brush: instance.brush,
scribble: instance.scribble,
userId: store.props.userId,
userName: user.name,
followingUserId: instance.followingUserId,
camera: {
x: camera.x,
y: camera.y,
z: camera.z,
},
color: userPresence.color,
currentPageId: instance.currentPageId,
cursor: {
x: userPresence.cursor.x,
y: userPresence.cursor.y,
rotation: instance.cursor.rotation,
type: instance.cursor.type,
},
lastActivityTimestamp: userPresence.lastActivityTimestamp,
screenBounds: instance.screenBounds,
})
})
}

View file

@ -1,6 +1,5 @@
export { type TLRecord } from './TLRecord' export { type TLRecord } from './TLRecord'
export { export {
USER_COLORS,
createIntegrityChecker, createIntegrityChecker,
onValidationFailure, onValidationFailure,
type TLStore, type TLStore,
@ -24,8 +23,8 @@ export {
type TLVideoAsset, type TLVideoAsset,
} from './assets/TLVideoAsset' } from './assets/TLVideoAsset'
export { createAssetValidator, type TLBaseAsset } from './assets/asset-validation' export { createAssetValidator, type TLBaseAsset } from './assets/asset-validation'
export { createPresenceStateDerivation } from './createPresenceStateDerivation'
export { createTLSchema } from './createTLSchema' export { createTLSchema } from './createTLSchema'
export { defaultDerivePresenceState } from './defaultDerivePresenceState'
export { CLIENT_FIXUP_SCRIPT, fixupRecord } from './fixup' export { CLIENT_FIXUP_SCRIPT, fixupRecord } from './fixup'
export { type Box2dModel, type Vec2dModel } from './geometry-types' export { type Box2dModel, type Vec2dModel } from './geometry-types'
export { export {
@ -53,6 +52,12 @@ export {
} from './records/TLInstancePageState' } from './records/TLInstancePageState'
export { TLInstancePresence } from './records/TLInstancePresence' export { TLInstancePresence } from './records/TLInstancePresence'
export { TLPage, pageTypeValidator, type TLPageId } from './records/TLPage' export { TLPage, pageTypeValidator, type TLPageId } from './records/TLPage'
export {
TLPOINTER_ID,
TLPointer,
pointerTypeValidator,
type TLPointerId,
} from './records/TLPointer'
export { export {
createCustomShapeId, createCustomShapeId,
createShapeId, createShapeId,
@ -69,19 +74,12 @@ export {
type TLShapeProps, type TLShapeProps,
type TLUnknownShape, type TLUnknownShape,
} from './records/TLShape' } from './records/TLShape'
export { TLUser, userTypeValidator, type TLUserId } from './records/TLUser'
export { export {
TLUserDocument, TLUserDocument,
userDocumentTypeMigrations, userDocumentTypeMigrations,
userDocumentTypeValidator, userDocumentTypeValidator,
type TLUserDocumentId, type TLUserDocumentId,
} from './records/TLUserDocument' } from './records/TLUserDocument'
export {
TLUserPresence,
userPresenceTypeMigrations,
userPresenceTypeValidator,
type TLUserPresenceId,
} from './records/TLUserPresence'
export { storeMigrations } from './schema' export { storeMigrations } from './schema'
export { export {
TL_ARROW_TERMINAL_TYPE, TL_ARROW_TERMINAL_TYPE,
@ -218,6 +216,7 @@ export {
type TLStyleType, type TLStyleType,
type TLVerticalAlignType, type TLVerticalAlignType,
} from './style-types' } from './style-types'
export { getDefaultTranslationLocale } from './translations'
export { export {
TL_CURSOR_TYPES, TL_CURSOR_TYPES,
TL_HANDLE_TYPES, TL_HANDLE_TYPES,
@ -255,5 +254,4 @@ export {
shapeIdValidator, shapeIdValidator,
sizeValidator, sizeValidator,
splineValidator, splineValidator,
userIdValidator,
} from './validation' } from './validation'

View file

@ -3,13 +3,12 @@ import { structuredClone } from '@tldraw/utils'
import fs from 'fs' import fs from 'fs'
import { imageAssetMigrations } from './assets/TLImageAsset' import { imageAssetMigrations } from './assets/TLImageAsset'
import { videoAssetMigrations } from './assets/TLVideoAsset' import { videoAssetMigrations } from './assets/TLVideoAsset'
import { instanceTypeMigrations } from './records/TLInstance' import { instanceTypeMigrations, instanceTypeVersions } from './records/TLInstance'
import { instancePageStateMigrations } from './records/TLInstancePageState' import { instancePageStateMigrations } from './records/TLInstancePageState'
import { instancePresenceTypeMigrations } from './records/TLInstancePresence' import { instancePresenceTypeMigrations } from './records/TLInstancePresence'
import { rootShapeTypeMigrations, TLShape } from './records/TLShape' import { rootShapeTypeMigrations, TLShape } from './records/TLShape'
import { userDocumentTypeMigrations, userDocumentVersions } from './records/TLUserDocument' import { userDocumentTypeMigrations, userDocumentVersions } from './records/TLUserDocument'
import { userPresenceTypeMigrations } from './records/TLUserPresence' import { storeMigrations, storeVersions } from './schema'
import { storeMigrations } from './schema'
import { arrowShapeTypeMigrations } from './shapes/TLArrowShape' import { arrowShapeTypeMigrations } from './shapes/TLArrowShape'
import { bookmarkShapeTypeMigrations } from './shapes/TLBookmarkShape' import { bookmarkShapeTypeMigrations } from './shapes/TLBookmarkShape'
import { drawShapeTypeMigrations } from './shapes/TLDrawShape' import { drawShapeTypeMigrations } from './shapes/TLDrawShape'
@ -214,7 +213,7 @@ describe('Store removing Icon and Code shapes', () => {
} as any), } as any),
].map((shape) => [shape.id, shape]) ].map((shape) => [shape.id, shape])
) )
const fixed = storeMigrations.migrators[1].up(snapshot) const fixed = storeMigrations.migrators[storeVersions.RemoveCodeAndIconShapeTypes].up(snapshot)
expect(Object.entries(fixed)).toHaveLength(1) expect(Object.entries(fixed)).toHaveLength(1)
}) })
@ -230,7 +229,7 @@ describe('Store removing Icon and Code shapes', () => {
].map((shape) => [shape.id, shape]) ].map((shape) => [shape.id, shape])
) )
storeMigrations.migrators[1].down(snapshot) storeMigrations.migrators[storeVersions.RemoveCodeAndIconShapeTypes].down(snapshot)
expect(Object.entries(snapshot)).toHaveLength(1) expect(Object.entries(snapshot)).toHaveLength(1)
}) })
}) })
@ -560,17 +559,6 @@ describe('Adding followingUserId prop to instance', () => {
}) })
}) })
describe('Adding viewportPageBounds prop to user presence', () => {
const { up, down } = userPresenceTypeMigrations.migrators[1]
test('up works as expected', () => {
expect(up({})).toEqual({ viewportPageBounds: { x: 0, y: 0, w: 1, h: 1 } })
})
test('down works as expected', () => {
expect(down({ viewportPageBounds: { x: 1, y: 2, w: 3, h: 4 } })).toEqual({})
})
})
describe('Removing align=justify from propsForNextShape', () => { describe('Removing align=justify from propsForNextShape', () => {
const { up, down } = instanceTypeMigrations.migrators[7] const { up, down } = instanceTypeMigrations.migrators[7]
test('up works as expected', () => { test('up works as expected', () => {
@ -639,7 +627,7 @@ describe('Add crop=null to image shapes', () => {
}) })
describe('Adding instance_presence to the schema', () => { describe('Adding instance_presence to the schema', () => {
const { up, down } = storeMigrations.migrators[2] const { up, down } = storeMigrations.migrators[storeVersions.AddInstancePresenceType]
test('up works as expected', () => { test('up works as expected', () => {
expect(up({})).toEqual({}) expect(up({})).toEqual({})
@ -919,6 +907,103 @@ describe('Adds delay to scribble', () => {
}) })
}) })
describe('user config refactor', () => {
test('removes user and user_presence types from snapshots', () => {
const { up, down } =
storeMigrations.migrators[storeVersions.RemoveTLUserAndPresenceAndAddPointer]
const prevSnapshot = {
'user:123': {
id: 'user:123',
typeName: 'user',
},
'user_presence:123': {
id: 'user_presence:123',
typeName: 'user_presence',
},
'instance:123': {
id: 'instance:123',
typeName: 'instance',
},
}
const nextSnapshot = {
'instance:123': {
id: 'instance:123',
typeName: 'instance',
},
}
// up removes the user and user_presence types
expect(up(prevSnapshot)).toEqual(nextSnapshot)
// down cannot add them back so it should be a no-op
expect(
down({
...nextSnapshot,
'pointer:134': {
id: 'pointer:134',
typeName: 'pointer',
},
})
).toEqual(nextSnapshot)
})
test('removes userId from the instance state', () => {
const { up, down } = instanceTypeMigrations.migrators[instanceTypeVersions.RemoveUserId]
const prev = {
id: 'instance:123',
typeName: 'instance',
userId: 'user:123',
}
const next = {
id: 'instance:123',
typeName: 'instance',
}
expect(up(prev)).toEqual(next)
// it cannot be added back so it should add some meaningless id in there
// in practice, because we bumped the store version, this down migrator will never be used
expect(down(next)).toMatchInlineSnapshot(`
Object {
"id": "instance:123",
"typeName": "instance",
"userId": "user:none",
}
`)
})
test('removes userId and isDarkMode from TLUserDocument', () => {
const { up, down } =
userDocumentTypeMigrations.migrators[userDocumentVersions.RemoveUserIdAndIsDarkMode]
const prev = {
id: 'user_document:123',
typeName: 'user_document',
userId: 'user:123',
isDarkMode: false,
isGridMode: false,
}
const next = {
id: 'user_document:123',
typeName: 'user_document',
isGridMode: false,
}
expect(up(prev)).toEqual(next)
expect(down(next)).toMatchInlineSnapshot(`
Object {
"id": "user_document:123",
"isDarkMode": false,
"isGridMode": false,
"typeName": "user_document",
"userId": "user:none",
}
`)
})
})
/* --- PUT YOUR MIGRATIONS TESTS ABOVE HERE --- */ /* --- PUT YOUR MIGRATIONS TESTS ABOVE HERE --- */
for (const migrator of allMigrators) { for (const migrator of allMigrators) {

View file

@ -17,12 +17,10 @@ import {
pageIdValidator, pageIdValidator,
sizeValidator, sizeValidator,
splineValidator, splineValidator,
userIdValidator,
verticalAlignValidator, verticalAlignValidator,
} from '../validation' } from '../validation'
import { TLPageId } from './TLPage' import { TLPageId } from './TLPage'
import { TLShapeProps } from './TLShape' import { TLShapeProps } from './TLShape'
import { TLUserId } from './TLUser'
/** @public */ /** @public */
export type TLInstancePropsForNextShape = Pick<TLShapeProps, TLStyleType> export type TLInstancePropsForNextShape = Pick<TLShapeProps, TLStyleType>
@ -35,9 +33,8 @@ export type TLInstancePropsForNextShape = Pick<TLShapeProps, TLStyleType>
* @public * @public
*/ */
export interface TLInstance extends BaseRecord<'instance', TLInstanceId> { export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
userId: TLUserId
currentPageId: TLPageId currentPageId: TLPageId
followingUserId: TLUserId | null followingUserId: string | null
brush: Box2dModel | null brush: Box2dModel | null
propsForNextShape: TLInstancePropsForNextShape propsForNextShape: TLInstancePropsForNextShape
cursor: TLCursor cursor: TLCursor
@ -59,9 +56,8 @@ export const instanceTypeValidator: T.Validator<TLInstance> = T.model(
T.object({ T.object({
typeName: T.literal('instance'), typeName: T.literal('instance'),
id: idValidator<TLInstanceId>('instance'), id: idValidator<TLInstanceId>('instance'),
userId: userIdValidator,
currentPageId: pageIdValidator, currentPageId: pageIdValidator,
followingUserId: userIdValidator.nullable(), followingUserId: T.string.nullable(),
brush: T.boxModel.nullable(), brush: T.boxModel.nullable(),
propsForNextShape: T.object({ propsForNextShape: T.object({
color: colorValidator, color: colorValidator,
@ -101,11 +97,14 @@ const Versions = {
AddZoom: 8, AddZoom: 8,
AddVerticalAlign: 9, AddVerticalAlign: 9,
AddScribbleDelay: 10, AddScribbleDelay: 10,
RemoveUserId: 11,
} as const } as const
export { Versions as instanceTypeVersions }
/** @public */ /** @public */
export const instanceTypeMigrations = defineMigrations({ export const instanceTypeMigrations = defineMigrations({
currentVersion: Versions.AddScribbleDelay, currentVersion: Versions.RemoveUserId,
migrators: { migrators: {
[Versions.AddTransparentExportBgs]: { [Versions.AddTransparentExportBgs]: {
up: (instance: TLInstance) => { up: (instance: TLInstance) => {
@ -235,6 +234,14 @@ export const instanceTypeMigrations = defineMigrations({
return { ...instance } return { ...instance }
}, },
}, },
[Versions.RemoveUserId]: {
up: ({ userId: _, ...instance }: any) => {
return instance
},
down: (instance: TLInstance) => {
return { ...instance, userId: 'user:none' }
},
},
}, },
}) })
@ -244,7 +251,7 @@ export const TLInstance = createRecordType<TLInstance>('instance', {
validator: instanceTypeValidator, validator: instanceTypeValidator,
scope: 'instance', scope: 'instance',
}).withDefaultProperties( }).withDefaultProperties(
(): Omit<TLInstance, 'typeName' | 'id' | 'userId' | 'currentPageId'> => ({ (): Omit<TLInstance, 'typeName' | 'id' | 'currentPageId'> => ({
followingUserId: null, followingUserId: null,
propsForNextShape: { propsForNextShape: {
opacity: '1', opacity: '1',

View file

@ -2,16 +2,15 @@ import { BaseRecord, createRecordType, defineMigrations, ID } from '@tldraw/tlst
import { T } from '@tldraw/tlvalidate' import { T } from '@tldraw/tlvalidate'
import { Box2dModel } from '../geometry-types' import { Box2dModel } from '../geometry-types'
import { cursorTypeValidator, scribbleTypeValidator, TLCursor, TLScribble } from '../ui-types' import { cursorTypeValidator, scribbleTypeValidator, TLCursor, TLScribble } from '../ui-types'
import { idValidator, userIdValidator } from '../validation' import { idValidator } from '../validation'
import { TLInstanceId } from './TLInstance' import { TLInstanceId } from './TLInstance'
import { TLPageId } from './TLPage' import { TLPageId } from './TLPage'
import { TLShapeId } from './TLShape' import { TLShapeId } from './TLShape'
import { TLUserId } from './TLUser'
/** @public */ /** @public */
export interface TLInstancePresence extends BaseRecord<'instance_presence', TLInstancePresenceID> { export interface TLInstancePresence extends BaseRecord<'instance_presence', TLInstancePresenceID> {
instanceId: TLInstanceId instanceId: TLInstanceId
userId: TLUserId userId: string
userName: string userName: string
lastActivityTimestamp: number lastActivityTimestamp: number
color: string // can be any hex color color: string // can be any hex color
@ -21,7 +20,7 @@ export interface TLInstancePresence extends BaseRecord<'instance_presence', TLIn
brush: Box2dModel | null brush: Box2dModel | null
scribble: TLScribble | null scribble: TLScribble | null
screenBounds: Box2dModel screenBounds: Box2dModel
followingUserId: TLUserId | null followingUserId: string | null
cursor: { cursor: {
x: number x: number
y: number y: number
@ -41,10 +40,10 @@ export const instancePresenceTypeValidator: T.Validator<TLInstancePresence> = T.
instanceId: idValidator<TLInstanceId>('instance'), instanceId: idValidator<TLInstanceId>('instance'),
typeName: T.literal('instance_presence'), typeName: T.literal('instance_presence'),
id: idValidator<TLInstancePresenceID>('instance_presence'), id: idValidator<TLInstancePresenceID>('instance_presence'),
userId: userIdValidator, userId: T.string,
userName: T.string, userName: T.string,
lastActivityTimestamp: T.number, lastActivityTimestamp: T.number,
followingUserId: userIdValidator.nullable(), followingUserId: T.string.nullable(),
cursor: T.object({ cursor: T.object({
x: T.number, x: T.number,
y: T.number, y: T.number,
@ -95,4 +94,28 @@ export const TLInstancePresence = createRecordType<TLInstancePresence>('instance
migrations: instancePresenceTypeMigrations, migrations: instancePresenceTypeMigrations,
validator: instancePresenceTypeValidator, validator: instancePresenceTypeValidator,
scope: 'presence', scope: 'presence',
}) }).withDefaultProperties(() => ({
lastActivityTimestamp: 0,
followingUserId: null,
color: '#FF0000',
camera: {
x: 0,
y: 0,
z: 1,
},
cursor: {
x: 0,
y: 0,
type: 'default',
rotation: 0,
},
screenBounds: {
x: 0,
y: 0,
w: 1,
h: 1,
},
selectedIds: [],
brush: null,
scribble: null,
}))

View file

@ -0,0 +1,47 @@
import { BaseRecord, createRecordType, defineMigrations, ID } from '@tldraw/tlstore'
import { T } from '@tldraw/tlvalidate'
import { idValidator } from '../validation'
/**
* TLPointer
*
* @public
*/
export interface TLPointer extends BaseRecord<'pointer', TLPointerId> {
x: number
y: number
lastActivityTimestamp: number
}
/** @public */
export type TLPointerId = ID<TLPointer>
/** @public */
export const pointerTypeValidator: T.Validator<TLPointer> = T.model(
'pointer',
T.object({
typeName: T.literal('pointer'),
id: idValidator<TLPointerId>('pointer'),
x: T.number,
y: T.number,
lastActivityTimestamp: T.number,
})
)
/** @public */
export const TLPointer = createRecordType<TLPointer>('pointer', {
validator: pointerTypeValidator,
scope: 'instance',
}).withDefaultProperties(
(): Omit<TLPointer, 'id' | 'typeName'> => ({
x: 0,
y: 0,
lastActivityTimestamp: 0,
})
)
/** @public */
export const TLPOINTER_ID = TLPointer.createCustomId('pointer')
/** @public */
export const pointerTypeMigrations = defineMigrations({})

View file

@ -1,45 +0,0 @@
import { BaseRecord, createRecordType, defineMigrations, ID } from '@tldraw/tlstore'
import { T } from '@tldraw/tlvalidate'
import { getDefaultTranslationLocale } from '../translations'
import { userIdValidator } from '../validation'
/**
* A user of tldraw
*
* @public
*/
export interface TLUser extends BaseRecord<'user', TLUserId> {
name: string
locale: string
}
/** @public */
export type TLUserId = ID<TLUser>
/** @public */
export const userTypeValidator: T.Validator<TLUser> = T.model(
'user',
T.object({
typeName: T.literal('user'),
id: userIdValidator,
name: T.string,
locale: T.string,
})
)
/** @public */
export const TLUser = createRecordType<TLUser>('user', {
validator: userTypeValidator,
scope: 'instance',
}).withDefaultProperties((): Omit<TLUser, 'id' | 'typeName'> => {
let locale = 'en'
if (typeof window !== 'undefined' && window.navigator) {
locale = getDefaultTranslationLocale(window.navigator.languages)
}
return {
name: 'New User',
locale,
}
})
/** @public */
export const userTypeMigrations = defineMigrations({})

View file

@ -1,9 +1,8 @@
import { BaseRecord, createRecordType, defineMigrations, ID } from '@tldraw/tlstore' import { BaseRecord, createRecordType, defineMigrations, ID } from '@tldraw/tlstore'
import { T } from '@tldraw/tlvalidate' import { T } from '@tldraw/tlvalidate'
import { idValidator, instanceIdValidator, pageIdValidator, userIdValidator } from '../validation' import { idValidator, instanceIdValidator, pageIdValidator } from '../validation'
import { TLInstance } from './TLInstance' import { TLInstance } from './TLInstance'
import { TLPage } from './TLPage' import { TLPage } from './TLPage'
import { TLUserId } from './TLUser'
/** /**
* TLUserDocument * TLUserDocument
@ -13,10 +12,8 @@ import { TLUserId } from './TLUser'
* @public * @public
*/ */
export interface TLUserDocument extends BaseRecord<'user_document', TLUserDocumentId> { export interface TLUserDocument extends BaseRecord<'user_document', TLUserDocumentId> {
userId: TLUserId
isPenMode: boolean isPenMode: boolean
isGridMode: boolean isGridMode: boolean
isDarkMode: boolean
isMobileMode: boolean isMobileMode: boolean
isSnapMode: boolean isSnapMode: boolean
lastUpdatedPageId: ID<TLPage> | null lastUpdatedPageId: ID<TLPage> | null
@ -32,10 +29,8 @@ export const userDocumentTypeValidator: T.Validator<TLUserDocument> = T.model(
T.object({ T.object({
typeName: T.literal('user_document'), typeName: T.literal('user_document'),
id: idValidator<TLUserDocumentId>('user_document'), id: idValidator<TLUserDocumentId>('user_document'),
userId: userIdValidator,
isPenMode: T.boolean, isPenMode: T.boolean,
isGridMode: T.boolean, isGridMode: T.boolean,
isDarkMode: T.boolean,
isMobileMode: T.boolean, isMobileMode: T.boolean,
isSnapMode: T.boolean, isSnapMode: T.boolean,
lastUpdatedPageId: pageIdValidator.nullable(), lastUpdatedPageId: pageIdValidator.nullable(),
@ -47,11 +42,14 @@ export const Versions = {
AddSnapMode: 1, AddSnapMode: 1,
AddMissingIsMobileMode: 2, AddMissingIsMobileMode: 2,
RemoveIsReadOnly: 3, RemoveIsReadOnly: 3,
RemoveUserIdAndIsDarkMode: 4,
} as const } as const
export { Versions as userDocumentVersions }
/** @public */ /** @public */
export const userDocumentTypeMigrations = defineMigrations({ export const userDocumentTypeMigrations = defineMigrations({
currentVersion: Versions.RemoveIsReadOnly, currentVersion: Versions.RemoveUserIdAndIsDarkMode,
migrators: { migrators: {
[Versions.AddSnapMode]: { [Versions.AddSnapMode]: {
up: (userDocument: TLUserDocument) => { up: (userDocument: TLUserDocument) => {
@ -77,6 +75,18 @@ export const userDocumentTypeMigrations = defineMigrations({
return { ...userDocument, isReadOnly: false } return { ...userDocument, isReadOnly: false }
}, },
}, },
[Versions.RemoveUserIdAndIsDarkMode]: {
up: ({
userId: _,
isDarkMode: __,
...userDocument
}: TLUserDocument & { userId: string; isDarkMode: boolean }) => {
return userDocument
},
down: (userDocument: TLUserDocument) => {
return { ...userDocument, userId: 'user:none', isDarkMode: false }
},
},
}, },
}) })
/* STEP 4: Add your changes to the record type */ /* STEP 4: Add your changes to the record type */
@ -92,12 +102,9 @@ export const TLUserDocument = createRecordType<TLUserDocument>('user_document',
/* STEP 6: Add any new default values for properties here */ /* STEP 6: Add any new default values for properties here */
isPenMode: false, isPenMode: false,
isGridMode: false, isGridMode: false,
isDarkMode: false,
isMobileMode: false, isMobileMode: false,
isSnapMode: false, isSnapMode: false,
lastUpdatedPageId: null, lastUpdatedPageId: null,
lastUsedTabId: null, lastUsedTabId: null,
}) })
) )
export { Versions as userDocumentVersions }

View file

@ -1,69 +0,0 @@
import { BaseRecord, createRecordType, defineMigrations, ID } from '@tldraw/tlstore'
import { T } from '@tldraw/tlvalidate'
import { Box2dModel, Vec2dModel } from '../geometry-types'
import { idValidator, instanceIdValidator, userIdValidator } from '../validation'
import { TLInstanceId } from './TLInstance'
import { TLUserId } from './TLUser'
/** @public */
export interface TLUserPresence extends BaseRecord<'user_presence', TLUserPresenceId> {
userId: TLUserId
lastUsedInstanceId: TLInstanceId | null
lastActivityTimestamp: number
cursor: Vec2dModel
viewportPageBounds: Box2dModel
color: string // can be any hex color
}
/** @public */
export type TLUserPresenceId = ID<TLUserPresence>
/** @public */
export const userPresenceTypeValidator: T.Validator<TLUserPresence> = T.model(
'user_presence',
T.object({
typeName: T.literal('user_presence'),
id: idValidator<TLUserPresenceId>('user_presence'),
userId: userIdValidator,
lastUsedInstanceId: instanceIdValidator.nullable(),
lastActivityTimestamp: T.number,
cursor: T.point,
viewportPageBounds: T.boxModel,
color: T.string,
})
)
const Versions = {
AddViewportPageBounds: 1,
} as const
/** @public */
export const userPresenceTypeMigrations = defineMigrations({
currentVersion: Versions.AddViewportPageBounds,
migrators: {
[Versions.AddViewportPageBounds]: {
up: (record) => {
return {
...record,
viewportPageBounds: { x: 0, y: 0, w: 1, h: 1 },
}
},
down: ({ viewportPageBounds: _viewportPageBounds, ...rest }) => rest,
},
},
})
/** @public */
export const TLUserPresence = createRecordType<TLUserPresence>('user_presence', {
migrations: userPresenceTypeMigrations,
validator: userPresenceTypeValidator,
scope: 'instance',
}).withDefaultProperties(
(): Omit<TLUserPresence, 'id' | 'typeName' | 'userId'> => ({
lastUsedInstanceId: null,
lastActivityTimestamp: 0,
cursor: { x: 0, y: 0 },
viewportPageBounds: { x: 0, y: 0, w: 1, h: 1 },
color: '#000000',
})
)

View file

@ -4,11 +4,14 @@ import { TLRecord } from './TLRecord'
const Versions = { const Versions = {
RemoveCodeAndIconShapeTypes: 1, RemoveCodeAndIconShapeTypes: 1,
AddInstancePresenceType: 2, AddInstancePresenceType: 2,
RemoveTLUserAndPresenceAndAddPointer: 3,
} as const } as const
export { Versions as storeVersions }
/** @public */ /** @public */
export const storeMigrations = defineMigrations({ export const storeMigrations = defineMigrations({
currentVersion: Versions.AddInstancePresenceType, currentVersion: Versions.RemoveTLUserAndPresenceAndAddPointer,
migrators: { migrators: {
[Versions.RemoveCodeAndIconShapeTypes]: { [Versions.RemoveCodeAndIconShapeTypes]: {
up: (store: StoreSnapshot<TLRecord>) => { up: (store: StoreSnapshot<TLRecord>) => {
@ -33,5 +36,17 @@ export const storeMigrations = defineMigrations({
) )
}, },
}, },
[Versions.RemoveTLUserAndPresenceAndAddPointer]: {
up: (store: StoreSnapshot<TLRecord>) => {
return Object.fromEntries(
Object.entries(store).filter(([_, v]) => !v.typeName.match(/^(user|user_presence)$/))
)
},
down: (store: StoreSnapshot<TLRecord>) => {
return Object.fromEntries(
Object.entries(store).filter(([_, v]) => v.typeName !== 'pointer')
)
},
},
}, },
}) })

View file

@ -1,4 +1,4 @@
import { getDefaultTranslationLocale } from './translations' import { _getDefaultTranslationLocale } from './translations'
type DefaultLanguageTest = { type DefaultLanguageTest = {
name: string name: string
@ -37,7 +37,7 @@ describe('Choosing a sensible default translation locale', () => {
for (const test of tests) { for (const test of tests) {
it(test.name, () => { it(test.name, () => {
expect(getDefaultTranslationLocale(test.input)).toEqual(test.output) expect(_getDefaultTranslationLocale(test.input)).toEqual(test.output)
}) })
} }
}) })

View file

@ -9,7 +9,13 @@ type TLListedTranslations = TLListedTranslation[]
type TLTranslationLocale = TLListedTranslations[number]['locale'] type TLTranslationLocale = TLListedTranslations[number]['locale']
/** @public */ /** @public */
export function getDefaultTranslationLocale(locales: readonly string[]): TLTranslationLocale { export function getDefaultTranslationLocale(): TLTranslationLocale {
const locales = typeof window !== 'undefined' ? window.navigator.languages ?? ['en'] : ['en']
return _getDefaultTranslationLocale(locales)
}
/** @internal */
export function _getDefaultTranslationLocale(locales: readonly string[]): TLTranslationLocale {
for (const locale of locales) { for (const locale of locales) {
const supportedLocale = getSupportedLocale(locale) const supportedLocale = getSupportedLocale(locale)
if (supportedLocale) { if (supportedLocale) {

View file

@ -4,7 +4,6 @@ import type { TLAssetId } from './records/TLAsset'
import type { TLInstanceId } from './records/TLInstance' import type { TLInstanceId } from './records/TLInstance'
import type { TLPageId } from './records/TLPage' import type { TLPageId } from './records/TLPage'
import type { TLParentId, TLShapeId } from './records/TLShape' import type { TLParentId, TLShapeId } from './records/TLShape'
import type { TLUserId } from './records/TLUser'
import { import {
TLAlignType, TLAlignType,
TL_ALIGN_TYPES_WITH_LEGACY_STUFF, TL_ALIGN_TYPES_WITH_LEGACY_STUFF,
@ -33,8 +32,6 @@ export function idValidator<Id extends ID<UnknownRecord>>(
}) })
} }
/** @internal */ /** @internal */
export const userIdValidator = idValidator<TLUserId>('user')
/** @internal */
export const assetIdValidator = idValidator<TLAssetId>('asset') export const assetIdValidator = idValidator<TLAssetId>('asset')
/** @internal */ /** @internal */
export const pageIdValidator = idValidator<TLPageId>('page') export const pageIdValidator = idValidator<TLPageId>('page')

View file

@ -6,7 +6,6 @@
import { Atom } from 'signia'; import { Atom } from 'signia';
import { Computed } from 'signia'; import { Computed } from 'signia';
import { Signal } from 'signia';
// @public // @public
export type AllRecords<T extends Store<any>> = ExtractR<ExtractRecordType<T>>; export type AllRecords<T extends Store<any>> = ExtractR<ExtractRecordType<T>>;
@ -288,8 +287,6 @@ export class StoreSchema<R extends UnknownRecord, P = unknown> {
createIntegrityChecker(store: Store<R, P>): (() => void) | undefined; createIntegrityChecker(store: Store<R, P>): (() => void) | undefined;
// (undocumented) // (undocumented)
get currentStoreVersion(): number; get currentStoreVersion(): number;
// @internal (undocumented)
derivePresenceState(store: Store<R, P>): Signal<null | R> | undefined;
// (undocumented) // (undocumented)
migratePersistedRecord(record: R, persistedSchema: SerializedSchema, direction?: 'down' | 'up'): MigrationResult<R>; migratePersistedRecord(record: R, persistedSchema: SerializedSchema, direction?: 'down' | 'up'): MigrationResult<R>;
// (undocumented) // (undocumented)
@ -317,7 +314,6 @@ export type StoreSchemaOptions<R extends UnknownRecord, P> = {
recordBefore: null | R; recordBefore: null | R;
}) => R; }) => R;
createIntegrityChecker?: (store: Store<R, P>) => void; createIntegrityChecker?: (store: Store<R, P>) => void;
derivePresenceState?: (store: Store<R, P>) => Signal<null | R>;
}; };
// @public // @public

View file

@ -1,5 +1,4 @@
import { getOwnProperty, objectMapValues } from '@tldraw/utils' import { getOwnProperty, objectMapValues } from '@tldraw/utils'
import { Signal } from 'signia'
import { IdOf, UnknownRecord } from './BaseRecord' import { IdOf, UnknownRecord } from './BaseRecord'
import { RecordType } from './RecordType' import { RecordType } from './RecordType'
import { Store, StoreSnapshot } from './Store' import { Store, StoreSnapshot } from './Store'
@ -49,8 +48,6 @@ export type StoreSchemaOptions<R extends UnknownRecord, P> = {
}) => R }) => R
/** @internal */ /** @internal */
createIntegrityChecker?: (store: Store<R, P>) => void createIntegrityChecker?: (store: Store<R, P>) => void
/** @internal */
derivePresenceState?: (store: Store<R, P>) => Signal<R | null>
} }
/** @public */ /** @public */
@ -244,11 +241,6 @@ export class StoreSchema<R extends UnknownRecord, P = unknown> {
return this.options.createIntegrityChecker?.(store) ?? undefined return this.options.createIntegrityChecker?.(store) ?? undefined
} }
/** @internal */
derivePresenceState(store: Store<R, P>): Signal<R | null> | undefined {
return this.options.derivePresenceState?.(store)
}
serialize(): SerializedSchema { serialize(): SerializedSchema {
return { return {
schemaVersion: 1, schemaVersion: 1,

View file

@ -6,7 +6,6 @@
import { RecordsDiff } from '@tldraw/tlstore'; import { RecordsDiff } from '@tldraw/tlstore';
import { SerializedSchema } from '@tldraw/tlstore'; import { SerializedSchema } from '@tldraw/tlstore';
import { Store } from '@tldraw/tlstore';
import { StoreSnapshot } from '@tldraw/tlstore'; import { StoreSnapshot } from '@tldraw/tlstore';
import { SyncedStore } from '@tldraw/editor'; import { SyncedStore } from '@tldraw/editor';
import { TldrawEditorConfig } from '@tldraw/editor'; import { TldrawEditorConfig } from '@tldraw/editor';
@ -14,8 +13,6 @@ import { TLInstanceId } from '@tldraw/editor';
import { TLRecord } from '@tldraw/editor'; import { TLRecord } from '@tldraw/editor';
import { TLStore } from '@tldraw/editor'; import { TLStore } from '@tldraw/editor';
import { TLStoreSchema } from '@tldraw/editor'; import { TLStoreSchema } from '@tldraw/editor';
import { TLUser } from '@tldraw/editor';
import { TLUserId } from '@tldraw/editor';
// @public (undocumented) // @public (undocumented)
export function addDbName(name: string): void; export function addDbName(name: string): void;
@ -40,9 +37,6 @@ export const DEFAULT_DOCUMENT_NAME: any;
// @public (undocumented) // @public (undocumented)
export function getAllIndexDbNames(): string[]; export function getAllIndexDbNames(): string[];
// @public (undocumented)
export function getUserData(): TLUser;
// @public (undocumented) // @public (undocumented)
export function hardReset({ shouldReload }?: { export function hardReset({ shouldReload }?: {
shouldReload?: boolean | undefined; shouldReload?: boolean | undefined;
@ -69,9 +63,6 @@ export function storeSnapshotInIndexedDb(universalPersistenceKey: string, schema
didCancel?: () => boolean; didCancel?: () => boolean;
}): Promise<void>; }): Promise<void>;
// @public (undocumented)
export function subscribeToUserData(store: Store<any>): () => void;
// @public (undocumented) // @public (undocumented)
export const TAB_ID: TLInstanceId; export const TAB_ID: TLInstanceId;
@ -97,10 +88,9 @@ export class TLLocalSyncClient {
} }
// @public // @public
export function useLocalSyncClient({ universalPersistenceKey, instanceId, userId, config, }: { export function useLocalSyncClient({ universalPersistenceKey, instanceId, config, }: {
universalPersistenceKey: string; universalPersistenceKey: string;
instanceId: TLInstanceId; instanceId: TLInstanceId;
userId: TLUserId;
config: TldrawEditorConfig; config: TldrawEditorConfig;
}): SyncedStore; }): SyncedStore;

View file

@ -13,6 +13,4 @@ export {
TAB_ID, TAB_ID,
addDbName, addDbName,
getAllIndexDbNames, getAllIndexDbNames,
getUserData,
subscribeToUserData,
} from './lib/persistence-constants' } from './lib/persistence-constants'

View file

@ -1,11 +1,4 @@
import { import { TldrawEditorConfig, TLInstance, TLInstanceId, TLPage } from '@tldraw/editor'
TldrawEditorConfig,
TLInstance,
TLInstanceId,
TLPage,
TLUser,
TLUserId,
} from '@tldraw/editor'
import { promiseWithResolve } from '@tldraw/utils' import { promiseWithResolve } from '@tldraw/utils'
import * as idb from './indexedDb' import * as idb from './indexedDb'
import { TLLocalSyncClient } from './TLLocalSyncClient' import { TLLocalSyncClient } from './TLLocalSyncClient'
@ -31,11 +24,9 @@ class BroadcastChannelMock {
function testClient( function testClient(
instanceId: TLInstanceId = TLInstance.createCustomId('test'), instanceId: TLInstanceId = TLInstance.createCustomId('test'),
userId: TLUserId = TLUser.createCustomId('test'),
channel = new BroadcastChannelMock('test') channel = new BroadcastChannelMock('test')
) { ) {
const store = new TldrawEditorConfig().createStore({ const store = new TldrawEditorConfig().createStore({
userId,
instanceId, instanceId,
}) })
const onLoad = jest.fn(() => { const onLoad = jest.fn(() => {
@ -85,7 +76,7 @@ test('the client connects on instantiation, announcing its schema', async () =>
expect(msg).toMatchObject({ type: 'announce', schema: { recordVersions: {} } }) expect(msg).toMatchObject({ type: 'announce', schema: { recordVersions: {} } })
}) })
test('when a client receives an annouce with a newer schema version it reloads itself', async () => { test('when a client receives an announce with a newer schema version it reloads itself', async () => {
const { client, channel, onLoadError } = testClient() const { client, channel, onLoadError } = testClient()
await tick() await tick()
jest.advanceTimersByTime(10000) jest.advanceTimersByTime(10000)
@ -103,7 +94,7 @@ test('when a client receives an annouce with a newer schema version it reloads i
expect(onLoadError).not.toHaveBeenCalled() expect(onLoadError).not.toHaveBeenCalled()
}) })
test('when a client receives an annouce with a newer schema version shortly after loading it does not reload but instead reports a loadError', async () => { test('when a client receives an announce with a newer schema version shortly after loading it does not reload but instead reports a loadError', async () => {
const { client, channel, onLoadError } = testClient() const { client, channel, onLoadError } = testClient()
await tick() await tick()
jest.advanceTimersByTime(1000) jest.advanceTimersByTime(1000)

View file

@ -155,7 +155,12 @@ export class TLLocalSyncClient {
// 3. Merge the changes into the REAL STORE // 3. Merge the changes into the REAL STORE
this.store.mergeRemoteChanges(() => { this.store.mergeRemoteChanges(() => {
// Calling put will validate the records! // Calling put will validate the records!
this.store.put(Object.values(migrationResult.value), 'initialize') this.store.put(
Object.values(migrationResult.value).filter(
(r) => this.store.schema.types[r.typeName].scope !== 'presence'
),
'initialize'
)
}) })
} }

View file

@ -1,7 +1,6 @@
import { SyncedStore, TldrawEditorConfig, TLInstanceId, TLUserId, uniqueId } from '@tldraw/editor' import { SyncedStore, TldrawEditorConfig, TLInstanceId, uniqueId } from '@tldraw/editor'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import '../hardReset' import '../hardReset'
import { subscribeToUserData } from '../persistence-constants'
import { TLLocalSyncClient } from '../TLLocalSyncClient' import { TLLocalSyncClient } from '../TLLocalSyncClient'
/** /**
@ -13,12 +12,10 @@ import { TLLocalSyncClient } from '../TLLocalSyncClient'
export function useLocalSyncClient({ export function useLocalSyncClient({
universalPersistenceKey, universalPersistenceKey,
instanceId, instanceId,
userId,
config, config,
}: { }: {
universalPersistenceKey: string universalPersistenceKey: string
instanceId: TLInstanceId instanceId: TLInstanceId
userId: TLUserId
config: TldrawEditorConfig config: TldrawEditorConfig
}): SyncedStore { }): SyncedStore {
const [state, setState] = useState<{ id: string; syncedStore: SyncedStore } | null>(null) const [state, setState] = useState<{ id: string; syncedStore: SyncedStore } | null>(null)
@ -38,7 +35,7 @@ export function useLocalSyncClient({
}) })
} }
const store = config.createStore({ userId, instanceId }) const store = config.createStore({ instanceId })
const client = new TLLocalSyncClient(store, { const client = new TLLocalSyncClient(store, {
universalPersistenceKey, universalPersistenceKey,
@ -50,14 +47,11 @@ export function useLocalSyncClient({
}, },
}) })
const userDataUnsubcribe = subscribeToUserData(store)
return () => { return () => {
setState((prevState) => (prevState?.id === id ? null : prevState)) setState((prevState) => (prevState?.id === id ? null : prevState))
userDataUnsubcribe()
client.close() client.close()
} }
}, [instanceId, universalPersistenceKey, config, userId]) }, [instanceId, universalPersistenceKey, config])
return state?.syncedStore ?? { status: 'loading' } return state?.syncedStore ?? { status: 'loading' }
} }

View file

@ -1,6 +1,4 @@
import { TLInstance, TLInstanceId, TLUser, uniqueId } from '@tldraw/editor' import { TLInstance, TLInstanceId, uniqueId } from '@tldraw/editor'
import { Store } from '@tldraw/tlstore'
import { atom, react } from 'signia'
const tabIdKey = 'TLDRAW_TAB_ID_v2' as const const tabIdKey = 'TLDRAW_TAB_ID_v2' as const
@ -26,42 +24,6 @@ function iOS() {
) )
} }
// the id of the user, stored in localStorage so that it persists across sessions
const USER_DATA_KEY = 'TLDRAW_USER_DATA_v2'
const globalUserData = atom<TLUser>(
'globalUserData',
JSON.parse(window?.localStorage.getItem(USER_DATA_KEY) || 'null') ?? TLUser.create({})
)
react('set global user data', () => {
if (window) {
window.localStorage.setItem(USER_DATA_KEY, JSON.stringify(globalUserData.value))
}
})
/** @public */
export function getUserData() {
return globalUserData.value
}
/** @public */
export function subscribeToUserData(store: Store<any>) {
const userId = globalUserData.value.id
return store.listen(({ changes }) => {
for (const record of Object.values(changes.added)) {
if (record.typeName === 'user' && userId === record.id) {
globalUserData.set(record)
}
}
for (const [_, record] of Object.values(changes.updated)) {
if (record.typeName === 'user' && userId === record.id) {
globalUserData.set(record)
}
}
})
}
// the id of the document that will be loaded if the URL doesn't contain a document id // the id of the document that will be loaded if the URL doesn't contain a document id
// again, stored in localStorage // again, stored in localStorage
const defaultDocumentKey = 'TLDRAW_DEFAULT_DOCUMENT_NAME_v2' const defaultDocumentKey = 'TLDRAW_DEFAULT_DOCUMENT_NAME_v2'

View file

@ -9,9 +9,7 @@ export function LanguageMenu() {
const { languages, currentLanguage } = useLanguages() const { languages, currentLanguage } = useLanguages()
const handleLanguageSelect = useCallback( const handleLanguageSelect = useCallback(
(locale: TLTranslationLocale) => { (locale: TLTranslationLocale) => app.setLocale(locale),
app.updateUser({ locale })
},
[app] [app]
) )

View file

@ -33,7 +33,7 @@ export const Minimap = track(function Minimap({
const minimap = React.useMemo(() => new MinimapManager(app, app.devicePixelRatio), [app]) const minimap = React.useMemo(() => new MinimapManager(app, app.devicePixelRatio), [app])
const isDarkMode = app.userDocumentSettings.isDarkMode const isDarkMode = app.isDarkMode
React.useEffect(() => { React.useEffect(() => {
// Must check after render // Must check after render

View file

@ -49,7 +49,7 @@ export function MenuSchemaProvider({ overrides, children }: MenuSchemaProviderPr
const breakpoint = useBreakpoint() const breakpoint = useBreakpoint()
const isMobile = breakpoint < 5 const isMobile = breakpoint < 5
const isDarkMode = useValue('isDarkMode', () => app.userDocumentSettings.isDarkMode, [app]) const isDarkMode = useValue('isDarkMode', () => app.isDarkMode, [app])
const isGridMode = useValue('isGridMode', () => app.userDocumentSettings.isGridMode, [app]) const isGridMode = useValue('isGridMode', () => app.userDocumentSettings.isGridMode, [app])
const isSnapMode = useValue('isSnapMode', () => app.userDocumentSettings.isSnapMode, [app]) const isSnapMode = useValue('isSnapMode', () => app.userDocumentSettings.isSnapMode, [app])
const isToolLock = useValue('isToolLock', () => app.instanceState.isToolLocked, [app]) const isToolLock = useValue('isToolLock', () => app.instanceState.isToolLocked, [app])

View file

@ -4,5 +4,5 @@ import { LANGUAGES } from './languages'
/** @public */ /** @public */
export function useLanguages() { export function useLanguages() {
const app = useApp() const app = useApp()
return { languages: LANGUAGES, currentLanguage: app.user.locale } return { languages: LANGUAGES, currentLanguage: app.locale }
} }

View file

@ -35,7 +35,7 @@ export const TranslationProvider = track(function TranslationProvider({
children, children,
}: TranslationProviderProps) { }: TranslationProviderProps) {
const app = useApp() const app = useApp()
const locale = app.userSettings.locale const locale = app.locale
const getAssetUrl = useAssetUrls() const getAssetUrl = useAssetUrls()
const [currentTranslation, setCurrentTranslation] = React.useState<TLTranslation>(() => { const [currentTranslation, setCurrentTranslation] = React.useState<TLTranslation>(() => {