Skip to content

Latest commit

 

History

History
418 lines (333 loc) · 16.4 KB

README.md

File metadata and controls

418 lines (333 loc) · 16.4 KB

Partial Lenses History is a JavaScript library for state manipulation with Undo-Redo history. Basic features:

npm version Build Status Code Coverage

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 to 2^31-1 and specifies the maximum number of entries to keep in history.
  • pushEquals defaults to false and determines whether writing a value that is equal to the present value updates history or not.
  • replacePeriod defaults to 0 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 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 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 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 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 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 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 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 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