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 | */
|
72 | export 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
|
188 | function 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}
|
203 | function 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 | }
|