/********************************************************************** * * * **********************************************************************/ ((typeof define)[0]=='u'?function(f){module.exports=f(require)}:define) (function(require){ var module={} // make module AMD/node compatible... /*********************************************************************/ //var promise = require('promise') var toggler = require('../toggler') var keyboard = require('../keyboard') var object = require('../object') var widget = require('./widget') /*********************************************************************/ // Helpers... // XXX why do we need this??? var quoteWS = function(str){ return str.replace(/(\s)/g, '\\$1') } // Quote a string and convert to RegExp to match self literally. // XXX this depends on jli.quoteRegExp(..) function toRegExp(str){ return RegExp('^' // quote regular expression chars... +quoteRegExp(str) //+str.replace(/([\.\\\/\(\)\[\]\$\*\+\-\{\}\@\^\&\?\<\>])/g, '\\$1') +'$') } function makeBrowserMaker(constructor){ return function(elem, list, rest){ if(typeof(rest) == typeof('str')){ return constructor(elem, { data: list, path: rest }) } else { var opts = {} for(var k in rest){ opts[k] = rest[k] } opts.data = list return constructor(elem, opts) } } } function makeSimpleAction(direction){ return function(elem){ if(elem != null){ this.select(elem) } this.navigate(direction) return this } } /*********************************************************************/ // NOTE: the widget itself does not need a title, that's the job for // a container widget (dialog, field, ...) // ...it can be implemented trivially via an attribute and a :before // CSS class... var BrowserClassPrototype = { // Normalize path... // // This converts the path into a universal absolute array // representation, taking care of relative path constructs including // '.' (current path) and '..' (up one level) // // XXX does this need to handle trailing '/'??? // ...the problem is mainly encoding a trailing '/' into an // array, adding a '' at the end seems both obvious and // artificial... // XXX is this the correct name??? // ...should this be .normalizePath(..)??? path2list: function(path){ var splitter = /[\\\/]/ if(typeof(path) == typeof('str')){ path = path .split(splitter) .filter(function(e){ return e != '' }) } // we've got a relative path... if(path[0] == '.' || path[0] == '..'){ path = this.path.concat(path) } path = path // clear the '..'... // NOTE: we reverse to avoid setting elements with negative // indexes if we have a leading '..' .reverse() .map(function(e, i){ if(e == '..'){ e = '.' path[i] = '.' path[i+1] = '.' } return e }) .reverse() // filter out '.'... .filter(function(e){ return e != '.' }) return path }, // Construct the dom... make: function(obj, options){ var browser = $('
') .addClass('browse-widget '+ ((options.cloudView || false) ? 'cloud-view' : '')) // make thie widget focusable... // NOTE: tabindex 0 means automatic tab indexing and -1 means // focusable bot not tabable... //.attr('tabindex', -1) .attr('tabindex', 0) // focus the widget if something inside is clicked... .click(function(){ if($(this).find(':focus').length == 0){ $(this).focus() } }) if(options.flat){ browser.addClass('flat') } if(options.cls){ browser.addClass(options.cls) } // path... var path = $('
') .addClass('v-block path') /* .click(function(){ // XXX set contenteditable... // XXX set value to path... // XXX select all... }) .on('blur', function(){ // XXX unset contenteditable... }) .keyup(function(){ // XXX update path... // - set /../..../ to path // - use the part after the last '/' ad filter... }) */ if(options.pathPrefix){ path.attr('prefix', options.pathPrefix) } if(options.show_path == false){ path.hide() } browser .append(path) // list... .append($('
') .addClass('v-block list')) return browser }, } // XXX Q: should we make a base list dialog and build this on that or // simplify this to implement a list (removing the path and disabling // traversal)?? // XXX might be a good idea to add a ctrl-c/copy handler... // ...copy path by default but overloadable with something like // .getCopyValue() which would return .strPath by default... var BrowserPrototype = { dom: null, // option defaults and doc... options: { // CSS classes to add to widget... cls: null, // Initial path... //path: null, //show_path: true, // Set the path prefix... // // XXX at this time this is used only for generating paths, need // to also use this for parsing... pathPrefix: '/', // Enable/disable user selection filtering... // // NOTE: this only affects starting the filter... filter: true, // Enable/disable full path editing... // // NOTE: as with .filter above, this only affects .startFullPathEdit(..) fullPathEdit: true, // If false will disable traversal... // NOTE: if false this will also disable traversal up. // NOTE: this will not disable manual updates or explicit path // setting. // NOTE: another way to disable traversal is to set // .not-traversable on the .browse-widget element // NOTE: if false this will also disable .toggleNonTraversableDrawing() // as this will essentially hide/show the whole list. traversable: true, // If true non-traversable items will be shown... // // NOTE: setting both this and .traversable to false will hide // all elements in the list. showNonTraversable: true, // If true disabled items will be shown... // // NOTE: this will have an effect only on items disabled via list/make // items with .disabled CSS class set manually will not be // affected... showDisabled: true, // XXX showHidden: false, // Enable/disable disabled drawing... // // If false these will disable the corresponding methods. // // NOTE: these are here to let the user enable/disable these // without the need to go into the keyboard configuration... // NOTE: non-traversable drawing is disabled/enabled by .traversable // option above. // NOTE: this will have an effect only on items disabled via list/make // items with .disabled CSS class set manually will not be // affected... toggleDisabledDrawing: true, // XXX toggleHiddenDrawing: true, // Group traversable elements... // // Possible values: // null | false | 'none' - show items as-is // 'first' - group traversable items at top // 'last' - group traversable items at bottom sortTraversable: null, // Controls the display of the action button on each list item... // // Possible values: // false - disable the action button // true - show default action button // - display in action button actionButton: false, // Controls the display of the push button on each list item... // // This has the same semantics as .actionButton so see that for // more info. pushButton: false, // A set of custom buttons to add to each item. // // Format: itemButtons: false, // Handle keys that are not bound... // NOTE: to disable, set ot undefined. logKeys: function(k){ window.DEBUG && console.log(k) }, // If set disables leading and trailing '/' on list and path // elements. // This is mainly used for flat list selectors. flat: false, // If set this will switch the browse dialog into cloud mode. cloudView: false, // List of events that will not get propagated outside the browser... // // NOTE: these are local events defined on the widget, so it // would not be logical to propagate them up the DOM, but if // such behavior is desired one could always change the // configuration ;) nonPropagatedEvents: [ 'push', 'pop', 'open', 'update', 'select', 'deselect', 'keydown', //'close', ], // Shorthand elements... // // Format: // { // : { // class: , // html: , // }, // ... // } // // If make(..) gets passed it will construct and element // via with an optional // // NOTE: .class is optional... // NOTE: set this to null to disable shorthands... elementShorthand: { '---': { 'class': 'separator', 'html': '
' }, '...': { 'class': 'separator', 'html': '
', }, }, // Separator class... // // NOTE: if make(..) is passed an element with this class it will // be treated as a separator and not as a list element. // NOTE: to disable class checking set this to null elementSeparatorClass: 'separator', // Hold browse widget's size between updates... // // This prevents the element from collapsing and then growing // again on slowish loads. // // Supported values: // - null/false/undefined - feature disabled // - number - number of milliseconds to hold size // before timing out // - true - hold till first make is called // without a timeout. // // NOTE: recommended values are about the same value till the // first make(..) is called, but obviously this should be // as short as possible -- under 20-50ms. holdSize: 10, }, // XXX TEST: this should prevent event propagation... // XXX should we have things like ctrl- for fast selection // in filter mode??? keyboard: { // XXX this is the same as FullPathEdit, should we combine the two? ItemEdit: { pattern: '.browse-widget .list .text[contenteditable]', // keep text editing action from affecting the selection... ignore: [ 'Backspace', 'Up', 'Down', 'Left', 'Right', 'Home', 'End', 'Enter', 'Esc', '/', 'A', 'P', 'O', 'T', 'D', // let the system handle copy paste... 'C', 'V', 'X', // enter numbers as-is... '#1', '#2', '#3', '#4', '#5', '#6', '#7', '#8', '#9', ], // XXX Enter: 'push!', Esc: 'update!', }, FullPathEdit: { pattern: '.browse-widget .path[contenteditable]', // keep text editing action from affecting the selection... ignore: [ 'Backspace', 'Up', 'Down', 'Left', 'Right', 'Home', 'End', 'Enter', 'Esc', '/', 'A', 'P', 'O', 'T', 'D', // let the system handle copy paste... 'C', 'V', 'X', // enter numbers as-is... '#1', '#2', '#3', '#4', '#5', '#6', '#7', '#8', '#9', ], Enter: 'stopFullPathEdit!', Esc: 'abortFullPathEdit!', }, Filter: { pattern: '.browse-widget .path div.cur[contenteditable]', // keep text editing action from affecting the selection... ignore: [ 'Backspace', 'Left', 'Right', 'Home', 'End', 'Enter', 'Esc', '/', 'A', 'P', 'O', 'T', 'D', // let the system handle copy paste... 'C', 'V', 'X', // enter numbers as-is... '#1', '#2', '#3', '#4', '#5', '#6', '#7', '#8', '#9', ], // XXX should this be an action or a push???? //Enter: 'action!', Enter: 'push!', Esc: 'stopFilter!', }, General: { pattern: '.browse-widget', Up: 'up!', Down: 'down!', Left: { default: 'left!', ctrl: 'update!: "/"', }, Backspace: 'Left', Right: 'right', P: 'push', // XXX PgUp: 'prevPage!', PgDown: 'nextPage!', Home: 'navigate!: "first"', End: 'navigate!: "last"', Enter: 'action', O: 'action', Esc: 'close', '/': 'startFilter!', A: { ctrl: 'startFullPathEdit!', }, D: 'toggleDisabledDrawing', H: 'toggleHiddenDrawing', T: 'toggleNonTraversableDrawing', // XXX should these use .select(..)??? // XXX should these be relative to visible area or absolute // to current list regardless of scroll (as is now)??? // XXX should these work while filtering?? '#1': 'push!: "0!"', '#2': 'push!: "1!"', '#3': 'push!: "2!"', '#4': 'push!: "3!"', '#5': 'push!: "4!"', '#6': 'push!: "5!"', '#7': 'push!: "6!"', '#8': 'push!: "7!"', '#9': 'push!: "8!"', }, }, // Call the constructor's .path2list(..).. // // See: BrowserClassPrototype.path2list(..) for docs... path2list: function(path){ // if list is flat we do not need to split it, just format... if(this.options.flat && path && path.constructor !== Array){ return path == '' || path.length == 0 ? [] : [path] } return this.constructor.path2list.apply(this, arguments) }, // Trigger jQuery events on Browser... // // This will pass the Browser instance to .source attribute of the // event object triggered. // // NOTE: event propagation for some events is disabled by binding // to them handlers that stop propagation in .__init__(..). // The list of non-propagated events in defined in // .options.nonPropagatedEvents // // XXX triggering events from here and from jQuery/dom has a // different effect... //trigger: widget.triggerEventWithSource, // specific events... focus: function(handler){ if(handler != null){ this.on('focus', handler) // focus only if we do not have focus... } else if(!this.dom.is(':focus') && this.dom.find(':focus').length == 0) { this.dom.focus() } return this }, blur: widget.proxyToDom('blur'), // base api... // XXX should these set both the options and dom??? get flat(){ return !this.dom.hasClass('flat') || this.options.flat }, set flat(value){ if(value){ this.dom.addClass('flat') } else { this.dom.removeClass('flat') } this.options.flat = value }, get cloud(){ return this.dom.hasClass('cloud-view') || this.options.cloudView }, set cloud(value){ if(value){ this.dom.addClass('cloud-view') } else { this.dom.removeClass('cloud-view') } this.options.cloudView = value }, // XXX should these set both the options and dom??? get traversable(){ return !this.dom.hasClass('not-traversable') && this.options.traversable }, set traversable(value){ if(value){ this.dom.removeClass('not-traversable') } else { this.dom.addClass('not-traversable') } this.options.traversable = value }, // Get/set the listed path... // // On more info on setting the path see .update(..) // // NOTE: .path = is equivalent to .update() // NOTE: if the string path assigned does not contain a trailing '/' // the path will be loaded up to the last item and the last item // will be selected (see .update(..) for example). // NOTE: to avoid duplicating and syncing data, the actual path is // stored in DOM... // NOTE: path returned does not include the currently selected list // element, just the path to the current list... // To get the path with selection use: .selectionPath prop get path(){ var skip = false return this.dom.find('.path .dir:not(.cur)') .map(function(i, e){ return $(e).text() }) .toArray() }, set path(value){ this.update(value) }, // String path... // // This is the same as .path but returns a string result. // // NOTE: this does not include the selected element, i.e. the returned // path always ends with a trailing '/'. // NOTE: the setter is just a shorthand to .path setter for uniformity... // // XXX need to append '/' only if traversable... get strPath(){ return this.options.pathPrefix + this.path.join('/') + '/' }, set strPath(value){ this.path = value }, // Get/set path with selection... // // NOTE: this always returns the selected element last if one is // selected, if no element is selected this is equivalent to // .strPath // NOTE: the setter is just a shorthand to .path setter for uniformity... get selectionPath(){ return this.strPath + (this.selected || '') }, set selectionPath(value){ this.path = value }, // Get/set current selection (text)... // // NOTE: .selected = is equivalent to .select() for // more info on accepted values see .select(..) get selected(){ var e = this.select('!') if(e.length <= 0){ return null } return e.find('.text').text() }, set selected(value){ return this.select(value) }, // NOTE: if .options.traversable is false this will have no effect. // XXX might be a good idea to toggle .non-traversable-hidden CSS // class here too... // ...will need to account for 1-9 shortcut keys and hints to // still work... toggleNonTraversableDrawing: function(){ var cur = this.selected if(this.options.traversable == false){ return this } this.options.showNonTraversable = !this.options.showNonTraversable this.update() cur && this.select(cur) return this }, // XXX this will not affect elements that were disabled via setting // the .disabled class and not via list/make... // ...is this a problem??? // XXX might be a good idea to toggle .disabled-hidden CSS class // here too... // ...will need to account for 1-9 shortcut keys and hints to // still work... toggleDisabledDrawing: function(){ var cur = this.selected if(this.options.toggleDisabledDrawing == false){ return this } this.options.showDisabled = !this.options.showDisabled this.update() cur && this.select(cur) return this }, toggleHiddenDrawing: function(){ var cur = this.selected if(this.options.toggleHiddenDrawing == false){ return this } this.options.showHidden = !this.options.showHidden this.update() cur && this.select(cur) return this }, /* // Copy/Paste actions... // // XXX use 'Text' for IE... copy: function(){ var path = this.strPath if(NW){ gui.Clipboard.get() .set(path, 'text') // browser... // XXX use 'Test' for IE... } else if(event != undefined){ event.clipboardData.setData('text/plain', path) } return path }, paste: function(str){ // generic... if(str != null){ this.path = str // nw.js } else if(NW){ this.path = gui.Clipboard.get() .get('text') // browser... // XXX use 'Test' for IE... } else if(event != undefined){ this.path = event.clipboardData.getData('text/plain') } return this }, */ // update (load) path... // - build the path // - build the element list // - bind to control events // - return a deferred // // This will trigger the 'update' event. // // For uniformity and ease of access from DOM, this will also set the // 'path' html attribute on the .browse-widget element. // // If the given string path does not end with a '/' then the path // up to the last item will be loaded and the last item loaded. // // Examle: // Load and select... // '/some/path/there' -> .update('/some/path/') // .select('there') // // Load path only... // '/some/path/there/' -> .update('/some/path/there/') // // // NOTE: setting the DOM attr 'path' works one way, navigating to a // different path will overwrite the attr but setting a new // value to the html attr will not affect the actual path. // NOTE: .path = is equivalent to .update() // both exist at the same time to enable chaining... // NOTE: this will scroll the path to show the last element for paths // that do not fit in view... // // // Item constructor: // This is passed to the lister and can be used by the user to // construct and extend list items. // // Make an item... // make(item, options) // make(item, traversable, disabled, buttons) // -> item // // item format: // - str - item text // NOTE: if text is '---' then a // separator item is created, it is // not selectable (default:
). // // - [str/func, ... ] - item elements // Each of the elements is individually // wrapped in a .text container. // If an item is a function it is called // and the returned value is treated as // the text. // NOTE: empty strings will get replaced // with   // NOTE: if one of the items or constructor // returns is "$BUTTONS" then this // item will get replaced with the // button container // - DOM/jQuery - an element to be used as an item // // Both traversable and disabled are optional and can take bool // values. // // options format: // { // // If true make the element traversable... // traversable: , // // // If true disable the element... // disabled: , // // // If true hide the element... // hidden: , // // // If true the open event will also pass the element to open... // // // // This is useful for opening traversable elements both on // // pressing Enter or Left keys... // // // // This is equivalent to: // // make(...) // // .attr('push-on-open', 'on') // // or: // // make(...) // // .on('open', function(){ // // // X here is the browser object... // // X.push(this) }) // // // push_on_open: , // // // If true this element will be uncondionally hidden on search... // // // // NOTE: this is equivalent to setting .hide-on-search class // // on the element... // hide_on_search: , // // // If true the item will not get searched... // // // // NOTE: this is equivalent to setting .not-searchable class // // on the element... // not_searchable: , // // // If true item will not get hidden on filtering... // // // // NOTE: this is equivalent to setting .not-filtered-out class // // on the element... // not_filtered_out: , // // // element button spec... // buttons: , // } // // format (optional): // [ // [, ], // ... // ] // // NOTE: buttons will override .options.itemButtons, if this is not // desired simply copy .itemButtons and modify it... // // // Finalize the dialog (optional)... // - Call make.done() can optionally be called after all the itmes // are created. This will update the dialog to align the // selected position. // This is useful for dialogs with async loading items. // // // XXX need a way to handle path errors in the extension API... // ...for example, if .list(..) can't list or lists a different // path due to an error, we need to be able to render the new // path both in the path and list sections... // NOTE: current behaviour is not wrong, it just not too flexible... // // XXX one use-case here would be to pass this a custom lister or a full // browser, need to make this work correctly for full set of // events... // - custom lister -- handle all sub-paths in some way... // - full browser -- handle all sub-paths by the nested // browser... // one way to handle nested browsers is to implement a browser // stack which if not empty the top browser handles all the // sub-paths // ...this will also need to indicate a way to split the path // and when to 'pop' the sub browser... update: function(path, list){ path = path || this.path var browser = this.dom var that = this var focus = browser.find(':focus').length > 0 list = list || this.list var deferred = $.Deferred() // string path and terminated with '/' -- no selection... if(typeof(path) == typeof('str') && !/[\\\/]/.test(path.trim().slice(-1))){ path = this.path2list(path) var selection = path.pop() // restore selection if path did not change... } else if(path instanceof Array && path.length == this.path.length && path.filter(function(e, i){ return e != that.path[i] }).length == 0){ var selection = this.selected // no selection... } else { path = this.path2list(path) var selection = null } // prevent the browser from collapsing and then growing on // slow-ish loads... if(this.options.holdSize){ var _freeSize = function(){ browser.height('') browser.width('') } // cleanup, just in case... _freeSize() // only fix the size if we are not empty... if(browser.find('.list').children().length > 0){ browser.height(browser.height()) browser.width(browser.width()) } // reset after a timeout... typeof(this.options.holdSize) == typeof(123) && setTimeout(_freeSize, this.options.holdSize) } // clear the ui... var p = browser.find('.path').empty() var l = browser.find('.list').empty() // set the path prefix... p .attr('prefix', this.options.pathPrefix) .scroll(function(){ // handle path scroll.. if(p[0].offsetWidth < p[0].scrollWidth){ // scroll all the way to the right... p.addClass('scrolling') // left out of view... p[0].scrollLeft > 0 ? p.addClass('left') : p.removeClass('left') // right out of view... p[0].scrollLeft + p[0].offsetWidth + 5 <= p[0].scrollWidth ? p.addClass('right') : p.removeClass('right') // keep left aligned... } else { p.removeClass('scrolling') } }) var c = [] // fill the path field... path.forEach(function(e){ c.push(e) var cur = c.slice() p.append($('
') .addClass('dir') .click(function(){ if(that.traversable){ that.update(cur.join('/')) } }) .text(e)) }) // add current selection indicator... var txt p.append($('
') .addClass('dir cur') .click(function(){ event.stopPropagation() that.toggleFilter('on') }) .on('blur', function(){ that.toggleFilter('off') }) /* XXX does the right thing (replaces the later .focus(..) * and .keyup(..)) but does not work in IE... .on('input', function(){ //that.filterList(quoteWS($(this).text())) that.filterList($(this).text()) }) */ // only update if text changed... .focus(function(){ txt = $(this).text() }) .keyup(function(){ var cur = $(this).text() if(txt != cur){ txt = cur that.filterList(cur) } })) // handle path scroll.. // scroll to the end when wider than view... if(p[0].offsetWidth < p[0].scrollWidth){ // scroll all the way to the right... p.scrollLeft(p[0].scrollWidth) // keep left aligned... } else { p.scrollLeft(0) } var sort_traversable = this.options.sortTraversable var section_tail // fill the children list... // NOTE: this will be set to true if make(..) is called at least once... var interactive = false var size_freed = false // XXX revise signature... var make = function(p, traversable, disabled, buttons){ var opts = {} var hidden = false if(that.options.holdSize){ // we've started, no need to hold the size any more... // ...and we do not need to do this more than once. size_freed = !size_freed ? !_freeSize() : true } // options passed as an object... if(traversable != null && typeof(traversable) == typeof({})){ opts = traversable traversable = opts.traversable disabled = opts.disabled buttons = opts.buttons hidden = opts.hidden } buttons = buttons || (that.options.itemButtons && that.options.itemButtons.slice()) // special case: shorthand... if(p && (p in (that.options.elementShorthand || {}) || (p.hasClass && p in that.options.elementShorthand && that.options.elementShorthand[p].class && p.hasClass(that.options.elementShorthand[p].class)))){ var res = p var shorthand = that.options.elementShorthand[p] if(typeof(res) == typeof('str')){ res = $(shorthand.html) .addClass(shorthand.class || '') } res.appendTo(l) return res } // array of str/func... if(p.constructor === Array){ // resolve handlers... p = p.map(function(e){ return typeof(e) == typeof(function(){}) ? // XXX should this pass anything to the handler // and set the context??? e.call(that, p) : e}) var txt = p.join('') // XXX check if traversable... p = $(p.map(function(t){ return t == '$BUTTONS' ? $('') .addClass('button-container')[0] : t instanceof jQuery ? t[0] : $('') .addClass('text') // here we also replace empty strings with  ... [t ? 'text' : 'html'](t || ' ')[0] })) // jQuery or dom... } else if(p instanceof jQuery){ // XXX is this the correct way to do this??? var txt = p.text() // XXX disable search??? //console.warn('jQuery objects as browse list elements not yet fully supported.') // str and other stuff... } else { var txt = p = p + '' // trailing '/' -- dir... var dir = /[\\\/]\s*$/ traversable = dir.test(p) && traversable == null ? true : traversable traversable = traversable == null ? false : traversable p = $('') .addClass('text') .text(p.replace(dir, '')) } interactive = true // skip drawing of non-traversable or disabled elements if // .showNonTraversable or .showDisabled are false respectively... if((!traversable && !that.options.showNonTraversable) || (disabled && !that.options.showDisabled) || (hidden && !that.options.showHidden)){ return $() } // build list item... var res = $('
') // handle clicks ONLY when not disabled... .click(function(){ if(!$(this).hasClass('disabled')){ //that.push(quoteWS($(this).find('.text').text())) that.push('"'+ $(this).find('.text').text() +'"') } }) // append text elements... .append(p) res.addClass([ !traversable ? 'not-traversable' : '', disabled ? 'disabled' : '', hidden ? 'hidden' : '', opts.hide_on_search ? 'hide-on-search' : '', (opts.hide_on_search || opts.not_searchable) ? 'not-searchable' : '', opts.not_filtered_out ? 'not-filtered-out' : '', ].join(' ')) opts.push_on_open && res.attr('push-on-open', 'on') // buttons... // button container... var btn = res.find('.button-container') btn = btn.length == 0 ? $('') .addClass('button-container') .appendTo(res) : btn // action (open)... if(traversable && that.options.actionButton){ btn.append($('
') .addClass('button') .html(that.options.actionButton === true ? '✓' : that.options.actionButton) .click(function(evt){ evt.stopPropagation() that.select('"'+ txt +'"') that.action() })) } // push... if(traversable && that.options.pushButton){ btn.append($('
') .addClass('button') .html(that.options.pushButton ? 'p' : that.options.pushButton) .click(function(evt){ evt.stopPropagation() that.push('"'+ txt +'"') })) } // custom buttons... buttons && buttons // make the order consistent for the user -- first // in list, first in item (from left), and should // be added last... .reverse() .forEach(function(e){ var html = e[0] var func = e[1] //res.append($('
') btn.append($('
') .addClass('button') .html(html) .click(function(evt){ // prevent clicks from triggering the item action... evt.stopPropagation() // action name... if(typeof(func) == typeof('str')){ that[func](txt) // handler... } else { func.call(that, txt) } })) }) // place in list... // as-is... if(!sort_traversable || sort_traversable == 'none'){ res.appendTo(l) // traversable first/last... } else { if(sort_traversable == 'first' ? traversable : !traversable){ section_tail == null ? l.prepend(res) : section_tail.after(res) section_tail = res } else { res.appendTo(l) } } return res } // align the dialog... make.done = function(){ var s = l.find('.selected') s.length > 0 && that.select(s) } // build the list... var res = list.call(this, path, make) // second API: make is not called and .list(..) returns an Array // that will get loaded as list items... if(!interactive && res && res.constructor == Array){ res.forEach(make) } // wait for the render... if(res && res.then){ res.then(function(){ deferred.resolve() }) // sync... } else { deferred.resolve() } //return this return deferred .done(function(){ that.dom.attr('path', this.strPath) that.trigger('update') // select the item... if(selection){ //that.select(selection) that.select('"'+ selection +'"') } // maintain focus within the widget... if(focus && browser.find(':focus').length == 0){ that.focus() } }) }, // Filter the item list... // // General signature... // .filter([, ][, ]) // -> elements // // // Get all elements... // .filter() // .filter('*') // -> all elements // // Get all elements containing a string... // .filter() // -> elements // NOTE: as whitespace is treated as a pattern separator, if it // is need explicitly simply quote it... // 'a b c' - three sub patterns: 'a', 'b' and 'c' // 'a\ b\ c' - single pattern // // Get element exactly matching a string... // .filter() // -> elements // NOTE: this supports bot single and double quotes, e.g. // '"abc"' and "'abc'" are equivalent... // NOTE: only outer quotes are considered, so if there is a // need to exactly match '"X"', just add a set of quotes // around it, e.g. '""X""' or '\'"X"\''... // // Get all elements matching a regexp... // .filter() // -> elements // // Filter the elements via a function... // .filter() // -> elements // NOTE: the elements passed to the on each iteration // are unwrapped for compatibility with jQuery API. // // Get specific element... // .filter() // .filter() // -> element // -> $() // NOTE: when passing a jQuery-obj it will be returned iff it's // an element. // NOTE: unlike .select(..) index overflow will produce empty // lists rather than to/bottom elements. // // Get specific absolute element... // .filter('!') // -> element // -> $() // NOTE: this is equivalent to setting ignore_disabled tp false // // If function is passed it will get called with // every element that was rejected by the predicate / not matching // the pattern. // // By default, is true, thus this will ignore // disabled elements. If is false then disabled // elements will be searched too. // // If an item has .not-searchable class set, then it will neither be // searched nor filtered out. // // If an item has .not-filtered-out class set, then it will not be // hidden on filtering (see: .filterList(..)). // // NOTE: this will filter every item loaded regardless of visibility. // // // Extended string patterns: // // The pattern string is split by whitespace and each resulting // substring is searched independently. // Order is not considered. // // Examples: // 'aaa' - matches any element containing 'aaa' // (Same as: /aaa/) // 'aa bb' - matches any element containing both 'aa' // AND 'bb' in any order. // (Same as: /aa.*bb|bb.*aa/) // // NOTE: currently there is no way to search for whitespace explicitly, // at this point this is "by-design" as an experiment on how // vital this feature is. // // TODO need to support glob / nested patterns... // ..things like /**/a*/*moo/ should list all matching items in // a single list. // // XXX case sensitivity??? // XXX invalid patterns that the user did not finish inputing??? filter: function(pattern, a, b){ pattern = pattern == null ? '*' : pattern var ignore_disabled = typeof(a) == typeof(true) ? a : b ignore_disabled = ignore_disabled == null ? true : ignore_disabled var rejected = typeof(a) == typeof(true) ? null : a var that = this var browser = this.dom var elems = browser.find('.list>div:not(.not-searchable)' + (this.options.elementSeparatorClass ? ':not('+ this.options.elementSeparatorClass +')' : '') + (ignore_disabled ? ':not(.disabled):not(.filtered-out)' : '')) if(pattern == '*'){ return elems } // special case: absolute position... if(/\d+!/.test(pattern)){ return this.filter(parseInt(pattern), rejected, false) } // function... if(typeof(pattern) == typeof(function(){})){ var filter = function(i, e){ e = e[0] if(!pattern.call(e, i, e)){ if(rejected){ rejected.call(e, i, e) } return false } return true } // regexp... } else if(pattern.constructor == RegExp || (typeof(pattern) == typeof('str') && /^(['"]).*\1$/.test(pattern.trim()))){ if(typeof(pattern) == typeof('str')){ pattern = toRegExp(pattern.trim().slice(1, -1)) } var filter = function(i, e){ if(!pattern.test($(e).find('.text').text())){ if(rejected){ rejected.call(e, i, e) } return false } return true } // string... // NOTE: this supports several space-separated patterns. // NOTE: this is case-agnostic... // ...for case sensitivity remove .toLowerCase()... // XXX support glob... } else if(typeof(pattern) == typeof('str')){ //var pl = pattern.trim().split(/\s+/) var pl = pattern.trim() // split on whitespace but keep quoted chars... .split(/\s*((?:\\\s|[^\s])*)\s*/g) // remove empty strings... .filter(function(e){ return e.trim() != '' }) // remove '\' -- enables direct string comparison... .map(function(e){ return e.replace(/\\(\s)/g, '$1').toLowerCase() }) var filter = function(i, e){ e = $(e) var t = e.find('.text').text().toLowerCase() for(var p=0; p < pl.length; p++){ // NOTE: we are not using search here as it treats // the string as a regex and we need literal // search... var i = t.indexOf(pl[p]) if(!(i >= 0)){ if(rejected){ rejected.call(e, i, e) } return false } } return true } // number... } else if(typeof(pattern) == typeof(123)){ return elems.eq(pattern) // jQuery object... } else if(elems.index(pattern) >= 0){ return pattern // unknown pattern... } else { return $() } return elems.filter(filter) }, // Filter list elements... // // This will set the .filtered-out class on all non-matching elements. // // Use .filterList('*') to clear filter and show all elements. // // If an item has .not-filtered-out class set, then it will not be // hidden on filtering. // // NOTE: see .filter(..) for docs on actual filtering. // NOTE: this does not affect any UI modes, for list filtering mode // see: .toggleFilter(..)... // XXX should this be case insensitive??? filterList: function(pattern){ var that = this var browser = this.dom // show all... if(pattern == null || pattern.trim() == '*' || pattern == ''){ browser.find('.filtered-out') .removeClass('filtered-out') // clear the highlighting... browser.find('.list b') .replaceWith(function() { return this.innerHTML }) // basic filter... } else { // hide stuff that needs to be unconditionally hidden... browser.find('.hide-on-search') .addClass('filtered-out') var p = RegExp('(' + pattern .trim() // ignore trailing '\' .replace(/\\+$/, '') .split(/(?=[^\\])\s/) // drop empty strings... .filter(function(e){ return e.trim() != '' }) // remove escapes... .map(function(e){ return e.replace(/\\(\s)/, '$1') }) .join('|') + ')', 'gi') // XXX should this be case insensitive??? this.filter(pattern, // rejected... function(i, e){ !e.hasClass('not-filtered-out') && e.addClass('filtered-out') e.removeClass('selected') }, // NOTE: setting this to true will not remove disabled // elements from view as they will neither get // included in the filter nor in the filtered out // thus it will require manual setting of the // .filtered-out class false) // passed... .removeClass('filtered-out') // NOTE: this will mess up (clear) any highlighting that was // present before... .each(function(_, e){ e = $(e) .find('.text') // NOTE: here we support multiple text elements per // list element... .each(function(i, e){ e = $(e) var t = e.text() e.html(t.replace(p, '$1')) }) }) } return this }, // internal actions... // full path editing... // // start ----> edit --(enter)--> stop (accept) // | // +-------(esc)--> abort (reset) // // // NOTE: the event handlers for this are set in .__init__()... // // XXX should these be a toggle??? startFullPathEdit: function(){ if(this.options.fullPathEdit){ var browser = this.dom var path = this.strPath var orig = this.selected browser .attr('orig-path', path) .attr('orig-selection', orig) var range = document.createRange() var selection = window.getSelection() var e = browser.find('.path') .text(path) .attr('contenteditable', true) .focus() range.selectNodeContents(e[0]) selection.removeAllRanges() selection.addRange(range) } return this }, abortFullPathEdit: function(){ var browser = this.dom var e = browser.find('.path') var path = '/' + browser.attr('orig-path') var selection = browser.attr('orig-selection') this.stopFullPathEdit(path) if(selection != ''){ this.select(selection) } return this }, stopFullPathEdit: function(path){ var browser = this.dom .removeAttr('orig-path') .removeAttr('orig-selection') var e = browser.find('.path') .removeAttr('contenteditable') this.path = path || e.text() return this .focus() }, // list filtering... // // start ----> edit / select --(enter)--> action (use selection) // | // +-------(blur/esc)--> exit (clear) // // // NOTE: the action as a side effect exits the filter (causes blur // on filter field)... // NOTE: this uses .filter(..) for actual filtering... // NOTE: on state change this will return this... toggleFilter: toggler.CSSClassToggler( function(){ return this.dom }, 'filtering', // do not enter filter mode if filtering is disabled... function(action){ return action != 'on' || this.options.filter }, function(action){ // on... if(action == 'on'){ var range = document.createRange() var selection = window.getSelection() var that = this var e = this.dom.find('.path .dir.cur') //.text('') .attr('contenteditable', true) // place the cursor... //range.setStart(e[0], 0) //range.collapse(true) range.selectNodeContents(e[0]) selection.removeAllRanges() selection.addRange(range) // off... } else { this.filterList('*') this.dom .find('.path .dir.cur') .text('') .removeAttr('contenteditable') // NOTE: we might select an item outside of the current visible // area, thus re-selecting it after we remove the filter // will place it correctly. this.select(this.select('!')) this.focus() } return this }), // shorthands mostly for use as actions... startFilter: function(){ return this.toggleFilter('on') }, stopFilter: function(){ return this.toggleFilter('off') }, // Toggle filter view mode... toggleFilterViewMode: function(){ this.dom.toggleClass('show-filtered-out') return this }, // XXX should this be a toggler??? disableElements: function(pattern){ this.filter(pattern, false) .addClass('disabled') .removeClass('selected') return this }, enableElements: function(pattern){ this.filter(pattern, false) .removeClass('disabled') return this }, // Select an element from current list... // // This is like .filter(..) but: // - adds several special case arguments (see below) // - gets it first matched element and selects it // - takes care of visual scrolling. // // Get selected element if it exists, otherwise select and return // the first... // .select() // -> elem // // Get selected element if it exists, null otherwise... // .select('!') // -> elem // -> $() // // Deselect // .select(null) // -> $() // // Select jQuery object... // .select() // -> elem // -> $() // // All other call configurations are like .filter(..) so see that // for more info. // // This will return a jQuery object. // // This will trigger the 'select' or 'deselect' events. // // For uniformity and ease of access from DOM, this will also set // the value attr on the .browse-widget element. // NOTE: this is one way and setting the html attribute "value" will // not affect the selection, but changing the selection will // overwrite the attribute. // // NOTE: if multiple matches occur this will select the first. // NOTE: 'none' will always return an empty jQuery object, to get // the selection state before deselecting use .select('!') // NOTE: this uses .filter(..) for string and regexp matching... select: function(elem, filtering){ var browser = this.dom var pattern = '.list>div' + (this.options.elementSeparatorClass ? ':not('+ this.options.elementSeparatorClass +')' : '') +':not(.disabled):not(.filtered-out):visible' var elems = browser.find(pattern) if(elems.length == 0){ return $() } filtering = filtering == null ? this.toggleFilter('?') == 'on' : filtering // empty list/string selects none... elem = elem != null && elem.length == 0 ? null : elem // no args -> either we start with the selected or the first... if(elem === undefined){ var cur = this.select('!') elem = cur.length == 0 ? 0 : cur } // explicit deselect... if(elem === null){ if(!filtering){ browser.find('.path .dir.cur').empty() } elems = elems .filter('.selected') .removeClass('selected') this.trigger('deselect', elems) return $() } // strict... if(elem == '!'){ return elems.filter('.selected') } var item = elem instanceof $ ? elem : this.filter(elem).first() // we found a match or got an element... // NOTE: if elem was a keyword it means we have an item with the // same text on the list... if(item.length != 0){ elem = $(item).first() // clear selection... this.select(null, filtering) if(!filtering){ browser.find('.path .dir.cur').text(elem.find('.text').text()) } // handle scroll position... var p = elem.scrollParent() var S = p.scrollTop() var H = p.height() var h = elem.height() var t = elem.offset().top - p.offset().top // XXX should this be in config??? var D = 3 * h // too low... if(t+h+D > H){ p.scrollTop(S + (t+h+D) - H) // too high... } else if(t < D){ p.scrollTop(S + t - D) } // now do the selection... elem.addClass('selected') browser.attr('value', elem.find('.text').text()) this.trigger('select', elem) // handle path scroll -- scroll to the end when wider than view... var p = browser.find('.path') if(p[0].offsetWidth < p[0].scrollWidth){ // scroll all the way to the right... p.scrollLeft(p[0].scrollWidth) // keep left aligned... } else { p.scrollLeft(0) } return elem } // nothing found... return $() }, // Navigate relative to selection... // // Navigate to first/previous/next/last element... // .navigate('first') // .navigate('prev') // .navigate('next') // .navigate('last') // -> elem // NOTE: this will overflow, i.e. navigating 'next' when on the // last element will navigate to the first. // NOTE: when no element is selected, 'next' will select the // first, while 'prev' the last element's // // Deselect element... // .navigate('none') // -> elem // // // Other arguments are compatible with .select(..) and then .filter(..) // but note that this will "shadow" any element with the save name as // a keyword, e.g. if we have an element with the text "next", // .navigate('next') will simply navigate to the next element while // .select('next') / .filter('next') will yield that element by name. navigate: function(action, filtering){ var pattern = '.list>div' + (this.options.elementSeparatorClass ? ':not('+ this.options.elementSeparatorClass +')' : '') +':not(.disabled):not(.filtered-out):visible' action = action || 'first' if(action == 'none'){ return this.select(null, filtering) } else if(action == 'next' || action == 'prev'){ var to = this.select('!', filtering)[action+'All'](pattern).first() // range check and overflow... if(to.length == 0){ action = action == 'next' ? 'first' : 'last' } else { return this.select(to, filtering) } } else if(action == 'down' || action == 'up'){ var from = this.select('!', filtering) // special case: nothing selected -> select first/last... if(from.length == 0){ return this.navigate(action == 'down' ? 'first' : 'last') } var t = from.offset() var l = t.left t = t.top // next lines... var to = from[(action == 'down' ? 'next' : 'prev') +'All'](pattern) .filter(function(_, e){ return $(e).offset().top != t }) // special case: nothing below -> select wrap | last/first... if(to.length == 0){ // select first/last... //return this.navigate(action == 'down' ? 'last' : 'first') // wrap around.... to = this.filter('*').filter(pattern) // when going up start from the bottom... if(action == 'up'){ to = $(to.toArray().reverse()) } } t = to.eq(0).offset().top to = to // next line only... .filter(function(_, e){ return $(e).offset().top == t }) // sort by distance... // XXX this does not account for element width... .sort(function(a, b){ return Math.abs(l - $(a).offset().left) - Math.abs(l - $(b).offset().left) }) .first() return this.select(to, filtering) } return action == 'first' ? this.select(0, filtering) : action == 'last' ? this.select(-1, filtering) // fall back to select... : this.select(action, filtering) }, // shorthand actions... next: makeSimpleAction('next'), prev: makeSimpleAction('prev'), up: makeSimpleAction('up'), down: makeSimpleAction('down'), left: function(elem){ if(elem != null){ this.select(elem) } return this.cloud ? this.navigate('prev') : this.pop() }, right: function(elem){ if(elem != null){ this.select(elem) } return this.cloud ? this.navigate('next') : this.push() }, getTopVisibleElem: function(){ var elems = this.filter('*') var p = elems.first().scrollParent() var S = p.scrollTop() var T = p.offset().top if(S == 0){ return elems.first() } return elems .filter(function(i, e){ return $(e).offset().top - T >= 0 }) .first() }, getBottomVisibleElem: function(){ var elems = this.filter('*') var p = elems.first().scrollParent() var S = p.scrollTop() var T = p.offset().top var H = p.height() if(S + H == p[0].scrollHeight){ return elems.last() } return elems .filter(function(i, e){ e = $(e) return e.offset().top + e.height() <= T + H }) .last() }, // NOTE: this will not give a number greater than the number of // elements, thus for lists without scroll, this will always // return the number of elements. // XXX this will not count the elements at the top if they are // disabled... getHeightInElems: function(){ var t = this.getTopVisibleElem() var b = this.getBottomVisibleElem() var res = 1 while(!t.is(b)){ t = t.next() if(t.length == 0){ break } res += 1 } return res }, // XXX there are two modes of doing page travel: // 1) keep relative to page position // 2) travel up on top element and down on bottom (curret) // ...is this the natural choice? // XXX merge with .select(..)??? // XXX still not too happy with this, item sizes will throw this // off... prevPage: function(){ var t = this.getTopVisibleElem() var cur = this.select('!') // nothing selected... if(cur.length == 0 // element not near the top... // XXX make the delta configurable (see .select(..) // for same issue)... || cur.offset().top - t.offset().top > (3 * t.height())){ // select top... this.select(t) // make the top bottom... } else { var p = t.scrollParent() var S = p.scrollTop() var H = p.height() // rough scroll... // XXX make the delta configurable (see .select(..) // for same issue)... p.scrollTop(S - (H - 4 * t.height())) // select the element and fix scrolling errors... this.select(this.getTopVisibleElem()) } return this }, // XXX this is essentially identical to .prevPage(..) nextPage: function(){ var b = this.getBottomVisibleElem() var cur = this.select('!') // nothing selected... if(cur.length == 0 // element not near the top... // XXX make the delta configurable (see .select(..) // for same issue)... || b.offset().top - cur.offset().top > (3 * b.height())){ // select bottom... this.select(b) // make the top bottom... } else { var p = b.scrollParent() var S = p.scrollTop() var H = p.height() // rough scroll... // XXX make the delta configurable (see .select(..) // for same issue)... p.scrollTop(S + (H - 4 * b.height())) // select the element and fix scrolling errors... this.select(this.getBottomVisibleElem()) } return this }, // Push an element to path / go down one level... // // This will trigger the 'push' event. // // NOTE: if the element is not traversable it will be opened. // // XXX might be a good idea to add a live traversable check... // XXX revise event... push: function(pattern){ var browser = this.dom var cur = this.select('!') var elem = arguments.length == 0 ? cur : this.filter(/-?[0-9]+/.test(pattern) ? pattern // XXX avoid keywords that .select(..) understands... //: '"'+pattern+'"' ) : pattern) // item not found... if(elem.length == 0 && pattern != null){ return this } // item disabled... if(elem.hasClass('disabled')){ return this } // nothing selected, select first and exit... if(cur.length == 0 && elem.length == 0){ this.select() return this } // if not traversable call the action... if(!this.traversable || elem.hasClass('not-traversable')){ this.select(elem) return this.action() } this.select(elem) var path = this.path // XXX do we need qotes here??? //path.push('"'+ elem.find('.text').text() +'"') path.push(elem.find('.text').text()) // XXX should this be before or after the actual path update??? // XXX can we cancel the update from a handler??? this.trigger('push', path) // do the actual traverse... this.path = path this.select() return this }, // Pop an element off the path / go up one level... // // This will trigger the 'pop' event. // // XXX revise event... pop: function(){ var that = this var browser = this.dom if(!this.traversable){ return this } var path = this.path var dir = path.pop() // XXX should this be before or after the actual path update??? // XXX can we cancel the update from a handler??? this .trigger('pop', path) .update(path) .done(function(){ that.select('"'+dir+'"') }) return this }, // Pre-open action... // // This opens (.open(..)) the selected item and if none are selected // selects the default (.select()) and exits. action: function(){ var elem = this.select('!') // nothing selected, select first and exit... if(elem.length == 0){ this.select() return this } var path = this.path //path.push(quoteWS(elem.find('.text').text())) //path.push('"'+ elem.find('.text').text() +'"') path.push(elem.find('.text').text()) var res = this.open(path) return res }, // Extension methods... // ...these are resolved from .options // Open action... // // Open current element... // NOTE: if no element selected this will do nothing. // NOTE: this will return the return of .options.open(..) or the // full path if null is returned... // .open() // -> this // -> object // // Open a path... // .open() // -> this // -> object // // Register an open event handler... // .open() // -> this // // // The following signatures are relative from current context via // .select(..), see it for more details... // NOTE: this will also select the opened element, so to get the full // path from the handler just get the current path and value: // browser.dom.attr('path') +'/'+ browser.dom.attr('value') // or: // browser.selectionPath // // Open first/last element... // .open('first') // .open('last') // -> this // // Open next/prev element... // .open('next') // .open('prev') // -> this // // Open active element at index... // .open() // -> this // // Open element by absolute index... // .open('!') // -> this // // Open element by full or partial text... // .open('') // .open("''") // .open('""') // -> this // // Open first element matching a regexp... // .open() // -> this // // Open an element explicitly... // .open() // -> this // // // This will trigger the 'open' event on the opened element and the // widget. // // This is called when an element is selected and opened. // // By default this happens in the following situations: // - an element is selected and Enter is pressed. // - an element is not traversable and push (Left, click) is called. // // By default this only triggers the 'open' event on both the browser // and the selected element if one exists. // // This is signature compatible with .select(..) but adds support // for full paths. // // The .options.open(..), if defined, will always get the full path // as first argument. // // If 'push-on-open' attribute is set on an element, then this will // also pass the element to .push(..) // // NOTE: if nothing is selected this will do nothing... // NOTE: internally this is never called directly, instead a pre-open // stage is used to execute default behavior not directly // related to opening an item (see: .action()). // NOTE: unlike .list(..) this can be used directly if an item is // selected and an actual open action is defined, either in an // instance or in .options open: function(path){ // special case: register the open handler... if(typeof(path) == typeof(function(){})){ return this.on('open', path) } var elem = this.select('!') // normalize and load path... if(path && (path.constructor == Array || /[\\\/]/.test(path))){ path = this.path2list(path) var elem = path.slice(-1)[0] // only update path if it has changed... if(this.path.filter(function(e, i){ return e == path[i] }).length != path.length - 1){ this.path = path.slice(0, -1) } elem = this.select('"'+ elem +'"') // get path + selection... } else { // select-compatible -- select from current context... if(!path){ // NOTE: this is select compatible thus no need to quote // anything here... elem = this.select(path) } if(elem.length == 0){ return this } path = this.path // NOTE: we are quoting here to get a explicit element // selected from list... path.push('"'+ elem.find('.text').text() +'"') } // get the options method and call it if it exists... var m = this.options.open var args = args2array(arguments) args[0] = path var res = m ? m.apply(this, args) : this res = res || this // XXX do we stringify the path??? // XXX should we use .strPath here??? path = this.options.pathPrefix + path.join('/') // trigger the 'open' events... if(elem.length > 0){ // NOTE: this will bubble up to the browser root... elem.trigger({ type: 'open', source: this, }, path) // push an element if attr is set... // NOTE: a good way to do this is to check if we have any // handlers bound, but so var I've found no non-hack-ish // ways to do this... elem.attr('push-on-open') == 'on' && this.push(elem) } else { this.trigger('open', path) } return res }, // List current path level... // // This will get passed a path and an item constructor and should // return a list. // // NOTE: This is not intended for direct client use, rather it is // designed to either be overloaded by the user in an instance // or in the .options // To re-list/re-load the view use .update() // // // There are two mods of operation: // // 1) interactive: // .list(path, make) // - for each item make is called with it's text // - make will return a jQuery object of the item // // NOTE: selection is currently done based on .find('.text').text() thus the // modification should not affect it's output... // // 2) non-interactive: // .list(path) -> list // - .list(..) should return an array // - make should never get called // - the returned list will be rendered // // // This can set the following classes on elements: // // .disabled // an element is disabled. // // .non-traversable // an element is not traversable/listable and will trigger the // .open(..) on push... // // XXX need a way to constructively communicate errors up... // XXX also need a load strategy when something bad happens... // ...e.g. load up until the first error, or something like: // while(!this.list(path, make)){ // path.pop() // } list: function(path, make){ path = path || this.path var m = this.options.list return m ? m.apply(this, arguments) : [] }, // XXX need to get a container -- UI widget API.... // XXX paste does not work on IE yet... // XXX handle copy... __init__: function(parent, options){ var that = this object.superMethod(Browser, '__init__').call(this, parent, options) var dom = this.dom options = this.options // basic permanent interactions... dom.find('.path') // NOTE: these are used for full-path editing and are defined // here in contrast to other feature handlers as the // '.path' element is long-lived and not rewritten // on .update(..) .dblclick(function(){ that.startFullPathEdit() }) .keyup(function(){ var e = $(this) // clear the list on edit... if(e.attr('contenteditable') && e.text() != dom.attr('orig-path')){ dom.find('.list').empty() } }) /* XXX // Handle copy/paste... // // Make the whole widget support copy/paste of current path. // // NOTE: on nw.js mode this will handle this via keyboard // directly, skipping the events and their quirks... // // XXX does not work on IE yet... // XXX do we handle other types??? // ...try and get the path of anything, including files, dirs, etc... // XXX seems not to work until we cycle any of the editable // controls (filter/path), and then it still is on and // off... // XXX does not work with ':not([contenteditable])' and kills // copy/paste on editable fields without... // XXX do we bother with these?? .on('paste', ':not([contenteditable])', function(){ event.preventDefault() that.paste() }) // XXX does not work... .on('cut copy', function(){ event.preventDefault() that.copy() }) */ // attach to parent... if(parent != null){ parent.append(dom) } // Select the default path... // // NOTE: this may not work when the dialog is loaded async... setTimeout(function(){ // load the initial state... that.update(options.path || that.path || '/') // in case we have a manually selected item but that was // not aligned... that.selected && that.select() }, 0) }, } var Browser = module.Browser = object.makeConstructor('Browser', BrowserClassPrototype, BrowserPrototype) // inherit from widget... Browser.prototype.__proto__ = widget.Widget.prototype /*********************************************************************/ var ListerPrototype = Object.create(Browser.prototype) ListerPrototype.options = { pathPrefix: '', fullPathEdit: false, traversable: false, flat: true, // XXX not sure if we need these... skipDisabledItems: false, // NOTE: to disable this set it to false or null disableItemPattern: '^- ', } // XXX should we inherit or copy options??? // ...inheriting might pose problems with deleting values reverting // them to default instead of nulling them and mutable options might // get overwritten... ListerPrototype.options.__proto__ = Browser.prototype.options var Lister = module.Lister = object.makeConstructor('Lister', BrowserClassPrototype, ListerPrototype) // This is a shorthand for: new List(, { data: }) var makeLister = module.makeLister = function(elem, lister, options){ var opts = {} for(var k in options){ opts[k] = options[k] } opts.list = lister return Lister(elem, opts) } /*********************************************************************/ // Flat list... // // This expects a data option set with the following format: // { // : , // ... // } // // or: // [ // , // ... // ] // // If starts with a '- ' then it will be added disabled, // to control the pattern use the .disableItemPattern option, and to // disable this feature set it to false|null. // // NOTE: this essentially a different default configuration of Browser... var ListPrototype = Object.create(Browser.prototype) ListPrototype.options = { pathPrefix: '', fullPathEdit: false, traversable: false, flat: true, // XXX not sure if we need these... skipDisabledItems: false, // NOTE: to disable this set it to false or null disableItemPattern: '^- ', list: function(path, make){ var that = this var data = this.options.data var keys = data.constructor == Array ? data : Object.keys(data) var pattern = this.options.disableItemPattern && RegExp(this.options.disableItemPattern) return keys .map(function(k){ var disable = null var n = k // XXX make this support list args as well... if(pattern){ var t = typeof(n) == typeof('str') ? n : n[0] if(typeof(t) == typeof('str')){ var tt = t.replace(pattern, '') if(t != tt){ disable = true if(typeof(k) == typeof('str')){ n = tt } else { // NOTE: here we want to avoid .data contamination // so we'll make a copy... n = n.slice() n[0] = tt } if(that.options.skipDisabledItems){ return } } } } var e = make(n, null, disable) if(data !== keys && that.options.data[k] != null){ e.on('open', function(){ return that.options.data[k].apply(this, arguments) }) } return k }) }, } // XXX should we inherit or copy options??? // ...inheriting might pose problems with deleting values reverting // them to default instead of nulling them and mutable options might // get overwritten... ListPrototype.options.__proto__ = Browser.prototype.options var List = module.List = object.makeConstructor('List', BrowserClassPrototype, ListPrototype) // This is a shorthand for: new List(, { data: }) var makeList = module.makeList = makeBrowserMaker(List) /*********************************************************************/ // This is similar to List(..) but will parse paths in keys... // // Path grammar: // // PATH ::= [/] - simple traversable path // | [/]/ - path with last item non-traversable // | [/]/* - path to lister // // ::= // | // // // ::= - explicit path element // | | - multiple path elements (a-la simlink) // // ::= [^\|\\\/]* // // NOTE: always ends with '/' or '\' and produces a set of // traversable items. // NOTE: the last item is non-traversable iff: // - it does not end with '/' or '\' // - there is no other path defined where it is traversable // // // Format: // { // // basic 'file' path... // // NOTE: this path is non-traversable by default, but if a // // sub-path handler is defined (e.g. 'dir/file/x') then this // // will be set traversable... // 'dir/file': function(evt, path){ .. }, // // // file object at the tree root... // // NOTE: the leading '/' is optional... // 'file': function(evt, path){ .. }, // // // a directory handler is defined by path ending with '/', // // set traversable... // 'dir/dir/': function(evt, path){ .. }, // // // add a file object to two dirs... // 'dir|other/other file': function(evt, path){ .. }, // // // path lister... // 'dynamic/*': function(path, make){ .. } // } // // The above definition will be interpreted into the following tree: // // / // dir/ // file // dir/ // other file // file // other/ // other file // dynamic/ // .. // // Here the contents of the '/dynamic/' path are generated by the matching // lister for that pattern path... // // NOTE: in the A|B|C pattern, ALL of the alternatives will be created. // NOTE: there may be multiple matching patterns/listers or a given path // the one used is the longest match. // NOTE: if path is receded with '- ' ('- a|b/c') then the basename of // that path will be disabled, to control the pattern use // .disableItemPattern and to disable this feature set it to false. // // // Handler format: // function(evt, path){ .. } // // This function will be called on the 'open' event for the defined // item. // // // Lister format: // function(path, make){ .. } -> list // // This function will get called on .update(..) of the matching path. // // make(text, traversable) is a list item constructor. // for more docs see: Browser.list(..) // // // NOTE: listers take precedence over explicit path definitions, thus // if a custom lister pattern intersects with a normal path the path // will be ignored and the lister called. // NOTE: currently only trailing '*' are supported. // // XXX add support for '*' and '**' glob patterns... var PathListPrototype = Object.create(Browser.prototype) PathListPrototype.options = { fullPathEdit: true, traversable: true, flat: false, // XXX not sure if we need these... skipDisabledItems: false, // NOTE: to disable this set it to false or null disableItemPattern: '^- ', list: function(path, make){ var that = this var data = this.options.data var keys = data.constructor == Array ? data : Object.keys(data) var pattern = this.options.disableItemPattern && RegExp(this.options.disableItemPattern) if(pattern && this.options.skipDisabledItems){ keys = keys.filter(function(k){ return !pattern.test(k) }) } var visited = [] // match path elements accounting for patterns... // // Supported patterns: // A - matches A exactly // A|B - matches either A or B var match = function(a, path){ // NOTE: might be good to make this recursive when expanding // pattern support... return a .split('|') .filter(function(e){ return e == path }).length > 0 } // get the '*' listers... var lister = keys .filter(function(k){ return k.trim().split(/[\\\/]+/g).pop() == '*' }) .filter(function(k){ k = k.split(/[\\\/]+/) // remove the trailing '*'... .slice(0, -1) // do the match... return k.length <= path.length && k.filter(function(e, i){ return e != '*' && !match(e, path[i]) }).length == 0 }) .sort(function(a, b){ return a.length - b.length}) .pop() // use the custom lister (defined by trailing '*')... if(data !== keys && lister){ return data[lister].call(this, this.options.pathPrefix + path.join('/'), make) // list via provided paths... } else { return keys .map(function(k){ var disable = null if(pattern){ var n = k.replace(pattern, '') disable = n != k k = n } var kp = k.split(/[\\\/]+/g) kp[0] == '' && kp.shift() // see if we have a star... var star = kp.slice(-1)[0] == '*' star && kp.pop() // get and check current path, continue if relevant... var p = kp.splice(0, path.length) if(kp.length == 0 || p.length < path.length || p.filter(function(e, i){ return !match(e, path[i]) }).length > 0){ return false } // get current path element if one exists and we did not create it already... cur = kp.shift() if(cur == undefined){ return false } cur.split('|') // skip empty path items... // NOTE: this avoids creating empty items in cases // of paths ending with '/' or containing '//' .filter(function(e){ return e.trim() != '' }) .forEach(function(cur){ if(visited.indexOf(cur) >= 0){ // set element to traversable if we visit it again... if(kp.length > 0){ that.filter(cur, false) .removeClass('not-traversable') //.removeClass('disabled') } return false } visited.push(cur) // build the element.... var e = make(cur, star || kp.length > 0, // XXX this might still disable a dir... !star && kp.length == 0 && disable) // setup handlers... if(!star && data !== keys && kp.length == 0 && data[k] != null){ e.on('open', function(){ return that.options.data[k].apply(this, arguments) }) } }) return cur }) .filter(function(e){ return e !== false }) } }, } // XXX should we inherit or copy options??? // ...inheriting might pose problems with deleting values reverting // them to default instead of nulling them and mutable options might // get overwritten... PathListPrototype.options.__proto__ = Browser.prototype.options var PathList = module.PathList = object.makeConstructor('PathList', BrowserClassPrototype, PathListPrototype) var makePathList = module.makePathList = makeBrowserMaker(PathList) /********************************************************************** * vim:set ts=4 sw=4 : */ return module })