// This file is part of the “jQuery.Syntax” project, and is distributed under the MIT License. // Copyright © 2011 Samuel G. D. Williams. <www.oriontransfer.co.nz> // See <jquery.syntax.js> for licensing details.
Syntax.Editor = function(container, text) {
this.container = container; this.current = this.getLines();
}
// This function generates an array of accumulated line offsets e.g. // If line 8 is actually in child element 6, indices = -2 Syntax.Editor.prototype.getLines = function() {
var children = this.container.childNodes, lines = [], offsets = []; // Sometimes, e.g. when deleting text, children elements are not complete lines. // We need to accumulate incomplete lines (1), and then append them to the // start of the next complete line (2) var text = "", startChild = 0; for (var i = 0; i < children.length; i += 1) { var childLines = Syntax.innerText([children[i]]).split('\n'); if (childLines.length > 1) { childLines[0] = text + childLines[0]; // (2) text = childLines.pop(); } else { text += childLines[0]; // (1) continue; } for (var j = 0; j < childLines.length; j += 1) { offsets.push(startChild - lines.length); lines.push(childLines[j]); } startChild = i + 1; } // Final line, any remaining text if (text != "") { offsets.push(startChild - lines.length); lines.push(text); } else { startChild -= 1; } offsets.push(startChild); console.log("getLines", offsets, lines, children); return {lines: lines, offsets: offsets};
}
// This function updates the editor's internal state with regards to lines changed. // This can be lines added, removed or modified partially. This function returns // a list of lines which are different between the previous set of lines and the // updated set of lines. // This algorithm is not a general diff algorithm because we expect three cases only: // 1: A single line was modified (most common case) // 2: Some lines were removed (selection -> delete) // 3: Some lines were added (paste) Syntax.Editor.prototype.updateChangedLines = function() {
var result = {}; var updated = this.getLines(); // Find the sequence of lines at the start preceeding the change: var i = 0, j = 0; while (i < this.current.lines.length && j < updated.lines.length) { if (this.current.lines[i] == updated.lines[j]) { i += 1; j += 1; } else { break; } } // The length of the initial segment which hasn't changed: result.start = j; // Find the sequence of lines at the end proceeding the change: i = this.current.lines.length, j = updated.lines.length; while (i > result.start && j > result.start) { if (this.current.lines[i-1] == updated.lines[j-1]) { i -= 1; j -= 1; } else { break; } } // The index of the remaining portion which hasn't changed: result.end = j; // The index to the original set of lines which were the same: result.originalEnd = i; // Did we add or remove some lines? result.difference = updated.lines.length - this.current.lines.length; // This should be augmented to improve the above. while (result.start > 0) { if (updated.offsets[result.start] == updated.offsets[result.start-1]) break; result.start -= 1; } if (result.difference > 0) { while (result.end < (updated.lines.length-1)) { if (updated.offsets[result.end-1] == updated.offsets[result.end]) break; result.end += 1; result.originalEnd += 1; } } // Update the internal state for the next update. this.current = updated; this.changed = result; return result;
}
Syntax.Editor.prototype.textForLines = function(start, end) {
return this.current.lines.slice(start, end).join('\n') + '\n';
}
Syntax.Editor.prototype.updateLines = function(changed, newLines) {
// We have two cases to handle, either we are replacing lines // (1a) Replacing old lines with one more more new lines (update) // (1b) Replacing old lines with zero new lines (removal) // Or we are inserting lines // (2a) We are inserting lines at the start of the element // (2b) We are inserting lines after an existing element. if (changed.start != changed.end) { // When text is deleted, at most two elements can remain: // (1) Whatever was partially remaining on the first line. // (2) Whatever was partially remaining on the last line. // All other lines have already been removed by the container. // changed.difference tells us how many elements have already been removed. // Cases (1a) and (1b) var start = changed.start, end = changed.end; start += this.current.offsets[start]; end += this.current.offsets[end]; var oldLines = Array.prototype.slice.call(this.container.childNodes, start, end); $(oldLines).replaceWith(newLines); } else { if (changed.start == 0) $(this.container).prepend(newLines); else { var start = changed.start; start += this.current.offsets[start]; $(this.container.childNodes[start]).after(newLines); } }
}
// jsfiddle.net/TjXEG/1/ Syntax.Editor.getCharacterOffset = function(element) {
var caretOffset = 0; if (typeof window.getSelection != "undefined") { var range = window.getSelection().getRangeAt(0); var preCaretRange = range.cloneRange(); preCaretRange.selectNodeContents(element); preCaretRange.setEnd(range.endContainer, range.endOffset); caretOffset = preCaretRange.toString().length; } else if (typeof document.selection != "undefined" && document.selection.type != "Control") { var textRange = document.selection.createRange(); var preCaretTextRange = document.body.createTextRange(); preCaretTextRange.moveToElementText(element); preCaretTextRange.setEndPoint("EndToEnd", textRange); caretOffset = preCaretTextRange.text.length; } return caretOffset;
};
Syntax.Editor.getNodesForCharacterOffsets = function(offsets, node) {
var treeWalker = document.createTreeWalker( node, NodeFilter.SHOW_TEXT, function(node) { return NodeFilter.FILTER_ACCEPT; }, false ); var nodes = [], charCount = 0, i = 0; while (i < offsets.length && treeWalker.nextNode()) { var end = charCount + treeWalker.currentNode.length; while (i < offsets.length && offsets[i] < end) { nodes.push([treeWalker.currentNode, charCount, end]); i += 1; } charCount = end; } return nodes;
};
Syntax.Editor.prototype.getClientState = function() {
var state = {}; var selection = window.getSelection(); if (selection.rangeCount > 0) state.range = selection.getRangeAt(0); if (state.range) { state.startOffset = Syntax.Editor.getCharacterOffset(this.container); } return state;
};
Syntax.Editor.prototype.setClientState = function(state) {
if (state.startOffset) { var nodes = Syntax.Editor.getNodesForCharacterOffsets([state.startOffset], this.container); var range = document.createRange(); range.setStart(nodes[0][0], state.startOffset - nodes[0][1]); range.setEnd(nodes[0][0], state.startOffset - nodes[0][1]); var selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); }
};
Syntax.layouts.editor = function(options, code/*, container*/) {
var container = jQuery('<div class="editor syntax highlighted" contentEditable="true">'); container.append(code.children()); var editor = new Syntax.Editor(container.get(0)); var updateContainer = function(lineHint) { // Need to save cursor position/selection var clientState = editor.getClientState(); var changed = editor.updateChangedLines(); // Sometimes there are problems where multiple spans exist on the same line. if (changed.difference < 0 && changed.start > 0) changed.start -= 1; var text = editor.textForLines(changed.start, changed.end); if (changed.start == changed.end) { editor.updateLines(changed, []); } else { // Lines have been added, update the highlighting. Syntax.highlightText(text, options, function(html) { editor.updateLines(changed, html.children().get()); // Restore cusor position/selection if possible editor.setClientState(clientState); }); } }; // 'blur keyup paste mouseup' container.bind('keyup', function(){ updateContainer(); }); container.bind('paste', function(event){ updateContainer(); }); container.bind('keydown', function(event){ if (event.keyCode == 9) { event.preventDefault(); document.execCommand('insertHTML', false, " "); } else if (event.keyCode == 13) { event.preventDefault(); document.execCommand('insertHTML', false, "\n"); } }); return jQuery('<div class="syntax-container">').append(container);
};