/*\ title: $:/core/modules/widgets/widget.js type: application/javascript module-type: widget Widget base class \*/ (function(){ /*jslint node: true, browser: true */ /*global $tw: false */ "use strict"; /* Maximum permitted depth of the widget tree for recursion detection */ var MAX_WIDGET_TREE_DEPTH = 1000; /* Create a widget object for a parse tree node parseTreeNode: reference to the parse tree node to be rendered options: see below Options include: wiki: mandatory reference to wiki associated with this render tree parentWidget: optional reference to a parent renderer node for the context chain document: optional document object to use instead of global document */ var Widget = function(parseTreeNode,options) { this.initialise(parseTreeNode,options); }; /* Initialise widget properties. These steps are pulled out of the constructor so that we can reuse them in subclasses */ Widget.prototype.initialise = function(parseTreeNode,options) { // Bail if parseTreeNode is undefined, meaning that the widget constructor was called without any arguments so that it can be subclassed if(parseTreeNode === undefined) { return; } options = options || {}; // Save widget info this.parseTreeNode = parseTreeNode; this.wiki = options.wiki; this.parentWidget = options.parentWidget; this.variablesConstructor = function() {}; this.variablesConstructor.prototype = this.parentWidget ? this.parentWidget.variables : {}; this.variables = new this.variablesConstructor(); this.document = options.document; this.attributes = {}; this.children = []; this.domNodes = []; this.eventListeners = {}; // Hashmap of the widget classes if(!this.widgetClasses) { // Get widget classes Widget.prototype.widgetClasses = $tw.modules.applyMethods("widget"); // Process any subclasses $tw.modules.forEachModuleOfType("widget-subclass",function(title,module) { if(module.baseClass) { var baseClass = Widget.prototype.widgetClasses[module.baseClass]; if(!baseClass) { throw "Module '" + title + "' is attemping to extend a non-existent base class '" + module.baseClass + "'"; } var subClass = module.constructor; subClass.prototype = new baseClass(); $tw.utils.extend(subClass.prototype,module.prototype); Widget.prototype.widgetClasses[module.name || module.baseClass] = subClass; } }); } }; /* Render this widget into the DOM */ Widget.prototype.render = function(parent,nextSibling) { this.parentDomNode = parent; this.execute(); this.renderChildren(parent,nextSibling); }; /* Compute the internal state of the widget */ Widget.prototype.execute = function() { this.makeChildWidgets(); }; /* Set the value of a context variable name: name of the variable value: value of the variable params: array of {name:, default:} for each parameter isMacroDefinition: true if the variable is set via a \define macro pragma (and hence should have variable substitution performed) options includes: isProcedureDefinition: true if the variable is set via a \procedure pragma (and hence should not have variable substitution performed) isFunctionDefinition: true if the variable is set via a \function pragma (and hence should not have variable substitution performed) isWidgetDefinition: true if the variable is set via a \widget pragma (and hence should not have variable substitution performed) */ Widget.prototype.setVariable = function(name,value,params,isMacroDefinition,options) { options = options || {}; this.variables[name] = { value: value, params: params, isMacroDefinition: !!isMacroDefinition, isFunctionDefinition: !!options.isFunctionDefinition, isProcedureDefinition: !!options.isProcedureDefinition, isWidgetDefinition: !!options.isWidgetDefinition, configTrimWhiteSpace: !!options.configTrimWhiteSpace }; }; /* Get the prevailing value of a context variable name: name of variable options: see below Options include params: array of {name:, value:} for each parameter defaultValue: default value if the variable is not defined allowSelfAssigned: if true, includes the current widget in the context chain instead of just the parent Returns an object with the following fields: params: array of {name:,value:} of parameters passed to wikitext variables text: text of variable, with parameters properly substituted */ Widget.prototype.getVariableInfo = function(name,options) { options = options || {}; var self = this, actualParams = options.params || [], currWidget = options.allowSelfAssigned ? this : this.parentWidget, processVariable = function(variable) { var originalValue = variable.value, value = originalValue, params = []; // Only substitute parameter and variable references if this variable was defined with the \define pragma if(variable.isMacroDefinition) { params = self.resolveVariableParameters(variable.params,actualParams); // Substitute any parameters specified in the definition $tw.utils.each(params,function(param) { value = $tw.utils.replaceString(value,new RegExp("\\$" + $tw.utils.escapeRegExp(param.name) + "\\$","mg"),param.value); }); value = self.substituteVariableReferences(value,options); } return { text: value, params: params, srcVariable: variable, isCacheable: originalValue === value }; }; // Check for the variable defined in the parent widget (or an ancestor in the prototype chain) if(currWidget && name in currWidget.variables) { return processVariable(currWidget.variables[name]); } // If the variable doesn't exist in the parent widget then look for a macro module var text = this.evaluateMacroModule(name,actualParams); if(text === undefined) { // Check for a shadow variable tiddler var tiddler = this.wiki.getTiddler("$:/global/" + name); if(tiddler) { return processVariable({ value: tiddler.getFieldString("text"), params: $tw.utils.parseParameterDefinition(tiddler.getFieldString("parameters"),{requireParenthesis: true}), isMacroDefinition: tiddler.getFieldString("is-macro") === "yes", isWidgetDefinition: tiddler.getFieldString("is-widget") === "yes", isProcedureDefinition: tiddler.getFieldString("is-procedure") === "yes", isFunctionDefinition: tiddler.getFieldString("is-function") === "yes" }); } } if(text === undefined) { text = options.defaultValue; } return { text: text, srcVariable: {} }; }; /* Simplified version of getVariableInfo() that just returns the text */ Widget.prototype.getVariable = function(name,options) { return this.getVariableInfo(name,options).text; }; Widget.prototype.resolveVariableParameters = function(formalParams,actualParams) { formalParams = formalParams || []; actualParams = actualParams || []; var nextAnonParameter = 0, // Next candidate anonymous parameter in macro call paramInfo, paramValue, results = []; // Step through each of the parameters in the macro definition for(var p=0; p 0) { var nextAnonParameter = 0, // Next candidate anonymous parameter in macro call paramInfo, paramValue; // Step through each of the parameters in the macro definition for(var p=0; p>) will be treated as missing */ Widget.prototype.hasAttribute = function(name) { return $tw.utils.hop(this.attributes,name); }; /* Check for the presence of a raw attribute on the widget parse tree node. Note that attributes set to a missing variable (ie attr=<>) will NOT be treated as missing */ Widget.prototype.hasParseTreeNodeAttribute = function(name) { return $tw.utils.hop(this.parseTreeNode.attributes,name); }; /* Get the value of an attribute */ Widget.prototype.getAttribute = function(name,defaultText) { if($tw.utils.hop(this.attributes,name)) { return this.attributes[name]; } else { return defaultText; } }; /* Assign the computed attributes of the widget to a domNode options include: excludeEventAttributes: ignores attributes whose name begins with "on" */ Widget.prototype.assignAttributes = function(domNode,options) { options = options || {}; var self = this; var assignAttribute = function(name,value) { // Check for excluded attribute names if(options.excludeEventAttributes && name.substr(0,2) === "on") { value = undefined; } if(value !== undefined) { // Handle the xlink: namespace var namespace = null; if(name.substr(0,6) === "xlink:" && name.length > 6) { namespace = "http://www.w3.org/1999/xlink"; name = name.substr(6); } // Handle styles if(name.substr(0,6) === "style." && name.length > 6) { domNode.style[$tw.utils.unHyphenateCss(name.substr(6))] = value; } else { // Setting certain attributes can cause a DOM error (eg xmlns on the svg element) try { domNode.setAttributeNS(namespace,name,value); } catch(e) { } } } } // Not all parse tree nodes have the orderedAttributes property if(this.parseTreeNode.orderedAttributes) { $tw.utils.each(this.parseTreeNode.orderedAttributes,function(attribute,index) { assignAttribute(attribute.name,self.attributes[attribute.name]); }); } else { $tw.utils.each(Object.keys(self.attributes).sort(),function(name) { assignAttribute(name,self.attributes[name]); }); } }; /* Get the number of ancestor widgets for this widget */ Widget.prototype.getAncestorCount = function() { if(this.ancestorCount === undefined) { if(this.parentWidget) { this.ancestorCount = this.parentWidget.getAncestorCount() + 1; } else { this.ancestorCount = 0; } } return this.ancestorCount; }; /* Make child widgets correspondng to specified parseTreeNodes */ Widget.prototype.makeChildWidgets = function(parseTreeNodes,options) { options = options || {}; this.children = []; var self = this; // Check for too much recursion if(this.getAncestorCount() > MAX_WIDGET_TREE_DEPTH) { // Error message needs special permission not to cause a recursive error loop this.children.push(this.makeChildWidget({type: "error", attributes: { "$message": {type: "string", value: $tw.language.getString("Error/RecursiveTransclusion")} }})); } else { // Create set variable widgets for each variable $tw.utils.each(options.variables,function(value,name) { var setVariableWidget = { type: "set", attributes: { name: {type: "string", value: name}, value: {type: "string", value: value} }, children: parseTreeNodes }; parseTreeNodes = [setVariableWidget]; }); // Create the child widgets $tw.utils.each(parseTreeNodes || (this.parseTreeNode && this.parseTreeNode.children),function(childNode) { self.children.push(self.makeChildWidget(childNode)); }); } }; /* Construct the widget object for a parse tree node options include: variables: optional hashmap of variables to wrap around the widget */ Widget.prototype.makeChildWidget = function(parseTreeNode,options) { var self = this; options = options || {}; // Check whether this node type is defined by a custom widget definition var variableDefinitionName = "$" + parseTreeNode.type, variableInfo = this.getVariableInfo(variableDefinitionName,{allowSelfAssigned: true}), isOverrideable = function() { // Widget is overrideable if it has a double dollar user defined name, or if it is an existing JS widget return parseTreeNode.type.charAt(0) === "$" || !!self.widgetClasses[parseTreeNode.type]; }; if(!parseTreeNode.isNotRemappable && isOverrideable() && variableInfo && variableInfo.srcVariable && variableInfo.srcVariable.value && variableInfo.srcVariable.isWidgetDefinition) { var newParseTreeNode = { type: "transclude", children: [ { type: "fill", children: parseTreeNode.children } ], isBlock: parseTreeNode.isBlock }; $tw.utils.addAttributeToParseTreeNode(newParseTreeNode,"$variable",variableDefinitionName); $tw.utils.addAttributeToParseTreeNode(newParseTreeNode.children[0],"$name","ts-body"); $tw.utils.each(parseTreeNode.attributes,function(attr,name) { // If the attribute starts with a dollar then add an extra dollar so that it doesn't clash with the $xxx attributes of transclude name = name.charAt(0) === "$" ? "$" + name : name; $tw.utils.addAttributeToParseTreeNode(newParseTreeNode,$tw.utils.extend({},attr,{name: name})); }); parseTreeNode = newParseTreeNode; } // Get the widget class for this node type var WidgetClass = this.widgetClasses[parseTreeNode.type]; if(!WidgetClass) { WidgetClass = this.widgetClasses.text; parseTreeNode = {type: "text", text: "Undefined widget '" + parseTreeNode.type + "'"}; } // Create set variable widgets for each variable $tw.utils.each(options.variables,function(value,name) { var setVariableWidget = { type: "set", attributes: { name: {type: "string", value: name}, value: {type: "string", value: value} }, children: [ parseTreeNode ] }; parseTreeNode = setVariableWidget; }); return new WidgetClass(parseTreeNode,{ wiki: this.wiki, parentWidget: this, document: this.document }); }; /* Get the next sibling of this widget */ Widget.prototype.nextSibling = function() { if(this.parentWidget) { var index = this.parentWidget.children.indexOf(this); if(index !== -1 && index < this.parentWidget.children.length-1) { return this.parentWidget.children[index+1]; } } return null; }; /* Get the previous sibling of this widget */ Widget.prototype.previousSibling = function() { if(this.parentWidget) { var index = this.parentWidget.children.indexOf(this); if(index !== -1 && index > 0) { return this.parentWidget.children[index-1]; } } return null; }; /* Render the children of this widget into the DOM */ Widget.prototype.renderChildren = function(parent,nextSibling) { var children = this.children; for(var i = 0; i < children.length; i++) { children[i].render(parent,nextSibling); }; }; /* Add a list of event listeners from an array [{type:,handler:},...] */ Widget.prototype.addEventListeners = function(listeners) { var self = this; $tw.utils.each(listeners,function(listenerInfo) { self.addEventListener(listenerInfo.type,listenerInfo.handler); }); }; /* Add an event listener */ Widget.prototype.addEventListener = function(type,handler) { var self = this; if(typeof handler === "string") { // The handler is a method name on this widget this.eventListeners[type] = function(event) { return self[handler].call(self,event); }; } else { // The handler is a function this.eventListeners[type] = function(event) { return handler.call(self,event); }; } }; /* Dispatch an event to a widget. If the widget doesn't handle the event then it is also dispatched to the parent widget */ Widget.prototype.dispatchEvent = function(event) { event.widget = event.widget || this; // Dispatch the event if this widget handles it var listener = this.eventListeners[event.type]; if(listener) { // Don't propagate the event if the listener returned false if(!listener(event)) { return false; } } // Dispatch the event to the parent widget if(this.parentWidget) { return this.parentWidget.dispatchEvent(event); } return true; }; /* Selectively refreshes the widget if needed. Returns true if the widget or any of its children needed re-rendering */ Widget.prototype.refresh = function(changedTiddlers) { return this.refreshChildren(changedTiddlers); }; /* Rebuild a previously rendered widget */ Widget.prototype.refreshSelf = function() { var nextSibling = this.findNextSiblingDomNode(); this.removeChildDomNodes(); this.render(this.parentDomNode,nextSibling); }; /* Refresh all the children of a widget */ Widget.prototype.refreshChildren = function(changedTiddlers) { var children = this.children, refreshed = false; for (var i = 0; i < children.length; i++) { refreshed = children[i].refresh(changedTiddlers) || refreshed; } return refreshed; }; /* Find the next sibling in the DOM to this widget. This is done by scanning the widget tree through all next siblings and their descendents that share the same parent DOM node */ Widget.prototype.findNextSiblingDomNode = function(startIndex) { // Refer to this widget by its index within its parents children var parent = this.parentWidget, index = startIndex !== undefined ? startIndex : parent.children.indexOf(this); if(index === -1) { throw "node not found in parents children"; } // Look for a DOM node in the later siblings while(++index < parent.children.length) { var domNode = parent.children[index].findFirstDomNode(); if(domNode) { return domNode; } } // Go back and look for later siblings of our parent if it has the same parent dom node var grandParent = parent.parentWidget; if(grandParent && parent.parentDomNode === this.parentDomNode) { index = grandParent.children.indexOf(parent); if(index !== -1) { return parent.findNextSiblingDomNode(index); } } return null; }; /* Find the first DOM node generated by a widget or its children */ Widget.prototype.findFirstDomNode = function() { // Return the first dom node of this widget, if we've got one if(this.domNodes.length > 0) { return this.domNodes[0]; } // Otherwise, recursively call our children for(var t=0; t 0) { $tw.utils.each(this.domNodes,function(domNode) { domNode.parentNode.removeChild(domNode); }); this.domNodes = []; } else { // Otherwise, ask the child widgets to delete their DOM nodes $tw.utils.each(this.children,function(childWidget) { childWidget.removeChildDomNodes(); }); } }; /* Invoke the action widgets that are descendents of the current widget. */ Widget.prototype.invokeActions = function(triggeringWidget,event) { var handled = false; // For each child widget for(var t=0; t