diff --git a/build_data.js b/build_data.js index 1f2daac3a4..50d282f7a3 100644 --- a/build_data.js +++ b/build_data.js @@ -55,9 +55,12 @@ module.exports = function buildData() { // Font Awesome icons used var faIcons = { - 'fas-long-arrow-alt-right': {} + 'fas-i-cursor': {}, + 'fas-long-arrow-alt-right': {}, + 'fas-th-list': {} }; + // The Noun Project icons used var tnpIcons = {}; // Start clean diff --git a/css/80_app.css b/css/80_app.css index a99b480a3c..29398a4062 100644 --- a/css/80_app.css +++ b/css/80_app.css @@ -259,10 +259,12 @@ table.tags, table.tags td, table.tags th { .ar { right: 0; } input.hide, +textarea.hide, div.hide, form.hide, button.hide, a.hide, +ul.hide, li.hide { display: none; } @@ -2400,8 +2402,56 @@ div.combobox { /* Raw Tag Editor ------------------------------------------------------- */ +.raw-tag-options { + display: flex; + flex-flow: row nowrap; + flex-direction: row-reverse; + margin-top: -25px; + padding: 0 3px; +} +button.raw-tag-option { + flex: 0 0 20px; + height: 20px; + width: 20px; + background: #aaa; + color: #eee; + margin: 0 3px; +} +button.raw-tag-option:focus, +button.raw-tag-option:hover, +button.raw-tag-option.active { + color: #fff; + background: #597be7; +} +button.raw-tag-option.selected { + color: #fff; + background: #7092ff; +} +button.raw-tag-option svg.icon { + width: 14px; + height: 14px; + vertical-align: text-bottom; +} +[dir='ltr'] button.raw-tag-option-list { + -moz-transform: scaleX(-1); + -o-transform: scaleX(-1); + -webkit-transform: scaleX(-1); + transform: scaleX(-1); + filter: FlipH; + -ms-filter: "FlipH"; +} + + +.tag-text { + width: 100%; + height: 100%; + font-family: monospace; + white-space: pre; +} + +.tag-text, .tag-list { - padding-top: 10px; + margin-top: 10px; } .tag-row { width: 100%; diff --git a/modules/ui/entity_editor.js b/modules/ui/entity_editor.js index 9b3ae46e1e..55204e4751 100644 --- a/modules/ui/entity_editor.js +++ b/modules/ui/entity_editor.js @@ -17,7 +17,7 @@ import { uiTagReference } from './tag_reference'; import { uiPresetEditor } from './preset_editor'; import { uiEntityIssues } from './entity_issues'; import { uiTooltipHtml } from './tooltipHtml'; -import { utilCleanTags, utilRebind } from '../util'; +import { utilCallWhenIdle, utilCleanTags, utilRebind } from '../util'; export function uiEntityEditor(context) { @@ -25,6 +25,7 @@ export function uiEntityEditor(context) { var _state = 'select'; var _coalesceChanges = false; var _modified = false; + var _scrolled = false; var _base; var _entityID; var _activePreset; @@ -83,7 +84,8 @@ export function uiEntityEditor(context) { // Enter var bodyEnter = body.enter() .append('div') - .attr('class', 'inspector-body'); + .attr('class', 'inspector-body') + .on('scroll.entity-editor', function() { _scrolled = true; }); bodyEnter .append('div') @@ -327,9 +329,14 @@ export function uiEntityEditor(context) { _coalesceChanges = false; // reset the scroll to the top of the inspector (warning: triggers reflow) - var body = d3_selectAll('.entity-editor-pane .inspector-body'); - if (!body.empty()) { - body.node().scrollTop = 0; + if (_scrolled) { + utilCallWhenIdle(function() { + var body = d3_selectAll('.entity-editor-pane .inspector-body'); + if (!body.empty()) { + _scrolled = false; + body.node().scrollTop = 0; + } + })(); } var presetMatch = context.presets().match(context.entity(_entityID), _base); diff --git a/modules/ui/raw_tag_editor.js b/modules/ui/raw_tag_editor.js index 72c48745fa..1353694966 100644 --- a/modules/ui/raw_tag_editor.js +++ b/modules/ui/raw_tag_editor.js @@ -7,12 +7,18 @@ import { svgIcon } from '../svg/icon'; import { uiCombobox } from './combobox'; import { uiDisclosure } from './disclosure'; import { uiTagReference } from './tag_reference'; -import { utilArrayDifference, utilGetSetValue, utilNoAuto, utilRebind } from '../util'; +import { utilArrayDifference, utilGetSetValue, utilNoAuto, utilRebind, utilTagDiff } from '../util'; export function uiRawTagEditor(context) { var taginfo = services.taginfo; var dispatch = d3_dispatch('change'); + var availableViews = [ + { id: 'text', icon: '#fas-i-cursor' }, + { id: 'list', icon: '#fas-th-list' } + ]; + + var _tagView = (context.storage('raw-tag-editor-view') || 'list'); // 'list, 'text' var _readOnlyTags = []; var _indexedKeys = []; var _showBlank = false; @@ -75,13 +81,80 @@ export function uiRawTagEditor(context) { rowData.push({ index: _indexedKeys.length, key: '', value: '' }); } - // List of tags + + // View Options + var options = wrap.selectAll('.raw-tag-options') + .data([0]); + + var optionsEnter = options.enter() + .append('div') + .attr('class', 'raw-tag-options'); + + var optionEnter = optionsEnter.selectAll('.raw-tag-option') + .data(availableViews, function(d) { return d.id; }) + .enter(); + + optionEnter + .append('button') + .attr('class', function(d) { + return 'raw-tag-option raw-tag-option-' + d.id + (_tagView === d.id ? ' selected' : ''); + }) + .attr('title', function(d) { return d.id; }) + .on('click', function(d) { + _tagView = d.id; + context.storage('raw-tag-editor-view', d.id); + + wrap.selectAll('.raw-tag-option') + .classed('selected', function(datum) { return datum === d; }); + + wrap.selectAll('.tag-text') + .classed('hide', (d.id !== 'text')) + .each(setTextareaHeight); + + wrap.selectAll('.tag-list, .add-row') + .classed('hide', (d.id !== 'list')); + }) + .each(function(d) { + d3_select(this) + .call(svgIcon(d.icon)); + }); + + + // View as Text + var textData = rowsToText(rowData); + var textarea = wrap.selectAll('.tag-text') + .data([0]); + + textarea = textarea.enter() + .append('textarea') + .attr('class', 'tag-text' + (_tagView !== 'text' ? ' hide' : '')) + .call(utilNoAuto) + .attr('spellcheck', 'false') + .merge(textarea); + + textarea + .call(utilGetSetValue, textData) + .each(setTextareaHeight) + .on('input', setTextareaHeight) + .on('blur', textChanged) + .on('change', textChanged); + + // If All Fields section is hidden, focus textarea and put cursor at end.. + var fieldsExpanded = d3_select('.hide-toggle-preset_fields.expanded').size(); + if (_state !== 'hover' && _tagView === 'text' && !fieldsExpanded) { + var element = textarea.node(); + element.focus(); + element.setSelectionRange(textData.length, textData.length); + } + + + // View as List var list = wrap.selectAll('.tag-list') .data([0]); list = list.enter() .append('ul') - .attr('class', 'tag-list') + .attr('class', 'tag-list' + (_tagView !== 'list' ? ' hide' : '')) .merge(list); @@ -90,7 +163,7 @@ export function uiRawTagEditor(context) { .data([0]) .enter() .append('div') - .attr('class', 'add-row'); + .attr('class', 'add-row' + (_tagView !== 'list' ? ' hide' : '')); addRowEnter .append('button') @@ -217,6 +290,75 @@ export function uiRawTagEditor(context) { } + function setTextareaHeight() { + if (_tagView !== 'text') return; + + var selection = d3_select(this); + selection.style('height', null); + selection.style('height', selection.node().scrollHeight + 5 + 'px'); + } + + + function stringify(s) { + return JSON.stringify(s).slice(1, -1); // without leading/trailing " + } + + function unstringify(s) { + var leading = ''; + var trailing = ''; + if (s.length < 1 || s.charAt(0) !== '"') { + leading = '"'; + } + if (s.length < 2 || s.charAt(s.length - 1) !== '"' || + (s.charAt(s.length - 1) === '"' && s.charAt(s.length - 2) === '\\') + ) { + trailing = '"'; + } + return JSON.parse(leading + s + trailing); + } + + + function rowsToText(rows) { + var str = rows + .filter(function(row) { return row.key && row.key.trim() !== ''; }) + .map(function(row) { return stringify(row.key) + '=' + stringify(row.value); }) + .join('\n'); + + return _state === 'hover' ? str : str + '\n'; + } + + + function textChanged() { + var newText = this.value.trim(); + var newTags = {}; + newText.split('\n').forEach(function(row) { + var m = row.match(/^\s*([^=]+)=(.*)$/); + if (m !== null) { + var k = unstringify(m[1].trim()); + var v = unstringify(m[2].trim()); + newTags[k] = v; + } + }); + + var tagDiff = utilTagDiff(_tags, newTags); + if (!tagDiff.length) return; + + _pendingChange = _pendingChange || {}; + + tagDiff.forEach(function(change) { + if (isReadOnly({ key: change.key })) return; + + if (change.type === '-') { + _pendingChange[change.key] = undefined; + } else if (change.type === '+') { + _pendingChange[change.key] = change.newVal || ''; + } + }); + + scheduleChange(); + } + + function pushMore() { if (d3_event.keyCode === 9 && !d3_event.shiftKey && list.selectAll('li:last-child input.value').node() === this) { @@ -317,25 +459,7 @@ export function uiRawTagEditor(context) { _pendingChange[kOld] = undefined; } - // if the key looks like "key=value key2=value2", split them up - #5024 - var keys = (kNew.match(/[\w_]+=/g) || []).map(function (key) { return key.slice(0, -1); }); - var vals = keys.length === 0 - ? [] - : kNew - .split(new RegExp(keys.map(function (key) { return key.replace('_', '\\_'); }).join('|'))) - .splice(1) - .map(function (val) { return val.slice(1).trim(); }); - - if (keys.length > 0) { - kNew = keys[0]; - vNew = vals[0]; - - keys.forEach(function (key, i) { - _pendingChange[key] = vals[i]; - }); - } else { - _pendingChange[kNew] = vNew; - } + _pendingChange[kNew] = vNew; d.key = kNew; // update datum to avoid exit/enter on tag update d.value = vNew; diff --git a/modules/util/index.js b/modules/util/index.js index b7d51e668c..dde2a5614a 100644 --- a/modules/util/index.js +++ b/modules/util/index.js @@ -37,6 +37,7 @@ export { utilRebind } from './rebind'; export { utilSetTransform } from './util'; export { utilSessionMutex } from './session_mutex'; export { utilStringQs } from './util'; +export { utilTagDiff } from './util'; export { utilTagText } from './util'; export { utilTiler } from './tiler'; export { utilTriggerEvent } from './trigger_event'; diff --git a/modules/util/util.js b/modules/util/util.js index 2dc9c9b03a..a5bfc25343 100644 --- a/modules/util/util.js +++ b/modules/util/util.js @@ -1,8 +1,10 @@ -import { t, textDirection } from './locale'; -import { utilDetect } from './detect'; import { remove as removeDiacritics } from 'diacritics'; import { fixRTLTextForSvg, rtlRegex } from './svg_paths_rtl_fix'; +import { t, textDirection } from './locale'; +import { utilArrayUnion } from './array'; +import { utilDetect } from './detect'; + export function utilTagText(entity) { var obj = (entity && entity.tags) || {}; @@ -12,6 +14,36 @@ export function utilTagText(entity) { } +export function utilTagDiff(oldTags, newTags) { + var tagDiff = []; + var keys = utilArrayUnion(Object.keys(oldTags), Object.keys(newTags)).sort(); + keys.forEach(function(k) { + var oldVal = oldTags[k]; + var newVal = newTags[k]; + + if (oldVal && (!newVal || newVal !== oldVal)) { + tagDiff.push({ + type: '-', + key: k, + oldVal: oldVal, + newVal: newVal, + display: '- ' + k + '=' + oldVal + }); + } + if (newVal && (!oldVal || newVal !== oldVal)) { + tagDiff.push({ + type: '+', + key: k, + oldVal: oldVal, + newVal: newVal, + display: '+ ' + k + '=' + newVal + }); + } + }); + return tagDiff; +} + + export function utilEntitySelector(ids) { return ids.length ? '.' + ids.join(',.') : 'nothing'; } diff --git a/modules/validations/outdated_tags.js b/modules/validations/outdated_tags.js index a17156a79c..e3336e68da 100644 --- a/modules/validations/outdated_tags.js +++ b/modules/validations/outdated_tags.js @@ -3,7 +3,7 @@ import { actionChangePreset } from '../actions/change_preset'; import { actionChangeTags } from '../actions/change_tags'; import { actionUpgradeTags } from '../actions/upgrade_tags'; import { osmIsOldMultipolygonOuterMember, osmOldMultipolygonOuterMemberOfRelation } from '../osm/multipolygon'; -import { utilArrayUnion, utilDisplayLabel } from '../util'; +import { utilDisplayLabel, utilTagDiff } from '../util'; import { validationIssue, validationIssueFix } from '../core/validation'; @@ -48,20 +48,7 @@ export function validationOutdatedTags() { } // determine diff - var keys = utilArrayUnion(Object.keys(oldTags), Object.keys(newTags)).sort(); - var tagDiff = []; - keys.forEach(function(k) { - var oldVal = oldTags[k]; - var newVal = newTags[k]; - - if (oldVal && (!newVal || newVal !== oldVal)) { - tagDiff.push('- ' + k + '=' + oldVal); - } - if (newVal && (!oldVal || newVal !== oldVal)) { - tagDiff.push('+ ' + k + '=' + newVal); - } - }); - + var tagDiff = utilTagDiff(oldTags, newTags); if (!tagDiff.length) return []; return [new validationIssue({ @@ -112,10 +99,10 @@ export function validationOutdatedTags() { .attr('class', 'tagDiff-row') .append('td') .attr('class', function(d) { - var klass = d.charAt(0) === '+' ? 'add' : 'remove'; + var klass = d.type === '+' ? 'add' : 'remove'; return 'tagDiff-cell tagDiff-cell-' + klass; }) - .text(function(d) { return d; }); + .text(function(d) { return d.display; }); } } diff --git a/modules/validations/private_data.js b/modules/validations/private_data.js index 53adef44cc..15c6ae5b79 100644 --- a/modules/validations/private_data.js +++ b/modules/validations/private_data.js @@ -1,6 +1,6 @@ import { actionChangeTags } from '../actions/change_tags'; import { t } from '../util/locale'; -import { utilDisplayLabel } from '../util'; +import { utilDisplayLabel, utilTagDiff } from '../util'; import { validationIssue, validationIssueFix } from '../core/validation'; @@ -40,20 +40,17 @@ export function validationPrivateData() { var validation = function checkPrivateData(entity, context) { var tags = entity.tags; - var keepTags = {}; - var tagDiff = []; if (!tags.building || !privateBuildingValues[tags.building]) return []; + var keepTags = {}; for (var k in tags) { if (publicKeys[k]) return []; // probably a public feature - - if (personalTags[k]) { - tagDiff.push('- ' + k + '=' + tags[k]); - } else { + if (!personalTags[k]) { keepTags[k] = tags[k]; } } + var tagDiff = utilTagDiff(tags, keepTags); if (!tagDiff.length) return []; var fixID = tagDiff.length === 1 ? 'remove_tag' : 'remove_tags'; @@ -110,10 +107,10 @@ export function validationPrivateData() { .attr('class', 'tagDiff-row') .append('td') .attr('class', function(d) { - var klass = d.charAt(0) === '+' ? 'add' : 'remove'; + var klass = d.type === '+' ? 'add' : 'remove'; return 'tagDiff-cell tagDiff-cell-' + klass; }) - .text(function(d) { return d; }); + .text(function(d) { return d.display; }); } }; diff --git a/svg/fontawesome/fas-i-cursor.svg b/svg/fontawesome/fas-i-cursor.svg new file mode 100644 index 0000000000..8670a48aab --- /dev/null +++ b/svg/fontawesome/fas-i-cursor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svg/fontawesome/fas-th-list.svg b/svg/fontawesome/fas-th-list.svg new file mode 100644 index 0000000000..108272386a --- /dev/null +++ b/svg/fontawesome/fas-th-list.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test/spec/util/util.js b/test/spec/util/util.js index be330efaf9..63b9999552 100644 --- a/test/spec/util/util.js +++ b/test/spec/util/util.js @@ -2,57 +2,76 @@ describe('iD.util', function() { describe('utilGetAllNodes', function() { it('gets all descendant nodes of a way', function() { - var a = iD.osmNode({ id: 'a' }), - b = iD.osmNode({ id: 'b' }), - w = iD.osmWay({ id: 'w', nodes: ['a','b','a'] }), - graph = iD.coreGraph([a, b, w]), - result = iD.utilGetAllNodes(['w'], graph); + var a = iD.osmNode({ id: 'a' }); + var b = iD.osmNode({ id: 'b' }); + var w = iD.osmWay({ id: 'w', nodes: ['a','b','a'] }); + var graph = iD.coreGraph([a, b, w]); + var result = iD.utilGetAllNodes(['w'], graph); expect(result).to.have.members([a, b]); expect(result).to.have.lengthOf(2); }); it('gets all descendant nodes of a relation', function() { - var a = iD.osmNode({ id: 'a' }), - b = iD.osmNode({ id: 'b' }), - c = iD.osmNode({ id: 'c' }), - w = iD.osmWay({ id: 'w', nodes: ['a','b','a'] }), - r = iD.osmRelation({ id: 'r', members: [{id: 'w'}, {id: 'c'}] }), - graph = iD.coreGraph([a, b, c, w, r]), - result = iD.utilGetAllNodes(['r'], graph); + var a = iD.osmNode({ id: 'a' }); + var b = iD.osmNode({ id: 'b' }); + var c = iD.osmNode({ id: 'c' }); + var w = iD.osmWay({ id: 'w', nodes: ['a','b','a'] }); + var r = iD.osmRelation({ id: 'r', members: [{id: 'w'}, {id: 'c'}] }); + var graph = iD.coreGraph([a, b, c, w, r]); + var result = iD.utilGetAllNodes(['r'], graph); expect(result).to.have.members([a, b, c]); expect(result).to.have.lengthOf(3); }); it('gets all descendant nodes of multiple ids', function() { - var a = iD.osmNode({ id: 'a' }), - b = iD.osmNode({ id: 'b' }), - c = iD.osmNode({ id: 'c' }), - d = iD.osmNode({ id: 'd' }), - e = iD.osmNode({ id: 'e' }), - w1 = iD.osmWay({ id: 'w1', nodes: ['a','b','a'] }), - w2 = iD.osmWay({ id: 'w2', nodes: ['c','b','a','c'] }), - r = iD.osmRelation({ id: 'r', members: [{id: 'w1'}, {id: 'd'}] }), - graph = iD.coreGraph([a, b, c, d, e, w1, w2, r]), - result = iD.utilGetAllNodes(['r', 'w2', 'e'], graph); + var a = iD.osmNode({ id: 'a' }); + var b = iD.osmNode({ id: 'b' }); + var c = iD.osmNode({ id: 'c' }); + var d = iD.osmNode({ id: 'd' }); + var e = iD.osmNode({ id: 'e' }); + var w1 = iD.osmWay({ id: 'w1', nodes: ['a','b','a'] }); + var w2 = iD.osmWay({ id: 'w2', nodes: ['c','b','a','c'] }); + var r = iD.osmRelation({ id: 'r', members: [{id: 'w1'}, {id: 'd'}] }); + var graph = iD.coreGraph([a, b, c, d, e, w1, w2, r]); + var result = iD.utilGetAllNodes(['r', 'w2', 'e'], graph); expect(result).to.have.members([a, b, c, d, e]); expect(result).to.have.lengthOf(5); }); it('handles recursive relations', function() { - var a = iD.osmNode({ id: 'a' }), - r1 = iD.osmRelation({ id: 'r1', members: [{id: 'r2'}] }), - r2 = iD.osmRelation({ id: 'r2', members: [{id: 'r1'}, {id: 'a'}] }), - graph = iD.coreGraph([a, r1, r2]), - result = iD.utilGetAllNodes(['r1'], graph); + var a = iD.osmNode({ id: 'a' }); + var r1 = iD.osmRelation({ id: 'r1', members: [{id: 'r2'}] }); + var r2 = iD.osmRelation({ id: 'r2', members: [{id: 'r1'}, {id: 'a'}] }); + var graph = iD.coreGraph([a, r1, r2]); + var result = iD.utilGetAllNodes(['r1'], graph); expect(result).to.have.members([a]); expect(result).to.have.lengthOf(1); }); }); + it('utilTagDiff', function() { + var oldTags = { a: 'one', b: 'two', c: 'three' }; + var newTags = { a: 'one', b: 'three', d: 'four' }; + var diff = iD.utilTagDiff(oldTags, newTags); + expect(diff).to.have.length(4); + expect(diff[0]).to.eql({ + type: '-', key: 'b', oldVal: 'two', newVal: 'three', display: '- b=two' // delete-modify + }); + expect(diff[1]).to.eql({ + type: '+', key: 'b', oldVal: 'two', newVal: 'three', display: '+ b=three' // insert-modify + }); + expect(diff[2]).to.eql({ + type: '-', key: 'c', oldVal: 'three', newVal: undefined, display: '- c=three' // delete + }); + expect(diff[3]).to.eql({ + type: '+', key: 'd', oldVal: undefined, newVal: 'four', display: '+ d=four' // insert + }); + }); + it('utilTagText', function() { expect(iD.utilTagText({})).to.eql(''); expect(iD.utilTagText({tags:{foo:'bar'}})).to.eql('foo=bar');