ballonizer 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/examples/ballonizer_app/config.ru +59 -0
  3. data/examples/ballonizer_app/index.html +159 -0
  4. data/examples/ballonizer_js_module/index.html +196 -0
  5. data/lib/assets/javascripts/ballonizer.js +482 -0
  6. data/lib/assets/stylesheets/ballonizer.css +78 -0
  7. data/lib/ballonizer.rb +201 -36
  8. data/spec/ballonizer_spec.rb +153 -2
  9. data/spec/javascripts/ballonizer_spec.js +568 -0
  10. data/spec/javascripts/fixtures/ballonized-xkcd-with-anchor-in-image.html +163 -0
  11. data/spec/javascripts/fixtures/ballonized-xkcd-with-ballons.html +163 -0
  12. data/spec/javascripts/fixtures/ballonized-xkcd-without-ballons.html +163 -0
  13. data/spec/javascripts/fixtures/xkcd.css +191 -0
  14. data/spec/javascripts/helpers/jasmine-jquery.js +660 -0
  15. data/spec/javascripts/helpers/jquery.simulate-ext.js +32 -0
  16. data/spec/javascripts/helpers/jquery.simulate.drag-n-drop.js +583 -0
  17. data/spec/javascripts/helpers/jquery.simulate.js +328 -0
  18. data/spec/javascripts/support/jasmine.yml +99 -0
  19. data/vendor/assets/javascripts/jquery-2.0.1.js +8837 -0
  20. data/vendor/assets/javascripts/jquery-ui-1.10.3.custom.min.js +6 -0
  21. data/vendor/assets/javascripts/jquery.json-2.4.min.js +24 -0
  22. data/vendor/assets/stylesheets/ui-lightness/images/animated-overlay.gif +0 -0
  23. data/vendor/assets/stylesheets/ui-lightness/images/ui-bg_diagonals-thick_18_b81900_40x40.png +0 -0
  24. data/vendor/assets/stylesheets/ui-lightness/images/ui-bg_diagonals-thick_20_666666_40x40.png +0 -0
  25. data/vendor/assets/stylesheets/ui-lightness/images/ui-bg_flat_10_000000_40x100.png +0 -0
  26. data/vendor/assets/stylesheets/ui-lightness/images/ui-bg_glass_100_f6f6f6_1x400.png +0 -0
  27. data/vendor/assets/stylesheets/ui-lightness/images/ui-bg_glass_100_fdf5ce_1x400.png +0 -0
  28. data/vendor/assets/stylesheets/ui-lightness/images/ui-bg_glass_65_ffffff_1x400.png +0 -0
  29. data/vendor/assets/stylesheets/ui-lightness/images/ui-bg_gloss-wave_35_f6a828_500x100.png +0 -0
  30. data/vendor/assets/stylesheets/ui-lightness/images/ui-bg_highlight-soft_100_eeeeee_1x100.png +0 -0
  31. data/vendor/assets/stylesheets/ui-lightness/images/ui-bg_highlight-soft_75_ffe45c_1x100.png +0 -0
  32. data/vendor/assets/stylesheets/ui-lightness/images/ui-icons_222222_256x240.png +0 -0
  33. data/vendor/assets/stylesheets/ui-lightness/images/ui-icons_228ef1_256x240.png +0 -0
  34. data/vendor/assets/stylesheets/ui-lightness/images/ui-icons_ef8c08_256x240.png +0 -0
  35. data/vendor/assets/stylesheets/ui-lightness/images/ui-icons_ffd27a_256x240.png +0 -0
  36. data/vendor/assets/stylesheets/ui-lightness/images/ui-icons_ffffff_256x240.png +0 -0
  37. data/vendor/assets/stylesheets/ui-lightness/jquery-ui-1.10.3.custom.min.css +5 -0
  38. metadata +51 -3
@@ -0,0 +1,482 @@
1
+ // Module pattern
2
+ (function (global) {
3
+ "use strict";
4
+ var Ballonizer = (function () {
5
+
6
+ var htmlEscape = function (str) {
7
+ return (str)
8
+ .replace(/&/g, "&")
9
+ .replace(/"/g, """)
10
+ .replace(/'/g, "'")
11
+ .replace(/</g, "&lt;")
12
+ .replace(/>/g, "&gt;");
13
+ };
14
+
15
+ var objectIsEmpty = function (obj) {
16
+ for (var p in obj) {
17
+ if (obj.hasOwnProperty(p)) {
18
+ return false;
19
+ }
20
+ }
21
+ return true;
22
+ };
23
+
24
+ // Classes used by the module
25
+ var Ballonizer;
26
+ var BallonizedImageContainer;
27
+ var InterfaceBallon;
28
+
29
+ /**
30
+ * @constructor Construct a Ballonizer object. This includes
31
+ * the BallonizedImageContainer objects for any images who match
32
+ * the ballonizerContainerSelector in the context. Add form for the
33
+ * submit of the ballonized image changes.
34
+ * @param actionFormURL A string with the value of the action
35
+ * attribute of the ballonizer form.
36
+ * @param ballonizerContainerSelector A css selector string who
37
+ * match the container wrapped around each image to be ballonized.
38
+ * @param context A node or JQuery object. The Ballonizer will
39
+ * affect and see only inside of this node.
40
+ * @param config A hash with the config to be used in some
41
+ * situations:
42
+ * ballonInitialText: ballon text when the ballon is created (do
43
+ * not use a empty string or only spaces). Defaults
44
+ * to "double click to edit ballon text";
45
+ * submitButtonValue: text of the button who submits the changes
46
+ * in the ballons. Defaults to "Submit ballon changes";
47
+ * @return Ballonizer object
48
+ */
49
+ Ballonizer = function (actionFormURL,
50
+ ballonizerContainerSelector,
51
+ context,
52
+ config) {
53
+
54
+ // Implicit constructor pattern
55
+ if (!(this instanceof Ballonizer)) {
56
+ return new Ballonizer(actionFormURL,
57
+ ballonizerContainerSelector,
58
+ context,
59
+ config);
60
+ }
61
+
62
+ if (null == config) {
63
+ this.config = {
64
+ ballonInitialText: "double click to edit ballon text",
65
+ submitButtonValue: "Submit ballon changes"
66
+ };
67
+ } else {
68
+ this.config = config;
69
+ }
70
+
71
+ this.actionFormURL = actionFormURL;
72
+ this.context = $(context);
73
+
74
+ this.ballonizedImages = {};
75
+
76
+ var imageContainers = $(ballonizerContainerSelector, this.context);
77
+ imageContainers.each($.proxy(function (ix, element) {
78
+ var container = $(element);
79
+ var ballonizedContainer = new BallonizedImageContainer(this, container);
80
+ var imgSrc = $("img", container).prop("src");
81
+
82
+ this.ballonizedImages[imgSrc] = ballonizedContainer;
83
+
84
+ }, this));
85
+
86
+ this.formNode = null;
87
+ if (!objectIsEmpty(this.ballonizedImages)) {
88
+ this.formNode = this.generateBallonizerFormNode();
89
+ this.context.children().last().after(this.formNode);
90
+ }
91
+
92
+ return this;
93
+ };
94
+
95
+ Ballonizer.Error = function (message) {
96
+ this.message = message;
97
+ };
98
+
99
+ Ballonizer.Error.prototype = new Error();
100
+
101
+ // Instance methods
102
+ Ballonizer.prototype.getBallonInitialText = function () {
103
+ return this.config.ballonInitialText;
104
+ };
105
+
106
+ Ballonizer.prototype.getContext = function () {
107
+ return this.context;
108
+ };
109
+
110
+ Ballonizer.prototype.getForm = function () {
111
+ return this.formNode;
112
+ };
113
+
114
+ Ballonizer.prototype.getBallonizedImageContainers = function () {
115
+ return this.ballonizedImages;
116
+ };
117
+
118
+ Ballonizer.prototype.generateBallonizerFormNode = function () {
119
+ var form = $("<form class='ballonizer_page_form' method='post' >" +
120
+ "<input name='ballonizer_data' type='hidden' />" +
121
+ "<input name='ballonizer_submit' type='submit' " +
122
+ "value='" + this.config.submitButtonValue +
123
+ "' /></form>");
124
+
125
+ form.attr("action", this.actionFormURL);
126
+
127
+ return form;
128
+ };
129
+
130
+ Ballonizer.prototype.notifyBallonChange = function () {
131
+ var submitButton = $("input[type='submit']", this.formNode);
132
+ var dataInput = $("input[type='hidden']", this.formNode);
133
+
134
+ if (!submitButton.hasClass("ballonizer_ballons_have_changes")) {
135
+ submitButton.addClass("ballonizer_ballons_have_changes");
136
+ }
137
+
138
+ dataInput.val(jQuery.toJSON(this.serialize()));
139
+
140
+ return true;
141
+ };
142
+
143
+ Ballonizer.prototype.serialize = function () {
144
+ var serialization = {};
145
+
146
+ jQuery.each(this.ballonizedImages, function (ix, el) {
147
+ // makeArray remove non-numerical keys as img_src
148
+ serialization[ix] = jQuery.makeArray(el.serialize());
149
+ });
150
+
151
+ return serialization;
152
+ };
153
+
154
+ BallonizedImageContainer = (function () {
155
+ var BallonizedImageContainer = function (ballonizerInstance,
156
+ containerNode) {
157
+
158
+ // Implicit constructor pattern
159
+ if (!(this instanceof BallonizedImageContainer)) {
160
+ return new BallonizedImageContainer(ballonizerInstance,
161
+ containerNode);
162
+ }
163
+
164
+ this.ballonizerInstance = ballonizerInstance;
165
+ this.containerNode = containerNode;
166
+ this.ballons = [];
167
+
168
+ // Insert the form for the ballons in edit mode
169
+ $("<form class='ballonizer_image_form' method='#'></form>").insertBefore(
170
+ this.containerNode.children().first()
171
+ );
172
+
173
+ var ballons = $(".ballonizer_ballon",
174
+ this.ballonizerInstance.getContext());
175
+
176
+ ballons.each($.proxy(function (ix, element) {
177
+ this.ballons.push(new InterfaceBallon(this, $(element)));
178
+ }, this));
179
+
180
+ var img = $("img", this.containerNode);
181
+ img.click($.proxy(function (event) {
182
+ this.click(event);
183
+ }, this));
184
+ img.dblclick($.proxy(function (event) {
185
+ this.dblclick(event);
186
+ }, this));
187
+ };
188
+
189
+ BallonizedImageContainer.prototype.getContainerNode = function () {
190
+ return this.containerNode;
191
+ };
192
+
193
+ BallonizedImageContainer.prototype.getBallonizerInstance = function () {
194
+ return this.ballonizerInstance;
195
+ };
196
+
197
+ BallonizedImageContainer.prototype.notifyBallonChange = function () {
198
+ return this.ballonizerInstance.notifyBallonChange();
199
+ };
200
+
201
+ BallonizedImageContainer.prototype.removeBallonFromList = function (ballon) {
202
+ var ix = jQuery.inArray(ballon, this.ballons);
203
+ this.ballons.splice(ix, 1);
204
+
205
+ this.notifyBallonChange();
206
+
207
+ return this;
208
+ };
209
+
210
+ BallonizedImageContainer.prototype.getBallons = function () {
211
+ return this.ballons;
212
+ };
213
+
214
+ BallonizedImageContainer.prototype.click = function (event) {
215
+ // The container don't have an action to do when it's clicked,
216
+ // but it have when are double-clicked, and a double-click
217
+ // trigger a click event too. To avoid problems with webcomic
218
+ // pages who are links to the next page we disable the default
219
+ // efects of the click.
220
+ event.preventDefault();
221
+ };
222
+
223
+ BallonizedImageContainer.prototype.dblclick = function (event) {
224
+ event.preventDefault();
225
+ event.stopImmediatePropagation();
226
+ var offset = this.containerNode.offset();
227
+ var ballonX = event.pageX - offset.left;
228
+ var ballonY = event.pageY - offset.top;
229
+
230
+ // Width and height are magic number choosen for no good reason
231
+ var ballonWidth = 129, ballonHeight = 41;
232
+ var ballon = new InterfaceBallon(this, ballonX, ballonY,
233
+ ballonWidth, ballonHeight,
234
+ this.ballonizerInstance.getBallonInitialText());
235
+
236
+ this.ballons.push(ballon);
237
+
238
+ this.notifyBallonChange();
239
+ };
240
+
241
+ BallonizedImageContainer.prototype.serialize = function () {
242
+ /* jshint camelcase: false */
243
+ var serialization = {
244
+ // The img_src is out of style (not in camel case) because
245
+ // it will be submitted to the ruby, and i'm giving
246
+ // preference to the ruby convention when in conflict
247
+ img_src: $("img", this.containerNode).prop("src"),
248
+ length: this.ballons.length
249
+ };
250
+
251
+ jQuery.each(this.ballons, function (ix, el) {
252
+ serialization[ix] = el.serialize();
253
+ });
254
+
255
+ return serialization;
256
+ };
257
+
258
+ return BallonizedImageContainer;
259
+ })();
260
+
261
+ InterfaceBallon = (function () {
262
+ var InterfaceBallon = function (imgContainer, xOrNode, y, width,
263
+ height, initialText) {
264
+
265
+ // Implicit constructor pattern
266
+ if (!(this instanceof InterfaceBallon)) {
267
+ return new InterfaceBallon(imgContainer, xOrNode, y, width,
268
+ height, initialText);
269
+ }
270
+
271
+ this.imgContainer = imgContainer;
272
+ this.state = "initial";
273
+
274
+ if (2 === arguments.length) {
275
+ this.node = xOrNode;
276
+ this.updatePositionAndSize();
277
+ this.text = this.node.text();
278
+ } else {
279
+ this.left = xOrNode;
280
+ this.top = y;
281
+ this.width = width;
282
+ this.height = height;
283
+ var containerNode = this.imgContainer.getContainerNode();
284
+
285
+ // The ifs purpose are avoid the ballon of being created
286
+ // partially outside of the image (after the creation the
287
+ // jQueryUI handles this for us).
288
+ if (this.left + this.width > containerNode.width()) {
289
+ if (containerNode.width() < this.width) {
290
+ this.left = 0;
291
+ this.width = containerNode.width();
292
+ } else {
293
+ this.left = containerNode.width() - this.width;
294
+ }
295
+ }
296
+ if (this.top + this.height > containerNode.height()) {
297
+ if (containerNode.height() < this.height) {
298
+ this.top = 0;
299
+ this.height = containerNode.height();
300
+ } else {
301
+ this.top = containerNode.height() - this.height;
302
+ }
303
+ }
304
+
305
+ this.text = initialText;
306
+ this.node = this.generateBallonNode();
307
+ this.node.insertBefore(
308
+ this.imgContainer.getContainerNode().children().first()
309
+ );
310
+ }
311
+
312
+ this.node.draggable({
313
+ containment: "parent",
314
+ opacity: 0.5,
315
+ distance: 5,
316
+ stop: $.proxy(function (event, ui) {
317
+ /* jshint unused: false */
318
+ this.updatePositionAndSize();
319
+ }, this)
320
+ });
321
+ this.node.resizable({
322
+ containment: "parent",
323
+ opacity: 0.5,
324
+ distance: 5,
325
+ // Hides the handle when not hovering
326
+ autoHide: true,
327
+ stop: $.proxy(function (event, ui) {
328
+ /* jshint unused: false */
329
+ this.updatePositionAndSize();
330
+ }, this)
331
+ });
332
+
333
+ var imageForm = $(".ballonizer_image_form",
334
+ this.imgContainer.getContainerNode());
335
+ var editionBallon = $(
336
+ "<textarea class='ballonizer_edition_ballon'></textarea>"
337
+ ).val(this.text);
338
+
339
+ this.editionNode = editionBallon;
340
+ imageForm.prepend(this.editionNode);
341
+
342
+ this.editionNode.blur($.proxy(function (event) {
343
+ this.blur(event);
344
+ }, this));
345
+ this.node.dblclick($.proxy(function (event) {
346
+ this.dblclick(event);
347
+ }, this));
348
+ };
349
+
350
+ InterfaceBallon.prototype.getBallonizedImageContainer = function () {
351
+ return this.imgContainer;
352
+ };
353
+
354
+ InterfaceBallon.prototype.generateBallonNode = function () {
355
+ var nodeStyle = ["left: ", this.left, "px; ", "top: ",
356
+ this.top, "px; ", "width: ", this.width, "px; ",
357
+ "height: ", this.height, "px;"].join("");
358
+ var node = $("<p class='ballonizer_ballon' ></p>");
359
+
360
+ // The use of ".text" will escape '<', '>', and others
361
+ node.text(this.text).attr("style", nodeStyle);
362
+
363
+ return node;
364
+ };
365
+
366
+ InterfaceBallon.prototype.getText = function () {
367
+ return this.text;
368
+ };
369
+ InterfaceBallon.prototype.getPositionAndSize = function () {
370
+ return {
371
+ left: this.left,
372
+ top: this.top,
373
+ width: this.width,
374
+ height: this.height
375
+ };
376
+ };
377
+ InterfaceBallon.prototype.updatePositionAndSize = function () {
378
+ var newX = this.node.position().left;
379
+ var newY = this.node.position().top;
380
+ var newWidth = this.node.width();
381
+ var newHeight = this.node.height();
382
+
383
+ if (newX !== this.left || newY !== this.top ||
384
+ newWidth !== this.width || newHeight !== this.height) {
385
+
386
+ this.left = newX;
387
+ this.top = newY;
388
+ this.width = newWidth;
389
+ this.height = newHeight;
390
+ this.imgContainer.notifyBallonChange();
391
+ }
392
+ };
393
+ InterfaceBallon.prototype.getState = function () {
394
+ return this.state;
395
+ };
396
+ InterfaceBallon.prototype.getNode = function () {
397
+ if (this.state.match(/edit/)) {
398
+ return this.editionNode;
399
+ } else {
400
+ return this.node;
401
+ }
402
+ };
403
+ InterfaceBallon.prototype.getNormalNode = function () {
404
+ return this.node;
405
+ };
406
+ InterfaceBallon.prototype.getEditionNode = function () {
407
+ return this.editionNode;
408
+ };
409
+ InterfaceBallon.prototype.blur = function () {
410
+ /* jshint loopfunc: true */
411
+ if ("edit" !== this.state) {
412
+ throw new Ballonizer.Error(
413
+ "losing focus when not in the edit state"
414
+ );
415
+ }
416
+ var oldText = this.text;
417
+ this.text = this.editionNode.val();
418
+ if ((/^\s*$/).test(this.text)) {
419
+ this.node.remove();
420
+ this.editionNode.remove();
421
+ // implies notifyBallonChange
422
+ this.imgContainer.removeBallonFromList(this);
423
+ // throw a exception if any method is called
424
+ for (var method in InterfaceBallon.prototype)
425
+ {
426
+ if (InterfaceBallon.prototype.hasOwnProperty(method)) {
427
+ this[method] = function () {
428
+ throw new Ballonizer.Error(
429
+ "this ballon already have been destroyed" +
430
+ ", do not call any methods over it ('" +
431
+ method + "' was called)");
432
+ };
433
+ }
434
+ }
435
+ } else {
436
+ this.state = "initial";
437
+ // only change the text in the node, and not what
438
+ // the resizable insert inside the element (the handles)
439
+ this.node.contents().filter(function () {
440
+ return this.nodeType === 3;
441
+ }).replaceWith(htmlEscape(this.text));
442
+ this.node.removeClass("ballonizer_ballon_hidden_for_edition");
443
+ this.editionNode.removeClass("ballonizer_ballon_in_edition");
444
+ if (this.text !== oldText) {
445
+ this.imgContainer.notifyBallonChange();
446
+ }
447
+ }
448
+ };
449
+
450
+ InterfaceBallon.prototype.dblclick = function () {
451
+ this.state = this.state.replace("initial", "edit");
452
+ this.node.addClass("ballonizer_ballon_hidden_for_edition");
453
+ this.editionNode.attr("style", this.node.attr("style"));
454
+ this.editionNode.addClass("ballonizer_ballon_in_edition");
455
+ // focus after is visible, otherwise will crash in IE
456
+ // http://api.jquery.com/focus/#focus
457
+ this.editionNode.focus();
458
+ };
459
+
460
+ InterfaceBallon.prototype.serialize = function () {
461
+ var containerWidth = this.imgContainer.getContainerNode().width();
462
+ var containerHeight = this.imgContainer.getContainerNode().height();
463
+
464
+ return {
465
+ left: this.left / containerWidth,
466
+ top: this.top / containerHeight,
467
+ width: this.width / containerWidth,
468
+ height: this.height / containerHeight,
469
+ text: this.text
470
+ };
471
+ };
472
+
473
+ return InterfaceBallon;
474
+ })();
475
+
476
+ return Ballonizer;
477
+ })();
478
+
479
+ global.Ballonizer = Ballonizer;
480
+
481
+ })(this);
482
+
@@ -0,0 +1,78 @@
1
+ .ballonizer_image_container
2
+ {
3
+ position: relative;
4
+ display: inline-block;
5
+ padding: 0;
6
+ margin: 0;
7
+ border: 0;
8
+ }
9
+
10
+ .ballonizer_ballon
11
+ {
12
+ position: absolute;
13
+ margin: 0;
14
+ background-color: white;
15
+ color: black;
16
+ text-align: center;
17
+ overflow: hidden;
18
+ }
19
+
20
+ .ballonizer_ballon_hidden_for_edition
21
+ {
22
+ display: none;
23
+ }
24
+
25
+ /* The ballonizer image form is hidden but we don't use "display: none".
26
+ * The "display: none" in a parent element make impossible to diplay the child
27
+ * elements. So we remove any padding. As the childs are positioned
28
+ * absolutely they don't ocuppy space. This make height equal zero, but
29
+ * width is equal to the width of the parent element. Using "display: inline-block"
30
+ * fix this problem and the height is equal to the sum of the childs who
31
+ * occupy space (no one). With this the form is "hidden" because don't occupy
32
+ * any space (the width AND height are equal zero).
33
+ */
34
+ .ballonizer_image_form
35
+ {
36
+ margin: 0;
37
+ padding: 0;
38
+ border: 0;
39
+ display: inline-block;
40
+ }
41
+
42
+ .ballonizer_image_form > *
43
+ {
44
+ position: absolute;
45
+ display: none;
46
+ margin: 0;
47
+ text-align: center;
48
+ }
49
+
50
+ .ballonizer_image_form > .ballonizer_ballon_in_edition
51
+ {
52
+ display: block;
53
+ }
54
+
55
+ /* We use the same trick used in the image form in the page form */
56
+ .ballonizer_page_form
57
+ {
58
+ display: inline-block;
59
+ padding: 0;
60
+ margin: 0;
61
+ border: 0;
62
+ }
63
+
64
+ .ballonizer_page_form > *
65
+ {
66
+ display: none;
67
+ margin: 0;
68
+ padding: 0;
69
+ }
70
+
71
+ .ballonizer_page_form > .ballonizer_ballons_have_changes
72
+ {
73
+ display: block;
74
+ position: fixed;
75
+ top: 0;
76
+ right: 0;
77
+ }
78
+