diff --git a/packages/ckeditor5-style/package.json b/packages/ckeditor5-style/package.json index 9315910f9b7..992ec026138 100644 --- a/packages/ckeditor5-style/package.json +++ b/packages/ckeditor5-style/package.json @@ -17,6 +17,7 @@ "devDependencies": { "@ckeditor/ckeditor5-alignment": "^34.0.0", "@ckeditor/ckeditor5-basic-styles": "^34.0.0", + "@ckeditor/ckeditor5-block-quote": "^34.0.0", "@ckeditor/ckeditor5-cloud-services": "^34.0.0", "@ckeditor/ckeditor5-code-block": "^34.0.0", "@ckeditor/ckeditor5-core": "^34.0.0", diff --git a/packages/ckeditor5-style/src/stylecommand.js b/packages/ckeditor5-style/src/stylecommand.js index 9c4af15b0bb..4c1bb79ea64 100644 --- a/packages/ckeditor5-style/src/stylecommand.js +++ b/packages/ckeditor5-style/src/stylecommand.js @@ -18,29 +18,28 @@ import { logWarning, first } from 'ckeditor5/src/utils'; * @extends module:core/command~Command */ export default class StyleCommand extends Command { - constructor( editor, styles ) { + /** + * Creates an instance of the command. + * + * @param {module:core/editor/editor~Editor} editor Editor on which this command will be used. + * @param {Object} styleDefinitions Normalized definitions of the styles. + * @param {Array.} styleDefinitions.block Definitions of block styles. + * @param {Array.} styleDefinitions.inline Definitions of inline styles. + */ + constructor( editor, styleDefinitions ) { super( editor ); /** - * Set of currently applied styles on current selection. + * Set of currently applied styles on the current selection. * * Names of styles correspond to the `name` property of * {@link module:style/style~StyleDefinition configured definitions}. * - * @observable * @readonly - * @member {Boolean|String} #value - */ - - /** - * Styles object. Helps in getting styles definitions by - * class name, style name and model element name. - * - * @private - * @readonly - * @member {module:style/styleediting~Styles} + * @observable + * @member {Array.} #value */ - this.styles = styles; + this.set( 'value', [] ); /** * Names of enabled styles (styles that can be applied to the current selection). @@ -55,33 +54,80 @@ export default class StyleCommand extends Command { this.set( 'enabledStyles', [] ); /** - * Refresh state. + * Normalized definitions of the styles. + * + * @private + * @readonly + * @member {Object} #styleDefinitions */ - this.refresh(); + this._styleDefinitions = styleDefinitions; } /** * @inheritDoc */ refresh() { - let value = []; - const editor = this.editor; - const selection = editor.model.document.selection; - const block = first( selection.getSelectedBlocks() ); + const model = this.editor.model; + const selection = model.document.selection; + + const value = new Set(); + const enabledStyles = new Set(); + + // Inline styles. + for ( const definition of this._styleDefinitions.inline ) { + for ( const ghsAttributeName of definition.ghsAttributes ) { + // Check if this inline style is enabled. + if ( model.schema.checkAttributeInSelection( selection, ghsAttributeName ) ) { + enabledStyles.add( definition.name ); + } + + // Check if this inline style is active. + const ghsAttributeValue = this._getValueFromFirstAllowedNode( ghsAttributeName ); + + if ( hasAllClasses( ghsAttributeValue, definition.classes ) ) { + value.add( definition.name ); + } + } + } + + // Block styles. + const firstBlock = first( selection.getSelectedBlocks() ); + + if ( firstBlock ) { + const ancestorBlocks = firstBlock.getAncestors( { includeSelf: true, parentFirst: true } ); - this.enabledStyles = []; + for ( const block of ancestorBlocks ) { + // E.g. reached a model table when the selection is in a cell. The command should not modify + // ancestors of a table. + if ( model.schema.isLimit( block ) ) { + break; + } + + if ( !model.schema.checkAttribute( block, 'htmlAttributes' ) ) { + continue; + } + + for ( const definition of this._styleDefinitions.block ) { + // Check if this block style is enabled. + if ( !definition.modelElements.includes( block.name ) ) { + continue; + } - if ( !block || !editor.model.schema.isObject( block ) ) { - value = this._prepareNewInlineElementValue( value, selection ); - this.enabledStyles = this.styles.getInlineElementsNames(); + enabledStyles.add( definition.name ); - if ( block ) { - value = this._prepareNewBlockElementValue( value, block ); + // Check if this block style is active. + const ghsAttributeValue = block.getAttribute( 'htmlAttributes' ); + + if ( hasAllClasses( ghsAttributeValue, definition.classes ) ) { + value.add( definition.name ); + } + } } } + this.enabledStyles = Array.from( enabledStyles ).sort(); this.isEnabled = this.enabledStyles.length > 0; - this.value = this.isEnabled ? value : []; + this.value = this.isEnabled ? Array.from( value ).sort() : []; } /** @@ -95,145 +141,68 @@ export default class StyleCommand extends Command { * * When applying inline styles: * * If the selection is on a range, the command applies the style classes to all nodes in that range. * * If the selection is collapsed in a non-empty node, the command applies the style classes to the - * {@link module:engine/model/document~Document#selection} itself (note that typed characters copy style classes from the selection). + * {@link module:engine/model/document~Document#selection}. * * * When applying block styles: * * If the selection is on a range, the command applies the style classes to the nearest block parent element. * - * * When selection is set on a widget object: - * * Do nothing. Widgets are not yet supported by the style command. - * * @fires execute - * @param {String} styleName Style name matching the one defined in the config. + * @param {String} styleName Style name matching the one defined in the + * {@link module:style/style~StyleConfig#definitions configuration}. */ execute( styleName ) { if ( !this.enabledStyles.includes( styleName ) ) { /** - * Style command can be executed only on a correct style name. - * This warning may be caused by passing name that it not specified in any of the - * definitions in the styles config, when trying to apply style that is not allowed - * on given element or passing class name instead of the style name. + * Style command can be executed only with a correct style name. + * + * This warning may be caused by: + * + * * passing a name that is not specified in the {@link module:style/style~StyleConfig#definitions configuration} + * (e.g. a CSS class name), + * * when trying to apply a style that is not allowed on a given element. * * @error style-command-executed-with-incorrect-style-name */ logWarning( 'style-command-executed-with-incorrect-style-name' ); - return; - } - - const editor = this.editor; - const model = editor.model; - const doc = model.document; - const selection = doc.selection; - - const selectedBlockElement = first( selection.getSelectedBlocks() ); - const definition = this.styles.getDefinitionsByName( styleName ); - if ( selectedBlockElement && definition.isBlock ) { - this._handleStyleUpdate( definition, selectedBlockElement ); - } else { - this._handleStyleUpdate( definition, selection ); + return; } - } - /** - * Adds or removes classes to element, range or selection. - * - * @private - * @param {Object} definition Style definition object. - * @param {module:engine/model/selection~Selectable} selectable Selection, range or element to update the style on. - */ - _handleStyleUpdate( definition, selectable ) { - const { name, element, classes } = definition; + const model = this.editor.model; + const selection = model.document.selection; const htmlSupport = this.editor.plugins.get( 'GeneralHtmlSupport' ); - if ( this.value.includes( name ) ) { - htmlSupport.removeModelHtmlClass( element, classes, selectable ); - } else { - htmlSupport.addModelHtmlClass( element, classes, selectable ); - } - } + const definition = [ + ...this._styleDefinitions.inline, + ...this._styleDefinitions.block + ].find( ( { name } ) => name == styleName ); - /** - * Returns inline element value. - * - * @private - * @param {Array} value - * @param {module:engine/model/selection~Selection} selection - */ - _prepareNewInlineElementValue( value, selection ) { - let newValue = [ ...value ]; - - const attributes = selection.getAttributes(); - - for ( const [ attribute ] of attributes ) { - newValue = [ ...newValue, ...this._getAttributeValue( attribute ) ]; - } - - return newValue; - } - - /** - * Returns element value and sets enabled styles. - * - * @private - * @param {Array} value - * @param {Object|null} element - * @return {Array} Current block element styles value. - */ - _prepareNewBlockElementValue( value, element ) { - const availableDefinitions = this.styles.getDefinitionsByElementName( element.name ); - - if ( availableDefinitions ) { - const blockStyleNames = availableDefinitions.map( ( { name } ) => name ); - this.enabledStyles = [ ...this.enabledStyles, ...blockStyleNames ]; - } + model.change( () => { + let selectables; - return [ ...value, ...this._getAttributeValue( 'htmlAttributes' ) ]; - } - - /** - * Get classes attribute value. - * - * @private - * @param {String} attribute - */ - _getAttributeValue( attribute ) { - const value = []; - const classes = attribute === 'htmlAttributes' ? - this._getValueFromBlockElement() : - this._getValueFromFirstAllowedNode( attribute ); - - for ( const htmlClass of classes ) { - const { name } = this.styles.getDefinitionsByClassName( htmlClass ) || {}; - - value.push( name ); - } - - return value; - } - - /** - * Gets classes from currently selected block element. - * - * @private - */ - _getValueFromBlockElement() { - const selection = this.editor.model.document.selection; - const block = first( selection.getSelectedBlocks() ); - const attributes = block.getAttribute( 'htmlAttributes' ); - - if ( attributes ) { - return attributes.classes; - } + if ( definition.isBlock ) { + selectables = getAffectedBlocks( selection.getSelectedBlocks(), definition.modelElements, model.schema ); + } else { + selectables = [ selection ]; + } - return []; + for ( const selectable of selectables ) { + if ( this.value.includes( definition.name ) ) { + htmlSupport.removeModelHtmlClass( definition.element, definition.classes, selectable ); + } else { + htmlSupport.addModelHtmlClass( definition.element, definition.classes, selectable ); + } + } + } ); } /** - * Gets classes from currently selected text element. + * Checks the attribute value of the first node in the selection that allows the attribute. + * For the collapsed selection returns the selection attribute. * * @private - * @param {String} attributeName Text attribute name. + * @param {String} attributeName Name of the GHS attribute. + * @returns {Object|null} The attribute value. */ _getValueFromFirstAllowedNode( attributeName ) { const model = this.editor.model; @@ -241,27 +210,49 @@ export default class StyleCommand extends Command { const selection = model.document.selection; if ( selection.isCollapsed ) { - /* istanbul ignore next */ - const { classes } = selection.getAttribute( attributeName ) || {}; - - /* istanbul ignore next */ - return classes || []; + return selection.getAttribute( attributeName ); } for ( const range of selection.getRanges() ) { for ( const item of range.getItems() ) { - /* istanbul ignore else */ if ( schema.checkAttribute( item, attributeName ) ) { - /* istanbul ignore next */ - const { classes } = item.getAttribute( attributeName ) || {}; - - /* istanbul ignore next */ - return classes || []; + return item.getAttribute( attributeName ); } } } - /* istanbul ignore next */ - return []; + return null; + } +} + +// Verifies if all classes are set on the given GHS attribute. +function hasAllClasses( ghsAttributeValue, classes ) { + if ( !ghsAttributeValue || !ghsAttributeValue.classes ) { + return false; } + + return classes.every( className => ghsAttributeValue.classes.includes( className ) ); +} + +// Returns a set of elements that should be affected by the block-style change. +function getAffectedBlocks( selectedBlocks, elementNames, schema ) { + const blocks = new Set(); + + for ( const selectedBlock of selectedBlocks ) { + const ancestorBlocks = selectedBlock.getAncestors( { includeSelf: true, parentFirst: true } ); + + for ( const block of ancestorBlocks ) { + if ( schema.isLimit( block ) ) { + break; + } + + if ( elementNames.includes( block.name ) ) { + blocks.add( block ); + + break; + } + } + } + + return blocks; } diff --git a/packages/ckeditor5-style/src/styleediting.js b/packages/ckeditor5-style/src/styleediting.js index d883df5d085..a8179e93679 100644 --- a/packages/ckeditor5-style/src/styleediting.js +++ b/packages/ckeditor5-style/src/styleediting.js @@ -43,9 +43,8 @@ export default class StyleEditing extends Plugin { const editor = this.editor; const dataSchema = editor.plugins.get( 'DataSchema' ); const normalizedStyleDefinitions = normalizeConfig( dataSchema, editor.config.get( 'style.definitions' ) ); - const styles = new Styles( normalizedStyleDefinitions ); - editor.commands.add( 'style', new StyleCommand( editor, styles ) ); + editor.commands.add( 'style', new StyleCommand( editor, normalizedStyleDefinitions ) ); this._configureGHSDataFilter( normalizedStyleDefinitions ); } @@ -66,89 +65,6 @@ export default class StyleEditing extends Plugin { } } -/** - * The helper class storing various mappings based on - * {@link module:style/style~StyleConfig#definitions configured style definitions}. Used internally by - * {@link module:style/stylecommand~StyleCommand}. - * - * @private - */ -class Styles { - /** - * @param {Object} An object with normalized style definitions grouped into `block` and `inline` categories (arrays). - */ - constructor( styleDefinitions ) { - this.styleTypes = [ 'inline', 'block' ]; - this.styleDefinitions = styleDefinitions; - this.elementToDefinition = new Map(); - this.classToDefinition = new Map(); - this.nameToDefinition = new Map(); - - this._prepareDefinitionsMapping(); - } - - /** - * Populates various maps to simplify getting config definitions - * by model name,class name and style name. - * - * @private - */ - _prepareDefinitionsMapping() { - for ( const type of this.styleTypes ) { - for ( const { modelElements, name, element, classes, isBlock } of this.styleDefinitions[ type ] ) { - for ( const modelElement of modelElements ) { - const currentValue = this.elementToDefinition.get( modelElement ) || []; - const newValue = [ ...currentValue, { name, element, classes } ]; - this.elementToDefinition.set( modelElement, newValue ); - } - - this.classToDefinition.set( classes.join( ' ' ), { name, element, classes } ); - this.nameToDefinition.set( name, { name, element, classes, isBlock } ); - } - } - } - - /** - * Returns all inline definitions elements names. - * - * @protected - * @return {Array.} Inline elements names. - */ - getInlineElementsNames() { - return this.styleDefinitions.inline.map( ( { name } ) => name ); - } - - /** - * Returns the style config definitions by the model element name. - * - * @protected - * @return {Object} Style config definition. - */ - getDefinitionsByElementName( elementName ) { - return this.elementToDefinition.get( elementName ); - } - - /** - * Returns the style config definitions by the style name. - * - * @protected - * @return {Object} Style config definition. - */ - getDefinitionsByName( name ) { - return this.nameToDefinition.get( name ); - } - - /** - * Returns the style config definitions by the style name. - * - * @protected - * @return {Object} Style config definition. - */ - getDefinitionsByClassName( className ) { - return this.classToDefinition.get( className ); - } -} - // Translates a normalized style definition to a view matcher pattern. // // @param {Object} definition A normalized style definition. diff --git a/packages/ckeditor5-style/src/styleui.js b/packages/ckeditor5-style/src/styleui.js index a35917071c0..de36b4b8a68 100644 --- a/packages/ckeditor5-style/src/styleui.js +++ b/packages/ckeditor5-style/src/styleui.js @@ -39,7 +39,7 @@ export default class StyleUI extends Plugin { const dataSchema = editor.plugins.get( 'DataSchema' ); const normalizedStyleDefinitions = normalizeConfig( dataSchema, editor.config.get( 'style.definitions' ) ); - // Add the dropdown fo the component factory. + // Add the dropdown for the component factory. editor.ui.componentFactory.add( 'style', locale => { const t = locale.t; const dropdown = createDropdown( locale ); diff --git a/packages/ckeditor5-style/src/ui/stylegridbuttonview.js b/packages/ckeditor5-style/src/ui/stylegridbuttonview.js index d7fd577df1e..0f8302afd67 100644 --- a/packages/ckeditor5-style/src/ui/stylegridbuttonview.js +++ b/packages/ckeditor5-style/src/ui/stylegridbuttonview.js @@ -109,7 +109,7 @@ export default class StyleGridButtonView extends ButtonView { * be used instead. This avoids previewing a standalone ``, `
  • `, etc. without a parent. * * @private - * @param {module:style/style~StyleDefinition} styleDefinition + * @param {String} elementName * @returns {Boolean} `true` when the element can be rendered. `false` otherwise. */ _isPreviewable( elementName ) { diff --git a/packages/ckeditor5-style/src/utils.js b/packages/ckeditor5-style/src/utils.js index 34db83f89ce..e880b0b2723 100644 --- a/packages/ckeditor5-style/src/utils.js +++ b/packages/ckeditor5-style/src/utils.js @@ -36,14 +36,21 @@ export function normalizeConfig( dataSchema, styleDefinitions = [] ) { }; for ( const definition of styleDefinitions ) { - const matchingDefinitions = Array.from( dataSchema.getDefinitionsForView( definition.element ) ); - const modelElements = matchingDefinitions.map( ( { model } ) => model ); - const isBlock = matchingDefinitions.some( ( { isBlock } ) => isBlock ); + const modelElements = []; + const ghsAttributes = []; - if ( isBlock ) { - normalizedDefinitions.block.push( { isBlock, modelElements, ...definition } ); + for ( const ghsDefinition of dataSchema.getDefinitionsForView( definition.element ) ) { + if ( ghsDefinition.isBlock ) { + modelElements.push( ghsDefinition.model ); + } else { + ghsAttributes.push( ghsDefinition.model ); + } + } + + if ( modelElements.length ) { + normalizedDefinitions.block.push( { ...definition, modelElements, isBlock: true } ); } else { - normalizedDefinitions.inline.push( { isBlock, modelElements, ...definition } ); + normalizedDefinitions.inline.push( { ...definition, ghsAttributes } ); } } return normalizedDefinitions; diff --git a/packages/ckeditor5-style/tests/manual/sample.jpg b/packages/ckeditor5-style/tests/manual/sample.jpg new file mode 100644 index 00000000000..b77d07e7bff Binary files /dev/null and b/packages/ckeditor5-style/tests/manual/sample.jpg differ diff --git a/packages/ckeditor5-style/tests/manual/styledropdown.html b/packages/ckeditor5-style/tests/manual/styledropdown.html index 93ea0fadb4d..5a7b8e0943d 100644 --- a/packages/ckeditor5-style/tests/manual/styledropdown.html +++ b/packages/ckeditor5-style/tests/manual/styledropdown.html @@ -36,6 +36,41 @@ border-style: dotted; } + .ck.ck-content blockquote.side-quote { + font-family: 'Bebas Neue'; + font-style: normal; + float: right; + width: 35%; + position: relative; + border: 0; + overflow: visible; + z-index: 1; + margin-left: 1em; + } + + .ck.ck-content blockquote.side-quote::before { + content: "“"; + position: absolute; + top: -37px; + left: -10px; + display: block; + font-size: 200px; + color: #e7e7e7; + z-index: -1; + line-height: 1; + } + + .ck.ck-content blockquote.side-quote p { + font-size: 2em; + line-height: 1; + } + + .ck.ck-content blockquote.side-quote p:last-child { + font-size: 1.3em; + text-align: right; + color: #555; + } + .ck.ck-content .colorful-cell { background: hsl(300, 100%, 50%); color: hsl(0, 0%, 100%); @@ -82,6 +117,7 @@

    Lots of styles: block + inline

    Heading 1

    Paragraph

    +
    Foobar
    @@ -91,6 +127,15 @@

    Heading 1

    +
    + bar +
    Caption
    +
    +
    +

    + Collaboration is essential in today’s world with so many people working remotely. +

    +

    Bold Italic Link

    • UL List item 1
    • diff --git a/packages/ckeditor5-style/tests/manual/styledropdown.js b/packages/ckeditor5-style/tests/manual/styledropdown.js index a529fd0f27a..e4bb6eb7c9d 100644 --- a/packages/ckeditor5-style/tests/manual/styledropdown.js +++ b/packages/ckeditor5-style/tests/manual/styledropdown.js @@ -220,6 +220,11 @@ ClassicEditor element: 'h2', classes: [ 'large-heading' ] }, + { + name: 'Large paragraph', + element: 'p', + classes: [ 'large-heading' ] + }, { name: 'Rounded container', element: 'p', @@ -250,6 +255,11 @@ ClassicEditor element: 'pre', classes: [ 'vibrant-code' ] }, + { + name: 'Side quote', + element: 'blockquote', + classes: [ 'side-quote' ] + }, { name: 'Marker', element: 'span', diff --git a/packages/ckeditor5-style/tests/stylecommand.js b/packages/ckeditor5-style/tests/stylecommand.js index 8ac98a5b661..c30f88e275f 100644 --- a/packages/ckeditor5-style/tests/stylecommand.js +++ b/packages/ckeditor5-style/tests/stylecommand.js @@ -5,130 +5,442 @@ /* global document, console */ -import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import Heading from '@ckeditor/ckeditor5-heading/src/heading'; import GeneralHtmlSupport from '@ckeditor/ckeditor5-html-support/src/generalhtmlsupport'; import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; -import Style from '../src/style'; +import ImageBlock from '@ckeditor/ckeditor5-image/src/imageblock'; +import ImageCaption from '@ckeditor/ckeditor5-image/src/imagecaption'; +import CodeBlock from '@ckeditor/ckeditor5-code-block/src/codeblock'; +import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote'; import Table from '@ckeditor/ckeditor5-table/src/table'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; + +import Style from '../src/style'; describe( 'StyleCommand', () => { let editor, editorElement, command, model, doc, root; - beforeEach( () => { - editorElement = document.createElement( 'div' ); - document.body.appendChild( editorElement ); - - return ClassicTestEditor - .create( editorElement, { - plugins: [ Paragraph, Table, Heading, GeneralHtmlSupport, Style ], - style: { - definitions: [ - { - name: 'Marker', - element: 'span', - classes: [ 'marker' ] - }, - { - name: 'Typewriter', - element: 'span', - classes: [ 'typewriter' ] - }, - { - name: 'Deleted text', - element: 'span', - classes: [ 'deleted' ] - }, - { - name: 'Multiple classes', - element: 'span', - classes: [ 'class-one', 'class-two' ] - }, - { - name: 'Big heading', - element: 'h2', - classes: [ 'big-heading' ] - }, - { - name: 'Red heading', - element: 'h2', - classes: [ 'red-heading' ] - }, - { - name: 'Table style', - element: 'table', - classes: [ 'example' ] - } - ] - } - } ) - .then( newEditor => { - editor = newEditor; - model = editor.model; - command = editor.commands.get( 'style' ); - doc = model.document; - root = doc.getRoot(); - } ); + testUtils.createSinonSandbox(); + + const inlineStyles = [ + { + name: 'Marker', + element: 'span', + classes: [ 'marker' ] + }, + { + name: 'Typewriter', + element: 'span', + classes: [ 'typewriter' ] + }, + { + name: 'Deleted text', + element: 'span', + classes: [ 'deleted' ] + }, + { + name: 'Multiple classes', + element: 'span', + classes: [ 'class-one', 'class-two' ] + }, + { + name: 'Vibrant code', + element: 'code', + classes: [ 'vibrant-code' ] + } + ]; + + const blockParagraphStyles = [ + { + name: 'Red paragraph', + element: 'p', + classes: [ 'red' ] + } + ]; + + const blockHeadingStyles = [ + { + name: 'Big heading', + element: 'h2', + classes: [ 'big-heading' ] + }, + { + name: 'Red heading', + element: 'h2', + classes: [ 'red' ] + } + ]; + + const blockCodeBlockStyles = [ + { + name: 'Vibrant code block', + element: 'pre', + classes: [ 'vibrant-code' ] + } + ]; + + const blockQuoteBlockStyles = [ + { + name: 'Side quote', + element: 'blockquote', + classes: [ 'side-quote' ] + } + ]; + + const blockWidgetStyles = [ + { + name: 'Table style', + element: 'table', + classes: [ 'example' ] + } + ]; + + beforeEach( async () => { + await createEditor( [ + ...inlineStyles, + ...blockParagraphStyles, + ...blockHeadingStyles, + ...blockCodeBlockStyles, + ...blockQuoteBlockStyles, + ...blockWidgetStyles + ] ); } ); - afterEach( () => { + afterEach( async () => { editorElement.remove(); - return editor.destroy(); + await editor.destroy(); } ); - describe( 'value', () => { - it( 'should detect applied inline style', () => { - setData( model, '[foobar]' ); + describe( '#enabledStyles', () => { + describe( 'block styles', () => { + it( 'should enable styles for paragraph', () => { + setData( model, 'foo[bar]baz' ); + + command.refresh(); - model.change( writer => { - writer.setAttribute( 'htmlSpan', { classes: [ 'marker' ] }, root.getChild( 0 ).getChild( 0 ) ); + expect( command.enabledStyles ).to.have.members( [ + ...inlineStyles.map( ( { name } ) => name ), + ...blockParagraphStyles.map( ( { name } ) => name ) + ] ); } ); - expect( command.value ).to.deep.equal( [ 'Marker' ] ); - expect( getData( model ) ).to.equal( - '[<$text htmlSpan="{"classes":["marker"]}">foobar]' - ); - } ); + it( 'should enable styles for heading', () => { + setData( model, + 'foo' + + 'bar[]' + ); + + command.refresh(); + + expect( command.enabledStyles ).to.have.members( [ + ...inlineStyles.map( ( { name } ) => name ), + ...blockHeadingStyles.map( ( { name } ) => name ) + ] ); + } ); + + it( 'should enable styles for block quote', () => { + setData( model, + 'foo' + + '
      ' + + 'bar[]' + + '
      ' + + 'baz' + ); + + command.refresh(); + + expect( command.enabledStyles ).to.have.members( [ + ...inlineStyles.map( ( { name } ) => name ), + ...blockParagraphStyles.map( ( { name } ) => name ), + ...blockQuoteBlockStyles.map( ( { name } ) => name ) + ] ); + } ); + + it( 'should enable styles for the code block', () => { + setData( model, 'foo[bar]baz' ); - it( 'should detect applied multiple inline styles', () => { - setData( model, '[foobar]' ); + command.refresh(); - model.change( writer => { - writer.setAttribute( 'htmlSpan', { classes: [ 'marker', 'typewriter' ] }, root.getChild( 0 ).getChild( 0 ) ); + expect( command.enabledStyles ).to.have.members( [ + ...blockCodeBlockStyles.map( ( { name } ) => name ) + ] ); } ); - expect( command.value ).to.deep.equal( [ 'Marker', 'Typewriter' ] ); - expect( getData( model ) ).to.equal( - '[<$text htmlSpan="{"classes":["marker","typewriter"]}">foobar]' - ); + it( 'should enable styles for the first selected block', () => { + setData( model, + 'foo' + + 'b[ar' + + 'ba]z' + ); + + command.refresh(); + + expect( command.enabledStyles ).to.have.members( [ + ...blockHeadingStyles.map( ( { name } ) => name ), + ...inlineStyles.map( ( { name } ) => name ) + ] ); + } ); + + it( 'should not enable styles for blocks that disable GHS', () => { + model.schema.addAttributeCheck( ( context, attributeName ) => { + if ( context.endsWith( 'paragraph' ) && attributeName == 'htmlAttributes' ) { + return false; + } + } ); + + setData( model, 'bar[]' ); + + command.refresh(); + + expect( command.enabledStyles ).to.have.members( [ + ...inlineStyles.map( ( { name } ) => name ) + ] ); + } ); + + it( 'should not enable styles for elements outside a limit element', () => { + setData( model, + '
      ' + + '' + + '' + + '' + + '[foo]' + + '' + + '' + + '
      ' + + '
      ' + ); + + command.refresh(); + + expect( command.enabledStyles ).to.have.members( [ + ...inlineStyles.map( ( { name } ) => name ), + ...blockParagraphStyles.map( ( { name } ) => name ) + ] ); + } ); + + it( 'should not crash if there are no selected blocks', () => { + setData( model, 'foo' ); + + model.change( writer => { + writer.setSelection( root, 0 ); + + command.refresh(); + + expect( command.enabledStyles ).to.have.members( [ + ...inlineStyles.map( ( { name } ) => name ) + ] ); + } ); + } ); } ); - // https://github.com/ckeditor/ckeditor5/issues/11588 - it( 'should detect applied multiple inline styles x', () => { - setData( model, '[foobar]' ); + describe( 'inline styles', () => { + it( 'should enable styles for text', () => { + setData( model, 'foo[bar]baz' ); + + command.refresh(); - model.change( writer => { - writer.setAttribute( 'htmlSpan', { classes: [ 'marker' ] }, root.getChild( 0 ).getChild( 0 ) ); - writer.setAttribute( 'bold', true, root.getChild( 0 ).getChild( 0 ) ); + expect( command.enabledStyles ).to.have.members( [ + ...inlineStyles.map( ( { name } ) => name ), + ...blockParagraphStyles.map( ( { name } ) => name ) + ] ); } ); - expect( command.value ).to.deep.equal( [ 'Marker' ] ); - expect( getData( model ) ).to.equal( - '[<$text bold="true" htmlSpan="{"classes":["marker"]}">foobar]' - ); + it( 'should not enable styles for text in code block', () => { + setData( model, 'foo[bar]baz' ); + + command.refresh(); + + expect( command.enabledStyles ).to.have.members( [ + ...blockCodeBlockStyles.map( ( { name } ) => name ) + ] ); + } ); } ); } ); - describe( 'isEnabled', () => { - it( 'should be disabled if selection is on an widget object', () => { - setData( model, '[foo
      ]' ); + describe( '#isEnabled', () => { + it( 'should be disabled if selection is on a block widget', () => { + setData( model, '[]' ); expect( command.isEnabled ).to.be.false; } ); + + it( 'should be enabled if selection is on a block widget but there are nested blocks that allow inline style', () => { + setData( model, '[foo]' ); + + expect( command.isEnabled ).to.be.true; + } ); } ); - describe( 'execute()', () => { + describe( '#value', () => { + describe( 'block styles', () => { + it( 'should detect a single style applied', () => { + setData( model, 'fo[]o' ); + + model.change( writer => { + writer.setAttribute( 'htmlAttributes', { classes: [ 'red' ] }, root.getChild( 0 ) ); + } ); + + expect( command.value ).to.have.members( [ 'Red paragraph' ] ); + } ); + + it( 'should detect styles for heading', () => { + setData( model, + 'foo' + + 'bar[]' + ); + + model.change( writer => { + writer.setAttribute( 'htmlAttributes', { classes: [ 'big-heading' ] }, root.getChild( 1 ) ); + } ); + + expect( command.value ).to.have.members( [ 'Big heading' ] ); + } ); + + it( 'should detect style for specified element if style shares an element name', () => { + setData( model, + 'fo[]o' + + 'bar' + ); + + model.change( writer => { + writer.setAttribute( 'htmlAttributes', { classes: [ 'red' ] }, root.getChild( 0 ) ); + writer.setAttribute( 'htmlAttributes', { classes: [ 'red' ] }, root.getChild( 1 ) ); + } ); + + expect( command.value ).to.have.members( [ 'Red paragraph' ] ); + + model.change( writer => { + writer.setSelection( root.getChild( 1 ), 0 ); + } ); + + expect( command.value ).to.have.members( [ 'Red heading' ] ); + } ); + + it( 'should detect styles for block quote', () => { + setData( model, + 'foo' + + '
      ' + + 'bar[]' + + '
      ' + + 'baz' + ); + + model.change( writer => { + writer.setAttribute( 'htmlAttributes', { classes: [ 'side-quote' ] }, root.getChild( 1 ) ); + } ); + + expect( command.value ).to.have.members( [ 'Side quote' ] ); + } ); + + it( 'should detect styles for the code block', () => { + setData( model, 'foo[bar]baz' ); + + model.change( writer => { + writer.setAttribute( 'htmlAttributes', { classes: [ 'vibrant-code' ] }, root.getChild( 0 ) ); + } ); + + expect( command.value ).to.have.members( [ 'Vibrant code block' ] ); + } ); + + it( 'should not detect styles for elements outside a limit element', () => { + setData( model, + '
      ' + + '' + + '' + + '' + + '[foo]' + + '' + + '' + + '
      ' + + '
      ' + ); + + model.change( writer => { + writer.setAttribute( 'htmlAttributes', { classes: [ 'side-quote' ] }, root.getChild( 0 ) ); + writer.setAttribute( 'htmlAttributes', { classes: [ 'red' ] }, root.getNodeByPath( [ 0, 0, 0, 0, 0 ] ) ); + } ); + + expect( command.value ).to.have.members( [ 'Red paragraph' ] ); + } ); + } ); + + describe( 'inline styles', () => { + it( 'should detect style', () => { + setData( model, 'foo[bar]baz' ); + + model.change( writer => { + writer.setAttribute( 'htmlSpan', { classes: [ 'marker', 'typewriter' ] }, doc.selection.getFirstRange() ); + } ); + + expect( command.value ).to.have.members( [ 'Marker', 'Typewriter' ] ); + } ); + + it( 'should detect styles that use multiple classes', () => { + setData( model, 'foo[bar]baz' ); + + model.change( writer => { + writer.setAttribute( 'htmlSpan', { classes: [ 'class-one', 'class-two' ] }, doc.selection.getFirstRange() ); + } ); + + expect( command.value ).to.have.members( [ 'Multiple classes' ] ); + } ); + + it( 'should not detect styles that does not have all classes for a style', () => { + setData( model, 'foo[bar]baz' ); + + model.change( writer => { + writer.setAttribute( 'htmlSpan', { classes: [ 'class-one', 'marker' ] }, doc.selection.getFirstRange() ); + } ); + + expect( command.value ).to.have.members( [ 'Marker' ] ); + } ); + + it( 'should detect applied inline style', () => { + setData( model, '[foobar]' ); + + model.change( writer => { + writer.setAttribute( 'htmlSpan', { classes: [ 'marker' ] }, root.getChild( 0 ).getChild( 0 ) ); + } ); + + expect( command.value ).to.deep.equal( [ 'Marker' ] ); + expect( getData( model ) ).to.equal( + '[<$text htmlSpan="{"classes":["marker"]}">foobar]' + ); + } ); + + it( 'should detect applied multiple inline styles', () => { + setData( model, '[foobar]' ); + + model.change( writer => { + writer.setAttribute( 'htmlSpan', { classes: [ 'marker', 'typewriter' ] }, root.getChild( 0 ).getChild( 0 ) ); + } ); + + expect( command.value ).to.deep.equal( [ 'Marker', 'Typewriter' ] ); + expect( getData( model ) ).to.equal( + '[<$text htmlSpan="{"classes":["marker","typewriter"]}">foobar]' + ); + } ); + + // https://github.com/ckeditor/ckeditor5/issues/11588 + it( 'should detect applied multiple inline styles (ignore basic styles)', () => { + setData( model, '[foobar]' ); + + model.change( writer => { + writer.setAttribute( 'htmlSpan', { classes: [ 'marker' ] }, root.getChild( 0 ).getChild( 0 ) ); + writer.setAttribute( 'bold', true, root.getChild( 0 ).getChild( 0 ) ); + } ); + + expect( command.value ).to.deep.equal( [ 'Marker' ] ); + expect( getData( model ) ).to.equal( + '[<$text bold="true" htmlSpan="{"classes":["marker"]}">foobar]' + ); + } ); + } ); + } ); + + describe( '#execute()', () => { it( 'should do nothing if the command is disabled', () => { setData( model, 'fo[ob]ar' ); @@ -168,7 +480,7 @@ describe( 'StyleCommand', () => { ); } ); - it( 'should add multiple htmlSpan attributes with proper classes to the collapsed selection', () => { + it( 'should add htmlSpan attribute with proper classes to the collapsed selection', () => { setData( model, 'foobar[]' ); command.execute( 'Marker' ); @@ -197,7 +509,7 @@ describe( 'StyleCommand', () => { ); } ); - it( 'should add multiple htmlSpan attributes with proper class to the selected text', () => { + it( 'should add htmlSpan attribute with proper classes to the selected text', () => { setData( model, 'fo[ob]ar' ); command.execute( 'Marker' ); @@ -208,7 +520,7 @@ describe( 'StyleCommand', () => { ); } ); - it( 'should add htmlSpan attribute classes to elements with other htmlSpan attributes existing', () => { + it( 'should add htmlSpan attribute classes to elements with other htmlSpan attribute existing', () => { // initial selection [foo b]ar baz. setData( model, '[foo b]ar baz' ); @@ -237,8 +549,7 @@ describe( 'StyleCommand', () => { ); } ); - // TODO: classes as arrays. - it( 'should add multiple htmlSpan attributes to the selected text if definition specify multiple classes', () => { + it( 'should add htmlSpan attribute to the selected text if definition specify multiple classes', () => { setData( model, 'fo[ob]ar' ); command.execute( 'Multiple classes' ); @@ -248,6 +559,22 @@ describe( 'StyleCommand', () => { ); } ); + it( 'should add htmlSpan attribute obly to nodes that allow it', () => { + setData( model, + 'f[oo' + + 'bar' + + 'ba]z' + ); + + command.execute( 'Marker' ); + + expect( getData( model ) ).to.equal( + 'f[<$text htmlSpan="{"classes":["marker"]}">oo' + + 'bar' + + '<$text htmlSpan="{"classes":["marker"]}">ba]z' + ); + } ); + it( 'should remove class from htmlSpan attribute element', () => { setData( model, 'foo[bar]' ); @@ -270,10 +597,6 @@ describe( 'StyleCommand', () => { 'foo[bar]' ); } ); - - // TODO: add removing attributes. - // TODO: more complex selection tests. - // TODO: test for adding styles outside of enabledStyles. } ); describe( 'block styles', () => { @@ -287,14 +610,62 @@ describe( 'StyleCommand', () => { ); } ); - it( 'should add multiple htmlAttribute classes the selected element', () => { + it( 'should add htmlAttribute with multiple classes to the selected element', () => { setData( model, 'foo[]bar' ); command.execute( 'Big heading' ); command.execute( 'Red heading' ); expect( getData( model ) ).to.equal( - 'foo[]bar' + 'foo[]bar' + ); + } ); + + it( 'should add htmlAttribute only for matching element names', () => { + setData( model, + 'fo[o' + + 'bar' + + 'ba]z' + ); + + command.execute( 'Red heading' ); + + expect( getData( model ) ).to.equal( + 'fo[o' + + 'bar' + + 'ba]z' + ); + } ); + + it( 'should add htmlAttribute only to elements in the same limit element', () => { + setData( model, + '
      ' + + '' + + '' + + '' + + '
      ' + + 'fo[]o' + + '
      ' + + '
      ' + + '
      ' + + '
      ' + + '
      ' + ); + + command.execute( 'Side quote' ); + + expect( getData( model ) ).to.equal( + '
      ' + + '' + + '' + + '' + + '
      ' + + 'fo[]o' + + '
      ' + + '
      ' + + '
      ' + + '
      ' + + '
      ' ); } ); @@ -308,4 +679,21 @@ describe( 'StyleCommand', () => { } ); } ); } ); + + async function createEditor( styleDefinitions ) { + editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); + + editor = await ClassicTestEditor.create( editorElement, { + plugins: [ Paragraph, ImageBlock, ImageCaption, Heading, CodeBlock, BlockQuote, Table, GeneralHtmlSupport, Style ], + style: { + definitions: styleDefinitions + } + } ); + + model = editor.model; + command = editor.commands.get( 'style' ); + doc = model.document; + root = doc.getRoot(); + } } );