svelte/src/compile/render-dom/wrappers/Element/Binding.ts

296 lines
8.0 KiB

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