UNPKG

elation

Version:

Elation Javascript Component Framework

758 lines (725 loc) 27.9 kB
elation.require(['utils.template'], function() { elation.extend('elements', { initialized: false, uniqueids: {}, types: {}, activeElements: new Set(), init() { elation.elements.initialized = true; // Set up a mutation observer so we can keep track of all our elements and any style changes that require updates this.observer = new window.MutationObserver(mutations => mutations.forEach(this.observe.bind(this))) this.observer.observe(window.document, { attributes: true, attributeFilter: ['class'], childList: true, subtree: true }) }, observe(mutation) { if (mutation.type == 'childList' && mutation.addedNodes.length > 0) { for (let addition of mutation.addedNodes) { if (addition.tagName == 'LINK') { // new external CSS file, refresh elements when it finishes loading addition.addEventListener('load', (ev) => { elation.elements.refresh();}); } else if (addition.tagName == 'STYLE') { // new inline CSS, refresh elements now elation.elements.refresh(); } else if (addition instanceof elation.elements.base) { // New element, add it to our list of active elements this.activeElements.add(addition); } } for (let removal of mutation.removedNodes) { // Remove elements from activeElements set if (this.activeElements.has(removal)) { this.activeElements.delete(removal); } } } }, refresh() { this.activeElements.forEach(el => el.refresh()); }, define: function(name, classdef, notag) { var elementname = name.replace(/\./g, '-'), componentname = name.replace(/-/g, '.'); elation.extend('elements.' + componentname, classdef); if (!notag) { customElements.define(elementname, classdef); } //console.log('define element:', name, '<' + elementname + '>'); }, create: function(type, attrs={}) { var elementname = type.replace(/\./g, '-'); var element = document.createElement(elementname); if (!elation.elements.initialized) { elation.elements.init(); } if (element) { if (attrs.append) { elation.html.attach(attrs.append, element, attrs.before); delete attrs.append; } for (var k in attrs) { if (k == 'innerHTML') { element[k] = attrs[k]; } else { // FIXME - this should be handled by the type coersion system if (elation.utils.isObject(attrs[k])) { element[k] = attrs[k]; } else if (attrs[k] === true) { element.setAttribute(k, ''); } else if (!(attrs[k] === false || attrs[k] === undefined || attrs[k] === null)) { element.setAttribute(k, attrs[k]); } } } } return element; }, registerType: function(type, handler) { this.types[type] = handler; }, fromString: function(str, parent) { let container = document.createElement('div'); container.innerHTML = str; var nodes = container.querySelectorAll('*'); var elements = { length: nodes.length }; for (var i = 0; i < elements.length; i++) { elements[i] = nodes[i]; let elname = elements[i].getAttribute('name'); if (elname) { elements[elname] = elements[i]; } if (elements[i].id) { elements[elements[i].id] = elements[i]; } } if (parent) { while (container.childNodes.length > 0) { parent.appendChild(container.childNodes[0]); } } return elements; }, fromTemplate: function(tplname, parent) { return elation.elements.fromString(elation.template.get(tplname, parent), parent); }, getEvent: function(type, args) { var ev = new Event(type); for (var k in args) { ev[k] = args[k]; } return ev; }, getUniqueId: function(type) { if (!type) { type = 'element'; } // Initialize to zero if (!this.uniqueids[type]) this.uniqueids[type] = 0; // Increment the counter for this type as we generate our new name return type + '_' + (++this.uniqueids[type]); }, mixin: function(BaseClass) { return class extends BaseClass { constructor() { super(); this.initElation(); } initElation() { this._elation = { properties: {}, classdef: { } }; this.init(); //this.initAttributes(); } init() { this.defineAttributes({ deferred: { type: 'boolean', default: false }, template: { type: 'string' }, name: { type: 'string' }, //classname: { type: 'string' }, preview: { type: 'boolean', default: false }, hover: { type: 'boolean', default: false }, editable: { type: 'boolean', default: false }, flex: { type: 'string' } }); elation.events.add(this, 'mouseover', (ev) => this.onhover(ev)); elation.events.add(this, 'mouseout', (ev) => this.onunhover(ev)); } defineAttributes(attrs) { for (var k in attrs) { this.defineAttribute(k, attrs[k]); } } defineAttribute(attrname, attrdef) { this._elation.classdef[attrname] = attrdef; Object.defineProperty(this, attrname, { configurable: true, enumerable: true, get: () => { return this.getProperty(attrname) }, set: (v) => { this.setProperty(attrname, v); } }); //var observer = new MutationObserver((ev) => console.log('now they mutate', ev, this); ); //observer.observe(this, {attributes: true}); } initAttributes() { var attributes = this.getAttributeNames(); for (var i = 0; i < attributes.length; i++) { var attrname = attributes[i]; if (attrname.indexOf('.') != -1) { elation.utils.arrayset(this, attrname, this.getAttribute(attrname)); } } } setProperty(k, v, skip) { // TODO - type coersion magic happens here elation.utils.arrayset(this._elation.properties, k, v); //this._elation.properties[k] = v; //console.log(this._elation.properties); //if (v == '[object HTMLElement]') debugger; let classdef = this._elation.classdef[k]; if (!skip && !classdef.innerHTML) { if (classdef.type == 'boolean') { if (v) { this.setAttribute(k, ''); } else { this.removeAttribute(k); } } else { if (elation.elements.types[classdef.type]) { this.setAttribute(k, elation.elements.types[classdef.type].write(v)); } else { this.setAttribute(k, v); } } if (classdef.set) { classdef.set.call(this, v); } } } getProperty(k) { // TODO - type coersion magic happens here let prop = elation.utils.arrayget(this._elation.properties, k, null); let classdef = this._elation.classdef[k]; if (classdef.get) { return this.getPropertyAsType(classdef.get.call(this, k), classdef.type); //} else if (k in this._elation.properties) { // return this._elation.properties[k]; } else if (prop !== null) { return this.getPropertyAsType(prop, classdef.type); } else if (this.hasAttribute(k)) { return this.getPropertyAsType(this.getAttribute(k), classdef.type); } else if (typeof classdef.default != 'undefined') { return classdef.default; } } getPropertyAsType(value, type) { switch (type) { case 'boolean': return ((value && value !== '0' && value !== 'false') || value === '' ); case 'integer': return value|0; case 'float': return +value; case 'callback': if (elation.utils.isString(value)) { return new Function('event', value); } return value; default: if (elation.elements.types[type]) { return elation.elements.types[type].read(value); } return value; } } connectedCallback() { // FIXME - the document-register-element polyfill seems to throw away any object setup we do in the constructor, if that happened just re-init if (!this._elation) this.initElation(); this.initAttributes(); if (this.create && !this.created) { // Call the element's create function asynchronously so that its childNodes can populate setTimeout(() => this.create(), 0); this.created = true; } this.dispatchEvent({type: 'elementconnect'}); } handleEvent(ev) { if (typeof this['on' + ev.type] == 'function') { this['on' + ev.type](ev); } } dispatchEvent(ev) { if (typeof this['on' + ev.type] == 'function') { this['on' + ev.type](ev); } //var evobj = elation.elements.getEvent(ev); //super.dispatchEvent(evobj); let element = ev.element = this; //ev.target = element; let fired = elation.events.fire(ev); if (ev.bubbles) { while ((element = element.parentNode) && !elation.events.wasBubbleCancelled(fired)) { let bubbleev = elation.events.clone(ev, {target: this, currentTarget: element, element: element}) //ev.element = element; //ev.currentTarget = element; fired = elation.events.fire(bubbleev); } } } /* * Handle default element creation. If template is specified, use it for our contents. */ create() { if (this.template) { this.innerHTML = elation.template.get(this.template, this); } } /** * Mark data as dirty, and then start the render loop if not already active * @function refresh * @memberof elation.elements.base# */ refresh() { this.needsUpdate = true; if (this.deferred) { if (!this.renderloopActive) { this.setuprenderloop(); } } else { this.render(); } } /** * Refresh all of this element's children * @function refreshChildren * @memberof elation.elements.base# */ refreshChildren() { for (var i = 0; i < this.childNodes.length; i++) { var node = this.childNodes[i]; if (node instanceof elation.elements.base) { node.refresh(); node.refreshChildren(); } } } /** * Hook into the browser's animation loop to make component renders as efficient as possible * This also automatically rate-limits updates to the render speed of the browser (normally * 60fps) rather than triggering a render every time data changes (which could be > 60fps) * * @function renderloop * @memberof elation.elements.base# */ setuprenderloop() { requestAnimationFrame(this.renderloop.bind(this)); } renderloop() { if (this.needsUpdate) { this.render(); //if (this.image) this.toCanvas(); this.needsUpdate = false; this.renderloopActive = true; this.setuprenderloop(); } else { this.renderloopActive = false; } } /** * Update the component's visual representation to reflect the current state of the data * * @function render * @abstract * @memberof elation.elements.base# */ render() { if (this.flex && this.flex != this.style.flex) { this.style.flex = this.flex; } if (this.canvas) { this.updateCanvas(); } } /** * Add an HTML class to this component * @function addclass * @memberof elation.ui.base# */ addclass(classname) { if (!elation.html.hasclass(this, classname)) { elation.html.addclass(this, classname); } } /** * Remove an HTML class from this component * @function removeclass * @memberof elation.ui.base# */ removeclass(classname) { if (elation.html.hasclass(this, classname)) { elation.html.removeclass(this, classname); } } /** * Check whether this component has the specified class * @function hasclass * @memberof elation.ui.base# * @returns {bool} */ hasclass(classname) { return elation.html.hasclass(this, classname); } /** * Make this component visible * @function show * @memberof elation.ui.base# */ show() { if (this.hidden) { this.hidden = false; this.removeclass('state_hidden'); this.refresh(); } } /** * Make this component invisible * @function hide * @memberof elation.ui.base# */ hide() { this.hidden = true; this.addclass('state_hidden'); } /** * Enable this component * @function enable * @memberof elation.ui.base# */ enable() { this.enabled = true; this.removeclass('state_disabled'); } /** * Disable this component * @function disable * @memberof elation.ui.base# */ disable() { this.enabled = false; this.addclass('state_disabled'); } /** * Set this component's hover state * @function hover * @memberof elation.ui.base# */ onhover() { this.hover = true; } /** * Unset this component's hover state * @function unhover * @memberof elation.ui.base# */ onunhover() { this.hover = false; } /** * Sets the orientation of this component * @function setOrientation * @memberof elation.ui.base# * @param {string} orientation */ setOrientation(orientation) { if (this.orientation) { this.removeclass('orientation_' + this.orientation); } this.orientation = orientation; this.addclass('orientation_' + this.orientation); } addPropertyProxies(element, properties) { properties = (elation.utils.isString(properties) ? properties.split(',') : properties); for (var i = 0; i < properties.length; i++) { ((p) => { // Set current value if (typeof this[p] != 'undefined' && this[p] !== null) { element[p] = this[p]; } // Define getter and setter to proxy requests for this property to another element Object.defineProperty(this, p, { get: function() { return element[p]; }, set: function(v) { element[p] = v; } }); })(properties[i]); } } addEventProxies(element, events) { var passiveEvents = ['touchstart', 'touchmove', 'touchend', 'mousewheel']; events = (elation.utils.isString(events) ? events.split(',') : events); for (var i = 0; i < events.length; i++) { elation.events.add(element, events[i], (ev) => { //this.dispatchEvent({type: ev.type, event: ev }); this.dispatchEvent(ev); }, (passiveEvents.indexOf(events[i]) != -1 ? {passive: true} : false)); } } /** * Render this element to an image */ toCanvas(width, height, scale) { this.canvasNeedsUpdate = true; if (typeof width == 'undefined') { width = this.offsetWidth; } if (typeof height == 'undefined') { height = this.offsetHeight; } if (typeof scale == 'undefined') { scale = 1; } if (!this.canvas) { this.canvas = document.createElement('canvas'); this.canvas.crossOrigin = 'anonymous'; this.canvas.width = width; this.canvas.height = height; this.canvasscale = scale; //document.body.appendChild(this.canvas); this.observer = new MutationObserver(() => { // Rate limit refreshes to avoid too many updates if (this.refreshtimer) clearTimeout(this.refreshtimer); this.refreshtimer = setTimeout(() => { // Use requestIdleCallback to reduce the amount of jank when updating requestIdleCallback(() => { this.updateCanvas(); this.refreshqueued = false; this.refreshtimer = false; }, { timeout: 20 }); }, 50); //this.refresh(); }); this.observer.observe(this, { subtree: true, childList: true, attributes: true, characterData: true }); } //var img = new Image(); // We need to sanitize our HTML in case someone provides us with malformed markup. // We use SVG to render the mark-up, and since SVG is XML it means we need well-formed data // However, for whatever reason, <br> amd <hr> seem to break things, so we replace them with // styled divs instead. //var sanitarydiv = document.createElement('div'); //sanitarydiv.innerHTML = this.outerHTML; /* if (this.stylesheetsChanged()) { let fetches = []; // Fetch all active stylesheets, so we can inject them into our foreignObject for (let i = 0; i < document.styleSheets.length; i++) { let stylesheet = document.styleSheets[i]; fetches[i] = fetch(stylesheet.href).then(r => r.text()).then(t => { return { url: stylesheet.href, text: t, order: i }; }); } this.stylecachenames = this.getStylesheetList(); Promise.all(fetches).then((stylesheets) => { var styletext = ''; // Make sure stylesheets are loaded in the same order as in the page stylesheets.sort((a, b) => { return b.order - a.order; }); for (var i = 0; i < stylesheets.length; i++) { styletext += stylesheets[i].text.replace(/\/\*[^\*]+\*\//g, '').replace(/</g, '&lt;'); } this.styletext = styletext; this.updateCanvas(); }); } else { this.updateCanvas(); } */ this.updateCanvas(); return this.canvas; } async updateCanvas(force) { if (this.loading) return; let outerHTML = this.outerHTML; if (this.lasthtml == outerHTML && !force) return false; this.lasthtml = outerHTML; //console.time('updateCanvas'); this.loading = true; var width = this.canvas.width, height = this.canvas.height; var ctx = this.canvas.getContext('2d'); //console.time('updateCanvas:get images'); var imgtags = this.getElementsByTagName('img'); //console.timeEnd('updateCanvas:get images'); var images = [], promises = []; if (!this.imagecache) this.imagecache = {}; //console.time('updateCanvas:imagesrc'); for (var i = 0; i < imgtags.length; i++) { if (imgtags[i].src.substring(0, 5) == 'data:') { //promises.push(this.fetchImage(imgtags[i].src)); promises.push(new Promise(resolve => resolve(imgtags[i].src))); images[i] = imgtags[i].src; } else { promises.push(this.fetchImage(imgtags[i].src)); images[i] = imgtags[i].src; } } //console.timeEnd('updateCanvas:imagesrc'); //console.time('updateCanvas:style'); if (this.stylesheetsChanged()) { await this.updateStylesheets(); } //console.timeEnd('updateCanvas:style'); Promise.all(promises).then((imgdata) => { //console.time('updateCanvas:img set src'); for (var i = 0; i < imgtags.length; i++) { //content = content.replace(images[i], imgdata[i]); if (imgtags[i].src.substring(0, 5) != 'data:' && imgtags[i].src != imgdata[i]) { imgtags[i].src = imgdata[i]; } } for (var i = 0; i < imgtags.length; i++) { //content = content.replace(images[i], imgdata[i]); //imgtags[i].src = images[i]; } //console.timeEnd('updateCanvas:img set src'); let img = this.img; //let svg = this.svg; if (!img) { //console.time('updateCanvas:create svg'); img = this.img = new Image(); img.eager = true; img.addEventListener('load', () => { this.canvas.width = width; this.canvas.height = height; ctx.drawImage(img, 0, 0) this.loading = false; elation.events.fire({element: this.canvas, type: 'asset_update'}); }); img.addEventListener('error', (err) => { console.log('Error generating image from HTML', err, img, content); this.loading = false; }); } let content = this.lastcontent; //console.time('updateCanvas:update svg'); content = this.outerHTML.replace(/<br\s*\/?>/g, '<div class="br"></div>'); content = content.replace(/<hr\s*\/?>/g, '<div class="hr"></div>'); content = content.replace(/<img(.*?)>/g, "<img$1 />"); content = content.replace(/<input(.*?)>/g, "<input$1 />"); this.lastcontent = content; this.lasthtml = this.outerHTML; var svgdata = '<foreignObject requiredExtensions="http://www.w3.org/1999/xhtml" width="' + (width / this.canvasscale) + '" height="' + (height / this.canvasscale) + '" transform="scale(' + this.canvasscale + ')">' + '<html xmlns="http://www.w3.org/1999/xhtml"><body class="dark janusweb">' + '<style>' + encodeURIComponent(this.styletext) + '</style>' + content + '</body></html>' + '</foreignObject>'; var data = '<svg xmlns="http://www.w3.org/2000/svg" width="' + width + '" height="' + height + '">' + svgdata + '</svg>'; var url = 'data:image/svg+xml,' + data; img.src = url; //svg.innerHTML = svgdata; //console.timeEnd('updateCanvas:update svg'); this.canvasNeedsUpdate = false; //console.timeEnd('updateCanvas'); }); this.loading = false; } queryParentSelector(selector) { var node = this.parentNode; while (node) { if (node.matches && node.matches(selector)) { return node; } node = node.parentNode; } return null; } blobToDataURL(blob) { return new Promise((resolve, reject) => { var a = new FileReader(); a.onload = function(e) {resolve(e.target.result);} a.readAsDataURL(blob); }); } async fetchImage(src) { if (this.imagecache[src]) { return this.imagecache[src]; } else { return fetch(this.getFullURL(src)) .then(r => r.blob()) .then(d => { let u = this.blobToDataURL(d); this.imagecache[src] = u; return u;}); } } getFullURL(src) { // FIXME - egregious hack for CORS white building prototype. Do not check this in! let proxyurl = 'https://p.janusvr.com/'; if (src.indexOf(proxyurl) != 0) { return proxyurl + src; } return src; } toString() { if (!this.id) { this.id = elation.elements.getUniqueId(this.nodeName.toLowerCase()); } return '#' + this.id; } fromString(str) { this.elements = elation.elements.fromString(str, this); return this.elements; } fromTemplate(tplname, obj) { this.elements = elation.elements.fromTemplate(tplname, this); return this.elements; } stylesheetsChanged() { if (!this.styletext) return true; let stylesheets = this.getStylesheetList(); if (stylesheets != this.stylecachenames) return true; return false; } async updateStylesheets(proxy='') { console.log('update stylesheets', this); let fetches = []; proxy = elation.engine.assets.corsproxy; // Fetch all active stylesheets, so we can inject them into our foreignObject for (let i = 0; i < document.styleSheets.length; i++) { let stylesheet = document.styleSheets[i]; if (stylesheet.href) { fetches.push(fetch(proxy + stylesheet.href).then(r => r.text()).then(t => { return { url: stylesheet.href, text: t, order: i }; })); } else if (document.styleSheets[i].cssRules.length > 0) { let txt = ''; let sheet = document.styleSheets[i]; for (let i = 0; i < sheet.cssRules.length; i++) { txt += sheet.cssRules[i].cssText + '\n'; } fetches.push(new Promise((resolve) => resolve({url: null, text: txt, order: i }))); } } this.stylecachenames = this.getStylesheetList(); let stylesheets = await Promise.all(fetches); var styletext = ''; // Make sure stylesheets are loaded in the same order as in the page stylesheets.sort((a, b) => { return b.order - a.order; }); for (var i = 0; i < stylesheets.length; i++) { styletext += stylesheets[i].text.replace(/\/\*[^\*]+\*\//g, '').replace(/</g, '&lt;'); } this.styletext = styletext; //this.dispatchEvent(new CustomEvent('styleupdate', { detail: stylesheets})); elation.events.fire({type: 'styleupdate', element: this, data: stylesheets}); setTimeout(() => { this.updateCanvas(true); }, 0); return styletext; } getStylesheetList() { return Array.prototype.map.call(document.styleSheets, n => n.href).join(' '); } }; } }); });