diff --git a/apps/playground/src/app/components/text-editor/text-editor-series/text-editor-series.component.html b/apps/playground/src/app/components/text-editor/text-editor-series/text-editor-series.component.html new file mode 100644 index 0000000000..b07d65d2cb --- /dev/null +++ b/apps/playground/src/app/components/text-editor/text-editor-series/text-editor-series.component.html @@ -0,0 +1,25 @@ +
+

Text editor looped

+
+ + @for (editor of editors; track editor) { + + +

Preview HTML

+
+
+ } +
+
+
diff --git a/apps/playground/src/app/components/text-editor/text-editor-series/text-editor-series.component.scss b/apps/playground/src/app/components/text-editor/text-editor-series/text-editor-series.component.scss new file mode 100644 index 0000000000..9e470184fb --- /dev/null +++ b/apps/playground/src/app/components/text-editor/text-editor-series/text-editor-series.component.scss @@ -0,0 +1,5 @@ +.content-display { + background-color: white; + height: 300px; + overflow-y: auto; +} diff --git a/apps/playground/src/app/components/text-editor/text-editor-series/text-editor-series.component.ts b/apps/playground/src/app/components/text-editor/text-editor-series/text-editor-series.component.ts new file mode 100644 index 0000000000..d81338fd3f --- /dev/null +++ b/apps/playground/src/app/components/text-editor/text-editor-series/text-editor-series.component.ts @@ -0,0 +1,64 @@ +import { Component, DestroyRef, OnInit, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + FormBuilder, + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { SkyVerticalTabsetModule } from '@skyux/tabs'; +import { SkyTextEditorModule } from '@skyux/text-editor'; + +import { startWith } from 'rxjs'; + +@Component({ + selector: 'app-text-editor-series', + templateUrl: './text-editor-series.component.html', + styleUrls: ['./text-editor-series.component.scss'], + standalone: true, + imports: [SkyTextEditorModule, SkyVerticalTabsetModule, ReactiveFormsModule], +}) +export class TextEditorSeriesComponent implements OnInit { + public displayValue: Record = {}; + + public myForm: FormGroup; + + public editors = ['textEditor1', 'textEditor2', 'textEditor3', 'textEditor4']; + + readonly #destroyRef = inject(DestroyRef); + readonly #formBuilder = inject(FormBuilder); + readonly #sanitizer = inject(DomSanitizer); + + public ngOnInit(): void { + this.myForm = this.#formBuilder.group({ + textEditor1: new FormControl( + 'Super styled text 1', + [Validators.required], + ), + textEditor2: new FormControl( + 'Super styled text 2', + [Validators.required], + ), + textEditor3: new FormControl( + 'Super styled text 3', + [Validators.required], + ), + textEditor4: new FormControl( + 'Super styled text 4', + [Validators.required], + ), + }); + + this.myForm.valueChanges + .pipe(takeUntilDestroyed(this.#destroyRef), startWith(this.myForm.value)) + .subscribe((value) => { + for (const key in value) { + this.displayValue[key] = this.#sanitizer.bypassSecurityTrustHtml( + value[key], + ); + } + }); + } +} diff --git a/apps/playground/src/app/components/text-editor/text-editor.module.ts b/apps/playground/src/app/components/text-editor/text-editor.module.ts index 45188be792..2f60c1ab5a 100644 --- a/apps/playground/src/app/components/text-editor/text-editor.module.ts +++ b/apps/playground/src/app/components/text-editor/text-editor.module.ts @@ -16,6 +16,18 @@ const routes: Routes = [ (m) => m.TextEditorModule, ), }, + { + path: 'text-editor-series', + loadComponent: () => + import('./text-editor-series/text-editor-series.component').then( + (m) => m.TextEditorSeriesComponent, + ), + data: { + name: 'Text editor looped', + icon: 'pencil-square-o', + library: 'text-editor', + }, + }, ]; @NgModule({ diff --git a/apps/playground/src/app/components/text-editor/text-editor/text-editor.component.html b/apps/playground/src/app/components/text-editor/text-editor/text-editor.component.html index e77f199e0a..fcd9894aa7 100644 --- a/apps/playground/src/app/components/text-editor/text-editor/text-editor.component.html +++ b/apps/playground/src/app/components/text-editor/text-editor/text-editor.component.html @@ -18,7 +18,7 @@

Text editor

helpPopoverContent="This is some helpful content." helpPopoverTitle="Did you know?" hintText="Hint text" - name="text-editor" + stacked [menus]="menus" [mergeFields]="mergeFields" [labelText]="labelText" diff --git a/apps/playground/src/app/home/home.component.ts b/apps/playground/src/app/home/home.component.ts index 71334d73e6..97b68cbc87 100644 --- a/apps/playground/src/app/home/home.component.ts +++ b/apps/playground/src/app/home/home.component.ts @@ -135,7 +135,6 @@ export class HomeComponent implements AfterViewInit { routes: ComponentRouteInfo[], parentPath: string, ): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any const promises: Promise[] = []; for (const route of routes) { diff --git a/libs/components/text-editor/src/lib/modules/text-editor/services/text-editor-adapter.service.spec.ts b/libs/components/text-editor/src/lib/modules/text-editor/services/text-editor-adapter.service.spec.ts new file mode 100644 index 0000000000..07ffb51d8d --- /dev/null +++ b/libs/components/text-editor/src/lib/modules/text-editor/services/text-editor-adapter.service.spec.ts @@ -0,0 +1,107 @@ +import { TestBed } from '@angular/core/testing'; +import { SkyTextEditorStyleState } from '@skyux/text-editor'; + +import { STYLE_STATE_DEFAULTS } from '../defaults/style-state-defaults'; + +import { SkyTextEditorAdapterService } from './text-editor-adapter.service'; +import { SkyTextEditorSelectionService } from './text-editor-selection.service'; +import { SkyTextEditorService } from './text-editor.service'; + +import SpyObj = jasmine.SpyObj; + +describe('SkyTextEditorAdapterService', () => { + let styleState: SkyTextEditorStyleState; + let doc: SpyObj; + let win: SpyObj; + + beforeEach(() => { + styleState = Object.assign({}, STYLE_STATE_DEFAULTS); + doc = jasmine.createSpyObj( + 'Document', + [ + 'addEventListener', + 'close', + 'createElement', + 'open', + 'removeEventListener', + 'querySelector', + ], + { + body: jasmine.createSpyObj([ + 'addEventListener', + 'setAttribute', + ]), + head: jasmine.createSpyObj(['appendChild']), + }, + ); + doc.createElement.and.callFake((tagName: string) => { + return document.createElement(tagName); + }); + win = jasmine.createSpyObj( + 'Window', + ['addEventListener', 'removeEventListener'], + { + document: doc, + }, + ); + TestBed.configureTestingModule({ + providers: [ + SkyTextEditorAdapterService, + SkyTextEditorSelectionService, + SkyTextEditorService, + ], + }); + }); + + it('should initialize the editor', () => { + const service = TestBed.inject(SkyTextEditorAdapterService); + const iframe = jasmine.createSpyObj( + 'HTMLIFrameElement', + ['addEventListener', 'removeEventListener'], + { + contentWindow: win, + contentDocument: doc, + }, + ); + service.initEditor('test', iframe, styleState); + expect(doc.body.setAttribute).toHaveBeenCalledWith( + 'contenteditable', + 'true', + ); + }); + + it('should initialize the editor using contentDocument', () => { + const service = TestBed.inject(SkyTextEditorAdapterService); + const iframe = jasmine.createSpyObj( + 'HTMLIFrameElement', + ['addEventListener', 'removeEventListener'], + { + contentWindow: undefined, + contentDocument: doc, + }, + ); + service.initEditor('test', iframe, styleState); + expect(doc.body.setAttribute).toHaveBeenCalledWith( + 'contenteditable', + 'true', + ); + }); + + it('should stop initializing the editor when document is null', () => { + const service = TestBed.inject(SkyTextEditorAdapterService); + const iframe = jasmine.createSpyObj( + 'HTMLIFrameElement', + ['addEventListener', 'removeEventListener'], + { + contentWindow: undefined, + contentDocument: undefined, + }, + ); + service.initEditor('test', iframe, styleState); + expect(doc.body.setAttribute).not.toHaveBeenCalled(); + expect(service.editorSelected()).toBeFalse(); + expect(service.saveSelection()).toBeUndefined(); + expect(service.getCurrentSelection()).toBeFalsy(); + expect(service.getSelectedAnchorTag()).toBeFalsy(); + }); +}); diff --git a/libs/components/text-editor/src/lib/modules/text-editor/services/text-editor-adapter.service.ts b/libs/components/text-editor/src/lib/modules/text-editor/services/text-editor-adapter.service.ts index 295d64e268..15671fad80 100644 --- a/libs/components/text-editor/src/lib/modules/text-editor/services/text-editor-adapter.service.ts +++ b/libs/components/text-editor/src/lib/modules/text-editor/services/text-editor-adapter.service.ts @@ -47,6 +47,9 @@ export class SkyTextEditorAdapterService { this.#textEditorService.editor = this.#createObservers(iframeElement); const documentEl = this.#getIframeDocumentEl(); + if (!documentEl) { + return; + } const styleEl = documentEl.createElement('style'); styleEl.innerHTML = `.editor:empty:before { @@ -109,7 +112,7 @@ export class SkyTextEditorAdapterService { await navigator.clipboard.readText().then((clipText) => { /* istanbul ignore else */ if (this.editorSelected()) { - documentEl.execCommand('insertHTML', false, clipText); + documentEl?.execCommand('insertHTML', false, clipText); this.focusEditor(); this.#textEditorService.editor.commandChangeObservable.next(); @@ -119,7 +122,7 @@ export class SkyTextEditorAdapterService { } else { /* istanbul ignore else */ if (this.editorSelected()) { - documentEl.execCommand( + documentEl?.execCommand( editorCommand.command, false, editorCommand.value, @@ -133,9 +136,11 @@ export class SkyTextEditorAdapterService { } public getCurrentSelection(): Selection | null { - return this.#selectionService.getCurrentSelection( - this.#getIframeDocumentEl(), - ); + const documentEl = this.#getIframeDocumentEl(); + if (documentEl) { + return this.#selectionService.getCurrentSelection(documentEl); + } + return null; } /** @@ -178,7 +183,7 @@ export class SkyTextEditorAdapterService { const documentEl = this.#getIframeDocumentEl(); /* istanbul ignore else */ - if (this.editorSelected()) { + if (documentEl && this.editorSelected()) { return { backColor: this.#getColor(documentEl, 'BackColor'), fontColor: this.#getColor(documentEl, 'ForeColor'), @@ -205,9 +210,9 @@ export class SkyTextEditorAdapterService { public setEditorInnerHtml(value: string): void { const documentEl = this.#getIframeDocumentEl(); - const editorContent = documentEl.body; + const editorContent = documentEl?.body; /* istanbul ignore else */ - if (editorContent.innerHTML !== value) { + if (editorContent && editorContent.innerHTML !== value) { editorContent.innerHTML = value; } } @@ -217,35 +222,38 @@ export class SkyTextEditorAdapterService { if (this.#textEditorService.editor) { const windowEl = this.#getContentWindowEl(); const iframeDocumentEl = this.#getIframeDocumentEl(); - const currentSelection = this.#selectionService.getCurrentSelectionRange( - iframeDocumentEl, - windowEl, - ); - const cursorIsNotActiveAndHasReset = - currentSelection && - currentSelection.startOffset === 0 && - currentSelection.endOffset === 0; - - if (!this.editorSelected() || cursorIsNotActiveAndHasReset) { - // focus the end of the editor - const documentEl = this.#windowRef.nativeWindow.document; - const editor = iframeDocumentEl.body as HTMLBodyElement; - const range = documentEl.createRange(); - - this.#textEditorService.editor.iframeElementRef.focus(); - editor.focus(); - - if (windowEl.getSelection && documentEl.createRange) { - range.selectNodeContents(editor); - range.collapse(false); - const sel = windowEl.getSelection(); - sel?.removeAllRanges(); - sel?.addRange(range); + if (windowEl && iframeDocumentEl) { + const currentSelection = + this.#selectionService.getCurrentSelectionRange( + iframeDocumentEl, + windowEl, + ); + const cursorIsNotActiveAndHasReset = + currentSelection && + currentSelection.startOffset === 0 && + currentSelection.endOffset === 0; + + if (!this.editorSelected() || cursorIsNotActiveAndHasReset) { + // focus the end of the editor + const documentEl = this.#windowRef.nativeWindow.document; + const editor = iframeDocumentEl.body as HTMLBodyElement; + const range = documentEl.createRange(); + + this.#textEditorService.editor.iframeElementRef.focus(); + editor.focus(); + + if (windowEl.getSelection && documentEl.createRange) { + range.selectNodeContents(editor); + range.collapse(false); + const sel = windowEl.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + } + } else { + // Cursor may not be active, restore it + this.#textEditorService.editor.iframeElementRef.focus(); + iframeDocumentEl.body.focus(); } - } else { - // Cursor may not be active, restore it - this.#textEditorService.editor.iframeElementRef.focus(); - iframeDocumentEl.body.focus(); } } } @@ -273,23 +281,27 @@ export class SkyTextEditorAdapterService { return this.#getParent(selectedEl, 'a') as HTMLAnchorElement; } - /* istanbul ignore next */ return undefined; } public saveSelection(): Range | undefined { - return this.#selectionService.getCurrentSelectionRange( - this.#getIframeDocumentEl(), - this.#getContentWindowEl(), - ); + const documentEl = this.#getIframeDocumentEl(); + const windowEl = this.#getContentWindowEl(); + if (documentEl && windowEl) { + return this.#selectionService.getCurrentSelectionRange( + documentEl, + windowEl, + ); + } + return undefined; } public selectElement(element: HTMLElement): void { - this.#selectionService.selectElement( - this.#getIframeDocumentEl(), - this.#getContentWindowEl(), - element, - ); + const documentEl = this.#getIframeDocumentEl(); + const windowEl = this.#getContentWindowEl(); + if (documentEl && windowEl) { + this.#selectionService.selectElement(documentEl, windowEl, element); + } } public setErrorAttributes( @@ -297,42 +309,44 @@ export class SkyTextEditorAdapterService { errors: ValidationErrors | null, ): void { const documentEl = this.#getIframeDocumentEl(); - documentEl.body.setAttribute('aria-invalid', (!!errors).toString()); + documentEl?.body.setAttribute('aria-invalid', (!!errors).toString()); if (errors && errorId) { - documentEl.body.setAttribute('aria-errormessage', errorId); + documentEl?.body.setAttribute('aria-errormessage', errorId); } else { - documentEl.body.removeAttribute('aria-errormessage'); + documentEl?.body.removeAttribute('aria-errormessage'); } } public setLabelAttribute(label: string | undefined): void { if (label) { const documentEl = this.#getIframeDocumentEl(); - documentEl.body.setAttribute('aria-label', label); + documentEl?.body.setAttribute('aria-label', label); } } public setPlaceholder(placeholder?: string): void { const documentEl = this.#getIframeDocumentEl(); - documentEl.body.setAttribute('data-placeholder', placeholder || ''); + documentEl?.body.setAttribute('data-placeholder', placeholder || ''); } public setRequiredAttribute(required: boolean): void { const documentEl = this.#getIframeDocumentEl(); - documentEl.body.setAttribute('aria-required', required.toString()); + documentEl?.body.setAttribute('aria-required', required.toString()); } public async setFontSize(fontSize: number): Promise { const doc = this.#getIframeDocumentEl(); await this.execCommand({ command: 'fontSize', value: '1' }); - const fontElements: HTMLElement[] = Array.from( - doc.querySelectorAll('font[size="1"]'), - ); - for (const element of fontElements) { - element.removeAttribute('size'); - element.style.fontSize = fontSize + 'px'; + if (doc) { + const fontElements: HTMLElement[] = Array.from( + doc.querySelectorAll('font[size="1"]'), + ); + for (const element of fontElements) { + element.removeAttribute('size'); + element.style.fontSize = fontSize + 'px'; + } + this.#cleanUpBlankStyleTags(doc); } - this.#cleanUpBlankStyleTags(doc); this.focusEditor(); this.#textEditorService.editor.commandChangeObservable.next(); @@ -357,7 +371,7 @@ export class SkyTextEditorAdapterService { } } - #getContentWindowEl(): Window { + #getContentWindowEl(): Window | undefined { return this.#textEditorService.editor.iframeElementRef .contentWindow as Window; } @@ -414,7 +428,7 @@ export class SkyTextEditorAdapterService { }); } - #getIframeDocumentEl(): Document { + #getIframeDocumentEl(): Document | undefined { const contentWindowEl = this.#getContentWindowEl(); /* istanbul ignore else */ if (contentWindowEl) { @@ -452,11 +466,11 @@ export class SkyTextEditorAdapterService { /* istanbul ignore next */ const documentEl = element.contentWindow ? element.contentWindow.document - : (element.contentDocument as Document); + : element.contentDocument; // Firefox bug where we need to open/close to cancel load so it doesn't overwrite attrs - documentEl.open(); - documentEl.close(); + documentEl?.open(); + documentEl?.close(); const selectionObservable = new Subject(); const selectionListener = (): void => selectionObservable.next(); @@ -470,12 +484,12 @@ export class SkyTextEditorAdapterService { const inputObservable = new Subject(); const inputListener = (): void => inputObservable.next(); - documentEl.addEventListener('selectionchange', selectionListener); - documentEl.addEventListener('input', inputListener); - documentEl.addEventListener('mousedown', clickListener); - documentEl.body.addEventListener('paste', pasteListener); - documentEl.body.addEventListener('blur', blurListener); - documentEl.body.addEventListener('focusin', focusListener); + documentEl?.addEventListener('selectionchange', selectionListener); + documentEl?.addEventListener('input', inputListener); + documentEl?.addEventListener('mousedown', clickListener); + documentEl?.body.addEventListener('paste', pasteListener); + documentEl?.body.addEventListener('blur', blurListener); + documentEl?.body.addEventListener('focusin', focusListener); return { blurObservable, clickObservable, @@ -509,9 +523,13 @@ export class SkyTextEditorAdapterService { } #getCurrentSelectionParentElement(): Element | null | undefined { - return this.#selectionService.getCurrentSelectionParentElement( - this.#getIframeDocumentEl(), - ); + const documentEl = this.#getIframeDocumentEl(); + if (documentEl) { + return this.#selectionService.getCurrentSelectionParentElement( + documentEl, + ); + } + return undefined; } #getColor(documentEl: Document, selector: string): string { @@ -560,10 +578,13 @@ export class SkyTextEditorAdapterService { public editorSelected(): boolean { const documentEl = this.#getIframeDocumentEl(); - return this.#selectionService.isElementSelected( - documentEl, - documentEl.body, - ); + if (documentEl) { + return this.#selectionService.isElementSelected( + documentEl, + documentEl.body, + ); + } + return false; } #cleanUpBlankStyleTags(doc: Document): void { @@ -619,7 +640,7 @@ export class SkyTextEditorAdapterService { aFocusableChild.tabIndex = disabled ? -1 : 0; }); } - this.#getIframeDocumentEl().body.setAttribute( + this.#getIframeDocumentEl()?.body.setAttribute( 'contenteditable', disabled ? 'false' : 'true', );