diff --git a/packages/ckeditor5-block-quote/_src/blockquote.js b/packages/ckeditor5-block-quote/_src/blockquote.js new file mode 100644 index 00000000000..517603d53a5 --- /dev/null +++ b/packages/ckeditor5-block-quote/_src/blockquote.js @@ -0,0 +1,39 @@ +/** + * @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 block-quote/blockquote + */ + +import { Plugin } from 'ckeditor5/src/core'; + +import BlockQuoteEditing from './blockquoteediting'; +import BlockQuoteUI from './blockquoteui'; + +/** + * The block quote plugin. + * + * For more information about this feature check the {@glink api/block-quote package page}. + * + * This is a "glue" plugin which loads the {@link module:block-quote/blockquoteediting~BlockQuoteEditing block quote editing feature} + * and {@link module:block-quote/blockquoteui~BlockQuoteUI block quote UI feature}. + * + * @extends module:core/plugin~Plugin + */ +export default class BlockQuote extends Plugin { + /** + * @inheritDoc + */ + static get requires() { + return [ BlockQuoteEditing, BlockQuoteUI ]; + } + + /** + * @inheritDoc + */ + static get pluginName() { + return 'BlockQuote'; + } +} diff --git a/packages/ckeditor5-block-quote/_src/blockquotecommand.js b/packages/ckeditor5-block-quote/_src/blockquotecommand.js new file mode 100644 index 00000000000..e5ca1ce18d3 --- /dev/null +++ b/packages/ckeditor5-block-quote/_src/blockquotecommand.js @@ -0,0 +1,232 @@ +/** + * @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 block-quote/blockquotecommand + */ + +import { Command } from 'ckeditor5/src/core'; +import { first } from 'ckeditor5/src/utils'; + +/** + * The block quote command plugin. + * + * @extends module:core/command~Command + */ +export default class BlockQuoteCommand extends Command { + /** + * Whether the selection starts in a block quote. + * + * @observable + * @readonly + * @member {Boolean} #value + */ + + /** + * @inheritDoc + */ + refresh() { + this.value = this._getValue(); + this.isEnabled = this._checkEnabled(); + } + + /** + * Executes the command. When the command {@link #value is on}, all top-most block quotes within + * the selection will be removed. If it is off, all selected blocks will be wrapped with + * a block quote. + * + * @fires execute + * @param {Object} [options] Command options. + * @param {Boolean} [options.forceValue] If set, it will force the command behavior. If `true`, the command will apply a block quote, + * otherwise the command will remove the block quote. If not set, the command will act basing on its current value. + */ + execute( options = {} ) { + const model = this.editor.model; + const schema = model.schema; + const selection = model.document.selection; + + const blocks = Array.from( selection.getSelectedBlocks() ); + + const value = ( options.forceValue === undefined ) ? !this.value : options.forceValue; + + model.change( writer => { + if ( !value ) { + this._removeQuote( writer, blocks.filter( findQuote ) ); + } else { + const blocksToQuote = blocks.filter( block => { + // Already quoted blocks needs to be considered while quoting too + // in order to reuse their elements. + return findQuote( block ) || checkCanBeQuoted( schema, block ); + } ); + + this._applyQuote( writer, blocksToQuote ); + } + } ); + } + + /** + * Checks the command's {@link #value}. + * + * @private + * @returns {Boolean} The current value. + */ + _getValue() { + const selection = this.editor.model.document.selection; + + const firstBlock = first( selection.getSelectedBlocks() ); + + // In the current implementation, the block quote must be an immediate parent of a block element. + return !!( firstBlock && findQuote( firstBlock ) ); + } + + /** + * Checks whether the command can be enabled in the current context. + * + * @private + * @returns {Boolean} Whether the command should be enabled. + */ + _checkEnabled() { + if ( this.value ) { + return true; + } + + const selection = this.editor.model.document.selection; + const schema = this.editor.model.schema; + + const firstBlock = first( selection.getSelectedBlocks() ); + + if ( !firstBlock ) { + return false; + } + + return checkCanBeQuoted( schema, firstBlock ); + } + + /** + * Removes the quote from given blocks. + * + * If blocks which are supposed to be "unquoted" are in the middle of a quote, + * start it or end it, then the quote will be split (if needed) and the blocks + * will be moved out of it, so other quoted blocks remained quoted. + * + * @private + * @param {module:engine/model/writer~Writer} writer + * @param {Array.} blocks + */ + _removeQuote( writer, blocks ) { + // Unquote all groups of block. Iterate in the reverse order to not break following ranges. + getRangesOfBlockGroups( writer, blocks ).reverse().forEach( groupRange => { + if ( groupRange.start.isAtStart && groupRange.end.isAtEnd ) { + writer.unwrap( groupRange.start.parent ); + + return; + } + + // The group of blocks are at the beginning of an so let's move them left (out of the ). + if ( groupRange.start.isAtStart ) { + const positionBefore = writer.createPositionBefore( groupRange.start.parent ); + + writer.move( groupRange, positionBefore ); + + return; + } + + // The blocks are in the middle of an so we need to split the after the last block + // so we move the items there. + if ( !groupRange.end.isAtEnd ) { + writer.split( groupRange.end ); + } + + // Now we are sure that groupRange.end.isAtEnd is true, so let's move the blocks right. + + const positionAfter = writer.createPositionAfter( groupRange.end.parent ); + + writer.move( groupRange, positionAfter ); + } ); + } + + /** + * Applies the quote to given blocks. + * + * @private + * @param {module:engine/model/writer~Writer} writer + * @param {Array.} blocks + */ + _applyQuote( writer, blocks ) { + const quotesToMerge = []; + + // Quote all groups of block. Iterate in the reverse order to not break following ranges. + getRangesOfBlockGroups( writer, blocks ).reverse().forEach( groupRange => { + let quote = findQuote( groupRange.start ); + + if ( !quote ) { + quote = writer.createElement( 'blockQuote' ); + + writer.wrap( groupRange, quote ); + } + + quotesToMerge.push( quote ); + } ); + + // Merge subsequent elements. Reverse the order again because this time we want to go through + // the elements in the source order (due to how merge works – it moves the right element's content + // to the first element and removes the right one. Since we may need to merge a couple of subsequent `` elements + // we want to keep the reference to the first (furthest left) one. + quotesToMerge.reverse().reduce( ( currentQuote, nextQuote ) => { + if ( currentQuote.nextSibling == nextQuote ) { + writer.merge( writer.createPositionAfter( currentQuote ) ); + + return currentQuote; + } + + return nextQuote; + } ); + } +} + +function findQuote( elementOrPosition ) { + return elementOrPosition.parent.name == 'blockQuote' ? elementOrPosition.parent : null; +} + +// Returns a minimal array of ranges containing groups of subsequent blocks. +// +// content: abcdefgh +// blocks: [ a, b, d, f, g, h ] +// output ranges: [ab]c[d]e[fgh] +// +// @param {Array.} blocks +// @returns {Array.} +function getRangesOfBlockGroups( writer, blocks ) { + let startPosition; + let i = 0; + const ranges = []; + + while ( i < blocks.length ) { + const block = blocks[ i ]; + const nextBlock = blocks[ i + 1 ]; + + if ( !startPosition ) { + startPosition = writer.createPositionBefore( block ); + } + + if ( !nextBlock || block.nextSibling != nextBlock ) { + ranges.push( writer.createRange( startPosition, writer.createPositionAfter( block ) ) ); + startPosition = null; + } + + i++; + } + + return ranges; +} + +// Checks whether can wrap the block. +function checkCanBeQuoted( schema, block ) { + // TMP will be replaced with schema.checkWrap(). + const isBQAllowed = schema.checkChild( block.parent, 'blockQuote' ); + const isBlockAllowedInBQ = schema.checkChild( [ '$root', 'blockQuote' ], block ); + + return isBQAllowed && isBlockAllowedInBQ; +} diff --git a/packages/ckeditor5-block-quote/_src/blockquoteediting.js b/packages/ckeditor5-block-quote/_src/blockquoteediting.js new file mode 100644 index 00000000000..dd5ff54b44a --- /dev/null +++ b/packages/ckeditor5-block-quote/_src/blockquoteediting.js @@ -0,0 +1,146 @@ +/** + * @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 block-quote/blockquoteediting + */ + +import { Plugin } from 'ckeditor5/src/core'; +import { Enter } from 'ckeditor5/src/enter'; +import { Delete } from 'ckeditor5/src/typing'; + +import BlockQuoteCommand from './blockquotecommand'; + +/** + * The block quote editing. + * + * Introduces the `'blockQuote'` command and the `'blockQuote'` model element. + * + * @extends module:core/plugin~Plugin + */ +export default class BlockQuoteEditing extends Plugin { + /** + * @inheritDoc + */ + static get pluginName() { + return 'BlockQuoteEditing'; + } + + /** + * @inheritDoc + */ + static get requires() { + return [ Enter, Delete ]; + } + + /** + * @inheritDoc + */ + init() { + const editor = this.editor; + const schema = editor.model.schema; + + editor.commands.add( 'blockQuote', new BlockQuoteCommand( editor ) ); + + schema.register( 'blockQuote', { + inheritAllFrom: '$container' + } ); + + editor.conversion.elementToElement( { model: 'blockQuote', view: 'blockquote' } ); + + // Postfixer which cleans incorrect model states connected with block quotes. + editor.model.document.registerPostFixer( writer => { + const changes = editor.model.document.differ.getChanges(); + + for ( const entry of changes ) { + if ( entry.type == 'insert' ) { + const element = entry.position.nodeAfter; + + if ( !element ) { + // We are inside a text node. + continue; + } + + if ( element.is( 'element', 'blockQuote' ) && element.isEmpty ) { + // Added an empty blockQuote - remove it. + writer.remove( element ); + + return true; + } else if ( element.is( 'element', 'blockQuote' ) && !schema.checkChild( entry.position, element ) ) { + // Added a blockQuote in incorrect place. Unwrap it so the content inside is not lost. + writer.unwrap( element ); + + return true; + } else if ( element.is( 'element' ) ) { + // Just added an element. Check that all children meet the scheme rules. + const range = writer.createRangeIn( element ); + + for ( const child of range.getItems() ) { + if ( + child.is( 'element', 'blockQuote' ) && + !schema.checkChild( writer.createPositionBefore( child ), child ) + ) { + writer.unwrap( child ); + + return true; + } + } + } + } else if ( entry.type == 'remove' ) { + const parent = entry.position.parent; + + if ( parent.is( 'element', 'blockQuote' ) && parent.isEmpty ) { + // Something got removed and now blockQuote is empty. Remove the blockQuote as well. + writer.remove( parent ); + + return true; + } + } + } + + return false; + } ); + + const viewDocument = this.editor.editing.view.document; + const selection = editor.model.document.selection; + const blockQuoteCommand = editor.commands.get( 'blockQuote' ); + + // Overwrite default Enter key behavior. + // If Enter key is pressed with selection collapsed in empty block inside a quote, break the quote. + this.listenTo( viewDocument, 'enter', ( evt, data ) => { + if ( !selection.isCollapsed || !blockQuoteCommand.value ) { + return; + } + + const positionParent = selection.getLastPosition().parent; + + if ( positionParent.isEmpty ) { + editor.execute( 'blockQuote' ); + editor.editing.view.scrollToTheSelection(); + + data.preventDefault(); + evt.stop(); + } + }, { context: 'blockquote' } ); + + // Overwrite default Backspace key behavior. + // If Backspace key is pressed with selection collapsed in first empty block inside a quote, break the quote. + this.listenTo( viewDocument, 'delete', ( evt, data ) => { + if ( data.direction != 'backward' || !selection.isCollapsed || !blockQuoteCommand.value ) { + return; + } + + const positionParent = selection.getLastPosition().parent; + + if ( positionParent.isEmpty && !positionParent.previousSibling ) { + editor.execute( 'blockQuote' ); + editor.editing.view.scrollToTheSelection(); + + data.preventDefault(); + evt.stop(); + } + }, { context: 'blockquote' } ); + } +} diff --git a/packages/ckeditor5-block-quote/_src/blockquoteui.js b/packages/ckeditor5-block-quote/_src/blockquoteui.js new file mode 100644 index 00000000000..ae68301b515 --- /dev/null +++ b/packages/ckeditor5-block-quote/_src/blockquoteui.js @@ -0,0 +1,60 @@ +/** + * @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 block-quote/blockquoteui + */ + +import { Plugin, icons } from 'ckeditor5/src/core'; +import { ButtonView } from 'ckeditor5/src/ui'; + +import '../theme/blockquote.css'; + +/** + * The block quote UI plugin. + * + * It introduces the `'blockQuote'` button. + * + * @extends module:core/plugin~Plugin + */ +export default class BlockQuoteUI extends Plugin { + /** + * @inheritDoc + */ + static get pluginName() { + return 'BlockQuoteUI'; + } + + /** + * @inheritDoc + */ + init() { + const editor = this.editor; + const t = editor.t; + + editor.ui.componentFactory.add( 'blockQuote', locale => { + const command = editor.commands.get( 'blockQuote' ); + const buttonView = new ButtonView( locale ); + + buttonView.set( { + label: t( 'Block quote' ), + icon: icons.quote, + tooltip: true, + isToggleable: true + } ); + + // Bind button model to command. + buttonView.bind( 'isOn', 'isEnabled' ).to( command, 'value', 'isEnabled' ); + + // Execute command. + this.listenTo( buttonView, 'execute', () => { + editor.execute( 'blockQuote' ); + editor.editing.view.focus(); + } ); + + return buttonView; + } ); + } +} diff --git a/packages/ckeditor5-block-quote/_src/index.js b/packages/ckeditor5-block-quote/_src/index.js new file mode 100644 index 00000000000..ef0b37e303e --- /dev/null +++ b/packages/ckeditor5-block-quote/_src/index.js @@ -0,0 +1,12 @@ +/** + * @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 block-quote + */ + +export { default as BlockQuote } from './blockquote'; +export { default as BlockQuoteEditing } from './blockquoteediting'; +export { default as BlockQuoteUI } from './blockquoteui';