Files
iD/modules/ui/fields/combo.js
2019-02-10 22:08:13 -05:00

458 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import _compact from 'lodash-es/compact';
import _filter from 'lodash-es/filter';
import _find from 'lodash-es/find';
import _map from 'lodash-es/map';
import _reject from 'lodash-es/reject';
import _remove from 'lodash-es/remove';
import _some from 'lodash-es/some';
import _uniq from 'lodash-es/uniq';
import { dispatch as d3_dispatch } from 'd3-dispatch';
import {
event as d3_event,
select as d3_select
} from 'd3-selection';
import { t } from '../../util/locale';
import { services } from '../../services';
import { uiCombobox } from '../index';
import { 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 nominatim = services.geocoder;
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 _country;
// 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 = _find(_comboData, 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 = _find(_comboData, 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;
}
function objectDifference(a, b) {
return _reject(a, function(d1) {
return _some(b, 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 && _country && _country.indexOf(q.toLowerCase()) === 0;
if (hasCountryPrefix) {
query = _country + ':';
}
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;
if (hasCountryPrefix) {
data = _filter(data, function(d) {
return d.value.toLowerCase().indexOf(_country + ':') === 0;
});
}
// hide the caret if there are no suggestions
container.classed('empty-combobox', data.length === 0);
_comboData = _map(data, 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(d) {
var ph;
if (isMulti || isSemi) {
ph = field.placeholder() || t('inspector.add');
} else {
var vals = _map(d, 'value').filter(function(s) { return s.length < 20; }),
placeholders = vals.length > 1 ? vals : _map(d, 'key');
ph = field.placeholder() || placeholders.slice(0, 3).join(', ');
}
if (!/(…|\.\.\.)$/.test(ph)) {
ph += '…';
}
container.selectAll('input')
.attr('placeholder', ph);
}
function change() {
var val = tagValue(utilGetSetValue(input));
var t = {};
if (isMulti || isSemi) {
if (!val) return;
container.classed('active', false);
utilGetSetValue(input, '');
if (isMulti) {
var key = field.key + val;
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.push(val);
t[field.key] = _compact(_uniq(arr)).join(';');
}
window.setTimeout(function() { input.node().focus(); }, 10);
} else {
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) {
_remove(_multiData, function(md) { return md.key === d.key; });
var arr = _multiData.map(function(md) { return md.key; });
arr = _compact(_uniq(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 && nominatim && _entity) {
var center = _entity.extent(context.graph()).center();
nominatim.countryCode(center, function (err, code) {
_country = code;
});
}
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 = _map(_multiData, 'key');
} else if (isSemi) {
var arr = _compact(_uniq((tags[field.key] || '').split(';')));
_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');
}