@winged/core
Version:
Morden webapp framekwork made only for ts developers. (UNDER DEVELOPMENT, PLEASE DO NOT USE)
431 lines (393 loc) • 15.5 kB
text/typescript
import 'reflect-metadata'
import { Constructor, ModificationTree, NonFuncPropNames, Optional, RSDObject } from './types'
import { RenderableStateDescriber } from './types'
import { utils } from './utils'
import { LNSlot } from './vdom/logicalNode/LNSlot'
import { LNSlotPlugin } from './vdom/logicalNode/LNSlotPlugin'
import { LNSubview } from './vdom/logicalNode/LNSubview'
import { PrevSibling, vDomFactory, VDomRoot, VDomStruct, VNodeRegister } from './vdom/vdom'
import { vdomUtils } from './vdom/vdomUtils'
import { ViewModel } from './viewModel/ViewModel'
type StateNames<V> = Exclude<NonFuncPropNames<V>, 'refs' | '_propsType' | '_events' | '_contents' | '_subviewMap'>
type States<V> = Pick<V, StateNames<V>>
export type VEventData<
V extends View<any>,
N extends keyof V['_subviewMap'],
E extends keyof EV,
EV = InstanceType<V['_subviewMap'][N]>['_events'],
F = EV[E]>
= F extends (data: infer A) => void ? A : void
export type VSubviewType<V extends View<any>, P> = Constructor<V & { _propsType: P }>
const M_SUBVIEW_PROPS = Symbol('custom:SubviewProps')
const M_STATES = Symbol('custom:ViewStates')
interface ViewStateMeta {
className: string
describerMerged: boolean
renderableStateDescriber: RSDObject
computedStates: {
[stateName: string]: {
getter: () => any;
firstLevelDependencies: { [field: string]: true };
}
}
stateWatchers: Array<{
callback: () => any;
firstLevelDependencies: { [field: string]: true };
}>
}
interface ViewClassReflection {
defineState: (metadata: ViewStateMeta, stateName: string, describer?: RenderableStateDescriber) => void
defineSubviewProps: (metadata: ViewStateMeta, stateName: string, describer?: RenderableStateDescriber) => void
defineComputedState: (metadata: ViewStateMeta, getter: () => any, stateName: string, dependencies: string[]) => void
defineStateWatcher: (metadata: ViewStateMeta, callback: () => any, dependencies: string[]) => void
getMetadata(instance: BaseView): ViewStateMeta
}
type BaseView = View<any>
interface SubviewPropsInfo {
[name: string]: { dependencies: string[], getter: () => any }
}
export function vSubviewMap(...subviewPropDescribers: RenderableStateDescriber[]) {
return (view: BaseView, _: string) => {
const describers: RenderableStateDescriber = {}
for (const d of subviewPropDescribers) {
vdomUtils.mergeStateDescribers(describers, d)
}
Reflect.defineMetadata(M_SUBVIEW_PROPS, describers, view)
}
}
export function vState(describer?: RenderableStateDescriber) {
return (view: BaseView, stateName: string) => {
const ViewReflect: ViewClassReflection = View as any
const metadata = ViewReflect.getMetadata(view)
ViewReflect.defineState(metadata, stateName, describer)
}
}
export function vComputed<V extends BaseView>(...dependencies: Array<StateNames<V>>) {
return <T>(view: BaseView, stateName: string, descriptor: TypedPropertyDescriptor<T>) => {
const ViewReflect: ViewClassReflection = View as any
const metadata = ViewReflect.getMetadata(view)
if (!descriptor || !descriptor.get) {
throw new Error(
`Invalid computedState '${stateName}' in ${metadata.className}: Must be defined as a getter`
)
}
if (descriptor.set) {
throw new Error(
`Invalid computedState '${stateName}' in ${metadata.className}: Can't have a setter`
)
}
ViewReflect.defineComputedState(metadata, descriptor.get, stateName, dependencies as string[])
}
}
export function vWatch<V extends BaseView>(...dependencies: Array<StateNames<V>>) {
return <T>(view: BaseView, key: string, descriptor: TypedPropertyDescriptor<T>) => {
const ViewReflect: ViewClassReflection = View as any
const metadata = ViewReflect.getMetadata(view)
const callback = (view as any)[key]
if (typeof callback !== 'function') {
throw new Error(' Decorator vWatch must be used on a method member')
}
ViewReflect.defineStateWatcher(metadata, callback, dependencies as string[])
}
}
export abstract class View<V extends View<any>> {
private get _viewClassName() { return `[${this.constructor.name}]` }
public static readonly propsDescriber: RenderableStateDescriber = {}
private static getMetadata(instance: BaseView): ViewStateMeta {
let metadata: ViewStateMeta = Reflect.getMetadata(M_STATES, instance)
if (!metadata) {
metadata = {
describerMerged: false,
renderableStateDescriber: {},
computedStates: {},
stateWatchers: [],
className: instance._viewClassName
}
Reflect.defineMetadata(M_STATES, metadata, instance)
}
return metadata
}
private static defineState(metadata: ViewStateMeta, stateName: string, describer?: RenderableStateDescriber) {
const { renderableStateDescriber } = metadata
if (renderableStateDescriber[stateName]) {
return
}
renderableStateDescriber[stateName] = describer || true
}
private static defineComputedState(
metadata: ViewStateMeta, definedGetter: () => any, stateName: string, dependencies: string[]
) {
if (metadata.computedStates[stateName]) {
return
}
const firstLevelDependencies = utils.listToMap(dependencies)
// check loop structure
for (const field in metadata.computedStates) {
const info = metadata.computedStates[field]
if (
info.firstLevelDependencies[stateName] &&
firstLevelDependencies[field]
) {
throw new Error(
`ComputedState "${field}" and "${stateName}" has bidirectional dependencies`
)
}
}
metadata.computedStates[stateName] = {
getter: definedGetter, firstLevelDependencies
}
}
private static defineStateWatcher(metadata: ViewStateMeta, callback: () => any, dependencies: string[]) {
metadata.stateWatchers.push({
callback, firstLevelDependencies: utils.listToMap(dependencies)
})
}
// ============
// core part
// ============
public abstract _propsType: {}
public abstract readonly _events: { [k: string]: (data?: any) => void }
public abstract readonly _subviewMap: { [k: string]: Constructor<View<any>> }
protected abstract readonly _contents: VDomStruct
protected readonly refs: { [ref: string]: HTMLElement }
private _metadata: ViewStateMeta = View.getMetadata(this)
private _vNodeRegister: VNodeRegister
private _vRoot?: VDomRoot
private _viewModel: ViewModel
private _subviewPropsInfo: SubviewPropsInfo
private _initialProps: this['_propsType']
private _slotPlugins: { [slotName: string]: LNSlotPlugin }
private _insertTarget?: {
container: HTMLElement,
prevSiblingNode?: Node
}
constructor(props?: V['_propsType']) {
this._initialProps = props || {}
const stateMeta = View.getMetadata(this)
if (!stateMeta.describerMerged) {
this.mergeStateMetaDescribers(stateMeta)
}
this._viewModel = new ViewModel({}, stateMeta.renderableStateDescriber)
this._viewModel._watchBy(this, (modificationTree) => {
this.update(modificationTree)
})
this.initView()
}
// ============
// logical part
// ============
public appendTo(container: HTMLElement) {
if (this._insertTarget || this._vRoot && this._vRoot.domCreated) {
throw new Error(`Can't call .appendTo() of ${this._viewClassName} because it's already rendered`)
}
if (this._vRoot) {
this.initialRender(container, { node: undefined })
this.onMount(this._initialProps)
delete this._initialProps
} else {
this._insertTarget = { container }
}
}
public insertAfter(prevSiblingNode: Node) {
if (this._insertTarget || this._vRoot && this._vRoot.domCreated) {
throw new Error(`Can't call .insertAfter() of ${this._viewClassName} because it's already rendered`)
}
if (this._vRoot) {
this.initialRender(prevSiblingNode.parentElement!, { node: prevSiblingNode })
this.onMount(this._initialProps)
delete this._initialProps
} else {
this._insertTarget = { container: prevSiblingNode.parentElement!, prevSiblingNode }
}
}
/** nothing but a shorthand, do the exact same thing as direct assignment */
// tslint:disable-next-line:no-empty
public setState(state: Optional<States<V>>) { }
// life cycle methods
// tslint:disable-next-line:no-empty
protected onMount(props: this['_propsType']) { }
// tslint:disable-next-line:no-empty
protected onPropsChange(props: this['_propsType']) { }
// tslint:disable-next-line:no-empty
protected onBeforeDestroy() { }
/** abstract v.ts view class will generate and implement this */
protected getPropsDescriber(): RenderableStateDescriber | null {
return null
}
protected abstract _linkSubviewProps(): SubviewPropsInfo
private mergeStateMetaDescribers(stateMeta: ViewStateMeta) {
const subviewPropsMeta = Reflect.getMetadata(M_SUBVIEW_PROPS, this)
vdomUtils.mergeStateDescribers(stateMeta.renderableStateDescriber, subviewPropsMeta)
for (const stateName in stateMeta.computedStates) {
for (const name in stateMeta.computedStates[stateName].firstLevelDependencies) {
if (!stateMeta.renderableStateDescriber[name]) {
stateMeta.renderableStateDescriber[name] = true
}
}
}
for (const stateName in stateMeta.stateWatchers) {
for (const name in stateMeta.stateWatchers[stateName].firstLevelDependencies) {
if (!stateMeta.renderableStateDescriber[name]) {
stateMeta.renderableStateDescriber[name] = true
}
}
}
stateMeta.describerMerged = true
Reflect.defineMetadata(M_STATES, stateMeta, this)
}
private initView() {
this.defineProperties()
this._subviewPropsInfo = this._linkSubviewProps()
// wait for impl class constructor to initialize property
setTimeout(() => {
this.createVRoot()
}, 0)
// NOTE: suppressing unused private method exception;
// tslint:disable-next-line:no-unused-expression
View.defineState
// tslint:disable-next-line:no-unused-expression
View.defineComputedState
// tslint:disable-next-line:no-unused-expression
View.defineStateWatcher
// tslint:disable-next-line:no-unused-expression
this.setPropsFromParent
// tslint:disable-next-line:no-unused-expression
this.setSlotPluginsFromParent
// tslint:disable-next-line:no-unused-expression
this.destroy
}
private destroy() {
this.onBeforeDestroy()
if (this._viewModel) {
this._viewModel._destory()
delete this._viewModel
}
if (this._vRoot) {
this._vRoot.destroy()
}
this._vNodeRegister.destory()
delete this._vRoot
delete this._vNodeRegister
delete this._metadata
}
private createVRoot() {
// parse vdom expressions
const register = new VNodeRegister(this)
register.registerSubviewNode = this.registerSubviewNode.bind(this)
register.registerSlotNode = this.registerSlotNode.bind(this)
this._vNodeRegister = register
this._vRoot = vDomFactory.createVDomRoot(this._contents, register)
if (this._insertTarget) {
const { container, prevSiblingNode } = this._insertTarget
delete this._insertTarget
if (prevSiblingNode) {
this.insertAfter(prevSiblingNode)
} else {
this.appendTo(container)
}
}
}
private defineProperties() {
const { renderableStateDescriber, computedStates } = this._metadata
console.log('METADATA', this._metadata)
// define states
// define computed states
for (const field in computedStates) {
Object.defineProperty(this, field, {
get: () => this._viewModel._get(field),
set: () => { throw new Error(`Computed State '${field}' of ${this._viewClassName} is not writable`) },
configurable: false
})
}
for (const field in renderableStateDescriber) {
if (computedStates[field]) {
continue
}
Object.defineProperty(this, field, {
get() {
return (this as V)._viewModel._get(field)
},
set(value) {
(this as V)._viewModel._set(field, value)
}
})
}
// define propsType
Object.defineProperty(this, '_propsType', {
get: () => { throw new Error('View.propsType is only for typing purpose, can\'t be get as a property') },
set: () => { throw new Error('View.propsType is only for typing purpose, can\'t be set as a property') }
})
}
private setPropsFromParent(props: this['_propsType']) {
if (!this._vRoot) {
this._initialProps = props
} else {
this.onPropsChange(props)
}
}
private setSlotPluginsFromParent(slotPlugins: { [slotName: string]: LNSlotPlugin }) {
this._slotPlugins = slotPlugins
}
// ============
// render part
// ============
private registerSubviewNode(node: LNSubview): { propsGetter: () => any, stateDependencies: string[] } {
if (!this._subviewPropsInfo[node.subviewName]) {
console.error(this._subviewPropsInfo)
throw new Error(`Can't find subview props info for ${node.subviewName} in ${this._viewClassName}`)
}
node.setViewClass(this._subviewMap[node.viewClassPath])
for (const eventName in node.outputEventNames) {
const handlerName = node.outputEventNames[eventName]
const handler = this[handlerName as keyof this]
if (typeof handler !== 'function') {
throw new Error(`invalid subview event handler. handlerName:${handlerName}`)
}
node.outputEventHandlers[eventName] = handler
}
const { dependencies, getter } = this._subviewPropsInfo[node.subviewName]
return { propsGetter: getter, stateDependencies: dependencies }
}
private registerSlotNode(node: LNSlot) {
node.registerSlotPlugin(this._slotPlugins[node.slotName])
}
private initialRender(container: HTMLElement, prevSibling: PrevSibling) {
// initial evaluation of computed states
(this._vRoot as VDomRoot).initialRender(this._viewModel, container, prevSibling)
}
private update(modificationTree: ModificationTree) {
const { computedStates, stateWatchers } = View.getMetadata(this)
if (this._vRoot) {
console.log('UPDATE', modificationTree)
this._vRoot.update(this._viewModel, modificationTree)
}
// TODO: need to consider dead loops:
// - like bidirectional computed state dependencies
// - computedState "b" depends on "a", and a watcher change "a" on the change of "b"
for (const entryField in modificationTree) {
// update computed states
for (const computedStateName in computedStates) {
const { firstLevelDependencies, getter } = computedStates[computedStateName]
if (firstLevelDependencies[entryField]) {
let res: any = null
try {
res = getter.call(this)
} catch (error) {
console.warn(`Unable to calculate computedState '${computedStateName}' of ${this._viewClassName}:`)
console.warn(error)
}
if (res !== this._viewModel._get(computedStateName)) {
console.log('recalculate computed state', computedStateName, JSON.stringify(res))
this._viewModel._set(computedStateName, res)
}
}
}
// update watchers
for (const watcherInfo of stateWatchers) {
if (watcherInfo.firstLevelDependencies[entryField]) {
watcherInfo.callback()
}
}
}
}
}