diff --git a/packages/ckeditor5-table/lang/contexts.json b/packages/ckeditor5-table/lang/contexts.json index 349fbe9eba0..e33f150479d 100644 --- a/packages/ckeditor5-table/lang/contexts.json +++ b/packages/ckeditor5-table/lang/contexts.json @@ -4,6 +4,7 @@ "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.", "Delete column": "Label for the delete table column button.", + "Resize column": "Label for the resize table column button.", "Select column": "Label for the select the entire table column button.", "Column": "Label for the table column dropdown button.", "Header row": "Label for the set/unset table header row button.", @@ -63,5 +64,8 @@ "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.", + "Column width in pixels": "The label for the column width input.", + "Column width must not be empty.": "Text used as error label when user submitted resize table column form with blank value.", + "Incorrect column width value.": "Text used as error label when user submitted resize table column form with incorrect value." } diff --git a/packages/ckeditor5-table/package.json b/packages/ckeditor5-table/package.json index 1111e8d8fd5..e9a5f2c0f4a 100644 --- a/packages/ckeditor5-table/package.json +++ b/packages/ckeditor5-table/package.json @@ -14,6 +14,7 @@ "main": "src/index.ts", "dependencies": { "ckeditor5": "41.3.1", + "@ckeditor/ckeditor5-ui": "41.3.1", "lodash-es": "4.17.21" }, "devDependencies": { @@ -36,7 +37,6 @@ "@ckeditor/ckeditor5-paragraph": "41.3.1", "@ckeditor/ckeditor5-theme-lark": "41.3.1", "@ckeditor/ckeditor5-typing": "41.3.1", - "@ckeditor/ckeditor5-ui": "41.3.1", "@ckeditor/ckeditor5-undo": "41.3.1", "@ckeditor/ckeditor5-utils": "41.3.1", "@ckeditor/ckeditor5-widget": "41.3.1", diff --git a/packages/ckeditor5-table/src/augmentation.ts b/packages/ckeditor5-table/src/augmentation.ts index 93ae947bc76..02d4575923e 100644 --- a/packages/ckeditor5-table/src/augmentation.ts +++ b/packages/ckeditor5-table/src/augmentation.ts @@ -19,6 +19,7 @@ import type { TableClipboard, TableColumnResize, TableColumnResizeEditing, + TableColumnResizeUI, TableEditing, TableKeyboard, TableMouse, @@ -29,6 +30,7 @@ import type { TableToolbar, TableUI, TableUtils, + TableColumnResizeUtils, PlainTableOutput, // Commands @@ -56,6 +58,7 @@ import type { TableCellWidthCommand, TableAlignmentCommand, TableBackgroundColorCommand, + TableColumnResizeCommand, TableBorderColorCommand, TableBorderStyleCommand, TableBorderWidthCommand, @@ -85,6 +88,8 @@ declare module '@ckeditor/ckeditor5-core' { [ TableCellWidthEditing.pluginName ]: TableCellWidthEditing; [ TableClipboard.pluginName ]: TableClipboard; [ TableColumnResize.pluginName ]: TableColumnResize; + [ TableColumnResizeUtils.pluginName ]: TableColumnResizeUtils; + [ TableColumnResizeUI.pluginName ]: TableColumnResizeUI; [ TableColumnResizeEditing.pluginName ]: TableColumnResizeEditing; [ TableEditing.pluginName ]: TableEditing; [ TableKeyboard.pluginName ]: TableKeyboard; @@ -135,5 +140,6 @@ declare module '@ckeditor/ckeditor5-core' { tableBorderWidth: TableBorderWidthCommand; tableHeight: TableHeightCommand; tableWidth: TableWidthCommand; + resizeTableColumn: TableColumnResizeCommand; } } diff --git a/packages/ckeditor5-table/src/index.ts b/packages/ckeditor5-table/src/index.ts index 041b2c40dcf..81f2498da14 100644 --- a/packages/ckeditor5-table/src/index.ts +++ b/packages/ckeditor5-table/src/index.ts @@ -28,7 +28,9 @@ export { default as TableKeyboard } from './tablekeyboard.js'; export { default as TableSelection } from './tableselection.js'; export { default as TableUtils } from './tableutils.js'; export { default as TableColumnResize } from './tablecolumnresize.js'; +export { default as TableColumnResizeUtils } from './tablecolumnresize/tablecolumnresizeutils.js'; export { default as TableColumnResizeEditing } from './tablecolumnresize/tablecolumnresizeediting.js'; +export { default as TableColumnResizeUI } from './tablecolumnresize/tablecolumnresizeui.js'; export type { TableConfig } from './tableconfig.js'; export type { default as InsertColumnCommand } from './commands/insertcolumncommand.js'; @@ -60,5 +62,6 @@ export type { default as TableBorderStyleCommand } from './tableproperties/comma export type { default as TableBorderWidthCommand } from './tableproperties/commands/tableborderwidthcommand.js'; export type { default as TableHeightCommand } from './tableproperties/commands/tableheightcommand.js'; export type { default as TableWidthCommand } from './tableproperties/commands/tablewidthcommand.js'; +export type { default as TableColumnResizeCommand } from './tablecolumnresize/commands/tablecolumnresizecommand.js'; import './augmentation.js'; diff --git a/packages/ckeditor5-table/src/tablecolumnresize.ts b/packages/ckeditor5-table/src/tablecolumnresize.ts index d350c68bbcd..c6c913e2ddd 100644 --- a/packages/ckeditor5-table/src/tablecolumnresize.ts +++ b/packages/ckeditor5-table/src/tablecolumnresize.ts @@ -10,6 +10,7 @@ import { Plugin } from 'ckeditor5/src/core.js'; import TableColumnResizeEditing from './tablecolumnresize/tablecolumnresizeediting.js'; import TableCellWidthEditing from './tablecellwidth/tablecellwidthediting.js'; +import TableColumnResizeUI from './tablecolumnresize/tablecolumnresizeui.js'; import '../theme/tablecolumnresize.css'; @@ -23,7 +24,7 @@ export default class TableColumnResize extends Plugin { * @inheritDoc */ public static get requires() { - return [ TableColumnResizeEditing, TableCellWidthEditing ] as const; + return [ TableColumnResizeEditing, TableColumnResizeUI, TableCellWidthEditing ] as const; } /** diff --git a/packages/ckeditor5-table/src/tablecolumnresize/commands/tablecolumnresizecommand.ts b/packages/ckeditor5-table/src/tablecolumnresize/commands/tablecolumnresizecommand.ts new file mode 100644 index 00000000000..11f5630836a --- /dev/null +++ b/packages/ckeditor5-table/src/tablecolumnresize/commands/tablecolumnresizecommand.ts @@ -0,0 +1,100 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module table/tablecolumnresize/commands/tablecolumnresizecommand + */ + +import { Command, type Editor } from 'ckeditor5/src/core.js'; +import type { DowncastInsertEvent } from 'ckeditor5/src/engine.js'; +import type { PossibleResizeColumnRange } from '../tablecolumnresizeutils.js'; + +/** + * The resize table column command. + * + * The command is registered by {@link module:table/tablecolumnresize/tablecolumnresizeui~TableColumnResizeUI} + * as the `'resizeTableColumn'` editor command. + * + * To resize currently selected column, execute the command: + * + * ```ts + * editor.execute( 'resizeTableColumn', { newColumnWidth: 250 } ); + * ``` + */ +export default class TableColumnResizeCommand extends Command { + /** + * The command value: Current size of column (in pixels) and possible resize range of such column.. + * + * @readonly + * @observable + */ + declare public possibleRange: PossibleResizeColumnRange | null; + + constructor( editor: Editor ) { + super( editor ); + + // After inserting a table, the Resizer column elements are added to the document. + // However, the plugin remains disabled because none of the column resizers have been mounted in the DOM yet. + // To ensure that the resize option is enabled, refresh the plugin after rendering the table. + this.editor.conversion.for( 'editingDowncast' ).add( dispatcher => { + dispatcher.on( 'insert:table', () => { + editor.editing.view.once( 'render', this.refresh.bind( this ) ); + } ); + } ); + } + + /** + * @inheritDoc + */ + public override refresh(): void { + const { plugins } = this.editor; + + const resizeUtils = plugins.get( 'TableColumnResizeUtils' ); + const tableUtils = plugins.get( 'TableUtils' ); + + const smallestResizer = resizeUtils.getSmallestSelectedColumnResizer(); + + if ( smallestResizer ) { + const { last, first } = tableUtils.getColumnIndexes( + tableUtils.getSelectionAffectedTableCells( this.editor.model.document.selection ) + ); + + if ( last === first ) { + this.isEnabled = true; + this.possibleRange = resizeUtils.getPossibleResizeColumnRange( smallestResizer ); + return; + } + } + + this.isEnabled = false; + this.possibleRange = null; + } + + /** + * Executes the command. + * + * Resizes selected column to new size. + * + * @param options.newColumnWidth New column size in pixels. + * @fires execute + */ + public override execute( { newColumnWidth }: TableColumnResizeCommandOptions ): void { + const resizeUtils = this.editor.plugins.get( 'TableColumnResizeUtils' ); + const resizer = resizeUtils.getSmallestSelectedColumnResizer(); + + resizeUtils.resizeColumnUsingResizer( resizer!, newColumnWidth ); + } +} + +export interface TableColumnResizeCommandOptions { + + /** + * Represents the width of a new table column in pixels. + * + * * The width is automatically clamped if it's too large or too small. + * * The width corresponds to the inner cell content width (excluding borders). + */ + newColumnWidth: number; +} diff --git a/packages/ckeditor5-table/src/tablecolumnresize/tablewidthscommand.ts b/packages/ckeditor5-table/src/tablecolumnresize/commands/tablewidthscommand.ts similarity index 96% rename from packages/ckeditor5-table/src/tablecolumnresize/tablewidthscommand.ts rename to packages/ckeditor5-table/src/tablecolumnresize/commands/tablewidthscommand.ts index 78e90dc47fc..2155ae434e8 100644 --- a/packages/ckeditor5-table/src/tablecolumnresize/tablewidthscommand.ts +++ b/packages/ckeditor5-table/src/tablecolumnresize/commands/tablewidthscommand.ts @@ -4,12 +4,12 @@ */ /** - * @module table/tablecolumnresize/tablewidthscommand + * @module table/tablecolumnresize/commands/tablewidthscommand */ import type { Element } from 'ckeditor5/src/engine.js'; import { Command } from 'ckeditor5/src/core.js'; -import { normalizeColumnWidths } from './utils.js'; +import { normalizeColumnWidths } from '../utils.js'; /** * Command used by the {@link module:table/tablecolumnresize~TableColumnResize Table column resize feature} that diff --git a/packages/ckeditor5-table/src/tablecolumnresize/tablecolumnresizeediting.ts b/packages/ckeditor5-table/src/tablecolumnresize/tablecolumnresizeediting.ts index 3bdf7f6643d..da788c84048 100644 --- a/packages/ckeditor5-table/src/tablecolumnresize/tablecolumnresizeediting.ts +++ b/packages/ckeditor5-table/src/tablecolumnresize/tablecolumnresizeediting.ts @@ -23,66 +23,33 @@ import type { Differ, DomEventData, DowncastInsertEvent, - DowncastWriter, Element, - ViewElement, - ViewNode + ViewElement } from 'ckeditor5/src/engine.js'; import MouseEventsObserver from '../../src/tablemouse/mouseeventsobserver.js'; import TableEditing from '../tableediting.js'; import TableUtils from '../tableutils.js'; -import TableWalker from '../tablewalker.js'; -import TableWidthsCommand from './tablewidthscommand.js'; +import TableWidthsCommand from './commands/tablewidthscommand.js'; +import TableColumnResizeUtils, { type ResizingData } from './tablecolumnresizeutils.js'; import { downcastTableResizedClass, upcastColgroupElement } from './converters.js'; import { - clamp, createFilledArray, sumArray, - getColumnEdgesIndexes, getChangedResizedTables, getColumnMinWidthAsPercentage, - getElementWidthInPixels, - getTableWidthInPixels, normalizeColumnWidths, - toPrecision, - getDomCellOuterWidth, updateColumnElements, getColumnGroupElement, getTableColumnElements, getTableColumnsWidths } from './utils.js'; -import { COLUMN_MIN_WIDTH_IN_PIXELS } from './constants.js'; import type TableColumnResize from '../tablecolumnresize.js'; -type ResizingData = { - columnPosition: number; - flags: { - isRightEdge: boolean; - isTableCentered: boolean; - isLtrContent: boolean; - }; - elements: { - viewResizer: ViewElement; - modelTable: Element; - viewFigure: ViewElement; - viewColgroup: ViewElement; - viewLeftColumn: ViewElement; - viewRightColumn?: ViewElement; - }; - widths: { - viewFigureParentWidth: number; - viewFigureWidth: number; - tableWidth: number; - leftColumnWidth: number; - rightColumnWidth?: number; - }; -}; - /** * The table column resize editing plugin. */ @@ -121,7 +88,7 @@ export default class TableColumnResizeEditing extends Plugin { * @inheritDoc */ public static get requires() { - return [ TableEditing, TableUtils ] as const; + return [ TableEditing, TableUtils, TableColumnResizeUtils ] as const; } /** @@ -473,107 +440,22 @@ export default class TableColumnResizeEditing extends Plugin { * @param domEventData The data related to the DOM event. */ private _onMouseDownHandler( eventInfo: EventInfo, domEventData: DomEventData ) { - const target = domEventData.target; - - if ( !target.hasClass( 'ck-table-column-resizer' ) ) { - return; - } + const resizerElement = domEventData.target; - if ( !this._isResizingAllowed ) { + if ( !resizerElement.hasClass( 'ck-table-column-resizer' ) || !this._isResizingAllowed ) { return; } - const editor = this.editor; - const modelTable = editor.editing.mapper.toModelElement( target.findAncestor( 'figure' )! )!; - - // Do not resize if table model is in non-editable place. - if ( !editor.model.canEditAt( modelTable ) ) { - return; - } - - domEventData.preventDefault(); - eventInfo.stop(); - - // The column widths are calculated upon mousedown to allow lazy applying the `columnWidths` attribute on the table. - const columnWidthsInPx = _calculateDomColumnWidths( modelTable, this._tableUtilsPlugin, editor ); - const viewTable = target.findAncestor( 'table' )!; - const editingView = editor.editing.view; - - // Insert colgroup for the table that is resized for the first time. - if ( !Array.from( viewTable.getChildren() ).find( viewCol => viewCol.is( 'element', 'colgroup' ) ) ) { - editingView.change( viewWriter => { - _insertColgroupElement( viewWriter, columnWidthsInPx, viewTable ); - } ); - } - - this._isResizingActive = true; - this._resizingData = this._getResizingData( domEventData, columnWidthsInPx ); - - // At this point we change only the editor view - we don't want other users to see our changes yet, - // so we can't apply them in the model. - editingView.change( writer => _applyResizingAttributesToTable( writer, viewTable, this._resizingData! ) ); - - /** - * Calculates the DOM columns' widths. It is done by taking the width of the widest cell - * from each table column (we rely on the {@link module:table/tablewalker~TableWalker} - * to determine which column the cell belongs to). - * - * @param modelTable A table which columns should be measured. - * @param tableUtils The Table Utils plugin instance. - * @param editor The editor instance. - * @returns Columns' widths expressed in pixels (without unit). - */ - function _calculateDomColumnWidths( modelTable: Element, tableUtilsPlugin: TableUtils, editor: Editor ) { - const columnWidthsInPx = Array( tableUtilsPlugin.getColumns( modelTable ) ); - const tableWalker = new TableWalker( modelTable ); - - for ( const cellSlot of tableWalker ) { - const viewCell = editor.editing.mapper.toViewElement( cellSlot.cell )!; - const domCell = editor.editing.view.domConverter.mapViewToDom( viewCell )!; - const domCellWidth = getDomCellOuterWidth( domCell ); - - if ( !columnWidthsInPx[ cellSlot.column ] || domCellWidth < columnWidthsInPx[ cellSlot.column ] ) { - columnWidthsInPx[ cellSlot.column ] = toPrecision( domCellWidth ); - } - } - - return columnWidthsInPx; - } - - /** - * Creates a `` element with ``s and inserts it into a given view table. - * - * @param viewWriter A writer instance. - * @param columnWidthsInPx Column widths. - * @param viewTable A table view element. - */ - function _insertColgroupElement( viewWriter: DowncastWriter, columnWidthsInPx: Array, viewTable: ViewElement ) { - const colgroup = viewWriter.createContainerElement( 'colgroup' ); - - for ( let i = 0; i < columnWidthsInPx.length; i++ ) { - const viewColElement = viewWriter.createEmptyElement( 'col' ); - const columnWidthInPc = `${ toPrecision( columnWidthsInPx[ i ] / sumArray( columnWidthsInPx ) * 100 ) }%`; - - viewWriter.setStyle( 'width', columnWidthInPc, viewColElement ); - viewWriter.insert( viewWriter.createPositionAt( colgroup, 'end' ), viewColElement ); - } - - viewWriter.insert( viewWriter.createPositionAt( viewTable, 0 ), colgroup ); - } - - /** - * Applies the style and classes to the view table as the resizing begun. - * - * @param viewWriter A writer instance. - * @param viewTable A table containing the clicked resizer. - * @param resizingData Data related to the resizing. - */ - function _applyResizingAttributesToTable( viewWriter: DowncastWriter, viewTable: ViewElement, resizingData: ResizingData ) { - const figureInitialPcWidth = resizingData.widths.viewFigureWidth / resizingData.widths.viewFigureParentWidth; + this._resizingData = this._resizeUtils.prepareColumnResize( resizerElement ); - viewWriter.addClass( 'ck-table-resized', viewTable ); - viewWriter.addClass( 'ck-table-column-resizer__active', resizingData.elements.viewResizer ); - viewWriter.setStyle( 'width', `${ toPrecision( figureInitialPcWidth * 100 ) }%`, viewTable.findAncestor( 'figure' )! ); + // By default, the position of the resizer is set as the startDraggingPosition element. + // It works, but it feels much less responsive when starting to drag if you use + // the actual cursor position instead of the element position. + if ( this._resizingData ) { + this._resizingData.startDraggingPosition = ( domEventData.domEvent as MouseEvent ).clientX; + this._isResizingActive = true; + } else { + this._isResizingActive = false; } } @@ -597,62 +479,11 @@ export default class TableColumnResizeEditing extends Plugin { return; } - const { - columnPosition, - flags: { - isRightEdge, - isTableCentered, - isLtrContent - }, - elements: { - viewFigure, - viewLeftColumn, - viewRightColumn - }, - widths: { - viewFigureParentWidth, - tableWidth, - leftColumnWidth, - rightColumnWidth - } - } = this._resizingData!; - - const dxLowerBound = -leftColumnWidth + COLUMN_MIN_WIDTH_IN_PIXELS; - - const dxUpperBound = isRightEdge ? - viewFigureParentWidth - tableWidth : - rightColumnWidth! - COLUMN_MIN_WIDTH_IN_PIXELS; - - // The multiplier is needed for calculating the proper movement offset: - // - it should negate the sign if content language direction is right-to-left, - // - it should double the offset if the table edge is resized and table is centered. - const multiplier = ( isLtrContent ? 1 : -1 ) * ( isRightEdge && isTableCentered ? 2 : 1 ); - - const dx = clamp( - ( mouseEventData.clientX - columnPosition ) * multiplier, - Math.min( dxLowerBound, 0 ), - Math.max( dxUpperBound, 0 ) - ); - - if ( dx === 0 ) { - return; - } - - this.editor.editing.view.change( writer => { - const leftColumnWidthAsPercentage = toPrecision( ( leftColumnWidth + dx ) * 100 / tableWidth ); - - writer.setStyle( 'width', `${ leftColumnWidthAsPercentage }%`, viewLeftColumn ); - - if ( isRightEdge ) { - const tableWidthAsPercentage = toPrecision( ( tableWidth + dx ) * 100 / viewFigureParentWidth ); + const newColumnWidth = ( + mouseEventData.clientX - this._resizingData!.startDraggingPosition + ) + this._resizingData!.widths.leftColumnWidth; - writer.setStyle( 'width', `${ tableWidthAsPercentage }%`, viewFigure ); - } else { - const rightColumnWidthAsPercentage = toPrecision( ( rightColumnWidth! - dx ) * 100 / tableWidth ); - - writer.setStyle( 'width', `${ rightColumnWidthAsPercentage }%`, viewRightColumn! ); - } - } ); + this._resizeUtils.assignColumnWidth( this._resizingData!, newColumnWidth, true ); } /** @@ -666,149 +497,11 @@ export default class TableColumnResizeEditing extends Plugin { return; } - const { - viewResizer, - modelTable, - viewFigure, - viewColgroup - } = this._resizingData!.elements; - - const editor = this.editor; - const editingView = editor.editing.view; - - const tableColumnGroup = this.getColumnGroupElement( modelTable ); - const viewColumns: Array = Array - .from( viewColgroup.getChildren() ) - .filter( ( column: ViewNode ): column is ViewElement => column.is( 'view:element' ) ); - - const columnWidthsAttributeOld = tableColumnGroup ? - this.getTableColumnsWidths( tableColumnGroup )! : - null; - - const columnWidthsAttributeNew = viewColumns.map( column => column.getStyle( 'width' ) ); - - const isColumnWidthsAttributeChanged = !isEqual( columnWidthsAttributeOld, columnWidthsAttributeNew ); - - const tableWidthAttributeOld = modelTable.getAttribute( 'tableWidth' ) as string; - const tableWidthAttributeNew = viewFigure.getStyle( 'width' )!; - - const isTableWidthAttributeChanged = tableWidthAttributeOld !== tableWidthAttributeNew; - - if ( isColumnWidthsAttributeChanged || isTableWidthAttributeChanged ) { - if ( this._isResizingAllowed ) { - editor.execute( 'resizeTableWidth', { - table: modelTable, - tableWidth: `${ toPrecision( tableWidthAttributeNew ) }%`, - columnWidths: columnWidthsAttributeNew - } ); - } else { - // In read-only mode revert all changes in the editing view. The model is not touched so it does not need to be restored. - // This case can occur if the read-only mode kicks in during the resizing process. - editingView.change( writer => { - // If table had resized columns before, restore the previous column widths. - // Otherwise clean up the view from the temporary column resizing markup. - if ( columnWidthsAttributeOld ) { - for ( const viewCol of viewColumns ) { - writer.setStyle( 'width', columnWidthsAttributeOld.shift()!, viewCol ); - } - } else { - writer.remove( viewColgroup ); - } - - if ( isTableWidthAttributeChanged ) { - // If the whole table was already resized before, restore the previous table width. - // Otherwise clean up the view from the temporary table resizing markup. - if ( tableWidthAttributeOld ) { - writer.setStyle( 'width', tableWidthAttributeOld, viewFigure ); - } else { - writer.removeStyle( 'width', viewFigure ); - } - } - - // If a table and its columns weren't resized before, - // prune the remaining common resizing markup. - if ( !columnWidthsAttributeOld && !tableWidthAttributeOld ) { - writer.removeClass( - 'ck-table-resized', - [ ... viewFigure.getChildren() as IterableIterator ].find( element => element.name === 'table' )! - ); - } - } ); - } - } - - editingView.change( writer => { - writer.removeClass( 'ck-table-column-resizer__active', viewResizer ); - } ); - + this._resizeUtils.endResize( this._resizingData!, !this._isResizingAllowed ); this._isResizingActive = false; this._resizingData = null; } - /** - * Retrieves and returns required data needed for the resizing process. - * - * @param domEventData The data of the `mousedown` event. - * @param columnWidths The current widths of the columns. - * @returns The data needed for the resizing process. - */ - private _getResizingData( domEventData: DomEventData, columnWidths: Array ): ResizingData { - const editor = this.editor; - - const columnPosition = ( domEventData.domEvent as Event & { clientX: number } ).clientX; - - const viewResizer = domEventData.target; - const viewLeftCell = viewResizer.findAncestor( 'td' )! || viewResizer.findAncestor( 'th' )!; - const modelLeftCell = editor.editing.mapper.toModelElement( viewLeftCell )!; - const modelTable = modelLeftCell.findAncestor( 'table' )!; - - const leftColumnIndex = getColumnEdgesIndexes( modelLeftCell, this._tableUtilsPlugin ).rightEdge; - const lastColumnIndex = this._tableUtilsPlugin.getColumns( modelTable ) - 1; - - const isRightEdge = leftColumnIndex === lastColumnIndex; - const isTableCentered = !modelTable.hasAttribute( 'tableAlignment' ); - const isLtrContent = editor.locale.contentLanguageDirection !== 'rtl'; - - const viewTable = viewLeftCell.findAncestor( 'table' )!; - const viewFigure = viewTable.findAncestor( 'figure' ) as ViewElement; - const viewColgroup = [ ...viewTable.getChildren() as IterableIterator ] - .find( viewCol => viewCol.is( 'element', 'colgroup' ) )!; - const viewLeftColumn = viewColgroup.getChild( leftColumnIndex ) as ViewElement; - const viewRightColumn = isRightEdge ? undefined : viewColgroup.getChild( leftColumnIndex + 1 ) as ViewElement; - - const viewFigureParentWidth = getElementWidthInPixels( - editor.editing.view.domConverter.mapViewToDom( viewFigure.parent! ) as HTMLElement - ); - const viewFigureWidth = getElementWidthInPixels( editor.editing.view.domConverter.mapViewToDom( viewFigure )! ); - const tableWidth = getTableWidthInPixels( modelTable, editor ); - const leftColumnWidth = columnWidths[ leftColumnIndex ]; - const rightColumnWidth = isRightEdge ? undefined : columnWidths[ leftColumnIndex + 1 ]; - - return { - columnPosition, - flags: { - isRightEdge, - isTableCentered, - isLtrContent - }, - elements: { - viewResizer, - modelTable, - viewFigure, - viewColgroup, - viewLeftColumn, - viewRightColumn - }, - widths: { - viewFigureParentWidth, - viewFigureWidth, - tableWidth, - leftColumnWidth, - rightColumnWidth - } - }; - } - /** * Registers a listener ensuring that each resizable cell have a resizer handle. */ @@ -826,4 +519,11 @@ export default class TableColumnResizeEditing extends Plugin { }, { priority: 'lowest' } ); } ); } + + /** + * Getter for table resize utils plugin. + */ + private get _resizeUtils(): TableColumnResizeUtils { + return this.editor.plugins.get( 'TableColumnResizeUtils' ); + } } diff --git a/packages/ckeditor5-table/src/tablecolumnresize/tablecolumnresizeui.ts b/packages/ckeditor5-table/src/tablecolumnresize/tablecolumnresizeui.ts new file mode 100644 index 00000000000..521038f4703 --- /dev/null +++ b/packages/ckeditor5-table/src/tablecolumnresize/tablecolumnresizeui.ts @@ -0,0 +1,258 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module table/tablecolumnresize/tablecolumnresizeui + */ + +import { Plugin, type Editor } from 'ckeditor5/src/core.js'; +import { + ViewModel, + ContextualBalloon, + clickOutsideHandler, + CssTransitionDisablerMixin, + type ViewWithCssTransitionDisabler, + type ListDropdownItemDefinition +} from 'ckeditor5/src/ui.js'; + +import { getBalloonTablePositionData } from '../utils/ui/contextualballoon.js'; + +import TableColumnResizeCommand from './commands/tablecolumnresizecommand.js'; +import TableColumnResizeUtils from './tablecolumnresizeutils.js'; + +import TableColumnResizeFormView, { + type TableColumnResizeFormValidatorCallback, + type TableColumnResizeFormViewCancelEvent, + type TableColumnResizeFormViewSubmitEvent +} from './ui/tablecolumnresizeformview.js'; + +/** + * The resize table column UI plugin. + * + * The plugin uses the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon}. + */ +export default class TableColumnResizeUI extends Plugin { + /** + * The contextual balloon plugin instance. + */ + private _balloon?: ContextualBalloon; + + /** + * A form containing a textarea and buttons, used to change the `alt` text value. + */ + private _form?: TableColumnResizeFormView & ViewWithCssTransitionDisabler; + + /** + * @inheritDoc + */ + public static get requires() { + return [ ContextualBalloon, TableColumnResizeUtils ] as const; + } + + /** + * @inheritDoc + */ + public static get pluginName() { + return 'TableColumnResizeUI' as const; + } + + /** + * @inheritDoc + */ + public init(): void { + this.editor.commands.add( 'resizeTableColumn', new TableColumnResizeCommand( this.editor ) ); + } + + /** + * @inheritDoc + */ + public override destroy(): void { + super.destroy(); + + // Destroy created UI components as they are not automatically destroyed (see ckeditor5#1341). + if ( this._form ) { + this._form.destroy(); + } + } + + /** + * @internal + */ + public _createDropdownEntry(): ListDropdownItemDefinition { + const editor = this.editor; + const t = editor.t; + + const command: TableColumnResizeCommand = editor.commands.get( 'resizeTableColumn' )!; + const model = new ViewModel(); + + model.set( { + label: t( 'Resize column' ), + withText: true, + onClick: () => { + this._showForm(); + } + } ); + + model.bind( 'isEnabled' ).to( command, 'isEnabled' ); + model.bind( 'isOn' ).to( command, 'value', value => !!value ); + + return { + type: 'button', + model + }; + } + + /** + * Creates the {@link module:table/tablecolumnresize/ui/tablecolumnresizeformview~TableColumnResizeFormView} + * form. + */ + private _createForm(): void { + const editor = this.editor; + + this._balloon = this.editor.plugins.get( 'ContextualBalloon' ); + + this._form = new ( CssTransitionDisablerMixin( TableColumnResizeFormView ) )( editor.locale, getFormValidators( editor ) ); + + // Render the form so its #element is available for clickOutsideHandler. + this._form.render(); + + this.listenTo( this._form, 'submit', () => { + if ( this._form!.isValid() ) { + editor.execute( 'resizeTableColumn', { + newColumnWidth: this._form!.parsedSize! + } ); + + this._hideForm( true ); + } + } ); + + // Update balloon position when form error label is added . + this.listenTo( this._form.labeledInput, 'change:errorText', () => { + editor.ui.update(); + } ); + + this.listenTo( this._form, 'cancel', () => { + this._hideForm( true ); + } ); + + // Close the form on Esc key press. + this._form.keystrokes.set( 'Esc', ( data, cancel ) => { + this._hideForm( true ); + cancel(); + } ); + + // Close on click outside of balloon panel element. + clickOutsideHandler( { + emitter: this._form, + activator: () => this._isVisible, + contextElements: () => [ this._balloon!.view.element! ], + callback: () => this._hideForm() + } ); + } + + /** + * Shows the {@link #_form} in the {@link #_balloon}. + */ + private _showForm(): void { + if ( this._isVisible ) { + return; + } + + if ( !this._form ) { + this._createForm(); + } + + const editor = this.editor; + const command: TableColumnResizeCommand = editor.commands.get( 'resizeTableColumn' )!; + const labeledInput = this._form!.labeledInput; + + this._form!.disableCssTransitions(); + this._form!.resetFormStatus(); + + if ( !this._isInBalloon ) { + this._balloon!.add( { + view: this._form!, + position: getBalloonTablePositionData( editor ) + } ); + } + + // Ensure that command value is always up to date. Column resizing does not trigger command refresh all of the time. + // In some scenarios like resizing column and then undoing this the plugins are not refreshed and value is outdated. + command.refresh(); + + // Make sure that each time the panel shows up, the field remains in sync with the value of + // the command. If the user typed in the input, then canceled the balloon (`labeledInput#value` + // stays unaltered) and re-opened it without changing the value of the command, they would see the + // old value instead of the actual value of the command. + const possibleRange = command.possibleRange!; + + labeledInput.fieldView.value = labeledInput.fieldView.element!.value = Math.ceil( possibleRange.current ).toString(); + labeledInput.fieldView.set( { + min: Math.floor( possibleRange.lower ), + max: Math.ceil( possibleRange.upper ) + } ); + + this._form!.labeledInput.fieldView.select(); + this._form!.enableCssTransitions(); + } + + /** + * Removes the {@link #_form} from the {@link #_balloon}. + * + * @param focusEditable Controls whether the editing view is focused afterwards. + */ + private _hideForm( focusEditable: boolean = false ): void { + if ( !this._isInBalloon ) { + return; + } + + // Blur the input element before removing it from DOM to prevent issues in some browsers. + // See https://github.com/ckeditor/ckeditor5/issues/1501. + if ( this._form!.focusTracker.isFocused ) { + this._form!.saveButtonView.focus(); + } + + this._balloon!.remove( this._form! ); + + if ( focusEditable ) { + this.editor.editing.view.focus(); + } + } + + /** + * Returns `true` when the {@link #_form} is the visible view in the {@link #_balloon}. + */ + private get _isVisible(): boolean { + return !!this._balloon && this._balloon.visibleView === this._form; + } + + /** + * Returns `true` when the {@link #_form} is in the {@link #_balloon}. + */ + private get _isInBalloon(): boolean { + return !!this._balloon && this._balloon.hasView( this._form! ); + } +} + +/** + * Returns column resize form validation callbacks. + * + * @param editor Editor instance. + */ +function getFormValidators( editor: Editor ): Array { + const t = editor.t; + + return [ + form => { + if ( form.rawSize!.trim() === '' ) { + return t( 'Column width must not be empty.' ); + } + + if ( form.parsedSize === null ) { + return t( 'Incorrect column width value.' ); + } + } + ]; +} diff --git a/packages/ckeditor5-table/src/tablecolumnresize/tablecolumnresizeutils.ts b/packages/ckeditor5-table/src/tablecolumnresize/tablecolumnresizeutils.ts new file mode 100644 index 00000000000..3120769d509 --- /dev/null +++ b/packages/ckeditor5-table/src/tablecolumnresize/tablecolumnresizeutils.ts @@ -0,0 +1,561 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module table/tablecolumnresize/tablecolumnresizeutils + */ + +import { isEqual } from 'lodash-es'; + +import { Plugin, type Editor } from 'ckeditor5/src/core.js'; + +import type { DowncastWriter, Element, ViewElement, ViewNode } from 'ckeditor5/src/engine.js'; +import TableUtils from '../tableutils.js'; + +import { COLUMN_MIN_WIDTH_IN_PIXELS } from './constants.js'; +import { + clamp, getColumnEdgesIndexes, getColumnGroupElement, getDomCellOuterWidth, + getElementWidthInPixels, + getTableColumnsWidths, getTableWidthInPixels, sumArray, toPrecision +} from './utils.js'; + +import TableWalker from '../tablewalker.js'; + +/** + * The table column resize utils plugin. + */ +export default class TableColumnResizeUtils extends Plugin { + /** + * @inheritDoc + */ + public static get requires() { + return [ TableUtils ] as const; + } + + /** + * @inheritDoc + */ + public static get pluginName() { + return 'TableColumnResizeUtils' as const; + } + + /** + * Performs resize of specified by resizer column with specified width. + * It composes steps from {@link #prepareColumnResize}, {@link #assignColumnWidth} and {@link #endResize}. + * + * @param resizerElement Resize column handle view element. + * @param newColumnWidth New column width in pixels. + * @param compensateWidthWhenMoveCenteredTable + */ + public resizeColumnUsingResizer( + resizerElement: ViewElement, + newColumnWidth: number, + compensateWidthWhenMoveCenteredTable?: boolean + ): void { + const resizingData = this.prepareColumnResize( resizerElement ); + + if ( resizingData ) { + this.assignColumnWidth( resizingData, newColumnWidth, compensateWidthWhenMoveCenteredTable ); + this.endResize( resizingData ); + } + } + + /** + * In this scenario: + * + * +---+---+---+ + * | a | + * +---+---+---+ + * | b | c | d | + * +---+---+---+ + * | e | f | g | + * +---+---+---+ + * + * When user selects column that contains `a`, `b`, `e` cells this function returns the first smallest + * column resize view element in that selection (`b` column). The resize view element is the blue draggable + * border element on the right side of selected column. + * + * @returns View element of column resizer DOM node. + */ + public getSmallestSelectedColumnResizer(): ViewElement | null { + const sortedElements = getAllSelectedResizersDOMNodes( this.editor ) + .sort( ( a, b ) => Math.sign( a.getBoundingClientRect().left - b.getBoundingClientRect().left ) ); + + if ( !sortedElements.length ) { + return null; + } + + const viewElement = this.editor.editing.view.domConverter.domToView( sortedElements[ 0 ] )!; + + return viewElement as ViewElement; + } + + /** + * Starts resizing process. + * + * * assigns resize attributes that will be used to resize table in next steps, + * * calculates resizing data based on resizer element, + * * applies the attributes to the `` view element. + * + * @param resizerElement Resize column handle view element. + */ + public prepareColumnResize( resizerElement: ViewElement ): ResizingData | null { + const { editing } = this.editor; + + const viewTable = resizerElement.findAncestor( 'table' )!; + const editingView = editing.view; + const columnWidthsInPx = calculateResizerColumnWidth( this.editor, resizerElement ); + + if ( !columnWidthsInPx ) { + return null; + } + + // Insert colgroup for the table that is resized for the first time. + if ( !Array.from( viewTable.getChildren() ).find( viewCol => viewCol.is( 'element', 'colgroup' ) ) ) { + editingView.change( viewWriter => { + insertColgroupElement( viewWriter, columnWidthsInPx, viewTable ); + } ); + } + + const resizingData = getResizingData( this.editor, resizerElement, columnWidthsInPx ); + + // At this point we change only the editor view - we don't want other users to see our changes yet, + // so we can't apply them in the model. + this.editor.editing.view.change( writer => applyResizingAttributesToTable( writer, viewTable, resizingData ) ); + + return resizingData; + } + + /** + * This function returns the maximum and minimum dimensions to which a table column can be expanded. + * It also provides the current dimension of the column. + * + * @param resizingDataOrElement Resizing data or view element. + */ + public getPossibleResizeColumnRange( resizingDataOrElement: ResizingData | ViewElement ): PossibleResizeColumnRange | null { + const resizingData = ( () => { + if ( 'startDraggingPosition' in resizingDataOrElement ) { + return resizingDataOrElement; + } + + const columnWidthsInPx = calculateResizerColumnWidth( this.editor, resizingDataOrElement ); + + if ( !columnWidthsInPx ) { + return null; + } + + return getResizingData( this.editor, resizingDataOrElement, columnWidthsInPx ); + } )(); + + if ( !resizingData ) { + return null; + } + + const { + flags: { + isRightEdge + }, + widths: { + viewFigureParentWidth, + tableWidth, + leftColumnWidth, + rightColumnWidth + } + } = resizingData; + + const dxUpperBound = isRightEdge ? + viewFigureParentWidth - tableWidth : + rightColumnWidth! - COLUMN_MIN_WIDTH_IN_PIXELS; + + return { + current: leftColumnWidth, + lower: COLUMN_MIN_WIDTH_IN_PIXELS, + upper: leftColumnWidth + Math.max( 0, dxUpperBound ) + }; + } + + /** + * Update column width using provided resizing data. + * + * @param resizingData Resizing data. + * @param newColumnWidth New width of table column specified by resizingData. + * @param compensateWidthWhenMoveCenteredTable When the last column is resized in centered mode, the table shifts to the left + * based on the `newColumnWidth` value. However, when dragging to resize, the column width + * must be multiplied because during the drag operation, the table’s left corner also moves + * leftward by the same delta as the right corner. As a result, the resized table ends up + * being twice as large as the provided value, but the right corner remains + * under the mouse cursor. + */ + public assignColumnWidth( + resizingData: ResizingData, + newColumnWidth: number, + compensateWidthWhenMoveCenteredTable?: boolean + ): void { + const { + flags: { + isRightEdge, + isLtrContent, + isTableCentered + }, + elements: { + viewFigure, + viewLeftColumn, + viewRightColumn + }, + widths: { + viewFigureParentWidth, + tableWidth, + leftColumnWidth, + rightColumnWidth + } + } = resizingData; + + // The multiplier is needed for calculating the proper movement offset: + // - it should negate the sign if content language direction is right-to-left, + // - it should double the offset if the table edge is resized and table is centered. + const multiplier = compensateWidthWhenMoveCenteredTable ? + ( isLtrContent ? 1 : -1 ) * ( isRightEdge && isTableCentered ? 2 : 1 ) : 1; + + const possibleResizeRange = this.getPossibleResizeColumnRange( resizingData )!; + + const dx = clamp( + ( newColumnWidth - leftColumnWidth ) * multiplier, + Math.min( 0, possibleResizeRange.lower - leftColumnWidth ), + possibleResizeRange.upper - leftColumnWidth + ); + + if ( dx === 0 ) { + return; + } + + this.editor.editing.view.change( writer => { + const leftColumnWidthAsPercentage = toPrecision( ( leftColumnWidth + dx ) * 100 / tableWidth ); + + writer.setStyle( 'width', `${ leftColumnWidthAsPercentage }%`, viewLeftColumn! ); + + if ( isRightEdge ) { + const tableWidthAsPercentage = toPrecision( ( tableWidth + dx ) * 100 / viewFigureParentWidth ); + + writer.setStyle( 'width', `${ tableWidthAsPercentage }%`, viewFigure ); + } else { + const rightColumnWidthAsPercentage = toPrecision( ( rightColumnWidth! - dx ) * 100 / tableWidth ); + + writer.setStyle( 'width', `${ rightColumnWidthAsPercentage }%`, viewRightColumn! ); + } + } ); + } + + /** + * Stops table resizing process. + * + * * If read only mode, it cancels the resizing process restoring the original widths. + * * Otherwise it propagates the changes from view to the model by executing the adequate commands. + * + * @param resizingData Resizing data of table. + * @param readOnly Flag that indicates that table is read only. + */ + public endResize( resizingData: ResizingData, readOnly?: boolean ): void { + const editor = this.editor; + const editingView = editor.editing.view; + + const { + viewResizer, + modelTable, + viewFigure, + viewColgroup + } = resizingData.elements; + + const tableColumnGroup = getColumnGroupElement( modelTable ); + const viewColumns: Array = Array + .from( viewColgroup!.getChildren() ) + .filter( ( column: ViewNode ): column is ViewElement => column.is( 'view:element' ) ); + + const columnWidthsAttributeOld = tableColumnGroup ? + getTableColumnsWidths( tableColumnGroup )! : + null; + + const columnWidthsAttributeNew = viewColumns.map( column => column.getStyle( 'width' ) ); + + const isColumnWidthsAttributeChanged = !isEqual( columnWidthsAttributeOld, columnWidthsAttributeNew ); + + const tableWidthAttributeOld = modelTable.getAttribute( 'tableWidth' ) as string; + const tableWidthAttributeNew = viewFigure.getStyle( 'width' )!; + + const isTableWidthAttributeChanged = tableWidthAttributeOld !== tableWidthAttributeNew; + + if ( isColumnWidthsAttributeChanged || isTableWidthAttributeChanged ) { + if ( readOnly ) { + // In read-only mode revert all changes in the editing view. The model is not touched so it does not need to be restored. + // This case can occur if the read-only mode kicks in during the resizing process. + editingView.change( writer => { + // If table had resized columns before, restore the previous column widths. + // Otherwise clean up the view from the temporary column resizing markup. + if ( columnWidthsAttributeOld ) { + for ( const viewCol of viewColumns ) { + writer.setStyle( 'width', columnWidthsAttributeOld.shift()!, viewCol ); + } + } else { + writer.remove( viewColgroup! ); + } + + if ( isTableWidthAttributeChanged ) { + // If the whole table was already resized before, restore the previous table width. + // Otherwise clean up the view from the temporary table resizing markup. + if ( tableWidthAttributeOld ) { + writer.setStyle( 'width', tableWidthAttributeOld, viewFigure ); + } else { + writer.removeStyle( 'width', viewFigure ); + } + } + + // If a table and its columns weren't resized before, + // prune the remaining common resizing markup. + if ( !columnWidthsAttributeOld && !tableWidthAttributeOld ) { + writer.removeClass( + 'ck-table-resized', + [ ... viewFigure.getChildren() as IterableIterator ].find( element => element.name === 'table' )! + ); + } + } ); + } else { + editor.execute( 'resizeTableWidth', { + table: modelTable, + tableWidth: `${ toPrecision( tableWidthAttributeNew ) }%`, + columnWidths: columnWidthsAttributeNew + } ); + } + } + + editingView.change( writer => { + writer.removeClass( 'ck-table-column-resizer__active', viewResizer ); + } ); + } +} + +/** + * Function picks all column resizer DOM nodes from provided table selection. + * + * @param editor Editor instance. + * @returns Array of HTML elements. + */ +function getAllSelectedResizersDOMNodes( editor: Editor ): Array { + const { editing, plugins, model } = editor; + const tableUtils = plugins.get( 'TableUtils' ); + const { domConverter } = editing.view; + + return tableUtils + .getSelectionAffectedTableCells( model.document.selection ) + .map( model => editing.mapper.toViewElement( model ) ) + .map( view => view && domConverter.viewToDom( view ) ) + .flatMap( dom => dom ? Array.from( dom.querySelectorAll( '.ck-table-column-resizer' ) ) : [] ); +} + +/** + * Calculates the DOM column's width based on provided resizer element. + * Uses {@link #_calculateDomColumnWidths} under the hood. + * + * @param editor Editor instance. + * @param resizerElement Resize column handle view element. + * @returns Widths of columns or null (if table is readonly). + */ +function calculateResizerColumnWidth( editor: Editor, resizerElement: ViewElement ): Array | null { + const { editing, model } = editor; + const modelTable = editing.mapper.toModelElement( resizerElement.findAncestor( 'figure' )! )!; + + // Do not resize if table model is in non-editable place. + if ( !model.canEditAt( modelTable ) ) { + return null; + } + + const columnWidthsInPx = calculateDomColumnWidths( modelTable, editor ); + + if ( !columnWidthsInPx ) { + return null; + } + + return columnWidthsInPx; +} + +/** + * Calculates the DOM columns' widths. It is done by taking the width of the widest cell + * from each table column (we rely on the {@link module:table/tablewalker~TableWalker} + * to determine which column the cell belongs to). + * + * @param modelTable A table which columns should be measured. + * @param editor The editor instance. + * @returns Columns' widths expressed in pixels (without unit and if all cells are present in DOM). + */ +function calculateDomColumnWidths( modelTable: Element, editor: Editor ): Array | null { + const tableUtilsPlugin = editor.plugins.get( 'TableUtils' ); + const columnWidthsInPx = Array( tableUtilsPlugin.getColumns( modelTable ) ); + const tableWalker = new TableWalker( modelTable ); + + for ( const cellSlot of tableWalker ) { + const viewCell = editor.editing.mapper.toViewElement( cellSlot.cell )!; + const domCell = editor.editing.view.domConverter.mapViewToDom( viewCell ); + + if ( !domCell ) { + return null; + } + + const domCellWidth = getDomCellOuterWidth( domCell ); + + if ( !columnWidthsInPx[ cellSlot.column ] || domCellWidth < columnWidthsInPx[ cellSlot.column ] ) { + columnWidthsInPx[ cellSlot.column ] = toPrecision( domCellWidth ); + } + } + + return columnWidthsInPx; +} + +/** + * Creates a `` element with ``s and inserts it into a given view table. + * + * @internal + * @param viewWriter A writer instance. + * @param columnWidthsInPx Column widths. + * @param viewTable A table view element. + */ +function insertColgroupElement( + viewWriter: DowncastWriter, + columnWidthsInPx: Array, + viewTable: ViewElement +): void { + const colgroup = viewWriter.createContainerElement( 'colgroup' ); + + for ( let i = 0; i < columnWidthsInPx.length; i++ ) { + const viewColElement = viewWriter.createEmptyElement( 'col' ); + const columnWidthInPc = `${ toPrecision( columnWidthsInPx[ i ] / sumArray( columnWidthsInPx ) * 100 ) }%`; + + viewWriter.setStyle( 'width', columnWidthInPc, viewColElement ); + viewWriter.insert( viewWriter.createPositionAt( colgroup, 'end' ), viewColElement ); + } + + viewWriter.insert( viewWriter.createPositionAt( viewTable, 0 ), colgroup ); +} + +/** + * Applies the style and classes to the view table as the resizing begun. + * + * @param viewWriter A writer instance. + * @param viewTable A table containing the clicked resizer. + * @param resizingData Data related to the resizing. + */ +function applyResizingAttributesToTable( + viewWriter: DowncastWriter, + viewTable: ViewElement, + resizingData: ResizingData +): void { + const figureInitialPcWidth = resizingData.widths.viewFigureWidth / resizingData.widths.viewFigureParentWidth; + + viewWriter.addClass( 'ck-table-resized', viewTable ); + viewWriter.addClass( 'ck-table-column-resizer__active', resizingData.elements.viewResizer ); + viewWriter.setStyle( 'width', `${ toPrecision( figureInitialPcWidth * 100 ) }%`, viewTable.findAncestor( 'figure' )! ); +} + +/** + * Retrieves and returns required data needed for the resizing process. + * + * @param viewResizer Resize column handle element. + * @param columnWidths The current widths of the columns. + * @returns The data needed for the resizing process. + */ +function getResizingData( editor: Editor, viewResizer: ViewElement, columnWidths: Array ): ResizingData { + const { domConverter } = editor.editing.view; + const resizerRect = domConverter.mapViewToDom( viewResizer )!.getBoundingClientRect(); + + const tableUtilsPlugin = editor.plugins.get( 'TableUtils' ); + const startDraggingPosition = resizerRect.left + resizerRect.width / 2; + + const viewLeftCell = viewResizer.findAncestor( 'td' )! || viewResizer.findAncestor( 'th' )!; + const modelLeftCell = editor.editing.mapper.toModelElement( viewLeftCell )!; + const modelTable = modelLeftCell.findAncestor( 'table' )!; + + const leftColumnIndex = getColumnEdgesIndexes( modelLeftCell, tableUtilsPlugin ).rightEdge; + const lastColumnIndex = tableUtilsPlugin.getColumns( modelTable ) - 1; + + const isRightEdge = leftColumnIndex === lastColumnIndex; + const isTableCentered = !modelTable.hasAttribute( 'tableAlignment' ); + const isLtrContent = editor.locale.contentLanguageDirection !== 'rtl'; + + const viewTable = viewLeftCell.findAncestor( 'table' )!; + const viewFigure = viewTable.findAncestor( 'figure' ) as ViewElement; + + const viewFigureParentWidth = getElementWidthInPixels( + domConverter.mapViewToDom( viewFigure.parent! ) as HTMLElement + ); + const viewFigureWidth = getElementWidthInPixels( domConverter.mapViewToDom( viewFigure )! ); + const tableWidth = getTableWidthInPixels( modelTable, editor ); + const leftColumnWidth = columnWidths[ leftColumnIndex ]; + const rightColumnWidth = isRightEdge ? undefined : columnWidths[ leftColumnIndex + 1 ]; + + const viewColgroup = [ ...viewTable.getChildren() as IterableIterator ] + .find( viewCol => viewCol.is( 'element', 'colgroup' ) )!; + + const viewLeftColumn = viewColgroup && viewColgroup.getChild( leftColumnIndex ) as ViewElement; + const viewRightColumn = !viewColgroup || isRightEdge ? undefined : viewColgroup.getChild( leftColumnIndex + 1 ) as ViewElement; + + return { + startDraggingPosition, + startColumnWidths: columnWidths, + flags: { + isRightEdge, + isTableCentered, + isLtrContent + }, + elements: { + viewResizer, + modelTable, + viewFigure, + viewColgroup, + viewLeftColumn, + viewRightColumn + }, + widths: { + viewFigureParentWidth, + viewFigureWidth, + tableWidth, + leftColumnWidth, + rightColumnWidth + } + }; +} + +/** + * @internal + */ +export type PossibleResizeColumnRange = { + upper: number; + lower: number; + current: number; +}; + +/** + * @internal + */ +export type ResizingData = { + startDraggingPosition: number; + startColumnWidths: Array; + flags: { + isRightEdge: boolean; + isTableCentered: boolean; + isLtrContent: boolean; + }; + elements: { + viewResizer: ViewElement; + modelTable: Element; + viewFigure: ViewElement; + viewColgroup?: ViewElement; + viewLeftColumn?: ViewElement; + viewRightColumn?: ViewElement; + }; + widths: { + viewFigureParentWidth: number; + viewFigureWidth: number; + tableWidth: number; + leftColumnWidth: number; + rightColumnWidth?: number; + }; +}; diff --git a/packages/ckeditor5-table/src/tablecolumnresize/ui/tablecolumnresizeformview.ts b/packages/ckeditor5-table/src/tablecolumnresize/ui/tablecolumnresizeformview.ts new file mode 100644 index 00000000000..22f1aafc2c1 --- /dev/null +++ b/packages/ckeditor5-table/src/tablecolumnresize/ui/tablecolumnresizeformview.ts @@ -0,0 +1,296 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module table/tablecolumnresize/ui/tablecolumnresizeformview + */ + +import { + ButtonView, + FocusCycler, + LabeledFieldView, + View, + ViewCollection, + createLabeledInputNumber, + submitHandler, + type FocusableView, + type InputNumberView +} from 'ckeditor5/src/ui.js'; + +import { FocusTracker, KeystrokeHandler, type Locale } from 'ckeditor5/src/utils.js'; +import { icons } from 'ckeditor5/src/core.js'; + +import '../../../theme/tableresizecolumnform.css'; + +// See: #8833. +// eslint-disable-next-line ckeditor5-rules/ckeditor-imports +import '@ckeditor/ckeditor5-ui/theme/components/responsive-form/responsiveform.css'; + +/** + * The TableColumnResizeFormView class. + */ +export default class TableColumnResizeFormView extends View { + /** + * Tracks information about the DOM focus in the form. + */ + public readonly focusTracker: FocusTracker; + + /** + * An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}. + */ + public readonly keystrokes: KeystrokeHandler; + + /** + * An input with a label. + */ + public labeledInput: LabeledFieldView; + + /** + * A button used to submit the form. + */ + public saveButtonView: ButtonView; + + /** + * A button used to cancel the form. + */ + public cancelButtonView: ButtonView; + + /** + * A collection of views which can be focused in the form. + */ + protected readonly _focusables: ViewCollection; + + /** + * Helps cycling over {@link #_focusables} in the form. + */ + protected readonly _focusCycler: FocusCycler; + + /** + * An array of form validators used by {@link #isValid}. + */ + private readonly _validators: Array; + + /** + * @inheritDoc + */ + constructor( locale: Locale, validators: Array ) { + super( locale ); + const t = this.locale!.t; + + this.focusTracker = new FocusTracker(); + + this.keystrokes = new KeystrokeHandler(); + + this.labeledInput = this._createLabeledInputView(); + + this.saveButtonView = this._createButton( t( 'Save' ), icons.check, 'ck-button-save' ); + this.saveButtonView.type = 'submit'; + + this.cancelButtonView = this._createButton( t( 'Cancel' ), icons.cancel, 'ck-button-cancel', 'cancel' ); + + this._focusables = new ViewCollection(); + this._validators = validators; + + this._focusCycler = new FocusCycler( { + focusables: this._focusables, + focusTracker: this.focusTracker, + keystrokeHandler: this.keystrokes, + actions: { + // Navigate form fields backwards using the Shift + Tab keystroke. + focusPrevious: 'shift + tab', + + // Navigate form fields forwards using the Tab key. + focusNext: 'tab' + } + } ); + + this.setTemplate( { + tag: 'form', + + attributes: { + class: [ + 'ck', + 'ck-resize-column-form', + 'ck-responsive-form' + ], + + // https://github.com/ckeditor/ckeditor5-image/issues/40 + tabindex: '-1' + }, + + children: [ + this.labeledInput, + this.saveButtonView, + this.cancelButtonView + ] + } ); + } + + /** + * @inheritDoc + */ + public override render(): void { + super.render(); + + this.keystrokes.listenTo( this.element! ); + + submitHandler( { view: this } ); + + [ this.labeledInput, this.saveButtonView, this.cancelButtonView ] + .forEach( v => { + // Register the view as focusable. + this._focusables.add( v ); + + // Register the view in the focus tracker. + this.focusTracker.add( v.element! ); + } ); + } + + /** + * @inheritDoc + */ + public override destroy(): void { + super.destroy(); + + this.focusTracker.destroy(); + this.keystrokes.destroy(); + } + + /** + * Creates the button view. + * + * @param label The button label + * @param icon The button's icon. + * @param className The additional button CSS class name. + * @param eventName The event name that the ButtonView#execute event will be delegated to. + * @returns The button view instance. + */ + private _createButton( label: string, icon: string, className: string, eventName?: string ): ButtonView { + const button = new ButtonView( this.locale ); + + button.set( { + label, + icon, + tooltip: true + } ); + + button.extendTemplate( { + attributes: { + class: className + } + } ); + + if ( eventName ) { + button.delegate( 'execute' ).to( this, eventName ); + } + + return button; + } + + /** + * Creates an input with a label. + * + * @returns Labeled field view instance. + */ + private _createLabeledInputView(): LabeledFieldView { + const t = this.locale!.t; + const labeledInput = new LabeledFieldView( this.locale, createLabeledInputNumber ); + + labeledInput.label = t( 'Column width in pixels' ); + + return labeledInput; + } + + /** + * Validates the form and returns `false` when some fields are invalid. + */ + public isValid(): boolean { + this.resetFormStatus(); + + for ( const validator of this._validators ) { + const errorText = validator( this ); + + // One error per field is enough. + if ( errorText ) { + // Apply updated error. + this.labeledInput.errorText = errorText; + + return false; + } + } + + return true; + } + + /** + * Cleans up the supplementary error and information text of the {@link #labeledInput} + * bringing them back to the state when the form has been displayed for the first time. + * + * See {@link #isValid}. + */ + public resetFormStatus(): void { + this.labeledInput.errorText = null; + } + + /** + * The native DOM `value` of the input element of {@link #labeledInput}. + */ + public get rawSize(): string | null { + const { element } = this.labeledInput.fieldView; + + if ( !element ) { + return null; + } + + return element.value; + } + + /** + * Get numeric value of size. Returns `null` if value of size input element in {@link #labeledInput}.is not a number. + */ + public get parsedSize(): number | null { + const { rawSize } = this; + + if ( rawSize === null ) { + return null; + } + + const parsed = Number.parseFloat( rawSize ); + + if ( Number.isNaN( parsed ) ) { + return null; + } + + return parsed; + } +} + +/** + * Callback used by {@link ~TableColumnResizeFormView} to check if passed form value is valid. + * + * * If `undefined` is returned, it is assumed that the form value is correct and there is no error. + * * If string is returned, it is assumed that the form value is incorrect and the returned string is displayed in the error label + */ +export type TableColumnResizeFormValidatorCallback = ( form: TableColumnResizeFormView ) => string | undefined; + +/** + * Fired when the form view is submitted. + * + * @eventName ~TableColumnResizeFormView#submit + */ +export type TableColumnResizeFormViewSubmitEvent = { + name: 'submit'; + args: []; +}; + +/** + * Fired when the form view is canceled. + * + * @eventName ~TableColumnResizeFormView#cancel + */ +export type TableColumnResizeFormViewCancelEvent = { + name: 'cancel'; + args: []; +}; diff --git a/packages/ckeditor5-table/src/tablecolumnresize/utils.ts b/packages/ckeditor5-table/src/tablecolumnresize/utils.ts index 430ae75484b..2bf148ef126 100644 --- a/packages/ckeditor5-table/src/tablecolumnresize/utils.ts +++ b/packages/ckeditor5-table/src/tablecolumnresize/utils.ts @@ -313,7 +313,7 @@ export function getDomCellOuterWidth( domCell: HTMLElement ): number { // In the 'border-box' box sizing algorithm, the element's width // already includes the padding and border width (#12335). if ( styles.boxSizing === 'border-box' ) { - return parseInt( styles.width ); + return parseFloat( styles.width ); } else { return parseFloat( styles.width ) + parseFloat( styles.paddingLeft ) + diff --git a/packages/ckeditor5-table/src/tableui.ts b/packages/ckeditor5-table/src/tableui.ts index 82e7d7eb76f..87cca3724ab 100644 --- a/packages/ckeditor5-table/src/tableui.ts +++ b/packages/ckeditor5-table/src/tableui.ts @@ -162,6 +162,12 @@ export default class TableUI extends Plugin { } ] as Array; + if ( editor.plugins.has( 'TableColumnResizeUI' ) ) { + const resizeUI = editor.plugins.get( 'TableColumnResizeUI' ); + + options.push( resizeUI._createDropdownEntry() ); + } + return this._prepareDropdown( t( 'Column' ), tableColumnIcon, options, locale ); } ); @@ -285,11 +291,17 @@ export default class TableUI extends Plugin { } ); this.listenTo( dropdownView, 'execute', evt => { - editor.execute( ( evt.source as any ).commandName ); + if ( 'onClick' in evt.source ) { + ( evt.source as any ).onClick(); + } else { + if ( 'commandName' in evt.source ) { + editor.execute( ( evt.source as any ).commandName ); + } - // Toggling a switch button view should not move the focus to the editable. - if ( !( evt.source instanceof SwitchButtonView ) ) { - editor.editing.view.focus(); + if ( !( evt.source instanceof SwitchButtonView ) ) { + // Toggling a switch button view should not move the focus to the editable. + editor.editing.view.focus(); + } } } ); @@ -377,7 +389,7 @@ function addListOption( commands: Array, itemDefinitions: Collection ) { - if ( option.type === 'button' || option.type === 'switchbutton' ) { + if ( ( option.type === 'button' || option.type === 'switchbutton' ) && !( option.model instanceof ViewModel ) ) { const model = option.model = new ViewModel( option.model ); const { commandName, bindIsOn } = option.model; const command = editor.commands.get( commandName as string )!; diff --git a/packages/ckeditor5-table/tests/tablecolumnresize/commands/tablecolumnresizecommand.js b/packages/ckeditor5-table/tests/tablecolumnresize/commands/tablecolumnresizecommand.js new file mode 100644 index 00000000000..3478c46766b --- /dev/null +++ b/packages/ckeditor5-table/tests/tablecolumnresize/commands/tablecolumnresizecommand.js @@ -0,0 +1,90 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* global document */ + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor.js'; +import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model.js'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph.js'; +import ClipboardPipeline from '@ckeditor/ckeditor5-clipboard/src/clipboardpipeline.js'; + +import TableColumnResize from '../../../src/tablecolumnresize.js'; +import Table from '../../../src/table.js'; +import { modelTable } from '../../_utils/utils.js'; + +const DEFAULT_TABLE_DATA = [ + [ '00', '01', '02' ], + [ '10', '11', '12' ], + [ '20', '21', '22' ] +]; + +describe( 'TableColumnResizeCommand', () => { + let model, modelRoot, editor, editorElement, command, tableSelection; + + beforeEach( async () => { + editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); + + editor = await ClassicTestEditor.create( editorElement, { + plugins: [ Table, TableColumnResize, Paragraph, ClipboardPipeline ] + } ); + + model = editor.model; + modelRoot = model.document.getRoot(); + command = editor.commands.get( 'resizeTableColumn' ); + tableSelection = editor.plugins.get( 'TableSelection' ); + + setModelData( model, modelTable( DEFAULT_TABLE_DATA, { tableWidth: '40%' } ) ); + selectColumn(); + } ); + + afterEach( async () => { + editorElement.remove(); + await editor.destroy(); + } ); + + describe( 'Check resize table columns width', () => { + let resizeTableWidthSpy; + + beforeEach( () => { + resizeTableWidthSpy = sinon.spy( editor, 'execute' ); + } ); + + it( 'should be possible to make single selected column smaller', () => { + command.execute( { newColumnWidth: 0.1 * getTableWidth() } ); + expectColumnInnerWidths( [ '33.33%', '10.02%', '56.66%' ] ); + } ); + + it( 'should be possible to make single selected column bigger', () => { + command.execute( { newColumnWidth: 0.5 * getTableWidth() } ); + expectColumnInnerWidths( [ '33.33%', '50.1%', '16.57%' ] ); + } ); + + function expectColumnInnerWidths( columnWidths ) { + const normalizePercentage = num => Number( num.toString().split( '.' )[ 0 ] ); + + // Compare only integer part of percentage. Karma that runs in CI seems to use browser with different settings + // and screen resolution. It leads to a bit shifted values in table scaling. + expect( resizeTableWidthSpy ).to.be.calledWith( 'resizeTableWidth', { + columnWidths: sinon.match( array => columnWidths.every( + ( item, index ) => normalizePercentage( item ) === normalizePercentage( array[ index ] ) ) + ), + table: sinon.match.object, + tableWidth: sinon.match.string + } ); + } + + function getTableWidth() { + return document.querySelector( 'table' ).getBoundingClientRect().width; + } + } ); + + function selectColumn( columnIndex = 1 ) { + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, columnIndex ] ), + modelRoot.getNodeByPath( [ 0, 1, columnIndex ] ) + ); + } +} ); diff --git a/packages/ckeditor5-table/tests/tablecolumnresize/tablewidthscommand.js b/packages/ckeditor5-table/tests/tablecolumnresize/commands/tablewidthscommand.js similarity index 97% rename from packages/ckeditor5-table/tests/tablecolumnresize/tablewidthscommand.js rename to packages/ckeditor5-table/tests/tablecolumnresize/commands/tablewidthscommand.js index 5908ad81b3d..47d0a842c24 100644 --- a/packages/ckeditor5-table/tests/tablecolumnresize/tablewidthscommand.js +++ b/packages/ckeditor5-table/tests/tablecolumnresize/commands/tablewidthscommand.js @@ -10,9 +10,9 @@ import { getData as getModelData, setData as setModelData } from '@ckeditor/cked import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph.js'; import ClipboardPipeline from '@ckeditor/ckeditor5-clipboard/src/clipboardpipeline.js'; -import TableColumnResize from '../../src/tablecolumnresize.js'; -import Table from '../../src/table.js'; -import { modelTable } from '../_utils/utils.js'; +import TableColumnResize from '../../../src/tablecolumnresize.js'; +import Table from '../../../src/table.js'; +import { modelTable } from '../../_utils/utils.js'; describe( 'TableWidthsCommand', () => { let model, editor, editorElement, command; diff --git a/packages/ckeditor5-table/tests/tablecolumnresize/tablecolumnresizeediting.js b/packages/ckeditor5-table/tests/tablecolumnresize/tablecolumnresizeediting.js index 9f5f1b8ca6f..6e4eb223dc3 100644 --- a/packages/ckeditor5-table/tests/tablecolumnresize/tablecolumnresizeediting.js +++ b/packages/ckeditor5-table/tests/tablecolumnresize/tablecolumnresizeediting.js @@ -46,7 +46,7 @@ import { getTableColumnsWidths, getColumnGroupElement } from '../../src/tablecolumnresize/utils.js'; -import TableWidthsCommand from '../../src/tablecolumnresize/tablewidthscommand.js'; +import TableWidthsCommand from '../../src/tablecolumnresize/commands/tablewidthscommand.js'; import WidgetResize from '@ckeditor/ckeditor5-widget/src/widgetresize.js'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph.js'; import { Undo } from '@ckeditor/ckeditor5-undo'; @@ -724,7 +724,7 @@ describe( 'TableColumnResizeEditing', () => { } ); describe( 'model change integration', () => { - describe( 'and the widhtStrategy is "manualWidth"', () => { + describe( 'and the widthStrategy is "manualWidth"', () => { it( 'should create resizers when table is inserted', () => { editor.execute( 'insertTable' ); diff --git a/packages/ckeditor5-table/tests/tablecolumnresize/tablecolumnresizeui.js b/packages/ckeditor5-table/tests/tablecolumnresize/tablecolumnresizeui.js new file mode 100644 index 00000000000..3f09fa6cfa9 --- /dev/null +++ b/packages/ckeditor5-table/tests/tablecolumnresize/tablecolumnresizeui.js @@ -0,0 +1,343 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals document, Event */ + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor.js'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph.js'; +import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model.js'; +import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard.js'; + +import { modelTable } from '../_utils/utils.js'; + +import Table from '../../src/table.js'; +import { TableColumnResize, TableColumnResizeEditing, TableColumnResizeUI, TableSelection } from '../../src/index.js'; + +describe( 'TableColumnResizeUI', () => { + let element, model, modelRoot, editor, dropdown, button, command, tableSelection, plugin, balloon; + + beforeEach( async () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); + + editor = await ClassicTestEditor + .create( element, { + plugins: [ + TableColumnResizeUI, TableColumnResizeEditing, TableColumnResize, + TableSelection, Table, Paragraph + ] + } ); + + model = editor.model; + modelRoot = model.document.getRoot(); + + plugin = editor.plugins.get( TableColumnResizeUI ); + command = editor.commands.get( 'resizeTableColumn' ); + tableSelection = editor.plugins.get( 'TableSelection' ); + + balloon = editor.plugins.get( 'ContextualBalloon' ); + dropdown = editor.ui.componentFactory.create( 'tableColumn' ); + dropdown.isOpen = true; + + button = dropdown.listView.items + .map( item => item.children && item.children.first ) + .filter( Boolean ) + .find( item => item.label === 'Resize column' ); + + setModelData( model, modelTable( [ + [ '00[]', '01', '02' ], + [ '10', '11', '12' ], + [ '20', '21', '22' ] + ] ) ); + } ); + + afterEach( async () => { + await editor.destroy(); + element.remove(); + } ); + + it( 'should be named', () => { + expect( TableColumnResizeUI.pluginName ).to.equal( 'TableColumnResizeUI' ); + } ); + + describe( 'dropdown button', () => { + it( 'should be present in table column utils dropdown', () => { + expect( button ).not.to.be.undefined; + } ); + + it( 'should be enabled when there is at least one table', () => { + expect( button.isEnabled ).to.be.true; + } ); + + it( 'should be enabled when there are not any tables', () => { + setModelData( model, '' ); + + expect( button.isEnabled ).to.be.false; + } ); + + it( 'should have isEnabled property bind to command\'s isEnabled property', () => { + command.isEnabled = true; + expect( button.isEnabled ).to.be.true; + + command.isEnabled = false; + expect( button.isEnabled ).to.be.false; + } ); + + it( 'should be enabled on single column selection', () => { + selectColumn( 1 ); + expect( button.isEnabled ).to.be.true; + } ); + + it( 'should be disabled on multi column selection', () => { + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 1 ] ), + modelRoot.getNodeByPath( [ 0, 1, 2 ] ) + ); + + expect( button.isEnabled ).to.be.false; + } ); + + it( 'should open balloon panel on click', () => { + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 1 ] ), + modelRoot.getNodeByPath( [ 0, 1, 1 ] ) + ); + + expect( balloon.visibleView ).to.be.null; + + button.fire( 'execute' ); + + expect( balloon.visibleView ).to.equal( plugin._form ); + expect( plugin._isVisible ).to.be.true; + } ); + + it( 'should open with default column width', () => { + plugin._createForm(); + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 1 ] ), + modelRoot.getNodeByPath( [ 0, 1, 1 ] ) + ); + + expect( balloon.visibleView ).to.be.null; + + button.fire( 'execute' ); + expect( plugin._form.labeledInput.fieldView.value ).equals( '46' ); + } ); + + it( 'should disable CSS transitions before showing the form to avoid unnecessary animations (and then enable them again)', () => { + selectColumn(); + plugin._createForm(); + + const addSpy = sinon.spy( balloon, 'add' ); + const disableCssTransitionsSpy = sinon.spy( plugin._form, 'disableCssTransitions' ); + const enableCssTransitionsSpy = sinon.spy( plugin._form, 'enableCssTransitions' ); + + button.fire( 'execute' ); + + sinon.assert.callOrder( disableCssTransitionsSpy, addSpy, enableCssTransitionsSpy ); + } ); + } ); + + describe( 'form status', () => { + it( 'should update ui on error due to change ballon position', () => { + const updateSpy = sinon.spy( editor.ui, 'update' ); + + plugin._showForm(); + fillFormSize( 'for sure incorrect value' ); + + expect( updateSpy ).not.to.be.called; + + plugin._form.fire( 'submit' ); + + expect( updateSpy ).to.be.calledOnce; + } ); + + it( 'should show error form status if passed empty size', () => { + plugin._showForm(); + fillFormSize( '' ); + plugin._form.fire( 'submit' ); + expect( getErrorLabel() ).to.be.equal( 'Column width must not be empty.' ); + } ); + + it( 'should show error form status if passed incorrect size', () => { + plugin._showForm(); + fillFormSize( 'for sure incorrect value' ); + plugin._form.fire( 'submit' ); + expect( getErrorLabel() ).to.be.equal( 'Incorrect column width value.' ); + } ); + + it( 'should reset error form status after filling empty size', () => { + plugin._showForm(); + + fillFormSize( 'for sure incorrect value' ); + plugin._form.fire( 'submit' ); + expect( getErrorLabel() ).not.to.be.null; + + fillFormSize( '123456' ); + plugin._form.fire( 'submit' ); + expect( getErrorLabel() ).to.be.null; + } ); + + it( 'should reset form status on show', () => { + plugin._showForm(); + fillFormSize( 'for sure incorrect value' ); + plugin._form.fire( 'submit' ); + + expect( getErrorLabel() ).not.to.be.null; + + plugin._hideForm(); + plugin._showForm(); + expect( getErrorLabel() ).to.be.null; + } ); + + function getErrorLabel() { + return plugin._form.labeledInput.errorText; + } + + function fillFormSize( size ) { + const { fieldView } = plugin._form.labeledInput; + + // jasmine disallow to set non-number value in numeric input + fieldView.element.type = ''; + fieldView.value = size; + } + } ); + + describe( 'balloon panel form', () => { + beforeEach( () => { + selectColumn(); + } ); + + it( 'should implement the CSS transition disabling feature', () => { + plugin._createForm(); + expect( plugin._form.disableCssTransitions ).to.be.a( 'function' ); + } ); + + // https://github.com/ckeditor/ckeditor5-image/issues/114 + it( 'should make sure the input always stays in sync with the value of the command', () => { + button.fire( 'execute' ); + + // Mock the user using the form, changing the value but clicking "Cancel". + // so the command's value is not updated. + plugin._form.labeledInput.fieldView.element.value = 'This value was canceled.'; + plugin._form.fire( 'cancel' ); + + button.fire( 'execute' ); + expect( plugin._form.labeledInput.fieldView.element.value ).to.equal( '46' ); + } ); + + it( 'should not reset input value if #_showForm called on already visible balloon', () => { + plugin._showForm(); + + const resetMethodSpy = sinon.spy( plugin._form.labeledInput.fieldView, 'set' ); + + plugin._showForm(); + expect( resetMethodSpy ).not.to.be.called; + } ); + + it( 'should not remove from balloon if form is not there', () => { + plugin._createForm(); + plugin._hideForm(); + + const resetMethodSpy = sinon.spy( plugin._balloon, 'remove' ); + plugin._hideForm(); + expect( resetMethodSpy ).not.to.be.called; + } ); + + it( 'should execute command on submit', () => { + plugin._showForm(); + + const spy = sinon.spy( editor, 'execute' ); + + plugin._form.labeledInput.fieldView.value = '123'; + plugin._form.fire( 'submit' ); + + sinon.assert.calledWithExactly( spy, 'resizeTableColumn', { + newColumnWidth: 123 + } ); + } ); + + describe( 'blur', () => { + beforeEach( () => { + button.fire( 'execute' ); + } ); + + // https://github.com/ckeditor/ckeditor5/issues/1501 + it( 'should input element before hiding the view', () => { + const editableFocusSpy = sinon.spy( editor.editing.view, 'focus' ); + const buttonFocusSpy = sinon.spy( plugin._form.saveButtonView, 'focus' ); + + plugin._form.focusTracker.isFocused = true; + plugin._form.fire( 'submit' ); + + expect( buttonFocusSpy.calledBefore( editableFocusSpy ) ).to.equal( true ); + } ); + + // https://github.com/ckeditor/ckeditor5-image/issues/299 + it( 'should not blur input element before hiding the view when view was not focused', () => { + const buttonFocusSpy = sinon.spy( plugin._form.saveButtonView, 'focus' ); + + plugin._form.focusTracker.isFocused = false; + plugin._form.fire( 'cancel' ); + + sinon.assert.notCalled( buttonFocusSpy ); + } ); + + it( 'should hide the panel on cancel and focus the editing view', () => { + const focusSpy = sinon.spy( editor.editing.view, 'focus' ); + + expect( balloon.visibleView ).to.equal( plugin._form ); + + plugin._form.fire( 'cancel' ); + expect( balloon.visibleView ).to.be.null; + sinon.assert.calledOnce( focusSpy ); + } ); + } ); + + describe( 'close listeners', () => { + let hideSpy, focusSpy; + + beforeEach( () => { + expect( balloon.visibleView ).to.be.null; + button.fire( 'execute' ); + expect( balloon.visibleView ).not.to.be.null; + + hideSpy = sinon.spy( plugin, '_hideForm' ); + focusSpy = sinon.spy( editor.editing.view, 'focus' ); + } ); + + it( 'should close upon Esc key press and focus the editing view', () => { + const keyEvtData = { + keyCode: keyCodes.esc, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + plugin._form.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( hideSpy ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( focusSpy ); + } ); + + it( 'should close and not focus editable on click outside the panel', () => { + document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) ); + sinon.assert.called( hideSpy ); + sinon.assert.notCalled( focusSpy ); + } ); + + it( 'should not close on click inside the panel', () => { + plugin._form.element.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) ); + sinon.assert.notCalled( hideSpy ); + } ); + } ); + } ); + + function selectColumn( columnIndex = 1 ) { + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, columnIndex ] ), + modelRoot.getNodeByPath( [ 0, 1, columnIndex ] ) + ); + } +} ); diff --git a/packages/ckeditor5-table/tests/tablecolumnresize/ui/tablecolumnresizeformview.js b/packages/ckeditor5-table/tests/tablecolumnresize/ui/tablecolumnresizeformview.js new file mode 100644 index 00000000000..8fed1575a56 --- /dev/null +++ b/packages/ckeditor5-table/tests/tablecolumnresize/ui/tablecolumnresizeformview.js @@ -0,0 +1,262 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* global document, Event */ + +import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard.js'; +import TableColumnResizeFormView from '../../../src/tablecolumnresize/ui/tablecolumnresizeformview.js'; +import View from '@ckeditor/ckeditor5-ui/src/view.js'; +import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler.js'; +import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker.js'; +import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler.js'; +import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection.js'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; + +describe( 'TableColumnResizeFormView', () => { + let view; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + view = new TableColumnResizeFormView( { t: () => {} } ); + } ); + + describe( 'constructor()', () => { + it( 'should create element from template', () => { + view.render(); + + expect( view.element.classList.contains( 'ck' ) ).to.be.true; + expect( view.element.classList.contains( 'ck-resize-column-form' ) ).to.be.true; + expect( view.element.classList.contains( 'ck-responsive-form' ) ).to.be.true; + expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' ); + } ); + + it( 'should create #focusTracker instance', () => { + expect( view.focusTracker ).to.be.instanceOf( FocusTracker ); + } ); + + it( 'should create #keystrokes instance', () => { + expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler ); + } ); + + it( 'should create child views', () => { + expect( view.labeledInput ).to.be.instanceOf( View ); + expect( view.saveButtonView ).to.be.instanceOf( View ); + expect( view.cancelButtonView ).to.be.instanceOf( View ); + + view.render(); + + expect( view.saveButtonView.element.classList.contains( 'ck-button-save' ) ).to.be.true; + expect( view.cancelButtonView.element.classList.contains( 'ck-button-cancel' ) ).to.be.true; + } ); + + it( 'should create #_focusCycler instance', () => { + expect( view._focusCycler ).to.be.instanceOf( FocusCycler ); + } ); + + it( 'should create #_focusables view collection', () => { + expect( view._focusables ).to.be.instanceOf( ViewCollection ); + } ); + + it( 'should fire `cancel` event on cancelButtonView#execute', () => { + const spy = sinon.spy(); + view.on( 'cancel', spy ); + view.cancelButtonView.fire( 'execute' ); + + sinon.assert.calledOnce( spy ); + } ); + } ); + + describe( 'render()', () => { + it( 'starts listening for #keystrokes coming from #element', () => { + const spy = sinon.spy( view.keystrokes, 'listenTo' ); + + view.render(); + sinon.assert.calledOnce( spy ); + sinon.assert.calledWithExactly( spy, view.element ); + } ); + + describe( 'focus cycling and management', () => { + it( 'should register child views in #_focusables', () => { + view.render(); + + expect( view._focusables.map( f => f ) ).to.have.members( [ + view.labeledInput, + view.saveButtonView, + view.cancelButtonView + ] ); + } ); + + it( 'should register child views\' #element in #focusTracker', () => { + const spy = testUtils.sinon.spy( view.focusTracker, 'add' ); + + view.render(); + + sinon.assert.calledWithExactly( spy.getCall( 0 ), view.labeledInput.element ); + sinon.assert.calledWithExactly( spy.getCall( 1 ), view.saveButtonView.element ); + sinon.assert.calledWithExactly( spy.getCall( 2 ), view.cancelButtonView.element ); + } ); + + describe( 'activates keyboard navigation in the form', () => { + beforeEach( () => { + view.render(); + document.body.appendChild( view.element ); + } ); + + afterEach( () => { + view.element.remove(); + view.destroy(); + } ); + + it( 'so "tab" focuses the next focusable item', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the url input is focused. + view.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.labeledInput.element; + + const spy = sinon.spy( view.saveButtonView, 'focus' ); + + view.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'so "shift + tab" focuses the previous focusable item', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + shiftKey: true, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the cancel button is focused. + view.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.cancelButtonView.element; + + const spy = sinon.spy( view.saveButtonView, 'focus' ); + + view.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + } ); + } ); + } ); + + describe( 'isValid()', () => { + it( 'should reset error after successful validation', () => { + const view = new TableColumnResizeFormView( { t: () => {} }, [ + () => undefined + ] ); + + expect( view.isValid() ).to.be.true; + expect( view.labeledInput.errorText ).to.be.null; + } ); + + it( 'should display first error returned from validators list', () => { + const view = new TableColumnResizeFormView( { t: () => {} }, [ + () => undefined, + () => 'Foo bar', + () => 'Another error' + ] ); + + expect( view.isValid() ).to.be.false; + expect( view.labeledInput.errorText ).to.be.equal( 'Foo bar' ); + } ); + + it( 'should pass view reference as argument to validator', () => { + const validatorSpy = sinon.spy(); + const view = new TableColumnResizeFormView( { t: () => {} }, [ validatorSpy ] ); + + view.isValid(); + + expect( validatorSpy ).to.be.calledOnceWithExactly( view ); + } ); + } ); + + describe( 'rawSize getter', () => { + beforeEach( () => { + view.render(); + } ); + + it( 'should return null `rawSize` if element is `null`', () => { + view.labeledInput.fieldView.element = null; + + expect( view.rawSize ).to.be.equal( null ); + } ); + + it( 'should return raw unparsed value of input element in `rawSize`', () => { + view.labeledInput.fieldView.element.value = '1234'; + + expect( view.rawSize ).to.be.equal( '1234' ); + } ); + } ); + + describe( 'parsedSize getter', () => { + beforeEach( () => { + view.render(); + } ); + + it( 'should return null `parsedSize` if element is `null`', () => { + view.labeledInput.fieldView.element = null; + + expect( view.parsedSize ).to.be.equal( null ); + } ); + + it( 'should return parsed value of input element in `parsedSize`', () => { + view.labeledInput.fieldView.element.value = '1234'; + expect( view.parsedSize ).to.be.equal( 1234 ); + + view.labeledInput.fieldView.element.value = '1234.5'; + expect( view.parsedSize ).to.be.equal( 1234.5 ); + } ); + + it( 'should null if `rawSize` is not a number', () => { + view.labeledInput.fieldView.element.value = '1234'; + sinon.stub( view, 'rawSize' ).get( () => 'Foo' ); + + expect( view.parsedSize ).to.be.equal( null ); + } ); + } ); + + describe( 'destroy()', () => { + it( 'should destroy the FocusTracker instance', () => { + const destroySpy = sinon.spy( view.focusTracker, 'destroy' ); + + view.destroy(); + + sinon.assert.calledOnce( destroySpy ); + } ); + + it( 'should destroy the KeystrokeHandler instance', () => { + const destroySpy = sinon.spy( view.keystrokes, 'destroy' ); + + view.destroy(); + + sinon.assert.calledOnce( destroySpy ); + } ); + } ); + + describe( 'DOM bindings', () => { + describe( 'submit event', () => { + it( 'should trigger submit event', () => { + const spy = sinon.spy(); + + view.render(); + view.on( 'submit', spy ); + view.element.dispatchEvent( new Event( 'submit' ) ); + + expect( spy.calledOnce ).to.true; + } ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-table/tests/tableui.js b/packages/ckeditor5-table/tests/tableui.js index 121117e512c..8a8a1ed0827 100644 --- a/packages/ckeditor5-table/tests/tableui.js +++ b/packages/ckeditor5-table/tests/tableui.js @@ -11,6 +11,7 @@ import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; import TableEditing from '../src/tableediting.js'; import TableUI from '../src/tableui.js'; +import TableColumnResize from '../src/tablecolumnresize.js'; import InsertTableView from '../src/ui/inserttableview.js'; import SwitchButtonView from '@ckeditor/ckeditor5-ui/src/button/switchbuttonview.js'; import DropdownView from '@ckeditor/ckeditor5-ui/src/dropdown/dropdownview.js'; @@ -32,17 +33,11 @@ describe( 'TableUI', () => { clearTranslations(); } ); - beforeEach( () => { + beforeEach( async () => { element = document.createElement( 'div' ); document.body.appendChild( element ); - return ClassicTestEditor - .create( element, { - plugins: [ TableEditing, TableUI ] - } ) - .then( newEditor => { - editor = newEditor; - } ); + editor = await createEditor(); } ); afterEach( () => { @@ -354,6 +349,33 @@ describe( 'TableUI', () => { } ); } ); + describe( 'resize table column integration', () => { + it( 'resize column entry is present in dropdown when ResizeTableColumnUI plugin is present', async () => { + await editor.destroy(); + + editor = await createEditor( { + plugins: [ TableEditing, TableUI, TableColumnResize ] + } ); + + const dropdown = editor.ui.componentFactory.create( 'tableColumn' ); + + dropdown.render(); + dropdown.isOpen = true; + + const listView = dropdown.listView; + const labels = listView.items.map( item => item instanceof ListSeparatorView ? '|' : item.children.first.label ); + + expect( labels ).to.deep.equal( + [ + 'Header column', '|', + 'Insert column left', 'Insert column right', + 'Delete column', 'Select column', + 'Resize column' + ] + ); + } ); + } ); + describe( 'tableColumn dropdown', () => { let dropdown; @@ -386,7 +408,11 @@ describe( 'TableUI', () => { const labels = listView.items.map( item => item instanceof ListSeparatorView ? '|' : item.children.first.label ); expect( labels ).to.deep.equal( - [ 'Header column', '|', 'Insert column left', 'Insert column right', 'Delete column', 'Select column' ] + [ + 'Header column', '|', + 'Insert column left', 'Insert column right', + 'Delete column', 'Select column' + ] ); } ); @@ -710,4 +736,11 @@ describe( 'TableUI', () => { expect( spy.args[ 0 ][ 0 ] ).to.equal( 'mergeTableCellUp' ); } ); } ); + + async function createEditor( config ) { + return ClassicTestEditor.create( element, { + plugins: [ TableEditing, TableUI ], + ...config + } ); + } } ); diff --git a/packages/ckeditor5-table/theme/table.css b/packages/ckeditor5-table/theme/table.css index 25fd7a694b6..3c6414679ca 100644 --- a/packages/ckeditor5-table/theme/table.css +++ b/packages/ckeditor5-table/theme/table.css @@ -26,13 +26,15 @@ & td, & th { + --ck-color-cell-border: hsl(0, 0%, 75%); + min-width: 2em; padding: .4em; /* The border is inherited from .ck-editor__nested-editable styles, so theoretically it's not necessary here. However, the border is a content style, so it should use .ck-content (so it works outside the editor). Hence, the duplication. See https://github.com/ckeditor/ckeditor5/issues/6314 */ - border: 1px solid hsl(0, 0%, 75%); + border: 1px solid var(--ck-color-cell-border); } & th { diff --git a/packages/ckeditor5-table/theme/tableresizecolumnform.css b/packages/ckeditor5-table/theme/tableresizecolumnform.css new file mode 100644 index 00000000000..7f0a16198e2 --- /dev/null +++ b/packages/ckeditor5-table/theme/tableresizecolumnform.css @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +@import "@ckeditor/ckeditor5-ui/theme/mixins/_rwd.css"; + +.ck.ck-resize-column-form { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: flex-start; + + & .ck-labeled-field-view { + display: inline-block; + } + + & .ck-label { + display: none; + } + + @mixin ck-media-phone { + flex-wrap: wrap; + + & .ck-labeled-field-view { + flex-basis: 100%; + } + + & .ck-button { + flex-basis: 50%; + } + } +} diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-table/tableediting.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-table/tableediting.css index 46ea9b3561a..c9b3011458e 100644 --- a/packages/ckeditor5-theme-lark/theme/ckeditor5-table/tableediting.css +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-table/tableediting.css @@ -15,10 +15,12 @@ /* A very slight background to highlight the focused cell */ background: var(--ck-color-selector-focused-cell-background); - /* Fixes the problem where surrounding cells cover the focused cell's border. - It does not fix the problem in all places but the UX is improved. - See https://github.com/ckeditor/ckeditor5-table/issues/29. */ - border-style: none; + /* + * Fixes the problem where surrounding cells cover the focused cell's border. + * It does not fix the problem in all places but the UX is improved. + * See https://github.com/ckeditor/ckeditor5-table/issues/29. + */ + border-color: var(--ck-color-cell-border); outline: 1px solid var(--ck-color-focus-border); outline-offset: -1px; /* progressive enhancement - no IE support */ }