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

Commit 34a7a06

Browse files
authored
Merge pull request #970 from ckeditor/t/969
### Suggested merge commit message ([convention](https://github.com/ckeditor/ckeditor5-design/wiki/Git-commit-message-convention)) Feature: Introduced two `Schema` helpers – `#checkAttributeInSelection()` and `#getValidRanges()`. Closes #969.
2 parents f86cb65 + 6e21468 commit 34a7a06

File tree

2 files changed

+249
-8
lines changed

2 files changed

+249
-8
lines changed

src/model/schema.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import clone from '@ckeditor/ckeditor5-utils/src/lib/lodash/clone';
1313
import isArray from '@ckeditor/ckeditor5-utils/src/lib/lodash/isArray';
1414
import isString from '@ckeditor/ckeditor5-utils/src/lib/lodash/isString';
1515
import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
16+
import Range from './range';
1617

1718
/**
1819
* Schema is a definition of the structure of the document. It allows to define which tree model items (element, text, etc.)
@@ -305,6 +306,80 @@ export default class Schema {
305306
return chain.some( itemName => itemName == parentItemName );
306307
}
307308

309+
/**
310+
* Checks whether the attribute is allowed in selection:
311+
*
312+
* * if the selection is not collapsed, then checks if the attribute is allowed on any of nodes in that range,
313+
* * if the selection is collapsed, then checks if on the selection position there's a text with the
314+
* specified attribute allowed.
315+
*
316+
* @param {module:engine/model/selection~Selection} selection Selection which will be checked.
317+
* @param {String} attribute The name of the attribute to check.
318+
* @returns {Boolean}
319+
*/
320+
checkAttributeInSelection( selection, attribute ) {
321+
if ( selection.isCollapsed ) {
322+
// Check whether schema allows for a text with the attribute in the selection.
323+
return this.check( { name: '$text', inside: selection.getFirstPosition(), attributes: attribute } );
324+
} else {
325+
const ranges = selection.getRanges();
326+
327+
// For all ranges, check nodes in them until you find a node that is allowed to have the attribute.
328+
for ( const range of ranges ) {
329+
for ( const value of range ) {
330+
// If returned item does not have name property, it is a TextFragment.
331+
const name = value.item.name || '$text';
332+
333+
if ( this.check( { name, inside: value.previousPosition, attributes: attribute } ) ) {
334+
// If we found a node that is allowed to have the attribute, return true.
335+
return true;
336+
}
337+
}
338+
}
339+
}
340+
341+
// If we haven't found such node, return false.
342+
return false;
343+
}
344+
345+
/**
346+
* Transforms the given set ranges into a set of ranges where the given attribute is allowed (and can be applied).
347+
*
348+
* @param {Array.<module:engine/model/range~Range>} ranges Ranges to be validated.
349+
* @param {String} attribute The name of the attribute to check.
350+
* @returns {Array.<module:engine/model/range~Range>} Ranges in which the attribute is allowed.
351+
*/
352+
getValidRanges( ranges, attribute ) {
353+
const validRanges = [];
354+
355+
for ( const range of ranges ) {
356+
let last = range.start;
357+
let from = range.start;
358+
const to = range.end;
359+
360+
for ( const value of range.getWalker() ) {
361+
const name = value.item.name || '$text';
362+
const itemPosition = Position.createBefore( value.item );
363+
364+
if ( !this.check( { name, inside: itemPosition, attributes: attribute } ) ) {
365+
if ( !from.isEqual( last ) ) {
366+
validRanges.push( new Range( from, last ) );
367+
}
368+
369+
from = value.nextPosition;
370+
}
371+
372+
last = value.nextPosition;
373+
}
374+
375+
if ( from && !from.isEqual( to ) ) {
376+
validRanges.push( new Range( from, to ) );
377+
}
378+
}
379+
380+
return validRanges;
381+
}
382+
308383
/**
309384
* Returns {@link module:engine/model/schema~SchemaItem schema item} that was registered in the schema under given name.
310385
* If item has not been found, throws error.

tests/model/schema/schema.js

Lines changed: 174 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@ import { default as Schema, SchemaItem } from '../../../src/model/schema';
77
import Document from '../../../src/model/document';
88
import Element from '../../../src/model/element';
99
import Position from '../../../src/model/position';
10+
import Range from '../../../src/model/range';
11+
import Selection from '../../../src/model/selection';
1012
import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
1113
import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
14+
import { setData, stringify } from '../../../src/dev-utils/model';
1215

1316
testUtils.createSinonSandbox();
1417

@@ -61,7 +64,7 @@ describe( 'Schema', () => {
6164
} );
6265
} );
6366

64-
describe( 'registerItem', () => {
67+
describe( 'registerItem()', () => {
6568
it( 'should register in schema item under given name', () => {
6669
schema.registerItem( 'new' );
6770

@@ -101,7 +104,7 @@ describe( 'Schema', () => {
101104
} );
102105
} );
103106

104-
describe( 'hasItem', () => {
107+
describe( 'hasItem()', () => {
105108
it( 'should return true if given item name has been registered in schema', () => {
106109
expect( schema.hasItem( '$block' ) ).to.be.true;
107110
} );
@@ -111,7 +114,7 @@ describe( 'Schema', () => {
111114
} );
112115
} );
113116

114-
describe( '_getItem', () => {
117+
describe( '_getItem()', () => {
115118
it( 'should return SchemaItem registered under given name', () => {
116119
schema.registerItem( 'new' );
117120

@@ -127,7 +130,7 @@ describe( 'Schema', () => {
127130
} );
128131
} );
129132

130-
describe( 'allow', () => {
133+
describe( 'allow()', () => {
131134
it( 'should add passed query to allowed in schema', () => {
132135
schema.registerItem( 'p', '$block' );
133136
schema.registerItem( 'div', '$block' );
@@ -140,7 +143,7 @@ describe( 'Schema', () => {
140143
} );
141144
} );
142145

143-
describe( 'disallow', () => {
146+
describe( 'disallow()', () => {
144147
it( 'should add passed query to disallowed in schema', () => {
145148
schema.registerItem( 'p', '$block' );
146149
schema.registerItem( 'div', '$block' );
@@ -155,7 +158,7 @@ describe( 'Schema', () => {
155158
} );
156159
} );
157160

158-
describe( 'check', () => {
161+
describe( 'check()', () => {
159162
describe( 'string or array of strings as inside', () => {
160163
it( 'should return false if given element is not registered in schema', () => {
161164
expect( schema.check( { name: 'new', inside: [ 'div', 'header' ] } ) ).to.be.false;
@@ -409,7 +412,7 @@ describe( 'Schema', () => {
409412
} );
410413
} );
411414

412-
describe( 'itemExtends', () => {
415+
describe( 'itemExtends()', () => {
413416
it( 'should return true if given item extends another given item', () => {
414417
schema.registerItem( 'div', '$block' );
415418
schema.registerItem( 'myDiv', 'div' );
@@ -438,7 +441,7 @@ describe( 'Schema', () => {
438441
} );
439442
} );
440443

441-
describe( '_normalizeQueryPath', () => {
444+
describe( '_normalizeQueryPath()', () => {
442445
it( 'should normalize string with spaces to an array of strings', () => {
443446
expect( Schema._normalizeQueryPath( '$root div strong' ) ).to.deep.equal( [ '$root', 'div', 'strong' ] );
444447
} );
@@ -471,4 +474,167 @@ describe( 'Schema', () => {
471474
expect( Schema._normalizeQueryPath( input ) ).to.deep.equal( [ '$root', 'div', 'p', 'strong' ] );
472475
} );
473476
} );
477+
478+
describe( 'checkAttributeInSelection()', () => {
479+
const attribute = 'bold';
480+
let doc, schema;
481+
482+
beforeEach( () => {
483+
doc = new Document();
484+
doc.createRoot();
485+
486+
schema = doc.schema;
487+
488+
schema.registerItem( 'p', '$block' );
489+
schema.registerItem( 'h1', '$block' );
490+
schema.registerItem( 'img', '$inline' );
491+
492+
// Bold text is allowed only in P.
493+
schema.allow( { name: '$text', attributes: 'bold', inside: 'p' } );
494+
schema.allow( { name: 'p', attributes: 'bold', inside: '$root' } );
495+
496+
// Disallow bold on image.
497+
schema.disallow( { name: 'img', attributes: 'bold', inside: '$root' } );
498+
} );
499+
500+
describe( 'when selection is collapsed', () => {
501+
it( 'should return true if characters with the attribute can be placed at caret position', () => {
502+
setData( doc, '<p>f[]oo</p>' );
503+
expect( schema.checkAttributeInSelection( doc.selection, attribute ) ).to.be.true;
504+
} );
505+
506+
it( 'should return false if characters with the attribute cannot be placed at caret position', () => {
507+
setData( doc, '<h1>[]</h1>' );
508+
expect( schema.checkAttributeInSelection( doc.selection, attribute ) ).to.be.false;
509+
510+
setData( doc, '[]' );
511+
expect( schema.checkAttributeInSelection( doc.selection, attribute ) ).to.be.false;
512+
} );
513+
} );
514+
515+
describe( 'when selection is not collapsed', () => {
516+
it( 'should return true if there is at least one node in selection that can have the attribute', () => {
517+
// Simple selection on a few characters.
518+
setData( doc, '<p>[foo]</p>' );
519+
expect( schema.checkAttributeInSelection( doc.selection, attribute ) ).to.be.true;
520+
521+
// Selection spans over characters but also include nodes that can't have attribute.
522+
setData( doc, '<p>fo[o<img />b]ar</p>' );
523+
expect( schema.checkAttributeInSelection( doc.selection, attribute ) ).to.be.true;
524+
525+
// Selection on whole root content. Characters in P can have an attribute so it's valid.
526+
setData( doc, '[<p>foo<img />bar</p><h1></h1>]' );
527+
expect( schema.checkAttributeInSelection( doc.selection, attribute ) ).to.be.true;
528+
529+
// Selection on empty P. P can have the attribute.
530+
setData( doc, '[<p></p>]' );
531+
expect( schema.checkAttributeInSelection( doc.selection, attribute ) ).to.be.true;
532+
} );
533+
534+
it( 'should return false if there are no nodes in selection that can have the attribute', () => {
535+
// Selection on DIV which can't have bold text.
536+
setData( doc, '[<h1></h1>]' );
537+
expect( schema.checkAttributeInSelection( doc.selection, attribute ) ).to.be.false;
538+
539+
// Selection on two images which can't be bold.
540+
setData( doc, '<p>foo[<img /><img />]bar</p>' );
541+
expect( schema.checkAttributeInSelection( doc.selection, attribute ) ).to.be.false;
542+
} );
543+
} );
544+
} );
545+
546+
describe( 'getValidRanges()', () => {
547+
const attribute = 'bold';
548+
let doc, root, schema, ranges;
549+
550+
beforeEach( () => {
551+
doc = new Document();
552+
schema = doc.schema;
553+
root = doc.createRoot();
554+
555+
schema.registerItem( 'p', '$block' );
556+
schema.registerItem( 'h1', '$block' );
557+
schema.registerItem( 'img', '$inline' );
558+
559+
schema.allow( { name: '$text', attributes: 'bold', inside: 'p' } );
560+
schema.allow( { name: 'p', attributes: 'bold', inside: '$root' } );
561+
562+
setData( doc, '<p>foo<img />bar</p>' );
563+
ranges = [ Range.createOn( root.getChild( 0 ) ) ];
564+
} );
565+
566+
it( 'should return unmodified ranges when attribute is allowed on each item (text is not allowed in img)', () => {
567+
schema.allow( { name: 'img', attributes: 'bold', inside: 'p' } );
568+
569+
expect( schema.getValidRanges( ranges, attribute ) ).to.deep.equal( ranges );
570+
} );
571+
572+
it( 'should return unmodified ranges when attribute is allowed on each item (text is allowed in img)', () => {
573+
schema.allow( { name: 'img', attributes: 'bold', inside: 'p' } );
574+
schema.allow( { name: '$text', inside: 'img' } );
575+
576+
expect( schema.getValidRanges( ranges, attribute ) ).to.deep.equal( ranges );
577+
} );
578+
579+
it( 'should return two ranges when attribute is not allowed on one item', () => {
580+
schema.allow( { name: 'img', attributes: 'bold', inside: 'p' } );
581+
schema.allow( { name: '$text', inside: 'img' } );
582+
583+
setData( doc, '[<p>foo<img>xxx</img>bar</p>]' );
584+
585+
const validRanges = schema.getValidRanges( doc.selection.getRanges(), attribute );
586+
const sel = new Selection();
587+
sel.setRanges( validRanges );
588+
589+
expect( stringify( root, sel ) ).to.equal( '[<p>foo<img>]xxx[</img>bar</p>]' );
590+
} );
591+
592+
it( 'should return three ranges when attribute is not allowed on one element but is allowed on its child', () => {
593+
schema.allow( { name: '$text', inside: 'img' } );
594+
schema.allow( { name: '$text', attributes: 'bold', inside: 'img' } );
595+
596+
setData( doc, '[<p>foo<img>xxx</img>bar</p>]' );
597+
598+
const validRanges = schema.getValidRanges( doc.selection.getRanges(), attribute );
599+
const sel = new Selection();
600+
sel.setRanges( validRanges );
601+
602+
expect( stringify( root, sel ) ).to.equal( '[<p>foo]<img>[xxx]</img>[bar</p>]' );
603+
} );
604+
605+
it( 'should not leak beyond the given ranges', () => {
606+
setData( doc, '<p>[foo<img></img>bar]x[bar<img></img>foo]</p>' );
607+
608+
const validRanges = schema.getValidRanges( doc.selection.getRanges(), attribute );
609+
const sel = new Selection();
610+
sel.setRanges( validRanges );
611+
612+
expect( stringify( root, sel ) ).to.equal( '<p>[foo]<img></img>[bar]x[bar]<img></img>[foo]</p>' );
613+
} );
614+
615+
it( 'should correctly handle a range which ends in a disallowed position', () => {
616+
schema.allow( { name: '$text', inside: 'img' } );
617+
618+
setData( doc, '<p>[foo<img>bar]</img>bom</p>' );
619+
620+
const validRanges = schema.getValidRanges( doc.selection.getRanges(), attribute );
621+
const sel = new Selection();
622+
sel.setRanges( validRanges );
623+
624+
expect( stringify( root, sel ) ).to.equal( '<p>[foo]<img>bar</img>bom</p>' );
625+
} );
626+
627+
it( 'should split range into two ranges and omit disallowed element', () => {
628+
// Disallow bold on img.
629+
doc.schema.disallow( { name: 'img', attributes: 'bold', inside: 'p' } );
630+
631+
const result = schema.getValidRanges( ranges, attribute );
632+
633+
expect( result ).to.length( 2 );
634+
expect( result[ 0 ].start.path ).to.members( [ 0 ] );
635+
expect( result[ 0 ].end.path ).to.members( [ 0, 3 ] );
636+
expect( result[ 1 ].start.path ).to.members( [ 0, 4 ] );
637+
expect( result[ 1 ].end.path ).to.members( [ 1 ] );
638+
} );
639+
} );
474640
} );

0 commit comments

Comments
 (0)