diff --git a/package.json b/package.json index fbca94f2f09f..7a124c0fda56 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,13 @@ "precodecov": "npm run coverage", "lint": "eslint src test/*.js", "build": "npm run build:main && npm run build:shared && npm run build:ssr", - "build:main": "rollup -c rollup/rollup.config.main.js", + "build:main": "node src/shared/_build.js && rollup -c rollup/rollup.config.main.js", "build:shared": "rollup -c rollup/rollup.config.shared.js", "build:ssr": "rollup -c rollup/rollup.config.ssr.js", + "dev": "node src/shared/_build.js && rollup -c rollup/rollup.config.main.js -w", + "dev:shared": "rollup -c rollup/rollup.config.shared.js -w", "pretest": "npm run build", - "prepublish": "npm run lint && npm run build" + "prepublish": "npm run build && npm run lint" }, "repository": { "type": "git", @@ -72,6 +74,7 @@ "rollup-plugin-commonjs": "^7.0.0", "rollup-plugin-json": "^2.1.0", "rollup-plugin-node-resolve": "^2.0.0", + "rollup-watch": "^3.2.2", "source-map": "^0.5.6", "source-map-support": "^0.4.8" }, diff --git a/src/generators/Generator.js b/src/generators/Generator.js index cf6ec0548c95..7a316adc85d1 100644 --- a/src/generators/Generator.js +++ b/src/generators/Generator.js @@ -24,6 +24,7 @@ export default class Generator { this.helpers = new Set(); this.components = new Set(); this.events = new Set(); + this.transitions = new Set(); this.importedComponents = new Map(); this.bindingGroups = []; @@ -328,7 +329,7 @@ export default class Generator { }); } - [ 'helpers', 'events', 'components' ].forEach( key => { + [ 'helpers', 'events', 'components', 'transitions' ].forEach( key => { if ( templateProperties[ key ] ) { templateProperties[ key ].value.properties.forEach( prop => { this[ key ].add( prop.key.name ); diff --git a/src/generators/dom/Block.js b/src/generators/dom/Block.js index 2e8d54f03c0c..f90575481505 100644 --- a/src/generators/dom/Block.js +++ b/src/generators/dom/Block.js @@ -24,11 +24,17 @@ export default class Block { create: new CodeBuilder(), mount: new CodeBuilder(), update: new CodeBuilder(), + intro: new CodeBuilder(), + outro: new CodeBuilder(), detach: new CodeBuilder(), detachRaw: new CodeBuilder(), destroy: new CodeBuilder() }; + this.hasIntroMethod = false; // a block could have an intro method but not intro transitions, e.g. if a sibling block has intros + this.hasOutroMethod = false; + this.outros = 0; + this.aliases = new Map(); this.variables = new Map(); this.getUniqueName = this.generator.getUniqueNameMaker( options.params ); @@ -100,6 +106,20 @@ export default class Block { } render () { + let introing; + const hasIntros = !this.builders.intro.isEmpty(); + if ( hasIntros ) { + introing = this.getUniqueName( 'introing' ); + this.addVariable( introing ); + } + + let outroing; + const hasOutros = !this.builders.outro.isEmpty(); + if ( hasOutros ) { + outroing = this.getUniqueName( 'outroing' ); + this.addVariable( outroing ); + } + if ( this.variables.size ) { const variables = Array.from( this.variables.keys() ) .map( key => { @@ -157,6 +177,50 @@ export default class Block { } } + if ( this.hasIntroMethod ) { + if ( hasIntros ) { + properties.addBlock( deindent` + intro: function ( ${this.target}, anchor ) { + if ( ${introing} ) return; + ${introing} = true; + ${hasOutros && `${outroing} = false;`} + + ${this.builders.intro} + + this.mount( ${this.target}, anchor ); + }, + ` ); + } else { + properties.addBlock( deindent` + intro: function ( ${this.target}, anchor ) { + this.mount( ${this.target}, anchor ); + }, + ` ); + } + } + + if ( this.hasOutroMethod ) { + if ( hasOutros ) { + properties.addBlock( deindent` + outro: function ( ${this.alias( 'outrocallback' )} ) { + if ( ${outroing} ) return; + ${outroing} = true; + ${hasIntros && `${introing} = false;`} + + var ${this.alias( 'outros' )} = ${this.outros}; + + ${this.builders.outro} + }, + ` ); + } else { + properties.addBlock( deindent` + outro: function ( outrocallback ) { + outrocallback(); + }, + ` ); + } + } + if ( this.builders.destroy.isEmpty() ) { properties.addBlock( `destroy: ${this.generator.helper( 'noop' )}` ); } else { diff --git a/src/generators/dom/index.js b/src/generators/dom/index.js index cb6d33108739..23adc72f02c9 100644 --- a/src/generators/dom/index.js +++ b/src/generators/dom/index.js @@ -1,12 +1,12 @@ import MagicString from 'magic-string'; -import { parse } from 'acorn'; +import { parseExpressionAt } from 'acorn'; import annotateWithScopes from '../../utils/annotateWithScopes.js'; import isReference from '../../utils/isReference.js'; import { walk } from 'estree-walker'; import deindent from '../../utils/deindent.js'; import CodeBuilder from '../../utils/CodeBuilder.js'; import visit from './visit.js'; -import { nameMap, sharedMap } from './sharedNames.js'; +import shared from './shared.js'; import Generator from '../Generator.js'; import preprocess from './preprocess.js'; @@ -25,7 +25,7 @@ class DomGenerator extends Generator { } helper ( name ) { - if ( this.options.dev && sharedMap.has( `${name}Dev` ) ) { + if ( this.options.dev && `${name}Dev` in shared ) { name = `${name}Dev`; } @@ -138,7 +138,7 @@ export default function dom ( parsed, source, options ) { builders.init.addLine( `if ( !${generator.alias( 'added_css' )} ) ${generator.alias( 'add_css' )}();` ); } - if ( generator.hasComponents ) { + if ( generator.hasComponents || generator.hasIntroTransitions ) { builders.init.addLine( `this._renderHooks = [];` ); } @@ -158,7 +158,7 @@ export default function dom ( parsed, source, options ) { ` ); } - if ( generator.hasComponents ) { + if ( generator.hasComponents || generator.hasIntroTransitions ) { const statement = `this._flush();`; builders.init.addBlock( statement ); @@ -168,7 +168,7 @@ export default function dom ( parsed, source, options ) { if ( templateProperties.oncreate ) { builders.init.addBlock( deindent` if ( options._root ) { - options._root._renderHooks.push({ fn: ${generator.alias( 'template' )}.oncreate, context: this }); + options._root._renderHooks.push( ${generator.alias( 'template' )}.oncreate.bind( this ) ); } else { ${generator.alias( 'template' )}.oncreate.call( this ); } @@ -218,7 +218,7 @@ export default function dom ( parsed, source, options ) { this._handlers = Object.create( null ); - this._root = options._root; + this._root = options._root || this; this._yield = options._yield; ${builders.init} @@ -275,20 +275,20 @@ export default function dom ( parsed, source, options ) { ); } else { generator.uses.forEach( key => { - const str = sharedMap.get( key ); + const str = shared[ key ]; const code = new MagicString( str ); - const fn = parse( str ).body[0]; + const expression = parseExpressionAt( str, 0 ); - let scope = annotateWithScopes( fn ); + let scope = annotateWithScopes( expression ); - walk( fn, { + walk( expression, { enter ( node, parent ) { if ( node._scope ) scope = node._scope; if ( node.type === 'Identifier' && isReference( node, parent ) && !scope.has( node.name ) ) { - if ( nameMap.has( node.name ) ) { + if ( node.name in shared ) { // this helper function depends on another one - const dependency = nameMap.get( node.name ); + const dependency = node.name; generator.uses.add( dependency ); const alias = generator.alias( dependency ); @@ -302,10 +302,18 @@ export default function dom ( parsed, source, options ) { } }); - const alias = generator.alias( key ); - if ( alias !== fn.id.name ) code.overwrite( fn.id.start, fn.id.end, alias ); + if ( key === 'transitionManager' ) { // special case + const global = `_svelteTransitionManager`; - builders.main.addBlock( code.toString() ); + builders.main.addBlock( + `var ${generator.alias( 'transitionManager' )} = window.${global} || ( window.${global} = ${code});` + ); + } else { + const alias = generator.alias( expression.id.name ); + if ( alias !== expression.id.name ) code.overwrite( expression.id.start, expression.id.end, alias ); + + builders.main.addBlock( code.toString() ); + } }); } diff --git a/src/generators/dom/preprocess.js b/src/generators/dom/preprocess.js index 6e6c33eafffc..522a68207483 100644 --- a/src/generators/dom/preprocess.js +++ b/src/generators/dom/preprocess.js @@ -59,6 +59,8 @@ const preprocessors = { IfBlock: ( generator, block, state, node ) => { const blocks = []; let dynamic = false; + let hasIntros = false; + let hasOutros = false; function attachBlocks ( node ) { const dependencies = block.findDependencies( node.expression ); @@ -78,6 +80,9 @@ const preprocessors = { block.addDependencies( node._block.dependencies ); } + if ( node._block.hasIntroMethod ) hasIntros = true; + if ( node._block.hasOutroMethod ) hasOutros = true; + if ( isElseIf( node.else ) ) { attachBlocks( node.else.children[0] ); } else if ( node.else ) { @@ -101,6 +106,8 @@ const preprocessors = { blocks.forEach( block => { block.hasUpdateMethod = dynamic; + block.hasIntroMethod = hasIntros; + block.hasOutroMethod = hasOutros; }); generator.blocks.push( ...blocks ); @@ -200,6 +207,14 @@ const preprocessors = { const dependencies = block.findDependencies( attribute.value ); block.addDependencies( dependencies ); } + + else if ( attribute.type === 'Transition' ) { + if ( attribute.intro ) generator.hasIntroTransitions = block.hasIntroMethod = true; + if ( attribute.outro ) { + generator.hasOutroTransitions = block.hasOutroMethod = true; + block.outros += 1; + } + } }); if ( node.children.length ) { diff --git a/src/generators/dom/sharedNames.js b/src/generators/dom/sharedNames.js deleted file mode 100644 index 347969fe5f08..000000000000 --- a/src/generators/dom/sharedNames.js +++ /dev/null @@ -1,12 +0,0 @@ -import * as shared from '../../shared/index.js'; - -export const nameMap = new Map(); -export const sharedMap = new Map(); - -Object.keys(shared).forEach( key => { - const value = shared[ key ]; // eslint-disable-line import/namespace - if ( typeof value === 'function' ) { - nameMap.set( value.name, key ); - } - sharedMap.set( key, value.toString() ); -}); diff --git a/src/generators/dom/visitors/Component/Component.js b/src/generators/dom/visitors/Component/Component.js index b2768d6cf12a..e4685cf9dd3e 100644 --- a/src/generators/dom/visitors/Component/Component.js +++ b/src/generators/dom/visitors/Component/Component.js @@ -92,7 +92,7 @@ export default function visitComponent ( generator, block, state, node ) { const componentInitProperties = [ `target: ${!isToplevel ? state.parentNode: 'null'}`, - `_root: ${block.component}._root || ${block.component}` + `_root: ${block.component}._root` ]; // Component has children, put them in a separate {{yield}} block diff --git a/src/generators/dom/visitors/EachBlock.js b/src/generators/dom/visitors/EachBlock.js index e4df8c575da6..5a427c94c699 100644 --- a/src/generators/dom/visitors/EachBlock.js +++ b/src/generators/dom/visitors/EachBlock.js @@ -11,7 +11,8 @@ export default function visitEachBlock ( generator, block, state, node ) { const params = block.params.join( ', ' ); const anchor = node.needsAnchor ? block.getUniqueName( `${each_block}_anchor` ) : ( node.next && node.next._state.name ) || 'null'; - const vars = { each_block, create_each_block, each_block_value, iterations, i, params, anchor }; + const mountOrIntro = node._block.hasIntroMethod ? 'intro' : 'mount'; + const vars = { each_block, create_each_block, each_block_value, iterations, i, params, anchor, mountOrIntro }; const { snippet } = block.contextualise( node.expression ); @@ -29,7 +30,7 @@ export default function visitEachBlock ( generator, block, state, node ) { if ( isToplevel ) { block.builders.mount.addBlock( deindent` for ( var ${i} = 0; ${i} < ${iterations}.length; ${i} += 1 ) { - ${iterations}[${i}].mount( ${block.target}, null ); + ${iterations}[${i}].${mountOrIntro}( ${block.target}, null ); } ` ); } @@ -52,13 +53,13 @@ export default function visitEachBlock ( generator, block, state, node ) { block.builders.create.addBlock( deindent` if ( !${each_block_value}.length ) { ${each_block_else} = ${node.else._block.name}( ${params}, ${block.component} ); - ${!isToplevel ? `${each_block_else}.mount( ${state.parentNode}, null );` : ''} + ${!isToplevel ? `${each_block_else}.${mountOrIntro}( ${state.parentNode}, null );` : ''} } ` ); block.builders.mount.addBlock( deindent` if ( ${each_block_else} ) { - ${each_block_else}.mount( ${state.parentNode || block.target}, null ); + ${each_block_else}.${mountOrIntro}( ${state.parentNode || block.target}, null ); } ` ); @@ -70,7 +71,7 @@ export default function visitEachBlock ( generator, block, state, node ) { ${each_block_else}.update( changed, ${params} ); } else if ( !${each_block_value}.length ) { ${each_block_else} = ${node.else._block.name}( ${params}, ${block.component} ); - ${each_block_else}.mount( ${parentNode}, ${anchor} ); + ${each_block_else}.${mountOrIntro}( ${parentNode}, ${anchor} ); } else if ( ${each_block_else} ) { ${each_block_else}.destroy( true ); ${each_block_else} = null; @@ -85,7 +86,7 @@ export default function visitEachBlock ( generator, block, state, node ) { } } else if ( !${each_block_else} ) { ${each_block_else} = ${node.else._block.name}( ${params}, ${block.component} ); - ${each_block_else}.mount( ${parentNode}, ${anchor} ); + ${each_block_else}.${mountOrIntro}( ${parentNode}, ${anchor} ); } ` ); } @@ -109,12 +110,12 @@ export default function visitEachBlock ( generator, block, state, node ) { } } -function keyed ( generator, block, state, node, snippet, { each_block, create_each_block, each_block_value, iterations, i, params, anchor } ) { +function keyed ( generator, block, state, node, snippet, { each_block, create_each_block, each_block_value, iterations, i, params, anchor, mountOrIntro } ) { const fragment = block.getUniqueName( 'fragment' ); const value = block.getUniqueName( 'value' ); const key = block.getUniqueName( 'key' ); const lookup = block.getUniqueName( `${each_block}_lookup` ); - const _lookup = block.getUniqueName( `_${each_block}_lookup` ); + const keys = block.getUniqueName( `${each_block}_keys` ); const iteration = block.getUniqueName( `${each_block}_iteration` ); const _iterations = block.getUniqueName( `_${each_block}_iterations` ); @@ -124,12 +125,12 @@ function keyed ( generator, block, state, node, snippet, { each_block, create_ea create.addBlock( deindent` var ${key} = ${each_block_value}[${i}].${node.key}; - ${iterations}[${i}] = ${lookup}[ ${key} ] = ${create_each_block}( ${params}, ${each_block_value}, ${each_block_value}[${i}], ${i}, ${block.component}${node.key ? `, ${key}` : `` } ); + ${iterations}[${i}] = ${lookup}[ ${key} ] = ${create_each_block}( ${params}, ${each_block_value}, ${each_block_value}[${i}], ${i}, ${block.component}, ${key} ); ` ); if ( state.parentNode ) { create.addLine( - `${iterations}[${i}].mount( ${state.parentNode}, null );` + `${iterations}[${i}].${mountOrIntro}( ${state.parentNode}, null );` ); } @@ -141,17 +142,40 @@ function keyed ( generator, block, state, node, snippet, { each_block, create_ea const consequent = node._block.hasUpdateMethod ? deindent` - ${_iterations}[${i}] = ${_lookup}[ ${key} ] = ${lookup}[ ${key} ]; - ${_lookup}[ ${key} ].update( changed, ${params}, ${each_block_value}, ${each_block_value}[${i}], ${i} ); + ${_iterations}[${i}] = ${lookup}[ ${key} ] = ${lookup}[ ${key} ]; + ${lookup}[ ${key} ].update( changed, ${params}, ${each_block_value}, ${each_block_value}[${i}], ${i} ); ` : - `${_iterations}[${i}] = ${_lookup}[ ${key} ] = ${lookup}[ ${key} ];`; + `${_iterations}[${i}] = ${lookup}[ ${key} ] = ${lookup}[ ${key} ];`; const parentNode = state.parentNode || `${anchor}.parentNode`; + const hasIntros = node._block.hasIntroMethod; + + const destroy = node._block.hasOutroMethod ? + deindent` + function outro ( key ) { + ${lookup}[ key ].outro( function () { + ${lookup}[ key ].destroy( true ); + ${lookup}[ key ] = null; + }); + } + + for ( var ${i} = 0; ${i} < ${iterations}.length; ${i} += 1 ) { + ${key} = ${iterations}[${i}].key; + if ( !${keys}[ ${key} ] ) outro( ${key} ); + } + ` : + deindent` + for ( var ${i} = 0; ${i} < ${iterations}.length; ${i} += 1 ) { + var ${iteration} = ${iterations}[${i}]; + if ( !${keys}[ ${iteration}.key ] ) ${iteration}.destroy( true ); + } + `; + block.builders.update.addBlock( deindent` var ${each_block_value} = ${snippet}; var ${_iterations} = []; - var ${_lookup} = Object.create( null ); + var ${keys} = Object.create( null ); var ${fragment} = document.createDocumentFragment(); @@ -159,32 +183,29 @@ function keyed ( generator, block, state, node, snippet, { each_block, create_ea for ( var ${i} = 0; ${i} < ${each_block_value}.length; ${i} += 1 ) { var ${value} = ${each_block_value}[${i}]; var ${key} = ${value}.${node.key}; + ${keys}[ ${key} ] = true; if ( ${lookup}[ ${key} ] ) { ${consequent} + ${hasIntros && `${_iterations}[${i}].mount( ${fragment}, null );`} } else { - ${_iterations}[${i}] = ${_lookup}[ ${key} ] = ${create_each_block}( ${params}, ${each_block_value}, ${each_block_value}[${i}], ${i}, ${block.component}${node.key ? `, ${key}` : `` } ); + ${_iterations}[${i}] = ${lookup}[ ${key} ] = ${create_each_block}( ${params}, ${each_block_value}, ${each_block_value}[${i}], ${i}, ${block.component}, ${key} ); + ${hasIntros && `${_iterations}[${i}].intro( ${fragment}, null );`} } - ${_iterations}[${i}].mount( ${fragment}, null ); + ${!hasIntros && `${_iterations}[${i}].mount( ${fragment}, null );`} } // remove old iterations - for ( var ${i} = 0; ${i} < ${iterations}.length; ${i} += 1 ) { - var ${iteration} = ${iterations}[${i}]; - if ( !${_lookup}[ ${iteration}.key ] ) { - ${iteration}.destroy( true ); - } - } + ${destroy} ${parentNode}.insertBefore( ${fragment}, ${anchor} ); ${iterations} = ${_iterations}; - ${lookup} = ${_lookup}; ` ); } -function unkeyed ( generator, block, state, node, snippet, { create_each_block, each_block_value, iterations, i, params, anchor } ) { +function unkeyed ( generator, block, state, node, snippet, { create_each_block, each_block_value, iterations, i, params, anchor, mountOrIntro } ) { const create = new CodeBuilder(); create.addLine( @@ -193,7 +214,7 @@ function unkeyed ( generator, block, state, node, snippet, { create_each_block, if ( state.parentNode ) { create.addLine( - `${iterations}[${i}].mount( ${state.parentNode}, null );` + `${iterations}[${i}].${mountOrIntro}( ${state.parentNode}, null );` ); } @@ -222,16 +243,34 @@ function unkeyed ( generator, block, state, node, snippet, { create_each_block, ${iterations}[${i}].update( changed, ${params}, ${each_block_value}, ${each_block_value}[${i}], ${i} ); } else { ${iterations}[${i}] = ${create_each_block}( ${params}, ${each_block_value}, ${each_block_value}[${i}], ${i}, ${block.component} ); - ${iterations}[${i}].mount( ${parentNode}, ${anchor} ); + ${iterations}[${i}].${mountOrIntro}( ${parentNode}, ${anchor} ); } ` : deindent` ${iterations}[${i}] = ${create_each_block}( ${params}, ${each_block_value}, ${each_block_value}[${i}], ${i}, ${block.component} ); - ${iterations}[${i}].mount( ${parentNode}, ${anchor} ); + ${iterations}[${i}].${mountOrIntro}( ${parentNode}, ${anchor} ); `; const start = node._block.hasUpdateMethod ? '0' : `${iterations}.length`; + const destroy = node._block.hasOutroMethod ? + deindent` + function outro ( i ) { + if ( ${iterations}[i] ) { + ${iterations}[i].outro( function () { + ${iterations}[i].destroy( true ); + ${iterations}[i] = null; + }); + } + } + + for ( ; ${i} < ${iterations}.length; ${i} += 1 ) outro( ${i} ); + ` : + deindent` + ${generator.helper( 'destroyEach' )}( ${iterations}, true, ${each_block_value}.length ); + ${iterations}.length = ${each_block_value}.length; + `; + block.builders.update.addBlock( deindent` var ${each_block_value} = ${snippet}; @@ -240,9 +279,7 @@ function unkeyed ( generator, block, state, node, snippet, { create_each_block, ${forLoopBody} } - ${generator.helper( 'destroyEach' )}( ${iterations}, true, ${each_block_value}.length ); - - ${iterations}.length = ${each_block_value}.length; + ${destroy} } ` ); } diff --git a/src/generators/dom/visitors/Element/Element.js b/src/generators/dom/visitors/Element/Element.js index 5ce0f6ba5b63..bdf408732f6a 100644 --- a/src/generators/dom/visitors/Element/Element.js +++ b/src/generators/dom/visitors/Element/Element.js @@ -6,6 +6,7 @@ import visitAttribute from './Attribute.js'; import visitEventHandler from './EventHandler.js'; import visitBinding from './Binding.js'; import visitRef from './Ref.js'; +import addTransitions from './addTransitions.js'; const meta = { ':Window': visitWindow @@ -40,21 +41,34 @@ export default function visitElement ( generator, block, state, node ) { block.builders.create.addLine( `var ${name} = ${getRenderStatement( generator, childState.namespace, node.name )};` ); block.mount( name, state.parentNode ); - if ( !state.parentNode ) { - block.builders.detach.addLine( `${generator.helper( 'detachNode' )}( ${name} );` ); - } - // add CSS encapsulation attribute if ( generator.cssId && state.isTopLevel ) { block.builders.create.addLine( `${generator.helper( 'setAttribute' )}( ${name}, '${generator.cssId}', '' );` ); } function visitAttributes () { + let intro; + let outro; + node.attributes .sort( ( a, b ) => order[ a.type ] - order[ b.type ] ) .forEach( attribute => { + if ( attribute.type === 'Transition' ) { + if ( attribute.intro ) intro = attribute; + if ( attribute.outro ) outro = attribute; + return; + } + visitors[ attribute.type ]( generator, block, childState, node, attribute ); }); + + if ( intro || outro ) addTransitions( generator, block, childState, node, intro, outro ); + } + + if ( !state.parentNode ) { + // TODO we eventually need to consider what happens to elements + // that belong to the same outgroup as an outroing element... + block.builders.detach.addLine( `${generator.helper( 'detachNode' )}( ${name} );` ); } if ( node.name !== 'select' ) { diff --git a/src/generators/dom/visitors/Element/addTransitions.js b/src/generators/dom/visitors/Element/addTransitions.js new file mode 100644 index 000000000000..5e19c3d283a6 --- /dev/null +++ b/src/generators/dom/visitors/Element/addTransitions.js @@ -0,0 +1,73 @@ +import deindent from '../../../../utils/deindent.js'; + +export default function addTransitions ( generator, block, state, node, intro, outro ) { + const wrapTransition = generator.helper( 'wrapTransition' ); + + if ( intro === outro ) { + const name = block.getUniqueName( `${state.name}_transition` ); + const snippet = intro.expression ? block.contextualise( intro.expression ).snippet : '{}'; + + block.addVariable( name ); + + const fn = `${generator.alias( 'template' )}.transitions.${intro.name}`; + + block.builders.intro.addBlock( deindent` + ${block.component}._renderHooks.push( function () { + if ( !${name} ) ${name} = ${wrapTransition}( ${state.name}, ${fn}, ${snippet}, true, null ); + ${name}.run( ${name}.t, 1, function () { + ${block.component}.fire( 'intro.end', { node: ${state.name} }); + }); + }); + ` ); + + block.builders.outro.addBlock( deindent` + ${name}.run( ${name}.t, 0, function () { + ${block.component}.fire( 'outro.end', { node: ${state.name} }); + if ( --${block.alias( 'outros' )} === 0 ) ${block.alias( 'outrocallback' )}(); + ${name} = null; + }); + ` ); + } + + else { + const introName = intro && block.getUniqueName( `${state.name}_intro` ); + const outroName = outro && block.getUniqueName( `${state.name}_outro` ); + + if ( intro ) { + block.addVariable( introName ); + const snippet = intro.expression ? block.contextualise( intro.expression ).snippet : '{}'; + + const fn = `${generator.alias( 'template' )}.transitions.${intro.name}`; // TODO add built-in transitions? + + if ( outro ) { + block.builders.intro.addBlock( `if ( ${outroName} ) ${outroName}.abort();` ); + } + + block.builders.intro.addBlock( deindent` + ${block.component}._renderHooks.push( function () { + ${introName} = ${wrapTransition}( ${state.name}, ${fn}, ${snippet}, true, null ); + ${introName}.run( 0, 1, function () { + ${block.component}.fire( 'intro.end', { node: ${state.name} }); + }); + }); + ` ); + } + + if ( outro ) { + block.addVariable( outroName ); + const snippet = outro.expression ? block.contextualise( outro.expression ).snippet : '{}'; + + const fn = `${generator.alias( 'template' )}.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}( ${state.name}, ${fn}, ${snippet}, false, null ); + ${outroName}.run( 1, 0, function () { + ${block.component}.fire( 'outro.end', { node: ${state.name} }); + if ( --${block.alias( 'outros' )} === 0 ) ${block.alias( 'outrocallback' )}(); + }); + ` ); + } + } +} \ No newline at end of file diff --git a/src/generators/dom/visitors/IfBlock.js b/src/generators/dom/visitors/IfBlock.js index 94be3a408169..11b267717c66 100644 --- a/src/generators/dom/visitors/IfBlock.js +++ b/src/generators/dom/visitors/IfBlock.js @@ -9,7 +9,9 @@ function getBranches ( generator, block, state, node ) { const branches = [{ condition: block.contextualise( node.expression ).snippet, block: node._block.name, - dynamic: node._block.dependencies.size > 0 + hasUpdateMethod: node._block.hasUpdateMethod, + hasIntroMethod: node._block.hasIntroMethod, + hasOutroMethod: node._block.hasOutroMethod }]; visitChildren( generator, block, state, node ); @@ -22,7 +24,9 @@ function getBranches ( generator, block, state, node ) { branches.push({ condition: null, block: node.else ? node.else._block.name : null, - dynamic: node.else ? node.else._block.dependencies.size > 0 : false + hasUpdateMethod: node.else ? node.else._block.hasUpdateMethod : false, + hasIntroMethod: node.else ? node.else._block.hasIntroMethod : false, + hasOutroMethod: node.else ? node.else._block.hasOutroMethod : false }); if ( node.else ) { @@ -53,10 +57,15 @@ export default function visitIfBlock ( generator, block, state, node ) { } const branches = getBranches( generator, block, state, node, generator.getUniqueName( `create_if_block` ) ); - const dynamic = branches.some( branch => branch.dynamic ); + const dynamic = branches[0].hasUpdateMethod; // can use [0] as proxy for all, since they necessarily have the same value + const hasOutros = branches[0].hasOutroMethod; if ( node.else ) { - compound( generator, block, state, node, branches, dynamic, vars ); + if ( hasOutros ) { + compoundWithOutros( generator, block, state, node, branches, dynamic, vars ); + } else { + compound( generator, block, state, node, branches, dynamic, vars ); + } } else { simple( generator, block, state, node, branches[0], dynamic, vars ); } @@ -72,85 +81,193 @@ function simple ( generator, block, state, node, branch, dynamic, { name, anchor ` ); const isToplevel = !state.parentNode; + const mountOrIntro = branch.hasIntroMethod ? 'intro' : 'mount'; if ( isToplevel ) { - block.builders.mount.addLine( `if ( ${name} ) ${name}.mount( ${block.target}, null );` ); + block.builders.mount.addLine( `if ( ${name} ) ${name}.${mountOrIntro}( ${block.target}, null );` ); } else { - block.builders.create.addLine( `if ( ${name} ) ${name}.mount( ${state.parentNode}, null );` ); + block.builders.create.addLine( `if ( ${name} ) ${name}.${mountOrIntro}( ${state.parentNode}, null );` ); } const parentNode = state.parentNode || `${anchor}.parentNode`; - if ( dynamic ) { - block.builders.update.addBlock( deindent` - if ( ${branch.condition} ) { + const enter = dynamic ? + ( branch.hasIntroMethod ? + deindent` + if ( ${name} ) { + ${name}.update( changed, ${params} ); + } else { + ${name} = ${branch.block}( ${params}, ${block.component} ); + } + + ${name}.intro( ${parentNode}, ${anchor} ); + ` : + deindent` if ( ${name} ) { ${name}.update( changed, ${params} ); } else { ${name} = ${branch.block}( ${params}, ${block.component} ); ${name}.mount( ${parentNode}, ${anchor} ); } - } else if ( ${name} ) { - ${name}.destroy( true ); - ${name} = null; - } - ` ); - } else { - block.builders.update.addBlock( deindent` - if ( ${branch.condition} ) { + ` ) : + ( branch.hasIntroMethod ? + deindent` + if ( !${name} ) ${name} = ${branch.block}( ${params}, ${block.component} ); + ${name}.intro( ${parentNode}, ${anchor} ); + ` : + deindent` if ( !${name} ) { ${name} = ${branch.block}( ${params}, ${block.component} ); ${name}.mount( ${parentNode}, ${anchor} ); } - } else if ( ${name} ) { + ` ); + + // no `update()` here — we don't want to update outroing nodes, + // as that will typically result in glitching + const exit = branch.hasOutroMethod ? + deindent` + ${name}.outro( function () { ${name}.destroy( true ); ${name} = null; - } - ` ); - } + }); + ` : + deindent` + ${name}.destroy( true ); + ${name} = null; + `; + + block.builders.update.addBlock( deindent` + if ( ${branch.condition} ) { + ${enter} + } else if ( ${name} ) { + ${exit} + } + ` ); } function compound ( generator, block, state, node, branches, dynamic, { name, anchor, params } ) { - const getBlock = block.getUniqueName( `get_block` ); + const get_block = block.getUniqueName( `get_block` ); const current_block = block.getUniqueName( `current_block` ); block.builders.create.addBlock( deindent` - function ${getBlock} ( ${params} ) { + function ${get_block} ( ${params} ) { ${branches.map( ({ condition, block }) => { return `${condition ? `if ( ${condition} ) ` : ''}return ${block};`; } ).join( '\n' )} } - var ${current_block} = ${getBlock}( ${params} ); + var ${current_block} = ${get_block}( ${params} ); var ${name} = ${current_block} && ${current_block}( ${params}, ${block.component} ); ` ); const isToplevel = !state.parentNode; + const mountOrIntro = branches[0].hasIntroMethod ? 'intro' : 'mount'; if ( isToplevel ) { - block.builders.mount.addLine( `if ( ${name} ) ${name}.mount( ${block.target}, null );` ); + block.builders.mount.addLine( `if ( ${name} ) ${name}.${mountOrIntro}( ${block.target}, null );` ); } else { - block.builders.create.addLine( `if ( ${name} ) ${name}.mount( ${state.parentNode}, null );` ); + block.builders.create.addLine( `if ( ${name} ) ${name}.${mountOrIntro}( ${state.parentNode}, null );` ); } const parentNode = state.parentNode || `${anchor}.parentNode`; + const changeBlock = deindent` + if ( ${name} ) ${name}.destroy( true ); + ${name} = ${current_block} && ${current_block}( ${params}, ${block.component} ); + if ( ${name} ) ${name}.${mountOrIntro}( ${parentNode}, ${anchor} ); + `; + if ( dynamic ) { block.builders.update.addBlock( deindent` - if ( ${current_block} === ( ${current_block} = ${getBlock}( ${params} ) ) && ${name} ) { + if ( ${current_block} === ( ${current_block} = ${get_block}( ${params} ) ) && ${name} ) { ${name}.update( changed, ${params} ); } else { - if ( ${name} ) ${name}.destroy( true ); - ${name} = ${current_block} && ${current_block}( ${params}, ${block.component} ); - if ( ${name} ) ${name}.mount( ${parentNode}, ${anchor} ); + ${changeBlock} + } + ` ); + } else { + block.builders.update.addBlock( deindent` + if ( ${current_block} !== ( ${current_block} = ${get_block}( ${params} ) ) ) { + ${changeBlock} + } + ` ); + } +} + +// if any of the siblings have outros, we need to keep references to the blocks +// (TODO does this only apply to bidi transitions?) +function compoundWithOutros ( generator, block, state, node, branches, dynamic, { name, anchor, params } ) { + const get_block = block.getUniqueName( `get_block` ); + const current_block_index = block.getUniqueName( `current_block_index` ); + const previous_block_index = block.getUniqueName( `previous_block_index` ); + const if_block_creators = block.getUniqueName( `if_block_creators` ); + const if_blocks = block.getUniqueName( `if_blocks` ); + + block.addVariable( current_block_index ); + + block.builders.create.addBlock( deindent` + var ${if_block_creators} = [ + ${branches.map( branch => branch.block ).join( ',\n' )} + ]; + + var ${if_blocks} = []; + + function ${get_block} ( ${params} ) { + ${branches.map( ({ condition, block }, i ) => { + return `${condition ? `if ( ${condition} ) ` : ''}return ${block ? i : -1};`; + } ).join( '\n' )} + } + + if ( ~( ${current_block_index} = ${get_block}( ${params} ) ) ) { + ${if_blocks}[ ${current_block_index} ] = ${if_block_creators}[ ${current_block_index} ]( ${params}, ${block.component} ); + } + ` ); + + const isToplevel = !state.parentNode; + const mountOrIntro = branches[0].hasIntroMethod ? 'intro' : 'mount'; + const initialTarget = isToplevel ? block.target : state.parentNode; + + ( isToplevel ? block.builders.mount : block.builders.create ).addBlock( + `if ( ~${current_block_index} ) ${if_blocks}[ ${current_block_index} ].${mountOrIntro}( ${initialTarget}, null );` + ); + + const parentNode = state.parentNode || `${anchor}.parentNode`; + + const changeBlock = deindent` + var ${name} = ${if_blocks}[ ${previous_block_index} ]; + if ( ${name} ) { + ${name}.outro( function () { + ${if_blocks}[ ${previous_block_index} ].destroy( true ); + ${if_blocks}[ ${previous_block_index} ] = null; + }); + } + + if ( ~${current_block_index} ) { + ${name} = ${if_blocks}[ ${current_block_index} ]; + if ( !${name} ) { + ${name} = ${if_blocks}[ ${current_block_index} ] = ${if_block_creators}[ ${current_block_index} ]( ${params}, ${block.component} ); + } + + ${name}.${mountOrIntro}( ${parentNode}, ${anchor} ); + } + `; + + if ( dynamic ) { + block.builders.update.addBlock( deindent` + var ${previous_block_index} = ${current_block_index}; + ${current_block_index} = ${get_block}( state ); + if ( ${current_block_index} === ${previous_block_index} ) { + if ( ~${current_block_index} ) ${if_blocks}[ ${current_block_index} ].update( changed, ${params} ); + } else { + ${changeBlock} } ` ); } else { block.builders.update.addBlock( deindent` - if ( ${current_block} !== ( ${current_block} = ${getBlock}( ${params} ) ) ) { - if ( ${name} ) ${name}.destroy( true ); - ${name} = ${current_block} && ${current_block}( ${params}, ${block.component} ); - if ( ${name} ) ${name}.mount( ${parentNode}, ${anchor} ); + var ${previous_block_index} = ${current_block_index}; + ${current_block_index} = ${get_block}( state ); + if ( ${current_block_index} !== ${previous_block_index} ) { + ${changeBlock} } ` ); } diff --git a/src/parse/read/directives.js b/src/parse/read/directives.js index 28adc3078a92..32e86a0a4d22 100644 --- a/src/parse/read/directives.js +++ b/src/parse/read/directives.js @@ -1,19 +1,11 @@ -import { parse, parseExpressionAt } from 'acorn'; +import { parseExpressionAt } from 'acorn'; import spaces from '../../utils/spaces.js'; -export function readEventHandlerDirective ( parser, start, name ) { - const quoteMark = ( - parser.eat( `'` ) ? `'` : - parser.eat( `"` ) ? `"` : - null - ); - - const expressionStart = parser.index; - +function readExpression ( parser, start, quoteMark ) { let str = ''; let escaped = false; - for ( let i = expressionStart; i < parser.template.length; i += 1 ) { + for ( let i = start; i < parser.template.length; i += 1 ) { const char = parser.template[i]; if ( quoteMark ) { @@ -21,7 +13,6 @@ export function readEventHandlerDirective ( parser, start, name ) { if ( escaped ) { str += quoteMark; } else { - parser.index = i + 1; break; } } else if ( escaped ) { @@ -35,7 +26,6 @@ export function readEventHandlerDirective ( parser, start, name ) { } else if ( /\s/.test( char ) ) { - parser.index = i; break; } @@ -44,13 +34,25 @@ export function readEventHandlerDirective ( parser, start, name ) { } } - const ast = parse( spaces( expressionStart ) + str ); + const expression = parseExpressionAt( spaces( start ) + str, start ); + parser.index = expression.end; - if ( ast.body.length > 1 ) { - parser.error( `Event handler should be a single call expression`, ast.body[1].start ); - } + parser.allowWhitespace(); + if ( quoteMark ) parser.eat( quoteMark, true ); + + return expression; +} + +export function readEventHandlerDirective ( parser, start, name ) { + const quoteMark = ( + parser.eat( `'` ) ? `'` : + parser.eat( `"` ) ? `"` : + null + ); + + const expressionStart = parser.index; - const expression = ast.body[0].expression; + const expression = readExpression( parser, expressionStart, quoteMark ); if ( expression.type !== 'CallExpression' ) { parser.error( `Expected call expression`, expressionStart ); @@ -127,3 +129,33 @@ export function readBindingDirective ( parser, start, name ) { value }; } + +export function readTransitionDirective ( parser, start, name, type ) { + let expression = null; + + if ( parser.eat( '=' ) ) { + const quoteMark = ( + parser.eat( `'` ) ? `'` : + parser.eat( `"` ) ? `"` : + null + ); + + const expressionStart = parser.index; + + expression = readExpression( parser, expressionStart, quoteMark ); + + if ( expression.type !== 'ObjectExpression' ) { + parser.error( `Expected object expression`, expressionStart ); + } + } + + return { + start, + end: parser.index, + type: 'Transition', + name, + intro: type === 'in' || type === 'transition', + outro: type === 'out' || type === 'transition', + expression + }; +} \ No newline at end of file diff --git a/src/parse/state/tag.js b/src/parse/state/tag.js index 48e8cd197e02..475043f311c2 100644 --- a/src/parse/state/tag.js +++ b/src/parse/state/tag.js @@ -1,7 +1,7 @@ import readExpression from '../read/expression.js'; import readScript from '../read/script.js'; import readStyle from '../read/style.js'; -import { readEventHandlerDirective, readBindingDirective } from '../read/directives.js'; +import { readEventHandlerDirective, readBindingDirective, readTransitionDirective } from '../read/directives.js'; import { trimStart, trimEnd } from '../../utils/trim.js'; import { decodeCharacterReferences } from '../utils/html.js'; import isVoidElementName from '../../utils/isVoidElementName.js'; @@ -253,6 +253,11 @@ function readAttribute ( parser, uniqueNames ) { }; } + const match = /^(in|out|transition):/.exec( name ); + if ( match ) { + return readTransitionDirective( parser, start, name.slice( match[0].length ), match[1] ); + } + let value; // :foo is shorthand for foo='{{foo}}' diff --git a/src/shared/.eslintrc.json b/src/shared/.eslintrc.json new file mode 100644 index 000000000000..46f19570776a --- /dev/null +++ b/src/shared/.eslintrc.json @@ -0,0 +1,38 @@ +{ + "root": true, + "rules": { + "indent": [ 2, "tab", { "SwitchCase": 1 } ], + "semi": [ 2, "always" ], + "keyword-spacing": [ 2, { "before": true, "after": true } ], + "space-before-blocks": [ 2, "always" ], + "no-mixed-spaces-and-tabs": [ 2, "smart-tabs" ], + "no-cond-assign": 0, + "no-unused-vars": 2, + "no-const-assign": 2, + "no-class-assign": 2, + "no-this-before-super": 2, + "no-unreachable": 2, + "valid-typeof": 2, + "quote-props": [ 2, "as-needed" ], + "arrow-spacing": 2, + "no-inner-declarations": 0 + }, + "env": { + "es6": true, + "browser": true, + "node": true, + "mocha": true + }, + "extends": [ + "eslint:recommended", + "plugin:import/errors", + "plugin:import/warnings" + ], + "parserOptions": { + "ecmaVersion": 6, + "sourceType": "module" + }, + "settings": { + "import/core-modules": [ "svelte" ] + } +} diff --git a/src/shared/_build.js b/src/shared/_build.js new file mode 100644 index 000000000000..d608fb78206d --- /dev/null +++ b/src/shared/_build.js @@ -0,0 +1,35 @@ +const fs = require( 'fs' ); +const path = require( 'path' ); +const acorn = require( 'acorn' ); + +const declarations = {}; + +fs.readdirSync( __dirname ).forEach( file => { + if ( !/^[a-z]+\.js$/.test( file ) ) return; + + const source = fs.readFileSync( path.join( __dirname, file ), 'utf-8' ); + const ast = acorn.parse( source, { + ecmaVersion: 6, + sourceType: 'module' + }); + + ast.body.forEach( node => { + if ( node.type !== 'ExportNamedDeclaration' ) return; + + const declaration = node.declaration; + if ( !declaration ) return; + + const name = declaration.type === 'VariableDeclaration' ? + declaration.declarations[0].id.name : + declaration.id.name; + + const value = declaration.type === 'VariableDeclaration' ? + declaration.declarations[0].init : + declaration; + + declarations[ name ] = source.slice( value.start, value.end ); + }); +}); + +fs.writeFileSync( 'src/generators/dom/shared.js', `// this file is auto-generated, do not edit it +export default ${JSON.stringify( declarations, null, '\t' )};` ); \ No newline at end of file diff --git a/src/shared/index.js b/src/shared/index.js index 9fa41f3122d9..d31bbffc1784 100644 --- a/src/shared/index.js +++ b/src/shared/index.js @@ -1,15 +1,7 @@ +import { assign } from './utils.js'; export * from './dom.js'; - -export function noop () {} - -export function assign ( target ) { - for ( var i = 1; i < arguments.length; i += 1 ) { - var source = arguments[i]; - for ( var k in source ) target[k] = source[k]; - } - - return target; -} +export * from './transitions.js'; +export * from './utils.js'; export function differs ( a, b ) { return ( a !== b ) || ( a && ( typeof a === 'object' ) || ( typeof a === 'function' ) ); @@ -107,15 +99,14 @@ export function onDev ( eventName, handler ) { export function set ( newState ) { this._set( assign( {}, newState ) ); - ( this._root || this )._flush(); + this._root._flush(); } export function _flush () { if ( !this._renderHooks ) return; while ( this._renderHooks.length ) { - var hook = this._renderHooks.pop(); - hook.fn.call( hook.context ); + this._renderHooks.pop()(); } } diff --git a/src/shared/transitions.js b/src/shared/transitions.js new file mode 100644 index 000000000000..76595a3989fc --- /dev/null +++ b/src/shared/transitions.js @@ -0,0 +1,129 @@ +import { assign, noop } from './utils.js'; + +export function linear ( t ) { + return t; +} + +function generateKeyframes ( a, b, delta, duration, ease, fn, node, style ) { + var id = '__svelte' + ~~( Math.random() * 1e9 ); // TODO make this more robust + var keyframes = '@keyframes ' + id + '{\n'; + + for ( var p = 0; p <= 1; p += 16.666 / duration ) { + var t = a + delta * ease( p ); + keyframes += ( p * 100 ) + '%{' + fn( t ) + '}\n'; + } + + keyframes += '100% {' + fn( b ) + '}\n}'; + style.textContent += keyframes; + + document.head.appendChild( style ); + + node.style.animation = node.style.animation.split( ',' ) + .filter( function ( anim ) { + // when introing, discard old animations if there are any + return anim && ( delta < 0 || !/__svelte/.test( anim ) ); + }) + .concat( id + ' ' + duration + 'ms linear 1 forwards' ) + .join( ', ' ); +} + +export function wrapTransition ( node, fn, params, intro, outgroup ) { + var obj = fn( node, params, intro ); + var duration = obj.duration || 300; + var ease = obj.easing || linear; + + // TODO share