From 356a0d1e73000ab94dcf828527eaedb230842096 Mon Sep 17 00:00:00 2001 From: David Sheldrick Date: Thu, 25 May 2023 10:54:29 +0100 Subject: [PATCH] [chore] refactor user preferences (#1435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove TLUser, TLUserPresence - Add first-class support for user preferences that persists across rooms and tabs ### Change Type - [ ] `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. --- .github/workflows/webdriver-nightly.yml | 64 ------- .github/workflows/webdriver-on-demand.yml | 117 ------------- .../11-user-presence/UserPresenceExample.tsx | 61 +++---- .../src/5-exploded/ExplodedExample.tsx | 14 +- apps/vscode/editor/src/ChangeResponder.tsx | 8 +- apps/vscode/editor/src/FileOpen.tsx | 16 +- apps/vscode/editor/src/app.tsx | 23 +-- packages/editor/api-report.md | 36 ++-- packages/editor/src/index.ts | 1 + packages/editor/src/lib/TldrawEditor.tsx | 47 +----- packages/editor/src/lib/app/App.ts | 157 ++++++----------- .../app/managers/UserPreferencesManager.ts | 33 ++++ .../shapeutils/TLLineUtil/TLLineUtil.test.ts | 6 +- .../__snapshots__/TLLineUtil.test.ts.snap | 2 +- .../lib/app/shapeutils/shared/ShapeFill.tsx | 2 +- .../lib/components/DefaultErrorFallback.tsx | 2 +- .../src/lib/components/LiveCollaborators.tsx | 3 +- .../src/lib/config/TLUserPreferences.ts | 158 ++++++++++++++++++ .../src/lib/config/TldrawEditorConfig.tsx | 20 ++- packages/editor/src/lib/hooks/useDarkMode.ts | 2 +- packages/editor/src/lib/hooks/usePeerIds.ts | 2 +- packages/editor/src/lib/hooks/usePresence.ts | 4 +- packages/editor/src/lib/test/TestApp.ts | 3 - .../editor/src/lib/test/TldrawEditor.test.tsx | 4 +- .../editor/src/lib/test/tools/groups.test.ts | 6 +- packages/file-format/api-report.md | 4 +- packages/file-format/src/lib/file.ts | 6 +- packages/file-format/src/test/file.test.ts | 5 +- packages/tldraw/src/lib/Tldraw.tsx | 28 +--- packages/tlschema/api-report.md | 108 +++++------- packages/tlschema/src/TLRecord.ts | 6 +- packages/tlschema/src/TLStore.ts | 57 +------ .../src/createPresenceStateDerivation.ts | 58 +++++++ packages/tlschema/src/createTLSchema.ts | 16 +- .../src/defaultDerivePresenceState.ts | 57 ------- packages/tlschema/src/index.ts | 18 +- packages/tlschema/src/migrations.test.ts | 119 +++++++++++-- packages/tlschema/src/records/TLInstance.ts | 23 ++- .../src/records/TLInstancePresence.ts | 37 +++- packages/tlschema/src/records/TLPointer.ts | 47 ++++++ packages/tlschema/src/records/TLUser.ts | 45 ----- .../tlschema/src/records/TLUserDocument.ts | 27 +-- .../tlschema/src/records/TLUserPresence.ts | 69 -------- packages/tlschema/src/schema.ts | 17 +- packages/tlschema/src/translations.test.ts | 4 +- packages/tlschema/src/translations.ts | 8 +- packages/tlschema/src/validation.ts | 3 - packages/tlstore/api-report.md | 4 - packages/tlstore/src/lib/StoreSchema.ts | 8 - packages/tlsync-client/api-report.md | 12 +- packages/tlsync-client/src/index.ts | 2 - .../src/lib/TLLocalSyncClient.test.ts | 15 +- .../src/lib/TLLocalSyncClient.ts | 7 +- .../src/lib/hooks/useLocalSyncClient.ts | 12 +- .../src/lib/persistence-constants.ts | 40 +---- .../ui/src/lib/components/LanguageMenu.tsx | 4 +- .../lib/components/NavigationZone/Minimap.tsx | 2 +- packages/ui/src/lib/hooks/useMenuSchema.tsx | 2 +- .../lib/hooks/useTranslation/useLanguages.tsx | 2 +- .../hooks/useTranslation/useTranslation.tsx | 2 +- 60 files changed, 710 insertions(+), 955 deletions(-) delete mode 100644 .github/workflows/webdriver-nightly.yml delete mode 100644 .github/workflows/webdriver-on-demand.yml create mode 100644 packages/editor/src/lib/app/managers/UserPreferencesManager.ts create mode 100644 packages/editor/src/lib/config/TLUserPreferences.ts create mode 100644 packages/tlschema/src/createPresenceStateDerivation.ts delete mode 100644 packages/tlschema/src/defaultDerivePresenceState.ts create mode 100644 packages/tlschema/src/records/TLPointer.ts delete mode 100644 packages/tlschema/src/records/TLUser.ts delete mode 100644 packages/tlschema/src/records/TLUserPresence.ts diff --git a/.github/workflows/webdriver-nightly.yml b/.github/workflows/webdriver-nightly.yml deleted file mode 100644 index ddb218e19..000000000 --- a/.github/workflows/webdriver-nightly.yml +++ /dev/null @@ -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 }} diff --git a/.github/workflows/webdriver-on-demand.yml b/.github/workflows/webdriver-on-demand.yml deleted file mode 100644 index f97bdba7f..000000000 --- a/.github/workflows/webdriver-on-demand.yml +++ /dev/null @@ -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 }} diff --git a/apps/examples/src/11-user-presence/UserPresenceExample.tsx b/apps/examples/src/11-user-presence/UserPresenceExample.tsx index 1e9d27093..7ed39abdc 100644 --- a/apps/examples/src/11-user-presence/UserPresenceExample.tsx +++ b/apps/examples/src/11-user-presence/UserPresenceExample.tsx @@ -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/ui.css' import { useRef } from 'react' -const SHOW_MOVING_CURSOR = false -const CURSOR_SPEED = 0.1 +const SHOW_MOVING_CURSOR = true +const CURSOR_SPEED = 0.5 const CIRCLE_RADIUS = 100 const UPDATE_FPS = 60 @@ -15,39 +15,19 @@ export default function UserPresenceExample() { { - // There are several records related to user presence that must be - // included for each user. These are created automatically by each - // 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. + // For every connected peer you should put a TLInstancePresence record in the + // store with their cursor position etc. - const userId = TLUser.createCustomId('user-1') - - const user = TLUser.create({ - id: userId, - name: 'User 1', + const peerPresence = TLInstancePresence.create({ + id: TLInstancePresence.createCustomId('peer-1-presence'), + currentPageId: app.currentPageId, + userId: 'peer-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.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]) + app.store.put([peerPresence]) // Make the fake user's cursor rotate in a circle if (rTimeout.current) { @@ -62,24 +42,21 @@ export default function UserPresenceExample() { // rotate in a circle app.store.put([ { - ...userPresence, + ...peerPresence, cursor: { - x: Math.cos(t * Math.PI * 2) * CIRCLE_RADIUS, - y: Math.sin(t * Math.PI * 2) * CIRCLE_RADIUS, + ...peerPresence.cursor, + x: 150 + Math.cos(t * Math.PI * 2) * CIRCLE_RADIUS, + y: 150 + Math.sin(t * Math.PI * 2) * CIRCLE_RADIUS, }, lastActivityTimestamp: now, }, ]) }, 1000 / UPDATE_FPS) } else { - app.store.put([ - { ...userPresence, cursor: { x: 0, y: 0 }, lastActivityTimestamp: Date.now() }, - ]) + app.store.put([{ ...peerPresence, lastActivityTimestamp: Date.now() }]) rTimeout.current = setInterval(() => { - app.store.put([ - { ...userPresence, cursor: { x: 0, y: 0 }, lastActivityTimestamp: Date.now() }, - ]) + app.store.put([{ ...peerPresence, lastActivityTimestamp: Date.now() }]) }, 1000) } }} diff --git a/apps/examples/src/5-exploded/ExplodedExample.tsx b/apps/examples/src/5-exploded/ExplodedExample.tsx index 538055310..3f990d82b 100644 --- a/apps/examples/src/5-exploded/ExplodedExample.tsx +++ b/apps/examples/src/5-exploded/ExplodedExample.tsx @@ -1,7 +1,6 @@ import { Canvas, ContextMenu, - getUserData, TldrawEditor, TldrawEditorConfig, TldrawUi, @@ -13,28 +12,19 @@ import '@tldraw/tldraw/ui.css' const instanceId = TLInstance.createCustomId('example') +// for custom config, see 3-custom-config const config = new TldrawEditorConfig() export default function Example() { - const userData = getUserData() - const syncedStore = useLocalSyncClient({ config, instanceId, - userId: userData.id, universalPersistenceKey: 'exploded-example', - // config: myConfig // for custom config, see 3-custom-config }) return (
- + diff --git a/apps/vscode/editor/src/ChangeResponder.tsx b/apps/vscode/editor/src/ChangeResponder.tsx index f9d412ec4..53c09a279 100644 --- a/apps/vscode/editor/src/ChangeResponder.tsx +++ b/apps/vscode/editor/src/ChangeResponder.tsx @@ -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 { useDefaultHelpers } from '@tldraw/ui' import { debounce } from '@tldraw/utils' @@ -11,11 +11,9 @@ import type { VscodeMessage } from '../../messages' export const ChangeResponder = ({ syncedStore, - userId, instanceId, }: { syncedStore: SyncedStore - userId: TLUserId instanceId: TLInstanceId }) => { const app = useApp() @@ -46,7 +44,7 @@ export const ChangeResponder = ({ clearToasts() window.removeEventListener('message', handleMessage) } - }, [app, userId, instanceId, msg, addToast, clearToasts]) + }, [app, instanceId, msg, addToast, clearToasts]) React.useEffect(() => { // When the history changes, send the new file contents to VSCode @@ -71,7 +69,7 @@ export const ChangeResponder = ({ handleChange() app.off('change-history', handleChange) } - }, [app, syncedStore, userId, instanceId]) + }, [app, syncedStore, instanceId]) return null } diff --git a/apps/vscode/editor/src/FileOpen.tsx b/apps/vscode/editor/src/FileOpen.tsx index a6fb0c4ab..17ad6d0e0 100644 --- a/apps/vscode/editor/src/FileOpen.tsx +++ b/apps/vscode/editor/src/FileOpen.tsx @@ -1,17 +1,15 @@ -import { TLInstanceId, TLUserId, useApp } from '@tldraw/editor' +import { TLInstanceId, useApp } from '@tldraw/editor' import { parseAndLoadDocument } from '@tldraw/file-format' import { useDefaultHelpers } from '@tldraw/ui' import React from 'react' import { vscode } from './utils/vscode' export function FileOpen({ - userId, fileContents, instanceId, forceDarkMode, }: { instanceId: TLInstanceId - userId: TLUserId fileContents: string forceDarkMode: boolean }) { @@ -44,17 +42,7 @@ export function FileOpen({ return () => { clearToasts() } - }, [ - fileContents, - app, - userId, - instanceId, - addToast, - msg, - clearToasts, - forceDarkMode, - isFileLoaded, - ]) + }, [fileContents, app, instanceId, addToast, msg, clearToasts, forceDarkMode, isFileLoaded]) return null } diff --git a/apps/vscode/editor/src/app.tsx b/apps/vscode/editor/src/app.tsx index 92f7ef8e2..8c67abcdb 100644 --- a/apps/vscode/editor/src/app.tsx +++ b/apps/vscode/editor/src/app.tsx @@ -5,7 +5,6 @@ import { setRuntimeOverrides, TldrawEditor, TldrawEditorConfig, - TLUserId, } from '@tldraw/editor' import { linksUiOverrides } from './utils/links' // eslint-disable-next-line import/no-internal-modules @@ -97,7 +96,6 @@ export const TldrawWrapper = () => { assetSrc: message.data.assetSrc, fileContents: message.data.fileContents, uri: message.data.uri, - userId: message.data.userId as TLUserId, isDarkMode: message.data.isDarkMode, config, }) @@ -128,24 +126,15 @@ export type TLDrawInnerProps = { assetSrc: string fileContents: string uri: string - userId: TLUserId isDarkMode: boolean config: TldrawEditorConfig } -function TldrawInner({ - uri, - config, - assetSrc, - userId, - isDarkMode, - fileContents, -}: TLDrawInnerProps) { +function TldrawInner({ uri, config, assetSrc, isDarkMode, fileContents }: TLDrawInnerProps) { const instanceId = TAB_ID const syncedStore = useLocalSyncClient({ universalPersistenceKey: uri, instanceId, - userId, config, }) @@ -156,20 +145,14 @@ function TldrawInner({ config={config} assetUrls={assetUrls} instanceId={TAB_ID} - userId={userId} store={syncedStore} onCreateBookmarkFromUrl={onCreateBookmarkFromUrl} autoFocus > {/* */} - - + + diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index 21a07f4bc..d2abe959c 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -93,10 +93,7 @@ import { TLStyleType } from '@tldraw/tlschema'; import { TLTextShape } from '@tldraw/tlschema'; import { TLTextShapeProps } from '@tldraw/tlschema'; import { TLUnknownShape } from '@tldraw/tlschema'; -import { TLUser } from '@tldraw/tlschema'; import { TLUserDocument } from '@tldraw/tlschema'; -import { TLUserId } from '@tldraw/tlschema'; -import { TLUserPresence } from '@tldraw/tlschema'; import { TLVideoAsset } from '@tldraw/tlschema'; import { TLVideoShape } from '@tldraw/tlschema'; import { UnknownRecord } from '@tldraw/tlstore'; @@ -368,6 +365,7 @@ export class App extends EventEmitter { // (undocumented) get isToolLocked(): boolean; isWithinSelection(id: TLShapeId): boolean; + get locale(): string; // (undocumented) lockShapes(_ids?: TLShapeId[]): this; mark(reason?: string, onUndo?: boolean, onRedo?: boolean): string; @@ -467,6 +465,7 @@ export class App extends EventEmitter { setHintingIds(ids: TLShapeId[]): this; setHoveredId(id?: null | TLShapeId): this; setInstancePageState(partial: Partial, ephemeral?: boolean): void; + setLocale(locale: string): void; // (undocumented) setPenMode(isPenMode: boolean): this; setProp(key: TLShapeProp, value: any, ephemeral?: boolean, squashing?: boolean): this; @@ -495,7 +494,7 @@ export class App extends EventEmitter { readonly snaps: SnapManager; get sortedShapesArray(): TLShape[]; stackShapes(operation: 'horizontal' | 'vertical', ids?: TLShapeId[], gap?: number): this; - startFollowingUser: (userId: TLUserId) => this | undefined; + startFollowingUser: (userId: string) => this | undefined; stopCameraAnimation(): this; stopFollowingUser: () => this; readonly store: TLStore; @@ -511,22 +510,12 @@ export class App extends EventEmitter { updateInstanceState(partial: Partial>, ephemeral?: boolean, squashing?: boolean): this; updatePage(partial: RequiredKeys, squashing?: boolean): this; updateShapes(partials: (null | TLShapePartial | undefined)[], squashing?: boolean): this; - updateUser(partial: Partial): void; updateUserDocumentSettings(partial: Partial, ephemeral?: boolean): this; - // (undocumented) - updateUserPresence: ({ cursor, color, viewportPageBounds, }?: { - cursor?: undefined | Vec2dModel; - color?: string | undefined; - viewportPageBounds?: Box2dModel | undefined; - }) => void; updateViewportScreenBounds(center?: boolean): this; - get user(): TLUser; + // @internal (undocumented) + readonly user: UserPreferencesManager; // (undocumented) get userDocumentSettings(): TLUserDocument; - get userId(): TLUserId; - // (undocumented) - get userPresence(): TLUserPresence | undefined; - get userSettings(): TLUser; get viewportPageBounds(): Box2d; get viewportPageCenter(): Vec2d; get viewportScreenBounds(): Box2d; @@ -1794,10 +1783,13 @@ export class TldrawEditorConfig { // (undocumented) createStore(config: { initialData?: StoreSnapshot; - userId: TLUserId; instanceId: TLInstanceId; }): TLStore; // (undocumented) + readonly derivePresenceState: (store: TLStore) => Signal; + // (undocumented) + readonly setUserPreferences: (userPreferences: TLUserPreferences) => void; + // (undocumented) readonly shapeUtils: Record>; // (undocumented) readonly storeSchema: StoreSchema; @@ -1805,6 +1797,8 @@ export class TldrawEditorConfig { readonly TLShape: RecordType; // (undocumented) readonly tools: readonly StateNodeConstructor[]; + // (undocumented) + readonly userPreferences: Signal; } // @public (undocumented) @@ -1825,7 +1819,6 @@ export interface TldrawEditorProps { }>; onMount?: (app: App) => void; store?: SyncedStore | TLStore; - userId?: TLUserId; } // @public (undocumented) @@ -2658,17 +2651,20 @@ export const useApp: () => App; export function useContainer(): HTMLDivElement; // @internal (undocumented) -export function usePeerIds(): TLUserId[]; +export function usePeerIds(): string[]; // @public (undocumented) export function usePrefersReducedMotion(): boolean; // @internal (undocumented) -export function usePresence(userId: TLUserId): null | TLInstancePresence; +export function usePresence(userId: string): null | TLInstancePresence; // @public (undocumented) 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) export function useReactor(name: string, reactFn: () => void, deps?: any[] | undefined): void; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 447b66b60..3f17c2bb6 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -131,6 +131,7 @@ export { type ReadySyncedStore, type SyncedStore, } from './lib/config/SyncedStore' +export { USER_COLORS } from './lib/config/TLUserPreferences' export { TldrawEditorConfig } from './lib/config/TldrawEditorConfig' export { ANIMATION_MEDIUM_MS, diff --git a/packages/editor/src/lib/TldrawEditor.tsx b/packages/editor/src/lib/TldrawEditor.tsx index 7a55fba6d..8ce45956b 100644 --- a/packages/editor/src/lib/TldrawEditor.tsx +++ b/packages/editor/src/lib/TldrawEditor.tsx @@ -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 { 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 { EditorAssetUrls, defaultEditorAssetUrls } from './assetUrls' import { OptionalErrorBoundary } from './components/ErrorBoundary' @@ -87,8 +87,6 @@ export interface TldrawEditorProps { * from a server or database. */ 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 * tab). If not given, one will be generated. @@ -132,38 +130,19 @@ export function TldrawEditor(props: TldrawEditorProps) { ) } -function TldrawEditorBeforeLoading({ - config, - userId, - instanceId, - store, - ...props -}: TldrawEditorProps) { +function TldrawEditorBeforeLoading({ config, instanceId, store, ...props }: TldrawEditorProps) { const { done: preloadingComplete, error: preloadingError } = usePreloadAssets( props.assetUrls ?? defaultEditorAssetUrls ) - const [_store, _setStore] = useState(() => { + const _store = useMemo(() => { return ( store ?? config.createStore({ - userId: userId ?? TLUser.createId(), instanceId: instanceId ?? TLInstance.createId(), }) ) - }) - - useEffect(() => { - _setStore(() => { - return ( - store ?? - config.createStore({ - userId: userId ?? TLUser.createId(), - instanceId: instanceId ?? TLInstance.createId(), - }) - ) - }) - }, [store, config, userId, instanceId]) + }, [store, config, instanceId]) let loadedStore: TLStore | SyncedStore 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) { return Could not load assets. Please refresh the page. } @@ -208,7 +181,6 @@ function TldrawEditorBeforeLoading({ function TldrawEditorAfterLoading({ onMount, config, - isDarkMode, children, onCreateAssetFromFile, onCreateBookmarkFromUrl, @@ -257,20 +229,15 @@ function TldrawEditorAfterLoading({ const onMountEvent = useEvent((app: App) => { onMount?.(app) app.emit('mount') + window.tldrawReady = true }) React.useEffect(() => { if (app) { - // Set the initial theme state. - if (isDarkMode !== undefined) { - app.updateUserDocumentSettings({ isDarkMode }) - } - // Run onMount - window.tldrawReady = true onMountEvent(app) } - }, [app, onMountEvent, isDarkMode]) + }, [app, onMountEvent]) const crashingError = useSyncExternalStore( useCallback( diff --git a/packages/editor/src/lib/app/App.ts b/packages/editor/src/lib/app/App.ts index 73b2954a7..4faa11db8 100644 --- a/packages/editor/src/lib/app/App.ts +++ b/packages/editor/src/lib/app/App.ts @@ -40,6 +40,7 @@ import { TLInstanceId, TLInstancePageState, TLNullableShapeProps, + TLPOINTER_ID, TLPage, TLPageId, TLParentId, @@ -52,9 +53,7 @@ import { TLSizeStyle, TLStore, TLUnknownShape, - TLUser, TLUserDocument, - TLUserId, TLVideoAsset, Vec2dModel, createCustomShapeId, @@ -116,6 +115,7 @@ import { HistoryManager } from './managers/HistoryManager' import { SnapManager } from './managers/SnapManager' import { TextManager } from './managers/TextManager' import { TickManager } from './managers/TickManager' +import { UserPreferencesManager } from './managers/UserPreferencesManager' import { TLArrowUtil } from './shapeutils/TLArrowUtil/TLArrowUtil' import { getCurvedArrowInfo } from './shapeutils/TLArrowUtil/arrow/curved-arrow' import { @@ -185,6 +185,8 @@ export class App extends EventEmitter { this.store = store + this.user = new UserPreferencesManager(this) + this.getContainer = getContainer ?? (() => document.body) this.textMeasure = new TextManager(this) @@ -355,6 +357,11 @@ export class App extends EventEmitter { */ readonly snaps = new SnapManager(this) + /** + * @internal + */ + readonly user: UserPreferencesManager + /** * Whether the editor is running in Safari. * @@ -424,21 +431,6 @@ export class App extends EventEmitter { */ 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). * @@ -1197,7 +1189,6 @@ export class App extends EventEmitter { } } - this.updateUserPresence() this.emit('update') } @@ -1500,16 +1491,6 @@ export class App extends EventEmitter { return this.documentSettings.gridSize } - /** - * The user's global settings. - * - * @public - * @readonly - */ - get userSettings(): TLUser { - return this.store.get(this.userId)! - } - get isSnapMode() { return this.userDocumentSettings.isSnapMode } @@ -1522,12 +1503,12 @@ export class App extends EventEmitter { } get isDarkMode() { - return this.userDocumentSettings.isDarkMode + return this.user.isDarkMode } setDarkMode(isDarkMode: boolean) { if (isDarkMode !== this.isDarkMode) { - this.updateUserDocumentSettings({ isDarkMode }, true) + this.user.updateUserPreferences({ isDarkMode }) } return this } @@ -1556,7 +1537,7 @@ export class App extends EventEmitter { /** @internal */ @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 { @@ -1609,15 +1590,6 @@ export class App extends EventEmitter { // User / User App State - /** - * The current user state. - * - * @public - */ - get user(): TLUser { - return this.store.get(this.userId)! - } - /** The current tab state */ get instanceState(): TLInstance { return this.store.get(this.instanceId)! @@ -3467,7 +3439,15 @@ export class App extends EventEmitter { } // 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 --------------------- */ @@ -5017,6 +4997,27 @@ export class App extends EventEmitter { } ) + /** + * 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. * @@ -5260,56 +5261,6 @@ export class App extends EventEmitter { } ) - /** - * 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) { - 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. * @@ -5577,7 +5528,7 @@ export class App extends EventEmitter { scale = 1, background = false, padding = SVG_PADDING, - darkMode = this.userDocumentSettings.isDarkMode, + darkMode = this.isDarkMode, preserveAspectRatio = false, } = opts @@ -7246,17 +7197,11 @@ export class App extends EventEmitter { this.store.put([{ ...this.instanceState, currentPageId: toId }]) - this.updateUserPresence({ - viewportPageBounds: this.viewportPageBounds.toJson(), - }) this.updateCullingBounds() }, undo: ({ fromId }) => { this.store.put([{ ...this.instanceState, currentPageId: fromId }]) - this.updateUserPresence({ - viewportPageBounds: this.viewportPageBounds.toJson(), - }) this.updateCullingBounds() }, squash: ({ fromId }, { toId }) => { @@ -7890,10 +7835,6 @@ export class App extends EventEmitter { isPen: this.isPenMode ?? false, }) - this.updateUserPresence({ - viewportPageBounds: this.viewportPageBounds.toJson(), - }) - this._cameraManager.tick() }) @@ -8494,7 +8435,7 @@ export class App extends EventEmitter { * @param userId - The id of the user to follow. * @public */ - startFollowingUser = (userId: TLUserId) => { + startFollowingUser = (userId: string) => { // Currently, we get the leader's viewport page bounds from their user presence. // 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. @@ -8502,8 +8443,14 @@ export class App extends EventEmitter { 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 (leaderPresences.value.some((p) => p.followingUserId === this.userId)) { + if (leaderPresences.value.some((p) => p.followingUserId === thisUserId)) { return } @@ -8554,7 +8501,7 @@ export class App extends EventEmitter { // 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 // 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 const desiredWidth = width + (leaderWidth - width) * chaseProportion diff --git a/packages/editor/src/lib/app/managers/UserPreferencesManager.ts b/packages/editor/src/lib/app/managers/UserPreferencesManager.ts new file mode 100644 index 000000000..33f1cd6e6 --- /dev/null +++ b/packages/editor/src/lib/app/managers/UserPreferencesManager.ts @@ -0,0 +1,33 @@ +import { TLUserPreferences } from '../../config/TLUserPreferences' +import { App } from '../App' + +export class UserPreferencesManager { + constructor(private readonly editor: App) {} + + updateUserPreferences = (userPreferences: Partial) => { + 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 + } +} diff --git a/packages/editor/src/lib/app/shapeutils/TLLineUtil/TLLineUtil.test.ts b/packages/editor/src/lib/app/shapeutils/TLLineUtil/TLLineUtil.test.ts index a6aa9da72..e62e14da2 100644 --- a/packages/editor/src/lib/app/shapeutils/TLLineUtil/TLLineUtil.test.ts +++ b/packages/editor/src/lib/app/shapeutils/TLLineUtil/TLLineUtil.test.ts @@ -2,8 +2,10 @@ import { createCustomShapeId, TLGeoShape, TLLineShape } from '@tldraw/tlschema' import { deepCopy } from '@tldraw/utils' import { TestApp } from '../../../test/TestApp' -let i = 0 -jest.mock('nanoid', () => ({ nanoid: () => 'id' + i++ })) +jest.mock('nanoid', () => { + let i = 0 + return { nanoid: () => 'id' + i++ } +}) let app: TestApp const id = createCustomShapeId('line1') diff --git a/packages/editor/src/lib/app/shapeutils/TLLineUtil/__snapshots__/TLLineUtil.test.ts.snap b/packages/editor/src/lib/app/shapeutils/TLLineUtil/__snapshots__/TLLineUtil.test.ts.snap index 7a5598aba..6162b9de5 100644 --- a/packages/editor/src/lib/app/shapeutils/TLLineUtil/__snapshots__/TLLineUtil.test.ts.snap +++ b/packages/editor/src/lib/app/shapeutils/TLLineUtil/__snapshots__/TLLineUtil.test.ts.snap @@ -5,7 +5,7 @@ Object { "id": "shape:line1", "index": "a1", "isLocked": false, - "parentId": "page:id51", + "parentId": "page:id50", "props": Object { "color": "black", "dash": "draw", diff --git a/packages/editor/src/lib/app/shapeutils/shared/ShapeFill.tsx b/packages/editor/src/lib/app/shapeutils/shared/ShapeFill.tsx index f0d325fdd..ffa89fb33 100644 --- a/packages/editor/src/lib/app/shapeutils/shared/ShapeFill.tsx +++ b/packages/editor/src/lib/app/shapeutils/shared/ShapeFill.tsx @@ -33,7 +33,7 @@ export const ShapeFill = React.memo(function ShapeFill({ d, color, fill }: Shape const PatternFill = function PatternFill({ d, color }: ShapeFillProps) { const app = useApp() 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 teenyTiny = app.zoomLevel <= 0.18 diff --git a/packages/editor/src/lib/components/DefaultErrorFallback.tsx b/packages/editor/src/lib/components/DefaultErrorFallback.tsx index 311151d19..9d05b18e2 100644 --- a/packages/editor/src/lib/components/DefaultErrorFallback.tsx +++ b/packages/editor/src/lib/components/DefaultErrorFallback.tsx @@ -30,7 +30,7 @@ export const DefaultErrorFallback: TLErrorFallback = ({ error, app }) => { () => { try { if (app) { - return app.userDocumentSettings.isDarkMode + return app.isDarkMode } } catch { // we're in a funky error state so this might not work for spooky diff --git a/packages/editor/src/lib/components/LiveCollaborators.tsx b/packages/editor/src/lib/components/LiveCollaborators.tsx index ed6de18df..2cbaef42b 100644 --- a/packages/editor/src/lib/components/LiveCollaborators.tsx +++ b/packages/editor/src/lib/components/LiveCollaborators.tsx @@ -1,4 +1,3 @@ -import { TLUserId } from '@tldraw/tlschema' import { track } from 'signia-react' import { useApp } from '../hooks/useApp' 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 { viewportPageBounds, zoomLevel } = app diff --git a/packages/editor/src/lib/config/TLUserPreferences.ts b/packages/editor/src/lib/config/TLUserPreferences.ts new file mode 100644 index 000000000..c6f0f34de --- /dev/null +++ b/packages/editor/src/lib/config/TLUserPreferences.ts @@ -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 = 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({ + 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('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 +} diff --git a/packages/editor/src/lib/config/TldrawEditorConfig.tsx b/packages/editor/src/lib/config/TldrawEditorConfig.tsx index 056643124..1778f7027 100644 --- a/packages/editor/src/lib/config/TldrawEditorConfig.tsx +++ b/packages/editor/src/lib/config/TldrawEditorConfig.tsx @@ -9,12 +9,10 @@ import { TLShape, TLStore, TLStoreProps, - TLUser, - TLUserId, createTLSchema, } from '@tldraw/tlschema' 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 { TLBookmarkUtil } from '../app/shapeutils/TLBookmarkUtil/TLBookmarkUtil' 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 { TLVideoUtil } from '../app/shapeutils/TLVideoUtil/TLVideoUtil' import { StateNodeConstructor } from '../app/statechart/StateNode' +import { TLUserPreferences, getUserPreferences, setUserPreferences } from './TLUserPreferences' // Secret shape types that don't have a shape util yet type ShapeTypesNotImplemented = 'icon' @@ -63,6 +62,8 @@ export type TldrawEditorConfigOptions = { > /** @internal */ derivePresenceState?: (store: TLStore) => Signal + userPreferences?: Signal + setUserPreferences?: (userPreferences: TLUserPreferences) => void } /** @public */ @@ -78,11 +79,18 @@ export class TldrawEditorConfig { // The schema used for the store incorporating any custom shapes readonly storeSchema: StoreSchema + readonly derivePresenceState: (store: TLStore) => Signal + readonly userPreferences: Signal + readonly setUserPreferences: (userPreferences: TLUserPreferences) => void constructor(opts = {} as TldrawEditorConfigOptions) { const { shapes = {}, tools = [], derivePresenceState } = opts this.tools = tools + this.derivePresenceState = derivePresenceState ?? (() => computed('presence', () => null)) + this.userPreferences = + opts.userPreferences ?? computed('userPreferences', () => getUserPreferences()) + this.setUserPreferences = opts.setUserPreferences ?? setUserPreferences this.shapeUtils = { ...DEFAULT_SHAPE_UTILS, @@ -91,7 +99,6 @@ export class TldrawEditorConfig { this.storeSchema = createTLSchema({ customShapes: shapes, - derivePresenceState: derivePresenceState, }) this.TLShape = this.storeSchema.types.shape as RecordType< @@ -103,18 +110,17 @@ export class TldrawEditorConfig { createStore(config: { /** The store's initial data. */ initialData?: StoreSnapshot - userId: TLUserId instanceId: TLInstanceId }): TLStore { let initialData = config.initialData if (initialData) { initialData = CLIENT_FIXUP_SCRIPT(initialData) } - return new Store({ + + return new Store({ schema: this.storeSchema, initialData, props: { - userId: config?.userId ?? TLUser.createId(), instanceId: config?.instanceId ?? TLInstance.createId(), documentId: TLDOCUMENT_ID, }, diff --git a/packages/editor/src/lib/hooks/useDarkMode.ts b/packages/editor/src/lib/hooks/useDarkMode.ts index 56075ef84..878c51ec9 100644 --- a/packages/editor/src/lib/hooks/useDarkMode.ts +++ b/packages/editor/src/lib/hooks/useDarkMode.ts @@ -6,7 +6,7 @@ import { useContainer } from './useContainer' export function useDarkMode() { const app = useApp() const container = useContainer() - const isDarkMode = useValue('isDarkMode', () => app.userDocumentSettings.isDarkMode, [app]) + const isDarkMode = useValue('isDarkMode', () => app.isDarkMode, [app]) React.useEffect(() => { if (isDarkMode) { diff --git a/packages/editor/src/lib/hooks/usePeerIds.ts b/packages/editor/src/lib/hooks/usePeerIds.ts index 9394503ac..db565c2c5 100644 --- a/packages/editor/src/lib/hooks/usePeerIds.ts +++ b/packages/editor/src/lib/hooks/usePeerIds.ts @@ -11,7 +11,7 @@ import { useApp } from './useApp' export function usePeerIds() { const app = useApp() const $presences = useMemo(() => { - return app.store.query.records('instance_presence') + return app.store.query.records('instance_presence', () => ({ userId: { neq: app.user.id } })) }, [app]) const $userIds = useComputed( diff --git a/packages/editor/src/lib/hooks/usePresence.ts b/packages/editor/src/lib/hooks/usePresence.ts index b75af8312..3a6368959 100644 --- a/packages/editor/src/lib/hooks/usePresence.ts +++ b/packages/editor/src/lib/hooks/usePresence.ts @@ -1,4 +1,4 @@ -import { TLInstancePresence, TLUserId } from '@tldraw/tlschema' +import { TLInstancePresence } from '@tldraw/tlschema' import { useMemo } from 'react' import { useValue } from 'signia-react' import { useApp } from './useApp' @@ -8,7 +8,7 @@ import { useApp } from './useApp' * @returns The list of peer UserIDs * @internal */ -export function usePresence(userId: TLUserId): TLInstancePresence | null { +export function usePresence(userId: string): TLInstancePresence | null { const app = useApp() const $presences = useMemo(() => { diff --git a/packages/editor/src/lib/test/TestApp.ts b/packages/editor/src/lib/test/TestApp.ts index 1e12fca62..fb588e755 100644 --- a/packages/editor/src/lib/test/TestApp.ts +++ b/packages/editor/src/lib/test/TestApp.ts @@ -13,7 +13,6 @@ import { TLPage, TLShapeId, TLShapePartial, - TLUser, createCustomShapeId, createShapeId, } from '@tldraw/tlschema' @@ -52,7 +51,6 @@ declare global { } } export const TEST_INSTANCE_ID = TLInstance.createCustomId('testInstance1') -export const TEST_USER_ID = TLUser.createCustomId('testUser1') export class TestApp extends App { constructor(options = {} as Partial>) { @@ -62,7 +60,6 @@ export class TestApp extends App { super({ config, store: config.createStore({ - userId: TEST_USER_ID, instanceId: TEST_INSTANCE_ID, }), getContainer: () => elm, diff --git a/packages/editor/src/lib/test/TldrawEditor.test.tsx b/packages/editor/src/lib/test/TldrawEditor.test.tsx index 4af352bb4..a053eb31b 100644 --- a/packages/editor/src/lib/test/TldrawEditor.test.tsx +++ b/packages/editor/src/lib/test/TldrawEditor.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react' -import { TLInstance, TLUser } from '@tldraw/tlschema' +import { TLInstance } from '@tldraw/tlschema' import { TldrawEditor } from '../TldrawEditor' import { TldrawEditorConfig } from '../config/TldrawEditorConfig' @@ -25,7 +25,6 @@ describe('', () => { const initialStore = config.createStore({ instanceId: TLInstance.createCustomId('test'), - userId: TLUser.createCustomId('test'), }) const onMount = jest.fn() @@ -54,7 +53,6 @@ describe('', () => { // re-render with a new store: const newStore = config.createStore({ instanceId: TLInstance.createCustomId('test'), - userId: TLUser.createCustomId('test'), }) rendered.rerender( diff --git a/packages/editor/src/lib/test/tools/groups.test.ts b/packages/editor/src/lib/test/tools/groups.test.ts index 6e64062dd..21a20497c 100644 --- a/packages/editor/src/lib/test/tools/groups.test.ts +++ b/packages/editor/src/lib/test/tools/groups.test.ts @@ -19,8 +19,10 @@ import { TLLineTool } from '../../app/statechart/TLLineTool/TLLineTool' import { TLNoteTool } from '../../app/statechart/TLNoteTool/TLNoteTool' import { TestApp } from '../TestApp' -let i = 0 -jest.mock('nanoid', () => ({ nanoid: () => 'id' + i++ })) +jest.mock('nanoid', () => { + let i = 0 + return { nanoid: () => 'id' + i++ } +}) const ids = { boxA: createCustomShapeId('boxA'), diff --git a/packages/file-format/api-report.md b/packages/file-format/api-report.md index 62df41a13..ee755f685 100644 --- a/packages/file-format/api-report.md +++ b/packages/file-format/api-report.md @@ -12,7 +12,6 @@ import { TldrawEditorConfig } from '@tldraw/editor'; import { TLInstanceId } from '@tldraw/editor'; import { TLStore } from '@tldraw/editor'; import { TLTranslationKey } from '@tldraw/ui'; -import { TLUserId } from '@tldraw/editor'; import { ToastsContextType } from '@tldraw/ui'; 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; // @public (undocumented) -export function parseTldrawJsonFile({ config, json, userId, instanceId, }: { +export function parseTldrawJsonFile({ config, json, instanceId, }: { config: TldrawEditorConfig; json: string; - userId: TLUserId; instanceId: TLInstanceId; }): Result; diff --git a/packages/file-format/src/lib/file.ts b/packages/file-format/src/lib/file.ts index a91c3829e..225cfcf8f 100644 --- a/packages/file-format/src/lib/file.ts +++ b/packages/file-format/src/lib/file.ts @@ -7,7 +7,6 @@ import { TLInstanceId, TLRecord, TLStore, - TLUserId, } from '@tldraw/editor' import { ID, @@ -84,12 +83,10 @@ export type TldrawFileParseError = export function parseTldrawJsonFile({ config, json, - userId, instanceId, }: { config: TldrawEditorConfig json: string - userId: TLUserId instanceId: TLInstanceId }): Result { // 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 // are invalid, we don't open the file try { - return Result.ok(config.createStore({ initialData: migrationResult.value, userId, instanceId })) + return Result.ok(config.createStore({ initialData: migrationResult.value, instanceId })) } catch (e) { // 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 @@ -211,7 +208,6 @@ export async function parseAndLoadDocument( config: new TldrawEditorConfig(), json: document, instanceId: app.instanceId, - userId: app.userId, }) if (!parseFileResult.ok) { let description diff --git a/packages/file-format/src/test/file.test.ts b/packages/file-format/src/test/file.test.ts index 4553b25ff..000a6a781 100644 --- a/packages/file-format/src/test/file.test.ts +++ b/packages/file-format/src/test/file.test.ts @@ -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 { assert } from '@tldraw/utils' import { parseTldrawJsonFile as _parseTldrawJsonFile, TldrawFile } from '../lib/file' @@ -7,7 +7,6 @@ const parseTldrawJsonFile = (config: TldrawEditorConfig, json: string) => _parseTldrawJsonFile({ config, json, - userId: TLUser.createCustomId('user'), instanceId: TLInstance.createCustomId('instance'), }) @@ -22,7 +21,7 @@ describe('parseTldrawJsonFile', () => { 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( new TldrawEditorConfig(), JSON.stringify({ not: 'a tldraw file' }) diff --git a/packages/tldraw/src/lib/Tldraw.tsx b/packages/tldraw/src/lib/Tldraw.tsx index 06f59b7db..ab4c327ff 100644 --- a/packages/tldraw/src/lib/Tldraw.tsx +++ b/packages/tldraw/src/lib/Tldraw.tsx @@ -1,12 +1,7 @@ import { Canvas, TldrawEditor, TldrawEditorConfig, TldrawEditorProps } from '@tldraw/editor' -import { - DEFAULT_DOCUMENT_NAME, - TAB_ID, - getUserData, - useLocalSyncClient, -} from '@tldraw/tlsync-client' +import { DEFAULT_DOCUMENT_NAME, TAB_ID, useLocalSyncClient } from '@tldraw/tlsync-client' import { ContextMenu, TldrawUi, TldrawUiContextProviderProps } from '@tldraw/ui' -import { useEffect, useState } from 'react' +import { useMemo } from 'react' /** @public */ export function Tldraw( @@ -28,31 +23,16 @@ export function Tldraw( ...rest } = props - const [_config, _setConfig] = useState(() => config ?? new TldrawEditorConfig()) - - useEffect(() => { - _setConfig(config ?? new TldrawEditorConfig()) - }, [config]) - - const userData = getUserData() - - const userId = props.userId ?? userData.id + const _config = useMemo(() => config ?? new TldrawEditorConfig(), [config]) const syncedStore = useLocalSyncClient({ instanceId, - userId, config: _config, universalPersistenceKey: persistenceKey, }) return ( - + diff --git a/packages/tlschema/api-report.md b/packages/tlschema/api-report.md index 60fc2e665..170cd1d65 100644 --- a/packages/tlschema/api-report.md +++ b/packages/tlschema/api-report.md @@ -87,6 +87,13 @@ export function createCustomShapeId(id: string): TLShapeId; // @internal (undocumented) export function createIntegrityChecker(store: TLStore): () => void; +// @internal (undocumented) +export const createPresenceStateDerivation: ($user: Signal<{ + id: string; + color: string; + name: string; +}>) => (store: TLStore) => Signal; + // @public (undocumented) export function createShapeId(): TLShapeId; @@ -107,7 +114,6 @@ export function createShapeValidator( // @public export function createTLSchema(opts?: { customShapes?: { [K in T["type"]]: CustomShapeInfo; } | undefined; - derivePresenceState?: ((store: TLStore) => Signal) | undefined; }): StoreSchema; // @public (undocumented) @@ -119,9 +125,6 @@ export const cursorValidator: T.Validator; // @internal (undocumented) export const dashValidator: T.Validator<"dashed" | "dotted" | "draw" | "solid">; -// @internal (undocumented) -export const defaultDerivePresenceState: (store: TLStore) => Signal; - // @public (undocumented) export const documentTypeValidator: T.Validator; @@ -351,6 +354,9 @@ export const geoShapeTypeValidator: T.Validator; // @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">; +// @public (undocumented) +export function getDefaultTranslationLocale(): TLTranslationLocale; + // @public (undocumented) export const groupShapeTypeMigrations: Migrations; @@ -432,6 +438,9 @@ export const pageTypeValidator: T.Validator; // @internal (undocumented) export const parentIdValidator: T.Validator; +// @public (undocumented) +export const pointerTypeValidator: T.Validator; + // @internal (undocumented) export const rootShapeTypeMigrations: Migrations; @@ -970,7 +979,7 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> { // (undocumented) exportBackground: boolean; // (undocumented) - followingUserId: null | TLUserId; + followingUserId: null | string; // (undocumented) isDebugMode: boolean; // (undocumented) @@ -984,13 +993,11 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> { // (undocumented) scribble: null | TLScribble; // (undocumented) - userId: TLUserId; - // (undocumented) zoomBrush: Box2dModel | null; } // @public (undocumented) -export const TLInstance: RecordType; +export const TLInstance: RecordType; // @public (undocumented) export type TLInstanceId = ID; @@ -1047,7 +1054,7 @@ export interface TLInstancePresence extends BaseRecord<'instance_presence', TLIn rotation: number; }; // (undocumented) - followingUserId: null | TLUserId; + followingUserId: null | string; // (undocumented) instanceId: TLInstanceId; // (undocumented) @@ -1059,13 +1066,13 @@ export interface TLInstancePresence extends BaseRecord<'instance_presence', TLIn // (undocumented) selectedIds: TLShapeId[]; // (undocumented) - userId: TLUserId; + userId: string; // (undocumented) userName: string; } // @public (undocumented) -export const TLInstancePresence: RecordType; +export const TLInstancePresence: RecordType; // @public (undocumented) export type TLInstancePropsForNextShape = Pick; @@ -1133,8 +1140,27 @@ export type TLPageId = ID; // @public (undocumented) export type TLParentId = TLPageId | TLShapeId; +// @public +export interface TLPointer extends BaseRecord<'pointer', TLPointerId> { + // (undocumented) + lastActivityTimestamp: number; + // (undocumented) + x: number; + // (undocumented) + y: number; +} + // @public (undocumented) -export type TLRecord = TLAsset | TLCamera | TLDocument | TLInstance | TLInstancePageState | TLInstancePresence | TLPage | TLShape | TLUser | TLUserDocument | TLUserPresence; +export const TLPointer: RecordType; + +// @public (undocumented) +export const TLPOINTER_ID: TLPointerId; + +// @public (undocumented) +export type TLPointerId = ID; + +// @public (undocumented) +export type TLRecord = TLAsset | TLCamera | TLDocument | TLInstance | TLInstancePageState | TLInstancePresence | TLPage | TLPointer | TLShape | TLUserDocument; // @public (undocumented) export type TLScribble = { @@ -1192,7 +1218,6 @@ export type TLStore = Store; // @public (undocumented) export type TLStoreProps = { - userId: TLUserId; instanceId: TLInstanceId; documentId: typeof TLDOCUMENT_ID; }; @@ -1262,21 +1287,8 @@ export type TLUiColorType = SetValue; // @public export type TLUnknownShape = TLBaseShape; -// @public -export interface TLUser extends BaseRecord<'user', TLUserId> { - // (undocumented) - locale: string; - // (undocumented) - name: string; -} - -// @public (undocumented) -export const TLUser: RecordType; - // @public export interface TLUserDocument extends BaseRecord<'user_document', TLUserDocumentId> { - // (undocumented) - isDarkMode: boolean; // (undocumented) isGridMode: boolean; // (undocumented) @@ -1289,41 +1301,14 @@ export interface TLUserDocument extends BaseRecord<'user_document', TLUserDocume lastUpdatedPageId: ID | null; // (undocumented) lastUsedTabId: ID | null; - // (undocumented) - userId: TLUserId; } // @public (undocumented) -export const TLUserDocument: RecordType; +export const TLUserDocument: RecordType; // @public (undocumented) export type TLUserDocumentId = ID; -// @public (undocumented) -export type TLUserId = ID; - -// @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; - -// @public (undocumented) -export type TLUserPresenceId = ID; - // @public (undocumented) export type TLVerticalAlignType = SetValue; @@ -1354,27 +1339,12 @@ export type TLVideoShapeProps = { // @public (undocumented) export const uiColorTypeValidator: T.Validator<"accent" | "black" | "laser" | "muted-1" | "selection-fill" | "selection-stroke" | "white">; -// @internal (undocumented) -export const USER_COLORS: string[]; - // @public (undocumented) export const userDocumentTypeMigrations: Migrations; // @public (undocumented) export const userDocumentTypeValidator: T.Validator; -// @internal (undocumented) -export const userIdValidator: T.Validator; - -// @public (undocumented) -export const userPresenceTypeMigrations: Migrations; - -// @public (undocumented) -export const userPresenceTypeValidator: T.Validator; - -// @public (undocumented) -export const userTypeValidator: T.Validator; - // @public (undocumented) export interface Vec2dModel { // (undocumented) diff --git a/packages/tlschema/src/TLRecord.ts b/packages/tlschema/src/TLRecord.ts index a75a10c52..b753cc144 100644 --- a/packages/tlschema/src/TLRecord.ts +++ b/packages/tlschema/src/TLRecord.ts @@ -5,10 +5,9 @@ import { TLInstance } from './records/TLInstance' import { TLInstancePageState } from './records/TLInstancePageState' import { TLInstancePresence } from './records/TLInstancePresence' import { TLPage } from './records/TLPage' +import { TLPointer } from './records/TLPointer' import { TLShape } from './records/TLShape' -import { TLUser } from './records/TLUser' import { TLUserDocument } from './records/TLUserDocument' -import { TLUserPresence } from './records/TLUserPresence' /** @public */ export type TLRecord = @@ -19,7 +18,6 @@ export type TLRecord = | TLInstancePageState | TLPage | TLShape - | TLUser | TLUserDocument - | TLUserPresence | TLInstancePresence + | TLPointer diff --git a/packages/tlschema/src/TLStore.ts b/packages/tlschema/src/TLStore.ts index b9bd21417..363bb8085 100644 --- a/packages/tlschema/src/TLStore.ts +++ b/packages/tlschema/src/TLStore.ts @@ -6,9 +6,8 @@ import { TLDOCUMENT_ID, TLDocument } from './records/TLDocument' import { TLInstance, TLInstanceId } from './records/TLInstance' import { TLInstancePageState } from './records/TLInstancePageState' import { TLPage } from './records/TLPage' -import { TLUser, TLUserId } from './records/TLUser' +import { TLPOINTER_ID, TLPointer } from './records/TLPointer' import { TLUserDocument } from './records/TLUserDocument' -import { TLUserPresence } from './records/TLUserPresence' function sortByIndex(a: T, b: T) { if (a.index < b.index) { @@ -19,22 +18,6 @@ function sortByIndex(a: T, b: T) { return 0 } -/** @internal */ -export const USER_COLORS = [ - '#FF802B', - '#EC5E41', - '#F2555A', - '#F04F88', - '#E34BA9', - '#BD54C6', - '#9D5BD2', - '#7B66DC', - '#02B1CC', - '#11B3A3', - '#39B178', - '#55B467', -] - function redactRecordForErrorReporting(record: any) { if (record.typeName === 'asset') { if ('src' in record) { @@ -55,7 +38,6 @@ export type TLStoreSnapshot = StoreSnapshot /** @public */ export type TLStoreProps = { - userId: TLUserId instanceId: TLInstanceId documentId: typeof TLDOCUMENT_ID } @@ -90,10 +72,6 @@ export const onValidationFailure: StoreSchemaOptions< throw error } -function getRandomColor() { - return USER_COLORS[Math.floor(Math.random() * USER_COLORS.length)] -} - function getDefaultPages() { return [TLPage.create({ name: 'Page 1', index: 'a1' })] } @@ -101,32 +79,32 @@ function getDefaultPages() { /** @internal */ export function createIntegrityChecker(store: TLStore): () => void { const $pages = store.query.records('page') - const $userDocumentSettings = store.query.record('user_document', () => ({ - userId: { eq: store.props.userId }, - })) + const $userDocumentSettings = store.query.record('user_document') const $instanceState = store.query.record('instance', () => ({ 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 ensureStoreIsUsable = (): void => { - const { userId, instanceId: tabId } = store.props + const { instanceId: tabId } = store.props // make sure we have exactly one document if (!store.has(TLDOCUMENT_ID)) { store.put([TLDocument.create({ id: TLDOCUMENT_ID })]) 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 const userDocumentSettings = $userDocumentSettings.value if (!userDocumentSettings) { - store.put([TLUserDocument.create({ userId })]) + store.put([TLUserDocument.create({})]) return ensureStoreIsUsable() } @@ -151,7 +129,6 @@ export function createIntegrityChecker(store: TLStore): () => void { store.put([ TLInstance.create({ id: tabId, - userId, currentPageId, propsForNextShape, exportBackground: true, @@ -169,22 +146,6 @@ export function createIntegrityChecker(store: TLStore): () => void { 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) { const instancePageStates = $instancePageStates.value.filter( (tps) => tps.pageId === page.id && tps.instanceId === tabId diff --git a/packages/tlschema/src/createPresenceStateDerivation.ts b/packages/tlschema/src/createPresenceStateDerivation.ts new file mode 100644 index 000000000..49c1e9996 --- /dev/null +++ b/packages/tlschema/src/createPresenceStateDerivation.ts @@ -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 => { + 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, + }) + }) + } diff --git a/packages/tlschema/src/createTLSchema.ts b/packages/tlschema/src/createTLSchema.ts index 564f6bbb7..06f33bc37 100644 --- a/packages/tlschema/src/createTLSchema.ts +++ b/packages/tlschema/src/createTLSchema.ts @@ -1,9 +1,7 @@ import { Migrations, StoreSchema, createRecordType, defineMigrations } from '@tldraw/tlstore' import { T } from '@tldraw/tlvalidate' -import { Signal } from 'signia' import { TLRecord } from './TLRecord' -import { TLStore, TLStoreProps, createIntegrityChecker, onValidationFailure } from './TLStore' -import { defaultDerivePresenceState } from './defaultDerivePresenceState' +import { TLStoreProps, createIntegrityChecker, onValidationFailure } from './TLStore' import { TLAsset } from './records/TLAsset' import { TLCamera } from './records/TLCamera' import { TLDocument } from './records/TLDocument' @@ -11,10 +9,9 @@ import { TLInstance } from './records/TLInstance' import { TLInstancePageState } from './records/TLInstancePageState' import { TLInstancePresence } from './records/TLInstancePresence' import { TLPage } from './records/TLPage' +import { TLPointer } from './records/TLPointer' import { TLShape, TLUnknownShape, rootShapeTypeMigrations } from './records/TLShape' -import { TLUser } from './records/TLUser' import { TLUserDocument } from './records/TLUserDocument' -import { TLUserPresence } from './records/TLUserPresence' import { storeMigrations } from './schema' import { arrowShapeTypeMigrations, arrowShapeTypeValidator } from './shapes/TLArrowShape' import { bookmarkShapeTypeMigrations, bookmarkShapeTypeValidator } from './shapes/TLBookmarkShape' @@ -61,10 +58,9 @@ type CustomShapeInfo = { export function createTLSchema( opts = {} as { customShapes?: { [K in T['type']]: CustomShapeInfo } - derivePresenceState?: (store: TLStore) => Signal } ) { - const { customShapes = {}, derivePresenceState } = opts + const { customShapes = {} } = opts const defaultShapeSubTypeEntries = Object.entries(DEFAULT_SHAPES) as [ TLShape['type'], @@ -76,7 +72,7 @@ export function createTLSchema( CustomShapeInfo ][] - // 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 // 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. @@ -119,16 +115,14 @@ export function createTLSchema( instance_page_state: TLInstancePageState, page: TLPage, shape: shapeRecord, - user: TLUser, user_document: TLUserDocument, - user_presence: TLUserPresence, instance_presence: TLInstancePresence, + pointer: TLPointer, }, { snapshotMigrations: storeMigrations, onValidationFailure, createIntegrityChecker: createIntegrityChecker, - derivePresenceState: derivePresenceState ?? defaultDerivePresenceState, } ) } diff --git a/packages/tlschema/src/defaultDerivePresenceState.ts b/packages/tlschema/src/defaultDerivePresenceState.ts deleted file mode 100644 index 74ea52905..000000000 --- a/packages/tlschema/src/defaultDerivePresenceState.ts +++ /dev/null @@ -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 => { - 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, - }) - }) -} diff --git a/packages/tlschema/src/index.ts b/packages/tlschema/src/index.ts index ccd19d9ba..7e7b04c95 100644 --- a/packages/tlschema/src/index.ts +++ b/packages/tlschema/src/index.ts @@ -1,6 +1,5 @@ export { type TLRecord } from './TLRecord' export { - USER_COLORS, createIntegrityChecker, onValidationFailure, type TLStore, @@ -24,8 +23,8 @@ export { type TLVideoAsset, } from './assets/TLVideoAsset' export { createAssetValidator, type TLBaseAsset } from './assets/asset-validation' +export { createPresenceStateDerivation } from './createPresenceStateDerivation' export { createTLSchema } from './createTLSchema' -export { defaultDerivePresenceState } from './defaultDerivePresenceState' export { CLIENT_FIXUP_SCRIPT, fixupRecord } from './fixup' export { type Box2dModel, type Vec2dModel } from './geometry-types' export { @@ -53,6 +52,12 @@ export { } from './records/TLInstancePageState' export { TLInstancePresence } from './records/TLInstancePresence' export { TLPage, pageTypeValidator, type TLPageId } from './records/TLPage' +export { + TLPOINTER_ID, + TLPointer, + pointerTypeValidator, + type TLPointerId, +} from './records/TLPointer' export { createCustomShapeId, createShapeId, @@ -69,19 +74,12 @@ export { type TLShapeProps, type TLUnknownShape, } from './records/TLShape' -export { TLUser, userTypeValidator, type TLUserId } from './records/TLUser' export { TLUserDocument, userDocumentTypeMigrations, userDocumentTypeValidator, type TLUserDocumentId, } from './records/TLUserDocument' -export { - TLUserPresence, - userPresenceTypeMigrations, - userPresenceTypeValidator, - type TLUserPresenceId, -} from './records/TLUserPresence' export { storeMigrations } from './schema' export { TL_ARROW_TERMINAL_TYPE, @@ -218,6 +216,7 @@ export { type TLStyleType, type TLVerticalAlignType, } from './style-types' +export { getDefaultTranslationLocale } from './translations' export { TL_CURSOR_TYPES, TL_HANDLE_TYPES, @@ -255,5 +254,4 @@ export { shapeIdValidator, sizeValidator, splineValidator, - userIdValidator, } from './validation' diff --git a/packages/tlschema/src/migrations.test.ts b/packages/tlschema/src/migrations.test.ts index ed2ca18d2..bd540a627 100644 --- a/packages/tlschema/src/migrations.test.ts +++ b/packages/tlschema/src/migrations.test.ts @@ -3,13 +3,12 @@ import { structuredClone } from '@tldraw/utils' import fs from 'fs' import { imageAssetMigrations } from './assets/TLImageAsset' import { videoAssetMigrations } from './assets/TLVideoAsset' -import { instanceTypeMigrations } from './records/TLInstance' +import { instanceTypeMigrations, instanceTypeVersions } from './records/TLInstance' import { instancePageStateMigrations } from './records/TLInstancePageState' import { instancePresenceTypeMigrations } from './records/TLInstancePresence' import { rootShapeTypeMigrations, TLShape } from './records/TLShape' import { userDocumentTypeMigrations, userDocumentVersions } from './records/TLUserDocument' -import { userPresenceTypeMigrations } from './records/TLUserPresence' -import { storeMigrations } from './schema' +import { storeMigrations, storeVersions } from './schema' import { arrowShapeTypeMigrations } from './shapes/TLArrowShape' import { bookmarkShapeTypeMigrations } from './shapes/TLBookmarkShape' import { drawShapeTypeMigrations } from './shapes/TLDrawShape' @@ -214,7 +213,7 @@ describe('Store removing Icon and Code shapes', () => { } as any), ].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) }) @@ -230,7 +229,7 @@ describe('Store removing Icon and Code shapes', () => { ].map((shape) => [shape.id, shape]) ) - storeMigrations.migrators[1].down(snapshot) + storeMigrations.migrators[storeVersions.RemoveCodeAndIconShapeTypes].down(snapshot) 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', () => { const { up, down } = instanceTypeMigrations.migrators[7] test('up works as expected', () => { @@ -639,7 +627,7 @@ describe('Add crop=null to image shapes', () => { }) 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', () => { 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 --- */ for (const migrator of allMigrators) { diff --git a/packages/tlschema/src/records/TLInstance.ts b/packages/tlschema/src/records/TLInstance.ts index 9c1e8b678..43a551515 100644 --- a/packages/tlschema/src/records/TLInstance.ts +++ b/packages/tlschema/src/records/TLInstance.ts @@ -17,12 +17,10 @@ import { pageIdValidator, sizeValidator, splineValidator, - userIdValidator, verticalAlignValidator, } from '../validation' import { TLPageId } from './TLPage' import { TLShapeProps } from './TLShape' -import { TLUserId } from './TLUser' /** @public */ export type TLInstancePropsForNextShape = Pick @@ -35,9 +33,8 @@ export type TLInstancePropsForNextShape = Pick * @public */ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> { - userId: TLUserId currentPageId: TLPageId - followingUserId: TLUserId | null + followingUserId: string | null brush: Box2dModel | null propsForNextShape: TLInstancePropsForNextShape cursor: TLCursor @@ -59,9 +56,8 @@ export const instanceTypeValidator: T.Validator = T.model( T.object({ typeName: T.literal('instance'), id: idValidator('instance'), - userId: userIdValidator, currentPageId: pageIdValidator, - followingUserId: userIdValidator.nullable(), + followingUserId: T.string.nullable(), brush: T.boxModel.nullable(), propsForNextShape: T.object({ color: colorValidator, @@ -101,11 +97,14 @@ const Versions = { AddZoom: 8, AddVerticalAlign: 9, AddScribbleDelay: 10, + RemoveUserId: 11, } as const +export { Versions as instanceTypeVersions } + /** @public */ export const instanceTypeMigrations = defineMigrations({ - currentVersion: Versions.AddScribbleDelay, + currentVersion: Versions.RemoveUserId, migrators: { [Versions.AddTransparentExportBgs]: { up: (instance: TLInstance) => { @@ -235,6 +234,14 @@ export const instanceTypeMigrations = defineMigrations({ 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('instance', { validator: instanceTypeValidator, scope: 'instance', }).withDefaultProperties( - (): Omit => ({ + (): Omit => ({ followingUserId: null, propsForNextShape: { opacity: '1', diff --git a/packages/tlschema/src/records/TLInstancePresence.ts b/packages/tlschema/src/records/TLInstancePresence.ts index b1bf40471..bf6169825 100644 --- a/packages/tlschema/src/records/TLInstancePresence.ts +++ b/packages/tlschema/src/records/TLInstancePresence.ts @@ -2,16 +2,15 @@ import { BaseRecord, createRecordType, defineMigrations, ID } from '@tldraw/tlst import { T } from '@tldraw/tlvalidate' import { Box2dModel } from '../geometry-types' import { cursorTypeValidator, scribbleTypeValidator, TLCursor, TLScribble } from '../ui-types' -import { idValidator, userIdValidator } from '../validation' +import { idValidator } from '../validation' import { TLInstanceId } from './TLInstance' import { TLPageId } from './TLPage' import { TLShapeId } from './TLShape' -import { TLUserId } from './TLUser' /** @public */ export interface TLInstancePresence extends BaseRecord<'instance_presence', TLInstancePresenceID> { instanceId: TLInstanceId - userId: TLUserId + userId: string userName: string lastActivityTimestamp: number color: string // can be any hex color @@ -21,7 +20,7 @@ export interface TLInstancePresence extends BaseRecord<'instance_presence', TLIn brush: Box2dModel | null scribble: TLScribble | null screenBounds: Box2dModel - followingUserId: TLUserId | null + followingUserId: string | null cursor: { x: number y: number @@ -41,10 +40,10 @@ export const instancePresenceTypeValidator: T.Validator = T. instanceId: idValidator('instance'), typeName: T.literal('instance_presence'), id: idValidator('instance_presence'), - userId: userIdValidator, + userId: T.string, userName: T.string, lastActivityTimestamp: T.number, - followingUserId: userIdValidator.nullable(), + followingUserId: T.string.nullable(), cursor: T.object({ x: T.number, y: T.number, @@ -95,4 +94,28 @@ export const TLInstancePresence = createRecordType('instance migrations: instancePresenceTypeMigrations, validator: instancePresenceTypeValidator, 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, +})) diff --git a/packages/tlschema/src/records/TLPointer.ts b/packages/tlschema/src/records/TLPointer.ts new file mode 100644 index 000000000..a3a668734 --- /dev/null +++ b/packages/tlschema/src/records/TLPointer.ts @@ -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 + +/** @public */ +export const pointerTypeValidator: T.Validator = T.model( + 'pointer', + T.object({ + typeName: T.literal('pointer'), + id: idValidator('pointer'), + x: T.number, + y: T.number, + lastActivityTimestamp: T.number, + }) +) + +/** @public */ +export const TLPointer = createRecordType('pointer', { + validator: pointerTypeValidator, + scope: 'instance', +}).withDefaultProperties( + (): Omit => ({ + x: 0, + y: 0, + lastActivityTimestamp: 0, + }) +) + +/** @public */ +export const TLPOINTER_ID = TLPointer.createCustomId('pointer') + +/** @public */ +export const pointerTypeMigrations = defineMigrations({}) diff --git a/packages/tlschema/src/records/TLUser.ts b/packages/tlschema/src/records/TLUser.ts deleted file mode 100644 index 3cc7b4afd..000000000 --- a/packages/tlschema/src/records/TLUser.ts +++ /dev/null @@ -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 - -/** @public */ -export const userTypeValidator: T.Validator = T.model( - 'user', - T.object({ - typeName: T.literal('user'), - id: userIdValidator, - name: T.string, - locale: T.string, - }) -) - -/** @public */ -export const TLUser = createRecordType('user', { - validator: userTypeValidator, - scope: 'instance', -}).withDefaultProperties((): Omit => { - let locale = 'en' - if (typeof window !== 'undefined' && window.navigator) { - locale = getDefaultTranslationLocale(window.navigator.languages) - } - return { - name: 'New User', - locale, - } -}) - -/** @public */ -export const userTypeMigrations = defineMigrations({}) diff --git a/packages/tlschema/src/records/TLUserDocument.ts b/packages/tlschema/src/records/TLUserDocument.ts index 430881109..10239d403 100644 --- a/packages/tlschema/src/records/TLUserDocument.ts +++ b/packages/tlschema/src/records/TLUserDocument.ts @@ -1,9 +1,8 @@ import { BaseRecord, createRecordType, defineMigrations, ID } from '@tldraw/tlstore' import { T } from '@tldraw/tlvalidate' -import { idValidator, instanceIdValidator, pageIdValidator, userIdValidator } from '../validation' +import { idValidator, instanceIdValidator, pageIdValidator } from '../validation' import { TLInstance } from './TLInstance' import { TLPage } from './TLPage' -import { TLUserId } from './TLUser' /** * TLUserDocument @@ -13,10 +12,8 @@ import { TLUserId } from './TLUser' * @public */ export interface TLUserDocument extends BaseRecord<'user_document', TLUserDocumentId> { - userId: TLUserId isPenMode: boolean isGridMode: boolean - isDarkMode: boolean isMobileMode: boolean isSnapMode: boolean lastUpdatedPageId: ID | null @@ -32,10 +29,8 @@ export const userDocumentTypeValidator: T.Validator = T.model( T.object({ typeName: T.literal('user_document'), id: idValidator('user_document'), - userId: userIdValidator, isPenMode: T.boolean, isGridMode: T.boolean, - isDarkMode: T.boolean, isMobileMode: T.boolean, isSnapMode: T.boolean, lastUpdatedPageId: pageIdValidator.nullable(), @@ -47,11 +42,14 @@ export const Versions = { AddSnapMode: 1, AddMissingIsMobileMode: 2, RemoveIsReadOnly: 3, + RemoveUserIdAndIsDarkMode: 4, } as const +export { Versions as userDocumentVersions } + /** @public */ export const userDocumentTypeMigrations = defineMigrations({ - currentVersion: Versions.RemoveIsReadOnly, + currentVersion: Versions.RemoveUserIdAndIsDarkMode, migrators: { [Versions.AddSnapMode]: { up: (userDocument: TLUserDocument) => { @@ -77,6 +75,18 @@ export const userDocumentTypeMigrations = defineMigrations({ 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 */ @@ -92,12 +102,9 @@ export const TLUserDocument = createRecordType('user_document', /* STEP 6: Add any new default values for properties here */ isPenMode: false, isGridMode: false, - isDarkMode: false, isMobileMode: false, isSnapMode: false, lastUpdatedPageId: null, lastUsedTabId: null, }) ) - -export { Versions as userDocumentVersions } diff --git a/packages/tlschema/src/records/TLUserPresence.ts b/packages/tlschema/src/records/TLUserPresence.ts deleted file mode 100644 index 8bc7a37ef..000000000 --- a/packages/tlschema/src/records/TLUserPresence.ts +++ /dev/null @@ -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 - -/** @public */ -export const userPresenceTypeValidator: T.Validator = T.model( - 'user_presence', - T.object({ - typeName: T.literal('user_presence'), - id: idValidator('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('user_presence', { - migrations: userPresenceTypeMigrations, - validator: userPresenceTypeValidator, - scope: 'instance', -}).withDefaultProperties( - (): Omit => ({ - lastUsedInstanceId: null, - lastActivityTimestamp: 0, - cursor: { x: 0, y: 0 }, - viewportPageBounds: { x: 0, y: 0, w: 1, h: 1 }, - color: '#000000', - }) -) diff --git a/packages/tlschema/src/schema.ts b/packages/tlschema/src/schema.ts index 432688543..301aaa9a7 100644 --- a/packages/tlschema/src/schema.ts +++ b/packages/tlschema/src/schema.ts @@ -4,11 +4,14 @@ import { TLRecord } from './TLRecord' const Versions = { RemoveCodeAndIconShapeTypes: 1, AddInstancePresenceType: 2, + RemoveTLUserAndPresenceAndAddPointer: 3, } as const +export { Versions as storeVersions } + /** @public */ export const storeMigrations = defineMigrations({ - currentVersion: Versions.AddInstancePresenceType, + currentVersion: Versions.RemoveTLUserAndPresenceAndAddPointer, migrators: { [Versions.RemoveCodeAndIconShapeTypes]: { up: (store: StoreSnapshot) => { @@ -33,5 +36,17 @@ export const storeMigrations = defineMigrations({ ) }, }, + [Versions.RemoveTLUserAndPresenceAndAddPointer]: { + up: (store: StoreSnapshot) => { + return Object.fromEntries( + Object.entries(store).filter(([_, v]) => !v.typeName.match(/^(user|user_presence)$/)) + ) + }, + down: (store: StoreSnapshot) => { + return Object.fromEntries( + Object.entries(store).filter(([_, v]) => v.typeName !== 'pointer') + ) + }, + }, }, }) diff --git a/packages/tlschema/src/translations.test.ts b/packages/tlschema/src/translations.test.ts index 285f3f276..919fbd737 100644 --- a/packages/tlschema/src/translations.test.ts +++ b/packages/tlschema/src/translations.test.ts @@ -1,4 +1,4 @@ -import { getDefaultTranslationLocale } from './translations' +import { _getDefaultTranslationLocale } from './translations' type DefaultLanguageTest = { name: string @@ -37,7 +37,7 @@ describe('Choosing a sensible default translation locale', () => { for (const test of tests) { it(test.name, () => { - expect(getDefaultTranslationLocale(test.input)).toEqual(test.output) + expect(_getDefaultTranslationLocale(test.input)).toEqual(test.output) }) } }) diff --git a/packages/tlschema/src/translations.ts b/packages/tlschema/src/translations.ts index e74ac729c..e53395c80 100644 --- a/packages/tlschema/src/translations.ts +++ b/packages/tlschema/src/translations.ts @@ -9,7 +9,13 @@ type TLListedTranslations = TLListedTranslation[] type TLTranslationLocale = TLListedTranslations[number]['locale'] /** @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) { const supportedLocale = getSupportedLocale(locale) if (supportedLocale) { diff --git a/packages/tlschema/src/validation.ts b/packages/tlschema/src/validation.ts index 7f9b02099..8ac0249a7 100644 --- a/packages/tlschema/src/validation.ts +++ b/packages/tlschema/src/validation.ts @@ -4,7 +4,6 @@ import type { TLAssetId } from './records/TLAsset' import type { TLInstanceId } from './records/TLInstance' import type { TLPageId } from './records/TLPage' import type { TLParentId, TLShapeId } from './records/TLShape' -import type { TLUserId } from './records/TLUser' import { TLAlignType, TL_ALIGN_TYPES_WITH_LEGACY_STUFF, @@ -33,8 +32,6 @@ export function idValidator>( }) } /** @internal */ -export const userIdValidator = idValidator('user') -/** @internal */ export const assetIdValidator = idValidator('asset') /** @internal */ export const pageIdValidator = idValidator('page') diff --git a/packages/tlstore/api-report.md b/packages/tlstore/api-report.md index c77a86bf7..9b25b3535 100644 --- a/packages/tlstore/api-report.md +++ b/packages/tlstore/api-report.md @@ -6,7 +6,6 @@ import { Atom } from 'signia'; import { Computed } from 'signia'; -import { Signal } from 'signia'; // @public export type AllRecords> = ExtractR>; @@ -288,8 +287,6 @@ export class StoreSchema { createIntegrityChecker(store: Store): (() => void) | undefined; // (undocumented) get currentStoreVersion(): number; - // @internal (undocumented) - derivePresenceState(store: Store): Signal | undefined; // (undocumented) migratePersistedRecord(record: R, persistedSchema: SerializedSchema, direction?: 'down' | 'up'): MigrationResult; // (undocumented) @@ -317,7 +314,6 @@ export type StoreSchemaOptions = { recordBefore: null | R; }) => R; createIntegrityChecker?: (store: Store) => void; - derivePresenceState?: (store: Store) => Signal; }; // @public diff --git a/packages/tlstore/src/lib/StoreSchema.ts b/packages/tlstore/src/lib/StoreSchema.ts index dcb7f6f32..8ff3caf34 100644 --- a/packages/tlstore/src/lib/StoreSchema.ts +++ b/packages/tlstore/src/lib/StoreSchema.ts @@ -1,5 +1,4 @@ import { getOwnProperty, objectMapValues } from '@tldraw/utils' -import { Signal } from 'signia' import { IdOf, UnknownRecord } from './BaseRecord' import { RecordType } from './RecordType' import { Store, StoreSnapshot } from './Store' @@ -49,8 +48,6 @@ export type StoreSchemaOptions = { }) => R /** @internal */ createIntegrityChecker?: (store: Store) => void - /** @internal */ - derivePresenceState?: (store: Store) => Signal } /** @public */ @@ -244,11 +241,6 @@ export class StoreSchema { return this.options.createIntegrityChecker?.(store) ?? undefined } - /** @internal */ - derivePresenceState(store: Store): Signal | undefined { - return this.options.derivePresenceState?.(store) - } - serialize(): SerializedSchema { return { schemaVersion: 1, diff --git a/packages/tlsync-client/api-report.md b/packages/tlsync-client/api-report.md index d3c953fe2..b1abf2504 100644 --- a/packages/tlsync-client/api-report.md +++ b/packages/tlsync-client/api-report.md @@ -6,7 +6,6 @@ import { RecordsDiff } from '@tldraw/tlstore'; import { SerializedSchema } from '@tldraw/tlstore'; -import { Store } from '@tldraw/tlstore'; import { StoreSnapshot } from '@tldraw/tlstore'; import { SyncedStore } from '@tldraw/editor'; import { TldrawEditorConfig } from '@tldraw/editor'; @@ -14,8 +13,6 @@ import { TLInstanceId } from '@tldraw/editor'; import { TLRecord } from '@tldraw/editor'; import { TLStore } from '@tldraw/editor'; import { TLStoreSchema } from '@tldraw/editor'; -import { TLUser } from '@tldraw/editor'; -import { TLUserId } from '@tldraw/editor'; // @public (undocumented) export function addDbName(name: string): void; @@ -40,9 +37,6 @@ export const DEFAULT_DOCUMENT_NAME: any; // @public (undocumented) export function getAllIndexDbNames(): string[]; -// @public (undocumented) -export function getUserData(): TLUser; - // @public (undocumented) export function hardReset({ shouldReload }?: { shouldReload?: boolean | undefined; @@ -69,9 +63,6 @@ export function storeSnapshotInIndexedDb(universalPersistenceKey: string, schema didCancel?: () => boolean; }): Promise; -// @public (undocumented) -export function subscribeToUserData(store: Store): () => void; - // @public (undocumented) export const TAB_ID: TLInstanceId; @@ -97,10 +88,9 @@ export class TLLocalSyncClient { } // @public -export function useLocalSyncClient({ universalPersistenceKey, instanceId, userId, config, }: { +export function useLocalSyncClient({ universalPersistenceKey, instanceId, config, }: { universalPersistenceKey: string; instanceId: TLInstanceId; - userId: TLUserId; config: TldrawEditorConfig; }): SyncedStore; diff --git a/packages/tlsync-client/src/index.ts b/packages/tlsync-client/src/index.ts index a839080e3..8763347f0 100644 --- a/packages/tlsync-client/src/index.ts +++ b/packages/tlsync-client/src/index.ts @@ -13,6 +13,4 @@ export { TAB_ID, addDbName, getAllIndexDbNames, - getUserData, - subscribeToUserData, } from './lib/persistence-constants' diff --git a/packages/tlsync-client/src/lib/TLLocalSyncClient.test.ts b/packages/tlsync-client/src/lib/TLLocalSyncClient.test.ts index 726bedfb1..ee365fceb 100644 --- a/packages/tlsync-client/src/lib/TLLocalSyncClient.test.ts +++ b/packages/tlsync-client/src/lib/TLLocalSyncClient.test.ts @@ -1,11 +1,4 @@ -import { - TldrawEditorConfig, - TLInstance, - TLInstanceId, - TLPage, - TLUser, - TLUserId, -} from '@tldraw/editor' +import { TldrawEditorConfig, TLInstance, TLInstanceId, TLPage } from '@tldraw/editor' import { promiseWithResolve } from '@tldraw/utils' import * as idb from './indexedDb' import { TLLocalSyncClient } from './TLLocalSyncClient' @@ -31,11 +24,9 @@ class BroadcastChannelMock { function testClient( instanceId: TLInstanceId = TLInstance.createCustomId('test'), - userId: TLUserId = TLUser.createCustomId('test'), channel = new BroadcastChannelMock('test') ) { const store = new TldrawEditorConfig().createStore({ - userId, instanceId, }) 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: {} } }) }) -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() await tick() 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() }) -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() await tick() jest.advanceTimersByTime(1000) diff --git a/packages/tlsync-client/src/lib/TLLocalSyncClient.ts b/packages/tlsync-client/src/lib/TLLocalSyncClient.ts index 8aef7b0ae..db6392fd5 100644 --- a/packages/tlsync-client/src/lib/TLLocalSyncClient.ts +++ b/packages/tlsync-client/src/lib/TLLocalSyncClient.ts @@ -155,7 +155,12 @@ export class TLLocalSyncClient { // 3. Merge the changes into the REAL STORE this.store.mergeRemoteChanges(() => { // 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' + ) }) } diff --git a/packages/tlsync-client/src/lib/hooks/useLocalSyncClient.ts b/packages/tlsync-client/src/lib/hooks/useLocalSyncClient.ts index aeab97120..814319ffc 100644 --- a/packages/tlsync-client/src/lib/hooks/useLocalSyncClient.ts +++ b/packages/tlsync-client/src/lib/hooks/useLocalSyncClient.ts @@ -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 '../hardReset' -import { subscribeToUserData } from '../persistence-constants' import { TLLocalSyncClient } from '../TLLocalSyncClient' /** @@ -13,12 +12,10 @@ import { TLLocalSyncClient } from '../TLLocalSyncClient' export function useLocalSyncClient({ universalPersistenceKey, instanceId, - userId, config, }: { universalPersistenceKey: string instanceId: TLInstanceId - userId: TLUserId config: TldrawEditorConfig }): SyncedStore { 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, { universalPersistenceKey, @@ -50,14 +47,11 @@ export function useLocalSyncClient({ }, }) - const userDataUnsubcribe = subscribeToUserData(store) - return () => { setState((prevState) => (prevState?.id === id ? null : prevState)) - userDataUnsubcribe() client.close() } - }, [instanceId, universalPersistenceKey, config, userId]) + }, [instanceId, universalPersistenceKey, config]) return state?.syncedStore ?? { status: 'loading' } } diff --git a/packages/tlsync-client/src/lib/persistence-constants.ts b/packages/tlsync-client/src/lib/persistence-constants.ts index c834374e9..086131136 100644 --- a/packages/tlsync-client/src/lib/persistence-constants.ts +++ b/packages/tlsync-client/src/lib/persistence-constants.ts @@ -1,6 +1,4 @@ -import { TLInstance, TLInstanceId, TLUser, uniqueId } from '@tldraw/editor' -import { Store } from '@tldraw/tlstore' -import { atom, react } from 'signia' +import { TLInstance, TLInstanceId, uniqueId } from '@tldraw/editor' 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( - '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) { - 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 // again, stored in localStorage const defaultDocumentKey = 'TLDRAW_DEFAULT_DOCUMENT_NAME_v2' diff --git a/packages/ui/src/lib/components/LanguageMenu.tsx b/packages/ui/src/lib/components/LanguageMenu.tsx index 7915f1106..758a4f0a1 100644 --- a/packages/ui/src/lib/components/LanguageMenu.tsx +++ b/packages/ui/src/lib/components/LanguageMenu.tsx @@ -9,9 +9,7 @@ export function LanguageMenu() { const { languages, currentLanguage } = useLanguages() const handleLanguageSelect = useCallback( - (locale: TLTranslationLocale) => { - app.updateUser({ locale }) - }, + (locale: TLTranslationLocale) => app.setLocale(locale), [app] ) diff --git a/packages/ui/src/lib/components/NavigationZone/Minimap.tsx b/packages/ui/src/lib/components/NavigationZone/Minimap.tsx index b6dc7d56f..1899a4f60 100644 --- a/packages/ui/src/lib/components/NavigationZone/Minimap.tsx +++ b/packages/ui/src/lib/components/NavigationZone/Minimap.tsx @@ -33,7 +33,7 @@ export const Minimap = track(function Minimap({ const minimap = React.useMemo(() => new MinimapManager(app, app.devicePixelRatio), [app]) - const isDarkMode = app.userDocumentSettings.isDarkMode + const isDarkMode = app.isDarkMode React.useEffect(() => { // Must check after render diff --git a/packages/ui/src/lib/hooks/useMenuSchema.tsx b/packages/ui/src/lib/hooks/useMenuSchema.tsx index bb7f71f3d..66c8c4b6f 100644 --- a/packages/ui/src/lib/hooks/useMenuSchema.tsx +++ b/packages/ui/src/lib/hooks/useMenuSchema.tsx @@ -49,7 +49,7 @@ export function MenuSchemaProvider({ overrides, children }: MenuSchemaProviderPr const breakpoint = useBreakpoint() 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 isSnapMode = useValue('isSnapMode', () => app.userDocumentSettings.isSnapMode, [app]) const isToolLock = useValue('isToolLock', () => app.instanceState.isToolLocked, [app]) diff --git a/packages/ui/src/lib/hooks/useTranslation/useLanguages.tsx b/packages/ui/src/lib/hooks/useTranslation/useLanguages.tsx index 93160dd6a..bde618c4d 100644 --- a/packages/ui/src/lib/hooks/useTranslation/useLanguages.tsx +++ b/packages/ui/src/lib/hooks/useTranslation/useLanguages.tsx @@ -4,5 +4,5 @@ import { LANGUAGES } from './languages' /** @public */ export function useLanguages() { const app = useApp() - return { languages: LANGUAGES, currentLanguage: app.user.locale } + return { languages: LANGUAGES, currentLanguage: app.locale } } diff --git a/packages/ui/src/lib/hooks/useTranslation/useTranslation.tsx b/packages/ui/src/lib/hooks/useTranslation/useTranslation.tsx index fde3334f0..9ab37cf60 100644 --- a/packages/ui/src/lib/hooks/useTranslation/useTranslation.tsx +++ b/packages/ui/src/lib/hooks/useTranslation/useTranslation.tsx @@ -35,7 +35,7 @@ export const TranslationProvider = track(function TranslationProvider({ children, }: TranslationProviderProps) { const app = useApp() - const locale = app.userSettings.locale + const locale = app.locale const getAssetUrl = useAssetUrls() const [currentTranslation, setCurrentTranslation] = React.useState(() => {