/*! * AggressiveLayout - dynamic layout for the web * Copyright (c) 2011-NOW(), Olof Thorén. All rights reserved. * This software will be open source when ready. * Version 2 - not compatible with version 1 * instead of this: https://github.com/kamranahmedse/developer-roadmap - just aLayout and Node+JSDom */ //NOTE: //This is the bleeding edge version of aggressiveLayout, to work on better algorithms that might break backwards compatibility. //When finalized we will begin work on layout4.js //Should we use strict? Slightly faster otherwise no difference, so why not? 'use strict'; //You can get all the computed styles by calling: var style = window.getComputedStyle(element); //let's get rid of some of that string-typing by having constants. Static position is default, fixed is the one always causing problems var AUTO_CONST = { absolute: "absolute", relative : "relative", static : "static", fixed : "fixed", phone : 1, pad : 2, width: "width", height : "height", top : "top", bottom : "bottom", left : "left", right : "right", auto : "auto", both : "both", none : "none", block : "block", inline : "inline", inlineBlock : "inline-block", float : "cssFloat" }; var AUTO_NODE_TYPE = { ELEMENT_NODE : 1, ATTRIBUTE_NODE : 2, TEXT_NODE : 3 }; //there are 12 different node types //if you want to know the type of element you use element.nodeName var AUTO_DEVICE_WIDTH = { phone : 700, pad : 1024, smallerPhone : 500 } //iPhone 6 landscape is 667, everything bigger is to be considered a "normal" view, below is "constrained". We set this to 700 to capture some in-between Androids too. iPhone 6+ is 736 landscape. //add all types here for easy access when creating elements with JSON var AUTO_TYPE = { div: "newDiv", button : "newButton", anchorLink: "newAnchorLink", dropDownBox : "newDropDownBox", textField : "newTextField", textArea: "newTextArea", checkBox : "newCheckbox", segmentController : "newSegmentController", image : "newImage", table : "newTable", upload: "newUploadDiv", svg: "newSVG", spinner: "newSpinner", textSpinner: "newTextSpinner", tagCloud: "newTagCloud" } var AUTO_EMPTY_IMAGE = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==' //this is the smallest emty image I can think of (1 pixel transparent gif) /** We have started documentation in agg_doc/doc.md Random notes: delay is: setTimeout(func, delay); * there is loading flicker when doing the blog, due to safari using hyphens and nothing else can. var onePoint = 1 / window.devicePixelRatio TODO: allow navigator.sendBeacon when available and useful (when we don't want response) TREEDIFF: There was much problems with the old diff so we started over. This time, if diff - we remove and redo (EXCEPT when we have variables). This makes it a bit slower - but only when there are changes, wich are very slow anyway. Now, build decent tests! Fonts: - loading webfonts is horrible, it either shows no text then download and suddenly: BOOM text. OR it flashes unstyled text first (older browsers). The solution is to inline fonts using base64. This is the only acceptable way. Problem is we only want to do this for non-mac/ios users... - the solution is to load fonts after css has been applied, e.g by fetching the font-data as base64 encoded and loading it. In aggressiveLayout we do this by simply adding the css rule ONLY on the client. Then the default css will contain no fonts, the JS-CSS will be applied at first update (after first render). This way we get first a page with readable text, then it fetches fonts if needed, then it updates the page. We had an option "hideLayoutSetup" before, but it will never be needed unless we start with a complete empty page. */ //First some backwards compatible code, also for Node if (!('localStorage' in window)) { window.localStorage = { _data : {}, setItem : function(id, val) { this._data[id] = String(val); return this._data[id]; }, getItem : function(id) { return this._data.hasOwnProperty(id) ? this._data[id] : undefined; }, removeItem : function(id) { return delete this._data[id]; }, clear : function() { this._data = {}; return this._data; } }; } /** @arg imageLoadingError, if you don't want an empty image when loading fails - set this to null. */ function AggressiveLayout(options) { this.previousAnchor = null this.anchor = null this.elements = {} //this.elements = this; //forward compatible mode. We want to put variables into a safe place so they don't destroy other existing things //we also want an easy way to refer all our elements to our plugins //NOTE that they can still destroy each other. should stop using variables all toghether if we can figure out how. Sometimes you just need references to stuff... if (!options) options = {} this.options = options this.mainClass = options.main || this if (options.textConversion) { this.textConversion = options.textConversion } this.imageLoadingError = AUTO_EMPTY_IMAGE; if (options.imageLoadingError !== undefined) this.imageLoadingError = options.imageLoadingError; this.API_URL = options.API_URL || options.url || null if (options.preventDidChangeOnBlur) this.preventDidChangeOnBlur = true; //layouts can be either the driving force of a layout, or a plugin to other layouts. Then it won't handle window-resizes or onload events, but expect to be called by the main layout when this happens. if (!options.isPlugin) { if (options.plugins) { for (var index = 0; index < options.plugins.length; index++) { this.addPlugin(options.plugins[index]); } } this.ignoreAnchorChanges = false || options.ignoreAnchorChanges if (options.errorURL) { this.errorURL = options.errorURL; var layout = this; window.addEventListener('error', function (error) { layout.errorHandler.call(layout, error) } ); } //why prevent loading if server? if (!window.serverGenerated) { // run layout after html is done, but before images are downloaded. When serverGeneration it calls us when it is ready. this.documentIsLoadedFunc = this.documentIsLoaded.bind(this) window.addEventListener("DOMContentLoaded", this.documentIsLoadedFunc, false); } var resizeEventHandle; if (window.requestAnimationFrame) { //we use animationFrame if we can, so we can update as soon as possible every time! var updateLayoutFunc = this.updateLayout.bind(this); resizeEventHandle = function() { requestAnimationFrame(updateLayoutFunc); } } else { resizeEventHandle = function() { //if resizing, we want to perform the first but skip all other until the last. We do this with a delay. When rotating, this will make layout happen twice. if (!this.reflowTimestpamp || !this.timeStampInTheFuture(this.reflowTimestpamp)) { this.updateLayout(); } this.reflowTimestpamp = this.timeStampMilli(400); if (this.reflowImageTimeout) window.clearTimeout(this.reflowImageTimeout); var reflow = function() { this.reflowTimestpamp = null; window.clearTimeout(this.reflowImageTimeout); this.updateLayout(); }.bind(this) this.reflowImageTimeout = setTimeout(reflow, 400); }.bind(this) } // all browsers except IE before version 9 window.addEventListener("resize", resizeEventHandle, false); } } AggressiveLayout.prototype = { platform: function() { if (!this.platformDic) { var platformDic = { chrome: navigator.userAgent.indexOf('Chrome') > -1, explorer: navigator.userAgent.indexOf('MSIE') > -1, firefox: navigator.userAgent.indexOf('Firefox') > -1, safari: (/Safari/i).test(navigator.appVersion), opera: navigator.userAgent.indexOf("Presto") > -1, iPhone: (/iPhone /).test(navigator.appVersion), android: navigator.userAgent.match(/Android/i), } platformDic.iOS = platformDic.iPhone || (/ipod|ipad/gi).test(navigator.platform) if (platformDic.safari) { if (platformDic.chrome || platformDic.explorer) platformDic.safari = false; else if ('standalone' in navigator && navigator.standalone) { platformDic.standalone = true } } this.platformDic = platformDic } return this.platformDic }, //when we can better detect hover, we can change things here hasHover: function() { var platform = this.platform() return (!platform.iOS && !platform.android) }, } //every layout-plugin automatically get a reference to AggressiveLayout, if they need to do some layout. Just implement the setLayout function. AggressiveLayout.prototype.addPlugin = function(plugin) { if (!this.pluginArray) this.pluginArray = [] if (!this.plugins) this.plugins = {} //we have a dictionary the plugins can save themselves to. this.pluginArray.push(plugin) if (plugin.setLayout) plugin.setLayout(this); } AggressiveLayout.prototype.removePlugin = function(plugin) { if (!this.pluginArray) return; for (var index = 0; index < this.pluginArray.length; index++) { var item = this.pluginArray[index]; if (item == plugin) { this.pluginArray.splice(index, 1) break; } } for (var key in this.plugins) { if (this.plugins.hasOwnProperty(key)) { var item = this.plugins[key]; if (item == plugin) { delete this.plugins[key] break; } } } } /* for every layout function go through your plugins and see if they respond to the method call. Plugins can return true if they have handled the message and think that execution should end. Good when you want to know if e.g. an error has been presented to the user or not. */ AggressiveLayout.prototype.handlePlugins = function(functionToCall, argumentArray) { if (!this.pluginArray || this.pluginArray.length == 0) return; for (var index = 0; index < this.pluginArray.length; index++) { var plug = this.pluginArray[index] if (plug[functionToCall]) { plug[functionToCall].apply(plug, argumentArray) } } } /*we update layout in this order: 1. call plugins that need to perform layout BEFORE the main function 2. call the main layoutFunction 3. call plugins that need to perform layout after the main function This way plugins can setup/create needed divs and let the main script fill them with content. If we have things that need calculation, like reflowing text or filling parents - this can be done in the layoutFunction. Be aware to never call updateLayout from layoutBefore/layoutFunction - since this can/will create infinte loops. Other plugins may want to do it the other way - only modify divs that already exists. */ AggressiveLayout.prototype.updateLayout = function() { //probably never needed to call layoutBefore, but if you need to layout before plugins you can do so here. if (this.layoutBefore) this.layoutBefore(); this.handlePlugins("layoutBefore"); //instead of fetching with a function, just use a property that will always contain the json data (or not exist). if (this.mainClass.layoutJSONData) { this.layoutJSON(this.elements.rootElement, this.mainClass.layoutJSONData) } //if getLayout or templates are used, we do not always need the layoutFunction if (this.mainClass.layoutFunction) this.mainClass.layoutFunction(); this.handlePlugins("layoutFunction"); } /* If you are using layoutJSONData, the components need to tell layout (their callers, to rebuild their json) It always ends with a call to updateLayout, to also refresh the UI The reason to have separate names is to allow for you to overload the layout object. */ AggressiveLayout.prototype.updateJSONData = function() { this.handlePlugins("updateJSON"); if (this.mainClass.updateJSON) this.mainClass.updateJSON() this.updateLayout() } AggressiveLayout.prototype.getAnchor = function() { var anchor = window.location.hash; if (anchor.length <= 1) { return null; } var hashPos = anchor.indexOf('#'); if (hashPos !== -1) { anchor = anchor.substr(1) } return anchor; } AggressiveLayout.prototype.getSearch = function() { //chrome does not have "search" as a parameter anymore - says who? var search = window.location.search; if (search.charAt(0) == "?") search = search.substring(1) return search; /* var searchPos = window.location.href.indexOf('?'); if (searchPos != -1) { var search = window.location.href.substring(searchPos + 1) //find hash and remove if exists var searchPos = window.location.href.indexOf('#'); } return null; */ } //A convinient way to get your anchor into a dictionary like URL.html#app=38&paper=543 becomes { app: "38", paper: "543" } //it may be mixed with value-omitted keys, where the result will contain an array "adActionList" with those keys, like: //URL.html#leadSinger&tourPlans&app=38&paper=543 becomes { app: "38", paper: "543" } will add { ... adActionList: ["leadSinger", "tourPlans"]} AggressiveLayout.prototype.parseQueryString = function(queryString, optionalStart) { if (!queryString) return {}; var firstChar = queryString.charAt(0) if ((optionalStart && firstChar == optionalStart) || firstChar == "#" || firstChar == "?") { //sometimes when getting the anchor it also returns you the #-tag. queryString = queryString.substring(1) } var actions = {} var items = queryString.split("&") for (var index = 0; index < items.length; index++) { var trigger = items[index].split("="); if (trigger && trigger.length > 1) { actions[trigger[0]] = trigger[1] } else if (trigger != "_") { if (!actions.adActionList) actions.adActionList = [] actions.adActionList.push(trigger[0]) } } return actions; } //A convinient way to modify one element in e.g. your anchor/hash, or add it if the element is missing. //like how #key=value&name=theKing becomes: #key=newValue&name=theKing //use it like this: var newAnchor = this.layout.modifyValueInQueryString(this.anchor, "articleEdit", element.value, "#") //this.setAnchor(newAnchor) AggressiveLayout.prototype.modifyValueInQueryString = function(queryString, key, newValue, optionalStart) { var actions; if (queryString) { if (!optionalStart) optionalStart = "#" if (queryString.charAt(0) == optionalStart) { //sometimes when getting the anchor it also returns you the #-tag. queryString = queryString.substring(1) } actions = this.parseQueryString(queryString, optionalStart); } if (!actions || !actions[key]) { if (newValue == undefined) return queryString; //no need to delete value since it didn't exist if (!queryString || queryString == "") return key + "=" + newValue return queryString + "&" + key + "=" + newValue } //set or delete value if (newValue == undefined) delete actions[key] else actions[key] = newValue var result = [] for (var item in actions) { if (item == "adActionList") { var list = actions[item] if (list.length > 1 || (list[0] != "_" && list[0] != "null")) console.error("list is used instead of naming your parameters, ILLIGAL!"); //otherwise this is just the regular null-hash. (we want to remove stuff) //result.appendArray(actions[item]) } else if (actions.hasOwnProperty(item)) { result.push(item + "=" + actions[item]) } } return result.join("&") } ///use setAnchor to make browsers not scroll weirdly when anchor changes. AggressiveLayout.prototype.setAnchor = function(anchor, event) { if (anchor == this.anchor) return false; //no change if (!anchor) { if (this.anchor && this.anchor.length) anchor = "_"; //it is illigal to leave the anchor empty. The page will then scroll to top. else return false; //if we don't change the anchor no point in } if (history.pushState) { try { history.pushState(null, null, '#' + anchor); this.detectAnchorChange(event || true) } catch (error) { console.log(error); window.location.hash = anchor } } else { window.location.hash = anchor } return true; } //This rolls the three above functions into one, quick and easy to remember! AggressiveLayout.prototype.modifyAnchor = function(key, newValue, optionalStart) { var newAnchor = this.modifyValueInQueryString(this.anchor, key, newValue, optionalStart) return this.setAnchor(newAnchor) } /** NOTE: initialRequest is removed and anchorChangeCallback is always called (even if there is no anchor). This is to make things easier, you won't need to check both places - every plugin will always process anchorChangeCallback. NOTE2: Don't call handle plugins or call updateLayout in your own anchorChangeCallback function - this is taken care of automatically. BUT IF you want anchorChangeCallback to prevent updating layout, return true. Called when anchor (hash) changes, and when app starts so you know what parameters you need to send to the server to setup the initial UI. Also: use it to make bookmarkable paths to your app. Plugins are always called afterwards, but all is processed before updateLayout is called. If you find yourself in a situation where this is problematic - you are doing something wrong. Look at it this way: Whenever anchors change we will need to update the UI, to do this properly - set states in anchorChangeCallback and then let your updateLayout function do the drawing. @param actions, if you structure your anchor like #key=value&anotherKey=anotherValue actions will be a dictionary with these keys and values */ AggressiveLayout.prototype.anchorChangeHandler = function(event, actions) //never override this, instead use anchorChangeCallback! { if (this.mainClass.anchorChangeCallback) this.mainClass.anchorChangeCallback(event, actions); this.handlePlugins("anchorChangeCallback", [event, actions]); this.updateLayout(); } AggressiveLayout.prototype.detectAnchorChange = function(event) { var newAnchor = this.getAnchor(); if (!newAnchor) { //if backing to a state where there are no hash, check for search newAnchor = this.getSearch(); } if (newAnchor != this.anchor) { //we have a new anchor, relayout. this.previousAnchor = this.anchor this.anchor = newAnchor if (this.anchor == "null") newAnchor = null; if (event) { var actions = this.parseQueryString(this.anchor); this.anchorChangeHandler(event, actions) } return true } } AggressiveLayout.prototype.setAttributes = function(element, attributes) { for (var key in attributes) { if (attributes.hasOwnProperty(key)) { var value = attributes[key] element.setAttribute(key, value) } } } AggressiveLayout.prototype.documentIsLoaded = function(event) { if (this.documentIsLoadedFunc) { window.removeEventListener("DOMContentLoaded", this.documentIsLoadedFunc, false); delete this.documentIsLoadedFunc; } //setup hash-handling - a straight forward way for JS to let the user know that it is loading other pages, and allows for URL-copy so the user can share or get back to the same place. if (!this.ignoreAnchorChanges) { //If coming from a shared link, or Search engine indexed page, we have search-query instead of anchor - and no anchor, so transform it to anchor this.anchor = this.getAnchor(); if (!this.anchor) { if (window.autoSearchQuery) { //used by auto server this.anchor = window.autoSearchQuery } else { this.anchor = this.getSearch(); } } //this function gets called one or more times when hash has changed, that is anchor in your url: http...some-url...#anchor //if will only call its callback if there is an actual event var detectAnchorChange = this.detectAnchorChange.bind(this) //listen to Event triggered in JavaScript when the URL's hash has changed, Android browser < 4.1 does not support this if (!('onhashchange' in window)) { //for android var oldHref = location.href; var hashChangeFunction = function() { var newHref = location.href; if (oldHref != newHref) { oldHref = newHref; detectAnchorChange(true); } } setInterval(hashChangeFunction, 100); } else if (window.addEventListener) { window.addEventListener("hashchange", detectAnchorChange, false); } else if (window.attachEvent) { window.attachEvent("onhashchange", detectAnchorChange); } } this.elements.rootElement = document.getElementById(this.options.rootElement || "autoRoot"); //if no root we create a default one in the body if (!this.elements.rootElement) { //if you have external components (like Stripe, SweetAlert, etc) they will apply themselves to the body. And then just using the body won't work, so we solve that by simply creating a root of our own. this.elements.rootElement = this.newDiv({ var: "autoRoot", parent: document.body }) } if (this.mainClass.setupFunction) this.mainClass.setupFunction(this); this.handlePlugins("setupFunction"); if (this.mainClass.afterSetup) this.mainClass.afterSetup(); //imagine you have plugins that setup stuff you need to make use of, then you must wait and setup stuff afterwards. if (this.ignoreAnchorChanges) { this.updateLayout(); } else { //if we care about anchor we get the anchor callback first - so we know which is to be the initial layout. This way we only need to fetch whats relevant. if (this.anchor) { //we have detected an anchor, auto-call the ancor callBack var actions = this.parseQueryString(this.anchor); this.anchorChangeHandler(event, actions); } else this.anchorChangeHandler(event, {}); } if (!this.mainClass.layoutJSONData && this.mainClass.updateJSON) this.updateJSONData() //update if there is no JSON at start. }; /* Creating layout from JSON, an array with arrays on the form: [{ var: variableName, html: "text to display", autoType: autoType, etc,}] omit className from options if you want it to have the same name as var. html is optional, remove it to have no text inside. text can be used instead of html for automatic formating. Extra options: "children" which in turn can be another array - for sub elements autoText, set this to use text instead of html (option.text = html) autoType, can be set to AUTO_TYPE.image for to use aggressiveLayout's newImage function (and similar) instead of newDiv example: var layout = [ { var: "header", text: "#Harry App - Quizk!"}, { children : [ { html: "Här öppnar snart en fantastisk sida för en fantastisk app."}, { var: "ad_login_div", html: "Log in", autoType : AUTO_TYPE.button, plain : 1 }, ] } ] this.layoutJSON(this.elements.rootElement, layout) layoutJSON compares with existing elements - removing elements if it needs to. The purpose of this is primarily to let us create a layout without setting variables on each element, which quickly becomes cumbersome on large applications. "Replace" takes into account all elements in the super node, so you must use a sub-node or let layoutJSON take care of the whole node-tree. If you want it to skip certain subtrees, just omit the "children" of that parent node. TAKE NOTE! You can build your tree with several "replacing" calls to layoutJSON as long as you omit "children" for subtrees you don't want to change. Example: first we build our app with these: var json = [ { html : "Info about Bjork"}, { var: "bjork_sub_tree", children: [ { html : "Dancing in the dark"}, { html : "etc" } ] }, ] this.layoutJSON(this.elements.rootElement, json) //then we need to update the main tree: var json = [ { html : "Info about Bjork" }, { var: "bjork_sub_tree" }, { html : "some other header" }, { var: "other_sub_tree" } ] this.layoutJSON(this.elements.rootElement, json) //now you'll see that the bjork-tree remains untouched. Note these extra params: text - set when you want to use the textConversion on html autoType - set when you want to use one of aggressiveLayout's creation methods Cloning will never work since it just copies some properties, and Object.assign has other probles that should be avoided. Instead we use a "virtual-DOM", by saving the JSON and comparing with the previous one (or the DOM if no JSON exists). Then we modify as little as possible BUT still one change at a time. Perhaps in the future we can come up with a better way of doing this. TODO: supply a target to encapsulate variables (or should we stop using var at all? We already have id, which is global) - Think on this! */ AggressiveLayout.prototype.layoutJSON = function(parentElement, json, settings) { if (!parentElement) { //We must be able to opt-out of optimizations, so if this does not work or makes no sense we can just overwrite / redo. console.warn("Children must have a parent, are you manually moving divs?"); //This can happen if you have a layoutJSON with an element with var, e.g. autoError that you move around manually after layout. Then the previous DOM tree does not match the JSON. - this is ok, we just re-create the rest. return 1; } //if we don't make copies, the diffing is much faster, but that requires you to always create new json for every update. Which is still a good process. But perhaps not working? Let's see. var makeCopies = 1 var onlyAppend = (settings && settings.append); //we diff smarter by first comparing with the json. If they are the same, no need to do work nor compare actual nodes. var childNodes = !onlyAppend && parentElement.children; if (childNodes && childNodes.length == 0) childNodes = null; var parentJSON = !onlyAppend && childNodes && parentElement.autoJSON; if (parentJSON && parentJSON.length) { //Magical tree diffs! var treeError = false; //overwrite/redo if there are tree errors! this.treeDiff(parentElement, json, childNodes); if (json) { for (var index = 0; index < json.length; index++) { var item = json[index]; if (item.children !== undefined) { treeError = this.layoutJSON(childNodes[index], item.children, settings) if (treeError) break; } } } if (!treeError) return; } //save the json for next time, as a shallow copy if (!onlyAppend && json) { for (var index = json.length - 1; index >= 0; index--) { var item = json[index]; if (!item) json.splice(index, 1) } if (makeCopies) parentElement.autoJSON = json.slice() else parentElement.autoJSON = json } var child; //if the plain HTML contains more nodes than there are. if (childNodes && (!json || childNodes.length > json.length)) { //remove nodes that overflow - we won't reach those for replacements. var newJSONLength = (json && json.length) || 0; while (childNodes.length > newJSONLength) { child = childNodes[newJSONLength] child.parentNode.removeChild(child) } } if (!json) return; //if we only wanted to remove children, we exit early. for (var index = 0; index < json.length; index++) { //item is a dict with options for the new element. if (makeCopies) var item = Object.assign({}, json[index]); //we use a shallow copy so we don't change JSON when adding stuff (like turning text into html, etc) else var item = json[index] item.parent = parentElement //find a matching element, we get here first run when server-generated. while (childNodes && childNodes.length > index && !onlyAppend) { //in order to diff these we set the options.element, and call the creation method. All these creation methods must handle diffing with item.element var currentChild = childNodes[index]; var idMissmatch = (item.var && item.var != currentChild.id) || currentChild.id var typeMissmatch = (item.type && item.type.toUpperCase() != currentChild.nodeName) if (idMissmatch || typeMissmatch) parentElement.removeChild(currentChild) else { item.element = currentChild break } //NOTE: we cannot match autoTypes since they don't include type - must be handled by themselves! } //we need to add a new div - this usually happens the first run (when we don't have any elements) var div = null; if (item.autoType) { div = this[item.autoType](item) if (!div) { console.error(item.autoType + " gave us no element! (crashing now)"); } if (div.autoType != item.autoType) div.autoType = item.autoType } else { div = this.newDiv(item) } //TODO: if we had children they must be removed, verify that this works if (item.children) { this.layoutJSON(div, item.children, settings) } } //if we didn't manage to use existing items (due to variables or type problems), we have created extra and need to remove those here. while (childNodes && childNodes.length > json.length) { child = childNodes[json.length] child.parentNode.removeChild(child) } } /* Diff two trees (branches). This was harder than it looked so we are starting over. First pass: Just detect diffs - if not the same replace with brand new THen: 2. Try to reuse if possible (types matches, and ALL children but a few other attributes don't - like text or listener, etc). 3. When you need performance and this is 100% robust. Look around at the siblings to find the best matches and do re-orderings. Then we could do this: Diff to find items most similar. One common situation is when we add stuff above but want the items below remain unchanged - to not having to repaint the whole tree. - but it is tricky! We can't just loop through the tree once and take the first item that matches the best. We need to sort the items for "best" matches first, e.g. the best match for new_item_0 is old_item_0, but new_item_1 is matching perfect with old_item_0 - then can't pair new_item_0 with old_item_0 just because it comes first. Instead we save all those in an array that want's to compete for old_item_0 and handle that separately afterwards. FIXME: Wow, this was hard. Running into problems constantly. We need to change this: Whenever we are diffing we must both look for absent variables, and new ones. That is, both items in the newJSON, and the oldJSON must be acounted for. E:G Imagine that you have two divs, that are identical except that one does not have a listener. We cannot expect the author to know that they might be diffed against each other, so absence of listeners must remove it. ALSO: our elements should never need to do any type-checking, if we have elements of non-matching types they must create new ones! */ AggressiveLayout.prototype.treeDiff = function(parentNode, newJSONOriginal, childNodes) { if (!newJSONOriginal) { //no json, then just remove all and quit while (childNodes.length) { var child = childNodes[0] child.parentNode.removeChild(child) } parentNode.autoJSON = newJSONOriginal } else if (parentNode.autoJSON != newJSONOriginal) { //this algorithm tries to reuse what we already have, a real diff this.treeDiffReuseSimilar(parentNode, newJSONOriginal, childNodes) } else if (childNodes.length != newJSONOriginal.length) { //We should always make a copy, so this should not matter! //we can't diff this since you are using the same objects. When intentional it allows us to skip all diffs and just continue - optimal efficiency but only when you only modify/generate your json when data changes //However! When modifying your cached json you must make a copy. THINK: I don't want unneccesary copying, but perhaps must? console.error("You are modifying a cached json!"); this.treeDiffReuseSimilar(parentNode, newJSONOriginal, childNodes) } } //slightly more advanced version where we try to reuse some similar elements. //to do that we must loop through the arrays in at least 2 passes, where we look for identical items AggressiveLayout.prototype.treeDiffReuseSimilar = function(parentNode, newJSONOriginal, childNodes) { //var debug = "companySettingsFieldset"; if (this.debug && (parentNode.id == this.debug || parentNode.className == this.debug)) { console.log("treeDiff::found " + this.debug); } //what do we need to remove the wrong stuff and keep the right? var matchInfo = { originalNodes: {}, sources: [], targets: {}, missingMatches: [] } var nodeCounter = 0; //first build a list with the new objects (the source) for (var index = 0; index < newJSONOriginal.length; index++) { var newItem = newJSONOriginal[index] if (!newItem) { //allow for insertions of null in the arrays, so we can more easily create them. This just skips those items. newJSONOriginal.splice(index, 1); index-- continue } var originalNode = (childNodes.length > index && childNodes[index]); if (originalNode) matchInfo.originalNodes[index] = originalNode //start by building an array matches, now every index has an object - and we want to find its best match. matchInfo.sources.push({ index: index, item: newItem, diffMatches: {} }) } //try to match each source to an existing target (best match of the old json) for (var index = 0; index < newJSONOriginal.length; index++) { this.bestNodeMatch(matchInfo.sources[index], parentNode.autoJSON, matchInfo) } //TODO: if item 1 best match is 5 and its target is 1, the next best match also 5 is 2 //then item 2 matches 6 with 2, and item 3 matches perfectly with 1. //now item 2 will hold on to item 2 even though its a better match to item 1 //NOTE: I don't think this matters much, I actually think that only type, id and perfect matches matter. //when if (matchInfo.missingMatches.length) { this.handleMissingMatches(parentNode.autoJSON, matchInfo) } this.handleMatches(matchInfo, parentNode, newJSONOriginal, childNodes) } /* TODO: optimize: skip if var/id then we can only match one element. */ //give us the best node match in matchInfo.sources array and save the diff in matchInfo.matchingDiff { index: } AggressiveLayout.prototype.bestNodeMatch = function(source, autoJSON, matchInfo) { //source.diffMatches is an array to save previous results so we don't need to recalculate anything when re-assigning matches. var bestCount = Number.MAX_VALUE var lastIndex = source.index var firstIndex = lastIndex - 1 //optimize: instead of looping through everything start by looking at likely matches, the first new with the first old, then the first with the second, the second with the first. remove identical matches if (lastIndex < autoJSON.length) { bestCount = this.bestNodeMatchFunc(source, autoJSON, matchInfo, bestCount, lastIndex) lastIndex++ } if (bestCount && lastIndex < autoJSON.length) bestCount = this.bestNodeMatchFunc(source, autoJSON, matchInfo, bestCount, lastIndex) if (bestCount && firstIndex >= 0 && firstIndex < autoJSON.length) bestCount = this.bestNodeMatchFunc(source, autoJSON, matchInfo, bestCount, firstIndex) for (var index = 0; index < autoJSON.length; index++) { if (!bestCount) return if (index >= firstIndex && index <= lastIndex) continue bestCount = this.bestNodeMatchFunc(source, autoJSON, matchInfo, bestCount, index) } } AggressiveLayout.prototype.bestNodeMatchFunc = function(source, autoJSON, matchInfo, bestCount, index) { var newItem = source.item //this.debugItem = "Card holder name" if (this.debugItem && (newItem.placeholder == this.debugItem)) //"VAT number within EU (optional)" { console.log("here is " + this.debugItem); } if (this.debug) { //console.log("debug!") } var oldItem = autoJSON[index] var diff = source.diffMatches[index] if (diff !== false && !diff) { diff = this.nodeMatches(newItem, oldItem, matchInfo) source.diffMatches[index] = diff //save all the diffs! } if (diff && diff.count < bestCount) { //this matches and it is better. bestCount = diff.count var targetMatch = matchInfo.targets[index] if (targetMatch) { if (targetMatch.diffCount <= bestCount) return bestCount //old diff is better! //old diff is worse, add source. Re-run this later by removing it from the source list matchInfo.missingMatches.push(targetMatch.source) targetMatch.source.target = null } //remove any previous records if (source.target) matchInfo.targets[source.target.index] = 0 //create and store this object targetMatch = { diffCount: bestCount, source: source, node: matchInfo.originalNodes[index], index: index } matchInfo.targets[index] = targetMatch //reference our target source.target = targetMatch } return bestCount } //secondary matches, here we need to go through items until all is removed AggressiveLayout.prototype.handleMissingMatches = function(autoJSON, matchInfo, availableTargets) { if (!availableTargets) { availableTargets = {} for (var index = 0; index < autoJSON.length; index++) { if (!matchInfo.targets[index]) availableTargets[index] = 1 } } else { for (var index in availableTargets) { if (!availableTargets.hasOwnProperty(index) || !availableTargets[index]) continue if (matchInfo.targets[index]) availableTargets[index] = 0 } } if (autoEmptyObject(availableTargets)) return var missingMatches = matchInfo.missingMatches for (var index = missingMatches.length - 1; index >= 0; index--) { var source = missingMatches[index]; missingMatches.splice(index, 1) var bestCount = Number.MAX_VALUE for (var targetIndex in availableTargets) { if (!availableTargets.hasOwnProperty(targetIndex) || !availableTargets[targetIndex]) continue bestCount = this.bestNodeMatchFunc(source, autoJSON, matchInfo, bestCount, targetIndex) } } if (missingMatches.length) this.handleMissingMatches(autoJSON, matchInfo, availableTargets) } //Does the nodes match? If so return a DiffLayoutJSON object counting the diffs. AggressiveLayout.prototype.nodeMatches = function(newItem, oldItem, matchInfo) { if (!oldItem || !newItem) { console.log("missing new or old item!") return false; } var typeMatches = newItem.var == oldItem.var if (!typeMatches) return false; //if any has autoType, one may be missing if the other has "div" (since that is the default type). if (oldItem.autoType || newItem.autoType) { typeMatches = oldItem.autoType == newItem.autoType || (!oldItem.autoType && newItem.autoType == "div") || (!newItem.autoType && oldItem.autoType == "div") } else { typeMatches = oldItem.type == newItem.type || ((!oldItem.type && newItem.type == "div") || (!newItem.type && oldItem.type == "div")); } if (!typeMatches) return false; //if this is costly you can create one and reuse it instead! No need to build super-weird and advanced functions. //we cannot look through children, because it will remove the whole tree if it discovers any changes - so if you've built your JSON as a big tree containing everything, the slightest change removes everything and starts over. //This is actually a good thing. Instead just check if the current element should be removed and redo sub-trees. if (oldItem.func) { console.error("error! func is around!"); } //TODO: THERE IS AN ERROR that we need func here return new DiffLayoutJSON(oldItem, newItem, { skipAttributes: { type: 1, autoType: 1, parent: 1, element: 1, func: 1 }}) } //We have now a complete list of items and matches (if any). AggressiveLayout.prototype.handleMatches = function(matchInfo, parentNode, newJSONOriginal, childNodes) { if (this.debug) { //console.log("debug!") } //Remove targets without matches var reusedTargets = {} for (var index = 0; index < matchInfo.sources.length; index++) { var source = matchInfo.sources[index]; if (source.target) reusedTargets[source.target.index] = 1 } //go through the old json and remove non-matches for (var index = childNodes.length - 1; index >= 0; index--) { if (reusedTargets[index]) continue var child = childNodes[index] if (child && child.parentNode) { //sometimes they are already removed but not updated in the child (only the parent) - then we get exception here - or is it a bug? try { child.parentNode.removeChild(child) } catch (error){} } } //only matching targets left, but may be displaced for (var index = 0; index < matchInfo.sources.length; index++) { var source = matchInfo.sources[index]; var originalNode = childNodes.length > index && childNodes[index] if (source.target && source.target.node && source.target.diffCount == 0) { if (originalNode && originalNode != source.target.node) { //place above current item, positioning has changed parentNode.insertBefore(source.target.node, originalNode); } continue } //imagine having x diffs, wouldn't it be cool if we could just apply those changes? //doing it automatically seems impossible, but does it matter? If we compare all changes or just those we know of? var newItem = Object.assign({}, source.item); //we use a shallow copy so we don't change JSON when adding stuff (like turning text into html, etc) if (!newItem.autoType) newItem.autoType = AUTO_TYPE.div newItem.parent = parentNode; //this is needed for creation. newItem.element = source.target && source.target.node var diff = source.diffMatches && source.target && source.diffMatches[source.target.index] if (this.debug && source.item.html == "one removed") console.log("debug") //Can I autoDiff here? I can handle remove but what about changed? if (diff && originalNode) { this.applyDiff(diff, originalNode) } var div = this[newItem.autoType](newItem, diff) if (originalNode && originalNode != newItem.element) { //place above current item, it has matched with someone else (or is a new node) parentNode.insertBefore(div, originalNode); } } //make sure childNodes is the same amount as newNodes. while (childNodes && childNodes.length > newJSONOriginal.length) { var child = childNodes[newJSONOriginal.length] child.parentNode.removeChild(child) } parentNode.autoJSON = newJSONOriginal }; //we only do removal now, since the other diffing functions take care of the rest. AggressiveLayout.prototype.applyDiff = function(diff, node) { if (diff.removedAttributes) { for (var diffIndex = 0; diffIndex < diff.removedAttributes.length; diffIndex++) { var attribute = diff.removedAttributes[diffIndex]; if (node.removeAttribute) { if (node.hasAttribute(attribute)) node.removeAttribute(attribute) else { switch (attribute) { case 'html': case 'children': this.removeChildren(node) break; case 'className': node.classList.remove() node[attribute] = null break; default: if (node[attribute]) { //find all special cases and treat them specially. if (attribute != "checked") console.error(attribute + " is a special case.") node[attribute] = null } break; } } } else node[attribute] = null } } if (diff.children) { for (var attribute in diff.children) { if (!diff.children.hasOwnProperty(attribute)) continue var subDiff = diff.children[attribute]; var nodeItem = node[attribute] if (!nodeItem) nodeItem = node.getAttribute(attribute) if (nodeItem) this.applyDiff(subDiff, nodeItem) } } } //DEPRICATED use convertPost when washing data instead. AggressiveLayout.prototype.convertPostDataToJSON = function(post, classPrefix, postJSON) { if (!postJSON) postJSON = []; if (post.washedJSON) { postJSON.push(post.washedJSON) } else if (post.title != undefined || post.lead != undefined || post.body != undefined) { if (!classPrefix) classPrefix = "" if (post.title != undefined) postJSON.push({ text: post.title, className : classPrefix + "_title", type: "h1" }) if (post.lead != undefined) postJSON.push({ text: post.lead, className : classPrefix + "_lead", type: "h2" }) if (post.body != undefined) postJSON.push({ text: post.body, className : classPrefix + "_body" }) } else if (post.options) postJSON.push(post.options) else postJSON.push(post) return postJSON; } /// --- Working with nodes, we use object references instead of overloading DOM-objects since it historically has been so many issues with that. And this guarantees that it will work fine cross-browsers. --- AggressiveLayout.prototype.removeFromParent = function(element) { if (element && element.parentNode) element.parentNode.removeChild(element); }; AggressiveLayout.prototype.removeChildren = function(element) { while (element.hasChildNodes()) { element.removeChild(element.lastChild); } }; AggressiveLayout.prototype.addToParent = function(element, parent, afterNode) { if (!afterNode) parent.appendChild(element); else { //simulate insertAfter by inserting before next sibling: var nextNode = afterNode.nextSibling; if (nextNode) parent.insertBefore(element, nextNode); else parent.appendChild(element); } return element; } /** get an element's absolute offset by calculating everything up the DOM tree. You can't use a common ancestor to measure between, since that is not how HTML works - offset deals with padding and magic to fuck with you. NOTE: when measuring inline elements (span), they describe the positions of the ***first*** border box. This usually means the first span in sequence. To get the left of such element you must do the following: 1. measure the offsetWidth and offset of the first element in sequence 2. add the clientWidth of all elements after the first but before this element. 3. add all those numbers together. 4. Note that there is a difference between clientWidth and offsetWidth - spans usually dont have clientWidth. */ AggressiveLayout.prototype.offset = function(element) { var x = 0; var y = 0; while (element && !isNaN(element.offsetLeft) && !isNaN(element.offsetTop) ) { x += element.offsetLeft; y += element.offsetTop; element = element.offsetParent; } return { top: y, left: x }; } // --- Working with CSS, it is far better and more efficient to create css rules, and use classes than to change each and every element by itself. Having CSS in your JS is also smarter since it gives you CSS-variables and other programming tools. --- AggressiveLayout.prototype.getRuleList = function(styleSheet) { //var cssRuleCode = document.all ? 'rules' : 'cssRules'; //why no look at the actual value instead? var cssRuleCode = styleSheet.cssRules ? 'cssRules' : 'rules'; //account for IE and FF return styleSheet[cssRuleCode]; } AggressiveLayout.prototype.getCSSMediaRulesFromSelector = function(selector, styleSheet, ruleList) { if (!styleSheet) styleSheet = this.getStyleSheet() if (!ruleList) ruleList = this.getRuleList(styleSheet); for (var index = ruleList.length - 1; index >= 0; index--) { var rule = ruleList[index]; if (rule && rule.type == 4 && rule.selectorText && rule.selectorText.toUpperCase() == selector.toUpperCase()) { return rule; } } //no media rule, create! try { styleSheet.insertRule(selector + "{humbug}", ruleList.length); return ruleList[ruleList.length - 1] } catch (error) { console.error("You cannot create rule with selector: " + selector + " exception was: " + error); } return null; } AggressiveLayout.prototype.findCSSRuleFromTopBySelector = function(selector, styleSheet) { if (!styleSheet) styleSheet = this.getStyleSheet() var ruleList = this.getRuleList(styleSheet); if (!ruleList) { console.log("Error, could not find cssRules") return null } for (var j = 0; j < ruleList.length; j++) { var rule = ruleList[j]; if (rule && rule.type == 1 && rule.selectorText && rule.selectorText.toUpperCase() == selector.toUpperCase()) { return j; } } return null; } //Shorthand of cssStyleFromSelector AggressiveLayout.prototype.cssStyleFromClass = function(className, initialStyle) { return this.cssStyleFromSelector("." + className, null, initialStyle); } /* Use it like this: var annotationDiv = this.cssStyleFromSelector(".annotationDiv"); annotationDiv.style.backgroundColor = "rgba(200, 200, 0, 0.0)"; //hide - make it fully transparent */ AggressiveLayout.prototype.cssStyleFromSelector = function(selector, styleSheet, initialStyle) { if (!styleSheet) styleSheet = this.getStyleSheet() var cssRules = this.getRuleList(styleSheet); var selectorIndex = this.findCSSRuleFromTopBySelector(selector, styleSheet); if (selectorIndex != null) { return cssRules[selectorIndex]; } if (!initialStyle) { initialStyle = "humbug"; //as long as they don't implement the humbug style and that it don't throw errors when it cannot parse humbug styles, this will work. } this.addCSSRules(selector, initialStyle, styleSheet) //this.addCSSRules(selector, "color: inherit", styleSheet) //we must have at least one style for this to work. But we cannot add whatever, this cannot be done automatically. selectorIndex = this.findCSSRuleFromTopBySelector(selector, styleSheet); if (selectorIndex != null) { return cssRules[selectorIndex]; } return null; //could not add style } //define a css item with an option object. Like this: this.setCSSFromSelector(".loginTextField", { marginLeft : marginLeft + "px"}) AggressiveLayout.prototype.setCSSFromSelector = function(selector, options, styleSheet) { if (!styleSheet) styleSheet = this.getStyleSheet(); var styleItem = this.cssStyleFromSelector(selector, styleSheet); if (!styleItem) { //don't report twice console.error("Could not add style with selector " + selector); return null; } for (var style in options) { if (options.hasOwnProperty(style)) { if (styleItem.style[style] != options[style]) styleItem.style[style] = options[style]; } } return styleItem; }; //instead of multiple calls to setCSSFromSelector, we can roll it all into a single json. //@arg prefix apply a prefix on every classname, so (prefix = auto) MainDiv, TitleDiv becomes autoMainDiv, autoTitleDiv etc. AggressiveLayout.prototype.applyCSS = function(json, prefix, styleSheet) { if (prefix && typeof prefix !== 'string') { styleSheet = prefix; prefix = null; } if (!styleSheet) styleSheet = this.getStyleSheet(); for (var selector in json) { if (json.hasOwnProperty(selector)) { var firstChar = selector.charAt(0) if (firstChar == "@") { //This is a media rule, fetch it and apply contents inside. var mediaRule = this.getCSSMediaRulesFromSelector(selector, styleSheet); if (mediaRule) { console.log("got mediaRule!"); console.log(mediaRule); this.applyCSS(json[selector], prefix, mediaRule); } } else { var prefixSelector = selector; if (prefix) { if (firstChar == "." || firstChar == "#") prefixSelector = firstChar + prefix + selector.substring(1) else prefixSelector = prefix + selector } this.setCSSFromSelector(prefixSelector, json[selector], styleSheet); } } } } /*Check if a selector is supported - NOTE: there are a few caveats you must be avare of: 1, if CSS.support is not supported, it will return false. http://caniuse.com/#feat=css-supports-api 2, it uses css selectors and values NOT the JS version. So you must check for initial-letter instead of initialLetter 3, It checks all vendors - if you want to skip this you must include options { skipVendorPrefix : true } */ AggressiveLayout.prototype.cssIsSupported = function(cssSelector, value, options) { if (window.CSS === undefined || !CSS.supports) return false; var hasSupport = CSS.supports(cssSelector, value); if (options && options.skipVendorPrefix) { return hasSupport; } hasSupport = CSS.supports("-webkit-" + cssSelector, value) || CSS.supports("-ms-" + cssSelector, value) || CSS.supports("-moz-" + cssSelector, value) || CSS.supports("-o-" + cssSelector, value); return hasSupport; } /* @return cssStyle if it exists otherwise null */ AggressiveLayout.prototype.cssStyleSelectorExists = function(selector, styleSheet) { if (!styleSheet) styleSheet = this.getStyleSheet() var cssRules = this.getRuleList(styleSheet); var selectorIndex = this.findCSSRuleFromTopBySelector(selector, styleSheet); if (selectorIndex != null) { return cssRules[selectorIndex]; } return null; } //get current styles (create new if not existing) AggressiveLayout.prototype.getStyleSheet = function() { if(!this.styleSheet) { var styleSheet = null /*Old code that isn't working: //always create our css first var head = document.getElementsByTagName("head")[0]; if (head.children.length > 0) { for (var i = 0; i < head.children.length; i++) { var css = head.children[i] //Pick the first sheet available if (css.sheet) { console.log(css.sheet.media.mediaText); if (css.sheet.media && css.sheet.media.mediaText) continue; styleSheet = css.sheet break; } } } */ //if there already are stylesheets we try to find one that is a good match for us (plain - meadia all) if (document.styleSheets.length > 0) { for (var i = document.styleSheets.length - 1; i >= 0; i--) { var docSheet = document.styleSheets[i] if (docSheet.media && docSheet.media.mediaText) continue; this.styleSheet = docSheet; break; } } //if no style was found or matched, create a new one. if (!this.styleSheet) { var head = document.getElementsByTagName("head")[0]; styleSheet = document.createElement("style"); styleSheet.setAttribute('type', 'text/css'); head.appendChild(styleSheet); // We have just appended our styleSheet, - it's the last one this.styleSheet = document.styleSheets[document.styleSheets.length - 1] } } return this.styleSheet; }; /* Create style sheet for only phones: var phoneSheet = this.createStyleSheet({mediaType : AUTO_CONST.phone}) Or for larger screens, (not phone) var padSheet = this.createStyleSheet({mediaType : AUTO_CONST.pad}) or yourself: var smallerSheet = this.createStyleSheet( { media: "all and (max-width: " + AUTO_DEVICE_WIDTH.smallerPhone + "px)"}) NOTE: to make it work with smallerPhone, you need to define it after phone! This is since we don't want to code all the situations where you have both sheets or only one (or three interleaving ones). */ AggressiveLayout.prototype.createStyleSheet = function(options) { var media = options.media if (!media) { if (options.mediaType == AUTO_CONST.smallerPhone) { media = "all and (max-width: " + AUTO_DEVICE_WIDTH.smallerPhone + "px)" } else if (options.mediaType == AUTO_CONST.phone) { media = "all and (max-width: " + AUTO_DEVICE_WIDTH.phone + "px)" } else if (options.mediaType == AUTO_CONST.pad) { media = "all and (min-width: " + AUTO_DEVICE_WIDTH.phone + "px)" } } var styleSheet = document.createElement("style"); styleSheet.setAttribute('type', 'text/css'); if (media) styleSheet.setAttribute("media", media) var head = document.getElementsByTagName("head")[0]; head.appendChild(styleSheet); //we have appended this sheet last, return it. return document.styleSheets[document.styleSheets.length - 1] }; //create a new rule AggressiveLayout.prototype.addCSSRules = function(selector, rules, sheet) { if (!sheet) sheet = this.getStyleSheet() var cssRules = this.getRuleList(sheet); if (sheet.insertRule) { try { sheet.insertRule(selector + "{" + rules + "}", cssRules.length); } catch (error) { if (selector.indexOf("-moz-") == -1) console.error("You cannot create rule with selector: " + selector + " exception was: " + error); } } else if (sheet.addRule) { sheet.addRule(selector, rules, cssRules.length); } } /* Not in production, but copy if needed AggressiveLayout.prototype.lookupObject = function(object) { console.log(object.constructor.name); console.log(object.__proto__); console.log(Object.getOwnPropertyNames(object)); } */ /** siteComponent - we have these use cases: call arguments if component exists, otherwise use options to load. SET options.auto to use the autoComponent functions Usage: variable to check if it has been loaded (and to retrieve the object) options: { component: "CompanyInvoices", autoURL: "/js/main/companyInvoices.js" } options.target the object to send into the init method if other than layout options.url to load any other script than auto-components callFunction and argList, called when loaded and arguments to use */ AggressiveLayout.prototype.siteComponent = function(variable, options, callFunction, argList) { if (!this.components) this.components = {} var component = this.components[variable]; if (component || window[options.component]) { if (!component) { if (!options.target) options.target = this; component = new window[options.component](options.target); this.components[variable] = component; if (component.setupFunction) component.setupFunction(this); } if (callFunction && component[callFunction]) component[callFunction].apply(component, argList) return component; } else { var include = {}, autoInclude = {} if (options.url) include[options.url] = options.component; else autoInclude[options.autoURL] = options.component; var callback = null; if (callFunction) { callback = function() { var component = this.siteComponent(variable, options) if (component[callFunction]) component[callFunction].apply(component, argList) }.bind(this); } this.batchInclude(include, autoInclude, callback) return null; } } /** batchInclude several scripts. NOTE: both autoComponents and callback can be used as callback. @arg options.components dict on the form (url : prototype/className) @arg options.autoComponents similar dict but now we rework this into a cms_component request (where the server can combine/cache/minify etc). @arg options.autoComponentURLs (array), list of URLs of components that should only be added once - caller needs to check weather it should be added or not. @arg options.callback the callback when done @arg options.multipleCallbacks run callback every time, even if its already loaded. TODO: (wait) combine and minify them all at once, making this one single request. this should return a promise instead of using callbacks. Here is an example: var didInclude = this.layout.batchInclude({ autoComponents: { "/js/utils/aggressiveSettings.js": "AggressiveSettings", }, callback: function() { this.settings = new AggressiveSettings({ formVar: "publicSettings", classPrefix: this.prefix, controller: this, data: this.public_info }); this.privateSettings = new AggressiveSettings({ formVar: "privateSettings", classPrefix: this.prefix, controller: this, data: this.private_info }); this.setupCSS(); }.bind(this), }) if (didInclude) return; //will run setupCSS() again. **/ AggressiveLayout.prototype.batchInclude = function(options, autoComponents, callback) { var components, autoComponentURLs; var multipleCallbacks = options && options.multipleCallbacks if (options && (options.components || options.autoComponents || options.autoComponentURLs)) { callback = options.callback || callback || autoComponents autoComponentURLs = options.autoComponentURLs components = options.components autoComponents = options.autoComponents } else { //old style components = options; multipleCallbacks = true } if (autoComponents) { //query like this: curl "http://localhost/js/purchasePage.js.comp.auto" if (!components) components = {} var autoPrefix = ".comp.auto" var branch = "" var search = this.parseQueryString(this.getSearch(), "?"); if (search && search.cms_branch) branch = "?cms_branch=" + search.cms_branch if (branch) autoPrefix += branch for (var path in autoComponents) { if (autoComponents.hasOwnProperty(path)) { var componentName = autoComponents[path]; components[path + autoPrefix] = componentName } } } var didInclude = false; if (!this.includeScriptPaths) this.includeScriptPaths = {}; for (var path in components) { if (components.hasOwnProperty(path)) { var componentName = components[path]; if (!window[componentName]) { didInclude = true; //included previous run or now, either case - don't send callbacks yet! if (!this.includeScriptPaths[path]) { this.includeScriptPaths[path] = true; this.importScript(path, this.batchIncludeSuccess.bind(this), this.batchIncludeError.bind(this)); } } } } if (autoComponentURLs) { //query like this: curl "http://localhost/js/purchasePage.js.comp.auto" var autoPrefix = ".comp.auto" var branch = "" var search = this.parseQueryString(this.getSearch(), "?"); if (search && search.cms_branch) branch = "?cms_branch=" + search.cms_branch if (branch) autoPrefix += branch for (var index = 0; index < autoComponentURLs.length; index++) { var path = autoComponentURLs[index] + autoPrefix; if (!this.includeScriptPaths[path]) { didInclude = true; this.includeScriptPaths[path] = true; this.importScript(path, this.batchIncludeSuccess.bind(this), this.batchIncludeError.bind(this)); } } } //we want to wait for all imports to complete before calling if (callback && (multipleCallbacks || didInclude)) { if (!didInclude) //if nothing new this run, we send callbacks at once. { //usually they update layout after load, which usually is what causing them to be loaded in the first place. We must have an update at next tick. setTimeout(callback, 40); } else if (!this.includeScriptCallbacks) this.includeScriptCallbacks = [callback] else this.includeScriptCallbacks.push(callback); } return didInclude; } AggressiveLayout.prototype.batchIncludeError = function(event) { //error! what to do now? this.showAutoError("Could not load code, please check your internet connection and reload your browser") //if one fails all fail, so when including external stuff we might need special handling? } AggressiveLayout.prototype.batchIncludeSuccess = function(url) { //never remove this, since we cannot know if import failed (in case of script errors). delete this.includeScriptPaths[url]; this.includeScriptPaths[url] = 2; for (var url in this.includeScriptPaths) { if (this.includeScriptPaths.hasOwnProperty(url)) { var result = this.includeScriptPaths[url]; if (result != 2) return; } } //all includeScripts have downloaded! if (this.includeScriptCallbacks && this.includeScriptCallbacks.length) { //make a copy since the callbacks will/may include scripts themselves, whose callbacks will go into this array. var callbacks = this.includeScriptCallbacks.slice(0, this.includeScriptCallbacks.length); delete this.includeScriptCallbacks; for (var index = 0; index < callbacks.length; index++) { callbacks[index]() } } this.updateLayout(); } //With this we can build dependencies without needing to declare them in the HTML. Compartmentalize! (see siteComponent above) //Understand that you need to make sure only to call this once. AggressiveLayout.prototype.importScript = function(url, loadedCallback, errorCallback) { var script = document.createElement('script'); var loaded = false; var loadFunction = function () { if (loaded) return; loaded = true; if (loadedCallback) loadedCallback(url); script.onload = null; script.onreadystatechange = null; }; script.onerror = errorCallback; script.onload = loadFunction; script.onreadystatechange = loadFunction; script.setAttribute("type","text/javascript"); script.setAttribute("src", url); document.head.appendChild(script); return true; } /* Only use woff, or woff2. If it can't handle this it is better to use system fonts. oet is a old legacy IE format, ttf is also old. Set server = true if you want the css to be added when generating, otherwise the page will load and font will be downloaded afterwards (which is usually what you want). But if you have base64 encoded fonts, its probably not. append "data:font/woff;charset=utf-8;base64," if you want to use base64 data instead of url-loading (note that you need to replace woff with something). */ AggressiveLayout.prototype.addFont = function(options) { if (!options.server && window.serverGenerated) return; var family = options.family; var dataURLs = "" if (options.woff2URL) { dataURLs += "url('" + options.woff2URL + "') format('woff2')\n"; } if (options.woffURL) { if (dataURLs) dataURLs += "," dataURLs += "url('" + options.woffURL + "') format('woff')\n"; } if (options.trueTypeURL) { if (dataURLs) dataURLs += "," dataURLs += "url('" + options.trueTypeURL + "') format('truetype')\n"; } if (options.svgURL) { if (dataURLs) dataURLs += "," dataURLs += "url('" + options.svgURL + "') format('svg')\n"; } if (dataURLs) dataURLs = "src:" + dataURLs + ";" var weight = options.fontWeight; if (!weight) weight = "normal" var style = options.fontStyle; if (!style) style = "normal"; var newStyle = document.createElement('style'); newStyle.appendChild(document.createTextNode("\ @font-face\ {\ font-family: '" + family + "'; " + dataURLs + "\ font-weight: " + weight + ";\ font-style: + " + style + ";\ }")); document.head.appendChild(newStyle); }; //I remember testing this, but not the result... AggressiveLayout.prototype.cssFlex = function(cssObject, selectorName) { if (!cssObject) cssObject = this.setCSSFromSelector(selectorName, { display: "-webkit-box" }) else cssObject.style.display = "-webkit-box"; //older browsers need webkit-box or -webkit-flex, but recent browsers all accept flex. cssObject.style.display = "-moz-box"; cssObject.style.display = "-ms-flexbox"; cssObject.style.display = "-webkit-flex"; cssObject.style.display = "flex"; } /** Animations in css - perhaps move this to a separate file animationExtensions.js? All new animations goes into aggressiveAnimation.js - we will probably build a few, bouncing alerts/warnings etc. This is very easy to work with, first you create your animation with the frames (animation steps). And then you apply a css class to this animation. Like in this example where we create a rotation called "rotationAnimation", and apply it to "spinnerOuter" (where we set it to take 0.6 seconds and go on forever): var frames = {'100%': { transform: "rotate(360deg)" }}; if (!this.createAnimationCSS("rotationAnimation", frames)) log.error("Could not create animations - too old browser?") var spinnerOuter = this.cssStyleFromSelector(".spinnerOuter"); this.styleWithVendorPrefix(spinnerOuter, "animation", "rotationAnimation .6s linear infinite") A more advanced example is the fade function, that takes an element and fades it in or out. */ /** options can be: @arg element, the element to fade. @arg fade_in, if we should fade in instead of fade out. @arg completionBlock, function to be called when the animation completes. */ AggressiveLayout.prototype.fadeElement = function(options) { if (!this.fadeInClass) { //create classes once. var frames = { '0%': { opacity: 0 }, '100%': { opacity: 1 } }; var animation = this.createAnimationCSS("fadeAnimation", frames); if (animation) { this.fadeInClass = this.cssStyleFromSelector(".fadeInClass"); this.styleWithVendorPrefix(this.fadeInClass, "animationName", "fadeAnimation") this.styleWithVendorPrefix(this.fadeInClass, "animationDuration", '0.5s') this.styleWithVendorPrefix(this.fadeInClass, "animationTimingFunction", "ease-in-out") } frames = { '0%': { opacity: 1 }, '100%': { opacity: 0 } }; animation = this.createAnimationCSS("fadeOutAnimation", frames); if (animation) { this.fadeOutClass = this.cssStyleFromSelector(".fadeOutClass"); this.styleWithVendorPrefix(this.fadeOutClass, "animationName", "fadeOutAnimation") this.styleWithVendorPrefix(this.fadeOutClass, "animationDuration", '0.5s') this.styleWithVendorPrefix(this.fadeOutClass, "animationTimingFunction", "ease-in-out") } } if (!options.element) return; if (window.serverGenerated) return; //I don't think we get animationEnd callbacks on the server... if (options.fade_in) { options.addClass = "fadeInClass"; if (this.hasClass(options.element, options.addClass)) return; //don't double add animations! options.element.removeClass("fadeOutClass"); } else { options.addClass = "fadeOutClass"; if (this.hasClass(options.element, options.addClass)) return; options.element.removeClass("fadeInClass"); } //We must capture our callback inside our options here, so we can remove it when animations has completed. options.callback = function(event) { //TODO: test if this works if (options.fade_in) { if (options.element.ad_originalDisplay && options.element.ad_originalDisplay != options.element.style.display) { options.element.style.display = options.element.ad_originalDisplay; } else if (options.element.style.display == "none") options.element.style.display = ""; } else { options.element.ad_originalDisplay = options.element.style.display options.element.style.display = "none"; } this.animationEnd(event, options, this); }.bind(this) if (options.element.style.display == "none") { options.element.style.display = "" || options.element.ad_originalDisplay; } options.element.addEventListener("webkitAnimationEnd", options.callback, false); options.element.addEventListener("animationend", options.callback, false); options.element.addEventListener("msanimationend", options.callback, false); this.addClass(options.element, options.addClass) }; AggressiveLayout.prototype.animationEnd = function(event, options, layout) { if (options.completionBlock) { var target = options.target || this; var func = target[options.completionBlock].bind(target) func(event, options.element, layout); } options.element.removeEventListener("webkitAnimationEnd", options.callback, false); options.element.removeEventListener("animationend", options.callback, false); options.element.removeEventListener("msanimationend", options.callback, false); options.element.removeClass(options.addClass); } AggressiveLayout.prototype.createAnimationCSS = function(animationName, frames) { var styles = this.getStyleSheet(); // Append a empty animation to the end of the stylesheet var idx; try { idx = styles.insertRule('@keyframes ' + animationName + '{}', styles.cssRules.length); } catch (e) { if (e.name == 'SYNTAX_ERR' || e.name == 'SyntaxError') { try { idx = styles.insertRule('@-webkit-keyframes ' + animationName + '{}', styles.cssRules.length); } catch (e) { console.log("Could not animate CSS, syntax error. " + e); return null; } } else { console.log("Could not animate CSS, syntax error. " + e); return null; } } var animation = styles.cssRules[idx]; if (!animation) { console.error("Could not create animations"); return null; } if (!this.animations) this.animations = {}; this.animations[animationName] = animation; if (frames) { for (var text in frames) { var css = frames[text]; var cssRule = text + " {"; for (var keyFrame in css) { cssRule += keyFrame + ':' + css[keyFrame] + ';'; } cssRule += "}"; if (animation.appendRule) { animation.appendRule(cssRule); } else if (animation.insertRule) { animation.insertRule(cssRule); } else if (animation.cssRules && Array.isArray(animation.cssRules)) { //animation is CSSKeyframesRule animation.cssRules.push(cssRule); } else { console.error("Could not insert animation rule"); //console.error(animation) } } } return animation; }; /* Common css functions */ AggressiveLayout.prototype.setupStandardCSS = function(options) { if (!options) options = {} //now some basic standard settings - things we want to have in every app. this.applyCSS({ "img": { maxWidth: "100%" }, "a": { cursor: "pointer" }, "a img": { display: AUTO_CONST.block }, //headers are over-ridden by safari's "user settings"... "h1": { fontWeight: "200" }, "h2": { fontWeight: "200" }, "#autoError": { display: "none", backgroundColor: "rgba(255, 184, 5, 0.85098)", border: "3px solid black", fontSize: "1.5em", fontWeight: "bold", margin: "1em", padding: "1em" }, ".autoError": { display: "none", backgroundColor: "rgba(255, 184, 5, 0.85098)", border: "3px solid black", fontSize: "1.5em", fontWeight: "bold", margin: "1em", padding: "1em" }, //auto flex behavior for rows ".autoFlexRow": { justifyContent: "space-around", padding: "1em" }, //disable auto-zoom on iPhones by having >= 16px font on inputs. "textarea": { fontSize: "16px"}, "input[type='text']": { fontSize: "16px"}, }) //input[type='number'], this.cssFlex(null, ".autoFlexRow") var all = this.cssStyleFromSelector("*"); this.boxSize(all); var body = this.setCSSFromSelector("body", { margin : "0px", padding : "0px", border : "0px", verticalAlign : "baseline", fontFamily : "'San Francisco', Helvetica, Roboto, HelveticaNeueLight, 'Helvetica Neue Light', 'Helvetica Neue', sans-serif", fontWeight : "200", backgroundColor: "whitesmoke" }) if (options.webApp || options.disableTapHighlight) { body.style.webkitTapHighlightColor = "rgba(0,0,0,0)"; } if (options.disableLongPress) body.style.webkitTouchCallout = "none"; /* This is how you hide/show stuff on mobiles - create a new css sheet for this mediaType //create css for "as small as possible" screens this.smallWidthSheet = this.createStyleSheet({ media : "all and (max-width : " + AUTO_DEVICE_WIDTH.smallerPhone + "px)" }) //or create css for "as big as possible phones" this.phoneSheet = this.createStyleSheet({mediaType : AUTO_CONST.phone}) var mobile = this.cssStyleFromSelector(".mobile-hide", this.phoneSheet); mobile.style.display = "none"; //or if you want to specify something mobile-specific this.setCSSFromSelector(".the_css_selector", { padding : "10px 3px 10px 3px" }, this.phoneSheet); mobile = this.cssStyleFromSelector(".mobile-only", this.phoneSheet); mobile.style.display = "block"; */ }; AggressiveLayout.prototype.justifyText = function(cssRule, textJustify) { if (textJustify) cssRule.style.textJustify = textJustify else cssRule.style.textJustify = "newspaper" cssRule.style.textAlign = "justify"; return this.hyphenText(cssRule) } AggressiveLayout.prototype.hyphenText = function(cssRule) { cssRule.style.webkitHyphens = "auto" cssRule.style.mozHyphens = "auto" cssRule.style.msHyphens = "auto" cssRule.style.hyphens = "auto" return cssRule; } AggressiveLayout.prototype.boxSize = function(cssRule) { cssRule.style.msBoxSizing = "border-box"; cssRule.style.webkitBoxSizing = "border-box"; cssRule.style.mozBoxSizing = "border-box"; cssRule.style.boxSizing = "border-box"; return cssRule }; AggressiveLayout.prototype.borderRadius = function(cssRule, radius) { radius = radius + "px"; cssRule.style.msBorderRadius = radius cssRule.style.mozBorderRadius = radius cssRule.style.webkitBorderRadius = radius cssRule.style.borderRadius = radius }; /** Set a shadow on a css or element. Like: this.setShadow(menu_bar, "inset 0px 0px 30px 14px rgba(255,255,255,0.59)") More normal shadow is: "4px 4px 10px 4px rgba(0,0,0,0.59)" */ AggressiveLayout.prototype.setShadow = function(cssRule, cssString) { //"inset 0px 0px 30px 14px rgba(255,255,255,0.59)") cssRule.style.webkitBoxShadow = cssString; cssRule.style.mozBoxShadow = cssString; cssRule.style.boxShadow = cssString; } AggressiveLayout.prototype.preventSelection = function(element) { element.style.webkitUserSelect = AUTO_CONST.none; element.style.mozUserSelect = AUTO_CONST.none; element.style.msUserSelect = AUTO_CONST.none; element.style.userSelect = AUTO_CONST.none; element.unselectable = "on"; //IE needs this } /* Set a style with all possible vendor prefixes, like this.styleWithVendorPrefix(divCSS, "transform", "translate(-50%, -50%)") */ AggressiveLayout.prototype.styleWithVendorPrefix = function(element, property, value) { element.style[property] = value; property = property.charAt(0).toUpperCase() + property.slice(1); element.style["webkit" + property] = value; element.style["moz" + property] = value; element.style["ms" + property] = value; element.style["o" + property] = value; } /** Working with classes. Detect, add and remove classes */ AggressiveLayout.prototype.hasClass = function(element, className) { if (element.className.indexOf(className) == -1) { return false; } return true; } ///DEPRICATED! Use element.classList.add(className) instead AggressiveLayout.prototype.addClass = function(element, className) { if (!element.className) element.className = className; else if (element.className.indexOf(" " + className) == -1) { element.className += " " + className; } } ///DEPRICATED! Use element.classList.remove(className) instead AggressiveLayout.prototype.removeClass = function(element, className) { console.warn("layout.removeClass is depricated"); element.removeClass(element, className) } /** Remove a css class from an element, starting at IE 10 and Anndroid 3 you can use classList instead. If we use Object.defineProperty, we can make it not appear in for loops. Object.defineProperty(String.prototype, 'removeClass', { enumerable: false, configurable: false, writable: false, value: function... }); */ HTMLElement.prototype.removeClass = function(className) { if (this.classList) { this.classList.remove(className); } else { var classes = this.className.split(" "); var index = classes.indexOf(className); if (index >= 0) { classes.splice(index, 1); this.className = classes.join(" "); } } return this; } //overwrite several css styles at once - at least code-wise AggressiveLayout.prototype.setStyles = function(cssRule, styles) { for (var style in styles) { if (styles.hasOwnProperty(style)) { cssRule.style[style] = styles[style]; } } } /// ---------- helper functions - move these to another js-file? //Hide an element after a delay (set visibility to hidden), if called repeatedly - also supply a cancelExistingKey (then we cancel the previous before hiding again). AggressiveLayout.prototype.hideElementAfterDelay = function(element, delay, cancelExistingKey) { if (!element) return; if (!this.timeouts) this.timeouts = {} element.style.visibility = "visible"; var clearSaveDivTimeout = setTimeout(function(){ element.style.visibility = "hidden"; }, delay); if (cancelExistingKey) { if (this.timeouts[cancelExistingKey]) window.clearTimeout(this.timeouts[cancelExistingKey]); this.timeouts[cancelExistingKey] = clearSaveDivTimeout; } } /** Returns a function that, as long as it continues to be invoked, will not be triggered. The function will be called after it stops being called for N milliseconds. If `immediate` is passed, trigger the function on the leading edge also. @arg maxWait sets the amount of maximum seconds to wait until firing. // Usage this.delayedSearch = delayUntilUnchanged(this.performSearch, 1); //put this in setup //then just call it from wherever this.delayedSearch(); or var myEfficientFn = delayUntilUnchanged(function() { // All the taxing stuff you do }); window.addEventListener('resize', myEfficientFn); was called 'throttle' */ function delayUntilUnchanged(innerFunction, delayInSec, immediate, maxWait) { var timeout; var maxTime; return function() { var context = this, args = arguments; var later = function() { timeout = null; innerFunction.apply(context, args); }; var wait = 400; if (delayInSec) wait = delayInSec * 1000; var callNow = (immediate && !timeout); if (maxWait) { var now = AggressiveLayout.prototype.timeStampMilli() if (!maxTime) maxTime = now + maxWait * 1000 else if (now >= maxTime) { maxTime = 0 clearTimeout(timeout); innerFunction.apply(context, args); return } } clearTimeout(timeout); timeout = setTimeout(later, wait); if (callNow) { innerFunction.apply(context, args); } }; }; /** Poll - good for getting events for things not event-based. Like checking if something is visible Usage: poll( function() { return document.getElementById('lightbox').offsetWidth > 0; }, function() { // Done, success callback }, function() { // Error, failure callback } ); */ function poll(fn, callback, errback, timeout, interval) { var endTime = Number(new Date()) + (timeout || 4000); interval = interval || 100; var func = function p() { // If the condition is met, we're done! if (fn()) { callback(); } // If the condition isn't met but the timeout hasn't elapsed, go again else if (Number(new Date()) < endTime) { setTimeout(p, interval); } // Didn't match and too much time, reject! else { errback(new Error('timed out for ' + fn + ': ' + arguments)); } } func = func(); } /** Create a function that can only fire once, specify context to get the right caller. It also saves the result so you get the same result all times called. Usage: var canOnlyFireOnce = once(function() { console.log('Fired!'); }, this); canOnlyFireOnce(); // "Fired!" canOnlyFireOnce(); // nada */ function once(fn, context) { var result; return function() { if (fn) { result = fn.apply(context || this, arguments); fn = null; } return result; }; } //Get a local abolute URL, usage: getAbsoluteUrl("/read/123") returns https://www.qiozk.com/read/123 var getAbsoluteUrl = function() { var a; return function(url) { if (!a) { a = document.createElement('a'); } a.href = url; return a.href; }; }; getAbsoluteUrl = getAbsoluteUrl(); //cross platform fetching of body size AggressiveLayout.prototype.windowSize = function() { var d = document, e = d.documentElement, g = d.getElementsByTagName('body')[0], width = window.innerWidth || e.clientWidth || g.clientWidth, height = window.innerHeight|| e.clientHeight|| g.clientHeight; return { width : width, height : height}; }; /* Measure the size of a textString, asuming a div with a class that only contains this text. */ AggressiveLayout.prototype.measureText = function(text, className, maxWidth) { if (!this.measureTexts) this.measureTexts = {} var issueTextMeasure = this.measureTexts[className] if (!issueTextMeasure) { var issueTextMeasure = this.newDiv({parent : document.body, html : text, classStrings : className, position : AUTO_CONST.absolute}); issueTextMeasure.style.whiteSpace = "nowrap" issueTextMeasure.style.visibility = "hidden"; issueTextMeasure.style.height = "auto"; issueTextMeasure.style.top = "0px" issueTextMeasure.style.left = "0px" this.measureTexts[className] = issueTextMeasure } else { issueTextMeasure.innerHTML = text; } //allow for constrain by width. And height? if (!maxWidth) issueTextMeasure.style.width = "auto"; else issueTextMeasure.style.width = maxWidth + "px"; return { width : (issueTextMeasure.clientWidth + 1), height : (issueTextMeasure.clientHeight + 1)} } /* You should not be using this, but instead have everything boxSized. Then the divs width is without the scrollbars (at least in chrome and safari). */ AggressiveLayout.prototype.getScrollBarWidth = function() { if (!this.scrollBarWidth) { var inner = document.createElement('p'); inner.style.width = "100%"; inner.style.height = "200px"; var outer = document.createElement('div'); outer.style.position = "absolute"; outer.style.top = "0px"; outer.style.left = "0px"; outer.style.visibility = "hidden"; outer.style.width = "200px"; outer.style.height = "150px"; outer.style.overflow = "hidden"; outer.appendChild (inner); document.body.appendChild(outer); var w1 = inner.offsetWidth; outer.style.overflow = 'scroll'; var w2 = inner.offsetWidth; if (w1 == w2) w2 = outer.clientWidth; document.body.removeChild(outer); this.scrollBarWidth = (w1 - w2); } return this.scrollBarWidth; }; //functions inside click, change or similar DOM-callbacks must call this in order to behave properly AggressiveLayout.prototype.blockPropagation = function(e) { if (!e) return; if (e.stopPropagation) e.stopPropagation(); // DOM Level 2 else e.cancelBubble = true; // IE if (e.preventDefault) e.preventDefault(); // prevent image dragging else e.returnValue = false; } ///How far have the document scrolled? AggressiveLayout.prototype.scrollPosition = function() { var x = window.scrollX || window.pageXOffset || document.documentElement.scrollLeft || document.body.scrollLeft || 0; var y = window.scrollY || window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0; var scrollPos = { x : x, y : y } return scrollPos; }; AggressiveLayout.prototype.scrollToElement = function(element) { //first check the computed styles, if we are visible or not var scrollPos = this.offset(element); var documentPosition = this.scrollPosition(); var guessMinHeight = 40; if (documentPosition.y <= scrollPos.top + guessMinHeight && documentPosition.y + this.windowSize().height > scrollPos.top + guessMinHeight) { //we can see the element, since the window is big enough return; } window.scroll(window.pageXOffset, scrollPos.top); //should we do x aswell or don't care? scrollPos.left } /* Set the height property of an element to fill its parent. Usuable when you have a header or similar that take up space so you can't set height 100% but you still want to. Like this | ---- header ----- | ------------------ | | | element | ------------------ <----- move this bar | | | remaining body | | | ------------------ */ AggressiveLayout.prototype.fillHeightOfParent = function(element) { if (!element || element.clientHeight < 2) { console.error("AggressiveLayout:fillHeightOfParent:: Element is empty or not visible, to measure an element it has to be visible first."); return; } var parentHeight = element.parentElement.offsetHeight - element.offsetTop; if (element.clientHeight < parentHeight) { element.style.height = parentHeight + "px" //how do we get back down? } else if (element.style.height && element.style.height != "initial") { element.style.height = "initial" //now we need to check again! parentHeight = element.parentElement.offsetHeight - element.offsetTop; if (element.clientHeight < parentHeight) { element.style.height = parentHeight + "px" } } return parentHeight; } //finding out the window height is not so easy, likely to be different among browsers too. Use this so we can know it works in all places. AggressiveLayout.prototype.totalWindowHeight = function() { var htmlElement = document.getElementsByTagName("html")[0]; var bodyElement = document.getElementsByTagName("body")[0]; var htmlHeight = htmlElement.scrollHeight var bodyHeight = bodyElement.scrollHeight; return htmlHeight > bodyHeight ? htmlHeight : bodyHeight; } /** Working with time */ //get date object from string with regular time-format, using local timezone AggressiveLayout.prototype.dateTimeFromString = function(string) { var normalized = string.replace(/[^0-9]/g, '-'); //all non-num normalized = normalized.replace(/\-\-*/g, '-'); //all doubles var array = normalized.split('-'); var date = new Date(Date.UTC(array[0], array[1] - 1, array[2], array[3], array[4], array[5])); return date; } //format date and time normally (ISO standard), for current date - just leave date parameter empty or null. //To ONLY format date, set options.noTime AggressiveLayout.prototype.formatDateTime = function(date, options) { var formatTime = true if (options && options.noTime) { formatTime = false } if (!date) date = new Date(); var date_string = date.getFullYear() date_string += "-" + (date.getMonth() + 1).toString().padStart(2, "0") date_string += "-" + date.getDate().toString().padStart(2, "0") if (formatTime) { date_string += " " + date.getHours().toString().padStart(2, "0") date_string += ":" + date.getMinutes().toString().padStart(2, "0") date_string += ":" + date.getSeconds().toString().padStart(2, "0") } return date_string; } //How many days, hours, etc is X seconds? (we assume a month is 30 days) AggressiveLayout.prototype.formatTime = function(seconds) { var day = 86400, hour = 3600, minute = 60 var year = 31536000, month = day * 30, week = day * 7 var years = Math.floor(seconds / year) seconds -= years * year var months = Math.floor(seconds / month) seconds -= months * month var weeks = Math.floor(seconds / week) seconds -= weeks * week var days = Math.floor(seconds / day) seconds -= days * day var hours = Math.floor(seconds / hour) seconds -= hours * hour var minutes = Math.floor(seconds / minute) seconds -= minutes * minute var string = (years ? years + " " + this.formatNumberStringPlural(years, "year") + " " : "") + (months ? months + " " + this.formatNumberStringPlural(months, "month") + " " : "") + (weeks ? weeks + " " + this.formatNumberStringPlural(weeks, "week") + " " : "") + (days ? days + " " + this.formatNumberStringPlural(days, "day") + " " : "") + (hours ? hours + " " + this.formatNumberStringPlural(hours, "hour") + " " : "") + (minutes ? minutes + " " + this.formatNumberStringPlural(minutes, "minute") + " " : "") + (seconds ? seconds + " " + this.formatNumberStringPlural(seconds, "second") : "") string = string.trim() if (!string) return "Zero seconds" return string } //This only works in a handful of languages, like English AggressiveLayout.prototype.formatNumberStringPlural = function(number, singularString) { if (number <= 1) return singularString else return singularString + "s" }; //never forget that JS uses milliseconds AggressiveLayout.prototype.formatTimeStamp = function(timeStamp, options) { return this.formatDateTime(new Date(timeStamp * 1000), options); } AggressiveLayout.prototype.timeStamp = function(seconds) { if (!Date.now) Date.now = function() { return new Date().getTime(); }; var timeStamp = Math.floor(new Date().getTime() * 0.001); if (seconds) timeStamp += seconds; return timeStamp; }; AggressiveLayout.prototype.timeStampMilli = function(thousends) { if (!Date.now) Date.now = function() { return new Date().getTime(); }; var timeStamp = new Date().getTime(); if (thousends) timeStamp += thousends; return timeStamp; }; /* timeStampInTheFuture returns true if the timeStamped argument is in the future */ AggressiveLayout.prototype.timeStampInTheFuture = function(timeStamp) { if (!timeStamp) return false; var currentTimeStamp = this.timeStamp() return (timeStamp > currentTimeStamp) }; /* Working with localstorage */ AggressiveLayout.prototype.setObjectWithTTL = function(key, value, seconds) { var ttlObject = { value : value, ttl : this.timeStamp(seconds) } this.setObject(key, ttlObject) } AggressiveLayout.prototype.getObjectWithTTL = function(key) { var ttlObject = this.getObject(key) if (ttlObject && this.timeStampInTheFuture(ttlObject.ttl)) { return ttlObject.value; } localStorage.removeItem(key) return null; } /** LocalStorage only accepts strings. Stringify Objects to make them insertable into localStorage. If you want to use regular strings you cannot use this function. */ AggressiveLayout.prototype.setObject = function(key, value) { localStorage.setItem(key, JSON.stringify(value)); } AggressiveLayout.prototype.getObject = function(key) { var value = localStorage.getItem(key); return value && JSON.parse(value); } AggressiveLayout.prototype.deleteObject = function(key) { localStorage.removeItem(key) }; /** Cache JSON on the page (directly in the HTML file) by converting them to strings, and inserting them as attributes */ AggressiveLayout.prototype.setPageCacheJSON = function(key, json) { var cacheRoot = document.getElementById("AUTO_CACHE"); if (!cacheRoot) { cacheRoot = document.createElement("auto_cache"); cacheRoot.id = "AUTO_CACHE"; cacheRoot.setAttribute("id", "AUTO_CACHE") document.head.appendChild(cacheRoot) } cacheRoot.setAttribute(key, JSON.stringify(json)) } AggressiveLayout.prototype.getPageCacheJSON = function(key) { var cacheRoot = document.getElementById("AUTO_CACHE"); if (cacheRoot) { var string = cacheRoot.getAttribute(key); if (string) return JSON.parse(string); } return null; } /** postAPI - a general function to fetch new data from an API, it expects result from the server in a JSON-payload like this: { rsp : "OK", payload : payload } or with error { rsp : "ERROR", error : error_code, message : "localized error message" } All callbacks will then be called with: (payload, request) - where request is the XMLHttpRequest sent with the extra parameter "postDictionary" with the variables you sent. @param options.successCallback (String) to override the successCallback (defaults to this.successCallback) @param options.errorCallback (String) to override the errorCallback (defaults to this.errorCallback) @param options.target to go somewhere else than this (defaults to this) @param options.raw_results to not perform any result checks. To ignore callbacks you just use an empty function like: successCallback : function(){} Think: This is not really a layout function and should perhaps be contained in another JS-library? Everybody is also most like already using their own ajax/API functions. */ AggressiveLayout.prototype.postAPI = function(postDictionary, options, url) { if (!url) url = this.API_URL; //first: instead of handling double-posting for each and every function - do something here, simply ignore double posted request? var stringPostDictionary = (postDictionary && JSON.stringify(postDictionary)) || ""; if (!this.currentRequests) this.currentRequests = {} if (!this.currentRequests[url]) this.currentRequests[url] = {} if (this.currentRequests[url][stringPostDictionary]) { console.error("Trying to double post to " + getAbsoluteUrl(url) + " with parameters " + stringPostDictionary); return; } this.currentRequests[url][stringPostDictionary] = 1; //now perform the actual request if (!options) options = {} //if the callbacks are nil, we use our standard callbacks. To use other callers than the default ones, use name + target. var target = options.target || this; if ( (options.successCallback && !target[options.successCallback]) || options.errorCallback && !target[options.errorCallback]) { if (options.successCallback && !target[options.successCallback]) console.error("successCallback don't exist!"); else console.error("errorCallback don't exist!"); console.error("Referring to a callback using a function instead of name is illegal! This only complicates things with no upside: turn it into a dedicated function!\nThis post will not be sent."); return; } //if you have a target and a default successCallback, you shouldn't need to specify it. var successCallback = (options.successCallback && target[options.successCallback].bind(target)) || (!options.successCallback && options.target && target.successCallback && target.successCallback.bind(target)) || this.successCallback.bind(this) var errorCallback = (options.errorCallback && target[options.errorCallback].bind(target)) || (!options.errorCallback && options.target && target.errorCallback && target.errorCallback.bind(target)) || this.errorCallback.bind(this) var xmlDoc = new XMLHttpRequest(); var method = options.GET ? "GET" : "POST"; var sendData; //we can either send the data raw or as a POST string if (options.POST || options.GET) { sendData = [] for (var key in postDictionary) { sendData.push(encodeURIComponent(key) + '=' + encodeURIComponent(postDictionary[key])); } if (options.POST) { sendData = sendData.join('&'); } else { url += "?" + sendData.join('&'); } xmlDoc.open(method, url, true); xmlDoc.setRequestHeader("Content-type", "application/x-www-form-urlencoded; charset=UTF-8"); } else if (options.formData) { sendData = options.formData; xmlDoc.open(method, url, true); xmlDoc.setRequestHeader("Cache-Control", "no-cache"); //send additional parameters as a JSON - and handle it on the server to make things easier. //as post variables, like so: $uploadParameters = $_POST['uploadParameters']; if (postDictionary) sendData.append('uploadParameters', stringPostDictionary); } else { sendData = stringPostDictionary; xmlDoc.open(method, url, true); xmlDoc.setRequestHeader("Content-type", "application/json; charset=UTF-8"); } if (options.progress) { //just supply a progress element if you wish to show download/upload progress xmlDoc.upload.onprogress = function(event) { if (event.lengthComputable) { var complete = (event.loaded / event.total * 100 | 0); options.progress.value = options.progress.innerHTML = complete; } } } //keep track of how much we need to wait if (window.serverGenerated) { window.serverAwaitingCallbacks++; if (!window.waitForCallbacks) window.waitForCallbacks = true; } xmlDoc.onreadystatechange = function() { if (xmlDoc.readyState !== XMLHttpRequest.DONE) return; xmlDoc.onreadystatechange = null; //clear it to avoid weird errors when DONE is messaged multiple times. if (window.serverGenerated) window.serverAwaitingCallbacks--; if (xmlDoc.status !== 200) { if (this.currentRequests[url]) { this.currentRequests[url][stringPostDictionary] = 0; delete this.currentRequests[url][stringPostDictionary]; } if (xmlDoc.status == 0 && xmlDoc.statusText === 'abort') { return; //this is not an error. } } if (this.currentRequests && this.currentRequests[url] && this.currentRequests[url][stringPostDictionary]) { this.currentRequests[url][stringPostDictionary] = 0; delete this.currentRequests[url][stringPostDictionary]; } else { this.currentRequests[url] = 0; delete this.currentRequests[url]; } this.postAPIResult(url, postDictionary, options, xmlDoc, successCallback, errorCallback) }.bind(this); xmlDoc.postDictionary = postDictionary; xmlDoc.send(sendData); return xmlDoc; }; AggressiveLayout.prototype.postAPIResult = function(url, postDictionary, options, xmlDoc, successCallback, errorCallback) { if (xmlDoc.status !== 200) { var stringPostDictionary; try { stringPostDictionary = (postDictionary && JSON.stringify(postDictionary)) || ""; } catch (error) { stringPostDictionary = ""; } console.log("ERROR! ADFatal" + "Errors internal error, status became: " + xmlDoc.status + " status message: " + xmlDoc.statusText + " vars: " + stringPostDictionary) errorCallback({ rsp : "ERROR", error : 0 }, xmlDoc); if (window.serverGenerated && window.serverAwaitingCallbacks == 0) window.generateFinal(); return; } var data = xmlDoc.responseText; if (options.raw_results) { successCallback(data, xmlDoc); if (window.serverGenerated && window.serverAwaitingCallbacks == 0) window.generateFinal(); return; } try { if (!data.rsp) data = JSON.parse(decodeURIComponent( window.escape(data) )); } catch (error) { errorCallback({ message : data }, xmlDoc); console.log("ERROR JSON! ADFatal" + "Errors: " + error + " for data " + data); if (window.serverGenerated && window.serverAwaitingCallbacks == 0) window.generateFinal(); return; } if (data.rsp == "OK") { var payload = data.payload || []; successCallback(payload, xmlDoc); } else if (!errorCallback) {} //no error handler else if (data.rsp == "ERROR") { console.log("ERROR response! ADFatal" + "Errors: " + data.message); console.log(getAbsoluteUrl(url) + " : " + JSON.stringify(postDictionary)); errorCallback(data, xmlDoc); } else { var message = "ERROR! ADFatal" + "Errors: internal error"; console.log(message); errorCallback(null, xmlDoc); } if (window.serverGenerated && window.serverAwaitingCallbacks == 0 && window.generateFinal) window.generateFinal(); } //Callback for successful requests AggressiveLayout.prototype.successCallback = function(payload, request) { this.hideAutoError() this.handlePlugins("successCallback", [payload, request]); } AggressiveLayout.prototype.hideAutoError = function() { var autoError = this.elements.autoError; if (autoError) { autoError.innerHTML = "" autoError.style.display = "none" } } ///Callback for unsuccessful requests AggressiveLayout.prototype.errorCallback = function(payload, request) { //here we have standard error handling this.showAutoError((payload && payload.message) || "Server error, please try again later") this.handlePlugins("errorCallback", [payload, request]); } //Present errors to the user in a consistent way. AggressiveLayout.prototype.showAutoError = function(message) { var autoError = this.elements.autoError; if (!autoError) { //if you have no error div we must create one for you. autoError = this.newDiv({ var: "autoError", className: "autoError", html: message }) var root = this.elements.rootElement; root.insertBefore(autoError, root.firstChild); } autoError.innerHTML = message; autoError.style.display = "block" this.scrollToElement(autoError); } //check cookies AggressiveLayout.prototype.getCookie = function (cookieName) { var name = cookieName + "="; var cookieArray = document.cookie.split(';'); for (var i = 0; i < cookieArray.length; i++) { var cookie = cookieArray[i]; while (cookie.charAt(0) == ' ') { cookie = cookie.substring(1); } var index = cookie.indexOf(name); if (index != -1) { return cookie.substring(index + name.length); } } return false; } /** Handle errors. All errors should be reported to the server so their bugs can be fixed. */ AggressiveLayout.prototype.errorHandler = function(error) { var agent = navigator.userAgent.toLowerCase(); var debug_info = ""; if (error) { var errorMsg = error.message || "" var url = error.filename || ""; var lineNumber = error.lineno || ""; var column = error.colno || ""; if (lineNumber && url && this.errorURL) { var performSend = true; if (this.options.errorURLWhiteList) { //we can prevent errors for unknowned sources - no point in sending user-script errors to us. performSend = false; for (var index = 0; index < this.options.errorURLWhiteList.length; index++) { var component = this.options.errorURLWhiteList[index]; if (url.indexOf(component) != -1) { performSend = true; break; } } } if (performSend) { var postDictionary = { msg : errorMsg, url : url, line : lineNumber, debug_info : debug_info, agent : agent } console.log("Perform send for values : " + JSON.stringify(postDictionary)); var successCallback = function() {}; this.postAPI(postDictionary, this.errorURL, { successCallback : successCallback, errorCallback : null }); //ignore errors return true; } else { console.log("We are not handling errors for: " + url); } } } console.log("We have an error we cannot handle: " + error.filename); return false; //there is no point in trying to "catch" these. }; /** When auto generating static pages, let the external process check for errors */ AggressiveLayout.prototype.autoGenerateErrors = function() { if (this.autoGenerateError) return this.autoGenerateError } /** Helper functions! Note that some are better suited to be global functions, since they can be null, numbers or other non-objects. */ var logWarning = function() { console.error("function does not exist!") } AggressiveLayout.prototype.objectLength = logWarning //use autoLength instead AggressiveLayout.prototype.firstValue = logWarning //use autoFirstValue AggressiveLayout.prototype.objectIsEmpty = logWarning //use autoEmptyObject instead AggressiveLayout.prototype.isString = logWarning ///detect if an item is a string - since strings can be both objects and primitives you have to do this double-check. var isString = function(item) { return item && (typeof item === 'string' || item instanceof String); } /* return true for dictionaries only, false for all objects with constructor (like array, set, Date, etc), those are objects too but rather obvious. //can be used for all objects and primitives To detect strings you do: if (typeof myVar === 'string' || myVar instanceof String) */ var isObject = function(object) { //primitive or null if (!object || typeof object != 'object') return false //return false for all objects with constructor (like array, set, Date, etc), those are objects too but rather obvious. return object.constructor == Object } //get any/first value from an object var autoFirstValue = function(object) { if (isObject(object)) { for (var k in object) if (object.hasOwnProperty(k)) return object[k] } return null; } //Get the length of all objects, just as an array. var autoLength = function(object) { //primitive or null if (!object) return 0 if (object.length !== undefined) return object.length if (typeof object != 'object') return 0 var count = 0; for (var k in object) if (object.hasOwnProperty(k)) ++count; return count; } //Checks if an object has members, we can add this prototype to the Object object with define property! var autoEmptyObject = function(object) { if (!object) return 0 if (object.length !== undefined) return object.length == 0 if (typeof object != 'object') return 0 for (var k in object) if (object.hasOwnProperty(k)) return false; return true; } //NOTE: since variables can be non-objects like numbers, we must compare using non-OO var autoEqual = function(first, second, ignoreNodeKeys) { if (first === second) return true if (!first || !second || typeof first != 'object' || typeof second != 'object') { //primitive or null return first == second // true if both NaN, false otherwise //return a!==a && b!==b; } //equal object types? if (first.constructor !== second.constructor) return false; //we are dealing with some sort of object - and its the same type var i, length, index if (Array.isArray(first)) { length = first.length if (length != second.length) return false for (index = 0; index < length; index++) { //these may not be objects if (autoEqual(first[index], second[index], ignoreNodeKeys) == false) return false } } else if (isObject(first)) { //console.log("first: " + JSON.stringify(first)) var keys = Object.keys(first); length = keys.length if (length !== Object.keys(second).length) return false; //if one of the keys does not exists, then its diffing. We don't need to check first, since they otherwise won't compare in the next step for (i = length; i-- !== 0;) //stupid loop is supposed to be the "fastest", probably true but just think of it like a regular loop if (second.hasOwnProperty(keys[i]) == false) return false; for (i = length; i-- !== 0;) { var key = keys[i]; if (ignoreNodeKeys && key == "children") { //we only compare one hierarchy at a time - so we must stop here if (first[key] != second[key]) return false continue } if (autoEqual(first[key], second[key], ignoreNodeKeys) == false) return false } } else return first == second //how do we compare other objects? we can't and don't need to? return true } //DEFINE STRING FUNCTIONS //good and common string functions Object.defineProperties(String.prototype, { //return a readable but url-friendly version of this string slug: { value: function() { var string = this.toLowerCase().replace(/\s+/g, '-') // collapse whitespace and replace by - string = string.replace(/[^a-zA-Z0-9\-]+/g,''); //remove invalid chars string = string.replace(/\-\-+/g, '-') // Replace multiple - with single - return string }, enumerable: false, writable: true, configurable: true }, hashCode: { value: function() { var hash = 0; if (this.length == 0) return hash; for (var i = 0; i < this.length; i++) { hash = ((hash << 5) - hash) + this.charCodeAt(i); hash = hash & hash; // Convert to 32bit integer } return hash; }, enumerable: false, writable: true, configurable: true }, }) //Shims if (!String.prototype.padStart) { Object.defineProperty(String, "padStart", { value: function padStart(targetLength, padString) { targetLength = targetLength >> 0; //truncate if number, or convert non-number to 0; padString = String(typeof padString !== 'undefined' ? padString : ' '); if (this.length >= targetLength) { return String(this); } else { targetLength = targetLength - this.length; if (targetLength > padString.length) { padString += padString.repeat(targetLength / padString.length); //append to original to ensure we are longer than needed } return padString.slice(0, targetLength) + String(this); } }, enumerable: false, writable: true, configurable: true }) } //Shims for ECMA 5 if (!String.prototype.endsWith) { Object.defineProperties(String.prototype, { /** str.endsWith(searchString[, position]) searchString The characters to be searched for at the end of this string. position Optional. Search within this string as if this string were only this long; defaults to this string's actual length, clamped within the range established by this string's length. */ endsWith: { value: function(searchString, position) { if (!position || position > this.length) { position = this.length; } position -= searchString.length; var lastIndex = this.indexOf(searchString, position); return lastIndex !== -1 && lastIndex === position; }, enumerable: false, writable: true, configurable: true }, startsWith: { value: function(search, rawPos) { var pos = rawPos > 0 ? rawPos|0 : 0; return this.substring(pos, pos + search.length) === search }, enumerable: false, writable: true, configurable: true }, }) } //DEFINE OBJECT FUNCTIONS //ES5 version of Object.assign - android webView does not have it! if (typeof Object.assign != 'function') { // Must be writable: true, enumerable: false, configurable: true Object.defineProperty(Object, "assign", { value: function assign(target, varArgs) { // .length of function is 2 if (target == null) return null; var to = Object(target); for (var index = 1; index < arguments.length; index++) { var nextSource = arguments[index]; if (nextSource != null) { // Skip over if undefined or null for (var nextKey in nextSource) { // Avoid bugs when hasOwnProperty is shadowed if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) { to[nextKey] = nextSource[nextKey]; } } } } return to; }, writable: true, configurable: true }); } //same as mergeOptions but with a better name and returns the first objects so we can use chaining //DEPRICATED use Object.assign instead! AggressiveLayout.prototype.mergeObjects = function(obj1, obj2) { /* for (var attributeName in obj2) { if (!obj2.hasOwnProperty(attributeName)) continue; obj1[attributeName] = obj2[attributeName]; } return obj1; */ console.warn("DEPRICATED use Object.assign instead!") return Object.assign(obj1, obj2) } Object.defineProperties(Object.prototype, { deepCopy: { //this is the recommended way to do it according to MDN. Functions are not supported. value: function() { return JSON.parse(JSON.stringify(this)); }, enumerable: false }, shallowCopy: { value: function() { if (Array.isArray(this)) return this.slice() return Object.assign({}, this) }, enumerable: false }, }) Object.defineProperties(Array.prototype, { //shim for an isEqual on arrays, when you only want to compare first-level values isEqual: { value: function(otherArray) { if (this === otherArray) return true; if (otherArray == null || !Array.isArray(otherArray) || this.length != otherArray.length) return false; var length = this.length for (var i = 0; i < length; ++i) { if (this[i] !== otherArray[i]) return false; } return true; }, enumerable: false, writable: true, configurable: true }, contains: { value: function(needle) { for (var i in this) { if (this[i] == needle) return true; } return false; }, enumerable: false, }, /* Append/extend an array to this array. If you want to remove stuff from an array use splice: array.splice(index, 1); If you you want to insert into the array: array.splice(index, 0, newItemToInsert); (its not called spliff but splice) */ appendArray: { value: function(otherArray) { if (!otherArray) return; for (var index = 0; index < otherArray.length; index++) { this.push(otherArray[index]); } }, enumerable: false }, //Append/extend an array into this array at a certain index. insertArray: { value: function(index, otherArray) { if (!otherArray) return; for (var i = otherArray.length - 1; i >= 0; i--) { this.splice(index, 0, otherArray[i]); } } }, }) /** Detect diffs of two objects (old and new), and return if there are diffs. You may supply an object to skip some attributes, skipAttributes = { children : 1}. @attribute onlyCountObjects - to compare children, we only count the amount of diffs (and cutoff at ~20), otherwise this can become too slow! @return a new object with the diffs, or null if equal. Removed attributes are not reported. To overwrite something you need to set it to null, e.g. oldObj = { html: "waiting for data to be fetched for this div" }. newObj = { html : null, children: ... }. */ function DiffLayoutJSON(oldObject, newObject, options) { this.count = 0; var memory = this.doDiff(oldObject, newObject, options, 0) if (memory) Object.assign(this, memory) } DiffLayoutJSON.prototype = { hasDiff: function(attributeName, type, count, options, memory) { //if we are in a subNode we inc its count, then return and in the return also add it to the correct change-array and inc the total amount. this.count += count if (options.onlyCountObjects) return; //Don't move this, if we have sub-nodes we must have a memory. if (!memory) memory = {} if (type == "remove") { if (!memory.removedAttributes) memory.removedAttributes = [attributeName] else memory.removedAttributes.push(attributeName) } /* we are not using these yet, so no need to pay the cost. Should they be used? else if (type == "new") { if (!memory.newAttributes) memory.newAttributes = [] memory.newAttributes.push(attributeName) } else if (type == "change") { if (!memory.changedAttributes) memory.changedAttributes = [] memory.changedAttributes.push(attributeName) } */ return memory }, doDiff: function(oldObject, newObject, options, searchDepth, memory) { if (oldObject === newObject) return; if (searchDepth > 5) { this.count ++; return; } for (var attributeName in newObject) { if (options.skipAttributes[attributeName] || newObject.hasOwnProperty(attributeName) === false) continue var oldMember = oldObject[attributeName]; var newMember = newObject[attributeName]; if (oldMember === newMember) continue; if (!oldMember && newMember) { memory = this.hasDiff(attributeName, "new", 1, options, memory); } else //both have this - diff arrays and dicts for subTypes, otherwise just add the object as a change. { if (Array.isArray(newMember)) { if (!oldMember || !Array.isArray(oldMember)) { memory = this.hasDiff(attributeName, "change", 1, options, memory) } else if (newMember !== oldMember) { //the arrays are not the same, we need to diff them but it in a smart, efficient way. Only count the diffs! var isChildren = attributeName == "children"; if (isChildren) { //we cannot look through children, because it will remove the whole tree if it discovers any changes - so if you've built your JSON as a big tree containing everything, the slightest change removes everything and starts over. //This is actually a good thing. Instead just check if the current element should be removed and redo sub-trees. continue; } var arrayDiffs = 0; for (var i = 0; i < newMember.length; ++i) { var newMemberChild = newMember[i] if (oldMember.length <= i) { arrayDiffs++; //we just count the amount of differences } /* else if (isChildren) { //we must go into children - why? - we must also report the chain somehow - NO subtypes ONLY report count! var subType = parentName ? parentName + "." + attributeName : attributeName arrayDiffs += this.doDiff(oldMember[i], newMemberChild, true, searchDepth + 1, subType); } */ else { //for other arrays we just count the diffs arrayDiffs += autoEqual(oldMember[i], newMemberChild, true) ? 0 : 1 } } if (arrayDiffs) { memory = this.hasDiff(attributeName, "change", arrayDiffs, options, memory); } } } else if (isObject(newMember)) { if (isObject(oldMember) == false) { memory = this.hasDiff(attributeName, "change", 1, options, memory); } else { //TODO: we have a dict, like attributes or styles. recursively go through it and build a diff-tree. var nextDiff = this.doDiff(oldMember, newMember, options, searchDepth + 1) if (nextDiff) { //the object has changed, note that memory = this.hasDiff(attributeName, "change", 0, options, memory); //and also add the changes as a tree if (!memory.children) memory.children = {} memory.children[attributeName] = nextDiff } } } else { memory = this.hasDiff(attributeName, "change", 1, options, memory); } } } for (var attributeName in oldObject) { if (options.skipAttributes[attributeName] || newObject[attributeName] || oldObject.hasOwnProperty(attributeName) === false) continue; //already diffed this! //otherwise it was removed. if (!oldObject[attributeName]) continue; //we must also check for null, undefined and the empty string - since all those means the same as removed? This means that (textField["value"] = "") is the same as (delete textField["value"]) OR (textField["value"] = null) memory = this.hasDiff(attributeName, "remove", 1, options, memory); } return memory } } /** Here it begins... The autoCMS data fetcher. It caches data in the page if on server, otherwise it retrieves from it and populates templates. AND then fetches from server. Auto-fetch data from cache or server, if template has the washData function we use that to create additional layout info and connect to our layout objects if needed. Then send to template.washData(data) (if exists) and finally inserted using template.setContentData(data) After insertion, layout is called to update. This allows you to set yourself as the template if you want to control getting/setting/washing yourself. Usage, here we have layout and a template, and the table for the CMS is called "help": var dataHandler = new TemplateData({layout: this.layout, template: this.template, table: "help", isStatic: 1}) dataHandler.fetchData() It will then fetch the "help" table from server, run the data through "defaultWashData", which creates a json-tree of article objects and then call: this.template.setContentData(data, series); this.layout.updateLayout(); This works since layout knows its API_URL and the CMS always does fetching the same way (fetch_feed with blog_name). Note how defaultWashData takes post with or without layout_data and creates divs with them. AND also creates the tree if parent_id is used. Advanced topics: To select a part, or parts of the feed for special purposes - e.g. you have a main section and a aside-table. Create "series" for those, then to lay out main - simply select the main series. Series are just name:array, like this: { name: [elements], name2: [elements], ...} Tags will be used in similar ways in the future. */ function TemplateData(options) { this.layout = options.layout; this.template = options.template; this.table = options.table; this.isStatic = options.isStatic; this.series = {}; } TemplateData.prototype = { fetchData: function() { /*First we must decide if we want to update. We have these situations: 1. on server (always fetch) 2. on client and no cache (fetch) 3. on client and cache and static page (never fetch, must be generated on the server) 4. on client and cache (fetch if old or always fetch?) */ //TODO: let several templates use the same table var data = this.layout.getPageCacheJSON(this.table) if (data) { if (this.template.washData) this.template.washData(data); else (this.defaultWashData(data)) this.template.setContentData(data) if (!window.serverGenerated && this.isStatic) return; } var options = {"fetch_feed" : 1} if (this.table_id) options.info_id = this.table_id; else options.feed_name = this.table this.layout.postAPI(options, { target : this }); }, successCallback: function(payload, request) { //begin by sorting! var data = payload.feed data.sort(function (first, second){ return second.publish_date - first.publish_date; }) //set cache so server can compare with previous versions. if (window.serverGenerated) { this.layout.setPageCacheJSON(this.table, data) //store the sorted, but otherwise non-modified data. } if (this.template.washData) this.template.washData(data); else (this.defaultWashData(data)) this.template.setContentData(data, this.series); this.layout.updateLayout(); }, errorCallback: function(data, request) { this.template.setContentData([{ title : "Server error", body : "Could not connect to server."}]); this.layout.updateLayout(); }, //Auto-toggle faqs, TODO: make this complete! (not working at the moment) toggleFAQButtonClick: function(e, element, layout) { var index = element.auto_data this.layout.elements["toggle_button_" + index].style.display = "none" this.layout.elements["faq_body_" + index].style.display = "block" }, defaultWashData: function(data) { var parents = {} var children = [] //for (var index = data.length - 1; index >= 0; index--) for (var index = 0; index < data.length; index++) { var post = data[index]; parents[post.id] = post var layoutJSON = post.json_data if (layoutJSON) { //this is only needed if contents are placed inside json_data instead of title/lead/body - layoutJSON = post.json_data.replace(/(\r\n|\n|\r)/gm, "\\n") try { layoutJSON = JSON.parse(layoutJSON); } catch (error) { post.json_data = null; console.error("json data is broken for: " + post.id); console.log("ERROR! ADFatal" + "Errors json washData"); this.convertPost(post) continue; } if (layoutJSON.type == "faq") //this is deprecated - can be built in code instead. { //replace general format to specific post.children = [ { html: post.title, type: "h1" }, { auto_data: index, var: "toggle_button_" + index, autoType: AUTO_TYPE.button, html: "➡︎ " + post.lead, autoListeners: { "click" : { target: this, includeEvent : 1, includeElement : 1, function: "toggleFAQButtonClick"}}, type: "div", className: "toggle_button" }, { var: "faq_body_" + index, text: post.body, style : { display : "none" }}, ] post.title = null; post.lead = null; post.body = null; } else { this.recursiveWash(layoutJSON) post.washedJSON = layoutJSON //we allow mixing json data with article data. So you can easily set classNames, etc. if (post.title != undefined || post.lead != undefined || post.body != undefined) { var layoutJSON = [] if (post.title != undefined) layoutJSON.push({ text: post.title, type: "h1" }) if (post.lead != undefined) layoutJSON.push({ text: post.lead, type: "h2" }) if (post.body != undefined) layoutJSON.push({ text: post.body }); post.washedJSON.children = layoutJSON } } } else { this.convertPost(post) } //handle parents - just make the parent into a div with it's own post as the first child, then the other children as siblings if (post.parent_id) { children.push(post) //remove index and dec so we don't skip the next item data.splice(index, 1) //this is going inside some other div index-- } } for (var index = 0; index < children.length; index++) { var child = children[index]; var parentPost = parents[child.parent_id]; if (!parentPost) { console.log("error, missing parent when washing data"); continue; } var parentJSON = parentPost.washedJSON; if (!parentJSON.children) { if (!parentJSON) { parentJSON = {} parentPost.washedJSON = parentJSON; } //NOTE: DO NOT turn the parent into a div with itself as the first child - if your parent contains layout it will (likely) be removed by children. parentPost.washedJSON.children = [] } parentJSON.children.push(child.washedJSON) } //now we can remove the washedJSON, and turn it into regular json_data for (var index = data.length - 1; index >= 0; index--) { var item = data[index]; if (item.series) { var series = this.series[item.series]; if (!series) { series = [] this.series[item.series] = series; } series.push(item.washedJSON) } data[index] = item.washedJSON; } }, /* Create real objects of nested layout data, imagine looping through this: { type: "a", href: "#page=login", html : "» Coming soon: Signup to be the first to try it out.", autoListeners: { "click" : { target: "layout", function: "scrollToDiv", includeEvent: 1, arguments: ["autoLoginDiv"] }}, }, or this { "autoType": "anchorLink", "html" : "» Coming soon: Signup to be the first to try it out.", "key" : "scroll", "value": "login" }, */ recursiveWash: function(layoutJSON) { for (var variable in layoutJSON) { var value = layoutJSON[variable]; if (variable == "target") { //translate this to an object switch (value) { case "layout": layoutJSON[variable] = this.layout; break; case "this": case "template": case "this.template": layoutJSON[variable] = this.template; break; default: var object = this.template[value]; if (object) layoutJSON[variable] = object; } } else if (variable == "autoType") { //check if this works layoutJSON[variable] = AUTO_TYPE[value]; } else if (Array.isArray(value)) { for (var index = 0; index < value.length; index++) { var item = value[index]; if (isObject(item)) { this.recursiveWash(item) } } } else if (isObject(value)) { this.recursiveWash(value) } } }, /** We want to standardize data for use with templates, always convert in the same way. PostData has the following properties: It has json_data that is taken care of in dedicated washing-function, or is a regular post (has title, post or body that describes the content). OR is regular json object. @param post, the post data */ convertPost: function(post) { if (post.washedJSON) { return; } else if (post.title != undefined || post.lead != undefined || post.body != undefined) { var layoutJSON = [] if (post.title != undefined) layoutJSON.push({ text: post.title, type: "h1" }) if (post.lead != undefined) layoutJSON.push({ text: post.lead, type: "h2" }) if (post.body != undefined) layoutJSON.push({ text: post.body }); post.washedJSON = { children : layoutJSON } } else { //an empty post at this point. post.washedJSON = post.json_data; } } }; /** Here we wrap the basic templates with template data, so we can auto-build data driven components. Build it like this: var templateSettings = { classPrefix: "privacy", showAll: 1, leadList : "ol" } var dataSettings = { table: "privacy", isStatic: 1 } this.privacy = this.autoTemplate({ url: "/aggressiveLayoutJS/basicTemplates.js", className: "ToggleFAQTemplate", templateSettings: templateSettings, dataSettings: dataSettings }) Note that url is optional. */ AggressiveLayout.prototype.autoTemplate = function(options) { if (!options.dataSettings || !options.templateSettings) { console.error("Missing dataSettings or templateSettings, cannot fetch this!"); return null; } if (!options.templateSettings.layout) options.templateSettings.layout = this; if (!options.dataSettings.layout) options.dataSettings.layout = this; return new AutoTemplate(options) } function AutoTemplate(options) { this.layout = options.dataSettings.layout var className = options.className var callback = function() { //create the template this.template = new window[className](options.templateSettings) options.dataSettings.template = this.template //fetch the data var dataHandler = new TemplateData(options.dataSettings) dataHandler.fetchData() }.bind(this) if (options.url) { var dict = {} dict[options.url] = className this.layout.batchInclude(null, dict, callback) } else callback() } AutoTemplate.prototype = { layoutFunction: function(rootElement) { if (!rootElement) rootElement = this.layout.elements.rootElement; this.layout.layoutJSON(rootElement, this.getLayout()) }, //here we just return what the template has built getLayout: function() { if (!this.template) return {} return { children: this.template.templateJSON() || [] } }, } /** Some code is just duplicated over and over. Inherit that functionality instead! Like this, simple observer pattern: */ function AutoObserve() { this.observers = [] } AutoObserve.prototype = { observe: function(callback) { this.observers.push(callback) }, callObservers: function() { for (var index = 0; index < this.observers.length; index++) { var callback = this.observers[index]; callback(this) } }, } AutoObserve.inherit = function(subject) { subject.observers = [] var prototype = Object.getPrototypeOf(subject); prototype.observe = AutoObserve.prototype.observe prototype.callObservers = AutoObserve.prototype.callObservers }