# HG changeset patch # User David Eads # Date 1236121059 21600 # Node ID bb68dc3ad56fb35ca752708d680bd6cdddc21d8f # Parent 7a5f74482ee3a70e6453abc092e692604a87a817 Major refactor to provide better TinyMCE support and less configuration options, added new jquery dependency, etc. diff -r 7a5f74482ee3 -r bb68dc3ad56f dnd.module --- 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'); diff -r 7a5f74482ee3 -r bb68dc3ad56f js/dnd-library.js --- 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
tags). + */ +(function($) { + // Custom selectors + $.extend($.expr[":"], { + 'empty' : function(a, i, m) { + var text = $(a).html(); + text.replace(/\u00a0/g,''); // Remove   + $('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: '

_

', + 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('

' + representation + '

'); + + // 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('

_

'); + 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('_'); + } + else { + var after = dom.create('p', {}, '_'); + 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); diff -r 7a5f74482ee3 -r bb68dc3ad56f js/dnd.js --- 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. '

') + * 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. '
') + * 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. '
') - * - * 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 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: '

', - insertBefore: false, - insertAfter: false, - processedClass: 'dnd-processed', - ignoreClass: 'dnd-dropped', + targets: $('iframe, textarea'), processTargets: function(targets) { return targets.each(function() { $('head', $(this).contents()).append(''); 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 '' + representation_id + ''; + $(dropped).replaceWith('

' + representation_id + '

'); }, + processTextAreaDrop: function(target, clicked, representation_id, e, data) { + var snippet = '
'; + $(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); + }); } }); } diff -r 7a5f74482ee3 -r bb68dc3ad56f js/jquery.fieldselection.js --- /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 - 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; }); + +})();