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 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 Text from './Text';
import * as namespaces from '../../utils/namespaces';
export default class Element extends Node {
type: 'Element';
name: string;
attributes: (Attribute | Binding | EventHandler | Ref | Transition)[]; // TODO split these up sooner
children: Node[];
init(
block: Block,
stripWhitespace: boolean,
nextSibling: Node
) {
if (this.name === 'slot' || this.name === 'option') {
this.cannotUseInnerHTML();
}
const parentElement = this.parent && this.parent.findNearest(/^Element/);
this.namespace = this.name === 'svg' ?
namespaces.svg :
parentElement ? parentElement.namespace : this.generator.namespace;
this.attributes.forEach(attribute => {
if (attribute.type === 'Attribute' && attribute.value !== true) {
// special case — xmlns
if (attribute.name === 'xmlns') {
// TODO this attribute must be static – enforce at compile time
this.namespace = attribute.value[0].data;
}
attribute.value.forEach((chunk: Node) => {
if (chunk.type !== 'Text') {
if (this.parent) this.parent.cannotUseInnerHTML();
const dependencies = chunk.metadata.dependencies;
block.addDependencies(dependencies);
// special case —
//
// ...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. ``?
const dependencies = binding.metadata.dependencies;
this.selectBindingDependencies = dependencies;
dependencies.forEach((prop: string) => {
this.generator.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);
}
this.var = block.getUniqueName(
this.name.replace(/[^a-zA-Z0-9_$]/g, '_')
);
this.generator.stylesheet.apply(this);
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 { generator } = this;
if (this.name === 'slot') {
const slotName = this.getStaticAttributeValue('name') || 'default';
this.generator.slots.add(slotName);
}
const childState = {
parentNode: this.var,
parentNodes: parentNodes && block.getUniqueName(`${this.var}_nodes`) // if we're in unclaimable territory, i.e.
, parentNodes is null
};
const name = this.var;
const allUsedContexts: Set = 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.generator, this.namespace, this.name);
block.builders.create.addLine(
`${name} = ${renderStatement};`
);
if (this.generator.hydratable) {
if (parentNodes) {
block.builders.claim.addBlock(deindent`
${name} = ${getClaimStatement(generator, 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});`);
}
// add CSS encapsulation attribute
if (this._needsCssAttribute && !this.generator.customElement) {
this.generator.needsEncapsulateHelper = true;
block.builders.hydrate.addLine(
`@encapsulateStyles(${name});`
);
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);
this.attributes.filter((a: Attribute) => a.type === 'Attribute').forEach((attribute: Attribute) => {
attribute.render(block);
});
// event handlers
let eventHandlerUsesComponent = false;
this.attributes.filter((a: Node) => a.type === 'EventHandler').forEach((attribute: Node) => {
const isCustomEvent = generator.events.has(attribute.name);
const shouldHoist = !isCustomEvent && this.hasAncestor('EachBlock');
const context = shouldHoist ? null : name;
const usedContexts: string[] = [];
if (attribute.expression) {
generator.addSourcemapLocations(attribute.expression);
const flattened = flattenReference(attribute.expression.callee);
if (!validCalleeObjects.has(flattened.name)) {
// allow event.stopPropagation(), this.select() etc
// TODO verify that it's a valid callee (i.e. built-in or declared method)
generator.code.prependRight(
attribute.expression.start,
`${block.alias('component')}.`
);
if (shouldHoist) eventHandlerUsesComponent = true; // this feels a bit hacky but it works!
}
attribute.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';
const declarations = usedContexts
.map(name => {
if (name === 'state') {
if (shouldHoist) eventHandlerUsesComponent = true;
return `var state = ${block.alias('component')}.get();`;
}
const contextType = block.contextTypes.get(name);
if (contextType === 'each') {
const listName = block.listNames.get(name);
const indexName = block.indexNames.get(name);
const contextName = block.contexts.get(name);
return `var ${listName} = ${ctx}._svelte.${listName}, ${indexName} = ${ctx}._svelte.${indexName}, ${contextName} = ${listName}[${indexName}];`;
}
})
.filter(Boolean);
// get a name for the event handler that is globally unique
// if hoisted, locally unique otherwise
const handlerName = (shouldHoist ? generator : block).getUniqueName(
`${attribute.name.replace(/[^a-zA-Z0-9_$]/g, '_')}_handler`
);
// create the handler body
const handlerBody = deindent`
${eventHandlerUsesComponent &&
`var ${block.alias('component')} = ${ctx}._svelte.component;`}
${declarations}
${attribute.expression ?
`[✂${attribute.expression.start}-${attribute.expression.end}✂];` :
`${block.alias('component')}.fire("${attribute.name}", event);`}
`;
if (isCustomEvent) {
block.addVariable(handlerName);
block.builders.hydrate.addBlock(deindent`
${handlerName} = %events-${attribute.name}.call(#component, ${name}, function(event) {
${handlerBody}
});
`);
block.builders.destroy.addLine(deindent`
${handlerName}.teardown();
`);
} else {
const handler = deindent`
function ${handlerName}(event) {
${handlerBody}
}
`;
if (shouldHoist) {
generator.blocks.push(handler);
} else {
block.builders.init.addBlock(handler);
}
block.builders.hydrate.addLine(
`@addListener(${name}, "${attribute.name}", ${handlerName});`
);
block.builders.destroy.addLine(
`@removeListener(${name}, "${attribute.name}", ${handlerName});`
);
}
});
// refs
this.attributes.filter((a: Node) => a.type === 'Ref').forEach((attribute: Node) => {
const ref = `#component.refs.${attribute.name}`;
block.builders.mount.addLine(
`${ref} = ${name};`
);
block.builders.destroy.addLine(
`if (${ref} === ${name}) ${ref} = null;`
);
generator.usesRefs = true; // so component.refs object is created
});
this.addTransitions(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}: ${listName},\n${indexName}: ${indexName}`
);
updates.push(
`${name}._svelte.${listName} = ${listName};\n${name}._svelte.${indexName} = ${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 escapeHTML(node.data);
let open = `<${node.name}`;
if (node._needsCssAttribute) {
open += ` ${generator.stylesheet.id}`;
}
if (node._cssRefAttribute) {
open += ` svelte-ref-${node._cssRefAttribute}`;
}
node.attributes.forEach((attr: Node) => {
open += ` ${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
) {
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, 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.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
);
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';
}
}
function getRenderStatement(
generator: 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(
generator: DomGenerator,
namespace: string,
nodes: string,
node: Node
) {
const attributes = node.attributes
.filter((attr: Node) => attr.type === 'Attribute')
.map((attr: Node) => `${quoteProp(attr.name, generator.legacy)}: true`)
.join(', ');
const name = namespace ? node.name : node.name.toUpperCase();
return `@claimElement(${nodes}, "${name}", ${attributes
? `{ ${attributes} }`
: `{}`}, ${namespace === namespaces.svg ? true : false})`;
}
function quoteProp(name: string, legacy: boolean) {
const isLegacyPropName = legacy && reservedNames.has(name);
if (/[^a-zA-Z_$0-9]/.test(name) || isLegacyPropName) return `"${name}"`;
return name;
}
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')
}
];