I\'m going to go a bit in-depth with my problem, you can jump to the TL;DR if you don\'t want to read all of this
I
Try to create basic comparison identifiers, e.g., at js
below "+"
, "-"
; utilize .map()
to compare original o
, edited e
input strings , return array diff
of differences between o
, e
; set o
, e,
, diff
as properties of object
var o = "Lorem ipsum dolor sit amet",
e = "Foo ipsum amet_ sit"
, res = {
"original": o,
"edited": e,
"diff": o.split("").map(function(val, key) {
// log edits
// `+` preceding character: added character ,
// `-`: preceding character: removed character;
// `+` preceding "|": no changes ,
// `-`: preceding "": no changes;
// `"index"`: character `index` of original `o` input string
return e[key] !== val
? "[edits:" + "+" + (e[key] || "") + "|-" + val
+ ", index:" + key + "]" + (e[key] || "")
: "[edits:+|-, index:" + key + "]" + val
})
};
document.getElementsByTagName("pre")[0].textContent = JSON.stringify(res, null, 2);
<pre></pre>
Running a proper diff using just JavaScript can be potentially slow, but it depends on the performance requirements and the quality of the diff, and of course how often it must be run.
One quite efficient way would be to track the edits when the user is actually editing the document and only store those changes just after the moment they are done. For this you can use for example ACE editor, or any other editor that supports change tracking.
http://ace.c9.io/
ACE is tracking the changes while the document is edited. The ACE editor tracks the commands in easily comprehensible format like:
{"action":"insertText","range":{"start":{"row":0,"column":0},
"end":{"row":0,"column":1}},"text":"d"}
You can hook to the changes of the ACE editor and listen to the change events:
var changeList = []; // list of changes
// editor is here the ACE editor instance for example
var editor = ace.edit(document.getElementById("editorDivId"));
editor.setValue("original text contents");
editor.on("change", function(e) {
// e.data has the change
var cmd = e.data;
var range = cmd.range;
if(cmd.action=="insertText") {
changeList.push([
1,
range.start.row,
range.start.column,
range.end.row,
range.end.column,
cmd.text
])
}
if(cmd.action=="removeText") {
changeList.push([
2,
range.start.row,
range.start.column,
range.end.row,
range.end.column,
cmd.text
])
}
if(cmd.action=="insertLines") {
changeList.push([
3,
range.start.row,
range.start.column,
range.end.row,
range.end.column,
cmd.lines
])
}
if(cmd.action=="removeLines") {
changeList.push([
4,
range.start.row,
range.start.column,
range.end.row,
range.end.column,
cmd.lines,
cmd.nl
])
}
});
To learn how it works just create some test runs which capture the changes. Basicly there are only those for commands:
Removing the newline from the text can be a bit tricky.
When you have this list of changes you are ready to replay the changes against the text file. You can even merge similar or overlapping changes into a single change - for example inserts to subsequent characters could be merged into a single change.
There will be some problems when you are testing this, composing the string back to text is not trivial but quite doable and should not be more than around 100 lines of code or so.
The nice thing is that when you are finished, you have also undo and redo commands easily available, so you can replay the whole editing process.
This is a problem akin to code versioning and saving only the changes between versions.
Take a look at jsdiff
You can create a patch, save it, and apply it later to the original text to get the modified text.
Edit: Added modified script that can handle more than one text area.
Here is the JSFiddle for a page with more than one editable text area. (Don't forget to open dev tools to see the edits.) You just need to assign each textarea a unique id. Then, create a map using those id's as keys and each textarea's edits array as the values. Here is the updated script:
'use strict';
function Edit(type, position, text) {
this.type = type;
this.position = position;
this.text = text;
}
var ADD = 'add';
var DELETE = 'delete';
var textAreaEditsMap = {};
var cursorStart = -1;
var cursorEnd = -1;
var currentEdit = null;
var deleteOffset = 1;
window.addEventListener('load', function() {
var textareas = document.getElementsByClassName('text-editable');
for (var i = 0; i < textareas.length; ++i) {
var textarea = textareas.item(i);
var id = textarea.getAttribute('id');
textAreaEditsMap[id] = [];
textarea.addEventListener('mouseup', handleMouseUp);
textarea.addEventListener('keydown', handleKeyDown);
textarea.addEventListener('keypress', handleKeyPress);
}
});
function handleMouseUp(event) {
cursorStart = this.selectionStart;
cursorEnd = this.selectionEnd;
currentEdit = null;
}
function handleKeyDown(event) {
cursorStart = this.selectionStart;
cursorEnd = this.selectionEnd;
if (event.keyCode >= 35 && event.keyCode <= 40) { // detect cursor movement keys
currentEdit = null;
}
// deleting text
if (event.keyCode === 8 || event.keyCode === 46) {
if (currentEdit != null && currentEdit.type !== 'delete') {
currentEdit = null;
}
if (cursorStart !== cursorEnd) { // Deleting highlighted text
var edit = new Edit(DELETE, cursorStart, this.innerHTML.substring(cursorStart, cursorEnd));
textAreaEditsMap[this.getAttribute('id')].push(edit);
currentEdit = null;
} else if (event.keyCode === 8) { // backspace
if (currentEdit == null) {
deleteOffset = 1;
var edit = new Edit(DELETE, cursorStart, this.innerHTML[cursorStart - 1]);
textAreaEditsMap[this.getAttribute('id')].push(edit);
currentEdit = edit;
} else {
++deleteOffset;
currentEdit.text = this.innerHTML[cursorStart - 1] + currentEdit.text;
}
} else if (event.keyCode === 46) { // delete
if (currentEdit == null) {
deleteOffset = 1;
var edit = new Edit(DELETE, cursorStart, this.innerHTML[cursorStart]);
textAreaEditsMap[this.getAttribute('id')].push(edit);
currentEdit = edit;
} else {
currentEdit.text += this.innerHTML[cursorStart + deleteOffset++];
}
}
}
console.log(textAreaEditsMap)
}
function handleKeyPress(event) {
if (currentEdit != null && currentEdit.type !== 'add') {
currentEdit = null;
}
if (currentEdit == null) {
currentEdit = new Edit(ADD, cursorStart, String.fromCharCode(event.charCode));
textAreaEditsMap[this.getAttribute('id')].push(currentEdit);
} else {
currentEdit.text += String.fromCharCode(event.charCode);
}
console.log(textAreaEditsMap);
}
Original post with original script that only handles one textarea follows:
I made an example script that does what you need. I put a working example on JSFiddle. Make sure you press ctrl+shift+J on the JSFiddle example page to open dev tools so you can see the array of edits logged as edits are made. Edits are added to the edits array in chronological order, so you can revert back to the original text by applying the inverse (i.e., add deleted text back; remove added text) in reverse chronological order (i.e., iterate the array backwards). I did not handle copying, pasting, undoing, or redoing from the context menu or through key bindings, but I think that you should be able to use this example as a guide to take care of those things. Here is the script:
'use strict';
function Edit(type, position, text) {
this.type = type;
this.position = position;
this.text = text;
}
window.addEventListener('load', function() {
var ADD = 'add';
var DELETE = 'delete';
var cursorStart = -1;
var cursorEnd = -1;
var edits = [];
var currentEdit = null;
var deleteOffset = 1;
var textarea = document.getElementById('saved-text');
textarea.addEventListener('mouseup', function(event) {
cursorStart = this.selectionStart;
cursorEnd = this.selectionEnd;
currentEdit = null;
});
textarea.addEventListener('keydown', function(event) {
cursorStart = this.selectionStart;
cursorEnd = this.selectionEnd;
if(event.keyCode >= 35 && event.keyCode <= 40) { // detect cursor movement keys
currentEdit = null;
}
// deleting text
if(event.keyCode === 8 || event.keyCode === 46) {
if(currentEdit != null && currentEdit.type !== 'delete') {
currentEdit = null;
}
if(cursorStart !== cursorEnd) {
var edit = new Edit(DELETE, cursorStart, textarea.innerHTML.substring(cursorStart, cursorEnd));
edits.push(edit);
currentEdit = null;
} else if (event.keyCode === 8) { // backspace
if (currentEdit == null) {
deleteOffset = 1;
var edit = new Edit(DELETE, cursorStart, textarea.innerHTML[cursorStart - 1]);
edits.push(edit);
currentEdit = edit;
} else {
++deleteOffset;
currentEdit.text = textarea.innerHTML[cursorStart - 1] + currentEdit.text;
}
} else if (event.keyCode === 46) { // delete
if(currentEdit == null) {
deleteOffset = 1;
var edit = new Edit(DELETE, cursorStart, textarea.innerHTML[cursorStart]);
edits.push(edit);
currentEdit = edit;
} else {
currentEdit.text += textarea.innerHTML[cursorStart + deleteOffset++];
}
}
}
console.log(edits)
});
textarea.addEventListener('keypress', function(event) {
if(currentEdit != null && currentEdit.type !== 'add') {
currentEdit = null;
}
// adding text
if(currentEdit == null) {
currentEdit = new Edit(ADD, cursorStart, String.fromCharCode(event.charCode));
edits.push(currentEdit);
} else {
currentEdit.text += String.fromCharCode(event.charCode);
}
console.log(edits);
});
});