@tldraw/store
Version: 
tldraw infinite canvas SDK (store).
444 lines (413 loc) • 14.4 kB
text/typescript
import { UnknownRecord } from './BaseRecord'
import { Store } from './Store'
/** @public */
export type StoreBeforeCreateHandler<R extends UnknownRecord> = (
	record: R,
	source: 'remote' | 'user'
) => R
/** @public */
export type StoreAfterCreateHandler<R extends UnknownRecord> = (
	record: R,
	source: 'remote' | 'user'
) => void
/** @public */
export type StoreBeforeChangeHandler<R extends UnknownRecord> = (
	prev: R,
	next: R,
	source: 'remote' | 'user'
) => R
/** @public */
export type StoreAfterChangeHandler<R extends UnknownRecord> = (
	prev: R,
	next: R,
	source: 'remote' | 'user'
) => void
/** @public */
export type StoreBeforeDeleteHandler<R extends UnknownRecord> = (
	record: R,
	source: 'remote' | 'user'
) => void | false
/** @public */
export type StoreAfterDeleteHandler<R extends UnknownRecord> = (
	record: R,
	source: 'remote' | 'user'
) => void
/** @public */
export type StoreOperationCompleteHandler = (source: 'remote' | 'user') => void
/**
 * The side effect manager (aka a "correct state enforcer") is responsible
 * for making sure that the editor's state is always correct. This includes
 * things like: deleting a shape if its parent is deleted; unbinding
 * arrows when their binding target is deleted; etc.
 *
 * @public
 */
export class StoreSideEffects<R extends UnknownRecord> {
	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
	/** @internal */
	isEnabled() {
		return this._isEnabled
	}
	/** @internal */
	setIsEnabled(enabled: boolean) {
		this._isEnabled = enabled
	}
	/** @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
	}
	/** @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)
			}
		}
	}
	/** @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
	}
	/** @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)
			}
		}
	}
	/** @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
	}
	/** @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)
			}
		}
	}
	/** @internal */
	handleOperationComplete(source: 'remote' | 'user') {
		if (!this._isEnabled) return
		for (const handler of this._operationCompleteHandlers) {
			handler(source)
		}
	}
	/**
	 * Internal helper for registering a bunch of side effects at once and keeping them organized.
	 * @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<TLTextShape>({
	 *         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)
	}
}