Browse Source

Merge pull request #7770 from ckeditor/i/6432-schema-is-selectable

Feature (engine): Introduced `SchemaItemDefinition#isSelectable` and `SchemaItemDefinition#isContent` properties. Closes #6432.

Other (table): The `tableCell` model element brought by the `TableEditing` plugin is no longer an object (`SchemaItemDefinition#isObject`) in the `Schema` but a selectable (`SchemaItemDefinition#isSelectable`) (see #6432).

Internal (ui): Aligned the `BalloonToolbar` plugin behavior to the new  `SchemaItemDefinition#isSelectable` property (see #6432).

Docs (engine): Extended the "Schema" deep dive guide with the new properties and methods (see #6432, #7631).

Tests (image): Aligned tests to the fact that the `tableCell` model element is no longer an object but a selectable in the schema (see #6432).

Tests (horizontal-line): Aligned tests to the fact that the `tableCell` model element is no longer an object but a selectable in the schema (see #6432).

MINOR BREAKING CHANGE (table): The `tableCell` model element brought by the `TableEditing` plugin is no longer an object (`SchemaItemDefinition#isObject`) in the `Schema` but a selectable (`SchemaItemDefinition#isSelectable`). Please update your integration code accordingly (see #6432).
Piotrek Koszuliński 5 years ago
parent
commit
579c1c851c

+ 304 - 3
packages/ckeditor5-engine/docs/framework/guides/deep-dive/schema.md

@@ -1,5 +1,6 @@
 ---
 category: framework-deep-dive
+classes: schema-deep-dive
 ---
 
 # Schema
@@ -48,6 +49,222 @@ While this would be incorrect:
 
 In addition to setting allowed structures, the schema can also define additional traits of model elements. By using the `is*` properties, a feature author may declare how a certain element should be treated by other features and the engine.
 
+Here is a table listing various model elements and their properties registered in the schema:
+
+<table>
+	<thead>
+		<tr>
+			<th rowspan="2">Schema entry</th>
+			<th colspan="6">Properties in the <a href="#defining-allowed-structures">definition</a></th>
+		</tr>
+		<tr>
+			<th><a href="#block-elements"><code>isBlock</code></a></th>
+			<th><a href="#limit-elements"><code>isLimit</code></a></th>
+			<th><a href="#object-elements"><code>isObject</code></a></th>
+			<th><a href="#inline-elements"><code>isInline</code></a></th>
+			<th><a href="#selectable-elements"><code>isSelectable</code></a></th>
+			<th><a href="#content-elements"><code>isContent</code></a></th>
+		</tr>
+	</thead>
+	<tbody>
+		<tr>
+			<td><code>$block</code></td>
+			<td class="value_positive"><code>true</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+		</tr>
+		<tr>
+			<td><code>$clipboardHolder</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_positive"><code>true</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+		</tr>
+		<tr>
+			<td><code>$marker</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+		</tr>
+		<tr>
+			<td><code>$root</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_positive"><code>true</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+		</tr>
+		<tr>
+			<td><code>$text</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_positive"><code>true</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_positive"><code>true</code></td>
+		</tr>
+		<tr>
+			<td><code>blockQuote</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+		</tr>
+		<tr>
+			<td><code>caption</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_positive"><code>true</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+		</tr>
+		<tr>
+			<td><code>codeBlock</code></td>
+			<td class="value_positive"><code>true</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+		</tr>
+		<tr>
+			<td><code>heading1</code></td>
+			<td class="value_positive"><code>true</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+		</tr>
+		<tr>
+			<td><code>heading2</code></td>
+			<td class="value_positive"><code>true</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+		</tr>
+		<tr>
+			<td><code>heading3</code></td>
+			<td class="value_positive"><code>true</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+		</tr>
+		<tr>
+			<td><code>horizontalLine</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_positive_inherited"><code>true</code><a href="#inherited1"><sup>[1]</sup></a></td>
+			<td class="value_positive"><code>true</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_positive_inherited"><code>true</code><a href="#inherited2"><sup>[2]</sup></a></td>
+			<td class="value_positive_inherited"><code>true</code><a href="#inherited3"><sup>[3]</sup></a></td>
+		</tr>
+		<tr>
+			<td><code>image</code></td>
+			<td class="value_positive"><code>true</code></td>
+			<td class="value_positive_inherited"><code>true</code><a href="#inherited1"><sup>[1]</sup></a></td>
+			<td class="value_positive"><code>true</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_positive_inherited"><code>true</code><a href="#inherited2"><sup>[2]</sup></a></td>
+			<td class="value_positive_inherited"><code>true</code><a href="#inherited3"><sup>[3]</sup></a></td>
+		</tr>
+		<tr>
+			<td><code>listItem</code></td>
+			<td class="value_positive"><code>true</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+		</tr>
+		<tr>
+			<td><code>media</code></td>
+			<td class="value_positive"><code>true</code></td>
+			<td class="value_positive_inherited"><code>true</code><a href="#inherited1"><sup>[1]</sup></a></td>
+			<td class="value_positive"><code>true</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_positive_inherited"><code>true</code><a href="#inherited2"><sup>[2]</sup></a></td>
+			<td class="value_positive_inherited"><code>true</code><a href="#inherited3"><sup>[3]</sup></a></td>
+		</tr>
+		<tr>
+			<td><code>pageBreak</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_positive_inherited"><code>true</code><a href="#inherited1"><sup>[1]</sup></a></td>
+			<td class="value_positive"><code>true</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_positive_inherited"><code>true</code><a href="#inherited2"><sup>[2]</sup></a></td>
+			<td class="value_positive_inherited"><code>true</code><a href="#inherited3"><sup>[3]</sup></a></td>
+		</tr>
+		<tr>
+			<td><code>paragraph</code></td>
+			<td class="value_positive"><code>true</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+		</tr>
+		<tr>
+			<td><code>softBreak</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_positive"><code>true</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+		</tr>
+		<tr>
+			<td><code>table</code></td>
+			<td class="value_positive"><code>true</code></td>
+			<td class="value_positive_inherited"><code>true</code><a href="#inherited1"><sup>[1]</sup></a></td>
+			<td class="value_positive"><code>true</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_positive_inherited"><code>true</code><a href="#inherited2"><sup>[2]</sup></a></td>
+			<td class="value_positive_inherited"><code>true</code><a href="#inherited3"><sup>[3]</sup></a></td>
+		</tr>
+		<tr>
+			<td><code>tableRow</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_positive"><code>true</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+		</tr>
+		<tr>
+			<td><code>tableCell</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_positive"><code>true</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_negative"><code>false</code></td>
+			<td class="value_positive"><code>true</code></td>
+			<td class="value_negative"><code>false</code></td>
+		</tr>
+	</tbody>
+</table>
+
+<info-box>
+	* <span id="inherited1">[1]</span> The value of `isLimit` is `true` for this element because all [objects](#object-elements) are automatically [limit elements](#limit-elements),
+	* <span id="inherited2">[2]</span> The value of `isSelectable` is `true` for this element because all [objects](#object-elements) are automatically [selectable elements](#selectable-elements),
+	* <span id="inherited3">[3]</span> The value of `isContent` is `true` for this element because all [objects](#object-elements) are automatically [content elements](#content-elements).
+</info-box>
+
 ### Limit elements
 
 Consider a feature like an image caption. The caption text area should construct a boundary to some internal actions:
@@ -86,9 +303,11 @@ schema.register( 'myImage', {
 The {@link module:engine/model/schema~Schema#isObject `Schema#isObject()`} can later be used to check this property.
 
 <info-box>
-	Every "object" is also a "limit" element.
+	Every "object" is automatically also:
 
-	It means that for every element with `isObject` set to `true`, {@link module:engine/model/schema~Schema#isLimit `Schema#isLimit( element )`} will always return `true`.
+	* a [limit element](#limit-elements) – for every element with `isObject` set `true`, {@link module:engine/model/schema~Schema#isLimit `Schema#isLimit( element )`} will always return `true`.
+	* a [selectable element](#selectable-elements) – for every element with `isObject` set `true`, {@link module:engine/model/schema~Schema#isSelectable `Schema#isSelectable( element )`} will always return `true`.
+	* a [content element](#content-elements) – for every element with `isObject` set `true`, {@link module:engine/model/schema~Schema#isContent `Schema#isContent( element )`} will always return `true`.
 </info-box>
 
 ### Block elements
@@ -109,6 +328,42 @@ Currently, the {@link module:engine/model/schema~SchemaItemDefinition#isInline `
 
 The support for inline elements in CKEditor 5 is so far limited to self-contained elements. Because of this, all elements marked with `isInline` should also be marked with `isObject`.
 
+### Selectable elements
+
+Elements that users can select as a whole (with all their internals) and then, for instance, copy them or apply formatting, are marked with the {@link module:engine/model/schema~SchemaItemDefinition#isSelectable `isSelectable`} property in the schema:
+
+```js
+schema.register( 'mySelectable', {
+	isSelectable: true
+} );
+```
+
+The {@link module:engine/model/schema~Schema#isSelectable `Schema#isSelectable()`} method can later be used to check this property.
+
+<info-box>
+	All [object elements](#object-elements) are selectable by default. There are other selectable elements registered in the editor, though. For instance, there is also the `tableCell` model element (rendered as a `<td>` in the editing view) that is selectable while **not** registered as an object. The {@link features/table#table-selection table selection} plugin takes advantage of this fact and allows users create rectangular selections made of multiple table cells.
+</info-box>
+
+### Content elements
+
+You can tell content model elements from other elements by looking at their representation in the editor data (you can use {@link module:editor-classic/classiceditor~ClassicEditor#getData `editor.getData()`} or {@link module:engine/model/model~Model#hasContent Model#hasContent()} to check this out).
+
+Elements such as images or media will **always** find their way into editor data and this is what makes them content elements. They are marked with the {@link module:engine/model/schema~SchemaItemDefinition#isContent `isContent`} property in the schema:
+
+```js
+schema.register( 'myImage', {
+	isContent: true
+} );
+```
+
+The {@link module:engine/model/schema~Schema#isContent `Schema#isContent()`} method can later be used to check this property.
+
+At the same time, elements like paragraphs, list items, or headings **are not** content elements because they are skipped in the editor output when they are empty. From the data perspective they are transparent unless they contain other content elements (an empty paragraph is as good as no paragraph).
+
+<info-box>
+	[Object elements](#object-elements) and [`$text`](#generic-items) are content by default.
+</info-box>
+
 ## Generic items
 
 There are three basic generic items: `$root`, `$block` and `$text`. They are defined as follows:
@@ -267,4 +522,50 @@ Finally, the schema plays a crucial role during the conversion from the view to
 	Some features may miss schema checks. If you happen to find such a scenario, do not hesitate to [report it to us](https://github.com/ckeditor/ckeditor5/issues).
 </info-box>
 
-
+<style>
+.schema-deep-dive table {
+	text-align: center;
+}
+
+.schema-deep-dive table td,
+.schema-deep-dive table th {
+	border-color: hsl(72deg 6% 16%);
+}
+
+.schema-deep-dive table thead th {
+	font-weight: bold;
+	vertical-align: middle;
+}
+
+.schema-deep-dive table thead th code {
+	white-space: nowrap;
+}
+
+.schema-deep-dive table tbody td.value_negative {
+	background: hsl(354deg, 100%, 90%);
+}
+
+.schema-deep-dive table tbody td.value_positive {
+	background: hsl(88deg, 50%, 60%);
+}
+
+.schema-deep-dive table tbody td.value_negative code,
+.schema-deep-dive table tbody td.value_positive code,
+.schema-deep-dive table tbody td.value_positive_inherited code {
+	background: none;
+	text-shadow: 0px 0px 2px hsl(0deg, 0%, 100%);
+}
+
+.schema-deep-dive table tbody td.value_positive_inherited {
+	background-image: linear-gradient(45deg, hsl(88deg, 50%, 60%) 25%, hsl(89deg, 58% ,71%) 25%, hsl(89deg, 58%, 71%) 50%, hsl(88deg, 50%, 60%) 50%, hsl(88deg, 50%, 60%) 75%, hsl(89deg, 58%, 71%) 75%, hsl(89deg, 58%, 71%) 100%);
+	background-size: 3px 3px;
+}
+
+.schema-deep-dive table tbody td sup {
+	top: -0.5em;
+	position: relative;
+	font-size: 75%;
+	line-height: 0;
+	vertical-align: baseline;
+}
+</style>

+ 11 - 8
packages/ckeditor5-engine/src/model/model.js

@@ -100,7 +100,8 @@ export default class Model {
 		} );
 		this.schema.register( '$text', {
 			allowIn: '$block',
-			isInline: true
+			isInline: true,
+			isContent: true
 		} );
 		this.schema.register( '$clipboardHolder', {
 			allowContentOf: '$root',
@@ -540,7 +541,7 @@ export default class Model {
 	 *
 	 * * any text node (`options.ignoreWhitespaces` allows controlling whether this text node must also contain
 	 * any non-whitespace characters),
-	 * * or any {@link module:engine/model/schema~Schema#isObject object element},
+	 * * or any {@link module:engine/model/schema~Schema#isContent content element},
 	 * * or any {@link module:engine/model/markercollection~Marker marker} which
 	 * {@link module:engine/model/markercollection~Marker#_affectsData affects data}.
 	 *
@@ -573,14 +574,16 @@ export default class Model {
 		}
 
 		for ( const item of range.getItems() ) {
-			if ( item.is( '$textProxy' ) ) {
-				if ( !ignoreWhitespaces ) {
-					return true;
-				} else if ( item.data.search( /\S/ ) !== -1 ) {
+			if ( this.schema.isContent( item ) ) {
+				if ( item.is( '$textProxy' ) ) {
+					if ( !ignoreWhitespaces ) {
+						return true;
+					} else if ( item.data.search( /\S/ ) !== -1 ) {
+						return true;
+					}
+				} else {
 					return true;
 				}
-			} else if ( this.schema.isObject( item ) ) {
-				return true;
 			}
 		}
 

+ 81 - 2
packages/ckeditor5-engine/src/model/schema.js

@@ -282,7 +282,13 @@ export default class Schema {
 	isObject( item ) {
 		const def = this.getDefinition( item );
 
-		return !!( def && def.isObject );
+		if ( !def ) {
+			return false;
+		}
+
+		// Note: Check out the implementation of #isLimit(), #isSelectable(), and #isContent()
+		// to understand why these three constitute an object.
+		return !!( def.isObject || ( def.isLimit && def.isSelectable && def.isContent ) );
 	}
 
 	/**
@@ -292,7 +298,7 @@ export default class Schema {
 	 *		schema.isInline( 'paragraph' ); // -> false
 	 *		schema.isInline( 'softBreak' ); // -> true
 	 *
-	 *		const text = writer.createText('foo' );
+	 *		const text = writer.createText( 'foo' );
 	 *		schema.isInline( text ); // -> true
 	 *
 	 * See the {@glink framework/guides/deep-dive/schema#inline-elements Inline elements} section of the Schema deep dive
@@ -307,6 +313,60 @@ export default class Schema {
 	}
 
 	/**
+	 * Returns `true` if the given item is defined to be
+	 * a selectable element by the {@link module:engine/model/schema~SchemaItemDefinition}'s `isSelectable` property.
+	 *
+	 *		schema.isSelectable( 'paragraph' ); // -> false
+	 *		schema.isSelectable( 'heading1' ); // -> false
+	 *		schema.isSelectable( 'image' ); // -> true
+	 *		schema.isSelectable( 'tableCell' ); // -> true
+	 *
+	 *		const text = writer.createText( 'foo' );
+	 *		schema.isSelectable( text ); // -> false
+	 *
+	 * See the {@glink framework/guides/deep-dive/schema#selectable-elements Selectable elements} section of the Schema deep dive}
+	 * guide for more details.
+	 *
+	 * @param {module:engine/model/item~Item|module:engine/model/schema~SchemaContextItem|String} item
+	 */
+	isSelectable( item ) {
+		const def = this.getDefinition( item );
+
+		if ( !def ) {
+			return false;
+		}
+
+		return !!( def.isSelectable || def.isObject );
+	}
+
+	/**
+	 * Returns `true` if the given item is defined to be
+	 * a content by the {@link module:engine/model/schema~SchemaItemDefinition}'s `isContent` property.
+	 *
+	 *		schema.isContent( 'paragraph' ); // -> false
+	 *		schema.isContent( 'heading1' ); // -> false
+	 *		schema.isContent( 'image' ); // -> true
+	 *		schema.isContent( 'horizontalLine' ); // -> true
+	 *
+	 *		const text = writer.createText( 'foo' );
+	 *		schema.isContent( text ); // -> true
+	 *
+	 * See the {@glink framework/guides/deep-dive/schema#content-elements Content elements} section of the Schema deep dive}
+	 * guide for more details.
+	 *
+	 * @param {module:engine/model/item~Item|module:engine/model/schema~SchemaContextItem|String} item
+	 */
+	isContent( item ) {
+		const def = this.getDefinition( item );
+
+		if ( !def ) {
+			return false;
+		}
+
+		return !!( def.isContent || def.isObject );
+	}
+
+	/**
 	 * Checks whether the given node (`child`) can be a child of the given context.
 	 *
 	 *		schema.checkChild( model.document.getRoot(), paragraph ); // -> false
@@ -1185,6 +1245,25 @@ mix( Schema, ObservableMixin );
  *
  * Read more about the object elements in the
  * {@glink framework/guides/deep-dive/schema#object-elements Object elements} section of the Schema deep dive} guide.
+ *
+ * @property {Boolean} isSelectable
+ * `true` when an element should be selectable as a whole by the user. Examples of selectable elements: `image`, `table`, `tableCell`, etc.
+ *
+ * **Note:** An object is also a selectable element, so
+ * {@link module:engine/model/schema~Schema#isSelectable `isSelectable()`} returns `true` for object elements automatically.
+ *
+ * Read more about selectable elements in the
+ * {@glink framework/guides/deep-dive/schema#selectable-elements Selectable elements} section of the Schema deep dive} guide.
+ *
+ * @property {Boolean} isContent
+ * An item is a content when it always finds its way to editor data output regardless of the number and type of its descendants.
+ * Examples of content elements: `$text`, `image`, `table`, etc. (but not `paragraph`, `heading1` or `tableCell`).
+ *
+ * **Note:** An object is also a content element, so
+ * {@link module:engine/model/schema~Schema#isContent `isContent()`} returns `true` for object elements automatically.
+ *
+ * Read more about content elements in the
+ * {@glink framework/guides/deep-dive/schema#content-elements Content elements} section of the Schema deep dive} guide.
  */
 
 /**

+ 16 - 13
packages/ckeditor5-engine/src/model/utils/modifyselection.js

@@ -92,41 +92,44 @@ export default function modifySelection( model, selection, options = {} ) {
 // @param {{ walker, unit, isForward, schema }} data
 // @param {module:engine/view/treewalker~TreeWalkerValue} value
 function tryExtendingTo( data, value ) {
+	const { isForward, walker, unit, schema } = data;
+	const { type, item, nextPosition } = value;
+
 	// If found text, we can certainly put the focus in it. Let's just find a correct position
 	// based on the unit.
-	if ( value.type == 'text' ) {
+	if ( type == 'text' ) {
 		if ( data.unit === 'word' ) {
-			return getCorrectWordBreakPosition( data.walker, data.isForward );
+			return getCorrectWordBreakPosition( walker, isForward );
 		}
 
-		return getCorrectPosition( data.walker, data.unit, data.isForward );
+		return getCorrectPosition( walker, unit, isForward );
 	}
 
 	// Entering an element.
-	if ( value.type == ( data.isForward ? 'elementStart' : 'elementEnd' ) ) {
-		// If it's an object, we can select it now.
-		if ( data.schema.isObject( value.item ) ) {
-			return Position._createAt( value.item, data.isForward ? 'after' : 'before' );
+	if ( type == ( isForward ? 'elementStart' : 'elementEnd' ) ) {
+		// If it's a selectable, we can select it now.
+		if ( schema.isSelectable( item ) ) {
+			return Position._createAt( item, isForward ? 'after' : 'before' );
 		}
 
 		// If text allowed on this position, extend to this place.
-		if ( data.schema.checkChild( value.nextPosition, '$text' ) ) {
-			return value.nextPosition;
+		if ( schema.checkChild( nextPosition, '$text' ) ) {
+			return nextPosition;
 		}
 	}
 	// Leaving an element.
 	else {
 		// If leaving a limit element, stop.
-		if ( data.schema.isLimit( value.item ) ) {
+		if ( schema.isLimit( item ) ) {
 			// NOTE: Fast-forward the walker until the end.
-			data.walker.skip( () => true );
+			walker.skip( () => true );
 
 			return;
 		}
 
 		// If text allowed on this position, extend to this place.
-		if ( data.schema.checkChild( value.nextPosition, '$text' ) ) {
-			return value.nextPosition;
+		if ( schema.checkChild( nextPosition, '$text' ) ) {
+			return nextPosition;
 		}
 	}
 }

+ 12 - 13
packages/ckeditor5-engine/src/model/utils/selection-post-fixer.js

@@ -22,7 +22,7 @@ import Position from '../position';
  * allows a `$text`.
  * * None of the selection's non-collapsed ranges crosses a {@link module:engine/model/schema~Schema#isLimit limit element}
  * boundary (a range must be rooted within one limit element).
- * * Only {@link module:engine/model/schema~Schema#isObject object elements} can be selected from the outside
+ * * Only {@link module:engine/model/schema~Schema#isSelectable selectable elements} can be selected from the outside
  * (e.g. `[<paragraph>foo</paragraph>]` is invalid). This rule applies independently to both selection ends, so this
  * selection is correct: `<paragraph>f[oo</paragraph><image></image>]`.
  *
@@ -154,8 +154,7 @@ function tryFixingCollapsedRange( range, schema ) {
 // @param {module:engine/model/schema~Schema} schema
 // @returns {module:engine/model/range~Range|null} Returns fixed range or null if range is valid.
 function tryFixingNonCollapsedRage( range, schema ) {
-	const start = range.start;
-	const end = range.end;
+	const { start, end } = range;
 
 	const isTextAllowedOnStart = schema.checkChild( start, '$text' );
 	const isTextAllowedOnEnd = schema.checkChild( end, '$text' );
@@ -176,13 +175,13 @@ function tryFixingNonCollapsedRage( range, schema ) {
 		// - [<block>foo</block>]    ->    <block>[foo]</block>
 		// - [<block>foo]</block>    ->    <block>[foo]</block>
 		// - <block>f[oo</block>]    ->    <block>f[oo]</block>
-		// - [<block>foo</block><object></object>]    ->    <block>[foo</block><object></object>]
+		// - [<block>foo</block><selectable></selectable>]    ->    <block>[foo</block><selectable></selectable>]
 		if ( checkSelectionOnNonLimitElements( start, end, schema ) ) {
-			const isStartObject = start.nodeAfter && schema.isObject( start.nodeAfter );
-			const fixedStart = isStartObject ? null : schema.getNearestSelectionRange( start, 'forward' );
+			const isStartBeforeSelectable = start.nodeAfter && schema.isSelectable( start.nodeAfter );
+			const fixedStart = isStartBeforeSelectable ? null : schema.getNearestSelectionRange( start, 'forward' );
 
-			const isEndObject = end.nodeBefore && schema.isObject( end.nodeBefore );
-			const fixedEnd = isEndObject ? null : schema.getNearestSelectionRange( end, 'backward' );
+			const isEndAfterSelectable = end.nodeBefore && schema.isSelectable( end.nodeBefore );
+			const fixedEnd = isEndAfterSelectable ? null : schema.getNearestSelectionRange( end, 'backward' );
 
 			// The schema.getNearestSelectionRange might return null - if that happens use original position.
 			const rangeStart = fixedStart ? fixedStart.start : start;
@@ -200,8 +199,8 @@ function tryFixingNonCollapsedRage( range, schema ) {
 	if ( isStartInLimit || isEndInLimit ) {
 		const bothInSameParent = ( start.nodeAfter && end.nodeBefore ) && start.nodeAfter.parent === end.nodeBefore.parent;
 
-		const expandStart = isStartInLimit && ( !bothInSameParent || !isInObject( start.nodeAfter, schema ) );
-		const expandEnd = isEndInLimit && ( !bothInSameParent || !isInObject( end.nodeBefore, schema ) );
+		const expandStart = isStartInLimit && ( !bothInSameParent || !isSelectable( start.nodeAfter, schema ) );
+		const expandEnd = isEndInLimit && ( !bothInSameParent || !isSelectable( end.nodeBefore, schema ) );
 
 		// Although we've already found limit element on start/end positions we must find the outer-most limit element.
 		// as limit elements might be nested directly inside (ie table > tableRow > tableCell).
@@ -285,11 +284,11 @@ function mergeIntersectingRanges( ranges ) {
 	return nonIntersectingRanges;
 }
 
-// Checks if node exists and if it's an object.
+// Checks if node exists and if it's a selectable.
 //
 // @param {module:engine/model/node~Node} node
 // @param {module:engine/model/schema~Schema} schema
 // @returns {Boolean}
-function isInObject( node, schema ) {
-	return node && schema.isObject( node );
+function isSelectable( node, schema ) {
+	return node && schema.isSelectable( node );
 }

+ 25 - 3
packages/ckeditor5-engine/tests/model/model.js

@@ -43,6 +43,7 @@ describe( 'Model', () => {
 
 		it( 'registers $text to the schema', () => {
 			expect( schema.isRegistered( '$text' ) ).to.be.true;
+			expect( schema.isContent( '$text' ) ).to.be.true;
 			expect( schema.checkChild( [ '$block' ], '$text' ) ).to.be.true;
 		} );
 
@@ -535,6 +536,10 @@ describe( 'Model', () => {
 			schema.register( 'image', {
 				isObject: true
 			} );
+			schema.register( 'content', {
+				inheritAllFrom: '$block',
+				isContent: true
+			} );
 			schema.extend( 'image', { allowIn: 'div' } );
 			schema.register( 'listItem', {
 				inheritAllFrom: '$block'
@@ -544,15 +549,19 @@ describe( 'Model', () => {
 				model,
 
 				'<div>' +
-				'<paragraph></paragraph>' +
+					'<paragraph></paragraph>' +
 				'</div>' +
 				'<paragraph>foo</paragraph>' +
 				'<div>' +
-				'<image></image>' +
+					'<image></image>' +
 				'</div>' +
 				'<listItem></listItem>' +
 				'<listItem></listItem>' +
-				'<listItem></listItem>'
+				'<listItem></listItem>' +
+				'<content>foo</content>' +
+				'<div>' +
+					'<content></content>' +
+				'</div>'
 			);
 
 			root = model.document.getRoot();
@@ -761,6 +770,19 @@ describe( 'Model', () => {
 			expect( model.hasContent( pEmpty, { ignoreMarkers: true } ) ).to.be.true;
 			expect( model.hasContent( pEmpty, { ignoreMarkers: true, ignoreWhitespaces: true } ) ).to.be.false;
 		} );
+
+		it( 'should return true for an item registered as a content (isContent=true, isObject=false) in the schema', () => {
+			const contentElement = root.getChild( 6 );
+
+			expect( model.hasContent( contentElement ) ).to.be.true;
+		} );
+
+		it( 'should return true if a range contains an item registered as a content (isContent=true, isObject=false) in the schema', () => {
+			// [<div><content></content></div>]
+			const range = new ModelRange( ModelPosition._createAt( root, 6 ), ModelPosition._createAt( root, 7 ) );
+
+			expect( model.hasContent( range ) ).to.be.true;
+		} );
 	} );
 
 	describe( 'createPositionFromPath()', () => {

+ 113 - 0
packages/ckeditor5-engine/tests/model/schema.js

@@ -401,6 +401,16 @@ describe( 'Schema', () => {
 			expect( schema.isObject( 'foo' ) ).to.be.true;
 		} );
 
+		it( 'returns true if an item is a limit, selectable, and a content at once (but not explicitely an object)', () => {
+			schema.register( 'foo', {
+				isLimit: true,
+				isSelectable: true,
+				isContent: true
+			} );
+
+			expect( schema.isObject( 'foo' ) ).to.be.true;
+		} );
+
 		it( 'returns false if an item was registered as a limit (because not all limits are objects)', () => {
 			schema.register( 'foo', {
 				isLimit: true
@@ -409,6 +419,39 @@ describe( 'Schema', () => {
 			expect( schema.isObject( 'foo' ) ).to.be.false;
 		} );
 
+		it( 'returns false if an item is a limit and a selectable but not a content ' +
+			'(because an object must always find its way into data regardless of its children)',
+		() => {
+			schema.register( 'foo', {
+				isLimit: true,
+				isSelectable: true
+			} );
+
+			expect( schema.isObject( 'foo' ) ).to.be.false;
+		} );
+
+		it( 'returns false if an item is a limit and content but not a selectable ' +
+			'(because the user must always be able to select an object)',
+		() => {
+			schema.register( 'foo', {
+				isLimit: true,
+				isContent: true
+			} );
+
+			expect( schema.isObject( 'foo' ) ).to.be.false;
+		} );
+
+		it( 'returns false if an item is a selectable and a content but not a limit ' +
+			'(because an object should never be split or crossed by the selection)',
+		() => {
+			schema.register( 'foo', {
+				isSelectable: true,
+				isContent: true
+			} );
+
+			expect( schema.isObject( 'foo' ) ).to.be.false;
+		} );
+
 		it( 'returns false if an item was not registered as an object', () => {
 			schema.register( 'foo' );
 
@@ -462,6 +505,76 @@ describe( 'Schema', () => {
 		} );
 	} );
 
+	describe( 'isSelectable()', () => {
+		it( 'should return true if an item was registered as a selectable', () => {
+			schema.register( 'foo', {
+				isSelectable: true
+			} );
+
+			expect( schema.isSelectable( 'foo' ) ).to.be.true;
+		} );
+
+		it( 'should return true if an item was registered as an object (because all objects are selectables)', () => {
+			schema.register( 'foo', {
+				isObject: true
+			} );
+
+			expect( schema.isSelectable( 'foo' ) ).to.be.true;
+		} );
+
+		it( 'should return false if an item was not registered as an object or selectable', () => {
+			schema.register( 'foo' );
+
+			expect( schema.isSelectable( 'foo' ) ).to.be.false;
+		} );
+
+		it( 'should return false if an item was not registered at all', () => {
+			expect( schema.isSelectable( 'foo' ) ).to.be.false;
+		} );
+
+		it( 'uses getDefinition()\'s item to definition normalization', () => {
+			const stub = sinon.stub( schema, 'getDefinition' ).returns( { isSelectable: true } );
+
+			expect( schema.isSelectable( 'foo' ) ).to.be.true;
+			expect( stub.calledOnce ).to.be.true;
+		} );
+	} );
+
+	describe( 'isContent()', () => {
+		it( 'should return true if an item was registered as a content', () => {
+			schema.register( 'foo', {
+				isContent: true
+			} );
+
+			expect( schema.isContent( 'foo' ) ).to.be.true;
+		} );
+
+		it( 'should return true if an item was registered as an object (because all objects are content)', () => {
+			schema.register( 'foo', {
+				isObject: true
+			} );
+
+			expect( schema.isContent( 'foo' ) ).to.be.true;
+		} );
+
+		it( 'should return false if an item was not registered as an object or a content', () => {
+			schema.register( 'foo' );
+
+			expect( schema.isContent( 'foo' ) ).to.be.false;
+		} );
+
+		it( 'should return false if an item was not registered at all', () => {
+			expect( schema.isContent( 'foo' ) ).to.be.false;
+		} );
+
+		it( 'uses getDefinition()\'s item to definition normalization', () => {
+			const stub = sinon.stub( schema, 'getDefinition' ).returns( { isContent: true } );
+
+			expect( schema.isContent( 'foo' ) ).to.be.true;
+			expect( stub.calledOnce ).to.be.true;
+		} );
+	} );
+
 	describe( 'checkChild()', () => {
 		beforeEach( () => {
 			schema.register( '$root' );

+ 1 - 1
packages/ckeditor5-engine/tests/model/selection.js

@@ -973,7 +973,7 @@ describe( 'Selection', () => {
 
 			model.schema.register( 'table', { isBlock: true, isLimit: true, isObject: true, allowIn: '$root' } );
 			model.schema.register( 'tableRow', { allowIn: 'table', isLimit: true } );
-			model.schema.register( 'tableCell', { allowIn: 'tableRow', isObject: true } );
+			model.schema.register( 'tableCell', { allowIn: 'tableRow', isLimit: true, isSelectable: true } );
 
 			model.schema.extend( 'p', { allowIn: 'tableCell' } );
 		} );

+ 70 - 0
packages/ckeditor5-engine/tests/model/utils/modifyselection.js

@@ -917,6 +917,76 @@ describe( 'DataController utils', () => {
 			);
 		} );
 
+		describe( 'selectable handling', () => {
+			beforeEach( () => {
+				model.schema.register( 'sel', {
+					isSelectable: true
+				} );
+				model.schema.extend( 'sel', { allowIn: '$root' } );
+				model.schema.extend( '$text', { allowIn: 'sel' } );
+
+				model.schema.register( 'inlineSel', {
+					allowIn: 'p',
+					isObject: true
+				} );
+				model.schema.extend( '$text', { allowIn: 'inlineSel' } );
+			} );
+
+			test(
+				'extends over next selectable element when at the end of an element',
+				'<p>foo[]</p><sel>bar</sel>',
+				'<p>foo[</p><sel>bar</sel>]'
+			);
+
+			test(
+				'extends over previous selectable element when at the beginning of an element ',
+				'<sel>bar</sel><p>[]foo</p>',
+				'[<sel>bar</sel><p>]foo</p>',
+				{ direction: 'backward' }
+			);
+
+			test(
+				'extends over selectable elements - forward',
+				'[<sel></sel>]<sel></sel>',
+				'[<sel></sel><sel></sel>]'
+			);
+
+			it( 'extends over selectable elements - backward', () => {
+				setData( model, '<sel></sel>[<sel></sel>]', { lastRangeBackward: true } );
+
+				modifySelection( model, doc.selection, { direction: 'backward' } );
+
+				expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( '[<sel></sel><sel></sel>]' );
+				expect( doc.selection.isBackward ).to.true;
+			} );
+
+			test(
+				'extends over inline selectables - forward',
+				'<p>foo[]<inlineSel>bar</inlineSel></p>',
+				'<p>foo[<inlineSel>bar</inlineSel>]</p>'
+			);
+
+			test(
+				'extends over inline selectables - backward',
+				'<p><inlineSel>bar</inlineSel>[]foo</p>',
+				'<p>[<inlineSel>bar</inlineSel>]foo</p>',
+				{ direction: 'backward' }
+			);
+
+			test(
+				'extends over empty inline selectables - forward',
+				'<p>foo[]<inlineSel></inlineSel></p>',
+				'<p>foo[<inlineSel></inlineSel>]</p>'
+			);
+
+			test(
+				'extends over empty inline selectables - backward',
+				'<p><inlineSel></inlineSel>[]foo</p>',
+				'<p>[<inlineSel></inlineSel>]foo</p>',
+				{ direction: 'backward' }
+			);
+		} );
+
 		describe( 'limits handling', () => {
 			beforeEach( () => {
 				model.schema.register( 'inlineLimit', {

+ 28 - 3
packages/ckeditor5-engine/tests/model/utils/selection-post-fixer.js

@@ -41,7 +41,8 @@ describe( 'Selection post-fixer', () => {
 			model.schema.register( 'tableCell', {
 				allowIn: 'tableRow',
 				allowAttributes: [ 'colspan', 'rowspan' ],
-				isObject: true
+				isLimit: true,
+				isSelectable: true
 			} );
 
 			model.schema.extend( '$block', { allowIn: 'tableCell' } );
@@ -1205,6 +1206,30 @@ describe( 'Selection post-fixer', () => {
 
 				expect( getModelData( model ) ).to.equal( '<div>[<div></div>]</div>' );
 			} );
+
+			it( 'should not fix #5 (selection starts before a selectable, which is not an object)', () => {
+				model.schema.register( 'div', { allowIn: '$root' } );
+				model.schema.register( 'selectable', { isSelectable: true, allowIn: 'div' } );
+				model.schema.extend( '$text', { allowIn: 'selectable' } );
+
+				setModelData( model,
+					'<div>[<selectable>foo]</selectable></div>'
+				);
+
+				expect( getModelData( model ) ).to.equal( '<div>[<selectable>foo]</selectable></div>' );
+			} );
+
+			it( 'should not fix #6 (selection ends after before a selectable, which is not an object)', () => {
+				model.schema.register( 'div', { allowIn: '$root' } );
+				model.schema.register( 'selectable', { isSelectable: true, allowIn: 'div' } );
+				model.schema.extend( '$text', { allowIn: 'selectable' } );
+
+				setModelData( model,
+					'<div><selectable>[foo</selectable>]</div>'
+				);
+
+				expect( getModelData( model ) ).to.equal( '<div><selectable>[foo</selectable>]</div>' );
+			} );
 		} );
 
 		describe( 'non-collapsed selection - inline widget scenarios', () => {
@@ -1274,7 +1299,7 @@ describe( 'Selection post-fixer', () => {
 					'<paragraph>foo</paragraph>' +
 					'<table>' +
 						'<tableRow>' +
-							'[<tableCell><paragraph>aaa</paragraph></tableCell>]' +
+							'<tableCell><paragraph>[]aaa</paragraph></tableCell>' +
 							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
 						'</tableRow>' +
 					'</table>' +
@@ -1296,7 +1321,7 @@ describe( 'Selection post-fixer', () => {
 					'<paragraph>foo</paragraph>' +
 					'<table>' +
 						'<tableRow>' +
-							'[<tableCell><paragraph>aaa</paragraph></tableCell>]' +
+							'<tableCell><paragraph>[]aaa</paragraph></tableCell>' +
 							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
 						'</tableRow>' +
 					'</table>' +

+ 1 - 1
packages/ckeditor5-horizontal-line/tests/horizontallinecommand.js

@@ -84,7 +84,7 @@ describe( 'HorizontalLineCommand', () => {
 		it( 'should be true when the selection is inside block element inside isLimit element which allows horizontal line', () => {
 			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.register( 'tableCell', { allowIn: 'tableRow', isLimit: true, isSelectable: 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' } );

+ 1 - 1
packages/ckeditor5-image/tests/image/imageinsertcommand.js

@@ -91,7 +91,7 @@ describe( 'ImageInsertCommand', () => {
 		it( 'should be true when the selection is inside block element inside isLimit element which allows image', () => {
 			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.register( 'tableCell', { allowIn: 'tableRow', isLimit: true, isSelectable: 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' } );

+ 1 - 1
packages/ckeditor5-image/tests/image/utils.js

@@ -192,7 +192,7 @@ describe( 'image widget utils', () => {
 		it( 'should be true when the selection is inside isLimit element which allows image', () => {
 			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.register( 'tableCell', { allowIn: 'tableRow', isLimit: true, isSelectable: 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' } );

+ 1 - 1
packages/ckeditor5-image/tests/imagecaption/imagecaptionediting.js

@@ -256,7 +256,7 @@ describe( 'ImageCaptionEditing', () => {
 			model.change( writer => {
 				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.register( 'tableCell', { allowIn: 'tableRow', isLimit: true, isSelectable: true } );
 				model.schema.extend( '$block', { allowIn: 'tableCell' } );
 				editor.conversion.for( 'downcast' ).elementToElement( { model: 'table', view: 'table' } );
 				editor.conversion.for( 'downcast' ).elementToElement( { model: 'tableRow', view: 'tr' } );

+ 1 - 1
packages/ckeditor5-image/tests/imageupload/imageuploadcommand.js

@@ -108,7 +108,7 @@ describe( 'ImageUploadCommand', () => {
 		it( 'should be true when the selection is inside block element inside isLimit element which allows image', () => {
 			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.register( 'tableCell', { allowIn: 'tableRow', isLimit: true, isSelectable: 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' } );

+ 1 - 1
packages/ckeditor5-page-break/tests/pagebreakcommand.js

@@ -84,7 +84,7 @@ describe( 'PageBreakCommand', () => {
 		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.register( 'tableCell', { allowIn: 'tableRow', isLimit: true, isSelectable: 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' } );

+ 2 - 2
packages/ckeditor5-table/src/tableediting.js

@@ -64,7 +64,6 @@ export default class TableEditing extends Plugin {
 		schema.register( 'table', {
 			allowWhere: '$block',
 			allowAttributes: [ 'headingRows', 'headingColumns' ],
-			isLimit: true,
 			isObject: true,
 			isBlock: true
 		} );
@@ -77,7 +76,8 @@ export default class TableEditing extends Plugin {
 		schema.register( 'tableCell', {
 			allowIn: 'tableRow',
 			allowAttributes: [ 'colspan', 'rowspan' ],
-			isObject: true
+			isLimit: true,
+			isSelectable: true
 		} );
 
 		// Allow all $block content inside table cell.

+ 10 - 0
packages/ckeditor5-table/tests/table-integration.js

@@ -185,5 +185,15 @@ describe( 'Table feature – integration', () => {
 				[ '<blockQuote><paragraph>Foo[]Bar</paragraph></blockQuote>' ]
 			] ) );
 		} );
+
+		it( 'should not make the Model#hasContent() method return "true" when an empty table cell is selected', () => {
+			setModelData( editor.model, '<table>' +
+				'<tableRow>' +
+					'[<tableCell><paragraph></paragraph></tableCell>]' +
+				'</tableRow>' +
+			'</table>' );
+
+			expect( editor.model.hasContent( editor.model.document.selection.getFirstRange() ) ).to.be.false;
+		} );
 	} );
 } );

+ 2 - 1
packages/ckeditor5-table/tests/tableediting.js

@@ -52,7 +52,6 @@ describe( 'TableEditing', () => {
 		expect( model.schema.isRegistered( 'table' ) ).to.be.true;
 		expect( model.schema.isObject( 'table' ) ).to.be.true;
 		expect( model.schema.isBlock( 'table' ) ).to.be.true;
-		expect( model.schema.isLimit( 'table' ) ).to.be.true;
 
 		expect( model.schema.checkChild( [ '$root' ], 'table' ) ).to.be.true;
 		expect( model.schema.checkAttribute( [ '$root', 'table' ], 'headingRows' ) ).to.be.true;
@@ -68,6 +67,8 @@ describe( 'TableEditing', () => {
 		// Table cell:
 		expect( model.schema.isRegistered( 'tableCell' ) ).to.be.true;
 		expect( model.schema.isLimit( 'tableCell' ) ).to.be.true;
+		expect( model.schema.isObject( 'tableCell' ) ).to.be.false;
+		expect( model.schema.isSelectable( 'tableCell' ) ).to.be.true;
 
 		expect( model.schema.checkChild( [ '$root' ], 'tableCell' ) ).to.be.false;
 		expect( model.schema.checkChild( [ 'table' ], 'tableCell' ) ).to.be.false;

+ 1 - 0
packages/ckeditor5-ui/package.json

@@ -28,6 +28,7 @@
     "@ckeditor/ckeditor5-mention": "^21.0.0",
     "@ckeditor/ckeditor5-paragraph": "^21.0.0",
     "@ckeditor/ckeditor5-horizontal-line": "^21.0.0",
+    "@ckeditor/ckeditor5-table": "^21.0.0",
     "@ckeditor/ckeditor5-typing": "^21.0.0"
   },
   "engines": {

+ 5 - 5
packages/ckeditor5-ui/src/toolbar/balloon/balloontoolbar.js

@@ -222,9 +222,9 @@ export default class BalloonToolbar extends Plugin {
 			return;
 		}
 
-		// Do not show the toolbar when there is more than one range in the selection and they fully contain object elements.
+		// Do not show the toolbar when there is more than one range in the selection and they fully contain selectable elements.
 		// See https://github.com/ckeditor/ckeditor5/issues/6443.
-		if ( selectionContainsOnlyMultipleObjects( selection, schema ) ) {
+		if ( selectionContainsOnlyMultipleSelectables( selection, schema ) ) {
 			return;
 		}
 
@@ -364,14 +364,14 @@ function getBalloonPositions( isBackward ) {
 	];
 }
 
-// Returns "true" when the selection has multiple ranges and each range contains an object
+// Returns "true" when the selection has multiple ranges and each range contains a selectable element
 // and nothing else.
 //
 // @private
 // @param {module:engine/model/selection~Selection} selection
 // @param {module:engine/model/schema~Schema} schema
 // @returns {Boolean}
-function selectionContainsOnlyMultipleObjects( selection, schema ) {
+function selectionContainsOnlyMultipleSelectables( selection, schema ) {
 	// It doesn't contain multiple objects if there is only one range.
 	if ( selection.rangeCount === 1 ) {
 		return false;
@@ -380,7 +380,7 @@ function selectionContainsOnlyMultipleObjects( selection, schema ) {
 	return [ ...selection.getRanges() ].every( range => {
 		const element = range.getContainedElement();
 
-		return element && schema.isObject( element );
+		return element && schema.isSelectable( element );
 	} );
 }
 

+ 16 - 2
packages/ckeditor5-ui/tests/toolbar/balloon/balloontoolbar.js

@@ -15,6 +15,7 @@ import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
 import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import HorizontalLine from '@ckeditor/ckeditor5-horizontal-line/src/horizontalline';
+import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import ResizeObserver from '@ckeditor/ckeditor5-utils/src/dom/resizeobserver';
 
@@ -56,7 +57,7 @@ describe( 'BalloonToolbar', () => {
 
 		return ClassicTestEditor
 			.create( editorElement, {
-				plugins: [ Paragraph, Bold, Italic, BalloonToolbar, HorizontalLine ],
+				plugins: [ Paragraph, Bold, Italic, BalloonToolbar, HorizontalLine, TableEditing ],
 				balloonToolbar: [ 'bold', 'italic' ]
 			} )
 			.then( newEditor => {
@@ -377,13 +378,26 @@ describe( 'BalloonToolbar', () => {
 
 		// https://github.com/ckeditor/ckeditor5/issues/6443
 		it( 'should not add the #toolbarView to the #_balloon when the selection contains more than one fully contained object', () => {
-			// This is for multi cell selection in tables.
 			setData( model, '[<horizontalLine></horizontalLine>]<paragraph>foo</paragraph>[<horizontalLine></horizontalLine>]' );
 
 			balloonToolbar.show();
 			sinon.assert.notCalled( balloonAddSpy );
 		} );
 
+		// https://github.com/ckeditor/ckeditor5/issues/6432
+		it( 'should not add the #toolbarView to the #_balloon when the selection contains more than one fully contained selectable', () => {
+			// This is for multi cell selection in tables.
+			setData( model, '<table>' +
+				'<tableRow>' +
+					'[<tableCell><paragraph>foo</paragraph></tableCell>]' +
+					'[<tableCell><paragraph>bar</paragraph></tableCell>]' +
+				'</tableRow>' +
+			'</table>' );
+
+			balloonToolbar.show();
+			sinon.assert.notCalled( balloonAddSpy );
+		} );
+
 		it( 'should not add #toolbarView to the #_balloon when all components inside #toolbarView are disabled', () => {
 			Array.from( balloonToolbar.toolbarView.items ).forEach( item => {
 				item.isEnabled = false;