Files
server/usr/share/psa-horde/imp/js/viewport.js
2026-01-07 20:52:11 +01:00

1903 lines
57 KiB
JavaScript

/**
* viewport.js - Code to create a viewport window, with optional split pane
* functionality.
*
* Usage:
* ======
* var viewport = new ViewPort({ options });
*
* Required options:
* -----------------
* ajax: (function) The function that will send the AJAX request to the server
* endpoint. The parameters to send to the server will be passed in as
* the first argument as a Hash object.
* container: (Element/string) A DOM element/ID of the container that holds
* the viewport. This element should be empty and have no children.
* onContent: (function) A function that takes 2 arguments - the data object
* for the row and a string indicating the current pane_mode.
*
* This function MUST return the HTML representation of the row.
*
* This representation MUST include both the DOM ID (stored in
* the VP_domid data entry) and the CSS class name (stored as an
* array in the VP_bg data entry) in the outermost element.
*
* Selected rows will contain the classname 'vpRowSelected'.
*
*
* Optional options:
* -----------------
* buffer_pages: (integer) The number of viewable pages to send to the browser
* per server access when listing rows.
* empty_msg: (string | function) A string to display when the view is empty.
* Inserted in a SPAN element with class 'vpEmpty'. If a function,
* will use the return value from the function for the text.
* limit_factor: (integer) When browsing through a list, if a user comes
* within this percentage of the end of the current cached
* viewport, send a background request to the server to retrieve
* the next slice.
* list_class: (string) The CSS class to use for the results list.
* list_header: (Element/string) A DOM element to insert above the results list
* as a header.
* lookbehind: (integer) What percentage of the received buffer should be
* used to download rows before the given row number?
* onAjaxRequest: (function) Callback function that allows additional
* parameters to be added to the outgoing AJAX request.
* params: (Hash) The params list (the current view can be
* obtained via the view property).
* return: (Hash) The params list to use for the outgoing
* request.
* onContentOffset: (function) Callback function that alters the starting
* offset of the content about to be rendered.
* params: (integer) The current offset.
* return: (integer) The altered offset.
* page_size: (integer) Default page size to view on load. Only used if
* pane_mode is 'horiz'.
* pane_data: (Element/string) A DOM element/ID of the container to hold
* the split pane data. This element will be moved inside of the
* container element.
* pane_mode: (string) The split pane mode to show on load? Either empty,
* 'horiz', or 'vert'.
* pane_width: (integer) The default pane width to use on load. Only used if
* pane_mode is 'vert'.
* split_bar_class: (object) The CSS class(es) to use for the split bar.
* Takes two properties: 'horiz' and 'vert'.
* split_bar_handle_class: (object) The CSS class(es) to use for the split bar
* handle. Takes two properties: 'horiz' and 'vert'.
* wait: (integer) How long, in seconds, to wait before displaying an
* informational message to users that the list is still being
* built.
*
*
* Custom events:
* --------------
* Custom events are triggered on the container element. The parameters given
* below are available through the 'memo' property of the Event object.
*
* ViewPort:add
* Fired when a row has been added to the screen.
* params: (Element) The viewport row being added.
*
* ViewPort:clear
* Fired when a row is being removed from the screen.
* params: (Element) The viewport row being removed.
*
* ViewPort:contentComplete
* Fired when the view has changed and all viewport rows have been added.
* params: NONE
*
* ViewPort:deselect
* Fired when rows are deselected.
* params: (object) opts = (object) Boolean options [right]
* vs = (ViewPort_Selection) A ViewPort_Selection object.
*
* ViewPort:endFetch
* Fired when a fetch AJAX response is completed.
* params: (string) Current view.
*
* ViewPort:endRangeFetch
* Fired when a fetch rangeslice AJAX response is completed.
* params: (string) Current view.
*
* ViewPort:fetch
* Fired when a non-background AJAX response is sent.
* params: (string) Current view.
*
* ViewPort:remove
* Fired when rows are removed from the buffer.
* params: (ViewPort_Selection) The removed rows.
*
* ViewPort:resize
* Fired when viewport is being resized.
* params: NONE
*
* ViewPort:select
* Fired when rows are selected.
* params: (object) opts = (object) Boolean options [right]
* vs = (ViewPort_Selection) A ViewPort_Selection object.
*
* ViewPort:sliderEnd
* Fired when the scrollbar slide is completed.
* params: NONE
*
* ViewPort:sliderSlide
* Fired when the scrollbar is moved.
* params: NONE
*
* ViewPort:sliderStart
* Fired when the scrollbar is first clicked on.
* params: NONE
*
* ViewPort:splitBarChange
* Fired when the splitbar is moved.
* params: (string) The current pane mode ('horiz' or 'vert').
*
* ViewPort:splitBarEnd
* Fired when the splitbar is released.
* params: (string) The current pane mode ('horiz' or 'vert').
*
* ViewPort:splitBarStart
* Fired when the splitbar is initially clicked.
* params: (string) The current pane mode ('horiz' or 'vert').
*
* ViewPort:wait
* Fired if viewport_wait seconds have passed since request was sent.
* params: (string) Current view.
*
*
* Outgoing AJAX request has the following params:
* -----------------------------------------------
* For ALL requests:
* cache: (string) The list of uids cached on the browser.
* cacheid: (string) A unique string that changes whenever the viewport
* list changes.
* initial: (integer) This is the initial browser request for this view.
* requestid: (integer) A unique identifier for this AJAX request.
* view: (string) The view of the request.
*
* For a row request:
* slice: (string) The list of rows to retrieve from the server.
* In the format: [first_row]:[last_row]
*
* For a search request:
* after: (integer) The number of rows to return after the selected row.
* before: (integer) The number of rows to return before the selected row.
* search: (JSON object) The search query.
*
* For a rangeslice request:
* rangeslice: (integer) If present, indicates that slice is a rangeslice
* request.
* slice: (string) The list of rows to retrieve from the server.
* In the format: [first_row]:[last_row]
*
*
* Incoming AJAX response has the following parameters:
* ----------------------------------------------------
* cacheid: (string) A unique string that changes whenever the viewport
* list changes.
* data: (object) Data for each entry. Keys are a unique ID (see also the
* 'rowlist' entry). Values are the data objects. Internal keys for
* these data objects must NOT begin with the string 'VP_' (reserved
* keys). These values update current cached values.
* data_reset: (integer) If set, purge all browser cached data objects.
* disappear: (array) The list of unique IDs that are browser cached but no
* longer exist on the server.
* label: (string) [REQUIRED on initial response] The label to use for the
* view.
* metadata: (object) Metadata for the view. Entries in buffer are updated
* with these entries.
* metadata_reset: (integer) If set, purges all browser cached metadata.
* rangelist: (object) The list of unique IDs -> rownumbers that correspond
* to the given request. Only returned for a rangeslice request.
* requestid: (string) The request ID from the outgoing AJAX request.
* rowlist: (object) A mapping of unique IDs (keys) to the row numbers
* (values). Row numbers start at 1.
* rowlist_reset: (integer) If set, purges the browser cached rowlist.
* rownum: (integer) The row number to position screen on.
* totalrows: (integer) Total number of rows in the view.
* view: (string) The view ID of the request.
*
*
* Data entries:
* -------------
* In addition to the data provided from the server, the following
* dynamically created entries are also available:
* VP_domid: (string) The DOM ID of the row.
* VP_id: (string) The unique ID used to store the data entry.
* VP_rownum: (integer) The row number of the row.
* VP_view: (string) The containing view.
*
*
* Scroll bars are styled using these CSS class names:
* ---------------------------------------------------
* vpScroll - The scroll bar container.
* vpScrollUp - The UP arrow.
* vpScrollCursor - The cursor used to slide within the bounds.
* vpScrollDown - The DOWN arrow.
*
*
* Requires:
* - prototypejs 1.6+
* - scriptaculous 1.8+ (effects.js only)
* - dragdrop2.js (Horde)
* - slider2.js (Horde)
* - viewport_utils.js
*
* @author Michael Slusarz <slusarz@horde.org>
* @copyright 2005-2015 Horde LLC
* @license GPL-2 (http://www.horde.org/licenses/gpl)
*/
var ViewPort = Class.create({
initialize: function(opts)
{
this.opts = Object.extend({
buffer_pages: 10,
limit_factor: 35,
lookbehind: 40,
split_bar_class: {},
split_bar_handle_class: {}
}, opts);
this.opts.container = $(opts.container);
this.opts.pane_data = $(opts.pane_data);
this.opts.content = new Element('DIV', { className: opts.list_class });
this.opts.list_container = new Element('DIV');
if (this.opts.list_header) {
this.opts.list_container.insert(this.opts.list_header);
}
this.opts.list_container.insert(this.opts.content);
this.opts.container.insert(this.opts.list_container);
this.scroller = new ViewPort_Scroller(this);
this.split_pane = {
curr: null,
currbar: null,
horiz: {
loc: opts.page_size
},
spacer: null,
vert: {
width: opts.pane_width
}
};
this.views = {};
this.pane_mode = opts.pane_mode;
this.isbusy = this.page_size = null;
this.request_num = 1;
this.id = 0;
// Init empty string now.
this.empty_msg = new Element('SPAN', { className: 'vpEmpty' });
Event.observe(window, 'resize', function() { this.onResize(); }.bind(this));
document.observe('DragDrop2:start', this._onDragStart.bindAsEventListener(this));
document.observe('DragDrop2:end', this._onDragEnd.bindAsEventListener(this));
document.observe('dblclick', this._onDragDblClick.bindAsEventListener(this));
},
// view = (string) ID of view.
// opts = (object) background: (boolean) Load view in background?
// search: (object) Search parameters
loadView: function(view, opts)
{
var buffer, curr, ps,
f_opts = {},
init = true;
this._clearWait();
// Need a page size before we can continue - this is what determines
// the slice size to request from the server.
if (this.page_size === null) {
ps = this.getPageSize(this.pane_mode ? 'default' : 'max');
if (isNaN(ps)) {
return this.loadView.bind(this, view, opts).delay(0.1);
}
this.page_size = ps;
}
if (this.view) {
if (!opts.background && (view != this.view)) {
// Need to store current buffer to save current offset
buffer = this._getBuffer();
buffer.setMetaData({ offset: this.currentOffset() }, true);
this.views[this.view] = buffer;
}
init = false;
}
if (opts.background) {
f_opts = { background: true, view: view };
} else {
if (!this.view) {
this.onResize(true);
} else if (this.view != view) {
delete this.active_req;
}
this.view = view;
}
if ((curr = this.views[view])) {
this._updateContent(curr.getMetaData('offset') || 0, f_opts);
if (!opts.background) {
this.opts.container.fire('ViewPort:fetch', view);
this.opts.ajax(this.addRequestParams({ checkcache: 1 }));
}
return;
}
if (!init) {
this.visibleRows().each(this.opts.content.fire.bind(this.opts.content, 'ViewPort:clear'));
this.opts.content.update();
this.scroller.clear();
}
this.views[view] = this._getBuffer(view, true);
if (opts.search) {
f_opts.search = opts.search;
} else {
f_opts.offset = 0;
}
f_opts.initial = 1;
this._fetchBuffer(f_opts);
},
// view = ID of view
deleteView: function(view)
{
if (this.view == view) {
return false;
}
this.opts.container.fire('ViewPort:remove', this.createSelectionBuffer(view));
delete this.views[view];
return true;
},
// rownum = (integer) Row number
// opts = (Object) [bottom, noupdate, top] TODO
scrollTo: function(rownum, opts)
{
var s = this.scroller,
to = null;
opts = opts || {};
s.noupdate = opts.noupdate;
switch (this.isVisible(rownum)) {
case -1:
to = rownum - 1;
break;
case 0:
if (opts.top) {
to = rownum - 1;
}
break;
case 1:
to = opts.bottom
? Math.max(0, rownum - this.getPageSize())
: Math.min(rownum - 1, this.getMetaData('total_rows') - this.getPageSize());
break;
}
if (to !== null) {
s.moveScroll(to);
}
s.noupdate = false;
},
// rownum = (integer) Row number
isVisible: function(rownum)
{
var offset = this.currentOffset();
return (rownum < offset + 1)
? -1
: ((rownum > (offset + this.getPageSize('current'))) ? 1 : 0);
},
// params = (object) Parameters to add to outgoing URL
reload: function(params)
{
this._fetchBuffer({
offset: this.currentOffset(),
params: $H(params),
purge: true
});
},
// vs = (ViewPort_Selection) A ViewPort_Selection object.
remove: function(vs)
{
if (vs.size()) {
if (this.isbusy) {
this.remove.bind(this, vs).delay(0.1);
} else {
this.isbusy = true;
try {
this._remove(vs);
} catch (e) {
this.isbusy = false;
throw e;
}
this.isbusy = false;
}
}
},
// vs = (ViewPort_Selection) A ViewPort_Selection object.
_remove: function(vs)
{
var buffer = vs.getBuffer();
this.deselect(vs);
this.opts.container.fire('ViewPort:remove', vs);
buffer.remove(vs.get('rownum'));
buffer.setMetaData({ total_rows: buffer.getMetaData('total_rows') - vs.size() }, true);
if (vs.getBuffer().getView() == this.view) {
this.requestContentRefresh(this.currentOffset());
}
},
// nowait = (boolean) If true, don't delay before resizing.
// size = (integer) The page size to use instead of auto-determining.
onResize: function(nowait, size)
{
if (!this.opts.content.visible()) {
return;
}
if (this.resizefunc) {
clearTimeout(this.resizefunc);
}
if (nowait) {
this._onResize(size);
} else {
this.resizefunc = this._onResize.bind(this, size).delay(0.1);
}
},
// size = (integer) The page size to use instead of auto-determining.
_onResize: function(size)
{
this.opts.container.fire('ViewPort:resize');
var c_opts = {}, w,
h = this.opts.list_header ? this.opts.list_header.getHeight() : 0,
lh = this._getLineHeight(),
sp = this.split_pane;
if (size) {
this.page_size = size;
}
if (this.view && sp.curr != this.pane_mode) {
c_opts.updated = true;
}
// Get split pane dimensions
switch (this.pane_mode) {
case 'horiz':
this._initSplitBar();
if (!size) {
this.page_size = (sp.horiz.loc && sp.horiz.loc > 0)
? Math.min(sp.horiz.loc, this.getPageSize('max'))
: this.getPageSize('default');
}
sp.horiz.loc = this.page_size;
h += lh * this.page_size;
this.opts.list_container.setStyle({
cssFloat: 'none',
height: h + 'px',
width: 'auto'
});
this.opts.content.setStyle({ width: 'auto' });
sp.currbar.show();
this.opts.pane_data.show().setStyle({
height: Math.max(document.viewport.getHeight() - this.opts.pane_data.viewportOffset()[1], 0) + 'px'
});
break;
case 'vert':
this._initSplitBar();
if (!size) {
this.page_size = this.getPageSize('max');
}
w = this.opts.container.getWidth();
/* Adapt splitbar width to current screen size. */
sp.vert.width = sp.vert.width
? Math.max(15, Math.min(w - 15, sp.vert.width))
: parseInt(w * 0.45, 10);
h += lh * this.page_size - this.opts.container.getLayout().get('border-bottom');
this.opts.list_container.setStyle({
cssFloat: 'left',
height: h + 'px',
width: sp.vert.width + 'px'
});
this.opts.content.setStyle({ width: sp.vert.width + 'px' });
sp.currbar.setStyle({
height: h - sp.currbar.getLayout().get('border-bottom') + 'px'
}).show();
this.opts.pane_data.setStyle({
height: h - this.opts.pane_data.getLayout().get('border-bottom') + 'px'
}).show();
break;
default:
if (sp.curr) {
if (this.pane_mode == 'horiz') {
sp.horiz.loc = this.page_size;
}
[ this.opts.pane_data, sp.currbar ].invoke('hide');
delete sp.curr;
delete sp.currbar;
}
if (!size) {
this.page_size = this.getPageSize('max');
}
this.opts.list_container.setStyle({
cssFloat: 'none',
height: (h + (lh * this.page_size)) + 'px',
width: 'auto'
});
this.opts.content.setStyle({ width: 'auto' });
break;
}
if (this.view) {
this.requestContentRefresh(this.currentOffset(), c_opts);
}
},
// offset = (integer) Offset of row to display
// opts = (object) See _updateContent()
requestContentRefresh: function(offset, opts)
{
offset = Math.max(0, offset);
if (!this._updateContent(offset, opts)) {
return false;
}
var limit = this._getBuffer().isNearingLimit(offset);
if (limit) {
this._fetchBuffer({
background: true,
nearing: limit,
offset: offset
});
}
return true;
},
// opts = (object) The following parameters:
// One of the following is REQUIRED:
// offset: (integer) Value of offset
// search: (object) List of search keys/values
//
// OPTIONAL:
// background: (boolean) Do fetch in background
// callback: (function) A callback to run when the request is complete
// initial: (boolean) Is this the initial access to this view?
// nearing: (string) TODO [only used w/offset]
// params: (object) Parameters to add to outgoing URL
// purge: (boolean) If true, purge the current rowlist and rebuild.
// Attempts to reuse the current data cache.
// view: (string) The view to retrieve. Defaults to current view.
_fetchBuffer: function(opts)
{
if (this.isbusy) {
this._fetchBuffer.bind(this, opts).delay(0.1);
} else {
this.isbusy = true;
try {
this._fetchBufferDo(opts);
} catch (e) {
this.isbusy = false;
throw e;
}
this.isbusy = false;
}
},
_fetchBufferDo: function(opts)
{
var llist, lrows, rlist, tmp, value,
view = (opts.view || this.view),
b = this._getBuffer(view),
params = $H(opts.params),
r_id = this.request_num++;
// Only fire fetch event if we are loading in foreground.
if (!opts.background) {
this.opts.container.fire('ViewPort:fetch', view);
}
params.update({ requestid: r_id });
// Determine if we are querying via offset or a search query
if (opts.search || opts.initial || opts.purge) {
if (opts.search) {
value = opts.search;
params.set('search', Object.toJSON(value));
}
if (opts.initial) {
params.set('initial', 1);
}
if (opts.purge) {
this.opts.container.fire('ViewPort:remove', this.createSelectionBuffer(view));
b.resetRowlist();
}
tmp = this._lookbehind();
params.update({
after: this.bufferSize() - tmp,
before: tmp
});
}
if (!opts.search) {
value = opts.offset + 1;
// llist: keys - request_ids; vals - loading rownums
llist = b.getMetaData('llist') || $H();
lrows = llist.values().flatten();
b.setMetaData({ req_offset: opts.offset }, true);
/* If the current offset is part of a pending request, update
* the offset. */
if (lrows.size() &&
b.sliceLoaded(value, lrows)) {
/* One more hurdle. If we are loading in background, and now
* we are in foreground, we need to search for the request
* that contains the current rownum. For now, just use the
* last request. */
if (!this.active_req && !opts.background) {
this.active_req = llist.keys().numericSort().last();
}
return;
}
/* This gets the list of rows needed which do not already appear
* in the buffer. */
tmp = this._getSliceBounds(value, opts.nearing, view);
rlist = $A($R(tmp.start, tmp.end)).diff(b.getAllRows());
if (!rlist.size()) {
return;
}
/* Add rows to the loading list for the view. */
rlist = rlist.diff(lrows).numericSort();
llist.set(r_id, rlist);
b.setMetaData({ llist: llist }, true);
params.update({ slice: rlist.first() + ':' + rlist.last() });
}
if (opts.callback) {
tmp = b.getMetaData('callback') || $H();
tmp.set(r_id, opts.callback);
b.setMetaData({ callback: tmp }, true);
}
if (!opts.background) {
this.active_req = r_id;
this._handleWait();
}
this.opts.ajax(this.addRequestParams(params, {
noslice: true,
view: view
}));
},
// rownum = (integer) Row number
// nearing = (string) 'bottom', 'top', null
// view = (string) ID of view.
_getSliceBounds: function(rownum, nearing, view)
{
var b_size = this.bufferSize(),
ob = {}, trows;
switch (nearing) {
case 'bottom':
ob.start = rownum + this.getPageSize();
ob.end = ob.start + b_size;
break;
case 'top':
ob.start = Math.max(rownum - b_size, 1);
ob.end = rownum;
break;
default:
ob.start = rownum - this._lookbehind();
/* Adjust slice if it runs past edge of available rows. In this
* case, fetching a tiny buffer isn't as useful as switching
* the unused buffer space to the other endpoint. Always allow
* searching past the value of total_rows, since the size of the
* dataset may have increased. */
trows = this.getMetaData('total_rows', view);
if (trows) {
ob.end = ob.start + b_size;
if (ob.end > trows) {
ob.start -= ob.end - trows;
}
if (ob.start < 1) {
ob.end += 1 - ob.start;
ob.start = 1;
}
} else {
ob.start = Math.max(ob.start, 1);
ob.end = ob.start + b_size;
}
break;
}
return ob;
},
_lookbehind: function()
{
return parseInt((this.opts.lookbehind * 0.01) * this.bufferSize(), 10);
},
// args = (object) The list of parameters.
// opts = (object) [noslice, view]
// Returns a Hash object
addRequestParams: function(args, opts)
{
args = args || {};
opts = opts || {};
var cid = this.getMetaData('cacheid', opts.view),
params = $H(),
cached, rowlist;
params.set('view', opts.view || this.view);
if (cid) {
params.set('cacheid', cid);
}
if (!opts.noslice) {
rowlist = this._getSliceBounds(this.currentOffset(), null, opts.view);
params.set('slice', rowlist.start + ':' + rowlist.end);
}
cached = this._getBuffer(opts.view).getAllUIDs();
if (cached.size()) {
params.set('cache', cached.toViewportUidString());
}
params.update(args);
return this.opts.onAjaxRequest
? $H(this.opts.onAjaxRequest(params))
: params;
},
// r - (object) responseJSON returned from the server.
parseJSONResponse: function(r)
{
if (r.rangelist) {
this.select(this.createSelection('uid', r.rangelist, r.view));
this.opts.container.fire('ViewPort:endRangeFetch', r.view);
}
this._ajaxResponse(r);
},
_ajaxResponse: function(r)
{
if (this.isbusy) {
this._ajaxResponse.bind(this, r).delay(0.1);
return;
}
this.isbusy = true;
this._clearWait();
var callback, offset, tmp,
buffer = this._getBuffer(r.view),
llist = buffer.getMetaData('llist') || $H();
if (r.data_reset) {
this.deselect(this.getSelected(r.view));
this.opts.container.fire('ViewPort:remove', this.createSelectionBuffer(r.view));
} else if (r.disappear && r.disappear.size()) {
this._remove(this.createSelection('uid', r.disappear, r.view));
}
buffer.update(Object.isArray(r.data) ? {} : r.data, Object.isArray(r.rowlist) ? {} : r.rowlist, r.metadata || {}, {
datareset: r.data_reset,
mdreset: r.metadata_reset,
rowreset: r.rowlist_reset
});
llist.unset(r.requestid);
tmp = {
cacheid: r.cacheid,
llist: llist
};
if (r.label) {
tmp.label = r.label;
}
if (r.totalrows) {
tmp.total_rows = r.totalrows;
}
buffer.setMetaData(tmp, true);
if (r.requestid &&
r.requestid == this.active_req) {
delete this.active_req;
callback = buffer.getMetaData('callback');
offset = buffer.getMetaData('req_offset');
if (callback && callback.get(r.requestid)) {
callback.get(r.requestid)(r);
callback.unset(r.requestid);
}
buffer.setMetaData({ callback: undefined, req_offset: undefined }, true);
this.opts.container.fire('ViewPort:endFetch', r.view);
}
if (this.view == r.view) {
this._updateContent(Object.isUndefined(r.rownum) ? (Object.isUndefined(offset) ? this.currentOffset() : offset) : Number(r.rownum) - 1, { updated: r.rowlist_reset });
} else if (r.rownum) {
// We loaded in the background. If rownumber information was
// provided, we need to save this or else we will position the
// viewport incorrectly.
buffer.setMetaData({ offset: Number(r.rownum) - 1 }, true);
}
this.isbusy = false;
},
// offset = (integer) Offset of row to display
// opts = (object) TODO [background, updated, view]
_updateContent: function(offset, opts)
{
offset = Math.max(0, offset);
opts = opts || {};
if (!this._getBuffer(opts.view).sliceLoaded(offset)) {
opts.offset = offset;
this._fetchBuffer(opts);
return false;
}
var added = {},
c = this.opts.content,
page_size = this.getPageSize(),
tmp = [],
vr = this.visibleRows(),
fdiv, rows;
this.scroller.setSize(page_size, this.getMetaData('total_rows'));
this.scrollTo(offset + 1, { noupdate: true, top: true });
offset = this.currentOffset();
if (this.opts.onContentOffset) {
offset = this.opts.onContentOffset(offset);
}
rows = this.createSelection('rownum', $A($R(offset + 1, offset + page_size)));
if (rows.size()) {
fdiv = document.createDocumentFragment().appendChild(new Element('DIV'));
rows.get('dataob').each(function(r) {
var elt;
if (!opts.updated && (elt = $(r.VP_domid))) {
tmp.push(elt);
} else {
fdiv.insert({ top: this.prepareRow(r) });
added[r.VP_domid] = 1;
tmp.push(fdiv.down());
}
}, this);
if (vr.size()) {
vr.pluck('id').diff(rows.get('domid')).each($).compact().each(this.opts.content.fire.bind(this.opts.content, 'ViewPort:clear'));
}
c.childElements().invoke('remove');
tmp.each(function(r) {
c.insert(r);
if (added[r.identify()]) {
this.opts.container.fire('ViewPort:add', r);
}
}, this);
} else {
vr.each(this.opts.content.fire.bind(this.opts.content, 'ViewPort:clear'));
vr.invoke('remove');
c.update(this.empty_msg.clone(true).insert(Object.isFunction(this.opts.empty_msg) ? this.opts.empty_msg() : this.opts.empty_msg));
}
this.scroller.updateDisplay();
this.opts.container.fire('ViewPort:contentComplete');
return true;
},
prepareRow: function(row)
{
var r = Object.clone(row);
r.VP_bg = this.getSelected().contains('uid', r.VP_id)
? [ 'vpRowSelected' ]
: [];
return this.opts.onContent(r, this.pane_mode);
},
updateRow: function(row)
{
var d = $(row.VP_domid);
if (d) {
this.opts.container.fire('ViewPort:clear', d);
d.replace(this.prepareRow(row));
this.opts.container.fire('ViewPort:add', $(row.VP_domid));
}
},
_handleWait: function(call)
{
this._clearWait();
// Server did not respond in defined amount of time. Alert the
// callback function and set the next timeout.
if (call) {
this.opts.container.fire('ViewPort:wait', this.view);
}
// Call wait handler every x seconds
if (this.opts.viewport_wait) {
this.waitHandler = this._handleWait.bind(this, true).delay(this.opts.viewport_wait);
}
},
_clearWait: function()
{
if (this.waitHandler) {
clearTimeout(this.waitHandler);
delete this.waitHandler;
}
},
visibleRows: function()
{
return this.opts.content.select('DIV.vpRow');
},
getMetaData: function(id, view)
{
return this._getBuffer(view).getMetaData(id);
},
setMetaData: function(vals, view)
{
this._getBuffer(view).setMetaData(vals, false);
},
_getBuffer: function(view, create)
{
view = view || this.view;
return (!create && this.views[view])
? this.views[view]
: new ViewPort_Buffer(this, view);
},
bufferLoaded: function(view)
{
return !!this.views[view];
},
bufferCount: function()
{
return Object.keys(this.views).size();
},
currentOffset: function()
{
return this.scroller.currentOffset();
},
// return: (object) The current viewable range of the viewport.
// first: Top-most row offset
// last: Bottom-most row offset
currentViewableRange: function()
{
var offset = this.currentOffset();
return {
first: offset + 1,
last: Math.min(offset + this.getPageSize(), this.getMetaData('total_rows'))
};
},
_getLineHeight: function()
{
var d, mode = this.pane_mode || 'horiz';
if (!this.split_pane[mode].lh) {
// To avoid hardcoding the line height, create a temporary row to
// figure out what the CSS says.
d = new Element('DIV', { className: this.opts.list_class }).insert(this.prepareRow({ VP_domid: null }, mode)).hide();
$(document.body).insert(d);
this.split_pane[mode].lh = d.getHeight();
d.remove();
}
return this.split_pane[mode].lh;
},
// (type) = (string) [null (DEFAULT), 'current', 'default', 'max']
// return: (integer) Number of rows in current view.
getPageSize: function(type)
{
var h, lh;
switch (type) {
case 'current':
return Math.min(this.page_size, this.getMetaData('total_rows'));
case 'default':
return (this.pane_mode == 'vert')
? this.getPageSize('max')
: Math.max(parseInt(this.getPageSize('max') * 0.45, 10), 5);
case 'max':
h = document.viewport.getHeight() - this.opts.content.viewportOffset()[1];
lh = this._getLineHeight();
if (this.split_pane.currbar && this.pane_mode == 'horiz') {
h -= this.split_pane.currbar.getHeight() + lh;
}
return parseInt(h / lh, 10);
default:
return this.page_size;
}
},
bufferSize: function()
{
// Buffer size must be at least the maximum page size.
return Math.round(Math.max(this.getPageSize('max') + 1, this.opts.buffer_pages * this.getPageSize()));
},
limitTolerance: function()
{
return Math.round(this.bufferSize() * (this.opts.limit_factor / 100));
},
// mode = (string) Either 'horiz', 'vert', or empty.
showSplitPane: function(mode)
{
this.pane_mode = mode;
this.onResize(true);
},
// Return the vertical width of the row listing if splitbar is enabled
// and is in vertical mode.
getVertWidth: function()
{
return (this.pane_mode == 'vert')
? this.opts.content.getWidth()
: 0;
},
_initSplitBar: function()
{
var sp = this.split_pane;
if (sp.currbar) {
sp.currbar.hide();
}
sp.curr = this.pane_mode;
if (sp[this.pane_mode].bar) {
sp.currbar = sp[this.pane_mode].bar.show();
return;
}
sp.currbar = sp[this.pane_mode].bar = new Element('DIV', { className: this.opts.split_bar_class[this.pane_mode] })
.insert(new Element('DIV', { className: this.opts.split_bar_handle_class[this.pane_mode] }));
if (!this.opts.pane_data.descendantOf(this.opts.container)) {
this.opts.container.insert(this.opts.pane_data.remove());
}
this.opts.pane_data.insert({ before: sp.currbar });
switch (this.pane_mode) {
case 'horiz':
new Drag(sp.currbar, {
constraint: 'vertical',
ghosting: true,
nodrop: true,
snap: function(x, y) {
var sp = this.split_pane,
l = parseInt((y - sp.pos) / sp.lh, 10);
if (l < 1) {
l = 1;
} else if (l > sp.max) {
l = sp.max;
}
sp.lines = l;
return [ x, sp.pos + (l * sp.lh) ];
}.bind(this)
});
break;
case 'vert':
new Drag(sp.currbar.setStyle({
cssFloat: 'left',
position: 'relative'
}), {
constraint: 'horizontal',
ghosting: true,
nodrop: true,
snapToParent: true
});
break;
}
},
_onDragStart: function(e)
{
var sp = this.split_pane;
if (e.element() != sp.currbar) {
return;
}
if (this.pane_mode == 'horiz') {
// Cache these values since we will be using them multiple
// times in snap().
sp.lh = this._getLineHeight();
sp.lines = this.page_size;
sp.max = this.getPageSize('max');
sp.orig = this.page_size;
sp.pos = this.opts.content.viewportOffset()[1];
}
this.opts.container.fire('ViewPort:splitBarStart', this.pane_mode);
},
_onDragEnd: function(e)
{
var change, drag,
sp = this.split_pane;
if (e.element() != sp.currbar) {
return;
}
switch (this.pane_mode) {
case 'horiz':
this.onResize(true, sp.lines);
change = (sp.orig != sp.lines);
break;
case 'vert':
drag = DragDrop.Drags.getDrag(e.element());
sp.vert.width = drag.lastCoord[0] - this.opts.list_container.viewportOffset()[0];
this.onResize(true);
change = drag.wasDragged;
break;
}
if (change) {
this.opts.container.fire('ViewPort:splitBarChange', this.pane_mode);
}
this.opts.container.fire('ViewPort:splitBarEnd', this.pane_mode);
},
_onDragDblClick: function(e)
{
if (!Object.isElement(this.split_pane.currbar) ||
(e.element() != this.split_pane.currbar &&
!e.element().descendantOf(this.split_pane.currbar))) {
return;
}
var old_size;
switch (this.pane_mode) {
case 'horiz':
old_size = this.page_size;
this.onResize(true, this.getPageSize('default'));
if (old_size != this.page_size) {
this.opts.container.fire('ViewPort:splitBarChange', 'horiz');
}
break;
case 'vert':
delete this.split_pane.vert.width;
this.onResize(true);
this.opts.container.fire('ViewPort:splitBarChange', 'vert');
break;
}
},
getAllRows: function(view)
{
var buffer = this._getBuffer(view);
return buffer
? buffer.getAllRows()
: [];
},
createSelection: function(format, data, view)
{
var buffer = this._getBuffer(view);
return buffer
? new ViewPort_Selection(buffer, format, data)
: new ViewPort_Selection(this._getBuffer(this.view));
},
// Creates a selection object comprising all entries contained in the
// buffer.
createSelectionBuffer: function(view)
{
return this.createSelection('rownum', this.getAllRows(view), view);
},
getSelection: function(view)
{
var buffer = this._getBuffer(view);
return this.createSelection('uid', buffer ? buffer.getSelected().get('uid') : [], view);
},
// vs = (ViewPort_Selection | array) A ViewPort_Selection object -or- an
// array of row numbers.
// opts = (object) [add, search]
select: function(vs, opts)
{
opts = opts || {};
var b = this._getBuffer(),
sel, slice;
if (Object.isArray(vs)) {
slice = this.createSelection('rownum', vs);
if (vs.size() != slice.size()) {
this.opts.container.fire('ViewPort:fetch', this.view);
return this.opts.ajax(this.addRequestParams({
rangeslice: 1,
slice: vs.min() + ':' + vs.max()
}));
}
vs = slice;
}
if (opts.search) {
return this._fetchBuffer({
callback: function(r) {
if (r.rownum) {
this.select(this.createSelection('rownum', [ r.rownum ]), { add: opts.add });
}
}.bind(this),
search: opts.search
});
}
if (!opts.add) {
sel = this.getSelected();
b.deselect(sel, true);
sel.get('div').invoke('removeClassName', 'vpRowSelected');
}
b.select(vs);
vs.get('div').invoke('addClassName', 'vpRowSelected');
this.opts.container.fire('ViewPort:select', { opts: opts, vs: vs });
},
// vs = (ViewPort_Selection) A ViewPort_Selection object.
// opts = (object) TODO [clearall]
deselect: function(vs, opts)
{
var buffer = vs.getBuffer();
opts = opts || {};
if (vs.size() &&
buffer.deselect(vs, opts.clearall) &&
buffer.getView() == this.view) {
vs.get('div').invoke('removeClassName', 'vpRowSelected');
this.opts.container.fire('ViewPort:deselect', { opts: opts, vs: vs });
}
},
getSelected: function(view)
{
return Object.clone(this._getBuffer(view).getSelected());
}
}),
ViewPort_Scroller = Class.create({
// Variables initialized to undefined:
// noupdate, scrollDiv, scrollbar, vertscroll, vp
initialize: function(vp)
{
this.vp = vp;
},
_createScrollBar: function()
{
if (this.scrollDiv) {
return;
}
var c = this.vp.opts.content,
mw = this.mousewheelHandler.bindAsEventListener(this);
// Create the outer div.
this.scrollDiv = new Element('DIV', { className: 'vpScroll' }).setStyle({ cssFloat: 'right', overflow: 'hidden' }).hide();
c.insert({ before: this.scrollDiv });
this.scrollDiv.observe('Slider2:change', function() {
if (!this.noupdate) {
this.vp.requestContentRefresh(this.currentOffset());
}
}.bind(this));
this.scrollDiv.observe('Slider2:end', function() {
this.vp.opts.container.fire('ViewPort:sliderEnd');
}.bind(this));
this.scrollDiv.observe('Slider2:slide', function() {
this.vp.opts.container.fire('ViewPort:sliderSlide');
}.bind(this));
this.scrollDiv.observe('Slider2:start', function() {
this.vp.opts.container.fire('ViewPort:sliderStart');
}.bind(this));
// Create scrollbar object.
this.scrollbar = new Slider2(this.scrollDiv, {
buttonclass: { up: 'vpScrollUp', down: 'vpScrollDown' },
cursorclass: 'vpScrollCursor',
pagesize: this.vp.getPageSize(),
totalsize: this.vp.getMetaData('total_rows')
});
// Mouse wheel handler.
if ('onwheel' in document || (document.documentMode >= 9)) {
c.observe('wheel', mw);
} else {
c.observe('mousewheel', mw).observe('DomMouseScroll', mw);
}
},
mousewheelHandler: function(e)
{
var delta = e.wheelDelta || 0;
if (e.detail) {
delta = e.detail * -1;
}
if (e.deltaY) {
delta = e.deltaY * -1;
}
if (!Object.isUndefined(e.wheelDeltaY)) {
delta = e.wheelDeltaY;
}
if (delta) {
this.moveScroll(this.currentOffset() + (Math.min(this.vp.getPageSize(), 3) * (delta > 0 ? -1 : 1)));
}
},
setSize: function(viewsize, totalsize)
{
this._createScrollBar();
this.scrollbar.setHandleLength(viewsize, totalsize);
},
updateDisplay: function()
{
var c = this.vp.opts.content,
vs = false;
if (this.scrollbar.needScroll()) {
switch (this.vp.pane_mode) {
case 'vert':
if (!this.vertscroll) {
c.setStyle({ width: (c.clientWidth - this.scrollDiv.getWidth()) + 'px' });
}
vs = true;
break;
}
this.scrollDiv.setStyle({ height: c.clientHeight + 'px' });
} else if ((this.vp.pane_mode == 'vert') && this.vertscroll) {
c.setStyle({ width: (c.clientWidth + this.scrollDiv.getWidth()) + 'px' });
}
this.vertscroll = vs;
this.scrollbar.updateHandleLength();
},
clear: function()
{
this.setSize(0, 0);
this.scrollbar.updateHandleLength();
},
// offset = (integer) Offset to move the scrollbar to
moveScroll: function(offset)
{
this._createScrollBar();
this.scrollbar.setScrollPosition(offset);
},
currentOffset: function()
{
return this.scrollbar ? this.scrollbar.getValue() : 0;
}
}),
/* Note: recognize the difference between offset (current location in the
* viewport - starts at 0) with start parameters (the row numbers - starts
* at 1). */
ViewPort_Buffer = Class.create({
initialize: function(vp, view)
{
this.vp = vp;
this.view = view;
this.clear();
},
getView: function()
{
return this.view;
},
// d = (object) Data
// l = (object) Rowlist
// md = (object) User defined metadata
// opts = (object) TODO [datareset, mdreset, rowreset]
update: function(d, l, md, opts)
{
d = $H(d);
l = $H(l);
opts = opts || {};
if (!opts.datareset) {
this.data.update(d);
} else {
this.data = d;
}
if (opts.rowreset || opts.datareset) {
this.resetRowlist();
}
l.each(function(o) {
this.data.get(o.key).VP_rownum = o.value;
this.rowlist.set(o.value, o.key);
}, this);
if (opts.mdreset) {
this.usermdata = $H();
}
$H(md).each(function(pair) {
if (Object.isString(pair.value) ||
Object.isNumber(pair.value) ||
Object.isArray(pair.value)) {
this.usermdata.set(pair.key, pair.value);
} else {
var val = this.usermdata.get(pair.key);
if (val) {
val.update($H(pair.value));
} else {
this.usermdata.set(pair.key, $H(pair.value));
}
}
}, this);
},
// offset = (integer) Offset of the beginning of the slice.
// rows = (array) Additional rows to include in the search.
sliceLoaded: function(offset, rows)
{
var range, tr = this.getMetaData('total_rows');
// Undefined here indicates we have never sent a previous buffer
// request.
if (Object.isUndefined(tr)) {
return false;
}
range = $A($R(offset + 1, Math.min(offset + this.vp.getPageSize() - 1, tr)));
return rows
? (range.diff(this.rowlist.keys().concat(rows)).size() === 0)
: !this._rangeCheck(range);
},
isNearingLimit: function(offset)
{
if (this.rowlist.size() != this.getMetaData('total_rows')) {
if (offset !== 0 &&
this._rangeCheck($A($R(Math.max(offset + 1 - this.vp.limitTolerance(), 1), offset)))) {
return 'top';
} else if (this._rangeCheck($A($R(offset + 1, Math.min(offset + this.vp.limitTolerance() + this.vp.getPageSize() - 1, this.getMetaData('total_rows')))).reverse())) {
// Search for missing rows in reverse order since in normal
// usage (sequential scrolling through the row list) rows are
// more likely to be missing at furthest from the current
// view.
return 'bottom';
}
}
},
_rangeCheck: function(range)
{
return !range.all(this.rowlist.get.bind(this.rowlist));
},
getData: function(uids)
{
return uids.collect(function(u) {
var e = this.data.get(u);
if (!Object.isUndefined(e)) {
// We can directly write the rownum to the original object
// since we will always rewrite when creating rows.
if (!e.VP_domid) {
e.VP_domid = 'VProw_' + (++this.vp.id);
}
e.VP_id = u;
e.VP_view = this.view;
return e;
}
}, this).compact();
},
getAllUIDs: function()
{
return this.rowlist.values();
},
getAllRows: function()
{
return this.rowlist.keys();
},
domidsToUIDs: function(ids)
{
var i = 0,
idsize = ids.size(),
uids = [];
this.data.each(function(d) {
if (d.value.VP_domid && ids.include(d.value.VP_domid)) {
uids.push(d.key);
if (++i == idsize) {
throw $break;
}
}
});
return uids;
},
rowsToUIDs: function(rows)
{
return rows.collect(this.rowlist.get.bind(this.rowlist)).compact();
},
UIDsToRows: function(uids)
{
return uids.collect(this.rowlist.index.bind(this.rowlist)).compact();
},
// vs = (ViewPort_Selection) TODO
select: function(vs)
{
this.selected.add('uid', vs.get('uid'));
},
// vs = (ViewPort_Selection) TODO
// clearall = (boolean) Clear all entries?
deselect: function(vs, clearall)
{
var size = this.selected.size();
if (clearall) {
this.selected.clear();
} else {
this.selected.remove('uid', vs.get('uid'));
}
return size != this.selected.size();
},
getSelected: function()
{
return this.selected;
},
// rownums = (array) Array of row numbers to remove.
remove: function(rownums)
{
var minrow = rownums.min(),
rowsize = this.rowlist.size(),
rowsubtract = 0,
newsize = rowsize - rownums.size();
return this.rowlist.keys().each(function(n) {
n = parseInt(n, 10);
if (n >= minrow) {
var id = this.rowlist.get(n), r;
if (rownums.include(n)) {
this.data.unset(id);
rowsubtract++;
} else if (rowsubtract) {
r = n - rowsubtract;
this.rowlist.set(r, id);
this.data.get(id).VP_rownum = r;
}
if (n > newsize) {
this.rowlist.unset(n);
}
}
}, this);
},
removeData: function(uids)
{
uids.each(this.data.unset.bind(this.data));
},
resetRowlist: function()
{
this.rowlist = $H();
this.setMetaData({ total_rows: 0 }, true);
},
clear: function()
{
this.data = $H();
this.mdata = $H({ total_rows: 0 });
this.selected = new ViewPort_Selection(this);
this.usermdata = $H();
this.resetRowlist();
},
getMetaData: function(id)
{
var data = this.mdata.get(id);
return Object.isUndefined(data)
? this.usermdata.get(id)
: data;
},
setMetaData: function(vals, priv)
{
if (priv) {
this.mdata.update(vals);
} else {
this.usermdata.update(vals);
}
},
debug: function()
{
return Object.toJSON({
data: this.data,
mdata: this.mdata,
rowlist: this.rowlist,
selected: this.selected.get('uid')
});
}
}),
ViewPort_Selection = Class.create({
// Define property to aid in object detection
viewport_selection: true,
// Formats:
// 'dataob' = Data objects
// 'div' = DOM DIVs
// 'domid' = DOM IDs
// 'rownum' = Row numbers
// 'uid' = Unique IDs
initialize: function(buffer, format, data)
{
this.buffer = buffer;
this.clear();
if (!Object.isUndefined(format)) {
this.add(format, data);
}
},
add: function(format, d)
{
var c = this._convert(format, d);
this.data = this.data.size()
? this.data.concat(c.reject(this.data.include.bind(this.data)))
: c;
},
remove: function(format, d)
{
this.data = this.data.diff(this._convert(format, d));
},
_convert: function(format, d)
{
d = Object.isArray(d) ? d : [ d ];
// Data is stored internally as UIDs.
switch (format) {
case 'dataob':
return d.pluck('VP_id');
case 'div':
// ID here is the DOM ID of the element object.
d = d.pluck('id');
// Fall-through
case 'domid':
return this.buffer.domidsToUIDs(d);
case 'rownum':
return this.buffer.rowsToUIDs(d);
case 'uid':
return d;
}
},
clear: function()
{
this.data = [];
},
get: function(format)
{
format = Object.isUndefined(format) ? 'uid' : format;
if (format == 'uid') {
return this.data;
}
var d = this.buffer.getData(this.data);
switch (format) {
case 'dataob':
return d;
case 'div':
return d.pluck('VP_domid').collect(function(e) { return $(e); }).compact();
case 'domid':
return d.pluck('VP_domid');
case 'rownum':
return d.pluck('VP_rownum');
}
},
contains: function(format, d)
{
return this.data.include(this._convert(format, d).first());
},
// params = (Object) Key is search key, value is object -> key of object
// must be the following:
// equal - Matches any value contained in the query array.
// include - Matches if this value is contained within the array.
// notequal - Matches any value not contained in the query array.
// notinclude - Matches if this value is not contained within the array.
// regex - Matches the RegExp contained in the query.
search: function(params)
{
return new ViewPort_Selection(this.buffer, 'uid', this.get('dataob').findAll(function(i) {
// i = data object
return $H(params).all(function(k) {
// k.key = search key; k.value = search criteria
return $H(k.value).all(function(s) {
var r;
// Normalize dynamically created values. We know the
// required types for these values, and certain browsers
// do strict type-checking (e.g. Chrome).
switch (k.key) {
case 'VP_domid':
case 'VP_id':
s.value = s.value.invoke('toString');
break;
case 'VP_rownum':
s.value = s.value.collect(function(i) {
var val = parseInt(i, 10);
return isNaN(val) ? null : val;
}).compact();
break;
}
// s.key = search type; s.value = search query
switch (s.key) {
case 'equal':
case 'notequal':
r = i[k.key] && s.value.include(i[k.key]);
return (s.key == 'equal') ? r : !r;
case 'include':
case 'notinclude':
r = i[k.key] && Object.isArray(i[k.key]) && i[k.key].include(s.value);
return (s.key == 'include') ? r : !r;
case 'regex':
return i[k.key].match(s.value);
}
});
});
}).pluck('VP_id'));
},
size: function()
{
return this.data.size();
},
set: function(vals)
{
this.get('dataob').each(function(d) {
$H(vals).each(function(v) {
d[v.key] = v.value;
});
});
},
getBuffer: function()
{
return this.buffer;
}
});