import Binding from '../../../nodes/Binding'; import ElementWrapper from '.'; import { dimensions } from '../../../../utils/patterns'; import getObject from '../../../../utils/getObject'; import Block from '../../Block'; import Node from '../../../nodes/shared/Node'; import Renderer from '../../Renderer'; import flattenReference from '../../../../utils/flattenReference'; import { get_tail } from '../../../../utils/get_tail_snippet'; // TODO this should live in a specific binding const readOnlyMediaAttributes = new Set([ 'duration', 'buffered', 'seekable', 'played' ]); export default class BindingWrapper { node: Binding; parent: ElementWrapper; object: string; handler: { usesContext: boolean; mutation: string; contextual_dependencies: Set<string> }; snippet: string; initialUpdate: string; isReadOnly: boolean; needsLock: boolean; constructor(block: Block, node: Binding, parent: ElementWrapper) { this.node = node; this.parent = parent; const { dynamic_dependencies } = this.node.expression; block.addDependencies(dynamic_dependencies); // TODO does this also apply to e.g. `<input type='checkbox' bind:group='foo'>`? if (parent.node.name === 'select') { parent.selectBindingDependencies = dynamic_dependencies; dynamic_dependencies.forEach((prop: string) => { parent.renderer.component.indirectDependencies.set(prop, new Set()); }); } if (node.isContextual) { // we need to ensure that the each block creates a context including // the list and the index, if they're not otherwise referenced const { name } = getObject(this.node.expression.node); const eachBlock = block.contextOwners.get(name); eachBlock.hasBinding = true; } this.object = getObject(this.node.expression.node).name; // TODO unfortunate code is necessary because we need to use `ctx` // inside the fragment, but not inside the <script> const contextless_snippet = this.parent.renderer.component.source.slice(this.node.expression.node.start, this.node.expression.node.end); // view to model this.handler = getEventHandler(this, parent.renderer, block, this.object, contextless_snippet); this.snippet = this.node.expression.render(); const type = parent.node.getStaticAttributeValue('type'); this.isReadOnly = ( dimensions.test(this.node.name) || (parent.node.isMediaNode() && readOnlyMediaAttributes.has(this.node.name)) || (parent.node.name === 'input' && type === 'file') // TODO others? ); this.needsLock = this.node.name === 'currentTime'; // TODO others? } get_dependencies() { const dependencies = new Set(this.node.expression.dependencies); this.node.expression.dependencies.forEach((prop: string) => { const indirectDependencies = this.parent.renderer.component.indirectDependencies.get(prop); if (indirectDependencies) { indirectDependencies.forEach(indirectDependency => { dependencies.add(indirectDependency); }); } }); return dependencies; } isReadOnlyMediaAttribute() { return readOnlyMediaAttributes.has(this.node.name); } render(block: Block, lock: string) { if (this.isReadOnly) return; const { parent } = this; let updateConditions: string[] = this.needsLock ? [`!${lock}`] : []; const dependencyArray = [...this.node.expression.dynamic_dependencies] if (dependencyArray.length === 1) { updateConditions.push(`changed.${dependencyArray[0]}`) } else if (dependencyArray.length > 1) { updateConditions.push( `(${dependencyArray.map(prop => `changed.${prop}`).join(' || ')})` ) } // model to view let updateDom = getDomUpdater(parent, this); // special cases switch (this.node.name) { case 'group': const bindingGroup = getBindingGroup(parent.renderer, this.node.expression.node); block.builders.hydrate.addLine( `(#component.$$.binding_groups[${bindingGroup}] || (#component.$$.binding_groups[${bindingGroup}] = [])).push(${parent.var});` ); block.builders.destroy.addLine( `#component.$$.binding_groups[${bindingGroup}].splice(#component.$$.binding_groups[${bindingGroup}].indexOf(${parent.var}), 1);` ); break; case 'currentTime': case 'volume': updateConditions.push(`!isNaN(${this.snippet})`); break; case 'paused': // this is necessary to prevent audio restarting by itself const last = block.getUniqueName(`${parent.var}_is_paused`); block.addVariable(last, 'true'); updateConditions.push(`${last} !== (${last} = ${this.snippet})`); updateDom = `${parent.var}[${last} ? "pause" : "play"]();`; break; case 'value': if (parent.getStaticAttributeValue('type') === 'file') { updateDom = null; } } if (updateDom) { block.builders.update.addLine( updateConditions.length ? `if (${updateConditions.join(' && ')}) ${updateDom}` : updateDom ); } if (!/(currentTime|paused)/.test(this.node.name)) { block.builders.mount.addBlock(updateDom); } } } function getDomUpdater( element: ElementWrapper, binding: BindingWrapper ) { const { node } = element; if (binding.isReadOnlyMediaAttribute()) { return null; } if (binding.node.name === 'this') { return null; } if (node.name === 'select') { return node.getStaticAttributeValue('multiple') === true ? `@selectOptions(${element.var}, ${binding.snippet})` : `@selectOption(${element.var}, ${binding.snippet})`; } if (binding.node.name === 'group') { const type = node.getStaticAttributeValue('type'); const condition = type === 'checkbox' ? `~${binding.snippet}.indexOf(${element.var}.__value)` : `${element.var}.__value === ${binding.snippet}`; return `${element.var}.checked = ${condition};` } return `${element.var}.${binding.node.name} = ${binding.snippet};`; } function getBindingGroup(renderer: Renderer, value: Node) { const { parts } = flattenReference(value); // TODO handle cases involving computed member expressions const keypath = parts.join('.'); // TODO handle contextual bindings — `keypath` should include unique ID of // each block that provides context let index = renderer.bindingGroups.indexOf(keypath); if (index === -1) { index = renderer.bindingGroups.length; renderer.bindingGroups.push(keypath); } return index; } function getEventHandler( binding: BindingWrapper, renderer: Renderer, block: Block, name: string, snippet: string ) { const value = getValueFromDom(renderer, binding.parent, binding); if (binding.node.isContextual) { let tail = ''; if (binding.node.expression.node.type === 'MemberExpression') { const { start, end } = get_tail(binding.node.expression.node); tail = renderer.component.source.slice(start, end); } const { object, property, snippet } = block.bindings.get(name); return { usesContext: true, mutation: `${snippet}${tail} = ${value};`, contextual_dependencies: new Set([object, property]) }; } if (binding.node.expression.node.type === 'MemberExpression') { return { usesContext: false, mutation: `${snippet} = ${value};`, contextual_dependencies: new Set() }; } return { usesContext: false, mutation: `${snippet} = ${value};`, contextual_dependencies: new Set() }; } function getValueFromDom( renderer: Renderer, element: ElementWrapper, binding: BindingWrapper ) { const { node } = element; const { name } = binding.node; if (name === 'this') { return `$$node`; } // <select bind:value='selected> if (node.name === 'select') { return node.getStaticAttributeValue('multiple') === true ? `@selectMultipleValue(this)` : `@selectValue(this)`; } const type = node.getStaticAttributeValue('type'); // <input type='checkbox' bind:group='foo'> if (name === 'group') { const bindingGroup = getBindingGroup(renderer, binding.node.expression.node); if (type === 'checkbox') { return `@getBindingGroupValue($$self.$$.binding_groups[${bindingGroup}])`; } return `this.__value`; } // <input type='range|number' bind:value> if (type === 'range' || type === 'number') { return `@toNumber(this.${name})`; } if ((name === 'buffered' || name === 'seekable' || name === 'played')) { return `@timeRangesToArray(this.${name})` } // everything else return `this.${name}`; }