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 == 0 && item.grandparent )
104 				{
105 					var 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 ( var 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.getName() == paragraphName && currentListItem.$.firstChild )
129 					{
130 						currentListItem.trim();
131 						var firstChild = currentListItem.getFirst();
132 						if ( firstChild.type == CKEDITOR.NODE_ELEMENT && firstChild.isBlockBoundary() )
133 						{
134 							var tmp = new CKEDITOR.dom.documentFragment( doc );
135 							currentListItem.moveChildren( tmp );
136 							currentListItem = tmp;
137 						}
138 					}
139
140 					var currentListItemName = currentListItem.$.nodeName.toLowerCase();
141 					if ( !CKEDITOR.env.ie && ( currentListItemName == 'div' || currentListItemName == 'p' ) )
142 						currentListItem.appendBogus();
143 					retval.append( currentListItem );
144 					rootNode = null;
145 					currentIndex++;
146 				}
147 				else
148 					return null;
149
150 				if ( listArray.length <= currentIndex || Math.max( listArray[ currentIndex ].indent, 0 ) < indentLevel )
151 					break;
152 			}
153
154 			// Clear marker attributes for the new list tree made of cloned nodes, if any.
155 			if ( database )
156 			{
157 				var currentNode = retval.getFirst();
158 				while ( currentNode )
159 				{
160 					if ( currentNode.type == CKEDITOR.NODE_ELEMENT )
161 						CKEDITOR.dom.element.clearMarkers( database, currentNode );
162 					currentNode = currentNode.getNextSourceNode();
163 				}
164 			}
165
166 			return { listNode : retval, nextIndex : currentIndex };
167 		}
168 	};
169
170 	function setState( editor, state )
171 	{
172 		editor.getCommand( this.name ).setState( state );
173 	}
174
175 	function onSelectionChange( evt )
176 	{
177 		var elements = evt.data.path.elements;
178
179 		for ( var i = 0 ; i < elements.length ; i++ )
180 		{
181 			if ( listNodeNames[ elements[i].getName() ] )
182 				return setState.call( this, evt.editor,
183 						this.type == elements[i].getName() ? CKEDITOR.TRISTATE_ON : CKEDITOR.TRISTATE_OFF );
184 		}
185
186 		setState.call( this, evt.editor, CKEDITOR.TRISTATE_OFF );
187 	}
188
189 	function changeListType( editor, groupObj, database, listsCreated )
190 	{
191 		// This case is easy...
192 		// 1. Convert the whole list into a one-dimensional array.
193 		// 2. Change the list type by modifying the array.
194 		// 3. Recreate the whole list by converting the array to a list.
195 		// 4. Replace the original list with the recreated list.
196 		var listArray = CKEDITOR.plugins.list.listToArray( groupObj.root, database ),
197 			selectedListItems = [];
198
199 		for ( var i = 0 ; i < groupObj.contents.length ; i++ )
200 		{
201 			var itemNode = groupObj.contents[i];
202 			itemNode = itemNode.getAscendant( 'li', true );
203 			if ( !itemNode || itemNode.getCustomData( 'list_item_processed' ) )
204 				continue;
205 			selectedListItems.push( itemNode );
206 			CKEDITOR.dom.element.setMarker( database, itemNode, 'list_item_processed', true );
207 		}
208
209 		var fakeParent = groupObj.root.getDocument().createElement( this.type );
210 		for ( var i = 0 ; i < selectedListItems.length ; i++ )
211 		{
212 			var listIndex = selectedListItems[i].getCustomData( 'listarray_index' );
213 			listArray[listIndex].parent = fakeParent;
214 		}
215 		var newList = CKEDITOR.plugins.list.arrayToList( listArray, database, null, editor.config.enterMode );
216 		for ( var i = 0, length = newList.listNode.getChildCount(), child ;
217 				i < length && ( child = newList.listNode.getChild( i ) ) ; i++ )
218 		{
219 			if ( child.getName() == this.type )
220 				listsCreated.push( child );
221 		}
222 		newList.listNode.replace( groupObj.root );
223 	}
224
225 	function createList( editor, groupObj, listsCreated )
226 	{
227 		var contents = groupObj.contents,
228 			doc = groupObj.root.getDocument(),
229 			listContents = [];
230
231 		// It is possible to have the contents returned by DomRangeIterator to be the same as the root.
232 		// e.g. when we're running into table cells.
233 		// In such a case, enclose the childNodes of contents[0] into a <div>.
234 		if ( contents.length == 1 && contents[0].equals( groupObj.root ) )
235 		{
236 			var divBlock = doc.createElement( 'div' );
237 			contents[0].moveChildren && contents[0].moveChildren( divBlock );
238 			contents[0].append( divBlock );
239 			contents[0] = divBlock;
240 		}
241
242 		// Calculate the common parent node of all content blocks.
243 		var commonParent = groupObj.contents[0].getParent();
244 		for ( var i = 0 ; i < contents.length ; i++ )
245 			commonParent = commonParent.getCommonAncestor( contents[i].getParent() );
246
247 		// We want to insert things that are in the same tree level only, so calculate the contents again
248 		// by expanding the selected blocks to the same tree level.
249 		for ( var i = 0 ; i < contents.length ; i++ )
250 		{
251 			var contentNode = contents[i],
252 				parentNode;
253 			while ( ( parentNode = contentNode.getParent() ) )
254 			{
255 				if ( parentNode.equals( commonParent ) )
256 				{
257 					listContents.push( contentNode );
258 					break;
259 				}
260 				contentNode = parentNode;
261 			}
262 		}
263
264 		if ( listContents.length < 1 )
265 			return;
266
267 		// Insert the list to the DOM tree.
268 		var insertAnchor = listContents[ listContents.length - 1 ].getNext(),
269 			listNode = doc.createElement( this.type );
270
271 		listsCreated.push( listNode );
272 		while ( listContents.length )
273 		{
274 			var contentBlock = listContents.shift(),
275 				listItem = doc.createElement( 'li' );
276 			contentBlock.moveChildren( listItem );
277 			contentBlock.remove();
278 			listItem.appendTo( listNode );
279 		}
280 		if ( insertAnchor )
281 			listNode.insertBefore( insertAnchor );
282 		else
283 			listNode.appendTo( commonParent );
284 	}
285
286 	function removeList( editor, groupObj, database )
287 	{
288 		// This is very much like the change list type operation.
289 		// Except that we're changing the selected items' indent to -1 in the list array.
290 		var listArray = CKEDITOR.plugins.list.listToArray( groupObj.root, database ),
291 			selectedListItems = [];
292
293 		for ( var i = 0 ; i < groupObj.contents.length ; i++ )
294 		{
295 			var itemNode = groupObj.contents[i];
296 			itemNode = itemNode.getAscendant( 'li', true );
297 			if ( !itemNode || itemNode.getCustomData( 'list_item_processed' ) )
298 				continue;
299 			selectedListItems.push( itemNode );
300 			CKEDITOR.dom.element.setMarker( database, itemNode, 'list_item_processed', true );
301 		}
302
303 		var lastListIndex = null;
304 		for ( var i = 0 ; i < selectedListItems.length ; i++ )
305 		{
306 			var listIndex = selectedListItems[i].getCustomData( 'listarray_index' );
307 			listArray[listIndex].indent = -1;
308 			lastListIndex = listIndex;
309 		}
310
311 		// After cutting parts of the list out with indent=-1, we still have to maintain the array list
312 		// model's nextItem.indent <= currentItem.indent + 1 invariant. Otherwise the array model of the
313 		// list cannot be converted back to a real DOM list.
314 		for ( var i = lastListIndex + 1 ; i < listArray.length ; i++ )
315 		{
316 			if ( listArray[i].indent > listArray[i-1].indent + 1 )
317 			{
318 				var indentOffset = listArray[i-1].indent + 1 - listArray[i].indent;
319 				var oldIndent = listArray[i].indent;
320 				while ( listArray[i] && listArray[i].indent >= oldIndent )
321 				{
322 					listArray[i].indent += indentOffset;
323 					i++;
324 				}
325 				i--;
326 			}
327 		}
328
329 		var newList = CKEDITOR.plugins.list.arrayToList( listArray, database, null, editor.config.enterMode );
330 		// If groupObj.root is the last element in its parent, or its nextSibling is a <br>, then we should
331 		// not add a <br> after the final item. So, check for the cases and trim the <br>.
332 		if ( groupObj.root.getNext() == null || groupObj.root.getNext().$.nodeName.toLowerCase() == 'br' )
333 		{
334 			if ( newList.listNode.getLast().$.nodeName.toLowerCase() == 'br' )
335 				newList.listNode.getLast().remove();
336 		}
337 		newList.listNode.replace( groupObj.root );
338 	}
339
340 	function listCommand( name, type )
341 	{
342 		this.name = name;
343 		this.type = type;
344 	}
345
346 	listCommand.prototype = {
347 		exec : function( editor )
348 		{
349 			editor.focus();
350
351 			var doc = editor.document,
352 				selection = editor.getSelection(),
353 				ranges = selection && selection.getRanges();
354
355 			// There should be at least one selected range.
356 			if ( !ranges || ranges.length < 1 )
357 				return;
358
359 			// Midas lists rule #1 says we can create a list even in an empty document.
360 			// But DOM iterator wouldn't run if the document is really empty.
361 			// So create a paragraph if the document is empty and we're going to create a list.
362 			if ( this.state == CKEDITOR.TRISTATE_OFF )
363 			{
364 				var body = doc.getBody();
365 				body.trim();
366 				if ( !body.getFirst() )
367 				{
368 					var paragraph = doc.createElement( editor.config.enterMode == CKEDITOR.ENTER_P ? 'p' :
369 							( editor.config.enterMode == CKEDITOR.ENTER_DIV ? 'div' : 'br' ) );
370 					paragraph.appendTo( body );
371 					ranges = [ new CKEDITOR.dom.range( doc ) ];
372 					ranges[0].selectNodeContents( paragraph );
373 					selection.selectRanges( ranges );
374 				}
375 			}
376
377 			var bookmarks = selection.createBookmarks( true );
378
379 			// Group the blocks up because there are many cases where multiple lists have to be created,
380 			// or multiple lists have to be cancelled.
381 			var listGroups = [],
382 				database = {};
383
384 			while ( ranges.length > 0 )
385 			{
386 				var range = ranges.shift(),
387 					boundaryNodes = range.getBoundaryNodes(),
388 					startNode = boundaryNodes.startNode,
389 					endNode = boundaryNodes.endNode;
390 				if ( startNode.type == CKEDITOR.NODE_ELEMENT && startNode.getName() == 'td' )
391 					range.setStartAt( boundaryNodes.startNode, CKEDITOR.POSITION_AFTER_START );
392 				if ( endNode.type == CKEDITOR.NODE_ELEMENT && endNode.getName() == 'td' )
393 					range.setEndAt( boundaryNodes.endNode, CKEDITOR.POSITION_BEFORE_END );
394
395 				var iterator = range.createIterator(),
396 					block;
397 				iterator.forceBrBreak = ( this.state == CKEDITOR.TRISTATE_OFF );
398
399 				while ( ( block = iterator.getNextParagraph() ) )
400 				{
401 					var path = new CKEDITOR.dom.elementPath( block ),
402 						listNode = null,
403 						processedFlag = false,
404 						blockLimit = path.blockLimit;
405
406 					// First, try to group by a list ancestor.
407 					for ( var i = 0 ; i < path.elements.length ; i++ )
408 					{
409 						var element = path.elements[i];
410 						if ( listNodeNames[ element.getName() ] )
411 						{
412 							// If we've encountered a list inside a block limit
413 							// The last group object of the block limit element should
414 							// no longer be valid. Since paragraphs after the list
415 							// should belong to a different group of paragraphs before
416 							// the list. (Bug #1309)
417 							blockLimit.removeCustomData( 'list_group_object' );
418
419 							var groupObj = element.getCustomData( 'list_group_object' );
420 							if ( groupObj )
421 								groupObj.contents.push( block );
422 							else
423 							{
424 								groupObj = { root : element, contents : [ block ] };
425 								listGroups.push( groupObj );
426 								CKEDITOR.dom.element.setMarker( database, element, 'list_group_object', groupObj );
427 							}
428 							processedFlag = true;
429 							break;
430 						}
431 					}
432
433 					if ( processedFlag )
434 						continue;
435
436 					// No list ancestor? Group by block limit.
437 					var root = blockLimit;
438 					if ( root.getCustomData( 'list_group_object' ) )
439 						root.getCustomData( 'list_group_object' ).contents.push( block );
440 					else
441 					{
442 						var groupObj = { root : root, contents : [ block ] };
443 						CKEDITOR.dom.element.setMarker( database, root, 'list_group_object', groupObj );
444 						listGroups.push( groupObj );
445 					}
446 				}
447 			}
448
449 			// Now we have two kinds of list groups, groups rooted at a list, and groups rooted at a block limit element.
450 			// We either have to build lists or remove lists, for removing a list does not makes sense when we are looking
451 			// at the group that's not rooted at lists. So we have three cases to handle.
452 			var listsCreated = [];
453 			while ( listGroups.length > 0 )
454 			{
455 				var groupObj = listGroups.shift();
456 				if ( this.state == CKEDITOR.TRISTATE_OFF )
457 				{
458 					if ( listNodeNames[ groupObj.root.getName() ] )
459 						changeListType.call( this, editor, groupObj, database, listsCreated );
460 					else
461 						createList.call( this, editor, groupObj, listsCreated );
462 				}
463 				else if ( this.state == CKEDITOR.TRISTATE_ON && listNodeNames[ groupObj.root.getName() ] )
464 					removeList.call( this, editor, groupObj, database );
465 			}
466
467 			// For all new lists created, merge adjacent, same type lists.
468 			for ( var i = 0 ; i < listsCreated.length ; i++ )
469 			{
470 				var listNode = listsCreated[i],
471 					stopFlag = false,
472 					currentNode = listNode;
473
474 				while ( !stopFlag )
475 				{
476 					currentNode = currentNode.getNext();
477 					if ( currentNode && currentNode.type == CKEDITOR.NODE_TEXT && emptyTextRegex.test( currentNode.getText() ) )
478 						continue;
479 					stopFlag = true;
480 				}
481
482 				if ( currentNode && currentNode.getName() == this.type )
483 				{
484 					currentNode.remove();
485 					currentNode.moveChildren( listNode );
486 				}
487
488 				stopFlag = false;
489 				currentNode = listNode;
490 				while ( !stopFlag )
491 				{
492 					currentNode = currentNode.getNext();
493 					if ( currentNode && currentNode.type == CKEDITOR.NODE_TEXT && emptyTextRegex.test( currentNode.getText() ) )
494 						continue;
495 					stopFlag = true;
496 				}
497 				if ( currentNode && currentNode.getName() == this.type  )
498 				{
499 					currentNode.remove();
500 					currentNode.moveChildren( listNode, true );
501 				}
502 			}
503
504 			// Clean up, restore selection and update toolbar button states.
505 			CKEDITOR.dom.element.clearAllMarkers( database );
506 			selection.selectBookmarks( bookmarks );
507 			editor.focus();
508 		}
509 	};
510
511 	CKEDITOR.plugins.add( 'list',
512 	{
513 		init : function( editor )
514 		{
515 			// Register commands.
516 			var numberedListCommand = new listCommand( 'numberedlist', 'ol' ),
517 				bulletedListCommand = new listCommand( 'bulletedlist', 'ul' );
518 			editor.addCommand( 'numberedlist', numberedListCommand );
519 			editor.addCommand( 'bulletedlist', bulletedListCommand );
520
521 			// Register the toolbar button.
522 			editor.ui.addButton( 'NumberedList',
523 				{
524 					label : editor.lang.numberedlist,
525 					command : 'numberedlist'
526 				} );
527 			editor.ui.addButton( 'BulletedList',
528 				{
529 					label : editor.lang.bulletedlist,
530 					command : 'bulletedlist'
531 				} );
532
533 			// Register the state changing handlers.
534 			editor.on( 'selectionChange', CKEDITOR.tools.bind( onSelectionChange, numberedListCommand ) );
535 			editor.on( 'selectionChange', CKEDITOR.tools.bind( onSelectionChange, bulletedListCommand ) );
536 		},
537
538 		requires : [ 'domiterator' ]
539 	} );
540 })();
541