Skip to content

Instantly share code, notes, and snippets.

@sag1v
Last active December 3, 2024 22:00
Show Gist options
  • Save sag1v/86dabe5f5ca9e3ca28ea39c374d51c9f to your computer and use it in GitHub Desktop.
Save sag1v/86dabe5f5ca9e3ca28ea39c374d51c9f to your computer and use it in GitHub Desktop.
A simple implementation of undo and redo in JavaScript
const stripLast = arr => {
// split the last item from an array and return a tupple of [rest, last]
const length = arr.length;
const lastItem = arr[length - 1];
const restOfArr = arr.slice(0, length - 1);
return [restOfArr, lastItem];
};
const createTrimStartArray = length => arr => {
const startIndex = arr.length < length ? 0 : length;
const trimmedArr = arr.slice(startIndex, arr.length);
return trimmedArr;
};
function createUndoRedo(initial, options = {}) {
const { trace, historyLimit = Infinity } = options;
let _historyLimit = historyLimit;
let _trimStartArray = createTrimStartArray(_historyLimit);
let _timeline = {
past: [],
current: initial,
future: []
};
log("init");
function log(str = "") {
trace && console.log(str, this.current);
}
function _getCurrent() {
return _timeline.current;
}
function _canUndo() {
return _timeline.past.length > 0;
}
function _canRedo() {
return _timeline.future.length > 0;
}
function update(next) {
// update the current value
const { past, current } = _timeline;
// calculate history storage limit
const limitedPast = _trimStartArray(past);
_timeline = {
past: [...limitedPast, current],
current: next,
// reset redo, don't allow redo if we update in the middle of the timeline
// this seems to be the idiomatic approach for most applications
future: []
};
log("update");
return this.current;
}
function undo() {
if (this.canUndo) {
const { past, current, future } = _timeline;
const [restOfArr, lastItem] = stripLast(past);
_timeline = {
past: restOfArr,
current: lastItem,
future: [...future, current]
};
log("undo");
return this.current;
}
}
function redo() {
if (this.canRedo) {
const { past, current, future } = _timeline;
const [restOfArr, lastItem] = stripLast(future);
_timeline = {
past: [...past, current],
current: lastItem,
future: restOfArr
};
log("redo");
return this.current;
}
}
const publicAPI = {
update,
undo,
redo,
get current() {
return _getCurrent();
},
get canRedo() {
return _canRedo();
},
get canUndo() {
return _canUndo();
},
get historyLimit() {
return _historyLimit;
},
set historyLimit(val) {
_historyLimit = val;
_trimStartArray = createTrimStartArray(_historyLimit);
}
};
return publicAPI;
}
const splitLast = arr => {
// split the last item from an array and return a tupple of [rest, last]
const length = arr.length;
const lastItem = arr[length - 1];
const restOfArr = arr.slice(0, length - 1);
return [restOfArr, lastItem];
};
const createShiftArrayItem = length => arr => {
const startIndex = arr.length < length ? 0 : length;
const trimmedArr = arr.slice(startIndex, arr.length);
return trimmedArr;
};
const purify = val => {
const typeAsString = Object.prototype.toString.call(val);
switch (typeAsString) {
case "[object Array]":
return val.slice();
case "[object Object]":
return { ...val };
default:
return val;
}
};
function createUndoRedo(initial, options = {}) {
const { trace, immutable, historyLimit = Infinity } = options;
let _historyLimit = historyLimit;
let _shiftArrayItem = createShiftArrayItem(_historyLimit);
logCurrent("init");
function createTimeline(initialCurrent) {
return {
history: [],
current: initialCurrent,
future: []
};
}
function logCurrent(str = "") {
trace && console.log(str, this.current);
}
function update(timeline, next) {
// update the current value
const { history, current } = timeline;
// calculate history storage limit
const limitedHistory = _shiftArrayItem(history);
const nextTimeline = {
history: [...limitedHistory, current],
current: immutable ? purify(next) : next,
// reset redo, don't allow redo if we update in the middle of the timeline
// this seems to be the idiomatic approach for most applications
future: []
};
logCurrent("update");
return nextTimeline;
}
function undo(timeline) {
if (_canUndo(timeline)) {
const { history, current, future } = timeline;
const [restOfArr, lastItem] = splitLast(history);
const nextTimeline = {
history: restOfArr,
current: lastItem,
future: [...future, current]
};
logCurrent("undo");
return nextTimeline;
} else {
return timeline;
}
}
function redo(timeline) {
if (_canRedo(timeline)) {
const { history, current, future } = timeline;
const [restOfArr, lastItem] = splitLast(future);
const nextTimeline = {
history: [...history, current],
current: lastItem,
future: restOfArr
};
logCurrent("redo");
return nextTimeline;
} else {
return timeline;
}
}
function _canUndo(timeline) {
return timeline.history.length > 0;
}
function _canRedo(timeline) {
return timeline.future.length > 0;
}
const publicAPI = {
createTimeline,
update,
undo,
redo,
get historyLimit() {
return _historyLimit;
},
set historyLimit(val) {
_historyLimit = val;
_shiftArrayItem = createShiftArrayItem(_historyLimit);
}
};
return publicAPI;
}
@sag1v
Copy link
Author

sag1v commented Apr 22, 2019

usage example with react

@Benjythebee
Copy link

const splitLast = <T>(arr: T[][]): [T[][], T[] | undefined] => {
	// split the last item from an array and return a tupple of [rest, last]
	const length = arr.length
	const lastItem = arr[length - 1]
	const restOfArr = arr.slice(0, length - 1)
	return [restOfArr, lastItem]
}

const sliceEnd = <T>(arr: T[][], size: number) => {
	// slice array from to end by size
	const startIndex = arr.length < size ? 0 : size
	const trimmedArr = arr.slice(startIndex, arr.length)
	return trimmedArr
}

export class UndoRedo<T> {
	trace: boolean
	_historyLimit: number

	_timeline: {
		history: T[][]
		current: T[]
		future: T[][]
	}
	constructor(
		initial: T[],
		options: {
			trace?: boolean
			historyLimit?: number
		} = {},
	) {
		const { trace, historyLimit = Infinity } = options
		this.trace = trace || false
		this._historyLimit = historyLimit

		this._timeline = {
			history: [],
			current: initial,
			future: [],
		}
		this.log('init')
	}

	log(str = '') {
		this.trace && console.log(str, this.current)
	}

	_getCurrent() {
		return this._timeline.current
	}

	_canUndo() {
		return this._timeline.history.length > 0
	}

	_canRedo() {
		return this._timeline.future.length > 0
	}

	update(next: T[] | undefined) {
		// update the current value
		const { history, current } = this._timeline
		// calculate history storage limit
		const limitedHistory = sliceEnd(history, this._historyLimit)
		this._timeline = {
			history: [...limitedHistory, current],
			current: next || [],
			// reset redo, don't allow redo if we update in the middle of the timeline
			// this seems to be the idiomatic approach for most applications
			future: [],
		}
		this.log('update')
		return this.current
	}

	add(value: T) {
		const next = [...this._timeline.current, value]
		return this.update(next)
	}

	undo() {
		if (this.canUndo) {
			const { history, current, future } = this._timeline
			const [restOfArr, lastItem] = splitLast(history)
			this._timeline = {
				history: restOfArr,
				current: lastItem || [],
				future: [...future, current],
			}
			this.log('undo')
			return this.current
		}
	}

	redo() {
		if (this.canRedo) {
			const { history, current, future } = this._timeline
			const [restOfArr, lastItem] = splitLast(future)
			this._timeline = {
				history: [...history, current],
				current: lastItem || [],
				future: restOfArr,
			}
			this.log('redo')
			return this.current
		}
	}

	resetHistory() {
		this._timeline.history = []
	}

	resetFuture() {
		this._timeline.future = []
	}

	get current() {
		return this._getCurrent()
	}
	get canRedo() {
		return this._canRedo()
	}
	get canUndo() {
		return this._canUndo()
	}
	get historyLimit() {
		return this._historyLimit
	}
	set historyLimit(val) {
		this._historyLimit = val
	}
	get timeline() {
		return this._timeline
	}
}

export default UndoRedo

For those wanting typescript

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment