kopia lustrzana https://github.com/c9/core
792 wiersze
32 KiB
JavaScript
792 wiersze
32 KiB
JavaScript
/*
|
|
* Copyright (c) 2013 Adobe Systems Incorporated. All rights reserved.
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person obtaining a
|
|
* copy of this software and associated documentation files (the "Software"),
|
|
* to deal in the Software without restriction, including without limitation
|
|
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
|
* and/or sell copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be included in
|
|
* all copies or substantial portions of the Software.
|
|
*
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
* DEALINGS IN THE SOFTWARE.
|
|
*
|
|
*/
|
|
|
|
|
|
/*unittests: HTML Instrumentation*/
|
|
|
|
/**
|
|
* HTMLInstrumentation
|
|
*
|
|
* This module contains functions for "instrumenting" html code so that we can track
|
|
* the relationship of source code to DOM nodes in the browser. This functionality is
|
|
* used by both live highlighting and live HTML editing.
|
|
*
|
|
* During live HTML development, the HTML source code is parsed to identify tag boundaries.
|
|
* Each tag is assigned an ID which is stored in markers that are inserted into the session.
|
|
* These IDs are also included in "data-cloud9-id" attributes that are inserted in the
|
|
* HTML code that's served to the browser via the Live Development server.
|
|
*
|
|
* The primary function for that functionality is generateInstrumentedHTML(). This does just
|
|
* what it says - it will read the HTML content in the doc and generate instrumented code by
|
|
* injecting "data-cloud9-id" attributes. Additionally, it caches the parsed DOM for use
|
|
* by future updates.
|
|
*
|
|
* As the user makes edits in the session, we determine how the DOM structure should change
|
|
* based on the edits to the source code; those edits are generated by getUnappliedEditList().
|
|
* HTMLDocument (in LiveDevelopment) takes those edits and sends them to the browser (via
|
|
* RemoteFunctions) so that the DOM structure in the live preview can be updated accordingly.
|
|
*
|
|
* There are also helper functions for returning the tagID associated with a specified
|
|
* position in the document--this is used in live highlighting.
|
|
*/
|
|
define(function (require, exports, module) {
|
|
"use strict";
|
|
|
|
var extend = require("ace/lib/oop").mixin;
|
|
var Range = require("ace/range").Range;
|
|
var comparePoints = Range.comparePoints;
|
|
var HTMLSimpleDOM = require("./HTMLSimpleDOM");
|
|
var HTMLDOMDiff = require("./HTMLDOMDiff");
|
|
|
|
/**
|
|
* @private
|
|
* Checks if two CodeMirror-style {row, column} positions are equal.
|
|
* @param {{row: number, column: number}} pos1
|
|
* @param {{row: number, column: number}} pos2
|
|
* @return {boolean} true if pos1 and pos2 are equal. Fails if either of them is falsy.
|
|
*/
|
|
function _posEq(pos1, pos2) {
|
|
return pos1 && pos2 && pos1.row === pos2.row && pos1.column === pos2.column;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* Filters the given marks to find the ones that correspond to instrumented tags,
|
|
* sorts them by their starting position, and looks up and/or stores their ranges
|
|
* in the given markCache.
|
|
* @param {Array} marks An array of mark objects returned by CodeMirror.
|
|
* @param {Object} markCache An object that maps tag IDs to {mark, range} objects.
|
|
* If a mark in the marks array is already in the cache, we use the cached range info,
|
|
* otherwise we look up its range in CodeMirror and store it in the cache.
|
|
* @return {Array.<{mark: Object, range: {row: number, column: number}}>} The filtered and
|
|
* sorted array of mark info objects (each of which contains the mark and its range,
|
|
* so the range doesn't need to be looked up again).
|
|
*/
|
|
function _getSortedTagMarks(marks, markCache) {
|
|
throw "not implemented";
|
|
// marks = marks.filter(function (mark) {
|
|
// return !!mark.tagID;
|
|
// }).map(function (mark) {
|
|
// // All marks should exist since we just got them from CodeMirror.
|
|
// if (!markCache[mark.tagID]) {
|
|
// markCache[mark.tagID] = {mark: mark, range: mark.find()};
|
|
// }
|
|
// return markCache[mark.tagID];
|
|
// });
|
|
// marks.sort(function (mark1, mark2) {
|
|
// return (mark1.range.from.row === mark2.range.from.row ?
|
|
// mark1.range.from.column - mark2.range.from.column :
|
|
// mark1.range.from.row - mark2.range.from.row);
|
|
// });
|
|
|
|
// return marks;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* Finds the mark for the DOM node at the given position in the session.
|
|
* @param {session} session The session containing the instrumented document.
|
|
* @param {{row: number, column: number}} pos The position to find the DOM marker for.
|
|
* @param {boolean} preferParent If true, and the pos is at one or the other edge of the
|
|
* innermost marked range, return the immediately enclosing mark instead.
|
|
* @param {Object=} markCache An optional cache to look up positions of existing
|
|
* markers. (This avoids calling the find() operation on marks multiple times,
|
|
* which is expensive.)
|
|
* @return {Object} The CodeMirror mark object that represents the DOM node at the
|
|
* given position.
|
|
*/
|
|
function _getNodeAtDocumentPos(session, pos, preferParent, markCache) {
|
|
return findNode(session.dom, pos, preferParent);
|
|
}
|
|
|
|
function findNode(node, pos, preferParent) {
|
|
var children = node && node.children || [];
|
|
for (var i = 0; i < children.length; i++) {
|
|
var ch = node.children[i];
|
|
if (!ch.children) continue;
|
|
var cmp = comparePoints(pos, ch.endPos);
|
|
if (cmp < 0) {
|
|
if (ch && comparePoints(pos, ch.startPos) > 0)
|
|
return findNode(ch, pos, preferParent);
|
|
break;
|
|
}
|
|
if (preferParent && cmp === 0)
|
|
return node;
|
|
}
|
|
return node;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* Dumps the current list of mark ranges for instrumented tags to the console. Used for debugging.
|
|
* @param {session} session The session to find the mark ranges for.
|
|
* @param {Object=} nodeMap If specified, a map of tag IDs to DOM nodes, used so we can indicate which tag name
|
|
* the DOM thinks corresponds to the given mark.
|
|
*/
|
|
function _dumpMarks(session, nodeMap) {
|
|
var markCache = {},
|
|
marks = _getSortedTagMarks(session._codeMirror.getAllMarks(), markCache);
|
|
marks.forEach(function (markInfo) {
|
|
var mark = markInfo.mark,
|
|
range = markInfo.range;
|
|
console.log("<" + nodeMap[mark.tagID].tag + "> (" + mark.tagID + ") " +
|
|
range.from.row + ":" + range.from.column + " - " + range.to.row + ":" + range.to.ch);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get the instrumented tagID at the specified position. Returns -1 if
|
|
* there are no instrumented tags at the location.
|
|
* The _markText() function must be called before calling this function.
|
|
*
|
|
* NOTE: This function is "private" for now (has a leading underscore), since
|
|
* the API is likely to change in the future.
|
|
*
|
|
* @param {session} session The session to scan.
|
|
* @return {number} tagID at the specified position, or -1 if there is no tag
|
|
*/
|
|
function _getTagIDAtDocumentPos(session, pos, markCache) {
|
|
var match = _getNodeAtDocumentPos(session, pos, false, markCache);
|
|
|
|
return (match) ? match.tagID : -1;
|
|
}
|
|
|
|
/**
|
|
* @constructor
|
|
* Subclass of HTMLSimpleDOM.Builder that builds an updated DOM after changes have been made,
|
|
* and maps nodes from the new DOM to the old DOM by tag ID. For non-structural edits, avoids reparsing
|
|
* the whole session. Also updates marks in the session based on the new DOM state.
|
|
*
|
|
* @param {Object} previousDOM The root of the HTMLSimpleDOM tree representing a previous state of the DOM.
|
|
* @param {session} session The session containing the instrumented HTML.
|
|
* @param {Array=} changeList An optional list of CodeMirror change records representing the
|
|
* edits the user made in the session since previousDOM was built. If provided, and the
|
|
* edits are not structural, DOMUpdater will do a fast incremental reparse. If not provided,
|
|
* or if one of the edits changes the DOM structure, DOMUpdater will reparse the whole DOM.
|
|
*/
|
|
function DOMUpdater(previousDOM, session, delta) {
|
|
var text, startOffset = 0, startOffsetPos;
|
|
|
|
this.isIncremental = false;
|
|
|
|
if (delta) {
|
|
text = delta.text;
|
|
if (typeof text != "string")
|
|
text = delta.lines.join("\n");
|
|
if (previousDOM)
|
|
updatePositions(previousDOM, delta);
|
|
}
|
|
|
|
// If the inserted or removed text doesn't have any characters that could change the
|
|
// structure of the DOM (e.g. by adding or removing a tag boundary), then we can do
|
|
// an incremental reparse of just the parent tag containing the edit. This should just
|
|
// be the marked range that contains the beginning of the edit range, since that position
|
|
// isn't changed by the edit.
|
|
if (text && !isDangerousEdit(text)) {
|
|
// If the edit is right at the beginning or end of a tag, we want to be conservative
|
|
// and use the parent as the edit range.
|
|
var startNode = _getNodeAtDocumentPos(session, delta.start, true);
|
|
if (startNode) {
|
|
var range = Range.fromPoints(startNode.startPos, startNode.endPos);
|
|
if (range) {
|
|
text = session.getTextRange(range);
|
|
this.changedTagID = startNode.tagID;
|
|
startOffsetPos = startNode.startPos;
|
|
startOffset = session.doc.positionToIndex(startOffsetPos);
|
|
this.isIncremental = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
if (!this.changedTagID) {
|
|
// We weren't able to incrementally update, so just rebuild and diff everything.
|
|
text = session.getValue();
|
|
}
|
|
|
|
HTMLSimpleDOM.Builder.call(this, text, startOffset, startOffsetPos);
|
|
this.session = session;
|
|
this.cm = session._codeMirror;
|
|
this.previousDOM = previousDOM;
|
|
}
|
|
|
|
function isDangerousEdit(text) {
|
|
// We don't consider & dangerous since entities only affect text content, not
|
|
// overall DOM structure.
|
|
return (/[<>\/=\"\']/).test(text);
|
|
}
|
|
|
|
DOMUpdater.prototype = Object.create(HTMLSimpleDOM.Builder.prototype);
|
|
|
|
/**
|
|
* @private
|
|
* Returns true if the given node has an ancestor whose tagID is the given ID.
|
|
* @param {Object} node A node from an HTMLSimpleDOM structure.
|
|
* @param {number} id The ID of the tag to check for.
|
|
* @return {boolean} true if the node has an ancestor with that ID.
|
|
*/
|
|
function _hasAncestorWithID(node, id) {
|
|
var ancestor = node.parent;
|
|
while (ancestor && ancestor.tagID !== id) {
|
|
ancestor = ancestor.parent;
|
|
}
|
|
return !!ancestor;
|
|
}
|
|
|
|
/**
|
|
* Overrides the `getID` method to return the tag ID from the document. If a viable tag
|
|
* ID cannot be found in the document marks, then a new ID is returned. This will also
|
|
* assign a new ID if the tag changed between the previous and current versions of this
|
|
* node.
|
|
*
|
|
* @param {Object} newTag tag object for the current element
|
|
* @return {int} best ID
|
|
*/
|
|
DOMUpdater.prototype.getID = function (newTag, markCache) {
|
|
// Get the mark at the start of the tagname (not before the beginning of the tag, because that's
|
|
// actually inside the parent).
|
|
var currentTagID = _getTagIDAtDocumentPos(this.session, HTMLSimpleDOM._offsetPos(newTag.startPos, 1), markCache);
|
|
|
|
// If the new tag is in an unmarked range, or the marked range actually corresponds to an
|
|
// ancestor tag, then this must be a newly inserted tag, so give it a new tag ID.
|
|
if (currentTagID === -1 || _hasAncestorWithID(newTag, currentTagID)) {
|
|
currentTagID = this.getNewID();
|
|
} else {
|
|
// If the tag has changed between the previous DOM and the new one, we assign a new ID
|
|
// so that the old tag will be deleted and the new one inserted.
|
|
var oldNode = this.previousDOM.nodeMap[currentTagID];
|
|
if (!oldNode || oldNode.tag !== newTag.tag) {
|
|
currentTagID = this.getNewID();
|
|
}
|
|
}
|
|
return currentTagID;
|
|
};
|
|
|
|
/**
|
|
* Updates the CodeMirror marks in the session to reflect the new bounds of nodes in
|
|
* the given nodeMap.
|
|
* @param {Object} nodeMap The node map from the new DOM.
|
|
* @param {Object} markCache The cache of existing mark ranges built during the latest parse.
|
|
*/
|
|
DOMUpdater.prototype._updateMarkedRanges = function (nodeMap, markCache) {
|
|
// FUTURE: this is somewhat inefficient (getting all the marks involves passing linearly through
|
|
// the document once), but it doesn't seem to be a hotspot right now.
|
|
var updateIDs = Object.keys(nodeMap),
|
|
cm = this.cm,
|
|
marks = cm.getAllMarks();
|
|
|
|
cm.operation(function () {
|
|
marks.forEach(function (mark) {
|
|
if (mark.hasOwnProperty("tagID") && nodeMap[mark.tagID]) {
|
|
var node = nodeMap[mark.tagID],
|
|
markInfo = markCache[mark.tagID];
|
|
// If the mark's bounds already match, avoid destroying and recreating the mark,
|
|
// since that incurs some overhead.
|
|
if (!(markInfo && _posEq(markInfo.range.from, node.startPos) && _posEq(markInfo.range.to, node.endPos))) {
|
|
mark.clear();
|
|
mark = cm.markText(node.startPos, node.endPos);
|
|
mark.tagID = node.tagID;
|
|
}
|
|
updateIDs.splice(updateIDs.indexOf(String(node.tagID)), 1);
|
|
}
|
|
});
|
|
|
|
// Any remaining updateIDs are new.
|
|
updateIDs.forEach(function (id) {
|
|
var node = nodeMap[id], mark;
|
|
if (node.isElement()) {
|
|
mark = cm.markText(node.startPos, node.endPos);
|
|
mark.tagID = Number(id);
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
/**
|
|
* @private
|
|
* Creates a map from tagIDs to nodes in the given HTMLSimpleDOM subtree and
|
|
* stores it on the root.
|
|
* @param {Object} root The root of an HTMLSimpleDOM tree.
|
|
*/
|
|
DOMUpdater.prototype._buildNodeMap = function (root) {
|
|
var nodeMap = {};
|
|
|
|
function walk(node) {
|
|
if (node.tagID) {
|
|
nodeMap[node.tagID] = node;
|
|
}
|
|
if (node.isElement()) {
|
|
node.children.forEach(walk);
|
|
}
|
|
}
|
|
|
|
walk(root);
|
|
root.nodeMap = nodeMap;
|
|
};
|
|
|
|
/**
|
|
* @private
|
|
* Removes all nodes deleted between the oldSubtree and the newSubtree from the given nodeMap,
|
|
* and clears marks associated with those nodes.
|
|
* @param {Object} nodeMap The nodeMap to update to remove deleted items.
|
|
* @param {Object} oldSubtreeMap The nodeMap for the original subtree (which should be a subset of the
|
|
* first nodeMap).
|
|
* @param {Object} newSubtreeMap The nodeMap for the new subtree.
|
|
*/
|
|
DOMUpdater.prototype._handleDeletions = function (nodeMap, oldSubtreeMap, newSubtreeMap) {
|
|
var deletedIDs = [];
|
|
Object.keys(oldSubtreeMap).forEach(function (key) {
|
|
if (!newSubtreeMap.hasOwnProperty(key)) {
|
|
deletedIDs.push(key);
|
|
delete nodeMap[key];
|
|
}
|
|
});
|
|
|
|
// if (deletedIDs.length) {
|
|
// // FUTURE: would be better to cache the mark for each node. Also, could
|
|
// // conceivably combine this with _updateMarkedRanges().
|
|
// var marks = this.cm.getAllMarks();
|
|
// marks.forEach(function (mark) {
|
|
// if (mark.hasOwnProperty("tagID") && deletedIDs.indexOf(mark.tagID) !== -1) {
|
|
// mark.clear();
|
|
// }
|
|
// });
|
|
// }
|
|
};
|
|
|
|
/**
|
|
* Reparses the document (or a portion of it if we can do it incrementally).
|
|
* Note that in an incremental update, the old DOM is actually mutated (the new
|
|
* subtree is swapped in for the old subtree).
|
|
* @return {?{newDOM: Object, oldSubtree: Object, newSubtree: Object}} newDOM is
|
|
* the full new DOM. For a full update, oldSubtree is the full old DOM
|
|
* and newSubtree is the same as newDOM; for an incremental update,
|
|
* oldSubtree is the portion of the old tree that was reparsed,
|
|
* newSubtree is the updated version, and newDOM is actually the same
|
|
* as the original DOM (with newSubtree swapped in for oldSubtree).
|
|
* If the document can't be parsed due to invalid HTML, returns null.
|
|
*/
|
|
DOMUpdater.prototype.update = function () {
|
|
var markCache = {};
|
|
var newSubtree = this.build(!true, markCache);
|
|
var result = {
|
|
// default result if we didn't identify a changed portion
|
|
newDOM: newSubtree,
|
|
oldSubtree: this.previousDOM,
|
|
newSubtree: newSubtree
|
|
};
|
|
|
|
if (!newSubtree) {
|
|
return null;
|
|
}
|
|
|
|
if (this.changedTagID) {
|
|
// Find the old subtree that's going to get swapped out.
|
|
var oldSubtree = this.previousDOM.nodeMap[this.changedTagID],
|
|
parent = oldSubtree.parent;
|
|
|
|
// If we didn't have a parent, then the whole tree changed anyway, so
|
|
// we'll just return the default result.
|
|
if (parent) {
|
|
var childIndex = parent.children.indexOf(oldSubtree);
|
|
if (childIndex === -1) {
|
|
// This should never happen...
|
|
console.error("DOMUpdater.update(): couldn't locate old subtree in tree");
|
|
} else {
|
|
// Swap the new subtree in place of the old subtree.
|
|
oldSubtree.parent = null;
|
|
newSubtree.parent = parent;
|
|
parent.children[childIndex] = newSubtree;
|
|
|
|
// Overwrite any node mappings in the parent DOM with the
|
|
// mappings for the new subtree. We keep the nodeMap around
|
|
// on the new subtree so that the differ can use it later.
|
|
extend(this.previousDOM.nodeMap, newSubtree.nodeMap);
|
|
|
|
// Update marked ranges for all items in the new subtree.
|
|
// this._updateMarkedRanges(newSubtree.nodeMap, markCache);
|
|
|
|
// Build a local nodeMap for the old subtree so the differ can
|
|
// use it.
|
|
this._buildNodeMap(oldSubtree);
|
|
|
|
// Clean up the info for any deleted nodes that are no longer in
|
|
// the new tree.
|
|
this._handleDeletions(this.previousDOM.nodeMap, oldSubtree.nodeMap, newSubtree.nodeMap);
|
|
|
|
// Update the signatures for all parents of the new subtree.
|
|
var curParent = parent;
|
|
while (curParent) {
|
|
curParent.update();
|
|
curParent = curParent.parent;
|
|
}
|
|
|
|
result.newDOM = this.previousDOM;
|
|
result.oldSubtree = oldSubtree;
|
|
}
|
|
}
|
|
} else {
|
|
this.session.dom = result.newDOM;
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
/**
|
|
* @private
|
|
* Builds a new DOM for the current state of the session, diffs it against the
|
|
* previous DOM, and generates a DOM edit list that can be used to replay the
|
|
* diffs in the browser.
|
|
* @param {Object} previousDOM The HTMLSimpleDOM corresponding to the previous state of the session.
|
|
* Note that in the case of an incremental edit, this will be mutated to create the new DOM
|
|
* (by swapping out the subtree corresponding to the changed portion).
|
|
* @param {session} session The session containing the instrumented HTML.
|
|
* @param {Array=} changeList If specified, a CodeMirror changelist reflecting all the
|
|
* text changes in the session since previousDOM was built. If specified, we will
|
|
* attempt to do an incremental update (although we might fall back to a full update
|
|
* in various cases). If not specified, we will always do a full update.
|
|
* @return {{dom: Object, edits: Array}} The new DOM representing the current state of the
|
|
* session, and an array of edits that can be applied to update the browser (see
|
|
* HTMLDOMDiff for more information on the edit format).
|
|
*/
|
|
function _updateDOM(previousDOM, session, delta) {
|
|
var updater = new DOMUpdater(previousDOM, session, delta);
|
|
var result = updater.update();
|
|
if (!result) {
|
|
return { errors: updater.errors };
|
|
}
|
|
|
|
var edits = HTMLDOMDiff.domdiff(result.oldSubtree, result.newSubtree);
|
|
|
|
// We're done with the nodeMap that was added to the subtree by the updater.
|
|
if (result.newSubtree !== result.newDOM) {
|
|
delete result.newSubtree.nodeMap;
|
|
}
|
|
|
|
return {
|
|
errors: updater.errors,
|
|
dom: result.newDOM,
|
|
edits: edits,
|
|
_wasIncremental: updater.isIncremental // for unit tests only
|
|
};
|
|
}
|
|
|
|
function updatePositions(dom, delta) {
|
|
var start = delta.start;
|
|
var end = delta.end;
|
|
var sign = delta.action[0] == "i" ? 1 : -1;
|
|
var rowChange = sign * (end.row - start.row);
|
|
var columnChange = sign * (end.column - start.column);
|
|
|
|
function update(pos) {
|
|
if (pos.row == (sign == -1 ? end.row : start.row))
|
|
pos.column += columnChange;
|
|
if (rowChange)
|
|
pos.row += rowChange;
|
|
}
|
|
function walk(node) {
|
|
if (!node.children)
|
|
return;
|
|
var sp = node.startPos;
|
|
var ep = node.endPos;
|
|
|
|
if (sign === 1) {
|
|
var cmpStartPos = comparePoints(start, sp);
|
|
if (cmpStartPos < 0) {
|
|
update(sp);
|
|
update(ep);
|
|
if (!rowChange && sp.row > start.row)
|
|
return;
|
|
} else {
|
|
var cmpEndPos = comparePoints(start, ep);
|
|
if (cmpEndPos < 0) {
|
|
update(ep);
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
} else {
|
|
var cmp = comparePoints(end, sp);
|
|
if (cmp <= 0) {
|
|
update(sp);
|
|
update(ep);
|
|
if (!rowChange && sp.row > end.row)
|
|
return;
|
|
} else {
|
|
cmp = comparePoints(start, ep);
|
|
if (cmp <= 0) {
|
|
update(ep);
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
return node.children.some(walk);
|
|
}
|
|
walk(dom);
|
|
}
|
|
|
|
/**
|
|
* Calculates the DOM edits that are needed to update the browser from the state the
|
|
* session was in the last time that scanDocument(), getInstrumentedHTML(), or
|
|
* getUnappliedEditList() was called (whichever is most recent). Caches this state so
|
|
* it can be used as the base state for the next getUnappliedEditList().
|
|
*
|
|
* For simple text edits, this update is done quickly and incrementally. For structural
|
|
* edits (edits that change the DOM structure or add/remove attributes), the update
|
|
* requires a full reparse.
|
|
*
|
|
* If the document currently contains invalid HTML, no edits will be generated until
|
|
* getUnappliedEditList() is called when the document is valid, at which point the edits
|
|
* will reflect all the changes needed to catch the browser up with all the edits
|
|
* made while the document was invalid.
|
|
*
|
|
* @param {session} session The session containing the instrumented HTML
|
|
* @param {Array} changeList A CodeMirror change list describing the text changes made
|
|
* in the session since the last update. If specified, we will attempt to do an
|
|
* incremental update.
|
|
* @return {Array} edits A list of edits to apply in the browser. See HTMLDOMDiff for
|
|
* more information on the format of these edits.
|
|
*/
|
|
function getUnappliedEditList(session, delta) {
|
|
var dom = session.dom;
|
|
var result = _updateDOM(dom, session, delta);
|
|
|
|
if (dom && result.errors) {
|
|
dom.errors = result.errors;
|
|
}
|
|
if (result.dom) {
|
|
session.dom = result.dom;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* Add SimpleDOMBuilder metadata to browser DOM tree JSON representation
|
|
* @param {Object} root
|
|
*/
|
|
function _processBrowserSimpleDOM(browserRoot, sessionRootTagID) {
|
|
var nodeMap = {},
|
|
root;
|
|
|
|
function _processElement(elem) {
|
|
elem.tagID = elem.attributes["data-cloud9-id"];
|
|
|
|
// remove data-cloud9-id attribute for diff
|
|
delete elem.attributes["data-cloud9-id"];
|
|
|
|
elem.children.forEach(function (child) {
|
|
// set parent
|
|
child.parent = elem;
|
|
|
|
if (child.isElement()) {
|
|
_processElement(child);
|
|
} else if (child.isText()) {
|
|
child.update();
|
|
child.tagID = HTMLSimpleDOM.getTextNodeID(child);
|
|
|
|
nodeMap[child.tagID] = child;
|
|
}
|
|
});
|
|
|
|
elem.update();
|
|
|
|
nodeMap[elem.tagID] = elem;
|
|
|
|
// Choose the root element based on the root tag in the session.
|
|
// The browser may insert html, head and body elements if missing.
|
|
if (elem.tagID === sessionRootTagID) {
|
|
root = elem;
|
|
}
|
|
}
|
|
|
|
_processElement(browserRoot);
|
|
|
|
root = root || browserRoot;
|
|
root.nodeMap = nodeMap;
|
|
|
|
return root;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* Diff the browser DOM with the in-session DOM
|
|
* @param {session} session
|
|
* @param {Object} browserSimpleDOM
|
|
*/
|
|
function _getBrowserDiff(session, browserSimpleDOM) {
|
|
var sessionRoot = session.dom,
|
|
browserRoot;
|
|
|
|
browserRoot = _processBrowserSimpleDOM(browserSimpleDOM, sessionRoot.tagID);
|
|
|
|
return {
|
|
diff: HTMLDOMDiff.domdiff(sessionRoot, browserRoot),
|
|
browser: browserRoot,
|
|
session: sessionRoot
|
|
};
|
|
}
|
|
|
|
// @todo
|
|
// $(DocumentManager).on("beforeDocumentDelete", _removeDocFromCache);
|
|
|
|
/**
|
|
* Parses the document, returning an HTMLSimpleDOM structure and caching it as the
|
|
* initial state of the document. Will return a cached copy of the DOM if the
|
|
* document hasn't changed since the last time scanDocument was called.
|
|
*
|
|
* This is called by generateInstrumentedHTML(), but it can be useful to call it
|
|
* ahead of time so the DOM is cached and doesn't need to be rescanned when the
|
|
* instrumented HTML is requested by the browser.
|
|
*
|
|
* @param {Document} doc The doc to scan.
|
|
* @return {Object} Root DOM node of the document.
|
|
*/
|
|
function scanDocument(session) {
|
|
var value = session.getValue();
|
|
var savedValue = session.c9doc.meta.$savedValue || value;
|
|
if (!session.savedDom)
|
|
session.savedDom = HTMLSimpleDOM.build(savedValue);
|
|
|
|
var update;
|
|
if (savedValue != value) {
|
|
session.dom = session.savedDom;
|
|
update = _updateDOM(session.savedDom, session);
|
|
session.dom = update.dom;
|
|
update.dom = session.savedDom;
|
|
} else {
|
|
session.dom = session.savedDom;
|
|
update = { dom: session.savedDom };
|
|
}
|
|
|
|
return update;
|
|
}
|
|
|
|
function syncTagIds(session) {
|
|
var value = session.getValue();
|
|
var savedValue = session.c9doc.meta.$savedValue || value;
|
|
if (!session.savedDom)
|
|
session.savedDom = HTMLSimpleDOM.build(savedValue);
|
|
|
|
if (!session.savedDom)
|
|
return { errors: ["save"]};
|
|
|
|
var update = {};
|
|
if (savedValue != value && !session.dom) {
|
|
session.dom = session.savedDom;
|
|
|
|
getDeltaList(savedValue, value).forEach(function(delta) {
|
|
updatePositions(session.savedDom, delta);
|
|
});
|
|
update = _updateDOM(session.savedDom, session);
|
|
session.dom = update.dom;
|
|
} else if (session.dom) {
|
|
var edits = HTMLDOMDiff.domdiff(session.savedDom, session.dom);
|
|
update.edits = edits;
|
|
} else {
|
|
session.dom = session.savedDom;
|
|
}
|
|
|
|
var getNewID = HTMLSimpleDOM.Builder.newIdGenerator();
|
|
|
|
var idMap = {};
|
|
function walk(node) {
|
|
if (!node.children)
|
|
return;
|
|
var defaultId = getNewID(node);
|
|
if (node.tagID != defaultId) {
|
|
idMap[defaultId] = node.tagID;
|
|
}
|
|
node.children && node.children.forEach(walk);
|
|
}
|
|
|
|
walk(session.savedDom);
|
|
|
|
update.dom = null;
|
|
update.idMap = idMap;
|
|
|
|
return update;
|
|
}
|
|
|
|
function getDeltaList(v1, v2) {
|
|
var DiffMatchPatch = require("plugins/c9.ide.threewaymerge/diff_match_patch_amd").diff_match_patch;
|
|
var dfm = new DiffMatchPatch();
|
|
|
|
var row = 0, column = 0;
|
|
var deltas = [];
|
|
|
|
dfm.diff_main(v1, v2).forEach(function(change) {
|
|
var text = change[1];
|
|
var lines = text.split(/\r\n|\r|\n/);
|
|
var colCh = lines[lines.length - 1].length;
|
|
var rowCh = lines.length - 1;
|
|
var endRow = row + rowCh;
|
|
var endColumn = colCh;
|
|
if (!rowCh) {
|
|
endColumn = column + colCh;
|
|
}
|
|
|
|
if (change[0] === 0) {
|
|
row = endRow;
|
|
column = endColumn;
|
|
}
|
|
else if (change[0] == -1) {
|
|
deltas.push({
|
|
start: { row: row, column: column },
|
|
end: { row: endRow, column: endColumn },
|
|
lines: lines,
|
|
action: "remove"
|
|
});
|
|
}
|
|
else if (change[0] == 1) {
|
|
deltas.push({
|
|
start: { row: row, column: column },
|
|
end: { row: endRow, column: endColumn },
|
|
lines: lines,
|
|
action: "insert"
|
|
});
|
|
row = endRow;
|
|
column = endColumn;
|
|
}
|
|
});
|
|
|
|
return deltas;
|
|
}
|
|
|
|
|
|
// private methods
|
|
exports._getNodeAtDocumentPos = _getNodeAtDocumentPos;
|
|
exports._getTagIDAtDocumentPos = _getTagIDAtDocumentPos;
|
|
exports._updateDOM = _updateDOM;
|
|
exports._getBrowserDiff = _getBrowserDiff;
|
|
|
|
// public API
|
|
exports.syncTagIds = syncTagIds;
|
|
exports.scanDocument = scanDocument;
|
|
exports.generateInstrumentedHTML = HTMLSimpleDOM.generateInstrumentedHTML;
|
|
exports.getUnappliedEditList = getUnappliedEditList;
|
|
|
|
});
|