Skip to content
This repository was archived by the owner on Apr 12, 2024. It is now read-only.

Commit ffbd276

Browse files
committed
fix($compile): use the correct namespace for transcluded svg elements
Via transclusion, svg elements can occur outside an `<svg>` container in an Angular template but are put into an `<svg>` container through compilation and linking. E.g. Given that `svg-container` is a transcluding directive with the following template: ``` <svg ng-transclude></svg> ``` The following markup creates a `<circle>` inside of an `<svg>` element during runtime: ``` <svg-container> <circle></circle> </svg-container> ``` However, this produces non working `<circle>` elements, as svg elements need to be created inside of an `<svg>` element. This change detects for most cases the correct namespace of transcluded content and recreates that content in the correct `<svg>` container when needed during compilation. For special cases it adds an addition argument to `$transclude` that allows to specify the future parent node of elements that will be cloned and attached using the `cloneAttachFn`. Related to #8494 Closes #8716
1 parent 75c4cbf commit ffbd276

File tree

5 files changed

+255
-15
lines changed

5 files changed

+255
-15
lines changed

src/ng/compile.js

+51-15
Original file line numberDiff line numberDiff line change
@@ -185,9 +185,18 @@
185185
* * `$scope` - Current scope associated with the element
186186
* * `$element` - Current element
187187
* * `$attrs` - Current attributes object for the element
188-
* * `$transclude` - A transclude linking function pre-bound to the correct transclusion scope.
189-
* The scope can be overridden by an optional first argument.
190-
* `function([scope], cloneLinkingFn)`.
188+
* * `$transclude` - A transclude linking function pre-bound to the correct transclusion scope:
189+
* `function([scope], cloneLinkingFn, futureParentElement)`.
190+
* * `scope`: optional argument to override the scope.
191+
* * `cloneLinkingFn`: optional argument to create clones of the original translcuded content.
192+
* * `futureParentElement`:
193+
* * defines the parent to which the `cloneLinkingFn` will add the cloned elements.
194+
* * default: `$element.parent()` resp. `$element` for `transclude:'element'` resp. `transclude:true`.
195+
* * only needed for transcludes that are allowed to contain non html elements (e.g. SVG elements)
196+
* and when the `cloneLinkinFn` is passed,
197+
* as those elements need to created and cloned in a special way when they are defined outside their
198+
* usual containers (e.g. like `<svg>`).
199+
* * See also the `directive.templateNamespace` property.
191200
*
192201
*
193202
* #### `require`
@@ -265,6 +274,10 @@
265274
* one. See the {@link guide/directive#creating-custom-directives_creating-directives_template-expanding-directive
266275
* Directives Guide} for an example.
267276
*
277+
* There very few scenarios were element replacement is required for the application function,
278+
* the main one being reusable custom components that are used within SVG contexts
279+
* (because SVG doesn't work with custom elements in the DOM tree).
280+
*
268281
* #### `transclude`
269282
* compile the content of the element and make it available to the directive.
270283
* Typically used with {@link ng.directive:ngTransclude
@@ -359,10 +372,9 @@
359372
* the directives to use the controllers as a communication channel.
360373
*
361374
* * `transcludeFn` - A transclude linking function pre-bound to the correct transclusion scope.
362-
* The scope can be overridden by an optional first argument. This is the same as the `$transclude`
363-
* parameter of directive controllers.
364-
* `function([scope], cloneLinkingFn)`.
365-
*
375+
* This is the same as the `$transclude`
376+
* parameter of directive controllers, see there for details.
377+
* `function([scope], cloneLinkingFn, futureParentElement)`.
366378
*
367379
* #### Pre-linking function
368380
*
@@ -879,8 +891,18 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
879891
compileNodes($compileNodes, transcludeFn, $compileNodes,
880892
maxPriority, ignoreDirective, previousCompileContext);
881893
safeAddClass($compileNodes, 'ng-scope');
882-
return function publicLinkFn(scope, cloneConnectFn, transcludeControllers, parentBoundTranscludeFn){
894+
var namespace = null;
895+
return function publicLinkFn(scope, cloneConnectFn, transcludeControllers, parentBoundTranscludeFn, futureParentElement){
883896
assertArg(scope, 'scope');
897+
if (!namespace) {
898+
namespace = detectNamespaceForChildElements(futureParentElement);
899+
if (namespace !== 'html') {
900+
$compileNodes = jqLite(
901+
wrapTemplate(namespace, jqLite('<div>').append($compileNodes).html())
902+
);
903+
}
904+
}
905+
884906
// important!!: we must call our jqLite.clone() since the jQuery one is trying to be smart
885907
// and sometimes changes the structure of the DOM.
886908
var $linkNode = cloneConnectFn
@@ -901,6 +923,16 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
901923
};
902924
}
903925

926+
function detectNamespaceForChildElements(parentElement) {
927+
// TODO: Make this detect MathML as well...
928+
var node = parentElement && parentElement[0];
929+
if (!node) {
930+
return 'html';
931+
} else {
932+
return nodeName_(node) !== 'foreignobject' && node.toString().match(/SVG/) ? 'svg': 'html';
933+
}
934+
}
935+
904936
function safeAddClass($element, className) {
905937
try {
906938
$element.addClass(className);
@@ -1024,7 +1056,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
10241056

10251057
function createBoundTranscludeFn(scope, transcludeFn, previousBoundTranscludeFn, elementTransclusion) {
10261058

1027-
var boundTranscludeFn = function(transcludedScope, cloneFn, controllers) {
1059+
var boundTranscludeFn = function(transcludedScope, cloneFn, controllers, futureParentElement) {
10281060
var scopeCreated = false;
10291061

10301062
if (!transcludedScope) {
@@ -1033,7 +1065,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
10331065
scopeCreated = true;
10341066
}
10351067

1036-
var clone = transcludeFn(transcludedScope, cloneFn, controllers, previousBoundTranscludeFn);
1068+
var clone = transcludeFn(transcludedScope, cloneFn, controllers, previousBoundTranscludeFn, futureParentElement);
10371069
if (scopeCreated && !elementTransclusion) {
10381070
clone.on('$destroy', function() { transcludedScope.$destroy(); });
10391071
}
@@ -1645,20 +1677,24 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
16451677
}
16461678

16471679
// This is the function that is injected as `$transclude`.
1648-
function controllersBoundTransclude(scope, cloneAttachFn) {
1680+
// Note: all arguments are optional!
1681+
function controllersBoundTransclude(scope, cloneAttachFn, futureParentElement) {
16491682
var transcludeControllers;
16501683

1651-
// no scope passed
1652-
if (!cloneAttachFn) {
1684+
// No scope passed in:
1685+
if (!isScope(scope)) {
1686+
futureParentElement = cloneAttachFn;
16531687
cloneAttachFn = scope;
16541688
scope = undefined;
16551689
}
16561690

16571691
if (hasElementTranscludeDirective) {
16581692
transcludeControllers = elementControllers;
16591693
}
1660-
1661-
return boundTranscludeFn(scope, cloneAttachFn, transcludeControllers);
1694+
if (!futureParentElement) {
1695+
futureParentElement = hasElementTranscludeDirective ? $element.parent() : $element;
1696+
}
1697+
return boundTranscludeFn(scope, cloneAttachFn, transcludeControllers, futureParentElement);
16621698
}
16631699
}
16641700
}

test/ng/compileSpec.js

+144
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ describe('$compile', function() {
2626
return !isUnknownElement(d.firstChild);
2727
}
2828

29+
// IE9-11 do not support foreignObject in svg...
30+
function supportsForeignObject() {
31+
var d = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
32+
return !!d.toString().match(/SVGForeignObject/);
33+
}
34+
2935
var element, directive, $compile, $rootScope;
3036

3137
beforeEach(module(provideLog, function($provide, $compileProvider){
@@ -80,6 +86,45 @@ describe('$compile', function() {
8086
terminal: true
8187
}));
8288

89+
directive('svgContainer', function() {
90+
return {
91+
template: '<svg width="400" height="400" ng-transclude></svg>',
92+
replace: true,
93+
transclude: true
94+
};
95+
});
96+
97+
directive('svgCustomTranscludeContainer', function() {
98+
return {
99+
template: '<svg width="400" height="400"></svg>',
100+
transclude: true,
101+
link: function(scope, element, attr, ctrls, $transclude) {
102+
var futureParent = element.children().eq(0);
103+
$transclude(function(clone) {
104+
futureParent.append(clone);
105+
}, futureParent);
106+
}
107+
};
108+
});
109+
110+
directive('svgCircle', function(){
111+
return {
112+
template: '<circle cx="2" cy="2" r="1"></circle>',
113+
templateNamespace: 'svg',
114+
replace: true
115+
};
116+
});
117+
118+
directive('myForeignObject', function(){
119+
return {
120+
template: '<foreignObject width="100" height="100" ng-transclude></foreignObject>',
121+
templateNamespace: 'svg',
122+
replace: true,
123+
transclude: true
124+
};
125+
});
126+
127+
83128
return function(_$compile_, _$rootScope_) {
84129
$rootScope = _$rootScope_;
85130
$compile = _$compile_;
@@ -154,6 +199,105 @@ describe('$compile', function() {
154199
});
155200

156201

202+
describe('svg namespace transcludes', function() {
203+
// this method assumes some sort of sized SVG element is being inspected.
204+
function assertIsValidSvgCircle(elem) {
205+
expect(isUnknownElement(elem)).toBe(false);
206+
expect(isSVGElement(elem)).toBe(true);
207+
var box = elem.getBoundingClientRect();
208+
expect(box.width === 0 && box.height === 0).toBe(false);
209+
}
210+
211+
it('should handle transcluded svg elements', inject(function($compile){
212+
element = jqLite('<div><svg-container>' +
213+
'<circle cx="4" cy="4" r="2"></circle>' +
214+
'</svg-container></div>');
215+
$compile(element.contents())($rootScope);
216+
document.body.appendChild(element[0]);
217+
218+
var circle = element.find('circle');
219+
220+
assertIsValidSvgCircle(circle[0]);
221+
}));
222+
223+
it('should handle custom svg elements inside svg tag', inject(function(){
224+
element = jqLite('<div><svg width="300" height="300">' +
225+
'<svg-circle></svg-circle>' +
226+
'</svg></div>');
227+
$compile(element.contents())($rootScope);
228+
document.body.appendChild(element[0]);
229+
230+
var circle = element.find('circle');
231+
assertIsValidSvgCircle(circle[0]);
232+
}));
233+
234+
it('should handle transcluded custom svg elements', inject(function(){
235+
element = jqLite('<div><svg-container>' +
236+
'<svg-circle></svg-circle>' +
237+
'</svg-container></div>');
238+
$compile(element.contents())($rootScope);
239+
document.body.appendChild(element[0]);
240+
241+
var circle = element.find('circle');
242+
assertIsValidSvgCircle(circle[0]);
243+
}));
244+
245+
if (supportsForeignObject()) {
246+
it('should handle foreignObject', inject(function(){
247+
element = jqLite('<div><svg-container>' +
248+
'<foreignObject width="100" height="100"><div class="test" style="position:absolute;width:20px;height:20px">test</div></foreignObject>' +
249+
'</svg-container></div>');
250+
$compile(element.contents())($rootScope);
251+
document.body.appendChild(element[0]);
252+
253+
var testElem = element.find('div');
254+
expect(isHTMLElement(testElem[0])).toBe(true);
255+
var bounds = testElem[0].getBoundingClientRect();
256+
expect(bounds.width === 20 && bounds.height === 20).toBe(true);
257+
}));
258+
259+
it('should handle custom svg containers that transclude to foreignObject that transclude html', inject(function(){
260+
element = jqLite('<div><svg-container>' +
261+
'<my-foreign-object><div class="test" style="width:20px;height:20px">test</div></my-foreign-object>' +
262+
'</svg-container></div>');
263+
$compile(element.contents())($rootScope);
264+
document.body.appendChild(element[0]);
265+
266+
var testElem = element.find('div');
267+
expect(isHTMLElement(testElem[0])).toBe(true);
268+
var bounds = testElem[0].getBoundingClientRect();
269+
expect(bounds.width === 20 && bounds.height === 20).toBe(true);
270+
}));
271+
272+
// NOTE: This test may be redundant.
273+
it('should handle custom svg containers that transclude to foreignObject'+
274+
' that transclude to custom svg containers that transclude to custom elements', inject(function(){
275+
element = jqLite('<div><svg-container>' +
276+
'<my-foreign-object><svg-container><svg-circle></svg-circle></svg-container></my-foreign-object>' +
277+
'</svg-container></div>');
278+
$compile(element.contents())($rootScope);
279+
document.body.appendChild(element[0]);
280+
281+
var circle = element.find('circle');
282+
assertIsValidSvgCircle(circle[0]);
283+
}));
284+
}
285+
286+
it('should handle directives with templates that manually add the transclude further down', inject(function() {
287+
element = jqLite('<div><svg-custom-transclude-container>' +
288+
'<circle cx="2" cy="2" r="1"></circle></svg-custom-transclude-container>' +
289+
'</div>');
290+
$compile(element.contents())($rootScope);
291+
document.body.appendChild(element[0]);
292+
293+
var circle = element.find('circle');
294+
assertIsValidSvgCircle(circle[0]);
295+
296+
}));
297+
298+
});
299+
300+
157301
describe('compile phase', function() {
158302

159303
it('should attach scope to the document node when it is compiled explicitly', inject(function($document){

test/ng/directive/ngIfSpec.js

+19
Original file line numberDiff line numberDiff line change
@@ -351,4 +351,23 @@ describe('ngIf animations', function () {
351351
});
352352
});
353353

354+
it('should work with svg elements when the svg container is transcluded', function() {
355+
module(function($compileProvider) {
356+
$compileProvider.directive('svgContainer', function() {
357+
return {
358+
template: '<svg ng-transclude></svg>',
359+
replace: true,
360+
transclude: true
361+
};
362+
});
363+
});
364+
inject(function($compile, $rootScope) {
365+
element = $compile('<svg-container><circle ng-if="flag"></circle></svg-container>')($rootScope);
366+
$rootScope.flag = true;
367+
$rootScope.$apply();
368+
369+
var circle = element.find('circle');
370+
expect(circle[0].toString()).toMatch(/SVG/);
371+
});
372+
});
354373
});

test/ng/directive/ngRepeatSpec.js

+20
Original file line numberDiff line numberDiff line change
@@ -1463,4 +1463,24 @@ describe('ngRepeat animations', function() {
14631463
})
14641464
);
14651465

1466+
it('should work with svg elements when the svg container is transcluded', function() {
1467+
module(function($compileProvider) {
1468+
$compileProvider.directive('svgContainer', function() {
1469+
return {
1470+
template: '<svg ng-transclude></svg>',
1471+
replace: true,
1472+
transclude: true
1473+
};
1474+
});
1475+
});
1476+
inject(function($compile, $rootScope) {
1477+
element = $compile('<svg-container><circle ng-repeat="r in rows"></circle></svg-container>')($rootScope);
1478+
$rootScope.rows = [1];
1479+
$rootScope.$apply();
1480+
1481+
var circle = element.find('circle');
1482+
expect(circle[0].toString()).toMatch(/SVG/);
1483+
});
1484+
});
1485+
14661486
});

test/ng/directive/ngSwitchSpec.js

+21
Original file line numberDiff line numberDiff line change
@@ -433,4 +433,25 @@ describe('ngSwitch animations', function() {
433433
expect(destroyed).toBe(true);
434434
});
435435
});
436+
437+
it('should work with svg elements when the svg container is transcluded', function() {
438+
module(function($compileProvider) {
439+
$compileProvider.directive('svgContainer', function() {
440+
return {
441+
template: '<svg ng-transclude></svg>',
442+
replace: true,
443+
transclude: true
444+
};
445+
});
446+
});
447+
inject(function($compile, $rootScope) {
448+
element = $compile('<svg-container ng-switch="inc"><circle ng-switch-when="one"></circle>' +
449+
'</svg-container>')($rootScope);
450+
$rootScope.inc = 'one';
451+
$rootScope.$apply();
452+
453+
var circle = element.find('circle');
454+
expect(circle[0].toString()).toMatch(/SVG/);
455+
});
456+
});
436457
});

0 commit comments

Comments
 (0)