/** * @requires javelin-install javelin-event javelin-util javelin-magical-init * @provides javelin-stratcom * @javelin */ /** * Javelin strategic command, the master event delegation core. This class is * a sort of hybrid between Arbiter and traditional event delegation, and * serves to route event information to handlers in a general way. * * Each Javelin :JX.Event has a 'type', which may be a normal Javascript type * (for instance, a click or a keypress) or an application-defined type. It * also has a "path", based on the path in the DOM from the root node to the * event target. Note that, while the type is required, the path may be empty * (it often will be for application-defined events which do not originate * from the DOM). * * The path is determined by walking down the tree to the event target and * looking for nodes that have been tagged with metadata. These names are used * to build the event path, and unnamed nodes are ignored. Each named node may * also have data attached to it. * * Listeners specify one or more event types they are interested in handling, * and, optionally, one or more paths. A listener will only receive events * which occurred on paths it is listening to. See listen() for more details. * * @task invoke Invoking Events * @task listen Listening to Events * @task handle Responding to Events * @task sigil Managing Sigils * @task meta Managing Metadata * @task internal Internals */ JX.install('Stratcom', { statics : { ready : false, _targets : {}, _handlers : [], _need : {}, _auto : '*', _data : {}, _execContext : [], /** * Node metadata is stored in a series of blocks to prevent collisions * between indexes that are generated on the server side (and potentially * concurrently). Block 0 is for metadata on the initial page load, block 1 * is for metadata added at runtime with JX.Stratcom.siglize(), and blocks * 2 and up are for metadata generated from other sources (e.g. JX.Request). * Use allocateMetadataBlock() to reserve a block, and mergeData() to fill * a block with data. * * When a JX.Request is sent, a block is allocated for it and any metadata * it returns is filled into that block. */ _dataBlock : 2, /** * Within each datablock, data is identified by a unique index. The data * pointer (data-meta attribute) on a node looks like this: * * 1_2 * * ...where 1 is the block, and 2 is the index within that block. Normally, * blocks are filled on the server side, so index allocation takes place * there. However, when data is provided with JX.Stratcom.addData(), we * need to allocate indexes on the client. */ _dataIndex : 0, /** * Dispatch a simple event that does not have a corresponding native event * object. It is unusual to call this directly. Generally, you will instead * dispatch events from an object using the invoke() method present on all * objects. See @{JX.Base.invoke()} for documentation. * * @param string Event type. * @param string|list? Optionally, a sigil path to attach to the event. * This is rarely meaningful for simple events. * @param object? Optionally, arbitrary data to send with the event. * @return @{JX.Event} The event object which was dispatched to listeners. * The main use of this is to test whether any * listeners prevented the event. * @task invoke */ invoke : function(type, path, data) { if (__DEV__) { if (path && typeof path !== 'string' && !JX.isArray(path)) { throw new Error( 'JX.Stratcom.invoke(...): path must be a string or an array.'); } } path = JX.$AX(path); return this._dispatchProxy( new JX.Event() .setType(type) .setData(data || {}) .setPath(path || []) ); }, /** * Listen for events on given paths. Specify one or more event types, and * zero or more paths to filter on. If you don't specify a path, you will * receive all events of the given type: * * // Listen to all clicks. * JX.Stratcom.listen('click', null, handler); * * This will notify you of all clicks anywhere in the document (unless * they are intercepted and killed by a higher priority handler before they * get to you). * * Often, you may be interested in only clicks on certain elements. You * can specify the paths you're interested in to filter out events which * you do not want to be notified of. * * // Listen to all clicks inside elements annotated "news-feed". * JX.Stratcom.listen('click', 'news-feed', handler); * * By adding more elements to the path, you can create a finer-tuned * filter: * * // Listen to only "like" clicks inside "news-feed". * JX.Stratcom.listen('click', ['news-feed', 'like'], handler); * * * TODO: Further explain these shenanigans. * * @param string|list<string> Event type (or list of event names) to * listen for. For example, ##'click'## or * ##['keydown', 'keyup']##. * * @param wild Sigil paths to listen for this event on. See discussion * in method documentation. * * @param function Callback to invoke when this event is triggered. It * should have the signature ##f(:JX.Event e)##. * * @return object A reference to the installed listener. You can later * remove the listener by calling this object's remove() * method. * @task listen */ listen : function(types, paths, func) { if (__DEV__) { if (arguments.length != 3) { JX.$E( 'JX.Stratcom.listen(...): '+ 'requires exactly 3 arguments. Did you mean JX.DOM.listen?'); } if (typeof func != 'function') { JX.$E( 'JX.Stratcom.listen(...): '+ 'callback is not a function.'); } } var ids = []; types = JX.$AX(types); if (!paths) { paths = this._auto; } if (!JX.isArray(paths)) { paths = [[paths]]; } else if (!JX.isArray(paths[0])) { paths = [paths]; } var listener = { _callback : func }; // To listen to multiple event types on multiple paths, we just install // the same listener a whole bunch of times: if we install for two // event types on three paths, we'll end up with six references to the // listener. // // TODO: we'll call your listener twice if you install on two paths where // one path is a subset of another. The solution is "don't do that", but // it would be nice to verify that the caller isn't doing so, in __DEV__. for (var ii = 0; ii < types.length; ++ii) { var type = types[ii]; if (('onpagehide' in window) && type == 'unload') { // If we use "unload", we break the bfcache ("Back-Forward Cache") in // Safari and Firefox. The BFCache makes using the back/forward // buttons really fast since the pages can come out of magical // fairyland instead of over the network, so use "pagehide" as a proxy // for "unload" in these browsers. type = 'pagehide'; } if (!(type in this._targets)) { this._targets[type] = {}; } var type_target = this._targets[type]; for (var jj = 0; jj < paths.length; ++jj) { var path = paths[jj]; var id = this._handlers.length; this._handlers.push(listener); this._need[id] = path.length; ids.push(id); for (var kk = 0; kk < path.length; ++kk) { if (__DEV__) { if (path[kk] == 'tag:#document') { JX.$E( 'JX.Stratcom.listen(..., "tag:#document", ...): ' + 'listen for all events using null, not "tag:#document"'); } if (path[kk] == 'tag:window') { JX.$E( 'JX.Stratcom.listen(..., "tag:window", ...): ' + 'listen for window events using null, not "tag:window"'); } } (type_target[path[kk]] || (type_target[path[kk]] = [])).push(id); } } } // Add a remove function to the listener listener['remove'] = function() { if (listener._callback) { delete listener._callback; for (var ii = 0; ii < ids.length; ii++) { delete JX.Stratcom._handlers[ids[ii]]; } } }; return listener; }, /** * Sometimes you may be interested in removing a listener directly from it's * handler. This is possible by calling JX.Stratcom.removeCurrentListener() * * // Listen to only the first click on the page * JX.Stratcom.listen('click', null, function() { * // do interesting things * JX.Stratcom.removeCurrentListener(); * }); * * @task remove */ removeCurrentListener : function() { var context = this._execContext[this._execContext.length - 1]; var listeners = context.listeners; // JX.Stratcom.pass will have incremented cursor by now var cursor = context.cursor - 1; if (listeners[cursor]) { listeners[cursor].handler.remove(); } }, /** * Dispatch a native Javascript event through the Stratcom control flow. * Generally, this is automatically called for you by the master dispatcher * installed by ##init.js##. When you want to dispatch an application event, * you should instead call invoke(). * * @param Event Native event for dispatch. * @return :JX.Event Dispatched :JX.Event. * @task internal */ dispatch : function(event) { var path = []; var nodes = {}; var distances = {}; var push = function(key, node, distance) { // we explicitly only store the first occurrence of each key if (!nodes.hasOwnProperty(key)) { nodes[key] = node; distances[key] = distance; path.push(key); } }; var target = event.srcElement || event.target; // Touch events may originate from text nodes, but we want to start our // traversal from the nearest Element, so we grab the parentNode instead. if (target && target.nodeType === 3) { target = target.parentNode; } // Since you can only listen by tag, id, or sigil we unset the target if // it isn't an Element. Document and window are Nodes but not Elements. if (!target || !target.getAttribute) { target = null; } var distance = 1; var cursor = target; while (cursor && cursor.getAttribute) { push('tag:' + cursor.nodeName.toLowerCase(), cursor, distance); var id = cursor.id; if (id) { push('id:' + id, cursor, distance); } var sigils = cursor.getAttribute('data-sigil'); if (sigils) { sigils = sigils.split(' '); for (var ii = 0; ii < sigils.length; ii++) { push(sigils[ii], cursor, distance); } } var auto_id = cursor.getAttribute('data-autoid'); if (auto_id) { push('autoid:' + auto_id, cursor, distance); } ++distance; cursor = cursor.parentNode; } var etype = event.type; if (etype == 'focusin') { etype = 'focus'; } else if (etype == 'focusout') { etype = 'blur'; } var proxy = new JX.Event() .setRawEvent(event) .setData(event.customData) .setType(etype) .setTarget(target) .setNodes(nodes) .setNodeDistances(distances) .setPath(path.reverse()); // Don't touch this for debugging purposes //JX.log('~> '+proxy.toString()); return this._dispatchProxy(proxy); }, /** * Dispatch a previously constructed proxy :JX.Event. * * @param :JX.Event Event to dispatch. * @return :JX.Event Returns the event argument. * @task internal */ _dispatchProxy : function(proxy) { var scope = this._targets[proxy.getType()]; if (!scope) { return proxy; } var path = proxy.getPath(); var distances = proxy.getNodeDistances(); var len = path.length; var hits = {}; var hit_distances = {}; var matches; // A large number (larger than any distance we will ever encounter), but // we need to do math on it in the sort function so we can't use // Number.POSITIVE_INFINITY. var far_away = 1000000; for (var root = -1; root < len; ++root) { matches = scope[(root == -1) ? this._auto : path[root]]; if (matches) { var distance = distances[path[root]] || far_away; for (var ii = 0; ii < matches.length; ++ii) { var match = matches[ii]; hits[match] = (hits[match] || 0) + 1; hit_distances[match] = Math.min( hit_distances[match] || distance, distance ); } } } var listeners = []; for (var k in hits) { if (hits[k] == this._need[k]) { var handler = this._handlers[k]; if (handler) { listeners.push({ distance: hit_distances[k], handler: handler }); } } } // Sort listeners by matched sigil closest to the target node // Listeners with the same closest sigil are called in an undefined order listeners.sort(function(a, b) { if (__DEV__) { // Make sure people play by the rules. >:) return (a.distance - b.distance) || (Math.random() - 0.5); } return a.distance - b.distance; }); this._execContext.push({ listeners: listeners, event: proxy, cursor: 0 }); this.pass(); this._execContext.pop(); return proxy; }, /** * Pass on an event, allowing other handlers to process it. The use case * here is generally something like: * * if (JX.Stratcom.pass()) { * // something else handled the event * return; * } * // handle the event * event.prevent(); * * This allows you to install event handlers that operate at a lower * effective priority, and provide a default behavior which is overridable * by listeners. * * @return bool True if the event was stopped or prevented by another * handler. * @task handle */ pass : function() { var context = this._execContext[this._execContext.length - 1]; var event = context.event; var listeners = context.listeners; while (context.cursor < listeners.length) { var cursor = context.cursor++; if (listeners[cursor]) { var handler = listeners[cursor].handler; handler._callback && handler._callback(event); } if (event.getStopped()) { break; } } return event.getStopped() || event.getPrevented(); }, /** * Retrieve the event (if any) which is currently being dispatched. * * @return :JX.Event|null Event which is currently being dispatched, or * null if there is no active dispatch. * @task handle */ context : function() { var len = this._execContext.length; return len ? this._execContext[len - 1].event : null; }, /** * Merge metadata. You must call this (even if you have no metadata) to * start the Stratcom queue. * * @param int The datablock to merge data into. * @param dict Dictionary of metadata. * @return void * @task internal */ mergeData : function(block, data) { if (this._data[block]) { if (__DEV__) { for (var key in data) { if (key in this._data[block]) { JX.$E( 'JX.Stratcom.mergeData(' + block + ', ...); is overwriting ' + 'existing data.'); } } } JX.copy(this._data[block], data); } else { this._data[block] = data; if (block === 0) { JX.Stratcom.ready = true; JX.flushHoldingQueue('install-init', function(fn) { fn(); }); JX.__rawEventQueue({type: 'start-queue'}); } } }, /** * Determine if a node has a specific sigil. * * @param Node Node to test. * @param string Sigil to check for. * @return bool True if the node has the sigil. * * @task sigil */ hasSigil : function(node, sigil) { if (__DEV__) { if (!node || !node.getAttribute) { JX.$E( 'JX.Stratcom.hasSigil(<non-element>, ...): ' + 'node is not an element. Most likely, you\'re passing window or ' + 'document, which are not elements and can\'t have sigils.'); } } var sigils = node.getAttribute('data-sigil') || false; return sigils && (' ' + sigils + ' ').indexOf(' ' + sigil + ' ') > -1; }, /** * Add a sigil to a node. * * @param Node Node to add the sigil to. * @param string Sigil to name the node with. * @return void * @task sigil */ addSigil: function(node, sigil) { if (__DEV__) { if (!node || !node.getAttribute) { JX.$E( 'JX.Stratcom.addSigil(<non-element>, ...): ' + 'node is not an element. Most likely, you\'re passing window or ' + 'document, which are not elements and can\'t have sigils.'); } } var sigils = node.getAttribute('data-sigil') || ''; if (!JX.Stratcom.hasSigil(node, sigil)) { sigils += ' ' + sigil; } node.setAttribute('data-sigil', sigils); }, /** * Retrieve a node's metadata. * * @param Node Node from which to retrieve data. * @return object Data attached to the node. If no data has been attached * to the node yet, an empty object will be returned, but * subsequent calls to this method will always retrieve the * same object. * @task meta */ getData : function(node) { if (__DEV__) { if (!node || !node.getAttribute) { JX.$E( 'JX.Stratcom.getData(<non-element>): ' + 'node is not an element. Most likely, you\'re passing window or ' + 'document, which are not elements and can\'t have data.'); } } var meta_id = (node.getAttribute('data-meta') || '').split('_'); if (meta_id[0] && meta_id[1]) { var block = this._data[meta_id[0]]; var index = meta_id[1]; if (block && (index in block)) { return block[index]; } else if (__DEV__) { JX.$E( 'JX.Stratcom.getData(<node>): Tried to access data (block ' + meta_id[0] + ', index ' + index + ') that was not present. This ' + 'probably means you are calling getData() before the block ' + 'is provided by mergeData().'); } } var data = {}; if (!this._data[1]) { // data block 1 is reserved for JavaScript this._data[1] = {}; } this._data[1][this._dataIndex] = data; node.setAttribute('data-meta', '1_' + (this._dataIndex++)); return data; }, /** * Add data to a node's metadata. * * @param Node Node which data should be attached to. * @param object Data to add to the node's metadata. * @return object Data attached to the node that is returned by * JX.Stratcom.getData(). * @task meta */ addData : function(node, data) { if (__DEV__) { if (!node || !node.getAttribute) { JX.$E( 'JX.Stratcom.addData(<non-element>, ...): ' + 'node is not an element. Most likely, you\'re passing window or ' + 'document, which are not elements and can\'t have sigils.'); } if (!data || typeof data != 'object') { JX.$E( 'JX.Stratcom.addData(..., <nonobject>): ' + 'data to attach to node is not an object. You must use ' + 'objects, not primitives, for metadata.'); } } return JX.copy(JX.Stratcom.getData(node), data); }, /** * @task internal */ allocateMetadataBlock : function() { return this._dataBlock++; } } });