diff --git a/.babelrc.json b/.babelrc.json new file mode 100644 index 000000000..7aafc53b2 --- /dev/null +++ b/.babelrc.json @@ -0,0 +1,18 @@ +{ + "sourceType": "unambiguous", + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "chrome": 100, + "safari": 15, + "firefox": 91 + } + } + ], + "@babel/preset-typescript", + "@babel/preset-react" + ], + "plugins": [] +} \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index 204e417a1..90acd3f2d 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -11,7 +11,17 @@ "sourceType": "module" }, "rules": { - "@tinymce/prefer-fun": "off" + "@tinymce/prefer-fun": "off", + "@typescript-eslint/no-unsafe-argument": "off" + } + }, + { + "files": [ + "src/test/**/*" + ], + "rules": { + "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], + "no-var": "off" // Without this the `using` keyword causes eslint to throw an error during linting. } } ] diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..a6064c23b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @tinymce/tinymce-reviewers \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 3d1ba6c0b..3949d80b2 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -9,7 +9,7 @@ assignees: '' **What is the current behavior?** -**Please provide the steps to reproduce and if possible a minimal demo of the problem via [codesandbox.io](https://codesandbox.io/s/6yz4jzrrxw?fontsize=14) or similar.** +**Please provide the steps to reproduce and if possible a minimal demo of the problem via [codesandbox.io](https://codesandbox.io/p/sandbox/tinymce-react-yny726?file=src/index.tsx) or similar.** **What is the expected behavior?** diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md index 1e5210542..408a358e9 100644 --- a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md +++ b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md @@ -1,6 +1,6 @@ **Before submitting a pull request** please do the following: -1. Fork [the repository](https://github.com/tinymce/tinymce-react) and create your branch from `master` +1. Fork [the repository](https://github.com/tinymce/tinymce-react) and create your branch from `main` 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!) 3. Ensure that the tests pass: `npm run test` 4. Make sure to sign the CLA. \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..6867e71eb --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..60f15e6be --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,46 @@ +name: "CodeQL" + +## Github workflow code scanning +# Configure this file to setup code scanning for the repository +# Code scanning uses Github actions minutes. To learn more: https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] +## Schedule cron running +# schedule: +# - cron: "0 0 1 * *" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ javascript ] + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + queries: +security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000..b3ab61289 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,26 @@ +name: "Stale" + +# This uses Github actions minutes. To learn more: https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions + +on: + schedule: + - cron: "0 0 * * *" + +jobs: + stale: + name: Close stale issues + runs-on: ubuntu-latest + permissions: + # contents: write # only for delete-branch option + issues: write + # pull-requests: write # only makes issues stale, not PRs + + steps: + - uses: actions/stale@v8 + with: + stale-issue-message: 'This issue is stale because it has been open for 30 days with no activity. Respond with a comment or this will be closed in 7 days.' + days-before-stale: 30 # Days before an issue becomes stale + days-before-close: 7 # Days before a stale labelled issue is closed + any-of-labels: 'needs: more info' + labels-to-remove-when-unstale: 'needs: more info' + diff --git a/.storybook/main.js b/.storybook/main.js deleted file mode 100644 index 26dfeaf66..000000000 --- a/.storybook/main.js +++ /dev/null @@ -1,10 +0,0 @@ -module.exports = { - "stories": [ - "../src/**/*.stories.mdx", - "../src/**/*.stories.@(js|jsx|ts|tsx)" - ], - "addons": [ - "@storybook/addon-links", - "@storybook/addon-essentials" - ] -} \ No newline at end of file diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 000000000..bb4153444 --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,18 @@ +export default { + stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], + addons: [ + "@storybook/addon-links", + "@storybook/addon-essentials", + "@storybook/addon-interactions", + ], + core: { + disableTelemetry: true + }, + docs: { + autodocs: "tag", + }, + framework: { + name: "@storybook/react-vite", + options: {} + } +}; \ No newline at end of file diff --git a/.storybook/preview.js b/.storybook/preview.js deleted file mode 100644 index 645f52de9..000000000 --- a/.storybook/preview.js +++ /dev/null @@ -1,10 +0,0 @@ - -export const parameters = { - actions: { argTypesRegex: "^on[A-Z].*" }, - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/, - }, - }, -} \ No newline at end of file diff --git a/.storybook/preview.ts b/.storybook/preview.ts new file mode 100644 index 000000000..ff58bbda2 --- /dev/null +++ b/.storybook/preview.ts @@ -0,0 +1,15 @@ +import type { Preview } from "@storybook/react"; + +const preview: Preview = { + parameters: { + actions: { argTypesRegex: "^on[A-Z].*" }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + }, +}; + +export default preview; diff --git a/CHANGELOG.md b/CHANGELOG.md index 5929c78de..60031a4e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,17 +5,142 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased + +## 6.3.0 - 2025-07-31 + +### Changed +- Set the default `cloudChannel` to `8`. #INT-3350 +- Updated peer dependency to support tinymce `8`. #INT-3350 + +## 6.2.1 - 2025-06-03 + +### Fixed +- Project build failure caused by a missing `@tinymce/miniature` dependency. #INT-3347 + +## 6.2.0 - 2025-05-29 + +### Changed +- The `disabled` property now toggles the `disabled` option. #TINY-11906 + +### Added +- Added `readonly` property that can be used to toggle the `readonly` mode. #TINY-11906 + +## 6.1.0 - 2025-03-31 + +### Added +- Support for `react` version `^19.0.0` by updating the `react` and `react-dom` in the `peerDependencies` to `^19.0.0` + +### Fixed +- The `onEditorChange` callback was called three times when content was inserted with `insertContent` editor API. #INT-3226 + +## 6.0.0 - 2025-02-21 + +### Fixed +- Updated dependencies. #INT-3324 + +### Changed +- Moved tinymce dependency to peerDependencies, as well as making it optional. #INT-3324 + +## 5.1.0 - 2024-06-11 + +### Added +- Added `tabIndex` prop. Community PR acknowledgement: Grand Julivan + +## 5.0.0 - 2024-03-27 + +### Added +- Added `licenseKey` property that overrides the TinyMCE `license_key` init property. #INT-3291 +- Added events `onInput`, `onCompositionEnd`, `onCompositionStart` & `onCompositionUpdate`. #INT-3291 +- Added a JSDoc link to the TinyMCE 7 React Technical Reference docs page. #INT-3291 + +### Improved +- Improved `cloudChannel` type. #INT-3291 +- Updated to Storybook v8 and it now uses react-vite as a bundler/builder instead of webpack. #INT-3291 +- Storybook examples now use CSFv3 components. #INT-3291 +- Tests now run against TinyMCE version 7 as well. #INT-3291 + +### Changed +- Updated dependencies. #INT-3291 +- Changed default cloudChannel to `'7'`. #INT-3291 + +### Fixed +- `readonly` init property is now properly typed as undefined, as it's overriden by the integration. #INT-3287 + +## 4.3.2 - 2023-11-20 + +### Fixed +- Removed `storybook-deploy` dependency. #INT-3254 + +## 4.3.1 - 2023-11-16 + +### Fixed +- Reverted accidental change to allowed tinymce dependency versions in 4.3.0 +- Updated dependencies + +## 4.3.0 - 2023-01-18 + +### Added +- Added events `onScriptsLoad` and `onScriptsLoadError` for the loading of the script tags when no `tinymce` global is available. + +### Changed +- Changed `tinymceScriptSrc` prop so it can now accept an array of scripts to make hybrid mode easier to use. + An empty array will avoid loading scripts but will call `onScriptsLoadError` when `tinymce` is missing. + +### Fixed +- Updated CI library to latest +- Updated dependencies. + +## 4.2.0 - 2022-08-03 + +### Added +- Added prop for the event `CommentChange`. + +### Fixed +- Added `CommentChange` to the list of events that trigger the `onEditorChange` handler. +- Source the tinymce object from the window associated with the target element. +- Updated dependencies. + +## 4.1.0 - 2022-05-12 + +### Added +- Added props for the events `SkinLoadError`, `ThemeLoadError`, `ModelLoadError`, `PluginLoadError`, `IconsLoadError` and `LanguageLoadError`. + +### Fixed +- Updated issue templates with updated codesandbox +- Updated the readme with links to the TinyMCE 6 documentation +- Updated the security issue reporting link to the TinyMCE 6 documentation + +## 4.0.0 - 2022-04-08 + +### Changed +- License changed to MIT (from Apache 2) this matches TinyMCE 6 license +- Changed default cloudChannel to `'6'`. + +### Removed +- Removed `outputFormat` prop. If text output is required call `editor.getContent({ format: 'text' })` in any of the event callbacks. + +## 3.14.0 - 2022-04-04 + +### Changed +- Allow react version "^18.0.0" + +### Fixed +- Updated dependencies. + ## 3.13.1 - 2022-01-20 + ### Fixed - Updated dependencies - Use `editor.mode.set(...)` when available instead of the deprecated `editor.setMode(..)` which will be removed in TinyMCE 6. - Addressed lint errors ## 3.13.0 - 2021-10-06 + ### Added - Added new `rollback` prop for changing the delay after which a rollback is performed (when value and editor content don't match) or disabling it completely. ## 3.12.8 - 2021-10-06 + ### Changed - Wait 10 seconds (increased from 1) for the target element to be in the DOM before giving up. @@ -23,43 +148,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Updated dependencies. ## 3.12.7 - 2021-10-05 + ### Fixed -- The integration will now initialize in a shadow dom. +- The integration will now initialize in a shadow dom. - Updated dependencies ## 3.12.6 - 2021-05-20 + ### Fixed - As the tinymce dependency is only used for types allow all versions after 5.5.1 when types were first added, but prefer the latest when unspecified. ## 3.12.5 - 2021-05-20 + ### Fixed - Fixed ignoring the `toolbar` prop when it contains a falsy value when it should only be ignored if it is undefined or null. - Ensure that the editor is initialized attached to a document. - Updated dependencies ## 3.12.4 - 2021-05-12 + ### Fixed - Allow use of react version "^16.7.0" as well as "^17.0.1" to correct the unintended breaking change introduced in release 3.9. - Updated dependencies ## 3.12.3 - 2021-04-30 + ### Fixed - Avoid trying to get a bookmark for an unfocused inline editor - Recreated the storybook examples in the new recommend format as the format we were using broke on an upgrade - Updated dependencies ## 3.12.2 - 2021-04-01 + ### Fixed - Slowed down the change rollback to 200 milliseconds to allow async frameworks a chance to set the value. #INT-2475 - Fixed cursor position recording for rolling back formatting-only changes. - Updated dependencies ## 3.12.1 - 2021-03-30 + ### Fixed - Re-added change event as a trigger of `onEditorChange` so toggling formatting will be detected. #INT-2473 - Defer evaluating browser capabilities until after the component has mounted to allow server side rendering. #INT-2472 ## 3.12.0 - 2021-03-26 + ### Added - Storybook demo for a controlled component with a fixed value. INT-2352 - Storybook demo for a controlled component with a maximum length. INT-2462 @@ -68,11 +201,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - When used as a controlled component the editor will rollback a change if it is not set via the `value` prop within a millisecond. INT-2352 ## 3.11.1 - 2021-03-24 + ### Fixed - Stopped inline editor grabbing focus when the value is set - Caught exceptions thrown while trying to restore selection after setting content ## 3.11.0 - 2021-03-23 + ### Changed - Updates to `initialValue` after editor initialization will now be detected and applied and will result in a complete reset of undo state. #INT-2367 - During `componentDidUpdate` the `value` prop will be applied if it is different to the current editor content even when it is not different to the previous value of the `value` prop. #INT-2372 @@ -85,203 +220,252 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Deprecated - The `outputFormat` prop will be removed in a future release. If text output is required call `editor.getContent({ format: 'text' })` in any of the event callbacks. #INT-2368 -## [3.10.4] - 2021-03-10 +## 3.10.4 - 2021-03-10 + ### Fixed - Check for editor changes on `"compositionend"` event to more accurately trigger `onEditorChange`. #INT-2348 - Updated dependencies to latest available -## [3.10.3] - 2021-03-04 +## 3.10.3 - 2021-03-04 + ### Fixed - Updated dependencies to latest available -## [3.10.2] - 2021-02-20 -## Fixed +## 3.10.2 - 2021-02-20 + +### Fixed - Event handlers are registered at setup time so props like `onBeforeRenderUI` will now be called. #INT-2325 -## [3.10.1] - 2021-02-01 +## 3.10.1 - 2021-02-01 + ### Fixed - Fixed CI build -## [3.10.0] - 2021-02-01 +## 3.10.0 - 2021-02-01 + ### Fixed - Fixed event binding to lookup handlers at call time instead of rebinding on every change. -## [3.9.0] - 2021-01-11 +## 3.9.0 - 2021-01-11 + ### Changed - Adopted beehive-flow branching and versioning process/tooling. - Changed the changelog formatting - Updated dependencies to latest available -## [3.8.4] - 2020-12-16 +## 3.8.4 - 2020-12-16 + ### Fixed - Updated dependencies to latest available -## [3.8.3] - 2020-12-08 +## 3.8.3 - 2020-12-08 + ### Changed - Updated TinyMCE types to 5.6 release. ### Fixed - Fixed event binding to only rebind handler on changes to the specific property instead of any property. -## [3.8.2] - 2020-12-03 +## 3.8.2 - 2020-12-03 + ### Fixed - Fixed external changes not generating undo levels -## [3.8.1] - 2020-10-22 +## 3.8.1 - 2020-10-22 + ### Fixed - Fixed an issue where the component would throw an error when unmounted while loading -## [3.8.0] - 2020-10-08 +## 3.8.0 - 2020-10-08 + ### Added - Added types from TinyMCE 5.5 release. -## [3.7.0] - 2020-09-28 +## 3.7.0 - 2020-09-28 + ### Added - Added `scriptLoading` prop with settings `async`, `defer` and `delay` to modify the loading behaviour of the TinyMCE script tag -## [3.6.1] - 2020-09-07 +## 3.6.1 - 2020-09-07 + ### Fixed - Upgraded dependencies to latest available. -## [3.6.0] - 2020-05-07 +## 3.6.0 - 2020-05-07 + ### Fixed - Fixed an issue that allowed events to be fired during `componentWillUnmount` -## [3.5.1] - 2020-04-30 +## 3.5.1 - 2020-04-30 + ### Fixed - Remove `util` module from dependencies by purging usage in code. - Upgraded jquery in dev dependencies in response to security alert. -## [3.5.0] - 2020-02-24 +## 3.5.0 - 2020-02-24 + ### Added - Added new `tinymceScriptSrc` prop for specifying an external version of TinyMCE to lazy load -## [3.4.0] - 2020-01-31 +## 3.4.0 - 2020-01-31 + ### Added - Added new `outputFormat` prop for specifying the format of the content emitted via the `onEditorChange` event -## [3.3.2] - 2019-08-29 +## 3.3.2 - 2019-08-29 + ### Fixed - Fixed an issue that caused `onEditorChange` to fire multiple times -## [3.3.1] - 2019-08-16 +## 3.3.1 - 2019-08-16 + ### Fixed - Changed referrer policy to origin to allow cloud caching -## [3.3.0] - 2019-07-29 +## 3.3.0 - 2019-07-29 + ### Fixed - Fixed an issue that made the editor ignore new event handlers provided through props - Removed use of deprecated lifecycle hooks `componentWillMount` and `componentWillReceiveProps` -## [3.2.0] - 2019-06-04 +## 3.2.0 - 2019-06-04 + ### Fixed - Changed the CDN URL to use `cdn.tiny.cloud` -## [3.1.1] - 2019-05-10 +## 3.1.1 - 2019-05-10 + ### Changed - Removed preinstall script -## [3.1.0] - 2019-05-10 +## 3.1.0 - 2019-05-10 + ### Changed - Added the editor as a second argument to `onEditorChange` - Exported Typescript prop types -## [3.0.1] - 2019-02-11 +## 3.0.1 - 2019-02-11 + ### Changed - Updated readme on cloud channels. -## [3.0.0] - 2019-02-11 +## 3.0.0 - 2019-02-11 + ### Changed - Changed default cloudChannel to `'5'`. -## [2.6.1] - 2019-02-07 +## 2.6.1 - 2019-02-07 + ### Fixed - Changed `react` and `react-dom` to be peerDependencies and devDependencies. -## [2.6.0] - 2019-02-07 +## 2.6.0 - 2019-02-07 + ### Fixed - Changed `react` and `react-dom` to be peerDependencies. -## [2.5.0] - 2019-01-24 +## 2.5.0 - 2019-01-24 + ### Changed - Loosened cloudChannel proptype to take any string to be able to use the locked version channels. -## [2.4.0] - 2018-11-02 +## 2.4.0 - 2018-11-02 + ### Added - Added `textareaName` prop that sets the name attribute on the textarea for use in forms. -## [2.3.0] - 2018-10-01 +## 2.3.0 - 2018-10-01 + ### Added - Added `disabled` prop that sets the editor into readonly mode. -## [2.2.6] - 2018-09-03 +## 2.2.6 - 2018-09-03 + ### Fixed - Fixed broken links in readme. -## [2.2.5] - 2018-04-23 +## 2.2.5 - 2018-04-23 + ### Fixed - Added a nullcheck in componentWillUnmount to check that tinymce is available before running remove. -## [2.2.4] - 2018-04-06 +## 2.2.4 - 2018-04-06 + ### Fixed - Removed onPreInit shorthand as it never worked. -## [2.2.3] - 2018-04-06 +## 2.2.3 - 2018-04-06 + ### Fixed - Fixed a bug with onInit not working. -## [2.2.2] - 2018-04-05 +## 2.2.2 - 2018-04-05 + ### Fixed - Fixed a bug with values set while the editor was initializing. -## [2.2.1] - 2018-04-03 +## 2.2.1 - 2018-04-03 + ### Fixed - No change, pushed to fix missing readme on npm. -## [2.2.0] - 2018-02-19 +## 2.2.0 - 2018-02-19 + ### Added - Added functionality so you can use the editor as a controlled component by setting the `value` property and using the `onEditorChange` event. -## [2.1.4] - 2018-02-16 +## 2.1.4 - 2018-02-16 + ### Fixed - Fixed bug where is wasn't possible to set inline in the init object, only on the shorthand. -## [2.1.3] - 2018-01-17 +## 2.1.3 - 2018-01-17 + ### Added - Added `plugins` and `toolbar` shorthands. -## [2.1.2] - 2018-01-08 +## 2.1.2 - 2018-01-08 + ### Changed - Use `target` on element instead of `selector` in init config. -## [2.1.1] - 2018-01-03 +## 2.1.1 - 2018-01-03 + ### Added - Fix so init setup callback gets called like it should. -## [2.1.0] - 2017-12-18 +## 2.1.0 - 2017-12-18 + ### Added - Added auto-loading of TinyMCE from TinyMCE Cloud -## [2.0.3] - 2017-11-22 +## 2.0.3 - 2017-11-22 + ### Fixed - Added check for `initialValue` prop to prevent javascript error that was shown when `tinymce.setContent` was called with undefined. -## [2.0.2] - 2017-11-22 +## 2.0.2 - 2017-11-22 + ### Fixed - Accidentally published 2.0.1 without building first, published this version to fix that and added prepare hook to prevent the same thing from happening again. -## [2.0.1] - 2017-11-21 +## 2.0.1 - 2017-11-21 + ### Added - Added description to package.json and improved readme wording. -## [2.0.0] - 2017-11-08 +## 2.0.0 - 2017-11-08 + ### Added - Add support for `tagName` prop when using editor inline to define what element you want to initialize the editor on. + ### Changed -- *BREAKING!* Switched `value` prop to `initialValue` to make it clearer what it actually does. +- *BREAKING!* Switched `value` prop to `initialValue` to make it clearer what it actually does. + ### Fixed - Only bind whitelisted events. -## [1.0.0] - 2017-11-07 +## 1.0.0 - 2017-11-07 + ### Added - Initial Release. diff --git a/Jenkinsfile b/Jenkinsfile index 8b694b1fa..101b5e83a 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,49 +1,28 @@ #!groovy -@Library('waluigi@v4.1.0') _ - -standardProperties() - -node("primary") { - echo "Clean workspace" - cleanWs() - - stage("checkout") { - checkout localBranch(scm) - } - - stage("dependencies") { - yarnInstall() - } - - stage("stamp") { - sh "yarn beehive-flow stamp" - } - - stage("build") { - sh "yarn build" - } - - stage("lint") { - sh "yarn lint" - } - - bedrockBrowsers testDirs: [ "src/test/ts/browser" ] - - stage("update storybook") { - def status = beehiveFlowStatus(); - if (status.branchState == 'releaseReady' && status.isLatest) { - sshagent (credentials: ['dcd9940f-08e1-4b75-bf0c-63fff1913540']) { - sh 'yarn storybook-to-ghpages' +@Library('waluigi@release/7') _ + +mixedBeehiveFlow( + container: [ resourceRequestMemory: '3Gi', resourceLimitMemory: '3Gi' ], + testPrefix: 'Tiny-React', + platforms: [ + [ browser: 'chrome', headless: true ], + [ browser: 'firefox', provider: 'aws', buckets: 1 ], + [ browser: 'safari', provider: 'lambdatest', buckets: 1 ] + ], + customSteps: { + stage("update storybook") { + def status = beehiveFlowStatus() + if (status.branchState == 'releaseReady' && status.isLatest) { + tinyGit.withGitHubSSHCredentials { + exec('yarn deploy-storybook') + } + } else { + echo "Skipping as is not latest release" } - } else { - echo "Skipping as is not latest release" - } - } - - stage("publish") { - sshagent(credentials: ['jenkins2-github']) { - sh "yarn beehive-flow publish" - sh "yarn beehive-flow advance-ci" } + }, + preparePublish:{ + yarnInstall() + sh "yarn build" } -} +) diff --git a/LICENSE.txt b/LICENSE.txt index 27e6427a3..816c87d78 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,13 +1,21 @@ -Copyright 2017-present Tiny Technologies, Inc. +MIT License -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at +Copyright (c) 2022 Ephox Corporation DBA Tiny Technologies, Inc. - http://www.apache.org/licenses/LICENSE-2.0 +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. \ No newline at end of file +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index f85fd16b2..30408b462 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ This package is a thin wrapper around [TinyMCE](https://github.com/tinymce/tinymce) to make it easier to use in a React application. -* If you need detailed documentation on TinyMCE, see: [TinyMCE Documentation](https://www.tiny.cloud/docs/). -* For the TinyMCE React Quick Start, see: [TinyMCE Documentation - React Integration](https://www.tiny.cloud/docs/integrations/react/#tinymcereactintegrationquickstartguide). -* For the TinyMCE React Technical Reference, see: [TinyMCE Documentation - TinyMCE React Technical Reference](https://www.tiny.cloud/docs/integrations/react/#tinymcereacttechnicalreference). +* 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/). diff --git a/SECURITY.md b/SECURITY.md index ff16d792d..720754b60 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,4 +2,4 @@ ## Reporting a Vulnerability -For details on how to report security issues to Tiny, refer to the [Reporting TinyMCE security issues documentation](https://www.tiny.cloud/docs/advanced/security/#reportingtinymcesecurityissues). \ No newline at end of file +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/#reporting-tinymce-security-issues). \ No newline at end of file diff --git a/package.json b/package.json index cd00540e7..a04f4b0a5 100644 --- a/package.json +++ b/package.json @@ -18,54 +18,66 @@ "test-manual": "bedrock -f src/test/ts/**/*Test.ts", "build": "yarn run clean && tsc -p ./tsconfig.es2015.json && tsc -p ./tsconfig.cjs.json", "watch": "tsc -w -p ./tsconfig.es2015.json", - "storybook": "start-storybook -p 6006", - "build-storybook": "build-storybook" + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build", + "deploy-storybook": "storybook build && gh-pages -d ./storybook-static -u 'tiny-bot ' --nojekyll" }, "keywords": [], - "author": "Tiny Technologies", - "license": "Apache-2.0", + "author": "Ephox Corporation DBA Tiny Technologies, Inc.", + "license": "MIT", "dependencies": { - "prop-types": "^15.6.2", - "tinymce": "^5.5.1" + "prop-types": "^15.6.2" }, "peerDependencies": { - "react": "^17.0.1 || ^16.7.0", - "react-dom": "^17.0.1 || ^16.7.0" + "react": "^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0", + "react-dom": "^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0", + "tinymce": "^8.0.0 || ^7.0.0 || ^6.0.0 || ^5.5.1" + }, + "peerDependenciesMeta": { + "tinymce": { + "optional": true + } }, "devDependencies": { - "@babel/core": "^7.17.5", - "@ephox/agar": "^6.0.3", - "@ephox/bedrock-client": "^13.0.0", - "@ephox/bedrock-server": "^13.1.0", - "@ephox/katamari": "^8.1.1", - "@ephox/mcagar": "^7.0.4", - "@ephox/sand": "^5.0.3", - "@ephox/sugar": "^8.1.1", - "@storybook/addon-actions": "^6.4.19", - "@storybook/addon-essentials": "^6.4.19", - "@storybook/addon-links": "^6.4.19", - "@storybook/react": "^6.4.19", - "@storybook/storybook-deployer": "^2.8.10", - "@tinymce/beehive-flow": "^0.17.0", - "@tinymce/eslint-plugin": "^2.0.1", - "@tinymce/miniature": "^4.0.0", - "@types/node": "^17.0.21", - "@types/prop-types": "^15.7.4", - "@types/react": "^17.0.39", - "@types/react-dom": "^17.0.11", - "awesome-typescript-loader": "^5.2.1", - "babel-loader": "^8.2.3", - "core-js": "^3.21.1", - "raf": "^3.4.1", - "react": "^17.0.2", - "react-dom": "^17.0.2", - "rimraf": "^3.0.2", + "@babel/core": "^7.26.9", + "@babel/preset-env": "^7.26.9", + "@babel/preset-react": "^7.26.3", + "@babel/preset-typescript": "^7.26.0", + "@ephox/agar": "^8.0.1", + "@ephox/bedrock-client": "^15.0.0", + "@ephox/bedrock-server": "^15.0.3-alpha.0", + "@ephox/katamari": "^9.1.5", + "@ephox/mcagar": "^9.0.0-alpha.0", + "@ephox/sand": "^6.0.9", + "@ephox/sugar": "^9.2.1", + "@storybook/addon-essentials": "^8.6.4", + "@storybook/addon-interactions": "^8.6.4", + "@storybook/addon-links": "^8.6.4", + "@storybook/blocks": "^8.6.4", + "@storybook/react": "^8.6.4", + "@storybook/react-vite": "^8.6.4", + "@tinymce/beehive-flow": "^0.19.0", + "@tinymce/eslint-plugin": "^2.4.0", + "@tinymce/miniature": "^6.0.0", + "@types/node": "^22.13.10", + "@types/prop-types": "^15.7.14", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "gh-pages": "^6.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "rimraf": "^6.0.1", + "storybook": "^8.6.4", + "tinymce": "^8.0.1", "tinymce-4": "npm:tinymce@^4", "tinymce-5": "npm:tinymce@^5", - "ts-loader": "^9.2.6", - "typescript": "~4.5.5", - "webpack": "^5.69.1" + "tinymce-6": "npm:tinymce@^6", + "tinymce-7": "npm:tinymce@^7", + "tinymce-7.5": "npm:tinymce@7.5", + "tinymce-8": "npm:tinymce@^8.0.0", + "typescript": "~5.8.2", + "vite": "^6.2.1" }, - "version": "3.13.1", + "version": "6.3.0", "name": "@tinymce/tinymce-react" } diff --git a/src/main/ts/Events.ts b/src/main/ts/Events.ts index 105b87c30..3d1ce01c4 100644 --- a/src/main/ts/Events.ts +++ b/src/main/ts/Events.ts @@ -1,12 +1,4 @@ -/** - * Copyright (c) 2017-present, Ephox, Inc. - * - * This source code is licensed under the Apache 2 license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import { Editor as TinyMCEEditor, EditorEvent, Events } from 'tinymce'; +import type { Editor as TinyMCEEditor, EditorEvent, Events } from 'tinymce'; export type EventHandler = (a: EditorEvent, editor: TinyMCEEditor) => unknown; @@ -16,6 +8,9 @@ export interface INativeEvents { onBeforePaste: EEventHandler<'beforepaste'>; onBlur: EEventHandler<'blur'>; onClick: EEventHandler<'click'>; + onCompositionEnd: EEventHandler<'compositionend'>; + onCompositionStart: EEventHandler<'compositionstart'>; + onCompositionUpdate: EEventHandler<'compositionupdate'>; onContextMenu: EEventHandler<'contextmenu'>; onCopy: EEventHandler<'copy'>; onCut: EEventHandler<'cut'>; @@ -29,6 +24,7 @@ export interface INativeEvents { onFocus: EEventHandler<'focus'>; onFocusIn: EEventHandler<'focusin'>; onFocusOut: EEventHandler<'focusout'>; + onInput: EEventHandler<'input'>; onKeyDown: EEventHandler<'keydown'>; onKeyPress: EEventHandler<'keypress'>; onKeyUp: EEventHandler<'keyup'>; @@ -53,6 +49,7 @@ export interface ITinyEvents { onBeforeSetContent: EEventHandler<'BeforeSetContent'>; onChange: EventHandler; onClearUndos: EEventHandler<'ClearUndos'>; + onCommentChange: EventHandler; onDeactivate: EEventHandler<'deactivate'>; onDirty: EventHandler; onExecCommand: EEventHandler<'ExecCommand'>; @@ -78,6 +75,14 @@ export interface ITinyEvents { onSubmit: EventHandler; onUndo: EEventHandler<'Undo'>; onVisualAid: EventHandler; + onSkinLoadError: EEventHandler<'SkinLoadError'>; + onThemeLoadError: EEventHandler<'ThemeLoadError'>; + onModelLoadError: EEventHandler<'ModelLoadError'>; + onPluginLoadError: EEventHandler<'PluginLoadError'>; + onIconsLoadError: EEventHandler<'IconsLoadError'>; + onLanguageLoadError: EEventHandler<'LanguageLoadError'>; + onScriptsLoad: () => void; + onScriptsLoadError: (err: unknown) => void; } export interface IEvents extends INativeEvents, ITinyEvents {} diff --git a/src/main/ts/ScriptLoader.ts b/src/main/ts/ScriptLoader.ts deleted file mode 100644 index 7ee1e9d84..000000000 --- a/src/main/ts/ScriptLoader.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Copyright (c) 2017-present, Ephox, Inc. - * - * This source code is licensed under the Apache 2 license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import { uuid } from './Utils'; - -export type callbackFn = () => void; -export interface IStateObj { - listeners: callbackFn[]; - scriptId: string; - scriptLoading: boolean; - scriptLoaded: boolean; -} - -const createState = (): IStateObj => ({ - listeners: [], - scriptId: uuid('tiny-script'), - scriptLoading: false, - scriptLoaded: false -}); - -interface ScriptLoader { - load: (doc: Document, url: string, async: boolean, defer: boolean, delay: number, callback: callbackFn) => void; - reinitialize: () => void; -} - -const CreateScriptLoader = (): ScriptLoader => { - let state: IStateObj = createState(); - - const injectScriptTag = (scriptId: string, doc: Document, url: string, async: boolean, defer: boolean, callback: callbackFn) => { - const scriptTag = doc.createElement('script'); - scriptTag.referrerPolicy = 'origin'; - scriptTag.type = 'application/javascript'; - scriptTag.id = scriptId; - scriptTag.src = url; - scriptTag.async = async; - scriptTag.defer = defer; - - const handler = () => { - scriptTag.removeEventListener('load', handler); - callback(); - }; - scriptTag.addEventListener('load', handler); - if (doc.head) { - doc.head.appendChild(scriptTag); - } - }; - - const load = (doc: Document, url: string, async: boolean, defer: boolean, delay: number, callback: callbackFn) => { - - const scriptTagInjection = () => injectScriptTag( - state.scriptId, doc, url, async, defer, - () => { - state.listeners.forEach((fn) => fn()); - state.scriptLoaded = true; - } - ); - - if (state.scriptLoaded) { - callback(); - } else { - state.listeners.push(callback); - if (!state.scriptLoading) { - state.scriptLoading = true; - if (delay > 0) { - setTimeout(scriptTagInjection, delay); - } else { - scriptTagInjection(); - } - } - } - }; - - // Only to be used by tests. - const reinitialize = () => { - state = createState(); - }; - - return { - load, - reinitialize - }; -}; - -const ScriptLoader = CreateScriptLoader(); - -export { - ScriptLoader -}; \ No newline at end of file diff --git a/src/main/ts/ScriptLoader2.ts b/src/main/ts/ScriptLoader2.ts new file mode 100644 index 000000000..f507a7c48 --- /dev/null +++ b/src/main/ts/ScriptLoader2.ts @@ -0,0 +1,161 @@ +import { uuid } from './Utils'; + +export type CallbackFn = () => void; + +export interface ScriptItem { + src: string; + async?: boolean; + defer?: boolean; +} + +interface Id { + id: string; +} + +const injectScriptTag = (doc: Document, item: ScriptItem & Id, handler: (id: string, err?: unknown) => void) => { + const scriptTag = doc.createElement('script'); + scriptTag.referrerPolicy = 'origin'; + scriptTag.type = 'application/javascript'; + scriptTag.id = item.id; + scriptTag.src = item.src; + scriptTag.async = item.async ?? false; + scriptTag.defer = item.defer ?? false; + + const loadHandler = () => { + scriptTag.removeEventListener('load', loadHandler); + scriptTag.removeEventListener('error', errorHandler); + handler(item.src); + }; + const errorHandler = (err: unknown) => { + scriptTag.removeEventListener('load', loadHandler); + scriptTag.removeEventListener('error', errorHandler); + handler(item.src, err); + }; + + scriptTag.addEventListener('load', loadHandler); + scriptTag.addEventListener('error', errorHandler); + + if (doc.head) { + doc.head.appendChild(scriptTag); + } +}; + +interface ScriptState { + id: string; + src: string; + done: boolean; + error?: unknown; + handlers: ((src: string, err?: unknown) => void)[]; +} + +const createDocumentScriptLoader = (doc: Document) => { + let lookup: Record = {}; + + const scriptLoadOrErrorHandler = (src: string, err?: unknown) => { + const item = lookup[src]; + item.done = true; + item.error = err; + for (const h of item.handlers) { + h(src, err); + } + item.handlers = []; + }; + + const loadScripts = (items: ScriptItem[], success: () => void, failure?: (err: unknown) => void) => { + // eslint-disable-next-line no-console + const failureOrLog = (err: unknown) => failure !== undefined ? failure(err) : console.error(err); + if (items.length === 0) { + failureOrLog(new Error('At least one script must be provided')); + return; + } + let successCount = 0; + let failed = false; + const loaded = (_src: string, err?: unknown) => { + if (failed) { + return; + } + if (err) { + failed = true; + failureOrLog(err); + } else if (++successCount === items.length) { + success(); + } + }; + for (const item of items) { + const existing = lookup[item.src]; + if (existing) { + if (existing.done) { + loaded(item.src, existing.error); + } else { + existing.handlers.push(loaded); + } + } else { + // create a new entry + const id = uuid('tiny-'); + lookup[item.src] = { + id, + src: item.src, + done: false, + error: null, + handlers: [ loaded ], + }; + injectScriptTag(doc, { id, ...item }, scriptLoadOrErrorHandler); + } + } + }; + + const deleteScripts = () => { + for (const item of Object.values(lookup)) { + const scriptTag = doc.getElementById(item.id); + if (scriptTag != null && scriptTag.tagName === 'SCRIPT') { + scriptTag.parentNode?.removeChild(scriptTag); + } + } + lookup = {}; + }; + + const getDocument = () => doc; + + return { + loadScripts, + deleteScripts, + getDocument + }; +}; + +type DocumentScriptLoader = ReturnType; + +const createScriptLoader = () => { + const cache: DocumentScriptLoader[] = []; + + const getDocumentScriptLoader = (doc: Document) => { + let loader = cache.find((l) => l.getDocument() === doc); + if (loader === undefined) { + loader = createDocumentScriptLoader(doc); + cache.push(loader); + } + return loader; + }; + + const loadList = (doc: Document, items: ScriptItem[], delay: number, success: () => void, failure?: (err: unknown) => void) => { + const doLoad = () => getDocumentScriptLoader(doc).loadScripts(items, success, failure); + if (delay > 0) { + setTimeout(doLoad, delay); + } else { + doLoad(); + } + }; + + const reinitialize = () => { + for (let loader = cache.pop(); loader != null; loader = cache.pop()) { + loader.deleteScripts(); + } + }; + + return { + loadList, + reinitialize + }; +}; + +export const ScriptLoader = createScriptLoader(); \ No newline at end of file diff --git a/src/main/ts/TinyMCE.ts b/src/main/ts/TinyMCE.ts index 1a87f403f..726005549 100644 --- a/src/main/ts/TinyMCE.ts +++ b/src/main/ts/TinyMCE.ts @@ -1,17 +1,7 @@ -/** - * Copyright (c) 2017-present, Ephox, Inc. - * - * This source code is licensed under the Apache 2 license found in the - * LICENSE file in the root directory of this source tree. - * - */ +import type { TinyMCE as TinyMCEGlobal } from 'tinymce'; -import { TinyMCE as TinyMCEGlobal } from 'tinymce'; - -const getGlobal = (): any => (typeof window !== 'undefined' ? window : global); - -const getTinymce = (): TinyMCEGlobal | null => { - const global = getGlobal(); +const getTinymce = (view: Window): TinyMCEGlobal | null => { + const global = view as any; return global && global.tinymce ? global.tinymce : null; }; diff --git a/src/main/ts/Utils.ts b/src/main/ts/Utils.ts index 39ec77f7d..dbba0d229 100644 --- a/src/main/ts/Utils.ts +++ b/src/main/ts/Utils.ts @@ -1,14 +1,7 @@ -/** - * Copyright (c) 2017-present, Ephox, Inc. - * - * This source code is licensed under the Apache 2 license found in the - * LICENSE file in the root directory of this source tree. - * - */ - import { eventPropTypes, IEventPropTypes } from './components/EditorPropTypes'; import { IAllProps } from './components/Editor'; -import { Editor as TinyMCEEditor, EditorEvent } from 'tinymce'; +import type { Editor as TinyMCEEditor, EditorEvent } from 'tinymce'; +import { getTinymce } from './TinyMCE'; export const isFunction = (x: unknown): x is Function => typeof x === 'function'; @@ -114,7 +107,18 @@ export const setMode = (editor: TinyMCEEditor | undefined, mode: 'readonly' | 'd if (editor.mode != null && typeof editor.mode === 'object' && typeof editor.mode.set === 'function') { editor.mode.set(mode); } else { // support TinyMCE 4 - editor.setMode(mode); + (editor as any).setMode(mode); } } -}; \ No newline at end of file +}; + +export const getTinymceOrError = (view: Window) => { + const tinymce = getTinymce(view); + if (!tinymce) { + throw new Error('tinymce should have been loaded into global scope'); + } + + return tinymce; +}; + +export const isDisabledOptionSupported = (editor: TinyMCEEditor) => editor.options && editor.options.isRegistered('disabled'); \ No newline at end of file diff --git a/src/main/ts/components/Editor.tsx b/src/main/ts/components/Editor.tsx index cfd8ba536..d45ce9b51 100644 --- a/src/main/ts/components/Editor.tsx +++ b/src/main/ts/components/Editor.tsx @@ -1,60 +1,162 @@ -/** - * Copyright (c) 2017-present, Ephox, Inc. - * - * This source code is licensed under the Apache 2 license found in the - * LICENSE file in the root directory of this source tree. - * - */ - import * as React from 'react'; +import type { Bookmark, EditorEvent, TinyMCE, Editor as TinyMCEEditor } from 'tinymce'; import { IEvents } from '../Events'; -import { ScriptLoader } from '../ScriptLoader'; -import { getTinymce } from '../TinyMCE'; -import { isFunction, isTextareaOrInput, mergePlugins, uuid, configHandlers, isBeforeInputEventAvailable, isInDoc, setMode } from '../Utils'; +import { ScriptItem, ScriptLoader } from '../ScriptLoader2'; +import { configHandlers, isBeforeInputEventAvailable, + isFunction, isInDoc, isTextareaOrInput, mergePlugins, + setMode, uuid, isDisabledOptionSupported, + getTinymceOrError } from '../Utils'; import { EditorPropTypes, IEditorPropTypes } from './EditorPropTypes'; -import { Bookmark, Editor as TinyMCEEditor, EditorEvent, RawEditorSettings } from 'tinymce'; +import { getTinymce } from '../TinyMCE'; + +const changeEvents = 'change keyup compositionend setcontent CommentChange'; + +type OmitStringIndexSignature = { [K in keyof T as string extends K ? never : K]: T[K] }; + +interface DoNotUse { + __brand: T; +} + +type OmittedInitProps = 'selector' | 'target' | 'readonly' | 'disabled' | 'license_key'; + +type EditorOptions = Parameters[0]; + +export type InitOptions = Omit, OmittedInitProps> & { + selector?: DoNotUse<'selector prop is handled internally by the component'>; + target?: DoNotUse<'target prop is handled internally by the component'>; + readonly?: DoNotUse<'readonly prop is overridden by the component'>; + disabled?: DoNotUse<'disabled prop is overridden by the component'>; + license_key?: DoNotUse<'license_key prop is overridden by the integration, use the `licenseKey` prop instead'>; +} & { [key: string]: unknown }; + +export type Version = `${'4' | '5' | '6' | '7' | '8'}${'' | '-dev' | '-testing' | `.${number}` | `.${number}.${number}`}`; export interface IProps { + /** + * @see {@link https://www.tiny.cloud/docs/tinymce/7/react-ref/#apikey React Tech Ref - apiKey} + * @description TinyMCE API key for deployments using Tiny Cloud. + */ apiKey: string; + /** + * @see {@link https://www.tiny.cloud/docs/tinymce/7/react-ref/#id React Tech Ref - id} + * @description The ID of the element to render the editor into. + */ id: string; + /** + * @see {@link https://www.tiny.cloud/docs/tinymce/7/react-ref/#inline React Tech Ref - inline} + * @description Whether the editor should be rendered inline. Equivalent to the `inline` option in TinyMCE. + */ inline: boolean; + /** + * @see {@link https://www.tiny.cloud/docs/tinymce/7/react-ref/#initialvalue React Tech Ref - initialValue} + * @description The initial HTML content of the editor. + * + * IMPORTANT: Ensure that this is **not** updated by `onEditorChange` or the editor will be unusable. + */ initialValue: string; + /** + * @see {@link https://www.tiny.cloud/docs/tinymce/7/react-ref/#oneditorchange React Tech Ref - onEditorChange} + * @description Used to store the state of the editor outside the component. Typically used for controlled components. + * @param a The current HTML content of the editor. + * @param editor The TinyMCE editor instance. + * @returns void + */ onEditorChange: (a: string, editor: TinyMCEEditor) => void; + /** + * @see {@link https://www.tiny.cloud/docs/tinymce/7/react-ref/#value React Tech Ref - value} + * @description The current HTML content of the editor. Typically used for controlled components. + */ value: string; - init: RawEditorSettings & { selector?: undefined; target?: undefined }; - /** @deprecated use `editor.getContent({format: 'text' })` in `onEditorChange` prop instead */ - outputFormat: 'html' | 'text'; + /** + * @see {@link https://www.tiny.cloud/docs/tinymce/7/react-ref/#init React Tech Ref - init} + * @description Additional settings passed to `tinymce.init()` when initializing the editor. + */ + init: InitOptions; + /** + * @see {@link https://www.tiny.cloud/docs/tinymce/7/react-ref/#tagname React Tech Ref - tagName} + * @description The tag name of the element to render the editor into. Only valid when `inline` is `true`. + */ tagName: string; - cloudChannel: string; - plugins: NonNullable; - toolbar: NonNullable; + /** + * @see {@link https://www.tiny.cloud/docs/tinymce/7/react-ref/#tabIndex React Tech Ref - tabIndex} + * @description The tab index of the element that the editor wraps. + */ + tabIndex: number; + /** + * @see {@link https://www.tiny.cloud/docs/tinymce/7/react-ref/#cloudchannel React Tech Ref - cloudChannel} + * @description The TinyMCE build to use when loading from Tiny Cloud. + */ + cloudChannel: Version; + /** + * @see {@link https://www.tiny.cloud/docs/tinymce/7/react-ref/#plugins React Tech Ref - plugins} + * @description The plugins to load into the editor. Equivalent to the `plugins` option in TinyMCE. + */ + plugins: NonNullable; + /** + * @see {@link https://www.tiny.cloud/docs/tinymce/7/react-ref/#toolbar React Tech Ref - toolbar} + * @description The toolbar to load into the editor. Equivalent to the `toolbar` option in TinyMCE. + */ + toolbar: NonNullable; + /** + * @see {@link https://www.tiny.cloud/docs/tinymce/7/react-ref/#disabled React Tech Ref - disabled} + * @description Whether the editor should be disabled. + */ disabled: boolean; + /** + * @see {@link https://www.tiny.cloud/docs/tinymce/7/react-ref/#readonly React Tech Ref - readonly} + * @description Whether the editor should be readonly. + */ + readonly: boolean; + /** + * @see {@link https://www.tiny.cloud/docs/tinymce/7/react-ref/#textareaname React Tech Ref - textareaName} + * @description Set the `name` attribute of the `textarea` element used for the editor in forms. Only valid in iframe mode. + */ textareaName: string; - tinymceScriptSrc: string; + /** + * @see {@link https://www.tiny.cloud/docs/tinymce/7/react-ref/#tinymcescriptsrc React Tech Ref - tinymceScriptSrc} + * @description The URL of the TinyMCE script to lazy load. + */ + tinymceScriptSrc: string | string[] | ScriptItem[]; + /** + * @see {@link https://www.tiny.cloud/docs/tinymce/7/react-ref/#rollback React Tech Ref - rollback} + * @description The number of milliseconds to wait before reverting to the previous value when the editor's content changes. + */ rollback: number | false; + /** + * @see {@link https://www.tiny.cloud/docs/tinymce/7/react-ref/#scriptloading React Tech Ref - scriptLoading} + * @description Options for how the TinyMCE script should be loaded. + * @property async Whether the script should be loaded with the `async` attribute. + * @property defer Whether the script should be loaded with the `defer` attribute. + * @property delay The number of milliseconds to wait before loading the script. + */ scriptLoading: { async?: boolean; defer?: boolean; delay?: number; }; + /** + * @see {@link https://www.tiny.cloud/docs/tinymce/7/react-ref/#licenseKey React Tech Ref - licenseKey} + * @description Tiny Cloud License Key for when self-hosting TinyMCE. + */ + licenseKey: string; } export interface IAllProps extends Partial, Partial { } -const changeEvents = () => getTinymce()?.Env?.browser?.isIE() ? 'change keyup compositionend setcontent' : 'change input compositionend setcontent'; -const beforeInputEvent = () => isBeforeInputEventAvailable() ? 'beforeinput SelectionChange' : 'SelectionChange'; - +/** + * @see {@link https://www.tiny.cloud/docs/tinymce/7/react-ref/ TinyMCE React Technical Reference} + */ export class Editor extends React.Component { public static propTypes: IEditorPropTypes = EditorPropTypes; public static defaultProps: Partial = { - cloudChannel: '5' + cloudChannel: '8', }; public editor?: TinyMCEEditor; private id: string; - private elementRef: React.RefObject; + private elementRef: React.RefObject; private inline: boolean; private currentContent?: string; private boundHandlers: Record) => unknown>; @@ -64,11 +166,15 @@ export class Editor extends React.Component { public constructor(props: Partial) { super(props); this.id = this.props.id || uuid('tiny-react'); - this.elementRef = React.createRef(); + this.elementRef = React.createRef(); this.inline = this.props.inline ?? this.props.init?.inline ?? false; this.boundHandlers = {}; } + private get view() { + return this.elementRef.current?.ownerDocument.defaultView ?? window; + } + public componentDidUpdate(prevProps: Partial) { if (this.rollbackTimer) { clearTimeout(this.rollbackTimer); @@ -95,7 +201,7 @@ export class Editor extends React.Component { // getBookmark throws exceptions when the editor has not been focused // possibly only in inline mode but I'm not taking chances cursor = localEditor.selection.getBookmark(3); - } catch (e) { /* ignore */ } + } catch (_e) { /* ignore */ } } const valueCursor = this.valueCursor; localEditor.setContent(this.props.value as string); @@ -106,31 +212,48 @@ export class Editor extends React.Component { localEditor.selection.moveToBookmark(bookmark); this.valueCursor = bookmark; break; - } catch (e) { /* ignore */ } + } catch (_e) { /* ignore */ } } } } }); } + + if (this.props.readonly !== prevProps.readonly) { + const readonly = this.props.readonly ?? false; + setMode(this.editor, readonly ? 'readonly' : 'design'); + } + if (this.props.disabled !== prevProps.disabled) { - const disabled = this.props.disabled ?? false; - setMode(this.editor, disabled ? 'readonly' : 'design'); + if (isDisabledOptionSupported(this.editor)) { + this.editor.options.set('disabled', this.props.disabled); + } else { + setMode(this.editor, this.props.disabled ? 'readonly' : 'design'); + } } } } } public componentDidMount() { - if (getTinymce() !== null) { + if (getTinymce(this.view) !== null) { this.initialise(); - } else if (this.elementRef.current && this.elementRef.current.ownerDocument) { - ScriptLoader.load( + } else if (Array.isArray(this.props.tinymceScriptSrc) && this.props.tinymceScriptSrc.length === 0) { + this.props.onScriptsLoadError?.(new Error('No `tinymce` global is present but the `tinymceScriptSrc` prop was an empty array.')); + } else if (this.elementRef.current?.ownerDocument) { + const successHandler = () => { + this.props.onScriptsLoad?.(); + this.initialise(); + }; + const errorHandler = (err: unknown) => { + this.props.onScriptsLoadError?.(err); + }; + ScriptLoader.loadList( this.elementRef.current.ownerDocument, - this.getScriptSrc(), - this.props.scriptLoading?.async ?? false, - this.props.scriptLoading?.defer ?? false, + this.getScriptSources(), this.props.scriptLoading?.delay ?? 0, - this.initialise + successHandler, + errorHandler ); } } @@ -138,8 +261,8 @@ export class Editor extends React.Component { public componentWillUnmount() { const editor = this.editor; if (editor) { - editor.off(changeEvents(), this.handleEditorChange); - editor.off(beforeInputEvent(), this.handleBeforeInput); + editor.off(changeEvents, this.handleEditorChange); + editor.off(this.beforeInputEvent(), this.handleBeforeInput); editor.off('keypress', this.handleEditorChangeSpecial); editor.off('keydown', this.handleBeforeInputSpecial); editor.off('NewBlock', this.handleEditorChange); @@ -156,12 +279,17 @@ export class Editor extends React.Component { return this.inline ? this.renderInline() : this.renderIframe(); } + private beforeInputEvent() { + return isBeforeInputEventAvailable() ? 'beforeinput SelectionChange' : 'SelectionChange'; + } + private renderInline() { const { tagName = 'div' } = this.props; return React.createElement(tagName, { ref: this.elementRef, - id: this.id + id: this.id, + tabIndex: this.props.tabIndex }); } @@ -170,18 +298,34 @@ export class Editor extends React.Component { ref: this.elementRef, style: { visibility: 'hidden' }, name: this.props.textareaName, - id: this.id + id: this.id, + tabIndex: this.props.tabIndex }); } - private getScriptSrc() { - if (typeof this.props.tinymceScriptSrc === 'string') { - return this.props.tinymceScriptSrc; - } else { - const channel = this.props.cloudChannel; - const apiKey = this.props.apiKey ? this.props.apiKey : 'no-api-key'; - return `https://cdn.tiny.cloud/1/${apiKey}/tinymce/${channel}/tinymce.min.js`; + private getScriptSources(): ScriptItem[] { + const async = this.props.scriptLoading?.async; + const defer = this.props.scriptLoading?.defer; + if (this.props.tinymceScriptSrc !== undefined) { + if (typeof this.props.tinymceScriptSrc === 'string') { + return [{ src: this.props.tinymceScriptSrc, async, defer }]; + } + // multiple scripts can be specified which allows for hybrid mode + return this.props.tinymceScriptSrc.map((item) => { + if (typeof item === 'string') { + // async does not make sense for multiple items unless + // they are not dependent (which will be unlikely) + return { src: item, async, defer }; + } else { + return item; + } + }); } + // fallback to the cloud when the tinymceScriptSrc is not specified + const channel = this.props.cloudChannel as Version; // `cloudChannel` is in `defaultProps`, so it's always defined. + const apiKey = this.props.apiKey ? this.props.apiKey : 'no-api-key'; + const cloudTinyJs = `https://cdn.tiny.cloud/1/${apiKey}/tinymce/${channel}/tinymce.min.js`; + return [{ src: cloudTinyJs, async, defer }]; } private getInitialValue() { @@ -203,14 +347,14 @@ export class Editor extends React.Component { const wasControlled = isValueControlled(prevProps); const nowControlled = isValueControlled(this.props); if (!wasControlled && nowControlled) { - this.editor.on(changeEvents(), this.handleEditorChange); - this.editor.on(beforeInputEvent(), this.handleBeforeInput); + this.editor.on(changeEvents, this.handleEditorChange); + this.editor.on(this.beforeInputEvent(), this.handleBeforeInput); this.editor.on('keydown', this.handleBeforeInputSpecial); this.editor.on('keyup', this.handleEditorChangeSpecial); this.editor.on('NewBlock', this.handleEditorChange); } else if (wasControlled && !nowControlled) { - this.editor.off(changeEvents(), this.handleEditorChange); - this.editor.off(beforeInputEvent(), this.handleBeforeInput); + this.editor.off(changeEvents, this.handleEditorChange); + this.editor.off(this.beforeInputEvent(), this.handleBeforeInput); this.editor.off('keydown', this.handleBeforeInputSpecial); this.editor.off('keyup', this.handleEditorChangeSpecial); this.editor.off('NewBlock', this.handleEditorChange); @@ -229,7 +373,7 @@ export class Editor extends React.Component { if (this.valueCursor && (!this.inline || editor.hasFocus())) { try { editor.selection.moveToBookmark(this.valueCursor); - } catch (e) { /* ignore */ } + } catch (_e) { /* ignore */ } } }); } @@ -238,12 +382,12 @@ export class Editor extends React.Component { private handleBeforeInput = (_evt: EditorEvent) => { if (this.props.value !== undefined && this.props.value === this.currentContent && this.editor) { - if (!this.inline || this.editor.hasFocus) { + if (!this.inline || this.editor.hasFocus()) { try { // getBookmark throws exceptions when the editor has not been focused // possibly only in inline mode but I'm not taking chances this.valueCursor = this.editor.selection.getBookmark(3); - } catch (e) { /* ignore */ } + } catch (_e) { /* ignore */ } } } }; @@ -271,9 +415,7 @@ export class Editor extends React.Component { if (newContent !== this.currentContent) { this.currentContent = newContent; if (isFunction(this.props.onEditorChange)) { - const format = this.props.outputFormat; - const out = format === 'html' ? newContent : editor.getContent({ format }); - this.props.onEditorChange(out, editor); + this.props.onEditorChange(newContent, editor); } } } @@ -307,19 +449,18 @@ export class Editor extends React.Component { return; } - const tinymce = getTinymce(); - if (!tinymce) { - throw new Error('tinymce should have been loaded into global scope'); - } + const tinymce = getTinymceOrError(this.view); - const finalInit: RawEditorSettings = { - ...this.props.init, + const finalInit: EditorOptions = { + ...this.props.init as Omit, selector: undefined, target, - readonly: this.props.disabled, + disabled: this.props.disabled, + readonly: this.props.readonly, inline: this.inline, plugins: mergePlugins(this.props.init?.plugins, this.props.plugins), toolbar: this.props.toolbar ?? this.props.init?.toolbar, + ...(this.props.licenseKey ? { license_key: this.props.licenseKey } : {}), setup: (editor) => { this.editor = editor; this.bindHandlers({}); @@ -339,6 +480,14 @@ export class Editor extends React.Component { if (this.props.init && isFunction(this.props.init.setup)) { this.props.init.setup(editor); } + + if (this.props.disabled) { + if (isDisabledOptionSupported(this.editor)) { + this.editor.options.set('disabled', this.props.disabled); + } else { + this.editor.mode.set('readonly'); + } + } }, init_instance_callback: (editor) => { // check for changes that happened since tinymce.init() was called @@ -352,8 +501,7 @@ export class Editor extends React.Component { editor.undoManager.add(); editor.setDirty(false); } - const disabled = this.props.disabled ?? false; - setMode(this.editor, disabled ? 'readonly' : 'design'); + // ensure existing init_instance_callback is called if (this.props.init && isFunction(this.props.init.init_instance_callback)) { this.props.init.init_instance_callback(editor); @@ -367,6 +515,7 @@ export class Editor extends React.Component { target.value = this.getInitialValue(); } + // eslint-disable-next-line @typescript-eslint/no-floating-promises tinymce.init(finalInit); }; } diff --git a/src/main/ts/components/EditorPropTypes.ts b/src/main/ts/components/EditorPropTypes.ts index 6885850fc..e4c202109 100644 --- a/src/main/ts/components/EditorPropTypes.ts +++ b/src/main/ts/components/EditorPropTypes.ts @@ -1,11 +1,3 @@ -/** - * Copyright (c) 2017-present, Ephox, Inc. - * - * This source code is licensed under the Apache 2 license found in the - * LICENSE file in the root directory of this source tree. - * - */ - import * as PropTypes from 'prop-types'; import { IEvents } from '../Events'; import { IProps } from './Editor'; @@ -30,6 +22,10 @@ export const eventPropTypes: IEventPropTypes = { onClearUndos: PropTypes.func, onClick: PropTypes.func, onContextMenu: PropTypes.func, + onCommentChange: PropTypes.func, + onCompositionEnd: PropTypes.func, + onCompositionStart: PropTypes.func, + onCompositionUpdate: PropTypes.func, onCopy: PropTypes.func, onCut: PropTypes.func, onDblclick: PropTypes.func, @@ -48,6 +44,7 @@ export const eventPropTypes: IEventPropTypes = { onGetContent: PropTypes.func, onHide: PropTypes.func, onInit: PropTypes.func, + onInput: PropTypes.func, onKeyDown: PropTypes.func, onKeyPress: PropTypes.func, onKeyUp: PropTypes.func, @@ -78,25 +75,43 @@ export const eventPropTypes: IEventPropTypes = { onShow: PropTypes.func, onSubmit: PropTypes.func, onUndo: PropTypes.func, - onVisualAid: PropTypes.func + onVisualAid: PropTypes.func, + onSkinLoadError: PropTypes.func, + onThemeLoadError: PropTypes.func, + onModelLoadError: PropTypes.func, + onPluginLoadError: PropTypes.func, + onIconsLoadError: PropTypes.func, + onLanguageLoadError: PropTypes.func, + onScriptsLoad: PropTypes.func, + onScriptsLoadError: PropTypes.func, }; export const EditorPropTypes: IEditorPropTypes = { apiKey: PropTypes.string, + licenseKey: PropTypes.string, id: PropTypes.string, inline: PropTypes.bool, init: PropTypes.object, initialValue: PropTypes.string, onEditorChange: PropTypes.func, - outputFormat: PropTypes.oneOf([ 'html', 'text' ]), value: PropTypes.string, tagName: PropTypes.string, + tabIndex: PropTypes.number, cloudChannel: PropTypes.string, plugins: PropTypes.oneOfType([ PropTypes.string, PropTypes.array ]), toolbar: PropTypes.oneOfType([ PropTypes.string, PropTypes.array ]), disabled: PropTypes.bool, + readonly: PropTypes.bool, textareaName: PropTypes.string, - tinymceScriptSrc: PropTypes.string, + tinymceScriptSrc: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.arrayOf(PropTypes.string), + PropTypes.arrayOf(PropTypes.shape({ + src: PropTypes.string, + async: PropTypes.bool, + defer: PropTypes.bool + })) + ]), rollback: PropTypes.oneOfType([ PropTypes.number, PropTypes.oneOf([ false ]) ]), scriptLoading: PropTypes.shape({ async: PropTypes.bool, diff --git a/src/main/ts/index.tsx b/src/main/ts/index.tsx index 5f2c414b0..51ad84cb1 100644 --- a/src/main/ts/index.tsx +++ b/src/main/ts/index.tsx @@ -1,11 +1,3 @@ -/** - * Copyright (c) 2017-present, Ephox, Inc. - * - * This source code is licensed under the Apache 2 license found in the - * LICENSE file in the root directory of this source tree. - * - */ - import { Editor, IAllProps } from './components/Editor'; export { Editor, IAllProps }; diff --git a/src/stories/Editor.stories.tsx b/src/stories/Editor.stories.tsx index 13ee829c2..0b5007f24 100644 --- a/src/stories/Editor.stories.tsx +++ b/src/stories/Editor.stories.tsx @@ -1,153 +1,179 @@ +import { StoryObj } from '@storybook/react'; import React from 'react'; import { EditorEvent, Events, Editor as TinyMCEEditor } from 'tinymce'; - -import { Story } from '@storybook/react'; - -import { Editor } from '../main/ts/components/Editor'; +import { Editor, IAllProps } from '../main/ts/components/Editor'; const apiKey = 'qagffr3pkuv17a8on1afax661irst1hbr4e6tbv888sz91jc'; -const sampleContent = ` +const initialValue = `

Full-featured rich text editing experience

No matter what you're building, TinyMCE has got you covered.

-`; +`.trim(); + +/** Assigning this on a StoryObj will allow its args to be modified. */ +const argTypes = { + // Define arg types that need it, i.e. ones that haven't got a good default: + plugins: { control: { type: 'text' }}, + toolbar: { control: { type: 'text' }}, + cloudChannel: { control: { type: 'text' }}, + rollback: { control: { type: 'number' }} +}; export default { title: 'Editor', component: Editor, + parameters: { + actions: { + disable: true + } + } }; -export const IframeEditor: Story = () => ( - -); -IframeEditor.parameters = { - controls: { hideNoControlsWarning: true }, +export const IframeEditor: StoryObj = { + args: { + apiKey, + initialValue, + }, + argTypes, }; -export const InlineEditor: Story = () => ( -
- -
-); -InlineEditor.parameters = { - controls: { hideNoControlsWarning: true }, -}; - -export const ControlledInput: Story = () => { - const [ data, setData ] = React.useState(sampleContent); - return ( -
+export const InlineEditor: StoryObj = { + args: { + apiKey, + initialValue, + inline: true, + }, + argTypes, + render: (args) => ( +
setData(e)} - /> -