Merge pull request #2498 from bhousel/copy-paste

Copy paste map features with Cmd-C, Cmd-V
This commit is contained in:
Bryan Housel
2015-01-15 21:24:58 -05:00
16 changed files with 497 additions and 1 deletions
+3
View File
@@ -147,6 +147,7 @@
<script src='js/id/actions/change_tags.js'></script>
<script src='js/id/actions/circularize.js'></script>
<script src='js/id/actions/connect.js'></script>
<script src='js/id/actions/copy_entity.js'></script>
<script src='js/id/actions/delete_member.js'></script>
<script src='js/id/actions/delete_multiple.js'></script>
<script src='js/id/actions/delete_node.js'></script>
@@ -170,6 +171,7 @@
<script src='js/id/behavior.js'></script>
<script src='js/id/behavior/add_way.js'></script>
<script src='js/id/behavior/copy.js'></script>
<script src='js/id/behavior/drag.js'></script>
<script src='js/id/behavior/draw.js'></script>
<script src='js/id/behavior/draw_way.js'></script>
@@ -177,6 +179,7 @@
<script src='js/id/behavior/hash.js'></script>
<script src='js/id/behavior/hover.js'></script>
<script src='js/id/behavior/lasso.js'></script>
<script src='js/id/behavior/paste.js'></script>
<script src='js/id/behavior/select.js'></script>
<script src='js/id/behavior/tail.js'></script>
+19
View File
@@ -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;
};
+78
View File
@@ -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;
};
+75
View File
@@ -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;
};
+11 -1
View File
@@ -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);
},
+28
View File
@@ -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();
+26
View File
@@ -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();
+8
View File
@@ -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();
+1
View File
@@ -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),
+2
View File
@@ -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),
+4
View File
@@ -126,6 +126,7 @@
<script src='../js/id/actions/change_tags.js'></script>
<script src='../js/id/actions/circularize.js'></script>
<script src='../js/id/actions/connect.js'></script>
<script src='../js/id/actions/copy_entity.js'></script>
<script src='../js/id/actions/delete_member.js'></script>
<script src='../js/id/actions/delete_multiple.js'></script>
<script src='../js/id/actions/delete_node.js'></script>
@@ -149,6 +150,7 @@
<script src='../js/id/behavior.js'></script>
<script src='../js/id/behavior/add_way.js'></script>
<script src='../js/id/behavior/copy.js'></script>
<script src='../js/id/behavior/drag.js'></script>
<script src='../js/id/behavior/draw.js'></script>
<script src='../js/id/behavior/draw_way.js'></script>
@@ -156,6 +158,7 @@
<script src='../js/id/behavior/hash.js'></script>
<script src='../js/id/behavior/hover.js'></script>
<script src='../js/id/behavior/lasso.js'></script>
<script src='../js/id/behavior/paste.js'></script>
<script src='../js/id/behavior/select.js'></script>
<script src='../js/id/behavior/tail.js'></script>
@@ -225,6 +228,7 @@
<script src='spec/actions/orthogonalize.js'></script>
<script src='spec/actions/straighten.js'></script>
<script src='spec/actions/connect.js'></script>
<script src="spec/actions/copy_entity.js"></script>
<script src='spec/actions/delete_member.js'></script>
<script src="spec/actions/delete_multiple.js"></script>
<script src="spec/actions/delete_node.js"></script>
+1
View File
@@ -35,6 +35,7 @@
<script src="spec/actions/change_tags.js"></script>
<script src='spec/actions/circularize.js'></script>
<script src='spec/actions/connect.js'></script>
<script src="spec/actions/copy_entity.js"></script>
<script src='spec/actions/delete_member.js'></script>
<script src="spec/actions/delete_multiple.js"></script>
<script src="spec/actions/delete_node.js"></script>
+76
View File
@@ -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);
});
});
+24
View File
@@ -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(),
+95
View File
@@ -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]}),
+46
View File
@@ -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');