From d0d2a7fe47fb90b92af20967a8d7433f1a3b476e Mon Sep 17 00:00:00 2001 From: azivner Date: Thu, 8 Nov 2018 10:11:00 +0100 Subject: [PATCH] store images in notes, basic structure --- db/migrations/0115__images_in_notes.sql | 56 + src/entities/entity_constructor.js | 4 + src/entities/link.js | 51 + src/entities/note.js | 7 + src/public/images/icons/image-16.png | Bin 0 -> 392 bytes src/public/javascripts/services/export.js | 4 +- .../javascripts/services/note_detail.js | 5 +- .../javascripts/services/note_detail_image.js | 52 + src/public/javascripts/services/note_type.js | 7 +- .../fancytree/jquery.fancytree-all-deps.js | 11240 ++++++++++++++++ .../jquery.fancytree-all-deps.min.js.map | 1 + src/public/stylesheets/style.css | 4 + src/routes/api/notes.js | 4 +- src/services/app_info.js | 4 +- src/services/consistency_checks.js | 8 +- src/services/sync.js | 3 +- src/services/sync_table.js | 7 +- src/services/sync_update.js | 17 + src/views/details/detail.ejs | 28 + src/views/details/file.ejs | 23 + src/views/details/relation_map.ejs | 25 + src/views/details/render.ejs | 9 + src/views/details/search.ejs | 39 + src/views/index.ejs | 132 +- 24 files changed, 11589 insertions(+), 141 deletions(-) create mode 100644 db/migrations/0115__images_in_notes.sql create mode 100644 src/entities/link.js create mode 100644 src/public/images/icons/image-16.png create mode 100644 src/public/javascripts/services/note_detail_image.js create mode 100644 src/public/libraries/fancytree/jquery.fancytree-all-deps.js create mode 100644 src/public/libraries/fancytree/jquery.fancytree-all-deps.min.js.map create mode 100644 src/views/details/detail.ejs create mode 100644 src/views/details/file.ejs create mode 100644 src/views/details/relation_map.ejs create mode 100644 src/views/details/render.ejs create mode 100644 src/views/details/search.ejs diff --git a/db/migrations/0115__images_in_notes.sql b/db/migrations/0115__images_in_notes.sql new file mode 100644 index 000000000..3d7d151b5 --- /dev/null +++ b/db/migrations/0115__images_in_notes.sql @@ -0,0 +1,56 @@ +-- allow null for note content (for deleted notes) +CREATE TABLE IF NOT EXISTS "notes_mig" ( + `noteId` TEXT NOT NULL, + `title` TEXT NOT NULL DEFAULT "note", + `content` TEXT NULL DEFAULT NULL, + `isProtected` INT NOT NULL DEFAULT 0, + `type` TEXT NOT NULL DEFAULT 'text', + `mime` TEXT NOT NULL DEFAULT 'text/html', + `hash` TEXT DEFAULT "" NOT NULL, + `isDeleted` INT NOT NULL DEFAULT 0, + `dateCreated` TEXT NOT NULL, + `dateModified` TEXT NOT NULL, + PRIMARY KEY(`noteId`) +); + +INSERT INTO notes_mig (noteId, title, content, isProtected, isDeleted, dateCreated, dateModified, type, mime, hash) +SELECT noteId, title, content, isProtected, isDeleted, dateCreated, dateModified, type, mime, hash FROM notes; + +DROP TABLE notes; + +ALTER TABLE notes_mig RENAME TO notes; + +CREATE TABLE "links" ( + `linkId` TEXT NOT NULL, + `noteId` TEXT NOT NULL, + `targetNoteId` TEXT NOT NULL, + `type` TEXT NOT NULL, + `isDeleted` INTEGER NOT NULL DEFAULT 0, + `dateCreated` TEXT NOT NULL, + `dateModified` TEXT NOT NULL, + PRIMARY KEY(`linkId`) +); + +INSERT INTO links (linkId, noteId, targetNoteId, type, isDeleted, dateCreated, dateModified) + SELECT 'L' || SUBSTR(noteImageId, 1), noteId, imageId, 'image', isDeleted, dateCreated, dateModified FROM note_images; + +INSERT INTO branches (branchId, noteId, parentNoteId, notePosition, prefix, isExpanded, isDeleted, dateModified, hash, dateCreated) + SELECT 'B' || SUBSTR(noteImageId, 1), imageId, noteId, 100, '', 0, isDeleted, dateModified, hash, dateCreated FROM note_images; + +DROP TABLE note_images; + +INSERT INTO notes (noteId, title, content, isProtected, isDeleted, dateCreated, dateModified, type, mime, hash) + SELECT imageId, name, data, 0, isDeleted, dateCreated, dateModified, 'image', 'image/' || format, hash FROM images; + +DROP TABLE images; + +UPDATE sync SET entityName = 'notes' WHERE entityName = 'images'; + +INSERT INTO sync (entityName, entityId, sourceId, syncDate) +SELECT 'links', 'L' || SUBSTR(entityId, 1), sourceId, syncDate FROM sync WHERE entityName = 'note_images'; + +INSERT INTO sync (entityName, entityId, sourceId, syncDate) + SELECT 'branches', 'B' || SUBSTR(entityId, 1), sourceId, syncDate FROM sync WHERE entityName = 'note_images'; + +DELETE FROM sync WHERE entityName = 'note_images'; +DELETE FROM sync WHERE entityName = 'images'; \ No newline at end of file diff --git a/src/entities/entity_constructor.js b/src/entities/entity_constructor.js index b798423e3..a890899ce 100644 --- a/src/entities/entity_constructor.js +++ b/src/entities/entity_constructor.js @@ -2,6 +2,7 @@ const Note = require('../entities/note'); const NoteRevision = require('../entities/note_revision'); const Image = require('../entities/image'); const NoteImage = require('../entities/note_image'); +const Link = require('../entities/link'); const Branch = require('../entities/branch'); const Attribute = require('../entities/attribute'); const RecentNote = require('../entities/recent_note'); @@ -38,6 +39,9 @@ function createEntityFromRow(row) { else if (row.noteRevisionId) { entity = new NoteRevision(row); } + else if (row.linkId) { + entity = new Link(row); + } else if (row.noteImageId) { entity = new NoteImage(row); } diff --git a/src/entities/link.js b/src/entities/link.js new file mode 100644 index 000000000..32271386e --- /dev/null +++ b/src/entities/link.js @@ -0,0 +1,51 @@ +"use strict"; + +const Entity = require('./entity'); +const repository = require('../services/repository'); +const dateUtils = require('../services/date_utils'); + +/** + * This class represents link from one note to another in the form of hyperlink or image reference. Note that + * this is different concept than attribute/relation. + * + * @param {string} linkId + * @param {string} noteId + * @param {string} targetNoteId + * @param {string} type + * @param {boolean} isDeleted + * @param {string} dateModified + * @param {string} dateCreated + * + * @extends Entity + */ +class Link extends Entity { + static get entityName() { return "links"; } + static get primaryKeyName() { return "linkId"; } + static get hashedProperties() { return ["linkId", "noteId", "targetNoteId", "type", "isDeleted", "dateCreated", "dateModified"]; } + + async getNote() { + return await repository.getEntity("SELECT * FROM notes WHERE noteId = ?", [this.noteId]); + } + + async getTargetNote() { + return await repository.getEntity("SELECT * FROM notes WHERE noteId = ?", [this.targetNoteId]); + } + + beforeSaving() { + if (!this.isDeleted) { + this.isDeleted = false; + } + + if (!this.dateCreated) { + this.dateCreated = dateUtils.nowDate(); + } + + super.beforeSaving(); + + if (this.isChanged) { + this.dateModified = dateUtils.nowDate(); + } + } +} + +module.exports = Link; \ No newline at end of file diff --git a/src/entities/note.js b/src/entities/note.js index ffa4b67e9..20ef4943f 100644 --- a/src/entities/note.js +++ b/src/entities/note.js @@ -487,6 +487,13 @@ class Note extends Entity { return await repository.getEntities("SELECT * FROM note_images WHERE noteId = ? AND isDeleted = 0", [this.noteId]); } + /** + * @returns {Promise} + */ + async getLinks() { + return await repository.getEntities("SELECT * FROM links WHERE noteId = ? AND isDeleted = 0", [this.noteId]); + } + /** * @returns {Promise} */ diff --git a/src/public/images/icons/image-16.png b/src/public/images/icons/image-16.png new file mode 100644 index 0000000000000000000000000000000000000000..20dd4cdf51d31c3d6dc0391ce59eefb6d03ef122 GIT binary patch literal 392 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Y)RhkE)4%caKYZ?lYt_f1s;*b zKpodXn9)gNb_Gz7y~NYkmHiPn2M3E)r@zZh1_nk^PZ!4!i_=Rl@78k(lwf^u{!E9< zo~Cq*sJYj4g{Gz}2u&Bi&=t_tHqArBG{a?Cf#!^WOC4;EN>as6pXaAMOF750anj_? zpWpAjw*OmP^=JQ*qx*X#d5-On4GP)VXA*7GoqFft?q&z`WgojXui4SO*WTj(akD9l z0=Tjb`pab&EO%HiFL&#eq$14|hb3O6ES-8lbJ-zR-7HSIL$ShIx-4poi+TGU?T&GW zNl(f?lvh)!QgMj$mw~uu?DOl}g(MCXm%Ptiq2F?%@}gwQ6XlphnfbiM**gw|UzAj_ zIKrz|^n~a8PmxO}OgqnQmpJO)c-X5f--tbQO#)B;#_wyHiX@dIu6!uaQ=a!vVtMX6 jKiSzgejMHXvPZrm&)((Ky~<<2Fk|p^^>bP0l+XkKz~P@9 literal 0 HcmV?d00001 diff --git a/src/public/javascripts/services/export.js b/src/public/javascripts/services/export.js index bba4167f4..a459e2d6c 100644 --- a/src/public/javascripts/services/export.js +++ b/src/public/javascripts/services/export.js @@ -31,8 +31,8 @@ $("#import-upload").change(async function() { data: formData, dataType: 'json', type: 'POST', - contentType: false, // NEEDED, DON'T OMIT THIS - processData: false, // NEEDED, DON'T OMIT THIS + contentType: false, // NEEDED, DON'T REMOVE THIS + processData: false, // NEEDED, DON'T REMOVE THIS }) .fail((xhr, status, error) => alert('Import error: ' + xhr.responseText)) .done(async note => { diff --git a/src/public/javascripts/services/note_detail.js b/src/public/javascripts/services/note_detail.js index 3c8f0a09e..b308b2d11 100644 --- a/src/public/javascripts/services/note_detail.js +++ b/src/public/javascripts/services/note_detail.js @@ -238,7 +238,10 @@ async function loadNoteDetail(noteId) { async function showChildrenOverview() { const note = getCurrentNote(); const attributes = await attributePromise; - const hideChildrenOverview = attributes.some(attr => attr.type === 'label' && attr.name === 'hideChildrenOverview') || note.type === 'relation-map'; + const hideChildrenOverview = attributes.some(attr => attr.type === 'label' && attr.name === 'hideChildrenOverview') + || note.type === 'relation-map' + || note.type === 'image' + || note.type === 'file'; if (hideChildrenOverview) { $childrenOverview.hide(); diff --git a/src/public/javascripts/services/note_detail_image.js b/src/public/javascripts/services/note_detail_image.js new file mode 100644 index 000000000..c71443220 --- /dev/null +++ b/src/public/javascripts/services/note_detail_image.js @@ -0,0 +1,52 @@ +import utils from "./utils.js"; +import server from "./server.js"; +import protectedSessionHolder from "./protected_session_holder.js"; +import noteDetailService from "./note_detail.js"; + +const $noteDetailFile = $('#note-detail-file'); + +const $fileFileName = $("#file-filename"); +const $fileFileType = $("#file-filetype"); +const $fileFileSize = $("#file-filesize"); +const $fileDownload = $("#file-download"); +const $fileOpen = $("#file-open"); + +async function show() { + const currentNote = noteDetailService.getCurrentNote(); + + const attributes = await server.get('notes/' + currentNote.noteId + '/attributes'); + const attributeMap = utils.toObject(attributes, l => [l.name, l.value]); + + $noteDetailFile.show(); + + $fileFileName.text(attributeMap.originalFileName); + $fileFileSize.text(attributeMap.fileSize + " bytes"); + $fileFileType.text(currentNote.mime); +} + +$fileDownload.click(() => utils.download(getFileUrl())); + +$fileOpen.click(() => { + if (utils.isElectron()) { + const open = require("open"); + + open(getFileUrl()); + } + else { + window.location.href = getFileUrl(); + } +}); + +function getFileUrl() { + // electron needs absolute URL so we extract current host, port, protocol + return utils.getHost() + "/api/notes/" + noteDetailService.getCurrentNoteId() + + "/download?protectedSessionId=" + encodeURIComponent(protectedSessionHolder.getProtectedSessionId()); +} + +export default { + show, + getContent: () => null, + focus: () => null, + onNoteChange: () => null, + cleanup: () => null +} \ No newline at end of file diff --git a/src/public/javascripts/services/note_type.js b/src/public/javascripts/services/note_type.js index 176d16ffc..d629c457e 100644 --- a/src/public/javascripts/services/note_type.js +++ b/src/public/javascripts/services/note_type.js @@ -81,7 +81,10 @@ function NoteTypeModel() { return 'Relation Map'; } else if (type === 'search') { - // ignore and do nothing, "type" will be hidden since it's not possible to switch to and from search + return 'Search note' + } + else if (type === 'image') { + return 'Image' } else { infoService.throwError('Unrecognized type: ' + type); @@ -89,7 +92,7 @@ function NoteTypeModel() { }; this.isDisabled = function() { - return self.type() === "file"; + return ["file", "image", "search"].includes(self.type()); }; async function save() { diff --git a/src/public/libraries/fancytree/jquery.fancytree-all-deps.js b/src/public/libraries/fancytree/jquery.fancytree-all-deps.js new file mode 100644 index 000000000..af626e1be --- /dev/null +++ b/src/public/libraries/fancytree/jquery.fancytree-all-deps.js @@ -0,0 +1,11240 @@ +/*! jQuery Fancytree Plugin - 2.30.0 - 2018-09-02T15:42:49Z + * https://github.com/mar10/fancytree + * Copyright (c) 2018 Martin Wendt; Licensed MIT + */ +/*! jQuery UI - v1.12.1 - 2018-05-20 +* http://jqueryui.com +* Includes: widget.js, position.js, keycode.js, scroll-parent.js, unique-id.js +* Copyright jQuery Foundation and other contributors; Licensed MIT */ + +/* + NOTE: Original jQuery UI wrapper was replaced with a simple IIFE. + See README-Fancytree.md +*/ +(function( $ ) { + +$.ui = $.ui || {}; + +var version = $.ui.version = "1.12.1"; + + +/*! + * jQuery UI Widget 1.12.1 + * http://jqueryui.com + * + * Copyright jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + */ + +//>>label: Widget +//>>group: Core +//>>description: Provides a factory for creating stateful widgets with a common API. +//>>docs: http://api.jqueryui.com/jQuery.widget/ +//>>demos: http://jqueryui.com/widget/ + + + +var widgetUuid = 0; +var widgetSlice = Array.prototype.slice; + +$.cleanData = ( function( orig ) { + return function( elems ) { + var events, elem, i; + for ( i = 0; ( elem = elems[ i ] ) != null; i++ ) { + try { + + // Only trigger remove when necessary to save time + events = $._data( elem, "events" ); + if ( events && events.remove ) { + $( elem ).triggerHandler( "remove" ); + } + + // Http://bugs.jquery.com/ticket/8235 + } catch ( e ) {} + } + orig( elems ); + }; +} )( $.cleanData ); + +$.widget = function( name, base, prototype ) { + var existingConstructor, constructor, basePrototype; + + // ProxiedPrototype allows the provided prototype to remain unmodified + // so that it can be used as a mixin for multiple widgets (#8876) + var proxiedPrototype = {}; + + var namespace = name.split( "." )[ 0 ]; + name = name.split( "." )[ 1 ]; + var fullName = namespace + "-" + name; + + if ( !prototype ) { + prototype = base; + base = $.Widget; + } + + if ( $.isArray( prototype ) ) { + prototype = $.extend.apply( null, [ {} ].concat( prototype ) ); + } + + // Create selector for plugin + $.expr[ ":" ][ fullName.toLowerCase() ] = function( elem ) { + return !!$.data( elem, fullName ); + }; + + $[ namespace ] = $[ namespace ] || {}; + existingConstructor = $[ namespace ][ name ]; + constructor = $[ namespace ][ name ] = function( options, element ) { + + // Allow instantiation without "new" keyword + if ( !this._createWidget ) { + return new constructor( options, element ); + } + + // Allow instantiation without initializing for simple inheritance + // must use "new" keyword (the code above always passes args) + if ( arguments.length ) { + this._createWidget( options, element ); + } + }; + + // Extend with the existing constructor to carry over any static properties + $.extend( constructor, existingConstructor, { + version: prototype.version, + + // Copy the object used to create the prototype in case we need to + // redefine the widget later + _proto: $.extend( {}, prototype ), + + // Track widgets that inherit from this widget in case this widget is + // redefined after a widget inherits from it + _childConstructors: [] + } ); + + basePrototype = new base(); + + // We need to make the options hash a property directly on the new instance + // otherwise we'll modify the options hash on the prototype that we're + // inheriting from + basePrototype.options = $.widget.extend( {}, basePrototype.options ); + $.each( prototype, function( prop, value ) { + if ( !$.isFunction( value ) ) { + proxiedPrototype[ prop ] = value; + return; + } + proxiedPrototype[ prop ] = ( function() { + function _super() { + return base.prototype[ prop ].apply( this, arguments ); + } + + function _superApply( args ) { + return base.prototype[ prop ].apply( this, args ); + } + + return function() { + var __super = this._super; + var __superApply = this._superApply; + var returnValue; + + this._super = _super; + this._superApply = _superApply; + + returnValue = value.apply( this, arguments ); + + this._super = __super; + this._superApply = __superApply; + + return returnValue; + }; + } )(); + } ); + constructor.prototype = $.widget.extend( basePrototype, { + + // TODO: remove support for widgetEventPrefix + // always use the name + a colon as the prefix, e.g., draggable:start + // don't prefix for widgets that aren't DOM-based + widgetEventPrefix: existingConstructor ? ( basePrototype.widgetEventPrefix || name ) : name + }, proxiedPrototype, { + constructor: constructor, + namespace: namespace, + widgetName: name, + widgetFullName: fullName + } ); + + // If this widget is being redefined then we need to find all widgets that + // are inheriting from it and redefine all of them so that they inherit from + // the new version of this widget. We're essentially trying to replace one + // level in the prototype chain. + if ( existingConstructor ) { + $.each( existingConstructor._childConstructors, function( i, child ) { + var childPrototype = child.prototype; + + // Redefine the child widget using the same prototype that was + // originally used, but inherit from the new version of the base + $.widget( childPrototype.namespace + "." + childPrototype.widgetName, constructor, + child._proto ); + } ); + + // Remove the list of existing child constructors from the old constructor + // so the old child constructors can be garbage collected + delete existingConstructor._childConstructors; + } else { + base._childConstructors.push( constructor ); + } + + $.widget.bridge( name, constructor ); + + return constructor; +}; + +$.widget.extend = function( target ) { + var input = widgetSlice.call( arguments, 1 ); + var inputIndex = 0; + var inputLength = input.length; + var key; + var value; + + for ( ; inputIndex < inputLength; inputIndex++ ) { + for ( key in input[ inputIndex ] ) { + value = input[ inputIndex ][ key ]; + if ( input[ inputIndex ].hasOwnProperty( key ) && value !== undefined ) { + + // Clone objects + if ( $.isPlainObject( value ) ) { + target[ key ] = $.isPlainObject( target[ key ] ) ? + $.widget.extend( {}, target[ key ], value ) : + + // Don't extend strings, arrays, etc. with objects + $.widget.extend( {}, value ); + + // Copy everything else by reference + } else { + target[ key ] = value; + } + } + } + } + return target; +}; + +$.widget.bridge = function( name, object ) { + var fullName = object.prototype.widgetFullName || name; + $.fn[ name ] = function( options ) { + var isMethodCall = typeof options === "string"; + var args = widgetSlice.call( arguments, 1 ); + var returnValue = this; + + if ( isMethodCall ) { + + // If this is an empty collection, we need to have the instance method + // return undefined instead of the jQuery instance + if ( !this.length && options === "instance" ) { + returnValue = undefined; + } else { + this.each( function() { + var methodValue; + var instance = $.data( this, fullName ); + + if ( options === "instance" ) { + returnValue = instance; + return false; + } + + if ( !instance ) { + return $.error( "cannot call methods on " + name + + " prior to initialization; " + + "attempted to call method '" + options + "'" ); + } + + if ( !$.isFunction( instance[ options ] ) || options.charAt( 0 ) === "_" ) { + return $.error( "no such method '" + options + "' for " + name + + " widget instance" ); + } + + methodValue = instance[ options ].apply( instance, args ); + + if ( methodValue !== instance && methodValue !== undefined ) { + returnValue = methodValue && methodValue.jquery ? + returnValue.pushStack( methodValue.get() ) : + methodValue; + return false; + } + } ); + } + } else { + + // Allow multiple hashes to be passed on init + if ( args.length ) { + options = $.widget.extend.apply( null, [ options ].concat( args ) ); + } + + this.each( function() { + var instance = $.data( this, fullName ); + if ( instance ) { + instance.option( options || {} ); + if ( instance._init ) { + instance._init(); + } + } else { + $.data( this, fullName, new object( options, this ) ); + } + } ); + } + + return returnValue; + }; +}; + +$.Widget = function( /* options, element */ ) {}; +$.Widget._childConstructors = []; + +$.Widget.prototype = { + widgetName: "widget", + widgetEventPrefix: "", + defaultElement: "
", + + options: { + classes: {}, + disabled: false, + + // Callbacks + create: null + }, + + _createWidget: function( options, element ) { + element = $( element || this.defaultElement || this )[ 0 ]; + this.element = $( element ); + this.uuid = widgetUuid++; + this.eventNamespace = "." + this.widgetName + this.uuid; + + this.bindings = $(); + this.hoverable = $(); + this.focusable = $(); + this.classesElementLookup = {}; + + if ( element !== this ) { + $.data( element, this.widgetFullName, this ); + this._on( true, this.element, { + remove: function( event ) { + if ( event.target === element ) { + this.destroy(); + } + } + } ); + this.document = $( element.style ? + + // Element within the document + element.ownerDocument : + + // Element is window or document + element.document || element ); + this.window = $( this.document[ 0 ].defaultView || this.document[ 0 ].parentWindow ); + } + + this.options = $.widget.extend( {}, + this.options, + this._getCreateOptions(), + options ); + + this._create(); + + if ( this.options.disabled ) { + this._setOptionDisabled( this.options.disabled ); + } + + this._trigger( "create", null, this._getCreateEventData() ); + this._init(); + }, + + _getCreateOptions: function() { + return {}; + }, + + _getCreateEventData: $.noop, + + _create: $.noop, + + _init: $.noop, + + destroy: function() { + var that = this; + + this._destroy(); + $.each( this.classesElementLookup, function( key, value ) { + that._removeClass( value, key ); + } ); + + // We can probably remove the unbind calls in 2.0 + // all event bindings should go through this._on() + this.element + .off( this.eventNamespace ) + .removeData( this.widgetFullName ); + this.widget() + .off( this.eventNamespace ) + .removeAttr( "aria-disabled" ); + + // Clean up events and states + this.bindings.off( this.eventNamespace ); + }, + + _destroy: $.noop, + + widget: function() { + return this.element; + }, + + option: function( key, value ) { + var options = key; + var parts; + var curOption; + var i; + + if ( arguments.length === 0 ) { + + // Don't return a reference to the internal hash + return $.widget.extend( {}, this.options ); + } + + if ( typeof key === "string" ) { + + // Handle nested keys, e.g., "foo.bar" => { foo: { bar: ___ } } + options = {}; + parts = key.split( "." ); + key = parts.shift(); + if ( parts.length ) { + curOption = options[ key ] = $.widget.extend( {}, this.options[ key ] ); + for ( i = 0; i < parts.length - 1; i++ ) { + curOption[ parts[ i ] ] = curOption[ parts[ i ] ] || {}; + curOption = curOption[ parts[ i ] ]; + } + key = parts.pop(); + if ( arguments.length === 1 ) { + return curOption[ key ] === undefined ? null : curOption[ key ]; + } + curOption[ key ] = value; + } else { + if ( arguments.length === 1 ) { + return this.options[ key ] === undefined ? null : this.options[ key ]; + } + options[ key ] = value; + } + } + + this._setOptions( options ); + + return this; + }, + + _setOptions: function( options ) { + var key; + + for ( key in options ) { + this._setOption( key, options[ key ] ); + } + + return this; + }, + + _setOption: function( key, value ) { + if ( key === "classes" ) { + this._setOptionClasses( value ); + } + + this.options[ key ] = value; + + if ( key === "disabled" ) { + this._setOptionDisabled( value ); + } + + return this; + }, + + _setOptionClasses: function( value ) { + var classKey, elements, currentElements; + + for ( classKey in value ) { + currentElements = this.classesElementLookup[ classKey ]; + if ( value[ classKey ] === this.options.classes[ classKey ] || + !currentElements || + !currentElements.length ) { + continue; + } + + // We are doing this to create a new jQuery object because the _removeClass() call + // on the next line is going to destroy the reference to the current elements being + // tracked. We need to save a copy of this collection so that we can add the new classes + // below. + elements = $( currentElements.get() ); + this._removeClass( currentElements, classKey ); + + // We don't use _addClass() here, because that uses this.options.classes + // for generating the string of classes. We want to use the value passed in from + // _setOption(), this is the new value of the classes option which was passed to + // _setOption(). We pass this value directly to _classes(). + elements.addClass( this._classes( { + element: elements, + keys: classKey, + classes: value, + add: true + } ) ); + } + }, + + _setOptionDisabled: function( value ) { + this._toggleClass( this.widget(), this.widgetFullName + "-disabled", null, !!value ); + + // If the widget is becoming disabled, then nothing is interactive + if ( value ) { + this._removeClass( this.hoverable, null, "ui-state-hover" ); + this._removeClass( this.focusable, null, "ui-state-focus" ); + } + }, + + enable: function() { + return this._setOptions( { disabled: false } ); + }, + + disable: function() { + return this._setOptions( { disabled: true } ); + }, + + _classes: function( options ) { + var full = []; + var that = this; + + options = $.extend( { + element: this.element, + classes: this.options.classes || {} + }, options ); + + function processClassString( classes, checkOption ) { + var current, i; + for ( i = 0; i < classes.length; i++ ) { + current = that.classesElementLookup[ classes[ i ] ] || $(); + if ( options.add ) { + current = $( $.unique( current.get().concat( options.element.get() ) ) ); + } else { + current = $( current.not( options.element ).get() ); + } + that.classesElementLookup[ classes[ i ] ] = current; + full.push( classes[ i ] ); + if ( checkOption && options.classes[ classes[ i ] ] ) { + full.push( options.classes[ classes[ i ] ] ); + } + } + } + + this._on( options.element, { + "remove": "_untrackClassesElement" + } ); + + if ( options.keys ) { + processClassString( options.keys.match( /\S+/g ) || [], true ); + } + if ( options.extra ) { + processClassString( options.extra.match( /\S+/g ) || [] ); + } + + return full.join( " " ); + }, + + _untrackClassesElement: function( event ) { + var that = this; + $.each( that.classesElementLookup, function( key, value ) { + if ( $.inArray( event.target, value ) !== -1 ) { + that.classesElementLookup[ key ] = $( value.not( event.target ).get() ); + } + } ); + }, + + _removeClass: function( element, keys, extra ) { + return this._toggleClass( element, keys, extra, false ); + }, + + _addClass: function( element, keys, extra ) { + return this._toggleClass( element, keys, extra, true ); + }, + + _toggleClass: function( element, keys, extra, add ) { + add = ( typeof add === "boolean" ) ? add : extra; + var shift = ( typeof element === "string" || element === null ), + options = { + extra: shift ? keys : extra, + keys: shift ? element : keys, + element: shift ? this.element : element, + add: add + }; + options.element.toggleClass( this._classes( options ), add ); + return this; + }, + + _on: function( suppressDisabledCheck, element, handlers ) { + var delegateElement; + var instance = this; + + // No suppressDisabledCheck flag, shuffle arguments + if ( typeof suppressDisabledCheck !== "boolean" ) { + handlers = element; + element = suppressDisabledCheck; + suppressDisabledCheck = false; + } + + // No element argument, shuffle and use this.element + if ( !handlers ) { + handlers = element; + element = this.element; + delegateElement = this.widget(); + } else { + element = delegateElement = $( element ); + this.bindings = this.bindings.add( element ); + } + + $.each( handlers, function( event, handler ) { + function handlerProxy() { + + // Allow widgets to customize the disabled handling + // - disabled as an array instead of boolean + // - disabled class as method for disabling individual parts + if ( !suppressDisabledCheck && + ( instance.options.disabled === true || + $( this ).hasClass( "ui-state-disabled" ) ) ) { + return; + } + return ( typeof handler === "string" ? instance[ handler ] : handler ) + .apply( instance, arguments ); + } + + // Copy the guid so direct unbinding works + if ( typeof handler !== "string" ) { + handlerProxy.guid = handler.guid = + handler.guid || handlerProxy.guid || $.guid++; + } + + var match = event.match( /^([\w:-]*)\s*(.*)$/ ); + var eventName = match[ 1 ] + instance.eventNamespace; + var selector = match[ 2 ]; + + if ( selector ) { + delegateElement.on( eventName, selector, handlerProxy ); + } else { + element.on( eventName, handlerProxy ); + } + } ); + }, + + _off: function( element, eventName ) { + eventName = ( eventName || "" ).split( " " ).join( this.eventNamespace + " " ) + + this.eventNamespace; + element.off( eventName ).off( eventName ); + + // Clear the stack to avoid memory leaks (#10056) + this.bindings = $( this.bindings.not( element ).get() ); + this.focusable = $( this.focusable.not( element ).get() ); + this.hoverable = $( this.hoverable.not( element ).get() ); + }, + + _delay: function( handler, delay ) { + function handlerProxy() { + return ( typeof handler === "string" ? instance[ handler ] : handler ) + .apply( instance, arguments ); + } + var instance = this; + return setTimeout( handlerProxy, delay || 0 ); + }, + + _hoverable: function( element ) { + this.hoverable = this.hoverable.add( element ); + this._on( element, { + mouseenter: function( event ) { + this._addClass( $( event.currentTarget ), null, "ui-state-hover" ); + }, + mouseleave: function( event ) { + this._removeClass( $( event.currentTarget ), null, "ui-state-hover" ); + } + } ); + }, + + _focusable: function( element ) { + this.focusable = this.focusable.add( element ); + this._on( element, { + focusin: function( event ) { + this._addClass( $( event.currentTarget ), null, "ui-state-focus" ); + }, + focusout: function( event ) { + this._removeClass( $( event.currentTarget ), null, "ui-state-focus" ); + } + } ); + }, + + _trigger: function( type, event, data ) { + var prop, orig; + var callback = this.options[ type ]; + + data = data || {}; + event = $.Event( event ); + event.type = ( type === this.widgetEventPrefix ? + type : + this.widgetEventPrefix + type ).toLowerCase(); + + // The original event may come from any element + // so we need to reset the target on the new event + event.target = this.element[ 0 ]; + + // Copy original event properties over to the new event + orig = event.originalEvent; + if ( orig ) { + for ( prop in orig ) { + if ( !( prop in event ) ) { + event[ prop ] = orig[ prop ]; + } + } + } + + this.element.trigger( event, data ); + return !( $.isFunction( callback ) && + callback.apply( this.element[ 0 ], [ event ].concat( data ) ) === false || + event.isDefaultPrevented() ); + } +}; + +$.each( { show: "fadeIn", hide: "fadeOut" }, function( method, defaultEffect ) { + $.Widget.prototype[ "_" + method ] = function( element, options, callback ) { + if ( typeof options === "string" ) { + options = { effect: options }; + } + + var hasOptions; + var effectName = !options ? + method : + options === true || typeof options === "number" ? + defaultEffect : + options.effect || defaultEffect; + + options = options || {}; + if ( typeof options === "number" ) { + options = { duration: options }; + } + + hasOptions = !$.isEmptyObject( options ); + options.complete = callback; + + if ( options.delay ) { + element.delay( options.delay ); + } + + if ( hasOptions && $.effects && $.effects.effect[ effectName ] ) { + element[ method ]( options ); + } else if ( effectName !== method && element[ effectName ] ) { + element[ effectName ]( options.duration, options.easing, callback ); + } else { + element.queue( function( next ) { + $( this )[ method ](); + if ( callback ) { + callback.call( element[ 0 ] ); + } + next(); + } ); + } + }; +} ); + +var widget = $.widget; + + +/*! + * jQuery UI Position 1.12.1 + * http://jqueryui.com + * + * Copyright jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + * + * http://api.jqueryui.com/position/ + */ + +//>>label: Position +//>>group: Core +//>>description: Positions elements relative to other elements. +//>>docs: http://api.jqueryui.com/position/ +//>>demos: http://jqueryui.com/position/ + + +( function() { +var cachedScrollbarWidth, + max = Math.max, + abs = Math.abs, + rhorizontal = /left|center|right/, + rvertical = /top|center|bottom/, + roffset = /[\+\-]\d+(\.[\d]+)?%?/, + rposition = /^\w+/, + rpercent = /%$/, + _position = $.fn.position; + +function getOffsets( offsets, width, height ) { + return [ + parseFloat( offsets[ 0 ] ) * ( rpercent.test( offsets[ 0 ] ) ? width / 100 : 1 ), + parseFloat( offsets[ 1 ] ) * ( rpercent.test( offsets[ 1 ] ) ? height / 100 : 1 ) + ]; +} + +function parseCss( element, property ) { + return parseInt( $.css( element, property ), 10 ) || 0; +} + +function getDimensions( elem ) { + var raw = elem[ 0 ]; + if ( raw.nodeType === 9 ) { + return { + width: elem.width(), + height: elem.height(), + offset: { top: 0, left: 0 } + }; + } + if ( $.isWindow( raw ) ) { + return { + width: elem.width(), + height: elem.height(), + offset: { top: elem.scrollTop(), left: elem.scrollLeft() } + }; + } + if ( raw.preventDefault ) { + return { + width: 0, + height: 0, + offset: { top: raw.pageY, left: raw.pageX } + }; + } + return { + width: elem.outerWidth(), + height: elem.outerHeight(), + offset: elem.offset() + }; +} + +$.position = { + scrollbarWidth: function() { + if ( cachedScrollbarWidth !== undefined ) { + return cachedScrollbarWidth; + } + var w1, w2, + div = $( "
" + + "
" ), + innerDiv = div.children()[ 0 ]; + + $( "body" ).append( div ); + w1 = innerDiv.offsetWidth; + div.css( "overflow", "scroll" ); + + w2 = innerDiv.offsetWidth; + + if ( w1 === w2 ) { + w2 = div[ 0 ].clientWidth; + } + + div.remove(); + + return ( cachedScrollbarWidth = w1 - w2 ); + }, + getScrollInfo: function( within ) { + var overflowX = within.isWindow || within.isDocument ? "" : + within.element.css( "overflow-x" ), + overflowY = within.isWindow || within.isDocument ? "" : + within.element.css( "overflow-y" ), + hasOverflowX = overflowX === "scroll" || + ( overflowX === "auto" && within.width < within.element[ 0 ].scrollWidth ), + hasOverflowY = overflowY === "scroll" || + ( overflowY === "auto" && within.height < within.element[ 0 ].scrollHeight ); + return { + width: hasOverflowY ? $.position.scrollbarWidth() : 0, + height: hasOverflowX ? $.position.scrollbarWidth() : 0 + }; + }, + getWithinInfo: function( element ) { + var withinElement = $( element || window ), + isWindow = $.isWindow( withinElement[ 0 ] ), + isDocument = !!withinElement[ 0 ] && withinElement[ 0 ].nodeType === 9, + hasOffset = !isWindow && !isDocument; + return { + element: withinElement, + isWindow: isWindow, + isDocument: isDocument, + offset: hasOffset ? $( element ).offset() : { left: 0, top: 0 }, + scrollLeft: withinElement.scrollLeft(), + scrollTop: withinElement.scrollTop(), + width: withinElement.outerWidth(), + height: withinElement.outerHeight() + }; + } +}; + +$.fn.position = function( options ) { + if ( !options || !options.of ) { + return _position.apply( this, arguments ); + } + + // Make a copy, we don't want to modify arguments + options = $.extend( {}, options ); + + var atOffset, targetWidth, targetHeight, targetOffset, basePosition, dimensions, + target = $( options.of ), + within = $.position.getWithinInfo( options.within ), + scrollInfo = $.position.getScrollInfo( within ), + collision = ( options.collision || "flip" ).split( " " ), + offsets = {}; + + dimensions = getDimensions( target ); + if ( target[ 0 ].preventDefault ) { + + // Force left top to allow flipping + options.at = "left top"; + } + targetWidth = dimensions.width; + targetHeight = dimensions.height; + targetOffset = dimensions.offset; + + // Clone to reuse original targetOffset later + basePosition = $.extend( {}, targetOffset ); + + // Force my and at to have valid horizontal and vertical positions + // if a value is missing or invalid, it will be converted to center + $.each( [ "my", "at" ], function() { + var pos = ( options[ this ] || "" ).split( " " ), + horizontalOffset, + verticalOffset; + + if ( pos.length === 1 ) { + pos = rhorizontal.test( pos[ 0 ] ) ? + pos.concat( [ "center" ] ) : + rvertical.test( pos[ 0 ] ) ? + [ "center" ].concat( pos ) : + [ "center", "center" ]; + } + pos[ 0 ] = rhorizontal.test( pos[ 0 ] ) ? pos[ 0 ] : "center"; + pos[ 1 ] = rvertical.test( pos[ 1 ] ) ? pos[ 1 ] : "center"; + + // Calculate offsets + horizontalOffset = roffset.exec( pos[ 0 ] ); + verticalOffset = roffset.exec( pos[ 1 ] ); + offsets[ this ] = [ + horizontalOffset ? horizontalOffset[ 0 ] : 0, + verticalOffset ? verticalOffset[ 0 ] : 0 + ]; + + // Reduce to just the positions without the offsets + options[ this ] = [ + rposition.exec( pos[ 0 ] )[ 0 ], + rposition.exec( pos[ 1 ] )[ 0 ] + ]; + } ); + + // Normalize collision option + if ( collision.length === 1 ) { + collision[ 1 ] = collision[ 0 ]; + } + + if ( options.at[ 0 ] === "right" ) { + basePosition.left += targetWidth; + } else if ( options.at[ 0 ] === "center" ) { + basePosition.left += targetWidth / 2; + } + + if ( options.at[ 1 ] === "bottom" ) { + basePosition.top += targetHeight; + } else if ( options.at[ 1 ] === "center" ) { + basePosition.top += targetHeight / 2; + } + + atOffset = getOffsets( offsets.at, targetWidth, targetHeight ); + basePosition.left += atOffset[ 0 ]; + basePosition.top += atOffset[ 1 ]; + + return this.each( function() { + var collisionPosition, using, + elem = $( this ), + elemWidth = elem.outerWidth(), + elemHeight = elem.outerHeight(), + marginLeft = parseCss( this, "marginLeft" ), + marginTop = parseCss( this, "marginTop" ), + collisionWidth = elemWidth + marginLeft + parseCss( this, "marginRight" ) + + scrollInfo.width, + collisionHeight = elemHeight + marginTop + parseCss( this, "marginBottom" ) + + scrollInfo.height, + position = $.extend( {}, basePosition ), + myOffset = getOffsets( offsets.my, elem.outerWidth(), elem.outerHeight() ); + + if ( options.my[ 0 ] === "right" ) { + position.left -= elemWidth; + } else if ( options.my[ 0 ] === "center" ) { + position.left -= elemWidth / 2; + } + + if ( options.my[ 1 ] === "bottom" ) { + position.top -= elemHeight; + } else if ( options.my[ 1 ] === "center" ) { + position.top -= elemHeight / 2; + } + + position.left += myOffset[ 0 ]; + position.top += myOffset[ 1 ]; + + collisionPosition = { + marginLeft: marginLeft, + marginTop: marginTop + }; + + $.each( [ "left", "top" ], function( i, dir ) { + if ( $.ui.position[ collision[ i ] ] ) { + $.ui.position[ collision[ i ] ][ dir ]( position, { + targetWidth: targetWidth, + targetHeight: targetHeight, + elemWidth: elemWidth, + elemHeight: elemHeight, + collisionPosition: collisionPosition, + collisionWidth: collisionWidth, + collisionHeight: collisionHeight, + offset: [ atOffset[ 0 ] + myOffset[ 0 ], atOffset [ 1 ] + myOffset[ 1 ] ], + my: options.my, + at: options.at, + within: within, + elem: elem + } ); + } + } ); + + if ( options.using ) { + + // Adds feedback as second argument to using callback, if present + using = function( props ) { + var left = targetOffset.left - position.left, + right = left + targetWidth - elemWidth, + top = targetOffset.top - position.top, + bottom = top + targetHeight - elemHeight, + feedback = { + target: { + element: target, + left: targetOffset.left, + top: targetOffset.top, + width: targetWidth, + height: targetHeight + }, + element: { + element: elem, + left: position.left, + top: position.top, + width: elemWidth, + height: elemHeight + }, + horizontal: right < 0 ? "left" : left > 0 ? "right" : "center", + vertical: bottom < 0 ? "top" : top > 0 ? "bottom" : "middle" + }; + if ( targetWidth < elemWidth && abs( left + right ) < targetWidth ) { + feedback.horizontal = "center"; + } + if ( targetHeight < elemHeight && abs( top + bottom ) < targetHeight ) { + feedback.vertical = "middle"; + } + if ( max( abs( left ), abs( right ) ) > max( abs( top ), abs( bottom ) ) ) { + feedback.important = "horizontal"; + } else { + feedback.important = "vertical"; + } + options.using.call( this, props, feedback ); + }; + } + + elem.offset( $.extend( position, { using: using } ) ); + } ); +}; + +$.ui.position = { + fit: { + left: function( position, data ) { + var within = data.within, + withinOffset = within.isWindow ? within.scrollLeft : within.offset.left, + outerWidth = within.width, + collisionPosLeft = position.left - data.collisionPosition.marginLeft, + overLeft = withinOffset - collisionPosLeft, + overRight = collisionPosLeft + data.collisionWidth - outerWidth - withinOffset, + newOverRight; + + // Element is wider than within + if ( data.collisionWidth > outerWidth ) { + + // Element is initially over the left side of within + if ( overLeft > 0 && overRight <= 0 ) { + newOverRight = position.left + overLeft + data.collisionWidth - outerWidth - + withinOffset; + position.left += overLeft - newOverRight; + + // Element is initially over right side of within + } else if ( overRight > 0 && overLeft <= 0 ) { + position.left = withinOffset; + + // Element is initially over both left and right sides of within + } else { + if ( overLeft > overRight ) { + position.left = withinOffset + outerWidth - data.collisionWidth; + } else { + position.left = withinOffset; + } + } + + // Too far left -> align with left edge + } else if ( overLeft > 0 ) { + position.left += overLeft; + + // Too far right -> align with right edge + } else if ( overRight > 0 ) { + position.left -= overRight; + + // Adjust based on position and margin + } else { + position.left = max( position.left - collisionPosLeft, position.left ); + } + }, + top: function( position, data ) { + var within = data.within, + withinOffset = within.isWindow ? within.scrollTop : within.offset.top, + outerHeight = data.within.height, + collisionPosTop = position.top - data.collisionPosition.marginTop, + overTop = withinOffset - collisionPosTop, + overBottom = collisionPosTop + data.collisionHeight - outerHeight - withinOffset, + newOverBottom; + + // Element is taller than within + if ( data.collisionHeight > outerHeight ) { + + // Element is initially over the top of within + if ( overTop > 0 && overBottom <= 0 ) { + newOverBottom = position.top + overTop + data.collisionHeight - outerHeight - + withinOffset; + position.top += overTop - newOverBottom; + + // Element is initially over bottom of within + } else if ( overBottom > 0 && overTop <= 0 ) { + position.top = withinOffset; + + // Element is initially over both top and bottom of within + } else { + if ( overTop > overBottom ) { + position.top = withinOffset + outerHeight - data.collisionHeight; + } else { + position.top = withinOffset; + } + } + + // Too far up -> align with top + } else if ( overTop > 0 ) { + position.top += overTop; + + // Too far down -> align with bottom edge + } else if ( overBottom > 0 ) { + position.top -= overBottom; + + // Adjust based on position and margin + } else { + position.top = max( position.top - collisionPosTop, position.top ); + } + } + }, + flip: { + left: function( position, data ) { + var within = data.within, + withinOffset = within.offset.left + within.scrollLeft, + outerWidth = within.width, + offsetLeft = within.isWindow ? within.scrollLeft : within.offset.left, + collisionPosLeft = position.left - data.collisionPosition.marginLeft, + overLeft = collisionPosLeft - offsetLeft, + overRight = collisionPosLeft + data.collisionWidth - outerWidth - offsetLeft, + myOffset = data.my[ 0 ] === "left" ? + -data.elemWidth : + data.my[ 0 ] === "right" ? + data.elemWidth : + 0, + atOffset = data.at[ 0 ] === "left" ? + data.targetWidth : + data.at[ 0 ] === "right" ? + -data.targetWidth : + 0, + offset = -2 * data.offset[ 0 ], + newOverRight, + newOverLeft; + + if ( overLeft < 0 ) { + newOverRight = position.left + myOffset + atOffset + offset + data.collisionWidth - + outerWidth - withinOffset; + if ( newOverRight < 0 || newOverRight < abs( overLeft ) ) { + position.left += myOffset + atOffset + offset; + } + } else if ( overRight > 0 ) { + newOverLeft = position.left - data.collisionPosition.marginLeft + myOffset + + atOffset + offset - offsetLeft; + if ( newOverLeft > 0 || abs( newOverLeft ) < overRight ) { + position.left += myOffset + atOffset + offset; + } + } + }, + top: function( position, data ) { + var within = data.within, + withinOffset = within.offset.top + within.scrollTop, + outerHeight = within.height, + offsetTop = within.isWindow ? within.scrollTop : within.offset.top, + collisionPosTop = position.top - data.collisionPosition.marginTop, + overTop = collisionPosTop - offsetTop, + overBottom = collisionPosTop + data.collisionHeight - outerHeight - offsetTop, + top = data.my[ 1 ] === "top", + myOffset = top ? + -data.elemHeight : + data.my[ 1 ] === "bottom" ? + data.elemHeight : + 0, + atOffset = data.at[ 1 ] === "top" ? + data.targetHeight : + data.at[ 1 ] === "bottom" ? + -data.targetHeight : + 0, + offset = -2 * data.offset[ 1 ], + newOverTop, + newOverBottom; + if ( overTop < 0 ) { + newOverBottom = position.top + myOffset + atOffset + offset + data.collisionHeight - + outerHeight - withinOffset; + if ( newOverBottom < 0 || newOverBottom < abs( overTop ) ) { + position.top += myOffset + atOffset + offset; + } + } else if ( overBottom > 0 ) { + newOverTop = position.top - data.collisionPosition.marginTop + myOffset + atOffset + + offset - offsetTop; + if ( newOverTop > 0 || abs( newOverTop ) < overBottom ) { + position.top += myOffset + atOffset + offset; + } + } + } + }, + flipfit: { + left: function() { + $.ui.position.flip.left.apply( this, arguments ); + $.ui.position.fit.left.apply( this, arguments ); + }, + top: function() { + $.ui.position.flip.top.apply( this, arguments ); + $.ui.position.fit.top.apply( this, arguments ); + } + } +}; + +} )(); + +var position = $.ui.position; + + +/*! + * jQuery UI Keycode 1.12.1 + * http://jqueryui.com + * + * Copyright jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + */ + +//>>label: Keycode +//>>group: Core +//>>description: Provide keycodes as keynames +//>>docs: http://api.jqueryui.com/jQuery.ui.keyCode/ + + +var keycode = $.ui.keyCode = { + BACKSPACE: 8, + COMMA: 188, + DELETE: 46, + DOWN: 40, + END: 35, + ENTER: 13, + ESCAPE: 27, + HOME: 36, + LEFT: 37, + PAGE_DOWN: 34, + PAGE_UP: 33, + PERIOD: 190, + RIGHT: 39, + SPACE: 32, + TAB: 9, + UP: 38 +}; + + +/*! + * jQuery UI Scroll Parent 1.12.1 + * http://jqueryui.com + * + * Copyright jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + */ + +//>>label: scrollParent +//>>group: Core +//>>description: Get the closest ancestor element that is scrollable. +//>>docs: http://api.jqueryui.com/scrollParent/ + + + +var scrollParent = $.fn.scrollParent = function( includeHidden ) { + var position = this.css( "position" ), + excludeStaticParent = position === "absolute", + overflowRegex = includeHidden ? /(auto|scroll|hidden)/ : /(auto|scroll)/, + scrollParent = this.parents().filter( function() { + var parent = $( this ); + if ( excludeStaticParent && parent.css( "position" ) === "static" ) { + return false; + } + return overflowRegex.test( parent.css( "overflow" ) + parent.css( "overflow-y" ) + + parent.css( "overflow-x" ) ); + } ).eq( 0 ); + + return position === "fixed" || !scrollParent.length ? + $( this[ 0 ].ownerDocument || document ) : + scrollParent; +}; + + +/*! + * jQuery UI Unique ID 1.12.1 + * http://jqueryui.com + * + * Copyright jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + */ + +//>>label: uniqueId +//>>group: Core +//>>description: Functions to generate and remove uniqueId's +//>>docs: http://api.jqueryui.com/uniqueId/ + + + +var uniqueId = $.fn.extend( { + uniqueId: ( function() { + var uuid = 0; + + return function() { + return this.each( function() { + if ( !this.id ) { + this.id = "ui-id-" + ( ++uuid ); + } + } ); + }; + } )(), + + removeUniqueId: function() { + return this.each( function() { + if ( /^ui-id-\d+$/.test( this.id ) ) { + $( this ).removeAttr( "id" ); + } + } ); + } +} ); + + +// NOTE: Original jQuery UI wrapper was replaced. See README-Fancytree.md +// })); +})(jQuery); + +(function( factory ) { + if ( typeof define === "function" && define.amd ) { + // AMD. Register as an anonymous module. + define( [ "jquery" ], factory ); + } else if ( typeof module === "object" && module.exports ) { + // Node/CommonJS + module.exports = factory(require("jquery")); + } else { + // Browser globals + factory( jQuery ); + } +}(function( $ ) { + + +/*! Fancytree Core *//*! + * jquery.fancytree.js + * Tree view control with support for lazy loading and much more. + * https://github.com/mar10/fancytree/ + * + * Copyright (c) 2008-2018, Martin Wendt (http://wwWendt.de) + * Released under the MIT license + * https://github.com/mar10/fancytree/wiki/LicenseInfo + * + * @version 2.30.0 + * @date 2018-09-02T15:42:49Z + */ + +/** Core Fancytree module. + */ + +// UMD wrapper for the Fancytree core module +;(function( factory ) { + if ( typeof define === "function" && define.amd ) { + // AMD. Register as an anonymous module. + define( [ "jquery", "./jquery.fancytree.ui-deps" ], factory ); + } else if ( typeof module === "object" && module.exports ) { + // Node/CommonJS + require("./jquery.fancytree.ui-deps"); + module.exports = factory(require("jquery")); + } else { + // Browser globals + factory( jQuery ); + } + +}( function( $ ) { + +"use strict"; + +// prevent duplicate loading +if ( $.ui && $.ui.fancytree ) { + $.ui.fancytree.warn("Fancytree: ignored duplicate include"); + return; +} + + +/* ***************************************************************************** + * Private functions and variables + */ + +var i, attr, + FT = null, // initialized below + TEST_IMG = new RegExp(/\.|\//), // strings are considered image urls if they contain '.' or '/' + REX_HTML = /[&<>"'\/]/g, // Escape those characters + REX_TOOLTIP = /[<>"'\/]/g, // Don't escape `&` in tooltips + RECURSIVE_REQUEST_ERROR = "$recursive_request", + ENTITY_MAP = {"&": "&", "<": "<", ">": ">", "\"": """, "'": "'", "/": "/"}, + IGNORE_KEYCODES = { 16: true, 17: true, 18: true }, + SPECIAL_KEYCODES = { + 8: "backspace", 9: "tab", 10: "return", 13: "return", + // 16: null, 17: null, 18: null, // ignore shift, ctrl, alt + 19: "pause", 20: "capslock", 27: "esc", 32: "space", 33: "pageup", + 34: "pagedown", 35: "end", 36: "home", 37: "left", 38: "up", + 39: "right", 40: "down", 45: "insert", 46: "del", 59: ";", 61: "=", + 96: "0", 97: "1", 98: "2", 99: "3", 100: "4", 101: "5", 102: "6", + 103: "7", 104: "8", 105: "9", 106: "*", 107: "+", 109: "-", 110: ".", + 111: "/", 112: "f1", 113: "f2", 114: "f3", 115: "f4", 116: "f5", + 117: "f6", 118: "f7", 119: "f8", 120: "f9", 121: "f10", 122: "f11", + 123: "f12", 144: "numlock", 145: "scroll", 173: "-", 186: ";", 187: "=", + 188: ",", 189: "-", 190: ".", 191: "/", 192: "`", 219: "[", 220: "\\", + 221: "]", 222: "'"}, + MOUSE_BUTTONS = { 0: "", 1: "left", 2: "middle", 3: "right" }, + // Boolean attributes that can be set with equivalent class names in the LI tags + // Note: v2.23: checkbox and hideCheckbox are *not* in this list + CLASS_ATTRS = "active expanded focus folder lazy radiogroup selected unselectable unselectableIgnore".split(" "), + CLASS_ATTR_MAP = {}, + // Top-level Fancytree attributes, that can be set by dict + TREE_ATTRS = "columns types".split(" "), + // TREE_ATTR_MAP = {}, + // Top-level FancytreeNode attributes, that can be set by dict + NODE_ATTRS = "checkbox expanded extraClasses folder icon iconTooltip key lazy partsel radiogroup refKey selected statusNodeType title tooltip type unselectable unselectableIgnore unselectableStatus".split(" "), + NODE_ATTR_MAP = {}, + // Mapping of lowercase -> real name (because HTML5 data-... attribute only supports lowercase) + NODE_ATTR_LOWERCASE_MAP = {}, + // Attribute names that should NOT be added to node.data + NONE_NODE_DATA_MAP = {"active": true, "children": true, "data": true, "focus": true}; + +for(i=0; i t ); + } + } + return true; +} + + +/** + * Deep-merge a list of objects (but replace array-type options). + * + * jQuery's $.extend(true, ...) method does a deep merge, that also merges Arrays. + * This variant is used to merge extension defaults with user options, and should + * merge objects, but override arrays (for example the `triggerStart: [...]` option + * of ext-edit). Also `null` values are copied over and not skipped. + * + * See issue #876 + * + * Example: + * _simpleDeepMerge({}, o1, o2); + */ + function _simpleDeepMerge() { + var options, name, src, copy, clone, + target = arguments[ 0 ] || {}, + i = 1, + length = arguments.length; + + // Handle case when target is a string or something (possible in deep copy) + if ( typeof target !== "object" && !$.isFunction( target ) ) { + target = {}; + } + if ( i === length ) { + throw "need at least two args"; + } + for ( ; i < length; i++ ) { + // Only deal with non-null/undefined values + if ( ( options = arguments[ i ] ) != null ) { + // Extend the base object + for ( name in options ) { + src = target[ name ]; + copy = options[ name ]; + // Prevent never-ending loop + if ( target === copy ) { + continue; + } + // Recurse if we're merging plain objects + // (NOTE: unlike $.extend, we don't merge arrays, but relace them) + if ( copy && $.isPlainObject( copy ) ) { + clone = src && $.isPlainObject( src ) ? src : {}; + // Never move original objects, clone them + target[ name ] = _simpleDeepMerge( clone, copy ); + // Don't bring in undefined values + } else if ( copy !== undefined ) { + target[ name ] = copy; + } + } + } + } + // Return the modified object + return target; +} + + +/** Return a wrapper that calls sub.methodName() and exposes + * this : tree + * this._local : tree.ext.EXTNAME + * this._super : base.methodName.call() + * this._superApply : base.methodName.apply() + */ +function _makeVirtualFunction(methodName, tree, base, extension, extName){ + // $.ui.fancytree.debug("_makeVirtualFunction", methodName, tree, base, extension, extName); + // if(rexTestSuper && !rexTestSuper.test(func)){ + // // extension.methodName() doesn't call _super(), so no wrapper required + // return func; + // } + // Use an immediate function as closure + var proxy = (function(){ + var prevFunc = tree[methodName], // org. tree method or prev. proxy + baseFunc = extension[methodName], // + _local = tree.ext[extName], + _super = function(){ + return prevFunc.apply(tree, arguments); + }, + _superApply = function(args){ + return prevFunc.apply(tree, args); + }; + + // Return the wrapper function + return function(){ + var prevLocal = tree._local, + prevSuper = tree._super, + prevSuperApply = tree._superApply; + + try{ + tree._local = _local; + tree._super = _super; + tree._superApply = _superApply; + return baseFunc.apply(tree, arguments); + }finally{ + tree._local = prevLocal; + tree._super = prevSuper; + tree._superApply = prevSuperApply; + } + }; + })(); // end of Immediate Function + return proxy; +} + +/** + * Subclass `base` by creating proxy functions + */ +function _subclassObject(tree, base, extension, extName){ + // $.ui.fancytree.debug("_subclassObject", tree, base, extension, extName); + for(var attrName in extension){ + if(typeof extension[attrName] === "function"){ + if(typeof tree[attrName] === "function"){ + // override existing method + tree[attrName] = _makeVirtualFunction(attrName, tree, base, extension, extName); + }else if(attrName.charAt(0) === "_"){ + // Create private methods in tree.ext.EXTENSION namespace + tree.ext[extName][attrName] = _makeVirtualFunction(attrName, tree, base, extension, extName); + }else{ + $.error("Could not override tree." + attrName + ". Use prefix '_' to create tree." + extName + "._" + attrName); + } + }else{ + // Create member variables in tree.ext.EXTENSION namespace + if(attrName !== "options"){ + tree.ext[extName][attrName] = extension[attrName]; + } + } + } +} + + +function _getResolvedPromise(context, argArray){ + if(context === undefined){ + return $.Deferred(function(){this.resolve();}).promise(); + }else{ + return $.Deferred(function(){this.resolveWith(context, argArray);}).promise(); + } +} + + +function _getRejectedPromise(context, argArray){ + if(context === undefined){ + return $.Deferred(function(){this.reject();}).promise(); + }else{ + return $.Deferred(function(){this.rejectWith(context, argArray);}).promise(); + } +} + + +function _makeResolveFunc(deferred, context){ + return function(){ + deferred.resolveWith(context); + }; +} + + +function _getElementDataAsDict($el){ + // Evaluate 'data-NAME' attributes with special treatment for 'data-json'. + var d = $.extend({}, $el.data()), + json = d.json; + + delete d.fancytree; // added to container by widget factory (old jQuery UI) + delete d.uiFancytree; // added to container by widget factory + + if( json ) { + delete d.json; + //
  • is already returned as object (http://api.jquery.com/data/#data-html5) + d = $.extend(d, json); + } + return d; +} + + +function _escapeTooltip(s){ + return ("" + s).replace(REX_TOOLTIP, function(s) { + return ENTITY_MAP[s]; + }); +} + + +// TODO: use currying +function _makeNodeTitleMatcher(s){ + s = s.toLowerCase(); + return function(node){ + return node.title.toLowerCase().indexOf(s) >= 0; + }; +} + + +function _makeNodeTitleStartMatcher(s){ + var reMatch = new RegExp("^" + s, "i"); + return function(node){ + return reMatch.test(node.title); + }; +} + + +/* ***************************************************************************** + * FancytreeNode + */ + + +/** + * Creates a new FancytreeNode + * + * @class FancytreeNode + * @classdesc A FancytreeNode represents the hierarchical data model and operations. + * + * @param {FancytreeNode} parent + * @param {NodeData} obj + * + * @property {Fancytree} tree The tree instance + * @property {FancytreeNode} parent The parent node + * @property {string} key Node id (must be unique inside the tree) + * @property {string} title Display name (may contain HTML) + * @property {object} data Contains all extra data that was passed on node creation + * @property {FancytreeNode[] | null | undefined} children Array of child nodes.
    + * For lazy nodes, null or undefined means 'not yet loaded'. Use an empty array + * to define a node that has no children. + * @property {boolean} expanded Use isExpanded(), setExpanded() to access this property. + * @property {string} extraClasses Additional CSS classes, added to the node's `<span>`.
    + * Note: use `node.add/remove/toggleClass()` to modify. + * @property {boolean} folder Folder nodes have different default icons and click behavior.
    + * Note: Also non-folders may have children. + * @property {string} statusNodeType null for standard nodes. Otherwise type of special system node: 'error', 'loading', 'nodata', or 'paging'. + * @property {boolean} lazy True if this node is loaded on demand, i.e. on first expansion. + * @property {boolean} selected Use isSelected(), setSelected() to access this property. + * @property {string} tooltip Alternative description used as hover popup + * @property {string} iconTooltip Description used as hover popup for icon. @since 2.27 + * @property {string} type Node type, used with tree.types map. @since 2.27 + */ +function FancytreeNode(parent, obj){ + var i, l, name, cl; + + this.parent = parent; + this.tree = parent.tree; + this.ul = null; + this.li = null; //
  • tag + this.statusNodeType = null; // if this is a temp. node to display the status of its parent + this._isLoading = false; // if this node itself is loading + this._error = null; // {message: '...'} if a load error occurred + this.data = {}; + + // TODO: merge this code with node.toDict() + // copy attributes from obj object + for(i=0, l=NODE_ATTRS.length; i= 0, "insertBefore must be an existing child"); + // insert nodeList after children[pos] + this.children.splice.apply(this.children, [pos, 0].concat(nodeList)); + } + if ( origFirstChild && !insertBefore ) { + // #708: Fast path -- don't render every child of root, just the new ones! + // #723, #729: but only if it's appended to an existing child list + for(i=0, l=nodeList.length; i= 0; i--) { + n = this.children[i]; + if( n.statusNodeType === "paging" ) { + this.removeChild(n); + } + } + this.partload = false; + return; + } + node = $.extend({ + title: this.tree.options.strings.moreData, + statusNodeType: "paging", + icon: false + }, node); + this.partload = true; + return this.addNode(node, mode); + }, + /** + * Append new node after this. + * + * This a convenience function that calls addNode(node, 'after') + * + * @param {NodeData} node node definition + * @returns {FancytreeNode} new node + */ + appendSibling: function(node){ + return this.addNode(node, "after"); + }, + /** + * Modify existing child nodes. + * + * @param {NodePatch} patch + * @returns {$.Promise} + * @see FancytreeNode#addChildren + */ + applyPatch: function(patch) { + // patch [key, null] means 'remove' + if(patch === null){ + this.remove(); + return _getResolvedPromise(this); + } + // TODO: make sure that root node is not collapsed or modified + // copy (most) attributes to node.ATTR or node.data.ATTR + var name, promise, v, + IGNORE_MAP = { children: true, expanded: true, parent: true }; // TODO: should be global + + for(name in patch){ + v = patch[name]; + if( !IGNORE_MAP[name] && !$.isFunction(v)){ + if(NODE_ATTR_MAP[name]){ + this[name] = v; + }else{ + this.data[name] = v; + } + } + } + // Remove and/or create children + if(patch.hasOwnProperty("children")){ + this.removeChildren(); + if(patch.children){ // only if not null and not empty list + // TODO: addChildren instead? + this._setChildren(patch.children); + } + // TODO: how can we APPEND or INSERT child nodes? + } + if(this.isVisible()){ + this.renderTitle(); + this.renderStatus(); + } + // Expand collapse (final step, since this may be async) + if(patch.hasOwnProperty("expanded")){ + promise = this.setExpanded(patch.expanded); + }else{ + promise = _getResolvedPromise(this); + } + return promise; + }, + /** Collapse all sibling nodes. + * @returns {$.Promise} + */ + collapseSiblings: function() { + return this.tree._callHook("nodeCollapseSiblings", this); + }, + /** Copy this node as sibling or child of `node`. + * + * @param {FancytreeNode} node source node + * @param {string} [mode=child] 'before' | 'after' | 'child' + * @param {Function} [map] callback function(NodeData) that could modify the new node + * @returns {FancytreeNode} new + */ + copyTo: function(node, mode, map) { + return node.addNode(this.toDict(true, map), mode); + }, + /** Count direct and indirect children. + * + * @param {boolean} [deep=true] pass 'false' to only count direct children + * @returns {int} number of child nodes + */ + countChildren: function(deep) { + var cl = this.children, i, l, n; + if( !cl ){ + return 0; + } + n = cl.length; + if(deep !== false){ + for(i=0, l=n; i= 4 (prepending node info) + * + * @param {*} msg string or object or array of such + */ + debug: function(msg){ + if( this.tree.options.debugLevel >= 4 ) { + Array.prototype.unshift.call(arguments, this.toString()); + consoleApply("log", arguments); + } + }, + /** Deprecated. + * @deprecated since 2014-02-16. Use resetLazy() instead. + */ + discard: function(){ + this.warn("FancytreeNode.discard() is deprecated since 2014-02-16. Use .resetLazy() instead."); + return this.resetLazy(); + }, + /** Remove DOM elements for all descendents. May be called on .collapse event + * to keep the DOM small. + * @param {boolean} [includeSelf=false] + */ + discardMarkup: function(includeSelf){ + var fn = includeSelf ? "nodeRemoveMarkup" : "nodeRemoveChildMarkup"; + this.tree._callHook(fn, this); + }, + /** Write error to browser console if debugLevel >= 1 (prepending tree info) + * + * @param {*} msg string or object or array of such + */ + error: function(msg){ + if( this.options.debugLevel >= 1 ) { + Array.prototype.unshift.call(arguments, this.toString()); + consoleApply("error", arguments); + } + }, + /**Find all nodes that match condition (excluding self). + * + * @param {string | function(node)} match title string to search for, or a + * callback function that returns `true` if a node is matched. + * @returns {FancytreeNode[]} array of nodes (may be empty) + */ + findAll: function(match) { + match = $.isFunction(match) ? match : _makeNodeTitleMatcher(match); + var res = []; + this.visit(function(n){ + if(match(n)){ + res.push(n); + } + }); + return res; + }, + /**Find first node that matches condition (excluding self). + * + * @param {string | function(node)} match title string to search for, or a + * callback function that returns `true` if a node is matched. + * @returns {FancytreeNode} matching node or null + * @see FancytreeNode#findAll + */ + findFirst: function(match) { + match = $.isFunction(match) ? match : _makeNodeTitleMatcher(match); + var res = null; + this.visit(function(n){ + if(match(n)){ + res = n; + return false; + } + }); + return res; + }, + /* Apply selection state (internal use only) */ + _changeSelectStatusAttrs: function(state) { + var changed = false, + opts = this.tree.options, + unselectable = FT.evalOption("unselectable", this, this, opts, false), + unselectableStatus = FT.evalOption("unselectableStatus", this, this, opts, undefined); + + if( unselectable && unselectableStatus != null ) { + state = unselectableStatus; + } + switch(state){ + case false: + changed = ( this.selected || this.partsel ); + this.selected = false; + this.partsel = false; + break; + case true: + changed = ( !this.selected || !this.partsel ); + this.selected = true; + this.partsel = true; + break; + case undefined: + changed = ( this.selected || !this.partsel ); + this.selected = false; + this.partsel = true; + break; + default: + _assert(false, "invalid state: " + state); + } + // this.debug("fixSelection3AfterLoad() _changeSelectStatusAttrs()", state, changed); + if( changed ){ + this.renderStatus(); + } + return changed; + }, + /** + * Fix selection status, after this node was (de)selected in multi-hier mode. + * This includes (de)selecting all children. + */ + fixSelection3AfterClick: function(callOpts) { + var flag = this.isSelected(); + +// this.debug("fixSelection3AfterClick()"); + + this.visit(function(node){ + node._changeSelectStatusAttrs(flag); + }); + this.fixSelection3FromEndNodes(callOpts); + }, + /** + * Fix selection status for multi-hier mode. + * Only end-nodes are considered to update the descendants branch and parents. + * Should be called after this node has loaded new children or after + * children have been modified using the API. + */ + fixSelection3FromEndNodes: function(callOpts) { + var opts = this.tree.options; + +// this.debug("fixSelection3FromEndNodes()"); + _assert(opts.selectMode === 3, "expected selectMode 3"); + + // Visit all end nodes and adjust their parent's `selected` and `partsel` + // attributes. Return selection state true, false, or undefined. + function _walk(node){ + var i, l, child, s, state, allSelected, someSelected, unselIgnore, unselState, + children = node.children; + + if( children && children.length ){ + // check all children recursively + allSelected = true; + someSelected = false; + + for( i=0, l=children.length; i= 3 (prepending node info) + * + * @param {*} msg string or object or array of such + */ + info: function(msg){ + if( this.tree.options.debugLevel >= 3 ) { + Array.prototype.unshift.call(arguments, this.toString()); + consoleApply("info", arguments); + } + }, + /** Return true if node is active (see also FancytreeNode#isSelected). + * @returns {boolean} + */ + isActive: function() { + return (this.tree.activeNode === this); + }, + /** Return true if node is vertically below `otherNode`, i.e. rendered in a subsequent row. + * @param {FancytreeNode} otherNode + * @returns {boolean} + * @since 2.28 + */ + isBelowOf: function(otherNode) { + return (this.getIndexHier(".", 5) > otherNode.getIndexHier(".", 5)); + }, + /** Return true if node is a direct child of otherNode. + * @param {FancytreeNode} otherNode + * @returns {boolean} + */ + isChildOf: function(otherNode) { + return (this.parent && this.parent === otherNode); + }, + /** Return true, if node is a direct or indirect sub node of otherNode. + * @param {FancytreeNode} otherNode + * @returns {boolean} + */ + isDescendantOf: function(otherNode) { + if(!otherNode || otherNode.tree !== this.tree){ + return false; + } + var p = this.parent; + while( p ) { + if( p === otherNode ){ + return true; + } + if( p === p.parent ) { $.error("Recursive parent link: " + p); } + p = p.parent; + } + return false; + }, + /** Return true if node is expanded. + * @returns {boolean} + */ + isExpanded: function() { + return !!this.expanded; + }, + /** Return true if node is the first node of its parent's children. + * @returns {boolean} + */ + isFirstSibling: function() { + var p = this.parent; + return !p || p.children[0] === this; + }, + /** Return true if node is a folder, i.e. has the node.folder attribute set. + * @returns {boolean} + */ + isFolder: function() { + return !!this.folder; + }, + /** Return true if node is the last node of its parent's children. + * @returns {boolean} + */ + isLastSibling: function() { + var p = this.parent; + return !p || p.children[p.children.length-1] === this; + }, + /** Return true if node is lazy (even if data was already loaded) + * @returns {boolean} + */ + isLazy: function() { + return !!this.lazy; + }, + /** Return true if node is lazy and loaded. For non-lazy nodes always return true. + * @returns {boolean} + */ + isLoaded: function() { + return !this.lazy || this.hasChildren() !== undefined; // Also checks if the only child is a status node + }, + /** Return true if children are currently beeing loaded, i.e. a Ajax request is pending. + * @returns {boolean} + */ + isLoading: function() { + return !!this._isLoading; + }, + /* + * @deprecated since v2.4.0: Use isRootNode() instead + */ + isRoot: function() { + return this.isRootNode(); + }, + /** Return true if node is partially selected (tri-state). + * @returns {boolean} + * @since 2.23 + */ + isPartsel: function() { + return !this.selected && !!this.partsel; + }, + /** (experimental) Return true if this is partially loaded. + * @returns {boolean} + * @since 2.15 + */ + isPartload: function() { + return !!this.partload; + }, + /** Return true if this is the (invisible) system root node. + * @returns {boolean} + * @since 2.4 + */ + isRootNode: function() { + return (this.tree.rootNode === this); + }, + /** Return true if node is selected, i.e. has a checkmark set (see also FancytreeNode#isActive). + * @returns {boolean} + */ + isSelected: function() { + return !!this.selected; + }, + /** Return true if this node is a temporarily generated system node like + * 'loading', 'paging', or 'error' (node.statusNodeType contains the type). + * @returns {boolean} + */ + isStatusNode: function() { + return !!this.statusNodeType; + }, + /** Return true if this node is a status node of type 'paging'. + * @returns {boolean} + * @since 2.15 + */ + isPagingNode: function() { + return this.statusNodeType === "paging"; + }, + /** Return true if this a top level node, i.e. a direct child of the (invisible) system root node. + * @returns {boolean} + * @since 2.4 + */ + isTopLevel: function() { + return (this.tree.rootNode === this.parent); + }, + /** Return true if node is lazy and not yet loaded. For non-lazy nodes always return false. + * @returns {boolean} + */ + isUndefined: function() { + return this.hasChildren() === undefined; // also checks if the only child is a status node + }, + /** Return true if all parent nodes are expanded. Note: this does not check + * whether the node is scrolled into the visible part of the screen. + * @returns {boolean} + */ + isVisible: function() { + var i, l, + parents = this.getParentList(false, false); + + for(i=0, l=parents.length; iexpanded state is maintained. + * @param {boolean} [forceReload=false] Pass true to discard any existing nodes before. Otherwise this method does nothing if the node was already loaded. + * @returns {$.Promise} + */ + load: function(forceReload) { + var res, source, + that = this, + wasExpanded = this.isExpanded(); + + _assert( this.isLazy(), "load() requires a lazy node" ); + // _assert( forceReload || this.isUndefined(), "Pass forceReload=true to re-load a lazy node" ); + if( !forceReload && !this.isUndefined() ) { + return _getResolvedPromise(this); + } + if( this.isLoaded() ){ + this.resetLazy(); // also collapses + } + // This method is also called by setExpanded() and loadKeyPath(), so we + // have to avoid recursion. + source = this.tree._triggerNodeEvent("lazyLoad", this); + if( source === false ) { // #69 + return _getResolvedPromise(this); + } + _assert(typeof source !== "boolean", "lazyLoad event must return source in data.result"); + res = this.tree._callHook("nodeLoadChildren", this, source); + if( wasExpanded ) { + this.expanded = true; + res.always(function(){ + that.render(); + }); + } else { + res.always(function(){ + that.renderStatus(); // fix expander icon to 'loaded' + }); + } + return res; + }, + /** Expand all parents and optionally scroll into visible area as neccessary. + * Promise is resolved, when lazy loading and animations are done. + * @param {object} [opts] passed to `setExpanded()`. + * Defaults to {noAnimation: false, noEvents: false, scrollIntoView: true} + * @returns {$.Promise} + */ + makeVisible: function(opts) { + var i, + that = this, + deferreds = [], + dfd = new $.Deferred(), + parents = this.getParentList(false, false), + len = parents.length, + effects = !(opts && opts.noAnimation === true), + scroll = !(opts && opts.scrollIntoView === false); + + // Expand bottom-up, so only the top node is animated + for(i = len - 1; i >= 0; i--){ + // that.debug("pushexpand" + parents[i]); + deferreds.push(parents[i].setExpanded(true, opts)); + } + $.when.apply($, deferreds).done(function(){ + // All expands have finished + // that.debug("expand DONE", scroll); + if( scroll ){ + that.scrollIntoView(effects).done(function(){ + // that.debug("scroll DONE"); + dfd.resolve(); + }); + } else { + dfd.resolve(); + } + }); + return dfd.promise(); + }, + /** Move this node to targetNode. + * @param {FancytreeNode} targetNode + * @param {string} mode
    +	 *      'child': append this node as last child of targetNode.
    +	 *               This is the default. To be compatble with the D'n'd
    +	 *               hitMode, we also accept 'over'.
    +	 *      'firstChild': add this node as first child of targetNode.
    +	 *      'before': add this node as sibling before targetNode.
    +	 *      'after': add this node as sibling after targetNode.
    + * @param {function} [map] optional callback(FancytreeNode) to allow modifcations + */ + moveTo: function(targetNode, mode, map) { + if(mode === undefined || mode === "over"){ + mode = "child"; + } else if ( mode === "firstChild" ) { + if( targetNode.children && targetNode.children.length ) { + mode = "before"; + targetNode = targetNode.children[0]; + } else { + mode = "child"; + } + } + var pos, + prevParent = this.parent, + targetParent = (mode === "child") ? targetNode : targetNode.parent; + + if(this === targetNode){ + return; + }else if( !this.parent ){ + $.error("Cannot move system root"); + }else if( targetParent.isDescendantOf(this) ){ + $.error("Cannot move a node to its own descendant"); + } + if( targetParent !== prevParent ) { + prevParent.triggerModifyChild("remove", this); + } + // Unlink this node from current parent + if( this.parent.children.length === 1 ) { + if( this.parent === targetParent ){ + return; // #258 + } + this.parent.children = this.parent.lazy ? [] : null; + this.parent.expanded = false; + } else { + pos = $.inArray(this, this.parent.children); + _assert(pos >= 0, "invalid source parent"); + this.parent.children.splice(pos, 1); + } + // Remove from source DOM parent +// if(this.parent.ul){ +// this.parent.ul.removeChild(this.li); +// } + + // Insert this node to target parent's child list + this.parent = targetParent; + if( targetParent.hasChildren() ) { + switch(mode) { + case "child": + // Append to existing target children + targetParent.children.push(this); + break; + case "before": + // Insert this node before target node + pos = $.inArray(targetNode, targetParent.children); + _assert(pos >= 0, "invalid target parent"); + targetParent.children.splice(pos, 0, this); + break; + case "after": + // Insert this node after target node + pos = $.inArray(targetNode, targetParent.children); + _assert(pos >= 0, "invalid target parent"); + targetParent.children.splice(pos+1, 0, this); + break; + default: + $.error("Invalid mode " + mode); + } + } else { + targetParent.children = [ this ]; + } + // Parent has no
      tag yet: +// if( !targetParent.ul ) { +// // This is the parent's first child: create UL tag +// // (Hidden, because it will be +// targetParent.ul = document.createElement("ul"); +// targetParent.ul.style.display = "none"; +// targetParent.li.appendChild(targetParent.ul); +// } +// // Issue 319: Add to target DOM parent (only if node was already rendered(expanded)) +// if(this.li){ +// targetParent.ul.appendChild(this.li); +// }^ + + // Let caller modify the nodes + if( map ){ + targetNode.visit(map, true); + } + if( targetParent === prevParent ) { + targetParent.triggerModifyChild("move", this); + } else { + // prevParent.triggerModifyChild("remove", this); + targetParent.triggerModifyChild("add", this); + } + // Handle cross-tree moves + if( this.tree !== targetNode.tree ) { + // Fix node.tree for all source nodes +// _assert(false, "Cross-tree move is not yet implemented."); + this.warn("Cross-tree moveTo is experimantal!"); + this.visit(function(n){ + // TODO: fix selection state and activation, ... + n.tree = targetNode.tree; + }, true); + } + + // A collaposed node won't re-render children, so we have to remove it manually + // if( !targetParent.expanded ){ + // prevParent.ul.removeChild(this.li); + // } + + // Update HTML markup + if( !prevParent.isDescendantOf(targetParent)) { + prevParent.render(); + } + if( !targetParent.isDescendantOf(prevParent) && targetParent !== prevParent) { + targetParent.render(); + } + // TODO: fix selection state + // TODO: fix active state + +/* + var tree = this.tree; + var opts = tree.options; + var pers = tree.persistence; + + + // Always expand, if it's below minExpandLevel +// tree.logDebug ("%s._addChildNode(%o), l=%o", this, ftnode, ftnode.getLevel()); + if ( opts.minExpandLevel >= ftnode.getLevel() ) { +// tree.logDebug ("Force expand for %o", ftnode); + this.bExpanded = true; + } + + // In multi-hier mode, update the parents selection state + // DT issue #82: only if not initializing, because the children may not exist yet +// if( !ftnode.data.isStatusNode() && opts.selectMode==3 && !isInitializing ) +// ftnode._fixSelectionState(); + + // In multi-hier mode, update the parents selection state + if( ftnode.bSelected && opts.selectMode==3 ) { + var p = this; + while( p ) { + if( !p.hasSubSel ) + p._setSubSel(true); + p = p.parent; + } + } + // render this node and the new child + if ( tree.bEnableUpdate ) + this.render(); + + return ftnode; + +*/ + }, + /** Set focus relative to this node and optionally activate. + * + * @param {number} where The keyCode that would normally trigger this move, + * e.g. `$.ui.keyCode.LEFT` would collapse the node if it + * is expanded or move to the parent oterwise. + * @param {boolean} [activate=true] + * @returns {$.Promise} + */ + navigate: function(where, activate) { + var i, parents, res, + handled = true, + KC = $.ui.keyCode, + sib = null; + + // Navigate to node + function _goto(n){ + if( n ){ + // setFocus/setActive will scroll later (if autoScroll is specified) + try { n.makeVisible({scrollIntoView: false}); } catch(e) {} // #272 + // Node may still be hidden by a filter + if( ! $(n.span).is(":visible") ) { + n.debug("Navigate: skipping hidden node"); + n.navigate(where, activate); + return; + } + return activate === false ? n.setFocus() : n.setActive(); + } + } + + switch( where ) { + case KC.BACKSPACE: + if( this.parent && this.parent.parent ) { + res = _goto(this.parent); + } + break; + case KC.HOME: + this.tree.visit(function(n){ // goto first visible node + if( $(n.span).is(":visible") ) { + res = _goto(n); + return false; + } + }); + break; + case KC.END: + this.tree.visit(function(n){ // goto last visible node + if( $(n.span).is(":visible") ) { + res = n; + } + }); + if( res ) { + res = _goto(res); + } + break; + case KC.LEFT: + if( this.expanded ) { + this.setExpanded(false); + res = _goto(this); + } else if( this.parent && this.parent.parent ) { + res = _goto(this.parent); + } + break; + case KC.RIGHT: + if( !this.expanded && (this.children || this.lazy) ) { + this.setExpanded(); + res = _goto(this); + } else if( this.children && this.children.length ) { + res = _goto(this.children[0]); + } + break; + case KC.UP: + sib = this.getPrevSibling(); + // #359: skip hidden sibling nodes, preventing a _goto() recursion + while( sib && !$(sib.span).is(":visible") ) { + sib = sib.getPrevSibling(); + } + while( sib && sib.expanded && sib.children && sib.children.length ) { + sib = sib.children[sib.children.length - 1]; + } + if( !sib && this.parent && this.parent.parent ){ + sib = this.parent; + } + res = _goto(sib); + break; + case KC.DOWN: + if( this.expanded && this.children && this.children.length ) { + sib = this.children[0]; + } else { + parents = this.getParentList(false, true); + for(i=parents.length-1; i>=0; i--) { + sib = parents[i].getNextSibling(); + // #359: skip hidden sibling nodes, preventing a _goto() recursion + while( sib && !$(sib.span).is(":visible") ) { + sib = sib.getNextSibling(); + } + if( sib ){ break; } + } + } + res = _goto(sib); + break; + default: + handled = false; + } + return res || _getResolvedPromise(); + }, + /** + * Remove this node (not allowed for system root). + */ + remove: function() { + return this.parent.removeChild(this); + }, + /** + * Remove childNode from list of direct children. + * @param {FancytreeNode} childNode + */ + removeChild: function(childNode) { + return this.tree._callHook("nodeRemoveChild", this, childNode); + }, + /** + * Remove all child nodes and descendents. This converts the node into a leaf.
      + * If this was a lazy node, it is still considered 'loaded'; call node.resetLazy() + * in order to trigger lazyLoad on next expand. + */ + removeChildren: function() { + return this.tree._callHook("nodeRemoveChildren", this); + }, + /** + * Remove class from node's span tag and .extraClasses. + * + * @param {string} className class name + * + * @since 2.17 + */ + removeClass: function(className){ + return this.toggleClass(className, false); + }, + /** + * This method renders and updates all HTML markup that is required + * to display this node in its current state.
      + * Note: + *
        + *
      • It should only be neccessary to call this method after the node object + * was modified by direct access to its properties, because the common + * API methods (node.setTitle(), moveTo(), addChildren(), remove(), ...) + * already handle this. + *
      • {@link FancytreeNode#renderTitle} and {@link FancytreeNode#renderStatus} + * are implied. If changes are more local, calling only renderTitle() or + * renderStatus() may be sufficient and faster. + *
      + * + * @param {boolean} [force=false] re-render, even if html markup was already created + * @param {boolean} [deep=false] also render all descendants, even if parent is collapsed + */ + render: function(force, deep) { + return this.tree._callHook("nodeRender", this, force, deep); + }, + /** Create HTML markup for the node's outer <span> (expander, checkbox, icon, and title). + * Implies {@link FancytreeNode#renderStatus}. + * @see Fancytree_Hooks#nodeRenderTitle + */ + renderTitle: function() { + return this.tree._callHook("nodeRenderTitle", this); + }, + /** Update element's CSS classes according to node state. + * @see Fancytree_Hooks#nodeRenderStatus + */ + renderStatus: function() { + return this.tree._callHook("nodeRenderStatus", this); + }, + /** + * (experimental) Replace this node with `source`. + * (Currently only available for paging nodes.) + * @param {NodeData[]} source List of child node definitions + * @since 2.15 + */ + replaceWith: function(source) { + var res, + parent = this.parent, + pos = $.inArray(this, parent.children), + that = this; + + _assert( this.isPagingNode(), "replaceWith() currently requires a paging status node" ); + + res = this.tree._callHook("nodeLoadChildren", this, source); + res.done(function(data){ + // New nodes are currently children of `this`. + var children = that.children; + // Prepend newly loaded child nodes to `this` + // Move new children after self + for( i=0; i container. + // For ext-table, we choose the nearest parent that has `position: relative` + // and `overflow` set. + // (This default can be overridden by the local or global `scrollParent` option.) + var opts = $.extend({ + effects: (effects === true) ? {duration: 200, queue: false} : effects, + scrollOfs: this.tree.options.scrollOfs, + scrollParent: this.tree.options.scrollParent, + topNode: null + }, options), + $scrollParent = opts.scrollParent; + + if( !$scrollParent ) { + $scrollParent = this.tree.tbody ? this.tree.$container.scrollParent() : this.tree.$container; + } else if( !$scrollParent.jquery ) { + // Make sure we have a jQuery object + $scrollParent = $($scrollParent); + } + if( $scrollParent[0] === document ) { + // `document` may returned by $().scrollParent(), if nothing is found, + // but would not work: + $scrollParent = $(window); + } + + var topNodeY, nodeY, horzScrollbarHeight, containerOffsetTop, + dfd = new $.Deferred(), + that = this, + nodeHeight = $(this.span).height(), + topOfs = opts.scrollOfs.top || 0, + bottomOfs = opts.scrollOfs.bottom || 0, + containerHeight = $scrollParent.height(), + scrollTop = $scrollParent.scrollTop(), + $animateTarget = $scrollParent, + isParentWindow = $scrollParent[0] === window, + topNode = opts.topNode || null, + newScrollTop = null; + + // this.debug("scrollIntoView(), scrollTop=" + scrollTop, opts.scrollOfs); +// _assert($(this.span).is(":visible"), "scrollIntoView node is invisible"); // otherwise we cannot calc offsets + if( !$(this.span).is(":visible") ) { + // We cannot calc offsets for hidden elements + this.warn("scrollIntoView(): node is invisible."); + return _getResolvedPromise(); + } + if( isParentWindow ) { + nodeY = $(this.span).offset().top; + topNodeY = (topNode && topNode.span) ? $(topNode.span).offset().top : 0; + $animateTarget = $("html,body"); + + } else { + _assert($scrollParent[0] !== document && $scrollParent[0] !== document.body, + "scrollParent should be a simple element or `window`, not document or body."); + + containerOffsetTop = $scrollParent.offset().top, + nodeY = $(this.span).offset().top - containerOffsetTop + scrollTop; // relative to scroll parent + topNodeY = topNode ? $(topNode.span).offset().top - containerOffsetTop + scrollTop : 0; + horzScrollbarHeight = Math.max(0, ($scrollParent.innerHeight() - $scrollParent[0].clientHeight)); + containerHeight -= horzScrollbarHeight; + } + + // this.debug(" scrollIntoView(), nodeY=" + nodeY + ", containerHeight=" + containerHeight); + if( nodeY < (scrollTop + topOfs) ){ + // Node is above visible container area + newScrollTop = nodeY - topOfs; + // this.debug(" scrollIntoView(), UPPER newScrollTop=" + newScrollTop); + + }else if((nodeY + nodeHeight) > (scrollTop + containerHeight - bottomOfs)){ + newScrollTop = nodeY + nodeHeight - containerHeight + bottomOfs; + // this.debug(" scrollIntoView(), LOWER newScrollTop=" + newScrollTop); + // If a topNode was passed, make sure that it is never scrolled + // outside the upper border + if(topNode){ + _assert(topNode.isRootNode() || $(topNode.span).is(":visible"), "topNode must be visible"); + if( topNodeY < newScrollTop ){ + newScrollTop = topNodeY - topOfs; + // this.debug(" scrollIntoView(), TOP newScrollTop=" + newScrollTop); + } + } + } + + if(newScrollTop !== null){ + // this.debug(" scrollIntoView(), SET newScrollTop=" + newScrollTop); + if(opts.effects){ + opts.effects.complete = function(){ + dfd.resolveWith(that); + }; + $animateTarget.stop(true).animate({ + scrollTop: newScrollTop + }, opts.effects); + }else{ + $animateTarget[0].scrollTop = newScrollTop; + dfd.resolveWith(this); + } + }else{ + dfd.resolveWith(this); + } + return dfd.promise(); + }, + + /**Activate this node. + * + * The `cell` option requires the ext-table and ext-ariagrid extensions. + * + * @param {boolean} [flag=true] pass false to deactivate + * @param {object} [opts] additional options. Defaults to {noEvents: false, noFocus: false, cell: null} + * @returns {$.Promise} + */ + setActive: function(flag, opts){ + return this.tree._callHook("nodeSetActive", this, flag, opts); + }, + /**Expand or collapse this node. Promise is resolved, when lazy loading and animations are done. + * @param {boolean} [flag=true] pass false to collapse + * @param {object} [opts] additional options. Defaults to {noAnimation: false, noEvents: false} + * @returns {$.Promise} + */ + setExpanded: function(flag, opts){ + return this.tree._callHook("nodeSetExpanded", this, flag, opts); + }, + /**Set keyboard focus to this node. + * @param {boolean} [flag=true] pass false to blur + * @see Fancytree#setFocus + */ + setFocus: function(flag){ + return this.tree._callHook("nodeSetFocus", this, flag); + }, + /**Select this node, i.e. check the checkbox. + * @param {boolean} [flag=true] pass false to deselect + * @param {object} [opts] additional options. Defaults to {noEvents: false, p + * propagateDown: null, propagateUp: null, callback: null } + */ + setSelected: function(flag, opts){ + return this.tree._callHook("nodeSetSelected", this, flag, opts); + }, + /**Mark a lazy node as 'error', 'loading', 'nodata', or 'ok'. + * @param {string} status 'error'|'empty'|'ok' + * @param {string} [message] + * @param {string} [details] + */ + setStatus: function(status, message, details){ + return this.tree._callHook("nodeSetStatus", this, status, message, details); + }, + /**Rename this node. + * @param {string} title + */ + setTitle: function(title){ + this.title = title; + this.renderTitle(); + this.triggerModify("rename"); + }, + /**Sort child list by title. + * @param {function} [cmp] custom compare function(a, b) that returns -1, 0, or 1 (defaults to sort by title). + * @param {boolean} [deep=false] pass true to sort all descendant nodes + */ + sortChildren: function(cmp, deep) { + var i,l, + cl = this.children; + + if( !cl ){ + return; + } + cmp = cmp || function(a, b) { + var x = a.title.toLowerCase(), + y = b.title.toLowerCase(); + return x === y ? 0 : x > y ? 1 : -1; + }; + cl.sort(cmp); + if( deep ){ + for(i=0, l=cl.length; i= 0; + flag = (flag === undefined) ? (!hasClass) : !!flag; + if ( flag ) { + if( !hasClass ) { + curClasses += className + " "; + wasAdded = true; + } + } else { + while ( curClasses.indexOf( " " + className + " " ) > -1 ) { + curClasses = curClasses.replace( " " + className + " ", " " ); + } + } + } + this.extraClasses = $.trim(curClasses); + // this.info("-> toggleClass('" + value + "', " + flag + "): '" + this.extraClasses + "'"); + return wasAdded; + }, + /** Flip expanded status. */ + toggleExpanded: function(){ + return this.tree._callHook("nodeToggleExpanded", this); + }, + /** Flip selection status. */ + toggleSelected: function(){ + return this.tree._callHook("nodeToggleSelected", this); + }, + toString: function() { + return "FancytreeNode@" + this.key + "[title='" + this.title + "']"; + // return ""; + }, + /** + * Trigger `modifyChild` event on a parent to signal that a child was modified. + * @param {string} operation Type of change: 'add', 'remove', 'rename', 'move', 'data', ... + * @param {FancytreeNode} [childNode] + * @param {object} [extra] + */ + triggerModifyChild: function(operation, childNode, extra){ + var data, + modifyChild = this.tree.options.modifyChild; + + if ( modifyChild ){ + if( childNode && childNode.parent !== this ) { + $.error("childNode " + childNode + " is not a child of " + this); + } + data = { + node: this, + tree: this.tree, + operation: operation, + childNode: childNode || null + }; + if( extra ) { + $.extend(data, extra); + } + modifyChild({type: "modifyChild"}, data); + } + }, + /** + * Trigger `modifyChild` event on node.parent(!). + * @param {string} operation Type of change: 'add', 'remove', 'rename', 'move', 'data', ... + * @param {object} [extra] + */ + triggerModify: function(operation, extra){ + this.parent.triggerModifyChild(operation, this, extra); + }, + /** Call fn(node) for all child nodes in hierarchical order (depth-first).
      + * Stop iteration, if fn() returns false. Skip current branch, if fn() returns "skip".
      + * Return false if iteration was stopped. + * + * @param {function} fn the callback function. + * Return false to stop iteration, return "skip" to skip this node and + * its children only. + * @param {boolean} [includeSelf=false] + * @returns {boolean} + */ + visit: function(fn, includeSelf) { + var i, l, + res = true, + children = this.children; + + if( includeSelf === true ) { + res = fn(this); + if( res === false || res === "skip" ){ + return res; + } + } + if(children){ + for(i=0, l=children.length; i + * Note: If you need this method, you probably should consider to review + * your architecture! Recursivley loading nodes is a perfect way for lazy + * programmers to flood the server with requests ;-) + * + * @param {function} [fn] optional callback function. + * Return false to stop iteration, return "skip" to skip this node and + * its children only. + * @param {boolean} [includeSelf=false] + * @returns {$.Promise} + * @since 2.4 + */ + visitAndLoad: function(fn, includeSelf, _recursion) { + var dfd, res, loaders, + node = this; + + // node.debug("visitAndLoad"); + if( fn && includeSelf === true ) { + res = fn(node); + if( res === false || res === "skip" ) { + return _recursion ? res : _getResolvedPromise(); + } + } + if( !node.children && !node.lazy ) { + return _getResolvedPromise(); + } + dfd = new $.Deferred(); + loaders = []; + // node.debug("load()..."); + node.load().done(function(){ + // node.debug("load()... done."); + for(var i=0, l=node.children.length; i + * Stop iteration, if fn() returns false.
      + * Return false if iteration was stopped. + * + * @param {function} fn the callback function. + * Return false to stop iteration, return "skip" to skip this node and children only. + * @param {boolean} [includeSelf=false] + * @returns {boolean} + */ + visitParents: function(fn, includeSelf) { + // Visit parent nodes (bottom up) + if(includeSelf && fn(this) === false){ + return false; + } + var p = this.parent; + while( p ) { + if(fn(p) === false){ + return false; + } + p = p.parent; + } + return true; + }, + /** Call fn(node) for all sibling nodes.
      + * Stop iteration, if fn() returns false.
      + * Return false if iteration was stopped. + * + * @param {function} fn the callback function. + * Return false to stop iteration. + * @param {boolean} [includeSelf=false] + * @returns {boolean} + */ + visitSiblings: function(fn, includeSelf) { + var i, l, n, + ac = this.parent.children; + + for (i=0, l=ac.length; i= 2 (prepending node info) + * + * @param {*} msg string or object or array of such + */ + warn: function(msg){ + if( this.tree.options.debugLevel >= 2 ) { + Array.prototype.unshift.call(arguments, this.toString()); + consoleApply("warn", arguments); + } + } +}; + + +/* ***************************************************************************** + * Fancytree + */ +/** + * Construct a new tree object. + * + * @class Fancytree + * @classdesc The controller behind a fancytree. + * This class also contains 'hook methods': see {@link Fancytree_Hooks}. + * + * @param {Widget} widget + * + * @property {string} _id Automatically generated unique tree instance ID, e.g. "1". + * @property {string} _ns Automatically generated unique tree namespace, e.g. ".fancytree-1". + * @property {FancytreeNode} activeNode Currently active node or null. + * @property {string} ariaPropName Property name of FancytreeNode that contains the element which will receive the aria attributes. + * Typically "li", but "tr" for table extension. + * @property {jQueryObject} $container Outer <ul> element (or <table> element for ext-table). + * @property {jQueryObject} $div A jQuery object containing the element used to instantiate the tree widget (`widget.element`) + * @property {object|array} columns Recommended place to store shared column meta data. @since 2.27 + * @property {object} data Metadata, i.e. properties that may be passed to `source` in addition to a children array. + * @property {object} ext Hash of all active plugin instances. + * @property {FancytreeNode} focusNode Currently focused node or null. + * @property {FancytreeNode} lastSelectedNode Used to implement selectMode 1 (single select) + * @property {string} nodeContainerAttrName Property name of FancytreeNode that contains the outer element of single nodes. + * Typically "li", but "tr" for table extension. + * @property {FancytreeOptions} options Current options, i.e. default options + options passed to constructor. + * @property {FancytreeNode} rootNode Invisible system root node. + * @property {string} statusClassPropName Property name of FancytreeNode that contains the element which will receive the status classes. + * Typically "span", but "tr" for table extension. + * @property {object} types Map for shared type specific meta data, used with node.type attribute. @since 2.27 + * @property {object} widget Base widget instance. + */ +function Fancytree(widget) { + this.widget = widget; + this.$div = widget.element; + this.options = widget.options; + if( this.options ) { + if( $.isFunction(this.options.lazyload ) && !$.isFunction(this.options.lazyLoad) ) { + this.options.lazyLoad = function() { + FT.warn("The 'lazyload' event is deprecated since 2014-02-25. Use 'lazyLoad' (with uppercase L) instead."); + return widget.options.lazyload.apply(this, arguments); + }; + } + if( $.isFunction(this.options.loaderror) ) { + $.error("The 'loaderror' event was renamed since 2014-07-03. Use 'loadError' (with uppercase E) instead."); + } + if( this.options.fx !== undefined ) { + FT.warn("The 'fx' option was replaced by 'toggleEffect' since 2014-11-30."); + } + if( this.options.removeNode !== undefined ) { + $.error("The 'removeNode' event was replaced by 'modifyChild' since 2.20 (2016-09-10)."); + } + } + this.ext = {}; // Active extension instances + this.types = {}; + this.columns = {}; + // allow to init tree.data.foo from
      + this.data = _getElementDataAsDict(this.$div); + // TODO: use widget.uuid instead? + this._id = $.ui.fancytree._nextId++; + // TODO: use widget.eventNamespace instead? + this._ns = ".fancytree-" + this._id; // append for namespaced events + this.activeNode = null; + this.focusNode = null; + this._hasFocus = null; + this._tempCache = {}; + this._lastMousedownNode = null; + this._enableUpdate = true; + this.lastSelectedNode = null; + this.systemFocusElement = null; + this.lastQuicksearchTerm = ""; + this.lastQuicksearchTime = 0; + + this.statusClassPropName = "span"; + this.ariaPropName = "li"; + this.nodeContainerAttrName = "li"; + + // Remove previous markup if any + this.$div.find(">ul.fancytree-container").remove(); + + // Create a node without parent. + var fakeParent = { tree: this }, + $ul; + this.rootNode = new FancytreeNode(fakeParent, { + title: "root", + key: "root_" + this._id, + children: null, + expanded: true + }); + this.rootNode.parent = null; + + // Create root markup + $ul = $("
        ", { + "class": "ui-fancytree fancytree-container fancytree-plain" + }).appendTo(this.$div); + this.$container = $ul; + this.rootNode.ul = $ul[0]; + + if(this.options.debugLevel == null){ + this.options.debugLevel = FT.debugLevel; + } + // // Add container to the TAB chain + // // See http://www.w3.org/TR/wai-aria-practices/#focus_activedescendant + // // #577: Allow to set tabindex to "0", "-1" and "" + // this.$container.attr("tabindex", this.options.tabindex); + + // if( this.options.rtl ) { + // this.$container.attr("DIR", "RTL").addClass("fancytree-rtl"); + // // }else{ + // // this.$container.attr("DIR", null).removeClass("fancytree-rtl"); + // } + // if(this.options.aria){ + // this.$container.attr("role", "tree"); + // if( this.options.selectMode !== 1 ) { + // this.$container.attr("aria-multiselectable", true); + // } + // } +} + + +Fancytree.prototype = /** @lends Fancytree# */{ + /* Return a context object that can be re-used for _callHook(). + * @param {Fancytree | FancytreeNode | EventData} obj + * @param {Event} originalEvent + * @param {Object} extra + * @returns {EventData} + */ + _makeHookContext: function(obj, originalEvent, extra) { + var ctx, tree; + if(obj.node !== undefined){ + // obj is already a context object + if(originalEvent && obj.originalEvent !== originalEvent){ + $.error("invalid args"); + } + ctx = obj; + }else if(obj.tree){ + // obj is a FancytreeNode + tree = obj.tree; + ctx = { node: obj, tree: tree, widget: tree.widget, options: tree.widget.options, originalEvent: originalEvent, + typeInfo: tree.types[obj.type] || {}}; + }else if(obj.widget){ + // obj is a Fancytree + ctx = { node: null, tree: obj, widget: obj.widget, options: obj.widget.options, originalEvent: originalEvent }; + }else{ + $.error("invalid args"); + } + if(extra){ + $.extend(ctx, extra); + } + return ctx; + }, + /* Trigger a hook function: funcName(ctx, [...]). + * + * @param {string} funcName + * @param {Fancytree|FancytreeNode|EventData} contextObject + * @param {any} [_extraArgs] optional additional arguments + * @returns {any} + */ + _callHook: function(funcName, contextObject, _extraArgs) { + var ctx = this._makeHookContext(contextObject), + fn = this[funcName], + args = Array.prototype.slice.call(arguments, 2); + if(!$.isFunction(fn)){ + $.error("_callHook('" + funcName + "') is not a function"); + } + args.unshift(ctx); +// this.debug("_hook", funcName, ctx.node && ctx.node.toString() || ctx.tree.toString(), args); + return fn.apply(this, args); + }, + _setExpiringValue: function(key, value, ms){ + this._tempCache[key] = {value: value, expire: Date.now() + (+ms || 50)}; + }, + _getExpiringValue: function(key){ + var entry = this._tempCache[key]; + if( entry && entry.expire > Date.now() ) { + return entry.value; + } + delete this._tempCache[key]; + return null; + }, + /* Check if current extensions dependencies are met and throw an error if not. + * + * This method may be called inside the `treeInit` hook for custom extensions. + * + * @param {string} extension name of the required extension + * @param {boolean} [required=true] pass `false` if the extension is optional, but we want to check for order if it is present + * @param {boolean} [before] `true` if `name` must be included before this, `false` otherwise (use `null` if order doesn't matter) + * @param {string} [message] optional error message (defaults to a descriptve error message) + */ + _requireExtension: function(name, required, before, message) { + before = !!before; + var thisName = this._local.name, + extList = this.options.extensions, + isBefore = $.inArray(name, extList) < $.inArray(thisName, extList), + isMissing = required && this.ext[name] == null, + badOrder = !isMissing && before != null && (before !== isBefore); + + _assert(thisName && thisName !== name, "invalid or same name"); + + if( isMissing || badOrder ){ + if( !message ){ + if( isMissing || required ){ + message = "'" + thisName + "' extension requires '" + name + "'"; + if( badOrder ){ + message += " to be registered " + (before ? "before" : "after") + " itself"; + } + }else{ + message = "If used together, `" + name + "` must be registered " + (before ? "before" : "after") + " `" + thisName + "`"; + } + } + $.error(message); + return false; + } + return true; + }, + /** Activate node with a given key and fire focus and activate events. + * + * A previously activated node will be deactivated. + * If activeVisible option is set, all parents will be expanded as necessary. + * Pass key = false, to deactivate the current node only. + * @param {string} key + * @param {object} [opts] additional options. Defaults to {noEvents: false, noFocus: false} + * @returns {FancytreeNode} activated node (null, if not found) + */ + activateKey: function(key, opts) { + var node = this.getNodeByKey(key); + if(node){ + node.setActive(true, opts); + }else if(this.activeNode){ + this.activeNode.setActive(false, opts); + } + return node; + }, + /** (experimental) Add child status nodes that indicate 'More...', .... + * @param {boolean|object} node optional node definition. Pass `false` to remove all paging nodes. + * @param {string} [mode='append'] 'child'|firstChild' + * @since 2.15 + */ + addPagingNode: function(node, mode){ + return this.rootNode.addPagingNode(node, mode); + }, + /** (experimental) Modify existing data model. + * + * @param {Array} patchList array of [key, NodePatch] arrays + * @returns {$.Promise} resolved, when all patches have been applied + * @see TreePatch + */ + applyPatch: function(patchList) { + var dfd, i, p2, key, patch, node, + patchCount = patchList.length, + deferredList = []; + + for(i=0; i= 4 (prepending tree name) + * + * @param {*} msg string or object or array of such + */ + debug: function(msg){ + if( this.options.debugLevel >= 4 ) { + Array.prototype.unshift.call(arguments, this.toString()); + consoleApply("log", arguments); + } + }, + /** Enable (or disable) the tree control. + * + * @param {boolean} [flag=true] pass false to disable + * @since 2.30 + */ + enable: function(flag){ + if( flag === false ) { + this.widget.disable(); + } else { + this.widget.enable(); + } + }, + /** Temporarily suppress rendering to improve performance on bulk-updates. + * + * @param {boolean} flag + * @returns {boolean} previous status + * @since 2.19 + */ + enableUpdate: function(flag) { + flag = ( flag !== false ); + /*jshint -W018 */ // Confusing use of '!' + if ( !!this._enableUpdate === !!flag ) { + return flag; + } + /*jshint +W018 */ + this._enableUpdate = flag; + if ( flag ) { + this.debug("enableUpdate(true): redraw "); //, this._dirtyRoots); + this.render(); + } else { + // this._dirtyRoots = null; + this.debug("enableUpdate(false)..."); + } + return !flag; // return previous value + }, + /** Expand (or collapse) all parent nodes. + * + * This convenience method uses `tree.visit()` and `tree.setExpanded()` + * internally. + * + * @param {boolean} [flag=true] pass false to collapse + * @param {object} [opts] passed to setExpanded() + * @since 2.30 + */ + expandAll: function(flag, opts){ + flag = (flag !== false); + this.visit(function(node){ + if( node.hasChildren() !== false && node.isExpanded() !== flag ) { + node.setExpanded(flag, opts); + } + }); + }, + /**Find all nodes that matches condition. + * + * @param {string | function(node)} match title string to search for, or a + * callback function that returns `true` if a node is matched. + * @returns {FancytreeNode[]} array of nodes (may be empty) + * @see FancytreeNode#findAll + * @since 2.12 + */ + findAll: function(match) { + return this.rootNode.findAll(match); + }, + /**Find first node that matches condition. + * + * @param {string | function(node)} match title string to search for, or a + * callback function that returns `true` if a node is matched. + * @returns {FancytreeNode} matching node or null + * @see FancytreeNode#findFirst + * @since 2.12 + */ + findFirst: function(match) { + return this.rootNode.findFirst(match); + }, + /** Find the next visible node that starts with `match`, starting at `startNode` + * and wrap-around at the end. + * + * @param {string|function} match + * @param {FancytreeNode} [startNode] defaults to first node + * @returns {FancytreeNode} matching node or null + */ + findNextNode: function(match, startNode, visibleOnly) { + match = (typeof match === "string") ? _makeNodeTitleStartMatcher(match) : match; + startNode = startNode || this.getFirstChild(); + + var stopNode = null, + parentChildren = startNode.parent.children, + matchingNode = null, + walkVisible = function(parent, idx, fn) { + var i, grandParent, + parentChildren = parent.children, + siblingCount = parentChildren.length, + node = parentChildren[idx]; + // visit node itself + if( node && fn(node) === false ) { + return false; + } + // visit descendants + if( node && node.children && node.expanded ) { + if( walkVisible(node, 0, fn) === false ) { + return false; + } + } + // visit subsequent siblings + for( i = idx + 1; i < siblingCount; i++ ) { + if( walkVisible(parent, i, fn) === false ) { + return false; + } + } + // visit parent's subsequent siblings + grandParent = parent.parent; + if( grandParent ) { + return walkVisible(grandParent, grandParent.children.indexOf(parent) + 1, fn); + } else { + // wrap-around: restart with first node + return walkVisible(parent, 0, fn); + } + }; + + walkVisible(startNode.parent, parentChildren.indexOf(startNode), function(node){ + // Stop iteration if we see the start node a second time + if( node === stopNode ) { + return false; + } + stopNode = stopNode || node; + // Ignore nodes hidden by a filter + if( ! $(node.span).is(":visible") ) { + node.debug("quicksearch: skipping hidden node"); + return; + } + // Test if we found a match, but search for a second match if this + // was the currently active node + if( match(node) ) { + // node.debug("quicksearch match " + node.title, startNode); + matchingNode = node; + if( matchingNode !== startNode ) { + return false; + } + } + }); + return matchingNode; + }, + // TODO: fromDict + /** + * Generate INPUT elements that can be submitted with html forms. + * + * In selectMode 3 only the topmost selected nodes are considered, unless + * `opts.stopOnParents: false` is passed. + * + * @example + * // Generate input elements for active and selected nodes + * tree.generateFormElements(); + * // Generate input elements selected nodes, using a custom `name` attribute + * tree.generateFormElements("cust_sel", false); + * // Generate input elements using a custom filter + * tree.generateFormElements(true, true, { filter: function(node) { + * return node.isSelected() && node.data.yes; + * }}); + * + * @param {boolean | string} [selected=true] Pass false to disable, pass a string to override the field name (default: 'ft_ID[]') + * @param {boolean | string} [active=true] Pass false to disable, pass a string to override the field name (default: 'ft_ID_active') + * @param {object} [opts] default { filter: null, stopOnParents: true } + */ + generateFormElements: function(selected, active, opts) { + opts = opts || {}; + + var nodeList, + selectedName = (typeof selected === "string") ? selected : "ft_" + this._id + "[]", + activeName = (typeof active === "string") ? active : "ft_" + this._id + "_active", + id = "fancytree_result_" + this._id, + $result = $("#" + id), + stopOnParents = this.options.selectMode === 3 && opts.stopOnParents !== false; + + if($result.length){ + $result.empty(); + }else{ + $result = $("
        ", { + id: id + }).hide().insertAfter(this.$container); + } + if(active !== false && this.activeNode){ + $result.append($("", { + type: "radio", + name: activeName, + value: this.activeNode.key, + checked: true + })); + } + function _appender( node ) { + $result.append($("", { + type: "checkbox", + name: selectedName, + value: node.key, + checked: true + })); + } + if ( opts.filter ) { + this.visit(function(node) { + var res = opts.filter(node); + if( res === "skip" ) { return res; } + if ( res !== false ) { + _appender(node); + } + }); + } else if ( selected !== false ) { + nodeList = this.getSelectedNodes(stopOnParents); + $.each(nodeList, function(idx, node) { + _appender(node); + }); + } + }, + /** + * Return the currently active node or null. + * @returns {FancytreeNode} + */ + getActiveNode: function() { + return this.activeNode; + }, + /** Return the first top level node if any (not the invisible root node). + * @returns {FancytreeNode | null} + */ + getFirstChild: function() { + return this.rootNode.getFirstChild(); + }, + /** + * Return node that has keyboard focus or null. + * @returns {FancytreeNode} + */ + getFocusNode: function() { + return this.focusNode; + }, + /** + * Return current option value. + * (Note: this is the preferred variant of `$().fancytree("option", "KEY")`) + * + * @param {string} name option name (may contain '.') + * @returns {any} + */ + getOption: function(optionName) { + return this.widget.option(optionName); + }, + /** + * Return node with a given key or null if not found. + * + * @param {string} key + * @param {FancytreeNode} [searchRoot] only search below this node + * @returns {FancytreeNode | null} + */ + getNodeByKey: function(key, searchRoot) { + // Search the DOM by element ID (assuming this is faster than traversing all nodes). + var el, match; + // TODO: use tree.keyMap if available + // TODO: check opts.generateIds === true + if(!searchRoot){ + el = document.getElementById(this.options.idPrefix + key); + if( el ){ + return el.ftnode ? el.ftnode : null; + } + } + // Not found in the DOM, but still may be in an unrendered part of tree + searchRoot = searchRoot || this.rootNode; + match = null; + searchRoot.visit(function(node){ + if(node.key === key) { + match = node; + return false; // Stop iteration + } + }, true); + return match; + }, + /** Return the invisible system root node. + * @returns {FancytreeNode} + */ + getRootNode: function() { + return this.rootNode; + }, + /** + * Return an array of selected nodes. + * @param {boolean} [stopOnParents=false] only return the topmost selected + * node (useful with selectMode 3) + * @returns {FancytreeNode[]} + */ + getSelectedNodes: function(stopOnParents) { + return this.rootNode.getSelectedNodes(stopOnParents); + }, + /** Return true if the tree control has keyboard focus + * @returns {boolean} + */ + hasFocus: function(){ + return !!this._hasFocus; + }, + /** Write to browser console if debugLevel >= 3 (prepending tree name) + * @param {*} msg string or object or array of such + */ + info: function(msg){ + if( this.options.debugLevel >= 3 ) { + Array.prototype.unshift.call(arguments, this.toString()); + consoleApply("info", arguments); + } + }, +/* + TODO: isInitializing: function() { + return ( this.phase=="init" || this.phase=="postInit" ); + }, + TODO: isReloading: function() { + return ( this.phase=="init" || this.phase=="postInit" ) && this.options.persist && this.persistence.cookiesFound; + }, + TODO: isUserEvent: function() { + return ( this.phase=="userEvent" ); + }, +*/ + + /** + * Make sure that a node with a given ID is loaded, by traversing - and + * loading - its parents. This method is meant for lazy hierarchies. + * A callback is executed for every node as we go. + * @example + * // Resolve using node.key: + * tree.loadKeyPath("/_3/_23/_26/_27", function(node, status){ + * if(status === "loaded") { + * console.log("loaded intermediate node " + node); + * }else if(status === "ok") { + * node.activate(); + * } + * }); + * // Use deferred promise: + * tree.loadKeyPath("/_3/_23/_26/_27").progress(function(data){ + * if(data.status === "loaded") { + * console.log("loaded intermediate node " + data.node); + * }else if(data.status === "ok") { + * node.activate(); + * } + * }).done(function(){ + * ... + * }); + * // Custom path segment resolver: + * tree.loadKeyPath("/321/431/21/2", { + * matchKey: function(node, key){ + * return node.data.refKey === key; + * }, + * callback: function(node, status){ + * if(status === "loaded") { + * console.log("loaded intermediate node " + node); + * }else if(status === "ok") { + * node.activate(); + * } + * } + * }); + * @param {string | string[]} keyPathList one or more key paths (e.g. '/3/2_1/7') + * @param {function | object} optsOrCallback callback(node, status) is called for every visited node ('loading', 'loaded', 'ok', 'error'). + * Pass an object to define custom key matchers for the path segments: {callback: function, matchKey: function}. + * @returns {$.Promise} + */ + loadKeyPath: function(keyPathList, optsOrCallback) { + var callback, i, path, + self = this, + dfd = new $.Deferred(), + parent = this.getRootNode(), + sep = this.options.keyPathSeparator, + pathSegList = [], + opts = $.extend({}, optsOrCallback); + + // Prepare options + if( typeof optsOrCallback === "function" ) { + callback = optsOrCallback; + } else if ( optsOrCallback && optsOrCallback.callback ) { + callback = optsOrCallback.callback; + } + opts.callback = function(ctx, node, status){ + if( callback ) { + callback.call(ctx, node, status); + } + dfd.notifyWith(ctx, [{node: node, status: status}]); + }; + if( opts.matchKey == null ) { + opts.matchKey = function(node, key) { return node.key === key; }; + } + // Convert array of path strings to array of segment arrays + if(!$.isArray(keyPathList)){ + keyPathList = [keyPathList]; + } + for(i=0; i