Skip to content
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
18 changes: 16 additions & 2 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
59 changes: 46 additions & 13 deletions .circleci/download_google_fonts.py
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion .circleci/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
;;
Expand Down
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)'
}
};
105 changes: 105 additions & 0 deletions test/jasmine/tests/modebar_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
});
});
});
});