/**
* @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md.
*/
/* globals
ClassicEditor, Plugin, ButtonView, Command, toWidget, toWidgetEditable, createElement, findOptimalInsertionPosition,
console, window, prompt, document
*/
import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';
/**
* Helper for extracting the side card type from a view element based on its CSS class.
*/
const getTypeFromViewElement = viewElement => {
for ( const type of [ 'default', 'alternate' ] ) {
if ( viewElement.hasClass( `side-card-${ type }` ) ) {
return type;
}
}
return 'default';
};
/**
* Single upcast converter to the 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 a 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 the card editing UI inside the card.
*/
const createActionsView = ( editor, modelElement ) => function( domElement ) {
//
// Set the 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 === 'default' ? 'alternate' : 'default';
//
// Change the card action button.
//
addActionButton( 'Change type', writer => {
writer.setAttribute( 'cardType', newType, modelElement );
}, domElement, editor );
const childCount = modelElement.childCount;
//
// Add the content section to the 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 will always be a title).
if ( childCount > 4 ) {
addButton.setAttribute( 'disabled', 'disabled' );
}
//
// Remove the content section from the 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 will always be a title).
if ( childCount < 3 ) {
removeButton.setAttribute( 'disabled', 'disabled' );
}
};
/**
* The downcast converter for the element.
*
* It returns the 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' ) || 'default';
// The main view element for the side card.
const sideCardView = writer.createContainerElement( 'aside', {
class: `side-card side-card-${ type }`
} );
// Create inner views from the 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 and 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 an 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 the side card's attributes.
// It will only be needed in the editing view inside the widgetized element.
// The data output should not contain this section.
if ( asWidget ) {
const actionsView = writer.createRawElement( 'div', {
class: 'side-card-actions',
contenteditable: 'false', // Prevents editing of the element.
'data-cke-ignore-events': 'true' // Allows using custom UI elements inside the editing view.
}, createActionsView( editor, modelElement ) ); // See the full code for details.
writer.insert( writer.createPositionAt( sideCardView, 'end' ), actionsView );
toWidget( sideCardView, writer, { widgetLabel: 'Side card', hasSelectionHandle: true } );
}
return sideCardView;
};
};
class InsertCardCommand extends Command {
/**
* Refresh used schema definition to check if a side card 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 a 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: 'default' } );
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 `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;
} );
}
}
ClassicEditor
.create( document.querySelector( '#editor-element-reconversion' ), {
cloudServices: CS_CONFIG,
extraPlugins: [ ComplexBox ],
image: {
toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ]
},
table: {
contentToolbar: [
'tableColumn',
'tableRow',
'mergeTableCells'
]
},
toolbar: {
items: [ 'heading', '|', 'bold', 'italic', '|', 'insertCard' ],
viewportTopOffset: window.getViewportTopOffsetConfig()
}
} )
.then( editor => {
window.editor = editor;
} )
.catch( err => {
console.error( err );
} );