2018-11-08 21:10:58 +03:00

4509 lines
121 KiB
JavaScript
Executable File

/**********************************************************************
*
*
*
**********************************************************************/
((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: <text>,
//
// ...
// }
//
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: <function>,
//
// // 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: <number> | 'first' | 'last' | <selector>,
//
// ...
// }
//
// 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: <bool>,
//
// // 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: <number> | 'first' | 'last' | <selector>,
//
// // 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: <func(new-text)>,
//
// // Called when editing is done...
// editdone: <func>,
//
// ...
// }
//
// 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([<item>, ...])
// -> <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 = $('<div>')
.addClass('item-group')
.appendTo($(res).parent())
.append($(res))
return group
}
// List of elements...
//
//
// data format:
// [
// // single text element...
// <item-text>,
//
// // multi-text element...
// [<item-text>, ...],
//
// ...
// ]
//
// or:
// {
// <item-lext>: <function>,
// }
//
//
// 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: <pattern> | <function>,
// isItemHidden: <pattern> | <function>,
//
// // if true, disabled/hidden items will not get created...
// skipDisabledItems: false,
// skipHiddenItems: false,
//
// // if true, group the items into a <span> element...
// groupList: false,
//
// // process each dom element...
// each: <function>,
//
// // if true disable all items, if list then disable all items
// // from that list...
// disabled: <bool> | [ <item>, ... ],
//
// // 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([ <item>, .. ])
// .EditableList([ <item>, .. ], <options>)
// -> <items>
//
// Show object keys...
// NOTE: editing buttons are disabled.
// .EditableList({ <item>: <value>, .. })
// .EditableList({ <item>: <value>, .. }, <options>)
// -> <items>
//
// Show list provided via getter/setter function items...
// .EditableList(<func>)
// .EditableList(<func>, <options>)
// -> <items>
//
// 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: <text>,
//
// // If true (default), display the "new..." item, if string set
// // it as item text...
// new_item: <text>|<bool>,
//
// // If true (default), enable direct editing of items...
// //
// editable_items: <bool>,
//
// // 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: [ <key>, ... ],
// item_edit_events: 'menu',
//
// length_limit: <number>,
//
// // 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: <bool> | 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: <bool> || 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 | <list>,
//
// // 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
// // <function> - merge the changes
// //
// update_merge: null | 'drop_changes' | 'keep_changes' | 'merge' | <function>,
//
// // 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: '&#9206;')
// shift_up_button: <html> | null,
// // Down button html... (default: '&#9207;')
// shift_down_button: <html> | 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: '&#10514;' and '&#10515;' respectively.
// to_top_button: false | true | <html>,
// to_bottom_button: false | true | <html>,
//
// // Delete item button...
// delete_button: true | false | <html>,
//
// // 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 || '&#9206;',
function(p, e){
move(p, -1)
&& e.prev().before(e)
// XXX hackish...
&& dialog
.updateItemNumbers()
.trigger('up_button', p, e) }],
DOWN: [options.shift_down_button || '&#9207;',
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) ?
'&#10514;'
: 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) ?
'&#10515;'
: 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($('<span>')
.addClass('sort-handle')
.html('&#x2630;'))
/*
res.find('.button-container')
.before($('<span>')
.addClass('sort-handle')
.html('&#x2630;'))
//*/
// 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 = [
'<span class="pin-set">&#9679;</span>'
+'<span class="pin-unset">&#9675;</span>',
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 || '&times;',
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 = $('<div>')
.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 = $('<div>')
.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($('<div>')
.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
// <text/html> - display <text/html> 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:
// {
// <key>: {
// class: <element-class-str>,
// html: <element-html-str>,
// },
// ...
// }
//
// If make(..) gets passed <key> it will construct and element
// via <element-html-str> with an optional <element-class-str>
//
// NOTE: .class is optional...
// NOTE: set this to null to disable shorthands...
elementShorthand: {
'---': {
'class': 'separator',
'html': '<hr>'
},
'...': {
'class': 'separator',
'html': '<center><div class="loader"/></center>',
},
},
// 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-<number> 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 = <path> is equivalent to .update(<path>)
// 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 = <value> is equivalent to .select(<value>) 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 = <some-path> is equivalent to .update(<some-path>)
// 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 &nbsp;
// 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: <str>,
//
// // If true make the element traversable...
// traversable: <bool>,
//
// // If true disable the element...
// disabled: <bool>,
//
// // If true hide the element...
// hidden: <bool>,
//
// // 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: <bool>,
//
// // 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: <bool>,
//
// // If true the item will not get searched...
// //
// // NOTE: this is equivalent to setting .not-searchable class
// // on the element...
// not_searchable: <bool>,
//
// // 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: <bool>,
//
// // element button spec...
// buttons: <bottons>,
//
// // shortcut key to open the item...
// shortcut_key: <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: <handler>,
// menu: <handler>,
// update: <handler>,
//
// // 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: <handler>,
//
// menu: <handler>,
//
// <event>: <handler>,
// ...
// },
//
// // element attributes...
// attrs: {
// <attr>: <value>,
// ...
// },
//
// // element css style...
// style: {
// <attr>: <value>,
// }
// }
//
// <buttons> format (optional):
// [
// [<html>, <func>],
// ...
// ]
//
// 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($('<div>')
.addClass('dir')
.click(function(){
if(that.traversable){
that.update(cur.join('/'))
}
})
.text(e))
})
// add current selection indicator...
var txt
p.append($('<div>')
.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' ?
$('<span/>')
.addClass('button-container')[0]
: t instanceof jQuery ?
t[0]
: $('<span>')
.addClass('text')
.attr('text', t || '')
// here we also replace empty strings with &nbsp;...
[t ? 'text' : 'html'](t || '&nbsp;')[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 = $('<span>')
.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 ?
`<span class="keyboard-shortcut">${k}</span>`
: 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 = $('<div>')
// 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)
opts.style
&& res.css(opts.style)
//--------------------------------------------- buttons ---
// button container...
var btn = res.find('.button-container')
btn = btn.length == 0 ?
$('<span/>')
.addClass('button-container')
.appendTo(res)
: btn
// action (open) button...
if(traversable && that.options.actionButton){
btn.append($('<div>')
.addClass('button')
.html(that.options.actionButton === true ?
'&check;'
: that.options.actionButton)
.click(function(evt){
evt.stopPropagation()
that.select(res)
that.action()
}))
}
// push button...
if(traversable && that.options.pushButton){
btn.append($('<div>')
.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($('<div>')
.addClass('button blank')
.html(html))
} else {
btn.append($('<div>')
.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(<pattern>[, <rejected-handler>][, <ignore-disabled>])
// -> elements
//
//
// Get all elements...
// .filter()
// .filter('*')
// -> all elements
//
// Get all elements containing a string...
// .filter(<string>)
// -> 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(<quoted-string>)
// -> 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(<regexp>)
// -> elements
//
// Filter the elements via a function...
// .filter(<function>)
// -> elements
// NOTE: the elements passed to the <function> on each iteration
// are unwrapped for compatibility with jQuery API.
//
// Get specific element...
// .filter(<index>)
// .filter(<jQuery-obj>)
// -> 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('<index>!')
// -> element
// -> $()
// NOTE: this is equivalent to setting ignore_disabled tp false
//
// If <rejected-handler> function is passed it will get called with
// every element that was rejected by the predicate / not matching
// the pattern.
//
// By default, <ignore-disabled> is true, thus this will ignore
// disabled elements. If <ignore_disabled> 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, '<b>$1</b>'))
})
})
}
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>)
// -> 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(<path>)
// -> this
// -> object
//
// Register an open event handler...
// .open(<function>)
// -> 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(<number>)
// -> this
//
// Open element by absolute index...
// .open('<number>!')
// -> this
//
// Open element by full or partial text...
// .open('<text>')
// .open("'<text>'")
// .open('"<text>"')
// -> this
//
// Open first element matching a regexp...
// .open(<regexp>)
// -> this
//
// Open an element explicitly...
// .open(<elem>)
// -> 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
// handle close event...
options.close
&& this.close(options.close)
// 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(<elem>, { data: <list> })
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:
// {
// <option-text>: <callback>,
// ...
// }
//
// or:
// [
// <option-text>,
// ...
// ]
//
// If <option-test> 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(<elem>, { data: <list> })
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 ::= [/]<dirs> - simple traversable path
// | [/]<dirs>/<item> - path with last item non-traversable
// | [/]<dirs>/* - path to lister
//
// <dirs> ::= <item>
// | <dirs>/<item>/
//
// <item> ::= <name> - explicit path element
// | <item>|<name> - multiple path elements (a-la simlink)
//
// <name> ::= [^\|\\\/]*
//
// NOTE: <dirs> 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 })