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.
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.
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:
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 {}
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
This is key. Note the accent on the:
[modules]=editorConfig
- this adds the dynamic config on the instance, which makes the custom module registration dynamic<div class="counter" [id]="counterId"></div>
- allowing the id of the counter to be dynamic and sent to the Counter.ts custom modulequill.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>
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.