src/LiveDevelopment/Documents/HTMLDocument.js
/*
* Copyright (c) 2012 - present 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.
*
*/
/**
* HTMLDocument manages a single HTML source document
*
* __EDITING__
*
* Editing the document will cause the corresponding node to be updated
* by calling `applyChanges` on the DOMAgent. This will only work for
* altering text nodes and will break when attempting to change DOM elements
* or inserting or deleting nodes.
*
* __HIGHLIGHTING__
*
* HTMLDocument supports highlighting nodes from the HighlightAgent and
* highlighting the DOMNode corresponding to the cursor position in the
* editor.
*/
define(function HTMLDocumentModule(require, exports, module) {
"use strict";
var EditorManager = require("editor/EditorManager"),
EventDispatcher = require("utils/EventDispatcher"),
HighlightAgent = require("LiveDevelopment/Agents/HighlightAgent"),
HTMLInstrumentation = require("language/HTMLInstrumentation"),
Inspector = require("LiveDevelopment/Inspector/Inspector"),
LiveDevelopment = require("LiveDevelopment/LiveDevelopment"),
PerfUtils = require("utils/PerfUtils"),
RemoteAgent = require("LiveDevelopment/Agents/RemoteAgent"),
_ = require("thirdparty/lodash");
/**
* @constructor
* @param {!Document} doc The source document from Brackets
* @param {!Editor} editor The editor for this document
*/
var HTMLDocument = function HTMLDocument(doc, editor) {
this.doc = doc;
if (this.doc) {
this.doc.addRef();
}
this.editor = editor;
this._instrumentationEnabled = false;
this._onActiveEditorChange = this._onActiveEditorChange.bind(this);
EditorManager.on("activeEditorChange", this._onActiveEditorChange);
// Attach now
this.attachToEditor(editor);
};
EventDispatcher.makeEventDispatcher(HTMLDocument.prototype);
/**
* Enable or disable instrumented HTML
* @param {boolean} enabled Whether to enable or disable
*/
HTMLDocument.prototype.setInstrumentationEnabled = function setInstrumentationEnabled(enabled) {
if (enabled && !this._instrumentationEnabled && this.editor) {
HTMLInstrumentation.scanDocument(this.doc);
HTMLInstrumentation._markText(this.editor);
}
this._instrumentationEnabled = enabled;
};
/**
* Returns true if document edits appear live in the connected browser
* @return {boolean}
*/
HTMLDocument.prototype.isLiveEditingEnabled = function () {
return this._instrumentationEnabled;
};
/**
* Returns a JSON object with HTTP response overrides
* @param {boolean} enabled (Unused)
* @return {{body: string}}
*/
HTMLDocument.prototype.getResponseData = function getResponseData(enabled) {
var body;
if (this._instrumentationEnabled) {
if (this.editor) {
body = HTMLInstrumentation.generateInstrumentedHTML(this.editor);
} else {
this.doc._ensureMasterEditor();
body = HTMLInstrumentation.generateInstrumentedHTML(this.doc._masterEditor);
}
}
return {
body: body || this.doc.getText()
};
};
/**
* Close the document
*/
HTMLDocument.prototype.close = function close() {
if (this.editor) {
this.editor.off(".HTMLDocument");
}
if (this.doc) {
this.doc.releaseRef();
}
EditorManager.off("activeEditorChange", this._onActiveEditorChange);
// Experimental code
if (LiveDevelopment.config.experimental) {
// Force highlight teardown
this._onHighlight();
}
};
/**
* Attach new editor
* @param {!Editor} editor The editor for this document
*/
HTMLDocument.prototype.attachToEditor = function (editor) {
var self = this;
this.editor = editor;
// Performance optimization to use closures instead of Function.bind()
// to improve responsiveness during cursor movement and keyboard events
this.editor.on("cursorActivity.HTMLDocument", function (event, editor) {
self._onCursorActivity(event, editor);
});
this.editor.on("change.HTMLDocument", function (event, editor, change) {
self._onChange(event, editor, change);
});
this.editor.on("beforeDestroy.HTMLDocument", function (event, editor) {
self._onDestroy(event, editor);
});
// Experimental code
if (LiveDevelopment.config.experimental) {
HighlightAgent.on("highlight.HTMLDocument", function (event, node) {
self._onHighlight(event, node);
});
}
if (this._instrumentationEnabled) {
// Resync instrumentation with editor
HTMLInstrumentation._markText(this.editor);
}
};
/**
* Detach current editor
*/
HTMLDocument.prototype.detachFromEditor = function () {
if (this.editor) {
HighlightAgent.hide();
this.editor.off(".HTMLDocument");
this._removeHighlight();
this.editor = null;
}
};
/**
* Update the highlight
*/
HTMLDocument.prototype.updateHighlight = function () {
var editor = this.editor,
ids = [];
if (Inspector.config.highlight) {
if (editor) {
_.each(editor.getSelections(), function (sel) {
var tagID = HTMLInstrumentation._getTagIDAtDocumentPos(
editor,
sel.reversed ? sel.end : sel.start
);
if (tagID !== -1) {
ids.push(tagID);
}
});
}
if (!ids.length) {
HighlightAgent.hide();
} else {
HighlightAgent.domElement(ids);
}
}
};
/** Event Handlers *******************************************************/
/**
* Triggered on cursor activity by the editor
* @param {$.Event} event Event
* @param {!Editor} editor The editor for this document
*/
HTMLDocument.prototype._onCursorActivity = function (event, editor) {
if (this.editor !== editor) {
return;
}
this.updateHighlight();
};
/**
* @private
* For the given editor change, compare the resulting browser DOM with the
* in-editor DOM. If there are any diffs, a warning is logged to the
* console along with each diff.
* @param {Object} change CodeMirror editor change data
*/
HTMLDocument.prototype._compareWithBrowser = function (change) {
var self = this;
RemoteAgent.call("getSimpleDOM").done(function (res) {
var browserSimpleDOM = JSON.parse(res.result.value),
edits,
node,
result;
try {
result = HTMLInstrumentation._getBrowserDiff(self.editor, browserSimpleDOM);
} catch (err) {
console.error("Error comparing in-browser DOM to in-editor DOM");
console.error(err.stack);
return;
}
edits = result.diff.filter(function (delta) {
// ignore textDelete in html root element
node = result.browser.nodeMap[delta.parentID];
if (node && node.tag === "html" && delta.type === "textDelete") {
return false;
}
return true;
});
if (edits.length > 0) {
console.warn("Browser DOM does not match after change: " + JSON.stringify(change));
edits.forEach(function (delta) {
console.log(delta);
});
}
});
};
/**
* Triggered when the editor is being destroyed
* @param {$.Event} event Event
* @param {!Editor} editor The editor being destroyed
*/
HTMLDocument.prototype._onDestroy = function (event, editor) {
if (this.editor === editor) {
this.detachFromEditor();
}
};
/**
* Triggered on change by the editor
* @param {$.Event} event Event
* @param {!Editor} editor The editor for this document
* @param {Object} change CodeMirror editor change data
*/
HTMLDocument.prototype._onChange = function (event, editor, change) {
// Make sure LiveHTML is turned on
if (!this._instrumentationEnabled) {
return;
}
// Apply DOM edits is async, so previous PerfUtils timer may still be
// running. PerfUtils does not support running multiple timers with same
// name, so do not start another timer in this case.
var perfTimerName = "HTMLDocument applyDOMEdits",
isNestedTimer = PerfUtils.isActive(perfTimerName);
if (!isNestedTimer) {
PerfUtils.markStart(perfTimerName);
}
// Only handles attribute changes currently.
// TODO: text changes should be easy to add
// TODO: if new tags are added, need to instrument them
var self = this,
result = HTMLInstrumentation.getUnappliedEditList(editor, change),
applyEditsPromise;
if (result.edits) {
applyEditsPromise = RemoteAgent.call("applyDOMEdits", result.edits);
applyEditsPromise.always(function () {
if (!isNestedTimer) {
PerfUtils.addMeasurement(perfTimerName);
}
});
}
this.errors = result.errors || [];
this.trigger("statusChanged", this);
// Debug-only: compare in-memory vs. in-browser DOM
// edit this file or set a conditional breakpoint at the top of this function:
// "this._debug = true, false"
if (this._debug) {
console.log("Edits applied to browser were:");
console.log(JSON.stringify(result.edits, null, 2));
applyEditsPromise.done(function () {
self._compareWithBrowser(change);
});
}
// var marker = HTMLInstrumentation._getMarkerAtDocumentPos(
// this.editor,
// editor.getCursorPos()
// );
//
// if (marker && marker.tagID) {
// var range = marker.find(),
// text = marker.doc.getRange(range.from, range.to);
//
// // HACK maintain ID
// text = text.replace(">", " data-brackets-id='" + marker.tagID + "'>");
//
// // FIXME incorrectly replaces body elements with content only, missing body element
// RemoteAgent.remoteElement(marker.tagID).replaceWith(text);
// }
// if (!this.editor) {
// return;
// }
// var codeMirror = this.editor._codeMirror;
// while (change) {
// var from = codeMirror.indexFromPos(change.from);
// var to = codeMirror.indexFromPos(change.to);
// var text = change.text.join("\n");
// DOMAgent.applyChange(from, to, text);
// change = change.next;
// }
};
/**
* Triggered when the active editor changes
* @param {$.Event} event Event
* @param {!Editor} newActive The new active editor
* @param {!Editor} oldActive The old active editor
*/
HTMLDocument.prototype._onActiveEditorChange = function (event, newActive, oldActive) {
this.detachFromEditor();
if (newActive && newActive.document === this.doc) {
this.attachToEditor(newActive);
}
};
/**
* Triggered by the HighlightAgent to highlight a node in the editor
* @param {$.Event} event Event
* @param {DOMElement} node Element to highlight
*/
HTMLDocument.prototype._onHighlight = function (event, node) {
this._removeHighlight();
if (!node || !node.location || !this.editor) {
return;
}
var codeMirror = this.editor._codeMirror;
var to, from = codeMirror.posFromIndex(node.location);
if (node.closeLocation) {
to = node.closeLocation + node.closeLength;
} else {
to = node.location + node.length;
}
to = codeMirror.posFromIndex(to);
this._highlight = codeMirror.markText(from, to, { className: "highlight" });
};
/**
* Remove all highlighting
*/
HTMLDocument.prototype._removeHighlight = function () {
if (this._highlight) {
this._highlight.clear();
this._highlight = null;
}
};
// Export the class
module.exports = HTMLDocument;
});