как сделать AngularJS двухстороннюю привязку нескольких объектов в contenteditable, включая окружающий текст

Я хочу иметь коллекцию вроде:

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

и div с редактируемым содержимым, в котором все они будут наверху, но привязаны к массиву, так что, когда я изменю либо innerText, либо входные данные, представляющие объекты, массив будет соответствующим образом обновлен.

Например, div будет выглядеть примерно так без материала angularJS:

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

Это должно работать с обратным пространством в div, а также с новыми вводами, которые нужно вставить, или текстом, который нужно ввести, соответствующим образом обновляя массив.

Я знаю, что, возможно, мне придется использовать наблюдателей мутаций, но я не знаю, как это сделать в этом сложном примере. Я надеялся, что AngularJs получит более автоматизированную интеграцию с наблюдателями мутаций: /

Мой примитивный подход заключался в следующем: я сделал директиву для всей коллекции, директиву для innerText и директиву для объектов. Связывание входных данных с именами объектов, конечно, работает, но не тогда, когда внутренняя DOM contenteditable изменена. Кроме того, наличие {{innerText}} в качестве шаблона для innerText и использование его в contenteditable не гарантировало, что кто-то действительно введет его, поэтому привязка будет работать (а не до или после нее)

Изменить: если это упрощает, подобная коллекция с таким же контентом по-прежнему очень полезна.

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

Edit2: повторно открыл вопрос. Ранее принятый подход к ответам был действительно хорош, но сегодня я понял, что это не настоящая двусторонняя привязка. На самом деле это односторонняя привязка. Переход от взгляда к модели. Награда будет присуждена, если обновленная версия предоставленного кода (из ранее принятого ответа) используется для получения такой модели, как

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

и это сделает вид:

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

Решение должно будет предоставить код для службы, которая будет возвращать статическую модель, подобную приведенной выше, при нажатии новой кнопки, и функцию в контроллере, которая поместит modelValue в область видимости, и модель будет преобразована в указанное выше viewValue. .

Edit3: на основе обновленного ответа ниже показано, как реальная двусторонняя привязка работает без предложенных $ watch с использованием предварительной и последующей ссылки компиляции:

// 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>


person Michail Michailidis    schedule 15.10.2014    source источник
comment
см. ngShow ..........   -  person Noypi Gilas    schedule 15.10.2014
comment
Я не понимаю, почему ngShow имеет значение ... как он уничтожит мой объект, когда я удалю ввод из DOM, в котором есть ngShow?   -  person Michail Michailidis    schedule 15.10.2014
comment
Я пытался сделать это некоторое время назад (без особого успеха), но вы должны прочитать документы . angularjs.org/api/ng/type/ngModel.NgModelController   -  person Constantinos    schedule 17.10.2014
comment
Также см. stackoverflow.com/questions/15108602/ и stackoverflow.com/questions/14561676/   -  person Constantinos    schedule 17.10.2014
comment
Спасибо Константин. Я считаю, что оба этих примера не смешивают множественные привязки текста и объектов. Я могу воспроизвести те, что указаны в этих ссылках, но не в моем случае. За исключением случаев, когда я чего-то упускаю, дайте мне пример, как эта информация может быть применима в моем случае.   -  person Michail Michailidis    schedule 17.10.2014
comment
Нет, как я уже сказал, я сам не смог заставить это работать, но, надеюсь, кто-то там разберется ... удачи :)   -  person Constantinos    schedule 17.10.2014
comment
Спасибо! У NgModelController есть функция синтаксического анализа, но все придется делать вручную, верно?   -  person Michail Michailidis    schedule 17.10.2014


Ответы (1)


Вот способ сделать это с помощью настраиваемой директивы. Я не смог точно сопоставить вашу модель данных, но она должна делать то, что вы хотите.

Вот как выглядит объектная модель:

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

viewValue - это HTML, из которого состоит contenteditable, а то, что вы описали, находится в modelValue.

Здесь мы устанавливаем группу прослушивателей событий (вдохновленных этим вопросом) и строим модель.

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);
    });
});

Он получает все childNodes из contenteditable div и проверяет, относятся ли они к типу input или text, и добавляет соответствующие значения в модель. Мы также сохраняем html-состояние div, чтобы мы могли перерисовать представление.

Функция рендеринга вызывается для рисования представления, и мы устанавливаем html представления в html, который мы сохранили в модели.

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);
            }
        }
    });
};

РЕДАКТИРОВАТЬ: Вот способ двусторонней привязки данных:

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);

Это устанавливает наблюдателя для объекта, на который ссылается ng-model, и всякий раз, когда он изменяется, он повторно вычисляет innerHTML представления. В нем есть ошибка, из-за которой теряется фокус при перерисовке поля. Это должно исправить сохранение элемента, у которого есть фокус, и его восстановление при перерисовке.

Чтобы увидеть остальную часть кода и увидеть его в действии, просмотрите фрагмент ниже. Я добавил кнопку, которая добавляет дополнительные текстовые поля, чтобы показать, что это поддерживает добавление дополнительных входов.

// 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>

person quw    schedule 17.10.2014
comment
вау ты классный! еще одно дополнение, если бы вы могли сделать, прежде чем я его приму! Не могли бы вы изменить его так, чтобы ввод добавлялся в позиции курсора? - person Michail Michailidis; 17.10.2014
comment
Конечно, я попробую это добавить. Дай мне пару минут. - person quw; 17.10.2014
comment
Также существует ли способ, когда вход содержит что-то и становится пустым, соответствующий объект в коллекции должен быть удален (возможно с помощью prompt ())? - person Michail Michailidis; 17.10.2014
comment
Просто проверьте, является ли значение текстового поля нулевым, прежде чем нажимать его. Модель полностью переделывается после каждого нажатия клавиши, поэтому, если вы не нажмете ее снова, она не появится. Я могу отредактировать свой ответ, чтобы показать его, если хотите. - person quw; 17.10.2014
comment
Да, это было бы здорово :) Так что, по сути, коллекция перестраивается при каждом нажатии клавиши, верно? Вход тоже можно убрать? Другими словами, вход будет вставлен, и там будет объект, только если есть значение ... и если вы введете что-то во вход, а затем сделаете его пустым, вход должен исчезнуть (как это было бы с backspace). Извините за непонятный ответ! - person Michail Michailidis; 17.10.2014
comment
Хорошо, теперь он удаляет объект из modelValue, если текстовое поле пусто. Ты прав. Коллекция перестраивается каждый раз при срабатывании прослушивателя событий. - person quw; 17.10.2014
comment
Спасибо :) В моем предыдущем комментарии я имел в виду также текстовое поле, которое нужно удалить, если оно станет пустым (за исключением того, что изначально оно добавлено). Но я могу это сделать. Я сделал вставку позиции курсора, но только в текстовых областях, а не в contentEditable. Btw Кажется, что двухсторонние привязки AngularJS не так полезны для более сложных случаев - person Michail Michailidis; 17.10.2014
comment
Я добавил функцию, которая удаляет текстовое поле. Чтобы узнать позицию курсора, посмотрите stackoverflow.com/a/4770562/2506493. Как только вы определите позицию курсора, вы нужно будет найти, в каком узле он находится, а затем либо добавить текстовое поле после узла, либо, если это текстовый узел, разделить текстовый узел на 2 и вставить текстовое поле. Если у вас возникли проблемы с этим, задайте другой вопрос. - person quw; 17.10.2014
comment
Большое вам спасибо :) Награда будет вашей примерно через 22 часа;) - person Michail Michailidis; 17.10.2014
comment
Для вставки элемента в редактируемый контент в позиции курсора я использовал код из этого ответа: stackoverflow.com/questions/2937975/ - person Michail Michailidis; 27.10.2014
comment
Теперь я понял, что этот код не обеспечивает двустороннюю привязку, потому что он обновляет модель на основе представления, но не наоборот. Теперь, когда у меня есть данные из моей базы данных, поступающие как json, нет возможности передать их и обновить представление соответствующим образом. Я поставил 200 наград за эту часть решения. - person Michail Michailidis; 08.11.2014
comment
@MichailMichailidis, я добавил способ двусторонней привязки данных. Поле теперь изначально извлекается из некоторого JSON, и изменение этого JSON должно обновить представление. Когда пользователь набирает слишком быстро (теряются фокус и позиция курсора), возникает небольшой сбой, но, надеюсь, это поможет вам начать работу. - person quw; 08.11.2014
comment
Спасибо! Могу ли я обойтись без часов $ - или, другими словами, сделать это только вначале, а затем получить код в том виде, в каком он был у вас? Думаю, это тоже исправит глюк? Думаю, теперь он дважды повторно применяет изменения к представлению? - person Michail Michailidis; 08.11.2014
comment
Вам понадобится $watch, потому что модель не будет полностью готова к моменту создания директивы. Вы можете отвязать $watch после первого успешного запуска. См. this, чтобы узнать, как это сделать. - person quw; 08.11.2014
comment
значит, вы имеете в виду, что модель, полученная из базы данных, не имеет viewValue, поэтому она не готова? - person Michail Michailidis; 08.11.2014
comment
Нет, значение переменной не было привязано к модели директивы. Пройдите через это в отладчике и посмотрите на значение ctrl, чтобы узнать, когда происходит фактическое вложение. - person quw; 08.11.2014
comment
Итак, нет возможности вызвать getViewValue () на этапе компиляции или компоновки директивы один раз без наблюдения? - person Michail Michailidis; 08.11.2014
comment
Проверьте мое последнее редактирование - мне удалось сделать это без уродливых $ watch, скомпилировав предварительную ссылку и пост-ссылку - мне также пришлось использовать $ compile для каждого из существующих входов для директивы autosize (не входит в демонстрацию) - person Michail Michailidis; 08.11.2014