@tldraw/store
Version:
tldraw infinite canvas SDK (store).
461 lines (460 loc) • 16.2 kB
JavaScript
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var StoreSideEffects_exports = {};
__export(StoreSideEffects_exports, {
StoreSideEffects: () => StoreSideEffects
});
module.exports = __toCommonJS(StoreSideEffects_exports);
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);
}
}
//# sourceMappingURL=StoreSideEffects.js.map