Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changelog/20260428123311_ck_20000.md
Comment thread
martnpaneq marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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`.
26 changes: 26 additions & 0 deletions packages/ckeditor5-basic-styles/docs/features/basic-styles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<info-box info>
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 `<sub><sup>x</sup></sub>` keeps both attributes on the same text regardless of this option.
</info-box>

## 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.
Expand Down
3 changes: 2 additions & 1 deletion packages/ckeditor5-basic-styles/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 11 additions & 0 deletions packages/ckeditor5-basic-styles/src/augmentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
Strikethrough,
StrikethroughEditing,
StrikethroughUI,
BasicStylesConfig,
SubscriptEditing,
SubscriptUI,
SuperscriptEditing,
Expand All @@ -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;
}
Comment thread
martnpaneq marked this conversation as resolved.

interface PluginsMap {
[ Superscript.pluginName ]: Superscript;
[ Subscript.pluginName ]: Subscript;
Expand Down
49 changes: 49 additions & 0 deletions packages/ckeditor5-basic-styles/src/basicstylesconfig.ts
Original file line number Diff line number Diff line change
@@ -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;
}
22 changes: 22 additions & 0 deletions packages/ckeditor5-basic-styles/src/constants.ts
Original file line number Diff line number Diff line change
@@ -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';
3 changes: 3 additions & 0 deletions packages/ckeditor5-basic-styles/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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( '<sub><sup>x</sup></sub>' )`) 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' )
);
}
Comment thread
cursor[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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, {
Expand All @@ -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 ) );
}
}
3 changes: 1 addition & 2 deletions packages/ckeditor5-basic-styles/src/subscript/subscriptui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
50 changes: 50 additions & 0 deletions packages/ckeditor5-basic-styles/src/subscriptconfig.ts
Original file line number Diff line number Diff line change
@@ -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( '<sub><sup>x</sup></sub>' )`) keeps both attributes regardless of this option.
*
* @default false
*/
allowNesting?: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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, {
Expand All @@ -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 ) );
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading