diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js index 87196d29524..5f426686815 100644 --- a/src/components/modebar/buttons.js +++ b/src/components/modebar/buttons.js @@ -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'); }, diff --git a/src/components/modebar/manage.js b/src/components/modebar/manage.js index 3c9a4626192..d9acc09b26c 100644 --- a/src/components/modebar/manage.js +++ b/src/components/modebar/manage.js @@ -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); diff --git a/src/fonts/ploticon.js b/src/fonts/ploticon.js index f9f496f7ed0..efd26de4afa 100644 --- a/src/fonts/ploticon.js +++ b/src/fonts/ploticon.js @@ -183,5 +183,11 @@ module.exports = { ' ', '' ].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)' } }; diff --git a/test/jasmine/tests/modebar_test.js b/test/jasmine/tests/modebar_test.js index 4d14c87d700..2f6f9058c74 100644 --- a/test/jasmine/tests/modebar_test.js +++ b/test/jasmine/tests/modebar_test.js @@ -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); + }); + }); + }); });