diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js
index 25619fa9ebc..1c149d89741 100644
--- a/src/components/annotations/draw.js
+++ b/src/components/annotations/draw.js
@@ -188,6 +188,18 @@ function drawOne(gd, index) {
}
function drawGraphicalElements() {
+ // if the text has *only* a link, make the whole box into a link
+ var anchor = annText.selectAll('a');
+ if(anchor.size() === 1 && anchor.text() === annText.text()) {
+ var wholeLink = annTextGroupInner.insert('a', ':first-child').attr({
+ 'xlink:xlink:href': anchor.attr('xlink:href'),
+ 'xlink:xlink:show': anchor.attr('xlink:show')
+ })
+ .style({cursor: 'pointer'});
+
+ wholeLink.node().appendChild(annTextBG.node());
+ }
+
// make sure lines are aligned the way they will be
// at the end, even if their position changes
diff --git a/src/lib/svg_text_utils.js b/src/lib/svg_text_utils.js
index bb76fa2d283..e63e6485ca9 100644
--- a/src/lib/svg_text_utils.js
+++ b/src/lib/svg_text_utils.js
@@ -221,19 +221,25 @@ function texToSVG(_texString, _config, _callback) {
}
var TAG_STYLES = {
- // would like to use baseline-shift but FF doesn't support it yet
+ // would like to use baseline-shift for sub/sup but FF doesn't support it
// so we need to use dy along with the uber hacky shift-back-to
// baseline below
sup: 'font-size:70%" dy="-0.6em',
sub: 'font-size:70%" dy="0.3em',
b: 'font-weight:bold',
i: 'font-style:italic',
- a: '',
+ a: 'cursor:pointer',
span: '',
br: '',
em: 'font-style:italic;font-weight:bold'
};
+// sub/sup: extra tspan with zero-width space to get back to the right baseline
+var TAG_CLOSE = {
+ sup: '',
+ sub: ''
+};
+
var PROTOCOLS = ['http:', 'https:', 'mailto:'];
var STRIP_TAGS = new RegExp('?(' + Object.keys(TAG_STYLES).join('|') + ')( [^>]*)?/?>', 'g');
@@ -254,6 +260,18 @@ var UNICODE_TO_ENTITY = Object.keys(stringMappings.unicodeToEntity).map(function
var NEWLINES = /(\r\n?|\n)/g;
+var SPLIT_TAGS = /(<[^<>]*>)/;
+
+var ONE_TAG = /<(\/?)([^ >]*)(\s+(.*))?>/i;
+
+// Style and href: pull them out of either single or double quotes.
+// Because we hack in other attributes with style (sub & sup), drop any trailing
+// semicolon in user-supplied styles so we can consistently append the tag-dependent style
+var STYLEMATCH = /(^|[\s"'])style\s*=\s*("([^"]*);?"|'([^']*);?')/i;
+var HREFMATCH = /(^|[\s"'])href\s*=\s*("([^"]*)"|'([^']*)')/i;
+
+var COLORMATCH = /(^|;)\s*color:/;
+
exports.plainText = function(_str) {
// strip out our pseudo-html so we have a readable
// version to put into text fields
@@ -280,84 +298,93 @@ function encodeForHTML(_str) {
}
function convertToSVG(_str) {
- _str = convertEntities(_str);
-
- // normalize behavior between IE and others wrt newlines and whitespace:pre
- // this combination makes IE barf https://github.com/plotly/plotly.js/issues/746
- // Chrome and FF display \n, \r, or \r\n as a space in this mode.
- // I feel like at some point we turned these into
but currently we don't so
- // I'm just going to cement what we do now in Chrome and FF
- _str = _str.replace(NEWLINES, ' ');
+ _str = convertEntities(_str)
+ /*
+ * Normalize behavior between IE and others wrt newlines and whitespace:pre
+ * this combination makes IE barf https://github.com/plotly/plotly.js/issues/746
+ * Chrome and FF display \n, \r, or \r\n as a space in this mode.
+ * I feel like at some point we turned these into
but currently we don't so
+ * I'm just going to cement what we do now in Chrome and FF
+ */
+ .replace(NEWLINES, ' ');
var result = _str
- .split(/(<[^<>]*>)/).map(function(d) {
- var match = d.match(/<(\/?)([^ >]*)\s*(.*)>/i),
- tag = match && match[2].toLowerCase(),
- style = TAG_STYLES[tag];
-
- if(style !== undefined) {
- var close = match[1],
- extra = match[3],
- /**
- * extraStyle: any random extra css (that's supported by svg)
- * use this like to change font in the middle
- *
- * at one point we supported but as this isn't even
- * valid HTML anymore and we dropped it accidentally for many months, we will not
- * resurrect it.
- */
- extraStyle = extra.match(/^style\s*=\s*"([^"]+)"\s*/i);
-
- // anchor and br are the only ones that don't turn into a tspan
+ .split(SPLIT_TAGS).map(function(d) {
+ var match = d.match(ONE_TAG);
+ var tag = match && match[2].toLowerCase();
+ var tagStyle = TAG_STYLES[tag];
+
+ if(tagStyle !== undefined) {
+ var isClose = match[1];
+ if(isClose) return (tag === 'a' ? '' : '') + (TAG_CLOSE[tag] || '');
+
+ // break: later we'll turn these into newline s
+ // but we need to know about all the other tags first
+ if(tag === 'br') return '
';
+
+ /**
+ * extra includes href and any random extra css (that's supported by svg)
+ * use this like to change font in the middle
+ *
+ * at one point we supported but as this isn't even
+ * valid HTML anymore and we dropped it accidentally for many months, we will not
+ * resurrect it.
+ */
+ var extra = match[4];
+
+ var out;
+
+ // anchor is the only tag that doesn't turn into a tspan
if(tag === 'a') {
- if(close) return '';
- else if(extra.substr(0, 4).toLowerCase() !== 'href') return '';
- else {
- // remove quotes, leading '=', replace '&' with '&'
- var href = extra.substr(4)
- .replace(/["']/g, '')
- .replace(/=/, '');
-
- // check protocol
+ var hrefMatch = extra && extra.match(HREFMATCH);
+ var href = hrefMatch && (hrefMatch[3] || hrefMatch[4]);
+
+ out = '';
-
- return '';
+ if(PROTOCOLS.indexOf(dummyAnchor.protocol) !== -1) {
+ out += ' xlink:show="new" xlink:href="' + encodeForHTML(href) + '"';
+ }
}
}
- else if(tag === 'br') return '
';
- else if(close) {
- // closing tag
-
- // sub/sup: extra tspan with zero-width space to get back to the right baseline
- if(tag === 'sup') return '';
- if(tag === 'sub') return '';
- else return '';
- }
else {
- var tspanStart = '';
+ // now add style, from both the tag name and any extra css
+ // Most of the svg css that users will care about is just like html,
+ // but font color is different (uses fill). Let our users ignore this.
+ var cssMatch = extra && extra.match(STYLEMATCH);
+ var css = cssMatch && (cssMatch[3] || cssMatch[4]);
+ if(css) {
+ css = encodeForHTML(css.replace(COLORMATCH, '$1 fill:'));
+ if(tagStyle) css += ';' + tagStyle;
}
+ else if(tagStyle) css = tagStyle;
+
+ if(css) return out + ' style="' + css + '">';
+
+ return out + '>';
}
else {
return exports.xml_entity_encode(d).replace(/ which isn't a tspan even now!)
+ // we should really do this in a type-aware way *before* converting to tspans.
var indices = [];
for(var index = result.indexOf('
'); index > 0; index = result.indexOf('
', index + 1)) {
indices.push(index);
diff --git a/test/jasmine/tests/annotations_test.js b/test/jasmine/tests/annotations_test.js
index edaf854741b..6a9d1c846c2 100644
--- a/test/jasmine/tests/annotations_test.js
+++ b/test/jasmine/tests/annotations_test.js
@@ -1251,4 +1251,38 @@ describe('annotation effects', function() {
.catch(failTest)
.then(done);
});
+
+ it('makes the whole text box a link if the link is the whole text', function(done) {
+ makePlot([
+ {x: 20, y: 20, text: 'Plot', showarrow: false},
+ {x: 50, y: 50, text: 'or not', showarrow: false},
+ {x: 80, y: 80, text: 'arrow'},
+ {x: 20, y: 80, text: 'nor this'}
+ ])
+ .then(function() {
+ function checkBoxLink(index, isLink) {
+ var boxLink = d3.selectAll('.annotation[data-index="' + index + '"] g>a');
+ expect(boxLink.size()).toBe(isLink ? 1 : 0);
+
+ var textLink = d3.selectAll('.annotation[data-index="' + index + '"] text a');
+ expect(textLink.size()).toBe(1);
+ checkLink(textLink);
+
+ if(isLink) checkLink(boxLink);
+ }
+
+ function checkLink(link) {
+ expect(link.style('cursor')).toBe('pointer');
+ expect(link.attr('xlink:href')).toBe('https://plot.ly');
+ expect(link.attr('xlink:show')).toBe('new');
+ }
+
+ checkBoxLink(0, true);
+ checkBoxLink(1, false);
+ checkBoxLink(2, true);
+ checkBoxLink(3, false);
+ })
+ .catch(failTest)
+ .then(done);
+ });
});
diff --git a/test/jasmine/tests/svg_text_utils_test.js b/test/jasmine/tests/svg_text_utils_test.js
index 3468f65317c..cacb9cfef0e 100644
--- a/test/jasmine/tests/svg_text_utils_test.js
+++ b/test/jasmine/tests/svg_text_utils_test.js
@@ -6,7 +6,7 @@ var util = require('@src/lib/svg_text_utils');
describe('svg+text utils', function() {
'use strict';
- describe('convertToTspans should', function() {
+ describe('convertToTspans', function() {
function mockTextSVGElement(txt) {
return d3.select('body')
@@ -30,7 +30,7 @@ describe('svg+text utils', function() {
expect(tspan.attr('style')).toBe(style);
}
- function assertAnchorAttrs(node) {
+ function assertAnchorAttrs(node, style) {
var a = node.select('a');
var WHITE_LIST = ['xlink:href', 'xlink:show', 'style'],
@@ -44,6 +44,12 @@ describe('svg+text utils', function() {
});
expect(hasWrongAttr).toBe(false);
+
+ var fullStyle = style || '';
+ if(style) fullStyle += ';';
+ fullStyle += 'cursor:pointer';
+
+ expect(a.attr('style')).toBe(fullStyle);
}
function listAttributes(node) {
@@ -60,7 +66,7 @@ describe('svg+text utils', function() {
d3.select('#text').remove();
});
- it('check for XSS attack in href', function() {
+ it('checks for XSS attack in href', function() {
var node = mockTextSVGElement(
'XSS'
);
@@ -70,7 +76,7 @@ describe('svg+text utils', function() {
assertAnchorLink(node, null);
});
- it('check for XSS attack in href (with plenty of white spaces)', function() {
+ it('checks for XSS attack in href (with plenty of white spaces)', function() {
var node = mockTextSVGElement(
'XSS'
);
@@ -80,7 +86,7 @@ describe('svg+text utils', function() {
assertAnchorLink(node, null);
});
- it('whitelist relative hrefs (interpreted as http)', function() {
+ it('whitelists relative hrefs (interpreted as http)', function() {
var node = mockTextSVGElement(
'mylink'
);
@@ -90,7 +96,7 @@ describe('svg+text utils', function() {
assertAnchorLink(node, '/mylink');
});
- it('whitelist http hrefs', function() {
+ it('whitelists http hrefs', function() {
var node = mockTextSVGElement(
'bl.ocks.org'
);
@@ -100,7 +106,7 @@ describe('svg+text utils', function() {
assertAnchorLink(node, 'http://bl.ocks.org/');
});
- it('whitelist https hrefs', function() {
+ it('whitelists https hrefs', function() {
var node = mockTextSVGElement(
'plot.ly'
);
@@ -110,7 +116,7 @@ describe('svg+text utils', function() {
assertAnchorLink(node, 'https://plot.ly');
});
- it('whitelist mailto hrefs', function() {
+ it('whitelists mailto hrefs', function() {
var node = mockTextSVGElement(
'support'
);
@@ -120,7 +126,8 @@ describe('svg+text utils', function() {
assertAnchorLink(node, 'mailto:support@plot.ly');
});
- it('wrap XSS attacks in href', function() {
+ it('drops XSS attacks in href', function() {
+ // "XSS" gets interpreted as a relative link (http)
var textCases = [
'Subtitle',
'Subtitle'
@@ -130,12 +137,32 @@ describe('svg+text utils', function() {
var node = mockTextSVGElement(textCase);
expect(node.text()).toEqual('Subtitle');
- assertAnchorAttrs(node);
- assertAnchorLink(node, 'XSS onmouseover=alert(1) style=font-size:300px');
+ assertAnchorAttrs(node, 'font-size:300px');
+ assertAnchorLink(node, 'XSS');
+ });
+ });
+
+ it('accepts href and style in in any order and tosses other stuff', function() {
+ var textCases = [
+ 'z',
+ 'z',
+ 'z',
+ 'z',
+ 'z',
+ 'z',
+ 'z',
+ ];
+
+ textCases.forEach(function(textCase) {
+ var node = mockTextSVGElement(textCase);
+
+ expect(node.text()).toEqual('z');
+ assertAnchorAttrs(node, 'y');
+ assertAnchorLink(node, 'x');
});
});
- it('should keep query parameters in href', function() {
+ it('keeps query parameters in href', function() {
var textCases = [
'abc.com?shared-key',
'abc.com?shared-key'
@@ -150,7 +177,7 @@ describe('svg+text utils', function() {
});
});
- it('allow basic spans', function() {
+ it('allows basic spans', function() {
var node = mockTextSVGElement(
'text'
);
@@ -159,7 +186,7 @@ describe('svg+text utils', function() {
assertTspanStyle(node, null);
});
- it('ignore unquoted styles in spans', function() {
+ it('ignores unquoted styles in spans', function() {
var node = mockTextSVGElement(
'text'
);
@@ -168,7 +195,7 @@ describe('svg+text utils', function() {
assertTspanStyle(node, null);
});
- it('allow quoted styles in spans', function() {
+ it('allows quoted styles in spans', function() {
var node = mockTextSVGElement(
'text'
);
@@ -177,7 +204,7 @@ describe('svg+text utils', function() {
assertTspanStyle(node, 'quoted: yeah;');
});
- it('ignore extra stuff after span styles', function() {
+ it('ignores extra stuff after span styles', function() {
var node = mockTextSVGElement(
'text'
);
@@ -195,7 +222,7 @@ describe('svg+text utils', function() {
assertTspanStyle(node, 'quoted: yeah&\';;');
});
- it('decode some HTML entities in text', function() {
+ it('decodes some HTML entities in text', function() {
var node = mockTextSVGElement(
'100μ & < 10 > 0 ' +
'100 × 20 ± 0.5 °'
@@ -203,5 +230,50 @@ describe('svg+text utils', function() {
expect(node.text()).toEqual('100μ & < 10 > 0 100 × 20 ± 0.5 °');
});
+
+ it('supports superscript by itself', function() {
+ var node = mockTextSVGElement('123');
+ expect(node.html()).toBe(
+ '123' +
+ '');
+ });
+
+ it('supports subscript by itself', function() {
+ var node = mockTextSVGElement('123');
+ expect(node.html()).toBe(
+ '123' +
+ '');
+ });
+
+ it('supports superscript and subscript together with normal text', function() {
+ var node = mockTextSVGElement('SO42-');
+ expect(node.html()).toBe(
+ 'SO4' +
+ '' +
+ '2-' +
+ '');
+ });
+
+ it('allows one to span
s', function() {
+ var node = mockTextSVGElement('be Bold
and
Strong');
+ expect(node.html()).toBe(
+ 'be ' +
+ 'Bold' +
+ '' +
+ 'and' +
+ '' +
+ '' +
+ 'Strong');
+ });
+
+ it('allows one to span
s', function() {
+ var node = mockTextSVGElement('SO4
44');
+ expect(node.html()).toBe(
+ 'SO' +
+ '4' +
+ '' +
+ '44' +
+ '');
+ });
});
});
diff --git a/test/jasmine/tests/transform_sort_test.js b/test/jasmine/tests/transform_sort_test.js
index 6125eeadde3..b6f97ab793b 100644
--- a/test/jasmine/tests/transform_sort_test.js
+++ b/test/jasmine/tests/transform_sort_test.js
@@ -283,7 +283,7 @@ describe('Test sort transform interactions:', function() {
function wait() {
return new Promise(function(resolve) {
- setTimeout(resolve, 60);
+ setTimeout(resolve, 100);
});
}