How to use [(ngModel)] on div's contenteditable in angular2?

56,870

Solution 1

NgModel expects the bound element to have a value property, which divs don't have. That's why you get the No value accessor error.

You can set up your own equivalent property and event databinding using the textContent property (instead of value) and the input event:

import { Component } from "angular2/core";

@Component({
    selector: "my-app",
    template: `{{ title }}
        <div contenteditable="true" [textContent]="model" (input)="model = $event.target.textContent"></div>
        <p>{{ model }}</p>`
})
export class AppComponent {
    title = "Angular 2 RC.4";
    model = "some text";
    constructor() {
        console.clear();
    }
}

Plunker

I don't know if the input event is supported on all browsers for contenteditable. You could always bind to some keyboard event instead.

Solution 2

Updated answer (2017-10-09):

Now I have ng-contenteditable module. Its compatibility with Angular forms.

Old answer (2017-05-11): In my case, I can simple to do:

<div
  contenteditable="true"
  (input)="post.postTitle = $event.target.innerText"
  >{{ postTitle }}</div>

Where post - it's object with property postTitle.

First time, after ngOnInit() and get post from backend, I set this.postTitle = post.postTitle in my component.

Solution 3

Working Plunkr here http://plnkr.co/edit/j9fDFc, but relevant code below.


Binding to and manually updating textContent wasn't working for me, it doesn't handle line breaks (in Chrome, typing after a line break jumps cursor back to the beginning) but I was able to get it work using a contenteditable model directive from https://www.namekdev.net/2016/01/two-way-binding-to-contenteditable-element-in-angular-2/.

I tweaked it to handle multi-line plain text (with \ns, not <br>s) by using white-space: pre-wrap, and updated it to use keyup instead of blur. Note that some solutions to this problem use the input event which isn't supported on IE or Edge on contenteditable elements yet.

Here's the code:

Directive:

import {Directive, ElementRef, Input, Output, EventEmitter, SimpleChanges} from 'angular2/core';

@Directive({
  selector: '[contenteditableModel]',
  host: {
    '(keyup)': 'onKeyup()'
  }
})
export class ContenteditableModel {
  @Input('contenteditableModel') model: string;
  @Output('contenteditableModelChange') update = new EventEmitter();

  /**
   * By updating this property on keyup, and checking against it during
   * ngOnChanges, we can rule out change events fired by our own onKeyup.
   * Ideally we would not have to check against the whole string on every
   * change, could possibly store a flag during onKeyup and test against that
   * flag in ngOnChanges, but implementation details of Angular change detection
   * cycle might make this not work in some edge cases?
   */
  private lastViewModel: string;

  constructor(private elRef: ElementRef) {
  }

  ngOnChanges(changes: SimpleChanges) {
    if (changes['model'] && changes['model'].currentValue !== this.lastViewModel) {
      this.lastViewModel = this.model;
      this.refreshView();
    }
  }

  /** This should probably be debounced. */
  onKeyup() {
    var value = this.elRef.nativeElement.innerText;
    this.lastViewModel = value;
    this.update.emit(value);
  }

  private refreshView() {
    this.elRef.nativeElement.innerText = this.model
  }
}

Usage:

import {Component} from 'angular2/core'
import {ContenteditableModel} from './contenteditable-model'

@Component({
  selector: 'my-app',
  providers: [],
  directives: [ContenteditableModel],
  styles: [
    `div {
      white-space: pre-wrap;

      /* just for looks: */
      border: 1px solid coral;
      width: 200px;
      min-height: 100px;
      margin-bottom: 20px;
    }`
  ],
  template: `
    <b>contenteditable:</b>
    <div contenteditable="true" [(contenteditableModel)]="text"></div>

    <b>Output:</b>
    <div>{{text}}</div>

    <b>Input:</b><br>
    <button (click)="text='Success!'">Set model to "Success!"</button>
  `
})
export class App {
  text: string;

  constructor() {
    this.text = "This works\nwith multiple\n\nlines"
  }
}

Only tested in Chrome and FF on Linux so far.

Solution 4

Here's another version, based on @tobek's answer, which also supports html and pasting:

import {
  Directive, ElementRef, Input, Output, EventEmitter, SimpleChanges, OnChanges,
  HostListener, Sanitizer, SecurityContext
} from '@angular/core';

@Directive({
  selector: '[contenteditableModel]'
})
export class ContenteditableDirective implements OnChanges {
  /** Model */
  @Input() contenteditableModel: string;
  @Output() contenteditableModelChange?= new EventEmitter();
  /** Allow (sanitized) html */
  @Input() contenteditableHtml?: boolean = false;

  constructor(
    private elRef: ElementRef,
    private sanitizer: Sanitizer
  ) { }

  ngOnChanges(changes: SimpleChanges) {
    if (changes['contenteditableModel']) {
      // On init: if contenteditableModel is empty, read from DOM in case the element has content
      if (changes['contenteditableModel'].isFirstChange() && !this.contenteditableModel) {
        this.onInput(true);
      }
      this.refreshView();
    }
  }

  @HostListener('input') // input event would be sufficient, but isn't supported by IE
  @HostListener('blur')  // additional fallback
  @HostListener('keyup') onInput(trim = false) {
    let value = this.elRef.nativeElement[this.getProperty()];
    if (trim) {
      value = value.replace(/^[\n\s]+/, '');
      value = value.replace(/[\n\s]+$/, '');
    }
    this.contenteditableModelChange.emit(value);
  }

  @HostListener('paste') onPaste() {
    this.onInput();
    if (!this.contenteditableHtml) {
      // For text-only contenteditable, remove pasted HTML.
      // 1 tick wait is required for DOM update
      setTimeout(() => {
        if (this.elRef.nativeElement.innerHTML !== this.elRef.nativeElement.innerText) {
          this.elRef.nativeElement.innerHTML = this.elRef.nativeElement.innerText;
        }
      });
    }
  }

  private refreshView() {
    const newContent = this.sanitize(this.contenteditableModel);
    // Only refresh if content changed to avoid cursor loss
    // (as ngOnChanges can be triggered an additional time by onInput())
    if (newContent !== this.elRef.nativeElement[this.getProperty()]) {
      this.elRef.nativeElement[this.getProperty()] = newContent;
    }
  }

  private getProperty(): string {
    return this.contenteditableHtml ? 'innerHTML' : 'innerText';
  }

  private sanitize(content: string): string {
    return this.contenteditableHtml ? this.sanitizer.sanitize(SecurityContext.HTML, content) : content;
  }
}

Solution 5

I've fiddled around with this solutions and will use the following solution in my project now:

<div #topicTitle contenteditable="true" [textContent]="model" (input)="model=topicTitle.innerText"></div>

I prefer using the template reference variable to the "$event" stuff.

Related link: https://angular.io/guide/user-input#get-user-input-from-a-template-reference-variable

Share:
56,870
Kim Wong
Author by

Kim Wong

I can do this all day.

Updated on July 08, 2022

Comments

  • Kim Wong
    Kim Wong almost 2 years

    I am trying to use ngModel to two way bind div's contenteditable input content as follows:

    <div id="replyiput" class="btn-input"  [(ngModel)]="replyContent"  contenteditable="true" data-text="type..." style="outline: none;"    ></div> 
    

    but it is not working and an error occurs:

    EXCEPTION: No value accessor for '' in [ddd in PostContent@64:141]
    app.bundle.js:33898 ORIGINAL EXCEPTION: No value accessor for ''
    
  • Kim Wong
    Kim Wong about 8 years
    Thank you for your answer. But it is not a two way binding. When the user type something in the input, the "model" var will not change.
  • Mark Rajcok
    Mark Rajcok about 8 years
    @KimWong, the model var is definitely changing in the Plunker I provided. That's why I put {{model}} in the view/template, so that we can see it change when we edit the div.
  • Meir
    Meir almost 8 years
    Thx! This helps. For some reason angular2rc1 does not like textContent, using innerText instead works fine.
  • Zach Botterman
    Zach Botterman almost 8 years
    Big help thank you! Any way to easily support multiline and resist the text from being generated at the beginning of the line i.e. backwards?
  • Mark Rajcok
    Mark Rajcok almost 8 years
    @ZachBotterman, if I add styles: ['div { white-space: pre}' ] to the component metadata it sort of works. I have to type shift-enter for the newline to not be replaced. Tested on Chrome only.
  • Zach Botterman
    Zach Botterman almost 8 years
    @MarkRajcok Thanks for the reply. Will check asap
  • tobek
    tobek over 7 years
    I ran into the same issue, got it working in a slightly different way answered below.
  • Lys
    Lys about 7 years
    Regardless of the event used to trigger model=$event.target.textContent, this currently doesn't work properly on Firefox and Edge. The cursor is always set at index 0 when typing. You should be aware of this.
  • Cel
    Cel about 7 years
    Tested on Firefox with Windows as well, under Ionic 2, and your code works there as well. Thanks!
  • user911
    user911 almost 7 years
    Thank you for your answer. I have a little more complex use case. I also have an attribute level directive in the same div and I need to have 2 way binding for "model". So that my directive will get the updated model and if I change the model from directive, it's reflected in UI. How do I do that?
  • Chris Tarasovs
    Chris Tarasovs almost 7 years
    guys, anyone know how to sort out so the cursor index to not be set at 0 all the time?
  • Atiris
    Atiris over 6 years
    Thanks, but to avoid ExpressionChangedAfterItHasBeenCheckedError please use asynchronous EventEmitter in output @Output() contenteditableModelChange?= new EventEmitter(true); reference to article. Maybe you can update your code.
  • Ziggler
    Ziggler over 6 years
    This doesnot work in IE. Just open the plunker in IE.
  • szaman
    szaman about 6 years
    currently this is only useful for typing backwards
  • p0enkie
    p0enkie about 6 years
    I used this solution on an editable TD as well. {{model}} as suggested by some other solutions gave me issues while typing. It would dynamically update the text and I would get some garbled gibberish, with this solution that didn't happen
  • Always_a_learner
    Always_a_learner almost 6 years
    @MarkRajcok Great solution. But your plunker do not work fine in firefox. It types backward in firefox. I have added post for this: stackoverflow.com/questions/51206076/… Need your help.
  • illnr
    illnr over 5 years
    I like the solution, but it also types backwards in firefox.
  • Muhammad bin Yusrat
    Muhammad bin Yusrat almost 4 years
    This wouldn't work if you allow the user to edit their response after they have moved on to another screen (preset values).