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