├── .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 | Undo
2 | Redo
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 | {{ isDisabled ? 'enable' : 'disable' }}
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 | {{ isReadonly ? 'Escape readonly' : 'Enter readonly' }}
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 |
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 |
32 |
33 |
34 |
Title: {{post.title}}
35 |
36 |
37 |
Raw: {{post | json}}
38 |
39 |
40 | Edit
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 |
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 |
--------------------------------------------------------------------------------