/********************************************************************** * * * **********************************************************************/ ((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... // 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 } } /*********************************************************************/ // Collections of helpers... // // NOTE: from a design perspective all of these constructors can and // will be called on each refresh, so maintaining state should if // state is needed should be done outside of the actual call. // For this reason closures can be used but only for state // relevant within a single call. // So the possible ways to store the outer state: // - threaded through the arguments // Example: // first argument of make.EditableList(..) // - make.dialog attributes // Example: // temporary state of make.EditableList(..) // - config // this requires that the config is saved and maintained // by the caller // This approach is not recommended. // // //--------------------------------------------------------------------- // NOTE: all item constructors/helpers abide by either the new-style // make protocol, i.e. make(content[, options]) or their own... var Items = module.items = function(){} // Empty list place holder... // // XXX should this be in CSS??? Items.Empty = function(msg, options){ options = options || {} options.disabled = options.disabled !== undefined ? options.disabled : true options.hide_on_search = options.hide_on_search !== undefined ? options.hide_on_search : true options.cls = (options.cls || '') + ' empty-msg' msg = msg || options.message || 'Empty...' return this(msg, options) } // NOTE: this is the same as make('---'[, options]) Items.Separator = function(options){ return this('---', options) } // NOTE: this is the same as make('...'[, options]) Items.Spinner = function(options){ return this('...', options) } // Heading... // // options format: // { // doc: , // // ... // } // Items.Heading = function(text, options){ options = Object.create(options || {}) options.cls = (options.cls || '') + ' heading' var attrs = options.doc ? {doc: options.doc} : {} attrs.__proto__ = options.attrs || {} options.attrs = attrs return this(text, options) } // Action... // // XXX should this have a callback??? Items.Action = function(text, options){ options = Object.create(options || {}) options.cls = (options.cls || '') + ' action' return this(text, options) } // Action requiring confirmation... // // options format: // { // // A callback to be called when action is confirmed... // callback: , // // // Time (ms) to wait for confirm before resetting... // timeout: '2000ms', // // // Text to use as confirm message... // // // // Supported placeholders: // // ${text} - item text // // ${text:l} - item text in lowercase // // ${text:u} - item text in uppercase // // ${text:c} - item text capitalized // // // confirm_text: 'Confirm ${text}?', // // ... // } // // XXX doc... // XXX refactor to use options instead of elem modification... Items.ConfirmAction = function(text, options){ options = options || {} var elem = this.Action(text, options) var callback = options.callback var timeout = options.timeout || 2000 var confirm_text = (options.confirm_text || 'Confirm ${text:l}?') .replace(/\$\{text\}/, text) .replace(/\$\{text:l\}/, text.toLowerCase()) .replace(/\$\{text:u\}/, text.toUpperCase()) .replace(/\$\{text:c\}/, text.capitalize()) return elem .on('open', function(){ var item = $(this) var elem = item.find('.text') // ready to delete... if(elem.text() != confirm_text){ text = elem.text() elem.text(confirm_text) item.addClass('warn') // reset... setTimeout(function(){ elem.text(text) item.removeClass('warn') }, timeout) // confirmed... } else { callback && callback() } }) } // Item with auto selected text on select... // // options format: // { // // XXX make this generic, something like cls: ... // action: false, // // select_text: | 'first' | 'last' | , // // ... // } // // NOTE: this need selection enabled in CSS... Items.Selected = function(text, options){ var elem = (options.action ? this.Action : this).call(this, text, options) .on('select', function(){ var text = elem.find('.text') // get the specific .text element... text = // select index... typeof(options.select_text) == typeof(123) ? text.eq(options.select_text) // first/last : (options.select_text == 'first' || options.select_text == 'last') ? text[options.select_text]() // selector... : typeof(options.select_text) == typeof('str') ? elem.find(options.select_text) // all... : text text.selectText() }) return elem } // Editable item or it's part... // // options format: // { // // show as action (via. .Action(..)) // action: , // // // if true, set multi-line mode... // // // // (see: util.makeEditable(..) for more info) // multiline: false, // // // .text element index to edit... // // // // NOTE: by default this will select all the elements, if there // // are more than one, this may result in an odd element // // state... // // NOTE: the selector is used to filter text elements... // edit_text: | 'first' | 'last' | , // // // item event to start the edit on... // start_on: 'select', // // // if true, trigger abort on deselect... // abort_on_deselect: true, // // // If true, clear text when item is selected... // // // // (see: util.makeEditable(..) for more info) // clear_on_edit: false, // // // Keep item selection after abort/commit... // keep_selection: true, // // // Events to stop propagating up... // // // // This is useful to prevent actions that start should an edit // // from triggering something else in the dialog... // // // // If false, nothing will get stopped... // stop_propagation: 'open', // // // Called when editing is abrted... // editaborted: , // // // Called when editing is done... // editdone: , // // ... // } // // XXX add option to select the element on start or just focus it... Items.Editable = function(text, options){ options = options || {} var dialog = this.dialog var start_on = options.start_on || 'select' var stop_propagation = options.stop_propagation === false ? false : 'open' var keep_selection = options.keep_selection === undefined ? true : false var getEditable = function(){ var editable = elem.find('.text') // get the specific .text element... // index... return typeof(options.edit_text) == typeof(123) ? editable.eq(options.edit_text) // first/last... : (options.edit_text == 'first' || options.edit_text == 'last') ? editable[options.edit_text]() // selecter... : typeof(options.edit_text) == typeof('str') ? editable.filter(options.edit_text) // all... : editable } var elem = (options.action ? this.Action : this).call(this, text, options) .on(start_on, function(evt){ event && event.preventDefault() // edit the element... var editable = getEditable() //.makeEditable({ //activate: true, //blur_on_abort: false, //blur_on_commit: false, //multiline: options.multiline, //clear_on_edit: options.clear_on_edit, //reset_on_commit: options.reset_on_commit === undefined ? // true // // XXX need to take this from .makeEditable(..) defaults // : options.reset_on_commit, //reset_on_abort: options.reset_on_abort === undefined ? // true // // XXX need to take this from .makeEditable(..) defaults // : options.reset_on_abort, //) // XXX check if shadowing attrs between .Editable(..) and // util.makeEditable(..) can be a problem... .makeEditable(Object.assign({ activate: true, blur_on_abort: false, blur_on_commit: false, }, options)) !keep_selection // deselect on abort/commit... && editable .on('blur', function(){ dialog.select(null) }) // deselect on abort -- if we started with a select... start_on == 'select' && editable .on('edit-abort', function(){ dialog.select(null) }) // edit event handlers... options.editaborted && editable.on('edit-abort', options.editaborted) options.editdone && editable.on('edit-commit', options.editdone) }) .on('deselect', function(){ //editable && editable.trigger( var editable = getEditable() // XXX need to pass the text.... editable .trigger( options.abort_on_deselect !== false ? 'edit-abort' : 'edit-commit', editable.text()) }) stop_propagation && elem .on(stop_propagation, function(e){ e.stopPropagation() }) return elem } //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // Group items... // // .Group([, ...]) // -> // // This will return a group element, to get the items use .children() // // // Usage example: // make.Group([ // make.Heading('Group'), // make('---'), // make('Group item'), // ... // ]) // Items.Group = function(list){ var res = [] list.forEach(function(e){ e instanceof jQuery ? (res = res.concat(e.toArray())) : e instanceof Array ? (res = res.concat(e)) : res.push(e) }) var group = $('
') .addClass('item-group') .appendTo($(res).parent()) .append($(res)) return group } // List of elements... // // // data format: // [ // // single text element... // , // // // multi-text element... // [, ...], // // ... // ] // // or: // { // : , // } // // // options format: // { // // test if item is disabled/hidden... // // // // NOTE: if this is a string or regexp, this is used via // // .replace(..) so the match will get removed from the item // // text, unless prevented via regexp. // isItemDisabled: | , // isItemHidden: | , // // // if true, disabled/hidden items will not get created... // skipDisabledItems: false, // skipHiddenItems: false, // // // if true, group the items into a element... // groupList: false, // // // process each dom element... // each: , // // // if true disable all items, if list then disable all items // // from that list... // disabled: | [ , ... ], // // // see: make(..) for additional option info. // ... // } // Items.List = function(data, options){ var make = this var res = [] var keys = data instanceof Array ? data : Object.keys(data) options = options || {} var predicates = { disabled: typeof(options.isItemDisabled) == typeof('str') ? RegExp(options.isItemDisabled) : options.isItemDisabled, hidden: typeof(options.isItemHidden) == typeof('str') ? RegExp(options.isItemHidden) : options.isItemHidden, } keys.forEach(function(k){ var txt var opts = Object.create(options) Object.keys(predicates).forEach(function(p){ var test = predicates[p] if(test){ // check only the first item... txt = txt || (k instanceof Array ? k[0] : k) // item passes disabled predicate... if(test instanceof Function && test(txt)){ opts[p] = true // item matches disabled test... } else if(test instanceof RegExp && test.test(txt)){ var t = txt.replace(test, '') opts.disabled = true txt = k instanceof Array ? [t].concat(k.slice(1)) : t // no match -- restore text... } else { txt = k } } }) if(opts.disabled && opts.disabled instanceof Array){ opts.disabled = opts.disabled.indexOf(txt || k) >= 0 } if((opts.disabled && opts.skipDisabledItems) || (opts.hidden && opts.skipHiddenItems)){ return } var elem = make(txt || k, opts) keys !== data && data[k] && elem.on('open', data[k]) opts.each && opts.each.call(elem, txt || k) res.push(elem[0]) }) return options.groupList ? make.Group(res).children() : $(res) } // Editable list of elements... // // This is like .List(..) but adds functionality to add, remove and // manually sort list items. // // Show list items... // .EditableList([ , .. ]) // .EditableList([ , .. ], ) // -> // // Show object keys... // NOTE: editing buttons are disabled. // .EditableList({ : , .. }) // .EditableList({ : , .. }, ) // -> // // Show list provided via getter/setter function items... // .EditableList() // .EditableList(, ) // -> // // This will edit the input list in-place but only when closing the dialog. // // // options format: // { // // List identifier, used when multiple lists are drawn in one // // dialog... // // // // NOTE: if multiple editable lists are drawn this is required. // list_id: , // // // If true (default), display the "new..." item, if string set // // it as item text... // new_item: |, // // // If true (default), enable direct editing of items... // // // editable_items: , // // // Keys to trigger item edit... // // // // default: [ 'F2' ] // // // // NOTE: the keyboard settings are global to dialog, if multiple // // editable lists are defined they mess things up. // item_edit_keys: [ , ... ], // item_edit_events: 'menu', // // length_limit: , // // // Item edit event handler... // // // itemedit: function(from, to){ ... }, // // // Item open event handler... // // // // NOTE: this is simpler that binding to the global open event // // and filtering through the results... // itemopen: function(evt, value){ ... }, // // // Check input value... // check: function(value){ ... }, // // // Normalize new input value... // // // // NOTE: this will replace the input with normalized value. // normalize: function(value){ ... }, // // // If true only unique values will be stored... // // // // If a function this will be used to normalize the values before // // uniqueness check is performed... // // // // NOTE: if this is a function the value returned is only used // // for uniqueness checking and will not be stored. // unique: | function(value){ ... }, // // // called when new item is added to list... // // // itemadded: function(value){ ... }, // // // If true sort values... // // If function will be used as cmp for sorting... // sort: || function(a, b){ ... }, // // // Make list sortable... // // // // This can be: // // true - enable sort (both x and y axis) // // 'y' - sort only in y axis // // 'x' - sort only in x axis // // false - disable // // // // NOTE: this will force .groupList to true. // // NOTE: this depends on jquery-ui's Sortable... // sortable: false, // // // This is called when a new value is added via new_item but // // list length limit is reached... // overflow: function(selected){ ... }, // // // list of items to remove, if not given this will be maintained // // internally // to_remove: null | , // // // Merge list state and external list mode on update... // // // // This can be: // // null - keep dialog state, ignore external state (default) // // 'drop_changes' - replace dialog state with input state // // 'keep_changes' - keep dialog state (ignoring input) // // 'merge' - merge dialog state and input state // // - merge the changes // // // update_merge: null | 'drop_changes' | 'keep_changes' | 'merge' | , // // // Special buttons... // // // // NOTE: these can be used only if .sort if not set. // // // // Item order editing (up/down) // item_order_buttons: false, // // Up button html... (default: '⏶') // shift_up_button: | null, // // Down button html... (default: '⏷') // shift_down_button: | null, // // // Move to top/bottom buttons, if not false the button is enabled, // // if not bool the value is set as button html. // // Defaults when enabled: '⤒' and '⤓' respectively. // to_top_button: false | true | , // to_bottom_button: false | true | , // // // Delete item button... // delete_button: true | false | , // // // Item buttons... // buttons: [ // // Placeholders that if given will be replace with the corresponding // // special button... // // NOTE: placeholders for disabled or not activated buttons // // will get removed. // // NOTE: if button is enabled but no placeholder is preset // // it will be appended to the button list. // // NOTE: special buttons can be set in one of two formats, // // see UP for an example... // // // // Up... // 'UP' | ['html', 'UP'], // // Down... // 'DOWN', // // Move to top... // 'TO_TOP', // // Move to bottom... // 'TO_BOTTOM' // // Remove item... // 'REMOVE', // // // See: itemButtons doc in browse.js for more info... // .. // ], // // ... // } // // // Temporary state is stored in the dialog object: // .__list - cached input list // .__editable - list editable status // .__to_remove - list of items to remove // .__editable_list_handlers // - indicator that the dialog handlers are set up // // // NOTE: if at least one order button is present this will set // .groupList to true // NOTE: this uses .List(..) internally, see it's doc for additional // info. // NOTE: the list must contain strings. // NOTE: this accounts for '$' as a key binding marker in item text... // // XXX should id be the first argument?? // XXX TEST: potential problem: when reloading the list this will // overwrite the .__list[id] cache, with the input list, this may // result in losing the edited state if the lists were not synced // properly... // XXX the problem with this is that it adds elements live while removing // elements on close, either both should be live or both on close... Items.EditableList = function(list, options){ var make = this var dialog = make.dialog // write back the list... var write = function(list, lst){ return (list instanceof Function ? // call the writer... list(lst) // in-place replace list elements... // NOTE: this is necessary as not everything we do with lst // is in-place... : list.splice.apply(list, [0, list.length].concat(lst))) // we need to return the list itself... && lst // in case the list(..) returns nothing... || lst } // save item to lst... var saveItem = function(txt, replace){ if(txt == replace || txt.trim() == ''){ return txt } txt = options.normalize ? options.normalize(txt) : txt // account for '$' as key binding marker... var ntxt = txt.replace(/\$/g, '') // unique-test text... var utxt = options.unique instanceof Function ? options.unique(txt)+'' : null // invalid format... if(options.check && !options.check(txt)){ dialog.update() return } lst = dialog.__list[id] var normalized = lst.map(function(e){ return e.replace(/\$/g, '') }) // list length limit if(options.length_limit && (lst.length >= options.length_limit)){ options.overflow && options.overflow.call(dialog, txt) return } // prevent editing non-arrays... if(!editable || !lst){ return } // check if item pre-existed... var preexisted = utxt ? //lst.indexOf(options.unique(txt)) >= 0 (lst.indexOf(utxt) >= 0 // account for '$' as key binding marker... (XXX ???) || normalized.indexOf(utxt.replace(/\$/g, '')) >= 0) : (lst.indexOf(txt) >= 0 || normalized.indexOf(ntxt) >= 0) // add new value and sort list... ;(replace && lst.indexOf(replace) >= 0) ? lst[lst.indexOf(replace)] = txt : lst.push(txt) // unique... if(options.unique == null || options.unique === true){ // account for '$' as key binding marker... lst = lst.unique(function(e){ return e.replace(/\$/g, '') }) // unique filter... } else if(options.unique instanceof Function){ lst = lst.unique(options.unique) } // itemadded handler... options.itemadded && !(options.unique && preexisted) && options .itemadded.call(dialog, txt) // sort... if(options.sort){ lst = lst .sort(options.sort instanceof Function ? options.sort : undefined) } lst = write(dialog.__list[id], lst) return txt } // edit item inline... var editItem = function(elem){ var elem = $(elem).find('.text').last() from = elem.attr('text') || from elem // NOTE: we need to do this to account for // '$' in names... .html(from) .makeEditable({ activate: true, clear_on_edit: false, abort_keys: [ 'Esc', // XXX 'Up', 'Down', ], }) .on('edit-commit', function(evt, to){ if(to.trim() != ''){ to = saveItem(to, from) options.itemedit && options.itemedit.call(elem, evt, from, to) } }) .on('edit-abort edit-commit', function(_, title){ title = title.trim() == '' ? from : title title = title.replace(/\$/g, '') dialog.update() .then(function(){ dialog.select(`"${title}"`) }) }) } dialog.__list = dialog.__list || {} dialog.__editable = dialog.__editable || {} dialog.__to_remove = dialog.__to_remove || {} dialog.__editable_list_handlers = dialog.__editable_list_handlers || {} options = options || {} var id = options.list_id || 'default' var to_remove = dialog.__to_remove[id] = options.to_remove || dialog.__to_remove[id] || [] // make a copy of options, to keep it safe from changes we are going // to make... options = options || {} var opts = {} for(var k in options){ opts[k] = options[k] } options = opts var lst = // no local data -> load initial state... !dialog.__list[id] ? (list instanceof Function ? list() : list) // load dialog state (ignore input)... : (options.update_merge == null || options.update_merge == 'keep_changes') ? dialog.__list[id] // load input/external state (ignore dialog state)... : (options.update_merge == 'drop_changes') ? (list instanceof Function ? list() : list) // merge local and external states... : (options.update_merge == 'merge') ? (function(local, input){ return input .sort(function(a, b){ // get base order from input... var i = local.indexOf(a) var j = local.indexOf(b) // order items not in input (added/renamed) // via their position in local... i = i == -1 ? input.indexOf(a) : i j = j == -1 ? input.indexOf(b) : j return i - j }) })(dialog.__list[id] || [], list instanceof Function ? list() : list) // user merge... : options.update_merge instanceof Function ? //options.update_merge(dialog.__list[id]) options.update_merge( dialog.__list[id], list instanceof Function ? list() : list) : list instanceof Function ? list() : list var editable = dialog.__editable[id] = lst instanceof Array // NOTE: we .slice() here to make the changes a bit better packaged // or discrete and not done as they come in... lst = lst instanceof Array ? lst.slice() : Object.keys(lst) dialog.__list[id] = lst var buttons = options.buttons = (options.buttons || []).slice() // buttons: options... // NOTE: the order here is important... // NOTE: user-added buttons take priority over these, so we do not // need to check if a button already exists... if(editable && !options.sort){ // up/down... options.item_order_buttons && buttons.push('UP') && buttons.push('DOWN') // top/bottom... options.to_top_button && buttons.push('TO_TOP') options.to_bottom_button && buttons.push('TO_BOTTOM') } // remove... editable && options.delete_button !== false && buttons.push('REMOVE') var move = function(p, offset){ var l = dialog.__list[id] var i = l.indexOf(p) // not in list... if(i < 0 // first element... || (i == 0 && offset < 0) // last element... || (i >= l.length-1 && offset > 0)){ return false } var j = i + offset j = j < 0 ? 0 : j >= l.length ? l.length-1 : j // update list... l.splice(j, 0, l.splice(i, 1)[0]) // return the shift distance... return j - i } var __buttons = { UP: [options.shift_up_button || '⏶', function(p, e){ move(p, -1) && e.prev().before(e) // XXX hackish... && dialog .updateItemNumbers() .trigger('up_button', p, e) }], DOWN: [options.shift_down_button || '⏷', function(p, e){ move(p, 1) && e.next().after(e) // XXX hackish... && dialog .updateItemNumbers() .trigger('down_button', p, e) }], TO_TOP: [ (options.to_top_button === true || buttons.indexOf('TO_TOP') >= 0) ? '⤒' : options.to_top_button, function(p, e){ var d = move(p, -dialog.__list[id].length) d != null //&& e.prevAll().eq(Math.abs(d+1)).before(e) && e.prevAll().last().before(e) && dialog // XXX hackish... .updateItemNumbers() .trigger('to_top_button', p, e) }], TO_BOTTOM: [ (options.to_bottom_button === true || buttons.indexOf('TO_BOTTOM') >= 0) ? '⤓' : options.to_bottom_button, function(p, e){ var d = move(p, dialog.__list[id].length) d != null //&& e.nextAll().eq(Math.abs(d-1)).after(e) && e.nextAll().last().after(e) && dialog // XXX hackish... .updateItemNumbers() .trigger('to_bottom_button', p, e) }], REMOVE: Buttons.markForRemoval( to_remove, options.delete_button !== true ? options.delete_button : undefined) } // replace the button placeholders... // NOTE: only the first button instance is used, also not that all // the config buttons are pushed to the end of the list thus // they will be overridden buy user buttons... var seen = [] buttons = options.buttons = buttons .map(function(button){ var key = button instanceof Array ? button[1] : button // skip seen buttons... if(seen.indexOf(key) >= 0){ return key } var res = button in __buttons ? __buttons[button] : button[1] in __buttons ? [button[0], __buttons[button[1]][1]] : button // group if at least one sort button is present... if(res !== button){ options.groupList = true // avoid duplicates... seen.push(key) } return res.slice() }) // clear out the unused button placeholders... .filter(function(b){ return ['UP', 'DOWN', 'TO_TOP', 'TO_BOTTOM', 'REMOVE'].indexOf(b) < 0 }) // if we are sortable then we will need to also be grouped... options.sortable && (options.groupList = true) // make the list... var res = make.List(lst, options) .attr('list-id', id) // make sortable... if(options.sortable){ // add sort handle... res.find('.text:first-child') .before($('') .addClass('sort-handle') .html('☰')) /* res.find('.button-container') .before($('') .addClass('sort-handle') .html('☰')) //*/ // make the block sortable... res.parent().sortable({ handle: '.sort-handle', axis: options.sortable === true ? false : options.sortable, forcePlaceholderSize: true, containment: 'parent', tolerance: 'pointer', update: function(evt, ui){ var order = ui.item.parent() .find('.item') .map(function(){ //return $(this).find('.text').text() }) return $(this).find('.text').attr('text') || $(this).find('.text').text() }) .toArray() var l = dialog.__list[id] l.splice.apply(l, [0, l.length].concat(order)) dialog.updateItemNumbers() }, }) } // mark items for removal -- if a list is given by user... to_remove.forEach(function(e){ dialog.filter('"'+ e +'"') .addClass('strike-out') }) options.itemopen && res.on('open', function(evt){ options.itemopen.call(this, evt, dialog.selected) }) res = res.toArray() // editable... if(options.editable_items !== false){ var trigger_edit = function(){ dialog.select('!') .trigger('itemedit') } $(res) .on('itemedit', function(){ editItem($(this)) }) ;(options.item_edit_keys || ['F2']) .forEach(function(key){ dialog.keyboard .handler('General', key, trigger_edit) }) options.item_edit_events != false && options.item_edit_events != '' && $(res).on(options.item_edit_events || 'menu', function(){ $(this).trigger('itemedit') }) } // new button... if(options.new_item !== false){ var new_item = options.new_item || true new_item = new_item === true ? '$New...' : new_item res.push(make.Editable( new_item, { action: true, clear_on_edit: true, }) // update list on edit done... .on('edit-commit', function(evt, txt){ if(txt.trim() != ''){ txt = saveItem(txt) dialog.update() .done(function(){ //dialog.select('"'+txt+'"') dialog.select('"'+txt.replace(/\$/g, '')+'"') }) } })) } // dialog handlers... // NOTE: we bind these only once per dialog... if(dialog.__editable_list_handlers[id] == null){ dialog.__editable_list_handlers[id] = true dialog // update the striked-out items (to_remove)... .on('update', function(){ to_remove.forEach(function(e){ dialog.filter('"'+ e +'"') .addClass('strike-out') }) }) // clear the to_remove items + save list... .on('close', function(){ // prevent editing non-arrays... if(!editable){ return } lst = dialog.__list[id] // remove items... to_remove.forEach(function(e){ var i = lst.indexOf(e) i >= 0 && lst.splice(i, 1) }) // sort... if(options.sort){ lst.sort(options.sort !== true ? options.sort : undefined) } write(list, lst) }) } return $(res) } // Editable list of pinnable elements... // // This is like .EditableList(..) but adds the ability to pin items to // the top sub-list and either maintain that sub-list order independently // or keep it the same as the main list... // // Format: // { // // Equivalent to .length_limit option in .List(..) but applies // // only to pins... // pins_length_limit: .. , // // // Equivalent to .sortable option in .List(..) but applies only // // to pins... // pins_sortable: .. , // // // Equivalent to .buttons option in .List(..) but applies only // // to pins... // // If this is not given the same buttons are used for both lists. // pins_buttons: .. , // // ... // } // // XXX should id be the first argument?? Items.EditablePinnedList = function(list, pins, options){ var that = this pins = pins || [] options = options || {} var id = options.list_id var pins_id = id + '-pins' var dialog = this.dialog // prepare the cache... // XXX check if either list/pins is a function... dialog.__list = dialog.__list || {} list = dialog.__list[id] = dialog.__list[id] || list pins = dialog.__list[pins_id] = dialog.__list[pins_id] || pins // link the to_remove lists of pins and the main list... dialog.__to_remove = dialog.__to_remove || {} if(dialog.__to_remove[id] == null){ dialog.__to_remove[id] = dialog.__to_remove[pins_id] = [] } // XXX redraw.... // - sort - within one list this is trivial (history.js) // - pin/unpin - remove item from one list and update the // other... (can we update a sub-list?) //------------------------------------ setup options: main/pins --- // buttons... var buttons = options.buttons = (options.buttons || []).slice() var pins_buttons = (options.pins_buttons || buttons).slice() // pin/unpin button... var pin = [ '' +'', function(p, cur){ // XXX if this line's not here, for some reason on first // run this sees the wrong instance of pins... var pins = dialog.__list[pins_id] // pin... if(!cur.hasClass('pinned')){ // XXX check pins length limit... pins.splice(0, 0, p) // sort pins... sortable || (options.sort instanceof Function ? pins.sort(options.sort) : pins.sortAs(dialog.__list[id])) // unpin... } else { pins.splice(pins.indexOf(p), 1) } // XXX this is slow... that.dialog .update() .then(function(){ that.dialog.trigger('pin_button', p, cur) }) }] ;[buttons, pins_buttons] .forEach(function(b){ var i = b.indexOf('PIN') i < 0 ? b.push(pin) : (b[i] = pin) }) options.isItemHidden = function(e){ return pins.indexOf(e) >= 0 } options.skipHiddenItems = options.skipHiddenItems !== false ? true : false //----------------------------------------- setup options: pins --- var pins_options = { list_id: pins_id, new_item: false, length_limit: options.pins_length_limit || 10, isItemHidden: null, buttons: pins_buttons, } pins_options.__proto__ = options var sortable = pins_options.sortable = options.pins_sortable === undefined || options.pins_sortable if(!sortable){ pins_options.sort = options.sort instanceof Function ? options.sort : pins.sortAs(dialog.__list[id]) } //---------------------------------------------- build the list --- var res = this.EditableList(pins, pins_options) .addClass('pinned') .toArray() res.length > 0 && res.push(this.Separator()[0]) res.concat(this.EditableList( // remove pinned from list... list, options) .toArray()) return $(res) } //--------------------------------------------------------------------- // Browse item buttons (button constructors)... var Buttons = Items.buttons = module.buttons = {} // Mark an item for removal and add it to a list of marked items... // Buttons.markForRemoval = function(list, html){ return [html || '×', function(p, e){ e.toggleClass('strike-out') if(list == null){ return } p = e.find('.text').attr('text') || p if(e.hasClass('strike-out')){ list.indexOf(p) < 0 && list.push(p) } else { var i = list.indexOf(p) i >= 0 && list.splice(i, 1) } }] } /*********************************************************************/ // 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 '..' and markers... // 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 ? '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... // XXX feels a bit over-complicated... // ...might be a good idea to split this into: // - base // - structure // - path/traversable // - navigation (mouse/keyboard) // - search/filtering // - buttons // XXX add a fast redraw mode to .update(..) (???) // - do not clear items // - if args did not change: // - check if cur item is the same // ...same text, options, signature to make(..)??? // - if the same, keep the element // - if different find and place // - if nothing found, create var BrowserPrototype = { dom: null, // option defaults and doc... options: { // CSS classes to add to widget... cls: null, // Initial path... // // NOTE: this can be a number indicating the item to select when // load is done. //path: null, //selected: 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, // Create item shortcuts... // // If false, no shortcuts will be created. setItemShortcuts: true, // Item shortcut text marker... // // This can be a regexp string pattern or a RegExp object. This // should contain one group containing the key. // Everything outside the last group will be cleaned out of the // text... // // NOTE: it is best to keep this HTML compatible, this makes the // use of chars like '&' not to be recommended... itemShortcutMarker: '\\$(\\w)', // 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', ], // List of event handlers that can be set directly from item // options... // // This is a shorthand to using options.events object. itemOptionsEventShorthands: [ 'open', 'menu', 'update', ], // 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: 20, keyboardRepeatPause: 100, }, // XXX need a way to access buttons... // XXX should we have things like ctrl- for fast selection // in filter mode??? keybindings: { ItemEdit: { pattern: '.list .text[contenteditable]', // keep text editing action from affecting the selection... drop: '*', Up: 'NEXT!', Down: 'NEXT!', Tab: 'NEXT!', shift_Tab: 'NEXT!', Enter: 'push!', Esc: 'update!', }, FullPathEdit: { pattern: '.path[contenteditable]', // keep text editing action from affecting the selection... drop: '*', Enter: 'stopFullPathEdit!', Esc: 'abortFullPathEdit!', }, Filter: { pattern: '.path div.cur[contenteditable]', // keep text editing action from affecting the selection... drop: '*', Up: 'NEXT!', Down: 'NEXT!', Tab: 'NEXT!', shift_Tab: 'NEXT!', Enter: 'push!', Esc: 'stopFilter!', }, General: { pattern: '*', Up: 'up!', Down: 'down!', Left: 'left!', ctrl_Left: 'update!: "/"', Right: 'right', Backspace: 'Left', Space: 'Right', // XXX should these also select buttons??? Tab: 'down!', shift_Tab: 'up!', // XXX is this correct?? ctrl_Tab: 'nop!', // XXX PgUp: 'prevPage!', PgDown: 'nextPage!', Home: 'navigate!: "first"', End: 'navigate!: "last"', Enter: 'action', Esc: 'close: "reject"', '/': 'startFilter!', ctrl_A: 'startFullPathEdit!', ctrl_D: 'toggleDisabledDrawing', ctrl_H: 'toggleHiddenDrawing', ctrl_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!"', '#0': 'push!: "9!"', // handlers for standard shortcuts... Menu: 'menu!', ctrl_C: function(){ console.log('!!!!!') }, }, ItemShortcuts: { doc: 'Item shortcuts', pattern: '*', }, }, // Call the constructor's .path2list(..) and clear out shortcut markers... // // See: BrowserClassPrototype.path2list(..) for docs... path2list: function(path){ var marker = this.options.itemShortcutMarker marker = marker && RegExp(marker, 'g') // if list is flat we do not need to split it, just format... if(this.options.flat && path && path instanceof Array){ return (path == '' || path.length == 0) ? [] : [path] } return this.constructor .path2list.apply(this, arguments) .map(function(e){ return marker ? e.replace(marker, '$1') : e }) }, // Trigger jQuery events on Item then bubble to Browser... // // This will extend the event object with: // .source - Browser instance that triggered the event // .type - event type/name // .args - arguments passed to trigger // // 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 trigger: function(event){ var elem = this.select('!') // NOTE: this will propagate up to the dialog... if(elem.length > 0){ var args = [].slice.call(arguments).slice(1) elem.trigger({ type: arguments[0], source: this, args: args, }, args) // no items selected -- trigger event on main ui... } else { object.superMethod(Browser, 'trigger').apply(this, arguments) } return this }, // specific events... focus: function(handler){ if(handler != null){ //this.on('focus', handler) this.on('focus', handler.bind(this)) // 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'), // Trigger/bind to menu event... // // Bind handler to menu event... // .menu(handler) // -> this // // Trigger menu event on current item... // .menu() // -> this // // Select and trigger menu event on selected item... // .menu(pattern) // -> this // menu: function(){ arguments[0] instanceof Function ? this.dom.on('menu', arguments[0]) : this.select(arguments[0] || '!') .trigger('menu', [this.selected]) return this }, // 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 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 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 }, // 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: see: .options.elementShorthand // for shorthands for common elements // // - [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. // // If item matches .options.itemShortcutMarker (default: /\$(\w)/) // then the char after the '$' will be used as a keyboard shortcut // for this item the char wrapped in a span (class: .keyboard-shortcut), // and the marker (in this case '$') will be cleaned out. // Also see: item.options.shortcut_key below. // // NOTE: only the first occurrence of key will get registered... // NOTE: shortcuts can't override Browse shortcuts... // // // options format: // { // // item css class... // cls: , // // // 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: , // // // shortcut key to open the item... // shortcut_key: , // // // event handler shorthands... // // // // These are the sugar for commonly used events in the events // // section below... // // NOTE: these are defined in .options.itemOptionsEventShorthands // open: , // menu: , // update: , // // // event handlers... // events: { // // item-specific update events... // // // // item added to dom by .update(..)... // // NOTE: this is not propagated up, thus it will not trigger // // the list update. // update: , // // menu: , // // : , // ... // }, // // attrs: { // : , // ... // }, // } // // format (optional): // [ // [, ], // ... // ] // // NOTE: buttons will override .options.itemButtons, if this is not // desired simply copy .itemButtons and modify it... // Example: // make(.., { // buttons: [ // // ... // // // dialog here refers to the browse object... // ].concat(dialog.options.itemButtons), // }) // // // Finalize the dialog (optional)... // - Call make.done() can optionally be called after all the items // 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... // XXX should we use the button tag for item buttons??? // ...basically for this to work we need to either reset or override // user-agent-stylesheet... // to override just set most of the affected options to inherit... 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() //-------------------------- prepare the path and selection --- // 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 } //-------------------------------------- prepare for update --- // 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() //---------------------------------------------- setup path --- // 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') }) // 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) } //---------------------------------------------------- make --- 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 // NOTE: this is only used for the contextmenu event... var debounced = false setTimeout(function(){ debounced = true }, 100) //---------------------- prepare for new keyboard shortcuts --- // clear previous shortcuts... var item_shortcuts = this.options.setItemShortcuts ? (this.keybindings.ItemShortcuts = this.keybindings.ItemShortcuts || {}) : null // clear the shortcuts... Object.keys(item_shortcuts).forEach(function(k){ if(k != 'doc' && k != 'pattern'){ delete item_shortcuts[k] } }) var item_shortcut_marker = this.options.itemShortcutMarker item_shortcut_marker = item_shortcut_marker ? RegExp(item_shortcut_marker, 'g') : null var registered_shortcuts = [] //--------------------------------------------- define make --- // 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(true)){ opts = traversable traversable = opts.traversable disabled = opts.disabled buttons = opts.buttons hidden = opts.hidden } buttons = buttons || (that.options.itemButtons && that.options.itemButtons.slice()) // NOTE: this is becoming a bit big, so here the code is // split into more wieldable sections... //------------------------ special case: shorthand item --- 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 } //------------------------------------------- item text --- // array of str/func/dom... 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') .attr('text', t || '') // 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') .attr('text', p.replace(dir, '')) .text(p.replace(dir, '')) } //---------------------------------- keyboard shortcuts --- if(item_shortcuts){ // key set in options... opts.shortcut_key && !item_shortcuts[opts.shortcut_key] && that.keyboard.handler( 'ItemShortcuts', opts.shortcut_key, //function(){ that.push(res) }) function(){ that.select(res) }) // text marker... if(item_shortcut_marker){ var _replace = function(){ // get the last group... var key = [].slice.call(arguments).slice(-3)[0] !item_shortcuts[keyboard.normalizeKey(key)] // NOTE: this is a side-effect... && that.keyboard.handler( 'ItemShortcuts', key, //function(){ that.push(res) }) function(){ that.action(res) }) return key } // clean out markers from text... txt = txt.replace(item_shortcut_marker, '$1') p.filter('.text') .each(function(_, e){ e = $(e) e.html(e.html().replace(item_shortcut_marker, function(){ var k = _replace.apply(this, arguments) var nk = keyboard.normalizeKey(k) // only mark the first occurrence... var mark = !!(registered_shortcuts.indexOf(nk) < 0 && registered_shortcuts.push(nk)) return mark ? `${k}` : k })) }) } } //--------------------------------------------------------- // tell the lister that we have started in interactive mode... 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 item --- var res = $('
') // handle clicks ONLY when not disabled... .click(function(){ !$(this).hasClass('disabled') && that.push($(this)) }) .on('contextmenu', function(evt){ evt.preventDefault() evt.stopPropagation() if(debounced){ that.select($(this)) res.trigger('menu', [txt]) } }) // append text elements... .append(p) // NOTE: this is not done inline because we need access to // res below... res.addClass([ 'item', // XXX use the same algorithm as .select(..) selection && res.text() == selection ? 'selected' : '', !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' : '', // extra user classes... opts.cls || '', ].join(' ')) opts.push_on_open && res.attr('push-on-open', 'on') opts.attrs && res.attr(opts.attrs) //--------------------------------------------- buttons --- // button container... var btn = res.find('.button-container') btn = btn.length == 0 ? $('') .addClass('button-container') .appendTo(res) : btn // action (open) button... if(traversable && that.options.actionButton){ btn.append($('
') .addClass('button') .html(that.options.actionButton === true ? '✓' : that.options.actionButton) .click(function(evt){ evt.stopPropagation() that.select(res) that.action() })) } // push button... if(traversable && that.options.pushButton){ btn.append($('
') .addClass('button') .html(that.options.pushButton ? 'p' : that.options.pushButton) .click(function(evt){ evt.stopPropagation() that.push(res) })) } // custom buttons... buttons && buttons .slice() // 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] // blank button... if(func == null){ btn.append($('
') .addClass('button blank') .html(html)) } else { 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, res) // handler... } else { func.call(that, txt, res) } })) } }) //--------------------------------- user event handlers --- res.on('update', function(evt){ evt.stopPropagation() }) // shorthands... ;(that.options.itemOptionsEventShorthands || []) .forEach(function(p){ res.on(p, opts[p]) }) // events... Object.keys(opts.events || {}) .forEach(function(evt){ res.on(evt, opts.events[evt]) }) //--------------------------------------- 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) } } //------------------------------- item lifecycle events --- res.trigger('update', txt) //--------------------------------------------------------- return res } make.__proto__ = Items // align the dialog... make.done = function(){ var s = l.find('.selected') s.length > 0 && that.select(s) return deferred } make.dialog = this //------------------------------------------ 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) } // -------------------------------- notify that we are done --- // 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 +'"') } // maintain focus within the widget... if(focus && browser.find(':focus').length == 0){ that.focus() } // XXX hackish... that.updateItemNumbers() }) //------------------------------------------------------------- }, // Update item shortcut key number hints... // // Update hints... // .updateItemNumbers() // -> this // // Clear hints... // .updateItemNumbers(true) // -> this // // This should be called every time the list is modified manually, // the automatic side of things is taken care of by .update(..)... // // XXX hackish -- move this back to CSS as soon as :nth-match(..) gets // enough support... updateItemNumbers: function(clear){ this.dom .find('[shortcut-number]') .removeAttr('shortcut-number') !clear && this.filter('*', false) .slice(0, 10) .each(function(i){ $(this).attr('shortcut-number', (i+1)%10) }) return this }, // 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 .item' + (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() // allow pattern matching regardless of special chars... // XXX not sure about this... .replace(/\$/g, '') // 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') .removeClass('selected') // clear selection... e.find('b') .replaceWith(function(){ return this.innerHTML }) }, // 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) // skip non-searchable... .filter(':not(.not-searchable)') // 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... // NOTE: this will not select disabled elements (XXX) select: function(elem, filtering){ var browser = this.dom var pattern = '.list .item' + (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') .trigger('deselect') 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) // XXX not sure if this is correct... if(elem.hasClass('disabled')){ return $() } 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 .item' + (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 all = this.filter('*') //var to = this.select('!', filtering)[action+'All'](pattern).first() var to = all.eq(all.index(this.select('!', filtering)) + (action == 'next' ? 1 : -1)) // stop keyboard repeat... to.length == 1 && this.options.keyboardRepeatPause > 0 && this.keyboard.pauseRepeat && this.keyboard.pauseRepeat() // 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) var all = this.filter('*') // 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) var to = (action == 'down' ? all.slice(all.index(from)) : $(all.slice(0, all.index(from)).toArray().reverse())) .filter(function(_, e){ return $(e).offset().top != t }) // stop keyboard repeat... to.length == 1 && this.options.keyboardRepeatPause > 0 && this.keyboard.pauseRepeat && this.keyboard.pauseRepeat() // 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. // // NOTE: this ignores items with empty text... // XXX not sure about this... action: function(elem){ elem = this.select(elem || '!') // nothing selected, select first and exit... if(elem.length == 0){ //this.select() return this } var path = this.path var txt = elem.find('.text').text() // if text is empty, skip action... if(txt != ''){ //path.push(elem.find('.text').text()) path.push(txt) 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(path instanceof Function){ return this.on('open', path.bind(this)) } 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... this.trigger('open', path) if(elem.length > 0){ // 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) } 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) : [] }, // Run a function in the context of the object... // run: function(func){ var res = func ? func.call(this) : undefined return res === undefined ? this : res }, // 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) } // load the initial state... // NOTE: path can be a number so simply or-ing here is a bad idea... var path = options.path != null ? options.path : that.path var selected = options.selected typeof(path) == typeof(123) ? // select item number... that .update() .then(function(){ that.select(path) }) // select path... : that .update(path || '/') // Select the default path... .then(function(){ // explicit config selection... // NOTE: this takes precedence over the path syntax... // XXX not sure if we need this... // ...currently this is used only when path is // a list and we need to also select an item... selected ? that.select(selected) // we have a manually selected item but that was // not aligned... : that.selected ? that.select() : null }) }, } 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 isItemDisabled: '^- ', } // 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 one of the following formats: // { // : , // ... // } // // or: // [ // , // ... // ] // // If starts with a '- ' then it will be added disabled, // to control the pattern use the .isItemDisabled option, and to // disable this feature set it to false|null. // // NOTE: this essentially a different default configuration of Browser... // NOTE: this is essentially a wrapper around make.List(...) 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 isItemDisabled: '^- ', list: function(path, make){ var that = this var data = this.options.data var res = [] // this is here to get the modified titles... var _make = function(txt){ res.push(txt) return make.apply(make, arguments) } _make.__proto__ = make // build the list... _make .List(data, { isItemDisabled: this.options.isItemDisabled, skipDisabledItems: this.options.skipDisabledItems, }) return res }, } 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) /*********************************************************************/ // Make an list/Array editor... // // // For options format see: Items.EditableList(..) var makeListEditor = module.makeListEditor = function(list, options){ return makeLister(null, function(path, make){ make.EditableList(list, options) }, options) } /*********************************************************************/ // 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 // .isItemDisabled 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 isItemDisabled: '^- ', 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.isItemDisabled && RegExp(this.options.isItemDisabled) 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 // shortcut marker // - see .options.itemShortcutMarker // // NOTE: only the second argument is checked for '|' patterns... var match = function(a, path){ var marker = that.options.itemShortcutMarker marker = marker && RegExp(marker, 'g') path = marker ? e.replace(marker, '$1') : path // NOTE: might be good to make this recursive when expanding // pattern support... return a .split('|') .map(function(e){ return marker ? e.replace(marker, '$1') : e }) .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 }) } }, } 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 })