Enforce max unicode charachter length of keys, values, and relation roles through truncation upon input rather than HTML maxlength attribute (close #6817)

Normalize unicode when changing keys, values, and relation roles
This commit is contained in:
Quincy Morgan
2020-06-10 14:11:55 -04:00
parent 2064f7a2f7
commit 874c412b74
15 changed files with 109 additions and 104 deletions
+22 -1
View File
@@ -18,7 +18,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 } from '../util';
import { utilKeybinding, utilRebind, utilStringQs, utilUnicodeCharsTruncated } from '../util';
export function coreContext() {
@@ -196,6 +196,27 @@ 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());
/* History */
let _inIntro = false;
+15 -21
View File
@@ -12,7 +12,7 @@ import { uiChangesetEditor } from './changeset_editor';
import { uiSectionChanges } from './sections/changes';
import { uiCommitWarnings } from './commit_warnings';
import { uiSectionRawTagEditor } from './sections/raw_tag_editor';
import { utilArrayGroupBy, utilRebind, utilUnicodeCharsTruncated, utilUniqueDomId } from '../util';
import { utilArrayGroupBy, utilRebind, utilUniqueDomId } from '../util';
import { utilDetect } from '../util/detect';
@@ -63,8 +63,6 @@ export function uiCommit(context) {
function initChangeset() {
var tagCharLimit = context.maxCharsForTagValue();
// expire stored comment, hashtags, source after cutoff datetime - #3947 #4899
var commentDate = +prefs('commentDate') || 0;
var currDate = Date.now();
@@ -92,9 +90,9 @@ export function uiCommit(context) {
var detected = utilDetect();
var tags = {
comment: prefs('comment') || '',
created_by: utilUnicodeCharsTruncated('iD ' + context.version, tagCharLimit),
host: utilUnicodeCharsTruncated(detected.host, tagCharLimit),
locale: utilUnicodeCharsTruncated(localizer.localeCode(), tagCharLimit)
created_by: context.cleanTagValue('iD ' + context.version),
host: context.cleanTagValue(detected.host),
locale: context.cleanTagValue(localizer.localeCode())
};
// call findHashtags initially - this will remove stored
@@ -126,7 +124,7 @@ export function uiCommit(context) {
}
});
tags.source = utilUnicodeCharsTruncated(sources.join(';'), tagCharLimit);
tags.source = context.cleanTagValue(sources.join(';'));
}
context.changeset = new osmChangeset({ tags: tags });
@@ -139,36 +137,34 @@ export function uiCommit(context) {
var osm = context.connection();
if (!osm) return;
var tagCharLimit = context.maxCharsForTagValue();
var tags = Object.assign({}, context.changeset.tags); // shallow copy
// assign tags for imagery used
var imageryUsed = utilUnicodeCharsTruncated(context.history().imageryUsed().join(';'), tagCharLimit);
var imageryUsed = context.cleanTagValue(context.history().imageryUsed().join(';'));
tags.imagery_used = imageryUsed || 'None';
// assign tags for closed issues and notes
var osmClosed = osm.getClosedIDs();
var itemType;
if (osmClosed.length) {
tags['closed:note'] = utilUnicodeCharsTruncated(osmClosed.join(';'), tagCharLimit);
tags['closed:note'] = context.cleanTagValue(osmClosed.join(';'));
}
if (services.keepRight) {
var krClosed = services.keepRight.getClosedIDs();
if (krClosed.length) {
tags['closed:keepright'] = utilUnicodeCharsTruncated(krClosed.join(';'), tagCharLimit);
tags['closed:keepright'] = context.cleanTagValue(krClosed.join(';'));
}
}
if (services.improveOSM) {
var iOsmClosed = services.improveOSM.getClosedCounts();
for (itemType in iOsmClosed) {
tags['closed:improveosm:' + itemType] = utilUnicodeCharsTruncated(iOsmClosed[itemType].toString(), tagCharLimit);
tags['closed:improveosm:' + itemType] = context.cleanTagValue(iOsmClosed[itemType].toString());
}
}
if (services.osmose) {
var osmoseClosed = services.osmose.getClosedCounts();
for (itemType in osmoseClosed) {
tags['closed:osmose:' + itemType] = utilUnicodeCharsTruncated(osmoseClosed[itemType].toString(), tagCharLimit);
tags['closed:osmose:' + itemType] = context.cleanTagValue(osmoseClosed[itemType].toString());
}
}
@@ -187,10 +183,10 @@ export function uiCommit(context) {
var issuesBySubtype = utilArrayGroupBy(issuesOfType, 'subtype');
for (var issueSubtype in issuesBySubtype) {
var issuesOfSubtype = issuesBySubtype[issueSubtype];
tags[prefix + ':' + issueType + ':' + issueSubtype] = utilUnicodeCharsTruncated(issuesOfSubtype.length.toString(), tagCharLimit);
tags[prefix + ':' + issueType + ':' + issueSubtype] = context.cleanTagValue(issuesOfSubtype.length.toString());
}
} else {
tags[prefix + ':' + issueType] = utilUnicodeCharsTruncated(issuesOfType.length.toString(), tagCharLimit);
tags[prefix + ':' + issueType] = context.cleanTagValue(issuesOfType.length.toString());
}
}
}
@@ -546,18 +542,16 @@ export function uiCommit(context) {
function updateChangeset(changed, onInput) {
var tags = Object.assign({}, context.changeset.tags); // shallow copy
var tagCharLimit = context.maxCharsForTagValue();
Object.keys(changed).forEach(function(k) {
var v = changed[k];
k = utilUnicodeCharsTruncated(k.trim(), tagCharLimit);
k = context.cleanTagKey(k);
if (readOnlyTags.indexOf(k) !== -1) return;
if (k !== '' && v !== undefined) {
if (onInput) {
tags[k] = v;
} else {
tags[k] = utilUnicodeCharsTruncated(v.trim(), tagCharLimit);
tags[k] = context.cleanTagValue(v);
}
} else {
delete tags[k];
@@ -569,7 +563,7 @@ export function uiCommit(context) {
var commentOnly = changed.hasOwnProperty('comment') && (changed.comment !== '');
var arr = findHashtags(tags, commentOnly);
if (arr.length) {
tags.hashtags = utilUnicodeCharsTruncated(arr.join(';'), tagCharLimit);
tags.hashtags = context.cleanTagValue(arr.join(';'));
prefs('hashtags', tags.hashtags);
} else {
delete tags.hashtags;
+1 -2
View File
@@ -47,7 +47,6 @@ export function uiFieldAccess(field, context) {
.attr('class', 'preset-input-access-wrap')
.append('input')
.attr('type', 'text')
.attr('maxlength', context.maxCharsForTagValue())
.attr('class', function(d) { return 'preset-input-access preset-input-access-' + d; })
.call(utilNoAuto)
.each(function(d) {
@@ -69,7 +68,7 @@ export function uiFieldAccess(field, context) {
function change(d) {
var tag = {};
var value = utilGetSetValue(d3_select(this));
var value = context.cleanTagValue(utilGetSetValue(d3_select(this)));
// don't override multiple values with blank string
if (!value && typeof _tags[d] !== 'string') return;
+5 -4
View File
@@ -186,7 +186,6 @@ export function uiFieldAddress(field, context) {
.append('input')
.property('type', 'text')
.call(updatePlaceholder)
.attr('maxlength', context.maxCharsForTagValue())
.attr('class', function (d) { return 'addr-' + d.id; })
.call(utilNoAuto)
.each(addDropdown)
@@ -259,10 +258,12 @@ export function uiFieldAddress(field, context) {
.each(function (subfield) {
var key = field.key + ':' + subfield.id;
// don't override multiple values with blank string
if (Array.isArray(_tags[key]) && !this.value) return;
var value = context.cleanTagValue(this.value);
tags[key] = this.value || undefined;
// don't override multiple values with blank string
if (Array.isArray(_tags[key]) && !value) return;
tags[key] = value || undefined;
});
dispatch.call('change', this, tags, onInput);
+5 -8
View File
@@ -290,6 +290,7 @@ export function uiFieldCombo(field, context) {
var old = _tags[key];
if (typeof old === 'string' && old.toLowerCase() !== 'no') return;
}
key = context.cleanTagKey(key);
field.keys.push(key);
t[key] = 'yes';
});
@@ -297,7 +298,7 @@ export function uiFieldCombo(field, context) {
} else if (isSemi) {
var arr = _multiData.map(function(d) { return d.key; });
arr = arr.concat(vals);
t[field.key] = utilArrayUniq(arr).filter(Boolean).join(';');
t[field.key] = context.cleanTagValue(utilArrayUniq(arr).filter(Boolean).join(';'));
}
window.setTimeout(function() { input.node().focus(); }, 10);
@@ -308,7 +309,7 @@ export function uiFieldCombo(field, context) {
// don't override multiple values with blank string
if (!rawValue && Array.isArray(_tags[field.key])) return;
val = tagValue(rawValue);
val = context.cleanTagValue(tagValue(rawValue));
t[field.key] = val;
}
@@ -383,7 +384,6 @@ export function uiFieldCombo(field, context) {
.append('input')
.attr('type', 'text')
.attr('id', field.domId)
.attr('maxlength', context.maxCharsForTagValue())
.call(utilNoAuto)
.call(initCombo, selection)
.merge(input);
@@ -456,8 +456,8 @@ export function uiFieldCombo(field, context) {
var commonValues;
if (Array.isArray(tags[field.key])) {
tags[field.key].forEach(function(tagValue) {
var thisVals = utilArrayUniq((tagValue || '').split(';')).filter(Boolean);
tags[field.key].forEach(function(tagVal) {
var thisVals = utilArrayUniq((tagVal || '').split(';')).filter(Boolean);
allValues = allValues.concat(thisVals);
if (!commonValues) {
commonValues = thisVals;
@@ -544,9 +544,6 @@ export function uiFieldCombo(field, context) {
.attr('class', 'remove')
.text('×');
container.selectAll('input[type="text"]')
.attr('maxlength', maxLength);
} else {
var isMixed = Array.isArray(tags[field.key]);
+1 -2
View File
@@ -56,7 +56,6 @@ export function uiFieldCycleway(field, context) {
.attr('class', 'preset-input-cycleway-wrap')
.append('input')
.attr('type', 'text')
.attr('maxlength', context.maxCharsForTagValue())
.attr('class', function(d) { return 'preset-input-cycleway preset-input-' + stripcolon(d); })
.call(utilNoAuto)
.each(function(d) {
@@ -77,7 +76,7 @@ export function uiFieldCycleway(field, context) {
function change(key) {
var newValue = utilGetSetValue(d3_select(this));
var newValue = context.cleanTagValue(utilGetSetValue(d3_select(this)));
// don't override multiple values with blank string
if (!newValue && (Array.isArray(_tags.cycleway) || Array.isArray(_tags[key]))) return;
+4 -5
View File
@@ -56,7 +56,6 @@ export function uiFieldText(field, context) {
.append('input')
.attr('type', field.type === 'identifier' ? 'text' : field.type)
.attr('id', field.domId)
.attr('maxlength', context.maxCharsForTagValue())
.classed(field.type, true)
.call(utilNoAuto)
.merge(input);
@@ -167,13 +166,13 @@ export function uiFieldText(field, context) {
function change(onInput) {
return function() {
var t = {};
var val = utilGetSetValue(input).trim() || undefined;
var val = context.cleanTagValue(utilGetSetValue(input));
// don't override multiple values with blank string
if (!val && Array.isArray(_tags[field.key])) return;
if (!onInput) {
if (field.type === 'number' && val !== undefined) {
if (field.type === 'number' && val) {
var vals = val.split(';');
vals = vals.map(function(v) {
var num = parseFloat(v.trim(), 10);
@@ -181,9 +180,9 @@ export function uiFieldText(field, context) {
});
val = vals.join(';');
}
utilGetSetValue(input, val || '');
utilGetSetValue(input, val);
}
t[field.key] = val;
t[field.key] = val || undefined;
dispatch.call('change', this, t, onInput);
};
}
+12 -10
View File
@@ -170,7 +170,6 @@ export function uiFieldLocalized(field, context) {
.attr('type', 'text')
.attr('id', field.domId)
.attr('class', 'localized-main')
.attr('maxlength', context.maxCharsForTagValue())
.call(utilNoAuto)
.merge(input);
@@ -385,13 +384,15 @@ export function uiFieldLocalized(field, context) {
d3_event.preventDefault();
return;
}
var t = {};
var val = utilGetSetValue(d3_select(this)) || undefined;
var val = context.cleanTagValue(utilGetSetValue(d3_select(this)));
// don't override multiple values with blank string
if (!val && Array.isArray(_tags[field.key])) return;
t[field.key] = val;
var t = {};
t[field.key] = val || undefined;
dispatch.call('change', this, t, onInput);
};
}
@@ -420,12 +421,14 @@ export function uiFieldLocalized(field, context) {
tags[key(d.lang)] = undefined;
}
var newKey = lang && context.cleanTagKey(key(lang));
var value = utilGetSetValue(d3_select(this.parentNode).selectAll('.localized-value'));
if (lang && value) {
tags[key(lang)] = value;
} else if (lang && _wikiTitles && _wikiTitles[d.lang]) {
tags[key(lang)] = _wikiTitles[d.lang];
if (newKey && value) {
tags[newKey] = value;
} else if (newKey && _wikiTitles && _wikiTitles[d.lang]) {
tags[newKey] = _wikiTitles[d.lang];
}
d.lang = lang;
@@ -435,7 +438,7 @@ export function uiFieldLocalized(field, context) {
function changeValue(d) {
if (!d.lang) return;
var value = utilGetSetValue(d3_select(this)) || undefined;
var value = context.cleanTagValue(utilGetSetValue(d3_select(this))) || undefined;
// don't override multiple values with blank string
if (!value && Array.isArray(d.value)) return;
@@ -549,7 +552,6 @@ export function uiFieldLocalized(field, context) {
wrap
.append('input')
.attr('type', 'text')
.attr('maxlength', context.maxCharsForTagValue())
.attr('class', 'localized-value')
.on('blur', changeValue)
.on('change', changeValue);
+3 -4
View File
@@ -43,7 +43,6 @@ export function uiFieldMaxspeed(field, context) {
.attr('type', 'text')
.attr('class', 'maxspeed-number')
.attr('id', field.domId)
.attr('maxlength', context.maxCharsForTagValue() - 4)
.call(utilNoAuto)
.call(speedCombo)
.merge(input);
@@ -95,7 +94,7 @@ export function uiFieldMaxspeed(field, context) {
function change() {
var tag = {};
var value = utilGetSetValue(input);
var value = utilGetSetValue(input).trim();
// don't override multiple values with blank string
if (!value && Array.isArray(_tags[field.key])) return;
@@ -103,9 +102,9 @@ export function uiFieldMaxspeed(field, context) {
if (!value) {
tag[field.key] = undefined;
} else if (isNaN(value) || !_isImperial) {
tag[field.key] = value;
tag[field.key] = context.cleanTagValue(value);
} else {
tag[field.key] = value + ' mph';
tag[field.key] = context.cleanTagValue(value + ' mph');
}
dispatch.call('change', this, tag);
+2 -3
View File
@@ -30,7 +30,6 @@ export function uiFieldTextarea(field, context) {
input = input.enter()
.append('textarea')
.attr('id', field.domId)
.attr('maxlength', context.maxCharsForTagValue())
.call(utilNoAuto)
.on('input', change(true))
.on('blur', change())
@@ -42,13 +41,13 @@ export function uiFieldTextarea(field, context) {
function change(onInput) {
return function() {
var val = utilGetSetValue(input) || undefined;
var val = context.cleanTagValue(utilGetSetValue(input));
// don't override multiple values with blank string
if (!val && Array.isArray(_tags[field.key])) return;
var t = {};
t[field.key] = val;
t[field.key] = val || undefined;
dispatch.call('change', this, t, onInput);
};
}
+2 -3
View File
@@ -14,8 +14,7 @@ import { svgIcon } from '../../svg/icon';
import {
utilGetSetValue,
utilNoAuto,
utilRebind,
utilUnicodeCharsTruncated
utilRebind
} from '../../util';
import { t } from '../../core/localizer';
@@ -237,7 +236,7 @@ export function uiFieldWikidata(field, context) {
}
if (newWikipediaValue) {
newWikipediaValue = utilUnicodeCharsTruncated(newWikipediaValue, context.maxCharsForTagValue());
newWikipediaValue = context.cleanTagValue(newWikipediaValue);
}
if (typeof newWikipediaValue === 'undefined') return;
+2 -3
View File
@@ -7,7 +7,7 @@ import { actionChangeTags } from '../../actions/change_tags';
import { services } from '../../services/index';
import { svgIcon } from '../../svg/icon';
import { uiCombobox } from '../combobox';
import { utilGetSetValue, utilNoAuto, utilRebind, utilUnicodeCharsTruncated } from '../../util';
import { utilGetSetValue, utilNoAuto, utilRebind } from '../../util';
export function uiFieldWikipedia(field, context) {
@@ -115,7 +115,6 @@ export function uiFieldWikipedia(field, context) {
.attr('type', 'text')
.attr('class', 'wiki-title')
.attr('id', field.domId)
.attr('maxlength', context.maxCharsForTagValue() - 4)
.call(utilNoAuto)
.call(titleCombo)
.merge(_titleInput);
@@ -192,7 +191,7 @@ export function uiFieldWikipedia(field, context) {
}
if (value) {
syncTags.wikipedia = utilUnicodeCharsTruncated(language()[2] + ':' + value, context.maxCharsForTagValue());
syncTags.wikipedia = context.cleanTagValue(language()[2] + ':' + value);
} else {
syncTags.wikipedia = undefined;
}
+1 -2
View File
@@ -81,7 +81,7 @@ export function uiSectionRawMemberEditor(context) {
function changeRole(d) {
var oldRole = d.role;
var newRole = d3_select(this).property('value');
var newRole = context.cleanRelationRole(d3_select(this).property('value'));
if (oldRole !== newRole) {
var member = { id: d.id, type: d.type, role: newRole };
@@ -232,7 +232,6 @@ export function uiSectionRawMemberEditor(context) {
return d.domId;
})
.property('type', 'text')
.attr('maxlength', context.maxCharsForRelationRole())
.attr('placeholder', t('inspector.role'))
.call(utilNoAuto);
+2 -4
View File
@@ -67,7 +67,7 @@ export function uiSectionRawMembershipEditor(context) {
if (_inChange) return; // avoid accidental recursive call #5731
var oldRole = d.member.role;
var newRole = d3_select(this).property('value');
var newRole = context.cleanRelationRole(d3_select(this).property('value'));
if (oldRole !== newRole) {
_inChange = true;
@@ -267,7 +267,6 @@ export function uiSectionRawMembershipEditor(context) {
return d.domId;
})
.property('type', 'text')
.attr('maxlength', context.maxCharsForRelationRole())
.attr('placeholder', t('inspector.role'))
.call(utilNoAuto)
.property('value', function(d) { return d.member.role; })
@@ -315,7 +314,6 @@ export function uiSectionRawMembershipEditor(context) {
.append('input')
.attr('class', 'member-role')
.property('type', 'text')
.attr('maxlength', context.maxCharsForRelationRole())
.attr('placeholder', t('inspector.role'))
.call(utilNoAuto);
@@ -387,7 +385,7 @@ export function uiSectionRawMembershipEditor(context) {
// remove hover-higlighting
if (d.relation) utilHighlightEntities([d.relation.id], false, context);
var role = list.selectAll('.member-row-new .member-role').property('value');
var role = context.cleanRelationRole(list.selectAll('.member-row-new .member-role').property('value'));
addMembership(d, role);
}
+32 -32
View File
@@ -9,7 +9,7 @@ import { uiTagReference } from '../tag_reference';
import { prefs } from '../../core/preferences';
import { t } from '../../core/localizer';
import { utilArrayDifference, utilArrayIdentical } from '../../util/array';
import { utilGetSetValue, utilNoAuto, utilRebind, utilTagDiff, utilUnicodeCharsTruncated } from '../../util';
import { utilGetSetValue, utilNoAuto, utilRebind, utilTagDiff } from '../../util';
export function uiSectionRawTagEditor(id, context) {
@@ -187,7 +187,6 @@ export function uiSectionRawTagEditor(id, context) {
.append('input')
.property('type', 'text')
.attr('class', 'key')
.attr('maxlength', context.maxCharsForTagKey())
.call(utilNoAuto)
.on('blur', keyChange)
.on('change', keyChange);
@@ -198,7 +197,6 @@ export function uiSectionRawTagEditor(id, context) {
.append('input')
.property('type', 'text')
.attr('class', 'value')
.attr('maxlength', context.maxCharsForTagValue())
.call(utilNoAuto)
.on('blur', valueChange)
.on('change', valueChange)
@@ -338,13 +336,11 @@ export function uiSectionRawTagEditor(id, context) {
function textChanged() {
var newText = this.value.trim();
var newTags = {};
var maxKeyLength = context.maxCharsForTagKey();
var maxValueLength = context.maxCharsForTagValue();
newText.split('\n').forEach(function(row) {
var m = row.match(/^\s*([^=]+)=(.*)$/);
if (m !== null) {
var k = utilUnicodeCharsTruncated(unstringify(m[1].trim()), maxKeyLength);
var v = utilUnicodeCharsTruncated(unstringify(m[2].trim()), maxValueLength);
var k = context.cleanTagKey(unstringify(m[1].trim()));
var v = context.cleanTagValue(unstringify(m[2].trim()));
newTags[k] = v;
}
});
@@ -461,10 +457,11 @@ export function uiSectionRawTagEditor(id, context) {
if (d3_select(this).attr('readonly')) return;
var kOld = d.key;
var kNew = this.value.trim();
var row = this.parentNode.parentNode;
var inputVal = d3_select(row).selectAll('input.value');
var vNew = utilGetSetValue(inputVal);
// exit if we are currently about to delete this row anyway - #6366
if (_pendingChange && _pendingChange.hasOwnProperty(kOld) && _pendingChange[kOld] === undefined) return;
var kNew = context.cleanTagKey(this.value.trim());
// allow no change if the key should be readonly
if (isReadOnly({ key: kNew })) {
@@ -472,26 +469,29 @@ export function uiSectionRawTagEditor(id, context) {
return;
}
// switch focus if key is already in use
if (kNew && kNew !== kOld) {
if (_tags[kNew] !== undefined) { // new key is already in use
this.value = kOld; // reset the key
section.selection().selectAll('.tag-list input.value')
.each(function(d) {
if (d.key === kNew) { // send focus to that other value combo instead
var input = d3_select(this).node();
input.focus();
input.select();
}
});
return;
}
if (kNew &&
kNew !== kOld &&
_tags[kNew] !== undefined) {
// new key is already in use, switch focus to the existing row
this.value = kOld; // reset the key
section.selection().selectAll('.tag-list input.value')
.each(function(d) {
if (d.key === kNew) { // send focus to that other value combo instead
var input = d3_select(this).node();
input.focus();
input.select();
}
});
return;
}
_pendingChange = _pendingChange || {};
// exit if we are currently about to delete this row anyway - #6366
if (_pendingChange.hasOwnProperty(d.key) && _pendingChange[d.key] === undefined) return;
var row = this.parentNode.parentNode;
var inputVal = d3_select(row).selectAll('input.value');
var vNew = context.cleanTagValue(utilGetSetValue(inputVal));
_pendingChange = _pendingChange || {};
if (kOld) {
_pendingChange[kOld] = undefined;
@@ -517,12 +517,12 @@ export function uiSectionRawTagEditor(id, context) {
// exit if this is a multiselection and no value was entered
if (typeof d.value !== 'string' && !this.value) return;
_pendingChange = _pendingChange || {};
// exit if we are currently about to delete this row anyway - #6366
if (_pendingChange.hasOwnProperty(d.key) && _pendingChange[d.key] === undefined) return;
if (_pendingChange && _pendingChange.hasOwnProperty(d.key) && _pendingChange[d.key] === undefined) return;
_pendingChange[d.key] = this.value;
_pendingChange = _pendingChange || {};
_pendingChange[d.key] = context.cleanTagValue(this.value);
scheduleChange();
}