Skip to content
This repository has been archived by the owner on Jun 26, 2020. It is now read-only.

Commit

Permalink
Merge pull request #70 from ckeditor/t/ckeditor5/1096
Browse files Browse the repository at this point in the history
Other: Introduce support and utils for creating inline widgets. Closes [ckeditor/ckeditor5#1096](ckeditor/ckeditor5#1096).
  • Loading branch information
Reinmar committed Feb 18, 2019
2 parents f9710f4 + 77fe639 commit 38fa159
Show file tree
Hide file tree
Showing 5 changed files with 366 additions and 6 deletions.
61 changes: 60 additions & 1 deletion src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ export function toWidgetEditable( editable, writer ) {
export function findOptimalInsertionPosition( selection, model ) {
const selectedElement = selection.getSelectedElement();

if ( selectedElement ) {
if ( selectedElement && model.schema.isBlock( selectedElement ) ) {
return model.createPositionAfter( selectedElement );
}

Expand All @@ -289,6 +289,65 @@ export function findOptimalInsertionPosition( selection, model ) {
return selection.focus;
}

/**
* A util to be used in order to map view positions to correct model positions when implementing a widget
* which renders non-empty view element for an empty model element.
*
* For example:
*
* // Model:
* <placeholder type="name"></placeholder>
*
* // View:
* <span class="placeholder">name</span>
*
* In such case, view positions inside `<span>` cannot be correct mapped to the model (because the model element is empty).
* To handle mapping positions inside `<span class="placeholder">` to the model use this util as follows:
*
* editor.editing.mapper.on(
* 'viewToModelPosition',
* viewToModelPositionOutsideModelElement( model, viewElement => viewElement.hasClass( 'placeholder' ) )
* );
*
* The callback will try to map the view offset of selection to an expected model position.
*
* 1. When the position is at the end (or in the middle) of the inline widget:
*
* // View:
* <p>foo <span class="placeholder">name|</span> bar</p>
*
* // Model:
* <paragraph>foo <placeholder type="name"></placeholder>| bar</paragraph>
*
* 2. When the position is at the beginning of the inline widget:
*
* // View:
* <p>foo <span class="placeholder">|name</span> bar</p>
*
* // Model:
* <paragraph>foo |<placeholder type="name"></placeholder> bar</paragraph>
*
* @param {module:engine/model/model~Model} model Model instance on which the callback operates.
* @param {Function} viewElementMatcher Function that is passed a view element and should return `true` if the custom mapping
* should be applied to the given view element.
* @return {Function}
*/
export function viewToModelPositionOutsideModelElement( model, viewElementMatcher ) {
return ( evt, data ) => {
const { mapper, viewPosition } = data;

const viewParent = mapper.findMappedViewAncestor( viewPosition );

if ( !viewElementMatcher( viewParent ) ) {
return;
}

const modelParent = mapper.toModelElement( viewParent );

data.modelPosition = model.createPositionAt( modelParent, viewPosition.isAtStart ? 'before' : 'after' );
};
}

// Default filler offset function applied to all widget elements.
//
// @returns {null}
Expand Down
38 changes: 38 additions & 0 deletions tests/manual/inline-widget.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<div id="editor">
<h1>Hello <placeholder>{name}</placeholder>!</h1>

<p>We would like to invite you to <placeholder>{place}</placeholder> that will take place on <placeholder>{date}</placeholder>.</p>
</div>

<h2>Model contents:</h2>

<pre id="model"></pre>

<style>
placeholder {
background: #ffff00;
padding: 4px 2px;
outline-offset: -2px;
line-height: 1em;
}

placeholder::selection {
display: none;
}

/* This will show box when the fake selection is active. */
.ck.ck-content div[style*="left: -9999px;"]::before {
background: hsla(9,100%,56%,.3);
border: 1px dotted hsl(15, 100%, 43%);
color: #333;
content: 'fake selection set';
display: block;
height: 20px;
left: calc(50% - 60px);
line-height: 20px;
padding: 2px 5px;
position: fixed;
top: 5px;
z-index: 1;
}
</style>
144 changes: 144 additions & 0 deletions tests/manual/inline-widget.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md.
*/

/* global console, window */

import { getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
import global from '@ckeditor/ckeditor5-utils/src/dom/global';

import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
import Enter from '@ckeditor/ckeditor5-enter/src/enter';
import Heading from '@ckeditor/ckeditor5-heading/src/heading';
import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
import Typing from '@ckeditor/ckeditor5-typing/src/typing';
import Undo from '@ckeditor/ckeditor5-undo/src/undo';
import Widget from '@ckeditor/ckeditor5-widget/src/widget';
import { toWidget, viewToModelPositionOutsideModelElement } from '../../src/utils';
import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter';
import Table from '@ckeditor/ckeditor5-table/src/table';

class InlineWidget extends Plugin {
constructor( editor ) {
super( editor );

editor.model.schema.register( 'placeholder', {
allowWhere: '$text',
isObject: true,
isInline: true,
allowAttributes: [ 'type' ]
} );

editor.conversion.for( 'editingDowncast' ).elementToElement( {
model: 'placeholder',
view: ( modelItem, viewWriter ) => {
const widgetElement = createPlaceholderView( modelItem, viewWriter );

return toWidget( widgetElement, viewWriter );
}
} );

editor.conversion.for( 'dataDowncast' ).elementToElement( {
model: 'placeholder',
view: createPlaceholderView
} );

editor.conversion.for( 'upcast' ).elementToElement( {
view: 'placeholder',
model: ( viewElement, modelWriter ) => {
let type = 'general';

if ( viewElement.childCount ) {
const text = viewElement.getChild( 0 );

if ( text.is( 'text' ) ) {
type = text.data.slice( 1, -1 );
}
}

return modelWriter.createElement( 'placeholder', { type } );
}
} );

editor.editing.mapper.on(
'viewToModelPosition',
viewToModelPositionOutsideModelElement( editor.model, viewElement => viewElement.name == 'placeholder' )
);

this._createToolbarButton();

function createPlaceholderView( modelItem, viewWriter ) {
const widgetElement = viewWriter.createContainerElement( 'placeholder' );
const viewText = viewWriter.createText( '{' + modelItem.getAttribute( 'type' ) + '}' );

viewWriter.insert( viewWriter.createPositionAt( widgetElement, 0 ), viewText );

return widgetElement;
}
}

_createToolbarButton() {
const editor = this.editor;
const t = editor.t;

editor.ui.componentFactory.add( 'placeholder', locale => {
const buttonView = new ButtonView( locale );

buttonView.set( {
label: t( 'Insert placeholder' ),
tooltip: true,
withText: true
} );

this.listenTo( buttonView, 'execute', () => {
const model = editor.model;

model.change( writer => {
const placeholder = writer.createElement( 'placeholder', { type: 'placeholder' } );

model.insertContent( placeholder );

writer.setSelection( placeholder, 'on' );
} );
} );

return buttonView;
} );
}
}

ClassicEditor
.create( global.document.querySelector( '#editor' ), {
plugins: [ Enter, Typing, Paragraph, Heading, Bold, Undo, Clipboard, Widget, ShiftEnter, InlineWidget, Table ],
toolbar: [ 'heading', '|', 'bold', '|', 'placeholder', '|', 'insertTable', '|', 'undo', 'redo' ]
} )
.then( editor => {
window.editor = editor;

editor.model.document.on( 'change', () => {
printModelContents( editor );
} );

printModelContents( editor );
} )
.catch( err => {
console.error( err.stack );
} );

const modelDiv = global.document.querySelector( '#model' );

function printModelContents( editor ) {
modelDiv.innerText = formatData( getData( editor.model ) );
}

function formatData( data ) {
return data
.replace( /<(paragraph|\/tableRow|tableCell|table|heading[0-5])>/g, '\n<$1>' )
.replace( /(<tableCell>)\n(<paragraph>)/g, '$1$2' )
.replace( /\n(<tableCell>)/g, '\n\t$1' );
}
9 changes: 9 additions & 0 deletions tests/manual/inline-widget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
### Inline widget

* You should be able to type after inline widget (if it is last child of parent <heading> or <paragraph>).
* Select text before/after widget and expand selection beyond widget. The widget should be selected and selection expanded beyond it on further expanding.
* The inline-widget should work in nested editable widget (table).
* Use "insert placeholder" toolbar button to insert inline widget.
* Selected inline widget should have
* widget border (blue)
* activate fake selection - the "fake selection" element will be shown above the editor
Loading

0 comments on commit 38fa159

Please sign in to comment.