UNPKG

@tldraw/state

Version:

tldraw infinite canvas SDK (state).

292 lines (263 loc) • 8.51 kB
import { ArraySet } from './ArraySet' import { HistoryBuffer } from './HistoryBuffer' import { maybeCaptureParent } from './capture' import { EMPTY_ARRAY, equals, singleton } from './helpers' import { advanceGlobalEpoch, atomDidChange, getGlobalEpoch } from './transactions' import { Child, ComputeDiff, RESET_VALUE, Signal } from './types' /** * The options to configure an atom, passed into the {@link atom} function. * @public */ export interface AtomOptions<Value, Diff> { /** * The maximum number of diffs to keep in the history buffer. * * If you don't need to compute diffs, or if you will supply diffs manually via {@link Atom.set}, you can leave this as `undefined` and no history buffer will be created. * * If you expect the value to be part of an active effect subscription all the time, and to not change multiple times inside of a single transaction, you can set this to a relatively low number (e.g. 10). * * Otherwise, set this to a higher number based on your usage pattern and memory constraints. * */ historyLength?: number /** * A method used to compute a diff between the atom's old and new values. If provided, it will not be used unless you also specify {@link AtomOptions.historyLength}. */ computeDiff?: ComputeDiff<Value, Diff> /** * If provided, this will be used to compare the old and new values of the atom to determine if the value has changed. * By default, values are compared using first using strict equality (`===`), then `Object.is`, and finally any `.equals` method present in the object's prototype chain. * @param a - The old value * @param b - The new value * @returns True if the values are equal, false otherwise. */ isEqual?(a: any, b: any): boolean } /** * An Atom is a signal that can be updated directly by calling {@link Atom.set} or {@link Atom.update}. * * Atoms are created using the {@link atom} function. * * @example * ```ts * const name = atom('name', 'John') * * print(name.get()) // 'John' * ``` * * @public */ export interface Atom<Value, Diff = unknown> extends Signal<Value, Diff> { /** * Sets the value of this atom to the given value. If the value is the same as the current value, this is a no-op. * * @param value - The new value to set. * @param diff - The diff to use for the update. If not provided, the diff will be computed using {@link AtomOptions.computeDiff}. */ set(value: Value, diff?: Diff): Value /** * Updates the value of this atom using the given updater function. If the returned value is the same as the current value, this is a no-op. * * @param updater - A function that takes the current value and returns the new value. */ update(updater: (value: Value) => Value): Value } /** * Internal implementation of the Atom interface. This class should not be used directly - use the {@link atom} function instead. * * @internal */ class __Atom__<Value, Diff = unknown> implements Atom<Value, Diff> { constructor( public readonly name: string, private current: Value, options?: AtomOptions<Value, Diff> ) { this.isEqual = options?.isEqual ?? null if (!options) return if (options.historyLength) { this.historyBuffer = new HistoryBuffer(options.historyLength) } this.computeDiff = options.computeDiff } /** * Custom equality function for comparing values, or null to use default equality. * @internal */ readonly isEqual: null | ((a: any, b: any) => boolean) /** * Optional function to compute diffs between old and new values. * @internal */ computeDiff?: ComputeDiff<Value, Diff> /** * The global epoch when this atom was last changed. * @internal */ lastChangedEpoch = getGlobalEpoch() /** * Set of child signals that depend on this atom. * @internal */ children = new ArraySet<Child>() /** * Optional history buffer for tracking changes over time. * @internal */ historyBuffer?: HistoryBuffer<Diff> /** * Gets the current value without capturing it as a dependency in the current reactive context. * This is unsafe because it breaks the reactivity chain - use with caution. * * @param _ignoreErrors - Unused parameter for API compatibility * @returns The current value * @internal */ __unsafe__getWithoutCapture(_ignoreErrors?: boolean): Value { return this.current } /** * Gets the current value of this atom. When called within a computed signal or reaction, * this atom will be automatically captured as a dependency. * * @returns The current value * @example * ```ts * const count = atom('count', 5) * console.log(count.get()) // 5 * ``` */ get() { maybeCaptureParent(this) return this.current } /** * Sets the value of this atom to the given value. If the value is the same as the current value, this is a no-op. * * @param value - The new value to set * @param diff - The diff to use for the update. If not provided, the diff will be computed using {@link AtomOptions.computeDiff} * @returns The new value * @example * ```ts * const count = atom('count', 0) * count.set(5) // count.get() is now 5 * ``` */ set(value: Value, diff?: Diff): Value { // If the value has not changed, do nothing. if (this.isEqual?.(this.current, value) ?? equals(this.current, value)) { return this.current } // Tick forward the global epoch advanceGlobalEpoch() // Add the diff to the history buffer. if (this.historyBuffer) { this.historyBuffer.pushEntry( this.lastChangedEpoch, getGlobalEpoch(), diff ?? this.computeDiff?.(this.current, value, this.lastChangedEpoch, getGlobalEpoch()) ?? RESET_VALUE ) } // Update the atom's record of the epoch when last changed. this.lastChangedEpoch = getGlobalEpoch() const oldValue = this.current this.current = value // Notify all children that this atom has changed. atomDidChange(this as any, oldValue) return value } /** * Updates the value of this atom using the given updater function. If the returned value is the same as the current value, this is a no-op. * * @param updater - A function that takes the current value and returns the new value * @returns The new value * @example * ```ts * const count = atom('count', 5) * count.update(n => n + 1) // count.get() is now 6 * ``` */ update(updater: (value: Value) => Value): Value { return this.set(updater(this.current)) } /** * Gets all the diffs that have occurred since the given epoch. When called within a computed * signal or reaction, this atom will be automatically captured as a dependency. * * @param epoch - The epoch to get changes since * @returns An array of diffs, or RESET_VALUE if history is insufficient * @internal */ getDiffSince(epoch: number): RESET_VALUE | Diff[] { maybeCaptureParent(this) // If no changes have occurred since the given epoch, return an empty array. if (epoch >= this.lastChangedEpoch) { return EMPTY_ARRAY } return this.historyBuffer?.getChangesSince(epoch) ?? RESET_VALUE } } /** * Singleton reference to the Atom constructor. Used internally to create atom instances. * @internal */ export const _Atom = singleton('Atom', () => __Atom__) /** * Type alias for instances of the internal Atom class. * @internal */ export type _Atom = InstanceType<typeof _Atom> /** * Creates a new {@link Atom}. * * An Atom is a signal that can be updated directly by calling {@link Atom.set} or {@link Atom.update}. * * @example * ```ts * const name = atom('name', 'John') * * name.get() // 'John' * * name.set('Jane') * * name.get() // 'Jane' * ``` * * @public */ export function atom<Value, Diff = unknown>( /** * A name for the signal. This is used for debugging and profiling purposes, it does not need to be unique. */ name: string, /** * The initial value of the signal. */ initialValue: Value, /** * The options to configure the atom. See {@link AtomOptions}. */ options?: AtomOptions<Value, Diff> ): Atom<Value, Diff> { return new _Atom(name, initialValue, options) } /** * Returns true if the given value is an {@link Atom}. * * @param value - The value to check * @returns True if the value is an Atom, false otherwise * @example * ```ts * const myAtom = atom('test', 42) * const notAtom = 'hello' * * console.log(isAtom(myAtom)) // true * console.log(isAtom(notAtom)) // false * ``` * @public */ export function isAtom(value: unknown): value is Atom<unknown> { return value instanceof _Atom }