import deindent from '../../utils/deindent'; import { stringify, escapeHTML } from '../../utils/stringify'; import flattenReference from '../../utils/flattenReference'; import isVoidElementName from '../../utils/isVoidElementName'; import validCalleeObjects from '../../utils/validCalleeObjects'; import reservedNames from '../../utils/reservedNames'; import fixAttributeCasing from '../../utils/fixAttributeCasing'; import quoteIfNecessary from '../../utils/quoteIfNecessary'; import mungeAttribute from './shared/mungeAttribute'; import Node from './shared/Node'; import Block from '../dom/Block'; import Attribute from './Attribute'; import Binding from './Binding'; import EventHandler from './EventHandler'; import Ref from './Ref'; import Transition from './Transition'; import Action from './Action'; import Text from './Text'; import * as namespaces from '../../utils/namespaces'; import mapChildren from './shared/mapChildren'; export default class Element extends Node { type: 'Element'; name: string; attributes: Attribute[]; bindings: Binding[]; handlers: EventHandler[]; intro: Transition; outro: Transition; children: Node[]; ref: string; namespace: string; constructor(compiler, parent, info: any) { super(compiler, parent, info); this.name = info.name; const parentElement = parent.findNearest(/^Element/); this.namespace = this.name === 'svg' ? namespaces.svg : parentElement ? parentElement.namespace : this.compiler.namespace; this.attributes = []; this.bindings = []; this.handlers = []; this.intro = null; this.outro = null; info.attributes.forEach(node => { switch (node.type) { case 'Attribute': // special case if (node.name === 'xmlns') this.namespace = node.value[0].data; this.attributes.push(new Attribute(compiler, this, node)); break; case 'Binding': this.bindings.push(new Binding(compiler, this, node)); break; case 'EventHandler': this.handlers.push(new EventHandler(compiler, this, node)); break; case 'Transition': const transition = new Transition(compiler, this, node); if (node.intro) this.intro = transition; if (node.outro) this.outro = transition; break; case 'Ref': // TODO catch this in validation if (this.ref) throw new Error(`Duplicate refs`); compiler.usesRefs = true this.ref = node.name; break; default: throw new Error(`Not implemented: ${node.type}`); } }); // TODO break out attributes and directives here this.children = mapChildren(compiler, this, info.children); } init( block: Block, stripWhitespace: boolean, nextSibling: Node ) { if (this.name === 'slot' || this.name === 'option') { this.cannotUseInnerHTML(); } this.attributes.forEach(attr => { if (attr.dependencies.size) { this.parent.cannotUseInnerHTML(); block.addDependencies(attr.dependencies); // special case — <option value={foo}> — see below if (this.name === 'option' && attr.name === 'value') { let select = this.parent; while (select && (select.type !== 'Element' || select.name !== 'select')) select = select.parent; if (select && select.selectBindingDependencies) { select.selectBindingDependencies.forEach(prop => { dependencies.forEach((dependency: string) => { this.compiler.indirectDependencies.get(prop).add(dependency); }); }); } } } }); this.bindings.forEach(binding => { this.cannotUseInnerHTML(); block.addDependencies(binding.value.dependencies); }); this.handlers.forEach(handler => { this.cannotUseInnerHTML(); block.addDependencies(handler.dependencies); }); if (this.intro) { this.compiler.hasIntroTransitions = block.hasIntroMethod = true; } if (this.outro) { this.compiler.hasOutroTransitions = block.hasOutroMethod = true; block.outros += 1; } this.attributes.forEach(attribute => { if (attribute.type === 'Attribute' && attribute.value !== true) { // removed } else { if (this.parent) this.parent.cannotUseInnerHTML(); if (attribute.type === 'Action' && attribute.expression) { block.addDependencies(attribute.metadata.dependencies); } else if (attribute.type === 'Spread') { block.addDependencies(attribute.metadata.dependencies); } } }); const valueAttribute = this.attributes.find((attribute: Attribute) => attribute.name === 'value'); if (this.name === 'textarea') { // this is an egregious hack, but it's the easiest way to get <textarea> // children treated the same way as a value attribute if (this.children.length > 0) { this.attributes.push(new Attribute(this.compiler, this, { name: 'value', value: this.children })); this.children = []; } } // special case — in a case like this... // // <select bind:value='foo'> // <option value='{{bar}}'>bar</option> // <option value='{{baz}}'>baz</option> // </option> // // ...we need to know that `foo` depends on `bar` and `baz`, // so that if `foo.qux` changes, we know that we need to // mark `bar` and `baz` as dirty too if (this.name === 'select') { const binding = this.attributes.find(node => node.type === 'Binding' && node.name === 'value'); if (binding) { // TODO does this also apply to e.g. `<input type='checkbox' bind:group='foo'>`? const dependencies = binding.metadata.dependencies; this.selectBindingDependencies = dependencies; dependencies.forEach((prop: string) => { this.compiler.indirectDependencies.set(prop, new Set()); }); } else { this.selectBindingDependencies = null; } } const slot = this.getStaticAttributeValue('slot'); if (slot && this.hasAncestor('Component')) { this.cannotUseInnerHTML(); this.slotted = true; // TODO validate slots — no nesting, no dynamic names... const component = this.findNearest(/^Component/); component._slots.add(slot); } if (this.spread) { block.addDependencies(this.spread.metadata.dependencies); } this.var = block.getUniqueName( this.name.replace(/[^a-zA-Z0-9_$]/g, '_') ); if (this.children.length) { if (this.name === 'pre' || this.name === 'textarea') stripWhitespace = false; this.initChildren(block, stripWhitespace, nextSibling); } } build( block: Block, parentNode: string, parentNodes: string ) { const { compiler } = this; if (this.name === 'slot') { const slotName = this.getStaticAttributeValue('name') || 'default'; this.compiler.slots.add(slotName); } if (this.name === 'noscript') return; const childState = { parentNode: this.var, parentNodes: parentNodes && block.getUniqueName(`${this.var}_nodes`) // if we're in unclaimable territory, i.e. <head>, parentNodes is null }; const name = this.var; const allUsedContexts: Set<string> = new Set(); const slot = this.attributes.find((attribute: Node) => attribute.name === 'slot'); const initialMountNode = this.slotted ? `${this.findNearest(/^Component/).var}._slotted.${slot.value[0].data}` : // TODO this looks bonkers parentNode; block.addVariable(name); const renderStatement = getRenderStatement(this.compiler, this.namespace, this.name); block.builders.create.addLine( `${name} = ${renderStatement};` ); if (this.compiler.hydratable) { if (parentNodes) { block.builders.claim.addBlock(deindent` ${name} = ${getClaimStatement(compiler, this.namespace, parentNodes, this)}; var ${childState.parentNodes} = @children(${name}); `); } else { block.builders.claim.addLine( `${name} = ${renderStatement};` ); } } if (initialMountNode) { block.builders.mount.addLine( `@appendNode(${name}, ${initialMountNode});` ); if (initialMountNode === 'document.head') { block.builders.unmount.addLine(`@detachNode(${name});`); } } else { block.builders.mount.addLine(`@insertNode(${name}, #target, anchor);`); // TODO we eventually need to consider what happens to elements // that belong to the same outgroup as an outroing element... block.builders.unmount.addLine(`@detachNode(${name});`); } // TODO move this into a class as well? if (this._cssRefAttribute) { block.builders.hydrate.addLine( `@setAttribute(${name}, "svelte-ref-${this._cssRefAttribute}", "");` ) } // insert static children with textContent or innerHTML if (!this.namespace && this.canUseInnerHTML && this.children.length > 0) { if (this.children.length === 1 && this.children[0].type === 'Text') { block.builders.create.addLine( `${name}.textContent = ${stringify(this.children[0].data)};` ); } else { block.builders.create.addLine( `${name}.innerHTML = ${stringify(this.children.map(toHTML).join(''))};` ); } } else { this.children.forEach((child: Node) => { child.build(block, childState.parentNode, childState.parentNodes); }); } this.addBindings(block, allUsedContexts); const eventHandlerUsesComponent = this.addEventHandlers(block, allUsedContexts); if (this.ref) this.addRef(block); this.addAttributes(block); this.addTransitions(block); this.addActions(block); if (allUsedContexts.size || eventHandlerUsesComponent) { const initialProps: string[] = []; const updates: string[] = []; if (eventHandlerUsesComponent) { initialProps.push(`component: #component`); } allUsedContexts.forEach((contextName: string) => { if (contextName === 'state') return; if (block.contextTypes.get(contextName) !== 'each') return; const listName = block.listNames.get(contextName); const indexName = block.indexNames.get(contextName); initialProps.push( `${listName}: state.${listName},\n${indexName}: state.${indexName}` ); updates.push( `${name}._svelte.${listName} = state.${listName};\n${name}._svelte.${indexName} = state.${indexName};` ); }); if (initialProps.length) { block.builders.hydrate.addBlock(deindent` ${name}._svelte = { ${initialProps.join(',\n')} }; `); } if (updates.length) { block.builders.update.addBlock(updates.join('\n')); } } if (this.initialUpdate) { block.builders.mount.addBlock(this.initialUpdate); } if (childState.parentNodes) { block.builders.claim.addLine( `${childState.parentNodes}.forEach(@detachNode);` ); } function toHTML(node: Element | Text) { if (node.type === 'Text') { return node.parent && node.parent.type === 'Element' && (node.parent.name === 'script' || node.parent.name === 'style') ? node.data : escapeHTML(node.data); } if (node.name === 'noscript') return ''; let open = `<${node.name}`; if (node._cssRefAttribute) { open += ` svelte-ref-${node._cssRefAttribute}`; } node.attributes.forEach((attr: Node) => { open += ` ${fixAttributeCasing(attr.name)}${stringifyAttributeValue(attr.value)}` }); if (isVoidElementName(node.name)) return open + '>'; return `${open}>${node.children.map(toHTML).join('')}</${node.name}>`; } } addBindings( block: Block, allUsedContexts: Set<string> ) { if (this.bindings.length === 0) return; if (this.name === 'select' || this.isMediaNode()) this.compiler.hasComplexBindings = true; const needsLock = this.name !== 'input' || !/radio|checkbox|range|color/.test(this.getStaticAttributeValue('type')); // TODO munge in constructor const mungedBindings = this.bindings.map(binding => binding.munge(block, allUsedContexts)); 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.compiler.hasComplexBindings = true; block.builders.hydrate.addLine( `if (!(${allInitialStateIsDefined})) #component.root._beforecreate.push(${handler});` ); } }); this.initialUpdate = mungedBindings.map(binding => binding.initialUpdate).filter(Boolean).join('\n'); } addAttributes(block: Block) { if (this.attributes.find(attr => attr.type === 'Spread')) { this.addSpreadAttributes(block); return; } this.attributes.forEach((attribute: Attribute) => { attribute.render(block); }); } addSpreadAttributes(block: Block) { const levels = block.getUniqueName(`${this.var}_levels`); const data = block.getUniqueName(`${this.var}_data`); const initialProps = []; const updates = []; this.attributes .filter(attr => attr.type === 'Attribute' || attr.type === 'Spread') .forEach(attr => { if (attr.type === 'Attribute') { const { dynamic, value, dependencies } = mungeAttribute(attr, block); const snippet = `{ ${quoteIfNecessary(attr.name)}: ${value} }`; initialProps.push(snippet); const condition = dependencies && dependencies.map(d => `changed.${d}`).join(' || '); updates.push(condition ? `${condition} && ${snippet}` : snippet); } else { block.contextualise(attr.expression); // TODO gah const { snippet, dependencies } = attr.metadata; initialProps.push(snippet); const condition = dependencies && dependencies.map(d => `changed.${d}`).join(' || '); updates.push(condition ? `${condition} && ${snippet}` : snippet); } }); block.builders.init.addBlock(deindent` var ${levels} = [ ${initialProps.join(',\n')} ]; var ${data} = {}; for (var #i = 0; #i < ${levels}.length; #i += 1) { ${data} = @assign(${data}, ${levels}[#i]); } `); block.builders.hydrate.addLine( `@setAttributes(${this.var}, ${data});` ); block.builders.update.addBlock(deindent` @setAttributes(${this.var}, @getSpreadUpdate(${levels}, [ ${updates.join(',\n')} ])); `); } addEventHandlers(block: Block, allUsedContexts) { const { compiler } = this; let eventHandlerUsesComponent = false; this.handlers.forEach(handler => { const isCustomEvent = compiler.events.has(handler.name); const shouldHoist = !isCustomEvent && this.hasAncestor('EachBlock'); const context = shouldHoist ? null : this.var; const usedContexts: string[] = []; if (handler.callee) { handler.render(this.compiler, block); if (!validCalleeObjects.has(handler.callee.name)) { if (shouldHoist) eventHandlerUsesComponent = true; // this feels a bit hacky but it works! } // handler.expression.arguments.forEach((arg: Node) => { // const { contexts } = block.contextualise(arg, context, true); // contexts.forEach(context => { // if (!~usedContexts.indexOf(context)) usedContexts.push(context); // allUsedContexts.add(context); // }); // }); } const ctx = context || 'this'; // get a name for the event handler that is globally unique // if hoisted, locally unique otherwise const handlerName = (shouldHoist ? compiler : block).getUniqueName( `${handler.name.replace(/[^a-zA-Z0-9_$]/g, '_')}_handler` ); const component = block.alias('component'); // can't use #component, might be hoisted // create the handler body const handlerBody = deindent` ${eventHandlerUsesComponent && `var #component = ${ctx}._svelte.component;`} ${handler.dependencies.size > 0 && `const ctx = #component.get();`} ${handler.snippet ? handler.snippet : `#component.fire("${handler.name}", event);`} `; if (isCustomEvent) { block.addVariable(handlerName); block.builders.hydrate.addBlock(deindent` ${handlerName} = %events-${handler.name}.call(#component, ${this.var}, function(event) { ${handlerBody} }); `); block.builders.destroy.addLine(deindent` ${handlerName}.destroy(); `); } else { const handlerFunction = deindent` function ${handlerName}(event) { ${handlerBody} } `; if (shouldHoist) { compiler.blocks.push(handlerFunction); } else { block.builders.init.addBlock(handlerFunction); } block.builders.hydrate.addLine( `@addListener(${this.var}, "${handler.name}", ${handlerName});` ); block.builders.destroy.addLine( `@removeListener(${this.var}, "${handler.name}", ${handlerName});` ); } }); return eventHandlerUsesComponent; } addRef(block: Block) { const ref = `#component.refs.${this.ref}`; block.builders.mount.addLine( `${ref} = ${this.var};` ); block.builders.destroy.addLine( `if (${ref} === ${this.var}) ${ref} = null;` ); } addTransitions( block: Block ) { const { intro, outro } = this; if (!intro && !outro) return; if (intro === outro) { const name = block.getUniqueName(`${this.var}_transition`); const snippet = intro.expression ? intro.expression.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.addVariable(introName); const snippet = intro.expression ? intro.expression.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.addVariable(outroName); const snippet = outro.expression ? outro.expression.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(); }); `); } } } addActions(block: Block) { this.attributes.filter((a: Action) => a.type === 'Action').forEach((attribute:Action) => { const { expression } = attribute; let snippet, dependencies; if (expression) { this.compiler.addSourcemapLocations(expression); block.contextualise(expression); snippet = attribute.metadata.snippet; dependencies = attribute.metadata.dependencies; } const name = block.getUniqueName( `${attribute.name.replace(/[^a-zA-Z0-9_$]/g, '_')}_action` ); block.addVariable(name); const fn = `%actions-${attribute.name}`; block.builders.hydrate.addLine( `${name} = ${fn}.call(#component, ${this.var}${snippet ? `, ${snippet}` : ''}) || {};` ); if (dependencies && dependencies.length) { let conditional = `typeof ${name}.update === 'function' && `; const deps = dependencies.map(dependency => `changed.${dependency}`).join(' || '); conditional += dependencies.length > 1 ? `(${deps})` : deps; block.builders.update.addConditional( conditional, `${name}.update.call(#component, ${snippet});` ); } block.builders.destroy.addLine( `if (typeof ${name}.destroy === 'function') ${name}.destroy.call(#component);` ); }); } getStaticAttributeValue(name: string) { const attribute = this.attributes.find( (attr: Attribute) => attr.type === 'Attribute' && attr.name.toLowerCase() === name ); if (!attribute) return null; if (attribute.value === true) return true; if (attribute.value.length === 0) return ''; if (attribute.value.length === 1 && attribute.value[0].type === 'Text') { return attribute.value[0].data; } return null; } isMediaNode() { return this.name === 'audio' || this.name === 'video'; } remount(name: string) { const slot = this.attributes.find(attribute => attribute.name === 'slot'); if (slot) { return `@appendNode(${this.var}, ${name}._slotted.${this.getStaticAttributeValue('slot')});`; } return `@appendNode(${this.var}, ${name}._slotted.default);`; } addCssClass() { const classAttribute = this.attributes.find(a => a.name === 'class'); if (classAttribute && classAttribute.value !== true) { if (classAttribute.value.length === 1 && classAttribute.value[0].type === 'Text') { classAttribute.value[0].data += ` ${this.compiler.stylesheet.id}`; } else { (<Node[]>classAttribute.value).push( new Node({ type: 'Text', data: ` ${this.compiler.stylesheet.id}` }) ); } } else { this.attributes.push( new Attribute({ compiler: this.compiler, name: 'class', value: [new Node({ type: 'Text', data: `${this.compiler.stylesheet.id}` })], parent: this, }) ); } } } function getRenderStatement( compiler: DomGenerator, namespace: string, name: string ) { if (namespace === 'http://www.w3.org/2000/svg') { return `@createSvgElement("${name}")`; } if (namespace) { return `document.createElementNS("${namespace}", "${name}")`; } return `@createElement("${name}")`; } function getClaimStatement( compiler: DomGenerator, namespace: string, nodes: string, node: Node ) { const attributes = node.attributes .filter((attr: Node) => attr.type === 'Attribute') .map((attr: Node) => `${quoteIfNecessary(attr.name)}: true`) .join(', '); const name = namespace ? node.name : node.name.toUpperCase(); return `@claimElement(${nodes}, "${name}", ${attributes ? `{ ${attributes} }` : `{}`}, ${namespace === namespaces.svg ? true : false})`; } function stringifyAttributeValue(value: Node | true) { if (value === true) return ''; if (value.length === 0) return `=""`; const data = value[0].data; return `=${JSON.stringify(data)}`; } 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') }, { eventNames: ['volumechange'], filter: (node: Element, name: string) => node.isMediaNode() && name === 'volume' } ];