mobx-keystone
Version:
A MobX powered state management solution based on data trees with first class support for TypeScript, snapshots, patches and much more
798 lines (710 loc) • 20.2 kB
text/typescript
import { action, computed } from "mobx"
import type { ActionMiddlewareDisposer } from "../action/middleware"
import { modelAction } from "../action/modelAction"
import { Model } from "../model/Model"
import { model } from "../modelShared/modelDecorator"
import { fastGetRootPath } from "../parent/path"
import type { Path } from "../parent/pathTypes"
import { Patch, PatchRecorder, applyPatches, patchRecorder } from "../patch"
import { assertTweakedObject } from "../tweaker/core"
import { typesArray } from "../types/arrayBased/typesArray"
import { tProp } from "../types/tProp"
import { typesUnchecked } from "../types/utility/typesUnchecked"
import { failure, getMobxVersion, mobx6, namespace } from "../utils"
import { SimpleActionContext, actionTrackingMiddleware } from "./actionTrackingMiddleware"
/**
* An undo/redo event without attached state.
*/
export type UndoEventWithoutAttachedState = UndoSingleEvent | UndoEventGroup
/**
* An undo/redo event.
*/
export type UndoEvent = UndoEventWithoutAttachedState & {
/**
* The state saved before the event actions started / after the event actions finished.
*/
attachedState: {
/**
* The state saved before the event actions started.
*/
beforeEvent?: unknown
/**
* The state saved after the event actions finished.
*/
afterEvent?: unknown
}
}
/**
* Undo event type.
*/
export enum UndoEventType {
Single = "single",
Group = "group",
}
/**
* An undo/redo single event.
*/
export interface UndoSingleEvent {
/**
* Expresses this is a single event.
*/
readonly type: UndoEventType.Single
/**
* Path to the object that invoked the action from its root.
*/
readonly targetPath: Path
/**
* Name of the action that was invoked.
*/
readonly actionName: string
/**
* Patches with changes done inside the action.
* Use `redo()` in the `UndoManager` to apply them.
*/
readonly patches: ReadonlyArray<Patch>
/**
* Patches to undo the changes done inside the action.
* Use `undo()` in the `UndoManager` to apply them.
*/
readonly inversePatches: ReadonlyArray<Patch>
}
/**
* An undo/redo event group.
*/
export interface UndoEventGroup {
/**
* Expresses this is an event group.
*/
readonly type: UndoEventType.Group
/**
* Name of the group (if any).
*/
readonly groupName?: string
/**
* Events that conform this group (might be single events or other nested groups).
*/
readonly events: ReadonlyArray<UndoEventWithoutAttachedState>
}
function toSingleEvents(
event: UndoEventWithoutAttachedState,
reverse: boolean
): ReadonlyArray<UndoSingleEvent> {
if (event.type === UndoEventType.Single) {
return [event]
} else {
const array: UndoSingleEvent[] = []
for (const e of event.events) {
if (reverse) {
array.unshift(...toSingleEvents(e, true))
} else {
array.push(...toSingleEvents(e, false))
}
}
return array
}
}
/**
* Store model instance for undo/redo actions.
* Do not manipulate directly, other that creating it.
*/
(`${namespace}/UndoStore`)
export class UndoStore extends Model({
// TODO: add proper type checking to undo store
undoEvents: tProp(typesArray(typesUnchecked<UndoEvent>()), () => []),
redoEvents: tProp(typesArray(typesUnchecked<UndoEvent>()), () => []),
}) {
/**
* @ignore
*/
_clearUndo() {
withoutUndo(() => {
this.undoEvents.length = 0
})
}
/**
* @ignore
*/
_clearRedo() {
withoutUndo(() => {
this.redoEvents.length = 0
})
}
/**
* @ignore
*/
enforceMaxLevels({
maxUndoLevels,
maxRedoLevels,
}: {
maxUndoLevels?: number
maxRedoLevels?: number
}) {
if (maxUndoLevels !== undefined) {
while (this.undoEvents.length > maxUndoLevels) {
this.undoEvents.shift()
}
}
if (maxRedoLevels !== undefined) {
while (this.redoEvents.length > maxRedoLevels) {
this.redoEvents.shift()
}
}
}
/**
* @ignore
*/
_undo({ maxRedoLevels }: { maxRedoLevels: number | undefined }) {
withoutUndo(() => {
const event = this.undoEvents.pop()!
this.redoEvents.push(event)
this.enforceMaxLevels({ maxRedoLevels })
})
}
/**
* @ignore
*/
_redo({ maxUndoLevels }: { maxUndoLevels: number | undefined }) {
withoutUndo(() => {
const event = this.redoEvents.pop()!
this.undoEvents.push(event)
this.enforceMaxLevels({ maxUndoLevels })
})
}
/**
* @ignore
*/
_addUndo({ event, maxUndoLevels }: { event: UndoEvent; maxUndoLevels: number | undefined }) {
withoutUndo(() => {
this.undoEvents.push(event)
// once an undo event is added redo queue is no longer valid
this.redoEvents.length = 0
this.enforceMaxLevels({ maxUndoLevels })
})
}
private _groupStack: UndoEventGroup[] = []
/**
* @ignore
*/
_addUndoToParentGroup(parentGroup: UndoEventGroup, event: UndoEventWithoutAttachedState) {
;(parentGroup.events as UndoEventWithoutAttachedState[]).push(event)
}
/**
* @ignore
*/
get _currentGroup(): UndoEventGroup | undefined {
return this._groupStack[this._groupStack.length - 1]
}
/**
* @ignore
*/
_startGroup(
groupName: string | undefined,
startRunning: boolean,
options: UndoMiddlewareOptions<unknown> | undefined
) {
let running = false
let ended = false
const parentGroup = this._currentGroup
const group: UndoEventGroup = {
type: UndoEventType.Group,
groupName,
events: [],
}
const attachedStateBeforeEvent = parentGroup ? undefined : options?.attachedState?.save()
const api = {
pause: () => {
if (ended) {
throw failure("cannot pause a group when it is already ended")
}
if (!running) {
throw failure("cannot pause a group when it is not running")
}
if (this._currentGroup !== group) {
throw failure("group out of order")
}
this._groupStack.pop()
running = false
},
resume: () => {
if (ended) {
throw failure("cannot resume a group when it is already ended")
}
if (running) {
throw failure("cannot resume a group when it is already running")
}
this._groupStack.push(group)
running = true
},
end: () => {
if (running) {
api.pause()
}
ended = true
if (parentGroup) {
this._addUndoToParentGroup(parentGroup, group)
} else {
this._addUndo({
event: {
...group,
attachedState: {
beforeEvent: attachedStateBeforeEvent,
afterEvent: options?.attachedState?.save(),
},
},
maxUndoLevels: options?.maxUndoLevels,
})
}
},
}
if (startRunning) {
api.resume()
}
return api
}
}
/**
* Manager class returned by `undoMiddleware` that allows you to perform undo/redo actions.
*/
export class UndoManager {
/**
* The store currently being used to store undo/redo action events.
*/
readonly store: UndoStore
/**
* The undo stack, where the first operation to undo will be the last of the array.
* Do not manipulate this array directly.
*/
get undoQueue(): ReadonlyArray<UndoEvent> {
return this.store.undoEvents
}
/**
* The redo stack, where the first operation to redo will be the last of the array.
* Do not manipulate this array directly.
*/
get redoQueue(): ReadonlyArray<UndoEvent> {
return this.store.redoEvents
}
/**
* The number of undo actions available.
*/
get undoLevels() {
return this.undoQueue.length
}
/**
* If undo can be performed (if there is at least one undo action available).
*/
get canUndo() {
return this.undoLevels > 0
}
/**
* Clears the undo queue.
*/
clearUndo() {
this.store._clearUndo()
}
/**
* The number of redo actions available.
*/
get redoLevels() {
return this.redoQueue.length
}
/**
* If redo can be performed (if there is at least one redo action available)
*/
get canRedo() {
return this.redoLevels > 0
}
/**
* Clears the redo queue.
*/
clearRedo() {
this.store._clearRedo()
}
/**
* Undoes the last action.
* Will throw if there is no action to undo.
*/
undo() {
if (!this.canUndo) {
throw failure("nothing to undo")
}
const event = this.undoQueue[this.undoQueue.length - 1]
withoutUndo(() => {
toSingleEvents(event, true).forEach((e) => {
applyPatches(this.subtreeRoot, e.inversePatches, true)
})
// restore the attached state before the operation was made
if (event.attachedState?.beforeEvent) {
this.options?.attachedState?.restore(event.attachedState.beforeEvent)
}
})
this.store._undo({ maxRedoLevels: this.options?.maxRedoLevels })
}
/**
* Redoes the previous action.
* Will throw if there is no action to redo.
*/
redo() {
if (!this.canRedo) {
throw failure("nothing to redo")
}
const event = this.redoQueue[this.redoQueue.length - 1]
withoutUndo(() => {
toSingleEvents(event, false).forEach((e) => {
applyPatches(this.subtreeRoot, e.patches)
})
// restore the attached state after the operation was made
if (event.attachedState?.afterEvent) {
this.options?.attachedState?.restore(event.attachedState.afterEvent)
}
})
this.store._redo({ maxUndoLevels: this.options?.maxUndoLevels })
}
/**
* Disposes the undo middleware.
*/
dispose() {
this.disposer()
}
private _isUndoRecordingDisabled = false
/**
* Returns if undo recording is currently disabled or not for this particular `UndoManager`.
*/
get isUndoRecordingDisabled() {
return this._isUndoRecordingDisabled
}
/**
* Skips the undo recording mechanism for the code block that gets run synchronously inside.
*
* @template T Code block return type.
* @param fn Code block to run.
* @returns The value returned by the code block.
*/
withoutUndo<T>(fn: () => T): T {
const savedUndoDisabled = this._isUndoRecordingDisabled
this._isUndoRecordingDisabled = true
try {
return fn()
} finally {
this._isUndoRecordingDisabled = savedUndoDisabled
}
}
/**
* Creates a custom group that can be continued multiple times and then ended.
* @param groupName Optional group name.
* @returns An API to continue/end the group.
*/
createGroup(groupName?: string) {
const group = this.store._startGroup(groupName, false, this.options)
return {
continue<T>(fn: () => T): T {
group.resume()
try {
return fn()
} finally {
group.pause()
}
},
end() {
group.end()
},
}
}
/**
* Runs a synchronous code block as an undo group.
* Note that nested groups are allowed.
*
* @param groupName Group name.
* @param fn Code block.
* @returns Code block return value.
*/
withGroup<T>(groupName: string, fn: () => T): T
/**
* Runs a synchronous code block as an undo group.
* Note that nested groups are allowed.
*
* @param fn Code block.
* @returns Code block return value.
*/
withGroup<T>(fn: () => T): T
withGroup<T>(arg1: any, arg2?: any): T {
let groupName: string | undefined
let fn: () => T
if (typeof arg1 === "string") {
groupName = arg1
fn = arg2
} else {
fn = arg1
}
const group = this.store._startGroup(groupName, true, this.options)
try {
return fn()
} finally {
group.end()
}
}
/**
* Runs an asynchronous code block as an undo group.
* Note that nested groups are allowed.
*
* @param groupName Group name.
* @param fn Flow function.
* @returns Flow function return value.
*/
withGroupFlow<R>(groupName: string, fn: () => Generator<any, R, any>): Promise<R>
/**
* Runs an asynchronous code block as an undo group.
* Note that nested groups are allowed.
*
* @param fn Flow function.
* @returns Flow function return value.
*/
withGroupFlow<R>(fn: () => Generator<any, R, any>): Promise<R>
withGroupFlow<R>(arg1: any, arg2?: any): Promise<R> {
let groupName: string | undefined
let fn: () => Generator<any, R, any>
if (typeof arg1 === "string") {
groupName = arg1
fn = arg2
} else {
fn = arg1
}
const gen = fn()
const group = this.store._startGroup(groupName, false, this.options)
// use bound functions to fix es6 compilation
const genNext = gen.next.bind(gen)
const genThrow = gen.throw.bind(gen)
const promise = new Promise<R>((resolve, reject) => {
function onFulfilled(res: any): void {
group.resume()
let ret: unknown
try {
ret = genNext(res)
} catch (e) {
group.end()
reject(e as Error)
return
}
group.pause()
next(ret)
}
function onRejected(err: unknown): void {
group.resume()
let ret: unknown
try {
ret = genThrow(err)
} catch (e) {
group.end()
reject(e as Error)
return
}
group.pause()
next(ret)
}
function next(ret: any): void {
if (ret && typeof ret.then === "function") {
// an async iterator
ret.then(next, reject)
} else if (ret.done) {
// done
group.end()
resolve(ret.value)
} else {
// continue
Promise.resolve(ret.value).then(onFulfilled, onRejected)
}
}
onFulfilled(undefined) // kick off the process
})
return promise
}
/**
* Creates an instance of `UndoManager`.
* Do not use directly, use `undoMiddleware` instead.
*
* @param disposer
* @param subtreeRoot
* @param [store]
*/
constructor(
private readonly disposer: ActionMiddlewareDisposer,
private readonly subtreeRoot: object,
store: UndoStore | undefined,
private readonly options: UndoMiddlewareOptions<unknown> | undefined
) {
if (getMobxVersion() >= 6) {
mobx6.makeObservable(this)
}
this.store = store ?? new UndoStore({})
}
}
/**
* Undo middleware options.
*/
export interface UndoMiddlewareOptions<S> {
/**
* Max number of undo levels to keep, or undefined for infinite.
*/
maxUndoLevels?: number
/**
* Max number of redo levels to keep, or undefined for infinite.
*/
maxRedoLevels?: number
/**
* Attached states are states that are saved/restored when undoing/redoing.
* Usually used to restore state that is not part of the document model such as focus, selection, scroll position, etc.
*/
attachedState?: {
/**
* Saves a certain state and associates it with the undo step. This state can be restored when undoing/redoing.
*/
save(): S
/**
* Restores a certain state previously saved.
* @param s State to restore.
*/
restore(s: S): void
}
}
/**
* Creates an undo middleware.
*
* @param subtreeRoot Subtree root target object.
* @param store Optional `UndoStore` where to store the undo/redo queues. Use this if you want to
* store such queues somewhere in your models. If none is provided it will reside in memory.
* @param options Extra options, such as how to save / restore certain snapshot of the state to be restored when undoing/redoing.
* @returns An `UndoManager` which allows you to do the manage the undo/redo operations and dispose of the middleware.
*/
export function undoMiddleware<S>(
subtreeRoot: object,
store?: UndoStore,
options?: UndoMiddlewareOptions<S>
): UndoManager {
assertTweakedObject(subtreeRoot, "subtreeRoot")
// biome-ignore lint/style/useConst: is reassigned later
let manager: UndoManager
interface PatchRecorderData {
recorder: PatchRecorder
recorderStack: number
undoRootContext: SimpleActionContext
group: UndoEventGroup | undefined
attachedStateBeforeEvent: S | undefined
}
const patchRecorderSymbol = Symbol("patchRecorder")
function initPatchRecorder(ctx: SimpleActionContext) {
const group = manager.store._currentGroup
const patchRecorderData: PatchRecorderData = {
recorder: patchRecorder(subtreeRoot, {
recording: false,
filter: () => {
return !(_isGlobalUndoRecordingDisabled || manager.isUndoRecordingDisabled)
},
}),
recorderStack: 0,
undoRootContext: ctx,
group,
attachedStateBeforeEvent: options?.attachedState?.save(),
}
ctx.rootContext.data[patchRecorderSymbol] = patchRecorderData
}
function getPatchRecorderData(ctx: SimpleActionContext): PatchRecorderData {
return ctx.rootContext.data[patchRecorderSymbol]
}
const middlewareDisposer = actionTrackingMiddleware(subtreeRoot, {
onStart(ctx) {
if (!getPatchRecorderData(ctx)) {
initPatchRecorder(ctx)
}
},
onResume(ctx) {
const patchRecorderData = getPatchRecorderData(ctx)
patchRecorderData.recorderStack++
patchRecorderData.recorder.recording = patchRecorderData.recorderStack > 0
},
onSuspend(ctx) {
const patchRecorderData = getPatchRecorderData(ctx)
patchRecorderData.recorderStack--
patchRecorderData.recorder.recording = patchRecorderData.recorderStack > 0
},
onFinish(ctx) {
const patchRecorderData = getPatchRecorderData(ctx)
if (patchRecorderData && patchRecorderData.undoRootContext === ctx) {
const patchRecorder = patchRecorderData.recorder
if (patchRecorder.events.length > 0) {
const patches: Patch[] = []
const inversePatches: Patch[] = []
for (const event of patchRecorder.events) {
patches.push(...event.patches)
inversePatches.push(...event.inversePatches)
}
const event = {
type: UndoEventType.Single,
targetPath: fastGetRootPath(ctx.target, false).path,
actionName: ctx.actionName,
patches,
inversePatches,
} as const
const parentGroup = patchRecorderData.group
if (parentGroup) {
manager.store._addUndoToParentGroup(parentGroup, event)
} else {
manager.store._addUndo({
event: {
...event,
attachedState: {
beforeEvent: patchRecorderData.attachedStateBeforeEvent,
afterEvent: options?.attachedState?.save(),
},
},
maxUndoLevels: options?.maxUndoLevels,
})
}
}
patchRecorder.dispose()
}
},
})
manager = new UndoManager(middlewareDisposer, subtreeRoot, store, options)
return manager
}
let _isGlobalUndoRecordingDisabled = false
/**
* Returns if the undo recording mechanism is currently disabled.
*
* @returns `true` if it is currently disabled, `false` otherwise.
*/
export function isGlobalUndoRecordingDisabled() {
return _isGlobalUndoRecordingDisabled
}
/**
* Globally skips the undo recording mechanism for the code block that gets run synchronously inside.
* Consider using the `withoutUndo` method of a particular `UndoManager` instead.
*
* @template T Code block return type.
* @param fn Code block to run.
* @returns The value returned by the code block.
*/
export function withoutUndo<T>(fn: () => T): T {
const savedUndoDisabled = _isGlobalUndoRecordingDisabled
_isGlobalUndoRecordingDisabled = true
try {
return fn()
} finally {
_isGlobalUndoRecordingDisabled = savedUndoDisabled
}
}