angularjssurveyjsngx-quill

SurveyJs with QuillJS and custom module counter


I implemented SurveyJS Creator with QuillJS in Angular following the documentation, and everything was working fine. Then, I decided to add a custom module to the Rich Text to count the number of characters entered, as in the future I will need to set a character limit.

I managed to do this, but now I am encountering a problem. If I add more than one Rich Text field with Survey, the character counter remains fixed only on the first Rich Text and updates when I type in both text fields, as if it were linked to both. I need to find a way to associate a single counter to each Rich Text field individually to avoid this issue.

I'm adding the code below:

App.module.ts

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { SurveyModule } from 'survey-angular-ui';
import { CreatorComponent } from './creator/creator.component';
import { FormsModule } from '@angular/forms';
import { QuillModule } from 'ngx-quill';
import { QuillComponent } from './quill/quill.component';
import { SurveyCreatorModule } from 'survey-creator-angular';
import { NgIf } from '@angular/common';
import Counter from './quill/counter';

@NgModule({
  declarations: [AppComponent, QuillComponent, CreatorComponent],
  imports: [
    BrowserModule,
    SurveyModule,
    FormsModule,
    SurveyCreatorModule,
    SurveyModule,
    NgIf,

    QuillModule.forRoot({
      modules: {
        toolbar: [
          [{ header: [1, 2, 3, false] }],
          ['bold', 'italic', 'underline', 'link'],
          [{ list: 'ordered' }, { list: 'bullet' }],
          ['clean'],
        ],                                                                                                                                                    

        counter: {
          container: '.counter',
          unit: 'char',
        },
      },
      customModules: [
        {
          implementation: Counter,
          path: 'modules/counter',
        },
      ],
    }),
  ],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}

counter.ts

import 'quill';

export interface Config {
  container: string;
  unit: 'word' | 'char';
}

export interface QuillInstance {
  on: any;
  getText: any;
  getSemanticHTML: any
}

export default class Counter {
  quill: QuillInstance;
  options: Config;

  constructor(quill: QuillInstance, options: Config) {
    debugger
    this.quill = quill;
    this.options = options;

    const container = document.querySelector(this.options.container);

    if (!container) {
      console.error("Container not found: ${this.options.container}");
      return;
    }
    this.quill.on('text-change', () => {
      const length = this.calculate();
      container.innerHTML = length + ' ' + this.options.unit + 's';
    });
  }

  removeHtmlTags(str:string) {
    var tempDiv = document.createElement('div');
    tempDiv.innerHTML = str;
    return tempDiv.textContent || tempDiv.innerText || '';
}
  calculate() {
    const text = this.removeHtmlTags(this.quill.getText().trim());

    if (this.options.unit === 'word') {
      return !text ? 0 : text.split(/\s+/).length;
    }

    return text.replace(/\s/g, '').length;
  }
}


quill.component.ts

import { Component } from '@angular/core';
import { AngularComponentFactory, QuestionAngular } from 'survey-angular-ui';
import { ElementFactory, Question, Serializer } from 'survey-core';
import { PropertyGridEditorCollection } from 'survey-creator-core';

const CUSTOM_TYPE = 'quill';

// Create a question model
export class QuestionQuillModel extends Question {
  override getType() {
    return CUSTOM_TYPE;
  }
  public get height(): string {
    return this.getPropertyValue('height');
  }
  public set height(val: string) {
    this.setPropertyValue('height', val);
  }
  public override get width(): string {
    return this.getPropertyValue('width');
  }
  public override set width(val: string) {
    this.setPropertyValue('width', val);
  }
}

// Register the model in `ElementFactory`
ElementFactory.Instance.registerElement(CUSTOM_TYPE, (name) => {
  return new QuestionQuillModel(name);
});

// Add question type metadata for further serialization into JSON
Serializer.addClass(
  CUSTOM_TYPE,
  [
    { name: 'height', default: '200px', category: 'layout' },
    { name: 'width', default: '100%', category: 'layout' },
  ],

  function () {
    return new QuestionQuillModel('');
  },
  'question'
);

// Create a component that renders Quill
@Component({
  selector: 'quill',
  templateUrl: './quill.component.html',
  styleUrls: ['./quill.component.scss'],
})
export class QuillComponent extends QuestionAngular<QuestionQuillModel> {
  public get content() {
    return this.model.value;
  }
  public set content(val: string) {
    this.model.value = val;
  }
}
// Register the component in `AngularComponentFactory`
AngularComponentFactory.Instance.registerComponent(
  CUSTOM_TYPE + '-question',
  QuillComponent
);

// Register `quill` as an editor for properties of the `text` and `html` types in the Survey Creator's Property Grid
PropertyGridEditorCollection.register({
  fit: function (prop) {
    return prop.type == 'text' || prop.type == 'html';
  },
  getJSON: function () {
    return { type: 'quill' };
  },
});

quill.component.html

<div class="editor-counter">
  <quill-editor
    class="q-editor"
    [readOnly]="model.isReadOnly"
    [(ngModel)]="content"
    [styles]="{ height: model.height, width: model.width }"
  >
  </quill-editor>
  <div class="counter"></div>
</div>

I have already tried several solutions, such as associating an ID, but it didn't work.


Solution

  • On the SurveyJS side of things your code is absolutely fine. The main issue with the described behaviour appears to originate from the use of Global configuration by ngx-quill - more info here.

    While this keeps your code dry, it completely separates your Angular components from the custom Counter Quill module - the document.querySelector(this.options.container); selector always returns the first counter container from the DOM tree and hence the bug.

    Proposed Solution

    There is probably more than one way to solve this, but it all boils down to provide a reference to the Counter DOM element hydrated with the Angular context. My proposed way has the following steps:

    Step 1: Comment out the "modules" part of the Global configuration

    app.module.ts

    import { NgModule } from "@angular/core";
    import { BrowserModule } from "@angular/platform-browser";
    import { AppComponent } from "./app.component";
    import { SurveyModule } from "survey-angular-ui";
    import { SurveyCreatorComponent } from "./survey-creator/survey-creator.component";
    import { FormsModule } from "@angular/forms";
    import { QuillModule } from "ngx-quill";
    import { QuillComponent } from "./quill.component";
    import { SurveyCreatorModule } from "survey-creator-angular";
    import { NgIf } from "@angular/common";
    import Counter from "./counter";
    
    @NgModule({
      declarations: [AppComponent, QuillComponent, SurveyCreatorComponent],
      imports: [
        BrowserModule,
        SurveyModule,
        FormsModule,
        SurveyCreatorModule,
        SurveyModule,
        NgIf,
        QuillModule.forRoot({
          customModules: [
            {
              implementation: Counter,
              path: "modules/counter",
            },
          ],
        }),
      ],
      providers: [],
      bootstrap: [AppComponent],
    })
    export class AppModule {}
    

    Step 2: Add Per component Quill module configuration

    You can see this in the github.com/KillerCodeMonkey/ngx-quill-example/src/app/components/custom-toolbar/custom-toolbar.component.html

    quill.component.ts

    
    //... previous code cut out for clarity
    
    export class QuillComponent
      extends QuestionAngular<QuestionQuillModel>
      implements OnInit
    {
      counterId: string = ""; // this will provide the unique counter element Id for each instance of this component
      editorConfig: any = {
        toolbar: [
          [{ header: [1, 2, 3, false] }],
          ["bold", "italic", "underline", "link"],
          [{ list: "ordered" }, { list: "bullet" }],
          ["clean"],
        ]
      }; // instance config
    
      override ngOnInit(): void {
        // generate uniqueId and pass it to the counterId instance variable
        const uniqueId = `counter-${Math.random().toString(36).substring(2, 9)}`;
        this.counterId = uniqueId;
    
        // modify the Quill editor config and register the custom module using the unique counterId and pass it to the Counter custom module
        this.editorConfig.counter = { containerId: this.counterId, unit: "char" };
    
        super.ngOnInit(); // call base ngOnInit
      }
      
      //... rest of code goes here
    

    Step 3: Glue everything together in the template

    This is key. Note the accent on the:

    quill.component.html

    <div class="editor-counter">
      <quill-editor class="q-editor" [readOnly]="model.isReadOnly" [(ngModel)]="content"
        [styles]="{ height: model.height, width: model.width }" [modules]=editorConfig>
      </quill-editor>
      <div [id]="counterId"></div>
    </div>
    

    Step 4: Modify the Counter.ts's DOM selector

    This is small change to accommodate the new selector logic as it's Id now and not class. Technically, it can be class as well, but that's how I did it.

    counter.ts

    export interface Config {
      containerId: string; //modified this to be more honest. It's containerId now
      unit: 'word' | 'char';
    }
    
    //.... rest of code goes here
    
    const container = document.querySelector(`#${this.options.containerId}`);
    
    

    Please try this and let me know if it works or if you need more items.

    Further Improvements