/** * TableDnD plug-in for JQuery, allows you to drag and drop table rows * You can set up various options to control how the system will work * Copyright (c) Denis Howlett * Licensed like jQuery, see http://docs.jquery.com/License. * * Configuration options: * * onDragStyle * This is the style that is assigned to the row during drag. There are limitations to the styles that can be * associated with a row (such as you can't assign a border--well you can, but it won't be * displayed). (So instead consider using onDragClass.) The CSS style to apply is specified as * a map (as used in the jQuery css(...) function). * onDropStyle * This is the style that is assigned to the row when it is dropped. As for onDragStyle, there are limitations * to what you can do. Also this replaces the original style, so again consider using onDragClass which * is simply added and then removed on drop. * onDragClass * This class is added for the duration of the drag and then removed when the row is dropped. It is more * flexible than using onDragStyle since it can be inherited by the row cells and other content. The default * is class is tDnD_whileDrag. So to use the default, simply customise this CSS class in your * stylesheet. * onDrop * Pass a function that will be called when the row is dropped. The function takes 2 parameters: the table * and the row that was dropped. You can work out the new order of the rows by using * table.rows. * onDragStart * Pass a function that will be called when the user starts dragging. The function takes 2 parameters: the * table and the row which the user has started to drag. * onAllowDrop * Pass a function that will be called as a row is over another row. If the function returns true, allow * dropping on that row, otherwise not. The function takes 2 parameters: the dragged row and the row under * the cursor. It returns a boolean: true allows the drop, false doesn't allow it. * scrollAmount * This is the number of pixels to scroll if the user moves the mouse cursor to the top or bottom of the * window. The page should automatically scroll up or down as appropriate (tested in IE6, IE7, Safari, FF2, * FF3 beta * dragHandle * This is a jQuery mach string for one or more cells in each row that is draggable. If you * specify this, then you are responsible for setting cursor: move in the CSS and only these cells * will have the drag behaviour. If you do not specify a dragHandle, then you get the old behaviour where * the whole row is draggable. * * Other ways to control behaviour: * * Add class="nodrop" to any rows for which you don't want to allow dropping, and class="nodrag" to any rows * that you don't want to be draggable. * * Inside the onDrop method you can also call $.tableDnD.serialize() this returns a string of the form * []=&[]= so that you can send this back to the server. The table must have * an ID as must all the rows. * * Other methods: * * $("...").tableDnDUpdate() * Will update all the matching tables, that is it will reapply the mousedown method to the rows (or handle cells). * This is useful if you have updated the table rows using Ajax and you want to make the table draggable again. * The table maintains the original configuration (so you don't have to specify it again). * * $("...").tableDnDSerialize() * Will serialize and return the serialized string as above, but for each of the matching tables--so it can be * called from anywhere and isn't dependent on the currentTable being set up correctly before calling * * Known problems: * - Auto-scoll has some problems with IE7 (it scrolls even when it shouldn't), work-around: set scrollAmount to 0 * * Version 0.2: 2008-02-20 First public version * Version 0.3: 2008-02-07 Added onDragStart option * Made the scroll amount configurable (default is 5 as before) * Version 0.4: 2008-03-15 Changed the noDrag/noDrop attributes to nodrag/nodrop classes * Added onAllowDrop to control dropping * Fixed a bug which meant that you couldn't set the scroll amount in both directions * Added serialize method * Version 0.5: 2008-05-16 Changed so that if you specify a dragHandle class it doesn't make the whole row * draggable * Improved the serialize method to use a default (and settable) regular expression. * Added tableDnDupate() and tableDnDSerialize() to be called when you are outside the table * Version 0.6: 2011-12-02 Added support for touch devices * Version 0.7 2012-04-09 Now works with jQuery 1.7 and supports touch, tidied up tabs and spaces */ !function ($, window, document, undefined) { // Determine if this is a touch device var hasTouch = 'ontouchstart' in document.documentElement, startEvent = 'touchstart mousedown', moveEvent = 'touchmove mousemove', endEvent = 'touchend mouseup'; // If we're on a touch device, then wire up the events // see http://stackoverflow.com/a/8456194/1316086 hasTouch && $.each("touchstart touchmove touchend".split(" "), function(i, name) { $.event.fixHooks[name] = $.event.mouseHooks; }); $(document).ready(function () { function parseStyle(css) { var objMap = {}, parts = css.match(/([^;:]+)/g) || []; while (parts.length) objMap[parts.shift()] = parts.shift().trim(); return objMap; } $('table').each(function () { if ($(this).data('table') == 'dnd') { $(this).tableDnD({ onDragStyle: $(this).data('ondragstyle') && parseStyle($(this).data('ondragstyle')) || null, onDropStyle: $(this).data('ondropstyle') && parseStyle($(this).data('ondropstyle')) || null, onDragClass: $(this).data('ondragclass') == undefined && "tDnD_whileDrag" || $(this).data('ondragclass'), onDrop: $(this).data('ondrop') && new Function('table', 'row', $(this).data('ondrop')), // 'return eval("'+$(this).data('ondrop')+'");') || null, onDragStart: $(this).data('ondragstart') && new Function('table', 'row' ,$(this).data('ondragstart')), // 'return eval("'+$(this).data('ondragstart')+'");') || null, scrollAmount: $(this).data('scrollamount') || 5, sensitivity: $(this).data('sensitivity') || 10, hierarchyLevel: $(this).data('hierarchylevel') || 0, indentArtifact: $(this).data('indentartifact') || '
 
', autoWidthAdjust: $(this).data('autowidthadjust') || true, autoCleanRelations: $(this).data('autocleanrelations') || true, jsonPretifySeparator: $(this).data('jsonpretifyseparator') || '\t', serializeRegexp: $(this).data('serializeregexp') && new RegExp($(this).data('serializeregexp')) || /[^\-]*$/, serializeParamName: $(this).data('serializeparamname') || false, dragHandle: $(this).data('draghandle') || null }); } }); }); jQuery.tableDnD = { /** Keep hold of the current table being dragged */ currentTable: null, /** Keep hold of the current drag object if any */ dragObject: null, /** The current mouse offset */ mouseOffset: null, /** Remember the old value of X and Y so that we don't do too much processing */ oldX: 0, oldY: 0, /** Actually build the structure */ build: function(options) { // Set up the defaults if any this.each(function() { // This is bound to each matching table, set up the defaults and override with user options this.tableDnDConfig = $.extend({ onDragStyle: null, onDropStyle: null, // Add in the default class for whileDragging onDragClass: "tDnD_whileDrag", onDrop: null, onDragStart: null, scrollAmount: 5, /** Sensitivity setting will throttle the trigger rate for movement detection */ sensitivity: 10, /** Hierarchy level to support parent child. 0 switches this functionality off */ hierarchyLevel: 0, /** The html artifact to prepend the first cell with as indentation */ indentArtifact: '
 
', /** Automatically adjust width of first cell */ autoWidthAdjust: true, /** Automatic clean-up to ensure relationship integrity */ autoCleanRelations: true, /** Specify a number (4) as number of spaces or any indent string for JSON.stringify */ jsonPretifySeparator: '\t', /** The regular expression to use to trim row IDs */ serializeRegexp: /[^\-]*$/, /** If you want to specify another parameter name instead of the table ID */ serializeParamName: false, /** If you give the name of a class here, then only Cells with this class will be draggable */ dragHandle: null }, options || {}); // Now make the rows draggable $.tableDnD.makeDraggable(this); // Prepare hierarchy support this.tableDnDConfig.hierarchyLevel && $.tableDnD.makeIndented(this); }); // Don't break the chain return this; }, makeIndented: function (table) { var config = table.tableDnDConfig, rows = table.rows, firstCell = $(rows).first().find('td:first')[0], indentLevel = 0, cellWidth = 0, longestCell, tableStyle; if ($(table).hasClass('indtd')) return null; tableStyle = $(table).addClass('indtd').attr('style'); $(table).css({whiteSpace: "nowrap"}); for (var w = 0; w < rows.length; w++) { if (cellWidth < $(rows[w]).find('td:first').text().length) { cellWidth = $(rows[w]).find('td:first').text().length; longestCell = w; } } $(firstCell).css({width: 'auto'}); for (w = 0; w < config.hierarchyLevel; w++) $(rows[longestCell]).find('td:first').prepend(config.indentArtifact); firstCell && $(firstCell).css({width: firstCell.offsetWidth}); tableStyle && $(table).css(tableStyle); for (w = 0; w < config.hierarchyLevel; w++) $(rows[longestCell]).find('td:first').children(':first').remove(); config.hierarchyLevel && $(rows).each(function () { indentLevel = $(this).data('level') || 0; indentLevel <= config.hierarchyLevel && $(this).data('level', indentLevel) || $(this).data('level', 0); for (var i = 0; i < $(this).data('level'); i++) $(this).find('td:first').prepend(config.indentArtifact); }); return this; }, /** This function makes all the rows on the table draggable apart from those marked as "NoDrag" */ makeDraggable: function(table) { var config = table.tableDnDConfig; config.dragHandle // We only need to add the event to the specified cells && $(config.dragHandle, table).each(function() { // The cell is bound to "this" $(this).bind(startEvent, function(e) { $.tableDnD.initialiseDrag($(this).parents('tr')[0], table, this, e, config); return false; }); }) // For backwards compatibility, we add the event to the whole row // get all the rows as a wrapped set || $(table.rows).each(function() { // Iterate through each row, the row is bound to "this" if (! $(this).hasClass("nodrag")) { $(this).bind(startEvent, function(e) { if (e.target.tagName == "TD") { $.tableDnD.initialiseDrag(this, table, this, e, config); return false; } }).css("cursor", "move"); // Store the tableDnD object } else { $(this).css("cursor", ""); // Remove the cursor if we don't have the nodrag class } }); }, currentOrder: function() { var rows = this.currentTable.rows; return $.map(rows, function (val) { return ($(val).data('level') + val.id).replace(/\s/g, ''); }).join(''); }, initialiseDrag: function(dragObject, table, target, e, config) { this.dragObject = dragObject; this.currentTable = table; this.mouseOffset = this.getMouseOffset(target, e); this.originalOrder = this.currentOrder(); // Now we need to capture the mouse up and mouse move event // We can use bind so that we don't interfere with other event handlers $(document) .bind(moveEvent, this.mousemove) .bind(endEvent, this.mouseup); // Call the onDragStart method if there is one config.onDragStart && config.onDragStart(table, target); }, updateTables: function() { this.each(function() { // this is now bound to each matching table if (this.tableDnDConfig) $.tableDnD.makeDraggable(this); }); }, /** Get the mouse coordinates from the event (allowing for browser differences) */ mouseCoords: function(e) { if (e.originalEvent.changedTouches) return { x: e.originalEvent.changedTouches[0].clientX, y: e.originalEvent.changedTouches[0].clientY }; if(e.pageX || e.pageY) return { x: e.pageX, y: e.pageY }; return { x: e.clientX + document.body.scrollLeft - document.body.clientLeft, y: e.clientY + document.body.scrollTop - document.body.clientTop }; }, /** Given a target element and a mouse eent, get the mouse offset from that element. To do this we need the element's position and the mouse position */ getMouseOffset: function(target, e) { var mousePos, docPos; e = e || window.event; docPos = this.getPosition(target); mousePos = this.mouseCoords(e); return { x: mousePos.x - docPos.x, y: mousePos.y - docPos.y }; }, /** Get the position of an element by going up the DOM tree and adding up all the offsets */ getPosition: function(element) { var left = 0, top = 0; // Safari fix -- thanks to Luis Chato for this! // Safari 2 doesn't correctly grab the offsetTop of a table row // this is detailed here: // http://jacob.peargrove.com/blog/2006/technical/table-row-offsettop-bug-in-safari/ // the solution is likewise noted there, grab the offset of a table cell in the row - the firstChild. // note that firefox will return a text node as a first child, so designing a more thorough // solution may need to take that into account, for now this seems to work in firefox, safari, ie if (element.offsetHeight == 0) element = element.firstChild; // a table cell while (element.offsetParent) { left += element.offsetLeft; top += element.offsetTop; element = element.offsetParent; } left += element.offsetLeft; top += element.offsetTop; return { x: left, y: top }; }, autoScroll: function (mousePos) { var config = this.currentTable.tableDnDConfig, yOffset = window.pageYOffset, windowHeight = window.innerHeight ? window.innerHeight : document.documentElement.clientHeight ? document.documentElement.clientHeight : document.body.clientHeight; // Windows version // yOffset=document.body.scrollTop; if (document.all) if (typeof document.compatMode != 'undefined' && document.compatMode != 'BackCompat') yOffset = document.documentElement.scrollTop; else if (typeof document.body != 'undefined') yOffset = document.body.scrollTop; mousePos.y - yOffset < config.scrollAmount && window.scrollBy(0, - config.scrollAmount) || windowHeight - (mousePos.y - yOffset) < config.scrollAmount && window.scrollBy(0, config.scrollAmount); }, moveVerticle: function (moving, currentRow) { if (0 != moving.vertical // If we're over a row then move the dragged row to there so that the user sees the // effect dynamically && currentRow && this.dragObject != currentRow && this.dragObject.parentNode == currentRow.parentNode) 0 > moving.vertical && this.dragObject.parentNode.insertBefore(this.dragObject, currentRow.nextSibling) || 0 < moving.vertical && this.dragObject.parentNode.insertBefore(this.dragObject, currentRow); }, moveHorizontal: function (moving, currentRow) { var config = this.currentTable.tableDnDConfig, currentLevel; if (!config.hierarchyLevel || 0 == moving.horizontal // We only care if moving left or right on the current row || !currentRow || this.dragObject != currentRow) return null; currentLevel = $(currentRow).data('level'); 0 < moving.horizontal && currentLevel > 0 && $(currentRow).find('td:first').children(':first').remove() && $(currentRow).data('level', --currentLevel); 0 > moving.horizontal && currentLevel < config.hierarchyLevel && $(currentRow).prev().data('level') >= currentLevel && $(currentRow).children(':first').prepend(config.indentArtifact) && $(currentRow).data('level', ++currentLevel); }, mousemove: function(e) { var dragObj = $($.tableDnD.dragObject), config = $.tableDnD.currentTable.tableDnDConfig, currentRow, mousePos, moving, x, y; e && e.preventDefault(); if (!$.tableDnD.dragObject) return false; // prevent touch device screen scrolling e.type == 'touchmove' && event.preventDefault(); // TODO verify this is event and not really e // update the style to show we're dragging config.onDragClass && dragObj.addClass(config.onDragClass) || dragObj.css(config.onDragStyle); mousePos = $.tableDnD.mouseCoords(e); x = mousePos.x - $.tableDnD.mouseOffset.x; y = mousePos.y - $.tableDnD.mouseOffset.y; // auto scroll the window $.tableDnD.autoScroll(mousePos); currentRow = $.tableDnD.findDropTargetRow(dragObj, y); moving = $.tableDnD.findDragDirection(x, y); $.tableDnD.moveVerticle(moving, currentRow); $.tableDnD.moveHorizontal(moving, currentRow); return false; }, findDragDirection: function (x,y) { var sensitivity = this.currentTable.tableDnDConfig.sensitivity, oldX = this.oldX, oldY = this.oldY, xMin = oldX - sensitivity, xMax = oldX + sensitivity, yMin = oldY - sensitivity, yMax = oldY + sensitivity, moving = { horizontal: x >= xMin && x <= xMax ? 0 : x > oldX ? -1 : 1, vertical : y >= yMin && y <= yMax ? 0 : y > oldY ? -1 : 1 }; // update the old value if (moving.horizontal != 0) this.oldX = x; if (moving.vertical != 0) this.oldY = y; return moving; }, /** We're only worried about the y position really, because we can only move rows up and down */ findDropTargetRow: function(draggedRow, y) { var rowHeight = 0, rows = this.currentTable.rows, config = this.currentTable.tableDnDConfig, rowY = 0, row = null; for (var i = 0; i < rows.length; i++) { row = rows[i]; rowY = this.getPosition(row).y; rowHeight = parseInt(row.offsetHeight) / 2; if (row.offsetHeight == 0) { rowY = this.getPosition(row.firstChild).y; rowHeight = parseInt(row.firstChild.offsetHeight) / 2; } // Because we always have to insert before, we need to offset the height a bit if (y > (rowY - rowHeight) && y < (rowY + rowHeight)) // that's the row we're over // If it's the same as the current row, ignore it if (draggedRow.is(row) || (config.onAllowDrop && !config.onAllowDrop(draggedRow, row)) // If a row has nodrop class, then don't allow dropping (inspired by John Tarr and Famic) || $(row).hasClass("nodrop")) return null; else return row; } return null; }, processMouseup: function() { if (!this.currentTable || !this.dragObject) return null; var config = this.currentTable.tableDnDConfig, droppedRow = this.dragObject, parentLevel = 0, myLevel = 0; // Unbind the event handlers $(document) .unbind(moveEvent, this.mousemove) .unbind(endEvent, this.mouseup); config.hierarchyLevel && config.autoCleanRelations && $(this.currentTable.rows).first().find('td:first').children().each(function () { myLevel = $(this).parents('tr:first').data('level'); myLevel && $(this).parents('tr:first').data('level', --myLevel) && $(this).remove(); }) && config.hierarchyLevel > 1 && $(this.currentTable.rows).each(function () { myLevel = $(this).data('level'); if (myLevel > 1) { parentLevel = $(this).prev().data('level'); while (myLevel > parentLevel + 1) { $(this).find('td:first').children(':first').remove(); $(this).data('level', --myLevel); } } }); // If we have a dragObject, then we need to release it, // The row will already have been moved to the right place so we just reset stuff config.onDragClass && $(droppedRow).removeClass(config.onDragClass) || $(droppedRow).css(config.onDropStyle); this.dragObject = null; // Call the onDrop method if there is one config.onDrop && this.originalOrder != this.currentOrder() && $(droppedRow).hide().fadeIn('fast') && config.onDrop(this.currentTable, droppedRow); this.currentTable = null; // let go of the table too }, mouseup: function(e) { e && e.preventDefault(); $.tableDnD.processMouseup(); return false; }, jsonize: function(pretify) { var table = this.currentTable; if (pretify) return JSON.stringify( this.tableData(table), null, table.tableDnDConfig.jsonPretifySeparator ); return JSON.stringify(this.tableData(table)); }, serialize: function() { return $.param(this.tableData(this.currentTable)); }, serializeTable: function(table) { var result = ""; var paramName = table.tableDnDConfig.serializeParamName || table.id; var rows = table.rows; for (var i=0; i 0) result += "&"; var rowId = rows[i].id; if (rowId && table.tableDnDConfig && table.tableDnDConfig.serializeRegexp) { rowId = rowId.match(table.tableDnDConfig.serializeRegexp)[0]; result += paramName + '[]=' + rowId; } } return result; }, serializeTables: function() { var result = []; $('table').each(function() { this.id && result.push($.param(this.tableData(this))); }); return result.join('&'); }, tableData: function (table) { var config = table.tableDnDConfig, previousIDs = [], currentLevel = 0, indentLevel = 0, rowID = null, data = {}, getSerializeRegexp, paramName, currentID, rows; if (!table) table = this.currentTable; if (!table || !table.id || !table.rows || !table.rows.length) return {error: { code: 500, message: "Not a valid table, no serializable unique id provided."}}; rows = config.autoCleanRelations && table.rows || $.makeArray(table.rows); paramName = config.serializeParamName || table.id; currentID = paramName; getSerializeRegexp = function (rowId) { if (rowId && config && config.serializeRegexp) return rowId.match(config.serializeRegexp)[0]; return rowId; }; data[currentID] = []; !config.autoCleanRelations && $(rows[0]).data('level') && rows.unshift({id: 'undefined'}); for (var i=0; i < rows.length; i++) { if (config.hierarchyLevel) { indentLevel = $(rows[i]).data('level') || 0; if (indentLevel == 0) { currentID = paramName; previousIDs = []; } else if (indentLevel > currentLevel) { previousIDs.push([currentID, currentLevel]); currentID = getSerializeRegexp(rows[i-1].id); } else if (indentLevel < currentLevel) { for (var h = 0; h < previousIDs.length; h++) { if (previousIDs[h][1] == indentLevel) currentID = previousIDs[h][0]; if (previousIDs[h][1] >= currentLevel) previousIDs[h][1] = 0; } } currentLevel = indentLevel; if (!$.isArray(data[currentID])) data[currentID] = []; rowID = getSerializeRegexp(rows[i].id); rowID && data[currentID].push(rowID); } else { rowID = getSerializeRegexp(rows[i].id); rowID && data[currentID].push(rowID); } } return data; } }; jQuery.fn.extend( { tableDnD : $.tableDnD.build, tableDnDUpdate : $.tableDnD.updateTables, tableDnDSerialize : $.proxy($.tableDnD.serialize, $.tableDnD), tableDnDSerializeAll : $.tableDnD.serializeTables, tableDnDData : $.proxy($.tableDnD.tableData, $.tableDnD) } ); }(jQuery, window, window.document);