diff --git a/packages/ckeditor5-image/src/imagecaption/imagecaptionediting.js b/packages/ckeditor5-image/src/imagecaption/imagecaptionediting.js
index 648916a478f..a926bbd56ac 100644
--- a/packages/ckeditor5-image/src/imagecaption/imagecaptionediting.js
+++ b/packages/ckeditor5-image/src/imagecaption/imagecaptionediting.js
@@ -47,11 +47,17 @@ export default class ImageCaptionEditing extends Plugin {
*/
// Schema configuration.
- schema.register( 'caption', {
- allowIn: 'image',
- allowContentOf: '$block',
- isLimit: true
- } );
+ if ( !schema.isRegistered( 'caption' ) ) {
+ schema.register( 'caption', {
+ allowIn: 'image',
+ allowContentOf: '$block',
+ isLimit: true
+ } );
+ } else {
+ schema.extend( 'caption', {
+ allowIn: 'image'
+ } );
+ }
// Add caption element to each image inserted without it.
editor.model.document.registerPostFixer( writer => this._insertMissingModelCaptionElement( writer ) );
diff --git a/packages/ckeditor5-image/tests/imagecaption/imagecaptionediting.js b/packages/ckeditor5-image/tests/imagecaption/imagecaptionediting.js
index e1f50c604a6..d78a1e33ace 100644
--- a/packages/ckeditor5-image/tests/imagecaption/imagecaptionediting.js
+++ b/packages/ckeditor5-image/tests/imagecaption/imagecaptionediting.js
@@ -9,6 +9,7 @@ import ImageCaptionEditing from '../../src/imagecaption/imagecaptionediting';
import ImageEditing from '../../src/image/imageediting';
import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting';
import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
@@ -18,6 +19,34 @@ import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
describe( 'ImageCaptionEditing', () => {
let editor, model, doc, view;
+ // FakePlugin helps check if the plugin under test extends existing schema correctly.
+ class FakePlugin extends Plugin {
+ init() {
+ const schema = this.editor.model.schema;
+ const conversion = this.editor.conversion;
+
+ schema.register( 'foo', {
+ isObject: true,
+ isBlock: true,
+ allowWhere: '$block'
+ } );
+ schema.register( 'caption', {
+ allowIn: 'foo',
+ allowContentOf: '$block',
+ isLimit: true
+ } );
+
+ conversion.elementToElement( {
+ view: 'foo',
+ model: 'foo'
+ } );
+ conversion.elementToElement( {
+ view: 'caption',
+ model: 'caption'
+ } );
+ }
+ }
+
testUtils.createSinonSandbox();
beforeEach( () => {
@@ -61,6 +90,17 @@ describe( 'ImageCaptionEditing', () => {
expect( model.schema.checkAttribute( [ '$root', 'image', 'caption' ], 'alignment' ) ).to.be.false;
} );
+ it( 'should extend caption if schema for it is already registered', async () => {
+ const { model } = await VirtualTestEditor
+ .create( {
+ plugins: [ FakePlugin, ImageCaptionEditing, ImageEditing, UndoEditing, Paragraph ]
+ } );
+
+ expect( model.schema.isRegistered( 'caption' ) ).to.be.true;
+ expect( model.schema.isLimit( 'caption' ) ).to.be.true;
+ expect( model.schema.checkChild( [ 'image' ], 'caption' ) ).to.be.true;
+ } );
+
describe( 'data pipeline', () => {
describe( 'view to model', () => {
it( 'should convert figcaption inside image figure', () => {
diff --git a/packages/ckeditor5-table/docs/_snippets/features/build-table-source.js b/packages/ckeditor5-table/docs/_snippets/features/build-table-source.js
index 9522011bdbb..a175cb1086b 100644
--- a/packages/ckeditor5-table/docs/_snippets/features/build-table-source.js
+++ b/packages/ckeditor5-table/docs/_snippets/features/build-table-source.js
@@ -13,6 +13,8 @@ import Alignment from '@ckeditor/ckeditor5-alignment/src/alignment';
import IndentBlock from '@ckeditor/ckeditor5-indent/src/indentblock';
import TableProperties from '@ckeditor/ckeditor5-table/src/tableproperties';
import TableCellProperties from '@ckeditor/ckeditor5-table/src/tablecellproperties';
+import TableCaption from '@ckeditor/ckeditor5-table/src/tablecaption';
+import Superscript from '@ckeditor/ckeditor5-basic-styles/src/superscript';
import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';
@@ -45,5 +47,7 @@ ClassicEditor.defaultConfig = {
window.ClassicEditor = ClassicEditor;
window.CKEditorPlugins = {
TableProperties,
- TableCellProperties
+ TableCellProperties,
+ TableCaption,
+ Superscript
};
diff --git a/packages/ckeditor5-table/docs/_snippets/features/table-caption.html b/packages/ckeditor5-table/docs/_snippets/features/table-caption.html
new file mode 100644
index 00000000000..ef27368da9b
--- /dev/null
+++ b/packages/ckeditor5-table/docs/_snippets/features/table-caption.html
@@ -0,0 +1,104 @@
+
+
+
+
+
+ Name
+ Mass (1024 kg)
+ Diameter (km)
+ Gravity (m/s2 )
+ Length of day (hours)
+ Distance from Sun (106 km)
+ Mean temperature (°C)
+
+
+
+
+ Mercury
+ 0.330
+ 4,879
+ 3.7
+ 4222.6
+ 57.9
+ 167
+
+
+ Venus
+ 4.87
+ 12,104
+ 8.9
+ 2802.0
+ 108.2
+ 464
+
+
+ Earth
+ 5.97
+ 12,756
+ 9.8
+ 24.0
+ 149.6
+ 15
+
+
+ Mars
+ 0.642
+ 6,792
+ 3.7
+ 24.7
+ 227.9
+ -65
+
+
+ Jupiter
+ 1898
+ 142,984
+ 23.1
+ 9.9
+ 778.6
+ -110
+
+
+ Saturn
+ 568
+ 120,536
+ 9.0
+ 10.7
+ 1433.5
+ -140
+
+
+ Uranus
+ 86.8
+ 51,118
+ 8.7
+ 17.2
+ 2872.5
+ -195
+
+
+ Neptune
+ 102
+ 49,528
+ 11.0
+ 16.1
+ 4495.1
+ -200
+
+
+ Pluto
+ 0.0146
+ 2,370
+ 0.7
+ 153.3
+ 5906.4
+ -225
+
+
+
+
+ Data about the planets of our solar system (Planetary facts taken from
+ Nasa's Planetary Fact Sheet - Metric ).
+
+
+
diff --git a/packages/ckeditor5-table/docs/_snippets/features/table-caption.js b/packages/ckeditor5-table/docs/_snippets/features/table-caption.js
new file mode 100644
index 00000000000..69fe7fdc8b4
--- /dev/null
+++ b/packages/ckeditor5-table/docs/_snippets/features/table-caption.js
@@ -0,0 +1,30 @@
+/**
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals ClassicEditor, CKEditorPlugins, console, window, document */
+
+ClassicEditor
+ .create( document.querySelector( '#snippet-table-caption' ), {
+ extraPlugins: [
+ CKEditorPlugins.TableCaption, CKEditorPlugins.Superscript
+ ],
+ table: {
+ contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells', 'toggleTableCaption' ]
+ },
+ image: {
+ toolbar: [
+ 'imageStyle:full',
+ 'imageStyle:side',
+ '|',
+ 'imageTextAlternative'
+ ]
+ }
+ } )
+ .then( editor => {
+ window.editorCaption = editor;
+ } )
+ .catch( err => {
+ console.error( err.stack );
+ } );
diff --git a/packages/ckeditor5-table/docs/api/table.md b/packages/ckeditor5-table/docs/api/table.md
index 044869e1868..64a0ced15e5 100644
--- a/packages/ckeditor5-table/docs/api/table.md
+++ b/packages/ckeditor5-table/docs/api/table.md
@@ -19,6 +19,7 @@ See the {@link features/table Table feature} guide and the documentation for the
* {@link module:table/table~Table}
* {@link module:table/tabletoolbar~TableToolbar}
* {@link module:table/tableproperties~TableProperties}
+* {@link module:table/tablecaption~TableCaption}
* {@link module:table/tablecellproperties~TableCellProperties}
* {@link module:table/tableselection~TableSelection}
* {@link module:table/tableclipboard~TableClipboard}
diff --git a/packages/ckeditor5-table/docs/features/table.md b/packages/ckeditor5-table/docs/features/table.md
index ee217926f99..060879528ab 100644
--- a/packages/ckeditor5-table/docs/features/table.md
+++ b/packages/ckeditor5-table/docs/features/table.md
@@ -15,7 +15,7 @@ CKEditor 5 offers all necessary functionality to produce advanced, visually appe
### Basic table features
-The editor bellow shows the basic set of table features focusing on the **structure and semantics**. These features allow users to insert new tables into the content, add or remove columns and rows, define headers, and merge multiple cells. It is also worth noting that you will find them out–of–the–box in all {@link builds/guides/overview ready–to–use editor builds}.
+The editor bellow shows the basic set of table features focusing on the **structure and semantics**. These features allow users to insert new tables into the content, add or remove columns and rows, define headers, add caption, and merge multiple cells. It is also worth noting that you will find them out–of–the–box in all {@link builds/guides/overview ready–to–use editor builds}.
{@snippet features/table}
@@ -35,6 +35,20 @@ Put the caret anywhere inside the table and click the **"Table properties"** but
By default, table styling tools are not included in the {@link builds/guides/overview ready–to–use editor builds} and must be installed separately. See the [installation](#table-and-cell-styling-tools-2) section to learn how to enable them in your editor.
+### Table caption
+
+The {@link module:table/tablecaption~TableCaption} plugin adds support for table captions.
+
+{@snippet features/table-caption}
+
+
+ By default, table caption feature is not included in the {@link builds/guides/overview ready–to–use editor builds} and must be installed separately. See the [installation](#table-caption-2) section to learn how to enable it in your editor.
+
+
+
+ By default, the table caption is placed above the table. You can change the placement by setting [`caption-side`](https://developer.mozilla.org/en-US/docs/Web/CSS/caption-side) in your {@link builds/guides/integration/content-styles content styles} for the `.ck-content .table > figcaption` style. Changing it to `caption-side: bottom` will display the caption below the table.
+
+
### Nesting tables
Starting from version 27.1.0 CKEditor 5 allows nesting tables inside other table's cells. This may be used for creating advanced charts or layouts based on tables. The nested table can be formatted just like a regular one.
@@ -137,6 +151,39 @@ ClassicEditor
Read more about {@link builds/guides/integration/installing-plugins installing plugins}.
+### Table caption
+
+To enable table caption feature in your editor, install the [`@ckeditor/ckeditor5-table`](https://www.npmjs.com/package/@ckeditor/ckeditor5-table) package:
+
+```
+npm install --save @ckeditor/ckeditor5-table
+```
+
+Then add the `Table`, `TableToolbar`, and **`TableCaption`** plugins to your plugin list and configure the table toolbar:
+
+```js
+import Table from '@ckeditor/ckeditor5-table/src/table';
+import TableToolbar from '@ckeditor/ckeditor5-table/src/tabletoolbar';
+import TableCaption from '@ckeditor/ckeditor5-table/src/tablecaption';
+
+ClassicEditor
+ .create( document.querySelector( '#editor' ), {
+ plugins: [ Table, TableToolbar, TableCaption, Bold, ... ],
+ toolbar: [ 'insertTable', ... ],
+ table: {
+ contentToolbar: [
+ 'toggleTableCaption'
+ ]
+ }
+ } )
+ .then( ... )
+ .catch( ... );
+```
+
+
+ Read more about {@link builds/guides/integration/installing-plugins installing plugins}.
+
+
## Configuring styling tools
@@ -460,6 +507,10 @@ The table plugins register the following UI components:
The 'tableProperties'
button
{@link module:table/tableproperties~TableProperties}
+
+ The 'toggleTableCaption'
button
+ {@link module:table/tablecaption~TableCaption}
+
The 'tableCellProperties'
button
{@link module:table/tablecellproperties~TableCellProperties}
@@ -487,7 +538,7 @@ The {@link module:table/tabletoolbar~TableToolbar} plugin introduces two balloon
'insertTable'
{@link module:table/commands/inserttablecommand~InsertTableCommand}
- {@link module:table/table~Table}
+ {@link module:table/table~Table}
'insertTableColumnLeft'
@@ -553,6 +604,11 @@ The {@link module:table/tabletoolbar~TableToolbar} plugin introduces two balloon
'splitTableCellHorizontally'
{@link module:table/commands/splitcellcommand~SplitCellCommand}
+
+ 'toggleTableCaption'
+ {@link module:table/tablecaption/toggletablecaptioncommand~ToggleTableCaptionCommand}
+ {@link module:table/tablecaption~TableCaption}
+
'tableBorderColor'
{@link module:table/tableproperties/commands/tablebordercolorcommand~TableBorderColorCommand}
diff --git a/packages/ckeditor5-table/lang/contexts.json b/packages/ckeditor5-table/lang/contexts.json
index fc742f98077..d39090733f9 100644
--- a/packages/ckeditor5-table/lang/contexts.json
+++ b/packages/ckeditor5-table/lang/contexts.json
@@ -56,5 +56,7 @@
"Align table to the right": "The label used by assistive technologies describing a button that aligns the table to the right.",
"The color is invalid. Try \"#FF0000\" or \"rgb(255,0,0)\" or \"red\".": "The localized error string that can be displayed next to color (background, border) fields that have an invalid value",
"The value is invalid. Try \"10px\" or \"2em\" or simply \"2\".": "The localized error string that can be displayed next to length (padding, border width) fields that have an invalid value.",
- "Color picker": "The label used by assistive technologies describing a button that opens a color picker, where user can choose a configured color for a certain properties (eg.: background color, color, border-color etc.)."
+ "Color picker": "The label used by assistive technologies describing a button that opens a color picker, where user can choose a configured color for a certain properties (eg.: background color, color, border-color etc.).",
+ "Toggle caption off": "The button label for the table toolbar hiding caption attached to the table.",
+ "Toggle caption on": "The button label for the table toolbar showing caption attached to the table."
}
diff --git a/packages/ckeditor5-table/src/commands/insertcolumncommand.js b/packages/ckeditor5-table/src/commands/insertcolumncommand.js
index b33cc8106d1..5f90e7780e2 100644
--- a/packages/ckeditor5-table/src/commands/insertcolumncommand.js
+++ b/packages/ckeditor5-table/src/commands/insertcolumncommand.js
@@ -52,10 +52,9 @@ export default class InsertColumnCommand extends Command {
*/
refresh() {
const selection = this.editor.model.document.selection;
+ const isAnyCellSelected = !!getSelectionAffectedTableCells( selection ).length;
- const tableParent = selection.getFirstPosition().findAncestor( 'table' );
-
- this.isEnabled = !!tableParent;
+ this.isEnabled = isAnyCellSelected;
}
/**
diff --git a/packages/ckeditor5-table/src/commands/insertrowcommand.js b/packages/ckeditor5-table/src/commands/insertrowcommand.js
index 14f95699e62..069e40bce04 100644
--- a/packages/ckeditor5-table/src/commands/insertrowcommand.js
+++ b/packages/ckeditor5-table/src/commands/insertrowcommand.js
@@ -52,10 +52,9 @@ export default class InsertRowCommand extends Command {
*/
refresh() {
const selection = this.editor.model.document.selection;
+ const isAnyCellSelected = !!getSelectionAffectedTableCells( selection ).length;
- const tableParent = selection.getFirstPosition().findAncestor( 'table' );
-
- this.isEnabled = !!tableParent;
+ this.isEnabled = isAnyCellSelected;
}
/**
diff --git a/packages/ckeditor5-table/src/commands/mergecellcommand.js b/packages/ckeditor5-table/src/commands/mergecellcommand.js
index bc4b83a50d0..49d8006fb12 100644
--- a/packages/ckeditor5-table/src/commands/mergecellcommand.js
+++ b/packages/ckeditor5-table/src/commands/mergecellcommand.js
@@ -133,7 +133,7 @@ export default class MergeCellCommand extends Command {
// First get the cell on proper direction.
const cellToMerge = this.isHorizontal ?
getHorizontalCell( tableCell, this.direction, tableUtils ) :
- getVerticalCell( tableCell, this.direction );
+ getVerticalCell( tableCell, this.direction, tableUtils );
if ( !cellToMerge ) {
return;
@@ -155,6 +155,7 @@ export default class MergeCellCommand extends Command {
//
// @param {module:engine/model/element~Element} tableCell
// @param {String} direction
+// @param {module:table/tableutils~TableUtils} tableUtils
// @returns {module:engine/model/node~Node|null}
function getHorizontalCell( tableCell, direction, tableUtils ) {
const tableRow = tableCell.parent;
@@ -195,15 +196,16 @@ function getHorizontalCell( tableCell, direction, tableUtils ) {
//
// @param {module:engine/model/element~Element} tableCell
// @param {String} direction
+// @param {module:table/tableutils~TableUtils} tableUtils
// @returns {module:engine/model/node~Node|null}
-function getVerticalCell( tableCell, direction ) {
+function getVerticalCell( tableCell, direction, tableUtils ) {
const tableRow = tableCell.parent;
const table = tableRow.parent;
const rowIndex = table.getChildIndex( tableRow );
// Don't search for mergeable cell if direction points out of the table.
- if ( ( direction == 'down' && rowIndex === table.childCount - 1 ) || ( direction == 'up' && rowIndex === 0 ) ) {
+ if ( ( direction == 'down' && rowIndex === tableUtils.getRows( table ) - 1 ) || ( direction == 'up' && rowIndex === 0 ) ) {
return;
}
diff --git a/packages/ckeditor5-table/src/commands/removerowcommand.js b/packages/ckeditor5-table/src/commands/removerowcommand.js
index 68332ee03d6..fcaf5b943ba 100644
--- a/packages/ckeditor5-table/src/commands/removerowcommand.js
+++ b/packages/ckeditor5-table/src/commands/removerowcommand.js
@@ -51,23 +51,25 @@ export default class RemoveRowCommand extends Command {
*/
execute() {
const model = this.editor.model;
+ const tableUtils = this.editor.plugins.get( 'TableUtils' );
+
const referenceCells = getSelectionAffectedTableCells( model.document.selection );
const removedRowIndexes = getRowIndexes( referenceCells );
const firstCell = referenceCells[ 0 ];
const table = firstCell.findAncestor( 'table' );
- const columnIndexToFocus = this.editor.plugins.get( 'TableUtils' ).getCellLocation( firstCell ).column;
+ const columnIndexToFocus = tableUtils.getCellLocation( firstCell ).column;
model.change( writer => {
const rowsToRemove = removedRowIndexes.last - removedRowIndexes.first + 1;
- this.editor.plugins.get( 'TableUtils' ).removeRows( table, {
+ tableUtils.removeRows( table, {
at: removedRowIndexes.first,
rows: rowsToRemove
} );
- const cellToFocus = getCellToFocus( table, removedRowIndexes.first, columnIndexToFocus );
+ const cellToFocus = getCellToFocus( table, removedRowIndexes.first, columnIndexToFocus, tableUtils.getRows( table ) );
writer.setSelection( writer.createPositionAt( cellToFocus, 0 ) );
} );
@@ -77,8 +79,9 @@ export default class RemoveRowCommand extends Command {
// Returns a cell that should be focused before removing the row, belonging to the same column as the currently focused cell.
// * If the row was not the last one, the cell to focus will be in the row that followed it (before removal).
// * If the row was the last one, the cell to focus will be in the row that preceded it (before removal).
-function getCellToFocus( table, removedRowIndex, columnToFocus ) {
- const row = table.getChild( removedRowIndex ) || table.getChild( table.childCount - 1 );
+function getCellToFocus( table, removedRowIndex, columnToFocus, tableRowCount ) {
+ // Don't go beyond last row's index.
+ const row = table.getChild( Math.min( removedRowIndex, tableRowCount - 1 ) );
// Default to first table cell.
let cellToFocus = row.getChild( 0 );
diff --git a/packages/ckeditor5-table/src/converters/downcast.js b/packages/ckeditor5-table/src/converters/downcast.js
index 540b3ecf6e6..47bd6cf3396 100644
--- a/packages/ckeditor5-table/src/converters/downcast.js
+++ b/packages/ckeditor5-table/src/converters/downcast.js
@@ -74,7 +74,8 @@ export function downcastInsertTable( options = {} ) {
for ( const tableRow of table.getChildren() ) {
const rowIndex = tableRow.index;
- if ( !viewRows.has( rowIndex ) ) {
+ // Make sure that this is a table row and not some other element (i.e., caption).
+ if ( tableRow.is( 'element', 'tableRow' ) && !viewRows.has( rowIndex ) ) {
viewRows.set( rowIndex, createTr( tableElement, tableRow, rowIndex, tableAttributes, conversionApi ) );
}
}
diff --git a/packages/ckeditor5-table/src/converters/table-caption-post-fixer.js b/packages/ckeditor5-table/src/converters/table-caption-post-fixer.js
new file mode 100644
index 00000000000..d9bcb660a3b
--- /dev/null
+++ b/packages/ckeditor5-table/src/converters/table-caption-post-fixer.js
@@ -0,0 +1,69 @@
+/**
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/converters/table-caption-post-fixer
+ */
+
+/**
+ * Injects a table caption post-fixer into the model.
+ *
+ * The role of the table caption post-fixer is to ensure that the table with caption have the correct structure
+ * after a {@link module:engine/model/model~Model#change `change()`} block was executed.
+ *
+ * The correct structure means that:
+ *
+ * * If there are many caption model element, they are merged into one model.
+ * * A final, merged caption model is placed at the end of the table.
+ *
+ * @param {module:engine/model/model~Model} model
+ */
+export default function injectTableCaptionPostFixer( model ) {
+ model.document.registerPostFixer( writer => tableCaptionPostFixer( writer, model ) );
+}
+
+// The table caption post-fixer.
+//
+// @param {module:engine/model/writer~Writer} writer
+// @param {module:engine/model/model~Model} model
+function tableCaptionPostFixer( writer, model ) {
+ const changes = model.document.differ.getChanges();
+ let wasFixed = false;
+
+ for ( const entry of changes ) {
+ if ( entry.type != 'insert' ) {
+ continue;
+ }
+
+ const positionParent = entry.position.parent;
+
+ if ( positionParent.is( 'element', 'table' ) || entry.name == 'table' ) {
+ const table = entry.name == 'table' ? entry.position.nodeAfter : entry.position.parent;
+ const captionsToMerge = Array.from( table.getChildren() ).filter( child => child.is( 'element', 'caption' ) );
+ const firstCaption = captionsToMerge.shift();
+
+ if ( !firstCaption ) {
+ continue;
+ }
+
+ // Move all the contents of the captions to the first one.
+ for ( const caption of captionsToMerge ) {
+ writer.move( writer.createRangeIn( caption ), firstCaption, 'end' );
+ writer.remove( caption );
+ }
+
+ // Make sure the final caption is at the end of the table.
+ if ( firstCaption.nextSibling ) {
+ writer.move( writer.createRangeOn( firstCaption ), table, 'end' );
+ wasFixed = true;
+ }
+
+ // Do we merged captions and/or moved the single caption to the end of the table?
+ wasFixed = !!captionsToMerge.length || wasFixed;
+ }
+ }
+
+ return wasFixed;
+}
diff --git a/packages/ckeditor5-table/src/converters/table-cell-paragraph-post-fixer.js b/packages/ckeditor5-table/src/converters/table-cell-paragraph-post-fixer.js
index 39d4ec2eae8..170b8b80082 100644
--- a/packages/ckeditor5-table/src/converters/table-cell-paragraph-post-fixer.js
+++ b/packages/ckeditor5-table/src/converters/table-cell-paragraph-post-fixer.js
@@ -70,7 +70,9 @@ function fixTable( table, writer ) {
let wasFixed = false;
for ( const row of table.getChildren() ) {
- wasFixed = fixTableRow( row, writer ) || wasFixed;
+ if ( row.is( 'element', 'tableRow' ) ) {
+ wasFixed = fixTableRow( row, writer ) || wasFixed;
+ }
}
return wasFixed;
diff --git a/packages/ckeditor5-table/src/converters/table-layout-post-fixer.js b/packages/ckeditor5-table/src/converters/table-layout-post-fixer.js
index 7eb5fda0f52..d6bb5af2920 100644
--- a/packages/ckeditor5-table/src/converters/table-layout-post-fixer.js
+++ b/packages/ckeditor5-table/src/converters/table-layout-post-fixer.js
@@ -291,12 +291,13 @@ function fixTableCellsRowspan( table, writer ) {
function fixTableRowsSizes( table, writer ) {
let wasFixed = false;
- const rowsLengths = getRowsLengths( table );
+ const childrenLengths = getChildrenLengths( table );
const rowsToRemove = [];
// Find empty rows.
- for ( const [ rowIndex, size ] of rowsLengths.entries() ) {
- if ( !size ) {
+ for ( const [ rowIndex, size ] of childrenLengths.entries() ) {
+ // Ignore all non-row models.
+ if ( !size && table.getChild( rowIndex ).is( 'element', 'tableRow' ) ) {
rowsToRemove.push( rowIndex );
}
}
@@ -309,10 +310,13 @@ function fixTableRowsSizes( table, writer ) {
for ( const rowIndex of rowsToRemove.reverse() ) {
writer.remove( table.getChild( rowIndex ) );
- rowsLengths.splice( rowIndex, 1 );
+ childrenLengths.splice( rowIndex, 1 );
}
}
+ // Filter out everything that's not a table row.
+ const rowsLengths = childrenLengths.filter( ( row, rowIndex ) => table.getChild( rowIndex ).is( 'element', 'tableRow' ) );
+
// Verify if all the rows have the same number of columns.
const tableSize = rowsLengths[ 0 ];
const isValid = rowsLengths.every( length => length === tableSize );
@@ -346,7 +350,8 @@ function fixTableRowsSizes( table, writer ) {
// @returns {Array.<{{cell, rowspan}}>}
function findCellsToTrim( table ) {
const headingRows = parseInt( table.getAttribute( 'headingRows' ) || 0 );
- const maxRows = table.childCount;
+ const maxRows = Array.from( table.getChildren() )
+ .reduce( ( count, row ) => row.is( 'element', 'tableRow' ) ? count + 1 : count, 0 );
const cellsToTrim = [];
@@ -376,12 +381,12 @@ function findCellsToTrim( table ) {
//
// @param {module:engine/model/element~Element} table
// @returns {Array.}
-function getRowsLengths( table ) {
+function getChildrenLengths( table ) {
// TableWalker will not provide items for the empty rows, we need to pre-fill this array.
const lengths = new Array( table.childCount ).fill( 0 );
- for ( const { row } of new TableWalker( table, { includeAllSlots: true } ) ) {
- lengths[ row ]++;
+ for ( const { rowIndex } of new TableWalker( table, { includeAllSlots: true } ) ) {
+ lengths[ rowIndex ]++;
}
return lengths;
diff --git a/packages/ckeditor5-table/src/converters/upcasttable.js b/packages/ckeditor5-table/src/converters/upcasttable.js
index e691e6aa2a9..1614912ff45 100644
--- a/packages/ckeditor5-table/src/converters/upcasttable.js
+++ b/packages/ckeditor5-table/src/converters/upcasttable.js
@@ -8,6 +8,51 @@
*/
import { createEmptyTableCell } from '../utils/common';
+import { first } from 'ckeditor5/src/utils';
+
+/**
+ * Returns a function that converts the table view representation:
+ *
+ *
+ *
+ * to the model representation:
+ *
+ *
+ *
+ * @returns {Function}
+ */
+export function upcastTableFigure() {
+ return dispatcher => {
+ dispatcher.on( 'element:figure', ( evt, data, conversionApi ) => {
+ // Do not convert if this is not a "table figure".
+ if ( !conversionApi.consumable.test( data.viewItem, { name: true, classes: 'table' } ) ) {
+ return;
+ }
+
+ // Find an table element inside the figure element.
+ const viewTable = getViewTableFromFigure( data.viewItem );
+
+ // Do not convert if table element is absent or was already converted.
+ if ( !viewTable || !conversionApi.consumable.test( viewTable, { name: true } ) ) {
+ return;
+ }
+
+ // Convert view table to model table.
+ const conversionResult = conversionApi.convertItem( viewTable, data.modelCursor );
+
+ // Get table element from conversion result.
+ const modelTable = first( conversionResult.modelRange.getItems() );
+
+ // When table wasn't successfully converted then finish conversion.
+ if ( !modelTable ) {
+ return;
+ }
+
+ conversionApi.convertChildren( data.viewItem, conversionApi.writer.createPositionAt( modelTable, 'end' ) );
+ conversionApi.updateConversionResult( modelTable, data );
+ } );
+ };
+}
/**
* View table element to model table element conversion helper.
@@ -50,6 +95,9 @@ export default function upcastTable() {
// Upcast table rows in proper order (heading rows first).
rows.forEach( row => conversionApi.convertItem( row, conversionApi.writer.createPositionAt( table, 'end' ) ) );
+ // Convert everything else.
+ conversionApi.convertChildren( viewTable, conversionApi.writer.createPositionAt( table, 'end' ) );
+
// Create one row and one table cell for empty table.
if ( table.isEmpty ) {
const row = conversionApi.writer.createElement( 'tableRow' );
@@ -109,12 +157,26 @@ export function ensureParagraphInTableCell( elementName ) {
};
}
+// Get view `` element from the view widget (``).
+//
+// @private
+// @param {module:engine/view/element~Element} figureView
+// @returns {module:engine/view/element~Element}
+function getViewTableFromFigure( figureView ) {
+ for ( const figureChild of figureView.getChildren() ) {
+ if ( figureChild.is( 'element', 'table' ) ) {
+ return figureChild;
+ }
+ }
+}
+
// Scans table rows and extracts required metadata from the table:
//
// headingRows - The number of rows that go as table headers.
// headingColumns - The maximum number of row headings.
// rows - Sorted `` elements as they should go into the model - ie. if `` is inserted after ` ` in the view.
//
+// @private
// @param {module:engine/view/element~Element} viewTable
// @returns {{headingRows, headingColumns, rows}}
function scanTable( viewTable ) {
@@ -186,6 +248,7 @@ function scanTable( viewTable ) {
// - For body rows:
// - Calculates the number of column headings.
//
+// @private
// @param {module:engine/view/element~Element} tr
// @returns {Number}
function scanRowForHeadingColumns( tr ) {
diff --git a/packages/ckeditor5-table/src/index.js b/packages/ckeditor5-table/src/index.js
index 332be3df8cd..bc509140357 100644
--- a/packages/ckeditor5-table/src/index.js
+++ b/packages/ckeditor5-table/src/index.js
@@ -17,6 +17,9 @@ import TableCellPropertiesUI from './tablecellproperties/tablecellpropertiesui';
import TableProperties from './tableproperties';
import TablePropertiesEditing from './tableproperties/tablepropertiesediting';
import TablePropertiesUI from './tableproperties/tablepropertiesui';
+import TableCaption from './tablecaption';
+import TableCaptionEditing from './tablecaption/tablecaptionediting';
+import TableCaptionUI from './tablecaption/tablecaptionui';
import TableClipboard from './tableclipboard';
import TableMouse from './tablemouse';
import TableKeyboard from './tablekeyboard';
@@ -34,6 +37,9 @@ export default {
TableProperties,
TablePropertiesEditing,
TablePropertiesUI,
+ TableCaption,
+ TableCaptionEditing,
+ TableCaptionUI,
TableMouse,
TableClipboard,
TableKeyboard,
diff --git a/packages/ckeditor5-table/src/tablecaption.js b/packages/ckeditor5-table/src/tablecaption.js
new file mode 100644
index 00000000000..a9ca83c9c1a
--- /dev/null
+++ b/packages/ckeditor5-table/src/tablecaption.js
@@ -0,0 +1,35 @@
+/**
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tablecaption
+ */
+
+import { Plugin } from 'ckeditor5/src/core';
+import TableCaptionEditing from './tablecaption/tablecaptionediting';
+import TableCaptionUI from './tablecaption/tablecaptionui';
+
+import '../theme/tablecaption.css';
+
+/**
+ * The table caption plugin.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class TableCaption extends Plugin {
+ /**
+ * @inheritDoc
+ */
+ static get pluginName() {
+ return 'TableCaption';
+ }
+
+ /**
+ * @inheritDoc
+ */
+ static get requires() {
+ return [ TableCaptionEditing, TableCaptionUI ];
+ }
+}
diff --git a/packages/ckeditor5-table/src/tablecaption/tablecaptionediting.js b/packages/ckeditor5-table/src/tablecaption/tablecaptionediting.js
new file mode 100644
index 00000000000..7d497316a1a
--- /dev/null
+++ b/packages/ckeditor5-table/src/tablecaption/tablecaptionediting.js
@@ -0,0 +1,153 @@
+/**
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tablecaption/tablecaptionediting
+ */
+
+import { Plugin } from 'ckeditor5/src/core';
+import { Element, enablePlaceholder } from 'ckeditor5/src/engine';
+import { toWidgetEditable } from 'ckeditor5/src/widget';
+
+import injectTableCaptionPostFixer from '../converters/table-caption-post-fixer';
+import ToggleTableCaptionCommand from './toggletablecaptioncommand';
+import { isTable, matchTableCaptionViewElement } from './utils';
+
+/**
+ * The table caption editing plugin.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class TableCaptionEditing extends Plugin {
+ /**
+ * @inheritDoc
+ */
+ static get pluginName() {
+ return 'TableCaptionEditing';
+ }
+
+ /**
+ * @inheritDoc
+ */
+ constructor( editor ) {
+ super( editor );
+
+ /**
+ * A map that keeps saved JSONified table captions and table model elements they are
+ * associated with.
+ *
+ * To learn more about this system, see {@link #_saveCaption}.
+ *
+ * @member {WeakMap.}
+ */
+ this._savedCaptionsMap = new WeakMap();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ init() {
+ const editor = this.editor;
+ const schema = editor.model.schema;
+ const view = editor.editing.view;
+ const t = editor.t;
+
+ if ( !schema.isRegistered( 'caption' ) ) {
+ schema.register( 'caption', {
+ allowIn: 'table',
+ allowContentOf: '$block',
+ isLimit: true
+ } );
+ } else {
+ schema.extend( 'caption', {
+ allowIn: 'table'
+ } );
+ }
+
+ editor.commands.add( 'toggleTableCaption', new ToggleTableCaptionCommand( this.editor ) );
+
+ // View -> model converter for the data pipeline.
+ editor.conversion.for( 'upcast' ).elementToElement( {
+ view: matchTableCaptionViewElement,
+ model: 'caption'
+ } );
+
+ // Model -> view converter for the data pipeline.
+ editor.conversion.for( 'dataDowncast' ).elementToElement( {
+ model: 'caption',
+ view: ( modelElement, { writer } ) => {
+ if ( !isTable( modelElement.parent ) ) {
+ return null;
+ }
+
+ return writer.createContainerElement( 'figcaption' );
+ }
+ } );
+
+ // Model -> view converter for the editing pipeline.
+ editor.conversion.for( 'editingDowncast' ).elementToElement( {
+ model: 'caption',
+ view: ( modelElement, { writer } ) => {
+ if ( !isTable( modelElement.parent ) ) {
+ return null;
+ }
+
+ const figcaptionElement = writer.createEditableElement( 'figcaption' );
+ writer.setCustomProperty( 'tableCaption', true, figcaptionElement );
+
+ enablePlaceholder( {
+ view,
+ element: figcaptionElement,
+ text: t( 'Enter table caption' ),
+ keepOnFocus: true
+ } );
+
+ return toWidgetEditable( figcaptionElement, writer );
+ }
+ } );
+
+ injectTableCaptionPostFixer( editor.model );
+ }
+
+ /**
+ * Returns the saved {@link module:engine/model/element~Element#toJSON JSONified} caption
+ * of a table model element.
+ *
+ * See {@link #_saveCaption}.
+ *
+ * @protected
+ * @param {module:engine/model/element~Element} tableModelElement The model element the
+ * caption should be returned for.
+ * @returns {module:engine/model/element~Element|null} The model caption element or `null` if there is none.
+ */
+ _getSavedCaption( tableModelElement ) {
+ const jsonObject = this._savedCaptionsMap.get( tableModelElement );
+
+ return jsonObject ? Element.fromJSON( jsonObject ) : null;
+ }
+
+ /**
+ * Saves a {@link module:engine/model/element~Element#toJSON JSONified} caption for
+ * a table element to allow restoring it in the future.
+ *
+ * A caption is saved every time it gets hidden. The
+ * user should be able to restore it on demand.
+ *
+ * **Note**: The caption cannot be stored in the table model element attribute because,
+ * for instance, when the model state propagates to collaborators, the attribute would get
+ * lost (mainly because it does not convert to anything when the caption is hidden) and
+ * the states of collaborators' models would de-synchronize causing numerous issues.
+ *
+ * See {@link #_getSavedCaption}.
+ *
+ * @protected
+ * @param {module:engine/model/element~Element} tableModelElement The model element the
+ * caption is saved for.
+ * @param {module:engine/model/element~Element} caption The caption model element to be saved.
+ */
+ _saveCaption( tableModelElement, caption ) {
+ this._savedCaptionsMap.set( tableModelElement, caption.toJSON() );
+ }
+}
diff --git a/packages/ckeditor5-table/src/tablecaption/tablecaptionui.js b/packages/ckeditor5-table/src/tablecaption/tablecaptionui.js
new file mode 100644
index 00000000000..1b4d43e5590
--- /dev/null
+++ b/packages/ckeditor5-table/src/tablecaption/tablecaptionui.js
@@ -0,0 +1,71 @@
+/**
+* @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
+* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+*/
+
+/**
+* @module table/tablecaption/tablecaptionui
+*/
+
+import { Plugin, icons } from 'ckeditor5/src/core';
+import { ButtonView } from 'ckeditor5/src/ui';
+
+import { getCaptionFromModelSelection } from './utils';
+
+/**
+ * The table caption UI plugin. It introduces the `'toggleTableCaption'` UI button.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class TableCaptionUI extends Plugin {
+ /**
+ * @inheritDoc
+ */
+ static get pluginName() {
+ return 'TableCaptionUI';
+ }
+
+ /**
+ * @inheritDoc
+ */
+ init() {
+ const editor = this.editor;
+ const editingView = editor.editing.view;
+ const t = editor.t;
+
+ editor.ui.componentFactory.add( 'toggleTableCaption', locale => {
+ const command = editor.commands.get( 'toggleTableCaption' );
+ const view = new ButtonView( locale );
+
+ view.set( {
+ icon: icons.caption,
+ tooltip: true,
+ isToggleable: true
+ } );
+
+ view.bind( 'isOn', 'isEnabled' ).to( command, 'value', 'isEnabled' );
+ view.bind( 'label' ).to( command, 'value', value => value ? t( 'Toggle caption off' ) : t( 'Toggle caption on' ) );
+
+ this.listenTo( view, 'execute', () => {
+ editor.execute( 'toggleTableCaption', { focusCaptionOnShow: true } );
+
+ // Scroll to the selection and highlight the caption if the caption showed up.
+ if ( command.value ) {
+ const modelCaptionElement = getCaptionFromModelSelection( editor.model.document.selection );
+ const figcaptionElement = editor.editing.mapper.toViewElement( modelCaptionElement );
+
+ if ( !figcaptionElement ) {
+ return;
+ }
+
+ editingView.scrollToTheSelection();
+ editingView.change( writer => {
+ writer.addClass( 'table__caption_highlighted', figcaptionElement );
+ } );
+ }
+ } );
+
+ return view;
+ } );
+ }
+}
diff --git a/packages/ckeditor5-table/src/tablecaption/toggletablecaptioncommand.js b/packages/ckeditor5-table/src/tablecaption/toggletablecaptioncommand.js
new file mode 100644
index 00000000000..bb0606b4c95
--- /dev/null
+++ b/packages/ckeditor5-table/src/tablecaption/toggletablecaptioncommand.js
@@ -0,0 +1,120 @@
+/**
+* @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
+* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+*/
+
+/**
+* @module table/tablecaption/toggletablecaptioncommand
+*/
+
+import { Command } from 'ckeditor5/src/core';
+
+import { getCaptionFromTableModelElement, getSelectionAffectedTable } from './utils';
+
+/**
+ * The toggle table caption command.
+ *
+ * This command is registered by {@link module:table/tablecaption/tablecaptionediting~TableCaptionEditing} as the
+ * `'toggleTableCaption'` editor command.
+ *
+ * Executing this command:
+ *
+ * * either adds or removes the table caption of a selected table (depending on whether the caption is present or not),
+ * * removes the table caption if the selection is anchored in one.
+ *
+ * // Toggle the presence of the caption.
+ * editor.execute( 'toggleTableCaption' );
+ *
+ * **Note**: You can move the selection to the caption right away as it shows up upon executing this command by using
+ * the `focusCaptionOnShow` option:
+ *
+ * editor.execute( 'toggleTableCaption', { focusCaptionOnShow: true } );
+ *
+ * @extends module:core/command~Command
+ */
+export default class ToggleTableCaptionCommand extends Command {
+ /**
+ * @inheritDoc
+ */
+ refresh() {
+ const editor = this.editor;
+ const tableElement = getSelectionAffectedTable( editor.model.document.selection );
+
+ this.isEnabled = !!tableElement;
+
+ if ( !this.isEnabled ) {
+ this.value = false;
+ } else {
+ this.value = !!getCaptionFromTableModelElement( tableElement );
+ }
+ }
+
+ /**
+ * Executes the command.
+ *
+ * editor.execute( 'toggleTableCaption' );
+ *
+ * @param {Object} [options] Options for the executed command.
+ * @param {String} [options.focusCaptionOnShow] When true and the caption shows up, the selection will be moved into it straight away.
+ * @fires execute
+ */
+ execute( options = {} ) {
+ const { focusCaptionOnShow } = options;
+
+ this.editor.model.change( writer => {
+ if ( this.value ) {
+ this._hideTableCaption( writer );
+ } else {
+ this._showTableCaption( writer, focusCaptionOnShow );
+ }
+ } );
+ }
+
+ /**
+ * Shows the table caption. Also:
+ *
+ * * it attempts to restore the caption content from the `TableCaptionEditing` caption registry,
+ * * it moves the selection to the caption right away, it the `focusCaptionOnShow` option was set.
+ *
+ * @private
+ * @param {module:engine/model/writer~Writer} writer
+ * @param {Boolean} focusCaptionOnShow Default focus behavior when showing the caption.
+ */
+ _showTableCaption( writer, focusCaptionOnShow ) {
+ const model = this.editor.model;
+ const tableElement = getSelectionAffectedTable( model.document.selection );
+ const tableCaptionEditing = this.editor.plugins.get( 'TableCaptionEditing' );
+ const savedCaptionElement = tableCaptionEditing._getSavedCaption( tableElement );
+
+ // Try restoring the caption from the TableCaptionEditing plugin storage.
+ const newCaptionElement = savedCaptionElement || writer.createElement( 'caption' );
+
+ writer.append( newCaptionElement, tableElement );
+
+ if ( focusCaptionOnShow ) {
+ writer.setSelection( newCaptionElement, 'in' );
+ }
+ }
+
+ /**
+ * Hides the caption of a selected table (or an table caption the selection is anchored to).
+ *
+ * The content of the caption is stored in the `TableCaptionEditing` caption registry to make this
+ * a reversible action.
+ *
+ * @private
+ * @param {module:engine/model/writer~Writer} writer
+ */
+ _hideTableCaption( writer ) {
+ const model = this.editor.model;
+ const tableElement = getSelectionAffectedTable( model.document.selection );
+ const tableCaptionEditing = this.editor.plugins.get( 'TableCaptionEditing' );
+ const captionElement = getCaptionFromTableModelElement( tableElement );
+
+ // Store the caption content so it can be restored quickly if the user changes their mind.
+ tableCaptionEditing._saveCaption( tableElement, captionElement );
+
+ writer.setSelection( writer.createRangeIn( tableElement.getChild( 0 ).getChild( 0 ) ) );
+ writer.remove( captionElement );
+ }
+}
diff --git a/packages/ckeditor5-table/src/tablecaption/utils.js b/packages/ckeditor5-table/src/tablecaption/utils.js
new file mode 100644
index 00000000000..dbbaca82e89
--- /dev/null
+++ b/packages/ckeditor5-table/src/tablecaption/utils.js
@@ -0,0 +1,93 @@
+/**
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tablecaption/utils
+ */
+
+/**
+ * Checks if the provided model element is a `table`.
+ *
+ * @param {module:engine/model/element~Element} modelElement Element to check if it is a table.
+ * @returns {Boolean}
+ */
+export function isTable( modelElement ) {
+ return !!modelElement && modelElement.is( 'element', 'table' );
+}
+
+/**
+ * Returns the caption model element from a given table element. Returns `null` if no caption is found.
+ *
+ * @param {module:engine/model/element~Element} tableModelElement Table element in which we will try to find a caption element.
+ * @returns {module:engine/model/element~Element|null}
+ */
+export function getCaptionFromTableModelElement( tableModelElement ) {
+ for ( const node of tableModelElement.getChildren() ) {
+ if ( node.is( 'element', 'caption' ) ) {
+ return node;
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Returns the caption model element for a model selection. Returns `null` if the selection has no caption element ancestor.
+ *
+ * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection
+ * The selection checked for caption presence.
+ * @returns {module:engine/model/element~Element|null}
+ */
+export function getCaptionFromModelSelection( selection ) {
+ const tableElement = getSelectionAffectedTable( selection );
+
+ if ( !tableElement ) {
+ return null;
+ }
+
+ return getCaptionFromTableModelElement( tableElement );
+}
+
+/**
+ * {@link module:engine/view/matcher~Matcher} pattern. Checks if a given element is a caption.
+ *
+ * There are two possible forms of the valid caption:
+ * - A `` element inside a `` element.
+ * - A `` inside a .
+ *
+ * @param {module:engine/view/element~Element} element
+ * @returns {Object|null} Returns the object accepted by {@link module:engine/view/matcher~Matcher} or `null` if the element
+ * cannot be matched.
+ */
+export function matchTableCaptionViewElement( element ) {
+ const parent = element.parent;
+
+ if ( element.name == 'figcaption' && parent && parent.name == 'figure' && parent.hasClass( 'table' ) ) {
+ return { name: true };
+ }
+
+ if ( element.name == 'caption' && parent && parent.name == 'table' ) {
+ return { name: true };
+ }
+
+ return null;
+}
+
+/**
+ * Depending on the position of the selection we either return the table under cursor or look for the table higher in the hierarchy.
+ *
+ * @param {module:engine/model/position~Position} position
+ * @returns {module:engine/model/element~Element}
+ */
+export function getSelectionAffectedTable( selection ) {
+ const selectedElement = selection.getSelectedElement();
+
+ // Is the command triggered from the `tableToolbar`?
+ if ( selectedElement && selectedElement.is( 'element', 'table' ) ) {
+ return selectedElement;
+ }
+
+ return selection.getFirstPosition().findAncestor( 'table' );
+}
diff --git a/packages/ckeditor5-table/src/tableediting.js b/packages/ckeditor5-table/src/tableediting.js
index 9b06b3bade3..635a8ace927 100644
--- a/packages/ckeditor5-table/src/tableediting.js
+++ b/packages/ckeditor5-table/src/tableediting.js
@@ -9,7 +9,7 @@
import { Plugin } from 'ckeditor5/src/core';
-import upcastTable, { ensureParagraphInTableCell, skipEmptyTableRow } from './converters/upcasttable';
+import upcastTable, { ensureParagraphInTableCell, skipEmptyTableRow, upcastTableFigure } from './converters/upcasttable';
import {
convertParagraphInTableCell,
downcastInsertCell,
@@ -82,6 +82,9 @@ export default class TableEditing extends Plugin {
isSelectable: true
} );
+ // Figure conversion.
+ conversion.for( 'upcast' ).add( upcastTableFigure() );
+
// Table conversion.
conversion.for( 'upcast' ).add( upcastTable() );
@@ -104,7 +107,7 @@ export default class TableEditing extends Plugin {
conversion.for( 'editingDowncast' ).add( downcastInsertCell() );
// Duplicates code - needed to properly refresh paragraph inside a table cell.
- editor.conversion.for( 'editingDowncast' ).elementToElement( {
+ conversion.for( 'editingDowncast' ).elementToElement( {
model: 'paragraph',
view: convertParagraphInTableCell,
converterPriority: 'high'
diff --git a/packages/ckeditor5-table/src/tablekeyboard.js b/packages/ckeditor5-table/src/tablekeyboard.js
index 475a2448d1a..ba42daae0fa 100644
--- a/packages/ckeditor5-table/src/tablekeyboard.js
+++ b/packages/ckeditor5-table/src/tablekeyboard.js
@@ -115,15 +115,16 @@ export default class TableKeyboard extends Plugin {
return;
}
+ const tableUtils = this.editor.plugins.get( 'TableUtils' );
const isLastCellInRow = currentCellIndex === tableRow.childCount - 1;
- const isLastRow = currentRowIndex === table.childCount - 1;
+ const isLastRow = currentRowIndex === tableUtils.getRows( table ) - 1;
if ( isForward && isLastRow && isLastCellInRow ) {
editor.execute( 'insertTableRowBelow' );
// Check if the command actually added a row. If `insertTableRowBelow` execution didn't add a row (because it was disabled
// or it got overwritten) set the selection over the whole table to mirror the first cell case.
- if ( currentRowIndex === table.childCount - 1 ) {
+ if ( currentRowIndex === tableUtils.getRows( table ) - 1 ) {
editor.model.change( writer => {
writer.setSelection( writer.createRangeOn( table ) );
} );
diff --git a/packages/ckeditor5-table/src/tableui.js b/packages/ckeditor5-table/src/tableui.js
index ba97d716216..3e5730d37e7 100644
--- a/packages/ckeditor5-table/src/tableui.js
+++ b/packages/ckeditor5-table/src/tableui.js
@@ -278,7 +278,11 @@ export default class TableUI extends Plugin {
const dropdownView = createDropdown( locale, SplitButtonView );
const mergeCommandName = 'mergeTableCells';
- this._fillDropdownWithListOptions( dropdownView, options );
+ // Main command.
+ const mergeCommand = editor.commands.get( mergeCommandName );
+
+ // Subcommands in the dropdown.
+ const commands = this._fillDropdownWithListOptions( dropdownView, options );
dropdownView.buttonView.set( {
label,
@@ -287,6 +291,11 @@ export default class TableUI extends Plugin {
isEnabled: true
} );
+ // Make dropdown button disabled when all options are disabled together with the main command.
+ dropdownView.bind( 'isEnabled' ).toMany( [ mergeCommand, ...commands ], 'isEnabled', ( ...areEnabled ) => {
+ return areEnabled.some( isEnabled => isEnabled );
+ } );
+
// Merge selected table cells when the main part of the split button is clicked.
this.listenTo( dropdownView.buttonView, 'execute', () => {
editor.execute( mergeCommandName );
diff --git a/packages/ckeditor5-table/src/tableutils.js b/packages/ckeditor5-table/src/tableutils.js
index 23b7ea9d8ca..211087ad354 100644
--- a/packages/ckeditor5-table/src/tableutils.js
+++ b/packages/ckeditor5-table/src/tableutils.js
@@ -7,6 +7,7 @@
* @module table/tableutils
*/
+import { CKEditorError } from 'ckeditor5/src/utils';
import { Plugin } from 'ckeditor5/src/core';
import TableWalker from './tablewalker';
@@ -153,6 +154,19 @@ export default class TableUtils extends Plugin {
const rows = this.getRows( table );
const columns = this.getColumns( table );
+ if ( insertAt > rows ) {
+ /**
+ * The `options.at` points at a row position that does not exist.
+ *
+ * @error tableutils-insertrows-insert-out-of-range
+ */
+ throw new CKEditorError(
+ 'tableutils-insertrows-insert-out-of-range',
+ this,
+ { options }
+ );
+ }
+
model.change( writer => {
const headingRows = table.getAttribute( 'headingRows' ) || 0;
@@ -261,6 +275,11 @@ export default class TableUtils extends Plugin {
// Inserting at the end and at the beginning of a table doesn't require to calculate anything special.
if ( insertAt === 0 || tableColumns === insertAt ) {
for ( const tableRow of table.getChildren() ) {
+ // Ignore non-row elements inside the table (e.g. caption).
+ if ( !tableRow.is( 'element', 'tableRow' ) ) {
+ continue;
+ }
+
createCells( columnsToInsert, writer, writer.createPositionAt( tableRow, insertAt ? 'end' : 0 ) );
}
@@ -329,9 +348,23 @@ export default class TableUtils extends Plugin {
const model = this.editor.model;
const rowsToRemove = options.rows || 1;
+ const rowCount = this.getRows( table );
const first = options.at;
const last = first + rowsToRemove - 1;
+ if ( last > rowCount - 1 ) {
+ /**
+ * The `options.at` param must point at existing row and `options.rows` must not exceed the rows in the table.
+ *
+ * @error tableutils-removerows-row-index-out-of-range
+ */
+ throw new CKEditorError(
+ 'tableutils-removerows-row-index-out-of-range',
+ this,
+ { table, options }
+ );
+ }
+
model.change( writer => {
// Removing rows from the table require that most calculations to be done prior to changing table structure.
// Preparations must be done in the same enqueueChange callback to use the current table structure.
@@ -718,6 +751,8 @@ export default class TableUtils extends Plugin {
*/
getColumns( table ) {
// Analyze first row only as all the rows should have the same width.
+ // Using the first row without checking if it's a tableRow because we expect
+ // that table will have only tableRow model elements at the beginning.
const row = table.getChild( 0 );
return [ ...row.getChildren() ].reduce( ( columns, row ) => {
@@ -728,7 +763,7 @@ export default class TableUtils extends Plugin {
}
/**
- * Returns the number of rows for a given table.
+ * Returns the number of rows for a given table. Any other element present in the table model is omitted.
*
* editor.plugins.get( 'TableUtils' ).getRows( table );
*
@@ -736,8 +771,9 @@ export default class TableUtils extends Plugin {
* @returns {Number}
*/
getRows( table ) {
- // Simple row counting, not including rowspan due to #6427.
- return table.childCount;
+ // Rowspan not included due to #6427.
+ return Array.from( table.getChildren() )
+ .reduce( ( rowCount, child ) => child.is( 'element', 'tableRow' ) ? rowCount + 1 : rowCount, 0 );
}
}
diff --git a/packages/ckeditor5-table/src/tablewalker.js b/packages/ckeditor5-table/src/tablewalker.js
index 335c29f417a..f51722601cd 100644
--- a/packages/ckeditor5-table/src/tablewalker.js
+++ b/packages/ckeditor5-table/src/tablewalker.js
@@ -159,6 +159,14 @@ export default class TableWalker {
*/
this._row = 0;
+ /**
+ * The index of the current row element in the table.
+ *
+ * @type {Number}
+ * @protected
+ */
+ this._rowIndex = 0;
+
/**
* The current column index.
*
@@ -209,13 +217,20 @@ export default class TableWalker {
* @returns {module:table/tablewalker~TableSlot} The next table walker's value.
*/
next() {
- const row = this._table.getChild( this._row );
+ const row = this._table.getChild( this._rowIndex );
// Iterator is done when there's no row (table ended) or the row is after `endRow` limit.
if ( !row || this._isOverEndRow() ) {
return { done: true };
}
+ // We step over current element when it is not a tableRow instance.
+ if ( !row.is( 'element', 'tableRow' ) ) {
+ this._rowIndex++;
+
+ return this.next();
+ }
+
if ( this._isOverEndColumn() ) {
return this._advanceToNextRow();
}
@@ -280,6 +295,7 @@ export default class TableWalker {
*/
_advanceToNextRow() {
this._row++;
+ this._rowIndex++;
this._column = 0;
this._cellIndex = 0;
this._nextCellAtColumn = -1;
@@ -465,6 +481,15 @@ class TableSlot {
*/
this._cellIndex = tableWalker._cellIndex;
+ /**
+ * The index of the current row element in the table.
+ *
+ * @readonly
+ * @member {Number}
+ * @private
+ */
+ this._rowIndex = tableWalker._rowIndex;
+
/**
* The table element.
*
@@ -505,6 +530,16 @@ class TableSlot {
return parseInt( this.cell.getAttribute( 'rowspan' ) || 1 );
}
+ /**
+ * The index of the current row element in the table.
+ *
+ * @readonly
+ * @returns {Number}
+ */
+ get rowIndex() {
+ return this._rowIndex;
+ }
+
/**
* Returns the {@link module:engine/model/position~Position} before the table slot.
*
diff --git a/packages/ckeditor5-table/src/utils/structure.js b/packages/ckeditor5-table/src/utils/structure.js
index a7abc0c674a..7e7dba80906 100644
--- a/packages/ckeditor5-table/src/utils/structure.js
+++ b/packages/ckeditor5-table/src/utils/structure.js
@@ -17,8 +17,8 @@ import { createEmptyTableCell, updateNumericAttribute } from './common';
*
* const croppedTable = cropTableToDimensions( table, {
* startRow: 1,
- * endRow: 1,
- * startColumn: 3,
+ * endRow: 3,
+ * startColumn: 1,
* endColumn: 3
* }, writer );
*
@@ -396,8 +396,9 @@ export function removeEmptyColumns( table, tableUtils ) {
*/
export function removeEmptyRows( table, tableUtils ) {
const emptyRows = [];
+ const tableRowCount = tableUtils.getRows( table );
- for ( let rowIndex = 0; rowIndex < table.childCount; rowIndex++ ) {
+ for ( let rowIndex = 0; rowIndex < tableRowCount; rowIndex++ ) {
const tableRow = table.getChild( rowIndex );
if ( tableRow.isEmpty ) {
diff --git a/packages/ckeditor5-table/src/utils/ui/widget.js b/packages/ckeditor5-table/src/utils/ui/widget.js
index af553db5498..09422e5fb7b 100644
--- a/packages/ckeditor5-table/src/utils/ui/widget.js
+++ b/packages/ckeditor5-table/src/utils/ui/widget.js
@@ -32,10 +32,14 @@ export function getSelectedTableWidget( selection ) {
* @returns {module:engine/view/element~Element|null}
*/
export function getTableWidgetAncestor( selection ) {
- const parentTable = findAncestor( 'table', selection.getFirstPosition() );
+ let parent = selection.getFirstPosition().parent;
- if ( parentTable && isTableWidget( parentTable.parent ) ) {
- return parentTable.parent;
+ while ( parent ) {
+ if ( parent.is( 'element' ) && isTableWidget( parent ) ) {
+ return parent;
+ }
+
+ parent = parent.parent;
}
return null;
@@ -48,15 +52,3 @@ export function getTableWidgetAncestor( selection ) {
function isTableWidget( viewElement ) {
return !!viewElement.getCustomProperty( 'table' ) && isWidget( viewElement );
}
-
-function findAncestor( parentName, positionOrElement ) {
- let parent = positionOrElement.parent;
-
- while ( parent ) {
- if ( parent.name === parentName ) {
- return parent;
- }
-
- parent = parent.parent;
- }
-}
diff --git a/packages/ckeditor5-table/tests/commands/insertcolumncommand.js b/packages/ckeditor5-table/tests/commands/insertcolumncommand.js
index 8e25b0cd8ea..88b2a0ee2cb 100644
--- a/packages/ckeditor5-table/tests/commands/insertcolumncommand.js
+++ b/packages/ckeditor5-table/tests/commands/insertcolumncommand.js
@@ -214,6 +214,27 @@ describe( 'InsertColumnCommand', () => {
] ) );
} );
} );
+
+ it( 'should be false when non-cell elements are in the selection', () => {
+ model.schema.register( 'foo', {
+ allowIn: 'table',
+ allowContentOf: '$block'
+ } );
+ editor.conversion.elementToElement( {
+ model: 'foo',
+ view: 'foo'
+ } );
+
+ setData( model,
+ '' +
+ '' +
+ ' ' +
+ ' ' +
+ 'bar[] ' +
+ '
'
+ );
+ expect( command.isEnabled ).to.be.false;
+ } );
} );
describe( 'order=left', () => {
@@ -381,5 +402,26 @@ describe( 'InsertColumnCommand', () => {
], { headingColumns: 5 } ) );
} );
} );
+
+ it( 'should be false when non-cell elements are in the selection', () => {
+ model.schema.register( 'foo', {
+ allowIn: 'table',
+ allowContentOf: '$block'
+ } );
+ editor.conversion.elementToElement( {
+ model: 'foo',
+ view: 'foo'
+ } );
+
+ setData( model,
+ '' +
+ '' +
+ ' ' +
+ ' ' +
+ 'bar[] ' +
+ '
'
+ );
+ expect( command.isEnabled ).to.be.false;
+ } );
} );
} );
diff --git a/packages/ckeditor5-table/tests/commands/insertrowcommand.js b/packages/ckeditor5-table/tests/commands/insertrowcommand.js
index c7c8b3c442b..4f804eac5c1 100644
--- a/packages/ckeditor5-table/tests/commands/insertrowcommand.js
+++ b/packages/ckeditor5-table/tests/commands/insertrowcommand.js
@@ -309,6 +309,27 @@ describe( 'InsertRowCommand', () => {
] ) );
} );
} );
+
+ it( 'should be false when non-cell elements are in the selection', () => {
+ model.schema.register( 'foo', {
+ allowIn: 'table',
+ allowContentOf: '$block'
+ } );
+ editor.conversion.elementToElement( {
+ model: 'foo',
+ view: 'foo'
+ } );
+
+ setData( model,
+ '' +
+ '' +
+ ' ' +
+ ' ' +
+ 'bar[] ' +
+ '
'
+ );
+ expect( command.isEnabled ).to.be.false;
+ } );
} );
describe( 'order=above', () => {
@@ -471,5 +492,26 @@ describe( 'InsertRowCommand', () => {
] ) );
} );
} );
+
+ it( 'should be false when non-cell elements are in the selection', () => {
+ model.schema.register( 'foo', {
+ allowIn: 'table',
+ allowContentOf: '$block'
+ } );
+ editor.conversion.elementToElement( {
+ model: 'foo',
+ view: 'foo'
+ } );
+
+ setData( model,
+ '' +
+ '' +
+ ' ' +
+ ' ' +
+ 'bar[] ' +
+ '
'
+ );
+ expect( command.isEnabled ).to.be.false;
+ } );
} );
} );
diff --git a/packages/ckeditor5-table/tests/commands/mergecellcommand.js b/packages/ckeditor5-table/tests/commands/mergecellcommand.js
index bec32a5e01b..18e9100fc05 100644
--- a/packages/ckeditor5-table/tests/commands/mergecellcommand.js
+++ b/packages/ckeditor5-table/tests/commands/mergecellcommand.js
@@ -607,6 +607,30 @@ describe( 'MergeCellCommand', () => {
expect( command.value ).to.be.undefined;
} );
+ it( 'should be undefined if in last row - ignore non-row elements', () => {
+ model.schema.register( 'foo', {
+ allowIn: 'table',
+ allowContentOf: '$block',
+ isLimit: true
+ } );
+
+ setData( model,
+ '' +
+ '' +
+ '00 ' +
+ '10 ' +
+ ' ' +
+ '' +
+ '10[] ' +
+ '11 ' +
+ ' ' +
+ 'An extra element ' +
+ '
'
+ );
+
+ expect( command.value ).to.be.undefined;
+ } );
+
it( 'should be set to mergeable cell with the same rowspan', () => {
setData( model, modelTable( [
[ { colspan: 2, contents: '00[]' }, '02' ],
diff --git a/packages/ckeditor5-table/tests/commands/removerowcommand.js b/packages/ckeditor5-table/tests/commands/removerowcommand.js
index ed628de7698..2e147d01e85 100644
--- a/packages/ckeditor5-table/tests/commands/removerowcommand.js
+++ b/packages/ckeditor5-table/tests/commands/removerowcommand.js
@@ -487,6 +487,45 @@ describe( 'RemoveRowCommand', () => {
] ) );
} );
+ it( 'should remove last row - ignore non-row elements', () => {
+ model.schema.register( 'foo', {
+ allowIn: 'table',
+ allowContentOf: '$block',
+ isLimit: true
+ } );
+
+ editor.conversion.elementToElement( {
+ view: 'foo',
+ model: 'foo'
+ } );
+
+ setData( model,
+ '' +
+ '' +
+ '00 ' +
+ '01 ' +
+ ' ' +
+ '' +
+ '[]10 ' +
+ '11 ' +
+ ' ' +
+ 'An extra element ' +
+ '
'
+ );
+
+ command.execute();
+
+ assertEqualMarkup( getData( model ),
+ '' +
+ '' +
+ '[]00 ' +
+ '01 ' +
+ ' ' +
+ 'An extra element ' +
+ '
'
+ );
+ } );
+
it( 'should change heading rows if removing a heading row', () => {
setData( model, modelTable( [
[ '00', '01' ],
diff --git a/packages/ckeditor5-table/tests/converters/table-caption-post-fixer.js b/packages/ckeditor5-table/tests/converters/table-caption-post-fixer.js
new file mode 100644
index 00000000000..a97fa5c7eb6
--- /dev/null
+++ b/packages/ckeditor5-table/tests/converters/table-caption-post-fixer.js
@@ -0,0 +1,256 @@
+/**
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import { getData as getModelData, parse, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
+
+import TableEditing from '../../src/tableediting';
+import TableCaptionEditing from '../../src/tablecaption/tablecaptionediting';
+import TableWalker from '../../src/tablewalker';
+
+describe( 'Table caption post-fixer', () => {
+ let editor, model, root;
+
+ beforeEach( () => {
+ return VirtualTestEditor
+ .create( {
+ plugins: [ TableEditing, TableCaptionEditing, Paragraph ]
+ } )
+ .then( newEditor => {
+ editor = newEditor;
+ model = editor.model;
+ root = model.document.getRoot();
+ } );
+ } );
+
+ afterEach( () => {
+ editor.destroy();
+ } );
+
+ describe( 'on insert table', () => {
+ it( 'should merge many captions into one', () => {
+ const modelTable =
+ '' +
+ '' +
+ '' +
+ '00 ' +
+ ' ' +
+ '' +
+ '01 ' +
+ ' ' +
+ ' ' +
+ 'caption 0 ' +
+ 'caption 1 ' +
+ '
';
+ const parsed = parse( modelTable, model.schema );
+
+ model.change( writer => {
+ writer.remove( writer.createRangeIn( root ) );
+ writer.insert( parsed, root );
+ } );
+
+ assertEqualMarkup( getModelData( model, { withoutSelection: true } ),
+ '' +
+ '' +
+ '' +
+ '00 ' +
+ ' ' +
+ '' +
+ '01 ' +
+ ' ' +
+ ' ' +
+ 'caption 0caption 1 ' +
+ '
'
+ );
+ } );
+
+ it( 'should merge all captions in between the rows', () => {
+ const modelTable =
+ '' +
+ 'caption 0 ' +
+ '' +
+ '' +
+ '00 ' +
+ ' ' +
+ '' +
+ '01 ' +
+ ' ' +
+ ' ' +
+ 'caption 1 ' +
+ '' +
+ '' +
+ '10 ' +
+ ' ' +
+ '' +
+ '11 ' +
+ ' ' +
+ ' ' +
+ 'caption 2 ' +
+ '
';
+ const parsed = parse( modelTable, model.schema );
+
+ model.change( writer => {
+ writer.remove( writer.createRangeIn( root ) );
+ writer.insert( parsed, root );
+ } );
+
+ assertEqualMarkup( getModelData( model, { withoutSelection: true } ),
+ '' +
+ '' +
+ '' +
+ '00 ' +
+ ' ' +
+ '' +
+ '01 ' +
+ ' ' +
+ ' ' +
+ '' +
+ '' +
+ '10 ' +
+ ' ' +
+ '' +
+ '11 ' +
+ ' ' +
+ ' ' +
+ 'caption 0caption 1caption 2 ' +
+ '
'
+ );
+ } );
+
+ it( 'should merge all captions in between the rows (and TableWalker should still provide valid rows)', () => {
+ const modelTable =
+ '' +
+ 'caption 0 ' +
+ '' +
+ '' +
+ '00 ' +
+ ' ' +
+ '' +
+ '01 ' +
+ ' ' +
+ ' ' +
+ 'caption 1 ' +
+ '' +
+ '' +
+ '10 ' +
+ ' ' +
+ '' +
+ '11 ' +
+ ' ' +
+ ' ' +
+ 'caption 2 ' +
+ '
';
+ const parsed = parse( modelTable, model.schema );
+
+ model.change( writer => {
+ writer.remove( writer.createRangeIn( root ) );
+ writer.insert( parsed, root );
+
+ const slots = Array.from( new TableWalker( root.getChild( 0 ) ) );
+
+ expect( slots.length ).to.equal( 4 );
+ expect( slots[ 0 ].row ).to.equal( 0 );
+ expect( slots[ 0 ].column ).to.equal( 0 );
+ expect( slots[ 1 ].row ).to.equal( 0 );
+ expect( slots[ 1 ].column ).to.equal( 1 );
+ expect( slots[ 2 ].row ).to.equal( 1 );
+ expect( slots[ 2 ].column ).to.equal( 0 );
+ expect( slots[ 3 ].row ).to.equal( 1 );
+ expect( slots[ 3 ].column ).to.equal( 1 );
+ } );
+
+ assertEqualMarkup( getModelData( model, { withoutSelection: true } ),
+ '' +
+ '' +
+ '' +
+ '00 ' +
+ ' ' +
+ '' +
+ '01 ' +
+ ' ' +
+ ' ' +
+ '' +
+ '' +
+ '10 ' +
+ ' ' +
+ '' +
+ '11 ' +
+ ' ' +
+ ' ' +
+ 'caption 0caption 1caption 2 ' +
+ '
'
+ );
+ } );
+
+ it( 'should move final caption at the end of the table', () => {
+ const modelTable =
+ '' +
+ 'caption 0 ' +
+ '' +
+ '' +
+ '00 ' +
+ ' ' +
+ '' +
+ '01 ' +
+ ' ' +
+ ' ' +
+ '
';
+ const parsed = parse( modelTable, model.schema );
+
+ model.change( writer => {
+ writer.remove( writer.createRangeIn( root ) );
+ writer.insert( parsed, root );
+ } );
+
+ assertEqualMarkup( getModelData( model, { withoutSelection: true } ),
+ '' +
+ '' +
+ '' +
+ '00 ' +
+ ' ' +
+ '' +
+ '01 ' +
+ ' ' +
+ ' ' +
+ 'caption 0 ' +
+ '
'
+ );
+ } );
+
+ it( 'should place new caption at the end of the table model', () => {
+ setModelData( model,
+ '' +
+ '' +
+ '' +
+ 'xyz ' +
+ ' ' +
+ ' ' +
+ '
'
+ );
+
+ model.change( writer => {
+ const caption = writer.createElement( 'caption' );
+
+ writer.insertText( 'foobar', caption, 'end' );
+
+ // Insert new caption at the beginning of the table (before first row).
+ writer.insert( caption, writer.createPositionFromPath( editor.model.document.getRoot(), [ 0, 0 ] ) );
+ } );
+
+ assertEqualMarkup( getModelData( model, { withoutSelection: true } ),
+ '' +
+ '' +
+ '' +
+ 'xyz ' +
+ ' ' +
+ ' ' +
+ 'foobar ' +
+ '
'
+ );
+ } );
+ } );
+} );
diff --git a/packages/ckeditor5-table/tests/converters/table-cell-paragraph-post-fixer.js b/packages/ckeditor5-table/tests/converters/table-cell-paragraph-post-fixer.js
index 87e8ad1e82d..31afbc3d99c 100644
--- a/packages/ckeditor5-table/tests/converters/table-cell-paragraph-post-fixer.js
+++ b/packages/ckeditor5-table/tests/converters/table-cell-paragraph-post-fixer.js
@@ -30,6 +30,29 @@ describe( 'Table cell paragraph post-fixer', () => {
editor.destroy();
} );
+ it( 'should omit elements that are not table rows (on table insert)', () => {
+ model.schema.register( 'foo', {
+ allowIn: 'table',
+ allowContentOf: '$block'
+ } );
+ editor.conversion.elementToElement( {
+ model: 'foo',
+ view: 'foo'
+ } );
+
+ setModelData( model,
+ '' +
+ '' +
+ 'bar' +
+ ' ' +
+ '
'
+ );
+
+ assertEqualMarkup( getModelData( model, { withoutSelection: true } ),
+ ''
+ );
+ } );
+
it( 'should add a paragraph to an empty table cell (on table insert)', () => {
setModelData( model,
'' +
diff --git a/packages/ckeditor5-table/tests/converters/upcasttable.js b/packages/ckeditor5-table/tests/converters/upcasttable.js
index 35a4e017f5f..9f10791efb9 100644
--- a/packages/ckeditor5-table/tests/converters/upcasttable.js
+++ b/packages/ckeditor5-table/tests/converters/upcasttable.js
@@ -70,11 +70,26 @@ describe( 'upcastTable()', () => {
} );
it( 'should not convert empty figure', () => {
- ' ';
+ editor.setData( ' ' );
expectModel( ' ' );
} );
+ it( 'should not convert if table was not converted', () => {
+ // Test a case when a conversion of a table inside a figure is not returning anything.
+ // Either because of a failed conversion or if the table was already consumed.
+ editor.conversion.for( 'upcast' ).add( dispatcher => {
+ dispatcher.on( 'element:table', ( evt, data, conversionApi ) => {
+ conversionApi.consumable.consume( data.viewItem, { name: true } );
+
+ data.modelRange = conversionApi.writer.createRange( data.modelCursor );
+ }, { priority: 'highest' } );
+ } );
+ editor.setData( ' ' );
+
+ expectModel( 'xyz ' );
+ } );
+
it( 'should convert if figure do not have class="table" attribute', () => {
editor.setData(
'' +
diff --git a/packages/ckeditor5-table/tests/manual/tablecaption.html b/packages/ckeditor5-table/tests/manual/tablecaption.html
new file mode 100644
index 00000000000..91a04320ac8
--- /dev/null
+++ b/packages/ckeditor5-table/tests/manual/tablecaption.html
@@ -0,0 +1,45 @@
+
+
+
+
+
+ 0
+ 1
+ 2
+ 3
+ 4
+
+
+
+
+ a
+ b
+ c
+ d
+ e
+
+
+ f
+ g
+ h
+ i
+ j
+
+
+ k
+ l
+ m
+ n
+ o
+
+
+ p
+ q
+ r
+ s
+ t
+
+
+
+
+
diff --git a/packages/ckeditor5-table/tests/manual/tablecaption.js b/packages/ckeditor5-table/tests/manual/tablecaption.js
new file mode 100644
index 00000000000..04d309ce250
--- /dev/null
+++ b/packages/ckeditor5-table/tests/manual/tablecaption.js
@@ -0,0 +1,41 @@
+/**
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals console, document, window */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import Table from '../../src/table';
+import TableToolbar from '../../src/tabletoolbar';
+import TableSelection from '../../src/tableselection';
+import TableClipboard from '../../src/tableclipboard';
+import TableProperties from '../../src/tableproperties';
+import TableCellProperties from '../../src/tablecellproperties';
+import TableCaption from '../../src/tablecaption';
+
+ClassicEditor
+ .create( document.querySelector( '#editor' ), {
+ plugins: [
+ ArticlePluginSet, Table, TableToolbar, TableSelection, TableClipboard, TableProperties, TableCellProperties, TableCaption
+ ],
+ toolbar: [
+ 'heading', '|',
+ 'insertTable', '|',
+ 'bold', 'italic', 'link', '|',
+ 'bulletedList', 'numberedList', 'blockQuote', '|',
+ 'undo', 'redo'
+ ],
+ table: {
+ contentToolbar: [
+ 'tableColumn', 'tableRow', 'mergeTableCells', 'tableProperties', 'tableCellProperties', 'toggleTableCaption'
+ ]
+ }
+ } )
+ .then( editor => {
+ window.editor = editor;
+ } )
+ .catch( err => {
+ console.error( err.stack );
+ } );
diff --git a/packages/ckeditor5-table/tests/manual/tablecaption.md b/packages/ckeditor5-table/tests/manual/tablecaption.md
new file mode 100644
index 00000000000..cafc325fada
--- /dev/null
+++ b/packages/ckeditor5-table/tests/manual/tablecaption.md
@@ -0,0 +1,19 @@
+### Table Caption
+
+Adding a caption to a table
+
+1. Put selection in the table.
+1. In the opened toolbar click the last icon on the right to turn on the caption.
+1. The table caption should:
+ - show up **above** the table with a highlight effect
+ - be focused
+ - have a placeholder `Enter table caption` visible
+
+Editing the caption
+
+1. Once there is a caption visible, type in any text.
+1. Turn off the caption.
+1. Toolbar button for toggling the caption should change from `Toggle caption off` to `Toggle caption on`.
+1. Turn on the caption.
+1. Toolbar button for toggling the caption should change from `Toggle caption on` to `Toggle caption off`.
+1. The text typed previously should be kept in the caption.
diff --git a/packages/ckeditor5-table/tests/tablecaption/tablecaptionediting.js b/packages/ckeditor5-table/tests/tablecaption/tablecaptionediting.js
new file mode 100644
index 00000000000..981de3c8aa4
--- /dev/null
+++ b/packages/ckeditor5-table/tests/tablecaption/tablecaptionediting.js
@@ -0,0 +1,314 @@
+/**
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
+import TableCaptionEditing from '../../src/tablecaption/tablecaptionediting';
+import TableEditing from '../../src/tableediting';
+
+describe( 'TableCaptionEditing', () => {
+ let editor, model, view;
+
+ // FakePlugin helps check if the plugin under test extends existing schema correctly.
+ class FakePlugin extends Plugin {
+ init() {
+ const schema = this.editor.model.schema;
+ const conversion = this.editor.conversion;
+
+ schema.register( 'foo', {
+ isObject: true,
+ isBlock: true,
+ allowWhere: '$block'
+ } );
+ schema.register( 'caption', {
+ allowIn: 'foo',
+ allowContentOf: '$block',
+ isLimit: true
+ } );
+
+ conversion.elementToElement( {
+ view: 'foo',
+ model: 'foo'
+ } );
+ conversion.elementToElement( {
+ view: 'caption',
+ model: 'caption'
+ } );
+ }
+ }
+
+ beforeEach( () => {
+ return VirtualTestEditor
+ .create( {
+ plugins: [ TableEditing, TableCaptionEditing, Paragraph, TableCaptionEditing ]
+ } )
+ .then( newEditor => {
+ editor = newEditor;
+ view = editor.editing.view;
+ model = editor.model;
+ } );
+ } );
+
+ afterEach( () => {
+ editor.destroy();
+ } );
+
+ it( 'should have pluginName', () => {
+ expect( TableCaptionEditing.pluginName ).to.equal( 'TableCaptionEditing' );
+ } );
+
+ it( 'should set proper schema rules', () => {
+ expect( model.schema.checkChild( [ '$root', 'table' ], 'caption' ) ).to.be.true;
+ expect( model.schema.checkChild( [ '$root', 'table', 'caption' ], '$text' ) ).to.be.true;
+ expect( model.schema.isLimit( 'caption' ) ).to.be.true;
+
+ expect( model.schema.checkChild( [ '$root', 'table', 'caption' ], 'caption' ) ).to.be.false;
+ } );
+
+ it( 'should extend caption if schema for it is already registered', async () => {
+ const { model } = await VirtualTestEditor
+ .create( {
+ plugins: [ FakePlugin, TableEditing, TableCaptionEditing, Paragraph, TableCaptionEditing ]
+ } );
+
+ expect( model.schema.isRegistered( 'caption' ) ).to.be.true;
+ expect( model.schema.isLimit( 'caption' ) ).to.be.true;
+ expect( model.schema.checkChild( [ 'table' ], 'caption' ) ).to.be.true;
+ } );
+
+ describe( 'data pipeline', () => {
+ describe( 'model to view', () => {
+ it( 'should not convert caption outside of the table', async () => {
+ const editor = await VirtualTestEditor
+ .create( {
+ plugins: [
+ FakePlugin,
+ TableEditing, TableCaptionEditing, Paragraph, TableCaptionEditing ]
+ } );
+
+ setModelData( editor.model,
+ '' +
+ 'Foo caption ' +
+ ' '
+ );
+
+ expect( editor.getData() ).to.equal(
+ '' +
+ 'Foo caption ' +
+ ' '
+ );
+ } );
+
+ it( 'should convert to figure > table + figcaption', () => {
+ setModelData( model,
+ '' +
+ '' +
+ '' +
+ 'foobar ' +
+ ' ' +
+ ' ' +
+ 'Foo caption ' +
+ '
'
+ );
+
+ expect( editor.getData() ).to.equal(
+ '' +
+ '' +
+ '' +
+ '' +
+ 'foobar ' +
+ ' ' +
+ ' ' +
+ '
' +
+ 'Foo caption ' +
+ ' '
+ );
+ } );
+
+ it( 'should merge many captions into one', () => {
+ setModelData( model,
+ '' +
+ '' +
+ '' +
+ 'xyz ' +
+ ' ' +
+ ' ' +
+ 'foo ' +
+ 'bar ' +
+ '
'
+ );
+
+ expect( editor.getData() ).to.equal(
+ '' +
+ '' +
+ '' +
+ '' +
+ 'xyz ' +
+ ' ' +
+ ' ' +
+ '
' +
+ 'foobar ' +
+ ' '
+ );
+ } );
+
+ it( 'should place new caption at the end of the table model', () => {
+ setModelData( model,
+ '' +
+ '' +
+ '' +
+ 'xyz ' +
+ ' ' +
+ ' ' +
+ '
'
+ );
+
+ model.change( writer => {
+ const caption = writer.createElement( 'caption' );
+
+ writer.insertText( 'foobar', caption, 'end' );
+
+ // Insert new caption at the beginning of the table (before first row).
+ writer.insert( caption, writer.createPositionFromPath( editor.model.document.getRoot(), [ 0, 0 ] ) );
+ } );
+
+ expect( editor.getData() ).to.equal(
+ '' +
+ '' +
+ '' +
+ '' +
+ 'xyz ' +
+ ' ' +
+ ' ' +
+ '
' +
+ 'foobar ' +
+ ' '
+ );
+ } );
+ } );
+
+ describe( 'view to model', () => {
+ it( 'should convert a table with ', () => {
+ editor.setData(
+ '' +
+ 'Foo caption ' +
+ '' +
+ '' +
+ '' +
+ 'foobar' +
+ ' ' +
+ ' ' +
+ ' ' +
+ '
'
+ );
+
+ expect( getModelData( model, { withoutSelection: true } ) )
+ .to.equal( String(
+ '' +
+ '' +
+ '' +
+ 'foobar ' +
+ ' ' +
+ ' ' +
+ 'Foo caption ' +
+ '
'
+ ) );
+ } );
+
+ it( 'should convert a table inside with preceding the table', () => {
+ editor.setData(
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ 'foobar' +
+ ' ' +
+ ' ' +
+ ' ' +
+ '
' +
+ 'Foo caption ' +
+ ' '
+ );
+
+ expect( getModelData( model, { withoutSelection: true } ) )
+ .to.equal( String(
+ '' +
+ '' +
+ '' +
+ 'foobar ' +
+ ' ' +
+ ' ' +
+ '' +
+ 'Foo caption' +
+ ' ' +
+ '
'
+ ) );
+ } );
+
+ it( 'should not convert a inside that has no class="table"', () => {
+ editor.setData(
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ 'foobar' +
+ ' ' +
+ ' ' +
+ ' ' +
+ '
' +
+ 'Foo caption ' +
+ ' '
+ );
+
+ expect( getModelData( model, { withoutSelection: true } ) )
+ .to.equal( String(
+ '' +
+ '' +
+ '' +
+ 'foobar ' +
+ ' ' +
+ ' ' +
+ '
' +
+ 'Foo caption '
+ ) );
+ } );
+ } );
+ } );
+
+ describe( 'editing pipeline', () => {
+ describe( 'model to view', () => {
+ it( 'should convert caption element to figcaption contenteditable', () => {
+ setModelData( model,
+ ''
+ );
+
+ expect( getViewData( view, { withoutSelection: true } ) ).to.equal(
+ '' +
+ '
' +
+ '' +
+ '' +
+ '' +
+ '' +
+ 'xyz ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ '
' +
+ '' +
+ 'Foo caption' +
+ ' ' +
+ ' '
+ );
+ } );
+ } );
+ } );
+} );
diff --git a/packages/ckeditor5-table/tests/tablecaption/toggletablecaptioncommand.js b/packages/ckeditor5-table/tests/tablecaption/toggletablecaptioncommand.js
new file mode 100644
index 00000000000..5267d40e22d
--- /dev/null
+++ b/packages/ckeditor5-table/tests/tablecaption/toggletablecaptioncommand.js
@@ -0,0 +1,308 @@
+/**
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
+
+import TableSelection from '../../src/tableselection';
+import TableEditing from '../../src/tableediting';
+import { modelTable } from '../_utils/utils';
+
+import ToggleTableCaptionCommand from '../../src/tablecaption/toggletablecaptioncommand';
+import TableCaptionEditing from '../../src/tablecaption/tablecaptionediting';
+
+describe( 'ToggleTableCaptionCommand', () => {
+ let editor, model, command;
+
+ beforeEach( () => {
+ return ModelTestEditor
+ .create( {
+ plugins: [ Paragraph, TableEditing, TableCaptionEditing, TableSelection ]
+ } )
+ .then( newEditor => {
+ editor = newEditor;
+ model = editor.model;
+ command = new ToggleTableCaptionCommand( editor );
+ } );
+ } );
+
+ afterEach( () => {
+ return editor.destroy();
+ } );
+
+ describe( 'isEnabled', () => {
+ it( 'should be false if wrong node', () => {
+ setData( model, 'foo[] ' );
+ expect( command.isEnabled ).to.be.false;
+ } );
+
+ it( 'should be true if in a table', () => {
+ setData( model, modelTable( [ [ '[]' ] ] ) );
+ expect( command.isEnabled ).to.be.true;
+ } );
+
+ it( 'should be true if on a table', () => {
+ setData( model,
+ '[' +
+ '' +
+ '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ '
]'
+ );
+ expect( command.isEnabled ).to.be.true;
+ } );
+ } );
+
+ describe( 'execute()', () => {
+ it( 'should insert caption while the cell\'s content is focused', () => {
+ setData( model, modelTable( [
+ [ '11[]', '12' ],
+ [ '21', '22' ]
+ ] ) );
+
+ command.execute();
+
+ assertEqualMarkup( getData( model ),
+ '' +
+ '' +
+ '' +
+ '11[] ' +
+ ' ' +
+ '' +
+ '12 ' +
+ ' ' +
+ ' ' +
+ '' +
+ '' +
+ '21 ' +
+ ' ' +
+ '' +
+ '22 ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ '
'
+ );
+ } );
+
+ it( 'should hide caption while the cell\'s content is focused', () => {
+ setData( model,
+ '' +
+ '' +
+ '' +
+ '11[] ' +
+ ' ' +
+ '' +
+ '12 ' +
+ ' ' +
+ ' ' +
+ '' +
+ '' +
+ '21 ' +
+ ' ' +
+ '' +
+ '22 ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ '
'
+ );
+
+ command.execute();
+
+ assertEqualMarkup( getData( model ),
+ '' +
+ '' +
+ '' +
+ '[11] ' +
+ ' ' +
+ '' +
+ '12 ' +
+ ' ' +
+ ' ' +
+ '' +
+ '' +
+ '21 ' +
+ ' ' +
+ '' +
+ '22 ' +
+ ' ' +
+ ' ' +
+ '
'
+ );
+ } );
+
+ it( 'should insert caption while the table is focused', () => {
+ setData( model,
+ '[' +
+ '' +
+ '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ '
]'
+ );
+
+ command.execute();
+
+ assertEqualMarkup( getData( model ),
+ '[' +
+ '' +
+ '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ '
]'
+ );
+ } );
+
+ it( 'should hide caption while the table is focused', () => {
+ setData( model,
+ '[' +
+ '' +
+ '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ '
]'
+ );
+
+ command.execute();
+
+ assertEqualMarkup( getData( model ),
+ '' +
+ '' +
+ '' +
+ '[] ' +
+ ' ' +
+ ' ' +
+ '
'
+ );
+ } );
+
+ it( 'should insert caption in given table while the table is focused and move focus to caption', () => {
+ setData( model,
+ '[' +
+ '' +
+ '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ '
]'
+ );
+
+ command.execute( { focusCaptionOnShow: true } );
+
+ assertEqualMarkup( getData( model ),
+ '' +
+ '' +
+ '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ '[] ' +
+ '
'
+ );
+ } );
+
+ it( 'should keep caption content even when caption is hidden', () => {
+ setData( model,
+ '' +
+ '' +
+ '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ 'Foo<$text bold="true">bar$text> ' +
+ '
'
+ );
+
+ command.execute();
+
+ assertEqualMarkup( getData( model ),
+ '' +
+ '' +
+ '' +
+ '[] ' +
+ ' ' +
+ ' ' +
+ '
'
+ );
+
+ command.execute();
+
+ assertEqualMarkup( getData( model ),
+ '' +
+ '' +
+ '' +
+ '[] ' +
+ ' ' +
+ ' ' +
+ 'Foo<$text bold="true">bar$text> ' +
+ '
'
+ );
+ } );
+
+ it( 'should overwrite caption with an empty one', () => {
+ setData( model,
+ '' +
+ '' +
+ '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ 'Foo ' +
+ '
'
+ );
+
+ // Hide the caption.
+ command.execute();
+
+ assertEqualMarkup( getData( model ),
+ '' +
+ '' +
+ '' +
+ '[] ' +
+ ' ' +
+ ' ' +
+ '
'
+ );
+
+ // Show the caption.
+ command.execute();
+
+ // Remove the caption content.
+ model.change( writer => {
+ const caption = model.document.getRoot().getNodeByPath( [ 0, 1 ] );
+ const range = writer.createRangeIn( caption );
+
+ writer.remove( range );
+ } );
+
+ // Hide and then show the caption.
+ command.execute();
+ command.execute();
+
+ assertEqualMarkup( getData( model ),
+ '' +
+ '' +
+ '' +
+ '[] ' +
+ ' ' +
+ ' ' +
+
+ // Caption should be empty.
+ ' ' +
+ '
'
+ );
+ } );
+ } );
+} );
diff --git a/packages/ckeditor5-table/tests/tablecaption/utils.js b/packages/ckeditor5-table/tests/tablecaption/utils.js
new file mode 100644
index 00000000000..687bef6a9e3
--- /dev/null
+++ b/packages/ckeditor5-table/tests/tablecaption/utils.js
@@ -0,0 +1,234 @@
+/**
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Selection from '@ckeditor/ckeditor5-engine/src/model/selection';
+import View from '@ckeditor/ckeditor5-engine/src/view/view';
+import ViewElement from '@ckeditor/ckeditor5-engine/src/view/element';
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import TableCaptionEditing from '../../src/tablecaption/tablecaptionediting';
+import TableEditing from '../../src/tableediting';
+import {
+ getCaptionFromModelSelection,
+ getCaptionFromTableModelElement,
+ getSelectionAffectedTable,
+ isTable,
+ matchTableCaptionViewElement
+} from '../../src/tablecaption/utils';
+
+describe( 'table caption utils', () => {
+ let editor, model, modelRoot;
+ let view, document;
+
+ beforeEach( async () => {
+ view = new View();
+ document = view.document;
+
+ editor = await VirtualTestEditor.create( {
+ plugins: [ TableEditing, TableCaptionEditing, Paragraph ]
+ } );
+
+ model = editor.model;
+ modelRoot = model.document.getRoot();
+
+ setModelData( model,
+ '' +
+ '' +
+ '' +
+ '11[] ' +
+ ' ' +
+ '' +
+ '12 ' +
+ ' ' +
+ ' ' +
+ '' +
+ '' +
+ '21 ' +
+ ' ' +
+ '' +
+ '22 ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ '
'
+ );
+ } );
+
+ afterEach( async () => {
+ await editor.destroy();
+ } );
+
+ describe( 'isTable', () => {
+ it( 'should return true when given a table as a parameter', () => {
+ const element = modelRoot.getNodeByPath( [ 0 ] );
+
+ expect( isTable( element ) ).to.be.true;
+ } );
+
+ it( 'should return false when given parameter is not a table', () => {
+ const element = modelRoot.getNodeByPath( [ 0, 0 ] );
+
+ expect( isTable( element ) ).to.be.false;
+ } );
+
+ it( 'should return false when given parameter is not an element', () => {
+ expect( isTable() ).to.be.false;
+ } );
+ } );
+
+ describe( 'getCaptionFromTableModelElement', () => {
+ it( 'should return null when given table has no caption', () => {
+ setModelData( model,
+ '' +
+ '' +
+ '' +
+ '11[] ' +
+ ' ' +
+ ' ' +
+ '
'
+ );
+ const element = modelRoot.getNodeByPath( [ 0 ] );
+
+ expect( getCaptionFromTableModelElement( element ) ).to.be.null;
+ } );
+
+ it( 'should return caption when given table has it', () => {
+ const element = modelRoot.getNodeByPath( [ 0 ] );
+
+ const captionElement = getCaptionFromTableModelElement( element );
+ expect( captionElement.is( 'element', 'caption' ) ).to.be.true;
+ } );
+ } );
+
+ describe( 'getCaptionFromModelSelection', () => {
+ it( 'should return null when given table has no caption - selection in a cell', () => {
+ setModelData( model,
+ '' +
+ '' +
+ '' +
+ '11[] ' +
+ ' ' +
+ ' ' +
+ '
'
+ );
+
+ expect( getCaptionFromModelSelection( model.document.selection ) ).to.be.null;
+ } );
+
+ it( 'should return null when given table has no caption - selection on a table', () => {
+ setModelData( model,
+ '[' +
+ '' +
+ '' +
+ '11 ' +
+ ' ' +
+ ' ' +
+ '
]'
+ );
+
+ expect( getCaptionFromModelSelection( model.document.selection ) ).to.be.null;
+ } );
+
+ it( 'should return caption when given table has it', () => {
+ const captionElement = getCaptionFromModelSelection( model.document.selection );
+
+ expect( captionElement.is( 'element', 'caption' ) ).to.be.true;
+ } );
+
+ it( 'should return null when no table has been found', () => {
+ setModelData( model,
+ '[] '
+ );
+
+ expect( getCaptionFromModelSelection( model.document.selection ) ).to.be.null;
+ } );
+ } );
+
+ describe( 'matchTableCaptionViewElement', () => {
+ describe( 'figcaption', () => {
+ it( 'should return null for element that is not a figcaption', () => {
+ const element = new ViewElement( document, 'div' );
+
+ expect( matchTableCaptionViewElement( element ) ).to.be.null;
+ } );
+
+ it( 'should return null if figcaption has no parent', () => {
+ const element = new ViewElement( document, 'figcaption' );
+
+ expect( matchTableCaptionViewElement( element ) ).to.be.null;
+ } );
+
+ it( 'should return null if figcaption\'s parent is not a figure', () => {
+ const element = new ViewElement( document, 'figcaption' );
+ new ViewElement( document, 'div', null, element ); // eslint-disable-line no-new
+
+ expect( matchTableCaptionViewElement( element ) ).to.be.null;
+ } );
+
+ it( 'should return null if parent has no image class', () => {
+ const element = new ViewElement( document, 'figcaption' );
+ new ViewElement( document, 'figure', null, element ); // eslint-disable-line no-new
+
+ expect( matchTableCaptionViewElement( element ) ).to.be.null;
+ } );
+
+ it( 'should return object if element is a valid caption', () => {
+ const element = new ViewElement( document, 'figcaption' );
+ new ViewElement( document, 'figure', { class: 'table' }, element ); // eslint-disable-line no-new
+
+ expect( matchTableCaptionViewElement( element ) ).to.deep.equal( { name: true } );
+ } );
+ } );
+
+ describe( 'caption', () => {
+ it( 'should return null for element that is not a caption', () => {
+ const element = new ViewElement( document, 'div' );
+
+ expect( matchTableCaptionViewElement( element ) ).to.be.null;
+ } );
+
+ it( 'should return null if caption has no parent', () => {
+ const element = new ViewElement( document, 'caption' );
+
+ expect( matchTableCaptionViewElement( element ) ).to.be.null;
+ } );
+
+ it( 'should return null if caption\'s parent is not a table', () => {
+ const element = new ViewElement( document, 'caption' );
+ new ViewElement( document, 'div', null, element ); // eslint-disable-line no-new
+
+ expect( matchTableCaptionViewElement( element ) ).to.be.null;
+ } );
+
+ it( 'should return object if element is a valid caption', () => {
+ const element = new ViewElement( document, 'caption' );
+ new ViewElement( document, 'table', null, element ); // eslint-disable-line no-new
+
+ expect( matchTableCaptionViewElement( element ) ).to.deep.equal( { name: true } );
+ } );
+ } );
+ } );
+
+ describe( 'getSelectionAffectedTable', () => {
+ it( 'should return null if table is not present', () => {
+ setModelData( model, 'Foo[] ' );
+ const selection = new Selection( model.createPositionFromPath( modelRoot, [ 0 ] ) );
+
+ const tableElement = getSelectionAffectedTable( selection );
+
+ expect( tableElement ).to.be.null;
+ } );
+
+ it( 'should return table if present higher in the model tree', () => {
+ const selection = new Selection( model.createPositionFromPath( modelRoot, [ 0, 0, 0 ] ) );
+
+ const tableElement = getSelectionAffectedTable( selection );
+
+ expect( tableElement ).to.equal( modelRoot.getNodeByPath( [ 0 ] ) );
+ } );
+ } );
+} );
diff --git a/packages/ckeditor5-table/tests/tablekeyboard.js b/packages/ckeditor5-table/tests/tablekeyboard.js
index 93f00944f69..c34ba59dfc5 100644
--- a/packages/ckeditor5-table/tests/tablekeyboard.js
+++ b/packages/ckeditor5-table/tests/tablekeyboard.js
@@ -139,6 +139,45 @@ describe( 'TableKeyboard', () => {
] ) );
} );
+ it( 'should create another row and move to the first cell in a new row - ignore non-row elements', () => {
+ model.schema.register( 'foo', {
+ allowIn: 'table',
+ allowContentOf: '$block',
+ isLimit: true
+ } );
+
+ editor.conversion.elementToElement( {
+ view: 'foo',
+ model: 'foo'
+ } );
+
+ setModelData( model,
+ '' +
+ '' +
+ '00 ' +
+ '[01] ' +
+ ' ' +
+ 'An extra element ' +
+ '
'
+ );
+
+ editor.editing.view.document.fire( 'keydown', domEvtDataStub );
+
+ assertEqualMarkup( getModelData( model ),
+ '' +
+ '' +
+ '00 ' +
+ '01 ' +
+ ' ' +
+ '' +
+ '[] ' +
+ ' ' +
+ ' ' +
+ 'An extra element ' +
+ '
'
+ );
+ } );
+
it( 'should select the whole table if the "insertTableRowBelow" command is disabled', () => {
setModelData( model, modelTable( [
[ '11', '12[]' ]
diff --git a/packages/ckeditor5-table/tests/tableui.js b/packages/ckeditor5-table/tests/tableui.js
index e5b34deca54..bf99fb1c60f 100644
--- a/packages/ckeditor5-table/tests/tableui.js
+++ b/packages/ckeditor5-table/tests/tableui.js
@@ -386,11 +386,28 @@ describe( 'TableUI', () => {
expect( dropdown.buttonView ).to.be.instanceOf( SplitButtonView );
} );
- it( 'should have #isEnabled always true regardless of the "mergeTableCells" command state', () => {
- command.isEnabled = false;
+ it( 'should be disabled if all of the merge commands are disabled, along with the main merge command', () => {
+ [
+ 'mergeTableCells',
+ 'mergeTableCellUp',
+ 'mergeTableCellRight',
+ 'mergeTableCellDown',
+ 'mergeTableCellLeft',
+ 'splitTableCellVertically',
+ 'splitTableCellHorizontally'
+ ].forEach( command => {
+ editor.commands.get( command ).isEnabled = false;
+ } );
+
+ expect( dropdown.isEnabled ).to.be.false;
+
+ editor.commands.get( 'mergeTableCellLeft' ).isEnabled = true;
+
expect( dropdown.isEnabled ).to.be.true;
+ editor.commands.get( 'mergeTableCellLeft' ).isEnabled = false;
command.isEnabled = true;
+
expect( dropdown.isEnabled ).to.be.true;
} );
@@ -403,14 +420,6 @@ describe( 'TableUI', () => {
sinon.assert.calledWithExactly( spy, 'mergeTableCells' );
} );
- it( 'should have the dropdown part of the split button always enabled no matter the "mergeTableCells" command state', () => {
- command.isEnabled = true;
- expect( dropdown.buttonView.arrowView.isEnabled ).to.be.true;
-
- command.isEnabled = false;
- expect( dropdown.buttonView.arrowView.isEnabled ).to.be.true;
- } );
-
it( 'should have proper items in panel', () => {
const listView = dropdown.listView;
diff --git a/packages/ckeditor5-table/tests/tableutils.js b/packages/ckeditor5-table/tests/tableutils.js
index cfc03d379fa..db98ff51ee1 100644
--- a/packages/ckeditor5-table/tests/tableutils.js
+++ b/packages/ckeditor5-table/tests/tableutils.js
@@ -8,11 +8,11 @@ import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
import TableEditing from '../src/tableediting';
-import { modelTable } from './_utils/utils';
-
import TableUtils from '../src/tableutils';
+import { modelTable } from './_utils/utils';
describe( 'TableUtils', () => {
let editor, model, root, tableUtils;
@@ -27,6 +27,16 @@ describe( 'TableUtils', () => {
model = editor.model;
root = model.document.getRoot( 'main' );
tableUtils = editor.plugins.get( TableUtils );
+
+ model.schema.register( 'foo', {
+ allowIn: 'table',
+ allowContentOf: '$block',
+ isLimit: true
+ } );
+ editor.conversion.elementToElement( {
+ view: 'foo',
+ model: 'foo'
+ } );
} );
} );
@@ -275,6 +285,81 @@ describe( 'TableUtils', () => {
] ) );
} );
+ it( 'should throw error when options.at is larger than the amount of rows in the table', () => {
+ setData( model, modelTable( [
+ [ '11[]', '12' ],
+ [ '21', '22' ]
+ ] ) );
+
+ expect(
+ () => tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 3, rows: 3 } )
+ ).to.throw(
+ CKEditorError,
+ 'tableutils-insertrows-insert-out-of-range'
+ );
+
+ assertEqualMarkup( getData( model ), modelTable( [
+ [ '11[]', '12' ],
+ [ '21', '22' ]
+ ] ) );
+ } );
+
+ it( 'should insert rows into a table with a non-row element', () => {
+ setData( model,
+ '' +
+ '' +
+ '00 ' +
+ '01 ' +
+ ' ' +
+ '' +
+ '[]10 ' +
+ '11 ' +
+ ' ' +
+ 'An extra element ' +
+ '
'
+ );
+
+ tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 2, rows: 3 } );
+
+ assertEqualMarkup( getData( model ),
+ '' +
+ '' +
+ '00 ' +
+ '01 ' +
+ ' ' +
+ '' +
+ '[]10 ' +
+ '11 ' +
+ ' ' +
+ '' +
+ '' +
+ ' ' +
+ ' ' +
+ '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ '' +
+ '' +
+ ' ' +
+ ' ' +
+ '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ '' +
+ '' +
+ ' ' +
+ ' ' +
+ '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ 'An extra element ' +
+ '
'
+ );
+ } );
+
describe( 'with copyStructureFrom enabled', () => {
beforeEach( () => {
// +----+----+----+----+----+----+
@@ -597,6 +682,90 @@ describe( 'TableUtils', () => {
[ '', '32' ]
], { headingColumns: 3 } ) );
} );
+
+ it( 'should ignore table element that is not a row', () => {
+ setData( model,
+ '' +
+ '' +
+ '11[] ' +
+ '12 ' +
+ ' ' +
+ '' +
+ '21 ' +
+ '22 ' +
+ ' ' +
+ 'Bar ' +
+ '
'
+ );
+
+ tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 0 } );
+
+ assertEqualMarkup( getData( model ),
+ '' +
+ '' +
+ '' +
+ ' ' +
+ ' ' +
+ '' +
+ '11[] ' +
+ ' ' +
+ '' +
+ '12 ' +
+ ' ' +
+ ' ' +
+ '' +
+ '' +
+ ' ' +
+ ' ' +
+ '' +
+ '21 ' +
+ ' ' +
+ '' +
+ '22 ' +
+ ' ' +
+ ' ' +
+ 'Bar ' +
+ '
'
+ );
+ } );
+
+ it( 'should insert columns into a table with a non-row element', () => {
+ setData( model,
+ '' +
+ '' +
+ '00 ' +
+ '01 ' +
+ ' ' +
+ '' +
+ '[]10 ' +
+ '11 ' +
+ ' ' +
+ 'An extra element ' +
+ '
'
+ );
+
+ tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 1, columns: 3 } );
+
+ assertEqualMarkup( getData( model ),
+ '' +
+ '' +
+ '00 ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ '01 ' +
+ ' ' +
+ '' +
+ '[]10 ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ '11 ' +
+ ' ' +
+ 'An extra element ' +
+ '
'
+ );
+ } );
} );
describe( 'splitCellVertically()', () => {
@@ -741,6 +910,40 @@ describe( 'TableUtils', () => {
[ '10[]', '', '', '11' ]
], { headingColumns: 3 } ) );
} );
+
+ it( 'should split cells in a table with a non-row element', () => {
+ setData( model,
+ '' +
+ '' +
+ '00 ' +
+ '01 ' +
+ ' ' +
+ '' +
+ '[]10 ' +
+ '11 ' +
+ ' ' +
+ 'An extra element ' +
+ '
'
+ );
+
+ tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 1, 0 ] ), 3 );
+
+ assertEqualMarkup( getData( model ),
+ '' +
+ '' +
+ '00 ' +
+ '01 ' +
+ ' ' +
+ '' +
+ '[]10 ' +
+ ' ' +
+ ' ' +
+ '11 ' +
+ ' ' +
+ 'An extra element ' +
+ '
'
+ );
+ } );
} );
describe( 'splitCellHorizontally()', () => {
@@ -916,6 +1119,44 @@ describe( 'TableUtils', () => {
[ '20', '21', '22' ]
], { headingRows: 3 } ) );
} );
+
+ it( 'should split cells in a table with a non-row element', () => {
+ setData( model,
+ '' +
+ '' +
+ '00 ' +
+ '01 ' +
+ ' ' +
+ 'An extra element ' +
+ '
'
+ );
+
+ tableUtils.splitCellHorizontally( root.getNodeByPath( [ 0, 0, 0 ] ), 3 );
+
+ assertEqualMarkup( getData( model ),
+ '[' +
+ '' +
+ '' +
+ '00 ' +
+ ' ' +
+ '' +
+ '01 ' +
+ ' ' +
+ ' ' +
+ '' +
+ '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ '' +
+ '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ 'An extra element ' +
+ '
]'
+ );
+ } );
} );
describe( 'getColumns()', () => {
@@ -956,6 +1197,24 @@ describe( 'TableUtils', () => {
expect( tableUtils.getRows( root.getNodeByPath( [ 0 ] ) ) ).to.equal( 3 );
} );
+
+ it( 'should return proper number of rows for a table with a non-row element', () => {
+ setData( model,
+ '' +
+ '' +
+ '00 ' +
+ '01 ' +
+ ' ' +
+ '' +
+ '[]10 ' +
+ '11 ' +
+ ' ' +
+ 'An extra element ' +
+ '
'
+ );
+
+ expect( tableUtils.getRows( root.getNodeByPath( [ 0 ] ) ) ).to.equal( 2 );
+ } );
} );
describe( 'removeRows()', () => {
@@ -1112,6 +1371,34 @@ describe( 'TableUtils', () => {
[ '20', '12', '23', '24' ]
] ) );
} );
+
+ it( 'should remove row in a table with a non-row element', () => {
+ setData( model,
+ '' +
+ '' +
+ '00 ' +
+ '01 ' +
+ ' ' +
+ '' +
+ '[]10 ' +
+ '11 ' +
+ ' ' +
+ 'An extra element ' +
+ '
'
+ );
+
+ tableUtils.removeRows( root.getChild( 0 ), { at: 1 } );
+
+ assertEqualMarkup( getData( model ),
+ '' +
+ '' +
+ '00 ' +
+ '01 ' +
+ ' ' +
+ '[]An extra element ' +
+ '
'
+ );
+ } );
} );
describe( 'many rows', () => {
@@ -1310,6 +1597,29 @@ describe( 'TableUtils', () => {
expect( createdBatches.size ).to.equal( 1 );
} );
+
+ it( 'should throw the error when provided options point to a non-existent rows', () => {
+ setData( model,
+ '' +
+ '' +
+ '00 ' +
+ '01 ' +
+ ' ' +
+ '' +
+ '[]10 ' +
+ '11 ' +
+ ' ' +
+ 'An extra element ' +
+ '
'
+ );
+
+ expect(
+ () => tableUtils.removeRows( root.getChild( 0 ), { at: 1, rows: 2 } )
+ ).to.throw(
+ CKEditorError,
+ 'tableutils-removerows-row-index-out-of-range'
+ );
+ } );
} );
} );
@@ -1482,6 +1792,36 @@ describe( 'TableUtils', () => {
[ '01', '02' ]
] ) );
} );
+
+ it( 'should remove column in a table with a non-row element', () => {
+ setData( model,
+ '' +
+ '' +
+ '00 ' +
+ '01 ' +
+ ' ' +
+ '' +
+ '[]10 ' +
+ '11 ' +
+ ' ' +
+ 'An extra element ' +
+ '
'
+ );
+
+ tableUtils.removeColumns( root.getChild( 0 ), { at: 0 } );
+
+ assertEqualMarkup( getData( model ),
+ '' +
+ '' +
+ '01 ' +
+ ' ' +
+ '' +
+ '[]11 ' +
+ ' ' +
+ 'An extra element ' +
+ '
'
+ );
+ } );
} );
describe( 'multiple columns', () => {
@@ -1568,6 +1908,36 @@ describe( 'TableUtils', () => {
[ '22' ]
] ) );
} );
+
+ it( 'should remove column in a table with a non-row element', () => {
+ setData( model,
+ '' +
+ '' +
+ '00 ' +
+ '01 ' +
+ ' ' +
+ '' +
+ '[]10 ' +
+ '11 ' +
+ ' ' +
+ 'An extra element ' +
+ '
'
+ );
+
+ tableUtils.removeColumns( root.getChild( 0 ), { at: 1, columns: 1 } );
+
+ assertEqualMarkup( getData( model ),
+ '' +
+ '' +
+ '00 ' +
+ ' ' +
+ '' +
+ '[]10 ' +
+ ' ' +
+ 'An extra element ' +
+ '
'
+ );
+ } );
} );
} );
diff --git a/packages/ckeditor5-table/tests/tablewalker.js b/packages/ckeditor5-table/tests/tablewalker.js
index 26f502afb6c..f88b01b8f23 100644
--- a/packages/ckeditor5-table/tests/tablewalker.js
+++ b/packages/ckeditor5-table/tests/tablewalker.js
@@ -6,7 +6,7 @@
import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
-import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { setData, parse } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
import TableEditing from '../src/tableediting';
import { modelTable } from './_utils/utils';
@@ -27,7 +27,10 @@ describe( 'TableWalker', () => {
} );
function testWalker( tableData, expected, options, skip ) {
- setData( model, modelTable( tableData ) );
+ // Accept either a table of cells or a HTML-like string describing model.
+ const modelData = Array.isArray( tableData ) ? modelTable( tableData ) : tableData;
+
+ setData( model, modelData );
const walker = new TableWalker( root.getChild( 0 ), options );
@@ -38,11 +41,12 @@ describe( 'TableWalker', () => {
const result = [ ...walker ];
const formattedResult = result.map( tableSlot => {
- const { cell, row, column, isAnchor, cellWidth, cellHeight, cellAnchorRow, cellAnchorColumn } = tableSlot;
+ const { cell, row, column, rowIndex, isAnchor, cellWidth, cellHeight, cellAnchorRow, cellAnchorColumn } = tableSlot;
return {
row,
column,
+ rowIndex,
data: cell && cell.getChild( 0 ).getChild( 0 ).data,
index: tableSlot.getPositionBefore().offset,
...( cellAnchorRow != row ? { anchorRow: cellAnchorRow } : null ),
@@ -66,10 +70,10 @@ describe( 'TableWalker', () => {
[ '00', '01' ],
[ '10', '11' ]
], [
- { row: 0, column: 0, index: 0, data: '00', isAnchor: true },
- { row: 0, column: 1, index: 1, data: '01', isAnchor: true },
- { row: 1, column: 0, index: 0, data: '10', isAnchor: true },
- { row: 1, column: 1, index: 1, data: '11', isAnchor: true }
+ { row: 0, column: 0, rowIndex: 0, index: 0, data: '00', isAnchor: true },
+ { row: 0, column: 1, rowIndex: 0, index: 1, data: '01', isAnchor: true },
+ { row: 1, column: 0, rowIndex: 1, index: 0, data: '10', isAnchor: true },
+ { row: 1, column: 1, rowIndex: 1, index: 1, data: '11', isAnchor: true }
] );
} );
@@ -80,8 +84,8 @@ describe( 'TableWalker', () => {
testWalker( [
[ { colspan: 2, contents: '00' }, '13' ]
], [
- { row: 0, column: 0, index: 0, data: '00', isAnchor: true, width: 2 },
- { row: 0, column: 2, index: 1, data: '13', isAnchor: true }
+ { row: 0, column: 0, rowIndex: 0, index: 0, data: '00', isAnchor: true, width: 2 },
+ { row: 0, column: 2, rowIndex: 0, index: 1, data: '13', isAnchor: true }
] );
} );
@@ -101,13 +105,13 @@ describe( 'TableWalker', () => {
[ '22' ],
[ '30', '31', '32' ]
], [
- { row: 0, column: 0, index: 0, data: '00', isAnchor: true, width: 2, height: 3 },
- { row: 0, column: 2, index: 1, data: '02', isAnchor: true },
- { row: 1, column: 2, index: 0, data: '12', isAnchor: true },
- { row: 2, column: 2, index: 0, data: '22', isAnchor: true },
- { row: 3, column: 0, index: 0, data: '30', isAnchor: true },
- { row: 3, column: 1, index: 1, data: '31', isAnchor: true },
- { row: 3, column: 2, index: 2, data: '32', isAnchor: true }
+ { row: 0, column: 0, rowIndex: 0, index: 0, data: '00', isAnchor: true, width: 2, height: 3 },
+ { row: 0, column: 2, rowIndex: 0, index: 1, data: '02', isAnchor: true },
+ { row: 1, column: 2, rowIndex: 1, index: 0, data: '12', isAnchor: true },
+ { row: 2, column: 2, rowIndex: 2, index: 0, data: '22', isAnchor: true },
+ { row: 3, column: 0, rowIndex: 3, index: 0, data: '30', isAnchor: true },
+ { row: 3, column: 1, rowIndex: 3, index: 1, data: '31', isAnchor: true },
+ { row: 3, column: 2, rowIndex: 3, index: 2, data: '32', isAnchor: true }
] );
} );
@@ -127,18 +131,70 @@ describe( 'TableWalker', () => {
[ '33' ],
[ '41', '42', '43' ]
], [
- { row: 0, column: 0, index: 0, data: '11', isAnchor: true, height: 3 },
- { row: 0, column: 1, index: 1, data: '12', isAnchor: true },
- { row: 0, column: 2, index: 2, data: '13', isAnchor: true },
- { row: 1, column: 1, index: 0, data: '22', isAnchor: true, height: 2 },
- { row: 1, column: 2, index: 1, data: '23', isAnchor: true },
- { row: 2, column: 2, index: 0, data: '33', isAnchor: true },
- { row: 3, column: 0, index: 0, data: '41', isAnchor: true },
- { row: 3, column: 1, index: 1, data: '42', isAnchor: true },
- { row: 3, column: 2, index: 2, data: '43', isAnchor: true }
+ { row: 0, column: 0, rowIndex: 0, index: 0, data: '11', isAnchor: true, height: 3 },
+ { row: 0, column: 1, rowIndex: 0, index: 1, data: '12', isAnchor: true },
+ { row: 0, column: 2, rowIndex: 0, index: 2, data: '13', isAnchor: true },
+ { row: 1, column: 1, rowIndex: 1, index: 0, data: '22', isAnchor: true, height: 2 },
+ { row: 1, column: 2, rowIndex: 1, index: 1, data: '23', isAnchor: true },
+ { row: 2, column: 2, rowIndex: 2, index: 0, data: '33', isAnchor: true },
+ { row: 3, column: 0, rowIndex: 3, index: 0, data: '41', isAnchor: true },
+ { row: 3, column: 1, rowIndex: 3, index: 1, data: '42', isAnchor: true },
+ { row: 3, column: 2, rowIndex: 3, index: 2, data: '43', isAnchor: true }
] );
} );
+ it( 'should iterate over a table, but ignore non-row elements', () => {
+ model.schema.register( 'foo', {
+ allowIn: 'table',
+ allowContentOf: '$block',
+ isLimit: true
+ } );
+
+ // +----+----+
+ // | 00 | 01 |
+ // +----+----+
+ // | |
+ // +----+----+
+ // | 10 | 11 |
+ // +---------+
+ const modelTable =
+ '' +
+ '' +
+ '00 ' +
+ '01 ' +
+ ' ' +
+ 'An extra element ' +
+ '' +
+ '[]10 ' +
+ '11 ' +
+ ' ' +
+ '
';
+
+ const parsed = parse( modelTable, model.schema );
+
+ // We don't want post-fixers to be applied here, as the TableWalker can be used inside them,
+ // when the structure of the table is not yet corrected.
+ const tableWalker = Array.from( new TableWalker( parsed.model ) );
+
+ expect( tableWalker.length ).to.equal( 4 );
+
+ expect( tableWalker[ 0 ].row ).to.equal( 0 );
+ expect( tableWalker[ 0 ].column ).to.equal( 0 );
+ expect( tableWalker[ 0 ].rowIndex ).to.equal( 0 );
+
+ expect( tableWalker[ 1 ].row ).to.equal( 0 );
+ expect( tableWalker[ 1 ].column ).to.equal( 1 );
+ expect( tableWalker[ 1 ].rowIndex ).to.equal( 0 );
+
+ expect( tableWalker[ 2 ].row ).to.equal( 1 );
+ expect( tableWalker[ 2 ].column ).to.equal( 0 );
+ expect( tableWalker[ 2 ].rowIndex ).to.equal( 2 );
+
+ expect( tableWalker[ 3 ].row ).to.equal( 1 );
+ expect( tableWalker[ 3 ].column ).to.equal( 1 );
+ expect( tableWalker[ 3 ].rowIndex ).to.equal( 2 );
+ } );
+
describe( 'option.startRow', () => {
it( 'should start iterating from given row but with cell spans properly calculated', () => {
// +----+----+----+
@@ -156,10 +212,10 @@ describe( 'TableWalker', () => {
[ '33' ],
[ '41', '42', '43' ]
], [
- { row: 2, column: 2, index: 0, data: '33', isAnchor: true },
- { row: 3, column: 0, index: 0, data: '41', isAnchor: true },
- { row: 3, column: 1, index: 1, data: '42', isAnchor: true },
- { row: 3, column: 2, index: 2, data: '43', isAnchor: true }
+ { row: 2, column: 2, rowIndex: 2, index: 0, data: '33', isAnchor: true },
+ { row: 3, column: 0, rowIndex: 3, index: 0, data: '41', isAnchor: true },
+ { row: 3, column: 1, rowIndex: 3, index: 1, data: '42', isAnchor: true },
+ { row: 3, column: 2, rowIndex: 3, index: 2, data: '43', isAnchor: true }
], { startRow: 2 } );
} );
@@ -179,12 +235,12 @@ describe( 'TableWalker', () => {
[ '33' ],
[ '41', '42', '43' ]
], [
- { row: 2, column: 0, index: 0, data: '11', width: 2, height: 3, anchorRow: 0 },
- { row: 2, column: 1, index: 0, data: '11', width: 2, height: 3, anchorRow: 0, anchorColumn: 0 },
- { row: 2, column: 2, index: 0, data: '33', isAnchor: true },
- { row: 3, column: 0, index: 0, data: '41', isAnchor: true },
- { row: 3, column: 1, index: 1, data: '42', isAnchor: true },
- { row: 3, column: 2, index: 2, data: '43', isAnchor: true }
+ { row: 2, column: 0, rowIndex: 2, index: 0, data: '11', width: 2, height: 3, anchorRow: 0 },
+ { row: 2, column: 1, rowIndex: 2, index: 0, data: '11', width: 2, height: 3, anchorRow: 0, anchorColumn: 0 },
+ { row: 2, column: 2, rowIndex: 2, index: 0, data: '33', isAnchor: true },
+ { row: 3, column: 0, rowIndex: 3, index: 0, data: '41', isAnchor: true },
+ { row: 3, column: 1, rowIndex: 3, index: 1, data: '42', isAnchor: true },
+ { row: 3, column: 2, rowIndex: 3, index: 2, data: '43', isAnchor: true }
], { startRow: 2, includeAllSlots: true } );
} );
} );
@@ -206,10 +262,10 @@ describe( 'TableWalker', () => {
[ '33' ],
[ '41', '42', '43' ]
], [
- { row: 0, column: 0, index: 0, data: '11', isAnchor: true, width: 2, height: 3 },
- { row: 0, column: 2, index: 1, data: '13', isAnchor: true },
- { row: 1, column: 2, index: 0, data: '23', isAnchor: true },
- { row: 2, column: 2, index: 0, data: '33', isAnchor: true }
+ { row: 0, column: 0, rowIndex: 0, index: 0, data: '11', isAnchor: true, width: 2, height: 3 },
+ { row: 0, column: 2, rowIndex: 0, index: 1, data: '13', isAnchor: true },
+ { row: 1, column: 2, rowIndex: 1, index: 0, data: '23', isAnchor: true },
+ { row: 2, column: 2, rowIndex: 2, index: 0, data: '33', isAnchor: true }
], { endRow: 2 } );
} );
@@ -229,8 +285,8 @@ describe( 'TableWalker', () => {
[ '33' ],
[ '41', '42', '43' ]
], [
- { row: 0, column: 0, index: 0, data: '11', isAnchor: true, width: 2, height: 3 },
- { row: 0, column: 2, index: 1, data: '13', isAnchor: true }
+ { row: 0, column: 0, rowIndex: 0, index: 0, data: '11', isAnchor: true, width: 2, height: 3 },
+ { row: 0, column: 2, rowIndex: 0, index: 1, data: '13', isAnchor: true }
], { endRow: 0 } );
} );
@@ -250,12 +306,12 @@ describe( 'TableWalker', () => {
[ '33' ],
[ '41', '42', '43' ]
], [
- { row: 0, column: 0, index: 0, data: '11', width: 2, height: 3, isAnchor: true },
- { row: 0, column: 1, index: 0, data: '11', width: 2, height: 3, anchorColumn: 0 },
- { row: 0, column: 2, index: 1, data: '13', isAnchor: true },
- { row: 1, column: 0, index: 0, data: '11', width: 2, height: 3, anchorRow: 0 },
- { row: 1, column: 1, index: 0, data: '11', width: 2, height: 3, anchorRow: 0, anchorColumn: 0 },
- { row: 1, column: 2, index: 0, data: '23', isAnchor: true }
+ { row: 0, column: 0, rowIndex: 0, index: 0, data: '11', width: 2, height: 3, isAnchor: true },
+ { row: 0, column: 1, rowIndex: 0, index: 0, data: '11', width: 2, height: 3, anchorColumn: 0 },
+ { row: 0, column: 2, rowIndex: 0, index: 1, data: '13', isAnchor: true },
+ { row: 1, column: 0, rowIndex: 1, index: 0, data: '11', width: 2, height: 3, anchorRow: 0 },
+ { row: 1, column: 1, rowIndex: 1, index: 0, data: '11', width: 2, height: 3, anchorRow: 0, anchorColumn: 0 },
+ { row: 1, column: 2, rowIndex: 1, index: 0, data: '23', isAnchor: true }
], { endRow: 1, includeAllSlots: true } );
} );
} );
@@ -277,7 +333,7 @@ describe( 'TableWalker', () => {
[ '22' ],
[ '30', '31', '32' ]
], [
- { row: 1, column: 2, index: 0, data: '12', isAnchor: true }
+ { row: 1, column: 2, rowIndex: 1, index: 0, data: '12', isAnchor: true }
], { row: 1 } );
} );
@@ -297,9 +353,9 @@ describe( 'TableWalker', () => {
[ '22' ],
[ '30', '31', '32' ]
], [
- { row: 1, column: 0, index: 0, data: '00', width: 2, height: 3, anchorRow: 0 },
- { row: 1, column: 1, index: 0, data: '00', width: 2, height: 3, anchorRow: 0, anchorColumn: 0 },
- { row: 1, column: 2, index: 0, data: '12', isAnchor: true }
+ { row: 1, column: 0, rowIndex: 1, index: 0, data: '00', width: 2, height: 3, anchorRow: 0 },
+ { row: 1, column: 1, rowIndex: 1, index: 0, data: '00', width: 2, height: 3, anchorRow: 0, anchorColumn: 0 },
+ { row: 1, column: 2, rowIndex: 1, index: 0, data: '12', isAnchor: true }
], { row: 1, includeAllSlots: true } );
} );
} );
@@ -321,11 +377,11 @@ describe( 'TableWalker', () => {
[ '22' ],
[ '30', '31', '32' ]
], [
- { row: 0, column: 2, index: 1, data: '02', isAnchor: true },
- { row: 1, column: 2, index: 0, data: '12', isAnchor: true },
- { row: 2, column: 2, index: 0, data: '22', isAnchor: true },
- { row: 3, column: 1, index: 1, data: '31', isAnchor: true },
- { row: 3, column: 2, index: 2, data: '32', isAnchor: true }
+ { row: 0, column: 2, rowIndex: 0, index: 1, data: '02', isAnchor: true },
+ { row: 1, column: 2, rowIndex: 1, index: 0, data: '12', isAnchor: true },
+ { row: 2, column: 2, rowIndex: 2, index: 0, data: '22', isAnchor: true },
+ { row: 3, column: 1, rowIndex: 3, index: 1, data: '31', isAnchor: true },
+ { row: 3, column: 2, rowIndex: 3, index: 2, data: '32', isAnchor: true }
], { startColumn: 1 } );
} );
@@ -345,14 +401,14 @@ describe( 'TableWalker', () => {
[ '22' ],
[ '30', '31', '32' ]
], [
- { row: 0, column: 1, index: 0, data: '00', width: 2, height: 3, anchorColumn: 0 },
- { row: 0, column: 2, index: 1, data: '02', isAnchor: true },
- { row: 1, column: 1, index: 0, data: '00', width: 2, height: 3, anchorColumn: 0, anchorRow: 0 },
- { row: 1, column: 2, index: 0, data: '12', isAnchor: true },
- { row: 2, column: 1, index: 0, data: '00', width: 2, height: 3, anchorColumn: 0, anchorRow: 0 },
- { row: 2, column: 2, index: 0, data: '22', isAnchor: true },
- { row: 3, column: 1, index: 1, data: '31', isAnchor: true },
- { row: 3, column: 2, index: 2, data: '32', isAnchor: true }
+ { row: 0, column: 1, rowIndex: 0, index: 0, data: '00', width: 2, height: 3, anchorColumn: 0 },
+ { row: 0, column: 2, rowIndex: 0, index: 1, data: '02', isAnchor: true },
+ { row: 1, column: 1, rowIndex: 1, index: 0, data: '00', width: 2, height: 3, anchorColumn: 0, anchorRow: 0 },
+ { row: 1, column: 2, rowIndex: 1, index: 0, data: '12', isAnchor: true },
+ { row: 2, column: 1, rowIndex: 2, index: 0, data: '00', width: 2, height: 3, anchorColumn: 0, anchorRow: 0 },
+ { row: 2, column: 2, rowIndex: 2, index: 0, data: '22', isAnchor: true },
+ { row: 3, column: 1, rowIndex: 3, index: 1, data: '31', isAnchor: true },
+ { row: 3, column: 2, rowIndex: 3, index: 2, data: '32', isAnchor: true }
], { startColumn: 1, includeAllSlots: true } );
} );
} );
@@ -374,9 +430,9 @@ describe( 'TableWalker', () => {
[ '22' ],
[ '30', '31', '32' ]
], [
- { row: 0, column: 0, index: 0, data: '00', isAnchor: true, width: 2, height: 3 },
- { row: 3, column: 0, index: 0, data: '30', isAnchor: true },
- { row: 3, column: 1, index: 1, data: '31', isAnchor: true }
+ { row: 0, column: 0, rowIndex: 0, index: 0, data: '00', isAnchor: true, width: 2, height: 3 },
+ { row: 3, column: 0, rowIndex: 3, index: 0, data: '30', isAnchor: true },
+ { row: 3, column: 1, rowIndex: 3, index: 1, data: '31', isAnchor: true }
], { endColumn: 1 } );
} );
@@ -396,14 +452,14 @@ describe( 'TableWalker', () => {
[ '22' ],
[ '30', '31', '32' ]
], [
- { row: 0, column: 0, index: 0, data: '00', width: 2, height: 3, isAnchor: true },
- { row: 0, column: 1, index: 0, data: '00', width: 2, height: 3, anchorColumn: 0 },
- { row: 1, column: 0, index: 0, data: '00', width: 2, height: 3, anchorRow: 0 },
- { row: 1, column: 1, index: 0, data: '00', width: 2, height: 3, anchorColumn: 0, anchorRow: 0 },
- { row: 2, column: 0, index: 0, data: '00', width: 2, height: 3, anchorRow: 0 },
- { row: 2, column: 1, index: 0, data: '00', width: 2, height: 3, anchorColumn: 0, anchorRow: 0 },
- { row: 3, column: 0, index: 0, data: '30', isAnchor: true },
- { row: 3, column: 1, index: 1, data: '31', isAnchor: true }
+ { row: 0, column: 0, rowIndex: 0, index: 0, data: '00', width: 2, height: 3, isAnchor: true },
+ { row: 0, column: 1, rowIndex: 0, index: 0, data: '00', width: 2, height: 3, anchorColumn: 0 },
+ { row: 1, column: 0, rowIndex: 1, index: 0, data: '00', width: 2, height: 3, anchorRow: 0 },
+ { row: 1, column: 1, rowIndex: 1, index: 0, data: '00', width: 2, height: 3, anchorColumn: 0, anchorRow: 0 },
+ { row: 2, column: 0, rowIndex: 2, index: 0, data: '00', width: 2, height: 3, anchorRow: 0 },
+ { row: 2, column: 1, rowIndex: 2, index: 0, data: '00', width: 2, height: 3, anchorColumn: 0, anchorRow: 0 },
+ { row: 3, column: 0, rowIndex: 3, index: 0, data: '30', isAnchor: true },
+ { row: 3, column: 1, rowIndex: 3, index: 1, data: '31', isAnchor: true }
], { endColumn: 1, includeAllSlots: true } );
} );
} );
@@ -425,7 +481,7 @@ describe( 'TableWalker', () => {
[ '22' ],
[ '30', '31', '32' ]
], [
- { row: 3, column: 1, index: 1, data: '31', isAnchor: true }
+ { row: 3, column: 1, rowIndex: 3, index: 1, data: '31', isAnchor: true }
], { column: 1 } );
} );
@@ -445,10 +501,10 @@ describe( 'TableWalker', () => {
[ '22' ],
[ '30', '31', '32' ]
], [
- { row: 0, column: 1, index: 0, data: '00', width: 2, height: 3, anchorColumn: 0 },
- { row: 1, column: 1, index: 0, data: '00', width: 2, height: 3, anchorColumn: 0, anchorRow: 0 },
- { row: 2, column: 1, index: 0, data: '00', width: 2, height: 3, anchorColumn: 0, anchorRow: 0 },
- { row: 3, column: 1, index: 1, data: '31', isAnchor: true }
+ { row: 0, column: 1, rowIndex: 0, index: 0, data: '00', width: 2, height: 3, anchorColumn: 0 },
+ { row: 1, column: 1, rowIndex: 1, index: 0, data: '00', width: 2, height: 3, anchorColumn: 0, anchorRow: 0 },
+ { row: 2, column: 1, rowIndex: 2, index: 0, data: '00', width: 2, height: 3, anchorColumn: 0, anchorRow: 0 },
+ { row: 3, column: 1, rowIndex: 3, index: 1, data: '31', isAnchor: true }
], { column: 1, includeAllSlots: true } );
} );
} );
@@ -464,10 +520,10 @@ describe( 'TableWalker', () => {
[ '00', { rowspan: 2, contents: '01' } ],
[ '10' ]
], [
- { row: 0, column: 0, index: 0, data: '00', isAnchor: true },
- { row: 0, column: 1, index: 1, data: '01', isAnchor: true, height: 2 },
- { row: 1, column: 0, index: 0, data: '10', isAnchor: true },
- { row: 1, column: 1, index: 1, data: '01', anchorRow: 0, height: 2 }
+ { row: 0, column: 0, rowIndex: 0, index: 0, data: '00', isAnchor: true },
+ { row: 0, column: 1, rowIndex: 0, index: 1, data: '01', isAnchor: true, height: 2 },
+ { row: 1, column: 0, rowIndex: 1, index: 0, data: '10', isAnchor: true },
+ { row: 1, column: 1, rowIndex: 1, index: 1, data: '01', anchorRow: 0, height: 2 }
], { includeAllSlots: true } );
} );
@@ -487,18 +543,18 @@ describe( 'TableWalker', () => {
[ '22' ],
[ '30', { colspan: 2, contents: '31' } ]
], [
- { row: 0, column: 0, index: 0, data: '00', width: 2, height: 3, isAnchor: true },
- { row: 0, column: 1, index: 0, data: '00', width: 2, height: 3, anchorColumn: 0 },
- { row: 0, column: 2, index: 1, data: '02', isAnchor: true },
- { row: 1, column: 0, index: 0, data: '00', width: 2, height: 3, anchorRow: 0 },
- { row: 1, column: 1, index: 0, data: '00', width: 2, height: 3, anchorRow: 0, anchorColumn: 0 },
- { row: 1, column: 2, index: 0, data: '12', isAnchor: true },
- { row: 2, column: 0, index: 0, data: '00', width: 2, height: 3, anchorRow: 0 },
- { row: 2, column: 1, index: 0, data: '00', width: 2, height: 3, anchorRow: 0, anchorColumn: 0 },
- { row: 2, column: 2, index: 0, data: '22', isAnchor: true },
- { row: 3, column: 0, index: 0, data: '30', isAnchor: true },
- { row: 3, column: 1, index: 1, data: '31', width: 2, isAnchor: true },
- { row: 3, column: 2, index: 1, data: '31', width: 2, anchorColumn: 1 }
+ { row: 0, column: 0, rowIndex: 0, index: 0, data: '00', width: 2, height: 3, isAnchor: true },
+ { row: 0, column: 1, rowIndex: 0, index: 0, data: '00', width: 2, height: 3, anchorColumn: 0 },
+ { row: 0, column: 2, rowIndex: 0, index: 1, data: '02', isAnchor: true },
+ { row: 1, column: 0, rowIndex: 1, index: 0, data: '00', width: 2, height: 3, anchorRow: 0 },
+ { row: 1, column: 1, rowIndex: 1, index: 0, data: '00', width: 2, height: 3, anchorRow: 0, anchorColumn: 0 },
+ { row: 1, column: 2, rowIndex: 1, index: 0, data: '12', isAnchor: true },
+ { row: 2, column: 0, rowIndex: 2, index: 0, data: '00', width: 2, height: 3, anchorRow: 0 },
+ { row: 2, column: 1, rowIndex: 2, index: 0, data: '00', width: 2, height: 3, anchorRow: 0, anchorColumn: 0 },
+ { row: 2, column: 2, rowIndex: 2, index: 0, data: '22', isAnchor: true },
+ { row: 3, column: 0, rowIndex: 3, index: 0, data: '30', isAnchor: true },
+ { row: 3, column: 1, rowIndex: 3, index: 1, data: '31', width: 2, isAnchor: true },
+ { row: 3, column: 2, rowIndex: 3, index: 1, data: '31', width: 2, anchorColumn: 1 }
], { includeAllSlots: true } );
} );
@@ -512,10 +568,10 @@ describe( 'TableWalker', () => {
[ '00', { rowspan: 2, contents: '01' } ],
[ '10' ]
], [
- { row: 0, column: 0, index: 0, data: '00', isAnchor: true },
- { row: 0, column: 1, index: 1, data: '01', isAnchor: true, height: 2 },
- { row: 1, column: 0, index: 0, data: '10', isAnchor: true },
- { row: 1, column: 1, index: 1, data: '01', anchorRow: 0, height: 2 }
+ { row: 0, column: 0, rowIndex: 0, index: 0, data: '00', isAnchor: true },
+ { row: 0, column: 1, rowIndex: 0, index: 1, data: '01', isAnchor: true, height: 2 },
+ { row: 1, column: 0, rowIndex: 1, index: 0, data: '10', isAnchor: true },
+ { row: 1, column: 1, rowIndex: 1, index: 1, data: '01', anchorRow: 0, height: 2 }
], { includeAllSlots: true } );
} );
@@ -535,12 +591,12 @@ describe( 'TableWalker', () => {
[ '22' ],
[ '30', '31', '32' ]
], [
- { row: 1, column: 0, index: 0, data: '00', anchorRow: 0, width: 2, height: 3 },
- { row: 1, column: 1, index: 0, data: '00', anchorRow: 0, width: 2, height: 3, anchorColumn: 0 },
- { row: 1, column: 2, index: 0, data: '12', isAnchor: true },
- { row: 2, column: 0, index: 0, data: '00', anchorRow: 0, width: 2, height: 3 },
- { row: 2, column: 1, index: 0, data: '00', anchorRow: 0, width: 2, height: 3, anchorColumn: 0 },
- { row: 2, column: 2, index: 0, data: '22', isAnchor: true }
+ { row: 1, column: 0, rowIndex: 1, index: 0, data: '00', anchorRow: 0, width: 2, height: 3 },
+ { row: 1, column: 1, rowIndex: 1, index: 0, data: '00', anchorRow: 0, width: 2, height: 3, anchorColumn: 0 },
+ { row: 1, column: 2, rowIndex: 1, index: 0, data: '12', isAnchor: true },
+ { row: 2, column: 0, rowIndex: 2, index: 0, data: '00', anchorRow: 0, width: 2, height: 3 },
+ { row: 2, column: 1, rowIndex: 2, index: 0, data: '00', anchorRow: 0, width: 2, height: 3, anchorColumn: 0 },
+ { row: 2, column: 2, rowIndex: 2, index: 0, data: '22', isAnchor: true }
], { includeAllSlots: true, startRow: 1, endRow: 2 } );
} );
@@ -557,10 +613,10 @@ describe( 'TableWalker', () => {
[ '10' ],
[ '20', '21' ]
], [
- { row: 0, column: 0, index: 0, data: '00', isAnchor: true },
- { row: 0, column: 1, index: 1, data: '01', isAnchor: true, height: 2 },
- { row: 1, column: 0, index: 0, data: '10', isAnchor: true },
- { row: 1, column: 1, index: 1, data: '01', anchorRow: 0, height: 2 }
+ { row: 0, column: 0, rowIndex: 0, index: 0, data: '00', isAnchor: true },
+ { row: 0, column: 1, rowIndex: 0, index: 1, data: '01', isAnchor: true, height: 2 },
+ { row: 1, column: 0, rowIndex: 1, index: 0, data: '10', isAnchor: true },
+ { row: 1, column: 1, rowIndex: 1, index: 1, data: '01', anchorRow: 0, height: 2 }
], { startRow: 0, endRow: 1, includeAllSlots: true } );
} );
} );
@@ -582,12 +638,12 @@ describe( 'TableWalker', () => {
[ '22' ],
[ '30', '31', '32' ]
], [
- { row: 0, column: 0, index: 0, data: '00', isAnchor: true, width: 2, height: 3 },
- { row: 0, column: 2, index: 1, data: '02', isAnchor: true },
- { row: 2, column: 2, index: 0, data: '22', isAnchor: true },
- { row: 3, column: 0, index: 0, data: '30', isAnchor: true },
- { row: 3, column: 1, index: 1, data: '31', isAnchor: true },
- { row: 3, column: 2, index: 2, data: '32', isAnchor: true }
+ { row: 0, column: 0, rowIndex: 0, index: 0, data: '00', isAnchor: true, width: 2, height: 3 },
+ { row: 0, column: 2, rowIndex: 0, index: 1, data: '02', isAnchor: true },
+ { row: 2, column: 2, rowIndex: 2, index: 0, data: '22', isAnchor: true },
+ { row: 3, column: 0, rowIndex: 3, index: 0, data: '30', isAnchor: true },
+ { row: 3, column: 1, rowIndex: 3, index: 1, data: '31', isAnchor: true },
+ { row: 3, column: 2, rowIndex: 3, index: 2, data: '32', isAnchor: true }
], {}, 1 );
} );
@@ -607,15 +663,15 @@ describe( 'TableWalker', () => {
[ '22' ],
[ '30', '31', '32' ]
], [
- { row: 0, column: 0, index: 0, data: '00', width: 2, height: 3, isAnchor: true },
- { row: 0, column: 1, index: 0, data: '00', width: 2, height: 3, anchorColumn: 0 },
- { row: 0, column: 2, index: 1, data: '02', isAnchor: true },
- { row: 2, column: 0, index: 0, data: '00', width: 2, height: 3, anchorRow: 0 },
- { row: 2, column: 1, index: 0, data: '00', width: 2, height: 3, anchorRow: 0, anchorColumn: 0 },
- { row: 2, column: 2, index: 0, data: '22', isAnchor: true },
- { row: 3, column: 0, index: 0, data: '30', isAnchor: true },
- { row: 3, column: 1, index: 1, data: '31', isAnchor: true },
- { row: 3, column: 2, index: 2, data: '32', isAnchor: true }
+ { row: 0, column: 0, rowIndex: 0, index: 0, data: '00', width: 2, height: 3, isAnchor: true },
+ { row: 0, column: 1, rowIndex: 0, index: 0, data: '00', width: 2, height: 3, anchorColumn: 0 },
+ { row: 0, column: 2, rowIndex: 0, index: 1, data: '02', isAnchor: true },
+ { row: 2, column: 0, rowIndex: 2, index: 0, data: '00', width: 2, height: 3, anchorRow: 0 },
+ { row: 2, column: 1, rowIndex: 2, index: 0, data: '00', width: 2, height: 3, anchorRow: 0, anchorColumn: 0 },
+ { row: 2, column: 2, rowIndex: 2, index: 0, data: '22', isAnchor: true },
+ { row: 3, column: 0, rowIndex: 3, index: 0, data: '30', isAnchor: true },
+ { row: 3, column: 1, rowIndex: 3, index: 1, data: '31', isAnchor: true },
+ { row: 3, column: 2, rowIndex: 3, index: 2, data: '32', isAnchor: true }
], { includeAllSlots: true }, 1 );
} );
} );
diff --git a/packages/ckeditor5-table/theme/tablecaption.css b/packages/ckeditor5-table/theme/tablecaption.css
new file mode 100644
index 00000000000..b6eff989ade
--- /dev/null
+++ b/packages/ckeditor5-table/theme/tablecaption.css
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+:root {
+ --ck-color-table-caption-background: hsl(0, 0%, 97%);
+ --ck-color-table-caption-text: hsl(0, 0%, 20%);
+ --ck-color-table-caption-highlighted-background: hsl(52deg 100% 50%);
+}
+
+/* Content styles */
+.ck-content .table > figcaption {
+ display: table-caption;
+ caption-side: top;
+ word-break: break-word;
+ text-align: center;
+ color: var(--ck-color-table-caption-text);
+ background-color: var(--ck-color-table-caption-background);
+ padding: .6em;
+ font-size: .75em;
+ outline-offset: -1px;
+}
+
+/* Editing styles */
+.ck.ck-editor__editable .table > figcaption {
+ &.table__caption_highlighted {
+ animation: ck-table-caption-highlight .6s ease-out;
+ }
+
+ &.ck-placeholder::before {
+ padding-left: inherit;
+ padding-right: inherit;
+
+ /*
+ * Make sure the table caption placeholder doesn't overflow the placeholder area.
+ * See https://github.com/ckeditor/ckeditor5/issues/9162.
+ */
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+}
+
+@keyframes ck-table-caption-highlight {
+ 0% {
+ background-color: var(--ck-color-table-caption-highlighted-background);
+ }
+
+ 100% {
+ background-color: var(--ck-color-table-caption-background);
+ }
+}
diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-widget/widget.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-widget/widget.css
index 747d5d14938..4612987aa9b 100644
--- a/packages/ckeditor5-theme-lark/theme/ckeditor5-widget/widget.css
+++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-widget/widget.css
@@ -72,6 +72,7 @@
/* Place the drag handler outside the widget wrapper. */
transform: translateY(-100%);
left: calc(0px - var(--ck-widget-outline-thickness));
+ top: 0;
& .ck-icon {
/* Make sure the dimensions of the icon are independent of the fon-size of the content. */
diff --git a/tests/manual/all-features-dll.js b/tests/manual/all-features-dll.js
index 890e55b28ee..ba99f336bd8 100644
--- a/tests/manual/all-features-dll.js
+++ b/tests/manual/all-features-dll.js
@@ -55,7 +55,7 @@ const { FontColor, FontFamily, FontSize, FontBackgroundColor } = window.CKEditor
const { Indent, IndentBlock } = window.CKEditor5.indent;
const { List, ListStyle, TodoList } = window.CKEditor5.list;
const { SpecialCharacters, SpecialCharactersEssentials } = window.CKEditor5.specialCharacters;
-const { Table, TableToolbar, TableCellProperties, TableProperties } = window.CKEditor5.table;
+const { Table, TableToolbar, TableCellProperties, TableProperties, TableCaption } = window.CKEditor5.table;
const { Alignment } = window.CKEditor5.alignment;
const { Autoformat } = window.CKEditor5.autoformat;
const { BlockQuote } = window.CKEditor5.blockQuote;
@@ -127,7 +127,7 @@ const config = {
PasteFromOffice,
RemoveFormat,
SpecialCharacters, SpecialCharactersEssentials,
- Table, TableToolbar, TableCellProperties, TableProperties,
+ Table, TableToolbar, TableCellProperties, TableProperties, TableCaption,
TextPartLanguage,
WordCount
],
@@ -156,7 +156,7 @@ const config = {
],
cloudServices: CS_CONFIG,
table: {
- contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells', 'tableProperties', 'tableCellProperties' ]
+ contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells', 'tableProperties', 'tableCellProperties', 'toggleTableCaption' ]
},
image: {
styles: [
diff --git a/tests/manual/all-features.js b/tests/manual/all-features.js
index 0deed473cd9..28079cafe1b 100644
--- a/tests/manual/all-features.js
+++ b/tests/manual/all-features.js
@@ -35,6 +35,7 @@ import Subscript from '@ckeditor/ckeditor5-basic-styles/src/subscript';
import Superscript from '@ckeditor/ckeditor5-basic-styles/src/superscript';
import TableCellProperties from '@ckeditor/ckeditor5-table/src/tablecellproperties';
import TableProperties from '@ckeditor/ckeditor5-table/src/tableproperties';
+import TableCaption from '@ckeditor/ckeditor5-table/src/tablecaption';
import TextTransformation from '@ckeditor/ckeditor5-typing/src/texttransformation';
import TextPartLanguage from '@ckeditor/ckeditor5-language/src/textpartlanguage';
import TodoList from '@ckeditor/ckeditor5-list/src/todolist';
@@ -50,7 +51,7 @@ ClassicEditor
plugins: [
ArticlePluginSet, Underline, Strikethrough, Superscript, Subscript, Code, RemoveFormat,
FontColor, FontBackgroundColor, FontFamily, FontSize, Highlight,
- CodeBlock, TodoList, ListStyle, TableProperties, TableCellProperties,
+ CodeBlock, TodoList, ListStyle, TableProperties, TableCellProperties, TableCaption,
EasyImage, ImageResize, LinkImage, AutoImage, HtmlEmbed,
AutoLink, Mention, TextTransformation,
Alignment, IndentBlock,
@@ -81,7 +82,9 @@ ClassicEditor
],
cloudServices: CS_CONFIG,
table: {
- contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells', 'tableProperties', 'tableCellProperties' ]
+ contentToolbar: [
+ 'tableColumn', 'tableRow', 'mergeTableCells', 'tableProperties', 'tableCellProperties', 'toggleTableCaption'
+ ]
},
image: {
styles: [