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