diff --git a/css/80_app.css b/css/80_app.css index 13f256df46f..522eafa81d9 100644 --- a/css/80_app.css +++ b/css/80_app.css @@ -2885,9 +2885,88 @@ img.tag-reference-wiki-image { margin: 0; padding-bottom: 10px; } + +.member-list .member-row label { + border-bottom: none; +} + +.member-list .member-row div.form-field-input-wrap { + border: 1px solid var(--border-color); + border-top: none; + border-right: none; +} +.member-list .member-row div.form-field-input-wrap input { + border-top: 1px solid var(--border-color); + flex: 1 1 auto; + min-width: 80px; +} +.member-list .member-row div.form-field-input-wrap .combobox-caret, +.member-list .member-row div.form-field-input-wrap button { + flex: 0 0 auto; +} +.section-raw-member-editor .member-list .member-row div.form-field-input-wrap input { + border-bottom: none; + border-radius: 0; +} +.section-raw-membership-editor .member-list .member-row div.form-field-input-wrap input { + border-bottom: none; + border-left: none; /* todo: rtl layout */ + border-bottom-right-radius: 0; /* todo: rtl layout */ +} +.member-list .member-row div.form-field-input-wrap button { + border-bottom: none; + border-top: 1px solid var(--border-color); +} + +.member-list .grab-icon svg.icon { + padding-top: 4px; + height: 20px; + fill: var(--text-color); + opacity: .5; +} + +.member-list .member-row.member-connects { + padding-bottom: 0; + margin-bottom: -1px; +} +.member-list .member-row.member-connects-prev label { + border-radius: 0; +} +.member-list .member-row.member-connects-next div.form-field-input-wrap { + border-radius: 0; +} +.member-list .member-row.member-connects-next button { + border-radius: 0; +} + +.members-download button, +.members-download button.loading { + width: 32px; + background: none; + margin-right: -5px; +} +.members-download button .icon { + fill: var(--text-color); + opacity: .5; +} + + /* only the raw-member-editor is reorederable, not the raw-membership-editor */ -.section-raw-member-editor .member-row .label-text { cursor: grab; } -.section-raw-member-editor .member-row .label-text:active { cursor: grabbing; } +.section-raw-member-editor .member-row label, +.section-raw-member-editor .member-row div.form-field-input-wrap { + cursor: grab; +} +.section-raw-member-editor .member-row:active label, +.section-raw-member-editor .member-row:active div.form-field-input-wrap { + cursor: grabbing; +} +.section-raw-member-editor .member-row:hover label, +.section-raw-member-editor .member-row:hover div.form-field-input-wrap { + background: var(--bg-color-3); +} +.section-raw-member-editor .member-row:hover .grab-icon svg.icon { + opacity: .8; +} .section-raw-member-editor .member-row .member-entity-name, .section-raw-membership-editor .member-row .member-entity-name { diff --git a/data/core.yaml b/data/core.yaml index dcbbc2e45c4..c2cbdbc5161 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -314,7 +314,12 @@ en: relation: This can't be disconnected because it connects members of a relation. merge: title: Merge - description: Merge these features. + description: + join: Join these lines. + merge_polygon: Combine the selected areas. + merge_members: Add these features as members to the selected relation. + merge_nodes: Merge these points. + merge: Merge these features. key: C annotation: one: "Merged a feature." @@ -328,6 +333,12 @@ en: conflicting_relations: These features can't be merged because they belong to conflicting relations. paths_intersect: These features can't be merged because the resulting path would intersect itself. too_many_vertices: These features can't be merged because the resulting path would have too many points. + members_remove: + title: Remove Members + description: Remove one or more selected members from the selected relation. + annotation: + one: "Remove a member from the relation." + other: "Merged {n} members from the relation." move: title: Move description: @@ -715,7 +726,10 @@ en: feature_type: Feature Type fields: Fields tags: Tags - members: Members + members: + title: Members + grab: Drag and drop to reorder members + download_all: Download all members relations: Relations features: Features title_count: "{title} ({count})" diff --git a/modules/actions/index.js b/modules/actions/index.js index ca8fe5f3d66..f59fbae8d92 100644 --- a/modules/actions/index.js +++ b/modules/actions/index.js @@ -17,9 +17,11 @@ export { actionDiscardTags } from './discard_tags'; export { actionDisconnect } from './disconnect'; export { actionExtract } from './extract'; export { actionJoin } from './join'; +export { actionMembersRemove } from './members_remove'; export { actionMerge } from './merge'; export { actionMergeNodes } from './merge_nodes'; export { actionMergePolygon } from './merge_polygon'; +export { actionMergeMembers } from './merge_members'; export { actionMergeRemoteChanges } from './merge_remote_changes'; export { actionMove } from './move'; export { actionMoveMember } from './move_member'; diff --git a/modules/actions/join.js b/modules/actions/join.js index 7bd9bd3108e..912d569737a 100644 --- a/modules/actions/join.js +++ b/modules/actions/join.js @@ -209,6 +209,8 @@ export function actionJoin(ids) { isFinite(tagsB[key])); } + action.id = 'join'; + return action; } diff --git a/modules/actions/members_remove.js b/modules/actions/members_remove.js new file mode 100644 index 00000000000..ddab2d6ab99 --- /dev/null +++ b/modules/actions/members_remove.js @@ -0,0 +1,59 @@ +import { actionDeleteMembers } from './delete_members'; + + +// `actionMembersRemove` removes selected members from a single relation +// +// * there must be only one relation in the selection +// * all other selected entities are members of the relation +// * the operation removes all occurrences of the features from +// being a member of the relation + +export function actionMembersRemove(entityIDs) { + + // export function actionDeleteMembers(relationId, memberIndexes) { + + var action = function(graph) { + let relation; + for (const entityID of entityIDs) { + var entity = graph.entity(entityID); + if (entity.type === 'relation') { + relation = entity; + } + } + let memberIndices = []; + for (let i = 0; i < relation.members.length; i++) { + if (entityIDs.indexOf(relation.members[i].id) > 0) { + memberIndices.push(i); + } + } + + graph = actionDeleteMembers(relation.id, memberIndices)(graph); + + // only keep relation in new selection (see operation/merge.js) + entityIDs.splice(0, entityIDs.indexOf(relation.id)); + entityIDs.splice(1, entityIDs.length - 1); + + return graph; + }; + + + action.disabled = function(graph) { + let relation; + for (const entityID of entityIDs) { + var entity = graph.entity(entityID); + if (entity.type === 'relation') { + if (relation !== undefined) return 'not_eligible'; + relation = entity; + } + } + if (relation === undefined) { + return 'not_eligible'; + } + if (entityIDs.some(entityID => entityID !== relation.id && + !relation.members.find(member => member.id === entityID))) { + return 'not_eligible'; + } + }; + + return action; +} diff --git a/modules/actions/merge.js b/modules/actions/merge.js index 289ce46afb3..85038736211 100644 --- a/modules/actions/merge.js +++ b/modules/actions/merge.js @@ -116,6 +116,8 @@ export function actionMerge(ids) { } }; + action.id = 'merge'; + return action; } diff --git a/modules/actions/merge_members.js b/modules/actions/merge_members.js new file mode 100644 index 00000000000..28b1574f86b --- /dev/null +++ b/modules/actions/merge_members.js @@ -0,0 +1,53 @@ +import { actionAddMember } from './add_member'; + + +// `actionMergeMembers` adds new members to a single relation +// +// * there must be only one relation in the selection +// * all other selected entities are added as new members to the relation +// * sorting is done "automagically" when applicable (e.g. connecting to +// existing members of a route), otherwise they will be appended at the +// end of the members list +// * members are added using an empty role + +export function actionMergeMembers(entityIDs) { + + var action = function(graph) { + let relationID; + let newMembers = []; + for (const entityID of entityIDs) { + var entity = graph.entity(entityID); + if (entity.type === 'relation') { + relationID = entityID; + } else { + newMembers.push({ + id: entity.id, + type: entity.type, + role: '' + }); + } + } + + for (const member of newMembers) { + graph = actionAddMember(relationID, member)(graph); + } + + // only keep relation in new selection (see operation/merge.js) + entityIDs.splice(0, entityIDs.indexOf(relationID)); + entityIDs.splice(1, entityIDs.length - 1); + + return graph; + }; + + + action.disabled = function(graph) { + const relationCount = entityIDs.filter(entityID => + graph.entity(entityID).type === 'relation') + .length; + if (relationCount !== 1) return 'not_eligible'; + }; + + action.id = 'merge_members'; + + return action; +} diff --git a/modules/actions/merge_nodes.js b/modules/actions/merge_nodes.js index 26f73eb0093..1322e69f77a 100644 --- a/modules/actions/merge_nodes.js +++ b/modules/actions/merge_nodes.js @@ -58,5 +58,7 @@ export function actionMergeNodes(nodeIDs, loc) { return actionConnect(nodeIDs).disabled(graph); }; + action.id = 'merge_nodes'; + return action; } diff --git a/modules/actions/merge_polygon.js b/modules/actions/merge_polygon.js index 0f29cd5b5e5..07b2e397d9e 100644 --- a/modules/actions/merge_polygon.js +++ b/modules/actions/merge_polygon.js @@ -153,6 +153,8 @@ export function actionMergePolygon(ids, newRelationId) { } }; + action.id = 'merge_polygon'; + return action; } diff --git a/modules/operations/index.js b/modules/operations/index.js index c76dd5a1f4f..de2d11dbb3e 100644 --- a/modules/operations/index.js +++ b/modules/operations/index.js @@ -5,6 +5,7 @@ export { operationDelete } from './delete'; export { operationDisconnect } from './disconnect'; export { operationDowngrade } from './downgrade'; export { operationExtract } from './extract'; +export { operationMembersRemove } from './members_remove'; export { operationMerge } from './merge'; export { operationMove } from './move'; export { operationOrthogonalize } from './orthogonalize'; diff --git a/modules/operations/members_remove.js b/modules/operations/members_remove.js new file mode 100644 index 00000000000..48766c3749c --- /dev/null +++ b/modules/operations/members_remove.js @@ -0,0 +1,52 @@ +import { t } from '../core/localizer'; + +import { actionMembersRemove } from '../actions/members_remove'; + +import { behaviorOperation } from '../behavior/operation'; +import { modeSelect } from '../modes/select'; + +export function operationMembersRemove(context, selectedIDs) { + + var _action = actionMembersRemove(selectedIDs); + + var operation = function() { + + if (operation.disabled()) return; + + context.perform(_action, operation.annotation()); + + context.validator().validate(); + + var resultIDs = selectedIDs.filter(context.hasEntity); + if (resultIDs.length > 1) { + var interestingIDs = resultIDs.filter(function(id) { + return context.entity(id).hasInterestingTags(); + }); + if (interestingIDs.length) resultIDs = interestingIDs; + } + context.enter(modeSelect(context, resultIDs)); + }; + + operation.available = function() { + return !_action.disabled(context.graph()); + }; + + operation.disabled = function() { + return _action.disabled(context.graph()); + }; + + operation.tooltip = function() { + return t.append('operations.members_remove.description'); + }; + + operation.annotation = function() { + return t('operations.members_remove.annotation', { n: selectedIDs.length }); + }; + + operation.id = 'members_remove'; + operation.keys = []; + operation.title = t.append('operations.members_remove.title'); + operation.behavior = behaviorOperation(context).which(operation); + + return operation; +} diff --git a/modules/operations/merge.js b/modules/operations/merge.js index 60b4baed1b1..ad5fc116033 100644 --- a/modules/operations/merge.js +++ b/modules/operations/merge.js @@ -4,6 +4,7 @@ import { actionJoin } from '../actions/join'; import { actionMerge } from '../actions/merge'; import { actionMergeNodes } from '../actions/merge_nodes'; import { actionMergePolygon } from '../actions/merge_polygon'; +import { actionMergeMembers } from '../actions/merge_members'; import { behaviorOperation } from '../behavior/operation'; import { modeSelect } from '../modes/select'; @@ -15,18 +16,21 @@ export function operationMerge(context, selectedIDs) { function getAction() { // prefer a non-disabled action first - var join = actionJoin(selectedIDs); + const join = actionJoin(selectedIDs); if (!join.disabled(context.graph())) return join; - var merge = actionMerge(selectedIDs); + const merge = actionMerge(selectedIDs); if (!merge.disabled(context.graph())) return merge; - var mergePolygon = actionMergePolygon(selectedIDs); + const mergePolygon = actionMergePolygon(selectedIDs); if (!mergePolygon.disabled(context.graph())) return mergePolygon; - var mergeNodes = actionMergeNodes(selectedIDs); + const mergeNodes = actionMergeNodes(selectedIDs); if (!mergeNodes.disabled(context.graph())) return mergeNodes; + const mergeWithRelation = actionMergeMembers(selectedIDs); + if (!mergeWithRelation.disabled(context.graph())) return mergeWithRelation; + // otherwise prefer an action with an interesting disabled reason if (join.disabled(context.graph()) !== 'not_eligible') return join; if (merge.disabled(context.graph()) !== 'not_eligible') return merge; @@ -83,7 +87,7 @@ export function operationMerge(context, selectedIDs) { } return t.append('operations.merge.' + disabled); } - return t.append('operations.merge.description'); + return t.append(`operations.merge.description.${_action.id}`); }; operation.annotation = function() { diff --git a/modules/ui/edit_menu.js b/modules/ui/edit_menu.js index c91827b7c66..47083db2d0b 100644 --- a/modules/ui/edit_menu.js +++ b/modules/ui/edit_menu.js @@ -107,7 +107,7 @@ export function uiEditMenu(context) { var tooltip = uiTooltip() .heading(() => d.title) .title(d.tooltip) - .keys([d.keys[0]]); + .keys(d.keys.slice(0, 1)); _tooltips.push(tooltip); diff --git a/modules/ui/sections/raw_member_editor.js b/modules/ui/sections/raw_member_editor.js index 8671760243d..88b06cc8e12 100644 --- a/modules/ui/sections/raw_member_editor.js +++ b/modules/ui/sections/raw_member_editor.js @@ -16,7 +16,7 @@ import { svgIcon } from '../../svg/icon'; import { services } from '../../services'; import { uiCombobox } from '../combobox'; import { uiSection } from '../section'; -import { utilDisplayName, utilDisplayType, utilHighlightEntities, utilNoAuto, utilUniqueDomId } from '../../util'; +import { utilArrayGroupBy, utilDisplayName, utilDisplayType, utilHighlightEntities, utilNoAuto, utilUniqueDomId } from '../../util'; export function uiSectionRawMemberEditor(context) { @@ -34,7 +34,7 @@ export function uiSectionRawMemberEditor(context) { var gt = entity.members.length > _maxMembers ? '>' : ''; var count = gt + entity.members.slice(0, _maxMembers).length; - return t.append('inspector.title_count', { title: t('inspector.members'), count: count }); + return t.append('inspector.title_count', { title: t('inspector.members.title'), count: count }); }) .disclosureContent(renderDisclosureContent); @@ -126,15 +126,138 @@ export function uiSectionRawMemberEditor(context) { var memberships = []; var entity = context.entity(entityID); - entity.members.slice(0, _maxMembers).forEach(function(member, index) { + + const graph = context.graph(); + const downloadMembers = selection.selectAll('.members-download') + .data(entity.members.every(m => graph.hasEntity(m.id)) ? []: [0]); + const downloadMembersEnter = downloadMembers.enter() + //.append('div') + .insert('div', ':first-child') + .classed('members-download', true) + .classed('section-footer', true) + .append('a') + .attr('role', 'button') + .on('click', function (d3_event) { + d3_event.preventDefault(); + const button = d3_select(this).select('button'); + // display the loading indicator + button.classed('loading', true); + context.loadEntity(entity.id, () => section.reRender()); + }); + downloadMembersEnter + .call(t.append('inspector.members.download_all')); + downloadMembersEnter + .append('button') + .attr('title', t('icons.download')) + .call(svgIcon('#iD-icon-load')); + downloadMembers.exit().remove(); + + + function connects(memberA, memberB, direction, ignoreNode) { + const entityA = context.hasEntity(memberA.id); + const entityB = context.hasEntity(memberB.id); + if (entityA === undefined || entityA.type !== 'way') return false; + if (entityB === undefined || entityB.type !== 'way') return false; + // determine valid connection points between A and B + const pointsA = []; + const pointsB = []; + if (memberA.role === 'forward' && direction || memberA.role === 'backward' && !direction) { + pointsA.push(entityA.nodes[entityA.nodes.length - 1]); + } else if (memberA.role === 'backward' && direction || memberA.role === 'forward' && !direction) { + pointsA.push(entityA.nodes[0]); + } else if (entityA.tags.junction === 'roundabout' && entityA.isClosed()) { + entityA.nodes.forEach(n => pointsA.push(n)); + } else { + pointsA.push(entityA.nodes[entityA.nodes.length - 1]); + pointsA.push(entityA.nodes[0]); + } + if (memberB.role === 'forward' && direction || memberB.role === 'backward' && !direction) { + pointsB.push(entityB.nodes[0]); + } else if (memberB.role === 'backward' && direction || memberB.role === 'forward' && !direction) { + pointsB.push(entityB.nodes[entityB.nodes.length - 1]); + } else if (entityB.tags.junction === 'roundabout' && entityB.isClosed()) { + entityB.nodes.forEach(n => pointsB.push(n)); + } else { + pointsB.push(entityB.nodes[entityB.nodes.length - 1]); + pointsB.push(entityB.nodes[0]); + } + return pointsA.find(idA => + idA !== ignoreNode && + pointsB.indexOf(idA) !== -1); + } + + const members = entity.members.slice(0, _maxMembers); + + const forwardConnections = members.map(() => undefined); + let thatIndex = 0; + let that = members[thatIndex]; + let lastConnectionVertex; + for (let i = 1; i < members.length; i++) { + const cur = members[i]; + const connectionVertex = connects(that, cur, true, lastConnectionVertex); + if (connectionVertex) { + forwardConnections[thatIndex] = true; + lastConnectionVertex = connectionVertex; + } else if (cur.role !== 'forward' && cur.role !== 'backward') { + forwardConnections[thatIndex] = false; + lastConnectionVertex = undefined; + } else { + // role is forward or backward -> skip current member and try next + continue; + } + that = cur; + thatIndex = i; + } + const backwardConnections = members.map(() => undefined); + thatIndex = members.length - 1; + that = members[thatIndex]; + lastConnectionVertex = undefined; + for (let i = members.length - 2; i >= 0; i--) { + const cur = members[i]; + const connectionVertex = connects(cur, that, false, lastConnectionVertex); + if (connectionVertex) { + backwardConnections[thatIndex] = true; + lastConnectionVertex = connectionVertex; + } else if (cur.role !== 'forward' && cur.role !== 'backward') { + backwardConnections[thatIndex] = false; + lastConnectionVertex = undefined; + } else { + continue; + } + that = cur; + thatIndex = i; + } + const loopsConnections = members.map(() => undefined); + for (let i = 0; i < members.length; i++) { + if (forwardConnections[i] === false || i === members.length - 1) { + // check if current segment forms a loop + let j = i - 1; + while (j >= 0 && forwardConnections[j] !== false) { + j--; + } + if (i !== j + 1 && connects(members[i], members[j + 1], true)) { + loopsConnections[i] = true; + loopsConnections[j + 1] = true; + } + } + } + + members.forEach(function(member, index) { + const memberEntity = context.hasEntity(member.id); memberships.push({ index: index, id: member.id, type: member.type, role: member.role, relation: entity, - member: context.hasEntity(member.id), - domId: utilUniqueDomId(entityID + '-member-' + index) + member: memberEntity, + domId: utilUniqueDomId(entityID + '-member-' + index), + connections: { + next: forwardConnections[index], + prev: backwardConnections[index], + joined: forwardConnections[index] || backwardConnections[index + 1], + loops: loopsConnections[index] + } }); }); @@ -148,10 +271,11 @@ export function uiSectionRawMemberEditor(context) { var items = list.selectAll('li') - .data(memberships, function(d) { - return osmEntity.key(d.relation) + ',' + d.index + ',' + - (d.member ? osmEntity.key(d.member) : 'incomplete'); - }); + .data(memberships, d => + osmEntity.key(d.relation) + ',' + d.index + ',' + + (d.member ? osmEntity.key(d.member) : 'incomplete') + ',' + + Object.values(d.connections).join('-') + ); items.exit() .each(unbind) @@ -159,18 +283,53 @@ export function uiSectionRawMemberEditor(context) { var itemsEnter = items.enter() .append('li') - .attr('class', 'member-row form-field') - .classed('member-incomplete', function(d) { return !d.member; }); + .classed('member-row form-field', true) + .classed('member-incomplete', d => !d.member) + .classed('member-connects', d => d.connections.joined) + .classed('member-connects-prev', d => d.connections.prev) + .classed('member-connects-next', d => d.connections.next); itemsEnter .each(function(d) { - var item = d3_select(this); + const item = d3_select(this); - var label = item + const label = item .append('label') - .attr('class', 'field-label') + .classed('field-label', true) .attr('for', d.domId); + const wrap = item + .append('div') + .classed('form-field-input-wrap', true) + .classed('form-field-input-member', true); + + wrap + .append('span') + .classed('grab-icon', true) + .attr('title', t('inspector.members.grab')) + .each(function(d) { + if (d.connections.prev && d.connections.next || d.connections.loops) { + d3_select(this).call(svgIcon('#iD-icon-grab-connects-both')); + } else if (d.connections.prev) { + d3_select(this).call(svgIcon('#iD-icon-grab-connects-prev')); + } else if (d.connections.next) { + d3_select(this).call(svgIcon('#iD-icon-grab-connects-next')); + } else { + d3_select(this).call(svgIcon('#iD-icon-grab')); + } + }); + + wrap.append('input') + .attr('class', 'member-role') + .attr('id', d => d.domId) + .property('type', 'text') + .attr('placeholder', t('inspector.role')) + .call(utilNoAuto); + + if (taginfo) { + wrap.each(bindTypeahead); + } + if (d.member) { // highlight the member feature in the map while hovering on the list item item @@ -203,15 +362,31 @@ export function uiSectionRawMemberEditor(context) { .style('border-color', d => d.member.type === 'relation' && d.member.tags.colour) .text(function(d) { return utilDisplayName(d.member); }); - label + wrap .append('button') + .classed('form-field-button', true) .attr('title', t('icons.remove')) - .attr('class', 'remove member-delete') + .classed('remove', true) + .classed('member-delete', true) .call(svgIcon('#iD-operation-delete')); - label + wrap.select('.grab-icon') + .each(function(d) { + if (d.connections.prev && d.connections.next) { + d3_select(this).call(svgIcon('#iD-icon-grab-connects-both')); + } else if (d.connections.prev) { + d3_select(this).call(svgIcon('#iD-icon-grab-connects-prev')); + } else if (d.connections.next) { + d3_select(this).call(svgIcon('#iD-icon-grab-connects-next')); + } else { + d3_select(this).call(svgIcon('#iD-icon-grab')); + } + }); + + wrap .append('button') .attr('class', 'member-zoom') + .classed('form-field-button', true) .attr('title', t('icons.zoom_to')) .call(svgIcon('#iD-icon-framed-dot', 'monochrome')) .on('click', zoomToMember); @@ -231,33 +406,16 @@ export function uiSectionRawMemberEditor(context) { .attr('class', 'member-entity-name') .call(t.append('inspector.incomplete', { id: d.id })); - label + wrap .append('button') .attr('class', 'member-download') + .classed('form-field-button', true) .attr('title', t('icons.download')) .call(svgIcon('#iD-icon-load')) .on('click', downloadMember); } }); - var wrapEnter = itemsEnter - .append('div') - .attr('class', 'form-field-input-wrap form-field-input-member'); - - wrapEnter - .append('input') - .attr('class', 'member-role') - .attr('id', function(d) { - return d.domId; - }) - .property('type', 'text') - .attr('placeholder', t('inspector.role')) - .call(utilNoAuto); - - if (taginfo) { - wrapEnter.each(bindTypeahead); - } - // update items = items .merge(itemsEnter) @@ -271,6 +429,23 @@ export function uiSectionRawMemberEditor(context) { items.select('button.member-delete') .on('click', deleteMember); + const dupeLabels = new WeakSet(Object.values( + utilArrayGroupBy(items.selectAll('.label-text').nodes(), 'textContent')) + .filter(v => v.length > 1) + .flat()); + + items.select('.label-text').each(function() { + const label = d3_select(this); + const entityName = label.select('.member-entity-name'); + if (dupeLabels.has(this)) { + // Dedupe identical names in hover text by appending entity id - see #2891, #10184 + label.attr('title', d => `${entityName.text()} ${d.id}`); + } else { + // set full label also as hover text: useful if a (long) label is cut off with an … ellipsis + label.attr('title', () => entityName.text()); + } + }); + var dragOrigin, targetIndex; items.call(d3_drag() diff --git a/modules/ui/sections/raw_membership_editor.js b/modules/ui/sections/raw_membership_editor.js index 4efda067c26..7c0db7f762d 100644 --- a/modules/ui/sections/raw_membership_editor.js +++ b/modules/ui/sections/raw_membership_editor.js @@ -423,33 +423,7 @@ export function uiSectionRawMembershipEditor(context) { return utilDisplayName(d.relation, matched.suggestion); }); - labelEnter - .append('button') - .attr('class', 'members-download') - .attr('title', t('icons.download')) - .call(svgIcon('#iD-icon-load')) - .on('click', downloadMembers); - - labelEnter - .append('button') - .attr('class', 'remove member-delete') - .attr('title', t('icons.remove')) - .call(svgIcon('#iD-operation-delete')) - .on('click', deleteMembership); - - labelEnter - .append('button') - .attr('class', 'member-zoom') - .attr('title', t('icons.zoom_to')) - .call(svgIcon('#iD-icon-framed-dot', 'monochrome')) - .on('click', zoomToRelation); - items = items.merge(itemsEnter); - items.selectAll('button.members-download') - .classed('hide', d => { - const graph = context.graph(); - return d.relation.members.every(m => graph.hasEntity(m.id)); - }); const dupeLabels = new WeakSet(Object.values( utilArrayGroupBy(items.selectAll('.label-text').nodes(), 'textContent')) @@ -495,6 +469,36 @@ export function uiSectionRawMembershipEditor(context) { .on('blur', changeRole) .on('change', changeRole); + wrapEnter + .append('button') + .classed('members-download', true) + .classed('form-field-button', true) + .attr('title', t('icons.download')) + .call(svgIcon('#iD-icon-load')) + .on('click', downloadMembers); + + items.selectAll('button.members-download') + .classed('hide', d => { + const graph = context.graph(); + return d.relation.members.every(m => graph.hasEntity(m.id)); + }); + + wrapEnter + .append('button') + .classed('remove member-delete', true) + .classed('form-field-button', true) + .attr('title', t('icons.remove')) + .call(svgIcon('#iD-operation-delete')) + .on('click', deleteMembership); + + wrapEnter + .append('button') + .classed('member-zoom', true) + .classed('form-field-button', true) + .attr('title', t('icons.zoom_to')) + .call(svgIcon('#iD-icon-framed-dot', 'monochrome')) + .on('click', zoomToRelation); + if (taginfo) { wrapEnter.each(bindTypeahead); } diff --git a/svg/iD-sprite/icons/icon-grab-connects-both.svg b/svg/iD-sprite/icons/icon-grab-connects-both.svg new file mode 100644 index 00000000000..94eef349250 --- /dev/null +++ b/svg/iD-sprite/icons/icon-grab-connects-both.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/svg/iD-sprite/icons/icon-grab-connects-next.svg b/svg/iD-sprite/icons/icon-grab-connects-next.svg new file mode 100644 index 00000000000..a252c308457 --- /dev/null +++ b/svg/iD-sprite/icons/icon-grab-connects-next.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/svg/iD-sprite/icons/icon-grab-connects-prev.svg b/svg/iD-sprite/icons/icon-grab-connects-prev.svg new file mode 100644 index 00000000000..aafe166c71d --- /dev/null +++ b/svg/iD-sprite/icons/icon-grab-connects-prev.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/svg/iD-sprite/icons/icon-grab.svg b/svg/iD-sprite/icons/icon-grab.svg new file mode 100644 index 00000000000..b4c81f6d0f2 --- /dev/null +++ b/svg/iD-sprite/icons/icon-grab.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/svg/iD-sprite/operations/operation-members_remove.svg b/svg/iD-sprite/operations/operation-members_remove.svg new file mode 100644 index 00000000000..17e3fa215ac --- /dev/null +++ b/svg/iD-sprite/operations/operation-members_remove.svg @@ -0,0 +1,5 @@ + + + + +