modules/ui/fields/combo.js (351 lines of code) (raw):
import { dispatch as d3_dispatch } from 'd3-dispatch';
import { event as d3_event, select as d3_select } from 'd3-selection';
import * as countryCoder from 'country-coder';
import { osmEntity } from '../../osm/entity';
import { t } from '../../util/locale';
import { services } from '../../services';
import { uiCombobox } from '../combobox';
import { utilArrayUniq, utilGetSetValue, utilNoAuto, utilRebind } from '../../util';
export {
uiFieldCombo as uiFieldMultiCombo,
uiFieldCombo as uiFieldNetworkCombo,
uiFieldCombo as uiFieldSemiCombo,
uiFieldCombo as uiFieldTypeCombo
};
export function uiFieldCombo(field, context) {
var dispatch = d3_dispatch('change');
var taginfo = services.taginfo;
var isMulti = (field.type === 'multiCombo');
var isNetwork = (field.type === 'networkCombo');
var isSemi = (field.type === 'semiCombo');
var optstrings = field.strings && field.strings.options;
var optarray = field.options;
var snake_case = (field.snake_case || (field.snake_case === undefined));
var caseSensitive = field.caseSensitive;
var combobox = uiCombobox(context, 'combo-' + field.safeid)
.caseSensitive(caseSensitive)
.minItems(isMulti || isSemi ? 1 : 2);
var container = d3_select(null);
var inputWrap = d3_select(null);
var input = d3_select(null);
var _comboData = [];
var _multiData = [];
var _entity;
var _countryCode;
// ensure multiCombo field.key ends with a ':'
if (isMulti && /[^:]$/.test(field.key)) {
field.key += ':';
}
function snake(s) {
return s.replace(/\s+/g, '_');
}
function unsnake(s) {
return s.replace(/_+/g, ' ');
}
function clean(s) {
return s.split(';')
.map(function(s) { return s.trim(); })
.join(';');
}
// returns the tag value for a display value
// (for multiCombo, dval should be the key suffix, not the entire key)
function tagValue(dval) {
dval = clean(dval || '');
if (optstrings) {
var found = _comboData.find(function(o) {
return o.key && clean(o.value) === dval;
});
if (found) {
return found.key;
}
}
if (field.type === 'typeCombo' && !dval) {
return 'yes';
}
return (snake_case ? snake(dval) : dval) || undefined;
}
// returns the display value for a tag value
// (for multiCombo, tval should be the key suffix, not the entire key)
function displayValue(tval) {
tval = tval || '';
if (optstrings) {
var found = _comboData.find(function(o) {
return o.key === tval && o.value;
});
if (found) {
return found.value;
}
}
if (field.type === 'typeCombo' && tval.toLowerCase() === 'yes') {
return '';
}
return snake_case ? unsnake(tval) : tval;
}
// Compute the difference between arrays of objects by `value` property
//
// objectDifference([{value:1}, {value:2}, {value:3}], [{value:2}])
// > [{value:1}, {value:3}]
//
function objectDifference(a, b) {
return a.filter(function(d1) {
return !b.some(function(d2) { return d1.value === d2.value; });
});
}
function initCombo(selection, attachTo) {
if (optstrings) {
selection.attr('readonly', 'readonly');
selection.call(combobox, attachTo);
setStaticValues(setPlaceholder);
} else if (optarray) {
selection.call(combobox, attachTo);
setStaticValues(setPlaceholder);
} else if (taginfo) {
selection.call(combobox.fetcher(setTaginfoValues), attachTo);
setTaginfoValues('', setPlaceholder);
}
}
function setStaticValues(callback) {
if (!(optstrings || optarray)) return;
if (optstrings) {
_comboData = Object.keys(optstrings).map(function(k) {
var v = field.t('options.' + k, { 'default': optstrings[k] });
return {
key: k,
value: v,
title: v
};
});
} else if (optarray) {
_comboData = optarray.map(function(k) {
var v = snake_case ? unsnake(k) : k;
return {
key: k,
value: v,
title: v
};
});
}
combobox.data(objectDifference(_comboData, _multiData));
if (callback) callback(_comboData);
}
function setTaginfoValues(q, callback) {
var fn = isMulti ? 'multikeys' : 'values';
var query = (isMulti ? field.key : '') + q;
var hasCountryPrefix = isNetwork && _countryCode && _countryCode.indexOf(q.toLowerCase()) === 0;
if (hasCountryPrefix) {
query = _countryCode + ':';
}
var params = {
debounce: (q !== ''),
key: field.key,
query: query
};
if (_entity) {
params.geometry = context.geometry(_entity.id);
}
taginfo[fn](params, function(err, data) {
if (err) return;
var deprecatedValues = osmEntity.deprecatedTagValuesByKey()[field.key];
if (deprecatedValues) {
// don't suggest deprecated tag values
data = data.filter(function(d) {
return deprecatedValues.indexOf(d.value) === -1;
});
}
if (hasCountryPrefix) {
data = data.filter(function(d) {
return d.value.toLowerCase().indexOf(_countryCode + ':') === 0;
});
}
// hide the caret if there are no suggestions
container.classed('empty-combobox', data.length === 0);
_comboData = data.map(function(d) {
var k = d.value;
if (isMulti) k = k.replace(field.key, '');
var v = snake_case ? unsnake(k) : k;
return {
key: k,
value: v,
title: isMulti ? v : d.title
};
});
_comboData = objectDifference(_comboData, _multiData);
if (callback) callback(_comboData);
});
}
function setPlaceholder(values) {
var ph;
if (isMulti || isSemi) {
ph = field.placeholder() || t('inspector.add');
} else {
var vals = values
.map(function(d) { return d.value; })
.filter(function(s) { return s.length < 20; });
var placeholders = vals.length > 1 ? vals : values.map(function(d) { return d.key; });
ph = field.placeholder() || placeholders.slice(0, 3).join(', ');
}
if (!/(…|\.\.\.)$/.test(ph)) {
ph += '…';
}
container.selectAll('input')
.attr('placeholder', ph);
}
function change() {
var t = {};
var val;
if (isMulti || isSemi) {
val = tagValue(utilGetSetValue(input).replace(/,/g, ';')) || '';
container.classed('active', false);
utilGetSetValue(input, '');
var vals = val.split(';').filter(Boolean);
if (!vals.length) return;
if (isMulti) {
utilArrayUniq(vals).forEach(function(v) {
var key = field.key + v;
if (_entity) {
// don't set a multicombo value to 'yes' if it already has a non-'no' value
// e.g. `language:de=main`
var old = _entity.tags[key] || '';
if (old && old.toLowerCase() !== 'no') return;
}
field.keys.push(key);
t[key] = 'yes';
});
} else if (isSemi) {
var arr = _multiData.map(function(d) { return d.key; });
arr = arr.concat(vals);
t[field.key] = utilArrayUniq(arr).filter(Boolean).join(';');
}
window.setTimeout(function() { input.node().focus(); }, 10);
} else {
val = tagValue(utilGetSetValue(input));
t[field.key] = val;
}
dispatch.call('change', this, t);
}
function removeMultikey(d) {
d3_event.stopPropagation();
var t = {};
if (isMulti) {
t[d.key] = undefined;
} else if (isSemi) {
var arr = _multiData.map(function(md) {
return md.key === d.key ? null : md.key;
}).filter(Boolean);
arr = utilArrayUniq(arr);
t[field.key] = arr.length ? arr.join(';') : undefined;
}
dispatch.call('change', this, t);
}
function combo(selection) {
container = selection.selectAll('.form-field-input-wrap')
.data([0]);
var type = (isMulti || isSemi) ? 'multicombo': 'combo';
container = container.enter()
.append('div')
.attr('class', 'form-field-input-wrap form-field-input-' + type)
.merge(container);
if (isMulti || isSemi) {
container = container.selectAll('.chiplist')
.data([0]);
var listClass = 'chiplist';
// Use a separate line for each value in the Destinations field
// to mimic highway exit signs
if (field.id === 'destination_oneway') {
listClass += ' full-line-chips';
}
container = container.enter()
.append('ul')
.attr('class', listClass)
.on('click', function() {
window.setTimeout(function() { input.node().focus(); }, 10);
})
.merge(container);
inputWrap = container.selectAll('.input-wrap')
.data([0]);
inputWrap = inputWrap.enter()
.append('li')
.attr('class', 'input-wrap')
.merge(inputWrap);
input = inputWrap.selectAll('input')
.data([0]);
} else {
input = container.selectAll('input')
.data([0]);
}
input = input.enter()
.append('input')
.attr('type', 'text')
.attr('id', 'preset-input-' + field.safeid)
.call(utilNoAuto)
.call(initCombo, selection)
.merge(input);
if (isNetwork && _entity) {
var center = _entity.extent(context.graph()).center();
var countryCode = countryCoder.iso1A2Code(center);
_countryCode = countryCode && countryCode.toLowerCase();
}
input
.on('change', change)
.on('blur', change);
input
.on('keydown.field', function() {
switch (d3_event.keyCode) {
case 13: // ↩ Return
input.node().blur(); // blurring also enters the value
d3_event.stopPropagation();
break;
}
});
if (isMulti || isSemi) {
combobox
.on('accept', function() {
input.node().blur();
input.node().focus();
});
input
.on('focus', function() { container.classed('active', true); });
}
}
combo.tags = function(tags) {
if (isMulti || isSemi) {
_multiData = [];
if (isMulti) {
// Build _multiData array containing keys already set..
for (var k in tags) {
if (k.indexOf(field.key) !== 0) continue;
var v = (tags[k] || '').toLowerCase();
if (v === '' || v === 'no') continue;
var suffix = k.substring(field.key.length);
_multiData.push({
key: k,
value: displayValue(suffix)
});
}
// Set keys for form-field modified (needed for undo and reset buttons)..
field.keys = _multiData.map(function(d) { return d.key; });
} else if (isSemi) {
var arr = utilArrayUniq((tags[field.key] || '').split(';')).filter(Boolean);
_multiData = arr.map(function(k) {
return {
key: k,
value: displayValue(k)
};
});
}
// Exclude existing multikeys from combo options..
var available = objectDifference(_comboData, _multiData);
combobox.data(available);
// Hide 'Add' button if this field uses fixed set of
// translateable optstrings and they're all currently used..
container.selectAll('.combobox-input, .combobox-caret')
.classed('hide', optstrings && !available.length);
// Render chips
var chips = container.selectAll('.chips')
.data(_multiData);
chips.exit()
.remove();
var enter = chips.enter()
.insert('li', '.input-wrap')
.attr('class', 'chips');
enter.append('span');
enter.append('a');
chips = chips.merge(enter);
chips.select('span')
.text(function(d) { return d.value; });
chips.select('a')
.on('click', removeMultikey)
.attr('class', 'remove')
.text('×');
} else {
utilGetSetValue(input, displayValue(tags[field.key]));
}
};
combo.focus = function() {
input.node().focus();
};
combo.entity = function(val) {
if (!arguments.length) return _entity;
_entity = val;
return combo;
};
return utilRebind(combo, dispatch, 'on');
}