UNPKG

7.82 kBJavaScriptView Raw
1/**
2 * @license Copyright (c) 2003-2022, 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/**
22 * Enables autoformatting mechanism for a given {@link module:core/editor/editor~Editor}.
23 *
24 * It formats the matched text by applying the given model attribute or by running the provided formatting callback.
25 * On every {@link module:engine/model/document~Document#event:change:data data change} in the model document
26 * the autoformatting engine checks the text on the left of the selection
27 * and executes the provided action if the text matches given criteria (regular expression or callback).
28 *
29 * @param {module:core/editor/editor~Editor} editor The editor instance.
30 * @param {module:autoformat/autoformat~Autoformat} plugin The autoformat plugin instance.
31 * @param {Function|RegExp} testRegexpOrCallback The regular expression or callback to execute on text.
32 * Provided regular expression *must* have three capture groups. The first and the third capture group
33 * should match opening and closing delimiters. The second capture group should match the text to format.
34 *
35 * // Matches the `**bold text**` pattern.
36 * // There are three capturing groups:
37 * // - The first to match the starting `**` delimiter.
38 * // - The second to match the text to format.
39 * // - The third to match the ending `**` delimiter.
40 * inlineAutoformatEditing( editor, plugin, /(\*\*)([^\*]+?)(\*\*)$/g, formatCallback );
41 *
42 * When a function is provided instead of the regular expression, it will be executed with the text to match as a parameter.
43 * The function should return proper "ranges" to delete and format.
44 *
45 * {
46 * remove: [
47 * [ 0, 1 ], // Remove the first letter from the given text.
48 * [ 5, 6 ] // Remove the 6th letter from the given text.
49 * ],
50 * format: [
51 * [ 1, 5 ] // Format all letters from 2nd to 5th.
52 * ]
53 * }
54 *
55 * @param {Function} formatCallback A callback to apply actual formatting.
56 * It should return `false` if changes should not be applied (e.g. if a command is disabled).
57 *
58 * inlineAutoformatEditing( editor, plugin, /(\*\*)([^\*]+?)(\*\*)$/g, ( writer, rangesToFormat ) => {
59 * const command = editor.commands.get( 'bold' );
60 *
61 * if ( !command.isEnabled ) {
62 * return false;
63 * }
64 *
65 * const validRanges = editor.model.schema.getValidRanges( rangesToFormat, 'bold' );
66 *
67 * for ( let range of validRanges ) {
68 * writer.setAttribute( 'bold', true, range );
69 * }
70 * } );
71 */
72export default function inlineAutoformatEditing( editor, plugin, testRegexpOrCallback, formatCallback ) {
73 let regExp;
74 let testCallback;
75
76 if ( testRegexpOrCallback instanceof RegExp ) {
77 regExp = testRegexpOrCallback;
78 } else {
79 testCallback = testRegexpOrCallback;
80 }
81
82 // A test callback run on changed text.
83 testCallback = testCallback || ( text => {
84 let result;
85 const remove = [];
86 const format = [];
87
88 while ( ( result = regExp.exec( text ) ) !== null ) {
89 // There should be full match and 3 capture groups.
90 if ( result && result.length < 4 ) {
91 break;
92 }
93
94 let {
95 index,
96 '1': leftDel,
97 '2': content,
98 '3': rightDel
99 } = result;
100
101 // Real matched string - there might be some non-capturing groups so we need to recalculate starting index.
102 const found = leftDel + content + rightDel;
103 index += result[ 0 ].length - found.length;
104
105 // Start and End offsets of delimiters to remove.
106 const delStart = [
107 index,
108 index + leftDel.length
109 ];
110 const delEnd = [
111 index + leftDel.length + content.length,
112 index + leftDel.length + content.length + rightDel.length
113 ];
114
115 remove.push( delStart );
116 remove.push( delEnd );
117
118 format.push( [ index + leftDel.length, index + leftDel.length + content.length ] );
119 }
120
121 return {
122 remove,
123 format
124 };
125 } );
126
127 editor.model.document.on( 'change:data', ( evt, batch ) => {
128 if ( batch.isUndo || !batch.isLocal || !plugin.isEnabled ) {
129 return;
130 }
131
132 const model = editor.model;
133 const selection = model.document.selection;
134
135 // Do nothing if selection is not collapsed.
136 if ( !selection.isCollapsed ) {
137 return;
138 }
139
140 const changes = Array.from( model.document.differ.getChanges() );
141 const entry = changes[ 0 ];
142
143 // Typing is represented by only a single change.
144 if ( changes.length != 1 || entry.type !== 'insert' || entry.name != '$text' || entry.length != 1 ) {
145 return;
146 }
147
148 const focus = selection.focus;
149 const block = focus.parent;
150 const { text, range } = getTextAfterCode( model.createRange( model.createPositionAt( block, 0 ), focus ), model );
151 const testOutput = testCallback( text );
152 const rangesToFormat = testOutputToRanges( range.start, testOutput.format, model );
153 const rangesToRemove = testOutputToRanges( range.start, testOutput.remove, model );
154
155 if ( !( rangesToFormat.length && rangesToRemove.length ) ) {
156 return;
157 }
158
159 // Use enqueueChange to create new batch to separate typing batch from the auto-format changes.
160 model.enqueueChange( writer => {
161 // Apply format.
162 const hasChanged = formatCallback( writer, rangesToFormat );
163
164 // Strict check on `false` to have backward compatibility (when callbacks were returning `undefined`).
165 if ( hasChanged === false ) {
166 return;
167 }
168
169 // Remove delimiters - use reversed order to not mix the offsets while removing.
170 for ( const range of rangesToRemove.reverse() ) {
171 writer.remove( range );
172 }
173
174 model.enqueueChange( () => {
175 editor.plugins.get( 'Delete' ).requestUndoOnBackspace();
176 } );
177 } );
178 } );
179}
180
181// Converts output of the test function provided to the inlineAutoformatEditing and converts it to the model ranges
182// inside provided block.
183//
184// @private
185// @param {module:engine/model/position~Position} start
186// @param {Array.<Array>} arrays
187// @param {module:engine/model/model~Model} model
188function testOutputToRanges( start, arrays, model ) {
189 return arrays
190 .filter( array => ( array[ 0 ] !== undefined && array[ 1 ] !== undefined ) )
191 .map( array => {
192 return model.createRange( start.getShiftedBy( array[ 0 ] ), start.getShiftedBy( array[ 1 ] ) );
193 } );
194}
195
196// Returns the last text line after the last code element from the given range.
197// It is similar to {@link module:typing/utils/getlasttextline.getLastTextLine `getLastTextLine()`},
198// but it ignores any text before the last `code`.
199//
200// @param {module:engine/model/range~Range} range
201// @param {module:engine/model/model~Model} model
202// @returns {module:typing/utils/getlasttextline~LastTextLineData}
203function getTextAfterCode( range, model ) {
204 let start = range.start;
205
206 const text = Array.from( range.getItems() ).reduce( ( rangeText, node ) => {
207 // Trim text to a last occurrence of an inline element and update range start.
208 if ( !( node.is( '$text' ) || node.is( '$textProxy' ) ) || node.getAttribute( 'code' ) ) {
209 start = model.createPositionAfter( node );
210
211 return '';
212 }
213
214 return rangeText + node.data;
215 }, '' );
216
217 return { text, range: model.createRange( start, range.end ) };
218}