Browse files

Make copy-paste always do the right thing

In D19447 we added copy-paste functionality to Perseus. However, I avoided the
issue of widget name conflicts by more or less ignoring the paste if it caused
a conflict.

{F161196 size=full}

In this diff, I add to the copy-paste functionality by renumbering pasted
widgets so that they never have a conflict. Basically, I find the highest
numbered widgets of the types we're about to paste in and make sure all the
widgets to be pasted are numbered higher.

For example, if I have a section with a single widget called "Image 2", and I'm
about to paste in an image widget and a dropdown widget, before the paste goes
through we'll renumber the widgets to be "Image 3" and "Dropdown 1".

{F161198 size=full}

This also means that we can now duplicate widgets by copy-pasting.

{F161200 size=full}

You can see from the gifs above that the numbering of pasted widgets in the
titles of the widget editor is weird. This doesn't affect editing the widgets
and goes away on a refresh, so I left it as a TODO.

Test Plan:
Go to a [Perseus link](http://localhost:9000/article.html#content=%5B%7B%22content%22%3A%22Hi%20I%27m%20a%20particle%21%5Cn%5Cn%5B%5B%E2%98%83%20image%201%5D%5D%5Cn%5Cn%5B%5B%E2%98%83%20dropdown%201%5D%5D%5Cn%22%2C%22images%22%3A%7B%7D%2C%22widgets%22%3A%7B%22image%201%22%3A%7B%22type%2 and verify that:
- Copy and paste from one section to a blank section works
- Copy and paste from one section to another section with nonconflicting widgets works
- Copy and paste from one section to a section with conflicting widgets works
- Copying widgets, then copying text from somewhere else and pasting it in Perseus works
- Copy and paste to duplicate widgets works

Reviewers: aria, alex

Reviewed By: aria, alex

Subscribers: aria, kevindangoor

Differential Revision:
  • Loading branch information...
SamLau95 committed Aug 14, 2015
1 parent edc3c6f commit 4d122c7db1c8938d1e6debb48dade1cb180c1190
Showing with 96 additions and 8 deletions.
  1. +96 −8 src/editor.jsx
@@ -641,7 +641,9 @@ var Editor = React.createClass({
_maybeCopyWidgets: function(e) {
// If there are widgets being cut/copied, put the widget JSON in
// localStorage.perseusLastCopiedWidgets to allow copy-pasting of
// widgets between Editors.
// widgets between Editors. Also store the text to be pasted in
// localStorage.perseusLastCopiedText since we want to know if the user
// is actually pasting something originally from Perseus later.
var textarea =;
var selectedText = textarea.value.substring(
@@ -652,27 +654,113 @@ var Editor = React.createClass({
return Util.rWidgetParts.exec(syntax)[1];
var widgetData = _.pick(this.props.widgets, widgetNames);
var widgetData = _.pick(this.serialize().widgets, widgetNames);
localStorage.perseusLastCopiedText = selectedText;
localStorage.perseusLastCopiedWidgets = JSON.stringify(widgetData);
`Widgets copied: ${localStorage.perseusLastCopiedWidgets}`);
_maybePasteWidgets: function() {
_maybePasteWidgets: function(e) {
// Use the data from localStorage to paste any widgets we copied
// before. If there is a widget name conflict, don't override the
// widgets in the Editor we're pasting into.
// before. Avoid name conflicts by renumbering pasted widgets so that
// their numbers are always higher than the highest numbered widget of
// their type.
// TODO(sam): Fix widget numbering in the widget editor titles
var widgetJSON = localStorage.perseusLastCopiedWidgets;
var lastCopiedText = localStorage.perseusLastCopiedText;
var textToBePasted = e.originalEvent.clipboardData.getData('text');
// Only intercept if we have widget data to paste and the user is
// pasting something originally from Perseus.
// TODO(sam/aria/alex): Make it so that you can paste arbitrary text
// (e.g. from a text editor) instead of exactly what was copied, and
// let the widgetJSON match up with it. This would let you copy text
// into a buffer, perform complex operations on it, then paste it back.
if (widgetJSON && lastCopiedText === textToBePasted) {
if (widgetJSON) {
var widgetData = JSON.parse(widgetJSON);
var newWidgets = _.extend(widgetData, this.props.widgets);
this.props.onChange({widgets: newWidgets});
var safeWidgetMapping = this._safeWidgetNameMapping(widgetData);
// Use safe widget name map to construct the new widget data
// TODO(aria/alex): Don't use `rWidgetSplit` or other piecemeal
// regexes directly; abstract this out so that we don't have to
// worry about potential edge cases.
var safeWidgetData = {};
_.each(widgetData, (data, key) => {
safeWidgetData[safeWidgetMapping[key]] = data;
var newWidgets = _.extend(safeWidgetData, this.props.widgets);
// Use safe widget name map to construct new text
var safeText = lastCopiedText.replace(rWidgetSplit, (syntax) => {
var match = Util.rWidgetParts.exec(syntax);
var completeWidget = match[0];
var widget = match[1];
return completeWidget.replace(
widget, safeWidgetMapping[widget]);
// Add pasted text to previous content, replacing selected text to
// replicate normal paste behavior.
var textarea =;
var newContent =
this.props.content.substr(0, textarea.selectionStart) +
safeText +
this.props.onChange({content: newContent, widgets: newWidgets});
_safeWidgetNameMapping: function(widgetData) {
// Helper function for _maybePasteWidgets.
// For each widget about to be pasted, construct a mapping from
// old widget name to a new widget name that doesn't have conflicts
// with widgets already in the editor.
// eg. If there is an "image 2" already present in the editor and we're
// about to paste in two new images, return
// { "image 1": "image 3", "image 2": "image 4" }
// List of widgets about to be pasted as [[name, number], ...]
var widgets = _.keys(widgetData).map((name) => name.split(' '));
var widgetTypes = _.uniq( => widget[0]));
// List of existing widgets as [[name, number], ...]
var existingWidgets = _.keys(this.props.widgets)
.map((name) => name.split(' '));
// Mapping of widget type to a safe (non-conflicting) number
// eg. { "image": 2, "dropdown": 1 }
var safeWidgetNums = {};
_.each(widgetTypes, (type) => {
safeWidgetNums[type] = _.chain(existingWidgets)
.filter((existingWidget) => existingWidget[0] === type)
.map((existingWidget) => +existingWidget[1] + 1)
// If there are no existing widgets _.max returns -Infinity
safeWidgetNums[type] = Math.max(safeWidgetNums[type], 1);
// Construct mapping, incrementing the vals in safeWidgetNums as we go
var safeWidgetMapping = {};
_.each(widgets, (widget) => {
var widgetName = widget.join(' ');
var widgetType = widget[0];
safeWidgetMapping[widgetName] =
`${widgetType} ${safeWidgetNums[widgetType]}`;
return safeWidgetMapping;
_addWidgetToContent: function(oldContent, cursorRange, widgetType) {
var textarea = this.refs.textarea.getDOMNode();

0 comments on commit 4d122c7

Please sign in to comment.