UNPKG

@tldraw/store

Version:

tldraw infinite canvas SDK (store).

691 lines (660 loc) • 23.2 kB
import { UnknownRecord } from './BaseRecord' import { Store } from './Store' /** * Handler function called before a record is created in the store. * The handler receives the record to be created and can return a modified version. * Use this to validate, transform, or modify records before they are added to the store. * * @param record - The record about to be created * @param source - Whether the change originated from 'user' interaction or 'remote' synchronization * @returns The record to actually create (may be modified) * * @example * ```ts * const handler: StoreBeforeCreateHandler<MyRecord> = (record, source) => { * // Ensure all user-created records have a timestamp * if (source === 'user' && !record.createdAt) { * return { ...record, createdAt: Date.now() } * } * return record * } * ``` * * @public */ export type StoreBeforeCreateHandler<R extends UnknownRecord> = ( record: R, source: 'remote' | 'user' ) => R /** * Handler function called after a record has been successfully created in the store. * Use this for side effects that should happen after record creation, such as updating * related records or triggering notifications. * * @param record - The record that was created * @param source - Whether the change originated from 'user' interaction or 'remote' synchronization * * @example * ```ts * const handler: StoreAfterCreateHandler<BookRecord> = (book, source) => { * if (source === 'user') { * console.log(`New book added: ${book.title}`) * updateAuthorBookCount(book.authorId) * } * } * ``` * * @public */ export type StoreAfterCreateHandler<R extends UnknownRecord> = ( record: R, source: 'remote' | 'user' ) => void /** * Handler function called before a record is updated in the store. * The handler receives the current and new versions of the record and can return * a modified version or the original to prevent the change. * * @param prev - The current version of the record in the store * @param next - The proposed new version of the record * @param source - Whether the change originated from 'user' interaction or 'remote' synchronization * @returns The record version to actually store (may be modified or the original to block change) * * @example * ```ts * const handler: StoreBeforeChangeHandler<ShapeRecord> = (prev, next, source) => { * // Prevent shapes from being moved outside the canvas bounds * if (next.x < 0 || next.y < 0) { * return prev // Block the change * } * return next * } * ``` * * @public */ export type StoreBeforeChangeHandler<R extends UnknownRecord> = ( prev: R, next: R, source: 'remote' | 'user' ) => R /** * Handler function called after a record has been successfully updated in the store. * Use this for side effects that should happen after record changes, such as * updating related records or maintaining consistency constraints. * * @param prev - The previous version of the record * @param next - The new version of the record that was stored * @param source - Whether the change originated from 'user' interaction or 'remote' synchronization * * @example * ```ts * const handler: StoreAfterChangeHandler<ShapeRecord> = (prev, next, source) => { * // Update connected arrows when a shape moves * if (prev.x !== next.x || prev.y !== next.y) { * updateConnectedArrows(next.id) * } * } * ``` * * @public */ export type StoreAfterChangeHandler<R extends UnknownRecord> = ( prev: R, next: R, source: 'remote' | 'user' ) => void /** * Handler function called before a record is deleted from the store. * The handler can return `false` to prevent the deletion from occurring. * * @param record - The record about to be deleted * @param source - Whether the change originated from 'user' interaction or 'remote' synchronization * @returns `false` to prevent deletion, `void` or any other value to allow it * * @example * ```ts * const handler: StoreBeforeDeleteHandler<BookRecord> = (book, source) => { * // Prevent deletion of books that are currently checked out * if (book.isCheckedOut) { * console.warn('Cannot delete checked out book') * return false * } * // Allow deletion for other books * } * ``` * * @public */ export type StoreBeforeDeleteHandler<R extends UnknownRecord> = ( record: R, source: 'remote' | 'user' ) => void | false /** * Handler function called after a record has been successfully deleted from the store. * Use this for cleanup operations and maintaining referential integrity. * * @param record - The record that was deleted * @param source - Whether the change originated from 'user' interaction or 'remote' synchronization * * @example * ```ts * const handler: StoreAfterDeleteHandler<ShapeRecord> = (shape, source) => { * // Clean up arrows that were connected to this shape * const connectedArrows = findArrowsConnectedTo(shape.id) * store.remove(connectedArrows.map(arrow => arrow.id)) * } * ``` * * @public */ export type StoreAfterDeleteHandler<R extends UnknownRecord> = ( record: R, source: 'remote' | 'user' ) => void /** * Handler function called when a store operation (atomic transaction) completes. * This is useful for performing actions after a batch of changes has been applied, * such as triggering saves or sending notifications. * * @param source - Whether the operation originated from 'user' interaction or 'remote' synchronization * * @example * ```ts * const handler: StoreOperationCompleteHandler = (source) => { * if (source === 'user') { * // Auto-save after user operations complete * saveStoreSnapshot() * } * } * ``` * * @public */ export type StoreOperationCompleteHandler = (source: 'remote' | 'user') => void /** * The side effect manager (aka a "correct state enforcer") is responsible * for making sure that the store's state is always correct and consistent. This includes * things like: deleting a shape if its parent is deleted; unbinding * arrows when their binding target is deleted; maintaining referential integrity; etc. * * Side effects are organized into lifecycle hooks that run before and after * record operations (create, change, delete), allowing you to validate data, * transform records, and maintain business rules. * * @example * ```ts * const sideEffects = new StoreSideEffects(store) * * // Ensure arrows are deleted when their target shape is deleted * sideEffects.registerAfterDeleteHandler('shape', (shape) => { * const arrows = store.query.records('arrow', () => ({ * toId: { eq: shape.id } * })).get() * store.remove(arrows.map(arrow => arrow.id)) * }) * ``` * * @public */ export class StoreSideEffects<R extends UnknownRecord> { /** * Creates a new side effects manager for the given store. * * store - The store instance to manage side effects for */ constructor(private readonly store: Store<R>) {} private _beforeCreateHandlers: { [K in string]?: StoreBeforeCreateHandler<any>[] } = {} private _afterCreateHandlers: { [K in string]?: StoreAfterCreateHandler<any>[] } = {} private _beforeChangeHandlers: { [K in string]?: StoreBeforeChangeHandler<any>[] } = {} private _afterChangeHandlers: { [K in string]?: StoreAfterChangeHandler<any>[] } = {} private _beforeDeleteHandlers: { [K in string]?: StoreBeforeDeleteHandler<any>[] } = {} private _afterDeleteHandlers: { [K in string]?: StoreAfterDeleteHandler<any>[] } = {} private _operationCompleteHandlers: StoreOperationCompleteHandler[] = [] private _isEnabled = true /** * Checks whether side effects are currently enabled. * When disabled, all side effect handlers are bypassed. * * @returns `true` if side effects are enabled, `false` otherwise * @internal */ isEnabled() { return this._isEnabled } /** * Enables or disables side effects processing. * When disabled, no side effect handlers will be called. * * @param enabled - Whether to enable or disable side effects * @internal */ setIsEnabled(enabled: boolean) { this._isEnabled = enabled } /** * Processes all registered 'before create' handlers for a record. * Handlers are called in registration order and can transform the record. * * @param record - The record about to be created * @param source - Whether the change originated from 'user' or 'remote' * @returns The potentially modified record to actually create * @internal */ handleBeforeCreate(record: R, source: 'remote' | 'user') { if (!this._isEnabled) return record const handlers = this._beforeCreateHandlers[record.typeName] as StoreBeforeCreateHandler<R>[] if (handlers) { let r = record for (const handler of handlers) { r = handler(r, source) } return r } return record } /** * Processes all registered 'after create' handlers for a record. * Handlers are called in registration order after the record is created. * * @param record - The record that was created * @param source - Whether the change originated from 'user' or 'remote' * @internal */ handleAfterCreate(record: R, source: 'remote' | 'user') { if (!this._isEnabled) return const handlers = this._afterCreateHandlers[record.typeName] as StoreAfterCreateHandler<R>[] if (handlers) { for (const handler of handlers) { handler(record, source) } } } /** * Processes all registered 'before change' handlers for a record. * Handlers are called in registration order and can modify or block the change. * * @param prev - The current version of the record * @param next - The proposed new version of the record * @param source - Whether the change originated from 'user' or 'remote' * @returns The potentially modified record to actually store * @internal */ handleBeforeChange(prev: R, next: R, source: 'remote' | 'user') { if (!this._isEnabled) return next const handlers = this._beforeChangeHandlers[next.typeName] as StoreBeforeChangeHandler<R>[] if (handlers) { let r = next for (const handler of handlers) { r = handler(prev, r, source) } return r } return next } /** * Processes all registered 'after change' handlers for a record. * Handlers are called in registration order after the record is updated. * * @param prev - The previous version of the record * @param next - The new version of the record that was stored * @param source - Whether the change originated from 'user' or 'remote' * @internal */ handleAfterChange(prev: R, next: R, source: 'remote' | 'user') { if (!this._isEnabled) return const handlers = this._afterChangeHandlers[next.typeName] as StoreAfterChangeHandler<R>[] if (handlers) { for (const handler of handlers) { handler(prev, next, source) } } } /** * Processes all registered 'before delete' handlers for a record. * If any handler returns `false`, the deletion is prevented. * * @param record - The record about to be deleted * @param source - Whether the change originated from 'user' or 'remote' * @returns `true` to allow deletion, `false` to prevent it * @internal */ handleBeforeDelete(record: R, source: 'remote' | 'user') { if (!this._isEnabled) return true const handlers = this._beforeDeleteHandlers[record.typeName] as StoreBeforeDeleteHandler<R>[] if (handlers) { for (const handler of handlers) { if (handler(record, source) === false) { return false } } } return true } /** * Processes all registered 'after delete' handlers for a record. * Handlers are called in registration order after the record is deleted. * * @param record - The record that was deleted * @param source - Whether the change originated from 'user' or 'remote' * @internal */ handleAfterDelete(record: R, source: 'remote' | 'user') { if (!this._isEnabled) return const handlers = this._afterDeleteHandlers[record.typeName] as StoreAfterDeleteHandler<R>[] if (handlers) { for (const handler of handlers) { handler(record, source) } } } /** * Processes all registered operation complete handlers. * Called after an atomic store operation finishes. * * @param source - Whether the operation originated from 'user' or 'remote' * @internal */ handleOperationComplete(source: 'remote' | 'user') { if (!this._isEnabled) return for (const handler of this._operationCompleteHandlers) { handler(source) } } /** * Internal helper for registering multiple side effect handlers at once and keeping them organized. * This provides a convenient way to register handlers for multiple record types and lifecycle events * in a single call, returning a single cleanup function. * * @param handlersByType - An object mapping record type names to their respective handlers * @returns A function that removes all registered handlers when called * * @example * ```ts * const cleanup = sideEffects.register({ * shape: { * afterDelete: (shape) => console.log('Shape deleted:', shape.id), * beforeChange: (prev, next) => ({ ...next, lastModified: Date.now() }) * }, * arrow: { * afterCreate: (arrow) => updateConnectedShapes(arrow) * } * }) * * // Later, remove all handlers * cleanup() * ``` * * @internal */ register(handlersByType: { [T in R as T['typeName']]?: { beforeCreate?: StoreBeforeCreateHandler<T> afterCreate?: StoreAfterCreateHandler<T> beforeChange?: StoreBeforeChangeHandler<T> afterChange?: StoreAfterChangeHandler<T> beforeDelete?: StoreBeforeDeleteHandler<T> afterDelete?: StoreAfterDeleteHandler<T> } }) { const disposes: (() => void)[] = [] for (const [type, handlers] of Object.entries(handlersByType) as any) { if (handlers?.beforeCreate) { disposes.push(this.registerBeforeCreateHandler(type, handlers.beforeCreate)) } if (handlers?.afterCreate) { disposes.push(this.registerAfterCreateHandler(type, handlers.afterCreate)) } if (handlers?.beforeChange) { disposes.push(this.registerBeforeChangeHandler(type, handlers.beforeChange)) } if (handlers?.afterChange) { disposes.push(this.registerAfterChangeHandler(type, handlers.afterChange)) } if (handlers?.beforeDelete) { disposes.push(this.registerBeforeDeleteHandler(type, handlers.beforeDelete)) } if (handlers?.afterDelete) { disposes.push(this.registerAfterDeleteHandler(type, handlers.afterDelete)) } } return () => { for (const dispose of disposes) dispose() } } /** * Register a handler to be called before a record of a certain type is created. Return a * modified record from the handler to change the record that will be created. * * Use this handle only to modify the creation of the record itself. If you want to trigger a * side-effect on a different record (for example, moving one shape when another is created), * use {@link StoreSideEffects.registerAfterCreateHandler} instead. * * @example * ```ts * editor.sideEffects.registerBeforeCreateHandler('shape', (shape, source) => { * // only modify shapes created by the user * if (source !== 'user') return shape * * //by default, arrow shapes have no label. Let's make sure they always have a label. * if (shape.type === 'arrow') { * return {...shape, props: {...shape.props, text: 'an arrow'}} * } * * // other shapes get returned unmodified * return shape * }) * ``` * * @param typeName - The type of record to listen for * @param handler - The handler to call * * @returns A callback that removes the handler. */ registerBeforeCreateHandler<T extends R['typeName']>( typeName: T, handler: StoreBeforeCreateHandler<R & { typeName: T }> ) { const handlers = this._beforeCreateHandlers[typeName] as StoreBeforeCreateHandler<any>[] if (!handlers) this._beforeCreateHandlers[typeName] = [] this._beforeCreateHandlers[typeName]!.push(handler) return () => remove(this._beforeCreateHandlers[typeName]!, handler) } /** * Register a handler to be called after a record is created. This is useful for side-effects * that would update _other_ records. If you want to modify the record being created use * {@link StoreSideEffects.registerBeforeCreateHandler} instead. * * @example * ```ts * editor.sideEffects.registerAfterCreateHandler('page', (page, source) => { * // Automatically create a shape when a page is created * editor.createShape({ * id: createShapeId(), * type: 'text', * props: { richText: toRichText(page.name) }, * }) * }) * ``` * * @param typeName - The type of record to listen for * @param handler - The handler to call * * @returns A callback that removes the handler. */ registerAfterCreateHandler<T extends R['typeName']>( typeName: T, handler: StoreAfterCreateHandler<R & { typeName: T }> ) { const handlers = this._afterCreateHandlers[typeName] as StoreAfterCreateHandler<any>[] if (!handlers) this._afterCreateHandlers[typeName] = [] this._afterCreateHandlers[typeName]!.push(handler) return () => remove(this._afterCreateHandlers[typeName]!, handler) } /** * Register a handler to be called before a record is changed. The handler is given the old and * new record - you can return a modified record to apply a different update, or the old record * to block the update entirely. * * Use this handler only for intercepting updates to the record itself. If you want to update * other records in response to a change, use * {@link StoreSideEffects.registerAfterChangeHandler} instead. * * @example * ```ts * editor.sideEffects.registerBeforeChangeHandler('shape', (prev, next, source) => { * if (next.isLocked && !prev.isLocked) { * // prevent shapes from ever being locked: * return prev * } * // other types of change are allowed * return next * }) * ``` * * @param typeName - The type of record to listen for * @param handler - The handler to call * * @returns A callback that removes the handler. */ registerBeforeChangeHandler<T extends R['typeName']>( typeName: T, handler: StoreBeforeChangeHandler<R & { typeName: T }> ) { const handlers = this._beforeChangeHandlers[typeName] as StoreBeforeChangeHandler<any>[] if (!handlers) this._beforeChangeHandlers[typeName] = [] this._beforeChangeHandlers[typeName]!.push(handler) return () => remove(this._beforeChangeHandlers[typeName]!, handler) } /** * Register a handler to be called after a record is changed. This is useful for side-effects * that would update _other_ records - if you want to modify the record being changed, use * {@link StoreSideEffects.registerBeforeChangeHandler} instead. * * @example * ```ts * editor.sideEffects.registerAfterChangeHandler('shape', (prev, next, source) => { * if (next.props.color === 'red') { * // there can only be one red shape at a time: * const otherRedShapes = editor.getCurrentPageShapes().filter(s => s.props.color === 'red' && s.id !== next.id) * editor.updateShapes(otherRedShapes.map(s => ({...s, props: {...s.props, color: 'blue'}}))) * } * }) * ``` * * @param typeName - The type of record to listen for * @param handler - The handler to call * * @returns A callback that removes the handler. */ registerAfterChangeHandler<T extends R['typeName']>( typeName: T, handler: StoreAfterChangeHandler<R & { typeName: T }> ) { const handlers = this._afterChangeHandlers[typeName] as StoreAfterChangeHandler<any>[] if (!handlers) this._afterChangeHandlers[typeName] = [] this._afterChangeHandlers[typeName]!.push(handler as StoreAfterChangeHandler<any>) return () => remove(this._afterChangeHandlers[typeName]!, handler) } /** * Register a handler to be called before a record is deleted. The handler can return `false` to * prevent the deletion. * * Use this handler only for intercepting deletions of the record itself. If you want to do * something to other records in response to a deletion, use * {@link StoreSideEffects.registerAfterDeleteHandler} instead. * * @example * ```ts * editor.sideEffects.registerBeforeDeleteHandler('shape', (shape, source) => { * if (shape.props.color === 'red') { * // prevent red shapes from being deleted * return false * } * }) * ``` * * @param typeName - The type of record to listen for * @param handler - The handler to call * * @returns A callback that removes the handler. */ registerBeforeDeleteHandler<T extends R['typeName']>( typeName: T, handler: StoreBeforeDeleteHandler<R & { typeName: T }> ) { const handlers = this._beforeDeleteHandlers[typeName] as StoreBeforeDeleteHandler<any>[] if (!handlers) this._beforeDeleteHandlers[typeName] = [] this._beforeDeleteHandlers[typeName]!.push(handler as StoreBeforeDeleteHandler<any>) return () => remove(this._beforeDeleteHandlers[typeName]!, handler) } /** * Register a handler to be called after a record is deleted. This is useful for side-effects * that would update _other_ records - if you want to block the deletion of the record itself, * use {@link StoreSideEffects.registerBeforeDeleteHandler} instead. * * @example * ```ts * editor.sideEffects.registerAfterDeleteHandler('shape', (shape, source) => { * // if the last shape in a frame is deleted, delete the frame too: * const parentFrame = editor.getShape(shape.parentId) * if (!parentFrame || parentFrame.type !== 'frame') return * * const siblings = editor.getSortedChildIdsForParent(parentFrame) * if (siblings.length === 0) { * editor.deleteShape(parentFrame.id) * } * }) * ``` * * @param typeName - The type of record to listen for * @param handler - The handler to call * * @returns A callback that removes the handler. */ registerAfterDeleteHandler<T extends R['typeName']>( typeName: T, handler: StoreAfterDeleteHandler<R & { typeName: T }> ) { const handlers = this._afterDeleteHandlers[typeName] as StoreAfterDeleteHandler<any>[] if (!handlers) this._afterDeleteHandlers[typeName] = [] this._afterDeleteHandlers[typeName]!.push(handler as StoreAfterDeleteHandler<any>) return () => remove(this._afterDeleteHandlers[typeName]!, handler) } /** * Register a handler to be called when a store completes an atomic operation. * * @example * ```ts * let count = 0 * * editor.sideEffects.registerOperationCompleteHandler(() => count++) * * editor.selectAll() * expect(count).toBe(1) * * editor.store.atomic(() => { * editor.selectNone() * editor.selectAll() * }) * * expect(count).toBe(2) * ``` * * @param handler - The handler to call * * @returns A callback that removes the handler. * * @public */ registerOperationCompleteHandler(handler: StoreOperationCompleteHandler) { this._operationCompleteHandlers.push(handler) return () => remove(this._operationCompleteHandlers, handler) } } function remove(array: any[], item: any) { const index = array.indexOf(item) if (index >= 0) { array.splice(index, 1) } }