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$text>'
+ );
+ } );
+
+ it( 'removes superscript when applying subscript on superscripted text', () => {
+ _setModelData( model, '[<$text superscript="true">foo$text>]' );
+
+ editor.execute( 'subscript' );
+
+ expect( _getModelData( model, { withoutSelection: true } ) ).to.equal(
+ '<$text subscript="true">foo$text>'
+ );
+ } );
+
+ it( 'removes superscript on a non-collapsed range crossing sup | none | sub regions', () => {
+ _setModelData( model,
+ '' +
+ '[<$text superscript="true">aa$text>' +
+ 'bb' +
+ '<$text subscript="true">cc$text>]' +
+ ''
+ );
+
+ editor.execute( 'subscript' );
+
+ expect( _getModelData( model, { withoutSelection: true } ) ).to.equal(
+ '<$text subscript="true">aabbcc$text>'
+ );
+ } );
+
+ it( 'does not touch superscript when toggling subscript off', () => {
+ _setModelData( model, '[<$text subscript="true">foo$text>]' );
+
+ 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$text>'
+ );
+
+ editor.execute( 'subscript', { forceValue: false } );
+
+ expect( _getModelData( model, { withoutSelection: true } ) ).to.equal(
+ 'foo<$text superscript="true">bar$text>'
+ );
+ } );
+
+ it( 'removes superscript when forceValue:true on a superscripted range', () => {
+ _setModelData( model, '[<$text superscript="true">foo$text>]' );
+
+ editor.execute( 'subscript', { forceValue: true } );
+
+ expect( _getModelData( model, { withoutSelection: true } ) ).to.equal(
+ '<$text subscript="true">foo$text>'
+ );
+ } );
+
+ it( 'flips selection attributes on a collapsed selection inside superscripted text', () => {
+ _setModelData( model, '<$text superscript="true">foo[]bar$text>' );
+
+ 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$text>]' );
+
+ editor.execute( 'subscript' );
+
+ expect( _getModelData( model, { withoutSelection: true } ) ).to.equal(
+ '<$text subscript="true">foo$text>'
+ );
+
+ editor.execute( 'undo' );
+
+ expect( _getModelData( model, { withoutSelection: true } ) ).to.equal(
+ '<$text superscript="true">foo$text>'
+ );
+ } );
+ } );
+
+ 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$text>]' );
+
+ editor.execute( 'subscript' );
+
+ expect( _getModelData( model, { withoutSelection: true } ) ).to.equal(
+ '<$text subscript="true" superscript="true">foo$text>'
+ );
+ } );
+ } );
+
+ 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$text>]' );
+
+ editor.execute( 'subscript' );
+
+ expect( _getModelData( model, { withoutSelection: true } ) ).to.equal(
+ '<$text subscript="true" superscript="true">foo$text>'
+ );
+ } );
+ } );
+} );
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$text>'
+ );
+ } );
+
+ it( 'removes subscript when applying superscript on subscripted text', () => {
+ _setModelData( model, '[<$text subscript="true">foo$text>]' );
+
+ editor.execute( 'superscript' );
+
+ expect( _getModelData( model, { withoutSelection: true } ) ).to.equal(
+ '<$text superscript="true">foo$text>'
+ );
+ } );
+
+ it( 'removes subscript on a non-collapsed range crossing sub | none | sup regions', () => {
+ _setModelData( model,
+ '' +
+ '[<$text subscript="true">aa$text>' +
+ 'bb' +
+ '<$text superscript="true">cc$text>]' +
+ ''
+ );
+
+ editor.execute( 'superscript' );
+
+ expect( _getModelData( model, { withoutSelection: true } ) ).to.equal(
+ '<$text superscript="true">aabbcc$text>'
+ );
+ } );
+
+ it( 'does not touch subscript when toggling superscript off', () => {
+ _setModelData( model, '[<$text superscript="true">foo$text>]' );
+
+ 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$text>'
+ );
+
+ editor.execute( 'superscript', { forceValue: false } );
+
+ expect( _getModelData( model, { withoutSelection: true } ) ).to.equal(
+ 'foo<$text subscript="true">bar$text>'
+ );
+ } );
+
+ it( 'removes subscript when forceValue:true on a subscripted range', () => {
+ _setModelData( model, '[<$text subscript="true">foo$text>]' );
+
+ editor.execute( 'superscript', { forceValue: true } );
+
+ expect( _getModelData( model, { withoutSelection: true } ) ).to.equal(
+ '<$text superscript="true">foo$text>'
+ );
+ } );
+
+ it( 'flips selection attributes on a collapsed selection inside subscripted text', () => {
+ _setModelData( model, '<$text subscript="true">foo[]bar$text>' );
+
+ 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$text>]' );
+
+ editor.execute( 'superscript' );
+
+ expect( _getModelData( model, { withoutSelection: true } ) ).to.equal(
+ '<$text superscript="true">foo$text>'
+ );
+
+ editor.execute( 'undo' );
+
+ expect( _getModelData( model, { withoutSelection: true } ) ).to.equal(
+ '<$text subscript="true">foo$text>'
+ );
+ } );
+ } );
+
+ 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$text>]' );
+
+ editor.execute( 'superscript' );
+
+ expect( _getModelData( model, { withoutSelection: true } ) ).to.equal(
+ '<$text subscript="true" superscript="true">foo$text>'
+ );
+ } );
+ } );
+
+ 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$text>]' );
+
+ editor.execute( 'superscript' );
+
+ expect( _getModelData( model, { withoutSelection: true } ) ).to.equal(
+ '<$text subscript="true" superscript="true">foo$text>'
+ );
+ } );
+ } );
+} );
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: