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:
parent
9422a0ecc2
commit
19d051c188
29 changed files with 1831 additions and 1715 deletions
|
@ -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.
|
||||
|
||||
## 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
|
||||
function SaveButton() {
|
||||
function SaveButton({ documentId, userId }) {
|
||||
const editor = useEditor()
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
const snapshot = editor.store.getSnapshot()
|
||||
const stringified = JSON.stringify(snapshot)
|
||||
localStorage.setItem('my-editor-snapshot', stringified)
|
||||
const { document, session } = getSnapshot(editor.store)
|
||||
// If you are building a multi-user app, you probably want to store
|
||||
// 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
|
||||
|
@ -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
|
||||
function LoadButton() {
|
||||
function LoadButton({ documentId, userId }) {
|
||||
const editor = useEditor()
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
const stringified = localStorage.getItem('my-editor-snapshot')
|
||||
const snapshot = JSON.parse(stringified)
|
||||
editor.store.loadSnapshot(snapshot)
|
||||
const document = await loadDocumentState(documentId)
|
||||
const session = await loadSessionState(documentId, userId)
|
||||
editor.setCurrentTool('select') // need to reset tool state separately
|
||||
loadSnapshot(editor.store, { document, session })
|
||||
}}
|
||||
>
|
||||
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
|
||||
|
||||
|
@ -112,6 +132,7 @@ export default function () {
|
|||
// Create the store
|
||||
const newStore = createTLStore({
|
||||
shapeUtils: defaultShapeUtils,
|
||||
bindingUtils: defaultBindingUtils,
|
||||
})
|
||||
|
||||
// Get the snapshot
|
||||
|
@ -119,7 +140,7 @@ export default function () {
|
|||
const snapshot = JSON.parse(stringified)
|
||||
|
||||
// Load the snapshot
|
||||
newStore.loadSnapshot(snapshot)
|
||||
loadSnapshot(newStore, snapshot)
|
||||
|
||||
return newStore
|
||||
})
|
||||
|
@ -146,10 +167,11 @@ export default function () {
|
|||
// Create the store
|
||||
const newStore = createTLStore({
|
||||
shapeUtils: defaultShapeUtils,
|
||||
bindingUtils: defaultBindingUtils,
|
||||
})
|
||||
|
||||
// Load the snapshot
|
||||
newStore.loadSnapshot(snapshot)
|
||||
loadSnapshot(newStore, snapshot)
|
||||
|
||||
// Update the store with status
|
||||
setStoreWithStatus({
|
||||
|
@ -211,7 +233,7 @@ myRemoteSource.on('change', (changes) => {
|
|||
|
||||
## 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
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { ROOM_PREFIX } from '@tldraw/dotcom-shared'
|
||||
import { RoomSnapshot } from '@tldraw/tlsync'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Tldraw, createTLStore, defaultShapeUtils } from 'tldraw'
|
||||
import { Tldraw } from 'tldraw'
|
||||
import '../../../styles/core.css'
|
||||
import { assetUrls } from '../../utils/assetUrls'
|
||||
import { useFileSystem } from '../../utils/useFileSystem'
|
||||
|
@ -17,14 +17,10 @@ export function BoardHistorySnapshot({
|
|||
timestamp: string
|
||||
token?: string
|
||||
}) {
|
||||
const [store] = useState(() => {
|
||||
const store = createTLStore({ shapeUtils: defaultShapeUtils })
|
||||
store.loadSnapshot({
|
||||
schema: data.schema!,
|
||||
store: Object.fromEntries(data.documents.map((doc) => [doc.state.id, doc.state])) as any,
|
||||
})
|
||||
return store
|
||||
})
|
||||
const [snapshot] = useState(() => ({
|
||||
schema: data.schema!,
|
||||
store: Object.fromEntries(data.documents.map((doc) => [doc.state.id, doc.state])) as any,
|
||||
}))
|
||||
|
||||
const fileSystemUiOverrides = useFileSystem({ isMultiplayer: true })
|
||||
|
||||
|
@ -57,7 +53,7 @@ export function BoardHistorySnapshot({
|
|||
<>
|
||||
<div className="tldraw__editor">
|
||||
<Tldraw
|
||||
store={store}
|
||||
snapshot={snapshot}
|
||||
assetUrls={assetUrls}
|
||||
onMount={(editor) => {
|
||||
editor.updateInstanceState({ isReadonly: true })
|
||||
|
|
|
@ -8,6 +8,7 @@ import {
|
|||
TLStoreSnapshot,
|
||||
Tldraw,
|
||||
TldrawImage,
|
||||
getSnapshot,
|
||||
} from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
import initialSnapshot from './snapshot.json'
|
||||
|
@ -38,7 +39,7 @@ export default function TldrawImageExample() {
|
|||
setShowBackground(editor.getInstanceState().exportBackground)
|
||||
setViewportPageBounds(editor.getViewportPageBounds())
|
||||
setCurrentPageId(editor.getCurrentPageId())
|
||||
setSnapshot(editor.store.getSnapshot())
|
||||
setSnapshot(getSnapshot(editor.store).document)
|
||||
setIsEditing(false)
|
||||
} else {
|
||||
setIsEditing(true)
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
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'
|
||||
|
||||
// There's a guide at the bottom of this file!
|
||||
|
@ -8,7 +16,9 @@ const PERSISTENCE_KEY = 'example-3'
|
|||
|
||||
export default function PersistenceExample() {
|
||||
//[1]
|
||||
const [store] = useState(() => createTLStore({ shapeUtils: defaultShapeUtils }))
|
||||
const [store] = useState(() =>
|
||||
createTLStore({ shapeUtils: defaultShapeUtils, bindingUtils: defaultBindingUtils })
|
||||
)
|
||||
//[2]
|
||||
const [loadingState, setLoadingState] = useState<
|
||||
{ status: 'loading' } | { status: 'ready' } | { status: 'error'; error: string }
|
||||
|
@ -25,7 +35,7 @@ export default function PersistenceExample() {
|
|||
if (persistedSnapshot) {
|
||||
try {
|
||||
const snapshot = JSON.parse(persistedSnapshot)
|
||||
store.loadSnapshot(snapshot)
|
||||
loadSnapshot(store, snapshot)
|
||||
setLoadingState({ status: 'ready' })
|
||||
} catch (error: any) {
|
||||
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
|
||||
const cleanupFn = store.listen(
|
||||
throttle(() => {
|
||||
const snapshot = store.getSnapshot()
|
||||
const snapshot = getSnapshot(store)
|
||||
localStorage.setItem(PERSISTENCE_KEY, JSON.stringify(snapshot))
|
||||
}, 500)
|
||||
)
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 _jsonSnapshot from './snapshot.json'
|
||||
|
||||
const jsonSnapshot = _jsonSnapshot as TLStoreSnapshot
|
||||
|
||||
// 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() {
|
||||
if (LOAD_SNAPSHOT_WITH_INITIAL_DATA) {
|
||||
return (
|
||||
<div className="tldraw__editor">
|
||||
<Tldraw snapshot={jsonSnapshot} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
//[2]
|
||||
return (
|
||||
<div className="tldraw__editor">
|
||||
<Tldraw
|
||||
onMount={(editor) => {
|
||||
editor.store.loadSnapshot(jsonSnapshot)
|
||||
// [5]
|
||||
snapshot={jsonSnapshot}
|
||||
components={{
|
||||
SharePanel: SnapshotToolbar,
|
||||
}}
|
||||
/>
|
||||
</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
|
||||
Tldraw component.
|
||||
[3] The 'document' state is the set of shapes and pages and images etc.
|
||||
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:
|
||||
Want to migrate a snapshot but not load it? Use `editor.store.migrateSnapshot()`
|
||||
*/
|
||||
[5] You can load an initial snapshot into the editor by passing it to the `snapshot` prop.
|
||||
|
||||
*/
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -1,16 +1,22 @@
|
|||
import { TldrawFile, createTLStore, defaultShapeUtils } from 'tldraw'
|
||||
import { TldrawFile, createTLStore, defaultBindingUtils, defaultShapeUtils } from 'tldraw'
|
||||
import * as vscode from 'vscode'
|
||||
import { nicelog } from './utils'
|
||||
|
||||
export const defaultFileContents: TldrawFile = {
|
||||
tldrawFileFormatVersion: 1,
|
||||
schema: createTLStore({ shapeUtils: defaultShapeUtils }).schema.serialize(),
|
||||
schema: createTLStore({
|
||||
shapeUtils: defaultShapeUtils,
|
||||
bindingUtils: defaultBindingUtils,
|
||||
}).schema.serialize(),
|
||||
records: [],
|
||||
}
|
||||
|
||||
export const fileContentWithErrors: TldrawFile = {
|
||||
tldrawFileFormatVersion: 1,
|
||||
schema: createTLStore({ shapeUtils: defaultShapeUtils }).schema.serialize(),
|
||||
schema: createTLStore({
|
||||
shapeUtils: defaultShapeUtils,
|
||||
bindingUtils: defaultBindingUtils,
|
||||
}).schema.serialize(),
|
||||
records: [{ typeName: 'shape', id: null } as any],
|
||||
}
|
||||
|
||||
|
|
|
@ -38,7 +38,6 @@ import { Signal } from '@tldraw/state';
|
|||
import { Store } from '@tldraw/store';
|
||||
import { StoreSchema } from '@tldraw/store';
|
||||
import { StoreSideEffects } from '@tldraw/store';
|
||||
import { StoreSnapshot } from '@tldraw/store';
|
||||
import { StyleProp } from '@tldraw/tlschema';
|
||||
import { StylePropValue } from '@tldraw/tlschema';
|
||||
import { TLAsset } from '@tldraw/tlschema';
|
||||
|
@ -67,11 +66,13 @@ import { TLParentId } from '@tldraw/tlschema';
|
|||
import { TLPropsMigrations } from '@tldraw/tlschema';
|
||||
import { TLRecord } from '@tldraw/tlschema';
|
||||
import { TLScribble } from '@tldraw/tlschema';
|
||||
import { TLSerializedStore } from '@tldraw/tlschema';
|
||||
import { TLShape } from '@tldraw/tlschema';
|
||||
import { TLShapeId } from '@tldraw/tlschema';
|
||||
import { TLShapePartial } from '@tldraw/tlschema';
|
||||
import { TLStore } from '@tldraw/tlschema';
|
||||
import { TLStoreProps } from '@tldraw/tlschema';
|
||||
import { TLStoreSnapshot } from '@tldraw/tlschema';
|
||||
import { TLUnknownBinding } from '@tldraw/tlschema';
|
||||
import { TLUnknownShape } from '@tldraw/tlschema';
|
||||
import { TLVideoAsset } from '@tldraw/tlschema';
|
||||
|
@ -1318,6 +1319,9 @@ export function getRotationSnapshot({ editor }: {
|
|||
editor: Editor;
|
||||
}): null | TLRotationSnapshot;
|
||||
|
||||
// @public (undocumented)
|
||||
export function getSnapshot(store: TLStore): TLEditorSnapshot;
|
||||
|
||||
// @public
|
||||
export function getSvgPathFromPoints(points: VecLike[], closed?: boolean): string;
|
||||
|
||||
|
@ -1492,6 +1496,9 @@ export function LoadingScreen({ children }: {
|
|||
// @public
|
||||
export function loadSessionStateSnapshotIntoStore(store: TLStore, snapshot: TLSessionStateSnapshot): void;
|
||||
|
||||
// @public
|
||||
export function loadSnapshot(store: TLStore, _snapshot: Partial<TLEditorSnapshot> | TLStoreSnapshot): void;
|
||||
|
||||
// @public (undocumented)
|
||||
export function loopToHtmlElement(elm: Element): HTMLElement;
|
||||
|
||||
|
@ -2311,11 +2318,11 @@ export interface TldrawEditorBaseProps {
|
|||
// @public
|
||||
export type TldrawEditorProps = Expand<TldrawEditorBaseProps & ({
|
||||
defaultName?: string;
|
||||
initialData?: SerializedStore<TLRecord>;
|
||||
initialData?: TLSerializedStore;
|
||||
migrations?: readonly MigrationSequence[];
|
||||
persistenceKey?: string;
|
||||
sessionId?: string;
|
||||
snapshot?: StoreSnapshot<TLRecord>;
|
||||
snapshot?: TLEditorSnapshot | TLStoreSnapshot;
|
||||
store?: undefined;
|
||||
} | {
|
||||
store: TLStore | TLStoreWithStatus;
|
||||
|
@ -2398,6 +2405,14 @@ export interface TLEditorOptions {
|
|||
user?: TLUser;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface TLEditorSnapshot {
|
||||
// (undocumented)
|
||||
document: TLStoreSnapshot;
|
||||
// (undocumented)
|
||||
session: TLSessionStateSnapshot;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export type TLEnterEventHandler = (info: any, from: string) => void;
|
||||
|
||||
|
@ -3065,7 +3080,7 @@ export function useIsEditing(shapeId: TLShapeId): boolean;
|
|||
export function useLocalStore({ persistenceKey, sessionId, ...rest }: {
|
||||
persistenceKey?: string;
|
||||
sessionId?: string;
|
||||
snapshot?: StoreSnapshot<TLRecord>;
|
||||
snapshot?: TLEditorSnapshot | TLStoreSnapshot;
|
||||
} & TLStoreOptions): TLStoreWithStatus;
|
||||
|
||||
// @internal (undocumented)
|
||||
|
@ -3104,7 +3119,7 @@ export function useSvgExportContext(): {
|
|||
|
||||
// @public (undocumented)
|
||||
export function useTLStore(opts: TLStoreOptions & {
|
||||
snapshot?: StoreSnapshot<TLRecord>;
|
||||
snapshot?: TLEditorSnapshot | TLStoreSnapshot;
|
||||
}): TLStore;
|
||||
|
||||
// @public (undocumented)
|
||||
|
|
|
@ -83,6 +83,7 @@ export {
|
|||
} from './lib/components/default-components/DefaultSnapIndictor'
|
||||
export { DefaultSpinner } from './lib/components/default-components/DefaultSpinner'
|
||||
export { DefaultSvgDefs } from './lib/components/default-components/DefaultSvgDefs'
|
||||
export { getSnapshot, loadSnapshot, type TLEditorSnapshot } from './lib/config/TLEditorSnapshot'
|
||||
export {
|
||||
TAB_ID,
|
||||
createSessionStateSnapshotSignal,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { MigrationSequence, SerializedStore, Store, StoreSnapshot } from '@tldraw/store'
|
||||
import { TLRecord, TLStore } from '@tldraw/tlschema'
|
||||
import { MigrationSequence, Store } from '@tldraw/store'
|
||||
import { TLSerializedStore, TLStore, TLStoreSnapshot } from '@tldraw/tlschema'
|
||||
import { Expand, Required, annotateError } from '@tldraw/utils'
|
||||
import React, {
|
||||
ReactNode,
|
||||
|
@ -14,6 +14,7 @@ import React, {
|
|||
import classNames from 'classnames'
|
||||
import { OptionalErrorBoundary } from './components/ErrorBoundary'
|
||||
import { DefaultErrorFallback } from './components/default-components/DefaultErrorFallback'
|
||||
import { TLEditorSnapshot } from './config/TLEditorSnapshot'
|
||||
import { TLUser, createTLUser } from './config/createTLUser'
|
||||
import { TLAnyBindingUtilConstructor } from './config/defaultBindings'
|
||||
import { TLAnyShapeUtilConstructor } from './config/defaultShapes'
|
||||
|
@ -51,8 +52,8 @@ export type TldrawEditorProps = Expand<
|
|||
| {
|
||||
store?: undefined
|
||||
migrations?: readonly MigrationSequence[]
|
||||
snapshot?: StoreSnapshot<TLRecord>
|
||||
initialData?: SerializedStore<TLRecord>
|
||||
snapshot?: TLEditorSnapshot | TLStoreSnapshot
|
||||
initialData?: TLSerializedStore
|
||||
persistenceKey?: string
|
||||
sessionId?: string
|
||||
defaultName?: string
|
||||
|
|
185
packages/editor/src/lib/config/TLEditorSnapshot.test.ts
Normal file
185
packages/editor/src/lib/config/TLEditorSnapshot.test.ts
Normal 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')
|
||||
})
|
||||
})
|
84
packages/editor/src/lib/config/TLEditorSnapshot.ts
Normal file
84
packages/editor/src/lib/config/TLEditorSnapshot.ts
Normal 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,
|
||||
}
|
||||
}
|
|
@ -1,20 +1,19 @@
|
|||
import { Signal, computed, transact } from '@tldraw/state'
|
||||
import { RecordsDiff, UnknownRecord, squashRecordDiffs } from '@tldraw/store'
|
||||
import { Signal, computed } from '@tldraw/state'
|
||||
import { UnknownRecord } from '@tldraw/store'
|
||||
import {
|
||||
CameraRecordType,
|
||||
InstancePageStateRecordType,
|
||||
TLINSTANCE_ID,
|
||||
TLPageId,
|
||||
TLRecord,
|
||||
TLShapeId,
|
||||
TLStore,
|
||||
pageIdValidator,
|
||||
pluckPreservingValues,
|
||||
shapeIdValidator,
|
||||
} from '@tldraw/tlschema'
|
||||
import {
|
||||
deleteFromSessionStorage,
|
||||
getFromSessionStorage,
|
||||
objectMapFromEntries,
|
||||
setInSessionStorage,
|
||||
structuredClone,
|
||||
} from '@tldraw/utils'
|
||||
|
@ -209,58 +208,43 @@ export function loadSessionStateSnapshotIntoStore(
|
|||
const res = migrateAndValidateSessionStateSnapshot(snapshot)
|
||||
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
|
||||
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)!
|
||||
}
|
||||
store.atomic(() => {
|
||||
store.remove(allPageStatesAndCameras.map((r) => r.id))
|
||||
// replace them with new ones
|
||||
for (const ps of res.pageStates) {
|
||||
store.put([
|
||||
CameraRecordType.create({
|
||||
id: CameraRecordType.createId(ps.pageId),
|
||||
x: ps.camera.x,
|
||||
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> = {
|
||||
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.put([instanceState])
|
||||
store.ensureStoreIsUsable()
|
||||
})
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ export type TLStoreOptions = {
|
|||
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.
|
||||
*
|
||||
|
|
|
@ -2981,8 +2981,8 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* editor.updateViewportScreenBounds()
|
||||
* editor.updateViewportScreenBounds(true)
|
||||
* editor.updateViewportScreenBounds(new Box(0, 0, 1280, 1024))
|
||||
* editor.updateViewportScreenBounds(new Box(0, 0, 1280, 1024), true)
|
||||
* ```
|
||||
*
|
||||
* @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,
|
||||
]
|
||||
|
||||
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
|
||||
|
||||
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 {
|
||||
if (_willSetInitialBounds) {
|
||||
// If we have just received the initial bounds, don't center the camera.
|
||||
this._willSetInitialBounds = false
|
||||
if (center && !this.getInstanceState().followingUserId) {
|
||||
// Get the page center before the change, make the change, and restore it
|
||||
const before = this.getViewportPageBounds().center
|
||||
this.updateInstanceState({ screenBounds: screenBounds.toJson(), insets })
|
||||
this.setCamera(this.getCamera())
|
||||
this.centerOnPoint(before)
|
||||
} else {
|
||||
if (center && !this.getInstanceState().followingUserId) {
|
||||
// Get the page center before the change, make the change, and restore it
|
||||
const before = this.getViewportPageBounds().center
|
||||
this.updateInstanceState({ screenBounds: screenBounds.toJson(), insets })
|
||||
this.centerOnPoint(before)
|
||||
} else {
|
||||
// Otherwise,
|
||||
this.updateInstanceState({ screenBounds: screenBounds.toJson(), insets })
|
||||
this._setCamera(Vec.From({ ...this.getCamera() }))
|
||||
}
|
||||
// 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 {
|
||||
this.focusManager.focus()
|
||||
this.updateInstanceState({ isFocused: true }, { history: 'ignore' })
|
||||
return this
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { StoreSnapshot } from '@tldraw/store'
|
||||
import { TLRecord } from '@tldraw/tlschema'
|
||||
import { TLStoreSnapshot } from '@tldraw/tlschema'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { TLEditorSnapshot } from '../config/TLEditorSnapshot'
|
||||
import { TLStoreOptions } from '../config/createTLStore'
|
||||
import { TLStoreWithStatus } from '../utils/sync/StoreWithStatus'
|
||||
import { TLLocalSyncClient } from '../utils/sync/TLLocalSyncClient'
|
||||
|
@ -15,7 +15,7 @@ export function useLocalStore({
|
|||
}: {
|
||||
persistenceKey?: string
|
||||
sessionId?: string
|
||||
snapshot?: StoreSnapshot<TLRecord>
|
||||
snapshot?: TLEditorSnapshot | TLStoreSnapshot
|
||||
} & TLStoreOptions): TLStoreWithStatus {
|
||||
const [state, setState] = useState<{ id: string; storeWithStatus: TLStoreWithStatus } | null>(
|
||||
null
|
||||
|
|
|
@ -1,24 +1,27 @@
|
|||
import { StoreSnapshot } from '@tldraw/store'
|
||||
import { TLRecord } from '@tldraw/tlschema'
|
||||
import { TLStoreSnapshot } from '@tldraw/tlschema'
|
||||
import { areObjectsShallowEqual } from '@tldraw/utils'
|
||||
import { useState } from 'react'
|
||||
import { TLEditorSnapshot } from '../..'
|
||||
import { loadSnapshot } from '../config/TLEditorSnapshot'
|
||||
import { TLStoreOptions, createTLStore } from '../config/createTLStore'
|
||||
|
||||
/** @public */
|
||||
type UseTLStoreOptions = TLStoreOptions & {
|
||||
snapshot?: StoreSnapshot<TLRecord>
|
||||
snapshot?: TLEditorSnapshot | TLStoreSnapshot
|
||||
}
|
||||
|
||||
function createStore(opts: UseTLStoreOptions) {
|
||||
const store = createTLStore(opts)
|
||||
if (opts.snapshot) {
|
||||
store.loadSnapshot(opts.snapshot)
|
||||
loadSnapshot(store, opts.snapshot)
|
||||
}
|
||||
return { store, opts }
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export function useTLStore(opts: TLStoreOptions & { snapshot?: StoreSnapshot<TLRecord> }) {
|
||||
export function useTLStore(
|
||||
opts: TLStoreOptions & { snapshot?: TLEditorSnapshot | TLStoreSnapshot }
|
||||
) {
|
||||
const [current, setCurrent] = useState(() => createStore(opts))
|
||||
|
||||
if (!areObjectsShallowEqual(current.opts, opts)) {
|
||||
|
|
|
@ -318,14 +318,18 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|||
// (undocumented)
|
||||
_flushHistory(): void;
|
||||
get: <K extends IdOf<R>>(id: K) => RecFromId<K> | undefined;
|
||||
// @deprecated (undocumented)
|
||||
getSnapshot(scope?: 'all' | RecordScope): StoreSnapshot<R>;
|
||||
getStoreSnapshot(scope?: 'all' | RecordScope): StoreSnapshot<R>;
|
||||
has: <K extends IdOf<R>>(id: K) => boolean;
|
||||
readonly history: Atom<number, RecordsDiff<R>>;
|
||||
readonly id: string;
|
||||
// @internal (undocumented)
|
||||
isPossiblyCorrupted(): boolean;
|
||||
listen: (onHistory: StoreListener<R>, filters?: Partial<StoreListenerFilters>) => () => void;
|
||||
// @deprecated (undocumented)
|
||||
loadSnapshot(snapshot: StoreSnapshot<R>): void;
|
||||
loadStoreSnapshot(snapshot: StoreSnapshot<R>): void;
|
||||
// @internal (undocumented)
|
||||
markAsPossiblyCorrupted(): void;
|
||||
mergeRemoteChanges: (fn: () => void) => void;
|
||||
|
|
|
@ -485,21 +485,31 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|||
* Get a serialized snapshot of the store and its schema.
|
||||
*
|
||||
* ```ts
|
||||
* const snapshot = store.getSnapshot()
|
||||
* store.loadSnapshot(snapshot)
|
||||
* const snapshot = store.getStoreSnapshot()
|
||||
* store.loadStoreSnapshot(snapshot)
|
||||
* ```
|
||||
*
|
||||
* @param scope - The scope of records to serialize. Defaults to 'document'.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
getSnapshot(scope: RecordScope | 'all' = 'document'): StoreSnapshot<R> {
|
||||
getStoreSnapshot(scope: RecordScope | 'all' = 'document'): StoreSnapshot<R> {
|
||||
return {
|
||||
store: this.serialize(scope),
|
||||
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.
|
||||
*
|
||||
|
@ -528,14 +538,14 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|||
* Load a serialized snapshot.
|
||||
*
|
||||
* ```ts
|
||||
* const snapshot = store.getSnapshot()
|
||||
* store.loadSnapshot(snapshot)
|
||||
* const snapshot = store.getStoreSnapshot()
|
||||
* store.loadStoreSnapshot(snapshot)
|
||||
* ```
|
||||
*
|
||||
* @param snapshot - The snapshot to load.
|
||||
* @public
|
||||
*/
|
||||
loadSnapshot(snapshot: StoreSnapshot<R>): void {
|
||||
loadStoreSnapshot(snapshot: StoreSnapshot<R>): void {
|
||||
const migrationResult = this.schema.migrateStoreSnapshot(snapshot)
|
||||
|
||||
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.
|
||||
*
|
||||
|
|
|
@ -789,7 +789,7 @@ describe('snapshots', () => {
|
|||
const serializedStore1 = store.serialize('all')
|
||||
const serializedSchema1 = store.schema.serialize()
|
||||
|
||||
const snapshot1 = store.getSnapshot()
|
||||
const snapshot1 = store.getStoreSnapshot()
|
||||
|
||||
const store2 = new Store({
|
||||
props: {},
|
||||
|
@ -799,11 +799,11 @@ describe('snapshots', () => {
|
|||
}),
|
||||
})
|
||||
|
||||
store2.loadSnapshot(snapshot1)
|
||||
store2.loadStoreSnapshot(snapshot1)
|
||||
|
||||
const serializedStore2 = store2.serialize('all')
|
||||
const serializedSchema2 = store2.schema.serialize()
|
||||
const snapshot2 = store2.getSnapshot()
|
||||
const snapshot2 = store2.getStoreSnapshot()
|
||||
|
||||
expect(serializedStore1).toEqual(serializedStore2)
|
||||
expect(serializedSchema1).toEqual(serializedSchema2)
|
||||
|
@ -811,7 +811,7 @@ describe('snapshots', () => {
|
|||
})
|
||||
|
||||
it('throws errors when loading a snapshot with a different schema', () => {
|
||||
const snapshot1 = store.getSnapshot()
|
||||
const snapshot1 = store.getStoreSnapshot()
|
||||
|
||||
const store2 = new Store({
|
||||
props: {},
|
||||
|
@ -823,12 +823,12 @@ describe('snapshots', () => {
|
|||
|
||||
expect(() => {
|
||||
// @ts-expect-error
|
||||
store2.loadSnapshot(snapshot1)
|
||||
store2.loadStoreSnapshot(snapshot1)
|
||||
}).toThrowErrorMatchingInlineSnapshot(`"Missing definition for record type author"`)
|
||||
})
|
||||
|
||||
it('throws errors when loading a snapshot with a different schema', () => {
|
||||
const snapshot1 = store.getSnapshot()
|
||||
const snapshot1 = store.getStoreSnapshot()
|
||||
|
||||
const store2 = new Store({
|
||||
props: {},
|
||||
|
@ -838,12 +838,12 @@ describe('snapshots', () => {
|
|||
})
|
||||
|
||||
expect(() => {
|
||||
store2.loadSnapshot(snapshot1 as any)
|
||||
store2.loadStoreSnapshot(snapshot1 as any)
|
||||
}).toThrowErrorMatchingInlineSnapshot(`"Missing definition for record type author"`)
|
||||
})
|
||||
|
||||
it('migrates the snapshot', () => {
|
||||
const snapshot1 = store.getSnapshot()
|
||||
const snapshot1 = store.getStoreSnapshot()
|
||||
const up = jest.fn((s: any) => {
|
||||
s['book:lotr'].numPages = 42
|
||||
})
|
||||
|
@ -876,7 +876,7 @@ describe('snapshots', () => {
|
|||
})
|
||||
|
||||
expect(() => {
|
||||
store2.loadSnapshot(snapshot1)
|
||||
store2.loadStoreSnapshot(snapshot1)
|
||||
}).not.toThrow()
|
||||
|
||||
expect(up).toHaveBeenCalledTimes(1)
|
||||
|
@ -1059,20 +1059,20 @@ describe('diffs', () => {
|
|||
Book.create({ title: 'The Hobbit', id: bookId, author: authorId, numPages: 300 }),
|
||||
])
|
||||
|
||||
const checkpoint1 = store.getSnapshot()
|
||||
const checkpoint1 = store.getStoreSnapshot()
|
||||
|
||||
const forwardsDiff = store.extractingChanges(() => {
|
||||
store.remove([authorId])
|
||||
store.update(bookId, (book) => ({ ...book, title: 'The Hobbit: There and Back Again' }))
|
||||
})
|
||||
|
||||
const checkpoint2 = store.getSnapshot()
|
||||
const checkpoint2 = store.getStoreSnapshot()
|
||||
|
||||
store.applyDiff(reverseRecordsDiff(forwardsDiff))
|
||||
expect(store.getSnapshot()).toEqual(checkpoint1)
|
||||
expect(store.getStoreSnapshot()).toEqual(checkpoint1)
|
||||
|
||||
store.applyDiff(forwardsDiff)
|
||||
expect(store.getSnapshot()).toEqual(checkpoint2)
|
||||
expect(store.getStoreSnapshot()).toEqual(checkpoint2)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -49,7 +49,6 @@ import { SerializedSchema } from '@tldraw/editor';
|
|||
import { ShapeUtil } from '@tldraw/editor';
|
||||
import { SharedStyle } from '@tldraw/editor';
|
||||
import { StateNode } from '@tldraw/editor';
|
||||
import { StoreSnapshot } from '@tldraw/editor';
|
||||
import { StyleProp } from '@tldraw/editor';
|
||||
import { SvgExportContext } from '@tldraw/editor';
|
||||
import { T } from '@tldraw/editor';
|
||||
|
@ -76,6 +75,7 @@ import { TldrawEditorBaseProps } from '@tldraw/editor';
|
|||
import { TLDrawShape } from '@tldraw/editor';
|
||||
import { TLDrawShapeSegment } from '@tldraw/editor';
|
||||
import { TLEditorComponents } from '@tldraw/editor';
|
||||
import { TLEditorSnapshot } from '@tldraw/editor';
|
||||
import { TLEmbedShape } from '@tldraw/editor';
|
||||
import { TLEnterEventHandler } from '@tldraw/editor';
|
||||
import { TLEventHandlers } from '@tldraw/editor';
|
||||
|
@ -105,7 +105,6 @@ import { TLPointerEvent } from '@tldraw/editor';
|
|||
import { TLPointerEventInfo } from '@tldraw/editor';
|
||||
import { TLPointerEventName } from '@tldraw/editor';
|
||||
import { TLPropsMigrations } from '@tldraw/editor';
|
||||
import { TLRecord } from '@tldraw/editor';
|
||||
import { TLRotationSnapshot } from '@tldraw/editor';
|
||||
import { TLSchema } from '@tldraw/editor';
|
||||
import { TLScribbleProps } from '@tldraw/editor';
|
||||
|
@ -119,6 +118,7 @@ import { TLShapeUtilCanBindOpts } from '@tldraw/editor';
|
|||
import { TLShapeUtilCanvasSvgDef } from '@tldraw/editor';
|
||||
import { TLShapeUtilFlag } from '@tldraw/editor';
|
||||
import { TLStore } from '@tldraw/editor';
|
||||
import { TLStoreSnapshot } from '@tldraw/editor';
|
||||
import { TLStoreWithStatus } from '@tldraw/editor';
|
||||
import { TLSvgOptions } from '@tldraw/editor';
|
||||
import { TLTextShape } from '@tldraw/editor';
|
||||
|
@ -1545,7 +1545,7 @@ pageId?: TLPageId | undefined;
|
|||
preserveAspectRatio?: string | undefined;
|
||||
scale?: number | undefined;
|
||||
shapeUtils?: readonly TLAnyShapeUtilConstructor[] | undefined;
|
||||
snapshot: StoreSnapshot<TLRecord>;
|
||||
snapshot: TLEditorSnapshot | TLStoreSnapshot;
|
||||
}>;
|
||||
|
||||
// @public
|
||||
|
@ -1554,14 +1554,14 @@ export type TldrawImageProps = Expand<{
|
|||
shapeUtils?: readonly TLAnyShapeUtilConstructor[];
|
||||
format?: 'png' | 'svg';
|
||||
pageId?: TLPageId;
|
||||
snapshot: StoreSnapshot<TLRecord>;
|
||||
snapshot: TLEditorSnapshot | TLStoreSnapshot;
|
||||
} & Partial<TLSvgOptions>>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type TldrawProps = Expand<(Omit<TldrawUiProps, 'components'> & Omit<TldrawEditorBaseProps, 'components'> & {
|
||||
components?: TLComponents;
|
||||
}) & Partial<TLExternalContentProps> & ({
|
||||
snapshot?: StoreSnapshot<TLRecord>;
|
||||
snapshot?: TLEditorSnapshot | TLStoreSnapshot;
|
||||
defaultName?: string;
|
||||
migrations?: readonly MigrationSequence[];
|
||||
persistenceKey?: string;
|
||||
|
|
|
@ -6,11 +6,11 @@ import {
|
|||
Expand,
|
||||
LoadingScreen,
|
||||
MigrationSequence,
|
||||
StoreSnapshot,
|
||||
TLEditorComponents,
|
||||
TLEditorSnapshot,
|
||||
TLOnMountHandler,
|
||||
TLRecord,
|
||||
TLStore,
|
||||
TLStoreSnapshot,
|
||||
TLStoreWithStatus,
|
||||
TldrawEditor,
|
||||
TldrawEditorBaseProps,
|
||||
|
@ -66,7 +66,7 @@ export type TldrawProps = Expand<
|
|||
/**
|
||||
* A snapshot to load for the store's initial data / schema.
|
||||
*/
|
||||
snapshot?: StoreSnapshot<TLRecord>
|
||||
snapshot?: TLEditorSnapshot | TLStoreSnapshot
|
||||
}
|
||||
)
|
||||
>
|
||||
|
|
|
@ -3,11 +3,11 @@ import {
|
|||
ErrorScreen,
|
||||
Expand,
|
||||
LoadingScreen,
|
||||
StoreSnapshot,
|
||||
TLAnyBindingUtilConstructor,
|
||||
TLAnyShapeUtilConstructor,
|
||||
TLEditorSnapshot,
|
||||
TLPageId,
|
||||
TLRecord,
|
||||
TLStoreSnapshot,
|
||||
TLSvgOptions,
|
||||
useShallowArrayIdentity,
|
||||
useTLStore,
|
||||
|
@ -29,7 +29,7 @@ export type TldrawImageProps = Expand<
|
|||
/**
|
||||
* The snapshot to display.
|
||||
*/
|
||||
snapshot: StoreSnapshot<TLRecord>
|
||||
snapshot: TLEditorSnapshot | TLStoreSnapshot
|
||||
|
||||
/**
|
||||
* The image format to use. Defaults to 'svg'.
|
||||
|
|
|
@ -5,6 +5,8 @@ import {
|
|||
TLShape,
|
||||
createShapeId,
|
||||
debounce,
|
||||
getSnapshot,
|
||||
loadSnapshot,
|
||||
} from '@tldraw/editor'
|
||||
import { TestEditor } from './TestEditor'
|
||||
import { TL } from './test-jsx'
|
||||
|
@ -583,11 +585,11 @@ describe('snapshots', () => {
|
|||
|
||||
// now serialize
|
||||
|
||||
const snapshot = editor.store.getSnapshot()
|
||||
const snapshot = getSnapshot(editor.store)
|
||||
|
||||
const newEditor = new TestEditor()
|
||||
|
||||
newEditor.store.loadSnapshot(snapshot)
|
||||
loadSnapshot(newEditor.store, snapshot)
|
||||
|
||||
expect(editor.store.serialize()).toEqual(newEditor.store.serialize())
|
||||
})
|
||||
|
|
|
@ -769,6 +769,9 @@ export const PageRecordType: RecordType<TLPage, "index" | "name">;
|
|||
// @public (undocumented)
|
||||
export const parentIdValidator: T.Validator<TLParentId>;
|
||||
|
||||
// @internal (undocumented)
|
||||
export const pluckPreservingValues: (val?: null | TLInstance) => null | Partial<TLInstance>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const PointerRecordType: RecordType<TLPointer, never>;
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Migration, MigrationId, Store, UnknownRecord } from '@tldraw/store'
|
||||
import { Migration, MigrationId } from '@tldraw/store'
|
||||
import { structuredClone } from '@tldraw/utils'
|
||||
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) {
|
||||
const migration = testSchema.sortedMigrations.find((m) => m.id === migrationId) as Migration
|
||||
if (!migration) {
|
||||
|
|
|
@ -68,7 +68,12 @@ export {
|
|||
} from './records/TLBinding'
|
||||
export { CameraRecordType, type TLCamera, type TLCameraId } from './records/TLCamera'
|
||||
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 {
|
||||
PageRecordType,
|
||||
isPageId,
|
||||
|
|
|
@ -5,7 +5,7 @@ import {
|
|||
createRecordType,
|
||||
RecordId,
|
||||
} from '@tldraw/store'
|
||||
import { JsonObject } from '@tldraw/utils'
|
||||
import { filterEntries, JsonObject } from '@tldraw/utils'
|
||||
import { T } from '@tldraw/validate'
|
||||
import { BoxModel, boxModelValidator } from '../misc/geometry-types'
|
||||
import { idValidator } from '../misc/id-validator'
|
||||
|
@ -69,6 +69,54 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
|
|||
} | 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 */
|
||||
export type TLInstanceId = RecordId<TLInstance>
|
||||
|
||||
|
|
Loading…
Reference in a new issue