// Module pattern (function (global) {
"use strict"; // We define these two variables as null to cause an error when the // global version of these variables is accessed. This is used to enforce // the use of the jQueryObject parameter of the ballonizer constructor. var jQuery = null; var $ = null; var Ballonizer = (function () { var htmlEscape = function (str) { return (str) .replace(/&/g, "&") .replace(/"/g, """) .replace(/'/g, "'") .replace(/</g, "<") .replace(/>/g, ">"); }; var objectIsEmpty = function (obj) { for (var p in obj) { if (obj.hasOwnProperty(p)) { return false; } } return true; }; // Classes used by the module var Ballonizer; var BallonizedImageContainer; var InterfaceBallon; /** * @constructor Construct a Ballonizer object. This includes * the BallonizedImageContainer objects for any images who match * the ballonizerContainerSelector in the context. Add form for the * submit of the ballonized image changes. * @param actionFormURL A string with the value of the action * attribute of the ballonizer form. * @param ballonizerContainerSelector A css selector string who * match the container wrapped around each image to be ballonized. * @param context A node or JQuery object. The Ballonizer will * affect and see only inside of this node. * @param jQueryObject The '$' or 'jQuery' variables defined by jQuery * library. The object will use this object instead the global one. * The main utility of this is allow the use of a different jQuery * version that the used in the rest of the page scripts. * @param config A hash with the config to be used in some * situations: * ballonInitialText: ballon text when the ballon is created (do * not use a empty string or only spaces). Defaults * to "double click to edit ballon text"; * submitButtonValue: text of the button who submits the changes * in the ballons. Defaults to "Submit ballon changes"; * jQueryObject: a jQuery object to use instead of the global one, * the main utility of this configuration is use multiple jQuery * versions in the page. * @return Ballonizer object */ Ballonizer = function (actionFormURL, ballonizerContainerSelector, context, jQueryObject, config) { // Implicit constructor pattern if (!(this instanceof Ballonizer)) { return new Ballonizer(actionFormURL, ballonizerContainerSelector, context, jQueryObject, config); } var defaultConfig = { ballonInitialText: "double click to edit ballon text", submitButtonValue: "Submit ballon changes" }; if (jQueryObject) { this.jQuery = jQueryObject; } else { throw new Ballonizer.Error( "the parameter jQueryObject is null or undefined" ); } var $ = this.jQuery; if (null == config) { this.config = defaultConfig; } else { this.config = $.extend({}, defaultConfig, config); } this.actionFormURL = actionFormURL; this.context = $(context); this.ballonizedImages = {}; var imageContainers = $(ballonizerContainerSelector, this.context); imageContainers.each($.proxy(function (ix, element) { var container = $(element); var ballonizedContainer = new BallonizedImageContainer( this, container, jQueryObject ); var imgSrc = $("img", container).prop("src"); this.ballonizedImages[imgSrc] = ballonizedContainer; }, this)); this.formNode = null; if (!objectIsEmpty(this.ballonizedImages)) { this.formNode = this.generateBallonizerFormNode(); this.context.children().last().after(this.formNode); } return this; }; Ballonizer.Error = function (message) { this.message = message; }; Ballonizer.Error.prototype = new Error(); // Instance methods Ballonizer.prototype.getBallonInitialText = function () { return this.config.ballonInitialText; }; Ballonizer.prototype.getContext = function () { return this.context; }; Ballonizer.prototype.getForm = function () { return this.formNode; }; Ballonizer.prototype.getBallonizedImageContainers = function () { return this.ballonizedImages; }; Ballonizer.prototype.generateBallonizerFormNode = function () { var $ = this.jQuery; var jQuery = this.jQuery; var form = $("<form class='ballonizer_page_form' method='post' >" + "<div><input name='ballonizer_data' type='hidden'>" + "</input><input name='ballonizer_submit'" + " type='submit' value='" + this.config.submitButtonValue + "'></input></div></form>"); form.attr("action", this.actionFormURL); // We find the greater z-index of a body direct child and // increment this value by one (this way the form will not // be hiiden behind another element) var zIndexes = []; $("body > *").each(function (ix, el) { var zIndexCSS = $(el).css('z-index'); // the inherit is made numeric by the jQuery, we are // filtering here only the "auto" string value if (jQuery.isNumeric(zIndexCSS)) { zIndexes.push(parseInt(zIndexCSS, 10)); } }); var zIndex = null; if (zIndexes.length > 0) { zIndex = Math.max.apply(null, zIndexes) + 1; } else { zIndex = 1; } $("input[name='ballonizer_submit']", form).css("z-index", zIndex); return form; }; Ballonizer.prototype.notifyBallonChange = function () { var $ = this.jQuery; var jQuery = this.jQuery; var submitButton = $("input[type='submit']", this.formNode); var dataInput = $("input[type='hidden']", this.formNode); if (!submitButton.hasClass("ballonizer_ballons_have_changes")) { submitButton.addClass("ballonizer_ballons_have_changes"); } dataInput.val(jQuery.toJSON(this.serialize())); return true; }; Ballonizer.prototype.serialize = function () { var jQuery = this.jQuery; var serialization = {}; jQuery.each(this.ballonizedImages, function (ix, el) { // makeArray remove non-numerical keys as img_src serialization[ix] = jQuery.makeArray(el.serialize()); }); return serialization; }; BallonizedImageContainer = (function () { var BallonizedImageContainer = function (ballonizerInstance, containerNode, jQueryObject) { // Implicit constructor pattern if (!(this instanceof BallonizedImageContainer)) { return new BallonizedImageContainer(ballonizerInstance, containerNode, jQueryObject); } this.jQuery = jQueryObject; var $ = this.jQuery; // See notifyBallonChange for this flag explanation this.userAlreadyInteracted = false; this.ballonizerInstance = ballonizerInstance; this.containerNode = containerNode; this.ballons = []; // Insert the form for the ballons in edit mode this.form = $( "<form class='ballonizer_image_form' action='#'></form>" ); this.formInnerContainer = $( "<div></div>" ); this.form.prepend(this.formInnerContainer); // The form is prepended in the beggining of the ballonizer // context and not inside the ballonizer_image_container // because not every element that can wrap a image (as an // anchor for example) can wrap a form (or a block element). // See more in: http://www.w3.org/TR/REC-html40/struct/global.html#block-inline, http://skypoetsworld.blogspot.com.br/2008/10/dont-ever-put-block-inside-inline.html, http://stackoverflow.com/questions/1091739/html-div-in-link-problem, stackoverflow.com/questions/1827965/is-putting-a-div-inside-an-anchor-ever-correcta this.ballonizerInstance.getContext().prepend(this.form); var ballons = $(".ballonizer_ballon", this.containerNode); ballons.each($.proxy(function (ix, element) { this.ballons.push(new InterfaceBallon( this, $(element), this.jQuery )); }, this)); var img = $("img", this.containerNode); img.click($.proxy(function (event) { this.click(event); }, this)); img.dblclick($.proxy(function (event) { this.dblclick(event); }, this)); }; BallonizedImageContainer.prototype.getFormInnerContainer = function () { return this.formInnerContainer; }; BallonizedImageContainer.prototype.getContainerNode = function () { return this.containerNode; }; BallonizedImageContainer.prototype.getBallonizerInstance = function () { return this.ballonizerInstance; }; // Avoid to call this method in methods that update the ballons // data, call this method only in callbacks of events, after calling // the methods that update the ballons data. This is intended to // preserve the semantics of the ballon change be always from a user // interaction. BallonizedImageContainer.prototype.notifyBallonChange = function () { var jQuery = this.jQuery; // This flag is necessary to work around the load of the image. // The ready callback don't wait for images to load and the // load callback has several caveats with using images (see // http://api.jquery.com/ready/ and http://api.jquery.com/load-event/). // All the ballons are defined in percentages and probably won't // be visible until the image is loaded (or will be over the alt // text but is hard to think of an user edit a ballon in this // case). So when the user interact with a ballon it will // calculate and update the sizes in pixels of all ballons // (the attributes used for the serialize), assuming that the // user is already editing because the image is loaded. // This fix a bug where all the ballons except the modified // (who the bounds are recalculated) are serialized and // submitted with the incorrect values (but are displayed // corretly to the user before the submission). if (!this.userAlreadyInteracted) { jQuery.each(this.ballons, function (ix, ballon) { ballon.updatePositionAndSize(); }); this.userAlreadyInteracted = true; } return this.ballonizerInstance.notifyBallonChange(); }; BallonizedImageContainer.prototype.removeBallonFromList = function (ballon) { var ix = this.jQuery.inArray(ballon, this.ballons); this.ballons.splice(ix, 1); this.notifyBallonChange(); return this; }; BallonizedImageContainer.prototype.getBallons = function () { return this.ballons; }; BallonizedImageContainer.prototype.click = function (event) { // The container don't have an action to do when it's clicked, // but it have when are double-clicked, and a double-click // trigger a click event too. To avoid problems with webcomic // pages who are links to the next page we disable the default // efects of the click. event.preventDefault(); }; BallonizedImageContainer.prototype.dblclick = function (event) { event.preventDefault(); event.stopImmediatePropagation(); var offset = this.containerNode.offset(); var ballonX = event.pageX - offset.left; var ballonY = event.pageY - offset.top; // Width and height are magic number choosen for no good reason var ballonWidth = 129, ballonHeight = 41; var ballon = new InterfaceBallon(this, ballonX, ballonY, ballonWidth, ballonHeight, this.ballonizerInstance.getBallonInitialText(), this.jQuery); this.ballons.push(ballon); this.notifyBallonChange(); }; BallonizedImageContainer.prototype.serialize = function () { var $ = this.jQuery; var jQuery = this.jQuery; /* jshint camelcase: false */ var serialization = { // The img_src is out of style (not in camel case) because // it will be submitted to the ruby, and i'm giving // preference to the ruby convention when in conflict img_src: $("img", this.containerNode).prop("src"), length: this.ballons.length }; jQuery.each(this.ballons, function (ix, el) { serialization[ix] = el.serialize(); }); return serialization; }; return BallonizedImageContainer; })(); InterfaceBallon = (function () { var InterfaceBallon = function (imgContainer, xOrNode, yOrJQueryObject, width, height, initialText, jQueryObject) { // Implicit constructor pattern if (!(this instanceof InterfaceBallon)) { return new InterfaceBallon(imgContainer, xOrNode, yOrJQueryObject, width, height, initialText, jQueryObject); } this.imgContainer = imgContainer; this.state = "initial"; if (3 === arguments.length) { this.node = xOrNode; this.jQuery = yOrJQueryObject; this.updatePositionAndSize(); this.fontSize = parseInt(this.node.css('font-size'), 10); this.text = this.node.text(); } else { this.left = xOrNode; this.top = yOrJQueryObject; this.width = width; this.height = height; this.jQuery = jQueryObject; var containerNode = this.imgContainer.getContainerNode(); // The ifs purpose are avoid the ballon of being created // partially outside of the image (after the creation the // jQueryUI handles this for us). if (this.left + this.width > containerNode.width()) { if (containerNode.width() < this.width) { this.left = 0; this.width = containerNode.width(); } else { this.left = containerNode.width() - this.width; } } if (this.top + this.height > containerNode.height()) { if (containerNode.height() < this.height) { this.top = 0; this.height = containerNode.height(); } else { this.top = containerNode.height() - this.height; } } this.text = initialText; this.node = this.generateBallonNode(); this.imgContainer.getContainerNode().prepend(this.node); this.changeFontSizeToBestFitBallon(); } var $ = this.jQuery; this.node.draggable({ containment: "parent", opacity: 0.5, distance: 5, stop: $.proxy(function (event, ui) { /* jshint unused: false */ this.updatePositionAndSize(); this.imgContainer.notifyBallonChange(); }, this) }); this.node.resizable({ containment: "parent", opacity: 0.5, distance: 5, // Hides the handle when not hovering autoHide: true, stop: $.proxy(function (event, ui) { /* jshint unused: false */ this.updatePositionAndSize(); this.changeFontSizeToBestFitBallon(); this.imgContainer.notifyBallonChange(); }, this) }); var imageForm = this.imgContainer.getFormInnerContainer(); var editionBallon = $( // cols and rows are obrigatory attributes, the choosen // values are magic numbers without a good reason "<textarea cols='100' rows='40' " + "class='ballonizer_edition_ballon'></textarea>" ).val(this.text); this.editionNode = editionBallon; imageForm.prepend(this.editionNode); // Some webcomics allows the reader to navigate using the // arrow keys. This can be very annoying when editing a // balloon ("edition mode"/textarea) because you normally // use the arrows to edit the ballon you are writing. The // result is you losing all your work every time you make // the mistake of pressing an arrow key. This guarantee that // any other scripts on the page that trigger by key events // will not trigger while the user is typing a balloon text. this.editionNode.keydown($.proxy(function (event) { event.stopPropagation(); }, this)); this.editionNode.keyup($.proxy(function (event) { event.stopPropagation(); }, this)); this.editionNode.keypress($.proxy(function (event) { event.stopPropagation(); }, this)); this.node.click($.proxy(function (event) { this.click(event); }, this)); this.editionNode.blur($.proxy(function (event) { this.blur(event); }, this)); this.node.dblclick($.proxy(function (event) { this.dblclick(event); }, this)); }; InterfaceBallon.prototype.getBallonizedImageContainer = function () { return this.imgContainer; }; InterfaceBallon.prototype.generateBallonNode = function () { var $ = this.jQuery; var nodeStyle = ["left: ", this.left, "px; ", "top: ", this.top, "px; ", "width: ", this.width, "px; ", "height: ", this.height, "px;"].join(""); var node = $("<span class='ballonizer_ballon' ></span>"); // The use of ".text" will escape '<', '>', and others node.text(this.text).attr("style", nodeStyle); return node; }; InterfaceBallon.prototype.getText = function () { return this.text; }; InterfaceBallon.prototype.getPositionAndSize = function () { return { left: this.left, top: this.top, width: this.width, height: this.height }; }; InterfaceBallon.prototype.changeFontSizeToBestFitBallon = function () { var oldHeight = this.node.css('height'); var desiredCalcHeight = this.node.height(); this.node.css('height', 'auto'); var actualFontSize = 1; this.node.css('font-size', actualFontSize); while (this.node.height() < desiredCalcHeight) { this.node.css('font-size', ++actualFontSize + 'px'); } this.node.css('font-size', --actualFontSize + 'px'); this.node.css('height', oldHeight); this.fontSize = actualFontSize; }; InterfaceBallon.prototype.updatePositionAndSize = function () { var newX = this.node.position().left; var newY = this.node.position().top; var newWidth = this.node.width(); var newHeight = this.node.height(); if (newX !== this.left || newY !== this.top || newWidth !== this.width || newHeight !== this.height) { this.left = newX; this.top = newY; this.width = newWidth; this.height = newHeight; } }; InterfaceBallon.prototype.getState = function () { return this.state; }; InterfaceBallon.prototype.getNode = function () { if (this.state.match(/edit/)) { return this.editionNode; } else { return this.node; } }; InterfaceBallon.prototype.getNormalNode = function () { return this.node; }; InterfaceBallon.prototype.getEditionNode = function () { return this.editionNode; }; InterfaceBallon.prototype.blur = function () { /* jshint loopfunc: true */ if ("edit" !== this.state) { throw new Ballonizer.Error( "losing focus when not in the edit state" ); } var oldText = this.text; this.text = this.editionNode.val(); if ((/^\s*$/).test(this.text)) { this.node.remove(); this.editionNode.remove(); // implies notifyBallonChange this.imgContainer.removeBallonFromList(this); // throw a exception if any method is called for (var method in InterfaceBallon.prototype) { if (InterfaceBallon.prototype.hasOwnProperty(method)) { this[method] = function () { throw new Ballonizer.Error( "this ballon already have been destroyed" + ", do not call any methods over it ('" + method + "' was called)"); }; } } } else { this.state = "initial"; // only change the text in the node, and not what // the resizable insert inside the element (the handles) this.node.contents().filter(function () { return this.nodeType === 3; }).replaceWith(htmlEscape(this.text)); this.node.removeClass("ballonizer_ballon_hidden_for_edition"); this.editionNode.removeClass("ballonizer_ballon_in_edition"); if (this.text !== oldText) { this.changeFontSizeToBestFitBallon(); this.imgContainer.notifyBallonChange(); } } }; InterfaceBallon.prototype.dblclick = function () { var $ = this.jQuery; var jQuery = this.jQuery; this.state = this.state.replace("initial", "edit"); var context = this.imgContainer.getBallonizerInstance().getContext(); // The add not only add this node to the set but sort the set // (that is a array, not a set) in the order that the elements // appears in the document. This way the most outer parent is // the first element. // This behaviour is documented in: http://api.jquery.com/add/ var els = this.node.parentsUntil(context).add(this.node); var zIndexes = []; els.each(function (ix, el) { var zIndexCSS = $(el).css('z-index'); // the inherit is made numeric by the jQuery, we are // filtering here only the "auto" string value if (jQuery.isNumeric(zIndexCSS)) { zIndexes.push(parseInt(zIndexCSS, 10)); } }); var zIndex = null; if (zIndexes.length > 0) { // The zIndex only need to be one greater than the // zIndex of the most outer parent with a defined zIndex. zIndex = zIndexes[0] + 1; } else { zIndex = 1; } // we subtract the offset of the imageform from the offset // of the normal ballon because the left and top of the edition // ballon is relative to this form (its the closest parent with // position: relative defined) var offset = this.node.offset(); var imageFormOffset = this.imgContainer.getFormInnerContainer().offset(); this.editionNode.css('top', offset.top - imageFormOffset.top); this.editionNode.css('left', offset.left - imageFormOffset.left); this.editionNode.css('width', this.node.css('width')); this.editionNode.css('height', this.node.css('height')); this.editionNode.css('z-index', zIndex); // only make the ballon hidden after getting the offset this.node.addClass("ballonizer_ballon_hidden_for_edition"); this.editionNode.addClass("ballonizer_ballon_in_edition"); // focus after is visible, otherwise will crash in IE // http://api.jquery.com/focus/#focus this.editionNode.focus(); }; // avoid following anchor when click over a ballon InterfaceBallon.prototype.click = function (event) { event.preventDefault(); }; InterfaceBallon.prototype.serialize = function () { var containerWidth = this.imgContainer.getContainerNode().width(); var containerHeight = this.imgContainer.getContainerNode().height(); return { left: this.left / containerWidth, top: this.top / containerHeight, width: this.width / containerWidth, height: this.height / containerHeight, text: this.text, /* jshint camelcase: false */ // The fontSize is out of style (not in camel case) because // it will be submitted to the ruby, and i'm giving // preference to the ruby convention when in conflict font_size: this.fontSize }; }; return InterfaceBallon; })(); return Ballonizer; })(); global.Ballonizer = Ballonizer;
})(this);