changeset 16:bb68dc3ad56f

Major refactor to provide better TinyMCE support and less configuration options, added new jquery dependency, etc.
author David Eads <eads@chicagotech.org>
date Tue, 03 Mar 2009 16:57:39 -0600
parents 7a5f74482ee3
children 1a77f87927dd
files dnd.module js/dnd-library.js js/dnd.js js/jquery.fieldselection.js
diffstat 4 files changed, 303 insertions(+), 140 deletions(-) [+]
line wrap: on
line diff
--- a/dnd.module	Mon Mar 02 23:22:37 2009 -0600
+++ b/dnd.module	Tue Mar 03 16:57:39 2009 -0600
@@ -78,6 +78,7 @@
   if ($element['#dnd-enabled']) {
 
     drupal_add_js(drupal_get_path('module', 'dnd') .'/js/jquery.url.packed.js', 'footer');
+    drupal_add_js(drupal_get_path('module', 'dnd') .'/js/jquery.fieldselection.js', 'footer');
     drupal_add_js(drupal_get_path('module', 'dnd') .'/js/dnd.js', 'footer');
     drupal_add_js(drupal_get_path('module', 'dnd') .'/js/dnd-library.js', 'footer');
 
--- a/js/dnd-library.js	Mon Mar 02 23:22:37 2009 -0600
+++ b/js/dnd-library.js	Tue Mar 03 16:57:39 2009 -0600
@@ -1,18 +1,39 @@
+/**
+ * Drag and Drop Library For Drupal
+ *
+ * This builds on the DnD jQuery plugin written to provide drag and drop media
+ * handling to Rich Text Editors to consume, display, and attach behavior to
+ * a "media library" provided via JSON and implemented for Drupal running
+ * the Wysiwyg plugin.
+ */
+
+/**
+ *  Extend jQuery a bit
+ *  
+ *  We add a selector to look for "empty" elements (empty elements in TinyMCE
+ *  often have non-breaking spaces and <br /> tags).
+ */
+(function($) {
+  // Custom selectors
+  $.extend($.expr[":"], {
+    'empty' : function(a, i, m) {
+      var text = $(a).html();
+      text.replace(/\u00a0/g,'');  // Remove &nbsp;
+      $('br', $(text)).remove();   // Remove breaks
+      return !$.trim(text);
+    }
+  });
+}) (jQuery);
+
 Drupal.behaviors.dndLibrary = function(context) {
-  $('.dnd-library-wrapper:not(.dnd-processed)', context).each(function() {
+  $('.dnd-library-wrapper', context).each(function() {
     var $this = $(this);
 
     // This is a bad hack to lop off '-dnd-library' from the id to get the editor name
     var editor = this.id.slice(0, -12); 
 
-    // Attempt to attach library
-    //Drupal.behaviors.dndLibrary.attach_library(false, Drupal.wysiwyg.instances[editor]);
-
-    // Bind Drag and Drop plugin invocation to wywsiwygAttach event
+    // Bind Drag and Drop plugin invocation to events emanating from Wysiwyg
     $('#' + editor).bind('wysiwygAttach', Drupal.behaviors.dndLibrary.attach_library);
-
-    // @TODO track and clear intervals to save memory at the cost of 
-    // more processing?
     $('#' + editor).bind('wysiwygDetach', Drupal.behaviors.dndLibrary.detach_library);
 
     // Ajax pager
@@ -30,25 +51,18 @@
       });
       return false;
     });
-
-    $this.addClass('dnd-processed');
   });
 }
 
+// Dynamically compose a callback based on the editor name
 Drupal.behaviors.dndLibrary.attach_library = function(e, data) {
-  var settings = {
-    renderRepresentation: function(target, drop, representation_id) {
-      return Drupal.settings.dndEditorRepresentations[representation_id];
-    }
-  }
-  settings = $.extend(settings, Drupal.settings.dndEnabledLibraries[data.field]);
-
   var editor_fn = 'attach_' + data.editor;
   if ($.isFunction(window.Drupal.behaviors.dndLibrary[editor_fn])) {
-    window.Drupal.behaviors.dndLibrary[editor_fn](data, settings); 
+    window.Drupal.behaviors.dndLibrary[editor_fn](data, Drupal.settings.dndEnabledLibraries[data.field]); 
   }
 }
 
+// Do garbage collection on detach
 Drupal.behaviors.dndLibrary.detach_library = function(e, data) {
   for (t in $(document).data('dnd_timers')) {
     clearInterval(t);
@@ -56,9 +70,20 @@
   $(document).removeData('dnd_timers');
 }
 
+// Basic textareas
+Drupal.behaviors.dndLibrary.attach_none = function(data, settings) {
+  settings = $.extend({
+    targets: $('#'+ data.field),
+    procressTextAreaDrop: function(target, clicked, representation_id, e, data) {
+      var snippet = Drupal.settings.dndEditorRepresentations[representation_id];
+      $(target).replaceSelection(snippet, true);
+    }
+  }, settings);
+  $(settings.drop_selector).dnd(settings);
+}
 
+// Attach TinyMCE
 Drupal.behaviors.dndLibrary.attach_tinymce = function(data, settings) {
-
   var tiny_instance = tinyMCE.getInstanceById(data.field);
 
   // If the Tiny instance exists, attach directly, otherwise wait until Tiny
@@ -76,27 +101,107 @@
   }
 }
 
+// Really attach TinyMCE
 Drupal.behaviors.dndLibrary._attach_tinymce = function(data, settings, tiny_instance) {
+  var ed = tiny_instance, dom = ed.dom, s = ed.selection;
+
   settings = $.extend({
     targets: $('#'+ data.field +'-wrapper iframe'),
-    insertAfter: '<p><span id="__caret">_</span></p>',
+    idSelector: function(element) { 
+      if ($(element).is('img')) {
+        return $.url.setUrl(element.src).param('dnd_id');
+      }
+      return false;
+    }, 
+    interval: 100,
+    processIframeDrop: function(target, dragged, dropped, representation_id) {
+      var representation = Drupal.settings.dndEditorRepresentations[representation_id];
+      var $target = $(target), $dropped = $(dropped), $dragged = $(dragged), block;
 
-    // Back out markup to render in place after parent container
-    preprocessDrop: function(target, drop) {
-      drop.id = 'DND-TMP-' + $.data(drop);
-      // Do some native tiny manipulations
-      return drop;
-    },
-    postprocessDrop: function(target, drop, element) {
-      // Get our special span, select it, delete it, and hope the caret
-      // resets correctly.
-      tiny_instance.selection.select(tiny_instance.dom.get('__caret'));
-      tiny_instance.execCommand('Delete', false, null);
-      tiny_instance.dom.remove('__caret');
+      // Search through block level parents
+      $dropped.parents().each(function() {
+        var $this = $(this);
+        if ($this.css('display') == 'block') {
+          block = this;
+          return false;
+        }
+      });
+
+      // Remove dropped item
+      $dropped.remove();
+
+      // Create an element to insert
+      var insert = dom.create('p', {'class' : 'dnd-dropped-wrapper', 'id' : 'dnd-inserted'}, representation);
+
+      // The no-parent case
+      if ($(block).is('body')) {
+        
+        s.setNode(insert);
+      }
+      else {
+        var old_id = block.id;
+        block.id = 'target-block';
+        $block = $('#target-block', $target.contents());
+
+        // @TODO is finding the parent broken in safari??
+        $block.after('<p class="dnd-dropped-wrapper" id="dnd-inserted">' + representation + '</p>');
+
+        // The active target block should be empty
+        if ($('#target-block:empty', $target.contents()).length > 0) {
+          var c = dom.get('target-block');
+          s.select(dom.get('target-block'));
+          ed.execCommand('Delete', false, null);
+          dom.remove(c);
+        } else if (old_id) {
+          block.id = old_id;
+        } else {
+          $block.removeAttr('id');
+        }
+      }
+
+      var $inserted = $('#dnd-inserted', $target.contents());
+      var inserted = $inserted.get(0);
+
+      // Look behind in the DOM
+      var previous = $inserted.prev().get(0);
+
+      // If the previous element is also an editor representation, we need to
+      // put a dummy paragraph between the elements to prevent editor errors.
+      if (previous && $(previous).hasClass('dnd-dropped-wrapper')) {
+        $inserted.before('<p><span id="__spacer">_</span></p>');
+        c = dom.get('__spacer');
+        s.select(c);
+        ed.execCommand('Delete', false, null);
+        dom.remove(c);
+      }
+
+      // Look ahead in the DOM
+      var next = $inserted.next().get(0);
+
+      // If the next item exists and isn't an editor representation, drop the
+      // caret at the beginning of the element, otherwise make a new paragraph
+      // to advance the caret to.
+      if (next && !$(next).hasClass('dnd-dropped-wrapper')) {
+        $(next).prepend('<span id="__caret">_</span>');
+      }
+      else {
+        var after = dom.create('p', {}, '<span id="__caret">_</span>');
+        dom.insertAfter(after, 'dnd-inserted');
+      }
+
+      // Clear the ID for the next drop
+      $inserted.removeAttr('id');
+
+      // Force selection to reset the caret
+      var c = dom.get('__caret');
+      s.select(c);
+      ed.execCommand('Delete', false, null);
+      dom.remove(c);
 
       // Add some classes to the library items
-      $(element).addClass('dnd-inserted'); 
-      $(element).parents('.editor-item').addClass('dnd-child-inserted');
+      $(dragged).addClass('dnd-inserted'); 
+      drag_parents = $(dragged).parents('.editor-item');
+      drag_parents.addClass('dnd-child-inserted');
     }
   }, settings);
 
--- a/js/dnd.js	Mon Mar 02 23:22:37 2009 -0600
+++ b/js/dnd.js	Tue Mar 03 16:57:39 2009 -0600
@@ -1,9 +1,9 @@
 /* jQuery Drag and Drop Library for Rich Editors
  *
  * A helper library which provides the ability to drag and drop images to Rich 
- * Text Editors (RTEs) that use the embedded iframe + DesignMode method, and
- * provides a simple "clicky" interface for inserting the same markup directly
- * into a textarea.  
+ * Text Editors (RTEs) that use the embedded iframe + DesignMode method. DnD
+ * also provides a simple "clicky" interface for inserting the same markup 
+ * directly into a textarea.  
  *
  * Basic usage:
  *
@@ -11,100 +11,86 @@
  *
  * Options:
  *
- * targets
- *   A jQuery object corresponding to the proper iframe(s) to enable dragging
- *   and dropping for. [Required]
+ * targets (Required):
+ *   A jQuery object corresponding to the proper iframe(s) and/or textarea(s)
+ *   that are allowed drop targets for the current set of elements.
  *
- * dropWrapper
- *   An html snippet to wrap the entire inserted content with in the editor 
- *   representation (i.e. '<p class="foo"></p>')
+ * idSelector:
+ *  A callback that parses out the unique ID of an image that is dropped in an 
+ *  iframe.  While some browsers (such as Firefox and Internet Explorer) allow
+ *  markup to be copied when dragging and dropping, Safari (and assumably
+ *  other webkit based browsers) don't.  The upshot is that the safest bet is
+ *  to parse the element's src URL.  Because querystrings seem to drop 
+ *  consistently across 
  *
- * insertBefore: 
- *   Markup to insert before drop (i.e. '<hr />')
+ * processIframeDrop:
+ *   A callback that defines the mechanism for inserting and rendering the 
+ *   dropped item in an iframe.  The typical usage pattern I expect to see is
+ *   that implementers will listen for their RTE to load and then invoke DnD
+ *   with a processIframeDrop appropriate to their editor.
  *
- * insertAfter:  
- *   Markup to insert after drop (i.e. '<div class="clearfix"></div>')
- *
- * processedClass:  
- *   The class to apply to links and images tagged as droppable.  This class 
- *   should have a style rule in the editor that sets display to 'none' for
- *   the best experience.
- *
- * idSelector:  
- *   A callback that parses a unique id out of a droppable element. By default
- *   this uses the id of the element, but one could parse out an ID based on 
- *   any part of the URL, interior markup, etc.
- *
- * renderRepresentation:
- *   A callback that defines the mechanism for rendering a representation of 
- *   the content.  The default is currently essential useless.
- *
- * preprocessDrop:
- *   A callback that preprocesses the dropped snippet in the iframe, before 
- *   replacing it.  By default this uses a little logic to walk up the DOM 
- *   tree to topmost parent of the place in the source where the item was 
- *   dropped, and add the dropped element after that parent instead of 
- *   inside it.
- *
- * postprocessDrop:
- *   A callback that postprocesses the iframe.
+ * processTextAreaDrop:
+ *   A callback that defines the mechanism for inserting and rendering the
+ *   clicked item in a textarea.  The default function just tries to insert
+ *   some markup at the caret location in the current textarea.
  *
  * interval:
  *   How often to check the iframe for a drop, in milliseconds.
  *
+ * 
+ *
  * Usage notes:
  *
- * This is a very tricky problem and to achieve cross browser (as of writing, 
- * IE, Safari, and Firefox) support, severe limitations must be made on the 
- * nature of DOM elements that are dropped.
+ * Due to cross browser flakiness, there are many many limitations on what is
+ * possible in terms of dragging and dropping to DesignMode enabled iframes.
  *
- * Droppable elements must be <img> tags.  Internet Explorer will not accept
- * anchors, and Safari strips attributes and additional markup from
- * dropped anchors, images, and snippets.
+ * To make this work, your "droppable" elements must be image tags.  No ifs, ands, 
+ * or buts:  image tags have the best cross browser behavior when dragging.
  *
- * While the idSelector option allows you to parse the dropped element for
- * any attribute that "comes along" with the element when it is dropped in a
- * designmode-enabled iframe, the only safe attribute to scan is the 'src' 
- * attribute, and to avoid strange "relativization" of links, the src should
- * always be expressed as an absolute url with a fully qualified domain name.
+ * When DnD is initialized, it begins timers which periodically check your 
+ * editor iframe.  If an image is dropped in, DnD takes the element and 
+ * attempts to parse it for a unique ID which you can use to do a lookup for 
+ * an "editor representation."  If an editor representation is found, 
+ * typically the image is replaced with the representation snippet, but you are
+ * free to do whatever you want.
  *
- * Implementation notes and todos:
+ * Because of browser limitations, the safest way to parse the element is to
+ * look at the img's src attribute and use some or all of the image URL. 
+ * In my experience, the best way to deal with this is simply to parse out
+ * generate a query string in the src attribute server side that corresponds 
+ * to the proper representation ID.
  *
- * Currently, there is no garbage collection instituted for the many many
- * timers that are created, so memory usage could become as issue in scenarios
- * where the user does a lot of paging or otherwise winds up invoking 
- * drag and drop on large numbers of elements.
+ * If the target is not an iframe but a textarea, DnD provides a very minimal
+ * system for clicking what would otherwise be dragged to insert markup into 
+ * the textarea.
+ *
+ * DnD is purely a utility library:  to make it work for any particular editor,
+ * CMS, etc:  It is very unlikely it will provide much benefit out of the box.
+ *
+ * Because DnD spawns so many timers, they are stored in a $.data element 
+ * attached to the parent document body.  Implementers should consider clearing
+ * the timers when building systems such as dynamic searches or paging 
+ * functionality to ensure memory usage and performance remains stable.
  */
 
 (function($) {
   $.fn.dnd = function(opt) {
     opt = $.extend({}, {
       interval: 250,
-      dropWrapper: '<p class="dnd-dropped-wrapper"></p>',
-      insertBefore: false,
-      insertAfter: false,
-      processedClass:  'dnd-processed',
-      ignoreClass: 'dnd-dropped',
+      targets: $('iframe, textarea'),
       processTargets: function(targets) {
         return targets.each(function() {
           $('head', $(this).contents()).append('<style type="text/css">img { display: none; } img.dnd-dropped {display: block; }</style>');
           return this;
         });
       },
-
-      // Must return a string
       idSelector: function(element) { 
         if ($(element).is('img')) {
-          return $.url.setUrl(element.src).param('dnd_id');
+          return element.src;
         }
+        return false;
       }, 
-
-      // @TODO:  Target should be jQuery object
-      targets: $('iframe, textarea'),
-
-      // Must return a string that DOES NOT share the id of any droppable item
-      // living outside the iframe
-      renderRepresentation: function(target, drop, representation_id) {
+      processIframeDrop: function(target, dragged, dropped, representation_id) {
         // Keep a counter of how many times this element was used
         var count = $.data(target, representation_id +'_count');
         if (!count) { 
@@ -113,14 +99,14 @@
           count++;
         }
         $.data(target, representation_id +'_count', count);
-        return '<span id="dnd-' + representation_id +'-'+ count +'">' + representation_id + '</span>';
+        $(dropped).replaceWith('<p id="dnd-' + representation_id +'-'+ count +'">' + representation_id + '</p>');
       },
+      processTextAreaDrop: function(target, clicked, representation_id, e, data) {
+        var snippet = '<div><img src="'+ representation_id +'" /></div>';
+        $(target).replaceSelection(snippet, true);
+        e.preventDefault();
+      }
 
-      preprocessDrop: function(target, drop) { return drop; },
-      postprocessDrop: function(target, drop, element) { 
-        $(element).addClass('dnd-inserted'); 
-      }
-                      
     }, opt);
 
     // Initialize plugin
@@ -129,7 +115,7 @@
     // Process!
     return this.each(function() {
       if ($(this).is('img')) {
-        var element = this;
+        var element = this, $element = $(element);
 
         // If we don't have a proper id, bail
         var representation_id = opt.idSelector(element);
@@ -138,45 +124,31 @@
           return this;
         };
 
-        // Add some UI sugar and a special class
-        $(element)
-          .css('cursor', 'move')
-          .addClass(opt.processedClass);
+        // Add a special class
+        $(element).addClass('dnd-processed');
 
         // We need to differentiate behavior based on the targets... I guess.
         targets.each(function() {
-          if ($(this).is('iframe')) {
-            var target = this;
+          var target = this, $target = $(target);
+          if ($target.is('iframe')) {
 
-            var selector = 'img:not(.' + opt.ignoreClass + ')';
+            // Indicate this element is draggy
+            $element.css('cursor', 'move');
+
 
             // Watch the iframe for changes
             var t = setInterval(function() {              
-              $(selector, $(target).contents()).each(function() {
-                if (opt.idSelector(this) == representation_id) {
-                  var drop = opt.preprocessDrop(target, $(this)); // Must return a jquery object
-                  var representation = opt.renderRepresentation(target, drop, representation_id);
-                  if (representation) {
-                    // Breaks IE 7!
-                    /*if (opt.dropWrapper) {
-                      drop.wrap(opt.dropWrapper);
-                    }*/
-                    if (opt.insertBefore) {
-                      drop.before(opt.insertBefore);
-                    }
-                    if (opt.insertAfter) {
-                      drop.after(opt.insertAfter);
-                    }
-                    drop.replaceWith(representation);
-                    opt.postprocessDrop(target, drop, element);
-                  }
-                } 
+              $('img:not(.dnd-dropped)', $(target).contents()).each(function() {
+                var dropped = this;
+                if (opt.idSelector(dropped) == representation_id) {
+                  opt.processIframeDrop(target, element, dropped, representation_id);
+                }
               });
             }, opt.interval);
 
-            // Track current active timers -- this means you can implement
-            // your own garbage collection for specific interactions, such
-            // as paging.
+            // Track current active timers -- developers working with DnD
+            // can implement their own garbage collection for specific 
+            // interactions, such as paging or live search.
             var data = $(document).data('dnd_timers');
             if (data) {
               data[data.length] = t;
@@ -186,8 +158,10 @@
             }
             $(document).data('dnd_timers', data);
 
-          } else if ($(this).is('textarea')) {
-            //console.log('@TODO handle textareas via.... regexp?');
+          } else if ($target.is('textarea')) {
+            $(element).click(function(e, data) {
+              opt.processTextAreaDrop(target, element, representation_id, e, data);
+            });
           }
         });
       }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/js/jquery.fieldselection.js	Tue Mar 03 16:57:39 2009 -0600
@@ -0,0 +1,83 @@
+/*
+ * jQuery plugin: fieldSelection - v0.1.0 - last change: 2006-12-16
+ * (c) 2006 Alex Brem <alex@0xab.cd> - http://blog.0xab.cd
+ */
+
+(function() {
+
+	var fieldSelection = {
+
+		getSelection: function() {
+
+			var e = this.jquery ? this[0] : this;
+
+			return (
+
+				/* mozilla / dom 3.0 */
+				('selectionStart' in e && function() {
+					var l = e.selectionEnd - e.selectionStart;
+					return { start: e.selectionStart, end: e.selectionEnd, length: l, text: e.value.substr(e.selectionStart, l) };
+				}) ||
+
+				/* exploder */
+				(document.selection && function() {
+
+					e.focus();
+
+					var r = document.selection.createRange();
+					if (r == null) {
+						return { start: 0, end: e.value.length, length: 0 }
+					}
+
+					var re = e.createTextRange();
+					var rc = re.duplicate();
+					re.moveToBookmark(r.getBookmark());
+					rc.setEndPoint('EndToStart', re);
+
+					return { start: rc.text.length, end: rc.text.length + r.text.length, length: r.text.length, text: r.text };
+				}) ||
+
+				/* browser not supported */
+				function() {
+					return { start: 0, end: e.value.length, length: 0 };
+				}
+
+			)();
+
+		},
+
+		replaceSelection: function() {
+
+			var e = this.jquery ? this[0] : this;
+			var text = arguments[0] || '';
+
+			return (
+
+				/* mozilla / dom 3.0 */
+				('selectionStart' in e && function() {
+					e.value = e.value.substr(0, e.selectionStart) + text + e.value.substr(e.selectionEnd, e.value.length);
+					return this;
+				}) ||
+
+				/* exploder */
+				(document.selection && function() {
+					e.focus();
+					document.selection.createRange().text = text;
+					return this;
+				}) ||
+
+				/* browser not supported */
+				function() {
+					e.value += text;
+					return this;
+				}
+
+			)();
+
+		}
+
+	};
+
+	jQuery.each(fieldSelection, function(i) { jQuery.fn[i] = this; });
+
+})();