Browse Source

Feature: Introduced the page break feature. Closes ckeditor/ckeditor5#1194.

Marek Lewandowski 6 years ago
parent
commit
b92255c51e

+ 24 - 0
packages/ckeditor5-page-break/docs/_snippets/features/page-break.html

@@ -0,0 +1,24 @@
+<div id="snippet-page-break">
+	<h1>What is Lorem Ipsum?</h1>
+	<p>Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry’s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.</p>
+	<div style="page-break-after: always"><span style="display: none;">&nbsp;</span></div>
+	<p>It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.</p>
+</div>
+
+<button type="button" id="print-data-action">Open print preview</button>
+<iframe class="visuallyhidden" id="print-data-container" aria-hidden="true" tabindex="-1"></iframe>
+
+<style>
+	.visuallyhidden {
+		position: absolute;
+		width: 1px;
+		height: 1px;
+		margin: -1px;
+		border: 0;
+		padding: 0;
+		white-space: nowrap;
+		clip-path: inset(100%);
+		clip: rect(0 0 0 0);
+		overflow: hidden;
+	}
+</style>

+ 82 - 0
packages/ckeditor5-page-break/docs/_snippets/features/page-break.js

@@ -0,0 +1,82 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals window, document, console */
+
+import ClassicEditor from '@ckeditor/ckeditor5-build-classic/src/ckeditor';
+import PageBreak from '@ckeditor/ckeditor5-page-break/src/pagebreak';
+import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';
+
+ClassicEditor.builtinPlugins.push( PageBreak );
+
+ClassicEditor
+	.create( document.querySelector( '#snippet-page-break' ), {
+		toolbar: {
+			items: [
+				'heading',
+				'|',
+				'bold',
+				'italic',
+				'bulletedList',
+				'numberedList',
+				'blockQuote',
+				'link',
+				'|',
+				'imageUpload',
+				'mediaEmbed',
+				'insertTable',
+				'pageBreak',
+				'|',
+				'undo',
+				'redo',
+			],
+			viewportTopOffset: window.getViewportTopOffsetConfig()
+		},
+		image: {
+			styles: [
+				'full',
+				'alignLeft',
+				'alignRight'
+			],
+			toolbar: [
+				'imageStyle:alignLeft',
+				'imageStyle:full',
+				'imageStyle:alignRight',
+				'|',
+				'imageTextAlternative'
+			]
+		},
+		table: {
+			contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells' ]
+		},
+		cloudServices: CS_CONFIG
+	} )
+	.then( editor => {
+		window.editor = editor;
+
+		// The "Print editor data" button logic.
+		document.querySelector( '#print-data-action' ).addEventListener( 'click', () => {
+			const snippetCSSElement = [ ...document.querySelectorAll( 'link' ) ]
+				.find( linkElement => linkElement.href.endsWith( 'snippet.css' ) );
+
+			const iframeElement = document.querySelector( '#print-data-container' );
+
+			iframeElement.srcdoc = '<html>' +
+				'<head>' +
+					`<title>${ document.title }</title>` +
+					`<link rel="stylesheet" href="${ snippetCSSElement.href }" type="text/css">` +
+				'</head>' +
+				'<body class="ck-content">' +
+					editor.getData() +
+					'<script>' +
+						'window.addEventListener( \'DOMContentLoaded\', () => { window.print(); } );' +
+					'</script>' +
+				'</body>' +
+				'</html>';
+		} );
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 34 - 0
packages/ckeditor5-page-break/docs/api/page-break.md

@@ -0,0 +1,34 @@
+---
+category: api-reference
+---
+
+# Page break feature for CKEditor 5
+
+[![npm version](https://badge.fury.io/js/%40ckeditor%2Fckeditor5-page-break.svg)](https://www.npmjs.com/package/@ckeditor/ckeditor5-page-break)
+
+This package implements the page break feature for CKEditor 5.
+
+## Demo
+
+Check out the {@link features/page-break#demo demo in the Page break feature} guide.
+
+## Documentation
+
+See the {@link features/page-break Page break feature} guide and the {@link module:page-break/pagebreak~PageBreak} plugin documentation.
+
+## Installation
+
+```bash
+npm install --save @ckeditor/ckeditor5-page-break
+```
+
+## Contribute
+
+The source code of this package is available on GitHub in https://github.com/ckeditor/ckeditor5-page-break.
+
+## External links
+
+* [`@ckeditor/ckeditor5-page-break` on npm](https://www.npmjs.com/package/@ckeditor/ckeditor5-page-break)
+* [`ckeditor/ckeditor5-page-break` on GitHub](https://github.com/ckeditor/ckeditor5-page-break)
+* [Issue tracker](https://github.com/ckeditor/ckeditor5/issues)
+* [Changelog](https://github.com/ckeditor/ckeditor5-page-break/blob/master/CHANGELOG.md)

+ 55 - 0
packages/ckeditor5-page-break/docs/features/page-break.md

@@ -0,0 +1,55 @@
+---
+category: features
+menu-title: Page break
+---
+
+# Page break
+
+The {@link module:page-break/pagebreak~PageBreak} plugin provides a possibility to insert a page break in the rich-text editor.
+
+## Demo
+
+Use the editor below to see the {@link module:page-break/pagebreak~PageBreak} plugin in action. Click the button below in order to open a print preview window.
+
+{@snippet features/page-break}
+
+## Installation
+
+To add this feature to your rich-text editor, install the [`@ckeditor/ckeditor5-page-break`](https://www.npmjs.com/package/@ckeditor/ckeditor5-page-break) package:
+
+```bash
+npm install --save @ckeditor/ckeditor5-page-break
+```
+
+And add it to your plugin list configuration:
+
+```js
+import PageBreak from '@ckeditor/ckeditor5-page-break/src/pagebreak';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ PageBreak, ... ],
+		toolbar: [ 'pageBreak', ... ],
+	} )
+	.then( ... )
+	.catch( ... );
+```
+
+<info-box info>
+	Read more about {@link builds/guides/integration/installing-plugins installing plugins}.
+</info-box>
+
+## Common API
+
+The {@link module:page-break/pagebreak~PageBreak} plugin registers the UI button component (`'pageBreak'`) and the `'pageBreak'` command implemented by {@link module:page-break/pagebreakcommand~PageBreakCommand}. 
+
+The command can be executed using the {@link module:core/editor/editor~Editor#execute `editor.execute()`} method:
+
+```js
+// Inserts the page break to the selected content.
+editor.execute( 'pageBreak' );
+```
+
+## Contribute
+
+The source code of the feature is available on GitHub in https://github.com/ckeditor/ckeditor5-page-break.

+ 3 - 0
packages/ckeditor5-page-break/lang/contexts.json

@@ -0,0 +1,3 @@
+{
+	"Page break": "Page break"
+}

+ 10 - 0
packages/ckeditor5-page-break/package.json

@@ -10,8 +10,18 @@
     "ckeditor5-plugin"
   ],
   "dependencies": {
+    "@ckeditor/ckeditor5-core": "^12.2.1",
+    "@ckeditor/ckeditor5-ui": "^14.0.0",
+    "@ckeditor/ckeditor5-widget": "^11.0.4"
   },
   "devDependencies": {
+    "@ckeditor/ckeditor5-cloud-services": "^11.0.5",
+    "@ckeditor/ckeditor5-engine": "^14.0.0",
+    "@ckeditor/ckeditor5-editor-classic": "^12.1.3",
+    "@ckeditor/ckeditor5-easy-image": "^11.0.5",
+    "@ckeditor/ckeditor5-image": "^14.0.0",
+    "@ckeditor/ckeditor5-paragraph": "^11.0.5",
+    "@ckeditor/ckeditor5-utils": "^14.0.0",
     "eslint": "^5.5.0",
     "eslint-config-ckeditor5": "^2.0.0",
     "husky": "^1.3.1",

+ 33 - 0
packages/ckeditor5-page-break/src/pagebreak.js

@@ -0,0 +1,33 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module page-break/pagebreak
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import PageBreakEditing from './pagebreakediting';
+import PageBreakUI from './pagebreakui';
+
+/**
+ * The page break plugin provides a possibility to insert a page break in the rich-text editor.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class PageBreak extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ PageBreakEditing, PageBreakUI ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'PageBreak';
+	}
+}

+ 115 - 0
packages/ckeditor5-page-break/src/pagebreakcommand.js

@@ -0,0 +1,115 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module page-break/pagebreakcommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+import { findOptimalInsertionPosition } from '@ckeditor/ckeditor5-widget/src/utils';
+
+/**
+ * The insert a page break command.
+ *
+ * The command is registered by the {@link module:page-break/pagebreakediting~PageBreakEditing} as `'pageBreak'`.
+ *
+ * To insert the page break at the current selection, execute the command:
+ *
+ *		editor.execute( 'pageBreak' );
+ *
+ * @extends module:core/command~Command
+ */
+export default class PageBreakCommand extends Command {
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		this.isEnabled = isPageBreakAllowed( this.editor.model );
+	}
+
+	/**
+	 * Executes the command.
+	 *
+	 * @fires execute
+	 */
+	execute() {
+		const model = this.editor.model;
+
+		model.change( writer => {
+			const pageBreakElement = writer.createElement( 'pageBreak' );
+
+			model.insertContent( pageBreakElement );
+
+			let nextElement = pageBreakElement.nextSibling;
+
+			// Check whether an element next to the inserted page break is defined and can contain a text.
+			const canSetSelection = nextElement && model.schema.checkChild( nextElement, '$text' );
+
+			// If the element is missing, but a paragraph could be inserted next to the page break, let's add it.
+			if ( !canSetSelection && model.schema.checkChild( pageBreakElement.parent, 'paragraph' ) ) {
+				nextElement = writer.createElement( 'paragraph' );
+
+				writer.insert( nextElement, writer.createPositionAfter( pageBreakElement ) );
+			}
+
+			// Put the selection inside the element, at the beginning.
+			if ( nextElement ) {
+				writer.setSelection( nextElement, 0 );
+			}
+		} );
+	}
+}
+
+// Checks if the `pageBreak` element can be inserted at current model selection.
+//
+// @param {module:engine/model/model~Model} model
+// @returns {Boolean}
+function isPageBreakAllowed( model ) {
+	const schema = model.schema;
+	const selection = model.document.selection;
+
+	return isPageBreakAllowedInParent( selection, schema, model ) &&
+		!checkSelectionOnObject( selection, schema );
+}
+
+// Checks if page break is allowed by schema in optimal insertion parent.
+//
+// @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection
+// @param {module:engine/model/schema~Schema} schema
+// @param {module:engine/model/model~Model} model Model instance.
+// @returns {Boolean}
+function isPageBreakAllowedInParent( selection, schema, model ) {
+	const parent = getInsertPageBreakParent( selection, model );
+
+	return schema.checkChild( parent, 'pageBreak' );
+}
+
+// Check if selection is on object.
+//
+// @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection
+// @param {module:engine/model/schema~Schema} schema
+// @returns {Boolean}
+function checkSelectionOnObject( selection, schema ) {
+	const selectedElement = selection.getSelectedElement();
+
+	return selectedElement && schema.isObject( selectedElement );
+}
+
+// Returns a node that will be used to insert page break with `model.insertContent` to check if page break can be placed there.
+//
+// @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection
+// @param {module:engine/model/model~Model} model Model instance.
+// @returns {module:engine/model/element~Element}
+function getInsertPageBreakParent( selection, model ) {
+	const insertAt = findOptimalInsertionPosition( selection, model );
+
+	const parent = insertAt.parent;
+
+	if ( parent.isEmpty && !parent.is( '$root' ) ) {
+		return parent.parent;
+	}
+
+	return parent;
+}

+ 112 - 0
packages/ckeditor5-page-break/src/pagebreakediting.js

@@ -0,0 +1,112 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module page-break/pagebreakediting
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import PageBreakCommand from './pagebreakcommand';
+import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
+import first from '@ckeditor/ckeditor5-utils/src/first';
+
+import '../theme/pagebreak.css';
+
+/**
+ * The page break editing feature.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class PageBreakEditing extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const schema = editor.model.schema;
+		const t = editor.t;
+		const conversion = editor.conversion;
+
+		schema.register( 'pageBreak', {
+			isObject: true,
+			allowWhere: '$block'
+		} );
+
+		conversion.for( 'dataDowncast' ).elementToElement( {
+			model: 'pageBreak',
+			view: ( modelElement, viewWriter ) => {
+				const divElement = viewWriter.createContainerElement( 'div', {
+					style: 'page-break-after: always'
+				} );
+
+				// For a rationale of using span inside a div see:
+				// https://github.com/ckeditor/ckeditor5-page-break/pull/1#discussion_r328934062.
+				const spanElement = viewWriter.createContainerElement( 'span', {
+					style: 'display: none'
+				} );
+
+				viewWriter.insert( viewWriter.createPositionAt( divElement, 0 ), spanElement );
+
+				return divElement;
+			}
+		} );
+
+		conversion.for( 'editingDowncast' ).elementToElement( {
+			model: 'pageBreak',
+			view: ( modelElement, viewWriter ) => {
+				const label = t( 'Page break' );
+				const viewWrapper = viewWriter.createContainerElement( 'div' );
+
+				viewWriter.addClass( 'ck-page-break', viewWrapper );
+				viewWriter.setCustomProperty( 'pageBreak', true, viewWrapper );
+
+				return toPageBreakWidget( viewWrapper, viewWriter, label );
+			}
+		} );
+
+		conversion.for( 'upcast' )
+			.elementToElement( {
+				view: element => {
+					// The "page break" div must have specified value for the 'page-break-after' definition and single child only.
+					if ( !element.is( 'div' ) || element.getStyle( 'page-break-after' ) != 'always' || element.childCount != 1 ) {
+						return;
+					}
+
+					const viewSpan = first( element.getChildren() );
+
+					// The child must be the "span" element that is not displayed and has a space inside.
+					if ( !viewSpan.is( 'span' ) || viewSpan.getStyle( 'display' ) != 'none' || viewSpan.childCount != 1 ) {
+						return;
+					}
+
+					const text = first( viewSpan.getChildren() );
+
+					if ( !text.is( 'text' ) || text.data !== ' ' ) {
+						return;
+					}
+
+					return { name: true };
+				},
+				model: 'pageBreak'
+			} );
+
+		editor.commands.add( 'pageBreak', new PageBreakCommand( editor ) );
+	}
+}
+
+// Converts a given {@link module:engine/view/element~Element} to a page break widget:
+// * Adds a {@link module:engine/view/element~Element#_setCustomProperty custom property} allowing to
+//   recognize the page break widget element.
+// * Calls the {@link module:widget/utils~toWidget} function with the proper element's label creator.
+//
+//  @param {module:engine/view/element~Element} viewElement
+//  @param {module:engine/view/downcastwriter~DowncastWriter} writer An instance of the view writer.
+//  @param {String} label The element's label.
+//  @returns {module:engine/view/element~Element}
+function toPageBreakWidget( viewElement, writer, label ) {
+	writer.setCustomProperty( 'pageBreak', true, viewElement );
+
+	return toWidget( viewElement, writer, { label } );
+}

+ 43 - 0
packages/ckeditor5-page-break/src/pagebreakui.js

@@ -0,0 +1,43 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module page-break/pagebreakui
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import pageBreakIcon from '../theme/icons/pagebreak.svg';
+
+/**
+ * The page break UI plugin.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class PageBreakUI extends Plugin {
+	init() {
+		const editor = this.editor;
+		const t = editor.t;
+
+		// Add pageBreak button to feature components.
+		editor.ui.componentFactory.add( 'pageBreak', locale => {
+			const command = editor.commands.get( 'pageBreak' );
+			const view = new ButtonView( locale );
+
+			view.set( {
+				label: t( 'Page break' ),
+				icon: pageBreakIcon,
+				tooltip: true
+			} );
+
+			view.bind( 'isEnabled' ).to( command, 'isEnabled' );
+
+			// Execute command.
+			this.listenTo( view, 'execute', () => editor.execute( 'pageBreak' ) );
+
+			return view;
+		} );
+	}
+}

File diff suppressed because it is too large
+ 20 - 0
packages/ckeditor5-page-break/tests/manual/pagebreak.html


+ 64 - 0
packages/ckeditor5-page-break/tests/manual/pagebreak.js

@@ -0,0 +1,64 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals console, window, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import ImageUpload from '@ckeditor/ckeditor5-image/src/imageupload';
+import EasyImage from '@ckeditor/ckeditor5-easy-image/src/easyimage';
+import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';
+import PageBreak from '../../src/pagebreak';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		cloudServices: CS_CONFIG,
+		plugins: [ ArticlePluginSet, ImageUpload, EasyImage, PageBreak ],
+		toolbar: [
+			'heading',
+			'|',
+			'bold', 'italic', 'numberedList', 'bulletedList',
+			'|',
+			'link', 'blockquote', 'imageUpload', 'insertTable', 'mediaEmbed',
+			'|',
+			'undo', 'redo',
+			'|',
+			'pageBreak'
+		],
+		image: {
+			styles: [
+				'full',
+				'alignLeft',
+				'alignRight'
+			],
+			toolbar: [
+				'imageStyle:alignLeft',
+				'imageStyle:full',
+				'imageStyle:alignRight',
+				'|',
+				'imageTextAlternative'
+			]
+		},
+		table: {
+			contentToolbar: [
+				'tableColumn',
+				'tableRow',
+				'mergeTableCells'
+			]
+		},
+	} )
+	.then( editor => {
+		window.editor = editor;
+
+		const logDataButton = document.querySelector( '#log-data' );
+
+		logDataButton.disabled = false;
+		logDataButton.addEventListener( 'click', () => {
+			console.log( window.editor.getData() );
+		} );
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 1 - 0
packages/ckeditor5-page-break/tests/manual/pagebreak.md

@@ -0,0 +1 @@
+There should be a page break widget between two paragraphs in the editor.

+ 18 - 0
packages/ckeditor5-page-break/tests/pagebreak.js

@@ -0,0 +1,18 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import PageBreak from '../src/pagebreak';
+import PageBreakEditing from '../src/pagebreakediting';
+import PageBreakUI from '../src/pagebreakui';
+
+describe( 'PageBreak', () => {
+	it( 'should require PageBreakEditing and PageBreakUI', () => {
+		expect( PageBreak.requires ).to.deep.equal( [ PageBreakEditing, PageBreakUI ] );
+	} );
+
+	it( 'should be named', () => {
+		expect( PageBreak.pluginName ).to.equal( 'PageBreak' );
+	} );
+} );

+ 279 - 0
packages/ckeditor5-page-break/tests/pagebreakcommand.js

@@ -0,0 +1,279 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals document */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import PageBreakEditing from '../src/pagebreakediting';
+
+describe( 'PageBreakCommand', () => {
+	let editor, model, editorElement, command;
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+
+		return ClassicTestEditor
+			.create( editorElement, {
+				plugins: [ Paragraph, PageBreakEditing ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				command = editor.commands.get( 'pageBreak' );
+			} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy()
+			.then( () => {
+				editorElement.remove();
+			} );
+	} );
+
+	describe( 'isEnabled', () => {
+		it( 'should be true when the selection directly in the root', () => {
+			model.enqueueChange( 'transparent', () => {
+				setModelData( model, '[]' );
+
+				command.refresh();
+				expect( command.isEnabled ).to.be.true;
+			} );
+		} );
+
+		it( 'should be true when the selection is in empty block', () => {
+			setModelData( model, '<paragraph>[]</paragraph>' );
+
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be true when the selection directly in a paragraph', () => {
+			setModelData( model, '<paragraph>foo[]</paragraph>' );
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be true when the selection directly in a block', () => {
+			model.schema.register( 'block', { inheritAllFrom: '$block' } );
+			model.schema.extend( '$text', { allowIn: 'block' } );
+			editor.conversion.for( 'downcast' ).elementToElement( { model: 'block', view: 'block' } );
+
+			setModelData( model, '<block>foo[]</block>' );
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be false when the selection is on other page break element', () => {
+			setModelData( model, '[<pageBreak></pageBreak>]' );
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be false when the selection is on other object', () => {
+			model.schema.register( 'object', { isObject: true, allowIn: '$root' } );
+			editor.conversion.for( 'downcast' ).elementToElement( { model: 'object', view: 'object' } );
+			setModelData( model, '[<object></object>]' );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be true when the selection is inside block element inside isLimit element which allows page break', () => {
+			model.schema.register( 'table', { allowWhere: '$block', isLimit: true, isObject: true, isBlock: true } );
+			model.schema.register( 'tableRow', { allowIn: 'table', isLimit: true } );
+			model.schema.register( 'tableCell', { allowIn: 'tableRow', isLimit: true } );
+			model.schema.extend( '$block', { allowIn: 'tableCell' } );
+			editor.conversion.for( 'downcast' ).elementToElement( { model: 'table', view: 'table' } );
+			editor.conversion.for( 'downcast' ).elementToElement( { model: 'tableRow', view: 'tableRow' } );
+			editor.conversion.for( 'downcast' ).elementToElement( { model: 'tableCell', view: 'tableCell' } );
+
+			setModelData( model, '<table><tableRow><tableCell><paragraph>foo[]</paragraph></tableCell></tableRow></table>' );
+		} );
+
+		it( 'should be false when schema disallows page break', () => {
+			model.schema.register( 'block', { inheritAllFrom: '$block' } );
+			model.schema.extend( 'paragraph', { allowIn: 'block' } );
+			// Block page break in block.
+			model.schema.addChildCheck( ( context, childDefinition ) => {
+				if ( childDefinition.name === 'pageBreak' && context.last.name === 'block' ) {
+					return false;
+				}
+			} );
+			editor.conversion.for( 'downcast' ).elementToElement( { model: 'block', view: 'block' } );
+
+			setModelData( model, '<block><paragraph>[]</paragraph></block>' );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+	} );
+
+	describe( 'execute()', () => {
+		beforeEach( () => {
+			model.schema.register( 'heading1', { inheritAllFrom: '$block' } );
+			editor.conversion.elementToElement( { model: 'heading1', view: 'h1' } );
+
+			model.schema.register( 'media', { allowWhere: '$block' } );
+			editor.conversion.elementToElement( { model: 'media', view: 'div' } );
+		} );
+
+		it( 'should create a single batch', () => {
+			setModelData( model, '<paragraph>foo[]</paragraph>' );
+
+			const spy = sinon.spy();
+
+			model.document.on( 'change', spy );
+
+			command.execute();
+
+			sinon.assert.calledOnce( spy );
+		} );
+
+		it( 'should insert a page break in an empty root and select it (a paragraph cannot be inserted)', () => {
+			// Block a paragraph in $root.
+			model.schema.addChildCheck( ( context, childDefinition ) => {
+				if ( childDefinition.name === 'paragraph' && context.last.name === '$root' ) {
+					return false;
+				}
+			} );
+
+			setModelData( model, '[]' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal( '[<pageBreak></pageBreak>]' );
+		} );
+
+		it( 'should split an element where selection is placed and insert a page break (non-collapsed selection)', () => {
+			setModelData( model, '<paragraph>f[o]o</paragraph>' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>f</paragraph><pageBreak></pageBreak><paragraph>[]o</paragraph>'
+			);
+		} );
+
+		it( 'should split an element where selection is placed and insert a page break (collapsed selection)', () => {
+			setModelData( model, '<paragraph>fo[]o</paragraph>' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>fo</paragraph><pageBreak></pageBreak><paragraph>[]o</paragraph>'
+			);
+		} );
+
+		it( 'should create an empty paragraph after inserting a page break after a paragraph and place selection inside', () => {
+			setModelData( model, '<paragraph>foo[]</paragraph>' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>foo</paragraph><pageBreak></pageBreak><paragraph>[]</paragraph>'
+			);
+		} );
+
+		it( 'should create an empty paragraph after inserting a page break after a heading and place selection inside', () => {
+			setModelData( model, '<heading1>foo[]</heading1>' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<heading1>foo</heading1><pageBreak></pageBreak><paragraph>[]</paragraph>'
+			);
+		} );
+
+		it( 'should create an empty paragraph after inserting a page break and next element must not having text', () => {
+			setModelData( model, '<paragraph>foo[]</paragraph><media></media>' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>foo</paragraph><pageBreak></pageBreak><paragraph>[]</paragraph><media></media>'
+			);
+		} );
+
+		it( 'should create an empty paragraph after inserting a page break in heading and next element must not having text', () => {
+			setModelData( model, '<heading1>foo[]</heading1><media></media>' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<heading1>foo</heading1><pageBreak></pageBreak><paragraph>[]</paragraph><media></media>'
+			);
+		} );
+
+		it( 'should not create an empty paragraph if a page break split an element with text', () => {
+			setModelData( model, '<heading1>foo[]bar</heading1>' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<heading1>foo</heading1><pageBreak></pageBreak><heading1>[]bar</heading1>'
+			);
+		} );
+
+		it( 'should replace an empty paragraph with a page break and insert another paragraph next to', () => {
+			setModelData( model, '<paragraph>[]</paragraph>' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<pageBreak></pageBreak><paragraph>[]</paragraph>'
+			);
+		} );
+
+		it( 'should replace an empty paragraph with a page break and move the selection to next paragraph', () => {
+			setModelData( model, '<paragraph>foo</paragraph><paragraph>[]</paragraph><paragraph>bar</paragraph>' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>foo</paragraph><pageBreak></pageBreak><paragraph>[]bar</paragraph>'
+			);
+		} );
+
+		it( 'should replace an empty paragraph with a page break and move the selection to next element that has text', () => {
+			setModelData( model, '<paragraph>foo</paragraph><paragraph>[]</paragraph><heading1>bar</heading1>' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>foo</paragraph><pageBreak></pageBreak><heading1>[]bar</heading1>'
+			);
+		} );
+
+		it( 'should replace an empty block element with a page break and insert a paragraph next to', () => {
+			setModelData( model, '<heading1>[]</heading1>' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<pageBreak></pageBreak><paragraph>[]</paragraph>'
+			);
+		} );
+
+		it( 'should move the selection to next element if it allows having text (paragraph + heading)', () => {
+			setModelData( model, '<paragraph>foo[]</paragraph><heading1>bar</heading1>' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>foo</paragraph><pageBreak></pageBreak><heading1>[]bar</heading1>'
+			);
+		} );
+
+		it( 'should move the selection to next element if it allows having text (heading + paragraph)', () => {
+			setModelData( model, '<heading1>foo[]</heading1><paragraph>bar</paragraph>' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<heading1>foo</heading1><pageBreak></pageBreak><paragraph>[]bar</paragraph>'
+			);
+		} );
+	} );
+} );

+ 229 - 0
packages/ckeditor5-page-break/tests/pagebreakediting.js

@@ -0,0 +1,229 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import PageBreakEditing from '../src/pagebreakediting';
+import PageBreakCommand from '../src/pagebreakcommand';
+import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+import { isWidget } from '@ckeditor/ckeditor5-widget/src/utils';
+import env from '@ckeditor/ckeditor5-utils/src/env';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+
+describe( 'PageBreakEditing', () => {
+	let editor, model, view, viewDocument;
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		// Most tests assume non-edge environment but we do not set `contenteditable=false` on Edge so stub `env.isEdge`.
+		testUtils.sinon.stub( env, 'isEdge' ).get( () => false );
+
+		return VirtualTestEditor
+			.create( {
+				plugins: [ PageBreakEditing ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				view = editor.editing.view;
+				viewDocument = view.document;
+			} );
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( PageBreakEditing ) ).to.be.instanceOf( PageBreakEditing );
+	} );
+
+	it( 'should set proper schema rules', () => {
+		expect( model.schema.checkChild( [ '$root' ], 'pageBreak' ) ).to.be.true;
+
+		expect( model.schema.isObject( 'pageBreak' ) ).to.be.true;
+
+		expect( model.schema.checkChild( [ '$root', 'pageBreak' ], '$text' ) ).to.be.false;
+		expect( model.schema.checkChild( [ '$root', '$block' ], 'pageBreak' ) ).to.be.false;
+	} );
+
+	it( 'should register imageInsert command', () => {
+		expect( editor.commands.get( 'pageBreak' ) ).to.be.instanceOf( PageBreakCommand );
+	} );
+
+	describe( 'conversion in data pipeline', () => {
+		describe( 'model to view', () => {
+			it( 'should convert', () => {
+				setModelData( model, '<pageBreak></pageBreak>' );
+
+				expect( editor.getData() ).to.equal(
+					'<div style="page-break-after:always;"><span style="display:none;">&nbsp;</span></div>'
+				);
+			} );
+		} );
+
+		describe( 'view to model', () => {
+			it( 'should convert the page break code element', () => {
+				editor.setData( '<div style="page-break-after:always;"><span style="display:none;">&nbsp;</span></div>' );
+
+				expect( getModelData( model, { withoutSelection: true } ) )
+					.to.equal( '<pageBreak></pageBreak>' );
+			} );
+
+			it( 'should not convert in wrong context', () => {
+				model.schema.register( 'div', { inheritAllFrom: '$block' } );
+				model.schema.addChildCheck( ( ctx, childDef ) => {
+					if ( ctx.endsWith( '$root' ) && childDef.name == 'pageBreak' ) {
+						return false;
+					}
+				} );
+
+				editor.conversion.elementToElement( { model: 'div', view: 'div' } );
+
+				editor.setData( '<div><div style="page-break-after:always;"><span style="display:none;">&nbsp;</span></div></div>' );
+
+				expect( getModelData( model, { withoutSelection: true } ) )
+					.to.equal( '<div> </div>' );
+			} );
+
+			it( 'should not convert if outer div has wrong styles', () => {
+				editor.setData( '<div style="page-break-after:auto;"><span style="display:none;">&nbsp;</span></div>' );
+
+				expect( getModelData( model, { withoutSelection: true } ) )
+					.to.equal( '' );
+			} );
+
+			it( 'should not convert if outer div has no children', () => {
+				editor.setData( '<div style="page-break-after:always;"></div>' );
+
+				expect( getModelData( model, { withoutSelection: true } ) )
+					.to.equal( '' );
+			} );
+
+			it( 'should not convert if outer div has too many children', () => {
+				editor.setData(
+					'<div style="page-break-after:always;">' +
+						'<span style="display:none;">&nbsp;</span>' +
+						'<span style="display:none;">&nbsp;</span>' +
+					'</div>'
+				);
+
+				expect( getModelData( model, { withoutSelection: true } ) )
+					.to.equal( '' );
+			} );
+
+			it( 'should not convert if inner span has wrong styles', () => {
+				editor.setData(
+					'<div style="page-break-after:always;">' +
+					'<span style="display:inline-block;">&nbsp;</span>' +
+					'</div>'
+				);
+
+				expect( getModelData( model, { withoutSelection: true } ) )
+					.to.equal( '' );
+			} );
+
+			it( 'should not convert if inner span has wrong styles', () => {
+				editor.setData(
+					'<div style="page-break-after:always;">' +
+					'<span style="display:inline-block;">&nbsp;</span>' +
+					'</div>'
+				);
+
+				expect( getModelData( model, { withoutSelection: true } ) )
+					.to.equal( '' );
+			} );
+
+			it( 'should not convert if inner span has any children', () => {
+				editor.setData(
+					'<div style="page-break-after:always;">' +
+						'<span style="display:none;">' +
+							'<span>Foo</span>' +
+						'</span>' +
+					'</div>'
+				);
+
+				expect( getModelData( model, { withoutSelection: true } ) )
+					.to.equal( '' );
+			} );
+
+			it( 'should not convert if inner span has text', () => {
+				editor.setData(
+					'<div style="page-break-after:always;">' +
+						'<span style="display:none;">Foo</span>' +
+					'</div>'
+				);
+
+				expect( getModelData( model, { withoutSelection: true } ) )
+					.to.equal( '' );
+			} );
+
+			it( 'should not convert inner span if outer div was already consumed', () => {
+				model.schema.register( 'section', { inheritAllFrom: '$block' } );
+				editor.conversion.elementToElement( { model: 'section', view: 'section' } );
+
+				model.schema.register( 'span', { inheritAllFrom: '$block' } );
+				editor.model.schema.extend( '$text', { allowAttributes: 'foo' } );
+
+				editor.conversion.attributeToElement( {
+					model: 'foo',
+					view: {
+						name: 'span',
+						styles: {
+							display: 'none'
+						}
+					}
+				} );
+
+				editor.setData(
+					'<section>' +
+						'<div style="page-break-after:always;">' +
+							'<span style="display:none;">&nbsp;</span>' +
+						'</div>' +
+						'<span style="display:none;">Foo</span>' +
+					'</section>'
+				);
+
+				expect( getModelData( model, { withoutSelection: true } ) )
+					.to.equal( '<pageBreak></pageBreak><section><$text foo="true">Foo</$text></section>' );
+			} );
+
+			it( 'should not convert if inner span has no children', () => {
+				editor.setData( '<div style="page-break-after:always;"><span style="display:none;"></span></div>' );
+
+				expect( getModelData( model, { withoutSelection: true } ) )
+					.to.equal( '' );
+			} );
+
+			it( 'should not convert if inner span has other element as a child', () => {
+				editor.setData( '<div style="page-break-after:always;"><span style="display:none;"><span></span></span></div>' );
+
+				expect( getModelData( model, { withoutSelection: true } ) )
+					.to.equal( '' );
+			} );
+		} );
+	} );
+
+	describe( 'conversion in editing pipeline', () => {
+		describe( 'model to view', () => {
+			it( 'should convert', () => {
+				setModelData( model, '<pageBreak></pageBreak>' );
+
+				expect( getViewData( view, { withoutSelection: true } ) ).to.equal(
+					'<div class="ck-page-break ck-widget" contenteditable="false"></div>'
+				);
+			} );
+
+			it( 'converted element should be widgetized', () => {
+				setModelData( model, '<pageBreak></pageBreak>' );
+				const widget = viewDocument.getRoot().getChild( 0 );
+
+				expect( widget.name ).to.equal( 'div' );
+				expect( isPageBreakWidget( widget ) ).to.be.true;
+			} );
+		} );
+	} );
+
+	function isPageBreakWidget( viewElement ) {
+		return !!viewElement.getCustomProperty( 'pageBreak' ) && isWidget( viewElement );
+	}
+} );

+ 67 - 0
packages/ckeditor5-page-break/tests/pagebreakui.js

@@ -0,0 +1,67 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals document */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import PageBreakEditing from '../src/pagebreakediting';
+import PageBreakUI from '../src/pagebreakui';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+describe( 'PageBreakUI', () => {
+	let editor, editorElement, pageBreakView;
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+
+		return ClassicTestEditor
+			.create( editorElement, {
+				plugins: [ Paragraph, PageBreakEditing, PageBreakUI ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+
+				pageBreakView = editor.ui.componentFactory.create( 'pageBreak' );
+			} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy()
+			.then( () => {
+				editorElement.remove();
+			} );
+	} );
+
+	it( 'should register pageBreak feature component', () => {
+		expect( pageBreakView ).to.be.instanceOf( ButtonView );
+		expect( pageBreakView.label ).to.equal( 'Page break' );
+		expect( pageBreakView.icon ).to.match( /<svg / );
+		expect( pageBreakView.isToggleable ).to.be.false;
+	} );
+
+	it( 'should execute pageBreak command on model execute event', () => {
+		const executeSpy = testUtils.sinon.spy( editor, 'execute' );
+
+		pageBreakView.fire( 'execute' );
+
+		sinon.assert.calledOnce( executeSpy );
+		sinon.assert.calledWithExactly( executeSpy, 'pageBreak' );
+	} );
+
+	it( 'should bind model to pageBreak command', () => {
+		const command = editor.commands.get( 'pageBreak' );
+
+		expect( pageBreakView.isEnabled ).to.be.true;
+
+		command.isEnabled = false;
+		expect( pageBreakView.isEnabled ).to.be.false;
+	} );
+} );

+ 1 - 0
packages/ckeditor5-page-break/theme/icons/pagebreak.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M3.598.6866h1.5v5h-1.5zm14.5 0h1.5v5h-1.5z"/><path d="M19.598 4.1866v1.5h-16v-1.5zm-16 14.5694h1.5v-5h-1.5zm14.5 0h1.5v-5h-1.5z"/><path d="M19.598 15.256v-1.5h-16v1.5zM5.0812 9h6v2h-6zm8 0h6v2h-6zM3.598 10L0 12.5v-5z"/></svg>

+ 12 - 0
packages/ckeditor5-page-break/theme/pagebreak.css

@@ -0,0 +1,12 @@
+/*
+ * Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+.ck .ck-page-break {
+	clear: both;
+	padding: 5px;
+	border: 2px dashed var(--ck-color-toolbar-border);
+	border-left: 0;
+	border-right: 0;
+}