diff --git a/src/generators/dom/visitors/Element/addBindings.ts b/src/generators/dom/visitors/Element/addBindings.ts deleted file mode 100644 index 2e41cd1b19..0000000000 --- a/src/generators/dom/visitors/Element/addBindings.ts +++ /dev/null @@ -1,428 +0,0 @@ -import deindent from '../../../../utils/deindent'; -import flattenReference from '../../../../utils/flattenReference'; -import getStaticAttributeValue from '../../../../utils/getStaticAttributeValue'; -import { DomGenerator } from '../../index'; -import Block from '../../Block'; -import { Node } from '../../../../interfaces'; -import { State } from '../../interfaces'; -import getObject from '../../../../utils/getObject'; -import getTailSnippet from '../../../../utils/getTailSnippet'; -import stringifyProps from '../../../../utils/stringifyProps'; -import { generateRule } from '../../../../shared/index'; -import flatten from '../../../../utils/flattenReference'; - -interface Binding { - name: string; -} - -const readOnlyMediaAttributes = new Set([ - 'duration', - 'buffered', - 'seekable', - 'played' -]); - -function isMediaNode(name: string) { - return name === 'audio' || name === 'video'; -} - -const events = [ - { - eventNames: ['input'], - filter: (node: Node, binding: Binding) => - node.name === 'textarea' || - node.name === 'input' && !/radio|checkbox/.test(getStaticAttributeValue(node, 'type')) - }, - { - eventNames: ['change'], - filter: (node: Node, binding: Binding) => - node.name === 'select' || - node.name === 'input' && /radio|checkbox|range/.test(getStaticAttributeValue(node, 'type')) - }, - - // media events - { - eventNames: ['timeupdate'], - filter: (node: Node, binding: Binding) => - isMediaNode(node.name) && - (binding.name === 'currentTime' || binding.name === 'played') - }, - { - eventNames: ['durationchange'], - filter: (node: Node, binding: Binding) => - isMediaNode(node.name) && - binding.name === 'duration' - }, - { - eventNames: ['play', 'pause'], - filter: (node: Node, binding: Binding) => - isMediaNode(node.name) && - binding.name === 'paused' - }, - { - eventNames: ['progress'], - filter: (node: Node, binding: Binding) => - isMediaNode(node.name) && - binding.name === 'buffered' - }, - { - eventNames: ['loadedmetadata'], - filter: (node: Node, binding: Binding) => - isMediaNode(node.name) && - (binding.name === 'buffered' || binding.name === 'seekable') - } -]; - -export default function addBindings( - generator: DomGenerator, - block: Block, - state: State, - node: Node -) { - const bindings: Node[] = node.attributes.filter((a: Node) => a.type === 'Binding'); - if (bindings.length === 0) return; - - if (node.name === 'select' || isMediaNode(node.name)) generator.hasComplexBindings = true; - - const needsLock = node.name !== 'input' || !/radio|checkbox|range|color/.test(getStaticAttributeValue(node, 'type')); - - const mungedBindings = bindings.map(binding => { - const isReadOnly = isMediaNode(node.name) && readOnlyMediaAttributes.has(binding.name); - - let updateCondition: string; - - const { name } = getObject(binding.value); - const { contexts } = block.contextualise(binding.value); - const { snippet } = binding.metadata; - - // special case: if you have e.g. `` - // and `selected` is an object chosen with a - if (binding.name === 'group') { - const bindingGroup = getBindingGroup(generator, binding.value); - if (type === 'checkbox') { - return `@getBindingGroupValue(#component._bindingGroups[${bindingGroup}])`; - } - - return `${node.var}.__value`; - } - - // - if (type === 'range' || type === 'number') { - return `@toNumber(${node.var}.${binding.name})`; - } - - if ((binding.name === 'buffered' || binding.name === 'seekable' || binding.name === 'played')) { - return `@timeRangesToArray(${node.var}.${binding.name})` - } - - // everything else - return `${node.var}.${binding.name}`; -} - -function isComputed(node: Node) { - while (node.type === 'MemberExpression') { - if (node.computed) return true; - node = node.object; - } - - return false; -} \ No newline at end of file diff --git a/src/generators/dom/visitors/Element/addTransitions.ts b/src/generators/dom/visitors/Element/addTransitions.ts deleted file mode 100644 index 0fa9d293c2..0000000000 --- a/src/generators/dom/visitors/Element/addTransitions.ts +++ /dev/null @@ -1,98 +0,0 @@ -import deindent from '../../../../utils/deindent'; -import { DomGenerator } from '../../index'; -import Block from '../../Block'; -import { Node } from '../../../../interfaces'; -import { State } from '../../interfaces'; - -export default function addTransitions( - generator: DomGenerator, - block: Block, - state: State, - node: Node -) { - const intro = node.attributes.find((a: Node) => a.type === 'Transition' && a.intro); - const outro = node.attributes.find((a: Node) => a.type === 'Transition' && a.outro); - - if (!intro && !outro) return; - - if (intro === outro) { - block.contextualise(intro.expression); // TODO remove all these - - const name = block.getUniqueName(`${node.var}_transition`); - const snippet = intro.expression - ? intro.metadata.snippet - : '{}'; - - block.addVariable(name); - - const fn = `%transitions-${intro.name}`; - - block.builders.intro.addBlock(deindent` - #component._root._aftercreate.push(function() { - if (!${name}) ${name} = @wrapTransition(#component, ${node.var}, ${fn}, ${snippet}, true, null); - ${name}.run(true, function() { - #component.fire("intro.end", { node: ${node.var} }); - }); - }); - `); - - block.builders.outro.addBlock(deindent` - ${name}.run(false, function() { - #component.fire("outro.end", { node: ${node.var} }); - if (--#outros === 0) #outrocallback(); - ${name} = null; - }); - `); - } else { - const introName = intro && block.getUniqueName(`${node.var}_intro`); - const outroName = outro && block.getUniqueName(`${node.var}_outro`); - - if (intro) { - block.contextualise(intro.expression); - - block.addVariable(introName); - const snippet = intro.expression - ? intro.metadata.snippet - : '{}'; - - const fn = `%transitions-${intro.name}`; // TODO add built-in transitions? - - if (outro) { - block.builders.intro.addBlock(deindent` - if (${introName}) ${introName}.abort(); - if (${outroName}) ${outroName}.abort(); - `); - } - - block.builders.intro.addBlock(deindent` - #component._root._aftercreate.push(function() { - ${introName} = @wrapTransition(#component, ${node.var}, ${fn}, ${snippet}, true, null); - ${introName}.run(true, function() { - #component.fire("intro.end", { node: ${node.var} }); - }); - }); - `); - } - - if (outro) { - block.contextualise(outro.expression); - - block.addVariable(outroName); - const snippet = outro.expression - ? outro.metadata.snippet - : '{}'; - - const fn = `%transitions-${outro.name}`; - - // TODO hide elements that have outro'd (unless they belong to a still-outroing - // group) prior to their removal from the DOM - block.builders.outro.addBlock(deindent` - ${outroName} = @wrapTransition(#component, ${node.var}, ${fn}, ${snippet}, false, null); - ${outroName}.run(false, function() { - #component.fire("outro.end", { node: ${node.var} }); - if (--#outros === 0) #outrocallback(); - }); - `); - } - } -} diff --git a/src/generators/nodes/Binding.ts b/src/generators/nodes/Binding.ts index 816bfd3cb0..52de1cbde9 100644 --- a/src/generators/nodes/Binding.ts +++ b/src/generators/nodes/Binding.ts @@ -1,7 +1,271 @@ import Node from './shared/Node'; +import Element from './Element'; +import getObject from '../../utils/getObject'; +import getTailSnippet from '../../utils/getTailSnippet'; +import flattenReference from '../../utils/flattenReference'; +import Block from '../dom/Block'; +import State from '../dom/State'; + +const readOnlyMediaAttributes = new Set([ + 'duration', + 'buffered', + 'seekable', + 'played' +]); export default class Binding extends Node { name: string; value: Node[] - expression: Node + expression: Node; + + munge( + block: Block, + state: State + ) { + const node: Element = this.parent; + + const needsLock = node.name !== 'input' || !/radio|checkbox|range|color/.test(node.getStaticAttributeValue('type')); + const isReadOnly = node.isMediaNode() && readOnlyMediaAttributes.has(this.name); + + let updateCondition: string; + + const { name } = getObject(this.value); + const { contexts } = block.contextualise(this.value); + const { snippet } = this.metadata; + + // special case: if you have e.g. `` + // and `selected` is an object chosen with a + if (binding.name === 'group') { + const bindingGroup = getBindingGroup(generator, binding.value); + if (type === 'checkbox') { + return `@getBindingGroupValue(#component._bindingGroups[${bindingGroup}])`; + } + + return `${node.var}.__value`; + } + + // + if (type === 'range' || type === 'number') { + return `@toNumber(${node.var}.${binding.name})`; + } + + if ((binding.name === 'buffered' || binding.name === 'seekable' || binding.name === 'played')) { + return `@timeRangesToArray(${node.var}.${binding.name})` + } + + // everything else + return `${node.var}.${binding.name}`; +} + +function isComputed(node: Node) { + while (node.type === 'MemberExpression') { + if (node.computed) return true; + node = node.object; + } + + return false; } \ No newline at end of file diff --git a/src/generators/nodes/Element.ts b/src/generators/nodes/Element.ts index db78c7675a..1f0f041a0f 100644 --- a/src/generators/nodes/Element.ts +++ b/src/generators/nodes/Element.ts @@ -8,18 +8,20 @@ import Node from './shared/Node'; import Block from '../dom/Block'; import State from '../dom/State'; import Attribute from './Attribute'; +import Binding from './Binding'; +import EventHandler from './EventHandler'; +import Ref from './Ref'; +import Transition from './Transition'; import Text from './Text'; import * as namespaces from '../../utils/namespaces'; // temp - move this logic in here import addBindings from '../dom/visitors/Element/addBindings'; -import addTransitions from '../dom/visitors/Element/addTransitions'; -import visitAttribute from '../dom/visitors/Element/Attribute'; export default class Element extends Node { type: 'Element'; name: string; - attributes: Attribute[]; // TODO have more specific Attribute type + attributes: (Attribute | Binding | EventHandler | Ref | Transition)[]; // TODO split these up sooner children: Node[]; init( @@ -242,7 +244,7 @@ export default class Element extends Node { }); } - addBindings(this.generator, block, childState, this); + this.addBindings(block, childState); this.attributes.filter((a: Attribute) => a.type === 'Attribute').forEach((attribute: Attribute) => { attribute.render(block, childState); @@ -361,7 +363,7 @@ export default class Element extends Node { generator.usesRefs = true; // so component.refs object is created }); - addTransitions(generator, block, childState, this); + this.addTransitions(block); if (childState.allUsedContexts.length || childState.usesComponent) { const initialProps: string[] = []; @@ -429,6 +431,210 @@ export default class Element extends Node { } } + addBindings( + block: Block, + state: State + ) { + const bindings: Binding[] = this.attributes.filter((a: Binding) => a.type === 'Binding'); + if (bindings.length === 0) return; + + if (this.name === 'select' || this.isMediaNode()) this.generator.hasComplexBindings = true; + + const needsLock = this.name !== 'input' || !/radio|checkbox|range|color/.test(this.getStaticAttributeValue('type')); + + const mungedBindings = bindings.map(binding => binding.munge(block, state)); + + const lock = mungedBindings.some(binding => binding.needsLock) ? + block.getUniqueName(`${this.var}_updating`) : + null; + + if (lock) block.addVariable(lock, 'false'); + + const groups = events + .map(event => { + return { + events: event.eventNames, + bindings: mungedBindings.filter(binding => event.filter(this, binding.name)) + }; + }) + .filter(group => group.bindings.length); + + groups.forEach(group => { + const handler = block.getUniqueName(`${this.var}_${group.events.join('_')}_handler`); + + const needsLock = group.bindings.some(binding => binding.needsLock); + + group.bindings.forEach(binding => { + if (!binding.updateDom) return; + + const updateConditions = needsLock ? [`!${lock}`] : []; + if (binding.updateCondition) updateConditions.push(binding.updateCondition); + + block.builders.update.addLine( + updateConditions.length ? `if (${updateConditions.join(' && ')}) ${binding.updateDom}` : binding.updateDom + ); + }); + + const usesContext = group.bindings.some(binding => binding.handler.usesContext); + const usesState = group.bindings.some(binding => binding.handler.usesState); + const usesStore = group.bindings.some(binding => binding.handler.usesStore); + const mutations = group.bindings.map(binding => binding.handler.mutation).filter(Boolean).join('\n'); + + const props = new Set(); + const storeProps = new Set(); + group.bindings.forEach(binding => { + binding.handler.props.forEach(prop => { + props.add(prop); + }); + + binding.handler.storeProps.forEach(prop => { + storeProps.add(prop); + }); + }); // TODO use stringifyProps here, once indenting is fixed + + // media bindings — awkward special case. The native timeupdate events + // fire too infrequently, so we need to take matters into our + // own hands + let animation_frame; + if (group.events[0] === 'timeupdate') { + animation_frame = block.getUniqueName(`${this.var}_animationframe`); + block.addVariable(animation_frame); + } + + block.builders.init.addBlock(deindent` + function ${handler}() { + ${ + animation_frame && deindent` + cancelAnimationFrame(${animation_frame}); + if (!${this.var}.paused) ${animation_frame} = requestAnimationFrame(${handler});` + } + ${usesContext && `var context = ${this.var}._svelte;`} + ${usesState && `var state = #component.get();`} + ${usesStore && `var $ = #component.store.get();`} + ${needsLock && `${lock} = true;`} + ${mutations.length > 0 && mutations} + ${props.size > 0 && `#component.set({ ${Array.from(props).join(', ')} });`} + ${storeProps.size > 0 && `#component.store.set({ ${Array.from(storeProps).join(', ')} });`} + ${needsLock && `${lock} = false;`} + } + `); + + group.events.forEach(name => { + block.builders.hydrate.addLine( + `@addListener(${this.var}, "${name}", ${handler});` + ); + + block.builders.destroy.addLine( + `@removeListener(${this.var}, "${name}", ${handler});` + ); + }); + + const allInitialStateIsDefined = group.bindings + .map(binding => `'${binding.object}' in state`) + .join(' && '); + + if (this.name === 'select' || group.bindings.find(binding => binding.name === 'indeterminate' || binding.isReadOnlyMediaAttribute)) { + this.generator.hasComplexBindings = true; + + block.builders.hydrate.addLine( + `if (!(${allInitialStateIsDefined})) #component._root._beforecreate.push(${handler});` + ); + } + }); + + this.initialUpdate = mungedBindings.map(binding => binding.initialUpdate).filter(Boolean).join('\n'); + } + + addTransitions( + block: Block + ) { + const intro = this.attributes.find((a: Transition) => a.type === 'Transition' && a.intro); + const outro = this.attributes.find((a: Transition) => a.type === 'Transition' && a.outro); + + if (!intro && !outro) return; + + if (intro === outro) { + block.contextualise(intro.expression); // TODO remove all these + + const name = block.getUniqueName(`${this.var}_transition`); + const snippet = intro.expression + ? intro.metadata.snippet + : '{}'; + + block.addVariable(name); + + const fn = `%transitions-${intro.name}`; + + block.builders.intro.addBlock(deindent` + #component._root._aftercreate.push(function() { + if (!${name}) ${name} = @wrapTransition(#component, ${this.var}, ${fn}, ${snippet}, true, null); + ${name}.run(true, function() { + #component.fire("intro.end", { node: ${this.var} }); + }); + }); + `); + + block.builders.outro.addBlock(deindent` + ${name}.run(false, function() { + #component.fire("outro.end", { node: ${this.var} }); + if (--#outros === 0) #outrocallback(); + ${name} = null; + }); + `); + } else { + const introName = intro && block.getUniqueName(`${this.var}_intro`); + const outroName = outro && block.getUniqueName(`${this.var}_outro`); + + if (intro) { + block.contextualise(intro.expression); + + block.addVariable(introName); + const snippet = intro.expression + ? intro.metadata.snippet + : '{}'; + + const fn = `%transitions-${intro.name}`; // TODO add built-in transitions? + + if (outro) { + block.builders.intro.addBlock(deindent` + if (${introName}) ${introName}.abort(); + if (${outroName}) ${outroName}.abort(); + `); + } + + block.builders.intro.addBlock(deindent` + #component._root._aftercreate.push(function() { + ${introName} = @wrapTransition(#component, ${this.var}, ${fn}, ${snippet}, true, null); + ${introName}.run(true, function() { + #component.fire("intro.end", { node: ${this.var} }); + }); + }); + `); + } + + if (outro) { + block.contextualise(outro.expression); + + block.addVariable(outroName); + const snippet = outro.expression + ? outro.metadata.snippet + : '{}'; + + const fn = `%transitions-${outro.name}`; + + // TODO hide elements that have outro'd (unless they belong to a still-outroing + // group) prior to their removal from the DOM + block.builders.outro.addBlock(deindent` + ${outroName} = @wrapTransition(#component, ${this.var}, ${fn}, ${snippet}, false, null); + ${outroName}.run(false, function() { + #component.fire("outro.end", { node: ${this.var} }); + if (--#outros === 0) #outrocallback(); + }); + `); + } + } + } + getStaticAttributeValue(name: string) { const attribute = this.attributes.find( (attr: Attribute) => attr.type === 'Attribute' && attr.name.toLowerCase() === name @@ -445,6 +651,10 @@ export default class Element extends Node { return null; } + + isMediaNode() { + return this.name === 'audio' || this.name === 'video'; + } } function getRenderStatement( @@ -494,4 +704,51 @@ function stringifyAttributeValue(value: Node | true) { const data = value[0].data; return `=${JSON.stringify(data)}`; -} \ No newline at end of file +} + +const events = [ + { + eventNames: ['input'], + filter: (node: Element, name: string) => + node.name === 'textarea' || + node.name === 'input' && !/radio|checkbox/.test(node.getStaticAttributeValue('type')) + }, + { + eventNames: ['change'], + filter: (node: Element, name: string) => + node.name === 'select' || + node.name === 'input' && /radio|checkbox|range/.test(node.getStaticAttributeValue('type')) + }, + + // media events + { + eventNames: ['timeupdate'], + filter: (node: Element, name: string) => + node.isMediaNode() && + (name === 'currentTime' || name === 'played') + }, + { + eventNames: ['durationchange'], + filter: (node: Element, name: string) => + node.isMediaNode() && + name === 'duration' + }, + { + eventNames: ['play', 'pause'], + filter: (node: Element, name: string) => + node.isMediaNode() && + name === 'paused' + }, + { + eventNames: ['progress'], + filter: (node: Element, name: string) => + node.isMediaNode() && + name === 'buffered' + }, + { + eventNames: ['loadedmetadata'], + filter: (node: Element, name: string) => + node.isMediaNode() && + (name === 'buffered' || name === 'seekable') + } +]; \ No newline at end of file