From 49212c9cbf7b652b1057ec5ac9aebf98614db595 Mon Sep 17 00:00:00 2001 From: arturovt Date: Wed, 10 Apr 2024 18:15:00 +0300 Subject: [PATCH] feat: switch to signal inputs This commit switches inputs to signal inputs to be compatible with zoneless change detection because everything should be a signal primitive. As such, its changes may be caught by the execution context. Every time a signal changes, it would schedule a change detection event, even in zoneless mode. I didn't update `@Output()` to `output` because we still use `.observed` properties. --- package.json | 2 +- projects/ngx-quill/src/lib/helpers.ts | 12 ++ .../src/lib/quill-editor.component.spec.ts | 24 +-- .../src/lib/quill-editor.component.ts | 185 +++++++++--------- .../src/lib/quill-view-html.component.spec.ts | 2 +- .../src/lib/quill-view-html.component.ts | 38 ++-- .../ngx-quill/src/lib/quill-view.component.ts | 73 +++---- 7 files changed, 182 insertions(+), 154 deletions(-) diff --git a/package.json b/package.json index 969fc897..78da0734 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "url": "https://github.com/KillerCodeMonkey/ngx-quill" }, "engines": { - "node": "20.9.0" + "node": "^18.13.0 || >=20.9.0" }, "keywords": [ "editor", diff --git a/projects/ngx-quill/src/lib/helpers.ts b/projects/ngx-quill/src/lib/helpers.ts index 03482b69..3c0ab41b 100644 --- a/projects/ngx-quill/src/lib/helpers.ts +++ b/projects/ngx-quill/src/lib/helpers.ts @@ -1,6 +1,18 @@ import { QuillFormat } from 'ngx-quill/config' +import { Observable } from 'rxjs' export const getFormat = (format?: QuillFormat, configFormat?: QuillFormat): QuillFormat => { const passedFormat = format || configFormat return passedFormat || 'html' } + +export function raf$() { + return new Observable(subscriber => { + const rafId = requestAnimationFrame(() => { + subscriber.next() + subscriber.complete() + }) + + return () => cancelAnimationFrame(rafId) + }) +} diff --git a/projects/ngx-quill/src/lib/quill-editor.component.spec.ts b/projects/ngx-quill/src/lib/quill-editor.component.spec.ts index 9b73129e..b2e4816c 100644 --- a/projects/ngx-quill/src/lib/quill-editor.component.spec.ts +++ b/projects/ngx-quill/src/lib/quill-editor.component.spec.ts @@ -44,6 +44,7 @@ class CustomModule { [maxLength]="maxLength" [readOnly]="isReadOnly" [debounceTime]="debounceTime" + [trimOnValidation]="trimOnValidation" (onEditorCreated)="handleEditorCreated($event)" (onEditorChanged)="handleEditorChange($event)" (onContentChanged)="handleChange($event)" @@ -61,6 +62,7 @@ class TestComponent { blured = false focusedNative = false bluredNative = false + trimOnValidation = false maxLength = 0 style: { backgroundColor?: string @@ -763,7 +765,7 @@ describe('Advanced QuillEditorComponent', () => { fixture.detectChanges() await fixture.whenStable() - expect(editorCmp.readOnly).toBe(false) + expect(editorCmp.readOnly()).toBe(false) fixture.componentInstance.isReadOnly = true @@ -773,7 +775,7 @@ describe('Advanced QuillEditorComponent', () => { fixture.detectChanges() await fixture.whenStable() - expect(editorCmp.readOnly).toBe(true) + expect(editorCmp.readOnly()).toBe(true) expect(editorElem.nativeElement.querySelectorAll('div.ql-container.ql-disabled').length).toBe(1) expect(editorElem.nativeElement.querySelector('div[quill-editor-element]').style.height).toBe('30px') }) @@ -1077,7 +1079,7 @@ describe('Advanced QuillEditorComponent', () => { fixture.componentInstance.minLength = 8 fixture.detectChanges() await fixture.whenStable() - expect(editorComponent.minLength).toBe(8) + expect(editorComponent.minLength()).toBe(8) fixture.componentInstance.title = 'Hallo1' fixture.detectChanges() @@ -1097,7 +1099,7 @@ describe('Advanced QuillEditorComponent', () => { const editorElement = fixture.debugElement.children[0].nativeElement // set min length - editorComponent.minLength = 2 + fixture.componentInstance.minLength = 2 // change text editorComponent.quillEditor.setText('', 'user') @@ -1125,7 +1127,7 @@ describe('Advanced QuillEditorComponent', () => { await fixture.whenStable() fixture.detectChanges() - expect(editorComponent.maxLength).toBe(3) + expect(editorComponent.maxLength()).toBe(3) expect(editorElement.className).toMatch('ng-invalid') }) @@ -1159,7 +1161,7 @@ describe('Advanced QuillEditorComponent', () => { it('should validate maxlength and minlength with trimming white spaces', async () => { // get editor component const editorElement = fixture.debugElement.children[0].nativeElement - fixture.componentInstance.editorComponent.trimOnValidation = true + fixture.componentInstance.trimOnValidation = true fixture.detectChanges() await fixture.whenStable() @@ -1194,7 +1196,7 @@ describe('Advanced QuillEditorComponent', () => { await fixture.whenStable() expect(fixture.debugElement.children[0].nativeElement.className).toMatch('ng-valid') - expect(editorComponent.required).toBeFalsy() + expect(editorComponent.required()).toBeFalsy() fixture.componentInstance.required = true fixture.componentInstance.title = '' @@ -1203,7 +1205,7 @@ describe('Advanced QuillEditorComponent', () => { await fixture.whenStable() fixture.detectChanges() - expect(editorComponent.required).toBeTruthy() + expect(editorComponent.required()).toBeTruthy() expect(editorElement.className).toMatch('ng-invalid') fixture.componentInstance.title = '1' @@ -1235,8 +1237,8 @@ describe('Advanced QuillEditorComponent', () => { expect(toolbarFixture.debugElement.children[0].nativeElement.children[3].attributes['quill-editor-element']).toBeDefined() const editorComponent = toolbarFixture.debugElement.children[0].componentInstance - expect(editorComponent.required).toBe(true) - expect(editorComponent.customToolbarPosition).toEqual('top') + expect(editorComponent.required()).toBe(true) + expect(editorComponent.customToolbarPosition()).toEqual('top') }) it('should add custom toolbar at the end', async () => { @@ -1253,7 +1255,7 @@ describe('Advanced QuillEditorComponent', () => { expect(toolbarFixture.debugElement.children[0].nativeElement.children[3].attributes['below-quill-editor-toolbar']).toBeDefined() const editorComponent = toolbarFixture.debugElement.children[0].componentInstance - expect(editorComponent.customToolbarPosition).toEqual('bottom') + expect(editorComponent.customToolbarPosition()).toEqual('bottom') }) it('should render custom link placeholder', async () => { diff --git a/projects/ngx-quill/src/lib/quill-editor.component.ts b/projects/ngx-quill/src/lib/quill-editor.component.ts index 73d547d3..144f7b3c 100644 --- a/projects/ngx-quill/src/lib/quill-editor.component.ts +++ b/projects/ngx-quill/src/lib/quill-editor.component.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { DOCUMENT, isPlatformServer, CommonModule } from '@angular/common' +import { DOCUMENT, isPlatformServer } from '@angular/common' import { DomSanitizer } from '@angular/platform-browser' import QuillType from 'quill' @@ -9,12 +9,13 @@ import { AfterViewInit, ChangeDetectorRef, Component, + DestroyRef, Directive, ElementRef, EventEmitter, forwardRef, inject, - Input, + input, NgZone, OnChanges, OnDestroy, @@ -23,6 +24,7 @@ import { PLATFORM_ID, Renderer2, SecurityContext, + signal, SimpleChanges, ViewEncapsulation } from '@angular/core' @@ -33,10 +35,11 @@ import { ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR, Validator } fro import { defaultModules, QuillModules, CustomOption, CustomModule } from 'ngx-quill/config' -import { getFormat } from './helpers' +import { getFormat, raf$ } from './helpers' import { QuillService } from './quill.service' import Toolbar from 'quill/modules/toolbar' import History from 'quill/modules/history' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' export interface Range { index: number @@ -76,31 +79,35 @@ export type EditorChangeSelection = SelectionChange & { event: 'selection-change @Directive() // eslint-disable-next-line @angular-eslint/directive-class-suffix export abstract class QuillEditorBase implements AfterViewInit, ControlValueAccessor, OnChanges, OnInit, OnDestroy, Validator { - @Input() format?: 'object' | 'html' | 'text' | 'json' - @Input() theme?: string - @Input() modules?: QuillModules - @Input() debug?: 'warn' | 'log' | 'error' | false - @Input() readOnly?: boolean - @Input() placeholder?: string - @Input() maxLength?: number - @Input() minLength?: number - @Input() required = false - @Input() formats?: string[] | null - @Input() customToolbarPosition: 'top' | 'bottom' = 'top' - @Input() sanitize?: boolean - @Input() beforeRender?: () => Promise - @Input() styles: any = null - @Input() registry?: Record | null - @Input() bounds?: HTMLElement | string - @Input() customOptions: CustomOption[] = [] - @Input() customModules: CustomModule[] = [] - @Input() trackChanges?: 'user' | 'all' - @Input() classes?: string - @Input() trimOnValidation = false - @Input() linkPlaceholder?: string - @Input() compareValues = false - @Input() filterNull = false - @Input() debounceTime?: number + readonly format = input<'object' | 'html' | 'text' | 'json' | undefined>( + undefined + ) + readonly theme = input(undefined) + readonly modules = input(undefined) + readonly debug = input<'warn' | 'log' | 'error' | false>(false) + readonly readOnly = input(false) + readonly placeholder = input(undefined) + readonly maxLength = input(undefined) + readonly minLength = input(undefined) + readonly required = input(false) + readonly formats = input(undefined) + readonly customToolbarPosition = input<'top' | 'bottom'>('top') + readonly sanitize = input(false) + readonly beforeRender = input<() => Promise | undefined>(undefined) + readonly styles = input(null) + readonly registry = input | null | undefined>( + undefined + ) + readonly bounds = input(undefined) + readonly customOptions = input([]) + readonly customModules = input([]) + readonly trackChanges = input<'user' | 'all' | undefined>(undefined) + readonly classes = input(undefined) + readonly trimOnValidation = input(false) + readonly linkPlaceholder = input(undefined) + readonly compareValues = input(false) + readonly filterNull = input(false) + readonly debounceTime = input(undefined) /* https://github.com/KillerCodeMonkey/ngx-quill/issues/1257 - fix null value set @@ -114,7 +121,7 @@ export abstract class QuillEditorBase implements AfterViewInit, ControlValueAcce formControlName="message" > */ - @Input() defaultEmptyValue?: any = null + readonly defaultEmptyValue = input(null) @Output() onEditorCreated: EventEmitter = new EventEmitter() @Output() onEditorChanged: EventEmitter = new EventEmitter() @@ -129,7 +136,8 @@ export abstract class QuillEditorBase implements AfterViewInit, ControlValueAcce editorElem!: HTMLElement content: any disabled = false // used to store initial value before ViewInit - toolbarPosition = 'top' + + readonly toolbarPosition = signal('top') onModelChange: (modelValue?: any) => void onModelTouched: () => void @@ -147,6 +155,7 @@ export abstract class QuillEditorBase implements AfterViewInit, ControlValueAcce private renderer = inject(Renderer2) private zone = inject(NgZone) private service = inject(QuillService) + private destroyRef = inject(DestroyRef) static normalizeClassNames(classes: string): string[] { const classList = classes.trim().split(' ') @@ -160,14 +169,13 @@ export abstract class QuillEditorBase implements AfterViewInit, ControlValueAcce }, []) } - @Input() - valueGetter = (quillEditor: QuillType): string | any => { + valueGetter = input((quillEditor: QuillType): string | any => { let html: string | null = quillEditor.getSemanticHTML() if (html === '


' || html === '

') { - html = this.defaultEmptyValue + html = this.defaultEmptyValue() } let modelValue: string | Delta | null = html - const format = getFormat(this.format, this.service.config.format) + const format = getFormat(this.format(), this.service.config.format) if (format === 'text') { modelValue = quillEditor.getText() @@ -182,13 +190,12 @@ export abstract class QuillEditorBase implements AfterViewInit, ControlValueAcce } return modelValue - } + }) - @Input() - valueSetter = (quillEditor: QuillType, value: any): any => { - const format = getFormat(this.format, this.service.config.format) + valueSetter = input((quillEditor: QuillType, value: any): any => { + const format = getFormat(this.format(), this.service.config.format) if (format === 'html') { - const sanitize = [true, false].includes(this.sanitize) ? this.sanitize : (this.service.config.sanitize || false) + const sanitize = [true, false].includes(this.sanitize()) ? this.sanitize() : (this.service.config.sanitize || false) if (sanitize) { value = this.domSanitizer.sanitize(SecurityContext.HTML, value) } @@ -202,10 +209,10 @@ export abstract class QuillEditorBase implements AfterViewInit, ControlValueAcce } return value - } + }) ngOnInit() { - this.toolbarPosition = this.customToolbarPosition + this.toolbarPosition.set(this.customToolbarPosition()) } ngAfterViewInit() { @@ -218,8 +225,8 @@ export abstract class QuillEditorBase implements AfterViewInit, ControlValueAcce this.quillSubscription = this.service.getQuill().pipe( mergeMap((Quill) => { - const promises = [this.service.registerCustomModules(Quill, this.customModules)] - const beforeRender = this.beforeRender ?? this.service.config.beforeRender + const promises = [this.service.registerCustomModules(Quill, this.customModules())] + const beforeRender = this.beforeRender() ?? this.service.config.beforeRender if (beforeRender) { promises.push(beforeRender()) } @@ -233,7 +240,7 @@ export abstract class QuillEditorBase implements AfterViewInit, ControlValueAcce const toolbarElem = this.elementRef.nativeElement.querySelector( '[quill-editor-toolbar]' ) - const modules = Object.assign({}, this.modules || this.service.config.modules) + const modules = Object.assign({}, this.modules() || this.service.config.modules) if (toolbarElem) { modules.toolbar = toolbarElem @@ -241,39 +248,40 @@ export abstract class QuillEditorBase implements AfterViewInit, ControlValueAcce modules.toolbar = defaultModules.toolbar } - let placeholder = this.placeholder !== undefined ? this.placeholder : this.service.config.placeholder + let placeholder = this.placeholder() !== undefined ? this.placeholder() : this.service.config.placeholder if (placeholder === undefined) { placeholder = 'Insert text here ...' } - if (this.styles) { - Object.keys(this.styles).forEach((key: string) => { - this.renderer.setStyle(this.editorElem, key, this.styles[key]) + const styles = this.styles() + if (styles) { + Object.keys(styles).forEach((key: string) => { + this.renderer.setStyle(this.editorElem, key, styles[key]) }) } - if (this.classes) { - this.addClasses(this.classes) + if (this.classes()) { + this.addClasses(this.classes()) } - this.customOptions.forEach((customOption) => { + this.customOptions().forEach((customOption) => { const newCustomOption = Quill.import(customOption.import) newCustomOption.whitelist = customOption.whitelist Quill.register(newCustomOption, true) }) - let bounds = this.bounds && this.bounds === 'self' ? this.editorElem : this.bounds + let bounds = this.bounds() && this.bounds() === 'self' ? this.editorElem : this.bounds() if (!bounds) { bounds = this.service.config.bounds ? this.service.config.bounds : this.document.body } - let debug = this.debug + let debug = this.debug() if (!debug && debug !== false && this.service.config.debug) { debug = this.service.config.debug } - let readOnly = this.readOnly - if (!readOnly && this.readOnly !== false) { + let readOnly = this.readOnly() + if (!readOnly && this.readOnly() !== false) { readOnly = this.service.config.readOnly !== undefined ? this.service.config.readOnly : false } @@ -283,7 +291,7 @@ export abstract class QuillEditorBase implements AfterViewInit, ControlValueAcce defaultEmptyValue = this.service.config.defaultEmptyValue } - let formats = this.formats + let formats = this.formats() if (!formats && formats === undefined) { formats = this.service.config.formats ? [...this.service.config.formats] : (this.service.config.formats === null ? null : undefined) } @@ -297,8 +305,8 @@ export abstract class QuillEditorBase implements AfterViewInit, ControlValueAcce placeholder, readOnly, defaultEmptyValue, - registry: this.registry, - theme: this.theme || (this.service.config.theme ? this.service.config.theme : 'snow') + registry: this.registry(), + theme: this.theme() || (this.service.config.theme ? this.service.config.theme : 'snow') }) if (this.onNativeBlur.observed) { @@ -320,22 +328,23 @@ export abstract class QuillEditorBase implements AfterViewInit, ControlValueAcce } // Set optional link placeholder, Quill has no native API for it so using workaround - if (this.linkPlaceholder) { + if (this.linkPlaceholder()) { const tooltip = (this.quillEditor as any)?.theme?.tooltip const input = tooltip?.root?.querySelector('input[data-link]') if (input?.dataset) { - input.dataset.link = this.linkPlaceholder + input.dataset.link = this.linkPlaceholder() } } }) if (this.content) { - const format = getFormat(this.format, this.service.config.format) + const format = getFormat(this.format(), this.service.config.format) if (format === 'text') { this.quillEditor.setText(this.content, 'silent') } else { - const newValue = this.valueSetter(this.quillEditor, this.content) + const valueSetter = this.valueSetter() + const newValue = valueSetter(this.quillEditor, this.content) this.quillEditor.setContents(newValue, 'silent') } @@ -357,18 +366,17 @@ export abstract class QuillEditorBase implements AfterViewInit, ControlValueAcce // The `requestAnimationFrame` will trigger change detection and `onEditorCreated` will also call `markDirty()` // internally, since Angular wraps template event listeners into `listener` instruction. We're using the `requestAnimationFrame` // to prevent the frame drop and avoid `ExpressionChangedAfterItHasBeenCheckedError` error. - requestAnimationFrame(() => { + raf$().pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { if (this.onValidatorChanged) { this.onValidatorChanged() } this.onEditorCreated.emit(this.quillEditor) - this.onEditorCreated.complete() }) }) } selectionChangeHandler = (range: Range | null, oldRange: Range | null, source: string) => { - const trackChanges = this.trackChanges || this.service.config.trackChanges + const trackChanges = this.trackChanges() || this.service.config.trackChanges const shouldTriggerOnModelTouched = !range && !!this.onModelTouched && (source === 'user' || trackChanges && trackChanges === 'all') // only emit changes when there's any listener @@ -414,10 +422,10 @@ export abstract class QuillEditorBase implements AfterViewInit, ControlValueAcce let html: string | null = this.quillEditor.getSemanticHTML() if (html === '


' || html === '

') { - html = this.defaultEmptyValue + html = this.defaultEmptyValue() } - const trackChanges = this.trackChanges || this.service.config.trackChanges + const trackChanges = this.trackChanges() || this.service.config.trackChanges const shouldTriggerOnModelChange = (source === 'user' || trackChanges && trackChanges === 'all') && !!this.onModelChange // only emit changes when there's any listener @@ -427,8 +435,9 @@ export abstract class QuillEditorBase implements AfterViewInit, ControlValueAcce this.zone.run(() => { if (shouldTriggerOnModelChange) { + const valueGetter = this.valueGetter() this.onModelChange( - this.valueGetter(this.quillEditor) + valueGetter(this.quillEditor) ) } @@ -463,7 +472,7 @@ export abstract class QuillEditorBase implements AfterViewInit, ControlValueAcce let html: string | null = this.quillEditor.getSemanticHTML() if (html === '


' || html === '

') { - html = this.defaultEmptyValue + html = this.defaultEmptyValue() } this.zone.run(() => { @@ -529,7 +538,7 @@ export abstract class QuillEditorBase implements AfterViewInit, ControlValueAcce } if (currentStyling) { Object.keys(currentStyling).forEach((key: string) => { - this.renderer.setStyle(this.editorElem, key, this.styles[key]) + this.renderer.setStyle(this.editorElem, key, this.styles()[key]) }) } } @@ -568,7 +577,7 @@ export abstract class QuillEditorBase implements AfterViewInit, ControlValueAcce writeValue(currentValue: any) { // optional fix for https://github.com/angular/angular/issues/14988 - if (this.filterNull && currentValue === null) { + if (this.filterNull() && currentValue === null) { return } @@ -578,10 +587,11 @@ export abstract class QuillEditorBase implements AfterViewInit, ControlValueAcce return } - const format = getFormat(this.format, this.service.config.format) - const newValue = this.valueSetter(this.quillEditor, currentValue) + const format = getFormat(this.format(), this.service.config.format) + const valueSetter = this.valueSetter() + const newValue = valueSetter(this.quillEditor, currentValue) - if (this.compareValues) { + if (this.compareValues()) { const currentEditorValue = this.quillEditor.getContents() if (JSON.stringify(currentEditorValue) === JSON.stringify(newValue)) { return @@ -608,7 +618,7 @@ export abstract class QuillEditorBase implements AfterViewInit, ControlValueAcce this.quillEditor.disable() this.renderer.setAttribute(this.elementRef.nativeElement, 'disabled', 'disabled') } else { - if (!this.readOnly) { + if (!this.readOnly()) { this.quillEditor.enable() } this.renderer.removeAttribute(this.elementRef.nativeElement, 'disabled') @@ -648,29 +658,29 @@ export abstract class QuillEditorBase implements AfterViewInit, ControlValueAcce const text = this.quillEditor.getText() // trim text if wanted + handle special case that an empty editor contains a new line - const textLength = this.trimOnValidation ? text.trim().length : (text.length === 1 && text.trim().length === 0 ? 0 : text.length - 1) + const textLength = this.trimOnValidation() ? text.trim().length : (text.length === 1 && text.trim().length === 0 ? 0 : text.length - 1) const deltaOperations = this.quillEditor.getContents().ops const onlyEmptyOperation = !!deltaOperations && deltaOperations.length === 1 && ['\n', ''].includes(deltaOperations[0].insert?.toString()) - if (this.minLength && textLength && textLength < this.minLength) { + if (this.minLength() && textLength && textLength < this.minLength()) { err.minLengthError = { given: textLength, - minLength: this.minLength + minLength: this.minLength() } valid = false } - if (this.maxLength && textLength > this.maxLength) { + if (this.maxLength() && textLength > this.maxLength()) { err.maxLengthError = { given: textLength, - maxLength: this.maxLength + maxLength: this.maxLength() } valid = false } - if (this.required && !textLength && onlyEmptyOperation) { + if (this.required() && !textLength && onlyEmptyOperation) { err.requiredError = { empty: true } @@ -704,9 +714,9 @@ export abstract class QuillEditorBase implements AfterViewInit, ControlValueAcce let textChange$ = fromEvent(this.quillEditor, 'text-change') let editorChange$ = fromEvent(this.quillEditor, 'editor-change') - if (typeof this.debounceTime === 'number') { - textChange$ = textChange$.pipe(debounceTime(this.debounceTime)) - editorChange$ = editorChange$.pipe(debounceTime(this.debounceTime)) + if (typeof this.debounceTime() === 'number') { + textChange$ = textChange$.pipe(debounceTime(this.debounceTime())) + editorChange$ = editorChange$.pipe(debounceTime(this.debounceTime())) } this.subscription.add( @@ -751,7 +761,7 @@ export abstract class QuillEditorBase implements AfterViewInit, ControlValueAcce ], selector: 'quill-editor', template: ` - @if (toolbarPosition !== 'top') { + @if (toolbarPosition() !== 'top') {
} @@ -759,7 +769,7 @@ export abstract class QuillEditorBase implements AfterViewInit, ControlValueAcce - @if (toolbarPosition === 'top') { + @if (toolbarPosition() === 'top') {
} `, @@ -770,7 +780,6 @@ export abstract class QuillEditorBase implements AfterViewInit, ControlValueAcce } ` ], - standalone: true, - imports: [CommonModule] + standalone: true }) export class QuillEditorComponent extends QuillEditorBase {} diff --git a/projects/ngx-quill/src/lib/quill-view-html.component.spec.ts b/projects/ngx-quill/src/lib/quill-view-html.component.spec.ts index 9edc8d63..2881b482 100644 --- a/projects/ngx-quill/src/lib/quill-view-html.component.spec.ts +++ b/projects/ngx-quill/src/lib/quill-view-html.component.spec.ts @@ -25,7 +25,7 @@ describe('Basic QuillViewHTMLComponent', () => { await fixture.whenStable() expect(element.querySelectorAll('.ql-editor').length).toBe(1) - expect(fixture.componentInstance.themeClass).toBe('ql-snow') + expect(fixture.componentInstance.themeClass()).toBe('ql-snow') const viewElement = element.querySelector('.ql-container.ql-snow.ngx-quill-view-html > .ql-editor') expect(viewElement).toBeDefined() })) diff --git a/projects/ngx-quill/src/lib/quill-view-html.component.ts b/projects/ngx-quill/src/lib/quill-view-html.component.ts index ff266dab..cec70784 100644 --- a/projects/ngx-quill/src/lib/quill-view-html.component.ts +++ b/projects/ngx-quill/src/lib/quill-view-html.component.ts @@ -3,13 +3,13 @@ import { QuillService } from './quill.service' import { Component, - Inject, - Input, OnChanges, SimpleChanges, - ViewEncapsulation + ViewEncapsulation, + input, + signal } from '@angular/core' -import { CommonModule } from '@angular/common' +import { NgClass } from '@angular/common' @Component({ encapsulation: ViewEncapsulation.None, @@ -20,40 +20,40 @@ import { CommonModule } from '@angular/common' } `], template: ` -
-
+
+
`, standalone: true, - imports: [CommonModule] + imports: [NgClass] }) export class QuillViewHTMLComponent implements OnChanges { - @Input() content = '' - @Input() theme?: string - @Input() sanitize?: boolean + readonly content = input('') + readonly theme = input(undefined) + readonly sanitize = input(false) - innerHTML: SafeHtml = '' - themeClass = 'ql-snow' + readonly innerHTML = signal('') + readonly themeClass = signal('ql-snow') constructor( - @Inject(DomSanitizer) private sanitizer: DomSanitizer, + private sanitizer: DomSanitizer, protected service: QuillService ) {} ngOnChanges(changes: SimpleChanges) { if (changes.theme) { const theme = changes.theme.currentValue || (this.service.config.theme ? this.service.config.theme : 'snow') - this.themeClass = `ql-${theme} ngx-quill-view-html` - } else if (!this.theme) { + this.themeClass.set(`ql-${theme} ngx-quill-view-html`) + } else if (!this.theme()) { const theme = this.service.config.theme ? this.service.config.theme : 'snow' - this.themeClass = `ql-${theme} ngx-quill-view-html` + this.themeClass.set(`ql-${theme} ngx-quill-view-html`) } if (changes.content) { const content = changes.content.currentValue - const sanitize = [true, false].includes(this.sanitize) ? this.sanitize : (this.service.config.sanitize || false) - - this.innerHTML = sanitize ? content : this.sanitizer.bypassSecurityTrustHtml(content) + const sanitize = [true, false].includes(this.sanitize()) ? this.sanitize() : (this.service.config.sanitize || false) + const innerHTML = sanitize ? content : this.sanitizer.bypassSecurityTrustHtml(content) + this.innerHTML.set(innerHTML) } } } diff --git a/projects/ngx-quill/src/lib/quill-view.component.ts b/projects/ngx-quill/src/lib/quill-view.component.ts index f46f8e3e..e6681629 100644 --- a/projects/ngx-quill/src/lib/quill-view.component.ts +++ b/projects/ngx-quill/src/lib/quill-view.component.ts @@ -1,15 +1,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { CommonModule, isPlatformServer } from '@angular/common' +import { isPlatformServer } from '@angular/common' import QuillType from 'quill' import { AfterViewInit, Component, ElementRef, - EventEmitter, Inject, - Input, - Output, OnChanges, PLATFORM_ID, Renderer2, @@ -17,14 +14,20 @@ import { ViewEncapsulation, NgZone, SecurityContext, - OnDestroy + OnDestroy, + input, + EventEmitter, + Output, + inject, + DestroyRef } from '@angular/core' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' import { Subscription } from 'rxjs' import { mergeMap } from 'rxjs/operators' import { CustomOption, CustomModule, QuillModules } from 'ngx-quill/config' -import {getFormat} from './helpers' +import { getFormat, raf$ } from './helpers' import { QuillService } from './quill.service' import { DomSanitizer } from '@angular/platform-browser' @@ -39,21 +42,22 @@ import { DomSanitizer } from '@angular/platform-browser' template: `
`, - standalone: true, - imports: [CommonModule] + standalone: true }) export class QuillViewComponent implements AfterViewInit, OnChanges, OnDestroy { - @Input() format?: 'object' | 'html' | 'text' | 'json' - @Input() theme?: string - @Input() modules?: QuillModules - @Input() debug?: 'warn' | 'log' | 'error' | false - @Input() formats?: string[] | null - @Input() sanitize?: boolean - @Input() beforeRender?: () => Promise - @Input() strict = true - @Input() content: any - @Input() customModules: CustomModule[] = [] - @Input() customOptions: CustomOption[] = [] + readonly format = input<'object' | 'html' | 'text' | 'json' | undefined>( + undefined + ) + readonly theme = input(undefined) + readonly modules = input(undefined) + readonly debug = input<'warn' | 'log' | 'error' | false>(false) + readonly formats = input(undefined) + readonly sanitize = input(false) + readonly beforeRender = input<() => Promise | undefined>(undefined) + readonly strict = input(true) + readonly content = input() + readonly customModules = input([]) + readonly customOptions = input([]) @Output() onEditorCreated: EventEmitter = new EventEmitter() @@ -62,6 +66,8 @@ export class QuillViewComponent implements AfterViewInit, OnChanges, OnDestroy { private quillSubscription: Subscription | null = null + private destroyRef = inject(DestroyRef) + constructor( public elementRef: ElementRef, protected renderer: Renderer2, @@ -72,13 +78,13 @@ export class QuillViewComponent implements AfterViewInit, OnChanges, OnDestroy { ) {} valueSetter = (quillEditor: QuillType, value: any): any => { - const format = getFormat(this.format, this.service.config.format) + const format = getFormat(this.format(), this.service.config.format) let content = value if (format === 'text') { quillEditor.setText(content) } else { if (format === 'html') { - const sanitize = [true, false].includes(this.sanitize) ? this.sanitize : (this.service.config.sanitize || false) + const sanitize = [true, false].includes(this.sanitize()) ? this.sanitize() : (this.service.config.sanitize || false) if (sanitize) { value = this.domSanitizer.sanitize(SecurityContext.HTML, value) } @@ -110,34 +116,34 @@ export class QuillViewComponent implements AfterViewInit, OnChanges, OnDestroy { this.quillSubscription = this.service.getQuill().pipe( mergeMap((Quill) => { - const promises = [this.service.registerCustomModules(Quill, this.customModules)] - const beforeRender = this.beforeRender ?? this.service.config.beforeRender + const promises = [this.service.registerCustomModules(Quill, this.customModules())] + const beforeRender = this.beforeRender() ?? this.service.config.beforeRender if (beforeRender) { promises.push(beforeRender()) } return Promise.all(promises).then(() => Quill) }) ).subscribe(Quill => { - const modules = Object.assign({}, this.modules || this.service.config.modules) + const modules = Object.assign({}, this.modules() || this.service.config.modules) modules.toolbar = false - this.customOptions.forEach((customOption) => { + this.customOptions().forEach((customOption) => { const newCustomOption = Quill.import(customOption.import) newCustomOption.whitelist = customOption.whitelist Quill.register(newCustomOption, true) }) - let debug = this.debug + let debug = this.debug() if (!debug && debug !== false && this.service.config.debug) { debug = this.service.config.debug } - let formats = this.formats + let formats = this.formats() if (!formats && formats === undefined) { formats = this.service.config.formats ? Object.assign({}, this.service.config.formats) : (this.service.config.formats === null ? null : undefined) } - const theme = this.theme || (this.service.config.theme ? this.service.config.theme : 'snow') + const theme = this.theme() || (this.service.config.theme ? this.service.config.theme : 'snow') this.editorElem = this.elementRef.nativeElement.querySelector( '[quill-view-element]' @@ -149,29 +155,28 @@ export class QuillViewComponent implements AfterViewInit, OnChanges, OnDestroy { formats: formats as any, modules, readOnly: true, - strict: this.strict, + strict: this.strict(), theme }) }) this.renderer.addClass(this.editorElem, 'ngx-quill-view') - if (this.content) { - this.valueSetter(this.quillEditor, this.content) + if (this.content()) { + this.valueSetter(this.quillEditor, this.content()) } // The `requestAnimationFrame` triggers change detection. There's no sense to invoke the `requestAnimationFrame` if anyone is // listening to the `onEditorCreated` event inside the template, for instance ``. - if (!this.onEditorCreated.observers.length) { + if (!this.onEditorCreated.observed) { return } // The `requestAnimationFrame` will trigger change detection and `onEditorCreated` will also call `markDirty()` // internally, since Angular wraps template event listeners into `listener` instruction. We're using the `requestAnimationFrame` // to prevent the frame drop and avoid `ExpressionChangedAfterItHasBeenCheckedError` error. - requestAnimationFrame(() => { + raf$().pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { this.onEditorCreated.emit(this.quillEditor) - this.onEditorCreated.complete() }) }) }