Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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_master.md
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.
24 changes: 24 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,30 @@ 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:

```js
ClassicEditor
.create( {
// ... Other configuration options ...
superscript: {
allowNesting: true
}
} )
.then( /* ... */ )
.catch( /* ... */ );
```

The flag is symmetric: setting `superscript.allowNesting` or `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
25 changes: 23 additions & 2 deletions packages/ckeditor5-basic-styles/src/augmentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@ import type {
Strikethrough,
StrikethroughEditing,
StrikethroughUI,
SubscriptCommand,
BasicStyleSubscriptConfig,
SubscriptEditing,
SubscriptUI,
SuperscriptCommand,
BasicStyleSuperscriptConfig,
SuperscriptEditing,
SuperscriptUI,
Underline,
Expand All @@ -28,6 +32,23 @@ import type {
} from './index.js';

declare module '@ckeditor/ckeditor5-core' {
interface EditorConfig {

/**
* 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;
}
Comment thread
martnpaneq marked this conversation as resolved.

interface PluginsMap {
[ Superscript.pluginName ]: Superscript;
[ Subscript.pluginName ]: Subscript;
Expand Down Expand Up @@ -58,8 +79,8 @@ declare module '@ckeditor/ckeditor5-core' {
code: AttributeCommand;
italic: AttributeCommand;
strikethrough: AttributeCommand;
subscript: AttributeCommand;
superscript: AttributeCommand;
subscript: SubscriptCommand;
superscript: SuperscriptCommand;
underline: AttributeCommand;
}
}
4 changes: 4 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,13 @@ export { StrikethroughUI } from './strikethrough/strikethroughui.js';
export { Subscript } from './subscript.js';
export { SubscriptEditing } from './subscript/subscriptediting.js';
export { SubscriptUI } from './subscript/subscriptui.js';
export { SubscriptCommand } from './subscript/subscriptcommand.js';
export type { BasicStyleSubscriptConfig } from './subscriptconfig.js';
export { Superscript } from './superscript.js';
export { SuperscriptEditing } from './superscript/superscriptediting.js';
export { SuperscriptUI } from './superscript/superscriptui.js';
export { SuperscriptCommand } from './superscript/superscriptcommand.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
91 changes: 91 additions & 0 deletions packages/ckeditor5-basic-styles/src/subscript/subscriptcommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* @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/subscript/subscriptcommand
*/

import type { Editor } from '@ckeditor/ckeditor5-core';
import { ModelDocumentSelection, type ModelElement, type ModelRange } from '@ckeditor/ckeditor5-engine';

import { AttributeCommand } from '../attributecommand.js';

const SUBSCRIPT = 'subscript';
const SUPERSCRIPT = 'superscript';
Comment thread
martnpaneq marked this conversation as resolved.
Outdated

/**
* The subscript command. It is registered as the `'subscript'` command
* by {@link module:basic-styles/subscript/subscriptediting~SubscriptEditing}.
*
* In addition to toggling the `subscript` attribute (the behavior provided by
* {@link module:basic-styles/attributecommand~AttributeCommand}), the command enforces mutual exclusion
* with the `superscript` attribute. When `subscript` is applied to a selection that already has
* `superscript`, the `superscript` attribute is removed in the same model change so the operation is a
* single undo step.
*
* The mutual exclusion can be disabled by setting either
* {@link module:basic-styles/subscriptconfig~BasicStyleSubscriptConfig#allowNesting `config.subscript.allowNesting`}
* or {@link module:basic-styles/superscriptconfig~BasicStyleSuperscriptConfig#allowNesting `config.superscript.allowNesting`}
* to `true`. In that case the command behaves 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.
*/
export class SubscriptCommand extends AttributeCommand {
constructor( editor: Editor ) {
super( editor, SUBSCRIPT );
}

/**
* @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;
}

model.change( writer => {
super.execute( options );

const selection = model.document.selection;

if ( selection.isCollapsed ) {
writer.removeSelectionAttribute( SUPERSCRIPT );

return;
}

const ranges = model.schema.getValidRanges( selection.getRanges(), SUPERSCRIPT, {
includeEmptyRanges: true
} );

for ( const range of ranges ) {
let itemOrRange: ModelRange | ModelElement = range;
let attributeKey = SUPERSCRIPT;

if ( range.isCollapsed ) {
itemOrRange = range.start.parent as ModelElement;
attributeKey = ModelDocumentSelection._getStoreAttributeKey( SUPERSCRIPT );
}

writer.removeAttribute( attributeKey, itemOrRange );
}
} );
}
}

function _isNestingAllowed( editor: Editor ): boolean {
return Boolean(
editor.config.get( 'superscript.allowNesting' ) ||
editor.config.get( 'subscript.allowNesting' )
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

import { Plugin } from '@ckeditor/ckeditor5-core';
import { AttributeCommand } from '../attributecommand.js';
import { SubscriptCommand } from './subscriptcommand.js';

const SUBSCRIPT = 'subscript';

Expand Down Expand Up @@ -38,6 +38,9 @@ export class SubscriptEditing extends Plugin {
*/
public init(): void {
const editor = this.editor;

editor.config.define( 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 +63,6 @@ export class SubscriptEditing extends Plugin {
} );

// Create sub command.
editor.commands.add( SUBSCRIPT, new AttributeCommand( editor, SUBSCRIPT ) );
editor.commands.add( SUBSCRIPT, new SubscriptCommand( editor ) );
}
}
47 changes: 47 additions & 0 deletions packages/ckeditor5-basic-styles/src/subscriptconfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* @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}.
*
* ```ts
* ClassicEditor
* .create( editorElement, {
* 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.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
@@ -0,0 +1,91 @@
/**
* @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/superscript/superscriptcommand
*/

import type { Editor } from '@ckeditor/ckeditor5-core';
import { ModelDocumentSelection, type ModelElement, type ModelRange } from '@ckeditor/ckeditor5-engine';

import { AttributeCommand } from '../attributecommand.js';

const SUPERSCRIPT = 'superscript';
const SUBSCRIPT = 'subscript';

/**
* The superscript command. It is registered as the `'superscript'` command
* by {@link module:basic-styles/superscript/superscriptediting~SuperscriptEditing}.
*
* In addition to toggling the `superscript` attribute (the behavior provided by
* {@link module:basic-styles/attributecommand~AttributeCommand}), the command enforces mutual exclusion
* with the `subscript` attribute. When `superscript` is applied to a selection that already has
* `subscript`, the `subscript` attribute is removed in the same model change so the operation is a
* single undo step.
*
* The mutual exclusion can be disabled by setting either
* {@link module:basic-styles/superscriptconfig~BasicStyleSuperscriptConfig#allowNesting `config.superscript.allowNesting`}
* or {@link module:basic-styles/subscriptconfig~BasicStyleSubscriptConfig#allowNesting `config.subscript.allowNesting`}
* to `true`. In that case the command behaves 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.
*/
export class SuperscriptCommand extends AttributeCommand {
constructor( editor: Editor ) {
super( editor, SUPERSCRIPT );
}

/**
* @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;
}

model.change( writer => {
super.execute( options );

const selection = model.document.selection;

if ( selection.isCollapsed ) {
writer.removeSelectionAttribute( SUBSCRIPT );

return;
}

const ranges = model.schema.getValidRanges( selection.getRanges(), SUBSCRIPT, {
includeEmptyRanges: true
} );

for ( const range of ranges ) {
let itemOrRange: ModelRange | ModelElement = range;
let attributeKey = SUBSCRIPT;

if ( range.isCollapsed ) {
itemOrRange = range.start.parent as ModelElement;
attributeKey = ModelDocumentSelection._getStoreAttributeKey( SUBSCRIPT );
}

writer.removeAttribute( attributeKey, itemOrRange );
}
} );
}
}

function _isNestingAllowed( editor: Editor ): boolean {
return Boolean(
editor.config.get( 'superscript.allowNesting' ) ||
editor.config.get( 'subscript.allowNesting' )
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

import { Plugin } from '@ckeditor/ckeditor5-core';
import { AttributeCommand } from '../attributecommand.js';
import { SuperscriptCommand } from './superscriptcommand.js';

const SUPERSCRIPT = 'superscript';

Expand Down Expand Up @@ -38,6 +38,9 @@ export class SuperscriptEditing extends Plugin {
*/
public init(): void {
const editor = this.editor;

editor.config.define( 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 +63,6 @@ export class SuperscriptEditing extends Plugin {
} );

// Create super command.
editor.commands.add( SUPERSCRIPT, new AttributeCommand( editor, SUPERSCRIPT ) );
editor.commands.add( SUPERSCRIPT, new SuperscriptCommand( editor ) );
}
}
Loading