/**
* DOMView - A view that provides a DOM templating engine for concepts
*
* This code is licensed under the MIT License (MIT).
*
* Copyright 2020, 2021, 2022 Rolf Bagge, Janus B. Kristensen, CAVI,
* Center for Advanced Visualization and Interaction, Aarhus University
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the “Software”), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*/
/**
*
*/
class DOMView {
constructor() {
const self = this;
// Add an observer to the DOM
this.observer = new MutationObserver((mutations) => {
self.mutationCallback(mutations);
});
self.startObserver();
}
/**
* Starts the element mutation observer
* @ignore
* @protected
*/
startObserver() {
this.observer.observe(document.body, {
attributes: true,
attributeOldValue: true,
childList: true,
subtree: true,
characterData: true,
characterDataOldValue: false
});
}
existsAsViewElement(viewName){
return document.querySelector("dom-view-template [view='"+viewName+"']");
}
async mutationCallback(mutationList) {
try {
const self = this;
let promises = [];
for(let mutation of mutationList) {
switch (mutation.type) {
case 'childList':
let rebuildTemplates = [];
let killTemplates = [];
// If change was inside a template, rebuild view
let potentialParent = mutation.target.closest("dom-view-template");
if (potentialParent){
rebuildTemplates.push(potentialParent);
}
// If a DOM view template was added either directly or indirectly, rebuild view
for(let node of mutation.addedNodes) {
try {
if (node.tagName === "DOM-VIEW-TEMPLATE") {
rebuildTemplates.push(node);
} else if (node.querySelectorAll != null){
// Could be a childnode as well
Array.prototype.push.apply(rebuildTemplates, Array.from(node.querySelectorAll("dom-view-template")));
}
} catch (ex) {
console.error(ex);
}
}
// Find directly or indirectly removed varv templates and destroy their views
for(let node of mutation.removedNodes) {
if (node.tagName === "DOM-VIEW-TEMPLATE") {
killTemplates.push(node);
} else if (node.querySelectorAll != null){
Array.prototype.push.apply(killTemplates, Array.from(node.querySelectorAll("dom-view-template")));
}
}
for (let templateElement of killTemplates){
let connectedView = templateElement.varvView;
if (connectedView){
self.tearDownElement(connectedView);
}
// Find DOM template elements in the removed varv templates and rebuild all other views in which they are used (since they are now unreferenceable)
for (let childTemplate of templateElement.querySelectorAll("template, varv-template")){
let name = childTemplate.getAttribute("name");
if (name){
await EventSystem.triggerEventAsync("domview.template.disappeared", name);
}
}
}
for(let templateElement of rebuildTemplates) {
await self.rebuildView(templateElement);
// Also notify any template-refs in other views of the DOM templates that just appeared within this varv template
for (let childTemplate of templateElement.querySelectorAll("template, varv-template")){
let name = childTemplate.getAttribute("name");
if (name){
await EventSystem.triggerEventAsync("domview.template.appeared", name);
}
}
}
break;
case "attributes":
// Handle someone changing the name of a template
if ((mutation.target.tagName==="VARV-TEMPLATE" || mutation.target.tagName==="TEMPLATE") && mutation.attributeName==="name"){
await EventSystem.triggerEventAsync("domview.template.disappeared", mutation.oldValue);
let name = mutation.target.getAttribute("name");
if (name){
await EventSystem.triggerEventAsync("domview.template.appeared", name);
}
} else if (mutation.target.tagName==="DOM-VIEW-TEMPLATE"){
// Handle changes to targetElement attributes etc
await self.rebuildView(mutation.target);
} else if (mutation.target.closest("dom-view-template")){
// Attribute inside a template
await self.rebuildView(mutation.target.closest("dom-view-template"));
}
break;
case "characterData":
// If change was inside a dom view template, rebuild view
let charParent = mutation.target.parentElement.closest("dom-view-template");
if (charParent){
await self.rebuildView(charParent);
}
// If it was also inside a varv template, rebuild anything depending on that
let varvTemplateParent = mutation.target.parentElement.closest("template, varv-template");
if (varvTemplateParent){
let varvTemplateParentName = varvTemplateParent.getAttribute("name");
if (varvTemplateParentName){
await EventSystem.triggerEventAsync("domview.template.appeared", varvTemplateParentName);
}
}
break;
}
}
} catch (ex){
console.log(ex);
}
}
/**
* Reconstructs a view based on the template element(s) currently loaded
* @param {HTMLElement} templateElement
* @returns {Promise<void>}
*/
async rebuildView(templateElement) {
const self = this;
// If view already exists, tear it down to let it rebuild
if (templateElement == null || templateElement == undefined) {
console.warn("[rebuildView] Template element did not exist?:", templateElement);
return;
}
if (templateElement.isRendering){
console.log("Requested render of DOMView while already busy rendering, queueing another frame");
templateElement.isRenderingInterruption = true;
} else {
if (templateElement.renderTimeout != null) clearTimeout(templateElement.renderTimeout);
templateElement.renderTimeout = setTimeout(async ()=>{
const mark = VarvPerformance.start()
try {
// Create a new rendering view
templateElement.isRendering = true;
// Find the target document
let targetFrameSpec = templateElement.getAttribute("target-iframe");
let targetDocument;
if (targetFrameSpec){
let frame = document.querySelector(targetFrameSpec);
if (!frame) throw new Error("DOMView: dom-view-template with target-iframe that does not exist in document failed to render", targetFrameSpec, templateElement);
targetDocument = frame.contentDocument;
} else {
targetDocument = document;
}
let view = targetDocument.createElement("varv-view");
let oldView = templateElement.varvView;
view.templateElement = templateElement;
// Re-render template nodes if additions/removals are made later
let addCallback = VarvEngine.registerEventCallback("appeared", async (evt) => {
let mark = VarvPerformance.start();
let conceptThatHadAdded = await VarvEngine.getConceptFromUUID(evt.target);
VarvPerformance.stop("DOMView.getConceptFromUUID", mark);
// TODO: Check if we actually use it anywhere as a concept
// TODO: Check if we actually use it as a property
// TODO: Rebuild only that part of the tree
// STUB: Rebuilding everything
self.stubTriggerFullRebuild(templateElement, "Concept was added somewhere");
VarvPerformance.stop("DOMView.registerEventCallback.appeared", mark);
});
let deleteCallback = VarvEngine.registerEventCallback("disappeared", (evt) => {
// STUB: Rebuilding everything
self.stubTriggerFullRebuild(templateElement, "Concept was removed somewhere");
});
let reloadCallback = VarvEngine.registerEventCallback("engineReloaded", (evt) => {
// STUB: Rebuilding everything
self.stubTriggerFullRebuild(templateElement, "Engine was reloaded entirely");
});
self.addCleanup(view, () => {
addCallback.delete();
deleteCallback.delete();
reloadCallback.delete();
});
// Go through the varv template one node at a time and clone it into the view
for (let templateChild of Array.from(templateElement.childNodes)) {
await self.cloneToView(targetDocument, view, templateChild);
}
// Insert into target document
let targetSpec = templateElement.getAttribute("target-element");
view.targetSpec = targetSpec;
view.targetFrameSpec = targetFrameSpec;
if ((oldView && oldView.targetSpec!=targetSpec) || (oldView && oldView.targetFrameSpec!=targetFrameSpec)){
// Old view was somewhere else, tear it down
self.tearDownElement(oldView);
oldView = false;
}
if (oldView && !oldView.parentNode){
// Old view was outside any document, it is useless
console.log("DOMView: Was replacing a view but it had no parent in any document and was useless", templateElement);
self.tearDownElement(oldView);
oldView = false;
}
if (oldView==null || oldView==false) {
// This is the first render, insert into target document
if (targetFrameSpec){
// Rendering to an iframe
if (targetSpec){
// This template uses a custom render target element, try to find it
let targetElement = targetDocument.querySelector(targetSpec);
if (targetElement){
targetElement.appendChild(view);
} else {
console.error("DOMView: Rendering into nothingness since template target-element does not exist in target iframe: ", targetFrameSpec, targetSpec);
}
} else {
// Just plain add it to body
targetDocument.body.appendChild(view);
}
} else {
// Rendering to the local document
if (targetSpec){
// This template uses a custom render target element, try to find it
let targetElement = targetDocument.querySelector(targetSpec);
if (targetElement){
targetElement.appendChild(view);
} else {
console.error("DOMView: Rendering into nothingness since template target-element does not exist in document: ", targetSpec);
}
} else {
// Default is to render just after the template element.
// Special-case for CodeStrates-based templates (avoid getting deleted inside autoDOM)
let autoDOM = templateElement.closest(".autoDom");
if (autoDOM){
// Add after autoDOM instead of inside of it
if (!autoDOM.parentNode){
console.log("DOMView: Was rendering an autoDOM template but it had no parent", templateElement);
}
autoDOM.parentNode.insertBefore(view, autoDOM.nextElementSibling);
} else {
// Outside we just insert after the template directly
if (!templateElement.parentNode){
console.log("DOMView: Was rendering a non autoDOM template but it had no parent", templateElement);
}
templateElement.parentNode.insertBefore(view, templateElement.nextElementSibling);
}
}
}
} else {
// Replace old view
oldView.parentNode.insertBefore(view, oldView);
self.tearDownElement(oldView);
}
// View has updated
templateElement.varvView = view;
} catch (ex){
console.error("DOMView render exception", ex);
}
templateElement.isRendering = false;
templateElement.renderTimeout = null;
if (templateElement.isRenderingInterruption){
// We got interrupted while rendering, try again
templateElement.isRenderingInterruption = false;
self.rebuildView(templateElement);
}
VarvPerformance.stop("DOMView.rebuildView", mark)
}, 100);
}
}
async cloneToView(targetDocument, currentViewElement, currentTemplateNode, currentScope = [], currentInsertBeforeElement=null) {
const self = this;
let results = [];
switch (currentTemplateNode.nodeType){
case Node.COMMENT_NODE:
// Drop all comments to minify view as much as possible - we cannot update them properly anyways
break;
case Node.TEXT_NODE:
// Rewrite contents by matching {zzz} with content from the scope and updating it if it changes
let element = targetDocument.createTextNode("");
element.templateElement = currentTemplateNode;
await new Promise(initialUpdateResolve => {
let selfUpdatingString = new UpdatingStringEvaluation(currentTemplateNode.nodeValue, currentScope, function textNodeUpdated(text){
element.nodeValue = text;
if (initialUpdateResolve){ // We wait for the first update to avoid voids in the UI rendering
initialUpdateResolve();
initialUpdateResolve = false;
}
});
self.addCleanup(element, ()=>{
selfUpdatingString.destroy();
});
});
currentViewElement.appendChild(element);
results.push(element);
break;
case Node.ELEMENT_NODE:
// Element nodes are more complicated
switch (currentTemplateNode.tagName) {
case "TEMPLATE":
console.error("DOMView: <template> element is deprecated inside dom view template - use <varv-template> instead! Live editing is disabled for this element", currentTemplateNode);
case "VARV-TEMPLATE":
// Ignored entirely, only ever used directly from varv template
break;
default:
// A conditional if attribute may block elements from rendering
let conditionalIf = async function conditionalIf(localInsertBeforeElement, insertList, scope) {
let ifAttributeRaw = currentTemplateNode.getAttribute("if");
if (ifAttributeRaw!==null){
// Inject a handle for updating the property
let conditionalHandle = targetDocument.createProcessingInstruction("whenjs-conditional-handle", ifAttributeRaw);
insertList.push(conditionalHandle); // Only expose this handle to the outside, we take care of the rest ourselves
currentViewElement.insertBefore(conditionalHandle, localInsertBeforeElement);
let conditionalHandleChildren = [];
// Handle changes to looked up conditional attribute itself (i.e. when dynamically using {...} as the attribute
let selfUpdatingConditionalAttribute = new UpdatingStringEvaluation(ifAttributeRaw, scope, async function conditionalAttributeUpdated(conditionSource){
// Remove previously rendered children
for (let child of conditionalHandleChildren){
self.tearDownElement(child);
}
conditionalHandleChildren = [];
try {
let negate = false;
let isTestingInstanceOf = false;
let testType;
// Check wether this is an existance "if" or type "if"
let originalConditionSource = conditionSource;
if (conditionSource.includes("concept ")){ // STUB: should probably be regex with captures...
// Instance-of if
if(conditionSource.startsWith("!")) throw new Error("DOMView: Unsupported negate of instance-of check");
isTestingInstanceOf = true;
testType = conditionSource.substring(conditionSource.indexOf("concept ")+8);
conditionSource = conditionSource.replace("concept "+testType, "").trim();
if (conditionSource.length === 0){
conditionSource = "concept::uuid"; // Use most recently bound concept
}
} else {
// Standard if
if(conditionSource.startsWith("!")) {
conditionSource = conditionSource.substring(1);
negate = true;
}
}
if (conditionSource === undefined){
console.warn("DOM varv template has conditional attribute '"+ifAttributeRaw+"' that evaluates to undefined", scope, currentTemplateNode);
throw new Error("Cannot render conditional element where condition source '"+ifAttributeRaw+"' evaluates to undefined");
}
let binding = await DOMView.getBindingFromScope(conditionSource, scope);
if (!binding) {
console.warn("DOM varv template conditional selecting undefined '"+conditionSource+"' not bound in scope: ", scope, currentTemplateNode);
throw new Error("Selecting conditional boolean '"+conditionSource+"' not bound in scope");
}
// Perform the actual conditional test
let conditionalValue = false;
if (isTestingInstanceOf){
let testTarget = await binding.getValueFor(conditionSource);
if (testTarget instanceof ConceptInstanceBinding){
testTarget = testTarget.concept;
} else {
// This may be an uuid, if so, look it up instead
testTarget = await VarvEngine.getConceptFromUUID(testTarget);
}
conditionalValue = testTarget.isA(testType);
} else {
// here everything that js considers "true" is accepted
try {
conditionalValue = await binding.getValueFor(conditionSource);
if(negate) {
conditionalValue = ! conditionalValue;
}
} catch (ex){
// Ignore this
console.warn(ex);
}
}
if (conditionalValue) {
for (let resultingChild of await self.insertFromTemplateElement(targetDocument, currentViewElement, currentTemplateNode, scope, conditionalHandle)){
conditionalHandleChildren.push(resultingChild);
}
}
// Register property update callbacks for the final looked up conditional source
let property = null;
if (binding instanceof ConceptInstanceBinding){
property = binding.concept.getProperty(conditionSource);
} else if (binding instanceof PropertyBinding){
property = binding.property;
}
if (property){
// The value is property-based and can update
let callback = (uuid)=>{
if (uuid===binding.uuid){
conditionalAttributeUpdated(originalConditionSource); // Re-render ourselves
}
};
property.addUpdatedCallback(callback);
// Clean it up later
let cleanupHandle = targetDocument.createProcessingInstruction("whenjs-cleanup-handle", ifAttributeRaw);
currentViewElement.insertBefore(cleanupHandle, conditionalHandle);
conditionalHandleChildren.push(cleanupHandle);
self.addCleanup(cleanupHandle, ()=>{
property.removeUpdatedCallback(callback);
});
};
} catch (exception){
console.warn("DOM varv template conditional value evaluation caused an error: ", exception, conditionSource, scope, currentTemplateNode);
let child = self.createErrorElement("Evaluating '"+conditionSource+"' caused "+exception, targetDocument);
currentViewElement.insertBefore(child, conditionalHandle);
conditionalHandleChildren.push(child); // intentionally only exposed to conditionalHandleChildren
}
});
self.addCleanup(conditionalHandle, ()=>{
selfUpdatingConditionalAttribute.destroy();
for (let child of conditionalHandleChildren){
self.tearDownElement(child);
}
conditionalHandleChildren = [];
});
} else {
// No if-attribute, everything goes straight thru
for (let resultingChild of await self.insertFromTemplateElement(targetDocument, currentViewElement, currentTemplateNode, scope, localInsertBeforeElement)){
insertList.push(resultingChild);
}
}
}
// Property Attribute: May create potential duplicates
let splitProperty = async function splitProperty(localInsertBeforeElement, insertList, scope) {
// Selecting a property changes what is in scope and could potentially create duplicates if the property is an array
let propertyAttributeRaw = currentTemplateNode.getAttribute("property");
if (propertyAttributeRaw!==null){
// Inject a handle for updating the property
let outerPropertyHandle = targetDocument.createProcessingInstruction("varv-property-handle", propertyAttributeRaw);
insertList.push(outerPropertyHandle); // Only expose this handle to the outside, we take care of the rest ourselves
currentViewElement.insertBefore(outerPropertyHandle, localInsertBeforeElement);
let outerPropertyHandleChildren = [];
// Handle changes to looked up property type attribute itself (i.e. when dynamically using {...} as the attribute
let selfUpdatingPropertyAttribute = new UpdatingStringEvaluation(propertyAttributeRaw, scope, async function propertyAttributeUpdated(propertyType){
//Fix scope being added too, every new call to propertyAttributeUpdated
const clonedScope = scope.slice();
// Remove previously rendered children
for (let child of outerPropertyHandleChildren){
self.tearDownElement(child);
}
outerPropertyHandleChildren = [];
let handle = targetDocument.createProcessingInstruction("varv-property-handle", propertyType);
outerPropertyHandleChildren.push(handle); // This handle is intentionally only exposed to the outerPropertyHandle
currentViewElement.insertBefore(handle, outerPropertyHandle);
// Insert the new ones
try {
if (propertyType === undefined){
console.warn("DOM varv template selecting property '"+propertyAttributeRaw+"' that evaluates to undefined", clonedScope, currentTemplateNode);
throw new Error("Cannot render property '"+propertyAttributeRaw+"' which evaluates to undefined");
}
let binding = await DOMView.getBindingFromScope(propertyType, clonedScope);
if (!binding) {
console.warn("DOM varv template selecting undefined property '"+propertyType+"' not bound in scope: ", clonedScope, currentTemplateNode);
throw new Error("Selecting undefined property '"+propertyType+"' not bound in scope");
}
if (!(binding instanceof ConceptInstanceBinding)){
console.warn("DOM varv template selecting property that was bound on something else than a concept, this is a bug: ", propertyType, clonedScope, currentTemplateNode);
throw new Error("Cannot select a property that was bound to something else than a concept");
}
// Register update callbacks for the final looked up property
let property = binding.concept.getProperty(propertyType);
let callback = ()=>{
propertyAttributeUpdated(propertyType); // Re-render ourselves
};
property.addUpdatedCallback(callback);
// Clean it up later
self.addCleanup(handle, ()=>{
property.removeUpdatedCallback(callback);
});
let propertyValue = await binding.getValueFor(propertyType);
if (propertyValue != null) {
if (Array.isArray(propertyValue)) {
// We need duplication
// STUB: No filtering of property values in SPEC?
// STUB: No sorting of property values in SPEC?
let index = 0;
for (let arrayEntry of propertyValue) {
let childScope = clonedScope.slice(); // Copy the scope
let newBinding = {};
if (arrayEntry instanceof ConceptInstance) {
childScope.push(arrayEntry); // If this was a concept, add it to the scope
childScope.push(new ValueBinding({
'concept::uuid': arrayEntry.uuid
})); // Make the uuid referenceable for debug etc
childScope.push(new ValueBinding({
'concept::name': arrayEntry.concept.name
})); // Make the concept.name referenceable for debug etc
newBinding[propertyType + ".value"] = arrayEntry.uuid;
} else {
newBinding[propertyType + ".value"] = arrayEntry; // otherwise then the value becomes bound under X.value
}
newBinding[propertyType + ".index"] = index;
index++;
childScope.push(new PropertyBinding(binding.concept.getProperty(propertyType), binding.uuid));
childScope.push(new ValueBinding(newBinding));
await conditionalIf(handle, outerPropertyHandleChildren, childScope);
}
} else {
// Single property value, no duplication
// Warn if this is not a concept as non-concept non-list values make no sense
if (!(propertyValue instanceof ConceptInstance)) {
console.warn("DOM varv template using something that is not a list of simple values, a concept or a list of concepts as a property, this is not valid", propertyType, clonedScope, currentTemplateNode);
throw new Error("Cannot use a type for the property attribute that is not a list of simple values or a concept reference");
}
clonedScope.push(await ConceptInstanceBinding.create(propertyValue.concept, propertyValue.uuid));
clonedScope.push(new PropertyBinding(binding.concept.getProperty(propertyType), binding.uuid));
clonedScope.push(new ValueBinding({
'concept::uuid': propertyValue.uuid,
'concept::name': propertyValue.concept.name,
[propertyType + ".value"]: propertyValue.uuid
}));
await conditionalIf(handle, outerPropertyHandleChildren, clonedScope);
}
}
} catch (exception){
console.warn("DOM varv template selecting property where value evaluation caused an error: ", exception, propertyType, clonedScope, currentTemplateNode);
let child = self.createErrorElement("Evaluating '"+propertyType+"' caused "+exception, targetDocument);
currentViewElement.insertBefore(child, handle);
outerPropertyHandleChildren.push(child); // intentionally only exposed to outerPropertyHandle
}
});
self.addCleanup(outerPropertyHandle, ()=>{
selfUpdatingPropertyAttribute.destroy();
for (let child of outerPropertyHandleChildren){
self.tearDownElement(child);
}
outerPropertyHandleChildren = [];
});
} else {
// No property, straight clone
await conditionalIf(localInsertBeforeElement, insertList, scope);
}
}
// Concept Attribute: Do we need to clone multiple nodes due to a concept selection?
let conceptAttributeRaw = currentTemplateNode.getAttribute("concept");
if (conceptAttributeRaw!==null){
// Inject a handle for updating the attribute
let conceptHandle = targetDocument.createProcessingInstruction("varv-concept-handle", conceptAttributeRaw);
results.push(conceptHandle); // Expose this handle to the outside
try {
currentViewElement.insertBefore(conceptHandle, currentInsertBeforeElement);
} catch (ex) {
console.error("Concept insert before failed with ", conceptHandle, currentInsertBeforeElement);
}
let conceptChildren = [];
// Handle changes to looked up concept type attribute itself (i.e. when dynamically using {...} as the attribute
let selfUpdatingConceptAttribute = new UpdatingStringEvaluation(conceptAttributeRaw, currentScope, async function conceptAttributeUpdated(conceptType){
if (!conceptHandle.parentNode) {
if (DOMView.DEBUG){
console.warn("FIXME: Harmless conceptAttributeUpdated while handle was not in DOM anymore, be sure to destroy the evaluator before causing any updates, ignored for now", conceptHandle);
}
return;
}
// Remove previously rendered children
for (let child of conceptChildren){
self.tearDownElement(child);
}
conceptChildren = [];
// Insert the new ones
try {
if (conceptType === undefined) {
console.warn("DOM varv template selecting concept '"+conceptAttributeRaw+"' that evaluates to undefined", currentScope, currentTemplateNode);
throw new Error("Cannot render concept '"+conceptAttributeRaw+"' which evaluates to undefined");
}
let concept = VarvEngine.getConceptFromType(conceptType);
if (!concept){
console.warn("DOM varv template selects concept '"+conceptType+"' that doesn't currently exist", conceptAttributeRaw, currentScope, currentTemplateNode);
throw new Error("Cannot render concept '"+conceptAttributeRaw+"' which evaluates to "+conceptType+" which does not exist");
}
let conceptUUIDs = await VarvEngine.getAllUUIDsFromType(conceptType, true);
// STUB: No filtering of concepts in SPEC?
// STUB: No sorting of concepts in SPEC?
for(let uuid of conceptUUIDs) {
let childScope = currentScope.slice(); // Copy current
let concreteConceptType = await VarvEngine.getConceptFromUUID(uuid);
childScope.push(await ConceptInstanceBinding.create(concreteConceptType, uuid)); // Add this new concept to lookup scope but with the concrete type
childScope.push(new ValueBinding({
'concept::uuid': uuid
})); // Make the uuid referenceable for debug etc
childScope.push(new ValueBinding({
'concept::name': concreteConceptType.name
})); // Make the concept.name referenceable for debug etc
await splitProperty(conceptHandle, conceptChildren, childScope);
}
} catch (exception){
console.warn("DOM varv template selecting concept where value evaluation caused an error: ", exception, conceptAttributeRaw, currentScope, currentTemplateNode);
let child = self.createErrorElement("Evaluating '"+conceptAttributeRaw+"' caused "+exception, targetDocument);
// TODO: Check if this if test is correct
if(conceptHandle.parentNode != null) {
currentViewElement.insertBefore(child, conceptHandle);
conceptChildren.push(child); // intentionally only exposed to conceptChildren
}
}
});
self.addCleanup(conceptHandle, ()=>{
selfUpdatingConceptAttribute.destroy();
// Remove previously rendered children
for (let child of conceptChildren){
self.tearDownElement(child);
}
conceptChildren = [];
});
} else {
// No concept attribute just append directly and add to results directly
await splitProperty(currentInsertBeforeElement, results, currentScope.slice());
}
break;
}
break;
default:
// Unknown non-element nodes are copied verbatim
let unknown = targetDocument.importNode(currentTemplateNode,false);
currentViewElement.insertBefore(unknown, currentInsertBeforeElement);
results.push(unknown);
}
return results;
}
/**
* Returns element with evaluation of values for a single template element, can return multiple
* @param {HTMLElement} templateElement
* @param {any[]} scope
* @returns {Promise<Node[]>} A list of the immediate nodes that were appended to currentViewElement
*/
async insertFromTemplateElement(targetDocument, currentViewElement, templateElement, scope, insertBeforeElement=null) {
const self = this;
let topLevelResults = [];
switch (templateElement.tagName){
case "TEMPLATE-REF":
// Find and jump into DOM template and recurse through it
let templateAttributeRaw = templateElement.getAttribute("template-name");
if (templateAttributeRaw === null) {
console.warn("template-ref without template-name, ignoring", templateElement);
return [];
}
// Check for children and warn since this is invalid
if (templateElement.childElementCount>0){
console.warn("template-ref with children elements, the children are ignored", templateElement);
}
// Immediately inject our handle, this is where we create our nodes asynchroneously
let handle = targetDocument.createProcessingInstruction("varv-template-handle", templateAttributeRaw);
topLevelResults.push(handle); // The handle is our only visible top-level result, we handle our children ourselves
currentViewElement.insertBefore(handle, insertBeforeElement);
let ourChildren = [];
let render = async function renderTemplateReference(templateName){
// Clean up any previous render
for (let child of ourChildren){
self.tearDownElement(child);
}
ourChildren = [];
if (!handle.parentNode){
// We have been torn out of the tree yet still got an update
if (DOMView.DEBUG){
console.log("STUB: Tried to render a template ref while outside of the document, this is likely fine if the result still works ok but should be avoided");
}
return;
}
// Find the template and insert it
let templates = document.querySelectorAll("template[name='" + templateName+"'], varv-template[name='" + templateName+"']");
let template = templates[templates.length - 1];
if (!template) {
// Reffed template does not exist (yet?), insert temporary failure node and wait for it
let child = self.createErrorElement("template-ref with template-name '"+templateName+"' that does not exist (yet?)", targetDocument);
ourChildren.push(child);
currentViewElement.insertBefore(child, handle);
} else {
for(let childTemplateNode of Array.from(template.content?template.content.childNodes:template.childNodes)){ // Could be a HTML template element
for (let child of await self.cloneToView(targetDocument, currentViewElement, childTemplateNode, scope, handle)){
ourChildren.push(child);
}
}
}
};
let appearCallback = null;
let disappearCallback = null;
// Handle changes to looked up template attribute itself (i.e. when dynamically using {...} as the attribute
await new Promise(initialUpdateResolve => {
let selfUpdatingTemplateAttribute = new UpdatingStringEvaluation(templateAttributeRaw, scope, async function templateAttributeUpdated(templateName){
// Clean up old template callbacks (if set)
if (appearCallback) appearCallback.delete();
if (disappearCallback) disappearCallback.delete();
let updateTimer = setTimeout(function(){
render(templateName);
initialUpdateResolve();
}, 0);
// Listen to template updates
appearCallback = EventSystem.registerEventCallback("domview.template.appeared", async (evt)=>{
if (evt.detail===templateName){
clearTimeout(updateTimer);
updateTimer = setTimeout(function(){
render(templateName);
initialUpdateResolve();
}, 0);
}
});
disappearCallback = EventSystem.registerEventCallback("domview.template.disappeared", async (evt)=>{
if (evt.detail===templateName){
clearTimeout(updateTimer);
updateTimer = setTimeout(function(){
render(templateName);
initialUpdateResolve();
}, 0);
}
});
});
self.addCleanup(handle, ()=>{
selfUpdatingTemplateAttribute.destroy();
if (appearCallback) appearCallback.delete();
if (disappearCallback) disappearCallback.delete();
for (let child of ourChildren){
self.tearDownElement(child);
}
});
});
break;
default:
let element = targetDocument.importNode(templateElement,false);
element.templateElement = templateElement;
// Evaluate all attributes
for(let attr of Array.from(templateElement.attributes)) {
let selfUpdatingString = new UpdatingStringEvaluation(attr.value, scope, function attributeNodeUpdated(value){
let shouldUpdateAttribute = true;
// Check for special attributes
if (attr.name==="value"){
if (element.tagName==="INPUT" || element.tagName==="TEXTAREA"){
shouldUpdateAttribute = false;
if (element.type==="checkbox"){
element.checked = value==="true" || value===true;
} else {
element.value = value;
}
} else if (element.tagName==="SELECT"){
// STUB: wait for the rest of the tree to render so that our OPTIONS nodes are ready
// TODO: Move this into a post-render queue to avoid flickering
shouldUpdateAttribute = false;
setTimeout(()=>{
console.log("Trying to set value to ", value);
element.value = value;
},0);
} else if (element.tagName==="DIV"){
shouldUpdateAttribute = false;
if (!element.blockReadbacks){
element.innerHTML = value;
}
}
} else if(attr.name === "disabled" && value === "false") {
// Don't move disabled=false over
shouldUpdateAttribute = false;
element.removeAttribute(attr.name);
}
if (shouldUpdateAttribute){
element.setAttribute(attr.name, value);
}
});
this.addCleanup(element, ()=>{
selfUpdatingString.destroy();
});
}
// Recurse to children nodes with this new element as viewtop
for(let templateChild of Array.from(templateElement.childNodes)) {
await self.cloneToView(targetDocument, element, templateChild, scope);
}
// Check for special elements that can push data back to the concepts
if (element.tagName==="INPUT" || element.tagName==="TEXTAREA"){
let valueLookupName = self.getLookupNameFromAttribute(templateElement, "value");
if (valueLookupName!==null){
let binding = DOMView.getBindingFromScope(valueLookupName,scope);
if (!(binding instanceof ConceptInstanceBinding)){
console.warn("Input field values cannot be bound to something that is not a concept property", valueLookupName, templateElement);
} else {
switch (element.getAttribute("type")){
case "checkbox":
element.addEventListener("input", ()=>{
binding.setValueFor(valueLookupName, element.checked);
});
break;
default:
element.addEventListener("input", ()=>{
binding.setValueFor(valueLookupName, element.value);
});
}
}
}
} else if (element.tagName==="SELECT"){
let valueLookupName = self.getLookupNameFromAttribute(templateElement, "value");
if (valueLookupName!==null){
let binding = DOMView.getBindingFromScope(valueLookupName,scope);
if (!(binding instanceof ConceptInstanceBinding)){
console.warn("DOMView: Select option group cannot be bound to something that is not a concept property", valueLookupName, templateElement);
} else {
element.addEventListener("input", ()=>{
binding.setValueFor(valueLookupName, element.value);
});
}
}
} else if (element.tagName==="DIV" && element.getAttribute("value") && element.getAttribute("contenteditable")!==null){
let valueLookupName = self.getLookupNameFromAttribute(templateElement, "value");
if (valueLookupName!==null){
let binding = DOMView.getBindingFromScope(valueLookupName,scope);
if (!(binding instanceof ConceptInstanceBinding)){
console.warn("DOMView: Contenteditable DIV with value cannot be bound to something that is not a concept property", valueLookupName, templateElement);
} else {
let coalesceTimer = null;
let needsAnotherUpdate = false;
element.addEventListener("input", async ()=>{
needsAnotherUpdate = true;
if (!coalesceTimer){
coalesceTimer = setTimeout(async ()=>{
while (needsAnotherUpdate){
needsAnotherUpdate = false;
element.blockReadbacks = true; // Avoid reading our own changes back
await binding.setValueFor(valueLookupName, element.innerHTML);
element.blockReadbacks = false;
}
coalesceTimer = null;
}, 100);
}
});
}
}
}
topLevelResults.push(element);
currentViewElement.insertBefore(element, insertBeforeElement);
}
// Store a reference to the current scope in the elements
for (let element of topLevelResults){
element.scope = scope.slice();
}
return topLevelResults;
}
/**
* Gets an ordered list of concepts instances involved in rendering this view element
* @param {HTMLElement} viewElement
* @returns {string[]}
*/
getConceptPath(viewElement){
let element = viewElement;
while (element != null && !element.scope){
element = element.parentElement;
if (element==null){
// No concepts in this tree path at all
return [];
}
}
let result = [];
if(element != null && element.scope != null) {
for (let binding of element.scope) {
if (binding instanceof ConceptInstanceBinding) {
result.push(binding);
}
}
}
return result;
}
getTemplatePath(viewElement){
let result = [];
let element = viewElement;
while (element != null){
if (element.templateElement){
result.push(element.templateElement);
}
element = element.parentElement;
}
return result.reverse();
}
/**
* Gets an ordered list of properties involved in rendering this view element
* @param {HTMLElement} viewElement
* @returns {string[]}
*/
getPropertyPath(viewElement){
let element = viewElement;
while (element != null && !element.scope){
element = element.parentElement;
if (element==null){
// No concepts in this tree path at all
return [];
}
}
let result = [];
if(element != null && element.scope != null) {
for (let binding of element.scope) {
if (binding instanceof PropertyBinding) {
result.push({uuid: binding.uuid, property: binding.property});
}
}
}
return result;
}
addCleanup(element, cleanupFunction){
if (!element.cleanup) element.cleanup = [];
element.cleanup.push(cleanupFunction);
}
createErrorElement(message, targetDocument){
let element = targetDocument.createElement("varv-failure");
element.setAttribute("title", message);
return element;
}
tearDownElement(element){
if(element.alreadyCleaned) {
if (DOMView.DEBUG) console.warn("STUB: Double teardown, this is most likely fine", element);
return;
}
element.alreadyCleaned = true;
// If the element had any cleanup to do, run it now
if (element.cleanup){
for (let entry of element.cleanup){
entry();
}
}
// If the element has any children, tear them down too
for(let node of Array.from(element.childNodes)){ // copy to avoid concurrent mods
this.tearDownElement(node);
}
// Then remove it from the DOM
element.remove();
}
stubTriggerFullRebuild(templateNode, message=""){
let mark = VarvPerformance.start();
// STUB: All rebuilds are full rebuilds rather than incremental rebuilds for now
if (DOMView.DEBUG) {
console.warn("FIXME: Unimplemtented partial update logic triggered full DOMView rebuild", templateNode, message);
}
if (templateNode.tagName==="DOM-VIEW-TEMPLATE"){
this.rebuildView(templateNode);
} else {
this.rebuildView(cQuery(templateNode).closest("dom-view-template")[0]);
}
VarvPerformance.stop("DOMView.stubTriggerFullRebuild", mark);
}
/**
* Returns a value lookup name if this attribute is on the form \{name\}, null
* if the attribute does not exist
* @param {HTMLElement} templateElement
* @param {string} attributeName
* @returns {undefined}
*/
getLookupNameFromAttribute(templateElement, attributeName){
if (!templateElement || !templateElement.getAttribute) {
console.warn("Evaluate attribute called without sensible input ", templateElement);
}
let attribute = templateElement.getAttribute(attributeName);
if (attribute === null) return null;
if (attribute.startsWith("{") && attribute.endsWith("}")) {
return attribute.substring(1, attribute.length - 1);
} else {
return null;
}
}
static getBindingFromScope(bindingName, scope){
for (let i = scope.length - 1; i >= 0; i--) {
if (scope[i].hasBindingFor(bindingName)) {
return scope[i];
}
}
return undefined;
}
async evaluateValueInScope(bindingName, scope) {
let binding = DOMView.getBindingFromScope(bindingName, scope);
if (binding===undefined) return undefined;
return await binding.getValueFor(bindingName);
}
}
class UpdatingStringEvaluation {
constructor(originalText, scope, onChangeCallback){
this.originalText = originalText;
this.bindings = new Map();
this.tokens = originalText.match(/{(.+?)}/g);
if (!this.tokens) this.tokens = [];
this.onChangeCallback = onChangeCallback;
this.updateCallbacks = [];
this.destroyed = false;
let self = this;
// Prepare it once manually
for(let token of this.tokens) {
token = token.trim();
let lookupQuery = token.substring(1, token.length - 1);
let binding = null;
let propertyName;
if (lookupQuery.includes("?")){
let regexp = /^(?<condition>.+?)\?(?<quote1>["']?)(?<true>.+?)\k<quote1>(?::(?<quote2>["']?)(?<false>.*)\k<quote2>)?$/gm;
let match = regexp.exec(lookupQuery);
propertyName = match.groups.condition;
let negated = false;
if(propertyName.startsWith("!")) {
negated = true;
propertyName = propertyName.substring(1);
}
// Fancy { x ? y : < } query
binding = DOMView.getBindingFromScope(propertyName, scope);
this.bindings.set(lookupQuery, async ()=>{
if (binding===undefined) return undefined;
let value = await binding.getValueFor(propertyName);
let trueValue = match.groups.true;
let falseValue = typeof match.groups.false === "undefined"?"":match.groups.false;
if(negated) {
let tmp = trueValue;
trueValue = falseValue;
falseValue = tmp;
}
return value?trueValue:falseValue;
});
} else {
// Normal {} query, the entire thing is the name
propertyName = lookupQuery;
binding = DOMView.getBindingFromScope(propertyName, scope);
this.bindings.set(lookupQuery, async ()=>{
if (binding===undefined) return undefined;
return binding.getValueFor(propertyName);
});
}
if (binding && binding.concept){
let property = binding.concept.getProperty(propertyName);
let callback = async function updateUpdatingStringEvaluation(uuid){
//Only update this stringEvaluation if the changed property was on the watched concept instance
if(uuid === binding.uuid) {
await self.update();
}
};
property.addUpdatedCallback(callback);
this.updateCallbacks.push({property: property, callback: callback});
}
}
this.update();
}
async update(){
let mark = VarvPerformance.start();
try {
let text = this.originalText;
for(let token of this.tokens) {
token = token.trim();
let lookupQuery = token.substring(1, token.length - 1);
let value = await this.bindings.get(lookupQuery)();
if (value !== undefined){
text = text.replace(token, value); // STUB: This can fail if the first token is replaced with something that looks like the second token
}
}
await this.onChangeCallback(text);
} catch (ex){
console.error(ex);
}
VarvPerformance.stop("UpdatingStringEvaluation.update", mark);
}
destroy(){
if (this.destroyed) {
if (DOMView.DEBUG){
console.warn("FIXME: Harmless double desctruction, ignoring - but try not to destroy me this much");
}
return;
}
for (let entry of this.updateCallbacks){
entry.property.removeUpdatedCallback(entry.callback);
}
this.destroyed = true;
}
}
class ConceptInstance {
constructor(concept, uuid) {
if (!uuid) throw new Error("Invalid reference to concept instance with a null or undefined uuid '"+uuid+"' and concept '"+concept+"'");
if (!concept) throw new Error("Invalid reference to unknown concept with uuid '"+uuid+"', concept is "+concept);
this.concept = concept;
this.uuid = uuid;
}
}
class PropertyBinding {
constructor(property, uuid) {
this.uuid = uuid;
this.property = property;
}
hasBindingFor(name){
// We don't supply this ourselves
return false;
}
}
class ConceptInstanceBinding extends ConceptInstance {
constructor(concept, uuid) {
super(concept, uuid);
}
hasBindingFor(name) {
let lookupName = name;
if(lookupName.startsWith(this.concept.name+".")) {
lookupName = lookupName.substring(this.concept.name.length+1);
}
try {
this.concept.getProperty(lookupName);
return true;
} catch (ex) {
// Ignored
}
return false;
}
async getValueFor(name) {
let lookupName = name;
if(lookupName.startsWith(this.concept.name+".")) {
lookupName = lookupName.substring(this.concept.name.length+1);
}
let property = null;
try {
property = this.concept.getProperty(lookupName);
} catch(e) {
//Ignore
}
if(property === null) {
return undefined;
}
let value = await property.getValue(this.uuid);
if (property.isConceptType()) {
if (!value) return undefined; // No uuid set
return await ConceptInstanceBinding.create(VarvEngine.getConceptFromUUID(value), value);
} else if (property.isConceptArrayType()) {
let conceptArray = [];
for(let entry of value) {
conceptArray.push(await ConceptInstanceBinding.create(VarvEngine.getConceptFromUUID(entry), entry));
}
return conceptArray;
} else {
return value;
}
}
async setValueFor(name, value){
const property = this.concept.getProperty(name);
await property.setValue(this.uuid, property.typeCast(value));
}
static async create(concept, uuid) {
if(typeof concept === "string") {
concept = VarvEngine.getConceptFromType(concept);
}
return new ConceptInstanceBinding(concept, uuid)
}
}
class ValueBinding {
constructor(bindings) {
this.bindings = bindings;
}
hasBindingFor(name) {
return this.bindings.hasOwnProperty(name);
}
async getValueFor(name) {
return this.bindings[name];
}
}
DOMView.DEBUG = false;
//If fragments exists postpone the DOMView until all fragments was loaded at least first time. (Fragments added later obviously does not count)
if(typeof Fragment !== "undefined") {
Fragment.addAllFragmentsLoadedCallback(()=>{
console.log("All fragments loaded!");
DOMView.singleton = new DOMView();
//We started after autoDOM has run, so no mutations. Bootstrap with what we have
let fakeAddMutationList = [{
type: "childList",
target: document.body,
addedNodes: Array.from(document.querySelectorAll("dom-view-template")),
removedNodes: []
}];
DOMView.singleton.mutationCallback(fakeAddMutationList);
});
} else {
//No fragments, just start
DOMView.singleton = new DOMView();
}
window.DOMView = DOMView;