Skip to content

Instantly share code, notes, and snippets.

@tonyonodi
Last active November 10, 2023 01:24
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save tonyonodi/6a1bf5a8ff50b601488b515b93a4b120 to your computer and use it in GitHub Desktop.
Save tonyonodi/6a1bf5a8ff50b601488b515b93a4b120 to your computer and use it in GitHub Desktop.

How a Modern Web Text Editor Should Work

The current crop of browser based text editors are difficult to integrate into websites that use state containers - such as Redux, Elm, and React's setState - in a satisfactory way. In a react context we would ideally like to give the editor component a value attribute and update the attribute ourselves, allowing the changes to flow back down to the editor which can then re-render. The editor can maintain a mutable text buffer in its internal state for performance reasons, but as long as its methods are bug free this should look no different to an immutable, state-free component to an outside component.

First let's look at what the initial state of such an editor, with an empty buffer and undo history, would look like assuming we are using immutable.js to maintain state:

{
  historyCursor: null
  history: List([])
}

As user interaction causes change events to be fired the component's onChange function is passed change objects, which describe, completely, the changes to the text buffer that a user interaction has caused. CodeMirror's change function achieves this using its changeObj object, let's look at how its documentation describes this object:

The changeObj is a {from, to, text, removed, origin} object containing information about the changes that occurred as second argument. from and to are the positions (in the pre-change coordinate system) where the change started and ended (for example, it might be {ch:0, line:18} if the position is at the beginning of line #19). text is an array of strings representing the text that replaced the changed range (split by line). removed is the text that used to be between from and to, which is overwritten by this change.

For our purposes we need to make only a few changes from the way this works. For simplicity's sake we can remove removed and origin, as properties for now and add timeSinceLastChange and id properties. timeSinceLastChange is the time in milliseconds since the last edit occurred. In instances where the time of the last update is unclear to the editor's inner workings (for example when a new history state has been loaded into the editor) it should be Infinity. So an example change object could look like this:

{
	id: `j4q8zh12vsfho124oy3v`
	from: { ch: 0, line: 18},
	to: { ch: 2, line: 18},
	text: ["skate"],
	timeSinceLastChange: 559
}

If line 18 of the buffer initially read "carpark" and this change were applied to it line 18 would then read "skatepark". In our example change and the cursor coordinate in from and to are both ordinary JavaScript objects for brevity's sake, but whether they are implemented this way or as an immutable.js Map is really a performance dependent implementation detail.

Now let's look at how an onChange method would apply changes to the editor's buffer using setState:

onChange(change) {
	const { id } = change;
	const { history } = this.state;
	
	this.setState({
		historyCursor: id,
		history: history.push(change)
	})
}

With this function our history acts as an append only journal of changes while historyCursor points to the most recent change. To undo or redo changes we can simply change the historyCursor to point at the id of another change in the history. However this implementation of the onChange function clearly is not set up to handle undo/redo events so we can alter its implementation as follows, copying the redux model somewhat:

onChange(action) {
	const { type } = action;
	switch(type) {
		case "appendChange":
			const { change } = action;
			const { history } = this.state;
			
			this.setState({
				historyCursor: change.id,
				history: history.push(change)
			})
		case "moveHistoryCursor":
			this.setState({
				historyCursor: action.historyCursor
			})
	}
}

For completeness' sake the render function in this instance might look something like

render() {
	<ImmutableEditor historyCursor={this.state.historyCursor} history={this.state.history} />
}

Where ImmutableEditor is the name of our editor component.

Saving and Retrieving History

Using this model one can save a buffer and its history and be certain they are in a consistent state simply by serialising the history using the List.prototype.toJSON() method that Immutable.js provides and saving the resulting JSON string. At any time one file's buffer and history can be swapped for another's simply by changing the historyCursor and history properties in the parent component's state. If a new instance of an editor is created with its history set to a previously serialised editor history it can simply run a reduce function on the history and, since the buffer state would be a deterministic product of the list of changes, recreate the state of the previous editor buffer when its history was serialised.

Chunking Undo Operations

It would obviously be a pretty poor user experience to have an undo action simply roll back the historyCursor position by one change, potentially undoing by one letter at a time! Which is why changes have timeSinceLastChange property. When the editor is asked to perform an undo operation it can chunk changes together by going back through the change history, starting at the change currently indicated by the cursor, summing the values of timeSinceLastChange until the sum exceeds 200 and then returning the id of the change that exceeded that interval as the new historyCursor. The same could be done in reverse for redo operations.

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