Skip to content

Commit 200a677

Browse files
authored
Merge pull request #1726 from plotly/popup-links
support target and popup attributes in pseudo-html text links
2 parents 0daf073 + 3b343ea commit 200a677

File tree

2 files changed

+108
-27
lines changed

2 files changed

+108
-27
lines changed

src/lib/svg_text_utils.js

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -282,11 +282,36 @@ var SPLIT_TAGS = /(<[^<>]*>)/;
282282

283283
var ONE_TAG = /<(\/?)([^ >]*)(\s+(.*))?>/i;
284284

285-
// Style and href: pull them out of either single or double quotes.
286-
// Because we hack in other attributes with style (sub & sup), drop any trailing
287-
// semicolon in user-supplied styles so we can consistently append the tag-dependent style
285+
/*
286+
* style and href: pull them out of either single or double quotes. Also
287+
* - target: (_blank|_self|_parent|_top|framename)
288+
* note that you can't use target to get a popup but if you use popup,
289+
* a `framename` will be passed along as the name of the popup window.
290+
* per the spec, cannot contain whitespace.
291+
* for backward compatibility we default to '_blank'
292+
* - popup: a custom one for us to enable popup (new window) links. String
293+
* for window.open -> strWindowFeatures, like 'menubar=yes,width=500,height=550'
294+
* note that at least in Chrome, you need to give at least one property
295+
* in this string or the page will open in a new tab anyway. We follow this
296+
* convention and will not make a popup if this string is empty.
297+
* per the spec, cannot contain whitespace.
298+
*
299+
* Because we hack in other attributes with style (sub & sup), drop any trailing
300+
* semicolon in user-supplied styles so we can consistently append the tag-dependent style
301+
*/
288302
var STYLEMATCH = /(^|[\s"'])style\s*=\s*("([^"]*);?"|'([^']*);?')/i;
289303
var HREFMATCH = /(^|[\s"'])href\s*=\s*("([^"]*)"|'([^']*)')/i;
304+
var TARGETMATCH = /(^|[\s"'])target\s*=\s*("([^"\s]*)"|'([^'\s]*)')/i;
305+
var POPUPMATCH = /(^|[\s"'])popup\s*=\s*("([^"\s]*)"|'([^'\s]*)')/i;
306+
307+
// dedicated matcher for these quoted regexes, that can return their results
308+
// in two different places
309+
function getQuotedMatch(_str, re) {
310+
if(!_str) return null;
311+
var match = _str.match(re);
312+
return match && (match[3] || match[4]);
313+
}
314+
290315

291316
var COLORMATCH = /(^|;)\s*color:/;
292317

@@ -297,14 +322,14 @@ exports.plainText = function(_str) {
297322
};
298323

299324
function replaceFromMapObject(_str, list) {
300-
var out = _str || '';
325+
if(!_str) return '';
301326

302327
for(var i = 0; i < list.length; i++) {
303328
var item = list[i];
304-
out = out.replace(item.regExp, item.sub);
329+
_str = _str.replace(item.regExp, item.sub);
305330
}
306331

307-
return out;
332+
return _str;
308333
}
309334

310335
function convertEntities(_str) {
@@ -354,8 +379,7 @@ function convertToSVG(_str) {
354379

355380
// anchor is the only tag that doesn't turn into a tspan
356381
if(tag === 'a') {
357-
var hrefMatch = extra && extra.match(HREFMATCH);
358-
var href = hrefMatch && (hrefMatch[3] || hrefMatch[4]);
382+
var href = getQuotedMatch(extra, HREFMATCH);
359383

360384
out = '<a';
361385

@@ -364,7 +388,34 @@ function convertToSVG(_str) {
364388
var dummyAnchor = document.createElement('a');
365389
dummyAnchor.href = href;
366390
if(PROTOCOLS.indexOf(dummyAnchor.protocol) !== -1) {
367-
out += ' xlink:show="new" xlink:href="' + encodeForHTML(href) + '"';
391+
href = encodeForHTML(href);
392+
393+
// look for target and popup specs
394+
var target = encodeForHTML(getQuotedMatch(extra, TARGETMATCH)) || '_blank';
395+
var popup = encodeForHTML(getQuotedMatch(extra, POPUPMATCH));
396+
397+
/*
398+
* xlink:show is for backward compatibility only,
399+
* newer browsers allow target just like html links.
400+
*/
401+
var xlinkShow = (target === '_blank' || target.charAt(0) !== '_') ?
402+
'new' : 'replace';
403+
404+
out += ' xlink:show="' + xlinkShow + '" target="' + target +
405+
'" xlink:href="' + href + '"';
406+
407+
if(popup) {
408+
/*
409+
* Add the window.open call to create a popup
410+
* Even when popup is specified, we leave the original
411+
* href and target in place in case javascript is
412+
* unavailable in this context (like downloaded svg)
413+
* and for accessibility and so users can see where
414+
* the link will lead.
415+
*/
416+
out += ' onclick="window.open(\'' + href + '\',\'' + target +
417+
'\',\'' + popup + '\');return false;"';
418+
}
368419
}
369420
}
370421
}
@@ -380,8 +431,7 @@ function convertToSVG(_str) {
380431
// now add style, from both the tag name and any extra css
381432
// Most of the svg css that users will care about is just like html,
382433
// but font color is different (uses fill). Let our users ignore this.
383-
var cssMatch = extra && extra.match(STYLEMATCH);
384-
var css = cssMatch && (cssMatch[3] || cssMatch[4]);
434+
var css = getQuotedMatch(extra, STYLEMATCH);
385435
if(css) {
386436
css = encodeForHTML(css.replace(COLORMATCH, '$1 fill:'));
387437
if(tagStyle) css += ';' + tagStyle;

test/jasmine/tests/svg_text_utils_test.js

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,38 +18,49 @@ describe('svg+text utils', function() {
1818
.attr('transform', 'translate(50,50)');
1919
}
2020

21-
function assertAnchorLink(node, href) {
21+
function assertAnchorLink(node, href, target, show, msg) {
2222
var a = node.select('a');
2323

24-
expect(a.attr('xlink:href')).toBe(href);
25-
expect(a.attr('xlink:show')).toBe(href === null ? null : 'new');
24+
if(target === undefined) target = href === null ? null : '_blank';
25+
if(show === undefined) show = href === null ? null : 'new';
26+
27+
expect(a.attr('xlink:href')).toBe(href, msg);
28+
expect(a.attr('target')).toBe(target, msg);
29+
expect(a.attr('xlink:show')).toBe(show, msg);
2630
}
2731

28-
function assertTspanStyle(node, style) {
32+
function assertTspanStyle(node, style, msg) {
2933
var tspan = node.select('tspan');
30-
expect(tspan.attr('style')).toBe(style);
34+
expect(tspan.attr('style')).toBe(style, msg);
3135
}
3236

33-
function assertAnchorAttrs(node, style) {
37+
function assertAnchorAttrs(node, expectedAttrs, msg) {
3438
var a = node.select('a');
3539

36-
var WHITE_LIST = ['xlink:href', 'xlink:show', 'style'],
40+
if(!expectedAttrs) expectedAttrs = {};
41+
42+
var WHITE_LIST = ['xlink:href', 'xlink:show', 'style', 'target', 'onclick'],
3743
attrs = listAttributes(a.node());
3844

3945
// check that no other attribute are found in anchor,
4046
// which can be lead to XSS attacks.
4147

42-
var hasWrongAttr = attrs.some(function(attr) {
43-
return WHITE_LIST.indexOf(attr) === -1;
48+
var wrongAttrs = [];
49+
attrs.forEach(function(attr) {
50+
if(WHITE_LIST.indexOf(attr) === -1) wrongAttrs.push(attr);
4451
});
4552

46-
expect(hasWrongAttr).toBe(false);
53+
expect(wrongAttrs).toEqual([], msg);
4754

55+
var style = expectedAttrs.style || '';
4856
var fullStyle = style || '';
4957
if(style) fullStyle += ';';
5058
fullStyle += 'cursor:pointer';
5159

52-
expect(a.attr('style')).toBe(fullStyle);
60+
expect(a.attr('style')).toBe(fullStyle, msg);
61+
62+
expect(a.attr('onclick')).toBe(expectedAttrs.onclick || null, msg);
63+
5364
}
5465

5566
function listAttributes(node) {
@@ -137,7 +148,7 @@ describe('svg+text utils', function() {
137148
var node = mockTextSVGElement(textCase);
138149

139150
expect(node.text()).toEqual('Subtitle');
140-
assertAnchorAttrs(node, 'font-size:300px');
151+
assertAnchorAttrs(node, {style: 'font-size:300px'});
141152
assertAnchorLink(node, 'XSS');
142153
});
143154
});
@@ -157,11 +168,31 @@ describe('svg+text utils', function() {
157168
var node = mockTextSVGElement(textCase);
158169

159170
expect(node.text()).toEqual('z');
160-
assertAnchorAttrs(node, 'y');
171+
assertAnchorAttrs(node, {style: 'y'});
161172
assertAnchorLink(node, 'x');
162173
});
163174
});
164175

176+
it('accepts `target` with links and tries to translate it to `xlink:show`', function() {
177+
var specs = [
178+
{target: '_blank', show: 'new'},
179+
{target: '_self', show: 'replace'},
180+
{target: '_parent', show: 'replace'},
181+
{target: '_top', show: 'replace'},
182+
{target: 'some_frame_name', show: 'new'}
183+
];
184+
specs.forEach(function(spec) {
185+
var node = mockTextSVGElement('<a href="x" target="' + spec.target + '">link</a>');
186+
assertAnchorLink(node, 'x', spec.target, spec.show, spec.target);
187+
});
188+
});
189+
190+
it('attaches onclick if popup is specified', function() {
191+
var node = mockTextSVGElement('<a href="x" target="fred" popup="width=500,height=400">link</a>');
192+
assertAnchorLink(node, 'x', 'fred', 'new');
193+
assertAnchorAttrs(node, {onclick: 'window.open(\'x\',\'fred\',\'width=500,height=400\');return false;'});
194+
});
195+
165196
it('keeps query parameters in href', function() {
166197
var textCases = [
167198
'<a href="https://abc.com/myFeature.jsp?name=abc&pwd=def">abc.com?shared-key</a>',
@@ -171,9 +202,9 @@ describe('svg+text utils', function() {
171202
textCases.forEach(function(textCase) {
172203
var node = mockTextSVGElement(textCase);
173204

174-
assertAnchorAttrs(node);
175-
expect(node.text()).toEqual('abc.com?shared-key');
176-
assertAnchorLink(node, 'https://abc.com/myFeature.jsp?name=abc&pwd=def');
205+
assertAnchorAttrs(node, {}, textCase);
206+
expect(node.text()).toEqual('abc.com?shared-key', textCase);
207+
assertAnchorLink(node, 'https://abc.com/myFeature.jsp?name=abc&pwd=def', undefined, undefined, textCase);
177208
});
178209
});
179210

0 commit comments

Comments
 (0)