├── .github ├── CODEOWNERS ├── PULL_REQUEST_TEMPLATE │ └── pull_request_template.md ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── codeql.yml ├── .browserslistrc ├── stories ├── viewquery │ ├── Viewquery.component.html │ └── Viewquery.component.ts ├── form-control │ ├── FormControl.component.html │ └── FormControl.component.ts ├── disable │ ├── Disabling.component.html │ └── Disable.component.ts ├── event-binding │ ├── EventBinding.component.html │ └── EventBinding.component.ts ├── readonly │ ├── Readonly.component.html │ └── Readonly.component.ts ├── materialtabs │ ├── MaterialTabs.component.html │ └── MaterialTabs.component.ts ├── data-binding │ ├── DataBinding.component.html │ └── DataBinding.component.ts ├── Settings.ts ├── pipes │ └── Safe.pipe.ts ├── event-forwarding │ ├── EventForwarding.component.ts │ └── EventForwarding.component.html ├── formvalidation │ ├── FormValidation.component.ts │ └── FormValidation.component.html ├── form-with-on-push │ ├── form-with-on-push.html │ └── form-with-on-push.component.ts ├── contentprojection │ └── ContentProjection.component.ts └── Editor.stories.ts ├── tinymce-angular-component ├── src │ ├── main │ │ └── ts │ │ │ ├── public_api.ts │ │ │ ├── utils │ │ │ ├── DisabledUtils.ts │ │ │ ├── ScriptLoader.ts │ │ │ └── Utils.ts │ │ │ ├── editor │ │ │ ├── editor.module.ts │ │ │ ├── Events.ts │ │ │ └── editor.component.ts │ │ │ └── TinyMCE.ts │ └── test │ │ └── ts │ │ ├── alien │ │ ├── InitTestEnvironment.ts │ │ ├── TestHelpers.ts │ │ └── TestHooks.ts │ │ └── browser │ │ ├── NgZoneTest.ts │ │ ├── EventBlacklistingTest.ts │ │ ├── LoadTinyTest.ts │ │ ├── NgModelTest.ts │ │ ├── PropTest.ts │ │ ├── FormControlTest.ts │ │ └── DisabledPropertyTest.ts ├── ng-package.json └── package.json ├── .gitignore ├── SECURITY.md ├── .editorconfig ├── .storybook ├── preview.ts ├── main.ts └── tsconfig.json ├── CONTRIBUTING.md ├── tsconfig.json ├── LICENSE.txt ├── angular.json ├── Jenkinsfile ├── README.md ├── eslint.config.mjs ├── package.json └── CHANGELOG.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @tinymce/tinymce-reviewers 2 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | >0.2% 2 | not dead 3 | not ie > 0 4 | not op_mini all 5 | -------------------------------------------------------------------------------- /stories/viewquery/Viewquery.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /stories/form-control/FormControl.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tinymce-angular-component/src/main/ts/public_api.ts: -------------------------------------------------------------------------------- 1 | export * from './editor/editor.module'; 2 | export { EventObj } from './editor/Events'; 3 | export { EditorComponent, TINYMCE_SCRIPT_SRC } from './editor/editor.component'; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /lib 6 | /scratch 7 | 8 | # dependencies 9 | /node_modules 10 | /out* 11 | /.angular/cache 12 | storybook-static 13 | -------------------------------------------------------------------------------- /stories/disable/Disabling.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /stories/event-binding/EventBinding.component.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tinymce-angular-component/src/main/ts/utils/DisabledUtils.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from 'tinymce'; 2 | 3 | const isDisabledOptionSupported = (editor: Editor) => editor.options && editor.options.isRegistered('disabled'); 4 | 5 | export { 6 | isDisabledOptionSupported 7 | }; 8 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | For details on how to report security issues to Tiny, refer to the [Reporting TinyMCE security issues documentation](https://www.tiny.cloud/docs/tinymce/latest/security/#reportingtinymcesecurityissues). 6 | -------------------------------------------------------------------------------- /stories/readonly/Readonly.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tinymce-angular-component/src/main/ts/editor/editor.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { EditorComponent } from './editor.component'; 4 | 5 | @NgModule({ 6 | imports: [ EditorComponent ], 7 | exports: [ EditorComponent ] 8 | }) 9 | export class EditorModule {} 10 | -------------------------------------------------------------------------------- /tinymce-angular-component/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../dist/tinymce-angular", 4 | "lib": { 5 | "entryFile": "./src/main/ts/public_api.ts" 6 | }, 7 | "allowedNonPeerDependencies": [ 8 | "tinymce" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from "@storybook/angular"; 2 | 3 | const preview: Preview = { 4 | parameters: { 5 | actions: { argTypesRegex: "^on[A-Z].*" }, 6 | controls: { 7 | matchers: { 8 | color: /(background|color)$/i, 9 | date: /Date$/i, 10 | }, 11 | }, 12 | }, 13 | }; 14 | 15 | export default preview; 16 | -------------------------------------------------------------------------------- /stories/materialtabs/MaterialTabs.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/pull_request_template.md: -------------------------------------------------------------------------------- 1 | **Before submitting a pull request** please do the following: 2 | 3 | 1. Fork [the repository](https://github.com/tinymce/tinymce-angular) and create your branch from `master` 4 | 2. Have you added some code that should be tested? Write some tests! (Are you unsure how to write the test you want to write, ask us for help!) 5 | 3. Ensure that the tests pass: `npm run test` 6 | 4. Make sure to sign the CLA. -------------------------------------------------------------------------------- /tinymce-angular-component/src/main/ts/TinyMCE.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Ephox, Inc. 3 | * 4 | * This source code is licensed under the Apache 2 license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | const getTinymce = () => { 10 | const w = typeof window !== 'undefined' ? (window as any) : undefined; 11 | return w && w.tinymce ? w.tinymce : null; 12 | }; 13 | 14 | export { getTinymce }; 15 | -------------------------------------------------------------------------------- /stories/disable/Disable.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { apiKey, sampleContent } from '../Settings'; 3 | 4 | @Component({ 5 | selector: 'disabling', 6 | templateUrl: './Disabling.component.html', 7 | }) 8 | export class DisablingComponent { 9 | public isDisabled = false; 10 | public apiKey = apiKey; 11 | public initialValue = sampleContent; 12 | public toggleDisabled = () => (this.isDisabled = !this.isDisabled); 13 | } 14 | -------------------------------------------------------------------------------- /stories/readonly/Readonly.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { apiKey, sampleContent } from '../Settings'; 3 | 4 | @Component({ 5 | selector: 'readonly', 6 | templateUrl: './Readonly.component.html', 7 | }) 8 | export class ReadonlyComponent { 9 | public isReadonly = false; 10 | public apiKey = apiKey; 11 | public initialValue = sampleContent; 12 | public toggleReadonly = () => (this.isReadonly = !this.isReadonly); 13 | } 14 | -------------------------------------------------------------------------------- /tinymce-angular-component/src/test/ts/alien/InitTestEnvironment.ts: -------------------------------------------------------------------------------- 1 | import 'core-js/features/reflect'; 2 | import 'zone.js'; 3 | import 'zone.js/plugins/fake-async-test'; 4 | 5 | import { TestBed } from '@angular/core/testing'; 6 | import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; 7 | 8 | TestBed.initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), { 9 | teardown: { destroyAfterEach: true }, 10 | }); 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This project follows the [beehive-flow](https://github.com/tinymce/beehive-flow/) branching process ("Basic process - release from main branch"). 4 | Please read the [beehive-flow readme](https://github.com/tinymce/beehive-flow/blob/main/README.md) for more information. 5 | This mainly affects branching and merging for Tiny staff. 6 | 7 | External contributors are free to submit PRs against the `main` branch. 8 | Note that contributions will require signing of our Contributor License Agreement. 9 | -------------------------------------------------------------------------------- /stories/data-binding/DataBinding.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 13 | 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest a feature for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from "@storybook/angular"; 2 | 3 | const config: StorybookConfig = { 4 | stories: [ 5 | "../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)", 6 | ], 7 | addons: [ 8 | "@storybook/addon-links", 9 | "@storybook/addon-essentials", 10 | "@storybook/addon-interactions", 11 | ], 12 | framework: { 13 | name: "@storybook/angular", 14 | options: {}, 15 | }, 16 | docs: { 17 | autodocs: "tag", 18 | }, 19 | core: { 20 | disableTelemetry: true 21 | } 22 | }; 23 | export default config; 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "sourceMap": true, 6 | "declaration": false, 7 | "moduleResolution": "node", 8 | "noUnusedLocals": true, 9 | "strict": true, 10 | "emitDecoratorMetadata": true, 11 | "experimentalDecorators": true, 12 | "target": "es5", 13 | "module": "es2015", 14 | "typeRoots": [ 15 | "node_modules/@types" 16 | ], 17 | "lib": [ 18 | "es2017", 19 | "dom" 20 | ] 21 | }, 22 | "include": [ 23 | "tinymce-angular-component/src/**/*", 24 | "stories/**/*" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What is the current behavior?** 11 | 12 | **Please provide the steps to reproduce and if possible a minimal demo of the problem via [codesandbox.io](https://codesandbox.io/p/sandbox/elegant-pare-6sxwqz?file=src/app/app.component.ts) or similar.** 13 | 14 | **What is the expected behavior?** 15 | 16 | **Which versions of TinyMCE/TinyMCE-Angular, and which browser / OS are affected by this issue? Did this work in previous versions of TinyMCE or TinyMCE-Angular?** 17 | -------------------------------------------------------------------------------- /.storybook/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | // Caretaker note: this is set to skip type-checking in third-party libraries, e.g. in `@ephox/sugar`. 5 | // Since it throws an error: 6 | // ERROR TS2536: Type 'K' cannot be used to index type 'HTMLElementTagNameMap'. 7 | // ERROR in @ephox/sugar/lib/main/ts/ephox/sugar/api/dom/Replication.d.ts 8 | "skipLibCheck": true, 9 | "types": ["node"], 10 | "allowSyntheticDefaultImports": true, 11 | "resolveJsonModule": true, 12 | "target": "ES2022" 13 | }, 14 | "include": ["../stories/**/*.ts", "./preview.ts"] 15 | } 16 | -------------------------------------------------------------------------------- /stories/Settings.ts: -------------------------------------------------------------------------------- 1 | 2 | const apiKey = 'qagffr3pkuv17a8on1afax661irst1hbr4e6tbv888sz91jc'; 3 | const modelEvents = 'change input undo redo'; 4 | const sampleContent = ` 5 |

6 | TinyMCE provides a full-featured rich text editing experience, and a featherweight download. 7 |

8 |

9 | No matter what you are building, TinyMCE has got you covered. 10 |

`; 11 | 12 | export { 13 | apiKey, 14 | modelEvents, 15 | sampleContent 16 | }; -------------------------------------------------------------------------------- /stories/pipes/Safe.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; 3 | 4 | @Pipe({ 5 | name: 'safe' 6 | }) 7 | export class SafePipe implements PipeTransform { 8 | 9 | // eslint-disable-next-line @typescript-eslint/parameter-properties 10 | public constructor(protected sanitizer: DomSanitizer) {} 11 | 12 | public transform(value: string, type: string): SafeHtml { 13 | switch (type) { 14 | case 'html': 15 | return this.sanitizer.bypassSecurityTrustHtml(value); 16 | default: 17 | throw new Error(`Invalid safe type specified: ${type}`); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tinymce-angular-component/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tinymce/tinymce-angular", 3 | "description": "Official TinyMCE Angular Component", 4 | "version": "", 5 | "repository": "https://github.com/tinymce/tinymce-angular.git", 6 | "author": "Ephox Corporation DBA Tiny Technologies, Inc.", 7 | "license": "MIT", 8 | "private": false, 9 | "peerDependencies": { 10 | "@angular/core": ">=16.0.0", 11 | "@angular/common": ">=16.0.0", 12 | "@angular/forms": ">=16.0.0", 13 | "rxjs": "^7.4.0", 14 | "tinymce": "^8.0.0 || ^7.0.0 || ^6.0.0 || ^5.5.0" 15 | }, 16 | "peerDependenciesMeta": { 17 | "tinymce": { 18 | "optional": true 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /stories/viewquery/Viewquery.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ViewChild } from '@angular/core'; 2 | import { EditorComponent } from '../../tinymce-angular-component/src/main/ts/editor/editor.component'; 3 | import { apiKey } from '../Settings'; 4 | 5 | @Component({ 6 | selector: 'view-query', 7 | templateUrl: './Viewquery.component.html' 8 | }) 9 | export class ViewQueryComponent { 10 | @ViewChild(EditorComponent, { static: true }) public editorComponent!: EditorComponent; 11 | public apiKey = apiKey; 12 | 13 | public undo() { 14 | this.editorComponent.editor?.undoManager.undo(); 15 | } 16 | 17 | public redo() { 18 | this.editorComponent.editor?.undoManager.redo(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /stories/data-binding/DataBinding.component.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { Component } from '@angular/core'; 3 | import { apiKey, modelEvents, sampleContent } from '../Settings'; 4 | 5 | @Component({ 6 | selector: 'binding', 7 | templateUrl: './DataBinding.component.html' 8 | }) 9 | export class BindingComponent { 10 | public isEditingContent = true; 11 | public content = sampleContent; 12 | public apiKey = apiKey; 13 | public modelEvents = modelEvents; 14 | 15 | public editContent() { 16 | this.isEditingContent = !this.isEditingContent; 17 | } 18 | 19 | public log({ event, editor }: any) { 20 | console.log(event); 21 | console.log(editor.getContent()); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /stories/event-forwarding/EventForwarding.component.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { Component } from '@angular/core'; 3 | import { apiKey } from '../Settings'; 4 | 5 | @Component({ 6 | selector: 'event-forwarding', 7 | templateUrl: './EventForwarding.component.html', 8 | }) 9 | export class EventForwardingComponent { 10 | public apiKey = apiKey; 11 | public allowed = [ 'onMouseLeave', 'onMouseEnter' ]; 12 | public ignore = [ 'onMouseLeave' ]; 13 | public fieldValue = 'some value'; 14 | public initObject = { 15 | height: 260, 16 | }; 17 | 18 | public logMouseEnter() { 19 | console.log('Log mouse enter'); 20 | } 21 | 22 | public logMouseLeave() { 23 | console.log('Log mouse leave'); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /stories/formvalidation/FormValidation.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { apiKey } from '../Settings'; 3 | 4 | @Component({ 5 | selector: 'blog', 6 | templateUrl: './FormValidation.component.html', 7 | styles: [ ` 8 | .valid { 9 | border: 2px solid rgb(138, 201, 138); 10 | } 11 | 12 | .invalid { 13 | border: 2px solid rgb(255, 108, 103); 14 | } 15 | 16 | .preview { 17 | border: 1px solid rgb(190, 190, 190); 18 | } 19 | ` ] 20 | }) 21 | export class BlogComponent { 22 | public submitted = false; 23 | public post = { title: '', content: '' }; 24 | public apiKey = apiKey; 25 | 26 | public onSubmit() { 27 | this.submitted = !this.submitted; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /stories/form-with-on-push/form-with-on-push.html: -------------------------------------------------------------------------------- 1 |
2 | 8 |
9 | 14 |
15 | 16 | 17 | 18 |
19 |
20 | Pristine: {{ form.pristine }}
21 | Touched: {{ form.touched }}
22 | Dirty: {{ form.dirty }}
23 | Valid: {{ form.valid }}
24 | Data: {{ form.value | json }}
25 | 
26 | -------------------------------------------------------------------------------- /stories/form-control/FormControl.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { FormBuilder, FormControl } from '@angular/forms'; 3 | import { apiKey } from 'stories/Settings'; 4 | 5 | @Component({ 6 | selector: 'form-control', 7 | templateUrl: './FormControl.component.html', 8 | }) 9 | export class FormControlComponent { 10 | public apiKey = apiKey; 11 | public formControl: FormControl; 12 | 13 | // eslint-disable-next-line @typescript-eslint/parameter-properties 14 | public constructor(private readonly formBuilder: FormBuilder) { 15 | this.formControl = this.formBuilder.control(null); 16 | // eslint-disable-next-line no-console 17 | this.formControl.valueChanges.subscribe(console.log); 18 | this.formControl.setValue('

Initial value

'); 19 | // Console log should be triggered just once 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /stories/materialtabs/MaterialTabs.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ViewChild, AfterViewInit } from '@angular/core'; 2 | import { MatTabChangeEvent } from '@angular/material/tabs'; 3 | import { apiKey } from '../Settings'; 4 | 5 | @Component({ 6 | selector: 'material-tabs', 7 | templateUrl: './MaterialTabs.component.html' 8 | }) 9 | export class MaterialTabs implements AfterViewInit { 10 | @ViewChild('tabGroup', { static: false }) public tabGroup: any; 11 | public apiKey = apiKey; 12 | public activeTabIndex: number | undefined = undefined; 13 | public firstEditorValue = 'First editor initial value'; 14 | public secondEditorValue = 'Second editor initial value'; 15 | 16 | public handleTabChange(e: MatTabChangeEvent) { 17 | this.activeTabIndex = e.index; 18 | } 19 | 20 | public ngAfterViewInit() { 21 | this.activeTabIndex = this.tabGroup.selectedIndex; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /stories/event-binding/EventBinding.component.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { Component } from '@angular/core'; 3 | import { apiKey } from '../Settings'; 4 | 5 | @Component({ 6 | selector: 'event-binding', 7 | templateUrl: './EventBinding.component.html', 8 | }) 9 | export class EventBindingComponent { 10 | public apiKey = apiKey; 11 | public fieldValue = 'some value'; 12 | public initObject = { 13 | height: 500, 14 | setup: (editor: any) => { 15 | editor.on('SetContent', (_e: any) => this.tinySetContent()); 16 | editor.on('Init', () => this.tinyInit()); 17 | } 18 | }; 19 | 20 | public tinySetContent() { 21 | console.log('set by tiny'); 22 | } 23 | 24 | public angularSetContent() { 25 | console.log('set by angular'); 26 | } 27 | 28 | public tinyInit() { 29 | console.log('init by tiny'); 30 | } 31 | 32 | public angularInit() { 33 | console.log('init by angular'); 34 | } 35 | 36 | public realAngularInit(e1: any) { 37 | console.log('Ready NgModel', e1); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /stories/event-forwarding/EventForwarding.component.html: -------------------------------------------------------------------------------- 1 | 10 | 11 | 20 | 21 | 29 | 30 | -------------------------------------------------------------------------------- /stories/form-with-on-push/form-with-on-push.component.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/unbound-method */ 2 | import { 3 | Component, 4 | ChangeDetectionStrategy, 5 | Input, 6 | } from '@angular/core'; 7 | import { FormControl, FormGroup, Validators } from '@angular/forms'; 8 | import type { EditorComponent } from '../../tinymce-angular-component/src/main/ts/public_api'; 9 | import { apiKey } from 'stories/Settings'; 10 | 11 | @Component({ 12 | selector: 'form-with-on-push', 13 | templateUrl: './form-with-on-push.html', 14 | changeDetection: ChangeDetectionStrategy.OnPush, 15 | }) 16 | export class FormWithOnPushComponent { 17 | @Input() public apiKey = apiKey; 18 | public readonly initialValue = ''; 19 | public readonly init: EditorComponent['init'] = { 20 | plugins: [ 'help' ], 21 | }; 22 | public readonly form = new FormGroup({ 23 | tiny: new FormControl('', { 24 | validators: Validators.compose([ 25 | Validators.required, 26 | Validators.minLength(10) 27 | ]), 28 | }), 29 | regular: new FormControl(''), 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ephox Corporation DBA Tiny Technologies, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "projects": { 5 | "angular": { 6 | "projectType": "library", 7 | "root": "", 8 | "architect": { 9 | "build": { 10 | "builder": "@angular-devkit/build-angular:browser", 11 | "options": { 12 | "outputPath": "dist/tinymce-angular", 13 | "index": "", 14 | "main": "", 15 | "tsConfig": "tsconfig.json" 16 | } 17 | }, 18 | "storybook": { 19 | "builder": "@storybook/angular:start-storybook", 20 | "options": { 21 | "configDir": ".storybook", 22 | "browserTarget": "angular:build", 23 | "compodoc": false, 24 | "port": 9001 25 | } 26 | }, 27 | "build-storybook": { 28 | "builder": "@storybook/angular:build-storybook", 29 | "options": { 30 | "configDir": ".storybook", 31 | "browserTarget": "angular:build", 32 | "compodoc": false, 33 | "outputDir": "storybook-static" 34 | } 35 | } 36 | } 37 | } 38 | }, 39 | "cli": { 40 | "analytics": false, 41 | "packageManager": "yarn" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /stories/formvalidation/FormValidation.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 12 |
13 | 14 |
15 | 23 |
24 | 25 |
26 | 27 | 28 | 29 |
30 |
31 |
32 | 33 |
34 |

Title: {{post.title}}

35 |
36 | 37 |
Raw: {{post | json}}
38 | 39 |
40 | 41 |
42 |
43 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | #!groovy 2 | @Library('waluigi@release/7') _ 3 | 4 | mixedBeehiveFlow( 5 | testPrefix: 'Tiny-Angular', 6 | testDirs: [ "tinymce-angular-component/src/test/ts/browser" ], 7 | platforms: [ 8 | [ browser: 'chrome', headless: true ], 9 | [ browser: 'firefox', provider: 'aws', buckets: 1 ], 10 | [ browser: 'safari', provider: 'lambdatest', os: 'macOS Sonoma', buckets: 1 ] 11 | ], 12 | testContainer: [ 13 | tag: '20', 14 | resourceRequestMemory: '4Gi', 15 | resourceLimitCpu: '4', 16 | resourceLimitMemory: '4Gi', 17 | selenium: [ image: tinyAws.getPullThroughCacheImage("selenium/standalone-chrome", "127.0") ] 18 | ], 19 | publishContainer: [ 20 | resourceRequestMemory: '4Gi', 21 | resourceLimitMemory: '4Gi' 22 | ], 23 | customSteps: { 24 | stage("update storybook") { 25 | def status = beehiveFlowStatus() 26 | if (status.branchState == 'releaseReady' && status.isLatest) { 27 | tinyGit.withGitHubSSHCredentials { 28 | exec('yarn deploy-storybook') 29 | } 30 | } else { 31 | echo "Skipping as is not latest release" 32 | } 33 | } 34 | }, 35 | publish: { 36 | sh "yarn build" 37 | tinyNpm.withNpmPublishCredentials('dist/tinymce-angular') { 38 | sh "yarn beehive-flow publish --working-dir dist/tinymce-angular" 39 | } 40 | } 41 | ) 42 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | ## Github workflow code scanning 4 | # Configure this file to setup code scanning for the repository 5 | # Code scanning uses Github actions minutes in private repos. To learn more: https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions 6 | 7 | on: 8 | push: 9 | branches: [ "main" ] 10 | ## Filter based on files changed: ts and js only for Angular 11 | # paths: [ "**.ts", "**.js" ] 12 | pull_request: 13 | branches: [ "main" ] 14 | ## Filter based on files changed: ts and js only for Angular 15 | # paths: [ "**.ts", "**.js" ] 16 | ## Specify schedule cron if needed 17 | # schedule: 18 | # - cron: "0 0 1 * *" 19 | 20 | jobs: 21 | analyze: 22 | name: Analyze 23 | runs-on: ubuntu-latest 24 | permissions: 25 | actions: read 26 | contents: read 27 | security-events: write 28 | 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | language: [ javascript ] 33 | 34 | steps: 35 | - name: Checkout 36 | uses: actions/checkout@v3 37 | 38 | - name: Initialize CodeQL 39 | uses: github/codeql-action/init@v3 40 | with: 41 | languages: ${{ matrix.language }} 42 | queries: +security-and-quality 43 | 44 | - name: Autobuild 45 | uses: github/codeql-action/autobuild@v3 46 | 47 | - name: Perform CodeQL Analysis 48 | uses: github/codeql-action/analyze@v3 49 | with: 50 | category: "/language:${{ matrix.language }}" 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Official TinyMCE Angular Component 2 | 3 | ## About 4 | 5 | This package is a thin wrapper around [TinyMCE](https://github.com/tinymce/tinymce) to make it easier to use in an Angular application. 6 | 7 | * If you need detailed documentation on TinyMCE, see: [TinyMCE Documentation](https://www.tiny.cloud/docs/tinymce/8/). 8 | * For the TinyMCE Angular Quick Start, see: [TinyMCE Documentation - Angular Integration](https://www.tiny.cloud/docs/tinymce/8/angular-cloud/). 9 | * For the TinyMCE Angular Technical Reference, see: [TinyMCE Documentation - TinyMCE Angular Technical Reference](https://www.tiny.cloud/docs/tinymce/8/angular-ref/). 10 | * For our quick demos, check out the TinyMCE Angular [Storybook](https://tinymce.github.io/tinymce-angular/). 11 | 12 | ### Support 13 | 14 | |Angular version|`tinymce-angular` version| 15 | |--- |--- | 16 | |16+ |8+ | 17 | |14+ |7.x | 18 | |13+ |6.x | 19 | |9+ |4.x | 20 | |<= 8 |3.x | 21 | |< 5 | Not supported | 22 | 23 | ### Issues 24 | 25 | Have you found an issue with tinymce-angular or do you have a feature request? 26 | Open up an [issue](https://github.com/tinymce/tinymce-angular/issues) and let us know 27 | or submit a [pull request](https://github.com/tinymce/tinymce-angular/pulls). 28 | 29 | _Note: for issues concerning TinyMCE please visit the [TinyMCE repository](https://github.com/tinymce/tinymce)._ 30 | -------------------------------------------------------------------------------- /tinymce-angular-component/src/test/ts/browser/NgZoneTest.ts: -------------------------------------------------------------------------------- 1 | import '../alien/InitTestEnvironment'; 2 | 3 | import { NgZone } from '@angular/core'; 4 | import { Assertions } from '@ephox/agar'; 5 | import { describe, it } from '@ephox/bedrock-client'; 6 | 7 | import { EditorComponent } from '../../../main/ts/editor/editor.component'; 8 | import { eachVersionContext, fixtureHook } from '../alien/TestHooks'; 9 | import { first } from 'rxjs'; 10 | import { throwTimeout } from '../alien/TestHelpers'; 11 | 12 | describe('NgZoneTest', () => { 13 | eachVersionContext([ '4', '5', '6', '7', '8' ], () => { 14 | const createFixture = fixtureHook(EditorComponent, { imports: [ EditorComponent ] }); 15 | 16 | it('Subscribers to events should run within NgZone', async () => { 17 | const fixture = createFixture(); 18 | const editor = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | await new Promise((resolve) => { 21 | editor.onInit.pipe(first(), throwTimeout(10000, 'Timed out waiting for init event')).subscribe(() => { 22 | Assertions.assertEq('Subscribers to onInit should run within NgZone', true, NgZone.isInAngularZone()); 23 | resolve(); 24 | }); 25 | }); 26 | }); 27 | 28 | // Lets just test one EventEmitter, if one works all should work 29 | it('Subscribers to onKeyUp should run within NgZone', async () => { 30 | const fixture = createFixture(); 31 | const editor = fixture.componentInstance; 32 | fixture.detectChanges(); 33 | await new Promise((resolve) => { 34 | editor.onKeyUp.pipe(first(), throwTimeout(10000, 'Timed out waiting for key up event')).subscribe(() => { 35 | Assertions.assertEq('Subscribers to onKeyUp should run within NgZone', true, NgZone.isInAngularZone()); 36 | resolve(); 37 | }); 38 | editor.editor?.fire('keyup'); 39 | }); 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /tinymce-angular-component/src/main/ts/utils/ScriptLoader.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Ephox, Inc. 3 | * 4 | * This source code is licensed under the Apache 2 license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import { fromEvent, Observable, shareReplay, switchMap, BehaviorSubject, first, filter, map } from 'rxjs'; 10 | 11 | interface ScriptLoader { 12 | load: (doc: Document, url: string) => Observable; 13 | /** Intended to only to be used by tests. */ 14 | reinitialize: () => void; 15 | } 16 | 17 | const firstEmission = () => (source: Observable): Observable => source.pipe(first(), map(() => undefined)); 18 | 19 | const CreateScriptLoader = (): ScriptLoader => { 20 | const params$ = new BehaviorSubject | null>(null); 21 | const loaded$: Observable = params$.pipe( 22 | filter(Boolean), 23 | switchMap(([ doc, url ]) => { 24 | const scriptTag = doc.createElement('script'); 25 | scriptTag.referrerPolicy = 'origin'; 26 | scriptTag.type = 'application/javascript'; 27 | scriptTag.src = url; 28 | doc.head.appendChild(scriptTag); 29 | return fromEvent(scriptTag, 'load').pipe(firstEmission()); 30 | }), 31 | // Caretaker note: `loaded$` is a multicast observable since it's piped with `shareReplay`, 32 | // so if there're multiple editor components simultaneously on the page, they'll subscribe to the internal 33 | // `ReplaySubject`. The script will be loaded only once, and `ReplaySubject` will cache the result. 34 | shareReplay({ bufferSize: 1, refCount: true }) 35 | ); 36 | 37 | return { 38 | load: (...args) => { 39 | if (!params$.getValue()) { 40 | params$.next(args); 41 | } 42 | return loaded$; 43 | }, 44 | reinitialize: () => { 45 | params$.next(null); 46 | }, 47 | }; 48 | }; 49 | 50 | const ScriptLoader = CreateScriptLoader(); 51 | 52 | export { ScriptLoader }; 53 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // eslint.config.js 2 | import { defineConfig } from 'eslint/config'; 3 | import tinymceEslintPlugin from '@tinymce/eslint-plugin'; 4 | import js from '@eslint/js'; 5 | 6 | import pluginChaiFriendly from 'eslint-plugin-chai-friendly'; 7 | 8 | export default defineConfig([ 9 | { 10 | plugins: { 11 | '@tinymce': tinymceEslintPlugin 12 | }, 13 | extends: [ '@tinymce/standard' ], 14 | files: [ 15 | 'tinymce-angular-component/src/**/*.ts', 16 | 'stories/**/*.ts' 17 | ], 18 | ignores: [ 19 | 'src/demo/demo.ts' 20 | ], 21 | languageOptions: { 22 | parserOptions: { 23 | sourceType: 'module', 24 | project: [ 25 | './tsconfig.json' 26 | ] 27 | }, 28 | }, 29 | rules: { 30 | '@tinymce/prefer-fun': 'off', 31 | 'no-underscore-dangle': 'off', 32 | '@typescript-eslint/member-ordering': 'off', 33 | } 34 | }, 35 | { 36 | files: [ 37 | '**/*.js' 38 | ], 39 | env: { 40 | es6: true, 41 | node: true, 42 | browser: true 43 | }, 44 | plugins: { js }, 45 | extends: [ 'js/recommended' ], 46 | parser: 'espree', 47 | languageOptions: { 48 | parserOptions: { 49 | ecmaVersion: 2020, 50 | sourceType: 'module' 51 | }, 52 | }, 53 | rules: { 54 | 'indent': [ 'error', 2, { 'SwitchCase': 1 } ], 55 | 'no-shadow': 'error', 56 | 'no-unused-vars': [ 'error', { 'argsIgnorePattern': '^_' } ], 57 | 'object-curly-spacing': [ 'error', 'always', { 'arraysInObjects': false, 'objectsInObjects': false } ], 58 | 'quotes': [ 'error', 'single' ], 59 | 'semi': 'error' 60 | } 61 | }, 62 | { 63 | files: [ 64 | '**/*Test.ts', 65 | '**/test/**/*.ts' 66 | ], 67 | plugins: { 68 | 'chai-friendly': pluginChaiFriendly 69 | }, 70 | rules: { 71 | 'no-unused-expressions': 'off', 72 | 'no-console': 'off', 73 | 'max-classes-per-file': 'off', 74 | '@typescript-eslint/no-non-null-assertion': 'off' 75 | } 76 | } 77 | ]); 78 | -------------------------------------------------------------------------------- /tinymce-angular-component/src/test/ts/browser/EventBlacklistingTest.ts: -------------------------------------------------------------------------------- 1 | import '../alien/InitTestEnvironment'; 2 | 3 | import { describe, it } from '@ephox/bedrock-client'; 4 | 5 | import { EditorComponent } from '../../../main/ts/public_api'; 6 | import { eachVersionContext, editorHook } from '../alien/TestHooks'; 7 | import { map, merge, timer, first, buffer, Observable, tap, firstValueFrom } from 'rxjs'; 8 | import { NgZone } from '@angular/core'; 9 | import { Assertions } from '@ephox/agar'; 10 | import { Fun } from '@ephox/katamari'; 11 | import { throwTimeout } from '../alien/TestHelpers'; 12 | 13 | describe('EventBlacklistingTest', () => { 14 | const shouldRunInAngularZone = (source: Observable) => 15 | source.pipe( 16 | tap(() => Assertions.assertEq('Subscribers to events should run within NgZone', true, NgZone.isInAngularZone())) 17 | ); 18 | 19 | eachVersionContext([ '4', '5', '6', '7', '8' ], () => { 20 | const createFixture = editorHook(EditorComponent); 21 | 22 | it('Events should be bound when allowed', async () => { 23 | const fixture = await createFixture({ 24 | allowedEvents: 'onKeyUp,onClick,onInit', 25 | ignoreEvents: 'onClick', 26 | }); 27 | 28 | const pEventsCompleted = firstValueFrom( 29 | merge( 30 | fixture.editorComponent.onKeyUp.pipe(map(Fun.constant('onKeyUp')), shouldRunInAngularZone), 31 | fixture.editorComponent.onKeyDown.pipe(map(Fun.constant('onKeyDown')), shouldRunInAngularZone), 32 | fixture.editorComponent.onClick.pipe(map(Fun.constant('onClick')), shouldRunInAngularZone) 33 | ).pipe(throwTimeout(10000, 'Timed out waiting for some event to fire'), buffer(timer(100)), first()) 34 | ); 35 | fixture.editor.fire('keydown'); 36 | fixture.editor.fire('keyclick'); 37 | fixture.editor.fire('keyup'); 38 | const eventsCompleted = await pEventsCompleted; 39 | Assertions.assertEq('Only one event should have fired', 1, eventsCompleted.length); 40 | Assertions.assertEq('Only keyup should fire', 'onKeyUp', eventsCompleted[0]); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /stories/contentprojection/ContentProjection.component.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | import { Component, TemplateRef, Input } from '@angular/core'; 3 | import { apiKey } from '../Settings'; 4 | 5 | /* 6 | Normally when projecting content you would use ng-content but this is not something that works 7 | with TinyMCE due to its usage of an iframe (when in iframe mode). The reason for this is that 8 | the lifecycle of ng-content is controlled by its parent view and not where it's consumed. In 9 | other words, the projected content can be initialized and destroyed when not actually being in 10 | the DOM. Iframes will re-render whenever they are detached/attached to the DOM and thus this 11 | breaks TinyMCE. 12 | 13 | A workaround is to use a template outlet instead of content projection. The result is what you 14 | would expect from content projection, but with the lifecycle being in sync to where the content 15 | is being consumed. 16 | */ 17 | 18 | @Component({ 19 | selector: 'container', 20 | styles: [ ` 21 | .container { 22 | border: 1px solid blue; 23 | display: block; 24 | padding: 15px; 25 | } 26 | ` ], 27 | template: ` 28 | 29 |

I am a placeholder.

30 |
31 | 32 |
33 | 34 | 35 |
36 | ` 37 | }) 38 | export class ContainerComponent { 39 | @Input() public editorTemplate!: TemplateRef; 40 | public show = true; 41 | 42 | public handleToggle() { 43 | this.show = !this.show; 44 | } 45 | } 46 | 47 | @Component({ 48 | selector: 'content-projection', 49 | template: ` 50 | 51 | 52 | 53 | 54 | 55 | ` 56 | }) 57 | export class ContentProjectionComponent { 58 | public apiKey = apiKey; 59 | public editorValue = ''; 60 | } 61 | -------------------------------------------------------------------------------- /tinymce-angular-component/src/test/ts/alien/TestHelpers.ts: -------------------------------------------------------------------------------- 1 | import { Fun, Global, Arr, Strings } from '@ephox/katamari'; 2 | import { Observable, throwError, timeout } from 'rxjs'; 3 | import { ScriptLoader } from '../../../main/ts/utils/ScriptLoader'; 4 | import { Attribute, Remove, SelectorFilter, SugarElement } from '@ephox/sugar'; 5 | import { ComponentFixture } from '@angular/core/testing'; 6 | import { By } from '@angular/platform-browser'; 7 | import { EditorComponent } from '../../../main/ts/editor/editor.component'; 8 | import type { Editor } from 'tinymce'; 9 | import { Keyboard, Keys } from '@ephox/agar'; 10 | 11 | export const apiKey = Fun.constant('qagffr3pkuv17a8on1afax661irst1hbr4e6tbv888sz91jc'); 12 | 13 | export const throwTimeout = 14 | (timeoutMs: number, message: string = `Timeout ${timeoutMs}ms`) => 15 | (source: Observable) => 16 | source.pipe( 17 | timeout({ 18 | first: timeoutMs, 19 | with: () => throwError(() => new Error(message)), 20 | }) 21 | ); 22 | 23 | export const deleteTinymce = () => { 24 | ScriptLoader.reinitialize(); 25 | 26 | delete Global.tinymce; 27 | delete Global.tinyMCE; 28 | 29 | const hasTinyUri = (attrName: string) => (elm: SugarElement) => 30 | Attribute.getOpt(elm, attrName).exists((src) => Strings.contains(src, 'tinymce')); 31 | 32 | const elements = Arr.flatten([ 33 | Arr.filter(SelectorFilter.all('script'), hasTinyUri('src')), 34 | Arr.filter(SelectorFilter.all('link'), hasTinyUri('href')), 35 | ]); 36 | 37 | Arr.each(elements, Remove.remove); 38 | }; 39 | 40 | export const captureLogs = async ( 41 | method: 'log' | 'warn' | 'debug' | 'error', 42 | fn: () => Promise | void 43 | ): Promise => { 44 | const original = console[method]; 45 | try { 46 | const logs: unknown[][] = []; 47 | console[method] = (...args: unknown[]) => logs.push(args); 48 | await fn(); 49 | return logs; 50 | } finally { 51 | console[method] = original; 52 | } 53 | }; 54 | 55 | export const fakeTypeInEditor = (fixture: ComponentFixture, str: string) => { 56 | const editor: Editor = fixture.debugElement.query(By.directive(EditorComponent)).componentInstance.editor!; 57 | editor.getBody().innerHTML = '

' + str + '

'; 58 | Keyboard.keystroke(Keys.space(), {}, SugarElement.fromDom(editor.getBody())); 59 | fixture.detectChanges(); 60 | }; 61 | -------------------------------------------------------------------------------- /tinymce-angular-component/src/test/ts/browser/LoadTinyTest.ts: -------------------------------------------------------------------------------- 1 | import '../alien/InitTestEnvironment'; 2 | 3 | import { Assertions } from '@ephox/agar'; 4 | import { describe, it, context, before } from '@ephox/bedrock-client'; 5 | import { Global } from '@ephox/katamari'; 6 | 7 | import { EditorComponent, TINYMCE_SCRIPT_SRC } from '../../../main/ts/public_api'; 8 | import { Version } from '../../../main/ts/editor/editor.component'; 9 | import { editorHook, tinymceVersionHook } from '../alien/TestHooks'; 10 | import type { Editor } from 'tinymce'; 11 | import { apiKey, deleteTinymce } from '../alien/TestHelpers'; 12 | 13 | describe('LoadTinyTest', () => { 14 | const key = apiKey(); 15 | const assertTinymceVersion = (version: Version, editor: Editor) => { 16 | Assertions.assertEq(`Loaded version of TinyMCE should be ${version}`, version, editor.editorManager.majorVersion); 17 | Assertions.assertEq(`Loaded version of TinyMCE should be ${version}`, version, Global.tinymce.majorVersion); 18 | }; 19 | 20 | for (const version of [ '4', '5', '6', '7', '8' ] as Version[]) { 21 | context(`With local version ${version}`, () => { 22 | const createFixture = editorHook(EditorComponent, { 23 | providers: [ 24 | { 25 | provide: TINYMCE_SCRIPT_SRC, 26 | useValue: `/project/node_modules/tinymce-${version}/tinymce.min.js`, 27 | }, 28 | ], 29 | }); 30 | 31 | before(deleteTinymce); 32 | 33 | it('Should be able to load local version of TinyMCE specified via dependency injection', async () => { 34 | const { editor } = await createFixture(); 35 | assertTinymceVersion(version, editor); 36 | }); 37 | }); 38 | 39 | context(`With version ${version} loaded from miniature`, () => { 40 | const createFixture = editorHook(EditorComponent); 41 | tinymceVersionHook(version); 42 | 43 | it('Should be able to load with miniature', async () => { 44 | const { editor } = await createFixture(); 45 | assertTinymceVersion(version, editor); 46 | }); 47 | }); 48 | } 49 | 50 | for (const version of [ '5', '6', '7', '8' ] as Version[]) { 51 | context(`With cloud version ${version}`, () => { 52 | const createFixture = editorHook(EditorComponent); 53 | 54 | before(deleteTinymce); 55 | 56 | it(`Should be able to load TinyMCE ${version} from Cloud`, async () => { 57 | const { editor } = await createFixture({ cloudChannel: version, apiKey: key }); 58 | assertTinymceVersion(version, editor); 59 | Assertions.assertEq( 60 | 'TinyMCE should have been loaded from Cloud', 61 | `https://cdn.tiny.cloud/1/${key}/tinymce/${version}`, 62 | Global.tinymce.baseURI.source 63 | ); 64 | }); 65 | }); 66 | } 67 | }); 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Official TinyMCE Angular Component", 3 | "author": "Ephox Corporation DBA Tiny Technologies, Inc.", 4 | "license": "MIT", 5 | "scripts": { 6 | "preinstall": "node -e \"if(process.env.npm_execpath.indexOf('yarn') === -1) throw new Error('You must use Yarn to install, not NPM')\"", 7 | "test": "yarn bedrock-auto -b chrome-headless -f tinymce-angular-component/src/test/ts/**/*Test.ts", 8 | "test-manual": "bedrock -f tinymce-angular-component/src/test/ts/**/*Test.ts", 9 | "clean": "yarn rimraf dist", 10 | "lint": "eslint tinymce-angular-component/src/**/*.ts stories/**/*.ts", 11 | "build": "yarn run clean && ng-packagr -p tinymce-angular-component/ng-package.json && json -I -f dist/tinymce-angular/package.json -e 'this.version=process.env.npm_package_version' && copyfiles README.md dist/tinymce-angular/", 12 | "storybook": "ng run angular:storybook", 13 | "build-storybook": "ng run angular:build-storybook", 14 | "deploy-storybook": "yarn build-storybook && gh-pages -d ./storybook-static -u 'tiny-bot '" 15 | }, 16 | "private": true, 17 | "devDependencies": { 18 | "@angular-devkit/build-angular": "^18.1.1", 19 | "@angular-devkit/core": "^18.1.1", 20 | "@angular/animations": "^18.1.1", 21 | "@angular/cdk": "^18.1.1", 22 | "@angular/cli": "^18.1.1", 23 | "@angular/common": "^18.1.1", 24 | "@angular/compiler": "^18.1.1", 25 | "@angular/compiler-cli": "^18.1.1", 26 | "@angular/core": "^18.1.1", 27 | "@angular/forms": "^18.1.1", 28 | "@angular/language-service": "^18.1.1", 29 | "@angular/material": "^18.1.1", 30 | "@angular/platform-browser": "^18.1.1", 31 | "@angular/platform-browser-dynamic": "^18.1.1", 32 | "@babel/core": "^7.24.9", 33 | "@ephox/agar": "^8.0.1", 34 | "@ephox/bedrock-client": "^14.1.1", 35 | "@ephox/bedrock-server": "^14.1.3", 36 | "@ephox/sugar": "^9.3.1", 37 | "@storybook/addon-essentials": "^8.2.5", 38 | "@storybook/addon-interactions": "^8.2.5", 39 | "@storybook/addon-links": "^8.2.5", 40 | "@storybook/angular": "^8.2.5", 41 | "@storybook/blocks": "^8.2.5", 42 | "@storybook/test": "^8.2.5", 43 | "@tinymce/beehive-flow": "^0.19.0", 44 | "@tinymce/eslint-plugin": "^3.0.0", 45 | "@tinymce/miniature": "^6.0.0", 46 | "@types/chai": "^4.3.16", 47 | "@types/node": "^20.14.12", 48 | "autoprefixer": "^10.4.19", 49 | "babel-loader": "^9.1.3", 50 | "chai": "^5.1.1", 51 | "codelyzer": "^6.0.2", 52 | "copyfiles": "^2.4.1", 53 | "core-js": "^3.36.1", 54 | "eslint-plugin-chai-friendly": "^1.0.0", 55 | "eslint-plugin-storybook": "^0.8.0", 56 | "gh-pages": "^6.1.0", 57 | "json": "11.0.0", 58 | "ng-packagr": "^18.1.0", 59 | "react": "^18.2.0", 60 | "react-dom": "^18.2.0", 61 | "regenerator-runtime": "^0.14.1", 62 | "rimraf": "^6.0.1", 63 | "rxjs": "^7.8.1", 64 | "storybook": "^8.2.5", 65 | "tinymce": "^8.0.0", 66 | "tinymce-4": "npm:tinymce@^4", 67 | "tinymce-5": "npm:tinymce@^5", 68 | "tinymce-6": "npm:tinymce@^6", 69 | "tinymce-7": "npm:tinymce@^7", 70 | "tinymce-7.5.0": "npm:tinymce@7.5.0", 71 | "tinymce-8": "npm:tinymce@^8", 72 | "to-string-loader": "^1.1.5", 73 | "tslib": "^2.6.2", 74 | "typescript": "~5.5.4", 75 | "webpack": "^5.95.0", 76 | "zone.js": "~0.14.8" 77 | }, 78 | "version": "9.1.2-rc", 79 | "name": "@tinymce/tinymce-angular" 80 | } 81 | -------------------------------------------------------------------------------- /tinymce-angular-component/src/test/ts/browser/NgModelTest.ts: -------------------------------------------------------------------------------- 1 | 2 | import '../alien/InitTestEnvironment'; 3 | 4 | import { Component } from '@angular/core'; 5 | import { FormsModule, NgModel } from '@angular/forms'; 6 | import { Assertions, Waiter } from '@ephox/agar'; 7 | import { describe, it } from '@ephox/bedrock-client'; 8 | 9 | import { EditorComponent } from '../../../main/ts/editor/editor.component'; 10 | import { eachVersionContext, editorHook } from '../alien/TestHooks'; 11 | import { fakeTypeInEditor } from '../alien/TestHelpers'; 12 | 13 | describe('NgModelTest', () => { 14 | const assertNgModelState = (prop: 'valid' | 'pristine' | 'touched', expected: boolean, ngModel: NgModel) => { 15 | Assertions.assertEq('assert ngModel ' + prop + ' state', expected, ngModel[prop]); 16 | }; 17 | 18 | eachVersionContext([ '4', '5', '6', '7', '8' ], () => { 19 | @Component({ 20 | standalone: true, 21 | imports: [ EditorComponent, FormsModule ], 22 | template: ``, 23 | }) 24 | class EditorWithNgModel { 25 | public content = ''; 26 | } 27 | const createFixture = editorHook(EditorWithNgModel); 28 | 29 | it('should be pristine, untouched, and valid initially', async () => { 30 | const fixture = await createFixture(); 31 | const ngModel = fixture.ngModel.getOrDie('NgModel not found'); 32 | await Waiter.pTryUntil('Waited too long for ngModel states', () => { 33 | assertNgModelState('valid', true, ngModel); 34 | assertNgModelState('pristine', true, ngModel); 35 | assertNgModelState('touched', false, ngModel); 36 | }); 37 | }); 38 | 39 | it('should be pristine, untouched, and valid after writeValue', async () => { 40 | const fixture = await createFixture(); 41 | fixture.editorComponent.writeValue('

X

'); 42 | fixture.detectChanges(); 43 | await Waiter.pTryUntil('Waited too long for writeValue', () => { 44 | Assertions.assertEq('Value should have been written to the editor', '

X

', fixture.editor.getContent()); 45 | }); 46 | const ngModel = fixture.ngModel.getOrDie('NgModel not found'); 47 | assertNgModelState('valid', true, ngModel); 48 | assertNgModelState('pristine', true, ngModel); 49 | assertNgModelState('touched', false, ngModel); 50 | }); 51 | 52 | it('should have correct control flags after interaction', async () => { 53 | const fixture = await createFixture(); 54 | const ngModel = fixture.ngModel.getOrDie('NgModel not found'); 55 | fakeTypeInEditor(fixture, 'X'); 56 | // Should be dirty after user input but remain untouched 57 | assertNgModelState('pristine', false, ngModel); 58 | assertNgModelState('touched', false, ngModel); 59 | fixture.editor.fire('blur'); 60 | fixture.detectChanges(); 61 | // If the editor loses focus, it should should remain dirty but should also turn touched 62 | assertNgModelState('pristine', false, ngModel); 63 | assertNgModelState('touched', true, ngModel); 64 | }); 65 | 66 | it('Test outputFormat="text"', async () => { 67 | const fixture = await createFixture({ outputFormat: 'text' }); 68 | fakeTypeInEditor(fixture, 'X'); 69 | Assertions.assertEq( 70 | 'Value bound to content via ngModel should be plain text', 71 | 'X', 72 | fixture.componentInstance.content 73 | ); 74 | }); 75 | 76 | it('Test outputFormat="html"', async () => { 77 | const fixture = await createFixture({ outputFormat: 'html' }); 78 | fakeTypeInEditor(fixture, 'X'); 79 | Assertions.assertEq( 80 | 'Value bound to content via ngModel should be html', 81 | '

X

', 82 | fixture.componentInstance.content 83 | ); 84 | }); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /tinymce-angular-component/src/test/ts/alien/TestHooks.ts: -------------------------------------------------------------------------------- 1 | import { after, before, beforeEach, context } from '@ephox/bedrock-client'; 2 | import { ComponentFixture, TestBed, TestModuleMetadata } from '@angular/core/testing'; 3 | import { Type } from '@angular/core'; 4 | import { EditorComponent, Version } from '../../../main/ts/editor/editor.component'; 5 | import { firstValueFrom, map, switchMap, tap } from 'rxjs'; 6 | import { By } from '@angular/platform-browser'; 7 | import { Optional, Singleton } from '@ephox/katamari'; 8 | import { VersionLoader } from '@tinymce/miniature'; 9 | import { deleteTinymce, throwTimeout } from './TestHelpers'; 10 | import { FormsModule, ReactiveFormsModule, NgModel } from '@angular/forms'; 11 | import type { Editor } from 'tinymce'; 12 | 13 | export const fixtureHook = (component: Type, moduleDef: TestModuleMetadata) => { 14 | before(async () => { 15 | await TestBed.configureTestingModule(moduleDef).compileComponents(); 16 | }); 17 | 18 | return () => TestBed.createComponent(component); 19 | }; 20 | 21 | export const tinymceVersionHook = (version: Version) => { 22 | before(async () => { 23 | await VersionLoader.pLoadVersion(version); 24 | }); 25 | after(() => { 26 | deleteTinymce(); 27 | }); 28 | }; 29 | 30 | export interface EditorFixture extends ComponentFixture { 31 | editorComponent: EditorComponent; 32 | editor: Editor; 33 | ngModel: Optional; 34 | } 35 | 36 | export type CreateEditorFixture = ( 37 | props?: Partial< 38 | Omit< 39 | EditorComponent, 40 | `${'on' | 'ng' | 'register' | 'set' | 'write'}${string}` | 'createElement' | 'initialise' | 'editor' 41 | > 42 | > 43 | ) => Promise>; 44 | 45 | export const editorHook = (component: Type, moduleDef: TestModuleMetadata = { 46 | imports: [ component, EditorComponent, FormsModule, ReactiveFormsModule ], 47 | }): CreateEditorFixture => { 48 | const createFixture = fixtureHook(component, moduleDef); 49 | const editorFixture = Singleton.value>(); 50 | beforeEach(() => editorFixture.clear()); 51 | 52 | return async (props = {}) => { 53 | if (editorFixture.isSet()) { 54 | return editorFixture.get().getOrDie(); 55 | } 56 | 57 | const fixture = createFixture(); 58 | const editorComponent = 59 | fixture.componentInstance instanceof EditorComponent 60 | ? fixture.componentInstance 61 | : Optional.from(fixture.debugElement.query(By.directive(EditorComponent))) 62 | .map((v): EditorComponent => v.componentInstance) 63 | .getOrDie('EditorComponent instance not found'); 64 | 65 | for (const [ key, value ] of Object.entries({ ...props })) { 66 | (editorComponent as any)[key] = value; 67 | } 68 | 69 | fixture.detectChanges(); 70 | 71 | return firstValueFrom( 72 | editorComponent.onInit.pipe( 73 | throwTimeout(10000, `Timed out waiting for editor to load`), 74 | switchMap( 75 | ({ editor }) => 76 | new Promise((resolve) => { 77 | if (editor.initialized) { 78 | resolve(editor); 79 | } 80 | editor.once( 'SkinLoaded', () => resolve(editor)); 81 | }) 82 | ), 83 | map( 84 | (editor): EditorFixture => 85 | Object.assign(fixture, { 86 | editorComponent, 87 | editor, 88 | ngModel: Optional.from(fixture.debugElement.query(By.directive(EditorComponent))).bind((debugEl) => 89 | Optional.from(debugEl.injector.get(NgModel, undefined, { optional: true })) 90 | ), 91 | }) 92 | ), 93 | tap(editorFixture.set) 94 | ) 95 | ); 96 | }; 97 | }; 98 | 99 | export const eachVersionContext = (versions: Version[], fn: (version: Version) => void) => 100 | versions.forEach((version) => 101 | context(`With version ${version}`, () => { 102 | tinymceVersionHook(version); 103 | fn(version); 104 | }) 105 | ); 106 | -------------------------------------------------------------------------------- /tinymce-angular-component/src/main/ts/utils/Utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Ephox, Inc. 3 | * 4 | * This source code is licensed under the Apache 2 license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import { EventEmitter } from '@angular/core'; 10 | import { fromEvent, Subject, takeUntil } from 'rxjs'; 11 | import { HasEventTargetAddRemove } from 'rxjs/internal/observable/fromEvent'; 12 | 13 | import { EditorComponent } from '../editor/editor.component'; 14 | import { validEvents, Events } from '../editor/Events'; 15 | import { Editor } from 'tinymce'; 16 | 17 | // Caretaker note: `fromEvent` supports passing JQuery-style event targets, the editor has `on` and `off` methods which 18 | // will be invoked upon subscription and teardown. 19 | const listenTinyMCEEvent = ( 20 | editor: any, 21 | eventName: string, 22 | destroy$: Subject 23 | ) => fromEvent(editor as HasEventTargetAddRemove | ArrayLike>, eventName).pipe(takeUntil(destroy$)); 24 | 25 | const bindHandlers = (ctx: EditorComponent, editor: any, destroy$: Subject): void => { 26 | const allowedEvents = getValidEvents(ctx); 27 | allowedEvents.forEach((eventName) => { 28 | const eventEmitter: EventEmitter = ctx[eventName]; 29 | 30 | listenTinyMCEEvent(editor, eventName.substring(2), destroy$).subscribe((event) => { 31 | // Caretaker note: `ngZone.run()` runs change detection since it notifies the forked Angular zone that it's 32 | // being re-entered. We don't want to run `ApplicationRef.tick()` if anyone listens to the specific event 33 | // within the template. E.g. if the `onSelectionChange` is not listened within the template like: 34 | // `` 35 | // then it won't be "observed", and we won't run "dead" change detection. 36 | if (isObserved(eventEmitter)) { 37 | ctx.ngZone.run(() => eventEmitter.emit({ event, editor })); 38 | } 39 | }); 40 | }); 41 | }; 42 | 43 | const getValidEvents = (ctx: EditorComponent): (keyof Events)[] => { 44 | const ignoredEvents = parseStringProperty(ctx.ignoreEvents, []); 45 | const allowedEvents = parseStringProperty(ctx.allowedEvents, validEvents).filter( 46 | (event) => validEvents.includes(event as (keyof Events)) && !ignoredEvents.includes(event)) as (keyof Events)[]; 47 | return allowedEvents; 48 | }; 49 | 50 | const parseStringProperty = (property: string | string[] | undefined, defaultValue: (keyof Events)[]): string[] => { 51 | if (typeof property === 'string') { 52 | return property.split(',').map((value) => value.trim()); 53 | } 54 | if (Array.isArray(property)) { 55 | return property; 56 | } 57 | return defaultValue; 58 | }; 59 | 60 | let unique = 0; 61 | 62 | const uuid = (prefix: string): string => { 63 | const date = new Date(); 64 | const time = date.getTime(); 65 | const random = Math.floor(Math.random() * 1000000000); 66 | 67 | unique++; 68 | 69 | return prefix + '_' + random + unique + String(time); 70 | }; 71 | 72 | const isTextarea = (element?: Element): element is HTMLTextAreaElement => typeof element !== 'undefined' && element.tagName.toLowerCase() === 'textarea'; 73 | 74 | const normalizePluginArray = (plugins?: string | string[]): string[] => { 75 | if (typeof plugins === 'undefined' || plugins === '') { 76 | return []; 77 | } 78 | 79 | return Array.isArray(plugins) ? plugins : plugins.split(' '); 80 | }; 81 | 82 | const mergePlugins = (initPlugins: string | string[], inputPlugins?: string | string[]) => 83 | normalizePluginArray(initPlugins).concat(normalizePluginArray(inputPlugins)); 84 | 85 | const noop: (...args: any[]) => void = () => { }; 86 | 87 | const isNullOrUndefined = (value: any): value is null | undefined => value === null || value === undefined; 88 | 89 | const isObserved = (o: Subject): boolean => 90 | // RXJS is making the `observers` property internal in v8. So this is intended as a backwards compatible way of 91 | // checking if a subject has observers. 92 | o.observed || o.observers?.length > 0; 93 | 94 | const setMode = (editor: Editor, mode: 'readonly' | 'design') => { 95 | if (typeof editor.mode?.set === 'function') { 96 | editor.mode.set(mode); 97 | } else if ('setMode' in editor && typeof editor.setMode === 'function') { 98 | editor.setMode(mode); 99 | } 100 | }; 101 | 102 | export { 103 | listenTinyMCEEvent, 104 | bindHandlers, 105 | uuid, 106 | isTextarea, 107 | normalizePluginArray, 108 | mergePlugins, 109 | noop, 110 | isNullOrUndefined, 111 | setMode 112 | }; 113 | -------------------------------------------------------------------------------- /tinymce-angular-component/src/test/ts/browser/PropTest.ts: -------------------------------------------------------------------------------- 1 | import '../alien/InitTestEnvironment'; 2 | 3 | import { Component, Type } from '@angular/core'; 4 | import { context, describe, it } from '@ephox/bedrock-client'; 5 | 6 | import { EditorComponent } from '../../../main/ts/public_api'; 7 | import { eachVersionContext, fixtureHook } from '../alien/TestHooks'; 8 | import { captureLogs, throwTimeout } from '../alien/TestHelpers'; 9 | import { concatMap, distinct, firstValueFrom, mergeMap, of, toArray } from 'rxjs'; 10 | import { ComponentFixture } from '@angular/core/testing'; 11 | import { By } from '@angular/platform-browser'; 12 | import type { Editor } from 'tinymce'; 13 | import { Fun } from '@ephox/katamari'; 14 | import { Waiter, Assertions } from '@ephox/agar'; 15 | import { TinyAssertions } from '@ephox/mcagar'; 16 | 17 | describe('PropTest', () => { 18 | const containsIDWarning = (logs: unknown[][]) => 19 | logs.length > 0 && 20 | logs.some((log) => { 21 | const [ message ] = log; 22 | return ( 23 | typeof message === 'string' && 24 | message.includes('TinyMCE-Angular: an element with id [') && 25 | message.includes('Editors with duplicate Id will not be able to mount') 26 | ); 27 | }); 28 | const findAllComponents = (fixture: ComponentFixture, component: Type): T[] => 29 | fixture.debugElement.queryAll(By.directive(component)).map((v) => v.componentInstance as T); 30 | const waitForEditorsToLoad = (fixture: ComponentFixture): Promise => 31 | firstValueFrom( 32 | of( 33 | findAllComponents(fixture, EditorComponent) 34 | .map((ed) => ed.editor) 35 | .filter((editor): editor is Editor => !!editor) 36 | ).pipe( 37 | mergeMap(Fun.identity), 38 | distinct((editor) => editor.id), 39 | concatMap((editor) => new Promise((resolve) => editor.once('SkinLoaded', () => resolve(editor)))), 40 | toArray(), 41 | throwTimeout(20000, 'Timeout waiting for editor(s) to load') 42 | ) 43 | ); 44 | 45 | eachVersionContext([ '4', '5', '6', '7', '8' ], () => { 46 | context('Single editor with ID', () => { 47 | @Component({ 48 | standalone: true, 49 | imports: [ EditorComponent ], 50 | template: ``, 51 | }) 52 | class EditorWithID {} 53 | const createFixture = fixtureHook(EditorWithID, { imports: [ EditorWithID ] }); 54 | 55 | it('INT-3299: setting an ID does not log a warning', async () => { 56 | const warnings = await captureLogs('warn', async () => { 57 | const fixture = createFixture(); 58 | fixture.detectChanges(); 59 | const [ ed ] = await waitForEditorsToLoad(fixture); 60 | Assertions.assertEq('Editor\'s id must match', ed.id, 'my-id'); 61 | }); 62 | 63 | Assertions.assertEq('Should not contain an ID warning', containsIDWarning(warnings), false); 64 | }); 65 | }); 66 | 67 | context('Multiple editors', () => { 68 | @Component({ 69 | standalone: true, 70 | imports: [ EditorComponent ], 71 | template: ` 72 | @for (props of editors; track props) { 73 | 74 | } 75 | `, 76 | }) 77 | class MultipleEditors { 78 | public editors: Partial>[] = []; 79 | } 80 | const createFixture = fixtureHook(MultipleEditors, { imports: [ MultipleEditors ] }); 81 | 82 | it('INT-3299: creating more than one editor with the same ID logs a warning', async () => { 83 | const warnings = await captureLogs('warn', async () => { 84 | const fixture = createFixture(); 85 | fixture.componentInstance.editors = [ 86 | { id: 'my-id-0', initialValue: 'text1' }, 87 | { id: 'my-id-0', initialValue: 'text2' }, 88 | ]; 89 | fixture.detectChanges(); 90 | const [ ed1, ed2 ] = await waitForEditorsToLoad(fixture); 91 | Assertions.assertEq('Editor\'s id must match', 'my-id-0', ed1.id); 92 | TinyAssertions.assertContent(ed1, '

text1

'); 93 | Assertions.assertEq('Editor 2 must be undefined', undefined, ed2); 94 | }); 95 | Assertions.assertEq( 'Should contain an ID warning', true, containsIDWarning(warnings)); 96 | }); 97 | 98 | it('INT-3299: creating more than one editor with different IDs does not log a warning', async () => { 99 | const warnings = await captureLogs('warn', async () => { 100 | const fixture = createFixture(); 101 | fixture.componentInstance.editors = Array.from({ length: 3 }, (_, i) => ({ 102 | id: `my-id-${i}`, 103 | initialValue: `text${i}`, 104 | })); 105 | fixture.detectChanges(); 106 | const [ ed1, ed2, ed3, ed4 ] = findAllComponents(fixture, EditorComponent); 107 | await Waiter.pTryUntil('All editors to have been initialised', () => { 108 | Assertions.assertEq('Editor 1\'s id must match', ed1.id, 'my-id-0'); 109 | Assertions.assertEq('Content of editor 1', ed1.editor?.getContent(), '

text0

'); 110 | Assertions.assertEq('Editor 2\'s id must match', ed2.id, 'my-id-1'); 111 | Assertions.assertEq('Content of editor 2', ed2.editor?.getContent(), '

text1

'); 112 | Assertions.assertEq('Editor 3\'s id must match', ed3.id, 'my-id-2'); 113 | Assertions.assertEq('Content of editor 3', ed3.editor?.getContent(), '

text2

'); 114 | Assertions.assertEq('Editor 4 should not exist', ed4?.editor, undefined); 115 | }, 1000, 10000); 116 | }); 117 | Assertions.assertEq( 'Should not contain an ID warning', containsIDWarning(warnings), false); 118 | }); 119 | }); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /tinymce-angular-component/src/test/ts/browser/FormControlTest.ts: -------------------------------------------------------------------------------- 1 | import '../alien/InitTestEnvironment'; 2 | 3 | import { ChangeDetectionStrategy, Component, ViewChild, ElementRef } from '@angular/core'; 4 | import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; 5 | import { Assertions } from '@ephox/agar'; 6 | import { context, describe, it } from '@ephox/bedrock-client'; 7 | 8 | import { EditorComponent } from '../../../main/ts/public_api'; 9 | import { eachVersionContext, editorHook, fixtureHook } from '../alien/TestHooks'; 10 | import { By } from '@angular/platform-browser'; 11 | import { first, firstValueFrom, switchMap } from 'rxjs'; 12 | import type { Editor } from 'tinymce'; 13 | import { fakeTypeInEditor } from '../alien/TestHelpers'; 14 | 15 | type FormControlProps = Partial>; 16 | 17 | describe('FormControlTest', () => { 18 | const assertFormControl = (label: string, control: FormControlProps, expected: FormControlProps) => { 19 | for (const [ key, value ] of Object.entries(expected)) { 20 | Assertions.assertEq(`${label} - ${key}`, value, control[key as keyof FormControlProps]); 21 | } 22 | }; 23 | 24 | eachVersionContext([ '4', '5', '6', '7', '8' ], () => { 25 | [ ChangeDetectionStrategy.Default, ChangeDetectionStrategy.OnPush ].forEach((changeDetection) => { 26 | context(`[formControl] with change detection: ${changeDetection}`, () => { 27 | @Component({ 28 | standalone: true, 29 | imports: [ EditorComponent, ReactiveFormsModule ], 30 | changeDetection, 31 | template: ``, 32 | }) 33 | class EditorWithFormControl { 34 | public control = new FormControl(); 35 | } 36 | const createFixture = editorHook(EditorWithFormControl); 37 | 38 | it('FormControl interaction', async () => { 39 | const fixture = await createFixture(); 40 | 41 | Assertions.assertEq('Expect editor to have no initial value', '', fixture.editor.getContent()); 42 | 43 | fixture.componentInstance.control.setValue('

Some Value

'); 44 | fixture.detectChanges(); 45 | 46 | Assertions.assertEq('Expect editor to have a value', '

Some Value

', fixture.editor.getContent()); 47 | 48 | fixture.componentInstance.control.reset(); 49 | fixture.detectChanges(); 50 | 51 | Assertions.assertEq('Expect editor to be empty after reset', '', fixture.editor.getContent()); 52 | }); 53 | }); 54 | 55 | context(`[formGroup] with change detection: ${changeDetection}`, () => { 56 | @Component({ 57 | standalone: true, 58 | changeDetection, 59 | imports: [ EditorComponent, ReactiveFormsModule ], 60 | template: ` 61 |
62 | 63 | 64 | 65 | 66 | `, 67 | }) 68 | class FormWithEditor { 69 | @ViewChild('resetBtn') public resetBtn!: ElementRef; 70 | @ViewChild('submitBtn') public submitBtn!: ElementRef; 71 | public readonly form = new FormGroup({ 72 | editor: new FormControl('', { 73 | validators: Validators.compose([ 74 | // eslint-disable-next-line @typescript-eslint/unbound-method 75 | Validators.required, 76 | Validators.minLength(10), 77 | ]), 78 | }), 79 | }); 80 | } 81 | const createFixture = fixtureHook(FormWithEditor, { imports: [ FormWithEditor ] }); 82 | 83 | it('interaction', async () => { 84 | const fixture = createFixture(); 85 | fixture.detectChanges(); 86 | const editorComponent: EditorComponent = fixture.debugElement.query( 87 | By.directive(EditorComponent) 88 | ).componentInstance; 89 | const editor = await firstValueFrom( 90 | editorComponent.onInit.pipe( 91 | first(), 92 | switchMap((ev) => new Promise((resolve) => ev.editor.on('SkinLoaded', () => resolve(ev.editor)))) 93 | ) 94 | ); 95 | const form = fixture.componentInstance.form; 96 | const initialProps: FormControlProps = { valid: false, dirty: false, pristine: true, touched: false }; 97 | // const editorCtrl = form.get('editor')!; 98 | 99 | assertFormControl('Initial form', form, initialProps); 100 | editor.fire('blur'); 101 | assertFormControl('Form after editor blur', form, { ...initialProps, touched: true }); 102 | fixture.componentInstance.resetBtn.nativeElement.click(); 103 | fixture.detectChanges(); 104 | assertFormControl('Form after reset', form, initialProps); 105 | 106 | fakeTypeInEditor(fixture, 'x'); 107 | assertFormControl('Form after typing one character', form, { 108 | valid: false, 109 | dirty: true, 110 | pristine: false, 111 | touched: false, 112 | }); 113 | editor.fire('blur'); 114 | assertFormControl('Form after editor blur', form, { 115 | valid: false, 116 | dirty: true, 117 | pristine: false, 118 | touched: true, 119 | }); 120 | fakeTypeInEditor(fixture, 'x'.repeat(20)); 121 | assertFormControl('Form after typing 10 characters', form, { 122 | valid: true, 123 | dirty: true, 124 | pristine: false, 125 | touched: true, 126 | }); 127 | Assertions.assertEq('Editor value has expected value', `

${'x'.repeat(20)}

`, form.value.editor); 128 | }); 129 | }); 130 | }); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /stories/Editor.stories.ts: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/angular'; 2 | import { EditorComponent } from 'tinymce-angular-component/src/main/ts/public_api'; 3 | import { apiKey, sampleContent } from './Settings'; 4 | import { FormsModule, ReactiveFormsModule } from '@angular/forms'; 5 | import { EventBindingComponent } from './event-binding/EventBinding.component'; 6 | import { EventForwardingComponent } from './event-forwarding/EventForwarding.component'; 7 | import { FormControlComponent } from './form-control/FormControl.component'; 8 | import { FormWithOnPushComponent } from './form-with-on-push/form-with-on-push.component'; 9 | import { BlogComponent } from './formvalidation/FormValidation.component'; 10 | import { DisablingComponent } from './disable/Disable.component'; 11 | import { ViewQueryComponent } from './viewquery/Viewquery.component'; 12 | import { MaterialTabs } from './materialtabs/MaterialTabs.component'; 13 | import { SafePipe } from './pipes/Safe.pipe'; 14 | import { MatTabsModule } from '@angular/material/tabs'; 15 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 16 | import { ContainerComponent, ContentProjectionComponent } from './contentprojection/ContentProjection.component'; 17 | import { BindingComponent } from './data-binding/DataBinding.component'; 18 | import { ReadonlyComponent } from './readonly/Readonly.component'; 19 | 20 | const meta: Meta = { 21 | component: EditorComponent, 22 | title: 'Editor', 23 | }; 24 | export default meta; 25 | 26 | export const IframeStory: StoryObj = { 27 | name: 'Iframe Editor', 28 | args: { 29 | apiKey, 30 | initialValue: sampleContent, 31 | init: { 32 | height: 300, 33 | plugins: 'help', 34 | }, 35 | } 36 | }; 37 | 38 | export const InlineStory: StoryObj = { 39 | name: 'Inline Editor', 40 | render: () => ({ 41 | template: ` 42 |
43 | 44 |
45 | ` 46 | }) 47 | }; 48 | 49 | export const EventBindingStory: StoryObj = { 50 | name: 'Event Binding', 51 | render: () => ({ 52 | moduleMetadata: { 53 | imports: [ ReactiveFormsModule, FormsModule ], 54 | declarations: [ EventBindingComponent ], 55 | }, 56 | template: `` 57 | }) 58 | }; 59 | 60 | export const EventForwardingStory: StoryObj = { 61 | name: 'Event Forwarding', 62 | render: () => ({ 63 | moduleMetadata: { 64 | imports: [ ReactiveFormsModule, FormsModule ], 65 | declarations: [ EventForwardingComponent ], 66 | }, 67 | template: `` 68 | }), 69 | }; 70 | 71 | export const DataBindingStory: StoryObj = { 72 | name: 'Data Binding', 73 | render: () => ({ 74 | moduleMetadata: { 75 | imports: [ ReactiveFormsModule, FormsModule ], 76 | declarations: [ BindingComponent ], 77 | }, 78 | template: `` 79 | }), 80 | parameters: { 81 | // TODO: show notes, or remove, or show in a different way 82 | notes: 'Simple example of data binding with ngModel' 83 | } 84 | }; 85 | 86 | export const FormControlStory: StoryObj = { 87 | name: 'Form Control', 88 | render: () => ({ 89 | moduleMetadata: { 90 | imports: [ ReactiveFormsModule, FormsModule ], 91 | declarations: [ FormControlComponent ], 92 | }, 93 | template: `` 94 | }), 95 | parameters: { 96 | notes: 'Simple example of subscribing to valueChanges' 97 | } 98 | }; 99 | 100 | export const FormStateStory: StoryObj = { 101 | name: 'Form with on-push change detection', 102 | render: () => ({ 103 | moduleMetadata: { 104 | imports: [ ReactiveFormsModule, FormsModule ], 105 | declarations: [ FormWithOnPushComponent ], 106 | }, 107 | template: `` 108 | }), 109 | }; 110 | 111 | export const FormValidationStory: StoryObj = { 112 | name: 'Form Validation', 113 | render: () => ({ 114 | moduleMetadata: { 115 | imports: [ ReactiveFormsModule, FormsModule ], 116 | declarations: [ BlogComponent, SafePipe ], 117 | }, 118 | template: `` 119 | }), 120 | parameters: { 121 | notes: 'Example of form validation and data binding with ngModel' 122 | } 123 | }; 124 | 125 | export const DisablingStory: StoryObj = { 126 | name: 'Disabling', 127 | render: () => ({ 128 | moduleMetadata: { 129 | imports: [ ReactiveFormsModule, FormsModule ], 130 | declarations: [ DisablingComponent ], 131 | }, 132 | template: `` 133 | }), 134 | parameters: { 135 | notes: 'Example of disabling/enabling the editor component' 136 | } 137 | }; 138 | 139 | export const ReadonlyStory: StoryObj = { 140 | name: 'Readonly', 141 | render: () => ({ 142 | moduleMetadata: { 143 | imports: [ ReactiveFormsModule, FormsModule ], 144 | declarations: [ ReadonlyComponent ], 145 | }, 146 | template: `` 147 | }), 148 | parameters: { 149 | notes: 'Example of toggling readonly state in the editor component' 150 | } 151 | }; 152 | 153 | export const ViewQueryStory: StoryObj = { 154 | name: 'View Query', 155 | render: () => ({ 156 | moduleMetadata: { 157 | imports: [ ReactiveFormsModule, FormsModule ], 158 | declarations: [ ViewQueryComponent ], 159 | }, 160 | template: `` 161 | }), 162 | parameters: { 163 | notes: 'Example of obtaining a reference to the editor with a view query' 164 | } 165 | }; 166 | 167 | export const MaterialTabsStory: StoryObj = { 168 | name: 'Material Tabs', 169 | render: () => ({ 170 | moduleMetadata: { 171 | imports: [ ReactiveFormsModule, FormsModule, BrowserAnimationsModule, MatTabsModule ], 172 | declarations: [ MaterialTabs ], 173 | }, 174 | template: `` 175 | }), 176 | }; 177 | 178 | export const ContentProjectionStory: StoryObj = { 179 | name: 'Content Projection', 180 | render: () => ({ 181 | moduleMetadata: { 182 | declarations: [ ContentProjectionComponent, ContainerComponent ], 183 | }, 184 | template: `` 185 | }), 186 | parameters: { 187 | notes: 'Content projection workaround.' 188 | } 189 | }; 190 | -------------------------------------------------------------------------------- /tinymce-angular-component/src/test/ts/browser/DisabledPropertyTest.ts: -------------------------------------------------------------------------------- 1 | import { Assertions } from '@ephox/agar'; 2 | import '../alien/InitTestEnvironment'; 3 | 4 | import { EditorComponent } from '../../../main/ts/public_api'; 5 | import { after, before, context, describe, it } from '@ephox/bedrock-client'; 6 | import { eachVersionContext, editorHook } from '../alien/TestHooks'; 7 | import { Editor } from 'tinymce'; 8 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 9 | import { Component, ViewChild } from '@angular/core'; 10 | import { FormsModule } from '@angular/forms'; 11 | import { VersionLoader } from '@tinymce/miniature'; 12 | import { deleteTinymce } from '../alien/TestHelpers'; 13 | 14 | describe('DisabledPropertyTest', () => { 15 | const getMode = (editor: Editor) => { 16 | if (typeof editor.mode?.get === 'function') { 17 | return editor.mode.get(); 18 | } 19 | return editor.readonly ? 'readonly' : 'design'; 20 | }; 21 | const assertDesignMode = (editor: Editor) => Assertions.assertEq('TinyMCE should be in design mode', 'design', getMode(editor)); 22 | const assertReadonlyMode = (editor: Editor) => Assertions.assertEq('TinyMCE should be in readonly mode', 'readonly', getMode(editor)); 23 | const assertDisabledOption = (editor: Editor, expected: boolean) => 24 | Assertions.assertEq(`TinyMCE should have disabled option set to ${expected}`, expected, editor.options.get('disabled')); 25 | 26 | eachVersionContext([ '5', '6', '7.5.0' ], () => { 27 | const createFixture = editorHook(EditorComponent); 28 | 29 | it(`Component 'disabled' property is mapped to editor 'readonly' mode`, async () => { 30 | const { editor } = await createFixture({ disabled: true }); 31 | assertReadonlyMode(editor); 32 | }); 33 | 34 | it(`Toggling component's 'disabled' property is mapped to editor 'readonly' mode`, async () => { 35 | const fixture = await createFixture(); 36 | const { editor } = fixture; 37 | 38 | assertDesignMode(editor); 39 | 40 | fixture.componentRef.setInput('disabled', true); 41 | fixture.detectChanges(); 42 | assertReadonlyMode(editor); 43 | 44 | fixture.componentRef.setInput('disabled', false); 45 | fixture.detectChanges(); 46 | assertDesignMode(editor); 47 | }); 48 | 49 | it(`Setting the 'readonly' property causing readonly mode`, async () => { 50 | const { editor } = await createFixture({ readonly: true }); 51 | assertReadonlyMode(editor); 52 | }); 53 | 54 | it(`Toggling component's 'readonly' property is mapped to editor 'readonly' mode`, async () => { 55 | const fixture = await createFixture(); 56 | const { editor } = fixture; 57 | 58 | assertDesignMode(editor); 59 | 60 | fixture.componentRef.setInput('readonly', true); 61 | fixture.detectChanges(); 62 | assertReadonlyMode(editor); 63 | 64 | fixture.componentRef.setInput('readonly', false); 65 | fixture.detectChanges(); 66 | assertDesignMode(editor); 67 | }); 68 | 69 | it(`[disabled]=true [readonly]=false triggers readonly mode`, async () => { 70 | const { editor } = await createFixture({ disabled: true, readonly: false }); 71 | assertReadonlyMode(editor); 72 | }); 73 | 74 | it(`[disabled]=false [readonly]=true triggers readonly mode`, async () => { 75 | const { editor } = await createFixture({ disabled: false, readonly: true }); 76 | assertReadonlyMode(editor); 77 | }); 78 | }); 79 | 80 | eachVersionContext([ '7', '8' ], () => { 81 | const createFixture = editorHook(EditorComponent); 82 | 83 | it(`Component 'disabled' property is mapped to editor 'disabled' property`, async () => { 84 | const { editor } = await createFixture({ disabled: true }); 85 | 86 | Assertions.assertEq('TinyMCE should have disabled option set to true', true, editor.options.get('disabled')); 87 | assertDesignMode(editor); 88 | }); 89 | 90 | it(`Toggling component's 'disabled' property is mapped to editor 'disabled' option`, async () => { 91 | const fixture = await createFixture({}); 92 | const { editor } = fixture; 93 | 94 | assertDesignMode(editor); 95 | assertDisabledOption(editor, false); 96 | 97 | fixture.componentRef.setInput('disabled', true); 98 | fixture.detectChanges(); 99 | assertDesignMode(editor); 100 | assertDisabledOption(editor, true); 101 | 102 | fixture.componentRef.setInput('disabled', false); 103 | fixture.detectChanges(); 104 | assertDesignMode(editor); 105 | assertDisabledOption(editor, false); 106 | }); 107 | }); 108 | 109 | context('With version 7', () => { 110 | @Component({ 111 | imports: [ FormsModule, EditorComponent ], 112 | template: ``, 113 | standalone: true, 114 | selector: 'test-host-component' 115 | }) 116 | class TestHostComponent { 117 | public text = '

Hello World

'; 118 | @ViewChild(EditorComponent) public editorRef!: EditorComponent; 119 | } 120 | 121 | const waitForEditorInitialized = (editor: Editor) => new Promise((resolve) => { 122 | if (editor.initialized) { 123 | resolve(); 124 | } 125 | editor.once('init', () => resolve()); 126 | }); 127 | 128 | let fixture: ComponentFixture; 129 | let testHost: TestHostComponent; 130 | let tinyEditor: Editor; 131 | 132 | before(async () => { 133 | await VersionLoader.pLoadVersion('7'); 134 | 135 | await TestBed.configureTestingModule({ 136 | imports: [ TestHostComponent ] 137 | }).compileComponents(); 138 | 139 | fixture = TestBed.createComponent(TestHostComponent); 140 | testHost = fixture.componentInstance; 141 | fixture.detectChanges(); 142 | tinyEditor = testHost.editorRef.editor!; 143 | }); 144 | 145 | after(() => { 146 | deleteTinymce(); 147 | }); 148 | 149 | it('INT-3328: disabled property should work with [ngModel] when TinyMCE has been loaded before editor component has been created', async () => { 150 | assertDisabledOption(tinyEditor!, true); 151 | /* 152 | I have to wait until the editor is fully initialized before using deleteTinymce() in after block. 153 | There's for example theme.js script that starts to load after editor instance has been created. 154 | If I remove tinymce from window too soon the theme.js will fail alongside with this test case. 155 | */ 156 | await waitForEditorInitialized(tinyEditor!); 157 | }); 158 | }); 159 | }); 160 | -------------------------------------------------------------------------------- /tinymce-angular-component/src/main/ts/editor/Events.ts: -------------------------------------------------------------------------------- 1 | import { Output, EventEmitter, Directive } from '@angular/core'; 2 | import type { Editor as TinyMCEEditor } from 'tinymce'; 3 | 4 | export interface EventObj { 5 | event: T; 6 | editor: TinyMCEEditor; 7 | } 8 | 9 | @Directive() 10 | export class Events { 11 | @Output() public onBeforePaste: EventEmitter> = new EventEmitter(); 12 | @Output() public onBlur: EventEmitter> = new EventEmitter(); 13 | @Output() public onClick: EventEmitter> = new EventEmitter(); 14 | @Output() public onCompositionEnd: EventEmitter> = new EventEmitter(); 15 | @Output() public onCompositionStart: EventEmitter> = new EventEmitter(); 16 | @Output() public onCompositionUpdate: EventEmitter> = new EventEmitter(); 17 | @Output() public onContextMenu: EventEmitter> = new EventEmitter(); 18 | @Output() public onCopy: EventEmitter> = new EventEmitter(); 19 | @Output() public onCut: EventEmitter> = new EventEmitter(); 20 | @Output() public onDblclick: EventEmitter> = new EventEmitter(); 21 | @Output() public onDrag: EventEmitter> = new EventEmitter(); 22 | @Output() public onDragDrop: EventEmitter> = new EventEmitter(); 23 | @Output() public onDragEnd: EventEmitter> = new EventEmitter(); 24 | @Output() public onDragGesture: EventEmitter> = new EventEmitter(); 25 | @Output() public onDragOver: EventEmitter> = new EventEmitter(); 26 | @Output() public onDrop: EventEmitter> = new EventEmitter(); 27 | @Output() public onFocus: EventEmitter> = new EventEmitter(); 28 | @Output() public onFocusIn: EventEmitter> = new EventEmitter(); 29 | @Output() public onFocusOut: EventEmitter> = new EventEmitter(); 30 | @Output() public onKeyDown: EventEmitter> = new EventEmitter(); 31 | @Output() public onKeyPress: EventEmitter> = new EventEmitter(); 32 | @Output() public onKeyUp: EventEmitter> = new EventEmitter(); 33 | @Output() public onMouseDown: EventEmitter> = new EventEmitter(); 34 | @Output() public onMouseEnter: EventEmitter> = new EventEmitter(); 35 | @Output() public onMouseLeave: EventEmitter> = new EventEmitter(); 36 | @Output() public onMouseMove: EventEmitter> = new EventEmitter(); 37 | @Output() public onMouseOut: EventEmitter> = new EventEmitter(); 38 | @Output() public onMouseOver: EventEmitter> = new EventEmitter(); 39 | @Output() public onMouseUp: EventEmitter> = new EventEmitter(); 40 | @Output() public onPaste: EventEmitter> = new EventEmitter(); 41 | @Output() public onSelectionChange: EventEmitter> = new EventEmitter(); 42 | @Output() public onActivate: EventEmitter> = new EventEmitter(); 43 | @Output() public onAddUndo: EventEmitter> = new EventEmitter(); 44 | @Output() public onBeforeAddUndo: EventEmitter> = new EventEmitter(); 45 | @Output() public onBeforeExecCommand: EventEmitter> = new EventEmitter(); 46 | @Output() public onBeforeGetContent: EventEmitter> = new EventEmitter(); 47 | @Output() public onBeforeRenderUI: EventEmitter> = new EventEmitter(); 48 | @Output() public onBeforeSetContent: EventEmitter> = new EventEmitter(); 49 | @Output() public onChange: EventEmitter> = new EventEmitter(); 50 | @Output() public onClearUndos: EventEmitter> = new EventEmitter(); 51 | @Output() public onDeactivate: EventEmitter> = new EventEmitter(); 52 | @Output() public onDirty: EventEmitter> = new EventEmitter(); 53 | @Output() public onExecCommand: EventEmitter> = new EventEmitter(); 54 | @Output() public onGetContent: EventEmitter> = new EventEmitter(); 55 | @Output() public onHide: EventEmitter> = new EventEmitter(); 56 | @Output() public onInit: EventEmitter> = new EventEmitter(); 57 | @Output() public onInput: EventEmitter> = new EventEmitter(); 58 | @Output() public onInitNgModel: EventEmitter> = new EventEmitter(); 59 | @Output() public onLoadContent: EventEmitter> = new EventEmitter(); 60 | @Output() public onNodeChange: EventEmitter> = new EventEmitter(); 61 | @Output() public onPostProcess: EventEmitter> = new EventEmitter(); 62 | @Output() public onPostRender: EventEmitter> = new EventEmitter(); 63 | @Output() public onPreInit: EventEmitter> = new EventEmitter(); 64 | @Output() public onPreProcess: EventEmitter> = new EventEmitter(); 65 | @Output() public onProgressState: EventEmitter> = new EventEmitter(); 66 | @Output() public onRedo: EventEmitter> = new EventEmitter(); 67 | @Output() public onRemove: EventEmitter> = new EventEmitter(); 68 | @Output() public onReset: EventEmitter> = new EventEmitter(); 69 | @Output() public onResizeEditor: EventEmitter> = new EventEmitter(); 70 | @Output() public onSaveContent: EventEmitter> = new EventEmitter(); 71 | @Output() public onSetAttrib: EventEmitter> = new EventEmitter(); 72 | @Output() public onObjectResizeStart: EventEmitter> = new EventEmitter(); 73 | @Output() public onObjectResized: EventEmitter> = new EventEmitter(); 74 | @Output() public onObjectSelected: EventEmitter> = new EventEmitter(); 75 | @Output() public onSetContent: EventEmitter> = new EventEmitter(); 76 | @Output() public onShow: EventEmitter> = new EventEmitter(); 77 | @Output() public onSubmit: EventEmitter> = new EventEmitter(); 78 | @Output() public onUndo: EventEmitter> = new EventEmitter(); 79 | @Output() public onVisualAid: EventEmitter> = new EventEmitter(); 80 | } 81 | 82 | export const validEvents: (keyof Events)[] = [ 83 | 'onActivate', 84 | 'onAddUndo', 85 | 'onBeforeAddUndo', 86 | 'onBeforeExecCommand', 87 | 'onBeforeGetContent', 88 | 'onBeforeRenderUI', 89 | 'onBeforeSetContent', 90 | 'onBeforePaste', 91 | 'onBlur', 92 | 'onChange', 93 | 'onClearUndos', 94 | 'onClick', 95 | 'onCompositionEnd', 96 | 'onCompositionStart', 97 | 'onCompositionUpdate', 98 | 'onContextMenu', 99 | 'onCopy', 100 | 'onCut', 101 | 'onDblclick', 102 | 'onDeactivate', 103 | 'onDirty', 104 | 'onDrag', 105 | 'onDragDrop', 106 | 'onDragEnd', 107 | 'onDragGesture', 108 | 'onDragOver', 109 | 'onDrop', 110 | 'onExecCommand', 111 | 'onFocus', 112 | 'onFocusIn', 113 | 'onFocusOut', 114 | 'onGetContent', 115 | 'onHide', 116 | 'onInit', 117 | 'onInput', 118 | 'onKeyDown', 119 | 'onKeyPress', 120 | 'onKeyUp', 121 | 'onLoadContent', 122 | 'onMouseDown', 123 | 'onMouseEnter', 124 | 'onMouseLeave', 125 | 'onMouseMove', 126 | 'onMouseOut', 127 | 'onMouseOver', 128 | 'onMouseUp', 129 | 'onNodeChange', 130 | 'onObjectResizeStart', 131 | 'onObjectResized', 132 | 'onObjectSelected', 133 | 'onPaste', 134 | 'onPostProcess', 135 | 'onPostRender', 136 | 'onPreProcess', 137 | 'onProgressState', 138 | 'onRedo', 139 | 'onRemove', 140 | 'onReset', 141 | 'onResizeEditor', 142 | 'onSaveContent', 143 | 'onSelectionChange', 144 | 'onSetAttrib', 145 | 'onSetContent', 146 | 'onShow', 147 | 'onSubmit', 148 | 'onUndo', 149 | 'onVisualAid' 150 | ]; 151 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## Unreleased 8 | 9 | ## 9.1.1 - 2025-10-31 10 | 11 | ### Fixed 12 | - `EventObj` interface not being importable. 13 | - `licenseKey` was not defaulted to `gpl`. #INT-3380 14 | 15 | ## 9.1.0 - 2025-07-31 16 | 17 | ### Changed 18 | - Defaulted cloudChannel to `8`. #INT-3351 19 | - Updated peer dependency to support tinymce `8`. #INT-3351 20 | 21 | ## 9.0.0 - 2025-05-29 22 | 23 | ### Added 24 | - Added 'readonly' property. #TINY-11907 25 | 26 | ### Fixed 27 | - Updated dependencies. #INT-3324 28 | - Fixed a bug where `[disabled]` property was ignored while using `[ngModel]`. #INT-3328 29 | 30 | ### Changed 31 | - Moved tinymce dependency to be a optional peer dependency. #INT-3324 32 | - Updated tinymce dev dependency to version ^7 from 5.10.7 so now all internal tinymce types point to version 7. #INT-3324 33 | - The 'disabled' property is now mapped to editor's 'disabled' option if Tiny >= 7.6.0 is used. #TINY-11907 34 | 35 | ## 8.0.1 - 2024-07-12 36 | 37 | ### Fixed 38 | - Added rxjs ^7.4.0 as a peer dependency. Since last major release now uses rxjs v7 imports. #INT-3306 39 | - `id` prop no longer logs a console warning on any use. #INT-3299 40 | 41 | ## 8.0.0 - 2024-04-29 42 | 43 | ### Added 44 | - Support for Angular 16 & 17. Angular 15 is still supported via ^7.0.0 #INT-3274 45 | - Support for the OnPush change detection strategy. #INT-2974 46 | - Support for TinyMCE version 7. #INT-3292 47 | - Added `licenseKey` prop. #INT-3292 48 | - Added events `onInput`, `onCompositionEnd`, `onCompositionStart` & `onCompositionUpdate`. #INT-3292 49 | - Added a JSDoc link to the TinyMCE 7 Angular Technical Reference docs page. #INT-3292 50 | 51 | ### Improved 52 | - Updated Storybook to v8, as well as now using CSFv3 components. #INT-3274 53 | - Improved `cloudChannel` type. #INT-3292 54 | 55 | ### Fixed 56 | - Updated CI library to latest 57 | - Updated dependencies. #INT-3274 58 | - Usage of RxJS deprecated operators. #INT-3274 59 | 60 | ## 7.0.0 - 2022-06-27 61 | 62 | ### Added 63 | - Support for Angular 14 64 | 65 | ## 6.0.1 - 2022-04-09 66 | 67 | ### Fixed 68 | - Disabling bug 69 | 70 | ## 6.0.0 - 2022-04-08 71 | 72 | ### Changed 73 | - License changed to MIT 74 | - Default cloud channel to '6' 75 | 76 | ## 5.0.1 - 2022-01-21 77 | 78 | ### Fixed 79 | - Dependencies issues having to manually install tinymce to get Types 80 | 81 | ## 5.0.0 - 2022-01-12 82 | 83 | ### Added 84 | - Support for Angular 13 85 | 86 | ## 4.2.5 - 2021-11-30 87 | 88 | ### Added 89 | - Types 90 | 91 | ### Fixed 92 | - Performance of removing event listeners 93 | - Initializing the editor when component has been destroyed 94 | - Setting disabling state 95 | 96 | ## 4.2.4 - 2021-05-31 97 | 98 | ### Fixed 99 | - Updated dependencies 100 | - Updated dependencies 101 | 102 | ## 4.2.2 - 2021-03-18 103 | 104 | ### Added 105 | - Event `ResizeEditor` to event handler 106 | - Warning on multiple ediotrs with same Id 107 | 108 | ## 4.2.1 - 2021-02-10 109 | 110 | ### Added 111 | - Beehive-flow release process 112 | - Support for Angular 11 113 | - Adopted beehive-flow release process 114 | - Support for Angular 11 115 | 116 | ## 4.2.0 - 2020-09-16 117 | 118 | ### Added 119 | - Added `allowedEvents` to specify what events are emitted by the component 120 | - Added `ignoreEvents` to blacklist events not to be emitted by the component 121 | 122 | ### Removed 123 | - Remove`change` event being emitted on initialization if the value is not changed by the editor 124 | 125 | ## 4.1.0 - 2020-07-20 126 | 127 | ### Added 128 | - Added `onInitNgModel` event 129 | - Use `input` instead of `keyup` as default modelEvent 130 | 131 | ## 4.0.0 - 2020-07-07 132 | 133 | ### Added 134 | - Compatibility with Angular ^10.0.0 compatibility 135 | 136 | ### Changed 137 | - Changed peer dependencies to support Angular 9 and 10 138 | 139 | ## 3.6.1 - 2020-05-26 140 | 141 | ### Changed 142 | - Setting the initial value on the editor now propagates the editor's content 143 | 144 | ## 3.6.0 - 2020-05-22 145 | 146 | ### Added 147 | - Added `modelEvents` property to update NgModel 148 | 149 | ## 3.5.2 - 2020-05-11 150 | 151 | ### Fixed 152 | - Fixed event binding order. 153 | 154 | ## 3.5.1 - 2020-04-30 155 | 156 | ### Fixed 157 | - Upgraded jquery in dev dependencies in response to security alert. 158 | 159 | ## 3.5.0 - 2020-03-02 160 | 161 | ### Added 162 | - Added new `TINYMCE_SCRIPT_SRC` injection token. To be used in a dependency injection provider to specify an external version of TinyMCE to load 163 | 164 | ## 3.4.0 - 2020-01-31 165 | 166 | ### Added 167 | - Added new `outputFormat` property for specifying the format of content emitted to form controls 168 | 169 | ## 3.3.1 - 2019-09-23 170 | 171 | ### Added 172 | - Added tslib as a dependency. Inlined tslib helpers caused an issue for the Angular Ivy compiler 173 | 174 | ## 3.3.0 - 2019-08-20 175 | 176 | ### Changed 177 | - Changed peer dependencies to support Angular 5 178 | 179 | ## 3.2.1 - 2019-08-16 180 | 181 | ### Changed 182 | - Changed referrer policy to origin to allow cloud caching 183 | 184 | ## 3.2.0 - 2019-07-01 185 | 186 | ### Added 187 | - Added a getter for obtaining a reference to the editor 188 | 189 | ### Fixed 190 | - Fixed a bug that made EventEmitters run outside of NgZone. Patch contributed by garrettld #GH-95 191 | 192 | ## 3.1.0 - 2019-06-06 193 | 194 | ### Added 195 | - Angular 8 support 196 | 197 | ### Changed 198 | - Changed the CDN URL to use `cdn.tiny.cloud` 199 | 200 | ## 3.0.1 - 2019-04-21 201 | 202 | ### Fixed 203 | - Fixed a bug where `ControlValueAccessor.writeValue()` or setting content programmatically would set `FormControl` pristine/dirty flags 204 | 205 | ## 3.0.0 - 2019-02-11 206 | 207 | ### Changed 208 | - Changed default cloudChannel to `'5'`. 209 | 210 | ## 2.5.0 - 2019-01-17 211 | 212 | ### Added 213 | - Add EditorComponent to public api. 214 | 215 | ## 2.4.1 - 2019-01-09 216 | 217 | ### Fixed 218 | - Fixed a bug where `FormGroup.reset()` didn't clear the editor content when used in a formgroup. Patch contributed by nishanthkarthik. 219 | 220 | ## 2.4.0 - 2019-01-07 221 | 222 | ### Added 223 | - Make editor always invoke touched callback on blur. Patch contributed by joensindholt 224 | 225 | ## 2.3.3 - 2018-12-14 226 | 227 | ### Fixed 228 | - Improved documentation. 229 | 230 | ## 2.3.2 - 2018-12-03 231 | 232 | ### Added 233 | - Angular 7 support 234 | 235 | ## 2.3.1 - 2018-10-10 236 | 237 | ### Fixed 238 | - Fixed incorrect documentation in readme.md file. 239 | 240 | ## 2.3.0 - 2018-10-08 241 | 242 | ### Added 243 | - Added platform detection to make the package work better with SSR. 244 | 245 | ## 2.2.0 - 2018-09-26 246 | 247 | ### Added 248 | - Added support for disabling the editor via the `disabled` attribute. 249 | 250 | ## 2.1.0 - 2018-09-24 251 | 252 | ### Changed 253 | - Changed `inline` attribute to accept truthy values, so you can now do this: `` instead of the earlier ``. 254 | 255 | ### Fixed 256 | - Fixed bug where textarea was being added to editor content if id was set. 257 | 258 | ## 2.0.1 - 2018-09-03 259 | 260 | ### Fixed 261 | - Fixed broken links in readme. 262 | 263 | ## 2.0.0 - 2018-05-08 264 | 265 | ### Added 266 | - Angular 6 support 267 | 268 | ### Changed 269 | - rxjs version 6 270 | 271 | ## 1.0.9 - 2018-05-04 272 | 273 | ### Added 274 | - Added `undo` and `redo` events to ngModel onChangeCallback. 275 | 276 | ## 1.0.8 - 2018-04-26 277 | 278 | ### Fixed 279 | - Added null check before removing editor to check that tinymce is actually available. 280 | 281 | ## 1.0.7 - 2018-04-06 282 | 283 | ### Fixed 284 | - Fixed bug with onInit not firing and removed onPreInit shorthand. 285 | 286 | ## 1.0.6 - 2018-04-06 287 | 288 | ### Changed 289 | - Changed so tinymce.init is run outside of angular with ngzone. 290 | 291 | ## 1.0.5 - 2018-02-15 292 | 293 | ### Fixed 294 | - Fixed bug where is wasn't possible to set inline in the init object, only on the shorthand. 295 | 296 | ## 1.0.4 - 2018-02-14 297 | 298 | ### Fixed 299 | - Fixed bug where the component threw errors because it tried to setContent on an editor that had not been initialized fully. 300 | 301 | ## 1.0.3 - 2018-02-13 302 | 303 | ### Fixed 304 | - Fixed bug where the component threw errors on change when not used together with the forms module. 305 | -------------------------------------------------------------------------------- /tinymce-angular-component/src/main/ts/editor/editor.component.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/parameter-properties */ 2 | import { isPlatformBrowser, CommonModule } from '@angular/common'; 3 | import { 4 | AfterViewInit, 5 | Component, 6 | ElementRef, 7 | forwardRef, 8 | Inject, 9 | Input, 10 | NgZone, 11 | OnDestroy, 12 | PLATFORM_ID, 13 | InjectionToken, 14 | Optional, 15 | ChangeDetectorRef, 16 | ChangeDetectionStrategy 17 | } from '@angular/core'; 18 | import { FormsModule, ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; 19 | import { Subject, takeUntil } from 'rxjs'; 20 | import { getTinymce } from '../TinyMCE'; 21 | import { listenTinyMCEEvent, bindHandlers, isTextarea, mergePlugins, uuid, noop, isNullOrUndefined, setMode } from '../utils/Utils'; 22 | import * as DisabledUtils from '../utils/DisabledUtils'; 23 | import { EventObj, Events } from './Events'; 24 | import { ScriptLoader } from '../utils/ScriptLoader'; 25 | import type { Editor as TinyMCEEditor, TinyMCE } from 'tinymce'; 26 | 27 | type EditorOptions = Parameters[0]; 28 | 29 | export const TINYMCE_SCRIPT_SRC = new InjectionToken('TINYMCE_SCRIPT_SRC'); 30 | 31 | const EDITOR_COMPONENT_VALUE_ACCESSOR = { 32 | provide: NG_VALUE_ACCESSOR, 33 | useExisting: forwardRef(() => EditorComponent), 34 | multi: true 35 | }; 36 | 37 | export type Version = `${'4' | '5' | '6' | '7' | '8'}${'' | '-dev' | '-testing' | `.${number}` | `.${number}.${number}`}`; 38 | 39 | @Component({ 40 | selector: 'editor', 41 | template: '', 42 | styles: [ ':host { display: block; }' ], 43 | providers: [ EDITOR_COMPONENT_VALUE_ACCESSOR ], 44 | standalone: true, 45 | imports: [ CommonModule, FormsModule ], 46 | changeDetection: ChangeDetectionStrategy.OnPush 47 | }) 48 | 49 | /** 50 | * @see {@link https://www.tiny.cloud/docs/tinymce/7/angular-ref/} for the TinyMCE Angular Technical Reference 51 | */ 52 | export class EditorComponent extends Events implements AfterViewInit, ControlValueAccessor, OnDestroy { 53 | 54 | @Input() public cloudChannel: Version = '8'; 55 | @Input() public apiKey = 'no-api-key'; 56 | @Input() public licenseKey = 'gpl'; 57 | @Input() public init?: EditorOptions; 58 | @Input() public id = ''; 59 | @Input() public initialValue?: string; 60 | @Input() public outputFormat?: 'html' | 'text'; 61 | @Input() public inline?: boolean; 62 | @Input() public tagName?: string; 63 | @Input() public plugins?: string; 64 | @Input() public toolbar?: string | string[]; 65 | @Input() public modelEvents = 'change input undo redo'; 66 | @Input() public allowedEvents?: string | string[]; 67 | @Input() public ignoreEvents?: string | string[]; 68 | @Input() 69 | public set readonly(val) { 70 | this._readonly = val; 71 | if (this._editor) { 72 | setMode(this._editor, val ? 'readonly' : 'design'); 73 | } 74 | } 75 | 76 | public get readonly() { 77 | return this._readonly; 78 | } 79 | 80 | @Input() 81 | public set disabled(val) { 82 | this._disabled = val; 83 | if (this._editor) { 84 | if (DisabledUtils.isDisabledOptionSupported(this._editor)) { 85 | this._editor.options.set('disabled', val ?? false); 86 | } else { 87 | setMode(this._editor, val ? 'readonly' : 'design'); 88 | } 89 | } 90 | } 91 | 92 | public get disabled() { 93 | return this._disabled; 94 | } 95 | 96 | public get editor() { 97 | return this._editor; 98 | } 99 | 100 | public ngZone: NgZone; 101 | 102 | private _elementRef: ElementRef; 103 | private _element?: HTMLElement; 104 | private _disabled?: boolean; 105 | private _readonly?: boolean; 106 | private _editor?: TinyMCEEditor; 107 | 108 | private onTouchedCallback = noop; 109 | private onChangeCallback: any; 110 | 111 | private destroy$ = new Subject(); 112 | 113 | public constructor( 114 | elementRef: ElementRef, 115 | ngZone: NgZone, 116 | private cdRef: ChangeDetectorRef, 117 | @Inject(PLATFORM_ID) private platformId: object, 118 | @Optional() @Inject(TINYMCE_SCRIPT_SRC) private tinymceScriptSrc?: string 119 | ) { 120 | super(); 121 | this._elementRef = elementRef; 122 | this.ngZone = ngZone; 123 | } 124 | 125 | public writeValue(value: string | null): void { 126 | if (this._editor && this._editor.initialized) { 127 | this._editor.setContent(isNullOrUndefined(value) ? '' : value); 128 | } else { 129 | this.initialValue = value === null ? undefined : value; 130 | } 131 | } 132 | 133 | public registerOnChange(fn: (_: any) => void): void { 134 | this.onChangeCallback = fn; 135 | } 136 | 137 | public registerOnTouched(fn: any): void { 138 | this.onTouchedCallback = fn; 139 | } 140 | 141 | public setDisabledState(isDisabled: boolean): void { 142 | this.disabled = isDisabled; 143 | } 144 | 145 | public ngAfterViewInit() { 146 | if (isPlatformBrowser(this.platformId)) { 147 | this.id = this.id || uuid('tiny-angular'); 148 | this.inline = this.inline !== undefined ? this.inline !== false : !!(this.init?.inline); 149 | this.createElement(); 150 | if (getTinymce() !== null) { 151 | this.initialise(); 152 | } else if (this._element && this._element.ownerDocument) { 153 | // Caretaker note: the component might be destroyed before the script is loaded and its code is executed. 154 | // This will lead to runtime exceptions if `initialise` will be called when the component has been destroyed. 155 | ScriptLoader.load(this._element.ownerDocument, this.getScriptSrc()) 156 | .pipe(takeUntil(this.destroy$)) 157 | .subscribe(this.initialise); 158 | } 159 | } 160 | } 161 | 162 | public ngOnDestroy() { 163 | this.destroy$.next(); 164 | 165 | if (getTinymce() !== null) { 166 | getTinymce().remove(this._editor); 167 | } 168 | } 169 | 170 | public createElement() { 171 | const tagName = typeof this.tagName === 'string' ? this.tagName : 'div'; 172 | this._element = document.createElement(this.inline ? tagName : 'textarea'); 173 | if (this._element) { 174 | const existingElement = document.getElementById(this.id); 175 | if (existingElement && existingElement !== this._elementRef.nativeElement) { 176 | /* eslint no-console: ["error", { allow: ["warn"] }] */ 177 | console.warn(`TinyMCE-Angular: an element with id [${this.id}] already exists. Editors with duplicate Id will not be able to mount`); 178 | } 179 | this._element.id = this.id; 180 | if (isTextarea(this._element)) { 181 | this._element.style.visibility = 'hidden'; 182 | } 183 | this._elementRef.nativeElement.appendChild(this._element); 184 | } 185 | } 186 | 187 | public initialise = (): void => { 188 | const finalInit: EditorOptions = { 189 | ...this.init, 190 | selector: undefined, 191 | target: this._element, 192 | inline: this.inline, 193 | disabled: this.disabled, 194 | readonly: this.readonly, 195 | license_key: this.licenseKey, 196 | plugins: mergePlugins((this.init && this.init.plugins) as string, this.plugins), 197 | toolbar: this.toolbar || (this.init && this.init.toolbar), 198 | setup: (editor: TinyMCEEditor) => { 199 | this._editor = editor; 200 | 201 | listenTinyMCEEvent(editor, 'init', this.destroy$).subscribe(() => { 202 | this.initEditor(editor); 203 | }); 204 | 205 | bindHandlers(this, editor, this.destroy$); 206 | 207 | if (this.init && typeof this.init.setup === 'function') { 208 | this.init.setup(editor); 209 | } 210 | 211 | if (this.disabled === true) { 212 | if (DisabledUtils.isDisabledOptionSupported(editor)) { 213 | this._editor.options.set('disabled', this.disabled); 214 | } else { 215 | this._editor.mode.set('readonly'); 216 | } 217 | } 218 | } 219 | }; 220 | 221 | if (isTextarea(this._element)) { 222 | this._element.style.visibility = ''; 223 | } 224 | 225 | this.ngZone.runOutsideAngular(() => { 226 | getTinymce().init(finalInit); 227 | }); 228 | }; 229 | 230 | private getScriptSrc() { 231 | return isNullOrUndefined(this.tinymceScriptSrc) ? 232 | `https://cdn.tiny.cloud/1/${this.apiKey}/tinymce/${this.cloudChannel}/tinymce.min.js` : 233 | this.tinymceScriptSrc; 234 | } 235 | 236 | private initEditor(editor: TinyMCEEditor) { 237 | listenTinyMCEEvent(editor, 'blur', this.destroy$).subscribe(() => { 238 | this.cdRef.markForCheck(); 239 | this.ngZone.run(() => this.onTouchedCallback()); 240 | }); 241 | 242 | listenTinyMCEEvent(editor, this.modelEvents, this.destroy$).subscribe(() => { 243 | this.cdRef.markForCheck(); 244 | this.ngZone.run(() => this.emitOnChange(editor)); 245 | }); 246 | 247 | if (typeof this.initialValue === 'string') { 248 | this.ngZone.run(() => { 249 | editor.setContent(this.initialValue as string); 250 | if (editor.getContent() !== this.initialValue) { 251 | this.emitOnChange(editor); 252 | } 253 | if (this.onInitNgModel !== undefined) { 254 | this.onInitNgModel.emit(editor as unknown as EventObj); 255 | } 256 | }); 257 | } 258 | } 259 | 260 | private emitOnChange(editor: TinyMCEEditor) { 261 | if (this.onChangeCallback) { 262 | this.onChangeCallback(editor.getContent({ format: this.outputFormat })); 263 | } 264 | } 265 | } 266 | --------------------------------------------------------------------------------