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

ckeditor / ckeditor5 / 6f7c4ea6-49ab-4ae8-85cc-c93a702378a1

09 Jul 2024 09:52AM CUT coverage: 100.0%. Remained the same
6f7c4ea6-49ab-4ae8-85cc-c93a702378a1

push

circleci

web-flow
Merge stable into master

13840 of 13840 branches covered (100.0%)

Branch coverage included in aggregate %.

36579 of 36579 relevant lines covered (100.0%)

11225.02 hits per line

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

100.0
/packages/ckeditor5-autoformat/src/inlineautoformatediting.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
/**
7
 * The inline autoformatting engine. It allows to format various inline patterns. For example,
8
 * it can be configured to make "foo" bold when typed `**foo**` (the `**` markers will be removed).
9
 *
10
 * The autoformatting operation is integrated with the undo manager,
11
 * so the autoformatting step can be undone if the user's intention was not to format the text.
12
 *
13
 * See the {@link module:autoformat/inlineautoformatediting~inlineAutoformatEditing `inlineAutoformatEditing`} documentation
14
 * to learn how to create custom inline autoformatters. You can also use
15
 * the {@link module:autoformat/autoformat~Autoformat} feature which enables a set of default autoformatters
16
 * (lists, headings, bold and italic).
17
 *
18
 * @module autoformat/inlineautoformatediting
19
 */
20

21
import type { Editor } from 'ckeditor5/src/core.js';
22
import type {
23
        DocumentChangeEvent,
24
        Model,
25
        Position,
26
        Range,
27
        Writer
28
} from 'ckeditor5/src/engine.js';
29
import type { Delete, LastTextLineData } from 'ckeditor5/src/typing.js';
30

31
import type Autoformat from './autoformat.js';
32

33
export type TestCallback = ( text: string ) => {
34
        remove: Array<Array<number>>;
35
        format: Array<Array<number>>;
36
};
37

38
/**
39
 * Enables autoformatting mechanism for a given {@link module:core/editor/editor~Editor}.
40
 *
41
 * It formats the matched text by applying the given model attribute or by running the provided formatting callback.
42
 * On every {@link module:engine/model/document~Document#event:change:data data change} in the model document
43
 * the autoformatting engine checks the text on the left of the selection
44
 * and executes the provided action if the text matches given criteria (regular expression or callback).
45
 *
46
 * @param editor The editor instance.
47
 * @param plugin The autoformat plugin instance.
48
 * @param testRegexpOrCallback The regular expression or callback to execute on text.
49
 * Provided regular expression *must* have three capture groups. The first and the third capture group
50
 * should match opening and closing delimiters. The second capture group should match the text to format.
51
 *
52
 * ```ts
53
 * // Matches the `**bold text**` pattern.
54
 * // There are three capturing groups:
55
 * // - The first to match the starting `**` delimiter.
56
 * // - The second to match the text to format.
57
 * // - The third to match the ending `**` delimiter.
58
 * inlineAutoformatEditing( editor, plugin, /(\*\*)([^\*]+?)(\*\*)$/g, formatCallback );
59
 * ```
60
 *
61
 * When a function is provided instead of the regular expression, it will be executed with the text to match as a parameter.
62
 * The function should return proper "ranges" to delete and format.
63
 *
64
 * ```ts
65
 * {
66
 *         remove: [
67
 *                 [ 0, 1 ],        // Remove the first letter from the given text.
68
 *                 [ 5, 6 ]        // Remove the 6th letter from the given text.
69
 *         ],
70
 *         format: [
71
 *                 [ 1, 5 ]        // Format all letters from 2nd to 5th.
72
 *         ]
73
 * }
74
 * ```
75
 *
76
 * @param formatCallback A callback to apply actual formatting.
77
 * It should return `false` if changes should not be applied (e.g. if a command is disabled).
78
 *
79
 * ```ts
80
 * inlineAutoformatEditing( editor, plugin, /(\*\*)([^\*]+?)(\*\*)$/g, ( writer, rangesToFormat ) => {
81
 *         const command = editor.commands.get( 'bold' );
82
 *
83
 *         if ( !command.isEnabled ) {
84
 *                 return false;
85
 *         }
86
 *
87
 *         const validRanges = editor.model.schema.getValidRanges( rangesToFormat, 'bold' );
88
 *
89
 *         for ( let range of validRanges ) {
90
 *                 writer.setAttribute( 'bold', true, range );
91
 *         }
92
 * } );
93
 * ```
94
 */
95
export default function inlineAutoformatEditing(
96
        editor: Editor,
97
        plugin: Autoformat,
98
        testRegexpOrCallback: RegExp | TestCallback,
99
        formatCallback: ( writer: Writer, rangesToFormat: Array<Range> ) => boolean | undefined
100
): void {
101
        let regExp: RegExp;
102
        let testCallback: TestCallback | undefined;
103

104
        if ( testRegexpOrCallback instanceof RegExp ) {
1,378✔
105
                regExp = testRegexpOrCallback;
1,374✔
106
        } else {
107
                testCallback = testRegexpOrCallback;
4✔
108
        }
109

110
        // A test callback run on changed text.
111
        testCallback = testCallback || ( text => {
1,378✔
112
                let result: RegExpExecArray | null;
113
                const remove: Array<Array<number>> = [];
1,166✔
114
                const format: Array<Array<number>> = [];
1,166✔
115

116
                while ( ( result = regExp.exec( text ) ) !== null ) {
1,166✔
117
                        // There should be full match and 3 capture groups.
118
                        if ( result && result.length < 4 ) {
32✔
119
                                break;
1✔
120
                        }
121

122
                        let {
123
                                index,
124
                                '1': leftDel,
125
                                '2': content,
126
                                '3': rightDel
127
                        } = result;
31✔
128

129
                        // Real matched string - there might be some non-capturing groups so we need to recalculate starting index.
130
                        const found = leftDel + content + rightDel;
31✔
131
                        index += result[ 0 ].length - found.length;
31✔
132

133
                        // Start and End offsets of delimiters to remove.
134
                        const delStart = [
31✔
135
                                index,
136
                                index + leftDel.length
137
                        ];
138
                        const delEnd = [
31✔
139
                                index + leftDel.length + content.length,
140
                                index + leftDel.length + content.length + rightDel.length
141
                        ];
142

143
                        remove.push( delStart );
31✔
144
                        remove.push( delEnd );
31✔
145

146
                        format.push( [ index + leftDel.length, index + leftDel.length + content.length ] );
31✔
147
                }
148

149
                return {
1,166✔
150
                        remove,
151
                        format
152
                };
153
        } );
154

155
        editor.model.document.on<DocumentChangeEvent>( 'change:data', ( evt, batch ) => {
1,378✔
156
                if ( batch.isUndo || !batch.isLocal || !plugin.isEnabled ) {
4,902✔
157
                        return;
158✔
158
                }
159

160
                const model = editor.model;
4,744✔
161
                const selection = model.document.selection;
4,744✔
162

163
                // Do nothing if selection is not collapsed.
164
                if ( !selection.isCollapsed ) {
4,744✔
165
                        return;
2✔
166
                }
167

168
                const changes = Array.from( model.document.differ.getChanges() );
4,742✔
169
                const entry = changes[ 0 ];
4,742✔
170

171
                // Typing is represented by only a single change.
172
                if ( changes.length != 1 || entry.type !== 'insert' || entry.name != '$text' || entry.length != 1 ) {
4,742✔
173
                        return;
3,572✔
174
                }
175

176
                const focus = selection.focus;
1,170✔
177
                const block = focus!.parent;
1,170✔
178
                const { text, range } = getTextAfterCode( model.createRange( model.createPositionAt( block, 0 ), focus! ), model );
1,170✔
179
                const testOutput = testCallback!( text );
1,170✔
180
                const rangesToFormat = testOutputToRanges( range.start, testOutput.format, model );
1,170✔
181
                const rangesToRemove = testOutputToRanges( range.start, testOutput.remove, model );
1,170✔
182

183
                if ( !( rangesToFormat.length && rangesToRemove.length ) ) {
1,170✔
184
                        return;
1,139✔
185
                }
186

187
                // Use enqueueChange to create new batch to separate typing batch from the auto-format changes.
188
                model.enqueueChange( writer => {
31✔
189
                        // Apply format.
190
                        const hasChanged = formatCallback( writer, rangesToFormat );
31✔
191

192
                        // Strict check on `false` to have backward compatibility (when callbacks were returning `undefined`).
193
                        if ( hasChanged === false ) {
31✔
194
                                return;
2✔
195
                        }
196

197
                        // Remove delimiters - use reversed order to not mix the offsets while removing.
198
                        for ( const range of rangesToRemove.reverse() ) {
29✔
199
                                writer.remove( range );
58✔
200
                        }
201

202
                        model.enqueueChange( () => {
29✔
203
                                const deletePlugin: Delete = editor.plugins.get( 'Delete' );
29✔
204

205
                                deletePlugin.requestUndoOnBackspace();
29✔
206
                        } );
207
                } );
208
        } );
209
}
210

211
/**
212
 * Converts output of the test function provided to the inlineAutoformatEditing and converts it to the model ranges
213
 * inside provided block.
214
 */
215
function testOutputToRanges( start: Position, arrays: Array<Array<number>>, model: Model ) {
216
        return arrays
2,340✔
217
                .filter( array => ( array[ 0 ] !== undefined && array[ 1 ] !== undefined ) )
98✔
218
                .map( array => {
219
                        return model.createRange( start.getShiftedBy( array[ 0 ] ), start.getShiftedBy( array[ 1 ] ) );
93✔
220
                } );
221
}
222

223
/**
224
 * Returns the last text line after the last code element from the given range.
225
 * It is similar to {@link module:typing/utils/getlasttextline.getLastTextLine `getLastTextLine()`},
226
 * but it ignores any text before the last `code`.
227
 */
228
function getTextAfterCode( range: Range, model: Model ): LastTextLineData {
229
        let start = range.start;
1,170✔
230

231
        const text = Array.from( range.getItems() ).reduce( ( rangeText, node ) => {
1,170✔
232
                // Trim text to a last occurrence of an inline element and update range start.
233
                if ( !( node.is( '$text' ) || node.is( '$textProxy' ) ) || node.getAttribute( 'code' ) ) {
1,434✔
234
                        start = model.createPositionAfter( node );
228✔
235

236
                        return '';
228✔
237
                }
238

239
                return rangeText + node.data;
1,206✔
240
        }, '' );
241

242
        return { text, range: model.createRange( start, range.end ) };
1,170✔
243
}
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