Prop-decorators and supporting DOMElements as VNodes in the renderer

This commit is contained in:
Miel Truyen 2019-11-29 23:06:05 +01:00
parent 698656c8f6
commit e4eef2cc1a
20 changed files with 332 additions and 6663 deletions

1
.idea/csx.iml generated
View File

@ -3,6 +3,7 @@
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/public" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>

View File

@ -17,7 +17,7 @@
"jsdoc": "latest",
"sass": "latest",
"rollup": "latest",
"rollup-plugin-babel": "latest",
"rollup-plugin-babel": "csx",
"rollup-plugin-node-resolve": "latest",
"rollup-plugin-commonjs": "latest",
"rollup-plugin-terser": "latest",
@ -37,8 +37,5 @@
"build:csx": "cd packages/csx && npm run build",
"watch:babel-transform-csx": "cd packages/babel-plugin-transform-csx && npm run watch",
"watch:csx": "cd packages/csx && npm run watch-es6"
},
"resolutions": {
"@babel/helpers": "file:./packages/babel-helpers"
}
}

View File

@ -36,7 +36,7 @@
"scripts": {
"build": "rollup -c",
"watch": "rollup -c -w",
"npm-publish": "npm run build && npm publish --registry https://npm.cerxes.net"
"npm-publish": "npm run build && npm publish --registry https://npm.cerxes.net --tag latest"
},
"module": "./src/index.js",
"main": "./dist/index.js"

View File

@ -35,7 +35,7 @@
"watch-cjs": "rollup -c -w",
"build-es6": "npx babel ./src --out-dir=lib",
"watch-es6": "npx babel ./src --out-dir=lib -w",
"npm-publish": "npm run build && npm publish --registry https://npm.cerxes.net"
"npm-publish": "npm run build && npm publish --registry https://npm.cerxes.net --tag latest"
},
"module": "./lib/index.js",
"main": "./dist/index.js"

View File

@ -1,62 +1,8 @@
import {render} from "../vdom";
/** Helper class to mark an initializer-value to be used on first get of a value**/
class InitializerValue{ constructor(value){ this.value = value; } }
/**
* The decorators proposal has changed since @babel implemented it. This code will need to change at some point...
*/
export function State() {
return function decorator(target, key, descriptor){
let {get: oldGet, set: oldSet} = descriptor;
let valueKey='__'+key;
// Rewrite the property as if using getters and setters (if needed)
descriptor.get = oldGet = oldGet || function(){
let val = this[valueKey];
if(val instanceof InitializerValue){
this[valueKey] = val = val.value.call(this);
}
return val;
};
oldSet = oldSet || function(newVal){
this[valueKey]=newVal;
return newVal;
};
// Overwrite the setter to call markDirty whenever it is used
descriptor.set = function(newValue){
let result = oldSet.call(this, newValue);
this.markDirty && this.markDirty();
return result;
};
// Update the descriptor to match with using getters and setters
target.kind = 'method'; // update to get and set if need be..
delete descriptor.writable;
// Catch usage of initial value or initalizers
if(descriptor.value){
Object.defineProperty(target, valueKey, {
writable: true,
value: descriptor.value
});
delete descriptor.value;
}else if(descriptor.initializer){
Object.defineProperty(target, valueKey, {
writable: true,
value: new InitializerValue(descriptor.initializer)
});
delete descriptor.initializer;
}
return descriptor;
}
}
// TODO a proper Prop-decorator
export {State as Prop};
// TODO the custom-element class could be removed, and its functionality implemented through the @defineElement decorator
// Currently there are issues: if a custom-element reimplements the connectedCallback, they need to magically know that
// they should run the super.connectCallback(). to make sure it actually gets rendered on mounting to the DOM...
/**
* This CustomElement class is to avoid having to do an ugly workaround in every custom-element:

View File

@ -1,2 +1,4 @@
export * from './define-element';
export * from './custom-element';
export * from './custom-element';
export * from "./prop";
export * from "./state";

View File

@ -0,0 +1,62 @@
import { trackValue } from "../decorator-utils/track-value";
/**
* Decorate a variable as a property of the custom-element.
* @param {Object} opts
* @param {boolean} opts.attr - Update the property when an attribute with specified name is set. Defaults to the property-name
* @param {boolean} opts.reflect - Reflect the property back to attributes when the property is set
*/
export function Prop(opts) {
opts = opts || {};
return function decorator(target, key, descriptor) {
// Dev-note: Tis is run for every instance made of the decorated class ...
// console.log("Prop " + key, target);
// TODO could check the prototype if a markDirty function exists, throw an error if it doesn't
// Register this prop on the class, so the VDOM-renderer can find this and set the prop instead of the attribute (if @meta is every introduced as a built-in decorator, this would be the place to use it)
if (!target.constructor.props) {
target.constructor.props = new Set();
}
target.constructor.props.add(key);
let attrName = opts.attr !== false && typeof (opts.attr) === "string" ? opts.attr : key;
// Prop behavior
let trackedDecorator = trackValue({ target, key, descriptor }, function (value) {
//console.log(`Prop ${key} was set: ` + JSON.stringify(value));
if (opts.reflect) {
if ((value ?? false) === false) {
this.removeAttribute(attrName);
} else if (value === true) {
this.setAttribute(attrName, "");
} else {
this.setAttribute(attrName, value);
}
}
this.markDirty && this.markDirty();
});
// Registering as attr and subscribing to relevant callbacks
if (opts.attr !== false || opts.attr === undefined) {
let oldCallback = target.attributeChangedCallback;
target.attributeChangedCallback = function (...args) {
let [name, oldValue, newValue] = args;
if (name === attrName) {
// This is our tracked prop, pipe the attribute-value the the property-setter
//console.log(`Attribute ${attrName} was set: ` + JSON.stringify(newValue));
trackedDecorator.set.call(this, newValue);
}
if (oldCallback) oldCallback.call(this, ...args);
};
let observedAttrs = Array.from(new Set([attrName, ...(target.constructor.observedAttributes || [])]));
Object.defineProperty(target.constructor, 'observedAttributes', {
get: () => observedAttrs,
configurable: true
});
}
return trackedDecorator
}
}

View File

@ -0,0 +1,13 @@
import {trackValue} from "../decorator-utils/track-value";
/**
* Decorate a variable as part of the component-state, and will thus trigger a re-render whenever it is changed
*/
export function State() {
return function decorator(target, key, descriptor){
// Dev-note: Tis is run for every instance made of the decorated class ...
// console.log("State " + key, target);
return trackValue({target, key, descriptor}, function(value){ this.markDirty && this.markDirty() });
}
}

View File

@ -0,0 +1,72 @@
/** Helper class to mark an initializer-value to be used on first get of a value**/
class InitializerValue{ constructor(value){ this.value = value; } }
/**
* This callback type is called `requestCallback` and is displayed as a global symbol.
*
* @callback trackValueCallback
* @param {any} value
* @param {PropertyKey} prop
*/
/**
* This is an implementation of https://github.com/tc39/proposal-decorators/blob/master/NEXTBUILTINS.md#tracked
* to use until support for the new decorators proposal lands in @babel
*
* @param {Object} decoratorArgs
* @param {any} decoratorArgs.target
* @param {PropertyKey} decoratorArgs.key
* @param {PropertyDescriptor} decoratorArgs.descriptor
* @param {trackValueCallback} cb
* @return {PropertyDescriptor}
*/
export function trackValue({target, key, descriptor}, cb){
let {get: oldGet, set: oldSet} = descriptor;
let valueKey='__'+key;
if(!cb) throw Error("No callback given to track property. No point in decorating the prop");
// TODO the story here of handling an initializer value is a bit dirty... Ideally, we'd want to run the value initializer before the class-constructor is called..
// Rewrite the property as if using getters and setters (if needed)
descriptor.get = oldGet = oldGet || function(){
let val = this[valueKey];
if(val instanceof InitializerValue){
this[valueKey] = val = val.value.call(this);
}
return val;
};
oldSet = oldSet || function(newVal){
this[valueKey]=newVal;
return newVal;
};
// Overwrite the setter to call markDirty whenever it is used
descriptor.set = function(newValue){
let oldvalue = oldGet.call(this);
let result = oldSet.call(this, newValue);
if(oldvalue!==newValue) cb.call(this,newValue, key);
return result;
};
// Update the descriptor to match with using getters and setters
target.kind = 'method'; // update to get and set if need be..
delete descriptor.writable;
// Catch usage of initial-value or value-initalizers and handle'em
if(descriptor.value){
Object.defineProperty(target, valueKey, {
writable: true,
value: descriptor.value
});
delete descriptor.value;
}else if(descriptor.initializer){
Object.defineProperty(target, valueKey, {
writable: true,
value: new InitializerValue(descriptor.initializer)
});
delete descriptor.initializer;
}
return descriptor;
}

View File

@ -3,17 +3,18 @@ import {
HostNodeRenderer, Host,
ShadowNodeRenderer, ShadowDOM,
PrimitiveRenderer, Primitive,
NodeTreeRenderer
NodeTreeRenderer, NativeRenderer
} from "./renderers";
// TODO Element renderer (for things that are already DOM-elements)
export function getNodeMeta(vnode){
if(vnode===undefined||vnode===null) return undefined; // Indicate it shouldn't render
if(vnode instanceof Element) return {renderer: NativeRenderer, normedType: Element};
let type = vnode?.type;
if(!type) return {renderer: PrimitiveRenderer, normedType: Primitive};
else if(type===Host) return {renderer: HostNodeRenderer, normedType: Host};
else if(type===ShadowDOM) return {renderer: ShadowNodeRenderer, normedType: ShadowDOM};
else return {renderer: NodeTreeRenderer, normedType: type};
else return {renderer: NodeTreeRenderer, normedType: window.customElements?.get(type)??type};
}
@ -70,7 +71,7 @@ export function render(vnode, opts = {}) {
// Create the element if no matching existing element was set
let newlyCreated = false;
if (!item.host) {
item.host = renderer.create(item);
item.host = renderer.create(item, meta);
newlyCreated = true;
if(item.vnode?.props?.ref){// If props specify a ref-function, queue it to be called at the end of the render
@ -79,10 +80,10 @@ export function render(vnode, opts = {}) {
}
// Update the element
renderer.update(item);
renderer.update(item, meta);
// Update children
if(item.vnode?.children || item.old?.children) {
if(meta.normedType!==Element && (item.vnode?.children || item.old?.children)) {
let childTypes = new Set();
// Flatten and organize new vNode-children (this could be a separate function, or implemented using a helper function (because mucht of the code is similar between old/new vnodes)

View File

@ -1,4 +1,5 @@
export * from "./hostnode";
export * from "./nodetree";
export * from "./nodeprimitive";
export * from "./shadownode";
export * from "./shadownode";
export * from "./nativeelement"

View File

@ -0,0 +1,23 @@
import '../types';
/**
* Takes care of rendering a Native DOM-Element
*
* @class
* @implements {VNodeRenderer}
*/
export const NativeRenderer = {
/**
* @param {VRenderItem} item
*/
create(item){
return item.vnode;
},
/**
* @param {VRenderItem} item
*/
update(item){
return;// NO-OP
}
};

View File

@ -12,6 +12,11 @@ const VNODEPROP_IGNORE = {
['key']: true,
['ref']: true
};
const VNODE_SPECIAL_PROPS = {
['key']: false,
['ref']: false,
// TODO: className, (style), event/events, (see react-docs)
}
let namespace = {
svg: "http://www.w3.org/2000/svg"
@ -48,8 +53,10 @@ export const NodeTreeRenderer = {
/**
* @param {VRenderItem} item
*/
update(item){
update(item, meta){
let vnode = item.vnode;
let vtype = meta?.normedType||item.vnode?.type;
/**
* @type {VNodeProps}
*/
@ -82,10 +89,11 @@ export const NodeTreeRenderer = {
// Now apply each
for(let [key, newVal, oldVal] of propDiffs){
if(VNODEPROP_IGNORE[key]){
// Prop to be ignored (like 'key')
}else if(VNODEPROP_DIRECT[key]){
// Direct-value prop only (e.g. checked attribute of checkbox will reflect in attributes automatically, no need to set the attribute..)
let special = VNODE_SPECIAL_PROPS[key];
if(special === false){
// Ignore the prop
}else if(vtype.props?.has && vtype.props.has(key)){
// Registered prop of the Custom-Element
host[key] = newVal;
}else if(key.slice(0,2)==='on' && key[2]>='A' && key[2]<='Z'){
// Event-prop
@ -96,13 +104,8 @@ export const NodeTreeRenderer = {
}else{
host.addEventListener(eventName, newVal);
}
// TODO might want to support objects for defining events, so we can specifiy passive or not, and other event options
}else{
if(!VNODEPROP_EXCLUDE_DIRECT[key] && !item.inSvg){
// TODO there are many properties we do not want to be setting directly.. (transform attr on svg's is a good example...)
// Unless otherwise excluded, set the prop directly on the Element as well (because this is what we'd typically want to do passing complex objects into custom-elements)
host[key] = newVal;
}
// Assumed to be just an attribute
if(newVal===undefined || newVal === false || newVal===null || newVal===''){
host.removeAttribute(key);
}else if(newVal === true){

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,47 @@
import {render} from "../../packages/csx";
import style from "./index.scss";
import {SvgLoader} from "./svg-loader";
import {SvgTester} from "./svg-tester";
import {SvgTesterTwo} from "./svg-tester-two";
let loader = render(<SvgLoader inverted="yes"/>);
document.body.appendChild(render(<style>{style}</style>));
document.body.appendChild(render(
<div class="center-me">
<h3>SVG Loader</h3>
<SvgLoader />
{loader}
<h3>SVG Tester</h3>
<SvgTester/>
<h3>SVG Tester Two</h3>
<SvgTesterTwo/>
</div>
));
));
setTimeout(()=>{
console.log("Uninverting");
loader.removeAttribute("inverted");
setTimeout(()=>{
console.log("Inverting");
loader.setAttribute("inverted", "ja");
setTimeout(()=>{
console.log("Stays inverted");
loader.setAttribute("inverted", "");
setTimeout(()=>{
console.log("Inverted color");
loader.setAttribute("inverted-color", "#0F0");
}, 1000);
}, 1000);
}, 1000);
}, 1000);

View File

@ -1,4 +1,4 @@
import {CustomElement, defineElement, Host, ShadowDOM, State} from "../../packages/csx";
import {CustomElement, defineElement, Host, ShadowDOM, State, Prop} from "../../packages/csx";
import loaderComponentShadowStyle from './svg-loader.shadow.scss';
// TODO configurability, like inverted and not with props...
@ -13,6 +13,8 @@ export class SvgLoader extends CustomElement{
// Private properties
// Properties
@Prop({reflect: true}) inverted;
@Prop({reflect: true, attr: "inverted-color"}) invertedColor = "#000";
// Handlers
@ -27,7 +29,7 @@ export class SvgLoader extends CustomElement{
<div class="loader-content">
<div class="spinner">
<svg width="38" height="38" viewBox="0 0 38 38" xmlns="http://www.w3.org/2000/svg" stroke="#000">
<svg width="38" height="38" viewBox="0 0 38 38" xmlns="http://www.w3.org/2000/svg" stroke={(this.inverted??false)!==false? this.invertedColor : "#F00"}>
<g fill="none" fill-rule="evenodd">
<g transform="translate(1 1)" stroke-width="2">
<circle stroke-opacity=".5" cx="18" cy="18" r="18"/>

View File

@ -0,0 +1,43 @@
import {CustomElement, defineElement, Host, ShadowDOM, State, Prop} from "../../packages/csx";
import {SvgLoader} from "./svg-loader";
@defineElement('svg-tester-two')
export class SvgTesterTwo extends CustomElement{
// Constructor
constructor(){
super();
}
// Private properties
states = [
{ inverted: true, invertedColor: "#F00"},
{ inverted: true, invertedColor: "#FF0"},
{ inverted: true, invertedColor: "#0FF"},
{ inverted: false},
{ inverted: true, invertedColor: "#0F0"},
];
// Properties
@State() state = this.states[0];
// Handlers
// CustomElement
connectedCallback() {
setInterval(()=>{
// Moving state
let curIndex = this.states.indexOf(this.state);
this.state = this.states[(curIndex+1)>=this.states.length?0:curIndex+1];
}, 1000);
super.connectedCallback();
}
render(){
// invertedColor instead of inverted-color is the only difference!
return (
<Host>
<SvgLoader inverted={this.state.inverted} invertedColor={this.state.invertedColor} />
</Host>
);
}
}

42
test/svg/svg-tester.jsx Normal file
View File

@ -0,0 +1,42 @@
import {CustomElement, defineElement, Host, ShadowDOM, State, Prop} from "../../packages/csx";
import {SvgLoader} from "./svg-loader";
@defineElement('svg-tester')
export class SvgTester extends CustomElement{
// Constructor
constructor(){
super();
}
// Private properties
states = [
{ inverted: true, invertedColor: "#F00"},
{ inverted: true, invertedColor: "#FF0"},
{ inverted: true, invertedColor: "#0FF"},
{ inverted: false},
{ inverted: true, invertedColor: "#0F0"},
];
// Properties
@State() state = this.states[0];
// Handlers
// CustomElement
connectedCallback() {
setInterval(()=>{
// Moving state
let curIndex = this.states.indexOf(this.state);
this.state = this.states[(curIndex+1)>=this.states.length?0:curIndex+1];
}, 1000);
super.connectedCallback();
}
render(){
return (
<Host>
<SvgLoader inverted={this.state.inverted} inverted-color={this.state.invertedColor} />
</Host>
);
}
}

View File

@ -18,19 +18,19 @@ export class MyTodo extends CustomElement{
<style>{ style }</style>
<h1>CSX Todo</h1>
<section>
<todo-input onSubmit={this.handleSubmit}/>
<TodoInput onSubmit={this.handleSubmit}/>
<ul id="list-container"
onCheck={this.handleCheck}
onRemove={this.handleRemove}
>
{this.todos.map(item =>
<todo-item
<TodoItem
key={item.id}
model={ item.id }
checked={ item.checked }
>
{ item.text }
</todo-item>
</TodoItem>
)}
</ul>
</section>

3646
yarn.lock

File diff suppressed because it is too large Load Diff