diff --git a/packages/ckeditor5-code-block/package.json b/packages/ckeditor5-code-block/package.json index 4506f42198c..11d1735156e 100644 --- a/packages/ckeditor5-code-block/package.json +++ b/packages/ckeditor5-code-block/package.json @@ -22,9 +22,10 @@ "@ckeditor/ckeditor5-clipboard": "39.0.2", "@ckeditor/ckeditor5-core": "39.0.2", "@ckeditor/ckeditor5-dev-utils": "^39.0.0", + "@ckeditor/ckeditor5-editor-classic": "39.0.2", "@ckeditor/ckeditor5-engine": "39.0.2", "@ckeditor/ckeditor5-enter": "39.0.2", - "@ckeditor/ckeditor5-editor-classic": "39.0.2", + "@ckeditor/ckeditor5-html-support": "39.0.2", "@ckeditor/ckeditor5-image": "39.0.2", "@ckeditor/ckeditor5-indent": "39.0.2", "@ckeditor/ckeditor5-list": "39.0.2", diff --git a/packages/ckeditor5-code-block/src/codeblockediting.ts b/packages/ckeditor5-code-block/src/codeblockediting.ts index 562ee2f3647..6514c63bac3 100644 --- a/packages/ckeditor5-code-block/src/codeblockediting.ts +++ b/packages/ckeditor5-code-block/src/codeblockediting.ts @@ -22,6 +22,8 @@ import { type Element } from 'ckeditor5/src/engine'; +import type { DocumentListEditing } from '@ckeditor/ckeditor5-list'; + import CodeBlockCommand from './codeblockcommand'; import IndentCodeBlockCommand from './indentcodeblockcommand'; import OutdentCodeBlockCommand from './outdentcodeblockcommand'; @@ -97,7 +99,8 @@ export default class CodeBlockEditing extends Plugin { const schema = editor.model.schema; const model = editor.model; const view = editor.editing.view; - const isDocumentListEditingLoaded = editor.plugins.has( 'DocumentListEditing' ); + const documentListEditing: DocumentListEditing | null = editor.plugins.has( 'DocumentListEditing' ) ? + editor.plugins.get( 'DocumentListEditing' ) : null; const normalizedLanguagesDefs = getNormalizedAndLocalizedLanguageDefinitions( editor ); @@ -133,11 +136,10 @@ export default class CodeBlockEditing extends Plugin { // Allow all list* attributes on `codeBlock` (integration with DocumentList). // Disallow all attributes on $text inside `codeBlock`. schema.addAttributeCheck( ( context, attributeName ) => { - const isDocumentListAttributeOnCodeBlock = context.endsWith( 'codeBlock' ) && - attributeName.startsWith( 'list' ) && - attributeName !== 'list'; - - if ( isDocumentListEditingLoaded && isDocumentListAttributeOnCodeBlock ) { + if ( + context.endsWith( 'codeBlock' ) && + documentListEditing && documentListEditing.getListAttributeNames().includes( attributeName ) + ) { return true; } diff --git a/packages/ckeditor5-code-block/tests/codeblock-integration.js b/packages/ckeditor5-code-block/tests/codeblock-integration.js index 365aed21d9c..7cb219ac1f2 100644 --- a/packages/ckeditor5-code-block/tests/codeblock-integration.js +++ b/packages/ckeditor5-code-block/tests/codeblock-integration.js @@ -12,6 +12,8 @@ import GFMDataProcessor from '@ckeditor/ckeditor5-markdown-gfm/src/gfmdataproces import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import ImageInlineEditing from '@ckeditor/ckeditor5-image/src/image/imageinlineediting'; import DocumentListEditing from '@ckeditor/ckeditor5-list/src/documentlist/documentlistediting'; +import DocumentListPropertiesEditing from '@ckeditor/ckeditor5-list/src/documentlistproperties/documentlistpropertiesediting'; +import GeneralHtmlSupport from '@ckeditor/ckeditor5-html-support/src/generalhtmlsupport'; import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import CodeBlockUI from '../src/codeblockui'; @@ -178,7 +180,14 @@ describe( 'CodeBlock - integration', () => { beforeEach( async () => { editor = await ClassicTestEditor .create( '', { - plugins: [ CodeBlockEditing, DocumentListEditing, Enter, Paragraph ] + plugins: [ + CodeBlockEditing, DocumentListEditing, DocumentListPropertiesEditing, Enter, Paragraph, GeneralHtmlSupport + ], + htmlSupport: { + allow: [ + { name: /./, attributes: true, styles: true, classes: true } + ] + } } ); model = editor.model; @@ -188,22 +197,26 @@ describe( 'CodeBlock - integration', () => { await editor.destroy(); } ); - it( 'should allow all attributes starting with list* in the schema', () => { + it( 'should allow all list attributes in the schema', () => { setData( model, '[]foo' ); const codeBlock = model.document.getRoot().getChild( 0 ); expect( model.schema.checkAttribute( codeBlock, 'listItemId' ), 'listItemId' ).to.be.true; expect( model.schema.checkAttribute( codeBlock, 'listType' ), 'listType' ).to.be.true; - expect( model.schema.checkAttribute( codeBlock, 'listStart' ), 'listStart' ).to.be.true; - expect( model.schema.checkAttribute( codeBlock, 'listFoo' ), 'listFoo' ).to.be.true; + expect( model.schema.checkAttribute( codeBlock, 'listStyle' ), 'listStyle' ).to.be.true; + expect( model.schema.checkAttribute( codeBlock, 'htmlLiAttributes' ), 'htmlLiAttributes' ).to.be.true; + expect( model.schema.checkAttribute( codeBlock, 'htmlUlAttributes' ), 'htmlUlAttributes' ).to.be.true; + expect( model.schema.checkAttribute( codeBlock, 'htmlOlAttributes' ), 'htmlOlAttributes' ).to.be.true; } ); - it( 'should disallow attributes that do not start with "list" in the schema but include the sequence', () => { + it( 'should disallow attributes that are not registered as list attributes', () => { setData( model, '[]foo' ); const codeBlock = model.document.getRoot().getChild( 0 ); + expect( model.schema.checkAttribute( codeBlock, 'listReversed' ), 'listReversed' ).to.be.false; + expect( model.schema.checkAttribute( codeBlock, 'listStart' ), 'listStart' ).to.be.false; expect( model.schema.checkAttribute( codeBlock, 'list' ), 'list' ).to.be.false; expect( model.schema.checkAttribute( codeBlock, 'fooList' ), 'fooList' ).to.be.false; expect( model.schema.checkAttribute( codeBlock, 'alist' ), 'alist' ).to.be.false; diff --git a/packages/ckeditor5-html-support/src/datafilter.ts b/packages/ckeditor5-html-support/src/datafilter.ts index de681af2e7a..97f79620ad7 100644 --- a/packages/ckeditor5-html-support/src/datafilter.ts +++ b/packages/ckeditor5-html-support/src/datafilter.ts @@ -132,17 +132,11 @@ export default class DataFilter extends Plugin { super( editor ); this._dataSchema = editor.plugins.get( 'DataSchema' ); - this._allowedAttributes = new Matcher(); - this._disallowedAttributes = new Matcher(); - this._allowedElements = new Set(); - this._disallowedElements = new Set(); - this._dataInitialized = false; - this._coupledAttributes = null; this._registerElementsAfterInit(); diff --git a/packages/ckeditor5-html-support/src/integrations/documentlist.ts b/packages/ckeditor5-html-support/src/integrations/documentlist.ts index e7c304286fa..d41f871bce3 100644 --- a/packages/ckeditor5-html-support/src/integrations/documentlist.ts +++ b/packages/ckeditor5-html-support/src/integrations/documentlist.ts @@ -107,36 +107,8 @@ export default class DocumentListElementSupport extends Plugin { } ); // Make sure that all items in a single list (items at the same level & listType) have the same properties. - // Note: This is almost an exact copy from DocumentListPropertiesEditing. documentListEditing.on( 'postFixer', ( evt, { listNodes, writer } ) => { - const previousNodesByIndent = []; // Last seen nodes of lower indented lists. - - for ( const { node, previous } of listNodes ) { - // For the first list block there is nothing to compare with. - if ( !previous ) { - continue; - } - - const nodeIndent = node.getAttribute( 'listIndent' ); - const previousNodeIndent = previous.getAttribute( 'listIndent' ); - - let previousNodeInList = null; // It's like `previous` but has the same indent as current node. - - // Let's find previous node for the same indent. - // We're going to need that when we get back to previous indent. - if ( nodeIndent > previousNodeIndent ) { - previousNodesByIndent[ previousNodeIndent ] = previous; - } - // Restore the one for given indent. - else if ( nodeIndent < previousNodeIndent ) { - previousNodeInList = previousNodesByIndent[ nodeIndent ]; - previousNodesByIndent.length = nodeIndent; - } - // Same indent. - else { - previousNodeInList = previous; - } - + for ( const { node, previousNodeInList } of listNodes ) { // This is a first item of a nested list. if ( !previousNodeInList ) { continue; @@ -174,7 +146,7 @@ export default class DocumentListElementSupport extends Plugin { for ( const { node } of listNodes ) { const listType = node.getAttribute( 'listType' ); - if ( listType === 'bulleted' && node.getAttribute( 'htmlOlAttributes' ) ) { + if ( listType !== 'numbered' && node.getAttribute( 'htmlOlAttributes' ) ) { writer.removeAttribute( 'htmlOlAttributes', node ); evt.return = true; } @@ -256,8 +228,8 @@ function viewToModelListAttributeConverter( attributeName: string, dataFilter: D /** * Returns HTML attribute name based on provided list type. */ -function getAttributeFromListType( listType: 'bulleted' | 'numbered' ) { - return listType === 'bulleted' ? - 'htmlUlAttributes' : - 'htmlOlAttributes'; +function getAttributeFromListType( listType: 'bulleted' | 'numbered' | 'todo' ) { + return listType === 'numbered' ? + 'htmlOlAttributes' : + 'htmlUlAttributes'; } diff --git a/packages/ckeditor5-list/src/augmentation.ts b/packages/ckeditor5-list/src/augmentation.ts index 7e579551e18..357b4cdfb1d 100644 --- a/packages/ckeditor5-list/src/augmentation.ts +++ b/packages/ckeditor5-list/src/augmentation.ts @@ -23,6 +23,8 @@ import type { TodoList, TodoListEditing, TodoListUI, + TodoDocumentList, + TodoDocumentListEditing, ListCommand, DocumentListCommand, @@ -36,7 +38,8 @@ import type { DocumentListStartCommand, ListReversedCommand, DocumentListReversedCommand, - CheckTodoListCommand + CheckTodoListCommand, + CheckTodoDocumentListCommand } from '.'; declare module '@ckeditor/ckeditor5-core' { @@ -69,6 +72,8 @@ declare module '@ckeditor/ckeditor5-core' { [ TodoList.pluginName ]: TodoList; [ TodoListEditing.pluginName ]: TodoListEditing; [ TodoListUI.pluginName ]: TodoListUI; + [ TodoDocumentList.pluginName ]: TodoDocumentList; + [ TodoDocumentListEditing.pluginName ]: TodoDocumentListEditing; } interface CommandsMap { @@ -83,7 +88,7 @@ declare module '@ckeditor/ckeditor5-core' { listStyle: ListStyleCommand | DocumentListStyleCommand; listStart: ListStartCommand | DocumentListStartCommand; listReversed: ListReversedCommand | DocumentListReversedCommand; - todoList: ListCommand; - checkTodoList: CheckTodoListCommand; + todoList: ListCommand | DocumentListCommand; + checkTodoList: CheckTodoListCommand | CheckTodoDocumentListCommand; } } diff --git a/packages/ckeditor5-list/src/documentlist/converters.ts b/packages/ckeditor5-list/src/documentlist/converters.ts index 751e4307b71..2497cc7b2b9 100644 --- a/packages/ckeditor5-list/src/documentlist/converters.ts +++ b/packages/ckeditor5-list/src/documentlist/converters.ts @@ -30,6 +30,7 @@ import { getAllListItemBlocks, getListItemBlocks, isListItemBlock, + isFirstBlockOfListItem, ListItemUid, type ListElement } from './utils/model'; @@ -48,8 +49,9 @@ import { findAndAddListHeadToMap } from './utils/postfixers'; import type { default as DocumentListEditing, DocumentListEditingCheckAttributesEvent, - DowncastStrategy, - ListItemAttributesMap + DocumentListEditingCheckElementEvent, + ListItemAttributesMap, + DowncastStrategy } from './documentlistediting'; /** @@ -72,15 +74,26 @@ export function listItemUpcastConverter(): GetCallback { return; } + const listItemId = ListItemUid.next(); + const listIndent = getIndent( data.viewItem ); + let listType = data.viewItem.parent && data.viewItem.parent.is( 'element', 'ol' ) ? 'numbered' : 'bulleted'; + + // Preserve list type if was already set (for example by to-do list feature). + const firstItemListType = items[ 0 ].getAttribute( 'listType' ) as string; + + if ( firstItemListType ) { + listType = firstItemListType; + } + const attributes = { - listItemId: ListItemUid.next(), - listIndent: getIndent( data.viewItem ), - listType: data.viewItem.parent && data.viewItem.parent.is( 'element', 'ol' ) ? 'numbered' : 'bulleted' + listItemId, + listIndent, + listType }; for ( const item of items ) { // Set list attributes only on same level items, those nested deeper are already handled by the recursive conversion. - if ( !isListItemBlock( item ) ) { + if ( !item.hasAttribute( 'listItemId' ) ) { writer.setAttributes( attributes, item ); } } @@ -172,7 +185,7 @@ export function reconvertItemsOnDataChange( findAndAddListHeadToMap( entry.range.start.getShiftedBy( 1 ), itemToListHead ); // Check if paragraph should be converted from bogus to plain paragraph. - if ( doesItemParagraphRequiresRefresh( item ) ) { + if ( doesItemBlockRequiresRefresh( item as Element ) ) { itemsToRefresh.push( item ); } } else { @@ -181,7 +194,7 @@ export function reconvertItemsOnDataChange( } else if ( isListItemBlock( item ) ) { // Some other attribute was changed on the list item, // check if paragraph does not need to be converted to bogus or back. - if ( doesItemParagraphRequiresRefresh( item ) ) { + if ( doesItemBlockRequiresRefresh( item ) ) { itemsToRefresh.push( item ); } } @@ -227,7 +240,7 @@ export function reconvertItemsOnDataChange( visited.add( block ); // Check if bogus vs plain paragraph needs refresh. - if ( doesItemParagraphRequiresRefresh( block, blocks ) ) { + if ( doesItemBlockRequiresRefresh( block, blocks ) ) { itemsToRefresh.push( block ); } // Check if wrapping with UL, OL, LIs needs refresh. @@ -240,14 +253,23 @@ export function reconvertItemsOnDataChange( return itemsToRefresh; } - function doesItemParagraphRequiresRefresh( item: Node, blocks?: Array ) { - if ( !item.is( 'element', 'paragraph' ) ) { + function doesItemBlockRequiresRefresh( item: Element, blocks?: Array ) { + const viewElement = editing.mapper.toViewElement( item ); + + if ( !viewElement ) { return false; } - const viewElement = editing.mapper.toViewElement( item ); + const needsRefresh = documentListEditing.fire( 'checkElement', { + modelElement: item, + viewElement + } ); - if ( !viewElement ) { + if ( needsRefresh ) { + return true; + } + + if ( !item.is( 'element', 'paragraph' ) ) { return false; } @@ -323,7 +345,8 @@ export function reconvertItemsOnDataChange( export function listItemDowncastConverter( attributeNames: Array, strategies: Array, - model: Model + model: Model, + { dataPipeline }: { dataPipeline?: boolean } = {} ): GetCallback> { const consumer = createAttributesConsumer( attributeNames ); @@ -345,11 +368,17 @@ export function listItemDowncastConverter( // This is for cases when mapping is using inner view element like in the code blocks (pre > code). const viewElement = findMappedViewElement( listItem, mapper, model )!; + // Remove custom item marker. + removeCustomMarkerElements( viewElement, writer, mapper ); + // Unwrap element from current list wrappers. unwrapListItemBlock( viewElement, writer ); - // Then wrap them with the new list wrappers. - wrapListItemBlock( listItem, writer.createRangeOn( viewElement ), strategies, writer ); + // Insert custom item marker. + const viewRange = insertCustomMarkerElements( listItem, viewElement, strategies, writer, { dataPipeline } ); + + // Then wrap them with the new list wrappers (UL, OL, LI). + wrapListItemBlock( listItem, viewRange, strategies, writer ); }; } @@ -395,10 +424,104 @@ export function findMappedViewElement( element: Element, mapper: Mapper, model: const modelRange = model.createRangeOn( element ); const viewRange = mapper.toViewRange( modelRange ).getTrimmed(); - return viewRange.getContainedElement(); + return viewRange.end.nodeBefore as ViewElement | null; +} + +/** + * Removes a custom marker elements and item wrappers related to that marker. + */ +function removeCustomMarkerElements( viewElement: ViewElement, viewWriter: DowncastWriter, mapper: Mapper ): void { + // Remove item wrapper. + while ( viewElement.parent!.is( 'attributeElement' ) && viewElement.parent!.getCustomProperty( 'listItemWrapper' ) ) { + viewWriter.unwrap( viewWriter.createRangeIn( viewElement.parent ), viewElement.parent ); + } + + // Remove custom item markers. + const viewWalker = viewWriter.createPositionBefore( viewElement ).getWalker( { direction: 'backward' } ); + const markersToRemove = []; + + for ( const { item } of viewWalker ) { + // Walk only over the non-mapped elements between list item blocks. + if ( item.is( 'element' ) && mapper.toModelElement( item ) ) { + break; + } + + if ( item.is( 'element' ) && item.getCustomProperty( 'listItemMarker' ) ) { + markersToRemove.push( item ); + } + } + + for ( const marker of markersToRemove ) { + viewWriter.remove( marker ); + } } -// Unwraps all ol, ul, and li attribute elements that are wrapping the provided view element. +/** + * Inserts a custom marker elements and wraps first block of a list item if marker requires it. + */ +function insertCustomMarkerElements( + listItem: Element, + viewElement: ViewElement, + strategies: Array, + writer: DowncastWriter, + { dataPipeline }: { dataPipeline?: boolean } +): ViewRange { + let viewRange = writer.createRangeOn( viewElement ); + + // Marker can be inserted only before the first block of a list item. + if ( !isFirstBlockOfListItem( listItem ) ) { + return viewRange; + } + + for ( const strategy of strategies ) { + if ( strategy.scope != 'itemMarker' ) { + continue; + } + + // Create the custom marker element and inject it before the first block of the list item. + const markerElement = strategy.createElement( writer, listItem, { dataPipeline } ); + + if ( !markerElement ) { + continue; + } + + writer.setCustomProperty( 'listItemMarker', true, markerElement ); + writer.insert( viewRange.start, markerElement ); + + viewRange = writer.createRange( + writer.createPositionBefore( markerElement ), + writer.createPositionAfter( viewElement ) + ); + + // Wrap the marker and optionally the first block with an attribute element (label for to-do lists). + if ( !strategy.createWrapperElement || !strategy.canWrapElement ) { + continue; + } + + const wrapper = strategy.createWrapperElement( writer, listItem, { dataPipeline } ); + + writer.setCustomProperty( 'listItemWrapper', true, wrapper ); + + // The whole block can be wrapped... + if ( strategy.canWrapElement( listItem ) ) { + viewRange = writer.wrap( viewRange, wrapper ); + } else { + // ... or only the marker element (if the block is downcasted to heading or block widget). + viewRange = writer.wrap( writer.createRangeOn( markerElement ), wrapper ); + + viewRange = writer.createRange( + viewRange.start, + writer.createPositionAfter( viewElement ) + ); + } + } + + return viewRange; +} + +/** + * Unwraps all ol, ul, and li attribute elements that are wrapping the provided view element. + */ function unwrapListItemBlock( viewElement: ViewElement, viewWriter: DowncastWriter ) { let attributeElement: ViewElement | ViewDocumentFragment = viewElement.parent!; @@ -411,7 +534,9 @@ function unwrapListItemBlock( viewElement: ViewElement, viewWriter: DowncastWrit } } -// Wraps the given list item with appropriate attribute elements for ul, ol, and li. +/** + * Wraps the given list item with appropriate attribute elements for ul, ol, and li. + */ function wrapListItemBlock( listItem: ListElement, viewRange: ViewRange, @@ -430,7 +555,10 @@ function wrapListItemBlock( const listViewElement = createListElement( writer, indent, currentListItem.getAttribute( 'listType' ) ); for ( const strategy of strategies ) { - if ( currentListItem.hasAttribute( strategy.attributeName ) ) { + if ( + ( strategy.scope == 'list' || strategy.scope == 'item' ) && + currentListItem.hasAttribute( strategy.attributeName ) + ) { strategy.setAttributeOnDowncast( writer, currentListItem.getAttribute( strategy.attributeName ), diff --git a/packages/ckeditor5-list/src/documentlist/documentlistcommand.ts b/packages/ckeditor5-list/src/documentlist/documentlistcommand.ts index e9b51287f56..72ed13e0ed4 100644 --- a/packages/ckeditor5-list/src/documentlist/documentlistcommand.ts +++ b/packages/ckeditor5-list/src/documentlist/documentlistcommand.ts @@ -29,7 +29,7 @@ export default class DocumentListCommand extends Command { /** * The type of the list created by the command. */ - public readonly type: 'numbered' | 'bulleted'; + public readonly type: 'numbered' | 'bulleted' | 'todo'; /** * A flag indicating whether the command is active, which means that the selection starts in a list of the same type. @@ -45,7 +45,7 @@ export default class DocumentListCommand extends Command { * @param editor The editor instance. * @param type List type that will be handled by this command. */ - constructor( editor: Editor, type: 'numbered' | 'bulleted' ) { + constructor( editor: Editor, type: 'numbered' | 'bulleted' | 'todo' ) { super( editor ); this.type = type; @@ -100,7 +100,7 @@ export default class DocumentListCommand extends Command { this._fireAfterExecute( changedBlocks ); } - // Turning on the list items for a collapsed selection inside a list item. + // Changing type of list items for a collapsed selection inside a list item. else if ( ( selectedBlockObject || document.selection.isCollapsed ) && isListItemBlock( blocks[ 0 ] ) ) { const changedBlocks = getListItems( selectedBlockObject || blocks[ 0 ] ); @@ -178,8 +178,10 @@ export default class DocumentListCommand extends Command { * @returns Whether the command should be enabled. */ private _checkEnabled(): boolean { - const selection = this.editor.model.document.selection; - const schema = this.editor.model.schema; + const model = this.editor.model; + const schema = model.schema; + const selection = model.document.selection; + const blocks = Array.from( selection.getSelectedBlocks() ); if ( !blocks.length ) { diff --git a/packages/ckeditor5-list/src/documentlist/documentlistediting.ts b/packages/ckeditor5-list/src/documentlist/documentlistediting.ts index 3046084f42f..aa706dbb16f 100644 --- a/packages/ckeditor5-list/src/documentlist/documentlistediting.ts +++ b/packages/ckeditor5-list/src/documentlist/documentlistediting.ts @@ -23,6 +23,7 @@ import type { UpcastElementEvent, ViewDocumentTabEvent, ViewElement, + ViewAttributeElement, Writer } from 'ckeditor5/src/engine'; @@ -79,7 +80,7 @@ const LIST_BASE_ATTRIBUTES = [ 'listType', 'listIndent', 'listItemId' ]; * Map of model attributes applicable to list blocks. */ export interface ListItemAttributesMap { - listType?: 'numbered' | 'bulleted'; + listType?: 'numbered' | 'bulleted' | 'todo'; listIndent?: number; listItemId?: string; } @@ -194,7 +195,7 @@ export default class DocumentListEditing extends Plugin { /** * Returns list of model attribute names that should affect downcast conversion. */ - private _getListAttributeNames() { + public getListAttributeNames(): Array { return [ ...LIST_BASE_ATTRIBUTES, ...this._downcastStrategies.map( strategy => strategy.attributeName ) @@ -388,10 +389,31 @@ export default class DocumentListEditing extends Plugin { private _setupConversion() { const editor = this.editor; const model = editor.model; - const attributeNames = this._getListAttributeNames(); + const attributeNames = this.getListAttributeNames(); editor.conversion.for( 'upcast' ) - .elementToElement( { view: 'li', model: 'paragraph' } ) + // Convert
  • to a generic paragraph so the content of
  • is always inside a block. + // Setting the listType attribute to let other features (to-do list) know that this is part of a list item. + .elementToElement( { + view: 'li', + model: ( viewElement, { writer } ) => writer.createElement( 'paragraph', { listType: '' } ) + } ) + // Convert paragraph to the list block (without list type defined yet). + // This is important to properly handle bogus paragraph and to-do lists. + // Most of the time the bogus paragraph should not appear in the data of to-do list, + // but if there is any marker or an attribute on the paragraph then the bogus paragraph + // is preserved in the data, and we need to be able to detect this case. + .elementToElement( { + view: 'p', + model: ( viewElement, { writer } ) => { + if ( viewElement.parent && viewElement.parent.is( 'element', 'li' ) ) { + return writer.createElement( 'paragraph', { listType: '' } ); + } + + return null; + }, + converterPriority: 'high' + } ) .add( dispatcher => { dispatcher.on( 'element:li', listItemUpcastConverter() ); dispatcher.on( 'element:ul', listUpcastCleanList(), { priority: 'high' } ); @@ -403,6 +425,12 @@ export default class DocumentListEditing extends Plugin { model: 'paragraph', view: bogusParagraphCreator( attributeNames ), converterPriority: 'high' + } ) + .add( dispatcher => { + dispatcher.on>( + 'attribute', + listItemDowncastConverter( attributeNames, this._downcastStrategies, model ) + ); } ); editor.conversion.for( 'dataDowncast' ) @@ -410,13 +438,11 @@ export default class DocumentListEditing extends Plugin { model: 'paragraph', view: bogusParagraphCreator( attributeNames, { dataPipeline: true } ), converterPriority: 'high' - } ); - - editor.conversion.for( 'downcast' ) + } ) .add( dispatcher => { dispatcher.on>( 'attribute', - listItemDowncastConverter( attributeNames, this._downcastStrategies, model ) + listItemDowncastConverter( attributeNames, this._downcastStrategies, model, { dataPipeline: true } ) ); } ); @@ -452,7 +478,7 @@ export default class DocumentListEditing extends Plugin { */ private _setupModelPostFixing() { const model = this.editor.model; - const attributeNames = this._getListAttributeNames(); + const attributeNames = this.getListAttributeNames(); // Register list fixing. // First the low level handler. @@ -518,9 +544,9 @@ export default class DocumentListEditing extends Plugin { } /** - * The downcast strategy. + * The attribute to attribute downcast strategy for UL, OL, LI elements. */ -export interface DowncastStrategy { +export interface AttributeDowncastStrategy { /** * The scope of the downcast (whether it applies to LI or OL/UL). @@ -538,6 +564,51 @@ export interface DowncastStrategy { setAttributeOnDowncast( writer: DowncastWriter, value: unknown, element: ViewElement ): void; } +/** + * The custom marker downcast strategy. + */ +export interface ItemMarkerDowncastStrategy { + + /** + * The scope of the downcast. + */ + scope: 'itemMarker'; + + /** + * The model attribute name. + */ + attributeName: string; + + /** + * Creates a view element for a custom item marker. + */ + createElement( + writer: DowncastWriter, + modelElement: Element, + { dataPipeline }: { dataPipeline?: boolean } + ): ViewElement | null; + + /** + * Creates an AttributeElement to be used for wrapping a first block of a list item. + */ + createWrapperElement?( + writer: DowncastWriter, + modelElement: Element, + { dataPipeline }: { dataPipeline?: boolean } + ): ViewAttributeElement; + + /** + * Should return true if the given list block can be wrapped with the wrapper created by `createWrapperElement()` + * or only the marker element should be wrapped. + */ + canWrapElement?( modelElement: Element ): boolean; +} + +/** + * The downcast strategy. + */ +export type DowncastStrategy = AttributeDowncastStrategy | ItemMarkerDowncastStrategy; + /** * Post-fixer that reacts to changes on document and fixes incorrect model states (invalid `listItemId` and `listIndent` values). * @@ -792,3 +863,21 @@ export type DocumentListEditingCheckAttributesEvent = { } ]; return: boolean; }; + +/** + * Event fired on changes detected on the model list element to verify if the view representation of a list block element + * is representing those attributes. + * + * It allows triggering a reconversion of a list item block. + * + * @internal + * @eventName ~DocumentListEditing#checkElement + */ +export type DocumentListEditingCheckElementEvent = { + name: 'checkElement'; + args: [ { + viewElement: ViewElement; + modelElement: Element; + } ]; + return: boolean; +}; diff --git a/packages/ckeditor5-list/src/documentlist/utils/listwalker.ts b/packages/ckeditor5-list/src/documentlist/utils/listwalker.ts index 745ff8ad6b0..95130c29732 100644 --- a/packages/ckeditor5-list/src/documentlist/utils/listwalker.ts +++ b/packages/ckeditor5-list/src/documentlist/utils/listwalker.ts @@ -10,7 +10,7 @@ import { first, toArray, type ArrayOrItem } from 'ckeditor5/src/utils'; import { isListItemBlock, type ListElement } from './model'; -import type { DocumentFragment, Element, Node } from 'ckeditor5/src/engine'; +import type { Element, Node } from 'ckeditor5/src/engine'; /** * Document list blocks iterator. @@ -216,10 +216,33 @@ export function* iterateSiblingListBlocks( direction: 'forward' | 'backward' = 'forward' ): IterableIterator { const isForward = direction == 'forward'; + const previousNodesByIndent: Array = []; // Last seen nodes of lower indented lists. let previous = null; while ( isListItemBlock( node ) ) { - yield { node, previous }; + let previousNodeInList = null; // It's like `previous` but has the same indent as current node. + + if ( previous ) { + const nodeIndent = node.getAttribute( 'listIndent' ); + const previousNodeIndent = previous.getAttribute( 'listIndent' ); + + // Let's find previous node for the same indent. + // We're going to need that when we get back to previous indent. + if ( nodeIndent > previousNodeIndent ) { + previousNodesByIndent[ previousNodeIndent ] = previous; + } + // Restore the one for given indent. + else if ( nodeIndent < previousNodeIndent ) { + previousNodeInList = previousNodesByIndent[ nodeIndent ]; + previousNodesByIndent.length = nodeIndent; + } + // Same indent. + else { + previousNodeInList = previous; + } + } + + yield { node, previous, previousNodeInList }; previous = node; node = isForward ? node.nextSibling : node.previousSibling; @@ -267,4 +290,9 @@ export interface ListIteratorValue { * The previous list node. */ previous: ListElement | null; + + /** + * The previous list node at the same indent as current node. + */ + previousNodeInList: ListElement | null; } diff --git a/packages/ckeditor5-list/src/documentlist/utils/model.ts b/packages/ckeditor5-list/src/documentlist/utils/model.ts index b9a34845488..3096212aca5 100644 --- a/packages/ckeditor5-list/src/documentlist/utils/model.ts +++ b/packages/ckeditor5-list/src/documentlist/utils/model.ts @@ -13,7 +13,8 @@ import type { Model, Node, Writer, - Item + Item, + Schema } from 'ckeditor5/src/engine'; import { uid, toArray, type ArrayOrItem } from 'ckeditor5/src/utils'; @@ -45,7 +46,7 @@ export class ListItemUid { export interface ListElement extends Element { getAttribute( key: 'listItemId' ): string; getAttribute( key: 'listIndent' ): number; - getAttribute( key: 'listType' ): 'numbered' | 'bulleted'; + getAttribute( key: 'listType' ): 'numbered' | 'bulleted' | 'todo'; getAttribute( key: string ): unknown; } diff --git a/packages/ckeditor5-list/src/documentlist/utils/view.ts b/packages/ckeditor5-list/src/documentlist/utils/view.ts index ad25eeb8254..20326aaebee 100644 --- a/packages/ckeditor5-list/src/documentlist/utils/view.ts +++ b/packages/ckeditor5-list/src/documentlist/utils/view.ts @@ -96,7 +96,7 @@ export function getIndent( listItem: ViewElement ): number { export function createListElement( writer: DowncastWriter, indent: number, - type: 'bulleted' | 'numbered', + type: 'bulleted' | 'numbered' | 'todo', id = getViewElementIdForListType( type, indent ) ): ViewAttributeElement { // Negative priorities so that restricted editing attribute won't wrap lists. @@ -128,7 +128,7 @@ export function createListItemElement( * * @internal */ -export function getViewElementNameForListType( type?: 'bulleted' | 'numbered' ): 'ol' | 'ul' { +export function getViewElementNameForListType( type?: 'bulleted' | 'numbered' | 'todo' ): 'ol' | 'ul' { return type == 'numbered' ? 'ol' : 'ul'; } @@ -137,6 +137,6 @@ export function getViewElementNameForListType( type?: 'bulleted' | 'numbered' ): * * @internal */ -export function getViewElementIdForListType( type?: 'bulleted' | 'numbered', indent?: number ): string { +export function getViewElementIdForListType( type?: 'bulleted' | 'numbered' | 'todo', indent?: number ): string { return `list-${ type }-${ indent }`; } diff --git a/packages/ckeditor5-list/src/documentlistproperties/documentlistpropertiesediting.ts b/packages/ckeditor5-list/src/documentlistproperties/documentlistpropertiesediting.ts index 8d6dabe3de7..251fca2d653 100644 --- a/packages/ckeditor5-list/src/documentlistproperties/documentlistpropertiesediting.ts +++ b/packages/ckeditor5-list/src/documentlistproperties/documentlistpropertiesediting.ts @@ -171,35 +171,8 @@ export default class DocumentListPropertiesEditing extends Plugin { } ); // Make sure that all items in a single list (items at the same level & listType) have the same properties. - documentListEditing.on( 'postFixer', ( evt, { listNodes, writer } ) => { - const previousNodesByIndent = []; // Last seen nodes of lower indented lists. - - for ( const { node, previous } of listNodes ) { - // For the first list block there is nothing to compare with. - if ( !previous ) { - continue; - } - - const nodeIndent = node.getAttribute( 'listIndent' ); - const previousNodeIndent = previous.getAttribute( 'listIndent' ); - - let previousNodeInList = null; // It's like `previous` but has the same indent as current node. - - // Let's find previous node for the same indent. - // We're going to need that when we get back to previous indent. - if ( nodeIndent > previousNodeIndent ) { - previousNodesByIndent[ previousNodeIndent ] = previous; - } - // Restore the one for given indent. - else if ( nodeIndent < previousNodeIndent ) { - previousNodeInList = previousNodesByIndent[ nodeIndent ]; - previousNodesByIndent.length = nodeIndent; - } - // Same indent. - else { - previousNodeInList = previous; - } - + documentListEditing.on( 'postFixer', ( evt, { listNodes, writer } ) => { + for ( const { node, previousNodeInList } of listNodes ) { // This is a first item of a nested list. if ( !previousNodeInList ) { continue; @@ -302,11 +275,15 @@ function createAttributeStrategies( enabledProperties: ListPropertiesConfig ) { editor.commands.add( 'listStyle', new DocumentListStyleCommand( editor, DEFAULT_LIST_TYPE, supportedTypes ) ); }, - appliesToListItem() { - return true; + appliesToListItem( item ) { + return item.getAttribute( 'listType' ) == 'numbered' || item.getAttribute( 'listType' ) == 'bulleted'; }, hasValidAttribute( item ) { + if ( !this.appliesToListItem( item ) ) { + return !item.hasAttribute( 'listStyle' ); + } + if ( !item.hasAttribute( 'listStyle' ) ) { return false; } diff --git a/packages/ckeditor5-list/src/index.ts b/packages/ckeditor5-list/src/index.ts index 5ff7baa1c15..c6dc8eb719c 100644 --- a/packages/ckeditor5-list/src/index.ts +++ b/packages/ckeditor5-list/src/index.ts @@ -26,6 +26,8 @@ export { default as ListPropertiesUI } from './listproperties/listpropertiesui'; export { default as TodoList } from './todolist'; export { default as TodoListEditing } from './todolist/todolistediting'; export { default as TodoListUI } from './todolist/todolistui'; +export { default as TodoDocumentList } from './tododocumentlist'; +export { default as TodoDocumentListEditing } from './tododocumentlist/tododocumentlistediting'; export type { ListConfig, ListPropertiesConfig } from './listconfig'; export type { default as ListStyle } from './liststyle'; @@ -40,5 +42,6 @@ export type { default as ListReversedCommand } from './listproperties/listrevers export type { default as ListStartCommand } from './listproperties/liststartcommand'; export type { default as ListStyleCommand } from './listproperties/liststylecommand'; export type { default as CheckTodoListCommand } from './todolist/checktodolistcommand'; +export type { default as CheckTodoDocumentListCommand } from './tododocumentlist/checktododocumentlistcommand'; import './augmentation'; diff --git a/packages/ckeditor5-list/src/tododocumentlist.ts b/packages/ckeditor5-list/src/tododocumentlist.ts new file mode 100644 index 00000000000..129b3d36abd --- /dev/null +++ b/packages/ckeditor5-list/src/tododocumentlist.ts @@ -0,0 +1,36 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module list/tododocumentlist + */ + +import TodoDocumentListEditing from './tododocumentlist/tododocumentlistediting'; +import TodoListUI from './todolist/todolistui'; +import { Plugin } from 'ckeditor5/src/core'; + +import '../theme/todolist.css'; + +/** + * The to-do list feature. + * + * This is a "glue" plugin that loads the {@link module:list/todolist/todolistediting~TodoListEditing to-do list editing feature} + * and the {@link module:list/todolist/todolistui~TodoListUI to-do list UI feature}. + */ +export default class TodoDocumentList extends Plugin { + /** + * @inheritDoc + */ + public static get requires() { + return [ TodoDocumentListEditing, TodoListUI ] as const; + } + + /** + * @inheritDoc + */ + public static get pluginName() { + return 'TodoDocumentList' as const; + } +} diff --git a/packages/ckeditor5-list/src/tododocumentlist/checktododocumentlistcommand.ts b/packages/ckeditor5-list/src/tododocumentlist/checktododocumentlistcommand.ts new file mode 100644 index 00000000000..18c94ac20f3 --- /dev/null +++ b/packages/ckeditor5-list/src/tododocumentlist/checktododocumentlistcommand.ts @@ -0,0 +1,104 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module list/tododocumentlist/checktododocumentlistcommand + */ + +import { Command, type Editor } from 'ckeditor5/src/core'; +import type { Element } from 'ckeditor5/src/engine'; +import { getAllListItemBlocks } from '../documentlist/utils/model'; + +/** + * The check to-do command. + * + * The command is registered by the {@link module:list/tododocumentlist/tododocumentlistediting~TodoDocumentListEditing} as + * the `checkTodoList` editor command. + */ +export default class CheckTodoDocumentListCommand extends Command { + /** + * A list of to-do list items selected by the {@link module:engine/model/selection~Selection}. + * + * @observable + * @readonly + */ + declare public value: boolean; + + /** + * @inheritDoc + */ + constructor( editor: Editor ) { + super( editor ); + + // Refresh command before executing to be sure all values are up to date. + // It is needed when selection has changed before command execution, in the same change block. + this.on( 'execute', () => { + this.refresh(); + }, { priority: 'highest' } ); + } + + /** + * Updates the command's {@link #value} and {@link #isEnabled} properties based on the current selection. + */ + public override refresh(): void { + const selectedElements = this._getSelectedItems(); + + this.value = this._getValue( selectedElements ); + this.isEnabled = !!selectedElements.length; + } + + /** + * Executes the command. + * + * @param options.forceValue If set, it will force the command behavior. If `true`, the command will apply + * the attribute. Otherwise, the command will remove the attribute. If not set, the command will look for its current + * value to decide what it should do. + */ + public override execute( options: { forceValue?: boolean } = {} ): void { + this.editor.model.change( writer => { + const selectedElements = this._getSelectedItems(); + const value = ( options.forceValue === undefined ) ? !this._getValue( selectedElements ) : options.forceValue; + + for ( const element of selectedElements ) { + if ( value ) { + writer.setAttribute( 'todoListChecked', true, element ); + } else { + writer.removeAttribute( 'todoListChecked', element ); + } + } + } ); + } + + /** + * Returns a value for the command. + */ + private _getValue( selectedElements: Array ): boolean { + return selectedElements.every( element => element.getAttribute( 'todoListChecked' ) ); + } + + /** + * Gets all to-do list items selected by the {@link module:engine/model/selection~Selection}. + */ + private _getSelectedItems() { + const model = this.editor.model; + const schema = model.schema; + + const selectionRange = model.document.selection.getFirstRange()!; + const startElement = selectionRange.start.parent as Element; + const elements: Array = []; + + if ( schema.checkAttribute( startElement, 'todoListChecked' ) ) { + elements.push( ...getAllListItemBlocks( startElement ) ); + } + + for ( const item of selectionRange.getItems( { shallow: true } ) as Iterable ) { + if ( schema.checkAttribute( item, 'todoListChecked' ) && !elements.includes( item ) ) { + elements.push( ...getAllListItemBlocks( item ) ); + } + } + + return elements; + } +} diff --git a/packages/ckeditor5-list/src/tododocumentlist/todocheckboxchangeobserver.ts b/packages/ckeditor5-list/src/tododocumentlist/todocheckboxchangeobserver.ts new file mode 100644 index 00000000000..cdf721287d5 --- /dev/null +++ b/packages/ckeditor5-list/src/tododocumentlist/todocheckboxchangeobserver.ts @@ -0,0 +1,59 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module list/tododocumentlist/todocheckboxchangeobserver + */ + +import { DomEventObserver, type DomEventData } from 'ckeditor5/src/engine'; + +/** + * Observes all to-do list checkboxes state changes. + * + * Note that this observer is not available by default. To make it available it needs to be added to + * {@link module:engine/view/view~View} by {@link module:engine/view/view~View#addObserver} method. + */ +export default class TodoCheckboxChangeObserver extends DomEventObserver<'change'> { + /** + * @inheritDoc + */ + public readonly domEventType = [ 'change' ] as const; + + /** + * @inheritDoc + */ + public onDomEvent( domEvent: Event ): void { + if ( domEvent.target ) { + const viewTarget = this.view.domConverter.mapDomToView( domEvent.target as HTMLElement ); + + if ( + viewTarget && + viewTarget.is( 'element', 'input' ) && + viewTarget.getAttribute( 'type' ) == 'checkbox' && + viewTarget.findAncestor( { classes: 'todo-list__label' } ) + ) { + this.fire( 'todoCheckboxChange', domEvent ); + } + } + } +} + +/** + * Fired when the to-do list checkbox is toggled. + * + * Introduced by {@link module:list/tododocumentlist/todocheckboxchangeobserver~TodoCheckboxChangeObserver}. + * + * Note that this event is not available by default. To make it available, + * {@link module:list/tododocumentlist/todocheckboxchangeobserver~TodoCheckboxChangeObserver} + * needs to be added to {@link module:engine/view/view~View} by the {@link module:engine/view/view~View#addObserver} method. + * + * @see module:list/tododocumentlist/todocheckboxchangeobserver~TodoCheckboxChangeObserver + * @eventName module:engine/view/document~Document#todoCheckboxChange + * @param data The event data. + */ +export type ViewDocumentTodoCheckboxChangeEvent = { + name: 'todoCheckboxChange'; + args: [ data: DomEventData ]; +}; diff --git a/packages/ckeditor5-list/src/tododocumentlist/tododocumentlistediting.ts b/packages/ckeditor5-list/src/tododocumentlist/tododocumentlistediting.ts new file mode 100644 index 00000000000..570facf16fc --- /dev/null +++ b/packages/ckeditor5-list/src/tododocumentlist/tododocumentlistediting.ts @@ -0,0 +1,529 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module list/tododocumentlist/tododocumentlistediting + */ + +import { + Matcher, + type UpcastElementEvent, + type Model, + type Element, + type MatcherPattern, + type ViewElement, + type ViewDocumentKeyDownEvent, + type ViewDocumentArrowKeyEvent, + type MapperViewToModelPositionEvent, + type ViewDocumentFragment +} from 'ckeditor5/src/engine'; + +import { + getCode, + parseKeystroke, + getLocalizedArrowKeyCodeDirection, + type GetCallback, + type Locale +} from 'ckeditor5/src/utils'; + +import { Plugin } from 'ckeditor5/src/core'; + +import { isFirstBlockOfListItem, isListItemBlock } from '../documentlist/utils/model'; +import DocumentListEditing, { + type DocumentListEditingCheckElementEvent, + type DocumentListEditingPostFixerEvent +} from '../documentlist/documentlistediting'; +import DocumentListCommand from '../documentlist/documentlistcommand'; +import CheckTodoDocumentListCommand from './checktododocumentlistcommand'; +import TodoCheckboxChangeObserver, { type ViewDocumentTodoCheckboxChangeEvent } from './todocheckboxchangeobserver'; + +const ITEM_TOGGLE_KEYSTROKE = parseKeystroke( 'Ctrl+Enter' ); + +/** + * The engine of the to-do list feature. It handles creating, editing and removing to-do lists and their items. + * + * It registers the entire functionality of the {@link module:list/documentlist/documentlistediting~DocumentListEditing list editing plugin} + * and extends it with the commands: + * + * - `'todoList'`, + * - `'checkTodoList'`, + */ +export default class TodoDocumentListEditing extends Plugin { + /** + * @inheritDoc + */ + public static get pluginName() { + return 'TodoDocumentListEditing' as const; + } + + /** + * @inheritDoc + */ + public static get requires() { + return [ DocumentListEditing ] as const; + } + + /** + * @inheritDoc + */ + public init(): void { + const editor = this.editor; + const model = editor.model; + const editing = editor.editing; + const documentListEditing = editor.plugins.get( DocumentListEditing ); + + editor.commands.add( 'todoList', new DocumentListCommand( editor, 'todo' ) ); + editor.commands.add( 'checkTodoList', new CheckTodoDocumentListCommand( editor ) ); + + editing.view.addObserver( TodoCheckboxChangeObserver ); + + model.schema.extend( '$container', { allowAttributes: 'todoListChecked' } ); + model.schema.extend( '$block', { allowAttributes: 'todoListChecked' } ); + model.schema.extend( '$blockObject', { allowAttributes: 'todoListChecked' } ); + + model.schema.addAttributeCheck( ( context, attributeName ) => { + const item = context.last; + + if ( attributeName != 'todoListChecked' ) { + return; + } + + if ( !item.getAttribute( 'listItemId' ) || item.getAttribute( 'listType' ) != 'todo' ) { + return false; + } + } ); + + editor.conversion.for( 'upcast' ).add( dispatcher => { + // Upcast of to-do list item is based on a checkbox at the beginning of a
  • to keep compatibility with markdown input. + dispatcher.on( 'element:input', todoItemInputConverter() ); + + // Consume other elements that are normally generated in data downcast, so they won't get captured by GHS. + dispatcher.on( 'element:label', elementUpcastConsumingConverter( + { name: 'label', classes: 'todo-list__label' } + ) ); + dispatcher.on( 'element:label', elementUpcastConsumingConverter( + { name: 'label', classes: [ 'todo-list__label', 'todo-list__label_without-description' ] } + ) ); + dispatcher.on( 'element:span', elementUpcastConsumingConverter( + { name: 'span', classes: 'todo-list__label__description' } + ) ); + dispatcher.on( 'element:ul', attributeUpcastConsumingConverter( + { name: 'ul', classes: 'todo-list' } + ) ); + } ); + + editor.conversion.for( 'downcast' ).elementToElement( { + model: 'paragraph', + view: ( element, { writer } ) => { + if ( isDescriptionBlock( element, documentListEditing.getListAttributeNames() ) ) { + return writer.createContainerElement( 'span', { class: 'todo-list__label__description' } ); + } + }, + converterPriority: 'highest' + } ); + + documentListEditing.registerDowncastStrategy( { + scope: 'list', + attributeName: 'listType', + + setAttributeOnDowncast( writer, value, element ) { + if ( value == 'todo' ) { + writer.addClass( 'todo-list', element ); + } else { + writer.removeClass( 'todo-list', element ); + } + } + } ); + + documentListEditing.registerDowncastStrategy( { + scope: 'itemMarker', + attributeName: 'todoListChecked', + + createElement( writer, modelElement, { dataPipeline } ) { + if ( modelElement.getAttribute( 'listType' ) != 'todo' ) { + return null; + } + + const viewElement = writer.createEmptyElement( 'input', { + type: 'checkbox', + ...( modelElement.getAttribute( 'todoListChecked' ) ? + { checked: 'checked' } : + null + ), + ...( dataPipeline ? + { disabled: 'disabled' } : + { tabindex: '-1' } + ) + } ); + + if ( dataPipeline ) { + return viewElement; + } + + return writer.createContainerElement( 'span', { contenteditable: 'false' }, viewElement ); + }, + + canWrapElement( modelElement ) { + return isDescriptionBlock( modelElement, documentListEditing.getListAttributeNames() ); + }, + + createWrapperElement( writer, modelElement, { dataPipeline } ) { + const classes = [ 'todo-list__label' ]; + + if ( !isDescriptionBlock( modelElement, documentListEditing.getListAttributeNames() ) ) { + classes.push( 'todo-list__label_without-description' ); + } + + return writer.createAttributeElement( dataPipeline ? 'label' : 'span', { + class: classes.join( ' ' ) + } ); + } + } ); + + // We need to register the model length callback for the view checkbox input because it has no mapped model element. + // The to-do list item checkbox does not use the UIElement because it would be trimmed by ViewRange#getTrimmed() + // and removing the default remove converter would not include checkbox in the range to remove. + editing.mapper.registerViewToModelLength( 'input', viewElement => { + if ( + viewElement.getAttribute( 'type' ) == 'checkbox' && + viewElement.findAncestor( { classes: 'todo-list__label' } ) + ) { + return 0; + } + + return editing.mapper.toModelElement( viewElement ) ? 1 : 0; + } ); + + // Verifies if a to-do list block requires reconversion of a first item downcasted as an item description. + documentListEditing.on( 'checkElement', ( evt, { modelElement, viewElement } ) => { + const isFirstTodoModelParagraphBlock = isDescriptionBlock( modelElement, documentListEditing.getListAttributeNames() ); + const hasViewClass = viewElement.hasClass( 'todo-list__label__description' ); + + if ( hasViewClass != isFirstTodoModelParagraphBlock ) { + evt.return = true; + evt.stop(); + } + } ); + + // Verifies if a to-do list block requires reconversion of a checkbox element + // (for example there is a new paragraph inserted as a first block of a list item). + documentListEditing.on( 'checkElement', ( evt, { modelElement, viewElement } ) => { + const isFirstTodoModelItemBlock = modelElement.getAttribute( 'listType' ) == 'todo' && isFirstBlockOfListItem( modelElement ); + + let hasViewItemMarker = false; + const viewWalker = editor.editing.view.createPositionBefore( viewElement ).getWalker( { direction: 'backward' } ); + + for ( const { item } of viewWalker ) { + if ( item.is( 'element' ) && editor.editing.mapper.toModelElement( item ) ) { + break; + } + + if ( item.is( 'element', 'input' ) && item.getAttribute( 'type' ) == 'checkbox' ) { + hasViewItemMarker = true; + } + } + + if ( hasViewItemMarker != isFirstTodoModelItemBlock ) { + evt.return = true; + evt.stop(); + } + } ); + + // Make sure that all blocks of the same list item have the same todoListChecked attribute. + documentListEditing.on( 'postFixer', ( evt, { listNodes, writer } ) => { + for ( const { node, previousNodeInList } of listNodes ) { + // This is a first item of a nested list. + if ( !previousNodeInList ) { + continue; + } + + if ( previousNodeInList.getAttribute( 'listItemId' ) != node.getAttribute( 'listItemId' ) ) { + continue; + } + + const previousHasAttribute = previousNodeInList.hasAttribute( 'todoListChecked' ); + const nodeHasAttribute = node.hasAttribute( 'todoListChecked' ); + + if ( nodeHasAttribute && !previousHasAttribute ) { + writer.removeAttribute( 'todoListChecked', node ); + evt.return = true; + } + else if ( !nodeHasAttribute && previousHasAttribute ) { + writer.setAttribute( 'todoListChecked', true, node ); + evt.return = true; + } + } + } ); + + // Make sure that todoListChecked attribute is only present for to-do list items. + model.document.registerPostFixer( writer => { + const changes = model.document.differ.getChanges(); + let wasFixed = false; + + for ( const change of changes ) { + if ( change.type == 'attribute' && change.attributeKey == 'listType' ) { + const element = change.range.start.nodeAfter!; + + if ( change.attributeOldValue == 'todo' && element.hasAttribute( 'todoListChecked' ) ) { + writer.removeAttribute( 'todoListChecked', element ); + wasFixed = true; + } + } else if ( change.type == 'insert' && change.name != '$text' ) { + for ( const { item } of writer.createRangeOn( change.position.nodeAfter! ) ) { + if ( item.is( 'element' ) && item.getAttribute( 'listType' ) != 'todo' && item.hasAttribute( 'todoListChecked' ) ) { + writer.removeAttribute( 'todoListChecked', item ); + wasFixed = true; + } + } + } + } + + return wasFixed; + } ); + + // Toggle check state of selected to-do list items on keystroke. + this.listenTo( editing.view.document, 'keydown', ( evt, data ) => { + if ( getCode( data ) === ITEM_TOGGLE_KEYSTROKE ) { + editor.execute( 'checkTodoList' ); + evt.stop(); + } + }, { priority: 'high' } ); + + // Toggle check state of a to-do list item clicked on the checkbox. + this.listenTo( editing.view.document, 'todoCheckboxChange', ( evt, data ) => { + const viewTarget = data.target; + + if ( !viewTarget || !viewTarget.is( 'element', 'input' ) ) { + return; + } + + const viewPositionAfter = editing.view.createPositionAfter( viewTarget ); + const modelPositionAfter = editing.mapper.toModelPosition( viewPositionAfter ); + const modelElement = modelPositionAfter.parent; + + if ( modelElement && isListItemBlock( modelElement ) && modelElement.getAttribute( 'listType' ) == 'todo' ) { + this._handleCheckmarkChange( modelElement ); + } + } ); + + // Jump at the start/end of the next node on right arrow key press, when selection is before the checkbox. + // + //

    Foo{}

    + //
    • Bar
    + // + // press: `->` + // + //

    Foo

    + //
    • {}Bar
    + // + this.listenTo( + editing.view.document, + 'arrowKey', + jumpOverCheckmarkOnSideArrowKeyPress( model, editor.locale ), + { context: '$text' } + ); + + // Map view positions inside the checkbox and wrappers to the position in the first block of the list item. + this.listenTo( editing.mapper, 'viewToModelPosition', ( evt, data ) => { + const viewParent = data.viewPosition.parent as ViewElement; + + const isStartOfListItem = viewParent.is( 'attributeElement', 'li' ) && data.viewPosition.offset == 0; + const isStartOfListLabel = isLabelElement( viewParent ) && data.viewPosition.offset <= 1; + + const isInInputWrapper = viewParent.is( 'element', 'span' ) && + viewParent.getAttribute( 'contenteditable' ) == 'false' && + isLabelElement( viewParent.parent ); + + if ( !isStartOfListItem && !isStartOfListLabel && !isInInputWrapper ) { + return; + } + + const nodeAfter = data.modelPosition!.nodeAfter; + + if ( nodeAfter && nodeAfter.getAttribute( 'listType' ) == 'todo' ) { + data.modelPosition = model.createPositionAt( nodeAfter, 0 ); + } + }, { priority: 'low' } ); + } + + /** + * Handles the checkbox element change, moves the selection to the corresponding model item to make it possible + * to toggle the `todoListChecked` attribute using the command, and restores the selection position. + * + * Some say it's a hack :) Moving the selection only for executing the command on a certain node and restoring it after, + * is not a clear solution. We need to design an API for using commands beyond the selection range. + * See https://github.com/ckeditor/ckeditor5/issues/1954. + */ + private _handleCheckmarkChange( listItem: Element ): void { + const editor = this.editor; + const model = editor.model; + const previousSelectionRanges = Array.from( model.document.selection.getRanges() ); + + model.change( writer => { + writer.setSelection( listItem, 'end' ); + editor.execute( 'checkTodoList' ); + writer.setSelection( previousSelectionRanges ); + } ); + } +} + +/** + * Returns an upcast converter that detects a to-do list checkbox and marks the list item as a to-do list. + */ +function todoItemInputConverter(): GetCallback { + return ( evt, data, conversionApi ) => { + const modelCursor = data.modelCursor; + const modelItem = modelCursor.parent as Element; + const viewItem = data.viewItem; + + if ( !conversionApi.consumable.test( viewItem, { name: true } ) ) { + return; + } + + if ( viewItem.getAttribute( 'type' ) != 'checkbox' || !modelCursor.isAtStart || !modelItem.hasAttribute( 'listType' ) ) { + return; + } + + conversionApi.consumable.consume( viewItem, { name: true } ); + + const writer = conversionApi.writer; + + writer.setAttribute( 'listType', 'todo', modelItem ); + + if ( data.viewItem.hasAttribute( 'checked' ) ) { + writer.setAttribute( 'todoListChecked', true, modelItem ); + } + + data.modelRange = writer.createRange( modelCursor ); + }; +} + +/** + * Returns an upcast converter that consumes element matching the given matcher pattern. + */ +function elementUpcastConsumingConverter( matcherPattern: MatcherPattern ): GetCallback { + const matcher = new Matcher( matcherPattern ); + + return ( evt, data, conversionApi ) => { + const matcherResult = matcher.match( data.viewItem ); + + if ( !matcherResult ) { + return; + } + + if ( !conversionApi.consumable.consume( data.viewItem, matcherResult.match ) ) { + return; + } + + Object.assign( data, conversionApi.convertChildren( data.viewItem, data.modelCursor ) ); + }; +} + +/** + * Returns an upcast converter that consumes attributes matching the given matcher pattern. + */ +function attributeUpcastConsumingConverter( matcherPattern: MatcherPattern ): GetCallback { + const matcher = new Matcher( matcherPattern ); + + return ( evt, data, conversionApi ) => { + const matcherResult = matcher.match( data.viewItem ); + + if ( !matcherResult ) { + return; + } + + const match = matcherResult.match; + + match.name = false; + conversionApi.consumable.consume( data.viewItem, match ); + }; +} + +/** + * Returns true if the given list item block should be converted as a description block of a to-do list item. + */ +function isDescriptionBlock( modelElement: Element, listAttributeNames: Array ): boolean { + return modelElement.is( 'element', 'paragraph' ) && + modelElement.getAttribute( 'listType' ) == 'todo' && + isFirstBlockOfListItem( modelElement ) && + hasOnlyListAttributes( modelElement, listAttributeNames ); +} + +/** + * Returns true if only attributes from the given list are present on the model element. + */ +function hasOnlyListAttributes( modelElement: Element, attributeNames: Array ): boolean { + for ( const attributeKey of modelElement.getAttributeKeys() ) { + // Ignore selection attributes stored on block elements. + if ( attributeKey.startsWith( 'selection:' ) ) { + continue; + } + + if ( !attributeNames.includes( attributeKey ) ) { + return false; + } + } + + return true; +} + +/** + * Jump at the start and end of a to-do list item. + */ +function jumpOverCheckmarkOnSideArrowKeyPress( model: Model, locale: Locale ): GetCallback { + return ( eventInfo, domEventData ) => { + const direction = getLocalizedArrowKeyCodeDirection( domEventData.keyCode, locale.contentLanguageDirection ); + + const schema = model.schema; + const selection = model.document.selection; + + if ( !selection.isCollapsed ) { + return; + } + + const position = selection.getFirstPosition()!; + const parent = position.parent as Element; + + // Right arrow before a to-do list item. + if ( direction == 'right' && position.isAtEnd ) { + const newRange = schema.getNearestSelectionRange( model.createPositionAfter( parent ), 'forward' ); + + if ( !newRange ) { + return; + } + + const newRangeParent = newRange.start.parent; + + if ( newRangeParent && isListItemBlock( newRangeParent ) && newRangeParent.getAttribute( 'listType' ) == 'todo' ) { + model.change( writer => writer.setSelection( newRange ) ); + + domEventData.preventDefault(); + domEventData.stopPropagation(); + eventInfo.stop(); + } + } + // Left arrow at the beginning of a to-do list item. + else if ( direction == 'left' && position.isAtStart && isListItemBlock( parent ) && parent.getAttribute( 'listType' ) == 'todo' ) { + const newRange = schema.getNearestSelectionRange( model.createPositionBefore( parent ), 'backward' ); + + if ( !newRange ) { + return; + } + + model.change( writer => writer.setSelection( newRange ) ); + + domEventData.preventDefault(); + domEventData.stopPropagation(); + eventInfo.stop(); + } + }; +} + +/** + * Returns true if the given element is a label element of a to-do list item. + */ +function isLabelElement( viewElement: ViewElement | ViewDocumentFragment | null ): boolean { + return !!viewElement && viewElement.is( 'attributeElement' ) && viewElement.hasClass( 'todo-list__label' ); +} diff --git a/packages/ckeditor5-list/tests/documentlist/_utils/utils.js b/packages/ckeditor5-list/tests/documentlist/_utils/utils.js index e3cbc42c3cf..5c15f135b73 100644 --- a/packages/ckeditor5-list/tests/documentlist/_utils/utils.js +++ b/packages/ckeditor5-list/tests/documentlist/_utils/utils.js @@ -116,10 +116,10 @@ export function setupTestHelpers( editor ) { test.test( input, output, actionCallback ); }, - changeType( input, output ) { + changeType( input, output, type ) { const actionCallback = selection => { const element = selection.getFirstPosition().nodeAfter; - const newType = element.getAttribute( 'listType' ) == 'numbered' ? 'bulleted' : 'numbered'; + const newType = type || ( element.getAttribute( 'listType' ) == 'numbered' ? 'bulleted' : 'numbered' ); model.change( writer => { const itemsToChange = Array.from( selection.getSelectedBlocks() ); @@ -159,12 +159,15 @@ export function setupTestHelpers( editor ) { test.test( input, output, actionCallback, testUndo ); }, - setListAttributes( newIndent, input, output ) { + setListAttributes( newIndentOrType, input, output ) { + const newIndent = typeof newIndentOrType == 'number' ? newIndentOrType : 0; + const newType = typeof newIndentOrType == 'string' ? newIndentOrType : 'bulleted'; + const actionCallback = selection => { const element = selection.getFirstPosition().nodeAfter; model.change( writer => { - writer.setAttributes( { listType: 'bulleted', listIndent: newIndent, listItemId: 'x' }, element ); + writer.setAttributes( { listType: newType, listIndent: newIndent, listItemId: 'x' }, element ); } ); }; diff --git a/packages/ckeditor5-list/tests/documentlist/converters-data.js b/packages/ckeditor5-list/tests/documentlist/converters-data.js index ffa0bed9e79..45b096a030d 100644 --- a/packages/ckeditor5-list/tests/documentlist/converters-data.js +++ b/packages/ckeditor5-list/tests/documentlist/converters-data.js @@ -2506,4 +2506,88 @@ describe( 'DocumentListEditing - converters - data pipeline', () => { } ); } ); } ); + + describe( 'list item content should be able to detect if it is inside some list item', () => { + beforeEach( () => { + model.schema.register( 'obj', { inheritAllFrom: '$inlineObject' } ); + + editor.conversion.for( 'upcast' ).add( dispatcher => { + dispatcher.on( 'element:obj', ( evt, data, conversionApi ) => { + const modelCursor = data.modelCursor; + const modelItem = modelCursor.parent; + const viewItem = data.viewItem; + + // This is the main part. + if ( !modelItem.hasAttribute( 'listType' ) ) { + return; + } + + if ( !conversionApi.consumable.consume( viewItem, { name: true } ) ) { + return; + } + + const writer = conversionApi.writer; + + writer.setAttribute( 'listType', 'todo', modelItem ); + + data.modelRange = writer.createRange( modelCursor ); + } ); + } ); + + editor.plugins.get( DocumentListEditing ).registerDowncastStrategy( { + scope: 'list', + attributeName: 'listType', + + setAttributeOnDowncast( writer, value, element ) { + if ( value === 'todo' ) { + writer.addClass( 'todo-list', element ); + } + } + } ); + + editor.conversion.elementToElement( { model: 'obj', view: 'obj' } ); + } ); + + it( 'content directly inside LI element', () => { + test.data( + '
      ' + + '
    • foo
    • ' + + '
    ' + + '

    bar

    ', + + 'foo' + + 'bar', + + '
      ' + + '
    • foo
    • ' + + '
    ' + + '

     bar

    ' + ); + } ); + + it( 'content inside a P in LI element', () => { + test.data( + '
      ' + + '
    • ' + + '' + + '

      foo

      ' + + '

      123

      ' + + '
    • ' + + '
    ' + + '

    bar

    ', + + 'foo' + + '123' + + 'bar', + + '
      ' + + '
    • ' + + '

      foo

      ' + + '

      123

      ' + + '
    • ' + + '
    ' + + '

     bar

    ' + ); + } ); + } ); } ); diff --git a/packages/ckeditor5-list/tests/documentlist/documentlistediting.js b/packages/ckeditor5-list/tests/documentlist/documentlistediting.js index a03eb25ebec..3f6deb5903e 100644 --- a/packages/ckeditor5-list/tests/documentlist/documentlistediting.js +++ b/packages/ckeditor5-list/tests/documentlist/documentlistediting.js @@ -24,6 +24,7 @@ import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils import ListEditing from '../../src/list/listediting'; import DocumentListIndentCommand from '../../src/documentlist/documentlistindentcommand'; import DocumentListSplitCommand from '../../src/documentlist/documentlistsplitcommand'; +import { isFirstBlockOfListItem } from '../../src/documentlist/utils/model'; import stubUid from './_utils/uid'; import { modelList, prepareTest } from './_utils/utils'; @@ -782,9 +783,219 @@ describe( 'DocumentListEditing - registerDowncastStrategy()', () => { ); } ); + describe( 'should allow registering strategy for list items markers', () => { + describe( 'without first block wrapper', () => { + beforeEach( async () => { + await createEditor( class CustomPlugin extends Plugin { + init() { + this.editor.plugins.get( 'DocumentListEditing' ).registerDowncastStrategy( { + scope: 'itemMarker', + attributeName: 'someFoo', + + createElement( writer, modelElement, { dataPipeline } ) { + return writer.createEmptyElement( 'input', { + type: 'checkbox', + value: modelElement.getAttribute( 'someFoo' ), + ...( dataPipeline ? { disabled: 'disabled' } : null ) + } ); + } + } ); + } + } ); + } ); + + it( 'single block in a list item', () => { + setModelData( model, modelList( ` + * foo + * bar + ` ) ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
      ' + + '
    • foo
    • ' + + '
    • bar
    • ' + + '
    ' + ); + + expect( editor.getData() ).to.equalMarkup( + '
      ' + + '
    • foo
    • ' + + '
    • bar
    • ' + + '
    ' + ); + } ); + + it( 'multiple blocks in a single list item', () => { + setModelData( model, modelList( ` + * foo + bar + ` ) ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
      ' + + '
    • ' + + '' + + '

      foo

      ' + + '

      bar

      ' + + '
    • ' + + '
    ' + ); + + expect( editor.getData() ).to.equalMarkup( + '
      ' + + '
    • ' + + '' + + '

      foo

      ' + + '

      bar

      ' + + '
    • ' + + '
    ' + ); + } ); + } ); + + describe( 'with first block wrapper', () => { + beforeEach( async () => { + await createEditor( class CustomPlugin extends Plugin { + init() { + this.editor.plugins.get( 'DocumentListEditing' ).registerDowncastStrategy( { + scope: 'itemMarker', + attributeName: 'someFoo', + + createElement( writer, modelElement, { dataPipeline } ) { + return writer.createEmptyElement( 'input', { + type: 'checkbox', + value: modelElement.getAttribute( 'someFoo' ), + ...( dataPipeline ? { disabled: 'disabled' } : null ) + } ); + }, + + canWrapElement( modelElement ) { + return isFirstBlockOfListItem( modelElement ) && modelElement.is( 'element', 'paragraph' ); + }, + + createWrapperElement( writer, modelElement, { dataPipeline } ) { + return writer.createAttributeElement( dataPipeline ? 'label' : 'span', { class: 'label' } ); + } + } ); + + this.editor.conversion.for( 'downcast' ).elementToElement( { + model: 'paragraph', + view: ( element, { writer } ) => { + if ( isFirstBlockOfListItem( element ) ) { + return writer.createContainerElement( 'span', { class: 'description' } ); + } + }, + converterPriority: 'highest' + } ); + } + } ); + } ); + + it( 'single block in a list item', () => { + setModelData( model, modelList( ` + * foo + * bar + ` ) ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
      ' + + '
    • ' + + '' + + '' + + 'foo' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'bar' + + '' + + '
    • ' + + '
    ' + ); + + expect( editor.getData() ).to.equalMarkup( + '
      ' + + '
    • ' + + '' + + '
    • ' + + '
    • ' + + '' + + '
    • ' + + '
    ' + ); + } ); + + it( 'multiple blocks in a single list item', () => { + setModelData( model, modelList( ` + * foo + bar + ` ) ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
      ' + + '
    • ' + + '' + + '' + + 'foo' + + '' + + '

      bar

      ' + + '
    • ' + + '
    ' + ); + + expect( editor.getData() ).to.equalMarkup( + '
      ' + + '
    • ' + + '' + + '

      bar

      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'single block (non paragraph) in a list item', () => { + setModelData( model, modelList( ` + * foo + ` ) ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
      ' + + '
    • ' + + '' + + '' + + '' + + '

      foo

      ' + + '
    • ' + + '
    ' + ); + + expect( editor.getData() ).to.equalMarkup( + '
      ' + + '
    • ' + + '' + + '

      foo

      ' + + '
    • ' + + '
    ' + ); + } ); + } ); + } ); + async function createEditor( extraPlugin ) { editor = await VirtualTestEditor.create( { - plugins: [ extraPlugin, Paragraph, DocumentListEditing, UndoEditing ] + plugins: [ extraPlugin, Paragraph, DocumentListEditing, UndoEditing, HeadingEditing ] } ); model = editor.model; diff --git a/packages/ckeditor5-list/tests/documentlistproperties/documentlistreversedcommand.js b/packages/ckeditor5-list/tests/documentlistproperties/documentlistreversedcommand.js index 9088387228a..a644a21b86d 100644 --- a/packages/ckeditor5-list/tests/documentlistproperties/documentlistreversedcommand.js +++ b/packages/ckeditor5-list/tests/documentlistproperties/documentlistreversedcommand.js @@ -63,6 +63,12 @@ describe( 'DocumentListReversedCommand', () => { expect( listReversedCommand.isEnabled ).to.be.false; } ); + it( 'should be false if selection is inside a to-do list item', () => { + setData( model, 'foo[]' ); + + expect( listReversedCommand.isEnabled ).to.be.false; + } ); + it( 'should be true if selection is inside a listItem (collapsed selection)', () => { setData( model, modelList( [ '# Foo[] {reversed:true}' ] ) ); diff --git a/packages/ckeditor5-list/tests/documentlistproperties/documentliststartcommand.js b/packages/ckeditor5-list/tests/documentlistproperties/documentliststartcommand.js index 5c588af7095..08ce81aa31a 100644 --- a/packages/ckeditor5-list/tests/documentlistproperties/documentliststartcommand.js +++ b/packages/ckeditor5-list/tests/documentlistproperties/documentliststartcommand.js @@ -63,6 +63,12 @@ describe( 'DocumentListStartCommand', () => { expect( listStartCommand.isEnabled ).to.be.false; } ); + it( 'should be false if selection is inside a listItem (listType: todo)', () => { + setData( model, 'foo[]' ); + + expect( listStartCommand.isEnabled ).to.be.false; + } ); + it( 'should be true if selection is inside a listItem (collapsed selection)', () => { setData( model, modelList( [ '# Foo[] {start:2}' ] ) ); diff --git a/packages/ckeditor5-list/tests/documentlistproperties/documentliststylecommand.js b/packages/ckeditor5-list/tests/documentlistproperties/documentliststylecommand.js index 6fac94e7684..0a8120ec89e 100644 --- a/packages/ckeditor5-list/tests/documentlistproperties/documentliststylecommand.js +++ b/packages/ckeditor5-list/tests/documentlistproperties/documentliststylecommand.js @@ -75,6 +75,14 @@ describe( 'DocumentListStyleCommand', () => { expect( listStyleCommand.isEnabled ).to.equal( false ); } ); + + it( 'should be true if selection is inside a to-do list item', () => { + setData( model, 'foo[]' ); + + listStyleCommand.refresh(); + + expect( listStyleCommand.isEnabled ).to.be.true; + } ); } ); describe( '#value', () => { diff --git a/packages/ckeditor5-list/tests/manual/todo-documentlist-rtl.html b/packages/ckeditor5-list/tests/manual/todo-documentlist-rtl.html new file mode 100644 index 00000000000..303a2b50a1c --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/todo-documentlist-rtl.html @@ -0,0 +1,35 @@ +

    Editor

    +
    +

    مرحبا

    +
      +
    • مرحبا
    • +
    • مرحبا
    • +
    • مرحبا
    • +
    • مرحبا
    • +
    • مرحبا
    • +
    • مرحبا
    • +
    • مرحبا
    • +
    • مرحبا
    • +
    +

    مرحبا.

    +

    مرحبا

    +
      +
    1. مرحبا
    2. +
    +
      +
    • مرحبا
    • +
    +
      +
    • مرحبا
    • +
    +
    + +

    Editor content preview

    +
    + + diff --git a/packages/ckeditor5-list/tests/manual/todo-documentlist-rtl.js b/packages/ckeditor5-list/tests/manual/todo-documentlist-rtl.js new file mode 100644 index 00000000000..84c0d516386 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/todo-documentlist-rtl.js @@ -0,0 +1,79 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals console, window, document */ + +import { ClassicEditor } from '@ckeditor/ckeditor5-editor-classic'; + +import { Essentials } from '@ckeditor/ckeditor5-essentials'; +import { Autoformat } from '@ckeditor/ckeditor5-autoformat'; +import { BlockQuote } from '@ckeditor/ckeditor5-block-quote'; +import { Bold, Italic } from '@ckeditor/ckeditor5-basic-styles'; +import { Heading } from '@ckeditor/ckeditor5-heading'; +import { Link } from '@ckeditor/ckeditor5-link'; +import { MediaEmbed } from '@ckeditor/ckeditor5-media-embed'; +import { Paragraph } from '@ckeditor/ckeditor5-paragraph'; +import { Table, TableToolbar } from '@ckeditor/ckeditor5-table'; +import { FontSize } from '@ckeditor/ckeditor5-font'; +import { Indent } from '@ckeditor/ckeditor5-indent'; +import { SourceEditing } from '@ckeditor/ckeditor5-source-editing'; +import { Alignment } from '@ckeditor/ckeditor5-alignment'; + +import Documentlist from '../../src/documentlist'; +import TodoDocumentlist from '../../src/tododocumentlist'; + +ClassicEditor + .create( document.querySelector( '#editor' ), { + plugins: [ + Essentials, + Autoformat, + BlockQuote, + Bold, + Heading, + Italic, + Link, + MediaEmbed, + Paragraph, + Table, + TableToolbar, + FontSize, + Indent, + Documentlist, + TodoDocumentlist, + SourceEditing, + Alignment + ], + language: 'ar', + toolbar: [ + 'heading', + '|', + 'bulletedList', 'numberedList', 'todoList', 'outdent', 'indent', + '|', + 'bold', 'link', 'insertTable', 'fontSize', 'alignment', + '|', + 'undo', 'redo', '|', 'sourceEditing' + ], + table: { + contentToolbar: [ + 'tableColumn', + 'tableRow', + 'mergeTableCells' + ] + } + } ) + .then( editor => { + window.editor = editor; + + const contentPreviewBox = document.getElementById( 'preview' ); + + contentPreviewBox.innerHTML = editor.getData(); + + editor.model.document.on( 'change:data', () => { + contentPreviewBox.innerHTML = editor.getData(); + } ); + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-list/tests/manual/todo-documentlist-rtl.md b/packages/ckeditor5-list/tests/manual/todo-documentlist-rtl.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/ckeditor5-list/tests/manual/todo-documentlist.html b/packages/ckeditor5-list/tests/manual/todo-documentlist.html new file mode 100644 index 00000000000..e36503201f3 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/todo-documentlist.html @@ -0,0 +1,44 @@ +

    Editor

    +
    +

    This is a test for list feature.

    +

    Some more text for testing.

    +
      +
    • To-do list item 1
    • +
    • To-do list item 2
    • +
    • To-do list item 3
    • +
    • To-do list item 4
    • +
    • To-do list item 5
    • +
    • + To-do list item 6 +
        +
      • + +
      • +
      +
    • +
    • To-do list item 7
    • +
    • To-do list item 8
    • +
    +

    Paragraph.

    +

    Another testing paragraph.

    +
      +
    1. Numbered list item
    2. +
    +
      +
    • To-do list item
    • +
    +
      +
    • Bullet list
    • +
    +

    Checkbox in paragraph

    +
    + +

    Editor content preview

    +
    + + diff --git a/packages/ckeditor5-list/tests/manual/todo-documentlist.js b/packages/ckeditor5-list/tests/manual/todo-documentlist.js new file mode 100644 index 00000000000..68d9384abe9 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/todo-documentlist.js @@ -0,0 +1,97 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals console, window, document */ + +import { ClassicEditor } from '@ckeditor/ckeditor5-editor-classic'; + +import { Essentials } from '@ckeditor/ckeditor5-essentials'; +import { Autoformat } from '@ckeditor/ckeditor5-autoformat'; +import { BlockQuote } from '@ckeditor/ckeditor5-block-quote'; +import { Bold, Italic } from '@ckeditor/ckeditor5-basic-styles'; +import { Heading } from '@ckeditor/ckeditor5-heading'; +import { Link, LinkImage } from '@ckeditor/ckeditor5-link'; +import { MediaEmbed } from '@ckeditor/ckeditor5-media-embed'; +import { Paragraph } from '@ckeditor/ckeditor5-paragraph'; +import { Table, TableToolbar } from '@ckeditor/ckeditor5-table'; +import { FontSize } from '@ckeditor/ckeditor5-font'; +import { Indent } from '@ckeditor/ckeditor5-indent'; +import { SourceEditing } from '@ckeditor/ckeditor5-source-editing'; +import { GeneralHtmlSupport } from '@ckeditor/ckeditor5-html-support'; +import { Alignment } from '@ckeditor/ckeditor5-alignment'; +import { CloudServices } from '@ckeditor/ckeditor5-cloud-services'; +import { EasyImage } from '@ckeditor/ckeditor5-easy-image'; +import { Image, ImageResize, ImageInsert } from '@ckeditor/ckeditor5-image'; + +import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config'; + +import Documentlist from '../../src/documentlist'; +import TodoDocumentlist from '../../src/tododocumentlist'; + +ClassicEditor + .create( document.querySelector( '#editor' ), { + plugins: [ + Essentials, + Autoformat, + BlockQuote, + Bold, + Heading, + Italic, + Link, + MediaEmbed, + Paragraph, + Table, + TableToolbar, + FontSize, + Indent, + Documentlist, + TodoDocumentlist, + SourceEditing, + GeneralHtmlSupport, + Alignment, + Image, + CloudServices, + EasyImage, + ImageResize, + ImageInsert, + LinkImage + ], + toolbar: [ + 'heading', + '|', + 'bulletedList', 'numberedList', 'todoList', 'outdent', 'indent', + '|', + 'bold', 'link', 'fontSize', 'alignment', + '|', + 'insertTable', 'insertImage', + '|', + 'undo', 'redo', '|', 'sourceEditing' + ], + cloudServices: CS_CONFIG, + table: { + contentToolbar: [ + 'tableColumn', + 'tableRow', + 'mergeTableCells' + ] + }, + htmlSupport: { + allow: [ { name: /.*/, attributes: true, classes: true, styles: true } ] + } + } ) + .then( editor => { + window.editor = editor; + + const contentPreviewBox = document.getElementById( 'preview' ); + + contentPreviewBox.innerHTML = editor.getData(); + + editor.model.document.on( 'change:data', () => { + contentPreviewBox.innerHTML = editor.getData(); + } ); + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-list/tests/manual/todo-documentlist.md b/packages/ckeditor5-list/tests/manual/todo-documentlist.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/ckeditor5-list/tests/tododocumentlist.js b/packages/ckeditor5-list/tests/tododocumentlist.js new file mode 100644 index 00000000000..c8a4df15897 --- /dev/null +++ b/packages/ckeditor5-list/tests/tododocumentlist.js @@ -0,0 +1,18 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import TodoDocumentList from '../src/tododocumentlist'; +import TodoDocumentListEditing from '../src/tododocumentlist/tododocumentlistediting'; +import TodoListUI from '../src/todolist/todolistui'; + +describe( 'TodoDocumentList', () => { + it( 'should be named', () => { + expect( TodoDocumentList.pluginName ).to.equal( 'TodoDocumentList' ); + } ); + + it( 'should require TodoDocumentListEditing and TodoListUI', () => { + expect( TodoDocumentList.requires ).to.deep.equal( [ TodoDocumentListEditing, TodoListUI ] ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/tododocumentlist/checktododocumentlistcommand.js b/packages/ckeditor5-list/tests/tododocumentlist/checktododocumentlistcommand.js new file mode 100644 index 00000000000..4b46eb0cf89 --- /dev/null +++ b/packages/ckeditor5-list/tests/tododocumentlist/checktododocumentlistcommand.js @@ -0,0 +1,417 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import HeadingEditing from '@ckeditor/ckeditor5-heading/src/headingediting'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; + +import TodoDocumentListEditing from '../../src/tododocumentlist/tododocumentlistediting'; +import CheckTodoDocumentListCommand from '../../src/tododocumentlist/checktododocumentlistcommand'; + +describe( 'CheckTodoListCommand', () => { + let editor, model, command; + + beforeEach( () => { + return VirtualTestEditor + .create( { + plugins: [ Paragraph, HeadingEditing, TodoDocumentListEditing ] + } ) + .then( newEditor => { + editor = newEditor; + model = editor.model; + command = new CheckTodoDocumentListCommand( editor ); + } ); + } ); + + afterEach( () => { + command.destroy(); + + return editor.destroy(); + } ); + + describe( 'isEnabled', () => { + it( 'should be enabled when collapsed selection is inside to-do list item', () => { + setModelData( model, 'f[]oo' ); + + expect( command.isEnabled ).to.equal( true ); + } ); + + it( 'should be enabled when item is already checked', () => { + setModelData( model, 'f[]oo' ); + + expect( command.isEnabled ).to.equal( true ); + } ); + + it( 'should be enabled when non-collapsed selection is inside to-do list item', () => { + setModelData( model, 'f[o]o' ); + + expect( command.isEnabled ).to.equal( true ); + } ); + + it( 'should be disabled when selection is not inside to-do list item', () => { + setModelData( model, 'f[]oo' ); + + expect( command.isEnabled ).to.equal( false ); + } ); + + it( 'should be enabled when at least one to-do list item is selected', () => { + setModelData( model, + 'f[oo' + + 'bar' + + 'ba]z' + ); + + expect( command.isEnabled ).to.equal( true ); + } ); + + it( 'should be disabled when no to-do list item is selected', () => { + setModelData( model, + 'foo' + + 'b[ar' + + 'baz' + + 'b]ax' + ); + + expect( command.isEnabled ).to.equal( false ); + } ); + + it( 'should be enabled when a to-do list item is selected together with other list items', () => { + setModelData( model, + 'fo[o' + + 'bar' + + 'b]az' + ); + + expect( command.isEnabled ).to.equal( true ); + } ); + + it( 'should be enabled when a to-do list item is selected together with other list items in nested list', () => { + setModelData( model, + 'fo[o' + + 'bar' + + 'b]az' + ); + + expect( command.isEnabled ).to.equal( true ); + } ); + + it( 'should be enabled when selection is in paragraph in list item', () => { + setModelData( model, + 'foo' + + 'b[]ar' + ); + + expect( command.isEnabled ).to.equal( true ); + } ); + + it( 'should be enabled when selection is in heading as a first child of list item', () => { + setModelData( model, + 'f[]oo' + + 'bar' + ); + + expect( command.isEnabled ).to.equal( true ); + } ); + + it( 'should be enabled when selection is in heading as a second child of list item', () => { + setModelData( model, + 'bar' + + 'f[]oo' + ); + + expect( command.isEnabled ).to.equal( true ); + } ); + } ); + + describe( 'value', () => { + it( 'should be false when collapsed selection is in not checked element', () => { + setModelData( model, 'f[]oo' ); + + expect( command.value ).to.equal( false ); + } ); + + it( 'should be false when non-collapsed selection is in not checked element', () => { + setModelData( model, 'f[o]o' ); + + expect( command.value ).to.equal( false ); + } ); + + it( 'should be true when collapsed selection is in checked element', () => { + setModelData( model, 'f[]oo' ); + + expect( command.value ).to.equal( true ); + } ); + + it( 'should be true when non-collapsed selection is in checked element', () => { + setModelData( model, 'f[o]o' ); + + expect( command.value ).to.equal( true ); + } ); + + it( 'should be false when at least one selected element is not checked', () => { + setModelData( model, + 'f[oo' + + 'bar' + + 'b]az' + ); + + expect( command.value ).to.equal( false ); + } ); + + it( 'should be true when all selected elements are checked', () => { + setModelData( model, + 'f[oo' + + 'bar' + + 'b]az' + ); + + expect( command.value ).to.equal( true ); + } ); + + it( 'should be true when a checked to-do list items are selected together with other list items', () => { + setModelData( model, + 'fo[o' + + 'bar' + + 'b]az' + ); + + expect( command.value ).to.equal( true ); + } ); + + it( 'should be true when a to-do list item is selected together with other list items in nested list', () => { + setModelData( model, + 'fo[o' + + 'bar' + + 'b]az' + ); + + expect( command.value ).to.equal( true ); + } ); + + it( 'should be true when selection is in paragraph', () => { + setModelData( model, + 'foo' + + 'b[]ar' + ); + + expect( command.value ).to.equal( true ); + } ); + + it( 'should be true when selection is in heading as a first child of checkked list item', () => { + setModelData( model, + 'f[]oo' + + 'bar' + ); + + expect( command.value ).to.equal( true ); + } ); + + it( 'should be true when selection is in heading as a second child of checkked list item', () => { + setModelData( model, + 'bar' + + 'f[]oo' + ); + + expect( command.value ).to.equal( true ); + } ); + } ); + + describe( 'execute()', () => { + it( 'should toggle checked state on to-do list item when collapsed selection is inside this item', () => { + testCommandToggle( + 'f[]oo', + 'f[]oo' + ); + } ); + + it( 'should toggle checked state on to-do list item when non-collapsed selection is inside this item', () => { + testCommandToggle( + 'f[o]o', + 'f[o]o' + ); + } ); + + it( 'should toggle state on multiple items', () => { + testCommandToggle( + 'foo[' + + 'bar' + + ']baz', + + 'foo[' + + 'bar' + + ']baz' + ); + } ); + + it( 'should toggle state on multiple items in nested list', () => { + testCommandToggle( + 'foo[' + + 'bar' + + ']baz', + + 'foo[' + + 'bar' + + ']baz' + ); + } ); + + it( 'should toggle state on multiple items mixed with none to-do list items', () => { + testCommandToggle( + 'a[bc' + + 'foo' + + 'bar' + + 'baz' + + 'xy]z', + + 'a[bc' + + 'foo' + + 'bar' + + 'baz' + + 'xy]z' + ); + } ); + + it( 'should toggle state on multiple items mixed with none to-do list items in nested list', () => { + testCommandToggle( + 'a[bc' + + 'foo' + + 'bar' + + 'baz' + + 'xy]z', + + 'a[bc' + + 'foo' + + 'bar' + + 'baz' + + 'xy]z' + ); + } ); + + it( 'should toggle state on items if selection is in paragraph', () => { + testCommandToggle( + 'foo' + + 'b[]ar', + + 'foo' + + 'b[]ar' + ); + } ); + + it( 'should toggle state items at the same level if selection is in paragraph', () => { + testCommandToggle( + 'foo' + + 'bar' + + 'b[]az', + + 'foo' + + 'bar' + + 'b[]az' + ); + } ); + + it( 'should toggle state items when selection is in heading as a first child of list item', () => { + testCommandToggle( + 'f[]oo' + + 'bar', + 'f[]oo' + + 'bar' + ); + } ); + + it( 'should toggle state items when selection is in heading as a second child of list item', () => { + testCommandToggle( + 'bar' + + 'f[]oo', + 'bar' + + 'f[]oo' + ); + } ); + + it( 'should toggle state items when selection is in heading as a second child of nested list item', () => { + testCommandToggle( + 'foo' + + 'bar' + + 'b[]az', + 'foo' + + 'bar' + + 'b[]az' + ); + } ); + + it( 'should mark all selected items as checked when at least one selected item is not checked', () => { + setModelData( model, + 'foo[' + + 'bar' + + ']baz' + ); + + command.execute(); + + expect( getModelData( model ) ).to.equal( + 'foo[' + + 'bar' + + ']baz' + ); + } ); + + it( 'should mark all selected items as checked when at least one selected item is not checked in nested list', () => { + setModelData( model, + 'foo[' + + 'bar' + + ']baz' + ); + + command.execute(); + + expect( getModelData( model ) ).to.equal( + 'foo[' + + 'bar' + + ']baz' + ); + } ); + + it( 'should do nothing when there are no elements to toggle attribute', () => { + setModelData( model, 'b[]ar' ); + + command.execute(); + + expect( getModelData( model ) ).to.equal( 'b[]ar' ); + } ); + + it( 'should set attribute if `forceValue` parameter is set to `true`', () => { + setModelData( model, 'f[]oo' ); + + command.execute( { forceValue: true } ); + + expect( getModelData( model ) ).to.equal( + 'f[]oo' + ); + } ); + + it( 'should remove attribute if `forceValue` parameter is set to `false`', () => { + setModelData( model, 'f[]oo' ); + + command.execute( { forceValue: false } ); + + expect( getModelData( model ) ).to.equal( + 'f[]oo' + ); + } ); + + function testCommandToggle( initialData, changedData ) { + setModelData( model, initialData ); + + command.execute(); + + expect( getModelData( model ) ).to.equal( changedData ); + + command.execute(); + + expect( getModelData( model ) ).to.equal( initialData ); + } + } ); +} ); diff --git a/packages/ckeditor5-list/tests/tododocumentlist/todocheckboxchangeobserver.js b/packages/ckeditor5-list/tests/tododocumentlist/todocheckboxchangeobserver.js new file mode 100644 index 00000000000..f0e22889983 --- /dev/null +++ b/packages/ckeditor5-list/tests/tododocumentlist/todocheckboxchangeobserver.js @@ -0,0 +1,112 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals document */ + +import View from '@ckeditor/ckeditor5-engine/src/view/view'; +import DomEventObserver from '@ckeditor/ckeditor5-engine/src/view/observer/domeventobserver'; +import createViewRoot from '@ckeditor/ckeditor5-engine/tests/view/_utils/createroot'; +import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; + +import TodoCheckboxChangeObserver from '../../src/tododocumentlist/todocheckboxchangeobserver'; + +describe( 'TodoCheckboxChangeObserver', () => { + let view, viewDocument, observer, domRoot; + + beforeEach( () => { + domRoot = document.createElement( 'div' ); + view = new View(); + viewDocument = view.document; + createViewRoot( viewDocument ); + view.attachDomRoot( domRoot ); + observer = view.addObserver( TodoCheckboxChangeObserver ); + } ); + + afterEach( () => { + view.destroy(); + } ); + + it( 'should extend DomEventObserver', () => { + expect( observer ).instanceof( DomEventObserver ); + } ); + + it( 'should define domEventType', () => { + expect( observer.domEventType ).to.deep.equal( [ 'change' ] ); + } ); + + it( 'should fire `todoCheckboxChange` for a checkbox in a span with "todo-list__label" class', () => { + const spy = sinon.spy(); + + viewDocument.on( 'todoCheckboxChange', spy ); + + setData( view, + '' + + '' + + '' + + '' + + '' + ); + + sinon.assert.notCalled( spy ); + + observer.onDomEvent( { type: 'change', target: domRoot.querySelector( 'input' ) } ); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'should not fire `todoCheckboxChange` for an input without type checkbox', () => { + const spy = sinon.spy(); + + viewDocument.on( 'todoCheckboxChange', spy ); + + setData( view, + '' + + '' + + '' + + '' + + '' + ); + + observer.onDomEvent( { type: 'change', target: domRoot.querySelector( 'input' ) } ); + + sinon.assert.notCalled( spy ); + } ); + + it( 'should not fire `todoCheckboxChange` for a checkbox in a span without a class', () => { + const spy = sinon.spy(); + + viewDocument.on( 'todoCheckboxChange', spy ); + + setData( view, + '' + + '' + + '' + + '' + + '' + ); + + observer.onDomEvent( { type: 'change', target: domRoot.querySelector( 'input' ) } ); + + sinon.assert.notCalled( spy ); + } ); + + it( 'should not fire `todoCheckboxChange` for a span in a span with "todo-list__label" class', () => { + const spy = sinon.spy(); + + viewDocument.on( 'todoCheckboxChange', spy ); + + setData( view, + '' + + '' + + '' + + '' + + '' + ); + + observer.onDomEvent( { type: 'change', target: domRoot.querySelector( 'span[contenteditable=false]' ) } ); + + sinon.assert.notCalled( spy ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/tododocumentlist/tododocumentlistediting-conversion-changes.js b/packages/ckeditor5-list/tests/tododocumentlist/tododocumentlistediting-conversion-changes.js new file mode 100644 index 00000000000..93f0c546838 --- /dev/null +++ b/packages/ckeditor5-list/tests/tododocumentlist/tododocumentlistediting-conversion-changes.js @@ -0,0 +1,3420 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; +import HeadingEditing from '@ckeditor/ckeditor5-heading/src/headingediting'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; +import { UndoEditing } from '@ckeditor/ckeditor5-undo'; +import { CodeBlockEditing } from '@ckeditor/ckeditor5-code-block'; + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + +import TodoDocumentListEditing from '../../src/tododocumentlist/tododocumentlistediting'; +import { setupTestHelpers } from '../documentlist/_utils/utils'; + +import stubUid from '../documentlist/_utils/uid'; + +describe( 'TodoDocumentListEditing - conversion - changes', () => { + let editor, model, test, modelRoot; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ Paragraph, TodoDocumentListEditing, BlockQuoteEditing, TableEditing, CodeBlockEditing, HeadingEditing, UndoEditing ] + } ); + + model = editor.model; + modelRoot = model.document.getRoot(); + + stubUid(); + + test = setupTestHelpers( editor ); + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + describe( 'flat lists', () => { + describe( 'insert', () => { + it( 'list item at the beginning of same list type', () => { + test.insert( + 'p' + + '[x]' + + 'a', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'x' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'list item in the middle of same list type', () => { + test.insert( + 'p' + + 'a' + + '[x]' + + 'b', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'x' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'list item at the end of same list type', () => { + test.insert( + 'p' + + 'a' + + '[x]', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'x' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'list item at the beginning of different list type', () => { + test.insert( + 'p' + + '[x]' + + 'a', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'x' + + '' + + '
    • ' + + '
    ' + + '
      ' + + '
    • a
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'list item in the middle of different list type', () => { + test.insert( + 'p' + + 'a' + + '[x]' + + 'b', + + '

    p

    ' + + '
      ' + + '
    • a
    • ' + + '
    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'x' + + '' + + '
    • ' + + '
    ' + + '
      ' + + '
    • b
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'list item at the end of different list type', () => { + test.insert( + 'p' + + 'a' + + '[x]', + + '

    p

    ' + + '
      ' + + '
    • a
    • ' + + '
    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'x' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'element between list items', () => { + test.insert( + 'a' + + '[x]' + + 'b', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    ' + + '

    x

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'list item that is not a paragraph', () => { + test.insert( + 'p' + + 'a' + + '[x]' + + 'b', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + '' + + '

      x

      ' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'new block at the start of list item', () => { + test.insert( + 'p' + + 'a' + + '[x]' + + 'b', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'x' + + '' + + '

      b

      ' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 3 ) ); + } ); + + it( 'new block at the start of list item that contains other element than paragraph', () => { + test.insert( + 'p' + + 'a' + + '[x]' + + 'b', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'x' + + '' + + '

      b

      ' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 3 ) ); + } ); + + it( 'new block at the end of list item', () => { + test.insert( + 'p' + + 'a' + + '[x]' + + 'b', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '

      x

      ' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'new block at the middle of list item', () => { + test.insert( + 'p' + + 'a' + + 'x1' + + '[x]' + + 'x2' + + 'b', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'x1' + + '' + + '

      x

      ' + + '

      x2

      ' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + } ); + + it( 'new list item in the middle of list item', () => { + test.insert( + 'p' + + 'a' + + 'x1' + + '[y]' + + 'x2' + + 'b', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'x1' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'y' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'x2' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 4 ) ); + } ); + } ); + + describe( 'remove', () => { + it( 'remove the first list item', () => { + test.remove( + 'p' + + '[a]' + + 'b' + + 'c', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'c' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'remove list item from the middle', () => { + test.remove( + 'p' + + 'a' + + '[b]' + + 'c', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'c' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'remove the last list item', () => { + test.remove( + 'p' + + 'a' + + 'b' + + '[c]', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'remove the only list item', () => { + test.remove( + 'p' + + '[x]' + + 'p', + + '

    p

    ' + + '

    p

    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'remove element from between lists of same type', () => { + test.remove( + 'p' + + 'a' + + '[x]' + + 'b' + + 'p', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    ' + + '

    p

    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'remove element from between lists of different type', () => { + test.remove( + 'p' + + 'a' + + '[x]' + + 'b' + + 'p', + + '

    p

    ' + + '
      ' + + '
    • a
    • ' + + '
    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    ' + + '

    p

    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'remove the first block of a list item', () => { + test.remove( + 'p' + + 'a' + + '[b1]' + + 'b2', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b2' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 2 ) ); + } ); + + it( 'remove the last block of a list item', () => { + test.remove( + 'p' + + 'a1' + + '[a2]' + + 'b', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'a1' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'remove the middle block of a list item', () => { + test.remove( + 'p' + + 'a1' + + '[a2]' + + 'a3', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'a1' + + '' + + '

      a3

      ' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + } ); + + describe( 'change type', () => { + it( 'change first list item into bulleted', () => { + test.changeType( + 'p' + + '[a]' + + 'b' + + 'c', + + '

    p

    ' + + '
      ' + + '
    1. a
    2. ' + + '
    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'c' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'change middle list item', () => { + test.changeType( + 'p' + + 'a' + + '[b]' + + 'c', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    ' + + '
      ' + + '
    1. b
    2. ' + + '
    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'c' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 2 ) ); + } ); + + it( 'change last list item', () => { + test.changeType( + 'p' + + 'a' + + 'b' + + '[c]', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    ' + + '
      ' + + '
    1. c
    2. ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 3 ) ); + } ); + + it( 'change only list item', () => { + test.changeType( + 'p' + + '[a]' + + 'p', + + '

    p

    ' + + '
      ' + + '
    1. a
    2. ' + + '
    ' + + '

    p

    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'change element into to-do list at the edge of two different lists (after to-do list)', () => { + test.changeType( + 'a' + + 'b' + + '[c]' + + 'd', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'c' + + '' + + '
    • ' + + '
    ' + + '
      ' + + '
    • d
    • ' + + '
    ', + + 'todo' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 2 ) ); + } ); + + it( 'change element into to-do list at the edge of two different lists (before to-do list)', () => { + test.changeType( + 'a' + + '[b]' + + 'c' + + 'd', + + '
      ' + + '
    • a
    • ' + + '
    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'c' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'd' + + '' + + '
    • ' + + '
    ', + + 'todo' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'change element into other list at the edge of two different lists (after to-do list)', () => { + test.changeType( + 'a' + + 'b' + + '[c]' + + 'd', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    ' + + '
      ' + + '
    • c
    • ' + + '
    • d
    • ' + + '
    ', + + 'bulleted' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 2 ) ); + } ); + + it( 'change element into other list at the edge of two different lists (before to-do list)', () => { + test.changeType( + 'a' + + '[b]' + + 'c' + + 'd', + + '
      ' + + '
    • a
    • ' + + '
    • b
    • ' + + '
    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'c' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'd' + + '' + + '
    • ' + + '
    ', + + 'bulleted' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'change multiple elements - to other type', () => { + test.changeType( + 'a' + + '[b' + + 'c]' + + 'd', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    ' + + '
      ' + + '
    1. b
    2. ' + + '
    3. c
    4. ' + + '
    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'd' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 2 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + expect( test.reconvertSpy.secondCall.firstArg ).to.equal( modelRoot.getChild( 2 ) ); + } ); + + it( 'change multiple elements - to same type', () => { + test.changeType( + 'a' + + '[b' + + 'c]' + + 'd', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'c' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'd' + + '' + + '
    • ' + + '
    ', + + 'todo' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 2 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + expect( test.reconvertSpy.secondCall.firstArg ).to.equal( modelRoot.getChild( 2 ) ); + } ); + + it( 'change of the first block of a list item (from todo)', () => { + test.changeType( + 'a' + + '[b1]' + + 'b2' + + 'c', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    ' + + '
      ' + + '
    1. b1
    2. ' + + '
    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'b2' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'c' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 2 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + expect( test.reconvertSpy.secondCall.firstArg ).to.equal( modelRoot.getChild( 2 ) ); + } ); + + it( 'change of the first block of a list item (into todo)', () => { + test.changeType( + 'a' + + '[b1]' + + 'b2' + + 'c', + + '
      ' + + '
    • a
    • ' + + '
    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'b1' + + '' + + '
    • ' + + + '
    ' + + '
      ' + + '
    • b2
    • ' + + '
    • c
    • ' + + '
    ', + + 'todo' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 2 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + expect( test.reconvertSpy.secondCall.firstArg ).to.equal( modelRoot.getChild( 2 ) ); + } ); + + it( 'change of the last block of a list item (from todo)', () => { + test.changeType( + 'a' + + 'b1' + + '[b2]' + + 'c', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b1' + + '' + + '
    • ' + + '
    ' + + '
      ' + + '
    1. b2
    2. ' + + '
    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'c' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 2 ) ); + } ); + + it( 'change of the last block of a list item (into todo)', () => { + test.changeType( + 'a' + + 'b1' + + '[b2]' + + 'c', + + '
      ' + + '
    • a
    • ' + + '
    • b1
    • ' + + '
    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'b2' + + '' + + '
    • ' + + '
    ' + + '
      ' + + '
    • c
    • ' + + '
    ', + + 'todo' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 2 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + expect( test.reconvertSpy.secondCall.firstArg ).to.equal( modelRoot.getChild( 2 ) ); + } ); + + it( 'change of the middle block of a list item (from todo)', () => { + test.changeType( + 'a' + + 'b1' + + '[b2]' + + 'b3' + + 'c', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b1' + + '' + + '
    • ' + + '
    ' + + '
      ' + + '
    1. b2
    2. ' + + '
    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'b3' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'c' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 2 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 2 ) ); + expect( test.reconvertSpy.secondCall.firstArg ).to.equal( modelRoot.getChild( 3 ) ); + } ); + + it( 'change of the middle block of a list item (into todo)', () => { + test.changeType( + 'a' + + 'b1' + + '[b2]' + + 'b3' + + 'c', + + '
      ' + + '
    • a
    • ' + + '
    • b1
    • ' + + '
    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'b2' + + '' + + '
    • ' + + '
    ' + + '
      ' + + '
    • b3
    • ' + + '
    • c
    • ' + + '
    ', + + 'todo' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 3 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + expect( test.reconvertSpy.secondCall.firstArg ).to.equal( modelRoot.getChild( 2 ) ); + expect( test.reconvertSpy.thirdCall.firstArg ).to.equal( modelRoot.getChild( 3 ) ); + } ); + + it( 'change outer list type with nested blockquote (from todo)', () => { + test.changeType( + '[a]' + + '
    ' + + 'b' + + 'c' + + '
    ', + + '
      ' + + '
    1. ' + + 'a' + + '
        ' + + '
      • ' + + '' + + '' + + '' + + '
        ' + + '
          ' + + '
        • ' + + '' + + '' + + 'b' + + '' + + '
            ' + + '
          • ' + + '' + + '' + + '' + + 'c' + + '' + + '
          • ' + + '
          ' + + '
        • ' + + '
        ' + + '
        ' + + '
      • ' + + '
      ' + + '
    2. ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 2 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 0 ) ); + expect( test.reconvertSpy.secondCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'change outer list type with nested blockquote (into todo)', () => { + test.changeType( + '[a]' + + '
    ' + + 'b' + + 'c' + + '
    ', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
        ' + + '
      • ' + + '
        ' + + '
          ' + + '
        • ' + + 'b' + + '
            ' + + '
          • c
          • ' + + '
          ' + + '
        • ' + + '
        ' + + '
        ' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ', + + 'todo' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 2 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 0 ) ); + expect( test.reconvertSpy.secondCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'change outer list type with nested code block (from todo)', () => { + test.changeType( + '[a]' + + '' + + 'abc' + + '', + + '
      ' + + '
    1. ' + + 'a' + + '
        ' + + '
      • ' + + '
        ' +
        +										'abc' +
        +									'
        ' + + '
      • ' + + '
      ' + + '
    2. ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 2 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 0 ) ); + expect( test.reconvertSpy.secondCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'change outer list type with nested code block (into todo)', () => { + test.changeType( + '[a]' + + '' + + 'abc' + + '', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
        ' + + '
      • ' + + '
        ' +
        +										'abc' +
        +									'
        ' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ', + + 'todo' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 2 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 0 ) ); + expect( test.reconvertSpy.secondCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + } ); + + describe( 'rename list item element', () => { + it( 'rename first list item', () => { + test.renameElement( + '[a]' + + 'b', + + '
      ' + + '
    • ' + + '' + + '' + + '' + + '

      a

      ' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 0 ) ); + } ); + + it( 'rename middle list item', () => { + test.renameElement( + 'a' + + '[b]' + + 'c', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + '' + + '

      b

      ' + + '
    • ' + + '
    • ' + + '' + + '' + + 'c' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'rename last list item', () => { + test.renameElement( + 'a' + + '[b]', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + '' + + '

      b

      ' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'rename first list item to paragraph', () => { + test.renameElement( + '[a]' + + 'b', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 0 ) ); + } ); + + it( 'rename middle list item to paragraph', () => { + test.renameElement( + 'a' + + '[b]' + + 'c', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'c' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'rename last list item to paragraph', () => { + test.renameElement( + 'a' + + '[b]', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'rename first block of list item', () => { + test.renameElement( + 'a' + + '[b1]' + + 'b2' + + 'c', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + '' + + '

      b1

      ' + + '

      b2

      ' + + '
    • ' + + '
    • ' + + '' + + '' + + 'c' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'rename last block of list item', () => { + test.renameElement( + 'a' + + 'b1' + + '[b2]' + + 'c', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b1' + + '' + + '

      b2

      ' + + '
    • ' + + '
    • ' + + '' + + '' + + 'c' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'rename first block of list item to paragraph', () => { + test.renameElement( + 'a' + + '[b1]' + + 'b2' + + 'c', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b1' + + '' + + '

      b2

      ' + + '
    • ' + + '
    • ' + + '' + + '' + + 'c' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'rename last block of list item to paragraph', () => { + test.renameElement( + 'a' + + 'b1' + + '[b2]' + + 'c', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b1' + + '' + + '

      b2

      ' + + '
    • ' + + '
    • ' + + '' + + '' + + 'c' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + } ); + + describe( 'remove list item attributes', () => { + it( 'first list item', () => { + test.removeListAttributes( + '[a]' + + 'b', + + '

    a

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 0 ) ); + } ); + + it( 'middle list item', () => { + test.removeListAttributes( + 'a' + + '[b]' + + 'c', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    ' + + '

    b

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'c' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'last list item', () => { + test.removeListAttributes( + 'a' + + '[b]', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    ' + + '

    b

    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'only list item', () => { + test.removeListAttributes( + 'p' + + '[x]' + + 'p', + + '

    p

    ' + + '

    x

    ' + + '

    p

    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'on non paragraph', () => { + test.removeListAttributes( + '[a]' + + 'b', + + '

    a

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 0 ) ); + } ); + + it( 'first block of list item', () => { + test.removeListAttributes( + '[a1]' + + 'a2', + + '

    a1

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'a2' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 2 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 0 ) ); + expect( test.reconvertSpy.secondCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'last block of list item', () => { + test.removeListAttributes( + 'a1' + + '[a2]', + + '
      ' + + '
    • ' + + '' + + '' + + 'a1' + + '' + + '
    • ' + + '
    ' + + '

    a2

    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'middle block of list item', () => { + test.removeListAttributes( + 'a1' + + '[a2]' + + 'a3', + + '
      ' + + '
    • ' + + '' + + '' + + 'a1' + + '' + + '
    • ' + + '
    ' + + '

    a2

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'a3' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 2 ) ); + } ); + } ); + + describe( 'set list item attributes', () => { + it( 'only paragraph', () => { + test.setListAttributes( 'todo', + '[a]', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 0 ) ); + } ); + + it( 'on paragraph between paragraphs', () => { + test.setListAttributes( 'todo', + 'x' + + '[a]' + + 'x', + + '

    x

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    ' + + '

    x

    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'on element before list of same type', () => { + test.setListAttributes( 'todo', + '[x]' + + 'a', + + '
      ' + + '
    • ' + + '' + + '' + + 'x' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 0 ) ); + } ); + + it( 'on element after list of same type', () => { + test.setListAttributes( 'todo', + 'a' + + '[x]', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'x' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'on element before list of different type', () => { + test.setListAttributes( 'todo', + '[x]' + + 'a', + + '
      ' + + '
    • ' + + '' + + '' + + 'x' + + '' + + '
    • ' + + '
    ' + + '
      ' + + '
    1. a
    2. ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 0 ) ); + } ); + + it( 'on element after list of different type', () => { + test.setListAttributes( 'todo', + 'a' + + '[x]', + + '
      ' + + '
    1. a
    2. ' + + '
    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'x' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'on element between lists of same type', () => { + test.setListAttributes( 'todo', + 'a' + + '[x]' + + 'b', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'x' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'before list item with the same id', () => { + test.setListAttributes( 'todo', + '[x]' + + 'a' + + 'b', + + '
      ' + + '
    • ' + + '' + + '' + + 'x' + + '' + + '

      a

      ' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 2 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 0 ) ); + expect( test.reconvertSpy.secondCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'after list item with the same id', () => { + test.setListAttributes( 'todo', + 'a' + + '[x]' + + 'b', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '

      x

      ' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 0 ) ); + } ); + } ); + + describe( 'move', () => { + it( 'list item inside same list', () => { + test.move( + 'p' + + 'a' + + '[b]' + + 'c', + + 4, // Move after last item. + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'c' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'out list item from list', () => { + test.move( + 'p' + + 'a' + + '[b]' + + 'p', + + 4, // Move after second paragraph. + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    ' + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'the only list item', () => { + test.move( + 'p' + + '[a]' + + 'p', + + 3, // Move after second paragraph. + + '

    p

    ' + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'list item between two lists of same type', () => { + test.move( + 'a' + + '[b]' + + 'p' + + 'c' + + 'd', + + 4, // Move between list item "c" and list item "d'. + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    ' + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'c' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'd' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'list item between two lists of different type', () => { + test.move( + 'a' + + '[b]' + + 'p' + + 'c' + + 'd', + + 4, // Move between list item "c" and list item "d'. + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    ' + + '

    p

    ' + + '
      ' + + '
    1. c
    2. ' + + '
    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    ' + + '
      ' + + '
    1. d
    2. ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'element between list items', () => { + test.move( + 'a' + + 'b' + + '[p]', + + 1, // Move between list item "a" and list item "b'. + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    ' + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + } ); + } ); + + describe( 'nested lists', () => { + describe( 'insert', () => { + it( 'after lower indent', () => { + test.insert( + 'p' + + '1' + + '[x]', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + '1' + + '' + + '
        ' + + '
      • ' + + '' + + '' + + 'x' + + '' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'after lower indent (multi block)', () => { + test.insert( + 'p' + + '1a' + + '1b' + + '[xa' + + 'xb]', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + '1a' + + '' + + '

      1b

      ' + + '
        ' + + '
      • ' + + '' + + '' + + 'xa' + + '' + + '

        xb

        ' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'after lower indent, before same indent', () => { + test.insert( + 'p' + + '1' + + '[x]' + + '1.1', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + '1' + + '' + + '
        ' + + '
      • ' + + '' + + '' + + 'x' + + '' + + '
      • ' + + '
      • ' + + '' + + '' + + '1.1' + + '' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'after lower indent, before same indent (multi block)', () => { + test.insert( + 'p' + + '1a' + + '1b' + + '[xa' + + 'xb]' + + '1.1a' + + '1.1b', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + '1a' + + '' + + '

      1b

      ' + + '
        ' + + '
      • ' + + '' + + '' + + 'xa' + + '' + + '

        xb

        ' + + '
      • ' + + '
      • ' + + '' + + '' + + '1.1a' + + '' + + '

        1.1b

        ' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 2 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + expect( test.reconvertSpy.secondCall.firstArg ).to.equal( modelRoot.getChild( 5 ) ); + } ); + + it( 'after lower indent, before lower indent', () => { + test.insert( + 'p' + + '1' + + '[x]' + + '2', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + '1' + + '' + + '
        ' + + '
      • ' + + '' + + '' + + 'x' + + '' + + '
      • ' + + '
      ' + + '
    • ' + + '
    • ' + + '' + + '' + + '2' + + '' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'after lower indent, before lower indent (multi block)', () => { + test.insert( + 'p' + + '1a' + + '1b' + + '[xa' + + 'xb]' + + '2a' + + '2b', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + '1a' + + '' + + '

      1b

      ' + + '
        ' + + '
      • ' + + '' + + '' + + 'xa' + + '' + + '

        xb

        ' + + '
      • ' + + '
      ' + + '
    • ' + + '
    • ' + + '' + + '' + + '2a' + + '' + + '

      2b

      ' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 2 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + expect( test.reconvertSpy.secondCall.firstArg ).to.equal( modelRoot.getChild( 5 ) ); + } ); + + it( 'after same indent', () => { + test.insert( + 'p' + + '1' + + '1.1' + + '[x]', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + '1' + + '' + + '
        ' + + '
      • ' + + '' + + '' + + '1.1' + + '' + + '
      • ' + + '
      • ' + + '' + + '' + + 'x' + + '' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'after same indent (multi block)', () => { + test.insert( + 'p' + + '1a' + + '1b' + + '1.1a' + + '1.1b' + + '[xa' + + 'xb]', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + '1a' + + '' + + '

      1b

      ' + + '
        ' + + '
      • ' + + '' + + '' + + '1.1a' + + '' + + '

        1.1b

        ' + + '
      • ' + + '
      • ' + + '' + + '' + + 'xa' + + '' + + '

        xb

        ' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 2 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + expect( test.reconvertSpy.secondCall.firstArg ).to.equal( modelRoot.getChild( 3 ) ); + } ); + + it( 'after same indent, before higher indent', () => { + test.insert( + 'p' + + '1' + + '[x]' + + '1.1', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + '1' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'x' + + '' + + '
        ' + + '
      • ' + + '' + + '' + + '1.1' + + '' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 3 ) ); + } ); + + it( 'after same indent, before higher indent (multi block)', () => { + test.insert( + 'p' + + '1a' + + '1b' + + '[xa' + + 'xb]' + + '1.1a' + + '1.1b', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + '1a' + + '' + + '

      1b

      ' + + '
    • ' + + '
    • ' + + '' + + '' + + 'xa' + + '' + + '

      xb

      ' + + '
        ' + + '
      • ' + + '' + + '' + + '1.1a' + + '' + + '

        1.1b

        ' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 3 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + expect( test.reconvertSpy.secondCall.firstArg ).to.equal( modelRoot.getChild( 5 ) ); + } ); + + it( 'after higher indent, before higher indent', () => { + test.insert( + 'p' + + '1' + + '1.1' + + '[x]' + + '1.2', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + '1' + + '' + + '
        ' + + '
      • ' + + '' + + '' + + '1.1' + + '' + + '
      • ' + + '
      ' + + '
    • ' + + '
    • ' + + '' + + '' + + 'x' + + '' + + '
        ' + + '
      • ' + + '' + + '' + + '1.2' + + '' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 4 ) ); + } ); + + it( 'after higher indent, before higher indent( multi block)', () => { + test.insert( + 'p' + + '1' + + '1.1' + + '1.1' + + '[x' + + 'x]' + + '1.2' + + '1.2', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + '1' + + '' + + '
        ' + + '
      • ' + + '' + + '' + + '1.1' + + '' + + '

        1.1

        ' + + '
      • ' + + '
      ' + + '
    • ' + + '
    • ' + + '' + + '' + + 'x' + + '' + + '

      x

      ' + + '
        ' + + '
      • ' + + '' + + '' + + '1.2' + + '' + + '

        1.2

        ' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 3 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 2 ) ); + expect( test.reconvertSpy.secondCall.firstArg ).to.equal( modelRoot.getChild( 6 ) ); + expect( test.reconvertSpy.thirdCall.firstArg ).to.equal( modelRoot.getChild( 7 ) ); + } ); + + it( 'list items with too big indent', () => { + test.insert( + 'a' + + 'b' + + '[x' + + 'x' + + 'x]' + + 'c', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
        ' + + '
      • ' + + '' + + '' + + 'b' + + '' + + '
          ' + + '
        • ' + + '' + + '' + + 'x' + + '' + + '
            ' + + '
          • ' + + '' + + '' + + '' + + '' + + 'x' + + '' + + '
          • ' + + '
          ' + + '
        • ' + + '
        • ' + + '' + + '' + + 'x' + + '' + + '
        • ' + + '
        ' + + '
      • ' + + '
      • ' + + '' + + '' + + 'c' + + '' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'additional block before higher indent', () => { + test.insert( + 'p' + + '1' + + '[x]' + + '2', + + '

    p

    ' + + '
      ' + + '
    • ' + + '' + + '' + + '1' + + '' + + '

      x

      ' + + '
        ' + + '
      • ' + + '' + + '' + + '2' + + '' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + } ); + + describe( 'remove', () => { + it( 'the first nested item', () => { + test.remove( + 'a' + + '[b]' + + 'c', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
        ' + + '
      • ' + + '' + + '' + + 'c' + + '' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'the last nested item', () => { + test.remove( + 'a' + + 'b' + + '[c]', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
        ' + + '
      • ' + + '' + + '' + + 'b' + + '' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'the only nested item', () => { + test.remove( + 'a' + + '[c]', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    ' + ); + } ); + + it( 'first list item that has nested list', () => { + test.remove( + '[a]' + + 'b' + + 'c', + + '
      ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
        ' + + '
      • ' + + '' + + '' + + 'c' + + '' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + } ); + + describe( 'change indent', () => { + it( 'indent last item of flat list', () => { + test.changeIndent( + 1, + + 'a' + + '[b]', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
        ' + + '
      • ' + + '' + + '' + + 'b' + + '' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'indent last item in nested list', () => { + test.changeIndent( + 2, + + 'a' + + 'b' + + '[c]', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
        ' + + '
      • ' + + '' + + '' + + 'b' + + '' + + '
          ' + + '
        • ' + + '' + + '' + + 'c' + + '' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'indent item that has nested list', () => { + test.changeIndent( + 1, + + 'a' + + '[b]' + + 'c', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
        ' + + '
      • ' + + '' + + '' + + 'b' + + '' + + '
      • ' + + '
      • ' + + '' + + '' + + 'c' + + '' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'indent item that in view is a next sibling of item that has nested list', () => { + test.changeIndent( + 1, + + 'a' + + 'b' + + '[c]' + + 'd', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
        ' + + '
      • ' + + '' + + '' + + 'b' + + '' + + '
      • ' + + '
      • ' + + '' + + '' + + 'c' + + '' + + '
      • ' + + '
      • ' + + '' + + '' + + 'd' + + '' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'outdent the first item of nested list', () => { + test.changeIndent( + 0, + + 'a' + + '[b]' + + 'c' + + 'd', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    • ' + + '' + + '' + + 'b' + + '' + + '
        ' + + '
      • ' + + '' + + '' + + 'c' + + '' + + '
      • ' + + '
      • ' + + '' + + '' + + 'd' + + '' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'outdent the last item of nested list', () => { + test.changeIndent( + 0, + + 'a' + + 'b' + + '[c]', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
        ' + + '
      • ' + + '' + + '' + + 'b' + + '' + + '
      • ' + + '
      ' + + '
    • ' + + '
    • ' + + '' + + '' + + 'c' + + '' + + '
    • ' + + '
    ' + ); + } ); + + it( 'outdent the only item of nested list', () => { + test.changeIndent( + 1, + + 'a' + + 'b' + + '[c]' + + 'd', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
        ' + + '
      • ' + + '' + + '' + + 'b' + + '' + + '
      • ' + + '
      • ' + + '' + + '' + + 'c' + + '' + + '
      • ' + + '
      • ' + + '' + + '' + + 'd' + + '' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + } ); + + describe( 'remove list item attributes', () => { + it( 'rename nested item from the middle #1', () => { + test.removeListAttributes( + 'a' + + 'b' + + '[c]' + + 'd', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
        ' + + '
      • ' + + '' + + '' + + 'b' + + '' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ' + + '

    c

    ' + + '
      ' + + '
    • ' + + '' + + '' + + 'd' + + '' + + '
    • ' + + '
    ' + ); + } ); + + it( 'rename the only nested item', () => { + test.removeListAttributes( + 'a' + + '[b]', + + '
      ' + + '
    • ' + + '' + + '' + + 'a' + + '' + + '
    • ' + + '
    ' + + '

    b

    ' + ); + } ); + } ); + } ); + + describe( 'position mapping', () => { + let mapper, view, viewRoot; + + beforeEach( () => { + mapper = editor.editing.mapper; + view = editor.editing.view; + viewRoot = view.document.getRoot(); + + setModelData( model, + '0' + + '1' + + '2' + ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '

    0

    ' + + '
      ' + + '
    • ' + + '' + + '' + + '1' + + '' + + '

      2

      ' + + '
    • ' + + '
    ' + ); + } ); + + describe( 'view to model', () => { + function testList( viewPath, modelPath ) { + const viewPos = getViewPosition( viewRoot, viewPath, view ); + const modelPos = mapper.toModelPosition( viewPos ); + + expect( modelPos.root ).to.equal( modelRoot ); + expect( modelPos.path ).to.deep.equal( modelPath ); + } + + it( 'before ul --> before first list item', () => { + testList( [ 1 ], [ 1 ] ); + } ); + + it( 'before first li --> before first list item', () => { + testList( [ 1, 0 ], [ 1 ] ); + } ); + + it( 'before label --> inside list item block', () => { + testList( [ 1, 0, 0 ], [ 1, 0 ] ); + } ); + + it( 'before checkbox wrapper --> inside list item block', () => { + testList( [ 1, 0, 0, 0 ], [ 1, 0 ] ); + } ); + + it( 'before checkbox --> inside list item block', () => { + testList( [ 1, 0, 0, 0, 0 ], [ 1, 0 ] ); + } ); + + it( 'after checkbox --> inside list item block', () => { + testList( [ 1, 0, 0, 0, 1 ], [ 1, 0 ] ); + } ); + + it( 'before description --> inside list item block', () => { + testList( [ 1, 0, 0, 1 ], [ 1, 0 ] ); + } ); + + it( 'start of description --> inside list item block', () => { + testList( [ 1, 0, 0, 1, 0 ], [ 1, 0 ] ); + } ); + + it( 'end of description --> inside list item block', () => { + testList( [ 1, 0, 0, 1, 1 ], [ 1, 1 ] ); + } ); + + it( 'after description --> after first block', () => { + testList( [ 1, 0, 0, 2 ], [ 2 ] ); + } ); + + it( 'after label --> after first block', () => { + testList( [ 1, 0, 1 ], [ 2 ] ); + } ); + } ); + + describe( 'model to view', () => { + function testList( modelPath, viewPath ) { + const modelPos = model.createPositionFromPath( modelRoot, modelPath ); + const viewPos = mapper.toViewPosition( modelPos ); + + expect( viewPos.root ).to.equal( viewRoot ); + expect( getViewPath( viewPos ) ).to.deep.equal( viewPath ); + } + + it( 'before list item --> before ul', () => { + testList( [ 1 ], [ 1 ] ); + } ); + + it( 'start of list item --> start of description', () => { + testList( [ 1, 0 ], [ 1, 0, 0, 1, 0, 0 ] ); + } ); + + it( 'end of list item --> start of description', () => { + testList( [ 1, 1 ], [ 1, 0, 0, 1, 0, 1 ] ); + } ); + + it( 'after list item --> after a description', () => { + testList( [ 2 ], [ 1, 0, 1 ] ); + } ); + + it( 'start of second list item block --> start of paragraph', () => { + testList( [ 2, 0 ], [ 1, 0, 1, 0, 0 ] ); + } ); + + it( 'should not affect other input elements', () => { + model.schema.register( 'input', { inheritAllFrom: '$inlineObject' } ); + editor.conversion.for( 'downcast' ).elementToElement( { + model: 'input', + view: 'input', + converterPriority: 'low' + } ); + + setModelData( model, 'foobar' ); + + testList( [ 0, 7 ], [ 0, 0, 0, 1, 2, 3 ] ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
      ' + + '
    • ' + + '' + + '' + + 'foobar' + + '' + + '
    • ' + + '
    ' + ); + } ); + + it( 'should not affect other input UI elements', () => { + editor.conversion.for( 'downcast' ).markerToElement( { + model: 'input', + view: ( data, { writer } ) => writer.createUIElement( 'input' ) + } ); + + setModelData( model, 'foo[]bar' ); + + model.change( writer => { + writer.addMarker( 'input', { + range: model.document.selection.getFirstRange(), + usingOperation: false, + affectsData: false + } ); + } ); + + testList( [ 0, 6 ], [ 0, 0, 0, 1, 2, 3 ] ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
      ' + + '
    • ' + + '' + + '' + + 'foobar' + + '' + + '
    • ' + + '
    ' + ); + } ); + } ); + + function getViewPosition( root, path, view ) { + let parent = root; + + while ( path.length > 1 ) { + parent = parent.getChild( path.shift() ); + } + + if ( !parent ) { + throw new Error( 'Invalid view path' ); + } + + return view.createPositionAt( parent, path[ 0 ] ); + } + + function getViewPath( position ) { + const path = [ position.offset ]; + let parent = position.parent; + + while ( parent.parent ) { + path.unshift( parent.index ); + parent = parent.parent; + } + + return path; + } + } ); +} ); diff --git a/packages/ckeditor5-list/tests/tododocumentlist/tododocumentlistediting.js b/packages/ckeditor5-list/tests/tododocumentlist/tododocumentlistediting.js new file mode 100644 index 00000000000..c6035d2c03a --- /dev/null +++ b/packages/ckeditor5-list/tests/tododocumentlist/tododocumentlistediting.js @@ -0,0 +1,1234 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* global document, Event */ + +import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; +import HeadingEditing from '@ckeditor/ckeditor5-heading/src/headingediting'; +import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; +import GeneralHtmlSupport from '@ckeditor/ckeditor5-html-support/src/generalhtmlsupport'; +import AlignmentEditing from '@ckeditor/ckeditor5-alignment/src/alignmentediting'; + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; +import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard'; +import { env } from '@ckeditor/ckeditor5-utils'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + +import TodoDocumentListEditing from '../../src/tododocumentlist/tododocumentlistediting'; +import DocumentListEditing from '../../src/documentlist/documentlistediting'; +import DocumentListCommand from '../../src/documentlist/documentlistcommand'; +import CheckTodoDocumentListCommand from '../../src/tododocumentlist/checktododocumentlistcommand'; +import TodoCheckboxChangeObserver from '../../src/tododocumentlist/todocheckboxchangeobserver'; +import DocumentListPropertiesEditing from '../../src/documentlistproperties/documentlistpropertiesediting'; + +import stubUid from '../documentlist/_utils/uid'; + +describe( 'TodoDocumentListEditing', () => { + let editor, model, view, editorElement; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); + + editor = await ClassicTestEditor.create( editorElement, { + plugins: [ Paragraph, TodoDocumentListEditing, BlockQuoteEditing, TableEditing, HeadingEditing, AlignmentEditing ] + } ); + + model = editor.model; + view = editor.editing.view; + + stubUid(); + } ); + + afterEach( async () => { + editorElement.remove(); + + await editor.destroy(); + } ); + + it( 'should have pluginName', () => { + expect( TodoDocumentListEditing.pluginName ).to.equal( 'TodoDocumentListEditing' ); + } ); + + it( 'should load DocumentListEditing', () => { + expect( TodoDocumentListEditing.requires ).to.have.members( [ DocumentListEditing ] ); + } ); + + describe( 'commands', () => { + it( 'should register todoList command', () => { + const command = editor.commands.get( 'todoList' ); + + expect( command ).to.be.instanceOf( DocumentListCommand ); + expect( command ).to.have.property( 'type', 'todo' ); + } ); + + it( 'should register checkTodoList command', () => { + const command = editor.commands.get( 'checkTodoList' ); + + expect( command ).to.be.instanceOf( CheckTodoDocumentListCommand ); + } ); + } ); + + it( 'should register TodoCheckboxChangeObserver', () => { + expect( view.getObserver( TodoCheckboxChangeObserver ) ).to.be.instanceOf( TodoCheckboxChangeObserver ); + } ); + + it( 'should set proper schema rules', () => { + const paragraph = new ModelElement( 'paragraph', { listItemId: 'foo', listType: 'todo' } ); + const heading = new ModelElement( 'heading1', { listItemId: 'foo', listType: 'todo' } ); + const blockQuote = new ModelElement( 'blockQuote', { listItemId: 'foo', listType: 'todo' } ); + const table = new ModelElement( 'table', { listItemId: 'foo', listType: 'todo' }, [ ] ); + + expect( model.schema.checkAttribute( [ '$root', paragraph ], 'todoListChecked' ) ).to.be.true; + expect( model.schema.checkAttribute( [ '$root', heading ], 'todoListChecked' ) ).to.be.true; + expect( model.schema.checkAttribute( [ '$root', blockQuote ], 'todoListChecked' ) ).to.be.true; + expect( model.schema.checkAttribute( [ '$root', table ], 'todoListChecked' ) ).to.be.true; + } ); + + describe( 'upcast', () => { + it( 'should convert li with a checkbox before the first text node as a to-do list item', () => { + testUpcast( + '
    • foo
    ', + 'foo' + ); + } ); + + it( 'should convert the full markup generated by the editor', () => { + testUpcast( + '
    • foo
    ', + 'foo' + ); + + testUpcast( + editor.getData(), + 'foo' + ); + } ); + + it( 'should convert li with checked checkbox as checked to-do list item', () => { + testUpcast( + '
      ' + + '
    • a
    • ' + + '
    • b
    • ' + + '
    • c
    • ' + + '
    ', + 'a' + + 'b' + + 'c' + ); + } ); + + it( 'should not convert li with checkbox in the middle of the text', () => { + testUpcast( + '
    • FooBar
    ', + 'FooBar' + ); + } ); + + it( 'should split items with checkboxes - bulleted list', () => { + testUpcast( + '
      ' + + '
    • foo
    • ' + + '
    • bar
    • ' + + '
    • baz
    • ' + + '
    ', + 'foo' + + 'bar' + + 'baz' + ); + } ); + + it( 'should split items with checkboxes - numbered list', () => { + testUpcast( + '
      ' + + '
    1. foo
    2. ' + + '
    3. bar
    4. ' + + '
    5. baz
    6. ' + + '
    ', + 'foo' + + 'bar' + + 'baz' + ); + } ); + + it( 'should convert li with a checkbox in a nested list', () => { + testUpcast( + '
      ' + + '
    • ' + + '' + + 'foo' + + '
      • foo
      ' + + '
    • ' + + '
    ', + 'foo' + + 'foo' + ); + } ); + + it( 'should convert li with checkboxes in a nested lists (bulleted > todo > todo)', () => { + testUpcast( + '
      ' + + '
    • ' + + '
        ' + + '
      • ' + + 'foo' + + '
        • bar
        ' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ', + '' + + 'foo' + + 'bar' + ); + } ); + + it( 'should convert li with a checkbox and a paragraph', () => { + testUpcast( + '
      ' + + '
    • ' + + '' + + '

      foo

      ' + + '
    • ' + + '
    ', + 'foo' + ); + } ); + + it( 'should convert li with a checkbox and two paragraphs', () => { + testUpcast( + '
      ' + + '
    • ' + + '' + + '

      foo

      ' + + '

      bar

      ' + + '
    • ' + + '
    ', + 'foo' + + 'bar' + ); + } ); + + it( 'should convert li with a checkbox and a blockquote', () => { + testUpcast( + '
      ' + + '
    • ' + + '' + + '
      foo
      ' + + '
    • ' + + '
    ', + '
    ' + + 'foo' + + '
    ' + ); + } ); + + it( 'should convert li with a checkbox and a heading', () => { + testUpcast( + '
      ' + + '
    • ' + + '' + + '

      foo

      ' + + '
    • ' + + '
    ', + 'foo' + ); + } ); + + it( 'should convert li with a checkbox and a table', () => { + testUpcast( + '
      ' + + '
    • ' + + '' + + '
      foo
      ' + + '
    • ' + + '
    ', + '' + + '' + + '' + + 'foo' + + '' + + '' + + '
    ' + ); + } ); + + it( 'should not convert checkbox if consumed by other converter', () => { + model.schema.register( 'input', { inheritAllFrom: '$inlineObject' } ); + editor.conversion.elementToElement( { model: 'input', view: 'input', converterPriority: 'high' } ); + + testUpcast( + '
      ' + + '
    • foo
    • ' + + '
    ', + 'foo' + ); + } ); + + it( 'should not convert label element if already consumed', () => { + model.schema.register( 'label', { inheritAllFrom: '$inlineObject', allowChildren: '$text' } ); + editor.conversion.elementToElement( { model: 'label', view: 'label', converterPriority: 'high' } ); + + testUpcast( + '
      ' + + '
    • ' + + '
    ', + '' + ); + } ); + } ); + + describe( 'upcast - list properties integration', () => { + let editor, model; + + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ Paragraph, TodoDocumentListEditing, DocumentListPropertiesEditing ], + list: { + properties: { + startIndex: true + } + } + } ); + + model = editor.model; + view = editor.editing.view; + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + it( 'should not convert list style on to-do list', () => { + editor.setData( + '
      ' + + '
    • Foo
    • ' + + '
    • Bar
    • ' + + '
    ' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'Foo' + + 'Bar' + ); + } ); + + it( 'should not convert list start on to-do list', () => { + editor.setData( + '
      ' + + '
    1. Foo
    2. ' + + '
    3. Bar
    4. ' + + '
    ' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'Foo' + + 'Bar' + ); + } ); + } ); + + describe( 'upcast - GHS integration', () => { + let element, editor, model; + + beforeEach( async () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); + + editor = await ClassicTestEditor.create( element, { + plugins: [ Paragraph, TodoDocumentListEditing, GeneralHtmlSupport ], + htmlSupport: { + allow: [ + { + name: /./, + styles: true, + attributes: true, + classes: true + } + ] + } + } ); + + model = editor.model; + view = editor.editing.view; + } ); + + afterEach( async () => { + element.remove(); + await editor.destroy(); + } ); + + it( 'should consume all to-do list related elements and attributes so GHS will not handle them (with description)', () => { + editor.setData( + '
      ' + + '
    • ' + + '' + + '
    • ' + + '
    ' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + '' + + 'foo' + + '' + ); + } ); + + it( 'should consume all to-do list related elements and attributes so GHS will not handle them (without description)', () => { + editor.setData( + '
      ' + + '
    • ' + + '' + + '

      foo

      ' + + '
    • ' + + '
    ' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'foo' + ); + } ); + + it( 'should not consume other label elements', () => { + editor.setData( '

    ' ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + '<$text htmlLabel="{}">foo' + ); + } ); + } ); + + describe( 'downcast - editing', () => { + it( 'should convert a todo list item', () => { + testEditing( + 'foo', + '
      ' + + '
    • ' + + '' + + '' + + '' + + '' + + '' + + 'foo' + + '' + + '' + + '
    • ' + + '
    ' + ); + } ); + + it( 'should convert a nested todo list item', () => { + testEditing( + 'foo' + + 'foo', + '
      ' + + '
    • ' + + '' + + '' + + '' + + '' + + '' + + 'foo' + + '' + + '' + + '
        ' + + '
      • ' + + '' + + '' + + '' + + '' + + '' + + 'foo' + + '' + + '' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'should convert to-do list item mixed with bulleted list items', () => { + testEditing( + 'foo' + + 'bar' + + 'baz', + '
      ' + + '
    • ' + + 'foo' + + '
    • ' + + '
    ' + + '
      ' + + '
    • ' + + '' + + '' + + '' + + '' + + 'bar' + + '' + + '
    • ' + + '
    ' + + '
      ' + + '
    • baz
    • ' + + '
    ' + ); + } ); + + it( 'should convert to-do list item mixed with numbered list items', () => { + testEditing( + 'foo' + + 'bar' + + 'baz', + '
      ' + + '
    1. ' + + 'foo' + + '
    2. ' + + '
    ' + + '
      ' + + '
    • ' + + '' + + '' + + '' + + '' + + 'bar' + + '' + + '
    • ' + + '
    ' + + '
      ' + + '
    1. baz
    2. ' + + '
    ' + ); + } ); + + it( 'should wrap a checkbox and first paragraph in a span with a special label class', () => { + testEditing( + 'foo' + + 'bar', + '
      ' + + '
    • ' + + '' + + '' + + '' + + '' + + '' + + 'foo' + + '' + + '' + + '

      bar

      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'should wrap only a checkbox in a span if first element is a blockquote', () => { + testEditing( + '
    ' + + 'foo' + + '
    ', + '
      ' + + '
    • ' + + '' + + '' + + '' + + '' + + '' + + '
      ' + + '

      foo

      ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'should wrap only a checkbox in a span if first element is a heading', () => { + testEditing( + 'foo', + '
      ' + + '
    • ' + + '' + + '' + + '' + + '' + + '' + + '

      foo

      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'should wrap only a checkbox in a span if first element is a table', () => { + testEditing( + '' + + '' + + '' + + 'foo' + + '' + + '' + + '
    ', + '
      ' + + '
    • ' + + '' + + '' + + '' + + '' + + '' + + '
      ' + + '
      ' + + '' + + '' + + '' + + '' + + '' + + '' + + '
      ' + + 'foo' + + '
      ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'should not use description span if there is an alignment set on the paragraph', () => { + setModelData( model, + 'foo' + ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
      ' + + '
    • ' + + '' + + '' + + '' + + '' + + 'foo' + + '' + + '
    • ' + + '
    ' + ); + + editor.execute( 'alignment', { value: 'right' } ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
      ' + + '
    • ' + + '' + + '' + + '' + + '' + + '' + + '

      ' + + 'foo' + + '

      ' + + '
    • ' + + '
    ' + ); + + editor.execute( 'alignment', { value: 'left' } ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
      ' + + '
    • ' + + '' + + '' + + '' + + '' + + 'foo' + + '' + + '
    • ' + + '
    ' + ); + } ); + + it( 'should use description span even if there is an selection attribute on block', () => { + setModelData( model, + '[]' + ); + + model.change( writer => writer.setSelectionAttribute( 'bold', true ) ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + '' + ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
      ' + + '
    • ' + + '' + + '' + + '' + + '' + + '' + + '' + + '
    • ' + + '
    ' + ); + } ); + } ); + + describe( 'downcast - data', () => { + it( 'should convert a todo list item', () => { + testData( + 'foo', + '
      ' + + '
    • ' + + '' + + '
    • ' + + '
    ' + ); + } ); + + it( 'should convert a nested todo list item', () => { + testData( + 'foo' + + 'foo', + '
      ' + + '
    • ' + + '' + + '
        ' + + '
      • ' + + '' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'should convert to-do list item mixed with bulleted list items', () => { + testData( + 'foo' + + 'bar' + + 'baz', + '
      ' + + '
    • foo
    • ' + + '
    ' + + '
      ' + + '
    • ' + + '' + + '
    • ' + + '
    ' + + '
      ' + + '
    • baz
    • ' + + '
    ' + ); + } ); + + it( 'should convert to-do list item mixed with numbered list items', () => { + testData( + 'foo' + + 'bar' + + 'baz', + '
      ' + + '
    1. foo
    2. ' + + '
    ' + + '
      ' + + '
    • ' + + '' + + '
    • ' + + '
    ' + + '
      ' + + '
    1. baz
    2. ' + + '
    ' + ); + } ); + + it( 'should wrap a checkbox and first paragraph in a label element', () => { + testData( + 'foo' + + 'bar', + '
      ' + + '
    • ' + + '' + + '

      bar

      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'should wrap only a checkbox in a label element if first element is a blockquote', () => { + testData( + '
    ' + + 'foo' + + '
    ', + '
      ' + + '
    • ' + + '' + + '

      foo

      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'should wrap only a checkbox in a label element if first element is a heading', () => { + testData( + 'foo', + '
      ' + + '
    • ' + + '' + + '

      foo

      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'should wrap only a checkbox in a label element if first element is a table', () => { + testData( + '' + + '' + + '' + + 'foo' + + '' + + '' + + '
    ', + '
      ' + + '
    • ' + + '' + + '
      ' + + '' + + '' + + '' + + '' + + '' + + '' + + '
      foo
      ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'should convert a todo list item with alignment set', () => { + testData( + 'foo', + + '
      ' + + '
    • ' + + '' + + '

      foo

      ' + + '
    • ' + + '
    ' + ); + } ); + } ); + + describe( 'postfixers', () => { + describe( '`todoListChecked` attribute should be the same in all blocks of a single list item', () => { + it( 'should add missing `todoListChecked` attribute to other blocks', () => { + testPostfixer( + 'foo' + + 'bar' + + 'baz', + + 'foo' + + 'bar' + + 'baz' + ); + } ); + + it( 'should add missing `todoListChecked` attribute to other blocks excluding nested list items', () => { + testPostfixer( + 'foo' + + 'bar' + + 'nested 1' + + 'nested 2' + + 'baz', + + 'foo' + + 'bar' + + 'nested 1' + + 'nested 2' + + 'baz' + ); + } ); + + it( 'should remove `todoListChecked` from other blocks', () => { + testPostfixer( + 'foo' + + 'bar' + + 'baz', + + 'foo' + + 'bar' + + 'baz' + ); + } ); + + it( 'should remove `todoListChecked` from other blocks excluding nested list items', () => { + testPostfixer( + 'foo' + + 'bar' + + 'nested 1' + + 'nested 2' + + 'baz', + + 'foo' + + 'bar' + + 'nested 1' + + 'nested 2' + + 'baz' + ); + } ); + } ); + + describe( '`todoListChecked` attribute should be applied only to todo list items', () => { + it( 'should remove `todoListChecked` from elements other than todo list items', () => { + testPostfixer( + 'foo' + + 'foo' + + 'baz', + + 'foo' + + 'foo' + + 'baz' + ); + } ); + + it( 'should remove `todoListChecked` attribute from list items that are converted from todo to bulleted type', () => { + setModelData( model, + '[foo' + + 'foo' + + 'baz]' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'foo' + + 'foo' + + 'baz' + ); + + editor.execute( 'bulletedList' ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'foo' + + 'foo' + + 'baz' + ); + } ); + } ); + } ); + + describe( 'user interaction events', () => { + it( 'should toggle check state of selected to-do list item on keystroke', () => { + const command = editor.commands.get( 'checkTodoList' ); + + sinon.spy( command, 'execute' ); + + const domEvtDataStub = { + keyCode: getCode( 'enter' ), + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + if ( env.isMac ) { + domEvtDataStub.metaKey = true; + } else { + domEvtDataStub.ctrlKey = true; + } + + view.document.fire( 'keydown', domEvtDataStub ); + + sinon.assert.calledOnce( command.execute ); + + view.document.fire( 'keydown', domEvtDataStub ); + + sinon.assert.calledTwice( command.execute ); + } ); + + it( 'should toggle check state of a to-do list item on clicking the checkbox', () => { + setModelData( model, + 'foo' + ); + + const command = editor.commands.get( 'checkTodoList' ); + + sinon.spy( command, 'execute' ); + + view.getDomRoot().querySelector( 'input' ).dispatchEvent( new Event( 'change', { 'bubbles': true } ) ); + + sinon.assert.calledOnce( command.execute ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'foo' + ); + } ); + + it( 'should toggle check state of a to-do list item on todoCheckboxChange event with input target element', () => { + setModelData( model, + 'foo' + ); + + const command = editor.commands.get( 'checkTodoList' ); + + sinon.spy( command, 'execute' ); + + view.document.fire( 'todoCheckboxChange', { + target: view.document.getRoot().getChild( 0 ).getChild( 0 ).getChild( 0 ).getChild( 0 ).getChild( 0 ) + } ); + + sinon.assert.calledOnce( command.execute ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'foo' + ); + } ); + + it( 'should not toggle check state of a to-do list item on todoCheckboxChange event without target element', () => { + setModelData( model, + 'foo' + ); + + const command = editor.commands.get( 'checkTodoList' ); + + sinon.spy( command, 'execute' ); + + view.document.fire( 'todoCheckboxChange', {} ); + + sinon.assert.notCalled( command.execute ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'foo' + ); + } ); + + it( 'should not toggle check state of a to-do list item on todoCheckboxChange event with target element', () => { + setModelData( model, + 'foo' + ); + + const command = editor.commands.get( 'checkTodoList' ); + + sinon.spy( command, 'execute' ); + + view.document.fire( 'todoCheckboxChange', { + target: view.document.getRoot() + } ); + + sinon.assert.notCalled( command.execute ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'foo' + ); + } ); + + describe( 'arrow keys', () => { + it( 'should move collapsed selection at start of following todo list item on right arrow in todo list item', () => { + setModelData( model, + 'foo[]' + + 'bar' + ); + + const eventData = { + keyCode: getCode( 'arrowRight' ), + preventDefault: () => {}, + stopPropagation: () => {} + }; + + view.document.fire( 'keydown', eventData ); + + expect( getModelData( model ) ).to.equalMarkup( + 'foo' + + '[]bar' + ); + } ); + + it( 'should move collapsed selection at start of following todo list item on right arrow in paragraph', () => { + setModelData( model, + 'foo[]' + + 'bar' + ); + + const eventData = { + keyCode: getCode( 'arrowRight' ), + preventDefault: () => {}, + stopPropagation: () => {} + }; + + view.document.fire( 'keydown', eventData ); + + expect( getModelData( model ) ).to.equalMarkup( + 'foo' + + '[]bar' + ); + } ); + + it( 'should do nothing if selection is at end of the last todo list item and right arrow is pressed', () => { + setModelData( model, + 'foo[]' + ); + + const eventData = { + keyCode: getCode( 'arrowRight' ), + preventDefault: sinon.spy(), + stopPropagation: sinon.spy(), + domTarget: view.getDomRoot() + }; + + view.document.fire( 'keydown', eventData ); + + sinon.assert.notCalled( eventData.preventDefault ); + sinon.assert.notCalled( eventData.stopPropagation ); + + expect( getModelData( model ) ).to.equalMarkup( + 'foo[]' + ); + } ); + + it( 'should not move non-collapsed selection at start of following todo list item on right arrow key in todo list item', () => { + setModelData( model, + 'fo[o]' + + 'bar' + ); + + const eventData = { + keyCode: getCode( 'arrowRight' ), + preventDefault: sinon.spy(), + stopPropagation: sinon.spy(), + domTarget: view.getDomRoot() + }; + + view.document.fire( 'keydown', eventData ); + + sinon.assert.notCalled( eventData.preventDefault ); + sinon.assert.notCalled( eventData.stopPropagation ); + + expect( getModelData( model ) ).to.equalMarkup( + 'fo[o]' + + 'bar' + ); + } ); + + it( 'should not move non-collapsed selection at start of following todo list item on right arrow key in paragraph', () => { + setModelData( model, + 'fo[o]' + + 'bar' + ); + + const eventData = { + keyCode: getCode( 'arrowRight' ), + preventDefault: sinon.spy(), + stopPropagation: sinon.spy(), + domTarget: view.getDomRoot() + }; + + view.document.fire( 'keydown', eventData ); + + sinon.assert.notCalled( eventData.preventDefault ); + sinon.assert.notCalled( eventData.stopPropagation ); + + expect( getModelData( model ) ).to.equalMarkup( + 'fo[o]' + + 'bar' + ); + } ); + + it( 'should move a collapsed selection to the end of the preceding todo list item on left arrow', () => { + setModelData( model, + 'foo' + + '[]bar' + ); + + const eventData = { + keyCode: getCode( 'arrowLeft' ), + preventDefault: () => {}, + stopPropagation: () => {}, + domTarget: view.getDomRoot() + }; + + view.document.fire( 'keydown', eventData ); + + expect( getModelData( model ) ).to.equalMarkup( + 'foo[]' + + 'bar' + ); + } ); + + it( 'should do nothing if selection is at start of first element which is a todo list item and left arrow is pressed', () => { + setModelData( model, + '[]foo' + ); + + const eventData = { + keyCode: getCode( 'arrowLeft' ), + preventDefault: sinon.spy(), + stopPropagation: sinon.spy(), + domTarget: view.getDomRoot() + }; + + view.document.fire( 'keydown', eventData ); + + sinon.assert.notCalled( eventData.preventDefault ); + sinon.assert.notCalled( eventData.stopPropagation ); + + expect( getModelData( model ) ).to.equalMarkup( + '[]foo' + ); + } ); + } ); + } ); + + function testUpcast( input, output ) { + editor.setData( input ); + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( output ); + } + + function testEditing( input, output ) { + setModelData( model, input ); + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( output ); + } + + function testData( input, output ) { + setModelData( model, input ); + expect( editor.getData() ).to.equalMarkup( output ); + } + + function testPostfixer( input, output ) { + setModelData( model, input ); + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( output ); + } +} ); diff --git a/packages/ckeditor5-list/theme/todolist.css b/packages/ckeditor5-list/theme/todolist.css index 6723fc1aae2..915c28085f6 100644 --- a/packages/ckeditor5-list/theme/todolist.css +++ b/packages/ckeditor5-list/theme/todolist.css @@ -7,10 +7,81 @@ --ck-todo-list-checkmark-size: 16px; } +@define-mixin todo-list-checkbox { + -webkit-appearance: none; + display: inline-block; + position: relative; + width: var(--ck-todo-list-checkmark-size); + height: var(--ck-todo-list-checkmark-size); + vertical-align: middle; + + /* Needed on iOS */ + border: 0; + + /* LTR styles */ + left: -25px; + margin-right: -15px; + right: 0; + margin-left: 0; + + /* RTL styles */ + @nest [dir=rtl]& { + left: 0; + margin-right: 0; + right: -25px; + margin-left: -15px; + } + + &::before { + display: block; + position: absolute; + box-sizing: border-box; + content: ''; + width: 100%; + height: 100%; + border: 1px solid hsl(0, 0%, 20%); + border-radius: 2px; + transition: 250ms ease-in-out box-shadow; + } + + &::after { + display: block; + position: absolute; + box-sizing: content-box; + pointer-events: none; + content: ''; + + /* Calculate tick position, size and border-width proportional to the checkmark size. */ + left: calc( var(--ck-todo-list-checkmark-size) / 3 ); + top: calc( var(--ck-todo-list-checkmark-size) / 5.3 ); + width: calc( var(--ck-todo-list-checkmark-size) / 5.3 ); + height: calc( var(--ck-todo-list-checkmark-size) / 2.6 ); + border-style: solid; + border-color: transparent; + border-width: 0 calc( var(--ck-todo-list-checkmark-size) / 8 ) calc( var(--ck-todo-list-checkmark-size) / 8 ) 0; + transform: rotate(45deg); + } + + &[checked] { + &::before { + background: hsl(126, 64%, 41%); + border-color: hsl(126, 64%, 41%); + } + + &::after { + border-color: hsl(0, 0%, 100%); + } + } +} + +/* + * To-do list content styles. + */ .ck-content .todo-list { list-style: none; & li { + position: relative; margin-bottom: 5px; & .todo-list { @@ -20,86 +91,46 @@ & .todo-list__label { & > input { - -webkit-appearance: none; - display: inline-block; - position: relative; - width: var(--ck-todo-list-checkmark-size); - height: var(--ck-todo-list-checkmark-size); - vertical-align: middle; - - /* Needed on iOS */ - border: 0; - - /* LTR styles */ - left: -25px; - margin-right: -15px; - right: 0; - margin-left: 0; - - &::before { - display: block; - position: absolute; - box-sizing: border-box; - content: ''; - width: 100%; - height: 100%; - border: 1px solid hsl(0, 0%, 20%); - border-radius: 2px; - transition: 250ms ease-in-out box-shadow, 250ms ease-in-out background, 250ms ease-in-out border; - } - - &::after { - display: block; - position: absolute; - box-sizing: content-box; - pointer-events: none; - content: ''; - - /* Calculate tick position, size and border-width proportional to the checkmark size. */ - left: calc( var(--ck-todo-list-checkmark-size) / 3 ); - top: calc( var(--ck-todo-list-checkmark-size) / 5.3 ); - width: calc( var(--ck-todo-list-checkmark-size) / 5.3 ); - height: calc( var(--ck-todo-list-checkmark-size) / 2.6 ); - border-style: solid; - border-color: transparent; - border-width: 0 calc( var(--ck-todo-list-checkmark-size) / 8 ) calc( var(--ck-todo-list-checkmark-size) / 8 ) 0; - transform: rotate(45deg); - } - - &[checked] { - &::before { - background: hsl(126, 64%, 41%); - border-color: hsl(126, 64%, 41%); - } - - &::after { - border-color: hsl(0, 0%, 100%); - } - } + @mixin todo-list-checkbox; } & .todo-list__label__description { vertical-align: middle; } - } -} -/* RTL styles */ -[dir="rtl"] .todo-list .todo-list__label > input { - left: 0; - margin-right: 0; - right: -25px; - margin-left: -15px; + &.todo-list__label_without-description input[type=checkbox] { + position: absolute; + } + } } /* - * To-do list should be interactive only during the editing - * (https://github.com/ckeditor/ckeditor5/issues/2090). + * To-do list editing view styles. */ -.ck-editor__editable .todo-list .todo-list__label > input { - cursor: pointer; +.ck-editor__editable.ck-content .todo-list .todo-list__label { + /* + * To-do list should be interactive only during the editing + * (https://github.com/ckeditor/ckeditor5/issues/2090). + */ + & > input, + & > span[contenteditable=false] > input { + cursor: pointer; + + &:hover::before { + box-shadow: 0 0 0 5px hsla(0, 0%, 0%, 0.1); + } + } - &:hover::before { - box-shadow: 0 0 0 5px hsla(0, 0%, 0%, 0.1); + /* + * Document Lists - editing view has an additional span around checkbox. + */ + & > span[contenteditable=false] > input { + @mixin todo-list-checkbox; + } + + &.todo-list__label_without-description { + & input[type=checkbox] { + position: absolute; + } } } diff --git a/packages/ckeditor5-source-editing/src/utils/formathtml.ts b/packages/ckeditor5-source-editing/src/utils/formathtml.ts index d1e440c8774..6c7a21e8578 100644 --- a/packages/ckeditor5-source-editing/src/utils/formathtml.ts +++ b/packages/ckeditor5-source-editing/src/utils/formathtml.ts @@ -51,7 +51,6 @@ export function formatHtml( input: string ): string { { name: 'header', isVoid: false }, { name: 'hgroup', isVoid: false }, { name: 'hr', isVoid: true }, - { name: 'input', isVoid: true }, { name: 'li', isVoid: false }, { name: 'main', isVoid: false }, { name: 'nav', isVoid: false }, @@ -61,7 +60,6 @@ export function formatHtml( input: string ): string { { name: 'table', isVoid: false }, { name: 'tbody', isVoid: false }, { name: 'td', isVoid: false }, - { name: 'textarea', isVoid: false }, { name: 'th', isVoid: false }, { name: 'thead', isVoid: false }, { name: 'tr', isVoid: false }, diff --git a/packages/ckeditor5-source-editing/tests/utils/formathtml.js b/packages/ckeditor5-source-editing/tests/utils/formathtml.js index 57b551f59e0..316472dcbb7 100644 --- a/packages/ckeditor5-source-editing/tests/utils/formathtml.js +++ b/packages/ckeditor5-source-editing/tests/utils/formathtml.js @@ -179,14 +179,13 @@ describe( 'SourceEditing utils', () => { '
    \n' + '
    \n' + '
    \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + + ' ' + + '' + + '' + + '' + + '' + + '' + + '\n' + '
    \n' + '
    \n' + '
    ';