@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)
}
}