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