Search code examples
javascriptangularjscontenteditablemutation-observers2-way-object-databinding

how to do AngularJS 2-way binding of multiple objects in contenteditable including surrounding text


I want to have a collection like:

var array = [{innerText: "I was with ", index:0},{obj: "Mary", id: 1, index:1}, {innerText: " and ", index:2},{obj: "John", id: 2, index:3}]; 

and a content editable div that will have all those up there but bound to the the array so that when I change either the innerText or the inputs that represent the objects the array will be updated accordingly.

For example the div would look something like that without the angularJS stuff:

<div contenteditable="true">
I was with <input type="text" value="Mary" data-index="1"/> and <input type="text" value="John" data-index="3"/>
</div>  

This should work with backspace in the div and also new inputs to be inserted or text to be typed, updating the array accordingly.

I know that possibly I have to use Mutation Observers but I don't know how in this complicated example. I hoped for AngularJs to have some more automated integration with mutation observers :/

My primitive approach was: I made a directive for the whole collection, a directive for the innerText and a directive for the objects. The binding of the inputs with the object names works of course but not when the internal DOM of contenteditable is mutated. Also having {{innerText}} as a template for innerText and using it in contenteditable wasn't guaranteethat someone will actually type in it so the binding will work (and not before or after it)

Edit: If it makes it easier a collection like that with the same contenteditable is still very useful

var array = [{obj: "Mary", id: 1, index:1}, {obj: "John", id: 2, index:3}, {innerText: "I was with @ and @"]; 

Edit2: Reopened the question. The previously accepted answer approach was really good but today I realized it is not real 2-way binding. It is actually 1-way binding. Going from the view to the model. The bounty will be awarded if an updated version of the provided code (from the previously accepted answer) is used to get a model like

modelValue": [
    {
      "innerText": "abc",
      "index": 0
    },
    {
      "obj": "abc",
      "index": 1
    },
    {
      "innerText": "abc",
      "index": 2
    }
  ]

and this will make the view:

"viewValue": "\n abc\n <input type=\"text\">\n abc\n "

The solution will have to provide code for a service that will return a static model like the one above when a new button is pressed and a function in the controller that will put the modelValue in the scope and the model will be converted to the above viewValue.

Edit3: Based on the updated answer below here is how real 2-way binding works without suggested $watch by using compile pre-link and post-link:

// Code goes here

var myApp = angular.module('myApp', []);
myApp.controller('test', ['$scope',
  function($scope) {
    $scope.addInput = function() {
      //Put in a directive if using for real
      var input = document.createElement('input');
      input.type = "text";
      $(input).attr("data-label","obj");
      $(input).attr("data-name","");
      $(input).attr("data-id","randomId");

      document.querySelector("div[contenteditable]").appendChild(input);
      input.focus();
    }

  }
]);

myApp.directive('contenteditable', ['$compile', function($compile) {
  return {
        require: 'ngModel',
        controller: [
            '$scope',
            function($scope) {

                 // Load initial value.

                $scope.getViewValue = function() {
                    var tempDiv = document.createElement("div");
                    angular.forEach($scope.model.modelValue, 
                        function(obj, index) {
                            if (obj.innerText) {
                                var newTextNode = document.createTextNode(" "+obj.innerText+" ");
                                tempDiv.appendChild(newTextNode);
                            } else if (obj.name) {
                                var newInput = document.createElement('input');
                                newInput.setAttribute('data-id',obj.id);
                                newInput.setAttribute('data-label', obj.label);
                                newInput.setAttribute('autosize', 'autosize');
                                newInput.setAttribute('data-name', obj.name);
                                newInput.setAttribute('value', obj.nickname);
                                newInput.setAttribute('type','text');
                                $(newInput).addClass('element-'+obj.label);
                                tempDiv.appendChild(newInput);
                            }
                        }
                    );
                    return tempDiv.innerHTML;
                };

                $scope.model = { "viewValue": "", "modelValue": [{"nickname":"Abc","index":0,"id":"2","label":"obj","name":"Abc"},{"innerText":"does something with","index":1},{"nickname":"bcd","index":3,"id":"0","label":"obj","name":"bcd"}] };

                $scope.model.viewValue = $scope.getViewValue();

        }],

        compile: function(elm, attrs){
 
             return {
                 pre: function(scope, elm, attrs, ctrl, transcludeFn){
                     
                    elm.html(scope.model.viewValue);
                    ctrl.$setViewValue(elm.html());

                    console.log(elm);
                    angular.forEach(elm[0].childNodes, function (node, index) {
                        if (node.nodeName === "INPUT") {
                           
                                $compile(node)(scope);
                            
                            

                        }
                    });
                    


                    //click all of them to make them autosize
                    $('div.editable input').click();

                 },
                 post: function(scope, elm, attrs, ctrl) {
                   

                    //prevent enter from being pressed
                    elm.bind('keydown',function(evt){
                        if (evt.keyCode == 13) {
                            evt.preventDefault();
                            return false;
                        }
                    });




                    //click all of them to make them autosize
                    $('div.editable input').click();


                    //Change listeners
                    elm.bind('blur keyup paste input click', function() {

                            var new$viewValue = {
                                viewValue: elm.html(),
                                modelValue: []
                            }
                            var index = 0;
                            angular.forEach(elm[0].childNodes, function(value, index) {
                                if (value.nodeName === "INPUT") {
                                    if (value.value) {

                                        var obj = {
                                            nickname: value.value,
                                            index: index,
                                            id: $(value).attr("data-id"),
                                            label: $(value).attr("data-label"),
                                            name: $(value).attr("data-name")
                                        };


                                        new$viewValue.modelValue.push(obj);

                                        //if type is entity


                                    } else {
                                        value.parentNode.removeChild(value);
                                    }
                                } else if (value.nodeName === "#text") {

                                    var last = null;
                                    if(new$viewValue.modelValue.length > 0){
                                        var last = new$viewValue.modelValue[new$viewValue.modelValue.length-1];
                                    }


                                    //if last was innerText (update it)
                                    if (last!=null && last.innerText){
                                        last.innerText += value.textContent.trim()
                                    }


                                    //else push it
                                    else {
                                        new$viewValue.modelValue.push({
                                            innerText: value.textContent.trim(),
                                            index: index
                                        });
                                    }
                                }
                                index++;
                            });
                            ctrl.$setViewValue(new$viewValue);
console.log(JSON.stringify(scope.model.modelValue));
                         
                    });

                }
             }
         },

    };
}]);
div > div > div {
  background-color: grey;
  min-width: 100px;
  min-height: 10px;
}
<script src="//ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.2.23/angular.min.js"></script>
<div ng-app="myApp">
  <div ng-controller="test">
    <button ng-click="addInput()">Add Input</button>
    <div contenteditable="true" ng-model="model">

    </div>
    See Console</div>
</div>


Solution

  • Here is a way to do it using a custom directive. I could not match your data model exactly, but this should be able to do what you want.

    This is what the object model looks like:

    {
      "viewValue": "\n abc\n <input type=\"text\">\n abc\n ",
      "modelValue": [
        {
          "innerText": "abc",
          "index": 0
        },
        {
          "obj": "abc",
          "index": 1
        },
        {
          "innerText": "abc",
          "index": 2
        }
      ]
    }
    

    viewValue is the html that makes up the contenteditable and what you described is in modelValue.

    Here we set a bunch of event listeners (inspired by this question) and construct the model.

    elm.bind('blur keyup paste input', function() {
        scope.$apply(function() {
            var new$viewValue = {
                viewValue: elm.html(),
                modelValue: []
            }
            var index = 0;
            angular.forEach(elm[0].childNodes, function(value, index) {
                if (value.nodeName === "INPUT") {
                    if (value.value) {
                        new$viewValue.modelValue.push({
                            obj: value.value,
                            index: index
                        });
                    } else {
                        value.parentNode.removeChild(value);
                    }
                } else if (value.nodeName === "#text") {
                    new$viewValue.modelValue.push({
                        innerText: value.textContent.trim(),
                        index: index
                    });
                }
                index++;
            });
            ctrl.$setViewValue(new$viewValue);
        });
    });
    

    What this does is get all the childNodes of the contenteditable div and checks to see if they are of type input or text and adds the appropriate values to the model. We also store the html state of the div to allow us to redraw the view.

    The render function is called to draw the view, and we set the view's html to the html that we stored in the model.

    ctrl.$render = function() {
        elm.html(ctrl.$viewValue.viewValue);
        //Untested code that should add the text back into the fields if the model already exists
        angular.forEach(elm[0].childNodes, function (value, index) {
            if (value.nodeName === "INPUT") {
                if (ctrl.$viewValue.modelValue[index].obj) {
                     value.value = ctrl.$viewValue.modelValue[index].obj
                }
                else {
                     value.parentNode.removeChild(value);
                }
            }
        });
    };
    

    EDIT: Here is a way of having two way data-binding:

    scope.getViewValue = function() {
        var tempDiv = document.createElement("div");
        angular.forEach(ctrl.$viewValue.modelValue, function(value, index) {
          if (value.innerText) {
            var newTextNode = document.createTextNode(value.innerText);
            tempDiv.appendChild(newTextNode);
          } else if (value.obj) {
            var newInput = document.createElement('input');
            newInput.type = "text";
            newInput.value = value.obj;
            tempDiv.appendChild(newInput);
          }
        });
        return tempDiv.innerHTML;
    };
    
    
    scope.$watch(function() { return ctrl.$modelValue; }, function(newVal, oldVal) {
        var newViewValue = scope.getViewValue();
        ctrl.$setViewValue({
          "viewValue": newViewValue,
          "modelValue": ctrl.$viewValue.modelValue
        });
       ctrl.$render();
    }, true);
    

    What this does is set a watcher on the object that is referenced by ng-model and whenever it changes, it recomputes the innerHTML of the view. It has a bug where focus is lost when the field is redrawn. Storing the element that has focus and restoring it upon redraw should fix this.

    For the rest of the code, and to see it in action, view the snippet below. I have added a button that adds additional text fields to show that this supports adding more inputs.

    // Code goes here
    
    var myApp = angular.module('myApp', []);
    myApp.controller('test', ['$scope',
      function($scope) {
        $scope.addInput = function() {
          //Put in a directive if using for real
          var input = document.createElement('input');
          input.type = "text";
          document.querySelector("div[contenteditable]").appendChild(input);
        }
    
        $scope.test = {
          "viewValue": "",
          "modelValue": [{
            "innerText": "abc",
            "index": 0
          }, {
            "obj": "abc",
            "index": 1
          }, {
            "innerText": "abc",
            "index": 2
          }]
        };
      }
    ]);
    
    myApp.directive('contenteditable', function() {
      return {
        require: 'ngModel',
        link: function(scope, elm, attrs, ctrl) {
    
          //Change listeners
          elm.bind('blur keyup paste input', function() {
            scope.$apply(function() {
              var new$viewValue = {
                viewValue: elm.html(),
                modelValue: []
              };
              var index = 0;
              angular.forEach(elm[0].childNodes, function(value, index) {
                if (value.nodeName === "INPUT") {
                  if (value.value) {
                    new$viewValue.modelValue.push({
                      obj: value.value,
                      index: index
                    });
                  } else {
                    value.parentNode.removeChild(value);
                  }
                } else if (value.nodeName === "#text") {
                  new$viewValue.modelValue.push({
                    innerText: value.textContent.trim(),
                    index: index
                  });
                }
                index++;
              });
              ctrl.$setViewValue(new$viewValue);
            });
          });
    
          // Draw the field
          ctrl.$render = function() {
            elm.html(ctrl.$viewValue.viewValue);
            //Untested code that should add the text back into the fields if the model already exists
            angular.forEach(elm[0].childNodes, function(value, index) {
              if (value.nodeName === "INPUT") {
                if (ctrl.$viewValue.modelValue[index].obj) {
                  value.value = ctrl.$viewValue.modelValue[index].obj;
                } else {
                  value.parentNode.removeChild(value);
                }
              }
            });
          };
    
          // Load initial value.
    
          scope.getViewValue = function() {
            var tempDiv = document.createElement("div");
            angular.forEach(ctrl.$viewValue.modelValue, function(value, index) {
              if (value.innerText) {
                var newTextNode = document.createTextNode(value.innerText);
                tempDiv.appendChild(newTextNode);
              } else if (value.obj) {
                var newInput = document.createElement('input');
                newInput.type = "text";
                newInput.value = value.obj;
                tempDiv.appendChild(newInput);
              }
            });
            return tempDiv.innerHTML;
          };
    
    
          scope.$watch(function() { return ctrl.$modelValue; }, function(newVal, oldVal) {
            var newViewValue = scope.getViewValue();
            ctrl.$setViewValue({
              "viewValue": newViewValue,
              "modelValue": ctrl.$viewValue.modelValue
            });
           ctrl.$render();
          }, true);
        }
      };
    });
    div > div > div {
      background-color: grey;
      min-width: 100px;
      min-height: 10px;
    }
    <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.2.23/angular.min.js"></script>
    <div ng-app="myApp">
      <div ng-controller="test">
        <button ng-click="addInput()">Add Input</button>
        <div contenteditable="true" ng-model="test">
    
        </div>
        {{test}}</div>
    </div>