model.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. /**
  2. * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. 'use strict';
  6. import TreeWalker from '/ckeditor5/engine/model/treewalker.js';
  7. import Range from '/ckeditor5/engine/model/range.js';
  8. import Position from '/ckeditor5/engine/model/position.js';
  9. import Text from '/ckeditor5/engine/model/text.js';
  10. import RootElement from '/ckeditor5/engine/model/rootelement.js';
  11. import Element from '/ckeditor5/engine/model/element.js';
  12. import DocumentFragment from '/ckeditor5/engine/model/documentfragment.js';
  13. import Selection from '/ckeditor5/engine/model/selection.js';
  14. import Document from '/ckeditor5/engine/model/document.js';
  15. /**
  16. * Writes the contents of the {@link engine.model.Document Document} to an HTML-like string.
  17. *
  18. * @param {engine.model.Document} document
  19. * @param {Object} [options]
  20. * @param {Boolean} [options.withoutSelection=false] Whether to write the selection. When set to `true` selection will
  21. * be not included in returned string.
  22. * @param {Boolean} [options.rootName='main'] Name of the root from which data should be stringified. If not provided
  23. * default `main` name will be used.
  24. * @returns {String} The stringified data.
  25. */
  26. export function getData( document, options = {} ) {
  27. if ( !( document instanceof Document ) ) {
  28. throw new TypeError( 'Document needs to be an instance of engine.model.Document.' );
  29. }
  30. const withoutSelection = !!options.withoutSelection;
  31. const rootName = options.rootName || 'main';
  32. const root = document.getRoot( rootName );
  33. return withoutSelection ? getData._stringify( root ) : getData._stringify( root, document.selection );
  34. }
  35. // Set stringify as getData private method - needed for testing/spying.
  36. getData._stringify = stringify;
  37. /**
  38. * Sets the contents of the {@link engine.model.Document Document} provided as HTML-like string.
  39. * It uses {@link engine.model.Document#enqueueChanges enqueueChanges} method.
  40. *
  41. * NOTE:
  42. * Remember to register elements in {@link engine.model.Document#schema document's schema} before inserting them.
  43. *
  44. * @param {engine.model.Document} document
  45. * @param {String} data HTML-like string to write into Document.
  46. * @param {Object} options
  47. * @param {String} [options.rootName='main'] Root name where parsed data will be stored. If not provided, default `main`
  48. * name will be used.
  49. * @param {String} [options.batchType='transparent'] Batch type used for inserting elements. See {@link engine.model.Batch#type}.
  50. */
  51. export function setData( document, data, options = {} ) {
  52. if ( !( document instanceof Document ) ) {
  53. throw new TypeError( 'Document needs to be an instance of engine.model.Document.' );
  54. }
  55. let model, selection;
  56. const result = setData._parse( data );
  57. if ( result.model && result.selection ) {
  58. model = result.model;
  59. selection = result.selection;
  60. } else {
  61. model = result;
  62. }
  63. // Save to model.
  64. const modelRoot = document.getRoot( options.rootName || 'main' );
  65. document.enqueueChanges( () => {
  66. document.batch( options.batchType || 'transparent' )
  67. .remove( Range.createFromElement( modelRoot ) )
  68. .insert( Position.createAt( modelRoot, 0 ), model );
  69. if ( selection ) {
  70. const ranges = [];
  71. for ( let range of selection.getRanges() ) {
  72. let start, end;
  73. // Each range returned from `parse()` method has its root placed in DocumentFragment.
  74. // Here we convert each range to have its root re-calculated properly and be placed inside
  75. // model document root.
  76. if ( range.start.parent instanceof DocumentFragment ) {
  77. start = Position.createFromParentAndOffset( modelRoot, range.start.offset );
  78. } else {
  79. start = Position.createFromParentAndOffset( range.start.parent, range.start.offset );
  80. }
  81. if ( range.end.parent instanceof DocumentFragment ) {
  82. end = Position.createFromParentAndOffset( modelRoot, range.end.offset );
  83. } else {
  84. end = Position.createFromParentAndOffset( range.end.parent, range.end.offset );
  85. }
  86. ranges.push( new Range( start, end ) );
  87. }
  88. document.selection.setRanges( ranges, selection.isBackward );
  89. }
  90. } );
  91. }
  92. // Set parse as setData private method - needed for testing/spying.
  93. setData._parse = parse;
  94. /**
  95. * Converts model nodes to HTML-like string representation.
  96. *
  97. * @param {engine.model.RootElement|engine.model.Element|engine.model.Text|
  98. * engine.model.DocumentFragment} node Node to stringify.
  99. * @param {engine.model.Selection|engine.model.Position|engine.model.Range} [selectionOrPositionOrRange = null ]
  100. * Selection instance which ranges will be included in returned string data. If Range instance is provided - it will be
  101. * converted to selection containing this range. If Position instance is provided - it will be converted to selection
  102. * containing one range collapsed at this position.
  103. * @returns {String} HTML-like string representing the model.
  104. */
  105. export function stringify( node, selectionOrPositionOrRange = null ) {
  106. let selection, range;
  107. if ( node instanceof RootElement || node instanceof DocumentFragment ) {
  108. range = Range.createFromElement( node );
  109. } else {
  110. // Node is detached - create new document fragment.
  111. if ( !node.parent ) {
  112. const fragment = new DocumentFragment( node );
  113. range = Range.createFromElement( fragment );
  114. } else {
  115. range = new Range(
  116. Position.createBefore( node ),
  117. Position.createAfter( node )
  118. );
  119. }
  120. }
  121. const walker = new TreeWalker( {
  122. boundaries: range
  123. } );
  124. if ( selectionOrPositionOrRange instanceof Selection ) {
  125. selection = selectionOrPositionOrRange;
  126. } else if ( selectionOrPositionOrRange instanceof Range ) {
  127. selection = new Selection();
  128. selection.addRange( selectionOrPositionOrRange );
  129. } else if ( selectionOrPositionOrRange instanceof Position ) {
  130. selection = new Selection();
  131. selection.addRange( new Range( selectionOrPositionOrRange, selectionOrPositionOrRange ) );
  132. }
  133. let ret = '';
  134. let lastPosition = Position.createFromPosition( range.start );
  135. const withSelection = !!selection;
  136. for ( let value of walker ) {
  137. if ( withSelection ) {
  138. ret += writeSelection( value.previousPosition, selection );
  139. }
  140. ret += writeItem( value, selection, { selection: withSelection } );
  141. lastPosition = value.nextPosition;
  142. }
  143. if ( withSelection ) {
  144. ret += writeSelection( lastPosition, selection );
  145. }
  146. return ret;
  147. }
  148. /**
  149. * Parses HTML-like string and returns model {@link engine.model.RootElement rootElement}.
  150. *
  151. * @param {String} data HTML-like string to be parsed.
  152. * @param {Object} options
  153. * @returns {engine.model.Element|engine.model.Text|engine.model.DocumentFragment|Object} Returns parsed model node or
  154. * object with two fields `model` and `selection` when selection ranges were included in data to parse.
  155. */
  156. export function parse( data ) {
  157. let root, selection;
  158. let withSelection = false;
  159. root = new DocumentFragment();
  160. selection = new Selection();
  161. const path = [];
  162. let selectionStart, selectionEnd, selectionAttributes, textAttributes;
  163. const handlers = {
  164. text( token ) {
  165. root.appendChildren( new Text( token.text, textAttributes ) );
  166. },
  167. textStart( token ) {
  168. textAttributes = token.attributes;
  169. path.push( '$text' );
  170. },
  171. textEnd() {
  172. if ( path.pop() != '$text' ) {
  173. throw new Error( 'Parse error - unexpected closing tag.' );
  174. }
  175. textAttributes = null;
  176. },
  177. openingTag( token ) {
  178. let el = new Element( token.name, token.attributes );
  179. root.appendChildren( el );
  180. root = el;
  181. path.push( token.name );
  182. },
  183. closingTag( token ) {
  184. if ( path.pop() != token.name ) {
  185. throw new Error( 'Parse error - unexpected closing tag.' );
  186. }
  187. root = root.parent;
  188. },
  189. collapsedSelection( token ) {
  190. withSelection = true;
  191. selection.collapse( root, 'END' );
  192. selection.setAttributesTo( token.attributes );
  193. },
  194. selectionStart( token ) {
  195. selectionStart = Position.createFromParentAndOffset( root, root.getChildCount() );
  196. selectionAttributes = token.attributes;
  197. },
  198. selectionEnd() {
  199. if ( !selectionStart ) {
  200. throw new Error( 'Parse error - missing selection start.' );
  201. }
  202. withSelection = true;
  203. selectionEnd = Position.createFromParentAndOffset( root, root.getChildCount() );
  204. selection.setRanges(
  205. [ new Range( selectionStart, selectionEnd ) ],
  206. selectionAttributes.backward
  207. );
  208. delete selectionAttributes.backward;
  209. selection.setAttributesTo( selectionAttributes );
  210. }
  211. };
  212. for ( let token of tokenize( data ) ) {
  213. handlers[ token.type ]( token );
  214. }
  215. if ( path.length ) {
  216. throw new Error( 'Parse error - missing closing tags: ' + path.join( ', ' ) + '.' );
  217. }
  218. if ( selectionStart && !selectionEnd ) {
  219. throw new Error( 'Parse error - missing selection end.' );
  220. }
  221. // If root DocumentFragment contains only one element - return that element.
  222. if ( root instanceof DocumentFragment && root.getChildCount() == 1 ) {
  223. root = root.getChild( 0 );
  224. }
  225. if ( withSelection ) {
  226. return {
  227. model: root,
  228. selection: selection
  229. };
  230. }
  231. return root;
  232. }
  233. // -- getData helpers ---------------------------------------------------------
  234. function writeItem( walkerValue, selection, options ) {
  235. const type = walkerValue.type;
  236. const item = walkerValue.item;
  237. if ( type == 'ELEMENT_START' ) {
  238. let attrs = writeAttributes( item.getAttributes() );
  239. if ( attrs ) {
  240. return `<${ item.name } ${ attrs }>`;
  241. }
  242. return `<${ item.name }>`;
  243. }
  244. if ( type == 'ELEMENT_END' ) {
  245. return `</${ item.name }>`;
  246. }
  247. return writeText( walkerValue, selection, options );
  248. }
  249. function writeText( walkerValue, selection, options ) {
  250. const item = walkerValue.item;
  251. const attrs = writeAttributes( item.getAttributes() );
  252. let text = Array.from( item.text );
  253. if ( options.selection ) {
  254. const startIndex = walkerValue.previousPosition.offset + 1;
  255. const endIndex = walkerValue.nextPosition.offset - 1;
  256. let index = startIndex;
  257. while ( index <= endIndex ) {
  258. // Add the selection marker without changing any indexes, so if second marker must be added
  259. // in the same loop it does not blow up.
  260. text[ index - startIndex ] +=
  261. writeSelection( Position.createFromParentAndOffset( item.commonParent, index ), selection );
  262. index++;
  263. }
  264. }
  265. text = text.join( '' );
  266. if ( attrs ) {
  267. return `<$text ${ attrs }>${ text }</$text>`;
  268. }
  269. return text;
  270. }
  271. function writeAttributes( attrs ) {
  272. attrs = Array.from( attrs );
  273. return attrs.map( attr => attr[ 0 ] + '=' + JSON.stringify( attr[ 1 ] ) ).sort().join( ' ' );
  274. }
  275. function writeSelection( currentPosition, selection ) {
  276. // TODO: This function obviously handles only the first range.
  277. const range = selection.getFirstRange();
  278. // Handle end of the selection.
  279. if ( !selection.isCollapsed && range.end.compareWith( currentPosition ) == 'SAME' ) {
  280. return '</selection>';
  281. }
  282. // Handle no match.
  283. if ( range.start.compareWith( currentPosition ) != 'SAME' ) {
  284. return '';
  285. }
  286. // Handle beginning of the selection.
  287. let ret = '<selection';
  288. const attrs = writeAttributes( selection.getAttributes() );
  289. // TODO: Once we'll support multiple ranges this will need to check which range it is.
  290. if ( selection.isBackward ) {
  291. ret += ' backward';
  292. }
  293. if ( attrs ) {
  294. ret += ' ' + attrs;
  295. }
  296. ret += ( selection.isCollapsed ? ' />' : '>' );
  297. return ret;
  298. }
  299. // -- setData helpers ---------------------------------------------------------
  300. const patterns = {
  301. selection: /^<(\/?selection)( [^>]*)?>/,
  302. tag: /^<([^>]+)>/,
  303. text: /^[^<]+/
  304. };
  305. const handlers = {
  306. selection( match ) {
  307. const tagName = match[ 1 ];
  308. const tagExtension = match[ 2 ] || '';
  309. if ( tagName[ 0 ] == '/' ) {
  310. return {
  311. type: 'selectionEnd'
  312. };
  313. }
  314. if ( tagExtension.endsWith( ' /' ) ) {
  315. return {
  316. type: 'collapsedSelection',
  317. attributes: parseAttributes( tagExtension.slice( 1, -2 ) )
  318. };
  319. }
  320. return {
  321. type: 'selectionStart',
  322. attributes: parseAttributes( tagExtension.slice( 1 ) )
  323. };
  324. },
  325. tag( match ) {
  326. const tagContents = match[ 1 ].split( /\s+/ );
  327. const tagName = tagContents.shift();
  328. const attrs = tagContents.join( ' ' );
  329. if ( tagName == '/$text' ) {
  330. return {
  331. type: 'textEnd'
  332. };
  333. }
  334. if ( tagName == '$text' ) {
  335. return {
  336. type: 'textStart',
  337. attributes: parseAttributes( attrs )
  338. };
  339. }
  340. if ( tagName[ 0 ] == '/' ) {
  341. return {
  342. type: 'closingTag',
  343. name: tagName.slice( 1 )
  344. };
  345. }
  346. return {
  347. type: 'openingTag',
  348. name: tagName,
  349. attributes: parseAttributes( attrs )
  350. };
  351. },
  352. text( match ) {
  353. return {
  354. type: 'text',
  355. text: match[ 0 ]
  356. };
  357. }
  358. };
  359. function *tokenize( data ) {
  360. while ( data ) {
  361. const consumed = consumeNextToken( data );
  362. data = consumed.data;
  363. yield consumed.token;
  364. }
  365. }
  366. function consumeNextToken( data ) {
  367. let match;
  368. for ( let patternName in patterns ) {
  369. match = data.match( patterns[ patternName ] );
  370. if ( match ) {
  371. data = data.slice( match[ 0 ].length );
  372. return {
  373. token: handlers[ patternName ]( match ),
  374. data
  375. };
  376. }
  377. }
  378. throw new Error( 'Parse error - unexpected token: ' + data + '.' );
  379. }
  380. function parseAttributes( attrsString ) {
  381. attrsString = attrsString.trim();
  382. if ( !attrsString ) {
  383. return {};
  384. }
  385. const pattern = /(?:backward|(\w+)=("[^"]+"|[^\s]+))\s*/;
  386. const attrs = {};
  387. while ( attrsString ) {
  388. let match = attrsString.match( pattern );
  389. if ( !match ) {
  390. throw new Error( 'Parse error - unexpected token: ' + attrsString + '.' );
  391. }
  392. if ( match[ 0 ].trim() == 'backward' ) {
  393. attrs.backward = true;
  394. } else {
  395. attrs[ match[ 1 ] ] = JSON.parse( match[ 2 ] );
  396. }
  397. attrsString = attrsString.slice( match[ 0 ].length );
  398. }
  399. return attrs;
  400. }