ソースを参照

Merge pull request #1632 from ckeditor/t/1625

Docs: Added demos and full source codes to the inline and block widget tutorials. Closes #1625.
Aleksander Nowodzinski 6 年 前
コミット
fa76a8ae55

+ 37 - 0
docs/_snippets/framework/tutorials/block-widget.html

@@ -0,0 +1,37 @@
+<style>
+	.simple-box {
+		padding: 10px;
+		margin: 1em 0;
+
+		background: rgba( 0, 0, 0, 0.1 );
+		border: solid 1px hsl(0, 0%, 77%);
+		border-radius: 2px;
+	}
+
+	.simple-box-title, .simple-box-description {
+		padding: 10px;
+		margin: 0;
+
+		background: #FFF;
+		border: solid 1px hsl(0, 0%, 77%);
+	}
+
+	.simple-box-title {
+		margin-bottom: 10px;
+	}
+</style>
+
+<div id="snippet-block-widget">
+	<p>This is a simple box:</p>
+
+	<section class="simple-box">
+		<h1 class="simple-box-title">Box title</h1>
+		<div class="simple-box-description">
+			<p>The description goes here.</p>
+			<ul>
+				<li>It can contain lists,</li>
+				<li>and other block elements like headings.</li>
+			</ul>
+		</div>
+	</section>
+</div>

+ 241 - 0
docs/_snippets/framework/tutorials/block-widget.js

@@ -0,0 +1,241 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals console, window, document */
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
+import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
+import Heading from '@ckeditor/ckeditor5-heading/src/heading';
+import List from '@ckeditor/ckeditor5-list/src/list';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import { toWidget, toWidgetEditable } from '@ckeditor/ckeditor5-widget/src/utils';
+import Widget from '@ckeditor/ckeditor5-widget/src/widget';
+import Command from '@ckeditor/ckeditor5-core/src/command';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+
+class SimpleBox extends Plugin {
+	static get requires() {
+		return [ SimpleBoxEditing, SimpleBoxUI ];
+	}
+}
+
+class SimpleBoxUI extends Plugin {
+	init() {
+		console.log( 'SimpleBoxUI#init() got called' );
+
+		const editor = this.editor;
+		const t = editor.t;
+
+		// The "simpleBox" button must be registered among UI components of the editor
+		// to be displayed in the toolbar.
+		editor.ui.componentFactory.add( 'simpleBox', locale => {
+			// The state of the button will be bound to the widget command.
+			const command = editor.commands.get( 'insertSimpleBox' );
+
+			// The button will be an instance of ButtonView.
+			const buttonView = new ButtonView( locale );
+
+			buttonView.set( {
+				// The t() function helps localize the editor. All strings enclosed in t() can be
+				// translated and change when the language of the editor changes.
+				label: t( 'Simple Box' ),
+				withText: true,
+				tooltip: true
+			} );
+
+			// Bind the state of the button to the command.
+			buttonView.bind( 'isOn', 'isEnabled' ).to( command, 'value', 'isEnabled' );
+
+			// Execute the command when the button is clicked (executed).
+			this.listenTo( buttonView, 'execute', () => editor.execute( 'insertSimpleBox' ) );
+
+			return buttonView;
+		} );
+	}
+}
+
+class SimpleBoxEditing extends Plugin {
+	static get requires() {
+		return [ Widget ];
+	}
+
+	init() {
+		console.log( 'SimpleBoxEditing#init() got called' );
+
+		this._defineSchema();
+		this._defineConverters();
+
+		this.editor.commands.add( 'insertSimpleBox', new InsertSimpleBoxCommand( this.editor ) );
+	}
+
+	_defineSchema() {
+		const schema = this.editor.model.schema;
+
+		schema.register( 'simpleBox', {
+			// Behaves like a self-contained object (e.g. an image).
+			isObject: true,
+
+			// Allow in places where other blocks are allowed (e.g. directly in the root).
+			allowWhere: '$block'
+		} );
+
+		schema.register( 'simpleBoxTitle', {
+			// Cannot be split or left by the caret.
+			isLimit: true,
+
+			allowIn: 'simpleBox',
+
+			// Allow content which is allowed in blocks (i.e. text with attributes).
+			allowContentOf: '$block'
+		} );
+
+		schema.register( 'simpleBoxDescription', {
+			// Cannot be split or left by the caret.
+			isLimit: true,
+
+			allowIn: 'simpleBox',
+
+			// Allow content which is allowed in the root (e.g. paragraphs).
+			allowContentOf: '$root'
+		} );
+
+		schema.addChildCheck( ( context, childDefinition ) => {
+			if ( context.endsWith( 'simpleBoxDescription' ) && childDefinition.name == 'simpleBox' ) {
+				return false;
+			}
+		} );
+	}
+
+	_defineConverters() {
+		const conversion = this.editor.conversion;
+
+		// <simpleBox> converters
+		conversion.for( 'upcast' ).elementToElement( {
+			model: 'simpleBox',
+			view: {
+				name: 'section',
+				classes: 'simple-box'
+			}
+		} );
+		conversion.for( 'dataDowncast' ).elementToElement( {
+			model: 'simpleBox',
+			view: {
+				name: 'section',
+				classes: 'simple-box'
+			}
+		} );
+		conversion.for( 'editingDowncast' ).elementToElement( {
+			model: 'simpleBox',
+			view: ( modelElement, viewWriter ) => {
+				const section = viewWriter.createContainerElement( 'section', { class: 'simple-box' } );
+
+				return toWidget( section, viewWriter, { label: 'simple box widget' } );
+			}
+		} );
+
+		// <simpleBoxTitle> converters
+		conversion.for( 'upcast' ).elementToElement( {
+			model: 'simpleBoxTitle',
+			view: {
+				name: 'h1',
+				classes: 'simple-box-title'
+			}
+		} );
+		conversion.for( 'dataDowncast' ).elementToElement( {
+			model: 'simpleBoxTitle',
+			view: {
+				name: 'h1',
+				classes: 'simple-box-title'
+			}
+		} );
+		conversion.for( 'editingDowncast' ).elementToElement( {
+			model: 'simpleBoxTitle',
+			view: ( modelElement, viewWriter ) => {
+				// Note: we use a more specialized createEditableElement() method here.
+				const h1 = viewWriter.createEditableElement( 'h1', { class: 'simple-box-title' } );
+
+				return toWidgetEditable( h1, viewWriter );
+			}
+		} );
+
+		// <simpleBoxDescription> converters
+		conversion.for( 'upcast' ).elementToElement( {
+			model: 'simpleBoxDescription',
+			view: {
+				name: 'div',
+				classes: 'simple-box-description'
+			}
+		} );
+		conversion.for( 'dataDowncast' ).elementToElement( {
+			model: 'simpleBoxDescription',
+			view: {
+				name: 'div',
+				classes: 'simple-box-description'
+			}
+		} );
+		conversion.for( 'editingDowncast' ).elementToElement( {
+			model: 'simpleBoxDescription',
+			view: ( modelElement, viewWriter ) => {
+				// Note: we use a more specialized createEditableElement() method here.
+				const div = viewWriter.createEditableElement( 'div', { class: 'simple-box-description' } );
+
+				return toWidgetEditable( div, viewWriter );
+			}
+		} );
+	}
+}
+
+class InsertSimpleBoxCommand extends Command {
+	execute() {
+		this.editor.model.change( writer => {
+			// Insert <simpleBox>*</simpleBox> at the current selection position
+			// in a way which will result in creating a valid model structure.
+			this.editor.model.insertContent( createSimpleBox( writer ) );
+		} );
+	}
+
+	refresh() {
+		const model = this.editor.model;
+		const selection = model.document.selection;
+		const allowedIn = model.schema.findAllowedParent( selection.getFirstPosition(), 'simpleBox' );
+
+		this.isEnabled = allowedIn !== null;
+	}
+}
+
+function createSimpleBox( writer ) {
+	const simpleBox = writer.createElement( 'simpleBox' );
+	const simpleBoxTitle = writer.createElement( 'simpleBoxTitle' );
+	const simpleBoxDescription = writer.createElement( 'simpleBoxDescription' );
+
+	writer.append( simpleBoxTitle, simpleBox );
+	writer.append( simpleBoxDescription, simpleBox );
+
+	// There must be at least one paragraph for the description to be editable.
+	// See https://github.com/ckeditor/ckeditor5/issues/1464.
+	writer.appendElement( 'paragraph', simpleBoxDescription );
+
+	return simpleBox;
+}
+
+ClassicEditor
+	.create( document.querySelector( '#snippet-block-widget' ), {
+		plugins: [ Essentials, Bold, Italic, Heading, List, Paragraph, SimpleBox ],
+		toolbar: {
+			items: [ 'heading', '|', 'bold', 'italic', 'numberedList', 'bulletedList', 'simpleBox' ],
+			viewportTopOffset: window.getViewportTopOffsetConfig()
+		}
+	} )
+	.then( editor => {
+		console.log( 'Editor was initialized', editor );
+
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 17 - 0
docs/_snippets/framework/tutorials/inline-widget.html

@@ -0,0 +1,17 @@
+<style>
+	.placeholder {
+		background: #ffff00;
+		padding: 4px 2px;
+		outline-offset: -2px;
+		line-height: 1em;
+		margin: 0 1px;
+	}
+
+	.placeholder::selection {
+		display: none;
+	}
+</style>
+
+<div id="snippet-inline-widget">
+	<p>Hello <span class="placeholder">{first name}</span> <span class="placeholder">{surname}</span>!</p>
+</div>

+ 213 - 0
docs/_snippets/framework/tutorials/inline-widget.js

@@ -0,0 +1,213 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+/* globals console, window, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Heading from '@ckeditor/ckeditor5-heading/src/heading';
+import List from '@ckeditor/ckeditor5-list/src/list';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
+import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import { toWidget, viewToModelPositionOutsideModelElement } from '@ckeditor/ckeditor5-widget/src/utils';
+import Widget from '@ckeditor/ckeditor5-widget/src/widget';
+import Command from '@ckeditor/ckeditor5-core/src/command';
+
+import { addListToDropdown, createDropdown } from '@ckeditor/ckeditor5-ui/src/dropdown/utils';
+import Collection from '@ckeditor/ckeditor5-utils/src/collection';
+import Model from '@ckeditor/ckeditor5-ui/src/model';
+
+class PlaceholderCommand extends Command {
+	execute( { value } ) {
+		const editor = this.editor;
+
+		editor.model.change( writer => {
+			// Create <placeholder> elment with name attribute...
+			const placeholder = writer.createElement( 'placeholder', { name: value } );
+
+			// ... and insert it into the document.
+			editor.model.insertContent( placeholder );
+
+			// Put the selection on inserted element.
+			writer.setSelection( placeholder, 'on' );
+		} );
+	}
+
+	refresh() {
+		const model = this.editor.model;
+		const selection = model.document.selection;
+
+		const isAllowed = model.schema.checkChild( selection.focus.parent, 'placeholder' );
+
+		this.isEnabled = isAllowed;
+	}
+}
+
+class Placeholder extends Plugin {
+	static get requires() {
+		return [ PlaceholderEditing, PlaceholderUI ];
+	}
+}
+
+class PlaceholderUI extends Plugin {
+	init() {
+		const editor = this.editor;
+		const t = editor.t;
+		const placeholderNames = editor.config.get( 'placeholderConfig.types' );
+
+		// The "placeholder" dropdown must be registered among UI components of the editor
+		// to be displayed in the toolbar.
+		editor.ui.componentFactory.add( 'placeholder', locale => {
+			const dropdownView = createDropdown( locale );
+
+			// Populate the list in the dropdown with items.
+			addListToDropdown( dropdownView, getDropdownItemsDefinitions( placeholderNames ) );
+
+			dropdownView.buttonView.set( {
+				// The t() function helps localize the editor. All strings enclosed in t() can be
+				// translated and change when the language of the editor changes.
+				label: t( 'Placeholder' ),
+				tooltip: true,
+				withText: true
+			} );
+
+			// Execute the command when the dropdown items is clicked (executed).
+			this.listenTo( dropdownView, 'execute', evt => {
+				editor.execute( 'placeholder', { value: evt.source.commandParam } );
+				editor.editing.view.focus();
+			} );
+
+			return dropdownView;
+		} );
+	}
+}
+
+function getDropdownItemsDefinitions( placeholderNames ) {
+	const itemDefinitions = new Collection();
+
+	for ( const name of placeholderNames ) {
+		const definition = {
+			type: 'button',
+			model: new Model( {
+				commandParam: name,
+				label: name,
+				withText: true
+			} )
+		};
+
+		// Add the item definition to the collection.
+		itemDefinitions.add( definition );
+	}
+
+	return itemDefinitions;
+}
+
+class PlaceholderEditing extends Plugin {
+	static get requires() {
+		return [ Widget ];
+	}
+
+	init() {
+		console.log( 'PlaceholderEditing#init() got called' );
+
+		this._defineSchema();
+		this._defineConverters();
+
+		this.editor.commands.add( 'placeholder', new PlaceholderCommand( this.editor ) );
+
+		this.editor.editing.mapper.on(
+			'viewToModelPosition',
+			viewToModelPositionOutsideModelElement( this.editor.model, viewElement => viewElement.hasClass( 'placeholder' ) )
+		);
+		this.editor.config.define( 'placeholderConfig', {
+			types: [ 'date', 'first name', 'surname' ]
+		} );
+	}
+
+	_defineSchema() {
+		const schema = this.editor.model.schema;
+
+		schema.register( 'placeholder', {
+			// Allow wherever text is allowed:
+			allowWhere: '$text',
+
+			// The placeholder will acts as an inline node:
+			isInline: true,
+
+			// The inline-widget is self-contained so cannot be split by the caret and can be selected:
+			isObject: true,
+
+			// The placeholder can have many types, like date, name, surname, etc:
+			allowAttributes: [ 'name' ]
+		} );
+	}
+
+	_defineConverters() {
+		const conversion = this.editor.conversion;
+
+		conversion.for( 'upcast' ).elementToElement( {
+			view: {
+				name: 'span',
+				classes: [ 'placeholder' ]
+			},
+			model: ( viewElement, modelWriter ) => {
+				// Extract the "name" from "{name}".
+				const name = viewElement.getChild( 0 ).data.slice( 1, -1 );
+
+				return modelWriter.createElement( 'placeholder', { name } );
+			}
+		} );
+
+		conversion.for( 'editingDowncast' ).elementToElement( {
+			model: 'placeholder',
+			view: ( modelItem, viewWriter ) => {
+				const widgetElement = createPlaceholderView( modelItem, viewWriter );
+
+				// Enable widget handling on placeholder element inside editing view.
+				return toWidget( widgetElement, viewWriter );
+			}
+		} );
+
+		conversion.for( 'dataDowncast' ).elementToElement( {
+			model: 'placeholder',
+			view: createPlaceholderView
+		} );
+
+		// Helper method for both downcast converters.
+		function createPlaceholderView( modelItem, viewWriter ) {
+			const name = modelItem.getAttribute( 'name' );
+
+			const placeholderView = viewWriter.createContainerElement( 'span', {
+				class: 'placeholder'
+			} );
+
+			// Insert the placeholder name (as a text).
+			const innerText = viewWriter.createText( '{' + name + '}' );
+			viewWriter.insert( viewWriter.createPositionAt( placeholderView, 0 ), innerText );
+
+			return placeholderView;
+		}
+	}
+}
+
+ClassicEditor
+	.create( document.querySelector( '#snippet-inline-widget' ), {
+		plugins: [ Essentials, Paragraph, Heading, List, Bold, Italic, Placeholder ],
+		toolbar: [ 'heading', '|', 'bold', 'italic', 'numberedList', 'bulletedList', '|', 'placeholder' ],
+		placeholderConfig: {
+			types: [ 'date', 'color', 'first name', 'surname' ]
+		}
+	} )
+	.then( editor => {
+		console.log( 'Editor was initialized', editor );
+
+		// Expose for playing in the console.
+		window.editor = editor;
+	} )
+	.catch( error => {
+		console.error( error.stack );
+	} );

+ 262 - 12
docs/framework/guides/tutorials/implementing-a-block-widget.md

@@ -5,7 +5,11 @@ order: 10
 
 # Implementing a block widget
 
-In this tutorial you will learn how to implement a more complex CKEditor 5 plugin. We will build a "Simple box" feature which will allow the user to insert a custom box with a title and body fields into the document. We will use the widget utils and work with the model-view conversion in order to properly setup the behavior of this feature. Later on, we will create a UI which will allow to insert new simple boxes into the document via the toolbar button.
+In this tutorial, you will learn how to implement a more complex CKEditor 5 plugin. We will build a "Simple box" feature which will allow the user to insert a custom box with a title and body fields into the document. We will use the widget utils and work with the model-view conversion in order to properly set up the behavior of this feature. Later on, we will create a UI which will allow inserting new simple boxes into the document via the toolbar button.
+
+<info-box>
+	If you want to see the final product of this tutorial before you plunge in, check out the [demo](#demo).
+</info-box>
 
 <!-- TODO: and allow controlling simple box properties such as alignment and width. -->
 
@@ -19,7 +23,7 @@ We will also reference various parts of the {@link framework/guides/architecture
 
 This guide assumes that you are familiar with npm and your project uses npm already. If not, see the [npm documentation](https://docs.npmjs.com/getting-started/what-is-npm) or call `npm init` in an empty directory and keep your fingers crossed.
 
-First, install packages needed to build and setup a basic CKEditor 5 instance.
+First, install packages needed to build and set up a basic CKEditor 5 instance.
 
 ```bash
 npm install --save \
@@ -165,7 +169,7 @@ You should see a CKEditor 5 instance like this:
 
 ## Plugin structure
 
-Once the editor is up and running we can start implementing the plugin. All the code of a plugin can be kept in a single file, however, we recommend splitting its "editing" and "UI" layers and creating a master plugin which loads both. This way, we ensure a better separation of concerns and allow recomposing the features (e.g. picking the editing part of an existing feature but writing your own UI for it). All official CKEditor 5 plugins follow this pattern.
+Once the editor is up and running we can start implementing the plugin. All the code of a plugin can be kept in a single file, however, we recommend splitting its "editing" and "UI" layers and creating a master plugin which loads both. This way, we ensure better separation of concerns and allow recomposing the features (e.g. picking the editing part of an existing feature but writing your own UI for it). All official CKEditor 5 plugins follow this pattern.
 
 Additionally, we will split code of commands, buttons and other "self-contained" components to separate files as well. In order to not mix up these files with your project's `app.js` and `webpack.config.js` files, let's create this directory structure:
 
@@ -271,13 +275,13 @@ Rebuild your project, refresh the browser and you should see that the `SimpleBox
 
 ## The model and the view layers
 
-CKEditor 5 implements an MVC architecture and its custom data model, while still being a tree structure, does not map to the DOM 1:1. You can think about the model as about an even more semantical representation of the editor content, while the DOM is its one of the possible representations.
+CKEditor 5 implements an MVC architecture and its custom data model, while still being a tree structure, does not map to the DOM 1:1. You can think about the model as about an even more semantical representation of the editor content, while the DOM is one of its possible representations.
 
 <info-box>
 	Read more about the {@link framework/guides/architecture/editing-engine#overview editing engine architecture}.
 </info-box>
 
-Since our simple box feature is meant to be a box with a title and description fields, let's define its model representation as this:
+Since our simple box feature is meant to be a box with a title and description fields, let's define its model representation like this:
 
 ```html
 <simpleBox>
@@ -288,7 +292,7 @@ Since our simple box feature is meant to be a box with a title and description f
 
 ### Defining the schema
 
-We need to start from defining the model's schema. We need to define there 3 elements and their types and allowed parent/children.
+We need to start with defining the model's schema. We need to define there 3 elements and their types and allowed parent/children.
 
 <info-box>
 	Read more about the {@link framework/guides/architecture/editing-engine#schema schema}.
@@ -342,7 +346,7 @@ export default class SimpleBoxEditing extends Plugin {
 }
 ```
 
-Defining the schema will not have any effect on the editor just yet. It is an information which can be used by plugins and the editor engine to understand how actions like pressing the <kbd>Enter</kbd> key, clicking on an element, typing text, inserting an image, etc. should behave.
+Defining the schema will not have any effect on the editor just yet. It is information which can be used by plugins and the editor engine to understand how actions like pressing the <kbd>Enter</kbd> key, clicking on an element, typing text, inserting an image, etc. should behave.
 
 For the simple box plugin to start doing anything we need to define model-view converters. Let's do that!
 
@@ -354,7 +358,7 @@ Converters tell the editor how to convert the view to the model (e.g. when loadi
 	Read more about the {@link framework/guides/architecture/editing-engine#conversion model-view conversion}.
 </info-box>
 
-This is the moment when we need to think how we want to render the `<simpleBox>` element and its children to the DOM (what user will see) and to the data. CKEditor 5 allows converting the model to a different structure for editing purposes and a different one to be stored as "data" or exchanged with other applications when copy-pasting the content. However, for simplicity, let's use the same representation in both pipelines for now.
+This is the moment when we need to think about how we want to render the `<simpleBox>` element and its children to the DOM (what user will see) and to the data. CKEditor 5 allows converting the model to a different structure for editing purposes and a different one to be stored as "data" or exchanged with other applications when copy-pasting the content. However, for simplicity, let's use the same representation in both pipelines for now.
 
 The structure in the view that we want to achieve:
 
@@ -560,7 +564,7 @@ It is time to check if the simple box behaves like we would like it to. You can
 * If you try to select the entire simple box instance and press <kbd>Delete</kbd>, it will be deleted as a whole. The same when you copy and paste it. That is because it was marked as an `isObject` element in the schema.
 * You cannot easily select the entire simple box instance by clicking on it. Also, the cursor pointer does not change when you hover it. In other words, it seems a bit "dead". That is because we have not yet defined the view behavior yet.
 
-Pretty cool so far, right? With a very little code you were able to define a behavior of your simple box plugin which maintains integrity of those elements. The engine ensures that the user does not break those instances.
+Pretty cool so far, right? With a very little code, you were able to define a behavior of your simple box plugin which maintains the integrity of those elements. The engine ensures that the user does not break those instances.
 
 Let's see what else we can improve.
 
@@ -723,7 +727,7 @@ This is all that we need from the model and the view layers for now. In terms of
 
 A {@link framework/guides/architecture/core-editor-architecture#commands command} is a combination of an action and a state. You can interact with most of the editor features by commands that they expose. This allows not only executing those features (e.g. bolding a fragment of text) but also checking if this action can be executed in the selection's current location as well as observing other state properties (such as whether the currently selected text is bolded).
 
-In case of simple box the situation is simple:
+In case of the simple box the situation is simple:
 
 * we need an "insert new simple box" action,
 * and "can we insert a new simple box here (at the current selection position)".
@@ -894,7 +898,7 @@ Now, the command should be disabled also when the selection is inside the descri
 
 ## Creating a button
 
-It is time to allow editor users insert the widget into the content. The best way to do that is through a UI button in the toolbar. You can quickly create one using the {@link module:ui/button/buttonview~ButtonView `ButtonView`} class brought by the {@link framework/guides/architecture/ui-library UI framework} of CKEditor 5.
+It is time to allow editor users to insert the widget into the content. The best way to do that is through a UI button in the toolbar. You can quickly create one using the {@link module:ui/button/buttonview~ButtonView `ButtonView`} class brought by the {@link framework/guides/architecture/ui-library UI framework} of CKEditor 5.
 
 The button should execute the [command](#creating-a-command) when clicked and become inactive if the widget cannot be inserted in some particular position of the selection ([as defined in the schema](#defining-the-schema)).
 
@@ -947,7 +951,7 @@ The last thing you need to do is tell the editor to display the button in the to
 ```js
 ClassicEditor
 	.create( document.querySelector( '#editor' ), {
-		plugins: [ Essentials, Paragraph, Heading, List, Bold, Italic ],
+		plugins: [ Essentials, Paragraph, Heading, List, Bold, Italic, SimpleBox ],
 		// Insert the "simpleBox" to the editor toolbar.
 		toolbar: [ 'heading', 'bold', 'italic', 'numberedList', 'bulletedList', 'simpleBox' ]
 	} )
@@ -962,3 +966,249 @@ ClassicEditor
 Refresh the web page and try it yourself:
 
 {@img assets/img/tutorial-implementing-a-widget-7.png Screenshot of the simple box widget being inserted using the toolbar button.}
+
+
+## Demo
+
+You can see the block widget implementation in action in the editor below. You can also check out the full [source code](#full-source-code) of this tutorial if you want to develop your own block widgets.
+
+{@snippet framework/tutorials/block-widget}
+
+## Full source code
+
+The following code contains a complete implementation of the `SimpleBox` plugin and the code to run the editor. You can paste it into the [`app.js`](#plugin-structure) file and it will run out–of–the–box:
+
+```js
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
+import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
+import Heading from '@ckeditor/ckeditor5-heading/src/heading';
+import List from '@ckeditor/ckeditor5-list/src/list';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import { toWidget, toWidgetEditable } from '@ckeditor/ckeditor5-widget/src/utils';
+import Widget from '@ckeditor/ckeditor5-widget/src/widget';
+import Command from '@ckeditor/ckeditor5-core/src/command';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+
+class SimpleBox extends Plugin {
+	static get requires() {
+		return [ SimpleBoxEditing, SimpleBoxUI ];
+	}
+}
+
+class SimpleBoxUI extends Plugin {
+	init() {
+		console.log( 'SimpleBoxUI#init() got called' );
+
+		const editor = this.editor;
+		const t = editor.t;
+
+		// The "simpleBox" button must be registered among UI components of the editor
+		// to be displayed in the toolbar.
+		editor.ui.componentFactory.add( 'simpleBox', locale => {
+			// The state of the button will be bound to the widget command.
+			const command = editor.commands.get( 'insertSimpleBox' );
+
+			// The button will be an instance of ButtonView.
+			const buttonView = new ButtonView( locale );
+
+			buttonView.set( {
+				// The t() function helps localize the editor. All strings enclosed in t() can be
+				// translated and change when the language of the editor changes.
+				label: t( 'Simple Box' ),
+				withText: true,
+				tooltip: true
+			} );
+
+			// Bind the state of the button to the command.
+			buttonView.bind( 'isOn', 'isEnabled' ).to( command, 'value', 'isEnabled' );
+
+			// Execute the command when the button is clicked (executed).
+			this.listenTo( buttonView, 'execute', () => editor.execute( 'insertSimpleBox' ) );
+
+			return buttonView;
+		} );
+	}
+}
+
+class SimpleBoxEditing extends Plugin {
+	static get requires() {
+		return [ Widget ];
+	}
+
+	init() {
+		console.log( 'SimpleBoxEditing#init() got called' );
+
+		this._defineSchema();
+		this._defineConverters();
+
+		this.editor.commands.add( 'insertSimpleBox', new InsertSimpleBoxCommand( this.editor ) );
+	}
+
+	_defineSchema() {
+		const schema = this.editor.model.schema;
+
+		schema.register( 'simpleBox', {
+			// Behaves like a self-contained object (e.g. an image).
+			isObject: true,
+
+			// Allow in places where other blocks are allowed (e.g. directly in the root).
+			allowWhere: '$block'
+		} );
+
+		schema.register( 'simpleBoxTitle', {
+			// Cannot be split or left by the caret.
+			isLimit: true,
+
+			allowIn: 'simpleBox',
+
+			// Allow content which is allowed in blocks (i.e. text with attributes).
+			allowContentOf: '$block'
+		} );
+
+		schema.register( 'simpleBoxDescription', {
+			// Cannot be split or left by the caret.
+			isLimit: true,
+
+			allowIn: 'simpleBox',
+
+			// Allow content which is allowed in the root (e.g. paragraphs).
+			allowContentOf: '$root'
+		} );
+
+		schema.addChildCheck( ( context, childDefinition ) => {
+			if ( context.endsWith( 'simpleBoxDescription' ) && childDefinition.name == 'simpleBox' ) {
+				return false;
+			}
+		} );
+	}
+
+	_defineConverters() {
+		const conversion = this.editor.conversion;
+
+		// <simpleBox> converters
+		conversion.for( 'upcast' ).elementToElement( {
+			model: 'simpleBox',
+			view: {
+				name: 'section',
+				classes: 'simple-box'
+			}
+		} );
+		conversion.for( 'dataDowncast' ).elementToElement( {
+			model: 'simpleBox',
+			view: {
+				name: 'section',
+				classes: 'simple-box'
+			}
+		} );
+		conversion.for( 'editingDowncast' ).elementToElement( {
+			model: 'simpleBox',
+			view: ( modelElement, viewWriter ) => {
+				const section = viewWriter.createContainerElement( 'section', { class: 'simple-box' } );
+
+				return toWidget( section, viewWriter, { label: 'simple box widget' } );
+			}
+		} );
+
+		// <simpleBoxTitle> converters
+		conversion.for( 'upcast' ).elementToElement( {
+			model: 'simpleBoxTitle',
+			view: {
+				name: 'h1',
+				classes: 'simple-box-title'
+			}
+		} );
+		conversion.for( 'dataDowncast' ).elementToElement( {
+			model: 'simpleBoxTitle',
+			view: {
+				name: 'h1',
+				classes: 'simple-box-title'
+			}
+		} );
+		conversion.for( 'editingDowncast' ).elementToElement( {
+			model: 'simpleBoxTitle',
+			view: ( modelElement, viewWriter ) => {
+				// Note: we use a more specialized createEditableElement() method here.
+				const h1 = viewWriter.createEditableElement( 'h1', { class: 'simple-box-title' } );
+
+				return toWidgetEditable( h1, viewWriter );
+			}
+		} );
+
+		// <simpleBoxDescription> converters
+		conversion.for( 'upcast' ).elementToElement( {
+			model: 'simpleBoxDescription',
+			view: {
+				name: 'div',
+				classes: 'simple-box-description'
+			}
+		} );
+		conversion.for( 'dataDowncast' ).elementToElement( {
+			model: 'simpleBoxDescription',
+			view: {
+				name: 'div',
+				classes: 'simple-box-description'
+			}
+		} );
+		conversion.for( 'editingDowncast' ).elementToElement( {
+			model: 'simpleBoxDescription',
+			view: ( modelElement, viewWriter ) => {
+				// Note: we use a more specialized createEditableElement() method here.
+				const div = viewWriter.createEditableElement( 'div', { class: 'simple-box-description' } );
+
+				return toWidgetEditable( div, viewWriter );
+			}
+		} );
+	}
+}
+
+class InsertSimpleBoxCommand extends Command {
+	execute() {
+		this.editor.model.change( writer => {
+			// Insert <simpleBox>*</simpleBox> at the current selection position
+			// in a way which will result in creating a valid model structure.
+			this.editor.model.insertContent( createSimpleBox( writer ) );
+		} );
+	}
+
+	refresh() {
+		const model = this.editor.model;
+		const selection = model.document.selection;
+		const allowedIn = model.schema.findAllowedParent( selection.getFirstPosition(), 'simpleBox' );
+
+		this.isEnabled = allowedIn !== null;
+	}
+}
+
+function createSimpleBox( writer ) {
+	const simpleBox = writer.createElement( 'simpleBox' );
+	const simpleBoxTitle = writer.createElement( 'simpleBoxTitle' );
+	const simpleBoxDescription = writer.createElement( 'simpleBoxDescription' );
+
+	writer.append( simpleBoxTitle, simpleBox );
+	writer.append( simpleBoxDescription, simpleBox );
+
+	// There must be at least one paragraph for the description to be editable.
+	// See https://github.com/ckeditor/ckeditor5/issues/1464.
+	writer.appendElement( 'paragraph', simpleBoxDescription );
+
+	return simpleBox;
+}
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ Essentials, Bold, Italic, Heading, List, Paragraph, SimpleBox ],
+		toolbar: [ 'heading', '|', 'bold', 'italic', 'numberedList', 'bulletedList', 'simpleBox' ],
+	} )
+	.then( editor => {
+		console.log( 'Editor was initialized', editor );
+
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );
+```

+ 239 - 15
docs/framework/guides/tutorials/implementing-an-inline-widget.md

@@ -5,7 +5,11 @@ order: 10
 
 # Implementing an inline widget
 
-In this tutorial you will learn how to implement an inline widget. We will build a "Placeholder" feature which allow the user to insert a predefined placeholders, like a date or a surname, into the document. We will use widget utils and conversion in order to define the behavior of this feature. Later on, we will use dropdown utils to create a dropdown which will allow inserting new placeholders. We will also learn how to use the editor configuration to define allowed placeholder names.
+In this tutorial, you will learn how to implement an inline widget. We will build a "Placeholder" feature which allow the user to insert predefined placeholders, like a date or a surname, into the document. We will use widget utils and conversion in order to define the behavior of this feature. Later on, we will use dropdown utils to create a dropdown which will allow inserting new placeholders. We will also learn how to use the editor configuration to define allowed placeholder names.
+
+<info-box>
+	If you want to see the final product of this tutorial before you plunge in, check out the [demo](#demo).
+</info-box>
 
 ## Before you start ⚠️
 
@@ -222,7 +226,7 @@ At this stage we can build the project and open it in the browser to verify if i
 
 ## The model and the view layers
 
-The placeholder feature will be {@link module:engine/model/schema~SchemaItemDefinition defined as  an inline} (text-like) element so it will be inserted in other editor blocks, like `<paragraph>`, that allow text. The placeholder will have `name` attribute. This means that the model containing some text and a placeholder will look like this:
+The placeholder feature will be {@link module:engine/model/schema~SchemaItemDefinition defined as  an inline} (text-like) element so it will be inserted in other editor blocks, like `<paragraph>`, that allow text. The placeholder will have a `name` attribute. This means that the model containing some text and a placeholder will look like this:
 
 ```html
 <paragraph>
@@ -281,7 +285,7 @@ The HTML structure (data output) of the converter will be a `<span>` with a `pla
 ```
 
 * **Upcast conversion**. This view-to-model converter will look for `<span>`s with class `placeholder`, read the `<span>`'s text and create a model `<placeholder>` elements with the `name` attribute set accordingly.
-* **Downcast conversion**. The model-to-view conversion will be slightly different for "editing" and "data" pipelines as the "editing downcast" pipeline will use widget utilities to enable widget specific behavior in the editing view. In both pipelines the element will be rendered using the same structure.
+* **Downcast conversion**. The model-to-view conversion will be slightly different for "editing" and "data" pipelines as the "editing downcast" pipeline will use widget utilities to enable widget specific behavior in the editing view. In both pipelines, the element will be rendered using the same structure.
 
 ```js
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
@@ -364,7 +368,7 @@ As you could notice the editing part imports the `./theme/placeholder.css` CSS f
 ```css
 /* placeholder/theme/placeholder.css */
 
-[data-placeholder] {
+.placeholder {
 	background: #ffff00;
 	padding: 4px 2px;
 	outline-offset: -2px;
@@ -372,7 +376,7 @@ As you could notice the editing part imports the `./theme/placeholder.css` CSS f
 	margin: 0 1px;
 }
 
-[data-placeholder]::selection {
+.placeholder::selection {
 	display: none;
 }
 ```
@@ -465,7 +469,7 @@ This should result in:
 
 ### Fixing position mapping
 
-If you play now more with the widget (e.g. try to select it by dragging the mouse from its right to left edge) you will see the following error logged on the console:
+If you play now more with the widget (e.g. try to select it by dragging the mouse from its right to the left edge) you will see the following error logged on the console:
 
 ```
 Uncaught CKEditorError: model-nodelist-offset-out-of-bounds: Given offset cannot be found in the node list.
@@ -537,11 +541,11 @@ After adding the custom mapping, the mapping will work perfectly. Every position
 
 ## Creating the UI
 
-The UI part will provide a dropdown button from which user can select a placeholder to insert into the editor.
+The UI part will provide a dropdown button from which the user can select a placeholder to insert into the editor.
 
 The CKEditor 5 framework features helpers to create different {@link framework/guides/architecture/ui-library#dropdowns dropdowns} like toolbar or list dropdowns.
 
-In this tutorial we will create a dropdown with list of available placeholders.
+In this tutorial, we will create a dropdown with a list of available placeholders.
 
 ```js
 // placeholder/placeholderui.js
@@ -641,7 +645,7 @@ ClassicEditor
 
 To make this plugin extensible, the types of placeholders will be read from editor configuration.
 
-The first step is to define placeholder configuration in the editing plugin:
+The first step is to define the placeholder configuration in the editing plugin:
 
 ```js
 // ... imports
@@ -664,7 +668,7 @@ export default class PlaceholderEditing extends Plugin {
 			viewToModelPositionOutsideModelElement( this.editor.model, viewElement => viewElement.hasClass( 'placeholder' ) )
 		);
 
-		this.editor.config.define( 'placeholder', {                                 // ADDED
+		this.editor.config.define( 'placeholderConfig', {                           // ADDED
 			types: [ 'date', 'first name', 'surname' ]
 		} );
 	}
@@ -688,7 +692,7 @@ export default class PlaceholderUI extends Plugin {
 	init() {
 		const editor = this.editor;
 
-		const placeholderNames = editor.config.get( 'placeholder.types' );                  // CHANGED
+		const placeholderNames = editor.config.get( 'placeholderConfig.types' );            // CHANGED
 
 		editor.ui.componentFactory.add( 'placeholder', locale => {
 			// ...
@@ -697,7 +701,7 @@ export default class PlaceholderUI extends Plugin {
 }
 ```
 
-Now the plugins is ready to accept configuration. Let's check how this works by adding `placeholder` configuration in editor's create method:
+Now the plugins is ready to accept configuration. Let's check how this works by adding `placeholderConfig` configuration in editor's create method:
 
 ```js
 // ... imports
@@ -706,13 +710,233 @@ ClassicEditor
 	.create( document.querySelector( '#editor' ), {
 		plugins: [ Essentials, Paragraph, Heading, List, Bold, Italic, Widget, Placeholder ],
 		toolbar: [ 'heading', 'bold', 'italic', 'numberedList', 'bulletedList', '|', 'placeholder' ],
-		placeholder: {
-			types: [ 'model', 'make', 'color' ]                                             // ADDED
+		placeholderConfig: {
+			types: [ 'date', 'color', 'first name', 'surname' ]                             // ADDED
 		}
 	} )
 	// ...
 ```
 
-Now if you open the dropdown in the toolbar you'll see new list of placeholders to insert.
+Now if you open the dropdown in the toolbar you'll see the new list of placeholders to insert.
 
 {@img assets/img/tutorial-implementing-an-inline-widget-2.png Screenshot of the placeholder widgets being inserted using the dropdown.}
+
+## Demo
+
+You can see the placeholder widget implementation in action in the editor below. You can also check out the full [source code](#full-source-code) of this tutorial if you want to develop your own inline widgets.
+
+{@snippet framework/tutorials/inline-widget}
+
+## Full source code
+
+The following code contains a complete implementation of the `Placeholder` plugin and the code to run the editor. You can paste it into the [`app.js`](#plugin-structure) file and it will run out–of–the–box:
+
+```js
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Heading from '@ckeditor/ckeditor5-heading/src/heading';
+import List from '@ckeditor/ckeditor5-list/src/list';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
+import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import { toWidget, viewToModelPositionOutsideModelElement } from '@ckeditor/ckeditor5-widget/src/utils';
+import Widget from '@ckeditor/ckeditor5-widget/src/widget';
+import Command from '@ckeditor/ckeditor5-core/src/command';
+
+import { addListToDropdown, createDropdown } from '@ckeditor/ckeditor5-ui/src/dropdown/utils';
+import Collection from '@ckeditor/ckeditor5-utils/src/collection';
+import Model from '@ckeditor/ckeditor5-ui/src/model';
+
+class Placeholder extends Plugin {
+	static get requires() {
+		return [ PlaceholderEditing, PlaceholderUI ];
+	}
+}
+
+class PlaceholderCommand extends Command {
+	execute( { value } ) {
+		const editor = this.editor;
+
+		editor.model.change( writer => {
+			// Create <placeholder> elment with name attribute...
+			const placeholder = writer.createElement( 'placeholder', { name: value } );
+
+			// ... and insert it into the document.
+			editor.model.insertContent( placeholder );
+
+			// Put the selection on inserted element.
+			writer.setSelection( placeholder, 'on' );
+		} );
+	}
+
+	refresh() {
+		const model = this.editor.model;
+		const selection = model.document.selection;
+
+		const isAllowed = model.schema.checkChild( selection.focus.parent, 'placeholder' );
+
+		this.isEnabled = isAllowed;
+	}
+}
+
+class PlaceholderUI extends Plugin {
+	init() {
+		const editor = this.editor;
+		const t = editor.t;
+		const placeholderNames = editor.config.get( 'placeholderConfig.types' );
+
+		// The "placeholder" dropdown must be registered among UI components of the editor
+		// to be displayed in the toolbar.
+		editor.ui.componentFactory.add( 'placeholder', locale => {
+			const dropdownView = createDropdown( locale );
+
+			// Populate the list in the dropdown with items.
+			addListToDropdown( dropdownView, getDropdownItemsDefinitions( placeholderNames ) );
+
+			dropdownView.buttonView.set( {
+				// The t() function helps localize the editor. All strings enclosed in t() can be
+				// translated and change when the language of the editor changes.
+				label: t( 'Placeholder' ),
+				tooltip: true,
+				withText: true
+			} );
+
+			// Execute the command when the dropdown items is clicked (executed).
+			this.listenTo( dropdownView, 'execute', evt => {
+				editor.execute( 'placeholder', { value: evt.source.commandParam } );
+				editor.editing.view.focus();
+			} );
+
+			return dropdownView;
+		} );
+	}
+}
+
+function getDropdownItemsDefinitions( placeholderNames ) {
+	const itemDefinitions = new Collection();
+
+	for ( const name of placeholderNames ) {
+		const definition = {
+			type: 'button',
+			model: new Model( {
+				commandParam: name,
+				label: name,
+				withText: true
+			} )
+		};
+
+		// Add the item definition to the collection.
+		itemDefinitions.add( definition );
+	}
+
+	return itemDefinitions;
+}
+
+class PlaceholderEditing extends Plugin {
+	static get requires() {
+		return [ Widget ];
+	}
+
+	init() {
+		console.log( 'PlaceholderEditing#init() got called' );
+
+		this._defineSchema();
+		this._defineConverters();
+
+		this.editor.commands.add( 'placeholder', new PlaceholderCommand( this.editor ) );
+
+		this.editor.editing.mapper.on(
+			'viewToModelPosition',
+			viewToModelPositionOutsideModelElement( this.editor.model, viewElement => viewElement.hasClass( 'placeholder' ) )
+		);
+		this.editor.config.define( 'placeholderConfig', {
+			types: [ 'date', 'first name', 'surname' ]
+		} );
+	}
+
+	_defineSchema() {
+		const schema = this.editor.model.schema;
+
+		schema.register( 'placeholder', {
+			// Allow wherever text is allowed:
+			allowWhere: '$text',
+
+			// The placeholder will acts as an inline node:
+			isInline: true,
+
+			// The inline-widget is self-contained so cannot be split by the caret and can be selected:
+			isObject: true,
+
+			// The placeholder can have many types, like date, name, surname, etc:
+			allowAttributes: [ 'name' ]
+		} );
+	}
+
+	_defineConverters() {
+		const conversion = this.editor.conversion;
+
+		conversion.for( 'upcast' ).elementToElement( {
+			view: {
+				name: 'span',
+				classes: [ 'placeholder' ]
+			},
+			model: ( viewElement, modelWriter ) => {
+				// Extract the "name" from "{name}".
+				const name = viewElement.getChild( 0 ).data.slice( 1, -1 );
+
+				return modelWriter.createElement( 'placeholder', { name } );
+			}
+		} );
+
+		conversion.for( 'editingDowncast' ).elementToElement( {
+			model: 'placeholder',
+			view: ( modelItem, viewWriter ) => {
+				const widgetElement = createPlaceholderView( modelItem, viewWriter );
+
+				// Enable widget handling on placeholder element inside editing view.
+				return toWidget( widgetElement, viewWriter );
+			}
+		} );
+
+		conversion.for( 'dataDowncast' ).elementToElement( {
+			model: 'placeholder',
+			view: createPlaceholderView
+		} );
+
+		// Helper method for both downcast converters.
+		function createPlaceholderView( modelItem, viewWriter ) {
+			const name = modelItem.getAttribute( 'name' );
+
+			const placeholderView = viewWriter.createContainerElement( 'span', {
+				class: 'placeholder'
+			} );
+
+			// Insert the placeholder name (as a text).
+			const innerText = viewWriter.createText( '{' + name + '}' );
+			viewWriter.insert( viewWriter.createPositionAt( placeholderView, 0 ), innerText );
+
+			return placeholderView;
+		}
+	}
+}
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ Essentials, Paragraph, Heading, List, Bold, Italic, Placeholder ],
+		toolbar: [ 'heading', '|', 'bold', 'italic', 'numberedList', 'bulletedList', '|', 'placeholder' ],
+		placeholderConfig: {
+			types: [ 'date', 'color', 'first name', 'surname' ]
+		}
+	} )
+	.then( editor => {
+		console.log( 'Editor was initialized', editor );
+
+		// Expose for playing in the console.
+		window.editor = editor;
+	} )
+	.catch( error => {
+		console.error( error.stack );
+	} );
+```

+ 3 - 1
scripts/docs/build-docs.js

@@ -17,6 +17,7 @@ const skipValidation = process.argv.includes( '--skip-validation' );
 const production = process.argv.includes( '--production' );
 const watch = process.argv.includes( '--watch' );
 const verbose = process.argv.includes( '--verbose' );
+const whitelistedSnippets = process.argv.find( item => item.startsWith( '--whitelisted-snippet=' ) );
 
 buildDocs();
 
@@ -56,6 +57,7 @@ function runUmberto( options ) {
 		},
 		skipApi: options.skipApi,
 		verbose: options.verbose,
-		watch: options.watch
+		watch: options.watch,
+		whitelistedSnippets: whitelistedSnippets ? whitelistedSnippets.replace( '--whitelisted-snippet=', '' ) : undefined
 	} );
 }