Partial Lenses History is a JavaScript library for state manipulation with Undo-Redo history. Basic features:
- History can be serialized as JSON
- All operations on history are either
O(1)
orO(log n)
- Interactive documentation (the ▶
links) and live
examples:
- The Basic Undo-Redo CodeSandbox provides a simple example that is discussed below.
- The Form using Context CodeSandbox also demonstrates this library.
- Mostly functional API:
- Supports tree-shaking
- Contract checking in non-production builds
- MIT license
- A basic example
- Reference
- Basic properties
- Creating
- Present
H.present ~> valueLens
v0.2.0
- Undo
H.undoForget(history) ~> history
v0.1.0H.undoIndex ~> numberLens
v0.2.0
- Redo
H.redoForget(history) ~> history
v0.1.0H.redoIndex ~> numberLens
v0.2.0
- Time travel
H.count(history) ~> number
v0.1.0H.index ~> numberLens
v0.2.0H.indexMax(history) ~> number
v0.2.3
This section describes the Basic Undo-Redo CodeSandbox example that was written to demonstrate usage of this library. There is a text area and edits retain history that can then be viewed through the undo and redo buttons. You probably want to open the example beside this tutorial.
Looking at the code, the first thing you might notice is the import statement:
import * as H from 'kefir.partial.lenses.history'
When used in a Karet UI, this library is intended to be used through the Kefir Partial Lenses History library, which is a simple lifted wrapper around this library. Lifting allows the functions of this library to be directly used on Kefir properties and atoms representing time-varying values and reactive variables. However, this library does not depend on Karet or Kefir and can be used with pretty much any UI framework.
To use history, one must first use H.init
to create the initial
history value and then store the value:
const history = U.atom(H.init({}, ''))
In this case we use U.atom
to
create an atom to store the history.
In a plain React UI, for example, one would typically store the history in component state:
this.state = {history: H.init({}, '')}
To access the present value from history, one uses the H.present
lens:
const text = U.view(H.present, history)
As we are using atoms, we can use the
U.view
function to create a
bidirectional view of the present that we can then use to both read and write
the present value.
In a plain React UI, one could use
L.get
to read the present value from component state:const currentText = L.get(['history', H.present], this.state)and
L.set
to write to the present value in component state:this.setState(L.set(['history', H.present], newText))The point here is that this library is not at all limited to Karet UIs. In the remainder we will only discuss the actual example.
Now that we have the text
view, we can use it to access the text without
knowing anything about the history. So we can simply instantiate a
U.TextArea
with the
text
as the value
:
<U.TextArea placeholder="Retains history" value={text} />
Now edits through the text area generate history. Note that, while in this case we only store simple strings in history, values stored in history can be arbitrarily complex trees of objects.
Of course, to actually make use of the history, we need to provide access to the history itself, rather than just the present value. To that end we implement a countdown button component:
const CountdownButton = ({count, shortcut, children, ...props}) => (
<button disabled={R.not(count)} onClick={U.doModify(count, R.dec)} {...props}>
{children}
{U.when(count, U.string` (${count})`)}
{U.when(
shortcut,
U.thru(
U.fromEvents(document.body, 'keydown', false),
U.skipUnless(shortcut),
U.consume(U.actions(U.preventDefault, U.doModify(count, R.dec)))
)
)}
</button>
)
The above CountdownButton
component expects to receive a count
atom
containing a non-negative integer and it then renders a button that is enabled
when the count is positive. Clicking the button decrements the count
.
Additionally, given a shortcut
event predicate, it also binds a keyboard event
handler to the document that performs the same decrement action. Note that the
above CountdownButton
knows nothing about history. It is just a generic
button that decrements a counter.
To wire countdown buttons to perform undo and redo actions on history, we use
the H.undoIndex
and H.redoIndex
lenses to
view the history. Here is how it looks like for the undo button:
<CountdownButton
count={U.view(H.undoIndex, history)}
title="Ctrl-z"
shortcut={e => e.ctrlKey && e.key === 'z'}>
Undo
</CountdownButton>
Modifying the undo index actually modifies the history. That pretty much covers basic usage of this library.
The combinators provided by this library are available as named imports. Typically one just imports the library as:
import * as H from 'partial.lenses.history'
The examples also use the Partial Lenses library imported as
import * as L from 'partial.lenses'
and the following helper function,
thru
, that pipes a value
through the given sequence of functions:
function thru(x, ...fns) {
return fns.reduce((x, fn) => fn(x), x)
}
The history data type should be considered opaque. However, the history data
structure itself only uses JSON compatible types.
Assuming that JSON.parse(JSON.stringify(v))
is considered equivalent to v
for any value v
put into history, then it is guaranteed that
JSON.parse(JSON.stringify(history))
is considered equivalent to history
.
The internal implementation of history uses a simple but fairly efficient data
structure (currently a radix search trie) that can perform all the operations
exposed by this library in either O(1)
or O(log n)
time.
Since version 1.1.0 the history data structure is kept
frozen
when NODE_ENV
is not production
. Only the history data structure itself is
frozen. Values inserted into history are not frozen by this library.
Certain operations, namely H.init
and
L.set(H.present)
in this library are not pure functions, because
they take
timestamps
underneath.
H.init
creates a new history state object with the given initial value
. The
named parameters, maxCount
, replacePeriod
, and pushEquals
, are optional
and control how history is updated when the state is modified through
H.present
.
maxCount
defaults to2^31-1
and specifies the maximum number of entries to keep in history.pushEquals
defaults tofalse
and determines whether writing a value that is equal to the present value updates history or not.replacePeriod
defaults to0
and specifies a period in milliseconds during which an update replaces the present value without adding history.
For example:
thru(
H.init({}, 101),
L.get(H.present)
)
// 101
Note that H.init
is not a pure function, because it takes a timestamp
underneath.
≡ ▶ H.present ~> valueLens
v0.2.0
H.present
is a
lens that focuses
on the present value of history.
For example:
thru(
H.init({}, 42),
L.modify(H.present, x => -x),
L.get(H.present)
)
// -42
Note that modifications through H.present
are not referentially transparent
operations, because setting through H.present
takes a timestamp underneath.
≡ ▶ H.undoForget(history) ~> history
v0.1.0
H.undoForget
removes all entries prior to present from history.
For example:
thru(
H.init({}, '1st'),
L.set(H.present, '2nd'),
L.set(H.present, '3rd'),
H.undoForget,
L.get(H.undoIndex)
)
// 0
≡ ▶ H.undoIndex ~> numberLens
v0.2.0
H.undoIndex
is a
lens that focuses
on the undo position of history.
For example:
thru(
H.init({}, '1st'),
L.set(H.present, '2nd'),
L.set(H.present, '3rd'),
L.modify(H.undoIndex, n => n-1),
L.get(H.present)
)
// '2nd'
≡ ▶ H.redoForget(history) ~> history
v0.1.0
H.redoForget
removes all entries following present from history.
For example:
thru(
H.init({}, '1st'),
L.set(H.present, '2nd'),
L.set(H.present, '3rd'),
L.set(H.index, 0),
H.redoForget,
L.get(H.redoIndex)
)
// 0
≡ ▶ H.redoIndex ~> numberLens
v0.2.0
H.redoIndex
is a
lens that focuses
on the redo position of history.
For example:
thru(
H.init({}, '1st'),
L.set(H.present, '2nd'),
L.set(H.present, '3rd'),
L.set(H.index, 0),
L.modify(H.redoIndex, n => n-1),
L.get(H.present)
)
// '2nd'
≡ ▶ H.count(history) ~> number
v0.1.0
H.count
returns the number of entries in history. See also
H.indexMax
.
For example:
thru(
H.init({}, '1st'),
L.set(H.present, '2nd'),
L.set(H.present, '3rd'),
H.count
)
// 3
≡ ▶ H.index ~> numberLens
v0.2.0
H.index
is a
lens that focuses
on the index of present of history.
For example:
thru(
H.init({}, '1st'),
L.set(H.present, '2nd'),
L.set(H.present, '3rd'),
L.set(H.index, 1),
L.get(H.present)
)
// '2nd'
≡ ▶ H.indexMax(history) ~> number
v0.2.3
H.indexMax
returns the maximum history index. See also H.count
.
For example:
thru(
H.init({}, '1st'),
L.set(H.present, '2nd'),
L.set(H.present, '3rd'),
H.indexMax
)
// 2