/** * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md. */ /** * @module autoformat/inlineautoformatengine */ import LiveRange from '@ckeditor/ckeditor5-engine/src/model/liverange'; import getSchemaValidRanges from '@ckeditor/ckeditor5-core/src/command/helpers/getschemavalidranges'; /** * The inline autoformatting engine. Allows to format various inline patterns. For example, * it can be configured to make "foo" bold when typed `**foo**` (the `**` markers will be removed). * * The autoformatting operation is integrated with the undo manager, * so the autoformatting step can be undone, if the user's intention wasn't to format the text. * * See the constructors documentation to learn how to create custom inline autoformatters. You can also use * the {@link module:autoformat/autoformat~Autoformat} feature which enables a set of default autoformatters * (lists, headings, bold and italic). */ export default class InlineAutoformatEngine { /** * Enables autoformatting mechanism on a given {@link module:core/editor/editor~Editor}. * * It formats the matched text by applying given model attribute or by running the provided formatting callback. * Each time data model changes text from given node (from the beginning of the current node to the collapsed * selection location) will be tested. * * @param {module:core/editor/editor~Editor} editor Editor instance. * @param {Function|RegExp} testRegexpOrCallback RegExp or callback to execute on text. * Provided RegExp *must* have three capture groups. First and third capture groups * should match opening/closing delimiters. Second capture group should match text to format. * * // Matches `**bold text**` pattern. * // There are three capturing groups: * // - first to match starting `**` delimiter, * // - second to match text to format, * // - third to match ending `**` delimiter. * new InlineAutoformatEngine( this.editor, /(\*\*)([^\*]+?)(\*\*)$/g, 'bold' ); * * When function is provided instead of RegExp, it will be executed with text to match as a parameter. Function * should return proper "ranges" to delete and format. * * { * remove: [ * [ 0, 1 ], // Remove first letter from the given text. * [ 5, 6 ] // Remove 6th letter from the given text. * ], * format: [ * [ 1, 5 ] // Format all letters from 2nd to 5th. * ] * } * * @param {Function|String} attributeOrCallback Name of attribute to apply on matching text or callback for manual * formatting. * * // Use attribute name: * new InlineAutoformatEngine( this.editor, /(\*\*)([^\*]+?)(\*\*)$/g, 'bold' ); * * // Use formatting callback: * new InlineAutoformatEngine( this.editor, /(\*\*)([^\*]+?)(\*\*)$/g, ( batch, validRanges ) => { * for ( let range of validRanges ) { * batch.setAttribute( range, command, true ); * } * } ); */ constructor( editor, testRegexpOrCallback, attributeOrCallback ) { this.editor = editor; let regExp; let command; let testCallback; let formatCallback; if ( testRegexpOrCallback instanceof RegExp ) { regExp = testRegexpOrCallback; } else { testCallback = testRegexpOrCallback; } if ( typeof attributeOrCallback == 'string' ) { command = attributeOrCallback; } else { formatCallback = attributeOrCallback; } // A test callback run on changed text. testCallback = testCallback || ( text => { let result; const remove = []; const format = []; while ( ( result = regExp.exec( text ) ) !== null ) { // There should be full match and 3 capture groups. if ( result && result.length < 4 ) { break; } let { index, '1': leftDel, '2': content, '3': rightDel } = result; // Real matched string - there might be some non-capturing groups so we need to recalculate starting index. const found = leftDel + content + rightDel; index += result[ 0 ].length - found.length; // Start and End offsets of delimiters to remove. const delStart = [ index, index + leftDel.length ]; const delEnd = [ index + leftDel.length + content.length, index + leftDel.length + content.length + rightDel.length ]; remove.push( delStart ); remove.push( delEnd ); format.push( [ index + leftDel.length, index + leftDel.length + content.length ] ); } return { remove, format }; } ); // A format callback run on matched text. formatCallback = formatCallback || ( ( batch, validRanges ) => { for ( const range of validRanges ) { batch.setAttribute( range, command, true ); } } ); editor.document.on( 'change', ( evt, type ) => { if ( type !== 'insert' ) { return; } const selection = this.editor.document.selection; if ( !selection.isCollapsed || !selection.focus || !selection.focus.parent ) { return; } const block = selection.focus.parent; const text = getText( block ).slice( 0, selection.focus.offset ); const ranges = testCallback( text ); const rangesToFormat = []; // Apply format before deleting text. ranges.format.forEach( range => { if ( range[ 0 ] === undefined || range[ 1 ] === undefined ) { return; } rangesToFormat.push( LiveRange.createFromParentsAndOffsets( block, range[ 0 ], block, range[ 1 ] ) ); } ); const rangesToRemove = []; // Reverse order to not mix the offsets while removing. ranges.remove.slice().reverse().forEach( range => { if ( range[ 0 ] === undefined || range[ 1 ] === undefined ) { return; } rangesToRemove.push( LiveRange.createFromParentsAndOffsets( block, range[ 0 ], block, range[ 1 ] ) ); } ); if ( !( rangesToFormat.length && rangesToRemove.length ) ) { return; } const batch = editor.document.batch(); editor.document.enqueueChanges( () => { const validRanges = getSchemaValidRanges( command, rangesToFormat, editor.document.schema ); // Apply format. formatCallback( batch, validRanges ); // Remove delimiters. for ( const range of rangesToRemove ) { batch.remove( range ); } } ); } ); } } // Returns whole text from parent element by adding all data from text nodes together. // // @private // @param {module:engine/model/element~Element} element // @returns {String} function getText( element ) { return Array.from( element.getChildren() ).reduce( ( a, b ) => a + b.data, '' ); }