Skip to content

Add clipboard copy button to modebar (fixes #6888) #7500

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions src/components/modebar/buttons.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,70 @@ modeBarButtons.toImage = {
}
};

modeBarButtons.copyToClipboard = {
name: 'copyToClipboard',
title: function(gd) { return _(gd, 'Copy plot to clipboard'); },
icon: Icons.clipboard,
click: function(gd) {
var toImageButtonOptions = gd._context.toImageButtonOptions || {};
var opts = {
format: 'png',
imageDataOnly: true
};

Lib.notifier(_(gd, 'Copying to clipboard...'), 'long');

['width', 'height', 'scale'].forEach(function(key) {
if(key in toImageButtonOptions) {
opts[key] = toImageButtonOptions[key];
}
});

Registry.call('toImage', gd, opts)
.then(function(imageData) {
// Convert base64 to blob
var byteString = atob(imageData);
var arrayBuffer = new ArrayBuffer(byteString.length);
var uint8Array = new Uint8Array(arrayBuffer);

for(var i = 0; i < byteString.length; i++) {
uint8Array[i] = byteString.charCodeAt(i);
}

var blob = new Blob([arrayBuffer], { type: 'image/png' });

// Modern clipboard API
if(navigator.clipboard && navigator.clipboard.write) {
var clipboardItem = new ClipboardItem({
'image/png': blob
});

return navigator.clipboard.write([clipboardItem])
.then(function() {
Lib.notifier(_(gd, 'Plot copied to clipboard!'), 'long');
});
} else {
// Fallback: copy data URL as text
var dataUrl = 'data:image/png;base64,' + imageData;
if(navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(dataUrl)
.then(function() {
Lib.notifier(_(gd, 'Image data copied as text'), 'long');
});
} else {
throw new Error('Clipboard API not supported');
}
}
})
.catch(function(err) {
console.error('Failed to copy to clipboard:', err);
Lib.notifier(_(gd, 'Clipboard failed, downloading instead...'), 'long');
// Fallback to download
Registry.call('downloadImage', gd, {format: 'png'});
});
}
};

modeBarButtons.sendDataToCloud = {
name: 'sendDataToCloud',
title: function(gd) { return _(gd, 'Edit in Chart Studio'); },
Expand Down
6 changes: 6 additions & 0 deletions src/components/modebar/manage.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,12 @@ function getButtonGroups(gd) {

// buttons common to all plot types
var commonGroup = ['toImage'];

// Add clipboard copy button if supported
if(typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.write) {
commonGroup.push('copyToClipboard');
}

if(context.showEditInChartStudio) commonGroup.push('editInChartStudio');
else if(context.showSendToCloud) commonGroup.push('sendDataToCloud');
addGroup(commonGroup);
Expand Down
6 changes: 6 additions & 0 deletions src/fonts/ploticon.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,5 +183,11 @@ module.exports = {
' </g>',
'</svg>'
].join('')
},
clipboard: {
width: 1000,
height: 1000,
path: 'm850 950l0-300-300 0 0-50 300 0 50 0 0 50 0 300-50 0z m-400-300l0 350 350 0 0-350-350 0z m25 325l300 0 0-300-300 0 0 300z m350-550l0-250-100 0 0-75q0-25-18-43t-43-18l-50 0q-25 0-43 18t-18 43l0 75-100 0 0 250 372 0z m-122-250l0-75q0-11 7-18t18-7l50 0q11 0 18 7t7 18l0 75-100 0z',
transform: 'matrix(1 0 0 -1 0 850)'
}
};
91 changes: 91 additions & 0 deletions test/jasmine/tests/modebar_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1945,4 +1945,95 @@ describe('ModeBar', function() {
});
});
});

describe('copyToClipboard button', function() {
var gd;

beforeEach(function() {
gd = createGraphDiv();
});

afterEach(destroyGraphDiv);

it('should be present when clipboard API is supported', function(done) {
// Mock clipboard API support
var originalClipboard = navigator.clipboard;
navigator.clipboard = { write: function() { return Promise.resolve(); } };

Plotly.newPlot(gd, [{
x: [1, 2, 3],
y: [1, 2, 3]
}])
.then(function() {
var modeBar = gd._fullLayout._modeBar;
var copyButton = selectButton(modeBar, 'copyToClipboard');
expect(copyButton.node).toBeDefined();
expect(copyButton.node.getAttribute('data-title')).toBe('Copy plot to clipboard');

// Restore original clipboard
navigator.clipboard = originalClipboard;
})
.then(done)
.catch(failTest);
});

it('should not be present when clipboard API is not supported', function(done) {
// Mock no clipboard API support
var originalClipboard = navigator.clipboard;
navigator.clipboard = undefined;

Plotly.newPlot(gd, [{
x: [1, 2, 3],
y: [1, 2, 3]
}])
.then(function() {
var modeBar = gd._fullLayout._modeBar;
var copyButton = selectButton(modeBar, 'copyToClipboard');
expect(copyButton.node).toBeNull();

// Restore original clipboard
navigator.clipboard = originalClipboard;
})
.then(done)
.catch(failTest);
});

it('should call clipboard API when clicked', function(done) {
var clipboardWriteCalled = false;
var originalClipboard = navigator.clipboard;

// Mock successful clipboard API
navigator.clipboard = {
write: function(items) {
clipboardWriteCalled = true;
expect(items.length).toBe(1);
expect(items[0]).toEqual(jasmine.any(ClipboardItem));
return Promise.resolve();
}
};

Plotly.newPlot(gd, [{
x: [1, 2, 3],
y: [1, 2, 3]
}])
.then(function() {
var copyButton = selectButton(gd._fullLayout._modeBar, 'copyToClipboard');
copyButton.click();

// Wait a bit for async operations
setTimeout(function() {
expect(clipboardWriteCalled).toBe(true);

// Restore original clipboard
navigator.clipboard = originalClipboard;
done();
}, 100);
})
.catch(function(err) {
// Restore original clipboard
navigator.clipboard = originalClipboard;
failTest(err);
});
});
});
});