diff --git a/css/80_app.css b/css/80_app.css index 56c635af6..2c0e773e9 100644 --- a/css/80_app.css +++ b/css/80_app.css @@ -1245,18 +1245,12 @@ a.hide-toggle { display: flex; flex-flow: row wrap; justify-content: flex-end; - padding: 0 20px; + padding: 5px 0 0 0; } .quick-link { margin: 0 5px; } -.data-editor .quick-links, -.error-editor .quick-links, -.note-editor .quick-links { - padding: 5px 0 0 0; -} - /* Entity/Preset Editor ------------------------------------------------------- */ diff --git a/data/core.yaml b/data/core.yaml index 3fd1e0841..a14a5092c 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -8,6 +8,8 @@ en: copy: copy view_on: view on {domain} favorite: favorite + list: list + text: text toolbar: inspect: Inspect undo_redo: Undo / Redo @@ -555,17 +557,19 @@ en: edit_reference: "edit/translate" wiki_reference: View documentation wiki_en_reference: View documentation in English + key_value: "key=value" + multiple_values: Multiple Values hidden_preset: manual: "{features} are hidden. Enable them in the Map Data pane." zoom: "{features} are hidden. Zoom in to enable them." back_tooltip: Change feature remove: Remove search: Search - multiselect: Selected features unknown: Unknown incomplete: feature_list: Search features edit: Edit feature + edit_features: Edit features check: "yes": "Yes" "no": "No" diff --git a/dist/locales/en.json b/dist/locales/en.json index ca3c293e7..f95a50d56 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -8,7 +8,9 @@ "zoom_to": "zoom to", "copy": "copy", "view_on": "view on {domain}", - "favorite": "favorite" + "favorite": "favorite", + "list": "list", + "text": "text" }, "toolbar": { "inspect": "Inspect", @@ -698,6 +700,8 @@ "edit_reference": "edit/translate", "wiki_reference": "View documentation", "wiki_en_reference": "View documentation in English", + "key_value": "key=value", + "multiple_values": "Multiple Values", "hidden_preset": { "manual": "{features} are hidden. Enable them in the Map Data pane.", "zoom": "{features} are hidden. Zoom in to enable them." @@ -705,11 +709,11 @@ "back_tooltip": "Change feature", "remove": "Remove", "search": "Search", - "multiselect": "Selected features", "unknown": "Unknown", "incomplete": "", "feature_list": "Search features", "edit": "Edit feature", + "edit_features": "Edit features", "check": { "yes": "Yes", "no": "No", diff --git a/modules/modes/select.js b/modules/modes/select.js index 9f59eef6a..34b87d987 100644 --- a/modules/modes/select.js +++ b/modules/modes/select.js @@ -19,7 +19,6 @@ import { modeDragNote } from './drag_note'; import { osmNode, osmWay } from '../osm'; import * as Operations from '../operations/index'; import { uiEditMenu } from '../ui/edit_menu'; -import { uiSelectionList } from '../ui/selection_list'; import { uiCmd } from '../ui/cmd'; import { utilArrayIntersection, utilDeepMemberSelector, utilEntityOrDeepMemberSelector, @@ -307,7 +306,7 @@ export function modeSelect(context, selectedIDs) { .call(keybinding); context.ui().sidebar - .select(singular() ? singular().id : null, _newFeature); + .select(selectedIDs, _newFeature); context.history() .on('change.select', function() { @@ -332,11 +331,6 @@ export function modeSelect(context, selectedIDs) { selectElements(); - if (selectedIDs.length > 1) { - var entities = uiSelectionList(context, selectedIDs); - context.ui().sidebar.show(entities); - } - if (_follow) { var extent = geoExtent(); var graph = context.graph(); diff --git a/modules/ui/entity_editor.js b/modules/ui/entity_editor.js index 18d70ce02..ed7c01bef 100644 --- a/modules/ui/entity_editor.js +++ b/modules/ui/entity_editor.js @@ -1,5 +1,5 @@ import { dispatch as d3_dispatch } from 'd3-dispatch'; -import {event as d3_event, selectAll as d3_selectAll } from 'd3-selection'; +import { event as d3_event, selectAll as d3_selectAll, select as d3_select } from 'd3-selection'; import deepEqual from 'fast-deep-equal'; import { t, textDirection } from '../util/locale'; @@ -15,6 +15,7 @@ import { uiRawTagEditor } from './raw_tag_editor'; import { uiTagReference } from './tag_reference'; import { uiPresetEditor } from './preset_editor'; import { uiEntityIssues } from './entity_issues'; +import { uiSelectionList } from './selection_list'; import { uiTooltipHtml } from './tooltipHtml'; import { utilCleanTags, utilRebind } from '../util'; @@ -24,12 +25,13 @@ export function uiEntityEditor(context) { var _state = 'select'; var _coalesceChanges = false; var _modified = false; - var _scrolled = false; var _base; - var _entityID; + var _entityIDs; var _activePreset; var _tagReference; + var _newFeature; + var selectionList = uiSelectionList(context); var entityIssues = uiEntityIssues(context); var quickLinks = uiQuickLinks(); var presetEditor = uiPresetEditor(context).on('change', changeTags); @@ -38,8 +40,9 @@ export function uiEntityEditor(context) { var rawMembershipEditor = uiRawMembershipEditor(context); function entityEditor(selection) { - var entity = context.entity(_entityID); - var tags = Object.assign({}, entity.tags); // shallow copy + var entityID = singularEntityID(); + var entity = entityID && context.entity(entityID); + var tags = entity && Object.assign({}, entity.tags); // shallow copy // Header var header = selection.selectAll('.header') @@ -62,19 +65,21 @@ export function uiEntityEditor(context) { .call(svgIcon(_modified ? '#iD-icon-apply' : '#iD-icon-close')); headerEnter - .append('h3') - .text(t('inspector.edit')); + .append('h3'); // Update header = header .merge(headerEnter); + header.selectAll('h3') + .text(entityID ? t('inspector.edit') : t('inspector.edit_features')); + header.selectAll('.preset-reset') + .style('display', entityID ? null : 'none') .on('click', function() { dispatch.call('choose', this, _activePreset); }); - // Body var body = selection.selectAll('.inspector-body') .data([0]); @@ -82,159 +87,215 @@ export function uiEntityEditor(context) { // Enter var bodyEnter = body.enter() .append('div') - .attr('class', 'inspector-body') - .on('scroll.entity-editor', function() { _scrolled = true; }); - - bodyEnter - .append('div') - .attr('class', 'preset-list-item inspector-inner') - .append('div') - .attr('class', 'preset-list-button-wrap') - .append('button') - .attr('class', 'preset-list-button preset-reset') - .call(tooltip().title(t('inspector.back_tooltip')).placement('bottom')) - .append('div') - .attr('class', 'label') - .append('div') - .attr('class', 'label-inner'); - - bodyEnter - .append('div') - .attr('class', 'preset-quick-links'); - - bodyEnter - .append('div') - .attr('class', 'entity-issues'); - - bodyEnter - .append('div') - .attr('class', 'preset-editor'); - - bodyEnter - .append('div') - .attr('class', 'raw-tag-editor inspector-inner'); - - bodyEnter - .append('div') - .attr('class', 'raw-member-editor inspector-inner'); - - bodyEnter - .append('div') - .attr('class', 'raw-membership-editor inspector-inner'); - - bodyEnter - .append('input') - .attr('type', 'text') - .attr('class', 'key-trap'); - + .attr('class', 'entity-editor inspector-body sep-top'); // Update body = body .merge(bodyEnter); - // update header - if (_tagReference) { - body.selectAll('.preset-list-button-wrap') - .call(_tagReference.button); - - body.selectAll('.preset-list-item') - .call(_tagReference.body); - } - - body.selectAll('.preset-reset') - .on('click', function() { - dispatch.call('choose', this, _activePreset); - }); - - body.select('.preset-list-item button') - .call(uiPresetIcon(context) - .geometry(context.geometry(_entityID)) - .preset(_activePreset) - ); - - // NOTE: split on en-dash, not a hypen (to avoid conflict with hyphenated names) - var label = body.select('.label-inner'); - var nameparts = label.selectAll('.namepart') - .data(_activePreset.name().split(' – '), function(d) { return d; }); - - nameparts.exit() - .remove(); - - nameparts - .enter() - .append('div') - .attr('class', 'namepart') - .text(function(d) { return d; }); - - // update quick links - var choices = [{ - id: 'zoom_to', - label: 'inspector.zoom_to.title', - tooltip: function() { - return uiTooltipHtml(t('inspector.zoom_to.tooltip_feature'), t('inspector.zoom_to.key')); - }, - click: function zoomTo() { - context.mode().zoomToSelected(); - } - }]; - - body.select('.preset-quick-links') - .call(quickLinks.choices(choices)); - - - // update editor sections - body.select('.entity-issues') - .call(entityIssues - .entityID(_entityID) - ); - - body.select('.preset-editor') - .call(presetEditor - .preset(_activePreset) - .entityID(_entityID) - .tags(tags) - .state(_state) - ); - - body.select('.raw-tag-editor') - .call(rawTagEditor - .preset(_activePreset) - .entityID(_entityID) - .tags(tags) - .state(_state) - ); - - if (entity.type === 'relation') { - body.select('.raw-member-editor') - .style('display', 'block') - .call(rawMemberEditor - .entityID(_entityID) - ); - } else { - body.select('.raw-member-editor') - .style('display', 'none'); - } - - body.select('.raw-membership-editor') - .call(rawMembershipEditor - .entityID(_entityID) - ); - - body.select('.key-trap') - .on('keydown.key-trap', function() { - // On tabbing, send focus back to the first field on the inspector-body - // (probably the `name` field) #4159 - if (d3_event.keyCode === 9 && !d3_event.shiftKey) { - d3_event.preventDefault(); - body.select('input').node().focus(); + var sectionInfos = [ + { + klass: 'selection-list', + shouldHave: _entityIDs.length > 1, + update: function(section) { + section + .call(selectionList + .selectedIDs(_entityIDs) + ); } + }, + { + klass: 'preset-list-item inspector-inner', + shouldHave: entityID, + create: function(sectionEnter) { + + var presetButtonWrap = sectionEnter + .append('div') + .attr('class', 'preset-list-button-wrap'); + + var presetButton = presetButtonWrap.append('button') + .attr('class', 'preset-list-button preset-reset') + .call(tooltip().title(t('inspector.back_tooltip')).placement('bottom')); + + presetButton + .append('div') + .attr('class', 'label') + .append('div') + .attr('class', 'label-inner'); + + presetButtonWrap.append('div') + .attr('class', 'accessory-buttons'); + + // update quick links + var choices = [{ + id: 'zoom_to', + label: 'inspector.zoom_to.title', + tooltip: function() { + return uiTooltipHtml(t('inspector.zoom_to.tooltip_feature'), t('inspector.zoom_to.key')); + }, + click: function zoomTo() { + context.mode().zoomToSelected(); + } + }]; + + sectionEnter + .append('div') + .attr('class', 'preset-quick-links') + .call(quickLinks.choices(choices)); + }, + update: function(section) { + + // update header + if (_tagReference) { + section.selectAll('.preset-list-button-wrap .accessory-buttons') + .call(_tagReference.button); + + section.selectAll('.preset-list-item') + .call(_tagReference.body); + } + + section.selectAll('.preset-reset') + .on('click', function() { + dispatch.call('choose', this, _activePreset); + }) + .on('mousedown', function() { + d3_event.preventDefault(); + d3_event.stopPropagation(); + }) + .on('mouseup', function() { + d3_event.preventDefault(); + d3_event.stopPropagation(); + }); + + section.select('.preset-list-item button') + .call(uiPresetIcon(context) + .geometry(context.geometry(entityID)) + .preset(_activePreset) + ); + + // NOTE: split on en-dash, not a hypen (to avoid conflict with hyphenated names) + var label = section.select('.label-inner'); + var nameparts = label.selectAll('.namepart') + .data(_activePreset.name().split(' – '), function(d) { return d; }); + + nameparts.exit() + .remove(); + + nameparts + .enter() + .append('div') + .attr('class', 'namepart') + .text(function(d) { return d; }); + + } + }, { + klass: 'entity-issues', + shouldHave: entityID, + update: function(section) { + section + .call(entityIssues + .entityID(entityID) + ); + } + }, { + klass: 'preset-editor', + shouldHave: entityID, + update: function(section) { + section + .call(presetEditor + .preset(_activePreset) + .entityID(entityID) + .tags(tags) + .state(_state) + ); + } + }, { + klass: 'raw-tag-editor inspector-inner', + shouldHave: true, + update: function(section) { + section + .call(rawTagEditor + .preset(_activePreset) + .entityIDs(_entityIDs) + .state(_state) + ); + } + }, { + klass: 'raw-member-editor inspector-inner', + shouldHave: entity && entity.type === 'relation', + update: function(section) { + section + .call(rawMemberEditor + .entityID(entityID) + ); + } + }, { + klass: 'raw-membership-editor inspector-inner', + shouldHave: entityID, + update: function(section) { + section + .call(rawMembershipEditor + .entityID(entityID) + ); + } + }, { + klass: 'key-trap-wrap', + shouldHave: true, + create: function(sectionEnter) { + sectionEnter + .append('input') + .attr('type', 'text') + .attr('class', 'key-trap'); + }, + update: function(section) { + section.select('key-trap') + .on('keydown.key-trap', function() { + // On tabbing, send focus back to the first field on the inspector-body + // (probably the `name` field) #4159 + if (d3_event.keyCode === 9 && !d3_event.shiftKey) { + d3_event.preventDefault(); + body.select('input').node().focus(); + } + }); + } + } + ]; + + sectionInfos = sectionInfos.filter(function(info) { + return info.shouldHave; + }); + + var sections = body.selectAll('.section') + .data(sectionInfos, function(d) { return d.klass; }); + + sections.exit().remove(); + + var sectionsEnter = sections.enter() + .append('div') + .attr('class', function(d) { + return 'section ' + d.klass; }); + sectionsEnter.each(function(d) { + if (d.create) { + d.create(d3_select(this)); + } + }); + + sections = sectionsEnter + .merge(sections); + + sections.each(function(d) { + if (d.update) { + d.update(d3_select(this)); + } + }); + context.history() .on('change.entity-editor', historyChanged); - function historyChanged(difference) { + if (selection.selectAll('.entity-editor').empty()) return; if (_state === 'hide') return; var significant = !difference || difference.didChange.properties || @@ -242,30 +303,12 @@ export function uiEntityEditor(context) { difference.didChange.deletion; if (!significant) return; - var entity = context.hasEntity(_entityID); + _entityIDs = _entityIDs.filter(context.hasEntity); + if (!_entityIDs.length) return; + + loadActivePreset(); + var graph = context.graph(); - if (!entity) return; - - var match = context.presets().match(entity, graph); - var activePreset = entityEditor.preset(); - var weakPreset = activePreset && - Object.keys(activePreset.addTags || {}).length === 0; - - // A "weak" preset doesn't set any tags. (e.g. "Address") - // Don't replace a weak preset with a fallback preset (e.g. "Point") - if (!(weakPreset && match.isFallback())) { - entityEditor.preset(match); - - if (match.id !== activePreset.id) { - // flash the button to indicate the preset changed - selection - .selectAll('button.preset-reset .label') - .style('background-color', '#fff') - .transition() - .duration(500) - .style('background-color', null); - } - } entityEditor.modified(_base !== graph); entityEditor(selection); } @@ -275,27 +318,45 @@ export function uiEntityEditor(context) { // Tag changes that fire on input can all get coalesced into a single // history operation when the user leaves the field. #2342 function changeTags(changed, onInput) { - var entity = context.entity(_entityID); - var annotation = t('operations.change_tags.annotation'); - var tags = Object.assign({}, entity.tags); // shallow copy - for (var k in changed) { - if (!k) continue; - var v = changed[k]; - if (v !== undefined || tags.hasOwnProperty(k)) { - tags[k] = v; + var actions = []; + for (var i in _entityIDs) { + var entityID = _entityIDs[i]; + var entity = context.entity(entityID); + + var tags = Object.assign({}, entity.tags); // shallow copy + + for (var k in changed) { + if (!k) continue; + var v = changed[k]; + if (v !== undefined || tags.hasOwnProperty(k)) { + tags[k] = v; + } + } + + if (!onInput) { + tags = utilCleanTags(tags); + } + + if (!deepEqual(entity.tags, tags)) { + actions.push(actionChangeTags(entityID, tags)); } } - if (!onInput) { - tags = utilCleanTags(tags); - } + if (actions.length) { + var combinedAction = function(graph) { + actions.forEach(function(action) { + graph = action(graph); + }); + return graph; + }; + + var annotation = t('operations.change_tags.annotation'); - if (!deepEqual(entity.tags, tags)) { if (_coalesceChanges) { - context.overwrite(actionChangeTags(_entityID, tags), annotation); + context.overwrite(combinedAction, annotation); } else { - context.perform(actionChangeTags(_entityID, tags), annotation); + context.perform(combinedAction, annotation); _coalesceChanges = !!onInput; } } @@ -310,8 +371,6 @@ export function uiEntityEditor(context) { entityEditor.modified = function(val) { if (!arguments.length) return _modified; _modified = val; - d3_selectAll('button.preset-close use') - .attr('xlink:href', (_modified ? '#iD-icon-apply' : '#iD-icon-close')); return entityEditor; }; @@ -323,39 +382,74 @@ export function uiEntityEditor(context) { }; - entityEditor.entityID = function(val) { - if (!arguments.length) return _entityID; - if (_entityID === val) return entityEditor; // exit early if no change + entityEditor.entityIDs = function(val) { + if (!arguments.length) return _entityIDs; + if (_entityIDs === val) return entityEditor; // exit early if no change - _entityID = val; + _entityIDs = val; _base = context.graph(); _coalesceChanges = false; - // reset the scroll to the top of the inspector (warning: triggers reflow) - if (_scrolled) { - window.requestIdleCallback(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); + loadActivePreset(); return entityEditor - .preset(presetMatch) .modified(false); }; + entityEditor.newFeature = function(val) { + if (!arguments.length) return _newFeature; + _newFeature = val; + return entityEditor; + }; + + + function singularEntityID() { + if (_entityIDs.length === 1) { + return _entityIDs[0]; + } + return null; + } + + + function loadActivePreset() { + var entityID = singularEntityID(); + var entity = entityID && context.hasEntity(entityID); + if (!entity) return; + + var graph = context.graph(); + var match = context.presets().match(entity, graph); + + // A "weak" preset doesn't set any tags. (e.g. "Address") + var weakPreset = _activePreset && + Object.keys(_activePreset.addTags || {}).length === 0; + + // Don't replace a weak preset with a fallback preset (e.g. "Point") + if ((weakPreset && match.isFallback()) || + // don't reload for same preset + match === _activePreset) return; + + if (_activePreset && match.id !== _activePreset.id) { + // flash the button to indicate the preset changed + d3_selectAll('.entity-editor button.preset-reset .label') + .style('background-color', '#fff') + .transition() + .duration(500) + .style('background-color', null); + } + + entityEditor.preset(match); + } + entityEditor.preset = function(val) { if (!arguments.length) return _activePreset; if (val !== _activePreset) { _activePreset = val; - _tagReference = uiTagReference(_activePreset.reference(context.geometry(_entityID)), context) - .showing(false); + var entityID = singularEntityID(); + if (entityID) { + _tagReference = uiTagReference(_activePreset.reference(context.geometry(entityID)), context) + .showing(false); + } } return entityEditor; }; diff --git a/modules/ui/inspector.js b/modules/ui/inspector.js index bce4f9479..dcbf2c3bf 100644 --- a/modules/ui/inspector.js +++ b/modules/ui/inspector.js @@ -13,19 +13,19 @@ export function uiInspector(context) { presetPane = d3_select(null), editorPane = d3_select(null); var _state = 'select'; - var _entityID; + var _entityIDs; var _newFeature = false; function inspector(selection, newFeature) { presetList - .entityID(_entityID) + .entityID(_entityIDs.length === 1 && _entityIDs[0]) .autofocus(_newFeature) .on('choose', inspector.setPreset); entityEditor .state(_state) - .entityID(_entityID) + .entityIDs(_entityIDs) .on('choose', inspector.showList); wrap = selection.selectAll('.panewrap') @@ -47,16 +47,34 @@ export function uiInspector(context) { presetPane = wrap.selectAll('.preset-list-pane'); editorPane = wrap.selectAll('.entity-editor-pane'); - var entity = context.entity(_entityID); + function shouldDefaultToPresetList() { + // can only change preset on single selection + if (_entityIDs.length !== 1) return false; - var hasNonGeometryTags = entity.hasNonGeometryTags(); - var isTaglessOrIntersectionVertex = entity.geometry(context.graph()) === 'vertex' && - (!hasNonGeometryTags && !entity.isHighwayIntersection(context.graph())); - var issues = context.validator().getEntityIssues(_entityID); - // start with the preset list if the feature is new and untagged or is an uninteresting vertex - var showPresetList = (newFeature && !hasNonGeometryTags) || (isTaglessOrIntersectionVertex && !issues.length); + var entityID = _entityIDs[0]; + var entity = context.hasEntity(entityID); + if (!entity) return false; - if (showPresetList) { + // default to inspector if there are already tags + if (entity.hasNonGeometryTags()) return false; + + // prompt to select preset if feature is new and untagged + if (newFeature) return true; + + // all existing features except vertices should default to inspector + if (entity.geometry(context.graph()) !== 'vertex') return false; + + // show vertex issues if there are any + if (context.validator().getEntityIssues(entityID).length) return false; + + // show turn retriction editor for junction vertices + if (entity.isHighwayIntersection(context.graph())) return false; + + // otherwise show preset list for uninteresting vertices + return true; + } + + if (shouldDefaultToPresetList()) { wrap.style('right', '-100%'); presetPane.call(presetList); } else { @@ -74,11 +92,21 @@ export function uiInspector(context) { footer .call(uiViewOnOSM(context) - .what(context.hasEntity(_entityID)) + .what(context.hasEntity(_entityIDs.length === 1 && _entityIDs[0])) ); } inspector.showList = function(preset) { + + if (!preset) { + if (_entityIDs.length !== 1) return; + + var entity = context.hasEntity(_entityIDs[0]); + if (!entity) return; + + preset = context.presets().match(entity, context.graph()); + } + wrap.transition() .styleTween('right', function() { return d3_interpolate('0%', '-100%'); }); @@ -115,9 +143,9 @@ export function uiInspector(context) { }; - inspector.entityID = function(val) { - if (!arguments.length) return _entityID; - _entityID = val; + inspector.entityIDs = function(val) { + if (!arguments.length) return _entityIDs; + _entityIDs = val; return inspector; }; diff --git a/modules/ui/preset_list.js b/modules/ui/preset_list.js index 6fb7d146a..50ecddcb2 100644 --- a/modules/ui/preset_list.js +++ b/modules/ui/preset_list.js @@ -25,6 +25,11 @@ export function uiPresetList(context) { function presetList(selection) { + if (!_entityID) { + //selection.html(''); + return; + } + var entity = context.entity(_entityID); var geometry = context.geometry(_entityID); @@ -461,7 +466,9 @@ export function uiPresetList(context) { presetList.entityID = function(val) { if (!arguments.length) return _entityID; _entityID = val; - presetList.preset(context.presets().match(context.entity(_entityID), context.graph())); + if (_entityID) { + presetList.preset(context.presets().match(context.entity(_entityID), context.graph())); + } return presetList; }; diff --git a/modules/ui/raw_tag_editor.js b/modules/ui/raw_tag_editor.js index c24cd85ee..631240662 100644 --- a/modules/ui/raw_tag_editor.js +++ b/modules/ui/raw_tag_editor.js @@ -7,7 +7,8 @@ import { svgIcon } from '../svg/icon'; import { uiCombobox } from './combobox'; import { uiDisclosure } from './disclosure'; import { uiTagReference } from './tag_reference'; -import { utilArrayDifference, utilGetSetValue, utilNoAuto, utilRebind, utilTagDiff } from '../util'; +import { utilArrayDifference, utilArrayIdentical } from '../util/array'; +import { utilGetSetValue, utilNoAuto, utilRebind, utilTagDiff } from '../util'; export function uiRawTagEditor(context) { @@ -22,6 +23,7 @@ export function uiRawTagEditor(context) { var _readOnlyTags = []; // the keys in the order we want them to display var _orderedKeys = []; + var _keyValues = null; var _showBlank = false; var _updatePreference = true; var _expanded = false; @@ -29,7 +31,7 @@ export function uiRawTagEditor(context) { var _state; var _preset; var _tags; - var _entityID; + var _entityIDs; function rawTagEditor(selection) { @@ -91,8 +93,11 @@ export function uiRawTagEditor(context) { var options = wrap.selectAll('.raw-tag-options') .data([0]); + options.exit() + .remove(); + var optionsEnter = options.enter() - .append('div') + .insert('div', ':first-child') .attr('class', 'raw-tag-options'); var optionEnter = optionsEnter.selectAll('.raw-tag-option') @@ -104,7 +109,7 @@ export function uiRawTagEditor(context) { .attr('class', function(d) { return 'raw-tag-option raw-tag-option-' + d.id + (_tagView === d.id ? ' selected' : ''); }) - .attr('title', function(d) { return d.id; }) + .attr('title', function(d) { return t('icons.' + d.id); }) .on('click', function(d) { _tagView = d.id; context.storage('raw-tag-editor-view', d.id); @@ -134,6 +139,7 @@ export function uiRawTagEditor(context) { .append('textarea') .attr('class', 'tag-text' + (_tagView !== 'text' ? ' hide' : '')) .call(utilNoAuto) + .attr('placeholder', t('inspector.key_value')) .attr('spellcheck', 'false') .merge(textarea); @@ -237,17 +243,23 @@ export function uiRawTagEditor(context) { var key = row.select('input.key'); // propagate bound data var value = row.select('input.value'); // propagate bound data - if (_entityID && taginfo && _state !== 'hover') { + if (_entityIDs && taginfo && _state !== 'hover') { bindTypeahead(key, value); } - var isRelation = (_entityID && context.entity(_entityID).type === 'relation'); var reference; - if (isRelation && d.key === 'type') { - reference = uiTagReference({ rtype: d.value }, context); + if (typeof d.value !== 'string') { + reference = uiTagReference({ key: d.key }, context); } else { - reference = uiTagReference({ key: d.key, value: d.value }, context); + var isRelation = _entityIDs && _entityIDs.some(function(entityID) { + return context.entity(entityID).type === 'relation'; + }); + if (isRelation && d.key === 'type') { + reference = uiTagReference({ rtype: d.value }, context); + } else { + reference = uiTagReference({ key: d.key, value: d.value }, context); + } } if (_state === 'hover') { @@ -266,12 +278,19 @@ export function uiRawTagEditor(context) { .attr('title', function(d) { return d.key; }) .call(utilGetSetValue, function(d) { return d.key; }) .attr('readonly', function(d) { - return isReadOnly(d) || null; + return (isReadOnly(d) || (typeof d.value !== 'string')) || null; }); items.selectAll('input.value') - .attr('title', function(d) { return d.value; }) - .call(utilGetSetValue, function(d) { return d.value; }) + .attr('title', function(d) { + return typeof d.value === 'string' ? d.value : Array.from(_keyValues[d.key]).sort().join('; '); + }) + .attr('placeholder', function(d) { + return typeof d.value === 'string' ? null : t('inspector.multiple_values'); + }) + .call(utilGetSetValue, function(d) { + return typeof d.value === 'string' ? d.value : ''; + }) .attr('readonly', function(d) { return isReadOnly(d) || null; }); @@ -323,7 +342,9 @@ export function uiRawTagEditor(context) { var str = rows .filter(function(row) { return row.key && row.key.trim() !== ''; }) .map(function(row) { - var val = row.value ? stringify(row.value) : ''; + var rawVal = row.value; + if (rawVal === true) rawVal = '*'; + var val = rawVal ? stringify(rawVal) : ''; return stringify(row.key) + '=' + val; }) .join('\n'); @@ -352,6 +373,9 @@ export function uiRawTagEditor(context) { tagDiff.forEach(function(change) { if (isReadOnly({ key: change.key })) return; + // skip unchanged multiselection placeholders + if (change.newVal === '*' && change.oldVal === true) return; + if (change.type === '-') { _pendingChange[change.key] = undefined; } else if (change.type === '+') { @@ -359,6 +383,11 @@ export function uiRawTagEditor(context) { } }); + if (Object.keys(_pendingChange).length === 0) { + _pendingChange = null; + return; + } + scheduleChange(); } @@ -376,7 +405,24 @@ export function uiRawTagEditor(context) { function bindTypeahead(key, value) { if (isReadOnly(key.datum())) return; - var geometry = context.geometry(_entityID); + if (typeof value.datum().value !== 'string' && _keyValues) { + value.call(uiCombobox(context, 'tag-value') + .minItems(1) + .fetcher(function(value, callback) { + var keyString = utilGetSetValue(key); + if (!_keyValues[keyString]) return; + var data = Array.from(_keyValues[keyString]).map(function(tagValue) { + return { + value: tagValue, + title: tagValue + }; + }); + callback(data); + })); + return; + } + + var geometry = context.geometry(_entityIDs[0]); key.call(uiCombobox(context, 'tag-key') .fetcher(function(value, callback) { @@ -432,6 +478,8 @@ export function uiRawTagEditor(context) { function keyChange(d) { + if (d3_select(this).attr('readonly')) return; + var kOld = d.key; var kNew = this.value.trim(); var row = this.parentNode.parentNode; @@ -487,6 +535,9 @@ export function uiRawTagEditor(context) { function valueChange(d) { if (isReadOnly(d)) return; + // 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 @@ -550,7 +601,7 @@ export function uiRawTagEditor(context) { rawTagEditor.preset = function(val) { if (!arguments.length) return _preset; _preset = val; - if (_preset.isFallback()) { + if (_preset && _preset.isFallback()) { _expanded = true; _updatePreference = false; } else { @@ -568,12 +619,65 @@ export function uiRawTagEditor(context) { }; - rawTagEditor.entityID = function(val) { - if (!arguments.length) return _entityID; - if (_entityID !== val) { + rawTagEditor.entityIDs = function(val) { + if (!arguments.length) return _entityIDs; + if (!_entityIDs || !val || !utilArrayIdentical(_entityIDs, val)) { + _entityIDs = val; _orderedKeys = []; - _entityID = val; } + + var combinedTags = {}; + var sharedKeys = null; + _keyValues = {}; + + _entityIDs.forEach(function(entityID) { + var entity = context.entity(entityID); + var entityTags = entity.tags; + var entityKey; + + if (sharedKeys === null) { + sharedKeys = {}; + for (entityKey in entityTags) { + sharedKeys[entityKey] = true; + } + } else { + for (var sharedKey in sharedKeys) { + if (!entityTags.hasOwnProperty(sharedKey)) { + delete sharedKeys[sharedKey]; + } + } + } + + for (entityKey in entityTags) { + + var entityValue = entityTags[entityKey]; + + if (!_keyValues.hasOwnProperty(entityKey)) { + _keyValues[entityKey] = new Set(); + } + _keyValues[entityKey].add(entityValue); + + if (combinedTags.hasOwnProperty(entityKey)) { + var combinedValue = combinedTags[entityKey]; + if (combinedValue !== true && + combinedValue !== entityValue) { + + combinedTags[entityKey] = true; + } + } else { + combinedTags[entityKey] = entityValue; + } + } + }); + + for (var key in combinedTags) { + if (!sharedKeys.hasOwnProperty(key)) { + // treat tags that aren't shared by all entities the same as if there are multiple values + combinedTags[key] = true; + } + } + + rawTagEditor.tags(combinedTags); return rawTagEditor; }; diff --git a/modules/ui/selection_list.js b/modules/ui/selection_list.js index 07ce9357e..fe201f23f 100644 --- a/modules/ui/selection_list.js +++ b/modules/ui/selection_list.js @@ -1,13 +1,15 @@ import { event as d3_event, select as d3_select } from 'd3-selection'; -import { t } from '../util/locale'; import { modeSelect } from '../modes/select'; import { osmEntity } from '../osm'; import { svgIcon } from '../svg/icon'; import { utilDisplayName, utilHighlightEntities } from '../util'; -export function uiSelectionList(context, selectedIDs) { +export function uiSelectionList(context) { + + var _selectedIDs = []; + function selectEntity(entity) { context.enter(modeSelect(context, [entity.id])); @@ -16,33 +18,25 @@ export function uiSelectionList(context, selectedIDs) { function deselectEntity(entity) { d3_event.stopPropagation(); + + var selectedIDs = _selectedIDs.slice(); var index = selectedIDs.indexOf(entity.id); if (index > -1) { selectedIDs.splice(index, 1); + context.enter(modeSelect(context, selectedIDs)); } - context.enter(modeSelect(context, selectedIDs)); } function selectionList(selection) { - selection.classed('selection-list-pane', true); - var header = selection + var list = selection.selectAll('.feature-list') + .data([0]); + + list = list.enter() .append('div') - .attr('class', 'header fillL cf'); - - header - .append('h3') - .text(t('inspector.multiselect')); - - var listWrap = selection - .append('div') - .attr('class', 'inspector-body'); - - var list = listWrap - .append('div') - .attr('class', 'feature-list cf'); - + .attr('class', 'feature-list') + .merge(list); context.history() .on('change.selectionList', function(difference) { @@ -51,11 +45,10 @@ export function uiSelectionList(context, selectedIDs) { drawList(); - function drawList() { - var entities = selectedIDs + var entities = _selectedIDs .map(function(id) { return context.hasEntity(id); }) - .filter(function(entity) { return entity; }); + .filter(Boolean); var items = list.selectAll('.feature-list-item') .data(entities, osmEntity.key); @@ -119,5 +112,13 @@ export function uiSelectionList(context, selectedIDs) { } } + + selectionList.selectedIDs = function(val) { + if (!arguments.length) return _selectedIDs; + _selectedIDs = val; + return selectionList; + }; + + return selectionList; } diff --git a/modules/ui/sidebar.js b/modules/ui/sidebar.js index d8f5d9a84..74ceeb52f 100644 --- a/modules/ui/sidebar.js +++ b/modules/ui/sidebar.js @@ -8,7 +8,7 @@ import { event as d3_event, selectAll as d3_selectAll } from 'd3-selection'; - +import { utilArrayIdentical } from '../util/array'; import { osmEntity, osmNote, qaError } from '../osm'; import { services } from '../services'; import { uiDataEditor } from './data_editor'; @@ -167,10 +167,10 @@ export function uiSidebar(context) { .classed('inspector-hidden', false) .classed('inspector-hover', true); - if (inspector.entityID() !== datum.id || inspector.state() !== 'hover') { + if (!inspector.entityIDs() || !utilArrayIdentical(inspector.entityIDs(), [datum.id]) || inspector.state() !== 'hover') { inspector .state('hover') - .entityID(datum.id); + .entityIDs([datum.id]); inspectorWrap .call(inspector); @@ -206,17 +206,16 @@ export function uiSidebar(context) { }; - sidebar.select = function(id, newFeature) { + sidebar.select = function(ids, newFeature) { sidebar.hide(); - if (id) { - var entity = context.entity(id); - // uncollapse the sidebar - if (selection.classed('collapsed')) { - if (newFeature) { - var extent = entity.extent(context.graph()); - sidebar.expand(sidebar.intersects(extent)); - } + if (ids && ids.length) { + + var entity = ids.length === 1 && context.entity(ids[0]); + if (entity && newFeature && selection.classed('collapsed')) { + // uncollapse the sidebar + var extent = entity.extent(context.graph()); + sidebar.expand(sidebar.intersects(extent)); } featureListWrap @@ -226,20 +225,16 @@ export function uiSidebar(context) { .classed('inspector-hidden', false) .classed('inspector-hover', false); - if (inspector.entityID() !== id || inspector.state() !== 'select') { + if (!inspector.entityIDs() || !utilArrayIdentical(inspector.entityIDs(), ids) || inspector.state() !== 'select') { inspector .state('select') - .entityID(id) + .entityIDs(ids) .newFeature(newFeature); inspectorWrap .call(inspector, newFeature); } - sidebar.showPresetList = function() { - inspector.showList(context.presets().match(entity, context.graph())); - }; - } else { inspector .state('hide'); @@ -247,6 +242,11 @@ export function uiSidebar(context) { }; + sidebar.showPresetList = function() { + inspector.showList(); + }; + + sidebar.show = function(component, element) { featureListWrap .classed('inspector-hidden', true); diff --git a/modules/util/array.js b/modules/util/array.js index c06e66cde..b653271da 100644 --- a/modules/util/array.js +++ b/modules/util/array.js @@ -1,4 +1,17 @@ +// Returns true if a and b have the same elements at the same indices. +export function utilArrayIdentical(a, b) { + // an array is always identical to itself + if (a === b) return true; + + var i = a.length; + if (i !== b.length) return false; + while (i--) { + if (a[i] !== b[i]) return false; + } + return true; +} + // http://2ality.com/2015/01/es6-set-operations.html // Difference (a \ b): create a set that contains those elements of set a that are not in set b. @@ -136,4 +149,3 @@ export function utilArrayUniqBy(a, key) { return acc; }, []); } - diff --git a/modules/util/index.js b/modules/util/index.js index ec202812b..9c83d6bd5 100644 --- a/modules/util/index.js +++ b/modules/util/index.js @@ -2,6 +2,7 @@ export { utilArrayChunk } from './array'; export { utilArrayDifference } from './array'; export { utilArrayFlatten } from './array'; export { utilArrayGroupBy } from './array'; +export { utilArrayIdentical } from './array'; export { utilArrayIntersection } from './array'; export { utilArrayUnion } from './array'; export { utilArrayUniq } from './array'; diff --git a/test/spec/ui/raw_tag_editor.js b/test/spec/ui/raw_tag_editor.js index ad699d506..f0f3bc526 100644 --- a/test/spec/ui/raw_tag_editor.js +++ b/test/spec/ui/raw_tag_editor.js @@ -3,7 +3,7 @@ describe('iD.uiRawTagEditor', function() { function render(tags) { taglist = iD.uiRawTagEditor(context) - .entityID(entity.id) + .entityIDs([entity.id]) .preset({isFallback: function() { return false; }}) .tags(tags) .expanded(true);