Files
iD/modules/ui/fields/input.js
T
Martin Raifer e0f11af2b2 also allow "raw" numbers to be input in numeric fields
when a user enters a decimal number using the "international"/English/OSM-raw-data formatting (e.g. as in `0.5`), it is parsed using the basic, non-localized, number parser. In such cases, the content of the input field should not be overwritten with the localized formatting, as that would cause unexpected glitches and make editing harder (e.g. when thousands-grouping characters seemingly "magically" disappear or appear while typing).

see https://github.com/openstreetmap/iD/pull/8769/#pullrequestreview-1324246437 ff.
2023-05-26 18:38:16 +02:00

543 lines
20 KiB
JavaScript

import { dispatch as d3_dispatch } from 'd3-dispatch';
import { select as d3_select } from 'd3-selection';
import _debounce from 'lodash-es/debounce';
import * as countryCoder from '@rapideditor/country-coder';
import { presetManager } from '../../presets';
import { fileFetcher } from '../../core/file_fetcher';
import { t, localizer } from '../../core/localizer';
import { utilDetect, utilGetSetValue, utilNoAuto, utilRebind, utilTotalExtent } from '../../util';
import { svgIcon } from '../../svg/icon';
import { cardinal } from '../../osm/node';
import { uiLengthIndicator } from '..';
import { uiTooltip } from '../tooltip';
export {
uiFieldText as uiFieldColour,
uiFieldText as uiFieldEmail,
uiFieldText as uiFieldIdentifier,
uiFieldText as uiFieldNumber,
uiFieldText as uiFieldTel,
uiFieldText as uiFieldUrl
};
export function uiFieldText(field, context) {
var dispatch = d3_dispatch('change');
var input = d3_select(null);
var outlinkButton = d3_select(null);
var wrap = d3_select(null);
var _lengthIndicator = uiLengthIndicator(context.maxCharsForTagValue());
var _entityIDs = [];
var _tags;
var _phoneFormats = {};
const isDirectionField = field.key.split(':').some(keyPart => keyPart === 'direction');
const formatFloat = localizer.floatFormatter(localizer.languageCode());
const parseLocaleFloat = localizer.floatParser(localizer.languageCode());
const countDecimalPlaces = localizer.decimalPlaceCounter(localizer.languageCode());
const likelyRawNumberFormat = /^-?(0\.\d*|\d*\.\d{0,2}(\d{4,})?|\d{4,}\.\d{3})$/;
if (field.type === 'tel') {
fileFetcher.get('phone_formats')
.then(function(d) {
_phoneFormats = d;
updatePhonePlaceholder();
})
.catch(function() { /* ignore */ });
}
function calcLocked() {
// Protect certain fields that have a companion `*:wikidata` value
var isLocked = (field.id === 'brand' || field.id === 'network' || field.id === 'operator' || field.id === 'flag') &&
_entityIDs.length &&
_entityIDs.some(function(entityID) {
var entity = context.graph().hasEntity(entityID);
if (!entity) return false;
// Features linked to Wikidata are likely important and should be protected
if (entity.tags.wikidata) return true;
var preset = presetManager.match(entity, context.graph());
var isSuggestion = preset && preset.suggestion;
// Lock the field if there is a value and a companion `*:wikidata` value
var which = field.id; // 'brand', 'network', 'operator', 'flag'
return isSuggestion && !!entity.tags[which] && !!entity.tags[which + ':wikidata'];
});
field.locked(isLocked);
}
function i(selection) {
calcLocked();
var isLocked = field.locked();
wrap = selection.selectAll('.form-field-input-wrap')
.data([0]);
wrap = wrap.enter()
.append('div')
.attr('class', 'form-field-input-wrap form-field-input-' + field.type)
.merge(wrap);
input = wrap.selectAll('input')
.data([0]);
input = input.enter()
.append('input')
.attr('type', field.type === 'identifier' ? 'text' : field.type)
.attr('id', field.domId)
.classed(field.type, true)
.call(utilNoAuto)
.merge(input);
input
.classed('disabled', !!isLocked)
.attr('readonly', isLocked || null)
.on('input', change(true))
.on('blur', change())
.on('change', change());
wrap.call(_lengthIndicator);
if (field.type === 'tel') {
updatePhonePlaceholder();
} else if (field.type === 'number') {
var rtl = (localizer.textDirection() === 'rtl');
input.attr('type', 'text');
var inc = field.increment;
var buttons = wrap.selectAll('.increment, .decrement')
.data(rtl ? [inc, -inc] : [-inc, inc]);
buttons.enter()
.append('button')
.attr('class', function(d) {
var which = (d > 0 ? 'increment' : 'decrement');
return 'form-field-button ' + which;
})
.attr('title', function(d) {
var which = (d > 0 ? 'increment' : 'decrement');
return t(`inspector.${which}`);
})
.merge(buttons)
.on('click', function(d3_event, d) {
d3_event.preventDefault();
// do nothing if this is a multi-selection with mixed values
var isMixed = Array.isArray(_tags[field.key]);
if (isMixed) return;
var raw_vals = input.node().value || '0';
var vals = raw_vals.split(';');
vals = vals.map(function(v) {
v = v.trim();
const isRawNumber = likelyRawNumberFormat.test(v);
var num = isRawNumber ? parseFloat(v) : parseLocaleFloat(v);
if (isDirectionField) {
const compassDir = cardinal[v.toLowerCase()];
if (compassDir !== undefined) {
num = compassDir;
}
}
// do nothing if the value is neither a number, nor a cardinal direction
if (!isFinite(num)) return v;
num = parseFloat(num);
if (!isFinite(num)) return v;
num += d;
// clamp to 0..359 degree range if it's a direction field
// https://github.com/openstreetmap/iD/issues/9386
if (isDirectionField) {
num = ((num % 360) + 360) % 360;
}
// make sure no extra decimals are introduced
return formatFloat(clamped(num), isRawNumber
? (v.includes('.') ? v.split('.')[1].length : 0)
: countDecimalPlaces(v));
});
input.node().value = vals.join(';');
change()();
});
} else if (field.type === 'identifier' && field.urlFormat && field.pattern) {
input.attr('type', 'text');
outlinkButton = wrap.selectAll('.foreign-id-permalink')
.data([0]);
outlinkButton.enter()
.append('button')
.call(svgIcon('#iD-icon-out-link'))
.attr('class', 'form-field-button foreign-id-permalink')
.attr('title', function() {
var domainResults = /^https?:\/\/(.{1,}?)\//.exec(field.urlFormat);
if (domainResults.length >= 2 && domainResults[1]) {
var domain = domainResults[1];
return t('icons.view_on', { domain: domain });
}
return '';
})
.on('click', function(d3_event) {
d3_event.preventDefault();
var value = validIdentifierValueForLink();
if (value) {
var url = field.urlFormat.replace(/{value}/, encodeURIComponent(value));
window.open(url, '_blank');
}
})
.merge(outlinkButton);
} else if (field.type === 'url') {
input.attr('type', 'text');
outlinkButton = wrap.selectAll('.foreign-id-permalink')
.data([0]);
outlinkButton.enter()
.append('button')
.call(svgIcon('#iD-icon-out-link'))
.attr('class', 'form-field-button foreign-id-permalink')
.attr('title', () => t('icons.visit_website'))
.on('click', function(d3_event) {
d3_event.preventDefault();
const value = validIdentifierValueForLink();
if (value) window.open(value, '_blank');
})
.merge(outlinkButton);
} else if (field.type === 'colour') {
input.attr('type', 'text');
updateColourPreview();
} else if (field.type === 'date') {
input.attr('type', 'text');
updateDateField();
}
}
function updateColourPreview() {
function isColourValid(colour) {
if (!colour.match(/^(#([0-9a-fA-F]{3}){1,2}|\w+)$/)) {
// OSM only supports hex or named colors
return false;
} else if (!CSS.supports('color', colour) || ['unset', 'inherit', 'initial', 'revert'].includes(colour)) {
// see https://stackoverflow.com/a/68217760/1627467
return false;
}
return true;
}
wrap.selectAll('.colour-preview')
.remove();
const colour = utilGetSetValue(input);
if (!isColourValid(colour) && colour !== '') {
wrap.selectAll('input.colour-selector').remove();
wrap.selectAll('.form-field-button').remove();
return;
}
var colourSelector = wrap.selectAll('.colour-selector')
.data([0]);
colourSelector
.enter()
.append('input')
.attr('type', 'color')
.attr('class', 'colour-selector')
.on('input', _debounce(function(d3_event) {
d3_event.preventDefault();
var colour = this.value;
if (!isColourValid(colour)) return;
utilGetSetValue(input, this.value);
change()();
updateColourPreview();
}, 100));
wrap.selectAll('input.colour-selector')
.attr('value', colour);
var chooserButton = wrap.selectAll('.colour-preview')
.data([colour]);
chooserButton = chooserButton
.enter()
.append('div')
.attr('class', 'form-field-button colour-preview')
.append('div')
.style('background-color', d => d)
.attr('class', 'colour-box');
if (colour === '') {
chooserButton = chooserButton
.call(svgIcon('#iD-icon-edit'));
}
chooserButton
.on('click', () => wrap.select('.colour-selector').node().showPicker());
}
function updateDateField() {
function isDateValid(date) {
return date.match(/^[0-9]{4}(-[0-9]{2}(-[0-9]{2})?)?$/);
}
const date = utilGetSetValue(input);
const now = new Date();
const today = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().split('T')[0];
if ((field.key === 'check_date' || field.key === 'survey:date') && date !== today) {
wrap.selectAll('.date-set-today')
.data([0])
.enter()
.append('button')
.attr('class', 'form-field-button date-set-today')
.call(svgIcon('#fas-rotate'))
.call(uiTooltip().title(() => t.append('inspector.set_today')))
.on('click', () => {
utilGetSetValue(input, today);
change()();
updateDateField();
});
} else {
wrap.selectAll('.date-set-today').remove();
}
if (!isDateValid(date) && date !== '') {
wrap.selectAll('input.date-selector').remove();
wrap.selectAll('.date-calendar').remove();
return;
}
if (utilDetect().browser !== 'Safari') {
// opening of the calendar pick is not yet supported in safari <= 16
// https://caniuse.com/mdn-api_htmlinputelement_showpicker_date_input
var dateSelector = wrap.selectAll('.date-selector')
.data([0]);
dateSelector
.enter()
.append('input')
.attr('type', 'date')
.attr('class', 'date-selector')
.on('input', _debounce(function(d3_event) {
d3_event.preventDefault();
var date = this.value;
if (!isDateValid(date)) return;
utilGetSetValue(input, this.value);
change()();
updateDateField();
}, 100));
wrap.selectAll('input.date-selector')
.attr('value', date);
var calendarButton = wrap.selectAll('.date-calendar')
.data([date]);
calendarButton = calendarButton
.enter()
.append('button')
.attr('class', 'form-field-button date-calendar')
.call(svgIcon('#fas-calendar-days'));
calendarButton
.on('click', () => wrap.select('.date-selector').node().showPicker());
}
}
function updatePhonePlaceholder() {
if (input.empty() || !Object.keys(_phoneFormats).length) return;
var extent = combinedEntityExtent();
var countryCode = extent && countryCoder.iso1A2Code(extent.center());
var format = countryCode && _phoneFormats[countryCode.toLowerCase()];
if (format) input.attr('placeholder', format);
}
function validIdentifierValueForLink() {
const value = utilGetSetValue(input).trim();
if (field.type === 'url' && value) {
try {
return (new URL(value)).href;
} catch (e) {
return null;
}
}
if (field.type === 'identifier' && field.pattern) {
return value && value.match(new RegExp(field.pattern))[0];
}
return null;
}
// clamp number to min/max
function clamped(num) {
if (field.minValue !== undefined) {
num = Math.max(num, field.minValue);
}
if (field.maxValue !== undefined) {
num = Math.min(num, field.maxValue);
}
return num;
}
// returns all values of a (potential) multiselection and/or multi-key field
function getVals(tags) {
if (field.keys) {
return new Set(field.keys.reduce((acc, key) => acc.concat(tags[key]), [])
.filter(Boolean));
} else {
return new Set([].concat(tags[field.key]).filter(Boolean));
}
}
function change(onInput) {
return function() {
var t = {};
var val = utilGetSetValue(input);
if (!onInput) val = context.cleanTagValue(val);
// don't override multiple values with blank string
if (!val && getVals(_tags).size > 1) return;
var displayVal = val;
if (field.type === 'number' && val) {
var numbers = val.split(';');
numbers = numbers.map(function(v) {
if (likelyRawNumberFormat.test(v)) {
// input number likely in "raw" format
return v;
}
var num = parseLocaleFloat(v);
const fractionDigits = countDecimalPlaces(v);
return isFinite(num) ? clamped(num).toFixed(fractionDigits) : v;
});
val = numbers.join(';');
}
if (!onInput) utilGetSetValue(input, displayVal);
t[field.key] = val || undefined;
if (field.keys) {
// for multi-key fields with: handle alternative tag keys gracefully
// https://github.com/openstreetmap/id-tagging-schema/issues/905
dispatch.call('change', this, tags => {
if (field.keys.some(key => tags[key])) {
// use exiting key(s)
field.keys.filter(key => tags[key]).forEach(key => {
tags[key] = val || undefined;
});
} else {
// fall back to default key if none of the `keys` is preset
tags[field.key] = val || undefined;
}
return tags;
}, onInput);
} else {
dispatch.call('change', this, t, onInput);
}
};
}
i.entityIDs = function(val) {
if (!arguments.length) return _entityIDs;
_entityIDs = val;
return i;
};
i.tags = function(tags) {
_tags = tags;
const vals = getVals(tags);
const isMixed = vals.size > 1;
var val = vals.size === 1 ? [...vals][0] : '';
var shouldUpdate = true;
if (field.type === 'number' && val) {
// for number fields, we don't want to override the content of the
// input element with the same number using a different formatting
// (e.g. when entering "1234.5", this should not be reformatted to
// "1.234,5" which could otherwise cause the cursor to be in the
// wrong location after the change)
// but if the actual numeric value of the field has changed (e.g.
// by pressing the +/- buttons or using the raw tag editor), we
// can and should update the content of the input element.
shouldUpdate = false;
var numbers = val.split(';');
var oriNumbers = utilGetSetValue(input).split(';');
if (numbers.length !== oriNumbers.length) shouldUpdate = true;
numbers = numbers.map(function(v, idx) {
v = v.trim();
var num = Number(v);
var oriNumber = oriNumbers[idx] || '';
if (!isFinite(num) || v === '') {
if (v !== oriNumber) shouldUpdate = true;
return v;
}
oriNumber = likelyRawNumberFormat.test(oriNumber) ? parseFloat(oriNumber) : parseLocaleFloat(oriNumber);
if (num !== oriNumber) shouldUpdate = true;
const fractionDigits = v.includes('.') ? v.split('.')[1].length : 0;
return formatFloat(num, fractionDigits);
});
val = numbers.join(';');
}
if (shouldUpdate) {
utilGetSetValue(input, val);
}
input
.attr('title', isMixed ? [...vals].join('\n') : undefined)
.attr('placeholder', isMixed ? t('inspector.multiple_values') : (field.placeholder() || t('inspector.unknown')))
.classed('mixed', isMixed);
if (field.type === 'number') {
const buttons = wrap.selectAll('.increment, .decrement');
if (isMixed) {
buttons.attr('disabled', 'disabled').classed('disabled', true);
} else {
var raw_vals = tags[field.key] || '0';
const canIncDec = raw_vals.split(';').some(val => isFinite(Number(val))
|| isDirectionField && cardinal[val.trim().toLowerCase()]);
buttons.attr('disabled', canIncDec ? null : 'disabled').classed('disabled', !canIncDec);
}
}
if (field.type === 'tel') updatePhonePlaceholder();
if (field.type === 'colour') updateColourPreview();
if (field.type === 'date') updateDateField();
if (outlinkButton && !outlinkButton.empty()) {
var disabled = !validIdentifierValueForLink();
outlinkButton.classed('disabled', disabled);
}
if (!isMixed) {
_lengthIndicator.update(tags[field.key]);
}
};
i.focus = function() {
var node = input.node();
if (node) node.focus();
};
function combinedEntityExtent() {
return _entityIDs && _entityIDs.length && utilTotalExtent(_entityIDs, context.graph());
}
return utilRebind(i, dispatch, 'on');
}