8
0

schema.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597
  1. /**
  2. * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /**
  6. * @module engine/model/schema
  7. */
  8. import Position from './position.js';
  9. import Element from './element.js';
  10. import clone from '../../utils/lib/lodash/clone.js';
  11. import isArray from '../../utils/lib/lodash/isArray.js';
  12. import isString from '../../utils/lib/lodash/isString.js';
  13. import CKEditorError from '../../utils/ckeditorerror.js';
  14. /**
  15. * Schema is a definition of the structure of the document. It allows to define which tree model items (element, text, etc.)
  16. * can be nested within which ones and which attributes can be applied to them. It's created during the run-time of the application,
  17. * typically by features. Also, the features can query the schema to learn what structure is allowed and act accordingly.
  18. *
  19. * For instance, if a feature wants to define that an attribute bold is allowed on the text it needs to register this rule like this:
  20. *
  21. * editor.document.schema.allow( '$text', 'bold' );
  22. *
  23. * Note: items prefixed with `$` are special group of items. By default, `Schema` defines three special items:
  24. *
  25. * * `$inline` represents all inline elements,
  26. * * `$text` is a sub-group of `$inline` and represents text nodes,
  27. * * `$block` represents block elements,
  28. * * `$root` represents default editing roots (those that allow only `$block`s inside them).
  29. *
  30. * When registering an item it's possible to tell that this item should inherit from some other existing item.
  31. * E.g. `p` can inherit from `$block`, so whenever given attribute is allowed on the `$block` it will automatically be
  32. * also allowed on the `p` element. By default, `$text` item already inherits from `$inline`.
  33. */
  34. export default class Schema {
  35. /**
  36. * Creates Schema instance.
  37. */
  38. constructor() {
  39. /**
  40. * Names of elements which have "object" nature. This means that these
  41. * elements should be treated as whole, never merged, can be selected from outside, etc.
  42. * Just like images, placeholder widgets, etc.
  43. *
  44. * @member {Set.<String>} module:engine/model/schema~Schema#objects
  45. */
  46. this.objects = new Set();
  47. /**
  48. * Schema items registered in the schema.
  49. *
  50. * @private
  51. * @member {Map} module:engine/model/schema~Schema#_items
  52. */
  53. this._items = new Map();
  54. /**
  55. * Description of what entities are a base for given entity.
  56. *
  57. * @private
  58. * @member {Map} module:engine/model/schema~Schema#_extensionChains
  59. */
  60. this._extensionChains = new Map();
  61. // Register some default abstract entities.
  62. this.registerItem( '$root' );
  63. this.registerItem( '$block' );
  64. this.registerItem( '$inline' );
  65. this.registerItem( '$text', '$inline' );
  66. this.allow( { name: '$block', inside: '$root' } );
  67. this.allow( { name: '$inline', inside: '$block' } );
  68. // TMP!
  69. // Create an "all allowed" context in the schema for processing the pasted content.
  70. // Read: https://github.com/ckeditor/ckeditor5-engine/issues/638#issuecomment-255086588
  71. this.registerItem( '$clipboardHolder', '$root' );
  72. this.allow( { name: '$inline', inside: '$clipboardHolder' } );
  73. }
  74. /**
  75. * Allows given query in the schema.
  76. *
  77. * // Allow text with bold attribute in all P elements.
  78. * schema.registerItem( 'p', '$block' );
  79. * schema.allow( { name: '$text', attributes: 'bold', inside: 'p' } );
  80. *
  81. * // Allow header in Ps that are in DIVs
  82. * schema.registerItem( 'header', '$block' );
  83. * schema.registerItem( 'div', '$block' );
  84. * schema.allow( { name: 'header', inside: 'div p' } ); // inside: [ 'div', 'p' ] would also work.
  85. *
  86. * @param {module:engine/model/schema~SchemaQuery} query Allowed query.
  87. */
  88. allow( query ) {
  89. this._getItem( query.name ).allow( Schema._normalizeQueryPath( query.inside ), query.attributes );
  90. }
  91. /**
  92. * Disallows given query in the schema.
  93. *
  94. * @see #allow
  95. * @param {module:engine/model/schema~SchemaQuery} query Disallowed query.
  96. */
  97. disallow( query ) {
  98. this._getItem( query.name ).disallow( Schema._normalizeQueryPath( query.inside ), query.attributes );
  99. }
  100. /**
  101. * Makes a requirement in schema that entity represented by given item has to have given set of attributes. Some
  102. * elements in the model might require some attributes to be set. If multiple sets of attributes are required it
  103. * is enough that the entity fulfills only one set.
  104. *
  105. * // "a" element must either have "href" attribute or "name" attribute
  106. * schema.requireAttributes( 'a', [ 'href' ] );
  107. * schema.requireAttributes( 'a', [ 'name' ] );
  108. * // "img" element must have both "src" and "alt" attributes
  109. * schema.requireAttributes( 'img', [ 'src', 'alt' ] );
  110. *
  111. * @param {String} name Entity name.
  112. * @param {Array.<String>} attributes Attributes that has to be set on the entity to make it valid.
  113. */
  114. requireAttributes( name, attributes ) {
  115. this._getItem( name ).requireAttributes( attributes );
  116. }
  117. /**
  118. * Checks whether given query is allowed in schema.
  119. *
  120. * // Check whether bold text is allowed in header element.
  121. * let query = {
  122. * name: '$text',
  123. * attributes: 'bold',
  124. * inside: 'header'
  125. * };
  126. * if ( schema.check( query ) ) { ... }
  127. *
  128. * // Check whether bold and italic text can be placed at caret position.
  129. * let caretPos = editor.document.selection.getFirstPosition();
  130. * let query = {
  131. * name: '$text',
  132. * attributes: [ 'bold', 'italic' ],
  133. * inside: caretPos
  134. * };
  135. * if ( schema.check( query ) ) { ... }
  136. *
  137. * // Check whether image with alt, src and title is allowed in given elements path.
  138. * let quoteElement = new Element( 'quote' );
  139. * let query = {
  140. * name: 'img',
  141. * attributes: [ 'alt', 'src', 'title' ],
  142. * // It is possible to mix strings with elements.
  143. * // Query will check whether "img" can be inside "quoteElement" that is inside a block element.
  144. * inside: [ '$block', quoteElement ]
  145. * };
  146. * if ( schema.check( query ) ) { ... }
  147. *
  148. * @param {module:engine/model/schema~SchemaQuery} query Query to check.
  149. * @returns {Boolean} `true` if given query is allowed in schema, `false` otherwise.
  150. */
  151. check( query ) {
  152. if ( !this.hasItem( query.name ) ) {
  153. return false;
  154. }
  155. // If attributes property is a string or undefined, wrap it in an array for easier processing.
  156. if ( !isArray( query.attributes ) ) {
  157. query.attributes = [ query.attributes ];
  158. } else if ( query.attributes.length === 0 ) {
  159. // To simplify algorithms, when a SchemaItem path is added "without" attribute, it is added with
  160. // attribute equal to undefined. This means that algorithms can work the same way for specified attributes
  161. // and no-atrtibutes, but we have to fill empty array with "fake" undefined value for algorithms reasons.
  162. query.attributes.push( undefined );
  163. }
  164. // Normalize the path to an array of strings.
  165. const path = Schema._normalizeQueryPath( query.inside );
  166. // Get extension chain of given item and retrieve all schema items that are extended by given item.
  167. const schemaItems = this._extensionChains.get( query.name ).map( ( name ) => {
  168. return this._getItem( name );
  169. } );
  170. // First check if the query meets at required attributes for this item.
  171. if ( !this._getItem( query.name )._checkRequiredAttributes( query.attributes ) ) {
  172. return false;
  173. }
  174. // If there is matching disallow path, this query is not valid with schema.
  175. for ( let attribute of query.attributes ) {
  176. for ( let schemaItem of schemaItems ) {
  177. if ( schemaItem._hasMatchingPath( 'disallow', path, attribute ) ) {
  178. return false;
  179. }
  180. }
  181. }
  182. // At this point, the query is not disallowed.
  183. // If there are correct allow paths that match the query, this query is valid with schema.
  184. // Since we are supporting multiple attributes, we have to make sure that if attributes are set,
  185. // we have allowed paths for all of them.
  186. // Keep in mind that if the query has no attributes, query.attribute was converted to an array
  187. // with a single `undefined` value. This fits the algorithm well.
  188. for ( let attribute of query.attributes ) {
  189. let matched = false;
  190. for ( let schemaItem of schemaItems ) {
  191. if ( schemaItem._hasMatchingPath( 'allow', path, attribute ) ) {
  192. matched = true;
  193. break;
  194. }
  195. }
  196. // The attribute has not been matched, so it is not allowed by any schema item.
  197. // The query is disallowed.
  198. if ( !matched ) {
  199. return false;
  200. }
  201. }
  202. return true;
  203. }
  204. /**
  205. * Checks whether there is an item registered under given name in schema.
  206. *
  207. * @param itemName
  208. * @returns {Boolean}
  209. */
  210. hasItem( itemName ) {
  211. return this._items.has( itemName );
  212. }
  213. /**
  214. * Registers given item name in schema.
  215. *
  216. * // Register P element that should be treated like all block elements.
  217. * schema.registerItem( 'p', '$block' );
  218. *
  219. * @param {String} itemName Name to register.
  220. * @param [isExtending] If set, new item will extend item with given name.
  221. */
  222. registerItem( itemName, isExtending ) {
  223. if ( this.hasItem( itemName ) ) {
  224. /**
  225. * Item with specified name already exists in schema.
  226. *
  227. * @error model-schema-item-exists
  228. */
  229. throw new CKEditorError( 'model-schema-item-exists: Item with specified name already exists in schema.' );
  230. }
  231. if ( !!isExtending && !this.hasItem( isExtending ) ) {
  232. /**
  233. * Item with specified name does not exist in schema.
  234. *
  235. * @error model-schema-no-item
  236. */
  237. throw new CKEditorError( 'model-schema-no-item: Item with specified name does not exist in schema.' );
  238. }
  239. // Create new SchemaItem and add it to the items store.
  240. this._items.set( itemName, new SchemaItem( this ) );
  241. // Create an extension chain.
  242. // Extension chain has all item names that should be checked when that item is on path to check.
  243. // This simply means, that if item is not extending anything, it should have only itself in it's extension chain.
  244. // Since extending is not dynamic, we can simply get extension chain of extended item and expand it with registered name,
  245. // if the registered item is extending something.
  246. const chain = this.hasItem( isExtending ) ? this._extensionChains.get( isExtending ).concat( itemName ) : [ itemName ];
  247. this._extensionChains.set( itemName, chain );
  248. }
  249. /**
  250. * Checks whether item of given name is extending item of another given name.
  251. *
  252. * @param {String} childItemName Name of the child item.
  253. * @param {String} parentItemName Name of the parent item.
  254. * @returns {Boolean} `true` if child item extends parent item, `false` otherwise.
  255. */
  256. itemExtends( childItemName, parentItemName ) {
  257. if ( !this.hasItem( childItemName ) || !this.hasItem( parentItemName ) ) {
  258. /**
  259. * Item with specified name does not exist in schema.
  260. *
  261. * @error model-schema-no-item
  262. */
  263. throw new CKEditorError( 'model-schema-no-item: Item with specified name does not exist in schema.' );
  264. }
  265. const chain = this._extensionChains.get( childItemName );
  266. return chain.some( itemName => itemName == parentItemName );
  267. }
  268. /**
  269. * Returns {@link module:engine/model/schema~SchemaItem schema item} that was registered in the schema under given name.
  270. * If item has not been found, throws error.
  271. *
  272. * @private
  273. * @param {String} itemName Name to look for in schema.
  274. * @returns {module:engine/model/schema~SchemaItem} Schema item registered under given name.
  275. */
  276. _getItem( itemName ) {
  277. if ( !this.hasItem( itemName ) ) {
  278. /**
  279. * Item with specified name does not exist in schema.
  280. *
  281. * @error model-schema-no-item
  282. */
  283. throw new CKEditorError( 'model-schema-no-item: Item with specified name does not exist in schema.' );
  284. }
  285. return this._items.get( itemName );
  286. }
  287. /**
  288. * Normalizes a path to an entity by converting it from {@link module:engine/model/schema~SchemaPath} to an array of strings.
  289. *
  290. * @protected
  291. * @param {module:engine/model/schema~SchemaPath} path Path to normalize.
  292. * @returns {Array.<String>} Normalized path.
  293. */
  294. static _normalizeQueryPath( path ) {
  295. let normalized = [];
  296. if ( isArray( path ) ) {
  297. for ( let pathItem of path ) {
  298. if ( pathItem instanceof Element ) {
  299. normalized.push( pathItem.name );
  300. } else if ( isString( pathItem ) ) {
  301. normalized.push( pathItem );
  302. }
  303. }
  304. } else if ( path instanceof Position ) {
  305. let parent = path.parent;
  306. while ( parent !== null ) {
  307. normalized.push( parent.name );
  308. parent = parent.parent;
  309. }
  310. normalized.reverse();
  311. } else if ( isString( path ) ) {
  312. normalized = path.split( ' ' );
  313. }
  314. return normalized;
  315. }
  316. }
  317. /**
  318. * SchemaItem is a singular registry item in {@link module:engine/model/schema~Schema} that groups and holds allow/disallow rules for
  319. * one entity. This class is used internally in {@link module:engine/model/schema~Schema} and should not be used outside it.
  320. *
  321. * @see module:engine/model/schema~Schema
  322. * @protected
  323. */
  324. export class SchemaItem {
  325. /**
  326. * Creates SchemaItem instance.
  327. *
  328. * @param {module:engine/model/schema~Schema} schema Schema instance that owns this item.
  329. */
  330. constructor( schema ) {
  331. /**
  332. * Schema instance that owns this item.
  333. *
  334. * @private
  335. * @member {module:engine/model/schema~Schema} module:engine/model/schema~SchemaItem#_schema
  336. */
  337. this._schema = schema;
  338. /**
  339. * Paths in which the entity, represented by this item, is allowed.
  340. *
  341. * @private
  342. * @member {Array} module:engine/model/schema~SchemaItem#_allowed
  343. */
  344. this._allowed = [];
  345. /**
  346. * Paths in which the entity, represented by this item, is disallowed.
  347. *
  348. * @private
  349. * @member {Array} module:engine/model/schema~SchemaItem#_disallowed
  350. */
  351. this._disallowed = [];
  352. /**
  353. * Attributes that are required by the entity represented by this item.
  354. *
  355. * @protected
  356. * @member {Array} module:engine/model/schema~SchemaItem#_requiredAttributes
  357. */
  358. this._requiredAttributes = [];
  359. }
  360. /**
  361. * Allows entity, represented by this item, to be in given path.
  362. *
  363. * @param {Array.<String>} path Path in which entity is allowed.
  364. * @param {Array.<String>|String} [attributes] If set, this path will be used only for entities that have attribute(s) with this key.
  365. */
  366. allow( path, attributes ) {
  367. this._addPath( '_allowed', path, attributes );
  368. }
  369. /**
  370. * Disallows entity, represented by this item, to be in given path.
  371. *
  372. * @param {Array.<String>} path Path in which entity is disallowed.
  373. * @param {Array.<String>|String} [attributes] If set, this path will be used only for entities that have an attribute(s) with this key.
  374. */
  375. disallow( path, attributes ) {
  376. this._addPath( '_disallowed', path, attributes );
  377. }
  378. /**
  379. * Specifies that the entity, to be valid, requires given attributes set. It is possible to register multiple
  380. * different attributes set. If there are more than one attributes set required, the entity will be valid if
  381. * at least one of them is fulfilled.
  382. *
  383. * @param {Array.<String>} attributes Attributes that has to be set on the entity to make it valid.
  384. */
  385. requireAttributes( attributes ) {
  386. this._requiredAttributes.push( attributes );
  387. }
  388. /**
  389. * Adds path to the SchemaItem instance.
  390. *
  391. * @private
  392. * @param {String} member Name of the array member into which the path will be added. Possible values are `_allowed` or `_disallowed`.
  393. * @param {Array.<String>} path Path to add.
  394. * @param {Array.<String>|String} [attributes] If set, this path will be used only for entities that have attribute(s) with this key.
  395. */
  396. _addPath( member, path, attributes ) {
  397. path = path.slice();
  398. if ( !isArray( attributes ) ) {
  399. attributes = [ attributes ];
  400. }
  401. for ( let attribute of attributes ) {
  402. this[ member ].push( { path, attribute } );
  403. }
  404. }
  405. /**
  406. * Returns all paths of given type that were previously registered in the item.
  407. *
  408. * @private
  409. * @param {String} type Paths' type. Possible values are `allow` or `disallow`.
  410. * @param {String} [attribute] If set, only paths registered for given attribute will be returned.
  411. * @returns {Array} Paths registered in the item.
  412. */
  413. _getPaths( type, attribute ) {
  414. const source = type === 'allow' ? this._allowed : this._disallowed;
  415. const paths = [];
  416. for ( let item of source ) {
  417. if ( item.attribute === attribute ) {
  418. paths.push( item.path );
  419. }
  420. }
  421. return paths;
  422. }
  423. /**
  424. * Checks whether given set of attributes fulfills required attributes of this item.
  425. *
  426. * @protected
  427. * @see module:engine/model/schema~SchemaItem#requireAttributes
  428. * @param {Array.<String>} attributesToCheck Attributes to check.
  429. * @returns {Boolean} `true` if given set or attributes fulfills required attributes, `false` otherwise.
  430. */
  431. _checkRequiredAttributes( attributesToCheck ) {
  432. let found = true;
  433. for ( let attributeSet of this._requiredAttributes ) {
  434. found = true;
  435. for ( let attribute of attributeSet ) {
  436. if ( attributesToCheck.indexOf( attribute ) == -1 ) {
  437. found = false;
  438. break;
  439. }
  440. }
  441. if ( found ) {
  442. break;
  443. }
  444. }
  445. return found;
  446. }
  447. /**
  448. * Checks whether this item has any registered path of given type that matches the provided path.
  449. *
  450. * @protected
  451. * @param {String} type Paths' type. Possible values are `allow` or `disallow`.
  452. * @param {Array.<String>} checkPath Path to check.
  453. * @param {String} [attribute] If set, only paths registered for given attribute will be checked.
  454. * @returns {Boolean} `true` if item has any registered matching path, `false` otherwise.
  455. */
  456. _hasMatchingPath( type, checkPath, attribute ) {
  457. const allowedPaths = this._getPaths( type, attribute );
  458. for ( const allowedPath of allowedPaths ) {
  459. if ( matchPaths( this._schema, checkPath, allowedPath ) ) {
  460. return true;
  461. }
  462. }
  463. return false;
  464. }
  465. /**
  466. * Custom toJSON method to solve child-parent circular dependencies.
  467. *
  468. * @returns {Object} Clone of this object with the parent property replaced with its name.
  469. */
  470. toJSON() {
  471. const json = clone( this );
  472. // Due to circular references we need to remove parent reference.
  473. json._schema = '[model.Schema]';
  474. return json;
  475. }
  476. }
  477. /**
  478. * Object with query used by {@link module:engine/model/schema~Schema} to query schema or add allow/disallow rules to schema.
  479. *
  480. * @typedef {Object} module:engine/model/schema~SchemaQuery
  481. * @property {String} name Entity name.
  482. * @property {module:engine/model/schema~SchemaPath} inside Path inside which the entity is placed.
  483. * @property {Array.<String>|String} [attributes] If set, the query applies only to entities that has attribute(s) with given key.
  484. */
  485. /**
  486. * Path to an entity, begins from the top-most ancestor. Can be passed in multiple formats. Internally, normalized to
  487. * an array of strings. If string is passed, entities from the path should be divided by ` ` (space character). If
  488. * an array is passed, unrecognized items are skipped. If position is passed, it is assumed that the entity is at given position.
  489. *
  490. * @typedef {String|Array.<String|module:engine/model/element~Element>|module:engine/model/position~Position}
  491. * module:engine/model/schema~SchemaPath
  492. */
  493. // Checks whether the given checkPath and allowedPath right ends match.
  494. //
  495. // checkPath: C, D
  496. // allowedPath: A, B, C, D
  497. // result: OK
  498. //
  499. // checkPath: A, B, C
  500. // allowedPath: A, B, C, D
  501. // result: NOK
  502. //
  503. // Note – when matching paths, element extension chains (inheritance) are taken into consideration.
  504. //
  505. // @param {Schema} schema
  506. // @param {Array.<String>} checkPath
  507. // @param {Array.<String>} allowedPath
  508. function matchPaths( schema, checkPath, allowedPath ) {
  509. // Start checking from the right end of both tables.
  510. let allowedPathIndex = allowedPath.length - 1;
  511. let checkPathIndex = checkPath.length - 1;
  512. // And finish once reaching an end of the shorter table.
  513. while ( allowedPathIndex >= 0 && checkPathIndex >= 0 ) {
  514. const checkName = checkPath[ checkPathIndex ];
  515. // Fail when checking a path which contains element which aren't even registered to the schema.
  516. if ( !schema.hasItem( checkName ) ) {
  517. return false;
  518. }
  519. const extChain = schema._extensionChains.get( checkName );
  520. if ( extChain.includes( allowedPath[ allowedPathIndex ] ) ) {
  521. allowedPathIndex--;
  522. checkPathIndex--;
  523. } else {
  524. return false;
  525. }
  526. }
  527. return true;
  528. }