|
|
@@ -146,28 +146,34 @@ The side card model structure is represented in the editor's {@link framework/gu
|
|
|
|
|
|
```js
|
|
|
// The main element with attributes for type and URL:
|
|
|
-editor.model.schema.register( 'sideCard', {
|
|
|
+schema.register( 'sideCard', {
|
|
|
allowWhere: '$block',
|
|
|
isObject: true,
|
|
|
allowAttributes: [ 'cardType', 'cardURL' ]
|
|
|
} );
|
|
|
+// Disallow side card nesting.
|
|
|
+schema.addChildCheck( ( context, childDefinition ) => {
|
|
|
+ if ( [ ...context.getNames() ].includes( 'sideCard' ) && childDefinition.name === 'sideCard' ) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+} );
|
|
|
|
|
|
// A text-only title.
|
|
|
-editor.model.schema.register( 'sideCardTitle', {
|
|
|
+schema.register( 'sideCardTitle', {
|
|
|
isLimit: true,
|
|
|
allowIn: 'sideCard'
|
|
|
} );
|
|
|
// Allow text in title...
|
|
|
-editor.model.schema.extend( '$text', { allowIn: 'sideCardTitle' } );
|
|
|
+schema.extend( '$text', { allowIn: 'sideCardTitle' } );
|
|
|
// ...but disallow any text attribute inside.
|
|
|
-editor.model.schema.addAttributeCheck( context => {
|
|
|
+schema.addAttributeCheck( context => {
|
|
|
if ( context.endsWith( 'sideCardTitle $text' ) ) {
|
|
|
return false;
|
|
|
}
|
|
|
} );
|
|
|
|
|
|
// A content block which can have any content allowed in $root.
|
|
|
-editor.model.schema.register( 'sideCardSection', {
|
|
|
+schema.register( 'sideCardSection', {
|
|
|
isLimit: true,
|
|
|
allowIn: 'sideCard',
|
|
|
allowContentOf: '$root'
|
|
|
@@ -179,9 +185,9 @@ editor.model.schema.register( 'sideCardSection', {
|
|
|
To enable an element reconversion define which attributes and children modification the main element will be converted for:
|
|
|
|
|
|
```js
|
|
|
-editor.conversion.for( 'downcast' ).elementToElement( {
|
|
|
+conversion.for( 'editingDowncast' ).elementToElement( {
|
|
|
model: 'sideCard',
|
|
|
- view: ( modelElement, conversionApi ) => downcastSideCard( modelElement, conversionApi ),
|
|
|
+ view: downcastSideCard( editor, { asWidget: true } ),
|
|
|
triggerBy: {
|
|
|
attributes: [ 'cardType', 'cardURL' ],
|
|
|
children: [ 'sideCardSection' ]
|
|
|
@@ -200,57 +206,68 @@ The above definition will use the `downcastSideCard()` function to re-create the
|
|
|
The function that creates a complete view for the model element:
|
|
|
|
|
|
```js
|
|
|
-const downcastSideCard = ( modelElement, { writer, consumable, mapper } ) => {
|
|
|
- const type = modelElement.getAttribute( 'cardType' ) || 'info';
|
|
|
+const downcastSideCard = ( editor, { asWidget } ) => {
|
|
|
+ return ( modelElement, { writer, consumable, mapper } ) => {
|
|
|
+ const type = modelElement.getAttribute( 'cardType' ) || 'info';
|
|
|
|
|
|
- const sideCardView = writer.createContainerElement( 'aside', {
|
|
|
- class: `side-card side-card-${ type }`
|
|
|
- } );
|
|
|
+ // Main view element for the side card.
|
|
|
+ const sideCardView = writer.createContainerElement( 'aside', {
|
|
|
+ class: `side-card side-card-${ type }`
|
|
|
+ } );
|
|
|
|
|
|
- // Create inner views from side card children.
|
|
|
- for ( const child of modelElement.getChildren() ) {
|
|
|
- const childView = writer.createEditableElement( 'div' );
|
|
|
+ // Create inner views from side card children.
|
|
|
+ for ( const child of modelElement.getChildren() ) {
|
|
|
+ const childView = writer.createEditableElement( 'div' );
|
|
|
|
|
|
- // Child is either a "title" or "section".
|
|
|
- if ( child.is( 'element', 'sideCardTitle' ) ) {
|
|
|
- writer.addClass( 'side-card-title', childView );
|
|
|
- } else {
|
|
|
- writer.addClass( 'side-card-section', childView );
|
|
|
- }
|
|
|
+ // Child is either a "title" or "section".
|
|
|
+ if ( child.is( 'element', 'sideCardTitle' ) ) {
|
|
|
+ writer.addClass( 'side-card-title', childView );
|
|
|
+ } else {
|
|
|
+ writer.addClass( 'side-card-section', childView );
|
|
|
+ }
|
|
|
|
|
|
- // It is important to consume & bind converted elements.
|
|
|
- consumable.consume( child, 'insert' );
|
|
|
- mapper.bindElements( child, childView );
|
|
|
+ // It is important to consume & bind converted elements.
|
|
|
+ consumable.consume( child, 'insert' );
|
|
|
+ mapper.bindElements( child, childView );
|
|
|
|
|
|
- // Make it an editable part of the widget.
|
|
|
- toWidgetEditable( childView, writer );
|
|
|
+ // Make it an editable part of the widget.
|
|
|
+ if ( asWidget ) {
|
|
|
+ toWidgetEditable( childView, writer );
|
|
|
+ }
|
|
|
|
|
|
- writer.insert( writer.createPositionAt( sideCardView, 'end' ), childView );
|
|
|
- }
|
|
|
+ writer.insert( writer.createPositionAt( sideCardView, 'end' ), childView );
|
|
|
+ }
|
|
|
|
|
|
- const urlAttribute = modelElement.getAttribute( 'cardURL' );
|
|
|
+ const urlAttribute = modelElement.getAttribute( 'cardURL' );
|
|
|
|
|
|
- // Do not render empty URL field
|
|
|
- if ( urlAttribute ) {
|
|
|
- const urlBox = writer.createRawElement( 'div', {
|
|
|
- class: 'side-card-url'
|
|
|
- }, function( domElement ) {
|
|
|
- domElement.innerText = `URL: "${ urlAttribute }"`;
|
|
|
- } );
|
|
|
+ // Do not render empty URL field
|
|
|
+ if ( urlAttribute ) {
|
|
|
+ const urlBox = writer.createRawElement( 'div', {
|
|
|
+ class: 'side-card-url'
|
|
|
+ }, function( domElement ) {
|
|
|
+ domElement.innerText = `URL: "${ urlAttribute }"`;
|
|
|
+ } );
|
|
|
|
|
|
- writer.insert( writer.createPositionAt( sideCardView, 'end' ), urlBox );
|
|
|
- }
|
|
|
+ writer.insert( writer.createPositionAt( sideCardView, 'end' ), urlBox );
|
|
|
+ }
|
|
|
+
|
|
|
+ // Inner element used to render a simple UI that allows to change side card's attributes.
|
|
|
+ // It will only be needed in the editing view inside a widgetized element.
|
|
|
+ // The data output should not contain this section.
|
|
|
+ if ( asWidget ) {
|
|
|
+ const actionsView = writer.createRawElement( 'div', {
|
|
|
+ class: 'side-card-actions',
|
|
|
+ contenteditable: 'false', // Prevent editing of the element:
|
|
|
+ 'data-cke-ignore-events': 'true' // Allows using custom UI elements inside editing view.
|
|
|
+ }, createActionsView( editor, modelElement ) ); // See the full code for details.
|
|
|
|
|
|
- // Inner element used to render simple UI that allows to change side card's attributes.
|
|
|
- const actionsView = writer.createRawElement( 'div', {
|
|
|
- class: 'side-card-actions',
|
|
|
- contenteditable: 'false', // Prevent editing of the element:
|
|
|
- 'data-cke-ignore-events': 'true' // Allows using custom UI elements inside editing view.
|
|
|
- }, renderActionsView( editor, modelElement ) ); // See the full code for details.
|
|
|
+ writer.insert( writer.createPositionAt( sideCardView, 'end' ), actionsView );
|
|
|
|
|
|
- writer.insert( writer.createPositionAt( sideCardView, 'end' ), actionsView );
|
|
|
+ toWidget( sideCardView, writer, { widgetLabel: 'Side card' } );
|
|
|
+ }
|
|
|
|
|
|
- return toWidget( sideCardView, writer, { widgetLabel: 'Side card' } );
|
|
|
+ return sideCardView;
|
|
|
+ };
|
|
|
};
|
|
|
```
|
|
|
|
|
|
@@ -264,7 +281,7 @@ The upcast conversion uses standard element-to-element converters for box & titl
|
|
|
editor.conversion.for( 'upcast' )
|
|
|
.elementToElement( {
|
|
|
view: { name: 'aside', classes: [ 'side-card' ] },
|
|
|
- model: upcastInfoBox
|
|
|
+ model: upcastCard // Details in the full source-code.
|
|
|
} )
|
|
|
.elementToElement( {
|
|
|
view: { name: 'div', classes: [ 'side-card-title' ] },
|
|
|
@@ -280,4 +297,328 @@ You can see the details of the upcast converter function (`upcastInfoBox()`) in
|
|
|
|
|
|
### Full source code
|
|
|
|
|
|
-TODO
|
|
|
+```js
|
|
|
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
|
|
|
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
|
|
|
+import Command from '@ckeditor/ckeditor5-core/src/command';
|
|
|
+import { toWidget, toWidgetEditable, findOptimalInsertionPosition } from '@ckeditor/ckeditor5-widget/src/utils';
|
|
|
+import createElement from '@ckeditor/ckeditor5-utils/src/dom/createelement';
|
|
|
+
|
|
|
+/**
|
|
|
+ * Helper for extracting side card type from a view element based on its CSS class.
|
|
|
+ */
|
|
|
+const getTypeFromViewElement = viewElement => {
|
|
|
+ for ( const type of [ 'info', 'warning' ] ) {
|
|
|
+ if ( viewElement.hasClass( `side-card-${ type }` ) ) {
|
|
|
+ return type;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return 'info';
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * Single upcast converter to <sideCard/> element with all its attributes.
|
|
|
+ */
|
|
|
+const upcastCard = ( viewElement, { writer } ) => {
|
|
|
+ const sideCard = writer.createElement( 'sideCard' );
|
|
|
+
|
|
|
+ const type = getTypeFromViewElement( viewElement );
|
|
|
+ writer.setAttribute( 'cardType', type, sideCard );
|
|
|
+
|
|
|
+ const urlWrapper = [ ...viewElement.getChildren() ].find( child => {
|
|
|
+ return child.is( 'element', 'div' ) && child.hasClass( 'side-card-url' );
|
|
|
+ } );
|
|
|
+
|
|
|
+ if ( urlWrapper ) {
|
|
|
+ writer.setAttribute( 'cardURL', urlWrapper.getChild( 0 ).data, sideCard );
|
|
|
+ }
|
|
|
+
|
|
|
+ return sideCard;
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * Helper for creating DOM button with an editor callback.
|
|
|
+ */
|
|
|
+const addActionButton = ( text, callback, domElement, editor ) => {
|
|
|
+ const domDocument = domElement.ownerDocument;
|
|
|
+
|
|
|
+ const button = createElement( domDocument, 'button', {}, [ text ] );
|
|
|
+
|
|
|
+ button.addEventListener( 'click', () => {
|
|
|
+ editor.model.change( callback );
|
|
|
+ } );
|
|
|
+
|
|
|
+ domElement.appendChild( button );
|
|
|
+
|
|
|
+ return button;
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * Helper function that creates card editing UI inside the card.
|
|
|
+ */
|
|
|
+const createActionsView = ( editor, modelElement ) => function( domElement ) {
|
|
|
+ //
|
|
|
+ // Set URL action button.
|
|
|
+ //
|
|
|
+ addActionButton( 'Set URL', writer => {
|
|
|
+ // eslint-disable-next-line no-alert
|
|
|
+ const newURL = prompt( 'Set URL', modelElement.getAttribute( 'cardURL' ) || '' );
|
|
|
+
|
|
|
+ writer.setAttribute( 'cardURL', newURL, modelElement );
|
|
|
+ }, domElement, editor );
|
|
|
+
|
|
|
+ const currentType = modelElement.getAttribute( 'cardType' );
|
|
|
+ const newType = currentType === 'info' ? 'warning' : 'info';
|
|
|
+
|
|
|
+ //
|
|
|
+ // Change card action button.
|
|
|
+ //
|
|
|
+ addActionButton( 'Change type', writer => {
|
|
|
+ writer.setAttribute( 'cardType', newType, modelElement );
|
|
|
+ }, domElement, editor );
|
|
|
+
|
|
|
+ const childCount = modelElement.childCount;
|
|
|
+
|
|
|
+ //
|
|
|
+ // Add content section to a card action button.
|
|
|
+ //
|
|
|
+ const addButton = addActionButton( 'Add section', writer => {
|
|
|
+ writer.insertElement( 'sideCardSection', modelElement, 'end' );
|
|
|
+ }, domElement, editor );
|
|
|
+
|
|
|
+ // Disable the button so only 1-3 content boxes are in the card (there always will be a title).
|
|
|
+ if ( childCount > 4 ) {
|
|
|
+ addButton.setAttribute( 'disabled', 'disabled' );
|
|
|
+ }
|
|
|
+
|
|
|
+ //
|
|
|
+ // Remove content section from a card action button.
|
|
|
+ //
|
|
|
+ const removeButton = addActionButton( 'Remove section', writer => {
|
|
|
+ writer.remove( modelElement.getChild( childCount - 1 ) );
|
|
|
+ }, domElement, editor );
|
|
|
+
|
|
|
+ // Disable the button so only 1-3 content boxes are in the card (there always will be a title).
|
|
|
+ if ( childCount < 3 ) {
|
|
|
+ removeButton.setAttribute( 'disabled', 'disabled' );
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * The downcast converter for <sideCard/> element.
|
|
|
+ *
|
|
|
+ * It returns a full view structure based on the current state of the model element.
|
|
|
+ */
|
|
|
+const downcastSideCard = ( editor, { asWidget } ) => {
|
|
|
+ return ( modelElement, { writer, consumable, mapper } ) => {
|
|
|
+ const type = modelElement.getAttribute( 'cardType' ) || 'info';
|
|
|
+
|
|
|
+ // Main view element for the side card.
|
|
|
+ const sideCardView = writer.createContainerElement( 'aside', {
|
|
|
+ class: `side-card side-card-${ type }`
|
|
|
+ } );
|
|
|
+
|
|
|
+ // Create inner views from side card children.
|
|
|
+ for ( const child of modelElement.getChildren() ) {
|
|
|
+ const childView = writer.createEditableElement( 'div' );
|
|
|
+
|
|
|
+ // Child is either a "title" or "section".
|
|
|
+ if ( child.is( 'element', 'sideCardTitle' ) ) {
|
|
|
+ writer.addClass( 'side-card-title', childView );
|
|
|
+ } else {
|
|
|
+ writer.addClass( 'side-card-section', childView );
|
|
|
+ }
|
|
|
+
|
|
|
+ // It is important to consume & bind converted elements.
|
|
|
+ consumable.consume( child, 'insert' );
|
|
|
+ mapper.bindElements( child, childView );
|
|
|
+
|
|
|
+ // Make it an editable part of the widget.
|
|
|
+ if ( asWidget ) {
|
|
|
+ toWidgetEditable( childView, writer );
|
|
|
+ }
|
|
|
+
|
|
|
+ writer.insert( writer.createPositionAt( sideCardView, 'end' ), childView );
|
|
|
+ }
|
|
|
+
|
|
|
+ const urlAttribute = modelElement.getAttribute( 'cardURL' );
|
|
|
+
|
|
|
+ // Do not render empty URL field
|
|
|
+ if ( urlAttribute ) {
|
|
|
+ const urlBox = writer.createRawElement( 'div', {
|
|
|
+ class: 'side-card-url'
|
|
|
+ }, function( domElement ) {
|
|
|
+ domElement.innerText = `URL: "${ urlAttribute }"`;
|
|
|
+ } );
|
|
|
+
|
|
|
+ writer.insert( writer.createPositionAt( sideCardView, 'end' ), urlBox );
|
|
|
+ }
|
|
|
+
|
|
|
+ // Inner element used to render a simple UI that allows to change side card's attributes.
|
|
|
+ // It will only be needed in the editing view inside a widgetized element.
|
|
|
+ // The data output should not contain this section.
|
|
|
+ if ( asWidget ) {
|
|
|
+ const actionsView = writer.createRawElement( 'div', {
|
|
|
+ class: 'side-card-actions',
|
|
|
+ contenteditable: 'false', // Prevent editing of the element:
|
|
|
+ 'data-cke-ignore-events': 'true' // Allows using custom UI elements inside editing view.
|
|
|
+ }, createActionsView( editor, modelElement ) ); // See the full code for details.
|
|
|
+
|
|
|
+ writer.insert( writer.createPositionAt( sideCardView, 'end' ), actionsView );
|
|
|
+
|
|
|
+ toWidget( sideCardView, writer, { widgetLabel: 'Side card' } );
|
|
|
+ }
|
|
|
+
|
|
|
+ return sideCardView;
|
|
|
+ };
|
|
|
+};
|
|
|
+
|
|
|
+class InsertCardCommand extends Command {
|
|
|
+ /**
|
|
|
+ * Refresh uses schema definition to checks if a sideCard can be inserted in the current selection.
|
|
|
+ */
|
|
|
+ refresh() {
|
|
|
+ const model = this.editor.model;
|
|
|
+ const validParent = findOptimalInsertionPosition( model.document.selection, model );
|
|
|
+
|
|
|
+ this.isEnabled = model.schema.checkChild( validParent, 'sideCard' );
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Creates full side card element with all required children and attributes.
|
|
|
+ */
|
|
|
+ execute() {
|
|
|
+ const model = this.editor.model;
|
|
|
+ const selection = model.document.selection;
|
|
|
+
|
|
|
+ const insertPosition = findOptimalInsertionPosition( selection, model );
|
|
|
+
|
|
|
+ model.change( writer => {
|
|
|
+ const sideCard = writer.createElement( 'sideCard', { cardType: 'info' } );
|
|
|
+ const title = writer.createElement( 'sideCardTitle' );
|
|
|
+ const section = writer.createElement( 'sideCardSection' );
|
|
|
+ const paragraph = writer.createElement( 'paragraph' );
|
|
|
+
|
|
|
+ writer.insert( title, sideCard, 0 );
|
|
|
+ writer.insert( section, sideCard, 1 );
|
|
|
+ writer.insert( paragraph, section, 0 );
|
|
|
+
|
|
|
+ model.insertContent( sideCard, insertPosition );
|
|
|
+
|
|
|
+ writer.setSelection( writer.createPositionAt( title, 0 ) );
|
|
|
+ } );
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+class ComplexBox extends Plugin {
|
|
|
+ constructor( editor ) {
|
|
|
+ super( editor );
|
|
|
+
|
|
|
+ this._defineSchema();
|
|
|
+ this._defineConversion();
|
|
|
+
|
|
|
+ editor.commands.add( 'insertCard', new InsertCardCommand( editor ) );
|
|
|
+
|
|
|
+ this._defineUI();
|
|
|
+ }
|
|
|
+
|
|
|
+ _defineConversion() {
|
|
|
+ const editor = this.editor;
|
|
|
+ const conversion = editor.conversion;
|
|
|
+
|
|
|
+ conversion.for( 'upcast' )
|
|
|
+ .elementToElement( {
|
|
|
+ view: { name: 'aside', classes: [ 'side-card' ] },
|
|
|
+ model: upcastCard
|
|
|
+ } )
|
|
|
+ .elementToElement( {
|
|
|
+ view: { name: 'div', classes: [ 'side-card-title' ] },
|
|
|
+ model: 'sideCardTitle'
|
|
|
+ } )
|
|
|
+ .elementToElement( {
|
|
|
+ view: { name: 'div', classes: [ 'side-card-section' ] },
|
|
|
+ model: 'sideCardSection'
|
|
|
+ } );
|
|
|
+
|
|
|
+ // The downcast conversion must be split as you need a widget in the editing pipeline.
|
|
|
+ conversion.for( 'editingDowncast' ).elementToElement( {
|
|
|
+ model: 'sideCard',
|
|
|
+ view: downcastSideCard( editor, { asWidget: true } ),
|
|
|
+ triggerBy: {
|
|
|
+ attributes: [ 'cardType', 'cardURL' ],
|
|
|
+ children: [ 'sideCardSection' ]
|
|
|
+ }
|
|
|
+ } );
|
|
|
+ // The data downcast is always executed from the current model stat, so the `triggerBy` will take no effect.
|
|
|
+ conversion.for( 'dataDowncast' ).elementToElement( {
|
|
|
+ model: 'sideCard',
|
|
|
+ view: downcastSideCard( editor, { asWidget: false } )
|
|
|
+ } );
|
|
|
+ }
|
|
|
+
|
|
|
+ _defineSchema() {
|
|
|
+ const schema = this.editor.model.schema;
|
|
|
+
|
|
|
+ // The main element with attributes for type and URL:
|
|
|
+ schema.register( 'sideCard', {
|
|
|
+ allowWhere: '$block',
|
|
|
+ isObject: true,
|
|
|
+ allowAttributes: [ 'cardType', 'cardURL' ]
|
|
|
+ } );
|
|
|
+ // Disallow side card nesting.
|
|
|
+ schema.addChildCheck( ( context, childDefinition ) => {
|
|
|
+ if ( [ ...context.getNames() ].includes( 'sideCard' ) && childDefinition.name === 'sideCard' ) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ } );
|
|
|
+
|
|
|
+ // A text-only title.
|
|
|
+ schema.register( 'sideCardTitle', {
|
|
|
+ isLimit: true,
|
|
|
+ allowIn: 'sideCard'
|
|
|
+ } );
|
|
|
+ // Allow text in title...
|
|
|
+ schema.extend( '$text', { allowIn: 'sideCardTitle' } );
|
|
|
+ // ...but disallow any text attribute inside.
|
|
|
+ schema.addAttributeCheck( context => {
|
|
|
+ if ( context.endsWith( 'sideCardTitle $text' ) ) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ } );
|
|
|
+
|
|
|
+ // A content block which can have any content allowed in $root.
|
|
|
+ schema.register( 'sideCardSection', {
|
|
|
+ isLimit: true,
|
|
|
+ allowIn: 'sideCard',
|
|
|
+ allowContentOf: '$root'
|
|
|
+ } );
|
|
|
+ }
|
|
|
+
|
|
|
+ _defineUI() {
|
|
|
+ const editor = this.editor;
|
|
|
+
|
|
|
+ // Defines a simple text button.
|
|
|
+ editor.ui.componentFactory.add( 'insertCard', locale => {
|
|
|
+ const button = new ButtonView( locale );
|
|
|
+
|
|
|
+ const command = editor.commands.get( 'insertCard' );
|
|
|
+
|
|
|
+ button.set( {
|
|
|
+ withText: true,
|
|
|
+ icon: false,
|
|
|
+ label: 'Insert card'
|
|
|
+ } );
|
|
|
+
|
|
|
+ button.bind( 'isEnabled' ).to( command );
|
|
|
+
|
|
|
+ button.on( 'execute', () => {
|
|
|
+ editor.execute( 'insertCard' );
|
|
|
+ editor.editing.view.focus();
|
|
|
+ } );
|
|
|
+
|
|
|
+ return button;
|
|
|
+ } );
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|