diff --git a/src/LiveDevelopment/LiveDevelopment.js b/src/LiveDevelopment/LiveDevelopment.js index 7327fd38f8e..48c9cc7ec92 100644 --- a/src/LiveDevelopment/LiveDevelopment.js +++ b/src/LiveDevelopment/LiveDevelopment.js @@ -42,7 +42,9 @@ * # STATUS * * Status updates are dispatched as `statusChange` jQuery events. The status - * codes are: + * is passed as the first parameter and the reason for the change as the second + * parameter. Currently only the "Inactive" status supports the reason parameter. + * The status codes are: * * -1: Error * 0: Inactive @@ -50,6 +52,13 @@ * 2: Loading agents * 3: Active * 4: Out of sync + * + * The reason codes are: + * - null (Unknown reason) + * - "explicit_close" (LiveDevelopment.close() was called) + * - "navigated_away" (The browser changed to a location outside of the project) + * - "detached_target_closed" (The tab or window was closed) + * - "detached_replaced_with_devtools" (The developer tools were opened in the browser) */ define(function LiveDevelopment(require, exports, module) { "use strict"; @@ -133,7 +142,8 @@ define(function LiveDevelopment(require, exports, module) { var _liveDocument; // the document open for live editing. var _relatedDocuments; // CSS and JS documents that are used by the live HTML document var _serverProvider; // current LiveDevServerProvider - + var _closeReason; // reason why live preview was closed + function _isHtmlFileExt(ext) { return (FileUtils.isStaticHtmlFileExt(ext) || (ProjectManager.getBaseUrl() && FileUtils.isServerHtmlFileExt(ext))); @@ -451,8 +461,14 @@ define(function LiveDevelopment(require, exports, module) { * @param {integer} new status */ function _setStatus(status) { + // Don't send a notification when the status didn't actually change + if (status === exports.status) { + return; + } + exports.status = status; - $(exports).triggerHandler("statusChange", status); + var reason = status === STATUS_INACTIVE ? _closeReason : null; + $(exports).triggerHandler("statusChange", [status, reason]); } /** Triggered by Inspector.error */ @@ -504,13 +520,6 @@ define(function LiveDevelopment(require, exports, module) { }); } - /** Triggered by Inspector.detached */ - function _onDetached(event, res) { - // res.reason, e.g. "replaced_with_devtools", "target_closed", "canceled_by_user" - // Sample list taken from https://chromiumcodereview.appspot.com/10947037/patch/12001/13004 - // However, the link refers to the Chrome Extension API, it may not apply 100% to the Inspector API - } - // WebInspector Event: Page.frameNavigated function _onFrameNavigated(event, res) { // res = {frame} @@ -539,6 +548,7 @@ define(function LiveDevelopment(require, exports, module) { if (!url.match(baseUrlRegExp)) { // No longer in site, so terminate live dev, but don't close browser window Inspector.disconnect(); + _closeReason = "navigated_away"; _setStatus(STATUS_INACTIVE); _serverProvider = null; } @@ -554,10 +564,22 @@ define(function LiveDevelopment(require, exports, module) { _setStatus(STATUS_INACTIVE); } + function _onDetached(event, res) { + // If there already is a reason for closing the session, do not overwrite it + if (!_closeReason) { + // Get the explanation from res.reason, e.g. "replaced_with_devtools", "target_closed", "canceled_by_user" + // Examples taken from https://chromiumcodereview.appspot.com/10947037/patch/12001/13004 + // However, the link refers to the Chrome Extension API, it may not apply 100% to the Inspector API + // Prefix with "detached_" to create a quasi-namespace for Chrome's reasons + _closeReason = "detached_" + res.reason; + } + } + function reconnect() { unloadAgents(); - var promises = loadAgents(); + _setStatus(STATUS_LOADING_AGENTS); + var promises = loadAgents(); $.when.apply(undefined, promises).done(_onLoad).fail(_onError); } @@ -569,6 +591,8 @@ define(function LiveDevelopment(require, exports, module) { var browserStarted = false; var retryCount = 0; + _closeReason = null; + function showWrongDocError() { Dialogs.showModalDialog( Dialogs.DIALOG_ID_ERROR, @@ -722,6 +746,8 @@ define(function LiveDevelopment(require, exports, module) { * @return {jQuery.Promise} Resolves once the connection is closed */ function close() { + _closeReason = "explicit_close"; + var deferred = $.Deferred(); /* @@ -835,7 +861,6 @@ define(function LiveDevelopment(require, exports, module) { $.when.apply(undefined, promises).done(_onLoad).fail(_onError); } - $(Inspector.Inspector).on("detached.livedev", _onDetached); $(Inspector.Page).on("frameNavigated.livedev", _onFrameNavigated); waitForInterstitialPageLoad() @@ -960,6 +985,7 @@ define(function LiveDevelopment(require, exports, module) { $(Inspector).on("connect", _onConnect) .on("disconnect", _onDisconnect) .on("error", _onError); + $(Inspector.Inspector).on("detached", _onDetached); $(DocumentManager).on("currentDocumentChange", _onDocumentChange) .on("documentSaved", _onDocumentSaved) .on("dirtyFlagChange", _onDirtyFlagChange); diff --git a/src/LiveDevelopment/main.js b/src/LiveDevelopment/main.js index 2630919325c..d431b0de336 100644 --- a/src/LiveDevelopment/main.js +++ b/src/LiveDevelopment/main.js @@ -48,7 +48,8 @@ define(function main(require, exports, module) { Dialogs = require("widgets/Dialogs"), UrlParams = require("utils/UrlParams").UrlParams, Strings = require("strings"), - ExtensionUtils = require("utils/ExtensionUtils"); + ExtensionUtils = require("utils/ExtensionUtils"), + StringUtils = require("utils/StringUtils"); var prefs; var params = new UrlParams(); @@ -131,17 +132,48 @@ define(function main(require, exports, module) { } } + /** Called on status change */ + function _showStatusChangeReason(reason) { + // Destroy the previous twipsy (options are not updated otherwise) + _$btnGoLive.twipsy("hide").removeData("twipsy"); + + // If there was no reason or the action was an explicit request by the user, don't show a twipsy + if (!reason || reason === "explicit_close") { + return; + } + + // Translate the reason + var translatedReason = Strings["LIVE_DEV_" + reason.toUpperCase()]; + if (!translatedReason) { + translatedReason = StringUtils.format(Strings.LIVE_DEV_CLOSED_UNKNOWN_REASON, reason); + } + + // Configure the twipsy + var options = { + placement: "left", + trigger: "manual", + autoHideDelay: 5000, + title: function () { + return translatedReason; + } + }; + + // Show the twipsy with the explanation + _$btnGoLive.twipsy(options).twipsy("show"); + } + /** Create the menu item "Go Live" */ function _setupGoLiveButton() { _$btnGoLive = $("#toolbar-go-live"); _$btnGoLive.click(function onGoLive() { _handleGoLiveCommand(); }); - $(LiveDevelopment).on("statusChange", function statusChange(event, status) { + $(LiveDevelopment).on("statusChange", function statusChange(event, status, reason) { // status starts at -1 (error), so add one when looking up name and style // See the comments at the top of LiveDevelopment.js for details on the // various status codes. _setLabel(_$btnGoLive, null, _statusStyle[status + 1], _statusTooltip[status + 1]); + _showStatusChangeReason(reason); if (config.autoconnect) { window.sessionStorage.setItem("live.enabled", status === 3); } diff --git a/src/brackets.js b/src/brackets.js index 82676a7fc73..a3127cb2345 100644 --- a/src/brackets.js +++ b/src/brackets.js @@ -53,6 +53,7 @@ define(function (require, exports, module) { // Load dependent non-module scripts require("widgets/bootstrap-dropdown"); require("widgets/bootstrap-modal"); + require("widgets/bootstrap-twipsy-mod"); require("thirdparty/path-utils/path-utils.min"); require("thirdparty/smart-auto-complete/jquery.smart_autocomplete"); diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index e3774ffbe71..fd5801c8e39 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -87,6 +87,11 @@ define({ "LIVE_DEV_STATUS_TIP_PROGRESS2" : "Live Preview: Initializing\u2026", "LIVE_DEV_STATUS_TIP_CONNECTED" : "Disconnect Live Preview", "LIVE_DEV_STATUS_TIP_OUT_OF_SYNC" : "Live Preview: Click to disconnect (Save file to update)", + + "LIVE_DEV_DETACHED_REPLACED_WITH_DEVTOOLS" : "Live Preview was cancelled because the browser's developer tools were opened", + "LIVE_DEV_DETACHED_TARGET_CLOSED" : "Live Preview was cancelled because the page was closed in the browser", + "LIVE_DEV_NAVIGATED_AWAY" : "Live Preview was cancelled because the browser navigated to a page that is not part of the current project", + "LIVE_DEV_CLOSED_UNKNOWN_REASON" : "Live Preview was cancelled for an unknown reason ({0})", "SAVE_CLOSE_TITLE" : "Save Changes", "SAVE_CLOSE_MESSAGE" : "Do you want to save the changes you made in the document {0}?", diff --git a/src/styles/brackets_patterns_override.less b/src/styles/brackets_patterns_override.less index fc5a7aa0442..433f66d29fa 100644 --- a/src/styles/brackets_patterns_override.less +++ b/src/styles/brackets_patterns_override.less @@ -572,6 +572,11 @@ } } +/* Twipsy tooltips */ +.twipsy-inner { + max-width: none; + white-space: nowrap; +} /* Buttons */ diff --git a/src/widgets/bootstrap-twipsy-mod.js b/src/widgets/bootstrap-twipsy-mod.js new file mode 100644 index 00000000000..70c62bc0d86 --- /dev/null +++ b/src/widgets/bootstrap-twipsy-mod.js @@ -0,0 +1,408 @@ +/* ========================================================== + * bootstrap-twipsy.js v1.4.0 + * http://twitter.github.com/bootstrap/javascript.html#twipsy + * Adapted from the original jQuery.tipsy by Jason Frame + * Adjusted for Brackets + * ========================================================== + * Copyright 2011 Twitter, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ========================================================== */ + + +!function( $ ) { + + "use strict" + + /***** [changed for Brackets] *****/ + // Undefined until the focus state changed once + var _windowHasFocus; + + $(window) + .focus(function _onWindowGainedFocus() { + _windowHasFocus = true; + }) + .blur(function _onWindowLostFocus() { + _windowHasFocus = false; + }); + /***** [/changed for Brackets] *****/ + + /* CSS TRANSITION SUPPORT (https://gist.github.com/373874) + * ======================================================= */ + + var transitionEnd + + $(document).ready(function () { + + $.support.transition = (function () { + var thisBody = document.body || document.documentElement + , thisStyle = thisBody.style + , support = thisStyle.transition !== undefined || thisStyle.WebkitTransition !== undefined || thisStyle.MozTransition !== undefined || thisStyle.MsTransition !== undefined || thisStyle.OTransition !== undefined + return support + })() + + // set CSS transition event type + if ( $.support.transition ) { + transitionEnd = "TransitionEnd" + if ( $.browser.webkit ) { + transitionEnd = "webkitTransitionEnd" + } else if ( $.browser.mozilla ) { + transitionEnd = "transitionend" + } else if ( $.browser.opera ) { + transitionEnd = "oTransitionEnd" + } + } + + }) + + + /* TWIPSY PUBLIC CLASS DEFINITION + * ============================== */ + + var Twipsy = function ( element, options ) { + this.$element = $(element) + this.options = options + this.enabled = true + /***** [changed for Brackets] *****/ + this.autoHideTimeout = null; + /***** [/changed for Brackets] *****/ + this.fixTitle() + } + + Twipsy.prototype = { + + show: function() { + /***** [changed for Brackets: moved some variables to updatePosition()] *****/ + var $tip + , that = this; + /***** [/changed for Brackets] *****/ + + if (this.hasContent() && this.enabled) { + $tip = this.tip() + this.setContent() + + if (this.options.animate) { + $tip.addClass('fade') + } + + $tip + .remove() + .css({ top: 0, left: 0, display: 'block' }) + .prependTo(document.body) + +/***** [changed for Brackets] *****/ + this.updatePosition(); + + $(window).off("resize", this.resizeHandler); + this.resizeHandler = function(e) { + that.updatePosition(); + }; + $(window).on("resize", this.resizeHandler); + + if (this.options.autoHideDelay) { + var startAutoHide = function () { + window.clearTimeout(that.autoHideTimeout); + that.autoHideTimeout = window.setTimeout(function () { + that.hide(); + }, that.options.autoHideDelay); + } + if (_windowHasFocus) { + startAutoHide(); + } else { + $(window).one("focus", startAutoHide); + } + } + + $tip.addClass('in'); + } + } + + , updatePosition: function () { + var pos + , actualWidth + , actualHeight + , paddingLeft + , paddingRight + , surplusRight + , shiftArrow + , placement + , $tip + , $arrow + , tp + , that = this + + $tip = this.tip() + + pos = $.extend({}, this.$element.offset(), { + width: this.$element[0].offsetWidth + , height: this.$element[0].offsetHeight + }) + + paddingLeft = parseInt(this.$element.css("padding-left"), 10); + paddingRight = parseInt(this.$element.css("padding-right"), 10); + + pos.left += paddingLeft; + pos.width -= (paddingLeft + paddingRight); + + actualWidth = $tip[0].offsetWidth + actualHeight = $tip[0].offsetHeight + + placement = maybeCall(this.options.placement, this, [ $tip[0], this.$element[0] ]) + // Add the placement class so the arrow's margin can be determined + $tip.addClass(placement) + + switch (placement) { + case 'below': + tp = {top: pos.top + pos.height + this.options.offset, left: pos.left + pos.width / 2 - actualWidth / 2} + break + case 'above': + tp = {top: pos.top - actualHeight - this.options.offset, left: pos.left + pos.width / 2 - actualWidth / 2} + break + case 'left': + tp = {top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left - actualWidth - this.options.offset} + break + case 'right': + tp = {top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left + pos.width + this.options.offset} + break + } + + shiftArrow = 0; + + surplusRight = (tp.left + actualWidth - $(document.body).width()); + if (surplusRight > 0) { + shiftArrow = surplusRight; + tp.left -= surplusRight; + } else if (tp.left < 0) { + shiftArrow = tp.left; + tp.left = 0; + } + + if (surplusRight > 0) { + $arrow = $tip.find(".twipsy-arrow"); + if (! this.defaultMargin) { + this.defaultMargin = parseInt($arrow.css("margin-left"), 10); + } + $arrow.css("margin-left", this.defaultMargin + shiftArrow); + } + + $tip.css(tp); + } +/***** [/changed for Brackets] *****/ + + , setContent: function () { + var $tip = this.tip() + $tip.find('.twipsy-inner')[this.options.html ? 'html' : 'text'](this.getTitle()) + $tip[0].className = 'twipsy' + } + + , hide: function() { + var that = this + , $tip = this.tip() + + $tip.removeClass('in') + + function removeElement () { + $tip.remove() + } + + $.support.transition && this.$tip.hasClass('fade') ? + $tip.bind(transitionEnd, removeElement) : + removeElement() + + /***** [changed for Brackets] *****/ + window.clearTimeout(this.autoHideTimeout); + $(window).off("resize", this.resizeHandler) + /***** [/changed for Brackets] *****/ + } + + , fixTitle: function() { + var $e = this.$element + if ($e.attr('title') || typeof($e.attr('data-original-title')) != 'string') { + $e.attr('data-original-title', $e.attr('title') || '').removeAttr('title') + } + } + + , hasContent: function () { + return this.getTitle() + } + + , getTitle: function() { + var title + , $e = this.$element + , o = this.options + + this.fixTitle() + + if (typeof o.title == 'string') { + title = $e.attr(o.title == 'title' ? 'data-original-title' : o.title) + } else if (typeof o.title == 'function') { + title = o.title.call($e[0]) + } + + title = ('' + title).replace(/(^\s*|\s*$)/, "") + + return title || o.fallback + } + + , tip: function() { + return this.$tip = this.$tip || $('
').html(this.options.template) + } + + , validate: function() { + if (!this.$element[0].parentNode) { + this.hide() + this.$element = null + this.options = null + } + } + + , enable: function() { + this.enabled = true + } + + , disable: function() { + this.enabled = false + } + + , toggleEnabled: function() { + this.enabled = !this.enabled + } + + , toggle: function () { + this[this.tip().hasClass('in') ? 'hide' : 'show']() + } + + } + + + /* TWIPSY PRIVATE METHODS + * ====================== */ + + function maybeCall ( thing, ctx, args ) { + return typeof thing == 'function' ? thing.apply(ctx, args) : thing + } + + /* TWIPSY PLUGIN DEFINITION + * ======================== */ + + $.fn.twipsy = function (options) { + $.fn.twipsy.initWith.call(this, options, Twipsy, 'twipsy') + return this + } + + $.fn.twipsy.initWith = function (options, Constructor, name) { + var twipsy + , binder + , eventIn + , eventOut + + if (options === true) { + return this.data(name) + } else if (typeof options == 'string') { + twipsy = this.data(name) + if (twipsy) { + twipsy[options]() + } + return this + } + + options = $.extend({}, $.fn[name].defaults, options) + + function get(ele) { + var twipsy = $.data(ele, name) + + if (!twipsy) { + twipsy = new Constructor(ele, $.fn.twipsy.elementOptions(ele, options)) + $.data(ele, name, twipsy) + } + + return twipsy + } + + function enter() { + var twipsy = get(this) + twipsy.hoverState = 'in' + + if (options.delayIn == 0) { + twipsy.show() + } else { + twipsy.fixTitle() + setTimeout(function() { + if (twipsy.hoverState == 'in') { + twipsy.show() + } + }, options.delayIn) + } + } + + function leave() { + var twipsy = get(this) + twipsy.hoverState = 'out' + if (options.delayOut == 0) { + twipsy.hide() + } else { + setTimeout(function() { + if (twipsy.hoverState == 'out') { + twipsy.hide() + } + }, options.delayOut) + } + } + + if (!options.live) { + this.each(function() { + get(this) + }) + } + + if (options.trigger != 'manual') { + binder = options.live ? 'live' : 'bind' + eventIn = options.trigger == 'hover' ? 'mouseenter' : 'focus' + eventOut = options.trigger == 'hover' ? 'mouseleave' : 'blur' + this[binder](eventIn, enter)[binder](eventOut, leave) + } + + return this + } + + $.fn.twipsy.Twipsy = Twipsy + + $.fn.twipsy.defaults = { + animate: true + , delayIn: 0 + , delayOut: 0 + , fallback: '' + , placement: 'above' + , html: false + , live: false + , offset: 0 + , title: 'title' + , trigger: 'hover' + , template: '
' + } + + $.fn.twipsy.rejectAttrOptions = [ 'title' ] + + $.fn.twipsy.elementOptions = function(ele, options) { + var data = $(ele).data() + , rejects = $.fn.twipsy.rejectAttrOptions + , i = rejects.length + + while (i--) { + delete data[rejects[i]] + } + + return $.extend({}, options, data) + } + +}( window.jQuery || window.ender ); \ No newline at end of file