pull/922/head
Rich Harris 8 years ago
parent 64e68033d9
commit 85f98704a3

@ -9,6 +9,11 @@ import getObject from '../../../../utils/getObject';
import getTailSnippet from '../../../../utils/getTailSnippet'; import getTailSnippet from '../../../../utils/getTailSnippet';
import visitBinding from './Binding'; import visitBinding from './Binding';
import { generateRule } from '../../../../shared/index'; import { generateRule } from '../../../../shared/index';
import flatten from '../../../../utils/flattenReference';
interface Binding {
name: string;
}
const types: Record<string, ( const types: Record<string, (
generator: DomGenerator, generator: DomGenerator,
@ -31,6 +36,57 @@ const readOnlyMediaAttributes = new Set([
'played' 'played'
]); ]);
function isMediaNode(node: Node) {
return node.name === 'audio' || node.name === 'video';
}
const events = [
{
name: 'input',
filter: (node: Node, binding: Binding) =>
node.name === 'textarea' ||
node.name === 'input' && !/radio|checkbox/.test(getStaticAttributeValue(node, 'type'))
},
{
name: 'change',
filter: (node: Node, binding: Binding) =>
node.name === 'select' ||
node.name === 'input' && /radio|checkbox|range/.test(getStaticAttributeValue(node, 'type'))
},
// media events
{
name: 'timeupdate',
filter: (node: Node, binding: Binding) =>
isMediaNode(node.name) &&
(binding.name === 'currentTime' || binding.name === 'played')
},
{
name: 'durationchange',
filter: (node: Node, binding: Binding) =>
isMediaNode(node.name) &&
binding.name === 'duration'
},
{
name: 'pause',
filter: (node: Node, binding: Binding) =>
isMediaNode(node.name) &&
binding.name === 'paused'
},
{
name: 'progress',
filter: (node: Node, binding: Binding) =>
isMediaNode(node.name) &&
binding.name === 'buffered'
},
{
name: 'loadedmetadata',
filter: (node: Node, binding: Binding) =>
isMediaNode(node.name) &&
(binding.name === 'buffered' || binding.name === 'seekable')
}
];
export default function addBindings( export default function addBindings(
generator: DomGenerator, generator: DomGenerator,
block: Block, block: Block,
@ -40,21 +96,14 @@ export default function addBindings(
const bindings: Node[] = node.attributes.filter((a: Node) => a.type === 'Binding'); const bindings: Node[] = node.attributes.filter((a: Node) => a.type === 'Binding');
if (bindings.length === 0) return; if (bindings.length === 0) return;
types[node.name](generator, block, state, node, bindings); if (node.name === 'select' || isMediaNode(node.name)) generator.hasComplexBindings = true;
}
function addInputBinding( const mungedBindings = bindings.map(binding => {
generator: DomGenerator, const needsLock = true; // TODO
block: Block,
state: State,
node: Node,
bindings: Node[]
) {
const attribute = bindings[0];
const { name } = getObject(attribute.value); const { name } = getObject(binding.value);
const { snippet, contexts, dependencies } = block.contextualise( const { snippet, contexts, dependencies } = block.contextualise(
attribute.value binding.value
); );
contexts.forEach(context => { contexts.forEach(context => {
@ -62,46 +111,38 @@ function addInputBinding(
state.allUsedContexts.push(context); state.allUsedContexts.push(context);
}); });
const type = getStaticAttributeValue(node, 'type'); // view to model
const eventName = type === 'radio' || type === 'checkbox' ? 'change' : 'input'; // TODO tidy this up
const valueFromDom = getBindingValue(
const handler = block.getUniqueName( generator,
`${node.var}_${eventName}_handler` block,
node._state,
node,
binding,
node.name === 'select' && getStaticAttributeValue(node, 'multiple') === true,
isMediaNode(node.name),
binding.name === 'group' ? getBindingGroup(generator, binding.value) : null,
getStaticAttributeValue(node, 'type')
); );
const bindingGroup = attribute.name === 'group' const setter = getSetter(
? getBindingGroup(generator, attribute.value) generator,
: null; block,
name,
const value = ( snippet,
attribute.name === 'group' ? node,
(type === 'checkbox' ? `@getBindingGroupValue(#component._bindingGroups[${bindingGroup}])` : `${node.var}.__value`) : binding,
(type === 'range' || type === 'number') ? dependencies,
`@toNumber(${node.var}.${attribute.name})` : valueFromDom
`${node.var}.${attribute.name}`
); );
let setter = getSetter(generator, block, name, snippet, node, attribute, dependencies, value); // model to view
let updateElement = `${node.var}.${attribute.name} = ${snippet};`; const update = getUpdater(node, binding, snippet);
block.builders.update.addLine(update);
const needsLock = !/radio|checkbox|range|color/.test(type); // TODO others?
const lock = `#${node.var}_updating`;
let updateConditions = needsLock ? [`!${lock}`] : [];
if (needsLock) block.addVariable(lock, 'false');
if (attribute.name === 'group') {
// <input type='checkbox|radio' bind:group='selected'> special case
if (type === 'radio') {
setter = deindent`
if (!${node.var}.checked) return;
${setter}
`;
}
const condition = type === 'checkbox' // special cases
? `~${snippet}.indexOf(${node.var}.__value)` if (binding.name === 'group') {
: `${node.var}.__value === ${snippet}`; const bindingGroup = getBindingGroup(generator, binding.value);
block.builders.hydrate.addLine( block.builders.hydrate.addLine(
`#component._bindingGroups[${bindingGroup}].push(${node.var});` `#component._bindingGroups[${bindingGroup}].push(${node.var});`
@ -110,234 +151,84 @@ function addInputBinding(
block.builders.destroy.addBlock( block.builders.destroy.addBlock(
`#component._bindingGroups[${bindingGroup}].splice(#component._bindingGroups[${bindingGroup}].indexOf(${node.var}), 1);` `#component._bindingGroups[${bindingGroup}].splice(#component._bindingGroups[${bindingGroup}].indexOf(${node.var}), 1);`
); );
updateElement = `${node.var}.checked = ${condition};`;
} }
block.builders.init.addBlock(deindent` return {
function ${handler}() { name: binding.name,
${needsLock && `${lock} = true;`} object: name,
${setter} setter,
${needsLock && `${lock} = false;`} update,
} needsLock
`); };
if (type === 'range') {
// need to bind to `input` and `change`, for the benefit of IE
block.builders.hydrate.addBlock(deindent`
@addListener(${node.var}, "input", ${handler});
@addListener(${node.var}, "change", ${handler});
`);
block.builders.destroy.addBlock(deindent`
@removeListener(${node.var}, "input", ${handler});
@removeListener(${node.var}, "change", ${handler});
`);
} else {
block.builders.hydrate.addLine(
`@addListener(${node.var}, "${eventName}", ${handler});`
);
block.builders.destroy.addLine(
`@removeListener(${node.var}, "${eventName}", ${handler});`
);
}
block.builders.update.addBlock(
needsLock ?
`if (!${lock}) ${updateElement}` :
updateElement
);
node.initialUpdate = updateElement;
}
function addSelectBinding(
generator: DomGenerator,
block: Block,
state: State,
node: Node,
bindings: Node[]
) {
const attribute = bindings[0];
const { name } = getObject(attribute.value);
const { snippet, contexts, dependencies } = block.contextualise(
attribute.value
);
contexts.forEach(context => {
if (!~state.allUsedContexts.indexOf(context))
state.allUsedContexts.push(context);
}); });
const lock = `#${node.var}_updating`; const groups = events
block.addVariable(lock, 'false'); .map(event => {
return {
const handler = block.getUniqueName( name: event.name,
`${node.var}_change_handler` bindings: mungedBindings.filter(binding => event.filter(node, binding))
); };
})
const isMultipleSelect = getStaticAttributeValue(node, 'multiple') === true; .filter(group => group.bindings.length);
// view to model
const value = isMultipleSelect ?
`[].map.call(${node.var}.querySelectorAll(':checked'), function(option) { return option.__value; })` :
`selectedOption && selectedOption.__value`;
let setter = getSetter(generator, block, name, snippet, node, attribute, dependencies, value); groups.forEach(group => {
const handler = block.getUniqueName(`${node.var}_${group.name}_handler`);
if (!isMultipleSelect) { const needsLock = group.bindings.some(binding => binding.needsLock);
setter = deindent`
var selectedOption = ${node.var}.querySelector(':checked') || ${node.var}.options[0];
${setter}`;
}
generator.hasComplexBindings = true; const lock = needsLock ? block.getUniqueName(`${node.var}_updating`) : null;
block.builders.hydrate.addBlock( if (needsLock) block.addVariable(lock, 'false');
`if (!('${name}' in state)) #component._root._beforecreate.push(${handler});`
);
block.builders.init.addBlock(deindent` block.builders.init.addBlock(deindent`
function ${handler}() { function ${handler}() {
${lock} = true; ${needsLock && `${lock} = true;`}
${setter} ${group.bindings.map(binding => binding.setter)}
${lock} = false; ${needsLock && `${lock} = false;`}
} }
`); `);
block.builders.hydrate.addLine( block.builders.hydrate.addLine(
`@addListener(${node.var}, "change", ${handler});` `@addListener(${node.var}, "${group.name}", ${handler});`
); );
block.builders.destroy.addLine( block.builders.destroy.addLine(
`@removeListener(${node.var}, "change", ${handler});` `@removeListener(${node.var}, "${group.name}", ${handler});`
); );
// model to view const allInitialStateIsDefined = group.bindings
const updateElement = isMultipleSelect ? .map(binding => `'${binding.object}' in state`)
`@selectOptions(${node.var}, ${snippet});` : .join(' && ');
`@selectOption(${node.var}, ${snippet});`;
block.builders.update.addLine( generator.hasComplexBindings = true;
`if (!${lock}) ${updateElement}`
block.builders.hydrate.addBlock(
`if (!(${allInitialStateIsDefined})) #component._root._beforecreate.push(${handler});`
); );
});
node.initialUpdate = updateElement; node.initialUpdate = mungedBindings.map(binding => binding.update).join('\n');
} }
function addMediaBinding( function getUpdater(
generator: DomGenerator,
block: Block,
state: State,
node: Node, node: Node,
bindings: Node[] binding: Node,
snippet: string
) { ) {
const attribute = bindings[0]; if (binding.name === 'group') {
const type = getStaticAttributeValue(node, 'type');
const { name } = getObject(attribute.value);
const { snippet, contexts, dependencies } = block.contextualise(
attribute.value
);
contexts.forEach(context => {
if (!~state.allUsedContexts.indexOf(context))
state.allUsedContexts.push(context);
});
const eventNames = getBindingEventName(node, attribute);
const handler = block.getUniqueName(
`${node.var}_${eventNames.join('_')}_handler`
);
const isReadOnly = readOnlyMediaAttributes.has(attribute.name)
const value = (attribute.name === 'buffered' || attribute.name === 'seekable' || attribute.name === 'played') ?
`@timeRangesToArray(${node.var}.${attribute.name})` :
`${node.var}.${attribute.name}`;
let setter = getSetter(generator, block, name, snippet, node, attribute, dependencies, value);
let updateElement = `${node.var}.${attribute.name} = ${snippet};`;
const needsLock = !isReadOnly;
const lock = `#${node.var}_updating`;
let updateConditions = needsLock ? [`!${lock}`] : [];
if (needsLock) block.addVariable(lock, 'false');
generator.hasComplexBindings = true;
block.builders.hydrate.addBlock(`#component._root._beforecreate.push(${handler});`);
if (attribute.name === 'currentTime') {
const frame = block.getUniqueName(`${node.var}_animationframe`);
block.addVariable(frame);
setter = deindent`
cancelAnimationFrame(${frame});
if (!${node.var}.paused) ${frame} = requestAnimationFrame(${handler});
${setter}
`;
updateConditions.push(`!isNaN(${snippet})`);
} else if (attribute.name === 'paused') {
// this is necessary to prevent the audio restarting by itself
const last = block.getUniqueName(`${node.var}_paused_value`);
block.addVariable(last, 'true');
updateConditions = [`${last} !== (${last} = ${snippet})`]; const condition = type === 'checkbox'
updateElement = `${node.var}[${last} ? "pause" : "play"]();`; ? `~${snippet}.indexOf(${node.var}.__value)`
} : `${node.var}.__value === ${snippet}`;
block.builders.init.addBlock(deindent` return `${node.var}.checked = ${condition};`
function ${handler}() {
${needsLock && `${lock} = true;`}
${setter}
${needsLock && `${lock} = false;`}
} }
`);
eventNames.forEach(eventName => {
block.builders.hydrate.addLine(
`@addListener(${node.var}, "${eventName}", ${handler});`
);
block.builders.destroy.addLine( if (binding.name === 'checked') {
`@removeListener(${node.var}, "${eventName}", ${handler});`
);
});
if (!isReadOnly) { // audio/video duration is read-only, it never updates
if (updateConditions.length) {
block.builders.update.addBlock(deindent`
if (${updateConditions.join(' && ')}) {
${updateElement}
}
`);
} else {
block.builders.update.addBlock(deindent`
${updateElement}
`);
}
} }
if (attribute.name === 'paused') { return `${node.var}.${binding.name} = ${snippet};`;
block.builders.create.addLine(
`@addListener(${node.var}, "play", ${handler});`
);
block.builders.destroy.addLine(
`@removeListener(${node.var}, "play", ${handler});`
);
}
}
function getBindingEventName(node: Node, attribute: Node) {
if (attribute.name === 'currentTime') return ['timeupdate'];
if (attribute.name === 'duration') return ['durationchange'];
if (attribute.name === 'paused') return ['pause'];
if (attribute.name === 'buffered') return ['progress', 'loadedmetadata'];
if (attribute.name === 'seekable') return ['loadedmetadata'];
if (attribute.name === 'played') return ['timeupdate'];
return ['change'];
} }
function getBindingGroup(generator: DomGenerator, value: Node) { function getBindingGroup(generator: DomGenerator, value: Node) {
@ -405,6 +296,51 @@ function getSetter(
return `#component.set({ ${name}: ${value} });`; return `#component.set({ ${name}: ${value} });`;
} }
function getBindingValue(
generator: DomGenerator,
block: Block,
state: State,
node: Node,
attribute: Node,
isMultipleSelect: boolean,
isMediaElement: boolean,
bindingGroup: number,
type: string
) {
// <select multiple bind:value='selected>
if (isMultipleSelect) {
return `@selectMultipleValue(${node.var})`;
// return `[].map.call(${node.var}.querySelectorAll(':checked'), function(option) { return option.__value; })`;
}
// <select bind:value='selected>
if (node.name === 'select') {
// return 'selectedOption && selectedOption.__value';
return `@selectValue(${node.var})`;
}
// <input type='checkbox' bind:group='foo'>
if (attribute.name === 'group') {
if (type === 'checkbox') {
return `@getBindingGroupValue(#component._bindingGroups[${bindingGroup}])`;
}
return `${node.var}.__value`;
}
// <input type='range|number' bind:value>
if (type === 'range' || type === 'number') {
return `@toNumber(${node.var}.${attribute.name})`;
}
if (isMediaElement && (attribute.name === 'buffered' || attribute.name === 'seekable' || attribute.name === 'played')) {
return `@timeRangesToArray(${node.var}.${attribute.name})`
}
// everything else
return `${node.var}.${attribute.name}`;
}
function isComputed(node: Node) { function isComputed(node: Node) {
while (node.type === 'MemberExpression') { while (node.type === 'MemberExpression') {
if (node.computed) return true; if (node.computed) return true;

@ -167,3 +167,14 @@ export function selectOptions(select, value) {
option.selected = ~value.indexOf(option.__value); option.selected = ~value.indexOf(option.__value);
} }
} }
export function selectValue(select) {
var selectedOption = select.querySelector(':checked') || select.options[0];
return selectedOption && selectedOption.__value;
}
export function selectMultipleValue(select) {
return [].map.call(select.querySelectorAll(':checked'), function(option) {
return option.__value;
});
}

@ -1,5 +1,5 @@
export default { export default {
// solo: true, 'skip-ssr': true,
data: { data: {
indeterminate: true, indeterminate: true,
@ -13,7 +13,8 @@ export default {
test(assert, component, target, window) { test(assert, component, target, window) {
const input = target.querySelector('input'); const input = target.querySelector('input');
assert.equal(input.checked, true); assert.equal(input.checked, false);
assert.equal(input.indeterminate, true);
const event = new window.Event('change'); const event = new window.Event('change');

Loading…
Cancel
Save