model.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  1. /**
  2. * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /**
  6. * @module engine/dev-utils/model
  7. */
  8. /**
  9. * Collection of methods for manipulating the {@link module:engine/model/model model} for testing purposes.
  10. */
  11. import RootElement from '../model/rootelement';
  12. import Model from '../model/model';
  13. import ModelRange from '../model/range';
  14. import ModelPosition from '../model/position';
  15. import ModelSelection from '../model/selection';
  16. import ModelDocumentFragment from '../model/documentfragment';
  17. import DocumentSelection from '../model/documentselection';
  18. import View from '../view/view';
  19. import ViewContainerElement from '../view/containerelement';
  20. import ViewRootEditableElement from '../view/rooteditableelement';
  21. import { parse as viewParse, stringify as viewStringify } from '../../src/dev-utils/view';
  22. import DowncastDispatcher from '../conversion/downcastdispatcher';
  23. import UpcastDispatcher from '../conversion/upcastdispatcher';
  24. import Mapper from '../conversion/mapper';
  25. import {
  26. convertRangeSelection,
  27. convertCollapsedSelection,
  28. } from '../conversion/downcast-selection-converters';
  29. import { insertText, insertElement, wrap } from '../conversion/downcast-converters';
  30. import isPlainObject from '@ckeditor/ckeditor5-utils/src/lib/lodash/isPlainObject';
  31. import toMap from '@ckeditor/ckeditor5-utils/src/tomap';
  32. /**
  33. * Writes the content of the {@link module:engine/model/document~Document document} to an HTML-like string.
  34. *
  35. * **Note:** A {@link module:engine/model/text~Text text} node that contains attributes will be represented as:
  36. *
  37. * <$text attribute="value">Text data</$text>
  38. *
  39. * @param {module:engine/model/model~Model} model
  40. * @param {Object} [options]
  41. * @param {Boolean} [options.withoutSelection=false] Whether to write the selection. When set to `true`, the selection will
  42. * not be included in the returned string.
  43. * @param {String} [options.rootName='main'] The name of the root from which the data should be stringified. If not provided,
  44. * the default `main` name will be used.
  45. * @returns {String} The stringified data.
  46. */
  47. export function getData( model, options = {} ) {
  48. if ( !( model instanceof Model ) ) {
  49. throw new TypeError( 'Model needs to be an instance of module:engine/model/model~Model.' );
  50. }
  51. const withoutSelection = options.withoutSelection;
  52. const rootName = options.rootName || 'main';
  53. const root = model.document.getRoot( rootName );
  54. return withoutSelection ? getData._stringify( root ) : getData._stringify( root, model.document.selection );
  55. }
  56. // Set stringify as getData private method - needed for testing/spying.
  57. getData._stringify = stringify;
  58. /**
  59. * Sets the content of the {@link module:engine/model/document~Document document} provided as an HTML-like string.
  60. *
  61. * **Note:** Remember to register elements in the {@link module:engine/model/model~Model#schema model's schema} before inserting them.
  62. *
  63. * **Note:** To create a {@link module:engine/model/text~Text text} node that contains attributes use:
  64. *
  65. * <$text attribute="value">Text data</$text>
  66. *
  67. * @param {module:engine/model/model~Model} model
  68. * @param {String} data HTML-like string to write into the document.
  69. * @param {Object} options
  70. * @param {String} [options.rootName='main'] Root name where parsed data will be stored. If not provided, the default `main`
  71. * name will be used.
  72. * @param {Array<Object>} [options.selectionAttributes] A list of attributes which will be passed to the selection.
  73. * @param {Boolean} [options.lastRangeBackward=false] If set to `true`, the last range will be added as backward.
  74. * @param {String} [options.batchType='transparent'] Batch type used for inserting elements.
  75. * See {@link module:engine/model/batch~Batch#type}.
  76. */
  77. export function setData( model, data, options = {} ) {
  78. if ( !( model instanceof Model ) ) {
  79. throw new TypeError( 'Model needs to be an instance of module:engine/model/model~Model.' );
  80. }
  81. let modelDocumentFragment, selection;
  82. const modelRoot = model.document.getRoot( options.rootName || 'main' );
  83. // Parse data string to model.
  84. const parsedResult = setData._parse( data, model.schema, {
  85. lastRangeBackward: options.lastRangeBackward,
  86. selectionAttributes: options.selectionAttributes,
  87. context: [ modelRoot.name ]
  88. } );
  89. // Retrieve DocumentFragment and Selection from parsed model.
  90. if ( parsedResult.model ) {
  91. modelDocumentFragment = parsedResult.model;
  92. selection = parsedResult.selection;
  93. } else {
  94. modelDocumentFragment = parsedResult;
  95. }
  96. model.change( writer => {
  97. // Replace existing model in document by new one.
  98. writer.remove( ModelRange.createIn( modelRoot ) );
  99. writer.insert( modelDocumentFragment, modelRoot );
  100. // Clean up previous document selection.
  101. writer.setSelection( null );
  102. writer.removeSelectionAttribute( model.document.selection.getAttributeKeys() );
  103. // Update document selection if specified.
  104. if ( selection ) {
  105. const ranges = [];
  106. for ( const range of selection.getRanges() ) {
  107. const start = new ModelPosition( modelRoot, range.start.path );
  108. const end = new ModelPosition( modelRoot, range.end.path );
  109. ranges.push( new ModelRange( start, end ) );
  110. }
  111. writer.setSelection( ranges, { backward: selection.isBackward } );
  112. if ( options.selectionAttributes ) {
  113. writer.setSelectionAttribute( selection.getAttributes() );
  114. }
  115. }
  116. } );
  117. }
  118. // Set parse as setData private method - needed for testing/spying.
  119. setData._parse = parse;
  120. /**
  121. * Converts model nodes to HTML-like string representation.
  122. *
  123. * **Note:** A {@link module:engine/model/text~Text text} node that contains attributes will be represented as:
  124. *
  125. * <$text attribute="value">Text data</$text>
  126. *
  127. * @param {module:engine/model/rootelement~RootElement|module:engine/model/element~Element|module:engine/model/text~Text|
  128. * module:engine/model/documentfragment~DocumentFragment} node A node to stringify.
  129. * @param {module:engine/model/selection~Selection|module:engine/model/position~Position|
  130. * module:engine/model/range~Range} [selectionOrPositionOrRange=null]
  131. * A selection instance whose ranges will be included in the returned string data. If a range instance is provided, it will be
  132. * converted to a selection containing this range. If a position instance is provided, it will be converted to a selection
  133. * containing one range collapsed at this position.
  134. * @returns {String} An HTML-like string representing the model.
  135. */
  136. export function stringify( node, selectionOrPositionOrRange = null ) {
  137. const model = new Model();
  138. const mapper = new Mapper();
  139. let selection, range;
  140. // Create a range witch wraps passed node.
  141. if ( node instanceof RootElement || node instanceof ModelDocumentFragment ) {
  142. range = ModelRange.createIn( node );
  143. } else {
  144. // Node is detached - create new document fragment.
  145. if ( !node.parent ) {
  146. const fragment = new ModelDocumentFragment( node );
  147. range = ModelRange.createIn( fragment );
  148. } else {
  149. range = new ModelRange(
  150. ModelPosition.createBefore( node ),
  151. ModelPosition.createAfter( node )
  152. );
  153. }
  154. }
  155. // Get selection from passed selection or position or range if at least one is specified.
  156. if ( selectionOrPositionOrRange instanceof ModelSelection ) {
  157. selection = selectionOrPositionOrRange;
  158. } else if ( selectionOrPositionOrRange instanceof DocumentSelection ) {
  159. selection = selectionOrPositionOrRange;
  160. } else if ( selectionOrPositionOrRange instanceof ModelRange ) {
  161. selection = new ModelSelection( selectionOrPositionOrRange );
  162. } else if ( selectionOrPositionOrRange instanceof ModelPosition ) {
  163. selection = new ModelSelection( selectionOrPositionOrRange );
  164. }
  165. // Set up conversion.
  166. // Create a temporary view controller.
  167. const view = new View();
  168. const viewDocument = view.document;
  169. const viewRoot = new ViewRootEditableElement( 'div' );
  170. // Create a temporary root element in view document.
  171. viewRoot._document = view.document;
  172. viewRoot.rootName = 'main';
  173. viewDocument.roots.add( viewRoot );
  174. // Create and setup downcast dispatcher.
  175. const downcastDispatcher = new DowncastDispatcher( { mapper } );
  176. // Bind root elements.
  177. mapper.bindElements( node.root, viewRoot );
  178. downcastDispatcher.on( 'insert:$text', insertText() );
  179. downcastDispatcher.on( 'attribute', ( evt, data, conversionApi ) => {
  180. if ( data.item instanceof ModelSelection || data.item instanceof DocumentSelection || data.item.is( 'textProxy' ) ) {
  181. const converter = wrap( ( modelAttributeValue, viewWriter ) => {
  182. return viewWriter.createAttributeElement(
  183. 'model-text-with-attributes',
  184. { [ data.attributeKey ]: stringifyAttributeValue( modelAttributeValue ) }
  185. );
  186. } );
  187. converter( evt, data, conversionApi );
  188. }
  189. } );
  190. downcastDispatcher.on( 'insert', insertElement( modelItem => {
  191. // Stringify object types values for properly display as an output string.
  192. const attributes = convertAttributes( modelItem.getAttributes(), stringifyAttributeValue );
  193. return new ViewContainerElement( modelItem.name, attributes );
  194. } ) );
  195. downcastDispatcher.on( 'selection', convertRangeSelection() );
  196. downcastDispatcher.on( 'selection', convertCollapsedSelection() );
  197. // Convert model to view.
  198. const writer = view._writer;
  199. downcastDispatcher.convertInsert( range, writer );
  200. // Convert model selection to view selection.
  201. if ( selection ) {
  202. downcastDispatcher.convertSelection( selection, model.markers, writer );
  203. }
  204. // Parse view to data string.
  205. let data = viewStringify( viewRoot, viewDocument.selection, { sameSelectionCharacters: true } );
  206. // Removing unneccessary <div> and </div> added because `viewRoot` was also stringified alongside input data.
  207. data = data.substr( 5, data.length - 11 );
  208. view.destroy();
  209. // Replace valid XML `model-text-with-attributes` element name to `$text`.
  210. return data.replace( new RegExp( 'model-text-with-attributes', 'g' ), '$text' );
  211. }
  212. /**
  213. * Parses an HTML-like string and returns the model {@link module:engine/model/rootelement~RootElement rootElement}.
  214. *
  215. * **Note:** To create a {@link module:engine/model/text~Text text} node that contains attributes use:
  216. *
  217. * <$text attribute="value">Text data</$text>
  218. *
  219. * @param {String} data HTML-like string to be parsed.
  220. * @param {module:engine/model/schema~Schema} schema A schema instance used by converters for element validation.
  221. * @param {module:engine/model/batch~Batch} batch A batch used for conversion.
  222. * @param {Object} [options={}] Additional configuration.
  223. * @param {Array<Object>} [options.selectionAttributes] A list of attributes which will be passed to the selection.
  224. * @param {Boolean} [options.lastRangeBackward=false] If set to `true`, the last range will be added as backward.
  225. * @param {module:engine/model/schema~SchemaContextDefinition} [options.context='$root'] The conversion context.
  226. * If not provided, the default `'$root'` will be used.
  227. * @returns {module:engine/model/element~Element|module:engine/model/text~Text|
  228. * module:engine/model/documentfragment~DocumentFragment|Object} Returns the parsed model node or
  229. * an object with two fields: `model` and `selection`, when selection ranges were included in the data to parse.
  230. */
  231. export function parse( data, schema, options = {} ) {
  232. const mapper = new Mapper();
  233. // Replace not accepted by XML `$text` tag name by valid one `model-text-with-attributes`.
  234. data = data.replace( new RegExp( '\\$text', 'g' ), 'model-text-with-attributes' );
  235. // Parse data to view using view utils.
  236. const parsedResult = viewParse( data, {
  237. sameSelectionCharacters: true,
  238. lastRangeBackward: !!options.lastRangeBackward
  239. } );
  240. // Retrieve DocumentFragment and Selection from parsed view.
  241. let viewDocumentFragment, viewSelection, selection;
  242. if ( parsedResult.view && parsedResult.selection ) {
  243. viewDocumentFragment = parsedResult.view;
  244. viewSelection = parsedResult.selection;
  245. } else {
  246. viewDocumentFragment = parsedResult;
  247. }
  248. // Set up upcast dispatcher.
  249. const modelController = new Model();
  250. const upcastDispatcher = new UpcastDispatcher( { schema, mapper } );
  251. upcastDispatcher.on( 'documentFragment', convertToModelFragment() );
  252. upcastDispatcher.on( 'element:model-text-with-attributes', convertToModelText( true ) );
  253. upcastDispatcher.on( 'element', convertToModelElement() );
  254. upcastDispatcher.on( 'text', convertToModelText() );
  255. upcastDispatcher.isDebug = true;
  256. // Convert view to model.
  257. let model = modelController.change(
  258. writer => upcastDispatcher.convert( viewDocumentFragment.root, writer, options.context || '$root' )
  259. );
  260. mapper.bindElements( model, viewDocumentFragment.root );
  261. // If root DocumentFragment contains only one element - return that element.
  262. if ( model.childCount == 1 ) {
  263. model = model.getChild( 0 );
  264. }
  265. // Convert view selection to model selection.
  266. if ( viewSelection ) {
  267. const ranges = [];
  268. // Convert ranges.
  269. for ( const viewRange of viewSelection.getRanges() ) {
  270. ranges.push( mapper.toModelRange( viewRange ) );
  271. }
  272. // Create new selection.
  273. selection = new ModelSelection( ranges, { backward: viewSelection.isBackward } );
  274. // Set attributes to selection if specified.
  275. for ( const [ key, value ] of toMap( options.selectionAttributes || [] ) ) {
  276. selection.setAttribute( key, value );
  277. }
  278. }
  279. // Return model end selection when selection was specified.
  280. if ( selection ) {
  281. return { model, selection };
  282. }
  283. // Otherwise return model only.
  284. return model;
  285. }
  286. // -- Converters view -> model -----------------------------------------------------
  287. function convertToModelFragment() {
  288. return ( evt, data, conversionApi ) => {
  289. const childrenResult = conversionApi.convertChildren( data.viewItem, data.modelCursor );
  290. conversionApi.mapper.bindElements( data.modelCursor.parent, data.viewItem );
  291. data = Object.assign( data, childrenResult );
  292. evt.stop();
  293. };
  294. }
  295. function convertToModelElement() {
  296. return ( evt, data, conversionApi ) => {
  297. const elementName = data.viewItem.name;
  298. if ( !conversionApi.schema.checkChild( data.modelCursor, elementName ) ) {
  299. throw new Error( `Element '${ elementName }' was not allowed in given position.` );
  300. }
  301. // View attribute value is a string so we want to typecast it to the original type.
  302. // E.g. `bold="true"` - value will be parsed from string `"true"` to boolean `true`.
  303. const attributes = convertAttributes( data.viewItem.getAttributes(), parseAttributeValue );
  304. const element = conversionApi.writer.createElement( data.viewItem.name, attributes );
  305. conversionApi.writer.insert( element, data.modelCursor );
  306. conversionApi.mapper.bindElements( element, data.viewItem );
  307. conversionApi.convertChildren( data.viewItem, ModelPosition.createAt( element ) );
  308. data.modelRange = ModelRange.createOn( element );
  309. data.modelCursor = data.modelRange.end;
  310. evt.stop();
  311. };
  312. }
  313. function convertToModelText( withAttributes = false ) {
  314. return ( evt, data, conversionApi ) => {
  315. if ( !conversionApi.schema.checkChild( data.modelCursor, '$text' ) ) {
  316. throw new Error( 'Text was not allowed in given position.' );
  317. }
  318. let node;
  319. if ( withAttributes ) {
  320. // View attribute value is a string so we want to typecast it to the original type.
  321. // E.g. `bold="true"` - value will be parsed from string `"true"` to boolean `true`.
  322. const attributes = convertAttributes( data.viewItem.getAttributes(), parseAttributeValue );
  323. node = conversionApi.writer.createText( data.viewItem.getChild( 0 ).data, attributes );
  324. } else {
  325. node = conversionApi.writer.createText( data.viewItem.data );
  326. }
  327. conversionApi.writer.insert( node, data.modelCursor );
  328. data.modelRange = ModelRange.createFromPositionAndShift( data.modelCursor, node.offsetSize );
  329. data.modelCursor = data.modelRange.end;
  330. evt.stop();
  331. };
  332. }
  333. // Tries to get original type of attribute value using JSON parsing:
  334. //
  335. // `'true'` => `true`
  336. // `'1'` => `1`
  337. // `'{"x":1,"y":2}'` => `{ x: 1, y: 2 }`
  338. //
  339. // Parse error means that value should be a string:
  340. //
  341. // `'foobar'` => `'foobar'`
  342. function parseAttributeValue( attribute ) {
  343. try {
  344. return JSON.parse( attribute );
  345. } catch ( e ) {
  346. return attribute;
  347. }
  348. }
  349. // When value is an Object stringify it.
  350. function stringifyAttributeValue( data ) {
  351. if ( isPlainObject( data ) ) {
  352. return JSON.stringify( data );
  353. }
  354. return data;
  355. }
  356. // Loop trough attributes map and converts each value by passed converter.
  357. function* convertAttributes( attributes, converter ) {
  358. for ( const [ key, value ] of attributes ) {
  359. yield [ key, converter( value ) ];
  360. }
  361. }