diff --git a/.circleci/config.yml b/.circleci/config.yml index b66d1326d15..3b6019c2acb 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -40,6 +40,7 @@ jobs: working_directory: ~/plotly.js steps: - run: sudo apt-get update + - browser-tools/start-xvfb - browser-tools/install-browser-tools: install-firefox: false install-geckodriver: false @@ -79,6 +80,7 @@ jobs: working_directory: ~/plotly.js steps: - run: sudo apt-get update + - browser-tools/start-xvfb - browser-tools/install-browser-tools: install-firefox: false install-geckodriver: false @@ -101,6 +103,7 @@ jobs: working_directory: ~/plotly.js steps: - run: sudo apt-get update + - browser-tools/start-xvfb - browser-tools/install-browser-tools: install-firefox: false install-geckodriver: false @@ -123,6 +126,7 @@ jobs: working_directory: ~/plotly.js steps: - run: sudo apt-get update + - browser-tools/start-xvfb - browser-tools/install-browser-tools: install-firefox: false install-geckodriver: false @@ -144,6 +148,7 @@ jobs: parallelism: 8 working_directory: ~/plotly.js steps: + - browser-tools/start-xvfb - browser-tools/install-browser-tools: &browser-versions install-firefox: false install-geckodriver: false @@ -164,6 +169,7 @@ jobs: working_directory: ~/plotly.js steps: - run: sudo apt-get update + - browser-tools/start-xvfb - browser-tools/install-browser-tools: install-firefox: false install-geckodriver: false @@ -185,6 +191,7 @@ jobs: working_directory: ~/plotly.js steps: - run: sudo apt-get update + - browser-tools/start-xvfb - browser-tools/install-browser-tools: install-firefox: false install-geckodriver: false @@ -205,6 +212,7 @@ jobs: TZ: "America/Anchorage" working_directory: ~/plotly.js steps: + - browser-tools/start-xvfb - browser-tools/install-browser-tools: install-chrome: false install-chromedriver: false @@ -581,9 +589,15 @@ workflows: requires: - install-and-cibuild - - publish-dist + - publish-dist: + filters: + branches: + only: master - - publish-dist-node-v22 + - publish-dist-node-v22: + filters: + branches: + only: master - test-stackgl-bundle diff --git a/.circleci/download_google_fonts.py b/.circleci/download_google_fonts.py index 8dc9dd7daa6..73fa5628483 100644 --- a/.circleci/download_google_fonts.py +++ b/.circleci/download_google_fonts.py @@ -1,11 +1,13 @@ import os +import time import requests dir_out = ".circleci/fonts/truetype/googleFonts/" -def download(repo, family, types, overwrite=True): +def download(repo, family, types, overwrite=True, retries=4, timeout=20): + session = requests.Session() for t in types: name = family + t + ".ttf" url = repo + name + "?raw=true" @@ -14,18 +16,49 @@ def download(repo, family, types, overwrite=True): if os.path.exists(out_file) and not overwrite: print(" => Already exists: ", out_file) continue - req = requests.get(url, allow_redirects=False) - if req.status_code != 200: - # If we get a redirect, print an error so that we know to update the URL - if req.status_code == 302 or req.status_code == 301: - new_url = req.headers.get("Location") - print(f" => Redirected -- please update URL to: {new_url}") - raise RuntimeError(f""" -Download failed. -Status code: {req.status_code} -Message: {req.reason} -""") - open(out_file, "wb").write(req.content) + + attempt = 0 + backoff = 2 + last_err = None + # follow up to 2 redirects manually to keep logs readable + max_redirects = 2 + while attempt <= retries: + try: + cur_url = url + redirects = 0 + while True: + req = session.get(cur_url, allow_redirects=False, timeout=timeout) + if req.status_code in (301, 302) and redirects < max_redirects: + new_url = req.headers.get("Location") + print(f" => Redirected to: {new_url}") + cur_url = new_url + redirects += 1 + continue + break + + if req.status_code == 200: + os.makedirs(os.path.dirname(out_file), exist_ok=True) + with open(out_file, "wb") as f: + f.write(req.content) + print(" => Saved:", out_file) + last_err = None + break + else: + print(f" => HTTP {req.status_code}: {req.reason}") + last_err = RuntimeError(f"HTTP {req.status_code}: {req.reason}") + except requests.exceptions.RequestException as e: + last_err = e + print(f" => Network error: {e}") + + attempt += 1 + if attempt <= retries: + print(f" => Retrying in {backoff}s (attempt {attempt}/{retries})...") + time.sleep(backoff) + backoff *= 2 + + if last_err is not None: + # Don't hard-fail the entire job; log and move on. + print(f" => Giving up on {name}: {last_err}") download( diff --git a/.circleci/test.sh b/.circleci/test.sh index 4fc876018b8..a367fae158a 100755 --- a/.circleci/test.sh +++ b/.circleci/test.sh @@ -79,7 +79,7 @@ case $1 in exit $EXIT_STATE ;; - mathjax-firefox) + mathjax-firefox-legacy) ./node_modules/karma/bin/karma start test/jasmine/karma.conf.js --FF --bundleTest=mathjax --nowatch || EXIT_STATE=$? exit $EXIT_STATE ;; 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..64a04662e25 100644 --- a/test/jasmine/tests/modebar_test.js +++ b/test/jasmine/tests/modebar_test.js @@ -11,6 +11,16 @@ var destroyGraphDiv = require('../assets/destroy_graph_div'); var selectButton = require('../assets/modebar_button'); var failTest = require('../assets/fail_test'); +// Ensure default environment doesn't expose clipboard API so button counts remain stable +// Individual clipboard tests will explicitly mock/enable it as needed. +var __origClipboard__ = (typeof navigator !== 'undefined') ? navigator.clipboard : undefined; +beforeAll(function() { + if(typeof navigator !== 'undefined') navigator.clipboard = undefined; +}); +afterAll(function() { + if(typeof navigator !== 'undefined') navigator.clipboard = __origClipboard__; +}); + describe('ModeBar', function() { 'use strict'; @@ -1945,4 +1955,99 @@ 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; + var originalClipboardItem = window.ClipboardItem; + + // Mock successful clipboard API + window.ClipboardItem = window.ClipboardItem || function ClipboardItem(data) { this.data = data; }; + navigator.clipboard = { + write: function(items) { + clipboardWriteCalled = true; + expect(items.length).toBe(1); + expect(items[0] instanceof ClipboardItem).toBeTrue(); + 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; + window.ClipboardItem = originalClipboardItem; + done(); + }, 100); + }) + .catch(function(err) { + // Restore original clipboard + navigator.clipboard = originalClipboard; + window.ClipboardItem = originalClipboardItem; + failTest(err); + }); + }); + }); });