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 | import { LiveRange } from 'ckeditor5/src/engine';
|
7 | import { 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 | */
|
54 | export 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 | }
|