diff --git a/.changelog/20260428123311_ck_20000.md b/.changelog/20260428123311_ck_20000.md new file mode 100644 index 00000000000..e475e57b3bc --- /dev/null +++ b/.changelog/20260428123311_ck_20000.md @@ -0,0 +1,7 @@ +--- +type: Fix +scope: + - ckeditor5-basic-styles +--- + +The `superscript` and `subscript` text styles are now mutually exclusive by default - applying one to text that already has the other replaces it. The previous behavior, where both attributes could coexist on the same text, can be restored by setting `config.basicStyles.superscript.allowNesting` or `config.basicStyles.subscript.allowNesting` to `true`. diff --git a/packages/ckeditor5-basic-styles/docs/features/basic-styles.md b/packages/ckeditor5-basic-styles/docs/features/basic-styles.md index 11da6b20f13..14babb53daa 100644 --- a/packages/ckeditor5-basic-styles/docs/features/basic-styles.md +++ b/packages/ckeditor5-basic-styles/docs/features/basic-styles.md @@ -63,6 +63,32 @@ CKEditor 5 allows for typing both at the inner and outer boundaries of code {@img assets/img/typing-after-code.gif 770 The animation showing typing after the code element in CKEditor 5 rich text editor.} +## Subscript and superscript exclusivity + +By default, the {@link module:basic-styles/subscript~Subscript subscript} and {@link module:basic-styles/superscript~Superscript superscript} features are mutually exclusive: applying one to text that already has the other replaces it, matching the behavior of common word processors. Toggling a style off does not affect the other style. + +To allow nesting, set the `allowNesting` option on either feature under the `basicStyles` configuration namespace: + +```js +ClassicEditor + .create( { + // ... Other configuration options ... + basicStyles: { + superscript: { + allowNesting: true + } + } + } ) + .then( /* ... */ ) + .catch( /* ... */ ); +``` + +The flag is symmetric: setting `basicStyles.superscript.allowNesting` or `basicStyles.subscript.allowNesting` to `true` disables the mutual exclusion for both commands. + + + The mutual exclusion only applies to command execution. Loading content through the {@link module:core/editor/editor~Editor#setData data API} or pasting HTML such as `x` keeps both attributes on the same text regardless of this option. + + ## Installation After {@link getting-started/integrations-cdn/quick-start installing the editor}, add the plugins which you need to your plugin list. Then, simply configure the toolbar items to make the features available in the user interface. diff --git a/packages/ckeditor5-basic-styles/package.json b/packages/ckeditor5-basic-styles/package.json index cc8665af8dc..5453675fc53 100644 --- a/packages/ckeditor5-basic-styles/package.json +++ b/packages/ckeditor5-basic-styles/package.json @@ -45,7 +45,8 @@ "devDependencies": { "@ckeditor/ckeditor5-editor-classic": "workspace:*", "@ckeditor/ckeditor5-essentials": "workspace:*", - "@ckeditor/ckeditor5-paragraph": "workspace:*" + "@ckeditor/ckeditor5-paragraph": "workspace:*", + "@ckeditor/ckeditor5-undo": "workspace:*" }, "scripts": { "build": "node ../../scripts/nim/build-package.mjs" diff --git a/packages/ckeditor5-basic-styles/src/augmentation.ts b/packages/ckeditor5-basic-styles/src/augmentation.ts index 0d05fac07b5..ff96a7f416a 100644 --- a/packages/ckeditor5-basic-styles/src/augmentation.ts +++ b/packages/ckeditor5-basic-styles/src/augmentation.ts @@ -18,6 +18,7 @@ import type { Strikethrough, StrikethroughEditing, StrikethroughUI, + BasicStylesConfig, SubscriptEditing, SubscriptUI, SuperscriptEditing, @@ -28,6 +29,16 @@ import type { } from './index.js'; declare module '@ckeditor/ckeditor5-core' { + interface EditorConfig { + + /** + * The configuration of the {@link module:basic-styles basic styles features}. + * + * Read more in {@link module:basic-styles/basicstylesconfig~BasicStylesConfig}. + */ + basicStyles?: BasicStylesConfig; + } + interface PluginsMap { [ Superscript.pluginName ]: Superscript; [ Subscript.pluginName ]: Subscript; diff --git a/packages/ckeditor5-basic-styles/src/basicstylesconfig.ts b/packages/ckeditor5-basic-styles/src/basicstylesconfig.ts new file mode 100644 index 00000000000..9547016bb2a --- /dev/null +++ b/packages/ckeditor5-basic-styles/src/basicstylesconfig.ts @@ -0,0 +1,49 @@ +/** + * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +/** + * @module basic-styles/basicstylesconfig + */ + +import type { BasicStyleSubscriptConfig } from './subscriptconfig.js'; +import type { BasicStyleSuperscriptConfig } from './superscriptconfig.js'; + +/** + * The configuration of the basic styles features (`Bold`, `Italic`, `Subscript`, `Superscript`, etc.). + * + * ```ts + * ClassicEditor + * .create( editorElement, { + * basicStyles: { + * superscript: { + * allowNesting: true + * }, + * subscript: { + * allowNesting: true + * } + * } + * } ) + * .then( ... ) + * .catch( ... ); + * ``` + * + * See {@link module:core/editor/editorconfig~EditorConfig all editor options}. + */ +export interface BasicStylesConfig { + + /** + * The configuration of the {@link module:basic-styles/superscript~Superscript superscript feature}. + * + * Read more in {@link module:basic-styles/superscriptconfig~BasicStyleSuperscriptConfig}. + */ + superscript?: BasicStyleSuperscriptConfig; + + /** + * The configuration of the {@link module:basic-styles/subscript~Subscript subscript feature}. + * + * Read more in {@link module:basic-styles/subscriptconfig~BasicStyleSubscriptConfig}. + */ + subscript?: BasicStyleSubscriptConfig; +} diff --git a/packages/ckeditor5-basic-styles/src/constants.ts b/packages/ckeditor5-basic-styles/src/constants.ts new file mode 100644 index 00000000000..40bd00e04d8 --- /dev/null +++ b/packages/ckeditor5-basic-styles/src/constants.ts @@ -0,0 +1,22 @@ +/** + * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +/** + * @module basic-styles/constants + */ + +/** + * The model attribute key for the `superscript` text style. + * + * @internal + */ +export const SUPERSCRIPT = 'superscript'; + +/** + * The model attribute key for the `subscript` text style. + * + * @internal + */ +export const SUBSCRIPT = 'subscript'; diff --git a/packages/ckeditor5-basic-styles/src/index.ts b/packages/ckeditor5-basic-styles/src/index.ts index fb3cd8f5794..c9052d952e6 100644 --- a/packages/ckeditor5-basic-styles/src/index.ts +++ b/packages/ckeditor5-basic-styles/src/index.ts @@ -22,9 +22,12 @@ export { StrikethroughUI } from './strikethrough/strikethroughui.js'; export { Subscript } from './subscript.js'; export { SubscriptEditing } from './subscript/subscriptediting.js'; export { SubscriptUI } from './subscript/subscriptui.js'; +export type { BasicStyleSubscriptConfig } from './subscriptconfig.js'; +export type { BasicStylesConfig } from './basicstylesconfig.js'; export { Superscript } from './superscript.js'; export { SuperscriptEditing } from './superscript/superscriptediting.js'; export { SuperscriptUI } from './superscript/superscriptui.js'; +export type { BasicStyleSuperscriptConfig } from './superscriptconfig.js'; export { Underline } from './underline.js'; export { UnderlineEditing } from './underline/underlineediting.js'; export { UnderlineUI } from './underline/underlineui.js'; diff --git a/packages/ckeditor5-basic-styles/src/mutuallyexclusiveattributecommand.ts b/packages/ckeditor5-basic-styles/src/mutuallyexclusiveattributecommand.ts new file mode 100644 index 00000000000..a1a293b0a98 --- /dev/null +++ b/packages/ckeditor5-basic-styles/src/mutuallyexclusiveattributecommand.ts @@ -0,0 +1,93 @@ +/** + * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +/** + * @module basic-styles/mutuallyexclusiveattributecommand + */ + +import type { Editor } from '@ckeditor/ckeditor5-core'; +import { ModelDocumentSelection, type ModelElement, type ModelRange } from '@ckeditor/ckeditor5-engine'; + +import { AttributeCommand } from './attributecommand.js'; + +/** + * An {@link module:basic-styles/attributecommand~AttributeCommand} variant that removes a configured + * opposite attribute from the affected ranges whenever the command turns its own attribute on. + * + * Used by the `superscript` and `subscript` commands to enforce their mutual exclusion. The opposite + * attribute is removed in the same model change as the parent's toggle, so the operation is a single + * undo step. + * + * The mutual exclusion can be disabled by setting either + * `config.basicStyles.superscript.allowNesting` or `config.basicStyles.subscript.allowNesting` to `true`. + * In that case, the command behaves exactly the same as the plain + * {@link module:basic-styles/attributecommand~AttributeCommand}. + * + * The exclusion only applies to command execution. Content set through the data pipeline + * (for example `editor.setData( 'x' )`) is not modified by this command. + * + * @internal + */ +export class MutuallyExclusiveAttributeCommand extends AttributeCommand { + private readonly _oppositeAttributeKey: string; + + constructor( editor: Editor, attributeKey: string, oppositeAttributeKey: string ) { + super( editor, attributeKey ); + + this._oppositeAttributeKey = oppositeAttributeKey; + } + + /** + * @inheritDoc + */ + public override execute( options: { forceValue?: boolean } = {} ): void { + const editor = this.editor; + const model = editor.model; + const value = ( options.forceValue === undefined ) ? !this.value : options.forceValue; + + if ( !value || _isNestingAllowed( editor ) ) { + super.execute( options ); + + return; + } + + const oppositeKey = this._oppositeAttributeKey; + + model.change( writer => { + super.execute( options ); + + const selection = model.document.selection; + + if ( selection.isCollapsed ) { + writer.removeSelectionAttribute( oppositeKey ); + + return; + } + + const ranges = model.schema.getValidRanges( selection.getRanges(), oppositeKey, { + includeEmptyRanges: true + } ); + + for ( const range of ranges ) { + let itemOrRange: ModelRange | ModelElement = range; + let attributeKey = oppositeKey; + + if ( range.isCollapsed ) { + itemOrRange = range.start.parent as ModelElement; + attributeKey = ModelDocumentSelection._getStoreAttributeKey( oppositeKey ); + } + + writer.removeAttribute( attributeKey, itemOrRange ); + } + } ); + } +} + +function _isNestingAllowed( editor: Editor ): boolean { + return Boolean( + editor.config.get( 'basicStyles.superscript.allowNesting' ) || + editor.config.get( 'basicStyles.subscript.allowNesting' ) + ); +} diff --git a/packages/ckeditor5-basic-styles/src/subscript/subscriptediting.ts b/packages/ckeditor5-basic-styles/src/subscript/subscriptediting.ts index 3175177ee84..44138b9548e 100644 --- a/packages/ckeditor5-basic-styles/src/subscript/subscriptediting.ts +++ b/packages/ckeditor5-basic-styles/src/subscript/subscriptediting.ts @@ -8,9 +8,8 @@ */ import { Plugin } from '@ckeditor/ckeditor5-core'; -import { AttributeCommand } from '../attributecommand.js'; - -const SUBSCRIPT = 'subscript'; +import { MutuallyExclusiveAttributeCommand } from '../mutuallyexclusiveattributecommand.js'; +import { SUBSCRIPT, SUPERSCRIPT } from '../constants.js'; /** * The subscript editing feature. @@ -38,6 +37,9 @@ export class SubscriptEditing extends Plugin { */ public init(): void { const editor = this.editor; + + editor.config.define( 'basicStyles', { [ SUBSCRIPT ]: { allowNesting: false } } ); + // Allow sub attribute on text nodes. editor.model.schema.extend( '$text', { allowAttributes: SUBSCRIPT } ); editor.model.schema.setAttributeProperties( SUBSCRIPT, { @@ -60,6 +62,6 @@ export class SubscriptEditing extends Plugin { } ); // Create sub command. - editor.commands.add( SUBSCRIPT, new AttributeCommand( editor, SUBSCRIPT ) ); + editor.commands.add( SUBSCRIPT, new MutuallyExclusiveAttributeCommand( editor, SUBSCRIPT, SUPERSCRIPT ) ); } } diff --git a/packages/ckeditor5-basic-styles/src/subscript/subscriptui.ts b/packages/ckeditor5-basic-styles/src/subscript/subscriptui.ts index 9211b9d4b21..dca2dfba27c 100644 --- a/packages/ckeditor5-basic-styles/src/subscript/subscriptui.ts +++ b/packages/ckeditor5-basic-styles/src/subscript/subscriptui.ts @@ -11,8 +11,7 @@ import { Plugin } from '@ckeditor/ckeditor5-core'; import { IconSubscript } from '@ckeditor/ckeditor5-icons'; import { ButtonView, MenuBarMenuListItemButtonView } from '@ckeditor/ckeditor5-ui'; import { getButtonCreator } from '../utils.js'; - -const SUBSCRIPT = 'subscript'; +import { SUBSCRIPT } from '../constants.js'; /** * The subscript UI feature. It introduces the Subscript button. diff --git a/packages/ckeditor5-basic-styles/src/subscriptconfig.ts b/packages/ckeditor5-basic-styles/src/subscriptconfig.ts new file mode 100644 index 00000000000..d99af582478 --- /dev/null +++ b/packages/ckeditor5-basic-styles/src/subscriptconfig.ts @@ -0,0 +1,50 @@ +/** + * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +/** + * @module basic-styles/subscriptconfig + */ + +/** + * The configuration of the {@link module:basic-styles/subscript~Subscript subscript feature}. + * Nested under {@link module:basic-styles/basicstylesconfig~BasicStylesConfig#subscript `config.basicStyles.subscript`}. + * + * ```ts + * ClassicEditor + * .create( editorElement, { + * basicStyles: { + * subscript: { + * allowNesting: true + * } + * } + * } ) + * .then( ... ) + * .catch( ... ); + * ``` + * + * See {@link module:core/editor/editorconfig~EditorConfig all editor options}. + */ +export interface BasicStyleSubscriptConfig { + + /** + * Whether `subscript` and `superscript` attributes are allowed to coexist on the same text. + * + * By default this is `false`: applying subscript to text that is already superscript removes the + * superscript attribute (and vice versa), matching the behavior of common word processors. + * + * Set to `true` to restore the historical behavior where both attributes can be applied to the same + * text. This is useful for content such as isotope notation (`¹⁴₆C`) or tensor indices (`T^i_j`). + * + * The flag is symmetric with + * {@link module:basic-styles/superscriptconfig~BasicStyleSuperscriptConfig#allowNesting `config.basicStyles.superscript.allowNesting`}: + * if either is set to `true`, both commands skip the mutual-exclusion step. + * + * The flag only affects command execution. Content set through the data pipeline (for example + * `editor.setData( 'x' )`) keeps both attributes regardless of this option. + * + * @default false + */ + allowNesting?: boolean; +} diff --git a/packages/ckeditor5-basic-styles/src/superscript/superscriptediting.ts b/packages/ckeditor5-basic-styles/src/superscript/superscriptediting.ts index 0bf66fa8ee3..a09b7789cb2 100644 --- a/packages/ckeditor5-basic-styles/src/superscript/superscriptediting.ts +++ b/packages/ckeditor5-basic-styles/src/superscript/superscriptediting.ts @@ -8,9 +8,8 @@ */ import { Plugin } from '@ckeditor/ckeditor5-core'; -import { AttributeCommand } from '../attributecommand.js'; - -const SUPERSCRIPT = 'superscript'; +import { MutuallyExclusiveAttributeCommand } from '../mutuallyexclusiveattributecommand.js'; +import { SUBSCRIPT, SUPERSCRIPT } from '../constants.js'; /** * The superscript editing feature. @@ -38,6 +37,9 @@ export class SuperscriptEditing extends Plugin { */ public init(): void { const editor = this.editor; + + editor.config.define( 'basicStyles', { [ SUPERSCRIPT ]: { allowNesting: false } } ); + // Allow super attribute on text nodes. editor.model.schema.extend( '$text', { allowAttributes: SUPERSCRIPT } ); editor.model.schema.setAttributeProperties( SUPERSCRIPT, { @@ -60,6 +62,6 @@ export class SuperscriptEditing extends Plugin { } ); // Create super command. - editor.commands.add( SUPERSCRIPT, new AttributeCommand( editor, SUPERSCRIPT ) ); + editor.commands.add( SUPERSCRIPT, new MutuallyExclusiveAttributeCommand( editor, SUPERSCRIPT, SUBSCRIPT ) ); } } diff --git a/packages/ckeditor5-basic-styles/src/superscript/superscriptui.ts b/packages/ckeditor5-basic-styles/src/superscript/superscriptui.ts index a47d1b9bfe3..7497de38b0e 100644 --- a/packages/ckeditor5-basic-styles/src/superscript/superscriptui.ts +++ b/packages/ckeditor5-basic-styles/src/superscript/superscriptui.ts @@ -11,8 +11,7 @@ import { Plugin } from '@ckeditor/ckeditor5-core'; import { IconSuperscript } from '@ckeditor/ckeditor5-icons'; import { ButtonView, MenuBarMenuListItemButtonView } from '@ckeditor/ckeditor5-ui'; import { getButtonCreator } from '../utils.js'; - -const SUPERSCRIPT = 'superscript'; +import { SUPERSCRIPT } from '../constants.js'; /** * The superscript UI feature. It introduces the Superscript button. diff --git a/packages/ckeditor5-basic-styles/src/superscriptconfig.ts b/packages/ckeditor5-basic-styles/src/superscriptconfig.ts new file mode 100644 index 00000000000..1902979830b --- /dev/null +++ b/packages/ckeditor5-basic-styles/src/superscriptconfig.ts @@ -0,0 +1,50 @@ +/** + * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +/** + * @module basic-styles/superscriptconfig + */ + +/** + * The configuration of the {@link module:basic-styles/superscript~Superscript superscript feature}. + * Nested under {@link module:basic-styles/basicstylesconfig~BasicStylesConfig#superscript `config.basicStyles.superscript`}. + * + * ```ts + * ClassicEditor + * .create( editorElement, { + * basicStyles: { + * superscript: { + * allowNesting: true + * } + * } + * } ) + * .then( ... ) + * .catch( ... ); + * ``` + * + * See {@link module:core/editor/editorconfig~EditorConfig all editor options}. + */ +export interface BasicStyleSuperscriptConfig { + + /** + * Whether `superscript` and `subscript` attributes are allowed to coexist on the same text. + * + * By default this is `false`: applying superscript to text that is already subscript removes the + * subscript attribute (and vice versa), matching the behavior of common word processors. + * + * Set to `true` to restore the historical behavior where both attributes can be applied to the same + * text. This is useful for content such as isotope notation (`¹⁴₆C`) or tensor indices (`T^i_j`). + * + * The flag is symmetric with + * {@link module:basic-styles/subscriptconfig~BasicStyleSubscriptConfig#allowNesting `config.basicStyles.subscript.allowNesting`}: + * if either is set to `true`, both commands skip the mutual-exclusion step. + * + * The flag only affects command execution. Content set through the data pipeline (for example + * `editor.setData( 'x' )`) keeps both attributes regardless of this option. + * + * @default false + */ + allowNesting?: boolean; +} diff --git a/packages/ckeditor5-basic-styles/tests/manual/basic-styles.md b/packages/ckeditor5-basic-styles/tests/manual/basic-styles.md index 11cc0cd3629..6c8bdebd505 100644 --- a/packages/ckeditor5-basic-styles/tests/manual/basic-styles.md +++ b/packages/ckeditor5-basic-styles/tests/manual/basic-styles.md @@ -10,3 +10,4 @@ * superscript X2. 2. The second sentence should bold the following words: `bold`, `600`, `700`, `800`, `900`. 3. Test the bold, italic, strikethrough, underline, code, subscript and superscript features live. +4. Subscript and superscript are mutually exclusive: select subscripted text and click **Superscript** — the text should become superscript only, not both. A single undo (Ctrl/Cmd+Z) should restore the original subscript. diff --git a/packages/ckeditor5-basic-styles/tests/subscript/subscriptcommand.js b/packages/ckeditor5-basic-styles/tests/subscript/subscriptcommand.js new file mode 100644 index 00000000000..5f36a7d65b0 --- /dev/null +++ b/packages/ckeditor5-basic-styles/tests/subscript/subscriptcommand.js @@ -0,0 +1,200 @@ +/** + * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +import { SubscriptEditing } from '../../src/subscript/subscriptediting.js'; +import { SuperscriptEditing } from '../../src/superscript/superscriptediting.js'; +import { MutuallyExclusiveAttributeCommand } from '../../src/mutuallyexclusiveattributecommand.js'; +import { AttributeCommand } from '../../src/attributecommand.js'; + +import { VirtualTestEditor } from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor.js'; +import { Paragraph } from '@ckeditor/ckeditor5-paragraph'; +import { UndoEditing } from '@ckeditor/ckeditor5-undo'; +import { _getModelData, _setModelData } from '@ckeditor/ckeditor5-engine'; + +describe( 'SubscriptCommand', () => { + let editor, model, command; + + function createEditor( config = {} ) { + return VirtualTestEditor + .create( { + plugins: [ Paragraph, UndoEditing, SubscriptEditing, SuperscriptEditing ], + ...config + } ) + .then( newEditor => { + editor = newEditor; + model = editor.model; + command = editor.commands.get( 'subscript' ); + } ); + } + + afterEach( () => { + return editor.destroy(); + } ); + + describe( 'instance', () => { + beforeEach( () => createEditor() ); + + it( 'is an instance of MutuallyExclusiveAttributeCommand', () => { + expect( command ).to.be.instanceOf( MutuallyExclusiveAttributeCommand ); + } ); + + it( 'is an instance of AttributeCommand', () => { + expect( command ).to.be.instanceOf( AttributeCommand ); + } ); + + it( 'has the subscript attribute key', () => { + expect( command.attributeKey ).to.equal( 'subscript' ); + } ); + } ); + + describe( 'config defaults', () => { + beforeEach( () => createEditor() ); + + it( 'sets basicStyles.subscript.allowNesting to false by default', () => { + expect( editor.config.get( 'basicStyles.subscript.allowNesting' ) ).to.be.false; + } ); + } ); + + describe( 'execute() with mutual exclusion (default)', () => { + beforeEach( () => createEditor() ); + + it( 'sets subscript on plain text without touching neighbors', () => { + _setModelData( model, '[foo]' ); + + editor.execute( 'subscript' ); + + expect( _getModelData( model, { withoutSelection: true } ) ).to.equal( + '<$text subscript="true">foo' + ); + } ); + + it( 'removes superscript when applying subscript on superscripted text', () => { + _setModelData( model, '[<$text superscript="true">foo]' ); + + editor.execute( 'subscript' ); + + expect( _getModelData( model, { withoutSelection: true } ) ).to.equal( + '<$text subscript="true">foo' + ); + } ); + + it( 'removes superscript on a non-collapsed range crossing sup | none | sub regions', () => { + _setModelData( model, + '' + + '[<$text superscript="true">aa' + + 'bb' + + '<$text subscript="true">cc]' + + '' + ); + + editor.execute( 'subscript' ); + + expect( _getModelData( model, { withoutSelection: true } ) ).to.equal( + '<$text subscript="true">aabbcc' + ); + } ); + + it( 'does not touch superscript when toggling subscript off', () => { + _setModelData( model, '[<$text subscript="true">foo]' ); + + editor.execute( 'subscript' ); + + expect( _getModelData( model, { withoutSelection: true } ) ).to.equal( + 'foo' + ); + } ); + + it( 'does not touch superscript adjacent to plain text when toggling off via forceValue:false', () => { + _setModelData( model, + '[foo]<$text superscript="true">bar' + ); + + editor.execute( 'subscript', { forceValue: false } ); + + expect( _getModelData( model, { withoutSelection: true } ) ).to.equal( + 'foo<$text superscript="true">bar' + ); + } ); + + it( 'removes superscript when forceValue:true on a superscripted range', () => { + _setModelData( model, '[<$text superscript="true">foo]' ); + + editor.execute( 'subscript', { forceValue: true } ); + + expect( _getModelData( model, { withoutSelection: true } ) ).to.equal( + '<$text subscript="true">foo' + ); + } ); + + it( 'flips selection attributes on a collapsed selection inside superscripted text', () => { + _setModelData( model, '<$text superscript="true">foo[]bar' ); + + editor.execute( 'subscript' ); + + const selection = model.document.selection; + + expect( selection.hasAttribute( 'subscript' ) ).to.be.true; + expect( selection.hasAttribute( 'superscript' ) ).to.be.false; + } ); + + it( 'removes the stored superscript on empty blocks inside a multi-block selection', () => { + _setModelData( model, '[foofoo]' ); + + editor.execute( 'superscript' ); + editor.execute( 'subscript' ); + + model.change( writer => { + writer.setSelection( model.document.getRoot().getNodeByPath( [ 1 ] ), 0 ); + } ); + + expect( model.document.selection.hasAttribute( 'superscript' ) ).to.be.false; + expect( model.document.selection.hasAttribute( 'subscript' ) ).to.be.true; + } ); + + it( 'restores the original superscript with a single undo step', () => { + _setModelData( model, '[<$text superscript="true">foo]' ); + + editor.execute( 'subscript' ); + + expect( _getModelData( model, { withoutSelection: true } ) ).to.equal( + '<$text subscript="true">foo' + ); + + editor.execute( 'undo' ); + + expect( _getModelData( model, { withoutSelection: true } ) ).to.equal( + '<$text superscript="true">foo' + ); + } ); + } ); + + describe( 'execute() with allowNesting on the subscript side', () => { + beforeEach( () => createEditor( { basicStyles: { subscript: { allowNesting: true } } } ) ); + + it( 'preserves superscript when applying subscript', () => { + _setModelData( model, '[<$text superscript="true">foo]' ); + + editor.execute( 'subscript' ); + + expect( _getModelData( model, { withoutSelection: true } ) ).to.equal( + '<$text subscript="true" superscript="true">foo' + ); + } ); + } ); + + describe( 'execute() with allowNesting on the superscript side (OR semantics)', () => { + beforeEach( () => createEditor( { basicStyles: { superscript: { allowNesting: true } } } ) ); + + it( 'preserves superscript when applying subscript', () => { + _setModelData( model, '[<$text superscript="true">foo]' ); + + editor.execute( 'subscript' ); + + expect( _getModelData( model, { withoutSelection: true } ) ).to.equal( + '<$text subscript="true" superscript="true">foo' + ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-basic-styles/tests/subscript/subscriptediting.js b/packages/ckeditor5-basic-styles/tests/subscript/subscriptediting.js index d792809b809..7918ecd496b 100644 --- a/packages/ckeditor5-basic-styles/tests/subscript/subscriptediting.js +++ b/packages/ckeditor5-basic-styles/tests/subscript/subscriptediting.js @@ -8,6 +8,7 @@ import { SubscriptEditing } from '../../src/subscript/subscriptediting.js'; import { VirtualTestEditor } from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor.js'; import { Paragraph } from '@ckeditor/ckeditor5-paragraph'; import { AttributeCommand } from '../../src/attributecommand.js'; +import { MutuallyExclusiveAttributeCommand } from '../../src/mutuallyexclusiveattributecommand.js'; import { _getModelData, _setModelData, _getViewData } from '@ckeditor/ckeditor5-engine'; @@ -66,6 +67,7 @@ describe( 'SubscriptEditing', () => { it( 'should register subscript command', () => { const command = editor.commands.get( 'subscript' ); + expect( command ).to.be.instanceOf( MutuallyExclusiveAttributeCommand ); expect( command ).to.be.instanceOf( AttributeCommand ); expect( command ).to.have.property( 'attributeKey', 'subscript' ); } ); diff --git a/packages/ckeditor5-basic-styles/tests/superscript/superscriptcommand.js b/packages/ckeditor5-basic-styles/tests/superscript/superscriptcommand.js new file mode 100644 index 00000000000..705a0fb06cc --- /dev/null +++ b/packages/ckeditor5-basic-styles/tests/superscript/superscriptcommand.js @@ -0,0 +1,200 @@ +/** + * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +import { SuperscriptEditing } from '../../src/superscript/superscriptediting.js'; +import { SubscriptEditing } from '../../src/subscript/subscriptediting.js'; +import { MutuallyExclusiveAttributeCommand } from '../../src/mutuallyexclusiveattributecommand.js'; +import { AttributeCommand } from '../../src/attributecommand.js'; + +import { VirtualTestEditor } from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor.js'; +import { Paragraph } from '@ckeditor/ckeditor5-paragraph'; +import { UndoEditing } from '@ckeditor/ckeditor5-undo'; +import { _getModelData, _setModelData } from '@ckeditor/ckeditor5-engine'; + +describe( 'SuperscriptCommand', () => { + let editor, model, command; + + function createEditor( config = {} ) { + return VirtualTestEditor + .create( { + plugins: [ Paragraph, UndoEditing, SuperscriptEditing, SubscriptEditing ], + ...config + } ) + .then( newEditor => { + editor = newEditor; + model = editor.model; + command = editor.commands.get( 'superscript' ); + } ); + } + + afterEach( () => { + return editor.destroy(); + } ); + + describe( 'instance', () => { + beforeEach( () => createEditor() ); + + it( 'is an instance of MutuallyExclusiveAttributeCommand', () => { + expect( command ).to.be.instanceOf( MutuallyExclusiveAttributeCommand ); + } ); + + it( 'is an instance of AttributeCommand', () => { + expect( command ).to.be.instanceOf( AttributeCommand ); + } ); + + it( 'has the superscript attribute key', () => { + expect( command.attributeKey ).to.equal( 'superscript' ); + } ); + } ); + + describe( 'config defaults', () => { + beforeEach( () => createEditor() ); + + it( 'sets basicStyles.superscript.allowNesting to false by default', () => { + expect( editor.config.get( 'basicStyles.superscript.allowNesting' ) ).to.be.false; + } ); + } ); + + describe( 'execute() with mutual exclusion (default)', () => { + beforeEach( () => createEditor() ); + + it( 'sets superscript on plain text without touching neighbors', () => { + _setModelData( model, '[foo]' ); + + editor.execute( 'superscript' ); + + expect( _getModelData( model, { withoutSelection: true } ) ).to.equal( + '<$text superscript="true">foo' + ); + } ); + + it( 'removes subscript when applying superscript on subscripted text', () => { + _setModelData( model, '[<$text subscript="true">foo]' ); + + editor.execute( 'superscript' ); + + expect( _getModelData( model, { withoutSelection: true } ) ).to.equal( + '<$text superscript="true">foo' + ); + } ); + + it( 'removes subscript on a non-collapsed range crossing sub | none | sup regions', () => { + _setModelData( model, + '' + + '[<$text subscript="true">aa' + + 'bb' + + '<$text superscript="true">cc]' + + '' + ); + + editor.execute( 'superscript' ); + + expect( _getModelData( model, { withoutSelection: true } ) ).to.equal( + '<$text superscript="true">aabbcc' + ); + } ); + + it( 'does not touch subscript when toggling superscript off', () => { + _setModelData( model, '[<$text superscript="true">foo]' ); + + editor.execute( 'superscript' ); + + expect( _getModelData( model, { withoutSelection: true } ) ).to.equal( + 'foo' + ); + } ); + + it( 'does not touch subscript adjacent to plain text when toggling off via forceValue:false', () => { + _setModelData( model, + '[foo]<$text subscript="true">bar' + ); + + editor.execute( 'superscript', { forceValue: false } ); + + expect( _getModelData( model, { withoutSelection: true } ) ).to.equal( + 'foo<$text subscript="true">bar' + ); + } ); + + it( 'removes subscript when forceValue:true on a subscripted range', () => { + _setModelData( model, '[<$text subscript="true">foo]' ); + + editor.execute( 'superscript', { forceValue: true } ); + + expect( _getModelData( model, { withoutSelection: true } ) ).to.equal( + '<$text superscript="true">foo' + ); + } ); + + it( 'flips selection attributes on a collapsed selection inside subscripted text', () => { + _setModelData( model, '<$text subscript="true">foo[]bar' ); + + editor.execute( 'superscript' ); + + const selection = model.document.selection; + + expect( selection.hasAttribute( 'superscript' ) ).to.be.true; + expect( selection.hasAttribute( 'subscript' ) ).to.be.false; + } ); + + it( 'removes the stored subscript on empty blocks inside a multi-block selection', () => { + _setModelData( model, '[foofoo]' ); + + editor.execute( 'subscript' ); + editor.execute( 'superscript' ); + + model.change( writer => { + writer.setSelection( model.document.getRoot().getNodeByPath( [ 1 ] ), 0 ); + } ); + + expect( model.document.selection.hasAttribute( 'subscript' ) ).to.be.false; + expect( model.document.selection.hasAttribute( 'superscript' ) ).to.be.true; + } ); + + it( 'restores the original subscript with a single undo step', () => { + _setModelData( model, '[<$text subscript="true">foo]' ); + + editor.execute( 'superscript' ); + + expect( _getModelData( model, { withoutSelection: true } ) ).to.equal( + '<$text superscript="true">foo' + ); + + editor.execute( 'undo' ); + + expect( _getModelData( model, { withoutSelection: true } ) ).to.equal( + '<$text subscript="true">foo' + ); + } ); + } ); + + describe( 'execute() with allowNesting on the superscript side', () => { + beforeEach( () => createEditor( { basicStyles: { superscript: { allowNesting: true } } } ) ); + + it( 'preserves subscript when applying superscript', () => { + _setModelData( model, '[<$text subscript="true">foo]' ); + + editor.execute( 'superscript' ); + + expect( _getModelData( model, { withoutSelection: true } ) ).to.equal( + '<$text subscript="true" superscript="true">foo' + ); + } ); + } ); + + describe( 'execute() with allowNesting on the subscript side (OR semantics)', () => { + beforeEach( () => createEditor( { basicStyles: { subscript: { allowNesting: true } } } ) ); + + it( 'preserves subscript when applying superscript', () => { + _setModelData( model, '[<$text subscript="true">foo]' ); + + editor.execute( 'superscript' ); + + expect( _getModelData( model, { withoutSelection: true } ) ).to.equal( + '<$text subscript="true" superscript="true">foo' + ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-basic-styles/tests/superscript/superscriptediting.js b/packages/ckeditor5-basic-styles/tests/superscript/superscriptediting.js index 3626ddc67c7..0b96637421c 100644 --- a/packages/ckeditor5-basic-styles/tests/superscript/superscriptediting.js +++ b/packages/ckeditor5-basic-styles/tests/superscript/superscriptediting.js @@ -8,6 +8,7 @@ import { SuperscriptEditing } from '../../src/superscript/superscriptediting.js' import { VirtualTestEditor } from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor.js'; import { Paragraph } from '@ckeditor/ckeditor5-paragraph'; import { AttributeCommand } from '../../src/attributecommand.js'; +import { MutuallyExclusiveAttributeCommand } from '../../src/mutuallyexclusiveattributecommand.js'; import { _getModelData, _setModelData, _getViewData } from '@ckeditor/ckeditor5-engine'; @@ -66,6 +67,7 @@ describe( 'SuperscriptEditing', () => { it( 'should register superscript command', () => { const command = editor.commands.get( 'superscript' ); + expect( command ).to.be.instanceOf( MutuallyExclusiveAttributeCommand ); expect( command ).to.be.instanceOf( AttributeCommand ); expect( command ).to.have.property( 'attributeKey', 'superscript' ); } ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a14e8510686..b81831057f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -574,6 +574,9 @@ importers: '@ckeditor/ckeditor5-paragraph': specifier: workspace:* version: link:../ckeditor5-paragraph + '@ckeditor/ckeditor5-undo': + specifier: workspace:* + version: link:../ckeditor5-undo packages/ckeditor5-block-quote: dependencies: