diff --git a/css/80_app.css b/css/80_app.css index f0d7659ca..83a6b051c 100644 --- a/css/80_app.css +++ b/css/80_app.css @@ -2324,6 +2324,36 @@ div.combobox { color: #333; } +.form-field-input-wrap { + position: relative; +} + +.form-field-input-wrap span.length-indicator-wrap { + visibility: hidden; + position: absolute; + top: -5px; + left: 0; + right: 0; +} + +.form-field-input-wrap > textarea:focus + span.length-indicator-wrap, +.form-field-input-wrap > textarea:focus + div.combobox-caret + span.length-indicator-wrap { + visibility: visible; +} + +.form-field-input-wrap span.length-indicator { + display: block; + left: 0; + right: 0; + height: 4px; + background-color: #7092ff; + border-right-style: solid; + border-right-color: lightgray; +} + +.form-field-input-wrap span.length-indicator.limit-reached { + border-right-color: red; +} /* Field Help ------------------------------------------------------- */ diff --git a/data/core.yaml b/data/core.yaml index f98a773c5..481f2b3cc 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -782,6 +782,7 @@ en: foot: ft # abbreviation of inches inch: in + max_length_reached: "Please keep in mind that texts cannot be longer than a maximum of {maxChars} characters. Anything exceeding that length will be truncated." background: title: Background description: Background Settings diff --git a/modules/core/context.js b/modules/core/context.js index ddce5cccc..e72f06a2b 100644 --- a/modules/core/context.js +++ b/modules/core/context.js @@ -19,7 +19,7 @@ import { presetManager } from '../presets'; import { rendererBackground, rendererFeatures, rendererMap, rendererPhotos } from '../renderer'; import { services } from '../services'; import { uiInit } from '../ui/init'; -import { utilKeybinding, utilRebind, utilStringQs, utilUnicodeCharsTruncated } from '../util'; +import { utilKeybinding, utilRebind, utilStringQs, utilCleanOsmString } from '../util'; export function coreContext() { @@ -220,26 +220,9 @@ export function coreContext() { context.maxCharsForTagValue = () => 255; context.maxCharsForRelationRole = () => 255; - function cleanOsmString(val, maxChars) { - // be lenient with input - if (val === undefined || val === null) { - val = ''; - } else { - val = val.toString(); - } - - // remove whitespace - val = val.trim(); - - // use the canonical form of the string - if (val.normalize) val = val.normalize('NFC'); - - // trim to the number of allowed characters - return utilUnicodeCharsTruncated(val, maxChars); - } - context.cleanTagKey = (val) => cleanOsmString(val, context.maxCharsForTagKey()); - context.cleanTagValue = (val) => cleanOsmString(val, context.maxCharsForTagValue()); - context.cleanRelationRole = (val) => cleanOsmString(val, context.maxCharsForRelationRole()); + context.cleanTagKey = (val) => utilCleanOsmString(val, context.maxCharsForTagKey()); + context.cleanTagValue = (val) => utilCleanOsmString(val, context.maxCharsForTagValue()); + context.cleanRelationRole = (val) => utilCleanOsmString(val, context.maxCharsForRelationRole()); /* History */ diff --git a/modules/osm/entity.js b/modules/osm/entity.js index 5636fdc78..155fe6438 100644 --- a/modules/osm/entity.js +++ b/modules/osm/entity.js @@ -156,7 +156,7 @@ osmEntity.prototype = { changed = true; merged[k] = utilUnicodeCharsTruncated( utilArrayUnion(t1.split(/;\s*/), t2.split(/;\s*/)).join(';'), - 255 // avoid exceeding character limit; see also services/osm.js -> maxCharsForTagValue() + 255 // avoid exceeding character limit; see also context.maxCharsForTagValue() ); } } diff --git a/modules/ui/fields/textarea.js b/modules/ui/fields/textarea.js index 31e792052..1824fac34 100644 --- a/modules/ui/fields/textarea.js +++ b/modules/ui/fields/textarea.js @@ -7,11 +7,13 @@ import { utilNoAuto, utilRebind } from '../../util'; +import { uiLengthIndicator } from '../length_indicator'; export function uiFieldTextarea(field, context) { var dispatch = d3_dispatch('change'); var input = d3_select(null); + var _lengthIndicator = uiLengthIndicator(context.maxCharsForTagValue()); var _tags; @@ -22,6 +24,7 @@ export function uiFieldTextarea(field, context) { wrap = wrap.enter() .append('div') .attr('class', 'form-field-input-wrap form-field-input-' + field.type) + .style('position', 'relative') .merge(wrap); input = wrap.selectAll('textarea') @@ -35,22 +38,23 @@ export function uiFieldTextarea(field, context) { .on('blur', change()) .on('change', change()) .merge(input); - } + wrap.call(_lengthIndicator); - function change(onInput) { - return function() { + function change(onInput) { + return function() { - var val = utilGetSetValue(input); - if (!onInput) val = context.cleanTagValue(val); + var val = utilGetSetValue(input); + if (!onInput) val = context.cleanTagValue(val); - // don't override multiple values with blank string - if (!val && Array.isArray(_tags[field.key])) return; + // don't override multiple values with blank string + if (!val && Array.isArray(_tags[field.key])) return; - var t = {}; - t[field.key] = val || undefined; - dispatch.call('change', this, t, onInput); - }; + var t = {}; + t[field.key] = val || undefined; + dispatch.call('change', this, t, onInput); + }; + } } @@ -63,6 +67,10 @@ export function uiFieldTextarea(field, context) { .attr('title', isMixed ? tags[field.key].filter(Boolean).join('\n') : undefined) .attr('placeholder', isMixed ? t('inspector.multiple_values') : (field.placeholder() || t('inspector.unknown'))) .classed('mixed', isMixed); + + if (!isMixed) { + _lengthIndicator.update(tags[field.key]); + } }; diff --git a/modules/ui/index.js b/modules/ui/index.js index c1e7abcd5..2ffbdd11b 100644 --- a/modules/ui/index.js +++ b/modules/ui/index.js @@ -33,6 +33,7 @@ export { uiIssuesInfo } from './issues_info'; export { uiKeepRightDetails } from './keepRight_details'; export { uiKeepRightEditor } from './keepRight_editor'; export { uiKeepRightHeader } from './keepRight_header'; +export { uiLengthIndicator } from './length_indicator'; export { uiLasso } from './lasso'; export { uiLoading } from './loading'; export { uiMapInMap } from './map_in_map'; diff --git a/modules/ui/length_indicator.js b/modules/ui/length_indicator.js new file mode 100644 index 000000000..765e24f28 --- /dev/null +++ b/modules/ui/length_indicator.js @@ -0,0 +1,58 @@ +import { select as d3_select } from 'd3-selection'; + +import { t } from '../core/localizer'; +import { + utilUnicodeCharsCount, + utilCleanOsmString +} from '../util'; +import { uiPopover } from './popover'; + + +export function uiLengthIndicator(maxChars) { + var _wrap = d3_select(null); + var _tooltip = uiPopover('tooltip') + .placement('bottom') + .hasArrow(true) + .content(() => selection => { + selection.text(''); + t.append('inspector.max_length_reached', { maxChars })(selection); + }); + + var lengthIndicator = function(selection) { + _wrap = selection.selectAll('span.length-indicator-wrap').data([0]); + _wrap = _wrap.enter() + .append('span') + .merge(_wrap) + .classed('length-indicator-wrap', true); + selection.call(_tooltip); + }; + + lengthIndicator.update = function(val) { + const strLen = utilUnicodeCharsCount(utilCleanOsmString(val, Number.POSITIVE_INFINITY)); + + var lengthIndicator = _wrap.selectAll('span.length-indicator') + .data([strLen]); + + lengthIndicator = lengthIndicator.enter() + .append('span') + .merge(lengthIndicator) + .classed('length-indicator', true) + .classed('limit-reached', d => d > maxChars) + .style('border-right-width', d => `${Math.abs(maxChars - d) * 2}px`) + .style('margin-right', d => d > maxChars + ? `${(maxChars - d) * 2}px` + : 0) + .style('opacity', d => d > maxChars * 0.8 + ? Math.min(1, (d / maxChars - 0.8) / (1 - 0.8)) + : 0) + .style('pointer-events', d => d > maxChars * 0.8 ? null: 'none'); + + if (strLen > maxChars) { + _tooltip.show(); + } else { + _tooltip.hide(); + } + } + + return lengthIndicator; +} diff --git a/modules/util/index.js b/modules/util/index.js index a1fd6c74f..1aebc3adb 100644 --- a/modules/util/index.js +++ b/modules/util/index.js @@ -54,3 +54,4 @@ export { utilUnicodeCharsCount } from './util'; export { utilUnicodeCharsTruncated } from './util'; export { utilUniqueDomId } from './util'; export { utilWrap } from './util'; +export { utilCleanOsmString } from './util'; diff --git a/modules/util/util.js b/modules/util/util.js index 13af49878..e370f3db5 100644 --- a/modules/util/util.js +++ b/modules/util/util.js @@ -629,3 +629,22 @@ export function utilOldestID(ids) { return ids[oldestIDIndex]; } + +// returns a normalized and truncated string to `maxChars` utf-8 characters +export function utilCleanOsmString(val, maxChars) { + // be lenient with input + if (val === undefined || val === null) { + val = ''; + } else { + val = val.toString(); + } + + // remove whitespace + val = val.trim(); + + // use the canonical form of the string + if (val.normalize) val = val.normalize('NFC'); + + // trim to the number of allowed characters + return utilUnicodeCharsTruncated(val, maxChars); + }