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');
}