(function(root, factory) { if (typeof define === 'function' && define.amd) { define([], factory); } else if (typeof exports === 'object') { module.exports = factory(require()); } else { root.AceDiff = factory(root); } }(this, function() { 'use strict'; var Range = require('ace/range').Range; var C = { DIFF_EQUAL: 0, DIFF_DELETE: -1, DIFF_INSERT: 1, EDITOR_RIGHT: 'right', EDITOR_LEFT: 'left', RTL: 'rtl', LTR: 'ltr', SVG_NS: 'http://www.w3.org/2000/svg', DIFF_GRANULARITY_SPECIFIC: 'specific', DIFF_GRANULARITY_BROAD: 'broad' }; // our constructor function AceDiff(options) { this.options = {}; extend(true, this.options, { mode: null, theme: null, diffGranularity: C.DIFF_GRANULARITY_BROAD, lockScrolling: false, // not implemented yet showDiffs: true, showConnectors: true, maxDiffs: 5000, left: { id: 'acediff-left-editor', content: null, mode: null, theme: null, editable: true, copyLinkEnabled: true }, right: { id: 'acediff-right-editor', content: null, mode: null, theme: null, editable: true, copyLinkEnabled: true }, classes: { gutterID: 'acediff-gutter', diff: 'acediff-diff', connector: 'acediff-connector', newCodeConnectorLink: 'acediff-new-code-connector-copy', newCodeConnectorLinkContent: '→', deletedCodeConnectorLink: 'acediff-deleted-code-connector-copy', deletedCodeConnectorLinkContent: '←', copyRightContainer: 'acediff-copy-right', copyLeftContainer: 'acediff-copy-left' }, connectorYOffset: 0 }, options); // instantiate the editors in an internal data structure that will store a little info about the diffs and // editor content this.editors = { left: { ace: ace.edit(this.options.left.id), markers: [], lineLengths: [] }, right: { ace: ace.edit(this.options.right.id), markers: [], lineLengths: [] }, editorHeight: null }; addEventHandlers(this); this.lineHeight = this.editors.left.ace.renderer.lineHeight; // assumption: both editors have same line heights // set up the editors this.editors.left.ace.getSession().setMode(getMode(this, C.EDITOR_LEFT)); this.editors.right.ace.getSession().setMode(getMode(this, C.EDITOR_RIGHT)); this.editors.left.ace.setReadOnly(!this.options.left.editable); this.editors.right.ace.setReadOnly(!this.options.right.editable); this.editors.left.ace.setTheme(getTheme(this, C.EDITOR_LEFT)); this.editors.right.ace.setTheme(getTheme(this, C.EDITOR_RIGHT)); createCopyContainers(this); createGutter(this); // if the data is being supplied by an option, set the editor values now if (this.options.left.content) { this.editors.left.ace.setValue(this.options.left.content, -1); } if (this.options.right.content) { this.editors.right.ace.setValue(this.options.right.content, -1); } // store the visible height of the editors (assumed the same) this.editors.editorHeight = getEditorHeight(this); this.diff(); } // our public API AceDiff.prototype = { // allows on-the-fly changes to the AceDiff instance settings setOptions: function(options) { extend(true, this.options, options); this.diff(); }, getNumDiffs: function() { return this.diffs.length; }, // exposes the Ace editors in case the dev needs it getEditors: function() { return { left: this.editors.left.ace, right: this.editors.right.ace } }, // our main diffing function. I actually don't think this needs to exposed: it's called automatically, // but just to be safe, it's included diff: function() { var dmp = new diff_match_patch(); var val1 = this.editors.left.ace.getSession().getValue(); var val2 = this.editors.right.ace.getSession().getValue(); var diff = dmp.diff_main(val2, val1); dmp.diff_cleanupSemantic(diff); this.editors.left.lineLengths = getLineLengths(this.editors.left); this.editors.right.lineLengths = getLineLengths(this.editors.right); // parse the raw diff into something a little more palatable var diffs = []; var offset = { left: 0, right: 0 }; diff.forEach(function(chunk) { var chunkType = chunk[0]; var text = chunk[1]; // oddly, occasionally the algorithm returns a diff with no changes made if (text.length === 0) { return; } if (chunkType === C.DIFF_EQUAL) { offset.left += text.length; offset.right += text.length; } else if (chunkType === C.DIFF_DELETE) { diffs.push(computeDiff(this, C.DIFF_DELETE, offset.left, offset.right, text)); offset.right += text.length; } else if (chunkType === C.DIFF_INSERT) { diffs.push(computeDiff(this, C.DIFF_INSERT, offset.left, offset.right, text)); offset.left += text.length; } }, this); // simplify our computed diffs; this groups together multiple diffs on subsequent lines this.diffs = simplifyDiffs(this, diffs); // if we're dealing with too many diffs, fail silently if (this.diffs.length > this.options.maxDiffs) { return; } clearDiffs(this); decorate(this); }, destroy: function() { // destroy the two editors var leftValue = this.editors.left.ace.getValue(); this.editors.left.ace.destroy(); var oldDiv = this.editors.left.ace.container; var newDiv = oldDiv.cloneNode(false); newDiv.textContent = leftValue; oldDiv.parentNode.replaceChild(newDiv, oldDiv); var rightValue = this.editors.right.ace.getValue(); this.editors.right.ace.destroy(); oldDiv = this.editors.right.ace.container; newDiv = oldDiv.cloneNode(false); newDiv.textContent = rightValue; oldDiv.parentNode.replaceChild(newDiv, oldDiv); document.getElementById(this.options.classes.gutterID).innerHTML = ''; } }; function getMode(acediff, editor) { var mode = acediff.options.mode; if (editor === C.EDITOR_LEFT && acediff.options.left.mode !== null) { mode = acediff.options.left.mode; } if (editor === C.EDITOR_RIGHT && acediff.options.right.mode !== null) { mode = acediff.options.right.mode; } return mode; } function getTheme(acediff, editor) { var theme = acediff.options.theme; if (editor === C.EDITOR_LEFT && acediff.options.left.theme !== null) { theme = acediff.options.left.theme; } if (editor === C.EDITOR_RIGHT && acediff.options.right.theme !== null) { theme = acediff.options.right.theme; } return theme; } function addEventHandlers(acediff) { var leftLastScrollTime = new Date().getTime(), rightLastScrollTime = new Date().getTime(), now; acediff.editors.left.ace.getSession().on('changeScrollTop', function(scroll) { now = new Date().getTime(); if (rightLastScrollTime + 50 < now) { updateGap(acediff, 'left', scroll); } }); acediff.editors.right.ace.getSession().on('changeScrollTop', function(scroll) { now = new Date().getTime(); if (leftLastScrollTime + 50 < now) { updateGap(acediff, 'right', scroll); } }); var diff = acediff.diff.bind(acediff); acediff.editors.left.ace.on('change', diff); acediff.editors.right.ace.on('change', diff); if (acediff.options.left.copyLinkEnabled) { on('#' + acediff.options.classes.gutterID, 'click', '.' + acediff.options.classes.newCodeConnectorLink, function(e) { copy(acediff, e, C.LTR); }); } if (acediff.options.right.copyLinkEnabled) { on('#' + acediff.options.classes.gutterID, 'click', '.' + acediff.options.classes.deletedCodeConnectorLink, function(e) { copy(acediff, e, C.RTL); }); } var onResize = debounce(function() { acediff.editors.availableHeight = document.getElementById(acediff.options.left.id).offsetHeight; // TODO this should re-init gutter acediff.diff(); }, 250); window.addEventListener('resize', onResize); } function copy(acediff, e, dir) { var diffIndex = parseInt(e.target.getAttribute('data-diff-index'), 10); var diff = acediff.diffs[diffIndex]; var sourceEditor, targetEditor; var startLine, endLine, targetStartLine, targetEndLine; if (dir === C.LTR) { sourceEditor = acediff.editors.left; targetEditor = acediff.editors.right; startLine = diff.leftStartLine; endLine = diff.leftEndLine; targetStartLine = diff.rightStartLine; targetEndLine = diff.rightEndLine; } else { sourceEditor = acediff.editors.right; targetEditor = acediff.editors.left; startLine = diff.rightStartLine; endLine = diff.rightEndLine; targetStartLine = diff.leftStartLine; targetEndLine = diff.leftEndLine; } var contentToInsert = ''; for (var i=startLine; i startLine) ? 'lines' : 'targetOnly'); endLine--; // because endLine is always + 1 // to get Ace to highlight the full row we just set the start and end chars to 0 and 1 editor.markers.push(editor.ace.session.addMarker(new Range(startLine, 0, endLine, 1), classNames, 'fullLine')); } // called onscroll. Updates the gap to ensure the connectors are all lining up function updateGap(acediff, editor, scroll) { clearDiffs(acediff); decorate(acediff); // reposition the copy containers containing all the arrows positionCopyContainers(acediff); } function clearDiffs(acediff) { acediff.editors.left.markers.forEach(function(marker) { this.editors.left.ace.getSession().removeMarker(marker); }, acediff); acediff.editors.right.markers.forEach(function(marker) { this.editors.right.ace.getSession().removeMarker(marker); }, acediff); } function addConnector(acediff, leftStartLine, leftEndLine, rightStartLine, rightEndLine) { var leftScrollTop = acediff.editors.left.ace.getSession().getScrollTop(); var rightScrollTop = acediff.editors.right.ace.getSession().getScrollTop(); // All connectors, regardless of ltr or rtl have the same point system, even if p1 === p3 or p2 === p4 // p1 p2 // // p3 p4 acediff.connectorYOffset = 1; var p1_x = -1; var p1_y = (leftStartLine * acediff.lineHeight) - leftScrollTop; var p2_x = acediff.gutterWidth + 1; var p2_y = rightStartLine * acediff.lineHeight - rightScrollTop; var p3_x = -1; var p3_y = (leftEndLine * acediff.lineHeight) - leftScrollTop + acediff.connectorYOffset; var p4_x = acediff.gutterWidth + 1; var p4_y = (rightEndLine * acediff.lineHeight) - rightScrollTop + acediff.connectorYOffset; var curve1 = getCurve(p1_x, p1_y, p2_x, p2_y); var curve2 = getCurve(p4_x, p4_y, p3_x, p3_y); var verticalLine1 = 'L' + p2_x + ',' + p2_y + ' ' + p4_x + ',' + p4_y; var verticalLine2 = 'L' + p3_x + ',' + p3_y + ' ' + p1_x + ',' + p1_y; var d = curve1 + ' ' + verticalLine1 + ' ' + curve2 + ' ' + verticalLine2; var el = document.createElementNS(C.SVG_NS, 'path'); el.setAttribute('d', d); el.setAttribute('class', acediff.options.classes.connector); acediff.gutterSVG.appendChild(el); } function addCopyArrows(acediff, info, diffIndex) { if (info.leftEndLine > info.leftStartLine && acediff.options.left.copyLinkEnabled) { var arrow = createArrow({ className: acediff.options.classes.newCodeConnectorLink, topOffset: info.leftStartLine * acediff.lineHeight, tooltip: 'Copy to right', diffIndex: diffIndex, arrowContent: acediff.options.classes.newCodeConnectorLinkContent }); acediff.copyRightContainer.appendChild(arrow); } if (info.rightEndLine > info.rightStartLine && acediff.options.right.copyLinkEnabled) { var arrow = createArrow({ className: acediff.options.classes.deletedCodeConnectorLink, topOffset: info.rightStartLine * acediff.lineHeight, tooltip: 'Copy to left', diffIndex: diffIndex, arrowContent: acediff.options.classes.deletedCodeConnectorLinkContent }); acediff.copyLeftContainer.appendChild(arrow); } } function positionCopyContainers(acediff) { var leftTopOffset = acediff.editors.left.ace.getSession().getScrollTop(); var rightTopOffset = acediff.editors.right.ace.getSession().getScrollTop(); acediff.copyRightContainer.style.cssText = 'top: ' + (-leftTopOffset) + 'px'; acediff.copyLeftContainer.style.cssText = 'top: ' + (-rightTopOffset) + 'px'; } /** * This method takes the raw diffing info from the Google lib and returns a nice clean object of the following * form: * { * leftStartLine: * leftEndLine: * rightStartLine: * rightEndLine: * } * * Ultimately, that's all the info we need to highlight the appropriate lines in the left + right editor, add the * SVG connectors, and include the appropriate <<, >> arrows. * * Note: leftEndLine and rightEndLine are always the start of the NEXT line, so for a single line diff, there will * be 1 separating the startLine and endLine values. So if leftStartLine === leftEndLine or rightStartLine === * rightEndLine, it means that new content from the other editor is being inserted and a single 1px line will be * drawn. */ function computeDiff(acediff, diffType, offsetLeft, offsetRight, diffText) { var lineInfo = {}; // this was added in to hack around an oddity with the Google lib. Sometimes it would include a newline // as the first char for a diff, other times not - and it would change when you were typing on-the-fly. This // is used to level things out so the diffs don't appear to shift around var newContentStartsWithNewline = /^\n/.test(diffText); if (diffType === C.DIFF_INSERT) { // pretty confident this returns the right stuff for the left editor: start & end line & char var info = getSingleDiffInfo(acediff.editors.left, offsetLeft, diffText); // this is the ACTUAL undoctored current line in the other editor. It's always right. Doesn't mean it's // going to be used as the start line for the diff though. var currentLineOtherEditor = getLineForCharPosition(acediff.editors.right, offsetRight); var numCharsOnLineOtherEditor = getCharsOnLine(acediff.editors.right, currentLineOtherEditor); var numCharsOnLeftEditorStartLine = getCharsOnLine(acediff.editors.left, info.startLine); var numCharsOnLine = getCharsOnLine(acediff.editors.left, info.startLine); // this is necessary because if a new diff starts on the FIRST char of the left editor, the diff can comes // back from google as being on the last char of the previous line so we need to bump it up one var rightStartLine = currentLineOtherEditor; if (numCharsOnLine === 0 && newContentStartsWithNewline) { newContentStartsWithNewline = false; } if (info.startChar === 0 && isLastChar(acediff.editors.right, offsetRight, newContentStartsWithNewline)) { rightStartLine = currentLineOtherEditor + 1; } var sameLineInsert = info.startLine === info.endLine; // whether or not this diff is a plain INSERT into the other editor, or overwrites a line take a little work to // figure out. This feels like the hardest part of the entire script. var numRows = 0; if ( // dense, but this accommodates two scenarios: // 1. where a completely fresh new line is being inserted in left editor, we want the line on right to stay a 1px line // 2. where a new character is inserted at the start of a newline on the left but the line contains other stuff, // we DO want to make it a full line (info.startChar > 0 || (sameLineInsert && diffText.length < numCharsOnLeftEditorStartLine)) && // if the right editor line was empty, it's ALWAYS a single line insert [not an OR above?] numCharsOnLineOtherEditor > 0 && // if the text being inserted starts mid-line (info.startChar < numCharsOnLeftEditorStartLine)) { numRows++; } lineInfo = { leftStartLine: info.startLine, leftEndLine: info.endLine + 1, rightStartLine: rightStartLine, rightEndLine: rightStartLine + numRows }; } else { var info = getSingleDiffInfo(acediff.editors.right, offsetRight, diffText); var currentLineOtherEditor = getLineForCharPosition(acediff.editors.left, offsetLeft); var numCharsOnLineOtherEditor = getCharsOnLine(acediff.editors.left, currentLineOtherEditor); var numCharsOnRightEditorStartLine = getCharsOnLine(acediff.editors.right, info.startLine); var numCharsOnLine = getCharsOnLine(acediff.editors.right, info.startLine); // this is necessary because if a new diff starts on the FIRST char of the left editor, the diff can comes // back from google as being on the last char of the previous line so we need to bump it up one var leftStartLine = currentLineOtherEditor; if (numCharsOnLine === 0 && newContentStartsWithNewline) { newContentStartsWithNewline = false; } if (info.startChar === 0 && isLastChar(acediff.editors.left, offsetLeft, newContentStartsWithNewline)) { leftStartLine = currentLineOtherEditor + 1; } var sameLineInsert = info.startLine === info.endLine; var numRows = 0; if ( // dense, but this accommodates two scenarios: // 1. where a completely fresh new line is being inserted in left editor, we want the line on right to stay a 1px line // 2. where a new character is inserted at the start of a newline on the left but the line contains other stuff, // we DO want to make it a full line (info.startChar > 0 || (sameLineInsert && diffText.length < numCharsOnRightEditorStartLine)) && // if the right editor line was empty, it's ALWAYS a single line insert [not an OR above?] numCharsOnLineOtherEditor > 0 && // if the text being inserted starts mid-line (info.startChar < numCharsOnRightEditorStartLine)) { numRows++; } lineInfo = { leftStartLine: leftStartLine, leftEndLine: leftStartLine + numRows, rightStartLine: info.startLine, rightEndLine: info.endLine + 1 }; } return lineInfo; } // helper to return the startline, endline, startChar and endChar for a diff in a particular editor. Pretty // fussy function function getSingleDiffInfo(editor, offset, diffString) { var info = { startLine: 0, startChar: 0, endLine: 0, endChar: 0 }; var endCharNum = offset + diffString.length; var runningTotal = 0; var startLineSet = false, endLineSet = false; editor.lineLengths.forEach(function(lineLength, lineIndex) { runningTotal += lineLength; if (!startLineSet && offset < runningTotal) { info.startLine = lineIndex; info.startChar = offset - runningTotal + lineLength; startLineSet = true; } if (!endLineSet && endCharNum <= runningTotal) { info.endLine = lineIndex; info.endChar = endCharNum - runningTotal + lineLength; endLineSet = true; } }); // if the start char is the final char on the line, it's a newline & we ignore it if (info.startChar > 0 && getCharsOnLine(editor, info.startLine) === info.startChar) { info.startLine++; info.startChar = 0; } // if the end char is the first char on the line, we don't want to highlight that extra line if (info.endChar === 0) { info.endLine--; } var endsWithNewline = /\n$/.test(diffString); if (info.startChar > 0 && endsWithNewline) { info.endLine++; } return info; } // note that this and everything else in this script uses 0-indexed row numbers function getCharsOnLine(editor, line) { return getLine(editor, line).length; } function getLine(editor, line) { return editor.ace.getSession().doc.getLine(line); } function getLineForCharPosition(editor, offsetChars) { var lines = editor.ace.getSession().doc.getAllLines(), foundLine = 0, runningTotal = 0; for (var i=0; i line 1, line 2 => line 2, line 3-4 => line 3. That could be * reduced to a single connector line 1=4 => line 1-3 */ function simplifyDiffs(acediff, diffs) { var groupedDiffs = []; function compare(val) { return (acediff.options.diffGranularity === C.DIFF_GRANULARITY_SPECIFIC) ? val < 1 : val <= 1; } diffs.forEach(function(diff, index) { if (index === 0) { groupedDiffs.push(diff); return; } // loop through all grouped diffs. If this new diff lies between an existing one, we'll just add to it, rather // than create a new one var isGrouped = false; for (var i=0; i