Independent instance state persistence (#1493)

This PR 

- Removes UserDocumentRecordType
  - moving isSnapMode to user preferences
  - moving isGridMode and isPenMode to InstanceRecordType
  - deleting the other properties which are no longer needed.
 
- Creates a separate pipeline for persisting instance state.
  
Previously the instance state records were stored alongside the document
state records, and in order to load the state for a particular instance
(in our case, a particular tab) you needed to pass the 'instanceId'
prop. This prop ended up totally pervading the public API and people ran
into all kinds of issues with it, e.g. using the same instance id in
multiple editor instances.

There was also an issue whereby it was hard for us to clean up old
instance state so the idb table ended up bloating over time.
  
This PR makes it so that rather than passing an instanceId, you load the
instance state yourself while creating the store. It provides tools to
make that easy.

- Undoes the assumption that we might have more than one instance's
state in the store.

- Like `document`, `instance` now has a singleton id
`instance:instance`.
- Page state ids and camera ids are no longer random, but rather derive
from the page they belong to. This is like having a foreign primary key
in SQL databases. It's something i'd love to support fully as part of
the RecordType/Store api.

Tests to do

- [x] Test Migrations
- [x] Test Store.listen filtering
- [x] Make type sets in Store public and readonly
- [x] Test RecordType.createId
- [x] Test Instance state snapshot loading/exporting
- [x] Manual test File I/O
- [x] Manual test Vscode extension with multiple tabs
- [x] Audit usages of store.query
- [x] Audit usages of changed types: InstanceRecordType, 'instance',
InstancePageStateRecordType, 'instance_page_state', 'user_document',
'camera', CameraRecordType, InstancePresenceRecordType,
'instance_presence'
- [x] Test user preferences
- [x] Manual test isSnapMode and isGridMode and isPenMode
- [ ] Test indexedDb functions
- [x] Add instanceId stuff back


### Change Type

- [x] `major` — Breaking Change


### Test Plan

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

- [ ] Unit Tests
- [ ] Webdriver tests

### Release Notes

- Add a brief release note for your PR here.
This commit is contained in:
David Sheldrick 2023-06-05 15:11:07 +01:00 committed by GitHub
parent 0f89309604
commit f15a8797f0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
70 changed files with 1873 additions and 974 deletions

View file

@ -5,7 +5,7 @@ import '@tldraw/tldraw/ui.css'
export default function Example() {
return (
<div className="tldraw__editor">
<Tldraw autoFocus />
<Tldraw persistenceKey="tldraw_example" autoFocus />
</div>
)
}

View file

@ -1,4 +1,4 @@
import { InstancePresenceRecordType, InstanceRecordType, Tldraw } from '@tldraw/tldraw'
import { InstancePresenceRecordType, Tldraw } from '@tldraw/tldraw'
import '@tldraw/tldraw/editor.css'
import '@tldraw/tldraw/ui.css'
import { useRef } from 'react'
@ -19,10 +19,9 @@ export default function UserPresenceExample() {
// store with their cursor position etc.
const peerPresence = InstancePresenceRecordType.create({
id: InstancePresenceRecordType.createCustomId('peer-1-presence'),
id: InstancePresenceRecordType.createId(editor.store.id),
currentPageId: editor.currentPageId,
userId: 'peer-1',
instanceId: InstanceRecordType.createCustomId('peer-1-editor-instance'),
userName: 'Peer 1',
cursor: { x: 0, y: 0, type: 'default', rotation: 0 },
})

View file

@ -1,4 +1,4 @@
import { Canvas, ContextMenu, TAB_ID, TldrawEditor, TldrawUi, createTLStore } from '@tldraw/tldraw'
import { Canvas, ContextMenu, TldrawEditor, TldrawUi, createTLStore } from '@tldraw/tldraw'
import '@tldraw/tldraw/editor.css'
import '@tldraw/tldraw/ui.css'
import { throttle } from '@tldraw/utils'
@ -7,15 +7,15 @@ import { useLayoutEffect, useState } from 'react'
const PERSISTENCE_KEY = 'example-3'
export default function PersistenceExample() {
const [store] = useState(() => createTLStore({ instanceId: TAB_ID }))
const [loadingStore, setLoadingStore] = useState<
const [store] = useState(() => createTLStore())
const [loadingState, setLoadingState] = useState<
{ status: 'loading' } | { status: 'ready' } | { status: 'error'; error: string }
>({
status: 'loading',
})
useLayoutEffect(() => {
setLoadingStore({ status: 'loading' })
setLoadingState({ status: 'loading' })
// Get persisted data from local storage
const persistedSnapshot = localStorage.getItem(PERSISTENCE_KEY)
@ -24,12 +24,12 @@ export default function PersistenceExample() {
try {
const snapshot = JSON.parse(persistedSnapshot)
store.loadSnapshot(snapshot)
setLoadingStore({ status: 'ready' })
setLoadingState({ status: 'ready' })
} catch (error: any) {
setLoadingStore({ status: 'error', error: error.message }) // Something went wrong
setLoadingState({ status: 'error', error: error.message }) // Something went wrong
}
} else {
setLoadingStore({ status: 'ready' }) // Nothing persisted, continue with the empty store
setLoadingState({ status: 'ready' }) // Nothing persisted, continue with the empty store
}
// Each time the store changes, run the (debounced) persist function
@ -45,7 +45,7 @@ export default function PersistenceExample() {
}
}, [store])
if (loadingStore.status === 'loading') {
if (loadingState.status === 'loading') {
return (
<div className="tldraw__editor">
<h2>Loading...</h2>
@ -53,11 +53,11 @@ export default function PersistenceExample() {
)
}
if (loadingStore.status === 'error') {
if (loadingState.status === 'error') {
return (
<div className="tldraw__editor">
<h2>Error!</h2>
<p>{loadingStore.error}</p>
<p>{loadingState.error}</p>
</div>
)
}

View file

@ -12,7 +12,7 @@ This folder contains the source for the tldraw VS Code extension.
In the root folder:
- Run `yarn dev:vscode`.
- Run `yarn dev-vscode`.
This will start the development server for the `apps/vscode/editor` project and open the `apps/vscode/extension` folder in a new VS Code window.

View file

@ -1,11 +1,4 @@
import {
Canvas,
Editor,
ErrorBoundary,
TAB_ID,
TldrawEditor,
setRuntimeOverrides,
} from '@tldraw/editor'
import { Canvas, Editor, ErrorBoundary, TldrawEditor, setRuntimeOverrides } from '@tldraw/editor'
import { linksUiOverrides } from './utils/links'
// eslint-disable-next-line import/no-internal-modules
import '@tldraw/editor/editor.css'
@ -129,7 +122,6 @@ function TldrawInner({ uri, assetSrc, isDarkMode, fileContents }: TLDrawInnerPro
return (
<TldrawEditor
assetUrls={assetUrls}
instanceId={TAB_ID}
persistenceKey={uri}
onCreateBookmarkFromUrl={onCreateBookmarkFromUrl}
autoFocus

View file

@ -3,8 +3,7 @@
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": true,
"checkJs": true,
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"removeComments": true,

View file

@ -3,8 +3,7 @@
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": true,
"checkJs": true,
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"removeComments": true,

View file

@ -57,7 +57,6 @@ import { TLHighlightShape } from '@tldraw/tlschema';
import { TLImageAsset } from '@tldraw/tlschema';
import { TLImageShape } from '@tldraw/tlschema';
import { TLInstance } from '@tldraw/tlschema';
import { TLInstanceId } from '@tldraw/tlschema';
import { TLInstancePageState } from '@tldraw/tlschema';
import { TLInstancePresence } from '@tldraw/tlschema';
import { TLInstancePropsForNextShape } from '@tldraw/tlschema';
@ -82,9 +81,9 @@ import { TLStyleType } from '@tldraw/tlschema';
import { TLTextShape } from '@tldraw/tlschema';
import { TLTextShapeProps } from '@tldraw/tlschema';
import { TLUnknownShape } from '@tldraw/tlschema';
import { TLUserDocument } from '@tldraw/tlschema';
import { TLVideoAsset } from '@tldraw/tlschema';
import { TLVideoShape } from '@tldraw/tlschema';
import { UnknownRecord } from '@tldraw/store';
import { Vec2d } from '@tldraw/primitives';
import { Vec2dModel } from '@tldraw/tlschema';
import { VecLike } from '@tldraw/primitives';
@ -261,6 +260,9 @@ export function createEmbedShapeAtPoint(editor: Editor, url: string, point: Vec2
doesResize?: boolean;
}): void;
// @public
export function createSessionStateSnapshotSignal(store: TLStore): Signal<null | TLSessionStateSnapshot>;
// @public (undocumented)
export function createShapesFromFiles(editor: Editor, files: File[], position: VecLike, _ignoreParent?: boolean): Promise<void>;
@ -573,7 +575,6 @@ export class Editor extends EventEmitter<TLEventMap> {
isPanning: boolean;
pointerVelocity: Vec2d;
};
get instanceId(): TLInstanceId;
get instanceState(): TLInstance;
interrupt(): this;
get isChangingStyle(): boolean;
@ -763,15 +764,12 @@ export class Editor extends EventEmitter<TLEventMap> {
updateCullingBounds(): this;
// @internal (undocumented)
updateDocumentSettings(settings: Partial<TLDocument>): void;
updateInstanceState(partial: Partial<Omit<TLInstance, 'currentPageId' | 'documentId' | 'userId'>>, ephemeral?: boolean, squashing?: boolean): this;
updateInstanceState(partial: Partial<Omit<TLInstance, 'currentPageId'>>, ephemeral?: boolean, squashing?: boolean): this;
updatePage(partial: RequiredKeys<TLPage, 'id'>, squashing?: boolean): this;
updateShapes(partials: (null | TLShapePartial | undefined)[], squashing?: boolean): this;
updateUserDocumentSettings(partial: Partial<TLUserDocument>, ephemeral?: boolean): this;
updateViewportScreenBounds(center?: boolean): this;
// @internal (undocumented)
readonly user: UserPreferencesManager;
// (undocumented)
get userDocumentSettings(): TLUserDocument;
get viewportPageBounds(): Box2d;
get viewportPageCenter(): Vec2d;
get viewportScreenBounds(): Box2d;
@ -836,6 +834,9 @@ export function ErrorScreen({ children }: {
// @public (undocumented)
export const EVENT_NAME_MAP: Record<Exclude<TLEventName, TLPinchEventName>, keyof TLEventHandlers>;
// @internal (undocumented)
export function extractSessionStateFromLegacySnapshot(store: Record<string, UnknownRecord>): null | TLSessionStateSnapshot;
// @internal (undocumented)
export const featureFlags: {
peopleMenu: DebugFlag<boolean>;
@ -1283,6 +1284,9 @@ export function LoadingScreen({ children }: {
children: any;
}): JSX.Element;
// @public
export function loadSessionStateSnapshotIntoStore(store: TLStore, snapshot: TLSessionStateSnapshot): void;
// @public (undocumented)
export function loopToHtmlElement(elm: Element): HTMLElement;
@ -1977,8 +1981,8 @@ export function SVGContainer({ children, className, ...rest }: SVGContainerProps
// @public (undocumented)
export type SVGContainerProps = React_3.HTMLAttributes<SVGElement>;
// @public (undocumented)
export const TAB_ID: TLInstanceId;
// @public
export const TAB_ID: string;
// @public (undocumented)
export const TEXT_PROPS: {
@ -2188,8 +2192,8 @@ export type TldrawEditorProps = {
} | {
store?: undefined;
initialData?: StoreSnapshot<TLRecord>;
instanceId?: TLInstanceId;
persistenceKey?: string;
sessionId?: string;
defaultName?: string;
});
@ -2527,6 +2531,35 @@ export type TLResizeMode = 'resize_bounds' | 'scale_shape';
// @public (undocumented)
export type TLSelectionHandle = RotateCorner | SelectionCorner | SelectionEdge;
// @public
export interface TLSessionStateSnapshot {
// (undocumented)
currentPageId: TLPageId;
// (undocumented)
exportBackground: boolean;
// (undocumented)
isDebugMode: boolean;
// (undocumented)
isFocusMode: boolean;
// (undocumented)
isGridMode: boolean;
// (undocumented)
isToolLocked: boolean;
// (undocumented)
pageStates: Array<{
pageId: TLPageId;
camera: {
x: number;
y: number;
z: number;
};
selectedIds: TLShapeId[];
focusLayerId: null | TLShapeId;
}>;
// (undocumented)
version: number;
}
// @public (undocumented)
export interface TLShapeUtilConstructor<T extends TLUnknownShape, U extends ShapeUtil<T> = ShapeUtil<T>> {
// (undocumented)
@ -2590,6 +2623,8 @@ export interface TLUserPreferences {
// (undocumented)
isDarkMode: boolean;
// (undocumented)
isSnapMode: boolean;
// (undocumented)
locale: string;
// (undocumented)
name: string;
@ -2626,6 +2661,7 @@ export const useEditor: () => Editor;
// @internal (undocumented)
export function useLocalStore(opts?: {
persistenceKey?: string | undefined;
sessionId?: string | undefined;
} & TLStoreOptions): TLStoreWithStatus;
// @internal (undocumented)

View file

@ -115,6 +115,13 @@ export {
} from './lib/components/ErrorBoundary'
export { HTMLContainer, type HTMLContainerProps } from './lib/components/HTMLContainer'
export { SVGContainer, type SVGContainerProps } from './lib/components/SVGContainer'
export {
TAB_ID,
createSessionStateSnapshotSignal,
extractSessionStateFromLegacySnapshot,
loadSessionStateSnapshotIntoStore,
type TLSessionStateSnapshot,
} from './lib/config/TLSessionStateSnapshot'
export {
USER_COLORS,
getUserPreferences,
@ -244,5 +251,4 @@ export {
export { getPointerInfo, getSvgPathFromStroke, getSvgPathFromStrokePoints } from './lib/utils/svg'
export { type TLStoreWithStatus } from './lib/utils/sync/StoreWithStatus'
export { hardReset } from './lib/utils/sync/hardReset'
export { TAB_ID } from './lib/utils/sync/persistence-constants'
export { openWindow } from './lib/utils/window-open'

View file

@ -1,5 +1,5 @@
import { Store, StoreSnapshot } from '@tldraw/store'
import { TLAsset, TLInstanceId, TLRecord, TLStore } from '@tldraw/tlschema'
import { TLAsset, TLRecord, TLStore } from '@tldraw/tlschema'
import { annotateError } from '@tldraw/utils'
import React, { memo, useCallback, useLayoutEffect, useState, useSyncExternalStore } from 'react'
import { Editor } from './app/Editor'
@ -24,7 +24,6 @@ import { usePreloadAssets } from './hooks/usePreloadAssets'
import { useSafariFocusOutFix } from './hooks/useSafariFocusOutFix'
import { useZoomCss } from './hooks/useZoomCss'
import { TLStoreWithStatus } from './utils/sync/StoreWithStatus'
import { TAB_ID } from './utils/sync/persistence-constants'
/** @public */
export type TldrawEditorProps = {
@ -111,16 +110,22 @@ export type TldrawEditorProps = {
*/
initialData?: StoreSnapshot<TLRecord>
/**
* The id of the editor instance (e.g. a browser tab if the editor will have only one tldraw app per
* tab). If not given, one will be generated.
*/
instanceId?: TLInstanceId
/**
* The id under which to sync and persist the editor's data.
* The id under which to sync and persist the editor's data. If none is given tldraw will not sync or persist
* the editor's data.
*/
persistenceKey?: string
/**
* The initial document name to use for the new store.
* When tldraw reloads a document from local persistence, it will try to bring back the
* editor UI state (e.g. camera position, which shapes are selected). It does this using a sessionId,
* which by default is unique per browser tab. If you wish to have more fine-grained
* control over this behavior, you can provide your own sessionId.
*
* If it can't find saved UI state for the given sessionId, it will use the most recently saved
* UI state for the given persistenceKey if available.
*/
sessionId?: string
/**
* The default initial document name. e.g. 'Untitled Document'
*/
defaultName?: string
}
@ -173,13 +178,13 @@ export const TldrawEditor = memo(function TldrawEditor(props: TldrawEditorProps)
})
function TldrawEditorWithOwnStore(props: TldrawEditorProps & { store: undefined }) {
const { defaultName, initialData, instanceId = TAB_ID, shapes, persistenceKey } = props
const { defaultName, initialData, shapes, persistenceKey, sessionId } = props
const syncedStore = useLocalStore({
customShapes: shapes,
instanceId,
initialData,
persistenceKey,
sessionId,
defaultName,
})

View file

@ -39,9 +39,9 @@ import {
TLDocument,
TLFrameShape,
TLGroupShape,
TLINSTANCE_ID,
TLImageAsset,
TLInstance,
TLInstanceId,
TLInstancePageState,
TLNullableShapeProps,
TLPOINTER_ID,
@ -57,7 +57,6 @@ import {
TLSizeStyle,
TLStore,
TLUnknownShape,
TLUserDocument,
TLVideoAsset,
Vec2dModel,
createShapeId,
@ -262,7 +261,7 @@ export class Editor extends EventEmitter<TLEventMap> {
prev.typeName === 'instance_page_state' &&
next.typeName === 'instance_page_state'
) {
this._tabStateDidChange(prev, next)
this._pageStateDidChange(prev, next)
}
this._updateDepth--
@ -451,21 +450,6 @@ export class Editor extends EventEmitter<TLEventMap> {
*/
getContainer: () => HTMLElement
/**
* The editor's instanceId (defined in its store.props).
*
* @example
*
* ```ts
* const instanceId = editor.instanceId
* ```
*
* @public
*/
get instanceId(): TLInstanceId {
return this.store.props.instanceId
}
/** @internal */
annotateError(
error: unknown,
@ -657,7 +641,7 @@ export class Editor extends EventEmitter<TLEventMap> {
// Clear any reset timeout
clearTimeout(this._isChangingStyleTimeout)
if (v) {
// If we've set to true, set a new reset timeout to change the value back to false after 2 seonds
// If we've set to true, set a new reset timeout to change the value back to false after 2 seconds
this._isChangingStyleTimeout = setTimeout(() => (this.isChangingStyle = false), 2000)
}
}
@ -672,7 +656,7 @@ export class Editor extends EventEmitter<TLEventMap> {
if (isPageId(shape.parentId)) {
return this.getTransform(shape)
}
// some weird circular type thing here that I had to work wround with (as any)
// some weird circular type thing here that I had to work around with (as any)
const parent = (this._pageTransformCache as any).get(shape.parentId)
return Matrix2d.Compose(parent, this.getTransform(shape))
@ -1185,17 +1169,6 @@ export class Editor extends EventEmitter<TLEventMap> {
/** @internal */
private _complete() {
const { lastUpdatedPageId, lastUsedTabId } = this.userDocumentSettings
if (lastUsedTabId !== this.instanceId || lastUpdatedPageId !== this.currentPageId) {
this.store.put([
{
...this.userDocumentSettings,
lastUsedTabId: this.instanceId,
lastUpdatedPageId: this.currentPageId,
},
])
}
for (const parentId of this._invalidParents) {
this._invalidParents.delete(parentId)
const parent = this.getShapeById(parentId)
@ -1439,7 +1412,7 @@ export class Editor extends EventEmitter<TLEventMap> {
allMovingIds.add(id)
})
for (const instancePageState of this.store.query.records('instance_page_state').value) {
for (const instancePageState of this._allPageStates.value) {
if (instancePageState.pageId === next.parentId) continue
const nextPageState = this._cleanupInstancePageState(instancePageState, allMovingIds)
@ -1459,9 +1432,9 @@ export class Editor extends EventEmitter<TLEventMap> {
}
/** @internal */
private _tabStateDidChange(prev: TLInstancePageState, next: TLInstancePageState) {
private _pageStateDidChange(prev: TLInstancePageState, next: TLInstancePageState) {
if (prev?.selectedIds !== next?.selectedIds) {
// ensure that descendants and ascenants are not selected at the same time
// ensure that descendants and ancestors are not selected at the same time
const filtered = next.selectedIds.filter((id) => {
let parentId = this.getShapeById(id)?.parentId
while (isShapeId(parentId)) {
@ -1490,14 +1463,11 @@ export class Editor extends EventEmitter<TLEventMap> {
/** @internal */
private _pageWillBeDeleted(page: TLPage) {
// page was deleted, need to check whether it's the current page and select another one if so
const instanceStates = this.store.query.exec('instance', { currentPageId: { eq: page.id } })
if (this.instanceState.currentPageId !== page.id) return
if (!instanceStates.length) return
const backupPageId = this.pages.find((p) => p.id !== page.id)?.id
if (!backupPageId) return
this.store.put(instanceStates.map((state) => ({ ...state, currentPageId: backupPageId })))
this.store.put([{ ...this.instanceState, currentPageId: backupPageId }])
}
/* -------------------- Shortcuts ------------------- */
@ -1527,12 +1497,12 @@ export class Editor extends EventEmitter<TLEventMap> {
}
get isSnapMode() {
return this.userDocumentSettings.isSnapMode
return this.user.isSnapMode
}
setSnapMode(isSnapMode: boolean) {
if (isSnapMode !== this.isSnapMode) {
this.updateUserDocumentSettings({ isSnapMode }, true)
this.user.updateUserPreferences({ isSnapMode })
}
return this
}
@ -1581,22 +1551,13 @@ export class Editor extends EventEmitter<TLEventMap> {
return this
}
/** @internal */
@computed private get _userDocumentSettings() {
return this.store.query.record('user_document')
}
get userDocumentSettings(): TLUserDocument {
return this._userDocumentSettings.value!
}
get isGridMode() {
return this.userDocumentSettings.isGridMode
return this.instanceState.isGridMode
}
setGridMode(isGridMode: boolean): this {
if (isGridMode !== this.isGridMode) {
this.updateUserDocumentSettings({ isGridMode }, true)
this.updateInstanceState({ isGridMode }, true)
}
return this
}
@ -1638,7 +1599,7 @@ export class Editor extends EventEmitter<TLEventMap> {
/** The current tab state */
get instanceState(): TLInstance {
return this.store.get(this.instanceId)!
return this.store.get(TLINSTANCE_ID)!
}
get cursor() {
@ -1658,17 +1619,8 @@ export class Editor extends EventEmitter<TLEventMap> {
}
/** @internal */
@computed private get _pageState() {
return this.store.query.record(
'instance_page_state',
() => {
return {
pageId: { eq: this.currentPageId },
instanceId: { eq: this.instanceId },
}
},
'editor._pageState'
)
@computed private get pageStateId() {
return InstancePageStateRecordType.createId(this.currentPageId)
}
/**
@ -1676,13 +1628,17 @@ export class Editor extends EventEmitter<TLEventMap> {
*
* @public
*/
get pageState(): TLInstancePageState {
return this._pageState.value!
@computed get pageState(): TLInstancePageState {
return this.store.get(this.pageStateId)!
}
@computed
private get cameraId() {
return CameraRecordType.createId(this.currentPageId)
}
/** The current camera. */
@computed get camera() {
return this.store.get(this.pageState.cameraId)!
return this.store.get(this.cameraId)!
}
/** The current camera zoom level. */
@ -1760,9 +1716,7 @@ export class Editor extends EventEmitter<TLEventMap> {
/** @internal */
@computed private get _pageStates() {
return this.store.query.records('instance_page_state', () => ({
instanceId: { eq: this.instanceId },
}))
return this.store.query.records('instance_page_state')
}
/**
@ -2756,7 +2710,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public
*/
screenToPage(x: number, y: number, z = 0.5, camera: Vec2dModel = this.camera) {
const { screenBounds } = this.store.unsafeGetWithoutCapture(this.instanceId)!
const { screenBounds } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)!
const { x: cx, y: cy, z: cz = 1 } = camera
return {
x: (x - screenBounds.x) / cz - cx,
@ -3521,7 +3475,7 @@ export class Editor extends EventEmitter<TLEventMap> {
isEditing: false,
/** Whether the user is panning. */
isPanning: false,
/** Veclocity of mouse pointer, in pixels per millisecond */
/** Velocity of mouse pointer, in pixels per millisecond */
pointerVelocity: new Vec2d(),
}
@ -3535,7 +3489,7 @@ export class Editor extends EventEmitter<TLEventMap> {
const { previousScreenPoint, previousPagePoint, currentScreenPoint, currentPagePoint } =
this.inputs
const { screenBounds } = this.store.unsafeGetWithoutCapture(this.instanceId)!
const { screenBounds } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)!
const { x: sx, y: sy, z: sz } = info.point
const { x: cx, y: cy, z: cz } = this.camera
@ -3962,7 +3916,7 @@ export class Editor extends EventEmitter<TLEventMap> {
inputs.isDragging = false
if (this.isMenuOpen) {
// Surpressing pointerup here as <ContextMenu/> doesn't seem to do what we what here.
// Suppressing pointerup here as <ContextMenu/> doesn't seem to do what we what here.
return
}
@ -4059,7 +4013,7 @@ export class Editor extends EventEmitter<TLEventMap> {
break
}
case 'key_repeat': {
// nooop
// noop
break
}
}
@ -4667,7 +4621,7 @@ export class Editor extends EventEmitter<TLEventMap> {
)
partial.parentId = parentId
// If the parent is a shape (rather than a page) then insert the
// shapes into the shape's children. Ajust the point and page rotation to be
// shapes into the shape's children. Adjust the point and page rotation to be
// preserved relative to the parent.
if (isShapeId(parentId)) {
const point = this.getPointInShapeSpace(this.getShapeById(parentId)!, {
@ -5083,40 +5037,6 @@ export class Editor extends EventEmitter<TLEventMap> {
}
)
/**
* Update user document settings
*
* @example
*
* ```ts
* editor.updateUserDocumentSettings({ isGridMode: true })
* ```
*
* @public
*/
updateUserDocumentSettings(partial: Partial<TLUserDocument>, ephemeral = false) {
this._updateUserDocumentSettings(partial, ephemeral)
return this
}
/** @internal */
private _updateUserDocumentSettings = this.history.createCommand(
'updateUserDocumentSettings',
(partial: Partial<TLUserDocument>, ephemeral = false) => {
const prev = this.userDocumentSettings
const next = { ...prev, ...partial }
return { data: { prev, next }, ephemeral }
},
{
do: ({ next }) => {
this.store.put([next])
},
undo: ({ prev }) => {
this.store.put([prev])
},
}
)
/**
* Get the editor's locale.
* @public
@ -5229,12 +5149,13 @@ export class Editor extends EventEmitter<TLEventMap> {
: getIndexAbove(topIndex),
})
const newCamera = CameraRecordType.create({})
const newCamera = CameraRecordType.create({
id: CameraRecordType.createId(newPage.id),
})
const newTabPageState = InstancePageStateRecordType.create({
id: InstancePageStateRecordType.createId(newPage.id),
pageId: newPage.id,
instanceId: this.instanceId,
cameraId: newCamera.id,
})
return {
@ -5257,9 +5178,9 @@ export class Editor extends EventEmitter<TLEventMap> {
])
this.updateCullingBounds()
},
undo: ({ newPage, prevPageState, prevTabState, newTabPageState }) => {
undo: ({ newPage, prevPageState, prevTabState, newTabPageState, newCamera }) => {
this.store.put([prevPageState, prevTabState])
this.store.remove([newTabPageState.id, newPage.id, newTabPageState.cameraId])
this.store.remove([newTabPageState.id, newPage.id, newCamera.id])
this.updateCullingBounds()
},
@ -5656,7 +5577,7 @@ export class Editor extends EventEmitter<TLEventMap> {
const realContainerStyle = getComputedStyle(realContainerEl)
// Get the styles from the container. We'll use these to pull out colors etc.
// NOTE: We can force force a light theme here becasue we don't want export
// NOTE: We can force force a light theme here because we don't want export
const fakeContainerEl = document.createElement('div')
fakeContainerEl.className = `tl-container tl-theme__${
darkMode ? 'dark' : 'light'
@ -6824,13 +6745,13 @@ export class Editor extends EventEmitter<TLEventMap> {
) {
const { type } = options.initialShape
// If a shape is not aligned with the scale axis we need to treat it differently to avoid skewing.
// Instead of skewing we normalise the scale aspect ratio (i.e. keep the same scale magnitude in both axes)
// Instead of skewing we normalize the scale aspect ratio (i.e. keep the same scale magnitude in both axes)
// and then after applying the scale to the shape we also rotate it if required and translate it so that it's center
// point ends up in the right place.
const shapeScale = new Vec2d(scale.x, scale.y)
// // make sure we are contraining aspect ratio, and using the smallest scale axis to avoid shapes getting bigger
// // make sure we are constraining aspect ratio, and using the smallest scale axis to avoid shapes getting bigger
// // than the selection bounding box
if (Math.abs(scale.x) > Math.abs(scale.y)) {
shapeScale.x = Math.sign(scale.x) * Math.abs(scale.y)
@ -6867,7 +6788,7 @@ export class Editor extends EventEmitter<TLEventMap> {
options.scaleAxisRotation
)
// now caculate how far away the shape is from where it needs to be
// now calculate how far away the shape is from where it needs to be
const currentPageCenter = this.getPageCenterById(id)
const currentPagePoint = this.getPagePointById(id)
if (!currentPageCenter || !currentPagePoint) return this
@ -6978,7 +6899,7 @@ export class Editor extends EventEmitter<TLEventMap> {
// resize the shape's local bounding box
const myScale = new Vec2d(scale.x, scale.y)
// the shape is algined with the rest of the shpaes in the selection, but may be
// the shape is aligned with the rest of the shapes in the selection, but may be
// 90deg offset from the main rotation of the selection, in which case
// we need to flip the width and height scale factors
const areWidthAndHeightAlignedWithCorrectAxis = approximately(
@ -7244,7 +7165,7 @@ export class Editor extends EventEmitter<TLEventMap> {
}
/**
* Remove a shpae from the existing set of selected shapes.
* Remove a shape from the existing set of selected shapes.
*
* @example
*
@ -7359,13 +7280,14 @@ export class Editor extends EventEmitter<TLEventMap> {
{
do: ({ toId }) => {
if (!this.getPageStateByPageId(toId)) {
const camera = CameraRecordType.create({})
const camera = CameraRecordType.create({
id: CameraRecordType.createId(toId),
})
this.store.put([
camera,
InstancePageStateRecordType.create({
id: InstancePageStateRecordType.createId(toId),
pageId: toId,
instanceId: this.instanceId,
cameraId: camera.id,
}),
])
}
@ -7387,7 +7309,7 @@ export class Editor extends EventEmitter<TLEventMap> {
/** Set the current user tab state */
updateInstanceState(
partial: Partial<Omit<TLInstance, 'documentId' | 'userId' | 'currentPageId'>>,
partial: Partial<Omit<TLInstance, 'currentPageId'>>,
ephemeral = false,
squashing = false
) {
@ -7398,11 +7320,7 @@ export class Editor extends EventEmitter<TLEventMap> {
/** @internal */
private _updateInstanceState = this.history.createCommand(
'updateTabState',
(
partial: Partial<Omit<TLInstance, 'documentId' | 'userId' | 'currentPageId'>>,
ephemeral = false,
squashing = false
) => {
(partial: Partial<Omit<TLInstance, 'currentPageId'>>, ephemeral = false, squashing = false) => {
const prev = this.instanceState
const next = { ...prev, ...partial }

View file

@ -51,7 +51,7 @@ describe('shapeIdsInCurrentPage', () => {
{ type: 'geo', id: ids.box2 },
{ type: 'geo', id: ids.box3 },
])
const id = PageRecordType.createCustomId('page2')
const id = PageRecordType.createId('page2')
editor.createPage('New Page 2', id)
editor.setCurrentPageId(id)
editor.createShapes([

View file

@ -34,4 +34,8 @@ export class UserPreferencesManager {
get color() {
return this.user.userPreferences.value.color
}
get isSnapMode() {
return this.user.userPreferences.value.isSnapMode
}
}

View file

@ -126,7 +126,7 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
protected updateBookmarkAsset = debounce((shape: TLBookmarkShape) => {
const { url } = shape.props
const assetId: TLAssetId = AssetRecordType.createCustomId(getHashForString(url))
const assetId: TLAssetId = AssetRecordType.createId(getHashForString(url))
const existing = this.editor.getAssetById(assetId)
if (existing) {

View file

@ -447,7 +447,7 @@ export class Drawing extends StateNode {
let didSnap = false
let snapSegment: TLDrawShapeSegment | undefined = undefined
const shouldSnap = this.editor.userDocumentSettings.isSnapMode ? !ctrlKey : ctrlKey
const shouldSnap = this.editor.isSnapMode ? !ctrlKey : ctrlKey
if (shouldSnap) {
if (newSegments.length > 2) {

View file

@ -208,7 +208,7 @@ export class DraggingHandle extends StateNode {
this.editor.snaps.clear()
const { ctrlKey } = this.editor.inputs
const shouldSnap = this.editor.userDocumentSettings.isSnapMode ? !ctrlKey : ctrlKey
const shouldSnap = this.editor.isSnapMode ? !ctrlKey : ctrlKey
if (shouldSnap && shape.type === 'line') {
const pagePoint = Matrix2d.applyToPoint(this.editor.getPageTransformById(shape.id)!, point)

View file

@ -214,7 +214,7 @@ export class Resizing extends StateNode {
this.editor.snaps.clear()
const shouldSnap = this.editor.userDocumentSettings.isSnapMode ? !ctrlKey : ctrlKey
const shouldSnap = this.editor.isSnapMode ? !ctrlKey : ctrlKey
if (shouldSnap && selectionRotation % TAU === 0) {
const { nudge } = this.editor.snaps.snapResize({

View file

@ -351,7 +351,7 @@ export function moveShapesToPoint({
editor.snaps.clear()
const shouldSnap =
(editor.userDocumentSettings.isSnapMode ? !inputs.ctrlKey : inputs.ctrlKey) &&
(editor.isSnapMode ? !inputs.ctrlKey : inputs.ctrlKey) &&
editor.inputs.pointerVelocity.len() < 0.5 // ...and if the user is not dragging fast
if (shouldSnap) {

View file

@ -0,0 +1,152 @@
import { react } from 'signia'
import { TestEditor } from '../test/TestEditor'
import {
TLSessionStateSnapshot,
createSessionStateSnapshotSignal,
extractSessionStateFromLegacySnapshot,
loadSessionStateSnapshotIntoStore,
} from './TLSessionStateSnapshot'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
})
describe('createSessionStateSnapshotSignal', () => {
it('creates a signal', () => {
const $snapshot = createSessionStateSnapshotSignal(editor.store)
expect($snapshot.value).toMatchObject({
exportBackground: true,
isDebugMode: false,
isFocusMode: false,
isGridMode: false,
isToolLocked: false,
pageStates: [
{
camera: {
x: 0,
y: 0,
z: 1,
},
focusLayerId: null,
selectedIds: [],
},
],
version: 0,
})
})
it('creates a signal that can be reacted to', () => {
const $snapshot = createSessionStateSnapshotSignal(editor.store)
let isGridMode = false
let numPages = 0
react('', () => {
isGridMode = $snapshot.value?.isGridMode ?? false
numPages = $snapshot.value?.pageStates.length ?? 0
})
expect(isGridMode).toBe(false)
expect(numPages).toBe(1)
editor.setGridMode(true)
expect(isGridMode).toBe(true)
expect(numPages).toBe(1)
editor.createPage('new page')
expect(isGridMode).toBe(true)
expect(editor.pages.length).toBe(2)
expect(numPages).toBe(2)
})
})
describe(loadSessionStateSnapshotIntoStore, () => {
it('loads a snapshot into the store', () => {
let snapshot = createSessionStateSnapshotSignal(editor.store).value
if (!snapshot) throw new Error('snapshot is null')
expect(editor.isGridMode).toBe(false)
expect(editor.camera.x).toBe(0)
expect(editor.camera.y).toBe(0)
snapshot = JSON.parse(JSON.stringify(snapshot)) as TLSessionStateSnapshot
snapshot.isGridMode = true
snapshot.pageStates[0].camera.x = 1
snapshot.pageStates[0].camera.y = 2
loadSessionStateSnapshotIntoStore(editor.store, snapshot)
expect(editor.isGridMode).toBe(true)
expect(editor.camera.x).toBe(1)
expect(editor.camera.y).toBe(2)
})
})
describe(extractSessionStateFromLegacySnapshot, () => {
it('pulls a snapshot from old data if it can', () => {
const oldSnapshot = {
'shape:whatever': {
id: 'shape:whatever',
typeName: 'shape',
},
'camera:whatever': {
id: 'camera:whatever',
typeName: 'camera',
x: 1,
y: 2,
z: 3,
},
'page:whatever': {
id: 'page:whatever',
typeName: 'page',
name: 'whatever',
index: 'whatever',
},
'instance:whatever': {
id: 'instance:whatever',
typeName: 'instance',
currentPageId: 'page:whatever',
},
'instance_page_state:whatever': {
id: 'instance_page_state:whatever',
typeName: 'instance_page_state',
instanceId: 'instance:whatever',
pageId: 'page:whatever',
selectedIds: ['shape:whatever'],
focusLayerId: null,
},
}
expect(extractSessionStateFromLegacySnapshot(oldSnapshot as any)).toMatchInlineSnapshot(`
Object {
"currentPageId": "page:whatever",
"exportBackground": false,
"isDebugMode": false,
"isFocusMode": false,
"isGridMode": false,
"isToolLocked": false,
"pageStates": Array [
Object {
"camera": Object {
"x": 0,
"y": 0,
"z": 1,
},
"focusLayerId": null,
"pageId": "page:whatever",
"selectedIds": Array [
"shape:whatever",
],
},
],
"version": 0,
}
`)
})
})

View file

@ -0,0 +1,323 @@
import {
RecordsDiff,
UnknownRecord,
defineMigrations,
migrate,
squashRecordDiffs,
} from '@tldraw/store'
import {
CameraRecordType,
InstancePageStateRecordType,
InstanceRecordType,
TLINSTANCE_ID,
TLPageId,
TLRecord,
TLShapeId,
TLStore,
pageIdValidator,
shapeIdValidator,
} from '@tldraw/tlschema'
import { objectMapFromEntries } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { Signal, computed, transact } from 'signia'
import { uniqueId } from '../utils/data'
const tabIdKey = 'TLDRAW_TAB_ID_v2' as const
const window = globalThis.window as
| {
navigator: Window['navigator']
localStorage: Window['localStorage']
sessionStorage: Window['sessionStorage']
addEventListener: Window['addEventListener']
TLDRAW_TAB_ID_v2?: string
}
| undefined
// https://stackoverflow.com/a/9039885
function iOS() {
if (!window) return false
return (
['iPad Simulator', 'iPhone Simulator', 'iPod Simulator', 'iPad', 'iPhone', 'iPod'].includes(
window.navigator.platform
) ||
// iPad on iOS 13 detection
(window.navigator.userAgent.includes('Mac') && 'ontouchend' in document)
)
}
/**
* A string that is unique per browser tab
* @public
*/
export const TAB_ID: string =
window?.[tabIdKey] ?? window?.sessionStorage[tabIdKey] ?? `TLDRAW_INSTANCE_STATE_V1_` + uniqueId()
if (window) {
window[tabIdKey] = TAB_ID
if (iOS()) {
// iOS does not trigger beforeunload
// so we need to keep the sessionStorage value around
// and hope the user doesn't figure out a way to duplicate their tab
// in which case they'll have two tabs with the same UI state.
// It's not a big deal, but it's not ideal.
// And anyway I can't see a way to duplicate a tab in iOS Safari.
window.sessionStorage[tabIdKey] = TAB_ID
} else {
delete window.sessionStorage[tabIdKey]
}
}
window?.addEventListener('beforeunload', () => {
window.sessionStorage[tabIdKey] = TAB_ID
})
const Versions = {
Initial: 0,
} as const
export const CURRENT_SESSION_STATE_SNAPSHOT_VERSION = Versions.Initial
/**
* The state of the editor instance, not including any document state.
*
* @public
*/
export interface TLSessionStateSnapshot {
version: number
currentPageId: TLPageId
isFocusMode: boolean
exportBackground: boolean
isDebugMode: boolean
isToolLocked: boolean
isGridMode: boolean
pageStates: Array<{
pageId: TLPageId
camera: { x: number; y: number; z: number }
selectedIds: TLShapeId[]
focusLayerId: TLShapeId | null
}>
}
const sessionStateSnapshotValidator: T.Validator<TLSessionStateSnapshot> = T.object({
version: T.number,
currentPageId: pageIdValidator,
isFocusMode: T.boolean,
exportBackground: T.boolean,
isDebugMode: T.boolean,
isToolLocked: T.boolean,
isGridMode: T.boolean,
pageStates: T.arrayOf(
T.object({
pageId: pageIdValidator,
camera: T.object({
x: T.number,
y: T.number,
z: T.number,
}),
selectedIds: T.arrayOf(shapeIdValidator),
focusLayerId: shapeIdValidator.nullable(),
})
),
})
const sessionStateSnapshotMigrations = defineMigrations({
currentVersion: CURRENT_SESSION_STATE_SNAPSHOT_VERSION,
})
function migrateAndValidateSessionStateSnapshot(state: unknown): TLSessionStateSnapshot | null {
if (!state || typeof state !== 'object') {
console.warn('Invalid instance state')
return null
}
if (!('version' in state) || typeof state.version !== 'number') {
console.warn('No version in instance state')
return null
}
const result = migrate<TLSessionStateSnapshot>({
value: state,
fromVersion: state.version,
toVersion: CURRENT_SESSION_STATE_SNAPSHOT_VERSION,
migrations: sessionStateSnapshotMigrations,
})
if (result.type === 'error') {
console.warn(result.reason)
return null
}
const value = { ...result.value, version: CURRENT_SESSION_STATE_SNAPSHOT_VERSION }
try {
sessionStateSnapshotValidator.validate(value)
} catch (e) {
console.warn(e)
return null
}
return value
}
/**
* Creates a signal of the instance state for a given store.
* @public
* @param store - The store to create the instance state snapshot signal for
* @returns
*/
export function createSessionStateSnapshotSignal(
store: TLStore
): Signal<TLSessionStateSnapshot | null> {
const $allPageIds = store.query.ids('page')
return computed<TLSessionStateSnapshot | null>('sessionStateSnapshot', () => {
const instanceState = store.get(TLINSTANCE_ID)
if (!instanceState) return null
const allPageIds = [...$allPageIds.value]
return {
version: CURRENT_SESSION_STATE_SNAPSHOT_VERSION,
currentPageId: instanceState.currentPageId,
exportBackground: instanceState.exportBackground,
isFocusMode: instanceState.isFocusMode,
isDebugMode: instanceState.isDebugMode,
isToolLocked: instanceState.isToolLocked,
isGridMode: instanceState.isGridMode,
pageStates: allPageIds.map((id) => {
const ps = store.get(InstancePageStateRecordType.createId(id))
const camera = store.get(CameraRecordType.createId(id))
return {
pageId: id,
camera: {
x: camera?.x ?? 0,
y: camera?.y ?? 0,
z: camera?.z ?? 1,
},
selectedIds: ps?.selectedIds ?? [],
focusLayerId: ps?.focusLayerId ?? null,
} satisfies TLSessionStateSnapshot['pageStates'][0]
}),
} satisfies TLSessionStateSnapshot
})
}
/**
* Loads a snapshot of the editor's instance state into the store of a new editor instance.
*
* @public
* @param store - The store to load the instance state into
* @param snapshot - The instance state snapshot to load
* @returns
*/
export function loadSessionStateSnapshotIntoStore(
store: TLStore,
snapshot: TLSessionStateSnapshot
) {
const res = migrateAndValidateSessionStateSnapshot(snapshot)
if (!res) return
// remove all page states and cameras and the instance state
const allPageStatesAndCameras = store
.allRecords()
.filter((r) => r.typeName === 'instance_page_state' || r.typeName === 'camera')
const removeDiff: RecordsDiff<TLRecord> = {
added: {},
updated: {},
removed: {
...objectMapFromEntries(allPageStatesAndCameras.map((r) => [r.id, r])),
},
}
if (store.has(TLINSTANCE_ID)) {
removeDiff.removed[TLINSTANCE_ID] = store.get(TLINSTANCE_ID)!
}
const addDiff: RecordsDiff<TLRecord> = {
removed: {},
updated: {},
added: {
[TLINSTANCE_ID]: InstanceRecordType.create({
id: TLINSTANCE_ID,
currentPageId: res.currentPageId,
isDebugMode: res.isDebugMode,
isFocusMode: res.isFocusMode,
isToolLocked: res.isToolLocked,
isGridMode: res.isGridMode,
exportBackground: res.exportBackground,
}),
},
}
// replace them with new ones
for (const ps of res.pageStates) {
const cameraId = CameraRecordType.createId(ps.pageId)
const pageStateId = InstancePageStateRecordType.createId(ps.pageId)
addDiff.added[cameraId] = CameraRecordType.create({
id: CameraRecordType.createId(ps.pageId),
x: ps.camera.x,
y: ps.camera.y,
z: ps.camera.z,
})
addDiff.added[pageStateId] = InstancePageStateRecordType.create({
id: InstancePageStateRecordType.createId(ps.pageId),
pageId: ps.pageId,
selectedIds: ps.selectedIds,
focusLayerId: ps.focusLayerId,
})
}
transact(() => {
store.applyDiff(squashRecordDiffs([removeDiff, addDiff]))
store.ensureStoreIsUsable()
})
}
/**
* @internal
*/
export function extractSessionStateFromLegacySnapshot(
store: Record<string, UnknownRecord>
): TLSessionStateSnapshot | null {
const instanceRecords = []
for (const record of Object.values(store)) {
if (record.typeName?.match(/^(instance.*|pointer|camera)$/)) {
instanceRecords.push(record)
}
}
// for scratch documents, we need to extract the most recently-used instance and it's associated page states
// but oops we don't have the concept of "most recently-used" so we'll just take the first one
const oldInstance = instanceRecords.filter(
(r) => r.typeName === 'instance' && r.id !== TLINSTANCE_ID
)[0] as any
if (!oldInstance) return null
const result: TLSessionStateSnapshot = {
version: CURRENT_SESSION_STATE_SNAPSHOT_VERSION,
currentPageId: oldInstance.currentPageId,
exportBackground: !!oldInstance.exportBackground,
isFocusMode: !!oldInstance.isFocusMode,
isDebugMode: !!oldInstance.isDebugMode,
isToolLocked: !!oldInstance.isToolLocked,
isGridMode: false,
pageStates: instanceRecords
.filter((r: any) => r.typeName === 'instance_page_state' && r.instanceId === oldInstance.id)
.map((ps: any): TLSessionStateSnapshot['pageStates'][0] => {
const camera = (store[ps.cameraId] as any) ?? { x: 0, y: 0, z: 1 }
return {
pageId: ps.pageId,
camera: {
x: camera.x,
y: camera.y,
z: camera.z,
},
selectedIds: ps.selectedIds,
focusLayerId: ps.focusLayerId,
}
}),
}
try {
sessionStateSnapshotValidator.validate(result)
return result
} catch (e) {
return null
}
}

View file

@ -0,0 +1,53 @@
import { atom } from 'signia'
import { TestEditor } from '../test/TestEditor'
import { TLUserPreferences } from './TLUserPreferences'
import { createTLUser } from './createTLUser'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
})
describe('TLUserPreferences', () => {
it('allows updating user preferences on the editor', () => {
expect(editor.isSnapMode).toBe(false)
editor.user.updateUserPreferences({ isSnapMode: true })
expect(editor.isSnapMode).toBe(true)
})
it('can be customized', () => {
const userPreferences = atom<TLUserPreferences>('userPreferences', {
animationSpeed: 1,
color: '#000000',
id: '123',
isDarkMode: true,
isSnapMode: false,
locale: 'en',
name: 'test',
})
editor = new TestEditor({
user: createTLUser({
setUserPreferences: (preferences) => userPreferences.set(preferences),
userPreferences,
}),
})
expect(editor.isDarkMode).toBe(true)
userPreferences.set({
...userPreferences.value,
isDarkMode: false,
})
expect(editor.isDarkMode).toBe(false)
editor.setDarkMode(true)
expect(editor.isDarkMode).toBe(true)
expect(userPreferences.value.isDarkMode).toBe(true)
})
})

View file

@ -18,6 +18,7 @@ export interface TLUserPreferences {
color: string
isDarkMode: boolean
animationSpeed: number
isSnapMode: boolean
}
interface UserDataSnapshot {
@ -38,14 +39,16 @@ const userTypeValidator: T.Validator<TLUserPreferences> = T.object<TLUserPrefere
color: T.string,
isDarkMode: T.boolean,
animationSpeed: T.number,
isSnapMode: T.boolean,
})
const Versions = {
AddAnimationSpeed: 1,
AddIsSnapMode: 2,
} as const
const userMigrations = defineMigrations({
currentVersion: 1,
currentVersion: Versions.AddIsSnapMode,
migrators: {
[Versions.AddAnimationSpeed]: {
up: (user) => {
@ -58,6 +61,14 @@ const userMigrations = defineMigrations({
return user
},
},
[Versions.AddIsSnapMode]: {
up: (user: TLUserPreferences) => {
return { ...user, isSnapMode: false }
},
down: ({ isSnapMode: _, ...user }: TLUserPreferences) => {
return user
},
},
},
})
@ -90,6 +101,7 @@ function getFreshUserPreferences(): TLUserPreferences {
// TODO: detect dark mode
isDarkMode: false,
animationSpeed: 1,
isSnapMode: false,
}
}

View file

@ -1,12 +1,5 @@
import { Migrations, Store, StoreSnapshot } from '@tldraw/store'
import {
InstanceRecordType,
TLDOCUMENT_ID,
TLInstanceId,
TLRecord,
TLStore,
createTLSchema,
} from '@tldraw/tlschema'
import { TLRecord, TLStore, createTLSchema } from '@tldraw/tlschema'
import { TLShapeUtilConstructor } from '../app/shapeutils/ShapeUtil'
/** @public */
@ -19,7 +12,6 @@ export type TLShapeInfo = {
/** @public */
export type TLStoreOptions = {
customShapes?: Record<string, TLShapeInfo>
instanceId?: TLInstanceId
initialData?: StoreSnapshot<TLRecord>
defaultName?: string
}
@ -31,19 +23,12 @@ export type TLStoreOptions = {
*
* @public */
export function createTLStore(opts = {} as TLStoreOptions): TLStore {
const {
customShapes = {},
instanceId = InstanceRecordType.createId(),
initialData,
defaultName = '',
} = opts
const { customShapes = {}, initialData, defaultName = '' } = opts
return new Store({
schema: createTLSchema({ customShapes }),
initialData,
props: {
instanceId,
documentId: TLDOCUMENT_ID,
defaultName,
},
})

View file

@ -7,9 +7,9 @@ import { useTLStore } from './useTLStore'
/** @internal */
export function useLocalStore(
opts = {} as { persistenceKey?: string } & TLStoreOptions
opts = {} as { persistenceKey?: string; sessionId?: string } & TLStoreOptions
): TLStoreWithStatus {
const { persistenceKey, ...rest } = opts
const { persistenceKey, sessionId, ...rest } = opts
const [state, setState] = useState<{ id: string; storeWithStatus: TLStoreWithStatus } | null>(
null
@ -42,7 +42,8 @@ export function useLocalStore(
}
const client = new TLLocalSyncClient(store, {
universalPersistenceKey: persistenceKey,
sessionId,
persistenceKey,
onLoad() {
setStoreWithStatus({ store, status: 'synced-local' })
},
@ -55,7 +56,7 @@ export function useLocalStore(
setState((prevState) => (prevState?.id === id ? null : prevState))
client.close()
}
}, [persistenceKey, store])
}, [persistenceKey, store, sessionId])
return state?.storeWithStatus ?? { status: 'loading' }
}

View file

@ -11,7 +11,7 @@ const ids = {
frame1: createShapeId('frame1'),
group1: createShapeId('group1'),
page2: PageRecordType.createCustomId('page2'),
page2: PageRecordType.createId('page2'),
}
beforeEach(() => {

View file

@ -52,7 +52,7 @@ declare global {
}
}
}
export const TEST_INSTANCE_ID = InstanceRecordType.createCustomId('testInstance1')
export const TEST_INSTANCE_ID = InstanceRecordType.createId('testInstance1')
export class TestEditor extends Editor {
constructor(options = {} as Partial<Omit<TLEditorOptions, 'store'>>) {
@ -63,7 +63,6 @@ export class TestEditor extends Editor {
shapes: { ...defaultShapes, ...shapes },
tools: [...defaultTools, ...tools],
store: createTLStore({
instanceId: TEST_INSTANCE_ID,
customShapes: shapes,
}),
getContainer: () => elm,
@ -190,7 +189,7 @@ export class TestEditor extends Editor {
return createShapeId(id)
}
testPageID(id: string) {
return PageRecordType.createCustomId(id)
return PageRecordType.createId(id)
}
expectToBeIn = (path: string) => {

View file

@ -1,5 +1,5 @@
import { act, render, screen } from '@testing-library/react'
import { InstanceRecordType, TLBaseShape, TLOpacityType, createShapeId } from '@tldraw/tlschema'
import { TLBaseShape, TLOpacityType, createShapeId } from '@tldraw/tlschema'
import { TldrawEditor } from '../TldrawEditor'
import { Editor } from '../app/Editor'
import { BaseBoxShapeUtil } from '../app/shapeutils/BaseBoxShapeUtil'
@ -64,9 +64,7 @@ describe('<TldrawEditor />', () => {
})
it('Accepts fresh versions of store and calls `onMount` for each one', async () => {
const initialStore = createTLStore({
instanceId: InstanceRecordType.createCustomId('test'),
})
const initialStore = createTLStore({})
const onMount = jest.fn()
const rendered = render(
<TldrawEditor store={initialStore} onMount={onMount} autoFocus>
@ -87,9 +85,7 @@ describe('<TldrawEditor />', () => {
// not called again:
expect(onMount).toHaveBeenCalledTimes(1)
// re-render with a new store:
const newStore = createTLStore({
instanceId: InstanceRecordType.createCustomId('test'),
})
const newStore = createTLStore({})
rendered.rerender(
<TldrawEditor store={newStore} onMount={onMount} autoFocus>
<div data-testid="canvas-3" />

View file

@ -36,17 +36,17 @@ it('[regression] does not die if every page has the same index', () => {
editor.store.put([
{
...page,
id: PageRecordType.createCustomId('2'),
id: PageRecordType.createId('2'),
name: 'a',
},
{
...page,
id: PageRecordType.createCustomId('3'),
id: PageRecordType.createId('3'),
name: 'b',
},
{
...page,
id: PageRecordType.createCustomId('4'),
id: PageRecordType.createId('4'),
name: 'c',
},
])

View file

@ -9,7 +9,7 @@ beforeEach(() => {
describe('deletePage', () => {
it('deletes the page', () => {
const page2Id = PageRecordType.createCustomId('page2')
const page2Id = PageRecordType.createId('page2')
editor.createPage('New Page 2', page2Id)
const pages = editor.pages
@ -19,7 +19,7 @@ describe('deletePage', () => {
expect(editor.pages[0]).toEqual(pages[1])
})
it('is undoable and redoable', () => {
const page2Id = PageRecordType.createCustomId('page2')
const page2Id = PageRecordType.createId('page2')
editor.mark()
editor.createPage('New Page 2', page2Id)
@ -38,7 +38,7 @@ describe('deletePage', () => {
expect(editor.pages[0]).toEqual(pages[1])
})
it('does not allow deleting all pages', () => {
const page2Id = PageRecordType.createCustomId('page2')
const page2Id = PageRecordType.createId('page2')
editor.mark()
editor.createPage('New Page 2', page2Id)
@ -52,7 +52,7 @@ describe('deletePage', () => {
expect(editor.pages.length).toBe(1)
})
it('switches the page if you are deleting the current page', () => {
const page2Id = PageRecordType.createCustomId('page2')
const page2Id = PageRecordType.createId('page2')
editor.mark()
editor.createPage('New Page 2', page2Id)
@ -64,7 +64,7 @@ describe('deletePage', () => {
})
it('switches the page if another user or tab deletes the current page', () => {
const currentPageId = editor.currentPageId
const page2Id = PageRecordType.createCustomId('page2')
const page2Id = PageRecordType.createId('page2')
editor.mark()
editor.createPage('New Page 2', page2Id)

View file

@ -1,40 +0,0 @@
import { InstanceRecordType, PageRecordType, createShapeId } from '@tldraw/tlschema'
import { TEST_INSTANCE_ID, TestEditor } from '../TestEditor'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
})
describe('running any commands', () => {
it('sets the lastUsedTabId and lastUpdatedPageId', () => {
expect(editor.userDocumentSettings.lastUsedTabId).toBe(null)
expect(editor.userDocumentSettings.lastUpdatedPageId).toBe(null)
editor.createShapes([{ type: 'geo', id: createShapeId('geo'), parentId: editor.currentPageId }])
expect(editor.userDocumentSettings.lastUsedTabId).toBe(TEST_INSTANCE_ID)
expect(editor.userDocumentSettings.lastUpdatedPageId).toBe(editor.currentPageId)
editor.store.put([
{
...editor.userDocumentSettings,
lastUsedTabId: InstanceRecordType.createCustomId('nope'),
lastUpdatedPageId: PageRecordType.createCustomId('nope'),
},
])
expect(editor.userDocumentSettings.lastUsedTabId).toBe(
InstanceRecordType.createCustomId('nope')
)
expect(editor.userDocumentSettings.lastUpdatedPageId).toBe(
PageRecordType.createCustomId('nope')
)
editor.createShapes([{ type: 'geo', id: createShapeId('geo'), parentId: editor.currentPageId }])
expect(editor.userDocumentSettings.lastUsedTabId).toBe(TEST_INSTANCE_ID)
expect(editor.userDocumentSettings.lastUpdatedPageId).toBe(editor.currentPageId)
})
})

View file

@ -8,8 +8,8 @@ const ids = {
box2: createShapeId('box2'),
ellipse1: createShapeId('ellipse1'),
ellipse2: createShapeId('ellipse2'),
page1: PageRecordType.createCustomId('page1'),
page2: PageRecordType.createCustomId('page2'),
page1: PageRecordType.createId('page1'),
page2: PageRecordType.createId('page2'),
}
beforeEach(() => {
@ -68,7 +68,7 @@ describe('Editor.moveShapesToPage', () => {
it('Does nothing if the new page is not found or is deleted', () => {
editor.history.clear()
editor.moveShapesToPage([ids.box1], PageRecordType.createCustomId('missing'))
editor.moveShapesToPage([ids.box1], PageRecordType.createId('missing'))
expect(editor.history.numUndos).toBe(0)
})
@ -101,7 +101,7 @@ describe('Editor.moveShapesToPage', () => {
it('Sets the correct indices', () => {
editor = new TestEditor()
const page2Id = PageRecordType.createCustomId('newPage2')
const page2Id = PageRecordType.createId('newPage2')
editor.createPage('New Page 2', page2Id)
expect(editor.currentPageId).toBe(page2Id)
@ -112,7 +112,7 @@ describe('Editor.moveShapesToPage', () => {
index: 'a1',
})
const page3Id = PageRecordType.createCustomId('newPage3')
const page3Id = PageRecordType.createId('newPage3')
editor.createPage('New Page 3', page3Id)
expect(editor.currentPageId).toBe(page3Id)
editor.createShapes([{ id: ids.box2, type: 'geo', x: 0, y: 0, props: { geo: 'ellipse' } }])

View file

@ -176,9 +176,7 @@ describe('When a shape is selected...', () => {
describe('When grid is enabled...', () => {
it('nudges a shape correctly', () => {
editor.updateUserDocumentSettings({
isGridMode: true,
})
editor.setGridMode(true)
editor.setSelectedIds([ids.boxA])
expect(nudgeAndGet([ids.boxA], 'ArrowUp', false)).toMatchObject([{ x: 10, y: 0 }])
@ -188,9 +186,7 @@ describe('When grid is enabled...', () => {
})
it('nudges a shape with shift key pressed', () => {
editor.updateUserDocumentSettings({
isGridMode: true,
})
editor.setGridMode(true)
editor.setSelectedIds([ids.boxA])
expect(nudgeAndGet([ids.boxA], 'ArrowUp', true)).toMatchObject([{ x: 10, y: -40 }])

View file

@ -10,7 +10,7 @@ beforeEach(() => {
describe('setCurrentPage', () => {
it('sets the current page', () => {
const page1Id = editor.pages[0].id
const page2Id = PageRecordType.createCustomId('page2')
const page2Id = PageRecordType.createId('page2')
editor.createPage('New Page 2', page2Id)
expect(editor.currentPageId).toEqual(page2Id)
@ -20,7 +20,7 @@ describe('setCurrentPage', () => {
expect(editor.currentPage).toEqual(editor.pages[0])
const page3Id = PageRecordType.createCustomId('page3')
const page3Id = PageRecordType.createId('page3')
editor.createPage('New Page 3', page3Id)
expect(editor.currentPageId).toEqual(page3Id)
@ -49,7 +49,7 @@ describe('setCurrentPage', () => {
})
it('squashes', () => {
const page2Id = PageRecordType.createCustomId('page2')
const page2Id = PageRecordType.createId('page2')
editor.createPage('New Page 2', page2Id)
editor.history.clear()
@ -61,7 +61,7 @@ describe('setCurrentPage', () => {
it('preserves the undo stack', () => {
const boxId = createShapeId('geo')
const page2Id = PageRecordType.createCustomId('page2')
const page2Id = PageRecordType.createId('page2')
editor.createPage('New Page 2', page2Id)
editor.history.clear()
@ -77,7 +77,7 @@ describe('setCurrentPage', () => {
})
it('logs an error when trying to navigate to a page that does not exist', () => {
const page2Id = PageRecordType.createCustomId('page2')
const page2Id = PageRecordType.createId('page2')
editor.createPage('New Page 2', page2Id)
console.error = jest.fn()

View file

@ -2944,7 +2944,7 @@ describe('snapping while the grid is enabled', () => {
editor.createShapes([box(ids.boxA, 0, 0, 20, 20), box(ids.boxB, 60, 0, 20, 20)])
editor.updateUserDocumentSettings({ isGridMode: true })
editor.setGridMode(true)
// try to move right side of A to left side of B
// doesn't work because of the grid

View file

@ -1706,7 +1706,7 @@ describe('translating while the grid is enabled', () => {
// └───┘ └───┘
editor.createShapes([box(ids.box1, 0, 0, 20, 20), box(ids.box2, 50, 0, 20, 20)])
editor.updateUserDocumentSettings({ isGridMode: true })
editor.setGridMode(true)
// try to snap A to B
// doesn't work because of the grid

View file

@ -166,7 +166,7 @@ export async function getMediaAssetFromFile(file: File): Promise<TLAsset> {
dataUrl = await getResizedImageDataUrl(dataUrl, size.w, size.h)
}
const assetId: TLAssetId = AssetRecordType.createCustomId(getHashForString(dataUrl))
const assetId: TLAssetId = AssetRecordType.createId(getHashForString(dataUrl))
const metadata = await getFileMetaData(file)
@ -420,7 +420,7 @@ export function createEmbedShapeAtPoint(
* @public
*/
export async function createBookmarkShapeAtPoint(editor: Editor, url: string, point: Vec2dModel) {
const assetId: TLAssetId = AssetRecordType.createCustomId(getHashForString(url))
const assetId: TLAssetId = AssetRecordType.createId(getHashForString(url))
const existing = editor.getAssetById(assetId) as TLBookmarkAsset
if (existing) {

View file

@ -1,4 +1,4 @@
import { InstanceRecordType, PageRecordType, TLInstanceId } from '@tldraw/tlschema'
import { PageRecordType } from '@tldraw/tlschema'
import { promiseWithResolve } from '@tldraw/utils'
import { createTLStore } from '../../config/createTLStore'
import { TLLocalSyncClient } from './TLLocalSyncClient'
@ -23,13 +23,8 @@ class BroadcastChannelMock {
})
}
function testClient(
instanceId: TLInstanceId = InstanceRecordType.createCustomId('test'),
channel = new BroadcastChannelMock('test')
) {
const store = createTLStore({
instanceId,
})
function testClient(channel = new BroadcastChannelMock('test')) {
const store = createTLStore()
const onLoad = jest.fn(() => {
return
})
@ -41,7 +36,7 @@ function testClient(
{
onLoad,
onLoadError,
universalPersistenceKey: 'test',
persistenceKey: 'test',
},
channel
)

View file

@ -1,7 +1,20 @@
import { RecordsDiff, SerializedSchema, compareSchemas, squashRecordDiffs } from '@tldraw/store'
import { TLInstanceId, TLRecord, TLStore } from '@tldraw/tlschema'
import { assert, hasOwnProperty } from '@tldraw/utils'
import { transact } from 'signia'
import {
RecordsDiff,
SerializedSchema,
UnknownRecord,
compareSchemas,
squashRecordDiffs,
} from '@tldraw/store'
import { TLStore } from '@tldraw/tlschema'
import { assert } from '@tldraw/utils'
import { Signal, transact } from 'signia'
import {
TAB_ID,
TLSessionStateSnapshot,
createSessionStateSnapshotSignal,
extractSessionStateFromLegacySnapshot,
loadSessionStateSnapshotIntoStore,
} from '../../config/TLSessionStateSnapshot'
import { showCantReadFromIndexDbAlert, showCantWriteToIndexDbAlert } from './alerts'
import { loadDataFromStore, storeChangesInIndexedDb, storeSnapshotInIndexedDb } from './indexedDb'
@ -10,6 +23,8 @@ const PERSIST_THROTTLE_MS = 350
/** If we're in an error state, how long should we wait before retrying a write? */
const PERSIST_RETRY_THROTTLE_MS = 10_000
const UPDATE_INSTANCE_STATE = Symbol('UPDATE_INSTANCE_STATE')
/**
* IMPORTANT!!!
*
@ -19,8 +34,8 @@ const PERSIST_RETRY_THROTTLE_MS = 10_000
type SyncMessage = {
type: 'diff'
instanceId: TLInstanceId
changes: RecordsDiff<any>
storeId: string
changes: RecordsDiff<UnknownRecord>
schema: SerializedSchema
}
@ -34,6 +49,8 @@ type AnnounceMessage = {
type Message = SyncMessage | AnnounceMessage
type UnpackPromise<T> = T extends Promise<infer U> ? U : T
const msg = (msg: Message) => msg
/** @internal */
@ -55,13 +72,16 @@ const BC = typeof BroadcastChannel === 'undefined' ? BroadcastChannelMock : Broa
/** @internal */
export class TLLocalSyncClient {
private disposables = new Set<() => void>()
private diffQueue: RecordsDiff<any>[] = []
private diffQueue: Array<RecordsDiff<UnknownRecord> | typeof UPDATE_INSTANCE_STATE> = []
private didDispose = false
private shouldDoFullDBWrite = true
private isReloading = false
readonly universalPersistenceKey: string
readonly persistenceKey: string
readonly sessionId: string
readonly serializedSchema: SerializedSchema
private isDebugging = false
private readonly documentTypes: ReadonlySet<string>
private readonly $sessionStateSnapshot: Signal<TLSessionStateSnapshot | null>
initTime = Date.now()
private debug(...args: any[]) {
@ -73,59 +93,77 @@ export class TLLocalSyncClient {
constructor(
public readonly store: TLStore,
{
universalPersistenceKey,
persistenceKey,
sessionId = TAB_ID,
onLoad,
onLoadError,
}: {
universalPersistenceKey: string
persistenceKey: string
sessionId?: string
onLoad: (self: TLLocalSyncClient) => void
onLoadError: (error: Error) => void
},
public readonly channel = new BC(`tldraw-tab-sync-${universalPersistenceKey}`)
public readonly channel = new BC(`tldraw-tab-sync-${persistenceKey}`)
) {
if (typeof window !== 'undefined') {
;(window as any).tlsync = this
}
this.universalPersistenceKey = universalPersistenceKey
this.persistenceKey = persistenceKey
this.sessionId = sessionId
this.serializedSchema = this.store.schema.serialize()
this.$sessionStateSnapshot = createSessionStateSnapshotSignal(this.store)
this.disposables.add(
// Set up a subscription to changes from the store: When
// the store changes (and if the change was made by the user)
// then immediately send the diff to other tabs via postMessage
// and schedule a persist.
store.listen(({ changes, source }) => {
this.debug('changes', changes, source)
if (source === 'user') {
store.listen(
({ changes }) => {
this.diffQueue.push(changes)
this.channel.postMessage(
msg({
type: 'diff',
instanceId: this.store.props.instanceId,
storeId: this.store.id,
changes,
schema: this.serializedSchema,
})
)
this.schedulePersist()
}
})
},
{ source: 'user', scope: 'document' }
)
)
this.disposables.add(
store.listen(
() => {
this.diffQueue.push(UPDATE_INSTANCE_STATE)
this.schedulePersist()
},
{ scope: 'session' }
)
)
this.connect(onLoad, onLoadError)
this.documentTypes = new Set(
Object.values(this.store.schema.types)
.filter((t) => t.scope === 'document')
.map((t) => t.typeName)
)
}
private async connect(onLoad: (client: this) => void, onLoadError: (error: Error) => void) {
this.debug('connecting')
let data:
| {
records: TLRecord[]
schema?: SerializedSchema
}
| undefined
let data: UnpackPromise<ReturnType<typeof loadDataFromStore>> | undefined
try {
data = await loadDataFromStore(this.universalPersistenceKey)
data = await loadDataFromStore({
persistenceKey: this.persistenceKey,
sessionId: this.sessionId,
didCancel: () => this.didDispose,
})
} catch (error: any) {
onLoadError(error)
showCantReadFromIndexDbAlert()
@ -140,9 +178,11 @@ export class TLLocalSyncClient {
try {
if (data) {
const snapshot = Object.fromEntries(data.records.map((r) => [r.id, r]))
const documentSnapshot = Object.fromEntries(data.records.map((r) => [r.id, r]))
const sessionStateSnapshot =
data.sessionStateSnapshot ?? extractSessionStateFromLegacySnapshot(documentSnapshot)
const migrationResult = this.store.schema.migrateStoreSnapshot(
snapshot,
documentSnapshot,
data.schema ?? this.store.schema.serializeEarliestVersion()
)
@ -156,18 +196,20 @@ export class TLLocalSyncClient {
this.store.mergeRemoteChanges(() => {
// Calling put will validate the records!
this.store.put(
Object.values(migrationResult.value).filter(
(r) => this.store.schema.types[r.typeName].scope !== 'presence'
),
Object.values(migrationResult.value).filter((r) => this.documentTypes.has(r.typeName)),
'initialize'
)
})
if (sessionStateSnapshot) {
loadSessionStateSnapshotIntoStore(this.store, sessionStateSnapshot)
}
}
this.channel.onmessage = ({ data }) => {
this.debug('got message', data)
const msg = data as Message
// if their schema is eralier than ours, we need to tell them so they can refresh
// if their schema is earlier than ours, we need to tell them so they can refresh
// if their schema is later than ours, we need to refresh
const comparison = compareSchemas(
this.serializedSchema,
@ -175,7 +217,7 @@ export class TLLocalSyncClient {
)
if (comparison === -1) {
// we are older, refresh
// but add a safety check to make sure we don't get in an infnite loop
// but add a safety check to make sure we don't get in an infinite loop
const timeSinceInit = Date.now() - this.initTime
if (timeSinceInit < 5000) {
// This tab was just reloaded, but is out of date compared to other tabs.
@ -202,17 +244,11 @@ export class TLLocalSyncClient {
// otherwise, all good, same version :)
if (msg.type === 'diff') {
this.debug('applying diff')
const doesDeleteInstance = hasOwnProperty(
msg.changes.removed,
this.store.props.instanceId
)
transact(() => {
this.store.mergeRemoteChanges(() => {
this.store.applyDiff(msg.changes)
})
if (doesDeleteInstance) {
this.store.applyDiff(msg.changes as any)
this.store.ensureStoreIsUsable()
}
})
})
}
}
@ -258,7 +294,7 @@ export class TLLocalSyncClient {
}
/**
* Persist to indexeddb only under certain circumstances:
* Persist to IndexedDB only under certain circumstances:
*
* - If we're not already persisting
* - If we're not reloading the page
@ -300,7 +336,7 @@ export class TLLocalSyncClient {
}
/**
* Actually persist to indexeddb. If the write fails, then we'll retry with a full db write after
* Actually persist to IndexedDB. If the write fails, then we'll retry with a full db write after
* a short delay.
*/
private async doPersist() {
@ -317,17 +353,26 @@ export class TLLocalSyncClient {
try {
if (this.shouldDoFullDBWrite) {
this.shouldDoFullDBWrite = false
await storeSnapshotInIndexedDb(
this.universalPersistenceKey,
this.store.schema,
this.store.serialize(),
{
didCancel: () => this.didDispose,
}
)
await storeSnapshotInIndexedDb({
persistenceKey: this.persistenceKey,
schema: this.store.schema,
snapshot: this.store.serialize(),
didCancel: () => this.didDispose,
sessionId: this.sessionId,
sessionStateSnapshot: this.$sessionStateSnapshot.value,
})
} else {
const diffs = squashRecordDiffs(diffQueue)
await storeChangesInIndexedDb(this.universalPersistenceKey, this.store.schema, diffs)
const diffs = squashRecordDiffs(
diffQueue.filter((d): d is RecordsDiff<UnknownRecord> => d !== UPDATE_INSTANCE_STATE)
)
await storeChangesInIndexedDb({
persistenceKey: this.persistenceKey,
changes: diffs,
schema: this.store.schema,
didCancel: () => this.didDispose,
sessionId: this.sessionId,
sessionStateSnapshot: this.$sessionStateSnapshot.value,
})
}
this.didLastWriteError = false
} catch (e) {

View file

@ -12,7 +12,7 @@ Keep seeing this message?
/** @internal */
export function showCantReadFromIndexDbAlert() {
window.alert(
`Oops! We could not access to your browser's storage—and the app won't work correctly without that. We now need to reload the page and try again.
`Oops! We could not access your browser's storage—and the app won't work correctly without that. We now need to reload the page and try again.
Keep seeing this message?
If you're using tldraw in a private or "incognito" window, try loading tldraw in a regular window or in a different browser.`

View file

@ -1,5 +1,5 @@
import { deleteDB } from 'idb'
import { getAllIndexDbNames } from './persistence-constants'
import { getAllIndexDbNames } from './indexedDb'
/**
* Clear the database of all data associated with tldraw.

View file

@ -0,0 +1,224 @@
import { createTLSchema } from '@tldraw/tlschema'
import {
getAllIndexDbNames,
loadDataFromStore,
storeChangesInIndexedDb,
storeSnapshotInIndexedDb,
} from './indexedDb'
const clearAll = async () => {
const dbs = (indexedDB as any)._databases as Map<any, any>
dbs.clear()
localStorage.clear()
}
beforeEach(async () => {
await clearAll()
})
const schema = createTLSchema()
describe('storeSnapshotInIndexedDb', () => {
it("creates documents if they don't exist", async () => {
await storeSnapshotInIndexedDb({
persistenceKey: 'test-0',
schema,
snapshot: {},
})
expect(getAllIndexDbNames()).toMatchInlineSnapshot(`
Array [
"TLDRAW_DOCUMENT_v2test-0",
]
`)
await storeSnapshotInIndexedDb({
persistenceKey: 'test-1',
schema,
snapshot: {},
})
expect(getAllIndexDbNames()).toMatchInlineSnapshot(`
Array [
"TLDRAW_DOCUMENT_v2test-0",
"TLDRAW_DOCUMENT_v2test-1",
]
`)
await storeSnapshotInIndexedDb({
persistenceKey: 'test-1',
schema,
snapshot: {},
})
expect(getAllIndexDbNames()).toMatchInlineSnapshot(`
Array [
"TLDRAW_DOCUMENT_v2test-0",
"TLDRAW_DOCUMENT_v2test-1",
]
`)
})
it('allows reading back the snapshot', async () => {
expect(getAllIndexDbNames()).toMatchInlineSnapshot(`Array []`)
await storeSnapshotInIndexedDb({
persistenceKey: 'test-0',
schema,
snapshot: {
'shape:1': {
id: 'shape:1',
type: 'rectangle',
},
'page:1': {
id: 'page:1',
name: 'steve',
},
},
})
expect(getAllIndexDbNames()).toMatchInlineSnapshot(`
Array [
"TLDRAW_DOCUMENT_v2test-0",
]
`)
const records = (await loadDataFromStore({ persistenceKey: 'test-0' }))?.records
expect(records).toMatchInlineSnapshot(`
Array [
Object {
"id": "page:1",
"name": "steve",
},
Object {
"id": "shape:1",
"type": "rectangle",
},
]
`)
})
it('allows storing a session under a particular ID and reading it back', async () => {
const snapshot = {
'shape:1': {
id: 'shape:1',
type: 'rectangle',
},
}
await storeSnapshotInIndexedDb({
persistenceKey: 'test-0',
sessionId: 'session-0',
schema,
snapshot,
sessionStateSnapshot: {
foo: 'bar',
} as any,
})
expect(
(await loadDataFromStore({ persistenceKey: 'test-0', sessionId: 'session-0' }))
?.sessionStateSnapshot
).toMatchInlineSnapshot(`
Object {
"foo": "bar",
}
`)
await storeSnapshotInIndexedDb({
persistenceKey: 'test-0',
sessionId: 'session-1',
schema,
snapshot,
sessionStateSnapshot: {
hello: 'world',
} as any,
})
expect(
(await loadDataFromStore({ persistenceKey: 'test-0', sessionId: 'session-0' }))
?.sessionStateSnapshot
).toMatchInlineSnapshot(`
Object {
"foo": "bar",
}
`)
expect(
(await loadDataFromStore({ persistenceKey: 'test-0', sessionId: 'session-1' }))
?.sessionStateSnapshot
).toMatchInlineSnapshot(`
Object {
"hello": "world",
}
`)
})
})
describe(storeChangesInIndexedDb, () => {
it('allows merging changes into an existing store', async () => {
await storeSnapshotInIndexedDb({
persistenceKey: 'test-0',
schema,
snapshot: {
'shape:1': {
id: 'shape:1',
version: 0,
},
'page:1': {
id: 'page:1',
version: 0,
},
'asset:1': {
id: 'asset:1',
version: 0,
},
},
})
await storeChangesInIndexedDb({
persistenceKey: 'test-0',
schema,
changes: {
added: {
'asset:2': {
id: 'asset:2',
version: 0,
},
},
updated: {
'page:1': [
{
id: 'page:1',
version: 0,
},
{
id: 'page:1',
version: 1,
},
],
},
removed: {
'shape:1': {
id: 'shape:1',
version: 0,
},
},
},
})
expect((await loadDataFromStore({ persistenceKey: 'test-0' }))?.records).toMatchInlineSnapshot(`
Array [
Object {
"id": "asset:1",
"version": 0,
},
Object {
"id": "asset:2",
"version": 0,
},
Object {
"id": "page:1",
"version": 1,
},
]
`)
})
})

View file

@ -1,16 +1,33 @@
import { RecordsDiff, SerializedSchema, StoreSnapshot } from '@tldraw/store'
import { TLRecord, TLStoreSchema } from '@tldraw/tlschema'
import { IDBPDatabase, openDB } from 'idb'
import { STORE_PREFIX, addDbName, getAllIndexDbNames } from './persistence-constants'
import { TLSessionStateSnapshot } from '../../config/TLSessionStateSnapshot'
async function withDb<T>(storeId: string, cb: (db: IDBPDatabase<unknown>) => Promise<T>) {
// DO NOT CHANGE THESE WITHOUT ADDING MIGRATION LOGIC. DOING SO WOULD WIPE ALL EXISTING DATA.
const STORE_PREFIX = 'TLDRAW_DOCUMENT_v2'
const dbNameIndexKey = 'TLDRAW_DB_NAME_INDEX_v2'
const Table = {
Records: 'records',
Schema: 'schema',
SessionState: 'session_state',
} as const
type StoreName = (typeof Table)[keyof typeof Table]
async function withDb<T>(storeId: string, cb: (db: IDBPDatabase<StoreName>) => Promise<T>) {
addDbName(storeId)
const db = await openDB(storeId, 2, {
const db = await openDB<StoreName>(storeId, 3, {
upgrade(database) {
if (!database.objectStoreNames.contains('records')) {
database.createObjectStore('records')
if (!database.objectStoreNames.contains(Table.Records)) {
database.createObjectStore(Table.Records)
}
if (!database.objectStoreNames.contains(Table.Schema)) {
database.createObjectStore(Table.Schema)
}
if (!database.objectStoreNames.contains(Table.SessionState)) {
database.createObjectStore(Table.SessionState)
}
database.createObjectStore('schema')
},
})
try {
@ -20,41 +37,81 @@ async function withDb<T>(storeId: string, cb: (db: IDBPDatabase<unknown>) => Pro
}
}
type LoadResult = {
records: TLRecord[]
schema?: SerializedSchema
sessionStateSnapshot?: TLSessionStateSnapshot | null
}
type SessionStateSnapshotRow = {
id: string
snapshot: TLSessionStateSnapshot
updatedAt: number
}
/** @internal */
export async function loadDataFromStore(
universalPersistenceKey: string,
opts?: {
didCancel?: () => boolean
}
): Promise<undefined | { records: TLRecord[]; schema?: SerializedSchema }> {
const storeId = STORE_PREFIX + universalPersistenceKey
export async function loadDataFromStore({
persistenceKey,
sessionId,
didCancel,
}: {
persistenceKey: string
sessionId?: string
didCancel?: () => boolean
}): Promise<undefined | LoadResult> {
const storeId = STORE_PREFIX + persistenceKey
if (!getAllIndexDbNames().includes(storeId)) return undefined
await pruneSessionState({ persistenceKey, didCancel })
return await withDb(storeId, async (db) => {
if (opts?.didCancel?.()) return undefined
const tx = db.transaction(['records', 'schema'], 'readonly')
const recordsStore = tx.objectStore('records')
const schemaStore = tx.objectStore('schema')
return {
records: await recordsStore.getAll(),
schema: await schemaStore.get('schema'),
if (didCancel?.()) return undefined
const tx = db.transaction([Table.Records, Table.Schema, Table.SessionState], 'readonly')
const recordsStore = tx.objectStore(Table.Records)
const schemaStore = tx.objectStore(Table.Schema)
const sessionStateStore = tx.objectStore(Table.SessionState)
let sessionStateSnapshot = sessionId
? ((await sessionStateStore.get(sessionId)) as SessionStateSnapshotRow | undefined)?.snapshot
: null
if (!sessionStateSnapshot) {
// get the most recent session state
const all = (await sessionStateStore.getAll()) as SessionStateSnapshotRow[]
sessionStateSnapshot = all.sort((a, b) => a.updatedAt - b.updatedAt).pop()?.snapshot
}
const result = {
records: await recordsStore.getAll(),
schema: await schemaStore.get(Table.Schema),
sessionStateSnapshot,
} satisfies LoadResult
if (didCancel?.()) {
tx.abort()
return undefined
}
await tx.done
return result
})
}
/** @internal */
export async function storeChangesInIndexedDb(
universalPersistenceKey: string,
schema: TLStoreSchema,
changes: RecordsDiff<any>,
opts?: {
didCancel?: () => boolean
}
) {
const storeId = STORE_PREFIX + universalPersistenceKey
export async function storeChangesInIndexedDb({
persistenceKey,
schema,
changes,
sessionId,
sessionStateSnapshot,
didCancel,
}: {
persistenceKey: string
schema: TLStoreSchema
changes: RecordsDiff<any>
sessionId?: string | null
sessionStateSnapshot?: TLSessionStateSnapshot | null
didCancel?: () => boolean
}) {
const storeId = STORE_PREFIX + persistenceKey
await withDb(storeId, async (db) => {
const tx = db.transaction(['records', 'schema'], 'readwrite')
const recordsStore = tx.objectStore('records')
const schemaStore = tx.objectStore('schema')
const tx = db.transaction([Table.Records, Table.Schema, Table.SessionState], 'readwrite')
const recordsStore = tx.objectStore(Table.Records)
const schemaStore = tx.objectStore(Table.Schema)
const sessionStateStore = tx.objectStore(Table.SessionState)
for (const [id, record] of Object.entries(changes.added)) {
await recordsStore.put(record, id)
@ -68,28 +125,48 @@ export async function storeChangesInIndexedDb(
await recordsStore.delete(id)
}
schemaStore.put(schema.serialize(), 'schema')
schemaStore.put(schema.serialize(), Table.Schema)
if (sessionStateSnapshot && sessionId) {
sessionStateStore.put(
{
snapshot: sessionStateSnapshot,
updatedAt: Date.now(),
id: sessionId,
} satisfies SessionStateSnapshotRow,
sessionId
)
} else if (sessionStateSnapshot || sessionId) {
console.error('sessionStateSnapshot and instanceId must be provided together')
}
if (opts?.didCancel?.()) return tx.abort()
if (didCancel?.()) return tx.abort()
await tx.done
})
}
/** @internal */
export async function storeSnapshotInIndexedDb(
universalPersistenceKey: string,
schema: TLStoreSchema,
snapshot: StoreSnapshot<any>,
opts?: {
didCancel?: () => boolean
}
) {
const storeId = STORE_PREFIX + universalPersistenceKey
export async function storeSnapshotInIndexedDb({
persistenceKey,
schema,
snapshot,
sessionId,
sessionStateSnapshot,
didCancel,
}: {
persistenceKey: string
schema: TLStoreSchema
snapshot: StoreSnapshot<any>
sessionId?: string | null
sessionStateSnapshot?: TLSessionStateSnapshot | null
didCancel?: () => boolean
}) {
const storeId = STORE_PREFIX + persistenceKey
await withDb(storeId, async (db) => {
const tx = db.transaction(['records', 'schema'], 'readwrite')
const recordsStore = tx.objectStore('records')
const schemaStore = tx.objectStore('schema')
const tx = db.transaction([Table.Records, Table.Schema, Table.SessionState], 'readwrite')
const recordsStore = tx.objectStore(Table.Records)
const schemaStore = tx.objectStore(Table.Schema)
const sessionStateStore = tx.objectStore(Table.SessionState)
await recordsStore.clear()
@ -97,16 +174,62 @@ export async function storeSnapshotInIndexedDb(
await recordsStore.put(record, id)
}
schemaStore.put(schema.serialize(), 'schema')
schemaStore.put(schema.serialize(), Table.Schema)
if (opts?.didCancel?.()) return tx.abort()
if (sessionStateSnapshot && sessionId) {
sessionStateStore.put(
{
snapshot: sessionStateSnapshot,
updatedAt: Date.now(),
id: sessionId,
} satisfies SessionStateSnapshotRow,
sessionId
)
} else if (sessionStateSnapshot || sessionId) {
console.error('sessionStateSnapshot and instanceId must be provided together')
}
if (didCancel?.()) return tx.abort()
await tx.done
})
}
/** @internal */
export function clearDb(universalPersistenceKey: string) {
const dbId = STORE_PREFIX + universalPersistenceKey
indexedDB.deleteDatabase(dbId)
async function pruneSessionState({
persistenceKey,
didCancel,
}: {
persistenceKey: string
didCancel?: () => boolean
}) {
await withDb(STORE_PREFIX + persistenceKey, async (db) => {
const tx = db.transaction([Table.SessionState], 'readwrite')
const sessionStateStore = tx.objectStore(Table.SessionState)
const all = (await sessionStateStore.getAll()).sort((a, b) => a.updatedAt - b.updatedAt)
if (all.length < 10) {
await tx.done
return
}
const toDelete = all.slice(0, all.length - 10)
for (const { id } of toDelete) {
await sessionStateStore.delete(id)
}
if (didCancel?.()) return tx.abort()
await tx.done
})
}
/** @internal */
export function getAllIndexDbNames(): string[] {
const result = JSON.parse(window?.localStorage.getItem(dbNameIndexKey) || '[]') ?? []
if (!Array.isArray(result)) {
return []
}
return result
}
function addDbName(name: string) {
const all = new Set(getAllIndexDbNames())
all.add(name)
window?.localStorage.setItem(dbNameIndexKey, JSON.stringify([...all]))
}

View file

@ -1,78 +0,0 @@
import { InstanceRecordType, TLInstanceId } from '@tldraw/tlschema'
import { uniqueId } from '../data'
const tabIdKey = 'TLDRAW_TAB_ID_v2' as const
const window = globalThis.window as
| {
navigator: Window['navigator']
localStorage: Window['localStorage']
sessionStorage: Window['sessionStorage']
addEventListener: Window['addEventListener']
TLDRAW_TAB_ID_v2?: string
}
| undefined
// https://stackoverflow.com/a/9039885
function iOS() {
if (!window) return false
return (
['iPad Simulator', 'iPhone Simulator', 'iPod Simulator', 'iPad', 'iPhone', 'iPod'].includes(
window.navigator.platform
) ||
// iPad on iOS 13 detection
(window.navigator.userAgent.includes('Mac') && 'ontouchend' in document)
)
}
// 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'
/** @public */
export const DEFAULT_DOCUMENT_NAME =
(window?.localStorage.getItem(defaultDocumentKey) as any) ?? uniqueId()
window?.localStorage.setItem(defaultDocumentKey, DEFAULT_DOCUMENT_NAME)
// the prefix for the IndexedDB store with the id matching either the URL's document id or the default document id
/** @public */
export const STORE_PREFIX = 'TLDRAW_DOCUMENT_v2'
/** @public */
export const TAB_ID: TLInstanceId =
window?.[tabIdKey] ?? window?.sessionStorage[tabIdKey] ?? InstanceRecordType.createId()
if (window) {
window[tabIdKey] = TAB_ID
if (iOS()) {
// iOS does not trigger beforeunload
// so we need to keep the sessionStorage value around
// and hope the user doesn't figure out a way to duplicate their tab
// in which case they'll have two tabs with the same UI state.
// It's not a big deal, but it's not ideal.
// And anyway I can't see a way to duplicate a tab in iOS Safari.
window.sessionStorage[tabIdKey] = TAB_ID
} else {
delete window.sessionStorage[tabIdKey]
}
}
window?.addEventListener('beforeunload', () => {
window.sessionStorage[tabIdKey] = TAB_ID
})
const dbNameIndexKey = 'TLDRAW_DB_NAME_INDEX_v2'
/** @internal */
export function getAllIndexDbNames(): string[] {
const result = JSON.parse(window?.localStorage.getItem(dbNameIndexKey) || '[]') ?? []
if (!Array.isArray(result)) {
return []
}
return result
}
/** @internal */
export function addDbName(name: string) {
const all = new Set(getAllIndexDbNames())
all.add(name)
window?.localStorage.setItem(dbNameIndexKey, JSON.stringify([...all]))
}

View file

@ -8,7 +8,6 @@ import { Editor } from '@tldraw/editor';
import { MigrationFailureReason } from '@tldraw/store';
import { Result } from '@tldraw/utils';
import { SerializedSchema } from '@tldraw/store';
import { TLInstanceId } from '@tldraw/editor';
import { TLStore } from '@tldraw/editor';
import { TLUiToastsContextType } from '@tldraw/ui';
import { TLUiTranslationKey } from '@tldraw/ui';
@ -40,10 +39,9 @@ export interface LegacyTldrawDocument {
export function parseAndLoadDocument(editor: Editor, document: string, msg: (id: TLUiTranslationKey) => string, addToast: TLUiToastsContextType['addToast'], onV1FileLoad?: () => void, forceDarkMode?: boolean): Promise<void>;
// @public (undocumented)
export function parseTldrawJsonFile({ json, instanceId, store, }: {
export function parseTldrawJsonFile({ json, store, }: {
store: TLStore;
json: string;
instanceId: TLInstanceId;
}): Result<TLStore, TldrawFileParseError>;
// @public (undocumented)

View file

@ -4,7 +4,6 @@ import {
fileToBase64,
TLAsset,
TLAssetId,
TLInstanceId,
TLRecord,
TLStore,
} from '@tldraw/editor'
@ -83,12 +82,10 @@ export type TldrawFileParseError =
/** @public */
export function parseTldrawJsonFile({
json,
instanceId,
store,
}: {
store: TLStore
json: string
instanceId: TLInstanceId
}): Result<TLStore, TldrawFileParseError> {
// first off, we parse .json file and check it matches the general shape of
// a tldraw file
@ -141,7 +138,6 @@ export function parseTldrawJsonFile({
return Result.ok(
createTLStore({
initialData: migrationResult.value,
instanceId,
})
)
} catch (e) {
@ -222,7 +218,6 @@ export async function parseAndLoadDocument(
const parseFileResult = parseTldrawJsonFile({
store: createTLStore(),
json: document,
instanceId: editor.instanceId,
})
if (!parseFileResult.ok) {
let description

View file

@ -1,4 +1,4 @@
import { createShapeId, createTLStore, InstanceRecordType, TLStore } from '@tldraw/editor'
import { createShapeId, createTLStore, TLStore } from '@tldraw/editor'
import { MigrationFailureReason, UnknownRecord } from '@tldraw/store'
import { assert } from '@tldraw/utils'
import { parseTldrawJsonFile as _parseTldrawJsonFile, TldrawFile } from '../lib/file'
@ -7,7 +7,6 @@ const parseTldrawJsonFile = (store: TLStore, json: string) =>
_parseTldrawJsonFile({
store,
json,
instanceId: InstanceRecordType.createCustomId('instance'),
})
function serialize(file: TldrawFile): string {

View file

@ -42,7 +42,7 @@ export type ComputedCache<Data, R extends UnknownRecord> = {
export function createRecordType<R extends UnknownRecord>(typeName: R['typeName'], config: {
migrations?: Migrations;
validator?: StoreValidator<R>;
scope: Scope;
scope: RecordScope;
}): RecordType<R, keyof Omit<R, 'id' | 'typeName'>>;
// @public (undocumented)
@ -69,7 +69,7 @@ export function getRecordVersion(record: UnknownRecord, serializedSchema: Serial
// @public
export type HistoryEntry<R extends UnknownRecord = UnknownRecord> = {
changes: RecordsDiff<R>;
source: 'remote' | 'user';
source: ChangeSource;
};
// @public (undocumented)
@ -166,21 +166,22 @@ export class RecordType<R extends UnknownRecord, RequiredProperties extends keyo
readonly validator?: {
validate: (r: unknown) => R;
} | StoreValidator<R>;
readonly scope?: Scope;
readonly scope?: RecordScope;
});
clone(record: R): R;
create(properties: Pick<R, RequiredProperties> & Omit<Partial<R>, RequiredProperties>): R;
// @deprecated
createCustomId(id: string): IdOf<R>;
// (undocumented)
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>;
createId(): IdOf<R>;
createId(customUniquePart?: string): IdOf<R>;
isId(id?: string): id is IdOf<R>;
isInstance: (record?: UnknownRecord) => record is R;
// (undocumented)
readonly migrations: Migrations;
parseId(id: string): IdOf<R>;
parseId(id: IdOf<R>): string;
// (undocumented)
readonly scope: Scope;
readonly scope: RecordScope;
readonly typeName: R['typeName'];
validate(record: unknown): R;
// (undocumented)
@ -228,23 +229,28 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
clear: () => void;
createComputedCache: <T, V extends R = R>(name: string, derive: (record: V) => T | undefined) => ComputedCache<T, V>;
createSelectedComputedCache: <T, J, V extends R = R>(name: string, selector: (record: V) => T | undefined, derive: (input: T) => J | undefined) => ComputedCache<J, V>;
deserialize: (snapshot: StoreSnapshot<R>) => void;
// @internal (undocumented)
ensureStoreIsUsable(): void;
// (undocumented)
extractingChanges(fn: () => void): RecordsDiff<R>;
filterChangesByScope(change: RecordsDiff<R>, scope: RecordScope): {
added: { [K in IdOf<R>]: R; };
updated: { [K_1 in IdOf<R>]: [from: R, to: R]; };
removed: { [K in IdOf<R>]: R; };
} | null;
// (undocumented)
_flushHistory(): void;
get: <K extends IdOf<R>>(id: K) => RecFromId<K> | undefined;
getSnapshot(): {
getSnapshot(scope?: 'all' | RecordScope): {
store: StoreSnapshot<R>;
schema: SerializedSchema;
};
has: <K extends IdOf<R>>(id: K) => boolean;
readonly history: Atom<number, RecordsDiff<R>>;
readonly id: string;
// @internal (undocumented)
isPossiblyCorrupted(): boolean;
listen: (listener: StoreListener<R>) => () => void;
listen: (onHistory: StoreListener<R>, filters?: Partial<StoreListenerFilters>) => () => void;
loadSnapshot(snapshot: {
store: StoreSnapshot<R>;
schema: SerializedSchema;
@ -263,8 +269,11 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
remove: (ids: IdOf<R>[]) => void;
// (undocumented)
readonly schema: StoreSchema<R, Props>;
serialize: (filter?: ((record: R) => boolean) | undefined) => StoreSnapshot<R>;
serializeDocumentState: () => StoreSnapshot<R>;
// (undocumented)
readonly scopedTypes: {
readonly [K in RecordScope]: ReadonlySet<R['typeName']>;
};
serialize: (scope?: 'all' | RecordScope) => StoreSnapshot<R>;
unsafeGetWithoutCapture: <K extends IdOf<R>>(id: K) => RecFromId<K> | undefined;
update: <K extends IdOf<R>>(id: K, updater: (record: RecFromId<K>) => RecFromId<K>) => void;
// (undocumented)

View file

@ -15,7 +15,7 @@ export type RecordTypeRecord<R extends RecordType<any, any>> = ReturnType<R['cre
*
* @public
* */
export type Scope = 'instance' | 'document' | 'presence'
export type RecordScope = 'session' | 'document' | 'presence'
/**
* A record type is a type that can be stored in a record store. It is created with
@ -31,7 +31,7 @@ export class RecordType<
readonly migrations: Migrations
readonly validator: StoreValidator<R> | { validate: (r: unknown) => R }
readonly scope: Scope
readonly scope: RecordScope
constructor(
/**
@ -45,7 +45,7 @@ export class RecordType<
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>
readonly migrations: Migrations
readonly validator?: StoreValidator<R> | { validate: (r: unknown) => R }
readonly scope?: Scope
readonly scope?: RecordScope
}
) {
this.createDefaultProperties = config.createDefaultProperties
@ -97,8 +97,8 @@ export class RecordType<
* @returns The new ID.
* @public
*/
createId(): IdOf<R> {
return (this.typeName + ':' + nanoid()) as IdOf<R>
createId(customUniquePart?: string): IdOf<R> {
return (this.typeName + ':' + (customUniquePart ?? nanoid())) as IdOf<R>
}
/**
@ -110,6 +110,7 @@ export class RecordType<
* const id = recordType.createCustomId('myId')
* ```
*
* @deprecated - Use `createId` instead.
* @param id - The ID to base the new ID on.
* @returns The new ID.
*/
@ -123,12 +124,12 @@ export class RecordType<
* @param id - The id
* @returns
*/
parseId(id: string): IdOf<R> {
parseId(id: IdOf<R>): string {
if (!this.isId(id)) {
throw new Error(`ID "${id}" is not a valid ID for type "${this.typeName}"`)
}
return id.slice(this.typeName.length + 1) as IdOf<R>
return id.slice(this.typeName.length + 1)
}
/**
@ -219,7 +220,7 @@ export function createRecordType<R extends UnknownRecord>(
config: {
migrations?: Migrations
validator?: StoreValidator<R>
scope: Scope
scope: RecordScope
}
): RecordType<R, keyof Omit<R, 'id' | 'typeName'>> {
return new RecordType<R, keyof Omit<R, 'id' | 'typeName'>>(typeName, {

View file

@ -1,14 +1,16 @@
import {
filterEntries,
objectMapEntries,
objectMapFromEntries,
objectMapKeys,
objectMapValues,
throttledRaf,
} from '@tldraw/utils'
import { nanoid } from 'nanoid'
import { Atom, Computed, Reactor, atom, computed, reactor, transact } from 'signia'
import { IdOf, RecordId, UnknownRecord } from './BaseRecord'
import { Cache } from './Cache'
import { RecordType } from './RecordType'
import { RecordScope } from './RecordType'
import { StoreQueries } from './StoreQueries'
import { SerializedSchema, StoreSchema } from './StoreSchema'
import { devFreeze } from './devFreeze'
@ -33,6 +35,13 @@ export type RecordsDiff<R extends UnknownRecord> = {
*/
export type CollectionDiff<T> = { added?: Set<T>; removed?: Set<T> }
export type ChangeSource = 'user' | 'remote'
export type StoreListenerFilters = {
source: ChangeSource | 'all'
scope: RecordScope | 'all'
}
/**
* An entry containing changes that originated either by user actions or remote changes.
*
@ -40,7 +49,7 @@ export type CollectionDiff<T> = { added?: Set<T>; removed?: Set<T> }
*/
export type HistoryEntry<R extends UnknownRecord = UnknownRecord> = {
changes: RecordsDiff<R>
source: 'user' | 'remote'
source: ChangeSource
}
/**
@ -94,6 +103,10 @@ export type StoreRecord<S extends Store<any>> = S extends Store<infer R> ? R : n
* @public
*/
export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
/**
* The random id of the store.
*/
public readonly id = nanoid()
/**
* An atom containing the store's atoms.
*
@ -125,7 +138,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
*
* @internal
*/
private listeners = new Set<StoreListener<R>>()
private listeners = new Set<{ onHistory: StoreListener<R>; filters: StoreListenerFilters }>()
/**
* An array of history entries that have not yet been flushed.
@ -146,6 +159,8 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
readonly props: Props
public readonly scopedTypes: { readonly [K in RecordScope]: ReadonlySet<R['typeName']> }
constructor(config: {
/** The store's initial data. */
initialData?: StoreSnapshot<R>
@ -182,6 +197,23 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
},
{ scheduleEffect: (cb) => throttledRaf(cb) }
)
this.scopedTypes = {
document: new Set(
objectMapValues(this.schema.types)
.filter((t) => t.scope === 'document')
.map((t) => t.typeName)
),
session: new Set(
objectMapValues(this.schema.types)
.filter((t) => t.scope === 'session')
.map((t) => t.typeName)
),
presence: new Set(
objectMapValues(this.schema.types)
.filter((t) => t.scope === 'presence')
.map((t) => t.typeName)
),
}
}
public _flushHistory() {
@ -189,11 +221,56 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
if (this.historyAccumulator.hasChanges()) {
const entries = this.historyAccumulator.flush()
for (const { changes, source } of entries) {
this.listeners.forEach((l) => l({ changes, source }))
let instanceChanges = null as null | RecordsDiff<R>
let documentChanges = null as null | RecordsDiff<R>
let presenceChanges = null as null | RecordsDiff<R>
for (const { onHistory, filters } of this.listeners) {
if (filters.source !== 'all' && filters.source !== source) {
continue
}
if (filters.scope !== 'all') {
if (filters.scope === 'document') {
documentChanges ??= this.filterChangesByScope(changes, 'document')
if (!documentChanges) continue
onHistory({ changes: documentChanges, source })
} else if (filters.scope === 'session') {
instanceChanges ??= this.filterChangesByScope(changes, 'session')
if (!instanceChanges) continue
onHistory({ changes: instanceChanges, source })
} else {
presenceChanges ??= this.filterChangesByScope(changes, 'presence')
if (!presenceChanges) continue
onHistory({ changes: presenceChanges, source })
}
} else {
onHistory({ changes, source })
}
}
}
}
}
/**
* Filters out non-document changes from a diff. Returns null if there are no changes left.
* @param change - the records diff
* @returns
*/
filterChangesByScope(change: RecordsDiff<R>, scope: RecordScope) {
const result = {
added: filterEntries(change.added, (_, r) => this.scopedTypes[scope].has(r.typeName)),
updated: filterEntries(change.updated, (_, r) => this.scopedTypes[scope].has(r[1].typeName)),
removed: filterEntries(change.removed, (_, r) => this.scopedTypes[scope].has(r.typeName)),
}
if (
Object.keys(result.added).length === 0 &&
Object.keys(result.updated).length === 0 &&
Object.keys(result.removed).length === 0
) {
return null
}
return result
}
/**
* Update the history with a diff of changes.
*
@ -421,46 +498,22 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
}
/**
* Opposite of `deserialize`. Creates a JSON payload from the record store.
* Creates a JSON payload from the record store.
*
* @param filter - A function to filter structs that do not satisfy the predicate.
* @param scope - The scope of records to serialize. Defaults to 'document'.
* @returns The record store snapshot as a JSON payload.
*/
serialize = (filter?: (record: R) => boolean): StoreSnapshot<R> => {
serialize = (scope: RecordScope | 'all' = 'document'): StoreSnapshot<R> => {
const result = {} as StoreSnapshot<R>
for (const [id, atom] of objectMapEntries(this.atoms.value)) {
const record = atom.value
if (typeof filter === 'function' && !filter(record)) continue
result[id as IdOf<R>] = record
if (scope === 'all' || this.scopedTypes[scope].has(record.typeName)) {
result[id as IdOf<R>] = record
}
}
return result
}
/**
* The same as `serialize`, but only serializes records with a scope of `document`.
* @returns The record store snapshot as a JSON payload.
*/
serializeDocumentState = (): StoreSnapshot<R> => {
return this.serialize((r) => {
const type = this.schema.types[r.typeName as R['typeName']] as RecordType<any, any>
return type.scope === 'document'
})
}
/**
* Opposite of `serialize`. Replace the store's current records with records as defined by a
* simple JSON structure into the stores.
*
* @param snapshot - The JSON snapshot to deserialize.
* @public
*/
deserialize = (snapshot: StoreSnapshot<R>): void => {
transact(() => {
this.clear()
this.put(Object.values(snapshot))
})
}
/**
* Get a serialized snapshot of the store and its schema.
*
@ -469,11 +522,12 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
* store.loadSnapshot(snapshot)
* ```
*
* @param scope - The scope of records to serialize. Defaults to 'document'.
* @public
*/
getSnapshot() {
getSnapshot(scope: RecordScope | 'all' = 'document') {
return {
store: this.serializeDocumentState(),
store: this.serialize(scope),
schema: this.schema.serialize(),
}
}
@ -497,7 +551,11 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
throw new Error(`Failed to migrate snapshot: ${migrationResult.reason}`)
}
this.deserialize(migrationResult.value)
transact(() => {
this.clear()
this.put(Object.values(migrationResult.value))
this.ensureStoreIsUsable()
})
}
/**
@ -548,13 +606,22 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
/**
* Add a new listener to the store.
*
* @param listener - The listener to call when the store updates.
* @param onHistory - The listener to call when the store updates.
* @param filters - Filters to apply to the listener.
* @returns A function to remove the listener.
*/
listen = (listener: StoreListener<R>) => {
listen = (onHistory: StoreListener<R>, filters?: Partial<StoreListenerFilters>) => {
// flush history so that this listener's history starts from exactly now
this._flushHistory()
const listener = {
onHistory,
filters: {
source: filters?.source ?? 'all',
scope: filters?.scope ?? 'all',
},
}
this.listeners.add(listener)
if (!this.historyReactor.scheduler.isActivelyListening) {
@ -802,18 +869,18 @@ export function reverseRecordsDiff(diff: RecordsDiff<any>) {
class HistoryAccumulator<T extends UnknownRecord> {
private _history: HistoryEntry<T>[] = []
private _inteceptors: Set<(entry: HistoryEntry<T>) => void> = new Set()
private _interceptors: Set<(entry: HistoryEntry<T>) => void> = new Set()
intercepting(fn: (entry: HistoryEntry<T>) => void) {
this._inteceptors.add(fn)
this._interceptors.add(fn)
return () => {
this._inteceptors.delete(fn)
this._interceptors.delete(fn)
}
}
add(entry: HistoryEntry<T>) {
this._history.push(entry)
for (const interceptor of this._inteceptors) {
for (const interceptor of this._interceptors) {
interceptor(entry)
}
}

View file

@ -27,15 +27,31 @@ const Author = createRecordType<Author>('author', {
isPseudonym: false,
}))
interface Visit extends BaseRecord<'visit', RecordId<Visit>> {
visitorName: string
booksInBasket: RecordId<Book>[]
}
const Visit = createRecordType<Visit>('visit', {
validator: { validate: (visit) => visit as Visit },
scope: 'session',
}).withDefaultProperties(() => ({
visitorName: 'Anonymous',
booksInBasket: [],
}))
type LibraryType = Book | Author | Visit
describe('Store', () => {
let store: Store<Book | Author>
let store: Store<Book | Author | Visit>
beforeEach(() => {
store = new Store({
props: {},
schema: StoreSchema.create<Book | Author>(
schema: StoreSchema.create<LibraryType>(
{
book: Book,
author: Author,
visit: Visit,
},
{
snapshotMigrations: {
@ -49,18 +65,18 @@ describe('Store', () => {
})
it('allows records to be added', () => {
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') })])
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })])
expect(store.query.records('author').value).toEqual([
{ id: 'author:tolkein', typeName: 'author', name: 'J.R.R Tolkein', isPseudonym: false },
])
store.put([
{
id: Book.createCustomId('the-hobbit'),
id: Book.createId('the-hobbit'),
typeName: 'book',
title: 'The Hobbit',
numPages: 423,
author: Author.createCustomId('tolkein'),
author: Author.createId('tolkein'),
},
])
@ -88,7 +104,7 @@ describe('Store', () => {
})
it('allows listening to the change history', () => {
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') })])
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })])
expect(lastDiff!).toMatchInlineSnapshot(`
Array [
@ -107,7 +123,7 @@ describe('Store', () => {
]
`)
store.update(Author.createCustomId('tolkein'), (r) => ({ ...r, name: 'Jimmy Tolks' }))
store.update(Author.createId('tolkein'), (r) => ({ ...r, name: 'Jimmy Tolks' }))
expect(lastDiff!).toMatchInlineSnapshot(`
Array [
@ -134,7 +150,7 @@ describe('Store', () => {
]
`)
store.remove([Author.createCustomId('tolkein')])
store.remove([Author.createId('tolkein')])
expect(lastDiff!).toMatchInlineSnapshot(`
Array [
@ -155,13 +171,13 @@ describe('Store', () => {
transact(() => {
store.put([
Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') }),
Author.create({ name: 'David Foster Wallace', id: Author.createCustomId('dfw') }),
Author.create({ name: 'Cynan Jones', id: Author.createCustomId('cj') }),
Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }),
Author.create({ name: 'David Foster Wallace', id: Author.createId('dfw') }),
Author.create({ name: 'Cynan Jones', id: Author.createId('cj') }),
])
store.update(Author.createCustomId('tolkein'), (r) => ({ ...r, name: 'Jimmy Tolks' }))
store.update(Author.createCustomId('cj'), (r) => ({ ...r, name: 'Carter, Jimmy' }))
store.update(Author.createId('tolkein'), (r) => ({ ...r, name: 'Jimmy Tolks' }))
store.update(Author.createId('cj'), (r) => ({ ...r, name: 'Carter, Jimmy' }))
})
expect(lastDiff!).toMatchInlineSnapshot(`
@ -199,11 +215,11 @@ describe('Store', () => {
/* ADDING */
store.onAfterCreate = jest.fn((current) => {
expect(current).toEqual(
Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') })
Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })
)
expect([...store.query.ids('author').value]).toEqual([Author.createCustomId('tolkein')])
expect([...store.query.ids('author').value]).toEqual([Author.createId('tolkein')])
})
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') })])
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })])
expect(store.onAfterCreate).toHaveBeenCalledTimes(1)
@ -213,11 +229,11 @@ describe('Store', () => {
expect(prev.name).toBe('J.R.R Tolkein')
expect(current.name).toBe('Butch Cassidy')
expect(store.get(Author.createCustomId('tolkein'))!.name).toBe('Butch Cassidy')
expect(store.get(Author.createId('tolkein'))!.name).toBe('Butch Cassidy')
}
})
store.update(Author.createCustomId('tolkein'), (r) => ({ ...r, name: 'Butch Cassidy' }))
store.update(Author.createId('tolkein'), (r) => ({ ...r, name: 'Butch Cassidy' }))
expect(store.onAfterChange).toHaveBeenCalledTimes(1)
@ -228,18 +244,18 @@ describe('Store', () => {
}
})
store.remove([Author.createCustomId('tolkein')])
store.remove([Author.createId('tolkein')])
expect(store.onAfterDelete).toHaveBeenCalledTimes(1)
})
it('allows finding and filtering records with a predicate', () => {
store.put([
Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') }),
Author.create({ name: 'James McAvoy', id: Author.createCustomId('mcavoy') }),
Author.create({ name: 'Butch Cassidy', id: Author.createCustomId('cassidy') }),
Author.create({ name: 'Cynan Jones', id: Author.createCustomId('cj') }),
Author.create({ name: 'David Foster Wallace', id: Author.createCustomId('dfw') }),
Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }),
Author.create({ name: 'James McAvoy', id: Author.createId('mcavoy') }),
Author.create({ name: 'Butch Cassidy', id: Author.createId('cassidy') }),
Author.create({ name: 'Cynan Jones', id: Author.createId('cj') }),
Author.create({ name: 'David Foster Wallace', id: Author.createId('dfw') }),
])
const Js = store.query.records('author').value.filter((r) => r.name.startsWith('J'))
expect(Js.map((j) => j.name).sort()).toEqual(['J.R.R Tolkein', 'James McAvoy'])
@ -259,7 +275,7 @@ describe('Store', () => {
expect(lastIdDiff).toBe(RESET_VALUE)
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') })])
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })])
expect(lastIdDiff).toMatchInlineSnapshot(`
Array [
@ -272,9 +288,9 @@ describe('Store', () => {
`)
transact(() => {
store.put([Author.create({ name: 'James McAvoy', id: Author.createCustomId('mcavoy') })])
store.put([Author.create({ name: 'Butch Cassidy', id: Author.createCustomId('cassidy') })])
store.remove([Author.createCustomId('tolkein')])
store.put([Author.create({ name: 'James McAvoy', id: Author.createId('mcavoy') })])
store.put([Author.create({ name: 'Butch Cassidy', id: Author.createId('cassidy') })])
store.remove([Author.createId('tolkein')])
})
expect(lastIdDiff).toMatchInlineSnapshot(`
@ -298,21 +314,21 @@ describe('Store', () => {
transact(() => {
store.put([
Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') }),
Author.create({ name: 'James McAvoy', id: Author.createCustomId('mcavoy') }),
Author.create({ name: 'Butch Cassidy', id: Author.createCustomId('cassidy') }),
Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }),
Author.create({ name: 'James McAvoy', id: Author.createId('mcavoy') }),
Author.create({ name: 'Butch Cassidy', id: Author.createId('cassidy') }),
Book.create({
title: 'The Hobbit',
id: Book.createCustomId('hobbit'),
author: Author.createCustomId('tolkein'),
id: Book.createId('hobbit'),
author: Author.createId('tolkein'),
numPages: 300,
}),
])
store.put([
Book.create({
title: 'The Lord of the Rings',
id: Book.createCustomId('lotr'),
author: Author.createCustomId('tolkein'),
id: Book.createId('lotr'),
author: Author.createId('tolkein'),
numPages: 1000,
}),
])
@ -365,11 +381,11 @@ describe('Store', () => {
`)
transact(() => {
store.update(Author.createCustomId('tolkein'), (author) => ({
store.update(Author.createId('tolkein'), (author) => ({
...author,
name: 'Jimmy Tolks',
}))
store.update(Book.createCustomId('lotr'), (book) => ({ ...book, numPages: 42 }))
store.update(Book.createId('lotr'), (book) => ({ ...book, numPages: 42 }))
})
await new Promise((resolve) => requestAnimationFrame(resolve))
@ -418,11 +434,11 @@ describe('Store', () => {
`)
transact(() => {
store.update(Author.createCustomId('mcavoy'), (author) => ({
store.update(Author.createId('mcavoy'), (author) => ({
...author,
name: 'Sookie Houseboat',
}))
store.remove([Book.createCustomId('lotr')])
store.remove([Book.createId('lotr')])
})
await new Promise((resolve) => requestAnimationFrame(resolve))
@ -463,8 +479,177 @@ describe('Store', () => {
`)
})
it('supports filtering history by scope', () => {
const listener = jest.fn()
store.listen(listener, {
scope: 'session',
})
store.put([
Author.create({ name: 'J.R.R Tolkien', id: Author.createId('tolkien') }),
Book.create({
title: 'The Hobbit',
id: Book.createId('hobbit'),
author: Author.createId('tolkien'),
numPages: 300,
}),
])
expect(listener).toHaveBeenCalledTimes(0)
store.put([
Author.create({ name: 'J.D. Salinger', id: Author.createId('salinger') }),
Visit.create({ id: Visit.createId('jimmy'), visitorName: 'Jimmy Beans' }),
])
expect(listener).toHaveBeenCalledTimes(1)
expect(listener.mock.calls[0][0].changes).toMatchInlineSnapshot(`
Object {
"added": Object {
"visit:jimmy": Object {
"booksInBasket": Array [],
"id": "visit:jimmy",
"typeName": "visit",
"visitorName": "Jimmy Beans",
},
},
"removed": Object {},
"updated": Object {},
}
`)
})
it('supports filtering history by scope (2)', () => {
const listener = jest.fn()
store.listen(listener, {
scope: 'document',
})
store.put([
Author.create({ name: 'J.D. Salinger', id: Author.createId('salinger') }),
Visit.create({ id: Visit.createId('jimmy'), visitorName: 'Jimmy Beans' }),
])
expect(listener).toHaveBeenCalledTimes(1)
expect(listener.mock.calls[0][0].changes).toMatchInlineSnapshot(`
Object {
"added": Object {
"author:salinger": Object {
"id": "author:salinger",
"isPseudonym": false,
"name": "J.D. Salinger",
"typeName": "author",
},
},
"removed": Object {},
"updated": Object {},
}
`)
})
it('supports filtering history by source', () => {
const listener = jest.fn()
store.listen(listener, {
source: 'remote',
})
store.put([
Author.create({ name: 'J.D. Salinger', id: Author.createId('salinger') }),
Visit.create({ id: Visit.createId('jimmy'), visitorName: 'Jimmy Beans' }),
])
expect(listener).toHaveBeenCalledTimes(0)
store.mergeRemoteChanges(() => {
store.put([
Author.create({ name: 'J.R.R Tolkien', id: Author.createId('tolkien') }),
Book.create({
title: 'The Hobbit',
id: Book.createId('hobbit'),
author: Author.createId('tolkien'),
numPages: 300,
}),
])
})
expect(listener).toHaveBeenCalledTimes(1)
expect(listener.mock.calls[0][0].changes).toMatchInlineSnapshot(`
Object {
"added": Object {
"author:tolkien": Object {
"id": "author:tolkien",
"isPseudonym": false,
"name": "J.R.R Tolkien",
"typeName": "author",
},
"book:hobbit": Object {
"author": "author:tolkien",
"id": "book:hobbit",
"numPages": 300,
"title": "The Hobbit",
"typeName": "book",
},
},
"removed": Object {},
"updated": Object {},
}
`)
})
it('supports filtering history by source (user)', () => {
const listener = jest.fn()
store.listen(listener, {
source: 'user',
})
store.mergeRemoteChanges(() => {
store.put([
Author.create({ name: 'J.R.R Tolkien', id: Author.createId('tolkien') }),
Book.create({
title: 'The Hobbit',
id: Book.createId('hobbit'),
author: Author.createId('tolkien'),
numPages: 300,
}),
])
})
expect(listener).toHaveBeenCalledTimes(0)
store.put([
Author.create({ name: 'J.D. Salinger', id: Author.createId('salinger') }),
Visit.create({ id: Visit.createId('jimmy'), visitorName: 'Jimmy Beans' }),
])
expect(listener).toHaveBeenCalledTimes(1)
expect(listener.mock.calls[0][0].changes).toMatchInlineSnapshot(`
Object {
"added": Object {
"author:salinger": Object {
"id": "author:salinger",
"isPseudonym": false,
"name": "J.D. Salinger",
"typeName": "author",
},
"visit:jimmy": Object {
"booksInBasket": Array [],
"id": "visit:jimmy",
"typeName": "visit",
"visitorName": "Jimmy Beans",
},
},
"removed": Object {},
"updated": Object {},
}
`)
})
it('does not keep global history if no listeners are attached', () => {
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') })])
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })])
expect((store as any).historyAccumulator._history).toHaveLength(0)
})
@ -472,12 +657,12 @@ describe('Store', () => {
try {
// @ts-expect-error
globalThis.__FORCE_RAF_IN_TESTS__ = true
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') })])
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })])
const firstListener = jest.fn()
store.listen(firstListener)
expect(firstListener).toHaveBeenCalledTimes(0)
store.put([Author.create({ name: 'Chips McCoy', id: Author.createCustomId('chips') })])
store.put([Author.create({ name: 'Chips McCoy', id: Author.createId('chips') })])
expect(firstListener).toHaveBeenCalledTimes(0)
@ -499,19 +684,19 @@ describe('Store', () => {
})
it('does not overwrite default properties with undefined', () => {
const tolkein = Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') })
const tolkein = Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })
expect(tolkein.isPseudonym).toBe(false)
const harkaway = Author.create({
name: 'Nick Harkaway',
id: Author.createCustomId('harkaway'),
id: Author.createId('harkaway'),
isPseudonym: true,
})
expect(harkaway.isPseudonym).toBe(true)
const burns = Author.create({
name: 'Anna Burns',
id: Author.createCustomId('burns'),
id: Author.createId('burns'),
isPseudonym: undefined,
})
@ -519,7 +704,7 @@ describe('Store', () => {
})
it('allows changed to be merged without triggering listeners', () => {
const id = Author.createCustomId('tolkein')
const id = Author.createId('tolkein')
store.put([Author.create({ name: 'J.R.R Tolkein', id })])
const listener = jest.fn()
@ -535,19 +720,19 @@ describe('Store', () => {
const listener = jest.fn()
store.listen(listener)
store.put([Author.create({ name: 'Jimmy Beans', id: Author.createCustomId('jimmy') })])
store.put([Author.create({ name: 'Jimmy Beans', id: Author.createId('jimmy') })])
await new Promise((resolve) => requestAnimationFrame(resolve))
expect(listener).toHaveBeenCalledTimes(1)
expect(listener.mock.calls[0][0].source).toBe('user')
store.mergeRemoteChanges(() => {
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') })])
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })])
store.put([
Book.create({
title: 'The Hobbit',
id: Book.createCustomId('hobbit'),
author: Author.createCustomId('tolkein'),
id: Book.createId('hobbit'),
author: Author.createId('tolkein'),
numPages: 300,
}),
])
@ -557,7 +742,7 @@ describe('Store', () => {
expect(listener).toHaveBeenCalledTimes(2)
expect(listener.mock.calls[1][0].source).toBe('remote')
store.put([Author.create({ name: 'Steve Ok', id: Author.createCustomId('stever') })])
store.put([Author.create({ name: 'Steve Ok', id: Author.createId('stever') })])
await new Promise((resolve) => requestAnimationFrame(resolve))
expect(listener).toHaveBeenCalledTimes(3)
@ -588,21 +773,21 @@ describe('snapshots', () => {
transact(() => {
store.put([
Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') }),
Author.create({ name: 'James McAvoy', id: Author.createCustomId('mcavoy') }),
Author.create({ name: 'Butch Cassidy', id: Author.createCustomId('cassidy') }),
Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }),
Author.create({ name: 'James McAvoy', id: Author.createId('mcavoy') }),
Author.create({ name: 'Butch Cassidy', id: Author.createId('cassidy') }),
Book.create({
title: 'The Hobbit',
id: Book.createCustomId('hobbit'),
author: Author.createCustomId('tolkein'),
id: Book.createId('hobbit'),
author: Author.createId('tolkein'),
numPages: 300,
}),
])
store.put([
Book.create({
title: 'The Lord of the Rings',
id: Book.createCustomId('lotr'),
author: Author.createCustomId('tolkein'),
id: Book.createId('lotr'),
author: Author.createId('tolkein'),
numPages: 1000,
}),
])
@ -610,7 +795,7 @@ describe('snapshots', () => {
})
it('creates and loads a snapshot', () => {
const serializedStore1 = store.serialize()
const serializedStore1 = store.serialize('all')
const serializedSchema1 = store.schema.serialize()
const snapshot1 = store.getSnapshot()
@ -634,7 +819,7 @@ describe('snapshots', () => {
store2.loadSnapshot(snapshot1)
const serializedStore2 = store2.serialize()
const serializedStore2 = store2.serialize('all')
const serializedSchema2 = store2.schema.serialize()
const snapshot2 = store2.getSnapshot()

View file

@ -156,7 +156,7 @@ function getRandomBookOp(
return {
type: 'add',
record: Book.create({
id: Book.createCustomId(getRandomNumber().toString()),
id: Book.createId(getRandomNumber().toString()),
authorId: author.id,
title: getRandomBookName(getRandomNumber),
}),
@ -214,7 +214,7 @@ function getRandomAuthorOp(
return {
type: 'add',
record: Author.create({
id: Author.createCustomId(getRandomNumber().toString()),
id: Author.createId(getRandomNumber().toString()),
name: getRandomAuthorName(getRandomNumber),
}),
}
@ -355,7 +355,7 @@ function runTest(seed: number) {
const authorNameIndexDiffs: RSIndexDiff<Author>[] = []
const authorIdIndexDiffs: RSIndexDiff<Book>[] = []
const authorIdQueryParam = atom('authorId', Author.createCustomId('does-not-exist'))
const authorIdQueryParam = atom('authorId', Author.createId('does-not-exist'))
const bookTitleQueryParam = atom('bookTitle', getRandomBookName(getRandomNumber))
const booksByAuthorQuery = store.query.records('book', () => ({

View file

@ -2,11 +2,18 @@ import { testSchemaV1 } from './testSchema.v1'
describe('parseId', () => {
it('should return the part after the colon', () => {
expect(testSchemaV1.types.user.parseId('user:123')).toBe('123')
expect(testSchemaV1.types.shape.parseId('shape:xyz')).toBe('xyz')
expect(testSchemaV1.types.user.parseId('user:123' as any)).toBe('123')
expect(testSchemaV1.types.shape.parseId('shape:xyz' as any)).toBe('xyz')
})
it('should throw if the typename does not match', () => {
expect(() => testSchemaV1.types.user.parseId('shape:123')).toThrow()
expect(() => testSchemaV1.types.shape.parseId('user:xyz')).toThrow()
expect(() => testSchemaV1.types.user.parseId('shape:123' as any)).toThrow()
expect(() => testSchemaV1.types.shape.parseId('user:xyz' as any)).toThrow()
})
})
describe('createId', () => {
it('should prepend the typename and a colon', () => {
expect(testSchemaV1.types.user.createId('123')).toBe('user:123')
expect(testSchemaV1.types.shape.createId('xyz')).toBe('shape:xyz')
})
})

View file

@ -67,7 +67,7 @@ describe('Store with validation', () => {
})
it('Accepts valid records and rejects invalid records', () => {
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') })])
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })])
expect(store.query.records('author').value).toEqual([
{ id: 'author:tolkein', typeName: 'author', name: 'J.R.R Tolkein', isPseudonym: false },
@ -76,11 +76,11 @@ describe('Store with validation', () => {
expect(() => {
store.put([
{
id: Book.createCustomId('the-hobbit'),
id: Book.createId('the-hobbit'),
typeName: 'book',
title: 'The Hobbit',
numPages: -1, // <---- Invalid!
author: Author.createCustomId('tolkein'),
author: Author.createId('tolkein'),
},
])
}).toThrow()
@ -93,9 +93,9 @@ describe('Validating initial data', () => {
let snapshot: StoreSnapshot<Book | Author>
beforeEach(() => {
const authorId = Author.createCustomId('tolkein')
const authorId = Author.createId('tolkein')
const authorRecord = Author.create({ name: 'J.R.R Tolkein', id: authorId })
const bookId = Book.createCustomId('the-hobbit')
const bookId = Book.createId('the-hobbit')
const bookRecord = Book.create({
title: 'The Hobbit',
numPages: 300,
@ -124,8 +124,8 @@ describe('Validating initial data', () => {
})
describe('Create & update validations', () => {
const authorId = Author.createCustomId('tolkein')
const bookId = Book.createCustomId('the-hobbit')
const authorId = Author.createId('tolkein')
const bookId = Book.createId('the-hobbit')
const initialAuthor = Author.create({ name: 'J.R.R Tolkein', id: authorId })
const invalidBook = Book.create({
// @ts-expect-error - deliberately invalid data
@ -165,7 +165,7 @@ describe('Create & update validations', () => {
})
it('Prevents adding invalid records to a store', () => {
const newAuthorId = Author.createCustomId('shearing')
const newAuthorId = Author.createId('shearing')
const store = new Store<Book | Author>({
schema,
initialData: { [bookId]: validBook, [authorId]: initialAuthor },

View file

@ -327,10 +327,10 @@ export const iconValidator: T.Validator<"activity" | "airplay" | "alert-circle"
export function idValidator<Id extends RecordId<UnknownRecord>>(prefix: Id['__type__']['typeName']): T.Validator<Id>;
// @public (undocumented)
export const InstancePageStateRecordType: RecordType<TLInstancePageState, "cameraId" | "instanceId" | "pageId">;
export const InstancePageStateRecordType: RecordType<TLInstancePageState, "pageId">;
// @public (undocumented)
export const InstancePresenceRecordType: RecordType<TLInstancePresence, "currentPageId" | "instanceId" | "userId" | "userName">;
export const InstancePresenceRecordType: RecordType<TLInstancePresence, "currentPageId" | "userId" | "userName">;
// @public (undocumented)
export const InstanceRecordType: RecordType<TLInstance, "currentPageId">;
@ -452,6 +452,9 @@ export const LANGUAGES: readonly [{
// @internal (undocumented)
export const opacityValidator: T.Validator<"0.1" | "0.25" | "0.5" | "0.75" | "1">;
// @internal (undocumented)
export const pageIdValidator: T.Validator<TLPageId>;
// @public (undocumented)
export const PageRecordType: RecordType<TLPage, "index" | "name">;
@ -839,6 +842,10 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
// (undocumented)
isFocusMode: boolean;
// (undocumented)
isGridMode: boolean;
// (undocumented)
isPenMode: boolean;
// (undocumented)
isToolLocked: boolean;
// (undocumented)
propsForNextShape: TLInstancePropsForNextShape;
@ -850,13 +857,14 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
zoomBrush: Box2dModel | null;
}
// @public (undocumented)
export const TLINSTANCE_ID: TLInstanceId;
// @public (undocumented)
export type TLInstanceId = RecordId<TLInstance>;
// @public
export interface TLInstancePageState extends BaseRecord<'instance_page_state', TLInstancePageStateId> {
// (undocumented)
cameraId: RecordId<TLCamera>;
// (undocumented)
croppingId: null | TLShapeId;
// (undocumented)
@ -870,8 +878,6 @@ export interface TLInstancePageState extends BaseRecord<'instance_page_state', T
// (undocumented)
hoveredId: null | TLShapeId;
// (undocumented)
instanceId: RecordId<TLInstance>;
// (undocumented)
pageId: RecordId<TLPage>;
// (undocumented)
selectedIds: TLShapeId[];
@ -901,8 +907,6 @@ export interface TLInstancePresence extends BaseRecord<'instance_presence', TLIn
// (undocumented)
followingUserId: null | string;
// (undocumented)
instanceId: TLInstanceId;
// (undocumented)
lastActivityTimestamp: number;
// (undocumented)
screenBounds: Box2dModel;
@ -962,7 +966,7 @@ export type TLParentId = TLPageId | TLShapeId;
export const TLPOINTER_ID: TLPointerId;
// @public (undocumented)
export type TLRecord = TLAsset | TLCamera | TLDocument | TLInstance | TLInstancePageState | TLInstancePresence | TLPage | TLPointer | TLShape | TLUserDocument;
export type TLRecord = TLAsset | TLCamera | TLDocument | TLInstance | TLInstancePageState | TLInstancePresence | TLPage | TLPointer | TLShape;
// @public
export type TLScribble = {
@ -1020,8 +1024,6 @@ export type TLStore = Store<TLRecord, TLStoreProps>;
// @public (undocumented)
export type TLStoreProps = {
instanceId: TLInstanceId;
documentId: typeof TLDOCUMENT_ID;
defaultName: string;
};
@ -1087,22 +1089,6 @@ export type TLTextShapeProps = {
// @public
export type TLUnknownShape = TLBaseShape<string, object>;
// @public
export interface TLUserDocument extends BaseRecord<'user_document', TLUserDocumentId> {
// (undocumented)
isGridMode: boolean;
// (undocumented)
isMobileMode: boolean;
// (undocumented)
isPenMode: boolean;
// (undocumented)
isSnapMode: boolean;
// (undocumented)
lastUpdatedPageId: null | RecordId<TLPage>;
// (undocumented)
lastUsedTabId: null | RecordId<TLInstance>;
}
// @public (undocumented)
export type TLVerticalAlignType = SetValue<typeof TL_VERTICAL_ALIGN_TYPES>;
@ -1119,9 +1105,6 @@ export type TLVideoAsset = TLBaseAsset<'video', {
// @public (undocumented)
export type TLVideoShape = TLBaseShape<'video', TLVideoShapeProps>;
// @internal (undocumented)
export const UserDocumentRecordType: RecordType<TLUserDocument, never>;
// @public
export interface Vec2dModel {
// (undocumented)

View file

@ -1,13 +1,12 @@
import { Store, StoreSchema, StoreSchemaOptions, StoreSnapshot } from '@tldraw/store'
import { annotateError, structuredClone } from '@tldraw/utils'
import { CameraRecordType } from './records/TLCamera'
import { CameraRecordType, TLCameraId } from './records/TLCamera'
import { DocumentRecordType, TLDOCUMENT_ID } from './records/TLDocument'
import { InstanceRecordType, TLInstanceId } from './records/TLInstance'
import { PageRecordType } from './records/TLPage'
import { InstancePageStateRecordType } from './records/TLPageState'
import { InstanceRecordType, TLINSTANCE_ID } from './records/TLInstance'
import { PageRecordType, TLPageId } from './records/TLPage'
import { InstancePageStateRecordType, TLInstancePageStateId } from './records/TLPageState'
import { PointerRecordType, TLPOINTER_ID } from './records/TLPointer'
import { TLRecord } from './records/TLRecord'
import { UserDocumentRecordType } from './records/TLUserDocument'
function sortByIndex<T extends { index: string }>(a: T, b: T) {
if (a.index < b.index) {
@ -38,8 +37,6 @@ export type TLStoreSnapshot = StoreSnapshot<TLRecord>
/** @public */
export type TLStoreProps = {
instanceId: TLInstanceId
documentId: typeof TLDOCUMENT_ID
defaultName: string
}
@ -79,17 +76,9 @@ function getDefaultPages() {
/** @internal */
export function createIntegrityChecker(store: TLStore): () => void {
const $pages = store.query.records('page')
const $userDocumentSettings = store.query.record('user_document')
const $instanceState = store.query.record('instance', () => ({
id: { eq: store.props.instanceId },
}))
const $instancePageStates = store.query.records('instance_page_state')
const $pageIds = store.query.ids('page')
const ensureStoreIsUsable = (): void => {
const { instanceId: tabId } = store.props
// make sure we have exactly one document
if (!store.has(TLDOCUMENT_ID)) {
store.put([DocumentRecordType.create({ id: TLDOCUMENT_ID, name: store.props.defaultName })])
@ -101,78 +90,58 @@ export function createIntegrityChecker(store: TLStore): () => void {
return ensureStoreIsUsable()
}
// make sure we have document state for the current user
const userDocumentSettings = $userDocumentSettings.value
if (!userDocumentSettings) {
store.put([UserDocumentRecordType.create({})])
return ensureStoreIsUsable()
}
// make sure there is at least one page
const pages = $pages.value.sort(sortByIndex)
if (pages.length === 0) {
const pageIds = $pageIds.value
if (pageIds.size === 0) {
store.put(getDefaultPages())
return ensureStoreIsUsable()
}
const getFirstPageId = () => [...pageIds].map((id) => store.get(id)!).sort(sortByIndex)[0].id!
// make sure we have state for the current user's current tab
const instanceState = $instanceState.value
const instanceState = store.get(TLINSTANCE_ID)
if (!instanceState) {
// The tab props are either the the last used tab's props or undefined
const propsForNextShape = userDocumentSettings.lastUsedTabId
? store.get(userDocumentSettings.lastUsedTabId)?.propsForNextShape
: undefined
// The current page is either the last updated page or the first page
const currentPageId = userDocumentSettings?.lastUpdatedPageId ?? pages[0].id!
store.put([
InstanceRecordType.create({
id: tabId,
currentPageId,
propsForNextShape,
id: TLINSTANCE_ID,
currentPageId: getFirstPageId(),
exportBackground: true,
}),
])
return ensureStoreIsUsable()
}
// make sure the user's currentPageId is still valid
let currentPageId = instanceState.currentPageId
if (!pages.find((p) => p.id === currentPageId)) {
currentPageId = pages[0].id!
store.put([{ ...instanceState, currentPageId }])
} else if (!pageIds.has(instanceState.currentPageId)) {
store.put([{ ...instanceState, currentPageId: getFirstPageId() }])
return ensureStoreIsUsable()
}
for (const page of pages) {
const instancePageStates = $instancePageStates.value.filter(
(tps) => tps.pageId === page.id && tps.instanceId === tabId
)
if (instancePageStates.length > 1) {
// make sure we only have one instancePageState per instance per page
store.remove(instancePageStates.slice(1).map((ips) => ips.id))
} else if (instancePageStates.length === 0) {
const camera = CameraRecordType.create({})
store.put([
camera,
InstancePageStateRecordType.create({
pageId: page.id,
instanceId: tabId,
cameraId: camera.id,
}),
])
return ensureStoreIsUsable()
// make sure we have page states and cameras for all the pages
const missingPageStateIds = new Set<TLInstancePageStateId>()
const missingCameraIds = new Set<TLCameraId>()
for (const id of pageIds) {
const pageStateId = InstancePageStateRecordType.createId(id)
if (!store.has(pageStateId)) {
missingPageStateIds.add(pageStateId)
}
const cameraId = CameraRecordType.createId(id)
if (!store.has(cameraId)) {
missingCameraIds.add(cameraId)
}
}
// make sure the camera exists
const camera = store.get(instancePageStates[0].cameraId)
if (!camera) {
store.put([CameraRecordType.create({ id: instancePageStates[0].cameraId })])
return ensureStoreIsUsable()
}
if (missingPageStateIds.size > 0) {
store.put(
[...missingPageStateIds].map((id) =>
InstancePageStateRecordType.create({
id,
pageId: InstancePageStateRecordType.parseId(id) as TLPageId,
})
)
)
}
if (missingCameraIds.size > 0) {
store.put([...missingCameraIds].map((id) => CameraRecordType.create({ id })))
}
}

View file

@ -1,37 +1,27 @@
import { Signal, computed } from 'signia'
import { TLStore } from './TLStore'
import { CameraRecordType } from './records/TLCamera'
import { TLINSTANCE_ID } from './records/TLInstance'
import { InstancePageStateRecordType } from './records/TLPageState'
import { TLPOINTER_ID } from './records/TLPointer'
import { InstancePresenceRecordType, TLInstancePresence } from './records/TLPresence'
/** @internal */
export const createPresenceStateDerivation =
($user: Signal<{ id: string; color: string; name: string }>) =>
(store: TLStore): Signal<TLInstancePresence | null> => {
const $instance = store.query.record('instance', () => ({
id: { eq: store.props.instanceId },
}))
const $pageState = store.query.record('instance_page_state', () => ({
instanceId: { eq: store.props.instanceId },
pageId: { eq: $instance.value?.currentPageId ?? ('' as any) },
}))
const $camera = store.query.record('camera', () => ({
id: { eq: $pageState.value?.cameraId ?? ('' as any) },
}))
const $pointer = store.query.record('pointer')
return computed('instancePresence', () => {
const pageState = $pageState.value
const instance = $instance.value
const camera = $camera.value
const pointer = $pointer.value
const instance = store.get(TLINSTANCE_ID)
const pageState = store.get(InstancePageStateRecordType.createId(instance?.currentPageId))
const camera = store.get(CameraRecordType.createId(instance?.currentPageId))
const pointer = store.get(TLPOINTER_ID)
const user = $user.value
if (!pageState || !instance || !camera || !pointer || !user) {
return null
}
return InstancePresenceRecordType.create({
id: InstancePresenceRecordType.createCustomId(store.props.instanceId),
instanceId: store.props.instanceId,
id: InstancePresenceRecordType.createId(store.id),
selectedIds: pageState.selectedIds,
brush: instance.brush,
scribble: instance.scribble,

View file

@ -11,7 +11,6 @@ import { PointerRecordType } from './records/TLPointer'
import { InstancePresenceRecordType } from './records/TLPresence'
import { TLRecord } from './records/TLRecord'
import { TLShape, rootShapeMigrations } from './records/TLShape'
import { UserDocumentRecordType } from './records/TLUserDocument'
import { arrowShapeMigrations, arrowShapeValidator } from './shapes/TLArrowShape'
import { bookmarkShapeMigrations, bookmarkShapeValidator } from './shapes/TLBookmarkShape'
import { drawShapeMigrations, drawShapeValidator } from './shapes/TLDrawShape'
@ -144,7 +143,6 @@ export function createTLSchema(
instance_page_state: InstancePageStateRecordType,
page: PageRecordType,
shape: ShapeRecordType,
user_document: UserDocumentRecordType,
instance_presence: InstancePresenceRecordType,
pointer: PointerRecordType,
},

View file

@ -30,12 +30,19 @@ export { CameraRecordType, type TLCamera, type TLCameraId } from './records/TLCa
export { DocumentRecordType, TLDOCUMENT_ID, type TLDocument } from './records/TLDocument'
export {
InstanceRecordType,
TLINSTANCE_ID,
instanceTypeValidator,
type TLInstance,
type TLInstanceId,
type TLInstancePropsForNextShape,
} from './records/TLInstance'
export { PageRecordType, isPageId, type TLPage, type TLPageId } from './records/TLPage'
export {
PageRecordType,
isPageId,
pageIdValidator,
type TLPage,
type TLPageId,
} from './records/TLPage'
export { InstancePageStateRecordType, type TLInstancePageState } from './records/TLPageState'
export { PointerRecordType, TLPOINTER_ID } from './records/TLPointer'
export { InstancePresenceRecordType, type TLInstancePresence } from './records/TLPresence'
@ -55,7 +62,6 @@ export {
type TLShapeProps,
type TLUnknownShape,
} from './records/TLShape'
export { UserDocumentRecordType, type TLUserDocument } from './records/TLUserDocument'
export {
type TLArrowShape,
type TLArrowShapeProps,

View file

@ -1,14 +1,12 @@
import { createRecordType, Migrations, Store } from '@tldraw/store'
import { structuredClone } from '@tldraw/utils'
import { Migrations, Store, createRecordType } from '@tldraw/store'
import fs from 'fs'
import { imageAssetMigrations } from './assets/TLImageAsset'
import { videoAssetMigrations } from './assets/TLVideoAsset'
import { documentMigrations } from './records/TLDocument'
import { instanceMigrations, instanceTypeVersions } from './records/TLInstance'
import { instancePageStateMigrations } from './records/TLPageState'
import { instancePresenceMigrations } from './records/TLPresence'
import { rootShapeMigrations, TLShape } from './records/TLShape'
import { userDocumentMigrations, userDocumentVersions } from './records/TLUserDocument'
import { instancePageStateMigrations, instancePageStateVersions } from './records/TLPageState'
import { instancePresenceMigrations, instancePresenceVersions } from './records/TLPresence'
import { TLShape, rootShapeMigrations } from './records/TLShape'
import { arrowShapeMigrations } from './shapes/TLArrowShape'
import { bookmarkShapeMigrations } from './shapes/TLBookmarkShape'
import { drawShapeMigrations } from './shapes/TLDrawShape'
@ -266,21 +264,6 @@ describe('Removing dialogs from instance', () => {
})
})
describe('Adding snap mode', () => {
const { up, down } = userDocumentMigrations.migrators[1]
test('up works as expected', () => {
const before = {}
const after = { isSnapMode: false }
expect(up(before)).toStrictEqual(after)
})
test('down works as expected', () => {
const before = { isSnapMode: false }
const after = {}
expect(down(before)).toStrictEqual(after)
})
})
describe('Adding url props', () => {
for (const [name, { up, down }] of [
['video shape', videoShapeMigrations.migrators[1]],
@ -336,19 +319,6 @@ describe('Renaming asset props', () => {
}
})
describe('Adding the missing isMobileMode', () => {
const { up, down } = userDocumentMigrations.migrators[2]
test('up works as expected', () => {
expect(up({})).toMatchObject({ isMobileMode: false })
expect(up({ isMobileMode: true })).toMatchObject({ isMobileMode: true })
})
test('down works as expected', () => {
expect(down({ isMobileMode: true })).toStrictEqual({})
expect(down({ isMobileMode: false })).toStrictEqual({})
})
})
describe('Adding instance.isToolLocked', () => {
const { up, down } = instanceMigrations.migrators[3]
test('up works as expected', () => {
@ -760,43 +730,6 @@ describe('Migrate NoteShape legacy horizontal alignment', () => {
})
})
describe('Removing isReadOnly from user_document', () => {
const { up, down } = userDocumentMigrations.migrators[userDocumentVersions.RemoveIsReadOnly]
const prev = {
id: 'user_document:123',
typeName: 'user_document',
userId: 'user:123',
isReadOnly: false,
isPenMode: false,
isGridMode: false,
isDarkMode: false,
isMobileMode: false,
isSnapMode: false,
lastUpdatedPageId: null,
lastUsedTabId: null,
}
const next = {
id: 'user_document:123',
typeName: 'user_document',
userId: 'user:123',
isPenMode: false,
isGridMode: false,
isDarkMode: false,
isMobileMode: false,
isSnapMode: false,
lastUpdatedPageId: null,
lastUsedTabId: null,
}
test('up removes the isReadOnly property', () => {
expect(up(prev)).toEqual(next)
})
test('down adds the isReadOnly property', () => {
expect(down(next)).toEqual(prev)
})
})
describe('Adds delay to scribble', () => {
const { up, down } = instanceMigrations.migrators[10]
@ -987,35 +920,113 @@ describe('user config refactor', () => {
}
`)
})
})
test('removes userId and isDarkMode from TLUserDocument', () => {
describe('making instance state independent', () => {
it('adds isPenMode and isGridMode to instance state', () => {
const { up, down } =
userDocumentMigrations.migrators[userDocumentVersions.RemoveUserIdAndIsDarkMode]
instanceMigrations.migrators[instanceTypeVersions.AddIsPenModeAndIsGridMode]
const prev = {
id: 'user_document:123',
typeName: 'user_document',
userId: 'user:123',
isDarkMode: false,
isGridMode: false,
id: 'instance:123',
typeName: 'instance',
}
const next = {
id: 'user_document:123',
typeName: 'user_document',
id: 'instance:123',
typeName: 'instance',
isPenMode: false,
isGridMode: false,
}
expect(up(prev)).toEqual(next)
expect(down(next)).toEqual(prev)
})
it('removes instanceId and cameraId from instancePageState', () => {
const { up, down } =
instancePageStateMigrations.migrators[instancePageStateVersions.RemoveInstanceIdAndCameraId]
const prev = {
id: 'instance_page_state:123',
typeName: 'instance_page_state',
instanceId: 'instance:123',
cameraId: 'camera:123',
selectedIds: [],
}
const next = {
id: 'instance_page_state:123',
typeName: 'instance_page_state',
selectedIds: [],
}
expect(up(prev)).toEqual(next)
// down should never be called
expect(down(next)).toMatchInlineSnapshot(`
Object {
"id": "user_document:123",
"isDarkMode": false,
"isGridMode": false,
"typeName": "user_document",
"userId": "user:none",
"cameraId": "camera:void",
"id": "instance_page_state:123",
"instanceId": "instance:instance",
"selectedIds": Array [],
"typeName": "instance_page_state",
}
`)
})
it('removes instanceId from instancePresence', () => {
const { up, down } =
instancePresenceMigrations.migrators[instancePresenceVersions.RemoveInstanceId]
const prev = {
id: 'instance_presence:123',
typeName: 'instance_presence',
instanceId: 'instance:123',
selectedIds: [],
}
const next = {
id: 'instance_presence:123',
typeName: 'instance_presence',
selectedIds: [],
}
expect(up(prev)).toEqual(next)
// down should never be called
expect(down(next)).toMatchInlineSnapshot(`
Object {
"id": "instance_presence:123",
"instanceId": "instance:instance",
"selectedIds": Array [],
"typeName": "instance_presence",
}
`)
})
it('removes userDocument from the schema', () => {
const { up, down } = storeMigrations.migrators[storeVersions.RemoveUserDocument]
const prev = {
'user_document:123': {
id: 'user_document:123',
typeName: 'user_document',
},
'instance:123': {
id: 'instance:123',
typeName: 'instance',
},
}
const next = {
'instance:123': {
id: 'instance:123',
typeName: 'instance',
},
}
expect(up(prev)).toEqual(next)
expect(down(next)).toEqual(next)
})
})
/* --- PUT YOUR MIGRATIONS TESTS ABOVE HERE --- */

View file

@ -38,7 +38,7 @@ export const cameraMigrations = defineMigrations({})
export const CameraRecordType = createRecordType<TLCamera>('camera', {
validator: cameraValidator,
migrations: cameraMigrations,
scope: 'instance',
scope: 'session',
}).withDefaultProperties(
(): Omit<TLCamera, 'id' | 'typeName'> => ({
x: 0,

View file

@ -55,4 +55,4 @@ export const DocumentRecordType = createRecordType<TLDocument>('document', {
// all document records have the same ID: 'document:document'
/** @public */
export const TLDOCUMENT_ID: RecordId<TLDocument> = DocumentRecordType.createCustomId('document')
export const TLDOCUMENT_ID: RecordId<TLDocument> = DocumentRecordType.createId('document')

View file

@ -43,6 +43,8 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
exportBackground: boolean
screenBounds: Box2dModel
zoomBrush: Box2dModel | null
isPenMode: boolean
isGridMode: boolean
}
/** @public */
@ -84,6 +86,8 @@ export const instanceTypeValidator: T.Validator<TLInstance> = T.model(
exportBackground: T.boolean,
screenBounds: T.boxModel,
zoomBrush: T.boxModel.nullable(),
isPenMode: T.boolean,
isGridMode: T.boolean,
})
)
@ -99,13 +103,14 @@ const Versions = {
AddVerticalAlign: 9,
AddScribbleDelay: 10,
RemoveUserId: 11,
AddIsPenModeAndIsGridMode: 12,
} as const
export { Versions as instanceTypeVersions }
/** @internal */
/** @public */
export const instanceMigrations = defineMigrations({
currentVersion: Versions.RemoveUserId,
currentVersion: Versions.AddIsPenModeAndIsGridMode,
migrators: {
[Versions.AddTransparentExportBgs]: {
up: (instance: TLInstance) => {
@ -243,6 +248,14 @@ export const instanceMigrations = defineMigrations({
return { ...instance, userId: 'user:none' }
},
},
[Versions.AddIsPenModeAndIsGridMode]: {
up: (instance: TLInstance) => {
return { ...instance, isPenMode: false, isGridMode: false }
},
down: ({ isPenMode: _, isGridMode: __, ...instance }: TLInstance) => {
return instance
},
},
},
})
@ -250,7 +263,7 @@ export const instanceMigrations = defineMigrations({
export const InstanceRecordType = createRecordType<TLInstance>('instance', {
migrations: instanceMigrations,
validator: instanceTypeValidator,
scope: 'instance',
scope: 'session',
}).withDefaultProperties(
(): Omit<TLInstance, 'typeName' | 'id' | 'currentPageId'> => ({
followingUserId: null,
@ -283,5 +296,10 @@ export const InstanceRecordType = createRecordType<TLInstance>('instance', {
isToolLocked: false,
screenBounds: { x: 0, y: 0, w: 1080, h: 720 },
zoomBrush: null,
isGridMode: false,
isPenMode: false,
})
)
/** @public */
export const TLINSTANCE_ID = InstanceRecordType.createId('instance')

View file

@ -2,8 +2,8 @@ import { BaseRecord, createRecordType, defineMigrations, RecordId } from '@tldra
import { T } from '@tldraw/validate'
import { idValidator } from '../misc/id-validator'
import { shapeIdValidator } from '../shapes/TLBaseShape'
import { TLCamera, TLCameraId } from './TLCamera'
import { instanceIdValidator, TLInstance } from './TLInstance'
import { CameraRecordType } from './TLCamera'
import { TLINSTANCE_ID } from './TLInstance'
import { pageIdValidator, TLPage } from './TLPage'
import { TLShapeId } from './TLShape'
@ -16,9 +16,7 @@ import { TLShapeId } from './TLShape'
*/
export interface TLInstancePageState
extends BaseRecord<'instance_page_state', TLInstancePageStateId> {
instanceId: RecordId<TLInstance>
pageId: RecordId<TLPage>
cameraId: RecordId<TLCamera>
selectedIds: TLShapeId[]
hintingIds: TLShapeId[]
erasingIds: TLShapeId[]
@ -34,9 +32,7 @@ export const instancePageStateValidator: T.Validator<TLInstancePageState> = T.mo
T.object({
typeName: T.literal('instance_page_state'),
id: idValidator<TLInstancePageStateId>('instance_page_state'),
instanceId: instanceIdValidator,
pageId: pageIdValidator,
cameraId: idValidator<TLCameraId>('camera'),
selectedIds: T.arrayOf(shapeIdValidator),
hintingIds: T.arrayOf(shapeIdValidator),
erasingIds: T.arrayOf(shapeIdValidator),
@ -49,11 +45,15 @@ export const instancePageStateValidator: T.Validator<TLInstancePageState> = T.mo
const Versions = {
AddCroppingId: 1,
RemoveInstanceIdAndCameraId: 2,
} as const
/** @internal */
export { Versions as instancePageStateVersions }
/** @public */
export const instancePageStateMigrations = defineMigrations({
currentVersion: Versions.AddCroppingId,
currentVersion: Versions.RemoveInstanceIdAndCameraId,
migrators: {
[Versions.AddCroppingId]: {
up(instance) {
@ -63,6 +63,19 @@ export const instancePageStateMigrations = defineMigrations({
return instance
},
},
[Versions.RemoveInstanceIdAndCameraId]: {
up({ instanceId: _, cameraId: __, ...instance }) {
return instance
},
down(instance) {
// this should never be called since we bump the schema version
return {
...instance,
instanceId: TLINSTANCE_ID,
cameraId: CameraRecordType.createId('void'),
}
},
},
},
})
@ -72,13 +85,10 @@ export const InstancePageStateRecordType = createRecordType<TLInstancePageState>
{
migrations: instancePageStateMigrations,
validator: instancePageStateValidator,
scope: 'instance',
scope: 'session',
}
).withDefaultProperties(
(): Omit<
TLInstancePageState,
'id' | 'typeName' | 'userId' | 'instanceId' | 'cameraId' | 'pageId'
> => ({
(): Omit<TLInstancePageState, 'id' | 'typeName' | 'pageId'> => ({
editingId: null,
croppingId: null,
selectedIds: [],

View file

@ -35,7 +35,7 @@ export const pointerMigrations = defineMigrations({})
export const PointerRecordType = createRecordType<TLPointer>('pointer', {
validator: pointerValidator,
migrations: pointerMigrations,
scope: 'instance',
scope: 'session',
}).withDefaultProperties(
(): Omit<TLPointer, 'id' | 'typeName'> => ({
x: 0,
@ -45,4 +45,4 @@ export const PointerRecordType = createRecordType<TLPointer>('pointer', {
)
/** @public */
export const TLPOINTER_ID = PointerRecordType.createCustomId('pointer')
export const TLPOINTER_ID = PointerRecordType.createId('pointer')

View file

@ -4,13 +4,12 @@ import { Box2dModel } from '../misc/geometry-types'
import { idValidator } from '../misc/id-validator'
import { cursorTypeValidator, TLCursor } from '../misc/TLCursor'
import { scribbleValidator, TLScribble } from '../misc/TLScribble'
import { TLInstanceId } from './TLInstance'
import { TLINSTANCE_ID } from './TLInstance'
import { TLPageId } from './TLPage'
import { TLShapeId } from './TLShape'
/** @public */
export interface TLInstancePresence extends BaseRecord<'instance_presence', TLInstancePresenceID> {
instanceId: TLInstanceId
userId: string
userName: string
lastActivityTimestamp: number
@ -37,7 +36,6 @@ export type TLInstancePresenceID = RecordId<TLInstancePresence>
export const instancePresenceValidator: T.Validator<TLInstancePresence> = T.model(
'instance_presence',
T.object({
instanceId: idValidator<TLInstanceId>('instance'),
typeName: T.literal('instance_presence'),
id: idValidator<TLInstancePresenceID>('instance_presence'),
userId: T.string,
@ -66,11 +64,13 @@ export const instancePresenceValidator: T.Validator<TLInstancePresence> = T.mode
const Versions = {
AddScribbleDelay: 1,
RemoveInstanceId: 2,
} as const
/** @internal */
export { Versions as instancePresenceVersions }
export const instancePresenceMigrations = defineMigrations({
currentVersion: Versions.AddScribbleDelay,
currentVersion: Versions.RemoveInstanceId,
migrators: {
[Versions.AddScribbleDelay]: {
up: (instance) => {
@ -87,6 +87,14 @@ export const instancePresenceMigrations = defineMigrations({
return { ...instance }
},
},
[Versions.RemoveInstanceId]: {
up: ({ instanceId: _, ...instance }) => {
return instance
},
down: (instance) => {
return { ...instance, instanceId: TLINSTANCE_ID }
},
},
},
})

View file

@ -7,7 +7,6 @@ import { TLInstancePageState } from './TLPageState'
import { TLPointer } from './TLPointer'
import { TLInstancePresence } from './TLPresence'
import { TLShape } from './TLShape'
import { TLUserDocument } from './TLUserDocument'
/** @public */
export type TLRecord =
@ -18,6 +17,5 @@ export type TLRecord =
| TLInstancePageState
| TLPage
| TLShape
| TLUserDocument
| TLInstancePresence
| TLPointer

View file

@ -1,108 +0,0 @@
import { BaseRecord, createRecordType, defineMigrations, RecordId } from '@tldraw/store'
import { T } from '@tldraw/validate'
import { idValidator } from '../misc/id-validator'
import { instanceIdValidator, TLInstance } from './TLInstance'
import { pageIdValidator, TLPage } from './TLPage'
/**
* TLUserDocument
*
* Settings that apply to this document for only the specified user
*
* @public
*/
export interface TLUserDocument extends BaseRecord<'user_document', TLUserDocumentId> {
isPenMode: boolean
isGridMode: boolean
isMobileMode: boolean
isSnapMode: boolean
lastUpdatedPageId: RecordId<TLPage> | null
lastUsedTabId: RecordId<TLInstance> | null
}
/** @public */
export type TLUserDocumentId = RecordId<TLUserDocument>
/** @internal */
export const userDocumentValidator: T.Validator<TLUserDocument> = T.model(
'user_document',
T.object({
typeName: T.literal('user_document'),
id: idValidator<TLUserDocumentId>('user_document'),
isPenMode: T.boolean,
isGridMode: T.boolean,
isMobileMode: T.boolean,
isSnapMode: T.boolean,
lastUpdatedPageId: pageIdValidator.nullable(),
lastUsedTabId: instanceIdValidator.nullable(),
})
)
export const Versions = {
AddSnapMode: 1,
AddMissingIsMobileMode: 2,
RemoveIsReadOnly: 3,
RemoveUserIdAndIsDarkMode: 4,
} as const
/** @internal */
export { Versions as userDocumentVersions }
/** @internal */
export const userDocumentMigrations = defineMigrations({
currentVersion: Versions.RemoveUserIdAndIsDarkMode,
migrators: {
[Versions.AddSnapMode]: {
up: (userDocument: TLUserDocument) => {
return { ...userDocument, isSnapMode: false }
},
down: ({ isSnapMode: _, ...userDocument }: TLUserDocument) => {
return userDocument
},
},
[Versions.AddMissingIsMobileMode]: {
up: (userDocument: TLUserDocument) => {
return { ...userDocument, isMobileMode: userDocument.isMobileMode ?? false }
},
down: ({ isMobileMode: _, ...userDocument }: TLUserDocument) => {
return userDocument
},
},
[Versions.RemoveIsReadOnly]: {
up: ({ isReadOnly: _, ...userDocument }: TLUserDocument & { isReadOnly: boolean }) => {
return userDocument
},
down: (userDocument: TLUserDocument) => {
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 }
},
},
},
})
/** @internal */
export const UserDocumentRecordType = createRecordType<TLUserDocument>('user_document', {
migrations: userDocumentMigrations,
validator: userDocumentValidator,
scope: 'instance',
}).withDefaultProperties(
(): Omit<TLUserDocument, 'id' | 'typeName' | 'userId'> => ({
isPenMode: false,
isGridMode: false,
isMobileMode: false,
isSnapMode: false,
lastUpdatedPageId: null,
lastUsedTabId: null,
})
)

View file

@ -5,13 +5,14 @@ const Versions = {
RemoveCodeAndIconShapeTypes: 1,
AddInstancePresenceType: 2,
RemoveTLUserAndPresenceAndAddPointer: 3,
RemoveUserDocument: 4,
} as const
export { Versions as storeVersions }
/** @public */
export const storeMigrations = defineMigrations({
currentVersion: Versions.RemoveTLUserAndPresenceAndAddPointer,
currentVersion: Versions.RemoveUserDocument,
migrators: {
[Versions.RemoveCodeAndIconShapeTypes]: {
up: (store: StoreSnapshot<TLRecord>) => {
@ -48,5 +49,15 @@ export const storeMigrations = defineMigrations({
)
},
},
[Versions.RemoveUserDocument]: {
up: (store: StoreSnapshot<TLRecord>) => {
return Object.fromEntries(
Object.entries(store).filter(([_, v]) => !v.typeName.match('user_document'))
)
},
down: (store: StoreSnapshot<TLRecord>) => {
return store
},
},
},
})

View file

@ -31,7 +31,7 @@ export function useKeyboardShortcuts() {
const container = editor.getContainer()
const hot = (keys: string, callback: (event: KeyboardEvent) => void) => {
hotkeys(keys, { element: container, scope: editor.instanceId }, callback)
hotkeys(keys, { element: container, scope: editor.store.id }, callback)
}
// Add hotkeys for actions and tools.
@ -86,10 +86,10 @@ export function useKeyboardShortcuts() {
actions['zoom-out'].onSelect('kbd')
})
hotkeys.setScope(editor.instanceId)
hotkeys.setScope(editor.store.id)
return () => {
hotkeys.deleteScope(editor.instanceId)
hotkeys.deleteScope(editor.store.id)
}
}, [actions, tools, isReadonly, editor, appIsFocused])
}

View file

@ -51,8 +51,8 @@ export function TLUiMenuSchemaProvider({ overrides, children }: TLUiMenuSchemaPr
const isDarkMode = useValue('isDarkMode', () => editor.isDarkMode, [editor])
const animationSpeed = useValue('animationSpeed', () => editor.animationSpeed, [editor])
const isGridMode = useValue('isGridMode', () => editor.userDocumentSettings.isGridMode, [editor])
const isSnapMode = useValue('isSnapMode', () => editor.userDocumentSettings.isSnapMode, [editor])
const isGridMode = useValue('isGridMode', () => editor.isGridMode, [editor])
const isSnapMode = useValue('isSnapMode', () => editor.isSnapMode, [editor])
const isToolLock = useValue('isToolLock', () => editor.instanceState.isToolLocked, [editor])
const isFocusMode = useValue('isFocusMode', () => editor.instanceState.isFocusMode, [editor])
const isDebugMode = useValue('isDebugMode', () => editor.instanceState.isDebugMode, [editor])