• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ckeditor / ckeditor5 / 1b17206b-3b2e-480c-9a97-c45c42dfc2bd

23 Apr 2024 09:34AM UTC coverage: 100.0%. Remained the same
1b17206b-3b2e-480c-9a97-c45c42dfc2bd

push

circleci

web-flow
Merge stable into master

13689 of 13689 branches covered (100.0%)

Branch coverage included in aggregate %.

36266 of 36266 relevant lines covered (100.0%)

11001.28 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

100.0
/packages/ckeditor5-autoformat/src/blockautoformatediting.ts
1
/**
2
 * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
3
 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
4
 */
5

6
import type { Command, Editor } from 'ckeditor5/src/core.js';
7

8
import {
9
        LiveRange,
10
        type DocumentChangeEvent,
11
        type Item,
12
        type Text
13
} from 'ckeditor5/src/engine.js';
14

15
import { first } from 'ckeditor5/src/utils.js';
16

17
import type Autoformat from './autoformat.js';
18

19
import type { Delete } from 'ckeditor5/src/typing.js';
20

21
/**
22
 * The block autoformatting engine. It allows to format various block patterns. For example,
23
 * it can be configured to turn a paragraph starting with `*` and followed by a space into a list item.
24
 *
25
 * The autoformatting operation is integrated with the undo manager,
26
 * so the autoformatting step can be undone if the user's intention was not to format the text.
27
 *
28
 * See the {@link module:autoformat/blockautoformatediting~blockAutoformatEditing `blockAutoformatEditing`} documentation
29
 * to learn how to create custom block autoformatters. You can also use
30
 * the {@link module:autoformat/autoformat~Autoformat} feature which enables a set of default autoformatters
31
 * (lists, headings, bold and italic).
32
 *
33
 * @module autoformat/blockautoformatediting
34
 */
35

36
/**
37
 * Creates a listener triggered on {@link module:engine/model/document~Document#event:change:data `change:data`} event in the document.
38
 * Calls the callback when inserted text matches the regular expression or the command name
39
 * if provided instead of the callback.
40
 *
41
 * Examples of usage:
42
 *
43
 * To convert a paragraph into heading 1 when `- ` is typed, using just the command name:
44
 *
45
 * ```ts
46
 * blockAutoformatEditing( editor, plugin, /^\- $/, 'heading1' );
47
 * ```
48
 *
49
 * To convert a paragraph into heading 1 when `- ` is typed, using just the callback:
50
 *
51
 * ```ts
52
 * blockAutoformatEditing( editor, plugin, /^\- $/, ( context ) => {
53
 *         const { match } = context;
54
 *         const headingLevel = match[ 1 ].length;
55
 *
56
 *         editor.execute( 'heading', {
57
 *                 formatId: `heading${ headingLevel }`
58
 *         } );
59
 * } );
60
 * ```
61
 *
62
 * @param editor The editor instance.
63
 * @param plugin The autoformat plugin instance.
64
 * @param pattern The regular expression to execute on just inserted text. The regular expression is tested against the text
65
 * from the beginning until the caret position.
66
 * @param callbackOrCommand The callback to execute or the command to run when the text is matched.
67
 * In case of providing the callback, it receives the following parameter:
68
 * * match RegExp.exec() result of matching the pattern to inserted text.
69
 */
70
export default function blockAutoformatEditing(
71
        editor: Editor,
72
        plugin: Autoformat,
73
        pattern: RegExp,
74
        callbackOrCommand: string | ( ( context: { match: RegExpExecArray } ) => unknown )
75
): void {
76
        let callback: ( context: { match: RegExpExecArray } ) => unknown;
77
        let command: Command | null = null;
1,123✔
78

79
        if ( typeof callbackOrCommand == 'function' ) {
1,123✔
80
                callback = callbackOrCommand;
563✔
81
        } else {
82
                // We assume that the actual command name was provided.
83
                command = editor.commands.get( callbackOrCommand )!;
560✔
84

85
                callback = () => {
560✔
86
                        editor.execute( callbackOrCommand );
31✔
87
                };
88
        }
89

90
        editor.model.document.on<DocumentChangeEvent>( 'change:data', ( evt, batch ) => {
1,123✔
91
                if ( command && !command.isEnabled || !plugin.isEnabled ) {
3,871✔
92
                        return;
24✔
93
                }
94

95
                const range = first( editor.model.document.selection.getRanges() )!;
3,847✔
96

97
                if ( !range.isCollapsed ) {
3,847✔
98
                        return;
2✔
99
                }
100

101
                if ( batch.isUndo || !batch.isLocal ) {
3,845✔
102
                        return;
84✔
103
                }
104

105
                const changes = Array.from( editor.model.document.differ.getChanges() );
3,761✔
106
                const entry = changes[ 0 ];
3,761✔
107

108
                // Typing is represented by only a single change.
109
                if ( changes.length != 1 || entry.type !== 'insert' || entry.name != '$text' || entry.length != 1 ) {
3,761✔
110
                        return;
2,822✔
111
                }
112

113
                const blockToFormat = entry.position.parent;
939✔
114

115
                // Block formatting should be disabled in codeBlocks (#5800).
116
                if ( blockToFormat.is( 'element', 'codeBlock' ) ) {
939✔
117
                        return;
10✔
118
                }
119

120
                // Only list commands and custom callbacks can be applied inside a list.
121
                if ( blockToFormat.is( 'element', 'listItem' ) &&
929✔
122
                        typeof callbackOrCommand !== 'function' &&
123
                        ![ 'numberedList', 'bulletedList', 'todoList' ].includes( callbackOrCommand )
124
                ) {
125
                        return;
34✔
126
                }
127

128
                // In case a command is bound, do not re-execute it over an existing block style which would result in a style removal.
129
                // Instead, just drop processing so that autoformat trigger text is not lost. E.g. writing "# " in a level 1 heading.
130
                if ( command && command.value === true ) {
895✔
131
                        return;
17✔
132
                }
133

134
                const firstNode = blockToFormat.getChild( 0 ) as Text;
878✔
135

136
                const firstNodeRange = editor.model.createRangeOn( firstNode );
878✔
137

138
                // Range is only expected to be within or at the very end of the first text node.
139
                if ( !firstNodeRange.containsRange( range ) && !range.end.isEqual( firstNodeRange.end ) ) {
878✔
140
                        return;
152✔
141
                }
142

143
                const match = pattern.exec( firstNode.data.substr( 0, range.end.offset ) );
726✔
144

145
                // ...and this text node's data match the pattern.
146
                if ( !match ) {
726✔
147
                        return;
669✔
148
                }
149

150
                // Use enqueueChange to create new batch to separate typing batch from the auto-format changes.
151
                editor.model.enqueueChange( writer => {
57✔
152
                        // Matched range.
153
                        const start = writer.createPositionAt( blockToFormat, 0 );
57✔
154
                        const end = writer.createPositionAt( blockToFormat, match[ 0 ].length );
57✔
155
                        const range = new LiveRange( start, end );
57✔
156

157
                        const wasChanged = callback( { match } );
57✔
158

159
                        // Remove matched text.
160
                        if ( wasChanged !== false ) {
57✔
161
                                writer.remove( range );
50✔
162

163
                                const selectionRange = editor.model.document.selection.getFirstRange()!;
50✔
164
                                const blockRange = writer.createRangeIn( blockToFormat );
50✔
165

166
                                // If the block is empty and the document selection has been moved when
167
                                // applying formatting (e.g. is now in newly created block).
168
                                if ( blockToFormat.isEmpty && !blockRange.isEqual( selectionRange ) && !blockRange.containsRange( selectionRange, true ) ) {
50✔
169
                                        writer.remove( blockToFormat as Item );
3✔
170
                                }
171
                        }
172
                        range.detach();
57✔
173

174
                        editor.model.enqueueChange( () => {
57✔
175
                                const deletePlugin: Delete = editor.plugins.get( 'Delete' );
57✔
176

177
                                deletePlugin.requestUndoOnBackspace();
57✔
178
                        } );
179
                } );
180
        } );
181
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc