Accessing $scope On The DOM Using AngularJS
Most of the time, when you create an AngularJS directive, you know what $scope reference you are dealing with - the one that is passed into your link() function. Sometimes, however, your directive needs to deal with a collection of DOM elements, each of which has its own scope. And, as much as you don't want your DOM tree to be your "source of truth," in the right circumstances, accessing $scope references from the DOM tree can make your life a lot easier. In such cases, AngularJS provides a jQuery plugin - scope() - which allows you to access the $scope reference that is associated with the given DOM element.
To demonstrate the scope() plugin, I thought I would try to apply the jQuery UI sortable behavior to an ngRepeat list. If you'll recall ngRepeat is an AngularJS directive that outputs a list based on a JavaScript collection. In this demo, we'll let the user manually sort that list; then, when they are done sorting, we'll rebuild the AngularJS collection based on the resulting DOM tree.
Unfortunately, the context of this demonstration is a bit complicated because we have to jump through a number of hoops in order to get the proper assignment expression from the nested ngRepeat. If you want to look at a smaller example that uses the scope() plugin, take a look at my post on using jQuery event delegation in AngularJS.
That said, as you look at this demo, notice what happens when the jQuery UI Sortable plugin triggers its "update" event - we query the DOM tree to see the order of items. Then, we extract the relevant $scope value from each item and use it to rebuild the original collection.
<!doctype html>
<html ng-app="Demo" ng-controller="DemoController">
<head>
<meta charset="utf-8" />
<title>
Accessing $scope On The DOM Using AngularJS
</title>
<style type="text/css">
ul {
list-style-type: none ;
margin: 0px 0px 0px 0px ;
padding: 0px 0px 0px 0px ;
}
li {
background-color: #FAFAFA ;
border: 1px solid #CCCCCC ;
margin: 0px 0px 5px 0px ;
padding: 10px 10px 10px 10px ;
}
</style>
</head>
<body>
<h1>
Accessing $scope On The DOM Using AngularJS
</h1>
<!-- Show one list of simple names. -->
<p>
<strong>Order</strong>:
<span ng-repeat="friend in friends">
{{ friend.name }}
<span ng-show=" ! $last ">
-
</span>
</span>
</p>
<!-- Show another list of SORTABLE names. -->
<ul bn-sortable>
<li ng-repeat="friend in friends">
{{ $index }} - {{ friend.name }}
</li>
</ul>
<!-- Load jQuery, jQuery UI, and AngularJS from the CDN. -->
<script
type="text/javascript"
src="//code.jquery.com/jquery-1.9.1.min.js">
</script>
<script
type="text/javascript"
src="//code.jquery.com/ui/1.10.1/jquery-ui.js">
</script>
<script
type="text/javascript"
src="//ajax.googleapis.com/ajax/libs/angularjs/1.0.4/angular.min.js">
</script>
<!-- Load the app module and its classes. -->
<script type="text/javascript">
// Define our AngularJS application module.
var demo = angular.module( "Demo", [] );
// -------------------------------------------------- //
// -------------------------------------------------- //
// I am the main controller for the application.
demo.controller(
"DemoController",
function( $scope ) {
// I am the collection that is being output.
$scope.friends = [
{
id: 1,
name: "Tricia"
},
{
id: 2,
name: "Sarah"
},
{
id: 3,
name: "Joanna"
},
{
id: 4,
name: "Franzi"
}
];
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// I manage the application of the sortable plugin and then
// update the collection to reflect the new DOM ordering. This
// directive ASSUMES that the sortable items are the children
// of the element on which the sortable behavior is being
// applied. It will gather the sortable expression from the
// actual ngRepeat directive.
demo.directive(
"bnSortable",
function( $parse ) {
// I link the element to the UI events.
function link( $scope, element, attributes ) {
// I return the ngRepeat expression. This has to
// be extracted from the anchor comment used in
// the ngRepeat transclusion.
function getNgRepeatExpression() {
var ngRepeatComment = element.contents().filter(
function() {
return(
( this.nodeType === 8 ) &&
( this.nodeValue.indexOf( "ngRepeat:" ) !== -1 )
);
}
);
return(
getNgRepeatExpressionFromComment(
ngRepeatComment[ 0 ]
)
);
}
// I get the ngRepeat expression from the comment
// that is used as the transclusion base for the
// ngRepeat link phase. This is in the form of:
// "ngRepeat: expression"
function getNgRepeatExpressionFromComment( comment ) {
var parts = comment.nodeValue.split( ":" );
return(
parts[ 1 ].replace( /^\s+|\s+$/g, "" )
);
}
// I get the children with the ngRepeat attribute.
function getNgRepeatChildren() {
var attributeVariants = [
"[ng-repeat]",
"[data-ng-repeat]",
"[x-ng-repeat]",
"[ng_repeat]",
"[ngRepeat]",
"[ng\\:repeat]"
];
return(
element.children(
attributeVariants.join( "," )
)
);
}
// I get called when the user has stopped sorting
// AND the DOM has actually changed.
function handleUpdate( event, ui ) {
// Gather all of the children that have some
// variant of the ngRepeat directive.
var children = getNgRepeatChildren();
// Now, extract all of the ngRepeat items out
// of the dom using the scope() plugin.
var items = $.map(
children,
function( domItem, index ) {
var scope = $( domItem ).scope();
return( scope[ itemName ] );
}
);
// Now that we have the re-ordered collection,
// let's assign it back to the original
// collection so that AngularJS can update the
// rendering of the ngRepeat.
$scope.$apply(
function() {
collectionSetter( $scope, items );
}
);
}
// -------------------------------------- //
// I am the pattern of acceptable invocation for
// the sortable expression. It must be in the form
// of "REPEAT-ITEM in COLLECTION" where "collection"
// is something that can be get/set a value.
var expressionPattern = /^([^\s]+) in (.+)$/i;
// I am the pattern provided by the user in the
// nested ngRepeat directive.
var expression = getNgRepeatExpression();
if ( ! expressionPattern.test( expression ) ) {
throw( new Error( "Expected ITEM in COLLECTION expression." ) );
}
// Break the expression up in to parts that we can
// parse for item assignment. This will return an
// array with 3 items:
// [ 0 ] = Full match.
// [ 1 ] = First group; item.
// [ 2 ] = Second group; collection.
var expressionParts = expression.match( expressionPattern );
// Pluck out the names of the relevant items.
var itemName = expressionParts[ 1 ];
var collectionName = expressionParts[ 2 ];
// Parse the collection name so that we can easily
// assign to it once the sort has been updated.
var collectionGetter = $parse( collectionName );
var collectionSetter = collectionGetter.assign;
// -------------------------------------- //
// Apply the sortable items to the children.
element.sortable({
cursor: "move",
update: handleUpdate
});
}
// I configure the directive.
return({
link: link,
restrict: "A"
});
}
);
</script>
</body>
</html>
Right now, this only works with ngRepeat elements that use the "variable in expression" repeater expression. You can also use object-based repeater expressions - ( key, value ) - but my directive is not quite that clever. Once I have extracted the ngRepeat expression, I then break it appart so that I can access the name of the "variable" as well as the name of the collection being rendered.
In order to re-assign the collection after it has been re-sorted by the user, I have to parse the collection expression using the $parse() service object (provided by AngualrJS). In doing this, I now have a Getter and a Setter method that can be used to read-from and write-to the collection, respectively.
Once the user has finished sorting the DOM elements, I then have to map the DOM tree onto a collection of items (ie. the "variable" part of the ngRepeat) and assign it back to the collection. As you can see, mapping the DOM elements onto an array is fairly straightforward because I can use the scope() plugin to access the $scope instance associated with each item in the ngRepeat.
The reassignment is done in an $apply() method so that AngularJS is made aware of the change and has a chance to re-render the HTML. Probably, a good augmentation to this demo would be to have the bnSortable attribute accept a "callback" expression to be triggered when the sort has changed.
Most of the time, you won't have to deal with the scope() plugin. Most of the time, you'll know which $scope instance you need to work with. But, in outlier cases - like this - where there's a lot of fancy DOM manipulation taking place, using scope() will make your life a lot easier.
Want to use code from this post? Check out the license.
Reader Comments
Have you looked at angular-ui? The sortable plugin there uses ng-model to sync DOM changes. It basically uses the sortable events to track initial index and then final index and then modifies the $modelValue to re-arrange items. Works with connected sortables too.
@Dave,
Yeah. I've actually learned a lot about AngularJS from reading through the angular-ui source code. I actually had ui sortable implemented in an app; but, then I had to replace it with a custom build (of basically the same thing) that would take into account filtering of the list that was being sorted.
So, imagine you have a list with items:
"xxxxAZxxxx"
... and then you sorted it using a filter down to:
"AZ"
At this point, if you drag-drop-sort the list to look like this, in which Z came before A:
"ZA"
... and then removed the filter, you would have to get the following:
"xxxxZAxxxx"
As you can see, the sort took affect in the smallest scope possible.
This was a really funky feature to have to workout and I had to take the direction of the sort into account when figuring out where to splice and inject the moved models.
Definitely, it would have been way nicer to just use angular-ui :D
@Ben,
That makes sense and was something I was just thinking about (should have been sleeping)... I've been using ui-sortable but was wondering how I was going to handle sorting while filtering - now I have some ideas.
Thanks for the useful articles, they have come in quite handy as I try to get my head around how angular does stuff.
@Dave,
Glad to help. I'll see if I can some up with a demo of my sortable / filterable approach. It was a little bit complicated because you have to take into account which direction the sorted item is moving in (ie. left vs right) as this will influence the way in which the moved item is injected before OR after the static item. It was annoying :)
@Ben
may you explain this line:
I can't find documentation for .italic
thank you
* I can't find documentation for .assign
sorry for disturbing,
I find the answer here http://docs.angularjs.org/api/ng.$parse
@Aladdin,
No problem at all. If you had the question, I am sure other people did to. The documentation for $parse() is pretty small; the only thing you really have to go on is the unit test example.
@Ben,
You can see my attempt at something like this at
https://github.com/davecoates/angular-ui-sortable
Was a good learning experience, especially trying to get some test cases going with the jQuery UI stuff.
@Dave,
Very cool that you took the Filtering into account. That was the biggest hurdle for me. I like that you ended up using the sortable classes to figure out where to insert - pretty clever!
Hi Ben,
About extracting the ngRepeat expression, why not simply extract it from the node attributes it in the compile phase instead of parsing the comments ?
eg:
@Jujule,
Excellent question! To be honest, I'm not that comfortable / familiar with the "compile" phase of the Directive, so I've only done some minor experiments with it. As such, I don't often think about trying to leverage it yet. But I think you're right - accessing it during that phase would have much easier than filtering for comment DOM nodes.
Thanks for the great suggestion!
Hi Dave,
What if we want to be able to select multiple elements and move them simultaneously ?
@james,
If you look at the demo at http://davecoates.github.io/angular-ui-sortable/ you can do that - just click and drag anywhere except the drag icon (the 4 pointed arrow). It requires using the selectable jquery-ui plugin in addition to sortable.
Thanks Dave.
This is pretty much what I was looking for.
I am stuck implementing certain aspects of it. I don't see it in the documentation. If you have get a chance, kindly assist.
http://stackoverflow.com/questions/21631205/working-around-angular-ui-sortable
regards,
In some cases the scope() function might not work, eespecially if you create a directive with isolated scope, for this use the $('selector').isolateScope() function.
Nice work! If you had a functionality that removes something from the DOM let's say the third element (using remove() or if it was in a contenteditable div and we used backspace), do you think the code could keep the order of the internal divs and update the collection ok? Thanks
More specifically: http://stackoverflow.com/questions/26373322/how-to-do-angularjs-2-way-binding-of-multiple-objects-in-contenteditable-includi
scope() is disabled in production when debug data is turned off.