diff --git a/docs/framework/architecture/plugins.md b/docs/framework/architecture/plugins.md index 7247fe78140..640e6701abf 100644 --- a/docs/framework/architecture/plugins.md +++ b/docs/framework/architecture/plugins.md @@ -1,9 +1,4 @@ --- -# Scope: -# * Introduction to plugins. -# * Exemplify use cases. -# * Point to resources to learn plugin development. - category: framework-architecture menu-title: Plugins in CKEditor 5 meta-title: Plugins in CKEditor 5 | CKEditor 5 Documentation @@ -11,7 +6,7 @@ toc-limit: 1 order: 10 --- -# Plugins in CKEditor 5 +# Plugins in CKEditor 5 Features in CKEditor are introduced by plugins. In fact, without plugins, CKEditor 5 is an empty API with no use. Plugins provided by the CKEditor core team are available in [npm](https://www.npmjs.com/search?q=ckeditor5) (and [GitHub](https://github.com/ckeditor?utf8=%E2%9C%93&q=ckeditor5&type=&language=), too) in the form of npm packages. A package may contain one or more plugins (for example, the [`@ckeditor/ckeditor5-image`](https://www.npmjs.com/package/@ckeditor/ckeditor5-image) package contains {@link features/images-overview several granular plugins}). diff --git a/packages/ckeditor5-html-support/docs/features/full-page-html.md b/packages/ckeditor5-html-support/docs/features/full-page-html.md index 24dbb682f30..27329fb21b8 100644 --- a/packages/ckeditor5-html-support/docs/features/full-page-html.md +++ b/packages/ckeditor5-html-support/docs/features/full-page-html.md @@ -35,12 +35,68 @@ ClassicEditor .create( document.querySelector( '#editor' ), { licenseKey: '', // Or 'GPL'. plugins: [ FullPage, /* ... */ ], + htmlSupport: { + fullPage: { + // Configuration. + } + } } ) .then( /* ... */ ) .catch( /* ... */ ); ``` +## Configuration + +### Render styles + +By default, the full page HTML feature does not render the CSS from `' + + '' + + '' + + '

foo

bar

' + + '' + + ''; + + const config = { + htmlSupport: { + fullPage: { + allowRenderStylesFromHead: true + } + } + }; + + await createEditor( content, config ); + + expect( editor.getData() ).to.equal( content ); + expect( document.querySelectorAll( 'style[data-full-page-style-id]' ) ).to.have.length( 1 ); + + const stylesheet = document.querySelectorAll( 'style[data-full-page-style-id]' )[ 0 ]; + + expect( stylesheet.textContent ).to.equal( 'p { color: red; }' ); + expect( stylesheet.getAttribute( 'data-full-page-style-id' ) ).to.equal( editor.id ); + } ); + + it( 'should remove previously attached `' + + '' + + '' + + '

foo

bar

' + + '' + + ''; + + const config = { + htmlSupport: { + fullPage: { + allowRenderStylesFromHead: true + } + } + }; + + await createEditor( content, config ); + + expect( editor.getData() ).to.equal( content ); + expect( document.querySelectorAll( 'style[data-full-page-style-id]' ) ).to.have.length( 1 ); + + const stylesheet = document.querySelectorAll( 'style[data-full-page-style-id]' )[ 0 ]; + + expect( stylesheet.textContent ).to.equal( 'p { color: red; }' ); + expect( stylesheet.getAttribute( 'data-full-page-style-id' ) ).to.equal( editor.id ); + + const contentToSet = + '' + + '' + + 'Testing full page' + + '' + + '' + + '' + + '

foo

bar

baz

' + + '' + + ''; + + editor.setData( contentToSet ); + + expect( editor.getData() ).to.equal( contentToSet ); + + expect( document.querySelectorAll( 'style[data-full-page-style-id]' ) ).to.have.length( 1 ); + + const stylesheetUpdated = document.querySelectorAll( 'style[data-full-page-style-id]' )[ 0 ]; + + expect( stylesheetUpdated.textContent ).to.equal( 'p { color: green; }' ); + expect( stylesheetUpdated.getAttribute( 'data-full-page-style-id' ) ).to.equal( editor.id ); + } ); + } ); + + describe( 'default `htmlSupport.fullPage.sanitizeCss`', () => { + let config = ''; + let fullPageConfig; + + beforeEach( async () => { + config = { + htmlSupport: { + fullPage: { + allowRenderStylesFromHead: true + } + } + }; + + await createEditor( '', config ); + + fullPageConfig = editor.config.get( 'htmlSupport.fullPage' ); + } ); + + it( 'should return an object with cleaned css and a note whether something has changed', async () => { + expect( fullPageConfig.sanitizeCss( 'p { color: red; }' ) ).to.deep.equal( { + css: 'p { color: red; }', + hasChanged: false + } ); + } ); + + it( 'should return an input string (without any modifications)', () => { + const unsafeCss = 'input[value="a"] { background: url(https://example.com/?value=a); }'; + + expect( fullPageConfig.sanitizeCss( unsafeCss ).css ).to.deep.equal( unsafeCss ); + } ); + + it( 'should display a warning when using the default sanitizer', () => { + fullPageConfig.sanitizeCss( 'p { color: red; }' ); + + expect( console.warn.callCount ).to.equal( 1 ); + expect( console.warn.firstCall.args[ 0 ] ).to.equal( 'css-full-page-provide-sanitize-function' ); + } ); + } ); + + describe( 'custom `htmlSupport.fullPage.sanitizeCss`', () => { + let config = ''; + let fullPageConfig; + + beforeEach( async () => { + config = { + htmlSupport: { + fullPage: { + allowRenderStylesFromHead: true, + sanitizeCss: rawCss => { + const cleanCss = rawCss.replace( /color: red;/g, 'color: #c0ffee;' ); + + return { + css: cleanCss, + hasChanged: rawCss !== cleanCss + }; + } + } + } + }; + + await createEditor( '', config ); + + fullPageConfig = editor.config.get( 'htmlSupport.fullPage' ); + } ); + + it( 'should return an object with cleaned css and a note whether something has changed', async () => { + expect( fullPageConfig.sanitizeCss( 'p { color: red; }' ) ).to.deep.equal( { + css: 'p { color: #c0ffee; }', + hasChanged: true + } ); + } ); + + it( 'should return an input string (without any modifications)', () => { + const unsafeCss = 'input[value="a"] { background: url(https://example.com/?value=a); }'; + + expect( fullPageConfig.sanitizeCss( unsafeCss ).css ).to.deep.equal( unsafeCss ); + } ); + + it( 'should allow to extract and append `' + + '' + + '' + + '

foo

bar

' + + '' + + ''; + + editor.setData( content ); + + expect( editor.getData() ).to.equal( content ); + expect( document.querySelectorAll( 'style[data-full-page-style-id]' ) ).to.have.length( 1 ); + + const stylesheet = document.querySelectorAll( 'style[data-full-page-style-id]' )[ 0 ]; + + expect( stylesheet.textContent ).to.equal( 'p { color: #c0ffee; }' ); + } ); + + it( 'should not display a warning when using the custom sanitizer', () => { + fullPageConfig.sanitizeCss( 'p { color: red; }' ); + + expect( console.warn.callCount ).to.equal( 0 ); + } ); + } ); + } ); + + describe( 'HtmlComment integration', () => { + it( 'should preserve comments', async () => { + const content = + '\n' + + '\n' + + '' + + 'Testing full page' + + '' + + '' + + '

foo

bar

' + + '' + + ''; + + await createEditor( content, { + plugins: [ Paragraph, ClipboardPipeline, HtmlComment, FullPage ] + } ); + + expect( editor.getData() ).to.equal( content ); + } ); + } ); + + async function createEditor( initialData, fullPageConfig = null ) { editor = await VirtualTestEditor.create( { plugins: [ Paragraph, ClipboardPipeline, FullPage ], - initialData + initialData, + ...fullPageConfig } ); // Stub `editor.editing.view.scrollToTheSelection` as it will fail on VirtualTestEditor without DOM. diff --git a/packages/ckeditor5-html-support/tests/manual/fullpage-with-head-styles.html b/packages/ckeditor5-html-support/tests/manual/fullpage-with-head-styles.html new file mode 100644 index 00000000000..624d2e6b7b6 --- /dev/null +++ b/packages/ckeditor5-html-support/tests/manual/fullpage-with-head-styles.html @@ -0,0 +1 @@ +
diff --git a/packages/ckeditor5-html-support/tests/manual/fullpage-with-head-styles.js b/packages/ckeditor5-html-support/tests/manual/fullpage-with-head-styles.js new file mode 100644 index 00000000000..22febdc9ea1 --- /dev/null +++ b/packages/ckeditor5-html-support/tests/manual/fullpage-with-head-styles.js @@ -0,0 +1,82 @@ +/** + * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +/* global document, window, console */ + +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor.js'; +import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset.js'; +import SourceEditing from '@ckeditor/ckeditor5-source-editing/src/sourceediting.js'; + +import FullPage from '../../src/fullpage.js'; + +const initialData = ` + + + + + Page title + + + + + + +

Heading

+

Page content

+ +`; + +ClassicEditor + .create( document.querySelector( '#editor' ), { + plugins: [ + ArticlePluginSet, + SourceEditing, + FullPage + ], + htmlSupport: { + fullPage: { + allowRenderStylesFromHead: true, + sanitizeCss: rawCss => { + const cleanCss = rawCss.replace( /color: green;/g, '' ); + + return { + css: cleanCss, + hasChanged: rawCss !== cleanCss + }; + } + } + }, + toolbar: [ + 'sourceEditing', '|', + 'heading', '|', 'bold', 'italic', 'link', '|', + 'bulletedList', 'numberedList', '|', + 'blockQuote', 'insertTable', '|', + 'undo', 'redo' + ], + image: { + toolbar: [ + 'imageStyle:inline', + 'imageStyle:block', + 'imageStyle:wrapText', + '|', + 'toggleImageCaption', + 'imageTextAlternative' + ] + }, + table: { + contentToolbar: [ + 'tableColumn', + 'tableRow', + 'mergeTableCells' + ] + }, + initialData + } ) + .then( editor => { + window.editor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-html-support/tests/manual/fullpage-with-head-styles.md b/packages/ckeditor5-html-support/tests/manual/fullpage-with-head-styles.md new file mode 100644 index 00000000000..985d50e1301 --- /dev/null +++ b/packages/ckeditor5-html-support/tests/manual/fullpage-with-head-styles.md @@ -0,0 +1,7 @@ +## Styles rendering from + +When `fullPage.allowRenderStylesFromHead` is set to `true` styles from `` are rendered. + +With this option on we recommend to use a CSS sanitizer function to increase the security. + +In this manual test sanitizer function is added and it simply removes `color: green;` from stylesheet; This property is initially added to the `h2` but as you can see the headline is not green but black (default color). When you click the `Source` button, you will see that style that is set to the `h2` remains the same. Sanitizer function only works during rendering the style on page while editing it in the editor. It does not affect the source style. diff --git a/packages/ckeditor5-html-support/tests/manual/fullpage.js b/packages/ckeditor5-html-support/tests/manual/fullpage.js index 0457bf6cc9b..ecaff281451 100644 --- a/packages/ckeditor5-html-support/tests/manual/fullpage.js +++ b/packages/ckeditor5-html-support/tests/manual/fullpage.js @@ -18,11 +18,12 @@ const initialData = ` Page title - + +

Heading

Page content

`; diff --git a/packages/ckeditor5-html-support/tests/manual/sample.mp4 b/packages/ckeditor5-html-support/tests/manual/sample.mp4 new file mode 100644 index 00000000000..72175a9b7d3 Binary files /dev/null and b/packages/ckeditor5-html-support/tests/manual/sample.mp4 differ diff --git a/packages/ckeditor5-html-support/tests/manual/sample.webp b/packages/ckeditor5-html-support/tests/manual/sample.webp new file mode 100644 index 00000000000..eab75d66ecb Binary files /dev/null and b/packages/ckeditor5-html-support/tests/manual/sample.webp differ diff --git a/packages/ckeditor5-icons/src/index.ts b/packages/ckeditor5-icons/src/index.ts index f0921f9b274..a848d7aee3d 100644 --- a/packages/ckeditor5-icons/src/index.ts +++ b/packages/ckeditor5-icons/src/index.ts @@ -152,6 +152,7 @@ export { default as IconTableOfContents } from '../theme/icons/table-of-contents export { default as IconTableProperties } from '../theme/icons/table-properties.svg'; export { default as IconTableRow } from '../theme/icons/table-row.svg'; export { default as IconTable } from '../theme/icons/table.svg'; +export { default as IconTableLayout } from '../theme/icons/table.svg'; // TODO: Add table layout icon. export { default as IconTemplateGeneric } from '../theme/icons/template-generic.svg'; export { default as IconTemplate } from '../theme/icons/template.svg'; export { default as IconTextAlternative } from '../theme/icons/text-alternative.svg'; diff --git a/packages/ckeditor5-table/lang/contexts.json b/packages/ckeditor5-table/lang/contexts.json index 8375f3e8b45..1b25f1e105a 100644 --- a/packages/ckeditor5-table/lang/contexts.json +++ b/packages/ckeditor5-table/lang/contexts.json @@ -1,5 +1,6 @@ { "Insert table": "Label for the insert table toolbar button.", + "Insert table layout": "Label for the insert table layout toolbar button.", "Header column": "Label for the set/unset table header column button.", "Insert column left": "Label for the insert table column to the left of the current one button.", "Insert column right": "Label for the insert table column to the right of the current one button.", @@ -62,5 +63,6 @@ "Move the selection to the previous cell": "Keystroke description for assistive technologies: keystroke for moving the selection to the previous cell.", "Insert a new table row (when in the last cell of a table)": "Keystroke description for assistive technologies: keystroke for inserting a new table row.", "Navigate through the table": "Keystroke description for assistive technologies: keystroke for navigating through the table.", - "Table": "The accessible label of the menu bar button that displays a user interface to insert a table into editor content." + "Table": "The accessible label of the menu bar button that displays a user interface to insert a table into editor content.", + "Table layout": "The accessible label of the menu bar button that displays a user interface to insert a table layout into editor content." } diff --git a/packages/ckeditor5-table/src/augmentation.ts b/packages/ckeditor5-table/src/augmentation.ts index 49636dc9073..84ddb9da0b0 100644 --- a/packages/ckeditor5-table/src/augmentation.ts +++ b/packages/ckeditor5-table/src/augmentation.ts @@ -21,6 +21,8 @@ import type { TableColumnResizeEditing, TableEditing, TableKeyboard, + TableLayout, + TableLayoutEditing, TableMouse, TableProperties, TablePropertiesEditing, @@ -35,6 +37,7 @@ import type { InsertColumnCommand, InsertRowCommand, InsertTableCommand, + InsertTableLayoutCommand, MergeCellCommand, MergeCellsCommand, RemoveColumnCommand, @@ -88,6 +91,8 @@ declare module '@ckeditor/ckeditor5-core' { [ TableColumnResizeEditing.pluginName ]: TableColumnResizeEditing; [ TableEditing.pluginName ]: TableEditing; [ TableKeyboard.pluginName ]: TableKeyboard; + [ TableLayout.pluginName ]: TableLayout; + [ TableLayoutEditing.pluginName ]: TableLayoutEditing; [ TableMouse.pluginName ]: TableMouse; [ TableProperties.pluginName ]: TableProperties; [ TablePropertiesEditing.pluginName ]: TablePropertiesEditing; @@ -105,6 +110,7 @@ declare module '@ckeditor/ckeditor5-core' { insertTableRowAbove: InsertRowCommand; insertTableRowBelow: InsertRowCommand; insertTable: InsertTableCommand; + insertTableLayout: InsertTableLayoutCommand; mergeTableCellRight: MergeCellCommand; mergeTableCellLeft: MergeCellCommand; mergeTableCellDown: MergeCellCommand; diff --git a/packages/ckeditor5-table/src/commands/inserttablelayoutcommand.ts b/packages/ckeditor5-table/src/commands/inserttablelayoutcommand.ts new file mode 100644 index 00000000000..0643a819e60 --- /dev/null +++ b/packages/ckeditor5-table/src/commands/inserttablelayoutcommand.ts @@ -0,0 +1,86 @@ +/** + * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +/** + * @module table/commands/inserttablelayoutcommand + */ + +import { Command } from 'ckeditor5/src/core.js'; + +import type { + DocumentSelection, + Schema, + Selection, + Element +} from 'ckeditor5/src/engine.js'; + +import type TableUtils from '../tableutils.js'; + +/** + * The insert table layout command. + * + * The command is registered by {@link module:table/tablelayout/tablelayoutediting~TableLayoutEditing} + * as the `'insertTableLayout'` editor command. + * + * To insert a layout table at the current selection, execute the command and specify the dimensions: + * + * ```ts + * editor.execute( 'insertTableLayout', { rows: 20, columns: 5 } ); + * ``` + */ +export default class InsertTableLayoutCommand extends Command { + /** + * @inheritDoc + */ + public override refresh(): void { + const model = this.editor.model; + const selection = model.document.selection; + const schema = model.schema; + + this.isEnabled = isAllowedInParent( selection, schema ); + } + + /** + * Executes the command. + * + * Inserts a layout table with the given number of rows and columns into the editor. + * + * @param options.rows The number of rows to create in the inserted table. Default value is 2. + * @param options.columns The number of columns to create in the inserted table. Default value is 2. + * @fires execute + */ + public override execute( + options: { + rows?: number; + columns?: number; + } = {} + ): void { + const editor = this.editor; + const model = editor.model; + const tableUtils: TableUtils = editor.plugins.get( 'TableUtils' ); + + model.change( writer => { + const normalizedOptions = { rows: options.rows, columns: options.columns }; + const table = tableUtils.createTable( writer, normalizedOptions ); + + writer.setAttribute( 'tableWidth', '100%', table ); + writer.setAttribute( 'tableType', 'layout', table ); + + model.insertObject( table, null, null, { findOptimalPosition: 'auto' } ); + + writer.setSelection( writer.createPositionAt( table.getNodeByPath( [ 0, 0, 0 ] ), 0 ) ); + } ); + } +} + +/** + * Checks if the table is allowed in the parent. + */ +function isAllowedInParent( selection: Selection | DocumentSelection, schema: Schema ) { + const positionParent = selection.getFirstPosition()!.parent; + const validParent = positionParent === positionParent.root ? positionParent : positionParent.parent; + + return schema.checkChild( validParent as Element, 'table' ); +} diff --git a/packages/ckeditor5-table/src/commands/setheadercolumncommand.ts b/packages/ckeditor5-table/src/commands/setheadercolumncommand.ts index 1abcdf8dc60..65f896c8356 100644 --- a/packages/ckeditor5-table/src/commands/setheadercolumncommand.ts +++ b/packages/ckeditor5-table/src/commands/setheadercolumncommand.ts @@ -44,14 +44,22 @@ export default class SetHeaderColumnCommand extends Command { * @inheritDoc */ public override refresh(): void { - const model = this.editor.model; const tableUtils: TableUtils = this.editor.plugins.get( 'TableUtils' ); + const model = this.editor.model; const selectedCells = tableUtils.getSelectionAffectedTableCells( model.document.selection ); - const isInTable = selectedCells.length > 0; - this.isEnabled = isInTable; - this.value = isInTable && selectedCells.every( cell => isHeadingColumnCell( tableUtils, cell ) ); + if ( selectedCells.length === 0 ) { + this.isEnabled = false; + this.value = false; + + return; + } + + const table = selectedCells[ 0 ].findAncestor( 'table' )!; + + this.isEnabled = model.schema.checkAttribute( table, 'headingColumns' ); + this.value = selectedCells.every( cell => isHeadingColumnCell( tableUtils, cell ) ); } /** diff --git a/packages/ckeditor5-table/src/commands/setheaderrowcommand.ts b/packages/ckeditor5-table/src/commands/setheaderrowcommand.ts index 9792a5908dd..187ef7a4294 100644 --- a/packages/ckeditor5-table/src/commands/setheaderrowcommand.ts +++ b/packages/ckeditor5-table/src/commands/setheaderrowcommand.ts @@ -43,11 +43,20 @@ export default class SetHeaderRowCommand extends Command { public override refresh(): void { const tableUtils: TableUtils = this.editor.plugins.get( 'TableUtils' ); const model = this.editor.model; + const selectedCells = tableUtils.getSelectionAffectedTableCells( model.document.selection ); - const isInTable = selectedCells.length > 0; - this.isEnabled = isInTable; - this.value = isInTable && selectedCells.every( cell => this._isInHeading( cell, cell.parent!.parent as Element ) ); + if ( selectedCells.length === 0 ) { + this.isEnabled = false; + this.value = false; + + return; + } + + const table = selectedCells[ 0 ].findAncestor( 'table' )!; + + this.isEnabled = model.schema.checkAttribute( table, 'headingRows' ); + this.value = selectedCells.every( cell => this._isInHeading( cell, cell.parent!.parent as Element ) ); } /** diff --git a/packages/ckeditor5-table/src/index.ts b/packages/ckeditor5-table/src/index.ts index 78ec1e25193..49b211aea99 100644 --- a/packages/ckeditor5-table/src/index.ts +++ b/packages/ckeditor5-table/src/index.ts @@ -16,6 +16,8 @@ export { default as TableCellProperties } from './tablecellproperties.js'; export { default as TableCellPropertiesEditing } from './tablecellproperties/tablecellpropertiesediting.js'; export { default as TableCellPropertiesUI } from './tablecellproperties/tablecellpropertiesui.js'; export { default as TableCellWidthEditing } from './tablecellwidth/tablecellwidthediting.js'; +export { default as TableLayout } from './tablelayout.js'; +export { default as TableLayoutEditing } from './tablelayout/tablelayoutediting.js'; export { default as TableProperties } from './tableproperties.js'; export { default as TablePropertiesEditing } from './tableproperties/tablepropertiesediting.js'; export { default as TablePropertiesUI } from './tableproperties/tablepropertiesui.js'; @@ -34,6 +36,7 @@ export type { TableConfig } from './tableconfig.js'; export type { default as InsertColumnCommand } from './commands/insertcolumncommand.js'; export type { default as InsertRowCommand } from './commands/insertrowcommand.js'; export type { default as InsertTableCommand } from './commands/inserttablecommand.js'; +export type { default as InsertTableLayoutCommand } from './commands/inserttablelayoutcommand.js'; export type { default as MergeCellCommand } from './commands/mergecellcommand.js'; export type { default as MergeCellsCommand } from './commands/mergecellscommand.js'; export type { default as RemoveColumnCommand } from './commands/removecolumncommand.js'; diff --git a/packages/ckeditor5-table/src/plaintableoutput.ts b/packages/ckeditor5-table/src/plaintableoutput.ts index 01024052298..d8ff0b48946 100644 --- a/packages/ckeditor5-table/src/plaintableoutput.ts +++ b/packages/ckeditor5-table/src/plaintableoutput.ts @@ -8,7 +8,7 @@ */ import { Plugin, type Editor } from 'ckeditor5/src/core.js'; -import type { DowncastWriter, Element, Node, ViewContainerElement } from 'ckeditor5/src/engine.js'; +import type { DowncastWriter, Element, Node, ViewContainerElement, UpcastElementEvent } from 'ckeditor5/src/engine.js'; import Table from './table.js'; @@ -67,6 +67,15 @@ export default class PlainTableOutput extends Plugin { if ( editor.plugins.has( 'TableProperties' ) ) { downcastTableBorderAndBackgroundAttributes( editor ); } + + editor.conversion.for( 'upcast' ).add( dispatcher => { + dispatcher.on( 'element:table', ( evt, data, conversionApi ) => { + // It's not necessary to upcast the `table` class. This class was only added in data downcast + // to center a plain table in the editor output. + // See: https://github.com/ckeditor/ckeditor5/issues/17888. + conversionApi.consumable.consume( data.viewItem, { classes: 'table' } ); + } ); + } ); } } @@ -121,7 +130,7 @@ function downcastTableElement( table: Element, { writer }: { writer: DowncastWri // {table-body-rows-slot} // // - return writer.createContainerElement( 'table', null, [ childrenSlot, ...tableContentElements ] ); + return writer.createContainerElement( 'table', { class: 'table' }, [ childrenSlot, ...tableContentElements ] ); } /** diff --git a/packages/ckeditor5-table/src/tablecaption/toggletablecaptioncommand.ts b/packages/ckeditor5-table/src/tablecaption/toggletablecaptioncommand.ts index 70dd2f58cd9..183d57a09c5 100644 --- a/packages/ckeditor5-table/src/tablecaption/toggletablecaptioncommand.ts +++ b/packages/ckeditor5-table/src/tablecaption/toggletablecaptioncommand.ts @@ -47,7 +47,7 @@ export default class ToggleTableCaptionCommand extends Command { const editor = this.editor; const tableElement = getSelectionAffectedTable( editor.model.document.selection ); - this.isEnabled = !!tableElement; + this.isEnabled = !!tableElement && editor.model.schema.checkChild( tableElement, 'caption' ); if ( !this.isEnabled ) { this.value = false; diff --git a/packages/ckeditor5-table/src/tablecolumnresize/tablecolumnresizeediting.ts b/packages/ckeditor5-table/src/tablecolumnresize/tablecolumnresizeediting.ts index e728e30fd98..4fd0f953e5b 100644 --- a/packages/ckeditor5-table/src/tablecolumnresize/tablecolumnresizeediting.ts +++ b/packages/ckeditor5-table/src/tablecolumnresize/tablecolumnresizeediting.ts @@ -386,9 +386,8 @@ export default class TableColumnResizeEditing extends Plugin { // Table width style conversion.for( 'upcast' ).attributeToAttribute( { view: { - name: 'figure', - key: 'style', - value: { + name: /^(figure|table)$/, + styles: { width: /[\s\S]+/ } }, diff --git a/packages/ckeditor5-table/src/tablelayout.ts b/packages/ckeditor5-table/src/tablelayout.ts new file mode 100644 index 00000000000..c249a3cdc88 --- /dev/null +++ b/packages/ckeditor5-table/src/tablelayout.ts @@ -0,0 +1,41 @@ +/** + * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +/** + * @module table/tablelayout + */ + +import { Plugin } from 'ckeditor5/src/core.js'; +import TableLayoutUI from './tablelayout/tablelayoutui.js'; + +import TableLayoutEditing from './tablelayout/tablelayoutediting.js'; +import PlainTableOutput from './plaintableoutput.js'; +import TableColumnResize from './tablecolumnresize.js'; + +/** + * The table layout plugin. + */ +export default class TableLayout extends Plugin { + /** + * @inheritDoc + */ + public static get pluginName() { + return 'TableLayout' as const; + } + + /** + * @inheritDoc + */ + public static override get isOfficialPlugin(): true { + return true; + } + + /** + * @inheritDoc + */ + public static get requires() { + return [ PlainTableOutput, TableColumnResize, TableLayoutEditing, TableLayoutUI ] as const; + } +} diff --git a/packages/ckeditor5-table/src/tablelayout/tablelayoutediting.ts b/packages/ckeditor5-table/src/tablelayout/tablelayoutediting.ts new file mode 100644 index 00000000000..035bba065dd --- /dev/null +++ b/packages/ckeditor5-table/src/tablelayout/tablelayoutediting.ts @@ -0,0 +1,291 @@ +/** + * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +/** + * @module table/tablelayout/tablelayoutediting + */ + +import { Plugin } from 'ckeditor5/src/core.js'; +import type { ClipboardContentInsertionEvent, ClipboardPipeline } from 'ckeditor5/src/clipboard.js'; +import type { + DowncastDispatcher, + UpcastDispatcher, + UpcastElementEvent, + ViewElement, + SchemaContext, + Writer +} from 'ckeditor5/src/engine.js'; + +import InsertTableLayoutCommand from './../commands/inserttablelayoutcommand.js'; +import { createEmptyTableCell } from '../utils/common.js'; + +/** + * The table layout editing plugin. + */ +export default class TableLayoutEditing extends Plugin { + /** + * @inheritDoc + */ + public static get pluginName() { + return 'TableLayoutEditing' as const; + } + + /** + * @inheritDoc + */ + public static override get isOfficialPlugin(): true { + return true; + } + + /** + * @inheritDoc + */ + public init(): void { + this._defineSchema(); + this._defineConverters(); + this._defineClipboardPasteHandlers(); + this._registerTableTypeAttributePostfixer(); + this.editor.commands.add( 'insertTableLayout', new InsertTableLayoutCommand( this.editor ) ); + } + + /** + * Defines the schema for the table layout feature. + */ + private _defineSchema() { + const { schema } = this.editor.model; + + schema.extend( 'table', { + allowAttributes: 'tableType' + } ); + + // Disallow adding `caption` to layout table. + schema.addChildCheck( layoutTableCheck, 'caption' ); + + // Disallow adding `headingRows` attribute to layout table. + schema.addAttributeCheck( layoutTableCheck, 'headingRows' ); + + // Disallow adding `headingColumns` attribute to layout table. + schema.addAttributeCheck( layoutTableCheck, 'headingColumns' ); + } + + /** + * Defines the converters for the table layout feature. + */ + private _defineConverters() { + const { editor } = this; + const { conversion } = editor; + + conversion.for( 'upcast' ).add( upcastLayoutTable() ); + conversion.for( 'dataDowncast' ).add( dataDowncastLayoutTable() ); + conversion.for( 'editingDowncast' ).attributeToAttribute( { + model: { + key: 'tableType', + values: [ 'layout', 'content' ] + }, + view: { + layout: { + key: 'class', + value: [ 'layout-table' ] + }, + content: { + key: 'class', + value: [ 'content-table' ] + } + } + } ); + } + + /** + * Handles the clipboard content insertion events. + * + * - If the content is from another editor, do not override the table type. + * - If the content is from another source, set the table type to 'content'. + * + * It handles the scenario when user copies `
` from Word. We do not want to + * change the table type to `layout` because it is really `content` table. + */ + private _defineClipboardPasteHandlers(): void { + const { plugins } = this.editor; + + if ( !plugins.has( 'ClipboardPipeline' ) ) { + return; + } + + const clipboardPipeline: ClipboardPipeline = plugins.get( 'ClipboardPipeline' ); + + this.listenTo( clipboardPipeline, 'contentInsertion', ( evt, data ) => { + // If content is pasted from the other editor, skip overriding table type. + if ( data.sourceEditorId ) { + return; + } + + // For content from other sources, always set table type to 'content'. + this.editor.model.change( writer => { + for ( const { item } of writer.createRangeIn( data.content ) ) { + if ( item.is( 'element', 'table' ) ) { + writer.setAttribute( 'tableType', 'content', item ); + } + } + } ); + } ); + } + + /** + * Registers a post-fixer that sets the `tableType` attribute to `content` for inserted "default" tables. + */ + private _registerTableTypeAttributePostfixer() { + const editor = this.editor; + + editor.model.document.registerPostFixer( ( writer: Writer ) => { + const changes = editor.model.document.differ.getChanges(); + let hasChanged = false; + + for ( const entry of changes ) { + if ( entry.type == 'insert' && entry.name != '$text' ) { + const element = entry.position.nodeAfter!; + const range = writer.createRangeOn( element ); + + for ( const item of range.getItems() ) { + if ( item.is( 'element', 'table' ) && !item.hasAttribute( 'tableType' ) ) { + writer.setAttribute( 'tableType', 'content', item ); + hasChanged = true; + } + } + } + } + + return hasChanged; + } ); + } +} + +/** + * View table element to model table element conversion helper. + * + * This conversion helper overrides the default table converter to meet table layout conditions. + * + * @returns Conversion helper. + */ +function upcastLayoutTable() { + return ( dispatcher: UpcastDispatcher ): void => { + dispatcher.on( 'element:table', ( evt, data, conversionApi ) => { + const viewTable = data.viewItem; + + if ( !conversionApi.consumable.test( viewTable, { name: true } ) ) { + return; + } + + const hasTableTypeContent = isTableTypeContent( viewTable ); + + // When element is a content table then skip it. + if ( hasTableTypeContent ) { + return; + } + + const table = conversionApi.writer.createElement( 'table' ); + + if ( !conversionApi.safeInsert( table, data.modelCursor ) ) { + return; + } + + conversionApi.consumable.consume( viewTable, { name: true } ); + conversionApi.consumable.consume( viewTable, { attributes: [ 'role' ] } ); + + // Get all rows from the table and convert them. + // While looping over the children of `` we can be sure that first will be `` + // and optionally `` and ``, and in these elements are the table rows found. + // We can be sure of that because of `DomParser` handle it. + for ( const tableChild of viewTable.getChildren() ) { + if ( tableChild.is( 'element' ) ) { + for ( const row of tableChild.getChildren() ) { + if ( row.is( 'element', 'tr' ) ) { + 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' ); + + conversionApi.writer.insert( row, conversionApi.writer.createPositionAt( table, 'end' ) ); + createEmptyTableCell( conversionApi.writer, conversionApi.writer.createPositionAt( row, 'end' ) ); + } + + conversionApi.updateConversionResult( table, data ); + }, { priority: 'high' } ); + + // Sets only the table type attribute. + dispatcher.on( 'element:table', ( evt, data, conversionApi ) => { + const { viewItem, modelRange } = data; + + if ( modelRange ) { + conversionApi.writer.setAttribute( + 'tableType', + isTableTypeContent( viewItem ) ? 'content' : 'layout', + modelRange + ); + } + }, { priority: 'low' } ); + }; +} + +/** + * Model table container element to view table element conversion helper. + * + * @returns Conversion helper. + */ +function dataDowncastLayoutTable() { + return ( dispatcher: DowncastDispatcher ): void => { + return dispatcher.on( 'attribute:tableType:table', ( evt, data, conversionApi ) => { + const { item, attributeNewValue } = data; + const { mapper, writer } = conversionApi; + + if ( !conversionApi.consumable.test( item, evt.name ) ) { + return; + } + + const table = mapper.toViewElement( item ); + + writer.addClass( `${ attributeNewValue }-table`, table ); + + if ( attributeNewValue == 'layout' ) { + writer.setAttribute( 'role', 'presentation', table ); + } + + conversionApi.consumable.consume( item, evt.name ); + } ); + }; +} + +/** + * Checks if the table is a content table. + * Returns `true` if any of the following conditions are met: + * - the `
` is wrapped with `
`, + * - the `
` has class `content-table` + * - the `
` has a `
` element. + * `false` otherwise. + */ +function isTableTypeContent( viewTable: ViewElement ): boolean { + const parent = viewTable.parent!; + + return parent.is( 'element', 'figure' ) || + viewTable.hasClass( 'content-table' ) || + Array.from( viewTable.getChildren() ).some( child => child.is( 'element', 'caption' ) ); +} + +/** + * Checks if the element is a layout table. + * It is used to disallow attributes or children that is managed by `Schema`. + */ +function layoutTableCheck( context: SchemaContext ) { + if ( context.endsWith( 'table' ) && context.last.getAttribute( 'tableType' ) == 'layout' ) { + return false; + } +} diff --git a/packages/ckeditor5-table/src/tablelayout/tablelayoutui.ts b/packages/ckeditor5-table/src/tablelayout/tablelayoutui.ts new file mode 100644 index 00000000000..11be9c8878d --- /dev/null +++ b/packages/ckeditor5-table/src/tablelayout/tablelayoutui.ts @@ -0,0 +1,123 @@ +/** + * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +/** + * @module table/tablelayout/tablelayoutui + */ + +import { Plugin } from 'ckeditor5/src/core.js'; +import { IconTableLayout } from 'ckeditor5/src/icons.js'; // TODO: Remember to update in ckeditor5-icons when icon is ready. +import { + createDropdown, + MenuBarMenuView +} from 'ckeditor5/src/ui.js'; +import type { ObservableChangeEvent } from 'ckeditor5/src/utils.js'; + +import InsertTableView from '../ui/inserttableview.js'; +import InsertTableLayoutCommand from '../commands/inserttablelayoutcommand.js'; + +/** + * The table layout UI plugin. It introduces: + * + * * The `'insertTableLayout'` dropdown, + * * The `'menuBar:insertTableLayout'` menu bar menu. + */ +export default class TableLayoutUI extends Plugin { + /** + * @inheritDoc + */ + public static get pluginName() { + return 'TableLayoutUI' as const; + } + + /** + * @inheritDoc + */ + public static override get isOfficialPlugin(): true { + return true; + } + + /** + * @inheritDoc + */ + public init(): void { + const editor = this.editor; + const t = this.editor.t; + + // TODO: Remove after merging with the editing part. + editor.commands.add( 'insertTableLayout', new InsertTableLayoutCommand( editor ) ); + + editor.ui.componentFactory.add( 'insertTableLayout', locale => { + const command: InsertTableLayoutCommand = editor.commands.get( 'insertTableLayout' )!; + const dropdownView = createDropdown( locale ); + + dropdownView.bind( 'isEnabled' ).to( command ); + + // Decorate dropdown's button. + dropdownView.buttonView.set( { + icon: IconTableLayout, + label: t( 'Insert table layout' ), + tooltip: true + } ); + + let insertTableLayoutView: InsertTableView; + + dropdownView.on( 'change:isOpen', () => { + if ( insertTableLayoutView ) { + return; + } + + // Prepare custom view for dropdown's panel. + insertTableLayoutView = new InsertTableView( locale ); + dropdownView.panelView.children.add( insertTableLayoutView ); + + insertTableLayoutView.delegate( 'execute' ).to( dropdownView ); + + dropdownView.on( 'execute', () => { + editor.execute( 'insertTableLayout', { + rows: insertTableLayoutView.rows, + columns: insertTableLayoutView.columns + } ); + editor.editing.view.focus(); + } ); + } ); + + return dropdownView; + } ); + + editor.ui.componentFactory.add( 'menuBar:insertTableLayout', locale => { + const command: InsertTableLayoutCommand = editor.commands.get( 'insertTableLayout' )!; + const menuView = new MenuBarMenuView( locale ); + const insertTableLayoutView = new InsertTableView( locale ); + + insertTableLayoutView.delegate( 'execute' ).to( menuView ); + + menuView.on>( 'change:isOpen', ( event, name, isOpen ) => { + if ( !isOpen ) { + insertTableLayoutView.reset(); + } + } ); + + insertTableLayoutView.on( 'execute', () => { + editor.execute( 'insertTableLayout', { + rows: insertTableLayoutView.rows, + columns: insertTableLayoutView.columns + } ); + editor.editing.view.focus(); + } ); + + menuView.buttonView.set( { + label: t( 'Table layout' ), + icon: IconTableLayout + } ); + + menuView.panelView.children.add( insertTableLayoutView ); + + menuView.bind( 'isEnabled' ).to( command ); + + return menuView; + } ); + } +} diff --git a/packages/ckeditor5-table/src/utils/structure.ts b/packages/ckeditor5-table/src/utils/structure.ts index a7c382dfdf8..d51be03fc0d 100644 --- a/packages/ckeditor5-table/src/utils/structure.ts +++ b/packages/ckeditor5-table/src/utils/structure.ts @@ -59,8 +59,17 @@ export function cropTableToDimensions( ): Element { const { startRow, startColumn, endRow, endColumn } = cropDimensions; - // Create empty table with empty rows equal to crop height. + // Initialize the cropped table element. const croppedTable = writer.createElement( 'table' ); + + // Copy table type attribute if present. + const sourceTableType = sourceTable.getAttribute( 'tableType' ); + + if ( sourceTableType ) { + writer.setAttribute( 'tableType', sourceTableType, croppedTable ); + } + + // Create empty table with empty rows equal to crop height. const cropHeight = endRow - startRow + 1; for ( let i = 0; i < cropHeight; i++ ) { diff --git a/packages/ckeditor5-table/tests/commands/inserttablelayoutcommand.js b/packages/ckeditor5-table/tests/commands/inserttablelayoutcommand.js new file mode 100644 index 00000000000..de77e48ea52 --- /dev/null +++ b/packages/ckeditor5-table/tests/commands/inserttablelayoutcommand.js @@ -0,0 +1,590 @@ +/** + * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor.js'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph.js'; +import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model.js'; + +import TableEditing from '../../src/tableediting.js'; +import TableColumnResize from '../../src/tablecolumnresize.js'; +import TableCaptionEditing from '../../src/tablecaption/tablecaptionediting.js'; +import TableLayoutEditing from '../../src/tablelayout/tablelayoutediting.js'; +import InsertTableLayoutCommand from '../../src/commands/inserttablelayoutcommand.js'; + +import { modelTable } from '../_utils/utils.js'; + +describe( 'InsertTableLayoutCommand', () => { + let editor, model, command; + + beforeEach( () => { + return ModelTestEditor + .create( { + plugins: [ Paragraph, TableEditing, TableCaptionEditing, TableLayoutEditing ] + } ) + .then( newEditor => { + editor = newEditor; + model = editor.model; + command = new InsertTableLayoutCommand( editor ); + } ); + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + describe( 'isEnabled', () => { + describe( 'when selection is collapsed', () => { + it( 'should be true if in a root', () => { + setData( model, '[]' ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if in paragraph', () => { + setData( model, 'foo[]' ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if in table', () => { + setData( model, 'foo[]
' ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if in table caption', () => { + setData( model, + '' + + 'foo' + + '' + + '
[]
' ); + expect( command.isEnabled ).to.be.false; + } ); + } ); + + describe( 'when selection is not collapsed', () => { + it( 'should be true if an object is selected', () => { + model.schema.register( 'media', { isObject: true, isBlock: true, allowWhere: '$block' } ); + + setData( model, '[]' ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if in a paragraph', () => { + setData( model, '[Foo]' ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if a non-object element is selected', () => { + model.schema.register( 'element', { allowIn: '$root', isSelectable: true } ); + + setData( model, '[]' ); + expect( command.isEnabled ).to.be.true; + } ); + } ); + } ); + + describe( 'execute()', () => { + it( 'should create a single batch', () => { + setData( model, 'foo[]' ); + + const spy = sinon.spy(); + + model.document.on( 'change', spy ); + + command.execute( { rows: 3, columns: 4 } ); + + sinon.assert.calledOnce( spy ); + } ); + + describe( 'collapsed selection', () => { + it( 'should insert table in empty root', () => { + setData( model, '[]' ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( + modelTable( [ + [ '[]', '' ], + [ '', '' ] + ], + { tableType: 'layout' } ) + ); + } ); + + it( 'should insert table with two rows and two columns after non-empty paragraph if selection is at the end', () => { + setData( model, 'foo[]' ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( + 'foo' + + modelTable( [ + [ '[]', '' ], + [ '', '' ] + ], + { tableType: 'layout' } ) + ); + } ); + + it( 'should insert table with given rows and columns after non-empty paragraph', () => { + setData( model, 'foo[]' ); + + command.execute( { rows: 3, columns: 4 } ); + + expect( getData( model ) ).to.equalMarkup( + 'foo' + + modelTable( [ + [ '[]', '', '', '' ], + [ '', '', '', '' ], + [ '', '', '', '' ] + ], + { tableType: 'layout' } ) + ); + } ); + + it( 'should insert table with given heading rows and heading columns after non-empty paragraph', () => { + setData( model, 'foo[]' ); + + command.execute( { rows: 3, columns: 4, headingRows: 1, headingColumns: 2 } ); + + expect( getData( model ) ).to.equalMarkup( + 'foo' + + modelTable( [ + [ '[]', '', '', '' ], + [ '', '', '', '' ], + [ '', '', '', '' ] + ], { tableType: 'layout' } ) + ); + } ); + + it( 'should insert table before after non-empty paragraph if selection is inside', () => { + setData( model, 'f[]oo' ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( + modelTable( [ + [ '[]', '' ], + [ '', '' ] + ], + { tableType: 'layout' } ) + + 'foo' + ); + } ); + + it( 'should replace empty paragraph with table', () => { + setData( model, '[]' ); + + command.execute( { rows: 3, columns: 4 } ); + + expect( getData( model ) ).to.equalMarkup( + modelTable( [ + [ '[]', '', '', '' ], + [ '', '', '', '' ], + [ '', '', '', '' ] + ], + { tableType: 'layout' } ) + ); + } ); + } ); + + describe( 'expanded selection', () => { + it( 'should replace an existing selected object with a table', () => { + model.schema.register( 'object', { isObject: true, allowIn: '$root' } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'object', view: 'object' } ); + + setData( model, 'foo[]bar' ); + + command.execute( { rows: 1, columns: 2 } ); + + expect( getData( model ) ).to.equal( + 'foo' + modelTable( [ [ '[]', '' ] ], + { tableType: 'layout' } ) + 'bar' + ); + } ); + + it( 'should replace an existing table with another table', () => { + setData( model, 'foo[' + modelTable( [ [ '', '' ], [ '', '' ] ] ) + ']bar' ); + + command.execute( { rows: 1, columns: 2 } ); + + expect( getData( model ) ).to.equal( + 'foo' + modelTable( [ [ '[]', '' ] ], + { tableType: 'layout' } ) + 'bar' + ); + } ); + } ); + + describe( 'with `TableColumnResize` plugin added', () => { + let editor, model, command; + + beforeEach( () => { + return ModelTestEditor + .create( { + plugins: [ Paragraph, TableEditing, TableLayoutEditing, TableColumnResize ] + } ) + .then( newEditor => { + editor = newEditor; + model = editor.model; + command = new InsertTableLayoutCommand( editor ); + } ); + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + describe( 'collapsed selection', () => { + it( 'should insert table in empty root', () => { + setData( model, '[]' ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( + modelTable( [ + [ '[]', '' ], + [ '', '' ] + ], + { tableType: 'layout', tableWidth: '100%' } ) + ); + } ); + + it( 'should insert table with two rows and two columns after non-empty paragraph if selection is at the end', () => { + setData( model, 'foo[]' ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( + 'foo' + + modelTable( [ + [ '[]', '' ], + [ '', '' ] + ], + { tableType: 'layout', tableWidth: '100%' } ) + ); + } ); + + it( 'should insert table with given rows and columns after non-empty paragraph', () => { + setData( model, 'foo[]' ); + + command.execute( { rows: 3, columns: 4 } ); + + expect( getData( model ) ).to.equalMarkup( + 'foo' + + modelTable( [ + [ '[]', '', '', '' ], + [ '', '', '', '' ], + [ '', '', '', '' ] + ], + { tableType: 'layout', tableWidth: '100%' } ) + ); + } ); + + it( 'should insert table with given heading rows and heading columns after non-empty paragraph', () => { + setData( model, 'foo[]' ); + + command.execute( { rows: 3, columns: 4, headingRows: 1, headingColumns: 2 } ); + + expect( getData( model ) ).to.equalMarkup( + 'foo' + + modelTable( [ + [ '[]', '', '', '' ], + [ '', '', '', '' ], + [ '', '', '', '' ] + ], + { tableType: 'layout', tableWidth: '100%' } ) + ); + } ); + + it( 'should insert table before after non-empty paragraph if selection is inside', () => { + setData( model, 'f[]oo' ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( + modelTable( [ + [ '[]', '' ], + [ '', '' ] + ], + { tableType: 'layout', tableWidth: '100%' } ) + + 'foo' + ); + } ); + + it( 'should replace empty paragraph with table', () => { + setData( model, '[]' ); + + command.execute( { rows: 3, columns: 4 } ); + + expect( getData( model ) ).to.equalMarkup( + modelTable( [ + [ '[]', '', '', '' ], + [ '', '', '', '' ], + [ '', '', '', '' ] + ], + { tableType: 'layout', tableWidth: '100%' } ) + ); + } ); + } ); + + describe( 'expanded selection', () => { + it( 'should replace an existing selected object with a table', () => { + model.schema.register( 'object', { isObject: true, allowIn: '$root' } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'object', view: 'object' } ); + + setData( model, 'foo[]bar' ); + + command.execute( { rows: 1, columns: 2 } ); + + expect( getData( model ) ).to.equal( + 'foo' + modelTable( [ [ '[]', '' ] ], + { tableType: 'layout', tableWidth: '100%' } ) + 'bar' + ); + } ); + + it( 'should replace an existing table with another table', () => { + setData( model, 'foo[' + + modelTable( [ [ '', '' ], [ '', '' ] ] ) + + ']bar' ); + + command.execute( { rows: 1, columns: 2 } ); + + expect( getData( model ) ).to.equal( + 'foo' + modelTable( [ [ '[]', '' ] ], + { tableType: 'layout', tableWidth: '100%' } ) + 'bar' + ); + } ); + } ); + } ); + + describe( 'auto headings', () => { + it( 'should not have first row as a heading by default', async () => { + const editor = await ModelTestEditor + .create( { + plugins: [ Paragraph, TableEditing ], + table: { + defaultHeadings: { rows: 1 } + } + } ); + + const model = editor.model; + const command = new InsertTableLayoutCommand( editor ); + + setData( model, '[]' ); + + command.execute( { rows: 2, columns: 3 } ); + + expect( getData( model ) ).to.equal( + modelTable( [ + [ '[]', '', '' ], + [ '', '', '' ] + ] ) + ); + + await editor.destroy(); + } ); + + it( 'should not have first column as a heading by default', async () => { + const editor = await ModelTestEditor + .create( { + plugins: [ Paragraph, TableEditing ], + table: { + defaultHeadings: { columns: 1 } + } + } ); + + const model = editor.model; + const command = new InsertTableLayoutCommand( editor ); + + setData( model, '[]' ); + + command.execute( { rows: 2, columns: 3 } ); + + expect( getData( model ) ).to.equal( + modelTable( [ + [ '[]', '', '' ], + [ '', '', '' ] + ] ) + ); + + await editor.destroy(); + } ); + + it( 'should not have first row and first column as a heading by default', async () => { + const editor = await ModelTestEditor + .create( { + plugins: [ Paragraph, TableEditing ], + table: { + defaultHeadings: { rows: 1, columns: 1 } + } + } ); + + const model = editor.model; + const command = new InsertTableLayoutCommand( editor ); + + setData( model, '[]' ); + + command.execute( { rows: 3, columns: 3 } ); + + expect( getData( model ) ).to.equal( + modelTable( [ + [ '[]', '', '' ], + [ '', '', '' ], + [ '', '', '' ] + ] ) + ); + + await editor.destroy(); + } ); + + it( 'should not have first three rows and two columns as a heading by default', async () => { + const editor = await ModelTestEditor + .create( { + plugins: [ Paragraph, TableEditing ], + table: { + defaultHeadings: { rows: 3, columns: 2 } + } + } ); + + const model = editor.model; + const command = new InsertTableLayoutCommand( editor ); + + setData( model, '[]' ); + + command.execute( { rows: 4, columns: 3 } ); + + expect( getData( model ) ).to.equal( + modelTable( [ + [ '[]', '', '' ], + [ '', '', '' ], + [ '', '', '' ], + [ '', '', '' ] + ] ) + ); + + await editor.destroy(); + } ); + + it( 'should not have auto headings not to be greater than table rows and columns', async () => { + const editor = await ModelTestEditor + .create( { + plugins: [ Paragraph, TableEditing ], + table: { + defaultHeadings: { rows: 3, columns: 3 } + } + } ); + + const model = editor.model; + const command = new InsertTableLayoutCommand( editor ); + + setData( model, '[]' ); + + command.execute( { rows: 2, columns: 2 } ); + + expect( getData( model ) ).to.equal( + modelTable( [ + [ '[]', '' ], + [ '', '' ] + ] ) + ); + + await editor.destroy(); + } ); + } ); + + describe( 'inheriting attributes', () => { + let editor; + let model, command; + + beforeEach( async () => { + editor = await ModelTestEditor + .create( { + plugins: [ Paragraph, TableEditing ], + table: { + defaultHeadings: { rows: 1 } + } + } ); + + model = editor.model; + command = new InsertTableLayoutCommand( editor ); + + const attributes = [ 'smart', 'pretty' ]; + + model.schema.extend( '$block', { + allowAttributes: attributes + } ); + + model.schema.extend( '$blockObject', { + allowAttributes: attributes + } ); + + for ( const attribute of attributes ) { + model.schema.setAttributeProperties( attribute, { + copyOnReplace: true + } ); + } + } ); + + afterEach( async () => { + await editor.destroy(); + } ); + + it( 'should copy $block attributes on a table element when inserting it in $block', async () => { + setData( model, '[]' ); + + command.execute( { rows: 2, columns: 2 } ); + + expect( getData( model ) ).to.equal( + modelTable( [ + [ '[]', '' ], + [ '', '' ] + ], { pretty: true, smart: true } ) + ); + } ); + + it( 'should copy attributes from first selected element', () => { + setData( model, '[foobar]' ); + + command.execute( { rows: 2, columns: 2 } ); + + expect( getData( model ) ).to.equal( + modelTable( [ + [ '[]', '' ], + [ '', '' ] + ], { pretty: true } ) + + 'foo' + + 'bar' + ); + } ); + + it( 'should only copy $block attributes marked with copyOnReplace', () => { + setData( model, '[]' ); + + command.execute( { rows: 2, columns: 2 } ); + + expect( getData( model ) ).to.equal( + modelTable( [ + [ '[]', '' ], + [ '', '' ] + ], { pretty: true, smart: true } ) + ); + } ); + + it( 'should copy attributes from object when it is selected during insertion', () => { + model.schema.register( 'object', { isObject: true, inheritAllFrom: '$blockObject' } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'object', view: 'object' } ); + + setData( model, '[]' ); + + command.execute( { rows: 2, columns: 2 } ); + + expect( getData( model ) ).to.equal( + modelTable( [ + [ '[]', '' ], + [ '', '' ] + ], { pretty: true, smart: true } ) + ); + } ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-table/tests/commands/setheadercolumncommand.js b/packages/ckeditor5-table/tests/commands/setheadercolumncommand.js index e3b321587b1..e14f1464b91 100644 --- a/packages/ckeditor5-table/tests/commands/setheadercolumncommand.js +++ b/packages/ckeditor5-table/tests/commands/setheadercolumncommand.js @@ -9,6 +9,7 @@ import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model import TableSelection from '../../src/tableselection.js'; import TableEditing from '../../src/tableediting.js'; +import { TableLayoutEditing } from '../../src/index.js'; import { assertSelectedCells, modelTable } from '../_utils/utils.js'; import SetHeaderColumnCommand from '../../src/commands/setheadercolumncommand.js'; @@ -72,6 +73,39 @@ describe( 'SetHeaderColumnCommand', () => { expect( command.isEnabled ).to.be.true; } ); + + describe( 'with `TableLayout` plugin', () => { + let editor, model, command; + + beforeEach( () => { + return ModelTestEditor + .create( { + plugins: [ Paragraph, TableEditing, TableSelection, TableLayoutEditing ] + } ) + .then( newEditor => { + editor = newEditor; + model = editor.model; + command = new SetHeaderColumnCommand( editor ); + } ); + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + it( 'should be true if selection is in table', () => { + setData( model, 'foo[]
' ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if selection is in table with `tableType="layout"`', () => { + setData( model, + '' + + 'foo[]' + + '
' ); + expect( command.isEnabled ).to.be.false; + } ); + } ); } ); describe( 'value', () => { diff --git a/packages/ckeditor5-table/tests/commands/setheaderrowcommand.js b/packages/ckeditor5-table/tests/commands/setheaderrowcommand.js index baeba4b2b20..7151d2ab60c 100644 --- a/packages/ckeditor5-table/tests/commands/setheaderrowcommand.js +++ b/packages/ckeditor5-table/tests/commands/setheaderrowcommand.js @@ -9,6 +9,7 @@ import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model import TableEditing from '../../src/tableediting.js'; import TableSelection from '../../src/tableselection.js'; +import { TableLayoutEditing } from '../../src/index.js'; import { assertSelectedCells, modelTable } from '../_utils/utils.js'; import SetHeaderRowCommand from '../../src/commands/setheaderrowcommand.js'; @@ -72,6 +73,39 @@ describe( 'SetHeaderRowCommand', () => { expect( command.isEnabled ).to.be.true; } ); + + describe( 'with `TableLayout` plugin', () => { + let editor, model, command; + + beforeEach( () => { + return ModelTestEditor + .create( { + plugins: [ Paragraph, TableEditing, TableSelection, TableLayoutEditing ] + } ) + .then( newEditor => { + editor = newEditor; + model = editor.model; + command = new SetHeaderRowCommand( editor ); + } ); + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + it( 'should be true if selection is in table', () => { + setData( model, 'foo[]
' ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if selection is in table with `tableType="layout"`', () => { + setData( model, + '' + + 'foo[]' + + '
' ); + expect( command.isEnabled ).to.be.false; + } ); + } ); } ); describe( 'value', () => { diff --git a/packages/ckeditor5-table/tests/manual/plaintableoutput.html b/packages/ckeditor5-table/tests/manual/plaintableoutput.html index 3ad135adf2e..9aadfdfc331 100644 --- a/packages/ckeditor5-table/tests/manual/plaintableoutput.html +++ b/packages/ckeditor5-table/tests/manual/plaintableoutput.html @@ -1,3 +1,4 @@ +

Editor:

@@ -16,17 +17,25 @@ +

Editor output preview:

+
+ +

Editor data:

+
+

+
+ -
-
Editor data
-

-
+ #editor-output-preview { + border: 1px solid #ccc; + } + diff --git a/packages/ckeditor5-table/tests/manual/plaintableoutput.js b/packages/ckeditor5-table/tests/manual/plaintableoutput.js index 1321371d4f0..b88c0fcfe66 100644 --- a/packages/ckeditor5-table/tests/manual/plaintableoutput.js +++ b/packages/ckeditor5-table/tests/manual/plaintableoutput.js @@ -79,11 +79,18 @@ ClassicEditor window.editor = editor; const element = document.getElementById( 'editor-data' ); - element.innerText = formatHtml( editor.getData() ); + const editorPreview = document.getElementById( 'editor-output-preview' ); + + updateOutput(); editor.model.document.on( 'change:data', () => { - element.innerText = formatHtml( editor.getData() ); + updateOutput(); } ); + + function updateOutput() { + element.innerText = formatHtml( editor.getData() ); + editorPreview.innerHTML = editor.getData(); + } } ) .catch( err => { console.error( err.stack ); diff --git a/packages/ckeditor5-table/tests/manual/tablelayout.html b/packages/ckeditor5-table/tests/manual/tablelayout.html new file mode 100644 index 00000000000..ce67401e504 --- /dev/null +++ b/packages/ckeditor5-table/tests/manual/tablelayout.html @@ -0,0 +1,352 @@ +
+ +

Source structure: <table>

+
+ +

Source: <table class="table"> => result = tableType will be layout table

+ +
Monthly savings
+ + + + + + + + + + +
abc
123
+

Source: <table class="table layout-table"> => result = tableType will be layout table

+ + + + + + + + + + + + +
abc
123
+ + +
+

Source: <table class="table layout-table ck-table-resized" style="width:30%;"> => result = tableType will be layout table with specified columns width

+ + + + + + + + + + + + + + + + + + + + + +
+

Source: <table class="table layout-table"> containing <caption> => result = tableType will be content table and <caption> will be preserved

+ + + + + + + + + + + + + +
Should be content table
abc
123
+ +
+

Source: <table class="table content-table"> containing <caption> => result = tableType will be + content table and <caption> will be preserved

+ + + + + + + + + + + + + +
Should be content table
abc
123
+ +
+

Source: <table class="table"> containing <caption> => result = tableType will be content table and <caption> will be preserved

+ + + + + + + + + + + + + +
Should be content table
abc
123
+ +
+

Source: <table> containing <thead>/<th> => result = tableType will be layout table and <th> will be changed into <td>

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
01234
abcde
fghij
klmno
pqrst
+ +
+

Source: <table class="table layout-table"> containing <thead>/<th> => result = tableType will be layout table and <th> will be changed into <td>

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
01234
abcde
fghij
klmno
pqrst
+ + +
+

Source: <table class="table content-table"> containing <thead>/<th> => result = tableType will be content table and <th> will be preserved

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
01234
abcde
fghij
klmno
pqrst
+ +
+

Source structure: <figure><table>

+
+

Source: <figure><table> => result = tableType will be content table

+ +
+ + + + + + + + + + + + +
Should be content table
abc
123
+
+ +
+

Source: <figure class="table"><table> => result = tableType will be content table

+ +
+ + + + + + + + + + + + +
Should be content table
abc
123
+
+ +
+

Source: <figure><table class="table layout-table"> => result = tableType will be content table

+ +
+ + + + + + + + + + + + +
Should be content table
abc
123
+
+ +
+

Source: <figure><table> containing <thead>/<th> => result = tableType will be content table and + <thead>/<th> will be preserved

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
01234
abcde
fghij
klmno
pqrst
+
+
diff --git a/packages/ckeditor5-table/tests/manual/tablelayout.js b/packages/ckeditor5-table/tests/manual/tablelayout.js new file mode 100644 index 00000000000..69fe663d970 --- /dev/null +++ b/packages/ckeditor5-table/tests/manual/tablelayout.js @@ -0,0 +1,63 @@ +/** + * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +/* globals console, document, window */ + +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor.js'; +import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset.js'; +import { HorizontalLine } from '@ckeditor/ckeditor5-horizontal-line'; +import Table from '../../src/table.js'; +import TableToolbar from '../../src/tabletoolbar.js'; +import TableSelection from '../../src/tableselection.js'; +import TableClipboard from '../../src/tableclipboard.js'; +import TableProperties from '../../src/tableproperties.js'; +import TableCellProperties from '../../src/tablecellproperties.js'; +import TableColumnResize from '../../src/tablecolumnresize.js'; +import TableCaption from '../../src/tablecaption.js'; +import PlainTableOutput from '../../src/plaintableoutput.js'; +import TableLayout from '../../src/tablelayout.js'; + +const config = { + image: { toolbar: [ 'toggleImageCaption', 'imageTextAlternative' ] }, + plugins: [ + ArticlePluginSet, + HorizontalLine, + Table, + TableToolbar, + TableSelection, + TableClipboard, + TableProperties, + TableCellProperties, + TableColumnResize, + TableCaption, + PlainTableOutput, + TableLayout + ], + toolbar: [ + 'undo', 'redo', '|', + 'insertTable', 'insertTableLayout', '|', + 'heading', '|', + 'bold', 'italic', 'link', '|', + 'bulletedList', 'numberedList', 'blockQuote' + ], + table: { + contentToolbar: [ + 'tableColumn', 'tableRow', 'mergeTableCells', 'tableProperties', 'tableCellProperties', 'toggleTableCaption' + ], + tableToolbar: [ 'bold', 'italic' ] + }, + menuBar: { + isVisible: true + } +}; + +ClassicEditor + .create( document.querySelector( '#editor' ), config ) + .then( editor => { + window.editor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-table/tests/manual/tablelayout.md b/packages/ckeditor5-table/tests/manual/tablelayout.md new file mode 100644 index 00000000000..999e3eed708 --- /dev/null +++ b/packages/ckeditor5-table/tests/manual/tablelayout.md @@ -0,0 +1,12 @@ +### Layout Table + +Based on feature heuristic, upcasted tables will be set to layout tables when one of below condition is met: + - there is no figure wrapper, + - there is no ` element inside ``, + - there is no `content-table` class. + +Besides this any other table will be set as a content table (regular one). + +If a table recognize as layout table will contains `element', () => { it( 'should handle the correct number of elements', () => { editor.setData( @@ -2991,7 +3025,7 @@ describe( 'TableColumnResizeEditing', () => { setModelData( ptoEditor.model, table ); expect( ptoEditor.getData() ).to.equal( - '
` all those cells will be changed into ``. + +If a table is recognized as a layout table, any `` elements it contains will be converted to `` elements. Setting header row or collumn on layout tables is blocked. diff --git a/packages/ckeditor5-table/tests/plaintableoutput.js b/packages/ckeditor5-table/tests/plaintableoutput.js index e66a6b4d435..e4f278222fa 100644 --- a/packages/ckeditor5-table/tests/plaintableoutput.js +++ b/packages/ckeditor5-table/tests/plaintableoutput.js @@ -59,7 +59,7 @@ describe( 'PlainTableOutput', () => { ] ) ); expect( editor.getData() ).to.equal( - '' + + '
' + '' + '' + '' + @@ -75,7 +75,7 @@ describe( 'PlainTableOutput', () => { ], { headingRows: 2 } ) ); expect( editor.getData() ).to.equal( - '
foo
' + + '
' + '' + '' + '' + @@ -95,7 +95,7 @@ describe( 'PlainTableOutput', () => { ], { headingColumns: 1 } ) ); expect( editor.getData() ).to.equal( - '
12
34
' + + '
' + '' + '' + '' + @@ -113,7 +113,7 @@ describe( 'PlainTableOutput', () => { ], { headingRows: 1, headingColumns: 1 } ) ); expect( editor.getData() ).to.equal( - '
12
34
' + + '
' + '' + '' + '' + @@ -132,7 +132,7 @@ describe( 'PlainTableOutput', () => { ], { headingRows: 3 } ) ); expect( editor.getData() ).to.equal( - '
12
' + + '
' + '' + '' + '' + @@ -153,7 +153,7 @@ describe( 'PlainTableOutput', () => { ); expect( editor.getData() ).to.equal( - '
12
34
' + + '
' + '' + '' + '' + @@ -177,7 +177,7 @@ describe( 'PlainTableOutput', () => { ); expect( testEditor.getData() ).to.equal( - '
Foo
12
' + + '
' + '' + '' + '' + @@ -512,11 +512,25 @@ describe( 'PlainTableOutput', () => { const tableStyleEntry = tableStyle ? ` style="${ tableStyle }"` : ''; expect( editor.getData() ).to.equalMarkup( - `` + + `
12
` + '' + '
foo
' ); } } ); } ); + + describe( 'upcast', () => { + it( 'should consume the `table` class', () => { + editor.conversion.for( 'upcast' ).add( dispatcher => { + dispatcher.on( 'element:table', ( evt, data, conversionApi ) => { + expect( conversionApi.consumable.test( data.viewItem, { classes: [ 'table' ] } ) ).to.be.false; + } ); + }, { priority: 'low' } ); + + editor.setData( + '
foo
' + ); + } ); + } ); } ); diff --git a/packages/ckeditor5-table/tests/tablecaption/toggletablecaptioncommand.js b/packages/ckeditor5-table/tests/tablecaption/toggletablecaptioncommand.js index dbb6eb5e630..0d7ae5547f1 100644 --- a/packages/ckeditor5-table/tests/tablecaption/toggletablecaptioncommand.js +++ b/packages/ckeditor5-table/tests/tablecaption/toggletablecaptioncommand.js @@ -57,6 +57,14 @@ describe( 'ToggleTableCaptionCommand', () => { ); expect( command.isEnabled ).to.be.true; } ); + + it( 'should be false if it is in a table that does not allow captions', () => { + editor.model.schema.extend( 'table', { disallowChildren: 'caption' } ); + + setData( model, modelTable( [ [ '[]' ] ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); } ); describe( 'execute()', () => { diff --git a/packages/ckeditor5-table/tests/tablecolumnresize/tablecolumnresizeediting.js b/packages/ckeditor5-table/tests/tablecolumnresize/tablecolumnresizeediting.js index 951c56058b1..023e7258607 100644 --- a/packages/ckeditor5-table/tests/tablecolumnresize/tablecolumnresizeediting.js +++ b/packages/ckeditor5-table/tests/tablecolumnresize/tablecolumnresizeediting.js @@ -127,7 +127,7 @@ describe( 'TableColumnResizeEditing', () => { describe( 'conversion', () => { describe( 'upcast', () => { - it( 'the table width style to tableWidth attribute correctly', () => { + it( 'the table width style set on
element to tableWidth attribute correctly', () => { editor.setData( `
@@ -163,6 +163,40 @@ describe( 'TableColumnResizeEditing', () => { ); } ); + it( 'the table width style set on
element to tableWidth attribute correctly', () => { + editor.setData( + `
+ + + + + + + + + + +
1112
` + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '' + + '' + + '11' + + '' + + '' + + '12' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + ); + } ); + describe( 'when upcasting
' + + '
' + '' + '' + '' + diff --git a/packages/ckeditor5-table/tests/tablelayout.js b/packages/ckeditor5-table/tests/tablelayout.js new file mode 100644 index 00000000000..a244c72e37a --- /dev/null +++ b/packages/ckeditor5-table/tests/tablelayout.js @@ -0,0 +1,49 @@ +/** + * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +/* globals document */ + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor.js'; +import TableLayout from '../src/tablelayout.js'; +import PlainTableOutput from '../src/plaintableoutput.js'; +import TableColumnResize from '../src/tablecolumnresize.js'; +import TableLayoutEditing from '../src/tablelayout/tablelayoutediting.js'; +import TableLayoutUI from '../src/tablelayout/tablelayoutui.js'; + +describe( 'TableLayout', () => { + let editor, editorElement; + + beforeEach( async () => { + editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); + + editor = await ClassicTestEditor.create( editorElement, { + plugins: [ TableLayout ] + } ); + } ); + + afterEach( async () => { + editorElement.remove(); + await editor.destroy(); + } ); + + it( 'requires PlainTableOutput, TableColumnResize, TableLayoutEditing and TableLayoutUI', () => { + expect( TableLayout.requires ).to.deep.equal( [ + PlainTableOutput, TableColumnResize, TableLayoutEditing, TableLayoutUI + ] ); + } ); + + it( 'should have pluginName', () => { + expect( TableLayout.pluginName ).to.equal( 'TableLayout' ); + } ); + + it( 'should have `isOfficialPlugin` static flag set to `true`', () => { + expect( TableLayout.isOfficialPlugin ).to.be.true; + } ); + + it( 'should have `isPremiumPlugin` static flag set to `false`', () => { + expect( TableLayout.isPremiumPlugin ).to.be.false; + } ); +} ); diff --git a/packages/ckeditor5-table/tests/tablelayout/tablelayoutediting.js b/packages/ckeditor5-table/tests/tablelayout/tablelayoutediting.js new file mode 100644 index 00000000000..a9177642834 --- /dev/null +++ b/packages/ckeditor5-table/tests/tablelayout/tablelayoutediting.js @@ -0,0 +1,1122 @@ +/** + * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +/* globals document */ + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor.js'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph.js'; +import { BlockQuote } from '@ckeditor/ckeditor5-block-quote'; +import GeneralHtmlSupport from '@ckeditor/ckeditor5-html-support/src/generalhtmlsupport.js'; +import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model.js'; +import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view.js'; + +import TableLayoutEditing from '../../src/tablelayout/tablelayoutediting.js'; +import Table from '../../src/table.js'; +import TableCaption from '../../src/tablecaption.js'; +import TableColumnResize from '../../src/tablecolumnresize.js'; +import PlainTableOutput from '../../src/plaintableoutput.js'; +import TableEditing from '../../src/tableediting.js'; + +describe( 'TableLayoutEditing', () => { + let editor, model, view, editorElement, insertTableCommand; + + beforeEach( async () => { + editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); + + editor = await ClassicTestEditor.create( editorElement, { + plugins: [ + Table, TableCaption, TableColumnResize, PlainTableOutput, TableLayoutEditing, Paragraph, BlockQuote + ] + } ); + + model = editor.model; + view = editor.editing.view; + insertTableCommand = editor.commands.get( 'insertTable' ); + } ); + + afterEach( async () => { + editorElement.remove(); + await editor.destroy(); + } ); + + it( 'should have pluginName', () => { + expect( TableLayoutEditing.pluginName ).to.equal( 'TableLayoutEditing' ); + } ); + + it( 'should have `isOfficialPlugin` static flag set to `true`', () => { + expect( TableLayoutEditing.isOfficialPlugin ).to.be.true; + } ); + + it( 'should have `isPremiumPlugin` static flag set to `false`', () => { + expect( TableLayoutEditing.isPremiumPlugin ).to.be.false; + } ); + + it( 'should set proper schema rule to allow
for content tables', () => { + expect( model.schema.checkChild( [ '$root', 'table' ], 'caption' ) ).to.be.true; + } ); + + it( 'should set proper schema rule to not allow for layout tables', () => { + setModelData( + model, + '' + + '' + + '' + + 'foo[]' + + '' + + '' + + '
' + ); + + const tableElement = model.document.getRoot().getChild( 0 ); + + expect( model.schema.checkChild( tableElement, 'caption' ) ).to.be.false; + } ); + + describe( 'dataDowncast', () => { + it( 'should add `layout-table` class and `role="presentation"` attribute', () => { + setModelData( + model, + '' + + '' + + '' + + 'foo[]' + + '' + + '' + + '
' + ); + + expect( editor.getData() ).to.equal( + '' + + '' + + '' + + '' + + '' + ); + } ); + + it( 'should add `content-table` class and not add the `role="presentation"` attribute', () => { + setModelData( + model, + '' + + '' + + '' + + 'foo[]' + + '' + + '' + + '
' + ); + + expect( editor.getData() ).to.equal( + '' + + '' + + '' + + '' + + '
foo
' + ); + } ); + + it( 'should not add `layout-table` class and `role="presentation"` attribute when already consumed', () => { + editor.conversion.for( 'dataDowncast' ).add( dispatcher => { + return dispatcher.on( 'attribute:tableType:table', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.item, evt.name ); + }, { priority: 'highest' } ); + } ); + + setModelData( + model, + '' + + '' + + '' + + 'foo[]' + + '' + + '' + + '
' + ); + + expect( editor.getData() ).to.equal( + '' + + '' + + '' + + '' + + '
foo
' + ); + } ); + } ); + + describe( 'editingDowncast', () => { + it( 'should properly downcast layout table', () => { + setModelData( + model, + '' + + '' + + '' + + 'foo[]' + + '' + + '' + + '
' + ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equal( + '
' + + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + 'foo' + + '
' + + '
' + + '
' + + '
' + ); + } ); + + it( 'should properly downcast content table', () => { + setModelData( + model, + '' + + '' + + '' + + 'foo[]' + + '' + + '' + + '
' + ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equal( + '
' + + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + 'foo' + + '
' + + '
' + + '
' + + '
' + ); + } ); + } ); + + describe( 'upcast', () => { + it( 'should set `tableType` to `layout` when there is class `layout-table`', () => { + editor.setData( + '' + + '' + + '
1
' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '1' + + '
' + ); + } ); + + it( 'should set `tableType` to `content` when there is class `content-table`', () => { + editor.setData( + '' + + '' + + '
1
' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '1' + + '
' + ); + } ); + + it( 'should set `tableType` to `layout` when there is no class responsible for table type', () => { + editor.setData( + '' + + '' + + '
1
' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '1' + + '
' + ); + } ); + + it( 'should set `tableType` to `content` when there is no class responsible for table type ' + + 'but contains a
element inside', () => { + editor.setData( + '' + + '' + + '' + + '
foo
1
' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '1' + + '' + + '
foo
' + ); + } ); + + it( 'should set `tableType` to `content` even when there is a `layout-table` class' + + 'but contains a
element inside', () => { + editor.setData( + '' + + '' + + '' + + '
foo
1
' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '1' + + '' + + '
foo
' + ); + } ); + + it( 'should set `tableType` to `content` when there is no `table` class but `content-table` is', () => { + editor.setData( + '' + + '' + + '
1
' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '1' + + '
' + ); + } ); + + it( 'should set `tableType` to `layout` when there is no `table` class but `layout-table` is', () => { + editor.setData( + '' + + '' + + '
1
' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '1' + + '
' + ); + } ); + + it( 'should set `tableType` to `layout` when there is no class responsible for table type ' + + 'and not add the `headingRows` attribute', () => { + editor.setData( + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
1
2
' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '1' + + '2' + + '
' + ); + } ); + + it( 'should set `tableType` to `layout` when there is class `layout-table` ' + + 'and not add the `headingRows` attribute', () => { + editor.setData( + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
1
2
' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '1' + + '2' + + '
' + ); + } ); + + it( 'should set `tableType` to `content` when there is class `content-table` ' + + 'and add the `headingRows` attribute', () => { + editor.setData( + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
1
2
' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '1' + + '2' + + '
' + ); + } ); + + it( 'should set `tableType` to `content` when there is class `content-table` ' + + 'and add the `headingColumns` attribute', () => { + editor.setData( + '' + + '' + + '' + + '' + + '' + + '
ab
12
' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '' + + 'a' + + 'b' + + '' + + '' + + '1' + + '2' + + '' + + '
' + ); + } ); + + it( 'should set `tableType` to `layout` when there is class `layout-table` and table is empty', () => { + editor.setData( + '
' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '' + + '
' + ); + } ); + + it( 'should set `tableType` to `layout` when there is no class responsible for type and table is empty', () => { + editor.setData( + '
' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '' + + '
' + ); + } ); + + it( 'should set `tableType` to `content` when there is class `content-table` and table is empty', () => { + editor.setData( + '
' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '' + + '
' + ); + } ); + + it( 'should set outer `tableType` to `content` and the inner
`tableType` to `layout`', () => { + editor.setData( + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + '' + + '' + + '' + + '' + + '
inner
' + + '
outer
' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '' + + '
' + + 'inner' + + '
' + + '' + + 'outer' + + '
' + ); + } ); + + it( 'should set `tableType` to `layout` also add tableWidth="30%" and apply tableColumnGroup', () => { + editor.setData( + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '' + + 'a' + + 'b' + + 'c' + + '' + + '' + + '1' + + '2' + + '3' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + ); + } ); + + it( 'should strip table in table if nested tables are forbidden', () => { + model.schema.addChildCheck( ( context, childDefinition ) => { + if ( childDefinition.name == 'table' && Array.from( context.getNames() ).includes( 'table' ) ) { + return false; + } + } ); + + editor.setData( + '' + + '' + + '' + + '' + + '' + + '
foo' + + '' + + '' + + '' + + '' + + '
bar
' + + '
' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '' + + '' + + 'foo' + + '' + + '' + + 'bar' + + '' + + '' + + '
' + ); + } ); + + describe( ' is wrapped with
', () => { + it( 'should set `tableType` to `content` when there is no class responsible for table type', () => { + editor.setData( + '
' + + '
' + + '' + + '
1
' + + '' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '1' + + '
' + ); + } ); + + it( 'should set `tableType` to `content` even when there is a `layout-table` class', () => { + editor.setData( + '
' + + '' + + '' + + '
1
' + + '
' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '1' + + '
' + ); + } ); + + it( 'should set `tableType` to `content` when there is a `content-table` class', () => { + editor.setData( + '
' + + '' + + '' + + '
1
' + + '
' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '1' + + '
' + ); + } ); + + it( 'should set `tableType` to `content` when there is no class responsible for table type ' + + 'and add the `headingRows` attribute', () => { + editor.setData( + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
1
2
' + + '
' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '1' + + '2' + + '
' + ); + } ); + + it( 'should set `tableType` to `content` even when there is a `layout-table` class ' + + 'and add the `headingRows` attribute', () => { + editor.setData( + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
1
2
' + + '
' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '1' + + '2' + + '
' + ); + } ); + } ); + + describe( 'GHS integration', () => { + let ghsEditor, model, editorElement; + + beforeEach( async () => { + editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); + + ghsEditor = await ClassicTestEditor.create( editorElement, { + plugins: [ + Table, TableCaption, TableColumnResize, PlainTableOutput, + TableLayoutEditing, Paragraph, GeneralHtmlSupport + ], + htmlSupport: { + allow: [ + { + name: /^.*$/, + styles: true, + attributes: true, + classes: true + } + ] + } + } ); + + model = editor.model; + view = editor.editing.view; + } ); + + afterEach( async () => { + editorElement.remove(); + + await ghsEditor.destroy(); + } ); + + it( 'should set `tableType` to `layout` when there is class `layout-table`', () => { + editor.setData( + '' + + '' + + '
1
' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '1' + + '
' + ); + } ); + + it( 'role="presentation" attribute should be consumed', () => { + editor.setData( + '' + + '' + + '
1
' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '1' + + '
' + ); + } ); + + it( 'should set `tableType` to `layout` also add tableWidth="30%" and apply tableColumnGroup', () => { + editor.setData( + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '' + + 'a' + + 'b' + + 'c' + + '' + + '' + + '1' + + '2' + + '3' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + ); + } ); + } ); + } ); + + describe( 'clipboard pipeline', () => { + it( 'should not crash the editor if there is no clipboard plugin', async () => { + await editor.destroy(); + + editor = await ClassicTestEditor.create( editorElement, { + plugins: [ TableEditing, TableLayoutEditing ] + } ); + + expect( editor.plugins.get( 'TableLayoutEditing' ) ).to.be.instanceOf( TableLayoutEditing ); + } ); + + describe( 'pasting content', () => { + it( 'should preserve table type if paste within the same editor', () => { + const dataTransferMock = createDataTransfer( { + 'application/ckeditor5-editor-id': editor.id, + 'text/html': '
Foo
' + } ); + + view.document.fire( 'paste', { + dataTransfer: dataTransferMock, + preventDefault: () => {}, + stopPropagation: () => {}, + method: 'paste' + } ); + + expect( getModelData( model ) ).to.equal( + '[' + + '' + + '' + + 'Foo' + + '' + + '' + + '
]' + ); + + expect( editor.getData() ).to.equal( + '' + + '' + + '' + + '' + + '' + ); + } ); + + it( 'should preserve table type if paste from the another editor', () => { + const dataTransferMock = createDataTransfer( { + 'application/ckeditor5-editor-id': 'other-editor', + 'text/html': '
Foo
' + } ); + + view.document.fire( 'paste', { + dataTransfer: dataTransferMock, + preventDefault: () => {}, + stopPropagation: () => {}, + method: 'paste' + } ); + + expect( getModelData( model ) ).to.equal( + '[' + + '' + + '' + + 'Foo' + + '' + + '' + + '
]' + ); + + expect( editor.getData() ).to.equal( + '' + + '' + + '' + + '' + + '' + ); + } ); + + it( 'should convert to content table if paste from external (with figure tag)', () => { + const dataTransferMock = createDataTransfer( { + 'text/html': '
Foo
' + } ); + + view.document.fire( 'paste', { + dataTransfer: dataTransferMock, + preventDefault: () => {}, + stopPropagation: () => {}, + method: 'paste' + } ); + + expect( getModelData( model ) ).to.equal( + '[' + + '' + + '' + + 'Foo' + + '' + + '' + + '
]' + ); + + expect( editor.getData() ).to.equal( + '' + + '' + + '' + + '' + + '
Foo
' + ); + } ); + + it( 'should convert to content table if paste from external (without figure tag)', () => { + const dataTransferMock = createDataTransfer( { + 'text/html': '
Foo
' + } ); + + view.document.fire( 'paste', { + dataTransfer: dataTransferMock, + preventDefault: () => {}, + stopPropagation: () => {}, + method: 'paste' + } ); + + expect( getModelData( model ) ).to.equal( + '[' + + '' + + '' + + 'Foo' + + '' + + '' + + '
]' + ); + + expect( editor.getData() ).to.equal( + '' + + '' + + '' + + '' + + '
Foo
' + ); + } ); + + it( 'should not convert to content table if it\'s pasted from other editor (without figure)', () => { + const dataTransferMock = createDataTransfer( { + 'application/ckeditor5-editor-id': 'other-editor', + 'text/html': '
Foo
' + } ); + + view.document.fire( 'paste', { + dataTransfer: dataTransferMock, + preventDefault: () => {}, + stopPropagation: () => {}, + method: 'paste' + } ); + + expect( getModelData( model ) ).to.equal( + '[' + + '' + + '' + + 'Foo' + + '' + + '' + + '
]' + ); + + expect( editor.getData() ).to.equal( + '' + + '' + + '' + + '' + + '' + ); + } ); + } ); + + describe( 'copying tables', () => { + describe( 'slice', () => { + it( 'should preserve table type when copying', () => { + setModelData( + model, + '' + + '' + + '[' + + 'Foo' + + ']' + + '' + + '
' + ); + + const dataTransferMock = createDataTransfer(); + + view.document.fire( 'copy', { + dataTransfer: dataTransferMock, + preventDefault: () => {}, + stopPropagation: () => {} + } ); + + expect( dataTransferMock.getData( 'text/html' ) ).to.equal( + '' + + '' + + '' + + '' + + '' + ); + } ); + + it( 'should preserve content table type when copying', () => { + setModelData( + model, + '' + + '' + + '[' + + 'Bar' + + ']' + + '' + + '
' + ); + + const dataTransferMock = createDataTransfer(); + + view.document.fire( 'copy', { + dataTransfer: dataTransferMock, + preventDefault: () => {}, + stopPropagation: () => {} + } ); + + expect( dataTransferMock.getData( 'text/html' ) ).to.equal( + '' + + '' + + '' + + '' + + '
Bar
' + ); + } ); + } ); + + describe( 'whole table', () => { + it( 'should preserve table type when copying entire layout table', () => { + setModelData( + model, + '[' + + '' + + '' + + 'Foo' + + '' + + '' + + '
]' + ); + + const dataTransferMock = createDataTransfer(); + + view.document.fire( 'copy', { + dataTransfer: dataTransferMock, + preventDefault: () => {}, + stopPropagation: () => {} + } ); + + expect( dataTransferMock.getData( 'text/html' ) ).to.equal( + '' + + '' + + '' + + '' + + '' + ); + } ); + + it( 'should preserve table type when copying entire content table', () => { + setModelData( + model, + '[' + + '' + + '' + + 'Bar' + + '' + + '' + + '
]' + ); + + const dataTransferMock = createDataTransfer(); + + view.document.fire( 'copy', { + dataTransfer: dataTransferMock, + preventDefault: () => {}, + stopPropagation: () => {} + } ); + + expect( dataTransferMock.getData( 'text/html' ) ).to.equal( + '' + + '' + + '' + + '' + + '
Bar
' + ); + } ); + } ); + } ); + } ); + + describe( 'postfixer', () => { + it( 'should add `tableType` attribute to the table', () => { + insertTableCommand.execute( { rows: 1, columns: 2 } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '' + + '' + + '' + + '' + + '
' + ); + } ); + + it( 'should add `tableType` attribute to all tables added in a single change block', () => { + const tableUtils = editor.plugins.get( 'TableUtils' ); + + editor.model.change( writer => { + const table1 = tableUtils.createTable( writer, { rows: 1, columns: 1 } ); + const table2 = tableUtils.createTable( writer, { rows: 2, columns: 1 } ); + + model.insertObject( table1, null, null, { findOptimalPosition: 'auto', setSelection: 'after' } ); + model.insertObject( table2, null, null, { findOptimalPosition: 'auto' } ); + + writer.setSelection( writer.createPositionAt( table2.getNodeByPath( [ 0, 0, 0 ] ), 0 ) ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '' + + '
' + + '' + + '' + + '' + + '
' + ); + } ); + + it( 'should add `tableType` attribute to table in a blockquote added in a single change block', () => { + const tableUtils = editor.plugins.get( 'TableUtils' ); + + model.change( writer => { + const table = tableUtils.createTable( writer, { rows: 1, columns: 1 } ); + const blockQuote = writer.createElement( 'blockQuote' ); + const docFrag = writer.createDocumentFragment(); + + writer.append( blockQuote, docFrag ); + writer.append( table, blockQuote ); + + editor.model.insertContent( docFrag ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '
' + + '' + + '' + + '
' + + '
' + ); + } ); + } ); +} ); + +function createDataTransfer( data = {} ) { + return { + getData( type ) { + return data[ type ]; + }, + setData( type, value ) { + data[ type ] = value; + } + }; +} diff --git a/packages/ckeditor5-table/tests/tablelayout/tablelayoutui.js b/packages/ckeditor5-table/tests/tablelayout/tablelayoutui.js new file mode 100644 index 00000000000..293a011fb8a --- /dev/null +++ b/packages/ckeditor5-table/tests/tablelayout/tablelayoutui.js @@ -0,0 +1,213 @@ +/** + * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +/* global document */ + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor.js'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; + +import TableEditing from '../../src/tableediting.js'; +import TableLayoutUI from '../../src/tablelayout/tablelayoutui.js'; +import InsertTableView from '../../src/ui/inserttableview.js'; +import DropdownView from '@ckeditor/ckeditor5-ui/src/dropdown/dropdownview.js'; +import { IconTableLayout } from '@ckeditor/ckeditor5-icons'; + +describe( 'TableLayoutUI', () => { + let editor, element; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); + + return ClassicTestEditor + .create( element, { + plugins: [ TableEditing, TableLayoutUI ] + } ) + .then( newEditor => { + editor = newEditor; + } ); + } ); + + afterEach( () => { + element.remove(); + + return editor.destroy(); + } ); + + it( 'should have `isOfficialPlugin` static flag set to `true`', () => { + expect( TableLayoutUI.isOfficialPlugin ).to.be.true; + } ); + + it( 'should have `isPremiumPlugin` static flag set to `false`', () => { + expect( TableLayoutUI.isPremiumPlugin ).to.be.false; + } ); + + describe( 'insertTableLayout dropdown', () => { + let insertTableLayout; + + beforeEach( () => { + insertTableLayout = editor.ui.componentFactory.create( 'insertTableLayout' ); + insertTableLayout.render(); + + document.body.appendChild( insertTableLayout.element ); + + // Dropdown is lazy loaded, so make sure it's open. See https://github.com/ckeditor/ckeditor5/issues/6193. + insertTableLayout.isOpen = true; + } ); + + afterEach( () => { + insertTableLayout.element.remove(); + } ); + + it( 'should register insertTableLayout button', () => { + expect( insertTableLayout ).to.be.instanceOf( DropdownView ); + expect( insertTableLayout.buttonView.label ).to.equal( 'Insert table layout' ); + expect( insertTableLayout.buttonView.icon ).to.equal( IconTableLayout ); + } ); + + it( 'should bind to insertTableLayout command', () => { + const command = editor.commands.get( 'insertTableLayout' ); + + command.isEnabled = true; + expect( insertTableLayout.buttonView.isOn ).to.be.true; + expect( insertTableLayout.buttonView.isEnabled ).to.be.true; + + command.isEnabled = false; + expect( insertTableLayout.buttonView.isEnabled ).to.be.false; + } ); + + it( 'should execute insertTableLayout command on button execute event', () => { + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + + const tableSizeView = insertTableLayout.panelView.children.first; + + tableSizeView.rows = 2; + tableSizeView.columns = 7; + + insertTableLayout.fire( 'execute' ); + + sinon.assert.calledOnce( executeSpy ); + sinon.assert.calledWithExactly( executeSpy, 'insertTableLayout', { rows: 2, columns: 7 } ); + } ); + + it( 'is not fully initialized until open', () => { + const dropdown = editor.ui.componentFactory.create( 'insertTableLayout' ); + + for ( const childView of dropdown.panelView.children ) { + expect( childView ).not.to.be.instanceOf( InsertTableView ); + } + } ); + + describe( 'on open', () => { + let insertTableLayout; + + beforeEach( () => { + insertTableLayout = editor.ui.componentFactory.create( 'insertTableLayout' ); + + insertTableLayout.render(); + document.body.appendChild( insertTableLayout.element ); + + insertTableLayout.isOpen = true; // Dropdown is lazy loaded (#6193). + insertTableLayout.isOpen = false; + } ); + + afterEach( () => { + insertTableLayout.element.remove(); + insertTableLayout.destroy(); + } ); + + it( 'should focus the first tile in the grid', () => { + const spy = sinon.spy( insertTableLayout.panelView.children.first.items.first, 'focus' ); + + insertTableLayout.buttonView.fire( 'open' ); + + sinon.assert.calledOnce( spy ); + } ); + } ); + } ); + + describe( 'menuBar:insertTableLayout menu bar menu', () => { + let menuView; + + beforeEach( () => { + menuView = editor.ui.componentFactory.create( 'menuBar:insertTableLayout' ); + menuView.render(); + + document.body.appendChild( menuView.element ); + + menuView.isOpen = true; + } ); + + afterEach( () => { + menuView.element.remove(); + } ); + + it( 'should set properties on a button', () => { + expect( menuView.buttonView.label ).to.equal( 'Table layout' ); + expect( menuView.buttonView.icon ).to.equal( IconTableLayout ); + } ); + + it( 'should bind #isEnabled to the InsertTableLayoutCommand', () => { + const command = editor.commands.get( 'insertTableLayout' ); + + expect( menuView.isEnabled ).to.be.true; + + command.forceDisabled( 'foo' ); + expect( menuView.isEnabled ).to.be.false; + + command.clearForceDisabled( 'foo' ); + expect( menuView.isEnabled ).to.be.true; + } ); + + it( 'should render InsertTableView', () => { + expect( menuView.panelView.children.first ).to.be.instanceOf( InsertTableView ); + } ); + + it( 'should delegate #execute from InsertTableView to the MenuBarMenuView', () => { + const spy = sinon.spy(); + + menuView.on( 'execute', spy ); + + menuView.panelView.children.first.fire( 'execute' ); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'should execute the insertTableLayout command upon the #execute event and focus editing', () => { + const command = editor.commands.get( 'insertTableLayout' ); + const commandSpy = sinon.spy( command, 'execute' ); + const focusSpy = sinon.spy( editor.editing.view, 'focus' ); + const insertView = menuView.panelView.children.first; + + insertView.rows = 3; + insertView.columns = 5; + + insertView.fire( 'execute' ); + + sinon.assert.calledOnceWithExactly( commandSpy, { rows: 3, columns: 5 } ); + sinon.assert.calledOnce( focusSpy ); + sinon.assert.callOrder( commandSpy, focusSpy ); + } ); + + it( 'should reset column and rows selection on reopen', () => { + const insertView = menuView.panelView.children.first; + + insertView.rows = 3; + insertView.columns = 5; + + menuView.isOpen = false; + + expect( insertView.rows ).to.equal( 1 ); + expect( insertView.columns ).to.equal( 1 ); + + menuView.isOpen = true; + + expect( insertView.rows ).to.equal( 1 ); + expect( insertView.columns ).to.equal( 1 ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-ui/src/menubar/utils.ts b/packages/ckeditor5-ui/src/menubar/utils.ts index 09d9a50e962..a91dcb71e08 100644 --- a/packages/ckeditor5-ui/src/menubar/utils.ts +++ b/packages/ckeditor5-ui/src/menubar/utils.ts @@ -830,7 +830,8 @@ export const DefaultMenuBarItems: MenuBarConfigObject[ 'items' ] = [ 'menuBar:insertImage', 'menuBar:ckbox', 'menuBar:ckfinder', - 'menuBar:insertTable' + 'menuBar:insertTable', + 'menuBar:insertTableLayout' ] }, { diff --git a/packages/ckeditor5-utils/src/index.ts b/packages/ckeditor5-utils/src/index.ts index 8c1bed66057..bea93d76ed7 100644 --- a/packages/ckeditor5-utils/src/index.ts +++ b/packages/ckeditor5-utils/src/index.ts @@ -45,7 +45,7 @@ export { default as abortableDebounce, type AbortableFunc } from './abortabledeb export { default as count } from './count.js'; export { default as compareArrays } from './comparearrays.js'; export { default as createElement } from './dom/createelement.js'; -export { default as Config } from './config.js'; +export { default as Config, type GetSubConfig } from './config.js'; export { default as isIterable } from './isiterable.js'; export { default as DomEmitterMixin, type DomEmitter } from './dom/emittermixin.js'; export { default as findClosestScrollableAncestor } from './dom/findclosestscrollableancestor.js'; diff --git a/scripts/vale/styles/Vocab/Docs/accept.txt b/scripts/vale/styles/Vocab/Docs/accept.txt index 9d02ca0771b..b8c186af861 100755 --- a/scripts/vale/styles/Vocab/Docs/accept.txt +++ b/scripts/vale/styles/Vocab/Docs/accept.txt @@ -45,6 +45,7 @@ DOM (?i)downcast draggable [Dd]ropdown +[Ee]mail embeddable Embedly ESLint