Files
iD/modules/ui/fields/combo.js

705 lines
24 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 { dispatch as d3_dispatch } from 'd3-dispatch';
import { event as d3_event, select as d3_select } from 'd3-selection';
import { drag as d3_drag } from 'd3-drag';
import * as countryCoder from '@ideditor/country-coder';
import { fileFetcher } from '../../core/file_fetcher';
import { osmEntity } from '../../osm/entity';
import { t } from '../../core/localizer';
import { services } from '../../services';
import { uiCombobox } from '../combobox';
import { utilArrayUniq, utilGetSetValue, utilNoAuto, utilRebind, utilTotalExtent, utilUnicodeCharsCount } from '../../util';
export {
uiFieldCombo as uiFieldManyCombo,
uiFieldCombo as uiFieldMultiCombo,
uiFieldCombo as uiFieldNetworkCombo,
uiFieldCombo as uiFieldSemiCombo,
uiFieldCombo as uiFieldTypeCombo
};
export function uiFieldCombo(field, context) {
var dispatch = d3_dispatch('change');
var _isMulti = (field.type === 'multiCombo' || field.type === 'manyCombo');
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 _combobox = uiCombobox(context, 'combo-' + field.safeid)
.caseSensitive(field.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 _entityIDs = [];
var _tags;
var _countryCode;
var _staticPlaceholder;
// initialize deprecated tags array
var _dataDeprecated = [];
fileFetcher.get('deprecated')
.then(function(d) { _dataDeprecated = d; })
.catch(function() { /* ignore */ });
// ensure multiCombo field.key ends with a ':'
if (_isMulti && field.key && /[^:]$/.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 !d2.isMixed && 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 (services.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,
display: field.t.html('options.' + k, { 'default': _optstrings[k] })
};
});
} 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 (_entityIDs.length) {
params.geometry = context.graph().geometry(_entityIDs[0]);
}
services.taginfo[fn](params, function(err, data) {
if (err) return;
data = data.filter(function(d) {
if (field.type === 'typeCombo' && d.value === 'yes') {
// don't show the fallback value
return false;
}
// don't show values with very low usage
return !d.count || d.count > 10;
});
var deprecatedValues = osmEntity.deprecatedTagValuesByKey(_dataDeprecated)[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) {
if (_isMulti || _isSemi) {
_staticPlaceholder = 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; });
_staticPlaceholder = field.placeholder() || placeholders.slice(0, 3).join(', ');
}
if (!/(…|\.\.\.)$/.test(_staticPlaceholder)) {
_staticPlaceholder += '…';
}
var ph;
if (!_isMulti && !_isSemi && _tags && Array.isArray(_tags[field.key])) {
ph = t('inspector.multiple_values');
} else {
ph = _staticPlaceholder;
}
_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 (_tags) {
// don't set a multicombo value to 'yes' if it already has a non-'no' value
// e.g. `language:de=main`
var old = _tags[key];
if (typeof old === 'string' && old.toLowerCase() !== 'no') return;
}
key = context.cleanTagKey(key);
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] = context.cleanTagValue(utilArrayUniq(arr).filter(Boolean).join(';'));
}
window.setTimeout(function() { _input.node().focus(); }, 10);
} else {
var rawValue = utilGetSetValue(_input);
// don't override multiple values with blank string
if (!rawValue && Array.isArray(_tags[field.key])) return;
val = context.cleanTagValue(tagValue(rawValue));
t[field.key] = val || undefined;
}
dispatch.call('change', this, t);
}
function removeMultikey(d) {
d3_event.preventDefault();
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.key === 'destination') {
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', field.domId)
.call(utilNoAuto)
.call(initCombo, selection)
.merge(_input);
if (_isNetwork) {
var extent = combinedEntityExtent();
var countryCode = extent && countryCoder.iso1A2Code(extent.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) {
_tags = tags;
if (_isMulti || _isSemi) {
_multiData = [];
var maxLength;
if (_isMulti) {
// Build _multiData array containing keys already set..
for (var k in tags) {
if ((field.key && k.indexOf(field.key) !== 0) || field.keys.indexOf(k) === -1) continue;
var v = tags[k];
if (!v || (typeof v === 'string' && v.toLowerCase() === 'no')) continue;
var suffix = field.key ? k.substring(field.key.length) : k;
_multiData.push({
key: k,
value: displayValue(suffix),
isMixed: Array.isArray(v)
});
}
if (field.key) {
// Set keys for form-field modified (needed for undo and reset buttons)..
field.keys = _multiData.map(function(d) { return d.key; });
// limit the input length so it fits after prepending the key prefix
maxLength = context.maxCharsForTagKey() - utilUnicodeCharsCount(field.key);
} else {
maxLength = context.maxCharsForTagKey();
}
} else if (_isSemi) {
var allValues = [];
var commonValues;
if (Array.isArray(tags[field.key])) {
tags[field.key].forEach(function(tagVal) {
var thisVals = utilArrayUniq((tagVal || '').split(';')).filter(Boolean);
allValues = allValues.concat(thisVals);
if (!commonValues) {
commonValues = thisVals;
} else {
commonValues = commonValues.filter(value => thisVals.includes(value));
}
});
allValues = utilArrayUniq(allValues).filter(Boolean);
} else {
allValues = utilArrayUniq((tags[field.key] || '').split(';')).filter(Boolean);
commonValues = allValues;
}
_multiData = allValues.map(function(v) {
return {
key: v,
value: displayValue(v),
isMixed: !commonValues.includes(v)
};
});
var currLength = utilUnicodeCharsCount(commonValues.join(';'));
// limit the input length to the remaining available characters
maxLength = context.maxCharsForTagValue() - currLength;
if (currLength > 0) {
// account for the separator if a new value will be appended to existing
maxLength -= 1;
}
}
// a negative maxlength doesn't make sense
maxLength = Math.max(0, maxLength);
var allowDragAndDrop = _isSemi // only semiCombo values are ordered
&& !Array.isArray(tags[field.key]);
// 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,
// or if the field is already at its character limit
var hideAdd = (_optstrings && !available.length) || maxLength <= 0;
_container.selectAll('.chiplist .input-wrap')
.style('display', hideAdd ? 'none' : null);
// Render chips
var chips = _container.selectAll('.chip')
.data(_multiData);
chips.exit()
.remove();
var enter = chips.enter()
.insert('li', '.input-wrap')
.attr('class', 'chip');
enter.append('span');
enter.append('a');
chips = chips.merge(enter)
.order()
.classed('draggable', allowDragAndDrop)
.classed('mixed', function(d) {
return d.isMixed;
})
.attr('title', function(d) {
return d.isMixed ? t('inspector.unshared_value_tooltip') : null;
});
if (allowDragAndDrop) {
registerDragAndDrop(chips);
}
chips.select('span')
.html(function(d) { return d.value; });
chips.select('a')
.attr('href', '#')
.on('click', removeMultikey)
.attr('class', 'remove')
.html('×');
} else {
var isMixed = Array.isArray(tags[field.key]);
var mixedValues = isMixed && tags[field.key].map(function(val) {
return displayValue(val);
}).filter(Boolean);
utilGetSetValue(_input, !isMixed ? displayValue(tags[field.key]) : '')
.attr('title', isMixed ? mixedValues.join('\n') : undefined)
.attr('placeholder', isMixed ? t('inspector.multiple_values') : _staticPlaceholder || '')
.classed('mixed', isMixed);
}
};
function registerDragAndDrop(selection) {
// allow drag and drop re-ordering of chips
var dragOrigin, targetIndex;
selection.call(d3_drag()
.on('start', function() {
dragOrigin = {
x: d3_event.x,
y: d3_event.y
};
targetIndex = null;
})
.on('drag', function(d, index) {
var x = d3_event.x - dragOrigin.x,
y = d3_event.y - dragOrigin.y;
if (!d3_select(this).classed('dragging') &&
// don't display drag until dragging beyond a distance threshold
Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)) <= 5) return;
d3_select(this)
.classed('dragging', true);
targetIndex = null;
var targetIndexOffsetTop = null;
var draggedTagWidth = d3_select(this).node().offsetWidth;
if (field.key === 'destination') { // meaning tags are full width
_container.selectAll('.chip')
.style('transform', function(d2, index2) {
var node = d3_select(this).node();
if (index === index2) {
return 'translate(' + x + 'px, ' + y + 'px)';
// move the dragged tag up the order
} else if (index2 > index && d3_event.y > node.offsetTop) {
if (targetIndex === null || index2 > targetIndex) {
targetIndex = index2;
}
return 'translateY(-100%)';
// move the dragged tag down the order
} else if (index2 < index && d3_event.y < node.offsetTop + node.offsetHeight) {
if (targetIndex === null || index2 < targetIndex) {
targetIndex = index2;
}
return 'translateY(100%)';
}
return null;
});
} else {
_container.selectAll('.chip')
.each(function(d2, index2) {
var node = d3_select(this).node();
// check the cursor is in the bounding box
if (
index !== index2 &&
d3_event.x < node.offsetLeft + node.offsetWidth + 5 &&
d3_event.x > node.offsetLeft &&
d3_event.y < node.offsetTop + node.offsetHeight &&
d3_event.y > node.offsetTop
) {
targetIndex = index2;
targetIndexOffsetTop = node.offsetTop;
}
})
.style('transform', function(d2, index2) {
var node = d3_select(this).node();
if (index === index2) {
return 'translate(' + x + 'px, ' + y + 'px)';
}
// only translate tags in the same row
if (node.offsetTop === targetIndexOffsetTop) {
if (index2 < index && index2 >= targetIndex) {
return 'translateX(' + draggedTagWidth + 'px)';
} else if (index2 > index && index2 <= targetIndex) {
return 'translateX(-' + draggedTagWidth + 'px)';
}
}
return null;
});
}
})
.on('end', function(d, index) {
if (!d3_select(this).classed('dragging')) {
return;
}
d3_select(this)
.classed('dragging', false);
_container.selectAll('.chip')
.style('transform', null);
if (typeof targetIndex === 'number') {
var element = _multiData[index];
_multiData.splice(index, 1);
_multiData.splice(targetIndex, 0, element);
var t = {};
if (_multiData.length) {
t[field.key] = _multiData.map(function(element) {
return element.key;
}).join(';');
} else {
t[field.key] = undefined;
}
dispatch.call('change', this, t);
}
dragOrigin = undefined;
targetIndex = undefined;
})
);
}
combo.focus = function() {
_input.node().focus();
};
combo.entityIDs = function(val) {
if (!arguments.length) return _entityIDs;
_entityIDs = val;
return combo;
};
function combinedEntityExtent() {
return _entityIDs && _entityIDs.length && utilTotalExtent(_entityIDs, context.graph());
}
return utilRebind(combo, dispatch, 'on');
}