ballonizer 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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
+