Snapshots pit of success (#3811)

Lots of people are having a bad time with loading/restoring snapshots
and there's a few reasons for that:

- It's not clear how to preserve UI state independently of document
state.
- Loading a snapshot wipes the instance state, which means we almost
always need to
  - update the viewport page bounds
  - refocus the editor
  - preserver some other sneaky properties of the `instance` record

### Change Type

<!--  Please select a 'Scope' label ️ -->

- [x] `sdk` — Changes the tldraw SDK
- [ ] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [ ] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [ ] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know


### Test Plan

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

- [ ] Unit Tests
- [ ] End to end tests

### Release Notes

- Add a brief release note for your PR here.
This commit is contained in:
David Sheldrick 2024-06-03 16:58:00 +01:00 committed by GitHub
parent 9422a0ecc2
commit 19d051c188
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1831 additions and 1715 deletions

View file

@ -54,19 +54,22 @@ export default function () {
In the example above, both editors would synchronize their document locally. They would still have two independent instance states (e.g. selections) but the document would be kept in sync and persisted under the same key. In the example above, both editors would synchronize their document locally. They would still have two independent instance states (e.g. selections) but the document would be kept in sync and persisted under the same key.
## Document Snapshots ## State Snapshots
You can get a JSON snapshot of the editor's content using the [Editor#store](?)'s [Store#getSnapshot](?) method. You can get a JSON snapshot of the document content and the user 'session' state using the [getSnapshot](?) function.
```tsx ```tsx
function SaveButton() { function SaveButton({ documentId, userId }) {
const editor = useEditor() const editor = useEditor()
return ( return (
<button <button
onClick={() => { onClick={() => {
const snapshot = editor.store.getSnapshot() const { document, session } = getSnapshot(editor.store)
const stringified = JSON.stringify(snapshot) // If you are building a multi-user app, you probably want to store
localStorage.setItem('my-editor-snapshot', stringified) // the document and session states separately because the
// session state is user-specific and normally shouldn't be shared.
await saveDocumentState(documentId, document)
await saveSessionState(documentId, userId, session)
}} }}
> >
Save Save
@ -75,17 +78,18 @@ function SaveButton() {
} }
``` ```
You can load the snapshot into a new editor with [Store#loadSnapshot](?). To load the snapshot back into an existing editor, use the [loadSnapshot](?) function.
```tsx ```tsx
function LoadButton() { function LoadButton({ documentId, userId }) {
const editor = useEditor() const editor = useEditor()
return ( return (
<button <button
onClick={() => { onClick={() => {
const stringified = localStorage.getItem('my-editor-snapshot') const document = await loadDocumentState(documentId)
const snapshot = JSON.parse(stringified) const session = await loadSessionState(documentId, userId)
editor.store.loadSnapshot(snapshot) editor.setCurrentTool('select') // need to reset tool state separately
loadSnapshot(editor.store, { document, session })
}} }}
> >
Load Load
@ -94,11 +98,27 @@ function LoadButton() {
} }
``` ```
A [snapshot](/reference/store/StoreSnapshot) includes both the store's [serialized records](/reference/store/SerializedStore) and its [serialized schema](/reference/store/SerializedSchema), which is used for migrations. You can also pass a snapshot as a prop to set the initial editor state.
> By default, the `getSnapshot` method returns only the editor's document data. If you want to get records from a different scope, you can pass in `session`, `document`, `presence`, or else `all` for all scopes. ```tsx
function MyApp({ userId, documentId }) {
const [snapshot, setSnapshot] = useState(null)
Note that loading a snapshot does not reset the editor's in memory state or UI state. For example, loading a snapshot during a resizing operation may lead to a crash. This is because the resizing state maintains its own cache of information about which shapes it is resizing, and its possible that those shapes may no longer exist! useEffect(() => {
async function load() {
const document = await getDocumentState(documentId)
const session = await getSessionState(documentId, userId)
setSnapshot({ document, session })
}
load()
}, [documentId, userId])
return snapshot ? <Tldraw snapshot={snapshot} /> : null
}
```
When tldraw loads a snapshot, it will run any necessary migrations to bring the data up to the latest tldraw schema version.
## The `"store"` prop ## The `"store"` prop
@ -112,6 +132,7 @@ export default function () {
// Create the store // Create the store
const newStore = createTLStore({ const newStore = createTLStore({
shapeUtils: defaultShapeUtils, shapeUtils: defaultShapeUtils,
bindingUtils: defaultBindingUtils,
}) })
// Get the snapshot // Get the snapshot
@ -119,7 +140,7 @@ export default function () {
const snapshot = JSON.parse(stringified) const snapshot = JSON.parse(stringified)
// Load the snapshot // Load the snapshot
newStore.loadSnapshot(snapshot) loadSnapshot(newStore, snapshot)
return newStore return newStore
}) })
@ -146,10 +167,11 @@ export default function () {
// Create the store // Create the store
const newStore = createTLStore({ const newStore = createTLStore({
shapeUtils: defaultShapeUtils, shapeUtils: defaultShapeUtils,
bindingUtils: defaultBindingUtils,
}) })
// Load the snapshot // Load the snapshot
newStore.loadSnapshot(snapshot) loadSnapshot(newStore, snapshot)
// Update the store with status // Update the store with status
setStoreWithStatus({ setStoreWithStatus({
@ -211,7 +233,7 @@ myRemoteSource.on('change', (changes) => {
## Migrations ## Migrations
Tldraw uses migrations to bring data from old snapshots up to date. These run automatically when calling `editor.store.loadSnapshot`. Tldraw uses migrations to bring data from old snapshots up to date. These run automatically when calling [loadSnapshot](?).
### Running migrations manually ### Running migrations manually

View file

@ -1,7 +1,7 @@
import { ROOM_PREFIX } from '@tldraw/dotcom-shared' import { ROOM_PREFIX } from '@tldraw/dotcom-shared'
import { RoomSnapshot } from '@tldraw/tlsync' import { RoomSnapshot } from '@tldraw/tlsync'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { Tldraw, createTLStore, defaultShapeUtils } from 'tldraw' import { Tldraw } from 'tldraw'
import '../../../styles/core.css' import '../../../styles/core.css'
import { assetUrls } from '../../utils/assetUrls' import { assetUrls } from '../../utils/assetUrls'
import { useFileSystem } from '../../utils/useFileSystem' import { useFileSystem } from '../../utils/useFileSystem'
@ -17,14 +17,10 @@ export function BoardHistorySnapshot({
timestamp: string timestamp: string
token?: string token?: string
}) { }) {
const [store] = useState(() => { const [snapshot] = useState(() => ({
const store = createTLStore({ shapeUtils: defaultShapeUtils }) schema: data.schema!,
store.loadSnapshot({ store: Object.fromEntries(data.documents.map((doc) => [doc.state.id, doc.state])) as any,
schema: data.schema!, }))
store: Object.fromEntries(data.documents.map((doc) => [doc.state.id, doc.state])) as any,
})
return store
})
const fileSystemUiOverrides = useFileSystem({ isMultiplayer: true }) const fileSystemUiOverrides = useFileSystem({ isMultiplayer: true })
@ -57,7 +53,7 @@ export function BoardHistorySnapshot({
<> <>
<div className="tldraw__editor"> <div className="tldraw__editor">
<Tldraw <Tldraw
store={store} snapshot={snapshot}
assetUrls={assetUrls} assetUrls={assetUrls}
onMount={(editor) => { onMount={(editor) => {
editor.updateInstanceState({ isReadonly: true }) editor.updateInstanceState({ isReadonly: true })

View file

@ -8,6 +8,7 @@ import {
TLStoreSnapshot, TLStoreSnapshot,
Tldraw, Tldraw,
TldrawImage, TldrawImage,
getSnapshot,
} from 'tldraw' } from 'tldraw'
import 'tldraw/tldraw.css' import 'tldraw/tldraw.css'
import initialSnapshot from './snapshot.json' import initialSnapshot from './snapshot.json'
@ -38,7 +39,7 @@ export default function TldrawImageExample() {
setShowBackground(editor.getInstanceState().exportBackground) setShowBackground(editor.getInstanceState().exportBackground)
setViewportPageBounds(editor.getViewportPageBounds()) setViewportPageBounds(editor.getViewportPageBounds())
setCurrentPageId(editor.getCurrentPageId()) setCurrentPageId(editor.getCurrentPageId())
setSnapshot(editor.store.getSnapshot()) setSnapshot(getSnapshot(editor.store).document)
setIsEditing(false) setIsEditing(false)
} else { } else {
setIsEditing(true) setIsEditing(true)

View file

@ -1,5 +1,13 @@
import { useLayoutEffect, useState } from 'react' import { useLayoutEffect, useState } from 'react'
import { Tldraw, createTLStore, defaultShapeUtils, throttle } from 'tldraw' import {
Tldraw,
createTLStore,
defaultBindingUtils,
defaultShapeUtils,
getSnapshot,
loadSnapshot,
throttle,
} from 'tldraw'
import 'tldraw/tldraw.css' import 'tldraw/tldraw.css'
// There's a guide at the bottom of this file! // There's a guide at the bottom of this file!
@ -8,7 +16,9 @@ const PERSISTENCE_KEY = 'example-3'
export default function PersistenceExample() { export default function PersistenceExample() {
//[1] //[1]
const [store] = useState(() => createTLStore({ shapeUtils: defaultShapeUtils })) const [store] = useState(() =>
createTLStore({ shapeUtils: defaultShapeUtils, bindingUtils: defaultBindingUtils })
)
//[2] //[2]
const [loadingState, setLoadingState] = useState< const [loadingState, setLoadingState] = useState<
{ status: 'loading' } | { status: 'ready' } | { status: 'error'; error: string } { status: 'loading' } | { status: 'ready' } | { status: 'error'; error: string }
@ -25,7 +35,7 @@ export default function PersistenceExample() {
if (persistedSnapshot) { if (persistedSnapshot) {
try { try {
const snapshot = JSON.parse(persistedSnapshot) const snapshot = JSON.parse(persistedSnapshot)
store.loadSnapshot(snapshot) loadSnapshot(store, snapshot)
setLoadingState({ status: 'ready' }) setLoadingState({ status: 'ready' })
} catch (error: any) { } catch (error: any) {
setLoadingState({ status: 'error', error: error.message }) // Something went wrong setLoadingState({ status: 'error', error: error.message }) // Something went wrong
@ -37,7 +47,7 @@ export default function PersistenceExample() {
// Each time the store changes, run the (debounced) persist function // Each time the store changes, run the (debounced) persist function
const cleanupFn = store.listen( const cleanupFn = store.listen(
throttle(() => { throttle(() => {
const snapshot = store.getSnapshot() const snapshot = getSnapshot(store)
localStorage.setItem(PERSISTENCE_KEY, JSON.stringify(snapshot)) localStorage.setItem(PERSISTENCE_KEY, JSON.stringify(snapshot))
}, 500) }, 500)
) )

View file

@ -9,4 +9,4 @@ Load a snapshot of the editor's contents.
--- ---
Use `editor.store.getSnapshot()` and `editor.store.loadSnapshot()` to save and restore the editor's contents. Use `getSnapshot()` and `loadSnapshot()` to save and restore the editor's contents.

View file

@ -1,47 +1,99 @@
import { TLStoreSnapshot, Tldraw } from 'tldraw' import { useCallback, useEffect, useState } from 'react'
import { TLEditorSnapshot, Tldraw, getSnapshot, loadSnapshot, useEditor } from 'tldraw'
import 'tldraw/tldraw.css' import 'tldraw/tldraw.css'
import _jsonSnapshot from './snapshot.json' import _jsonSnapshot from './snapshot.json'
const jsonSnapshot = _jsonSnapshot as TLStoreSnapshot
// There's a guide at the bottom of this file! // There's a guide at the bottom of this file!
const LOAD_SNAPSHOT_WITH_INITIAL_DATA = true const jsonSnapshot = _jsonSnapshot as any as TLEditorSnapshot
// [1]
function SnapshotToolbar() {
const editor = useEditor()
const save = useCallback(() => {
// [2]
const { document, session } = getSnapshot(editor.store)
// [3]
localStorage.setItem('snapshot', JSON.stringify({ document, session }))
}, [editor])
const load = useCallback(() => {
const snapshot = localStorage.getItem('snapshot')
if (!snapshot) return
// [4]
loadSnapshot(editor.store, JSON.parse(snapshot))
}, [editor])
const [showCheckMark, setShowCheckMark] = useState(false)
useEffect(() => {
if (showCheckMark) {
const timeout = setTimeout(() => {
setShowCheckMark(false)
}, 1000)
return () => clearTimeout(timeout)
}
return
})
return (
<div style={{ padding: 20, pointerEvents: 'all', display: 'flex', gap: '10px' }}>
<span
style={{
display: 'inline-block',
transition: 'transform 0.2s ease, opacity 0.2s ease',
transform: showCheckMark ? `scale(1)` : `scale(0.5)`,
opacity: showCheckMark ? 1 : 0,
}}
>
Saved
</span>
<button
onClick={() => {
save()
setShowCheckMark(true)
}}
>
Save Snapshot
</button>
<button onClick={load}>Load Snapshot</button>
</div>
)
}
//[1]
export default function SnapshotExample() { export default function SnapshotExample() {
if (LOAD_SNAPSHOT_WITH_INITIAL_DATA) {
return (
<div className="tldraw__editor">
<Tldraw snapshot={jsonSnapshot} />
</div>
)
}
//[2]
return ( return (
<div className="tldraw__editor"> <div className="tldraw__editor">
<Tldraw <Tldraw
onMount={(editor) => { // [5]
editor.store.loadSnapshot(jsonSnapshot) snapshot={jsonSnapshot}
components={{
SharePanel: SnapshotToolbar,
}} }}
/> />
</div> </div>
) )
} }
/* /*
This example shows how to load a snapshot into the editor. Thanks to our
migration system, you can load snapshots from any version of Tldraw. The
snapshot we're using can be found in the snapshot.json file in this folder.
You can generate a snapshot by using editor.store.getSnapshot().
There are two ways to load a snapshot: [1] We'll add a toolbar to the top-right of the editor viewport that allows the user to save and load snapshots.
[1] Via the `snapshot` prop of the Tldraw component. [2] Call `getSnapshot(editor.store)` to get the current state of the editor
[2] Using editor.store.loadSnapshot() in the callback of the onMount prop of the [3] The 'document' state is the set of shapes and pages and images etc.
Tldraw component. The 'session' state is the state of the editor like the current page, camera positions, zoom level, etc.
You probably need to store these separately if you're building a multi-user app, so that you can store per-user session state.
For this example we'll just store them together in localStorage.
[4] Call `loadSnapshot()` to load a snapshot into the editor
You can omit the `session` state, or load it later on it's own.
e.g.
loadSnapshot(editor.store, { document })
then optionally later
loadSnapshot(editor.store, { session })
Tips: [5] You can load an initial snapshot into the editor by passing it to the `snapshot` prop.
Want to migrate a snapshot but not load it? Use `editor.store.migrateSnapshot()`
*/ */

File diff suppressed because one or more lines are too long

View file

@ -1,16 +1,22 @@
import { TldrawFile, createTLStore, defaultShapeUtils } from 'tldraw' import { TldrawFile, createTLStore, defaultBindingUtils, defaultShapeUtils } from 'tldraw'
import * as vscode from 'vscode' import * as vscode from 'vscode'
import { nicelog } from './utils' import { nicelog } from './utils'
export const defaultFileContents: TldrawFile = { export const defaultFileContents: TldrawFile = {
tldrawFileFormatVersion: 1, tldrawFileFormatVersion: 1,
schema: createTLStore({ shapeUtils: defaultShapeUtils }).schema.serialize(), schema: createTLStore({
shapeUtils: defaultShapeUtils,
bindingUtils: defaultBindingUtils,
}).schema.serialize(),
records: [], records: [],
} }
export const fileContentWithErrors: TldrawFile = { export const fileContentWithErrors: TldrawFile = {
tldrawFileFormatVersion: 1, tldrawFileFormatVersion: 1,
schema: createTLStore({ shapeUtils: defaultShapeUtils }).schema.serialize(), schema: createTLStore({
shapeUtils: defaultShapeUtils,
bindingUtils: defaultBindingUtils,
}).schema.serialize(),
records: [{ typeName: 'shape', id: null } as any], records: [{ typeName: 'shape', id: null } as any],
} }

View file

@ -38,7 +38,6 @@ import { Signal } from '@tldraw/state';
import { Store } from '@tldraw/store'; import { Store } from '@tldraw/store';
import { StoreSchema } from '@tldraw/store'; import { StoreSchema } from '@tldraw/store';
import { StoreSideEffects } from '@tldraw/store'; import { StoreSideEffects } from '@tldraw/store';
import { StoreSnapshot } from '@tldraw/store';
import { StyleProp } from '@tldraw/tlschema'; import { StyleProp } from '@tldraw/tlschema';
import { StylePropValue } from '@tldraw/tlschema'; import { StylePropValue } from '@tldraw/tlschema';
import { TLAsset } from '@tldraw/tlschema'; import { TLAsset } from '@tldraw/tlschema';
@ -67,11 +66,13 @@ import { TLParentId } from '@tldraw/tlschema';
import { TLPropsMigrations } from '@tldraw/tlschema'; import { TLPropsMigrations } from '@tldraw/tlschema';
import { TLRecord } from '@tldraw/tlschema'; import { TLRecord } from '@tldraw/tlschema';
import { TLScribble } from '@tldraw/tlschema'; import { TLScribble } from '@tldraw/tlschema';
import { TLSerializedStore } from '@tldraw/tlschema';
import { TLShape } from '@tldraw/tlschema'; import { TLShape } from '@tldraw/tlschema';
import { TLShapeId } from '@tldraw/tlschema'; import { TLShapeId } from '@tldraw/tlschema';
import { TLShapePartial } from '@tldraw/tlschema'; import { TLShapePartial } from '@tldraw/tlschema';
import { TLStore } from '@tldraw/tlschema'; import { TLStore } from '@tldraw/tlschema';
import { TLStoreProps } from '@tldraw/tlschema'; import { TLStoreProps } from '@tldraw/tlschema';
import { TLStoreSnapshot } from '@tldraw/tlschema';
import { TLUnknownBinding } from '@tldraw/tlschema'; import { TLUnknownBinding } from '@tldraw/tlschema';
import { TLUnknownShape } from '@tldraw/tlschema'; import { TLUnknownShape } from '@tldraw/tlschema';
import { TLVideoAsset } from '@tldraw/tlschema'; import { TLVideoAsset } from '@tldraw/tlschema';
@ -1318,6 +1319,9 @@ export function getRotationSnapshot({ editor }: {
editor: Editor; editor: Editor;
}): null | TLRotationSnapshot; }): null | TLRotationSnapshot;
// @public (undocumented)
export function getSnapshot(store: TLStore): TLEditorSnapshot;
// @public // @public
export function getSvgPathFromPoints(points: VecLike[], closed?: boolean): string; export function getSvgPathFromPoints(points: VecLike[], closed?: boolean): string;
@ -1492,6 +1496,9 @@ export function LoadingScreen({ children }: {
// @public // @public
export function loadSessionStateSnapshotIntoStore(store: TLStore, snapshot: TLSessionStateSnapshot): void; export function loadSessionStateSnapshotIntoStore(store: TLStore, snapshot: TLSessionStateSnapshot): void;
// @public
export function loadSnapshot(store: TLStore, _snapshot: Partial<TLEditorSnapshot> | TLStoreSnapshot): void;
// @public (undocumented) // @public (undocumented)
export function loopToHtmlElement(elm: Element): HTMLElement; export function loopToHtmlElement(elm: Element): HTMLElement;
@ -2311,11 +2318,11 @@ export interface TldrawEditorBaseProps {
// @public // @public
export type TldrawEditorProps = Expand<TldrawEditorBaseProps & ({ export type TldrawEditorProps = Expand<TldrawEditorBaseProps & ({
defaultName?: string; defaultName?: string;
initialData?: SerializedStore<TLRecord>; initialData?: TLSerializedStore;
migrations?: readonly MigrationSequence[]; migrations?: readonly MigrationSequence[];
persistenceKey?: string; persistenceKey?: string;
sessionId?: string; sessionId?: string;
snapshot?: StoreSnapshot<TLRecord>; snapshot?: TLEditorSnapshot | TLStoreSnapshot;
store?: undefined; store?: undefined;
} | { } | {
store: TLStore | TLStoreWithStatus; store: TLStore | TLStoreWithStatus;
@ -2398,6 +2405,14 @@ export interface TLEditorOptions {
user?: TLUser; user?: TLUser;
} }
// @public (undocumented)
export interface TLEditorSnapshot {
// (undocumented)
document: TLStoreSnapshot;
// (undocumented)
session: TLSessionStateSnapshot;
}
// @public (undocumented) // @public (undocumented)
export type TLEnterEventHandler = (info: any, from: string) => void; export type TLEnterEventHandler = (info: any, from: string) => void;
@ -3065,7 +3080,7 @@ export function useIsEditing(shapeId: TLShapeId): boolean;
export function useLocalStore({ persistenceKey, sessionId, ...rest }: { export function useLocalStore({ persistenceKey, sessionId, ...rest }: {
persistenceKey?: string; persistenceKey?: string;
sessionId?: string; sessionId?: string;
snapshot?: StoreSnapshot<TLRecord>; snapshot?: TLEditorSnapshot | TLStoreSnapshot;
} & TLStoreOptions): TLStoreWithStatus; } & TLStoreOptions): TLStoreWithStatus;
// @internal (undocumented) // @internal (undocumented)
@ -3104,7 +3119,7 @@ export function useSvgExportContext(): {
// @public (undocumented) // @public (undocumented)
export function useTLStore(opts: TLStoreOptions & { export function useTLStore(opts: TLStoreOptions & {
snapshot?: StoreSnapshot<TLRecord>; snapshot?: TLEditorSnapshot | TLStoreSnapshot;
}): TLStore; }): TLStore;
// @public (undocumented) // @public (undocumented)

View file

@ -83,6 +83,7 @@ export {
} from './lib/components/default-components/DefaultSnapIndictor' } from './lib/components/default-components/DefaultSnapIndictor'
export { DefaultSpinner } from './lib/components/default-components/DefaultSpinner' export { DefaultSpinner } from './lib/components/default-components/DefaultSpinner'
export { DefaultSvgDefs } from './lib/components/default-components/DefaultSvgDefs' export { DefaultSvgDefs } from './lib/components/default-components/DefaultSvgDefs'
export { getSnapshot, loadSnapshot, type TLEditorSnapshot } from './lib/config/TLEditorSnapshot'
export { export {
TAB_ID, TAB_ID,
createSessionStateSnapshotSignal, createSessionStateSnapshotSignal,

View file

@ -1,5 +1,5 @@
import { MigrationSequence, SerializedStore, Store, StoreSnapshot } from '@tldraw/store' import { MigrationSequence, Store } from '@tldraw/store'
import { TLRecord, TLStore } from '@tldraw/tlschema' import { TLSerializedStore, TLStore, TLStoreSnapshot } from '@tldraw/tlschema'
import { Expand, Required, annotateError } from '@tldraw/utils' import { Expand, Required, annotateError } from '@tldraw/utils'
import React, { import React, {
ReactNode, ReactNode,
@ -14,6 +14,7 @@ import React, {
import classNames from 'classnames' import classNames from 'classnames'
import { OptionalErrorBoundary } from './components/ErrorBoundary' import { OptionalErrorBoundary } from './components/ErrorBoundary'
import { DefaultErrorFallback } from './components/default-components/DefaultErrorFallback' import { DefaultErrorFallback } from './components/default-components/DefaultErrorFallback'
import { TLEditorSnapshot } from './config/TLEditorSnapshot'
import { TLUser, createTLUser } from './config/createTLUser' import { TLUser, createTLUser } from './config/createTLUser'
import { TLAnyBindingUtilConstructor } from './config/defaultBindings' import { TLAnyBindingUtilConstructor } from './config/defaultBindings'
import { TLAnyShapeUtilConstructor } from './config/defaultShapes' import { TLAnyShapeUtilConstructor } from './config/defaultShapes'
@ -51,8 +52,8 @@ export type TldrawEditorProps = Expand<
| { | {
store?: undefined store?: undefined
migrations?: readonly MigrationSequence[] migrations?: readonly MigrationSequence[]
snapshot?: StoreSnapshot<TLRecord> snapshot?: TLEditorSnapshot | TLStoreSnapshot
initialData?: SerializedStore<TLRecord> initialData?: TLSerializedStore
persistenceKey?: string persistenceKey?: string
sessionId?: string sessionId?: string
defaultName?: string defaultName?: string

View file

@ -0,0 +1,185 @@
import { PageRecordType, TLStore } from '@tldraw/tlschema'
import { IndexKey } from '@tldraw/utils'
import { Editor } from '../editor/Editor'
import { Box } from '../primitives/Box'
import { TLEditorSnapshot, getSnapshot, loadSnapshot } from './TLEditorSnapshot'
import { createTLStore } from './createTLStore'
const createEditor = (store: TLStore) => {
return new Editor({
store,
bindingUtils: [],
shapeUtils: [],
getContainer: () => document.createElement('div'),
tools: [],
})
}
describe('getSnapshot', () => {
it('should return a TLEditorSnapshot', () => {
const store = createTLStore({})
store.ensureStoreIsUsable()
const snapshot = getSnapshot(store)
expect(snapshot).toMatchObject({
document: {
schema: {},
store: {
'document:document': {},
'page:page': {},
},
},
session: {
currentPageId: 'page:page',
exportBackground: true,
isDebugMode: false,
isFocusMode: false,
isGridMode: false,
isToolLocked: false,
pageStates: [
{
camera: { x: 0, y: 0, z: 1 },
focusedGroupId: null,
pageId: 'page:page',
selectedShapeIds: [],
},
],
version: 0,
},
})
const editor = createEditor(store)
editor.updateInstanceState({ isDebugMode: true })
editor.createPage({ id: PageRecordType.createId('page2') })
editor.setCurrentPage(PageRecordType.createId('page2'))
const snapshot2 = getSnapshot(store)
expect(snapshot2).toMatchObject({
document: {
schema: {},
store: {
'document:document': {},
'page:page': {},
'page:page2': {},
},
},
session: {
currentPageId: 'page:page2',
isDebugMode: true,
pageStates: [{}, {}],
version: 0,
},
})
})
})
const page2Id = PageRecordType.createId('page2')
function addPage2(snapshot: TLEditorSnapshot) {
// sneakily add a page
snapshot.document.store[page2Id] = PageRecordType.create({
id: page2Id,
name: 'my lovely page',
index: 'a4' as IndexKey,
})
// and set the current page id
snapshot.session.currentPageId = page2Id
}
describe('loadSnapshot', () => {
it('loads a snapshot into the store', () => {
const store = createTLStore({})
const editor = createEditor(store)
const snapshot = getSnapshot(store)
addPage2(snapshot)
loadSnapshot(store, snapshot)
expect(
editor
.getPages()
.map((p) => p.id)
.sort()
).toEqual(['page:page', 'page:page2'])
expect(editor.getCurrentPageId()).toBe(page2Id)
})
it('does not overwrite changes to the viewport page bounds', () => {
const store = createTLStore({})
const editor = createEditor(store)
const snapshot = getSnapshot(store)
expect(editor.getViewportScreenBounds()).not.toEqual(new Box(0, 0, 100, 100))
editor.updateViewportScreenBounds(new Box(0, 0, 100, 100))
expect(editor.getViewportScreenBounds()).toEqual(new Box(0, 0, 100, 100))
addPage2(snapshot)
loadSnapshot(store, snapshot)
expect(editor.getViewportScreenBounds()).toEqual(new Box(0, 0, 100, 100))
})
it('works with just the document bits (1)', () => {
const store = createTLStore({})
const editor = createEditor(store)
const snapshot = getSnapshot(store)
addPage2(snapshot)
loadSnapshot(store, snapshot.document)
expect(
editor
.getPages()
.map((p) => p.id)
.sort()
).toEqual(['page:page', 'page:page2'])
expect(editor.getCurrentPageId()).toBe('page:page')
})
it('works with just the document bits (2)', () => {
const store = createTLStore({})
const editor = createEditor(store)
const snapshot = getSnapshot(store)
addPage2(snapshot)
loadSnapshot(store, { document: snapshot.document })
expect(
editor
.getPages()
.map((p) => p.id)
.sort()
).toEqual(['page:page', 'page:page2'])
expect(editor.getCurrentPageId()).toBe('page:page')
})
it('allows loading the session bits later', () => {
const store = createTLStore({})
const editor = createEditor(store)
const snapshot = getSnapshot(store)
addPage2(snapshot)
loadSnapshot(store, snapshot.document)
expect(
editor
.getPages()
.map((p) => p.id)
.sort()
).toEqual(['page:page', 'page:page2'])
expect(editor.getCurrentPageId()).toBe('page:page')
loadSnapshot(store, { session: snapshot.session })
expect(editor.getCurrentPageId()).toBe('page:page2')
})
})

View file

@ -0,0 +1,84 @@
import { Signal } from '@tldraw/state'
import { TLINSTANCE_ID, TLStore, TLStoreSnapshot, pluckPreservingValues } from '@tldraw/tlschema'
import { WeakCache, filterEntries } from '@tldraw/utils'
import {
TLSessionStateSnapshot,
createSessionStateSnapshotSignal,
loadSessionStateSnapshotIntoStore,
} from './TLSessionStateSnapshot'
/** @public */
export interface TLEditorSnapshot {
document: TLStoreSnapshot
session: TLSessionStateSnapshot
}
/**
* Loads a snapshot into a store.
* @public
*/
export function loadSnapshot(
store: TLStore,
_snapshot: Partial<TLEditorSnapshot> | TLStoreSnapshot
) {
let snapshot: Partial<TLEditorSnapshot> = {}
if ('store' in _snapshot) {
// regular old TLStoreSnapshot
// let's migrate it and then filter out the non-doc state to help folks out
const migrationResult = store.schema.migrateStoreSnapshot(_snapshot)
if (migrationResult.type !== 'success') {
throw new Error('Failed to migrate store snapshot: ' + migrationResult.reason)
}
snapshot.document = {
schema: store.schema.serialize(),
store: filterEntries(migrationResult.value, (_, { typeName }) =>
store.scopedTypes.document.has(typeName)
),
}
} else {
// TLEditorSnapshot
snapshot = _snapshot
}
// We need to preserve a bunch of instance state properties that the Editor sets
// to avoid breaking the editor or causing jarring changes when loading a snapshot.
const preservingInstanceState = pluckPreservingValues(store.get(TLINSTANCE_ID))
store.atomic(() => {
// first load the document state (this will wipe the store if it happens)
if (snapshot.document) {
store.loadStoreSnapshot(snapshot.document)
}
// then make sure we preserve those instance state properties that must be preserved
// this is a noop if the document state wasn't loaded above
if (preservingInstanceState) {
store.update(TLINSTANCE_ID, (r) => ({ ...r, ...preservingInstanceState }))
}
// finally reinstate the UI state
if (snapshot.session) {
loadSessionStateSnapshotIntoStore(store, snapshot.session)
}
})
}
const sessionStateCache = new WeakCache<
TLStore,
Signal<TLSessionStateSnapshot | undefined | null>
>()
/** @public */
export function getSnapshot(store: TLStore): TLEditorSnapshot {
const sessionState$ = sessionStateCache.get(store, createSessionStateSnapshotSignal)
const session = sessionState$.get()
if (!session) {
throw new Error('Session state is not ready yet')
}
return {
document: store.getStoreSnapshot(),
session,
}
}

View file

@ -1,20 +1,19 @@
import { Signal, computed, transact } from '@tldraw/state' import { Signal, computed } from '@tldraw/state'
import { RecordsDiff, UnknownRecord, squashRecordDiffs } from '@tldraw/store' import { UnknownRecord } from '@tldraw/store'
import { import {
CameraRecordType, CameraRecordType,
InstancePageStateRecordType, InstancePageStateRecordType,
TLINSTANCE_ID, TLINSTANCE_ID,
TLPageId, TLPageId,
TLRecord,
TLShapeId, TLShapeId,
TLStore, TLStore,
pageIdValidator, pageIdValidator,
pluckPreservingValues,
shapeIdValidator, shapeIdValidator,
} from '@tldraw/tlschema' } from '@tldraw/tlschema'
import { import {
deleteFromSessionStorage, deleteFromSessionStorage,
getFromSessionStorage, getFromSessionStorage,
objectMapFromEntries,
setInSessionStorage, setInSessionStorage,
structuredClone, structuredClone,
} from '@tldraw/utils' } from '@tldraw/utils'
@ -209,58 +208,43 @@ export function loadSessionStateSnapshotIntoStore(
const res = migrateAndValidateSessionStateSnapshot(snapshot) const res = migrateAndValidateSessionStateSnapshot(snapshot)
if (!res) return if (!res) return
const instanceState = store.schema.types.instance.create({
id: TLINSTANCE_ID,
...pluckPreservingValues(store.get(TLINSTANCE_ID)),
currentPageId: res.currentPageId,
isDebugMode: res.isDebugMode,
isFocusMode: res.isFocusMode,
isToolLocked: res.isToolLocked,
isGridMode: res.isGridMode,
exportBackground: res.exportBackground,
})
// remove all page states and cameras and the instance state // remove all page states and cameras and the instance state
const allPageStatesAndCameras = store const allPageStatesAndCameras = store
.allRecords() .allRecords()
.filter((r) => r.typeName === 'instance_page_state' || r.typeName === 'camera') .filter((r) => r.typeName === 'instance_page_state' || r.typeName === 'camera')
const removeDiff: RecordsDiff<TLRecord> = { store.atomic(() => {
added: {}, store.remove(allPageStatesAndCameras.map((r) => r.id))
updated: {}, // replace them with new ones
removed: { for (const ps of res.pageStates) {
...objectMapFromEntries(allPageStatesAndCameras.map((r) => [r.id, r])), store.put([
}, CameraRecordType.create({
} id: CameraRecordType.createId(ps.pageId),
if (store.has(TLINSTANCE_ID)) { x: ps.camera.x,
removeDiff.removed[TLINSTANCE_ID] = store.get(TLINSTANCE_ID)! y: ps.camera.y,
} z: ps.camera.z,
}),
InstancePageStateRecordType.create({
id: InstancePageStateRecordType.createId(ps.pageId),
pageId: ps.pageId,
selectedShapeIds: ps.selectedShapeIds,
focusedGroupId: ps.focusedGroupId,
}),
])
}
const addDiff: RecordsDiff<TLRecord> = { store.put([instanceState])
removed: {},
updated: {},
added: {
[TLINSTANCE_ID]: store.schema.types.instance.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,
selectedShapeIds: ps.selectedShapeIds,
focusedGroupId: ps.focusedGroupId,
})
}
transact(() => {
store.applyDiff(squashRecordDiffs([removeDiff, addDiff]))
store.ensureStoreIsUsable() store.ensureStoreIsUsable()
}) })
} }

View file

@ -21,7 +21,7 @@ export type TLStoreOptions = {
export type TLStoreEventInfo = HistoryEntry<TLRecord> export type TLStoreEventInfo = HistoryEntry<TLRecord>
/** /**
* A helper for creating a TLStore. Custom shapes cannot override default shapes. * A helper for creating a TLStore.
* *
* @param opts - Options for creating the store. * @param opts - Options for creating the store.
* *

View file

@ -2981,8 +2981,8 @@ export class Editor extends EventEmitter<TLEventMap> {
* *
* @example * @example
* ```ts * ```ts
* editor.updateViewportScreenBounds() * editor.updateViewportScreenBounds(new Box(0, 0, 1280, 1024))
* editor.updateViewportScreenBounds(true) * editor.updateViewportScreenBounds(new Box(0, 0, 1280, 1024), true)
* ``` * ```
* *
* @param center - Whether to preserve the viewport page center as the viewport changes. * @param center - Whether to preserve the viewport page center as the viewport changes.
@ -3004,29 +3004,30 @@ export class Editor extends EventEmitter<TLEventMap> {
screenBounds.minX !== 0, screenBounds.minX !== 0,
] ]
const boundsAreEqual = screenBounds.equals(this.getViewportScreenBounds()) const { screenBounds: prevScreenBounds, insets: prevInsets } = this.getInstanceState()
if (screenBounds.equals(prevScreenBounds) && insets.every((v, i) => v === prevInsets[i])) {
// nothing to do
return this
}
const { _willSetInitialBounds } = this const { _willSetInitialBounds } = this
if (boundsAreEqual) { this._willSetInitialBounds = false
this._willSetInitialBounds = false
if (_willSetInitialBounds) {
// If we have just received the initial bounds, don't center the camera.
this.updateInstanceState({ screenBounds: screenBounds.toJson(), insets })
this.setCamera(this.getCamera())
} else { } else {
if (_willSetInitialBounds) { if (center && !this.getInstanceState().followingUserId) {
// If we have just received the initial bounds, don't center the camera. // Get the page center before the change, make the change, and restore it
this._willSetInitialBounds = false const before = this.getViewportPageBounds().center
this.updateInstanceState({ screenBounds: screenBounds.toJson(), insets }) this.updateInstanceState({ screenBounds: screenBounds.toJson(), insets })
this.setCamera(this.getCamera()) this.centerOnPoint(before)
} else { } else {
if (center && !this.getInstanceState().followingUserId) { // Otherwise,
// Get the page center before the change, make the change, and restore it this.updateInstanceState({ screenBounds: screenBounds.toJson(), insets })
const before = this.getViewportPageBounds().center this._setCamera(Vec.From({ ...this.getCamera() }))
this.updateInstanceState({ screenBounds: screenBounds.toJson(), insets })
this.centerOnPoint(before)
} else {
// Otherwise,
this.updateInstanceState({ screenBounds: screenBounds.toJson(), insets })
this._setCamera(Vec.From({ ...this.getCamera() }))
}
} }
} }
@ -8269,6 +8270,7 @@ export class Editor extends EventEmitter<TLEventMap> {
*/ */
focus(): this { focus(): this {
this.focusManager.focus() this.focusManager.focus()
this.updateInstanceState({ isFocused: true }, { history: 'ignore' })
return this return this
} }

View file

@ -1,6 +1,6 @@
import { StoreSnapshot } from '@tldraw/store' import { TLStoreSnapshot } from '@tldraw/tlschema'
import { TLRecord } from '@tldraw/tlschema'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { TLEditorSnapshot } from '../config/TLEditorSnapshot'
import { TLStoreOptions } from '../config/createTLStore' import { TLStoreOptions } from '../config/createTLStore'
import { TLStoreWithStatus } from '../utils/sync/StoreWithStatus' import { TLStoreWithStatus } from '../utils/sync/StoreWithStatus'
import { TLLocalSyncClient } from '../utils/sync/TLLocalSyncClient' import { TLLocalSyncClient } from '../utils/sync/TLLocalSyncClient'
@ -15,7 +15,7 @@ export function useLocalStore({
}: { }: {
persistenceKey?: string persistenceKey?: string
sessionId?: string sessionId?: string
snapshot?: StoreSnapshot<TLRecord> snapshot?: TLEditorSnapshot | TLStoreSnapshot
} & TLStoreOptions): TLStoreWithStatus { } & TLStoreOptions): TLStoreWithStatus {
const [state, setState] = useState<{ id: string; storeWithStatus: TLStoreWithStatus } | null>( const [state, setState] = useState<{ id: string; storeWithStatus: TLStoreWithStatus } | null>(
null null

View file

@ -1,24 +1,27 @@
import { StoreSnapshot } from '@tldraw/store' import { TLStoreSnapshot } from '@tldraw/tlschema'
import { TLRecord } from '@tldraw/tlschema'
import { areObjectsShallowEqual } from '@tldraw/utils' import { areObjectsShallowEqual } from '@tldraw/utils'
import { useState } from 'react' import { useState } from 'react'
import { TLEditorSnapshot } from '../..'
import { loadSnapshot } from '../config/TLEditorSnapshot'
import { TLStoreOptions, createTLStore } from '../config/createTLStore' import { TLStoreOptions, createTLStore } from '../config/createTLStore'
/** @public */ /** @public */
type UseTLStoreOptions = TLStoreOptions & { type UseTLStoreOptions = TLStoreOptions & {
snapshot?: StoreSnapshot<TLRecord> snapshot?: TLEditorSnapshot | TLStoreSnapshot
} }
function createStore(opts: UseTLStoreOptions) { function createStore(opts: UseTLStoreOptions) {
const store = createTLStore(opts) const store = createTLStore(opts)
if (opts.snapshot) { if (opts.snapshot) {
store.loadSnapshot(opts.snapshot) loadSnapshot(store, opts.snapshot)
} }
return { store, opts } return { store, opts }
} }
/** @public */ /** @public */
export function useTLStore(opts: TLStoreOptions & { snapshot?: StoreSnapshot<TLRecord> }) { export function useTLStore(
opts: TLStoreOptions & { snapshot?: TLEditorSnapshot | TLStoreSnapshot }
) {
const [current, setCurrent] = useState(() => createStore(opts)) const [current, setCurrent] = useState(() => createStore(opts))
if (!areObjectsShallowEqual(current.opts, opts)) { if (!areObjectsShallowEqual(current.opts, opts)) {

View file

@ -318,14 +318,18 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
// (undocumented) // (undocumented)
_flushHistory(): void; _flushHistory(): void;
get: <K extends IdOf<R>>(id: K) => RecFromId<K> | undefined; get: <K extends IdOf<R>>(id: K) => RecFromId<K> | undefined;
// @deprecated (undocumented)
getSnapshot(scope?: 'all' | RecordScope): StoreSnapshot<R>; getSnapshot(scope?: 'all' | RecordScope): StoreSnapshot<R>;
getStoreSnapshot(scope?: 'all' | RecordScope): StoreSnapshot<R>;
has: <K extends IdOf<R>>(id: K) => boolean; has: <K extends IdOf<R>>(id: K) => boolean;
readonly history: Atom<number, RecordsDiff<R>>; readonly history: Atom<number, RecordsDiff<R>>;
readonly id: string; readonly id: string;
// @internal (undocumented) // @internal (undocumented)
isPossiblyCorrupted(): boolean; isPossiblyCorrupted(): boolean;
listen: (onHistory: StoreListener<R>, filters?: Partial<StoreListenerFilters>) => () => void; listen: (onHistory: StoreListener<R>, filters?: Partial<StoreListenerFilters>) => () => void;
// @deprecated (undocumented)
loadSnapshot(snapshot: StoreSnapshot<R>): void; loadSnapshot(snapshot: StoreSnapshot<R>): void;
loadStoreSnapshot(snapshot: StoreSnapshot<R>): void;
// @internal (undocumented) // @internal (undocumented)
markAsPossiblyCorrupted(): void; markAsPossiblyCorrupted(): void;
mergeRemoteChanges: (fn: () => void) => void; mergeRemoteChanges: (fn: () => void) => void;

View file

@ -485,21 +485,31 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
* Get a serialized snapshot of the store and its schema. * Get a serialized snapshot of the store and its schema.
* *
* ```ts * ```ts
* const snapshot = store.getSnapshot() * const snapshot = store.getStoreSnapshot()
* store.loadSnapshot(snapshot) * store.loadStoreSnapshot(snapshot)
* ``` * ```
* *
* @param scope - The scope of records to serialize. Defaults to 'document'. * @param scope - The scope of records to serialize. Defaults to 'document'.
* *
* @public * @public
*/ */
getSnapshot(scope: RecordScope | 'all' = 'document'): StoreSnapshot<R> { getStoreSnapshot(scope: RecordScope | 'all' = 'document'): StoreSnapshot<R> {
return { return {
store: this.serialize(scope), store: this.serialize(scope),
schema: this.schema.serialize(), schema: this.schema.serialize(),
} }
} }
/**
* @deprecated use `getSnapshot` from the 'tldraw' package instead.
*/
getSnapshot(scope: RecordScope | 'all' = 'document') {
console.warn(
'[tldraw] `Store.getSnapshot` is deprecated and will be removed in a future release. Use `getSnapshot` from the `tldraw` package instead.'
)
return this.getStoreSnapshot(scope)
}
/** /**
* Migrate a serialized snapshot of the store and its schema. * Migrate a serialized snapshot of the store and its schema.
* *
@ -528,14 +538,14 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
* Load a serialized snapshot. * Load a serialized snapshot.
* *
* ```ts * ```ts
* const snapshot = store.getSnapshot() * const snapshot = store.getStoreSnapshot()
* store.loadSnapshot(snapshot) * store.loadStoreSnapshot(snapshot)
* ``` * ```
* *
* @param snapshot - The snapshot to load. * @param snapshot - The snapshot to load.
* @public * @public
*/ */
loadSnapshot(snapshot: StoreSnapshot<R>): void { loadStoreSnapshot(snapshot: StoreSnapshot<R>): void {
const migrationResult = this.schema.migrateStoreSnapshot(snapshot) const migrationResult = this.schema.migrateStoreSnapshot(snapshot)
if (migrationResult.type === 'error') { if (migrationResult.type === 'error') {
@ -555,6 +565,17 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
} }
} }
/**
* @public
* @deprecated use `loadSnapshot` from the 'tldraw' package instead.
*/
loadSnapshot(snapshot: StoreSnapshot<R>) {
console.warn(
"[tldraw] `Store.loadSnapshot` is deprecated and will be removed in a future release. Use `loadSnapshot` from the 'tldraw' package instead."
)
this.loadStoreSnapshot(snapshot)
}
/** /**
* Get an array of all values in the store. * Get an array of all values in the store.
* *

View file

@ -789,7 +789,7 @@ describe('snapshots', () => {
const serializedStore1 = store.serialize('all') const serializedStore1 = store.serialize('all')
const serializedSchema1 = store.schema.serialize() const serializedSchema1 = store.schema.serialize()
const snapshot1 = store.getSnapshot() const snapshot1 = store.getStoreSnapshot()
const store2 = new Store({ const store2 = new Store({
props: {}, props: {},
@ -799,11 +799,11 @@ describe('snapshots', () => {
}), }),
}) })
store2.loadSnapshot(snapshot1) store2.loadStoreSnapshot(snapshot1)
const serializedStore2 = store2.serialize('all') const serializedStore2 = store2.serialize('all')
const serializedSchema2 = store2.schema.serialize() const serializedSchema2 = store2.schema.serialize()
const snapshot2 = store2.getSnapshot() const snapshot2 = store2.getStoreSnapshot()
expect(serializedStore1).toEqual(serializedStore2) expect(serializedStore1).toEqual(serializedStore2)
expect(serializedSchema1).toEqual(serializedSchema2) expect(serializedSchema1).toEqual(serializedSchema2)
@ -811,7 +811,7 @@ describe('snapshots', () => {
}) })
it('throws errors when loading a snapshot with a different schema', () => { it('throws errors when loading a snapshot with a different schema', () => {
const snapshot1 = store.getSnapshot() const snapshot1 = store.getStoreSnapshot()
const store2 = new Store({ const store2 = new Store({
props: {}, props: {},
@ -823,12 +823,12 @@ describe('snapshots', () => {
expect(() => { expect(() => {
// @ts-expect-error // @ts-expect-error
store2.loadSnapshot(snapshot1) store2.loadStoreSnapshot(snapshot1)
}).toThrowErrorMatchingInlineSnapshot(`"Missing definition for record type author"`) }).toThrowErrorMatchingInlineSnapshot(`"Missing definition for record type author"`)
}) })
it('throws errors when loading a snapshot with a different schema', () => { it('throws errors when loading a snapshot with a different schema', () => {
const snapshot1 = store.getSnapshot() const snapshot1 = store.getStoreSnapshot()
const store2 = new Store({ const store2 = new Store({
props: {}, props: {},
@ -838,12 +838,12 @@ describe('snapshots', () => {
}) })
expect(() => { expect(() => {
store2.loadSnapshot(snapshot1 as any) store2.loadStoreSnapshot(snapshot1 as any)
}).toThrowErrorMatchingInlineSnapshot(`"Missing definition for record type author"`) }).toThrowErrorMatchingInlineSnapshot(`"Missing definition for record type author"`)
}) })
it('migrates the snapshot', () => { it('migrates the snapshot', () => {
const snapshot1 = store.getSnapshot() const snapshot1 = store.getStoreSnapshot()
const up = jest.fn((s: any) => { const up = jest.fn((s: any) => {
s['book:lotr'].numPages = 42 s['book:lotr'].numPages = 42
}) })
@ -876,7 +876,7 @@ describe('snapshots', () => {
}) })
expect(() => { expect(() => {
store2.loadSnapshot(snapshot1) store2.loadStoreSnapshot(snapshot1)
}).not.toThrow() }).not.toThrow()
expect(up).toHaveBeenCalledTimes(1) expect(up).toHaveBeenCalledTimes(1)
@ -1059,20 +1059,20 @@ describe('diffs', () => {
Book.create({ title: 'The Hobbit', id: bookId, author: authorId, numPages: 300 }), Book.create({ title: 'The Hobbit', id: bookId, author: authorId, numPages: 300 }),
]) ])
const checkpoint1 = store.getSnapshot() const checkpoint1 = store.getStoreSnapshot()
const forwardsDiff = store.extractingChanges(() => { const forwardsDiff = store.extractingChanges(() => {
store.remove([authorId]) store.remove([authorId])
store.update(bookId, (book) => ({ ...book, title: 'The Hobbit: There and Back Again' })) store.update(bookId, (book) => ({ ...book, title: 'The Hobbit: There and Back Again' }))
}) })
const checkpoint2 = store.getSnapshot() const checkpoint2 = store.getStoreSnapshot()
store.applyDiff(reverseRecordsDiff(forwardsDiff)) store.applyDiff(reverseRecordsDiff(forwardsDiff))
expect(store.getSnapshot()).toEqual(checkpoint1) expect(store.getStoreSnapshot()).toEqual(checkpoint1)
store.applyDiff(forwardsDiff) store.applyDiff(forwardsDiff)
expect(store.getSnapshot()).toEqual(checkpoint2) expect(store.getStoreSnapshot()).toEqual(checkpoint2)
}) })
}) })

View file

@ -49,7 +49,6 @@ import { SerializedSchema } from '@tldraw/editor';
import { ShapeUtil } from '@tldraw/editor'; import { ShapeUtil } from '@tldraw/editor';
import { SharedStyle } from '@tldraw/editor'; import { SharedStyle } from '@tldraw/editor';
import { StateNode } from '@tldraw/editor'; import { StateNode } from '@tldraw/editor';
import { StoreSnapshot } from '@tldraw/editor';
import { StyleProp } from '@tldraw/editor'; import { StyleProp } from '@tldraw/editor';
import { SvgExportContext } from '@tldraw/editor'; import { SvgExportContext } from '@tldraw/editor';
import { T } from '@tldraw/editor'; import { T } from '@tldraw/editor';
@ -76,6 +75,7 @@ import { TldrawEditorBaseProps } from '@tldraw/editor';
import { TLDrawShape } from '@tldraw/editor'; import { TLDrawShape } from '@tldraw/editor';
import { TLDrawShapeSegment } from '@tldraw/editor'; import { TLDrawShapeSegment } from '@tldraw/editor';
import { TLEditorComponents } from '@tldraw/editor'; import { TLEditorComponents } from '@tldraw/editor';
import { TLEditorSnapshot } from '@tldraw/editor';
import { TLEmbedShape } from '@tldraw/editor'; import { TLEmbedShape } from '@tldraw/editor';
import { TLEnterEventHandler } from '@tldraw/editor'; import { TLEnterEventHandler } from '@tldraw/editor';
import { TLEventHandlers } from '@tldraw/editor'; import { TLEventHandlers } from '@tldraw/editor';
@ -105,7 +105,6 @@ import { TLPointerEvent } from '@tldraw/editor';
import { TLPointerEventInfo } from '@tldraw/editor'; import { TLPointerEventInfo } from '@tldraw/editor';
import { TLPointerEventName } from '@tldraw/editor'; import { TLPointerEventName } from '@tldraw/editor';
import { TLPropsMigrations } from '@tldraw/editor'; import { TLPropsMigrations } from '@tldraw/editor';
import { TLRecord } from '@tldraw/editor';
import { TLRotationSnapshot } from '@tldraw/editor'; import { TLRotationSnapshot } from '@tldraw/editor';
import { TLSchema } from '@tldraw/editor'; import { TLSchema } from '@tldraw/editor';
import { TLScribbleProps } from '@tldraw/editor'; import { TLScribbleProps } from '@tldraw/editor';
@ -119,6 +118,7 @@ import { TLShapeUtilCanBindOpts } from '@tldraw/editor';
import { TLShapeUtilCanvasSvgDef } from '@tldraw/editor'; import { TLShapeUtilCanvasSvgDef } from '@tldraw/editor';
import { TLShapeUtilFlag } from '@tldraw/editor'; import { TLShapeUtilFlag } from '@tldraw/editor';
import { TLStore } from '@tldraw/editor'; import { TLStore } from '@tldraw/editor';
import { TLStoreSnapshot } from '@tldraw/editor';
import { TLStoreWithStatus } from '@tldraw/editor'; import { TLStoreWithStatus } from '@tldraw/editor';
import { TLSvgOptions } from '@tldraw/editor'; import { TLSvgOptions } from '@tldraw/editor';
import { TLTextShape } from '@tldraw/editor'; import { TLTextShape } from '@tldraw/editor';
@ -1545,7 +1545,7 @@ pageId?: TLPageId | undefined;
preserveAspectRatio?: string | undefined; preserveAspectRatio?: string | undefined;
scale?: number | undefined; scale?: number | undefined;
shapeUtils?: readonly TLAnyShapeUtilConstructor[] | undefined; shapeUtils?: readonly TLAnyShapeUtilConstructor[] | undefined;
snapshot: StoreSnapshot<TLRecord>; snapshot: TLEditorSnapshot | TLStoreSnapshot;
}>; }>;
// @public // @public
@ -1554,14 +1554,14 @@ export type TldrawImageProps = Expand<{
shapeUtils?: readonly TLAnyShapeUtilConstructor[]; shapeUtils?: readonly TLAnyShapeUtilConstructor[];
format?: 'png' | 'svg'; format?: 'png' | 'svg';
pageId?: TLPageId; pageId?: TLPageId;
snapshot: StoreSnapshot<TLRecord>; snapshot: TLEditorSnapshot | TLStoreSnapshot;
} & Partial<TLSvgOptions>>; } & Partial<TLSvgOptions>>;
// @public (undocumented) // @public (undocumented)
export type TldrawProps = Expand<(Omit<TldrawUiProps, 'components'> & Omit<TldrawEditorBaseProps, 'components'> & { export type TldrawProps = Expand<(Omit<TldrawUiProps, 'components'> & Omit<TldrawEditorBaseProps, 'components'> & {
components?: TLComponents; components?: TLComponents;
}) & Partial<TLExternalContentProps> & ({ }) & Partial<TLExternalContentProps> & ({
snapshot?: StoreSnapshot<TLRecord>; snapshot?: TLEditorSnapshot | TLStoreSnapshot;
defaultName?: string; defaultName?: string;
migrations?: readonly MigrationSequence[]; migrations?: readonly MigrationSequence[];
persistenceKey?: string; persistenceKey?: string;

View file

@ -6,11 +6,11 @@ import {
Expand, Expand,
LoadingScreen, LoadingScreen,
MigrationSequence, MigrationSequence,
StoreSnapshot,
TLEditorComponents, TLEditorComponents,
TLEditorSnapshot,
TLOnMountHandler, TLOnMountHandler,
TLRecord,
TLStore, TLStore,
TLStoreSnapshot,
TLStoreWithStatus, TLStoreWithStatus,
TldrawEditor, TldrawEditor,
TldrawEditorBaseProps, TldrawEditorBaseProps,
@ -66,7 +66,7 @@ export type TldrawProps = Expand<
/** /**
* A snapshot to load for the store's initial data / schema. * A snapshot to load for the store's initial data / schema.
*/ */
snapshot?: StoreSnapshot<TLRecord> snapshot?: TLEditorSnapshot | TLStoreSnapshot
} }
) )
> >

View file

@ -3,11 +3,11 @@ import {
ErrorScreen, ErrorScreen,
Expand, Expand,
LoadingScreen, LoadingScreen,
StoreSnapshot,
TLAnyBindingUtilConstructor, TLAnyBindingUtilConstructor,
TLAnyShapeUtilConstructor, TLAnyShapeUtilConstructor,
TLEditorSnapshot,
TLPageId, TLPageId,
TLRecord, TLStoreSnapshot,
TLSvgOptions, TLSvgOptions,
useShallowArrayIdentity, useShallowArrayIdentity,
useTLStore, useTLStore,
@ -29,7 +29,7 @@ export type TldrawImageProps = Expand<
/** /**
* The snapshot to display. * The snapshot to display.
*/ */
snapshot: StoreSnapshot<TLRecord> snapshot: TLEditorSnapshot | TLStoreSnapshot
/** /**
* The image format to use. Defaults to 'svg'. * The image format to use. Defaults to 'svg'.

View file

@ -5,6 +5,8 @@ import {
TLShape, TLShape,
createShapeId, createShapeId,
debounce, debounce,
getSnapshot,
loadSnapshot,
} from '@tldraw/editor' } from '@tldraw/editor'
import { TestEditor } from './TestEditor' import { TestEditor } from './TestEditor'
import { TL } from './test-jsx' import { TL } from './test-jsx'
@ -583,11 +585,11 @@ describe('snapshots', () => {
// now serialize // now serialize
const snapshot = editor.store.getSnapshot() const snapshot = getSnapshot(editor.store)
const newEditor = new TestEditor() const newEditor = new TestEditor()
newEditor.store.loadSnapshot(snapshot) loadSnapshot(newEditor.store, snapshot)
expect(editor.store.serialize()).toEqual(newEditor.store.serialize()) expect(editor.store.serialize()).toEqual(newEditor.store.serialize())
}) })

View file

@ -769,6 +769,9 @@ export const PageRecordType: RecordType<TLPage, "index" | "name">;
// @public (undocumented) // @public (undocumented)
export const parentIdValidator: T.Validator<TLParentId>; export const parentIdValidator: T.Validator<TLParentId>;
// @internal (undocumented)
export const pluckPreservingValues: (val?: null | TLInstance) => null | Partial<TLInstance>;
// @public (undocumented) // @public (undocumented)
export const PointerRecordType: RecordType<TLPointer, never>; export const PointerRecordType: RecordType<TLPointer, never>;

View file

@ -1,4 +1,4 @@
import { Migration, MigrationId, Store, UnknownRecord } from '@tldraw/store' import { Migration, MigrationId } from '@tldraw/store'
import { structuredClone } from '@tldraw/utils' import { structuredClone } from '@tldraw/utils'
import { createTLSchema } from '../createTLSchema' import { createTLSchema } from '../createTLSchema'
@ -24,29 +24,6 @@ for (const migration of testSchema.sortedMigrations) {
} }
} }
function getEmptySnapshot() {
const store = new Store({
schema: testSchema,
props: null as any,
})
store.ensureStoreIsUsable()
return store.getSnapshot()
}
export function snapshotUp(migrationId: MigrationId, ...records: UnknownRecord[]) {
const migration = testSchema.sortedMigrations.find((m) => m.id === migrationId) as Migration
if (!migration) {
throw new Error(`Migration ${migrationId} not found`)
}
const snapshot = getEmptySnapshot()
for (const record of records) {
snapshot.store[record.id as any] = structuredClone(record as any)
}
const result = migration.up(snapshot.store as any)
return result ?? snapshot.store
}
export function getTestMigration(migrationId: MigrationId) { export function getTestMigration(migrationId: MigrationId) {
const migration = testSchema.sortedMigrations.find((m) => m.id === migrationId) as Migration const migration = testSchema.sortedMigrations.find((m) => m.id === migrationId) as Migration
if (!migration) { if (!migration) {

View file

@ -68,7 +68,12 @@ export {
} from './records/TLBinding' } from './records/TLBinding'
export { CameraRecordType, type TLCamera, type TLCameraId } from './records/TLCamera' export { CameraRecordType, type TLCamera, type TLCameraId } from './records/TLCamera'
export { DocumentRecordType, TLDOCUMENT_ID, type TLDocument } from './records/TLDocument' export { DocumentRecordType, TLDOCUMENT_ID, type TLDocument } from './records/TLDocument'
export { TLINSTANCE_ID, type TLInstance, type TLInstanceId } from './records/TLInstance' export {
TLINSTANCE_ID,
pluckPreservingValues,
type TLInstance,
type TLInstanceId,
} from './records/TLInstance'
export { export {
PageRecordType, PageRecordType,
isPageId, isPageId,

View file

@ -5,7 +5,7 @@ import {
createRecordType, createRecordType,
RecordId, RecordId,
} from '@tldraw/store' } from '@tldraw/store'
import { JsonObject } from '@tldraw/utils' import { filterEntries, JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import { BoxModel, boxModelValidator } from '../misc/geometry-types' import { BoxModel, boxModelValidator } from '../misc/geometry-types'
import { idValidator } from '../misc/id-validator' import { idValidator } from '../misc/id-validator'
@ -69,6 +69,54 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
} | null } | null
} }
/** @internal */
export const shouldKeyBePreservedBetweenSessions = {
// This object defines keys that should be preserved across calls to loadSnapshot()
id: false, // meta
typeName: false, // meta
currentPageId: false, // does not preserve because who knows if the page still exists
opacityForNextShape: false, // does not preserve because it's a temporary state
stylesForNextShape: false, // does not preserve because it's a temporary state
followingUserId: false, // does not preserve because it's a temporary state
highlightedUserIds: false, // does not preserve because it's a temporary state
brush: false, // does not preserve because it's a temporary state
cursor: false, // does not preserve because it's a temporary state
scribbles: false, // does not preserve because it's a temporary state
isFocusMode: true, // preserves because it's a user preference
isDebugMode: true, // preserves because it's a user preference
isToolLocked: true, // preserves because it's a user preference
exportBackground: true, // preserves because it's a user preference
screenBounds: true, // preserves because it's capturing the user's screen state
insets: true, // preserves because it's capturing the user's screen state
zoomBrush: false, // does not preserve because it's a temporary state
chatMessage: false, // does not preserve because it's a temporary state
isChatting: false, // does not preserve because it's a temporary state
isPenMode: false, // does not preserve because it's a temporary state
isGridMode: true, // preserves because it's a user preference
isFocused: true, // preserves because obviously
devicePixelRatio: true, // preserves because it captures the user's screen state
isCoarsePointer: true, // preserves because it captures the user's screen state
isHoveringCanvas: false, // does not preserve because it's a temporary state
openMenus: false, // does not preserve because it's a temporary state
isChangingStyle: false, // does not preserve because it's a temporary state
isReadonly: true, // preserves because it's a config option
meta: false, // does not preserve because who knows what's in there, leave it up to sdk users to save and reinstate
duplicateProps: false, //
} as const satisfies { [K in keyof TLInstance]: boolean }
/** @internal */
export const pluckPreservingValues = (val?: TLInstance | null): null | Partial<TLInstance> =>
val
? (filterEntries(val, (key) => {
return shouldKeyBePreservedBetweenSessions[key as keyof TLInstance]
}) as Partial<TLInstance>)
: null
/** @public */ /** @public */
export type TLInstanceId = RecordId<TLInstance> export type TLInstanceId = RecordId<TLInstance>