diff --git a/index.html b/index.html
index f3129da2d..474ea4563 100644
--- a/index.html
+++ b/index.html
@@ -147,6 +147,7 @@
+
@@ -170,6 +171,7 @@
+
@@ -177,6 +179,7 @@
+
diff --git a/js/id/actions/copy_entity.js b/js/id/actions/copy_entity.js
new file mode 100644
index 000000000..af4a9138a
--- /dev/null
+++ b/js/id/actions/copy_entity.js
@@ -0,0 +1,19 @@
+iD.actions.CopyEntity = function(entity, deep) {
+ var newEntities = [];
+
+ var action = function(graph) {
+ newEntities = entity.copy(deep, graph);
+
+ for (var i = 0; i < newEntities.length; i++) {
+ graph = graph.replace(newEntities[i]);
+ }
+
+ return graph;
+ };
+
+ action.newEntities = function() {
+ return newEntities;
+ };
+
+ return action;
+};
diff --git a/js/id/behavior/copy.js b/js/id/behavior/copy.js
new file mode 100644
index 000000000..e7ca63bea
--- /dev/null
+++ b/js/id/behavior/copy.js
@@ -0,0 +1,78 @@
+iD.behavior.Copy = function(context) {
+ var keybinding = d3.keybinding('copy');
+
+ function groupEntities(ids, graph) {
+ var entities = ids.map(function (id) { return graph.entity(id); });
+ return _.extend({relation: [], way: [], node: []},
+ _.groupBy(entities, function(entity) { return entity.type; }));
+ }
+
+ function getDescendants(id, graph, descendants) {
+ var entity = graph.entity(id),
+ i, children;
+
+ descendants = descendants || {};
+
+ if (entity.type === 'relation') {
+ children = _.pluck(entity.members, 'id');
+ } else if (entity.type === 'way') {
+ children = entity.nodes;
+ } else {
+ children = [];
+ }
+
+ for (i = 0; i < children.length; i++) {
+ if (!descendants[children[i]]) {
+ descendants[children[i]] = true;
+ descendants = getDescendants(children[i], graph, descendants);
+ }
+ }
+
+ return descendants;
+ }
+
+ function doCopy() {
+ d3.event.preventDefault();
+
+ var graph = context.graph(),
+ selected = groupEntities(context.selectedIDs(), graph),
+ canCopy = [],
+ skip = {},
+ i, entity;
+
+ for (i = 0; i < selected.relation.length; i++) {
+ entity = selected.relation[i];
+ if (!skip[entity.id] && entity.isComplete()) {
+ canCopy.push(entity.id);
+ skip = getDescendants(entity.id, graph, skip);
+ }
+ }
+ for (i = 0; i < selected.way.length; i++) {
+ entity = selected.way[i];
+ if (!skip[entity.id]) {
+ canCopy.push(entity.id);
+ skip = getDescendants(entity.id, graph, skip);
+ }
+ }
+ for (i = 0; i < selected.node.length; i++) {
+ entity = selected.node[i];
+ if (!skip[entity.id]) {
+ canCopy.push(entity.id);
+ }
+ }
+
+ context.copiedIDs(canCopy);
+ }
+
+ function copy() {
+ keybinding.on(iD.ui.cmd('⌘C'), doCopy);
+ d3.select(document).call(keybinding);
+ return copy;
+ }
+
+ copy.off = function() {
+ d3.select(document).call(keybinding.off);
+ };
+
+ return copy;
+};
diff --git a/js/id/behavior/paste.js b/js/id/behavior/paste.js
new file mode 100644
index 000000000..92bf92d31
--- /dev/null
+++ b/js/id/behavior/paste.js
@@ -0,0 +1,75 @@
+iD.behavior.Paste = function(context) {
+ var keybinding = d3.keybinding('paste');
+
+ function omitTag(v, k) {
+ return (
+ k === 'phone' ||
+ k === 'fax' ||
+ k === 'email' ||
+ k === 'website' ||
+ k === 'url' ||
+ k === 'note' ||
+ k === 'description' ||
+ k.indexOf('name') !== -1 ||
+ k.indexOf('wiki') === 0 ||
+ k.indexOf('addr:') === 0 ||
+ k.indexOf('contact:') === 0
+ );
+ }
+
+ function doPaste() {
+ d3.event.preventDefault();
+
+ var mouse = context.mouse(),
+ projection = context.projection,
+ viewport = iD.geo.Extent(projection.clipExtent()).polygon();
+
+ if (!iD.geo.pointInPolygon(mouse, viewport)) return;
+
+ var graph = context.graph(),
+ extent = iD.geo.Extent(),
+ oldIDs = context.copiedIDs(),
+ newIDs = [],
+ i, j;
+
+ for (i = 0; i < oldIDs.length; i++) {
+ var oldEntity = graph.entity(oldIDs[i]),
+ action = iD.actions.CopyEntity(oldEntity, true),
+ newEntities;
+
+ extent._extend(oldEntity.extent(graph));
+ context.perform(action);
+
+ // First element in `newEntities` contains the copied Entity,
+ // Subsequent array elements contain any descendants..
+ newEntities = action.newEntities();
+ newIDs.push(newEntities[0].id);
+
+ for (j = 0; j < newEntities.length; j++) {
+ var newEntity = newEntities[j],
+ tags = _.omit(newEntity.tags, omitTag);
+
+ context.perform(iD.actions.ChangeTags(newEntity.id, tags));
+ }
+ }
+
+ // Put pasted objects where mouse pointer is..
+ var center = projection(extent.center()),
+ delta = [ mouse[0] - center[0], mouse[1] - center[1] ];
+
+ context.perform(iD.actions.Move(newIDs, delta, projection));
+ context.enter(iD.modes.Move(context, newIDs));
+ }
+
+ function paste() {
+ keybinding.on(iD.ui.cmd('⌘V'), doPaste);
+ d3.select(document).call(keybinding);
+ return paste;
+ }
+
+ paste.off = function() {
+ d3.select(document).call(keybinding.off);
+ };
+
+ return paste;
+};
diff --git a/js/id/core/entity.js b/js/id/core/entity.js
index 53a7ca17f..f4f52274c 100644
--- a/js/id/core/entity.js
+++ b/js/id/core/entity.js
@@ -44,7 +44,11 @@ iD.Entity.prototype = {
var source = sources[i];
for (var prop in source) {
if (Object.prototype.hasOwnProperty.call(source, prop)) {
- this[prop] = source[prop];
+ if (source[prop] === undefined) {
+ delete this[prop];
+ } else {
+ this[prop] = source[prop];
+ }
}
}
}
@@ -65,6 +69,12 @@ iD.Entity.prototype = {
return this;
},
+ copy: function() {
+ // Returns an array so that we can support deep copying ways and relations.
+ // The first array element will contain this.copy, followed by any descendants.
+ return [iD.Entity(this, {id: undefined, user: undefined, version: undefined})];
+ },
+
osmId: function() {
return iD.Entity.id.toOSM(this.id);
},
diff --git a/js/id/core/relation.js b/js/id/core/relation.js
index 038785b81..761db2a65 100644
--- a/js/id/core/relation.js
+++ b/js/id/core/relation.js
@@ -20,6 +20,34 @@ _.extend(iD.Relation.prototype, {
type: 'relation',
members: [],
+ copy: function(deep, resolver, replacements) {
+ var copy = iD.Entity.prototype.copy.call(this);
+ if (!deep || !resolver || !this.isComplete(resolver)) {
+ return copy;
+ }
+
+ var members = [],
+ i, oldmember, oldid, newid, children;
+
+ replacements = replacements || {};
+ replacements[this.id] = copy[0].id;
+
+ for (i = 0; i < this.members.length; i++) {
+ oldmember = this.members[i];
+ oldid = oldmember.id;
+ newid = replacements[oldid];
+ if (!newid) {
+ children = resolver.entity(oldid).copy(true, resolver, replacements);
+ newid = replacements[oldid] = children[0].id;
+ copy = copy.concat(children);
+ }
+ members.push({id: newid, type: oldmember.type, role: oldmember.role});
+ }
+
+ copy[0] = copy[0].update({members: members});
+ return copy;
+ },
+
extent: function(resolver, memo) {
return resolver.transient(this, 'extent', function() {
if (memo && memo[this.id]) return iD.geo.Extent();
diff --git a/js/id/core/way.js b/js/id/core/way.js
index a1a9f88a8..7fad36eba 100644
--- a/js/id/core/way.js
+++ b/js/id/core/way.js
@@ -12,6 +12,32 @@ _.extend(iD.Way.prototype, {
type: 'way',
nodes: [],
+ copy: function(deep, resolver) {
+ var copy = iD.Entity.prototype.copy.call(this);
+
+ if (!deep || !resolver) {
+ return copy;
+ }
+
+ var nodes = [],
+ replacements = {},
+ i, oldid, newid, child;
+
+ for (i = 0; i < this.nodes.length; i++) {
+ oldid = this.nodes[i];
+ newid = replacements[oldid];
+ if (!newid) {
+ child = resolver.entity(oldid).copy();
+ newid = replacements[oldid] = child[0].id;
+ copy = copy.concat(child);
+ }
+ nodes.push(newid);
+ }
+
+ copy[0] = copy[0].update({nodes: nodes});
+ return copy;
+ },
+
extent: function(resolver) {
return resolver.transient(this, 'extent', function() {
var extent = iD.geo.Extent();
diff --git a/js/id/id.js b/js/id/id.js
index 24c276ed5..d9546502d 100644
--- a/js/id/id.js
+++ b/js/id/id.js
@@ -204,6 +204,14 @@ window.iD = function () {
context.surface().call(behavior.off);
};
+ /* Copy/Paste */
+ var copiedIDs = [];
+ context.copiedIDs = function(_) {
+ if (!arguments.length) return copiedIDs;
+ copiedIDs = _;
+ return context;
+ };
+
/* Projection */
context.projection = iD.geo.RawMercator();
diff --git a/js/id/modes/browse.js b/js/id/modes/browse.js
index 1487fa832..9e33a1eb0 100644
--- a/js/id/modes/browse.js
+++ b/js/id/modes/browse.js
@@ -7,6 +7,7 @@ iD.modes.Browse = function(context) {
}, sidebar;
var behaviors = [
+ iD.behavior.Paste(context),
iD.behavior.Hover(context)
.on('hover', context.ui().sidebar.hover),
iD.behavior.Select(context),
diff --git a/js/id/modes/select.js b/js/id/modes/select.js
index 0b182a1b5..45d1c42c0 100644
--- a/js/id/modes/select.js
+++ b/js/id/modes/select.js
@@ -7,6 +7,8 @@ iD.modes.Select = function(context, selectedIDs) {
var keybinding = d3.keybinding('select'),
timeout = null,
behaviors = [
+ iD.behavior.Copy(context),
+ iD.behavior.Paste(context),
iD.behavior.Hover(context),
iD.behavior.Select(context),
iD.behavior.Lasso(context),
diff --git a/test/index.html b/test/index.html
index f5cc7da2a..c0dbc2970 100644
--- a/test/index.html
+++ b/test/index.html
@@ -126,6 +126,7 @@
+
@@ -149,6 +150,7 @@
+
@@ -156,6 +158,7 @@
+
@@ -225,6 +228,7 @@
+
diff --git a/test/index_packaged.html b/test/index_packaged.html
index 9df77e3cc..324ed0b69 100644
--- a/test/index_packaged.html
+++ b/test/index_packaged.html
@@ -35,6 +35,7 @@
+
diff --git a/test/spec/actions/copy_entity.js b/test/spec/actions/copy_entity.js
new file mode 100644
index 000000000..14f2de0fb
--- /dev/null
+++ b/test/spec/actions/copy_entity.js
@@ -0,0 +1,76 @@
+describe("iD.actions.CopyEntity", function () {
+ it("copies a Node and adds it to the graph", function () {
+ var a = iD.Node({id: 'a'}),
+ base = iD.Graph([a]),
+ head = iD.actions.CopyEntity(a)(base),
+ diff = iD.Difference(base, head),
+ created = diff.created();
+
+ expect(head.hasEntity('a')).to.be.ok;
+ expect(created).to.have.length(1);
+ expect(created[0]).to.be.an.instanceof(iD.Node);
+ });
+
+ it("shallow copies a Way and adds it to the graph", function () {
+ var a = iD.Node({id: 'a'}),
+ b = iD.Node({id: 'b'}),
+ w = iD.Way({id: 'w', nodes: ['a', 'b']}),
+ base = iD.Graph([a, b, w]),
+ head = iD.actions.CopyEntity(w)(base),
+ diff = iD.Difference(base, head),
+ created = diff.created();
+
+ expect(head.hasEntity('w')).to.be.ok;
+ expect(created).to.have.length(1);
+ expect(created[0]).to.be.an.instanceof(iD.Way);
+ });
+
+ it("deep copies a Way and child Nodes and adds them to the graph", function () {
+ var a = iD.Node({id: 'a'}),
+ b = iD.Node({id: 'b'}),
+ w = iD.Way({id: 'w', nodes: ['a', 'b']}),
+ base = iD.Graph([a, b, w]),
+ head = iD.actions.CopyEntity(w, true)(base),
+ diff = iD.Difference(base, head),
+ created = diff.created();
+
+ expect(head.hasEntity('w')).to.be.ok;
+ expect(created).to.have.length(3);
+ expect(created[0]).to.be.an.instanceof(iD.Way);
+ expect(created[1]).to.be.an.instanceof(iD.Node);
+ expect(created[2]).to.be.an.instanceof(iD.Node);
+ });
+
+ it("shallow copies a Relation and adds it to the graph", function () {
+ var a = iD.Node({id: 'a'}),
+ b = iD.Node({id: 'b'}),
+ w = iD.Way({id: 'w', nodes: ['a', 'b']}),
+ r = iD.Relation({id: 'r', members: [{id: 'w'}]}),
+ base = iD.Graph([a, b, w, r]),
+ head = iD.actions.CopyEntity(r)(base),
+ diff = iD.Difference(base, head),
+ created = diff.created();
+
+ expect(head.hasEntity('r')).to.be.ok;
+ expect(created).to.have.length(1);
+ expect(created[0]).to.be.an.instanceof(iD.Relation);
+ });
+
+ it("deep copies a Relation, member Ways, and child Nodes and adds them to the graph", function () {
+ var a = iD.Node({id: 'a'}),
+ b = iD.Node({id: 'b'}),
+ w = iD.Way({id: 'w', nodes: ['a', 'b']}),
+ r = iD.Relation({id: 'r', members: [{id: 'w'}]}),
+ base = iD.Graph([a, b, w, r]),
+ head = iD.actions.CopyEntity(r, true)(base),
+ diff = iD.Difference(base, head),
+ created = diff.created();
+
+ expect(head.hasEntity('r')).to.be.ok;
+ expect(created).to.have.length(4);
+ expect(created[0]).to.be.an.instanceof(iD.Relation);
+ expect(created[1]).to.be.an.instanceof(iD.Way);
+ expect(created[2]).to.be.an.instanceof(iD.Node);
+ expect(created[3]).to.be.an.instanceof(iD.Node);
+ });
+});
diff --git a/test/spec/core/entity.js b/test/spec/core/entity.js
index 85dd41ec7..5a6e0aa79 100644
--- a/test/spec/core/entity.js
+++ b/test/spec/core/entity.js
@@ -36,6 +36,30 @@ describe('iD.Entity', function () {
});
});
+ describe("#copy", function () {
+ it("returns a new Entity", function () {
+ var a = iD.Entity(),
+ result = a.copy();
+ expect(result).to.have.length(1);
+ expect(result[0]).to.be.an.instanceof(iD.Entity);
+ expect(a).not.to.equal(result[0]);
+ });
+
+ it("resets 'id', 'user', and 'version' properties", function () {
+ var a = iD.Entity({id: 'n1234', version: 10, user: 'bot-mode'}),
+ b = a.copy()[0];
+ expect(b.isNew()).to.be.ok;
+ expect(b.version).to.be.undefined;
+ expect(b.user).to.be.undefined;
+ });
+
+ it("copies tags", function () {
+ var a = iD.Entity({id: 'n1234', version: 10, user: 'test', tags: {foo: 'foo'}}),
+ b = a.copy()[0];
+ expect(b.tags).to.deep.equal(a.tags);
+ });
+ });
+
describe("#update", function () {
it("returns a new Entity", function () {
var a = iD.Entity(),
diff --git a/test/spec/core/relation.js b/test/spec/core/relation.js
index 4a617421b..8588f3781 100644
--- a/test/spec/core/relation.js
+++ b/test/spec/core/relation.js
@@ -26,6 +26,101 @@ describe('iD.Relation', function () {
expect(iD.Relation({tags: {foo: 'bar'}}).tags).to.eql({foo: 'bar'});
});
+ describe("#copy", function () {
+ it("returns a new Relation", function () {
+ var r1 = iD.Relation({id: 'r1'}),
+ result = r1.copy(),
+ r2 = result[0];
+
+ expect(result).to.have.length(1);
+ expect(r2).to.be.an.instanceof(iD.Relation);
+ expect(r1).not.to.equal(r2);
+ });
+
+ it("keeps same members when deep = false", function () {
+ var a = iD.Node({id: 'a'}),
+ b = iD.Node({id: 'b'}),
+ c = iD.Node({id: 'c'}),
+ w1 = iD.Way({id: 'w1', nodes: ['a','b','c','a']}),
+ r1 = iD.Relation({id: 'r1', members: [{id: 'w1', role: 'outer'}]}),
+ graph = iD.Graph([a, b, c, w1, r1]),
+ result = r1.copy(),
+ r1_copy = result[0];
+
+ expect(result).to.have.length(1);
+ expect(r1.members).to.deep.equal(r1_copy.members);
+ });
+
+ it("makes new members when deep = true", function () {
+ var a = iD.Node({id: 'a'}),
+ b = iD.Node({id: 'b'}),
+ c = iD.Node({id: 'c'}),
+ w1 = iD.Way({id: 'w1', nodes: ['a','b','c','a']}),
+ r1 = iD.Relation({id: 'r1', members: [{id: 'w1', role: 'outer'}]}),
+ graph = iD.Graph([a, b, c, w1, r1]),
+ result = r1.copy(true, graph),
+ r1_copy = result[0];
+
+ expect(result).to.have.length(5);
+ expect(result[0]).to.be.an.instanceof(iD.Relation);
+ expect(result[1]).to.be.an.instanceof(iD.Way);
+ expect(result[2]).to.be.an.instanceof(iD.Node);
+ expect(result[3]).to.be.an.instanceof(iD.Node);
+ expect(result[4]).to.be.an.instanceof(iD.Node);
+
+ expect(r1_copy.members[0].id).not.to.equal(r1.members[0].id);
+ expect(r1_copy.members[0].role).to.equal(r1.members[0].role);
+ });
+
+ it("deep copies non-tree relation graphs without duplicating children", function () {
+ var w = iD.Way({id: 'w'}),
+ r1 = iD.Relation({id: 'r1', members: [{id: 'r2'}, {id: 'w'}]}),
+ r2 = iD.Relation({id: 'r2', members: [{id: 'w'}]}),
+ graph = iD.Graph([w, r1, r2]),
+ result = r1.copy(true, graph),
+ r1_copy = result[0],
+ r2_copy = result[1],
+ w_copy = result[2];
+
+ expect(result).to.have.length(3);
+ expect(r1_copy).to.be.an.instanceof(iD.Relation);
+ expect(r2_copy).to.be.an.instanceof(iD.Relation);
+ expect(w_copy).to.be.an.instanceof(iD.Way);
+
+ expect(r1_copy.members[0].id).to.equal(r2_copy.id);
+ expect(r1_copy.members[1].id).to.equal(r2_copy.members[0].id);
+ });
+
+ // it("deep copies cyclical relation graphs without issue", function () {
+ // var r1 = iD.Relation({id: 'r1', members: [{id: 'r2'}]}),
+ // r2 = iD.Relation({id: 'r2', members: [{id: 'r1'}]}),
+ // graph = iD.Graph([r1, r2]),
+ // result = r1.copy(true, graph),
+ // r1_copy = result[0],
+ // r2_copy = result[1];
+
+ // expect(result).to.have.length(2);
+ // expect(r1_copy).to.be.an.instanceof(iD.Relation);
+ // expect(r2_copy).to.be.an.instanceof(iD.Relation);
+
+ // var msg = 'r1_copy = ' + JSON.stringify(r1_copy) +
+ // 'r2_copy = ' + JSON.stringify(r2_copy);
+ // expect(r1_copy.members[0].id).to.equal(r2_copy.id, msg);
+ // expect(r2_copy.members[0].id).to.equal(r1_copy.id, msg);
+ // });
+
+ // it("deep copies self-refrencing relations without issue", function () {
+ // var r1 = iD.Relation({id: 'r1', members: [{id: 'r1'}]}),
+ // graph = iD.Graph([r1]),
+ // result = r1.copy(true, graph),
+ // r1_copy = result[0];
+
+ // expect(result).to.have.length(1);
+ // expect(r1_copy).to.be.an.instanceof(iD.Relation);
+ // expect(r1_copy.members[0].id).to.equal(r1_copy.id);
+ // });
+ });
+
describe("#extent", function () {
it("returns the minimal extent containing the extents of all members", function () {
var a = iD.Node({loc: [0, 0]}),
diff --git a/test/spec/core/way.js b/test/spec/core/way.js
index 0ff1a56fc..274835fb1 100644
--- a/test/spec/core/way.js
+++ b/test/spec/core/way.js
@@ -26,6 +26,52 @@ describe('iD.Way', function() {
expect(iD.Way({tags: {foo: 'bar'}}).tags).to.eql({foo: 'bar'});
});
+ describe("#copy", function () {
+ it("returns a new Way", function () {
+ var w1 = iD.Way({id: 'w1'}),
+ result = w1.copy(),
+ w2 = result[0];
+
+ expect(result).to.have.length(1);
+ expect(w2).to.be.an.instanceof(iD.Way);
+ expect(w1).not.to.equal(w2);
+ });
+
+ it("keeps same nodes when deep = false", function () {
+ var a = iD.Node({id: 'a'}),
+ b = iD.Node({id: 'b'}),
+ c = iD.Node({id: 'c'}),
+ w1 = iD.Entity({id: 'w1', nodes: ['a','b','c','a']}),
+ graph = iD.Graph([a, b, c, w1]),
+ result = w1.copy(),
+ w2 = result[0];
+
+ expect(result).to.have.length(1);
+ expect(w1.nodes).to.deep.equal(w2.nodes);
+ });
+
+ it("makes new nodes when deep = true", function () {
+ var a = iD.Node({id: 'a'}),
+ b = iD.Node({id: 'b'}),
+ c = iD.Node({id: 'c'}),
+ w1 = iD.Entity({id: 'w1', nodes: ['a','b','c','a']}),
+ graph = iD.Graph([a, b, c, w1]),
+ result = w1.copy(true, graph),
+ w2 = result[0];
+
+ expect(result).to.have.length(4);
+ expect(result[0]).to.be.an.instanceof(iD.Way);
+ expect(result[1]).to.be.an.instanceof(iD.Node);
+ expect(result[2]).to.be.an.instanceof(iD.Node);
+ expect(result[3]).to.be.an.instanceof(iD.Node);
+
+ expect(w2.nodes[0]).not.to.equal(w1.nodes[0]);
+ expect(w2.nodes[1]).not.to.equal(w1.nodes[1]);
+ expect(w2.nodes[2]).not.to.equal(w1.nodes[2]);
+ expect(w2.nodes[3]).to.equal(w2.nodes[0]);
+ });
+ });
+
describe("#first", function () {
it("returns the first node", function () {
expect(iD.Way({nodes: ['a', 'b', 'c']}).first()).to.equal('a');