Index: .gitmodules =================================================================== --- .gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "externals/javelin"] - path = externals/javelin - url = git://github.com/facebook/javelin.git Index: externals/javelin =================================================================== --- externals/javelin +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 32c6e43f4b8b84df940bed8ed8d073e67f6c2b28 Index: externals/javelin/.gitignore =================================================================== --- /dev/null +++ externals/javelin/.gitignore @@ -0,0 +1,25 @@ +.DS_Store +._* +*.o +*.so +*.a + +/externals/libfbjs/parser.lex.cpp +/externals/libfbjs/parser.yacc.cpp +/externals/libfbjs/parser.yacc.hpp +/externals/libfbjs/parser.yacc.output + +/support/javelinsymbols/javelinsymbols +/support/jsast/jsast +/support/jsxmin/jsxmin + +# Diviner artifacts +/docs/ +/.divinercache/ + +/support/diviner/.phutil_module_cache + +# Mac OSX build artifacts +/support/jsast/jsast.dSYM/ +/support/jsxmin/jsxmin.dSYM/ +/support/javelinsymbols/javelinsymbols.dSYM/ Index: externals/javelin/LICENSE =================================================================== --- /dev/null +++ externals/javelin/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) 2009, Evan Priestley and Facebook, inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Facebook, inc. nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS ``AS IS'' AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + Index: externals/javelin/README =================================================================== --- /dev/null +++ externals/javelin/README @@ -0,0 +1,33 @@ +Javelin is a performance-oriented Javascript library originally developed at +Facebook. Learn more at <http://www.javelinjs.com/>. + +GETTING STARTED + +Eat a hearty breakfast. Breakfast is the most important meal of the day! + + +WHAT IS JAVELIN? + +Javelin is a compact Javascript library built around event delegation. Its +primary design goal is performance; it is consequently well-suited to projects +where performance is very important. It is not as good for smaller scale +projects where other concerns (like features or ease of development) are more +important. + + +PACKAGES + +Packages come in two flavors: "dev" and "min". The "dev" packages are intended +for development, and have comments and debugging code. The "min" packages have +the same code, but with comments and debugging information stripped out and +symbols crushed. They are intended for use in production -- ha ha ha! + + +FILES + + example/ Example code. + LICENSE A thrilling narrative. + pkg/ Ready-built Javelin packages. + README Who knows? Could be anything. + src/ Raw sources for Javelin. + support/ Support scripts and libraries. Index: externals/javelin/src/core/Event.js =================================================================== --- /dev/null +++ externals/javelin/src/core/Event.js @@ -0,0 +1,321 @@ +/** + * @requires javelin-install + * @provides javelin-event + * @javelin + */ + +/** + * A generic event, routed by @{class:JX.Stratcom}. All events within Javelin + * are represented by a {@class:JX.Event}, regardless of whether they originate + * from a native DOM event (like a mouse click) or are custom application + * events. + * + * See @{article:Concepts: Event Delegation} for an introduction to Javelin's + * event delegation model. + * + * Events have a propagation model similar to native Javascript events, in that + * they can be stopped with stop() (which stops them from continuing to + * propagate to other handlers) or prevented with prevent() (which prevents them + * from taking their default action, like following a link). You can do both at + * once with kill(). + * + * @task stop Stopping Event Behaviors + * @task info Getting Event Information + * @group event + */ +JX.install('Event', { + members : { + + /** + * Stop an event from continuing to propagate. No other handler will + * receive this event, but its default behavior will still occur. See + * ""Using Events"" for more information on the distinction between + * 'stopping' and 'preventing' an event. See also prevent() (which prevents + * an event but does not stop it) and kill() (which stops and prevents an + * event). + * + * @return this + * @task stop + */ + stop : function() { + var r = this.getRawEvent(); + if (r) { + r.cancelBubble = true; + r.stopPropagation && r.stopPropagation(); + } + this.setStopped(true); + return this; + }, + + + /** + * Prevent an event's default action. This depends on the event type, but + * the common default actions are following links, submitting forms, + * and typing text. Event prevention is generally used when you have a link + * or form which work properly without Javascript but have a specialized + * Javascript behavior. When you intercept the event and make the behavior + * occur, you prevent it to keep the browser from following the link. + * + * Preventing an event does not stop it from propagating, so other handlers + * will still receive it. See ""Using Events"" for more information on the + * distinction between 'stopping' and 'preventing' an event. See also + * stop() (which stops an event but does not prevent it) and kill() + * (which stops and prevents an event). + * + * @return this + * @task stop + */ + prevent : function() { + var r = this.getRawEvent(); + if (r) { + r.returnValue = false; + r.preventDefault && r.preventDefault(); + } + this.setPrevented(true); + return this; + }, + + + /** + * Stop and prevent an event, which stops it from propagating and prevents + * its defualt behavior. This is a convenience function, see stop() and + * prevent() for information on what it means to stop or prevent an event. + * + * @return this + * @task stop + */ + kill : function() { + this.prevent(); + this.stop(); + return this; + }, + + + /** + * Get the special key (like tab or return), if any, associated with this + * event. Browsers report special keys differently; this method allows you + * to identify a keypress in a browser-agnostic way. Note that this detects + * only some special keys: delete, tab, return escape, left, up, right, + * down. + * + * For example, if you want to react to the escape key being pressed, you + * could install a listener like this: + * + * JX.Stratcom.listen('keydown', 'example', function(e) { + * if (e.getSpecialKey() == 'esc') { + * JX.log("You pressed 'Escape'! Well done! Bravo!"); + * } + * }); + * + * @return string|null ##null## if there is no associated special key, + * or one of the strings 'delete', 'tab', 'return', + * 'esc', 'left', 'up', 'right', or 'down'. + * @task info + */ + getSpecialKey : function() { + var r = this.getRawEvent(); + if (!r || r.shiftKey) { + return null; + } + + return JX.Event._keymap[r.keyCode] || null; + }, + + + /** + * Get whether the mouse button associated with the mouse event is the + * right-side button in a browser-agnostic way. + * + * @return bool + * @task info + */ + isRightButton : function() { + var r = this.getRawEvent(); + return r.which == 3 || r.button == 2; + }, + + + /** + * Determine if a click event is a normal click (left mouse button, no + * modifier keys). + * + * @return bool + * @task info + */ + isNormalClick : function() { + if (this.getType() != 'click') { + return false; + } + + var r = this.getRawEvent(); + if (r.metaKey || r.altKey || r.ctrlkey || r.shiftKey) { + return false; + } + + if (('which' in r) && (r.which != 1)) { + return false; + } + + if (('button' in r) && r.button) { + return false; + } + + return true; + }, + + + /** + * Get the node corresponding to the specified key in this event's node map. + * This is a simple helper method that makes the API for accessing nodes + * less ugly. + * + * JX.Stratcom.listen('click', 'tag:a', function(e) { + * var a = e.getNode('tag:a'); + * // do something with the link that was clicked + * }); + * + * @param string sigil or stratcom node key + * @return node|null Node mapped to the specified key, or null if it the + * key does not exist. The available keys include: + * - 'tag:'+tag - first node of each type + * - 'id:'+id - all nodes with an id + * - sigil - first node of each sigil + * @task info + */ + getNode : function(key) { + return this.getNodes()[key] || null; + }, + + + /** + * Get the metadata associated with the node that corresponds to the key + * in this event's node map. This is a simple helper method that makes + * the API for accessing metadata associated with specific nodes less ugly. + * + * JX.Stratcom.listen('click', 'tag:a', function(event) { + * var anchorData = event.getNodeData('tag:a'); + * // do something with the metadata of the link that was clicked + * }); + * + * @param string sigil or stratcom node key + * @return dict dictionary of the node's metadata + * @task info + */ + getNodeData : function(key) { + // Evade static analysis - JX.Stratcom + return JX['Stratcom'].getData(this.getNode(key)); + } + }, + + statics : { + _keymap : { + 8 : 'delete', + 9 : 'tab', + 13 : 'return', + 27 : 'esc', + 37 : 'left', + 38 : 'up', + 39 : 'right', + 40 : 'down', + 63232 : 'up', + 63233 : 'down', + 62234 : 'left', + 62235 : 'right' + } + }, + + properties : { + + /** + * Native Javascript event which generated this @{class:JX.Event}. Not every + * event is generated by a native event, so there may be ##null## in + * this field. + * + * @type Event|null + * @task info + */ + rawEvent : null, + + /** + * String describing the event type, like 'click' or 'mousedown'. This + * may also be an application or object event. + * + * @type string + * @task info + */ + type : null, + + /** + * If available, the DOM node where this event occurred. For example, if + * this event is a click on a button, the target will be the button which + * was clicked. Application events will not have a target, so this property + * will return the value ##null##. + * + * @type DOMNode|null + * @task info + */ + target : null, + + /** + * Metadata attached to nodes associated with this event. + * + * For native events, the DOM is walked from the event target to the root + * element. Each sigil which is encountered while walking up the tree is + * added to the map as a key. If the node has associated metainformation, + * it is set as the value; otherwise, the value is null. + * + * @type dict<string, *> + * @task info + */ + data : null, + + /** + * Sigil path this event was activated from. TODO: explain this + * + * @type list<string> + * @task info + */ + path : [], + + /** + * True if propagation of the event has been stopped. See stop(). + * + * @type bool + * @task stop + */ + stopped : false, + + /** + * True if default behavior of the event has been prevented. See prevent(). + * + * @type bool + * @task stop + */ + prevented : false, + + /** + * @task info + */ + nodes : {}, + + /** + * @task info + */ + nodeDistances : {} + }, + + /** + * @{class:JX.Event} installs a toString() method in ##__DEV__## which allows + * you to log or print events and get a reasonable representation of them: + * + * Event<'click', ['path', 'stuff'], [object HTMLDivElement]> + */ + initialize : function() { + if (__DEV__) { + JX.Event.prototype.toString = function() { + var path = '['+this.getPath().join(', ')+']'; + return 'Event<'+this.getType()+', '+path+', '+this.getTarget()+'>'; + } + } + } +}); Index: externals/javelin/src/core/Stratcom.js =================================================================== --- /dev/null +++ externals/javelin/src/core/Stratcom.js @@ -0,0 +1,646 @@ +/** + * @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 + * @group event + */ +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++; + } + } +}); Index: externals/javelin/src/core/__tests__/event-stop-and-kill.js =================================================================== --- /dev/null +++ externals/javelin/src/core/__tests__/event-stop-and-kill.js @@ -0,0 +1,39 @@ +/** + * @requires javelin-event + */ + +describe('Event Stop/Kill', function() { + var target; + + beforeEach(function() { + target = new JX.Event(); + }); + + it('should stop an event', function() { + expect(target.getStopped()).toBe(false); + target.prevent(); + expect(target.getStopped()).toBe(false); + target.stop(); + expect(target.getStopped()).toBe(true); + }); + + it('should prevent the default action of an event', function() { + expect(target.getPrevented()).toBe(false); + target.stop(); + expect(target.getPrevented()).toBe(false); + target.prevent(); + expect(target.getPrevented()).toBe(true); + }); + + it('should kill (stop and prevent) an event', function() { + expect(target.getPrevented()).toBe(false); + expect(target.getStopped()).toBe(false); + target.kill(); + expect(target.getPrevented()).toBe(true); + expect(target.getStopped()).toBe(true); + }); +}); + + + + Index: externals/javelin/src/core/__tests__/install.js =================================================================== --- /dev/null +++ externals/javelin/src/core/__tests__/install.js @@ -0,0 +1,152 @@ +/** + * @requires javelin-install + */ + +describe('Javelin Install', function() { + + it('should extend from an object', function() { + JX.install('Animal', { + properties: { + name: 'bob' + } + }); + + JX.install('Dog', { + extend: 'Animal', + + members: { + bark: function() { + return 'bow wow'; + } + } + }); + + var bob = new JX.Dog(); + expect(bob.getName()).toEqual('bob'); + expect(bob.bark()).toEqual('bow wow'); + }); + + it('should create a class', function() { + var Animal = JX.createClass({ + name: 'Animal', + + properties: { + name: 'bob' + } + }); + + var Dog = JX.createClass({ + name: 'Dog', + + extend: Animal, + + members: { + bark: function() { + return 'bow wow'; + } + } + }); + + var bob = new Dog(); + expect(bob.getName()).toEqual('bob'); + expect(bob.bark()).toEqual('bow wow'); + }); + + it('should call base constructor when construct is not provided', function() { + var Base = JX.createClass({ + name: 'Base', + + construct: function() { + this.baseCalled = true; + } + }); + + var Sub = JX.createClass({ + name: 'Sub', + extend: Base + }); + + var obj = new Sub(); + expect(obj.baseCalled).toBe(true); + }); + + it('should call intialize after install', function() { + var initialized = false; + JX.install('TestClass', { + properties: { + foo: 'bar' + }, + initialize: function() { + initialized = true; + } + }); + + expect(initialized).toBe(true); + }); + + it('should call base ctor when construct is not provided in JX.install', + function() { + + JX.install('Base', { + construct: function() { + this.baseCalled = true; + } + }); + + JX.install('Sub', { + extend: 'Base' + }); + + var obj = new JX.Sub(); + expect(obj.baseCalled).toBe(true); + }); + + it('[DEV] should throw when calling install with name', function() { + ensure__DEV__(true, function() { + expect(function() { + JX.install('AngryAnimal', { + name: 'Kitty' + }); + }).toThrow(); + }); + }); + + it('[DEV] should throw when calling createClass with initialize', function() { + ensure__DEV__(true, function() { + expect(function() { + JX.createClass({ + initialize: function() { + + } + }); + }).toThrow(); + }); + }); + + it('initialize() should be able to access the installed class', function() { + JX.install('SomeClassWithInitialize', { + initialize : function() { + expect(!!JX.SomeClassWithInitialize).toBe(true); + } + }); + }); + + it('should work with toString and its friends', function() { + JX.install('NiceAnimal', { + members: { + toString: function() { + return 'I am very nice.'; + }, + + hasOwnProperty: function() { + return true; + } + } + }); + + expect(new JX.NiceAnimal().toString()).toEqual('I am very nice.'); + expect(new JX.NiceAnimal().hasOwnProperty('dont-haz')).toEqual(true); + }); + +}); + Index: externals/javelin/src/core/__tests__/stratcom.js =================================================================== --- /dev/null +++ externals/javelin/src/core/__tests__/stratcom.js @@ -0,0 +1,184 @@ +/** + * @requires javelin-stratcom + * javelin-dom + */ +describe('Stratcom Tests', function() { + node1 = document.createElement('div'); + JX.Stratcom.addSigil(node1, 'what'); + node2 = document; + node3 = document.createElement('div'); + node3.className = 'what'; + + it('should disallow document', function() { + ensure__DEV__(true, function() { + expect(function() { + JX.Stratcom.listen('click', 'tag:#document', function() {}); + }).toThrow(); + }); + }); + + it('should disallow window', function() { + ensure__DEV__(true, function() { + expect(function() { + JX.Stratcom.listen('click', 'tag:window', function() {}); + }).toThrow(); + }); + }); + + it('should test nodes for hasSigil', function() { + expect(JX.Stratcom.hasSigil(node1, 'what')).toBe(true); + expect(JX.Stratcom.hasSigil(node3, 'what')).toBe(false); + + ensure__DEV__(true, function() { + expect(function() { + JX.Stratcom.hasSigil(node2, 'what'); + }).toThrow(); + }); + }); + + it('should be able to add sigils', function() { + var node = document.createElement('div'); + JX.Stratcom.addSigil(node, 'my-sigil'); + expect(JX.Stratcom.hasSigil(node, 'my-sigil')).toBe(true); + expect(JX.Stratcom.hasSigil(node, 'i-dont-haz')).toBe(false); + JX.Stratcom.addSigil(node, 'javelin-rocks'); + expect(JX.Stratcom.hasSigil(node, 'my-sigil')).toBe(true); + expect(JX.Stratcom.hasSigil(node, 'javelin-rocks')).toBe(true); + + // Should not arbitrarily take away other sigils + JX.Stratcom.addSigil(node, 'javelin-rocks'); + expect(JX.Stratcom.hasSigil(node, 'my-sigil')).toBe(true); + expect(JX.Stratcom.hasSigil(node, 'javelin-rocks')).toBe(true); + }); + + it('should test dataPersistence', function() { + var n, d; + + n = JX.$N('div'); + d = JX.Stratcom.getData(n); + expect(d).toEqual({}); + d.noise = 'quack'; + expect(JX.Stratcom.getData(n).noise).toEqual('quack'); + + n = JX.$N('div'); + JX.Stratcom.addSigil(n, 'oink'); + d = JX.Stratcom.getData(n); + expect(JX.Stratcom.getData(n)).toEqual({}); + d.noise = 'quack'; + expect(JX.Stratcom.getData(n).noise).toEqual('quack'); + + ensure__DEV__(true, function(){ + var bad_values = [false, null, undefined, 'quack']; + for (var ii = 0; ii < bad_values.length; ii++) { + n = JX.$N('div'); + expect(function() { + JX.Stratcom.addSigil(n, 'oink'); + JX.Stratcom.addData(n, bad_values[ii]); + }).toThrow(); + } + }); + + }); + + it('should allow the merge of additional data', function() { + ensure__DEV__(true, function() { + var clown = JX.$N('div'); + clown.setAttribute('data-meta', '0_0'); + JX.Stratcom.mergeData('0', {'0' : 'clown'}); + + expect(JX.Stratcom.getData(clown)).toEqual('clown'); + + var town = JX.$N('div'); + town.setAttribute('data-meta', '0_1'); + JX.Stratcom.mergeData('0', {'1' : 'town'}); + + expect(JX.Stratcom.getData(clown)).toEqual('clown'); + expect(JX.Stratcom.getData(town)).toEqual('town'); + + expect(function() { + JX.Stratcom.mergeData('0', {'0' : 'oops'}); + }).toThrow(); + }); + }); + + it('all listeners should be called', function() { + ensure__DEV__(true, function() { + var callback_count = 0; + JX.Stratcom.listen('custom:eventA', null, function() { + callback_count++; + }); + + JX.Stratcom.listen('custom:eventA', null, function() { + callback_count++; + }); + + expect(callback_count).toEqual(0); + JX.Stratcom.invoke('custom:eventA'); + expect(callback_count).toEqual(2); + }); + }); + + it('removed listeners should not be called', function() { + ensure__DEV__(true, function() { + var callback_count = 0; + var listeners = []; + var remove_listeners = function() { + while (listeners.length) { + listeners.pop().remove(); + } + }; + + listeners.push( + JX.Stratcom.listen('custom:eventB', null, function() { + callback_count++; + remove_listeners(); + }) + ); + + listeners.push( + JX.Stratcom.listen('custom:eventB', null, function() { + callback_count++; + remove_listeners(); + }) + ); + + expect(callback_count).toEqual(0); + JX.Stratcom.invoke('custom:eventB'); + expect(listeners.length).toEqual(0); + expect(callback_count).toEqual(1); + }); + }); + + it('should throw when accessing data in an unloaded block', function() { + ensure__DEV__(true, function() { + + var n = JX.$N('div'); + n.setAttribute('data-meta', '9999999_9999999'); + + var caught; + try { + JX.Stratcom.getData(n); + } catch (error) { + caught = error; + } + + expect(caught instanceof Error).toEqual(true); + }); + }); + + // it('can set data serializer', function() { + // var uri = new JX.URI('http://www.facebook.com/home.php?key=value'); + // uri.setQuerySerializer(JX.PHPQuerySerializer.serialize); + // uri.setQueryParam('obj', { + // num : 1, + // obj : { + // str : 'abc', + // i : 123 + // } + // }); + // expect(decodeURIComponent(uri.toString())).toEqual( + // 'http://www.facebook.com/home.php?key=value&' + + // 'obj[num]=1&obj[obj][str]=abc&obj[obj][i]=123'); + // }); + +}); Index: externals/javelin/src/core/__tests__/util.js =================================================================== --- /dev/null +++ externals/javelin/src/core/__tests__/util.js @@ -0,0 +1,85 @@ +/** + * @requires javelin-util + */ + +describe('JX.isArray', function() { + + it('should correctly identify an array', function() { + expect(JX.isArray([1, 2, 3])).toBe(true); + + expect(JX.isArray([])).toBe(true); + }); + + it('should return false on anything that is not an array', function() { + expect(JX.isArray(1)).toBe(false); + expect(JX.isArray('a string')).toBe(false); + expect(JX.isArray(true)).toBe(false); + expect(JX.isArray(/regex/)).toBe(false); + + expect(JX.isArray(new String('a super string'))).toBe(false); + expect(JX.isArray(new Number(42))).toBe(false); + expect(JX.isArray(new Boolean(false))).toBe(false); + + expect(JX.isArray({})).toBe(false); + expect(JX.isArray({'0': 1, '1': 2, length: 2})).toBe(false); + expect(JX.isArray((function(){ + return arguments; + })('I', 'want', 'to', 'trick', 'you'))).toBe(false); + }); + + it('should identify an array from another context as an array', function() { + var iframe = document.createElement('iframe'); + var name = iframe.name = 'javelin-iframe-test'; + iframe.style.display = 'none'; + + document.body.insertBefore(iframe, document.body.firstChild); + var doc = iframe.contentWindow.document; + doc.write( + '<script>parent.MaybeArray = Array;</script>' + ); + + var array = MaybeArray(1, 2, 3); + var array2 = new MaybeArray(1); + array2[0] = 5; + + expect(JX.isArray(array)).toBe(true); + expect(JX.isArray(array2)).toBe(true); + }); + +}); + +describe('JX.bind', function() { + + it('should bind a function to a context', function() { + var object = {a: 5, b: 3}; + JX.bind(object, function() { + object.b = 1; + })(); + expect(object).toEqual({a: 5, b: 1}); + }); + + it('should bind a function without context', function() { + var called; + JX.bind(null, function() { + called = true; + })(); + expect(called).toBe(true); + }); + + it('should bind with arguments', function() { + var list = []; + JX.bind(null, function() { + list.push.apply(list, JX.$A(arguments)); + }, 'a', 2, 'c', 4)(); + expect(list).toEqual(['a', 2, 'c', 4]); + }); + + it('should allow to pass additional arguments', function() { + var list = []; + JX.bind(null, function() { + list.push.apply(list, JX.$A(arguments)); + }, 'a', 2)('c', 4); + expect(list).toEqual(['a', 2, 'c', 4]); + }); + +}); Index: externals/javelin/src/core/init.js =================================================================== --- /dev/null +++ externals/javelin/src/core/init.js @@ -0,0 +1,224 @@ +/** + * Javelin core; installs Javelin and Stratcom event delegation. + * + * @provides javelin-magical-init + * + * @javelin-installs JX.__rawEventQueue + * @javelin-installs JX.__simulate + * @javelin-installs JX.__allowedEvents + * @javelin-installs JX.enableDispatch + * @javelin-installs JX.onload + * @javelin-installs JX.flushHoldingQueue + * + * @javelin + */ +(function() { + + if (window.JX) { + return; + } + + window.JX = {}; + + // The holding queues hold calls to functions (JX.install() and JX.behavior()) + // before they load, so if you're async-loading them later in the document + // the page will execute correctly regardless of the order resources arrive + // in. + + var holding_queues = {}; + + function makeHoldingQueue(name) { + if (JX[name]) { + return; + } + holding_queues[name] = []; + JX[name] = function() { holding_queues[name].push(arguments); } + } + + JX.flushHoldingQueue = function(name, fn) { + for (var ii = 0; ii < holding_queues[name].length; ii++) { + fn.apply(null, holding_queues[name][ii]); + } + holding_queues[name] = {}; + } + + makeHoldingQueue('install'); + makeHoldingQueue('behavior'); + makeHoldingQueue('install-init'); + + window['__DEV__'] = window['__DEV__'] || 0; + + var loaded = false; + var onload = []; + var master_event_queue = []; + var root = document.documentElement; + var has_add_event_listener = !!root.addEventListener; + + JX.__rawEventQueue = function(what) { + master_event_queue.push(what); + + // Evade static analysis - JX.Stratcom + var Stratcom = JX['Stratcom']; + if (Stratcom && Stratcom.ready) { + // Empty the queue now so that exceptions don't cause us to repeatedly + // try to handle events. + var local_queue = master_event_queue; + master_event_queue = []; + for (var ii = 0; ii < local_queue.length; ++ii) { + var evt = local_queue[ii]; + + // Sometimes IE gives us events which throw when ".type" is accessed; + // just ignore them since we can't meaningfully dispatch them. TODO: + // figure out where these are coming from. + try { var test = evt.type; } catch (x) { continue; } + + if (!loaded && evt.type == 'domready') { + document.body && (document.body.id = null); + loaded = true; + for (var jj = 0; jj < onload.length; jj++) { + onload[jj](); + } + } + + Stratcom.dispatch(evt); + } + } else { + var target = what.srcElement || what.target; + if (target && + (what.type in {click: 1, submit: 1}) && + target.getAttribute && + target.getAttribute('data-mustcapture') === '1') { + what.returnValue = false; + what.preventDefault && what.preventDefault(); + document.body.id = 'event_capture'; + + // For versions of IE that use attachEvent, the event object is somehow + // stored globally by reference, and all the references we push to the + // master_event_queue will always refer to the most recent event. We + // work around this by popping the useless global event off the queue, + // and pushing a clone of the event that was just fired using the IE's + // proprietary createEventObject function. + // see: http://msdn.microsoft.com/en-us/library/ms536390(v=vs.85).aspx + if (!add_event_listener && document.createEventObject) { + master_event_queue.pop(); + master_event_queue.push(document.createEventObject(what)); + } + + return false; + } + } + } + + JX.enableDispatch = function(target, type) { + if (__DEV__) { + JX.__allowedEvents[type] = true; + } + + if (target.addEventListener) { + target.addEventListener(type, JX.__rawEventQueue, true); + } else if (target.attachEvent) { + target.attachEvent('on' + type, JX.__rawEventQueue); + } + }; + + var document_events = [ + 'click', + 'dblclick', + 'change', + 'submit', + 'keypress', + 'mousedown', + 'mouseover', + 'mouseout', + 'mouseup', + 'keyup', + 'keydown', + 'input', + 'drop', + 'dragenter', + 'dragleave', + 'dragover', + 'paste', + 'touchstart', + 'touchmove', + 'touchend', + 'touchcancel' + ]; + + // Simulate focus and blur in old versions of IE using focusin and focusout + // TODO: Document the gigantic IE mess here with focus/blur. + // TODO: beforeactivate/beforedeactivate? + // http://www.quirksmode.org/blog/archives/2008/04/delegating_the.html + if (!has_add_event_listener) { + document_events.push('focusin', 'focusout'); + } + + // Opera is multilol: it propagates focus / blur oddly + if (window.opera) { + document_events.push('focus', 'blur'); + } + + if (__DEV__) { + JX.__allowedEvents = {}; + if ('onpagehide' in window) { + JX.__allowedEvents.unload = true; + } + } + + for (var ii = 0; ii < document_events.length; ++ii) { + JX.enableDispatch(root, document_events[ii]); + } + + // In particular, we're interested in capturing window focus/blur here so + // long polls can abort when the window is not focused. + var window_events = [ + ('onpagehide' in window) ? 'pagehide' : 'unload', + 'resize', + 'scroll', + 'focus', + 'blur', + 'popstate', + 'hashchange' + ]; + + + for (var ii = 0; ii < window_events.length; ++ii) { + JX.enableDispatch(window, window_events[ii]); + } + + JX.__simulate = function(node, event) { + if (!has_add_event_listener) { + var e = {target: node, type: event}; + JX.__rawEventQueue(e); + if (e.returnValue === false) { + return false; + } + } + }; + + if (has_add_event_listener) { + document.addEventListener('DOMContentLoaded', function() { + JX.__rawEventQueue({type: 'domready'}); + }, true); + } else { + var ready = + "if (this.readyState == 'complete') {" + + "JX.__rawEventQueue({type: 'domready'});" + + "}"; + + document.write( + '<script' + + ' defer="defer"' + + ' src="javascript:void(0)"' + + ' onreadystatechange="' + ready + '"' + + '><\/sc' + 'ript\>'); + } + + JX.onload = function(func) { + if (loaded) { + func(); + } else { + onload.push(func); + } + } +})(); Index: externals/javelin/src/core/install.js =================================================================== --- /dev/null +++ externals/javelin/src/core/install.js @@ -0,0 +1,459 @@ +/** + * @requires javelin-util + * javelin-magical-init + * @provides javelin-install + * + * @javelin-installs JX.install + * @javelin-installs JX.createClass + * + * @javelin + */ + +/** + * Install a class into the Javelin ("JX") namespace. The first argument is the + * name of the class you want to install, and the second is a map of these + * attributes (all of which are optional): + * + * - ##construct## //(function)// Class constructor. If you don't provide one, + * one will be created for you (but it will be very boring). + * - ##extend## //(string)// The name of another JX-namespaced class to extend + * via prototypal inheritance. + * - ##members## //(map)// A map of instance methods and properties. + * - ##statics## //(map)// A map of static methods and properties. + * - ##initialize## //(function)// A function which will be run once, after + * this class has been installed. + * - ##properties## //(map)// A map of properties that should have instance + * getters and setters automatically generated for them. The key is the + * property name and the value is its default value. For instance, if you + * provide the property "size", the installed class will have the methods + * "getSize()" and "setSize()". It will **NOT** have a property ".size" + * and no guarantees are made about where install is actually chosing to + * store the data. The motivation here is to let you cheaply define a + * stable interface and refine it later as necessary. + * - ##events## //(list)// List of event types this class is capable of + * emitting. + * + * For example: + * + * JX.install('Dog', { + * construct : function(name) { + * this.setName(name); + * }, + * members : { + * bark : function() { + * // ... + * } + * }, + * properites : { + * name : null, + * } + * }); + * + * This creates a new ##Dog## class in the ##JX## namespace: + * + * var d = new JX.Dog(); + * d.bark(); + * + * Javelin classes are normal Javascript functions and generally behave in + * the expected way. Some properties and methods are automatically added to + * all classes: + * + * - ##instance.__id__## Globally unique identifier attached to each instance. + * - ##prototype.__class__## Reference to the class constructor. + * - ##constructor.__path__## List of path tokens used emit events. It is + * probably never useful to access this directly. + * - ##constructor.__readable__## Readable class name. You could use this + * for introspection. + * - ##constructor.__events__## //DEV ONLY!// List of events supported by + * this class. + * - ##constructor.listen()## Listen to all instances of this class. See + * @{JX.Base}. + * - ##instance.listen()## Listen to one instance of this class. See + * @{JX.Base}. + * - ##instance.invoke()## Invoke an event from an instance. See @{JX.Base}. + * + * + * @param string Name of the class to install. It will appear in the JX + * "namespace" (e.g., JX.Pancake). + * @param map Map of properties, see method documentation. + * @return void + * + * @group install + */ +JX.install = function(new_name, new_junk) { + + // If we've already installed this, something is up. + if (new_name in JX) { + if (__DEV__) { + JX.$E( + 'JX.install("' + new_name + '", ...): ' + + 'trying to reinstall something that has already been installed.'); + } + return; + } + + if (__DEV__) { + if ('name' in new_junk) { + JX.$E( + 'JX.install("' + new_name + '", {"name": ...}): ' + + 'trying to install with "name" property.' + + 'Either remove it or call JX.createClass directly.'); + } + } + + // Since we may end up loading things out of order (e.g., Dog extends Animal + // but we load Dog first) we need to keep a list of things that we've been + // asked to install but haven't yet been able to install around. + (JX.install._queue || (JX.install._queue = [])).push([new_name, new_junk]); + var name; + do { + var junk; + var initialize; + name = null; + for (var ii = 0; ii < JX.install._queue.length; ++ii) { + junk = JX.install._queue[ii][1]; + if (junk.extend && !JX[junk.extend]) { + // We need to extend something that we haven't been able to install + // yet, so just keep this in queue. + continue; + } + + // Install time! First, get this out of the queue. + name = JX.install._queue.splice(ii, 1)[0][0]; + --ii; + + if (junk.extend) { + junk.extend = JX[junk.extend]; + } + + initialize = junk.initialize; + delete junk.initialize; + junk.name = 'JX.' + name; + + JX[name] = JX.createClass(junk); + + if (initialize) { + if (JX['Stratcom'] && JX['Stratcom'].ready) { + initialize.apply(null); + } else { + // This is a holding queue, defined in init.js. + JX['install-init'](initialize); + } + } + } + + // In effect, this exits the loop as soon as we didn't make any progress + // installing things, which means we've installed everything we have the + // dependencies for. + } while (name); +}; + +/** + * Creates a class from a map of attributes. Requires ##extend## property to + * be an actual Class object and not a "String". Supports ##name## property + * to give the created Class a readable name. + * + * @see JX.install for description of supported attributes. + * + * @param junk Map of properties, see method documentation. + * @return function Constructor of a class created + * + * @group install + */ +JX.createClass = function(junk) { + var name = junk.name || ''; + var k; + var ii; + + if (__DEV__) { + var valid = { + construct : 1, + statics : 1, + members : 1, + extend : 1, + properties : 1, + events : 1, + name : 1 + }; + for (k in junk) { + if (!(k in valid)) { + JX.$E( + 'JX.createClass("' + name + '", {"' + k + '": ...}): ' + + 'trying to create unknown property `' + k + '`.'); + } + } + if (junk.constructor !== {}.constructor) { + JX.$E( + 'JX.createClass("' + name + '", {"constructor": ...}): ' + + 'property `constructor` should be called `construct`.'); + } + } + + // First, build the constructor. If construct is just a function, this + // won't change its behavior (unless you have provided a really awesome + // function, in which case it will correctly punish you for your attempt + // at creativity). + var Class = (function(name, junk) { + var result = function() { + this.__id__ = '__obj__' + (++JX.install._nextObjectID); + return (junk.construct || junk.extend || JX.bag).apply(this, arguments); + // TODO: Allow mixins to initialize here? + // TODO: Also, build mixins? + }; + + if (__DEV__) { + var inner = result; + result = function() { + if (this == window || this == JX) { + JX.$E( + '<' + Class.__readable__ + '>: ' + + 'Tried to construct an instance without the "new" operator.'); + } + return inner.apply(this, arguments); + }; + } + return result; + })(name, junk); + + Class.__readable__ = name; + + // Copy in all the static methods and properties. + for (k in junk.statics) { + // Can't use JX.copy() here yet since it may not have loaded. + Class[k] = junk.statics[k]; + } + + var proto; + if (junk.extend) { + var Inheritance = function() {}; + Inheritance.prototype = junk.extend.prototype; + proto = Class.prototype = new Inheritance(); + } else { + proto = Class.prototype = {}; + } + + proto.__class__ = Class; + var setter = function(prop) { + return function(v) { + this[prop] = v; + return this; + }; + }; + var getter = function(prop) { + return function(v) { + return this[prop]; + }; + }; + + // Build getters and setters from the `prop' map. + for (k in (junk.properties || {})) { + var base = k.charAt(0).toUpperCase() + k.substr(1); + var prop = '__auto__' + k; + proto[prop] = junk.properties[k]; + proto['set' + base] = setter(prop); + proto['get' + base] = getter(prop); + } + + if (__DEV__) { + + // Check for aliasing in default values of members. If we don't do this, + // you can run into a problem like this: + // + // JX.install('List', { members : { stuff : [] }}); + // + // var i_love = new JX.List(); + // var i_hate = new JX.List(); + // + // i_love.stuff.push('Psyduck'); // I love psyduck! + // JX.log(i_hate.stuff); // Show stuff I hate. + // + // This logs ["Psyduck"] because the push operation modifies + // JX.List.prototype.stuff, which is what both i_love.stuff and + // i_hate.stuff resolve to. To avoid this, set the default value to + // null (or any other scalar) and do "this.stuff = [];" in the + // constructor. + + for (var member_name in junk.members) { + if (junk.extend && member_name[0] == '_') { + JX.$E( + 'JX.createClass("' + name + '", ...): ' + + 'installed member "' + member_name + '" must not be named with ' + + 'a leading underscore because it is in a subclass. Variables ' + + 'are analyzed and crushed one file at a time, and crushed ' + + 'member variables in subclasses alias crushed member variables ' + + 'in superclasses. Remove the underscore, refactor the class so ' + + 'it does not extend anything, or fix the minifier to be ' + + 'capable of safely crushing subclasses.'); + } + var member_value = junk.members[member_name]; + if (typeof member_value == 'object' && member_value !== null) { + JX.$E( + 'JX.createClass("' + name + '", ...): ' + + 'installed member "' + member_name + '" is not a scalar or ' + + 'function. Prototypal inheritance in Javascript aliases object ' + + 'references across instances so all instances are initialized ' + + 'to point at the exact same object. This is almost certainly ' + + 'not what you intended. Make this member static to share it ' + + 'across instances, or initialize it in the constructor to ' + + 'prevent reference aliasing and give each instance its own ' + + 'copy of the value.'); + } + } + } + + + // This execution order intentionally allows you to override methods + // generated from the "properties" initializer. + for (k in junk.members) { + proto[k] = junk.members[k]; + } + + // IE does not enumerate some properties on objects + var enumerables = JX.install._enumerables; + if (junk.members && enumerables) { + ii = enumerables.length; + while (ii--){ + var property = enumerables[ii]; + if (junk.members[property]) { + proto[property] = junk.members[property]; + } + } + } + + // Build this ridiculous event model thing. Basically, this defines + // two instance methods, invoke() and listen(), and one static method, + // listen(). If you listen to an instance you get events for that + // instance; if you listen to a class you get events for all instances + // of that class (including instances of classes which extend it). + // + // This is rigged up through Stratcom. Each class has a path component + // like "class:Dog", and each object has a path component like + // "obj:23". When you invoke on an object, it emits an event with + // a path that includes its class, all parent classes, and its object + // ID. + // + // Calling listen() on an instance listens for just the object ID. + // Calling listen() on a class listens for that class's name. This + // has the effect of working properly, but installing them is pretty + // messy. + + var parent = junk.extend || {}; + var old_events = parent.__events__; + var new_events = junk.events || []; + var has_events = old_events || new_events.length; + + if (has_events) { + var valid_events = {}; + + // If we're in dev, we build up a list of valid events (for this class + // and our parent class), and then check them on listen and invoke. + if (__DEV__) { + for (var key in old_events || {}) { + valid_events[key] = true; + } + for (ii = 0; ii < new_events.length; ++ii) { + valid_events[junk.events[ii]] = true; + } + } + + Class.__events__ = valid_events; + + // Build the class name chain. + Class.__name__ = 'class:' + name; + var ancestry = parent.__path__ || []; + Class.__path__ = ancestry.concat([Class.__name__]); + + proto.invoke = function(type) { + if (__DEV__) { + if (!(type in this.__class__.__events__)) { + JX.$E( + this.__class__.__readable__ + '.invoke("' + type + '", ...): ' + + 'invalid event type. Valid event types are: ' + + JX.keys(this.__class__.__events__).join(', ') + '.'); + } + } + // Here and below, this nonstandard access notation is used to mask + // these callsites from the static analyzer. JX.Stratcom is always + // available by the time we hit these execution points. + return JX['Stratcom'].invoke( + 'obj:' + type, + this.__class__.__path__.concat([this.__id__]), + {args : JX.$A(arguments).slice(1)}); + }; + + proto.listen = function(type, callback) { + if (__DEV__) { + if (!(type in this.__class__.__events__)) { + JX.$E( + this.__class__.__readable__ + '.listen("' + type + '", ...): ' + + 'invalid event type. Valid event types are: ' + + JX.keys(this.__class__.__events__).join(', ') + '.'); + } + } + return JX['Stratcom'].listen( + 'obj:' + type, + this.__id__, + JX.bind(this, function(e) { + return callback.apply(this, e.getData().args); + })); + }; + + Class.listen = function(type, callback) { + if (__DEV__) { + if (!(type in this.__events__)) { + JX.$E( + this.__readable__ + '.listen("' + type + '", ...): ' + + 'invalid event type. Valid event types are: ' + + JX.keys(this.__events__).join(', ') + '.'); + } + } + return JX['Stratcom'].listen( + 'obj:' + type, + this.__name__, + JX.bind(this, function(e) { + return callback.apply(this, e.getData().args); + })); + }; + } else if (__DEV__) { + var error_message = + 'class does not define any events. Pass an "events" property to ' + + 'JX.createClass() to define events.'; + Class.listen = Class.listen || function() { + JX.$E( + this.__readable__ + '.listen(...): ' + + error_message); + }; + Class.invoke = Class.invoke || function() { + JX.$E( + this.__readable__ + '.invoke(...): ' + + error_message); + }; + proto.listen = proto.listen || function() { + JX.$E( + this.__class__.__readable__ + '.listen(...): ' + + error_message); + }; + proto.invoke = proto.invoke || function() { + JX.$E( + this.__class__.__readable__ + '.invoke(...): ' + + error_message); + }; + } + + return Class; +}; + +JX.install._nextObjectID = 0; +JX.flushHoldingQueue('install', JX.install); + +(function() { + // IE does not enter this loop. + for (var i in {toString: 1}) { + return; + } + + JX.install._enumerables = [ + 'toString', 'hasOwnProperty', 'valueOf', 'isPrototypeOf', + 'propertyIsEnumerable', 'toLocaleString', 'constructor' + ]; +})(); Index: externals/javelin/src/core/util.js =================================================================== --- /dev/null +++ externals/javelin/src/core/util.js @@ -0,0 +1,367 @@ +/** + * Javelin utility functions. + * + * @provides javelin-util + * + * @javelin-installs JX.$E + * @javelin-installs JX.$A + * @javelin-installs JX.$AX + * @javelin-installs JX.isArray + * @javelin-installs JX.copy + * @javelin-installs JX.bind + * @javelin-installs JX.bag + * @javelin-installs JX.keys + * @javelin-installs JX.log + * @javelin-installs JX.id + * @javelin-installs JX.now + * + * @javelin + */ + +/** + * Throw an exception and attach the caller data in the exception. + * + * @param string Exception message. + * + * @group util + */ +JX.$E = function(message) { + var e = new Error(message); + var caller_fn = JX.$E.caller; + if (caller_fn) { + e.caller_fn = caller_fn.caller; + } + throw e; +}; + + +/** + * Convert an array-like object (usually ##arguments##) into a real Array. An + * "array-like object" is something with a ##length## property and numerical + * keys. The most common use for this is to let you call Array functions on the + * magical ##arguments## object. + * + * JX.$A(arguments).slice(1); + * + * @param obj Array, or array-like object. + * @return Array Actual array. + * + * @group util + */ +JX.$A = function(mysterious_arraylike_object) { + // NOTE: This avoids the Array.slice() trick because some bizarre COM object + // I dug up somewhere was freaking out when I tried to do it and it made me + // very upset, so do not replace this with Array.slice() cleverness. + var r = []; + for (var ii = 0; ii < mysterious_arraylike_object.length; ii++) { + r.push(mysterious_arraylike_object[ii]); + } + return r; +}; + + +/** + * Cast a value into an array, by wrapping scalars into singletons. If the + * argument is an array, it is returned unmodified. If it is a scalar, an array + * with a single element is returned. For example: + * + * JX.$AX([3]); // Returns [3]. + * JX.$AX(3); // Returns [3]. + * + * Note that this function uses a @{function:JX.isArray} check whether or not + * the argument is an array, so you may need to convert array-like objects (such + * as ##arguments##) into real arrays with @{function:JX.$A}. + * + * This function is mostly useful to create methods which accept either a + * value or a list of values. + * + * @param wild Scalar or Array. + * @return Array If the argument was a scalar, an Array with the argument as + * its only element. Otherwise, the original Array. + * + * @group util + */ +JX.$AX = function(maybe_scalar) { + return JX.isArray(maybe_scalar) ? maybe_scalar : [maybe_scalar]; +}; + + +/** + * Checks whether a value is an array. + * + * JX.isArray(['an', 'array']); // Returns true. + * JX.isArray('Not an Array'); // Returns false. + * + * @param wild Any value. + * @return bool true if the argument is an array, false otherwise. + * + * @group util + */ +JX.isArray = Array.isArray || function(maybe_array) { + return Object.prototype.toString.call(maybe_array) == '[object Array]'; +}; + + +/** + * Copy properties from one object to another. If properties already exist, they + * are overwritten. + * + * var cat = { + * ears: 'clean', + * paws: 'clean', + * nose: 'DIRTY OH NOES' + * }; + * var more = { + * nose: 'clean', + * tail: 'clean' + * }; + * + * JX.copy(cat, more); + * + * // cat is now: + * // { + * // ears: 'clean', + * // paws: 'clean', + * // nose: 'clean', + * // tail: 'clean' + * // } + * + * NOTE: This function does not copy the ##toString## property or anything else + * which isn't enumerable or is somehow magic or just doesn't work. But it's + * usually what you want. + * + * @param obj Destination object, which properties should be copied to. + * @param obj Source object, which properties should be copied from. + * @return obj Modified destination object. + * + * @group util + */ +JX.copy = function(copy_dst, copy_src) { + for (var k in copy_src) { + copy_dst[k] = copy_src[k]; + } + return copy_dst; +}; + + +/** + * Create a function which invokes another function with a bound context and + * arguments (i.e., partial function application) when called; king of all + * functions. + * + * Bind performs context binding (letting you select what the value of ##this## + * will be when a function is invoked) and partial function application (letting + * you create some function which calls another one with bound arguments). + * + * = Context Binding = + * + * Normally, when you call ##obj.method()##, the magic ##this## object will be + * the ##obj## you invoked the method from. This can be undesirable when you + * need to pass a callback to another function. For instance: + * + * COUNTEREXAMPLE + * var dog = new JX.Dog(); + * dog.barkNow(); // Makes the dog bark. + * + * JX.Stratcom.listen('click', 'bark', dog.barkNow); // Does not work! + * + * This doesn't work because ##this## is ##window## when the function is + * later invoked; @{method:JX.Stratcom.listen} does not know about the context + * object ##dog##. The solution is to pass a function with a bound context + * object: + * + * var dog = new JX.Dog(); + * var bound_function = JX.bind(dog, dog.barkNow); + * + * JX.Stratcom.listen('click', 'bark', bound_function); + * + * ##bound_function## is a function with ##dog## bound as ##this##; ##this## + * will always be ##dog## when the function is called, no matter what + * property chain it is invoked from. + * + * You can also pass ##null## as the context argument to implicitly bind + * ##window##. + * + * = Partial Function Application = + * + * @{function:JX.bind} also performs partial function application, which allows + * you to bind one or more arguments to a function. For instance, if we have a + * simple function which adds two numbers: + * + * function add(a, b) { return a + b; } + * add(3, 4); // 7 + * + * Suppose we want a new function, like this: + * + * function add3(b) { return 3 + b; } + * add3(4); // 7 + * + * Instead of doing this, we can define ##add3()## in terms of ##add()## by + * binding the value ##3## to the ##a## argument: + * + * var add3_bound = JX.bind(null, add, 3); + * add3_bound(4); // 7 + * + * Zero or more arguments may be bound in this way. This is particularly useful + * when using closures in a loop: + * + * COUNTEREXAMPLE + * for (var ii = 0; ii < button_list.length; ii++) { + * button_list[ii].onclick = function() { + * JX.log('You clicked button number '+ii+'!'); // Fails! + * }; + * } + * + * This doesn't work; all the buttons report the highest number when clicked. + * This is because the local ##ii## is captured by the closure. Instead, bind + * the current value of ##ii##: + * + * var func = function(button_num) { + * JX.log('You clicked button number '+button_num+'!'); + * } + * for (var ii = 0; ii < button_list.length; ii++) { + * button_list[ii].onclick = JX.bind(null, func, ii); + * } + * + * @param obj|null Context object to bind as ##this##. + * @param function Function to bind context and arguments to. + * @param ... Zero or more arguments to bind. + * @return function New function which invokes the original function with + * bound context and arguments when called. + * + * @group util + */ +JX.bind = function(context, func, more) { + if (__DEV__) { + if (typeof func != 'function') { + JX.$E( + 'JX.bind(context, <yuck>, ...): '+ + 'Attempting to bind something that is not a function.'); + } + } + + var bound = JX.$A(arguments).slice(2); + if (func.bind) { + return func.bind.apply(func, [context].concat(bound)); + } + + return function() { + return func.apply(context || window, bound.concat(JX.$A(arguments))); + } +}; + + +/** + * "Bag of holding"; function that does nothing. Primarily, it's used as a + * placeholder when you want something to be callable but don't want it to + * actually have an effect. + * + * @return void + * + * @group util + */ +JX.bag = function() { + // \o\ \o/ /o/ woo dance party +}; + + +/** + * Convert an object's keys into a list. For example: + * + * JX.keys({sun: 1, moon: 1, stars: 1}); // Returns: ['sun', 'moon', 'stars'] + * + * @param obj Object to retrieve keys from. + * @return list List of keys. + * + * @group util + */ +JX.keys = Object.keys || function(obj) { + var r = []; + for (var k in obj) { + r.push(k); + } + return r; +}; + + +/** + * Identity function; returns the argument unmodified. This is primarily useful + * as a placeholder for some callback which may transform its argument. + * + * @param wild Any value. + * @return wild The passed argument. + * + * @group util + */ +JX.id = function(any) { + return any; +}; + + +JX.log = JX.bag; + +if (__DEV__) { + if (!window.console || !window.console.log) { + if (window.opera && window.opera.postError) { + window.console = {log: function(m) { window.opera.postError(m); }}; + } else { + window.console = {log: function(m) { }}; + } + } + + /** + * Print a message to the browser debugging console (like Firebug). This + * method exists only in ##__DEV__##. + * + * @param string Message to print to the browser debugging console. + * @return void + * + * @group util + */ + JX.log = function(message) { + window.console.log(message); + } + + window.alert = (function(native_alert) { + var recent_alerts = []; + var in_alert = false; + return function(msg) { + if (in_alert) { + JX.log( + 'alert(...): '+ + 'discarded reentrant alert.'); + return; + } + in_alert = true; + recent_alerts.push(JX.now()); + + if (recent_alerts.length > 3) { + recent_alerts.splice(0, recent_alerts.length - 3); + } + + if (recent_alerts.length >= 3 && + (recent_alerts[recent_alerts.length - 1] - recent_alerts[0]) < 5000) { + if (confirm(msg + "\n\nLots of alert()s recently. Kill them?")) { + window.alert = JX.bag; + } + } else { + // Note that we can't .apply() the IE6 version of this "function". + native_alert(msg); + } + in_alert = false; + } + })(window.alert); +} + +/** + * Date.now is the fastest timestamp function, but isn't supported by every + * browser. This gives the fastest version the environment can support. + * The wrapper function makes the getTime call even slower, but benchmarking + * shows it to be a marginal perf loss. Considering how small of a perf + * difference this makes overall, it's not really a big deal. The primary + * reason for this is to avoid hacky "just think of the byte savings" JS + * like +new Date() that has an unclear outcome for the unexposed. + * + * @return Int A Unix timestamp of the current time on the local machine + */ +JX.now = (Date.now || function() { return new Date().getTime(); }); Index: externals/javelin/src/docs/Base.js =================================================================== --- /dev/null +++ externals/javelin/src/docs/Base.js @@ -0,0 +1,75 @@ +/** + * @requires javelin-install + * @javelin + */ + +/** + * This is not a real class, but @{function:JX.install} provides several methods + * which exist on all Javelin classes. This class documents those methods. + * + * @task events Builtin Events + * @group install + */ +JX.install('Base', { + members : { + + /** + * Invoke a class event, notifying all listeners. You must declare the + * events your class invokes when you install it; see @{function:JX.install} + * for documentation. Any arguments you provide will be passed to listener + * callbacks. + * + * @param string Event type, must be declared when class is + * installed. + * @param ... Zero or more arguments. + * + * @return @{JX.Event} Event object which was dispatched. + * @task events + */ + invoke : function(type, more) { + // <docstub only, see JX.install()> // + }, + + /** + * Listen for events emitted by this object instance. You can also use + * the static flavor of this method to listen to events emitted by any + * instance of this object. + * + * See also @{method:JX.Stratcom.listen}. + * + * @param string Type of event to listen for. + * @param function Function to call when this event occurs. + * @return object A reference to the installed listener. You can later + * remove the listener by calling this object's remove() + * method. + * @task events + */ + listen : function(type, callback) { + // <docstub only, see JX.install()> // + } + + }, + statics : { + + /** + * Static listen interface for listening to events produced by any instance + * of this class. See @{method:listen} for documentation. + * + * @param string Type of event to listen for. + * @param function Function to call when this event occurs. + * @return object A reference to the installed listener. You can later + * remove the listener by calling this object's remove() + * method. + * @task events + */ + listen : function(type, callback) { + // <docstub only, see JX.install()> // + } + + } +}); + + + + + Index: externals/javelin/src/docs/concepts/behaviors.diviner =================================================================== --- /dev/null +++ externals/javelin/src/docs/concepts/behaviors.diviner @@ -0,0 +1,182 @@ +@title Concepts: Behaviors +@group concepts + +Javelin behaviors help you glue pieces of code together. + += Overview = + +Javelin behaviors provide a place for you to put glue code. For instance, when +a page loads, you often need to instantiate objects, or set up event listeners, +or alert the user that they've won a hog, or create a dependency between two +objects, or modify the DOM, etc. + +Sometimes there's enough code involved here or a particular setup step happens +often enough that it makes sense to write a class, but sometimes it's just a +few lines of one-off glue. Behaviors give you a structured place to put this +glue so that it's consistently organized and can benefit from Javelin +infrastructure. + += Behavior Basics = + +Behaviors are defined with @{function:JX.behavior}: + + lang=js + JX.behavior('win-a-hog', function(config, statics) { + alert("YOU WON A HOG NAMED " + config.hogName + "!"); + }); + +They are called with @{function:JX.initBehaviors}: + + lang=js + JX.initBehaviors({ + "win-a-hog" : [{hogName : "Ethel"}] + }); + +Normally, you don't construct the @{function:JX.initBehaviors} call yourself, +but instead use a server-side library which manages behavior initialization for +you. For example, using the PHP library: + + lang=php + $config = array('hogName' => 'Ethel'); + JavelinHelper::initBehaviors('win-a-hog', $config); + +Regardless, this will alert the user that they've won a hog (named Ethel, which +is a good name for a hog) when they load the page. + +The callback you pass to @{function:JX.behavior} should have this signature: + + lang=js + function(config, statics) { + // ... + } + +The function will be invoked once for each configuration dictionary passed to +@{function:JX.initBehaviors}, and the dictionary will be passed as the +##config## parameter. For example, to alert the user that they've won two hogs: + + lang=js + JX.initBehaviors({ + "win-a-hog" : [{hogName : "Ethel"}, {hogName: "Beatrice"}] + }); + +This will invoke the function twice, once for each ##config## dictionary. +Usually, you invoke a behavior multiple times if you have several similar +controls on a page, like multiple @{class:JX.Tokenizer}s. + +An initially empty object will be passed in the ##statics## parameter, but +changes to this object will persist across invocations of the behavior. For +example: + + lang=js + JX.initBehaviors('win-a-hog', function(config, statics) { + statics.hogsWon = (statics.hogsWon || 0) + 1; + + if (statics.hogsWon == 1) { + alert("YOU WON A HOG! YOUR HOG IS NAMED " + config.hogName + "!"); + } else { + alert("YOU WON ANOTHER HOG!!! THIS ONE IS NAMED " + config.hogName + "!"); + } + } + +One way to think about behaviors are that they take the anonymous function +passed to @{function:JX.behavior} and put it in a private Javelin namespace, +which you access with @{function:JX.initBehavior}. + +Another way to think about them is that you are defining methods which represent +the entirety of the API exposed by the document. The recommended approach to +glue code is that the server interact with Javascript on the client //only// by +invoking behaviors, so the set of available behaviors represent the complete set +of legal interactions available to the server. + += History and Rationale = + +This section explains why behaviors exist and how they came about. You can +understand and use them without knowing any of this, but it may be useful or +interesting. + +In early 2007, Facebook often solved the "glue code" problem through the use +of global functions and DOM Level 0 event handlers, by manually building HTML +tags in PHP: + + lang=php + echo '<a href="#" '. + 'onclick="win_a_hog('.escape_js_string($hog_name).'); return false;">'. + 'Click here to win!'. + '</a>'; + +(This example produces a link which the user can click to be alerted they have +won a hog, which is slightly different from the automatic alert in the other +examples in this document. Some subtle distinctions are ignored or glossed +over here because they are not important to understanding behaviors.) + +This has a wide array of technical and architectural problems: + + - Correctly escaping parameters is cumbersome and difficult. + - It resists static analysis, and is difficult to even grep for. You can't + easily package, minify, or determine dependencies for the piece of JS in + the result string. + - DOM Level 0 events have a host of issues in a complex application + environment. + - The JS global namespace becomes polluted with application glue functions. + - The server and client are tightly and relatively arbitrarily coupled, since + many of these handlers called multiple functions or had logic in the + strings. There is no structure to the coupling, so many callers relied on + the full power of arbitrary JS execution. + - It's utterly hideous. + +In 2007/2008, we introduced @{function@libphutil:jsprintf} and a function called +onloadRegister() to solve some of the obvious problems: + + lang=php + onloadRegister('win_a_hog(%s);', $hog_name); + +This registers the snippet for invocation after DOMContentReady fires. This API +makes escaping manageable, and was combined with recommendations to structure +code like this in order to address some of the other problems: + + lang=php + $id = uniq_id(); + echo '<a href="#" id="'.$id.'">Click here to win!</a>'; + onloadRegister('new WinAHogController(%s, %s);', $id, $hog_name); + +By 2010 (particularly with the introduction of XHP) the API had become more +sophisticated, but this is basically how most of Facebook's glue code still +works as of mid-2011. If you view the source of any page, you'll see a bunch +of ##onloadRegister()## calls in the markup which are generated like this. + +This mitigates many of the problems but is still fairly awkward. Escaping is +easier, but still possible to get wrong. Stuff is a bit easier to grep for, but +not much. You can't get very far with static analysis unless you get very +complex. Coupling between the languages has been reduced but not eliminated. And +now you have a bunch of classes which only really have glue code in them. + +Javelin behaviors provide a more structured solution to some of these problems: + + - All your Javascript code is in Javascript files, not embedded in strings in + in some host language on the server side. + - You can use static analysis and minification tools normally. + - Provided you use a reasonable server-side library, you can't get escaping + wrong. + - Coupling is reduced because server only passes data to the client, never + code. + - The server declares client dependencies explicitly, not implicitly inside + a string literal. Behaviors are also relatively easy to grep for. + - Behaviors exist in a private, structured namespace instead of the global + namespace. + - Separation between the document's layout and behavior is a consequence of + the structure of behaviors. + - The entire interface the server may invoke against can be readily inferred. + +Note that Javelin does provide @{function:JX.onload}, which behaves like +##onloadRegister()##. However, its use is discouraged. + +The two major downsides to the behavior design appear to be: + + - They have a higher setup cost than the ad-hoc methods, but Javelin + philosophically places a very low value on this. + - Because there's a further setup cost to migrate an existing behavior into a + class, behaviors sometimes grow little by little until they are too big, + have more than just glue code, and should have been refactored into a + real class some time ago. This is a pretty high-level drawback and is + manageable through awareness of the risk and code review. + Index: externals/javelin/src/docs/concepts/event_delegation.diviner =================================================================== --- /dev/null +++ externals/javelin/src/docs/concepts/event_delegation.diviner @@ -0,0 +1,191 @@ +@title Concepts: Event Delegation +@group concepts + +Explains Javelin event delegation with @{class:JX.Stratcom}. + += Overview = + +Javelin provides event delegation as a core feature of the library, orchestrated +with @{class:JX.Stratcom}. Event delegation means that the library listens to +every event in the document and then delegates them to handlers you install, +as opposed to you installing handlers on specific nodes for specific events you +are interested in. + +Event delegation can greatly simplify event handling for many types of user +interactions, and can also be used to do more traditional event listening for +specific events on specific nodes. The goal is to provide a strictly more +powerful event model, which gives you a very general delegation toolkit for +interactions where delegation makes sense but refines into a very specific +toolkit when you need less generality. + +Beyond DOM events, Stratcom provides a general event delegation framework which +Javelin classes integrate with. + += Event Delegation Basics = + +Javelin routes events based on the **event type** and **sigil set**. + +The **event type** is a string with a general description of the event, and +includes the DOM event types like 'click' and 'keydown'. It may also be a +class-specific event (JX.Duck might emit 'quack'). + +The **sigil set** is a list of sigils (see @{article:Concepts: Sigils and +Metadata}) for the event. If the event is a DOM event, Javelin builds the +sigil set by walking up the DOM tree from the event target and collecting all +the sigils on nodes (it also collects some other data into the sigil set, +see "Magic Sigils" below). If the event is a class event, Javelin walks up +the class hierarchy collecting class names. If the event is a raw event invoked +with @{method:JX.Stratcom.invoke}, the caller must provide the sigil set. + +When you install an event handler, you specify the event type and the (possibly +empty) sigil set you want to listen for. + +When an event is invoked, Javelin finds all the listeners for that event type +and compares their sigil sets with the event's sigil set. If all of the sigils +in a callback's sigil set appear in the event's sigil set, the callback is +invoked. Generally, this mechanism allows you to ignore events you are not +interested in. + += Listening to General DOM Events = + +You can listen to general DOM events with @{method:JX.Stratcom.listen}. This +method allows you to select which types of events you want to receive, and +which elements must be involved in the event: + + lang=js + JX.Stratcom.listen( + 'click', // Node + null, // Sigil set + function(e) { // Callback + // ... + }); + +This listens to all clicks on all elements in the document. More likely, you +want to listen only to some clicks. You accomplish this by annotating your +document with Javelin sigils (see @{article:Concepts: Sigils and Metadata}) +and specifying a set of sigils which must be present between the target node +and the document root. For instance, your document might look like this: + + lang=html + <a href="#" data-sigil="important">Important!</a> + <a href="#">Some Other Link</a> + +If you install a handler like the one above, you'll get callbacks for every +click, no matter which link it is or even if it's on the document itself. If +you just want clicks on the "Important!" link, you can install a more specific +handler: + + lang=js + JX.Stratcom.listen( + 'click', + 'important', // Listen only to this sigil set + function(e) { + // ... + }); + +Now you will receive a callback only when the event target or some ancestor of +it has the "important" sigil, i.e., only when the user clicks on the +"Important!" link. You can also specify multiple sigils; ancestors must have +all of the sigils for you to get a callback: + + lang=js + JX.Stratcom.listen( + 'click', + ['menu', 'item'], // Listen only for clicks on menu items. + function(e) { + // ... + }); + += Listening to Specific DOM Events = + +You can listen to specific DOM events with @{method:JX.DOM.listen}. This method +works like @{method:JX.Stratcom.listen} but takes a DOM node as the first +parameter and listens only for events within that node: + + lang=js + JX.DOM.listen( + node, // Node + 'click', // Event type(s) + null, // Sigil set + function(e) { // Callback + // ... + }); + +This is similar to setting ##node.onclick## or ##node.addEventListener##, but +uses the Javelin delegation core. You can also provide a sigil set, which works +just like @{method:JX.Stratcom.listen} general events. This is useful if your +node is a container, like a ##<div />##, with a lot of stuff in it. + + += DOM Event Flow = + +Events dispatched within the DOM propagate using a bubbling method, as detailed +by http://www.w3.org/TR/DOM-Level-3-Events/#event-flow +Listeners assigned using @{method:JX.Stratcom.listen} or @{method:JX.DOM.listen} +are called in order of the deepest node in the DOM of the nodes which match the +sigil set listened to. + +In this example the second listener, subscribed to 'inner', will be called +before the listener subscribed to 'outer' + + lang=html + <div data-sigil="outer"> + <div data-sigil="inner"> + Click Me + </div> + </div> + + <script type="text/javascript"> + JX.Stratcom.listen('click', ['outer'], function(e) { ... }); + JX.Stratcom.listen('click', ['inner'], function(e) { ... }); + </script> + + += Listening to Class Events = + +Beyond DOM events, you can also listen to class events. Every class installed +by Javelin has static and instance methods called ##listen## (see +@{method:JX.Base.listen}). The static method allows you to listen for all events +emitted by every instance of a class and its descendants: + + lang=js + JX.Animal.listen( + 'meow', + function(e) { + // Listen for ANY 'meow' from any JX.Animal instance or instance which + // extends JX.Animal. + }); + +The instance method allows you to listen for all events emitted by that +specific instance: + + lang=js + var cat = new JX.Cat(); + cat.listen( + 'meow', + function(e) { + // Listen for 'meow' from only this cat. + }); + += Magic Sigils = + +Javelin implements general delegation by building and comparing sigil sets. Some +of these sigils are not DOM sigils, but derived from other things: + + - ##id:*## ID sigils are generated when an examined node has an "id" property. + - ##obj:*## Object sigils are generated when an event affects a class + instance. + - ##class:*## Class sigils are generated while walking an affected instance's + class chain. + - ##tag:*## Tag sigils are generated by examining the tag names of DOM nodes. + +For instance, you can listen to all clicks on ##<a />## tags in a document like +this: + + lang=js + JX.Stratcom.listen( + 'click', + 'tag:a', + function(e) { + // ... + }); Index: externals/javelin/src/docs/concepts/sigils_metadata.diviner =================================================================== --- /dev/null +++ externals/javelin/src/docs/concepts/sigils_metadata.diviner @@ -0,0 +1,129 @@ +@title Concepts: Sigils and Metadata +@group concepts + +Explains Javelin's sigils and metadata. + += Overview = + +Javelin introduces two major concepts, "sigils" and "metadata", which are core +parts of the library but don't generally exist in other Javascript libraries. +Both sigils and metadata are extra information you add to the DOM which Javelin +can access. This document explains what they are, why they exist, and how you +use them. + += Sigils = + +Sigils are names attached to nodes in the DOM. They behave almost exactly like +CSS class names: sigils are strings, each node may have zero or more sigils, and +sigils are not unique within a document. Sigils convey semantic information +about the structure of the document. + +It is reasonable to think of sigils as being CSS class names in a different, +semantic namespace. + +If you're emitting raw tags, you specify sigils by adding a ##data-sigil## +attribute to a node: + + lang=html + <div data-sigil="newsfeed"> + <div data-sigil="story"> + ... + </div> + <div data-sigil="story"> + ... + </div> + </div> + +However, this should be considered an implementation detail and you should not +rely on it excessively. In Javelin, use @{method:JX.Stratcom.hasSigil} to test +if a node has a given sigil, and @{method:JX.Stratcom.addSigil} to add a sigil +to a node. + +Javelin uses sigils instead of CSS classes to rigidly enforce the difference +between semantic information and style information in the document. While CSS +classes can theoretically handle both, the conflation between semantic and style +information in a realistic engineering environment caused a number of problems +at Facebook, including a few silly, preventable, and unfortunately severe bugs. + +Javelin separates this information into different namespaces, so developers and +designers can be confident that changing CSS classes and presentation of a +document will never change its semantic meaning. This is also why Javelin does +not have a method to test whether a node has a CSS class, and does not have CSS +selectors. Unless you cheat, Javelin makes it very difficult to use CSS class +names semantically. + +This is an unusual decision for a library, and quite possibly the wrong tradeoff +in many environments. But this was a continual source of problems at Facebook's +scale and in its culture, such that it seemed to justify the measures Javelin +takes to prevent accidents with style information having inadvertent or +unrealized semantic value. + += Metadata = + +Metadata is arbitrary pieces of data attached to nodes in the DOM. Metadata can +be (and generally is) specified on the server, when the document is constructed. +The value of metadata is that it allows handlers which use event delegation to +distinguish between events which occur on similar nodes. For instance, if you +have newsfeed with several "Like" links in it, your document might look like +this: + + lang=html + <div data-sigil="newsfeed"> + <div data-sigil="story"> + ... + <a href="..." data-sigil="like">Like</a> + </div> + <div data-sigil="story"> + ... + <a href="..." data-sigil="like">Like</a> + </div> + </div> + +You can install a listener using Javelin event delegation (see @{article: +Concepts: Event Delegation} for more information) like this: + + lang=js + JX.Stratcom.listen( + 'click', + ['newsfeed', 'story', 'like'], + function(e) { + // ... + }); + +This calls the function you provide when the user clicks on a "like" link, but +you need to be able to distinguish between the different links so you can know +which story the user is trying to like. Javelin allows you to do this by +attaching **metadata** to each node. Metadata is attached to a node by adding a +##data-meta## attribute which has an index into data later provided to +@{method:JX.Stratcom.mergeData}: + + lang=html + <div data-sigil="newsfeed"> + <div data-sigil="story" data-meta="0_0"> + ... + <a href="..." data-sigil="like">Like</a> + </div> + <div data-sigil="story" data-meta="0_1"> + ... + <a href="..." data-sigil="like">Like</a> + </div> + </div> + ... + <script type="text/javascript"> + JX.Stratcom.mergeData(0, [{"storyID" : 12345}, {"storyID" : 23456}]); + </script> + +This data can now be accessed with @{method:JX.Stratcom.getData}, or with +@{method:JX.Event.getNodeData} in an event handler: + + lang=js + JX.Stratcom.listen( + 'click', + ['newsfeed', 'story', 'like'], + function(e) { + var id = e.getNodeData('story').storyID; + // ... + }); + +You can also add data to a node programmatically in Javascript with +@{method:JX.Stratcom.addData}. Index: externals/javelin/src/docs/facebook.diviner =================================================================== --- /dev/null +++ externals/javelin/src/docs/facebook.diviner @@ -0,0 +1,82 @@ +@title Javelin at Facebook +@group facebook + +Information specific to Javelin at Facebook. + += Building Support Scripts = + +Javelin now ships with the source to build several libfbjs-based binaries, which +serve to completely sever its dependencies on trunk: + + - ##javelinsymbols##: used for lint + - ##jsast##: used for documentation generation + - ##jsxmin##: used to crush packages + +To build these, first build libfbjs: + + javelin/ $ cd externals/libfbjs + javelin/externals/libfbjs/ $ CXX=/usr/bin/g++ make + +Note that **you must specify CXX explicitly because the default CXX is broken**. + +Now you should be able to build the individual binaries: + + javelin/ $ cd support/javelinsymbols + javelin/support/javelinsymbols $ CXX=/usr/bin/g++ make + + javelin/ $ cd support/jsast + javelin/support/jsast $ CXX=/usr/bin/g++ make + + javelin/ $ cd support/jsxmin + javelin/support/jsxmin $ CXX=/usr/bin/g++ make + += Synchronizing Javelin = + +To synchronize Javelin **from** Facebook trunk, run the synchronize script: + + javelin/ $ ./scripts/sync-from-facebook.php ~/www + +...where ##~/www## is the root you want to pull Javelin files from. The script +will copy files out of ##html/js/javelin## and build packages, and leave the +results in your working copy. From there you can review changes and commit, and +then push, diff, or send a pull request. + +To synchronize Javelin **to** Facebook trunk, run the, uh, reverse-synchronize +script: + + javelin/ $ ./scripts/sync-to-facebook.php ~/www + +...where ##~/www## is the root you want to push Javelin files to. The script +will copy files out of the working copy into your ##www## and leave you with a +dirty ##www##. From there you can review changes. + +Once Facebook moves to pure git for ##www## we can probably just submodule +Javelin into it and get rid of all this nonsense, but the mixed SVN/git +environment makes that difficult until then. + += Building Documentation = + +Check out ##diviner## and ##libphutil## from Facebook github, and put them in a +directory with ##javelin##: + + somewhere/ $ ls + diviner/ + javelin/ + libphutil/ + somewhere/ $ + +Now run ##diviner## on ##javelin##: + + somewhere/ $ cd javelin + somewhere/javelin/ $ ../diviner/bin/diviner . + [DivinerArticleEngine] Generating documentation for 48 files... + [JavelinDivinerEngine] Generating documentation for 74 files... + somewhere/javelin/ $ + +Documentation is now available in ##javelin/docs/##. + += Editing javelinjs.com = + +The source for javelinjs.com lives in ##javelin/support/webroot/##. The site +itself is served off the phabricator.com host. You need access to that host to +push it. Index: externals/javelin/src/docs/onload.js =================================================================== --- /dev/null +++ externals/javelin/src/docs/onload.js @@ -0,0 +1,22 @@ +/** + * @javelin + */ + +/** + * Register a callback for invocation after DOMContentReady. + * + * NOTE: Although it isn't private, use of this function is heavily discouraged. + * See @{article:Concepts: Behaviors} for information on using behaviors to + * structure and invoke glue code. + * + * This function is defined as a side effect of init.js. + * + * @param function Callback function to invoke after DOMContentReady. + * @return void + * @group util + */ +JX.onload = function(callback) { + // This isn't the real function definition, it's only defined here to let the + // documentation generator find it. The actual definition is in init.js. +}; + Index: externals/javelin/src/ext/fx/Color.js =================================================================== --- /dev/null +++ externals/javelin/src/ext/fx/Color.js @@ -0,0 +1,33 @@ +/** + * @provides javelin-color + * @requires javelin-install + * @javelin + */ + +JX.install('Color', { + + statics: { + + rgbRegex: new RegExp('([\\d]{1,3})', 'g'), + + rgbToHex: function(str, as_array) { + var rgb = str.match(JX.Color.rgbRegex); + var hex = [0, 1, 2].map(function(index) { + return ('0' + (rgb[index] - 0).toString(16)).substr(-2, 2); + }); + return as_array ? hex : '#' + hex.join(''); + }, + + hexRegex: new RegExp('^[#]{0,1}([\\w]{1,2})([\\w]{1,2})([\\w]{1,2})$'), + + hexToRgb: function(str, as_array) { + var hex = str.match(JX.Color.hexRegex); + var rgb = hex.slice(1).map(function(bit) { + return parseInt(bit.length == 1 ? bit + bit : bit, 16); + }); + return as_array ? rgb : 'rgb(' + rgb + ')'; + } + + } + +}); Index: externals/javelin/src/ext/fx/FX.js =================================================================== --- /dev/null +++ externals/javelin/src/ext/fx/FX.js @@ -0,0 +1,214 @@ +/** + * @provides javelin-fx + * @requires javelin-color javelin-install javelin-util + * @javelin + * + * Based on moo.fx (moofx.mad4milk.net). + */ + +JX.install('FX', { + + events: ['start', 'complete'], + + construct: function(element) { + this._config = {}; + this.setElement(element); + this.setTransition(JX.FX.Transitions.sine); + }, + + properties: { + fps: 50, + wait: true, + duration: 500, + element: null, + property: null, + transition: null + }, + + members: { + _to: null, + _now: null, + _from: null, + _start: null, + _config: null, + _interval: null, + + start: function(config) { + if (__DEV__) { + if (!config) { + throw new Error('What styles do you want to animate?'); + } + if (!this.getElement()) { + throw new Error('What element do you want to animate?'); + } + } + if (this._interval && this.getWait()) { + return; + } + var from = {}; + var to = {}; + for (var prop in config) { + from[prop] = config[prop][0]; + to[prop] = config[prop][1]; + if (/color/i.test(prop)) { + from[prop] = JX.Color.hexToRgb(from[prop], true); + to[prop] = JX.Color.hexToRgb(to[prop], true); + } + } + this._animate(from, to); + return this; + }, + + stop: function() { + clearInterval(this._interval); + this._interval = null; + return this; + }, + + then: function(func) { + var token = this.listen('complete', function() { + token.remove(); + func(); + }); + return this; + }, + + _animate: function(from, to) { + if (!this.getWait()) { + this.stop(); + } + if (this._interval) { + return; + } + setTimeout(JX.bind(this, this.invoke, 'start'), 10); + this._from = from; + this._to = to; + this._start = JX.now(); + this._interval = setInterval( + JX.bind(this, this._tween), + Math.round(1000 / this.getFps())); + }, + + _tween: function() { + var now = JX.now(); + var prop; + if (now < this._start + this.getDuration()) { + this._now = now - this._start; + for (prop in this._from) { + this._config[prop] = this._compute(this._from[prop], this._to[prop]); + } + } else { + setTimeout(JX.bind(this, this.invoke, 'complete'), 10); + + // Compute the final position using the transition function, in case + // the function applies transformations. + this._now = this.getDuration(); + for (prop in this._from) { + this._config[prop] = this._compute(this._from[prop], this._to[prop]); + } + this.stop(); + } + this._render(); + }, + + _compute: function(from, to) { + if (JX.isArray(from)) { + return from.map(function(value, ii) { + return Math.round(this._compute(value, to[ii])); + }, this); + } + var delta = to - from; + return this.getTransition()(this._now, from, delta, this.getDuration()); + }, + + _render: function() { + var style = this.getElement().style; + for (var prop in this._config) { + var value = this._config[prop]; + if (prop == 'opacity') { + value = parseInt(100 * value, 10); + if (window.ActiveXObject) { + style.filter = 'alpha(opacity=' + value + ')'; + } else { + style.opacity = value / 100; + } + } else if (/color/i.test(prop)) { + style[prop] = 'rgb(' + value + ')'; + } else { + style[prop] = value + 'px'; + } + } + } + }, + + statics: { + fade: function(element, visible) { + return new JX.FX(element).setDuration(250).start({ + opacity: visible ? [0, 1] : [1, 0] + }); + }, + + highlight: function(element, color) { + color = color || '#fff8dd'; + return new JX.FX(element).setDuration(1000).start({ + backgroundColor: [color, '#fff'] + }); + }, + + /** + * Easing equations based on work by Robert Penner + * http://www.robertpenner.com/easing/ + */ + Transitions: { + linear: function(t, b, c, d) { + return c * t / d + b; + }, + + sine: function(t, b, c, d) { + return -c / 2 * (Math.cos(Math.PI * t / d) - 1) + b; + }, + + sineIn: function(t, b, c, d) { + if (t == d) { + return c + b; + } + return -c * Math.cos(t / d * (Math.PI / 2)) + c + b; + }, + + sineOut: function(t, b, c, d) { + if (t == d) { + return c + b; + } + return c * Math.sin(t / d * (Math.PI / 2)) + b; + }, + + elastic: function(t, b, c, d, a, p) { + if (t === 0) { return b; } + if ((t /= d) == 1) { return b + c; } + if (!p) { p = d * 0.3; } + if (!a) { a = 1; } + var s; + if (a < Math.abs(c)) { + a = c; + s = p / 4; + } else { + s = p / (2 * Math.PI) * Math.asin(c / a); + } + return a * Math.pow(2, -10 * t) * + Math.sin((t * d - s) * (2 * Math.PI) / p) + c + b; + }, + + bounce: function(t, b, c, d) { + if ((t /= d) < (1 / 2.75)) { + return c * (7.5625 * t * t) + b; + } else if (t < (2 / 2.75)) { + return c * (7.5625 * (t -= (1.5 / 2.75)) * t + 0.75) + b; + } else if (t < (2.5 / 2.75)) { + return c * (7.5625 * (t -= (2.25 / 2.75)) * t + 0.9375) + b; + } else { + return c * (7.5625 * (t -= (2.625 / 2.75)) * t + 0.984375) + b; + } + } + } + } +}); Index: externals/javelin/src/ext/reactor/core/DynVal.js =================================================================== --- /dev/null +++ externals/javelin/src/ext/reactor/core/DynVal.js @@ -0,0 +1,48 @@ +/** + * @provides javelin-dynval + * @requires javelin-install + * javelin-reactornode + * javelin-util + * javelin-reactor + * @javelin + */ + +JX.install('DynVal', { + members : { + _lastPulseVal : null, + _reactorNode : null, + getValueNow : function() { + return this._lastPulseVal; + }, + getChanges : function() { + return this._reactorNode; + }, + forceValueNow : function(value) { + this.getChanges().forceSendValue(value); + }, + transform : function(fn) { + return new JX.DynVal( + this.getChanges().transform(fn), + fn(this.getValueNow()) + ); + }, + calm : function(min_interval) { + return new JX.DynVal( + this.getChanges().calm(min_interval), + this.getValueNow() + ); + } + }, + construct : function(stream, init) { + this._lastPulseVal = init; + this._reactorNode = + new JX.ReactorNode([stream], JX.bind(this, function(pulse) { + if (this._lastPulseVal == pulse) { + return JX.Reactor.DoNotPropagate; + } + this._lastPulseVal = pulse; + return pulse; + })); + } +}); + Index: externals/javelin/src/ext/reactor/core/Reactor.js =================================================================== --- /dev/null +++ externals/javelin/src/ext/reactor/core/Reactor.js @@ -0,0 +1,90 @@ +/** + * @provides javelin-reactor + * @requires javelin-install + * javelin-util + * @javelin + */ + +JX.install('Reactor', { + statics : { + /** + * Return this value from a ReactorNode transformer to indicate that + * its listeners should not be activated. + */ + DoNotPropagate : {}, + /** + * For internal use by the Reactor system. + */ + propagatePulse : function(start_pulse, start_node) { + var reverse_post_order = + JX.Reactor._postOrder(start_node).reverse(); + start_node.primeValue(start_pulse); + + for (var ix = 0; ix < reverse_post_order.length; ix++) { + var node = reverse_post_order[ix]; + var pulse = node.getNextPulse(); + if (pulse === JX.Reactor.DoNotPropagate) { + continue; + } + + var next_pulse = node.getTransformer()(pulse); + var sends_to = node.getListeners(); + for (var jx = 0; jx < sends_to.length; jx++) { + sends_to[jx].primeValue(next_pulse); + } + } + }, + /** + * For internal use by the Reactor system. + */ + _postOrder : function(node, result, pending) { + if (typeof result === "undefined") { + result = []; + pending = {}; + } + pending[node.getGraphID()] = true; + + var nexts = node.getListeners(); + for (var ix = 0; ix < nexts.length; ix++) { + var next = nexts[ix]; + if (pending[next.getGraphID()]) { + continue; + } + JX.Reactor._postOrder(next, result, pending); + } + + result.push(node); + return result; + }, + + // Helper for lift. + _valueNow : function(fn, dynvals) { + var values = []; + for (var ix = 0; ix < dynvals.length; ix++) { + values.push(dynvals[ix].getValueNow()); + } + return fn.apply(null, values); + }, + + /** + * Lift a function over normal values to be a function over dynvals. + * @param fn A function expecting normal values + * @param dynvals Array of DynVals whose instaneous values will be passed + * to fn. + * @return A DynVal representing the changing value of fn applies to dynvals + * over time. + */ + lift : function(fn, dynvals) { + var valueNow = JX.bind(null, JX.Reactor._valueNow, fn, dynvals); + + var streams = []; + for (var ix = 0; ix < dynvals.length; ix++) { + streams.push(dynvals[ix].getChanges()); + } + + var result = new JX['ReactorNode'](streams, valueNow); + return new JX['DynVal'](result, valueNow()); + } + } +}); + Index: externals/javelin/src/ext/reactor/core/ReactorNode.js =================================================================== --- /dev/null +++ externals/javelin/src/ext/reactor/core/ReactorNode.js @@ -0,0 +1,97 @@ +/** + * @provides javelin-reactornode + * @requires javelin-install + * javelin-reactor + * javelin-util + * javelin-reactor-node-calmer + * @javelin + */ + +JX.install('ReactorNode', { + members : { + _transformer : null, + _sendsTo : null, + _nextPulse : null, + _graphID : null, + + getGraphID : function() { + return this._graphID || this.__id__; + }, + + setGraphID : function(id) { + this._graphID = id; + return this; + }, + + setTransformer : function(fn) { + this._transformer = fn; + return this; + }, + + /** + * Set up dest as a listener to this. + */ + listen : function(dest) { + this._sendsTo[dest.__id__] = dest; + return { remove : JX.bind(null, this._removeListener, dest) }; + }, + /** + * Helper for listen. + */ + _removeListener : function(dest) { + delete this._sendsTo[dest.__id__]; + }, + /** + * For internal use by the Reactor system + */ + primeValue : function(value) { + this._nextPulse = value; + }, + getListeners : function() { + var result = []; + for (var k in this._sendsTo) { + result.push(this._sendsTo[k]); + } + return result; + }, + /** + * For internal use by the Reactor system + */ + getNextPulse : function(pulse) { + return this._nextPulse; + }, + getTransformer : function() { + return this._transformer; + }, + forceSendValue : function(pulse) { + JX.Reactor.propagatePulse(pulse, this); + }, + // fn should return JX.Reactor.DoNotPropagate to indicate a value that + // should not be retransmitted. + transform : function(fn) { + return new JX.ReactorNode([this], fn); + }, + + /** + * Suppress events to happen at most once per min_interval. + * The last event that fires within an interval will fire at the end + * of the interval. Events that are sandwiched between other events + * within an interval are dropped. + */ + calm : function(min_interval) { + var result = new JX.ReactorNode([this], JX.id); + var transformer = new JX.ReactorNodeCalmer(result, min_interval); + result.setTransformer(JX.bind(transformer, transformer.onPulse)); + return result; + } + }, + construct : function(source_streams, transformer) { + this._nextPulse = JX.Reactor.DoNotPropagate; + this._transformer = transformer; + this._sendsTo = {}; + for (var ix = 0; ix < source_streams.length; ix++) { + source_streams[ix].listen(this); + } + } +}); + Index: externals/javelin/src/ext/reactor/core/ReactorNodeCalmer.js =================================================================== --- /dev/null +++ externals/javelin/src/ext/reactor/core/ReactorNodeCalmer.js @@ -0,0 +1,48 @@ +/** + * @provides javelin-reactor-node-calmer + * @requires javelin-install + * javelin-reactor + * javelin-util + * @javelin + */ + +JX.install('ReactorNodeCalmer', { + properties : { + lastTime : 0, + timeout : null, + minInterval : 0, + reactorNode : null, + isEnabled : true + }, + construct : function(node, min_interval) { + this.setLastTime(-min_interval); + this.setMinInterval(min_interval); + this.setReactorNode(node); + }, + members: { + onPulse : function(pulse) { + if (!this.getIsEnabled()) { + return pulse; + } + var current_time = JX.now(); + if (current_time - this.getLastTime() > this.getMinInterval()) { + this.setLastTime(current_time); + return pulse; + } else { + clearTimeout(this.getTimeout()); + this.setTimeout(setTimeout( + JX.bind(this, this.send, pulse), + this.getLastTime() + this.getMinInterval() - current_time + )); + return JX.Reactor.DoNotPropagate; + } + }, + send : function(pulse) { + this.setLastTime(JX.now()); + this.setIsEnabled(false); + this.getReactorNode().forceSendValue(pulse); + this.setIsEnabled(true); + } + } +}); + Index: externals/javelin/src/ext/reactor/dom/RDOM.js =================================================================== --- /dev/null +++ externals/javelin/src/ext/reactor/dom/RDOM.js @@ -0,0 +1,406 @@ +/** + * Javelin Reactive functions to work with the DOM. + * @provides javelin-reactor-dom + * @requires javelin-dom + * javelin-dynval + * javelin-reactornode + * javelin-install + * javelin-util + * @javelin + */ +JX.install('RDOM', { + statics : { + _time : null, + /** + * DynVal of the current time in milliseconds. + */ + time : function() { + if (JX.RDOM._time === null) { + var time = new JX.ReactorNode([], JX.id); + window.setInterval(function() { + time.forceSendValue(JX.now()); + }, 100); + JX.RDOM._time = new JX.DynVal(time, JX.now()); + } + return JX.RDOM._time; + }, + + /** + * Given a DynVal[String], return a DOM text node whose value tracks it. + */ + $DT : function(dyn_string) { + var node = document.createTextNode(dyn_string.getValueNow()); + dyn_string.transform(function(s) { node.data = s; }); + return node; + }, + + _recvEventPulses : function(node, event) { + var reactor_node = new JX.ReactorNode([], JX.id); + var no_path = null; + JX.DOM.listen( + node, + event, + no_path, + JX.bind(reactor_node, reactor_node.forceSendValue) + ); + + reactor_node.setGraphID(JX.DOM.uniqID(node)); + return reactor_node; + }, + + _recvChangePulses : function(node) { + return JX.RDOM._recvEventPulses(node, 'change').transform(function() { + return node.value; + }); + }, + + + /** + * Sets up a bidirectional DynVal for a node. + * @param node :: DOM Node + * @param inPulsesFn :: DOM Node -> ReactorNode + * @param inDynValFn :: DOM Node -> ReactorNode -> DynVal + * @param outFn :: ReactorNode -> DOM Node + */ + _bidi : function(node, inPulsesFn, inDynValFn, outFn) { + var inPulses = inPulsesFn(node); + var inDynVal = inDynValFn(node, inPulses); + outFn(inDynVal.getChanges(), node); + inDynVal.getChanges().listen(inPulses); + return inDynVal; + }, + + /** + * ReactorNode[String] of the incoming values of a radio group. + * @param Array of DOM elements, all the radio buttons in a group. + */ + _recvRadioPulses : function(buttons) { + var ins = []; + for (var ii = 0; ii < buttons.length; ii++) { + ins.push(JX.RDOM._recvChangePulses(buttons[ii])); + } + return new JX.ReactorNode(ins, JX.id); + }, + + /** + * DynVal[String] of the incoming values of a radio group. + * pulses is a ReactorNode[String] of the incoming values of the group + */ + _recvRadio : function(buttons, pulses) { + var init = ''; + for (var ii = 0; ii < buttons.length; ii++) { + if (buttons[ii].checked) { + init = buttons[ii].value; + break; + } + } + + return new JX.DynVal(pulses, init); + }, + + /** + * Send the pulses from the ReactorNode[String] to the radio group. + * Sending an invalid value will result in a log message in __DEV__. + */ + _sendRadioPulses : function(rnode, buttons) { + return rnode.transform(function(val) { + var found; + if (__DEV__) { + found = false; + } + + for (var ii = 0; ii < buttons.length; ii++) { + if (buttons[ii].value == val) { + buttons[ii].checked = true; + if (__DEV__) { + found = true; + } + } + } + + if (__DEV__) { + if (!found) { + throw new Error("Mismatched radio button value"); + } + } + }); + }, + + /** + * Bidirectional DynVal[String] for a radio group. + * Sending an invalid value will result in a log message in __DEV__. + */ + radio : function(input) { + return JX.RDOM._bidi( + input, + JX.RDOM._recvRadioPulses, + JX.RDOM._recvRadio, + JX.RDOM._sendRadioPulses + ); + }, + + /** + * ReactorNode[Boolean] of the values of the checkbox when it changes. + */ + _recvCheckboxPulses : function(checkbox) { + return JX.RDOM._recvChangePulses(checkbox).transform(function(val) { + return Boolean(val); + }); + }, + + /** + * DynVal[Boolean] of the value of a checkbox. + */ + _recvCheckbox : function(checkbox, pulses) { + return new JX.DynVal(pulses, Boolean(checkbox.checked)); + }, + + /** + * Send the pulses from the ReactorNode[Boolean] to the checkbox + */ + _sendCheckboxPulses : function(rnode, checkbox) { + return rnode.transform(function(val) { + if (__DEV__) { + if (!(val === true || val === false)) { + throw new Error("Send boolean values to checkboxes."); + } + } + + checkbox.checked = val; + }); + }, + + /** + * Bidirectional DynVal[Boolean] for a checkbox. + */ + checkbox : function(input) { + return JX.RDOM._bidi( + input, + JX.RDOM._recvCheckboxPulses, + JX.RDOM._recvCheckbox, + JX.RDOM._sendCheckboxPulses + ); + }, + + /** + * ReactorNode[String] of the changing values of a text input. + */ + _recvInputPulses : function(input) { + // This misses advanced changes like paste events. + var live_changes = [ + JX.RDOM._recvChangePulses(input), + JX.RDOM._recvEventPulses(input, 'keyup'), + JX.RDOM._recvEventPulses(input, 'keypress'), + JX.RDOM._recvEventPulses(input, 'keydown') + ]; + + return new JX.ReactorNode(live_changes, function() { + return input.value; + }); + }, + + /** + * DynVal[String] of the value of a text input. + */ + _recvInput : function(input, pulses) { + return new JX.DynVal(pulses, input.value); + }, + + /** + * Send the pulses from the ReactorNode[String] to the input + */ + _sendInputPulses : function(rnode, input) { + var result = rnode.transform(function(val) { + input.value = val; + }); + result.setGraphID(JX.DOM.uniqID(input)); + return result; + }, + + + /** + * Bidirectional DynVal[String] for a text input. + */ + input : function(input) { + return JX.RDOM._bidi( + input, + JX.RDOM._recvInputPulses, + JX.RDOM._recvInput, + JX.RDOM._sendInputPulses + ); + }, + + /** + * ReactorNode[String] of the incoming changes in value of a select element. + */ + _recvSelectPulses : function(select) { + return JX.RDOM._recvChangePulses(select); + }, + + /** + * DynVal[String] of the value of a select element. + */ + _recvSelect : function(select, pulses) { + return new JX.DynVal(pulses, select.value); + }, + + /** + * Send the pulses from the ReactorNode[String] to the select. + * Sending an invalid value will result in a log message in __DEV__. + */ + _sendSelectPulses : function(rnode, select) { + return rnode.transform(function(val) { + select.value = val; + + if (__DEV__) { + if (select.value !== val) { + throw new Error("Mismatched select value"); + } + } + }); + }, + + /** + * Bidirectional DynVal[String] for the value of a select. + */ + select : function(select) { + return JX.RDOM._bidi( + select, + JX.RDOM._recvSelectPulses, + JX.RDOM._recvSelect, + JX.RDOM._sendSelectPulses + ); + }, + + /** + * ReactorNode[undefined] that fires when a button is clicked. + */ + clickPulses : function(button) { + return JX.RDOM._recvEventPulses(button, 'click').transform(function() { + return null; + }); + }, + + /** + * ReactorNode[Boolean] of whether the mouse is over a target. + */ + _recvIsMouseOverPulses : function(target) { + var mouseovers = JX.RDOM._recvEventPulses(target, 'mouseover').transform( + function() { + return true; + }); + var mouseouts = JX.RDOM._recvEventPulses(target, 'mouseout').transform( + function() { + return false; + }); + + return new JX.ReactorNode([mouseovers, mouseouts], JX.id); + }, + + /** + * DynVal[Boolean] of whether the mouse is over a target. + */ + isMouseOver : function(target) { + // Not worth it to initialize this properly. + return new JX.DynVal(JX.RDOM._recvIsMouseOverPulses(target), false); + }, + + /** + * ReactorNode[Boolean] of whether an element has the focus. + */ + _recvHasFocusPulses : function(target) { + var focuses = JX.RDOM._recvEventPulses(target, 'focus').transform( + function() { + return true; + }); + var blurs = JX.RDOM._recvEventPulses(target, 'blur').transform( + function() { + return false; + }); + + return new JX.ReactorNode([focuses, blurs], JX.id); + }, + + /** + * DynVal[Boolean] of whether an element has the focus. + */ + _recvHasFocus : function(target) { + var is_focused_now = (target === document.activeElement); + return new JX.DynVal(JX.RDOM._recvHasFocusPulses(target), is_focused_now); + }, + + _sendHasFocusPulses : function(rnode, target) { + rnode.transform(function(should_focus) { + if (should_focus) { + target.focus(); + } else { + target.blur(); + } + return should_focus; + }); + }, + + /** + * Bidirectional DynVal[Boolean] of whether an element has the focus. + */ + hasFocus : function(target) { + return JX.RDOM._bidi( + target, + JX.RDOM._recvHasFocusPulses, + JX.RDOM._recvHasFocus, + JX.RDOM._sendHasFocusPulses + ); + }, + + /** + * Send a CSS class from a DynVal to a node + */ + sendClass : function(dynval, node, className) { + return dynval.transform(function(add) { + JX.DOM.alterClass(node, className, add); + }); + }, + + /** + * Dynamically attach a set of DynVals to a DOM node's properties as + * specified by props. + * props: {left: someDynVal, style: {backgroundColor: someOtherDynVal}} + */ + sendProps : function(node, props) { + var dynvals = []; + var keys = []; + var style_keys = []; + for (var key in props) { + keys.push(key); + if (key === 'style') { + for (var style_key in props[key]) { + style_keys.push(style_key); + dynvals.push(props[key][style_key]); + node.style[style_key] = props[key][style_key].getValueNow(); + } + } else { + dynvals.push(props[key]); + node[key] = props[key].getValueNow(); + } + } + + return JX.Reactor.lift(JX.bind(null, function(keys, style_keys, node) { + var args = JX.$A(arguments).slice(3); + + for (var ii = 0; ii < args.length; ii++) { + if (keys[ii] === 'style') { + for (var jj = 0; jj < style_keys.length; jj++) { + node.style[style_keys[jj]] = args[ii]; + ii++; + } + ii--; + } else { + node[keys[ii]] = args[ii]; + } + } + }, keys, style_keys, node), dynvals); + } + } +}); + + Index: externals/javelin/src/ext/view/HTMLView.js =================================================================== --- /dev/null +++ externals/javelin/src/ext/view/HTMLView.js @@ -0,0 +1,136 @@ +/** + * Dumb HTML views. Mostly to demonstrate how the visitor pattern over these + * views works, as driven by validation. I'm not convinced it's actually a good + * idea to do validation. + * + * @provides javelin-view-html + * @requires javelin-install + * javelin-view + */ + +JX.install('HTMLView', { + extend: 'View', + members : { + render: function(rendered_children) { + return JX.$N(this.getName(), this.getAllAttributes(), rendered_children); + }, + validate: function() { + this.accept(JX.HTMLView.getValidatingVisitor()); + } + }, + + statics: { + getValidatingVisitor: function() { + return new JX.ViewVisitor(JX.HTMLView.validate); + }, + + validate: function(view, children) { + var spec = this._getHTMLSpec(); + if (!view.getName() in spec) { + throw new Error("invalid tag"); + } + + var tag_spec = spec[view.getName()]; + + var attrs = view.getAllAttributes(); + for (var attr in attrs) { + if (!(attr in tag_spec)) { + throw new Error("invalid attr"); + } + + var validator = tag_spec[attr]; + if (typeof validator === "function") { + return validator(attrs[attr]); + } + } + + return true; + }, + + _validateRel: function(target) { + return target in { + "_blank": 1, + "_self": 1, + "_parent": 1, + "_top": 1 + }; + }, + _getHTMLSpec: function() { + var attrs_any_can_have = { + className: 1, + id: 1, + sigil: 1 + }; + + var form_elem_attrs = { + name: 1, + value: 1 + }; + + var spec = { + a: { href: 1, target: JX.HTMLView._validateRel }, + b: {}, + blockquote: {}, + br: {}, + button: JX.copy({}, form_elem_attrs), + canvas: {}, + code: {}, + dd: {}, + div: {}, + dl: {}, + dt: {}, + em: {}, + embed: {}, + fieldset: {}, + form: { type: 1 }, + h1: {}, + h2: {}, + h3: {}, + h4: {}, + h5: {}, + h6: {}, + hr: {}, + i: {}, + iframe: { src: 1 }, + img: { src: 1, alt: 1 }, + input: JX.copy({}, form_elem_attrs), + label: {'for': 1}, + li: {}, + ol: {}, + optgroup: {}, + option: JX.copy({}, form_elem_attrs), + p: {}, + pre: {}, + q: {}, + select: {}, + span: {}, + strong: {}, + sub: {}, + sup: {}, + table: {}, + tbody: {}, + td: {}, + textarea: {}, + tfoot: {}, + th: {}, + thead: {}, + tr: {}, + ul: {} + }; + + for (var k in spec) { + JX.copy(spec[k], attrs_any_can_have); + } + + return spec; + }, + registerToInterpreter: function(view_interpreter) { + var spec = this._getHTMLSpec(); + for (var tag in spec) { + view_interpreter.register(tag, JX.HTMLView); + } + return view_interpreter; + } + } +}); + Index: externals/javelin/src/ext/view/View.js =================================================================== --- /dev/null +++ externals/javelin/src/ext/view/View.js @@ -0,0 +1,189 @@ +/** + * A View is a composable wrapper on JX.$N, allowing abstraction of higher-order + * views and a consistent pattern of parameterization. It is intended + * to be used either directly or as a building block for a syntactic sugar layer + * for concise expression of markup patterns. + * + * @provides javelin-view + * @requires javelin-install + * javelin-util + */ +JX.install('View', { + construct : function(attrs, children) { + this._attributes = JX.copy({}, this.getDefaultAttributeValues()); + JX.copy(this._attributes, attrs); + + this._rawChildren = {}; + this._childKeys = []; + + if (children) { + this.addChildren(JX.$AX(children)); + } + + this.setName(this.__class__.__readable__); + }, + events: [ + 'change' + ], + + properties: { + 'name': null + }, + + members : { + _attributes : null, + _rawChildren : null, + _childKeys: null, // keys of rawChildren, kept ordered. + _nextChildKey: 0, // next key to use for a new child + + /* + * Don't override. + * TODO: Strongly typed attribute access (getIntAttr, getStringAttr...)? + */ + getAttr : function(attrName) { + return this._attributes[attrName]; + }, + + /* + * Don't override. + */ + multisetAttr : function(attrs) { + JX.copy(this._attributes, attrs); + this.invoke('change'); + return this; + }, + + /* + * Don't override. + */ + setAttr : function(attrName, value) { + this._attributes[attrName] = value; + this.invoke('change'); + return this; + }, + /* + * Child views can override to specify default values for attributes. + */ + getDefaultAttributeValues : function() { + return {}; + }, + + /** + * Don't override. + */ + getAllAttributes: function() { + return JX.copy({}, this._attributes); + }, + + /** + * Get the children. Don't override. + */ + getChildren : function() { + var result = []; + var should_repack = false; + + for(var ii = 0; ii < this._childKeys.length; ii++) { + var key = this._childKeys[ii]; + if (this._rawChildren[key] === undefined) { + should_repack = true; + } else { + result.push(this._rawChildren[key]); + } + } + + if (should_repack) { + var new_child_keys = []; + for(var ii = 0; ii < this._childKeys.length; ii++) { + var key = this._childKeys[ii]; + if (this._rawChildren[key] !== undefined) { + new_child_keys.push(key); + } + } + + this._childKeys = new_child_keys; + } + + return result; + }, + + /** + * Add children to the view. Returns array of removal handles. + * Don't override. + */ + addChildren : function(children) { + var result = []; + for (var ii = 0; ii < children.length; ii++) { + result.push(this._addChild(children[ii])); + } + this.invoke('change'); + return result; + }, + + /** + * Add a single child view to the view. + * Returns a removal handle, i.e. an object that has a method remove(), + * that removes the added child from the view. + * + * Don't override. + */ + addChild: function(child) { + var result = this._addChild(child); + this.invoke('change'); + return result; + }, + + _addChild: function(child) { + var key = this._nextChildKey++; + this._rawChildren[key] = child; + this._childKeys.push(key); + + return { + remove: JX.bind(this, this._removeChild, key) + }; + }, + + _removeChild: function(child_key) { + delete this._rawChildren[child_key]; + this.invoke('change'); + }, + + /** + * Accept visitors. This allows adding new behaviors to Views without + * having to change View classes themselves. + * + * This implements a post-order traversal over the tree of views. Children + * are processed before parents, and for convenience the results of the + * visitor on the children are passed to it when processing the parent. + * + * The visitor parameter is a callable which receives two parameters. + * The first parameter is the view to visit. The second parameter is an + * array of the results of visiting the view's children. + * + * Don't override. + */ + accept: function(visitor) { + var results = []; + var children = this.getChildren(); + for(var ii = 0; ii < children.length; ii++) { + var result; + if (children[ii].accept) { + result = children[ii].accept(visitor); + } else { + result = children[ii]; + } + results.push(result); + } + return visitor(this, results); + }, + + /** + * Given the already-rendered children, return the rendered result of + * this view. + * By default, just pass the children through. + */ + render: function(rendered_children) { + return rendered_children; + } + } +}); + Index: externals/javelin/src/ext/view/ViewInterpreter.js =================================================================== --- /dev/null +++ externals/javelin/src/ext/view/ViewInterpreter.js @@ -0,0 +1,71 @@ +/** + * Experimental interpreter for nice views. + * This is CoffeeScript: + * + * d = declare + * selectable: false + * boxOrientation: Orientation.HORIZONTAL + * additionalClasses: ['some-css-class'] + * MultiAvatar ref: 'avatars' + * div + * flex: 1 + * div( + * span className: 'some-css-class', ref: 'actorTargetLine' + * span className: 'message-css', ref: 'message' + * ) + * + * div + * boxOrientation: Orientation.HORIZONTAL + * className: 'attachment-css-class' + * div + * className: 'attachment-image-css-class' + * ref: 'attachmentImageContainer' + * boxOrientation: Orientation.HORIZONTAL + * div className: 'inline attachment-text', ref: 'attachmentText', + * div + * className: 'attachment-title' + * ref: 'attachmentTitle' + * flex: 1 + * div + * className: 'attachment-subtitle' + * ref: 'attachmentSubtitle' + * flex: 1 + * div className: 'clear' + * MiniUfi ref: 'miniUfi' + * FeedbackFlyout ref: 'feedbackFlyout' + * + * It renders to nested function calls of the form: + * view({....options...}, child1, child2, ...); + * + * This view interpreter is meant to make it work. + * + * @provides javelin-view-interpreter + * @requires javelin-view + * javelin-install + * + */ + +JX.install('ViewInterpreter', { + members : { + register : function(name, view_cls) { + this[name] = function(/* [properties, ]children... */) { + var properties = arguments[0] || {}; + var children = Array.prototype.slice.call(arguments, 1); + + // Passing properties is optional + if (properties instanceof JX.View || + properties instanceof JX.HTML || + properties.nodeType || + typeof properties === "string") { + children.unshift(properties); + properties = {}; + } + + var result = new view_cls(properties).setName(name); + result.addChildren(children); + + return result; + } + } + } +}); Index: externals/javelin/src/ext/view/ViewPlaceholder.js =================================================================== --- /dev/null +++ externals/javelin/src/ext/view/ViewPlaceholder.js @@ -0,0 +1,103 @@ +/** + * Initialize a client-side view from the server. The main idea here is to + * give server-side views a way to integrate with client-side views. + * + * The idea is that a client-side view will have an accompanying + * thin server-side component. The server-side component renders a placeholder + * element in the document, and then it will invoke this behavior to initialize + * the view into the placeholder. + * + * Because server-side views may be nested, we need to add complexity to + * handle nesting properly. + * + * Assuming a server-side view design that looks like hierarchical views, + * we have to handle structures like + * + * <server:component> + * <client:component id="1"> + * <server:component> + * <client:component id="2"> + * </client:component> + * </server:component> + * </client:component> + * </server:component> + * + * This leads to a problem: Client component 1 needs to initialize the behavior + * with its children, which includes client component 2. So client component + * 2 must be rendered first. When client component 2 is rendered, it will also + * initialize a copy of this behavior. If behaviors are run in the order they + * are initialized, the child component will run before the parent, and its + * placeholder won't exist yet. + * + * To solve this problem, placeholder behaviors are initialized with the token + * of a containing view that must be rendered first (if any) and a token + * representing it for its own children to depend on. This means the server code + * is free to call initBehavior in any order. + * + * In Phabricator, AphrontJavelinView demonstrates how to handle this correctly. + * + * config: { + * id: Node id to replace. + * view: class of view, without the 'JX.' prefix. + * params: view parameters + * children: messy and loud, cute when drunk + * trigger_id: id of containing view that must be rendered first + * } + * + * @provides javelin-behavior-view-placeholder + * @requires javelin-behavior + * javelin-dom + * javelin-view-renderer + */ + + + +JX.behavior('view-placeholder', function(config, statics) { + JX.ViewPlaceholder.register(config.trigger_id, config.id, function() { + var replace = JX.$(config.id); + + var children = config.children; + if (typeof children === "string") { + children = JX.$H(children); + } + + var view = new JX[config.view](config.params, children); + var rendered = JX.ViewRenderer.render(view); + + JX.DOM.replace(replace, rendered); + }); +}); + +JX.install('ViewPlaceholder', { + statics: { + register: function(wait_on_token, token, cb) { + var ready_q = []; + + if (!wait_on_token || wait_on_token in JX.ViewPlaceholder.ready) { + ready_q.push({token: token, cb: cb}); + } else { + var waiting = JX.ViewPlaceholder.waiting; + waiting[wait_on_token] = waiting[wait_on_token] || []; + waiting[wait_on_token].push({token: token, cb: cb}); + } + + while(ready_q.length) { + var ready = ready_q.shift(); + + var waiting = JX.ViewPlaceholder.waiting[ready.token]; + if (waiting) { + for (var ii = 0; ii < waiting.length; ii++) { + ready_q.push(waiting[ii]); + } + delete JX.ViewPlaceholder.waiting[ready.token]; + } + ready.cb(); + + JX.ViewPlaceholder.ready[token] = true; + } + + }, + ready: {}, + waiting: {} + } +}); Index: externals/javelin/src/ext/view/ViewRenderer.js =================================================================== --- /dev/null +++ externals/javelin/src/ext/view/ViewRenderer.js @@ -0,0 +1,19 @@ +/** + * @provides javelin-view-renderer + * @requires javelin-install + */ + +JX.install('ViewRenderer', { + members: { + visit: function(view, children) { + return view.render(children); + } + }, + statics: { + render: function(view) { + var renderer = new JX.ViewRenderer(); + return view.accept(JX.bind(renderer, renderer.visit)); + } + } +}); + Index: externals/javelin/src/ext/view/ViewVisitor.js =================================================================== --- /dev/null +++ externals/javelin/src/ext/view/ViewVisitor.js @@ -0,0 +1,36 @@ +/** + * @provides javelin-view-visitor + * @requires javelin-install + * javelin-util + * + * Add new behaviors to views without changing the view classes themselves. + * + * Allows you to register specific visitor functions for certain view classes. + * If no visitor is registered for a view class, the default_visitor is used. + * If no default_visitor is invoked, a no-op visitor is used. + * + * Registered visitors should be functions with signature + * function(view, results_of_visiting_children) {} + * Children are visited before their containing parents, and the return values + * of the visitor on the children are passed to the parent. + * + */ + +JX.install('ViewVisitor', { + construct: function(default_visitor) { + this._visitors = {}; + this._default = default_visitor || JX.bag; + }, + members: { + _visitors: null, + _default: null, + register: function(cls, visitor) { + this._visitors[cls] = visitor; + }, + visit: function(view, children) { + var visitor = this._visitors[cls] || this._default; + return visitor(view, children); + } + } +}); + Index: externals/javelin/src/ext/view/__tests__/HTMLView.js =================================================================== --- /dev/null +++ externals/javelin/src/ext/view/__tests__/HTMLView.js @@ -0,0 +1,25 @@ +/** + * @requires javelin-view-html + * javelin-view-interpreter + */ + + +describe('JX.HTMLView', function() { + var html = new JX.ViewInterpreter(); + + JX.HTMLView.registerToInterpreter(html); + + it('should fail validation for a little view', function() { + var little_view = + html.div({className: 'pretty'}, + html.p({}, + html.span({sigil: 'hook', invalid: 'foo'}, + 'Check out ', + html.a({href: 'https://fb.com/', target: '_blank' }, 'Facebook')))); + + + expect(function() { + little_view.validate(); + }).toThrow(); + }); +}); Index: externals/javelin/src/ext/view/__tests__/View.js =================================================================== --- /dev/null +++ externals/javelin/src/ext/view/__tests__/View.js @@ -0,0 +1,61 @@ +/** + * @requires javelin-view + * javelin-util + */ + +describe('JX.View', function() { + JX.install('TestView', { + extend : 'View', + construct : function(name, attrs, children) { + JX.View.call(this, attrs, children); + this.setName(name); + }, + + members : { + getDefaultAttributeValues : function() { + return {id: 'test'}; + }, + render : function(rendered_children) { + return JX.$N( + 'span', + {id : this.getAttr('id')}, + [this.getName()].concat(rendered_children) + ); + } + } + }); + + it('should by default render children that are passed in', function() { + var t = new JX.TestView( + '', + {}, + [new JX.TestView('Hey', {id: "child"}, [])] + ); + var result = JX.ViewRenderer.render(t); + expect(JX.DOM.scry(result, 'span').length).toBe(1); + }); + + it('should fail sanely with a bad getAttr call', function() { + expect(new JX.TestView('', {}, []).getAttr('foo')).toBeUndefined(); + }); + + it('should allow attribute setting with multiset', function() { + var test_val = 'something else'; + expect(new JX.TestView('', {}, []).multisetAttr({ + id: 'some_id', + other: test_val + }).getAttr('other')).toBe(test_val); + }); + + it('should allow attribute setting with setAttr', function() { + var test_val = 'something else'; + expect(new JX.TestView('', {}, []) + .setAttr('other', test_val) + .getAttr('other')).toBe(test_val); + }); + + it('should set default attributes per getDefaultAttributeValues', function() { + // Also the test for getAttr + expect(new JX.TestView('', {}, []).getAttr('id')).toBe('test'); + }); +}); Index: externals/javelin/src/ext/view/__tests__/ViewInterpreter.js =================================================================== --- /dev/null +++ externals/javelin/src/ext/view/__tests__/ViewInterpreter.js @@ -0,0 +1,47 @@ +/** + * @requires javelin-view + * javelin-view-interpreter + * javelin-view-html + * javelin-util + */ + +describe('JX.ViewInterpreter', function() { + var html = new JX.ViewInterpreter(); + + JX.HTMLView.registerToInterpreter(html); + + it('should allow purty syntax to make a view', function() { + var little_view = + html.div({}, + html.p({className: 'pretty'}, + html.span({sigil: 'hook'}, + 'Check out ', + html.a({href: 'https://fb.com/', rel: '_blank' }, 'Facebook')))); + + var rendered = JX.ViewRenderer.render(little_view); + + expect(rendered.tagName).toBe('DIV'); + expect(JX.DOM.scry(rendered, 'span', 'hook').length).toBe(1); + }); + + it('should handle no-attr case', function() { + /* Coffeescript: + * div( + * span className: 'some-css-class', ref: 'actorTargetLine' + * span className: 'message-css', ref: 'message' + * ) + * + * = javascript: + * div(span({ + * className: 'some-css-class', + * ref: 'actorTargetLine' + * }), span({ + * className: 'message-css', + * ref: 'message' + * })); + */ + var little_view = html.div(html.span({sigil: 'hook'})); + var rendered = JX.ViewRenderer.render(little_view); + expect(JX.DOM.scry(rendered, 'span', 'hook').length).toBe(1); + }); +}); Index: externals/javelin/src/ext/view/__tests__/ViewRenderer.js =================================================================== --- /dev/null +++ externals/javelin/src/ext/view/__tests__/ViewRenderer.js @@ -0,0 +1,25 @@ +/** + * @requires javelin-view-renderer + * javelin-view + */ + +describe('JX.ViewRenderer', function() { + it('should render children then parent', function() { + var child_rendered = false; + var child_rendered_first = false; + + var child = new JX.View({}); + var parent = new JX.View({}); + parent.addChild(child); + child.render = function(_) { + child_rendered = true; + } + + parent.render = function(rendered_children) { + child_rendered_first = child_rendered; + } + + JX.ViewRenderer.render(parent); + expect(child_rendered_first).toBe(true); + }); +}); Index: externals/javelin/src/lib/Cookie.js =================================================================== --- /dev/null +++ externals/javelin/src/lib/Cookie.js @@ -0,0 +1,102 @@ +/** + * @provides javelin-cookie + * @requires javelin-install + * javelin-util + * @javelin + */ + +/* + * API/Wrapper for document cookie access and manipulation based heavily on the + * MooTools Cookie.js + * + * github.com/mootools/mootools-core/blob/master/Source/Utilities/Cookie.js + * + * Thx again, Moo. + */ +JX.install('Cookie', { + + /** + * Setting cookies involves setting up a cookie object which is eventually + * written. + * + * var prefs = new JX.Cookie('prefs'); + * prefs.setDaysToLive(5); + * prefs.setValue('1,0,10,1350'); + * prefs.setSecure(); + * prefs.write(); + * + * Retrieving a cookie from the browser requires only a read() call on the + * cookie object. However, because cookies have such a complex API you may + * not be able to get your value this way if a path or domain was set when the + * cookie was written. Good luck with that. + * + * var prefs_string = new JX.Cookie('prefs').read(); + * + * There is no real API in HTTP for deleting a cookie aside from setting the + * cookie to expire immediately. This dispose method will null out the value + * and expire the cookie as well. + * + * new JX.Cookie('prefs').dispose(); + */ + construct : function(key) { + if (__DEV__ && + (!key.length || + key.match(/^(?:expires|domain|path|secure)$/i) || + key.match(/[\s,;]/) || + key.indexOf('$') === 0)) { + JX.$E('JX.Cookie(): Invalid cookie name. "' + key + '" provided.'); + } + this.setKey(key); + this.setTarget(document); + }, + + properties : { + key : null, + value : null, + domain : null, + path : null, + daysToLive : 0, + secure : true, + target : null + }, + + members : { + write : function() { + this.setValue(encodeURIComponent(this.getValue())); + + var cookie_bits = []; + cookie_bits.push(this.getValue()); + + if (this.getDomain()) { + cookie_bits.push('Domain=' + this.getDomain()); + } + + if (this.getPath()) { + cookie_bits.push('Path=' + this.getPath()); + } + + var exp = new Date(JX.now() + this.getDaysToLive() * 1000 * 60 * 60 * 24); + cookie_bits.push('Expires=' + exp.toGMTString()); + + if (this.getSecure()) { + cookie_bits.push('Secure'); + } + + cookie_str = cookie_bits.join('; ') + ';'; + var cookie_str = this.getKey() + '=' + cookie_str; + this.getTarget().cookie = cookie_str; + }, + + read : function() { + var key = this.getKey().replace(/([-.*+?^${}()|[\]\/\\])/g, '\\$1'); + var val = this.getTarget().cookie.match('(?:^|;)\\s*' + key + '=([^;]*)'); + return (val) ? decodeURIComponent(val[1]) : null; + }, + + dispose : function() { + this.setValue(null); + this.setDaysToLive(-1); + this.write(); + } + } +}); Index: externals/javelin/src/lib/DOM.js =================================================================== --- /dev/null +++ externals/javelin/src/lib/DOM.js @@ -0,0 +1,883 @@ +/** + * @requires javelin-magical-init + * javelin-install + * javelin-util + * javelin-vector + * javelin-stratcom + * @provides javelin-dom + * + * @javelin-installs JX.$ + * @javelin-installs JX.$N + * @javelin-installs JX.$H + * + * @javelin + */ + + +/** + * Select an element by its "id" attribute, like ##document.getElementById()##. + * For example: + * + * var node = JX.$('some_id'); + * + * This will select the node with the specified "id" attribute: + * + * LANG=HTML + * <div id="some_id">...</div> + * + * If the specified node does not exist, @{JX.$()} will throw an exception. + * + * For other ways to select nodes from the document, see @{JX.DOM.scry()} and + * @{JX.DOM.find()}. + * + * @param string "id" attribute to select from the document. + * @return Node Node with the specified "id" attribute. + * + * @group dom + */ +JX.$ = function(id) { + + if (__DEV__) { + if (!id) { + JX.$E('Empty ID passed to JX.$()!'); + } + } + + var node = document.getElementById(id); + if (!node || (node.id != id)) { + if (__DEV__) { + if (node && (node.id != id)) { + JX.$E( + 'JX.$("'+id+'"): '+ + 'document.getElementById() returned an element without the '+ + 'correct ID. This usually means that the element you are trying '+ + 'to select is being masked by a form with the same value in its '+ + '"name" attribute.'); + } + } + JX.$E("JX.$('" + id + "') call matched no nodes."); + } + + return node; +}; + +/** + * Upcast a string into an HTML object so it is treated as markup instead of + * plain text. See @{JX.$N} for discussion of Javelin's security model. Every + * time you call this function you potentially open up a security hole. Avoid + * its use wherever possible. + * + * This class intentionally supports only a subset of HTML because many browsers + * named "Internet Explorer" have awkward restrictions around what they'll + * accept for conversion to document fragments. Alter your datasource to emit + * valid HTML within this subset if you run into an unsupported edge case. All + * the edge cases are crazy and you should always be reasonably able to emit + * a cohesive tag instead of an unappendable fragment. + * + * You may use @{JX.$H} as a shortcut for creating new JX.HTML instances: + * + * JX.$N('div', {}, some_html_blob); // Treat as string (safe) + * JX.$N('div', {}, JX.$H(some_html_blob)); // Treat as HTML (unsafe!) + * + * @task build String into HTML + * @task nodes HTML into Nodes + * + * @group dom + */ +JX.install('HTML', { + + construct : function(str) { + if (__DEV__) { + var tags = ['legend', 'thead', 'tbody', 'tfoot', 'column', 'colgroup', + 'caption', 'tr', 'th', 'td', 'option']; + var evil_stuff = new RegExp('^\\s*<(' + tags.join('|') + ')\\b', 'i'); + var match = null; + if (match = str.match(evil_stuff)) { + JX.$E( + 'new JX.HTML("<' + match[1] + '>..."): ' + + 'call initializes an HTML object with an invalid partial fragment ' + + 'and can not be converted into DOM nodes. The enclosing tag of an ' + + 'HTML content string must be appendable to a document fragment. ' + + 'For example, <table> is allowed but <tr> or <tfoot> are not.'); + } + + var really_evil = /<script\b/; + if (str.match(really_evil)) { + JX.$E( + 'new JX.HTML("...<script>..."): ' + + 'call initializes an HTML object with an embedded script tag! ' + + 'Are you crazy?! Do NOT do this!!!'); + } + + var wont_work = /<object\b/; + if (str.match(wont_work)) { + JX.$E( + 'new JX.HTML("...<object>..."): ' + + 'call initializes an HTML object with an embedded <object> tag. IE ' + + 'will not do the right thing with this.'); + } + + // TODO(epriestley): May need to deny <option> more broadly, see + // http://support.microsoft.com/kb/829907 and the whole mess in the + // heavy stack. But I seem to have gotten away without cloning into the + // documentFragment below, so this may be a nonissue. + } + + this._content = str; + }, + + members : { + _content : null, + /** + * Convert the raw HTML string into a DOM node tree. + * + * @task nodes + * @return DocumentFragment A document fragment which contains the nodes + * corresponding to the HTML string you provided. + */ + getFragment : function() { + var wrapper = JX.$N('div'); + wrapper.innerHTML = this._content; + var fragment = document.createDocumentFragment(); + while (wrapper.firstChild) { + // TODO(epriestley): Do we need to do a bunch of cloning junk here? + // See heavy stack. I'm disconnecting the nodes instead; this seems + // to work but maybe my test case just isn't extensive enough. + fragment.appendChild(wrapper.removeChild(wrapper.firstChild)); + } + return fragment; + } + } +}); + + +/** + * Build a new HTML object from a trustworthy string. JX.$H is a shortcut for + * creating new JX.HTML instances. + * + * @task build + * @param string A string which you want to be treated as HTML, because you + * know it is from a trusted source and any data in it has been + * properly escaped. + * @return JX.HTML HTML object, suitable for use with @{JX.$N}. + * + * @group dom + */ +JX.$H = function(str) { + return new JX.HTML(str); +}; + + +/** + * Create a new DOM node with attributes and content. + * + * var link = JX.$N('a'); + * + * This creates a new, empty anchor tag without any attributes. The equivalent + * markup would be: + * + * LANG=HTML + * <a /> + * + * You can also specify attributes by passing a dictionary: + * + * JX.$N('a', {name: 'anchor'}); + * + * This is equivalent to: + * + * LANG=HTML + * <a name="anchor" /> + * + * Additionally, you can specify content: + * + * JX.$N( + * 'a', + * {href: 'http://www.javelinjs.com'}, + * 'Visit the Javelin Homepage'); + * + * This is equivalent to: + * + * LANG=HTML + * <a href="http://www.javelinjs.com">Visit the Javelin Homepage</a> + * + * If you only want to specify content, you can omit the attribute parameter. + * That is, these calls are equivalent: + * + * JX.$N('div', {}, 'Lorem ipsum...'); // No attributes. + * JX.$N('div', 'Lorem ipsum...') // Same as above. + * + * Both are equivalent to: + * + * LANG=HTML + * <div>Lorem ipsum...</div> + * + * Note that the content is treated as plain text, not HTML. This means it is + * safe to use untrusted strings: + * + * JX.$N('div', '<script src="evil.com" />'); + * + * This is equivalent to: + * + * LANG=HTML + * <div><script src="evil.com" /></div> + * + * That is, the content will be properly escaped and will not create a + * vulnerability. If you want to set HTML content, you can use @{JX.HTML}: + * + * JX.$N('div', JX.$H(some_html)); + * + * **This is potentially unsafe**, so make sure you understand what you're + * doing. You should usually avoid passing HTML around in string form. See + * @{JX.HTML} for discussion. + * + * You can create new nodes with a Javelin sigil (and, optionally, metadata) by + * providing "sigil" and "meta" keys in the attribute dictionary. + * + * @param string Tag name, like 'a' or 'div'. + * @param dict|string|@{JX.HTML}? Property dictionary, or content if you don't + * want to specify any properties. + * @param string|@{JX.HTML}? Content string (interpreted as plain text) + * or @{JX.HTML} object (interpreted as HTML, + * which may be dangerous). + * @return Node New node with whatever attributes and + * content were specified. + * + * @group dom + */ +JX.$N = function(tag, attr, content) { + if (typeof content == 'undefined' && + (typeof attr != 'object' || attr instanceof JX.HTML)) { + content = attr; + attr = {}; + } + + if (__DEV__) { + if (tag.toLowerCase() != tag) { + JX.$E( + '$N("'+tag+'", ...): '+ + 'tag name must be in lower case; '+ + 'use "'+tag.toLowerCase()+'", not "'+tag+'".'); + } + } + + var node = document.createElement(tag); + + if (attr.style) { + JX.copy(node.style, attr.style); + delete attr.style; + } + + if (attr.sigil) { + JX.Stratcom.addSigil(node, attr.sigil); + delete attr.sigil; + } + + if (attr.meta) { + JX.Stratcom.addData(node, attr.meta); + delete attr.meta; + } + + if (__DEV__) { + if (('metadata' in attr) || ('data' in attr)) { + JX.$E( + '$N(' + tag + ', ...): ' + + 'use the key "meta" to specify metadata, not "data" or "metadata".'); + } + } + + JX.copy(node, attr); + if (content) { + JX.DOM.setContent(node, content); + } + return node; +}; + + +/** + * Query and update the DOM. Everything here is static, this is essentially + * a collection of common utility functions. + * + * @task stratcom Attaching Event Listeners + * @task content Changing DOM Content + * @task nodes Updating Nodes + * @task serialize Serializing Forms + * @task test Testing DOM Properties + * @task convenience Convenience Methods + * @task query Finding Nodes in the DOM + * @task view Changing View State + * + * @group dom + */ +JX.install('DOM', { + statics : { + _autoid : 0, + _uniqid : 0, + _metrics : {}, + + +/* -( Changing DOM Content )----------------------------------------------- */ + + + /** + * Set the content of some node. This uses the same content semantics as + * other Javelin content methods, see @{function:JX.$N} for a detailed + * explanation. Previous content will be replaced: you can also + * @{method:prependContent} or @{method:appendContent}. + * + * @param Node Node to set content of. + * @param mixed Content to set. + * @return void + * @task content + */ + setContent : function(node, content) { + if (__DEV__) { + if (!JX.DOM.isNode(node)) { + JX.$E( + 'JX.DOM.setContent(<yuck>, ...): '+ + 'first argument must be a DOM node.'); + } + } + + while (node.firstChild) { + JX.DOM.remove(node.firstChild); + } + JX.DOM.appendContent(node, content); + }, + + + /** + * Prepend content to some node. This method uses the same content semantics + * as other Javelin methods, see @{function:JX.$N} for an explanation. You + * can also @{method:setContent} or @{method:appendContent}. + * + * @param Node Node to prepend content to. + * @param mixed Content to prepend. + * @return void + * @task content + */ + prependContent : function(node, content) { + if (__DEV__) { + if (!JX.DOM.isNode(node)) { + JX.$E( + 'JX.DOM.prependContent(<junk>, ...): '+ + 'first argument must be a DOM node.'); + } + } + + this._insertContent(node, content, this._mechanismPrepend, true); + }, + + + /** + * Append content to some node. This method uses the same content semantics + * as other Javelin methods, see @{function:JX.$N} for an explanation. You + * can also @{method:setContent} or @{method:prependContent}. + * + * @param Node Node to append the content of. + * @param mixed Content to append. + * @return void + * @task content + */ + appendContent : function(node, content) { + if (__DEV__) { + if (!JX.DOM.isNode(node)) { + JX.$E( + 'JX.DOM.appendContent(<bleh>, ...): '+ + 'first argument must be a DOM node.'); + } + } + + this._insertContent(node, content, this._mechanismAppend); + }, + + + /** + * Internal, add content to a node by prepending. + * + * @param Node Node to prepend content to. + * @param Node Node to prepend. + * @return void + * @task content + */ + _mechanismPrepend : function(node, content) { + node.insertBefore(content, node.firstChild); + }, + + + /** + * Internal, add content to a node by appending. + * + * @param Node Node to append content to. + * @param Node Node to append. + * @task content + */ + _mechanismAppend : function(node, content) { + node.appendChild(content); + }, + + + /** + * Internal, add content to a node using some specified mechanism. + * + * @param Node Node to add content to. + * @param mixed Content to add. + * @param function Callback for actually adding the nodes. + * @param bool True if array elements should be passed to the mechanism + * in reverse order, i.e. the mechanism prepends nodes. + * @return void + * @task content + */ + _insertContent : function(parent, content, mechanism, reverse) { + if (JX.isArray(content)) { + if (reverse) { + content = [].concat(content).reverse(); + } + for (var ii = 0; ii < content.length; ii++) { + JX.DOM._insertContent(parent, content[ii], mechanism, reverse); + } + } else { + var type = typeof content; + if (content instanceof JX.HTML) { + content = content.getFragment(); + } else if (type == 'string' || type == 'number') { + content = document.createTextNode(content); + } + + if (__DEV__) { + if (content && !content.nodeType) { + JX.$E( + 'JX.DOM._insertContent(<node>, ...): '+ + 'second argument must be a string, a number, ' + + 'a DOM node or a JX.HTML instance'); + } + } + + content && mechanism(parent, content); + } + }, + + +/* -( Updating Nodes )----------------------------------------------------- */ + + + /** + * Remove a node from its parent, so it is no longer a child of any other + * node. + * + * @param Node Node to remove. + * @return Node The node. + * @task nodes + */ + remove : function(node) { + node.parentNode && JX.DOM.replace(node, null); + return node; + }, + + + /** + * Replace a node with some other piece of content. This method obeys + * Javelin content semantics, see @{function:JX.$N} for an explanation. + * You can also @{method:setContent}, @{method:prependContent}, or + * @{method:appendContent}. + * + * @param Node Node to replace. + * @param mixed Content to replace it with. + * @return Node the original node. + * @task nodes + */ + replace : function(node, replacement) { + if (__DEV__) { + if (!node.parentNode) { + JX.$E( + 'JX.DOM.replace(<node>, ...): '+ + 'node has no parent node, so it can not be replaced.'); + } + } + + var mechanism; + if (node.nextSibling) { + mechanism = JX.bind(node.nextSibling, function(parent, content) { + parent.insertBefore(content, this); + }); + } else { + mechanism = this._mechanismAppend; + } + var parent = node.parentNode; + parent.removeChild(node); + this._insertContent(parent, replacement, mechanism); + + return node; + }, + + +/* -( Serializing Froms )-------------------------------------------------- */ + + + /** + * Converts a form into a list of <name, value> pairs. + * + * Note: This function explicity does not match for submit inputs as there + * could be multiple in a form. It's the caller's obligation to add the + * submit input value if desired. + * + * @param Node The form element to convert into a list of pairs. + * @return List A list of <name, value> pairs. + * @task serialize + */ + convertFormToListOfPairs : function(form) { + var elements = form.getElementsByTagName('*'); + var data = []; + for (var ii = 0; ii < elements.length; ++ii) { + if (!elements[ii].name) { + continue; + } + if (elements[ii].disabled) { + continue; + } + var type = elements[ii].type; + var tag = elements[ii].tagName; + if ((type in {radio: 1, checkbox: 1} && elements[ii].checked) || + type in {text: 1, hidden: 1, password: 1, email: 1, tel: 1, + number: 1} || + tag in {TEXTAREA: 1, SELECT: 1}) { + data.push([elements[ii].name, elements[ii].value]); + } + } + return data; + }, + + + /** + * Converts a form into a dictionary mapping input names to values. This + * will overwrite duplicate inputs in an undefined way. + * + * @param Node The form element to convert into a dictionary. + * @return Dict A dictionary of form values. + * @task serialize + */ + convertFormToDictionary : function(form) { + var data = {}; + var pairs = JX.DOM.convertFormToListOfPairs(form); + for (var ii = 0; ii < pairs.length; ii++) { + data[pairs[ii][0]] = pairs[ii][1]; + } + return data; + }, + + +/* -( Testing DOM Properties )--------------------------------------------- */ + + + /** + * Test if an object is a valid Node. + * + * @param wild Something which might be a Node. + * @return bool True if the parameter is a DOM node. + * @task test + */ + isNode : function(node) { + return !!(node && node.nodeName && (node !== window)); + }, + + + /** + * Test if an object is a node of some specific (or one of several) types. + * For example, this tests if the node is an ##<input />##, ##<select />##, + * or ##<textarea />##. + * + * JX.DOM.isType(node, ['input', 'select', 'textarea']); + * + * @param wild Something which might be a Node. + * @param string|list One or more tags which you want to test for. + * @return bool True if the object is a node, and it's a node of one + * of the provided types. + * @task test + */ + isType : function(node, of_type) { + node = ('' + (node.nodeName || '')).toUpperCase(); + of_type = JX.$AX(of_type); + for (var ii = 0; ii < of_type.length; ++ii) { + if (of_type[ii].toUpperCase() == node) { + return true; + } + } + return false; + }, + + + /** + * Listen for events occuring beneath a specific node in the DOM. This is + * similar to @{JX.Stratcom.listen()}, but allows you to specify some node + * which serves as a scope instead of the default scope (the whole document) + * which you get if you install using @{JX.Stratcom.listen()} directly. For + * example, to listen for clicks on nodes with the sigil 'menu-item' below + * the root menu node: + * + * var the_menu = getReferenceToTheMenuNodeSomehow(); + * JX.DOM.listen(the_menu, 'click', 'menu-item', function(e) { ... }); + * + * @task stratcom + * @param Node The node to listen for events underneath. + * @param string|list One or more event types to listen for. + * @param list? A path to listen on, or a list of paths. + * @param function Callback to invoke when a matching event occurs. + * @return object A reference to the installed listener. You can later + * remove the listener by calling this object's remove() + * method. + */ + listen : function(node, type, path, callback) { + var auto_id = ['autoid:' + JX.DOM._getAutoID(node)]; + path = JX.$AX(path || []); + if (!path.length) { + path = auto_id; + } else { + for (var ii = 0; ii < path.length; ii++) { + path[ii] = auto_id.concat(JX.$AX(path[ii])); + } + } + return JX.Stratcom.listen(type, path, callback); + }, + + + /** + * Invoke a custom event on a node. This method is a companion to + * @{method:JX.DOM.listen} and parallels @{method:JX.Stratcom.invoke} in + * the same way that method parallels @{method:JX.Stratcom.listen}. + * + * This method can not be used to invoke native events (like 'click'). + * + * @param Node The node to invoke an event on. + * @param string Custom event type. + * @param dict Event data. + * @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. + */ + invoke : function(node, type, data) { + if (__DEV__) { + if (type in JX.__allowedEvents) { + throw new Error( + 'JX.DOM.invoke(..., "' + type + '", ...): ' + + 'you cannot invoke with the same type as a native event.'); + } + } + return JX.Stratcom.dispatch({ + target: node, + type: type, + customData: data + }); + }, + + + uniqID : function(node) { + if (!node.getAttribute('id')) { + node.setAttribute('id', 'uniqid_'+(++JX.DOM._uniqid)); + } + return node.getAttribute('id'); + }, + + alterClass : function(node, className, add) { + if (__DEV__) { + if (add !== false && add !== true) { + JX.$E( + 'JX.DOM.alterClass(...): ' + + 'expects the third parameter to be Boolean: ' + + add + ' was provided'); + } + } + + var has = ((' '+node.className+' ').indexOf(' '+className+' ') > -1); + if (add && !has) { + node.className += ' '+className; + } else if (has && !add) { + node.className = node.className.replace( + new RegExp('(^|\\s)' + className + '(?:\\s|$)', 'g'), ' '); + } + }, + + htmlize : function(str) { + return (''+str) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>'); + }, + + + /** + * Show one or more elements, by removing their "display" style. This + * assumes you have hidden them with @{method:hide}, or explicitly set + * the style to `display: none;`. + * + * @task convenience + * @param ... One or more nodes to remove "display" styles from. + * @return void + */ + show : function() { + if (__DEV__) { + for (var ii = 0; ii < arguments.length; ++ii) { + if (!arguments[ii]) { + JX.$E( + 'JX.DOM.show(...): ' + + 'one or more arguments were null or empty.'); + } + } + } + + for (var ii = 0; ii < arguments.length; ++ii) { + arguments[ii].style.display = ''; + } + }, + + + /** + * Hide one or more elements, by setting `display: none;` on them. This is + * a convenience method. See also @{method:show}. + * + * @task convenience + * @param ... One or more nodes to set "display: none" on. + * @return void + */ + hide : function() { + if (__DEV__) { + for (var ii = 0; ii < arguments.length; ++ii) { + if (!arguments[ii]) { + JX.$E( + 'JX.DOM.hide(...): ' + + 'one or more arguments were null or empty.'); + } + } + } + + for (var ii = 0; ii < arguments.length; ++ii) { + arguments[ii].style.display = 'none'; + } + }, + + textMetrics : function(node, pseudoclass, x) { + if (!this._metrics[pseudoclass]) { + var n = JX.$N( + 'var', + {className: pseudoclass}); + this._metrics[pseudoclass] = n; + } + var proxy = this._metrics[pseudoclass]; + document.body.appendChild(proxy); + proxy.style.width = x ? (x+'px') : ''; + JX.DOM.setContent( + proxy, + JX.$H(JX.DOM.htmlize(node.value).replace(/\n/g, '<br />'))); + var metrics = JX.Vector.getDim(proxy); + document.body.removeChild(proxy); + return metrics; + }, + + + /** + * Search the document for DOM nodes by providing a root node to look + * beneath, a tag name, and (optionally) a sigil. Nodes which match all + * specified conditions are returned. + * + * @task query + * + * @param Node Root node to search beneath. + * @param string Tag name, like 'a' or 'textarea'. + * @param string Optionally, a sigil which nodes are required to have. + * + * @return list List of matching nodes, which may be empty. + */ + scry : function(root, tagname, sigil) { + if (__DEV__) { + if (!JX.DOM.isNode(root)) { + JX.$E( + 'JX.DOM.scry(<yuck>, ...): '+ + 'first argument must be a DOM node.'); + } + } + + var nodes = root.getElementsByTagName(tagname); + if (!sigil) { + return JX.$A(nodes); + } + var result = []; + for (var ii = 0; ii < nodes.length; ii++) { + if (JX.Stratcom.hasSigil(nodes[ii], sigil)) { + result.push(nodes[ii]); + } + } + return result; + }, + + + /** + * Select a node uniquely identified by a root, tagname and sigil. This + * is similar to JX.DOM.scry() but expects exactly one result. + * + * @task query + * + * @param Node Root node to search beneath. + * @param string Tag name, like 'a' or 'textarea'. + * @param string Optionally, sigil which selected node must have. + * + * @return Node Node uniquely identified by the criteria. + */ + find : function(root, tagname, sigil) { + if (__DEV__) { + if (!JX.DOM.isNode(root)) { + JX.$E( + 'JX.DOM.find(<glop>, "'+tagname+'", "'+sigil+'"): '+ + 'first argument must be a DOM node.'); + } + } + + var result = JX.DOM.scry(root, tagname, sigil); + + if (__DEV__) { + if (result.length > 1) { + JX.$E( + 'JX.DOM.find(<node>, "'+tagname+'", "'+sigil+'"): '+ + 'matched more than one node.'); + } + } + + if (!result.length) { + JX.$E('JX.DOM.find(<node>, "' + + tagname + '", "' + sigil + '"): '+ 'matched no nodes.'); + } + + return result[0]; + }, + + + /** + * Focus a node safely. This is just a convenience wrapper that allows you + * to avoid IE's habit of throwing when nearly any focus operation is + * invoked. + * + * @task convenience + * @param Node Node to move cursor focus to, if possible. + * @return void + */ + focus : function(node) { + try { node.focus(); } catch (lol_ie) {} + }, + + /** + * Scroll to the position of an element in the document. + * @task view + * @param Node Node to move document scroll position to, if possible. + * @return void + */ + scrollTo : function(node) { + window.scrollTo(0, JX.$V(node).y); + }, + + _getAutoID : function(node) { + if (!node.getAttribute('data-autoid')) { + node.setAttribute('data-autoid', 'autoid_'+(++JX.DOM._autoid)); + } + return node.getAttribute('data-autoid'); + } + } +}); + Index: externals/javelin/src/lib/History.js =================================================================== --- /dev/null +++ externals/javelin/src/lib/History.js @@ -0,0 +1,222 @@ +/** + * @requires javelin-stratcom + * javelin-install + * javelin-uri + * javelin-util + * @provides javelin-history + * @javelin + */ + +/** + * JX.History provides a stable interface for managing the browser's history + * stack. Whenever the history stack mutates, the "history:change" event is + * invoked via JX.Stratcom. + * + * Inspired by History Manager implemented by Christoph Pojer (@cpojer) + * @see https://github.com/cpojer/mootools-history + */ +JX.install('History', { + + statics : { + + // Mechanisms to @{JX.History.install} with (in preferred support order). + // The default behavior is to use the best supported mechanism. + DEFAULT : Infinity, + PUSHSTATE : 3, + HASHCHANGE : 2, + POLLING : 1, + + // Last path parsed from the URL fragment. + _hash : null, + + // Some browsers fire an extra "popstate" on initial page load, so we keep + // track of the initial path to normalize behavior (and not fire the extra + // event). + _initialPath : null, + + // Mechanism used to interface with the browser history stack. + _mechanism : null, + + /** + * Starts history management. This method must be invoked first before any + * other JX.History method can be used. + * + * @param int An optional mechanism used to interface with the browser + * history stack. If it is not supported, the next supported + * mechanism will be used. + */ + install : function(mechanism) { + if (__DEV__) { + if (JX.History._installed) { + JX.$E('JX.History.install(): can only install once.'); + } + JX.History._installed = true; + } + + mechanism = mechanism || JX.History.DEFAULT; + + if (mechanism >= JX.History.PUSHSTATE && 'pushState' in history) { + JX.History._mechanism = JX.History.PUSHSTATE; + JX.History._initialPath = JX.History._getBasePath(location.href); + JX.Stratcom.listen('popstate', null, JX.History._handleChange); + } else if (mechanism >= JX.History.HASHCHANGE && + 'onhashchange' in window) { + JX.History._mechanism = JX.History.HASHCHANGE; + JX.Stratcom.listen('hashchange', null, JX.History._handleChange); + } else { + JX.History._mechanism = JX.History.POLLING; + setInterval(JX.History._handleChange, 200); + } + }, + + /** + * Get the name of the mechanism used to interface with the browser + * history stack. + * + * @return string Mechanism, either pushstate, hashchange, or polling. + */ + getMechanism : function() { + if (__DEV__) { + if (!JX.History._installed) { + JX.$E( + 'JX.History.getMechanism(): ' + + 'must call JX.History.install() first.'); + } + } + return JX.History._mechanism; + }, + + /** + * Returns the path on top of the history stack. + * + * If the HTML5 History API is unavailable and an eligible path exists in + * the current URL fragment, the fragment is parsed for a path. Otherwise, + * the current URL path is returned. + * + * @return string Path on top of the history stack. + */ + getPath : function() { + if (__DEV__) { + if (!JX.History._installed) { + JX.$E( + 'JX.History.getPath(): ' + + 'must call JX.History.install() first.'); + } + } + if (JX.History.getMechanism() === JX.History.PUSHSTATE) { + return JX.History._getBasePath(location.href); + } else { + var parsed = JX.History._parseFragment(location.hash); + return parsed || JX.History._getBasePath(location.href); + } + }, + + /** + * Pushes a path onto the history stack. + * + * @param string Path. + * @return void + */ + push : function(path) { + if (__DEV__) { + if (!JX.History._installed) { + JX.$E( + 'JX.History.push(): ' + + 'must call JX.History.install() first.'); + } + } + if (JX.History.getMechanism() === JX.History.PUSHSTATE) { + if (JX.History._initialPath && JX.History._initialPath !== path) { + JX.History._initialPath = null; + } + history.pushState(null, null, path); + JX.History._fire(path); + } else { + location.hash = JX.History._composeFragment(path); + } + }, + + /** + * Modifies the path on top of the history stack. + * + * @param string Path. + * @return void + */ + replace : function(path) { + if (__DEV__) { + if (!JX.History._installed) { + JX.$E( + 'JX.History.replace(): ' + + 'must call JX.History.install() first.'); + } + } + if (JX.History.getMechanism() === JX.History.PUSHSTATE) { + history.replaceState(null, null, path); + JX.History._fire(path); + } else { + var uri = JX.$U(location.href); + uri.setFragment(JX.History._composeFragment(path)); + // Safari bug: "location.replace" does not respect changes made via + // setting "location.hash", so use "history.replaceState" if possible. + if ('replaceState' in history) { + history.replaceState(null, null, uri.toString()); + JX.History._handleChange(); + } else { + location.replace(uri.toString()); + } + } + }, + + _handleChange : function() { + var path = JX.History.getPath(); + if (JX.History.getMechanism() === JX.History.PUSHSTATE) { + if (path === JX.History._initialPath) { + JX.History._initialPath = null; + } else { + JX.History._fire(path); + } + } else { + if (path !== JX.History._hash) { + JX.History._hash = path; + JX.History._fire(path); + } + } + }, + + _fire : function(path) { + JX.Stratcom.invoke('history:change', null, { + path: JX.History._getBasePath(path) + }); + }, + + _getBasePath : function(href) { + return JX.$U(href).setProtocol(null).setDomain(null).toString(); + }, + + _composeFragment : function(path) { + path = JX.History._getBasePath(path); + // If the URL fragment does not change, the new path will not get pushed + // onto the stack. So we alternate the hash prefix to force a new state. + if (JX.History.getPath() === path) { + var hash = location.hash; + if (hash && hash.charAt(1) === '!') { + return '~!' + path; + } + } + return '!' + path; + }, + + _parseFragment : function(fragment) { + if (fragment) { + if (fragment.charAt(1) === '!') { + return fragment.substr(2); + } else if (fragment.substr(1, 2) === '~!') { + return fragment.substr(3); + } + } + return null; + } + + } + +}); Index: externals/javelin/src/lib/JSON.js =================================================================== --- /dev/null +++ externals/javelin/src/lib/JSON.js @@ -0,0 +1,155 @@ +/** + * Simple JSON serializer. + * + * @requires javelin-install + * @provides javelin-json + * @javelin + */ + +/** + * JSON serializer and parser. This class uses the native JSON parser if it is + * available; if not, it provides an eval-based parser and a simple serializer. + * + * NOTE: This class uses eval() on some systems, without sanitizing input. It is + * not safe to use with untrusted data. Javelin does not provide a library + * suitable for parsing untrusted JSON. + * + * Usage is straightforward: + * + * JX.JSON.stringify({"bees":"knees"}); // Returns string: '{"bees":"knees"}' + * JX.JSON.parse('{"bees":"knees"}'); // Returns object: {"bees":"knees"} + * + * @task json JSON Manipulation + * @task internal Internal + * @group util + */ +JX.install('JSON', { + statics : { + + +/* -( JSON Manipulation )-------------------------------------------------- */ + + + /** + * Parse a **trusted** JSON string into an object. Accepts a valid JSON + * string and returns the object it encodes. + * + * NOTE: This method does not sanitize input and uses an eval-based parser + * on some systems. It is **NOT SAFE** to use with untrusted inputs. + * + * @param string A valid, trusted JSON string. + * @return object The object encoded by the JSON string. + * @task json + */ + parse : function(data) { + if (typeof data != 'string') { + return null; + } + + if (window.JSON && JSON.parse) { + var obj; + try { + obj = JSON.parse(data); + } catch (e) {} + return obj || null; + } + + return eval('(' + data + ')'); + }, + + /** + * Serialize an object into a JSON string. Accepts an object comprised of + * maps, lists and scalars and transforms it into a JSON representation. + * This method has undefined behavior if you pass in other complicated + * things, e.g. object graphs containing cycles, document.body, or Date + * objects. + * + * @param object An object comprised of maps, lists and scalars. + * @return string JSON representation of the object. + * @task json + */ + stringify : function(val) { + if (window.JSON && JSON.stringify) { + return JSON.stringify(val); + } + + var out = []; + if ( + val === null || val === true || val === false || typeof val == 'number' + ) { + return '' + val; + } + + if (val.push && val.pop) { + var v; + for (var ii = 0; ii < val.length; ii++) { + + // For consistency with JSON.stringify(), encode undefined array + // indices as null. + v = (typeof val[ii] == 'undefined') ? null : val[ii]; + + out.push(JX.JSON.stringify(v)); + } + return '[' + out.join(',') + ']'; + } + + if (typeof val == 'string') { + return JX.JSON._esc(val); + } + + for (var k in val) { + out.push(JX.JSON._esc(k) + ':' + JX.JSON.stringify(val[k])); + } + return '{' + out.join(',') + '}'; + }, + + +/* -( Internal )----------------------------------------------------------- */ + + + // Lifted more or less directly from Crockford's JSON2. + _escexp : /[\\\"\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g, + + // List of control character escape codes. + _meta : { + '\b' : '\\b', + '\t' : '\\t', + '\n' : '\\n', + '\f' : '\\f', + '\r' : '\\r', + '"' : '\\"', + '\\' : '\\\\' + }, + + /** + * Quote and escape a string for inclusion in serialized JSON. Finds + * characters in the string which need to be escaped and uses + * @{method:_replace} to escape them. + * + * @param string Unescaped string. + * @return string Escaped string. + * @task internal + */ + _esc : function(str) { + JX.JSON._escexp.lastIndex = 0; + return JX.JSON._escexp.test(str) ? + '"' + str.replace(JX.JSON._escexp, JX.JSON._replace) + '"' : + '"' + str + '"'; + }, + + /** + * Helper callback for @{method:_esc}, escapes characters which can't be + * represented normally in serialized JSON. + * + * @param string Unescaped character. + * @return string Escaped character. + * @task internal + */ + _replace : function(m) { + if (m in JX.JSON._meta) { + return JX.JSON._meta[m]; + } + return '\\u' + (('0000' + m.charCodeAt(0).toString(16)).slice(-4)); + } + } +}); Index: externals/javelin/src/lib/Mask.js =================================================================== --- /dev/null +++ externals/javelin/src/lib/Mask.js @@ -0,0 +1,109 @@ +/** + * @requires javelin-install + * javelin-dom + * @provides javelin-mask + * @javelin + */ + +/** + * Show a "mask" over the page for lightboxes or dialogs. This is used by + * Workflow to draw visual attention to modal dialogs. + * + * JX.Mask.show(); + * // Show a dialog, lightbox, or other modal UI. + * JX.Mask.hide(); + * + * Masks are stackable, if modal UIs need to spawn other modal UIs. + * + * The mask has class `jx-mask`, which you should apply styles to. For example: + * + * .jx-mask { + * opacity: 0.8; + * background: #000000; + * position: fixed; + * top: 0; + * bottom: 0; + * left: 0; + * right: 0; + * z-index: 2; + * } + * + * You can create multiple mask styles and select them with the `mask_type` + * parameter to `show()` (for instance, a light mask for dialogs and a dark + * mask for lightboxing): + * + * JX.Mask.show('jx-light-mask'); + * // ... + * JX.Mask.hide(); + * + * This will be applied as a class name to the mask element, which you can + * customize in CSS: + * + * .jx-light-mask { + * background: #ffffff; + * } + * + * The mask has sigil `jx-mask`, which can be used to intercept events + * targeting it, like clicks on the mask. + * + * @group control + */ +JX.install('Mask', { + statics : { + _stack : [], + _mask : null, + _currentType : null, + + + /** + * Show a mask over the document. Multiple calls push masks onto a stack. + * + * @param string Optional class name to apply to the mask, if you have + * multiple masks (e.g., one dark and one light). + * @return void + */ + show : function(mask_type) { + var self = JX.Mask; + mask_type = mask_type || null; + + if (!self._stack.length) { + self._mask = JX.$N('div', {className: 'jx-mask', sigil: 'jx-mask'}); + document.body.appendChild(self._mask); + } + + self._adjustType(mask_type) + JX.Mask._stack.push(mask_type); + }, + + /** + * Hide the current mask. The mask stack is popped, which may reveal another + * mask below the current mask. + * + * @return void + */ + hide : function() { + var self = JX.Mask; + var mask_type = self._stack.pop(); + + self._adjustType(mask_type); + + if (!self._stack.length) { + JX.DOM.remove(JX.Mask._mask); + JX.Mask._mask = null; + } + }, + + + _adjustType : function(new_type) { + var self = JX.Mask; + if (self._currentType) { + JX.DOM.alterClass(self._mask, self._currentType, false); + self._currentType = null; + } + if (new_type) { + JX.DOM.alterClass(self._mask, new_type, true); + self._currentType = new_type; + } + } + } +}); Index: externals/javelin/src/lib/Request.js =================================================================== --- /dev/null +++ externals/javelin/src/lib/Request.js @@ -0,0 +1,471 @@ +/** + * @requires javelin-install + * javelin-stratcom + * javelin-util + * javelin-behavior + * javelin-json + * javelin-dom + * javelin-resource + * @provides javelin-request + * @javelin + */ + +/** + * Make basic AJAX XMLHTTPRequests. + * + * @group workflow + */ +JX.install('Request', { + construct : function(uri, handler) { + this.setURI(uri); + if (handler) { + this.listen('done', handler); + } + }, + + events : ['start', 'open', 'send', 'statechange', 'done', 'error', 'finally', + 'uploadprogress'], + + members : { + + _xhrkey : null, + _transport : null, + _sent : false, + _finished : false, + _block : null, + _data : null, + + _getSameOriginTransport : function() { + try { + try { + return new XMLHttpRequest(); + } catch (x) { + return new ActiveXObject("Msxml2.XMLHTTP"); + } + } catch (x) { + return new ActiveXObject("Microsoft.XMLHTTP"); + } + }, + + _getCORSTransport : function() { + try { + var xport = new XMLHttpRequest(); + if ('withCredentials' in xport) { + // XHR supports CORS + } else if (typeof XDomainRequest != 'undefined') { + xport = new XDomainRequest(); + } + return xport; + } catch (x) { + return new XDomainRequest(); + } + }, + + getTransport : function() { + if (!this._transport) { + this._transport = this.getCORS() ? this._getCORSTransport() : + this._getSameOriginTransport(); + } + return this._transport; + }, + + send : function() { + if (this._sent || this._finished) { + if (__DEV__) { + if (this._sent) { + JX.$E( + 'JX.Request.send(): ' + + 'attempting to send a Request that has already been sent.'); + } + if (this._finished) { + JX.$E( + 'JX.Request.send(): ' + + 'attempting to send a Request that has finished or aborted.'); + } + } + return; + } + + // Fire the "start" event before doing anything. A listener may + // perform pre-processing or validation on this request + this.invoke('start', this); + if (this._finished) { + return; + } + + var xport = this.getTransport(); + xport.onreadystatechange = JX.bind(this, this._onreadystatechange); + if (xport.upload) { + xport.upload.onprogress = JX.bind(this, this._onuploadprogress); + } + + var method = this.getMethod().toUpperCase(); + + if (__DEV__) { + if (this.getRawData()) { + if (method != 'POST') { + JX.$E( + 'JX.Request.send(): ' + + 'attempting to send post data over GET. You must use POST.'); + } + } + } + + var list_of_pairs = this._data || []; + list_of_pairs.push(['__ajax__', true]); + + this._block = JX.Stratcom.allocateMetadataBlock(); + list_of_pairs.push(['__metablock__', this._block]); + + var q = (this.getDataSerializer() || + JX.Request.defaultDataSerializer)(list_of_pairs); + var uri = this.getURI(); + + // If we're sending a file, submit the metadata via the URI instead of + // via the request body, because the entire post body will be consumed by + // the file content. + if (method == 'GET' || this.getRawData()) { + uri += ((uri.indexOf('?') === -1) ? '?' : '&') + q; + } + + if (this.getTimeout()) { + this._timer = setTimeout( + JX.bind( + this, + this._fail, + JX.Request.ERROR_TIMEOUT), + this.getTimeout()); + } + + xport.open(method, uri, true); + + // Must happen after xport.open so that listeners can modify the transport + // Some transport properties can only be set after the transport is open + this.invoke('open', this); + if (this._finished) { + return; + } + + this.invoke('send', this); + if (this._finished) { + return; + } + + if (method == 'POST') { + if (this.getRawData()) { + xport.send(this.getRawData()); + } else { + xport.setRequestHeader( + 'Content-Type', + 'application/x-www-form-urlencoded'); + xport.send(q); + } + } else { + xport.send(null); + } + + this._sent = true; + }, + + abort : function() { + this._cleanup(); + }, + + _onuploadprogress : function(progress) { + this.invoke('uploadprogress', progress); + }, + + _onreadystatechange : function() { + var xport = this.getTransport(); + var response; + try { + this.invoke('statechange', this); + if (this._finished) { + return; + } + if (xport.readyState != 4) { + return; + } + // XHR requests to 'file:///' domains return 0 for success, which is why + // we treat it as a good result in addition to HTTP 2XX responses. + if (xport.status !== 0 && (xport.status < 200 || xport.status >= 300)) { + this._fail(); + return; + } + + if (__DEV__) { + var expect_guard = this.getExpectCSRFGuard(); + + if (!xport.responseText.length) { + JX.$E( + 'JX.Request("'+this.getURI()+'", ...): '+ + 'server returned an empty response.'); + } + if (expect_guard && xport.responseText.indexOf('for (;;);') != 0) { + JX.$E( + 'JX.Request("'+this.getURI()+'", ...): '+ + 'server returned an invalid response.'); + } + if (expect_guard && xport.responseText == 'for (;;);') { + JX.$E( + 'JX.Request("'+this.getURI()+'", ...): '+ + 'server returned an empty response.'); + } + } + + response = this._extractResponse(xport); + if (!response) { + JX.$E( + 'JX.Request("'+this.getURI()+'", ...): '+ + 'server returned an invalid response.'); + } + } catch (exception) { + + if (__DEV__) { + JX.log( + 'JX.Request("'+this.getURI()+'", ...): '+ + 'caught exception processing response: '+exception); + } + this._fail(); + return; + } + + try { + this._handleResponse(response); + this._cleanup(); + } catch (exception) { + // In Firefox+Firebug, at least, something eats these. :/ + setTimeout(function() { + throw exception; + }, 0); + } + }, + + _extractResponse : function(xport) { + var text = xport.responseText; + + if (this.getExpectCSRFGuard()) { + text = text.substring('for (;;);'.length); + } + + var type = this.getResponseType().toUpperCase(); + if (type == 'TEXT') { + return text; + } else if (type == 'JSON' || type == 'JAVELIN') { + return JX.JSON.parse(text); + } else if (type == 'XML') { + var doc; + try { + if (typeof DOMParser != 'undefined') { + var parser = new DOMParser(); + doc = parser.parseFromString(text, "text/xml"); + } else { // IE + // an XDomainRequest + doc = new ActiveXObject("Microsoft.XMLDOM"); + doc.async = false; + doc.loadXML(xport.responseText); + } + + return doc.documentElement; + } catch (exception) { + if (__DEV__) { + JX.log( + 'JX.Request("'+this.getURI()+'", ...): '+ + 'caught exception extracting response: '+exception); + } + this._fail(); + return null; + } + } + + if (__DEV__) { + JX.$E( + 'JX.Request("'+this.getURI()+'", ...): '+ + 'unrecognized response type.'); + } + return null; + }, + + _fail : function(error) { + this._cleanup(); + + this.invoke('error', error, this); + this.invoke('finally'); + }, + + _done : function(response) { + this._cleanup(); + + if (response.onload) { + for (var ii = 0; ii < response.onload.length; ii++) { + (new Function(response.onload[ii]))(); + } + } + + var payload; + if (this.getRaw()) { + payload = response; + } else { + payload = response.payload; + JX.Request._parseResponsePayload(payload); + } + + this.invoke('done', payload, this); + this.invoke('finally'); + }, + + _cleanup : function() { + this._finished = true; + clearTimeout(this._timer); + this._timer = null; + + // Should not abort the transport request if it has already completed + // Otherwise, we may see an "HTTP request aborted" error in the console + // despite it possibly having succeeded. + if (this._transport && this._transport.readyState != 4) { + this._transport.abort(); + } + }, + + setData : function(dictionary) { + this._data = null; + this.addData(dictionary); + return this; + }, + + addData : function(dictionary) { + if (!this._data) { + this._data = []; + } + for (var k in dictionary) { + this._data.push([k, dictionary[k]]); + } + return this; + }, + + setDataWithListOfPairs : function(list_of_pairs) { + this._data = list_of_pairs; + return this; + }, + + _handleResponse : function(response) { + if (this.getResponseType().toUpperCase() == 'JAVELIN') { + if (response.error) { + this._fail(response.error); + } else { + JX.Stratcom.mergeData( + this._block, + response.javelin_metadata || {}); + + var when_complete = JX.bind(this, function() { + this._done(response); + JX.initBehaviors(response.javelin_behaviors || {}); + }); + + if (response.javelin_resources) { + JX.Resource.load(response.javelin_resources, when_complete); + } else { + when_complete(); + } + } + } else { + this._cleanup(); + this.invoke('done', response, this); + this.invoke('finally'); + } + } + }, + + statics : { + ERROR_TIMEOUT : -9000, + defaultDataSerializer : function(list_of_pairs) { + var uri = []; + for (var ii = 0; ii < list_of_pairs.length; ii++) { + var pair = list_of_pairs[ii]; + var name = encodeURIComponent(pair[0]); + var value = encodeURIComponent(pair[1]); + uri.push(name + '=' + value); + } + return uri.join('&'); + }, + + /** + * When we receive a JSON blob, parse it to introduce meaningful objects + * where there are magic keys for placeholders. + * + * Objects with the magic key '__html' are translated into JX.HTML objects. + * + * This function destructively modifies its input. + */ + _parseResponsePayload: function(parent, index) { + var recurse = JX.Request._parseResponsePayload; + var obj = (typeof index !== 'undefined') ? parent[index] : parent; + if (JX.isArray(obj)) { + for (var ii = 0; ii < obj.length; ii++) { + recurse(obj, ii); + } + } else if (obj && typeof obj == 'object') { + if (obj.__html != null) { + parent[index] = JX.$H(obj.__html); + } else { + for (var key in obj) { + recurse(obj, key); + } + } + } + } + }, + + properties : { + URI : null, + dataSerializer : null, + /** + * Configure which HTTP method to use for the request. Permissible values + * are "POST" (default) or "GET". + * + * @param string HTTP method, one of "POST" or "GET". + */ + method : 'POST', + /** + * Set the data parameter of transport.send. Useful if you want to send a + * file or FormData. Not that you cannot send raw data and data at the same + * time. + * + * @param Data, argument to transport.send + */ + rawData: null, + raw : false, + + /** + * Configure a timeout, in milliseconds. If the request has not resolved + * (either with success or with an error) within the provided timeframe, + * it will automatically fail with error JX.Request.ERROR_TIMEOUT. + * + * @param int Timeout, in milliseconds (e.g. 3000 = 3 seconds). + */ + timeout : null, + + /** + * Whether or not we should expect the CSRF guard in the response. + * + * @param bool + */ + expectCSRFGuard : true, + + /** + * Whether it should be a CORS (Cross-Origin Resource Sharing) request to + * a third party domain other than the current site. + * + * @param bool + */ + CORS : false, + + /** + * Type of the response. + * + * @param enum 'JAVELIN', 'JSON', 'XML', 'TEXT' + */ + responseType : 'JAVELIN' + } + +}); Index: externals/javelin/src/lib/Resource.js =================================================================== --- /dev/null +++ externals/javelin/src/lib/Resource.js @@ -0,0 +1,177 @@ +/** + * @provides javelin-resource + * @requires javelin-magical-init + * javelin-stratcom + * javelin-util + * javelin-uri + * + * @javelin + */ + +JX.install('Resource', { + + statics: { + + _loading: {}, + _loaded: {}, + _links: [], + _callbacks: [], + + /** + * Loads one or many static resources (JavaScript or CSS) and executes a + * callback once these resources have finished loading. + * + * @param string|array static resource or list of resources to be loaded + * @param function callback when resources have finished loading + */ + load: function(list, callback) { + var resources = {}, + uri, resource, path, type; + + list = JX.$AX(list); + + // In the event there are no resources to wait on, call the callback and + // exit. NOTE: it's better to do this check outside this function and not + // call through JX.Resource, but it's not always easy/possible to do so + if (!list.length) { + setTimeout(callback, 0); + return; + } + + for (var ii = 0; ii < list.length; ii++) { + uri = new JX.URI(list[ii]); + resource = uri.toString(); + path = uri.getPath(); + resources[resource] = true; + + if (JX.Resource._loaded[resource]) { + setTimeout(JX.bind(JX.Resource, JX.Resource._complete, resource), 0); + } else if (!JX.Resource._loading[resource]) { + JX.Resource._loading[resource] = true; + if (path.indexOf('.css') == path.length - 4) { + JX.Resource._loadCSS(resource); + } else { + JX.Resource._loadJS(resource); + } + } + } + + JX.Resource._callbacks.push({ + resources: resources, + callback: callback + }); + }, + + _loadJS: function(uri) { + var script = document.createElement('script'); + var load_callback = function() { + JX.Resource._complete(uri); + }; + var error_callback = function() { + JX.$E('Resource: JS file download failure: ' + uri); + }; + + JX.copy(script, { + type: 'text/javascript', + src: uri + }); + + script.onload = load_callback; + script.onerror = error_callback; + script.onreadystatechange = function() { + var state = this.readyState; + if (state == 'complete' || state == 'loaded') { + load_callback(); + } + }; + document.getElementsByTagName('head')[0].appendChild(script); + }, + + _loadCSS: function(uri) { + var link = JX.copy(document.createElement('link'), { + type: 'text/css', + rel: 'stylesheet', + href: uri, + 'data-href': uri // don't trust href + }); + document.getElementsByTagName('head')[0].appendChild(link); + + JX.Resource._links.push(link); + if (!JX.Resource._timer) { + JX.Resource._timer = setInterval(JX.Resource._poll, 20); + } + }, + + _poll: function() { + var sheets = document.styleSheets, + ii = sheets.length, + links = JX.Resource._links; + + // Cross Origin CSS loading + // http://yearofmoo.com/2011/03/cross-browser-stylesheet-preloading/ + while (ii--) { + var link = sheets[ii], + owner = link.ownerNode || link.owningElement, + jj = links.length; + if (owner) { + while (jj--) { + if (owner == links[jj]) { + JX.Resource._complete(links[jj]['data-href']); + links.splice(jj, 1); + } + } + } + } + + if (!links.length) { + clearInterval(JX.Resource._timer); + JX.Resource._timer = null; + } + }, + + _complete: function(uri) { + var list = JX.Resource._callbacks, + current, ii; + + delete JX.Resource._loading[uri]; + JX.Resource._loaded[uri] = true; + + for (ii = 0; ii < list.length; ii++) { + current = list[ii]; + delete current.resources[uri]; + if (!JX.Resource._hasResources(current.resources)) { + current.callback(); + list.splice(ii--, 1); + } + } + }, + + _hasResources: function(resources) { + for (var hasResources in resources) { + return true; + } + return false; + } + + }, + + initialize: function() { + var list = JX.$A(document.getElementsByTagName('link')), + ii = list.length, + node; + while ((node = list[--ii])) { + if (node.type == 'text/css' && node.href) { + JX.Resource._loaded[(new JX.URI(node.href)).toString()] = true; + } + } + + list = JX.$A(document.getElementsByTagName('script')); + ii = list.length; + while ((node = list[--ii])) { + if (node.type == 'text/javascript' && node.src) { + JX.Resource._loaded[(new JX.URI(node.src)).toString()] = true; + } + } + } + +}); Index: externals/javelin/src/lib/URI.js =================================================================== --- /dev/null +++ externals/javelin/src/lib/URI.js @@ -0,0 +1,234 @@ +/** + * @provides javelin-uri + * @requires javelin-install + * javelin-util + * javelin-stratcom + * + * @javelin-installs JX.$U + * + * @javelin + */ + +/** + * Handy convenience function that returns a @{class:JX.URI} instance. This + * allows you to write things like: + * + * JX.$U('http://zombo.com/').getDomain(); + * + * @param string Unparsed URI. + * @return @{class:JX.URI} JX.URI instance. + * + * @group uri + */ +JX.$U = function(uri) { + return new JX.URI(uri); +}; + +/** + * Convert a string URI into a maleable object. + * + * var uri = new JX.URI('http://www.example.com/asdf.php?a=b&c=d#anchor123'); + * uri.getProtocol(); // http + * uri.getDomain(); // www.example.com + * uri.getPath(); // /asdf.php + * uri.getQueryParams(); // {a: 'b', c: 'd'} + * uri.getFragment(); // anchor123 + * + * ...and back into a string: + * + * uri.setFragment('clowntown'); + * uri.toString() // http://www.example.com/asdf.php?a=b&c=d#clowntown + * + * @group uri + */ +JX.install('URI', { + statics : { + _uriPattern : /(?:([^:\/?#]+):)?(?:\/\/([^:\/?#]*)(?::(\d*))?)?([^?#]*)(?:\?([^#]*))?(?:#(.*))?/, + _queryPattern : /(?:^|&)([^&=]*)=?([^&]*)/g, + + /** + * Convert a Javascript object into an HTTP query string. + * + * @param Object Map of query keys to values. + * @return String HTTP query string, like 'cow=quack&duck=moo'. + */ + _defaultQuerySerializer : function(obj) { + var kv_pairs = []; + for (var key in obj) { + if (obj[key] != null) { + var value = encodeURIComponent(obj[key]); + kv_pairs.push(encodeURIComponent(key) + (value ? '=' + value : '')); + } + } + + return kv_pairs.join('&'); + } + }, + + /** + * Construct a URI + * + * Accepts either absolute or relative URIs. Relative URIs may have protocol + * and domain properties set to undefined + * + * @param string absolute or relative URI + */ + construct : function(uri) { + // need to set the default value here rather than in the properties map, + // or else we get some crazy global state breakage + this.setQueryParams({}); + + if (uri) { + // parse the url + var result = JX.URI._uriPattern.exec(uri); + + // fallback to undefined because IE has weird behavior otherwise + this.setProtocol(result[1] || undefined); + this.setDomain(result[2] || undefined); + this.setPort(result[3] || undefined); + var path = result[4]; + var query = result[5]; + this.setFragment(result[6] || undefined); + + // parse the path + this.setPath(path.charAt(0) == '/' ? path : '/' + path); + + // parse the query data + if (query) { + var queryData = {}; + var data; + while ((data = JX.URI._queryPattern.exec(query)) != null) { + queryData[decodeURIComponent(data[1].replace(/\+/g, ' '))] = + decodeURIComponent(data[2].replace(/\+/g, ' ')); + } + this.setQueryParams(queryData); + } + } + }, + + properties : { + protocol: undefined, + port: undefined, + path: undefined, + queryParams: undefined, + fragment: undefined, + querySerializer: undefined + }, + + members : { + _domain: undefined, + + /** + * Append and override query data values + * Remove a query key by setting it undefined + * + * @param map + * @return @{JX.URI} self + */ + addQueryParams : function(map) { + JX.copy(this.getQueryParams(), map); + return this; + }, + + /** + * Set a specific query parameter + * Remove a query key by setting it undefined + * + * @param string + * @param wild + * @return @{JX.URI} self + */ + setQueryParam : function(key, value) { + var map = {}; + map[key] = value; + return this.addQueryParams(map); + }, + + /** + * Set the domain + * + * This function checks the domain name to ensure that it is safe for + * browser consumption. + */ + setDomain : function(domain) { + var re = new RegExp( + // For the bottom 128 code points, we use a strict whitelist of + // characters that are allowed by all browsers: -.0-9:A-Z[]_a-z + '[\\x00-\\x2c\\x2f\\x3b-\\x40\\x5c\\x5e\\x60\\x7b-\\x7f' + + // In IE, these chararacters cause problems when entity-encoded. + '\\uFDD0-\\uFDEF\\uFFF0-\\uFFFF' + + // In Safari, these characters terminate the hostname. + '\\u2047\\u2048\\uFE56\\uFE5F\\uFF03\\uFF0F\\uFF1F]'); + if (re.test(domain)) { + JX.$E('JX.URI.setDomain(...): invalid domain specified.'); + } + this._domain = domain; + return this; + }, + + getDomain : function() { + return this._domain; + }, + + toString : function() { + if (__DEV__) { + if (this.getPath() && this.getPath().charAt(0) != '/') { + JX.$E( + 'JX.URI.toString(): ' + + 'Path does not begin with a "/" which means this URI will likely' + + 'be malformed. Ensure any string passed to .setPath() leads "/"'); + } + } + var str = ''; + if (this.getProtocol()) { + str += this.getProtocol() + '://'; + } + str += this.getDomain() || ''; + + if (this.getPort()) { + str += ':' + this.getPort(); + } + + // If there is a domain or a protocol, we need to provide '/' for the + // path. If we don't have either and also don't have a path, we can omit + // it to produce a partial URI without path information which begins + // with "?", "#", or is empty. + str += this.getPath() || (str ? '/' : ''); + + str += this._getQueryString(); + if (this.getFragment()) { + str += '#' + this.getFragment(); + } + return str; + }, + + _getQueryString : function() { + var str = ( + this.getQuerySerializer() || JX.URI._defaultQuerySerializer + )(this.getQueryParams()); + return str ? '?' + str : ''; + }, + + /** + * Redirect the browser to another page by changing the window location. If + * the URI is empty, reloads the current page. + * + * You can install a Stratcom listener for the 'go' event if you need to log + * or prevent redirects. + * + * @return void + */ + go : function() { + var uri = this.toString(); + if (JX.Stratcom.invoke('go', null, {uri: uri}).getPrevented()) { + return; + } + if (!uri) { + // window.location.reload clears cache in Firefox. + uri = window.location.pathname + (window.location.query || ''); + } + window.location = uri; + } + + } +}); Index: externals/javelin/src/lib/Vector.js =================================================================== --- /dev/null +++ externals/javelin/src/lib/Vector.js @@ -0,0 +1,350 @@ +/** + * @requires javelin-install + * javelin-event + * @provides javelin-vector + * + * @javelin-installs JX.$V + * + * @javelin + */ + + +/** + * Convenience function that returns a @{class:JX.Vector} instance. This allows + * you to concisely write things like: + * + * JX.$V(x, y).add(10, 10); // Explicit coordinates. + * JX.$V(node).add(50, 50).setDim(node); // Position of a node. + * + * @param number|Node If a node, returns the node's position vector. + * If numeric, the x-coordinate for the new vector. + * @param number? The y-coordinate for the new vector. + * @return @{class:JX.Vector} New vector. + * + * @group dom + */ +JX.$V = function(x, y) { + return new JX.Vector(x, y); +}; + + +/** + * Query and update positions and dimensions of nodes (and other things) within + * within a document. Each vector has two elements, 'x' and 'y', which usually + * represent width/height ('dimension vector') or left/top ('position vector'). + * + * Vectors are used to manage the sizes and positions of elements, events, + * the document, and the viewport (the visible section of the document, i.e. + * how much of the page the user can actually see in their browser window). + * Unlike most Javelin classes, @{class:JX.Vector} exposes two bare properties, + * 'x' and 'y'. You can read and manipulate these directly: + * + * // Give the user information about elements when they click on them. + * JX.Stratcom.listen( + * 'click', + * null, + * function(e) { + * var p = new JX.Vector(e); + * var d = JX.Vector.getDim(e.getTarget()); + * + * alert('You clicked at <' + p.x + ',' + p.y + '> and the element ' + + * 'you clicked is ' + d.x + 'px wide and ' + d.y + 'px high.'); + * }); + * + * You can also update positions and dimensions using vectors: + * + * // When the user clicks on something, make it 10px wider and 10px taller. + * JX.Stratcom.listen( + * 'click', + * null, + * function(e) { + * var target = e.getTarget(); + * JX.$V(target).add(10, 10).setDim(target); + * }); + * + * Additionally, vectors can be used to query document and viewport information: + * + * var v = JX.Vector.getViewport(); // Viewport (window) width and height. + * var d = JX.Vector.getDocument(); // Document width and height. + * var visible_area = parseInt(100 * (v.x * v.y) / (d.x * d.y), 10); + * alert('You can currently see ' + visible_area + ' % of the document.'); + * + * The function @{function:JX.$V} provides convenience construction of common + * vectors. + * + * @task query Querying Positions and Dimensions + * @task update Changing Positions and Dimensions + * @task manip Manipulating Vectors + * + * @group dom + */ +JX.install('Vector', { + + /** + * Construct a vector, either from explicit coordinates or from a node + * or event. You can pass two Numbers to construct an explicit vector: + * + * var p = new JX.Vector(35, 42); + * + * Otherwise, you can pass a @{class:JX.Event} or a Node to implicitly + * construct a vector: + * + * var q = new JX.Vector(some_event); + * var r = new JX.Vector(some_node); + * + * These are just like calling JX.Vector.getPos() on the @{class:JX.Event} or + * Node. + * + * For convenience, @{function:JX.$V} constructs a new vector so you don't + * need to use the 'new' keyword. That is, these are equivalent: + * + * var s = new JX.Vector(x, y); + * var t = JX.$V(x, y); + * + * Methods like @{method:getScroll}, @{method:getViewport} and + * @{method:getDocument} also create new vectors. + * + * Once you have a vector, you can manipulate it with add(): + * + * var u = JX.$V(35, 42); + * var v = u.add(5, -12); // v = <40, 30> + * + * @param wild 'x' component of the vector, or a @{class:JX.Event}, or a + * Node. + * @param Number? If providing an 'x' component, the 'y' component of the + * vector. + * @return @{class:JX.Vector} Specified vector. + * @task query + */ + construct : function(x, y) { + if (typeof y == 'undefined') { + return JX.Vector.getPos(x); + } + + this.x = (x === null) ? null : parseFloat(x); + this.y = (y === null) ? null : parseFloat(y); + }, + + members : { + x : null, + y : null, + + /** + * Move a node around by setting the position of a Node to the vector's + * coordinates. For instance, if you want to move an element to the top left + * corner of the document, you could do this (assuming it has 'position: + * absolute'): + * + * JX.$V(0, 0).setPos(node); + * + * @param Node Node to move. + * @return this + * @task update + */ + setPos : function(node) { + node.style.left = (this.x === null) ? '' : (parseInt(this.x, 10) + 'px'); + node.style.top = (this.y === null) ? '' : (parseInt(this.y, 10) + 'px'); + return this; + }, + + /** + * Change the size of a node by setting its dimensions to the vector's + * coordinates. For instance, if you want to change an element to be 100px + * by 100px: + * + * JX.$V(100, 100).setDim(node); + * + * Or if you want to expand a node's dimensions by 50px: + * + * JX.$V(node).add(50, 50).setDim(node); + * + * @param Node Node to resize. + * @return this + * @task update + */ + setDim : function(node) { + node.style.width = + (this.x === null) + ? '' + : (parseInt(this.x, 10) + 'px'); + node.style.height = + (this.y === null) + ? '' + : (parseInt(this.y, 10) + 'px'); + return this; + }, + + /** + * Change a vector's x and y coordinates by adding numbers to them, or + * adding the coordinates of another vector. For example: + * + * var u = JX.$V(3, 4).add(100, 200); // u = <103, 204> + * + * You can also add another vector: + * + * var q = JX.$V(777, 999); + * var r = JX.$V(1000, 2000); + * var s = q.add(r); // s = <1777, 2999> + * + * Note that this method returns a new vector. It does not modify the + * 'this' vector. + * + * @param wild Value to add to the vector's x component, or another + * vector. + * @param Number? Value to add to the vector's y component. + * @return @{class:JX.Vector} New vector, with summed components. + * @task manip + */ + add : function(x, y) { + if (x instanceof JX.Vector) { + y = x.y; + x = x.x; + } + return new JX.Vector(this.x + parseFloat(x), this.y + parseFloat(y)); + } + }, + + statics : { + _viewport: null, + + /** + * Determine where in a document an element is (or where an event, like + * a click, occurred) by building a new vector containing the position of a + * Node or @{class:JX.Event}. The 'x' component of the vector will + * correspond to the pixel offset of the argument relative to the left edge + * of the document, and the 'y' component will correspond to the pixel + * offset of the argument relative to the top edge of the document. Note + * that all vectors are generated in document coordinates, so the scroll + * position does not affect them. + * + * See also @{method:getDim}, used to determine an element's dimensions. + * + * @param Node|@{class:JX.Event} Node or event to determine the position + * of. + * @return @{class:JX.Vector} New vector with the argument's position. + * @task query + */ + getPos : function(node) { + JX.Event && (node instanceof JX.Event) && (node = node.getRawEvent()); + + if (('pageX' in node) || ('clientX' in node)) { + var c = JX.Vector._viewport; + return new JX.Vector( + node.pageX || (node.clientX + c.scrollLeft), + node.pageY || (node.clientY + c.scrollTop) + ); + } + + var x = 0; + var y = 0; + do { + x += node.offsetLeft; + y += node.offsetTop; + node = node.offsetParent; + } while (node && node != document.body); + + return new JX.Vector(x, y); + }, + + /** + * Determine the width and height of a node by building a new vector with + * dimension information. The 'x' component of the vector will correspond + * to the element's width in pixels, and the 'y' component will correspond + * to its height in pixels. + * + * See also @{method:getPos}, used to determine an element's position. + * + * @param Node Node to determine the display size of. + * @return @{JX.$V} New vector with the node's dimensions. + * @task query + */ + getDim : function(node) { + return new JX.Vector(node.offsetWidth, node.offsetHeight); + }, + + /** + * Determine the current scroll position by building a new vector where + * the 'x' component corresponds to how many pixels the user has scrolled + * from the left edge of the document, and the 'y' component corresponds to + * how many pixels the user has scrolled from the top edge of the document. + * + * See also @{method:getViewport}, used to determine the size of the + * viewport. + * + * @return @{JX.$V} New vector with the document scroll position. + * @task query + */ + getScroll : function() { + // We can't use JX.Vector._viewport here because there's diversity between + // browsers with respect to where position/dimension and scroll position + // information is stored. + var b = document.body; + var e = document.documentElement; + return new JX.Vector( + window.pageXOffset || b.scrollLeft || e.scrollLeft, + window.pageYOffset || b.scrollTop || e.scrollTop + ); + }, + + /** + * Determine the size of the viewport (basically, the browser window) by + * building a new vector where the 'x' component corresponds to the width + * of the viewport in pixels and the 'y' component corresponds to the height + * of the viewport in pixels. + * + * See also @{method:getScroll}, used to determine the position of the + * viewport, and @{method:getDocument}, used to determine the size of the + * entire document. + * + * @return @{class:JX.Vector} New vector with the viewport dimensions. + * @task query + */ + getViewport : function() { + var c = JX.Vector._viewport; + return new JX.Vector( + window.innerWidth || c.clientWidth || 0, + window.innerHeight || c.clientHeight || 0 + ); + }, + + /** + * Determine the size of the document, including any area outside the + * current viewport which the user would need to scroll in order to see, by + * building a new vector where the 'x' component corresponds to the document + * width in pixels and the 'y' component corresponds to the document height + * in pixels. + * + * @return @{class:JX.Vector} New vector with the document dimensions. + * @task query + */ + getDocument : function() { + var c = JX.Vector._viewport; + return new JX.Vector(c.scrollWidth || 0, c.scrollHeight || 0); + } + }, + + /** + * On initialization, the browser-dependent viewport root is determined and + * stored. + * + * In ##__DEV__##, @{class:JX.Vector} installs a toString() method so + * vectors print in a debuggable way: + * + * <23, 92> + * + * This string representation of vectors is not available in a production + * context. + * + * @return void + */ + initialize : function() { + JX.Vector._viewport = document.documentElement || document.body; + + if (__DEV__) { + JX.Vector.prototype.toString = function() { + return '<' + this.x + ', ' + this.y + '>'; + }; + } + } + +}); Index: externals/javelin/src/lib/Workflow.js =================================================================== --- /dev/null +++ externals/javelin/src/lib/Workflow.js @@ -0,0 +1,285 @@ +/** + * @requires javelin-stratcom + * javelin-request + * javelin-dom + * javelin-vector + * javelin-install + * javelin-util + * javelin-mask + * javelin-uri + * @provides javelin-workflow + * @javelin + */ + +/** + * @group workflow + */ +JX.install('Workflow', { + construct : function(uri, data) { + if (__DEV__) { + if (!uri || uri == '#') { + JX.$E( + 'new JX.Workflow(<?>, ...): '+ + 'bogus URI provided when creating workflow.'); + } + } + this.setURI(uri); + this.setData(data || {}); + }, + + events : ['error', 'finally', 'submit'], + + statics : { + _stack : [], + newFromForm : function(form, data) { + var pairs = JX.DOM.convertFormToListOfPairs(form); + for (var k in data) { + pairs.push([k, data[k]]); + } + + // Disable form elements during the request + var inputs = [].concat( + JX.DOM.scry(form, 'input'), + JX.DOM.scry(form, 'button'), + JX.DOM.scry(form, 'textarea')); + for (var ii = 0; ii < inputs.length; ii++) { + if (inputs[ii].disabled) { + delete inputs[ii]; + } else { + inputs[ii].disabled = true; + } + } + + var workflow = new JX.Workflow(form.getAttribute('action'), {}); + workflow.setDataWithListOfPairs(pairs); + workflow.setMethod(form.getAttribute('method')); + workflow.listen('finally', function() { + // Re-enable form elements + for (var ii = 0; ii < inputs.length; ii++) { + inputs[ii] && (inputs[ii].disabled = false); + } + }); + return workflow; + }, + newFromLink : function(link) { + var workflow = new JX.Workflow(link.href); + return workflow; + }, + _push : function(workflow) { + JX.Mask.show(); + JX.Workflow._stack.push(workflow); + }, + _pop : function() { + var dialog = JX.Workflow._stack.pop(); + (dialog.getCloseHandler() || JX.bag)(); + dialog._destroy(); + JX.Mask.hide(); + }, + disable : function() { + JX.Workflow._disabled = true; + }, + _onbutton : function(event) { + + if (JX.Stratcom.pass()) { + return; + } + + if (JX.Workflow._disabled) { + return; + } + + var t = event.getTarget(); + if (t.name == '__cancel__' || t.name == '__close__') { + JX.Workflow._pop(); + } else { + + var form = event.getNode('jx-dialog'); + var data = JX.DOM.convertFormToListOfPairs(form); + data.push([t.name, true]); + + var active = JX.Workflow._getActiveWorkflow(); + var e = active.invoke('submit', {form: form, data: data}); + if (!e.getStopped()) { + active._destroy(); + active + .setURI(form.getAttribute('action') || active.getURI()) + .setDataWithListOfPairs(data) + .start(); + } + } + event.prevent(); + }, + _getActiveWorkflow : function() { + var stack = JX.Workflow._stack; + return stack[stack.length - 1]; + } + }, + + members : { + _root : null, + _pushed : false, + _data : null, + _onload : function(r) { + // It is permissible to send back a falsey redirect to force a page + // reload, so we need to take this branch if the key is present. + if (r && (typeof r.redirect != 'undefined')) { + JX.$U(r.redirect).go(); + } else if (r && r.dialog) { + this._push(); + this._root = JX.$N( + 'div', + {className: 'jx-client-dialog'}, + JX.$H(r.dialog)); + JX.DOM.listen( + this._root, + 'click', + [['jx-workflow-button'], ['tag:button']], + JX.Workflow._onbutton); + document.body.appendChild(this._root); + var d = JX.Vector.getDim(this._root); + var v = JX.Vector.getViewport(); + var s = JX.Vector.getScroll(); + JX.$V((v.x - d.x) / 2, s.y + 100).setPos(this._root); + try { + JX.DOM.focus(JX.DOM.find(this._root, 'button', '__default__')); + var inputs = JX.DOM.scry(this._root, 'input') + .concat(JX.DOM.scry(this._root, 'textarea')); + var miny = Number.POSITIVE_INFINITY; + var target = null; + for (var ii = 0; ii < inputs.length; ++ii) { + if (inputs[ii].type != 'hidden') { + // Find the topleft-most displayed element. + var p = JX.$V(inputs[ii]); + if (p.y < miny) { + miny = p.y; + target = inputs[ii]; + } + } + } + target && JX.DOM.focus(target); + } catch (_ignored) {} + } else if (this.getHandler()) { + this.getHandler()(r); + this._pop(); + } else if (r) { + if (__DEV__) { + JX.$E('Response to workflow request went unhandled.'); + } + } + }, + _push : function() { + if (!this._pushed) { + this._pushed = true; + JX.Workflow._push(this); + } + }, + _pop : function() { + if (this._pushed) { + this._pushed = false; + JX.Workflow._pop(); + } + }, + _destroy : function() { + if (this._root) { + JX.DOM.remove(this._root); + this._root = null; + } + }, + start : function() { + var uri = this.getURI(); + var method = this.getMethod(); + var r = new JX.Request(uri, JX.bind(this, this._onload)); + var list_of_pairs = this._data; + list_of_pairs.push(['__wflow__', true]); + r.setDataWithListOfPairs(list_of_pairs); + r.setDataSerializer(this.getDataSerializer()); + if (method) { + r.setMethod(method); + } + r.listen('finally', JX.bind(this, this.invoke, 'finally')); + r.listen('error', JX.bind(this, function(error) { + var e = this.invoke('error', error); + if (e.getStopped()) { + return; + } + // TODO: Default error behavior? On Facebook Lite, we just shipped the + // user to "/error/". We could emit a blanket 'workflow-failed' type + // event instead. + })); + r.send(); + }, + + setData : function(dictionary) { + this._data = []; + for (var k in dictionary) { + this._data.push([k, dictionary[k]]); + } + return this; + }, + + setDataWithListOfPairs : function(list_of_pairs) { + this._data = list_of_pairs; + return this; + } + }, + + properties : { + handler : null, + closeHandler : null, + dataSerializer : null, + method : null, + URI : null + }, + + initialize : function() { + + function close_dialog_when_user_presses_escape(e) { + if (e.getSpecialKey() != 'esc') { + // Some key other than escape. + return; + } + + if (JX.Workflow._disabled) { + // Workflows are disabled on this page. + return; + } + + if (JX.Stratcom.pass()) { + // Something else swallowed the event. + return; + } + + var active = JX.Workflow._getActiveWorkflow(); + if (!active) { + // No active workflow. + return; + } + + // Note: the cancel button is actually an <a /> tag. + var buttons = JX.DOM.scry(active._root, 'a', 'jx-workflow-button'); + if (!buttons.length) { + // No buttons in the dialog. + return; + } + + var cancel = null; + for (var ii = 0; ii < buttons.length; ii++) { + if (buttons[ii].name == '__cancel__') { + cancel = buttons[ii]; + break; + } + } + + if (!cancel) { + // No 'Cancel' button. + return; + } + + JX.Workflow._pop(); + e.prevent(); + }; + + JX.Stratcom.listen('keydown', null, close_dialog_when_user_presses_escape); + } + +}); Index: externals/javelin/src/lib/__tests__/Cookie.js =================================================================== --- /dev/null +++ externals/javelin/src/lib/__tests__/Cookie.js @@ -0,0 +1,48 @@ +/** + * @requires javelin-cookie + */ + +/* + * These all are hope-and-pray tests because cookies have such a piss poor + * API in HTTP and offer so little insight from JS. This is just a + * supplement to the battle testing the cookie library has. + */ +describe('Javelin Cookie', function() { + + it('should create a cookie string with the correct format', function() { + var doc = { cookie : null }; + var c = new JX.Cookie('omnom'); + c.setValue('nommy'); + c.setDaysToLive(5); + c.setTarget(doc); + c.setPath('/'); + c.setSecure(true); + c.write(); + + // Should be something like: + // omnom=nommy; path=/; expires=Sat, 10 Dec 2011 05:00:34 GMT; Secure; + + expect(doc.cookie).toMatch( + /^omnom=nommy;\sPath=\/;\sExpires=[^;]+;\sSecure;/); + }); + + it('should properly encode and decode special chars in cookie values', + function() { + var value = '!@#$%^&*()?+|/=\\{}[]<>'; + var doc = { cookie : null }; + var c = new JX.Cookie('data'); + c.setTarget(doc); + c.setValue(value); + c.write(); + + var data = doc.cookie.substr(0, doc.cookie.indexOf(';')); + + // Make sure the raw value is all escaped + expect(data).toEqual( + 'data=!%40%23%24%25%5E%26*()%3F%2B%7C%2F%3D%5C%7B%7D%5B%5D%3C%3E'); + + // Make sure the retrieved value is all unescaped + expect(c.read()).toEqual(value); + }); + +}); Index: externals/javelin/src/lib/__tests__/DOM.js =================================================================== --- /dev/null +++ externals/javelin/src/lib/__tests__/DOM.js @@ -0,0 +1,206 @@ +/** + * @requires javelin-uri javelin-php-serializer + */ +describe('JX.DOM', function() { + + describe('uniqID', function() { + it('must expect the unexpected', function() { + // Form with an in <input /> named "id", which collides with the "id" + // attribute. + var form_id = JX.$N('form', {}, JX.$N('input', {name : 'id'})); + var form_ok = JX.$N('form', {}, JX.$N('input', {name : 'ok'})); + + // Test that we avoid issues when "form.id" is actually the node named + // "id". + var id = JX.DOM.uniqID(form_id); + expect(typeof id).toBe('string'); + expect(!!id).toBe(true); + + var ok = JX.DOM.uniqID(form_ok); + expect(typeof ok).toBe('string'); + expect(!!ok).toBe(true); + + expect(id).toNotEqual(ok); + }); + }); + + describe('invoke', function() { + it('should invoke custom events', function() { + var span = JX.$N('span', 'test'); + var div = JX.$N('div', {}, span); + var data = { duck: 'quack' }; + + var invoked = false; + var bubbled = false; + JX.DOM.listen(span, 'custom', null, function(event) { + expect(event.getTarget()).toBe(span); + expect(event.getType()).toBe('custom'); + expect(event.getData()).toBe(data); + invoked = true; + }); + JX.DOM.listen(div, 'custom', null, function(event) { + expect(event.getTarget()).toBe(span); // not div + bubbled = true; + }); + JX.DOM.invoke(span, 'custom', data); + expect(invoked).toBe(true); + expect(bubbled).toBe(true); + }); + + it('should not allow invoking native events', function() { + ensure__DEV__(true, function() { + expect(function() { + JX.DOM.invoke(JX.$N('div'), 'click'); + }).toThrow(); + }); + }); + }); + + + describe('setContent', function() { + var node; + + beforeEach(function() { + node = JX.$N('div'); + }); + + it('should insert a node', function() { + var content = JX.$N('p'); + + JX.DOM.setContent(node, content); + expect(node.childNodes[0]).toEqual(content); + expect(node.childNodes.length).toEqual(1); + }); + + it('should insert two nodes', function() { + var content = [JX.$N('p'), JX.$N('div')]; + + JX.DOM.setContent(node, content); + expect(node.childNodes[0]).toEqual(content[0]); + expect(node.childNodes[1]).toEqual(content[1]); + expect(node.childNodes.length).toEqual(2); + }); + + it('should accept a text node', function() { + var content = 'This is not the text you are looking for'; + + JX.DOM.setContent(node, content); + expect(node.innerText || node.textContent).toEqual(content); + expect(node.childNodes.length).toEqual(1); + }); + + it('should accept nodes and strings in an array', function() { + var content = [ + 'This is not the text you are looking for', + JX.$N('div') + ]; + + JX.DOM.setContent(node, content); + expect(node.childNodes[0].nodeValue).toEqual(content[0]); + expect(node.childNodes[1]).toEqual(content[1]); + expect(node.childNodes.length).toEqual(2); + }); + + it('should accept a JX.HTML instance', function() { + var content = JX.$H('<div />'); + + JX.DOM.setContent(node, content); + // Can not rely on an equals match because JX.HTML creates nodes on + // the fly + expect(node.childNodes[0].tagName).toEqual('DIV'); + expect(node.childNodes.length).toEqual(1); + }); + + it('should accept multiple JX.HTML instances', function() { + var content = [JX.$H('<div />'), JX.$H('<a href="#"></a>')]; + + JX.DOM.setContent(node, content); + expect(node.childNodes[0].tagName).toEqual('DIV'); + expect(node.childNodes[1].tagName).toEqual('A'); + expect(node.childNodes.length).toEqual(2); + }); + + it('should accept nested arrays', function() { + var content = [['a', 'b'], 'c']; + + JX.DOM.setContent(node, content); + expect(node.childNodes.length).toEqual(3); + }); + + it('should retain order when prepending', function() { + var content = [JX.$N('a'), JX.$N('b')]; + + JX.DOM.setContent(node, JX.$N('div')); + JX.DOM.prependContent(node, content); + + expect(node.childNodes[0].tagName).toEqual('A'); + expect(node.childNodes[1].tagName).toEqual('B'); + expect(node.childNodes[2].tagName).toEqual('DIV'); + expect(node.childNodes.length).toEqual(3); + }); + + it('should retain order when doing nested prepends', function() { + // Note nesting. + var content = [[JX.$N('a'), JX.$N('b')]]; + + JX.DOM.prependContent(node, content); + + expect(node.childNodes[0].tagName).toEqual('A'); + expect(node.childNodes[1].tagName).toEqual('B'); + expect(node.childNodes.length).toEqual(2); + }); + + it('should ignore empty elements', function() { + var content = [null, undefined, [], JX.$N('p'), 2, JX.$N('div'), false, + [false, [0], [[]]], [[undefined], [,,,,,,,]]]; + + JX.DOM.setContent(node, content); + expect(node.childNodes[0].tagName).toEqual('P'); + expect(node.childNodes[2].tagName).toEqual('DIV'); + expect(node.childNodes.length).toEqual(4); + }); + + it('should fail when given an object with toString', function() { + // This test is just documenting the behavior of an edge case, we could + // later choose to support these objects. + + var content = {toString : function() { return 'quack'; }}; + + var ex; + try { + // We expect JX.DOM.setContent() to throw an exception when processing + // this object, since it will try to append it directly into the DOM + // and the browser will reject it, as it isn't a node. + JX.DOM.setContent(node, content); + } catch (exception) { + ex = exception; + } + + expect(!!ex).toBe(true); + }); + + it('should not cause array order side effects', function() { + var content = ['a', 'b']; + var original = [].concat(content); + + JX.DOM.prependContent(node, content); + + expect(content).toEqual(original); + }); + + it('should allow numbers', function() { + var content = 3; + + JX.DOM.setContent(node, content); + expect(node.innerText || node.textContent).toEqual('3'); + }); + + it('should work by re-setting a value', function() { + JX.DOM.setContent(node, 'text'); + JX.DOM.setContent(node, 'another text'); + + expect(node.innerText || node.textContent).toEqual('another text'); + }); + }); + +}); Index: externals/javelin/src/lib/__tests__/JSON.js =================================================================== --- /dev/null +++ externals/javelin/src/lib/__tests__/JSON.js @@ -0,0 +1,36 @@ +/** + * @requires javelin-json + */ + +describe('JSON', function() { + + it('should encode and decode an object', function() { + var object = { + a: [0, 1, 2], + s: "Javelin Stuffs", + u: '\x01', + n: 1, + f: 3.14, + b: false, + nil: null, + o: { + a: 1, + b: [1, 2], + c: { + a: 2, + b: 3 + } + } + }; + + expect(JX.JSON.parse(JX.JSON.stringify(object))).toEqual(object); + }); + + it('should encode undefined array indices as null', function() { + var a = []; + a.length = 2; + var o = { x : a }; + expect(JX.JSON.stringify(o)).toEqual('{"x":[null,null]}'); + }); + +}); Index: externals/javelin/src/lib/__tests__/URI.js =================================================================== --- /dev/null +++ externals/javelin/src/lib/__tests__/URI.js @@ -0,0 +1,293 @@ +/** + * @requires javelin-uri javelin-php-serializer + */ +describe('Javelin URI', function() { + + it('should understand parts of a uri', function() { + var uri = JX.$U('http://www.facebook.com:123/home.php?key=value#fragment'); + expect(uri.getProtocol()).toEqual('http'); + expect(uri.getDomain()).toEqual('www.facebook.com'); + expect(uri.getPort()).toEqual('123'); + expect(uri.getPath()).toEqual('/home.php'); + expect(uri.getQueryParams()).toEqual({'key' : 'value'}); + expect(uri.getFragment()).toEqual('fragment'); + }); + + it('can accept null as uri string', function() { + var uri = JX.$U(null); + expect(uri.getProtocol()).toEqual(undefined); + expect(uri.getDomain()).toEqual(undefined); + expect(uri.getPath()).toEqual(undefined); + expect(uri.getQueryParams()).toEqual({}); + expect(uri.getFragment()).toEqual(undefined); + expect(uri.toString()).toEqual(''); + }); + + it('can accept empty string as uri string', function() { + var uri = JX.$U(''); + expect(uri.getProtocol()).toEqual(undefined); + expect(uri.getDomain()).toEqual(undefined); + expect(uri.getPath()).toEqual(undefined); + expect(uri.getQueryParams()).toEqual({}); + expect(uri.getFragment()).toEqual(undefined); + expect(uri.toString()).toEqual(''); + }); + + it('should understand relative uri', function() { + var uri = JX.$U('/home.php?key=value#fragment'); + expect(uri.getProtocol()).toEqual(undefined); + expect(uri.getDomain()).toEqual(undefined); + expect(uri.getPath()).toEqual('/home.php'); + expect(uri.getQueryParams()).toEqual({'key' : 'value'}); + expect(uri.getFragment()).toEqual('fragment'); + }); + + function charRange(from, to) { + res = ''; + for (var i = from.charCodeAt(0); i <= to.charCodeAt(0); i++) { + res += String.fromCharCode(i); + } + return res; + } + + it('should reject unsafe domains', function() { + var unsafe_chars = + '\x00;\\%\u2047\u2048\ufe56\ufe5f\uff03\uff0f\uff1f' + + charRange('\ufdd0', '\ufdef') + charRange('\ufff0', '\uffff'); + for (var i = 0; i < unsafe_chars.length; i++) { + expect(function() { + JX.$U('http://foo' + unsafe_chars.charAt(i) + 'bar'); + }).toThrow(); + } + }); + + it('should allow safe domains', function() { + var safe_chars = + '-._' + charRange('a', 'z') + charRange('A', 'Z') + charRange('0', '9') + + '\u2046\u2049\ufdcf\ufdf0\uffef'; + for (var i = 0; i < safe_chars.length; i++) { + var domain = 'foo' + safe_chars.charAt(i) + 'bar'; + var uri = JX.$U('http://' + domain); + expect(uri.getDomain()).toEqual(domain); + } + }); + + it('should set slash as the default path', function() { + var uri = JX.$U('http://www.facebook.com'); + expect(uri.getPath()).toEqual('/'); + }); + + it('should set empty map as the default query data', function() { + var uri = JX.$U('http://www.facebook.com/'); + expect(uri.getQueryParams()).toEqual({}); + }); + + it('should set undefined as the default fragment', function() { + var uri = JX.$U('http://www.facebook.com/'); + expect(uri.getFragment()).toEqual(undefined); + }); + + it('should understand uri with no path', function() { + var uri = JX.$U('http://www.facebook.com?key=value'); + expect(uri.getPath()).toEqual('/'); + expect(uri.getQueryParams()).toEqual({'key' : 'value'}); + }); + + it('should understand multiple query keys', function() { + var uri = JX.$U('/?clown=town&herp=derp'); + expect(uri.getQueryParams()).toEqual({ + 'clown' : 'town', + 'herp' : 'derp' + }); + }); + + it('does not set keys for nonexistant data', function() { + var uri = JX.$U('/?clown=town'); + expect(uri.getQueryParams().herp).toEqual(undefined); + }); + + it('does not parse different types of query data', function() { + var uri = JX.$U('/?str=string&int=123&bool=true&badbool=false&raw'); + expect(uri.getQueryParams()).toEqual({ + 'str' : 'string', + 'int' : '123', + 'bool' : 'true', + 'badbool' : 'false', + 'raw' : '' + }); + }); + + it('should act as string', function() { + var string = 'http://www.facebook.com/home.php?key=value'; + var uri = JX.$U(string); + expect(uri.toString()).toEqual(string); + expect('' + uri).toEqual(string); + }); + + it('can remove path', function() { + var uri = JX.$U('http://www.facebook.com/home.php?key=value'); + uri.setPath(undefined); + expect(uri.getPath()).toEqual(undefined); + expect(uri.toString()).toEqual('http://www.facebook.com/?key=value'); + }); + + it('can remove queryData by undefining it', function() { + var uri = JX.$U('http://www.facebook.com/home.php?key=value'); + uri.setQueryParams(undefined); + expect(uri.getQueryParams()).toEqual(undefined); + expect(uri.toString()).toEqual('http://www.facebook.com/home.php'); + }); + + it('can remove queryData by replacing it', function() { + var uri = JX.$U('http://www.facebook.com/home.php?key=value'); + uri.setQueryParams({}); + expect(uri.getQueryParams()).toEqual({}); + expect(uri.toString()).toEqual('http://www.facebook.com/home.php'); + }); + + it('can amend to removed queryData', function() { + var uri = JX.$U('http://www.facebook.com/home.php?key=value'); + uri.setQueryParams({}); + expect(uri.getQueryParams()).toEqual({}); + uri.addQueryParams({'herp' : 'derp'}); + expect(uri.getQueryParams()).toEqual({'herp' : 'derp'}); + expect(uri.toString()).toEqual( + 'http://www.facebook.com/home.php?herp=derp'); + }); + + it('should properly decode entities', function() { + var uri = JX.$U('/?from=clown+town&to=cloud%20city&pass=cloud%2Bcountry'); + expect(uri.getQueryParams()).toEqual({ + 'from' : 'clown town', + 'to' : 'cloud city', + 'pass' : 'cloud+country' + }); + expect(uri.toString()).toEqual( + '/?from=clown%20town&to=cloud%20city&pass=cloud%2Bcountry'); + }); + + it('can add query data', function() { + var uri = JX.$U('http://www.facebook.com/'); + uri.addQueryParams({'key' : 'value'}); + expect(uri.getQueryParams()).toEqual({'key' : 'value'}); + expect(uri.toString()).toEqual('http://www.facebook.com/?key=value'); + uri.setQueryParam('key', 'lock'); + expect(uri.getQueryParams()).toEqual({'key' : 'lock'}); + expect(uri.toString()).toEqual('http://www.facebook.com/?key=lock'); + }); + + it('can add different types of query data', function() { + var uri = new JX.URI(); + uri.setQueryParams({ + 'str' : 'string', + 'int' : 123, + 'bool' : true, + 'badbool' : false, + 'raw' : '' + }); + expect(uri.toString()).toEqual( + '?str=string&int=123&bool=true&badbool=false&raw'); + }); + + it('should properly encode entities in added query data', function() { + var uri = new JX.URI(); + uri.addQueryParams({'key' : 'two words'}); + expect(uri.getQueryParams()).toEqual({'key' : 'two words'}); + expect(uri.toString()).toEqual('?key=two%20words'); + }); + + it('can add multiple query data', function() { + var uri = JX.$U('http://www.facebook.com/'); + uri.addQueryParams({ + 'clown' : 'town', + 'herp' : 'derp' + }); + expect(uri.getQueryParams()).toEqual({ + 'clown' : 'town', + 'herp' : 'derp' + }); + expect(uri.toString()).toEqual( + 'http://www.facebook.com/?clown=town&herp=derp'); + }); + + it('can append to existing query data', function() { + var uri = JX.$U('/?key=value'); + uri.addQueryParams({'clown' : 'town'}); + expect(uri.getQueryParams()).toEqual({ + 'key' : 'value', + 'clown' : 'town' + }); + expect(uri.toString()).toEqual('/?key=value&clown=town'); + }); + + it('can merge with existing query data', function() { + var uri = JX.$U('/?key=value&clown=town'); + uri.addQueryParams({ + 'clown' : 'ville', + 'herp' : 'derp' + }); + expect(uri.getQueryParams()).toEqual({ + 'key' : 'value', + 'clown' : 'ville', + 'herp' : 'derp' + }); + expect(uri.toString()).toEqual('/?key=value&clown=ville&herp=derp'); + }); + + it('can replace query data', function() { + var uri = JX.$U('/?key=value&clown=town'); + uri.setQueryParams({'herp' : 'derp'}); + expect(uri.getQueryParams()).toEqual({'herp' : 'derp'}); + expect(uri.toString()).toEqual('/?herp=derp'); + }); + + it('can remove query data', function() { + var uri = JX.$U('/?key=value&clown=town'); + uri.addQueryParams({'key' : null}); + expect(uri.getQueryParams()).toEqual({ + 'clown' : 'town', + 'key' : null + }); + expect(uri.toString()).toEqual('/?clown=town'); + }); + + it('can remove multiple query data', function() { + var uri = JX.$U('/?key=value&clown=town&herp=derp'); + uri.addQueryParams({'key' : null, 'herp' : undefined}); + expect(uri.getQueryParams()).toEqual({ + 'clown' : 'town', + 'key' : null, + 'herp' : undefined + }); + expect(uri.toString()).toEqual('/?clown=town'); + }); + + it('can remove non existant query data', function() { + var uri = JX.$U('/?key=value'); + uri.addQueryParams({'magic' : null}); + expect(uri.getQueryParams()).toEqual({ + 'key' : 'value', + 'magic' : null + }); + expect(uri.toString()).toEqual('/?key=value'); + }); + + it('can build uri from scratch', function() { + var uri = new JX.URI(); + uri.setProtocol('http'); + uri.setDomain('www.facebook.com'); + uri.setPath('/home.php'); + uri.setQueryParams({'key' : 'value'}); + uri.setFragment('fragment'); + expect(uri.toString()).toEqual( + 'http://www.facebook.com/home.php?key=value#fragment'); + }); + + it('no global state interference', function() { + var uri1 = JX.$U('/?key=value'); + var uri2 = JX.$U(); + expect(uri2.getQueryParams()).not.toEqual({'key' : 'value'}); + }); + +}); + Index: externals/javelin/src/lib/__tests__/behavior.js =================================================================== --- /dev/null +++ externals/javelin/src/lib/__tests__/behavior.js @@ -0,0 +1,96 @@ +/** + * @requires javelin-behavior + */ +describe('Javelin Behaviors', function() { + + beforeEach(function() { + // Don't try this at home, kids. + JX.behavior._behaviors = {}; + JX.behavior._initialized = {}; + JX.behavior._statics = {}; + }); + + it('JX.behavior should not work with clowny names', function() { + ensure__DEV__(true, function() { + expect(function() { + JX.behavior('toString', function() {}); + }).toThrow(); + }); + }); + + it('JX.initBehavior should pass a config object', function() { + var called = false; + var config = 'no-value'; + + JX.behavior('my-behavior', function(cfg) { + called = true; + config = cfg; + }); + + JX.initBehaviors({}); + expect(called).toBe(false); + expect(config).toEqual('no-value'); + + called = false; + config = null; + JX.initBehaviors({ 'my-behavior': [] }); + expect(called).toBe(true); + expect(config).toBeNull(); + + called = false; + config = null; + JX.initBehaviors({ 'my-behavior': ['foo'] }); + expect(called).toBe(true); + expect(config).toEqual('foo'); + }); + + it('JX.initBehavior should init a behavior with no config once', function() { + var count = 0; + JX.behavior('foo', function() { + count++; + }); + JX.initBehaviors({ 'foo': [] }); + expect(count).toEqual(1); + JX.initBehaviors({ 'foo': [] }); + expect(count).toEqual(1); + JX.initBehaviors({ 'foo': ['test'] }); + expect(count).toEqual(2); + }); + + it('Behavior statics should persist across behavior invocations', function() { + var expect_value; + var asserted = 0; + JX.behavior('static-test', function(config, statics) { + statics.value = (statics.value || 0) + 1; + expect(statics.value).toBe(expect_value); + asserted++; + }); + + expect_value = 1; + JX.initBehaviors({'static-test' : [{ hog : 0 }]}); + expect_value = 2; + JX.initBehaviors({'static-test' : [{ hog : 0 }]}); + + // Test that we actually invoked the behavior. + expect(asserted).toBe(2); + }); + + it('should throw for undefined behaviors', function() { + var called; + JX.behavior('can-haz', function() { + called = true; + }); + + expect(function() { + JX.initBehaviors({ + 'no-can-haz': [], + 'can-haz': [], + 'i-fail': [] + }); + }).toThrow(); + + expect(called).toBe(true); + }); + +}); + Index: externals/javelin/src/lib/behavior.js =================================================================== --- /dev/null +++ externals/javelin/src/lib/behavior.js @@ -0,0 +1,110 @@ +/** + * @provides javelin-behavior + * @requires javelin-magical-init + * + * @javelin-installs JX.behavior + * @javelin-installs JX.initBehaviors + * + * @javelin + */ + +/** + * Define a Javelin behavior, which holds glue code in a structured way. See + * @{article:Concepts: Behaviors} for a detailed description of Javelin + * behaviors. + * + * To define a behavior, provide a name and a function: + * + * JX.behavior('win-a-hog', function(config, statics) { + * alert("YOU WON A HOG NAMED " + config.hogName + "!"); + * }); + * + * @param string Behavior name. + * @param function Behavior callback/definition. + * @return void + * @group behavior + */ +JX.behavior = function(name, control_function) { + if (__DEV__) { + if (JX.behavior._behaviors.hasOwnProperty(name)) { + JX.$E( + 'JX.behavior("' + name + '", ...): '+ + 'behavior is already registered.'); + } + if (!control_function) { + JX.$E( + 'JX.behavior("' + name + '", <nothing>): '+ + 'initialization function is required.'); + } + if (typeof control_function != 'function') { + JX.$E( + 'JX.behavior("' + name + '", <garbage>): ' + + 'initialization function is not a function.'); + } + // IE does not enumerate over these properties + var enumerables = { + toString: true, + hasOwnProperty: true, + valueOf: true, + isPrototypeOf: true, + propertyIsEnumerable: true, + toLocaleString: true, + constructor: true + }; + if (enumerables[name]) { + JX.$E( + 'JX.behavior("' + name + '", <garbage>): ' + + 'do not use this property as a behavior.' + ); + } + } + JX.behavior._behaviors[name] = control_function; + JX.behavior._statics[name] = {}; +}; + + +/** + * Execute previously defined Javelin behaviors, running the glue code they + * contain to glue stuff together. See @{article:Concepts: Behaviors} for more + * information on Javelin behaviors. + * + * Normally, you do not call this function yourself; instead, your server-side + * library builds it for you. + * + * @param dict Map of behaviors to invoke: keys are behavior names, and values + * are lists of configuration dictionaries. The behavior will be + * invoked once for each configuration dictionary. + * @return void + * @group behavior + */ +JX.initBehaviors = function(map) { + var missing_behaviors = []; + for (var name in map) { + if (!(name in JX.behavior._behaviors)) { + missing_behaviors.push(name); + continue; + } + var configs = map[name]; + if (!configs.length) { + if (JX.behavior._initialized.hasOwnProperty(name)) { + continue; + } + configs = [null]; + } + for (var ii = 0; ii < configs.length; ii++) { + JX.behavior._behaviors[name](configs[ii], JX.behavior._statics[name]); + } + JX.behavior._initialized[name] = true; + } + if (missing_behaviors.length) { + JX.$E( + 'JX.initBehavior(map): behavior(s) not registered: ' + + missing_behaviors.join(', ') + ); + } +}; + +JX.behavior._behaviors = {}; +JX.behavior._statics = {}; +JX.behavior._initialized = {}; +JX.flushHoldingQueue('behavior', JX.behavior); Index: externals/javelin/src/lib/control/tokenizer/Tokenizer.js =================================================================== --- /dev/null +++ externals/javelin/src/lib/control/tokenizer/Tokenizer.js @@ -0,0 +1,384 @@ +/** + * @requires javelin-dom + * javelin-util + * javelin-stratcom + * javelin-install + * @provides javelin-tokenizer + * @javelin + */ + +/** + * A tokenizer is a UI component similar to a text input, except that it + * allows the user to input a list of items ("tokens"), generally from a fixed + * set of results. A familiar example of this UI is the "To:" field of most + * email clients, where the control autocompletes addresses from the user's + * address book. + * + * @{JX.Tokenizer} is built on top of @{JX.Typeahead}, and primarily adds the + * ability to choose multiple items. + * + * To build a @{JX.Tokenizer}, you need to do four things: + * + * 1. Construct it, padding a DOM node for it to attach to. See the constructor + * for more information. + * 2. Build a {@JX.Typeahead} and configure it with setTypeahead(). + * 3. Configure any special options you want. + * 4. Call start(). + * + * If you do this correctly, the input should suggest items and enter them as + * tokens as the user types. + * + * @group control + */ +JX.install('Tokenizer', { + construct : function(containerNode) { + this._containerNode = containerNode; + }, + + events : [ + /** + * Emitted when the value of the tokenizer changes, similar to an 'onchange' + * from a <select />. + */ + 'change'], + + properties : { + limit : null, + nextInput : null + }, + + members : { + _containerNode : null, + _root : null, + _focus : null, + _orig : null, + _typeahead : null, + _tokenid : 0, + _tokens : null, + _tokenMap : null, + _initialValue : null, + _seq : 0, + _lastvalue : null, + _placeholder : null, + + start : function() { + if (__DEV__) { + if (!this._typeahead) { + throw new Error( + 'JX.Tokenizer.start(): ' + + 'No typeahead configured! Use setTypeahead() to provide a ' + + 'typeahead.'); + } + } + + this._orig = JX.DOM.find(this._containerNode, 'input', 'tokenizer-input'); + this._tokens = []; + this._tokenMap = {}; + + var focus = this.buildInput(this._orig.value); + this._focus = focus; + + var input_container = JX.DOM.scry( + this._containerNode, + 'div', + 'tokenizer-input-container' + ); + input_container = input_container[0] || this._containerNode; + + JX.DOM.listen( + focus, + ['click', 'focus', 'blur', 'keydown', 'keypress'], + null, + JX.bind(this, this.handleEvent)); + + JX.DOM.listen( + input_container, + 'click', + null, + JX.bind( + this, + function(e) { + if (e.getNode('remove')) { + this._remove(e.getNodeData('token').key, true); + } else if (e.getTarget() == this._root) { + this.focus(); + } + })); + + var root = JX.$N('div'); + root.id = this._orig.id; + JX.DOM.alterClass(root, 'jx-tokenizer', true); + root.style.cursor = 'text'; + this._root = root; + + root.appendChild(focus); + + var typeahead = this._typeahead; + typeahead.setInputNode(this._focus); + typeahead.start(); + + setTimeout(JX.bind(this, function() { + var container = this._orig.parentNode; + JX.DOM.setContent(container, root); + var map = this._initialValue || {}; + for (var k in map) { + this.addToken(k, map[k]); + } + JX.DOM.appendContent( + root, + JX.$N('div', {style: {clear: 'both'}}) + ); + this._redraw(); + }), 0); + }, + + setInitialValue : function(map) { + this._initialValue = map; + return this; + }, + + setTypeahead : function(typeahead) { + + typeahead.setAllowNullSelection(false); + typeahead.removeListener(); + + typeahead.listen( + 'choose', + JX.bind(this, function(result) { + JX.Stratcom.context().prevent(); + if (this.addToken(result.rel, result.name)) { + if (this.shouldHideResultsOnChoose()) { + this._typeahead.hide(); + } + this._typeahead.clear(); + this._redraw(); + this.focus(); + } + }) + ); + + typeahead.listen( + 'query', + JX.bind( + this, + function(query) { + + // TODO: We should emit a 'query' event here to allow the caller to + // generate tokens on the fly, e.g. email addresses or other freeform + // or algorithmic tokens. + + // Then do this if something handles the event. + // this._focus.value = ''; + // this._redraw(); + // this.focus(); + + if (query.length) { + // Prevent this event if there's any text, so that we don't submit + // the form (either we created a token or we failed to create a + // token; in either case we shouldn't submit). If the query is + // empty, allow the event so that the form submission takes place. + JX.Stratcom.context().prevent(); + } + })); + + this._typeahead = typeahead; + + return this; + }, + + shouldHideResultsOnChoose : function() { + return true; + }, + + handleEvent : function(e) { + + this._typeahead.handleEvent(e); + if (e.getPrevented()) { + return; + } + + if (e.getType() == 'click') { + if (e.getTarget() == this._root) { + this.focus(); + e.prevent(); + return; + } + } else if (e.getType() == 'keydown') { + this._onkeydown(e); + } else if (e.getType() == 'blur') { + this._focus.value = ''; + this._redraw(); + + // Explicitly update the placeholder since we just wiped the field + // value. + this._typeahead.updatePlaceholder(); + } + }, + + refresh : function() { + this._redraw(true); + return this; + }, + + _redraw : function(force) { + + // If there are tokens in the tokenizer, never show a placeholder. + // Otherwise, show one if one is configured. + if (JX.keys(this._tokenMap).length) { + this._typeahead.setPlaceholder(null); + } else { + this._typeahead.setPlaceholder(this._placeholder); + } + + var focus = this._focus; + + if (focus.value === this._lastvalue && !force) { + return; + } + this._lastvalue = focus.value; + + var root = this._root; + var metrics = JX.DOM.textMetrics( + this._focus, + 'jx-tokenizer-metrics'); + metrics.y = null; + metrics.x += 24; + metrics.setDim(focus); + + // This is a pretty ugly hack to force a redraw after copy/paste in + // Firefox. If we don't do this, it doesn't redraw the input so pasting + // in an email address doesn't give you a very good behavior. + focus.value = focus.value; + }, + + setPlaceholder : function(string) { + this._placeholder = string; + return this; + }, + + addToken : function(key, value) { + if (key in this._tokenMap) { + return false; + } + + var focus = this._focus; + var root = this._root; + var token = this.buildToken(key, value); + + this._tokenMap[key] = { + value : value, + key : key, + node : token + }; + this._tokens.push(key); + + root.insertBefore(token, focus); + + this.invoke('change', this); + + return true; + }, + + removeToken : function(key) { + return this._remove(key, false); + }, + + buildInput: function(value) { + return JX.$N('input', { + className: 'jx-tokenizer-input', + type: 'text', + autocomplete: 'off', + value: value + }); + }, + + /** + * Generate a token based on a key and value. The "token" and "remove" + * sigils are observed by a listener in start(). + */ + buildToken: function(key, value) { + var input = JX.$N('input', { + type: 'hidden', + value: key, + name: this._orig.name + '[' + (this._seq++) + ']' + }); + + var remove = JX.$N('a', { + className: 'jx-tokenizer-x', + sigil: 'remove' + }, '\u00d7'); // U+00D7 multiplication sign + + return JX.$N('a', { + className: 'jx-tokenizer-token', + sigil: 'token', + meta: {key: key} + }, [value, input, remove]); + }, + + getTokens : function() { + var result = {}; + for (var key in this._tokenMap) { + result[key] = this._tokenMap[key].value; + } + return result; + }, + + _onkeydown : function(e) { + var focus = this._focus; + var root = this._root; + switch (e.getSpecialKey()) { + case 'tab': + var completed = this._typeahead.submit(); + if (this.getNextInput()) { + if (!completed) { + this._focus.value = ''; + } + setTimeout(JX.bind(this, function() { + this.getNextInput().focus(); + }), 0); + } + break; + case 'delete': + if (!this._focus.value.length) { + var tok; + while (tok = this._tokens.pop()) { + if (this._remove(tok, true)) { + break; + } + } + } + break; + case 'return': + // Don't subject this to token limits. + break; + default: + if (this.getLimit() && + JX.keys(this._tokenMap).length == this.getLimit()) { + e.prevent(); + } + setTimeout(JX.bind(this, this._redraw), 0); + break; + } + }, + + _remove : function(index, focus) { + if (!this._tokenMap[index]) { + return false; + } + JX.DOM.remove(this._tokenMap[index].node); + delete this._tokenMap[index]; + this._redraw(true); + focus && this.focus(); + + this.invoke('change', this); + + return true; + }, + + focus : function() { + var focus = this._focus; + JX.DOM.show(focus); + setTimeout(function() { JX.DOM.focus(focus); }, 0); + } + } +}); Index: externals/javelin/src/lib/control/typeahead/Typeahead.js =================================================================== --- /dev/null +++ externals/javelin/src/lib/control/typeahead/Typeahead.js @@ -0,0 +1,507 @@ +/** + * @requires javelin-install + * javelin-dom + * javelin-vector + * javelin-util + * @provides javelin-typeahead + * @javelin + */ + +/** + * A typeahead is a UI component similar to a text input, except that it + * suggests some set of results (like friends' names, common searches, or + * repository paths) as the user types them. Familiar examples of this UI + * include Google Suggest, the Facebook search box, and OS X's Spotlight + * feature. + * + * To build a @{JX.Typeahead}, you need to do four things: + * + * 1. Construct it, passing some DOM nodes for it to attach to. See the + * constructor for more information. + * 2. Attach a datasource by calling setDatasource() with a valid datasource, + * often a @{JX.TypeaheadPreloadedSource}. + * 3. Configure any special options that you want. + * 4. Call start(). + * + * If you do this correctly, a dropdown menu should appear under the input as + * the user types, suggesting matching results. + * + * @task build Building a Typeahead + * @task datasource Configuring a Datasource + * @task config Configuring Options + * @task start Activating a Typeahead + * @task control Controlling Typeaheads from Javascript + * @task internal Internal Methods + * @group control + */ +JX.install('Typeahead', { + /** + * Construct a new Typeahead on some "hardpoint". At a minimum, the hardpoint + * should be a ##<div>## with "position: relative;" wrapped around a text + * ##<input>##. The typeahead's dropdown suggestions will be appended to the + * hardpoint in the DOM. Basically, this is the bare minimum requirement: + * + * LANG=HTML + * <div style="position: relative;"> + * <input type="text" /> + * </div> + * + * Then get a reference to the ##<div>## and pass it as 'hardpoint', and pass + * the ##<input>## as 'control'. This will enhance your boring old + * ##<input />## with amazing typeahead powers. + * + * On the Facebook/Tools stack, ##<javelin:typeahead-template />## can build + * this for you. + * + * @param Node "Hardpoint", basically an anchorpoint in the document which + * the typeahead can append its suggestion menu to. + * @param Node? Actual ##<input />## to use; if not provided, the typeahead + * will just look for a (solitary) input inside the hardpoint. + * @task build + */ + construct : function(hardpoint, control) { + this._hardpoint = hardpoint; + this._control = control || JX.DOM.find(hardpoint, 'input'); + + this._root = JX.$N( + 'div', + {className: 'jx-typeahead-results'}); + this._display = []; + + this._listener = JX.DOM.listen( + this._control, + ['focus', 'blur', 'keypress', 'keydown', 'input'], + null, + JX.bind(this, this.handleEvent)); + + JX.DOM.listen( + this._root, + ['mouseover', 'mouseout'], + null, + JX.bind(this, this._onmouse)); + + JX.DOM.listen( + this._root, + 'mousedown', + 'tag:a', + JX.bind(this, function(e) { + if (!e.isRightButton()) { + this._choose(e.getNode('tag:a')); + } + })); + + }, + + events : ['choose', 'query', 'start', 'change', 'show'], + + properties : { + + /** + * Boolean. If true (default), the user is permitted to submit the typeahead + * with a custom or empty selection. This is a good behavior if the + * typeahead is attached to something like a search input, where the user + * might type a freeform query or select from a list of suggestions. + * However, sometimes you require a specific input (e.g., choosing which + * user owns something), in which case you can prevent null selections. + * + * @task config + */ + allowNullSelection : true + }, + + members : { + _root : null, + _control : null, + _hardpoint : null, + _listener : null, + _value : null, + _stop : false, + _focus : -1, + _focused : false, + _placeholderVisible : false, + _placeholder : null, + _display : null, + _datasource : null, + _waitingListener : null, + _readyListener : null, + + /** + * Activate your properly configured typeahead. It won't do anything until + * you call this method! + * + * @task start + * @return void + */ + start : function() { + this.invoke('start'); + if (__DEV__) { + if (!this._datasource) { + throw new Error( + "JX.Typeahead.start(): " + + "No datasource configured. Create a datasource and call " + + "setDatasource()."); + } + } + this.updatePlaceholder(); + }, + + + /** + * Configure a datasource, which is where the Typeahead gets suggestions + * from. See @{JX.TypeaheadDatasource} for more information. You must + * provide exactly one datasource. + * + * @task datasource + * @param JX.TypeaheadDatasource The datasource which the typeahead will + * draw from. + */ + setDatasource : function(datasource) { + if (this._datasource) { + this._datasource.unbindFromTypeahead(); + this._waitingListener.remove(); + this._readyListener.remove(); + } + this._waitingListener = datasource.listen( + 'waiting', + JX.bind(this, this.waitForResults) + ); + this._readyListener = datasource.listen( + 'resultsready', + JX.bind(this, this.showResults) + ); + datasource.bindToTypeahead(this); + this._datasource = datasource; + }, + + getDatasource : function() { + return this._datasource; + }, + + /** + * Override the <input /> selected in the constructor with some other input. + * This is primarily useful when building a control on top of the typeahead, + * like @{JX.Tokenizer}. + * + * @task config + * @param node An <input /> node to use as the primary control. + */ + setInputNode : function(input) { + this._control = input; + return this; + }, + + + /** + * Hide the typeahead's dropdown suggestion menu. + * + * @task control + * @return void + */ + hide : function() { + this._changeFocus(Number.NEGATIVE_INFINITY); + this._display = []; + this._moused = false; + JX.DOM.hide(this._root); + }, + + + /** + * Show a given result set in the typeahead's dropdown suggestion menu. + * Normally, you don't call this method directly. Usually it gets called + * in response to events from the datasource you have configured. + * + * @task control + * @param list List of ##<a />## tags to show as suggestions/results. + * @return void + */ + showResults : function(results) { + var obj = {show: results}; + var e = this.invoke('show', obj); + + // Note that the results list may have been update by the "show" event + // listener. Non-result node (e.g. divider or label) may have been + // inserted. + JX.DOM.setContent(this._root, results); + this._display = JX.DOM.scry(this._root, 'a', 'typeahead-result'); + + if (this._display.length && !e.getPrevented()) { + this._changeFocus(Number.NEGATIVE_INFINITY); + var d = JX.Vector.getDim(this._hardpoint); + d.x = 0; + d.setPos(this._root); + if (this._root.parentNode !== this._hardpoint) { + this._hardpoint.appendChild(this._root); + } + JX.DOM.show(this._root); + } else { + this.hide(); + JX.DOM.setContent(this._root, null); + } + }, + + refresh : function() { + if (this._stop) { + return; + } + + this._value = this._control.value; + this.invoke('change', this._value); + }, + /** + * Show a "waiting for results" UI in place of the typeahead's dropdown + * suggestion menu. NOTE: currently there's no such UI, lolol. + * + * @task control + * @return void + */ + waitForResults : function() { + // TODO: Build some sort of fancy spinner or "..." type UI here to + // visually indicate that we're waiting on the server. + // Wait on the datasource 'complete' event for hiding the spinner. + this.hide(); + }, + + /** + * @task internal + */ + _onmouse : function(event) { + this._moused = (event.getType() == 'mouseover'); + this._drawFocus(); + }, + + + /** + * @task internal + */ + _changeFocus : function(d) { + var n = Math.min(Math.max(-1, this._focus + d), this._display.length - 1); + if (!this.getAllowNullSelection()) { + n = Math.max(0, n); + } + if (this._focus >= 0 && this._focus < this._display.length) { + JX.DOM.alterClass(this._display[this._focus], 'focused', false); + } + this._focus = n; + this._drawFocus(); + return true; + }, + + + /** + * @task internal + */ + _drawFocus : function() { + var f = this._display[this._focus]; + if (f) { + JX.DOM.alterClass(f, 'focused', !this._moused); + } + }, + + + /** + * @task internal + */ + _choose : function(target) { + var result = this.invoke('choose', target); + if (result.getPrevented()) { + return; + } + + this._control.value = target.name; + this.hide(); + }, + + + /** + * @task control + */ + clear : function() { + this._control.value = ''; + this._value = ''; + this.hide(); + }, + + + /** + * @task control + */ + enable : function() { + this._control.disabled = false; + this._stop = false; + }, + + + /** + * @task control + */ + disable : function() { + this._control.blur(); + this._control.disabled = true; + this._stop = true; + }, + + + /** + * @task control + */ + submit : function() { + if (this._focus >= 0 && this._display[this._focus]) { + this._choose(this._display[this._focus]); + return true; + } else { + result = this.invoke('query', this._control.value); + if (result.getPrevented()) { + return true; + } + } + return false; + }, + + setValue : function(value) { + this._control.value = value; + }, + + getValue : function() { + return this._control.value; + }, + + /** + * @task internal + */ + _update : function(event) { + + if (event.getType() == 'focus') { + this._focused = true; + this.updatePlaceholder(); + } + + var k = event.getSpecialKey(); + if (k && event.getType() == 'keydown') { + switch (k) { + case 'up': + if (this._display.length && this._changeFocus(-1)) { + event.prevent(); + } + break; + case 'down': + if (this._display.length && this._changeFocus(1)) { + event.prevent(); + } + break; + case 'return': + if (this.submit()) { + event.prevent(); + return; + } + break; + case 'esc': + if (this._display.length && this.getAllowNullSelection()) { + this.hide(); + event.prevent(); + } + break; + case 'tab': + // If the user tabs out of the field, don't refresh. + return; + } + } + + // We need to defer because the keystroke won't be present in the input's + // value field yet. + setTimeout(JX.bind(this, function() { + if (this._value == this._control.value) { + // The typeahead value hasn't changed. + return; + } + this.refresh(); + }), 0); + }, + + /** + * This method is pretty much internal but @{JX.Tokenizer} needs access to + * it for delegation. You might also need to delegate events here if you + * build some kind of meta-control. + * + * Reacts to user events in accordance to configuration. + * + * @task internal + * @param JX.Event User event, like a click or keypress. + * @return void + */ + handleEvent : function(e) { + if (this._stop || e.getPrevented()) { + return; + } + var type = e.getType(); + if (type == 'blur') { + this._focused = false; + this.updatePlaceholder(); + this.hide(); + } else { + this._update(e); + } + }, + + removeListener : function() { + if (this._listener) { + this._listener.remove(); + } + }, + + + /** + * Set a string to display in the control when it is not focused, like + * "Type a user's name...". This string hints to the user how to use the + * control. + * + * When the string is displayed, the input will have class + * "jx-typeahead-placeholder". + * + * @param string Placeholder string, or null for no placeholder. + * @return this + * + * @task config + */ + setPlaceholder : function(string) { + this._placeholder = string; + this.updatePlaceholder(); + return this; + }, + + + /** + * Update the control to either show or hide the placeholder text as + * necessary. + * + * @return void + * @task internal + */ + updatePlaceholder : function() { + + if (this._placeholderVisible) { + // If the placeholder is visible, we want to hide if the control has + // been focused or the placeholder has been removed. + if (this._focused || !this._placeholder) { + this._placeholderVisible = false; + this._control.value = ''; + } + } else if (!this._focused) { + // If the placeholder is not visible, we want to show it if the control + // has benen blurred. + if (this._placeholder && !this._control.value) { + this._placeholderVisible = true; + } + } + + if (this._placeholderVisible) { + // We need to resist the Tokenizer wiping the input on blur. + this._control.value = this._placeholder; + } + + JX.DOM.alterClass( + this._control, + 'jx-typeahead-placeholder', + this._placeholderVisible); + } + } +}); Index: externals/javelin/src/lib/control/typeahead/normalizer/TypeaheadNormalizer.js =================================================================== --- /dev/null +++ externals/javelin/src/lib/control/typeahead/normalizer/TypeaheadNormalizer.js @@ -0,0 +1,27 @@ +/** + * @requires javelin-install + * @provides javelin-typeahead-normalizer + * @javelin + */ + +/** + * @group control + */ +JX.install('TypeaheadNormalizer', { + statics : { + /** + * Normalizes a string by lowercasing it and stripping out extra spaces + * and punctuation. + * + * @param string + * @return string Normalized string. + */ + normalize : function(str) { + return ('' + str) + .toLocaleLowerCase() + .replace(/[\.,-\/#!$%\^&\*;:{}=\-_`~()]/g, '') + .replace(/ +/g, ' ') + .replace(/^\s*|\s*$/g, ''); + } + } +}); Index: externals/javelin/src/lib/control/typeahead/source/TypeaheadCompositeSource.js =================================================================== --- /dev/null +++ externals/javelin/src/lib/control/typeahead/source/TypeaheadCompositeSource.js @@ -0,0 +1,78 @@ +/** + * @requires javelin-install + * javelin-typeahead-source + * javelin-util + * @provides javelin-typeahead-composite-source + * @javelin + */ + +/** + * @group control + */ +JX.install('TypeaheadCompositeSource', { + + extend : 'TypeaheadSource', + + construct : function(sources) { + JX.TypeaheadSource.call(this); + this.sources = sources; + + for (var ii = 0; ii < this.sources.length; ++ii) { + var child = this.sources[ii]; + child.listen('waiting', JX.bind(this, this.childWaiting)); + child.listen('resultsready', JX.bind(this, this.childResultsReady)); + child.listen('complete', JX.bind(this, this.childComplete)); + } + }, + + members : { + sources : null, + results : null, + completeCount : 0, + + didChange : function(value) { + this.results = []; + this.completeCount = 0; + for (var ii = 0; ii < this.sources.length; ++ii) { + this.sources[ii].didChange(value); + } + }, + + didStart : function() { + for (var ii = 0; ii < this.sources.length; ++ii) { + this.sources[ii].didStart(); + } + }, + + childWaiting : function() { + if (!this.results || !this.results.length) { + this.invoke('waiting'); + } + }, + + childResultsReady : function(nodes) { + this.results = this.mergeResults(this.results || [], nodes); + this.invoke('resultsready', this.results); + }, + + childComplete : function() { + this.completeCount++; + if (this.completeCount == this.sources.length) { + this.invoke('complete'); + } + }, + + /** + * Overrideable strategy for combining results. + * By default, appends results as they come in + * so that results don't jump around. + */ + mergeResults : function(oldResults, newResults) { + for (var ii = 0; ii < newResults.length; ++ii) { + oldResults.push(newResults[ii]); + } + return oldResults; + } + } +}); + Index: externals/javelin/src/lib/control/typeahead/source/TypeaheadOnDemandSource.js =================================================================== --- /dev/null +++ externals/javelin/src/lib/control/typeahead/source/TypeaheadOnDemandSource.js @@ -0,0 +1,87 @@ +/** + * @requires javelin-install + * javelin-util + * javelin-stratcom + * javelin-request + * javelin-typeahead-source + * @provides javelin-typeahead-ondemand-source + * @javelin + */ + +/** + * @group control + */ +JX.install('TypeaheadOnDemandSource', { + + extend : 'TypeaheadSource', + + construct : function(uri) { + JX.TypeaheadSource.call(this); + this.uri = uri; + this.haveData = { + '' : true + }; + }, + + properties : { + /** + * Configures how many milliseconds we wait after the user stops typing to + * send a request to the server. Setting a value of 250 means "wait 250 + * milliseconds after the user stops typing to request typeahead data". + * Higher values reduce server load but make the typeahead less responsive. + */ + queryDelay : 125, + /** + * Auxiliary data to pass along when sending the query for server results. + */ + auxiliaryData : {} + }, + + members : { + uri : null, + lastChange : null, + haveData : null, + + didChange : function(value) { + this.lastChange = JX.now(); + value = this.normalize(value); + + if (this.haveData[value]) { + this.matchResults(value); + } else { + this.waitForResults(); + setTimeout( + JX.bind(this, this.sendRequest, this.lastChange, value), + this.getQueryDelay() + ); + } + }, + + sendRequest : function(when, value) { + if (when != this.lastChange) { + return; + } + var r = new JX.Request( + this.uri, + JX.bind(this, this.ondata, this.lastChange, value)); + r.setMethod('GET'); + r.setData(JX.copy(this.getAuxiliaryData(), {q : value})); + r.send(); + }, + + ondata : function(when, value, results) { + if (results) { + for (var ii = 0; ii < results.length; ii++) { + this.addResult(results[ii]); + } + } + this.haveData[value] = true; + if (when != this.lastChange) { + return; + } + this.matchResults(value); + } + } +}); + + Index: externals/javelin/src/lib/control/typeahead/source/TypeaheadPreloadedSource.js =================================================================== --- /dev/null +++ externals/javelin/src/lib/control/typeahead/source/TypeaheadPreloadedSource.js @@ -0,0 +1,62 @@ +/** + * @requires javelin-install + * javelin-util + * javelin-stratcom + * javelin-request + * javelin-typeahead-source + * @provides javelin-typeahead-preloaded-source + * @javelin + */ + +/** + * Simple datasource that loads all possible results from a single call to a + * URI. This is appropriate if the total data size is small (up to perhaps a + * few thousand items). If you have more items so you can't ship them down to + * the client in one repsonse, use @{JX.TypeaheadOnDemandSource}. + * + * @group control + */ +JX.install('TypeaheadPreloadedSource', { + + extend : 'TypeaheadSource', + + construct : function(uri) { + JX.TypeaheadSource.call(this); + this.uri = uri; + }, + + members : { + + ready : false, + uri : null, + lastValue : null, + + didChange : function(value) { + if (this.ready) { + this.matchResults(value); + } else { + this.lastValue = value; + this.waitForResults(); + } + }, + + didStart : function() { + var r = new JX.Request(this.uri, JX.bind(this, this.ondata)); + r.setMethod('GET'); + r.send(); + }, + + ondata : function(results) { + for (var ii = 0; ii < results.length; ++ii) { + this.addResult(results[ii]); + } + if (this.lastValue !== null) { + this.matchResults(this.lastValue); + } + this.ready = true; + } + } +}); + + + Index: externals/javelin/src/lib/control/typeahead/source/TypeaheadSource.js =================================================================== --- /dev/null +++ externals/javelin/src/lib/control/typeahead/source/TypeaheadSource.js @@ -0,0 +1,349 @@ +/** + * @requires javelin-install + * javelin-util + * javelin-dom + * javelin-typeahead-normalizer + * @provides javelin-typeahead-source + * @javelin + */ + +/** + * @group control + */ +JX.install('TypeaheadSource', { + construct : function() { + this._raw = {}; + this._lookup = {}; + this.setNormalizer(JX.TypeaheadNormalizer.normalize); + this._excludeIDs = {}; + }, + + events : ['waiting', 'resultsready', 'complete'], + + properties : { + + /** + * Allows you to specify a function which will be used to normalize strings. + * Strings are normalized before being tokenized, and before being sent to + * the server. The purpose of normalization is to strip out irrelevant data, + * like uppercase/lowercase, extra spaces, or punctuation. By default, + * the @{JX.TypeaheadNormalizer} is used to normalize strings, but you may + * want to provide a different normalizer, particiularly if there are + * special characters with semantic meaning in your object names. + * + * @param function + */ + normalizer : null, + + /** + * If a typeahead query should be processed before being normalized and + * tokenized, specify a queryExtractor. + * + * @param function + */ + queryExtractor : null, + + /** + * Transformers convert data from a wire format to a runtime format. The + * transformation mechanism allows you to choose an efficient wire format + * and then expand it on the client side, rather than duplicating data + * over the wire. The transformation is applied to objects passed to + * addResult(). It should accept whatever sort of object you ship over the + * wire, and produce a dictionary with these keys: + * + * - **id**: a unique id for each object. + * - **name**: the string used for matching against user input. + * - **uri**: the URI corresponding with the object (must be present + * but need not be meaningful) + * + * You can also give: + * - **display**: the text or nodes to show in the DOM. Usually just the + * same as ##name##. + * - **tokenizable**: if you want to tokenize something other than the + * ##name##, for the typeahead to complete on, specify it here. A + * selected entry from the typeahead will still insert the ##name## + * into the input, but the ##tokenizable## field lets you complete on + * non-name things. + * + * The default transformer expects a three element list with elements + * [name, uri, id]. It assigns the first element to both ##name## and + * ##display##. + * + * @param function + */ + transformer : null, + + /** + * Configures the maximum number of suggestions shown in the typeahead + * dropdown. + * + * @param int + */ + maximumResultCount : 5, + + /** + * Optional function which is used to sort results. Inputs are the input + * string, the list of matches, and a default comparator. The function + * should sort the list for display. This is the minimum useful + * implementation: + * + * function(value, list, comparator) { + * list.sort(comparator); + * } + * + * Alternatively, you may pursue more creative implementations. + * + * The `value` is a raw string; you can bind the datasource into the + * function and use normalize() or tokenize() to parse it. + * + * The `list` is a list of objects returned from the transformer function, + * see the `transformer` property. These are the objects in the list which + * match the value. + * + * The `comparator` is a sort callback which implements sensible default + * sorting rules (e.g., alphabetic order), which you can use as a fallback + * if you just want to tweak the results (e.g., put some items at the top). + * + * The function is called after the user types some text, immediately before + * the possible completion results are displayed to the user. + * + * @param function + */ + sortHandler : null + + }, + + members : { + _raw : null, + _lookup : null, + _excludeIDs : null, + _changeListener : null, + _startListener : null, + + bindToTypeahead : function(typeahead) { + this._changeListener = typeahead.listen( + 'change', + JX.bind(this, this.didChange) + ); + this._startListener = typeahead.listen( + 'start', + JX.bind(this, this.didStart) + ); + }, + + unbindFromTypeahead : function() { + this._changeListener.remove(); + this._startListener.remove(); + }, + + didChange : function(value) { + return; + }, + + didStart : function() { + return; + }, + + clearCache : function() { + this._raw = {}; + this._lookup = {}; + }, + + addExcludeID : function(id) { + if (id) { + this._excludeIDs[id] = true; + } + }, + + removeExcludeID : function (id) { + if (id) { + delete this._excludeIDs[id]; + } + }, + + addResult : function(obj) { + obj = (this.getTransformer() || this._defaultTransformer)(obj); + + if (obj.id in this._raw) { + // We're already aware of this result. This will happen if someone + // searches for "zeb" and then for "zebra" with a + // TypeaheadRequestSource, for example, or the datasource just doesn't + // dedupe things properly. Whatever the case, just ignore it. + return; + } + + if (__DEV__) { + for (var k in {name : 1, id : 1, display : 1, uri : 1}) { + if (!(k in obj)) { + throw new Error( + "JX.TypeaheadSource.addResult(): " + + "result must have properties 'name', 'id', 'uri' and 'display'."); + } + } + } + + this._raw[obj.id] = obj; + var t = this.tokenize(obj.tokenizable || obj.name); + for (var jj = 0; jj < t.length; ++jj) { + this._lookup[t[jj]] = this._lookup[t[jj]] || []; + this._lookup[t[jj]].push(obj.id); + } + }, + + waitForResults : function() { + this.invoke('waiting'); + return this; + }, + + + /** + * Get the raw state of a result by its ID. A number of other events and + * mechanisms give a list of result IDs and limited additional data; if you + * need to act on the full result data you can look it up here. + * + * @param scalar Result ID. + * @return dict Corresponding raw result. + */ + getResult : function(id) { + return this._raw[id]; + }, + + + matchResults : function(value) { + + // This table keeps track of the number of tokens each potential match + // has actually matched. When we're done, the real matches are those + // which have matched every token (so the value is equal to the token + // list length). + var match_count = {}; + + // This keeps track of distinct matches. If the user searches for + // something like "Chris C" against "Chris Cox", the "C" will match + // both fragments. We need to make sure we only count distinct matches. + var match_fragments = {}; + + var matched = {}; + var seen = {}; + + var query_extractor = this.getQueryExtractor(); + if (query_extractor) { + value = query_extractor(value); + } + var t = this.tokenize(value); + + // Sort tokens by longest-first. We match each name fragment with at + // most one token. + t.sort(function(u, v) { return v.length - u.length; }); + + for (var ii = 0; ii < t.length; ++ii) { + // Do something reasonable if the user types the same token twice; this + // is sort of stupid so maybe kill it? + if (t[ii] in seen) { + t.splice(ii--, 1); + continue; + } + seen[t[ii]] = true; + var fragment = t[ii]; + for (var name_fragment in this._lookup) { + if (name_fragment.substr(0, fragment.length) === fragment) { + if (!(name_fragment in matched)) { + matched[name_fragment] = true; + } else { + continue; + } + var l = this._lookup[name_fragment]; + for (var jj = 0; jj < l.length; ++jj) { + var match_id = l[jj]; + if (!match_fragments[match_id]) { + match_fragments[match_id] = {}; + } + if (!(fragment in match_fragments[match_id])) { + match_fragments[match_id][fragment] = true; + match_count[match_id] = (match_count[match_id] || 0) + 1; + } + } + } + } + } + + var hits = []; + for (var k in match_count) { + if (match_count[k] == t.length && !this._excludeIDs[k]) { + hits.push(k); + } + } + + this.sortHits(value, hits); + + var nodes = this.renderNodes(value, hits); + this.invoke('resultsready', nodes); + this.invoke('complete'); + }, + + sortHits : function(value, hits) { + var objs = []; + for (var ii = 0; ii < hits.length; ii++) { + objs.push(this._raw[hits[ii]]); + } + + var default_comparator = function(u, v) { + var key_u = u.sort || u.name; + var key_v = v.sort || v.name; + return key_u.localeCompare(key_v); + }; + + var handler = this.getSortHandler() || function(value, list, cmp) { + list.sort(cmp); + }; + + handler(value, objs, default_comparator); + + hits.splice(0, hits.length); + for (var ii = 0; ii < objs.length; ii++) { + hits.push(objs[ii].id); + } + }, + + renderNodes : function(value, hits) { + var n = Math.min(this.getMaximumResultCount(), hits.length); + var nodes = []; + for (var kk = 0; kk < n; kk++) { + nodes.push(this.createNode(this._raw[hits[kk]])); + } + return nodes; + }, + + createNode : function(data) { + return JX.$N( + 'a', + { + sigil: 'typeahead-result', + href: data.uri, + name: data.name, + rel: data.id, + className: 'jx-result' + }, + data.display + ); + }, + + normalize : function(str) { + return this.getNormalizer()(str); + }, + tokenize : function(str) { + str = this.normalize(str); + if (!str.length) { + return []; + } + return str.split(/\s/g); + }, + _defaultTransformer : function(object) { + return { + name : object[0], + display : object[0], + uri : object[1], + id : object[2] + }; + } + } +}); Index: externals/javelin/src/lib/control/typeahead/source/TypeaheadStaticSource.js =================================================================== --- /dev/null +++ externals/javelin/src/lib/control/typeahead/source/TypeaheadStaticSource.js @@ -0,0 +1,40 @@ +/** + * @requires javelin-install + * javelin-typeahead-source + * @provides javelin-typeahead-static-source + * @javelin + */ + +/** + * Typeahead source that uses static data passed to the constructor. For larger + * datasets, use @{class:JX.TypeaheadPreloadedSource} or + * @{class:JX.TypeaheadOnDemandSource} to improve performance. + * + * @group control + */ +JX.install('TypeaheadStaticSource', { + + extend : 'TypeaheadSource', + + construct : function(data) { + JX.TypeaheadSource.call(this); + this._data = data; + }, + + members : { + _data : null, + + didChange : function(value) { + this.matchResults(value); + }, + + didStart : function() { + for (var ii = 0; ii < this._data.length; ii++) { + this.addResult(this._data[ii]); + } + } + } +}); + + + Index: scripts/install/install_rhel-derivs.sh =================================================================== --- scripts/install/install_rhel-derivs.sh +++ scripts/install/install_rhel-derivs.sh @@ -138,8 +138,6 @@ (cd phabricator && git pull --rebase) fi -(cd phabricator && git submodule update --init) - echo echo echo "Install probably worked mostly correctly. Continue with the 'Configuration Guide':"; Index: scripts/install/install_ubuntu.sh =================================================================== --- scripts/install/install_ubuntu.sh +++ scripts/install/install_ubuntu.sh @@ -81,8 +81,6 @@ (cd phabricator && git pull --rebase) fi -(cd phabricator && git submodule update --init) - echo echo echo "Install probably worked mostly correctly. Continue with the 'Configuration Guide':"; Index: scripts/install/update_phabricator.sh =================================================================== --- scripts/install/update_phabricator.sh +++ scripts/install/update_phabricator.sh @@ -28,7 +28,6 @@ cd $ROOT/phabricator git pull -git submodule update --init ### RUN TESTS ################################################################## Index: src/__celerity_resource_map__.php =================================================================== --- src/__celerity_resource_map__.php +++ src/__celerity_resource_map__.php @@ -3192,7 +3192,7 @@ ), 'sprite-icon-css' => array( - 'uri' => '/res/698745d1/rsrc/css/sprite-icon.css', + 'uri' => '/res/e7d63fcf/rsrc/css/sprite-icon.css', 'type' => 'css', 'requires' => array( @@ -3238,7 +3238,7 @@ ), array( 'packages' => array( - 57036208 => + '86c4a3b2' => array( 'name' => 'core.pkg.css', 'symbols' => @@ -3282,7 +3282,7 @@ 36 => 'phabricator-object-item-list-view-css', 37 => 'global-drag-and-drop-css', ), - 'uri' => '/res/pkg/57036208/core.pkg.css', + 'uri' => '/res/pkg/86c4a3b2/core.pkg.css', 'type' => 'css', ), 'c90b892e' => @@ -3472,19 +3472,19 @@ 'reverse' => array( 'aphront-attached-file-view-css' => '83f07678', - 'aphront-crumbs-view-css' => '57036208', - 'aphront-dialog-view-css' => '57036208', - 'aphront-error-view-css' => '57036208', - 'aphront-form-view-css' => '57036208', + 'aphront-crumbs-view-css' => '86c4a3b2', + 'aphront-dialog-view-css' => '86c4a3b2', + 'aphront-error-view-css' => '86c4a3b2', + 'aphront-form-view-css' => '86c4a3b2', 'aphront-headsup-action-list-view-css' => 'ec01d039', - 'aphront-headsup-view-css' => '57036208', - 'aphront-list-filter-view-css' => '57036208', - 'aphront-pager-view-css' => '57036208', - 'aphront-panel-view-css' => '57036208', - 'aphront-table-view-css' => '57036208', - 'aphront-tokenizer-control-css' => '57036208', - 'aphront-tooltip-css' => '57036208', - 'aphront-typeahead-control-css' => '57036208', + 'aphront-headsup-view-css' => '86c4a3b2', + 'aphront-list-filter-view-css' => '86c4a3b2', + 'aphront-pager-view-css' => '86c4a3b2', + 'aphront-panel-view-css' => '86c4a3b2', + 'aphront-table-view-css' => '86c4a3b2', + 'aphront-tokenizer-control-css' => '86c4a3b2', + 'aphront-tooltip-css' => '86c4a3b2', + 'aphront-typeahead-control-css' => '86c4a3b2', 'differential-changeset-view-css' => 'ec01d039', 'differential-core-view-css' => 'ec01d039', 'differential-inline-comment-editor' => 'ac53d36a', @@ -3498,7 +3498,7 @@ 'differential-table-of-contents-css' => 'ec01d039', 'diffusion-commit-view-css' => 'c8ce2d88', 'diffusion-icons-css' => 'c8ce2d88', - 'global-drag-and-drop-css' => '57036208', + 'global-drag-and-drop-css' => '86c4a3b2', 'inline-comment-summary-css' => 'ec01d039', 'javelin-aphlict' => 'c90b892e', 'javelin-behavior' => 'fbeded59', @@ -3568,48 +3568,48 @@ 'javelin-util' => 'fbeded59', 'javelin-vector' => 'fbeded59', 'javelin-workflow' => 'fbeded59', - 'lightbox-attachment-css' => '57036208', + 'lightbox-attachment-css' => '86c4a3b2', 'maniphest-task-summary-css' => '83f07678', 'maniphest-transaction-detail-css' => '83f07678', 'phabricator-busy' => 'c90b892e', 'phabricator-content-source-view-css' => 'ec01d039', - 'phabricator-core-buttons-css' => '57036208', - 'phabricator-core-css' => '57036208', - 'phabricator-crumbs-view-css' => '57036208', - 'phabricator-directory-css' => '57036208', + 'phabricator-core-buttons-css' => '86c4a3b2', + 'phabricator-core-css' => '86c4a3b2', + 'phabricator-crumbs-view-css' => '86c4a3b2', + 'phabricator-directory-css' => '86c4a3b2', 'phabricator-drag-and-drop-file-upload' => 'ac53d36a', 'phabricator-dropdown-menu' => 'c90b892e', 'phabricator-file-upload' => 'c90b892e', - 'phabricator-filetree-view-css' => '57036208', - 'phabricator-flag-css' => '57036208', - 'phabricator-form-view-css' => '57036208', - 'phabricator-header-view-css' => '57036208', - 'phabricator-jump-nav' => '57036208', + 'phabricator-filetree-view-css' => '86c4a3b2', + 'phabricator-flag-css' => '86c4a3b2', + 'phabricator-form-view-css' => '86c4a3b2', + 'phabricator-header-view-css' => '86c4a3b2', + 'phabricator-jump-nav' => '86c4a3b2', 'phabricator-keyboard-shortcut' => 'c90b892e', 'phabricator-keyboard-shortcut-manager' => 'c90b892e', - 'phabricator-main-menu-view' => '57036208', + 'phabricator-main-menu-view' => '86c4a3b2', 'phabricator-menu-item' => 'c90b892e', - 'phabricator-nav-view-css' => '57036208', + 'phabricator-nav-view-css' => '86c4a3b2', 'phabricator-notification' => 'c90b892e', - 'phabricator-notification-css' => '57036208', - 'phabricator-notification-menu-css' => '57036208', - 'phabricator-object-item-list-view-css' => '57036208', + 'phabricator-notification-css' => '86c4a3b2', + 'phabricator-notification-menu-css' => '86c4a3b2', + 'phabricator-object-item-list-view-css' => '86c4a3b2', 'phabricator-object-selector-css' => 'ec01d039', 'phabricator-paste-file-upload' => 'c90b892e', 'phabricator-prefab' => 'c90b892e', 'phabricator-project-tag-css' => '83f07678', - 'phabricator-remarkup-css' => '57036208', + 'phabricator-remarkup-css' => '86c4a3b2', 'phabricator-shaped-request' => 'ac53d36a', - 'phabricator-side-menu-view-css' => '57036208', - 'phabricator-standard-page-view' => '57036208', + 'phabricator-side-menu-view-css' => '86c4a3b2', + 'phabricator-standard-page-view' => '86c4a3b2', 'phabricator-textareautils' => 'c90b892e', 'phabricator-tooltip' => 'c90b892e', - 'phabricator-transaction-view-css' => '57036208', - 'phabricator-zindex-css' => '57036208', - 'sprite-apps-large-css' => '57036208', - 'sprite-gradient-css' => '57036208', - 'sprite-icon-css' => '57036208', - 'sprite-menu-css' => '57036208', - 'syntax-highlighting-css' => '57036208', + 'phabricator-transaction-view-css' => '86c4a3b2', + 'phabricator-zindex-css' => '86c4a3b2', + 'sprite-apps-large-css' => '86c4a3b2', + 'sprite-gradient-css' => '86c4a3b2', + 'sprite-icon-css' => '86c4a3b2', + 'sprite-menu-css' => '86c4a3b2', + 'syntax-highlighting-css' => '86c4a3b2', ), )); Index: src/applications/cache/PhabricatorCaches.php =================================================================== --- src/applications/cache/PhabricatorCaches.php +++ src/applications/cache/PhabricatorCaches.php @@ -11,8 +11,7 @@ /** * Highly specialized cache for performing setup checks. We use this cache - * to determine if we need to run expensive setup checks (e.g., verifying - * submodule versions, PATH, the presence of binaries, etc.) when the page + * to determine if we need to run expensive setup checks when the page * loads. Without it, we would need to run these checks every time. * * Normally, this cache is just APC. In the absence of APC, this cache Index: src/docs/installation_guide.diviner =================================================================== --- src/docs/installation_guide.diviner +++ src/docs/installation_guide.diviner @@ -63,8 +63,6 @@ somewhere/ $ git clone git://github.com/facebook/libphutil.git somewhere/ $ git clone git://github.com/facebook/arcanist.git somewhere/ $ git clone git://github.com/facebook/phabricator.git - somewhere/ $ cd phabricator - somewhere/phabricator/ $ git submodule update --init = Installing APC (Optional) = @@ -124,8 +122,7 @@ update Phabricator: - Stop the webserver. - - Run `git pull && git submodule update --init` in `libphutil/`, - `arcanist/` and `phabricator/`. + - Run `git pull` in `libphutil/`, `arcanist/` and `phabricator/`. - Run `phabricator/bin/storage upgrade`. - Restart the webserver. Index: src/infrastructure/PhabricatorSetup.php =================================================================== --- src/infrastructure/PhabricatorSetup.php +++ src/infrastructure/PhabricatorSetup.php @@ -154,51 +154,6 @@ $root = dirname(phutil_get_library_root('phabricator')); - self::writeHeader("GIT SUBMODULES"); - if (!Filesystem::pathExists($root.'/.git')) { - self::write(" skip Not a git clone.\n\n"); - } else { - list($info) = execx( - '(cd %s && git submodule status)', - $root); - foreach (explode("\n", rtrim($info)) as $line) { - $matches = null; - if (!preg_match('/^(.)([0-9a-f]{40}) (\S+)(?: |$)/', $line, $matches)) { - self::writeFailure(); - self::write( - "Setup failure! 'git submodule' produced unexpected output:\n". - $line); - return; - } - - $status = $matches[1]; - $module = $matches[3]; - - switch ($status) { - case '-': - case '+': - case 'U': - self::writeFailure(); - self::write( - "Setup failure! Git submodule '{$module}' is not up to date. ". - "Run:\n\n". - " cd {$root} && git submodule update --init\n\n". - "...to update submodules."); - return; - case ' ': - self::write(" okay Git submodule '{$module}' up to date.\n"); - break; - default: - self::writeFailure(); - self::write( - "Setup failure! 'git submodule' reported unknown status ". - "'{$status}' for submodule '{$module}'. This is a bug; report ". - "it to the Phabricator maintainers."); - return; - } - } - } - self::write("[OKAY] All submodules OKAY.\n"); self::writeHeader("BASIC CONFIGURATION"); Index: src/infrastructure/lint/linter/PhabricatorJavelinLinter.php =================================================================== --- src/infrastructure/lint/linter/PhabricatorJavelinLinter.php +++ src/infrastructure/lint/linter/PhabricatorJavelinLinter.php @@ -19,8 +19,8 @@ require_once $root.'/scripts/__init_script__.php'; if ($this->haveSymbolsBinary === null) { - $binary = $this->getSymbolsBinaryPath(); - $this->haveSymbolsBinary = Filesystem::pathExists($binary); + list($err) = exec_manual('which javelinsymbols'); + $this->haveSymbolsBinary = !$err; if (!$this->haveSymbolsBinary) { return; } @@ -28,6 +28,10 @@ $futures = array(); foreach ($paths as $path) { + if ($this->shouldIgnorePath($path)) { + continue; + } + $future = $this->newSymbolsFuture($path); $futures[$path] = $future; } @@ -53,11 +57,18 @@ self::LINT_MISSING_DEPENDENCY => 'Missing Javelin Dependency', self::LINT_UNNECESSARY_DEPENDENCY => 'Unnecessary Javelin Dependency', self::LINT_UNKNOWN_DEPENDENCY => 'Unknown Javelin Dependency', - self::LINT_MISSING_BINARY => '`javelinsymbols` Binary Not Built', + self::LINT_MISSING_BINARY => '`javelinsymbols` Not In Path', ); } + private function shouldIgnorePath($path) { + return preg_match('@/__tests__/|externals/javelin/src/docs/@', $path); + } + public function lintPath($path) { + if ($this->shouldIgnorePath($path)) { + return; + } if (!$this->haveSymbolsBinary) { if (!$this->haveWarnedAboutBinary) { @@ -68,9 +79,10 @@ 1, 0, self::LINT_MISSING_BINARY, - "The 'javelinsymbols' binary in the Javelin project has not been ". - "built, so the Javelin linter can't run. This isn't a big concern, ". - "but means some Javelin problems can't be automatically detected."); + "The 'javelinsymbols' binary in the Javelin project is not ". + "available in \$PATH, so the Javelin linter can't run. This ". + "isn't a big concern, but means some Javelin problems can't be ". + "automatically detected."); } return; } @@ -175,20 +187,13 @@ } private function newSymbolsFuture($path) { - $javelinsymbols = $this->getSymbolsBinaryPath(); + $javelinsymbols = 'javelinsymbols'; $future = new ExecFuture($javelinsymbols.' # '.escapeshellarg($path)); $future->write($this->getData($path)); return $future; } - private function getSymbolsBinaryPath() { - $root = dirname(phutil_get_library_root('phabricator')); - - $support = $root.'/externals/javelin/support'; - return $support.'/javelinsymbols/javelinsymbols'; - } - private function getUsedAndInstalledSymbolsForPath($path) { list($symbols) = $this->loadSymbols($path); $symbols = trim($symbols);