modules/ui/fields/combo.js (547 lines of code) (raw):

import { dispatch as d3_dispatch } from 'd3-dispatch'; import { select as d3_select } from 'd3-selection'; import { drag as d3_drag } from 'd3-drag'; import { utilArrayUniq, utilUnicodeCharsCount } from '@id-sdk/util'; 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 { utilKeybinding } from '../../util/keybinding'; import { utilGetSetValue, utilNoAuto, utilRebind, utilTotalExtent } 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 _optarray = field.options; var _showTagInfoSuggestions = field.type !== 'manyCombo' && field.autoSuggestions !== false; var _allowCustomValues = field.type !== 'manyCombo' && field.customValues !== false; 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, '_').toLowerCase(); } 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 || ''); 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 (field.hasTextForStringId('options.' + tval)) { return field.t('options.' + tval, { default: tval }); } if (field.type === 'typeCombo' && tval.toLowerCase() === 'yes') { return ''; } return 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 (!_allowCustomValues) { selection.attr('readonly', 'readonly'); } if (_showTagInfoSuggestions && services.taginfo) { selection.call(_combobox.fetcher(setTaginfoValues), attachTo); setTaginfoValues('', setPlaceholder); } else { selection.call(_combobox, attachTo); setStaticValues(setPlaceholder); } } function setStaticValues(callback) { if (!_optarray) return; _comboData = _optarray.map(function(v) { return { key: v, value: field.t('options.' + v, { default: v }), title: v, display: field.t.html('options.' + v, { default: v }), klass: field.hasTextForStringId('options.' + v) ? '' : 'raw-option' }; }); _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 label = field.t('options.' + k, { default: k }); return { key: k, value: label, display: field.t.html('options.' + k, { default: k }), title: d.title || label, klass: field.hasTextForStringId('options.' + k) ? '' : 'raw-option' }; }); _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 isFbRoadId (entity) { if (entity.id) { return entity.id.startswith('w-'); } else { return false; } } function removeMultikey(d3_event, d) { d3_event.preventDefault(); d3_event.stopPropagation(); // don't move source=digitalglobe or source=maxar on ML road // TODO: switch to check on __fbid__ if (field.key === 'source' && _entityIDs[0] && isFbRoadId(_entityIDs[0]) && (d.value === 'digitalglobe' || d.value === 'maxar')) return; 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 and Via fields // to mimic highway exit signs if (field.key === 'destination' || field.key === 'via') { 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(d3_event) { 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) continue; if (!field.key && field.keys.indexOf(k) === -1) continue; var v = tags[k]; if (!v || (typeof v === 'string' && v.toLowerCase() === 'no')) continue; var suffix = field.key ? k.substr(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 // options and they're all currently used, // or if the field is already at its character limit var hideAdd = (!_allowCustomValues && !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('raw-value', function(d) { var k = d.key; if (_isMulti) k = k.replace(field.key, ''); return !field.hasTextForStringId('options.' + k); }) .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') .text(function(d) { // don't show 'x' on the digitalglobe/maxar label on ML road // TODO: switch to check on __fbid__ return _entityIDs[0] && isFbRoadId(_entityIDs[0]) && field.key === 'source' && (d.value === 'digitalglobe' || d.value === 'maxar') ? '' : '×'; }); } else { var isMixed = Array.isArray(tags[field.key]); var mixedValues = isMixed && tags[field.key].map(function(val) { return displayValue(val); }).filter(Boolean); var showsValue = !isMixed && tags[field.key] && !(field.type === 'typeCombo' && tags[field.key] === 'yes'); var isRawValue = showsValue && !field.hasTextForStringId('options.' + tags[field.key]); var isKnownValue = showsValue && !isRawValue; var isReadOnly = !_allowCustomValues || isKnownValue; utilGetSetValue(_input, !isMixed ? displayValue(tags[field.key]) : '') .classed('raw-value', isRawValue) .classed('known-value', isKnownValue) .attr('readonly', isReadOnly ? 'readonly' : undefined) .attr('title', isMixed ? mixedValues.join('\n') : undefined) .attr('placeholder', isMixed ? t('inspector.multiple_values') : _staticPlaceholder || '') .classed('mixed', isMixed) .on('keydown.deleteCapture', function(d3_event) { if (isReadOnly && isKnownValue && (d3_event.keyCode === utilKeybinding.keyCodes['⌫'] || d3_event.keyCode === utilKeybinding.keyCodes['⌦'])) { d3_event.preventDefault(); d3_event.stopPropagation(); var t = {}; t[field.key] = undefined; dispatch.call('change', this, t); } }); } }; function registerDragAndDrop(selection) { // allow drag and drop re-ordering of chips var dragOrigin, targetIndex; selection.call(d3_drag() .on('start', function(d3_event) { dragOrigin = { x: d3_event.x, y: d3_event.y }; targetIndex = null; }) .on('drag', function(d3_event) { 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; var index = selection.nodes().indexOf(this); d3_select(this) .classed('dragging', true); targetIndex = null; var targetIndexOffsetTop = null; var draggedTagWidth = d3_select(this).node().offsetWidth; if (field.key === 'destination' || field.key === 'via') { // 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() { if (!d3_select(this).classed('dragging')) { return; } var index = selection.nodes().indexOf(this); 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'); }