mirror of
https://github.com/FoggedLens/iD.git
synced 2026-02-13 17:23:02 +00:00
The common use case is for the `sources` key, often you have multiple URLs separated with semicolon. This allow to navigate to URL and check them more easily instead of doing manual work to select the URL copy and paste. Created with the help of Martin Raifer at the sotmeu2023!
947 lines
34 KiB
JavaScript
947 lines
34 KiB
JavaScript
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 * as countryCoder from '@rapideditor/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 { svgIcon } from '../../svg/icon';
|
||
|
||
import { utilKeybinding } from '../../util/keybinding';
|
||
import { utilArrayUniq, utilGetSetValue, utilNoAuto, utilRebind, utilTotalExtent, utilUnicodeCharsCount } from '../../util';
|
||
import { uiLengthIndicator } from '../length_indicator';
|
||
|
||
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 _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(1);
|
||
var _container = d3_select(null);
|
||
var _inputWrap = d3_select(null);
|
||
var _input = d3_select(null);
|
||
var _lengthIndicator = uiLengthIndicator(context.maxCharsForTagValue());
|
||
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 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 = getOptions(true).find(function(o) {
|
||
return o.key && clean(o.value) === dval;
|
||
});
|
||
if (found) return found.key;
|
||
|
||
if (field.type === 'typeCombo' && !dval) {
|
||
return 'yes';
|
||
}
|
||
|
||
return restrictTagValueSpelling(dval) || undefined;
|
||
}
|
||
|
||
function restrictTagValueSpelling(dval) {
|
||
if (_snake_case) {
|
||
dval = snake(dval);
|
||
}
|
||
|
||
if (!field.caseSensitive) {
|
||
dval = dval.toLowerCase();
|
||
}
|
||
|
||
return dval;
|
||
}
|
||
|
||
|
||
function getLabelId(field, v) {
|
||
return field.hasTextForStringId(`options.${v}.title`)
|
||
? `options.${v}.title`
|
||
: `options.${v}`;
|
||
}
|
||
|
||
|
||
// 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 || '';
|
||
|
||
var stringsField = field.resolveReference('stringsCrossReference');
|
||
const labelId = getLabelId(stringsField, tval);
|
||
if (stringsField.hasTextForStringId(labelId)) {
|
||
return stringsField.t(labelId, { default: tval });
|
||
}
|
||
|
||
if (field.type === 'typeCombo' && tval.toLowerCase() === 'yes') {
|
||
return '';
|
||
}
|
||
|
||
return tval;
|
||
}
|
||
|
||
|
||
// returns function which renders the display value for a tag value
|
||
// (for multiCombo, tval should be the key suffix, not the entire key)
|
||
function renderValue(tval) {
|
||
tval = tval || '';
|
||
|
||
var stringsField = field.resolveReference('stringsCrossReference');
|
||
const labelId = getLabelId(stringsField, tval);
|
||
if (stringsField.hasTextForStringId(labelId)) {
|
||
return stringsField.t.append(labelId, { default: tval });
|
||
}
|
||
|
||
if (field.type === 'typeCombo' && tval.toLowerCase() === 'yes') {
|
||
tval = '';
|
||
}
|
||
|
||
return selection => selection.text(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 (!_allowCustomValues) {
|
||
selection.attr('readonly', 'readonly');
|
||
}
|
||
|
||
if (_showTagInfoSuggestions && services.taginfo) {
|
||
selection.call(_combobox.fetcher(setTaginfoValues), attachTo);
|
||
setTaginfoValues('', setPlaceholder);
|
||
} else {
|
||
selection.call(_combobox, attachTo);
|
||
setTimeout(() => setStaticValues(setPlaceholder), 0);
|
||
}
|
||
}
|
||
|
||
function getOptions(allOptions) {
|
||
var stringsField = field.resolveReference('stringsCrossReference');
|
||
if (!(field.options || stringsField.options)) return [];
|
||
|
||
let options;
|
||
if (allOptions !== true) {
|
||
options = field.options || stringsField.options;
|
||
} else {
|
||
options = [].concat(field.options, stringsField.options).filter(Boolean);
|
||
}
|
||
return options.map(function(v) {
|
||
const labelId = getLabelId(stringsField, v);
|
||
return {
|
||
key: v,
|
||
value: stringsField.t(labelId, { default: v }),
|
||
title: stringsField.t(`options.${v}.description`, { default: v }),
|
||
display: addComboboxIcons(stringsField.t.append(labelId, { default: v }), v),
|
||
klass: stringsField.hasTextForStringId(labelId) ? '' : 'raw-option'
|
||
};
|
||
});
|
||
}
|
||
|
||
|
||
function hasStaticValues() {
|
||
return getOptions().length > 0;
|
||
}
|
||
|
||
|
||
function setStaticValues(callback, filter) {
|
||
_comboData = getOptions();
|
||
|
||
if (filter !== undefined) {
|
||
_comboData = _comboData.filter(filter);
|
||
}
|
||
|
||
_comboData = objectDifference(_comboData, _multiData);
|
||
_combobox.data(_comboData);
|
||
if (callback) callback(_comboData);
|
||
}
|
||
|
||
|
||
function setTaginfoValues(q, callback) {
|
||
var queryFilter = d => d.value.toLowerCase().includes(q.toLowerCase()) || d.key.toLowerCase().includes(q.toLowerCase());
|
||
if (hasStaticValues()) {
|
||
setStaticValues(callback, queryFilter);
|
||
}
|
||
|
||
var stringsField = field.resolveReference('stringsCrossReference');
|
||
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;
|
||
|
||
// don't show the fallback value
|
||
data = data.filter(d =>
|
||
field.type !== 'typeCombo' || d.value !== 'yes');
|
||
|
||
// don't show misspelled values
|
||
data = data.filter(d => {
|
||
var value = d.value;
|
||
if (_isMulti) {
|
||
value = value.slice(field.key.length);
|
||
}
|
||
return value === restrictTagValueSpelling(value);
|
||
});
|
||
|
||
var deprecatedValues = osmEntity.deprecatedTagValuesByKey(_dataDeprecated)[field.key];
|
||
if (deprecatedValues) {
|
||
// don't suggest deprecated tag values
|
||
data = data.filter(d =>
|
||
!deprecatedValues.includes(d.value));
|
||
}
|
||
|
||
if (hasCountryPrefix) {
|
||
data = data.filter(d =>
|
||
d.value.toLowerCase().indexOf(_countryCode + ':') === 0);
|
||
}
|
||
|
||
const additionalOptions = (field.options || stringsField.options || [])
|
||
.filter(v => !data.some(dv => dv.value === (_isMulti ? field.key + v : v)))
|
||
.map(v => ({ value: v }));
|
||
|
||
// hide the caret if there are no suggestions
|
||
_container.classed('empty-combobox', data.length === 0);
|
||
|
||
_comboData = data.concat(additionalOptions).map(function(d) {
|
||
var v = d.value;
|
||
if (_isMulti) v = v.replace(field.key, '');
|
||
const labelId = getLabelId(stringsField, v);
|
||
var isLocalizable = stringsField.hasTextForStringId(labelId);
|
||
var label = stringsField.t(labelId, { default: v });
|
||
return {
|
||
key: v,
|
||
value: label,
|
||
title: stringsField.t(`options.${v}.description`, { default:
|
||
isLocalizable ? v : (d.title !== label ? d.title : '') }),
|
||
display: addComboboxIcons(stringsField.t.append(labelId, { default: v }), v),
|
||
klass: isLocalizable ? '' : 'raw-option'
|
||
};
|
||
});
|
||
|
||
_comboData = _comboData.filter(queryFilter);
|
||
|
||
_comboData = objectDifference(_comboData, _multiData);
|
||
if (callback) callback(_comboData, hasStaticValues());
|
||
});
|
||
}
|
||
|
||
// adds icons to tag values which have one
|
||
function addComboboxIcons(disp, value) {
|
||
const iconsField = field.resolveReference('iconsCrossReference');
|
||
if (iconsField.icons) {
|
||
return function(selection) {
|
||
var span = selection
|
||
.insert('span', ':first-child')
|
||
.attr('class', 'tag-value-icon');
|
||
if (iconsField.icons[value]) {
|
||
span.call(svgIcon(`#${iconsField.icons[value]}`));
|
||
}
|
||
disp.call(this, selection);
|
||
};
|
||
}
|
||
return disp;
|
||
}
|
||
|
||
|
||
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);
|
||
|
||
// Hide 'Add' button if this field uses fixed set of
|
||
// options and they're all currently used
|
||
var hideAdd = (!_allowCustomValues && !values.length);
|
||
_container.selectAll('.chiplist .input-wrap')
|
||
.style('display', hideAdd ? 'none' : null);
|
||
}
|
||
|
||
|
||
function change() {
|
||
var t = {};
|
||
var val;
|
||
|
||
if (_isMulti || _isSemi) {
|
||
var vals;
|
||
if (_isMulti) {
|
||
vals = [tagValue(utilGetSetValue(_input))];
|
||
} else if (_isSemi) {
|
||
val = tagValue(utilGetSetValue(_input)) || '';
|
||
val = val.replace(/,/g, ';');
|
||
vals = val.split(';');
|
||
}
|
||
vals = vals.filter(Boolean);
|
||
|
||
if (!vals.length) return;
|
||
|
||
_container.classed('active', false);
|
||
utilGetSetValue(_input, '');
|
||
|
||
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(d3_event, 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;
|
||
|
||
_lengthIndicator.update(t[field.key]);
|
||
}
|
||
dispatch.call('change', this, t);
|
||
}
|
||
|
||
|
||
function invertMultikey(d3_event, d) {
|
||
d3_event.preventDefault();
|
||
d3_event.stopPropagation();
|
||
var t = {};
|
||
if (_isMulti) {
|
||
t[d.key] = _tags[d.key] === 'yes' ? 'no' : 'yes';
|
||
}
|
||
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);
|
||
|
||
// Hide 'Add' button if this field uses fixed set of
|
||
// options and they're all currently used
|
||
var hideAdd = (!_allowCustomValues && !_comboData.length);
|
||
_inputWrap.style('display', hideAdd ? 'none' : null);
|
||
|
||
_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, _container)
|
||
.merge(_input);
|
||
|
||
if (_isSemi) {
|
||
_inputWrap.call(_lengthIndicator);
|
||
} else if (!_isMulti) {
|
||
_container.call(_lengthIndicator);
|
||
}
|
||
|
||
if (_isNetwork) {
|
||
var extent = combinedEntityExtent();
|
||
var countryCode = extent && countryCoder.iso1A2Code(extent.center());
|
||
_countryCode = countryCode && countryCode.toLowerCase();
|
||
}
|
||
|
||
_input
|
||
.on('change', change)
|
||
.on('blur', change)
|
||
.on('input', function() {
|
||
let val = utilGetSetValue(_input);
|
||
updateIcon(val);
|
||
if (_isSemi && _tags[field.key]) {
|
||
// when adding a new value to existing ones
|
||
val += ';' + _tags[field.key];
|
||
}
|
||
_lengthIndicator.update(val);
|
||
});
|
||
|
||
_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); });
|
||
}
|
||
|
||
_combobox
|
||
.on('cancel', function() {
|
||
_input.node().blur();
|
||
})
|
||
.on('update', function() {
|
||
updateIcon(utilGetSetValue(_input));
|
||
});
|
||
}
|
||
|
||
function updateIcon(value) {
|
||
value = tagValue(value);
|
||
let container = _container;
|
||
if (field.type === 'multiCombo' || field.type === 'semiCombo') {
|
||
container = _container.select('.input-wrap');
|
||
}
|
||
const iconsField = field.resolveReference('iconsCrossReference');
|
||
if (iconsField.icons) {
|
||
container.selectAll('.tag-value-icon').remove();
|
||
if (iconsField.icons[value]) {
|
||
container.selectAll('.tag-value-icon')
|
||
.data([value])
|
||
.enter()
|
||
.insert('div', 'input')
|
||
.attr('class', 'tag-value-icon')
|
||
.call(svgIcon(`#${iconsField.icons[value]}`));
|
||
}
|
||
}
|
||
}
|
||
|
||
combo.tags = function(tags) {
|
||
_tags = tags;
|
||
var stringsField = field.resolveReference('stringsCrossReference');
|
||
|
||
var isMixed = Array.isArray(tags[field.key]);
|
||
var showsValue = value => !isMixed && value && !(field.type === 'typeCombo' && value === 'yes');
|
||
var isRawValue = value => showsValue(value)
|
||
&& !stringsField.hasTextForStringId(`options.${value}`)
|
||
&& !stringsField.hasTextForStringId(`options.${value}.title`);
|
||
var isKnownValue = value => showsValue(value) && !isRawValue(value);
|
||
var isReadOnly = !_allowCustomValues;
|
||
|
||
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];
|
||
|
||
var suffix = field.key ? k.slice(field.key.length) : k;
|
||
_multiData.push({
|
||
key: k,
|
||
value: displayValue(suffix),
|
||
display: addComboboxIcons(renderValue(suffix), suffix),
|
||
state: typeof v === 'string' ? v.toLowerCase() : '',
|
||
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),
|
||
display: addComboboxIcons(renderValue(v), 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);
|
||
|
||
// Hide 'Add' button if this field is already at its character limit
|
||
var hideAdd = maxLength <= 0 || (!_allowCustomValues && !_comboData.length);
|
||
_container.selectAll('.chiplist .input-wrap')
|
||
.style('display', hideAdd ? 'none' : null);
|
||
|
||
var allowDragAndDrop = _isSemi // only semiCombo values are ordered
|
||
&& !Array.isArray(tags[field.key]);
|
||
|
||
// 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');
|
||
const field_buttons = enter
|
||
.append('div')
|
||
.attr('class', 'field_buttons');
|
||
field_buttons
|
||
.append('a')
|
||
.attr('class', 'remove');
|
||
|
||
chips = chips.merge(enter)
|
||
.order()
|
||
.classed('raw-value', function(d) {
|
||
var k = d.key;
|
||
if (_isMulti) k = k.replace(field.key, '');
|
||
return !stringsField.hasTextForStringId('options.' + k);
|
||
})
|
||
.classed('draggable', allowDragAndDrop)
|
||
.classed('mixed', function(d) {
|
||
return d.isMixed;
|
||
})
|
||
.attr('title', function(d) {
|
||
if (d.isMixed) {
|
||
return t('inspector.unshared_value_tooltip');
|
||
}
|
||
if (!['yes', 'no'].includes(d.state)) {
|
||
return d.state;
|
||
}
|
||
return null;
|
||
})
|
||
.classed('negated', d => d.state === 'no');
|
||
|
||
if (!_isSemi) {
|
||
chips.selectAll('input[type=checkbox]').remove();
|
||
chips.insert('input', 'span')
|
||
.attr('type', 'checkbox')
|
||
.property('checked', d => d.state === 'yes')
|
||
.property('indeterminate', d => d.isMixed || !['yes', 'no'].includes(d.state))
|
||
.on('click', invertMultikey);
|
||
}
|
||
|
||
if (allowDragAndDrop) {
|
||
registerDragAndDrop(chips);
|
||
}
|
||
|
||
chips.each(function(d) {
|
||
const selection = d3_select(this);
|
||
const text_span = selection.select('span');
|
||
const field_buttons = selection.select('.field_buttons');
|
||
const clean_value = d.value.trim();
|
||
text_span.text('');
|
||
if (clean_value.startsWith('https://')) {
|
||
// create a button to open the link in a new tab
|
||
text_span.text(clean_value);
|
||
field_buttons.select('button').remove();
|
||
field_buttons.append('button')
|
||
.call(svgIcon('#iD-icon-out-link'))
|
||
.attr('class', 'form-field-button foreign-id-permalink')
|
||
.attr('title', () => t('icons.visit_website'))
|
||
.attr('aria-label', () => t('icons.visit_website'))
|
||
.on('click', function(d3_event) {
|
||
d3_event.preventDefault();
|
||
window.open(clean_value, '_blank');
|
||
});
|
||
return;
|
||
}
|
||
if (d.display) {
|
||
d.display(text_span);
|
||
return;
|
||
}
|
||
text_span.text(d.value);
|
||
});
|
||
|
||
chips.select('a.remove')
|
||
.attr('href', '#')
|
||
.on('click', removeMultikey)
|
||
.attr('class', 'remove')
|
||
.text('×');
|
||
|
||
updateIcon('');
|
||
} else {
|
||
var mixedValues = isMixed && tags[field.key].map(function(val) {
|
||
return displayValue(val);
|
||
}).filter(Boolean);
|
||
|
||
utilGetSetValue(_input, !isMixed ? displayValue(tags[field.key]) : '')
|
||
.data([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(tags[field.key]) &&
|
||
(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);
|
||
}
|
||
});
|
||
|
||
if (!Array.isArray(tags[field.key])) {
|
||
updateIcon(tags[field.key]);
|
||
}
|
||
|
||
if (!isMixed) {
|
||
_lengthIndicator.update(tags[field.key]);
|
||
}
|
||
}
|
||
|
||
const refreshStyles = () => {
|
||
_input
|
||
.data([tagValue(utilGetSetValue(_input))])
|
||
.classed('raw-value', isRawValue)
|
||
.classed('known-value', isKnownValue);
|
||
};
|
||
_input.on('input.refreshStyles', refreshStyles);
|
||
_combobox.on('update.refreshStyles', refreshStyles);
|
||
refreshStyles();
|
||
};
|
||
|
||
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');
|
||
}
|