1154 lines
38 KiB
JavaScript
1154 lines
38 KiB
JavaScript
/**
|
|
* @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
|
|
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
|
|
*/
|
|
|
|
( function() {
|
|
'use strict';
|
|
|
|
var fakeSelectedClass = 'cke_table-faked-selection',
|
|
fakeSelectedEditorClass = fakeSelectedClass + '-editor',
|
|
fakeSelectedTableDataAttribute = 'cke-table-faked-selection-table',
|
|
fakeSelection = { active: false },
|
|
tabletools,
|
|
getSelectedCells,
|
|
getCellColIndex,
|
|
insertRow,
|
|
insertColumn;
|
|
|
|
function isWidget( element ) {
|
|
return CKEDITOR.plugins.widget && CKEDITOR.plugins.widget.isDomWidget( element );
|
|
}
|
|
|
|
function getCellsBetween( first, last ) {
|
|
var firstTable = first.getAscendant( 'table' ),
|
|
lastTable = last.getAscendant( 'table' ),
|
|
map = CKEDITOR.tools.buildTableMap( firstTable ),
|
|
startRow = getRowIndex( first ),
|
|
endRow = getRowIndex( last ),
|
|
cells = [],
|
|
markers = {},
|
|
start,
|
|
end,
|
|
i,
|
|
j,
|
|
cell;
|
|
|
|
// Support selection that began in outer's table, but ends in nested one.
|
|
if ( firstTable.contains( lastTable ) ) {
|
|
last = last.getAscendant( { td: 1, th: 1 } );
|
|
endRow = getRowIndex( last );
|
|
}
|
|
|
|
// First fetch start and end offset.
|
|
if ( startRow > endRow ) {
|
|
i = startRow;
|
|
startRow = endRow;
|
|
endRow = i;
|
|
|
|
i = first;
|
|
first = last;
|
|
last = i;
|
|
}
|
|
|
|
for ( i = 0; i < map[ startRow ].length; i++ ) {
|
|
if ( first.$ === map[ startRow ][ i ] ) {
|
|
start = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
for ( i = 0; i < map[ endRow ].length; i++ ) {
|
|
if ( last.$ === map[ endRow ][ i ] ) {
|
|
end = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ( start > end ) {
|
|
i = start;
|
|
start = end;
|
|
end = i;
|
|
}
|
|
|
|
for ( i = startRow; i <= endRow; i++ ) {
|
|
for ( j = start; j <= end; j++ ) {
|
|
// Table maps treat cells with colspan/rowspan as a separate cells, e.g.
|
|
// td[colspan=2] produces two adjacent cells in map. Therefore we mark
|
|
// all cells to know which were already processed.
|
|
cell = new CKEDITOR.dom.element( map[ i ][ j ] );
|
|
|
|
if ( cell.$ && !cell.getCustomData( 'selected_cell' ) ) {
|
|
cells.push( cell );
|
|
CKEDITOR.dom.element.setMarker( markers, cell, 'selected_cell', true );
|
|
}
|
|
}
|
|
}
|
|
|
|
CKEDITOR.dom.element.clearAllMarkers( markers );
|
|
|
|
return cells;
|
|
}
|
|
|
|
function detectLeftMouseButton( evt ) {
|
|
return CKEDITOR.tools.getMouseButton( evt ) === CKEDITOR.MOUSE_BUTTON_LEFT;
|
|
}
|
|
|
|
// Checks whether a given range fully contains a table element (cell/tbody/table etc).
|
|
// @param {CKEDITOR.dom.range} range
|
|
// @returns {Boolean}
|
|
function rangeContainsTableElement( range ) {
|
|
if ( range ) {
|
|
// Clone the range as we're going to enlarge it, and we don't want to modify the input.
|
|
range = range.clone();
|
|
|
|
range.enlarge( CKEDITOR.ENLARGE_ELEMENT );
|
|
|
|
var enclosedNode = range.getEnclosedNode();
|
|
|
|
return enclosedNode && enclosedNode.is && enclosedNode.is( CKEDITOR.dtd.$tableContent );
|
|
}
|
|
}
|
|
|
|
function getFakeSelectedTable( editor ) {
|
|
var selectedCell = editor.editable().findOne( '.' + fakeSelectedClass );
|
|
|
|
return selectedCell && selectedCell.getAscendant( 'table' );
|
|
}
|
|
|
|
function clearFakeCellSelection( editor, reset ) {
|
|
var selectedCells = editor.editable().find( '.' + fakeSelectedClass ),
|
|
i;
|
|
|
|
editor.fire( 'lockSnapshot' );
|
|
|
|
editor.editable().removeClass( fakeSelectedEditorClass );
|
|
|
|
for ( i = 0; i < selectedCells.count(); i++ ) {
|
|
selectedCells.getItem( i ).removeClass( fakeSelectedClass );
|
|
}
|
|
|
|
if ( selectedCells.count() > 0 ) {
|
|
selectedCells.getItem( 0 ).getAscendant( 'table' ).data( fakeSelectedTableDataAttribute, false );
|
|
}
|
|
|
|
editor.fire( 'unlockSnapshot' );
|
|
|
|
if ( reset ) {
|
|
fakeSelection = { active: false };
|
|
|
|
// Reset fake selection only if it's really a table one.
|
|
// Otherwise we'll make widget selection unusable.
|
|
if ( editor.getSelection().isInTable() ) {
|
|
editor.getSelection().reset();
|
|
}
|
|
}
|
|
}
|
|
|
|
function fakeSelectCells( editor, cells ) {
|
|
var ranges = [],
|
|
range,
|
|
i;
|
|
|
|
for ( i = 0; i < cells.length; i++ ) {
|
|
range = editor.createRange();
|
|
|
|
range.setStartBefore( cells[ i ] );
|
|
range.setEndAfter( cells[ i ] );
|
|
|
|
ranges.push( range );
|
|
}
|
|
|
|
editor.getSelection().selectRanges( ranges );
|
|
}
|
|
|
|
function restoreFakeSelection( editor ) {
|
|
var cells = editor.editable().find( '.' + fakeSelectedClass );
|
|
|
|
if ( cells.count() < 1 ) {
|
|
return;
|
|
}
|
|
|
|
cells = getCellsBetween( cells.getItem( 0 ), cells.getItem( cells.count() - 1 ) );
|
|
|
|
fakeSelectCells( editor, cells );
|
|
}
|
|
|
|
function fakeSelectByMouse( editor, cellOrTable, evt ) {
|
|
var selectedCells = getSelectedCells( editor.getSelection( true ) ),
|
|
cell = !cellOrTable.is( 'table' ) ? cellOrTable : null,
|
|
cells;
|
|
|
|
// getSelectedCells treats cells with cursor in them as also selected.
|
|
// We don't.
|
|
function areCellsReallySelected( selection, selectedCells ) {
|
|
var ranges = selection.getRanges();
|
|
|
|
if ( selectedCells.length > 1 || ( ranges[ 0 ] && !ranges[ 0 ].collapsed ) ) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Only start selecting when the fakeSelection.active is true (left mouse button is pressed)
|
|
// and there are some cells selected or the click was done in the table cell.
|
|
if ( fakeSelection.active && !fakeSelection.first &&
|
|
( cell || areCellsReallySelected( editor.getSelection(), selectedCells ) ) ) {
|
|
fakeSelection.first = cell || selectedCells[ 0 ];
|
|
fakeSelection.dirty = cell ? false : ( selectedCells.length !== 1 );
|
|
|
|
return;
|
|
}
|
|
|
|
if ( !fakeSelection.active ) {
|
|
return;
|
|
}
|
|
|
|
// We should check if the newly selected cell is still inside the same table (https://dev.ckeditor.com/ticket/17052, #493).
|
|
if ( cell && fakeSelection.first.getAscendant( 'table' ).equals( cell.getAscendant( 'table' ) ) ) {
|
|
cells = getCellsBetween( fakeSelection.first, cell );
|
|
|
|
// The selection is inside one cell, so we should allow native selection,
|
|
// but only in case if no other cell between mousedown and mouseup
|
|
// was selected.
|
|
// We don't want to clear selection if widget is event target (#1027).
|
|
if ( !fakeSelection.dirty && cells.length === 1 && !( isWidget( evt.data.getTarget() ) ) ) {
|
|
return clearFakeCellSelection( editor, evt.name === 'mouseup' );
|
|
}
|
|
|
|
fakeSelection.dirty = true;
|
|
fakeSelection.last = cell;
|
|
|
|
fakeSelectCells( editor, cells );
|
|
}
|
|
}
|
|
|
|
function fakeSelectionChangeHandler( evt ) {
|
|
var editor = evt.editor || evt.sender.editor,
|
|
selection = editor && editor.getSelection(),
|
|
ranges = selection && selection.getRanges() || [],
|
|
cells,
|
|
table,
|
|
i;
|
|
|
|
if ( !selection ) {
|
|
return;
|
|
}
|
|
|
|
clearFakeCellSelection( editor );
|
|
|
|
if ( !selection.isInTable() || !selection.isFake ) {
|
|
return;
|
|
}
|
|
|
|
// In case of whole nested table selection, getSelectedCells returns also
|
|
// cell which contains the table. We should filter it.
|
|
if ( ranges.length === 1 && ranges[ 0 ]._getTableElement() &&
|
|
ranges[ 0 ]._getTableElement().is( 'table' ) ) {
|
|
table = ranges[ 0 ]._getTableElement();
|
|
}
|
|
|
|
cells = getSelectedCells( selection, table );
|
|
|
|
editor.fire( 'lockSnapshot' );
|
|
|
|
for ( i = 0; i < cells.length; i++ ) {
|
|
cells[ i ].addClass( fakeSelectedClass );
|
|
}
|
|
|
|
if ( cells.length > 0 ) {
|
|
editor.editable().addClass( fakeSelectedEditorClass );
|
|
cells[ 0 ].getAscendant( 'table' ).data( fakeSelectedTableDataAttribute, '' );
|
|
}
|
|
|
|
editor.fire( 'unlockSnapshot' );
|
|
}
|
|
|
|
function getRowIndex( rowOrCell ) {
|
|
return rowOrCell.getAscendant( 'tr', true ).$.rowIndex;
|
|
}
|
|
|
|
function fakeSelectionMouseHandler( evt ) {
|
|
// Prevent of throwing error in console if target is undefined (#515).
|
|
if ( !evt.data.getTarget().getName ) {
|
|
return;
|
|
}
|
|
// Prevent applying table selection when widget is selected.
|
|
// Mouseup remains a possibility to finish table selection when user release mouse button above widget in table.
|
|
if ( evt.name !== 'mouseup' && isWidget( evt.data.getTarget() ) ) {
|
|
return;
|
|
}
|
|
|
|
var editor = evt.editor || evt.listenerData.editor,
|
|
selection = editor.getSelection( 1 ),
|
|
selectedTable = getFakeSelectedTable( editor ),
|
|
target = evt.data.getTarget(),
|
|
cell = target && target.getAscendant( { td: 1, th: 1 }, true ),
|
|
table = target && target.getAscendant( 'table', true ),
|
|
tableElements = { table: 1, thead: 1, tbody: 1, tfoot: 1, tr: 1, td: 1, th: 1 };
|
|
|
|
// Nested tables should be treated as the same one (e.g. user starts dragging from outer table
|
|
// and ends in inner one).
|
|
function isSameTable( selectedTable, table ) {
|
|
if ( !selectedTable || !table ) {
|
|
return false;
|
|
}
|
|
|
|
return selectedTable.equals( table ) || selectedTable.contains( table ) || table.contains( selectedTable ) ||
|
|
selectedTable.getCommonAncestor( table ).is( tableElements );
|
|
}
|
|
|
|
function isOutsideTable( node ) {
|
|
return !node.getAscendant( 'table', true ) && node.getDocument().equals( editor.document );
|
|
}
|
|
|
|
function canClearSelection( evt, selection, selectedTable, table ) {
|
|
// User starts click outside the table or not in the same table as in the previous selection.
|
|
if ( evt.name === 'mousedown' && ( detectLeftMouseButton( evt ) || !table ) ) {
|
|
return true;
|
|
}
|
|
|
|
// Covers a case when:
|
|
// 1. User releases mouse button outside the table.
|
|
// 2. User opens context menu not in the selected table.
|
|
if ( evt.name === 'mouseup' && !isOutsideTable( evt.data.getTarget() ) && !isSameTable( selectedTable, table ) ) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
if ( canClearSelection( evt, selection, selectedTable, table ) ) {
|
|
clearFakeCellSelection( editor, true );
|
|
}
|
|
|
|
// Start fake selection only if the left mouse button is really pressed inside the table.
|
|
if ( !fakeSelection.active && evt.name === 'mousedown' && detectLeftMouseButton( evt ) && table ) {
|
|
fakeSelection = { active: true };
|
|
|
|
// This listener covers case when mouse button is released outside the editor.
|
|
CKEDITOR.document.on( 'mouseup', fakeSelectionMouseHandler, null, { editor: editor } );
|
|
}
|
|
|
|
// The separate condition for table handles cases when user starts/stop dragging from/in
|
|
// spacing between cells.
|
|
if ( cell || table ) {
|
|
fakeSelectByMouse( editor, cell || table, evt );
|
|
}
|
|
|
|
if ( evt.name === 'mouseup' ) {
|
|
// If the selection ended outside of the table, there's a chance that selection was messed,
|
|
// e.g. by including text after the table. We should also cover selection inside nested tables
|
|
// that ends in outer table. In these cases, we just reselect cells.
|
|
if ( detectLeftMouseButton( evt ) &&
|
|
( isOutsideTable( evt.data.getTarget() ) || isSameTable( selectedTable, table ) ) ) {
|
|
restoreFakeSelection( editor );
|
|
}
|
|
|
|
fakeSelection = { active: false };
|
|
|
|
CKEDITOR.document.removeListener( 'mouseup', fakeSelectionMouseHandler );
|
|
}
|
|
}
|
|
|
|
function fakeSelectionDragHandler( evt ) {
|
|
var cell = evt.data.getTarget().getAscendant( { td: 1, th: 1 }, true );
|
|
|
|
if ( !cell || cell.hasClass( fakeSelectedClass ) ) {
|
|
return;
|
|
}
|
|
|
|
// We're not supporting dragging in our table selection for the time being.
|
|
evt.cancel();
|
|
evt.data.preventDefault();
|
|
}
|
|
|
|
function copyTable( editor, isCut ) {
|
|
var selection = editor.getSelection(),
|
|
bookmarks = selection.createBookmarks(),
|
|
doc = editor.document,
|
|
range = editor.createRange(),
|
|
docElement = doc.getDocumentElement().$,
|
|
needsScrollHack = CKEDITOR.env.ie && CKEDITOR.env.version < 9,
|
|
// [IE] Use span for copybin and its container to avoid bug with expanding editable height by
|
|
// absolutely positioned element.
|
|
copybinName = ( editor.blockless || CKEDITOR.env.ie ) ? 'span' : 'div',
|
|
copybin,
|
|
copybinContainer,
|
|
scrollTop,
|
|
listener;
|
|
|
|
function cancel( evt ) {
|
|
evt.cancel();
|
|
}
|
|
|
|
// We're still handling previous copy/cut.
|
|
// When keystroke is used to copy/cut this will also prevent
|
|
// conflict with copyTable called again for native copy/cut event.
|
|
if ( doc.getById( 'cke_table_copybin' ) ) {
|
|
return;
|
|
}
|
|
|
|
|
|
copybin = doc.createElement( copybinName );
|
|
copybinContainer = doc.createElement( copybinName );
|
|
|
|
copybinContainer.setAttributes( {
|
|
id: 'cke_table_copybin',
|
|
'data-cke-temp': '1'
|
|
} );
|
|
|
|
// Position copybin element outside current viewport.
|
|
copybin.setStyles( {
|
|
position: 'absolute',
|
|
width: '1px',
|
|
height: '1px',
|
|
overflow: 'hidden'
|
|
} );
|
|
|
|
copybin.setStyle( editor.config.contentsLangDirection == 'ltr' ? 'left' : 'right', '-5000px' );
|
|
|
|
copybin.setHtml( editor.getSelectedHtml( true ) );
|
|
|
|
// Ignore copybin.
|
|
editor.fire( 'lockSnapshot' );
|
|
|
|
copybinContainer.append( copybin );
|
|
editor.editable().append( copybinContainer );
|
|
|
|
listener = editor.on( 'selectionChange', cancel, null, null, 0 );
|
|
|
|
if ( needsScrollHack ) {
|
|
scrollTop = docElement.scrollTop;
|
|
}
|
|
|
|
// Once the clone of the table is inside of copybin, select
|
|
// the entire contents. This selection will be copied by the
|
|
// native browser's clipboard system.
|
|
range.selectNodeContents( copybin );
|
|
range.select();
|
|
|
|
if ( needsScrollHack ) {
|
|
docElement.scrollTop = scrollTop;
|
|
}
|
|
|
|
setTimeout( function() {
|
|
copybinContainer.remove();
|
|
|
|
selection.selectBookmarks( bookmarks );
|
|
listener.removeListener();
|
|
|
|
editor.fire( 'unlockSnapshot' );
|
|
|
|
if ( isCut ) {
|
|
editor.extractSelectedHtml();
|
|
editor.fire( 'saveSnapshot' );
|
|
}
|
|
}, 100 );
|
|
}
|
|
|
|
function fakeSelectionCopyCutHandler( evt ) {
|
|
var editor = evt.editor || evt.sender.editor,
|
|
selection = editor.getSelection();
|
|
|
|
if ( !selection.isInTable() ) {
|
|
return;
|
|
}
|
|
|
|
copyTable( editor, evt.name === 'cut' );
|
|
}
|
|
|
|
// A helper object abstracting table selection.
|
|
// By calling setSelectedCells() method it will automatically determine what's
|
|
// the first/last cell or row.
|
|
//
|
|
// Note: ATM the type does not make an actual selection, it just holds the data.
|
|
//
|
|
// @param {CKEDITOR.dom.element[]} [cells] An array of cells considered to be selected.
|
|
function TableSelection( cells ) {
|
|
this._reset();
|
|
|
|
if ( cells ) {
|
|
this.setSelectedCells( cells );
|
|
}
|
|
}
|
|
|
|
TableSelection.prototype = {};
|
|
|
|
// Resets the initial state of table selection.
|
|
TableSelection.prototype._reset = function() {
|
|
this.cells = {
|
|
first: null,
|
|
last: null,
|
|
all: []
|
|
};
|
|
|
|
this.rows = {
|
|
first: null,
|
|
last: null
|
|
};
|
|
};
|
|
|
|
// Sets the cells that are selected in the table. Based on this it figures out what cell is
|
|
// the first, and the last. Also sets rows property accordingly.
|
|
// Note: ATM the type does not make an actual selection, it just holds the data.
|
|
//
|
|
// @param {CKEDITOR.dom.element[]} [cells] An array of cells considered to be selected.
|
|
TableSelection.prototype.setSelectedCells = function( cells ) {
|
|
this._reset();
|
|
// Make sure we're not modifying input array.
|
|
cells = cells.slice( 0 );
|
|
this._arraySortByDOMOrder( cells );
|
|
|
|
this.cells.all = cells;
|
|
|
|
this.cells.first = cells[ 0 ];
|
|
this.cells.last = cells[ cells.length - 1 ];
|
|
|
|
this.rows.first = cells[ 0 ].getAscendant( 'tr' );
|
|
this.rows.last = this.cells.last.getAscendant( 'tr' );
|
|
};
|
|
|
|
// Returns a table map, returned by {@link CKEDITOR.tools#buildTableMap}.
|
|
// @returns {HTMLElement[]}
|
|
TableSelection.prototype.getTableMap = function() {
|
|
function getRealCellPosition( cell ) {
|
|
var table = cell.getAscendant( 'table' ),
|
|
rowIndex = getRowIndex( cell ),
|
|
map = CKEDITOR.tools.buildTableMap( table ),
|
|
i;
|
|
|
|
for ( i = 0; i < map[ rowIndex ].length; i++ ) {
|
|
if ( new CKEDITOR.dom.element( map[ rowIndex ][ i ] ).equals( cell ) ) {
|
|
return i;
|
|
}
|
|
}
|
|
}
|
|
|
|
var startIndex = getCellColIndex( this.cells.first ),
|
|
endIndex = getRealCellPosition( this.cells.last );
|
|
|
|
return CKEDITOR.tools.buildTableMap( this._getTable(), getRowIndex( this.rows.first ), startIndex,
|
|
getRowIndex( this.rows.last ), endIndex );
|
|
};
|
|
|
|
TableSelection.prototype._getTable = function() {
|
|
return this.rows.first.getAscendant( 'table' );
|
|
};
|
|
|
|
// @param {Number} count Number of rows to be inserted.
|
|
// @param {Boolean} [insertBefore=false] If set to `true` new rows will be prepended.
|
|
// @param {Boolean} [clearSelection=false] If set to `true`, it will set selected cells to the one inserted.
|
|
TableSelection.prototype.insertRow = function( count, insertBefore, clearSelection ) {
|
|
if ( typeof count === 'undefined' ) {
|
|
count = 1;
|
|
} else if ( count <= 0 ) {
|
|
return;
|
|
}
|
|
|
|
var cellIndexFirst = this.cells.first.$.cellIndex,
|
|
cellIndexLast = this.cells.last.$.cellIndex,
|
|
selectedCells = clearSelection ? [] : this.cells.all,
|
|
row,
|
|
newCells;
|
|
|
|
for ( var i = 0; i < count; i++ ) {
|
|
// In case of clearSelection we need explicitly use cached cells, as selectedCells is empty.
|
|
row = insertRow( clearSelection ? this.cells.all : selectedCells, insertBefore );
|
|
|
|
// Append cells from added row.
|
|
newCells = CKEDITOR.tools.array.filter( row.find( 'td, th' ).toArray(), function( cell ) {
|
|
return clearSelection ?
|
|
true : cell.$.cellIndex >= cellIndexFirst && cell.$.cellIndex <= cellIndexLast;
|
|
} );
|
|
|
|
// setSelectedCells will take care of refreshing the whole state at once.
|
|
if ( insertBefore ) {
|
|
selectedCells = newCells.concat( selectedCells );
|
|
} else {
|
|
selectedCells = selectedCells.concat( newCells );
|
|
}
|
|
}
|
|
|
|
this.setSelectedCells( selectedCells );
|
|
};
|
|
|
|
// @param {Number} count Number of columns to be inserted.
|
|
TableSelection.prototype.insertColumn = function( count ) {
|
|
if ( typeof count === 'undefined' ) {
|
|
count = 1;
|
|
} else if ( count <= 0 ) {
|
|
return;
|
|
}
|
|
|
|
var cells = this.cells,
|
|
selectedCells = cells.all,
|
|
minRowIndex = getRowIndex( cells.first ),
|
|
maxRowIndex = getRowIndex( cells.last );
|
|
|
|
function limitCells( cell ) {
|
|
var parentRowIndex = getRowIndex( cell );
|
|
|
|
return parentRowIndex >= minRowIndex && parentRowIndex <= maxRowIndex;
|
|
}
|
|
|
|
for ( var i = 0; i < count; i++ ) {
|
|
// Prepend added cells, then pass it to setSelectionCells so that it will take care of refreshing
|
|
// the whole state. Note that returned cells needs to be filtered, so that only cells that
|
|
// should get selected are added to the selectedCells array.
|
|
selectedCells = selectedCells.concat( CKEDITOR.tools.array.filter( insertColumn( selectedCells ), limitCells ) );
|
|
}
|
|
|
|
this.setSelectedCells( selectedCells );
|
|
};
|
|
|
|
// Clears the content of selected cells.
|
|
//
|
|
// @param {CKEDITOR.dom.element[]} [cells] If given, this cells will be cleared.
|
|
TableSelection.prototype.emptyCells = function( cells ) {
|
|
cells = cells || this.cells.all;
|
|
|
|
for ( var i = 0; i < cells.length; i++ ) {
|
|
cells[ i ].setHtml( '' );
|
|
}
|
|
};
|
|
|
|
// Sorts given arr according to DOM position.
|
|
//
|
|
// @param {CKEDITOR.dom.node[]} arr
|
|
TableSelection.prototype._arraySortByDOMOrder = function( arr ) {
|
|
arr.sort( function( el1, el2 ) {
|
|
return el1.getPosition( el2 ) & CKEDITOR.POSITION_PRECEDING ? -1 : 1;
|
|
} );
|
|
};
|
|
|
|
var fakeSelectionPasteHandler = {
|
|
onPaste: pasteListener,
|
|
// Check if the selection is collapsed on the beginning of the row (1) or at the end (2).
|
|
isBoundarySelection: function( selection ) {
|
|
var ranges = selection.getRanges(),
|
|
range = ranges[ 0 ],
|
|
row = range.endContainer.getAscendant( 'tr', true );
|
|
|
|
if ( row && range.collapsed ) {
|
|
if ( range.checkBoundaryOfElement( row, CKEDITOR.START ) ) {
|
|
return 1;
|
|
} else if ( range.checkBoundaryOfElement( row, CKEDITOR.END ) ) {
|
|
return 2;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
},
|
|
|
|
// Looks for a table in a given pasted content string. Returns it as a
|
|
// CKEDITOR.dom.element instance or null if mixed content, or more than one table found.
|
|
findTableInPastedContent: function( editor, dataValue ) {
|
|
var dataProcessor = editor.dataProcessor,
|
|
tmpContainer = new CKEDITOR.dom.element( 'body' );
|
|
|
|
if ( !dataProcessor ) {
|
|
dataProcessor = new CKEDITOR.htmlDataProcessor( editor );
|
|
}
|
|
|
|
// Pasted value must be filtered using dataProcessor to strip all unsafe code
|
|
// before inserting it into temporary container.
|
|
tmpContainer.setHtml( dataProcessor.toHtml( dataValue ), {
|
|
fixForBody: false
|
|
} );
|
|
|
|
return tmpContainer.getChildCount() > 1 ? null : tmpContainer.findOne( 'table' );
|
|
},
|
|
|
|
// Performs an actual paste into selectedTableMap based on content in pastedTableMap.
|
|
pasteTable: function( tableSel, selectedTableMap, pastedTableMap ) {
|
|
var cellToReplace,
|
|
// Index of first selected cell, it needs to be reused later, to calculate the
|
|
// proper position of newly pasted cells.
|
|
startIndex = getCellColIndex( tableSel.cells.first ),
|
|
selectedTable = tableSel._getTable(),
|
|
markers = {},
|
|
currentRow,
|
|
prevCell,
|
|
cellToPaste,
|
|
i,
|
|
j;
|
|
|
|
// And now paste!
|
|
for ( i = 0; i < pastedTableMap.length; i++ ) {
|
|
currentRow = new CKEDITOR.dom.element( selectedTable.$.rows[ tableSel.rows.first.$.rowIndex + i ] );
|
|
|
|
for ( j = 0; j < pastedTableMap[ i ].length; j++ ) {
|
|
cellToPaste = new CKEDITOR.dom.element( pastedTableMap[ i ][ j ] );
|
|
|
|
if ( selectedTableMap[ i ] && selectedTableMap[ i ][ j ] ) {
|
|
cellToReplace = new CKEDITOR.dom.element( selectedTableMap[ i ][ j ] );
|
|
} else {
|
|
cellToReplace = null;
|
|
}
|
|
|
|
// Only try to paste cells that aren't already pasted (it can occur if the pasted cell
|
|
// has [colspan] or [rowspan]).
|
|
if ( cellToPaste && !cellToPaste.getCustomData( 'processed' ) ) {
|
|
// If the cell to being replaced has [colspan], it could have been already
|
|
// replaced. In that case, it won't have parent.
|
|
if ( cellToReplace && cellToReplace.getParent() ) {
|
|
cellToPaste.replace( cellToReplace );
|
|
} else if ( j === 0 || pastedTableMap[ i ][ j - 1 ] ) {
|
|
if ( j !== 0 ) {
|
|
prevCell = new CKEDITOR.dom.element( pastedTableMap[ i ][ j - 1 ] );
|
|
} else {
|
|
prevCell = null;
|
|
}
|
|
|
|
// If the cell that should be replaced is not in the table, we must cover at least 3 cases:
|
|
// 1. Pasting cell in the same row as the previous pasted cell.
|
|
// 2. Pasting cell into the next row at the proper position.
|
|
// 3. If the selection started from the left edge of the table,
|
|
// prepending the proper row with the cell.
|
|
if ( prevCell && currentRow.equals( prevCell.getParent() ) ) {
|
|
cellToPaste.insertAfter( prevCell );
|
|
} else if ( startIndex > 0 ) {
|
|
// It might happen that there's no cell with startIndex, as it might be used by a rowspan.
|
|
if ( currentRow.$.cells[ startIndex ] ) {
|
|
cellToPaste.insertAfter( new CKEDITOR.dom.element( currentRow.$.cells[ startIndex ] ) );
|
|
} else {
|
|
// Since rowspans are erased from current selection, we want need to append a cell.
|
|
currentRow.append( cellToPaste );
|
|
}
|
|
} else {
|
|
currentRow.append( cellToPaste, true );
|
|
}
|
|
}
|
|
|
|
CKEDITOR.dom.element.setMarker( markers, cellToPaste, 'processed', true );
|
|
} else if ( cellToPaste.getCustomData( 'processed' ) && cellToReplace ) {
|
|
// If the cell was already pasted, but the cell to replace still exists (e.g. pasted
|
|
// cell has [colspan]), remove it.
|
|
cellToReplace.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
CKEDITOR.dom.element.clearAllMarkers( markers );
|
|
}
|
|
};
|
|
|
|
function pasteListener( evt ) {
|
|
var editor = evt.editor,
|
|
selection = editor.getSelection(),
|
|
selectedCells = getSelectedCells( selection ),
|
|
pastedTable = this.findTableInPastedContent( editor, evt.data.dataValue ),
|
|
boundarySelection = selection.isInTable( true ) && this.isBoundarySelection( selection ),
|
|
tableSel,
|
|
selectedTable,
|
|
selectedTableMap,
|
|
pastedTableMap;
|
|
|
|
function getLongestRowLength( map ) {
|
|
return Math.max.apply( null, CKEDITOR.tools.array.map( map, function( rowMap ) {
|
|
return rowMap.length;
|
|
}, 0 ) );
|
|
}
|
|
|
|
function selectCellContents( cell ) {
|
|
var range = editor.createRange();
|
|
|
|
range.selectNodeContents( cell );
|
|
range.select();
|
|
}
|
|
|
|
// Do not customize paste process in following cases:
|
|
// No cells are selected.
|
|
if ( !selectedCells.length ||
|
|
// It's single range that does not fully contain table element and is not boundary, e.g. collapsed selection within
|
|
// cell, part of cell etc.
|
|
( selectedCells.length === 1 && !rangeContainsTableElement( selection.getRanges()[ 0 ] ) && !boundarySelection ) ||
|
|
// It's a boundary position but with no table pasted.
|
|
( boundarySelection && !pastedTable ) ) {
|
|
return;
|
|
}
|
|
|
|
selectedTable = selectedCells[ 0 ].getAscendant( 'table' );
|
|
tableSel = new TableSelection( getSelectedCells( selection, selectedTable ) );
|
|
|
|
function getLastArrayItem( arr ) {
|
|
return arr[ arr.length - 1 ];
|
|
}
|
|
|
|
// Schedule selecting appropriate table cells after pasting. It covers both table and not-table
|
|
// content (#520).
|
|
editor.once( 'afterPaste', function() {
|
|
var toSelect = pastedTableMap ?
|
|
getCellsBetween( new CKEDITOR.dom.element( pastedTableMap[ 0 ][ 0 ] ),
|
|
new CKEDITOR.dom.element( getLastArrayItem( getLastArrayItem( pastedTableMap ) ) ) ) :
|
|
tableSel.cells.all;
|
|
|
|
fakeSelectCells( editor, toSelect );
|
|
} );
|
|
|
|
// In case of mixed content or non table content just select first cell, and erase content of other selected cells.
|
|
// Selection is left in first cell, so that default CKEditor logic puts pasted content in the selection (#520).
|
|
if ( !pastedTable ) {
|
|
selectCellContents( tableSel.cells.first );
|
|
|
|
// Due to limitations of our undo manager, in case of mixed content
|
|
// cells must be emptied after pasting (#520).
|
|
editor.once( 'afterPaste', function() {
|
|
editor.fire( 'lockSnapshot' );
|
|
tableSel.emptyCells( tableSel.cells.all.slice( 1 ) );
|
|
// Reselecting cells allows to create correct undo snapshot (#763).
|
|
fakeSelectCells( editor, tableSel.cells.all );
|
|
editor.fire( 'unlockSnapshot' );
|
|
} );
|
|
|
|
return;
|
|
}
|
|
|
|
// Preventing other paste handlers should be done after all early returns (#520).
|
|
evt.stop();
|
|
|
|
// In case of boundary selection, insert new row before/after selected one, select it
|
|
// and resume the rest of the algorithm.
|
|
if ( boundarySelection ) {
|
|
tableSel.insertRow( 1, boundarySelection === 1, true );
|
|
selection.selectElement( tableSel.rows.first );
|
|
} else {
|
|
// Otherwise simply clear all the selected cells.
|
|
tableSel.emptyCells();
|
|
// Reselecting cells allows to create correct undo snapshot (#763).
|
|
fakeSelectCells( editor, tableSel.cells.all );
|
|
}
|
|
|
|
// Build table map only for selected fragment.
|
|
selectedTableMap = tableSel.getTableMap();
|
|
pastedTableMap = CKEDITOR.tools.buildTableMap( pastedTable );
|
|
|
|
tableSel.insertRow( pastedTableMap.length - selectedTableMap.length );
|
|
|
|
// Now we compare the dimensions of the pasted table and the selected one.
|
|
// If the pasted one is bigger, we add missing rows and columns.
|
|
tableSel.insertColumn( getLongestRowLength( pastedTableMap ) - getLongestRowLength( selectedTableMap ) );
|
|
|
|
// Rebuild map for selected table.
|
|
selectedTableMap = tableSel.getTableMap();
|
|
|
|
this.pasteTable( tableSel, selectedTableMap, pastedTableMap );
|
|
|
|
editor.fire( 'saveSnapshot' );
|
|
|
|
// Manually fire afterPaste event as we stop pasting to handle everything via our custom handler.
|
|
setTimeout( function() {
|
|
editor.fire( 'afterPaste' );
|
|
}, 0 );
|
|
}
|
|
|
|
function customizeTableCommand( editor, cmds, callback ) {
|
|
editor.on( 'beforeCommandExec', function( evt ) {
|
|
if ( CKEDITOR.tools.array.indexOf( cmds, evt.data.name ) !== -1 ) {
|
|
evt.data.selectedCells = getSelectedCells( editor.getSelection() );
|
|
}
|
|
} );
|
|
|
|
editor.on( 'afterCommandExec', function( evt ) {
|
|
if ( CKEDITOR.tools.array.indexOf( cmds, evt.data.name ) !== -1 ) {
|
|
callback( editor, evt.data );
|
|
}
|
|
} );
|
|
}
|
|
|
|
/**
|
|
* Namespace providing a set of helper functions for working with tables, exposed by
|
|
* [Table Selection](https://ckeditor.com/cke4/addon/tableselection) plugin.
|
|
*
|
|
* @since 4.7.0
|
|
* @singleton
|
|
* @class CKEDITOR.plugins.tableselection
|
|
*/
|
|
CKEDITOR.plugins.tableselection = {
|
|
/**
|
|
* Fetches all cells between cells passed as parameters, including these cells.
|
|
*
|
|
* @param {CKEDITOR.dom.element} first The first cell to fetch.
|
|
* @param {CKEDITOR.dom.element} last The last cell to fetch.
|
|
* @return {CKEDITOR.dom.element[]} Array of fetched cells.
|
|
*/
|
|
getCellsBetween: getCellsBetween,
|
|
|
|
/**
|
|
* Adds keyboard integration for table selection in a given editor.
|
|
*
|
|
* @param {CKEDITOR.editor} editor
|
|
* @private
|
|
*/
|
|
keyboardIntegration: function( editor ) {
|
|
// Handle left, up, right, down, delete and backspace keystrokes inside table fake selection.
|
|
function getTableOnKeyDownListener( editor ) {
|
|
var keystrokes = {
|
|
37: 1, // Left Arrow
|
|
38: 1, // Up Arrow
|
|
39: 1, // Right Arrow,
|
|
40: 1, // Down Arrow
|
|
8: 1, // Backspace
|
|
46: 1 // Delete
|
|
},
|
|
tags = CKEDITOR.tools.extend( { table: 1 }, CKEDITOR.dtd.$tableContent );
|
|
|
|
delete tags.td;
|
|
delete tags.th;
|
|
|
|
// Called when removing empty subseleciton of the table.
|
|
// It should not allow for removing part of table, e.g. when user attempts to remove 2 cells
|
|
// out of 4 in row. It should however remove whole row or table, if it was fully selected.
|
|
function deleteEmptyTablePart( node, ranges ) {
|
|
if ( !ranges.length ) {
|
|
return null;
|
|
}
|
|
|
|
var rng = editor.createRange(),
|
|
mergedRanges = CKEDITOR.dom.range.mergeRanges( ranges );
|
|
|
|
// Enlarge each range, so that it wraps over tr.
|
|
CKEDITOR.tools.array.forEach( mergedRanges, function( mergedRange ) {
|
|
mergedRange.enlarge( CKEDITOR.ENLARGE_ELEMENT );
|
|
} );
|
|
|
|
var boundaryNodes = mergedRanges[ 0 ].getBoundaryNodes(),
|
|
startNode = boundaryNodes.startNode,
|
|
endNode = boundaryNodes.endNode;
|
|
|
|
if ( startNode && startNode.is && startNode.is( tags ) ) {
|
|
// A node that will receive selection after the firstRangeContainedNode is removed.
|
|
var boundaryTable = startNode.getAscendant( 'table', true ),
|
|
targetNode = startNode.getPreviousSourceNode( false, CKEDITOR.NODE_ELEMENT, boundaryTable ),
|
|
selectBeginning = false,
|
|
matchingElement = function( elem ) {
|
|
// We're interested in matching only td/th but not contained by the startNode since it will be removed.
|
|
// Technically none of startNode children should be visited but it will due to https://dev.ckeditor.com/ticket/12191.
|
|
return !startNode.contains( elem ) && elem.is && elem.is( 'td', 'th' );
|
|
};
|
|
|
|
while ( targetNode && !matchingElement( targetNode ) ) {
|
|
targetNode = targetNode.getPreviousSourceNode( false, CKEDITOR.NODE_ELEMENT, boundaryTable );
|
|
}
|
|
|
|
if ( !targetNode && endNode && endNode.is && !endNode.is( 'table' ) && endNode.getNext() ) {
|
|
// Special case: say we were removing the first row, so there are no more tds before, check if there's a cell after removed row.
|
|
targetNode = endNode.getNext().findOne( 'td, th' );
|
|
// In that particular case we want to select beginning.
|
|
selectBeginning = true;
|
|
}
|
|
|
|
if ( !targetNode ) {
|
|
// As a last resort of defence we'll put the selection before (about to be) removed table.
|
|
rng.setStartBefore( startNode.getAscendant( 'table', true ) );
|
|
rng.collapse( true );
|
|
} else {
|
|
rng[ 'moveToElementEdit' + ( selectBeginning ? 'Start' : 'End' ) ]( targetNode );
|
|
}
|
|
|
|
mergedRanges[ 0 ].deleteContents();
|
|
|
|
return [ rng ];
|
|
}
|
|
|
|
// By default return a collapsed selection in a first cell.
|
|
if ( startNode ) {
|
|
rng.moveToElementEditablePosition( startNode );
|
|
return [ rng ];
|
|
}
|
|
}
|
|
|
|
return function( evt ) {
|
|
// Use getKey directly in order to ignore modifiers.
|
|
// Justification: https://dev.ckeditor.com/ticket/11861#comment:13
|
|
var keystroke = evt.data.getKey(),
|
|
selection,
|
|
toStart = keystroke === 37 || keystroke == 38,
|
|
ranges,
|
|
firstCell,
|
|
lastCell,
|
|
i;
|
|
|
|
// Handle only left/right/del/bspace keys.
|
|
if ( !keystrokes[ keystroke ] ) {
|
|
return;
|
|
}
|
|
|
|
selection = editor.getSelection();
|
|
|
|
if ( !selection || !selection.isInTable() || !selection.isFake ) {
|
|
return;
|
|
}
|
|
|
|
ranges = selection.getRanges();
|
|
firstCell = ranges[ 0 ]._getTableElement();
|
|
lastCell = ranges[ ranges.length - 1 ]._getTableElement();
|
|
|
|
evt.data.preventDefault();
|
|
evt.cancel();
|
|
|
|
if ( keystroke > 8 && keystroke < 46 ) {
|
|
// Arrows.
|
|
ranges[ 0 ].moveToElementEditablePosition( toStart ? firstCell : lastCell, !toStart );
|
|
selection.selectRanges( [ ranges[ 0 ] ] );
|
|
} else {
|
|
// Delete.
|
|
for ( i = 0; i < ranges.length; i++ ) {
|
|
clearCellInRange( ranges[ i ] );
|
|
}
|
|
|
|
var newRanges = deleteEmptyTablePart( firstCell, ranges );
|
|
|
|
if ( newRanges ) {
|
|
ranges = newRanges;
|
|
} else {
|
|
// If no new range was returned fallback to selecting first cell.
|
|
ranges[ 0 ].moveToElementEditablePosition( firstCell );
|
|
}
|
|
|
|
selection.selectRanges( ranges );
|
|
editor.fire( 'saveSnapshot' );
|
|
}
|
|
};
|
|
}
|
|
|
|
function tableKeyPressListener( evt ) {
|
|
var selection = editor.getSelection(),
|
|
// Enter key also produces character, but Firefox doesn't think so (gh#415).
|
|
isCharKey = evt.data.$.charCode || ( evt.data.getKey() === 13 ),
|
|
ranges,
|
|
firstCell,
|
|
i;
|
|
|
|
// We must check if the event really did not produce any character as it's fired for all keys in Gecko.
|
|
if ( !selection || !selection.isInTable() || !selection.isFake || !isCharKey ||
|
|
evt.data.getKeystroke() & CKEDITOR.CTRL ) {
|
|
return;
|
|
}
|
|
|
|
ranges = selection.getRanges();
|
|
firstCell = ranges[ 0 ].getEnclosedNode().getAscendant( { td: 1, th: 1 }, true );
|
|
|
|
for ( i = 0; i < ranges.length; i++ ) {
|
|
clearCellInRange( ranges[ i ] );
|
|
}
|
|
|
|
// In case of selection of table element, there won't be any cell (#867).
|
|
if ( firstCell ) {
|
|
ranges[ 0 ].moveToElementEditablePosition( firstCell );
|
|
selection.selectRanges( [ ranges[ 0 ] ] );
|
|
}
|
|
}
|
|
|
|
function clearCellInRange( range ) {
|
|
var node = range.getEnclosedNode();
|
|
|
|
// Set text only in case of table cells, otherwise remove whole element (#867).
|
|
if ( node && node.is( { td: 1, th: 1 } ) ) {
|
|
range.getEnclosedNode().setText( '' );
|
|
} else {
|
|
range.deleteContents();
|
|
}
|
|
|
|
CKEDITOR.tools.array.forEach( range._find( 'td' ), function( cell ) {
|
|
// Cells that were not removed, need to contain bogus BR (if needed), otherwise row might
|
|
// collapse. (tp#2270)
|
|
cell.appendBogus();
|
|
} );
|
|
}
|
|
|
|
// Automatically select non-editable element when navigating into
|
|
// it by left/right or backspace/del keys.
|
|
var editable = editor.editable();
|
|
editable.attachListener( editable, 'keydown', getTableOnKeyDownListener( editor ), null, null, -1 );
|
|
editable.attachListener( editable, 'keypress', tableKeyPressListener, null, null, -1 );
|
|
},
|
|
|
|
/**
|
|
* Determines whether table selection is supported in the current environment.
|
|
*
|
|
* @property {Boolean}
|
|
* @private
|
|
*/
|
|
isSupportedEnvironment: !( CKEDITOR.env.ie && CKEDITOR.env.version < 11 )
|
|
};
|
|
|
|
CKEDITOR.plugins.add( 'tableselection', {
|
|
requires: 'clipboard,tabletools',
|
|
|
|
onLoad: function() {
|
|
// We can't alias these features earlier, as they could be still not loaded.
|
|
tabletools = CKEDITOR.plugins.tabletools;
|
|
getSelectedCells = tabletools.getSelectedCells;
|
|
getCellColIndex = tabletools.getCellColIndex;
|
|
insertRow = tabletools.insertRow;
|
|
insertColumn = tabletools.insertColumn;
|
|
|
|
CKEDITOR.document.appendStyleSheet( this.path + 'styles/tableselection.css' );
|
|
},
|
|
|
|
init: function( editor ) {
|
|
// Disable unsupported browsers.
|
|
if ( !CKEDITOR.plugins.tableselection.isSupportedEnvironment ) {
|
|
return;
|
|
}
|
|
|
|
// Add styles for fake visual selection.
|
|
if ( editor.addContentsCss ) {
|
|
editor.addContentsCss( this.path + 'styles/tableselection.css' );
|
|
}
|
|
|
|
editor.on( 'contentDom', function() {
|
|
var editable = editor.editable(),
|
|
mouseHost = editable.isInline() ? editable : editor.document,
|
|
evtInfo = { editor: editor };
|
|
|
|
// Explicitly set editor as DOM events generated on document does not convey information about it.
|
|
editable.attachListener( mouseHost, 'mousedown', fakeSelectionMouseHandler, null, evtInfo );
|
|
editable.attachListener( mouseHost, 'mousemove', fakeSelectionMouseHandler, null, evtInfo );
|
|
editable.attachListener( mouseHost, 'mouseup', fakeSelectionMouseHandler, null, evtInfo );
|
|
|
|
editable.attachListener( editable, 'dragstart', fakeSelectionDragHandler );
|
|
editable.attachListener( editor, 'selectionCheck', fakeSelectionChangeHandler );
|
|
|
|
CKEDITOR.plugins.tableselection.keyboardIntegration( editor );
|
|
|
|
// Setup copybin.
|
|
if ( CKEDITOR.plugins.clipboard && !CKEDITOR.plugins.clipboard.isCustomCopyCutSupported ) {
|
|
editable.attachListener( editable, 'cut', fakeSelectionCopyCutHandler );
|
|
editable.attachListener( editable, 'copy', fakeSelectionCopyCutHandler );
|
|
}
|
|
} );
|
|
|
|
editor.on( 'paste', fakeSelectionPasteHandler.onPaste, fakeSelectionPasteHandler );
|
|
|
|
customizeTableCommand( editor, [
|
|
'rowInsertBefore',
|
|
'rowInsertAfter',
|
|
'columnInsertBefore',
|
|
'columnInsertAfter',
|
|
'cellInsertBefore',
|
|
'cellInsertAfter'
|
|
], function( editor, data ) {
|
|
fakeSelectCells( editor, data.selectedCells );
|
|
} );
|
|
|
|
customizeTableCommand( editor, [
|
|
'cellMerge',
|
|
'cellMergeRight',
|
|
'cellMergeDown'
|
|
], function( editor, data ) {
|
|
fakeSelectCells( editor, [ data.commandData.cell ] );
|
|
} );
|
|
|
|
customizeTableCommand( editor, [
|
|
'cellDelete'
|
|
], function( editor ) {
|
|
clearFakeCellSelection( editor, true );
|
|
} );
|
|
}
|
|
} );
|
|
}() );
|