2023-06-03 08:59:04 +00:00
|
|
|
import { RecordsDiff, SerializedSchema, compareSchemas, squashRecordDiffs } from '@tldraw/store'
|
[refactor] User-facing APIs (#1478)
This PR updates our user-facing APIs for the Tldraw and TldrawEditor
components, as well as the Editor (App). It mainly incorporates surface
changes from #1450 without any changes to validators or migrators,
incorporating feedback / discussion with @SomeHats and @ds300.
Here we:
- remove the TldrawEditorConfig
- bring back a loose version of shape definitions
- make a separation between "core" shapes and "default" shapes
- do not allow custom shapes, migrators or validators to overwrite core
shapes
- but _do_ allow new shapes
## `<Tldraw>` component
In this PR, the `Tldraw` component wraps both the `TldrawEditor`
component and our `TldrawUi` component. It accepts a union of props for
both components. Previously, this component also added local syncing via
a `useLocalSyncClient` hook call, however that has been pushed down to
the `TldrawEditor` component.
## `<TldrawEditor>` component
The `TldrawEditor` component now more neatly wraps up the different ways
that the editor can be configured.
## The store prop (`TldrawEditorProps.store`)
There are three main ways for the `TldrawEditor` component to be run:
1. with an externally defined store
2. with an externally defined syncing store (local or remote)
3. with an internally defined store
4. with an internally defined locally syncing store
The `store` prop allows for these configurations.
If the `store` prop is defined, it may be defined either as a `TLStore`
or as a `SyncedStore`. If the store is a `TLStore`, then the Editor will
assume that the store is ready to go; if it is defined as a SyncedStore,
then the component will display the loading / error screens as needed,
or the final editor once the store's status is "synced".
When the store is left undefined, then the `TldrawEditor` will create
its own internal store using the optional `instanceId`, `initialData`,
or `shapes` props to define the store / store schema.
If the `persistenceKey` prop is left undefined, then the store will not
be synced. If the `persistenceKey` is defined, then the store will be
synced locally. In the future, we may also here accept the API key /
roomId / etc for creating a remotely synced store.
The `SyncedStore` type has been expanded to also include types used for
remote syncing, e.g. with `ConnectionStatus`.
## Tools
By default, the App has two "baked-in" tools: the select tool and the
zoom tool. These cannot (for now) be replaced or removed. The default
tools are used by default, but may be replaced by other tools if
provided.
## Shapes
By default, the App has a set of "core" shapes:
- group
- embed
- bookmark
- image
- video
- text
That cannot by overwritten because they're created by the app at
different moments, such as when double clicking on the canvas or via a
copy and paste event. In follow up PRs, we'll split these out so that
users can replace parts of the code where these shapes are created.
### Change Type
- [x] `major` — Breaking Change
### Test Plan
- [x] Unit Tests
2023-06-01 15:47:34 +00:00
|
|
|
import { TLInstanceId, TLRecord, TLStore } from '@tldraw/tlschema'
|
2023-04-25 11:01:25 +00:00
|
|
|
import { assert, hasOwnProperty } from '@tldraw/utils'
|
|
|
|
import { transact } from 'signia'
|
|
|
|
import { showCantReadFromIndexDbAlert, showCantWriteToIndexDbAlert } from './alerts'
|
|
|
|
import { loadDataFromStore, storeChangesInIndexedDb, storeSnapshotInIndexedDb } from './indexedDb'
|
|
|
|
|
|
|
|
/** How should we debounce persists? */
|
|
|
|
const PERSIST_THROTTLE_MS = 350
|
|
|
|
/** If we're in an error state, how long should we wait before retrying a write? */
|
|
|
|
const PERSIST_RETRY_THROTTLE_MS = 10_000
|
|
|
|
|
|
|
|
/**
|
|
|
|
* IMPORTANT!!!
|
|
|
|
*
|
|
|
|
* This is just a quick-n-dirty temporary solution that will be replaced with the remote sync client
|
|
|
|
* once it has the db integrated
|
|
|
|
*/
|
|
|
|
|
|
|
|
type SyncMessage = {
|
|
|
|
type: 'diff'
|
|
|
|
instanceId: TLInstanceId
|
|
|
|
changes: RecordsDiff<any>
|
|
|
|
schema: SerializedSchema
|
|
|
|
}
|
|
|
|
|
|
|
|
// Sent by new clients when they connect
|
|
|
|
// If another client is on the channel with a newer schema version
|
|
|
|
// It will
|
|
|
|
type AnnounceMessage = {
|
|
|
|
type: 'announce'
|
|
|
|
schema: SerializedSchema
|
|
|
|
}
|
|
|
|
|
|
|
|
type Message = SyncMessage | AnnounceMessage
|
|
|
|
|
|
|
|
const msg = (msg: Message) => msg
|
|
|
|
|
2023-05-27 16:18:32 +00:00
|
|
|
/** @internal */
|
2023-04-25 11:01:25 +00:00
|
|
|
export class BroadcastChannelMock {
|
|
|
|
onmessage?: (e: MessageEvent) => void
|
|
|
|
constructor(_name: string) {
|
|
|
|
// noop
|
|
|
|
}
|
|
|
|
postMessage(_msg: Message) {
|
|
|
|
// noop
|
|
|
|
}
|
|
|
|
close() {
|
|
|
|
// noop
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const BC = typeof BroadcastChannel === 'undefined' ? BroadcastChannelMock : BroadcastChannel
|
|
|
|
|
2023-05-27 16:18:32 +00:00
|
|
|
/** @internal */
|
2023-04-25 11:01:25 +00:00
|
|
|
export class TLLocalSyncClient {
|
|
|
|
private disposables = new Set<() => void>()
|
|
|
|
private diffQueue: RecordsDiff<any>[] = []
|
|
|
|
private didDispose = false
|
|
|
|
private shouldDoFullDBWrite = true
|
|
|
|
private isReloading = false
|
|
|
|
readonly universalPersistenceKey: string
|
|
|
|
readonly serializedSchema: SerializedSchema
|
|
|
|
private isDebugging = false
|
|
|
|
|
|
|
|
initTime = Date.now()
|
|
|
|
private debug(...args: any[]) {
|
|
|
|
if (this.isDebugging) {
|
|
|
|
// eslint-disable-next-line no-console
|
|
|
|
console.debug(...args)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
constructor(
|
|
|
|
public readonly store: TLStore,
|
|
|
|
{
|
|
|
|
universalPersistenceKey,
|
|
|
|
onLoad,
|
|
|
|
onLoadError,
|
|
|
|
}: {
|
|
|
|
universalPersistenceKey: string
|
|
|
|
onLoad: (self: TLLocalSyncClient) => void
|
|
|
|
onLoadError: (error: Error) => void
|
|
|
|
},
|
|
|
|
public readonly channel = new BC(`tldraw-tab-sync-${universalPersistenceKey}`)
|
|
|
|
) {
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
;(window as any).tlsync = this
|
|
|
|
}
|
|
|
|
this.universalPersistenceKey = universalPersistenceKey
|
|
|
|
|
|
|
|
this.serializedSchema = this.store.schema.serialize()
|
|
|
|
|
|
|
|
this.disposables.add(
|
|
|
|
// Set up a subscription to changes from the store: When
|
|
|
|
// the store changes (and if the change was made by the user)
|
|
|
|
// then immediately send the diff to other tabs via postMessage
|
|
|
|
// and schedule a persist.
|
|
|
|
store.listen(({ changes, source }) => {
|
|
|
|
this.debug('changes', changes, source)
|
|
|
|
if (source === 'user') {
|
|
|
|
this.diffQueue.push(changes)
|
|
|
|
this.channel.postMessage(
|
|
|
|
msg({
|
|
|
|
type: 'diff',
|
|
|
|
instanceId: this.store.props.instanceId,
|
|
|
|
changes,
|
|
|
|
schema: this.serializedSchema,
|
|
|
|
})
|
|
|
|
)
|
|
|
|
this.schedulePersist()
|
|
|
|
}
|
|
|
|
})
|
|
|
|
)
|
|
|
|
|
|
|
|
this.connect(onLoad, onLoadError)
|
|
|
|
}
|
|
|
|
|
|
|
|
private async connect(onLoad: (client: this) => void, onLoadError: (error: Error) => void) {
|
|
|
|
this.debug('connecting')
|
|
|
|
let data:
|
|
|
|
| {
|
|
|
|
records: TLRecord[]
|
|
|
|
schema?: SerializedSchema
|
|
|
|
}
|
|
|
|
| undefined
|
|
|
|
|
|
|
|
try {
|
|
|
|
data = await loadDataFromStore(this.universalPersistenceKey)
|
|
|
|
} catch (error: any) {
|
|
|
|
onLoadError(error)
|
|
|
|
showCantReadFromIndexDbAlert()
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
window.location.reload()
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
this.debug('loaded data from store', data, 'didDispose', this.didDispose)
|
|
|
|
if (this.didDispose) return
|
|
|
|
|
|
|
|
try {
|
|
|
|
if (data) {
|
|
|
|
const snapshot = Object.fromEntries(data.records.map((r) => [r.id, r]))
|
|
|
|
const migrationResult = this.store.schema.migrateStoreSnapshot(
|
|
|
|
snapshot,
|
|
|
|
data.schema ?? this.store.schema.serializeEarliestVersion()
|
|
|
|
)
|
|
|
|
|
|
|
|
if (migrationResult.type === 'error') {
|
|
|
|
console.error('failed to migrate store', migrationResult)
|
|
|
|
onLoadError(new Error(`Failed to migrate store: ${migrationResult.reason}`))
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// 3. Merge the changes into the REAL STORE
|
|
|
|
this.store.mergeRemoteChanges(() => {
|
|
|
|
// Calling put will validate the records!
|
2023-05-25 09:54:29 +00:00
|
|
|
this.store.put(
|
|
|
|
Object.values(migrationResult.value).filter(
|
|
|
|
(r) => this.store.schema.types[r.typeName].scope !== 'presence'
|
|
|
|
),
|
|
|
|
'initialize'
|
|
|
|
)
|
2023-04-25 11:01:25 +00:00
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
this.channel.onmessage = ({ data }) => {
|
|
|
|
this.debug('got message', data)
|
|
|
|
const msg = data as Message
|
|
|
|
// if their schema is eralier than ours, we need to tell them so they can refresh
|
|
|
|
// if their schema is later than ours, we need to refresh
|
|
|
|
const comparison = compareSchemas(
|
|
|
|
this.serializedSchema,
|
|
|
|
msg.schema ?? this.store.schema.serializeEarliestVersion()
|
|
|
|
)
|
|
|
|
if (comparison === -1) {
|
|
|
|
// we are older, refresh
|
|
|
|
// but add a safety check to make sure we don't get in an infnite loop
|
|
|
|
const timeSinceInit = Date.now() - this.initTime
|
|
|
|
if (timeSinceInit < 5000) {
|
|
|
|
// This tab was just reloaded, but is out of date compared to other tabs.
|
|
|
|
// Not expecting this to ever happen. It should only happen if we roll back a release that incremented
|
|
|
|
// the schema version (which we should never do)
|
|
|
|
// Or maybe during development if you have multiple local tabs open running the app on prod mode and you
|
|
|
|
// check out an older commit. Dev server should be fine.
|
|
|
|
onLoadError(new Error('Schema mismatch, please close other tabs and reload the page'))
|
|
|
|
return
|
|
|
|
}
|
|
|
|
this.debug('reloading')
|
|
|
|
this.isReloading = true
|
|
|
|
window?.location?.reload?.()
|
|
|
|
return
|
|
|
|
} else if (comparison === 1) {
|
|
|
|
// they are older, tell them to refresh and not write any more data
|
|
|
|
this.debug('telling them to reload')
|
|
|
|
this.channel.postMessage({ type: 'announce', schema: this.serializedSchema })
|
|
|
|
// schedule a full db write in case they wrote data anyway
|
|
|
|
this.shouldDoFullDBWrite = true
|
|
|
|
this.persistIfNeeded()
|
|
|
|
return
|
|
|
|
}
|
|
|
|
// otherwise, all good, same version :)
|
|
|
|
if (msg.type === 'diff') {
|
|
|
|
this.debug('applying diff')
|
|
|
|
const doesDeleteInstance = hasOwnProperty(
|
|
|
|
msg.changes.removed,
|
|
|
|
this.store.props.instanceId
|
|
|
|
)
|
|
|
|
transact(() => {
|
|
|
|
this.store.mergeRemoteChanges(() => {
|
|
|
|
this.store.applyDiff(msg.changes)
|
|
|
|
})
|
|
|
|
if (doesDeleteInstance) {
|
|
|
|
this.store.ensureStoreIsUsable()
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
this.channel.postMessage({ type: 'announce', schema: this.serializedSchema })
|
|
|
|
this.disposables.add(() => {
|
|
|
|
this.channel.close()
|
|
|
|
})
|
|
|
|
onLoad(this)
|
|
|
|
} catch (e: any) {
|
|
|
|
this.debug('error loading data from store', e)
|
|
|
|
if (this.didDispose) return
|
|
|
|
onLoadError(e)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
close() {
|
|
|
|
this.debug('closing')
|
|
|
|
this.didDispose = true
|
|
|
|
this.disposables.forEach((d) => d())
|
|
|
|
}
|
|
|
|
|
|
|
|
private isPersisting = false
|
|
|
|
private didLastWriteError = false
|
|
|
|
private scheduledPersistTimeout: ReturnType<typeof setTimeout> | null = null
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Schedule a persist. Persists don't happen immediately: they are throttled to avoid writing too
|
|
|
|
* often, and will retry if failed.
|
|
|
|
*
|
|
|
|
* @internal
|
|
|
|
*/
|
|
|
|
private schedulePersist() {
|
|
|
|
this.debug('schedulePersist', this.scheduledPersistTimeout)
|
|
|
|
if (this.scheduledPersistTimeout) return
|
|
|
|
this.scheduledPersistTimeout = setTimeout(
|
|
|
|
() => {
|
|
|
|
this.scheduledPersistTimeout = null
|
|
|
|
this.persistIfNeeded()
|
|
|
|
},
|
|
|
|
this.didLastWriteError ? PERSIST_RETRY_THROTTLE_MS : PERSIST_THROTTLE_MS
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Persist to indexeddb only under certain circumstances:
|
|
|
|
*
|
|
|
|
* - If we're not already persisting
|
|
|
|
* - If we're not reloading the page
|
|
|
|
* - And we have something to persist (a full db write scheduled or changes in the diff queue)
|
|
|
|
*
|
|
|
|
* @internal
|
|
|
|
*/
|
|
|
|
private persistIfNeeded() {
|
|
|
|
this.debug('persistIfNeeded', {
|
|
|
|
isPersisting: this.isPersisting,
|
|
|
|
isReloading: this.isReloading,
|
|
|
|
shouldDoFullDBWrite: this.shouldDoFullDBWrite,
|
|
|
|
diffQueueLength: this.diffQueue.length,
|
|
|
|
storeIsPossiblyCorrupt: this.store.isPossiblyCorrupted(),
|
|
|
|
})
|
|
|
|
|
|
|
|
// if we've scheduled a persist for the future, that's no longer needed
|
|
|
|
if (this.scheduledPersistTimeout) {
|
|
|
|
clearTimeout(this.scheduledPersistTimeout)
|
|
|
|
this.scheduledPersistTimeout = null
|
|
|
|
}
|
|
|
|
|
|
|
|
// if a persist is already in progress, we don't need to do anything -
|
|
|
|
// if there are still outstanding changes once it's finished, it'll
|
|
|
|
// schedule another persist
|
|
|
|
if (this.isPersisting) return
|
|
|
|
|
|
|
|
// if we're reloading the page, it's because there's a newer client
|
|
|
|
// present so lets not overwrite their changes
|
|
|
|
if (this.isReloading) return
|
|
|
|
|
|
|
|
// if the store is possibly corrupted, we don't want to persist
|
|
|
|
if (this.store.isPossiblyCorrupted()) return
|
|
|
|
|
|
|
|
// if we're scheduled for a full write or if we have changes outstanding, let's persist them!
|
|
|
|
if (this.shouldDoFullDBWrite || this.diffQueue.length > 0) {
|
|
|
|
this.doPersist()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Actually persist to indexeddb. If the write fails, then we'll retry with a full db write after
|
|
|
|
* a short delay.
|
|
|
|
*/
|
|
|
|
private async doPersist() {
|
|
|
|
assert(!this.isPersisting, 'persist already in progress')
|
|
|
|
this.isPersisting = true
|
|
|
|
|
|
|
|
this.debug('doPersist start')
|
|
|
|
|
|
|
|
// instantly empty the diff queue, but keep our own copy of it. this way
|
|
|
|
// diffs that come in during the persist will still get tracked
|
|
|
|
const diffQueue = this.diffQueue
|
|
|
|
this.diffQueue = []
|
|
|
|
|
|
|
|
try {
|
|
|
|
if (this.shouldDoFullDBWrite) {
|
|
|
|
this.shouldDoFullDBWrite = false
|
|
|
|
await storeSnapshotInIndexedDb(
|
|
|
|
this.universalPersistenceKey,
|
|
|
|
this.store.schema,
|
|
|
|
this.store.serialize(),
|
|
|
|
{
|
|
|
|
didCancel: () => this.didDispose,
|
|
|
|
}
|
|
|
|
)
|
|
|
|
} else {
|
|
|
|
const diffs = squashRecordDiffs(diffQueue)
|
|
|
|
await storeChangesInIndexedDb(this.universalPersistenceKey, this.store.schema, diffs)
|
|
|
|
}
|
|
|
|
this.didLastWriteError = false
|
|
|
|
} catch (e) {
|
|
|
|
// set this.shouldDoFullDBWrite because we clear the diffQueue no matter what,
|
|
|
|
// so if this is just a temporary error, we will still persist all changes
|
|
|
|
this.shouldDoFullDBWrite = true
|
|
|
|
this.didLastWriteError = true
|
|
|
|
console.error('failed to store changes in indexed db', e)
|
|
|
|
|
|
|
|
showCantWriteToIndexDbAlert()
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
// adios
|
|
|
|
window.location.reload()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
this.isPersisting = false
|
|
|
|
this.debug('doPersist end')
|
|
|
|
|
|
|
|
// changes might have come in between when we started the persist and
|
|
|
|
// now. we request another persist so any new changes can get written
|
|
|
|
this.schedulePersist()
|
|
|
|
}
|
|
|
|
}
|