1

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>
Michail Michailidis
  • 11,792
  • 6
  • 63
  • 106
  • see ngShow.......... – sss Oct 15 '14 at 09:06
  • I don't get why ngShow is relevant.. how will it destroy my object when I remove the input from the DOM which has ngShow? – Michail Michailidis Oct 15 '14 at 11:23
  • 1
    I tried doing this a while back (without much success) but you should read https://docs.angularjs.org/api/ng/type/ngModel.NgModelController – Constantinos Oct 17 '14 at 14:41
  • Also see http://stackoverflow.com/questions/15108602/two-way-binding-of-contenteditable-item-inside-ng-list and http://stackoverflow.com/questions/14561676/angularjs-and-contenteditable-two-way-binding-doesnt-work-as-expected – Constantinos Oct 17 '14 at 14:42
  • Thanks Constantine. I believe both of those examples don't mix multiple text and object bindings. I am able to replicate those in these links but not my case. Except if I am missing something then please give me an example how this information would be applicable to my case. – Michail Michailidis Oct 17 '14 at 14:47
  • No, like I said I couldn't get it working myself, but hopefully someone out there will figure it out... good luck :) – Constantinos Oct 17 '14 at 15:07
  • Thanks! The NgModelController has parsing functionality but everything will have to be done manually right? – Michail Michailidis Oct 17 '14 at 15:08

1 Answers1

4

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>
Community
  • 1
  • 1
quw
  • 2,844
  • 1
  • 26
  • 35
  • wow you are awesome! one more addition if you could do before I accept it! Can you please change it so that the input is added in caret position? – Michail Michailidis Oct 17 '14 at 15:12
  • Sure, I'll try to add that. Give me a few minutes. – quw Oct 17 '14 at 15:12
  • Also is there a way when an input had something in it and becomes empty, the corresponding object in the collection to be removed (possible with a prompt() ) ? – Michail Michailidis Oct 17 '14 at 15:18
  • 1
    Just check if the value of the textbox is null before pushing it. The model is completely redone after each keystroke, so if you don't push it again it won't show up. I can edit my answer to show it if you like. – quw Oct 17 '14 at 15:20
  • Yeah that would be great :) So basically the collection is rebuild with each keystroke right? The input can be removed too? So in other words the input will be inserted and there will be an object there only if there is a value.. and if you type something in the input and then make it empty then the input has to disappear (as it would do with the backspace). Sorry for not being clear in the answer! – Michail Michailidis Oct 17 '14 at 15:22
  • Ok, it now removes the object from `modelValue` if the textbox is empty. You are correct. The collection is rebuilt each time that event listener fires. – quw Oct 17 '14 at 15:22
  • Thanks :).In my previous comment I meant also the textbox to be removed if it becomes empty (except initially that it is added). But this I can do. I have done caret position inserting but only in textareas not in contentEditable. Btw It seems that 2-way AngularJS bindings are not that useful to more complicated cases – Michail Michailidis Oct 17 '14 at 15:29
  • 1
    I've added the feature that removes the textbox. For the cursor position, take a look at http://stackoverflow.com/a/4770562/2506493 Once you get the cursor position, you'll have to find what node it is in, and then either append the textbox after the node, or if it is a textnode, split the textnode into 2 and insert the textbox. If you have trouble doing that, ask another question. – quw Oct 17 '14 at 15:38
  • 1
    Thank you so much :) The bounty will be yours in about 22hours ;) – Michail Michailidis Oct 17 '14 at 15:40
  • 1
    For the insertion of an element in the content editable at caret position I used the code from this answer: http://stackoverflow.com/questions/2937975/contenteditable-text-editor-and-cursor-position – Michail Michailidis Oct 26 '14 at 22:27
  • I now realized that this code is not providing 2-way binding because it updates the model based on the view but not vice versa. Now that I have the data from my database coming as json there is no way to pass it in and the view to be updated accordingly. I put 200 bounty for this part of the solution. – Michail Michailidis Nov 08 '14 at 02:46
  • 1
    @MichailMichailidis, I added a way to have two way data-binding. The field is now initially drawn from some JSON, and changing that JSON should update the view. It has a slight glitch when the user types too fast (the focus and cursor position are lost), but hopefully this can help you get started. – quw Nov 08 '14 at 03:55
  • Thanks! Can I get away without the $watch - or in other words have this happen only initially and then have the code the way you had it? I think this will also fix the glitch? I think now it reapplies the changes to the view twice? – Michail Michailidis Nov 08 '14 at 04:36
  • You'll need the `$watch` because the model is not fully ready by the time the directive is created. You could unbind the `$watch` after the first time it successfully runs. See [this](http://angular-tips.com/blog/2013/08/removing-the-unneeded-watches/) for a way to do that. – quw Nov 08 '14 at 04:38
  • so you mean that the model coming from the database doesn't have the viewValue that's why it is not ready? – Michail Michailidis Nov 08 '14 at 04:43
  • No, the value of the variable has not been attached to the directive's model. Step through it in a debugger and look at the value of `ctrl` to see when the actual attachment happens. – quw Nov 08 '14 at 04:43
  • So there is no way to have the getViewValue() be called in the compile or link phase of the directive once without watch? – Michail Michailidis Nov 08 '14 at 04:46
  • 1
    Check my last edit - I managed to do it without ugly $watch through compile pre-link and post-link - I also had to use $compile on each of existing inputs for autosize directive (not included in demo) – Michail Michailidis Nov 08 '14 at 15:57