diff --git a/CHANGELOG.md b/CHANGELOG.md index 57b1f4ac..b08d7628 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### 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 diff --git a/package.json b/package.json index 64b98a1b..7e7b627d 100644 --- a/package.json +++ b/package.json @@ -68,14 +68,15 @@ "react-dom": "^19.0.0", "rimraf": "^6.0.1", "storybook": "^8.6.4", - "tinymce": "^7.2.1", + "tinymce": "^7", "tinymce-4": "npm:tinymce@^4", "tinymce-5": "npm:tinymce@^5", "tinymce-6": "npm:tinymce@^6", "tinymce-7": "npm:tinymce@^7", + "tinymce-7.5": "npm:tinymce@7.5", "typescript": "~5.8.2", "vite": "^6.2.1" }, - "version": "6.1.1-rc", + "version": "6.2.0-rc", "name": "@tinymce/tinymce-react" } diff --git a/src/main/ts/Utils.ts b/src/main/ts/Utils.ts index 1a517d7b..651ed288 100644 --- a/src/main/ts/Utils.ts +++ b/src/main/ts/Utils.ts @@ -1,6 +1,8 @@ import { eventPropTypes, IEventPropTypes } from './components/EditorPropTypes'; import { IAllProps } from './components/Editor'; import type { Editor as TinyMCEEditor, EditorEvent } from 'tinymce'; +import { getTinymce } from './TinyMCE'; +import { TinyVer } from '@tinymce/miniature'; export const isFunction = (x: unknown): x is Function => typeof x === 'function'; @@ -109,4 +111,19 @@ export const setMode = (editor: TinyMCEEditor | undefined, mode: 'readonly' | 'd (editor as any).setMode(mode); } } +}; + +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 = (view: Window) => { + const tinymce = getTinymceOrError(view); + + return !TinyVer.isLessThan(tinymce, '7.6.0'); }; \ No newline at end of file diff --git a/src/main/ts/components/Editor.tsx b/src/main/ts/components/Editor.tsx index 19f8be72..8c763b78 100644 --- a/src/main/ts/components/Editor.tsx +++ b/src/main/ts/components/Editor.tsx @@ -2,9 +2,12 @@ import * as React from 'react'; import type { Bookmark, EditorEvent, TinyMCE, Editor as TinyMCEEditor } from 'tinymce'; import { IEvents } from '../Events'; import { ScriptItem, ScriptLoader } from '../ScriptLoader2'; -import { getTinymce } from '../TinyMCE'; -import { configHandlers, isBeforeInputEventAvailable, isFunction, isInDoc, isTextareaOrInput, mergePlugins, setMode, uuid } from '../Utils'; +import { configHandlers, isBeforeInputEventAvailable, + isFunction, isInDoc, isTextareaOrInput, mergePlugins, + setMode, uuid, isDisabledOptionSupported, + getTinymceOrError } from '../Utils'; import { EditorPropTypes, IEditorPropTypes } from './EditorPropTypes'; +import { getTinymce } from '../TinyMCE'; const changeEvents = 'change keyup compositionend setcontent CommentChange'; @@ -14,14 +17,15 @@ interface DoNotUse { __brand: T; } -type OmittedInitProps = 'selector' | 'target' | 'readonly' | 'license_key'; +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, use the `disabled` prop instead'>; + 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 }; @@ -95,9 +99,14 @@ export interface IProps { 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" (read-only). + * @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. @@ -209,9 +218,18 @@ export class Editor extends React.Component { } }); } + + 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.view)) { + this.editor.options.set('disabled', this.props.disabled); + } else { + setMode(this.editor, this.props.disabled ? 'readonly' : 'design'); + } } } } @@ -431,16 +449,16 @@ export class Editor extends React.Component { return; } - const tinymce = getTinymce(this.view); - if (!tinymce) { - throw new Error('tinymce should have been loaded into global scope'); - } + const tinymce = getTinymceOrError(this.view); const finalInit: EditorOptions = { ...this.props.init as Omit, selector: undefined, target, - readonly: this.props.disabled, + ...isDisabledOptionSupported(this.view) + ? { disabled: this.props.disabled, readonly: this.props.readonly } + : { readonly: this.props.disabled || this.props.readonly } + , inline: this.inline, plugins: mergePlugins(this.props.init?.plugins, this.props.plugins), toolbar: this.props.toolbar ?? this.props.init?.toolbar, @@ -477,8 +495,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); diff --git a/src/main/ts/components/EditorPropTypes.ts b/src/main/ts/components/EditorPropTypes.ts index 771f1ae3..e4c20210 100644 --- a/src/main/ts/components/EditorPropTypes.ts +++ b/src/main/ts/components/EditorPropTypes.ts @@ -101,6 +101,7 @@ export const EditorPropTypes: IEditorPropTypes = { 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.oneOfType([ PropTypes.string, diff --git a/src/stories/Editor.stories.tsx b/src/stories/Editor.stories.tsx index 145ccc5f..0b5007f2 100644 --- a/src/stories/Editor.stories.tsx +++ b/src/stories/Editor.stories.tsx @@ -145,6 +145,25 @@ export const ToggleDisabledProp: StoryObj = { } }; +export const ToggleReadonlyProp: StoryObj = { + render: () => { + const [ readonly, setReadonly ] = React.useState(true); + const toggleReadonly = () => setReadonly((prev) => !prev); + return ( +
+ + +
+ ); + } +}; + export const CloudChannelSetTo5Dev: StoryObj = { name: 'Cloud Channel Set To "6-dev"', render: () => ( diff --git a/src/test/ts/browser/EditorDisabledTest.ts b/src/test/ts/browser/EditorDisabledTest.ts new file mode 100644 index 00000000..ecc9d284 --- /dev/null +++ b/src/test/ts/browser/EditorDisabledTest.ts @@ -0,0 +1,77 @@ +import { context, describe, it } from '@ephox/bedrock-client'; +import * as Loader from '../alien/Loader'; +import { Assertions, Waiter } from '@ephox/agar'; + +describe('EditorDisabledTest', () => { + + context('with TinyMCE < 7.6', () => { + Loader.withVersion('7.5', (render) => { + it('updating disabled prop should toggle the editor\'s mode', async () => { + using ctx = await render({ + disabled: true + }); + + Assertions.assertEq('mode is readonly', 'readonly', ctx.editor.mode.get()); + + await ctx.reRender({ + disabled: false + }); + await Waiter.pTryUntil('mode is changed to design', () => { + Assertions.assertEq('mode is design', 'design', ctx.editor.mode.get()); + }); + }); + + it('updating readonly prop should toggle the editor\'s mode', async () => { + using ctx = await render({ + readonly: true + }); + Assertions.assertEq('mode is readonly', 'readonly', ctx.editor.mode.get()); + + await ctx.reRender({ + readonly: false + }); + await Waiter.pTryUntil('mode is changed to design', () => { + Assertions.assertEq('mode is design', 'design', ctx.editor.mode.get()); + }); + }); + }); + }); + + context('with TinyMCE >= 7.6', () => { + Loader.withVersion('7', (render) => { + it('updating disabled prop should only change the editor\'s state', async () => { + using ctx = await render({ + disabled: true + }); + + Assertions.assertEq('mode is design', 'design', ctx.editor.mode.get()); + Assertions.assertEq('editor is disabled', true, ctx.editor.options.get('disabled')); + + await ctx.reRender({ + disabled: false + }); + + await Waiter.pTryUntil('editor\'s state should be updated', () => { + Assertions.assertEq('mode is design', 'design', ctx.editor.mode.get()); + Assertions.assertEq('editor is not disabled', false, ctx.editor.options.get('disabled')); + }); + }); + + it('updating readonly prop should only change the editor\'s mode', async () => { + using ctx = await render({ + readonly: true + }); + Assertions.assertEq('mode is readonly', 'readonly', ctx.editor.mode.get()); + Assertions.assertEq('editor is not disabled', false, ctx.editor.options.get('disabled')); + + await ctx.reRender({ + readonly: false + }); + await Waiter.pTryUntil('editor\'s mode should be updated', () => { + Assertions.assertEq('mode is design', 'design', ctx.editor.mode.get()); + Assertions.assertEq('editor is not disabled', false, ctx.editor.options.get('disabled')); + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/src/test/ts/browser/EditorInitTest.ts b/src/test/ts/browser/EditorInitTest.ts index a34c0b69..35f46b47 100644 --- a/src/test/ts/browser/EditorInitTest.ts +++ b/src/test/ts/browser/EditorInitTest.ts @@ -69,13 +69,6 @@ describe('EditorInitTest', () => { TinyAssertions.assertContent(ctx.editor, '

New Value

'); }); - it('Disabled prop should disable editor', async () => { - using ctx = await render(); - Assertions.assertEq('Should be design mode', true, '4' === version ? !ctx.editor.readonly : ctx.editor.mode.get() === 'design'); - await ctx.reRender({ ...defaultProps, disabled: true }); - Assertions.assertEq('Should be readonly mode', true, '4' === version ? ctx.editor.readonly : ctx.editor.mode.get() === 'readonly'); - }); - it('Using an overriden props will cause a TS error', async () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars using _ = await render({ diff --git a/yarn.lock b/yarn.lock index f0084390..dc8c1dd7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8705,15 +8705,20 @@ tiny-invariant@^1.3.1, tiny-invariant@^1.3.3: resolved "https://registry.yarnpkg.com/tinymce/-/tinymce-6.8.4.tgz#53e1313ebfe5524b24c0fa45937d51fda058632d" integrity sha512-okoJyxuPv1gzASxQDNgQbnUXOdAIyoOSXcXcZZu7tiW0PSKEdf3SdASxPBupRj+64/E3elHwVRnzSdo82Emqbg== -"tinymce-7@npm:tinymce@^7": - version "7.2.1" - resolved "https://registry.yarnpkg.com/tinymce/-/tinymce-7.2.1.tgz#9b4f6b5a0fa647e2953c174ac69aa47483683332" - integrity sha512-ADd1cvdIuq6NWyii0ZOZRuu+9sHIdQfcRNWBcBps2K8vy7OjlRkX6iw7zz1WlL9kY4z4L1DvIP+xOrVX/46aHA== +"tinymce-7.5@npm:tinymce@7.5": + version "7.5.1" + resolved "https://registry.yarnpkg.com/tinymce/-/tinymce-7.5.1.tgz#4c207ab930d3a073bf851ddd3a8aa44d8d94d7bd" + integrity sha512-GRXJUB0BEIOUHUEC+q9IjsgWGIAQ4Tn5t5hfpB/YR7No3oPgKHG03v1d3nbov9aqdyVW7Be+UD4I3ZerQG30VQ== -tinymce@^7.2.1: - version "7.7.1" - resolved "https://registry.yarnpkg.com/tinymce/-/tinymce-7.7.1.tgz#e7c19f14153dc9bcde11bbc012db45167892c116" - integrity sha512-rMetqSgZtYbj4YPOX+gYgmlhy/sIjVlI/qlrSOul/Mpn9e0aIIG/fR0qvQSVYvxFv6OzRTge++NQyTbzLJK1NA== +"tinymce-7@npm:tinymce@^7": + version "7.8.0" + resolved "https://registry.yarnpkg.com/tinymce/-/tinymce-7.8.0.tgz#d57a597aecdc2108f2dd68fe74c6099c0a0ef66f" + integrity sha512-MUER5MWV9mkOB4expgbWknh/C5ZJvOXQlMVSx4tJxTuYtcUCDB6bMZ34fWNOIc8LvrnXmGHGj0eGQuxjQyRgrA== + +tinymce@^7: + version "7.8.0" + resolved "https://registry.yarnpkg.com/tinymce/-/tinymce-7.8.0.tgz#d57a597aecdc2108f2dd68fe74c6099c0a0ef66f" + integrity sha512-MUER5MWV9mkOB4expgbWknh/C5ZJvOXQlMVSx4tJxTuYtcUCDB6bMZ34fWNOIc8LvrnXmGHGj0eGQuxjQyRgrA== tinyrainbow@^1.2.0: version "1.2.0"