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 #1654 from ckeditor/t/ckeditor5/1096
Browse files Browse the repository at this point in the history
Feature: Introduced support for inline objects (enables support for inline widgets). Introduced `Schema#isInline()`. Closes [ckeditor/ckeditor5#1049](ckeditor/ckeditor5#1049). Closes [ckeditor/ckeditor5#1426](ckeditor/ckeditor5#1426).
  • Loading branch information
Reinmar authored Feb 18, 2019
2 parents 551ab50 + 48d4242 commit 6b36bf1
Show file tree
Hide file tree
Showing 9 changed files with 194 additions and 19 deletions.
3 changes: 2 additions & 1 deletion docs/framework/guides/deep-dive/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ schema.register( '$block', {
isBlock: true
} );
schema.register( '$text', {
allowIn: '$block'
allowIn: '$block',
isInline: true
} );
```

Expand Down
27 changes: 19 additions & 8 deletions src/conversion/mapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,8 @@ export default class Mapper {
return;
}

let viewBlock = data.viewPosition.parent;
let modelParent = this._viewToModelMapping.get( viewBlock );

while ( !modelParent ) {
viewBlock = viewBlock.parent;
modelParent = this._viewToModelMapping.get( viewBlock );
}

const viewBlock = this.findMappedViewAncestor( data.viewPosition );
const modelParent = this._viewToModelMapping.get( viewBlock );
const modelOffset = this._toModelOffset( data.viewPosition.parent, data.viewPosition.offset, viewBlock );

data.modelPosition = ModelPosition._createAt( modelParent, modelOffset );
Expand Down Expand Up @@ -338,6 +332,23 @@ export default class Mapper {
this._viewToModelLengthCallbacks.set( viewElementName, lengthCallback );
}

/**
* For given `viewPosition`, finds and returns the closest ancestor of this position that has a mapping to
* the model.
*
* @param {module:engine/model/view/position~Position} viewPosition Position for which mapped ancestor should be found.
* @returns {module:engine/model/view/element~Element}
*/
findMappedViewAncestor( viewPosition ) {
let parent = viewPosition.parent;

while ( !this._viewToModelMapping.has( parent ) ) {
parent = parent.parent;
}

return parent;
}

/**
* Calculates model offset based on the view position and the block element.
*
Expand Down
3 changes: 2 additions & 1 deletion src/model/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ export default class Model {
isBlock: true
} );
this.schema.register( '$text', {
allowIn: '$block'
allowIn: '$block',
isInline: true
} );
this.schema.register( '$clipboardHolder', {
allowContentOf: '$root',
Expand Down
30 changes: 25 additions & 5 deletions src/model/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ export default class Schema {

/**
* Returns `true` if the given item is defined to be
* a object element by {@link module:engine/model/schema~SchemaItemDefinition}'s `isObject` property.
* an object element by {@link module:engine/model/schema~SchemaItemDefinition}'s `isObject` property.
*
* schema.isObject( 'paragraph' ); // -> false
* schema.isObject( 'image' ); // -> true
Expand All @@ -246,6 +246,24 @@ export default class Schema {
return !!( def && def.isObject );
}

/**
* Returns `true` if the given item is defined to be
* an inline element by {@link module:engine/model/schema~SchemaItemDefinition}'s `isInline` property.
*
* schema.isInline( 'paragraph' ); // -> false
* schema.isInline( 'softBreak' ); // -> true
*
* const text = writer.createText('foo' );
* schema.isInline( text ); // -> true
*
* @param {module:engine/model/item~Item|module:engine/model/schema~SchemaContextItem|String} item
*/
isInline( item ) {
const def = this.getDefinition( item );

return !!( def && def.isInline );
}

/**
* Checks whether the given node (`child`) can be a child of the given context.
*
Expand Down Expand Up @@ -899,7 +917,7 @@ mix( Schema, ObservableMixin );
* * `allowAttributesOf` – A string or an array of strings. Inherits attributes from other items.
* * `inheritTypesFrom` – A string or an array of strings. Inherits `is*` properties of other items.
* * `inheritAllFrom` – A string. A shorthand for `allowContentOf`, `allowWhere`, `allowAttributesOf`, `inheritTypesFrom`.
* * Additionally, you can define the following `is*` properties: `isBlock`, `isLimit`, `isObject`. Read about them below.
* * Additionally, you can define the following `is*` properties: `isBlock`, `isLimit`, `isObject`, `isInline`. Read about them below.
*
* # The is* properties
*
Expand All @@ -915,8 +933,9 @@ mix( Schema, ObservableMixin );
* a limit element are limited to its content. **Note:** All objects (`isObject`) are treated as limit elements, too.
* * `isObject` – Whether an item is "self-contained" and should be treated as a whole. Examples of object elements:
* `image`, `table`, `video`, etc. **Note:** An object is also a limit, so
* {@link module:engine/model/schema~Schema#isLimit `isLimit()`}
* returns `true` for object elements automatically.
* {@link module:engine/model/schema~Schema#isLimit `isLimit()`} returns `true` for object elements automatically.
* * `isInline` – Whether an item is "text-like" and should be treated as an inline node. Examples of inline elements:
* `$text`, `softBreak` (`<br>`), etc.
*
* # Generic items
*
Expand All @@ -931,7 +950,8 @@ mix( Schema, ObservableMixin );
* isBlock: true
* } );
* this.schema.register( '$text', {
* allowIn: '$block'
* allowIn: '$block',
* isInline: true
* } );
*
* They reflect typical editor content that is contained within one root, consists of several blocks
Expand Down
22 changes: 20 additions & 2 deletions src/view/selection.js
Original file line number Diff line number Diff line change
Expand Up @@ -414,8 +414,26 @@ export default class Selection {
}

const range = this.getFirstRange();
const nodeAfterStart = range.start.nodeAfter;
const nodeBeforeEnd = range.end.nodeBefore;

let nodeAfterStart = range.start.nodeAfter;
let nodeBeforeEnd = range.end.nodeBefore;

// Handle the situation when selection position is at the beginning / at the end of a text node.
// In such situation `.nodeAfter` and `.nodeBefore` are `null` but the selection still might be spanning
// over one element.
//
// <p>Foo{<span class="widget"></span>}bar</p> vs <p>Foo[<span class="widget"></span>]bar</p>
//
// These are basically the same selections, only the difference is if the selection position is at
// at the end/at the beginning of a text node or just before/just after the text node.
//
if ( range.start.parent.is( 'text' ) && range.start.isAtEnd && range.start.parent.nextSibling ) {
nodeAfterStart = range.start.parent.nextSibling;
}

if ( range.end.parent.is( 'text' ) && range.end.isAtStart && range.end.parent.previousSibling ) {
nodeBeforeEnd = range.end.parent.previousSibling;
}

return ( nodeAfterStart instanceof Element && nodeAfterStart == nodeBeforeEnd ) ? nodeAfterStart : null;
}
Expand Down
25 changes: 25 additions & 0 deletions tests/conversion/mapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -721,4 +721,29 @@ describe( 'Mapper', () => {
expect( mapper.getModelLength( viewDiv ) ).to.equal( 6 );
} );
} );

describe( 'findMappedViewAncestor()', () => {
it( 'should return for given view position the closest ancestor which is mapped to a model element', () => {
const mapper = new Mapper();

const modelP = new ModelElement( 'p' );
const modelDiv = new ModelElement( 'div' );

const viewText = new ViewText( 'foo' );
const viewSpan = new ViewElement( 'span', null, viewText );
const viewP = new ViewElement( 'p', null, viewSpan );
const viewDiv = new ViewElement( 'div', null, viewP );

mapper.bindElements( modelP, viewP );
mapper.bindElements( modelDiv, viewDiv );

// <div><p><span>f{}oo</span></p></div>

const viewPosition = new ViewPosition( viewText, 1 );

const viewMappedAncestor = mapper.findMappedViewAncestor( viewPosition );

expect( viewMappedAncestor ).to.equal( viewP );
} );
} );
} );
54 changes: 52 additions & 2 deletions tests/model/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,41 @@ describe( 'Schema', () => {
} );
} );

describe( 'isInline()', () => {
it( 'returns true if an item was registered as inline', () => {
schema.register( 'foo', {
isInline: true
} );

expect( schema.isInline( 'foo' ) ).to.be.true;
} );

it( 'returns false if an item was registered as a limit (because not all limits are objects)', () => {
schema.register( 'foo', {
isLimit: true
} );

expect( schema.isInline( 'foo' ) ).to.be.false;
} );

it( 'returns false if an item was not registered as an object', () => {
schema.register( 'foo' );

expect( schema.isInline( 'foo' ) ).to.be.false;
} );

it( 'returns false if an item was not registered at all', () => {
expect( schema.isInline( 'foo' ) ).to.be.false;
} );

it( 'uses getDefinition()\'s item to definition normalization', () => {
const stub = sinon.stub( schema, 'getDefinition' ).returns( { isInline: true } );

expect( schema.isInline( 'foo' ) ).to.be.true;
expect( stub.calledOnce ).to.be.true;
} );
} );

describe( 'checkChild()', () => {
beforeEach( () => {
schema.register( '$root' );
Expand Down Expand Up @@ -2468,7 +2503,8 @@ describe( 'Schema', () => {
},
() => {
schema.extend( '$text', {
allowAttributes: [ 'bold', 'italic' ]
allowAttributes: [ 'bold', 'italic' ],
isInline: true
} );

// Disallow bold in heading1.
Expand All @@ -2494,7 +2530,8 @@ describe( 'Schema', () => {
isBlock: true
} );
schema.register( '$text', {
allowIn: '$block'
allowIn: '$block',
isInline: true
} );

for ( const definition of definitions ) {
Expand Down Expand Up @@ -2738,40 +2775,53 @@ describe( 'Schema', () => {
expect( schema.checkAttribute( r1i, 'alignment' ) ).to.be.false;
} );

it( '$text is inline', () => {
expect( schema.isLimit( '$text' ) ).to.be.false;
expect( schema.isBlock( '$text' ) ).to.be.false;
expect( schema.isObject( '$text' ) ).to.be.false;
expect( schema.isInline( '$text' ) ).to.be.true;
} );

it( '$root is limit', () => {
expect( schema.isLimit( '$root' ) ).to.be.true;
expect( schema.isBlock( '$root' ) ).to.be.false;
expect( schema.isObject( '$root' ) ).to.be.false;
expect( schema.isInline( '$root' ) ).to.be.false;
} );

it( 'paragraph is block', () => {
expect( schema.isLimit( 'paragraph' ) ).to.be.false;
expect( schema.isBlock( 'paragraph' ) ).to.be.true;
expect( schema.isObject( 'paragraph' ) ).to.be.false;
expect( schema.isInline( 'paragraph' ) ).to.be.false;
} );

it( 'heading1 is block', () => {
expect( schema.isLimit( 'heading1' ) ).to.be.false;
expect( schema.isBlock( 'heading1' ) ).to.be.true;
expect( schema.isObject( 'heading1' ) ).to.be.false;
expect( schema.isInline( 'heading1' ) ).to.be.false;
} );

it( 'listItem is block', () => {
expect( schema.isLimit( 'listItem' ) ).to.be.false;
expect( schema.isBlock( 'listItem' ) ).to.be.true;
expect( schema.isObject( 'listItem' ) ).to.be.false;
expect( schema.isInline( 'lisItem' ) ).to.be.false;
} );

it( 'image is block object', () => {
expect( schema.isLimit( 'image' ) ).to.be.true;
expect( schema.isBlock( 'image' ) ).to.be.true;
expect( schema.isObject( 'image' ) ).to.be.true;
expect( schema.isInline( 'image' ) ).to.be.false;
} );

it( 'caption is limit', () => {
expect( schema.isLimit( 'caption' ) ).to.be.true;
expect( schema.isBlock( 'caption' ) ).to.be.false;
expect( schema.isObject( 'caption' ) ).to.be.false;
expect( schema.isInline( 'caption' ) ).to.be.false;
} );
} );

Expand Down
41 changes: 41 additions & 0 deletions tests/model/utils/selection-post-fixer.js
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,47 @@ describe( 'Selection post-fixer', () => {
} );
} );

describe( 'non-collapsed selection - inline widget scenarios', () => {
beforeEach( () => {
model.schema.register( 'placeholder', {
allowWhere: '$text',
isInline: true
} );
} );

it( 'should fix selection that ends in inline element', () => {
setModelData( model, '<paragraph>aaa[<placeholder>]</placeholder>bbb</paragraph>' );

expect( getModelData( model ) ).to.equal( '<paragraph>aaa[]<placeholder></placeholder>bbb</paragraph>' );
} );

it( 'should fix selection that starts in inline element', () => {
setModelData( model, '<paragraph>aaa<placeholder>[</placeholder>]bbb</paragraph>' );

expect( getModelData( model ) ).to.equal( '<paragraph>aaa<placeholder></placeholder>[]bbb</paragraph>' );
} );

it( 'should fix selection that ends in inline element that is also an object', () => {
model.schema.extend( 'placeholder', {
isObject: true
} );

setModelData( model, '<paragraph>aaa[<placeholder>]</placeholder>bbb</paragraph>' );

expect( getModelData( model ) ).to.equal( '<paragraph>aaa[<placeholder></placeholder>]bbb</paragraph>' );
} );

it( 'should fix selection that starts in inline element that is also an object', () => {
model.schema.extend( 'placeholder', {
isObject: true
} );

setModelData( model, '<paragraph>aaa<placeholder>[</placeholder>]bbb</paragraph>' );

expect( getModelData( model ) ).to.equal( '<paragraph>aaa[<placeholder></placeholder>]bbb</paragraph>' );
} );
} );

describe( 'collapsed selection', () => {
beforeEach( () => {
setModelData( model,
Expand Down
8 changes: 8 additions & 0 deletions tests/view/selection.js
Original file line number Diff line number Diff line change
Expand Up @@ -976,6 +976,14 @@ describe( 'Selection', () => {
expect( selection.getSelectedElement() ).to.equal( b );
} );

it( 'should return selected element if the selection is anchored at the end/at the beginning of a text node', () => {
const { selection: docSelection, view } = parse( 'foo {<b>bar</b>} baz' );
const b = view.getChild( 1 );
const selection = new Selection( docSelection );

expect( selection.getSelectedElement() ).to.equal( b );
} );

it( 'should return null if there is more than one range', () => {
const { selection: docSelection } = parse( 'foo [<b>bar</b>] [<i>baz</i>]' );
const selection = new Selection( docSelection );
Expand Down

0 comments on commit 6b36bf1

Please sign in to comment.