parent
991ad5aefe
commit
a29bbf349f
@ -0,0 +1,176 @@
|
||||
import { TAttributes } from "../html/htmldefs";
|
||||
import { ScalarVAO } from "../vao/ScalarVAO.js";
|
||||
import { AbstractVAO, Detachable, VAO } from "../vao/VAO.js";
|
||||
import { Renderable, INotifyMounted } from "./componentdefs";
|
||||
|
||||
// === TODO ===
|
||||
// [A] Separate VAO-rerender from component
|
||||
// [B] it shouldn't be allowed to render a zero-element component, right?
|
||||
// [C] ensure VAO updates can't be missed during a re-render
|
||||
// [D] should render bindings be checked when elements are created
|
||||
// or instead when notifyMounted is called?
|
||||
// [E] infer VAO from type
|
||||
// [F] investigate possibility of detecting/avoiding infinite recursion here
|
||||
// [G] definition of "mounted" doesn't match React. Maybe call it something
|
||||
// else to avoid confusion.
|
||||
|
||||
export abstract class Component {
|
||||
public attributes: TAttributes
|
||||
public children: Renderable[]
|
||||
|
||||
protected renderChildren_: Renderable[] | null = null;
|
||||
protected instanceProxy_: Component | null = null;
|
||||
protected vaos_: { [name: string]: VAO }
|
||||
protected elements_: Element[];
|
||||
protected parent_: Element | null = null;
|
||||
|
||||
// TODO: [A]
|
||||
protected rerenderVAOs_: { [name: string]: true };
|
||||
protected rerenderSubscriptions_: Detachable[];
|
||||
|
||||
abstract render (): Renderable | Renderable[]
|
||||
|
||||
constructor ({
|
||||
attributes, children
|
||||
}: {
|
||||
attributes: TAttributes | undefined,
|
||||
children: Renderable[] | undefined,
|
||||
}) {
|
||||
this.vaos_ = {}
|
||||
this.elements_ = [];
|
||||
|
||||
this.attributes = attributes ?? {};
|
||||
this.children = children ?? [];
|
||||
|
||||
// Adapt children: expand nested lists
|
||||
this.children = this.children.flat(Infinity);
|
||||
|
||||
// TODO: [A]
|
||||
this.rerenderVAOs_ = {};
|
||||
this.rerenderSubscriptions_ = [];
|
||||
|
||||
for ( let k in attributes ) {
|
||||
if ( attributes[k] instanceof AbstractVAO ) {
|
||||
this.vaos_[k] = attributes[k];
|
||||
} else {
|
||||
// TODO: [E]
|
||||
const vao = new ScalarVAO(attributes[k]);
|
||||
this.vaos_[k] = vao;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createElements (): Element[] {
|
||||
// TODO: [C]
|
||||
for ( const detachable of this.rerenderSubscriptions_ ) {
|
||||
detachable.detach();
|
||||
}
|
||||
|
||||
let renderables = this.render.call(this.instanceProxy);
|
||||
|
||||
let renderablesTS: Renderable[] = Array.isArray(renderables)
|
||||
? renderables : [renderables];
|
||||
|
||||
this.renderChildren_ = renderablesTS;
|
||||
|
||||
const elements = [];
|
||||
for ( const renderable of renderablesTS ) {
|
||||
const theseElements = renderable.createElements();
|
||||
elements.push(...theseElements);
|
||||
}
|
||||
|
||||
this.elements_ = elements;
|
||||
return elements;
|
||||
}
|
||||
|
||||
rerenderSelf () {
|
||||
console.log('rerenderSelf was called', this);
|
||||
if ( ! this.parent_ ) {
|
||||
throw new Error('cannot re-render; no existing element');
|
||||
}
|
||||
|
||||
if ( this.elements_.length === 0 ) {
|
||||
// TODO: [B]
|
||||
throw new Error('cannot re-render a component with zero elements');
|
||||
}
|
||||
|
||||
// The reference element lets us know where to put new elements
|
||||
const referenceElement = this.elements_[0];
|
||||
|
||||
// Remove following elements - we're going to replace the first one
|
||||
for ( const element of this.elements_.slice(1) ) {
|
||||
element.remove();
|
||||
}
|
||||
|
||||
// NEXT: update VAO rerender listeners in createElements
|
||||
const newElements = this.createElements();
|
||||
referenceElement.replaceWith(...newElements);
|
||||
this.notifyMounted({
|
||||
parent: this.parent_,
|
||||
})
|
||||
}
|
||||
|
||||
createDynamicVAO_ (vaoName: string) {
|
||||
this.vaos_[vaoName] = new ScalarVAO(null);
|
||||
}
|
||||
|
||||
notifyMounted(event: INotifyMounted) {
|
||||
this.parent_ = event.parent;
|
||||
|
||||
// TODO: [A]
|
||||
// TODO: [D]
|
||||
console.log('notifyMounted called on a component', this)
|
||||
debugger;
|
||||
for ( const vaoName in this.rerenderVAOs_ ) {
|
||||
this.rerenderSubscriptions_.push(
|
||||
this.vaos_[vaoName].sub('change', () => {
|
||||
this.rerenderSelf();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if ( ! this.renderChildren_ ) {
|
||||
// TODO: [G]
|
||||
throw new Error('component mounted but not rendered');
|
||||
}
|
||||
// TODO: [F]
|
||||
for ( const child of this.renderChildren_ ) {
|
||||
child.notifyMounted({
|
||||
parent: this.parent_
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get instanceProxy (): Component {
|
||||
if ( this.instanceProxy_ ) return this.instanceProxy_;
|
||||
|
||||
const self = this;
|
||||
this.instanceProxy_ = new Proxy(this, {
|
||||
get (target, p, receiver, ...__futureProof__) {
|
||||
if ( typeof p === 'symbol' || self.hasOwnProperty(p) ) {
|
||||
return Reflect.get(target, p, receiver, ...__futureProof__);
|
||||
}
|
||||
|
||||
const vaoRequested = p.endsWith('$');
|
||||
const vaoName = vaoRequested ? p.slice(0, -1) : p;
|
||||
|
||||
if ( ! self.vaos_.hasOwnProperty(vaoName) ) {
|
||||
self.createDynamicVAO_(vaoName);
|
||||
}
|
||||
|
||||
let returnValue = self.vaos_[vaoName];
|
||||
if ( ! vaoRequested ) {
|
||||
console.log('requested a value!', self);
|
||||
// TODO: [A]
|
||||
self.rerenderVAOs_[vaoName] = true;
|
||||
|
||||
returnValue = returnValue.get();
|
||||
}
|
||||
|
||||
return returnValue;
|
||||
}
|
||||
});
|
||||
|
||||
return this.instanceProxy_;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
import { TAttributes } from "../html/htmldefs.js";
|
||||
import { tagsupports } from "../tagsupports.js";
|
||||
import { INotifyMounted, Renderable } from "./componentdefs.js";
|
||||
|
||||
export interface TagAxiom {
|
||||
attach (element: Element): void
|
||||
detach (): void
|
||||
}
|
||||
|
||||
export class Tag {
|
||||
public nodeName: string
|
||||
public attributes: TAttributes
|
||||
public children: Renderable[]
|
||||
|
||||
protected element_: Element | null = null
|
||||
protected axioms_: TagAxiom[]
|
||||
|
||||
constructor ({
|
||||
nodeName, attributes, children
|
||||
}: {
|
||||
nodeName: string,
|
||||
attributes: TAttributes | undefined,
|
||||
children: Renderable[] | undefined
|
||||
}) {
|
||||
this.axioms_ = [];
|
||||
this.nodeName = nodeName;
|
||||
this.attributes = attributes ?? {};
|
||||
this.children = children ?? [];
|
||||
|
||||
// Adapt children: expand nested lists
|
||||
this.children = this.children.flat(Infinity);
|
||||
|
||||
for ( const tagsupport of tagsupports ) {
|
||||
tagsupport.maybeApply(this);
|
||||
}
|
||||
}
|
||||
|
||||
install (axiom: TagAxiom) {
|
||||
this.axioms_.push(axiom);
|
||||
}
|
||||
|
||||
createElements () {
|
||||
const el = document.createElement(this.nodeName);
|
||||
for ( const k in this.attributes ) {
|
||||
el.setAttribute(k, this.attributes[k])
|
||||
}
|
||||
|
||||
this.element_ = el;
|
||||
|
||||
for ( const child of this.children ) {
|
||||
const childElements = child.createElements();
|
||||
for ( const childElement of childElements.flat(Infinity) ) {
|
||||
el.appendChild(childElement);
|
||||
}
|
||||
}
|
||||
|
||||
for ( const axiom of this.axioms_ ) {
|
||||
axiom.attach(el);
|
||||
}
|
||||
|
||||
return [el];
|
||||
}
|
||||
|
||||
notifyMounted (event: INotifyMounted) {
|
||||
console.log('notifyMounted called on a tag', this);
|
||||
if ( this.element_ == null ) {
|
||||
throw new Error('this should never happen');
|
||||
}
|
||||
for ( const child of this.children ) {
|
||||
child.notifyMounted({
|
||||
parent: this.element_
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
import { TAttributes } from '../html/htmldefs.js';
|
||||
|
||||
export interface INotifyMounted {
|
||||
parent: Element
|
||||
}
|
||||
|
||||
export interface Renderable {
|
||||
createElements: () => Element[]
|
||||
notifyMounted: (event: INotifyMounted) => void
|
||||
}
|
||||
export interface RenderableConstructor {
|
||||
new (_: {attributes: TAttributes, children: Renderable[]}): Renderable
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export type TAttributes = { [key: string]: any };
|
||||
@ -0,0 +1,135 @@
|
||||
import { Tag, TagAxiom } from "./component/Tag.js";
|
||||
import { AbstractVAO, Detachable } from "./vao/VAO.js";
|
||||
|
||||
type TagSupportPredicate = {
|
||||
description: string,
|
||||
code: (tag: Tag) => boolean
|
||||
}
|
||||
|
||||
type TagSupportSpec = {
|
||||
predicates: TagSupportPredicate[],
|
||||
execute: (tag: Tag) => void
|
||||
};
|
||||
|
||||
export const tagsupports: TagSupport[] = [];
|
||||
|
||||
const p = (model: TagSupportSpec) => {
|
||||
tagsupports.push(TagSupport.fromSpec(model));
|
||||
}
|
||||
|
||||
class TagSupport {
|
||||
static fromSpec(spec: TagSupportSpec): TagSupport {
|
||||
return new TagSupport(spec);
|
||||
}
|
||||
protected spec: TagSupportSpec;
|
||||
constructor (spec: TagSupportSpec) {
|
||||
this.spec = spec;
|
||||
}
|
||||
|
||||
maybeApply (tag: Tag): void {
|
||||
for ( const predicate of this.spec.predicates ) {
|
||||
if ( ! predicate.code(tag) ) return;
|
||||
}
|
||||
|
||||
this.spec.execute(tag);
|
||||
}
|
||||
}
|
||||
|
||||
const IS_AN_INPUT_TAG: TagSupportPredicate = {
|
||||
description: 'is an input tag',
|
||||
code: tag => tag.nodeName == 'input'
|
||||
};
|
||||
const ATTR_VALUE_IS: (key: string, val: any) => TagSupportPredicate = (key, val) => {
|
||||
return {
|
||||
description: `attribute "${key}" is "${val}"`,
|
||||
code: tag => tag.attributes[key] == val
|
||||
};
|
||||
}
|
||||
const ATTR_VALUE_IS_VAO: TagSupportPredicate = {
|
||||
description: 'value attribute is a VAO',
|
||||
code: tag => tag.attributes.value instanceof AbstractVAO
|
||||
};
|
||||
|
||||
class BindInputValueAxiom implements TagAxiom {
|
||||
private feedback_ = false;
|
||||
private subs_: Detachable[] = [];
|
||||
private tag_: Tag
|
||||
constructor (tag: Tag) {
|
||||
this.tag_ = tag;
|
||||
}
|
||||
attach (element: Element) {
|
||||
const inputElement: HTMLInputElement = element as HTMLInputElement;
|
||||
|
||||
const vao = this.tag_.attributes.value;
|
||||
|
||||
inputElement.value = vao.get();
|
||||
|
||||
element.addEventListener('change', () => {
|
||||
this.feedback_ = true;
|
||||
vao.set(inputElement.value);
|
||||
this.feedback_ = false;
|
||||
})
|
||||
|
||||
// NEXT: doing subs next
|
||||
this.subs_.push(vao.sub('change', () => {
|
||||
if ( this.feedback_ ) return;
|
||||
inputElement.value = vao.get();
|
||||
}))
|
||||
}
|
||||
detach () {
|
||||
for ( const sub of this.subs_ ) sub.detach();
|
||||
}
|
||||
}
|
||||
|
||||
class BindCheckedAxiom implements TagAxiom {
|
||||
private feedback_ = false;
|
||||
private subs_: Detachable[] = [];
|
||||
private tag_: Tag
|
||||
constructor (tag: Tag) {
|
||||
this.tag_ = tag;
|
||||
}
|
||||
attach (element: Element) {
|
||||
const inputElement: HTMLInputElement = element as HTMLInputElement;
|
||||
|
||||
const vao = this.tag_.attributes.value;
|
||||
|
||||
inputElement.value = vao.get();
|
||||
|
||||
element.addEventListener('change', () => {
|
||||
this.feedback_ = true;
|
||||
vao.set(inputElement.checked);
|
||||
this.feedback_ = false;
|
||||
})
|
||||
|
||||
// NEXT: doing subs next
|
||||
this.subs_.push(vao.sub('change', () => {
|
||||
if ( this.feedback_ ) return;
|
||||
inputElement.checked = !! vao.get();
|
||||
}))
|
||||
}
|
||||
detach () {
|
||||
for ( const sub of this.subs_ ) sub.detach();
|
||||
}
|
||||
}
|
||||
|
||||
p({
|
||||
predicates: [
|
||||
IS_AN_INPUT_TAG,
|
||||
ATTR_VALUE_IS('type', 'text'),
|
||||
ATTR_VALUE_IS_VAO
|
||||
],
|
||||
execute: (tag) => {
|
||||
tag.install(new BindInputValueAxiom(tag));
|
||||
}
|
||||
});
|
||||
|
||||
p({
|
||||
predicates: [
|
||||
IS_AN_INPUT_TAG,
|
||||
ATTR_VALUE_IS('type', 'checkbox'),
|
||||
ATTR_VALUE_IS_VAO
|
||||
],
|
||||
execute: (tag) => {
|
||||
tag.install(new BindCheckedAxiom(tag));
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,26 @@
|
||||
import { AbstractVAO } from "./VAO.js";
|
||||
|
||||
export class ScalarVAO extends AbstractVAO {
|
||||
private value_: any
|
||||
|
||||
protected getTopics_(): string[] {
|
||||
return ['change'];
|
||||
}
|
||||
|
||||
constructor (initialValue: any) {
|
||||
super();
|
||||
this.value_ = initialValue;
|
||||
}
|
||||
|
||||
set (value: any) {
|
||||
let o = this.value_;
|
||||
this.value_ = value;
|
||||
if ( o !== this.value_ ) {
|
||||
this.pub('change');
|
||||
}
|
||||
}
|
||||
|
||||
get (): any {
|
||||
return this.value_;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
export interface Detachable {
|
||||
detach: () => void
|
||||
}
|
||||
|
||||
type TListenerNode = {
|
||||
next?: TListenerNode | null,
|
||||
prev?: TListenerNode | null,
|
||||
listener?: (event: TValueEvent) => void,
|
||||
}
|
||||
|
||||
type TListenerList = {
|
||||
[topic: string]: TListenerNode[]
|
||||
}
|
||||
|
||||
export type TValueEvent = {
|
||||
vao: VAO,
|
||||
value: any,
|
||||
extra: any,
|
||||
}
|
||||
|
||||
export interface VAO {
|
||||
sub (topic: string, listener: () => void): Detachable
|
||||
pub (topic: string, extra: any): void
|
||||
get (): any
|
||||
set (value: any): void
|
||||
}
|
||||
|
||||
export abstract class AbstractVAO implements VAO {
|
||||
private listeners_: TListenerList
|
||||
constructor () {
|
||||
this.listeners_ = {};
|
||||
|
||||
const topics = this.getTopics_();
|
||||
for ( const topic of topics ) {
|
||||
this.listeners_[topic] = [
|
||||
{ next: null }
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract getTopics_(): string[]
|
||||
abstract get (): any
|
||||
abstract set (value: any): void
|
||||
|
||||
sub (topic: string, listener: () => void): Detachable {
|
||||
const head = this.listeners_[topic][0];
|
||||
const node: TListenerNode = {
|
||||
next: head.next,
|
||||
prev: head,
|
||||
listener
|
||||
};
|
||||
|
||||
if ( node.next ) {
|
||||
node.next.prev = node;
|
||||
}
|
||||
head.next = node;
|
||||
|
||||
return {
|
||||
detach: () => {
|
||||
if ( node.prev ) node.prev.next = node.next;
|
||||
if ( node.next ) node.next.prev = node.prev;
|
||||
|
||||
node.prev = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub (topic: string, extra?: any) {
|
||||
let node = this.listeners_[topic][0];
|
||||
|
||||
const event = {
|
||||
...(extra || {}),
|
||||
vao: this,
|
||||
value: this.get()
|
||||
};
|
||||
|
||||
while ( node.next ) {
|
||||
node = node.next;
|
||||
if ( node.listener === undefined ) {
|
||||
continue;
|
||||
}
|
||||
node.listener(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue