schema.js 19 KB

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