diff --git a/README.md b/README.md index 3f6fdc6..d849d5d 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,13 @@ ## Features - **Component-Based**: Mosaic components are reusable pieces of code that each keep track of their own state (referred to as "data"), actions, lifecycle functions, and more. +- **Observable Data Pattern**: Mosaic uses Observables to keep track of changes to a component's data. This means +that there is no need to call "setState" or anything like that, instead just change the data directly. - **Virtual DOM**: The use of a virtual dom makes updating web apps very fast. -- **Written in JSX**: You can use jsx or the "h" function that comes with Mosaic to write a component's view. +- **JSX**: You can use jsx or the "h" function that comes with Mosaic to write a component's view. - **Easy to Learn**: The syntax and structure of Mosaic components is meant to make it easy to learn so that it does not require a lot of setup to start using. -## Usage +## Installation The easiest way to use Mosaic is to first install the npm package by using: ```shell npm install --save @authman2/mosaic @@ -26,9 +28,7 @@ You will also need to create a .babelrc file so that you can transpile JSX into ```js { "presets": ["env"], - "plugins": [["babel-plugin-transform-react-jsx", { - "pragma": "h" - }]] + "plugins": [["babel-plugin-transform-react-jsx", { "pragma": "h" }]] } ``` Now you are ready to use Mosaic! @@ -63,11 +63,9 @@ const NavButton = new Mosaic({ label: "Default Label", buttonTitle: "Default Button Title" }, - actions: function(self) { - return { - print: function() { - console.log(self.data.buttonTitle); - } + actions: { + print: function() { + console.log(this.data.buttonTitle); } }, view: function() { @@ -75,7 +73,7 @@ const NavButton = new Mosaic({ return (

{this.data.label}

-
@@ -89,18 +87,11 @@ const app = new Mosaic({ data: { title: "Mosaic App" }, - components: { - homeButton: Mosaic.Child(NavButton, { label: "Home Button", buttonTitle: "Home" }), - aboutButton: Mosaic.Child(NavButton, { label: "About Button", buttonTitle: "About" }), - contactButton: Mosaic.Child(NavButton, { label: "Contact Button", buttonTitle: "Contact" }), - }, - actions: function(thisComponent) { - return { - sayHello: function() { - console.log("Hello World!!"); - console.log("This component is ", thisComponent); - } - } + actions: { + sayHello: function() { + console.log("Hello World!!"); + console.log("This component is ", this); + } }, view: function() { return ( @@ -110,9 +101,9 @@ const app = new Mosaic({

- { this.homeButton.view() } - { this.aboutButton.view() } - { this.contactButton.view() } + + + ) } diff --git a/example/app.css b/example/app.css new file mode 100644 index 0000000..8cbd198 --- /dev/null +++ b/example/app.css @@ -0,0 +1,73 @@ +.app { + position: absolute; + margin: 0px; + width: 100%; + padding: 0px; + min-height: 100%; + overflow-y: auto; + text-align: center; + padding-bottom: 100px; + background-color: #4341b5; +} + +.app-title { + color: white; + font-weight: 300; + font-family: 'Avenir'; +} + +input { + width: 70%; + height: 50px; + border: none; + padding: 15px; + font-size: 16px; + border-radius: 10px; + font-family: 'Avenir'; +} + +button { + position: relative; + width: 20%; + height: 50px; + border: none; + outline: none; + display: block; + color: white; + cursor: pointer; + font-size: 14px; + margin-top: 10px; + margin-left: auto; + margin-right: auto; + border-radius: 25px; + transition-duration: 0.1s; + background-color: #6866d8; +} +button:hover { + color: lightgray; + background-color: #5856b9; +} +button:active { + color: gray; + background-color: #333194; +} + +.todo-item { + width: 70%; + padding: 15px; + color: white; + cursor: pointer; + font-weight: 300; + margin-top: 20px; + margin-left: auto; + margin-right: auto; + border-radius: 10px; + font-family: 'Avenir'; + background-color: #6866d8; +} +.todo-item:hover { + background-color: #5856b9; +} +.todo-item:active { + background-color: #333194; +} \ No newline at end of file diff --git a/example/app.html b/example/app.html index 56a90eb..cafaf45 100644 --- a/example/app.html +++ b/example/app.html @@ -1,6 +1,7 @@ Mosaic + diff --git a/example/app.js b/example/app.js index 1d1b08c..91e99e5 100644 --- a/example/app.js +++ b/example/app.js @@ -1,42 +1,48 @@ import { h, Mosaic } from '../src/index'; -import Home from './home'; -const root = document.getElementById('root'); -root.innerHTML = ''; +/* Example of a Todo application using Mosaic. */ +const TodoItem = new Mosaic({ + data: { title: "" }, + view: function() { + return
+ {this.data.title} +
+ } +}); -const appStyles = { - width: '100%', - color: 'white', - paddingTop: '10px', - textAlign: 'center', - fontFamily: 'Avenir', - paddingBottom: '100px', - backgroundColor: '#4341B5' -} -const app = new Mosaic({ - element: root, +const todoApp = new Mosaic({ + element: document.getElementById('root'), data: { - title: "Mosaic", - subtitle: "A front-end JavaScript library for building user interfaces" + todos: ['Click the "Add Todo" button to add another todo item!', + 'Click on a todo item to delete it.'] }, - components: { - home1: Mosaic.Child(Home, { instance: 0, componentInstance: "First Home Instance: " }), - home2: Mosaic.Child(Home, { instance: 1, componentInstance: "Second Home Instance: " }), + actions: { + addTodo: function() { + let value = document.getElementById('inp').value; + document.getElementById('inp').value = ''; + + this.data.todos = this.data.todos.concat(value); + }, + deleteTodo: function(todoIndex) { + this.data.todos = this.data.todos.filter((_, index) => index !== todoIndex); + } }, view: function() { - return ( -
-

Welcome to {this.data.title}!

-

{this.data.subtitle}

-

Use the buttons below to try out the counter!

+ return
+

Mosaic Todo List

+ { if(e.keyCode === 13) this.actions.addTodo() }}/> + - { this.home1.view() } - { this.home2.view() } -
- ) - }, - created: function() { - this.home1.setData({ count: 10 }); + { + this.data.todos.map((todo, index) => { + return + }) + } +
} }); -app.paint(); \ No newline at end of file +todoApp.paint(); \ No newline at end of file diff --git a/example/footer.js b/example/footer.js deleted file mode 100644 index 722d984..0000000 --- a/example/footer.js +++ /dev/null @@ -1,32 +0,0 @@ -import { h, Mosaic } from '../src/index'; - -export default new Mosaic({ - data: { - name: "Example Text" - }, - actions: function(self) { - return { - change: function() { - self.setData({ name: "NEW TEXT" }); - } - } - }, - view: function() { - return ( -
- -
- ) - }, - created: function() { - console.log("Created Footer: ", this); - }, - willUpdate: function(old) { - console.log("About to update footer: ", old); - }, - updated: function() { - console.log("Just updated footer: ", this); - } -}); \ No newline at end of file diff --git a/example/home.js b/example/home.js deleted file mode 100644 index b40179f..0000000 --- a/example/home.js +++ /dev/null @@ -1,58 +0,0 @@ -import { h, Mosaic } from '../src/index'; -import Footer from './footer'; - - -const homeStyles = { - paddingTop: '10px' -} -const buttonStyles = { - width: '50px', - height: '50px', - margin: '20px', - border: 'none', - outline: 'none', - fontSize: '25px', - color: '#4341B5', - cursor: 'pointer', - borderRadius: '100%', - fontFamily: 'Avenir', -} -export default new Mosaic({ - data: { - count: 0 - }, - components: { - footer: Mosaic.Child(Footer) - }, - actions: function(self) { - return { - countUp: function() { - self.setData({ count: self.data.count + 1 }); - }, - countDown: function() { - self.setData({ count: self.data.count - 1 }); - } - } - }, - view: function() { - return ( -
-

{this.data.componentInstance || ""}

-

Count: {this.data.count}

- - - { this.footer.view() } -
- ); - }, - - created: function() { - // Only put a timer on the second instance. - if(this.data.instance === 1) { - setInterval(() => { - const n = Math.floor(Math.random() * 100); - this.setData({ count: n }); - }, 1000); - } - }, -}); \ No newline at end of file diff --git a/package.json b/package.json index f38f6a3..7b7f299 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@authman2/mosaic", - "version": "0.1.4", + "version": "0.1.5", "description": "A front-end JavaScript library for creating user interfaces", "main": "src/index.js", "scripts": { @@ -23,5 +23,6 @@ "devDependencies": { "babel-core": "^6.26.3", "babel-plugin-transform-react-jsx": "^6.24.1" - } + }, + "dependencies": {} } diff --git a/src/index.js b/src/index.js index eb5f6cc..7e1c330 100644 --- a/src/index.js +++ b/src/index.js @@ -1,146 +1,125 @@ -const { createElement } = require('./vdom/createElement'); -const { render } = require('./vdom/render'); -const { mount } = require('./vdom/mount'); -const { diff } = require('./vdom/diff'); -const { validateMosaicChildren, randomID } = require('./util'); +import createElement from './vdom/createElement'; +import render from './vdom/render'; +import patch from './vdom/patch'; +import Observable from './observable'; +import { isHTMLElement, findInvalidOptions } from './validations'; -/** The attributes that go along with a Mosaic component. */ +/** The configuration options for a Mosaic component. */ const MosaicOptions = { - /** The DOM element to use to wrap around a Mosaic component. */ - element: Element || String, + /** The HTML element to inject this Mosaic component into. */ + element: HTMLElement, - /** The state of this component. */ - data: Object.create(null), + /** The state of this component. */ + data: Object, - /** The actions that modify this component's data. */ - actions: Function, + /** The view that will be rendered on the screen. */ + view: Function, - /** The Mosaic children that are nested components of this one. */ - components: Object, + /** The actions that can be used on this Mosaic component. */ + actions: Object, - /** The view that this component will take on. */ - view: Function, + /** The function to run when this component is created and injected into the DOM. */ + created: Function, - /** The function to run when this component gets created. */ - created: Function, + /** The function to run when this component is about to update its data. */ + willUpdate: Function, - /** The function to run when this component is about to update. */ - willUpdate: Function, + /** The function to run after this component has been updated. */ + updated: Function, + + /** The function that runs just before this component gets removed from the DOM. */ + willDestroy: Function +}; - /** The function to run when this component gets updated. */ - updated: Function -} -/** Creates a new Mosaic component. -* @param {MosaicOptions} options The configuration options for a Mosaic component. */ +/** Creates a new Mosaic component with configuration options. +* @param {MosaicOptions} options The configuration options for this Mosaic. */ const Mosaic = function(options) { - this.key = randomID(); - this.data = options.data || {}; - this.actions = options.actions ? options.actions(this) : ((comp) => {}); - this.view = options.view || ((comp) => {}); - this.created = options.created; - this.willUpdate = options.willUpdate; - this.updated = options.updated; - this.localParent = options.parent || null; // This is the parent directly above this component. - - // This is either the root dom node or a wrapper around a component. - this.$element = typeof options.element === 'string' ? document.createElement(options.element) : options.element; - - // Configure the references and the child Mosaic components. - // Then "place" then into this Mosaic component for easy access. - let componentStructures = validateMosaicChildren(options.components) ? options.components : {}; - for(var key in componentStructures) { - this[key] = componentStructures[key].type.copy(this); - this[key].data = Object.assign({}, this[key].data, componentStructures[key].data); - if(this[key].created) this[key].created(); - } - - - - /** Returns a copy of this Mosaic component. - * @param {Mosaic} parent (Optional) The local parent of this copy. - * @returns {Mosaic} A copy of this Mosaic component. */ - this.copy = function(parent = null) { - let cpy = new Mosaic(Object.assign({}, options, { - parent: parent, - // key: this.key, - })); - cpy.$element = this.$element; - return cpy; - } + let invalids = findInvalidOptions(options); + if(invalids !== undefined) throw new Error(invalids); + + this.element = options.element + this.view = options.view; + this.actions = options.actions; + this.created = options.created; + this.willUpdate = options.willUpdate; + this.updated = options.updated; + this.willDestroy = options.willDestroy; + this.data = new Observable(options.data || {}, (oldData) => { + if(this.willUpdate) this.willUpdate(oldData); + }, () => { + patch(this.element, this.view()); + if(this.updated) this.updated(); + }); + this.__isMosaic = true; + + // Bind all actions to this instance. + for(var i in this.actions) this.actions[i] = this.actions[i].bind(this); + + return this; } -/** A child component that is of type Mosaic. -* @param {Mosaic} type The Mosaic component to use as a blueprint. -* @param {Object} data (Optional) Extra data to add to the component. */ -Mosaic.Child = function(type, data = {}) { - return { type: type, data: data }; +/** "Paints" the Mosaic onto the page by injecting it into its base element. */ +Mosaic.prototype.paint = function() { + if(!this.element || !isHTMLElement(this.element)) { + throw new Error(`This Mosaic could not be painted because its element property is either not set + or is not a valid HTML element.`); + } + let htree = createElement(this); + render(htree, this.element, this); } -/** "Paints" your Mosaic onto the screen. Renders a real DOM element for this Mosaic component. */ -Mosaic.prototype.paint = function() { - if(!this.$element) { - console.error("This Mosaic could not be painted because no base element was supplied"); - return; - } else if(typeof this.$element === 'string') { - console.error("You cannot paint this Mosaic because it's base element must be an already existing DOM element"); - return; - } - - const htree = createElement(this); - const $element = render(htree); - const $newRoot = mount($element, this.$element); - - this.$element = $newRoot; - if(this.created) this.created(); -} -/** Sets the data on this Mosaic component and triggers a rerender. -* @param {Object} newData The new data to set on this Mosaic component. */ -Mosaic.prototype.setData = function(newData = {}) { - // When you set data you have to account for two cases: - // 1.) The component that you're setting the data on is the entry point so the entire - // dom tree must be updated anyway. This is also fine because it will have a direct reference - // to the root dom node that is definitely mounted on the page. - // - // 2.) The component having its data set is not the entry point but instead an "n-th level" child - // of the entry point. It should have it's dom element already added to a parent. Look for it and - // update it. - if(this.willUpdate) this.willUpdate(this.data); - - // First make sure that you have an absolute parent. - // Also do a bit of caching so the component can quickly find the absolute parent - // on the next update. - let lookAt = this; - if(!this.absoluteParent) { - while(lookAt.localParent !== null) { - // console.log("Checking: ", lookAt); - lookAt = lookAt.localParent; - } - this.absoluteParent = lookAt; - } else { - lookAt = this.absoluteParent; - } - // console.log("Absolute Parent: ", lookAt); - - // First things first, get a copy of the current HTree. - let oldHTree = lookAt.view(); - - // Then set the new data. - this.data = Object.assign({}, this.data, newData); - - // Get a new HTree for the absolute parent. - let newHTree = lookAt.view(); - // console.log(this, oldHTree, newHTree); - - // Find the patches that need to be done to update the DOM. - let patches = diff(oldHTree, newHTree); - lookAt.$element = patches(lookAt.$element); - - if(this.updated) this.updated(); +/** Static function for building a new instance of a Mosaic. Basically just takes a given VNode of a Mosaic + * and uses it as a blueprint for how to build reusable instances of that component. + */ +Mosaic.view = function(vnode, $parent = null) { + const props = Object.assign({}, vnode.props, { children: vnode.children }); + const _data = Object.assign({}, vnode.type.data, props.data ? props.data : {}); + + // Render a new instance of this component. + if(typeof vnode.type === 'object' && vnode.type.__isMosaic) { + const options = { + element: vnode.type.element, + data: _data, + view: vnode.type.view, + actions: Object.assign({}, vnode.type.actions), + created: vnode.type.created, + willUpdate: vnode.type.willUpdate, + updated: vnode.type.updated, + willDestroy: vnode.type.willDestroy, + } + const instance = new Mosaic(options); + instance.element = render(instance.view(), $parent, instance); + instance.element.__mosaicInstance = instance; + instance.element.__mosaicKey = vnode.props.key; + + if(instance.created) instance.created(); + return instance.element; + } else { + return render(vnode.type.view(props), $parent); + } } +/** Static function for diffing and patching changes between instances of Mosaics. */ +Mosaic.patch = function($dom, vnode, $parent = $dom.parentNode) { + const props = Object.assign({}, vnode.props, { children: vnode.children }); + + if($dom.__mosaicInstance && $dom.__mosaicInstance.constructor === vnode.type) { + $dom.__mosaicInstance.props = props; + return patch($dom, $dom.__mosaicInstance.view(), $parent, $dom.__mosaicInstance); + } + else if(typeof vnode.type === 'object' && vnode.type.__isMosaic === true) { + const $ndom = Mosaic.view(vnode, $parent); + return $parent ? ($parent.replaceChild($ndom, $dom) && $ndom) : $ndom; + } + else if(typeof vnode.type !== 'object' || vnode.type.__isMosaic === false) { + return patch($dom, vnode.type.view(props), $parent, $dom.__mosaicInstance); + } +} +exports.h = createElement; exports.Mosaic = Mosaic; -exports.h = createElement; \ No newline at end of file +window.h = createElement; +window.Mosaic = Mosaic; \ No newline at end of file diff --git a/src/observable.js b/src/observable.js new file mode 100644 index 0000000..ba68b4f --- /dev/null +++ b/src/observable.js @@ -0,0 +1,36 @@ +/** Basically an object that can perform a certain function when a property changes. +* @param {Object} observingObject The object to look for changes in.. */ +const Observable = (observingObject, willChange, didChange) => { + const Handler = { + get(object, name, receiver) { + if(name === '__TARGET') { return Object.assign({}, observingObject); }; + if(name === '__IS_PROXY') { return true }; + + return Reflect.get(object, name, receiver); + }, + set(object, name, value) { + // About to update. + let old = Object.assign({}, observingObject); + if(willChange) willChange(old); + + // Make changes. + object[name] = value; + + // Did update. + if(didChange) didChange(object); + + return Reflect.set(object, name, value); + }, + defineProperty(object, name, descriptor) { + didChange(object); + return Reflect.defineProperty(object, name, descriptor); + }, + deleteProperty(object, name) { + didChange(object); + return Reflect.deleteProperty(object, name); + } + }; + return new Proxy(observingObject, Handler); +} + +export default Observable; \ No newline at end of file diff --git a/src/util.js b/src/util.js index 198df95..49228a8 100644 --- a/src/util.js +++ b/src/util.js @@ -1,43 +1,98 @@ -/** Generates a random id for a component. */ -const randomID = () => { - return '_' + Math.random().toString(36).substr(2, 9); +const setAttributes = function($element, key, value, instance = null) { + // 1.) Function handler for dom element. + if(typeof value === 'function' && key.startsWith('on')) { + const event = key.slice(2).toLowerCase(); + $element.__mosaicHandlers = $element.__mosaicHandlers || {}; + $element.removeEventListener(event, $element.__mosaicHandlers[event]); + + $element.__mosaicHandlers[event] = value; + $element.addEventListener(event, $element.__mosaicHandlers[event]); + } + // 2.) Particular types of attributes. + else if(key === 'checked' || key === 'value' || key === 'className') { + $element[key] = value; + } + // 3.) Style property. + else if(key === 'style') { + if(typeof value === 'object') Object.assign($element.style, value); + else if(typeof value === 'string') $element[key] = value; + } + // 4.) Check for the reference type. + else if(key === 'ref' && typeof value === 'function') { + value($element); + } + // 5.) Support the key property for more efficient rendering. + else if(key === 'key') { + $element.__mosaicKey = value; + } + // 6.) Value is a not an object nor a function, so anything else basically. + else if(typeof value !== 'object' && typeof value !== 'function') { + $element.setAttribute(key, value); + } } -// /** Traverses a Mosaic component tree and calls the "created" method on all of them in order -// * of when they show up in the app. So if you have Footer inside of Home inside of App, then you -// * will run created on Footer first, then on Home, then on App. -// * @param {Mosaic} start The entry point of the Mosaic app. */ -// const incrementallyCreateMosaics = (start) => { -// let children = Object.values(start.references); -// if(children.length === 1) { -// children[0].created(); -// } +/** Start with a particular VNode and traverse the entire tree and only return the ones that match +* the comparator. +* @param {Object} head The absolute parent VNode. +* @param {Object} start The starting VNode. +* @param {Array} array The final array to return. +* @param {Function} comparator The compare function. +* @param {Function} action The action to take when the comparator is true. */ +const traverseVDomTree = function(head, start, array, comparator, action) { + // console.log("DOM: ", start); + if(comparator(head, start, array) === true) { + if(action) action(head, start, array); + array.push(start); + } -// children.forEach(child => incrementallyCreateMosaics(child)); -// } + for(var i in start.children) { + traverseVDomTree(head, start.children[i], array, comparator, action); + } + return array; +} -/** Validates the child Mosaic components of a parent Mosaic component to make sure they -* all follow the same schema. -* @param {Object} components The components to check for. -* @returns {Boolean} Whether or not there are no errors in the types of the input components. */ -const validateMosaicChildren = (components) => { - if(!components) return true; - - let children = Object.values(components); - if(children.length === 0) return true; +/** Clones a function. */ +const cloneFunction = function() { + var that = this; + var f = function() { return that.apply(this, arguments); }; + for(var key in this) { + if (this.hasOwnProperty(key)) { + f[key] = this[key]; + } + } + return f; +}; - let foundOneWrong = children.find(comp => { - if(!('type' in comp)) { - return false; - } else if('type' in comp) { - if(!comp['type'].view) { - return true; +/** Does a deep clone of an object, also cloning its children. + * @param {Object} from The input object to copy from. + */ +const deepClone = function(from) { + let out = Object.create({}); + if(typeof from === 'function') { + return cloneFunction.call(from); + } + for(var i in from) { + if(from.hasOwnProperty(i)) { + if(typeof from[i] === 'object') { + // if(from[i].__IS_PROXY) { + // let ulo = Object.assign({}, from[i].__TARGET); + // let nProx = new Observable(ulo, () => from[i].willChange, () => from[i].didChange); + // out[i] = nProx; + // } else { + out[i] = Object.assign({}, deepClone(from[i])); + // } + } + else if(typeof from[i] === 'function') { + out[i] = from[i].bind(out); + } + else { + out[i] = from[i]; } } - }); - return foundOneWrong === undefined || foundOneWrong === null; + } + return out; } -exports.randomID = randomID; -// exports.incrementallyCreateMosaics = incrementallyCreateMosaics; -exports.validateMosaicChildren = validateMosaicChildren; \ No newline at end of file +exports.setAttributes = setAttributes; +exports.traverseVDomTree = traverseVDomTree; +exports.deepClone = deepClone; diff --git a/src/validations.js b/src/validations.js new file mode 100644 index 0000000..d6a74c9 --- /dev/null +++ b/src/validations.js @@ -0,0 +1,54 @@ +function isHTMLElement(obj) { + try { return obj instanceof HTMLElement; } + catch(e){ + return (typeof obj === "object") && (obj.nodeType === 1) && (typeof obj.style === "object") && + (typeof obj.ownerDocument ==="object"); + } +} + +/** Looks at a Mosaic's configuration options and returns undefined if there is nothing wrong, and + * returns a descriptor sentence if something is wrong. + * @param {MosaicOptions} options The config options. + * @returns {undefined} If there is nothing wrong with the input options. + * @returns {String} describing the problem. + */ +const findInvalidOptions = function(options) { + // Element + if(options.element && (!isHTMLElement(options.element) || !document.contains(options.element))) { + return `The Mosaic could not be created because the "element" property is either not an HTML DOM + element or it does not already exist in the DOM. Make sure that the "element" property is an already + existing DOM element such as "document.body" or a div with the id of 'root' for example.`; + } + + // Data + if(options.data && typeof options.data !== 'object') { + return `The data property of a Mosaic must be defined as a plain, JavaScript object.`; + } + + // View + if(!options.view) { + return `View is a required property of Mosaic components.` + } + if(typeof options.view !== 'function') { + return `The view property must be a function that returns JSX code or an h-tree.`; + } + + // Actions + if(options.actions && typeof options.actions !== 'object') { + return `Actions must be defined as an object, where each entry is a function.`; + } + + // Lifecycle + if((options.created && typeof options.created !== 'function') || + (options.willUpdate && typeof options.willUpdate !== 'function') || + (options.updated && typeof options.updated !== 'function') || + (options.willDestory && typeof options.willDestory !== 'function')) { + return `All lifecycle methods (created, willUpdate, updated, and willDestroy) must be + function types.`; + } + + return undefined; +} + +exports.isHTMLElement = isHTMLElement; +exports.findInvalidOptions = findInvalidOptions; \ No newline at end of file diff --git a/src/vdom/createElement.js b/src/vdom/createElement.js index bb3161f..f1f4ac7 100644 --- a/src/vdom/createElement.js +++ b/src/vdom/createElement.js @@ -1,7 +1,8 @@ -const createElement = (nodeName, properties = {}, ...children) => { - if(typeof nodeName === 'object' && nodeName.view) { - return nodeName.view(); - } - return { nodeName, properties: properties || {}, children }; -}; -exports.createElement = createElement; \ No newline at end of file +const createElement = function(type, props = {}, ...children) { + return { + type: type, + props: props || {}, + children, + }; +} +export default createElement; \ No newline at end of file diff --git a/src/vdom/diff.js b/src/vdom/diff.js deleted file mode 100644 index 47b7c13..0000000 --- a/src/vdom/diff.js +++ /dev/null @@ -1,190 +0,0 @@ -const { createElement } = require('./createElement'); -const { render, setDomAttributes, setEventHandlers, isEventProperty} = require('./render'); - -const zip = (xs, ys) => { - const zipped = []; - for(let i = 0; i < Math.min(xs.length, ys.length); i++) { - zipped.push([xs[i], ys[i]]); - } - return zipped; -} - -const diffProperties = (oldProps, newProps) => { - // The array of patches to perform. - const patches = []; - - // Go through the new properties and add on to the list of patch functions that will run later. - Object.keys(newProps).forEach(nPropName => { - let nPropVal = newProps[nPropName]; - let _patch = ($node) => { - if(isEventProperty(nPropName)) { - setEventHandlers($node, nPropName, nPropVal); - } else { - setDomAttributes($node, nPropName, nPropVal); - } - return $node; - } - patches.push(_patch); - }); - - // Go through the old properties and remove the ones that are no longer in the new properties. - for(var i in oldProps) { - if(!(i in newProps)) { - let _patch = ($node) => { - $node.removeAttribute(i); - return $node; - } - patches.push(_patch); - } - } - - // Create a patch that just runs all of the accumulated patches. - // Applies all of the patch operations. - let patch = ($node) => { - for(var i in patches) patches[i]($node); - return $node - } - return patch; -} - -const diffChildren = (oldVChildren, newVChildren) => { - const patches = []; - - // Go through the children and add the result of their diffing. - oldVChildren.forEach((oldVChild, index) => { - let result = diff(oldVChild, newVChildren[index]); - patches.push(result); - }); - - // Make additional patches for unequal children lengths of the old and new vNodes. - const additionalPatches = []; - const sliced = newVChildren.slice(oldVChildren.length); - for(var i = 0; i < sliced.length; i++) { - let s = sliced[i]; - let _patch = ($node) => { - let res = render(s); - $node.appendChild(res); - return $node; - } - additionalPatches.push(_patch); - } - - - let patch = ($parent) => { - for(const [p, $child] of zip(patches, $parent.childNodes)) { - p($child); - } - for(var i in additionalPatches) { - const p = additionalPatches[i]; - p($parent); - } - return $parent; - } - return patch; -} - -/** Calculates the difference between different virtual nodes and returns a function -* to patch them together. -* @param {Object} oldVNode The old virtual dom node. -* @param {Object} newVNode The new virtual dom node. */ -const diff = (oldVNode, newVNode) => { - // console.log(oldVNode, newVNode); - - // Case 1: The old virtual node does not exist. - if(newVNode === undefined) { - let patch = ($node) => { $node.remove(); return undefined }; - return patch; - } - - // Case 2: They are both strings, so compare them. - if(typeof oldVNode === 'string' && typeof newVNode === 'string') { - // Case 2.1: One is a text node and one is an element. - if(oldVNode !== newVNode) { - let patch = ($node) => { - const $newDomNode = render(newVNode); - $node.replaceWith($newDomNode); - return $newDomNode; - } - return patch; - } - // Case 2.2: Both virtual nodes are strings and they match. - else { - let patch = ($node) => { return $node; } - return patch; - } - } - - // Case 3: They are both numbers, so compare them. - if(typeof oldVNode === 'number' && typeof newVNode === 'number') { - // Case 3.1: One is a text node and one is an element. - if(oldVNode !== newVNode) { - let patch = ($node) => { - const $newDomNode = render(newVNode); - $node.replaceWith($newDomNode); - return $newDomNode; - } - return patch; - } - // Case 3.2: Both virtual nodes are strings and they match. - else { - let patch = ($node) => { return $node; } - return patch; - } - } - - // Case 4: They are both Mosaic components, so diff their views. - if(typeof oldVNode === 'object' && typeof newVNode === 'object' && (oldVNode.view || newVNode.view)) { - let patch = diff(oldVNode.view(), newVNode.view()); - return patch; - // let patch = ($node) => { - // const $newDomNode = render(newVNode.view()); - // $node.replaceWith($newDomNode); - // return $newDomNode; - // } - // return patch; - // let patch = ($node) => { return $node; }; - return patch; - } - - // Case 5: They are arrays of elements, so go through each one and diff the objects. - if(typeof oldVNode === 'object' && typeof newVNode === 'object' && (oldVNode.length || newVNode.length)) { - // Create a patch for each child. - let allPatches = []; - for(var i = 0; i < oldVNode.length; i++) { - let patch = diff(oldVNode[i], newVNode[i]); - allPatches.push(patch); - } - // Create a final patch that applies all patch changes in the list. - let finalPatch = ($node) => { - allPatches.forEach((p, index) => { - p($node.childNodes[index]); - }); - return $node; - } - return finalPatch; - } - - // Case 6: In order to make the diff algo more efficient, assume that if the trees - // are of different types then we just replace the entire thing. - if(oldVNode.nodeName !== newVNode.nodeName) { - let patch = ($node) => { - const $newDomNode = render(newVNode); - $node.replaceWith($newDomNode); - return $newDomNode; - } - return patch; - } - - // Case 7: If we reach this point, it means that the only differences exist in either the - // properties or the child nodes. Handle these cases separately and return a patch that just - // updates the node, not neccessarily replaces them. - const propsPatch = diffProperties(oldVNode.properties, newVNode.properties); - const childrenPatch = diffChildren(oldVNode.children, newVNode.children); - let finalPatch = ($node) => { - propsPatch($node); - childrenPatch($node); - return $node; - } - return finalPatch; -} -exports.diff = diff; \ No newline at end of file diff --git a/src/vdom/mount.js b/src/vdom/mount.js deleted file mode 100644 index 1f4a671..0000000 --- a/src/vdom/mount.js +++ /dev/null @@ -1,19 +0,0 @@ -/** Actually places a DOM onto the page by replacing an old version with a new version. -* @param {Element} $newNode The node that you want to show on the page. -* @param {Element} $realNode The real node that will be replaced. -*/ -const mount = ($newNode, $realNode) => { - // This "works" by making the new node sit inside of root instead of replacing root. - // however, there is a problem where you update different component states and every now and - // then, if the updates comes at different times, we end up with child-child elements. - if($realNode.firstChild) { - $realNode.firstChild.replaceWith($newNode); - } else { - $realNode.appendChild($newNode); - } - - // This works. - // $realNode.replaceWith($newNode); - return $newNode; -} -exports.mount = mount; \ No newline at end of file diff --git a/src/vdom/patch.js b/src/vdom/patch.js new file mode 100644 index 0000000..88a49ee --- /dev/null +++ b/src/vdom/patch.js @@ -0,0 +1,62 @@ +import { Mosaic } from '../index'; +import render from './render'; +import { setAttributes } from '../util'; + +const patch = function($dom, vnode, $parent = $dom.parentNode, instance = null) { + const replace = $parent ? ($el => { $parent.replaceChild($el, $dom); return $el }) : ($el => $el); + + // 1.) Patch the differences of a Mosaic type. + if(typeof vnode === 'object' && typeof vnode.type === 'object' && vnode.type.__isMosaic === true) { + return Mosaic.patch($dom, vnode, $parent); + } + // 2.) Compare plain text nodes. + else if(typeof vnode !== 'object' && $dom instanceof Text) { + return ($dom.textContent !== vnode) ? replace(render(vnode, $parent, instance)) : $dom; + } + // 3.) If one is an object and one is text, just replace completely. + else if(typeof vnode === 'object' && $dom instanceof Text) { + return replace(render(vnode, $parent, instance)); + } + // 4.) One is an object and the tags are different, so replace completely. + else if(typeof vnode === 'object' && (vnode.type && !vnode.type.__isMosaic) && $dom.nodeName !== vnode.type.toUpperCase()) { + let n = replace(render(vnode, $parent, instance)); + return n; + } + // 5.) If they are objects and their tags are equal, patch their children recursively. + else if(typeof vnode === 'object' && $dom.nodeName === vnode.type.toUpperCase()) { + const pool = {}; + const active = document.activeElement; + + [].concat(...$dom.childNodes).map((child, index) => { + const key = child.__mosaicKey || `__index_${index}`; + pool[key] = child; + }); + [].concat(...vnode.children).map((child, index) => { + const key = child.props && child.props.key || `__index_${index}`; + var $node; + + if(pool[key]) $node = patch(pool[key], child) + else $node = render(child, $dom, instance); + + $dom.appendChild($node); + delete pool[key]; + }); + + // Unmount the component and call the lifecycle function. + for(const key in pool) { + const instance = pool[key].__mosaicInstance; + if(instance && instance.willDestroy) instance.willDestroy(); + pool[key].remove(); + } + + // Remove and reset the necessary attributes. + for(var attr in $dom.attributes) $dom.removeAttribute(attr.name); + for(var prop in vnode.props) setAttributes($dom, prop, vnode.props[prop], vnode); + active.focus(); + + // Return the real dom node. + return $dom; + } +} + +export default patch; \ No newline at end of file diff --git a/src/vdom/render.js b/src/vdom/render.js index 987e923..4e9013f 100644 --- a/src/vdom/render.js +++ b/src/vdom/render.js @@ -1,113 +1,29 @@ -const { createElement } = require('./createElement'); +import { Mosaic } from '../index'; +import { setAttributes } from '../util'; -/** Checks whether or not the property is an event handler. */ -const isEventProperty = (name) => { - return /^on/.test(name); -} - -/** Handles setting attributse on a real DOM element when it might be an object, etc. -* @param {Element} $element The dom element to set attributes on. -* @param {String} propName The name of the attribute. -* @param {String | Number | Object} propVal The value of the attribute. */ -const setDomAttributes = ($element, propName, propVal) => { - if(propName === 'style') { - let styleString = ""; - - if(typeof propVal === 'object') { - Object.keys(propVal).forEach(styleName => { - const hypenated = styleName.replace( /([a-z])([A-Z])/g, '$1-$2' ).toLowerCase(); - styleString += `${hypenated}:${propVal[styleName]};`; - }); - } else if(typeof propVal === 'string') { - styleString = propVal; - } - - $element.setAttribute(propName, styleString); - } else { - $element.setAttribute(propName, propVal); - } -} - - -/** Handles adding event handlers to a real DOM element. */ -const setEventHandlers = ($element, eventName, eventValue) => { - const name = eventName.slice(2).toLowerCase(); - $element.addEventListener(name, eventValue); -} - -/** Renders a component as a VNode. */ -const renderMosaicComponent = (component) => { - // Get the view. - const val = createElement(component); - - // Create the actual dom element. - const $element = document.createElement(val.nodeName); - - // Add all of the properties to this element. - Object.keys(val.properties).forEach(propName => { - if(isEventProperty(propName)) { - setEventHandlers($element, propName, vNode.properties[propName]); - } else { - setDomAttributes($element, propName, val.properties[propName]); - } - }); - - // Append all of the children to this element. - Object.keys(val.children).forEach(childIndex => { - const x = render(val.children[childIndex]); - $element.appendChild(x); - }); - - return $element; -} - -/** Renders any regular dom element that is not a text node. */ -const renderRegularNode = (vNode) => { - // Create the actual dom element. - const $element = document.createElement(vNode.nodeName); - - // Add all of the properties to this element. - Object.keys(vNode.properties).forEach(propName => { - if(isEventProperty(propName)) { - setEventHandlers($element, propName, vNode.properties[propName]); - } else { - setDomAttributes($element, propName, vNode.properties[propName]); - } - }); - - // Append all of the children to this element. - Object.keys(vNode.children).forEach(childIndex => { - const x = render(vNode.children[childIndex]); - if(typeof x !== 'undefined') $element.appendChild(x); - }); - - return $element; -} +const render = function(vnode, $parent = null, instance = null) { + const mount = $parent ? ($el => $parent.appendChild($el)) : ($el => $el); -/** Takes a virtual dom node and returns a real dom node. -* @param {Object} vNode A virtual dom node. -* @returns {Element} A real dom node. */ -const render = (vNode) => { - if(typeof vNode === 'string' || typeof vNode === 'number') { - return document.createTextNode(vNode); + // 1.) Primitive types. + if(typeof vnode === 'string' || typeof vnode === 'number') { + let $e = document.createTextNode(typeof vnode === 'boolean' ? '' : vnode); + return mount($e); } - else if(typeof vNode === 'object' && vNode.view) { - return renderMosaicComponent(vNode); + // 2.) A Mosaic component. + else if(typeof vnode === 'object' && typeof vnode.type === 'object' && vnode.type.__isMosaic === true) { + return Mosaic.view(vnode, $parent); } - else if(typeof vNode === 'object' && vNode.length) { - // Basically create a holder element and create elements for each child. - let $holder = document.createElement('div'); - for(var i = 0; i < vNode.length; i++) { - let $node = render(vNode[i]); - $holder.appendChild($node); - } - return $holder; + // 3.) Handle child components and attributes. + else if(typeof vnode === 'object' && typeof vnode.type === 'string') { + const $e = document.createElement(vnode.type); + const $dom = mount($e); + for(var child of [].concat(...vnode.children)) render(child, $dom, instance); + for(var prop in vnode.props) setAttributes($dom, prop, vnode.props[prop], instance); + return $dom; } + // 4.) Otherwise, throw an error. else { - return renderRegularNode(vNode); + throw new Error(`Invalid Virtual DOM Node: ${vnode}.`); } } -exports.render = render; -exports.setDomAttributes = setDomAttributes; -exports.setEventHandlers = setEventHandlers; -exports.isEventProperty = isEventProperty; \ No newline at end of file +export default render; \ No newline at end of file