@ckeditor/ckeditor5-table
Advanced tools
Comparing version 19.0.0 to 19.1.0
Changelog | ||
========= | ||
All changes in the package are documented in the main repository. See: https://github.com/ckeditor/ckeditor5/blob/master/CHANGELOG.md. | ||
Changes for the past releases are available below. | ||
## [19.0.0](https://github.com/ckeditor/ckeditor5-table/compare/v18.0.0...v19.0.0) (2020-04-29) | ||
@@ -5,0 +9,0 @@ |
@@ -7,3 +7,3 @@ { | ||
"Delete column": "Label for the delete table column button.", | ||
"Select column": "Label for the select table entire column button.", | ||
"Select column": "Label for the select the entire table column button.", | ||
"Column": "Label for the table column dropdown button.", | ||
@@ -14,3 +14,3 @@ "Header row": "Label for the set/unset table header row button.", | ||
"Delete row": "Label for the delete table row button.", | ||
"Select row": "Label for the select table entire row button.", | ||
"Select row": "Label for the select the entire table row button.", | ||
"Row": "Label for the table row dropdown button.", | ||
@@ -17,0 +17,0 @@ "Merge cell up": "Label for the merge table cell up button.", |
{ | ||
"name": "@ckeditor/ckeditor5-table", | ||
"version": "19.0.0", | ||
"version": "19.1.0", | ||
"description": "Table feature for CKEditor 5.", | ||
@@ -13,28 +13,24 @@ "keywords": [ | ||
"dependencies": { | ||
"@ckeditor/ckeditor5-core": "^19.0.0", | ||
"@ckeditor/ckeditor5-ui": "^19.0.0", | ||
"@ckeditor/ckeditor5-widget": "^19.0.0", | ||
"@ckeditor/ckeditor5-core": "^19.0.1", | ||
"@ckeditor/ckeditor5-ui": "^19.0.1", | ||
"@ckeditor/ckeditor5-widget": "^19.1.0", | ||
"lodash-es": "^4.17.10" | ||
}, | ||
"devDependencies": { | ||
"@ckeditor/ckeditor5-alignment": "^19.0.0", | ||
"@ckeditor/ckeditor5-block-quote": "^19.0.0", | ||
"@ckeditor/ckeditor5-clipboard": "^19.0.0", | ||
"@ckeditor/ckeditor5-editor-classic": "^19.0.0", | ||
"@ckeditor/ckeditor5-engine": "^19.0.0", | ||
"@ckeditor/ckeditor5-horizontal-line": "^19.0.0", | ||
"@ckeditor/ckeditor5-image": "^19.0.0", | ||
"@ckeditor/ckeditor5-indent": "^19.0.0", | ||
"@ckeditor/ckeditor5-list": "^19.0.0", | ||
"@ckeditor/ckeditor5-media-embed": "^19.0.0", | ||
"@ckeditor/ckeditor5-paragraph": "^19.0.0", | ||
"@ckeditor/ckeditor5-typing": "^19.0.0", | ||
"@ckeditor/ckeditor5-undo": "^19.0.0", | ||
"@ckeditor/ckeditor5-utils": "^19.0.0", | ||
"eslint": "^5.5.0", | ||
"eslint-config-ckeditor5": "^2.0.0", | ||
"husky": "^1.3.1", | ||
"lint-staged": "^7.0.0", | ||
"stylelint": "^11.1.1", | ||
"stylelint-config-ckeditor5": "^1.0.0" | ||
"@ckeditor/ckeditor5-alignment": "^19.0.1", | ||
"@ckeditor/ckeditor5-block-quote": "^19.0.1", | ||
"@ckeditor/ckeditor5-clipboard": "^19.0.1", | ||
"@ckeditor/ckeditor5-editor-classic": "^19.0.1", | ||
"@ckeditor/ckeditor5-engine": "^19.0.1", | ||
"@ckeditor/ckeditor5-horizontal-line": "^19.0.1", | ||
"@ckeditor/ckeditor5-image": "^19.0.1", | ||
"@ckeditor/ckeditor5-indent": "^19.0.1", | ||
"@ckeditor/ckeditor5-list": "^19.0.1", | ||
"@ckeditor/ckeditor5-media-embed": "^19.1.0", | ||
"@ckeditor/ckeditor5-paragraph": "^19.1.0", | ||
"@ckeditor/ckeditor5-typing": "^19.0.1", | ||
"@ckeditor/ckeditor5-undo": "^19.0.1", | ||
"@ckeditor/ckeditor5-utils": "^19.0.1", | ||
"json-diff": "^0.5.4", | ||
"lodash-es": "^4.17.10" | ||
}, | ||
@@ -51,3 +47,4 @@ "engines": { | ||
"type": "git", | ||
"url": "https://github.com/ckeditor/ckeditor5-table.git" | ||
"url": "https://github.com/ckeditor/ckeditor5.git", | ||
"directory": "packages/ckeditor5-table" | ||
}, | ||
@@ -58,24 +55,3 @@ "files": [ | ||
"theme" | ||
], | ||
"scripts": { | ||
"lint": "eslint --quiet '**/*.js'", | ||
"stylelint": "stylelint --quiet --allow-empty-input 'theme/**/*.css' 'docs/**/*.css'" | ||
}, | ||
"lint-staged": { | ||
"**/*.js": [ | ||
"eslint --quiet" | ||
], | ||
"**/*.css": [ | ||
"stylelint --quiet --allow-empty-input" | ||
] | ||
}, | ||
"eslintIgnore": [ | ||
"src/lib/**", | ||
"packages/**" | ||
], | ||
"husky": { | ||
"hooks": { | ||
"pre-commit": "lint-staged" | ||
} | ||
} | ||
] | ||
} |
@@ -5,5 +5,2 @@ CKEditor 5 table feature | ||
[![npm version](https://badge.fury.io/js/%40ckeditor%2Fckeditor5-table.svg)](https://www.npmjs.com/package/@ckeditor/ckeditor5-table) | ||
[![Build Status](https://travis-ci.org/ckeditor/ckeditor5-table.svg?branch=master)](https://travis-ci.org/ckeditor/ckeditor5-table) | ||
[![Coverage Status](https://coveralls.io/repos/github/ckeditor/ckeditor5-table/badge.svg?branch=master)](https://coveralls.io/github/ckeditor/ckeditor5-table?branch=master) | ||
<br> | ||
[![Dependency Status](https://david-dm.org/ckeditor/ckeditor5-table/status.svg)](https://david-dm.org/ckeditor/ckeditor5-table) | ||
@@ -10,0 +7,0 @@ [![devDependency Status](https://david-dm.org/ckeditor/ckeditor5-table/dev-status.svg)](https://david-dm.org/ckeditor/ckeditor5-table?type=dev) |
@@ -12,2 +12,3 @@ /** | ||
import { findAncestor } from './utils'; | ||
import { getColumnIndexes, getSelectionAffectedTableCells } from '../utils'; | ||
@@ -76,12 +77,10 @@ /** | ||
const referencePosition = insertBefore ? selection.getFirstPosition() : selection.getLastPosition(); | ||
const referenceRange = insertBefore ? selection.getFirstRange() : selection.getLastRange(); | ||
const affectedTableCells = getSelectionAffectedTableCells( selection ); | ||
const columnIndexes = getColumnIndexes( affectedTableCells ); | ||
const tableCell = referenceRange.getContainedElement() || findAncestor( 'tableCell', referencePosition ); | ||
const table = tableCell.parent.parent; | ||
const column = insertBefore ? columnIndexes.first : columnIndexes.last; | ||
const table = findAncestor( 'table', affectedTableCells[ 0 ] ); | ||
const { column } = tableUtils.getCellLocation( tableCell ); | ||
tableUtils.insertColumns( table, { columns: 1, at: insertBefore ? column : column + 1 } ); | ||
} | ||
} |
@@ -12,2 +12,3 @@ /** | ||
import { findAncestor } from './utils'; | ||
import { getRowIndexes, getSelectionAffectedTableCells } from '../utils'; | ||
@@ -75,13 +76,10 @@ /** | ||
const referencePosition = insertAbove ? selection.getFirstPosition() : selection.getLastPosition(); | ||
const referenceRange = insertAbove ? selection.getFirstRange() : selection.getLastRange(); | ||
const affectedTableCells = getSelectionAffectedTableCells( selection ); | ||
const rowIndexes = getRowIndexes( affectedTableCells ); | ||
const tableCell = referenceRange.getContainedElement() || findAncestor( 'tableCell', referencePosition ); | ||
const tableRow = tableCell.parent; | ||
const table = tableRow.parent; | ||
const row = insertAbove ? rowIndexes.first : rowIndexes.last; | ||
const table = findAncestor( 'table', affectedTableCells[ 0 ] ); | ||
const row = table.getChildIndex( tableRow ); | ||
tableUtils.insertRows( table, { rows: 1, at: this.order === 'below' ? row + 1 : row } ); | ||
tableUtils.insertRows( table, { at: insertAbove ? row : row + 1, copyStructureFromAbove: !insertAbove } ); | ||
} | ||
} |
@@ -12,6 +12,3 @@ /** | ||
import TableWalker from '../tablewalker'; | ||
import { | ||
updateNumericAttribute, | ||
isHeadingColumnCell | ||
} from './utils'; | ||
import { isHeadingColumnCell, findAncestor } from './utils'; | ||
import { getTableCellsContainingSelection } from '../utils'; | ||
@@ -87,2 +84,3 @@ | ||
const tableCell = getTableCellsContainingSelection( doc.selection )[ 0 ]; | ||
const cellToMerge = this.value; | ||
@@ -113,3 +111,6 @@ const direction = this.direction; | ||
if ( !removedTableCellRow.childCount ) { | ||
removeEmptyRow( removedTableCellRow, writer ); | ||
const tableUtils = this.editor.plugins.get( 'TableUtils' ); | ||
const table = findAncestor( 'table', removedTableCellRow ); | ||
tableUtils.removeRows( table, { at: removedTableCellRow.index, batch: writer.batch } ); | ||
} | ||
@@ -249,24 +250,4 @@ } ); | ||
// Properly removes an empty row from a table. It will update the `rowspan` attribute of cells that overlap the removed row. | ||
// | ||
// @param {module:engine/model/element~Element} removedTableCellRow | ||
// @param {module:engine/model/writer~Writer} writer | ||
function removeEmptyRow( removedTableCellRow, writer ) { | ||
const table = removedTableCellRow.parent; | ||
const removedRowIndex = table.getChildIndex( removedTableCellRow ); | ||
for ( const { cell, row, rowspan } of new TableWalker( table, { endRow: removedRowIndex } ) ) { | ||
const overlapsRemovedRow = row + rowspan - 1 >= removedRowIndex; | ||
if ( overlapsRemovedRow ) { | ||
updateNumericAttribute( 'rowspan', rowspan - 1, cell, writer ); | ||
} | ||
} | ||
writer.remove( removedTableCellRow ); | ||
} | ||
// Merges two table cells. It will ensure that after merging cells with an empty paragraph, the resulting table cell will only have one | ||
// paragraph. If one of the merged table cell is empty, the merged table cell will have the contents of the non-empty table cell. | ||
// paragraph. If one of the merged table cells is empty, the merged table cell will have the contents of the non-empty table cell. | ||
// If both are empty, the merged table cell will have only one empty paragraph. | ||
@@ -273,0 +254,0 @@ // |
@@ -11,6 +11,5 @@ /** | ||
import Command from '@ckeditor/ckeditor5-core/src/command'; | ||
import TableWalker from '../tablewalker'; | ||
import TableUtils from '../tableutils'; | ||
import { findAncestor, updateNumericAttribute } from './utils'; | ||
import TableUtils from '../tableutils'; | ||
import { getColumnIndexes, getRowIndexes, getSelectedTableCells } from '../utils'; | ||
import { isSelectionRectangular, getSelectedTableCells } from '../utils'; | ||
@@ -20,3 +19,3 @@ /** | ||
* | ||
* The command is registered by the {@link module:table/tableediting~TableEditing} as `'mergeTableCells'` editor command. | ||
* The command is registered by {@link module:table/tableediting~TableEditing} as the `'mergeTableCells'` editor command. | ||
* | ||
@@ -34,3 +33,4 @@ * For example, to merge selected table cells: | ||
refresh() { | ||
this.isEnabled = canMergeCells( this.editor.model.document.selection, this.editor.plugins.get( TableUtils ) ); | ||
const selectedTableCells = getSelectedTableCells( this.editor.model.document.selection ); | ||
this.isEnabled = isSelectionRectangular( selectedTableCells, this.editor.plugins.get( TableUtils ) ); | ||
} | ||
@@ -50,3 +50,3 @@ | ||
// All cells will be merge into the first one. | ||
// All cells will be merged into the first one. | ||
const firstTableCell = selectedTableCells.shift(); | ||
@@ -64,8 +64,20 @@ | ||
const emptyRowsIndexes = []; | ||
for ( const tableCell of selectedTableCells ) { | ||
const tableRow = tableCell.parent; | ||
mergeTableCells( tableCell, firstTableCell, writer ); | ||
removeRowIfEmpty( tableRow, writer ); | ||
if ( !tableRow.childCount ) { | ||
emptyRowsIndexes.push( tableRow.index ); | ||
} | ||
} | ||
if ( emptyRowsIndexes.length ) { | ||
const table = findAncestor( 'table', firstTableCell ); | ||
emptyRowsIndexes.reverse().forEach( row => tableUtils.removeRows( table, { at: row, batch: writer.batch } ) ); | ||
} | ||
writer.setSelection( firstTableCell, 'in' ); | ||
@@ -76,27 +88,4 @@ } ); | ||
// Properly removes the empty row from a table. Updates the `rowspan` attribute of cells that overlap the removed row. | ||
// | ||
// @param {module:engine/model/element~Element} row | ||
// @param {module:engine/model/writer~Writer} writer | ||
function removeRowIfEmpty( row, writer ) { | ||
if ( row.childCount ) { | ||
return; | ||
} | ||
const table = row.parent; | ||
const removedRowIndex = table.getChildIndex( row ); | ||
for ( const { cell, row, rowspan } of new TableWalker( table, { endRow: removedRowIndex } ) ) { | ||
const overlapsRemovedRow = row + rowspan - 1 >= removedRowIndex; | ||
if ( overlapsRemovedRow ) { | ||
updateNumericAttribute( 'rowspan', rowspan - 1, cell, writer ); | ||
} | ||
} | ||
writer.remove( row ); | ||
} | ||
// Merges two table cells - will ensure that after merging cells with empty paragraphs the result table cell will only have one paragraph. | ||
// If one of the merged table cells is empty, the merged table cell will have contents of the non-empty table cell. | ||
// Merges two table cells. It will ensure that after merging cells with empty paragraphs the resulting table cell will only have one | ||
// paragraph. If one of the merged table cells is empty, the merged table cell will have contents of the non-empty table cell. | ||
// If both are empty, the merged table cell will have only one empty paragraph. | ||
@@ -128,128 +117,2 @@ // | ||
// Checks if the selection contains mergeable cells. | ||
// | ||
// In a table below: | ||
// | ||
// ┌───┬───┬───┬───┐ | ||
// │ a │ b │ c │ d │ | ||
// ├───┴───┼───┤ │ | ||
// │ e │ f │ │ | ||
// ├ ├───┼───┤ | ||
// │ │ g │ h │ | ||
// └───────┴───┴───┘ | ||
// | ||
// Valid selections are these which create a solid rectangle (without gaps), such as: | ||
// - a, b (two horizontal cells) | ||
// - c, f (two vertical cells) | ||
// - a, b, e (cell "e" spans over four cells) | ||
// - c, d, f (cell d spans over a cell in the row below) | ||
// | ||
// While an invalid selection would be: | ||
// - a, c (cell "b" not selected creates a gap) | ||
// - f, g, h (cell "d" spans over a cell from row of "f" cell - thus creates a gap) | ||
// | ||
// @param {module:engine/model/selection~Selection} selection | ||
// @param {module:table/tableUtils~TableUtils} tableUtils | ||
// @returns {boolean} | ||
function canMergeCells( selection, tableUtils ) { | ||
const selectedTableCells = getSelectedTableCells( selection ); | ||
if ( selectedTableCells.length < 2 || !areCellInTheSameTableSection( selectedTableCells ) ) { | ||
return false; | ||
} | ||
// A valid selection is a fully occupied rectangle composed of table cells. | ||
// Below we will calculate the area of a selected table cells and the area of valid selection. | ||
// The area of a valid selection is defined by top-left and bottom-right cells. | ||
const rows = new Set(); | ||
const columns = new Set(); | ||
let areaOfSelectedCells = 0; | ||
for ( const tableCell of selectedTableCells ) { | ||
const { row, column } = tableUtils.getCellLocation( tableCell ); | ||
const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 ); | ||
const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 ); | ||
// Record row & column indexes of current cell. | ||
rows.add( row ); | ||
columns.add( column ); | ||
// For cells that spans over multiple rows add also the last row that this cell spans over. | ||
if ( rowspan > 1 ) { | ||
rows.add( row + rowspan - 1 ); | ||
} | ||
// For cells that spans over multiple columns add also the last column that this cell spans over. | ||
if ( colspan > 1 ) { | ||
columns.add( column + colspan - 1 ); | ||
} | ||
areaOfSelectedCells += ( rowspan * colspan ); | ||
} | ||
// We can only merge table cells that are in adjacent rows... | ||
const areaOfValidSelection = getBiggestRectangleArea( rows, columns ); | ||
return areaOfValidSelection == areaOfSelectedCells; | ||
} | ||
// Calculates the area of a maximum rectangle that can span over provided row & column indexes. | ||
// | ||
// @param {Array.<Number>} rows | ||
// @param {Array.<Number>} columns | ||
// @returns {Number} | ||
function getBiggestRectangleArea( rows, columns ) { | ||
const rowsIndexes = Array.from( rows.values() ); | ||
const columnIndexes = Array.from( columns.values() ); | ||
const lastRow = Math.max( ...rowsIndexes ); | ||
const firstRow = Math.min( ...rowsIndexes ); | ||
const lastColumn = Math.max( ...columnIndexes ); | ||
const firstColumn = Math.min( ...columnIndexes ); | ||
return ( lastRow - firstRow + 1 ) * ( lastColumn - firstColumn + 1 ); | ||
} | ||
// Checks if the selection does not mix header (column or row) with other cells. | ||
// | ||
// For instance, in the table below valid selections consist of cells with the same letter only. | ||
// So, a-a (same heading row and column) or d-d (body cells) are valid while c-d or a-b are not. | ||
// | ||
// header columns | ||
// ↓ ↓ | ||
// ┌───┬───┬───┬───┐ | ||
// │ a │ a │ b │ b │ ← header row | ||
// ├───┼───┼───┼───┤ | ||
// │ c │ c │ d │ d │ | ||
// ├───┼───┼───┼───┤ | ||
// │ c │ c │ d │ d │ | ||
// └───┴───┴───┴───┘ | ||
// | ||
function areCellInTheSameTableSection( tableCells ) { | ||
const table = findAncestor( 'table', tableCells[ 0 ] ); | ||
const rowIndexes = getRowIndexes( tableCells ); | ||
const headingRows = parseInt( table.getAttribute( 'headingRows' ) || 0 ); | ||
// Calculating row indexes is a bit cheaper so if this check fails we can't merge. | ||
if ( !areIndexesInSameSection( rowIndexes, headingRows ) ) { | ||
return false; | ||
} | ||
const headingColumns = parseInt( table.getAttribute( 'headingColumns' ) || 0 ); | ||
const columnIndexes = getColumnIndexes( tableCells ); | ||
// Similarly cells must be in same column section. | ||
return areIndexesInSameSection( columnIndexes, headingColumns ); | ||
} | ||
// Unified check if table rows/columns indexes are in the same heading/body section. | ||
function areIndexesInSameSection( { first, last }, headingSectionSize ) { | ||
const firstCellIsInHeading = first < headingSectionSize; | ||
const lastCellIsInHeading = last < headingSectionSize; | ||
return firstCellIsInHeading === lastCellIsInHeading; | ||
} | ||
function getMergeDimensions( firstTableCell, selectedTableCells, tableUtils ) { | ||
@@ -256,0 +119,0 @@ let maxWidthOffset = 0; |
@@ -13,3 +13,3 @@ /** | ||
import { findAncestor, isHeadingColumnCell, updateNumericAttribute } from './utils'; | ||
import { getColumnIndexes, getSelectionAffectedTableCells } from '../utils'; | ||
import { getColumnIndexes, getSelectionAffectedTableCells, getHorizontallyOverlappingCells, splitVertically } from '../utils'; | ||
@@ -73,9 +73,18 @@ /** | ||
const selectedCells = getSelectionAffectedTableCells( model.document.selection ); | ||
const table = findAncestor( 'table', selectedCells[ 0 ] ); | ||
const { first, last } = getColumnIndexes( selectedCells ); | ||
const headingColumnsToSet = this.value ? first : last + 1; | ||
model.change( writer => { | ||
const table = findAncestor( 'table', selectedCells[ 0 ] ); | ||
if ( headingColumnsToSet ) { | ||
// Changing heading columns requires to check if any of a heading cell is overlapping horizontally the table head. | ||
// Any table cell that has a colspan attribute > 1 will not exceed the table head so we need to fix it in columns before. | ||
const overlappingCells = getHorizontallyOverlappingCells( table, headingColumnsToSet ); | ||
for ( const { cell, column } of overlappingCells ) { | ||
splitVertically( cell, column, headingColumnsToSet, writer ); | ||
} | ||
} | ||
updateNumericAttribute( 'headingColumns', headingColumnsToSet, table, writer, 0 ); | ||
@@ -82,0 +91,0 @@ } ); |
@@ -12,5 +12,4 @@ /** | ||
import { createEmptyTableCell, findAncestor, updateNumericAttribute } from './utils'; | ||
import { getRowIndexes, getSelectionAffectedTableCells } from '../utils'; | ||
import TableWalker from '../tablewalker'; | ||
import { findAncestor, updateNumericAttribute } from './utils'; | ||
import { getVerticallyOverlappingCells, getRowIndexes, getSelectionAffectedTableCells, splitHorizontally } from '../utils'; | ||
@@ -81,5 +80,6 @@ /** | ||
// Any table cell that has a rowspan attribute > 1 will not exceed the table head so we need to fix it in rows below. | ||
const cellsToSplit = getOverlappingCells( table, headingRowsToSet, currentHeadingRows ); | ||
const startRow = headingRowsToSet > currentHeadingRows ? currentHeadingRows : 0; | ||
const overlappingCells = getVerticallyOverlappingCells( table, headingRowsToSet, startRow ); | ||
for ( const cell of cellsToSplit ) { | ||
for ( const { cell } of overlappingCells ) { | ||
splitHorizontally( cell, headingRowsToSet, writer ); | ||
@@ -107,75 +107,1 @@ } | ||
} | ||
// Returns cells that span beyond the new heading section. | ||
// | ||
// @param {module:engine/model/element~Element} table The table to check. | ||
// @param {Number} headingRowsToSet New heading rows attribute. | ||
// @param {Number} currentHeadingRows Current heading rows attribute. | ||
// @returns {Array.<module:engine/model/element~Element>} | ||
function getOverlappingCells( table, headingRowsToSet, currentHeadingRows ) { | ||
const cellsToSplit = []; | ||
const startAnalysisRow = headingRowsToSet > currentHeadingRows ? currentHeadingRows : 0; | ||
// We're analyzing only when headingRowsToSet > 0. | ||
const endAnalysisRow = headingRowsToSet - 1; | ||
const tableWalker = new TableWalker( table, { startRow: startAnalysisRow, endRow: endAnalysisRow } ); | ||
for ( const { row, rowspan, cell } of tableWalker ) { | ||
if ( rowspan > 1 && row + rowspan > headingRowsToSet ) { | ||
cellsToSplit.push( cell ); | ||
} | ||
} | ||
return cellsToSplit; | ||
} | ||
// Splits the table cell horizontally. | ||
// | ||
// @param {module:engine/model/element~Element} tableCell | ||
// @param {Number} headingRows | ||
// @param {module:engine/model/writer~Writer} writer | ||
function splitHorizontally( tableCell, headingRows, writer ) { | ||
const tableRow = tableCell.parent; | ||
const table = tableRow.parent; | ||
const rowIndex = tableRow.index; | ||
const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) ); | ||
const newRowspan = headingRows - rowIndex; | ||
const attributes = {}; | ||
const spanToSet = rowspan - newRowspan; | ||
if ( spanToSet > 1 ) { | ||
attributes.rowspan = spanToSet; | ||
} | ||
const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 ); | ||
if ( colspan > 1 ) { | ||
attributes.colspan = colspan; | ||
} | ||
const startRow = table.getChildIndex( tableRow ); | ||
const endRow = startRow + newRowspan; | ||
const tableMap = [ ...new TableWalker( table, { startRow, endRow, includeSpanned: true } ) ]; | ||
let columnIndex; | ||
for ( const { row, column, cell, cellIndex } of tableMap ) { | ||
if ( cell === tableCell && columnIndex === undefined ) { | ||
columnIndex = column; | ||
} | ||
if ( columnIndex !== undefined && columnIndex === column && row === endRow ) { | ||
const tableRow = table.getChild( row ); | ||
const tableCellPosition = writer.createPositionAt( tableRow, cellIndex ); | ||
createEmptyTableCell( writer, tableCellPosition, attributes ); | ||
} | ||
} | ||
// Update the rowspan attribute after updating table. | ||
updateNumericAttribute( 'rowspan', newRowspan, tableCell, writer ); | ||
} |
@@ -103,2 +103,4 @@ /** | ||
if ( tableCell.childCount == 0 ) { | ||
// @if CK_DEBUG_TABLE // console.log( 'Post-fixing table: insert paragraph in empty cell.' ); | ||
writer.insertElement( 'paragraph', tableCell ); | ||
@@ -113,2 +115,4 @@ | ||
// @if CK_DEBUG_TABLE // textNodes.length && console.log( 'Post-fixing table: wrap cell content with paragraph.' ); | ||
for ( const child of textNodes ) { | ||
@@ -115,0 +119,0 @@ writer.wrap( writer.createRangeOn( child ), 'paragraph' ); |
@@ -31,6 +31,17 @@ /** | ||
// Counting the paragraph inserts to verify if it increased to more than one paragraph in the current differ. | ||
let insertCount = 0; | ||
for ( const change of differ.getChanges() ) { | ||
const parent = change.type == 'insert' || change.type == 'remove' ? change.position.parent : change.range.start.parent; | ||
if ( parent.is( 'tableCell' ) && checkRefresh( parent, change.type ) ) { | ||
if ( !parent.is( 'tableCell' ) ) { | ||
continue; | ||
} | ||
if ( change.type == 'insert' ) { | ||
insertCount++; | ||
} | ||
if ( checkRefresh( parent, change.type, insertCount ) ) { | ||
cellsToRefresh.add( parent ); | ||
@@ -41,2 +52,4 @@ } | ||
if ( cellsToRefresh.size ) { | ||
// @if CK_DEBUG_TABLE // console.log( `Post-fixing table: refreshing cells (${ cellsToRefresh.size }).` ); | ||
for ( const tableCell of cellsToRefresh.values() ) { | ||
@@ -65,3 +78,4 @@ differ.refreshItem( tableCell ); | ||
// @param {String} type Type of change. | ||
function checkRefresh( tableCell, type ) { | ||
// @param {Number} insertCount The number of inserts in differ. | ||
function checkRefresh( tableCell, type, insertCount ) { | ||
const hasInnerParagraph = Array.from( tableCell.getChildren() ).some( child => child.is( 'paragraph' ) ); | ||
@@ -89,3 +103,5 @@ | ||
// - another element is removed and a single paragraph is left (childCount == 1) | ||
return tableCell.childCount <= ( type == 'insert' ? 2 : 1 ); | ||
// | ||
// Change is not needed if there were multiple blocks before change. | ||
return tableCell.childCount <= ( type == 'insert' ? insertCount + 1 : 1 ); | ||
} |
@@ -274,2 +274,4 @@ /** | ||
if ( cellsToTrim.length ) { | ||
// @if CK_DEBUG_TABLE // console.log( `Post-fixing table: trimming cells row-spans (${ cellsToTrim.length }).` ); | ||
wasFixed = true; | ||
@@ -294,10 +296,34 @@ | ||
const rowsLengths = getRowsLengths( table ); | ||
const rowsToRemove = []; | ||
// Find empty rows. | ||
for ( const [ rowIndex, size ] of rowsLengths.entries() ) { | ||
if ( !size ) { | ||
rowsToRemove.push( rowIndex ); | ||
} | ||
} | ||
// Remove empty rows. | ||
if ( rowsToRemove.length ) { | ||
// @if CK_DEBUG_TABLE // console.log( `Post-fixing table: remove empty rows (${ rowsToRemove.length }).` ); | ||
wasFixed = true; | ||
for ( const rowIndex of rowsToRemove.reverse() ) { | ||
writer.remove( table.getChild( rowIndex ) ); | ||
rowsLengths.splice( rowIndex, 1 ); | ||
} | ||
} | ||
// Verify if all the rows have the same number of columns. | ||
const tableSize = rowsLengths[ 0 ]; | ||
const isValid = rowsLengths.every( length => length === tableSize ); | ||
const isValid = Object.values( rowsLengths ).every( length => length === tableSize ); | ||
if ( !isValid ) { | ||
const maxColumns = Object.values( rowsLengths ).reduce( ( prev, current ) => current > prev ? current : prev, 0 ); | ||
// @if CK_DEBUG_TABLE // console.log( 'Post-fixing table: adding missing cells.' ); | ||
for ( const [ rowIndex, size ] of Object.entries( rowsLengths ) ) { | ||
// Find the maximum number of columns. | ||
const maxColumns = rowsLengths.reduce( ( prev, current ) => current > prev ? current : prev, 0 ); | ||
for ( const [ rowIndex, size ] of rowsLengths.entries() ) { | ||
const columnsToInsert = maxColumns - size; | ||
@@ -351,15 +377,12 @@ | ||
// Returns an object with lengths of rows assigned to the corresponding row index. | ||
// Returns an array with lengths of rows assigned to the corresponding row index. | ||
// | ||
// @param {module:engine/model/element~Element} table | ||
// @returns {Object} | ||
// @returns {Array.<Number>} | ||
function getRowsLengths( table ) { | ||
const lengths = {}; | ||
// TableWalker will not provide items for the empty rows, we need to pre-fill this array. | ||
const lengths = new Array( table.childCount ).fill( 0 ); | ||
for ( const { row } of new TableWalker( table, { includeSpanned: true } ) ) { | ||
if ( !lengths[ row ] ) { | ||
lengths[ row ] = 0; | ||
} | ||
lengths[ row ] += 1; | ||
lengths[ row ]++; | ||
} | ||
@@ -366,0 +389,0 @@ |
@@ -55,7 +55,7 @@ /** | ||
if ( rows.length ) { | ||
// Upcast table rows in proper order (heading rows first). | ||
rows.forEach( row => conversionApi.convertItem( row, conversionApi.writer.createPositionAt( table, 'end' ) ) ); | ||
} else { | ||
// Create one row and one table cell for empty table. | ||
// Upcast table rows in proper order (heading rows first). | ||
rows.forEach( row => conversionApi.convertItem( row, conversionApi.writer.createPositionAt( table, 'end' ) ) ); | ||
// Create one row and one table cell for empty table. | ||
if ( table.isEmpty ) { | ||
const row = conversionApi.writer.createElement( 'tableRow' ); | ||
@@ -94,2 +94,19 @@ conversionApi.writer.insert( row, conversionApi.writer.createPositionAt( table, 'end' ) ); | ||
/** | ||
* Conversion helper that skips empty <tr> from upcasting. | ||
* | ||
* Empty row is considered a table model error. | ||
* | ||
* @returns {Function} Conversion helper. | ||
*/ | ||
export function skipEmptyTableRow() { | ||
return dispatcher => { | ||
dispatcher.on( 'element:tr', ( evt, data ) => { | ||
if ( data.viewItem.isEmpty ) { | ||
evt.stop(); | ||
} | ||
}, { priority: 'high' } ); | ||
}; | ||
} | ||
export function upcastTableCell( elementName ) { | ||
@@ -96,0 +113,0 @@ return dispatcher => { |
@@ -35,2 +35,15 @@ /** | ||
// Map of view properties and related commands. | ||
const propertyToCommandMap = { | ||
borderStyle: 'tableCellBorderStyle', | ||
borderColor: 'tableCellBorderColor', | ||
borderWidth: 'tableCellBorderWidth', | ||
width: 'tableCellWidth', | ||
height: 'tableCellHeight', | ||
padding: 'tableCellPadding', | ||
backgroundColor: 'tableCellBackgroundColor', | ||
horizontalAlignment: 'tableCellHorizontalAlignment', | ||
verticalAlignment: 'tableCellVerticalAlignment' | ||
}; | ||
/** | ||
@@ -114,2 +127,9 @@ * The table cell properties UI plugin. It introduces the `'tableCellProperties'` button | ||
const commands = Object.values( propertyToCommandMap ) | ||
.map( commandName => editor.commands.get( commandName ) ); | ||
view.bind( 'isEnabled' ).toMany( commands, 'isEnabled', ( ...areEnabled ) => ( | ||
areEnabled.some( isCommandEnabled => isCommandEnabled ) | ||
) ); | ||
return view; | ||
@@ -261,13 +281,5 @@ } ); | ||
this.view.set( { | ||
borderStyle: commands.get( 'tableCellBorderStyle' ).value || '', | ||
borderColor: commands.get( 'tableCellBorderColor' ).value || '', | ||
borderWidth: commands.get( 'tableCellBorderWidth' ).value || '', | ||
width: commands.get( 'tableCellWidth' ).value || '', | ||
height: commands.get( 'tableCellHeight' ).value || '', | ||
padding: commands.get( 'tableCellPadding' ).value || '', | ||
backgroundColor: commands.get( 'tableCellBackgroundColor' ).value || '', | ||
horizontalAlignment: commands.get( 'tableCellHorizontalAlignment' ).value || '', | ||
verticalAlignment: commands.get( 'tableCellVerticalAlignment' ).value || '' | ||
} ); | ||
Object.entries( propertyToCommandMap ) | ||
.map( ( [ property, commandName ] ) => [ property, commands.get( commandName ).value || '' ] ) | ||
.forEach( ( [ property, value ] ) => this.view.set( property, value ) ); | ||
} | ||
@@ -274,0 +286,0 @@ |
@@ -11,3 +11,18 @@ /** | ||
import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; | ||
import TableSelection from './tableselection'; | ||
import TableWalker from './tablewalker'; | ||
import { | ||
getColumnIndexes, | ||
getVerticallyOverlappingCells, | ||
getRowIndexes, | ||
getSelectionAffectedTableCells, | ||
getHorizontallyOverlappingCells, | ||
isSelectionRectangular, | ||
splitHorizontally, | ||
splitVertically | ||
} from './utils'; | ||
import { findAncestor } from './commands/utils'; | ||
import { cropTableToDimensions, trimTableCellIfNeeded } from './tableselection/croptable'; | ||
import TableUtils from './tableutils'; | ||
@@ -32,3 +47,3 @@ /** | ||
static get requires() { | ||
return [ TableSelection ]; | ||
return [ TableSelection, TableUtils ]; | ||
} | ||
@@ -45,2 +60,3 @@ | ||
this.listenTo( viewDocument, 'cut', ( evt, data ) => this._onCopyCut( evt, data ) ); | ||
this.listenTo( editor.model, 'insertContent', ( evt, args ) => this._onInsertContent( evt, ...args ), { priority: 'high' } ); | ||
} | ||
@@ -56,3 +72,3 @@ | ||
_onCopyCut( evt, data ) { | ||
const tableSelection = this.editor.plugins.get( 'TableSelection' ); | ||
const tableSelection = this.editor.plugins.get( TableSelection ); | ||
@@ -81,2 +97,479 @@ if ( !tableSelection.getSelectedTableCells() ) { | ||
} | ||
/** | ||
* Overrides default {@link module:engine/model/model~Model#insertContent `model.insertContent()`} method to handle pasting table inside | ||
* selected table fragment. | ||
* | ||
* Depending on selected table fragment: | ||
* - If a selected table fragment is smaller than paste table it will crop pasted table to match dimensions. | ||
* - If dimensions are equal it will replace selected table fragment with a pasted table contents. | ||
* | ||
* @private | ||
* @param evt | ||
* @param {module:engine/model/documentfragment~DocumentFragment|module:engine/model/item~Item} content The content to insert. | ||
* @param {module:engine/model/selection~Selectable} [selectable=model.document.selection] | ||
* The selection into which the content should be inserted. If not provided the current model document selection will be used. | ||
*/ | ||
_onInsertContent( evt, content, selectable ) { | ||
if ( selectable && !selectable.is( 'documentSelection' ) ) { | ||
return; | ||
} | ||
const model = this.editor.model; | ||
const tableUtils = this.editor.plugins.get( TableUtils ); | ||
const selectedTableCells = getSelectionAffectedTableCells( model.document.selection ); | ||
if ( !selectedTableCells.length ) { | ||
return; | ||
} | ||
// We might need to crop table before inserting so reference might change. | ||
let pastedTable = getTableIfOnlyTableInContent( content ); | ||
if ( !pastedTable ) { | ||
return; | ||
} | ||
// Override default model.insertContent() handling at this point. | ||
evt.stop(); | ||
model.change( writer => { | ||
const columnIndexes = getColumnIndexes( selectedTableCells ); | ||
const rowIndexes = getRowIndexes( selectedTableCells ); | ||
let { first: firstColumnOfSelection, last: lastColumnOfSelection } = columnIndexes; | ||
let { first: firstRowOfSelection, last: lastRowOfSelection } = rowIndexes; | ||
const pasteHeight = tableUtils.getRows( pastedTable ); | ||
const pasteWidth = tableUtils.getColumns( pastedTable ); | ||
// Content table to which we insert a pasted table. | ||
const selectedTable = findAncestor( 'table', selectedTableCells[ 0 ] ); | ||
// Single cell selected - expand selection to pasted table dimensions. | ||
const shouldExpandSelection = selectedTableCells.length === 1; | ||
if ( shouldExpandSelection ) { | ||
lastRowOfSelection += pasteHeight - 1; | ||
lastColumnOfSelection += pasteWidth - 1; | ||
expandTableSize( selectedTable, lastRowOfSelection + 1, lastColumnOfSelection + 1, writer, tableUtils ); | ||
} | ||
// In case of expanding selection we do not reset the selection so in this case we will always try to fix selection | ||
// like in the case of a non-rectangular area. This might be fixed by re-setting selected cells array but this shortcut is safe. | ||
if ( shouldExpandSelection || !isSelectionRectangular( selectedTableCells, tableUtils ) ) { | ||
const splitDimensions = { | ||
firstRow: firstRowOfSelection, | ||
lastRow: lastRowOfSelection, | ||
firstColumn: firstColumnOfSelection, | ||
lastColumn: lastColumnOfSelection | ||
}; | ||
// For a non-rectangular selection (ie in which some cells sticks out from a virtual selection rectangle) we need to create | ||
// a table layout that has a rectangular selection. This will split cells so the selection become rectangular. | ||
// Beyond this point we will operate on fixed content table. | ||
splitCellsToRectangularSelection( selectedTable, splitDimensions, writer ); | ||
} | ||
// However a selected table fragment might be invalid if examined alone. Ie such table fragment: | ||
// | ||
// +---+---+---+---+ | ||
// 0 | a | b | c | d | | ||
// + + +---+---+ | ||
// 1 | | e | f | g | | ||
// + +---+ +---+ | ||
// 2 | | h | | i | <- last row, each cell has rowspan = 2, | ||
// + + + + + so we need to return 3, not 2 | ||
// 3 | | | | | | ||
// +---+---+---+---+ | ||
// | ||
// is invalid as the cells "h" and "i" have rowspans. | ||
// This case needs only adjusting the selection dimension as the rest of the algorithm operates on empty slots also. | ||
else { | ||
lastRowOfSelection = adjustLastRowIndex( selectedTable, rowIndexes, columnIndexes ); | ||
lastColumnOfSelection = adjustLastColumnIndex( selectedTable, rowIndexes, columnIndexes ); | ||
} | ||
// Beyond this point we operate on a fixed content table with rectangular selection and proper last row/column values. | ||
const selectionHeight = lastRowOfSelection - firstRowOfSelection + 1; | ||
const selectionWidth = lastColumnOfSelection - firstColumnOfSelection + 1; | ||
// Crop pasted table if: | ||
// - Pasted table dimensions exceeds selection area. | ||
// - Pasted table has broken layout (ie some cells sticks out by the table dimensions established by the first and last row). | ||
// | ||
// Note: The table dimensions are established by the width of the first row and the total number of rows. | ||
// It is possible to programmatically create a table that has rows which would have cells anchored beyond first row width but | ||
// such table will not be created by other editing solutions. | ||
const cropDimensions = { | ||
startRow: 0, | ||
startColumn: 0, | ||
endRow: Math.min( selectionHeight - 1, pasteHeight - 1 ), | ||
endColumn: Math.min( selectionWidth - 1, pasteWidth - 1 ) | ||
}; | ||
pastedTable = cropTableToDimensions( pastedTable, cropDimensions, writer, tableUtils ); | ||
const pastedDimensions = { | ||
width: pasteWidth, | ||
height: pasteHeight | ||
}; | ||
const selectionDimensions = { | ||
firstColumnOfSelection, | ||
firstRowOfSelection, | ||
lastColumnOfSelection, | ||
lastRowOfSelection | ||
}; | ||
replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selectedTable, selectionDimensions, writer ); | ||
} ); | ||
} | ||
} | ||
// Replaces the part of selectedTable with pastedTable. | ||
// | ||
// @param {module:engine/model/element~Element} pastedTable | ||
// @param {Object} pastedDimensions | ||
// @param {Number} pastedDimensions.height | ||
// @param {Number} pastedDimensions.width | ||
// @param {module:engine/model/element~Element} selectedTable | ||
// @param {Object} selectionDimensions | ||
// @param {Number} selectionDimensions.firstColumnOfSelection | ||
// @param {Number} selectionDimensions.firstRowOfSelection | ||
// @param {Number} selectionDimensions.lastColumnOfSelection | ||
// @param {Number} selectionDimensions.lastRowOfSelection | ||
// @param {module:engine/model/writer~Writer} writer | ||
function replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selectedTable, selectionDimensions, writer ) { | ||
const { | ||
firstColumnOfSelection, lastColumnOfSelection, | ||
firstRowOfSelection, lastRowOfSelection | ||
} = selectionDimensions; | ||
const { width: pastedWidth, height: pastedHeight } = pastedDimensions; | ||
// Holds two-dimensional array that is addressed by [ row ][ column ] that stores cells anchored at given location. | ||
const pastedTableLocationMap = createLocationMap( pastedTable, pastedWidth, pastedHeight ); | ||
const selectedTableMap = [ ...new TableWalker( selectedTable, { | ||
startRow: firstRowOfSelection, | ||
endRow: lastRowOfSelection, | ||
includeSpanned: true | ||
} ) ]; | ||
// Selection must be set to pasted cells (some might be removed or new created). | ||
const cellsToSelect = []; | ||
// Store previous cell in order to insert a new table cells after it (if required). | ||
let previousCellInRow; | ||
// Content table replace cells algorithm iterates over a selected table fragment and: | ||
// | ||
// - Removes existing table cells at current slot (location). | ||
// - Inserts cell from a pasted table for a matched slots. | ||
// | ||
// This ensures proper table geometry after the paste | ||
for ( const { row, column, cell, isSpanned } of selectedTableMap ) { | ||
if ( column === 0 ) { | ||
previousCellInRow = null; | ||
} | ||
// Could use startColumn, endColumn. See: https://github.com/ckeditor/ckeditor5/issues/6785. | ||
if ( column < firstColumnOfSelection || column > lastColumnOfSelection ) { | ||
// Only update the previousCellInRow for non-spanned slots. | ||
if ( !isSpanned ) { | ||
previousCellInRow = cell; | ||
} | ||
continue; | ||
} | ||
// If the slot is occupied by a cell in a selected table - remove it. | ||
// The slot of this cell will be either: | ||
// - Replaced by a pasted table cell. | ||
// - Spanned by a previously pasted table cell. | ||
if ( !isSpanned ) { | ||
writer.remove( cell ); | ||
} | ||
// Map current table slot location to an pasted table slot location. | ||
const pastedRow = row - firstRowOfSelection; | ||
const pastedColumn = column - firstColumnOfSelection; | ||
const pastedCell = pastedTableLocationMap[ pastedRow % pastedHeight ][ pastedColumn % pastedWidth ]; | ||
// There is no cell to insert (might be spanned by other cell in a pasted table) - advance to the next content table slot. | ||
if ( !pastedCell ) { | ||
continue; | ||
} | ||
// Clone cell to insert (to duplicate its attributes and children). | ||
// Cloning is required to support repeating pasted table content when inserting to a bigger selection. | ||
const cellToInsert = pastedCell._clone( true ); | ||
// Trim the cell if it's row/col-spans would exceed selection area. | ||
trimTableCellIfNeeded( cellToInsert, row, column, lastRowOfSelection, lastColumnOfSelection, writer ); | ||
let insertPosition; | ||
if ( !previousCellInRow ) { | ||
insertPosition = writer.createPositionAt( selectedTable.getChild( row ), 0 ); | ||
} else { | ||
insertPosition = writer.createPositionAfter( previousCellInRow ); | ||
} | ||
writer.insert( cellToInsert, insertPosition ); | ||
cellsToSelect.push( cellToInsert ); | ||
previousCellInRow = cellToInsert; | ||
} | ||
writer.setSelection( cellsToSelect.map( cell => writer.createRangeOn( cell ) ) ); | ||
} | ||
// Expand table (in place) to expected size. | ||
function expandTableSize( table, expectedHeight, expectedWidth, writer, tableUtils ) { | ||
const tableWidth = tableUtils.getColumns( table ); | ||
const tableHeight = tableUtils.getRows( table ); | ||
if ( expectedWidth > tableWidth ) { | ||
tableUtils.insertColumns( table, { | ||
batch: writer.batch, | ||
at: tableWidth, | ||
columns: expectedWidth - tableWidth | ||
} ); | ||
} | ||
if ( expectedHeight > tableHeight ) { | ||
tableUtils.insertRows( table, { | ||
batch: writer.batch, | ||
at: tableHeight, | ||
rows: expectedHeight - tableHeight | ||
} ); | ||
} | ||
} | ||
function getTableIfOnlyTableInContent( content ) { | ||
// Table passed directly. | ||
if ( content.is( 'table' ) ) { | ||
return content; | ||
} | ||
// We do not support mixed content when pasting table into table. | ||
// See: https://github.com/ckeditor/ckeditor5/issues/6817. | ||
if ( content.childCount != 1 || !content.getChild( 0 ).is( 'table' ) ) { | ||
return null; | ||
} | ||
return content.getChild( 0 ); | ||
} | ||
// Returns two-dimensional array that is addressed by [ row ][ column ] that stores cells anchored at given location. | ||
// | ||
// At given row & column location it might be one of: | ||
// | ||
// * cell - cell from pasted table anchored at this location. | ||
// * null - if no cell is anchored at this location. | ||
// | ||
// For instance, from a table below: | ||
// | ||
// +----+----+----+----+ | ||
// | 00 | 01 | 02 | 03 | | ||
// + +----+----+----+ | ||
// | | 11 | 13 | | ||
// +----+ +----+ | ||
// | 20 | | 23 | | ||
// +----+----+----+----+ | ||
// | ||
// The method will return an array (numbers represents cell element): | ||
// | ||
// const map = [ | ||
// [ '00', '01', '02', '03' ], | ||
// [ null, '11', null, '13' ], | ||
// [ '20', null, null, '23' ] | ||
// ] | ||
// | ||
// This allows for a quick access to table at give row & column. For instance to access table cell "13" from pasted table call: | ||
// | ||
// const cell = map[ 1 ][ 3 ] | ||
// | ||
function createLocationMap( table, width, height ) { | ||
// Create height x width (row x column) two-dimensional table to store cells. | ||
const map = new Array( height ).fill( null ) | ||
.map( () => new Array( width ).fill( null ) ); | ||
for ( const { column, row, cell } of new TableWalker( table ) ) { | ||
map[ row ][ column ] = cell; | ||
} | ||
return map; | ||
} | ||
// Make selected cells rectangular by splitting the cells that stand out from a rectangular selection. | ||
// | ||
// In the table below a selection is shown with "::" and slots with anchor cells are named. | ||
// | ||
// +----+----+----+----+----+ +----+----+----+----+----+ | ||
// | 00 | 01 | 02 | 03 | | 00 | 01 | 02 | 03 | | ||
// + +----+ +----+----+ | ::::::::::::::::----+ | ||
// | | 11 | | 13 | 14 | | ::11 | | 13:: 14 | <- first row | ||
// +----+----+ + +----+ +----::---| | ::----+ | ||
// | 20 | 21 | | | 24 | select cells: | 20 ::21 | | :: 24 | | ||
// +----+----+ +----+----+ 11 -> 33 +----::---| |---::----+ | ||
// | 30 | | 33 | 34 | | 30 :: | | 33:: 34 | <- last row | ||
// + + +----+ + | :::::::::::::::: + | ||
// | | | 43 | | | | | 43 | | | ||
// +----+----+----+----+----+ +----+----+----+----+----+ | ||
// ^ ^ | ||
// first & last columns | ||
// | ||
// Will update table to: | ||
// | ||
// +----+----+----+----+----+ | ||
// | 00 | 01 | 02 | 03 | | ||
// + +----+----+----+----+ | ||
// | | 11 | | 13 | 14 | | ||
// +----+----+ + +----+ | ||
// | 20 | 21 | | | 24 | | ||
// +----+----+ +----+----+ | ||
// | 30 | | | 33 | 34 | | ||
// + +----+----+----+ + | ||
// | | | | 43 | | | ||
// +----+----+----+----+----+ | ||
// | ||
// In th example above: | ||
// - Cell "02" which have `rowspan = 4` must be trimmed at first and at after last row. | ||
// - Cell "03" which have `rowspan = 2` and `colspan = 2` must be trimmed at first column and after last row. | ||
// - Cells "00", "03" & "30" which cannot be cut by this algorithm as they are outside the trimmed area. | ||
// - Cell "13" cannot be cut as it is inside the trimmed area. | ||
function splitCellsToRectangularSelection( table, dimensions, writer ) { | ||
const { firstRow, lastRow, firstColumn, lastColumn } = dimensions; | ||
const rowIndexes = { first: firstRow, last: lastRow }; | ||
const columnIndexes = { first: firstColumn, last: lastColumn }; | ||
// 1. Split cells vertically in two steps as first step might create cells that needs to split again. | ||
doVerticalSplit( table, firstColumn, rowIndexes, writer ); | ||
doVerticalSplit( table, lastColumn + 1, rowIndexes, writer ); | ||
// 2. Split cells horizontally in two steps as first step might create cells that needs to split again. | ||
doHorizontalSplit( table, firstRow, columnIndexes, writer ); | ||
doHorizontalSplit( table, lastRow + 1, columnIndexes, writer, firstRow ); | ||
} | ||
function doHorizontalSplit( table, splitRow, limitColumns, writer, startRow = 0 ) { | ||
// If selection starts at first row then no split is needed. | ||
if ( splitRow < 1 ) { | ||
return; | ||
} | ||
const overlappingCells = getVerticallyOverlappingCells( table, splitRow, startRow ); | ||
// Filter out cells that are not touching insides of the rectangular selection. | ||
const cellsToSplit = overlappingCells.filter( ( { column, colspan } ) => isAffectedBySelection( column, colspan, limitColumns ) ); | ||
for ( const { cell } of cellsToSplit ) { | ||
splitHorizontally( cell, splitRow, writer ); | ||
} | ||
} | ||
function doVerticalSplit( table, splitColumn, limitRows, writer ) { | ||
// If selection starts at first column then no split is needed. | ||
if ( splitColumn < 1 ) { | ||
return; | ||
} | ||
const overlappingCells = getHorizontallyOverlappingCells( table, splitColumn ); | ||
// Filter out cells that are not touching insides of the rectangular selection. | ||
const cellsToSplit = overlappingCells.filter( ( { row, rowspan } ) => isAffectedBySelection( row, rowspan, limitRows ) ); | ||
for ( const { cell, column } of cellsToSplit ) { | ||
splitVertically( cell, column, splitColumn, writer ); | ||
} | ||
} | ||
// Checks if cell at given row (column) is affected by a rectangular selection defined by first/last column (row). | ||
// | ||
// The same check is used for row as for column. | ||
function isAffectedBySelection( index, span, limit ) { | ||
const endIndex = index + span - 1; | ||
const { first, last } = limit; | ||
const isInsideSelection = index >= first && index <= last; | ||
const overlapsSelectionFromOutside = index < first && endIndex >= first; | ||
return isInsideSelection || overlapsSelectionFromOutside; | ||
} | ||
// Returns adjusted last row index if selection covers part of a row with empty slots (spanned by other cells). | ||
// The rowIndexes.last is equal to last row index but selection might be bigger. | ||
// | ||
// This happens *only* on rectangular selection so we analyze a case like this: | ||
// | ||
// +---+---+---+---+ | ||
// 0 | a | b | c | d | | ||
// + + +---+---+ | ||
// 1 | | e | f | g | | ||
// + +---+ +---+ | ||
// 2 | | h | | i | <- last row, each cell has rowspan = 2, | ||
// + + + + + so we need to return 3, not 2 | ||
// 3 | | | | | | ||
// +---+---+---+---+ | ||
function adjustLastRowIndex( table, rowIndexes, columnIndexes ) { | ||
const tableIterator = new TableWalker( table, { | ||
startRow: rowIndexes.last, | ||
endRow: rowIndexes.last | ||
} ); | ||
const lastRowMap = Array.from( tableIterator ).filter( ( { column } ) => { | ||
// Could use startColumn, endColumn. See: https://github.com/ckeditor/ckeditor5/issues/6785. | ||
return columnIndexes.first <= column && column <= columnIndexes.last; | ||
} ); | ||
const everyCellHasSingleRowspan = lastRowMap.every( ( { rowspan } ) => rowspan === 1 ); | ||
// It is a "flat" row, so the last row index is OK. | ||
if ( everyCellHasSingleRowspan ) { | ||
return rowIndexes.last; | ||
} | ||
// Otherwise get any cell's rowspan and adjust the last row index. | ||
const rowspanAdjustment = lastRowMap[ 0 ].rowspan - 1; | ||
return rowIndexes.last + rowspanAdjustment; | ||
} | ||
// Returns adjusted last column index if selection covers part of a column with empty slots (spanned by other cells). | ||
// The columnIndexes.last is equal to last column index but selection might be bigger. | ||
// | ||
// This happens *only* on rectangular selection so we analyze a case like this: | ||
// | ||
// 0 1 2 3 | ||
// +---+---+---+---+ | ||
// | a | | ||
// +---+---+---+---+ | ||
// | b | c | d | | ||
// +---+---+---+---+ | ||
// | e | f | | ||
// +---+---+---+---+ | ||
// | g | h | | ||
// +---+---+---+---+ | ||
// ^ | ||
// last column, each cell has colspan = 2, so we need to return 3, not 2 | ||
function adjustLastColumnIndex( table, rowIndexes, columnIndexes ) { | ||
const lastColumnMap = Array.from( new TableWalker( table, { | ||
startRow: rowIndexes.first, | ||
endRow: rowIndexes.last, | ||
column: columnIndexes.last | ||
} ) ); | ||
const everyCellHasSingleColspan = lastColumnMap.every( ( { colspan } ) => colspan === 1 ); | ||
// It is a "flat" column, so the last column index is OK. | ||
if ( everyCellHasSingleColspan ) { | ||
return columnIndexes.last; | ||
} | ||
// Otherwise get any cell's colspan and adjust the last column index. | ||
const colspanAdjustment = lastColumnMap[ 0 ].colspan - 1; | ||
return columnIndexes.last + colspanAdjustment; | ||
} |
@@ -12,3 +12,3 @@ /** | ||
import upcastTable, { upcastTableCell } from './converters/upcasttable'; | ||
import upcastTable, { upcastTableCell, skipEmptyTableRow } from './converters/upcasttable'; | ||
import { | ||
@@ -102,2 +102,3 @@ downcastInsertCell, | ||
conversion.for( 'upcast' ).elementToElement( { model: 'tableRow', view: 'tr' } ); | ||
conversion.for( 'upcast' ).add( skipEmptyTableRow() ); | ||
@@ -104,0 +105,0 @@ conversion.for( 'editingDowncast' ).add( downcastInsertRow( { asWidget: true } ) ); |
@@ -10,9 +10,11 @@ /** | ||
import TableSelection from './tableselection'; | ||
import TableWalker from './tablewalker'; | ||
import { findAncestor } from './commands/utils'; | ||
import { getSelectedTableCells, getTableCellsContainingSelection } from './utils'; | ||
import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; | ||
import { getSelectedTableCells, getTableCellsContainingSelection } from './utils'; | ||
import { findAncestor } from './commands/utils'; | ||
import TableWalker from './tablewalker'; | ||
import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect'; | ||
import priorities from '@ckeditor/ckeditor5-utils/src/priorities'; | ||
import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; | ||
import priorities from '@ckeditor/ckeditor5-utils/src/priorities'; | ||
@@ -36,2 +38,9 @@ /** | ||
*/ | ||
static get requires() { | ||
return [ TableSelection ]; | ||
} | ||
/** | ||
* @inheritDoc | ||
*/ | ||
init() { | ||
@@ -82,3 +91,3 @@ const view = this.editor.editing.view; | ||
* Returns a handler for {@link module:engine/view/document~Document#event:keydown keydown} events for the <kbd>Tab</kbd> key executed | ||
* inside table cell. | ||
* inside table cells. | ||
* | ||
@@ -166,3 +175,4 @@ * @private | ||
const wasHandled = this._handleArrowKeys( getDirectionFromKeyCode( keyCode, this.editor.locale.contentLanguageDirection ) ); | ||
const direction = getDirectionFromKeyCode( keyCode, this.editor.locale.contentLanguageDirection ); | ||
const wasHandled = this._handleArrowKeys( direction, domEventData.shiftKey ); | ||
@@ -177,9 +187,10 @@ if ( wasHandled ) { | ||
/** | ||
* Handles arrow keys to move the selection around a table. | ||
* Handles arrow keys to move the selection around the table. | ||
* | ||
* @private | ||
* @param {'left'|'up'|'right'|'down'} direction The direction of the arrow key. | ||
* @param {Boolean} expandSelection If the current selection should be expanded. | ||
* @returns {Boolean} Returns `true` if key was handled. | ||
*/ | ||
_handleArrowKeys( direction ) { | ||
_handleArrowKeys( direction, expandSelection ) { | ||
const model = this.editor.model; | ||
@@ -194,6 +205,12 @@ const selection = model.document.selection; | ||
if ( selectedCells.length ) { | ||
const tableCell = isForward ? selectedCells[ selectedCells.length - 1 ] : selectedCells[ 0 ]; | ||
let focusCell; | ||
this._navigateFromCellInDirection( tableCell, direction ); | ||
if ( expandSelection ) { | ||
focusCell = this.editor.plugins.get( 'TableSelection' ).getFocusCell(); | ||
} else { | ||
focusCell = isForward ? selectedCells[ selectedCells.length - 1 ] : selectedCells[ 0 ]; | ||
} | ||
this._navigateFromCellInDirection( focusCell, direction, expandSelection ); | ||
return true; | ||
@@ -213,3 +230,3 @@ } | ||
if ( this._isSelectionAtCellEdge( selection, isForward ) ) { | ||
this._navigateFromCellInDirection( tableCell, direction ); | ||
this._navigateFromCellInDirection( tableCell, direction, expandSelection ); | ||
@@ -236,3 +253,3 @@ return true; | ||
if ( !textRange ) { | ||
this._navigateFromCellInDirection( tableCell, direction ); | ||
this._navigateFromCellInDirection( tableCell, direction, expandSelection ); | ||
@@ -253,3 +270,12 @@ return true; | ||
model.change( writer => { | ||
writer.setSelection( isForward ? cellRange.end : cellRange.start ); | ||
const newPosition = isForward ? cellRange.end : cellRange.start; | ||
if ( expandSelection ) { | ||
const newSelection = model.createSelection( selection.anchor ); | ||
newSelection.setFocus( newPosition ); | ||
writer.setSelection( newSelection ); | ||
} else { | ||
writer.setSelection( newPosition ); | ||
} | ||
} ); | ||
@@ -262,3 +288,3 @@ | ||
/** | ||
* Returns true if the selection is at the boundary of a table cell according to the navigation direction. | ||
* Returns `true` if the selection is at the boundary of a table cell according to the navigation direction. | ||
* | ||
@@ -292,3 +318,3 @@ * @private | ||
* Checks if there is an {@link module:engine/model/element~Element element} next to the current | ||
* {@link module:engine/model/selection~Selection model selection} marked in | ||
* {@link module:engine/model/selection~Selection model selection} marked in the | ||
* {@link module:engine/model/schema~Schema schema} as an `object`. | ||
@@ -298,3 +324,3 @@ * | ||
* @param {module:engine/model/selection~Selection} modelSelection The selection. | ||
* @param {Boolean} isForward Direction of checking. | ||
* @param {Boolean} isForward The direction of checking. | ||
* @returns {Boolean} | ||
@@ -315,8 +341,8 @@ */ | ||
* Truncates the range so that it spans from the last selection position | ||
* to the last allowed $text position (mirrored if isForward is false). | ||
* to the last allowed `$text` position (mirrored if `isForward` is false). | ||
* | ||
* Returns `null` if resulting range can't contain $text element (according to schema). | ||
* Returns `null` if, according to the schema, the resulting range cannot contain a `$text` element. | ||
* | ||
* @private | ||
* @param {module:engine/model/range~Range} range Current table cell content range. | ||
* @param {module:engine/model/range~Range} range The current table cell content range. | ||
* @param {module:engine/model/selection~Selection} selection The current selection. | ||
@@ -351,9 +377,9 @@ * @param {Boolean} isForward The expected navigation direction. | ||
/** | ||
* Basing on provided range, finds first/last (depending on `direction`) position inside the range | ||
* Basing on the provided range, finds the first or last (depending on `direction`) position inside the range | ||
* that can contain `$text` (according to schema) and is visible in the view. | ||
* | ||
* @private | ||
* @param {module:engine/model/range~Range} range The range to find position in. | ||
* @param {module:engine/model/range~Range} range The range to find the position in. | ||
* @param {'forward'|'backward'} direction Search direction. | ||
* @returns {module:engine/model/position~Position} Nearest selection range. | ||
* @returns {module:engine/model/position~Position} The nearest selection range. | ||
*/ | ||
@@ -376,7 +402,7 @@ _getNearestVisibleTextPosition( range, direction ) { | ||
/** | ||
* Checks if the DOM range corresponding to provided model range renders as a single line by analyzing DOMRects | ||
* Checks if the DOM range corresponding to the provided model range renders as a single line by analyzing DOMRects | ||
* (verifying if they visually wrap content to the next line). | ||
* | ||
* @private | ||
* @param {module:engine/model/range~Range} modelRange Current table cell content range. | ||
* @param {module:engine/model/range~Range} modelRange The current table cell content range. | ||
* @param {Boolean} isForward The expected navigation direction. | ||
@@ -433,14 +459,15 @@ * @returns {Boolean} | ||
* | ||
* @private | ||
* @param {module:engine/model/element~Element} tableCell The table cell to start the selection navigation. | ||
* @protected | ||
* @param {module:engine/model/element~Element} focusCell The table cell that is current multi-cell selection focus. | ||
* @param {'left'|'up'|'right'|'down'} direction Direction in which selection should move. | ||
* @param {Boolean} [expandSelection=false] If the current selection should be expanded. | ||
*/ | ||
_navigateFromCellInDirection( tableCell, direction ) { | ||
_navigateFromCellInDirection( focusCell, direction, expandSelection = false ) { | ||
const model = this.editor.model; | ||
const table = findAncestor( 'table', tableCell ); | ||
const table = findAncestor( 'table', focusCell ); | ||
const tableMap = [ ...new TableWalker( table, { includeSpanned: true } ) ]; | ||
const { row: lastRow, column: lastColumn } = tableMap[ tableMap.length - 1 ]; | ||
const currentCellInfo = tableMap.find( ( { cell } ) => cell == tableCell ); | ||
const currentCellInfo = tableMap.find( ( { cell } ) => cell == focusCell ); | ||
let { row, column } = currentCellInfo; | ||
@@ -482,6 +509,6 @@ | ||
if ( column < 0 ) { | ||
column = lastColumn; | ||
column = expandSelection ? 0 : lastColumn; | ||
row--; | ||
} else if ( column > lastColumn ) { | ||
column = 0; | ||
column = expandSelection ? lastColumn : 0; | ||
row++; | ||
@@ -492,11 +519,19 @@ } | ||
const isForward = [ 'right', 'down' ].includes( direction ); | ||
const positionToSelect = model.createPositionAt( cellToSelect, isForward ? 0 : 'end' ); | ||
model.change( writer => { | ||
writer.setSelection( positionToSelect ); | ||
} ); | ||
if ( expandSelection ) { | ||
const tableSelection = this.editor.plugins.get( 'TableSelection' ); | ||
const anchorCell = tableSelection.getAnchorCell() || focusCell; | ||
tableSelection.setCellSelection( anchorCell, cellToSelect ); | ||
} else { | ||
const positionToSelect = model.createPositionAt( cellToSelect, isForward ? 0 : 'end' ); | ||
model.change( writer => { | ||
writer.setSelection( positionToSelect ); | ||
} ); | ||
} | ||
} | ||
} | ||
// Returns 'true' if provided key code represents one of the arrow keys. | ||
// Returns `true` if the provided key code represents one of the arrow keys. | ||
// | ||
@@ -513,3 +548,3 @@ // @private | ||
// Returns direction name from `keyCode`. | ||
// Returns the direction name from `keyCode`. | ||
// | ||
@@ -516,0 +551,0 @@ // @private |
@@ -35,2 +35,13 @@ /** | ||
// Map of view properties and related commands. | ||
const propertyToCommandMap = { | ||
borderStyle: 'tableBorderStyle', | ||
borderColor: 'tableBorderColor', | ||
borderWidth: 'tableBorderWidth', | ||
backgroundColor: 'tableBackgroundColor', | ||
width: 'tableWidth', | ||
height: 'tableHeight', | ||
alignment: 'tableAlignment' | ||
}; | ||
/** | ||
@@ -114,2 +125,9 @@ * The table properties UI plugin. It introduces the `'tableProperties'` button | ||
const commands = Object.values( propertyToCommandMap ) | ||
.map( commandName => editor.commands.get( commandName ) ); | ||
view.bind( 'isEnabled' ).toMany( commands, 'isEnabled', ( ...areEnabled ) => ( | ||
areEnabled.some( isCommandEnabled => isCommandEnabled ) | ||
) ); | ||
return view; | ||
@@ -253,11 +271,5 @@ } ); | ||
this.view.set( { | ||
borderStyle: commands.get( 'tableBorderStyle' ).value || '', | ||
borderColor: commands.get( 'tableBorderColor' ).value || '', | ||
borderWidth: commands.get( 'tableBorderWidth' ).value || '', | ||
backgroundColor: commands.get( 'tableBackgroundColor' ).value || '', | ||
width: commands.get( 'tableWidth' ).value || '', | ||
height: commands.get( 'tableHeight' ).value || '', | ||
alignment: commands.get( 'tableAlignment' ).value || '' | ||
} ); | ||
Object.entries( propertyToCommandMap ) | ||
.map( ( [ property, commandName ] ) => [ property, commands.get( commandName ).value || '' ] ) | ||
.forEach( ( [ property, value ] ) => this.view.set( property, value ) ); | ||
} | ||
@@ -264,0 +276,0 @@ |
@@ -11,2 +11,3 @@ /** | ||
import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; | ||
import first from '@ckeditor/ckeditor5-utils/src/first'; | ||
@@ -16,8 +17,5 @@ import TableWalker from './tablewalker'; | ||
import MouseEventsObserver from './tableselection/mouseeventsobserver'; | ||
import { | ||
getSelectedTableCells, | ||
getTableCellsContainingSelection | ||
} from './utils'; | ||
import { getColumnIndexes, getRowIndexes, getSelectedTableCells, getTableCellsContainingSelection } from './utils'; | ||
import { findAncestor } from './commands/utils'; | ||
import cropTable from './tableselection/croptable'; | ||
import { cropTableToDimensions } from './tableselection/croptable'; | ||
@@ -103,4 +101,17 @@ import '../theme/tableselection.css'; | ||
const documentFragment = writer.createDocumentFragment(); | ||
const table = cropTable( selectedCells, this.editor.plugins.get( 'TableUtils' ), writer ); | ||
const { first: startColumn, last: endColumn } = getColumnIndexes( selectedCells ); | ||
const { first: startRow, last: endRow } = getRowIndexes( selectedCells ); | ||
const sourceTable = findAncestor( 'table', selectedCells[ 0 ] ); | ||
const cropDimensions = { | ||
startRow, | ||
startColumn, | ||
endRow, | ||
endColumn | ||
}; | ||
const table = cropTableToDimensions( sourceTable, cropDimensions, writer, this.editor.plugins.get( 'TableUtils' ) ); | ||
writer.insert( table, documentFragment, 0 ); | ||
@@ -113,2 +124,61 @@ | ||
/** | ||
* Sets the model selection based on given anchor and target cells (can be the same cell). | ||
* Takes care of setting the backward flag. | ||
* | ||
* const modelRoot = editor.model.document.getRoot(); | ||
* const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] ); | ||
* const lastCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] ); | ||
* | ||
* const tableSelection = editor.plugins.get( 'TableSelection' ); | ||
* tableSelection.setCellSelection( firstCell, lastCell ); | ||
* | ||
* @param {module:engine/model/element~Element} anchorCell | ||
* @param {module:engine/model/element~Element} targetCell | ||
*/ | ||
setCellSelection( anchorCell, targetCell ) { | ||
const cellsToSelect = this._getCellsToSelect( anchorCell, targetCell ); | ||
this.editor.model.change( writer => { | ||
writer.setSelection( | ||
cellsToSelect.cells.map( cell => writer.createRangeOn( cell ) ), | ||
{ backward: cellsToSelect.backward } | ||
); | ||
} ); | ||
} | ||
/** | ||
* Returns the focus cell from the current selection. | ||
* | ||
* @returns {module:engine/model/element~Element} | ||
*/ | ||
getFocusCell() { | ||
const selection = this.editor.model.document.selection; | ||
const focusCellRange = [ ...selection.getRanges() ].pop(); | ||
const element = focusCellRange.getContainedElement(); | ||
if ( element && element.is( 'tableCell' ) ) { | ||
return element; | ||
} | ||
return null; | ||
} | ||
/** | ||
* Returns the anchor cell from the current selection. | ||
* | ||
* @returns {module:engine/model/element~Element} anchorCell | ||
*/ | ||
getAnchorCell() { | ||
const selection = this.editor.model.document.selection; | ||
const anchorCellRange = first( selection.getRanges() ); | ||
const element = anchorCellRange.getContainedElement(); | ||
if ( element && element.is( 'tableCell' ) ) { | ||
return element; | ||
} | ||
return null; | ||
} | ||
/** | ||
* Defines a selection converter which marks the selected cells with a specific class. | ||
@@ -178,3 +248,3 @@ * | ||
const anchorCell = getTableCellsContainingSelection( editor.model.document.selection )[ 0 ]; | ||
const anchorCell = this.getAnchorCell() || getTableCellsContainingSelection( editor.model.document.selection )[ 0 ]; | ||
@@ -189,3 +259,3 @@ if ( !anchorCell ) { | ||
blockSelectionChange = true; | ||
this._setCellSelection( anchorCell, targetCell ); | ||
this.setCellSelection( anchorCell, targetCell ); | ||
@@ -281,3 +351,3 @@ domEventData.preventDefault(); | ||
blockSelectionChange = true; | ||
this._setCellSelection( anchorCell, targetCell ); | ||
this.setCellSelection( anchorCell, targetCell ); | ||
@@ -374,21 +444,2 @@ domEventData.preventDefault(); | ||
/** | ||
* Sets the model selection based on given anchor and target cells (can be the same cell). | ||
* Takes care of setting the backward flag. | ||
* | ||
* @protected | ||
* @param {module:engine/model/element~Element} anchorCell | ||
* @param {module:engine/model/element~Element} targetCell | ||
*/ | ||
_setCellSelection( anchorCell, targetCell ) { | ||
const cellsToSelect = this._getCellsToSelect( anchorCell, targetCell ); | ||
this.editor.model.change( writer => { | ||
writer.setSelection( | ||
cellsToSelect.cells.map( cell => writer.createRangeOn( cell ) ), | ||
{ backward: cellsToSelect.backward } | ||
); | ||
} ); | ||
} | ||
/** | ||
* Returns the model table cell element based on the target element of the passed DOM event. | ||
@@ -436,35 +487,27 @@ * | ||
const cells = []; | ||
// 2-dimensional array of the selected cells to ease flipping the order of cells for backward selections. | ||
const selectionMap = new Array( endRow - startRow + 1 ).fill( null ).map( () => [] ); | ||
for ( const cellInfo of new TableWalker( findAncestor( 'table', anchorCell ), { startRow, endRow } ) ) { | ||
if ( cellInfo.column >= startColumn && cellInfo.column <= endColumn ) { | ||
cells.push( cellInfo.cell ); | ||
selectionMap[ cellInfo.row - startRow ].push( cellInfo.cell ); | ||
} | ||
} | ||
// A selection started in the bottom right corner and finished in the upper left corner | ||
// should have it ranges returned in the reverse order. | ||
// However, this is only half of the story because the selection could be made to the left (then the left cell is a focus) | ||
// or to the right (then the right cell is a focus), while being a forward selection in both cases | ||
// (because it was made from top to bottom). This isn't handled. | ||
// This method would need to be smarter, but the ROI is microscopic, so I skip this. | ||
if ( checkIsBackward( startLocation, endLocation ) ) { | ||
return { cells: cells.reverse(), backward: true }; | ||
const flipVertically = endLocation.row < startLocation.row; | ||
const flipHorizontally = endLocation.column < startLocation.column; | ||
if ( flipVertically ) { | ||
selectionMap.reverse(); | ||
} | ||
return { cells, backward: false }; | ||
} | ||
} | ||
if ( flipHorizontally ) { | ||
selectionMap.forEach( row => row.reverse() ); | ||
} | ||
// Naively check whether the selection should be backward or not. See the longer explanation where this function is used. | ||
function checkIsBackward( startLocation, endLocation ) { | ||
if ( startLocation.row > endLocation.row ) { | ||
return true; | ||
return { | ||
cells: selectionMap.flat(), | ||
backward: flipVertically || flipHorizontally | ||
}; | ||
} | ||
if ( startLocation.row == endLocation.row && startLocation.column > endLocation.column ) { | ||
return true; | ||
} | ||
return false; | ||
} | ||
@@ -471,0 +514,0 @@ |
@@ -10,136 +10,137 @@ /** | ||
import { findAncestor } from '../commands/utils'; | ||
import { createEmptyTableCell, updateNumericAttribute } from '../commands/utils'; | ||
import TableWalker from '../tablewalker'; | ||
/** | ||
* Returns a cropped table from the selected table cells. | ||
* Returns a cropped table according to given dimensions. | ||
* To return a cropped table that starts at first row and first column and end in third row and column: | ||
* | ||
* This function is to be used with the table selection. | ||
* const croppedTable = cropTableToDimensions( table, { | ||
* startRow: 1, | ||
* endRow: 1, | ||
* startColumn: 3, | ||
* endColumn: 3 | ||
* }, tableUtils, writer ); | ||
* | ||
* tableSelection.startSelectingFrom( startCell ) | ||
* tableSelection.setSelectingFrom( endCell ) | ||
* Calling the code above for the table below: | ||
* | ||
* const croppedTable = cropTable( tableSelection.getSelectedTableCells() ); | ||
* 0 1 2 3 4 0 1 2 | ||
* ┌───┬───┬───┬───┬───┐ | ||
* 0 │ a │ b │ c │ d │ e │ | ||
* ├───┴───┤ ├───┴───┤ ┌───┬───┬───┐ | ||
* 1 │ f │ │ g │ │ │ │ g │ 0 | ||
* ├───┬───┴───┼───┬───┤ will return: ├───┴───┼───┤ | ||
* 2 │ h │ i │ j │ k │ │ i │ j │ 1 | ||
* ├───┤ ├───┤ │ │ ├───┤ | ||
* 3 │ l │ │ m │ │ │ │ m │ 2 | ||
* ├───┼───┬───┤ ├───┤ └───────┴───┘ | ||
* 4 │ n │ o │ p │ │ q │ | ||
* └───┴───┴───┴───┴───┘ | ||
* | ||
* **Note**: This function is also used by {@link module:table/tableselection~TableSelection#getSelectionAsFragment}. | ||
* | ||
* @param {Iterable.<module:engine/model/element~Element>} selectedTableCellsIterator | ||
* @param {module:engine/model/element~Element} sourceTable | ||
* @param {Object} cropDimensions | ||
* @param {Number} cropDimensions.startRow | ||
* @param {Number} cropDimensions.startColumn | ||
* @param {Number} cropDimensions.endRow | ||
* @param {Number} cropDimensions.endColumn | ||
* @param {module:engine/model/writer~Writer} writer | ||
* @param {module:table/tableutils~TableUtils} tableUtils | ||
* @param {module:engine/model/writer~Writer} writer | ||
* @returns {module:engine/model/element~Element} | ||
*/ | ||
export default function cropTable( selectedTableCellsIterator, tableUtils, writer ) { | ||
const selectedTableCells = Array.from( selectedTableCellsIterator ); | ||
const startElement = selectedTableCells[ 0 ]; | ||
const endElement = selectedTableCells[ selectedTableCells.length - 1 ]; | ||
export function cropTableToDimensions( sourceTable, cropDimensions, writer, tableUtils ) { | ||
const { startRow, startColumn, endRow, endColumn } = cropDimensions; | ||
const { row: startRow, column: startColumn } = tableUtils.getCellLocation( startElement ); | ||
// Create empty table with empty rows equal to crop height. | ||
const croppedTable = writer.createElement( 'table' ); | ||
const cropHeight = endRow - startRow + 1; | ||
const tableCopy = makeTableCopy( selectedTableCells, startColumn, writer, tableUtils ); | ||
for ( let i = 0; i < cropHeight; i++ ) { | ||
writer.insertElement( 'tableRow', croppedTable, 'end' ); | ||
} | ||
const { row: endRow, column: endColumn } = tableUtils.getCellLocation( endElement ); | ||
const selectionWidth = endColumn - startColumn + 1; | ||
const selectionHeight = endRow - startRow + 1; | ||
const tableMap = [ ...new TableWalker( sourceTable, { startRow, endRow, includeSpanned: true } ) ]; | ||
trimTable( tableCopy, selectionWidth, selectionHeight, writer, tableUtils ); | ||
// Iterate over source table slots (including empty - spanned - ones). | ||
for ( const { row: sourceRow, column: sourceColumn, cell: tableCell, isSpanned } of tableMap ) { | ||
// Skip slots outside the cropped area. | ||
// Could use startColumn, endColumn. See: https://github.com/ckeditor/ckeditor5/issues/6785. | ||
if ( sourceColumn < startColumn || sourceColumn > endColumn ) { | ||
continue; | ||
} | ||
const sourceTable = findAncestor( 'table', startElement ); | ||
addHeadingsToTableCopy( tableCopy, sourceTable, startRow, startColumn, writer ); | ||
// Row index in cropped table. | ||
const rowInCroppedTable = sourceRow - startRow; | ||
const row = croppedTable.getChild( rowInCroppedTable ); | ||
return tableCopy; | ||
} | ||
// For empty slots: fill the gap with empty table cell. | ||
if ( isSpanned ) { | ||
// TODO: Remove table utils usage. See: https://github.com/ckeditor/ckeditor5/issues/6785. | ||
const { row: anchorRow, column: anchorColumn } = tableUtils.getCellLocation( tableCell ); | ||
// Creates a table copy from a selected table cells. | ||
// | ||
// It fills "gaps" in copied table - ie when cell outside copied range was spanning over selection. | ||
function makeTableCopy( selectedTableCells, startColumn, writer, tableUtils ) { | ||
const tableCopy = writer.createElement( 'table' ); | ||
// But fill the gap only if the spanning cell is anchored outside cropped area. | ||
// In the table from method jsdoc those cells are: "c" & "f". | ||
if ( anchorRow < startRow || anchorColumn < startColumn ) { | ||
createEmptyTableCell( writer, writer.createPositionAt( row, 'end' ) ); | ||
} | ||
} | ||
// Otherwise clone the cell with all children and trim if it exceeds cropped area. | ||
else { | ||
const tableCellCopy = tableCell._clone( true ); | ||
const rowToCopyMap = new Map(); | ||
const copyToOriginalColumnMap = new Map(); | ||
writer.append( tableCellCopy, row ); | ||
for ( const tableCell of selectedTableCells ) { | ||
const row = findAncestor( 'tableRow', tableCell ); | ||
if ( !rowToCopyMap.has( row ) ) { | ||
const rowCopy = row._clone(); | ||
writer.append( rowCopy, tableCopy ); | ||
rowToCopyMap.set( row, rowCopy ); | ||
// Trim table if it exceeds cropped area. | ||
// In the table from method jsdoc those cells are: "g" & "m". | ||
trimTableCellIfNeeded( tableCellCopy, sourceRow, sourceColumn, endRow, endColumn, writer ); | ||
} | ||
const tableCellCopy = tableCell._clone( true ); | ||
const { column } = tableUtils.getCellLocation( tableCell ); | ||
copyToOriginalColumnMap.set( tableCellCopy, column ); | ||
writer.append( tableCellCopy, rowToCopyMap.get( row ) ); | ||
} | ||
addMissingTableCells( tableCopy, startColumn, copyToOriginalColumnMap, writer, tableUtils ); | ||
// Adjust heading rows & columns in cropped table if crop selection includes headings parts. | ||
addHeadingsToCroppedTable( croppedTable, sourceTable, startRow, startColumn, writer ); | ||
return tableCopy; | ||
return croppedTable; | ||
} | ||
// Fills gaps for spanned cell from outside the selection range. | ||
function addMissingTableCells( tableCopy, startColumn, copyToOriginalColumnMap, writer, tableUtils ) { | ||
for ( const row of tableCopy.getChildren() ) { | ||
for ( const tableCell of Array.from( row.getChildren() ) ) { | ||
const { column } = tableUtils.getCellLocation( tableCell ); | ||
/** | ||
* Adjusts table cell dimensions to not exceed limit row and column. | ||
* | ||
* If table cell width (or height) covers a column (or row) that is after a limit column (or row) | ||
* this method will trim "colspan" (or "rowspan") attribute so the table cell will fit in a defined limits. | ||
* | ||
* @param {module:engine/model/element~Element} tableCell | ||
* @param {Number} cellRow | ||
* @param {Number} cellColumn | ||
* @param {Number} limitRow | ||
* @param {Number} limitColumn | ||
* @param {module:engine/model/writer~Writer} writer | ||
*/ | ||
export function trimTableCellIfNeeded( tableCell, cellRow, cellColumn, limitRow, limitColumn, writer ) { | ||
const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 ); | ||
const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 ); | ||
const originalColumn = copyToOriginalColumnMap.get( tableCell ); | ||
const shiftedColumn = originalColumn - startColumn; | ||
const endColumn = cellColumn + colspan - 1; | ||
if ( column !== shiftedColumn ) { | ||
for ( let i = 0; i < shiftedColumn - column; i++ ) { | ||
const prepCell = writer.createElement( 'tableCell' ); | ||
writer.insert( prepCell, writer.createPositionBefore( tableCell ) ); | ||
if ( endColumn > limitColumn ) { | ||
const trimmedSpan = limitColumn - cellColumn + 1; | ||
const paragraph = writer.createElement( 'paragraph' ); | ||
writer.insert( paragraph, prepCell, 0 ); | ||
writer.insertText( '', paragraph, 0 ); | ||
} | ||
} | ||
} | ||
updateNumericAttribute( 'colspan', trimmedSpan, tableCell, writer, 1 ); | ||
} | ||
} | ||
// Trims table to a given dimensions. | ||
function trimTable( table, width, height, writer, tableUtils ) { | ||
for ( const row of table.getChildren() ) { | ||
for ( const tableCell of row.getChildren() ) { | ||
const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 ); | ||
const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 ); | ||
const endRow = cellRow + rowspan - 1; | ||
const { row, column } = tableUtils.getCellLocation( tableCell ); | ||
if ( endRow > limitRow ) { | ||
const trimmedSpan = limitRow - cellRow + 1; | ||
if ( column + colspan > width ) { | ||
const newSpan = width - column; | ||
if ( newSpan > 1 ) { | ||
writer.setAttribute( 'colspan', newSpan, tableCell ); | ||
} else { | ||
writer.removeAttribute( 'colspan', tableCell ); | ||
} | ||
} | ||
if ( row + rowspan > height ) { | ||
const newSpan = height - row; | ||
if ( newSpan > 1 ) { | ||
writer.setAttribute( 'rowspan', newSpan, tableCell ); | ||
} else { | ||
writer.removeAttribute( 'rowspan', tableCell ); | ||
} | ||
} | ||
} | ||
updateNumericAttribute( 'rowspan', trimmedSpan, tableCell, writer, 1 ); | ||
} | ||
} | ||
// Sets proper heading attributes to copied table. | ||
function addHeadingsToTableCopy( tableCopy, sourceTable, startRow, startColumn, writer ) { | ||
// Sets proper heading attributes to a cropped table. | ||
function addHeadingsToCroppedTable( croppedTable, sourceTable, startRow, startColumn, writer ) { | ||
const headingRows = parseInt( sourceTable.getAttribute( 'headingRows' ) || 0 ); | ||
if ( headingRows > 0 ) { | ||
const copiedRows = headingRows - startRow; | ||
writer.setAttribute( 'headingRows', copiedRows, tableCopy ); | ||
const headingRowsInCrop = headingRows - startRow; | ||
updateNumericAttribute( 'headingRows', headingRowsInCrop, croppedTable, writer, 0 ); | ||
} | ||
@@ -150,5 +151,5 @@ | ||
if ( headingColumns > 0 ) { | ||
const copiedColumns = headingColumns - startColumn; | ||
writer.setAttribute( 'headingColumns', copiedColumns, tableCopy ); | ||
const headingColumnsInCrop = headingColumns - startColumn; | ||
updateNumericAttribute( 'headingColumns', headingColumnsInCrop, croppedTable, writer, 0 ); | ||
} | ||
} |
@@ -144,4 +144,4 @@ /** | ||
model: { | ||
commandName: 'insertTableRowBelow', | ||
label: t( 'Insert row below' ) | ||
commandName: 'insertTableRowAbove', | ||
label: t( 'Insert row above' ) | ||
} | ||
@@ -152,4 +152,4 @@ }, | ||
model: { | ||
commandName: 'insertTableRowAbove', | ||
label: t( 'Insert row above' ) | ||
commandName: 'insertTableRowBelow', | ||
label: t( 'Insert row below' ) | ||
} | ||
@@ -156,0 +156,0 @@ }, |
@@ -119,2 +119,4 @@ /** | ||
* @param {Number} [options.rows=1] The number of rows to insert. | ||
* @param {Boolean|undefined} [options.copyStructureFromAbove] The flag for copying row structure. Note that | ||
* the row structure will not be copied if this option is not provided. | ||
*/ | ||
@@ -126,3 +128,8 @@ insertRows( table, options = {} ) { | ||
const rowsToInsert = options.rows || 1; | ||
const isCopyStructure = options.copyStructureFromAbove !== undefined; | ||
const copyStructureFrom = options.copyStructureFromAbove ? insertAt - 1 : insertAt; | ||
const rows = this.getRows( table ); | ||
const columns = this.getColumns( table ); | ||
model.change( writer => { | ||
@@ -136,5 +143,5 @@ const headingRows = table.getAttribute( 'headingRows' ) || 0; | ||
// Inserting at the end and at the beginning of a table doesn't require to calculate anything special. | ||
if ( insertAt === 0 || insertAt === table.childCount ) { | ||
createEmptyRows( writer, table, insertAt, rowsToInsert, this.getColumns( table ) ); | ||
// Inserting at the end or at the beginning of a table doesn't require to calculate anything special. | ||
if ( !isCopyStructure && ( insertAt === 0 || insertAt === rows ) ) { | ||
createEmptyRows( writer, table, insertAt, rowsToInsert, columns ); | ||
@@ -144,27 +151,47 @@ return; | ||
// Iterate over all rows above inserted rows in order to check for rowspanned cells. | ||
const tableIterator = new TableWalker( table, { endRow: insertAt } ); | ||
// Iterate over all the rows above the inserted rows in order to check for the row-spanned cells. | ||
const walkerEndRow = isCopyStructure ? Math.max( insertAt, copyStructureFrom ) : insertAt; | ||
const tableIterator = new TableWalker( table, { endRow: walkerEndRow } ); | ||
// Will hold number of cells needed to insert in created rows. | ||
// The number might be different then table cell width when there are rowspanned cells. | ||
let cellsToInsert = 0; | ||
// Store spans of the reference row to reproduce it's structure. This array is column number indexed. | ||
const rowColSpansMap = new Array( columns ).fill( 1 ); | ||
for ( const { row, rowspan, colspan, cell } of tableIterator ) { | ||
const isBeforeInsertedRow = row < insertAt; | ||
const overlapsInsertedRow = row + rowspan > insertAt; | ||
for ( const { row, column, rowspan, colspan, cell } of tableIterator ) { | ||
const lastCellRow = row + rowspan - 1; | ||
if ( isBeforeInsertedRow && overlapsInsertedRow ) { | ||
// This cell overlaps inserted rows so we need to expand it further. | ||
const isOverlappingInsertedRow = row < insertAt && insertAt <= lastCellRow; | ||
const isReferenceRow = row <= copyStructureFrom && copyStructureFrom <= lastCellRow; | ||
// If the cell is row-spanned and overlaps the inserted row, then reserve space for it in the row map. | ||
if ( isOverlappingInsertedRow ) { | ||
// This cell overlaps the inserted rows so we need to expand it further. | ||
writer.setAttribute( 'rowspan', rowspan + rowsToInsert, cell ); | ||
// Mark this cell with negative number to indicate how many cells should be skipped when adding the new cells. | ||
rowColSpansMap[ column ] = -colspan; | ||
} | ||
// Store the colspan from reference row. | ||
else if ( isCopyStructure && isReferenceRow ) { | ||
rowColSpansMap[ column ] = colspan; | ||
} | ||
} | ||
// Calculate how many cells to insert based on the width of cells in a row at insert position. | ||
// It might be lower then table width as some cells might overlaps inserted row. | ||
// In the table above the cell 'a' overlaps inserted row so only two empty cells are need to be created. | ||
if ( row === insertAt ) { | ||
cellsToInsert += colspan; | ||
for ( let rowIndex = 0; rowIndex < rowsToInsert; rowIndex++ ) { | ||
const tableRow = writer.createElement( 'tableRow' ); | ||
writer.insert( tableRow, table, insertAt ); | ||
for ( let cellIndex = 0; cellIndex < rowColSpansMap.length; cellIndex++ ) { | ||
const colspan = rowColSpansMap[ cellIndex ]; | ||
const insertPosition = writer.createPositionAt( tableRow, 'end' ); | ||
// Insert the empty cell only if this slot is not row-spanned from any other cell. | ||
if ( colspan > 0 ) { | ||
createEmptyTableCell( writer, insertPosition, colspan > 1 ? { colspan } : null ); | ||
} | ||
// Skip the col-spanned slots, there won't be any cells. | ||
cellIndex += Math.abs( colspan ) - 1; | ||
} | ||
} | ||
createEmptyRows( writer, table, insertAt, rowsToInsert, cellsToInsert ); | ||
} ); | ||
@@ -296,13 +323,17 @@ } | ||
// Removing rows from table requires most calculations to be done prior to changing table structure. | ||
model.enqueueChange( batch, writer => { | ||
// Removing rows from the table require that most calculations to be done prior to changing table structure. | ||
// Preparations must be done in the same enqueueChange callback to use the current table structure. | ||
// 1. Preparation - get row-spanned cells that have to be modified after removing rows. | ||
const { cellsToMove, cellsToTrim } = getCellsToMoveAndTrimOnRemoveRow( table, first, last ); | ||
// 1. Preparation - get row-spanned cells that have to be modified after removing rows. | ||
const { cellsToMove, cellsToTrim } = getCellsToMoveAndTrimOnRemoveRow( table, first, last ); | ||
// 2. Execution | ||
model.enqueueChange( batch, writer => { | ||
// 2. Execution | ||
// 2a. Move cells from removed rows that extends over a removed section - must be done before removing rows. | ||
// This will fill any gaps in a rows below that previously were empty because of row-spanned cells. | ||
const rowAfterRemovedSection = last + 1; | ||
moveCellsToRow( table, rowAfterRemovedSection, cellsToMove, writer ); | ||
if ( cellsToMove.size ) { | ||
const rowAfterRemovedSection = last + 1; | ||
moveCellsToRow( table, rowAfterRemovedSection, cellsToMove, writer ); | ||
} | ||
@@ -362,2 +393,4 @@ // 2b. Remove all required rows. | ||
const emptyRowsIndexes = []; | ||
for ( let removedColumnIndex = last; removedColumnIndex >= first; removedColumnIndex-- ) { | ||
@@ -377,3 +410,3 @@ for ( const { cell, column, colspan } of [ ...new TableWalker( table ) ] ) { | ||
if ( !cellRow.childCount ) { | ||
this.removeRows( table, { at: cellRow.index } ); | ||
emptyRowsIndexes.push( cellRow.index ); | ||
} | ||
@@ -383,2 +416,4 @@ } | ||
} | ||
emptyRowsIndexes.reverse().forEach( row => this.removeRows( table, { at: row, batch: writer.batch } ) ); | ||
} ); | ||
@@ -764,13 +799,16 @@ } | ||
function updateHeadingRows( table, first, last, model, batch ) { | ||
const headingRows = table.getAttribute( 'headingRows' ) || 0; | ||
// Must be done after the changes in table structure (removing rows). | ||
// Otherwise the downcast converter for headingRows attribute will fail. | ||
// See https://github.com/ckeditor/ckeditor5/issues/6391. | ||
// | ||
// Must be completely wrapped in enqueueChange to get the current table state (after applying other enqueued changes). | ||
model.enqueueChange( batch, writer => { | ||
const headingRows = table.getAttribute( 'headingRows' ) || 0; | ||
if ( first < headingRows ) { | ||
const newRows = last < headingRows ? headingRows - ( last - first + 1 ) : first; | ||
if ( first < headingRows ) { | ||
const newRows = last < headingRows ? headingRows - ( last - first + 1 ) : first; | ||
// Must be done after the changes in table structure (removing rows). | ||
// Otherwise the downcast converter for headingRows attribute will fail. ckeditor/ckeditor5#6391. | ||
model.enqueueChange( batch, writer => { | ||
updateNumericAttribute( 'headingRows', newRows, table, writer, 0 ); | ||
} ); | ||
} | ||
} | ||
} ); | ||
} | ||
@@ -777,0 +815,0 @@ |
@@ -104,6 +104,16 @@ /** | ||
* @protected | ||
* @member {module:ui/dropdown/dropdown~DropdownView} | ||
* @member {module:ui/inputtext/inputtextview~InputTextView} | ||
*/ | ||
this._inputView = this._createInputTextView( locale ); | ||
/** | ||
* The flag that indicates whether the user is still typing. | ||
* If set to true, it means that the text input field ({@link #_inputView}) still has the focus. | ||
* So, we should interrupt the user by replacing the input's value. | ||
* | ||
* @protected | ||
* @member {Boolean} | ||
*/ | ||
this._stillTyping = false; | ||
this.setTemplate( { | ||
@@ -126,2 +136,4 @@ tag: 'div', | ||
} ); | ||
this.on( 'change:value', ( evt, name, inputValue ) => this._setInputValue( inputValue ) ); | ||
} | ||
@@ -191,21 +203,38 @@ | ||
/** | ||
* Creates and configures the {@link #_inputView}. | ||
* Creates and configures an instance of {@link module:ui/inputtext/inputtextview~InputTextView}. | ||
* | ||
* @private | ||
* @returns {module:ui/inputtext/inputtextview~InputTextView} A configured instance to be set as {@link #_inputView}. | ||
*/ | ||
_createInputTextView() { | ||
const locale = this.locale; | ||
const input = new InputTextView( locale ); | ||
const inputView = new InputTextView( locale ); | ||
input.bind( 'value' ).to( this ); | ||
input.bind( 'isReadOnly' ).to( this ); | ||
input.bind( 'hasError' ).to( this ); | ||
inputView.extendTemplate( { | ||
on: { | ||
blur: inputView.bindTemplate.to( 'blur' ) | ||
} | ||
} ); | ||
input.on( 'input', () => { | ||
this.value = input.element.value; | ||
inputView.value = this.value; | ||
inputView.bind( 'isReadOnly' ).to( this ); | ||
inputView.bind( 'hasError' ).to( this ); | ||
inputView.on( 'input', () => { | ||
const inputValue = inputView.element.value; | ||
// Check if the value matches one of our defined colors' label. | ||
const mappedColor = this.options.colorDefinitions.find( def => inputValue === def.label ); | ||
this._stillTyping = true; | ||
this.value = mappedColor && mappedColor.color || inputValue; | ||
} ); | ||
input.delegate( 'input' ).to( this ); | ||
inputView.on( 'blur', () => { | ||
this._stillTyping = false; | ||
this._setInputValue( inputView.element.value ); | ||
} ); | ||
return input; | ||
inputView.delegate( 'input' ).to( this ); | ||
return inputView; | ||
} | ||
@@ -252,3 +281,2 @@ | ||
} ); | ||
colorGrid.bind( 'selectedColor' ).to( this, 'value' ); | ||
@@ -258,2 +286,45 @@ | ||
} | ||
/** | ||
* Sets {@link #_inputView}'s value property to the color value or color label, | ||
* if there is one and the user is not typing. | ||
* | ||
* Handles cases like: | ||
* | ||
* * Someone picks the color in the grid. | ||
* * The color is set from the plugin level. | ||
* | ||
* @private | ||
* @param {String} inputValue Color value to be set. | ||
*/ | ||
_setInputValue( inputValue ) { | ||
if ( !this._stillTyping ) { | ||
const normalizedInputValue = normalizeColor( inputValue ); | ||
// Check if the value matches one of our defined colors. | ||
const mappedColor = this.options.colorDefinitions.find( def => normalizedInputValue === normalizeColor( def.color ) ); | ||
if ( mappedColor ) { | ||
this._inputView.value = mappedColor.label; | ||
} else { | ||
this._inputView.value = inputValue || ''; | ||
} | ||
} | ||
} | ||
} | ||
// Normalizes color value, by stripping extensive whitespace. | ||
// For example., transforms: | ||
// * ` rgb( 25 50 0 )` to `rgb(25 50 0)`, | ||
// * "\t rgb( 25 , 50,0 ) " to `rgb(25 50 0)`. | ||
// | ||
// @param {String} colorString The value to be normalized. | ||
// @returns {String} | ||
function normalizeColor( colorString ) { | ||
return colorString | ||
// Remove any whitespace right after `(` or `,`. | ||
.replace( /([(,])\s+/g, '$1' ) | ||
// Remove any whitespace at the beginning or right before the end, `)`, `,`, or another whitespace. | ||
.replace( /^\s+|\s+(?=[),\s]|$)/g, '' ) | ||
// Then, replace `,` or whitespace with a single space. | ||
.replace( /,|\s/g, ' ' ); | ||
} |
@@ -19,2 +19,3 @@ /** | ||
import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect'; | ||
import { centeredBalloonPositionForLongWidgets } from '@ckeditor/ckeditor5-widget/src/utils'; | ||
@@ -30,2 +31,6 @@ const DEFAULT_BALLOON_POSITIONS = BalloonPanelView.defaultPositions; | ||
]; | ||
const TABLE_PROPERTRIES_BALLOON_POSITIONS = [ | ||
...BALLOON_POSITIONS, | ||
centeredBalloonPositionForLongWidgets | ||
]; | ||
@@ -74,3 +79,3 @@ const isEmpty = val => val === ''; | ||
target: editor.editing.view.domConverter.viewToDom( viewTable ), | ||
positions: BALLOON_POSITIONS | ||
positions: TABLE_PROPERTRIES_BALLOON_POSITIONS | ||
}; | ||
@@ -77,0 +82,0 @@ } |
301
src/utils.js
@@ -11,3 +11,3 @@ /** | ||
import { isWidget, toWidget } from '@ckeditor/ckeditor5-widget/src/utils'; | ||
import { findAncestor } from './commands/utils'; | ||
import { createEmptyTableCell, findAncestor, updateNumericAttribute } from './commands/utils'; | ||
import TableWalker from './tablewalker'; | ||
@@ -143,3 +143,3 @@ | ||
/** | ||
* Returns an object with `first` and `last` row index contained in the given `tableCells`. | ||
* Returns an object with the `first` and `last` row index contained in the given `tableCells`. | ||
* | ||
@@ -153,3 +153,3 @@ * const selectedTableCells = getSelectedTableCells( editor.model.document.selection ); | ||
* @param {Array.<module:engine/model/element~Element>} tableCells | ||
* @returns {Object} Returns an object with `first` and `last` table row indexes. | ||
* @returns {Object} Returns an object with the `first` and `last` table row indexes. | ||
*/ | ||
@@ -163,3 +163,3 @@ export function getRowIndexes( tableCells ) { | ||
/** | ||
* Returns an object with `first` and `last` column index contained in the given `tableCells`. | ||
* Returns an object with the `first` and `last` column index contained in the given `tableCells`. | ||
* | ||
@@ -173,3 +173,3 @@ * const selectedTableCells = getSelectedTableCells( editor.model.document.selection ); | ||
* @param {Array.<module:engine/model/element~Element>} tableCells | ||
* @returns {Object} Returns an object with `first` and `last` table column indexes. | ||
* @returns {Object} Returns an object with the `first` and `last` table column indexes. | ||
*/ | ||
@@ -187,2 +187,235 @@ export function getColumnIndexes( tableCells ) { | ||
/** | ||
* Checks if the selection contains cells that do not exceed rectangular selection. | ||
* | ||
* In a table below: | ||
* | ||
* ┌───┬───┬───┬───┐ | ||
* │ a │ b │ c │ d │ | ||
* ├───┴───┼───┤ │ | ||
* │ e │ f │ │ | ||
* │ ├───┼───┤ | ||
* │ │ g │ h │ | ||
* └───────┴───┴───┘ | ||
* | ||
* Valid selections are these which create a solid rectangle (without gaps), such as: | ||
* - a, b (two horizontal cells) | ||
* - c, f (two vertical cells) | ||
* - a, b, e (cell "e" spans over four cells) | ||
* - c, d, f (cell d spans over a cell in the row below) | ||
* | ||
* While an invalid selection would be: | ||
* - a, c (the unselected cell "b" creates a gap) | ||
* - f, g, h (cell "d" spans over a cell from the row of "f" cell - thus creates a gap) | ||
* | ||
* @param {Array.<module:engine/model/element~Element>} selectedTableCells | ||
* @param {module:table/tableutils~TableUtils} tableUtils | ||
* @returns {Boolean} | ||
*/ | ||
export function isSelectionRectangular( selectedTableCells, tableUtils ) { | ||
if ( selectedTableCells.length < 2 || !areCellInTheSameTableSection( selectedTableCells ) ) { | ||
return false; | ||
} | ||
// A valid selection is a fully occupied rectangle composed of table cells. | ||
// Below we will calculate the area of a selected table cells and the area of valid selection. | ||
// The area of a valid selection is defined by top-left and bottom-right cells. | ||
const rows = new Set(); | ||
const columns = new Set(); | ||
let areaOfSelectedCells = 0; | ||
for ( const tableCell of selectedTableCells ) { | ||
const { row, column } = tableUtils.getCellLocation( tableCell ); | ||
const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 ); | ||
const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 ); | ||
// Record row & column indexes of current cell. | ||
rows.add( row ); | ||
columns.add( column ); | ||
// For cells that spans over multiple rows add also the last row that this cell spans over. | ||
if ( rowspan > 1 ) { | ||
rows.add( row + rowspan - 1 ); | ||
} | ||
// For cells that spans over multiple columns add also the last column that this cell spans over. | ||
if ( colspan > 1 ) { | ||
columns.add( column + colspan - 1 ); | ||
} | ||
areaOfSelectedCells += ( rowspan * colspan ); | ||
} | ||
// We can only merge table cells that are in adjacent rows... | ||
const areaOfValidSelection = getBiggestRectangleArea( rows, columns ); | ||
return areaOfValidSelection == areaOfSelectedCells; | ||
} | ||
/** | ||
* Returns slot info of cells that starts above and overlaps a given row. | ||
* | ||
* In a table below, passing `overlapRow = 3` | ||
* | ||
* ┌───┬───┬───┬───┬───┐ | ||
* 0 │ a │ b │ c │ d │ e │ | ||
* │ ├───┼───┼───┼───┤ | ||
* 1 │ │ f │ g │ h │ i │ | ||
* ├───┤ ├───┼───┤ │ | ||
* 2 │ j │ │ k │ l │ │ | ||
* │ │ │ ├───┼───┤ | ||
* 3 │ │ │ │ m │ n │ <- overlap row to check | ||
* ├───┼───┤ │ ├───│ | ||
* 4 │ o │ p │ │ │ q │ | ||
* └───┴───┴───┴───┴───┘ | ||
* | ||
* will return slot info for cells: "j", "f", "k". | ||
* | ||
* @param {module:engine/model/element~Element} table The table to check. | ||
* @param {Number} overlapRow The index of the row to check. | ||
* @param {Number} [startRow=0] A row to start analysis. Use it when it is known that the cells above that row will not overlap. | ||
* @returns {Array.<module:table/tablewalker~TableWalkerValue>} | ||
*/ | ||
export function getVerticallyOverlappingCells( table, overlapRow, startRow = 0 ) { | ||
const cells = []; | ||
const tableWalker = new TableWalker( table, { startRow, endRow: overlapRow - 1 } ); | ||
for ( const slotInfo of tableWalker ) { | ||
const { row, rowspan } = slotInfo; | ||
const cellEndRow = row + rowspan - 1; | ||
if ( row < overlapRow && overlapRow <= cellEndRow ) { | ||
cells.push( slotInfo ); | ||
} | ||
} | ||
return cells; | ||
} | ||
/** | ||
* Splits the table cell horizontally. | ||
* | ||
* @param {module:engine/model/element~Element} tableCell | ||
* @param {Number} splitRow | ||
* @param {module:engine/model/writer~Writer} writer | ||
*/ | ||
export function splitHorizontally( tableCell, splitRow, writer ) { | ||
const tableRow = tableCell.parent; | ||
const table = tableRow.parent; | ||
const rowIndex = tableRow.index; | ||
const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) ); | ||
const newRowspan = splitRow - rowIndex; | ||
const newCellAttributes = {}; | ||
const newCellRowSpan = rowspan - newRowspan; | ||
if ( newCellRowSpan > 1 ) { | ||
newCellAttributes.rowspan = newCellRowSpan; | ||
} | ||
const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 ); | ||
if ( colspan > 1 ) { | ||
newCellAttributes.colspan = colspan; | ||
} | ||
const startRow = rowIndex; | ||
const endRow = startRow + newRowspan; | ||
const tableMap = [ ...new TableWalker( table, { startRow, endRow, includeSpanned: true } ) ]; | ||
let columnIndex; | ||
for ( const { row, column, cell, cellIndex } of tableMap ) { | ||
if ( cell === tableCell && columnIndex === undefined ) { | ||
columnIndex = column; | ||
} | ||
if ( columnIndex !== undefined && columnIndex === column && row === endRow ) { | ||
const tableRow = table.getChild( row ); | ||
const tableCellPosition = writer.createPositionAt( tableRow, cellIndex ); | ||
createEmptyTableCell( writer, tableCellPosition, newCellAttributes ); | ||
} | ||
} | ||
// Update the rowspan attribute after updating table. | ||
updateNumericAttribute( 'rowspan', newRowspan, tableCell, writer ); | ||
} | ||
/** | ||
* Returns slot info of cells that starts before and overlaps a given column. | ||
* | ||
* In a table below, passing `overlapColumn = 3` | ||
* | ||
* 0 1 2 3 4 | ||
* ┌───────┬───────┬───┐ | ||
* │ a │ b │ c │ | ||
* │───┬───┴───────┼───┤ | ||
* │ d │ e │ f │ | ||
* ├───┼───┬───────┴───┤ | ||
* │ g │ h │ i │ | ||
* ├───┼───┼───┬───────┤ | ||
* │ j │ k │ l │ m │ | ||
* ├───┼───┴───┼───┬───┤ | ||
* │ n │ o │ p │ q │ | ||
* └───┴───────┴───┴───┘ | ||
* ^ | ||
* Overlap column to check | ||
* | ||
* will return slot info for cells: "b", "e", "i". | ||
* | ||
* @param {module:engine/model/element~Element} table The table to check. | ||
* @param {Number} overlapColumn The index of the column to check. | ||
* @returns {Array.<module:table/tablewalker~TableWalkerValue>} | ||
*/ | ||
export function getHorizontallyOverlappingCells( table, overlapColumn ) { | ||
const cellsToSplit = []; | ||
const tableWalker = new TableWalker( table ); | ||
for ( const slotInfo of tableWalker ) { | ||
const { column, colspan } = slotInfo; | ||
const cellEndColumn = column + colspan - 1; | ||
if ( column < overlapColumn && overlapColumn <= cellEndColumn ) { | ||
cellsToSplit.push( slotInfo ); | ||
} | ||
} | ||
return cellsToSplit; | ||
} | ||
/** | ||
* Splits the table cell vertically. | ||
* | ||
* @param {module:engine/model/element~Element} tableCell | ||
* @param {Number} columnIndex The table cell column index. | ||
* @param {Number} splitColumn The index of column to split cell on. | ||
* @param {module:engine/model/writer~Writer} writer | ||
*/ | ||
export function splitVertically( tableCell, columnIndex, splitColumn, writer ) { | ||
const colspan = parseInt( tableCell.getAttribute( 'colspan' ) ); | ||
const newColspan = splitColumn - columnIndex; | ||
const newCellAttributes = {}; | ||
const newCellColSpan = colspan - newColspan; | ||
if ( newCellColSpan > 1 ) { | ||
newCellAttributes.colspan = newCellColSpan; | ||
} | ||
const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 ); | ||
if ( rowspan > 1 ) { | ||
newCellAttributes.rowspan = rowspan; | ||
} | ||
createEmptyTableCell( writer, writer.createPositionAfter( tableCell ), newCellAttributes ); | ||
// Update the colspan attribute after updating table. | ||
updateNumericAttribute( 'colspan', newColspan, tableCell, writer ); | ||
} | ||
// Helper method to get an object with `first` and `last` indexes from an unsorted array of indexes. | ||
@@ -212,1 +445,59 @@ function getFirstLastIndexesObject( indexes ) { | ||
} | ||
// Calculates the area of a maximum rectangle that can span over the provided row & column indexes. | ||
// | ||
// @param {Array.<Number>} rows | ||
// @param {Array.<Number>} columns | ||
// @returns {Number} | ||
function getBiggestRectangleArea( rows, columns ) { | ||
const rowsIndexes = Array.from( rows.values() ); | ||
const columnIndexes = Array.from( columns.values() ); | ||
const lastRow = Math.max( ...rowsIndexes ); | ||
const firstRow = Math.min( ...rowsIndexes ); | ||
const lastColumn = Math.max( ...columnIndexes ); | ||
const firstColumn = Math.min( ...columnIndexes ); | ||
return ( lastRow - firstRow + 1 ) * ( lastColumn - firstColumn + 1 ); | ||
} | ||
// Checks if the selection does not mix a header (column or row) with other cells. | ||
// | ||
// For instance, in the table below valid selections consist of cells with the same letter only. | ||
// So, a-a (same heading row and column) or d-d (body cells) are valid while c-d or a-b are not. | ||
// | ||
// header columns | ||
// ↓ ↓ | ||
// ┌───┬───┬───┬───┐ | ||
// │ a │ a │ b │ b │ ← header row | ||
// ├───┼───┼───┼───┤ | ||
// │ c │ c │ d │ d │ | ||
// ├───┼───┼───┼───┤ | ||
// │ c │ c │ d │ d │ | ||
// └───┴───┴───┴───┘ | ||
// | ||
function areCellInTheSameTableSection( tableCells ) { | ||
const table = findAncestor( 'table', tableCells[ 0 ] ); | ||
const rowIndexes = getRowIndexes( tableCells ); | ||
const headingRows = parseInt( table.getAttribute( 'headingRows' ) || 0 ); | ||
// Calculating row indexes is a bit cheaper so if this check fails we can't merge. | ||
if ( !areIndexesInSameSection( rowIndexes, headingRows ) ) { | ||
return false; | ||
} | ||
const headingColumns = parseInt( table.getAttribute( 'headingColumns' ) || 0 ); | ||
const columnIndexes = getColumnIndexes( tableCells ); | ||
// Similarly cells must be in same column section. | ||
return areIndexesInSameSection( columnIndexes, headingColumns ); | ||
} | ||
// Unified check if table rows/columns indexes are in the same heading/body section. | ||
function areIndexesInSameSection( { first, last }, headingSectionSize ) { | ||
const firstCellIsInHeading = first < headingSectionSize; | ||
const lastCellIsInHeading = last < headingSectionSize; | ||
return firstCellIsInHeading === lastCellIsInHeading; | ||
} |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
828940
16
125
10903
21