-
-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
4 changed files
with
626 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,241 @@ | ||
/** | ||
* @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. | ||
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license | ||
*/ | ||
|
||
/** | ||
* @module autoformat/autoformat | ||
*/ | ||
|
||
import { Plugin } from 'ckeditor5/src/core'; | ||
import { Delete } from 'ckeditor5/src/typing'; | ||
|
||
import blockAutoformatEditing from './blockautoformatediting'; | ||
import inlineAutoformatEditing from './inlineautoformatediting'; | ||
|
||
/** | ||
* Enables a set of predefined autoformatting actions. | ||
* | ||
* For a detailed overview, check the {@glink features/autoformat Autoformatting feature documentation} | ||
* and the {@glink api/autoformat package page}. | ||
* | ||
* @extends module:core/plugin~Plugin | ||
*/ | ||
export default class Autoformat extends Plugin { | ||
/** | ||
* @inheritdoc | ||
*/ | ||
static get requires() { | ||
return [ Delete ]; | ||
} | ||
|
||
/** | ||
* @inheritDoc | ||
*/ | ||
static get pluginName() { | ||
return 'Autoformat'; | ||
} | ||
|
||
/** | ||
* @inheritDoc | ||
*/ | ||
afterInit() { | ||
this._addListAutoformats(); | ||
this._addBasicStylesAutoformats(); | ||
this._addHeadingAutoformats(); | ||
this._addBlockQuoteAutoformats(); | ||
this._addCodeBlockAutoformats(); | ||
this._addHorizontalLineAutoformats(); | ||
} | ||
|
||
/** | ||
* Adds autoformatting related to the {@link module:list/list~List}. | ||
* | ||
* When typed: | ||
* - `* ` or `- ` – A paragraph will be changed to a bulleted list. | ||
* - `1. ` or `1) ` – A paragraph will be changed to a numbered list ("1" can be any digit or a list of digits). | ||
* - `[] ` or `[ ] ` – A paragraph will be changed to a to-do list. | ||
* - `[x] ` or `[ x ] ` – A paragraph will be changed to a checked to-do list. | ||
* | ||
* @private | ||
*/ | ||
_addListAutoformats() { | ||
const commands = this.editor.commands; | ||
|
||
if ( commands.get( 'bulletedList' ) ) { | ||
blockAutoformatEditing( this.editor, this, /^[*-]\s$/, 'bulletedList' ); | ||
} | ||
|
||
if ( commands.get( 'numberedList' ) ) { | ||
blockAutoformatEditing( this.editor, this, /^1[.|)]\s$/, 'numberedList' ); | ||
} | ||
|
||
if ( commands.get( 'todoList' ) ) { | ||
blockAutoformatEditing( this.editor, this, /^\[\s?\]\s$/, 'todoList' ); | ||
} | ||
|
||
if ( commands.get( 'checkTodoList' ) ) { | ||
blockAutoformatEditing( this.editor, this, /^\[\s?x\s?\]\s$/, () => { | ||
this.editor.execute( 'todoList' ); | ||
this.editor.execute( 'checkTodoList' ); | ||
} ); | ||
} | ||
} | ||
|
||
/** | ||
* Adds autoformatting related to the {@link module:basic-styles/bold~Bold}, | ||
* {@link module:basic-styles/italic~Italic}, {@link module:basic-styles/code~Code} | ||
* and {@link module:basic-styles/strikethrough~Strikethrough} | ||
* | ||
* When typed: | ||
* - `**foobar**` – `**` characters are removed and `foobar` is set to bold, | ||
* - `__foobar__` – `__` characters are removed and `foobar` is set to bold, | ||
* - `*foobar*` – `*` characters are removed and `foobar` is set to italic, | ||
* - `_foobar_` – `_` characters are removed and `foobar` is set to italic, | ||
* - ``` `foobar` – ``` ` ``` characters are removed and `foobar` is set to code, | ||
* - `~~foobar~~` – `~~` characters are removed and `foobar` is set to strikethrough. | ||
* | ||
* @private | ||
*/ | ||
_addBasicStylesAutoformats() { | ||
const commands = this.editor.commands; | ||
|
||
if ( commands.get( 'bold' ) ) { | ||
const boldCallback = getCallbackFunctionForInlineAutoformat( this.editor, 'bold' ); | ||
|
||
inlineAutoformatEditing( this.editor, this, /(?:^|\s)(\*\*)([^*]+)(\*\*)$/g, boldCallback ); | ||
inlineAutoformatEditing( this.editor, this, /(?:^|\s)(__)([^_]+)(__)$/g, boldCallback ); | ||
} | ||
|
||
if ( commands.get( 'italic' ) ) { | ||
const italicCallback = getCallbackFunctionForInlineAutoformat( this.editor, 'italic' ); | ||
|
||
// The italic autoformatter cannot be triggered by the bold markers, so we need to check the | ||
// text before the pattern (e.g. `(?:^|[^\*])`). | ||
inlineAutoformatEditing( this.editor, this, /(?:^|\s)(\*)([^*_]+)(\*)$/g, italicCallback ); | ||
inlineAutoformatEditing( this.editor, this, /(?:^|\s)(_)([^_]+)(_)$/g, italicCallback ); | ||
} | ||
|
||
if ( commands.get( 'code' ) ) { | ||
const codeCallback = getCallbackFunctionForInlineAutoformat( this.editor, 'code' ); | ||
|
||
inlineAutoformatEditing( this.editor, this, /(`)([^`]+)(`)$/g, codeCallback ); | ||
} | ||
|
||
if ( commands.get( 'strikethrough' ) ) { | ||
const strikethroughCallback = getCallbackFunctionForInlineAutoformat( this.editor, 'strikethrough' ); | ||
|
||
inlineAutoformatEditing( this.editor, this, /(~~)([^~]+)(~~)$/g, strikethroughCallback ); | ||
} | ||
} | ||
|
||
/** | ||
* Adds autoformatting related to {@link module:heading/heading~Heading}. | ||
* | ||
* It is using a number at the end of the command name to associate it with the proper trigger: | ||
* | ||
* * `heading` with value `heading1` will be executed when typing `#`, | ||
* * `heading` with value `heading2` will be executed when typing `##`, | ||
* * ... up to `heading6` and `######`. | ||
* | ||
* @private | ||
*/ | ||
_addHeadingAutoformats() { | ||
const command = this.editor.commands.get( 'heading' ); | ||
|
||
if ( command ) { | ||
command.modelElements | ||
.filter( name => name.match( /^heading[1-6]$/ ) ) | ||
.forEach( modelName => { | ||
const level = modelName[ 7 ]; | ||
const pattern = new RegExp( `^(#{${ level }})\\s$` ); | ||
|
||
blockAutoformatEditing( this.editor, this, pattern, () => { | ||
// Should only be active if command is enabled and heading style associated with pattern is inactive. | ||
if ( !command.isEnabled || command.value === modelName ) { | ||
return false; | ||
} | ||
|
||
this.editor.execute( 'heading', { value: modelName } ); | ||
} ); | ||
} ); | ||
} | ||
} | ||
|
||
/** | ||
* Adds autoformatting related to {@link module:block-quote/blockquote~BlockQuote}. | ||
* | ||
* When typed: | ||
* * `> ` – A paragraph will be changed to a block quote. | ||
* | ||
* @private | ||
*/ | ||
_addBlockQuoteAutoformats() { | ||
if ( this.editor.commands.get( 'blockQuote' ) ) { | ||
blockAutoformatEditing( this.editor, this, /^>\s$/, 'blockQuote' ); | ||
} | ||
} | ||
|
||
/** | ||
* Adds autoformatting related to {@link module:code-block/codeblock~CodeBlock}. | ||
* | ||
* When typed: | ||
* - `` ``` `` – A paragraph will be changed to a code block. | ||
* | ||
* @private | ||
*/ | ||
_addCodeBlockAutoformats() { | ||
const editor = this.editor; | ||
const selection = editor.model.document.selection; | ||
|
||
if ( editor.commands.get( 'codeBlock' ) ) { | ||
blockAutoformatEditing( editor, this, /^```$/, () => { | ||
if ( selection.getFirstPosition().parent.is( 'element', 'listItem' ) ) { | ||
return false; | ||
} | ||
this.editor.execute( 'codeBlock', { | ||
usePreviousLanguageChoice: true | ||
} ); | ||
} ); | ||
} | ||
} | ||
|
||
/** | ||
* Adds autoformatting related to {@link module:horizontal-line/horizontalline~HorizontalLine}. | ||
* | ||
* When typed: | ||
* - `` --- `` – Will be replaced with a horizontal line. | ||
* | ||
* @private | ||
*/ | ||
_addHorizontalLineAutoformats() { | ||
if ( this.editor.commands.get( 'horizontalLine' ) ) { | ||
blockAutoformatEditing( this.editor, this, /^---$/, 'horizontalLine' ); | ||
} | ||
} | ||
} | ||
|
||
// Helper function for getting `inlineAutoformatEditing` callbacks that checks if command is enabled. | ||
// | ||
// @param {module:core/editor/editor~Editor} editor | ||
// @param {String} attributeKey | ||
// @returns {Function} | ||
function getCallbackFunctionForInlineAutoformat( editor, attributeKey ) { | ||
return ( writer, rangesToFormat ) => { | ||
const command = editor.commands.get( attributeKey ); | ||
|
||
if ( !command.isEnabled ) { | ||
return false; | ||
} | ||
|
||
const validRanges = editor.model.schema.getValidRanges( rangesToFormat, attributeKey ); | ||
|
||
for ( const range of validRanges ) { | ||
writer.setAttribute( attributeKey, true, range ); | ||
} | ||
|
||
// After applying attribute to the text, remove given attribute from the selection. | ||
// This way user is able to type a text without attribute used by auto formatter. | ||
writer.removeSelectionAttribute( attributeKey ); | ||
}; | ||
} |
157 changes: 157 additions & 0 deletions
157
packages/ckeditor5-autoformat/_src/blockautoformatediting.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,157 @@ | ||
/** | ||
* @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. | ||
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license | ||
*/ | ||
|
||
import { LiveRange } from 'ckeditor5/src/engine'; | ||
import { first } from 'ckeditor5/src/utils'; | ||
|
||
/** | ||
* The block autoformatting engine. It allows to format various block patterns. For example, | ||
* it can be configured to turn a paragraph starting with `*` and followed by a space into a list item. | ||
* | ||
* The autoformatting operation is integrated with the undo manager, | ||
* so the autoformatting step can be undone if the user's intention was not to format the text. | ||
* | ||
* See the {@link module:autoformat/blockautoformatediting~blockAutoformatEditing `blockAutoformatEditing`} documentation | ||
* to learn how to create custom block autoformatters. You can also use | ||
* the {@link module:autoformat/autoformat~Autoformat} feature which enables a set of default autoformatters | ||
* (lists, headings, bold and italic). | ||
* | ||
* @module autoformat/blockautoformatediting | ||
*/ | ||
|
||
/** | ||
* Creates a listener triggered on {@link module:engine/model/document~Document#event:change:data `change:data`} event in the document. | ||
* Calls the callback when inserted text matches the regular expression or the command name | ||
* if provided instead of the callback. | ||
* | ||
* Examples of usage: | ||
* | ||
* To convert a paragraph to heading 1 when `- ` is typed, using just the command name: | ||
* | ||
* blockAutoformatEditing( editor, plugin, /^\- $/, 'heading1' ); | ||
* | ||
* To convert a paragraph to heading 1 when `- ` is typed, using just the callback: | ||
* | ||
* blockAutoformatEditing( editor, plugin, /^\- $/, ( context ) => { | ||
* const { match } = context; | ||
* const headingLevel = match[ 1 ].length; | ||
* | ||
* editor.execute( 'heading', { | ||
* formatId: `heading${ headingLevel }` | ||
* } ); | ||
* } ); | ||
* | ||
* @param {module:core/editor/editor~Editor} editor The editor instance. | ||
* @param {module:autoformat/autoformat~Autoformat} plugin The autoformat plugin instance. | ||
* @param {RegExp} pattern The regular expression to execute on just inserted text. The regular expression is tested against the text | ||
* from the beginning until the caret position. | ||
* @param {Function|String} callbackOrCommand The callback to execute or the command to run when the text is matched. | ||
* In case of providing the callback, it receives the following parameter: | ||
* * {Object} match RegExp.exec() result of matching the pattern to inserted text. | ||
*/ | ||
export default function blockAutoformatEditing( editor, plugin, pattern, callbackOrCommand ) { | ||
let callback; | ||
let command = null; | ||
|
||
if ( typeof callbackOrCommand == 'function' ) { | ||
callback = callbackOrCommand; | ||
} else { | ||
// We assume that the actual command name was provided. | ||
command = editor.commands.get( callbackOrCommand ); | ||
|
||
callback = () => { | ||
editor.execute( callbackOrCommand ); | ||
}; | ||
} | ||
|
||
editor.model.document.on( 'change:data', ( evt, batch ) => { | ||
if ( command && !command.isEnabled || !plugin.isEnabled ) { | ||
return; | ||
} | ||
|
||
const range = first( editor.model.document.selection.getRanges() ); | ||
|
||
if ( !range.isCollapsed ) { | ||
return; | ||
} | ||
|
||
if ( batch.isUndo || !batch.isLocal ) { | ||
return; | ||
} | ||
|
||
const changes = Array.from( editor.model.document.differ.getChanges() ); | ||
const entry = changes[ 0 ]; | ||
|
||
// Typing is represented by only a single change. | ||
if ( changes.length != 1 || entry.type !== 'insert' || entry.name != '$text' || entry.length != 1 ) { | ||
return; | ||
} | ||
|
||
const blockToFormat = entry.position.parent; | ||
|
||
// Block formatting should be disabled in codeBlocks (#5800). | ||
if ( blockToFormat.is( 'element', 'codeBlock' ) ) { | ||
return; | ||
} | ||
|
||
// Only list commands and custom callbacks can be applied inside a list. | ||
if ( blockToFormat.is( 'element', 'listItem' ) && | ||
typeof callbackOrCommand !== 'function' && | ||
![ 'numberedList', 'bulletedList', 'todoList' ].includes( callbackOrCommand ) | ||
) { | ||
return; | ||
} | ||
|
||
// In case a command is bound, do not re-execute it over an existing block style which would result with a style removal. | ||
// Instead just drop processing so that autoformat trigger text is not lost. E.g. writing "# " in a level 1 heading. | ||
if ( command && command.value === true ) { | ||
return; | ||
} | ||
|
||
const firstNode = blockToFormat.getChild( 0 ); | ||
const firstNodeRange = editor.model.createRangeOn( firstNode ); | ||
|
||
// Range is only expected to be within or at the very end of the first text node. | ||
if ( !firstNodeRange.containsRange( range ) && !range.end.isEqual( firstNodeRange.end ) ) { | ||
return; | ||
} | ||
|
||
const match = pattern.exec( firstNode.data.substr( 0, range.end.offset ) ); | ||
|
||
// ...and this text node's data match the pattern. | ||
if ( !match ) { | ||
return; | ||
} | ||
|
||
// Use enqueueChange to create new batch to separate typing batch from the auto-format changes. | ||
editor.model.enqueueChange( writer => { | ||
// Matched range. | ||
const start = writer.createPositionAt( blockToFormat, 0 ); | ||
const end = writer.createPositionAt( blockToFormat, match[ 0 ].length ); | ||
const range = new LiveRange( start, end ); | ||
|
||
const wasChanged = callback( { match } ); | ||
|
||
// Remove matched text. | ||
if ( wasChanged !== false ) { | ||
writer.remove( range ); | ||
|
||
const selectionRange = editor.model.document.selection.getFirstRange(); | ||
const blockRange = writer.createRangeIn( blockToFormat ); | ||
|
||
// If the block is empty and the document selection has been moved when | ||
// applying formatting (e.g. is now in newly created block). | ||
if ( blockToFormat.isEmpty && !blockRange.isEqual( selectionRange ) && !blockRange.containsRange( selectionRange, true ) ) { | ||
writer.remove( blockToFormat ); | ||
} | ||
} | ||
range.detach(); | ||
|
||
editor.model.enqueueChange( () => { | ||
editor.plugins.get( 'Delete' ).requestUndoOnBackspace(); | ||
} ); | ||
} ); | ||
} ); | ||
} |
Oops, something went wrong.