UNPKG

5.78 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
6import { LiveRange } from 'ckeditor5/src/engine';
7import { first } from 'ckeditor5/src/utils';
8
9/**
10 * The block autoformatting engine. It allows to format various block patterns. For example,
11 * it can be configured to turn a paragraph starting with `*` and followed by a space into a list item.
12 *
13 * The autoformatting operation is integrated with the undo manager,
14 * so the autoformatting step can be undone if the user's intention was not to format the text.
15 *
16 * See the {@link module:autoformat/blockautoformatediting~blockAutoformatEditing `blockAutoformatEditing`} documentation
17 * to learn how to create custom block autoformatters. You can also use
18 * the {@link module:autoformat/autoformat~Autoformat} feature which enables a set of default autoformatters
19 * (lists, headings, bold and italic).
20 *
21 * @module autoformat/blockautoformatediting
22 */
23
24/**
25 * Creates a listener triggered on {@link module:engine/model/document~Document#event:change:data `change:data`} event in the document.
26 * Calls the callback when inserted text matches the regular expression or the command name
27 * if provided instead of the callback.
28 *
29 * Examples of usage:
30 *
31 * To convert a paragraph to heading 1 when `- ` is typed, using just the command name:
32 *
33 * blockAutoformatEditing( editor, plugin, /^\- $/, 'heading1' );
34 *
35 * To convert a paragraph to heading 1 when `- ` is typed, using just the callback:
36 *
37 * blockAutoformatEditing( editor, plugin, /^\- $/, ( context ) => {
38 * const { match } = context;
39 * const headingLevel = match[ 1 ].length;
40 *
41 * editor.execute( 'heading', {
42 * formatId: `heading${ headingLevel }`
43 * } );
44 * } );
45 *
46 * @param {module:core/editor/editor~Editor} editor The editor instance.
47 * @param {module:autoformat/autoformat~Autoformat} plugin The autoformat plugin instance.
48 * @param {RegExp} pattern The regular expression to execute on just inserted text. The regular expression is tested against the text
49 * from the beginning until the caret position.
50 * @param {Function|String} callbackOrCommand The callback to execute or the command to run when the text is matched.
51 * In case of providing the callback, it receives the following parameter:
52 * * {Object} match RegExp.exec() result of matching the pattern to inserted text.
53 */
54export default function blockAutoformatEditing( editor, plugin, pattern, callbackOrCommand ) {
55 let callback;
56 let command = null;
57
58 if ( typeof callbackOrCommand == 'function' ) {
59 callback = callbackOrCommand;
60 } else {
61 // We assume that the actual command name was provided.
62 command = editor.commands.get( callbackOrCommand );
63
64 callback = () => {
65 editor.execute( callbackOrCommand );
66 };
67 }
68
69 editor.model.document.on( 'change:data', ( evt, batch ) => {
70 if ( command && !command.isEnabled || !plugin.isEnabled ) {
71 return;
72 }
73
74 const range = first( editor.model.document.selection.getRanges() );
75
76 if ( !range.isCollapsed ) {
77 return;
78 }
79
80 if ( batch.isUndo || !batch.isLocal ) {
81 return;
82 }
83
84 const changes = Array.from( editor.model.document.differ.getChanges() );
85 const entry = changes[ 0 ];
86
87 // Typing is represented by only a single change.
88 if ( changes.length != 1 || entry.type !== 'insert' || entry.name != '$text' || entry.length != 1 ) {
89 return;
90 }
91
92 const blockToFormat = entry.position.parent;
93
94 // Block formatting should be disabled in codeBlocks (#5800).
95 if ( blockToFormat.is( 'element', 'codeBlock' ) ) {
96 return;
97 }
98
99 // Only list commands and custom callbacks can be applied inside a list.
100 if ( blockToFormat.is( 'element', 'listItem' ) &&
101 typeof callbackOrCommand !== 'function' &&
102 ![ 'numberedList', 'bulletedList', 'todoList' ].includes( callbackOrCommand )
103 ) {
104 return;
105 }
106
107 // In case a command is bound, do not re-execute it over an existing block style which would result with a style removal.
108 // Instead just drop processing so that autoformat trigger text is not lost. E.g. writing "# " in a level 1 heading.
109 if ( command && command.value === true ) {
110 return;
111 }
112
113 const firstNode = blockToFormat.getChild( 0 );
114 const firstNodeRange = editor.model.createRangeOn( firstNode );
115
116 // Range is only expected to be within or at the very end of the first text node.
117 if ( !firstNodeRange.containsRange( range ) && !range.end.isEqual( firstNodeRange.end ) ) {
118 return;
119 }
120
121 const match = pattern.exec( firstNode.data.substr( 0, range.end.offset ) );
122
123 // ...and this text node's data match the pattern.
124 if ( !match ) {
125 return;
126 }
127
128 // Use enqueueChange to create new batch to separate typing batch from the auto-format changes.
129 editor.model.enqueueChange( writer => {
130 // Matched range.
131 const start = writer.createPositionAt( blockToFormat, 0 );
132 const end = writer.createPositionAt( blockToFormat, match[ 0 ].length );
133 const range = new LiveRange( start, end );
134
135 const wasChanged = callback( { match } );
136
137 // Remove matched text.
138 if ( wasChanged !== false ) {
139 writer.remove( range );
140
141 const selectionRange = editor.model.document.selection.getFirstRange();
142 const blockRange = writer.createRangeIn( blockToFormat );
143
144 // If the block is empty and the document selection has been moved when
145 // applying formatting (e.g. is now in newly created block).
146 if ( blockToFormat.isEmpty && !blockRange.isEqual( selectionRange ) && !blockRange.containsRange( selectionRange, true ) ) {
147 writer.remove( blockToFormat );
148 }
149 }
150 range.detach();
151
152 editor.model.enqueueChange( () => {
153 editor.plugins.get( 'Delete' ).requestUndoOnBackspace();
154 } );
155 } );
156 } );
157}