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

Commit 6b36bf1

Browse files
authored
Merge pull request #1654 from ckeditor/t/ckeditor5/1096
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).
2 parents 551ab50 + 48d4242 commit 6b36bf1

File tree

9 files changed

+194
-19
lines changed

9 files changed

+194
-19
lines changed

docs/framework/guides/deep-dive/schema.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ schema.register( '$block', {
5757
isBlock: true
5858
} );
5959
schema.register( '$text', {
60-
allowIn: '$block'
60+
allowIn: '$block',
61+
isInline: true
6162
} );
6263
```
6364

src/conversion/mapper.js

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -91,14 +91,8 @@ export default class Mapper {
9191
return;
9292
}
9393

94-
let viewBlock = data.viewPosition.parent;
95-
let modelParent = this._viewToModelMapping.get( viewBlock );
96-
97-
while ( !modelParent ) {
98-
viewBlock = viewBlock.parent;
99-
modelParent = this._viewToModelMapping.get( viewBlock );
100-
}
101-
94+
const viewBlock = this.findMappedViewAncestor( data.viewPosition );
95+
const modelParent = this._viewToModelMapping.get( viewBlock );
10296
const modelOffset = this._toModelOffset( data.viewPosition.parent, data.viewPosition.offset, viewBlock );
10397

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

335+
/**
336+
* For given `viewPosition`, finds and returns the closest ancestor of this position that has a mapping to
337+
* the model.
338+
*
339+
* @param {module:engine/model/view/position~Position} viewPosition Position for which mapped ancestor should be found.
340+
* @returns {module:engine/model/view/element~Element}
341+
*/
342+
findMappedViewAncestor( viewPosition ) {
343+
let parent = viewPosition.parent;
344+
345+
while ( !this._viewToModelMapping.has( parent ) ) {
346+
parent = parent.parent;
347+
}
348+
349+
return parent;
350+
}
351+
341352
/**
342353
* Calculates model offset based on the view position and the block element.
343354
*

src/model/model.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@ export default class Model {
9494
isBlock: true
9595
} );
9696
this.schema.register( '$text', {
97-
allowIn: '$block'
97+
allowIn: '$block',
98+
isInline: true
9899
} );
99100
this.schema.register( '$clipboardHolder', {
100101
allowContentOf: '$root',

src/model/schema.js

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ export default class Schema {
230230

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

249+
/**
250+
* Returns `true` if the given item is defined to be
251+
* an inline element by {@link module:engine/model/schema~SchemaItemDefinition}'s `isInline` property.
252+
*
253+
* schema.isInline( 'paragraph' ); // -> false
254+
* schema.isInline( 'softBreak' ); // -> true
255+
*
256+
* const text = writer.createText('foo' );
257+
* schema.isInline( text ); // -> true
258+
*
259+
* @param {module:engine/model/item~Item|module:engine/model/schema~SchemaContextItem|String} item
260+
*/
261+
isInline( item ) {
262+
const def = this.getDefinition( item );
263+
264+
return !!( def && def.isInline );
265+
}
266+
249267
/**
250268
* Checks whether the given node (`child`) can be a child of the given context.
251269
*
@@ -899,7 +917,7 @@ mix( Schema, ObservableMixin );
899917
* * `allowAttributesOf` – A string or an array of strings. Inherits attributes from other items.
900918
* * `inheritTypesFrom` – A string or an array of strings. Inherits `is*` properties of other items.
901919
* * `inheritAllFrom` – A string. A shorthand for `allowContentOf`, `allowWhere`, `allowAttributesOf`, `inheritTypesFrom`.
902-
* * Additionally, you can define the following `is*` properties: `isBlock`, `isLimit`, `isObject`. Read about them below.
920+
* * Additionally, you can define the following `is*` properties: `isBlock`, `isLimit`, `isObject`, `isInline`. Read about them below.
903921
*
904922
* # The is* properties
905923
*
@@ -915,8 +933,9 @@ mix( Schema, ObservableMixin );
915933
* a limit element are limited to its content. **Note:** All objects (`isObject`) are treated as limit elements, too.
916934
* * `isObject` – Whether an item is "self-contained" and should be treated as a whole. Examples of object elements:
917935
* `image`, `table`, `video`, etc. **Note:** An object is also a limit, so
918-
* {@link module:engine/model/schema~Schema#isLimit `isLimit()`}
919-
* returns `true` for object elements automatically.
936+
* {@link module:engine/model/schema~Schema#isLimit `isLimit()`} returns `true` for object elements automatically.
937+
* * `isInline` – Whether an item is "text-like" and should be treated as an inline node. Examples of inline elements:
938+
* `$text`, `softBreak` (`<br>`), etc.
920939
*
921940
* # Generic items
922941
*
@@ -931,7 +950,8 @@ mix( Schema, ObservableMixin );
931950
* isBlock: true
932951
* } );
933952
* this.schema.register( '$text', {
934-
* allowIn: '$block'
953+
* allowIn: '$block',
954+
* isInline: true
935955
* } );
936956
*
937957
* They reflect typical editor content that is contained within one root, consists of several blocks

src/view/selection.js

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -414,8 +414,26 @@ export default class Selection {
414414
}
415415

416416
const range = this.getFirstRange();
417-
const nodeAfterStart = range.start.nodeAfter;
418-
const nodeBeforeEnd = range.end.nodeBefore;
417+
418+
let nodeAfterStart = range.start.nodeAfter;
419+
let nodeBeforeEnd = range.end.nodeBefore;
420+
421+
// Handle the situation when selection position is at the beginning / at the end of a text node.
422+
// In such situation `.nodeAfter` and `.nodeBefore` are `null` but the selection still might be spanning
423+
// over one element.
424+
//
425+
// <p>Foo{<span class="widget"></span>}bar</p> vs <p>Foo[<span class="widget"></span>]bar</p>
426+
//
427+
// These are basically the same selections, only the difference is if the selection position is at
428+
// at the end/at the beginning of a text node or just before/just after the text node.
429+
//
430+
if ( range.start.parent.is( 'text' ) && range.start.isAtEnd && range.start.parent.nextSibling ) {
431+
nodeAfterStart = range.start.parent.nextSibling;
432+
}
433+
434+
if ( range.end.parent.is( 'text' ) && range.end.isAtStart && range.end.parent.previousSibling ) {
435+
nodeBeforeEnd = range.end.parent.previousSibling;
436+
}
419437

420438
return ( nodeAfterStart instanceof Element && nodeAfterStart == nodeBeforeEnd ) ? nodeAfterStart : null;
421439
}

tests/conversion/mapper.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,4 +721,29 @@ describe( 'Mapper', () => {
721721
expect( mapper.getModelLength( viewDiv ) ).to.equal( 6 );
722722
} );
723723
} );
724+
725+
describe( 'findMappedViewAncestor()', () => {
726+
it( 'should return for given view position the closest ancestor which is mapped to a model element', () => {
727+
const mapper = new Mapper();
728+
729+
const modelP = new ModelElement( 'p' );
730+
const modelDiv = new ModelElement( 'div' );
731+
732+
const viewText = new ViewText( 'foo' );
733+
const viewSpan = new ViewElement( 'span', null, viewText );
734+
const viewP = new ViewElement( 'p', null, viewSpan );
735+
const viewDiv = new ViewElement( 'div', null, viewP );
736+
737+
mapper.bindElements( modelP, viewP );
738+
mapper.bindElements( modelDiv, viewDiv );
739+
740+
// <div><p><span>f{}oo</span></p></div>
741+
742+
const viewPosition = new ViewPosition( viewText, 1 );
743+
744+
const viewMappedAncestor = mapper.findMappedViewAncestor( viewPosition );
745+
746+
expect( viewMappedAncestor ).to.equal( viewP );
747+
} );
748+
} );
724749
} );

tests/model/schema.js

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,41 @@ describe( 'Schema', () => {
376376
} );
377377
} );
378378

379+
describe( 'isInline()', () => {
380+
it( 'returns true if an item was registered as inline', () => {
381+
schema.register( 'foo', {
382+
isInline: true
383+
} );
384+
385+
expect( schema.isInline( 'foo' ) ).to.be.true;
386+
} );
387+
388+
it( 'returns false if an item was registered as a limit (because not all limits are objects)', () => {
389+
schema.register( 'foo', {
390+
isLimit: true
391+
} );
392+
393+
expect( schema.isInline( 'foo' ) ).to.be.false;
394+
} );
395+
396+
it( 'returns false if an item was not registered as an object', () => {
397+
schema.register( 'foo' );
398+
399+
expect( schema.isInline( 'foo' ) ).to.be.false;
400+
} );
401+
402+
it( 'returns false if an item was not registered at all', () => {
403+
expect( schema.isInline( 'foo' ) ).to.be.false;
404+
} );
405+
406+
it( 'uses getDefinition()\'s item to definition normalization', () => {
407+
const stub = sinon.stub( schema, 'getDefinition' ).returns( { isInline: true } );
408+
409+
expect( schema.isInline( 'foo' ) ).to.be.true;
410+
expect( stub.calledOnce ).to.be.true;
411+
} );
412+
} );
413+
379414
describe( 'checkChild()', () => {
380415
beforeEach( () => {
381416
schema.register( '$root' );
@@ -2468,7 +2503,8 @@ describe( 'Schema', () => {
24682503
},
24692504
() => {
24702505
schema.extend( '$text', {
2471-
allowAttributes: [ 'bold', 'italic' ]
2506+
allowAttributes: [ 'bold', 'italic' ],
2507+
isInline: true
24722508
} );
24732509

24742510
// Disallow bold in heading1.
@@ -2494,7 +2530,8 @@ describe( 'Schema', () => {
24942530
isBlock: true
24952531
} );
24962532
schema.register( '$text', {
2497-
allowIn: '$block'
2533+
allowIn: '$block',
2534+
isInline: true
24982535
} );
24992536

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

2778+
it( '$text is inline', () => {
2779+
expect( schema.isLimit( '$text' ) ).to.be.false;
2780+
expect( schema.isBlock( '$text' ) ).to.be.false;
2781+
expect( schema.isObject( '$text' ) ).to.be.false;
2782+
expect( schema.isInline( '$text' ) ).to.be.true;
2783+
} );
2784+
27412785
it( '$root is limit', () => {
27422786
expect( schema.isLimit( '$root' ) ).to.be.true;
27432787
expect( schema.isBlock( '$root' ) ).to.be.false;
27442788
expect( schema.isObject( '$root' ) ).to.be.false;
2789+
expect( schema.isInline( '$root' ) ).to.be.false;
27452790
} );
27462791

27472792
it( 'paragraph is block', () => {
27482793
expect( schema.isLimit( 'paragraph' ) ).to.be.false;
27492794
expect( schema.isBlock( 'paragraph' ) ).to.be.true;
27502795
expect( schema.isObject( 'paragraph' ) ).to.be.false;
2796+
expect( schema.isInline( 'paragraph' ) ).to.be.false;
27512797
} );
27522798

27532799
it( 'heading1 is block', () => {
27542800
expect( schema.isLimit( 'heading1' ) ).to.be.false;
27552801
expect( schema.isBlock( 'heading1' ) ).to.be.true;
27562802
expect( schema.isObject( 'heading1' ) ).to.be.false;
2803+
expect( schema.isInline( 'heading1' ) ).to.be.false;
27572804
} );
27582805

27592806
it( 'listItem is block', () => {
27602807
expect( schema.isLimit( 'listItem' ) ).to.be.false;
27612808
expect( schema.isBlock( 'listItem' ) ).to.be.true;
27622809
expect( schema.isObject( 'listItem' ) ).to.be.false;
2810+
expect( schema.isInline( 'lisItem' ) ).to.be.false;
27632811
} );
27642812

27652813
it( 'image is block object', () => {
27662814
expect( schema.isLimit( 'image' ) ).to.be.true;
27672815
expect( schema.isBlock( 'image' ) ).to.be.true;
27682816
expect( schema.isObject( 'image' ) ).to.be.true;
2817+
expect( schema.isInline( 'image' ) ).to.be.false;
27692818
} );
27702819

27712820
it( 'caption is limit', () => {
27722821
expect( schema.isLimit( 'caption' ) ).to.be.true;
27732822
expect( schema.isBlock( 'caption' ) ).to.be.false;
27742823
expect( schema.isObject( 'caption' ) ).to.be.false;
2824+
expect( schema.isInline( 'caption' ) ).to.be.false;
27752825
} );
27762826
} );
27772827

tests/model/utils/selection-post-fixer.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -945,6 +945,47 @@ describe( 'Selection post-fixer', () => {
945945
} );
946946
} );
947947

948+
describe( 'non-collapsed selection - inline widget scenarios', () => {
949+
beforeEach( () => {
950+
model.schema.register( 'placeholder', {
951+
allowWhere: '$text',
952+
isInline: true
953+
} );
954+
} );
955+
956+
it( 'should fix selection that ends in inline element', () => {
957+
setModelData( model, '<paragraph>aaa[<placeholder>]</placeholder>bbb</paragraph>' );
958+
959+
expect( getModelData( model ) ).to.equal( '<paragraph>aaa[]<placeholder></placeholder>bbb</paragraph>' );
960+
} );
961+
962+
it( 'should fix selection that starts in inline element', () => {
963+
setModelData( model, '<paragraph>aaa<placeholder>[</placeholder>]bbb</paragraph>' );
964+
965+
expect( getModelData( model ) ).to.equal( '<paragraph>aaa<placeholder></placeholder>[]bbb</paragraph>' );
966+
} );
967+
968+
it( 'should fix selection that ends in inline element that is also an object', () => {
969+
model.schema.extend( 'placeholder', {
970+
isObject: true
971+
} );
972+
973+
setModelData( model, '<paragraph>aaa[<placeholder>]</placeholder>bbb</paragraph>' );
974+
975+
expect( getModelData( model ) ).to.equal( '<paragraph>aaa[<placeholder></placeholder>]bbb</paragraph>' );
976+
} );
977+
978+
it( 'should fix selection that starts in inline element that is also an object', () => {
979+
model.schema.extend( 'placeholder', {
980+
isObject: true
981+
} );
982+
983+
setModelData( model, '<paragraph>aaa<placeholder>[</placeholder>]bbb</paragraph>' );
984+
985+
expect( getModelData( model ) ).to.equal( '<paragraph>aaa[<placeholder></placeholder>]bbb</paragraph>' );
986+
} );
987+
} );
988+
948989
describe( 'collapsed selection', () => {
949990
beforeEach( () => {
950991
setModelData( model,

tests/view/selection.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -976,6 +976,14 @@ describe( 'Selection', () => {
976976
expect( selection.getSelectedElement() ).to.equal( b );
977977
} );
978978

979+
it( 'should return selected element if the selection is anchored at the end/at the beginning of a text node', () => {
980+
const { selection: docSelection, view } = parse( 'foo {<b>bar</b>} baz' );
981+
const b = view.getChild( 1 );
982+
const selection = new Selection( docSelection );
983+
984+
expect( selection.getSelectedElement() ).to.equal( b );
985+
} );
986+
979987
it( 'should return null if there is more than one range', () => {
980988
const { selection: docSelection } = parse( 'foo [<b>bar</b>] [<i>baz</i>]' );
981989
const selection = new Selection( docSelection );

0 commit comments

Comments
 (0)