      1 /*
      2 Copyright (c) 2003-2009, CKSource - Frederico Knabben. All rights reserved.
      3 For licensing, see LICENSE.html or http://ckeditor.com/license
      4 */
      5
      6 /**
      7  * @file Insert and remove numbered and bulleted lists.
      8  */
      9
     10 (function()
     11 {
     12 	var listNodeNames = { ol : 1, ul : 1 },
     13 		emptyTextRegex = /^[\n\r\t ]*$/;
     14
     15 	CKEDITOR.plugins.list = {
     16 		/*
     17 		 * Convert a DOM list tree into a data structure that is easier to
     18 		 * manipulate. This operation should be non-intrusive in the sense that it
     19 		 * does not change the DOM tree, with the exception that it may add some
     20 		 * markers to the list item nodes when database is specified.
     21 		 */
     22 		listToArray : function( listNode, database, baseArray, baseIndentLevel, grandparentNode )
     23 		{
     24 			if ( !listNodeNames[ listNode.getName() ] )
     25 				return [];
     26
     27 			if ( !baseIndentLevel )
     28 				baseIndentLevel = 0;
     29 			if ( !baseArray )
     30 				baseArray = [];
     31
     32 			// Iterate over all list items to get their contents and look for inner lists.
     33 			for ( var i = 0, count = listNode.getChildCount() ; i < count ; i++ )
     34 			{
     35 				var listItem = listNode.getChild( i );
     36
     37 				// It may be a text node or some funny stuff.
     38 				if ( listItem.$.nodeName.toLowerCase() != 'li' )
     39 					continue;
     40 				var itemObj = { 'parent' : listNode, indent : baseIndentLevel, contents : [] };
     41 				if ( !grandparentNode )
     42 				{
     43 					itemObj.grandparent = listNode.getParent();
     44 					if ( itemObj.grandparent && itemObj.grandparent.$.nodeName.toLowerCase() == 'li' )
     45 						itemObj.grandparent = itemObj.grandparent.getParent();
     46 				}
     47 				else
     48 					itemObj.grandparent = grandparentNode;
     49
     50 				if ( database )
     51 					CKEDITOR.dom.element.setMarker( database, listItem, 'listarray_index', baseArray.length );
     52 				baseArray.push( itemObj );
     53
     54 				for ( var j = 0, itemChildCount = listItem.getChildCount() ; j < itemChildCount ; j++ )
     55 				{
     56 					var child = listItem.getChild( j );
     57 					if ( child.type == CKEDITOR.NODE_ELEMENT && listNodeNames[ child.getName() ] )
     58 						// Note the recursion here, it pushes inner list items with
     59 						// +1 indentation in the correct order.
     60 						CKEDITOR.plugins.list.listToArray( child, database, baseArray, baseIndentLevel + 1, itemObj.grandparent );
     61 					else
     62 						itemObj.contents.push( child );
     63 				}
     64 			}
     65 			return baseArray;
     66 		},
     67
     68 		// Convert our internal representation of a list back to a DOM forest.
     69 		arrayToList : function( listArray, database, baseIndex, paragraphMode )
     70 		{
     71 			if ( !baseIndex )
     72 				baseIndex = 0;
     73 			if ( !listArray || listArray.length < baseIndex + 1 )
     74 				return null;
     75 			var doc = listArray[ baseIndex ].parent.getDocument(),
     76 				retval = new CKEDITOR.dom.documentFragment( doc ),
     77 				rootNode = null,
     78 				currentIndex = baseIndex,
     79 				indentLevel = Math.max( listArray[ baseIndex ].indent, 0 ),
     80 				currentListItem = null,
     81 				paragraphName = ( paragraphMode == CKEDITOR.ENTER_P ? 'p' : 'div' );
     82 			while ( true )
     83 			{
     84 				var item = listArray[ currentIndex ];
     85 				if ( item.indent == indentLevel )
     86 				{
     87 					if ( !rootNode || listArray[ currentIndex ].parent.getName() != rootNode.getName() )
     88 					{
     89 						rootNode = listArray[ currentIndex ].parent.clone( false, true );
     90 						retval.append( rootNode );
     91 					}
     92 					currentListItem = rootNode.append( doc.createElement( 'li' ) );
     93 					for ( var i = 0 ; i < item.contents.length ; i++ )
     94 						currentListItem.append( item.contents[i].clone( true, true ) );
     95 					currentIndex++;
     96 				}
     97 				else if ( item.indent == Math.max( indentLevel, 0 ) + 1 )
     98 				{
     99 					var listData = CKEDITOR.plugins.list.arrayToList( listArray, null, currentIndex, paragraphMode );
    100 					currentListItem.append( listData.listNode );
    101 					currentIndex = listData.nextIndex;
    102 				}
    103 				else if ( item.indent == -1 && !baseIndex && item.grandparent )
    104 				{
    105 					currentListItem;
    106 					if ( listNodeNames[ item.grandparent.getName() ] )
    107 						currentListItem = doc.createElement( 'li' );
    108 					else
    109 					{
    110 						if ( paragraphMode != CKEDITOR.ENTER_BR && item.grandparent.getName() != 'td' )
    111 							currentListItem = doc.createElement( paragraphName );
    112 						else
    113 							currentListItem = new CKEDITOR.dom.documentFragment( doc );
    114 					}
    115
    116 					for ( i = 0 ; i < item.contents.length ; i++ )
    117 						currentListItem.append( item.contents[i].clone( true, true ) );
    118
    119 					if ( currentListItem.type == CKEDITOR.NODE_DOCUMENT_FRAGMENT )
    120 					{
    121 						if ( currentListItem.getLast()
    122 								&& currentListItem.getLast().type == CKEDITOR.NODE_ELEMENT
    123 								&& currentListItem.getLast().getAttribute( 'type' ) == '_moz' )
    124 							currentListItem.getLast().remove();
    125 						currentListItem.appendBogus();
    126 					}
    127
    128 					if ( currentListItem.type == CKEDITOR.NODE_ELEMENT &&
    129 							currentListItem.getName() == paragraphName &&
    130 							currentListItem.$.firstChild )
    131 					{
    132 						currentListItem.trim();
    133 						var firstChild = currentListItem.getFirst();
    134 						if ( firstChild.type == CKEDITOR.NODE_ELEMENT && firstChild.isBlockBoundary() )
    135 						{
    136 							var tmp = new CKEDITOR.dom.documentFragment( doc );
    137 							currentListItem.moveChildren( tmp );
    138 							currentListItem = tmp;
    139 						}
    140 					}
    141
    142 					var currentListItemName = currentListItem.$.nodeName.toLowerCase();
    143 					if ( !CKEDITOR.env.ie && ( currentListItemName == 'div' || currentListItemName == 'p' ) )
    144 						currentListItem.appendBogus();
    145 					retval.append( currentListItem );
    146 					rootNode = null;
    147 					currentIndex++;
    148 				}
    149 				else
    150 					return null;
    151
    152 				if ( listArray.length <= currentIndex || Math.max( listArray[ currentIndex ].indent, 0 ) < indentLevel )
    153 					break;
    154 			}
    155
    156 			// Clear marker attributes for the new list tree made of cloned nodes, if any.
    157 			if ( database )
    158 			{
    159 				var currentNode = retval.getFirst();
    160 				while ( currentNode )
    161 				{
    162 					if ( currentNode.type == CKEDITOR.NODE_ELEMENT )
    163 						CKEDITOR.dom.element.clearMarkers( database, currentNode );
    164 					currentNode = currentNode.getNextSourceNode();
    165 				}
    166 			}
    167
    168 			return { listNode : retval, nextIndex : currentIndex };
    169 		}
    170 	};
    171
    172 	function setState( editor, state )
    173 	{
    174 		editor.getCommand( this.name ).setState( state );
    175 	}
    176
    177 	function onSelectionChange( evt )
    178 	{
    179 		var elements = evt.data.path.elements;
    180
    181 		for ( var i = 0 ; i < elements.length ; i++ )
    182 		{
    183 			if ( listNodeNames[ elements[i].getName() ] )
    184 			{
    185 				return setState.call( this, evt.editor,
    186 						this.type == elements[i].getName() ? CKEDITOR.TRISTATE_ON : CKEDITOR.TRISTATE_OFF );
    187 			}
    188 		}
    189
    190 		return setState.call( this, evt.editor, CKEDITOR.TRISTATE_OFF );
    191 	}
    192
    193 	function changeListType( editor, groupObj, database, listsCreated )
    194 	{
    195 		// This case is easy...
    196 		// 1. Convert the whole list into a one-dimensional array.
    197 		// 2. Change the list type by modifying the array.
    198 		// 3. Recreate the whole list by converting the array to a list.
    199 		// 4. Replace the original list with the recreated list.
    200 		var listArray = CKEDITOR.plugins.list.listToArray( groupObj.root, database ),
    201 			selectedListItems = [];
    202
    203 		for ( var i = 0 ; i < groupObj.contents.length ; i++ )
    204 		{
    205 			var itemNode = groupObj.contents[i];
    206 			itemNode = itemNode.getAscendant( 'li', true );
    207 			if ( !itemNode || itemNode.getCustomData( 'list_item_processed' ) )
    208 				continue;
    209 			selectedListItems.push( itemNode );
    210 			CKEDITOR.dom.element.setMarker( database, itemNode, 'list_item_processed', true );
    211 		}
    212
    213 		var fakeParent = groupObj.root.getDocument().createElement( this.type );
    214 		for ( i = 0 ; i < selectedListItems.length ; i++ )
    215 		{
    216 			var listIndex = selectedListItems[i].getCustomData( 'listarray_index' );
    217 			listArray[listIndex].parent = fakeParent;
    218 		}
    219 		var newList = CKEDITOR.plugins.list.arrayToList( listArray, database, null, editor.config.enterMode );
    220 		var child, length = newList.listNode.getChildCount();
    221 		for ( i = 0 ; i < length && ( child = newList.listNode.getChild( i ) ) ; i++ )
    222 		{
    223 			if ( child.getName() == this.type )
    224 				listsCreated.push( child );
    225 		}
    226 		newList.listNode.replace( groupObj.root );
    227 	}
    228
    229 	function createList( editor, groupObj, listsCreated )
    230 	{
    231 		var contents = groupObj.contents,
    232 			doc = groupObj.root.getDocument(),
    233 			listContents = [];
    234
    235 		// It is possible to have the contents returned by DomRangeIterator to be the same as the root.
    236 		// e.g. when we're running into table cells.
    237 		// In such a case, enclose the childNodes of contents[0] into a <div>.
    238 		if ( contents.length == 1 && contents[0].equals( groupObj.root ) )
    239 		{
    240 			var divBlock = doc.createElement( 'div' );
    241 			contents[0].moveChildren && contents[0].moveChildren( divBlock );
    242 			contents[0].append( divBlock );
    243 			contents[0] = divBlock;
    244 		}
    245
    246 		// Calculate the common parent node of all content blocks.
    247 		var commonParent = groupObj.contents[0].getParent();
    248 		for ( var i = 0 ; i < contents.length ; i++ )
    249 			commonParent = commonParent.getCommonAncestor( contents[i].getParent() );
    250
    251 		// We want to insert things that are in the same tree level only, so calculate the contents again
    252 		// by expanding the selected blocks to the same tree level.
    253 		for ( i = 0 ; i < contents.length ; i++ )
    254 		{
    255 			var contentNode = contents[i],
    256 				parentNode;
    257 			while ( ( parentNode = contentNode.getParent() ) )
    258 			{
    259 				if ( parentNode.equals( commonParent ) )
    260 				{
    261 					listContents.push( contentNode );
    262 					break;
    263 				}
    264 				contentNode = parentNode;
    265 			}
    266 		}
    267
    268 		if ( listContents.length < 1 )
    269 			return;
    270
    271 		// Insert the list to the DOM tree.
    272 		var insertAnchor = listContents[ listContents.length - 1 ].getNext(),
    273 			listNode = doc.createElement( this.type );
    274
    275 		listsCreated.push( listNode );
    276 		while ( listContents.length )
    277 		{
    278 			var contentBlock = listContents.shift(),
    279 				listItem = doc.createElement( 'li' );
    280 			contentBlock.moveChildren( listItem );
    281 			contentBlock.remove();
    282 			listItem.appendTo( listNode );
    283
    284 			// Append a bogus BR to force the LI to render at full height
    285 			if ( !CKEDITOR.env.ie )
    286 				listItem.appendBogus();
    287 		}
    288 		if ( insertAnchor )
    289 			listNode.insertBefore( insertAnchor );
    290 		else
    291 			listNode.appendTo( commonParent );
    292 	}
    293
    294 	function removeList( editor, groupObj, database )
    295 	{
    296 		// This is very much like the change list type operation.
    297 		// Except that we're changing the selected items' indent to -1 in the list array.
    298 		var listArray = CKEDITOR.plugins.list.listToArray( groupObj.root, database ),
    299 			selectedListItems = [];
    300
    301 		for ( var i = 0 ; i < groupObj.contents.length ; i++ )
    302 		{
    303 			var itemNode = groupObj.contents[i];
    304 			itemNode = itemNode.getAscendant( 'li', true );
    305 			if ( !itemNode || itemNode.getCustomData( 'list_item_processed' ) )
    306 				continue;
    307 			selectedListItems.push( itemNode );
    308 			CKEDITOR.dom.element.setMarker( database, itemNode, 'list_item_processed', true );
    309 		}
    310
    311 		var lastListIndex = null;
    312 		for ( i = 0 ; i < selectedListItems.length ; i++ )
    313 		{
    314 			var listIndex = selectedListItems[i].getCustomData( 'listarray_index' );
    315 			listArray[listIndex].indent = -1;
    316 			lastListIndex = listIndex;
    317 		}
    318
    319 		// After cutting parts of the list out with indent=-1, we still have to maintain the array list
    320 		// model's nextItem.indent <= currentItem.indent + 1 invariant. Otherwise the array model of the
    321 		// list cannot be converted back to a real DOM list.
    322 		for ( i = lastListIndex + 1 ; i < listArray.length ; i++ )
    323 		{
    324 			if ( listArray[i].indent > listArray[i-1].indent + 1 )
    325 			{
    326 				var indentOffset = listArray[i-1].indent + 1 - listArray[i].indent;
    327 				var oldIndent = listArray[i].indent;
    328 				while ( listArray[i] && listArray[i].indent >= oldIndent )
    329 				{
    330 					listArray[i].indent += indentOffset;
    331 					i++;
    332 				}
    333 				i--;
    334 			}
    335 		}
    336
    337 		var newList = CKEDITOR.plugins.list.arrayToList( listArray, database, null, editor.config.enterMode );
    338 		// If groupObj.root is the last element in its parent, or its nextSibling is a <br>, then we should
    339 		// not add a <br> after the final item. So, check for the cases and trim the <br>.
    340 		if ( !groupObj.root.getNext() || groupObj.root.getNext().$.nodeName.toLowerCase() == 'br' )
    341 		{
    342 			if ( newList.listNode.getLast().$.nodeName.toLowerCase() == 'br' )
    343 				newList.listNode.getLast().remove();
    344 		}
    345 		newList.listNode.replace( groupObj.root );
    346 	}
    347
    348 	function listCommand( name, type )
    349 	{
    350 		this.name = name;
    351 		this.type = type;
    352 	}
    353
    354 	listCommand.prototype = {
    355 		exec : function( editor )
    356 		{
    357 			editor.focus();
    358
    359 			var doc = editor.document,
    360 				selection = editor.getSelection(),
    361 				ranges = selection && selection.getRanges();
    362
    363 			// There should be at least one selected range.
    364 			if ( !ranges || ranges.length < 1 )
    365 				return;
    366
    367 			// Midas lists rule #1 says we can create a list even in an empty document.
    368 			// But DOM iterator wouldn't run if the document is really empty.
    369 			// So create a paragraph if the document is empty and we're going to create a list.
    370 			if ( this.state == CKEDITOR.TRISTATE_OFF )
    371 			{
    372 				var body = doc.getBody();
    373 				body.trim();
    374 				if ( !body.getFirst() )
    375 				{
    376 					var paragraph = doc.createElement( editor.config.enterMode == CKEDITOR.ENTER_P ? 'p' :
    377 							( editor.config.enterMode == CKEDITOR.ENTER_DIV ? 'div' : 'br' ) );
    378 					paragraph.appendTo( body );
    379 					ranges = [ new CKEDITOR.dom.range( doc ) ];
    380 					// IE exception on inserting anything when anchor inside <br>.
    381 					if ( paragraph.is( 'br' ) )
    382 					{
    383 						ranges[ 0 ].setStartBefore( paragraph );
    384 						ranges[ 0 ].setEndAfter( paragraph );
    385 					}
    386 					else
    387 						ranges[ 0 ].selectNodeContents( paragraph );
    388 					selection.selectRanges( ranges );
    389 				}
    390 			}
    391
    392 			var bookmarks = selection.createBookmarks( true );
    393
    394 			// Group the blocks up because there are many cases where multiple lists have to be created,
    395 			// or multiple lists have to be cancelled.
    396 			var listGroups = [],
    397 				database = {};
    398
    399 			while ( ranges.length > 0 )
    400 			{
    401 				var range = ranges.shift(),
    402 					boundaryNodes = range.getBoundaryNodes(),
    403 					startNode = boundaryNodes.startNode,
    404 					endNode = boundaryNodes.endNode;
    405 				if ( startNode.type == CKEDITOR.NODE_ELEMENT && startNode.getName() == 'td' )
    406 					range.setStartAt( boundaryNodes.startNode, CKEDITOR.POSITION_AFTER_START );
    407 				if ( endNode.type == CKEDITOR.NODE_ELEMENT && endNode.getName() == 'td' )
    408 					range.setEndAt( boundaryNodes.endNode, CKEDITOR.POSITION_BEFORE_END );
    409
    410 				var iterator = range.createIterator(),
    411 					block;
    412 				iterator.forceBrBreak = ( this.state == CKEDITOR.TRISTATE_OFF );
    413
    414 				while ( ( block = iterator.getNextParagraph() ) )
    415 				{
    416 					var path = new CKEDITOR.dom.elementPath( block ),
    417 						listNode = null,
    418 						processedFlag = false,
    419 						blockLimit = path.blockLimit;
    420
    421 					// First, try to group by a list ancestor.
    422 					for ( var i = 0 ; i < path.elements.length ; i++ )
    423 					{
    424 						var element = path.elements[i];
    425 						if ( listNodeNames[ element.getName() ] )
    426 						{
    427 							// If we've encountered a list inside a block limit
    428 							// The last group object of the block limit element should
    429 							// no longer be valid. Since paragraphs after the list
    430 							// should belong to a different group of paragraphs before
    431 							// the list. (Bug #1309)
    432 							blockLimit.removeCustomData( 'list_group_object' );
    433
    434 							var groupObj = element.getCustomData( 'list_group_object' );
    435 							if ( groupObj )
    436 								groupObj.contents.push( block );
    437 							else
    438 							{
    439 								groupObj = { root : element, contents : [ block ] };
    440 								listGroups.push( groupObj );
    441 								CKEDITOR.dom.element.setMarker( database, element, 'list_group_object', groupObj );
    442 							}
    443 							processedFlag = true;
    444 							break;
    445 						}
    446 					}
    447
    448 					if ( processedFlag )
    449 						continue;
    450
    451 					// No list ancestor? Group by block limit.
    452 					var root = blockLimit;
    453 					if ( root.getCustomData( 'list_group_object' ) )
    454 						root.getCustomData( 'list_group_object' ).contents.push( block );
    455 					else
    456 					{
    457 						groupObj = { root : root, contents : [ block ] };
    458 						CKEDITOR.dom.element.setMarker( database, root, 'list_group_object', groupObj );
    459 						listGroups.push( groupObj );
    460 					}
    461 				}
    462 			}
    463
    464 			// Now we have two kinds of list groups, groups rooted at a list, and groups rooted at a block limit element.
    465 			// We either have to build lists or remove lists, for removing a list does not makes sense when we are looking
    466 			// at the group that's not rooted at lists. So we have three cases to handle.
    467 			var listsCreated = [];
    468 			while ( listGroups.length > 0 )
    469 			{
    470 				groupObj = listGroups.shift();
    471 				if ( this.state == CKEDITOR.TRISTATE_OFF )
    472 				{
    473 					if ( listNodeNames[ groupObj.root.getName() ] )
    474 						changeListType.call( this, editor, groupObj, database, listsCreated );
    475 					else
    476 						createList.call( this, editor, groupObj, listsCreated );
    477 				}
    478 				else if ( this.state == CKEDITOR.TRISTATE_ON && listNodeNames[ groupObj.root.getName() ] )
    479 					removeList.call( this, editor, groupObj, database );
    480 			}
    481
    482 			// For all new lists created, merge adjacent, same type lists.
    483 			for ( i = 0 ; i < listsCreated.length ; i++ )
    484 			{
    485 				listNode = listsCreated[i];
    486 				var mergeSibling, listCommand = this;
    487 				( mergeSibling = function( rtl ){
    488
    489 					var sibling = listNode[ rtl ? 'getPrevious' : 'getNext' ].call( listNode, true );
    490 					if ( sibling && sibling.getName &&
    491 					     sibling.getName() == listCommand.type )
    492 					{
    493 						sibling.remove();
    494 						sibling.moveChildren( listNode );
    495 					}
    496 				} )();
    497 				mergeSibling( true );
    498 			}
    499
    500 			// Clean up, restore selection and update toolbar button states.
    501 			CKEDITOR.dom.element.clearAllMarkers( database );
    502 			selection.selectBookmarks( bookmarks );
    503 			editor.focus();
    504 		}
    505 	};
    506
    507 	CKEDITOR.plugins.add( 'list',
    508 	{
    509 		init : function( editor )
    510 		{
    511 			// Register commands.
    512 			var numberedListCommand = new listCommand( 'numberedlist', 'ol' ),
    513 				bulletedListCommand = new listCommand( 'bulletedlist', 'ul' );
    514 			editor.addCommand( 'numberedlist', numberedListCommand );
    515 			editor.addCommand( 'bulletedlist', bulletedListCommand );
    516
    517 			// Register the toolbar button.
    518 			editor.ui.addButton( 'NumberedList',
    519 				{
    520 					label : editor.lang.numberedlist,
    521 					command : 'numberedlist'
    522 				} );
    523 			editor.ui.addButton( 'BulletedList',
    524 				{
    525 					label : editor.lang.bulletedlist,
    526 					command : 'bulletedlist'
    527 				} );
    528
    529 			// Register the state changing handlers.
    530 			editor.on( 'selectionChange', CKEDITOR.tools.bind( onSelectionChange, numberedListCommand ) );
    531 			editor.on( 'selectionChange', CKEDITOR.tools.bind( onSelectionChange, bulletedListCommand ) );
    532 		},
    533
    534 		requires : [ 'domiterator' ]
    535 	} );
    536 })();
    537