Independent instance state persistence (#1493)
This PR - Removes UserDocumentRecordType - moving isSnapMode to user preferences - moving isGridMode and isPenMode to InstanceRecordType - deleting the other properties which are no longer needed. - Creates a separate pipeline for persisting instance state. Previously the instance state records were stored alongside the document state records, and in order to load the state for a particular instance (in our case, a particular tab) you needed to pass the 'instanceId' prop. This prop ended up totally pervading the public API and people ran into all kinds of issues with it, e.g. using the same instance id in multiple editor instances. There was also an issue whereby it was hard for us to clean up old instance state so the idb table ended up bloating over time. This PR makes it so that rather than passing an instanceId, you load the instance state yourself while creating the store. It provides tools to make that easy. - Undoes the assumption that we might have more than one instance's state in the store. - Like `document`, `instance` now has a singleton id `instance:instance`. - Page state ids and camera ids are no longer random, but rather derive from the page they belong to. This is like having a foreign primary key in SQL databases. It's something i'd love to support fully as part of the RecordType/Store api. Tests to do - [x] Test Migrations - [x] Test Store.listen filtering - [x] Make type sets in Store public and readonly - [x] Test RecordType.createId - [x] Test Instance state snapshot loading/exporting - [x] Manual test File I/O - [x] Manual test Vscode extension with multiple tabs - [x] Audit usages of store.query - [x] Audit usages of changed types: InstanceRecordType, 'instance', InstancePageStateRecordType, 'instance_page_state', 'user_document', 'camera', CameraRecordType, InstancePresenceRecordType, 'instance_presence' - [x] Test user preferences - [x] Manual test isSnapMode and isGridMode and isPenMode - [ ] Test indexedDb functions - [x] Add instanceId stuff back ### Change Type - [x] `major` — Breaking Change ### Test Plan 1. Add a step-by-step description of how to test your PR here. 2. - [ ] Unit Tests - [ ] Webdriver tests ### Release Notes - Add a brief release note for your PR here.
This commit is contained in:
parent
0f89309604
commit
f15a8797f0
70 changed files with 1873 additions and 974 deletions
|
@ -5,7 +5,7 @@ import '@tldraw/tldraw/ui.css'
|
|||
export default function Example() {
|
||||
return (
|
||||
<div className="tldraw__editor">
|
||||
<Tldraw autoFocus />
|
||||
<Tldraw persistenceKey="tldraw_example" autoFocus />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { InstancePresenceRecordType, InstanceRecordType, Tldraw } from '@tldraw/tldraw'
|
||||
import { InstancePresenceRecordType, Tldraw } from '@tldraw/tldraw'
|
||||
import '@tldraw/tldraw/editor.css'
|
||||
import '@tldraw/tldraw/ui.css'
|
||||
import { useRef } from 'react'
|
||||
|
@ -19,10 +19,9 @@ export default function UserPresenceExample() {
|
|||
// store with their cursor position etc.
|
||||
|
||||
const peerPresence = InstancePresenceRecordType.create({
|
||||
id: InstancePresenceRecordType.createCustomId('peer-1-presence'),
|
||||
id: InstancePresenceRecordType.createId(editor.store.id),
|
||||
currentPageId: editor.currentPageId,
|
||||
userId: 'peer-1',
|
||||
instanceId: InstanceRecordType.createCustomId('peer-1-editor-instance'),
|
||||
userName: 'Peer 1',
|
||||
cursor: { x: 0, y: 0, type: 'default', rotation: 0 },
|
||||
})
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Canvas, ContextMenu, TAB_ID, TldrawEditor, TldrawUi, createTLStore } from '@tldraw/tldraw'
|
||||
import { Canvas, ContextMenu, TldrawEditor, TldrawUi, createTLStore } from '@tldraw/tldraw'
|
||||
import '@tldraw/tldraw/editor.css'
|
||||
import '@tldraw/tldraw/ui.css'
|
||||
import { throttle } from '@tldraw/utils'
|
||||
|
@ -7,15 +7,15 @@ import { useLayoutEffect, useState } from 'react'
|
|||
const PERSISTENCE_KEY = 'example-3'
|
||||
|
||||
export default function PersistenceExample() {
|
||||
const [store] = useState(() => createTLStore({ instanceId: TAB_ID }))
|
||||
const [loadingStore, setLoadingStore] = useState<
|
||||
const [store] = useState(() => createTLStore())
|
||||
const [loadingState, setLoadingState] = useState<
|
||||
{ status: 'loading' } | { status: 'ready' } | { status: 'error'; error: string }
|
||||
>({
|
||||
status: 'loading',
|
||||
})
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setLoadingStore({ status: 'loading' })
|
||||
setLoadingState({ status: 'loading' })
|
||||
|
||||
// Get persisted data from local storage
|
||||
const persistedSnapshot = localStorage.getItem(PERSISTENCE_KEY)
|
||||
|
@ -24,12 +24,12 @@ export default function PersistenceExample() {
|
|||
try {
|
||||
const snapshot = JSON.parse(persistedSnapshot)
|
||||
store.loadSnapshot(snapshot)
|
||||
setLoadingStore({ status: 'ready' })
|
||||
setLoadingState({ status: 'ready' })
|
||||
} catch (error: any) {
|
||||
setLoadingStore({ status: 'error', error: error.message }) // Something went wrong
|
||||
setLoadingState({ status: 'error', error: error.message }) // Something went wrong
|
||||
}
|
||||
} else {
|
||||
setLoadingStore({ status: 'ready' }) // Nothing persisted, continue with the empty store
|
||||
setLoadingState({ status: 'ready' }) // Nothing persisted, continue with the empty store
|
||||
}
|
||||
|
||||
// Each time the store changes, run the (debounced) persist function
|
||||
|
@ -45,7 +45,7 @@ export default function PersistenceExample() {
|
|||
}
|
||||
}, [store])
|
||||
|
||||
if (loadingStore.status === 'loading') {
|
||||
if (loadingState.status === 'loading') {
|
||||
return (
|
||||
<div className="tldraw__editor">
|
||||
<h2>Loading...</h2>
|
||||
|
@ -53,11 +53,11 @@ export default function PersistenceExample() {
|
|||
)
|
||||
}
|
||||
|
||||
if (loadingStore.status === 'error') {
|
||||
if (loadingState.status === 'error') {
|
||||
return (
|
||||
<div className="tldraw__editor">
|
||||
<h2>Error!</h2>
|
||||
<p>{loadingStore.error}</p>
|
||||
<p>{loadingState.error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ This folder contains the source for the tldraw VS Code extension.
|
|||
|
||||
In the root folder:
|
||||
|
||||
- Run `yarn dev:vscode`.
|
||||
- Run `yarn dev-vscode`.
|
||||
|
||||
This will start the development server for the `apps/vscode/editor` project and open the `apps/vscode/extension` folder in a new VS Code window.
|
||||
|
||||
|
|
|
@ -1,11 +1,4 @@
|
|||
import {
|
||||
Canvas,
|
||||
Editor,
|
||||
ErrorBoundary,
|
||||
TAB_ID,
|
||||
TldrawEditor,
|
||||
setRuntimeOverrides,
|
||||
} from '@tldraw/editor'
|
||||
import { Canvas, Editor, ErrorBoundary, TldrawEditor, setRuntimeOverrides } from '@tldraw/editor'
|
||||
import { linksUiOverrides } from './utils/links'
|
||||
// eslint-disable-next-line import/no-internal-modules
|
||||
import '@tldraw/editor/editor.css'
|
||||
|
@ -129,7 +122,6 @@ function TldrawInner({ uri, assetSrc, isDarkMode, fileContents }: TLDrawInnerPro
|
|||
return (
|
||||
<TldrawEditor
|
||||
assetUrls={assetUrls}
|
||||
instanceId={TAB_ID}
|
||||
persistenceKey={uri}
|
||||
onCreateBookmarkFromUrl={onCreateBookmarkFromUrl}
|
||||
autoFocus
|
||||
|
|
|
@ -3,8 +3,7 @@
|
|||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"removeComments": true,
|
||||
|
|
|
@ -3,8 +3,7 @@
|
|||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"removeComments": true,
|
||||
|
|
|
@ -57,7 +57,6 @@ import { TLHighlightShape } from '@tldraw/tlschema';
|
|||
import { TLImageAsset } from '@tldraw/tlschema';
|
||||
import { TLImageShape } from '@tldraw/tlschema';
|
||||
import { TLInstance } from '@tldraw/tlschema';
|
||||
import { TLInstanceId } from '@tldraw/tlschema';
|
||||
import { TLInstancePageState } from '@tldraw/tlschema';
|
||||
import { TLInstancePresence } from '@tldraw/tlschema';
|
||||
import { TLInstancePropsForNextShape } from '@tldraw/tlschema';
|
||||
|
@ -82,9 +81,9 @@ import { TLStyleType } from '@tldraw/tlschema';
|
|||
import { TLTextShape } from '@tldraw/tlschema';
|
||||
import { TLTextShapeProps } from '@tldraw/tlschema';
|
||||
import { TLUnknownShape } from '@tldraw/tlschema';
|
||||
import { TLUserDocument } from '@tldraw/tlschema';
|
||||
import { TLVideoAsset } from '@tldraw/tlschema';
|
||||
import { TLVideoShape } from '@tldraw/tlschema';
|
||||
import { UnknownRecord } from '@tldraw/store';
|
||||
import { Vec2d } from '@tldraw/primitives';
|
||||
import { Vec2dModel } from '@tldraw/tlschema';
|
||||
import { VecLike } from '@tldraw/primitives';
|
||||
|
@ -261,6 +260,9 @@ export function createEmbedShapeAtPoint(editor: Editor, url: string, point: Vec2
|
|||
doesResize?: boolean;
|
||||
}): void;
|
||||
|
||||
// @public
|
||||
export function createSessionStateSnapshotSignal(store: TLStore): Signal<null | TLSessionStateSnapshot>;
|
||||
|
||||
// @public (undocumented)
|
||||
export function createShapesFromFiles(editor: Editor, files: File[], position: VecLike, _ignoreParent?: boolean): Promise<void>;
|
||||
|
||||
|
@ -573,7 +575,6 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
isPanning: boolean;
|
||||
pointerVelocity: Vec2d;
|
||||
};
|
||||
get instanceId(): TLInstanceId;
|
||||
get instanceState(): TLInstance;
|
||||
interrupt(): this;
|
||||
get isChangingStyle(): boolean;
|
||||
|
@ -763,15 +764,12 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
updateCullingBounds(): this;
|
||||
// @internal (undocumented)
|
||||
updateDocumentSettings(settings: Partial<TLDocument>): void;
|
||||
updateInstanceState(partial: Partial<Omit<TLInstance, 'currentPageId' | 'documentId' | 'userId'>>, ephemeral?: boolean, squashing?: boolean): this;
|
||||
updateInstanceState(partial: Partial<Omit<TLInstance, 'currentPageId'>>, ephemeral?: boolean, squashing?: boolean): this;
|
||||
updatePage(partial: RequiredKeys<TLPage, 'id'>, squashing?: boolean): this;
|
||||
updateShapes(partials: (null | TLShapePartial | undefined)[], squashing?: boolean): this;
|
||||
updateUserDocumentSettings(partial: Partial<TLUserDocument>, ephemeral?: boolean): this;
|
||||
updateViewportScreenBounds(center?: boolean): this;
|
||||
// @internal (undocumented)
|
||||
readonly user: UserPreferencesManager;
|
||||
// (undocumented)
|
||||
get userDocumentSettings(): TLUserDocument;
|
||||
get viewportPageBounds(): Box2d;
|
||||
get viewportPageCenter(): Vec2d;
|
||||
get viewportScreenBounds(): Box2d;
|
||||
|
@ -836,6 +834,9 @@ export function ErrorScreen({ children }: {
|
|||
// @public (undocumented)
|
||||
export const EVENT_NAME_MAP: Record<Exclude<TLEventName, TLPinchEventName>, keyof TLEventHandlers>;
|
||||
|
||||
// @internal (undocumented)
|
||||
export function extractSessionStateFromLegacySnapshot(store: Record<string, UnknownRecord>): null | TLSessionStateSnapshot;
|
||||
|
||||
// @internal (undocumented)
|
||||
export const featureFlags: {
|
||||
peopleMenu: DebugFlag<boolean>;
|
||||
|
@ -1283,6 +1284,9 @@ export function LoadingScreen({ children }: {
|
|||
children: any;
|
||||
}): JSX.Element;
|
||||
|
||||
// @public
|
||||
export function loadSessionStateSnapshotIntoStore(store: TLStore, snapshot: TLSessionStateSnapshot): void;
|
||||
|
||||
// @public (undocumented)
|
||||
export function loopToHtmlElement(elm: Element): HTMLElement;
|
||||
|
||||
|
@ -1977,8 +1981,8 @@ export function SVGContainer({ children, className, ...rest }: SVGContainerProps
|
|||
// @public (undocumented)
|
||||
export type SVGContainerProps = React_3.HTMLAttributes<SVGElement>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const TAB_ID: TLInstanceId;
|
||||
// @public
|
||||
export const TAB_ID: string;
|
||||
|
||||
// @public (undocumented)
|
||||
export const TEXT_PROPS: {
|
||||
|
@ -2188,8 +2192,8 @@ export type TldrawEditorProps = {
|
|||
} | {
|
||||
store?: undefined;
|
||||
initialData?: StoreSnapshot<TLRecord>;
|
||||
instanceId?: TLInstanceId;
|
||||
persistenceKey?: string;
|
||||
sessionId?: string;
|
||||
defaultName?: string;
|
||||
});
|
||||
|
||||
|
@ -2527,6 +2531,35 @@ export type TLResizeMode = 'resize_bounds' | 'scale_shape';
|
|||
// @public (undocumented)
|
||||
export type TLSelectionHandle = RotateCorner | SelectionCorner | SelectionEdge;
|
||||
|
||||
// @public
|
||||
export interface TLSessionStateSnapshot {
|
||||
// (undocumented)
|
||||
currentPageId: TLPageId;
|
||||
// (undocumented)
|
||||
exportBackground: boolean;
|
||||
// (undocumented)
|
||||
isDebugMode: boolean;
|
||||
// (undocumented)
|
||||
isFocusMode: boolean;
|
||||
// (undocumented)
|
||||
isGridMode: boolean;
|
||||
// (undocumented)
|
||||
isToolLocked: boolean;
|
||||
// (undocumented)
|
||||
pageStates: Array<{
|
||||
pageId: TLPageId;
|
||||
camera: {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
};
|
||||
selectedIds: TLShapeId[];
|
||||
focusLayerId: null | TLShapeId;
|
||||
}>;
|
||||
// (undocumented)
|
||||
version: number;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface TLShapeUtilConstructor<T extends TLUnknownShape, U extends ShapeUtil<T> = ShapeUtil<T>> {
|
||||
// (undocumented)
|
||||
|
@ -2590,6 +2623,8 @@ export interface TLUserPreferences {
|
|||
// (undocumented)
|
||||
isDarkMode: boolean;
|
||||
// (undocumented)
|
||||
isSnapMode: boolean;
|
||||
// (undocumented)
|
||||
locale: string;
|
||||
// (undocumented)
|
||||
name: string;
|
||||
|
@ -2626,6 +2661,7 @@ export const useEditor: () => Editor;
|
|||
// @internal (undocumented)
|
||||
export function useLocalStore(opts?: {
|
||||
persistenceKey?: string | undefined;
|
||||
sessionId?: string | undefined;
|
||||
} & TLStoreOptions): TLStoreWithStatus;
|
||||
|
||||
// @internal (undocumented)
|
||||
|
|
|
@ -115,6 +115,13 @@ export {
|
|||
} from './lib/components/ErrorBoundary'
|
||||
export { HTMLContainer, type HTMLContainerProps } from './lib/components/HTMLContainer'
|
||||
export { SVGContainer, type SVGContainerProps } from './lib/components/SVGContainer'
|
||||
export {
|
||||
TAB_ID,
|
||||
createSessionStateSnapshotSignal,
|
||||
extractSessionStateFromLegacySnapshot,
|
||||
loadSessionStateSnapshotIntoStore,
|
||||
type TLSessionStateSnapshot,
|
||||
} from './lib/config/TLSessionStateSnapshot'
|
||||
export {
|
||||
USER_COLORS,
|
||||
getUserPreferences,
|
||||
|
@ -244,5 +251,4 @@ export {
|
|||
export { getPointerInfo, getSvgPathFromStroke, getSvgPathFromStrokePoints } from './lib/utils/svg'
|
||||
export { type TLStoreWithStatus } from './lib/utils/sync/StoreWithStatus'
|
||||
export { hardReset } from './lib/utils/sync/hardReset'
|
||||
export { TAB_ID } from './lib/utils/sync/persistence-constants'
|
||||
export { openWindow } from './lib/utils/window-open'
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Store, StoreSnapshot } from '@tldraw/store'
|
||||
import { TLAsset, TLInstanceId, TLRecord, TLStore } from '@tldraw/tlschema'
|
||||
import { TLAsset, TLRecord, TLStore } from '@tldraw/tlschema'
|
||||
import { annotateError } from '@tldraw/utils'
|
||||
import React, { memo, useCallback, useLayoutEffect, useState, useSyncExternalStore } from 'react'
|
||||
import { Editor } from './app/Editor'
|
||||
|
@ -24,7 +24,6 @@ import { usePreloadAssets } from './hooks/usePreloadAssets'
|
|||
import { useSafariFocusOutFix } from './hooks/useSafariFocusOutFix'
|
||||
import { useZoomCss } from './hooks/useZoomCss'
|
||||
import { TLStoreWithStatus } from './utils/sync/StoreWithStatus'
|
||||
import { TAB_ID } from './utils/sync/persistence-constants'
|
||||
|
||||
/** @public */
|
||||
export type TldrawEditorProps = {
|
||||
|
@ -111,16 +110,22 @@ export type TldrawEditorProps = {
|
|||
*/
|
||||
initialData?: StoreSnapshot<TLRecord>
|
||||
/**
|
||||
* The id of the editor instance (e.g. a browser tab if the editor will have only one tldraw app per
|
||||
* tab). If not given, one will be generated.
|
||||
*/
|
||||
instanceId?: TLInstanceId
|
||||
/**
|
||||
* The id under which to sync and persist the editor's data.
|
||||
* The id under which to sync and persist the editor's data. If none is given tldraw will not sync or persist
|
||||
* the editor's data.
|
||||
*/
|
||||
persistenceKey?: string
|
||||
/**
|
||||
* The initial document name to use for the new store.
|
||||
* When tldraw reloads a document from local persistence, it will try to bring back the
|
||||
* editor UI state (e.g. camera position, which shapes are selected). It does this using a sessionId,
|
||||
* which by default is unique per browser tab. If you wish to have more fine-grained
|
||||
* control over this behavior, you can provide your own sessionId.
|
||||
*
|
||||
* If it can't find saved UI state for the given sessionId, it will use the most recently saved
|
||||
* UI state for the given persistenceKey if available.
|
||||
*/
|
||||
sessionId?: string
|
||||
/**
|
||||
* The default initial document name. e.g. 'Untitled Document'
|
||||
*/
|
||||
defaultName?: string
|
||||
}
|
||||
|
@ -173,13 +178,13 @@ export const TldrawEditor = memo(function TldrawEditor(props: TldrawEditorProps)
|
|||
})
|
||||
|
||||
function TldrawEditorWithOwnStore(props: TldrawEditorProps & { store: undefined }) {
|
||||
const { defaultName, initialData, instanceId = TAB_ID, shapes, persistenceKey } = props
|
||||
const { defaultName, initialData, shapes, persistenceKey, sessionId } = props
|
||||
|
||||
const syncedStore = useLocalStore({
|
||||
customShapes: shapes,
|
||||
instanceId,
|
||||
initialData,
|
||||
persistenceKey,
|
||||
sessionId,
|
||||
defaultName,
|
||||
})
|
||||
|
||||
|
|
|
@ -39,9 +39,9 @@ import {
|
|||
TLDocument,
|
||||
TLFrameShape,
|
||||
TLGroupShape,
|
||||
TLINSTANCE_ID,
|
||||
TLImageAsset,
|
||||
TLInstance,
|
||||
TLInstanceId,
|
||||
TLInstancePageState,
|
||||
TLNullableShapeProps,
|
||||
TLPOINTER_ID,
|
||||
|
@ -57,7 +57,6 @@ import {
|
|||
TLSizeStyle,
|
||||
TLStore,
|
||||
TLUnknownShape,
|
||||
TLUserDocument,
|
||||
TLVideoAsset,
|
||||
Vec2dModel,
|
||||
createShapeId,
|
||||
|
@ -262,7 +261,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
prev.typeName === 'instance_page_state' &&
|
||||
next.typeName === 'instance_page_state'
|
||||
) {
|
||||
this._tabStateDidChange(prev, next)
|
||||
this._pageStateDidChange(prev, next)
|
||||
}
|
||||
|
||||
this._updateDepth--
|
||||
|
@ -451,21 +450,6 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
*/
|
||||
getContainer: () => HTMLElement
|
||||
|
||||
/**
|
||||
* The editor's instanceId (defined in its store.props).
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```ts
|
||||
* const instanceId = editor.instanceId
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
get instanceId(): TLInstanceId {
|
||||
return this.store.props.instanceId
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
annotateError(
|
||||
error: unknown,
|
||||
|
@ -657,7 +641,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
// Clear any reset timeout
|
||||
clearTimeout(this._isChangingStyleTimeout)
|
||||
if (v) {
|
||||
// If we've set to true, set a new reset timeout to change the value back to false after 2 seonds
|
||||
// If we've set to true, set a new reset timeout to change the value back to false after 2 seconds
|
||||
this._isChangingStyleTimeout = setTimeout(() => (this.isChangingStyle = false), 2000)
|
||||
}
|
||||
}
|
||||
|
@ -672,7 +656,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
if (isPageId(shape.parentId)) {
|
||||
return this.getTransform(shape)
|
||||
}
|
||||
// some weird circular type thing here that I had to work wround with (as any)
|
||||
// some weird circular type thing here that I had to work around with (as any)
|
||||
const parent = (this._pageTransformCache as any).get(shape.parentId)
|
||||
|
||||
return Matrix2d.Compose(parent, this.getTransform(shape))
|
||||
|
@ -1185,17 +1169,6 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
|
||||
/** @internal */
|
||||
private _complete() {
|
||||
const { lastUpdatedPageId, lastUsedTabId } = this.userDocumentSettings
|
||||
if (lastUsedTabId !== this.instanceId || lastUpdatedPageId !== this.currentPageId) {
|
||||
this.store.put([
|
||||
{
|
||||
...this.userDocumentSettings,
|
||||
lastUsedTabId: this.instanceId,
|
||||
lastUpdatedPageId: this.currentPageId,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
for (const parentId of this._invalidParents) {
|
||||
this._invalidParents.delete(parentId)
|
||||
const parent = this.getShapeById(parentId)
|
||||
|
@ -1439,7 +1412,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
allMovingIds.add(id)
|
||||
})
|
||||
|
||||
for (const instancePageState of this.store.query.records('instance_page_state').value) {
|
||||
for (const instancePageState of this._allPageStates.value) {
|
||||
if (instancePageState.pageId === next.parentId) continue
|
||||
const nextPageState = this._cleanupInstancePageState(instancePageState, allMovingIds)
|
||||
|
||||
|
@ -1459,9 +1432,9 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
}
|
||||
|
||||
/** @internal */
|
||||
private _tabStateDidChange(prev: TLInstancePageState, next: TLInstancePageState) {
|
||||
private _pageStateDidChange(prev: TLInstancePageState, next: TLInstancePageState) {
|
||||
if (prev?.selectedIds !== next?.selectedIds) {
|
||||
// ensure that descendants and ascenants are not selected at the same time
|
||||
// ensure that descendants and ancestors are not selected at the same time
|
||||
const filtered = next.selectedIds.filter((id) => {
|
||||
let parentId = this.getShapeById(id)?.parentId
|
||||
while (isShapeId(parentId)) {
|
||||
|
@ -1490,14 +1463,11 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
/** @internal */
|
||||
private _pageWillBeDeleted(page: TLPage) {
|
||||
// page was deleted, need to check whether it's the current page and select another one if so
|
||||
const instanceStates = this.store.query.exec('instance', { currentPageId: { eq: page.id } })
|
||||
if (this.instanceState.currentPageId !== page.id) return
|
||||
|
||||
if (!instanceStates.length) return
|
||||
const backupPageId = this.pages.find((p) => p.id !== page.id)?.id
|
||||
|
||||
if (!backupPageId) return
|
||||
|
||||
this.store.put(instanceStates.map((state) => ({ ...state, currentPageId: backupPageId })))
|
||||
this.store.put([{ ...this.instanceState, currentPageId: backupPageId }])
|
||||
}
|
||||
|
||||
/* -------------------- Shortcuts ------------------- */
|
||||
|
@ -1527,12 +1497,12 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
}
|
||||
|
||||
get isSnapMode() {
|
||||
return this.userDocumentSettings.isSnapMode
|
||||
return this.user.isSnapMode
|
||||
}
|
||||
|
||||
setSnapMode(isSnapMode: boolean) {
|
||||
if (isSnapMode !== this.isSnapMode) {
|
||||
this.updateUserDocumentSettings({ isSnapMode }, true)
|
||||
this.user.updateUserPreferences({ isSnapMode })
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
@ -1581,22 +1551,13 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
return this
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@computed private get _userDocumentSettings() {
|
||||
return this.store.query.record('user_document')
|
||||
}
|
||||
|
||||
get userDocumentSettings(): TLUserDocument {
|
||||
return this._userDocumentSettings.value!
|
||||
}
|
||||
|
||||
get isGridMode() {
|
||||
return this.userDocumentSettings.isGridMode
|
||||
return this.instanceState.isGridMode
|
||||
}
|
||||
|
||||
setGridMode(isGridMode: boolean): this {
|
||||
if (isGridMode !== this.isGridMode) {
|
||||
this.updateUserDocumentSettings({ isGridMode }, true)
|
||||
this.updateInstanceState({ isGridMode }, true)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
@ -1638,7 +1599,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
|
||||
/** The current tab state */
|
||||
get instanceState(): TLInstance {
|
||||
return this.store.get(this.instanceId)!
|
||||
return this.store.get(TLINSTANCE_ID)!
|
||||
}
|
||||
|
||||
get cursor() {
|
||||
|
@ -1658,17 +1619,8 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
}
|
||||
|
||||
/** @internal */
|
||||
@computed private get _pageState() {
|
||||
return this.store.query.record(
|
||||
'instance_page_state',
|
||||
() => {
|
||||
return {
|
||||
pageId: { eq: this.currentPageId },
|
||||
instanceId: { eq: this.instanceId },
|
||||
}
|
||||
},
|
||||
'editor._pageState'
|
||||
)
|
||||
@computed private get pageStateId() {
|
||||
return InstancePageStateRecordType.createId(this.currentPageId)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1676,13 +1628,17 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
*
|
||||
* @public
|
||||
*/
|
||||
get pageState(): TLInstancePageState {
|
||||
return this._pageState.value!
|
||||
@computed get pageState(): TLInstancePageState {
|
||||
return this.store.get(this.pageStateId)!
|
||||
}
|
||||
@computed
|
||||
private get cameraId() {
|
||||
return CameraRecordType.createId(this.currentPageId)
|
||||
}
|
||||
|
||||
/** The current camera. */
|
||||
@computed get camera() {
|
||||
return this.store.get(this.pageState.cameraId)!
|
||||
return this.store.get(this.cameraId)!
|
||||
}
|
||||
|
||||
/** The current camera zoom level. */
|
||||
|
@ -1760,9 +1716,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
|
||||
/** @internal */
|
||||
@computed private get _pageStates() {
|
||||
return this.store.query.records('instance_page_state', () => ({
|
||||
instanceId: { eq: this.instanceId },
|
||||
}))
|
||||
return this.store.query.records('instance_page_state')
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -2756,7 +2710,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
* @public
|
||||
*/
|
||||
screenToPage(x: number, y: number, z = 0.5, camera: Vec2dModel = this.camera) {
|
||||
const { screenBounds } = this.store.unsafeGetWithoutCapture(this.instanceId)!
|
||||
const { screenBounds } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)!
|
||||
const { x: cx, y: cy, z: cz = 1 } = camera
|
||||
return {
|
||||
x: (x - screenBounds.x) / cz - cx,
|
||||
|
@ -3521,7 +3475,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
isEditing: false,
|
||||
/** Whether the user is panning. */
|
||||
isPanning: false,
|
||||
/** Veclocity of mouse pointer, in pixels per millisecond */
|
||||
/** Velocity of mouse pointer, in pixels per millisecond */
|
||||
pointerVelocity: new Vec2d(),
|
||||
}
|
||||
|
||||
|
@ -3535,7 +3489,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
const { previousScreenPoint, previousPagePoint, currentScreenPoint, currentPagePoint } =
|
||||
this.inputs
|
||||
|
||||
const { screenBounds } = this.store.unsafeGetWithoutCapture(this.instanceId)!
|
||||
const { screenBounds } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)!
|
||||
const { x: sx, y: sy, z: sz } = info.point
|
||||
const { x: cx, y: cy, z: cz } = this.camera
|
||||
|
||||
|
@ -3962,7 +3916,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
inputs.isDragging = false
|
||||
|
||||
if (this.isMenuOpen) {
|
||||
// Surpressing pointerup here as <ContextMenu/> doesn't seem to do what we what here.
|
||||
// Suppressing pointerup here as <ContextMenu/> doesn't seem to do what we what here.
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -4059,7 +4013,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
break
|
||||
}
|
||||
case 'key_repeat': {
|
||||
// nooop
|
||||
// noop
|
||||
break
|
||||
}
|
||||
}
|
||||
|
@ -4667,7 +4621,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
)
|
||||
partial.parentId = parentId
|
||||
// If the parent is a shape (rather than a page) then insert the
|
||||
// shapes into the shape's children. Ajust the point and page rotation to be
|
||||
// shapes into the shape's children. Adjust the point and page rotation to be
|
||||
// preserved relative to the parent.
|
||||
if (isShapeId(parentId)) {
|
||||
const point = this.getPointInShapeSpace(this.getShapeById(parentId)!, {
|
||||
|
@ -5083,40 +5037,6 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Update user document settings
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```ts
|
||||
* editor.updateUserDocumentSettings({ isGridMode: true })
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
updateUserDocumentSettings(partial: Partial<TLUserDocument>, ephemeral = false) {
|
||||
this._updateUserDocumentSettings(partial, ephemeral)
|
||||
return this
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
private _updateUserDocumentSettings = this.history.createCommand(
|
||||
'updateUserDocumentSettings',
|
||||
(partial: Partial<TLUserDocument>, ephemeral = false) => {
|
||||
const prev = this.userDocumentSettings
|
||||
const next = { ...prev, ...partial }
|
||||
return { data: { prev, next }, ephemeral }
|
||||
},
|
||||
{
|
||||
do: ({ next }) => {
|
||||
this.store.put([next])
|
||||
},
|
||||
undo: ({ prev }) => {
|
||||
this.store.put([prev])
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Get the editor's locale.
|
||||
* @public
|
||||
|
@ -5229,12 +5149,13 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
: getIndexAbove(topIndex),
|
||||
})
|
||||
|
||||
const newCamera = CameraRecordType.create({})
|
||||
const newCamera = CameraRecordType.create({
|
||||
id: CameraRecordType.createId(newPage.id),
|
||||
})
|
||||
|
||||
const newTabPageState = InstancePageStateRecordType.create({
|
||||
id: InstancePageStateRecordType.createId(newPage.id),
|
||||
pageId: newPage.id,
|
||||
instanceId: this.instanceId,
|
||||
cameraId: newCamera.id,
|
||||
})
|
||||
|
||||
return {
|
||||
|
@ -5257,9 +5178,9 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
])
|
||||
this.updateCullingBounds()
|
||||
},
|
||||
undo: ({ newPage, prevPageState, prevTabState, newTabPageState }) => {
|
||||
undo: ({ newPage, prevPageState, prevTabState, newTabPageState, newCamera }) => {
|
||||
this.store.put([prevPageState, prevTabState])
|
||||
this.store.remove([newTabPageState.id, newPage.id, newTabPageState.cameraId])
|
||||
this.store.remove([newTabPageState.id, newPage.id, newCamera.id])
|
||||
|
||||
this.updateCullingBounds()
|
||||
},
|
||||
|
@ -5656,7 +5577,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
const realContainerStyle = getComputedStyle(realContainerEl)
|
||||
|
||||
// Get the styles from the container. We'll use these to pull out colors etc.
|
||||
// NOTE: We can force force a light theme here becasue we don't want export
|
||||
// NOTE: We can force force a light theme here because we don't want export
|
||||
const fakeContainerEl = document.createElement('div')
|
||||
fakeContainerEl.className = `tl-container tl-theme__${
|
||||
darkMode ? 'dark' : 'light'
|
||||
|
@ -6824,13 +6745,13 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
) {
|
||||
const { type } = options.initialShape
|
||||
// If a shape is not aligned with the scale axis we need to treat it differently to avoid skewing.
|
||||
// Instead of skewing we normalise the scale aspect ratio (i.e. keep the same scale magnitude in both axes)
|
||||
// Instead of skewing we normalize the scale aspect ratio (i.e. keep the same scale magnitude in both axes)
|
||||
// and then after applying the scale to the shape we also rotate it if required and translate it so that it's center
|
||||
// point ends up in the right place.
|
||||
|
||||
const shapeScale = new Vec2d(scale.x, scale.y)
|
||||
|
||||
// // make sure we are contraining aspect ratio, and using the smallest scale axis to avoid shapes getting bigger
|
||||
// // make sure we are constraining aspect ratio, and using the smallest scale axis to avoid shapes getting bigger
|
||||
// // than the selection bounding box
|
||||
if (Math.abs(scale.x) > Math.abs(scale.y)) {
|
||||
shapeScale.x = Math.sign(scale.x) * Math.abs(scale.y)
|
||||
|
@ -6867,7 +6788,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
options.scaleAxisRotation
|
||||
)
|
||||
|
||||
// now caculate how far away the shape is from where it needs to be
|
||||
// now calculate how far away the shape is from where it needs to be
|
||||
const currentPageCenter = this.getPageCenterById(id)
|
||||
const currentPagePoint = this.getPagePointById(id)
|
||||
if (!currentPageCenter || !currentPagePoint) return this
|
||||
|
@ -6978,7 +6899,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
|
||||
// resize the shape's local bounding box
|
||||
const myScale = new Vec2d(scale.x, scale.y)
|
||||
// the shape is algined with the rest of the shpaes in the selection, but may be
|
||||
// the shape is aligned with the rest of the shapes in the selection, but may be
|
||||
// 90deg offset from the main rotation of the selection, in which case
|
||||
// we need to flip the width and height scale factors
|
||||
const areWidthAndHeightAlignedWithCorrectAxis = approximately(
|
||||
|
@ -7244,7 +7165,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Remove a shpae from the existing set of selected shapes.
|
||||
* Remove a shape from the existing set of selected shapes.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
|
@ -7359,13 +7280,14 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
{
|
||||
do: ({ toId }) => {
|
||||
if (!this.getPageStateByPageId(toId)) {
|
||||
const camera = CameraRecordType.create({})
|
||||
const camera = CameraRecordType.create({
|
||||
id: CameraRecordType.createId(toId),
|
||||
})
|
||||
this.store.put([
|
||||
camera,
|
||||
InstancePageStateRecordType.create({
|
||||
id: InstancePageStateRecordType.createId(toId),
|
||||
pageId: toId,
|
||||
instanceId: this.instanceId,
|
||||
cameraId: camera.id,
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
@ -7387,7 +7309,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
|
||||
/** Set the current user tab state */
|
||||
updateInstanceState(
|
||||
partial: Partial<Omit<TLInstance, 'documentId' | 'userId' | 'currentPageId'>>,
|
||||
partial: Partial<Omit<TLInstance, 'currentPageId'>>,
|
||||
ephemeral = false,
|
||||
squashing = false
|
||||
) {
|
||||
|
@ -7398,11 +7320,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
/** @internal */
|
||||
private _updateInstanceState = this.history.createCommand(
|
||||
'updateTabState',
|
||||
(
|
||||
partial: Partial<Omit<TLInstance, 'documentId' | 'userId' | 'currentPageId'>>,
|
||||
ephemeral = false,
|
||||
squashing = false
|
||||
) => {
|
||||
(partial: Partial<Omit<TLInstance, 'currentPageId'>>, ephemeral = false, squashing = false) => {
|
||||
const prev = this.instanceState
|
||||
const next = { ...prev, ...partial }
|
||||
|
||||
|
|
|
@ -51,7 +51,7 @@ describe('shapeIdsInCurrentPage', () => {
|
|||
{ type: 'geo', id: ids.box2 },
|
||||
{ type: 'geo', id: ids.box3 },
|
||||
])
|
||||
const id = PageRecordType.createCustomId('page2')
|
||||
const id = PageRecordType.createId('page2')
|
||||
editor.createPage('New Page 2', id)
|
||||
editor.setCurrentPageId(id)
|
||||
editor.createShapes([
|
||||
|
|
|
@ -34,4 +34,8 @@ export class UserPreferencesManager {
|
|||
get color() {
|
||||
return this.user.userPreferences.value.color
|
||||
}
|
||||
|
||||
get isSnapMode() {
|
||||
return this.user.userPreferences.value.isSnapMode
|
||||
}
|
||||
}
|
||||
|
|
|
@ -126,7 +126,7 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
|
|||
|
||||
protected updateBookmarkAsset = debounce((shape: TLBookmarkShape) => {
|
||||
const { url } = shape.props
|
||||
const assetId: TLAssetId = AssetRecordType.createCustomId(getHashForString(url))
|
||||
const assetId: TLAssetId = AssetRecordType.createId(getHashForString(url))
|
||||
const existing = this.editor.getAssetById(assetId)
|
||||
|
||||
if (existing) {
|
||||
|
|
|
@ -447,7 +447,7 @@ export class Drawing extends StateNode {
|
|||
let didSnap = false
|
||||
let snapSegment: TLDrawShapeSegment | undefined = undefined
|
||||
|
||||
const shouldSnap = this.editor.userDocumentSettings.isSnapMode ? !ctrlKey : ctrlKey
|
||||
const shouldSnap = this.editor.isSnapMode ? !ctrlKey : ctrlKey
|
||||
|
||||
if (shouldSnap) {
|
||||
if (newSegments.length > 2) {
|
||||
|
|
|
@ -208,7 +208,7 @@ export class DraggingHandle extends StateNode {
|
|||
this.editor.snaps.clear()
|
||||
|
||||
const { ctrlKey } = this.editor.inputs
|
||||
const shouldSnap = this.editor.userDocumentSettings.isSnapMode ? !ctrlKey : ctrlKey
|
||||
const shouldSnap = this.editor.isSnapMode ? !ctrlKey : ctrlKey
|
||||
|
||||
if (shouldSnap && shape.type === 'line') {
|
||||
const pagePoint = Matrix2d.applyToPoint(this.editor.getPageTransformById(shape.id)!, point)
|
||||
|
|
|
@ -214,7 +214,7 @@ export class Resizing extends StateNode {
|
|||
|
||||
this.editor.snaps.clear()
|
||||
|
||||
const shouldSnap = this.editor.userDocumentSettings.isSnapMode ? !ctrlKey : ctrlKey
|
||||
const shouldSnap = this.editor.isSnapMode ? !ctrlKey : ctrlKey
|
||||
|
||||
if (shouldSnap && selectionRotation % TAU === 0) {
|
||||
const { nudge } = this.editor.snaps.snapResize({
|
||||
|
|
|
@ -351,7 +351,7 @@ export function moveShapesToPoint({
|
|||
editor.snaps.clear()
|
||||
|
||||
const shouldSnap =
|
||||
(editor.userDocumentSettings.isSnapMode ? !inputs.ctrlKey : inputs.ctrlKey) &&
|
||||
(editor.isSnapMode ? !inputs.ctrlKey : inputs.ctrlKey) &&
|
||||
editor.inputs.pointerVelocity.len() < 0.5 // ...and if the user is not dragging fast
|
||||
|
||||
if (shouldSnap) {
|
||||
|
|
152
packages/editor/src/lib/config/TLSessionStateSnapshot.test.ts
Normal file
152
packages/editor/src/lib/config/TLSessionStateSnapshot.test.ts
Normal file
|
@ -0,0 +1,152 @@
|
|||
import { react } from 'signia'
|
||||
import { TestEditor } from '../test/TestEditor'
|
||||
import {
|
||||
TLSessionStateSnapshot,
|
||||
createSessionStateSnapshotSignal,
|
||||
extractSessionStateFromLegacySnapshot,
|
||||
loadSessionStateSnapshotIntoStore,
|
||||
} from './TLSessionStateSnapshot'
|
||||
|
||||
let editor: TestEditor
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new TestEditor()
|
||||
})
|
||||
|
||||
describe('createSessionStateSnapshotSignal', () => {
|
||||
it('creates a signal', () => {
|
||||
const $snapshot = createSessionStateSnapshotSignal(editor.store)
|
||||
|
||||
expect($snapshot.value).toMatchObject({
|
||||
exportBackground: true,
|
||||
isDebugMode: false,
|
||||
isFocusMode: false,
|
||||
isGridMode: false,
|
||||
isToolLocked: false,
|
||||
pageStates: [
|
||||
{
|
||||
camera: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 1,
|
||||
},
|
||||
focusLayerId: null,
|
||||
selectedIds: [],
|
||||
},
|
||||
],
|
||||
version: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a signal that can be reacted to', () => {
|
||||
const $snapshot = createSessionStateSnapshotSignal(editor.store)
|
||||
|
||||
let isGridMode = false
|
||||
let numPages = 0
|
||||
|
||||
react('', () => {
|
||||
isGridMode = $snapshot.value?.isGridMode ?? false
|
||||
numPages = $snapshot.value?.pageStates.length ?? 0
|
||||
})
|
||||
|
||||
expect(isGridMode).toBe(false)
|
||||
expect(numPages).toBe(1)
|
||||
|
||||
editor.setGridMode(true)
|
||||
|
||||
expect(isGridMode).toBe(true)
|
||||
expect(numPages).toBe(1)
|
||||
|
||||
editor.createPage('new page')
|
||||
|
||||
expect(isGridMode).toBe(true)
|
||||
expect(editor.pages.length).toBe(2)
|
||||
expect(numPages).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe(loadSessionStateSnapshotIntoStore, () => {
|
||||
it('loads a snapshot into the store', () => {
|
||||
let snapshot = createSessionStateSnapshotSignal(editor.store).value
|
||||
if (!snapshot) throw new Error('snapshot is null')
|
||||
|
||||
expect(editor.isGridMode).toBe(false)
|
||||
expect(editor.camera.x).toBe(0)
|
||||
expect(editor.camera.y).toBe(0)
|
||||
|
||||
snapshot = JSON.parse(JSON.stringify(snapshot)) as TLSessionStateSnapshot
|
||||
|
||||
snapshot.isGridMode = true
|
||||
snapshot.pageStates[0].camera.x = 1
|
||||
snapshot.pageStates[0].camera.y = 2
|
||||
|
||||
loadSessionStateSnapshotIntoStore(editor.store, snapshot)
|
||||
|
||||
expect(editor.isGridMode).toBe(true)
|
||||
expect(editor.camera.x).toBe(1)
|
||||
expect(editor.camera.y).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe(extractSessionStateFromLegacySnapshot, () => {
|
||||
it('pulls a snapshot from old data if it can', () => {
|
||||
const oldSnapshot = {
|
||||
'shape:whatever': {
|
||||
id: 'shape:whatever',
|
||||
typeName: 'shape',
|
||||
},
|
||||
'camera:whatever': {
|
||||
id: 'camera:whatever',
|
||||
typeName: 'camera',
|
||||
x: 1,
|
||||
y: 2,
|
||||
z: 3,
|
||||
},
|
||||
'page:whatever': {
|
||||
id: 'page:whatever',
|
||||
typeName: 'page',
|
||||
name: 'whatever',
|
||||
index: 'whatever',
|
||||
},
|
||||
'instance:whatever': {
|
||||
id: 'instance:whatever',
|
||||
typeName: 'instance',
|
||||
currentPageId: 'page:whatever',
|
||||
},
|
||||
'instance_page_state:whatever': {
|
||||
id: 'instance_page_state:whatever',
|
||||
typeName: 'instance_page_state',
|
||||
instanceId: 'instance:whatever',
|
||||
pageId: 'page:whatever',
|
||||
selectedIds: ['shape:whatever'],
|
||||
focusLayerId: null,
|
||||
},
|
||||
}
|
||||
|
||||
expect(extractSessionStateFromLegacySnapshot(oldSnapshot as any)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"currentPageId": "page:whatever",
|
||||
"exportBackground": false,
|
||||
"isDebugMode": false,
|
||||
"isFocusMode": false,
|
||||
"isGridMode": false,
|
||||
"isToolLocked": false,
|
||||
"pageStates": Array [
|
||||
Object {
|
||||
"camera": Object {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 1,
|
||||
},
|
||||
"focusLayerId": null,
|
||||
"pageId": "page:whatever",
|
||||
"selectedIds": Array [
|
||||
"shape:whatever",
|
||||
],
|
||||
},
|
||||
],
|
||||
"version": 0,
|
||||
}
|
||||
`)
|
||||
})
|
||||
})
|
323
packages/editor/src/lib/config/TLSessionStateSnapshot.ts
Normal file
323
packages/editor/src/lib/config/TLSessionStateSnapshot.ts
Normal file
|
@ -0,0 +1,323 @@
|
|||
import {
|
||||
RecordsDiff,
|
||||
UnknownRecord,
|
||||
defineMigrations,
|
||||
migrate,
|
||||
squashRecordDiffs,
|
||||
} from '@tldraw/store'
|
||||
import {
|
||||
CameraRecordType,
|
||||
InstancePageStateRecordType,
|
||||
InstanceRecordType,
|
||||
TLINSTANCE_ID,
|
||||
TLPageId,
|
||||
TLRecord,
|
||||
TLShapeId,
|
||||
TLStore,
|
||||
pageIdValidator,
|
||||
shapeIdValidator,
|
||||
} from '@tldraw/tlschema'
|
||||
import { objectMapFromEntries } from '@tldraw/utils'
|
||||
import { T } from '@tldraw/validate'
|
||||
import { Signal, computed, transact } from 'signia'
|
||||
import { uniqueId } from '../utils/data'
|
||||
|
||||
const tabIdKey = 'TLDRAW_TAB_ID_v2' as const
|
||||
|
||||
const window = globalThis.window as
|
||||
| {
|
||||
navigator: Window['navigator']
|
||||
localStorage: Window['localStorage']
|
||||
sessionStorage: Window['sessionStorage']
|
||||
addEventListener: Window['addEventListener']
|
||||
TLDRAW_TAB_ID_v2?: string
|
||||
}
|
||||
| undefined
|
||||
|
||||
// https://stackoverflow.com/a/9039885
|
||||
function iOS() {
|
||||
if (!window) return false
|
||||
return (
|
||||
['iPad Simulator', 'iPhone Simulator', 'iPod Simulator', 'iPad', 'iPhone', 'iPod'].includes(
|
||||
window.navigator.platform
|
||||
) ||
|
||||
// iPad on iOS 13 detection
|
||||
(window.navigator.userAgent.includes('Mac') && 'ontouchend' in document)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A string that is unique per browser tab
|
||||
* @public
|
||||
*/
|
||||
export const TAB_ID: string =
|
||||
window?.[tabIdKey] ?? window?.sessionStorage[tabIdKey] ?? `TLDRAW_INSTANCE_STATE_V1_` + uniqueId()
|
||||
if (window) {
|
||||
window[tabIdKey] = TAB_ID
|
||||
if (iOS()) {
|
||||
// iOS does not trigger beforeunload
|
||||
// so we need to keep the sessionStorage value around
|
||||
// and hope the user doesn't figure out a way to duplicate their tab
|
||||
// in which case they'll have two tabs with the same UI state.
|
||||
// It's not a big deal, but it's not ideal.
|
||||
// And anyway I can't see a way to duplicate a tab in iOS Safari.
|
||||
window.sessionStorage[tabIdKey] = TAB_ID
|
||||
} else {
|
||||
delete window.sessionStorage[tabIdKey]
|
||||
}
|
||||
}
|
||||
|
||||
window?.addEventListener('beforeunload', () => {
|
||||
window.sessionStorage[tabIdKey] = TAB_ID
|
||||
})
|
||||
|
||||
const Versions = {
|
||||
Initial: 0,
|
||||
} as const
|
||||
|
||||
export const CURRENT_SESSION_STATE_SNAPSHOT_VERSION = Versions.Initial
|
||||
|
||||
/**
|
||||
* The state of the editor instance, not including any document state.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface TLSessionStateSnapshot {
|
||||
version: number
|
||||
currentPageId: TLPageId
|
||||
isFocusMode: boolean
|
||||
exportBackground: boolean
|
||||
isDebugMode: boolean
|
||||
isToolLocked: boolean
|
||||
isGridMode: boolean
|
||||
pageStates: Array<{
|
||||
pageId: TLPageId
|
||||
camera: { x: number; y: number; z: number }
|
||||
selectedIds: TLShapeId[]
|
||||
focusLayerId: TLShapeId | null
|
||||
}>
|
||||
}
|
||||
|
||||
const sessionStateSnapshotValidator: T.Validator<TLSessionStateSnapshot> = T.object({
|
||||
version: T.number,
|
||||
currentPageId: pageIdValidator,
|
||||
isFocusMode: T.boolean,
|
||||
exportBackground: T.boolean,
|
||||
isDebugMode: T.boolean,
|
||||
isToolLocked: T.boolean,
|
||||
isGridMode: T.boolean,
|
||||
pageStates: T.arrayOf(
|
||||
T.object({
|
||||
pageId: pageIdValidator,
|
||||
camera: T.object({
|
||||
x: T.number,
|
||||
y: T.number,
|
||||
z: T.number,
|
||||
}),
|
||||
selectedIds: T.arrayOf(shapeIdValidator),
|
||||
focusLayerId: shapeIdValidator.nullable(),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
const sessionStateSnapshotMigrations = defineMigrations({
|
||||
currentVersion: CURRENT_SESSION_STATE_SNAPSHOT_VERSION,
|
||||
})
|
||||
|
||||
function migrateAndValidateSessionStateSnapshot(state: unknown): TLSessionStateSnapshot | null {
|
||||
if (!state || typeof state !== 'object') {
|
||||
console.warn('Invalid instance state')
|
||||
return null
|
||||
}
|
||||
if (!('version' in state) || typeof state.version !== 'number') {
|
||||
console.warn('No version in instance state')
|
||||
return null
|
||||
}
|
||||
const result = migrate<TLSessionStateSnapshot>({
|
||||
value: state,
|
||||
fromVersion: state.version,
|
||||
toVersion: CURRENT_SESSION_STATE_SNAPSHOT_VERSION,
|
||||
migrations: sessionStateSnapshotMigrations,
|
||||
})
|
||||
if (result.type === 'error') {
|
||||
console.warn(result.reason)
|
||||
return null
|
||||
}
|
||||
|
||||
const value = { ...result.value, version: CURRENT_SESSION_STATE_SNAPSHOT_VERSION }
|
||||
|
||||
try {
|
||||
sessionStateSnapshotValidator.validate(value)
|
||||
} catch (e) {
|
||||
console.warn(e)
|
||||
return null
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a signal of the instance state for a given store.
|
||||
* @public
|
||||
* @param store - The store to create the instance state snapshot signal for
|
||||
* @returns
|
||||
*/
|
||||
export function createSessionStateSnapshotSignal(
|
||||
store: TLStore
|
||||
): Signal<TLSessionStateSnapshot | null> {
|
||||
const $allPageIds = store.query.ids('page')
|
||||
|
||||
return computed<TLSessionStateSnapshot | null>('sessionStateSnapshot', () => {
|
||||
const instanceState = store.get(TLINSTANCE_ID)
|
||||
if (!instanceState) return null
|
||||
|
||||
const allPageIds = [...$allPageIds.value]
|
||||
return {
|
||||
version: CURRENT_SESSION_STATE_SNAPSHOT_VERSION,
|
||||
currentPageId: instanceState.currentPageId,
|
||||
exportBackground: instanceState.exportBackground,
|
||||
isFocusMode: instanceState.isFocusMode,
|
||||
isDebugMode: instanceState.isDebugMode,
|
||||
isToolLocked: instanceState.isToolLocked,
|
||||
isGridMode: instanceState.isGridMode,
|
||||
pageStates: allPageIds.map((id) => {
|
||||
const ps = store.get(InstancePageStateRecordType.createId(id))
|
||||
const camera = store.get(CameraRecordType.createId(id))
|
||||
return {
|
||||
pageId: id,
|
||||
camera: {
|
||||
x: camera?.x ?? 0,
|
||||
y: camera?.y ?? 0,
|
||||
z: camera?.z ?? 1,
|
||||
},
|
||||
selectedIds: ps?.selectedIds ?? [],
|
||||
focusLayerId: ps?.focusLayerId ?? null,
|
||||
} satisfies TLSessionStateSnapshot['pageStates'][0]
|
||||
}),
|
||||
} satisfies TLSessionStateSnapshot
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a snapshot of the editor's instance state into the store of a new editor instance.
|
||||
*
|
||||
* @public
|
||||
* @param store - The store to load the instance state into
|
||||
* @param snapshot - The instance state snapshot to load
|
||||
* @returns
|
||||
*/
|
||||
export function loadSessionStateSnapshotIntoStore(
|
||||
store: TLStore,
|
||||
snapshot: TLSessionStateSnapshot
|
||||
) {
|
||||
const res = migrateAndValidateSessionStateSnapshot(snapshot)
|
||||
if (!res) return
|
||||
|
||||
// remove all page states and cameras and the instance state
|
||||
const allPageStatesAndCameras = store
|
||||
.allRecords()
|
||||
.filter((r) => r.typeName === 'instance_page_state' || r.typeName === 'camera')
|
||||
|
||||
const removeDiff: RecordsDiff<TLRecord> = {
|
||||
added: {},
|
||||
updated: {},
|
||||
removed: {
|
||||
...objectMapFromEntries(allPageStatesAndCameras.map((r) => [r.id, r])),
|
||||
},
|
||||
}
|
||||
if (store.has(TLINSTANCE_ID)) {
|
||||
removeDiff.removed[TLINSTANCE_ID] = store.get(TLINSTANCE_ID)!
|
||||
}
|
||||
|
||||
const addDiff: RecordsDiff<TLRecord> = {
|
||||
removed: {},
|
||||
updated: {},
|
||||
added: {
|
||||
[TLINSTANCE_ID]: InstanceRecordType.create({
|
||||
id: TLINSTANCE_ID,
|
||||
currentPageId: res.currentPageId,
|
||||
isDebugMode: res.isDebugMode,
|
||||
isFocusMode: res.isFocusMode,
|
||||
isToolLocked: res.isToolLocked,
|
||||
isGridMode: res.isGridMode,
|
||||
exportBackground: res.exportBackground,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
// replace them with new ones
|
||||
for (const ps of res.pageStates) {
|
||||
const cameraId = CameraRecordType.createId(ps.pageId)
|
||||
const pageStateId = InstancePageStateRecordType.createId(ps.pageId)
|
||||
addDiff.added[cameraId] = CameraRecordType.create({
|
||||
id: CameraRecordType.createId(ps.pageId),
|
||||
x: ps.camera.x,
|
||||
y: ps.camera.y,
|
||||
z: ps.camera.z,
|
||||
})
|
||||
addDiff.added[pageStateId] = InstancePageStateRecordType.create({
|
||||
id: InstancePageStateRecordType.createId(ps.pageId),
|
||||
pageId: ps.pageId,
|
||||
selectedIds: ps.selectedIds,
|
||||
focusLayerId: ps.focusLayerId,
|
||||
})
|
||||
}
|
||||
|
||||
transact(() => {
|
||||
store.applyDiff(squashRecordDiffs([removeDiff, addDiff]))
|
||||
store.ensureStoreIsUsable()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function extractSessionStateFromLegacySnapshot(
|
||||
store: Record<string, UnknownRecord>
|
||||
): TLSessionStateSnapshot | null {
|
||||
const instanceRecords = []
|
||||
for (const record of Object.values(store)) {
|
||||
if (record.typeName?.match(/^(instance.*|pointer|camera)$/)) {
|
||||
instanceRecords.push(record)
|
||||
}
|
||||
}
|
||||
|
||||
// for scratch documents, we need to extract the most recently-used instance and it's associated page states
|
||||
// but oops we don't have the concept of "most recently-used" so we'll just take the first one
|
||||
const oldInstance = instanceRecords.filter(
|
||||
(r) => r.typeName === 'instance' && r.id !== TLINSTANCE_ID
|
||||
)[0] as any
|
||||
if (!oldInstance) return null
|
||||
|
||||
const result: TLSessionStateSnapshot = {
|
||||
version: CURRENT_SESSION_STATE_SNAPSHOT_VERSION,
|
||||
currentPageId: oldInstance.currentPageId,
|
||||
exportBackground: !!oldInstance.exportBackground,
|
||||
isFocusMode: !!oldInstance.isFocusMode,
|
||||
isDebugMode: !!oldInstance.isDebugMode,
|
||||
isToolLocked: !!oldInstance.isToolLocked,
|
||||
isGridMode: false,
|
||||
pageStates: instanceRecords
|
||||
.filter((r: any) => r.typeName === 'instance_page_state' && r.instanceId === oldInstance.id)
|
||||
.map((ps: any): TLSessionStateSnapshot['pageStates'][0] => {
|
||||
const camera = (store[ps.cameraId] as any) ?? { x: 0, y: 0, z: 1 }
|
||||
return {
|
||||
pageId: ps.pageId,
|
||||
camera: {
|
||||
x: camera.x,
|
||||
y: camera.y,
|
||||
z: camera.z,
|
||||
},
|
||||
selectedIds: ps.selectedIds,
|
||||
focusLayerId: ps.focusLayerId,
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
try {
|
||||
sessionStateSnapshotValidator.validate(result)
|
||||
return result
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
53
packages/editor/src/lib/config/TLUserPreferences.test.ts
Normal file
53
packages/editor/src/lib/config/TLUserPreferences.test.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
import { atom } from 'signia'
|
||||
import { TestEditor } from '../test/TestEditor'
|
||||
import { TLUserPreferences } from './TLUserPreferences'
|
||||
import { createTLUser } from './createTLUser'
|
||||
|
||||
let editor: TestEditor
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new TestEditor()
|
||||
})
|
||||
|
||||
describe('TLUserPreferences', () => {
|
||||
it('allows updating user preferences on the editor', () => {
|
||||
expect(editor.isSnapMode).toBe(false)
|
||||
|
||||
editor.user.updateUserPreferences({ isSnapMode: true })
|
||||
|
||||
expect(editor.isSnapMode).toBe(true)
|
||||
})
|
||||
|
||||
it('can be customized', () => {
|
||||
const userPreferences = atom<TLUserPreferences>('userPreferences', {
|
||||
animationSpeed: 1,
|
||||
color: '#000000',
|
||||
id: '123',
|
||||
isDarkMode: true,
|
||||
isSnapMode: false,
|
||||
locale: 'en',
|
||||
name: 'test',
|
||||
})
|
||||
|
||||
editor = new TestEditor({
|
||||
user: createTLUser({
|
||||
setUserPreferences: (preferences) => userPreferences.set(preferences),
|
||||
userPreferences,
|
||||
}),
|
||||
})
|
||||
|
||||
expect(editor.isDarkMode).toBe(true)
|
||||
|
||||
userPreferences.set({
|
||||
...userPreferences.value,
|
||||
isDarkMode: false,
|
||||
})
|
||||
|
||||
expect(editor.isDarkMode).toBe(false)
|
||||
|
||||
editor.setDarkMode(true)
|
||||
|
||||
expect(editor.isDarkMode).toBe(true)
|
||||
expect(userPreferences.value.isDarkMode).toBe(true)
|
||||
})
|
||||
})
|
|
@ -18,6 +18,7 @@ export interface TLUserPreferences {
|
|||
color: string
|
||||
isDarkMode: boolean
|
||||
animationSpeed: number
|
||||
isSnapMode: boolean
|
||||
}
|
||||
|
||||
interface UserDataSnapshot {
|
||||
|
@ -38,14 +39,16 @@ const userTypeValidator: T.Validator<TLUserPreferences> = T.object<TLUserPrefere
|
|||
color: T.string,
|
||||
isDarkMode: T.boolean,
|
||||
animationSpeed: T.number,
|
||||
isSnapMode: T.boolean,
|
||||
})
|
||||
|
||||
const Versions = {
|
||||
AddAnimationSpeed: 1,
|
||||
AddIsSnapMode: 2,
|
||||
} as const
|
||||
|
||||
const userMigrations = defineMigrations({
|
||||
currentVersion: 1,
|
||||
currentVersion: Versions.AddIsSnapMode,
|
||||
migrators: {
|
||||
[Versions.AddAnimationSpeed]: {
|
||||
up: (user) => {
|
||||
|
@ -58,6 +61,14 @@ const userMigrations = defineMigrations({
|
|||
return user
|
||||
},
|
||||
},
|
||||
[Versions.AddIsSnapMode]: {
|
||||
up: (user: TLUserPreferences) => {
|
||||
return { ...user, isSnapMode: false }
|
||||
},
|
||||
down: ({ isSnapMode: _, ...user }: TLUserPreferences) => {
|
||||
return user
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -90,6 +101,7 @@ function getFreshUserPreferences(): TLUserPreferences {
|
|||
// TODO: detect dark mode
|
||||
isDarkMode: false,
|
||||
animationSpeed: 1,
|
||||
isSnapMode: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,12 +1,5 @@
|
|||
import { Migrations, Store, StoreSnapshot } from '@tldraw/store'
|
||||
import {
|
||||
InstanceRecordType,
|
||||
TLDOCUMENT_ID,
|
||||
TLInstanceId,
|
||||
TLRecord,
|
||||
TLStore,
|
||||
createTLSchema,
|
||||
} from '@tldraw/tlschema'
|
||||
import { TLRecord, TLStore, createTLSchema } from '@tldraw/tlschema'
|
||||
import { TLShapeUtilConstructor } from '../app/shapeutils/ShapeUtil'
|
||||
|
||||
/** @public */
|
||||
|
@ -19,7 +12,6 @@ export type TLShapeInfo = {
|
|||
/** @public */
|
||||
export type TLStoreOptions = {
|
||||
customShapes?: Record<string, TLShapeInfo>
|
||||
instanceId?: TLInstanceId
|
||||
initialData?: StoreSnapshot<TLRecord>
|
||||
defaultName?: string
|
||||
}
|
||||
|
@ -31,19 +23,12 @@ export type TLStoreOptions = {
|
|||
*
|
||||
* @public */
|
||||
export function createTLStore(opts = {} as TLStoreOptions): TLStore {
|
||||
const {
|
||||
customShapes = {},
|
||||
instanceId = InstanceRecordType.createId(),
|
||||
initialData,
|
||||
defaultName = '',
|
||||
} = opts
|
||||
const { customShapes = {}, initialData, defaultName = '' } = opts
|
||||
|
||||
return new Store({
|
||||
schema: createTLSchema({ customShapes }),
|
||||
initialData,
|
||||
props: {
|
||||
instanceId,
|
||||
documentId: TLDOCUMENT_ID,
|
||||
defaultName,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -7,9 +7,9 @@ import { useTLStore } from './useTLStore'
|
|||
|
||||
/** @internal */
|
||||
export function useLocalStore(
|
||||
opts = {} as { persistenceKey?: string } & TLStoreOptions
|
||||
opts = {} as { persistenceKey?: string; sessionId?: string } & TLStoreOptions
|
||||
): TLStoreWithStatus {
|
||||
const { persistenceKey, ...rest } = opts
|
||||
const { persistenceKey, sessionId, ...rest } = opts
|
||||
|
||||
const [state, setState] = useState<{ id: string; storeWithStatus: TLStoreWithStatus } | null>(
|
||||
null
|
||||
|
@ -42,7 +42,8 @@ export function useLocalStore(
|
|||
}
|
||||
|
||||
const client = new TLLocalSyncClient(store, {
|
||||
universalPersistenceKey: persistenceKey,
|
||||
sessionId,
|
||||
persistenceKey,
|
||||
onLoad() {
|
||||
setStoreWithStatus({ store, status: 'synced-local' })
|
||||
},
|
||||
|
@ -55,7 +56,7 @@ export function useLocalStore(
|
|||
setState((prevState) => (prevState?.id === id ? null : prevState))
|
||||
client.close()
|
||||
}
|
||||
}, [persistenceKey, store])
|
||||
}, [persistenceKey, store, sessionId])
|
||||
|
||||
return state?.storeWithStatus ?? { status: 'loading' }
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ const ids = {
|
|||
frame1: createShapeId('frame1'),
|
||||
group1: createShapeId('group1'),
|
||||
|
||||
page2: PageRecordType.createCustomId('page2'),
|
||||
page2: PageRecordType.createId('page2'),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
|
@ -52,7 +52,7 @@ declare global {
|
|||
}
|
||||
}
|
||||
}
|
||||
export const TEST_INSTANCE_ID = InstanceRecordType.createCustomId('testInstance1')
|
||||
export const TEST_INSTANCE_ID = InstanceRecordType.createId('testInstance1')
|
||||
|
||||
export class TestEditor extends Editor {
|
||||
constructor(options = {} as Partial<Omit<TLEditorOptions, 'store'>>) {
|
||||
|
@ -63,7 +63,6 @@ export class TestEditor extends Editor {
|
|||
shapes: { ...defaultShapes, ...shapes },
|
||||
tools: [...defaultTools, ...tools],
|
||||
store: createTLStore({
|
||||
instanceId: TEST_INSTANCE_ID,
|
||||
customShapes: shapes,
|
||||
}),
|
||||
getContainer: () => elm,
|
||||
|
@ -190,7 +189,7 @@ export class TestEditor extends Editor {
|
|||
return createShapeId(id)
|
||||
}
|
||||
testPageID(id: string) {
|
||||
return PageRecordType.createCustomId(id)
|
||||
return PageRecordType.createId(id)
|
||||
}
|
||||
|
||||
expectToBeIn = (path: string) => {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { act, render, screen } from '@testing-library/react'
|
||||
import { InstanceRecordType, TLBaseShape, TLOpacityType, createShapeId } from '@tldraw/tlschema'
|
||||
import { TLBaseShape, TLOpacityType, createShapeId } from '@tldraw/tlschema'
|
||||
import { TldrawEditor } from '../TldrawEditor'
|
||||
import { Editor } from '../app/Editor'
|
||||
import { BaseBoxShapeUtil } from '../app/shapeutils/BaseBoxShapeUtil'
|
||||
|
@ -64,9 +64,7 @@ describe('<TldrawEditor />', () => {
|
|||
})
|
||||
|
||||
it('Accepts fresh versions of store and calls `onMount` for each one', async () => {
|
||||
const initialStore = createTLStore({
|
||||
instanceId: InstanceRecordType.createCustomId('test'),
|
||||
})
|
||||
const initialStore = createTLStore({})
|
||||
const onMount = jest.fn()
|
||||
const rendered = render(
|
||||
<TldrawEditor store={initialStore} onMount={onMount} autoFocus>
|
||||
|
@ -87,9 +85,7 @@ describe('<TldrawEditor />', () => {
|
|||
// not called again:
|
||||
expect(onMount).toHaveBeenCalledTimes(1)
|
||||
// re-render with a new store:
|
||||
const newStore = createTLStore({
|
||||
instanceId: InstanceRecordType.createCustomId('test'),
|
||||
})
|
||||
const newStore = createTLStore({})
|
||||
rendered.rerender(
|
||||
<TldrawEditor store={newStore} onMount={onMount} autoFocus>
|
||||
<div data-testid="canvas-3" />
|
||||
|
|
|
@ -36,17 +36,17 @@ it('[regression] does not die if every page has the same index', () => {
|
|||
editor.store.put([
|
||||
{
|
||||
...page,
|
||||
id: PageRecordType.createCustomId('2'),
|
||||
id: PageRecordType.createId('2'),
|
||||
name: 'a',
|
||||
},
|
||||
{
|
||||
...page,
|
||||
id: PageRecordType.createCustomId('3'),
|
||||
id: PageRecordType.createId('3'),
|
||||
name: 'b',
|
||||
},
|
||||
{
|
||||
...page,
|
||||
id: PageRecordType.createCustomId('4'),
|
||||
id: PageRecordType.createId('4'),
|
||||
name: 'c',
|
||||
},
|
||||
])
|
||||
|
|
|
@ -9,7 +9,7 @@ beforeEach(() => {
|
|||
|
||||
describe('deletePage', () => {
|
||||
it('deletes the page', () => {
|
||||
const page2Id = PageRecordType.createCustomId('page2')
|
||||
const page2Id = PageRecordType.createId('page2')
|
||||
editor.createPage('New Page 2', page2Id)
|
||||
|
||||
const pages = editor.pages
|
||||
|
@ -19,7 +19,7 @@ describe('deletePage', () => {
|
|||
expect(editor.pages[0]).toEqual(pages[1])
|
||||
})
|
||||
it('is undoable and redoable', () => {
|
||||
const page2Id = PageRecordType.createCustomId('page2')
|
||||
const page2Id = PageRecordType.createId('page2')
|
||||
editor.mark()
|
||||
editor.createPage('New Page 2', page2Id)
|
||||
|
||||
|
@ -38,7 +38,7 @@ describe('deletePage', () => {
|
|||
expect(editor.pages[0]).toEqual(pages[1])
|
||||
})
|
||||
it('does not allow deleting all pages', () => {
|
||||
const page2Id = PageRecordType.createCustomId('page2')
|
||||
const page2Id = PageRecordType.createId('page2')
|
||||
editor.mark()
|
||||
editor.createPage('New Page 2', page2Id)
|
||||
|
||||
|
@ -52,7 +52,7 @@ describe('deletePage', () => {
|
|||
expect(editor.pages.length).toBe(1)
|
||||
})
|
||||
it('switches the page if you are deleting the current page', () => {
|
||||
const page2Id = PageRecordType.createCustomId('page2')
|
||||
const page2Id = PageRecordType.createId('page2')
|
||||
editor.mark()
|
||||
editor.createPage('New Page 2', page2Id)
|
||||
|
||||
|
@ -64,7 +64,7 @@ describe('deletePage', () => {
|
|||
})
|
||||
it('switches the page if another user or tab deletes the current page', () => {
|
||||
const currentPageId = editor.currentPageId
|
||||
const page2Id = PageRecordType.createCustomId('page2')
|
||||
const page2Id = PageRecordType.createId('page2')
|
||||
editor.mark()
|
||||
editor.createPage('New Page 2', page2Id)
|
||||
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
import { InstanceRecordType, PageRecordType, createShapeId } from '@tldraw/tlschema'
|
||||
import { TEST_INSTANCE_ID, TestEditor } from '../TestEditor'
|
||||
|
||||
let editor: TestEditor
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new TestEditor()
|
||||
})
|
||||
|
||||
describe('running any commands', () => {
|
||||
it('sets the lastUsedTabId and lastUpdatedPageId', () => {
|
||||
expect(editor.userDocumentSettings.lastUsedTabId).toBe(null)
|
||||
expect(editor.userDocumentSettings.lastUpdatedPageId).toBe(null)
|
||||
|
||||
editor.createShapes([{ type: 'geo', id: createShapeId('geo'), parentId: editor.currentPageId }])
|
||||
|
||||
expect(editor.userDocumentSettings.lastUsedTabId).toBe(TEST_INSTANCE_ID)
|
||||
expect(editor.userDocumentSettings.lastUpdatedPageId).toBe(editor.currentPageId)
|
||||
|
||||
editor.store.put([
|
||||
{
|
||||
...editor.userDocumentSettings,
|
||||
lastUsedTabId: InstanceRecordType.createCustomId('nope'),
|
||||
lastUpdatedPageId: PageRecordType.createCustomId('nope'),
|
||||
},
|
||||
])
|
||||
|
||||
expect(editor.userDocumentSettings.lastUsedTabId).toBe(
|
||||
InstanceRecordType.createCustomId('nope')
|
||||
)
|
||||
expect(editor.userDocumentSettings.lastUpdatedPageId).toBe(
|
||||
PageRecordType.createCustomId('nope')
|
||||
)
|
||||
|
||||
editor.createShapes([{ type: 'geo', id: createShapeId('geo'), parentId: editor.currentPageId }])
|
||||
|
||||
expect(editor.userDocumentSettings.lastUsedTabId).toBe(TEST_INSTANCE_ID)
|
||||
expect(editor.userDocumentSettings.lastUpdatedPageId).toBe(editor.currentPageId)
|
||||
})
|
||||
})
|
|
@ -8,8 +8,8 @@ const ids = {
|
|||
box2: createShapeId('box2'),
|
||||
ellipse1: createShapeId('ellipse1'),
|
||||
ellipse2: createShapeId('ellipse2'),
|
||||
page1: PageRecordType.createCustomId('page1'),
|
||||
page2: PageRecordType.createCustomId('page2'),
|
||||
page1: PageRecordType.createId('page1'),
|
||||
page2: PageRecordType.createId('page2'),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
@ -68,7 +68,7 @@ describe('Editor.moveShapesToPage', () => {
|
|||
|
||||
it('Does nothing if the new page is not found or is deleted', () => {
|
||||
editor.history.clear()
|
||||
editor.moveShapesToPage([ids.box1], PageRecordType.createCustomId('missing'))
|
||||
editor.moveShapesToPage([ids.box1], PageRecordType.createId('missing'))
|
||||
expect(editor.history.numUndos).toBe(0)
|
||||
})
|
||||
|
||||
|
@ -101,7 +101,7 @@ describe('Editor.moveShapesToPage', () => {
|
|||
|
||||
it('Sets the correct indices', () => {
|
||||
editor = new TestEditor()
|
||||
const page2Id = PageRecordType.createCustomId('newPage2')
|
||||
const page2Id = PageRecordType.createId('newPage2')
|
||||
|
||||
editor.createPage('New Page 2', page2Id)
|
||||
expect(editor.currentPageId).toBe(page2Id)
|
||||
|
@ -112,7 +112,7 @@ describe('Editor.moveShapesToPage', () => {
|
|||
index: 'a1',
|
||||
})
|
||||
|
||||
const page3Id = PageRecordType.createCustomId('newPage3')
|
||||
const page3Id = PageRecordType.createId('newPage3')
|
||||
editor.createPage('New Page 3', page3Id)
|
||||
expect(editor.currentPageId).toBe(page3Id)
|
||||
editor.createShapes([{ id: ids.box2, type: 'geo', x: 0, y: 0, props: { geo: 'ellipse' } }])
|
||||
|
|
|
@ -176,9 +176,7 @@ describe('When a shape is selected...', () => {
|
|||
|
||||
describe('When grid is enabled...', () => {
|
||||
it('nudges a shape correctly', () => {
|
||||
editor.updateUserDocumentSettings({
|
||||
isGridMode: true,
|
||||
})
|
||||
editor.setGridMode(true)
|
||||
editor.setSelectedIds([ids.boxA])
|
||||
|
||||
expect(nudgeAndGet([ids.boxA], 'ArrowUp', false)).toMatchObject([{ x: 10, y: 0 }])
|
||||
|
@ -188,9 +186,7 @@ describe('When grid is enabled...', () => {
|
|||
})
|
||||
|
||||
it('nudges a shape with shift key pressed', () => {
|
||||
editor.updateUserDocumentSettings({
|
||||
isGridMode: true,
|
||||
})
|
||||
editor.setGridMode(true)
|
||||
editor.setSelectedIds([ids.boxA])
|
||||
|
||||
expect(nudgeAndGet([ids.boxA], 'ArrowUp', true)).toMatchObject([{ x: 10, y: -40 }])
|
||||
|
|
|
@ -10,7 +10,7 @@ beforeEach(() => {
|
|||
describe('setCurrentPage', () => {
|
||||
it('sets the current page', () => {
|
||||
const page1Id = editor.pages[0].id
|
||||
const page2Id = PageRecordType.createCustomId('page2')
|
||||
const page2Id = PageRecordType.createId('page2')
|
||||
|
||||
editor.createPage('New Page 2', page2Id)
|
||||
expect(editor.currentPageId).toEqual(page2Id)
|
||||
|
@ -20,7 +20,7 @@ describe('setCurrentPage', () => {
|
|||
|
||||
expect(editor.currentPage).toEqual(editor.pages[0])
|
||||
|
||||
const page3Id = PageRecordType.createCustomId('page3')
|
||||
const page3Id = PageRecordType.createId('page3')
|
||||
editor.createPage('New Page 3', page3Id)
|
||||
|
||||
expect(editor.currentPageId).toEqual(page3Id)
|
||||
|
@ -49,7 +49,7 @@ describe('setCurrentPage', () => {
|
|||
})
|
||||
|
||||
it('squashes', () => {
|
||||
const page2Id = PageRecordType.createCustomId('page2')
|
||||
const page2Id = PageRecordType.createId('page2')
|
||||
editor.createPage('New Page 2', page2Id)
|
||||
|
||||
editor.history.clear()
|
||||
|
@ -61,7 +61,7 @@ describe('setCurrentPage', () => {
|
|||
|
||||
it('preserves the undo stack', () => {
|
||||
const boxId = createShapeId('geo')
|
||||
const page2Id = PageRecordType.createCustomId('page2')
|
||||
const page2Id = PageRecordType.createId('page2')
|
||||
editor.createPage('New Page 2', page2Id)
|
||||
|
||||
editor.history.clear()
|
||||
|
@ -77,7 +77,7 @@ describe('setCurrentPage', () => {
|
|||
})
|
||||
|
||||
it('logs an error when trying to navigate to a page that does not exist', () => {
|
||||
const page2Id = PageRecordType.createCustomId('page2')
|
||||
const page2Id = PageRecordType.createId('page2')
|
||||
editor.createPage('New Page 2', page2Id)
|
||||
console.error = jest.fn()
|
||||
|
||||
|
|
|
@ -2944,7 +2944,7 @@ describe('snapping while the grid is enabled', () => {
|
|||
|
||||
editor.createShapes([box(ids.boxA, 0, 0, 20, 20), box(ids.boxB, 60, 0, 20, 20)])
|
||||
|
||||
editor.updateUserDocumentSettings({ isGridMode: true })
|
||||
editor.setGridMode(true)
|
||||
|
||||
// try to move right side of A to left side of B
|
||||
// doesn't work because of the grid
|
||||
|
|
|
@ -1706,7 +1706,7 @@ describe('translating while the grid is enabled', () => {
|
|||
// └───┘ └───┘
|
||||
editor.createShapes([box(ids.box1, 0, 0, 20, 20), box(ids.box2, 50, 0, 20, 20)])
|
||||
|
||||
editor.updateUserDocumentSettings({ isGridMode: true })
|
||||
editor.setGridMode(true)
|
||||
|
||||
// try to snap A to B
|
||||
// doesn't work because of the grid
|
||||
|
|
|
@ -166,7 +166,7 @@ export async function getMediaAssetFromFile(file: File): Promise<TLAsset> {
|
|||
dataUrl = await getResizedImageDataUrl(dataUrl, size.w, size.h)
|
||||
}
|
||||
|
||||
const assetId: TLAssetId = AssetRecordType.createCustomId(getHashForString(dataUrl))
|
||||
const assetId: TLAssetId = AssetRecordType.createId(getHashForString(dataUrl))
|
||||
|
||||
const metadata = await getFileMetaData(file)
|
||||
|
||||
|
@ -420,7 +420,7 @@ export function createEmbedShapeAtPoint(
|
|||
* @public
|
||||
*/
|
||||
export async function createBookmarkShapeAtPoint(editor: Editor, url: string, point: Vec2dModel) {
|
||||
const assetId: TLAssetId = AssetRecordType.createCustomId(getHashForString(url))
|
||||
const assetId: TLAssetId = AssetRecordType.createId(getHashForString(url))
|
||||
const existing = editor.getAssetById(assetId) as TLBookmarkAsset
|
||||
|
||||
if (existing) {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { InstanceRecordType, PageRecordType, TLInstanceId } from '@tldraw/tlschema'
|
||||
import { PageRecordType } from '@tldraw/tlschema'
|
||||
import { promiseWithResolve } from '@tldraw/utils'
|
||||
import { createTLStore } from '../../config/createTLStore'
|
||||
import { TLLocalSyncClient } from './TLLocalSyncClient'
|
||||
|
@ -23,13 +23,8 @@ class BroadcastChannelMock {
|
|||
})
|
||||
}
|
||||
|
||||
function testClient(
|
||||
instanceId: TLInstanceId = InstanceRecordType.createCustomId('test'),
|
||||
channel = new BroadcastChannelMock('test')
|
||||
) {
|
||||
const store = createTLStore({
|
||||
instanceId,
|
||||
})
|
||||
function testClient(channel = new BroadcastChannelMock('test')) {
|
||||
const store = createTLStore()
|
||||
const onLoad = jest.fn(() => {
|
||||
return
|
||||
})
|
||||
|
@ -41,7 +36,7 @@ function testClient(
|
|||
{
|
||||
onLoad,
|
||||
onLoadError,
|
||||
universalPersistenceKey: 'test',
|
||||
persistenceKey: 'test',
|
||||
},
|
||||
channel
|
||||
)
|
||||
|
|
|
@ -1,7 +1,20 @@
|
|||
import { RecordsDiff, SerializedSchema, compareSchemas, squashRecordDiffs } from '@tldraw/store'
|
||||
import { TLInstanceId, TLRecord, TLStore } from '@tldraw/tlschema'
|
||||
import { assert, hasOwnProperty } from '@tldraw/utils'
|
||||
import { transact } from 'signia'
|
||||
import {
|
||||
RecordsDiff,
|
||||
SerializedSchema,
|
||||
UnknownRecord,
|
||||
compareSchemas,
|
||||
squashRecordDiffs,
|
||||
} from '@tldraw/store'
|
||||
import { TLStore } from '@tldraw/tlschema'
|
||||
import { assert } from '@tldraw/utils'
|
||||
import { Signal, transact } from 'signia'
|
||||
import {
|
||||
TAB_ID,
|
||||
TLSessionStateSnapshot,
|
||||
createSessionStateSnapshotSignal,
|
||||
extractSessionStateFromLegacySnapshot,
|
||||
loadSessionStateSnapshotIntoStore,
|
||||
} from '../../config/TLSessionStateSnapshot'
|
||||
import { showCantReadFromIndexDbAlert, showCantWriteToIndexDbAlert } from './alerts'
|
||||
import { loadDataFromStore, storeChangesInIndexedDb, storeSnapshotInIndexedDb } from './indexedDb'
|
||||
|
||||
|
@ -10,6 +23,8 @@ const PERSIST_THROTTLE_MS = 350
|
|||
/** If we're in an error state, how long should we wait before retrying a write? */
|
||||
const PERSIST_RETRY_THROTTLE_MS = 10_000
|
||||
|
||||
const UPDATE_INSTANCE_STATE = Symbol('UPDATE_INSTANCE_STATE')
|
||||
|
||||
/**
|
||||
* IMPORTANT!!!
|
||||
*
|
||||
|
@ -19,8 +34,8 @@ const PERSIST_RETRY_THROTTLE_MS = 10_000
|
|||
|
||||
type SyncMessage = {
|
||||
type: 'diff'
|
||||
instanceId: TLInstanceId
|
||||
changes: RecordsDiff<any>
|
||||
storeId: string
|
||||
changes: RecordsDiff<UnknownRecord>
|
||||
schema: SerializedSchema
|
||||
}
|
||||
|
||||
|
@ -34,6 +49,8 @@ type AnnounceMessage = {
|
|||
|
||||
type Message = SyncMessage | AnnounceMessage
|
||||
|
||||
type UnpackPromise<T> = T extends Promise<infer U> ? U : T
|
||||
|
||||
const msg = (msg: Message) => msg
|
||||
|
||||
/** @internal */
|
||||
|
@ -55,13 +72,16 @@ const BC = typeof BroadcastChannel === 'undefined' ? BroadcastChannelMock : Broa
|
|||
/** @internal */
|
||||
export class TLLocalSyncClient {
|
||||
private disposables = new Set<() => void>()
|
||||
private diffQueue: RecordsDiff<any>[] = []
|
||||
private diffQueue: Array<RecordsDiff<UnknownRecord> | typeof UPDATE_INSTANCE_STATE> = []
|
||||
private didDispose = false
|
||||
private shouldDoFullDBWrite = true
|
||||
private isReloading = false
|
||||
readonly universalPersistenceKey: string
|
||||
readonly persistenceKey: string
|
||||
readonly sessionId: string
|
||||
readonly serializedSchema: SerializedSchema
|
||||
private isDebugging = false
|
||||
private readonly documentTypes: ReadonlySet<string>
|
||||
private readonly $sessionStateSnapshot: Signal<TLSessionStateSnapshot | null>
|
||||
|
||||
initTime = Date.now()
|
||||
private debug(...args: any[]) {
|
||||
|
@ -73,59 +93,77 @@ export class TLLocalSyncClient {
|
|||
constructor(
|
||||
public readonly store: TLStore,
|
||||
{
|
||||
universalPersistenceKey,
|
||||
persistenceKey,
|
||||
sessionId = TAB_ID,
|
||||
onLoad,
|
||||
onLoadError,
|
||||
}: {
|
||||
universalPersistenceKey: string
|
||||
persistenceKey: string
|
||||
sessionId?: string
|
||||
onLoad: (self: TLLocalSyncClient) => void
|
||||
onLoadError: (error: Error) => void
|
||||
},
|
||||
public readonly channel = new BC(`tldraw-tab-sync-${universalPersistenceKey}`)
|
||||
public readonly channel = new BC(`tldraw-tab-sync-${persistenceKey}`)
|
||||
) {
|
||||
if (typeof window !== 'undefined') {
|
||||
;(window as any).tlsync = this
|
||||
}
|
||||
this.universalPersistenceKey = universalPersistenceKey
|
||||
this.persistenceKey = persistenceKey
|
||||
this.sessionId = sessionId
|
||||
|
||||
this.serializedSchema = this.store.schema.serialize()
|
||||
this.$sessionStateSnapshot = createSessionStateSnapshotSignal(this.store)
|
||||
|
||||
this.disposables.add(
|
||||
// Set up a subscription to changes from the store: When
|
||||
// the store changes (and if the change was made by the user)
|
||||
// then immediately send the diff to other tabs via postMessage
|
||||
// and schedule a persist.
|
||||
store.listen(({ changes, source }) => {
|
||||
this.debug('changes', changes, source)
|
||||
if (source === 'user') {
|
||||
store.listen(
|
||||
({ changes }) => {
|
||||
this.diffQueue.push(changes)
|
||||
this.channel.postMessage(
|
||||
msg({
|
||||
type: 'diff',
|
||||
instanceId: this.store.props.instanceId,
|
||||
storeId: this.store.id,
|
||||
changes,
|
||||
schema: this.serializedSchema,
|
||||
})
|
||||
)
|
||||
this.schedulePersist()
|
||||
}
|
||||
})
|
||||
},
|
||||
{ source: 'user', scope: 'document' }
|
||||
)
|
||||
)
|
||||
this.disposables.add(
|
||||
store.listen(
|
||||
() => {
|
||||
this.diffQueue.push(UPDATE_INSTANCE_STATE)
|
||||
this.schedulePersist()
|
||||
},
|
||||
{ scope: 'session' }
|
||||
)
|
||||
)
|
||||
|
||||
this.connect(onLoad, onLoadError)
|
||||
|
||||
this.documentTypes = new Set(
|
||||
Object.values(this.store.schema.types)
|
||||
.filter((t) => t.scope === 'document')
|
||||
.map((t) => t.typeName)
|
||||
)
|
||||
}
|
||||
|
||||
private async connect(onLoad: (client: this) => void, onLoadError: (error: Error) => void) {
|
||||
this.debug('connecting')
|
||||
let data:
|
||||
| {
|
||||
records: TLRecord[]
|
||||
schema?: SerializedSchema
|
||||
}
|
||||
| undefined
|
||||
let data: UnpackPromise<ReturnType<typeof loadDataFromStore>> | undefined
|
||||
|
||||
try {
|
||||
data = await loadDataFromStore(this.universalPersistenceKey)
|
||||
data = await loadDataFromStore({
|
||||
persistenceKey: this.persistenceKey,
|
||||
sessionId: this.sessionId,
|
||||
didCancel: () => this.didDispose,
|
||||
})
|
||||
} catch (error: any) {
|
||||
onLoadError(error)
|
||||
showCantReadFromIndexDbAlert()
|
||||
|
@ -140,9 +178,11 @@ export class TLLocalSyncClient {
|
|||
|
||||
try {
|
||||
if (data) {
|
||||
const snapshot = Object.fromEntries(data.records.map((r) => [r.id, r]))
|
||||
const documentSnapshot = Object.fromEntries(data.records.map((r) => [r.id, r]))
|
||||
const sessionStateSnapshot =
|
||||
data.sessionStateSnapshot ?? extractSessionStateFromLegacySnapshot(documentSnapshot)
|
||||
const migrationResult = this.store.schema.migrateStoreSnapshot(
|
||||
snapshot,
|
||||
documentSnapshot,
|
||||
data.schema ?? this.store.schema.serializeEarliestVersion()
|
||||
)
|
||||
|
||||
|
@ -156,18 +196,20 @@ export class TLLocalSyncClient {
|
|||
this.store.mergeRemoteChanges(() => {
|
||||
// Calling put will validate the records!
|
||||
this.store.put(
|
||||
Object.values(migrationResult.value).filter(
|
||||
(r) => this.store.schema.types[r.typeName].scope !== 'presence'
|
||||
),
|
||||
Object.values(migrationResult.value).filter((r) => this.documentTypes.has(r.typeName)),
|
||||
'initialize'
|
||||
)
|
||||
})
|
||||
|
||||
if (sessionStateSnapshot) {
|
||||
loadSessionStateSnapshotIntoStore(this.store, sessionStateSnapshot)
|
||||
}
|
||||
}
|
||||
|
||||
this.channel.onmessage = ({ data }) => {
|
||||
this.debug('got message', data)
|
||||
const msg = data as Message
|
||||
// if their schema is eralier than ours, we need to tell them so they can refresh
|
||||
// if their schema is earlier than ours, we need to tell them so they can refresh
|
||||
// if their schema is later than ours, we need to refresh
|
||||
const comparison = compareSchemas(
|
||||
this.serializedSchema,
|
||||
|
@ -175,7 +217,7 @@ export class TLLocalSyncClient {
|
|||
)
|
||||
if (comparison === -1) {
|
||||
// we are older, refresh
|
||||
// but add a safety check to make sure we don't get in an infnite loop
|
||||
// but add a safety check to make sure we don't get in an infinite loop
|
||||
const timeSinceInit = Date.now() - this.initTime
|
||||
if (timeSinceInit < 5000) {
|
||||
// This tab was just reloaded, but is out of date compared to other tabs.
|
||||
|
@ -202,17 +244,11 @@ export class TLLocalSyncClient {
|
|||
// otherwise, all good, same version :)
|
||||
if (msg.type === 'diff') {
|
||||
this.debug('applying diff')
|
||||
const doesDeleteInstance = hasOwnProperty(
|
||||
msg.changes.removed,
|
||||
this.store.props.instanceId
|
||||
)
|
||||
transact(() => {
|
||||
this.store.mergeRemoteChanges(() => {
|
||||
this.store.applyDiff(msg.changes)
|
||||
})
|
||||
if (doesDeleteInstance) {
|
||||
this.store.applyDiff(msg.changes as any)
|
||||
this.store.ensureStoreIsUsable()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -258,7 +294,7 @@ export class TLLocalSyncClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* Persist to indexeddb only under certain circumstances:
|
||||
* Persist to IndexedDB only under certain circumstances:
|
||||
*
|
||||
* - If we're not already persisting
|
||||
* - If we're not reloading the page
|
||||
|
@ -300,7 +336,7 @@ export class TLLocalSyncClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* Actually persist to indexeddb. If the write fails, then we'll retry with a full db write after
|
||||
* Actually persist to IndexedDB. If the write fails, then we'll retry with a full db write after
|
||||
* a short delay.
|
||||
*/
|
||||
private async doPersist() {
|
||||
|
@ -317,17 +353,26 @@ export class TLLocalSyncClient {
|
|||
try {
|
||||
if (this.shouldDoFullDBWrite) {
|
||||
this.shouldDoFullDBWrite = false
|
||||
await storeSnapshotInIndexedDb(
|
||||
this.universalPersistenceKey,
|
||||
this.store.schema,
|
||||
this.store.serialize(),
|
||||
{
|
||||
didCancel: () => this.didDispose,
|
||||
}
|
||||
)
|
||||
await storeSnapshotInIndexedDb({
|
||||
persistenceKey: this.persistenceKey,
|
||||
schema: this.store.schema,
|
||||
snapshot: this.store.serialize(),
|
||||
didCancel: () => this.didDispose,
|
||||
sessionId: this.sessionId,
|
||||
sessionStateSnapshot: this.$sessionStateSnapshot.value,
|
||||
})
|
||||
} else {
|
||||
const diffs = squashRecordDiffs(diffQueue)
|
||||
await storeChangesInIndexedDb(this.universalPersistenceKey, this.store.schema, diffs)
|
||||
const diffs = squashRecordDiffs(
|
||||
diffQueue.filter((d): d is RecordsDiff<UnknownRecord> => d !== UPDATE_INSTANCE_STATE)
|
||||
)
|
||||
await storeChangesInIndexedDb({
|
||||
persistenceKey: this.persistenceKey,
|
||||
changes: diffs,
|
||||
schema: this.store.schema,
|
||||
didCancel: () => this.didDispose,
|
||||
sessionId: this.sessionId,
|
||||
sessionStateSnapshot: this.$sessionStateSnapshot.value,
|
||||
})
|
||||
}
|
||||
this.didLastWriteError = false
|
||||
} catch (e) {
|
||||
|
|
|
@ -12,7 +12,7 @@ Keep seeing this message?
|
|||
/** @internal */
|
||||
export function showCantReadFromIndexDbAlert() {
|
||||
window.alert(
|
||||
`Oops! We could not access to your browser's storage—and the app won't work correctly without that. We now need to reload the page and try again.
|
||||
`Oops! We could not access your browser's storage—and the app won't work correctly without that. We now need to reload the page and try again.
|
||||
|
||||
Keep seeing this message?
|
||||
• If you're using tldraw in a private or "incognito" window, try loading tldraw in a regular window or in a different browser.`
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { deleteDB } from 'idb'
|
||||
import { getAllIndexDbNames } from './persistence-constants'
|
||||
import { getAllIndexDbNames } from './indexedDb'
|
||||
|
||||
/**
|
||||
* Clear the database of all data associated with tldraw.
|
||||
|
|
224
packages/editor/src/lib/utils/sync/indexedDb.test.ts
Normal file
224
packages/editor/src/lib/utils/sync/indexedDb.test.ts
Normal file
|
@ -0,0 +1,224 @@
|
|||
import { createTLSchema } from '@tldraw/tlschema'
|
||||
import {
|
||||
getAllIndexDbNames,
|
||||
loadDataFromStore,
|
||||
storeChangesInIndexedDb,
|
||||
storeSnapshotInIndexedDb,
|
||||
} from './indexedDb'
|
||||
|
||||
const clearAll = async () => {
|
||||
const dbs = (indexedDB as any)._databases as Map<any, any>
|
||||
dbs.clear()
|
||||
localStorage.clear()
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearAll()
|
||||
})
|
||||
const schema = createTLSchema()
|
||||
describe('storeSnapshotInIndexedDb', () => {
|
||||
it("creates documents if they don't exist", async () => {
|
||||
await storeSnapshotInIndexedDb({
|
||||
persistenceKey: 'test-0',
|
||||
schema,
|
||||
snapshot: {},
|
||||
})
|
||||
|
||||
expect(getAllIndexDbNames()).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"TLDRAW_DOCUMENT_v2test-0",
|
||||
]
|
||||
`)
|
||||
|
||||
await storeSnapshotInIndexedDb({
|
||||
persistenceKey: 'test-1',
|
||||
schema,
|
||||
snapshot: {},
|
||||
})
|
||||
|
||||
expect(getAllIndexDbNames()).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"TLDRAW_DOCUMENT_v2test-0",
|
||||
"TLDRAW_DOCUMENT_v2test-1",
|
||||
]
|
||||
`)
|
||||
|
||||
await storeSnapshotInIndexedDb({
|
||||
persistenceKey: 'test-1',
|
||||
schema,
|
||||
snapshot: {},
|
||||
})
|
||||
|
||||
expect(getAllIndexDbNames()).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"TLDRAW_DOCUMENT_v2test-0",
|
||||
"TLDRAW_DOCUMENT_v2test-1",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('allows reading back the snapshot', async () => {
|
||||
expect(getAllIndexDbNames()).toMatchInlineSnapshot(`Array []`)
|
||||
await storeSnapshotInIndexedDb({
|
||||
persistenceKey: 'test-0',
|
||||
schema,
|
||||
snapshot: {
|
||||
'shape:1': {
|
||||
id: 'shape:1',
|
||||
type: 'rectangle',
|
||||
},
|
||||
'page:1': {
|
||||
id: 'page:1',
|
||||
name: 'steve',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(getAllIndexDbNames()).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"TLDRAW_DOCUMENT_v2test-0",
|
||||
]
|
||||
`)
|
||||
|
||||
const records = (await loadDataFromStore({ persistenceKey: 'test-0' }))?.records
|
||||
expect(records).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"id": "page:1",
|
||||
"name": "steve",
|
||||
},
|
||||
Object {
|
||||
"id": "shape:1",
|
||||
"type": "rectangle",
|
||||
},
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('allows storing a session under a particular ID and reading it back', async () => {
|
||||
const snapshot = {
|
||||
'shape:1': {
|
||||
id: 'shape:1',
|
||||
type: 'rectangle',
|
||||
},
|
||||
}
|
||||
|
||||
await storeSnapshotInIndexedDb({
|
||||
persistenceKey: 'test-0',
|
||||
sessionId: 'session-0',
|
||||
schema,
|
||||
snapshot,
|
||||
sessionStateSnapshot: {
|
||||
foo: 'bar',
|
||||
} as any,
|
||||
})
|
||||
|
||||
expect(
|
||||
(await loadDataFromStore({ persistenceKey: 'test-0', sessionId: 'session-0' }))
|
||||
?.sessionStateSnapshot
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"foo": "bar",
|
||||
}
|
||||
`)
|
||||
|
||||
await storeSnapshotInIndexedDb({
|
||||
persistenceKey: 'test-0',
|
||||
sessionId: 'session-1',
|
||||
schema,
|
||||
snapshot,
|
||||
sessionStateSnapshot: {
|
||||
hello: 'world',
|
||||
} as any,
|
||||
})
|
||||
|
||||
expect(
|
||||
(await loadDataFromStore({ persistenceKey: 'test-0', sessionId: 'session-0' }))
|
||||
?.sessionStateSnapshot
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"foo": "bar",
|
||||
}
|
||||
`)
|
||||
|
||||
expect(
|
||||
(await loadDataFromStore({ persistenceKey: 'test-0', sessionId: 'session-1' }))
|
||||
?.sessionStateSnapshot
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"hello": "world",
|
||||
}
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
describe(storeChangesInIndexedDb, () => {
|
||||
it('allows merging changes into an existing store', async () => {
|
||||
await storeSnapshotInIndexedDb({
|
||||
persistenceKey: 'test-0',
|
||||
schema,
|
||||
snapshot: {
|
||||
'shape:1': {
|
||||
id: 'shape:1',
|
||||
version: 0,
|
||||
},
|
||||
'page:1': {
|
||||
id: 'page:1',
|
||||
version: 0,
|
||||
},
|
||||
'asset:1': {
|
||||
id: 'asset:1',
|
||||
version: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await storeChangesInIndexedDb({
|
||||
persistenceKey: 'test-0',
|
||||
schema,
|
||||
changes: {
|
||||
added: {
|
||||
'asset:2': {
|
||||
id: 'asset:2',
|
||||
version: 0,
|
||||
},
|
||||
},
|
||||
updated: {
|
||||
'page:1': [
|
||||
{
|
||||
id: 'page:1',
|
||||
version: 0,
|
||||
},
|
||||
{
|
||||
id: 'page:1',
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
removed: {
|
||||
'shape:1': {
|
||||
id: 'shape:1',
|
||||
version: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect((await loadDataFromStore({ persistenceKey: 'test-0' }))?.records).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"id": "asset:1",
|
||||
"version": 0,
|
||||
},
|
||||
Object {
|
||||
"id": "asset:2",
|
||||
"version": 0,
|
||||
},
|
||||
Object {
|
||||
"id": "page:1",
|
||||
"version": 1,
|
||||
},
|
||||
]
|
||||
`)
|
||||
})
|
||||
})
|
|
@ -1,16 +1,33 @@
|
|||
import { RecordsDiff, SerializedSchema, StoreSnapshot } from '@tldraw/store'
|
||||
import { TLRecord, TLStoreSchema } from '@tldraw/tlschema'
|
||||
import { IDBPDatabase, openDB } from 'idb'
|
||||
import { STORE_PREFIX, addDbName, getAllIndexDbNames } from './persistence-constants'
|
||||
import { TLSessionStateSnapshot } from '../../config/TLSessionStateSnapshot'
|
||||
|
||||
async function withDb<T>(storeId: string, cb: (db: IDBPDatabase<unknown>) => Promise<T>) {
|
||||
// DO NOT CHANGE THESE WITHOUT ADDING MIGRATION LOGIC. DOING SO WOULD WIPE ALL EXISTING DATA.
|
||||
const STORE_PREFIX = 'TLDRAW_DOCUMENT_v2'
|
||||
const dbNameIndexKey = 'TLDRAW_DB_NAME_INDEX_v2'
|
||||
|
||||
const Table = {
|
||||
Records: 'records',
|
||||
Schema: 'schema',
|
||||
SessionState: 'session_state',
|
||||
} as const
|
||||
|
||||
type StoreName = (typeof Table)[keyof typeof Table]
|
||||
|
||||
async function withDb<T>(storeId: string, cb: (db: IDBPDatabase<StoreName>) => Promise<T>) {
|
||||
addDbName(storeId)
|
||||
const db = await openDB(storeId, 2, {
|
||||
const db = await openDB<StoreName>(storeId, 3, {
|
||||
upgrade(database) {
|
||||
if (!database.objectStoreNames.contains('records')) {
|
||||
database.createObjectStore('records')
|
||||
if (!database.objectStoreNames.contains(Table.Records)) {
|
||||
database.createObjectStore(Table.Records)
|
||||
}
|
||||
if (!database.objectStoreNames.contains(Table.Schema)) {
|
||||
database.createObjectStore(Table.Schema)
|
||||
}
|
||||
if (!database.objectStoreNames.contains(Table.SessionState)) {
|
||||
database.createObjectStore(Table.SessionState)
|
||||
}
|
||||
database.createObjectStore('schema')
|
||||
},
|
||||
})
|
||||
try {
|
||||
|
@ -20,41 +37,81 @@ async function withDb<T>(storeId: string, cb: (db: IDBPDatabase<unknown>) => Pro
|
|||
}
|
||||
}
|
||||
|
||||
type LoadResult = {
|
||||
records: TLRecord[]
|
||||
schema?: SerializedSchema
|
||||
sessionStateSnapshot?: TLSessionStateSnapshot | null
|
||||
}
|
||||
|
||||
type SessionStateSnapshotRow = {
|
||||
id: string
|
||||
snapshot: TLSessionStateSnapshot
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export async function loadDataFromStore(
|
||||
universalPersistenceKey: string,
|
||||
opts?: {
|
||||
didCancel?: () => boolean
|
||||
}
|
||||
): Promise<undefined | { records: TLRecord[]; schema?: SerializedSchema }> {
|
||||
const storeId = STORE_PREFIX + universalPersistenceKey
|
||||
export async function loadDataFromStore({
|
||||
persistenceKey,
|
||||
sessionId,
|
||||
didCancel,
|
||||
}: {
|
||||
persistenceKey: string
|
||||
sessionId?: string
|
||||
didCancel?: () => boolean
|
||||
}): Promise<undefined | LoadResult> {
|
||||
const storeId = STORE_PREFIX + persistenceKey
|
||||
if (!getAllIndexDbNames().includes(storeId)) return undefined
|
||||
await pruneSessionState({ persistenceKey, didCancel })
|
||||
return await withDb(storeId, async (db) => {
|
||||
if (opts?.didCancel?.()) return undefined
|
||||
const tx = db.transaction(['records', 'schema'], 'readonly')
|
||||
const recordsStore = tx.objectStore('records')
|
||||
const schemaStore = tx.objectStore('schema')
|
||||
return {
|
||||
records: await recordsStore.getAll(),
|
||||
schema: await schemaStore.get('schema'),
|
||||
if (didCancel?.()) return undefined
|
||||
const tx = db.transaction([Table.Records, Table.Schema, Table.SessionState], 'readonly')
|
||||
const recordsStore = tx.objectStore(Table.Records)
|
||||
const schemaStore = tx.objectStore(Table.Schema)
|
||||
const sessionStateStore = tx.objectStore(Table.SessionState)
|
||||
let sessionStateSnapshot = sessionId
|
||||
? ((await sessionStateStore.get(sessionId)) as SessionStateSnapshotRow | undefined)?.snapshot
|
||||
: null
|
||||
if (!sessionStateSnapshot) {
|
||||
// get the most recent session state
|
||||
const all = (await sessionStateStore.getAll()) as SessionStateSnapshotRow[]
|
||||
sessionStateSnapshot = all.sort((a, b) => a.updatedAt - b.updatedAt).pop()?.snapshot
|
||||
}
|
||||
const result = {
|
||||
records: await recordsStore.getAll(),
|
||||
schema: await schemaStore.get(Table.Schema),
|
||||
sessionStateSnapshot,
|
||||
} satisfies LoadResult
|
||||
if (didCancel?.()) {
|
||||
tx.abort()
|
||||
return undefined
|
||||
}
|
||||
await tx.done
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export async function storeChangesInIndexedDb(
|
||||
universalPersistenceKey: string,
|
||||
schema: TLStoreSchema,
|
||||
changes: RecordsDiff<any>,
|
||||
opts?: {
|
||||
didCancel?: () => boolean
|
||||
}
|
||||
) {
|
||||
const storeId = STORE_PREFIX + universalPersistenceKey
|
||||
export async function storeChangesInIndexedDb({
|
||||
persistenceKey,
|
||||
schema,
|
||||
changes,
|
||||
sessionId,
|
||||
sessionStateSnapshot,
|
||||
didCancel,
|
||||
}: {
|
||||
persistenceKey: string
|
||||
schema: TLStoreSchema
|
||||
changes: RecordsDiff<any>
|
||||
sessionId?: string | null
|
||||
sessionStateSnapshot?: TLSessionStateSnapshot | null
|
||||
didCancel?: () => boolean
|
||||
}) {
|
||||
const storeId = STORE_PREFIX + persistenceKey
|
||||
await withDb(storeId, async (db) => {
|
||||
const tx = db.transaction(['records', 'schema'], 'readwrite')
|
||||
const recordsStore = tx.objectStore('records')
|
||||
const schemaStore = tx.objectStore('schema')
|
||||
const tx = db.transaction([Table.Records, Table.Schema, Table.SessionState], 'readwrite')
|
||||
const recordsStore = tx.objectStore(Table.Records)
|
||||
const schemaStore = tx.objectStore(Table.Schema)
|
||||
const sessionStateStore = tx.objectStore(Table.SessionState)
|
||||
|
||||
for (const [id, record] of Object.entries(changes.added)) {
|
||||
await recordsStore.put(record, id)
|
||||
|
@ -68,28 +125,48 @@ export async function storeChangesInIndexedDb(
|
|||
await recordsStore.delete(id)
|
||||
}
|
||||
|
||||
schemaStore.put(schema.serialize(), 'schema')
|
||||
schemaStore.put(schema.serialize(), Table.Schema)
|
||||
if (sessionStateSnapshot && sessionId) {
|
||||
sessionStateStore.put(
|
||||
{
|
||||
snapshot: sessionStateSnapshot,
|
||||
updatedAt: Date.now(),
|
||||
id: sessionId,
|
||||
} satisfies SessionStateSnapshotRow,
|
||||
sessionId
|
||||
)
|
||||
} else if (sessionStateSnapshot || sessionId) {
|
||||
console.error('sessionStateSnapshot and instanceId must be provided together')
|
||||
}
|
||||
|
||||
if (opts?.didCancel?.()) return tx.abort()
|
||||
if (didCancel?.()) return tx.abort()
|
||||
|
||||
await tx.done
|
||||
})
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export async function storeSnapshotInIndexedDb(
|
||||
universalPersistenceKey: string,
|
||||
schema: TLStoreSchema,
|
||||
snapshot: StoreSnapshot<any>,
|
||||
opts?: {
|
||||
didCancel?: () => boolean
|
||||
}
|
||||
) {
|
||||
const storeId = STORE_PREFIX + universalPersistenceKey
|
||||
export async function storeSnapshotInIndexedDb({
|
||||
persistenceKey,
|
||||
schema,
|
||||
snapshot,
|
||||
sessionId,
|
||||
sessionStateSnapshot,
|
||||
didCancel,
|
||||
}: {
|
||||
persistenceKey: string
|
||||
schema: TLStoreSchema
|
||||
snapshot: StoreSnapshot<any>
|
||||
sessionId?: string | null
|
||||
sessionStateSnapshot?: TLSessionStateSnapshot | null
|
||||
didCancel?: () => boolean
|
||||
}) {
|
||||
const storeId = STORE_PREFIX + persistenceKey
|
||||
await withDb(storeId, async (db) => {
|
||||
const tx = db.transaction(['records', 'schema'], 'readwrite')
|
||||
const recordsStore = tx.objectStore('records')
|
||||
const schemaStore = tx.objectStore('schema')
|
||||
const tx = db.transaction([Table.Records, Table.Schema, Table.SessionState], 'readwrite')
|
||||
const recordsStore = tx.objectStore(Table.Records)
|
||||
const schemaStore = tx.objectStore(Table.Schema)
|
||||
const sessionStateStore = tx.objectStore(Table.SessionState)
|
||||
|
||||
await recordsStore.clear()
|
||||
|
||||
|
@ -97,16 +174,62 @@ export async function storeSnapshotInIndexedDb(
|
|||
await recordsStore.put(record, id)
|
||||
}
|
||||
|
||||
schemaStore.put(schema.serialize(), 'schema')
|
||||
schemaStore.put(schema.serialize(), Table.Schema)
|
||||
|
||||
if (opts?.didCancel?.()) return tx.abort()
|
||||
if (sessionStateSnapshot && sessionId) {
|
||||
sessionStateStore.put(
|
||||
{
|
||||
snapshot: sessionStateSnapshot,
|
||||
updatedAt: Date.now(),
|
||||
id: sessionId,
|
||||
} satisfies SessionStateSnapshotRow,
|
||||
sessionId
|
||||
)
|
||||
} else if (sessionStateSnapshot || sessionId) {
|
||||
console.error('sessionStateSnapshot and instanceId must be provided together')
|
||||
}
|
||||
|
||||
if (didCancel?.()) return tx.abort()
|
||||
|
||||
await tx.done
|
||||
})
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function clearDb(universalPersistenceKey: string) {
|
||||
const dbId = STORE_PREFIX + universalPersistenceKey
|
||||
indexedDB.deleteDatabase(dbId)
|
||||
async function pruneSessionState({
|
||||
persistenceKey,
|
||||
didCancel,
|
||||
}: {
|
||||
persistenceKey: string
|
||||
didCancel?: () => boolean
|
||||
}) {
|
||||
await withDb(STORE_PREFIX + persistenceKey, async (db) => {
|
||||
const tx = db.transaction([Table.SessionState], 'readwrite')
|
||||
const sessionStateStore = tx.objectStore(Table.SessionState)
|
||||
const all = (await sessionStateStore.getAll()).sort((a, b) => a.updatedAt - b.updatedAt)
|
||||
if (all.length < 10) {
|
||||
await tx.done
|
||||
return
|
||||
}
|
||||
const toDelete = all.slice(0, all.length - 10)
|
||||
for (const { id } of toDelete) {
|
||||
await sessionStateStore.delete(id)
|
||||
}
|
||||
if (didCancel?.()) return tx.abort()
|
||||
await tx.done
|
||||
})
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function getAllIndexDbNames(): string[] {
|
||||
const result = JSON.parse(window?.localStorage.getItem(dbNameIndexKey) || '[]') ?? []
|
||||
if (!Array.isArray(result)) {
|
||||
return []
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function addDbName(name: string) {
|
||||
const all = new Set(getAllIndexDbNames())
|
||||
all.add(name)
|
||||
window?.localStorage.setItem(dbNameIndexKey, JSON.stringify([...all]))
|
||||
}
|
||||
|
|
|
@ -1,78 +0,0 @@
|
|||
import { InstanceRecordType, TLInstanceId } from '@tldraw/tlschema'
|
||||
import { uniqueId } from '../data'
|
||||
|
||||
const tabIdKey = 'TLDRAW_TAB_ID_v2' as const
|
||||
|
||||
const window = globalThis.window as
|
||||
| {
|
||||
navigator: Window['navigator']
|
||||
localStorage: Window['localStorage']
|
||||
sessionStorage: Window['sessionStorage']
|
||||
addEventListener: Window['addEventListener']
|
||||
TLDRAW_TAB_ID_v2?: string
|
||||
}
|
||||
| undefined
|
||||
|
||||
// https://stackoverflow.com/a/9039885
|
||||
function iOS() {
|
||||
if (!window) return false
|
||||
return (
|
||||
['iPad Simulator', 'iPhone Simulator', 'iPod Simulator', 'iPad', 'iPhone', 'iPod'].includes(
|
||||
window.navigator.platform
|
||||
) ||
|
||||
// iPad on iOS 13 detection
|
||||
(window.navigator.userAgent.includes('Mac') && 'ontouchend' in document)
|
||||
)
|
||||
}
|
||||
|
||||
// the id of the document that will be loaded if the URL doesn't contain a document id
|
||||
// again, stored in localStorage
|
||||
const defaultDocumentKey = 'TLDRAW_DEFAULT_DOCUMENT_NAME_v2'
|
||||
|
||||
/** @public */
|
||||
export const DEFAULT_DOCUMENT_NAME =
|
||||
(window?.localStorage.getItem(defaultDocumentKey) as any) ?? uniqueId()
|
||||
window?.localStorage.setItem(defaultDocumentKey, DEFAULT_DOCUMENT_NAME)
|
||||
|
||||
// the prefix for the IndexedDB store with the id matching either the URL's document id or the default document id
|
||||
/** @public */
|
||||
export const STORE_PREFIX = 'TLDRAW_DOCUMENT_v2'
|
||||
|
||||
/** @public */
|
||||
export const TAB_ID: TLInstanceId =
|
||||
window?.[tabIdKey] ?? window?.sessionStorage[tabIdKey] ?? InstanceRecordType.createId()
|
||||
if (window) {
|
||||
window[tabIdKey] = TAB_ID
|
||||
if (iOS()) {
|
||||
// iOS does not trigger beforeunload
|
||||
// so we need to keep the sessionStorage value around
|
||||
// and hope the user doesn't figure out a way to duplicate their tab
|
||||
// in which case they'll have two tabs with the same UI state.
|
||||
// It's not a big deal, but it's not ideal.
|
||||
// And anyway I can't see a way to duplicate a tab in iOS Safari.
|
||||
window.sessionStorage[tabIdKey] = TAB_ID
|
||||
} else {
|
||||
delete window.sessionStorage[tabIdKey]
|
||||
}
|
||||
}
|
||||
window?.addEventListener('beforeunload', () => {
|
||||
window.sessionStorage[tabIdKey] = TAB_ID
|
||||
})
|
||||
|
||||
const dbNameIndexKey = 'TLDRAW_DB_NAME_INDEX_v2'
|
||||
|
||||
/** @internal */
|
||||
export function getAllIndexDbNames(): string[] {
|
||||
const result = JSON.parse(window?.localStorage.getItem(dbNameIndexKey) || '[]') ?? []
|
||||
if (!Array.isArray(result)) {
|
||||
return []
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function addDbName(name: string) {
|
||||
const all = new Set(getAllIndexDbNames())
|
||||
all.add(name)
|
||||
window?.localStorage.setItem(dbNameIndexKey, JSON.stringify([...all]))
|
||||
}
|
|
@ -8,7 +8,6 @@ import { Editor } from '@tldraw/editor';
|
|||
import { MigrationFailureReason } from '@tldraw/store';
|
||||
import { Result } from '@tldraw/utils';
|
||||
import { SerializedSchema } from '@tldraw/store';
|
||||
import { TLInstanceId } from '@tldraw/editor';
|
||||
import { TLStore } from '@tldraw/editor';
|
||||
import { TLUiToastsContextType } from '@tldraw/ui';
|
||||
import { TLUiTranslationKey } from '@tldraw/ui';
|
||||
|
@ -40,10 +39,9 @@ export interface LegacyTldrawDocument {
|
|||
export function parseAndLoadDocument(editor: Editor, document: string, msg: (id: TLUiTranslationKey) => string, addToast: TLUiToastsContextType['addToast'], onV1FileLoad?: () => void, forceDarkMode?: boolean): Promise<void>;
|
||||
|
||||
// @public (undocumented)
|
||||
export function parseTldrawJsonFile({ json, instanceId, store, }: {
|
||||
export function parseTldrawJsonFile({ json, store, }: {
|
||||
store: TLStore;
|
||||
json: string;
|
||||
instanceId: TLInstanceId;
|
||||
}): Result<TLStore, TldrawFileParseError>;
|
||||
|
||||
// @public (undocumented)
|
||||
|
|
|
@ -4,7 +4,6 @@ import {
|
|||
fileToBase64,
|
||||
TLAsset,
|
||||
TLAssetId,
|
||||
TLInstanceId,
|
||||
TLRecord,
|
||||
TLStore,
|
||||
} from '@tldraw/editor'
|
||||
|
@ -83,12 +82,10 @@ export type TldrawFileParseError =
|
|||
/** @public */
|
||||
export function parseTldrawJsonFile({
|
||||
json,
|
||||
instanceId,
|
||||
store,
|
||||
}: {
|
||||
store: TLStore
|
||||
json: string
|
||||
instanceId: TLInstanceId
|
||||
}): Result<TLStore, TldrawFileParseError> {
|
||||
// first off, we parse .json file and check it matches the general shape of
|
||||
// a tldraw file
|
||||
|
@ -141,7 +138,6 @@ export function parseTldrawJsonFile({
|
|||
return Result.ok(
|
||||
createTLStore({
|
||||
initialData: migrationResult.value,
|
||||
instanceId,
|
||||
})
|
||||
)
|
||||
} catch (e) {
|
||||
|
@ -222,7 +218,6 @@ export async function parseAndLoadDocument(
|
|||
const parseFileResult = parseTldrawJsonFile({
|
||||
store: createTLStore(),
|
||||
json: document,
|
||||
instanceId: editor.instanceId,
|
||||
})
|
||||
if (!parseFileResult.ok) {
|
||||
let description
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { createShapeId, createTLStore, InstanceRecordType, TLStore } from '@tldraw/editor'
|
||||
import { createShapeId, createTLStore, TLStore } from '@tldraw/editor'
|
||||
import { MigrationFailureReason, UnknownRecord } from '@tldraw/store'
|
||||
import { assert } from '@tldraw/utils'
|
||||
import { parseTldrawJsonFile as _parseTldrawJsonFile, TldrawFile } from '../lib/file'
|
||||
|
@ -7,7 +7,6 @@ const parseTldrawJsonFile = (store: TLStore, json: string) =>
|
|||
_parseTldrawJsonFile({
|
||||
store,
|
||||
json,
|
||||
instanceId: InstanceRecordType.createCustomId('instance'),
|
||||
})
|
||||
|
||||
function serialize(file: TldrawFile): string {
|
||||
|
|
|
@ -42,7 +42,7 @@ export type ComputedCache<Data, R extends UnknownRecord> = {
|
|||
export function createRecordType<R extends UnknownRecord>(typeName: R['typeName'], config: {
|
||||
migrations?: Migrations;
|
||||
validator?: StoreValidator<R>;
|
||||
scope: Scope;
|
||||
scope: RecordScope;
|
||||
}): RecordType<R, keyof Omit<R, 'id' | 'typeName'>>;
|
||||
|
||||
// @public (undocumented)
|
||||
|
@ -69,7 +69,7 @@ export function getRecordVersion(record: UnknownRecord, serializedSchema: Serial
|
|||
// @public
|
||||
export type HistoryEntry<R extends UnknownRecord = UnknownRecord> = {
|
||||
changes: RecordsDiff<R>;
|
||||
source: 'remote' | 'user';
|
||||
source: ChangeSource;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
|
@ -166,21 +166,22 @@ export class RecordType<R extends UnknownRecord, RequiredProperties extends keyo
|
|||
readonly validator?: {
|
||||
validate: (r: unknown) => R;
|
||||
} | StoreValidator<R>;
|
||||
readonly scope?: Scope;
|
||||
readonly scope?: RecordScope;
|
||||
});
|
||||
clone(record: R): R;
|
||||
create(properties: Pick<R, RequiredProperties> & Omit<Partial<R>, RequiredProperties>): R;
|
||||
// @deprecated
|
||||
createCustomId(id: string): IdOf<R>;
|
||||
// (undocumented)
|
||||
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>;
|
||||
createId(): IdOf<R>;
|
||||
createId(customUniquePart?: string): IdOf<R>;
|
||||
isId(id?: string): id is IdOf<R>;
|
||||
isInstance: (record?: UnknownRecord) => record is R;
|
||||
// (undocumented)
|
||||
readonly migrations: Migrations;
|
||||
parseId(id: string): IdOf<R>;
|
||||
parseId(id: IdOf<R>): string;
|
||||
// (undocumented)
|
||||
readonly scope: Scope;
|
||||
readonly scope: RecordScope;
|
||||
readonly typeName: R['typeName'];
|
||||
validate(record: unknown): R;
|
||||
// (undocumented)
|
||||
|
@ -228,23 +229,28 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|||
clear: () => void;
|
||||
createComputedCache: <T, V extends R = R>(name: string, derive: (record: V) => T | undefined) => ComputedCache<T, V>;
|
||||
createSelectedComputedCache: <T, J, V extends R = R>(name: string, selector: (record: V) => T | undefined, derive: (input: T) => J | undefined) => ComputedCache<J, V>;
|
||||
deserialize: (snapshot: StoreSnapshot<R>) => void;
|
||||
// @internal (undocumented)
|
||||
ensureStoreIsUsable(): void;
|
||||
// (undocumented)
|
||||
extractingChanges(fn: () => void): RecordsDiff<R>;
|
||||
filterChangesByScope(change: RecordsDiff<R>, scope: RecordScope): {
|
||||
added: { [K in IdOf<R>]: R; };
|
||||
updated: { [K_1 in IdOf<R>]: [from: R, to: R]; };
|
||||
removed: { [K in IdOf<R>]: R; };
|
||||
} | null;
|
||||
// (undocumented)
|
||||
_flushHistory(): void;
|
||||
get: <K extends IdOf<R>>(id: K) => RecFromId<K> | undefined;
|
||||
getSnapshot(): {
|
||||
getSnapshot(scope?: 'all' | RecordScope): {
|
||||
store: StoreSnapshot<R>;
|
||||
schema: SerializedSchema;
|
||||
};
|
||||
has: <K extends IdOf<R>>(id: K) => boolean;
|
||||
readonly history: Atom<number, RecordsDiff<R>>;
|
||||
readonly id: string;
|
||||
// @internal (undocumented)
|
||||
isPossiblyCorrupted(): boolean;
|
||||
listen: (listener: StoreListener<R>) => () => void;
|
||||
listen: (onHistory: StoreListener<R>, filters?: Partial<StoreListenerFilters>) => () => void;
|
||||
loadSnapshot(snapshot: {
|
||||
store: StoreSnapshot<R>;
|
||||
schema: SerializedSchema;
|
||||
|
@ -263,8 +269,11 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|||
remove: (ids: IdOf<R>[]) => void;
|
||||
// (undocumented)
|
||||
readonly schema: StoreSchema<R, Props>;
|
||||
serialize: (filter?: ((record: R) => boolean) | undefined) => StoreSnapshot<R>;
|
||||
serializeDocumentState: () => StoreSnapshot<R>;
|
||||
// (undocumented)
|
||||
readonly scopedTypes: {
|
||||
readonly [K in RecordScope]: ReadonlySet<R['typeName']>;
|
||||
};
|
||||
serialize: (scope?: 'all' | RecordScope) => StoreSnapshot<R>;
|
||||
unsafeGetWithoutCapture: <K extends IdOf<R>>(id: K) => RecFromId<K> | undefined;
|
||||
update: <K extends IdOf<R>>(id: K, updater: (record: RecFromId<K>) => RecFromId<K>) => void;
|
||||
// (undocumented)
|
||||
|
|
|
@ -15,7 +15,7 @@ export type RecordTypeRecord<R extends RecordType<any, any>> = ReturnType<R['cre
|
|||
*
|
||||
* @public
|
||||
* */
|
||||
export type Scope = 'instance' | 'document' | 'presence'
|
||||
export type RecordScope = 'session' | 'document' | 'presence'
|
||||
|
||||
/**
|
||||
* A record type is a type that can be stored in a record store. It is created with
|
||||
|
@ -31,7 +31,7 @@ export class RecordType<
|
|||
readonly migrations: Migrations
|
||||
readonly validator: StoreValidator<R> | { validate: (r: unknown) => R }
|
||||
|
||||
readonly scope: Scope
|
||||
readonly scope: RecordScope
|
||||
|
||||
constructor(
|
||||
/**
|
||||
|
@ -45,7 +45,7 @@ export class RecordType<
|
|||
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>
|
||||
readonly migrations: Migrations
|
||||
readonly validator?: StoreValidator<R> | { validate: (r: unknown) => R }
|
||||
readonly scope?: Scope
|
||||
readonly scope?: RecordScope
|
||||
}
|
||||
) {
|
||||
this.createDefaultProperties = config.createDefaultProperties
|
||||
|
@ -97,8 +97,8 @@ export class RecordType<
|
|||
* @returns The new ID.
|
||||
* @public
|
||||
*/
|
||||
createId(): IdOf<R> {
|
||||
return (this.typeName + ':' + nanoid()) as IdOf<R>
|
||||
createId(customUniquePart?: string): IdOf<R> {
|
||||
return (this.typeName + ':' + (customUniquePart ?? nanoid())) as IdOf<R>
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -110,6 +110,7 @@ export class RecordType<
|
|||
* const id = recordType.createCustomId('myId')
|
||||
* ```
|
||||
*
|
||||
* @deprecated - Use `createId` instead.
|
||||
* @param id - The ID to base the new ID on.
|
||||
* @returns The new ID.
|
||||
*/
|
||||
|
@ -123,12 +124,12 @@ export class RecordType<
|
|||
* @param id - The id
|
||||
* @returns
|
||||
*/
|
||||
parseId(id: string): IdOf<R> {
|
||||
parseId(id: IdOf<R>): string {
|
||||
if (!this.isId(id)) {
|
||||
throw new Error(`ID "${id}" is not a valid ID for type "${this.typeName}"`)
|
||||
}
|
||||
|
||||
return id.slice(this.typeName.length + 1) as IdOf<R>
|
||||
return id.slice(this.typeName.length + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -219,7 +220,7 @@ export function createRecordType<R extends UnknownRecord>(
|
|||
config: {
|
||||
migrations?: Migrations
|
||||
validator?: StoreValidator<R>
|
||||
scope: Scope
|
||||
scope: RecordScope
|
||||
}
|
||||
): RecordType<R, keyof Omit<R, 'id' | 'typeName'>> {
|
||||
return new RecordType<R, keyof Omit<R, 'id' | 'typeName'>>(typeName, {
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
import {
|
||||
filterEntries,
|
||||
objectMapEntries,
|
||||
objectMapFromEntries,
|
||||
objectMapKeys,
|
||||
objectMapValues,
|
||||
throttledRaf,
|
||||
} from '@tldraw/utils'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { Atom, Computed, Reactor, atom, computed, reactor, transact } from 'signia'
|
||||
import { IdOf, RecordId, UnknownRecord } from './BaseRecord'
|
||||
import { Cache } from './Cache'
|
||||
import { RecordType } from './RecordType'
|
||||
import { RecordScope } from './RecordType'
|
||||
import { StoreQueries } from './StoreQueries'
|
||||
import { SerializedSchema, StoreSchema } from './StoreSchema'
|
||||
import { devFreeze } from './devFreeze'
|
||||
|
@ -33,6 +35,13 @@ export type RecordsDiff<R extends UnknownRecord> = {
|
|||
*/
|
||||
export type CollectionDiff<T> = { added?: Set<T>; removed?: Set<T> }
|
||||
|
||||
export type ChangeSource = 'user' | 'remote'
|
||||
|
||||
export type StoreListenerFilters = {
|
||||
source: ChangeSource | 'all'
|
||||
scope: RecordScope | 'all'
|
||||
}
|
||||
|
||||
/**
|
||||
* An entry containing changes that originated either by user actions or remote changes.
|
||||
*
|
||||
|
@ -40,7 +49,7 @@ export type CollectionDiff<T> = { added?: Set<T>; removed?: Set<T> }
|
|||
*/
|
||||
export type HistoryEntry<R extends UnknownRecord = UnknownRecord> = {
|
||||
changes: RecordsDiff<R>
|
||||
source: 'user' | 'remote'
|
||||
source: ChangeSource
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -94,6 +103,10 @@ export type StoreRecord<S extends Store<any>> = S extends Store<infer R> ? R : n
|
|||
* @public
|
||||
*/
|
||||
export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
||||
/**
|
||||
* The random id of the store.
|
||||
*/
|
||||
public readonly id = nanoid()
|
||||
/**
|
||||
* An atom containing the store's atoms.
|
||||
*
|
||||
|
@ -125,7 +138,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|||
*
|
||||
* @internal
|
||||
*/
|
||||
private listeners = new Set<StoreListener<R>>()
|
||||
private listeners = new Set<{ onHistory: StoreListener<R>; filters: StoreListenerFilters }>()
|
||||
|
||||
/**
|
||||
* An array of history entries that have not yet been flushed.
|
||||
|
@ -146,6 +159,8 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|||
|
||||
readonly props: Props
|
||||
|
||||
public readonly scopedTypes: { readonly [K in RecordScope]: ReadonlySet<R['typeName']> }
|
||||
|
||||
constructor(config: {
|
||||
/** The store's initial data. */
|
||||
initialData?: StoreSnapshot<R>
|
||||
|
@ -182,6 +197,23 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|||
},
|
||||
{ scheduleEffect: (cb) => throttledRaf(cb) }
|
||||
)
|
||||
this.scopedTypes = {
|
||||
document: new Set(
|
||||
objectMapValues(this.schema.types)
|
||||
.filter((t) => t.scope === 'document')
|
||||
.map((t) => t.typeName)
|
||||
),
|
||||
session: new Set(
|
||||
objectMapValues(this.schema.types)
|
||||
.filter((t) => t.scope === 'session')
|
||||
.map((t) => t.typeName)
|
||||
),
|
||||
presence: new Set(
|
||||
objectMapValues(this.schema.types)
|
||||
.filter((t) => t.scope === 'presence')
|
||||
.map((t) => t.typeName)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
public _flushHistory() {
|
||||
|
@ -189,11 +221,56 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|||
if (this.historyAccumulator.hasChanges()) {
|
||||
const entries = this.historyAccumulator.flush()
|
||||
for (const { changes, source } of entries) {
|
||||
this.listeners.forEach((l) => l({ changes, source }))
|
||||
let instanceChanges = null as null | RecordsDiff<R>
|
||||
let documentChanges = null as null | RecordsDiff<R>
|
||||
let presenceChanges = null as null | RecordsDiff<R>
|
||||
for (const { onHistory, filters } of this.listeners) {
|
||||
if (filters.source !== 'all' && filters.source !== source) {
|
||||
continue
|
||||
}
|
||||
if (filters.scope !== 'all') {
|
||||
if (filters.scope === 'document') {
|
||||
documentChanges ??= this.filterChangesByScope(changes, 'document')
|
||||
if (!documentChanges) continue
|
||||
onHistory({ changes: documentChanges, source })
|
||||
} else if (filters.scope === 'session') {
|
||||
instanceChanges ??= this.filterChangesByScope(changes, 'session')
|
||||
if (!instanceChanges) continue
|
||||
onHistory({ changes: instanceChanges, source })
|
||||
} else {
|
||||
presenceChanges ??= this.filterChangesByScope(changes, 'presence')
|
||||
if (!presenceChanges) continue
|
||||
onHistory({ changes: presenceChanges, source })
|
||||
}
|
||||
} else {
|
||||
onHistory({ changes, source })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out non-document changes from a diff. Returns null if there are no changes left.
|
||||
* @param change - the records diff
|
||||
* @returns
|
||||
*/
|
||||
filterChangesByScope(change: RecordsDiff<R>, scope: RecordScope) {
|
||||
const result = {
|
||||
added: filterEntries(change.added, (_, r) => this.scopedTypes[scope].has(r.typeName)),
|
||||
updated: filterEntries(change.updated, (_, r) => this.scopedTypes[scope].has(r[1].typeName)),
|
||||
removed: filterEntries(change.removed, (_, r) => this.scopedTypes[scope].has(r.typeName)),
|
||||
}
|
||||
if (
|
||||
Object.keys(result.added).length === 0 &&
|
||||
Object.keys(result.updated).length === 0 &&
|
||||
Object.keys(result.removed).length === 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the history with a diff of changes.
|
||||
*
|
||||
|
@ -421,46 +498,22 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Opposite of `deserialize`. Creates a JSON payload from the record store.
|
||||
* Creates a JSON payload from the record store.
|
||||
*
|
||||
* @param filter - A function to filter structs that do not satisfy the predicate.
|
||||
* @param scope - The scope of records to serialize. Defaults to 'document'.
|
||||
* @returns The record store snapshot as a JSON payload.
|
||||
*/
|
||||
serialize = (filter?: (record: R) => boolean): StoreSnapshot<R> => {
|
||||
serialize = (scope: RecordScope | 'all' = 'document'): StoreSnapshot<R> => {
|
||||
const result = {} as StoreSnapshot<R>
|
||||
for (const [id, atom] of objectMapEntries(this.atoms.value)) {
|
||||
const record = atom.value
|
||||
if (typeof filter === 'function' && !filter(record)) continue
|
||||
result[id as IdOf<R>] = record
|
||||
if (scope === 'all' || this.scopedTypes[scope].has(record.typeName)) {
|
||||
result[id as IdOf<R>] = record
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* The same as `serialize`, but only serializes records with a scope of `document`.
|
||||
* @returns The record store snapshot as a JSON payload.
|
||||
*/
|
||||
serializeDocumentState = (): StoreSnapshot<R> => {
|
||||
return this.serialize((r) => {
|
||||
const type = this.schema.types[r.typeName as R['typeName']] as RecordType<any, any>
|
||||
return type.scope === 'document'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Opposite of `serialize`. Replace the store's current records with records as defined by a
|
||||
* simple JSON structure into the stores.
|
||||
*
|
||||
* @param snapshot - The JSON snapshot to deserialize.
|
||||
* @public
|
||||
*/
|
||||
deserialize = (snapshot: StoreSnapshot<R>): void => {
|
||||
transact(() => {
|
||||
this.clear()
|
||||
this.put(Object.values(snapshot))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a serialized snapshot of the store and its schema.
|
||||
*
|
||||
|
@ -469,11 +522,12 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|||
* store.loadSnapshot(snapshot)
|
||||
* ```
|
||||
*
|
||||
* @param scope - The scope of records to serialize. Defaults to 'document'.
|
||||
* @public
|
||||
*/
|
||||
getSnapshot() {
|
||||
getSnapshot(scope: RecordScope | 'all' = 'document') {
|
||||
return {
|
||||
store: this.serializeDocumentState(),
|
||||
store: this.serialize(scope),
|
||||
schema: this.schema.serialize(),
|
||||
}
|
||||
}
|
||||
|
@ -497,7 +551,11 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|||
throw new Error(`Failed to migrate snapshot: ${migrationResult.reason}`)
|
||||
}
|
||||
|
||||
this.deserialize(migrationResult.value)
|
||||
transact(() => {
|
||||
this.clear()
|
||||
this.put(Object.values(migrationResult.value))
|
||||
this.ensureStoreIsUsable()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -548,13 +606,22 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|||
/**
|
||||
* Add a new listener to the store.
|
||||
*
|
||||
* @param listener - The listener to call when the store updates.
|
||||
* @param onHistory - The listener to call when the store updates.
|
||||
* @param filters - Filters to apply to the listener.
|
||||
* @returns A function to remove the listener.
|
||||
*/
|
||||
listen = (listener: StoreListener<R>) => {
|
||||
listen = (onHistory: StoreListener<R>, filters?: Partial<StoreListenerFilters>) => {
|
||||
// flush history so that this listener's history starts from exactly now
|
||||
this._flushHistory()
|
||||
|
||||
const listener = {
|
||||
onHistory,
|
||||
filters: {
|
||||
source: filters?.source ?? 'all',
|
||||
scope: filters?.scope ?? 'all',
|
||||
},
|
||||
}
|
||||
|
||||
this.listeners.add(listener)
|
||||
|
||||
if (!this.historyReactor.scheduler.isActivelyListening) {
|
||||
|
@ -802,18 +869,18 @@ export function reverseRecordsDiff(diff: RecordsDiff<any>) {
|
|||
class HistoryAccumulator<T extends UnknownRecord> {
|
||||
private _history: HistoryEntry<T>[] = []
|
||||
|
||||
private _inteceptors: Set<(entry: HistoryEntry<T>) => void> = new Set()
|
||||
private _interceptors: Set<(entry: HistoryEntry<T>) => void> = new Set()
|
||||
|
||||
intercepting(fn: (entry: HistoryEntry<T>) => void) {
|
||||
this._inteceptors.add(fn)
|
||||
this._interceptors.add(fn)
|
||||
return () => {
|
||||
this._inteceptors.delete(fn)
|
||||
this._interceptors.delete(fn)
|
||||
}
|
||||
}
|
||||
|
||||
add(entry: HistoryEntry<T>) {
|
||||
this._history.push(entry)
|
||||
for (const interceptor of this._inteceptors) {
|
||||
for (const interceptor of this._interceptors) {
|
||||
interceptor(entry)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,15 +27,31 @@ const Author = createRecordType<Author>('author', {
|
|||
isPseudonym: false,
|
||||
}))
|
||||
|
||||
interface Visit extends BaseRecord<'visit', RecordId<Visit>> {
|
||||
visitorName: string
|
||||
booksInBasket: RecordId<Book>[]
|
||||
}
|
||||
|
||||
const Visit = createRecordType<Visit>('visit', {
|
||||
validator: { validate: (visit) => visit as Visit },
|
||||
scope: 'session',
|
||||
}).withDefaultProperties(() => ({
|
||||
visitorName: 'Anonymous',
|
||||
booksInBasket: [],
|
||||
}))
|
||||
|
||||
type LibraryType = Book | Author | Visit
|
||||
|
||||
describe('Store', () => {
|
||||
let store: Store<Book | Author>
|
||||
let store: Store<Book | Author | Visit>
|
||||
beforeEach(() => {
|
||||
store = new Store({
|
||||
props: {},
|
||||
schema: StoreSchema.create<Book | Author>(
|
||||
schema: StoreSchema.create<LibraryType>(
|
||||
{
|
||||
book: Book,
|
||||
author: Author,
|
||||
visit: Visit,
|
||||
},
|
||||
{
|
||||
snapshotMigrations: {
|
||||
|
@ -49,18 +65,18 @@ describe('Store', () => {
|
|||
})
|
||||
|
||||
it('allows records to be added', () => {
|
||||
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') })])
|
||||
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })])
|
||||
expect(store.query.records('author').value).toEqual([
|
||||
{ id: 'author:tolkein', typeName: 'author', name: 'J.R.R Tolkein', isPseudonym: false },
|
||||
])
|
||||
|
||||
store.put([
|
||||
{
|
||||
id: Book.createCustomId('the-hobbit'),
|
||||
id: Book.createId('the-hobbit'),
|
||||
typeName: 'book',
|
||||
title: 'The Hobbit',
|
||||
numPages: 423,
|
||||
author: Author.createCustomId('tolkein'),
|
||||
author: Author.createId('tolkein'),
|
||||
},
|
||||
])
|
||||
|
||||
|
@ -88,7 +104,7 @@ describe('Store', () => {
|
|||
})
|
||||
|
||||
it('allows listening to the change history', () => {
|
||||
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') })])
|
||||
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })])
|
||||
|
||||
expect(lastDiff!).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
|
@ -107,7 +123,7 @@ describe('Store', () => {
|
|||
]
|
||||
`)
|
||||
|
||||
store.update(Author.createCustomId('tolkein'), (r) => ({ ...r, name: 'Jimmy Tolks' }))
|
||||
store.update(Author.createId('tolkein'), (r) => ({ ...r, name: 'Jimmy Tolks' }))
|
||||
|
||||
expect(lastDiff!).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
|
@ -134,7 +150,7 @@ describe('Store', () => {
|
|||
]
|
||||
`)
|
||||
|
||||
store.remove([Author.createCustomId('tolkein')])
|
||||
store.remove([Author.createId('tolkein')])
|
||||
|
||||
expect(lastDiff!).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
|
@ -155,13 +171,13 @@ describe('Store', () => {
|
|||
|
||||
transact(() => {
|
||||
store.put([
|
||||
Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') }),
|
||||
Author.create({ name: 'David Foster Wallace', id: Author.createCustomId('dfw') }),
|
||||
Author.create({ name: 'Cynan Jones', id: Author.createCustomId('cj') }),
|
||||
Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }),
|
||||
Author.create({ name: 'David Foster Wallace', id: Author.createId('dfw') }),
|
||||
Author.create({ name: 'Cynan Jones', id: Author.createId('cj') }),
|
||||
])
|
||||
|
||||
store.update(Author.createCustomId('tolkein'), (r) => ({ ...r, name: 'Jimmy Tolks' }))
|
||||
store.update(Author.createCustomId('cj'), (r) => ({ ...r, name: 'Carter, Jimmy' }))
|
||||
store.update(Author.createId('tolkein'), (r) => ({ ...r, name: 'Jimmy Tolks' }))
|
||||
store.update(Author.createId('cj'), (r) => ({ ...r, name: 'Carter, Jimmy' }))
|
||||
})
|
||||
|
||||
expect(lastDiff!).toMatchInlineSnapshot(`
|
||||
|
@ -199,11 +215,11 @@ describe('Store', () => {
|
|||
/* ADDING */
|
||||
store.onAfterCreate = jest.fn((current) => {
|
||||
expect(current).toEqual(
|
||||
Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') })
|
||||
Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })
|
||||
)
|
||||
expect([...store.query.ids('author').value]).toEqual([Author.createCustomId('tolkein')])
|
||||
expect([...store.query.ids('author').value]).toEqual([Author.createId('tolkein')])
|
||||
})
|
||||
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') })])
|
||||
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })])
|
||||
|
||||
expect(store.onAfterCreate).toHaveBeenCalledTimes(1)
|
||||
|
||||
|
@ -213,11 +229,11 @@ describe('Store', () => {
|
|||
expect(prev.name).toBe('J.R.R Tolkein')
|
||||
expect(current.name).toBe('Butch Cassidy')
|
||||
|
||||
expect(store.get(Author.createCustomId('tolkein'))!.name).toBe('Butch Cassidy')
|
||||
expect(store.get(Author.createId('tolkein'))!.name).toBe('Butch Cassidy')
|
||||
}
|
||||
})
|
||||
|
||||
store.update(Author.createCustomId('tolkein'), (r) => ({ ...r, name: 'Butch Cassidy' }))
|
||||
store.update(Author.createId('tolkein'), (r) => ({ ...r, name: 'Butch Cassidy' }))
|
||||
|
||||
expect(store.onAfterChange).toHaveBeenCalledTimes(1)
|
||||
|
||||
|
@ -228,18 +244,18 @@ describe('Store', () => {
|
|||
}
|
||||
})
|
||||
|
||||
store.remove([Author.createCustomId('tolkein')])
|
||||
store.remove([Author.createId('tolkein')])
|
||||
|
||||
expect(store.onAfterDelete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('allows finding and filtering records with a predicate', () => {
|
||||
store.put([
|
||||
Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') }),
|
||||
Author.create({ name: 'James McAvoy', id: Author.createCustomId('mcavoy') }),
|
||||
Author.create({ name: 'Butch Cassidy', id: Author.createCustomId('cassidy') }),
|
||||
Author.create({ name: 'Cynan Jones', id: Author.createCustomId('cj') }),
|
||||
Author.create({ name: 'David Foster Wallace', id: Author.createCustomId('dfw') }),
|
||||
Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }),
|
||||
Author.create({ name: 'James McAvoy', id: Author.createId('mcavoy') }),
|
||||
Author.create({ name: 'Butch Cassidy', id: Author.createId('cassidy') }),
|
||||
Author.create({ name: 'Cynan Jones', id: Author.createId('cj') }),
|
||||
Author.create({ name: 'David Foster Wallace', id: Author.createId('dfw') }),
|
||||
])
|
||||
const Js = store.query.records('author').value.filter((r) => r.name.startsWith('J'))
|
||||
expect(Js.map((j) => j.name).sort()).toEqual(['J.R.R Tolkein', 'James McAvoy'])
|
||||
|
@ -259,7 +275,7 @@ describe('Store', () => {
|
|||
|
||||
expect(lastIdDiff).toBe(RESET_VALUE)
|
||||
|
||||
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') })])
|
||||
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })])
|
||||
|
||||
expect(lastIdDiff).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
|
@ -272,9 +288,9 @@ describe('Store', () => {
|
|||
`)
|
||||
|
||||
transact(() => {
|
||||
store.put([Author.create({ name: 'James McAvoy', id: Author.createCustomId('mcavoy') })])
|
||||
store.put([Author.create({ name: 'Butch Cassidy', id: Author.createCustomId('cassidy') })])
|
||||
store.remove([Author.createCustomId('tolkein')])
|
||||
store.put([Author.create({ name: 'James McAvoy', id: Author.createId('mcavoy') })])
|
||||
store.put([Author.create({ name: 'Butch Cassidy', id: Author.createId('cassidy') })])
|
||||
store.remove([Author.createId('tolkein')])
|
||||
})
|
||||
|
||||
expect(lastIdDiff).toMatchInlineSnapshot(`
|
||||
|
@ -298,21 +314,21 @@ describe('Store', () => {
|
|||
|
||||
transact(() => {
|
||||
store.put([
|
||||
Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') }),
|
||||
Author.create({ name: 'James McAvoy', id: Author.createCustomId('mcavoy') }),
|
||||
Author.create({ name: 'Butch Cassidy', id: Author.createCustomId('cassidy') }),
|
||||
Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }),
|
||||
Author.create({ name: 'James McAvoy', id: Author.createId('mcavoy') }),
|
||||
Author.create({ name: 'Butch Cassidy', id: Author.createId('cassidy') }),
|
||||
Book.create({
|
||||
title: 'The Hobbit',
|
||||
id: Book.createCustomId('hobbit'),
|
||||
author: Author.createCustomId('tolkein'),
|
||||
id: Book.createId('hobbit'),
|
||||
author: Author.createId('tolkein'),
|
||||
numPages: 300,
|
||||
}),
|
||||
])
|
||||
store.put([
|
||||
Book.create({
|
||||
title: 'The Lord of the Rings',
|
||||
id: Book.createCustomId('lotr'),
|
||||
author: Author.createCustomId('tolkein'),
|
||||
id: Book.createId('lotr'),
|
||||
author: Author.createId('tolkein'),
|
||||
numPages: 1000,
|
||||
}),
|
||||
])
|
||||
|
@ -365,11 +381,11 @@ describe('Store', () => {
|
|||
`)
|
||||
|
||||
transact(() => {
|
||||
store.update(Author.createCustomId('tolkein'), (author) => ({
|
||||
store.update(Author.createId('tolkein'), (author) => ({
|
||||
...author,
|
||||
name: 'Jimmy Tolks',
|
||||
}))
|
||||
store.update(Book.createCustomId('lotr'), (book) => ({ ...book, numPages: 42 }))
|
||||
store.update(Book.createId('lotr'), (book) => ({ ...book, numPages: 42 }))
|
||||
})
|
||||
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve))
|
||||
|
@ -418,11 +434,11 @@ describe('Store', () => {
|
|||
`)
|
||||
|
||||
transact(() => {
|
||||
store.update(Author.createCustomId('mcavoy'), (author) => ({
|
||||
store.update(Author.createId('mcavoy'), (author) => ({
|
||||
...author,
|
||||
name: 'Sookie Houseboat',
|
||||
}))
|
||||
store.remove([Book.createCustomId('lotr')])
|
||||
store.remove([Book.createId('lotr')])
|
||||
})
|
||||
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve))
|
||||
|
@ -463,8 +479,177 @@ describe('Store', () => {
|
|||
`)
|
||||
})
|
||||
|
||||
it('supports filtering history by scope', () => {
|
||||
const listener = jest.fn()
|
||||
store.listen(listener, {
|
||||
scope: 'session',
|
||||
})
|
||||
|
||||
store.put([
|
||||
Author.create({ name: 'J.R.R Tolkien', id: Author.createId('tolkien') }),
|
||||
Book.create({
|
||||
title: 'The Hobbit',
|
||||
id: Book.createId('hobbit'),
|
||||
author: Author.createId('tolkien'),
|
||||
numPages: 300,
|
||||
}),
|
||||
])
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(0)
|
||||
|
||||
store.put([
|
||||
Author.create({ name: 'J.D. Salinger', id: Author.createId('salinger') }),
|
||||
Visit.create({ id: Visit.createId('jimmy'), visitorName: 'Jimmy Beans' }),
|
||||
])
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(listener.mock.calls[0][0].changes).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"added": Object {
|
||||
"visit:jimmy": Object {
|
||||
"booksInBasket": Array [],
|
||||
"id": "visit:jimmy",
|
||||
"typeName": "visit",
|
||||
"visitorName": "Jimmy Beans",
|
||||
},
|
||||
},
|
||||
"removed": Object {},
|
||||
"updated": Object {},
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('supports filtering history by scope (2)', () => {
|
||||
const listener = jest.fn()
|
||||
store.listen(listener, {
|
||||
scope: 'document',
|
||||
})
|
||||
|
||||
store.put([
|
||||
Author.create({ name: 'J.D. Salinger', id: Author.createId('salinger') }),
|
||||
Visit.create({ id: Visit.createId('jimmy'), visitorName: 'Jimmy Beans' }),
|
||||
])
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(listener.mock.calls[0][0].changes).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"added": Object {
|
||||
"author:salinger": Object {
|
||||
"id": "author:salinger",
|
||||
"isPseudonym": false,
|
||||
"name": "J.D. Salinger",
|
||||
"typeName": "author",
|
||||
},
|
||||
},
|
||||
"removed": Object {},
|
||||
"updated": Object {},
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('supports filtering history by source', () => {
|
||||
const listener = jest.fn()
|
||||
store.listen(listener, {
|
||||
source: 'remote',
|
||||
})
|
||||
|
||||
store.put([
|
||||
Author.create({ name: 'J.D. Salinger', id: Author.createId('salinger') }),
|
||||
Visit.create({ id: Visit.createId('jimmy'), visitorName: 'Jimmy Beans' }),
|
||||
])
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(0)
|
||||
|
||||
store.mergeRemoteChanges(() => {
|
||||
store.put([
|
||||
Author.create({ name: 'J.R.R Tolkien', id: Author.createId('tolkien') }),
|
||||
Book.create({
|
||||
title: 'The Hobbit',
|
||||
id: Book.createId('hobbit'),
|
||||
author: Author.createId('tolkien'),
|
||||
numPages: 300,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(listener.mock.calls[0][0].changes).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"added": Object {
|
||||
"author:tolkien": Object {
|
||||
"id": "author:tolkien",
|
||||
"isPseudonym": false,
|
||||
"name": "J.R.R Tolkien",
|
||||
"typeName": "author",
|
||||
},
|
||||
"book:hobbit": Object {
|
||||
"author": "author:tolkien",
|
||||
"id": "book:hobbit",
|
||||
"numPages": 300,
|
||||
"title": "The Hobbit",
|
||||
"typeName": "book",
|
||||
},
|
||||
},
|
||||
"removed": Object {},
|
||||
"updated": Object {},
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('supports filtering history by source (user)', () => {
|
||||
const listener = jest.fn()
|
||||
store.listen(listener, {
|
||||
source: 'user',
|
||||
})
|
||||
|
||||
store.mergeRemoteChanges(() => {
|
||||
store.put([
|
||||
Author.create({ name: 'J.R.R Tolkien', id: Author.createId('tolkien') }),
|
||||
Book.create({
|
||||
title: 'The Hobbit',
|
||||
id: Book.createId('hobbit'),
|
||||
author: Author.createId('tolkien'),
|
||||
numPages: 300,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(0)
|
||||
|
||||
store.put([
|
||||
Author.create({ name: 'J.D. Salinger', id: Author.createId('salinger') }),
|
||||
Visit.create({ id: Visit.createId('jimmy'), visitorName: 'Jimmy Beans' }),
|
||||
])
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(listener.mock.calls[0][0].changes).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"added": Object {
|
||||
"author:salinger": Object {
|
||||
"id": "author:salinger",
|
||||
"isPseudonym": false,
|
||||
"name": "J.D. Salinger",
|
||||
"typeName": "author",
|
||||
},
|
||||
"visit:jimmy": Object {
|
||||
"booksInBasket": Array [],
|
||||
"id": "visit:jimmy",
|
||||
"typeName": "visit",
|
||||
"visitorName": "Jimmy Beans",
|
||||
},
|
||||
},
|
||||
"removed": Object {},
|
||||
"updated": Object {},
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('does not keep global history if no listeners are attached', () => {
|
||||
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') })])
|
||||
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })])
|
||||
expect((store as any).historyAccumulator._history).toHaveLength(0)
|
||||
})
|
||||
|
||||
|
@ -472,12 +657,12 @@ describe('Store', () => {
|
|||
try {
|
||||
// @ts-expect-error
|
||||
globalThis.__FORCE_RAF_IN_TESTS__ = true
|
||||
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') })])
|
||||
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })])
|
||||
const firstListener = jest.fn()
|
||||
store.listen(firstListener)
|
||||
expect(firstListener).toHaveBeenCalledTimes(0)
|
||||
|
||||
store.put([Author.create({ name: 'Chips McCoy', id: Author.createCustomId('chips') })])
|
||||
store.put([Author.create({ name: 'Chips McCoy', id: Author.createId('chips') })])
|
||||
|
||||
expect(firstListener).toHaveBeenCalledTimes(0)
|
||||
|
||||
|
@ -499,19 +684,19 @@ describe('Store', () => {
|
|||
})
|
||||
|
||||
it('does not overwrite default properties with undefined', () => {
|
||||
const tolkein = Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') })
|
||||
const tolkein = Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })
|
||||
expect(tolkein.isPseudonym).toBe(false)
|
||||
|
||||
const harkaway = Author.create({
|
||||
name: 'Nick Harkaway',
|
||||
id: Author.createCustomId('harkaway'),
|
||||
id: Author.createId('harkaway'),
|
||||
isPseudonym: true,
|
||||
})
|
||||
expect(harkaway.isPseudonym).toBe(true)
|
||||
|
||||
const burns = Author.create({
|
||||
name: 'Anna Burns',
|
||||
id: Author.createCustomId('burns'),
|
||||
id: Author.createId('burns'),
|
||||
isPseudonym: undefined,
|
||||
})
|
||||
|
||||
|
@ -519,7 +704,7 @@ describe('Store', () => {
|
|||
})
|
||||
|
||||
it('allows changed to be merged without triggering listeners', () => {
|
||||
const id = Author.createCustomId('tolkein')
|
||||
const id = Author.createId('tolkein')
|
||||
store.put([Author.create({ name: 'J.R.R Tolkein', id })])
|
||||
|
||||
const listener = jest.fn()
|
||||
|
@ -535,19 +720,19 @@ describe('Store', () => {
|
|||
const listener = jest.fn()
|
||||
store.listen(listener)
|
||||
|
||||
store.put([Author.create({ name: 'Jimmy Beans', id: Author.createCustomId('jimmy') })])
|
||||
store.put([Author.create({ name: 'Jimmy Beans', id: Author.createId('jimmy') })])
|
||||
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve))
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(listener.mock.calls[0][0].source).toBe('user')
|
||||
|
||||
store.mergeRemoteChanges(() => {
|
||||
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') })])
|
||||
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })])
|
||||
store.put([
|
||||
Book.create({
|
||||
title: 'The Hobbit',
|
||||
id: Book.createCustomId('hobbit'),
|
||||
author: Author.createCustomId('tolkein'),
|
||||
id: Book.createId('hobbit'),
|
||||
author: Author.createId('tolkein'),
|
||||
numPages: 300,
|
||||
}),
|
||||
])
|
||||
|
@ -557,7 +742,7 @@ describe('Store', () => {
|
|||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
expect(listener.mock.calls[1][0].source).toBe('remote')
|
||||
|
||||
store.put([Author.create({ name: 'Steve Ok', id: Author.createCustomId('stever') })])
|
||||
store.put([Author.create({ name: 'Steve Ok', id: Author.createId('stever') })])
|
||||
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve))
|
||||
expect(listener).toHaveBeenCalledTimes(3)
|
||||
|
@ -588,21 +773,21 @@ describe('snapshots', () => {
|
|||
|
||||
transact(() => {
|
||||
store.put([
|
||||
Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') }),
|
||||
Author.create({ name: 'James McAvoy', id: Author.createCustomId('mcavoy') }),
|
||||
Author.create({ name: 'Butch Cassidy', id: Author.createCustomId('cassidy') }),
|
||||
Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }),
|
||||
Author.create({ name: 'James McAvoy', id: Author.createId('mcavoy') }),
|
||||
Author.create({ name: 'Butch Cassidy', id: Author.createId('cassidy') }),
|
||||
Book.create({
|
||||
title: 'The Hobbit',
|
||||
id: Book.createCustomId('hobbit'),
|
||||
author: Author.createCustomId('tolkein'),
|
||||
id: Book.createId('hobbit'),
|
||||
author: Author.createId('tolkein'),
|
||||
numPages: 300,
|
||||
}),
|
||||
])
|
||||
store.put([
|
||||
Book.create({
|
||||
title: 'The Lord of the Rings',
|
||||
id: Book.createCustomId('lotr'),
|
||||
author: Author.createCustomId('tolkein'),
|
||||
id: Book.createId('lotr'),
|
||||
author: Author.createId('tolkein'),
|
||||
numPages: 1000,
|
||||
}),
|
||||
])
|
||||
|
@ -610,7 +795,7 @@ describe('snapshots', () => {
|
|||
})
|
||||
|
||||
it('creates and loads a snapshot', () => {
|
||||
const serializedStore1 = store.serialize()
|
||||
const serializedStore1 = store.serialize('all')
|
||||
const serializedSchema1 = store.schema.serialize()
|
||||
|
||||
const snapshot1 = store.getSnapshot()
|
||||
|
@ -634,7 +819,7 @@ describe('snapshots', () => {
|
|||
|
||||
store2.loadSnapshot(snapshot1)
|
||||
|
||||
const serializedStore2 = store2.serialize()
|
||||
const serializedStore2 = store2.serialize('all')
|
||||
const serializedSchema2 = store2.schema.serialize()
|
||||
const snapshot2 = store2.getSnapshot()
|
||||
|
||||
|
|
|
@ -156,7 +156,7 @@ function getRandomBookOp(
|
|||
return {
|
||||
type: 'add',
|
||||
record: Book.create({
|
||||
id: Book.createCustomId(getRandomNumber().toString()),
|
||||
id: Book.createId(getRandomNumber().toString()),
|
||||
authorId: author.id,
|
||||
title: getRandomBookName(getRandomNumber),
|
||||
}),
|
||||
|
@ -214,7 +214,7 @@ function getRandomAuthorOp(
|
|||
return {
|
||||
type: 'add',
|
||||
record: Author.create({
|
||||
id: Author.createCustomId(getRandomNumber().toString()),
|
||||
id: Author.createId(getRandomNumber().toString()),
|
||||
name: getRandomAuthorName(getRandomNumber),
|
||||
}),
|
||||
}
|
||||
|
@ -355,7 +355,7 @@ function runTest(seed: number) {
|
|||
const authorNameIndexDiffs: RSIndexDiff<Author>[] = []
|
||||
const authorIdIndexDiffs: RSIndexDiff<Book>[] = []
|
||||
|
||||
const authorIdQueryParam = atom('authorId', Author.createCustomId('does-not-exist'))
|
||||
const authorIdQueryParam = atom('authorId', Author.createId('does-not-exist'))
|
||||
const bookTitleQueryParam = atom('bookTitle', getRandomBookName(getRandomNumber))
|
||||
|
||||
const booksByAuthorQuery = store.query.records('book', () => ({
|
||||
|
|
|
@ -2,11 +2,18 @@ import { testSchemaV1 } from './testSchema.v1'
|
|||
|
||||
describe('parseId', () => {
|
||||
it('should return the part after the colon', () => {
|
||||
expect(testSchemaV1.types.user.parseId('user:123')).toBe('123')
|
||||
expect(testSchemaV1.types.shape.parseId('shape:xyz')).toBe('xyz')
|
||||
expect(testSchemaV1.types.user.parseId('user:123' as any)).toBe('123')
|
||||
expect(testSchemaV1.types.shape.parseId('shape:xyz' as any)).toBe('xyz')
|
||||
})
|
||||
it('should throw if the typename does not match', () => {
|
||||
expect(() => testSchemaV1.types.user.parseId('shape:123')).toThrow()
|
||||
expect(() => testSchemaV1.types.shape.parseId('user:xyz')).toThrow()
|
||||
expect(() => testSchemaV1.types.user.parseId('shape:123' as any)).toThrow()
|
||||
expect(() => testSchemaV1.types.shape.parseId('user:xyz' as any)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createId', () => {
|
||||
it('should prepend the typename and a colon', () => {
|
||||
expect(testSchemaV1.types.user.createId('123')).toBe('user:123')
|
||||
expect(testSchemaV1.types.shape.createId('xyz')).toBe('shape:xyz')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -67,7 +67,7 @@ describe('Store with validation', () => {
|
|||
})
|
||||
|
||||
it('Accepts valid records and rejects invalid records', () => {
|
||||
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') })])
|
||||
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })])
|
||||
|
||||
expect(store.query.records('author').value).toEqual([
|
||||
{ id: 'author:tolkein', typeName: 'author', name: 'J.R.R Tolkein', isPseudonym: false },
|
||||
|
@ -76,11 +76,11 @@ describe('Store with validation', () => {
|
|||
expect(() => {
|
||||
store.put([
|
||||
{
|
||||
id: Book.createCustomId('the-hobbit'),
|
||||
id: Book.createId('the-hobbit'),
|
||||
typeName: 'book',
|
||||
title: 'The Hobbit',
|
||||
numPages: -1, // <---- Invalid!
|
||||
author: Author.createCustomId('tolkein'),
|
||||
author: Author.createId('tolkein'),
|
||||
},
|
||||
])
|
||||
}).toThrow()
|
||||
|
@ -93,9 +93,9 @@ describe('Validating initial data', () => {
|
|||
let snapshot: StoreSnapshot<Book | Author>
|
||||
|
||||
beforeEach(() => {
|
||||
const authorId = Author.createCustomId('tolkein')
|
||||
const authorId = Author.createId('tolkein')
|
||||
const authorRecord = Author.create({ name: 'J.R.R Tolkein', id: authorId })
|
||||
const bookId = Book.createCustomId('the-hobbit')
|
||||
const bookId = Book.createId('the-hobbit')
|
||||
const bookRecord = Book.create({
|
||||
title: 'The Hobbit',
|
||||
numPages: 300,
|
||||
|
@ -124,8 +124,8 @@ describe('Validating initial data', () => {
|
|||
})
|
||||
|
||||
describe('Create & update validations', () => {
|
||||
const authorId = Author.createCustomId('tolkein')
|
||||
const bookId = Book.createCustomId('the-hobbit')
|
||||
const authorId = Author.createId('tolkein')
|
||||
const bookId = Book.createId('the-hobbit')
|
||||
const initialAuthor = Author.create({ name: 'J.R.R Tolkein', id: authorId })
|
||||
const invalidBook = Book.create({
|
||||
// @ts-expect-error - deliberately invalid data
|
||||
|
@ -165,7 +165,7 @@ describe('Create & update validations', () => {
|
|||
})
|
||||
|
||||
it('Prevents adding invalid records to a store', () => {
|
||||
const newAuthorId = Author.createCustomId('shearing')
|
||||
const newAuthorId = Author.createId('shearing')
|
||||
const store = new Store<Book | Author>({
|
||||
schema,
|
||||
initialData: { [bookId]: validBook, [authorId]: initialAuthor },
|
||||
|
|
|
@ -327,10 +327,10 @@ export const iconValidator: T.Validator<"activity" | "airplay" | "alert-circle"
|
|||
export function idValidator<Id extends RecordId<UnknownRecord>>(prefix: Id['__type__']['typeName']): T.Validator<Id>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const InstancePageStateRecordType: RecordType<TLInstancePageState, "cameraId" | "instanceId" | "pageId">;
|
||||
export const InstancePageStateRecordType: RecordType<TLInstancePageState, "pageId">;
|
||||
|
||||
// @public (undocumented)
|
||||
export const InstancePresenceRecordType: RecordType<TLInstancePresence, "currentPageId" | "instanceId" | "userId" | "userName">;
|
||||
export const InstancePresenceRecordType: RecordType<TLInstancePresence, "currentPageId" | "userId" | "userName">;
|
||||
|
||||
// @public (undocumented)
|
||||
export const InstanceRecordType: RecordType<TLInstance, "currentPageId">;
|
||||
|
@ -452,6 +452,9 @@ export const LANGUAGES: readonly [{
|
|||
// @internal (undocumented)
|
||||
export const opacityValidator: T.Validator<"0.1" | "0.25" | "0.5" | "0.75" | "1">;
|
||||
|
||||
// @internal (undocumented)
|
||||
export const pageIdValidator: T.Validator<TLPageId>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const PageRecordType: RecordType<TLPage, "index" | "name">;
|
||||
|
||||
|
@ -839,6 +842,10 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
|
|||
// (undocumented)
|
||||
isFocusMode: boolean;
|
||||
// (undocumented)
|
||||
isGridMode: boolean;
|
||||
// (undocumented)
|
||||
isPenMode: boolean;
|
||||
// (undocumented)
|
||||
isToolLocked: boolean;
|
||||
// (undocumented)
|
||||
propsForNextShape: TLInstancePropsForNextShape;
|
||||
|
@ -850,13 +857,14 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
|
|||
zoomBrush: Box2dModel | null;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const TLINSTANCE_ID: TLInstanceId;
|
||||
|
||||
// @public (undocumented)
|
||||
export type TLInstanceId = RecordId<TLInstance>;
|
||||
|
||||
// @public
|
||||
export interface TLInstancePageState extends BaseRecord<'instance_page_state', TLInstancePageStateId> {
|
||||
// (undocumented)
|
||||
cameraId: RecordId<TLCamera>;
|
||||
// (undocumented)
|
||||
croppingId: null | TLShapeId;
|
||||
// (undocumented)
|
||||
|
@ -870,8 +878,6 @@ export interface TLInstancePageState extends BaseRecord<'instance_page_state', T
|
|||
// (undocumented)
|
||||
hoveredId: null | TLShapeId;
|
||||
// (undocumented)
|
||||
instanceId: RecordId<TLInstance>;
|
||||
// (undocumented)
|
||||
pageId: RecordId<TLPage>;
|
||||
// (undocumented)
|
||||
selectedIds: TLShapeId[];
|
||||
|
@ -901,8 +907,6 @@ export interface TLInstancePresence extends BaseRecord<'instance_presence', TLIn
|
|||
// (undocumented)
|
||||
followingUserId: null | string;
|
||||
// (undocumented)
|
||||
instanceId: TLInstanceId;
|
||||
// (undocumented)
|
||||
lastActivityTimestamp: number;
|
||||
// (undocumented)
|
||||
screenBounds: Box2dModel;
|
||||
|
@ -962,7 +966,7 @@ export type TLParentId = TLPageId | TLShapeId;
|
|||
export const TLPOINTER_ID: TLPointerId;
|
||||
|
||||
// @public (undocumented)
|
||||
export type TLRecord = TLAsset | TLCamera | TLDocument | TLInstance | TLInstancePageState | TLInstancePresence | TLPage | TLPointer | TLShape | TLUserDocument;
|
||||
export type TLRecord = TLAsset | TLCamera | TLDocument | TLInstance | TLInstancePageState | TLInstancePresence | TLPage | TLPointer | TLShape;
|
||||
|
||||
// @public
|
||||
export type TLScribble = {
|
||||
|
@ -1020,8 +1024,6 @@ export type TLStore = Store<TLRecord, TLStoreProps>;
|
|||
|
||||
// @public (undocumented)
|
||||
export type TLStoreProps = {
|
||||
instanceId: TLInstanceId;
|
||||
documentId: typeof TLDOCUMENT_ID;
|
||||
defaultName: string;
|
||||
};
|
||||
|
||||
|
@ -1087,22 +1089,6 @@ export type TLTextShapeProps = {
|
|||
// @public
|
||||
export type TLUnknownShape = TLBaseShape<string, object>;
|
||||
|
||||
// @public
|
||||
export interface TLUserDocument extends BaseRecord<'user_document', TLUserDocumentId> {
|
||||
// (undocumented)
|
||||
isGridMode: boolean;
|
||||
// (undocumented)
|
||||
isMobileMode: boolean;
|
||||
// (undocumented)
|
||||
isPenMode: boolean;
|
||||
// (undocumented)
|
||||
isSnapMode: boolean;
|
||||
// (undocumented)
|
||||
lastUpdatedPageId: null | RecordId<TLPage>;
|
||||
// (undocumented)
|
||||
lastUsedTabId: null | RecordId<TLInstance>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export type TLVerticalAlignType = SetValue<typeof TL_VERTICAL_ALIGN_TYPES>;
|
||||
|
||||
|
@ -1119,9 +1105,6 @@ export type TLVideoAsset = TLBaseAsset<'video', {
|
|||
// @public (undocumented)
|
||||
export type TLVideoShape = TLBaseShape<'video', TLVideoShapeProps>;
|
||||
|
||||
// @internal (undocumented)
|
||||
export const UserDocumentRecordType: RecordType<TLUserDocument, never>;
|
||||
|
||||
// @public
|
||||
export interface Vec2dModel {
|
||||
// (undocumented)
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
import { Store, StoreSchema, StoreSchemaOptions, StoreSnapshot } from '@tldraw/store'
|
||||
import { annotateError, structuredClone } from '@tldraw/utils'
|
||||
import { CameraRecordType } from './records/TLCamera'
|
||||
import { CameraRecordType, TLCameraId } from './records/TLCamera'
|
||||
import { DocumentRecordType, TLDOCUMENT_ID } from './records/TLDocument'
|
||||
import { InstanceRecordType, TLInstanceId } from './records/TLInstance'
|
||||
import { PageRecordType } from './records/TLPage'
|
||||
import { InstancePageStateRecordType } from './records/TLPageState'
|
||||
import { InstanceRecordType, TLINSTANCE_ID } from './records/TLInstance'
|
||||
import { PageRecordType, TLPageId } from './records/TLPage'
|
||||
import { InstancePageStateRecordType, TLInstancePageStateId } from './records/TLPageState'
|
||||
import { PointerRecordType, TLPOINTER_ID } from './records/TLPointer'
|
||||
import { TLRecord } from './records/TLRecord'
|
||||
import { UserDocumentRecordType } from './records/TLUserDocument'
|
||||
|
||||
function sortByIndex<T extends { index: string }>(a: T, b: T) {
|
||||
if (a.index < b.index) {
|
||||
|
@ -38,8 +37,6 @@ export type TLStoreSnapshot = StoreSnapshot<TLRecord>
|
|||
|
||||
/** @public */
|
||||
export type TLStoreProps = {
|
||||
instanceId: TLInstanceId
|
||||
documentId: typeof TLDOCUMENT_ID
|
||||
defaultName: string
|
||||
}
|
||||
|
||||
|
@ -79,17 +76,9 @@ function getDefaultPages() {
|
|||
|
||||
/** @internal */
|
||||
export function createIntegrityChecker(store: TLStore): () => void {
|
||||
const $pages = store.query.records('page')
|
||||
const $userDocumentSettings = store.query.record('user_document')
|
||||
|
||||
const $instanceState = store.query.record('instance', () => ({
|
||||
id: { eq: store.props.instanceId },
|
||||
}))
|
||||
|
||||
const $instancePageStates = store.query.records('instance_page_state')
|
||||
const $pageIds = store.query.ids('page')
|
||||
|
||||
const ensureStoreIsUsable = (): void => {
|
||||
const { instanceId: tabId } = store.props
|
||||
// make sure we have exactly one document
|
||||
if (!store.has(TLDOCUMENT_ID)) {
|
||||
store.put([DocumentRecordType.create({ id: TLDOCUMENT_ID, name: store.props.defaultName })])
|
||||
|
@ -101,78 +90,58 @@ export function createIntegrityChecker(store: TLStore): () => void {
|
|||
return ensureStoreIsUsable()
|
||||
}
|
||||
|
||||
// make sure we have document state for the current user
|
||||
const userDocumentSettings = $userDocumentSettings.value
|
||||
|
||||
if (!userDocumentSettings) {
|
||||
store.put([UserDocumentRecordType.create({})])
|
||||
return ensureStoreIsUsable()
|
||||
}
|
||||
|
||||
// make sure there is at least one page
|
||||
const pages = $pages.value.sort(sortByIndex)
|
||||
if (pages.length === 0) {
|
||||
const pageIds = $pageIds.value
|
||||
if (pageIds.size === 0) {
|
||||
store.put(getDefaultPages())
|
||||
return ensureStoreIsUsable()
|
||||
}
|
||||
|
||||
const getFirstPageId = () => [...pageIds].map((id) => store.get(id)!).sort(sortByIndex)[0].id!
|
||||
|
||||
// make sure we have state for the current user's current tab
|
||||
const instanceState = $instanceState.value
|
||||
const instanceState = store.get(TLINSTANCE_ID)
|
||||
if (!instanceState) {
|
||||
// The tab props are either the the last used tab's props or undefined
|
||||
const propsForNextShape = userDocumentSettings.lastUsedTabId
|
||||
? store.get(userDocumentSettings.lastUsedTabId)?.propsForNextShape
|
||||
: undefined
|
||||
|
||||
// The current page is either the last updated page or the first page
|
||||
const currentPageId = userDocumentSettings?.lastUpdatedPageId ?? pages[0].id!
|
||||
|
||||
store.put([
|
||||
InstanceRecordType.create({
|
||||
id: tabId,
|
||||
currentPageId,
|
||||
propsForNextShape,
|
||||
id: TLINSTANCE_ID,
|
||||
currentPageId: getFirstPageId(),
|
||||
exportBackground: true,
|
||||
}),
|
||||
])
|
||||
|
||||
return ensureStoreIsUsable()
|
||||
}
|
||||
|
||||
// make sure the user's currentPageId is still valid
|
||||
let currentPageId = instanceState.currentPageId
|
||||
if (!pages.find((p) => p.id === currentPageId)) {
|
||||
currentPageId = pages[0].id!
|
||||
store.put([{ ...instanceState, currentPageId }])
|
||||
} else if (!pageIds.has(instanceState.currentPageId)) {
|
||||
store.put([{ ...instanceState, currentPageId: getFirstPageId() }])
|
||||
return ensureStoreIsUsable()
|
||||
}
|
||||
|
||||
for (const page of pages) {
|
||||
const instancePageStates = $instancePageStates.value.filter(
|
||||
(tps) => tps.pageId === page.id && tps.instanceId === tabId
|
||||
)
|
||||
if (instancePageStates.length > 1) {
|
||||
// make sure we only have one instancePageState per instance per page
|
||||
store.remove(instancePageStates.slice(1).map((ips) => ips.id))
|
||||
} else if (instancePageStates.length === 0) {
|
||||
const camera = CameraRecordType.create({})
|
||||
store.put([
|
||||
camera,
|
||||
InstancePageStateRecordType.create({
|
||||
pageId: page.id,
|
||||
instanceId: tabId,
|
||||
cameraId: camera.id,
|
||||
}),
|
||||
])
|
||||
return ensureStoreIsUsable()
|
||||
// make sure we have page states and cameras for all the pages
|
||||
const missingPageStateIds = new Set<TLInstancePageStateId>()
|
||||
const missingCameraIds = new Set<TLCameraId>()
|
||||
for (const id of pageIds) {
|
||||
const pageStateId = InstancePageStateRecordType.createId(id)
|
||||
if (!store.has(pageStateId)) {
|
||||
missingPageStateIds.add(pageStateId)
|
||||
}
|
||||
const cameraId = CameraRecordType.createId(id)
|
||||
if (!store.has(cameraId)) {
|
||||
missingCameraIds.add(cameraId)
|
||||
}
|
||||
}
|
||||
|
||||
// make sure the camera exists
|
||||
const camera = store.get(instancePageStates[0].cameraId)
|
||||
if (!camera) {
|
||||
store.put([CameraRecordType.create({ id: instancePageStates[0].cameraId })])
|
||||
return ensureStoreIsUsable()
|
||||
}
|
||||
if (missingPageStateIds.size > 0) {
|
||||
store.put(
|
||||
[...missingPageStateIds].map((id) =>
|
||||
InstancePageStateRecordType.create({
|
||||
id,
|
||||
pageId: InstancePageStateRecordType.parseId(id) as TLPageId,
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
if (missingCameraIds.size > 0) {
|
||||
store.put([...missingCameraIds].map((id) => CameraRecordType.create({ id })))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,37 +1,27 @@
|
|||
import { Signal, computed } from 'signia'
|
||||
import { TLStore } from './TLStore'
|
||||
import { CameraRecordType } from './records/TLCamera'
|
||||
import { TLINSTANCE_ID } from './records/TLInstance'
|
||||
import { InstancePageStateRecordType } from './records/TLPageState'
|
||||
import { TLPOINTER_ID } from './records/TLPointer'
|
||||
import { InstancePresenceRecordType, TLInstancePresence } from './records/TLPresence'
|
||||
|
||||
/** @internal */
|
||||
export const createPresenceStateDerivation =
|
||||
($user: Signal<{ id: string; color: string; name: string }>) =>
|
||||
(store: TLStore): Signal<TLInstancePresence | null> => {
|
||||
const $instance = store.query.record('instance', () => ({
|
||||
id: { eq: store.props.instanceId },
|
||||
}))
|
||||
const $pageState = store.query.record('instance_page_state', () => ({
|
||||
instanceId: { eq: store.props.instanceId },
|
||||
pageId: { eq: $instance.value?.currentPageId ?? ('' as any) },
|
||||
}))
|
||||
const $camera = store.query.record('camera', () => ({
|
||||
id: { eq: $pageState.value?.cameraId ?? ('' as any) },
|
||||
}))
|
||||
|
||||
const $pointer = store.query.record('pointer')
|
||||
|
||||
return computed('instancePresence', () => {
|
||||
const pageState = $pageState.value
|
||||
const instance = $instance.value
|
||||
const camera = $camera.value
|
||||
const pointer = $pointer.value
|
||||
const instance = store.get(TLINSTANCE_ID)
|
||||
const pageState = store.get(InstancePageStateRecordType.createId(instance?.currentPageId))
|
||||
const camera = store.get(CameraRecordType.createId(instance?.currentPageId))
|
||||
const pointer = store.get(TLPOINTER_ID)
|
||||
const user = $user.value
|
||||
if (!pageState || !instance || !camera || !pointer || !user) {
|
||||
return null
|
||||
}
|
||||
|
||||
return InstancePresenceRecordType.create({
|
||||
id: InstancePresenceRecordType.createCustomId(store.props.instanceId),
|
||||
instanceId: store.props.instanceId,
|
||||
id: InstancePresenceRecordType.createId(store.id),
|
||||
selectedIds: pageState.selectedIds,
|
||||
brush: instance.brush,
|
||||
scribble: instance.scribble,
|
||||
|
|
|
@ -11,7 +11,6 @@ import { PointerRecordType } from './records/TLPointer'
|
|||
import { InstancePresenceRecordType } from './records/TLPresence'
|
||||
import { TLRecord } from './records/TLRecord'
|
||||
import { TLShape, rootShapeMigrations } from './records/TLShape'
|
||||
import { UserDocumentRecordType } from './records/TLUserDocument'
|
||||
import { arrowShapeMigrations, arrowShapeValidator } from './shapes/TLArrowShape'
|
||||
import { bookmarkShapeMigrations, bookmarkShapeValidator } from './shapes/TLBookmarkShape'
|
||||
import { drawShapeMigrations, drawShapeValidator } from './shapes/TLDrawShape'
|
||||
|
@ -144,7 +143,6 @@ export function createTLSchema(
|
|||
instance_page_state: InstancePageStateRecordType,
|
||||
page: PageRecordType,
|
||||
shape: ShapeRecordType,
|
||||
user_document: UserDocumentRecordType,
|
||||
instance_presence: InstancePresenceRecordType,
|
||||
pointer: PointerRecordType,
|
||||
},
|
||||
|
|
|
@ -30,12 +30,19 @@ export { CameraRecordType, type TLCamera, type TLCameraId } from './records/TLCa
|
|||
export { DocumentRecordType, TLDOCUMENT_ID, type TLDocument } from './records/TLDocument'
|
||||
export {
|
||||
InstanceRecordType,
|
||||
TLINSTANCE_ID,
|
||||
instanceTypeValidator,
|
||||
type TLInstance,
|
||||
type TLInstanceId,
|
||||
type TLInstancePropsForNextShape,
|
||||
} from './records/TLInstance'
|
||||
export { PageRecordType, isPageId, type TLPage, type TLPageId } from './records/TLPage'
|
||||
export {
|
||||
PageRecordType,
|
||||
isPageId,
|
||||
pageIdValidator,
|
||||
type TLPage,
|
||||
type TLPageId,
|
||||
} from './records/TLPage'
|
||||
export { InstancePageStateRecordType, type TLInstancePageState } from './records/TLPageState'
|
||||
export { PointerRecordType, TLPOINTER_ID } from './records/TLPointer'
|
||||
export { InstancePresenceRecordType, type TLInstancePresence } from './records/TLPresence'
|
||||
|
@ -55,7 +62,6 @@ export {
|
|||
type TLShapeProps,
|
||||
type TLUnknownShape,
|
||||
} from './records/TLShape'
|
||||
export { UserDocumentRecordType, type TLUserDocument } from './records/TLUserDocument'
|
||||
export {
|
||||
type TLArrowShape,
|
||||
type TLArrowShapeProps,
|
||||
|
|
|
@ -1,14 +1,12 @@
|
|||
import { createRecordType, Migrations, Store } from '@tldraw/store'
|
||||
import { structuredClone } from '@tldraw/utils'
|
||||
import { Migrations, Store, createRecordType } from '@tldraw/store'
|
||||
import fs from 'fs'
|
||||
import { imageAssetMigrations } from './assets/TLImageAsset'
|
||||
import { videoAssetMigrations } from './assets/TLVideoAsset'
|
||||
import { documentMigrations } from './records/TLDocument'
|
||||
import { instanceMigrations, instanceTypeVersions } from './records/TLInstance'
|
||||
import { instancePageStateMigrations } from './records/TLPageState'
|
||||
import { instancePresenceMigrations } from './records/TLPresence'
|
||||
import { rootShapeMigrations, TLShape } from './records/TLShape'
|
||||
import { userDocumentMigrations, userDocumentVersions } from './records/TLUserDocument'
|
||||
import { instancePageStateMigrations, instancePageStateVersions } from './records/TLPageState'
|
||||
import { instancePresenceMigrations, instancePresenceVersions } from './records/TLPresence'
|
||||
import { TLShape, rootShapeMigrations } from './records/TLShape'
|
||||
import { arrowShapeMigrations } from './shapes/TLArrowShape'
|
||||
import { bookmarkShapeMigrations } from './shapes/TLBookmarkShape'
|
||||
import { drawShapeMigrations } from './shapes/TLDrawShape'
|
||||
|
@ -266,21 +264,6 @@ describe('Removing dialogs from instance', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('Adding snap mode', () => {
|
||||
const { up, down } = userDocumentMigrations.migrators[1]
|
||||
test('up works as expected', () => {
|
||||
const before = {}
|
||||
const after = { isSnapMode: false }
|
||||
expect(up(before)).toStrictEqual(after)
|
||||
})
|
||||
|
||||
test('down works as expected', () => {
|
||||
const before = { isSnapMode: false }
|
||||
const after = {}
|
||||
expect(down(before)).toStrictEqual(after)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Adding url props', () => {
|
||||
for (const [name, { up, down }] of [
|
||||
['video shape', videoShapeMigrations.migrators[1]],
|
||||
|
@ -336,19 +319,6 @@ describe('Renaming asset props', () => {
|
|||
}
|
||||
})
|
||||
|
||||
describe('Adding the missing isMobileMode', () => {
|
||||
const { up, down } = userDocumentMigrations.migrators[2]
|
||||
test('up works as expected', () => {
|
||||
expect(up({})).toMatchObject({ isMobileMode: false })
|
||||
expect(up({ isMobileMode: true })).toMatchObject({ isMobileMode: true })
|
||||
})
|
||||
|
||||
test('down works as expected', () => {
|
||||
expect(down({ isMobileMode: true })).toStrictEqual({})
|
||||
expect(down({ isMobileMode: false })).toStrictEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Adding instance.isToolLocked', () => {
|
||||
const { up, down } = instanceMigrations.migrators[3]
|
||||
test('up works as expected', () => {
|
||||
|
@ -760,43 +730,6 @@ describe('Migrate NoteShape legacy horizontal alignment', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('Removing isReadOnly from user_document', () => {
|
||||
const { up, down } = userDocumentMigrations.migrators[userDocumentVersions.RemoveIsReadOnly]
|
||||
const prev = {
|
||||
id: 'user_document:123',
|
||||
typeName: 'user_document',
|
||||
userId: 'user:123',
|
||||
isReadOnly: false,
|
||||
isPenMode: false,
|
||||
isGridMode: false,
|
||||
isDarkMode: false,
|
||||
isMobileMode: false,
|
||||
isSnapMode: false,
|
||||
lastUpdatedPageId: null,
|
||||
lastUsedTabId: null,
|
||||
}
|
||||
|
||||
const next = {
|
||||
id: 'user_document:123',
|
||||
typeName: 'user_document',
|
||||
userId: 'user:123',
|
||||
isPenMode: false,
|
||||
isGridMode: false,
|
||||
isDarkMode: false,
|
||||
isMobileMode: false,
|
||||
isSnapMode: false,
|
||||
lastUpdatedPageId: null,
|
||||
lastUsedTabId: null,
|
||||
}
|
||||
|
||||
test('up removes the isReadOnly property', () => {
|
||||
expect(up(prev)).toEqual(next)
|
||||
})
|
||||
test('down adds the isReadOnly property', () => {
|
||||
expect(down(next)).toEqual(prev)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Adds delay to scribble', () => {
|
||||
const { up, down } = instanceMigrations.migrators[10]
|
||||
|
||||
|
@ -987,35 +920,113 @@ describe('user config refactor', () => {
|
|||
}
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
test('removes userId and isDarkMode from TLUserDocument', () => {
|
||||
describe('making instance state independent', () => {
|
||||
it('adds isPenMode and isGridMode to instance state', () => {
|
||||
const { up, down } =
|
||||
userDocumentMigrations.migrators[userDocumentVersions.RemoveUserIdAndIsDarkMode]
|
||||
instanceMigrations.migrators[instanceTypeVersions.AddIsPenModeAndIsGridMode]
|
||||
|
||||
const prev = {
|
||||
id: 'user_document:123',
|
||||
typeName: 'user_document',
|
||||
userId: 'user:123',
|
||||
isDarkMode: false,
|
||||
isGridMode: false,
|
||||
id: 'instance:123',
|
||||
typeName: 'instance',
|
||||
}
|
||||
const next = {
|
||||
id: 'user_document:123',
|
||||
typeName: 'user_document',
|
||||
id: 'instance:123',
|
||||
typeName: 'instance',
|
||||
isPenMode: false,
|
||||
isGridMode: false,
|
||||
}
|
||||
|
||||
expect(up(prev)).toEqual(next)
|
||||
expect(down(next)).toEqual(prev)
|
||||
})
|
||||
|
||||
it('removes instanceId and cameraId from instancePageState', () => {
|
||||
const { up, down } =
|
||||
instancePageStateMigrations.migrators[instancePageStateVersions.RemoveInstanceIdAndCameraId]
|
||||
|
||||
const prev = {
|
||||
id: 'instance_page_state:123',
|
||||
typeName: 'instance_page_state',
|
||||
instanceId: 'instance:123',
|
||||
cameraId: 'camera:123',
|
||||
selectedIds: [],
|
||||
}
|
||||
|
||||
const next = {
|
||||
id: 'instance_page_state:123',
|
||||
typeName: 'instance_page_state',
|
||||
selectedIds: [],
|
||||
}
|
||||
|
||||
expect(up(prev)).toEqual(next)
|
||||
// down should never be called
|
||||
expect(down(next)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"id": "user_document:123",
|
||||
"isDarkMode": false,
|
||||
"isGridMode": false,
|
||||
"typeName": "user_document",
|
||||
"userId": "user:none",
|
||||
"cameraId": "camera:void",
|
||||
"id": "instance_page_state:123",
|
||||
"instanceId": "instance:instance",
|
||||
"selectedIds": Array [],
|
||||
"typeName": "instance_page_state",
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('removes instanceId from instancePresence', () => {
|
||||
const { up, down } =
|
||||
instancePresenceMigrations.migrators[instancePresenceVersions.RemoveInstanceId]
|
||||
|
||||
const prev = {
|
||||
id: 'instance_presence:123',
|
||||
typeName: 'instance_presence',
|
||||
instanceId: 'instance:123',
|
||||
selectedIds: [],
|
||||
}
|
||||
|
||||
const next = {
|
||||
id: 'instance_presence:123',
|
||||
typeName: 'instance_presence',
|
||||
selectedIds: [],
|
||||
}
|
||||
|
||||
expect(up(prev)).toEqual(next)
|
||||
|
||||
// down should never be called
|
||||
expect(down(next)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"id": "instance_presence:123",
|
||||
"instanceId": "instance:instance",
|
||||
"selectedIds": Array [],
|
||||
"typeName": "instance_presence",
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('removes userDocument from the schema', () => {
|
||||
const { up, down } = storeMigrations.migrators[storeVersions.RemoveUserDocument]
|
||||
|
||||
const prev = {
|
||||
'user_document:123': {
|
||||
id: 'user_document:123',
|
||||
typeName: 'user_document',
|
||||
},
|
||||
'instance:123': {
|
||||
id: 'instance:123',
|
||||
typeName: 'instance',
|
||||
},
|
||||
}
|
||||
|
||||
const next = {
|
||||
'instance:123': {
|
||||
id: 'instance:123',
|
||||
typeName: 'instance',
|
||||
},
|
||||
}
|
||||
|
||||
expect(up(prev)).toEqual(next)
|
||||
expect(down(next)).toEqual(next)
|
||||
})
|
||||
})
|
||||
|
||||
/* --- PUT YOUR MIGRATIONS TESTS ABOVE HERE --- */
|
||||
|
|
|
@ -38,7 +38,7 @@ export const cameraMigrations = defineMigrations({})
|
|||
export const CameraRecordType = createRecordType<TLCamera>('camera', {
|
||||
validator: cameraValidator,
|
||||
migrations: cameraMigrations,
|
||||
scope: 'instance',
|
||||
scope: 'session',
|
||||
}).withDefaultProperties(
|
||||
(): Omit<TLCamera, 'id' | 'typeName'> => ({
|
||||
x: 0,
|
||||
|
|
|
@ -55,4 +55,4 @@ export const DocumentRecordType = createRecordType<TLDocument>('document', {
|
|||
|
||||
// all document records have the same ID: 'document:document'
|
||||
/** @public */
|
||||
export const TLDOCUMENT_ID: RecordId<TLDocument> = DocumentRecordType.createCustomId('document')
|
||||
export const TLDOCUMENT_ID: RecordId<TLDocument> = DocumentRecordType.createId('document')
|
||||
|
|
|
@ -43,6 +43,8 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
|
|||
exportBackground: boolean
|
||||
screenBounds: Box2dModel
|
||||
zoomBrush: Box2dModel | null
|
||||
isPenMode: boolean
|
||||
isGridMode: boolean
|
||||
}
|
||||
|
||||
/** @public */
|
||||
|
@ -84,6 +86,8 @@ export const instanceTypeValidator: T.Validator<TLInstance> = T.model(
|
|||
exportBackground: T.boolean,
|
||||
screenBounds: T.boxModel,
|
||||
zoomBrush: T.boxModel.nullable(),
|
||||
isPenMode: T.boolean,
|
||||
isGridMode: T.boolean,
|
||||
})
|
||||
)
|
||||
|
||||
|
@ -99,13 +103,14 @@ const Versions = {
|
|||
AddVerticalAlign: 9,
|
||||
AddScribbleDelay: 10,
|
||||
RemoveUserId: 11,
|
||||
AddIsPenModeAndIsGridMode: 12,
|
||||
} as const
|
||||
|
||||
export { Versions as instanceTypeVersions }
|
||||
|
||||
/** @internal */
|
||||
/** @public */
|
||||
export const instanceMigrations = defineMigrations({
|
||||
currentVersion: Versions.RemoveUserId,
|
||||
currentVersion: Versions.AddIsPenModeAndIsGridMode,
|
||||
migrators: {
|
||||
[Versions.AddTransparentExportBgs]: {
|
||||
up: (instance: TLInstance) => {
|
||||
|
@ -243,6 +248,14 @@ export const instanceMigrations = defineMigrations({
|
|||
return { ...instance, userId: 'user:none' }
|
||||
},
|
||||
},
|
||||
[Versions.AddIsPenModeAndIsGridMode]: {
|
||||
up: (instance: TLInstance) => {
|
||||
return { ...instance, isPenMode: false, isGridMode: false }
|
||||
},
|
||||
down: ({ isPenMode: _, isGridMode: __, ...instance }: TLInstance) => {
|
||||
return instance
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -250,7 +263,7 @@ export const instanceMigrations = defineMigrations({
|
|||
export const InstanceRecordType = createRecordType<TLInstance>('instance', {
|
||||
migrations: instanceMigrations,
|
||||
validator: instanceTypeValidator,
|
||||
scope: 'instance',
|
||||
scope: 'session',
|
||||
}).withDefaultProperties(
|
||||
(): Omit<TLInstance, 'typeName' | 'id' | 'currentPageId'> => ({
|
||||
followingUserId: null,
|
||||
|
@ -283,5 +296,10 @@ export const InstanceRecordType = createRecordType<TLInstance>('instance', {
|
|||
isToolLocked: false,
|
||||
screenBounds: { x: 0, y: 0, w: 1080, h: 720 },
|
||||
zoomBrush: null,
|
||||
isGridMode: false,
|
||||
isPenMode: false,
|
||||
})
|
||||
)
|
||||
|
||||
/** @public */
|
||||
export const TLINSTANCE_ID = InstanceRecordType.createId('instance')
|
||||
|
|
|
@ -2,8 +2,8 @@ import { BaseRecord, createRecordType, defineMigrations, RecordId } from '@tldra
|
|||
import { T } from '@tldraw/validate'
|
||||
import { idValidator } from '../misc/id-validator'
|
||||
import { shapeIdValidator } from '../shapes/TLBaseShape'
|
||||
import { TLCamera, TLCameraId } from './TLCamera'
|
||||
import { instanceIdValidator, TLInstance } from './TLInstance'
|
||||
import { CameraRecordType } from './TLCamera'
|
||||
import { TLINSTANCE_ID } from './TLInstance'
|
||||
import { pageIdValidator, TLPage } from './TLPage'
|
||||
import { TLShapeId } from './TLShape'
|
||||
|
||||
|
@ -16,9 +16,7 @@ import { TLShapeId } from './TLShape'
|
|||
*/
|
||||
export interface TLInstancePageState
|
||||
extends BaseRecord<'instance_page_state', TLInstancePageStateId> {
|
||||
instanceId: RecordId<TLInstance>
|
||||
pageId: RecordId<TLPage>
|
||||
cameraId: RecordId<TLCamera>
|
||||
selectedIds: TLShapeId[]
|
||||
hintingIds: TLShapeId[]
|
||||
erasingIds: TLShapeId[]
|
||||
|
@ -34,9 +32,7 @@ export const instancePageStateValidator: T.Validator<TLInstancePageState> = T.mo
|
|||
T.object({
|
||||
typeName: T.literal('instance_page_state'),
|
||||
id: idValidator<TLInstancePageStateId>('instance_page_state'),
|
||||
instanceId: instanceIdValidator,
|
||||
pageId: pageIdValidator,
|
||||
cameraId: idValidator<TLCameraId>('camera'),
|
||||
selectedIds: T.arrayOf(shapeIdValidator),
|
||||
hintingIds: T.arrayOf(shapeIdValidator),
|
||||
erasingIds: T.arrayOf(shapeIdValidator),
|
||||
|
@ -49,11 +45,15 @@ export const instancePageStateValidator: T.Validator<TLInstancePageState> = T.mo
|
|||
|
||||
const Versions = {
|
||||
AddCroppingId: 1,
|
||||
RemoveInstanceIdAndCameraId: 2,
|
||||
} as const
|
||||
|
||||
/** @internal */
|
||||
export { Versions as instancePageStateVersions }
|
||||
|
||||
/** @public */
|
||||
export const instancePageStateMigrations = defineMigrations({
|
||||
currentVersion: Versions.AddCroppingId,
|
||||
currentVersion: Versions.RemoveInstanceIdAndCameraId,
|
||||
migrators: {
|
||||
[Versions.AddCroppingId]: {
|
||||
up(instance) {
|
||||
|
@ -63,6 +63,19 @@ export const instancePageStateMigrations = defineMigrations({
|
|||
return instance
|
||||
},
|
||||
},
|
||||
[Versions.RemoveInstanceIdAndCameraId]: {
|
||||
up({ instanceId: _, cameraId: __, ...instance }) {
|
||||
return instance
|
||||
},
|
||||
down(instance) {
|
||||
// this should never be called since we bump the schema version
|
||||
return {
|
||||
...instance,
|
||||
instanceId: TLINSTANCE_ID,
|
||||
cameraId: CameraRecordType.createId('void'),
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -72,13 +85,10 @@ export const InstancePageStateRecordType = createRecordType<TLInstancePageState>
|
|||
{
|
||||
migrations: instancePageStateMigrations,
|
||||
validator: instancePageStateValidator,
|
||||
scope: 'instance',
|
||||
scope: 'session',
|
||||
}
|
||||
).withDefaultProperties(
|
||||
(): Omit<
|
||||
TLInstancePageState,
|
||||
'id' | 'typeName' | 'userId' | 'instanceId' | 'cameraId' | 'pageId'
|
||||
> => ({
|
||||
(): Omit<TLInstancePageState, 'id' | 'typeName' | 'pageId'> => ({
|
||||
editingId: null,
|
||||
croppingId: null,
|
||||
selectedIds: [],
|
||||
|
|
|
@ -35,7 +35,7 @@ export const pointerMigrations = defineMigrations({})
|
|||
export const PointerRecordType = createRecordType<TLPointer>('pointer', {
|
||||
validator: pointerValidator,
|
||||
migrations: pointerMigrations,
|
||||
scope: 'instance',
|
||||
scope: 'session',
|
||||
}).withDefaultProperties(
|
||||
(): Omit<TLPointer, 'id' | 'typeName'> => ({
|
||||
x: 0,
|
||||
|
@ -45,4 +45,4 @@ export const PointerRecordType = createRecordType<TLPointer>('pointer', {
|
|||
)
|
||||
|
||||
/** @public */
|
||||
export const TLPOINTER_ID = PointerRecordType.createCustomId('pointer')
|
||||
export const TLPOINTER_ID = PointerRecordType.createId('pointer')
|
||||
|
|
|
@ -4,13 +4,12 @@ import { Box2dModel } from '../misc/geometry-types'
|
|||
import { idValidator } from '../misc/id-validator'
|
||||
import { cursorTypeValidator, TLCursor } from '../misc/TLCursor'
|
||||
import { scribbleValidator, TLScribble } from '../misc/TLScribble'
|
||||
import { TLInstanceId } from './TLInstance'
|
||||
import { TLINSTANCE_ID } from './TLInstance'
|
||||
import { TLPageId } from './TLPage'
|
||||
import { TLShapeId } from './TLShape'
|
||||
|
||||
/** @public */
|
||||
export interface TLInstancePresence extends BaseRecord<'instance_presence', TLInstancePresenceID> {
|
||||
instanceId: TLInstanceId
|
||||
userId: string
|
||||
userName: string
|
||||
lastActivityTimestamp: number
|
||||
|
@ -37,7 +36,6 @@ export type TLInstancePresenceID = RecordId<TLInstancePresence>
|
|||
export const instancePresenceValidator: T.Validator<TLInstancePresence> = T.model(
|
||||
'instance_presence',
|
||||
T.object({
|
||||
instanceId: idValidator<TLInstanceId>('instance'),
|
||||
typeName: T.literal('instance_presence'),
|
||||
id: idValidator<TLInstancePresenceID>('instance_presence'),
|
||||
userId: T.string,
|
||||
|
@ -66,11 +64,13 @@ export const instancePresenceValidator: T.Validator<TLInstancePresence> = T.mode
|
|||
|
||||
const Versions = {
|
||||
AddScribbleDelay: 1,
|
||||
RemoveInstanceId: 2,
|
||||
} as const
|
||||
|
||||
/** @internal */
|
||||
export { Versions as instancePresenceVersions }
|
||||
|
||||
export const instancePresenceMigrations = defineMigrations({
|
||||
currentVersion: Versions.AddScribbleDelay,
|
||||
currentVersion: Versions.RemoveInstanceId,
|
||||
migrators: {
|
||||
[Versions.AddScribbleDelay]: {
|
||||
up: (instance) => {
|
||||
|
@ -87,6 +87,14 @@ export const instancePresenceMigrations = defineMigrations({
|
|||
return { ...instance }
|
||||
},
|
||||
},
|
||||
[Versions.RemoveInstanceId]: {
|
||||
up: ({ instanceId: _, ...instance }) => {
|
||||
return instance
|
||||
},
|
||||
down: (instance) => {
|
||||
return { ...instance, instanceId: TLINSTANCE_ID }
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
@ -7,7 +7,6 @@ import { TLInstancePageState } from './TLPageState'
|
|||
import { TLPointer } from './TLPointer'
|
||||
import { TLInstancePresence } from './TLPresence'
|
||||
import { TLShape } from './TLShape'
|
||||
import { TLUserDocument } from './TLUserDocument'
|
||||
|
||||
/** @public */
|
||||
export type TLRecord =
|
||||
|
@ -18,6 +17,5 @@ export type TLRecord =
|
|||
| TLInstancePageState
|
||||
| TLPage
|
||||
| TLShape
|
||||
| TLUserDocument
|
||||
| TLInstancePresence
|
||||
| TLPointer
|
||||
|
|
|
@ -1,108 +0,0 @@
|
|||
import { BaseRecord, createRecordType, defineMigrations, RecordId } from '@tldraw/store'
|
||||
import { T } from '@tldraw/validate'
|
||||
import { idValidator } from '../misc/id-validator'
|
||||
import { instanceIdValidator, TLInstance } from './TLInstance'
|
||||
import { pageIdValidator, TLPage } from './TLPage'
|
||||
|
||||
/**
|
||||
* TLUserDocument
|
||||
*
|
||||
* Settings that apply to this document for only the specified user
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface TLUserDocument extends BaseRecord<'user_document', TLUserDocumentId> {
|
||||
isPenMode: boolean
|
||||
isGridMode: boolean
|
||||
isMobileMode: boolean
|
||||
isSnapMode: boolean
|
||||
lastUpdatedPageId: RecordId<TLPage> | null
|
||||
lastUsedTabId: RecordId<TLInstance> | null
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export type TLUserDocumentId = RecordId<TLUserDocument>
|
||||
|
||||
/** @internal */
|
||||
export const userDocumentValidator: T.Validator<TLUserDocument> = T.model(
|
||||
'user_document',
|
||||
T.object({
|
||||
typeName: T.literal('user_document'),
|
||||
id: idValidator<TLUserDocumentId>('user_document'),
|
||||
isPenMode: T.boolean,
|
||||
isGridMode: T.boolean,
|
||||
isMobileMode: T.boolean,
|
||||
isSnapMode: T.boolean,
|
||||
lastUpdatedPageId: pageIdValidator.nullable(),
|
||||
lastUsedTabId: instanceIdValidator.nullable(),
|
||||
})
|
||||
)
|
||||
|
||||
export const Versions = {
|
||||
AddSnapMode: 1,
|
||||
AddMissingIsMobileMode: 2,
|
||||
RemoveIsReadOnly: 3,
|
||||
RemoveUserIdAndIsDarkMode: 4,
|
||||
} as const
|
||||
|
||||
/** @internal */
|
||||
export { Versions as userDocumentVersions }
|
||||
|
||||
/** @internal */
|
||||
export const userDocumentMigrations = defineMigrations({
|
||||
currentVersion: Versions.RemoveUserIdAndIsDarkMode,
|
||||
migrators: {
|
||||
[Versions.AddSnapMode]: {
|
||||
up: (userDocument: TLUserDocument) => {
|
||||
return { ...userDocument, isSnapMode: false }
|
||||
},
|
||||
down: ({ isSnapMode: _, ...userDocument }: TLUserDocument) => {
|
||||
return userDocument
|
||||
},
|
||||
},
|
||||
[Versions.AddMissingIsMobileMode]: {
|
||||
up: (userDocument: TLUserDocument) => {
|
||||
return { ...userDocument, isMobileMode: userDocument.isMobileMode ?? false }
|
||||
},
|
||||
down: ({ isMobileMode: _, ...userDocument }: TLUserDocument) => {
|
||||
return userDocument
|
||||
},
|
||||
},
|
||||
[Versions.RemoveIsReadOnly]: {
|
||||
up: ({ isReadOnly: _, ...userDocument }: TLUserDocument & { isReadOnly: boolean }) => {
|
||||
return userDocument
|
||||
},
|
||||
down: (userDocument: TLUserDocument) => {
|
||||
return { ...userDocument, isReadOnly: false }
|
||||
},
|
||||
},
|
||||
[Versions.RemoveUserIdAndIsDarkMode]: {
|
||||
up: ({
|
||||
userId: _,
|
||||
isDarkMode: __,
|
||||
...userDocument
|
||||
}: TLUserDocument & { userId: string; isDarkMode: boolean }) => {
|
||||
return userDocument
|
||||
},
|
||||
down: (userDocument: TLUserDocument) => {
|
||||
return { ...userDocument, userId: 'user:none', isDarkMode: false }
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/** @internal */
|
||||
export const UserDocumentRecordType = createRecordType<TLUserDocument>('user_document', {
|
||||
migrations: userDocumentMigrations,
|
||||
validator: userDocumentValidator,
|
||||
scope: 'instance',
|
||||
}).withDefaultProperties(
|
||||
(): Omit<TLUserDocument, 'id' | 'typeName' | 'userId'> => ({
|
||||
isPenMode: false,
|
||||
isGridMode: false,
|
||||
isMobileMode: false,
|
||||
isSnapMode: false,
|
||||
lastUpdatedPageId: null,
|
||||
lastUsedTabId: null,
|
||||
})
|
||||
)
|
|
@ -5,13 +5,14 @@ const Versions = {
|
|||
RemoveCodeAndIconShapeTypes: 1,
|
||||
AddInstancePresenceType: 2,
|
||||
RemoveTLUserAndPresenceAndAddPointer: 3,
|
||||
RemoveUserDocument: 4,
|
||||
} as const
|
||||
|
||||
export { Versions as storeVersions }
|
||||
|
||||
/** @public */
|
||||
export const storeMigrations = defineMigrations({
|
||||
currentVersion: Versions.RemoveTLUserAndPresenceAndAddPointer,
|
||||
currentVersion: Versions.RemoveUserDocument,
|
||||
migrators: {
|
||||
[Versions.RemoveCodeAndIconShapeTypes]: {
|
||||
up: (store: StoreSnapshot<TLRecord>) => {
|
||||
|
@ -48,5 +49,15 @@ export const storeMigrations = defineMigrations({
|
|||
)
|
||||
},
|
||||
},
|
||||
[Versions.RemoveUserDocument]: {
|
||||
up: (store: StoreSnapshot<TLRecord>) => {
|
||||
return Object.fromEntries(
|
||||
Object.entries(store).filter(([_, v]) => !v.typeName.match('user_document'))
|
||||
)
|
||||
},
|
||||
down: (store: StoreSnapshot<TLRecord>) => {
|
||||
return store
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
@ -31,7 +31,7 @@ export function useKeyboardShortcuts() {
|
|||
const container = editor.getContainer()
|
||||
|
||||
const hot = (keys: string, callback: (event: KeyboardEvent) => void) => {
|
||||
hotkeys(keys, { element: container, scope: editor.instanceId }, callback)
|
||||
hotkeys(keys, { element: container, scope: editor.store.id }, callback)
|
||||
}
|
||||
|
||||
// Add hotkeys for actions and tools.
|
||||
|
@ -86,10 +86,10 @@ export function useKeyboardShortcuts() {
|
|||
actions['zoom-out'].onSelect('kbd')
|
||||
})
|
||||
|
||||
hotkeys.setScope(editor.instanceId)
|
||||
hotkeys.setScope(editor.store.id)
|
||||
|
||||
return () => {
|
||||
hotkeys.deleteScope(editor.instanceId)
|
||||
hotkeys.deleteScope(editor.store.id)
|
||||
}
|
||||
}, [actions, tools, isReadonly, editor, appIsFocused])
|
||||
}
|
||||
|
|
|
@ -51,8 +51,8 @@ export function TLUiMenuSchemaProvider({ overrides, children }: TLUiMenuSchemaPr
|
|||
|
||||
const isDarkMode = useValue('isDarkMode', () => editor.isDarkMode, [editor])
|
||||
const animationSpeed = useValue('animationSpeed', () => editor.animationSpeed, [editor])
|
||||
const isGridMode = useValue('isGridMode', () => editor.userDocumentSettings.isGridMode, [editor])
|
||||
const isSnapMode = useValue('isSnapMode', () => editor.userDocumentSettings.isSnapMode, [editor])
|
||||
const isGridMode = useValue('isGridMode', () => editor.isGridMode, [editor])
|
||||
const isSnapMode = useValue('isSnapMode', () => editor.isSnapMode, [editor])
|
||||
const isToolLock = useValue('isToolLock', () => editor.instanceState.isToolLocked, [editor])
|
||||
const isFocusMode = useValue('isFocusMode', () => editor.instanceState.isFocusMode, [editor])
|
||||
const isDebugMode = useValue('isDebugMode', () => editor.instanceState.isDebugMode, [editor])
|
||||
|
|
Loading…
Reference in a new issue