diff --git a/src/generators/Generator.ts b/src/generators/Generator.ts index 7798cf3c86..b94f74e488 100644 --- a/src/generators/Generator.ts +++ b/src/generators/Generator.ts @@ -122,11 +122,16 @@ export default class Generator { expression: Node, context: string, isEventHandler: boolean - ) { + ): { + dependencies: string[], + contexts: Set, + indexes: Set, + snippet: string + } { this.addSourcemapLocations(expression); - const usedContexts = new Set(); - const usedIndexes = new Set(); + const usedContexts: Set = new Set(); + const usedIndexes: Set = new Set(); const { code, helpers } = this; const { contexts, indexes } = block; @@ -208,7 +213,7 @@ export default class Generator { }, }); - const dependencies = new Set(expression._dependencies || []); + const dependencies: Set = new Set(expression._dependencies || []); if (expression._dependencies) { expression._dependencies.forEach((prop: string) => { diff --git a/src/generators/dom/index.ts b/src/generators/dom/index.ts index 2513623c49..c77ff17158 100644 --- a/src/generators/dom/index.ts +++ b/src/generators/dom/index.ts @@ -178,6 +178,7 @@ export default function dom( this._root = options._root || this; this._yield = options._yield; + this._bind = options._bind; ${generator.stylesheet.hasStyles && options.css !== false && diff --git a/src/generators/dom/visitors/Component.ts b/src/generators/dom/visitors/Component.ts new file mode 100644 index 0000000000..e474eefd60 --- /dev/null +++ b/src/generators/dom/visitors/Component.ts @@ -0,0 +1,474 @@ +import deindent from '../../../utils/deindent'; +import CodeBuilder from '../../../utils/CodeBuilder'; +import visit from '../visit'; +import { DomGenerator } from '../index'; +import Block from '../Block'; +import getTailSnippet from '../../../utils/getTailSnippet'; +import getObject from '../../../utils/getObject'; +import { stringify } from '../../../utils/stringify'; +import { Node } from '../../../interfaces'; +import { State } from '../interfaces'; + +function stringifyProps(props: string[]) { + if (!props.length) return '{}'; + + const joined = props.join(', '); + if (joined.length > 40) { + // make larger data objects readable + return `{\n\t${props.join(',\n\t')}\n}`; + } + + return `{ ${joined} }`; +} + +interface Attribute { + name: string; + value: any; + dynamic: boolean; + dependencies?: string[] +} + +interface Binding { + name: string; + value: Node; + contexts: Set; + snippet: string; + obj: string; + prop: string; + dependencies: string[]; +} + +export default function visitComponent( + generator: DomGenerator, + block: Block, + state: State, + node: Node, + elementStack: Node[] +) { + generator.hasComponents = true; + + const name = block.getUniqueName( + (node.name === ':Self' ? generator.name : node.name).toLowerCase() + ); + + const componentInitProperties = [`_root: #component._root`]; + + // Component has children, put them in a separate {{yield}} block + if (node.children.length > 0) { + const params = block.params.join(', '); + + const childBlock = node._block; + + node.children.forEach((child: Node) => { + visit(generator, childBlock, node._state, child, elementStack); + }); + + const yield_fragment = block.getUniqueName(`${name}_yield_fragment`); + + block.builders.init.addLine( + `var ${yield_fragment} = ${childBlock.name}( ${params}, #component );` + ); + + block.builders.create.addLine(`${yield_fragment}.create();`); + + block.builders.claim.addLine( + `${yield_fragment}.claim( ${state.parentNodes} );` + ); + + if (childBlock.hasUpdateMethod) { + block.builders.update.addLine( + `${yield_fragment}.update( changed, ${params} );` + ); + } + + block.builders.destroy.addLine(`${yield_fragment}.destroy();`); + + componentInitProperties.push(`_yield: ${yield_fragment}`); + } + + const allContexts = new Set(); + const statements: string[] = []; + const name_context = block.getUniqueName(`${name}_context`); + + let name_updating: string; + let name_initial_data: string; + let beforecreate: string = null; + + const attributes = node.attributes + .filter((a: Node) => a.type === 'Attribute') + .map((a: Node) => mungeAttribute(a, block)); + + const bindings = node.attributes + .filter((a: Node) => a.type === 'Binding') + .map((a: Node) => mungeBinding(a, block)); + + if (attributes.length || bindings.length) { + const initialProps = attributes + .map((attribute: Attribute) => `${attribute.name}: ${attribute.value}`); + + const initialPropString = stringifyProps(initialProps); + + const updates: string[] = []; + + attributes + .filter((attribute: Attribute) => attribute.dynamic) + .forEach((attribute: Attribute) => { + if (attribute.dependencies.length) { + updates.push(deindent` + if ( ${attribute.dependencies + .map(dependency => `changed.${dependency}`) + .join(' || ')} ) ${name}_changes.${attribute.name} = ${attribute.value}; + `); + } + + else { + // TODO this is an odd situation to encounter – I *think* it should only happen with + // each block indices, in which case it may be possible to optimise this + updates.push(`${name}_changes.${attribute.name} = ${attribute.value};`); + } + }); + + if (bindings.length) { + generator.hasComplexBindings = true; + + name_updating = block.alias(`${name}_updating`); + name_initial_data = block.getUniqueName(`${name}_initial_data`); + + block.addVariable(name_updating, '{}'); + statements.push(`var ${name_initial_data} = ${initialPropString};`); + + const setParentFromChildOnChange = new CodeBuilder(); + const setParentFromChildOnInit = new CodeBuilder(); + + bindings.forEach((binding: Binding) => { + let setParentFromChild; + + binding.contexts.forEach(context => { + allContexts.add(context); + }); + + const { name: key } = getObject(binding.value); + + if (block.contexts.has(key)) { + const prop = binding.dependencies[0]; + const computed = isComputed(binding.value); + const tail = binding.value.type === 'MemberExpression' ? getTailSnippet(binding.value) : ''; + + setParentFromChild = deindent` + var list = ${name_context}.${block.listNames.get(key)}; + var index = ${name_context}.${block.indexNames.get(key)}; + list[index]${tail} = childState.${binding.name}; + + ${binding.dependencies + .map((prop: string) => `newState.${prop} = state.${prop};`) + .join('\n')} + `; + } + + else if (binding.value.type === 'MemberExpression') { + setParentFromChild = deindent` + ${binding.snippet} = childState.${binding.name}; + ${binding.dependencies.map((prop: string) => `newState.${prop} = state.${prop};`).join('\n')} + `; + } + + else { + setParentFromChild = `newState.${binding.value.name} = childState.${binding.name};`; + } + + statements.push(deindent` + if ( ${binding.prop} in ${binding.obj} ) { + ${name_initial_data}.${binding.name} = ${binding.snippet}; + ${name_updating}.${binding.name} = true; + }` + ); + + setParentFromChildOnChange.addConditional( + `!${name_updating}.${binding.name} && changed.${binding.name}`, + setParentFromChild + ); + + setParentFromChildOnInit.addConditional( + `!${name_updating}.${binding.name}`, + setParentFromChild + ); + + // TODO could binding.dependencies.length ever be 0? + if (binding.dependencies.length) { + updates.push(deindent` + if ( !${name_updating}.${binding.name} && ${binding.dependencies.map((dependency: string) => `changed.${dependency}`).join(' || ')} ) { + ${name}_changes.${binding.name} = ${binding.snippet}; + ${name_updating}.${binding.name} = true; + } + `); + } + }); + + componentInitProperties.push(`data: ${name_initial_data}`); + + componentInitProperties.push(deindent` + _bind: function(changed, childState) { + var state = #component.get(), newState = {}; + ${setParentFromChildOnChange} + ${name_updating} = changed; + #component._set(newState); + ${name_updating} = {}; + } + `); + + beforecreate = deindent` + #component._root._beforecreate.push(function () { + var state = #component.get(), childState = ${name}.get(), newState = {}; + if (!childState) return; + ${setParentFromChildOnInit} + ${name_updating} = { ${bindings.map((binding: Binding) => `${binding.name}: true`).join(', ')} }; + #component._set(newState); + ${name_updating} = {}; + }); + `; + } else if (initialProps.length) { + componentInitProperties.push(`data: ${initialPropString}`); + } + + block.builders.update.addBlock(deindent` + var ${name}_changes = {}; + ${updates.join('\n')} + ${name}._set( ${name}_changes ); + ${bindings.length && `${name_updating} = {};`} + `); + } + + const expression = node.name === ':Self' + ? generator.name + : generator.importedComponents.get(node.name) || + `@template.components.${node.name}`; + + block.builders.init.addBlock(deindent` + ${statements.join('\n')} + var ${name} = new ${expression}({ + ${componentInitProperties.join(',\n')} + }); + + ${beforecreate} + `); + + block.builders.create.addLine(`${name}._fragment.create();`); + + block.builders.claim.addLine( + `${name}._fragment.claim( ${state.parentNodes} );` + ); + + block.builders.mount.addLine( + `${name}._fragment.mount( ${state.parentNode || '#target'}, ${state.parentNode ? 'null' : 'anchor'} );` + ); + + if (!state.parentNode) block.builders.unmount.addLine(`${name}._fragment.unmount();`); + + block.builders.destroy.addLine(`${name}.destroy( false );`); + + // event handlers + node.attributes.filter((a: Node) => a.type === 'EventHandler').forEach((handler: Node) => { + const usedContexts: string[] = []; + + if (handler.expression) { + generator.addSourcemapLocations(handler.expression); + generator.code.prependRight( + handler.expression.start, + `${block.alias('component')}.` + ); + + handler.expression.arguments.forEach((arg: Node) => { + const { contexts } = block.contextualise(arg, null, true); + + contexts.forEach(context => { + if (!~usedContexts.indexOf(context)) usedContexts.push(context); + allContexts.add(context); + }); + }); + } + + // TODO hoist event handlers? can do `this.__component.method(...)` + const declarations = usedContexts.map(name => { + if (name === 'state') return `var state = ${name_context}.state;`; + + const listName = block.listNames.get(name); + const indexName = block.indexNames.get(name); + + return `var ${listName} = ${name_context}.${listName}, ${indexName} = ${name_context}.${indexName}, ${name} = ${listName}[${indexName}]`; + }); + + const handlerBody = + (declarations.length ? declarations.join('\n') + '\n\n' : '') + + (handler.expression ? + `[✂${handler.expression.start}-${handler.expression.end}✂];` : + `${block.alias('component')}.fire('${handler.name}', event);`); + + block.builders.init.addBlock(deindent` + ${name}.on( '${handler.name}', function ( event ) { + ${handlerBody} + }); + `); + }); + + // refs + node.attributes.filter((a: Node) => a.type === 'Ref').forEach((ref: Node) => { + generator.usesRefs = true; + + block.builders.init.addLine(`#component.refs.${ref.name} = ${name};`); + + block.builders.destroy.addLine(deindent` + if ( #component.refs.${ref.name} === ${name} ) #component.refs.${ref.name} = null; + `); + }); + + // maintain component context + if (allContexts.size) { + const contexts = Array.from(allContexts); + + const initialProps = contexts + .map(contextName => { + if (contextName === 'state') return `state: state`; + + const listName = block.listNames.get(contextName); + const indexName = block.indexNames.get(contextName); + + return `${listName}: ${listName},\n${indexName}: ${indexName}`; + }) + .join(',\n'); + + const updates = contexts + .map(contextName => { + if (contextName === 'state') return `${name_context}.state = state;`; + + const listName = block.listNames.get(contextName); + const indexName = block.indexNames.get(contextName); + + return `${name_context}.${listName} = ${listName};\n${name_context}.${indexName} = ${indexName};`; + }) + .join('\n'); + + block.builders.init.addBlock(deindent` + var ${name_context} = { + ${initialProps} + }; + `); + + block.builders.update.addBlock(updates); + } +} + +function mungeAttribute(attribute: Node, block: Block): Attribute { + if (attribute.value === true) { + // attributes without values, e.g.