diff --git a/.gitignore b/.gitignore index 7de95735d97..11b1889e23a 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ yalc.lock # Ignore compiled TypeScript files. packages/ckeditor5-alignment/src/**/*.js packages/ckeditor5-basic-styles/src/**/*.js +packages/ckeditor5-block-quote/src/**/*.js packages/ckeditor5-clipboard/src/**/*.js packages/ckeditor5-code-block/src/**/*.js packages/ckeditor5-core/src/**/*.js diff --git a/packages/ckeditor5-block-quote/src/blockquote.js b/packages/ckeditor5-block-quote/_src/blockquote.js similarity index 100% rename from packages/ckeditor5-block-quote/src/blockquote.js rename to packages/ckeditor5-block-quote/_src/blockquote.js diff --git a/packages/ckeditor5-block-quote/src/blockquotecommand.js b/packages/ckeditor5-block-quote/_src/blockquotecommand.js similarity index 100% rename from packages/ckeditor5-block-quote/src/blockquotecommand.js rename to packages/ckeditor5-block-quote/_src/blockquotecommand.js diff --git a/packages/ckeditor5-block-quote/src/blockquoteediting.js b/packages/ckeditor5-block-quote/_src/blockquoteediting.js similarity index 100% rename from packages/ckeditor5-block-quote/src/blockquoteediting.js rename to packages/ckeditor5-block-quote/_src/blockquoteediting.js diff --git a/packages/ckeditor5-block-quote/src/blockquoteui.js b/packages/ckeditor5-block-quote/_src/blockquoteui.js similarity index 100% rename from packages/ckeditor5-block-quote/src/blockquoteui.js rename to packages/ckeditor5-block-quote/_src/blockquoteui.js diff --git a/packages/ckeditor5-block-quote/src/index.js b/packages/ckeditor5-block-quote/_src/index.js similarity index 100% rename from packages/ckeditor5-block-quote/src/index.js rename to packages/ckeditor5-block-quote/_src/index.js diff --git a/packages/ckeditor5-block-quote/package.json b/packages/ckeditor5-block-quote/package.json index 6fa91541ee2..076a1f54810 100644 --- a/packages/ckeditor5-block-quote/package.json +++ b/packages/ckeditor5-block-quote/package.json @@ -10,7 +10,7 @@ "ckeditor5-plugin", "ckeditor5-dll" ], - "main": "src/index.js", + "main": "src/index.ts", "dependencies": { "ckeditor5": "^35.4.0" }, @@ -28,6 +28,7 @@ "@ckeditor/ckeditor5-table": "^35.4.0", "@ckeditor/ckeditor5-theme-lark": "^35.4.0", "@ckeditor/ckeditor5-typing": "^35.4.0", + "typescript": "^4.8.4", "webpack": "^5.58.1", "webpack-cli": "^4.9.0" }, @@ -46,13 +47,16 @@ }, "files": [ "lang", - "src", + "src/**/*.js", + "src/**/*.d.ts", "theme", "build", "ckeditor5-metadata.json", "CHANGELOG.md" ], "scripts": { - "dll:build": "webpack" + "dll:build": "webpack", + "build": "tsc -p ./tsconfig.release.json", + "postversion": "npm run build" } } diff --git a/packages/ckeditor5-block-quote/src/blockquote.ts b/packages/ckeditor5-block-quote/src/blockquote.ts new file mode 100644 index 00000000000..7bf6c25a61a --- /dev/null +++ b/packages/ckeditor5-block-quote/src/blockquote.ts @@ -0,0 +1,45 @@ +/** + * @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, type PluginDependencies } 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 + */ + public static get requires(): PluginDependencies { + return [ BlockQuoteEditing, BlockQuoteUI ]; + } + + /** + * @inheritDoc + */ + public static get pluginName(): 'BlockQuote' { + return 'BlockQuote'; + } +} + +declare module '@ckeditor/ckeditor5-core' { + interface PluginsMap { + [ BlockQuote.pluginName ]: BlockQuote; + } +} diff --git a/packages/ckeditor5-block-quote/src/blockquotecommand.ts b/packages/ckeditor5-block-quote/src/blockquotecommand.ts new file mode 100644 index 00000000000..7c06c89a7aa --- /dev/null +++ b/packages/ckeditor5-block-quote/src/blockquotecommand.ts @@ -0,0 +1,222 @@ +/** + * @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'; +import type { DocumentFragment, Element, Position, Range, Schema, Writer } from 'ckeditor5/src/engine'; + +/** + * 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 + */ + declare public value: boolean; + + /** + * @inheritDoc + */ + public override refresh(): void { + 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 options Command options. + * @param 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. + */ + public override execute( options: { forceValue?: boolean } = {} ): void { + 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 _getValue(): boolean { + 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. + * + * @returns Whether the command should be enabled. + */ + private _checkEnabled(): boolean { + 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 _removeQuote( writer: Writer, blocks: Array ): void { + // 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 as Element ); + + 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 as Element ); + + 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 as Element ); + + writer.move( groupRange, positionAfter ); + } ); + } + + /** + * Applies the quote to given blocks. + */ + private _applyQuote( writer: Writer, blocks: Array ): void { + const quotesToMerge: Array = []; + + // 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: Element | Position ): Element | DocumentFragment | null { + 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] + */ +function getRangesOfBlockGroups( writer: Writer, blocks: Array ): Array { + 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: Schema, block: Element ): boolean { + // TMP will be replaced with schema.checkWrap(). + const isBQAllowed = schema.checkChild( block.parent as Element, 'blockQuote' ); + const isBlockAllowedInBQ = schema.checkChild( [ '$root', 'blockQuote' ], block ); + + return isBQAllowed && isBlockAllowedInBQ; +} diff --git a/packages/ckeditor5-block-quote/src/blockquoteediting.ts b/packages/ckeditor5-block-quote/src/blockquoteediting.ts new file mode 100644 index 00000000000..7561a354d88 --- /dev/null +++ b/packages/ckeditor5-block-quote/src/blockquoteediting.ts @@ -0,0 +1,156 @@ +/** + * @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, type PluginDependencies } from 'ckeditor5/src/core'; +import { Enter, type ViewDocumentEnterEvent } from 'ckeditor5/src/enter'; +import { Delete, type ViewDocumentDeleteEvent } 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 + */ + public static get pluginName(): 'BlockQuoteEditing' { + return 'BlockQuoteEditing'; + } + + /** + * @inheritDoc + */ + public static get requires(): PluginDependencies { + return [ Enter, Delete ]; + } + + /** + * @inheritDoc + */ + public init(): void { + 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' } ); + } +} + +declare module '@ckeditor/ckeditor5-core' { + interface CommandsMap { + blockQuote: BlockQuoteCommand; + } + + interface PluginsMap { + [ BlockQuoteEditing.pluginName ]: BlockQuoteEditing; + } +} diff --git a/packages/ckeditor5-block-quote/src/blockquoteui.ts b/packages/ckeditor5-block-quote/src/blockquoteui.ts new file mode 100644 index 00000000000..c599ec7922d --- /dev/null +++ b/packages/ckeditor5-block-quote/src/blockquoteui.ts @@ -0,0 +1,66 @@ +/** + * @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 + */ + public static get pluginName(): 'BlockQuoteUI' { + return 'BlockQuoteUI'; + } + + /** + * @inheritDoc + */ + public init(): void { + 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; + } ); + } +} + +declare module '@ckeditor/ckeditor5-core' { + interface PluginsMap { + [ BlockQuoteUI.pluginName ]: BlockQuoteUI; + } +} diff --git a/packages/ckeditor5-block-quote/src/index.ts b/packages/ckeditor5-block-quote/src/index.ts new file mode 100644 index 00000000000..ef0b37e303e --- /dev/null +++ b/packages/ckeditor5-block-quote/src/index.ts @@ -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'; diff --git a/packages/ckeditor5-block-quote/tsconfig.json b/packages/ckeditor5-block-quote/tsconfig.json new file mode 100644 index 00000000000..9d4c891939c --- /dev/null +++ b/packages/ckeditor5-block-quote/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "ckeditor5/tsconfig.json", + "include": [ + "src", + "../../typings" + ] +} diff --git a/packages/ckeditor5-block-quote/tsconfig.release.json b/packages/ckeditor5-block-quote/tsconfig.release.json new file mode 100644 index 00000000000..6d2d43909f9 --- /dev/null +++ b/packages/ckeditor5-block-quote/tsconfig.release.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.release.json", + "include": [ + "./src/", + "../../typings/" + ], + "exclude": [ + "./tests/" + ] +}