/** * @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 ); } ); } } ); }() );