Skip to content

Commit

Permalink
Added ckeditor5-autoformat/_src.
Browse files Browse the repository at this point in the history
  • Loading branch information
arkflpc committed Jan 17, 2023
1 parent 8c61c69 commit de1c75c
Show file tree
Hide file tree
Showing 4 changed files with 626 additions and 0 deletions.
241 changes: 241 additions & 0 deletions packages/ckeditor5-autoformat/_src/autoformat.js
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 packages/ckeditor5-autoformat/_src/blockautoformatediting.js
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();
} );
} );
} );
}
Loading

0 comments on commit de1c75c

Please sign in to comment.