diff --git a/CHANGELOG.md b/CHANGELOG.md index 60031a4e..69e52e8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# Change log +# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), diff --git a/Jenkinsfile b/Jenkinsfile index 101b5e83..05f689ee 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -5,9 +5,9 @@ mixedBeehiveFlow( container: [ resourceRequestMemory: '3Gi', resourceLimitMemory: '3Gi' ], testPrefix: 'Tiny-React', platforms: [ - [ browser: 'chrome', headless: true ], + [ browser: 'chrome', provider: 'lambdatest', os: 'macOS Sonoma', buckets: 1 ], [ browser: 'firefox', provider: 'aws', buckets: 1 ], - [ browser: 'safari', provider: 'lambdatest', buckets: 1 ] + [ browser: 'safari', provider: 'lambdatest', os: 'macOS Sonoma', buckets: 1 ] ], customSteps: { stage("update storybook") { diff --git a/README.md b/README.md index 30408b46..5f57546a 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,66 @@ -# Official TinyMCE React component +# Official React Component for TinyMCE ## About -This package is a thin wrapper around [TinyMCE](https://github.com/tinymce/tinymce) to make it easier to use in a React application. +Official React component for TinyMCE, the rich text editor. It wraps TinyMCE as a React `` component, supporting controlled and uncontrolled usage. By default, it pulls TinyMCE from the Tiny Cloud CDN unless configured to use a different setup, such as self-hosting the [tinymce NPM package](https://www.npmjs.com/package/tinymce). -* If you need detailed documentation on TinyMCE, see: [TinyMCE Documentation](https://www.tiny.cloud/docs/tinymce/8/). -* For the TinyMCE React Quick Start, see: [TinyMCE Documentation - React Integration](https://www.tiny.cloud/docs/tinymce/8/react-cloud/). -* For the TinyMCE React Technical Reference, see: [TinyMCE Documentation - TinyMCE React Technical Reference](https://www.tiny.cloud/docs/tinymce/8/react-ref/). -* For our quick demos, check out the TinyMCE React [Storybook](https://tinymce.github.io/tinymce-react/). +## Quickstart +### Cloud CDN -### Issues +1. [Sign up for a Tiny Cloud account](https://www.tiny.cloud/pricing/) to receive a Tiny Cloud API key. +1. Then in your React project: + 1. Run `npm install @tinymce/tinymce-react` + 1. Include the following code: -Have you found an issue with tinymce-react or do you have a feature request? Open up an [issue](https://github.com/tinymce/tinymce-react/issues) and let us know or submit a [pull request](https://github.com/tinymce/tinymce-react/pulls). *Note: For issues concerning TinyMCE please visit the [TinyMCE repository](https://github.com/tinymce/tinymce).* \ No newline at end of file + ```js + import React from 'react'; + import { Editor } from '@tinymce/tinymce-react'; + + export default function App() { + return ( + <> +

TinyMCE React demo

+ + + ); + } + ``` + 1. Update the `apiKey` prop on the `Editor` component to include your Tiny Cloud API key. + +For more information: [Using TinyMCE with React - Cloud CDN](https://www.tiny.cloud/docs/tinymce/latest/react-cloud/) + +### Self hosted via NPM package + +Using TinyMCE from NPM in a React project requires a couple of extra steps. See the documentation for more information: [Using TinyMCE with React - Self hosted via NPM](https://www.tiny.cloud/docs/tinymce/latest/react-pm/) + + +## Demos + +For our quick demos, check out the TinyMCE React [Storybook](https://tinymce.github.io/tinymce-react/). + +## Detailed documentation + +* [TinyMCE React Technical Reference](https://www.tiny.cloud/docs/tinymce/latest/react-ref/). +* [TinyMCE Documentation](https://www.tiny.cloud/docs/tinymce/latest/). + + +## Issues + +Have you found an issue with `tinymce-react` or do you have a feature request? +Open up an [issue](https://github.com/tinymce/tinymce-react/issues) and let us know +or submit a [pull request](https://github.com/tinymce/tinymce-react/pulls). + +_Note: for issues concerning TinyMCE please visit the [TinyMCE repository](https://github.com/tinymce/tinymce)._ + + +## License + +`tinymce-react` is licensed under the MIT License. See the LICENSE.txt file for details. + +Depending on use case, the TinyMCE core editor can be used under either GPL-2.0-or-later or a commercial license. See the [tinymce package](https://www.npmjs.com/package/tinymce) for details. diff --git a/contributing.md b/contributing.md index b561e52d..c970dad8 100644 --- a/contributing.md +++ b/contributing.md @@ -1,8 +1,5 @@ # Contributing -This project follows the [beehive-flow](https://github.com/tinymce/beehive-flow/) branching process ("Basic process - release from main branch"). -Please read the [beehive-flow readme](https://github.com/tinymce/beehive-flow/blob/main/README.md) for more information. -This mainly affects branching and merging for Tiny staff. +External contributors are free to submit PRs against the `main` branch. -External contributors are free to submit PRs against the `main` branch. -Note that contributions will require signing of our Contributor License Agreement. +In order for Tiny to accept your contribution, we will need legal permission from you to use your code. Please email `legal@tiny.cloud` with the subject `CLA request for [your Github username]`. Our legal team will send you a Contributors License Agreement (CLA) which you will need to sign and return. diff --git a/package.json b/package.json index b627bd6e..719a7f0f 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "description": "Official TinyMCE React Component", + "description": "Official React component for the TinyMCE rich text editor.", "repository": { "url": "https://github.com/tinymce/tinymce-react" }, @@ -22,11 +22,21 @@ "build-storybook": "storybook build", "deploy-storybook": "storybook build && gh-pages -d ./storybook-static -u 'tiny-bot ' --nojekyll" }, - "keywords": [], + "keywords": [ + "tinymce", + "react", + "component", + "editor", + "wysiwyg", + "rich-text-editor", + "html", + "contenteditable" + ], "author": "Ephox Corporation DBA Tiny Technologies, Inc.", "license": "MIT", "dependencies": { - "prop-types": "^15.8.1" + "prop-types": "^15.8.1", + "tinymce-8": "npm:tinymce@^8.0.0" }, "peerDependencies": { "react": "^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0", @@ -74,7 +84,7 @@ "tinymce-6": "npm:tinymce@^6", "tinymce-7": "npm:tinymce@^7", "tinymce-7.5": "npm:tinymce@7.5", - "tinymce-8": "npm:tinymce@^8.0.0", + "tinymce-premium": "^8.9.2", "typescript": "~5.8.2", "vite": "^7.1.5" }, diff --git a/src/test/ts/alien/Loader.tsx b/src/test/ts/alien/Loader.tsx index 67fd19b6..f39a70d1 100644 --- a/src/test/ts/alien/Loader.tsx +++ b/src/test/ts/alien/Loader.tsx @@ -1,4 +1,4 @@ -import { before, context } from '@ephox/bedrock-client'; +import { after, before, context } from '@ephox/bedrock-client'; import { Fun, Optional } from '@ephox/katamari'; import { Remove, SugarElement, SugarNode } from '@ephox/sugar'; import { VersionLoader } from '@tinymce/miniature'; @@ -51,13 +51,25 @@ export const render = async (props: Partial = {}, container: HTMLElem .filter(SugarNode.isHTMLElement) .map((val) => val.dom) .fold(() => reject('Could not find DOMNode'), (DOMNode) => { - resolve({ - ref: ref as React.RefObject, - editor, - DOMNode, - }); - } - ); + // This is a workaround to avoid a race condition occurring in tinymce 8 where licenseKeyManager is still validating the license key + // after global tinymce is removed in a clean up. Specifically, it happens when unloading/loading different versions of TinyMCE + if (editor.licenseKeyManager) { + editor.licenseKeyManager.validate({}).then(() => { + resolve({ + ref: ref as React.RefObject, + editor, + DOMNode, + }); + // eslint-disable-next-line no-console + }).catch((reason) => console.warn(reason)); + } else { + resolve({ + ref: ref as React.RefObject, + editor, + DOMNode, + }); + } + }); }, 0); }); } @@ -109,5 +121,13 @@ export const withVersion = (version: Version, fn: (render: RenderWithVersion) => }); fn(render as RenderWithVersion); + + after(() => { + // remove script tag for version + const scriptTag = document.querySelector(`script[src*="tinymce-${version}"]`); + if (scriptTag) { + scriptTag.remove(); + } + }); }); }; diff --git a/src/test/ts/browser/EditorBehaviorTest.ts b/src/test/ts/browser/EditorBehaviorTest.ts index 9f4f25fc..cb1445cb 100644 --- a/src/test/ts/browser/EditorBehaviorTest.ts +++ b/src/test/ts/browser/EditorBehaviorTest.ts @@ -1,6 +1,6 @@ import { Assertions, Waiter } from '@ephox/agar'; -import { describe, it } from '@ephox/bedrock-client'; +import { afterEach, describe, it } from '@ephox/bedrock-client'; import { TinyAssertions, TinySelections } from '@ephox/mcagar'; import { PlatformDetection } from '@ephox/sand'; import { EditorEvent, Events, Editor as TinyMCEEditor } from 'tinymce'; @@ -29,6 +29,10 @@ describe('EditorBehaviourTest', () => { const eventStore = EventStore(); + afterEach(() => { + eventStore.clearState(); + }); + VERSIONS.forEach((version) => Loader.withVersion(version, (render) => { it('Assert structure of tinymce and tinymce-react events', async () => { @@ -67,7 +71,6 @@ describe('EditorBehaviourTest', () => { Assertions.assertEq('First arg should be new content', '

Initial Content

', events[0].editorEvent); Assertions.assertEq('Second arg should be editor', true, isEditor(events[0].editor)); }); - eventStore.clearState(); }); it('onEditorChange should only fire when the editors content changes', async () => { @@ -81,7 +84,6 @@ describe('EditorBehaviourTest', () => { eventStore.each('onEditorChange', (events) => { Assertions.assertEq('onEditorChange should have been fired once', 1, events.length); }); - eventStore.clearState(); }); it('Should be able to register an event handler after initial render', async () => { @@ -89,7 +91,7 @@ describe('EditorBehaviourTest', () => { await ctx.reRender({ onSetContent: eventStore.createHandler('onSetContent') }); TinyAssertions.assertContent(ctx.editor, '

Initial Content

'); - await Waiter.pWait(0); // Wait for React's state updates to complete before setting new content + await Waiter.pWait(100); // Wait for React's state updates to complete before setting new content ctx.editor.setContent('

New Content

'); eventStore.each('onSetContent', (events) => { @@ -99,7 +101,6 @@ describe('EditorBehaviourTest', () => { events[0].editorEvent.content ); }); - eventStore.clearState(); }); it('Providing a new event handler and re-rendering should unbind old handler and bind new handler', async () => { @@ -115,7 +116,7 @@ describe('EditorBehaviourTest', () => { eventStore.clearState(); ctx.editor.setContent('

Initial Content

'); await ctx.reRender({ onSetContent: eventStore.createHandler('NewHandler') }); - await Waiter.pWait(0); // Wait for React's state updates to complete before setting new content + await Waiter.pWait(100); // Wait for React's state updates to complete before setting new content ctx.editor.setContent('

New Content

'); eventStore.each('InitialHandler', (events) => { @@ -132,18 +133,21 @@ describe('EditorBehaviourTest', () => { events[0].editorEvent.content ); }); - - eventStore.clearState(); }); + it('INT-3226: onEditorChange is triggered only once after calling insertContent', async () => { using ctx = await render({ onEditorChange: eventStore.createHandler('onEditorChange') }); const { editor } = ctx; editor.setContent('

abc

'); - await Waiter.pTryUntilPredicate('Editor content is set to correct value', () => ctx.editor.getContent() === '

abc

'); + await Waiter.pTryUntilPredicate('Editor content is set to correct value', () => { + return ctx.editor.getContent() === '

abc

'; + }); eventStore.clearState(); TinySelections.setSelection(editor, [ 0, 0 ], 1, [ 0, 0 ], 2); editor.insertContent('e'); - await Waiter.pTryUntilPredicate('Editor content is set to correct value', () => ctx.editor.getContent() === '

aec

'); + await Waiter.pTryUntilPredicate('Editor content is set to correct value', () => { + return ctx.editor.getContent() === '

aec

'; + }); eventStore.each('onEditorChange', (events) => { Assertions.assertEq( 'onEditorChange should have been triggered once', diff --git a/src/test/ts/browser/LoadTinyTest.ts b/src/test/ts/browser/LoadTinyTest.ts index f6865b85..de531e9e 100644 --- a/src/test/ts/browser/LoadTinyTest.ts +++ b/src/test/ts/browser/LoadTinyTest.ts @@ -42,7 +42,7 @@ describe('LoadTinyTest', () => { CLOUD_VERSIONS.forEach((version) => { it(`Should be able to load TinyMCE from Cloud (${version})`, async () => { - const apiKey = 'a-fake-api-key'; + const apiKey = VALID_API_KEY; using _ = await render({ apiKey, cloudChannel: version }); assertTinymceVersion(version); Assertions.assertEq( @@ -55,10 +55,24 @@ describe('LoadTinyTest', () => { it(`Should be able to load TinyMCE (${version}) in hybrid`, async () => { using _ = await render({ tinymceScriptSrc: [ - `/project/node_modules/tinymce-${version}/tinymce.min.js`, - `https://cdn.tiny.cloud/1/${VALID_API_KEY}/tinymce/${version}/cloud-plugins.min.js?tinydrive=${version}` + `/project/node_modules/tinymce-${version}/tinymce.min.js` ], - plugins: [ 'tinydrive' ] + apiKey: VALID_API_KEY, + licenseKey: 'gpl', + plugins: [ 'formatpainter' ], + // TinyMCE 8 requires the Commercial License Key Manager to validate an apiKey. It's not + // served from Tiny Cloud, so for a self-hosted core it needs to load from the local + // `tinymce-premium` package instead. See https://www.tiny.cloud/docs/tinymce/latest/license-key/ + init: version === '8' ? { + external_plugins: { + licensekeymanager: '/project/node_modules/tinymce-premium/plugins/licensekeymanager/plugin.min.js', + formatpainter: `https://cdn.tiny.cloud/1/${VALID_API_KEY}/tinymce-plugins/formatpainter/${version}/plugin.min.js` + } + } : { + external_plugins: { + formatpainter: `https://cdn.tiny.cloud/1/${VALID_API_KEY}/tinymce-plugins/formatpainter/${version}/plugin.min.js`, + } + }, }); assertTinymceVersion(version); Assertions.assertEq( @@ -67,9 +81,9 @@ describe('LoadTinyTest', () => { Global.tinymce.baseURI.path ); Assertions.assertEq( - 'The tinydrive plugin should have defaults for the cloud', - `https://cdn.tiny.cloud/1/${VALID_API_KEY}/tinymce-plugins/tinydrive/${version}/plugin.min.js`, - (Global.tinymce.defaultOptions || Global.tinymce.defaultSettings)?.custom_plugin_urls?.tinydrive + 'The formatpainter plugin should have been loaded from Cloud', + `https://cdn.tiny.cloud/1/${VALID_API_KEY}/tinymce-plugins/formatpainter/${version}`, + Global.tinymce.PluginManager.urls.formatpainter ); }); }); diff --git a/yarn.lock b/yarn.lock index bbfa5ff9..0657aec3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8756,9 +8756,14 @@ tinyglobby@^0.2.15: integrity sha512-zaOHwmiP1EqTeLRXAvVriDb00JYnfEjWGPdKEuac7MiZJ5aiDMZ4Unc98Gmajn+PBljOmO1GKV6G0KwWn3+k8A== "tinymce-8@npm:tinymce@^8.0.0": - version "8.2.0" - resolved "https://registry.yarnpkg.com/tinymce/-/tinymce-8.2.0.tgz#3fde03d91a1a8fd2d33de6a378843724e1341e2e" - integrity sha512-HZasRaAYQmePi+jjP4YFqHMtBJvbUaxDmjlXNf96uuyqsH6zZkGtJiFpsNi80Ec+L+snyF0OTDjrWVYDbkUrgQ== + version "8.9.2" + resolved "https://registry.yarnpkg.com/tinymce/-/tinymce-8.9.2.tgz#0e6cb1d0230a27afe701651c1dbd99e2dac7ffdb" + integrity sha512-Qh6ADSC0x1F/KsrupfResNJFAaINJQBtfVjx5zxxWJ0ZrGtaKldtAXDMI6uEA57s2n+0rEtXmtbitiU4zV7+0Q== + +tinymce-premium@^8.9.2: + version "8.9.2" + resolved "https://registry.yarnpkg.com/tinymce-premium/-/tinymce-premium-8.9.2.tgz#e3beb35676d19709ba7ee33c5d54af46941084fa" + integrity sha512-9fx33G7Omixa7BlPqakx6xEs4jwQzA1EyPcS/5KenTWi+/yVEA2ILSkLeE4pfWW5qyY8ogdMYmGZDd476UJuEQ== tinymce@^8.0.2: version "8.2.0"