TiddlyWiki5/core/modules/widgets/widget.js
2026-02-19 12:45:27 +00:00

838 lines
25 KiB
JavaScript
Executable file

/*\
title: $:/core/modules/widgets/widget.js
type: application/javascript
module-type: widget
\*/
"use strict";
var Widget = function(parseTreeNode,options) {
this.initialise(parseTreeNode,options);
};
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.variables = Object.create(this.parentWidget ? this.parentWidget.variables : null);
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;
}
});
}
};
Widget.prototype.render = function(parent,nextSibling) {
this.parentDomNode = parent;
this.execute();
this.renderChildren(parent,nextSibling);
};
Widget.prototype.execute = function() {
this.makeChildWidgets();
};
Widget.prototype.setVariable = function(name,value,params,isMacroDefinition,options) {
options = options || {};
var valueIsArray = $tw.utils.isArray(value);
this.variables[name] = {
value: valueIsArray ? (value[0] || "") : value,
resultList: valueIsArray ? value : [value],
params: params,
isMacroDefinition: !!isMacroDefinition,
isFunctionDefinition: !!options.isFunctionDefinition,
isProcedureDefinition: !!options.isProcedureDefinition,
isWidgetDefinition: !!options.isWidgetDefinition,
configTrimWhiteSpace: !!options.configTrimWhiteSpace
};
};
Widget.prototype.getVariableInfo = function(name,options) {
options = options || {};
var self = this,
actualParams = options.params || [],
variable;
if(options.allowSelfAssigned) {
variable = this.variables[name];
} else {
variable = this.parentWidget && this.parentWidget.variables[name];
}
if(variable) {
var originalValue = variable.value,
value = originalValue,
params = [],
resultList = [value];
// 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) {
if("name" in param) {
value = $tw.utils.replaceString(value,new RegExp("\\$" + $tw.utils.escapeRegExp(param.name) + "\\$","mg"),param.value);
}
});
value = self.substituteVariableReferences(value,options);
resultList = [value];
} else if(variable.isFunctionDefinition) {
// Function evaluations
params = self.resolveVariableParameters(variable.params,actualParams);
var variables = $tw.utils.extend({},options.variables);
// Apply default parameter values
$tw.utils.each(variable.params,function(param,index) {
if(param["default"]) {
variables[param.name] = param["default"];
}
});
// Parameters are an array of {name:, value:, multivalue:} pairs (name and multivalue are optional)
$tw.utils.each(params,function(param) {
if(param.multiValue && param.multiValue.length) {
variables[param.name] = param.multiValue;
} else {
variables[param.name] = param.value || "";
}
});
resultList = this.wiki.filterTiddlers(value,this.makeFakeWidgetWithVariables(variables),options.source);
value = resultList[0] || "";
} else {
if(variable.resultList) {
resultList = variable.resultList;
}
params = variable.params;
}
return {
text: value,
params: params,
resultList: resultList,
srcVariable: variable,
isCacheable: originalValue === value
};
}
var text = this.evaluateMacroModule(name,actualParams);
if(text === undefined) {
text = options.defaultValue;
}
return {
text: text,
resultList: [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, paramMultiValue,
results = [];
// Step through each of the parameters in the macro definition
for(var p=0; p<formalParams.length; p++) {
// Check if we've got a macro call parameter with the same name
paramInfo = formalParams[p];
paramValue = undefined;
paramMultiValue = undefined;
for(var m=0; m<actualParams.length; m++) {
if(typeof actualParams[m] !== "string" && actualParams[m].name === paramInfo.name) {
paramValue = actualParams[m].value;
paramMultiValue = actualParams[m].multiValue || [paramValue]
}
}
// If not, use the next available anonymous macro call parameter
while(nextAnonParameter < actualParams.length && actualParams[nextAnonParameter].name) {
nextAnonParameter++;
}
if(paramValue === undefined && nextAnonParameter < actualParams.length) {
var param = actualParams[nextAnonParameter++];
paramValue = typeof param === "string" ? param : param.value;
paramMultiValue = typeof param === "string" ? [param] : (param.multiValue || [paramValue]);
}
// If we've still not got a value, use the default, if any
if(!paramValue) {
paramValue = paramInfo["default"] || "";
paramMultiValue = [paramValue];
}
results.push({name: paramInfo.name, value: paramValue, multiValue: paramMultiValue});
}
return results;
};
Widget.prototype.substituteVariableReferences = function(text,options) {
var self = this;
return (text || "").replace(/\$\(([^\)\$]+)\)\$/g,function(match,p1,offset,string) {
return options.variables && options.variables[p1] || (self.getVariable(p1,{defaultValue: ""}));
});
};
Widget.prototype.evaluateMacroModule = function(name,actualParams,defaultValue) {
if($tw.utils.hop($tw.macros,name)) {
var macro = $tw.macros[name],
args = [];
if(macro.params.length > 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<macro.params.length; p++) {
// Check if we've got a macro call parameter with the same name
paramInfo = macro.params[p];
paramValue = undefined;
for(var m=0; m<actualParams.length; m++) {
if(actualParams[m].name === paramInfo.name) {
paramValue = actualParams[m].value;
}
}
// If not, use the next available anonymous macro call parameter
while(nextAnonParameter < actualParams.length && actualParams[nextAnonParameter].name) {
nextAnonParameter++;
}
if(paramValue === undefined && nextAnonParameter < actualParams.length) {
paramValue = actualParams[nextAnonParameter++].value;
}
// If we've still not got a value, use the default, if any
paramValue = paramValue || paramInfo["default"] || "";
// Save the parameter
args.push(paramValue);
}
}
else for(var i=0; i<actualParams.length; ++i) {
args.push(actualParams[i].value);
}
return (macro.run.apply(this,args) || "").toString();
} else {
return defaultValue;
}
};
Widget.prototype.hasVariable = function(name,value) {
var node = this;
while(node) {
if($tw.utils.hop(node.variables,name) && node.variables[name].value === value) {
return true;
}
node = node.parentWidget;
}
return false;
};
Widget.prototype.getStateQualifier = function(name) {
this.qualifiers = this.qualifiers || Object.create(null);
name = name || "transclusion";
if(this.qualifiers[name]) {
return this.qualifiers[name];
} else {
var output = [],
node = this;
while(node && node.parentWidget) {
if($tw.utils.hop(node.parentWidget.variables,name)) {
output.push(node.getVariable(name));
}
node = node.parentWidget;
}
var value = $tw.utils.hashString(output.join(""));
this.qualifiers[name] = value;
return value;
}
};
Widget.prototype.makeFakeWidgetWithVariables = function(vars = {}) {
const self = this;
const fakeWidget = {
getVariableInfo(name,opts = {}) {
if(name in vars) {
const value = vars[name];
return Array.isArray(value)
? { text: value[0], resultList: value }
: { text: value, resultList: [value] };
}
opts = opts || {};
opts.variables = Object.assign({}, vars, opts.variables || {});
return self.getVariableInfo(name, opts);
},
getVariable(name,opts) {
return this.getVariableInfo(name, opts).text;
},
resolveVariableParameters: self.resolveVariableParameters,
wiki: self.wiki,
makeFakeWidgetWithVariables: self.makeFakeWidgetWithVariables,
get variables() {
// Merge parent vars via prototype-like delegation
return Object.create(self.variables || {},
Object.keys(vars).reduce((acc, key) => {
acc[key] = { value: vars[key], enumerable: true, configurable: true };
return acc;
}, {})
);
}
};
return fakeWidget;
};
Widget.prototype.computeAttributes = function(options) {
options = options || {};
var changedAttributes = {},
self = this,
newMultiValuedAttributes = Object.create(null);
$tw.utils.each(this.parseTreeNode.attributes,function(attribute,name) {
if(options.filterFn) {
if(!options.filterFn(name)) {
return;
}
}
var value = self.computeAttribute(attribute,options),
multiValue = null;
if($tw.utils.isArray(value)) {
multiValue = value;
newMultiValuedAttributes[name] = multiValue;
value = value[0] || "";
}
var changed = (self.attributes[name] !== value);
if(!changed && multiValue && self.multiValuedAttributes) {
changed = !$tw.utils.isArrayEqual(self.multiValuedAttributes[name] || [], multiValue);
}
if(changed) {
self.attributes[name] = value;
changedAttributes[name] = true;
}
});
this.multiValuedAttributes = newMultiValuedAttributes;
return changedAttributes;
};
Widget.prototype.computeAttribute = function(attribute,options) {
options = options || {};
var self = this,
value;
if(attribute.type === "filtered") {
value = this.wiki.filterTiddlers(attribute.filter,this);
if(!options.asList) {
value = value[0] || "";
}
} else if(attribute.type === "indirect") {
value = this.wiki.getTextReference(attribute.textReference,"",this.getVariable("currentTiddler"));
if(value && options.asList) {
value = [value];
}
} else if(attribute.type === "macro") {
// Get the macro name
var macroName = attribute.value.attributes["$variable"].value;
// Collect macro parameters
var params = [];
$tw.utils.each(attribute.value.orderedAttributes,function(attr) {
var param = {
value: self.computeAttribute(attr)
};
if(attr.name && !attr.isPositional) {
param.name = attr.name;
}
params.push(param);
});
// Invoke the macro
var variableInfo = this.getVariableInfo(macroName,{params: params});
if(options.asList || attribute.isMVV) {
value = variableInfo.resultList;
} else {
value = variableInfo.text;
}
} else if(attribute.type === "substituted") {
value = this.wiki.getSubstitutedText(attribute.rawValue,this) || "";
if(options.asList) {
value = [value];
}
} else { // String attribute
value = attribute.value;
if(options.asList) {
if(value === undefined) {
value = [];
} else {
value = [value];
}
}
}
return value;
};
Widget.prototype.hasAttribute = function(name) {
return $tw.utils.hop(this.attributes,name);
};
Widget.prototype.hasParseTreeNodeAttribute = function(name) {
return $tw.utils.hop(this.parseTreeNode.attributes,name);
};
Widget.prototype.getAttribute = function(name,defaultText) {
if($tw.utils.hop(this.attributes,name)) {
return this.attributes[name];
} else {
return defaultText;
}
};
Widget.prototype.assignAttributes = function(domNode,options) {
options = options || {};
var self = this,
changedAttributes = options.changedAttributes || this.attributes,
sourcePrefix = options.sourcePrefix || "",
destPrefix = options.destPrefix || "",
EVENT_ATTRIBUTE_PREFIX = "on";
var assignAttribute = function(name,value) {
// Process any CSS custom properties
if(name.substr(0,2) === "--" && name.length > 2) {
domNode.style.setProperty(name,value);
return;
}
if(name.substr(0,6) === "style." && name.length > 6) {
domNode.style[$tw.utils.unHyphenateCss(name.substr(6))] = value;
return;
}
if(name.substr(0,sourcePrefix.length) === sourcePrefix) {
name = destPrefix + name.substr(sourcePrefix.length);
} else {
value = undefined;
}
if(options.excludeEventAttributes && name.substr(0,2).toLowerCase() === EVENT_ATTRIBUTE_PREFIX) {
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);
}
try {
domNode.setAttributeNS(namespace,name,value);
} catch(e) {
}
}
};
// If the parse tree node has the orderedAttributes property then use that order
if(this.parseTreeNode.orderedAttributes) {
$tw.utils.each(this.parseTreeNode.orderedAttributes,function(attribute,index) {
if(attribute.name in changedAttributes) {
assignAttribute(attribute.name,self.getAttribute(attribute.name));
}
});
// Otherwise update each changed attribute irrespective of order
} else {
$tw.utils.each(changedAttributes,function(value,name) {
assignAttribute(name,self.getAttribute(name));
});
}
};
Widget.prototype.getAncestorCount = function() {
if(this.ancestorCount === undefined) {
if(this.parentWidget) {
this.ancestorCount = this.parentWidget.getAncestorCount() + 1;
} else {
this.ancestorCount = 0;
}
}
return this.ancestorCount;
};
Widget.prototype.makeChildWidgets = function(parseTreeNodes,options) {
options = options || {};
this.children = [];
var self = this;
// Check for too much recursion
if(this.getAncestorCount() > $tw.utils.TranscludeRecursionError.MAX_WIDGET_TREE_DEPTH) {
throw new $tw.utils.TranscludeRecursionError();
} 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));
});
}
};
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;
if(this.variables[variableDefinitionName]) {
var isOverrideable = function() {
// Widget is overrideable if its name contains a period, or if it is an existing JS widget and we're not in safe mode
return parseTreeNode.type.indexOf(".") !== -1 || (!!self.widgetClasses[parseTreeNode.type] && !$tw.safeMode);
};
if(!parseTreeNode.isNotRemappable && isOverrideable()) {
var variableInfo = this.getVariableInfo(variableDefinitionName,{allowSelfAssigned: true});
if(variableInfo && variableInfo.srcVariable && variableInfo.srcVariable.value && variableInfo.srcVariable.isWidgetDefinition) {
var newParseTreeNode = {
type: "transclude",
children: parseTreeNode.children,
isBlock: parseTreeNode.isBlock
};
$tw.utils.addAttributeToParseTreeNode(newParseTreeNode,"$variable",variableDefinitionName);
$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;
}
}
}
var WidgetClass = this.widgetClasses[parseTreeNode.type];
if(!WidgetClass) {
WidgetClass = this.widgetClasses.text;
parseTreeNode = {type: "text", text: "Undefined widget '" + parseTreeNode.type + "'"};
}
$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
});
};
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;
};
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;
};
Widget.prototype.renderChildren = function(parent,nextSibling) {
var children = this.children;
for(var i = 0; i < children.length; i++) {
children[i].render(parent,nextSibling);
};
};
Widget.prototype.addEventListeners = function(listeners) {
var self = this;
$tw.utils.each(listeners,function(listenerInfo) {
self.addEventListener(listenerInfo.type,listenerInfo.handler);
});
};
Widget.prototype.addEventListener = function(type,handler) {
this.eventListeners[type] = this.eventListeners[type] || [];
if(this.eventListeners[type].indexOf(handler) === -1) {
this.eventListeners[type].push(handler);
}
};
Widget.prototype.removeEventListener = function(type,handler) {
if(!this.eventListeners[type]) return;
var index = this.eventListeners[type].indexOf(handler);
if(index !== -1) {
this.eventListeners[type].splice(index,1);
}
};
Widget.prototype.dispatchEvent = function(event) {
event.widget = event.widget || this;
var listeners = this.eventListeners[event.type];
if(listeners) {
var self = this;
var shouldPropagate = true;
$tw.utils.each(listeners,function(handler) {
var propagate;
if(typeof handler === "string") {
// If handler is a string, call it as a method on the widget
propagate = self[handler].call(self,event);
} else {
// Otherwise call the function handler directly
propagate = handler.call(self,event);
}
if(propagate === false) {
shouldPropagate = false;
}
});
if(!shouldPropagate) {
return false;
}
}
if(this.parentWidget) {
return this.parentWidget.dispatchEvent(event);
}
return true;
};
Widget.prototype.refresh = function(changedTiddlers) {
return this.refreshChildren(changedTiddlers);
};
Widget.prototype.refreshSelf = function() {
var nextSibling = this.findNextSiblingDomNode();
this.removeChildDomNodes();
this.render(this.parentDomNode,nextSibling);
};
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;
};
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";
}
while(++index < parent.children.length) {
var domNode = parent.children[index].findFirstDomNode();
if(domNode) {
return domNode;
}
}
var grandParent = parent.parentWidget;
if(grandParent && parent.parentDomNode === this.parentDomNode) {
index = grandParent.children.indexOf(parent);
if(index !== -1) {
return parent.findNextSiblingDomNode(index);
}
}
return null;
};
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<this.children.length; t++) {
var domNode = this.children[t].findFirstDomNode();
if(domNode) {
return domNode;
}
}
return null;
};
/*
Entry into destroy procedure
options include:
removeDOMNodes: boolean (default true)
*/
Widget.prototype.destroyChildren = function(options) {
$tw.utils.each(this.children,function(childWidget) {
childWidget.destroy(options);
});
};
/*
Legacy entry into destroy procedure
*/
Widget.prototype.removeChildDomNodes = function() {
this.destroy({removeDOMNodes: true});
};
/*
Default destroy
options include:
- removeDOMNodes: boolean (default true)
*/
Widget.prototype.destroy = function(options) {
const { removeDOMNodes = true } = options || {};
let removeChildDOMNodes = removeDOMNodes;
if(removeDOMNodes && this.domNodes.length > 0) {
// If this widget will remove its own DOM nodes, children should not remove theirs
removeChildDOMNodes = false;
}
// Destroy children first
this.destroyChildren({removeDOMNodes: removeChildDOMNodes});
this.children = [];
// Call custom cleanup method if implemented
if(typeof this.onDestroy === "function") {
this.onDestroy();
}
// Remove our DOM nodes if needed
if(removeDOMNodes) {
this.removeLocalDomNodes();
}
};
/*
Remove any DOM nodes created by this widget
*/
Widget.prototype.removeLocalDomNodes = function() {
for(const domNode of this.domNodes) {
if(domNode.parentNode) {
domNode.parentNode.removeChild(domNode);
}
}
this.domNodes = [];
};
/*
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<this.children.length; t++) {
var child = this.children[t],
childIsActionWidget = !!child.invokeAction,
actionRefreshPolicy = child.getVariable("tv-action-refresh-policy"); // Default is "once"
// Refresh the child if required
if(childIsActionWidget || actionRefreshPolicy === "always") {
child.refreshSelf();
}
// Invoke the child if it is an action widget
if(childIsActionWidget) {
if(child.invokeAction(triggeringWidget,event)) {
handled = true;
}
}
// Propagate through through the child if it permits it
if(child.allowActionPropagation() && child.invokeActions(triggeringWidget,event)) {
handled = true;
}
}
return handled;
};
/*
Invoke the action widgets defined in a string
*/
Widget.prototype.invokeActionString = function(actions,triggeringWidget,event,variables) {
actions = actions || "";
var parser = this.wiki.parseText("text/vnd.tiddlywiki",actions,{
parentWidget: this,
document: this.document
}),
widgetNode = this.wiki.makeWidget(parser,{
parentWidget: this,
document: this.document,
variables: variables
});
var container = this.document.createElement("div");
widgetNode.render(container,null);
return widgetNode.invokeActions(this,event);
};
/*
Execute action tiddlers by tag
*/
Widget.prototype.invokeActionsByTag = function(tag,event,variables) {
var self = this;
$tw.utils.each(self.wiki.filterTiddlers("[all[shadows+tiddlers]tag[" + tag + "]!has[draft.of]]"),function(title) {
self.invokeActionString(self.wiki.getTiddlerText(title),self,event,variables);
});
};
Widget.prototype.allowActionPropagation = function() {
return true;
};
/*
Find child <$data> widgets recursively. The tag name allows aliased versions of the widget to be found too
*/
Widget.prototype.findChildrenDataWidgets = function(children,tag,callback) {
var self = this;
$tw.utils.each(children,function(child) {
if(child.dataWidgetTag === tag) {
callback(child);
}
if(child.children) {
self.findChildrenDataWidgets(child.children,tag,callback);
}
});
};
/*
Evaluate a variable with parameters. This is a static convenience method that attempts to evaluate a variable as a function, returning an array of strings
*/
Widget.evaluateVariable = function(widget,name,options) {
var result;
if(widget.getVariableInfo) {
var variableInfo = widget.getVariableInfo(name,options);
result = variableInfo.resultList || [variableInfo.text];
} else {
result = [widget.getVariable(name)];
}
return result;
};
exports.widget = Widget;