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

PR: Acknowledge inline widgets #70

Merged
merged 17 commits into from
Feb 18, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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' );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering – shouldn't that placeholder be selected by insertContent()? If it was a block, it would be. However, if it was a block, the case is slightly different because the selection should not be set after it. So selecting it is the most reasonable solution. In case of inline objects, we can set the selection after this element (and we do that now). WDYT?

cc @scofalik @jodator

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From UX perspective, I think that setting the selection after the inline-widget is defensible. You insert the widget and maybe want to go on with writing.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I can asnwer this myself – pasting should not select the placeholder, so should not insertContent().

} );
} );

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