threepipe
Version:
A modern 3D viewer framework built on top of three.js, written in TypeScript, designed to make creating high-quality, modular, and extensible 3D experiences on the web simple and enjoyable.
221 lines (202 loc) • 9.17 kB
text/typescript
import {AViewerPluginSync, ThreeViewer} from '../../viewer'
import {
ActionUndoCommand,
AnyFunction,
getUrlQueryParam,
JSUndoManager,
onChange,
PrimitiveVal,
recordUndoCommand,
SetValueUndoCommand,
setValueUndoCommand,
SetValueUndoCommandProps,
} from 'ts-browser-helpers'
/**
* UndoManagerPlugin is a plugin for ThreeViewer that provides undo/redo functionality.
* It uses the JSUndoManager(from ts-browser-helpers) library to maintain a common undo/redo history across the viewer and other plugins.
*/
// @uiPanelContainer('Undo Manager')
export class UndoManagerPlugin extends AViewerPluginSync {
public static readonly PluginType = 'UndoManagerPlugin'
// @uiToggle()
(UndoManagerPlugin.prototype._refresh)
enabled = true
undoManager?: JSUndoManager
(UndoManagerPlugin.prototype._refresh)
limit = 1000
constructor(enabled = true, limit = 1000) {
super()
this.enabled = enabled
this.limit = limit
}
protected _refresh() {
if (!this.undoManager) return
this.undoManager.enabled = this.enabled
this.undoManager.limit = this.limit
this.undoManager.options.debug = this._viewer?.debug || this.undoManager.options.debug
if (this.undoManager) Object.assign(this.undoManager.presets, this.undoPresets)
}
toJSON: any = undefined
onAdded(viewer: ThreeViewer) {
super.onAdded(viewer)
this.undoManager = new JSUndoManager({bindHotKeys: true, limit: this.limit, debug: viewer.debug || getUrlQueryParam('debugUndo') !== null, hotKeyRoot: document as any})
this._refresh()
}
onRemove(viewer: ThreeViewer) {
this.undoManager?.dispose()
this.undoManager = undefined
super.onRemove(viewer)
}
undoEditingWaitTime = 2000 // todo sync time with any ui plugins
recordUndo(com: SetValueUndoCommand|ActionUndoCommand) {
return recordUndoCommand(this.undoManager, com, this.undoCommandTypes.setValue, this.undoEditingWaitTime)
}
/**
* Performs an action with undo/redo support.
* @param targ - the target object to call the action on
* @param action - a function that returns - 1. an undo function, 2. an object with undo and redo functions (and optional action)
* @param args - the arguments to pass to the action function
* @param uid - unique identifier for the command, not really used in actions
* @param onUndoRedo - optional callback function to be called on undo/redo of the command. Not called on first action execution, only on undo/redo.
*/
async performAction<T extends AnyFunction>(targ: any|undefined, action: T, args: Parameters<T>, uid: any, onUndoRedo?: (c: ActionUndoCommand)=>void) {
const ac = ()=> targ === undefined ? action(...args) : action.call(targ, ...args) // if a function is returned, it is treated as undo function
let res = await ac()
const undo = typeof res === 'function' ? res : res?.undo?.bind(res)
const resAction = typeof res !== 'function' ? res?.action?.bind(res) : null
const redo = typeof res === 'function' ? ac : res?.redo?.bind(res) ?? resAction
if (typeof resAction === 'function') {
res = await resAction() // execute the action now. adding await just in case
}
if (typeof undo === 'function') {
this.recordUndo({
type: 'UiConfigMethods_action',
uid: uid,
target: targ,
undo: undo,
redo: redo,
args,
onUndoRedo,
})
}
}
/**
* Sets a value in the target object with undo/redo support.
* @param binding - a tuple of target object and key to set the value on
* @param value - the value to set
* @param props - properties for the undo command, including last, and lastValue(optional)
* @param uid - unique identifier for the command, used to merge commands
* @param forceOnChange
* @param trackUndo - whether to track the undo command or not, defaults to true
* @param onUndoRedo - optional callback function to be called on undo/redo of the command
* @returns true if the value was set and the command was recorded, false if the command was not recorded (e.g. if it was not undoable or forceOnChange was false)
*/
setValue<T extends PrimitiveVal, T1 = any>(binding: [T1, keyof T1], value: T, props: SetValueUndoCommandProps<T>, uid?: any, forceOnChange?: boolean, trackUndo = true, onUndoRedo?: (c: SetValueUndoCommand)=>void) {
const ev = setValueUndoCommand(this.undoManager, binding, value, props, uid, this.undoCommandTypes.setValue, trackUndo, this.undoEditingWaitTime, true, onUndoRedo)
if (!ev.undoable && !forceOnChange) return false
// this.dispatchOnChangeSync({...props, ...ev}) // todo
return true
}
setValues(bindings: [any, keyof any][], defs: any[], v: any[], props: SetValueUndoCommandProps<any>, uid?: any, forceOnChange?: boolean, trackUndo = true, onUndoRedo?: (c: SetValueUndoCommand)=>void) {
// array proxy for bindings, this is required because undo modifies arrays in place, and it's better as we only update the bindings that are actually changed.
const proxy = createBindingsProxy(bindings, defs)
return this.setValue([proxy, 'value'], v, props, uid, forceOnChange, trackUndo, onUndoRedo)
}
readonly undoCommandTypes = {
setValue: 'ThreeViewerUM_set' as const,
action: 'ThreeViewerUM_action' as const,
} as const
undoPresets = {
[this.undoCommandTypes.setValue]: (c: SetValueUndoCommand)=>{
const ref = ()=>{
c.onUndoRedo && c.onUndoRedo(c)
// c.uid.uiRefresh?.(false)
}
return {
undo: ()=>{
console.log('undo', c.lastVal)
if (!c.binding) return
this.setValue(c.binding, c.lastVal, c.props, c.uid, undefined, false)
// .then(ref)
ref()
},
redo: ()=>{
// console.log('redo', c.val)
if (!c.binding) return
this.setValue(c.binding, c.val, c.props, c.uid, undefined, false)
// .then(ref)
ref()
},
}
},
[this.undoCommandTypes.action]: (c: ActionUndoCommand)=>{
const ref = ()=>{
c.onUndoRedo && c.onUndoRedo(c)
}
return {
undo: async()=>{
await c.undo.call(c.target, ...c.args)
ref()
},
redo: async()=>{
await c.redo.call(c.target, ...c.args)
ref()
},
}
},
}
}
/**
* Creates a proxy for an array of bindings, allowing to access and set values in the target objects by editing the value.
* Useful for updating multiple properties in a single undo/redo command when dragging.
* @param bindings
* @param defs
*/
export function createBindingsProxy(bindings: [any, keyof any][], defs: any[]) {
return {
p: new Proxy([] as any[], {
get(_target, p, ...rest): any {
if (p === 'length') {
return bindings.length
}
const index = Number(p)
if (isNaN(index) || index < 0 || index >= bindings.length) {
return Reflect.get(Array.prototype, p, ...rest) || Reflect.get(_target, p, ...rest)
}
const [target, key] = bindings[index]
return target?.[key] ?? defs[index]
},
set(_target, p, newValue: any, ...rest): boolean {
const index = Number(p)
if (isNaN(index) || index < 0 || index >= bindings.length) {
return Reflect.set(_target, p, newValue, ...rest)
}
const [target, key] = bindings[index]
if (target) {
target[key] = newValue
return true
}
return false
},
// for every etc.
has(_target, p, ...rest) {
const index = Number(p)
if (isNaN(index) || index < 0 || index >= bindings.length) {
return Reflect.has(Array.prototype, p, ...rest) || Reflect.has(_target, p, ...rest)
}
return true
},
}),
get value() {
return this.p
},
set value(va: any[]) {
if (bindings.length !== va.length) {
console.error(`UndoManager - setValues: bindings length (${bindings.length}) does not match value length (${va.length})`)
}
for (let i = 0; i < Math.min(va.length, bindings.length); i++) {
this.p[i] = va[i]
}
},
}
}