UNPKG

@tldraw/store

Version:

tldraw infinite canvas SDK (store).

441 lines (440 loc) • 15.3 kB
class StoreSideEffects { /** * Creates a new side effects manager for the given store. * * store - The store instance to manage side effects for */ constructor(store) { this.store = store; } _beforeCreateHandlers = {}; _afterCreateHandlers = {}; _beforeChangeHandlers = {}; _afterChangeHandlers = {}; _beforeDeleteHandlers = {}; _afterDeleteHandlers = {}; _operationCompleteHandlers = []; _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) { 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, source) { if (!this._isEnabled) return record; const handlers = this._beforeCreateHandlers[record.typeName]; 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, source) { if (!this._isEnabled) return; const handlers = this._afterCreateHandlers[record.typeName]; 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, next, source) { if (!this._isEnabled) return next; const handlers = this._beforeChangeHandlers[next.typeName]; 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, next, source) { if (!this._isEnabled) return; const handlers = this._afterChangeHandlers[next.typeName]; 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, source) { if (!this._isEnabled) return true; const handlers = this._beforeDeleteHandlers[record.typeName]; 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, source) { if (!this._isEnabled) return; const handlers = this._afterDeleteHandlers[record.typeName]; 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) { 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) { const disposes = []; for (const [type, handlers] of Object.entries(handlersByType)) { 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(typeName, handler) { const handlers = this._beforeCreateHandlers[typeName]; 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(typeName, handler) { const handlers = this._afterCreateHandlers[typeName]; 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(typeName, handler) { const handlers = this._beforeChangeHandlers[typeName]; 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(typeName, handler) { const handlers = this._afterChangeHandlers[typeName]; if (!handlers) this._afterChangeHandlers[typeName] = []; this._afterChangeHandlers[typeName].push(handler); 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(typeName, handler) { const handlers = this._beforeDeleteHandlers[typeName]; if (!handlers) this._beforeDeleteHandlers[typeName] = []; this._beforeDeleteHandlers[typeName].push(handler); 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(typeName, handler) { const handlers = this._afterDeleteHandlers[typeName]; if (!handlers) this._afterDeleteHandlers[typeName] = []; this._afterDeleteHandlers[typeName].push(handler); 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) { this._operationCompleteHandlers.push(handler); return () => remove(this._operationCompleteHandlers, handler); } } function remove(array, item) { const index = array.indexOf(item); if (index >= 0) { array.splice(index, 1); } } export { StoreSideEffects }; //# sourceMappingURL=StoreSideEffects.mjs.map