From c1c57ac0a102882fe597524aa15c8bb35ef95063 Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Mon, 28 Dec 2020 17:13:32 +0100 Subject: [PATCH] POC ui module --- src/editor/index.js | 1 + src/editor/model/Editor.js | 1 + src/ui/elements/Base.js | 103 ++++++++++++++ src/ui/elements/InputField.js | 26 ++++ src/ui/elements/vdom.js | 259 ++++++++++++++++++++++++++++++++++ src/ui/index.js | 52 +++++++ 6 files changed, 442 insertions(+) create mode 100644 src/ui/elements/Base.js create mode 100644 src/ui/elements/InputField.js create mode 100644 src/ui/elements/vdom.js create mode 100644 src/ui/index.js diff --git a/src/editor/index.js b/src/editor/index.js index 55def9ae0..77fdf62da 100644 --- a/src/editor/index.js +++ b/src/editor/index.js @@ -144,6 +144,7 @@ export default (config = {}) => { [ 'I18n', + 'UI', 'Utils', 'Config', 'Commands', diff --git a/src/editor/model/Editor.js b/src/editor/model/Editor.js index ffdda1a0a..13ae394ed 100644 --- a/src/editor/model/Editor.js +++ b/src/editor/model/Editor.js @@ -16,6 +16,7 @@ Backbone.$ = $; const deps = [ require('utils'), require('i18n'), + require('ui'), require('keymaps'), require('undo_manager'), require('storage_manager'), diff --git a/src/ui/elements/Base.js b/src/ui/elements/Base.js new file mode 100644 index 000000000..6cff2d39e --- /dev/null +++ b/src/ui/elements/Base.js @@ -0,0 +1,103 @@ +import { View, Model } from 'backbone'; +import { isString, flatten } from 'underscore'; +import { h, text, patch } from './vdom'; + +export default View.extend({ + __h: h, + __text: text, + __patch: patch, + render: 0, + + initialize({ tag, props = {}, children = [] }) { + console.log('INIT', { tag, props, children }); + this.model = new Model({ + ...(this.props || {}), + ...props + }); + this.tag = tag; + this.children = flatten(children, 1); + this.listenTo(this.model, 'change', this.__create); + this.init && this.init(); + }, + + __rndrProps() { + return { + props: this.get(), + set: p => this.set(p) + }; + }, + + __createVDom() { + const rendered = + this.render && this.render(this.__rndrProps()).__createVDom(); + this._vdom = + rendered || + this.__h( + this.tag, + this.get(), + this.children.map(chl => + isString(chl) ? this.__text(chl) : chl.__createVDom() + ) + ); + return this._vdom; + }, + + __getRoot() { + const { el, _el, _vdom } = this; + const dom = _vdom && _vdom.dom; + + if (!dom && !_el && !el.firstChild) { + el.appendChild(document.createElement('div')); + } + + return dom || _el || el.firstChild; + }, + + __create(model) { + const prev = model.previousAttributes(); + this.__chn = { + ...(this.__chn || {}), + ...model.changed + }; + this.__prev = { + ...Object.keys(this.__chn).reduce((o, i) => ((o[i] = prev[i]), o), {}), + ...(this.__prev || {}) + }; + this.__tmo && clearTimeout(this.__tmo); + this.__tmo = setTimeout(() => { + const { __chn, __prev } = this; + this.trigger('change', __chn, __prev); + Object.keys(__chn).forEach(i => + this.trigger(`change:${i}`, __chn[i], __prev[i]) + ); + this.__chn = {}; + this.__prev = {}; + this.create(); + }); + }, + + get(prop) { + const attrs = this.model.attributes; + return prop ? attrs[prop] : attrs; + }, + + set(props) { + return this.model.set(props); + }, + + create() { + const root = this.__getRoot(); + console.log('CREATE prepatch for', this.tag, { + _el: this._el, + _dom: this._vdom && this._vdom.dom + }); + const vdom = this.__createVDom(); + this.__patch(root, vdom); + this._el = vdom.dom; + console.log('CREATE Patch', { + innerHTML: this.__getRoot().outerHTML, + vdomDOM: vdom.dom + }); + return this.__getRoot(); + } +}); diff --git a/src/ui/elements/InputField.js b/src/ui/elements/InputField.js new file mode 100644 index 000000000..12223b05d --- /dev/null +++ b/src/ui/elements/InputField.js @@ -0,0 +1,26 @@ +export default ({ el }) => ({ + props: { + myLabel: 'Hello', + value: '', + changed: 0 + }, + + render({ props, set }) { + return el('div', { class: 'input-field' }, [ + el( + 'label', + { class: 'my-label' }, + `My label: ${props.myLabel} Value: ${props.value} changed: ${props.changed} rnd: ${props.rnd}` + ), + el('input', { + class: 'my-input', + value: props.value, + oninput: ev => { + set({ value: ev.target.value }); + set({ rnd: Math.random() }); + }, + onchange: () => set({ changed: props.changed + 1 }) + }) + ]); + } +}); diff --git a/src/ui/elements/vdom.js b/src/ui/elements/vdom.js new file mode 100644 index 000000000..d8b6514a2 --- /dev/null +++ b/src/ui/elements/vdom.js @@ -0,0 +1,259 @@ +var SSR_NODE = 1; +var TEXT_NODE = 3; +var EMPTY_OBJ = {}; +var EMPTY_ARR = []; +var SVG_NS = 'http://www.w3.org/2000/svg'; + +var getKey = vdom => (vdom == null ? vdom : vdom.key); + +var listener = function(event) { + this.tag[event.type](event); +}; + +var patchProp = (dom, key, oldVal, newVal, isSvg) => { + if (key === 'key') { + } else if (key[0] === 'o' && key[1] === 'n') { + if (!((dom.tag || (dom.tag = {}))[(key = key.slice(2))] = newVal)) { + dom.removeEventListener(key, listener); + } else if (!oldVal) { + dom.addEventListener(key, listener); + } + } else if (!isSvg && key !== 'list' && key !== 'form' && key in dom) { + dom[key] = newVal == null ? '' : newVal; + } else if (newVal == null || newVal === false) { + dom.removeAttribute(key); + } else { + dom.setAttribute(key, newVal); + } +}; + +var createNode = (vdom, isSvg) => { + var props = vdom.props; + var dom = + vdom.tag === TEXT_NODE + ? document.createTextNode(vdom.type) + : (isSvg = isSvg || vdom.type === 'svg') + ? document.createElementNS(SVG_NS, vdom.type, { is: props.is }) + : document.createElement(vdom.type, { is: props.is }); + + for (var k in props) patchProp(dom, k, null, props[k], isSvg); + + vdom.children.map(kid => + dom.appendChild(createNode((kid = vdomify(kid)), isSvg)) + ); + + return (vdom.dom = dom); +}; + +var patchDom = (parent, dom, oldVdom, newVdom, isSvg) => { + if (oldVdom === newVdom) { + } else if ( + oldVdom != null && + oldVdom.tag === TEXT_NODE && + newVdom.tag === TEXT_NODE + ) { + if (oldVdom.type !== newVdom.type) dom.nodeValue = newVdom.type; + } else if (oldVdom == null || oldVdom.type !== newVdom.type) { + dom = parent.insertBefore( + createNode((newVdom = vdomify(newVdom)), isSvg), + dom + ); + if (oldVdom != null) { + parent.removeChild(oldVdom.dom); + } + } else { + var tmpVKid, + oldVKid, + oldKey, + newKey, + oldProps = oldVdom.props, + newProps = newVdom.props, + oldVKids = oldVdom.children, + newVKids = newVdom.children, + oldHead = 0, + newHead = 0, + oldTail = oldVKids.length - 1, + newTail = newVKids.length - 1; + + isSvg = isSvg || newVdom.type === 'svg'; + + for (var i in { ...oldProps, ...newProps }) { + if ( + (i === 'value' || i === 'selected' || i === 'checked' + ? dom[i] + : oldProps[i]) !== newProps[i] + ) { + patchProp(dom, i, oldProps[i], newProps[i], isSvg); + } + } + + while (newHead <= newTail && oldHead <= oldTail) { + if ( + (oldKey = getKey(oldVKids[oldHead])) == null || + oldKey !== getKey(newVKids[newHead]) + ) { + break; + } + + patchDom( + dom, + oldVKids[oldHead].dom, + oldVKids[oldHead++], + (newVKids[newHead] = vdomify(newVKids[newHead++])), + isSvg + ); + } + + while (newHead <= newTail && oldHead <= oldTail) { + if ( + (oldKey = getKey(oldVKids[oldTail])) == null || + oldKey !== getKey(newVKids[newTail]) + ) { + break; + } + + patchDom( + dom, + oldVKids[oldTail].dom, + oldVKids[oldTail--], + (newVKids[newTail] = vdomify(newVKids[newTail--])), + isSvg + ); + } + + if (oldHead > oldTail) { + while (newHead <= newTail) { + dom.insertBefore( + createNode((newVKids[newHead] = vdomify(newVKids[newHead++])), isSvg), + (oldVKid = oldVKids[oldHead]) && oldVKid.dom + ); + } + } else if (newHead > newTail) { + while (oldHead <= oldTail) { + dom.removeChild(oldVKids[oldHead++].dom); + } + } else { + for (var keyed = {}, newKeyed = {}, i = oldHead; i <= oldTail; i++) { + if ((oldKey = oldVKids[i].key) != null) { + keyed[oldKey] = oldVKids[i]; + } + } + + while (newHead <= newTail) { + oldKey = getKey((oldVKid = oldVKids[oldHead])); + newKey = getKey((newVKids[newHead] = vdomify(newVKids[newHead]))); + + if ( + newKeyed[oldKey] || + (newKey != null && newKey === getKey(oldVKids[oldHead + 1])) + ) { + if (oldKey == null) { + dom.removeChild(oldVKid.dom); + } + oldHead++; + continue; + } + + if (newKey == null || oldVdom.tag === SSR_NODE) { + if (oldKey == null) { + patchDom( + dom, + oldVKid && oldVKid.dom, + oldVKid, + newVKids[newHead], + isSvg + ); + newHead++; + } + oldHead++; + } else { + if (oldKey === newKey) { + patchDom(dom, oldVKid.dom, oldVKid, newVKids[newHead], isSvg); + newKeyed[newKey] = true; + oldHead++; + } else { + if ((tmpVKid = keyed[newKey]) != null) { + patchDom( + dom, + dom.insertBefore(tmpVKid.dom, oldVKid && oldVKid.dom), + tmpVKid, + newVKids[newHead], + isSvg + ); + newKeyed[newKey] = true; + } else { + patchDom( + dom, + oldVKid && oldVKid.dom, + null, + newVKids[newHead], + isSvg + ); + } + } + newHead++; + } + } + + while (oldHead <= oldTail) { + if (getKey((oldVKid = oldVKids[oldHead++])) == null) { + dom.removeChild(oldVKid.dom); + } + } + + for (var i in keyed) { + if (newKeyed[i] == null) { + dom.removeChild(keyed[i].dom); + } + } + } + } + + return (newVdom.dom = dom); +}; + +var vdomify = vdom => + vdom !== true && vdom !== false && vdom ? vdom : text(''); + +var recycleNode = dom => + dom.nodeType === TEXT_NODE + ? text(dom.nodeValue, dom) + : createVdom( + dom.nodeName.toLowerCase(), + EMPTY_OBJ, + EMPTY_ARR.map.call(dom.childNodes, recycleNode), + dom, + null, + SSR_NODE + ); + +var createVdom = (type, props, children, dom, key, tag) => ({ + type, + props, + children, + dom, + key, + tag +}); + +export var text = (value, dom) => + createVdom(value, EMPTY_OBJ, EMPTY_ARR, dom, null, TEXT_NODE); + +export var h = (type, props, ch) => + createVdom( + type, + props, + Array.isArray(ch) ? ch : ch == null ? EMPTY_ARR : [ch], + null, + props.key + ); + +export var patch = (dom, vdom) => ( + ((dom = patchDom( + dom.parentNode, + dom, + dom.v || recycleNode(dom), + vdom + )).v = vdom), + dom +); diff --git a/src/ui/index.js b/src/ui/index.js new file mode 100644 index 000000000..d95d78f2a --- /dev/null +++ b/src/ui/index.js @@ -0,0 +1,52 @@ +import { bindAll } from 'underscore'; +import Base from './elements/Base'; +import InputField from './elements/InputField'; + +const buildIn = { + InputField +}; + +export default () => ({ + name: 'UI', + + init(opts = {}) { + this.els = {}; + this.em = opts.em; + bindAll(this, 'el'); + Object.keys(buildIn).forEach(name => { + this.add(name, buildIn[name]({ el: this.el })); + }); + return this; + }, + + destroy() { + ['em', 'els'].forEach(i => (this[i] = {})); + }, + + add(name, definition) { + this.els[name] = Base.extend(definition); + }, + + get(name) { + return this.els[name]; + }, + + el(tag, props, ...children) { + const Element = this.get(tag) || Base; + return new Element({ tag, props, children }); + } +}); + +/* +let { el } = editor.UI; +let appEl = document.getElementById('app'); +let inpEl = el('InputField', { myLabel: 'Hello2' }); +inpEl.on('change', (next, prev) => console.log('Changed InputField', { next, prev })) +inpEl.on('change:value', (next, prev) => console.log('Changed InputField value', { next, prev })) +let mainEl = el('main', {}, [ + el('h1', {}, 'My text h1'), + el('p', {}, 'My text p'), + inpEl, +]) +appEl.appendChild(mainEl.create()) +*/